Exploring the Australian Radio Talkback (ART) RO-Crate
This notebook uses crategraph to explore an RO-Crate of Australian Radio Talkback transcripts.
The focus is on identifying the people and places in the collection, what they actually said, and
what NLP crategraph makes possible itself, as well as what it can hand off to other tools.
Draft case study
This case study is an early draft. The analysis is sound and the outputs are real, but upcoming refinements will further improve the code and language clarity.
What you'll learn
- How to load a collection and get a feel for who and what is in it.
- How to filter and profile the people and recordings it contains.
- How to build and visualise a network of who spoke where.
- How to search the transcripts in plain language using crategraph's built-in semantic search.
- How to hand the text off to NLP tools (sentiment, named entities, topics) for deeper analysis.
Running this tutorial
This notebook hands off to third-party NLP tools in Sections 8 to 10, so install the semantic search extra and the additional packages before launching Jupyter:
python -m pip install "crategraph[index]" jupyter pandas plotly transformers torch spacy
jupyter notebook
Then download the small spaCy English model once, in an environment that includes spaCy:
1. Load the crate
from crategraph import Crate
import pandas as pd
import plotly.express as px
import re
from pathlib import Path
from collections import Counter, defaultdict
crate = Crate("data/ldaca/talkback")
crate
=== Graph Summary ===
Source: data/ldaca/talkback
Entities: 452 | Relationships: 918
Entity types:
Person 323 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
File 89 ▒▒▒▒
RepositoryObject 29 ▒
DefinedTerm 3
CreativeWork 1
DataReuseLicense 1
Organization 1
Language 1
Geometry 1
PropertyValue 1
SoftwareSourceCode 1
CreateAction 1
Relationship types:
ldac:speaker 450 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
inLanguage 116 ▒▒▒▒
ldac:communicationMode 116 ▒▒▒▒
ldac:annotationType 87 ▒▒▒
hasPart 87 ▒▒▒
ldac:linguisticGenre 29 ▒
ldac:mainText 29 ▒
license 1
geo 1
instrument 1
result 1
Most connected: English (117), SpokenLanguage (116), Transcription (87), Nat4 (55), COMe5 (32)
452 entities and 918 relationships. The crate is dominated by Person (323) and File (89) entities, with 29 RepositoryObject entities representing the actual radio recordings. The most common relationship is ldac:speaker (450 links), which connects people to the recordings they
spoke in.
2. Collection-level metadata
Check the non-Person/File/RepositoryObject entities to understand what
this collection actually is, who published it, and under what licence.
metadata = crate.exclude(entity_types=["Person", "File", "RepositoryObject"])
for e in metadata.entities:
print(e.id, e.types)
for k, v in e.properties.items():
print(" ", k, ":", str(v)[:200])
print()
https://ror.org/01sf06y89 ('Organization',)
name : Macquarie University
ro-crate-metadata.json ('CreativeWork',)
identifier : ro-crate-metadata.json
about : arcp://name,doi10.25949%2F24769434.v1
conformsTo : https://w3id.org/ldac/profile#Collection
license : https://creativecommons.org/licenses/by/4.0/
#provenance ('CreateAction',)
name : Create RO-Crate using corpus-tools-australian-radio-talkback
instrument : git+https://github.com/Language-Research-Technology/corpus-tools-australian-radio-talkback.git
result : ro-crate-metadata.json
object : ART-corpus-catalogue.xls
_:local-id:LDaCA:arcp://name,doi10.25949%2F24769434.v1 ('PropertyValue',)
value : arcp://name,doi10.25949%2F24769434.v1
name : LDaCA
_geo-glottolog-stan1293 ('Geometry',)
name : Geographical coverage for English
asWKT : POINT(-1.0 53.0)
https://glottolog.org/resource/languoid/id/stan1293 ('Language',)
languageCode : stan1293
name : English
geo : _geo-glottolog-stan1293
source : Glottolog
sameAs : ['https://www.ethnologue.com/language/eng']
alternateName : ['English (Standard Southern British)', 'Englisch', 'Anglais moderne [fr]', 'English [en]', 'Inglese moderno [it]', 'Inglês moderno [pt]', 'Modern English [en]', 'Moderna angla lingvo [eo]', 'Moderne
iso639-3 : eng
git+https://github.com/Language-Research-Technology/corpus-tools-australian-radio-talkback.git ('SoftwareSourceCode',)
name : corpus-tools-australian-radio-talkback
description : Corpus Tools ART, corpus-tools-australian-radio-talkback
codeRepository : git+https://github.com/Language-Research-Technology/corpus-tools-australian-radio-talkback.git
programmingLanguage : ECMAScript
runtimePlarform : Node.js
https://creativecommons.org/licenses/by/4.0/ ('DataReuseLicense',)
metadataIsPublic : True
allowTextIndex : True
name : Attribution 4.0 International (CC BY 4.0)
description : You are free to:
Share — copy and redistribute the material in any medium or format
Adapt — remix, transform, and build upon the material
for any purpose, even commercially.
This license is acceptable
This is the "Australian Radio Talkback" (ART) corpus, a linguistics RO-Crate (LDaC Collection profile) archived by Macquarie University, with a DOI-based collection identifier (doi:10.25949/24769434.v1). It was generated from ART-corpus-catalogue.xls using the corpus-tools-australian-radio-talkback tool. The licence entity is visible in the metadata above; the tutorials index centralises the dataset licence list.
3. The recordings (RepositoryObject)
Each RepositoryObject is a single radio talkback segment, with its station,
program, recording/transcription dates, subject, language, and links to its
transcript files and speakers.
recordings_graph = crate.select(entity_types=["RepositoryObject"])
recordings = recordings_graph.entities
# count each recording's speakers, then pull the columns we want as rows
with_speaker_counts = recordings_graph.annotate_entities(
speakers=lambda e: len(e.get("ldac:speaker") or [])
)
rows = with_speaker_counts.entity_records(
columns=["name", "station", "program", "subject", "speakers"]
)
recordings_df = pd.DataFrame(rows).rename(columns={"name": "id"})
print(len(recordings_df), "recordings")
recordings_df
| id | station | program | subject | speakers | |
|---|---|---|---|---|---|
| 0 | Nat5 | ABC Local | Sunday Night | What would Jesus vote? | 10 |
| 1 | ABCe4 | ABC Local | Wildlife Talkback | NaN | 9 |
| 2 | COMne4 | 6PR | The Weekend Garden Party | NaN | 21 |
| 3 | COMe6 | Nova 96.9 | Paul Murray Show | "installing stuff" "love song dedications" fil... | 16 |
| 4 | COMe7 | 3AW | Neil Mitchell | NaN | 6 |
| 5 | Nat7 | ABC TripleJ | Super Request | various | 15 |
| 6 | ABCe1 | ABC Local | Gardening Talkback | gardening | 15 |
| 7 | ABCe3 | ABC Local | The Melbourne Book Club | NaN | 14 |
| 8 | COMe2 | 2GB | Real Estate | property prices | 11 |
| 9 | ABCne1 | ABC Local | Language Talkback | NaN | 11 |
| 10 | COMne1 | 6PR | Sports Today | NaN | 14 |
| 11 | COMne6 | FiveAA | Graham Goodings | NaN | 5 |
| 12 | COMne5 | FiveAA | Leon Byner | NaN | 7 |
| 13 | COMe4 | 2UE | John Laws Morning Show | NaN | 21 |
| 14 | COMne3 | 6PR | Talking Real Estate | NaN | 16 |
| 15 | Nat4 | ABC Local | Nightlife | Hormone replacement therapy/quiz/terrorism | 49 |
| 16 | COMe5 | 2UE | Sharina's Psychic Encounters | NaN | 26 |
| 17 | Nat8 | ABC TripleJ | The Chat Room | refugees | 18 |
| 18 | COMe8 | Mix 106.5 | Love Song Dedications | NaN | 16 |
| 19 | Nat3 | ABC National | Life Matters | children's books | 15 |
| 20 | Nat6 | ABC TripleJ | Morning Show | water | 9 |
| 21 | Nat2 | ABC National | Australia Talks Books | Cloudstreet: Tim Winton | 15 |
| 22 | COMe3 | 2GB | Your Health | health queries | 21 |
| 23 | ABCne2 | ABC Local | Gardening Talkback | NaN | 10 |
| 24 | Nat1 | ABC National | Australia Talks Back | week review | 22 |
| 25 | COMe1 | 2GB | The Garden Clinic | Gardening | 25 |
| 26 | COMne2 | 6PR | Drive | NaN | 13 |
| 27 | COMne7 | FiveAA | Nicole Haack | NaN | 8 |
| 28 | ABCe2 | ABC Local | The Woodies | DIY | 12 |
29 recordings spanning 10 stations, from national programs like Australia Talks Back (ABC National) down to local commercial shows like Gardening
Talkback. Each links out to a -raw.txt, -plain.txt, and .csv transcript file via hasPart, and to its speakers via ldac:speaker. Speaker counts range from a handful up to several dozen callers per episode. Notice that the subject column is a bit hit and miss.
4. Identifying the people
Select all Person entities and split them into the role-tagged speakers
(id pattern .../person/<Role>#<Name>) versus any other person entities
(e.g. catalogue contributors, who aren't linked via ldac:speaker).
people = crate.select(entity_types=["Person"]).annotate_entities(
role_tagged=lambda e: "/person/" in e.id
)
speakers = people.where(role_tagged=True)
print("Total people:", len(people))
print("Role-tagged speakers:", len(speakers))
# the rest are catalogue contributors, not linked via ldac:speaker
people.where(role_tagged=False).entity_records(columns=["name"])
| name |
|---|
| Pam Peters |
Speaker demographics
Each role-tagged speaker carries role, sex, ldac:age, and (sometimes)
location properties. Tally them to profile the cast of speakers.
def clean_sex(value):
value = (value or "").strip().upper()
return value if value in ("F", "M") else "?"
def clean_age_band(value):
value = (value or "").strip().rstrip("?")
if value in ("<45",):
return "<45"
if value in (">45", ">65", ">70"):
return ">45"
try:
return "<45" if float(value) < 45 else ">45"
except ValueError:
return "?"
# annotate the cleaned demographic fields, then let entity_counts tally each
profiled = speakers.annotate_entities(
sex_clean=lambda e: clean_sex(e.get("sex")),
age_band=lambda e: clean_age_band(e.get("ldac:age")),
location_clean=lambda e: e.get("location") or "?",
)
print("Role:", profiled.entity_counts("role"))
print("Sex:", profiled.entity_counts("sex_clean"))
print("Age band:", profiled.entity_counts("age_band"))
print("Top locations:", profiled.entity_counts("location_clean")[:10])
Role: [{'role': 'Caller', 'count': 257}, {'role': 'Expert', 'count': 38}, {'role': 'Presenter', 'count': 27}]
Sex: [{'sex_clean': 'F', 'count': 162}, {'sex_clean': 'M', 'count': 155}, {'sex_clean': '?', 'count': 5}]
Age band: [{'age_band': '>45', 'count': 162}, {'age_band': '<45', 'count': 123}, {'age_band': '?', 'count': 37}]
Top locations: [{'location_clean': '?', 'count': 149}, {'location_clean': 'Sydney', 'count': 13}, {'location_clean': 'Melbourne', 'count': 8}, {'location_clean': 'Ballarat', 'count': 3}, {'location_clean': 'Brisbane', 'count': 3}, {'location_clean': 'Adelaide', 'count': 2}, {'location_clean': 'Ferndale', 'count': 2}, {'location_clean': 'Gosnells', 'count': 2}, {'location_clean': 'Leeming', 'count': 2}, {'location_clean': 'Maylands', 'count': 2}]
The aggregate sex split (above) looks roughly even. But that number averages over three very different roles (Caller, Expert and Presenter).
speakers_df = pd.DataFrame(
profiled.entity_records(columns=["role", "sex_clean", "age_band"])
).rename(columns={"sex_clean": "sex"})
role_sex = pd.crosstab(speakers_df["role"], speakers_df["sex"])
role_sex["% male"] = (
100 * role_sex.get("M", 0) / (role_sex.get("M", 0) + role_sex.get("F", 0))
).round(1)
role_sex
| sex | ? | F | M | % male |
|---|---|---|---|---|
| role | ||||
| Caller | 2 | 142 | 113 | 44.3 |
| Expert | 2 | 10 | 26 | 72.2 |
| Presenter | 1 | 10 | 16 | 61.5 |
| age_band | <45 | >45 | ? |
|---|---|---|---|
| role | |||
| Caller | 113 | 139 | 5 |
| Expert | 8 | 17 | 13 |
| Presenter | 2 | 6 | 19 |
The aggregate sex split (162F/155M) hides a real skew once you split by role:
- Experts are 72% male (26M/10F) and Presenters are 62% male (16M/10F)
- Callers are slightly majority-female (142F/113M, 44% male)
Anyone can call in, but the "authority" roles (the people running the show and the people brought on as experts) skew male. The aggregate number, by averaging over all three roles, hides this.
Age bands are coarse (<45/>45, mostly inferred rather than exact) and locations are mostly unspecified (149 of 322 unknown); where known, Sydney (13) and Melbourne (8) dominate.
5. The people to recordings network and what it reveals about the IDs
Select the Person entities, then expand() one hop outward along the ldac:speaker relationship to pull in the recordings each person spoke in. This produces a bipartite-ish network of people and recordings and the network's structure turns out to expose a data-quality problem in how Caller identities were minted.
people_network = crate.select(entity_types=["Person"]).expand(
depth=1, via="ldac:speaker"
)
people_network
Creates a 352-entity, 450-relationship network saved to talkback-people.html (open with
!open "talkback-people.html"). Colouring by community (Louvain) groups each recording with the
people who spoke in it, and size_by="connections" makes the busiest nodes pop.
The largest nodes overall are recordings, because some episodes have many speakers. Among the
person nodes, the busiest Caller names are probably not single frequent callers: Caller IDs here
are first-name-only, so a node like Caller#Paul can merge several real people who share a first
name.
6. Native semantic search over the transcripts
Everything so far has only looked at structured metadata: names, roles, counts. crategraph can
also build an embedding-backed search index directly over the transcript content and let you query
it in plain language. Restrict to the clean -plain.txt transcripts first (same
annotate_entities() + where() idiom as filtering by any other derived property), then build the
index.
text_files = crate.select(entity_types=["File"])
plain_text = text_files.annotate_entities(
is_plain=lambda e: e.id.endswith("-plain.txt")
).where(is_plain=True)
index_path = "talkback-index.db"
stats = plain_text.build_semantic_index(index_path, progress=False)
stats
IndexerStats(sources_indexed=[], sources_skipped=['talkback'], sources_removed=[], total_chunks=1341, total_entities=29)
Query in plain language, grounded in real recorded subjects so the results are checkable against Section 3's table. A hit on "Cloudstreet Tim Winton" should surface Nat2, whose subject is literally "Cloudstreet: Tim Winton". Each result is a File entity; expand backward via hasPart to recover the owning recording's station/program/subject.
queries = [
"Tim Winton",
"therapy",
"asylum seekers and refugees",
"religion and politics",
]
for query in queries:
hits = crate.search(query, mode="semantic", store_path=index_path, k=2)
owners = hits.expand(depth=1, via="hasPart").select(entity_types=["RepositoryObject"])
print(f"## {query!r}")
for owner in owners.entities:
print(" ", owner.properties.get("name"), "-", owner.properties.get("station"), "|", owner.properties.get("subject"))
print()
## 'Tim Winton'
Nat2 - ABC National | Cloudstreet: Tim Winton
Nat1 - ABC National | week review
## 'therapy'
COMe3 - 2GB | health queries
Nat4 - ABC Local | Hormone replacement therapy/quiz/terrorism
## 'asylum seekers and refugees'
Nat8 - ABC TripleJ | refugees
## 'religion and politics'
Nat5 - ABC Local | What would Jesus vote?
Nat4 - ABC Local | Hormone replacement therapy/quiz/terrorism
For RAG-style use, chunk_records() gives ranked, scored passages instead of
whole-file hits.
for record in crate.chunk_records("asylum seekers and refugees", k=3, store_path=index_path):
print(f"score={record['score']:.3f} {record['entity_id']}")
print(" ", record["text"][:200].replace("\n", " "), "...")
print()
score=0.556 NAT8-plain.txt
-eight-hundred-oh-triple-five-three-six. Let's talk to Tony in Sydney hi Tony. Hi how are you. I'm well thank you. Now you're in two minds about this issue. Yeah I am of two minds um. I suppose the ...
score=0.556 NAT8-plain.txt
conundrum. Um it it needs to be this way it probably doesn't need to be that that that uh immigrants are jailed. I do disagree with the conditions. I think the farm idea would be much better. But per ...
score=0.553 NAT8-plain.txt
space. There's good reasons to be in Australia. But they exist because they're policed. And that's underpins it all. If if Australia wasn't a well-managed well-policed place it wouldn't be desirable ...
Semantic search finds topically relevant recordings without any keyword match. The embeddings capture meaning, not just shared vocabulary. This is the piece that's native to crategraph: build once, then query the corpus in plain language.
7. Joining per-utterance speech to speaker demographics
Section 4 profiled speakers from their RO-Crate properties alone. To analyse what they actually said, join their per-utterance speech (from each recording's .csv, columns Person,Speech,events) to those same role/sex properties. The Person codes in each CSV (P1, C1, E1...) are locally scoped to that recording. C1 means a different real person in Nat1 than in Nat4. So the join must be done per-recording, using that recording's own ldac:speaker list and each speaker's identifier property to build the local code to Person map, rather than a single global lookup table.
Since recordings_graph is a select subgraph of only RepositoryObjects (defined at the start of section 5), it contains no relationships to speaker entities, so we look up each recording on the full crate instead, whose views carry the complete relationship set.
base_dir = Path(crate.source)
leaked_speaker_tag = re.compile(r"^\[[^\]]+\]\s*")
example_recording = recordings[0]
example_csv = next(
(part for part in example_recording.properties.get("hasPart") or [] if part.endswith(".csv")),
None,
)
print(example_recording.properties.get("name"), example_csv)
example_speakers = crate.get(example_recording.id).related("ldac:speaker")
list(example_speakers)[:3]
For each recording, the transcript CSV uses local speaker codes such as P1, C1, and E1. Build that mapping for one recording first, so the larger join below is easier to read.
example_local_speakers = {
speaker.get("identifier"): speaker
for speaker in example_speakers
}
{code: speaker.label for code, speaker in list(example_local_speakers.items())[:8]}
Now apply the same per-recording mapping across the whole crate. The loop keeps one row per spoken utterance and attaches the speaker's role and cleaned sex value from the RO-Crate metadata.
utterance_rows = []
for recording in recordings:
csv_name = next(
(part for part in recording.properties.get("hasPart") or [] if part.endswith(".csv")),
None,
)
if csv_name is None:
continue
speakers_for_recording = crate.get(recording.id).related("ldac:speaker")
local_speakers = {
speaker.get("identifier"): speaker
for speaker in speakers_for_recording
}
transcript_rows = pd.read_csv(base_dir / csv_name)
for _, transcript_row in transcript_rows.iterrows():
speaker = local_speakers.get(transcript_row["Person"])
speech = transcript_row["Speech"]
if speaker is None or pd.isna(speech):
continue
utterance_rows.append(
{
"recording": recording.properties.get("name"),
"speaker_id": speaker.id,
"role": speaker.get("role"),
"sex": clean_sex(speaker.get("sex")),
"text": leaked_speaker_tag.sub("", str(speech)),
}
)
utterances_df = pd.DataFrame(utterance_rows)
print(
len(utterances_df),
"utterances joined across",
utterances_df["recording"].nunique(),
"recordings",
)
utterances_df.head()
8. Transformer sentiment by role and sex
Section 4 found a structural skew: experts and presenters are disproportionately male. Does that
skew show up in how people talk? This section runs a Hugging Face transformer sentiment model over
every utterance, then aggregates by role and sex.
from transformers import pipeline
sentiment_pipeline = pipeline(
"sentiment-analysis", model="distilbert-base-uncased-finetuned-sst-2-english"
)
utterance_texts = utterances_df["text"].tolist()
sentiment_results = sentiment_pipeline(utterance_texts, truncation=True, batch_size=32)
utterances_df["sentiment"] = [
(1 if result["label"] == "POSITIVE" else -1) * result["score"]
for result in sentiment_results
]
sentiment_by_role_sex = utterances_df.groupby(["role", "sex"])["sentiment"].agg(
["mean", "count"]
)
sentiment_by_role_sex.round(3)
Within every role, women read more positively than men in this model's scores.
Male Experts are the only subgroup with a net-negative average sentiment i.e. the group that's also 72% male reads the most negative on average. This is model-derived sentiment over short utterances, not ground truth about the speakers. It is best read as a prompt for closer qualitative checking, and it is consistent with male experts being brought on more often for clinical or contentious subjects such as health, terrorism, and policy.
Sentiment also varies by program, not just role/sex. There each utterance's recording is mapped to its program (from the recordings list in Section 3) and the average is charted.
program_by_recording = {r.properties.get("name"): r.properties.get("program") for r in recordings}
utterances_df["program"] = utterances_df["recording"].map(program_by_recording)
program_sentiment = (
utterances_df.groupby("program")["sentiment"].mean().sort_values().reset_index()
)
fig = px.bar(
program_sentiment,
x="sentiment",
y="program",
orientation="h",
title="Average sentiment by program",
color="sentiment",
color_continuous_scale="RdYlGn",
color_continuous_midpoint=0,
)
fig.update_layout(height=800)
fig.show()
The most negative programs are personality-driven current-affairs shows, e.g. Neil Mitchell (-0.257), while the most positive are lighter entertainment formats e.g. Sharina's Psychic Encounters (0.462) and Love Song Dedications (0.461). Format seems to predict tone.
9. spaCy NER on what's actually discussed
Everyone profiled so far is speaking in the corpus: Presenters, Callers, Experts. Below,
named-entity recognition is run over the -plain.txt transcripts to surface who and what is talked
about (politicians, places, organisations), which the graph's structured metadata doesn't fully
capture.
import spacy
nlp = spacy.load("en_core_web_sm", disable=["parser", "tagger", "lemmatizer", "attribute_ruler"])
disfluencies = {
"y'know", "mm", "um", "uh", "yeah", "okay", "lotta", "gonna", "gotta", "wanna", "kinda",
}
mentions = Counter()
locations_by_recording = defaultdict(Counter)
for record in plain_text.text_records():
doc = nlp(record["text"])
for ent in doc.ents:
if ent.label_ not in ("PERSON", "GPE", "ORG") or ent.text.lower() in disfluencies:
continue
mentions[(ent.label_, ent.text)] += 1
if ent.label_ == "GPE":
locations_by_recording[record["entity_id"]][ent.text] += 1
places = [(text, n) for (label, text), n in mentions.most_common() if label == "GPE"]
people = [(text, n) for (label, text), n in mentions.most_common() if label == "PERSON"]
print("Top places discussed:", places[:10])
print()
print("Top names mentioned:", people[:10])
Top places discussed: [('Australia', 145), ('Sydney', 79), ('Melbourne', 33), ('Queensland', 29), ('Iraq', 28), ('England', 22), ('Brisbane', 20), ('Chechnya', 20), ('Victoria', 18), ('Sharina', 17)]
Top names mentioned: [('John', 82), ('Tony', 59), ('Jesus', 59), ('Paul', 50), ('Tim', 42), ('Mark', 37), ('Peter', 32), ('Michael', 32), ('Linda', 31), ('Luke', 31)]
GPE (place) mentions are the cleanest signal. Australia, Sydney, Melbourne, and Queensland dominate, with Iraq, England, and Chechnya also
surfacing from the international/security topics covered in 2004. PERSON mentions are noisier. NER can't tell "Presenter greeting a Caller by first name" from "a public figure being discussed" (same first-name ambiguity as Section 5). A small disfluency stoplist (y'know, mm, um...) was also needed, since the out-of-the-box model otherwise mistags them as entities.
10. Topic/location network
Assign each recording a topic via zero-shot classification, then connect
that topic to every location mentioned in the same recording (from Section 9's
locations_by_recording), weighted by mention count.
topic_classifier = pipeline(
"zero-shot-classification",
model="MoritzLaurer/deberta-v3-base-zeroshot-v1",
)
candidate_topics = [
"gardening",
"real estate and property",
"health and medicine",
"religion",
"politics",
"books and literature",
"psychic and astrology",
"sport",
"immigration and refugees",
"relationships and family",
]
def classify_chunked(text, chunk_words=400, max_chunks=5):
words = text.split()
transcript_chunks = [
" ".join(words[start : start + chunk_words])
for start in range(0, len(words), chunk_words)
][:max_chunks]
topic_scores = {topic: 0.0 for topic in candidate_topics}
for chunk in transcript_chunks:
result = topic_classifier(chunk, candidate_topics, truncation=True)
for label, score in zip(result["labels"], result["scores"]):
topic_scores[label] += score
return max(topic_scores, key=topic_scores.get)
docs = {record["entity_id"]: record["text"] for record in plain_text.text_records()}
classify_chunked(docs["NAT5-plain.txt"])
The single-recording check above should label NAT5, whose subject is What would Jesus vote?, as a religion or politics topic. Once that looks sensible, run the same helper over every plain transcript.
recording_topic = {
entity_id: classify_chunked(text)
for entity_id, text in docs.items()
}
recording_topic
The labels line up well with known programs and subjects. NAT5 (What would Jesus vote?) is classified as a religion/politics topic, while COMNE3 (Talking Real Estate) lands on real estate and property. Generic shows with no clear subject are the ones to inspect most carefully.
import networkx as nx
min_mentions = 4
total_mentions = Counter()
for counts in locations_by_recording.values():
total_mentions.update(counts)
frequent_locations = {loc for loc, n in total_mentions.items() if n >= min_mentions}
topic_location = nx.Graph()
for eid, topic in recording_topic.items():
topic_location.add_node(topic, kind="topic", label=topic)
for loc, count in locations_by_recording.get(eid, {}).items():
if loc not in frequent_locations:
continue
topic_location.add_node(loc, kind="location", label=loc)
if topic_location.has_edge(topic, loc):
topic_location[topic][loc]["weight"] += count
else:
topic_location.add_edge(topic, loc, weight=count)
nx.write_graphml(topic_location, "talkback-topics-locations.graphml")
print(topic_location.number_of_nodes(), "nodes,", topic_location.number_of_edges(), "edges")
This creates a 55-node, 132-edge bipartite graph saved to talkback-topics-locations.graphml. This allows you to customise the graph without code and in a bespoke manner. 8 topic hubs connecting to 47 frequently-mentioned locations, with a few locations (e.g. Australia, Sydney)
bridging multiple topics. Open it in Gephi or Gephi Lite: colour by the kind node attribute (topic vs. location) and size/filter edges by weight. You can probably guess the age of the dataset from this graph...

11. Public vs Commercial Radio
Derive two new dimensions for each recording:
- station_type:
public(ABC: NAT, ABCe, ABCne) orcommercial(COMe, COMne) - region:
national,eastern, orsouth-west
Then map the recording_topic assignments from Section 10 (keyed by file IDs like 'NAT6-plain.txt') back to the same recording-ID scheme used throughout.
The station_type and region values are not stored as explicit fields in the RO-Crate. They are derived from the recording ID prefixes (Nat, ABCe, ABCne, COMe, COMne), which encode the collection codes defined in the corpus description on the LDaCA listing: "14 transcribed recordings of talkback from ABC National Radio (NAT), ABC Radio broadcasts to eastern Australia (ABCE), ABC Radio broadcasts to southern and western Australia (ABCNE); as well as 15 transcribed recordings of talkback radio from commercial stations broadcasting to eastern Australia (COME) and southern and western Australia (COMNE)."
def classify_station(name):
normalised_name = name.upper()
if normalised_name.startswith("NAT"):
return "public", "national"
if normalised_name.startswith("ABCNE"):
return "public", "south-west" # before ABCE
if normalised_name.startswith("ABCE"):
return "public", "eastern"
if normalised_name.startswith("COMNE"):
return "commercial", "south-west" # before COME
if normalised_name.startswith("COME"):
return "commercial", "eastern"
return "unknown", "unknown"
station_rows = []
for _, recording_row in recordings_df.iterrows():
station_type, region = classify_station(recording_row["id"])
station_rows.append(
{
"recording_id": recording_row["id"],
"station_type": station_type,
"region": region,
}
)
station_meta = pd.DataFrame(station_rows)
print("Recordings by station type:")
print(station_meta["station_type"].value_counts().to_string())
station_meta.head()
The topic labels are keyed by transcript file IDs, while station_meta is keyed by recording IDs. Check one conversion before mapping all topics back onto the recording table.
recording_id_lookup = {
recording_id.upper(): recording_id
for recording_id in recordings_df["id"]
}
example_topic_file = next(iter(recording_topic))
example_recording_key = example_topic_file.replace("-plain.txt", "").upper()
example_topic_file, example_recording_key, recording_id_lookup.get(example_recording_key)
topic_by_recording = {}
for file_id, topic in recording_topic.items():
recording_key = file_id.replace("-plain.txt", "").upper()
recording_id = recording_id_lookup.get(recording_key)
if recording_id:
topic_by_recording[recording_id] = topic
station_meta["topic"] = station_meta["recording_id"].map(topic_by_recording)
station_meta
Join utterances_df (8,950 rows with role/sex/sentiment, from Sections 7–8) with station_meta on recording ID so every utterance inherits its recording's topic, station type, and region.
master_df = utterances_df.merge(
station_meta, left_on="recording", right_on="recording_id"
)
print(f"{len(master_df):,} utterances across {master_df['recording'].nunique()} recordings")
print()
print(master_df.groupby("station_type")[["recording"]].nunique().rename(columns={"recording": "recordings"}))
master_df.head()
| recording | speaker_id | role | sex | text | sentiment | program | recording_id | station_type | region | topic | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Nat1 | arcp://name,doi10.25949%2F24769434.v1/person/P... | Presenter | M | One-eight-hundred-eight-oh-two-three-four-one ... | 0.999520 | Australia Talks Back | Nat1 | public | national | politics |
| 1 | Nat1 | arcp://name,doi10.25949%2F24769434.v1/person/C... | Caller | M | Oh hi how are you. | 0.999355 | Australia Talks Back | Nat1 | public | national | politics |
| 2 | Nat1 | arcp://name,doi10.25949%2F24769434.v1/person/P... | Presenter | M | Yeah good thank you. | 0.999832 | Australia Talks Back | Nat1 | public | national | politics |
| 3 | Nat1 | arcp://name,doi10.25949%2F24769434.v1/person/C... | Caller | M | Um I just wanted to put a thought forward abou... | -0.981924 | Australia Talks Back | Nat1 | public | national | politics |
| 4 | Nat1 | arcp://name,doi10.25949%2F24769434.v1/person/P... | Presenter | M | Oh the HECS funding yep yep. | -0.780669 | Australia Talks Back | Nat1 | public | national | politics |
Which topics come up more in ABC vs commercial talkback? Each recording is counted once under its assigned topic.
topic_counts = (
station_meta.groupby(["topic", "station_type"])
.size()
.reset_index(name="count")
)
fig = px.bar(
topic_counts,
x="topic",
y="count",
color="station_type",
barmode="group",
title="Topic distribution: public vs commercial (recordings)",
color_discrete_map={"public": "#1f77b4", "commercial": "#ff7f0e"},
labels={
"topic": "Topic",
"count": "Number of recordings",
"station_type": "Broadcaster type",
},
)
fig.update_xaxes(tickangle=30)
fig.update_layout(legend_title_text="Broadcaster type")
fig.show()
Does the same topic attract more negative or positive speech on ABC vs commercial radio? Aggregate at the utterance level. Then plot mean sentiment with standard error bars and a heatmap summary.
topic_sent = (
master_df.groupby(["topic", "station_type"])["sentiment"]
.agg(mean="mean", sem=lambda x: x.sem())
.reset_index()
)
# Heatmap
heatmap_data = topic_sent.pivot(index="topic", columns="station_type", values="mean").round(3)
fig_heat = px.imshow(
heatmap_data,
color_continuous_scale="RdYlGn",
color_continuous_midpoint=0,
title="Mean utterance sentiment by topic and broadcaster type",
labels={"color": "Mean sentiment"},
text_auto=True,
)
fig_heat.update_layout(height=500)
fig_heat.show()
# Bar chart with error bars
fig_bar = px.bar(
topic_sent,
x="topic",
y="mean",
color="station_type",
error_y="sem",
barmode="group",
title="Mean utterance sentiment by topic and broadcaster type (±1 SE)",
color_discrete_map={"public": "#1f77b4", "commercial": "#ff7f0e"},
labels={"mean": "Mean sentiment", "topic": "Topic", "station_type": "Broadcaster type"},
)
fig_bar.update_xaxes(tickangle=30)
fig_bar.update_layout(legend_title_text="Broadcaster type")
fig_bar.show()
Next steps
- The people-and-recordings network used
expand()andvisualise(); see Visualising a collection to colour by other properties or pull out ego-networks. - Every table here came from
entity_records(); the From Graph to DataFrame tutorial covers exporting these to CSV and joining them onto other analysis. - The semantic index built in Section 6 also powers
chunk_records()for RAG-style retrieval; swap in your own queries to explore the corpus.