Skip to content
ML to LLMs

Module 9: Classical NLP 40 min10 min quick readNLP

Topic modeling

Discover the themes in an unlabeled corpus with LDA (documents as mixtures of topics, topics as distributions over words), implement collapsed Gibbs sampling, and choose between LDA, NMF, and embedding-based BERTopic.

Before this: From text to vectors, Word embeddings

Read on GitHub

Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.

Mental model. Imagine each document was written by someone who first rolled a weighted die to pick a theme for every word (60% finance, 40% AI), then picked a word from that theme's vocabulary list. Topic modeling runs this story backwards: from the words alone it recovers the vocabulary lists (topics) and each document's die weights (its topic mixture).

You will learn to

  • State LDA's generative story and the two matrices it outputs: document-topic θ\theta and topic-word ϕ\phi.
  • Compute one collapsed Gibbs sampling update by hand and implement the sampler.
  • Explain why LDA takes raw counts, what α\alpha and β\beta control, and how to choose the number of topics.
  • Compare LDA, NMF, and BERTopic (embeddings plus clustering) and pick one for a given corpus.
  • Evaluate topics with coherence, diversity, and human reading, and avoid treating a discovered topic as a real category.

Why it matters. You have 50,000 support tickets and no labels. Topic modeling gives a first map of what's in there: which themes exist, how big they are, and how they move over time. It feeds dashboards, triage, and labeling plans. It's also a standard interview topic because it tests probabilistic modeling, unsupervised evaluation, and judgment about when to stop trusting the output.

1. Intuition

Two outputs. A topic model returns:

  • Topics: each is a probability distribution over the vocabulary. A finance topic puts high probability on "stocks", "rates", "inflation".
  • Document mixtures: each document is a distribution over topics. A news story about chip stocks might be 60% finance and 40% AI.

That second point is mixed membership: a document can belong to several topics at once, unlike k-means, where every point belongs to exactly one cluster.

Word co-occurrence is the only signal. LDA never sees meaning. It notices that "stocks" and "yields" keep showing up in the same documents, so they probably come from the same topic. That's why it needs many documents, and why it struggles with very short ones: a 6-word tweet carries little co-occurrence evidence.

Learning by repeated guessing. Collapsed Gibbs sampling starts by giving every word token a random topic. Then, one token at a time, it removes that token's assignment and re-picks a topic with probability proportional to two things: how much this document already uses each topic, and how much each topic already uses this word. Repeat for every token, hundreds of times. Rich-get-richer dynamics pull related words into the same topic and keep each document focused on a few topics.

The embedding alternative. BERTopic skips the word-count story: it embeds each document with a sentence encoder, clusters the embeddings, and describes each cluster by its most distinctive words. It captures meaning (synonyms land together) but gives each document one main topic by default.

Key distinction

A discovered topic is a statistical pattern in word use, not a real category. "Topic 7" might be a genuine theme, a writing style, a boilerplate signature, or a language. Name topics only after reading documents from them.

2. Visualization

Left: three topics found by from-scratch LDA on a synthetic corpus. Topic 0 top words researchers, inference, model, attention, training, tokens; topic 1 congress, governor, vote, tariffs, president, campaign; topic 2 market, investors, fed, inflation, stocks, treasury; each word has probability around 0.10 to 0.12. Right: a heatmap of document-topic mixtures for the first 12 documents; pure documents are a single dark cell, and documents 3, 7, and 11, generated as 60/40 mixtures, show two medium cells.

Synthetic corpus: 60 documents of 30 words drawn from three hand-written word lists of 10 words each. Every fourth document mixes two topics 60/40. LDA recovers the three lists exactly and assigns the mixed documents two-topic mixtures. Topic numbers are arbitrary: LDA does not know which list is "finance".

Try it (predict first, then run the script and check):

  1. Doc 3 was generated as 60% finance and 40% AI. Predict its inferred θ\theta before looking at the output.
  2. Set K=2 in the first gibbs_lda call. Predict which two true topics get merged, and whether topic diversity drops.
  3. Raise alpha from 0.1 to 5. Predict what happens to the heatmap's pure documents.
  4. Feed sklearn's LDA a TfidfVectorizer matrix instead of counts. Predict whether it errors, and whether the topics still make sense.

3. The math

Symbols

