Spaces:
Sleeping
Sleeping
Download analysis/theme_terms.py from Viney/readthrough: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/Viney/readthrough/resolve/main/analysis/theme_terms.py
- Command line
-
hf download hf://spaces/Viney/readthrough/analysis/theme_terms.py
-
curl -L -o theme_terms.py https://huggingface.co/spaces/Viney/readthrough/resolve/main/analysis/theme_terms.py
14 kB
| """What management actually named, inside each theme it was talking about. | |
| The heatmap next door scores five themes on lexical tone. On its own that | |
| score is unfalsifiable: a reader shown "Products +23" has no way to learn what | |
| management was calling a product. Worse, the theme patterns are sector-neutral | |
| by design -- they match the word "products", never the iPhone -- so the cell can | |
| be built entirely on generic vocabulary. | |
| This module counts the named things inside the sentences each theme already | |
| matched: iPhone, Apple Intelligence, Blackwell, Greater China, H20. Two sources | |
| feed it, and they are kept apart because their standing differs: | |
| * **filed** terms appear in the company's own segment disclosure, which carries | |
| a SEC accession behind it; | |
| * **discovered** terms are proper nouns the call itself used, found with no | |
| per-ticker curation at all. | |
| Only counts leave this module. A frequency is an aggregate statistic, not a | |
| quotation, so the section keeps its promise to render no transcript sentence. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections import Counter | |
| from dataclasses import dataclass, field | |
| # Words, keeping the internal punctuation that carries meaning in a name: | |
| # "U.S", "R&D", "China-based", "TSMC/Foxconn". Apostrophes are deliberately | |
| # left out, which is what stops "I'll" and "we're" from reading as names. | |
| _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:[.&/-][A-Za-z0-9]+)*") | |
| # Capitalised in every transcript, informative in none: call furniture, the | |
| # safe-harbour paragraph, and the titles of the people speaking. | |
| _BOILERPLATE = frozenset(""" | |
| form forms sec gaap non-gaap ir investor investors relations safe harbor act | |
| securities exchange commission litigation reform private research report | |
| operator thank thanks welcome hello everyone please question questions answer | |
| good morning afternoon evening today tomorrow yesterday | |
| january february march april may june july august september october november | |
| december monday tuesday wednesday thursday friday saturday sunday | |
| president chief executive officer financial chairman chairwoman director | |
| company companies corporation corporations incorporated | |
| section sections item note notes page slide slides appendix | |
| q1 q2 q3 q4 h1 h2 fy fy24 fy25 fy26 fy27 cy | |
| """.split()) | |
| # Words a filed label carries for structure rather than for meaning. Kept out | |
| # of the vocabulary because "Rest" of Asia Pacific and "Home" in Wearables, | |
| # Home & Accessories would otherwise be scored as things the company sells. | |
| _LABEL_FILLER = frozenset( | |
| "rest other others all and the total member members segment segments " | |
| "reportable corporate unallocated home sales revenue revenues".split() | |
| ) | |
| # A corporate suffix says nothing about the issuer; the rest of the registered | |
| # name does, and is the one term guaranteed to top every theme if kept. | |
| _CORPORATE_SUFFIXES = frozenset( | |
| "inc inc. corp corp. corporation co co. company ltd ltd. llc plc " | |
| "holdings holding group the and".split() | |
| ) | |
| # Spellings of the same subject that would otherwise occupy two rows of the | |
| # same table. Kept deliberately short: this is punctuation, not a synonym list. | |
| _ALIASES = {"us": "U.S.", "u.s": "U.S.", "usa": "U.S.", "usg": "U.S. government"} | |
| # Below this a term is an accident of one sentence rather than a subject. | |
| MIN_TOTAL_MENTIONS = 2 | |
| # How many terms a single theme shows. Enough to be a breakdown, few enough to | |
| # stay readable under a heatmap row. | |
| TERMS_PER_THEME = 8 | |
| # A tone reading on one sentence is noise; the parent module draws the same line. | |
| MIN_SENTENCES_FOR_TONE = 2 | |
| class Call: | |
| """One earnings call, already split into who chose to say what. | |
| The split matters more than it looks. Prepared remarks are what management | |
| decided to put in front of investors; answers are what analysts made it | |
| address. A subject that lives only in the answer column is a subject the | |
| company is not volunteering. | |
| """ | |
| period: str | |
| prepared_text: str = "" | |
| answers_text: str = "" | |
| def management_text(self) -> str: | |
| return f"{self.prepared_text}\n{self.answers_text}".strip() | |
| # ββ Extraction βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _core(token: str) -> str: | |
| return token.strip(".-&/") | |
| def _singular(word: str) -> str: | |
| """Fold the plural of an acronym into the acronym. | |
| "GPUs" and "GPU" are one subject and must not occupy two rows. The test is | |
| deliberately narrow -- the stem has to be entirely upper case -- so that | |
| AirPods stays AirPods rather than becoming an AirPod. | |
| """ | |
| stem = word[:-1] | |
| return stem if word.endswith("s") and len(stem) >= 2 and stem.isupper() else word | |
| def _flush(found: Counter, run: list, exclude: frozenset[str]) -> None: | |
| """Record a finished run of capitalised words as one name.""" | |
| if not run: | |
| return | |
| term = " ".join(_ALIASES.get(word.lower(), _singular(word)) for word in run) | |
| if _keep(term, exclude): | |
| found[term] += 1 | |
| def _is_named(token: str, first_in_sentence: bool) -> bool: | |
| """True when a token reads as the name of a thing rather than as prose.""" | |
| core = _core(token) | |
| if len(core) < 2 or core.lower() in _BOILERPLATE: | |
| return False | |
| # Internal capitals settle it on their own: iPhone, NVLink, GPUs, CUDA. | |
| if any(character.isupper() for character in core[1:]): | |
| return True | |
| if not core[0].isupper(): | |
| return False | |
| # A capital with a digit is a part number, not a sentence opening: H20, A18. | |
| if any(character.isdigit() for character in core): | |
| return True | |
| # An ordinary capitalised word is only a name away from the first slot, | |
| # where every sentence capitalises whatever happens to start it. | |
| return not first_in_sentence and len(core) >= 3 | |
| def _keep(term: str, exclude: frozenset[str]) -> bool: | |
| """Drop a finished name that is only the issuer's or a speaker's. | |
| The test runs on the whole name rather than on each word, which is the | |
| difference between "Apple Intelligence" surviving intact and being cut down | |
| to a phantom "Intelligence" because the issuer's own name opens it. | |
| """ | |
| words = term.lower().split() | |
| return bool(words) and not all(word in exclude for word in words) | |
| def named_terms(text: str, exclude: frozenset[str] = frozenset()) -> Counter: | |
| """Count the named terms in *text*, merging adjacent words into one name. | |
| Runs are joined so that "Apple Watch" and "Greater China" survive as | |
| themselves, and broken on anything that is not whitespace so that the list | |
| "iPhone, Mac, iPad and Apple Watch" yields four names rather than one. | |
| """ | |
| found: Counter = Counter() | |
| for sentence in re.split(r"(?<=[.!?])\s+|\n+", str(text or "")): | |
| run: list[str] = [] | |
| previous_end = 0 | |
| for index, match in enumerate(_TOKEN_RE.finditer(sentence)): | |
| separated = sentence[previous_end:match.start()].strip() != "" | |
| previous_end = match.end() | |
| if separated and run: | |
| _flush(found, run, exclude) | |
| run = [] | |
| if _is_named(match.group(0), index == 0): | |
| run.append(_core(match.group(0))) | |
| elif run: | |
| _flush(found, run, exclude) | |
| run = [] | |
| _flush(found, run, exclude) | |
| return found | |
| def build_exclusions( | |
| company_name: str = "", | |
| ticker: str = "", | |
| speakers: object = (), | |
| ) -> frozenset[str]: | |
| """Names that are always present and never informative. | |
| The issuer says its own name in every second sentence, and the people on the | |
| call are named at every hand-off. Neither is a subject management chose. | |
| """ | |
| excluded: set[str] = set() | |
| for word in re.findall(r"[A-Za-z][A-Za-z0-9.&-]*", str(company_name or "")): | |
| cleaned = _core(word).lower() | |
| if cleaned and cleaned not in _CORPORATE_SUFFIXES: | |
| excluded.add(cleaned) | |
| if ticker: | |
| excluded.add(str(ticker).lower()) | |
| for speaker in speakers or (): | |
| for part in re.findall(r"[A-Za-z][A-Za-z0-9.&-]*", str(speaker)): | |
| cleaned = _core(part).lower() | |
| if len(cleaned) >= 2: | |
| excluded.add(cleaned) | |
| return frozenset(excluded) | |
| def label_terms(label: str) -> tuple[str, ...]: | |
| """The searchable names inside one filed segment label. | |
| XBRL labels arrive machine-shaped -- "Wearables, Home & Accessories", | |
| "Microsoft Three Six Five Commercial Products And Cloud Services" -- so the | |
| whole phrase is rarely spoken. Both the phrase and its distinctive words | |
| are returned; the caller keeps only those the calls actually used, which is | |
| what stops "Three" and "Commercial" from becoming product names. | |
| """ | |
| cleaned = " ".join(str(label or "").split()) | |
| if not cleaned: | |
| return () | |
| terms = {cleaned} | |
| words = [word for word in re.split(r"[^A-Za-z0-9&.-]+", cleaned) if word] | |
| for word in words: | |
| if len(word) >= 3 and word.lower() not in _CORPORATE_SUFFIXES and word.lower() not in _LABEL_FILLER: | |
| terms.add(word) | |
| # Two adjacent words catch the names that only exist as a pair. | |
| for first, second in zip(words, words[1:]): | |
| if first.lower() not in _LABEL_FILLER and second.lower() not in _LABEL_FILLER: | |
| terms.add(f"{first} {second}") | |
| return tuple(sorted(terms)) | |
| # ββ Aggregation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _sentences(text: str) -> list[str]: | |
| return [ | |
| part.strip() | |
| for part in re.split(r"(?<=[.!?])\s+|\n+", str(text or "")) | |
| if len(part.split()) >= 4 | |
| ] | |
| def _matches_any(text: str, patterns) -> bool: | |
| return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) | |
| class _Period: | |
| """Working state for one theme in one call.""" | |
| counts: Counter = field(default_factory=Counter) | |
| prepared: Counter = field(default_factory=Counter) | |
| answers: Counter = field(default_factory=Counter) | |
| carriers: dict = field(default_factory=dict) | |
| words: int = 1 | |
| def _collect(text: str, patterns, exclude: frozenset[str]) -> tuple[Counter, dict]: | |
| """Term counts and the sentences carrying them, within one theme.""" | |
| counts: Counter = Counter() | |
| carriers: dict[str, list[str]] = {} | |
| for sentence in _sentences(text): | |
| if not _matches_any(sentence, patterns): | |
| continue | |
| for term, hits in named_terms(sentence, exclude).items(): | |
| counts[term] += hits | |
| carriers.setdefault(term, []).append(sentence) | |
| return counts, carriers | |
| def _tone(sentences: list[str], score_sentence) -> int | None: | |
| if len(sentences) < MIN_SENTENCES_FOR_TONE: | |
| return None | |
| return round(100 * sum(score_sentence(item)[0] for item in sentences) / len(sentences)) | |
| def analyse( | |
| calls: list[Call], | |
| themes, | |
| score_sentence, | |
| exclude: frozenset[str] = frozenset(), | |
| filed: frozenset[str] = frozenset(), | |
| ) -> dict[str, list[dict]]: | |
| """Per theme, the named terms behind it, newest period last. | |
| *score_sentence* is injected rather than imported so that the tone of a term | |
| is measured by exactly the lexicon that produced the heatmap cell above it β | |
| one scorer, not two that can drift apart. | |
| """ | |
| if not calls: | |
| return {} | |
| breakdown: dict[str, list[dict]] = {} | |
| for theme in themes: | |
| patterns = theme["patterns"] | |
| periods: list[_Period] = [] | |
| for call in calls: | |
| state = _Period(words=max(1, len(call.management_text.split()))) | |
| state.counts, state.carriers = _collect(call.management_text, patterns, exclude) | |
| state.prepared, _ = _collect(call.prepared_text, patterns, exclude) | |
| state.answers, _ = _collect(call.answers_text, patterns, exclude) | |
| periods.append(state) | |
| totals: Counter = Counter() | |
| for state in periods: | |
| totals.update(state.counts) | |
| rows: list[dict] = [] | |
| for term, total in totals.items(): | |
| if total < MIN_TOTAL_MENTIONS: | |
| continue | |
| counts = [state.counts.get(term, 0) for state in periods] | |
| rates = [ | |
| round(state.counts.get(term, 0) / state.words * 10_000, 1) | |
| for state in periods | |
| ] | |
| latest, prior = periods[-1], periods[-2] if len(periods) >= 2 else None | |
| rows.append({ | |
| "term": term, | |
| "filed": term.lower() in filed, | |
| "total_mentions": total, | |
| "counts": counts, | |
| "rates": rates, | |
| "mentions": counts[-1], | |
| "rate": rates[-1], | |
| "prior_rate": rates[-2] if prior is not None else None, | |
| "change": round(rates[-1] - rates[-2], 1) if prior is not None else None, | |
| "prepared": latest.prepared.get(term, 0), | |
| "answers": latest.answers.get(term, 0), | |
| "sentences": len(latest.carriers.get(term, [])), | |
| "score": _tone(latest.carriers.get(term, []), score_sentence), | |
| }) | |
| # The latest call leads, because that is the quarter being read. Weight | |
| # across every call breaks the tie, which keeps a name management has | |
| # stopped saying visible in the tail rather than deleting the signal. | |
| # Filed names come first at equal weight: one carries an accession, the | |
| # other carries only the call. | |
| rows.sort(key=lambda row: ( | |
| -row["mentions"], -row["total_mentions"], not row["filed"], row["term"], | |
| )) | |
| breakdown[theme["key"]] = rows[:TERMS_PER_THEME] | |
| return breakdown | |