Reporting uncertainty¶
Topic estimates are estimates. The proportions θ, the top words, the covariate
effects you put in a table all carry sampling error, and a credible result
reports it. topica.standard_errors is one entry point for the quantities people
publish, and it propagates the topic-estimation uncertainty rather than treating
the fit as if it were observed exactly.
There are two distinct uncertainties, and they call for two methods.
Method of composition (the default)¶
Given a fixed fit, θ is still uncertain: each document's topic proportions are
estimated from its tokens. The method of composition (Treier & Jackman 2008,
the procedure R stm uses) draws θ from the model's own posterior, computes the
quantity on each draw, and pools by Rubin's rules, so the standard errors inflate
over a naive regression on point θ. It is cheap (no refit) and sound for
covariate effects and group prevalence.
standard_errors detects the model family and picks the right sampler for you.
STM and CTM draw from their logistic-normal variational posterior. The Gibbs
models (LDA, keyATM, SeededLDA) retain thinned MCMC θ draws during the fit by
default (model.theta_draws, shape (num_draws, num_docs, num_topics)), so the
standard errors propagate real topic-estimation uncertainty: how much each
document's mixture moves across sweeps, which grows when topics overlap and
shrinks when the model is confident. No Corpus is needed in this case.
If you fit with keep_theta_draws=False (to save the
num_draws x num_docs x num_topics of f32 storage), the Gibbs path falls back to
a per-document Dirichlet conditional built from the token counts. A fitted model
retains its own per-document lengths (model.doc_lengths), so this still needs no
Corpus; pass one only to draw against a different corpus on purpose. That
approximation captures within-document sampling noise (it scales with 1 / N_d)
but is blind to whether the topics are actually identified, so its intervals can
be wider than the real posterior for short documents and narrower for long ones.
# Covariate effects, uncertainty propagated (one regression per topic):
eff = topica.standard_errors(model, corpus, of="effect",
formula="~ party", data=meta, nsims=50)
for e in eff:
print(e.topic, e.as_dict())
# Each topic's mean prevalence, with an interval:
prev = topica.standard_errors(model, corpus, of="prevalence", nsims=50)
The effect result is the same TopicEffect list as estimate_effect (so
cluster=, link=, spline, and interaction all carry over); prevalence
returns a TopicPrevalence per topic. estimate_effect itself now also accepts
the model directly: estimate_effect(model, X, corpus=corpus, nsims=50) draws the
right posterior internally.
Composition covers of="effect" and of="prevalence" for LDA, keyATM, STM, and
CTM. It cannot give top-word uncertainty (it only varies θ), and it does not
apply to the embedding models (they have no posterior over θ). For those, use
the bootstrap.
Bootstrap (top words, embedding models)¶
Refitting on resampled documents captures the full model uncertainty, including which topics emerge. It is the only route to standard errors for the embedding models and the only way to get top-word or topic-quality intervals.
# How stable is each topic's top-word list?
tw = topica.standard_errors(model, corpus, of="top_words", method="bootstrap",
n_boot=200, topn=10)
for t in tw:
for word, prob, lo, hi in t.words:
print(t.topic, word, f"kept in {prob:.0%} of resamples")
of="prevalence" and of="effect" also work with method="bootstrap" and report
the across-refit spread.
The alignment catch¶
Each refit permutes and drifts the topics, so a quantity must be matched back to
the reference fit before pooling. topica matches by top-word overlap (Hungarian),
but a forced one-to-one match silently corrupts the standard error when a topic
splits or merges across resamples, or when the reference topics are not
distinct. This is exactly why R stm prefers the method of composition.
So the bootstrap reports two alignment diagnostics per topic and suppresses the
standard error (sets it to NaN, reliable=False) when matching is unstable:
alignment_quality— mean top-word Jaccard with the matched topic (is the match close?).alignment_margin— how much better the match is than the next-best topic (is the match unambiguous?). A high quality with a low margin means the topics are interchangeable and the match is arbitrary, which the Jaccard alone would miss.
for t in topica.standard_errors(model, corpus, of="prevalence", method="bootstrap"):
if not t.reliable:
print(f"topic {t.topic}: unstable alignment "
f"(quality={t.alignment_quality:.2f}, margin={t.alignment_margin:.2f}) "
"— SE suppressed")
Embedding models¶
The default bootstrap refits a count model on resampled token lists. An embedding
model's fit also needs the document embeddings, so pass a refit hook that
resamples them alongside the documents and returns a fitted model:
def refit(doc_indices):
m = topica.BERTopic(min_cluster_size=15, seed=1)
m.fit([docs[i] for i in doc_indices], doc_emb[doc_indices])
return m
tw = topica.standard_errors(bertopic, corpus, of="top_words",
method="bootstrap", refit=refit, n_boot=200)
Which to use¶
| Quantity | Models | Method |
|---|---|---|
| Covariate effects | LDA, keyATM, STM, CTM | composition (default) |
| Group / overall prevalence | LDA, keyATM, STM, CTM | composition |
| Top words, topic quality | any | bootstrap |
| Anything, embedding models | BERTopic, Top2Vec, ETM, FASTopic | bootstrap (with refit=) |
Composition is cheaper and avoids the alignment problem, so prefer it where it
applies. Reach for the bootstrap when you need top-word intervals or you are on a
model with no posterior, and read the reliable flag before quoting a number.