SymbolMeaningShape
D,V,KD, V, Knumber of documents, vocabulary size, number of topicsscalars
θd\theta_dtopic mixture of document dd[K][K], rows of θ\theta [D,K][D, K]
ϕk\phi_kword distribution of topic kk[V][V], rows of ϕ\phi [K,V][K, V]
zd,iz_{d,i}topic assigned to the ii-th token of document ddinteger in 0..K−10..K-1
α\alphaDirichlet prior on θ\theta; small means each document uses few topicsscalar (symmetric)
β\betaDirichlet prior on ϕ\phi; small means each topic uses few wordsscalar (symmetric)
ndkn_{dk}tokens in document dd assigned to topic kk[D,K][D, K]
nkwn_{kw}times word ww is assigned to topic kk[K,V][K, V]
nkn_ktotal tokens assigned to topic kk[K][K]

The generative story

ϕk∼Dirichlet(β),θd∼Dirichlet(α),zd,i∼Categorical(θd),wd,i∼Categorical(ϕzd,i)\phi_k \sim \text{Dirichlet}(\beta), \qquad \theta_d \sim \text{Dirichlet}(\alpha), \qquad z_{d,i} \sim \text{Categorical}(\theta_d), \qquad w_{d,i} \sim \text{Categorical}(\phi_{z_{d,i}})

Only the words ww are observed. Inference recovers θ\theta, ϕ\phi, and zz. The model counts words, which is why LDA takes raw term counts (a bag of words), not TF-IDF weights.

Collapsed Gibbs update

"Collapsed" means θ\theta and ϕ\phi are integrated out, so we only sample the zz's. For one token with word ww in document dd, with that token's own count removed (marked ¬\neg):

P(zd,i=k∣rest)  ∝  (ndk¬+α)⏟doc likes topic k×nkw¬+βnk¬+Vβ⏟topic k likes word wP(z_{d,i} = k \mid \text{rest}) \;\propto\; \underbrace{(n_{dk}^{\neg} + \alpha)}_{\text{doc likes topic } k} \times \underbrace{\frac{n_{kw}^{\neg} + \beta}{n_k^{\neg} + V\beta}}_{\text{topic } k \text{ likes word } w}

After sampling, estimate

θdk=ndk+α∑k′ndk′+Kα,ϕkw=nkw+β∑w′nkw′+Vβ\theta_{dk} = \frac{n_{dk} + \alpha}{\sum_{k'} n_{dk'} + K\alpha}, \qquad \phi_{kw} = \frac{n_{kw} + \beta}{\sum_{w'} n_{kw'} + V\beta}

Worked example: one Gibbs update

K=2K = 2 topics, V=30V = 30, α=0.1\alpha = 0.1, β=0.01\beta = 0.01. The token is the word "model". With this token removed, its document has nd0=7n_{d0} = 7 and nd1=2n_{d1} = 2 tokens in each topic. Across the corpus "model" is assigned n0,model=0n_{0,\text{model}} = 0 times to topic 0 and n1,model=9n_{1,\text{model}} = 9 times to topic 1. Topic totals: n0=150n_0 = 150, n1=140n_1 = 140.

Denominators: n0+Vβ=150+30×0.01=150.3n_0 + V\beta = 150 + 30 \times 0.01 = 150.3 and n1+Vβ=140+0.3=140.3n_1 + V\beta = 140 + 0.3 = 140.3.

topic 0: (7+0.1)×0+0.01150.3=7.1×0.0000665=0.000472\text{topic 0: } (7 + 0.1) \times \frac{0 + 0.01}{150.3} = 7.1 \times 0.0000665 = 0.000472 topic 1: (2+0.1)×9+0.01140.3=2.1×0.06422=0.134861\text{topic 1: } (2 + 0.1) \times \frac{9 + 0.01}{140.3} = 2.1 \times 0.06422 = 0.134861

Normalize: the sum is 0.1353330.135333, so P(z=0)=0.000472/0.135333=0.003P(z = 0) = 0.000472 / 0.135333 = 0.003 and P(z=1)=0.997P(z = 1) = 0.997.

The document leans toward topic 0 (7 vs 2 tokens), but topic 0 has never produced "model", so the word factor wins. This is how LDA separates words: a topic that never uses a word almost never gets it.

Choosing K

There is no single right KK. Two cheap automatic signals:

  • Topic diversity: the fraction of unique words across all topics' top-nn lists. 1.0 means no overlap; low values mean KK is too large and topics are splitting into near-duplicates.
  • Topic coherence (CvC_v, NPMI): do a topic's top words co-occur in a reference corpus more than chance? Higher is better.

In the script, diversity of the top 6 words is 1.00 for K=2K = 2 and K=3K = 3, then 0.73 for K=5K = 5 and 0.58 for K=8K = 8. Note that K=2K = 2 also scores 1.00 while merging two real topics: diversity catches too many topics, not too few. Read the topics.

4. Implementation

