Skip to contents

OpTop implements the parametric tests of Lewis and Grossetti (2022, JMLR 23(58)) for identifying the optimal number of topics of a Latent Dirichlet Allocation (LDA) model, together with a family of regression-style goodness-of-fit (GoF) indices. This vignette illustrates both on the corpus of the paper’s case study and concludes with a simulation study in which the true number of topics is known.

The only step performed offline is the estimation of the LDA models: 100 fits on the full corpus and 30 fits on the training split of the held-out analysis. Everything downstream, from every optop_select() call to each table, figure, and number quoted in the text, is computed from those fits.

Setting Up: Corpus, Grid, Models

We use the U.S. Presidential Inaugural Address corpus distributed with quanteda, the same corpus analyzed in the paper’s case study, with a standard preprocessing pipeline and a vocabulary trim. The grid spans K = 2 to 200 in steps of 2.

library(OpTop)

ncores <- 6

# Tokenize
toks <- quanteda::data_corpus_inaugural |>
  quanteda::tokens(remove_punct = TRUE,
                   remove_symbols = TRUE,
                   remove_numbers = TRUE) |>
  quanteda::tokens_tolower() |>
  quanteda::tokens_remove(quanteda::stopwords())

# Create DFM (with raw counts as well as proportions)
mydfm        <- quanteda::dfm(toks)
mydfm_sub    <- quanteda::dfm_trim(mydfm, min_termfreq = 5)
weighted_dfm <- quanteda::dfm_weight(mydfm_sub, scheme = "prop")

K_grid <- seq(2, 200, by = 2)

# Estimate VEM models in parallel
VEM_models <- parallel::mclapply(
  K_grid,
  function(k) {
    message("Estimating LDA with K = ", k)
    topicmodels::LDA(x = mydfm_sub, k = k)
  },
  mc.cores = ncores
)

After trimming, the corpus has 60 documents and 2654 features (down from 9359). The VEM fits above are unseeded, so re-running the pipeline may alter individual statistics slightly; the numbers quoted in this vignette come from one such run.

The Test 1 Statistic

The test evaluates overall model adequacy: how well a K-topic model explains the corpus. It relies on the multinomial structure that LDA itself assumes. A fitted K-topic model describes document j by a vector of document-topic weights \theta_j^K and each topic by a distribution over the P vocabulary words, collected in the topic-word matrix \phi^K. The model’s fitted probability of word p in document j is

I_{jp}^{K} = \sum_{k=1}^{K} \theta_{jk}^{K}\,\phi_{kp}^{K}, \tag{1}

which is Equation 5 in the paper. Equation 4 of the paper represents the observed word-proportion vector d_j as this fitted distribution plus multinomial sampling noise. If the K-topic model fully characterizes the corpus, one would be unable to reject the hypothesis that the observed and estimated word distributions are statistically indistinct: a classical goodness-of-fit situation.

Because a large number of words carry near-zero fitted probability, the comparison is made on a per-document envelope. The fitted probabilities I_{jp}^K are sorted in decreasing order and the smallest leading set whose cumulative mass exceeds q is retained; these are the P_j relatively important words of document j. The remaining, relatively unimportant words are collapsed into a single bin (the paper’s Equations 6 and 7). With the words of document j indexed in decreasing order of I_{jp}^K, the fitted and observed masses of that bin are

I^{K}_{j,\min} = \sum_{p = P_j + 1}^{P} I^{K}_{jp} \;<\; 1 - q, \tag{2a}

D_{j,\min} = \sum_{p = P_j + 1}^{P} D_{jp}. \tag{2b}

Collapsing the relatively unimportant words in this manner preserves the underlying multinomial assumption. Note that the basis for the cutoff is the fitted model rather than the observed documents; the paper’s numerical setting I^K = 0.05 corresponds to q = 0.95. Test 1 (Equation 8 of the paper) then aggregates the Pearson discrepancies over documents:

\mathrm{OpTop}_J^K = \sum_{j=1}^{J} \left[\, (P_j + 1) \left( \sum_{p=1}^{P_j} \frac{\bigl(D_{jp} - I_{jp}^K\bigr)^2}{I_{jp}^K} +\; \frac{\bigl(D_{j,\min} - I_{j,\min}^K\bigr)^2}{I_{j,\min}^K} \right) \right] \sim \chi^2_{P_J}, \tag{3}

where \mathrm{OpTop}_J^K is distributed chi-square with P_J = \sum_{j=1}^{J} P_j degrees of freedom. Misspecification, that is a K too coarse to reproduce the word distributions, can only inflate the statistic, so evidence against adequacy is found in the upper tail. The package reports the standardized statistic \mathrm{OpTop}_J^K / P_J (the version plotted in the paper’s Figure 2, which fluctuates around 1 under an adequate model) in the OpTop column, the degrees of freedom P_J in df, and the upper-tail p-value in pval.

Running the Test

optop_select() consumes word proportions (weighted_dfm above), while the discrepancy indices of the second part consume the raw counts; both objects are therefore required. With verbose = TRUE, the function reports its setup, its progress across the grid, the selected model together with the rule that chose it, and the elapsed time:

# Use the sequential approach
chi_seq <- optop_select(topic_models = VEM_models,
                        weighted_dfm = weighted_dfm,
                        selection = "sequential",
                        q = 0.95,
                        alpha = 0.01,
                        do_plot = FALSE)
#> ── Optimal topic selection ──
#> Loading required namespace: topicmodels
#> ℹ Evaluating 100 models on 60 documents and 2654 features (q = 0.95, alpha = 0.01, selection = sequential)
#> ✔ Optimal model has 34 topics (selected by sequential adequacy scan at alpha = 0.01)
#> ℹ Completed in 0.93s
# Use the global minimum approach
chi_min <- optop_select(topic_models = VEM_models,
                        weighted_dfm = weighted_dfm,
                        selection = "min",
                        q = 0.95,
                        alpha = 0.01,
                        do_plot = FALSE)
#> ── Optimal topic selection ──
#> ℹ Evaluating 100 models on 60 documents and 2654 features (q = 0.95, alpha = 0.01, selection = min)
#> ✔ Optimal model has 96 topics (selected by global minimum)
#> ℹ Completed in 0.7s

Reading the Result

