Skip to main content

bixverse_methods/methods/lda/
mod.rs

1//! Latent Dirichlet allocation by variational Bayes, for sparse count and
2//! binary matrices.
3//!
4//! The topic model behind cisTopic: given a binarised cells x regions scATAC
5//! matrix or TF binary activity, recover a cell-topic distribution for
6//! clustering and a topic-region/TF distribution for region/TF set discovery.
7//! Nothing here is ATAC-specific or TF-specific, so any documents x terms count
8//! matrix works.
9//!
10//! ### Layout
11//!
12//! The solver holds the corpus in both orientations, CSR for the document-major
13//! E-step and CSC for the term-major M-step, the same trick the sparse NMF
14//! backend uses. Every dense buffer is column-major `faer::Mat`, laid out so
15//! that the `k`-vector each hot loop touches is contiguous.
16//!
17//! Note that [CompressedSparseData2] indexes with `u32`, which caps a corpus at
18//! roughly 4.3e9 non-zeros.
19//!
20//! ### References
21//!
22//! Hoffman, Blei and Bach, Online Learning for Latent Dirichlet Allocation,
23//! NIPS, 2010
24//!
25//! Bravo Gonzalez-Blas et al., cisTopic, Nature Methods, 2019
26
27use faer::Mat;
28use num_traits::ToPrimitive;
29
30use crate::prelude::*;
31
32pub mod metrics;
33pub mod vb;
34
35pub use vb::lda_bound;
36
37use metrics::{LdaMetrics, lda_metrics};
38use vb::fit_vb;
39
40////////////
41// Consts //
42////////////
43
44/// Multiplier turning a document index into a per-document RNG seed.
45///
46/// The E-step initialises every document's `gamma` from a random draw. Deriving
47/// each document's seed from the run seed rather than sharing one stream is
48/// what makes a run reproducible regardless of how `rayon` schedules the
49/// documents. A large odd multiplier keeps neighbouring documents from landing
50/// on correlated streams.
51const LDA_DOC_SEED_MULT: u64 = 0x9E37_79B9_7F4A_7C15;
52
53/// Shape of the gamma draw that initialises the variational parameters.
54///
55/// `Gamma(100, 0.01)` has mean one and a standard deviation of a tenth, so it
56/// breaks the symmetry between topics without starting any of them far from the
57/// uniform. Matches the initialisation used by Hoffman's reference code.
58const LDA_INIT_SHAPE: f64 = 100.0;
59
60/// Scale of the gamma draw that initialises the variational parameters. See
61/// [LDA_INIT_SHAPE].
62const LDA_INIT_SCALE: f64 = 0.01;
63
64/// Floor added to the per-token normaliser in the E-step and M-step.
65///
66/// `phinorm` is a sum of strictly positive terms, but a document whose terms
67/// all sit in topics that have collapsed can drive it to zero and turn the
68/// following division into an infinity. Hoffman's reference uses the same
69/// guard.
70const LDA_PHI_EPS: f64 = 1e-100;
71
72/// Default number of top-scoring topics averaged into the reported coherence.
73///
74/// pycisTopic's `top_topics_coh`. Coherence over all topics is dragged down by
75/// the ones that never specialised, which makes it useless for comparing across
76/// `k`; averaging the best few is what makes the metric monotone enough to
77/// select on.
78pub const DEFAULT_TOP_TOPICS_COH: usize = 5;
79
80/// Smallest topic count admitted into a coherence-based model selection.
81///
82/// pycisTopic's `min_topics_coh`. Below five topics the top-`n` region sets
83/// overlap so heavily that coherence saturates and would always win.
84pub const DEFAULT_MIN_TOPICS_COH: usize = 5;
85
86///////////
87// Enums //
88///////////
89
90/// How the variational parameters are updated across the corpus.
91#[derive(Clone, Copy, Debug, Default)]
92pub enum LdaLearning {
93    /// One sweep over every document per iteration, then a full replacement of
94    /// `lambda`. Deterministic and monotone in the bound.
95    #[default]
96    Batch,
97    /// Stochastic updates from shuffled mini-batches, with `lambda` moved a
98    /// decaying step towards each batch's estimate. Reaches a usable fit in far
99    /// fewer passes when the corpus is large, at the cost of the monotonicity
100    /// guarantee.
101    Online {
102        /// Documents per mini-batch.
103        batch_size: usize,
104        /// Learning-rate offset `tau0`, damping the first few steps.
105        tau0: f64,
106        /// Learning-rate decay `kappa`, in `(0.5, 1.0]`.
107        kappa: f64,
108        /// Passes over the whole corpus.
109        n_epochs: usize,
110    },
111}
112
113/// Parse the LDA learning strategy.
114///
115/// ### Params
116///
117/// * `s` - String to parse. One of `"batch"` or `"online"`.
118/// * `batch_size` - Documents per mini-batch, used only by the online variant.
119/// * `n_epochs` - Passes over the corpus, used only by the online variant.
120///
121/// ### Returns
122///
123/// The option of [LdaLearning], with the online variant carrying the default
124/// `tau0` and `kappa`.
125pub fn parse_lda_learning(s: &str, batch_size: usize, n_epochs: usize) -> Option<LdaLearning> {
126    match s.to_lowercase().as_str() {
127        "batch" | "full" => Some(LdaLearning::Batch),
128        "online" | "stochastic" | "minibatch" => Some(LdaLearning::Online {
129            batch_size,
130            tau0: 10.0,
131            kappa: 0.7,
132            n_epochs,
133        }),
134        _ => None,
135    }
136}
137
138////////////
139// Params //
140////////////
141
142/// Options for the variational Bayes LDA solver.
143///
144/// Defaults follow pycisTopic so the knobs mean the same thing on both sides.
145#[derive(Clone, Copy, Debug)]
146pub struct LdaParams<F: BixverseFloat> {
147    /// Dirichlet prior on the document-topic distributions.
148    pub alpha: F,
149    /// Whether `alpha` is divided by the topic count. `true` gives the
150    /// Griffiths and Steyvers `50 / k` heuristic that cisTopic defaults to.
151    pub alpha_by_topic: bool,
152    /// Dirichlet prior on the topic-term distributions.
153    pub eta: F,
154    /// Whether `eta` is divided by the topic count.
155    pub eta_by_topic: bool,
156    /// Maximum outer iterations (batch), ignored by the online variant which
157    /// counts epochs instead.
158    pub max_iter: usize,
159    /// Relative change in the bound below which the solver stops.
160    pub tol: F,
161    /// Maximum fixed-point iterations of the per-document E-step.
162    pub inner_max_iter: usize,
163    /// Relative L1 change in `gamma` below which the per-document E-step
164    /// stops. Relative rather than absolute, see [vb::e_step_document].
165    pub inner_tol: F,
166    /// Cadence, in iterations, for evaluating the bound and testing `tol`.
167    pub check_every: usize,
168    /// How the corpus is swept, see [LdaLearning].
169    pub learning: LdaLearning,
170    /// Seed for the `lambda` and `gamma` initialisation.
171    pub seed: u64,
172}
173
174impl<F: BixverseFloat> LdaParams<F> {
175    /// Generate a new instance of [LdaParams]
176    ///
177    /// ### Params
178    ///
179    /// * `alpha` - Document-topic Dirichlet prior.
180    /// * `alpha_by_topic` - Divide `alpha` by the topic count.
181    /// * `eta` - Topic-term Dirichlet prior.
182    /// * `eta_by_topic` - Divide `eta` by the topic count.
183    /// * `max_iter` - Maximum outer iterations.
184    /// * `tol` - Relative bound change for convergence.
185    /// * `inner_max_iter` - Maximum per-document E-step iterations.
186    /// * `inner_tol` - Relative L1 `gamma` change for E-step convergence.
187    /// * `check_every` - Iterations between bound evaluations.
188    /// * `learning` - Batch or online, see [LdaLearning].
189    /// * `seed` - Seed for the initialisation.
190    ///
191    /// ### Returns
192    ///
193    /// The initialised [LdaParams].
194    #[allow(clippy::too_many_arguments)]
195    pub fn new(
196        alpha: F,
197        alpha_by_topic: bool,
198        eta: F,
199        eta_by_topic: bool,
200        max_iter: usize,
201        tol: F,
202        inner_max_iter: usize,
203        inner_tol: F,
204        check_every: usize,
205        learning: LdaLearning,
206        seed: u64,
207    ) -> Self {
208        Self {
209            alpha,
210            alpha_by_topic,
211            eta,
212            eta_by_topic,
213            max_iter,
214            tol,
215            inner_max_iter,
216            inner_tol,
217            check_every,
218            learning,
219            seed,
220        }
221    }
222
223    /// Resolve the effective Dirichlet priors for a given topic count.
224    ///
225    /// ### Params
226    ///
227    /// * `k` - Number of topics.
228    ///
229    /// ### Returns
230    ///
231    /// The `(alpha, eta)` actually handed to the solver, or an error if either
232    /// is not strictly positive.
233    pub fn resolve_priors(&self, k: usize) -> Result<(F, F), MethodsErrors> {
234        let k_f = F::from_usize(k).unwrap();
235        let alpha = if self.alpha_by_topic {
236            self.alpha / k_f
237        } else {
238            self.alpha
239        };
240        let eta = if self.eta_by_topic {
241            self.eta / k_f
242        } else {
243            self.eta
244        };
245
246        for (name, value) in [("alpha", alpha), ("eta", eta)] {
247            if value <= F::zero() || !value.is_finite() {
248                return Err(MethodsErrors::LdaInvalidHyperparameter {
249                    name: name.to_string(),
250                    value: value.to_f64().unwrap_or(f64::NAN),
251                });
252            }
253        }
254
255        Ok((alpha, eta))
256    }
257}
258
259impl<F: BixverseFloat> Default for LdaParams<F> {
260    fn default() -> Self {
261        Self {
262            alpha: F::from_f64(50.0).unwrap(),
263            alpha_by_topic: true,
264            eta: F::from_f64(0.1).unwrap(),
265            eta_by_topic: false,
266            max_iter: 150,
267            tol: F::from_f64(1e-3).unwrap(),
268            inner_max_iter: 100,
269            inner_tol: F::from_f64(1e-3).unwrap(),
270            check_every: 10,
271            learning: LdaLearning::default(),
272            seed: 555,
273        }
274    }
275}
276
277/////////////
278// Results //
279/////////////
280
281/// A fitted LDA model.
282#[derive(Clone, Debug)]
283pub struct LdaResult<F: BixverseFloat> {
284    /// Topic proportions per document, shape `(k, n_docs)`. Columns sum to one,
285    /// so a document's topic vector is one contiguous column.
286    pub cell_topic: Mat<F>,
287    /// Term probabilities per topic, shape `(n_terms, k)`. Columns sum to one,
288    /// so a topic's term vector is one contiguous column.
289    pub topic_region: Mat<F>,
290    /// Final variational bound (ELBO). Higher is better.
291    pub bound: F,
292    /// Per-token perplexity, `exp(-bound / total_tokens)`. Lower is better.
293    pub perplexity: F,
294    /// Outer iterations actually run.
295    pub n_iter: usize,
296    /// Whether the relative bound change fell below `tol`.
297    pub converged: bool,
298}
299
300/// One fitted model plus its evaluation, as produced by a topic-count sweep.
301#[derive(Clone, Debug)]
302pub struct LdaSweepEntry<F: BixverseFloat> {
303    /// Topic count this entry was fitted with.
304    pub k: usize,
305    /// The fitted model.
306    pub model: LdaResult<F>,
307    /// Model selection metrics for it.
308    pub metrics: LdaMetrics<F>,
309}
310
311/// The outcome of a topic-count sweep.
312#[derive(Clone, Debug)]
313pub struct LdaSweepResult<F: BixverseFloat> {
314    /// One entry per requested topic count, in the order requested.
315    pub entries: Vec<LdaSweepEntry<F>>,
316    /// Combined rescaled score per entry. `NaN` for entries excluded from
317    /// selection by the coherence topic-count floor.
318    pub combined_score: Vec<F>,
319    /// Topic count with the highest combined score.
320    pub best_k: usize,
321}
322
323////////////
324// Corpus //
325////////////
326
327/// A corpus held in both orientations, with counts cast to the solver's float.
328///
329/// The E-step walks documents and the M-step walks terms, so keeping both
330/// layouts turns each into a contiguous scan. Cast once here rather than per
331/// touch, since the solver revisits every non-zero on every iteration.
332pub struct LdaCorpus<F: BixverseFloat> {
333    /// Documents x terms, CSR. Drives the E-step.
334    pub(crate) csr: CompressedSparseData2<F>,
335    /// Documents x terms, CSC. Drives the M-step.
336    pub(crate) csc: CompressedSparseData2<F>,
337    /// Number of documents.
338    pub(crate) n_docs: usize,
339    /// Number of terms.
340    pub(crate) n_terms: usize,
341    /// Total count mass, used to turn the bound into a perplexity.
342    pub(crate) total_tokens: f64,
343    /// Per-document count mass, needed by the Arun metric.
344    pub(crate) doc_lengths: Vec<F>,
345}
346
347impl<F: BixverseFloat + BixverseNumeric> LdaCorpus<F> {
348    /// Build a corpus from a documents x terms sparse matrix.
349    ///
350    /// Accepts either orientation of compressed storage and re-expresses it,
351    /// but the logical shape must already be documents x terms. A caller
352    /// holding terms x documents wants
353    /// [CompressedSparseData2::transpose_and_convert] first.
354    ///
355    /// ### Params
356    ///
357    /// * `matrix` - Documents x terms counts. Negative entries are rejected.
358    ///
359    /// ### Returns
360    ///
361    /// The [LdaCorpus], or an error if the matrix is empty or holds a negative
362    /// or non-finite count.
363    pub fn new<T, U>(matrix: &CompressedSparseData2<T, U>) -> Result<Self, MethodsErrors>
364    where
365        T: BixverseNumeric + ToPrimitive,
366        U: BixverseNumeric,
367    {
368        let (n_docs, n_terms) = matrix.shape();
369        if matrix.get_nnz() == 0 || n_docs == 0 || n_terms == 0 {
370            return Err(MethodsErrors::LdaEmptyMatrix);
371        }
372
373        let cast = cast_counts::<T, U, F>(matrix)?;
374        let (csr, csc) = match cast.cs_type {
375            CompressedSparseFormat::Csr => {
376                let csc = cast.transform();
377                (cast, csc)
378            }
379            CompressedSparseFormat::Csc => {
380                let csr = cast.transform();
381                (csr, cast)
382            }
383        };
384
385        let total_tokens = csr.data.iter().map(|v| v.to_f64().unwrap_or(0.0)).sum();
386        let doc_lengths = (0..n_docs)
387            .map(|d| {
388                let start = csr.indptr[d] as usize;
389                let end = csr.indptr[d + 1] as usize;
390                csr.data[start..end].iter().fold(F::zero(), |a, b| a + *b)
391            })
392            .collect();
393
394        Ok(Self {
395            csr,
396            csc,
397            n_docs,
398            n_terms,
399            total_tokens,
400            doc_lengths,
401        })
402    }
403}
404
405/// Cast a sparse matrix's primary counts into the solver's float type.
406///
407/// The second data layer, if any, is dropped: LDA reads counts and nothing
408/// else.
409///
410/// ### Params
411///
412/// * `matrix` - Source matrix, whose layout is carried over unchanged.
413///
414/// ### Returns
415///
416/// The same matrix with `F` data, or an error on a negative or non-finite
417/// count.
418fn cast_counts<T, U, F>(
419    matrix: &CompressedSparseData2<T, U>,
420) -> Result<CompressedSparseData2<F>, MethodsErrors>
421where
422    T: BixverseNumeric + ToPrimitive,
423    U: BixverseNumeric,
424    F: BixverseFloat + BixverseNumeric,
425{
426    let mut data = Vec::with_capacity(matrix.data.len());
427    for v in &matrix.data {
428        let f = F::from_f64(v.to_f64().ok_or(MethodsErrors::LdaNonFinite)?)
429            .ok_or(MethodsErrors::LdaNonFinite)?;
430        if !f.is_finite() || f < F::zero() {
431            return Err(MethodsErrors::LdaNonFinite);
432        }
433        data.push(f);
434    }
435
436    Ok(CompressedSparseData2::from_parts(
437        data,
438        matrix.indices.clone(),
439        matrix.indptr.clone(),
440        None,
441        matrix.cs_type,
442        matrix.shape,
443    ))
444}
445
446//////////
447// Main //
448//////////
449
450/// Fit an LDA model to a documents x terms count matrix.
451///
452/// Variational Bayes throughout, see the module documentation for the layout
453/// and the deviation from the collapsed Gibbs sampler cisTopic uses.
454///
455/// ### Params
456///
457/// * `matrix` - Documents x terms counts, either compressed orientation. For a
458///   cisTopic-style run this is the binarised cells x regions matrix.
459/// * `k` - Number of topics.
460/// * `params` - Solver options, `None` for [LdaParams::default].
461/// * `verbose` - Verbosity level, parsed by [parse_verbosity_level].
462///
463/// ### Returns
464///
465/// The fitted [LdaResult].
466pub fn lda_fit<T, U, F>(
467    matrix: &CompressedSparseData2<T, U>,
468    k: usize,
469    params: Option<LdaParams<F>>,
470    verbose: usize,
471) -> Result<LdaResult<F>, MethodsErrors>
472where
473    T: BixverseNumeric + ToPrimitive,
474    U: BixverseNumeric,
475    F: BixverseFloat + BixverseNumeric + BixverseSimd,
476{
477    let params = params.unwrap_or_default();
478    let corpus = LdaCorpus::new(matrix)?;
479    fit_vb(&corpus, k, &params, parse_verbosity_level(verbose))
480}
481
482/// Fit LDA across a range of topic counts and score each fit.
483///
484/// Each fit is already parallel over documents, so the sweep runs the topic
485/// counts sequentially rather than nesting `rayon` pools. The corpus is built
486/// once and shared.
487///
488/// ### Params
489///
490/// * `matrix` - Documents x terms counts, as for [lda_fit].
491/// * `ks` - Topic counts to try.
492/// * `params` - Solver options, `None` for [LdaParams::default].
493/// * `top_topics_coh` - Top-scoring topics averaged into the reported
494///   coherence, `None` for [DEFAULT_TOP_TOPICS_COH].
495/// * `verbose` - Verbosity level, parsed by [parse_verbosity_level].
496///
497/// ### Returns
498///
499/// The [LdaSweepResult], carrying every fit, its metrics, and the selected
500/// topic count.
501pub fn lda_k_sweep<T, U, F>(
502    matrix: &CompressedSparseData2<T, U>,
503    ks: &[usize],
504    params: Option<LdaParams<F>>,
505    top_topics_coh: Option<usize>,
506    verbose: usize,
507) -> Result<LdaSweepResult<F>, MethodsErrors>
508where
509    T: BixverseNumeric + ToPrimitive,
510    U: BixverseNumeric,
511    F: BixverseFloat + BixverseNumeric + BixverseSimd,
512{
513    if ks.is_empty() {
514        return Err(MethodsErrors::LdaInvalidTopicCount {
515            requested: 0,
516            max_available: 0,
517        });
518    }
519
520    let params = params.unwrap_or_default();
521    let verbosity = parse_verbosity_level(verbose);
522    let top_topics_coh = top_topics_coh.unwrap_or(DEFAULT_TOP_TOPICS_COH);
523    let corpus = LdaCorpus::new(matrix)?;
524
525    let mut entries = Vec::with_capacity(ks.len());
526    for &k in ks {
527        if verbosity.normal_verbosity() {
528            println!("LDA: fitting k = {k}");
529        }
530        let model = fit_vb(&corpus, k, &params, verbosity)?;
531        let metrics = lda_metrics(&corpus, &model, top_topics_coh)?;
532        entries.push(LdaSweepEntry { k, model, metrics });
533    }
534
535    let (combined_score, best_k) = metrics::select_best_k(&entries, DEFAULT_MIN_TOPICS_COH);
536
537    Ok(LdaSweepResult {
538        entries,
539        combined_score,
540        best_k,
541    })
542}