The full script is code/09-classical-nlp/topic_model_scratch.py (NumPy for the sampler, scikit-learn for the library comparison).

Build it from scratch

Step 1: map words to ids and start random. Every token gets a random topic. The three count tables are all the sampler needs: it never stores θ\theta or ϕ\phi while sampling.

python
def gibbs_lda(docs, K, alpha=0.1, beta=0.01, iters=200):
    vocab = sorted({w for d in docs for w in d})
    w2i = {w: i for i, w in enumerate(vocab)}
    V = len(vocab)
    W = [np.array([w2i[w] for w in d]) for d in docs]
    Z = [rng.integers(0, K, len(d)) for d in W]           # random initial topic per token
    ndk = np.zeros((len(W), K))                           # tokens in doc d assigned to topic k
    nkw = np.zeros((K, V))                                # times word w is assigned to topic k
    nk = np.zeros(K)                                      # tokens assigned to topic k

Predict: after initialization, roughly what fraction of a 30-token document's tokens is in each of 3 topics?

Step 2: fill the counts. One pass adds each token's random assignment to the three tables.

python
    for d, (ws, zs) in enumerate(zip(W, Z)):
        for w, z in zip(ws, zs):
            ndk[d, z] += 1
            nkw[z, w] += 1
            nk[z] += 1

Predict: what do ndk.sum(), nkw.sum(), and nk.sum() each equal?

Step 3: the sweep. For each token: subtract it from the counts (the ¬\neg in the formula), compute the KK unnormalized probabilities in one vectorized line, sample, and add it back under its new topic. Forgetting the subtraction counts the token as evidence for its own current topic.

python
    for _ in range(iters):
        for d, (ws, zs) in enumerate(zip(W, Z)):
            for i, (w, z) in enumerate(zip(ws, zs)):
                ndk[d, z] -= 1                            # remove this token's current assignment
                nkw[z, w] -= 1
                nk[z] -= 1
                p = (ndk[d] + alpha) * (nkw[:, w] + beta) / (nk + V * beta)   # [K]
                z = rng.choice(K, p=p / p.sum())          # resample its topic
                zs[i] = z
                ndk[d, z] += 1
                nkw[z, w] += 1
                nk[z] += 1

Predict: if beta were 0, what probability would a topic get for a word it has never been assigned?

Step 4: read off the estimates. Smoothed, normalized counts give θ\theta and ϕ\phi.

python
    theta = (ndk + alpha) / (ndk.sum(1, keepdims=True) + K * alpha)        # [D, K] doc-topic
    phi = (nkw + beta) / (nkw.sum(1, keepdims=True) + V * beta)            # [K, V] topic-word
    return theta, phi, vocab

Predict: with α=0.1\alpha = 0.1 and K=3K = 3, what is the smallest possible θdk\theta_{dk} for a 30-token document?

Step 5: topic diversity.

python
def topic_diversity(tops):
    flat = [w for t in tops for w in t]
    return len(set(flat)) / len(flat)

Predict: 8 topics, 6 top words each, and 28 unique words among the 48. What is the diversity?

Use the library

scikit-learn's LDA uses online variational Bayes instead of Gibbs sampling; it is faster and deterministic for a fixed seed. Its priors are doc_topic_prior (α\alpha) and topic_word_prior (β\beta), both defaulting to 1/K1/K. NMF factorizes the count (or TF-IDF) matrix X≈WHX \approx WH with non-negative factors and is a fast, often sharper alternative.

python
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation, NMF

cv = CountVectorizer(stop_words="english", min_df=2, max_df=0.9)   # raw counts; drop rare and ubiquitous words
X = cv.fit_transform(texts)                                          # [D, V] sparse
lda = LatentDirichletAllocation(n_components=10, random_state=0).fit(X)
doc_topic = lda.transform(X)                                         # [D, K], rows sum to 1
top = [cv.get_feature_names_out()[row.argsort()[::-1][:10]] for row in lda.components_]
nmf = NMF(n_components=10, init="nndsvd", random_state=0).fit(X)

For short or semantic text, BERTopic composes embeddings, dimensionality reduction, and clustering:

python
from bertopic import BERTopic   # pip install bertopic
topic_model = BERTopic(min_topic_size=15)                # default embeddings: all-MiniLM-L6-v2; UMAP; HDBSCAN
topics, probs = topic_model.fit_transform(texts)          # one topic id per doc; -1 = outliers
print(topic_model.get_topic_info().head())               # sizes and c-TF-IDF top words