optop_select() returns a data.table with one row per model:

head(chi_seq)
#>    topic    OpTop     df  pval
#>    <num>    <num>  <num> <num>
#> 1:     2 4.495543 127613     0
#> 2:     4 3.093535  93766     0
#> 3:     6 2.707646  85074     0
#> 4:     8 2.513613  79078     0
#> 5:    10 2.313265  74733     0
#> 6:    12 2.131487  71409     0
  • OpTop is the standardized Test 1 statistic, the raw chi-square of Equation (3) divided by its degrees of freedom, and the quantity plotted in the paper’s Figure 2. Under an adequate model it fluctuates around 1.
  • df is P_J = \sum_j P_j, the total number of relatively important words across documents.
  • pval is the upper-tail p-value of the raw statistic. Small values indicate that the observed discrepancy is larger than sampling noise can explain, so the K-topic model is rejected.

The selection rule affects which K is reported as optimal (and the plot guides), never the table itself. The two calls above return identical tables:

all.equal(chi_seq, chi_min)
#> [1] TRUE

The Two Selection Rules

Both rules are direct computations on the returned table and can be stated explicitly:

alpha <- 0.01

# "sequential": the smallest K the test fails to reject
seq_pick <- chi_seq[pval > alpha, topic][1]

# "min": the K with the minimum standardized statistic
min_pick <- chi_seq[which.min(OpTop), topic]

c(sequential = seq_pick, min = min_pick)
#> sequential        min 
#>         34         96

selection = "sequential" implements the classical sequential scan for model order: K is increased until the first model the test fails to reject (pval > alpha). The rationale is parsimony: the rule selects the smallest K that the data cannot distinguish from adequate. On this corpus the sequential rule selects K = 34. If every model were rejected, optop_select() would fall back to the global minimum with a warning.

selection = "min" reproduces the published case study: the optimum is identified as the K-topic model with the minimum standardized statistic across the grid. Here the minimum is at K = 96, close to the 92 topics the paper identifies on the untrimmed vocabulary of the same corpus and, as the sweep below shows, insensitive to the choice of q. One practical remark is in order: the minimum rule requires a grid that extends beyond the flattening of the curve. On a grid truncated at K = 100 the minimum sits at the boundary, and only the full scan up to K = 200 allows it to stabilize. The behavior has a structural explanation. The df column decreases steadily with K because the envelopes shrink as the fitted distributions concentrate, and since the null level of the standardized statistic scales with the envelope size, part of the curve’s long decline reflects envelope shrinkage rather than genuine improvement in fit. The calibrated p-values of the next section absorb this effect automatically; the raw curve does not. The sequential rule, by construction, stops at the first adequate K and is unaffected by the extent of the grid.

The p-value column explains when and why the two rules diverge:

summary(chi_seq$pval)
#>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
#>    0.00    1.00    1.00    0.83    1.00    1.00

With df of the order of 27,764, the chi-square reference distribution is extremely concentrated, so p-values saturate: models whose standardized statistic is materially above 1 receive p-values of essentially 0, and models at or below 1 receive p-values of essentially 1. When every model is rejected, the sequential rule falls back to the global minimum with a warning; when several models are accepted, it stops at the smallest, which may precede the minimum by a wide margin. This is a documented calibration caveat of the chi-square reference (see ?optop_select): in saturated regimes the information is carried by the shape of the OpTop curve and by its minimum.

Because the return value is a table, custom figures follow directly:

ggplot(chi_seq, aes(x = topic, y = OpTop)) +
  geom_line(linewidth = 0.8, color = "royalblue") +
  geom_vline(xintercept = seq_pick, linetype = 2) +
  geom_vline(xintercept = min_pick, linetype = 3) +
  annotate("point", x = min_pick, y = min(chi_seq$OpTop),
           color = "red", shape = 4, size = 4) +
  labs(x = "Topics", y = expression(OpTop[J]^{K}),
       title = "Standardized Test 1 statistic across the grid",
       subtitle = sprintf("sequential pick: K = %d (dashed) | global minimum: K = %d (dotted)",
                          seq_pick, min_pick)) +
  theme_bw()

Calibrated p-Values

The saturation above has a precise cause: the \chi^2_{P_J} reference is an asymptotic guide rather than the exact null law of the statistic. Write k_j = P_j + 1 for the number of envelope bins of document j and N_j for its length in tokens. The classical Pearson statistic on counts, X^2_j = N_j \sum_b (d_{jb} - p_{jb})^2 / p_{jb}, is the object with the textbook \chi^2_{k_j - 1} asymptotics; the document term of Equation (3) is instead

S_j = (P_j + 1) \sum_b \frac{(d_{jb} - p_{jb})^2}{p_{jb}} = \frac{k_j}{N_j}\, X^2_j , \tag{4}

which differs from the calibrated object by the factor k_j / N_j. In addition, the fitted probabilities are estimated from the same data they are tested against. Calibration therefore replaces the \chi^2 reference with the distribution of T = \sum_j S_j under the conditional fitted-model null

c_j \sim \mathrm{Multinomial}\bigl(N_j,\; I_j^K\bigr), \qquad j = 1, \dots, J, \tag{5}

with \hat\theta and \hat\phi held fixed at their estimates. Two properties make this exact and fast: the envelope depends only on the model, never on the data, and a multinomial collapsed over bins is multinomial on the collapsed probabilities. The null replicates can therefore be drawn directly on the k_j bins p_j = (I^K_{j1}, \dots, I^K_{jP_j}, I^K_{j,\min}), which is equivalent to simulating entire documents over the vocabulary at a small fraction of the cost. The only new ingredient the user supplies is the vector of document lengths N_j:

doc_lens <- quanteda::ntoken(mydfm_sub)

chi_cal <- optop_select(topic_models = VEM_models,
                        weighted_dfm = weighted_dfm,
                        q = 0.95,
                        alpha = 0.01,
                        calibrate = "bootstrap",
                        n_boot = 200,
                        doc_lengths = doc_lens,
                        seed = 20260703,
                        do_plot = FALSE)
