library(dplyr)
library(tibble)
library(stringr)
library(purrr)
library(readr)
library(quanteda)
library(topicmodels)
source("R/inaugural-corpus.R")
source("R/permutation-null.R")
paragraphs <- inaugural_paragraphs()
paragraph_tokens <- tokens(
paragraphs$paragraph,
remove_punct = TRUE,
remove_numbers = TRUE
) |>
tokens_tolower() |>
tokens_remove(stopwords("en"))
paragraph_dfm <- dfm(paragraph_tokens)
trimmed_dfm <- dfm_trim(paragraph_dfm, min_termfreq = 5)
lda_input <- convert(trimmed_dfm, to = "topicmodels")
matrix_summary <- tibble(
measure = c("documents", "features", "nonzero term counts"),
value = c(ndoc(trimmed_dfm), nfeat(trimmed_dfm), sum(trimmed_dfm))
)Reading topic-model output carefully
Topic counts, LDA fitting, and random starts
Priya receives five numbered topics and a request to name them before lunch. Each table row contains a few common words and a number.
The table can encourage interpretations that exceed the evidence. A topic model produces topics even when those topics vary across fits. The analyst must compare results from different random starts.
This lesson shows how to:
- explain Latent Dirichlet Allocation in everyday language.
- build a paragraph-level document-feature matrix.
- compare candidate topic counts with three diagnostics.
- distinguish an LDA model from the algorithm used to fit it.
- fit a five-topic LDA model with explicit seeds.
- match topics across runs by their top terms.
- compare observed agreement with a random-label null.
Build the matrix
Latent Dirichlet Allocation, usually shortened to LDA, groups words that tend to appear in the same documents. Here each document is one paragraph. A document-feature matrix stores document rows and word columns. The value k is the number of topics the analyst asks the model to find.
| Measure | Value |
|---|---|
| documents | 1377 |
| features | 2587 |
| nonzero term counts | 52934 |
Trimming keeps words that appear at least five times in the paragraph corpus. The matrix has 1,377 paragraph documents and 2,587 word features.
Paragraphs give the model 1,377 documents instead of 60 whole speeches. Each document contains fewer words, so the model calculates an estimate of its topic mixture from less evidence. Document size is therefore part of model design. This modest corpus describes these speeches rather than political language generally.
Compare candidate topic counts
There is no single correct value of k. Fewer topics create broad groups. More topics divide the same vocabulary into smaller groups. The appropriate value depends on the planned analysis.
The committed diagnostic study fits k = 3 through k = 8 under three random starts. It builds the vocabulary from training speeches and evaluates perplexity on held-out speeches. Three measures ask different questions:
- Held-out perplexity measures how poorly the fitted model predicts word counts in held-out speeches. Lower is better.
- Semantic coherence asks whether a topicβs most probable words appear together in the training paragraphs. Higher values, which are less negative here, are better.
- Adjusted top-word exclusivity measures whether those words concentrate in one topic rather than occur across all topics. Higher is better. The adjustment removes the (1/k) share expected under equal distribution.
topic_diagnostics <- read_csv(
"data/inaugural/topic-k-diagnostics.csv",
na = character(),
col_types = cols(
k = col_integer(),
seeds = col_integer(),
coherence_mean = col_double(),
coherence_low = col_double(),
coherence_high = col_double(),
exclusivity_mean = col_double(),
exclusivity_low = col_double(),
exclusivity_high = col_double(),
perplexity_mean = col_double(),
perplexity_low = col_double(),
perplexity_high = col_double(),
coherence_rank = col_integer(),
exclusivity_rank = col_integer(),
perplexity_rank = col_integer(),
selected = col_logical(),
selection_reason = col_character()
)
)
diagnostic_display <- topic_diagnostics |>
transmute(
k,
coherence = round(coherence_mean, 3),
coherence_rank,
adjusted_exclusivity = round(exclusivity_mean, 3),
exclusivity_rank,
heldout_perplexity = round(perplexity_mean, 1),
perplexity_rank,
used_below = if_else(selected, "yes", "no")
)
selected_diagnostics <- topic_diagnostics |>
filter(selected)
three_topic_diagnostics <- topic_diagnostics |>
filter(k == 3L)
coherence_lead <- (
selected_diagnostics$coherence_mean -
three_topic_diagnostics$coherence_mean
)
selected_coherence_spread <- (
selected_diagnostics$coherence_high -
selected_diagnostics$coherence_low
)| k | Coherence | Coherence rank | Adjusted exclusivity | Exclusivity rank | Held-out perplexity | Perplexity rank | Used below |
|---|---|---|---|---|---|---|---|
| 3 | -1.623 | 4 | 0.274 | 1 | 1084.9 | 1 | no |
| 4 | -1.618 | 3 | 0.196 | 2 | 1086.0 | 2 | no |
| 5 | -1.603 | 1 | 0.157 | 3 | 1087.0 | 3 | yes |
| 6 | -1.617 | 2 | 0.131 | 4 | 1087.6 | 4 | no |
| 7 | -1.631 | 5 | 0.117 | 5 | 1088.3 | 5 | no |
| 8 | -1.642 | 6 | 0.101 | 6 | 1088.9 | 6 | no |
The diagnostics do not agree. Three topics have the best held-out perplexity and adjusted exclusivity. Five topics have the best mean coherence. This lesson uses five because it gives coherence priority for this teaching example. Three would also be a defensible choice if prediction or top-word concentration mattered more. The later seed check examines only k = 5, so it cannot choose between five and three.
The coherence lead over three topics is only 0.02, while the three five-topic starts span 0.095. Rank 1 does not establish a clear advantage here. These ranges come from three starts on one speech-grouped split, not confidence intervals or a validated optimum for another corpus.
Do not choose k because its word table is easiest to name. That choice uses the output to select the model retrospectively. Set the diagnostics and their priority before reading topic tables. Then read the topics. People must examine the results because predictive fit and automated coherence do not guarantee useful or understandable topics.
The topicmodels package can fit LDA in two ways. Variational expectation maximization, or VEM, approximates the modelβs probability distributions. This lesson uses VEM. Gibbs sampling draws samples from those distributions across successive iterations. The analyst chooses the iteration count, warm-up, and number of retained samples. Both methods fit LDA but can return different answers.
Other topic models use different assumptions. LDA cannot represent a tendency for some topics to appear together in the same documents. A correlated topic model can represent that tendency. A structural topic model can use document information such as year to calculate estimates of topic prevalence or content. Seeded and supervised topic models add analyst guidance or an outcome. These models answer different questions and are not necessary for every analysis.
Fit five topics three times
The analyst chooses k = 5. The seed in control = list(seed = ...) fixes the random starting values used by the LDA fitting algorithm. It does not choose k, and it does not prove that the fitted topics are the only possible solution.
The committed diagnostic study used training and held-out speeches to inform that choice. The three objects below are new full-corpus fits for inspection at the selected resolution. They are not the diagnostic models.
top_terms <- function(model, n = 10L) {
beta <- posterior(model)$terms
bind_rows(lapply(seq_len(nrow(beta)), function(topic) {
ordered <- sort(beta[topic, ], decreasing = TRUE)
tibble(
topic = topic,
term = names(ordered)[seq_len(n)],
beta = as.numeric(ordered[seq_len(n)])
)
}))
}
set.seed(42)
lda_a <- LDA(
lda_input,
k = 5,
method = "VEM",
control = list(seed = 42)
)
set.seed(7)
lda_b <- LDA(
lda_input,
k = 5,
method = "VEM",
control = list(seed = 7)
)
set.seed(2024)
lda_c <- LDA(
lda_input,
k = 5,
method = "VEM",
control = list(seed = 2024)
)
terms_a <- top_terms(lda_a, n = 10L)
terms_b <- top_terms(lda_b, n = 10L)
terms_c <- top_terms(lda_c, n = 10L)
topics_a <- terms_a |>
slice_head(n = 8, by = topic) |>
summarise(top_terms = paste(term, collapse = ", "), .by = topic)| Topic number | Top terms |
|---|---|
| 1 | country, power, can, us, must, government, people, new |
| 2 | people, can, government, us, great, upon, states, shall |
| 3 | can, may, country, government, people, us, nation, one |
| 4 | free, public, states, nation, us, power, upon, world |
| 5 | must, government, people, upon, public, nations, peace, shall |
The row names identify topics by number. The words are high-probability terms under this fitted model, not validated descriptions of a subject.
Match topics across random starts
Topic numbers are arbitrary. Topic 1 in one run does not have to be topic 1 in another run. The comparison below matches topics by the largest total overlap in their top-10 term sets.
A permutation null computes the comparison again after shuffling topic assignments across top-term rows. The p-value adds 1 to the number of shuffled runs that match or exceed the observed overlap. It then divides that sum by the number of shuffled runs plus 1.
permutations <- function(x) {
if (length(x) == 1L) {
return(list(x))
}
seq_along(x) |>
map(\(i) {
permutations(x[-i]) |>
map(\(p) c(x[i], p))
}) |>
list_flatten()
}
best_topic_match <- function(terms_first, terms_second) {
topics <- sort(unique(terms_first$topic))
first_sets <- topics |>
map(\(topic_id) terms_first$term[terms_first$topic == topic_id])
second_sets <- topics |>
map(\(topic_id) terms_second$term[terms_second$topic == topic_id])
candidate_orders <- permutations(seq_along(topics))
candidate_scores <- candidate_orders |>
map_int(\(order) {
seq_along(topics) |>
map_int(\(i) length(intersect(first_sets[[i]], second_sets[[order[i]]]))) |>
sum()
})
best_order <- candidate_orders[[which.max(candidate_scores)]]
tibble(
topic_first = topics,
topic_second = topics[best_order],
shared_top_10_terms = seq_along(topics) |>
map_int(\(i) length(intersect(first_sets[[i]], second_sets[[best_order[i]]])))
)
}
seed_matches <- bind_rows(
best_topic_match(terms_a, terms_b) |>
mutate(comparison = "seed 42 vs 7", .before = 1),
best_topic_match(terms_a, terms_c) |>
mutate(comparison = "seed 42 vs 2024", .before = 1),
best_topic_match(terms_b, terms_c) |>
mutate(comparison = "seed 7 vs 2024", .before = 1)
)
seed_summary <- seed_matches |>
summarise(
mean_shared_terms = round(mean(shared_top_10_terms), 2),
minimum_shared_terms = min(shared_top_10_terms),
maximum_shared_terms = max(shared_top_10_terms),
total_shared_terms = sum(shared_top_10_terms),
.by = comparison
)
within_fit_overlap <- expand_grid(
topic_first = sort(unique(terms_a$topic)),
topic_second = sort(unique(terms_a$topic))
) |>
filter(topic_first < topic_second) |>
mutate(
shared_top_10_terms = map2_int(
topic_first,
topic_second,
\(first_topic, second_topic) {
length(intersect(
terms_a$term[terms_a$topic == first_topic],
terms_a$term[terms_a$topic == second_topic]
))
}
)
)
within_fit_mean <- mean(within_fit_overlap$shared_top_10_terms)
seed_null <- permutation_null(
observed = mean(seed_matches$shared_top_10_terms[seed_matches$comparison == "seed 42 vs 7"]),
replicate_fn = function() {
shuffled_terms <- terms_b |>
mutate(topic = sample(topic, n(), replace = FALSE))
mean(best_topic_match(terms_a, shuffled_terms)$shared_top_10_terms)
},
replicates = 500L,
seed = 5502L,
alternative = "greater"
)
seed_null_display <- seed_null |>
mutate(across(c(observed, null_mean, null_low, null_high, p_value), \(x) round(x, 3)))| Comparison | Topic in first run | Matched topic in second run | Shared top-10 terms |
|---|---|---|---|
| seed 42 vs 7 | 1 | 2 | 6 |
| seed 42 vs 7 | 2 | 5 | 4 |
| seed 42 vs 7 | 3 | 1 | 6 |
| seed 42 vs 7 | 4 | 3 | 5 |
| seed 42 vs 7 | 5 | 4 | 7 |
| seed 42 vs 2024 | 1 | 2 | 4 |
| seed 42 vs 2024 | 2 | 3 | 7 |
| seed 42 vs 2024 | 3 | 1 | 6 |
| seed 42 vs 2024 | 4 | 5 | 5 |
| seed 42 vs 2024 | 5 | 4 | 5 |
| seed 7 vs 2024 | 1 | 1 | 6 |
| seed 7 vs 2024 | 2 | 3 | 6 |
| seed 7 vs 2024 | 3 | 5 | 6 |
| seed 7 vs 2024 | 4 | 2 | 4 |
| seed 7 vs 2024 | 5 | 4 | 3 |
| Comparison | Mean shared top-10 terms | Minimum | Maximum | Total shared terms |
|---|---|---|---|---|
| seed 42 vs 7 | 5.6 | 4 | 7 | 28 |
| seed 42 vs 2024 | 5.4 | 4 | 7 | 27 |
| seed 7 vs 2024 | 5.0 | 3 | 6 | 25 |
| Observed | Null mean | Null low | Null high | p-value | Replicates | Alternative |
|---|---|---|---|---|---|---|
| 5.6 | 4.901 | 4.4 | 5.4 | 0.03 | 500 | greater |
The three full-corpus five-topic fits show modest repeatability. Matched topics share 5.6, 5.4, and 5.0 of their top 10 terms on average across the three pairwise comparisons. Distinct topics within the seed-42 fit already share 3.9 terms on average.
The null keeps the same 50 top-term rows, which contain repeated high-frequency terms. It randomly allocates those rows across five equal-sized topics. Its mean is 4.901, compared with observed overlap 5.6 and p = 0.03. This is limited evidence of repeatability for these three k = 5 fits. It does not establish that five topics are more stable than another topic count.
Changing k changes the question
A four-topic fit provides a sensitivity check rather than the same model as a five-topic fit. After k changes, topic numbers no longer identify corresponding topics across models.
set.seed(42)
lda_4 <- LDA(
lda_input,
k = 4,
method = "VEM",
control = list(seed = 42)
)
topics_4 <- top_terms(lda_4, n = 8L) |>
summarise(
top_terms = paste(term, collapse = ", "),
top_term = first(term),
.by = topic
)
shared_first_terms <- length(intersect(topics_a$top_terms |> str_extract("^[^,]+"), topics_4$top_term))| Topic number | Top terms |
|---|---|
| 1 | must, government, people, can, us, power, country, now |
| 2 | people, government, can, upon, great, us, states, shall |
| 3 | government, can, may, people, country, us, nation, world |
| 4 | public, free, upon, must, nation, states, good, world |
| Shared first terms with the seed-42 five-topic fit |
|---|
| 2 |
The first terms for the four-topic model are must, people, government, and public. Two are also first terms in the five-topic seed-42 fit. This count does not give an estimate of stability because a different k specifies a different modeling target.
What to remember
- LDA groups words that co-occur across paragraph documents.
- This lesson used 1,377 paragraph documents and 2,587 features.
- No diagnostic supplies a single correct
k. The choice depends on the intended resolution and purpose. - Perplexity, coherence, and exclusivity can favor different topic counts.
- Three full-corpus
k = 5fits showed modest repeatability. That check did not compare topic counts. - VEM and Gibbs sampling fit LDA through different algorithms. Other topic-model families answer different questions.
- A different
krequires new topic labels. - A topic name is a reading aid, not a finding produced by the model.
Keep the topic numbers with their model settings. Reassess the interpretation after model settings change.
Sources
- topicmodels package documentation
- topicmodels vignette: VEM, Gibbs, and correlated topic models
- Blei, Ng, and Jordan paper on Latent Dirichlet Allocation
- Chang et al. on human interpretation of topic models
- Mimno et al. on semantic coherence
- STM
searchK()diagnostics - Structural Topic Model overview
- quanteda document-feature matrix documentation