Each cluster's words come from class-based TF-IDF: all documents in a cluster are concatenated into one, and a word scores tfw,c⋅log⁡(1+A/fw)\text{tf}_{w,c} \cdot \log(1 + A / f_w), where AA is the average number of words per cluster and fwf_w is the word's frequency over all clusters. min_topic_size defaults to 10. For mixtures, calculate_probabilities=True or approximate_distribution gives per-document topic distributions.

Run it

bash
uv run code/09-classical-nlp/topic_model_scratch.py

Key lines of output:

text
unnormalised: topic0 = (7.1)(0.01)/150.3 = 0.000472, topic1 = (2.1)(9.01)/140.3 = 0.134861
normalised:   P(z=0) = 0.003, P(z=1) = 0.997

topic 0: researchers inference model attention training tokens
topic 1: congress governor vote tariffs president campaign
topic 2: market investors fed inflation stocks treasury
topic diversity (top 6): 1.00
doc 3: true mix [0.6 0.4 0. ], inferred theta [0.43 0.   0.56]
K = 5: diversity 0.73
K = 8: diversity 0.58

Doc 3 was 60% finance (topic 2 here) and 40% AI (topic 0), and LDA estimates 0.56 and 0.43. scikit-learn's LDA and NMF find the same three topics.

5. Engineering

When to pick what.

SituationGood defaultWhy
Long documents that mix themes (reports, articles)LDAmixed membership, cheap, works on any length
Short text (reviews, tweets, titles)BERTopic or embeddings plus clusteringtoo few words for co-occurrence; embeddings carry meaning
CPU only, huge corpus, fast iterationNMF or online LDAsparse matrix math, minutes not hours
Need readable labels for stakeholdersany, then an LLM to name topics from top words and sample docsnames are for humans; check them

Documents longer than the embedding model's context window must be chunked (and the chunk topics aggregated) for BERTopic; LDA has no length limit.

Preprocessing decides quality. Remove stop words and boilerplate (signatures, disclaimers, templates), or they become topics. Filter rare words (min_df) and near-universal ones (max_df). Lemmatize if inflections split topics. Bigrams help ("interest rate").

Evaluation. Combine automatic coherence (gensim CoherenceModel with coherence="c_v"), topic diversity, and a human read of the top words and 10 to 20 documents per topic. Perplexity on held-out text often disagrees with human judgments of quality, so don't choose KK by perplexity alone. Check stability: rerun with different seeds and see whether topics persist.

Production. Topic ids change between training runs, so freeze a model and map ids to names before building dashboards. To track trends, assign new documents with transform and watch topic shares over time. For BERTopic, watch the -1 outlier share; if it is large, lower min_topic_size or use reduce_outliers.

Common mistakes

  • Feeding TF-IDF into LDA. LDA's likelihood is over counts; use CountVectorizer.
  • Naming topics from top words alone, then reporting them as categories. Read documents first.
  • Picking KK from one metric. Diversity misses merged topics; coherence can favor generic topics.
  • Leaving boilerplate in, then finding a "topic" that is an email signature.
  • Running LDA on 10-word documents and blaming the algorithm.

6. Knowledge check

Quiz: Topic modeling

Question 1 of 10
Multiple choiceeasy

How does LDA model a document?

Practice exercise. K=2K = 2, V=20V = 20, α=0.5\alpha = 0.5, β=0.1\beta = 0.1. For a token of the word "vote", with the token removed: nd0=3n_{d0} = 3, nd1=3n_{d1} = 3; n0,vote=1n_{0,\text{vote}} = 1, n1,vote=5n_{1,\text{vote}} = 5; n0=98n_0 = 98, n1=98n_1 = 98. What is P(z=1)P(z = 1)?

Solution

Denominators are equal: 98+20×0.1=10098 + 20 \times 0.1 = 100. Topic 0: (3+0.5)(1+0.1)/100=3.5×1.1/100=0.0385(3 + 0.5)(1 + 0.1)/100 = 3.5 \times 1.1 / 100 = 0.0385. Topic 1: (3+0.5)(5+0.1)/100=3.5×5.1/100=0.1785(3 + 0.5)(5 + 0.1)/100 = 3.5 \times 5.1 / 100 = 0.1785. Sum 0.21700.2170. P(z=1)=0.1785/0.2170=0.823P(z = 1) = 0.1785/0.2170 = 0.823. The document is split evenly, so the word's history decides.

Implementation challenge. Add a perplexity estimate and a simple coherence score (average PMI of top-word pairs, computed from document co-occurrence in the same corpus) to the script. Plot both against K=2..8K = 2..8 and see whether they agree with topic diversity on the true K=3K = 3.

Hint

