Skip to content

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

  • KeyATM is the better-validated choice and the one with the political- science following; prefer it for new work.
  • SeededLDA is simpler and maps directly onto the seededlda workflow.
  • GuidedNMF is the fast, deterministic matrix-factorization option: no priors, seconds to fit, and topic-word content faithful to ssnmf; mind the prevalence caveat above.
  • EmbeddingLDA when 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.