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, ¶ms, 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, ¶ms, 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}