Skip to content

2. Choose and justify K

The principle

K is a research decision, not a tuning parameter. Multiple values of K are often defensible; your job is to pick one for a reason and show your conclusions don't hinge on it.

K sets the granularity of your themes: roughly, K=10 for broad themes, K=30 for specific topics, K=100 for fine distinctions. There is no single "correct" K, and you should resist any procedure that pretends otherwise.

Three converging justifications

Good practice combines all three:

1. Theory-driven. How many themes would you expect in this corpus? What level of granularity answers your research question? Start from theory and adjust.

2. Diagnostic-guided. Scan a range and look at quality metrics:

Metric What it measures Reading
Coherence (c_v / UMass) Do a topic's top words co-occur? Higher is better
Exclusivity Are words distinctive to a topic? Higher is better
Held-out perplexity Fit on unseen documents Lower is better

Do not just maximize coherence

A model with K=5 may have higher mean coherence yet miss distinctions that matter to your argument. Coherence trades off against exclusivity and against substantive richness. Use the metrics to inform a judgment, not to replace it.

3. Interpretability-focused. For each candidate K: can you label every topic? Do the topics make substantive sense? How many are "junk" (stopwords, artifacts)? Do topics split and merge sensibly as K grows?

A concrete procedure

import topica
import numpy as np

# 1) Scan a theoretically plausible range.
held_out = test_docs                     # a held-out split for perplexity
results = topica.search_k(
    train_docs, ks=[10, 15, 20, 25, 30],
    held_out=held_out, iters=800,
)
for r in results:
    print(f"K={r['k']:>3}  coherence={r['coherence']:.3f}  "
          f"exclusivity={r['exclusivity']:.3f}  perplexity={r.get('perplexity'):.0f}")

Then, for the two or three best candidates, fit the model and read the topics. Count how many you can label, look at the per-topic coherence×exclusivity spread, and check held-out perplexity directly:

model = topica.STM(num_topics=20, seed=1)
model.fit(docs, prevalence=X)

table = topica.diagnostics(model, texts)          # one row per topic: coherence,
                                                   # exclusivity, FREX, size, ...
pp = topica.perplexity(model, held_out)            # held-out, lower is better

frontier = topica.quality_frontier(model, n=10)   # per-topic coherence & exclusivity
# scatter frontier["coherence"] vs frontier["exclusivity"];
# weak topics cluster in the lower-left.

topica.perplexity(model, held_out) works across the generative models (LDA, DMR, CTM, STM, HDP, …) by inferring each held-out document's topic mixture from half its tokens and scoring the other half, so it is comparable across K.

Document-completion held-out log-likelihood

make_heldout and eval_heldout implement R stm's held-out word scoring rather than the standard perplexity split. We hold out a random fraction of words from a random fraction of documents, fit the model on the reduced corpus, then score the withheld words:

import topica

h = topica.make_heldout(corpus, prop_docs=0.5, prop_words=0.5, seed=0)
model = topica.STM(num_topics=20, seed=1)
model.fit(h.documents, prevalence=X)

result = topica.eval_heldout(model, h)
print(f"mean per-doc held-out log-likelihood: {result.mean_per_doc_loglik:.3f}")

Higher (less negative) values indicate better fit. This metric is comparable across values of K fit on the same h.documents corpus.

Best-of-N at fixed K

Gibbs models and STM can land on different local optima from different starting values. select_model runs runs initializations at a fixed K and returns all fitted models with their coherence and exclusivity scores:

result = topica.select_model(
    docs, K=20,
    runs=20,           # number of random initializations
    model="stm",       # "lda" or "stm"
    prevalence=X,      # required when model="stm"
    fraction=0.5,      # keep only the top 50% after a short burn-in
)
# inspect the coherence-exclusivity frontier across all runs:
topica.plot_models(result)

# pick the run in the upper-right corner and use that model:
best_idx = result.coherence.argmax()   # or use exclusivity, or visual inspection
model = result.models[best_idx]

The fraction argument mirrors R stm's "run briefly, keep the best ~20%" heuristic: a short burn-in filters out clearly poor starts before the full training runs.

A nonparametric model is a useful sanity check on your choice: it infers a topic count rather than taking one.

hdp = topica.HDP(eta=0.3, seed=1)
hdp.fit(docs, iters=300)
print("HDP suggests ~", hdp.num_topics, "topics")

Report sensitivity

Pick the K that balances metrics, interpretability, and theory, then show your finding survives nearby K. Re-run the headline result at K-5 and K+5; if a covariate effect or a key topic only appears at one exact K, say so. Reviewers read "we used K=20" charitably only when followed by "results were robust to K ∈ {15, 25}."

→ Next: Validate the topics.