Guided topics (seed words)¶
Plain LDA is unsupervised: you label the topics after the fact and have no control over whether the themes you care about appear. Guided models let you inject prior knowledge as a few seed words per topic, so a topic forms around words you already believe belong together. You seed topics with keywords, not documents with labels, so there is no hand-coding.
This is squarely a social-science tool: it improves measurement validity and reproducibility, the things reviewers push on. topica has two, matching the two standard R packages.
SeededLDA¶
Seed-word priors steer some topics; residual unseeded topics are learned
freely. Faithful to the seededlda package (Watanabe): by default each seed
word's prior pseudocount scales with its corpus frequency — count × weight × 100,
the package's tfm construction — and tokens are initialized at random
(seed_prior="frequency"; alpha and beta default to the package's 0.5 and
0.1). topica's original scheme is available as seed_prior="uniform": a flat
weight × 100 per seed word with seed-word tokens anchored to their topic at
initialization. You can read the exact per-topic, per-word pseudocounts a fit
used from model.seed_prior_matrix.
Seed patterns are matched to the vocabulary by seed_match, mirroring quanteda's
dictionary valuetype (the matcher the seededlda package uses): "fixed"
(default) is exact literal equality; "glob" reads */? wildcards anchored to
the whole token, so "tax*" seeds tax, taxes, and taxation at once; and
"regex" matches a regular expression anywhere in the token. case_insensitive
(default False) folds case — set it True with seed_match="glob" to reproduce
quanteda's dictionary defaults. An expanding pattern seeds every matched word
(each once); seed_prior_matrix reflects exactly what was applied.
import topica
model = topica.SeededLDA(
{"economy": ["job*", "wage*", "tax*"],
"immigration": ["border", "visa*", "deport*"]},
residual=3, # 3 extra unseeded topics
seed_match="glob", # "job*" seeds jobs, "tax*" seeds tax/taxes/taxation
seed=1,
)
model.fit(docs, iters=2000)
model.topic_names # ['economy', 'immigration', 'residual_1', ...]
for t in range(model.num_topics):
print(model.topic_names[t], [w for w, _ in model.top_words(8, topic=t)])
KeyATM¶
The Keyword-Assisted Topic Model (Eshima, Imai & Sasaki 2024) is the modern, well-validated version. A token in a keyword topic comes either from a distribution over only that topic's keywords or from the topic's full distribution; the learned mix is the keyword rate.
model = topica.KeyATM(
{"economy": ["jobs", "wages", "tax"],
"immigration": ["border", "visa", "deport"]},
num_topics=10, # 2 keyword topics + 8 regular topics
seed=1,
)
model.fit(docs, iters=1500)
model.keyword_rate # per-topic share drawn from the keyword distribution
By default keyATM applies information-theory token weighting (each token counts
by its word's surprisal in bits), which downweights frequent words and sharpens
topics. Set weights="inv-freq" or weights="none" to change it. On large
corpora, pass num_threads=N to sample document partitions in parallel
(approximate distributed Gibbs); both options apply to every variant below.
Covariate keyATM¶
Pass covariates to let document metadata shape topic prevalence, the keyATM
covariate model. The document-topic prior becomes a Dirichlet-multinomial
regression, α_{d,k} = exp(x_d · λ_k) (Mimno & McCallum 2008, the same engine as
DMR), so you can ask whether a covariate moves a named topic.
An intercept is prepended; the learned coefficients are in feature_effects.
import numpy as np
is_dem = np.array([...]).reshape(-1, 1) # one row per document
model = topica.KeyATM(seeds, num_topics=2, seed=1)
# λ is optimized only on the sweeps after burn_in, so give the fit enough
# iterations (>= burn_in + optimize_interval, 250 at the defaults) or
# feature_effects come back all-zero — the model warns when they would.
model.fit(docs, covariates=is_dem, feature_names=["is_dem"], iters=1000)
# Report predicted topic proportions at each covariate value, with CIs — the
# interpretable, on-the-proportion-scale answer (R keyATM's predicted props).
pp = topica.predicted_prevalence(
model, X=is_dem, feature_names=["is_dem"], at={"is_dem": [0, 1]}
)
print(pp) # per topic (with topic_name): predicted share at is_dem=0 vs 1, 95% CI
Report predicted_prevalence, not the raw coefficient. feature_effects[k, j]
is the underlying log-α regression coefficient λ, not a difference in topic
proportions: it lives on the exp(x·λ) prior scale, so its sign gives the
direction but its magnitude and z-score can disagree with the actual change in
prevalence (a λ that looks "not notable" can still move the predicted proportion
significantly). predicted_prevalence pushes the effect through to the topic-share
scale with simulation CIs, which is what keyATM users report (plot_predicted_prop).
model.feature_names # ['intercept', 'is_dem']
model.feature_effects # (num_topics, 2): the log-α coefficient λ per covariate
model.feature_effect_se # asymptotic SE of each λ (see the caveat below)
Two fidelity caveats. topica estimates λ by L-BFGS MAP every optimize_interval
sweeps (the penalized Dirichlet-multinomial DMR uses), whereas R keyATM
slice/MH-samples λ every iteration; the generative model, N(0,1) prior, and ±5
bound match, but the estimator does not. So feature_effect_se is an asymptotic
observed-information SE (a topica construct computed once at fit time in the
standardized space and mapped back, issue #270), not keyATM's posterior SD; an entry
is NaN when its standardized coefficient hit the ±5 bound, where the constrained
estimate has no valid asymptotic SE. For uncertainty on the resulting topic
prevalences, prefer predicted_prevalence above, or pair the fitted doc_topic with
estimate_effect.
visualize_keywords(model) and refine_keywords(...) inspect and prune the keyword
sets (the latter drops too-rare seeds before fitting); see their docstrings.
Dynamic keyATM¶
Pass timestamps (one per document) to let topic prevalence shift over time.
This is the keyATM dynamic model, a Chib (1998) change-point hidden Markov model:
the timeline is split into num_states latent regimes, each with its own
document-topic prior, and the model estimates where prevalence changes. Following
the keyATM Supreme Court application (Eshima, Imai & Sasaki 2024, Section 3.3),
documents carry a year and the model recovers when each topic rises or falls.
model = topica.KeyATM(seeds, num_topics=14, seed=1)
model.fit(docs, timestamps=years, num_states=5, iters=3000)
model.time_labels # ['1946', '1947', ..., '2012'] (T distinct timestamps)
model.time_state # [0, 0, 1, 1, ..., 4] regime of each segment
model.time_prevalence # (T, num_topics): smoothed prevalence path, rows sum to 1
model.transition_matrix # (num_states, num_states), left-to-right
Documents may be passed in any order; they are sorted by timestamp internally and
doc_topic is returned in the original order. Plot a column of time_prevalence
against time_labels to see a topic's trajectory.
Embedding-guided topics (EmbeddingLDA)¶
Experimental — validated by planted-recovery only
EmbeddingLDA is a topica original with no published paper or reference
implementation; its gold cannot distinguish it from plain LDA, whose label
recovery it does not beat on real text. It is gated behind
topica.enable_experimental() (or TOPICA_EXPERIMENTAL=1) and may change or
be removed without a deprecation cycle (issue #660). Its SeededLDA core is
validated; the embedding-seeding benefit is what remains unproven.
SeededLDA and KeyATM ask you to name the seed words. EmbeddingLDA instead
discovers them from a pre-trained embedding space: it clusters the vocabulary's
embeddings into num_topics semantic groups, seeds each topic with the words
nearest its cluster centroid, and fits a SeededLDA underneath. The embeddings
warm-start where topics form; the Gibbs sampler can still override any seed the
text contradicts, so this is a prior, not a constraint.
You supply the embeddings (topica does not call any model itself), aligned to the vocabulary:
from sentence_transformers import SentenceTransformer
import topica
topica.enable_experimental() # EmbeddingLDA is experimental and gated
vocab = sorted({w for d in docs for w in d})
emb = SentenceTransformer("all-MiniLM-L6-v2").encode(vocab)
model = topica.EmbeddingLDA(num_topics=10, embeddings=emb, vocabulary=vocab,
top_m=20) # weight defaults to a light 0.1
model.fit(docs, iters=1000)
for i, words in enumerate(model.top_words(8)):
print(f"Topic {i}:", ", ".join(w for w, _ in words))
top_m sets how many of each cluster's nearest words become seeds, and weight
how hard they anchor (a seed gets weight * 100 prior pseudocounts). The default
is a light weight=0.1: the embedding seeds are semantically grouped but do not
necessarily co-occur, so anchoring them hard lowers topic coherence (see the
embedding-models guide). Raise it toward 1.0 only to
hold topics closer to their semantic cluster.
The whole fitted-model surface (topic_word, doc_topic, top_words,
coherence, ...) is delegated to the underlying SeededLDA, and model.seeds
holds the embedding-derived seed sets. topica.embedding_seeds(...) exposes just
the clustering step if you want to inspect or edit the seeds before fitting.
GuidedNMF¶
GuidedNMF is the matrix-factorization member of this family: seed-word-guided
semi-supervised NMF (Vendrow et al. 2021). Where SeededLDA biases a Dirichlet
prior, GuidedNMF adds a supervision term to the NMF objective that pulls
designated topics toward your seed words. Same {name: [words]} seed dictionary and
the same seed_match/case_insensitive matcher as SeededLDA.
seeds = {"economy": ["tax", "market", "jobs"], "foreign": ["war", "troops"]}
m = topica.GuidedNMF(num_topics=10, seed_words=seeds, seed=1).fit(docs) # guidance defaults to 3.0
m.seed_topic_indices # which learned topic each group steered
dict(zip(m.seed_group_names, m.seed_topic_indices)) # name -> topic
m.top_words(10, topic=m.seed_topic_indices[0])
The full model reference and its ssnmf validation are in models.md#guidednmf. Two things to know before you report numbers:
guidance (λ) and document prevalence
guidance trades reconstruction against seed adherence. topica defaults to
guidance=3, deliberately lower than the reference's rarely-used 20: at 20
the guided topics are pinned so tightly to their seed words that they rarely
dominate a document, so their doc_topic share and argmax counts collapse
toward zero even when the theme is clearly present. At 3 the top words are the
same but document prevalence is interpretable. Raise guidance toward 20 to
reproduce the reference or hold topics closer to the seeds; lower it for more
data-driven topics. The topic-word content is faithful across λ; it is the
document side that shifts, so state the λ you used when you report prevalence.
convergence_tol defaults to 0.0 (run the full iters budget, matching the
reference), so a normal fit ends with converged=False — that is the "ran the whole
budget" state, not a failure. Set convergence_tol > 0 for a relative-decrease early
stop.
Which to use¶
KeyATMis the better-validated choice and the one with the political- science following; prefer it for new work.SeededLDAis simpler and maps directly onto theseededldaworkflow.GuidedNMFis the fast, deterministic matrix-factorization option: no priors, seconds to fit, and topic-word content faithful tossnmf; mind the prevalence caveat above.EmbeddingLDAwhen you have embeddings but no hand-picked seed list, and want the topic structure anchored to semantic similarity.
Both feed the same diagnostics, effects, and validation as every other model.
Faithful to the references
On a shared corpus with identical seeds, topica recovers seeded-topic
vocabulary close to R's seededlda and keyword topics that align with R's
keyATM as well as R aligns with itself across seeds. The comparison runs the
reference packages through the reproducible harness in parity/ (it needs a
local R install with the packages); it measures topic-word agreement, not
identical word lists.