PMI for words a,ba, b: log⁡P(a,b)+ϵP(a)P(b)\log \frac{P(a, b) + \epsilon}{P(a) P(b)}, with probabilities as the fraction of documents containing the word or pair. Average it over all pairs of each topic's top 6 words, then over topics. Expect coherence to drop when topics split into near-duplicates that share words.

Summary

  • LDA models documents as mixtures of topics and topics as distributions over words; it outputs θ\theta [D,K][D, K] and ϕ\phi [K,V][K, V].
  • Collapsed Gibbs sampling re-assigns each token with probability ∝(ndk+α)(nkw+β)/(nk+Vβ)\propto (n_{dk} + \alpha)(n_{kw} + \beta)/(n_k + V\beta).
  • Small α\alpha gives focused documents, small β\beta gives focused topics; LDA needs raw counts.
  • BERTopic (embeddings, UMAP, HDBSCAN, c-TF-IDF) wins on short semantic text; LDA wins on long mixed documents and low compute.
  • Choose KK and judge quality with coherence, diversity, stability, and reading documents. Topics are patterns, not ground-truth categories.

Next: The neural-network forward pass

Related: Named entity recognition · From text to vectors · K-means · Density, hierarchical, and mixture clustering

Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Topic Modeling — LDA and BERTopic.

Interview angle

Explain LDA's generative story and what you get out of it.

LDA assumes each topic is a probability distribution over the vocabulary, and each document has its own mixture over topics. To write a document, you repeatedly pick a topic from the document's mixture, then pick a word from that topic's distribution. Dirichlet priors control sparsity: a small alpha means each document uses few topics, and a small beta means each topic concentrates on few words. Only the words are observed, so inference runs the story backwards, usually with collapsed Gibbs sampling or variational Bayes. You get two matrices: document-topic theta, shape D by K, rows summing to 1, and topic-word phi, shape K by V. The key property is mixed membership: a document can be 60% finance and 40% AI, unlike k-means, which forces one cluster. It works on raw counts, so it needs bag-of-words input, not TF-IDF.

LDA or BERTopic for a corpus of 200,000 short product reviews? What about 5,000 long policy reports?

For short reviews, BERTopic or a similar embeddings-plus-clustering approach. Reviews have maybe 20 to 50 words, so word co-occurrence is sparse and LDA topics get noisy, while sentence embeddings put "battery dies fast" and "poor charge life" together even with no shared words. BERTopic embeds documents, reduces dimensions with UMAP, clusters with HDBSCAN, and names clusters with class-based TF-IDF. Watch the minus-one outlier bucket and set min_topic_size to the corpus scale. For long policy reports, LDA or NMF: each document genuinely mixes several themes, mixed membership is the point, and long documents would exceed an embedding model's context window unless chunked. LDA also runs cheaply on CPU. Either way I'd validate topics by reading documents, check stability across seeds, and have an LLM draft topic names that humans then confirm.

Your LDA run produces a topic whose top words are "regards", "thanks", "sent", "iphone", "please". What happened and how do you fix it?

LDA found a real co-occurrence pattern, just not a useful one: email signatures and boilerplate appear together in many documents, so they form their own topic. It's a preprocessing failure, not an algorithm failure. Fixes: strip signatures, quoted replies, and templates before vectorizing; add domain stop words; set max_df around 0.5 to 0.9 to drop words that appear in most documents; and set min_df to drop typos and one-off tokens. Then rerun and compare coherence and diversity, and read 10 to 20 documents per topic. More generally, junk topics come from boilerplate, different languages, or formatting artifacts. Treat every topic as a hypothesis about the corpus that you confirm by reading documents, not as a category, and freeze the preprocessing with the model so production assignments stay consistent.

Do one collapsed Gibbs update: alpha 0.1, beta 0.01, V 30. The document has 7 tokens in topic 0 and 2 in topic 1; the word has 0 assignments to topic 0 and 9 to topic 1; topic totals are 150 and 140.

The update is proportional to (n_dk plus alpha) times (n_kw plus beta) divided by (n_k plus V times beta), with the current token removed from all counts. V times beta is 30 times 0.01, or 0.3. Topic 0: 7.1 times 0.01 divided by 150.3, which is 0.000472. Topic 1: 2.1 times 9.01 divided by 140.3, which is 18.921 over 140.3, or 0.1349. The sum is about 0.1353, so the token goes to topic 1 with probability 0.997 and topic 0 with 0.003. The document prefers topic 0, but topic 0 has never used this word, and beta is tiny, so the word factor dominates. That's the mechanism that separates vocabularies across topics. A larger beta would soften it, letting topics share words more.