Skip to main content

bixverse_methods/methods/lda/
vb.rs

1//! The variational Bayes solver: E-step, M-step, bound and the outer loop.
2//!
3//! Both steps are fan-outs over disjoint output slices, so neither needs a
4//! reduction. The E-step gives each document its own column of `gamma`; the
5//! M-step gives each term its own column of the sufficient statistics.
6//!
7//! ### References
8//!
9//! Hoffman, Blei and Bach, Online Learning for Latent Dirichlet Allocation,
10//! NIPS, 2010
11
12use faer::Mat;
13use rand::SeedableRng;
14use rand::rngs::SmallRng;
15use rand::seq::SliceRandom;
16use rand_distr::{Distribution, Gamma};
17use rayon::prelude::*;
18use statrs::function::gamma::ln_gamma;
19
20use crate::prelude::*;
21use bixverse_core::core::math::special::digamma;
22
23use super::{
24    LDA_DOC_SEED_MULT, LDA_INIT_SCALE, LDA_INIT_SHAPE, LDA_PHI_EPS, LdaCorpus, LdaLearning,
25    LdaParams, LdaResult,
26};
27
28////////////
29// Consts //
30////////////
31
32/// Terms per task when accumulating the topic-term bound.
33const BOUND_TERM_CHUNK: usize = 1024;
34
35///////////
36// Utils //
37///////////
38
39/// Column-major dense buffer with a contiguous column accessor.
40///
41/// `faer::Mat` cannot hand out a `&mut [F]` column and is not mutably `Sync`,
42/// and both hot loops here want exactly that: a contiguous `k`-slice per
43/// document or per term, written from a `rayon` task that owns it. A flat `Vec`
44/// with an explicit leading dimension is the smaller thing that does the job.
45pub(crate) struct ColMajor<F> {
46    /// Flat buffer, column `j` at `j * rows .. (j + 1) * rows`.
47    data: Vec<F>,
48    /// Leading dimension, i.e. the length of one column.
49    rows: usize,
50}
51
52impl<F: BixverseFloat + Send + Sync> ColMajor<F> {
53    /// Allocate a `rows x cols` buffer filled with `value`.
54    ///
55    /// ### Params
56    ///
57    /// * `rows` - Column length.
58    /// * `cols` - Number of columns.
59    /// * `value` - Fill value.
60    ///
61    /// ### Returns
62    ///
63    /// The initialised [ColMajor].
64    fn filled(rows: usize, cols: usize, value: F) -> Self {
65        Self {
66            data: vec![value; rows * cols],
67            rows,
68        }
69    }
70
71    /// Immutable view of column `j`.
72    ///
73    /// ### Params
74    ///
75    /// * `j` - Column index.
76    ///
77    /// ### Returns
78    ///
79    /// The contiguous column slice.
80    #[inline]
81    pub(crate) fn col(&self, j: usize) -> &[F] {
82        &self.data[j * self.rows..(j + 1) * self.rows]
83    }
84
85    /// Overwrite column `j`.
86    ///
87    /// ### Params
88    ///
89    /// * `j` - Column index.
90    /// * `src` - Replacement values, of length `rows`.
91    fn set_col(&mut self, j: usize, src: &[F]) {
92        self.data[j * self.rows..(j + 1) * self.rows].copy_from_slice(src);
93    }
94
95    /// Copy a `faer` matrix into this layout.
96    ///
97    /// ### Params
98    ///
99    /// * `mat` - Source matrix; its columns become this buffer's columns.
100    ///
101    /// ### Returns
102    ///
103    /// The populated [ColMajor].
104    fn from_mat(mat: faer::MatRef<F>) -> Self {
105        let (rows, cols) = (mat.nrows(), mat.ncols());
106        let mut data = Vec::with_capacity(rows * cols);
107        for j in 0..cols {
108            for i in 0..rows {
109                data.push(mat[(i, j)]);
110            }
111        }
112        Self { data, rows }
113    }
114
115    /// Iterator over columns, for `rayon` fan-out over disjoint slices.
116    ///
117    /// ### Returns
118    ///
119    /// A parallel iterator yielding each column as a mutable slice.
120    fn par_cols_mut(&mut self) -> impl IndexedParallelIterator<Item = &mut [F]> {
121        self.data.par_chunks_mut(self.rows)
122    }
123
124    /// Convert to a `faer::Mat` of the same shape.
125    ///
126    /// ### Returns
127    ///
128    /// A `rows x cols` matrix holding a copy of the buffer.
129    fn to_mat(&self) -> Mat<F> {
130        let cols = self.data.len() / self.rows;
131        Mat::from_fn(self.rows, cols, |i, j| self.data[j * self.rows + i])
132    }
133}
134
135/// `exp(digamma(x) - digamma(sum x))` over a Dirichlet parameter vector.
136///
137/// The expected value under `q` of `log theta`, exponentiated, which is the
138/// only form either step ever needs.
139///
140/// ### Params
141///
142/// * `param` - Dirichlet parameters, strictly positive.
143/// * `out` - Destination, same length as `param`.
144#[inline]
145fn exp_expected_log<F: BixverseFloat + BixverseSimd>(param: &[F], out: &mut [F]) {
146    let total = F::bxv_sum(param);
147    let norm = digamma(total);
148    for (o, p) in out.iter_mut().zip(param) {
149        *o = (digamma(*p) - norm).exp();
150    }
151}
152
153////////////
154// E-step //
155////////////
156
157/// Run the per-document fixed point for one document.
158///
159/// Iterates `gamma_d = alpha + exp_elog_theta_d * sum_j (n_dj / phinorm_j) *
160/// exp_elog_beta[:, j]` to convergence, which is the coordinate ascent on the
161/// document's variational parameters with `phi` marginalised out.
162///
163/// ### Params
164///
165/// * `ids` - Term indices of this document's non-zeros.
166/// * `cts` - Counts matching `ids`.
167/// * `exp_elog_beta` - `k x n_terms` topic-term buffer.
168/// * `gamma_d` - Document's Dirichlet parameters, updated in place.
169/// * `exp_elog_theta_d` - Scratch of length `k`, left holding the converged
170///   value.
171/// * `alpha` - Document-topic prior.
172/// * `inner_max_iter` - Iteration budget.
173/// * `inner_tol` - Relative L1 change in `gamma_d` to stop at, see below.
174#[allow(clippy::too_many_arguments)]
175fn e_step_document<F: BixverseFloat + BixverseSimd + Send + Sync>(
176    ids: &[u32],
177    cts: &[F],
178    exp_elog_beta: &ColMajor<F>,
179    gamma_d: &mut [F],
180    exp_elog_theta_d: &mut [F],
181    alpha: F,
182    inner_max_iter: usize,
183    inner_tol: F,
184) {
185    let k = gamma_d.len();
186    let eps = F::from_f64(LDA_PHI_EPS).unwrap();
187
188    let mut acc = vec![F::zero(); k];
189    exp_expected_log(gamma_d, exp_elog_theta_d);
190
191    for _ in 0..inner_max_iter {
192        acc.iter_mut().for_each(|v| *v = F::zero());
193        for (&id, &ct) in ids.iter().zip(cts) {
194            let beta_col = exp_elog_beta.col(id as usize);
195            let phinorm = F::bxv_dot_simd(exp_elog_theta_d, beta_col) + eps;
196            F::bxv_axpy_simd(&mut acc, ct / phinorm, beta_col);
197        }
198
199        let mut change = F::zero();
200        let mut total = F::zero();
201        for ((g, a), t) in gamma_d
202            .iter_mut()
203            .zip(&acc)
204            .zip(exp_elog_theta_d.iter().copied())
205        {
206            let updated = alpha + t * *a;
207            change += (updated - *g).abs();
208            total += updated;
209            *g = updated;
210        }
211
212        exp_expected_log(gamma_d, exp_elog_theta_d);
213
214        if change < inner_tol * total {
215            break;
216        }
217    }
218}
219
220/// Run the E-step over a set of documents.
221///
222/// Each document owns its own column of `gamma` and `exp_elog_theta`, so the
223/// fan-out needs no synchronisation. Every document draws its initial `gamma`
224/// from a seed derived from its own index, which is what keeps a run
225/// reproducible independent of how `rayon` schedules it.
226///
227/// ### Params
228///
229/// * `corpus` - The corpus, read through its CSR view.
230/// * `docs` - Document indices to process. Must contain no duplicates: the
231///   scattered writes below are only disjoint because of that, and it is
232///   asserted in debug builds.
233/// * `exp_elog_beta` - `k x n_terms` topic-term buffer.
234/// * `gamma` - `k x n_docs` destination, written at the `docs` columns.
235/// * `exp_elog_theta` - `k x n_docs` destination, written at the `docs`
236///   columns.
237/// * `alpha` - Document-topic prior.
238/// * `params` - Solver options, for the inner loop budget and the seed.
239fn e_step<F: BixverseFloat + BixverseSimd + BixverseNumeric>(
240    corpus: &LdaCorpus<F>,
241    docs: &[usize],
242    exp_elog_beta: &ColMajor<F>,
243    gamma: &mut ColMajor<F>,
244    exp_elog_theta: &mut ColMajor<F>,
245    alpha: F,
246    params: &LdaParams<F>,
247) {
248    debug_assert!(
249        {
250            let mut seen = rustc_hash::FxHashSet::default();
251            docs.iter().all(|d| seen.insert(*d))
252        },
253        "e_step received duplicate document indices, which would alias the writes below"
254    );
255
256    let k = gamma.rows;
257    let csr = &corpus.csr;
258    let seed = params.seed;
259
260    // Collect the raw pointers once: each task writes only its own columns, so
261    // the aliasing the borrow checker cannot see here is genuinely absent.
262    let gamma_ptr = gamma.data.as_mut_ptr() as usize;
263    let theta_ptr = exp_elog_theta.data.as_mut_ptr() as usize;
264
265    docs.par_iter().for_each(|&d| {
266        let start = csr.indptr[d] as usize;
267        let end = csr.indptr[d + 1] as usize;
268        let ids = &csr.indices[start..end];
269        let cts = &csr.data[start..end];
270
271        let mut rng = SmallRng::seed_from_u64(seed ^ (d as u64).wrapping_mul(LDA_DOC_SEED_MULT));
272        let dist = Gamma::new(LDA_INIT_SHAPE, LDA_INIT_SCALE).unwrap();
273        let mut gamma_d: Vec<F> = (0..k)
274            .map(|_| F::from_f64(dist.sample(&mut rng)).unwrap())
275            .collect();
276        let mut theta_d = vec![F::zero(); k];
277
278        e_step_document(
279            ids,
280            cts,
281            exp_elog_beta,
282            &mut gamma_d,
283            &mut theta_d,
284            alpha,
285            params.inner_max_iter,
286            params.inner_tol,
287        );
288
289        // SAFETY: `d` is unique within `docs`, so the two `k`-length windows
290        // written here are disjoint from every other task's.
291        unsafe {
292            let g = (gamma_ptr as *mut F).add(d * k);
293            let t = (theta_ptr as *mut F).add(d * k);
294            std::ptr::copy_nonoverlapping(gamma_d.as_ptr(), g, k);
295            std::ptr::copy_nonoverlapping(theta_d.as_ptr(), t, k);
296        }
297    });
298}
299
300////////////
301// M-step //
302////////////
303
304/// Accumulate the sufficient statistics over a set of documents.
305///
306/// Walks terms rather than documents, so each task owns one column of `sstats`
307/// and the fan-out again needs no reduction. Returns the token part of the
308/// bound as a by-product, since it is exactly the `sum n_dw log phinorm_dw`
309/// this scan already forms.
310///
311/// ### Params
312///
313/// * `corpus` - The corpus, read through its CSC view.
314/// * `doc_mask` - `None` to use every document, or a per-document flag marking
315///   the mini-batch.
316/// * `exp_elog_beta` - `k x n_terms` topic-term buffer.
317/// * `exp_elog_theta` - `k x n_docs` document-topic buffer from the E-step.
318/// * `sstats` - `k x n_terms` destination, overwritten.
319///
320/// ### Returns
321///
322/// `sum_{d, w} n_dw log phinorm_dw` over the selected documents.
323fn m_step_sstats<F: BixverseFloat + BixverseSimd + BixverseNumeric>(
324    corpus: &LdaCorpus<F>,
325    doc_mask: Option<&[bool]>,
326    exp_elog_beta: &ColMajor<F>,
327    exp_elog_theta: &ColMajor<F>,
328    sstats: &mut ColMajor<F>,
329) -> f64 {
330    let csc = &corpus.csc;
331    let eps = F::from_f64(LDA_PHI_EPS).unwrap();
332
333    sstats
334        .par_cols_mut()
335        .enumerate()
336        .map(|(w, out)| {
337            out.iter_mut().for_each(|v| *v = F::zero());
338            let start = csc.indptr[w] as usize;
339            let end = csc.indptr[w + 1] as usize;
340            let beta_col = exp_elog_beta.col(w);
341
342            let mut token_ll = 0.0_f64;
343            for idx in start..end {
344                let d = csc.indices[idx] as usize;
345                if let Some(mask) = doc_mask
346                    && !mask[d]
347                {
348                    continue;
349                }
350                let ct = csc.data[idx];
351                let theta_col = exp_elog_theta.col(d);
352                let phinorm = F::bxv_dot_simd(theta_col, beta_col) + eps;
353                F::bxv_axpy_simd(out, ct / phinorm, theta_col);
354                token_ll += ct.to_f64().unwrap_or(0.0) * phinorm.to_f64().unwrap_or(0.0).ln();
355            }
356            token_ll
357        })
358        .sum()
359}
360
361/// Turn sufficient statistics into the topic-term Dirichlet parameters.
362///
363/// `lambda = eta + sstats * exp_elog_beta` elementwise, optionally blended into
364/// the previous `lambda` for the online variant.
365///
366/// ### Params
367///
368/// * `sstats` - Sufficient statistics from [m_step_sstats], consumed in place.
369/// * `exp_elog_beta` - The buffer the statistics were accumulated against.
370/// * `lambda` - `k x n_terms` Dirichlet parameters, updated in place.
371/// * `eta` - Topic-term prior.
372/// * `blend` - `None` for a full replacement, or `Some((rho, scale))` to move
373///   `lambda` a step `rho` towards `eta + scale * sstats * exp_elog_beta`.
374fn apply_sstats<F: BixverseFloat + Send + Sync>(
375    sstats: &ColMajor<F>,
376    exp_elog_beta: &ColMajor<F>,
377    lambda: &mut ColMajor<F>,
378    eta: F,
379    blend: Option<(F, F)>,
380) {
381    match blend {
382        None => lambda
383            .data
384            .par_iter_mut()
385            .zip(sstats.data.par_iter())
386            .zip(exp_elog_beta.data.par_iter())
387            .for_each(|((l, s), b)| *l = eta + *s * *b),
388        Some((rho, scale)) => {
389            let one_minus = F::one() - rho;
390            lambda
391                .data
392                .par_iter_mut()
393                .zip(sstats.data.par_iter())
394                .zip(exp_elog_beta.data.par_iter())
395                .for_each(|((l, s), b)| *l = one_minus * *l + rho * (eta + scale * *s * *b));
396        }
397    }
398}
399
400///////////
401// Bound //
402///////////
403
404/// Dirichlet part of the variational bound for one set of parameters.
405///
406/// `sum_j [(prior - q_j) E[log x_j] + lgamma(q_j) - lgamma(prior)] +
407/// lgamma(prior * n) - lgamma(sum_j q_j)`, i.e. the `E[log p] - E[log q]`
408/// contribution of one Dirichlet-distributed variable.
409///
410/// Accumulates in `f64` regardless of `F`: this is a sum of `n_terms` terms of
411/// mixed sign, and in `f32` the cancellation shows up directly in the reported
412/// bound.
413///
414/// ### Params
415///
416/// * `param` - Variational Dirichlet parameters.
417/// * `prior` - The corresponding symmetric Dirichlet prior.
418///
419/// ### Returns
420///
421/// The contribution to the bound.
422fn dirichlet_bound<F: BixverseFloat + BixverseSimd>(param: &[F], prior: F) -> f64 {
423    let n = param.len();
424    let prior_f = prior.to_f64().unwrap_or(0.0);
425    let total = F::bxv_sum(param);
426    let norm = digamma(total).to_f64().unwrap_or(0.0);
427
428    let mut acc = 0.0_f64;
429    for p in param {
430        let p_f = p.to_f64().unwrap_or(0.0);
431        let e_log = digamma(*p).to_f64().unwrap_or(0.0) - norm;
432        acc += (prior_f - p_f) * e_log + ln_gamma(p_f) - ln_gamma(prior_f);
433    }
434
435    acc + ln_gamma(prior_f * n as f64) - ln_gamma(total.to_f64().unwrap_or(0.0))
436}
437
438/// The variational bound (ELBO) for the current parameters.
439///
440/// The token term is handed in rather than recomputed, because the M-step scan
441/// already formed it.
442///
443/// ### Params
444///
445/// * `token_ll` - `sum_{d, w} n_dw log phinorm_dw` from [m_step_sstats].
446/// * `gamma` - `k x n_docs` document-topic parameters.
447/// * `lambda` - `k x n_terms` topic-term parameters.
448/// * `docs` - Documents contributing to `token_ll`.
449/// * `n_terms` - Vocabulary size.
450/// * `alpha` - Document-topic prior.
451/// * `eta` - Topic-term prior.
452/// * `doc_scale` - `n_docs / |docs|`, scaling the document part up to the whole
453///   corpus for a mini-batch estimate. One for a batch sweep.
454///
455/// ### Returns
456///
457/// The bound, in `f64`.
458#[allow(clippy::too_many_arguments)]
459fn variational_bound<F: BixverseFloat + BixverseSimd + Send + Sync>(
460    token_ll: f64,
461    gamma: &ColMajor<F>,
462    lambda: &ColMajor<F>,
463    docs: &[usize],
464    n_terms: usize,
465    alpha: F,
466    eta: F,
467    doc_scale: f64,
468) -> f64 {
469    let doc_part: f64 = docs
470        .par_iter()
471        .map(|&d| dirichlet_bound(gamma.col(d), alpha))
472        .sum();
473
474    let topic_part = topic_dirichlet_bound(lambda, eta, n_terms);
475
476    (token_ll + doc_part) * doc_scale + topic_part
477}
478
479/// Topic-term Dirichlet contribution to the bound.
480///
481/// The Dirichlet here runs over terms within a topic, i.e. along the rows of
482/// the `k x n_terms` layout. Gathering those rows would stride the whole
483/// buffer `k` times, so this instead carries `k` accumulators across one
484/// contiguous sweep of the columns.
485///
486/// ### Params
487///
488/// * `lambda` - `k x n_terms` topic-term parameters.
489/// * `eta` - Topic-term prior.
490/// * `n_terms` - Vocabulary size.
491///
492/// ### Returns
493///
494/// The contribution to the bound, summed over topics.
495fn topic_dirichlet_bound<F: BixverseFloat + BixverseSimd + Send + Sync>(
496    lambda: &ColMajor<F>,
497    eta: F,
498    n_terms: usize,
499) -> f64 {
500    let k = lambda.rows;
501    let eta_f = eta.to_f64().unwrap_or(0.0);
502
503    let mut row_sums = vec![F::zero(); k];
504    for w in 0..n_terms {
505        F::bxv_axpy_simd(&mut row_sums, F::one(), lambda.col(w));
506    }
507    let norms: Vec<f64> = row_sums
508        .iter()
509        .map(|s| digamma(*s).to_f64().unwrap_or(0.0))
510        .collect();
511
512    let acc: Vec<f64> = lambda
513        .data
514        .par_chunks(k * BOUND_TERM_CHUNK)
515        .fold(
516            || vec![0.0_f64; k],
517            |mut acc, chunk| {
518                for col in chunk.chunks_exact(k) {
519                    for ((a, v), norm) in acc.iter_mut().zip(col).zip(&norms) {
520                        let p = v.to_f64().unwrap_or(0.0);
521                        *a += (eta_f - p) * (digamma(p) - norm) + ln_gamma(p);
522                    }
523                }
524                acc
525            },
526        )
527        .reduce(
528            || vec![0.0_f64; k],
529            |mut a, b| {
530                a.iter_mut().zip(&b).for_each(|(x, y)| *x += y);
531                a
532            },
533        );
534
535    let const_part = ln_gamma(eta_f * n_terms as f64) - n_terms as f64 * ln_gamma(eta_f);
536    acc.iter()
537        .zip(&row_sums)
538        .map(|(a, total)| a + const_part - ln_gamma(total.to_f64().unwrap_or(0.0)))
539        .sum()
540}
541
542//////////
543// Main //
544//////////
545
546/// Initialise the topic-term Dirichlet parameters.
547///
548/// Draws `Gamma(100, 0.01)` per entry, seeded per term so the draw does not
549/// depend on the fan-out order.
550///
551/// ### Params
552///
553/// * `k` - Number of topics.
554/// * `n_terms` - Vocabulary size.
555/// * `seed` - Run seed.
556///
557/// ### Returns
558///
559/// The `k x n_terms` initial `lambda`.
560fn init_lambda<F: BixverseFloat + Send + Sync>(k: usize, n_terms: usize, seed: u64) -> ColMajor<F> {
561    let mut lambda = ColMajor::filled(k, n_terms, F::zero());
562    lambda.par_cols_mut().enumerate().for_each(|(w, col)| {
563        let mut rng =
564            SmallRng::seed_from_u64(seed ^ (w as u64).wrapping_mul(LDA_DOC_SEED_MULT) ^ 0xA5A5);
565        let dist = Gamma::new(LDA_INIT_SHAPE, LDA_INIT_SCALE).unwrap();
566        for v in col.iter_mut() {
567            *v = F::from_f64(dist.sample(&mut rng)).unwrap();
568        }
569    });
570    lambda
571}
572
573/// Normalise a set of columns to sum to one.
574///
575/// ### Params
576///
577/// * `buf` - Buffer whose columns are normalised in place.
578fn normalise_columns<F: BixverseFloat + BixverseSimd + Send + Sync>(buf: &mut ColMajor<F>) {
579    buf.par_cols_mut().for_each(|col| {
580        let total = F::bxv_sum(col);
581        if total > F::zero() {
582            col.iter_mut().for_each(|v| *v /= total);
583        }
584    });
585}
586
587/// Fit an LDA model by variational Bayes.
588///
589/// ### Params
590///
591/// * `corpus` - The corpus in both orientations.
592/// * `k` - Number of topics.
593/// * `params` - Solver options.
594/// * `verbosity` - Controls the per-iteration reporting.
595///
596/// ### Returns
597///
598/// The fitted [LdaResult].
599pub(crate) fn fit_vb<F>(
600    corpus: &LdaCorpus<F>,
601    k: usize,
602    params: &LdaParams<F>,
603    verbosity: Verbosity,
604) -> Result<LdaResult<F>, MethodsErrors>
605where
606    F: BixverseFloat + BixverseNumeric + BixverseSimd,
607{
608    if k == 0 || k > corpus.n_terms {
609        return Err(MethodsErrors::LdaInvalidTopicCount {
610            requested: k,
611            max_available: corpus.n_terms,
612        });
613    }
614    let (alpha, eta) = params.resolve_priors(k)?;
615
616    let n_docs = corpus.n_docs;
617    let n_terms = corpus.n_terms;
618
619    let mut lambda = init_lambda::<F>(k, n_terms, params.seed);
620    let mut exp_elog_beta = ColMajor::filled(k, n_terms, F::zero());
621    let mut sstats = ColMajor::filled(k, n_terms, F::zero());
622    let mut gamma = ColMajor::filled(k, n_docs, alpha);
623    let mut exp_elog_theta = ColMajor::filled(k, n_docs, F::zero());
624
625    // A zero cadence would divide by zero below, and it arrives from R as a
626    // plausible way of spelling "never check".
627    let check_every = params.check_every.max(1);
628
629    let all_docs: Vec<usize> = (0..n_docs).collect();
630    let mut previous_bound = f64::NEG_INFINITY;
631    let mut bound = f64::NEG_INFINITY;
632    let mut converged = false;
633    let mut n_iter = 0;
634
635    match params.learning {
636        LdaLearning::Batch => {
637            for iter in 0..params.max_iter {
638                n_iter = iter + 1;
639                update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
640                e_step(
641                    corpus,
642                    &all_docs,
643                    &exp_elog_beta,
644                    &mut gamma,
645                    &mut exp_elog_theta,
646                    alpha,
647                    params,
648                );
649                let token_ll =
650                    m_step_sstats(corpus, None, &exp_elog_beta, &exp_elog_theta, &mut sstats);
651                apply_sstats(&sstats, &exp_elog_beta, &mut lambda, eta, None);
652
653                let due = n_iter % check_every == 0 || n_iter == params.max_iter;
654                if due {
655                    bound = variational_bound(
656                        token_ll, &gamma, &lambda, &all_docs, n_terms, alpha, eta, 1.0,
657                    );
658                    if verbosity.normal_verbosity() {
659                        println!("LDA: iteration {n_iter}, bound {bound:.4}");
660                    }
661                    if previous_bound.is_finite() {
662                        let rel = (bound - previous_bound).abs() / previous_bound.abs().max(1.0);
663                        if rel < params.tol.to_f64().unwrap_or(0.0) {
664                            converged = true;
665                            break;
666                        }
667                    }
668                    previous_bound = bound;
669                }
670            }
671        }
672        LdaLearning::Online {
673            batch_size,
674            tau0,
675            kappa,
676            n_epochs,
677        } => {
678            let batch_size = batch_size.clamp(1, n_docs);
679            let mut order = all_docs.clone();
680            let mut rng = SmallRng::seed_from_u64(params.seed);
681            let mut step = 0_u64;
682
683            for epoch in 0..n_epochs {
684                order.shuffle(&mut rng);
685                for batch in order.chunks(batch_size) {
686                    n_iter += 1;
687                    let mut mask = vec![false; n_docs];
688                    batch.iter().for_each(|&d| mask[d] = true);
689
690                    update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
691                    e_step(
692                        corpus,
693                        batch,
694                        &exp_elog_beta,
695                        &mut gamma,
696                        &mut exp_elog_theta,
697                        alpha,
698                        params,
699                    );
700                    let token_ll = m_step_sstats(
701                        corpus,
702                        Some(&mask),
703                        &exp_elog_beta,
704                        &exp_elog_theta,
705                        &mut sstats,
706                    );
707
708                    let rho = F::from_f64((tau0 + step as f64).powf(-kappa)).unwrap();
709                    let scale =
710                        F::from_usize(n_docs).unwrap() / F::from_usize(batch.len()).unwrap();
711                    apply_sstats(
712                        &sstats,
713                        &exp_elog_beta,
714                        &mut lambda,
715                        eta,
716                        Some((rho, scale)),
717                    );
718                    step += 1;
719
720                    bound = variational_bound(
721                        token_ll,
722                        &gamma,
723                        &lambda,
724                        batch,
725                        n_terms,
726                        alpha,
727                        eta,
728                        n_docs as f64 / batch.len() as f64,
729                    );
730                }
731
732                if verbosity.normal_verbosity() {
733                    println!("LDA: epoch {}, bound estimate {bound:.4}", epoch + 1);
734                }
735                if previous_bound.is_finite() {
736                    let rel = (bound - previous_bound).abs() / previous_bound.abs().max(1.0);
737                    if rel < params.tol.to_f64().unwrap_or(0.0) {
738                        converged = true;
739                        break;
740                    }
741                }
742                previous_bound = bound;
743            }
744
745            // The online sweep only ever touched the sampled documents, so the
746            // untouched columns of gamma still hold the prior. One batch E-step
747            // over the whole corpus makes the returned model complete.
748            update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
749            e_step(
750                corpus,
751                &all_docs,
752                &exp_elog_beta,
753                &mut gamma,
754                &mut exp_elog_theta,
755                alpha,
756                params,
757            );
758        }
759    }
760
761    if lambda.data.iter().any(|v| !v.is_finite()) || gamma.data.iter().any(|v| !v.is_finite()) {
762        return Err(MethodsErrors::LdaNonFinite);
763    }
764
765    normalise_columns(&mut gamma);
766    let cell_topic = gamma.to_mat();
767
768    // lambda is k x n_terms; the public result is terms x k so a topic's term
769    // vector is the contiguous direction for the metrics and any binarisation.
770    let topic_region = topic_region_from_lambda(&lambda, k, n_terms);
771
772    let bound_f = F::from_f64(bound).unwrap_or(F::neg_infinity());
773    let perplexity = if corpus.total_tokens > 0.0 && bound.is_finite() {
774        F::from_f64((-bound / corpus.total_tokens).exp()).unwrap_or(F::infinity())
775    } else {
776        F::infinity()
777    };
778
779    Ok(LdaResult {
780        cell_topic,
781        topic_region,
782        bound: bound_f,
783        perplexity,
784        n_iter,
785        converged,
786    })
787}
788
789/// Evaluate the variational bound for an externally supplied set of parameters.
790///
791/// Scores variational parameters this crate did not necessarily fit, which is
792/// what makes the bound checkable against another implementation: fitting twice
793/// only ever shows that both solvers found *some* optimum, whereas running a
794/// foreign `lambda` and `gamma` through this function isolates the objective
795/// from the solver.
796///
797/// Note both arguments are the *unnormalised* Dirichlet parameters, not the
798/// probability matrices [LdaResult] returns.
799///
800/// ### Params
801///
802/// * `corpus` - The corpus to score against.
803/// * `lambda` - Topic-term Dirichlet parameters, shape `(k, n_terms)`.
804/// * `gamma` - Document-topic Dirichlet parameters, shape `(k, n_docs)`.
805/// * `alpha` - Document-topic prior, already resolved for this `k`.
806/// * `eta` - Topic-term prior, already resolved for this `k`.
807///
808/// ### Returns
809///
810/// The bound in `f64`, or an error if the shapes disagree with the corpus or a
811/// prior is not strictly positive.
812pub fn lda_bound<F>(
813    corpus: &LdaCorpus<F>,
814    lambda: faer::MatRef<F>,
815    gamma: faer::MatRef<F>,
816    alpha: F,
817    eta: F,
818) -> Result<f64, MethodsErrors>
819where
820    F: BixverseFloat + BixverseNumeric + BixverseSimd,
821{
822    let k = lambda.nrows();
823    if gamma.nrows() != k {
824        return Err(MethodsErrors::LdaDimensionMismatch {
825            expected: k,
826            got: gamma.nrows(),
827        });
828    }
829    if lambda.ncols() != corpus.n_terms {
830        return Err(MethodsErrors::LdaDimensionMismatch {
831            expected: corpus.n_terms,
832            got: lambda.ncols(),
833        });
834    }
835    if gamma.ncols() != corpus.n_docs {
836        return Err(MethodsErrors::LdaDimensionMismatch {
837            expected: corpus.n_docs,
838            got: gamma.ncols(),
839        });
840    }
841    for (name, value) in [("alpha", alpha), ("eta", eta)] {
842        if value <= F::zero() || !value.is_finite() {
843            return Err(MethodsErrors::LdaInvalidHyperparameter {
844                name: name.to_string(),
845                value: value.to_f64().unwrap_or(f64::NAN),
846            });
847        }
848    }
849
850    let lambda_buf = ColMajor::from_mat(lambda);
851    let gamma_buf = ColMajor::from_mat(gamma);
852
853    let mut exp_elog_beta = ColMajor::filled(k, corpus.n_terms, F::zero());
854    update_exp_elog_beta(&lambda_buf, &mut exp_elog_beta, corpus.n_terms);
855
856    let mut exp_elog_theta = ColMajor::filled(k, corpus.n_docs, F::zero());
857    for d in 0..corpus.n_docs {
858        let mut out = vec![F::zero(); k];
859        exp_expected_log(gamma_buf.col(d), &mut out);
860        exp_elog_theta.set_col(d, &out);
861    }
862
863    let mut sstats = ColMajor::filled(k, corpus.n_terms, F::zero());
864    let token_ll = m_step_sstats(corpus, None, &exp_elog_beta, &exp_elog_theta, &mut sstats);
865
866    let all_docs: Vec<usize> = (0..corpus.n_docs).collect();
867    Ok(variational_bound(
868        token_ll,
869        &gamma_buf,
870        &lambda_buf,
871        &all_docs,
872        corpus.n_terms,
873        alpha,
874        eta,
875        1.0,
876    ))
877}
878
879/// Refresh `exp(E[log beta])` from the current `lambda`.
880///
881/// The per-topic normaliser runs across terms, i.e. across columns of the
882/// `k x n_terms` layout, so it is formed once up front rather than per column.
883///
884/// ### Params
885///
886/// * `lambda` - `k x n_terms` topic-term parameters.
887/// * `out` - `k x n_terms` destination.
888/// * `n_terms` - Vocabulary size.
889fn update_exp_elog_beta<F: BixverseFloat + BixverseSimd + Send + Sync>(
890    lambda: &ColMajor<F>,
891    out: &mut ColMajor<F>,
892    n_terms: usize,
893) {
894    let k = lambda.rows;
895    let mut row_sums = vec![F::zero(); k];
896    for w in 0..n_terms {
897        F::bxv_axpy_simd(&mut row_sums, F::one(), lambda.col(w));
898    }
899    let norms: Vec<F> = row_sums.iter().map(|s| digamma(*s)).collect();
900
901    let lambda_data = &lambda.data;
902    out.par_cols_mut().enumerate().for_each(|(w, col)| {
903        let src = &lambda_data[w * k..(w + 1) * k];
904        for ((o, v), n) in col.iter_mut().zip(src).zip(&norms) {
905            *o = (digamma(*v) - *n).exp();
906        }
907    });
908}
909
910/// Build the terms x topics probability matrix from `lambda`.
911///
912/// ### Params
913///
914/// * `lambda` - `k x n_terms` topic-term parameters.
915/// * `k` - Number of topics.
916/// * `n_terms` - Vocabulary size.
917///
918/// ### Returns
919///
920/// A `n_terms x k` matrix whose columns sum to one.
921fn topic_region_from_lambda<F: BixverseFloat + BixverseSimd + Send + Sync>(
922    lambda: &ColMajor<F>,
923    k: usize,
924    n_terms: usize,
925) -> Mat<F> {
926    let mut totals = vec![F::zero(); k];
927    for w in 0..n_terms {
928        F::bxv_axpy_simd(&mut totals, F::one(), lambda.col(w));
929    }
930    Mat::from_fn(n_terms, k, |w, topic| {
931        let total = totals[topic];
932        if total > F::zero() {
933            lambda.col(w)[topic] / total
934        } else {
935            F::zero()
936        }
937    })
938}
939
940///////////
941// Tests //
942///////////
943
944#[cfg(test)]
945pub(crate) mod tests {
946    use super::*;
947    use crate::methods::lda::{LdaCorpus, lda_fit};
948    use approx::assert_relative_eq;
949
950    /// Build a CSR documents x terms matrix from dense rows.
951    ///
952    /// ### Params
953    ///
954    /// * `rows` - One row per document, each of length `n_terms`.
955    ///
956    /// ### Returns
957    ///
958    /// The CSR matrix with the zeros dropped.
959    pub(crate) fn csr_from_rows(rows: &[Vec<f64>]) -> CompressedSparseData2<f64> {
960        let n_terms = rows[0].len();
961        let mut data = Vec::new();
962        let mut indices = Vec::new();
963        let mut indptr = vec![0_u32];
964        for row in rows {
965            for (w, &v) in row.iter().enumerate() {
966                if v != 0.0 {
967                    data.push(v);
968                    indices.push(w as u32);
969                }
970            }
971            indptr.push(data.len() as u32);
972        }
973        CompressedSparseData2::from_parts(
974            data,
975            indices,
976            indptr,
977            None,
978            CompressedSparseFormat::Csr,
979            (rows.len(), n_terms),
980        )
981    }
982
983    /// Three disjoint vocabulary blocks, each document drawing from one block.
984    ///
985    /// ### Params
986    ///
987    /// * `docs_per_block` - Documents generated per block.
988    /// * `block_size` - Terms per block.
989    ///
990    /// ### Returns
991    ///
992    /// A binary documents x terms matrix with `3 * block_size` terms.
993    pub(crate) fn block_corpus(
994        docs_per_block: usize,
995        block_size: usize,
996    ) -> CompressedSparseData2<f64> {
997        let n_terms = 3 * block_size;
998        let mut rows = Vec::new();
999        for block in 0..3 {
1000            for d in 0..docs_per_block {
1001                let mut row = vec![0.0; n_terms];
1002                // Drop one term per document so documents within a block differ
1003                for t in 0..block_size {
1004                    if (t + d) % block_size != 0 {
1005                        row[block * block_size + t] = 1.0;
1006                    }
1007                }
1008                rows.push(row);
1009            }
1010        }
1011        csr_from_rows(&rows)
1012    }
1013
1014    /// Shared solver options: tight tolerance, checked every iteration.
1015    ///
1016    /// ### Returns
1017    ///
1018    /// The [LdaParams] every test below starts from.
1019    fn test_params() -> LdaParams<f64> {
1020        LdaParams {
1021            // The cisTopic default of 50/k is far too strong for corpora this
1022            // small: a four-token document cannot outvote a prior of 16 per
1023            // topic, so every document would come back uniform. Real scATAC
1024            // documents carry thousands of regions and are unaffected.
1025            alpha: 0.1,
1026            alpha_by_topic: false,
1027            max_iter: 60,
1028            check_every: 1,
1029            tol: 1e-9,
1030            seed: 42,
1031            ..Default::default()
1032        }
1033    }
1034
1035    /// The batch bound must never decrease: that is the guarantee coordinate
1036    /// ascent on the ELBO buys, and the cheapest thing that catches a sign or
1037    /// indexing slip in either step.
1038    #[test]
1039    fn test_batch_bound_is_monotone() {
1040        let matrix = block_corpus(6, 5);
1041        let corpus = LdaCorpus::<f64>::new(&matrix).unwrap();
1042
1043        let mut previous = f64::NEG_INFINITY;
1044        for iter in 1..=25 {
1045            let p = LdaParams {
1046                max_iter: iter,
1047                ..test_params()
1048            };
1049            let bound = fit_vb(&corpus, 3, &p, Verbosity::Quiet).unwrap().bound;
1050            assert!(
1051                bound >= previous - 1e-6,
1052                "bound decreased at iteration {iter}: {previous} -> {bound}"
1053            );
1054            previous = bound;
1055        }
1056    }
1057
1058    /// Each recovered topic should concentrate on exactly one vocabulary block.
1059    #[test]
1060    fn test_recovers_disjoint_blocks() {
1061        let block_size = 5;
1062        let matrix = block_corpus(8, block_size);
1063        let model = lda_fit(&matrix, 3, Some(test_params()), 0).unwrap();
1064
1065        let mut claimed = [false; 3];
1066        for topic in 0..3 {
1067            let col = model.topic_region.col_as_slice(topic);
1068            let mass: Vec<f64> = (0..3)
1069                .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1070                .collect();
1071            let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1072            assert!(
1073                mass[best] > 0.9,
1074                "topic {topic} spread across blocks: {mass:?}"
1075            );
1076            assert!(!claimed[best], "two topics claimed block {best}");
1077            claimed[best] = true;
1078        }
1079    }
1080
1081    /// The same seed must give the same fit, whatever the thread count. This is
1082    /// what the per-document seed derivation exists for.
1083    #[test]
1084    fn test_reproducible_across_thread_counts() {
1085        let matrix = block_corpus(6, 5);
1086        let params = test_params();
1087
1088        let run = |threads: usize| {
1089            rayon::ThreadPoolBuilder::new()
1090                .num_threads(threads)
1091                .build()
1092                .unwrap()
1093                .install(|| lda_fit(&matrix, 3, Some(params), 0).unwrap())
1094        };
1095
1096        let single = run(1);
1097        let multi = run(4);
1098        for topic in 0..3 {
1099            for (a, b) in single
1100                .topic_region
1101                .col_as_slice(topic)
1102                .iter()
1103                .zip(multi.topic_region.col_as_slice(topic))
1104            {
1105                assert_eq!(a, b);
1106            }
1107        }
1108        // The parameters are bit-identical, but the bound is a rayon
1109        // reduction whose association order follows the thread count.
1110        assert_relative_eq!(single.bound, multi.bound, max_relative = 1e-12);
1111    }
1112
1113    /// A single topic puts every document wholly in it.
1114    #[test]
1115    fn test_single_topic_is_degenerate() {
1116        let matrix = block_corpus(4, 4);
1117        let model = lda_fit(&matrix, 1, Some(test_params()), 0).unwrap();
1118        assert_eq!(model.cell_topic.nrows(), 1);
1119        for d in 0..model.cell_topic.ncols() {
1120            assert_relative_eq!(model.cell_topic[(0, d)], 1.0, epsilon = 1e-12);
1121        }
1122        let total: f64 = model.topic_region.col_as_slice(0).iter().sum();
1123        assert_relative_eq!(total, 1.0, epsilon = 1e-10);
1124    }
1125
1126    /// An empty document must not divide by zero; with no evidence it keeps the
1127    /// prior, which is uniform over topics.
1128    #[test]
1129    fn test_empty_document_falls_back_to_prior() {
1130        let rows = vec![
1131            vec![1.0, 1.0, 0.0, 0.0],
1132            vec![0.0, 0.0, 1.0, 1.0],
1133            vec![1.0, 1.0, 0.0, 0.0],
1134            vec![0.0; 4],
1135        ];
1136        let matrix = csr_from_rows(&rows);
1137        let model = lda_fit(&matrix, 2, Some(test_params()), 0).unwrap();
1138
1139        let empty = model.cell_topic.col_as_slice(3);
1140        assert!(empty.iter().all(|v| v.is_finite()));
1141        assert_relative_eq!(empty[0], 0.5, epsilon = 1e-9);
1142        assert_relative_eq!(empty[1], 0.5, epsilon = 1e-9);
1143    }
1144
1145    /// A term nobody uses still gets a well-formed, tiny probability.
1146    #[test]
1147    fn test_unused_term_keeps_prior_mass() {
1148        let rows = vec![
1149            vec![1.0, 1.0, 0.0, 0.0],
1150            vec![0.0, 1.0, 1.0, 0.0],
1151            vec![1.0, 0.0, 1.0, 0.0],
1152        ];
1153        let matrix = csr_from_rows(&rows);
1154        let model = lda_fit(&matrix, 2, Some(test_params()), 0).unwrap();
1155
1156        for topic in 0..2 {
1157            let col = model.topic_region.col_as_slice(topic);
1158            assert!(col[3] > 0.0 && col[3] < 0.05, "unused term got {}", col[3]);
1159            assert_relative_eq!(col.iter().sum::<f64>(), 1.0, epsilon = 1e-10);
1160        }
1161    }
1162
1163    /// Columns of both output matrices are probability vectors.
1164    #[test]
1165    fn test_outputs_are_normalised() {
1166        let matrix = block_corpus(5, 4);
1167        let model = lda_fit(&matrix, 3, Some(test_params()), 0).unwrap();
1168
1169        for d in 0..model.cell_topic.ncols() {
1170            let s: f64 = model.cell_topic.col_as_slice(d).iter().sum();
1171            assert_relative_eq!(s, 1.0, epsilon = 1e-10);
1172        }
1173        for topic in 0..model.topic_region.ncols() {
1174            let s: f64 = model.topic_region.col_as_slice(topic).iter().sum();
1175            assert_relative_eq!(s, 1.0, epsilon = 1e-10);
1176        }
1177    }
1178
1179    /// The online sweep lands in the same place as the batch one on a corpus
1180    /// this separable, which is what keeps the mini-batch arm honest.
1181    #[test]
1182    fn test_online_recovers_blocks() {
1183        let block_size = 5;
1184        let matrix = block_corpus(10, block_size);
1185        let params = LdaParams {
1186            learning: LdaLearning::Online {
1187                batch_size: 6,
1188                tau0: 10.0,
1189                kappa: 0.7,
1190                n_epochs: 40,
1191            },
1192            ..test_params()
1193        };
1194        let model = lda_fit(&matrix, 3, Some(params), 0).unwrap();
1195
1196        let mut claimed = [false; 3];
1197        for topic in 0..3 {
1198            let col = model.topic_region.col_as_slice(topic);
1199            let mass: Vec<f64> = (0..3)
1200                .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1201                .collect();
1202            let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1203            assert!(mass[best] > 0.8, "online topic {topic} spread: {mass:?}");
1204            claimed[best] = true;
1205        }
1206        assert!(claimed.iter().all(|c| *c), "online run collapsed topics");
1207    }
1208
1209    /// Guard rails on the topic count and the hyperparameters.
1210    #[test]
1211    fn test_invalid_inputs_error() {
1212        let matrix = block_corpus(3, 3);
1213        assert!(matches!(
1214            lda_fit(&matrix, 0, Some(test_params()), 0),
1215            Err(MethodsErrors::LdaInvalidTopicCount { .. })
1216        ));
1217        assert!(matches!(
1218            lda_fit(&matrix, 1000, Some(test_params()), 0),
1219            Err(MethodsErrors::LdaInvalidTopicCount { .. })
1220        ));
1221
1222        let bad = LdaParams {
1223            eta: 0.0,
1224            ..test_params()
1225        };
1226        assert!(matches!(
1227            lda_fit(&matrix, 2, Some(bad), 0),
1228            Err(MethodsErrors::LdaInvalidHyperparameter { .. })
1229        ));
1230    }
1231
1232    /// A CSC input describes the same corpus as its CSR twin, so both fit
1233    /// identically. Guards the orientation handling in the corpus builder.
1234    #[test]
1235    fn test_csc_input_matches_csr() {
1236        let rows = vec![
1237            vec![1.0, 1.0, 0.0, 0.0],
1238            vec![0.0, 0.0, 1.0, 1.0],
1239            vec![1.0, 0.0, 1.0, 0.0],
1240        ];
1241        let csr = csr_from_rows(&rows);
1242        let csc = csr.transform();
1243
1244        let a = lda_fit(&csr, 2, Some(test_params()), 0).unwrap();
1245        let b = lda_fit(&csc, 2, Some(test_params()), 0).unwrap();
1246        assert_relative_eq!(a.bound, b.bound, epsilon = 1e-9);
1247    }
1248
1249    /// An all-zero matrix is rejected rather than producing a degenerate fit.
1250    #[test]
1251    fn test_empty_matrix_errors() {
1252        let matrix = csr_from_rows(&[vec![0.0, 0.0], vec![0.0, 0.0]]);
1253        assert!(matches!(
1254            lda_fit(&matrix, 1, Some(test_params()), 0),
1255            Err(MethodsErrors::LdaEmptyMatrix)
1256        ));
1257    }
1258
1259    /// The solver is generic over the float, and `f32` is the one that matters
1260    /// for a real cells x regions matrix, where three dense `k x n_terms`
1261    /// buffers is the binding memory cost.
1262    #[test]
1263    fn test_f32_recovers_blocks() {
1264        let block_size = 5;
1265        let dense = block_corpus(8, block_size);
1266        let data: Vec<f32> = dense.data.iter().map(|v| *v as f32).collect();
1267        let matrix: CompressedSparseData2<f32> = CompressedSparseData2::from_parts(
1268            data,
1269            dense.indices.clone(),
1270            dense.indptr.clone(),
1271            None,
1272            CompressedSparseFormat::Csr,
1273            dense.shape,
1274        );
1275
1276        let params = LdaParams::<f32> {
1277            alpha: 0.1,
1278            alpha_by_topic: false,
1279            max_iter: 60,
1280            check_every: 1,
1281            tol: 1e-7,
1282            seed: 42,
1283            ..Default::default()
1284        };
1285        let model = lda_fit(&matrix, 3, Some(params), 0).unwrap();
1286
1287        assert!(model.bound.is_finite());
1288        for topic in 0..3 {
1289            let col = model.topic_region.col_as_slice(topic);
1290            let mass: Vec<f32> = (0..3)
1291                .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1292                .collect();
1293            let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1294            assert!(mass[best] > 0.9, "f32 topic {topic} spread: {mass:?}");
1295        }
1296    }
1297}