How the models compare: one dataset, many methods¶
Every model in topica makes a different bet about how text is generated. The way to see those bets clearly is to build a corpus where we know the answer, then watch which methods recover it and which do not. This page walks through exactly that: a single synthetic corpus of municipal council statements, attacked by the whole roster.
The corpus and the scoring scripts live in
benchmarks/synthetic/;
python benchmarks/synthetic/run_all.py reproduces every number below.
Why synthetic, and how it is built¶
Real corpora never come with an answer key, so on real text we can only compare models to each other, never to the truth. Synthetic data removes that ceiling: we plant the structure, so we can measure recovery directly.
The design rests on one rule: separate the answer key from the prose. A deterministic, seeded sampler assigns every document its latent structure (its topic mixture, its covariates, its author, the topic of each sentence). That assignment is the ground truth, and no language model ever touches it. Language models do only the surface work, turning each sentence's assigned topic into fluent prose. The result reads like real council debate while remaining fully labeled underneath.
The statements are short, roughly 150 words over seven sentences. A sample, with each sentence's planted topic shown in brackets:
[housing] Rent increases are pushing tenants toward eviction faster than we can process permits, and a universal, subsidized housing mandate is overdue. [environment] Every wetland we pave over adds to the emissions problem, and this council needs a collective climate resilience strategy for the watershed. [transit] Adding rail service and bike-share stations along the corridor, with subsidized fares for every rider, would finally give this city universal transit coverage.
Three signals are woven through every sentence: topic vocabulary (permits, wetland, corridor), the speaker's party framing (tenants, climate, emissions), and the speaker's market-versus-public leaning (universal, subsidized, collective). Each is planted independently, so a model that recovers one should not be picking up another by accident.
Finding the topics¶
The starting point for every method is recovering the topics themselves. We fit a model, align its topics to the planted ones, and measure the cosine between the recovered and true word distributions.
LDA recovers the six planted topics at a topic-word cosine of 0.80, and the
other admixture models (CTM, NMF, ProdLDA) land in the same range. This is
table stakes: any usable topic model clears it on clean data. The interesting
questions are everything the plain topic-word view cannot see.
Mixed membership versus single membership¶
The deepest split in the roster is whether a model treats a document as a blend
of topics or as one topic. LDA, STM, and CTM are admixture models: each
document is a distribution over topics. GSDMM is a mixture model: each document
is generated by a single topic. The distinction is invisible in topic-word
recovery and obvious in document-topic recovery.
We built a second corpus in which the number of topics per document is planted directly, from one up to five, and scored how well each model recovers the true document-topic mixture as documents blend more topics:
| topics per document | LDA | CTM | GSDMM |
|---|---|---|---|
| 1 (single-topic) | 0.985 | 0.998 | 0.833 |
| 2 | 0.988 | 0.987 | 0.627 |
| 3 | 0.992 | 0.985 | 0.611 |
| 4 | 0.991 | 0.990 | 0.535 |
| 5 | 0.986 | 0.978 | 0.539 |
The admixture models are flat and near-perfect across the whole range: more blend
to recover, and they recover it. GSDMM starts respectably on single-topic
documents and then falls away, because one label per document cannot represent a
mixture. This is not a defect. GSDMM is built for short texts where each
document really is about one thing, and on that terrain its single-topic
assumption is an advantage, not a handicap. Its recovery of single-sentence
documents in the main corpus (adjusted Rand index 0.73) is strong. The lesson
is to match the assumption to the text: reach for a mixture model when documents
are short and focused, an admixture model when they genuinely span topics.
Covariates: prevalence and content¶
Council statements carry metadata (region, party, year), and covariate models ask how that metadata relates to the topics. There are two distinct questions, and different models answer them.
Prevalence asks how often each group discusses a topic. We planted a rising
attention to housing over four years and region-specific emphases (the coast on
transit, the north on environment). STM recovers the housing trend with the
correct positive slope, and DMR maps every planted covariate to its topic with a
perfect 5 of 5 hit rate. When the question is "who talks about what, and how
has that changed," these are the tools.
Content asks how a group words a topic differently. We planted opposing
party frames inside each topic (one party frames housing as development and
supply, the other as affordability and tenants). SAGE and STM's content
covariate recover this cleanly: the party contrast in wording separates the
planted frame words with a large log-ratio (+4.4). A prevalence-only model
would miss this entirely, because both parties discuss housing at similar rates
while describing it in opposite vocabularies.
The practical point is that "effect of party" is ambiguous until you say whether you mean prevalence or content. topica lets you estimate both, and they can tell different stories about the same corpus.
Time¶
When topics themselves drift, the question is not prevalence but vocabulary. We
planted a transit topic whose words move across the four years, from bus and
parking early to rail and electric late. DTM tracks it: the probability of
rail rises across the slices while bus falls. DTM holds the topic's identity
fixed while letting its words evolve, which is the right model when a subject
persists but its language changes. It is the wrong model when the mix of topics
changes but each topic's vocabulary is stable; that is a prevalence question for
STM with a time covariate.
Position within a document¶
Most models ignore word order entirely; a document is a bag of words. NarrativeTM
does not. It asks where inside a text each topic tends to appear. We planted an
ordered arc into a set of longer documents, twenty-four sentences that move through
procedure, then housing, budget, transit, policing, and environment in sequence.
NarrativeTM recovers the order exactly: the peak position of each topic's
trajectory matches the planted sequence with a rank correlation of 1.00.
This axis carries the clearest length lesson in the suite. On the short main
corpus, where the only positional signal is procedural framing at the opening and
close, NarrativeTM recovers only a weak U-shape. A narrative-trajectory model
needs documents long enough to have a narrative. Give it transcripts, articles,
or opinions, and it reads their arc; give it a tweet, and there is no arc to read.
Author position¶
IdealPointTM and Wordfish ask a different question again: not what the topics
are, but where each author sits on a latent axis inferred from word choice. We
planted a market-versus-public leaning for each of twenty-four council members,
deliberately orthogonal to their party, expressed only through a handful of tilt
words. IdealPointTM recovers the planted positions at a correlation of 0.72,
from a signal thinner than either topic or party. These models are the tool when
the quantity of interest is a continuous trait of the speaker rather than the
content of the text.
Across languages¶
InfoCTM aligns topics across two languages using a bilingual dictionary. We
translated the corpus into Spanish and supplied a glossary linking the key terms.
InfoCTM aligns all six topics across English and Spanish: topic k in one
language denotes the same theme as topic k in the other. This is the model for a
corpus that spans languages and a question that should not, such as tracking one
policy debate across an English-language and a Spanish-language press.
Choosing the number of topics¶
Every parametric model asks for K, the number of topics, and the honest question
is what happens when K is wrong. We fit LDA across a range and measured two
things: coverage, how well the six true topics are each represented, and
redundancy, the fraction of fitted topics that are nobody's best match.
| fitted K | coverage | redundancy |
|---|---|---|
| 2 | 0.63 | 0.00 |
| 4 | 0.82 | 0.00 |
| 6 (true) | 0.99 | 0.00 |
| 8 | 0.98 | 0.25 |
| 12 | 0.97 | 0.50 |
| 16 | 0.97 | 0.62 |
The asymmetry is the whole story. Setting K too low is destructive: true topics
get merged and coverage collapses. Setting K too high is survivable: every true
topic is still found, and the cost is redundant topics that can be read and
pruned. When unsure, err high. topica's search_k
automates the scan.
The nonparametric alternative is to let the model discover K. On this corpus
that alternative disappoints. HDP, which is supposed to infer the topic count,
collapses to a single topic under its default concentration and, as that
concentration is raised, jumps from three topics straight to nine, skipping six
entirely. Its answer is governed by its prior, not the data. The reliable path to
choosing K here is the parametric scan with coverage and redundancy in view, not
a nonparametric count taken on faith.
Hierarchy¶
HLDA learns a tree of topics rather than a flat set. We built a corpus with a
planted two-level structure, three "urban" topics and two "civic-order" topics,
each domain sharing vocabulary that should surface at an internal node. HLDA
does place each domain's shared vocabulary at an internal node, which is the core
hierarchical claim. But its nonparametric tree over-splits into roughly thirty
nodes rather than the planted handful, so it does not partition documents cleanly
by domain. As with HDP, the nonparametric structure search is sensitive to its
concentration parameters and does not settle on the planted shape without tuning.
Hierarchy recovery is the one axis in the suite that only partially succeeds, and
that honest edge is worth more than a benchmark on which everything passes.
The scorecard¶
Reading down the roster, the pattern is that parametric models matched to a clear question recover their structure cleanly and degrade gracefully, while the nonparametric structure-discoverers are fragile on this data.
| Capability | Best-suited models | Recovery |
|---|---|---|
| Find topics | LDA, CTM, NMF, ProdLDA | 0.80 cosine |
| Document-topic mixture | LDA, CTM, STM | flat ~0.99 across mixedness |
| Single-topic short text | GSDMM | 0.73 ARI; degrades on blends |
| Prevalence covariates | STM, DMR | 5/5 covariate hits |
| Content covariates | SAGE, STM content | +4.4 frame separation |
| Vocabulary drift over time | DTM | rail up, bus down |
| Narrative position | NarrativeTM | 1.00 on long docs |
| Author ideal point | IdealPointTM, Wordfish | 0.72 correlation |
| Cross-lingual alignment | InfoCTM | 6/6 topics aligned |
| Choosing K | search_k (not HDP) | graceful; HDP unreliable |
| Hierarchy | HLDA | partial (over-splits) |
A caveat on synthetic data¶
Synthetic data flatters models whose assumptions match the generator. Our
documents are cleaner than any real corpus: topics are well separated, covariate
effects are large, and noise is mild. A model that fails here, on data built to
be recoverable, is in real trouble, which is what makes the HDP and HLDA
results informative. But passing here is necessary, not sufficient. Real text is
messier, and the honest use of this benchmark is as a floor: a method that cannot
recover planted structure under ideal conditions will not find it in the wild.
Reading agreement on real data: the self-agreement ceiling¶
On real text there is no answer key, so the only check available is agreement:
how closely two fits line up, scored here by aligned topic-word cosine (each topic
Hungarian-matched to its nearest counterpart, then the mean cosine). We use it two
ways — topica against a reference implementation (benchmarks/full_model_run.py
scores every model this way), and one model against another. Both are bounded by
the same thing, and it is easy to misread a number without it.
The bound is that a stochastic model does not even reproduce itself. Refit the same model on the same corpus with a different random seed and the two runs settle into different-but-comparable topics; their aligned cosine is below 1.0. That "self-agreement" is the ceiling: no cross-implementation comparison can beat how well a method agrees with itself, and how high the ceiling sits depends almost entirely on the inference family. Refitting each model twice (seeds 1 and 2) on the 5,000-document political-blog corpus at K=10:
| Inference family | Models (self-agreement) | Ceiling |
|---|---|---|
| Matrix factorization | NMF 1.00, LSA 0.99 | ~1.00 |
| Batch variational (EM / VAE) | CTM 1.00, ProdLDA 0.99 | ~1.00 |
| Collapsed Gibbs (MCMC) | DMR 0.83, LDA 0.79 | ~0.80 |
| Online / minibatch VB | gensim LdaModel 0.77, OnlineLDA 0.77 |
~0.77 |
The spread is large and it is not "variational versus sampled." Matrix methods and
batch variational fits (CTM, ProdLDA) converge to essentially one optimum, so
they reproduce themselves almost exactly. Collapsed-Gibbs samplers wander between
comparable modes, landing around 0.80. Online (minibatch-stochastic) VB is the
most seed-sensitive of all — its noisy per-minibatch gradient explores different
local optima, so even gensim's reference LdaModel only agrees with itself
around 0.77.
The consequence for reading the benchmark: score a model against its family's
ceiling, not against 1.0. The same cosine means different things across
families. Take OnlineLDA, which recovers the reference topics at ~0.67 against
gensim — a number that looks alarming until you place it:
- online-VB self-agreement is only ~0.77 (gensim does not beat that against itself), so ~0.67 is roughly 90% of the achievable agreement, not 67% of a reachable 1.0;
OnlineLDAagrees with topica's own collapsed-GibbsLDA(~0.72) better than gensim does (~0.67);- its topic coherence matches Gibbs
LDA, and its top words are clean.
So ~0.67 is a healthy online-VB result. The same 0.67 from NMF, whose ceiling is
1.00, would instead be a red flag. This is why the harness frames accuracy against
a per-model reference ceiling rather than a flat threshold.
These numbers are illustrative, not constants: the ceiling also moves with the corpus, with K, and with how far each fit is run toward convergence (a lightly-iterated Gibbs run self-agrees less than a long one). The ordering across families, though — deterministic and batch-variational at the top, online VB at the bottom — is the stable, transferable lesson.
Comparing two fits statistically: topica.compare¶
The ceiling above is exactly what makes a raw distance between two topics
uninterpretable on its own: is a drop from cosine 1.00 to 0.80 real movement, or
just the Monte-Carlo wander any refit shows? topica.compare(fit_a, fit_b) answers
that question as a first-class operation rather than a manual diff.
cmp = topica.compare(fit_a, fit_b)
cmp.aligned # Hungarian 1-to-1 matched topic pairs above `threshold`
cmp.unmatched_a # topics that vanished (only in A) — never force-paired
cmp.unmatched_b # topics that appeared (only in B)
cmp.splits, cmp.merges # one-to-many outcomes (overlay), named honestly (esp. across different K)
cmp.drift # per-pair distance + whether it exceeds the reseed range
cmp.prevalence_shift # change in topic prevalence, with a posterior-spread uncertainty
print(cmp.render()) # HTML card (same house as the manifest render()); to_markdown() too
Alignment reuses align_topics, which pairs two topics by the Hungarian 1-to-1
assignment and keeps a pair when its similarity clears threshold, so a topic with no
honest counterpart is reported as appeared / vanished, not paired to its least-bad
neighbor. Splits and merges (one topic in A → two in B, or vice versa) are an overlay,
flagged only when an extra partner is close to a topic's own best match relative to the
fit's cross-topic similarity floor — so a correlated-topic fit (STM/CTM) compared with
itself reports full stability instead of near-total splits/merges, and genuine
one-to-many outcomes across different K are still named (issue #642). (The reseed null
below reads the Hungarian self-assignment align_topics also exposes, so every A-topic
has a self-match to measure wander against.)
Drift needs a null. Give compare a reseed baseline and each matched pair is
flagged when it moves beyond the range of self-agreement A shows across the
reseeds — a heuristic band, not a calibrated significance test (the floor is the
worst self-match over n_reseed refits, so a truly-null pair trips it at a rough
~1/(n_reseed+1) rate, and only A is reseeded):
# refit A on the same corpus under fresh seeds; compare builds the per-topic floor
cmp = topica.compare(fit_a, fit_b, refit=lambda s: topica.LDA(num_topics=20, seed=s).fit(corpus))
# or pass refits you already have, or a flat similarity floor (a family ceiling)
cmp = topica.compare(fit_a, fit_b, reseed_fits=[a2, a3, a4])
cmp = topica.compare(fit_a, fit_b, baseline=0.70) # e.g. an online-VB ceiling
The floor for each A-topic is the worst self-agreement it keeps across the
reseeds; an A↔B match below that floor moved more than reseeding A alone did, so its
pair reads drifted=True. Without a null, distances are still reported but
drifted is None (honestly "unknown") — never a false verdict.
Three uses, one tool: track which topics are stable / drift / are new across two
corpora or time slices; measure how much apparent change is just seed wander;
and use it as a library regression test — refit a canonical corpus across topica
versions and flag when a "harmless" refactor silently moves the topics. It describes
and tests difference; it does not build a consensus model (that is
ensemble).
Comparing two manifests, without the models¶
When the models themselves are gone but their analysis manifests
remain, compare can still align them — as long as each fit was recorded with its
top words retained:
ra = topica.record_fit(fit_a, corpus_a, topic_words_n=25) # opt-in: top words are content
rb = topica.record_fit(fit_b, corpus_b, topic_words_n=25)
cmp = topica.compare(ra, rb) # same CompareResult, no refit
Manifests store only each topic's top-N words and mean prevalence, so the manifest
path aligns by Jaccard overlap of the top-word sets (metric="jaccard", with a
default threshold matched to that scale — lower than cosine's) and yields the same
matched / vanished / appeared / split / merge structure. With enough words retained,
this recovers the matching the live cosine path finds — but top-word set overlap is
coarser than the full-distribution cosine: seed-varying fits (e.g. Gibbs LDA)
churn their top-word lists between runs, so a genuinely-stable topic can share too few
of its top-10 words to clear the threshold and be mislabelled vanished + appeared.
Retain generously (topic_words_n≈25, not 10) so real matches sit well above the
noise; compare also flags a "near-miss" on any vanished/appeared topic whose
best cross-similarity sits just under the threshold, so a false disappearance is
surfaced rather than silent. Two further caveats follow from having only top words:
there is no prevalence-shift uncertainty (no posterior is stored), and a manifest
cannot be refit — so drift is judged only against a baseline= floor, not a reseed
null. Mixing a live model with a manifest is refused. Retaining top words is off by
default (topic_words_n=0) because they are corpus-derived content; a manifest
recorded without them raises a clear error rather than comparing on nothing.
Record both manifests with the same topic_words_n. Jaccard over sets of
different sizes is systematically depressed (a 10-word set nested in a 25-word one
scores at most 10/25 = 0.4), so if the two windows differ compare warns and
falls back to the common top-N rather than reporting spurious drift.