#> ── Optimal topic selection ──
#> ℹ Evaluating 100 models on 60 documents and 2654 features (q = 0.95, alpha = 0.01, selection = sequential)
#> ℹ Calibrating p-values by parametric bootstrap (B = 200, seed = 20260703) under the fitted-model null
#> ✔ Optimal model has 8 topics (selected by sequential adequacy scan at alpha = 0.01, bootstrap-calibrated)
#> ℹ Completed in 46.83s

With calibrate = "bootstrap", B = n_boot null replicates T^{*}_1, \dots, T^{*}_B are drawn as above and the reported p-value is the empirical upper tail with the standard finite-sample correction,

\hat{p} = \frac{1 + \#\{\, b : T^{*}_b \ge T \,\}}{B + 1}, \tag{6}

so alpha is a genuine Type-I error rate with respect to the conditional null, at resolution 1/(B+1). The bootstrap is the most expensive stage of the pipeline; its cost, and the effect of the n_threads argument, are quantified in the section A Note On Computational Efficiency. The table now carries two p-value columns: pval (the calibrated value used by the selection rules) and pval_chisq (the asymptotic value, kept for comparison):

head(chi_cal)
#>    topic    OpTop     df        pval pval_chisq
#>    <num>    <num>  <num>       <num>      <num>
#> 1:     2 4.495543 127613 0.004975124          0
#> 2:     4 3.093535  93766 0.004975124          0
#> 3:     6 2.707646  85074 0.004975124          0
#> 4:     8 2.513613  79078 0.119402985          0
#> 5:    10 2.313265  74733 0.004975124          0
#> 6:    12 2.131487  71409 0.014925373          0

The bootstrap is the reference method; calibrate = "moment" targets the same null in closed form. Haldane (1937) gives the exact multinomial moments of the count-based Pearson statistic over k_j bins:

\mathrm{E}\bigl[X^2_j\bigr] = k_j - 1, \tag{7a}

\begin{split} \mathrm{Var}\bigl[X^2_j\bigr] &= 2(k_j - 1) \\ &\quad + \frac{\sum_b 1/p_{jb} - k_j^2 - 2k_j + 2}{N_j}. \end{split} \tag{7b}

Since S_j = (k_j/N_j)\,X^2_j and documents are independent, the null mean and variance of T follow by scaling and summing, \mu = \sum_j (k_j/N_j)(k_j - 1) and \sigma^2 = \sum_j (k_j/N_j)^2\,\mathrm{Var}[X^2_j], and the reference is the Satterthwaite scaled chi-square matching them:

\begin{gathered} T \overset{\text{approx}}{\sim} a\,\chi^2_\nu, \qquad a = \frac{\sigma^2}{2\mu}, \qquad \nu = \frac{2\mu^2}{\sigma^2}, \\ \hat{p} = \Pr\!\left(\chi^2_\nu \ge T/a\right). \end{gathered} \tag{8}

The method requires no simulation and no seed; it is a fast approximation that corrects the location and the scale of the reference, although not its higher moments:

chi_cal_mm <- optop_select(topic_models = VEM_models,
                           weighted_dfm = weighted_dfm,
                           q = 0.95,
                           alpha = 0.01,
                           calibrate = "moment",
                           doc_lengths = doc_lens,
                           do_plot = FALSE)
#> ── Optimal topic selection ──
#> ℹ Evaluating 100 models on 60 documents and 2654 features (q = 0.95, alpha = 0.01, selection = sequential)
#> ℹ Calibrating p-values by moment matching (Satterthwaite scaled chi-square) under the fitted-model null
#> ✔ Optimal model has 8 topics (selected by sequential adequacy scan at alpha = 0.01, moment-calibrated)
#> ℹ Completed in 0.79s

The following figure compares the three reference distributions:

pv <- melt(
  data.table(topic = chi_cal$topic,
             `chi-square` = chi_cal$pval_chisq,
             bootstrap = chi_cal$pval,
             moment = chi_cal_mm$pval),
  id.vars = "topic", variable.name = "reference", value.name = "pvalue"
)

ggplot(pv, aes(x = topic, y = pvalue, color = reference)) +
  geom_line(linewidth = 0.7) +
  geom_hline(yintercept = alpha, linetype = 3) +
  labs(x = "Topics", y = "p-value",
       title = "P-values under the three reference distributions",
       subtitle = sprintf("dotted line: alpha = %.2f", alpha)) +
  theme_bw()

Because the p-values themselves do not depend on alpha, the conventional thresholds can be scanned directly on the tables already computed:

alphas <- c(0.01, 0.05, 0.10)

picks_alpha <- data.table(
  alpha = alphas,
  bootstrap = sapply(alphas, function(a) chi_cal[pval > a, topic][1]),
  moment = sapply(alphas, function(a) chi_cal_mm[pval > a, topic][1]),
  `chi-square` = sapply(alphas, function(a) chi_seq[pval > a, topic][1])
)

knitr::kable(picks_alpha,
             caption = "Sequential pick by reference distribution as alpha varies")
Sequential pick by reference distribution as alpha varies
alpha bootstrap moment chi-square
0.01 8 8 34
0.05 8 8 34
0.10 8 14 34

Interpretation follows from the null the calibration targets. The calibrated sequential pick answers a precise question: what is the smallest K whose residual discrepancy could plausibly be produced by multinomial sampling noise alone, if the fitted model were the truth? On this corpus this happens already at K = 8. The fit does continue to improve beyond that point, since the OpTop curve decreases until its minimum at K = 96, but from there onward the improvements are smaller than what sampling noise can account for at the chosen alpha.

The uncalibrated scan is best understood as a reduced-chi-square heuristic. With df this large the asymptotic p-values saturate at 0 or 1, so in practice the rule stops at the first K whose standardized discrepancy falls below 1. This is a reasonable descriptive criterion, but its implicit threshold is not tied to the actual null level of the statistic. On this corpus the bootstrap places that null level closer to 2 than to 1, so the heuristic demands more than noise-level adequacy and stops later, at K = 34. The selection is also unstable: on this grid the next model, K = 36, is rejected again, and the sweep of q below moves the pick from 20 to 38. When the implicit threshold happens to lie close to the true null level, the same heuristic performs well; the simulation study at the end of this vignette, where the ground truth is known, is such a case. What calibration adds is therefore not a different answer but a guarantee. Raising alpha demands stronger evidence of adequacy, so the pick moves weakly upward, as the table shows, and alpha becomes a genuine Type-I error rate with respect to the fitted-model null, at bootstrap resolution 1 / (n_boot + 1). In summary, the calibrated sequential rule identifies the most parsimonious statistically adequate K, while the global-minimum rule and the discrepancy indices below quantify maximal descriptive fit. The two questions are distinct.

One caveat applies to both calibrations: they hold the estimated theta and phi fixed, so they do not account for estimation noise in the LDA parameters themselves (see ?optop_select).

The Role of q

q controls the per-document envelope introduced in Equations (2a) and (2b): the fitted word probabilities are sorted in decreasing order and the smallest leading set whose cumulative mass exceeds q is retained, while the relatively unimportant words are collapsed into a single bin whose mass remains strictly below 1 - q. In the paper’s notation q = 1 - I^K, and its numerical setting I^K = 0.05 corresponds to the default q = 0.95.

Re-running the test across a sweep of q values is a direct sensitivity check:

q_grid <- c(0.80, 0.90, 0.95, 0.99)

chi_by_q <- lapply(q_grid, function(q) {
  optop_select(VEM_models, weighted_dfm, q = q, alpha = alpha,
               do_plot = FALSE)
})
names(chi_by_q) <- sprintf("q%.2f", q_grid)
q_curves <- rbindlist(
  lapply(names(chi_by_q), function(nm) {
    data.table(q = as.numeric(sub("q", "", nm)), chi_by_q[[nm]])
  })
)

ggplot(q_curves, aes(x = topic, y = OpTop, color = factor(q))) +
  geom_line(linewidth = 0.7) +
  labs(x = "Topics", y = expression(OpTop[J]^{K}), color = "q",
       title = "Standardized Test 1 statistic across the q sweep") +
  theme_bw()

Applying the two selection rules to each element of the sweep shows how the selected K responds to q:

picks_by_q <- data.table(
  q = as.numeric(sub("q", "", names(chi_by_q))),
  sequential = sapply(chi_by_q, function(tab) tab[pval > alpha, topic][1]),
  min = sapply(chi_by_q, function(tab) tab[which.min(OpTop), topic])
)

knitr::kable(picks_by_q, caption = "Selected K by rule as q varies")
Selected K by rule as q varies
q sequential min
0.80 20 96
0.90 26 96
0.95 34 96
0.99 38 96

Larger q retains more words per document, so the degrees of freedom grow and the envelope covers more of each document’s distribution, while smaller q makes the comparison coarser and computationally cheaper. The sequential pick moves with q, whereas the global minimum is unaffected. The default q = 0.95 follows the paper.

Goodness-of-Fit: The Discrepancy Indices

The second family of tools asks a complementary question: not which K is optimal but how much of the word-count structure a given model explains. Following Lewis and Grossetti (2026), each index measures the proportional reduction in discrepancy achieved by a K-topic model relative to the no-topics baseline in which every document follows the corpus word distribution. The comparison is carried out on a harmonized support: a fixed, document-specific set of rare words, computed as a union over the whole model grid and the baseline, is collapsed so that every K is evaluated on the same bins. Because the harmonized set depends on the grid and on the threshold constant c, indices are comparable across studies only under a common pair; the default c = 1 follows the paper’s recommendation for deviance-primary work. The indices consume raw counts:

dtm_counts <- methods::as(mydfm_sub, "CsparseMatrix")
partition <- optop_make_partition(VEM_models, dtm_counts, c = 1)
baseline <- optop_make_baseline(dtm_counts)

idx_tab <- optop_index_table(VEM_models, dtm_counts,
                             metrics = c("se", "chisq", "deviance"),
                             partition = partition, baseline = baseline,
                             macro = TRUE)
idx <- melt(idx_tab,
            id.vars = "K",
            measure.vars = c("R2_SE", "R2_chisq", "R2_dev"),
            variable.name = "metric", value.name = "R2")

ggplot(idx, aes(x = K, y = R2, color = metric)) +
  geom_line(linewidth = 0.7) +
  labs(x = "Topics", y = expression(R^2),
       title = "Discrepancy indices across the grid (document level, micro)") +
  theme_bw()

All three indices are regression-style R-squared analogues: 0 indicates that the model explains no more than the corpus-wide baseline, and 1 indicates a perfect account of the harmonized word counts. The deviance index is bounded above by one, while the Pearson and squared-error indices are not confined to [0, 1]; a negative value is informative, since it indicates that the fitted model performs worse than the baseline under that discrepancy. The indices typically rise steeply at small K and flatten once additional topics no longer provide significant incremental explanatory power; the region where the curves flatten corresponds to the neighborhood in which Test 1 locates the optimum. These full-corpus values are descriptive summaries of in-sample fit and should not be the sole basis for choosing K.

The indices are also available at the word level, computed on the unbinned vocabulary, which serves as a diagnostic: it identifies the words the selected model captures well and those it misses. For the sequential pick (K = 34), the micro word-level chi-square index is 0.867; the ten highest- and lowest-index words are reported below:

knitr::kable(
  data.frame(
    `best words` = names(word_r2$best),
    R2_best = round(unname(word_r2$best), 3),
    `worst words` = names(word_r2$worst),
    R2_worst = round(unname(word_r2$worst), 3),
    check.names = FALSE
  ),
  caption = sprintf("Word-level chi-square R2 at K = %d: the ten best and worst captured words",
                    word_r2$k)
)
Word-level chi-square R2 at K = 34: the ten best and worst captured words
best words R2_best worst words R2_worst
aggregate 1 basic -0.007
statutes 1 rights 0.296
enactment 1 called 0.353
whatsoever 1 law 0.408
revision 1 form 0.454
operations 1 fulfillment 0.458
roman 1 without 0.474
interstate 1 already 0.494
type 1 safe 0.528
eighteenth 1 many 0.535

The Null-Discrepancy Floor

Each document-level index is a ratio, and its denominator is itself a measured quantity: the discrepancy of the no-topics baseline on that document. For almost every document the denominator is comfortably large. The exception is a document whose entire support is rare relative to its length: at partition resolution every one of its words falls into the collapsed min bin, and there the observed bin mass equals the baseline bin mass by construction. The baseline discrepancy is then numerically zero in every family at once, and the ratio degenerates; such a document contributes an arbitrarily wild value to the Macro average while carrying no usable information about fit.

The index functions therefore restrict the Macro aggregation to the documents whose baseline discrepancy clears a floor, D_j(\mathrm{null}) \ge min_null, and report every exclusion. The default floor is the partition constant c, the same yardstick that defines the harmonized support: a discrepancy below the resolution of a single expected count is indistinguishable from rounding noise. Setting min_null = 0 restores the strict-positivity rule of versions before 0.14.1 exactly.

dev_floor <- optop_index_deviance(m_star, dtm_counts, partition, baseline,
                                  macro = TRUE)
dev_floor$n_null_excluded

The inaugural corpus is not a contrived example. At the sequential pick (K = 34) the floor excludes 1 speech (1.7% of the corpus): 1793-Washington, Washington’s famously brief second inaugural, 53 tokens after preprocessing. Every one of its surviving words is rare at the partition resolution \tau_j = c / L_j, the whole document lives in the collapsed min bin, and its baseline deviance evaluates to exactly 0: the ratio is 0/0 and carries no information about fit. The Macro index over the remaining speeches is 0.7901.

The strict-positivity rule of versions before 0.14.1 happens to drop an exact zero as well, but silently, and only by the sign luck of floating-point arithmetic: a collapsed document landing a hair above zero would have stayed in, free to contribute a ratio in the thousands to the Macro average. The floor replaces that accident with a rule stated in the units of the analysis (one expected count) and reports every exclusion through the accompanying alert, so a silent change of estimand can never happen. The Micro index is nearly immune either way: its weights are proportional to the baseline discrepancies, so a near-collapsed document has almost no say in it.

Held-Out Validation and Topic-Number Choice

The indices above are computed on the same documents used to estimate the models, so they describe in-sample fit and are optimistic by construction: every additional topic gives the model more freedom to adapt to the estimation sample, whether or not the gain reflects structure that generalizes. Formal inference on fit, and a topic-number choice that can be defended statistically, require documents the models have never seen (Lewis and Grossetti, 2026).

We therefore hold out 15 of the 60 speeches, chosen at random under a fixed seed, and refit the grid on the remaining 45 alone, for K = 2 to 60 in steps of 2. The vocabulary is fixed by the training split; the 7 word types that occur only in held-out speeches (about 0.4% of the evaluation tokens) are collapsed into a synthetic out-of-vocabulary bin that every model treats as rare. The corpus baseline is likewise estimated on the training split, so nothing in the evaluation documents influences the fitted models or the targets they are compared against.

set.seed(20260709)
n_docs <- quanteda::ndoc(mydfm_sub)
eval_idx <- sort(sample(n_docs, round(0.25 * n_docs)))
dfm_eval <- mydfm_sub[eval_idx, ]
dfm_train <- quanteda::dfm_trim(mydfm_sub[-eval_idx, ], min_termfreq = 1)

VEM_train <- lapply(seq(2, 60, by = 2), function(k) {
  topicmodels::LDA(x = dfm_train, k = k, control = list(seed = 500 + k))
})

base_train <- optop_make_baseline(dfm_train)
ho <- optop_index_holdout(VEM_train, dfm_eval, base_train)

optop_index_holdout() gives each evaluation document its topic weights under the trained topics held fixed, the fold-in step, reconstructs its word distribution, and scores the reconstruction with the same discrepancy indices as above, on a rare-word partition built from the training baseline. Because the evaluation documents are independent of the fitted models, the per-document indices support genuine confidence statements. The figure reports the Macro deviance index, the unweighted average of the per-document indices, with its 95% confidence band across the grid:

ho_dev <- ho_res$summary[ho_res$summary$metric == "deviance", ]

ggplot(ho_dev, aes(x = K, y = macro)) +
  geom_ribbon(aes(ymin = ci_lo, ymax = ci_hi), fill = "grey80") +
  geom_line(linewidth = 0.7) +
  labs(x = "Topics", y = expression(R^2),
       title = "Held-out Macro deviance index with 95% confidence band") +
  theme_bw()

The band quantifies the uncertainty in the average held-out fit across evaluation documents, conditional on the trained models. The summary table also reports the Micro index, which weights each document by its baseline discrepancy, and the Micro-Macro gap with its standard error; a gap far from zero indicates that fit quality varies systematically with document weight.

The curve flattens quickly, and reading a single best K off it would ignore the sampling noise. optop_gain_table() makes the choice formal: for each adjacent pair of topic counts it computes the paired per-document improvement in the held-out index, its one-sided upper confidence bound, and a test of the hypothesis that the improvement is zero.

gt <- optop_gain_table(ho, metric = "deviance", epsilon = 0.01)
show_upto <- if (identical(ho_res$selection_status, "selected")) {
  selected_gain <- match(ho_res$k_hat, ho_res$gains$K)
  min(nrow(ho_res$gains), selected_gain + 2L)
} else {
  min(nrow(ho_res$gains), 6L)
}
knitr::kable(
  ho_res$gains[seq_len(show_upto), ],
  digits = 4,
  caption = sprintf("Paired adjacent gains in the held-out deviance index (first %d of %d rows)",
                    show_upto, nrow(ho_res$gains))
)
Paired adjacent gains in the held-out deviance index (first 6 of 29 rows)
K succ_K n gain sd upper z pval
2 4 15 0.1271 0.1442 0.1883 3.4136 0.0003
4 6 15 0.0042 0.0299 0.0169 0.5511 0.2908
6 8 15 0.0211 0.0271 0.0327 3.0169 0.0013
8 10 15 -0.0153 0.0374 0.0006 -1.5861 0.9436
10 12 15 0.0056 0.0404 0.0228 0.5405 0.2944
12 14 15 0.0081 0.0302 0.0209 1.0364 0.1500

Each row compares K with its successor on the same evaluation documents: gain is the average paired improvement, upper its one-sided 95% upper confidence bound, and pval tests whether the improvement exceeds zero. The selection rule fixes a tolerance epsilon, the improvement judged too small to matter, before looking at the results, and picks the smallest K at which even the optimistic end of the next gain falls below it.

With epsilon = 0.01, the rule selects K = 8: the step out of 8 is the first whose upper bound falls below the tolerance, so no gain of practical size is compatible with the data at that step.

A scalar index says how much error remains, not where. The moment tests of Lewis and Grossetti (2026) complement it by grouping the vocabulary into labeled word sets built from the training sample, the instruments, and testing whether the model consistently over- or under-predicts any group on the evaluation documents. Here we stratify the vocabulary by training frequency and compare each stratum against the highest-frequency reference:

k_diagnostic <- gt$k_hat
if (is.na(k_diagnostic)) {
  dev <- ho$summary[ho$summary$metric == "deviance", ]
  k_diagnostic <- dev$K[which.max(dev$macro)]
}
m_star <- VEM_train[[which(seq(2, 60, by = 2) == k_diagnostic)]]
mt <- optop_moment_test(list(m_star), dfm_eval, dfm_train,
                        type = "strata", bins = 5)
knitr::kable(
  ho_res$moment_marginal,
  digits = c(0, 5, 5, 2, 4, 4),
  format.args = list(scientific = FALSE),
  caption = sprintf("Marginal frequency-strata moments at K = %d",
                    ho_res$k_diagnostic)
)
Marginal frequency-strata moments at K = 8
stratum estimate se t pval pval_adj
freq_1_vs_5 0.00013 0.00002 5.26 0.0000 0.0000
freq_2_vs_5 0.00003 0.00002 1.25 0.2124 0.2124
freq_3_vs_5 0.00002 0.00002 1.00 0.3193 0.3193
freq_4_vs_5 0.00002 0.00002 0.71 0.4787 0.4787

The joint Wald statistic at K = 8 is 267.26 on 4 degrees of freedom (p < 1e-15); with 15 evaluation documents the chi-square reference is an asymptotic yardstick, and the marginal t statistics are the more stable guide. In the marginal table, estimate is an effect size in probability-mass units: a positive value means the held-out documents place more mass on that frequency stratum than the model predicts, relative to the reference stratum, and a negative value the reverse. Here the largest imbalance sits in the lowest-frequency stratum, with an estimate of about 0.00013 in probability mass: statistically unambiguous, yet small in practical terms. Reading the estimates alongside the p-values in this way separates detectable imbalances from practically negligible ones.

Three caveats close the discussion of the single split. First, all confidence statements are conditional on the trained models: they quantify variability across evaluation documents, not across refits of the grid. Second, selecting K on this same evaluation sample, or choosing a diagnostic K from it when the adequacy rule does not select one, makes the reported diagnostic descriptive and subject to post-selection bias. A study that needs both a data-driven choice and clean inference at that choice should select on one evaluation sample and report on another, or rotate the roles of the splits. Third, a moment test that fails to reject does not certify the model class; it states that no systematic residual imbalance is detectable along the chosen instruments at the available sample size. The first two caveats have a standard remedy, and the package implements it.

V-Fold Cross-Fitting

optop_crossfit() rotates the roles of the splits systematically (Appendix B of the paper): the documents are dealt into V length-balanced folds, the model grid is refit on each fold’s complement by a fitting function you supply, and each fold is scored held-out through exactly the machinery of the previous subsections. Every document is thus evaluated once, by models that never saw it, and no single arbitrary split decides the analysis.

fit_vem <- function(dtm_train, k) {
  topicmodels::LDA(quanteda::convert(quanteda::as.dfm(as.matrix(dtm_train)),
                                     to = "topicmodels"),
                   k = k, method = "VEM", control = list(seed = 3000 + k))
}
cf <- optop_crossfit(dtm_counts, K = c(10, 20, 30, 40, 50),
                     fit_fun = fit_vem, V = 5,
                     metrics = "deviance", seed = 20260722)
plot(cf)

Two things change relative to a single split. Point estimates pool the out-of-fold scores of the whole corpus, so no document is spent on training only. And the standard errors are fold-replicate estimates: the V per-fold values of each quantity are treated as exchangeable replicates, so the reported uncertainty includes the fold-to-fold variation induced by refitting, precisely what the first caveat said a single split cannot see. The price is that only V replicates feed each standard error, which is why small corpora get a warning about noisy fold means.

On the inaugural corpus (60 speeches, V = 5, coarse grid K = 10, 20, 30, 40, 50), the cross-fitted adequacy rule at epsilon = 0.01 selects K = 30: the first step out of that grid point has a fold-replicate upper bound below the tolerance. At K = 30 the pooled Macro deviance index is 0.2007 with a fold-replicate standard error of 0.0167. The single-split analysis above worked on a finer grid, so the two analyses answer slightly different questions; what matters is that the cross-fitted result is reproducible under the fold seed and spends every document on both sides of the fence exactly once.

A Simulation with Known Truth

In empirical corpora the true number of topics is unknown. Following the paper, we therefore validate the procedure through a simulation study in which the ground truth is known: one corpus is simulated from the LDA generative process itself, with 100 documents over 1500 words drawn from 10 topics, using the package’s own sim_dfm() generator, and a grid of models is re-estimated around the truth.

rdirichlet <- function(n, alpha) {
  g <- matrix(rgamma(n * length(alpha), shape = alpha, rate = 1),
              nrow = n, byrow = TRUE)
  g / rowSums(g)
}

set.seed(20260703)
true_K <- 10L
Theta_true <- rdirichlet(100L, rep(0.3, true_K))    # documents x topics
Beta_true <- rdirichlet(true_K, rep(0.05, 1500L))   # topics x words
doc_len <- sample(500:1500, 100L, replace = TRUE)

sim_corpus <- sim_dfm(DTW = Theta_true, TWW = Beta_true,
                      doc_length = doc_len, seed = 42)

sim_models <- parallel::mclapply(
  seq(2, 20, by = 2),
  function(k) topicmodels::LDA(sim_corpus, k = k,
                               control = list(seed = 2000 + k)),
  mc.cores = ncores
)

sim_chi <- optop_select(sim_models,
                        quanteda::dfm_weight(sim_corpus, scheme = "prop"),
                        q = 0.95, alpha = alpha, verbose = TRUE)
ggplot(sim_chi, aes(x = topic, y = OpTop)) +
  geom_line(linewidth = 0.8, color = "royalblue") +
  geom_vline(xintercept = meta$true_K, color = "red", linetype = 1) +
  geom_vline(xintercept = sim_picks$sequential, linetype = 2) +
  geom_vline(xintercept = sim_picks$min, linetype = 3) +
  labs(x = "Topics", y = expression(OpTop[J]^{K}),
       title = "Simulated corpus with ground truth K = 10 (solid red)",
       subtitle = sprintf("sequential pick: K = %d (dashed) | global minimum: K = %d (dotted)",
                          sim_picks$sequential, sim_picks$min)) +
  theme_bw()

sim_idx <- melt(sim_idx_tab,
                id.vars = "K",
                measure.vars = c("R2_SE", "R2_chisq", "R2_dev"),
                variable.name = "metric", value.name = "R2")

ggplot(sim_idx, aes(x = K, y = R2, color = metric)) +
  geom_line(linewidth = 0.7) +
  geom_vline(xintercept = meta$true_K, color = "red", linetype = 1) +
  labs(x = "Topics", y = expression(R^2),
       title = "Discrepancy indices on the simulated corpus") +
  theme_bw()

The statistic bends in the neighborhood of the true K and the indices stop improving there, the same qualitative behavior reported in the paper’s simulation study (its Figure 7). As the paper acknowledges, the identified optimum is not always exactly K_{sim} but is typically adjacent or in close proximity to it: here the sequential rule stops at K = 8 with the truth at 10, while the global minimum moves toward the grid boundary as the curve continues to decrease past the bend. Note that this is the uncalibrated sequential rule performing well. On this corpus the post-truth plateau of the standardized statistic lies below 1, so the implicit reduced-chi-square threshold of the saturated p-values falls close to the actual null level, which is the favorable case discussed in the calibration section. The tools should be read as locating the region of adequate K, with the discrepancy indices quantifying the incremental explanatory power of each candidate.

Other Engines and Bare Matrices

Everything above ran on topicmodels fits, but nothing in the method is tied to an estimator. Fits from topicmodels (VEM and Gibbs, and CTM), seededlda, and NLPstudio are recognized directly: pass them to any OpTop function and the adapter layer extracts the two weight matrices behind the scenes. Two more doors cover everything else.

text2vec WarpLDA. The WarpLDA sampler splits its output in two: fit_transform() returns the document-topic matrix while the model object retains only the topic-word distribution. optop_warplda() bundles the two halves into one object that behaves like any supported fit, held-out fold-in included:

lda <- text2vec::LDA$new(n_topics = k_star)
theta <- lda$fit_transform(dtm_counts, n_iter = 500, progressbar = FALSE)
fit <- optop_warplda(lda, theta)

warp_part <- optop_make_partition(list(fit), dtm_counts, c = 1)
warp_dev <- optop_index_deviance(fit, dtm_counts, warp_part, baseline,
                                 macro = TRUE)

Run on the inaugural corpus at K = 34, the WarpLDA fit reaches a micro deviance index of 0.3938 against 0.8804 for the VEM fit of the same size: the indices compare engines on one scale, which is precisely their purpose.

Any other engine. If your estimator only hands you matrices, wrap them yourself: optop_model() validates the full contract (named documents and terms, nonnegative weights, unit row sums) and returns an object every in-sample tool accepts.

rdirich <- function(n, k) {
  g <- matrix(stats::rgamma(n * k, shape = 1), n, k)
  g / rowSums(g)
}
set.seed(11)
theta_toy <- rdirich(5, 2)
rownames(theta_toy) <- sprintf("doc%d", 1:5)
phi_toy <- rdirich(2, 8)
colnames(phi_toy) <- sprintf("w%02d", 1:8)

m_toy <- optop_model(theta_toy, phi_toy)
m_toy
#> <optop_theta_phi>: a topic model for OpTop
#> K = 2 topics, 5 documents, 8 terms
#> theta: 5 x 2, phi: 2 x 8; rows sum to 1

One limitation is structural: a bare matrix pair carries no fitted engine, so new documents cannot be folded in and the held-out tools refuse an optop_model() with a pointed message. Held-out evaluation always wants the engine’s own object.

A closing practical note: OpTop aligns by term name, never by position. If the document-term matrix and the models drift apart (a trimmed feature, a reordered vocabulary), the index functions stop and point at optop_align_dtm_to_models(), which subsets and reorders the counts to the grid’s common vocabulary; recompute the partition and the baseline on the aligned matrix afterwards.

A Note On Computational Efficiency

Both computational stages of optop_select() are implemented in compiled code and, as of v0.12.0, run in parallel. The same design extends to the goodness-of-fit tools presented above, whose partition and index computations are evaluated by a fused compiled engine. This section describes the implementations and reports a simulation study of their speed, thread scaling, and memory use.

The statistic. For each model, the fitted word probabilities of a block of 256 documents are obtained with a single BLAS matrix product, since the per-document algebra of Equation (3) reduces to one row of the matrix product \theta^K \phi^K. The remaining per-document work (a descending sort, a cumulative sum, and a scalar Pearson reduction), which profiling identified as the dominant cost once the algebra had been reduced to a matrix product, is parallelized over the documents of each block with OpenMP.

The bootstrap. Calibration is the most expensive stage: it draws B null replicates of the statistic per model, each requiring one multinomial draw per document. Since v0.12.0 the draws are generated in compiled code and fused with the Pearson reduction, so that no intermediate count matrices are materialized, and the document loop is parallelized. The sampler is chosen per document from the data alone: when the envelope holds more bins than the document holds tokens, the common regime on large vocabularies, the tokens are drawn directly through an alias table at O(N_j) per replicate and the untouched bins contribute a closed-form Pearson term; otherwise the conditional-binomial method is used. Each document draws from a private random-number stream seeded deterministically from the pair (seed, document index), and all reductions are performed in a fixed order. Two properties follow: the output is bit-identical for every value of n_threads, which therefore affects wall time only, and the calibrated p-values are reproducible under seed.

The study below evaluates VEM grids of ten models (five on the largest corpus) on four synthetic corpus scales, from J = 200 documents with W = 2,000 features up to J = 10,000 documents with W = 20,000 features, timing the statistic pass, the bootstrap-calibrated pass (B = 200), and the goodness-of-fit evaluation for one, two, and four threads, with the corresponding pure-R implementations as baselines. The sweep ends at four threads because that is the host’s logical core count; thread settings beyond the physical cores only measure oversubscription, so the benchmark driver caps the sweep at the core count and extends it automatically on machines with more cores. Peak memory is the maximum resident set size reported by the operating system for a fresh R process per configuration. The study is reproducible via data-raw/benchmark-efficiency.R; timings are machine-dependent (recorded here: Intel(R) Xeon(R) Processor @ 2.10GHz, 4 logical cores, R version 4.3.3 (2024-02-29)).

Wall time (seconds) of the bootstrap-calibrated pass across the grid, by thread count, with the 0.11.0 R implementation as baseline
scale J W R bootstrap (0.11.0) C++ bootstrap, 1 thread C++ bootstrap, 2 threads C++ bootstrap, 4 threads
small 200 2000 33.5 18.1 9.2 4.7
medium-1 500 5000 165.9 31.3 16.0 8.7
medium-2 1000 10000 579.2 67.0 36.1 20.5
large 10000 20000 6407.4 421.0 232.8 141.9
Wall time (seconds) of the statistic pass across the grid, by thread count
scale J W 1 thread 2 threads 4 threads
small 200 2000 0.37 0.26 0.18
medium-1 500 5000 2.29 1.34 0.81
medium-2 1000 10000 9.36 5.19 3.20
large 10000 20000 109.92 61.66 37.16

Three regularities hold across the scales. First, the compiled bootstrap is 1.9–15.2× faster than the R implementation before any threading, and the gain grows with the vocabulary: the alias-table path costs O(N_j) per replicate where the bin-wise method costs O(k_j), and wide corpora have k_j \gg N_j. Second, the parallel document loop scales almost linearly in the number of physical cores: the best configuration on the largest corpus (4 threads) runs the calibrated pass 45.2× faster than the 0.11.0 baseline, falling from 106.8 minutes to 142 seconds. The statistic pass shares the document-level parallelism and gains 3.0× at its best thread setting on the same corpus; its speedup is sub-linear because the BLAS product remains serial. Third, peak memory is governed by the objects the evaluation must hold, namely the fitted models and, on the largest corpus, the exported envelope bins, rather than by the sampling itself: the two implementations stay within roughly 15% of one another in either direction, and the per-thread state amounts to one result slot per document, so peak memory does not grow with n_threads.

The discrepancy indices. The goodness-of-fit tools presented above follow the same design. The harmonized partition of optop_make_partition() keeps the running minimum across the model grid one vocabulary block at a time in compiled code, so the full J \times W minimum matrix is never materialized, and the three discrepancy indices are evaluated by a fused engine that computes all requested metrics, for the model and for the no-topics baseline side, in a single traversal of each document block. The evaluation consumes the sparse counts directly, forms the baseline on the fly, and parallelizes over documents under the contract stated above: results are bit-identical for every value of n_threads, exposed on the index functions and on optop_make_partition(). The table reports the wall time of the full evaluation, the partition followed by a three-metric document-level optop_index_table() over the grid, with the vectorized R implementation that preceded the engine as baseline. The rare-word threshold is set so that the harmonized support remains non-trivial at every scale of these synthetic corpora; the computational cost of the evaluation does not depend on that choice.

Wall time (seconds) of the goodness-of-fit evaluation (partition plus three-metric index table) by thread count, with the pre-engine R implementation as baseline
scale J W R base
(pre-engine)
C++ base
1 thread
C++ base
2 threads
C++ base
4 threads
small 200 2000 1.0 0.2 0.2 0.1
medium-1 500 5000 6.4 0.9 0.7 0.7
medium-2 1000 10000 33.1 3.7 3.2 3.0
large 10000 20000 331.3 39.8 35.4 32.9

On the largest corpus the evaluation falls from 5.5 minutes to 32.9 seconds at 4 threads, a 10.1-fold reduction, and peak memory falls from 9.0 GB to 3.2 GB, since neither the minimum matrix nor the dense count and baseline blocks are formed.

On toolchains without OpenMP support (notably the default macOS compiler) the parallel loops run single-threaded; the thread-independent speedup of the compiled bootstrap and of the index engine is retained, while the scaling with n_threads is not. The internal helper OpTop:::optop_openmp_available() reports whether the installed build supports OpenMP.

Very large corpora

As of version 0.15.0 the package evaluates corpora of tens of millions of documents with bounded memory, exactly. Three design elements carry the scaling. First, the corpus crosses the compiled boundary as zero-copy views of the sparse matrix slots, so no copy of the corpus is ever made and its shape is unconstrained. Second, the harmonized partition stores the complement of the rare set, the per-document non-rare word lists, whose total size is bounded by the token count divided by c; the construction evaluates only the candidate cells whose baseline probability clears the document threshold, so its cost scales with the token count rather than with the documents-by-features product, and no dense documents-by-features object of any kind is materialized. Third, corpora larger than a single sparse matrix can hold (the container caps at 2^31 - 1 nonzero entries) are supplied as document shards through optop_corpus(), which every entry point accepts; shards are streamed one at a time, per-document results are bit-identical to an unsharded run, and the calibration bootstrap keys each document’s random stream by its global index. Model grids too large for memory are supplied as loader functions, materialized one model at a time. The package website article OpTop at very large scale is the operations manual for this regime: sharding, loaders, the cache budget, thread and BLAS configuration, and memory planning with the reference timings of the scale benchmark.

Notes

  • The pre-0.9.8 stability helpers and the selection = "legacy" rule were removed in 0.16.0 after a long deprecation; the discrepancy indices shown here are their replacement.
  • The chi-square reference for Test 1 is asymptotic rather than exact; the calibrated p-values presented above (calibrate = "bootstrap" or calibrate = "moment") address this, and ?optop_select documents the underlying statistical reasoning.

References

Haldane, J. B. S. (1937). The exact value of the moments of the distribution of chi-square. Biometrika, 29, 133–143.

Lewis, C. M. and Grossetti, F. (2022). A statistical approach for optimal topic model identification. Journal of Machine Learning Research, 23(58), 1–20. https://jmlr.org/papers/v23/19-297.html

Lewis, C. M. and Grossetti, F. (2026). Goodness-of-fit indices and diagnostics for topic models. Working paper.