ferrolearn_decomp/lda_topic.rs
1//! Latent Dirichlet Allocation (LDA) topic model.
2//!
3//! [`LatentDirichletAllocation`] discovers latent topics in a document-term
4//! matrix using variational inference. This is the *topic model* LDA, **not**
5//! Linear Discriminant Analysis (which lives in `ferrolearn-linear`).
6//!
7//! # Algorithm
8//!
9//! Two solvers are supported:
10//!
11//! - **Batch** variational EM: iterates over the full corpus each step.
12//! E-step updates per-document topic distributions; M-step updates the
13//! global topic-word distributions.
14//! - **Online** variational Bayes (Hoffman et al. 2010): processes mini-batches
15//! and uses a decaying learning rate to update global parameters
16//! incrementally.
17//!
18//! # Examples
19//!
20//! ```
21//! use ferrolearn_decomp::LatentDirichletAllocation;
22//! use ferrolearn_core::traits::{Fit, Transform};
23//! use ndarray::array;
24//!
25//! // Simple 4-document, 6-word corpus
26//! let dtm = array![
27//! [1.0, 1.0, 1.0, 0.0, 0.0, 0.0],
28//! [1.0, 1.0, 0.0, 0.0, 0.0, 0.0],
29//! [0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
30//! [0.0, 0.0, 0.0, 1.0, 1.0, 0.0],
31//! ];
32//! let lda = LatentDirichletAllocation::new(2).with_random_state(42);
33//! let fitted = lda.fit(&dtm, &()).unwrap();
34//! let topics = fitted.transform(&dtm).unwrap();
35//! assert_eq!(topics.dim(), (4, 2));
36//! ```
37//!
38//! ## REQ status
39//!
40//! Design: `.design/decomp/lda_topic.md`. Tracking: #1540. Each REQ is BINARY —
41//! SHIPPED (impl + non-test consumer + tests + green verification) or NOT-STARTED
42//! (concrete open blocker). Non-test consumer: crate re-export (`lib.rs:91`); there
43//! is NO PyO3 binding. Oracle = live sklearn 1.5.2 (`_lda.py`,
44//! `class LatentDirichletAllocation` — topic model, NOT LDA-discriminant), run from
45//! `/tmp` (R-CHAR-3). ferrolearn is a SIMPLIFIED f64-only variational Bayes
46//! reimplementation; exact component/topic VALUES are a carve-out (Uniform+beta init
47//! vs sklearn Gamma(100,0.01) + numpy RNG).
48//!
49//! | REQ | Scope | Status | Evidence / Blocker |
50//! |---|---|---|---|
51//! | REQ-1 | Structural: `components_` shape `(n_topics,n_words)`, `n_iter_`==max_iter, seed-determinism, digamma accuracy | SHIPPED (scoped) | `fit` stores `components_=lambda` (`:440`), `n_iter_=max_iter` (`:443`, matches sklearn default `evaluate_every=-1` `_lda.py:695`); `digamma` (`:277`) matches scipy.special.psi ~1.17e-10; green-guards + in-module tests. STRUCTURAL, NOT values (REQ-4) |
52//! | REQ-2 | `components_` non-negativity | SHIPPED | M-step adds non-negative suff-stats to non-negative init; `test_lda_components_non_negative` + green-guard |
53//! | REQ-3 | transform doc-topic shape + each row sums to 1 + topic separation + error contracts (incl. NON-FINITE rejection) | SHIPPED (scoped) | `transform` normalizes gamma rows (= sklearn `_lda.py:745`); fit/transform guards. NON-FINITE: `fit`+`transform` call `reject_non_finite` (`lda_topic.rs` symbol `reject_non_finite`) BEFORE the non-negativity check and the VB iterations, returning the CLEAN finiteness `InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."}` = sklearn `_check_non_neg_array`'s `_validate_data(force_all_finite=True)` finiteness-before-non-negativity (`_lda.py:566` before `:572`, `utils/validation.py:147-154`). `tests/divergence_nonfinite_spillover.rs::divergence_lda_fit_nan` matches the live sklearn 1.5.2 oracle (#2290). FLAG: sklearn raises `ValueError`, defaults n_components=10, doesn't pre-reject 0 words |
54//! | REQ-4 | EXACT `components_` value parity | NOT-STARTED | CARVE-OUT (R-DEFER-3): Uniform+beta/Xoshiro init vs Gamma(100,0.01)/numpy RandomState VI (`_lda.py:419-421`) — blocker #1541 |
55//! | REQ-5 | transform doc-topic VALUE parity | NOT-STARTED | CARVE-OUT, folds into REQ-4 (downstream of components_, no injectable API) — blocker #1542 |
56//! | REQ-6 | Gamma(100,0.01) init (components + per-doc gamma) | NOT-STARTED | sklearn `_lda.py:96-99,:419-421` — blocker #1543 |
57//! | REQ-7 | `exp_dirichlet_component_` representation/attr | NOT-STARTED | sklearn `_lda.py:424`; ferrolearn log-space on the fly — blocker #1544 |
58//! | REQ-8 | `perplexity`/`score`/`_approx_bound` | NOT-STARTED | sklearn `_lda.py:748,:827,:896` — blocker #1545 |
59//! | REQ-9 | `evaluate_every`/`perp_tol` perplexity early stop | NOT-STARTED | sklearn `_lda.py:676-691`; ferrolearn fixed max_iter loop — blocker #1546 |
60//! | REQ-10 | `batch_size`/`total_samples` online mini-batching | NOT-STARTED | sklearn `_lda.py:662,:535-538`; ferrolearn batch-of-1 — blocker #1547 |
61//! | REQ-11 | fitted attrs `n_features_in_`/`bound_` | NOT-STARTED | sklearn `_lda.py:701-703` — blocker #1548 |
62//! | REQ-12 | `n_jobs`/`verbose` | NOT-STARTED | sklearn `_lda.py:378-379` — blocker #1549 |
63//! | REQ-13 | generic `F` (f32+f64) | NOT-STARTED | f64-only — blocker #1550 |
64//! | REQ-14 | PyO3 binding | NOT-STARTED | absent; only consumer re-export `lib.rs:91` — blocker #1551 |
65//! | REQ-15 | ferray substrate | NOT-STARTED | `ndarray`+`rand`+hand-rolled digamma — blocker #1552 |
66//!
67//! Count: **3 SHIPPED (REQ-1,2,3) / 12 NOT-STARTED (REQ-4..15)**.
68
69use ferrolearn_core::error::FerroError;
70use ferrolearn_core::traits::{Fit, Transform};
71use ndarray::Array2;
72use rand::SeedableRng;
73use rand_distr::{Distribution, Uniform};
74use rand_xoshiro::Xoshiro256PlusPlus;
75
76/// Reject non-finite input the way sklearn's `_check_non_neg_array` does.
77///
78/// sklearn's `LatentDirichletAllocation` runs `_check_non_neg_array` which calls
79/// `_validate_data` with the default `force_all_finite=True`
80/// (`sklearn/decomposition/_lda.py:566`) BEFORE the non-negativity check
81/// (`check_non_negative`, `:572`) and any variational-Bayes math, raising
82/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
83/// (`sklearn/utils/validation.py:147-154`). NaN AND infinity are both rejected,
84/// finiteness BEFORE non-negativity. The message names "NaN" and "infinity" to
85/// mirror sklearn's `ValueError`. Never panics (R-CODE-2).
86fn reject_non_finite(x: &Array2<f64>) -> Result<(), FerroError> {
87 if x.iter().any(|v| !v.is_finite()) {
88 return Err(FerroError::InvalidParameter {
89 name: "X".into(),
90 reason: "Input X contains NaN or infinity.".into(),
91 });
92 }
93 Ok(())
94}
95
96// ---------------------------------------------------------------------------
97// Learning method enum
98// ---------------------------------------------------------------------------
99
100/// The learning method for LDA.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum LdaLearningMethod {
103 /// Batch variational EM — iterates over the full corpus each step.
104 Batch,
105 /// Online variational Bayes (Hoffman et al. 2010).
106 Online,
107}
108
109// ---------------------------------------------------------------------------
110// LatentDirichletAllocation (unfitted)
111// ---------------------------------------------------------------------------
112
113/// Latent Dirichlet Allocation configuration.
114///
115/// Holds hyperparameters for the LDA topic model. Calling [`Fit::fit`]
116/// learns topic-word distributions and returns a
117/// [`FittedLatentDirichletAllocation`].
118#[derive(Debug, Clone)]
119pub struct LatentDirichletAllocation {
120 /// Number of topics to extract.
121 n_components: usize,
122 /// Maximum number of E-M iterations (batch) or passes (online).
123 max_iter: usize,
124 /// Learning method.
125 learning_method: LdaLearningMethod,
126 /// Offset for learning rate in online mode (default 10.0).
127 learning_offset: f64,
128 /// Decay for learning rate in online mode (default 0.7).
129 learning_decay: f64,
130 /// Document-topic prior (Dirichlet alpha). None = 1/n_components.
131 doc_topic_prior: Option<f64>,
132 /// Topic-word prior (Dirichlet beta). None = 1/n_components.
133 topic_word_prior: Option<f64>,
134 /// Maximum E-step iterations per document.
135 max_doc_update_iter: usize,
136 /// Optional random seed.
137 random_state: Option<u64>,
138}
139
140impl LatentDirichletAllocation {
141 /// Create a new `LatentDirichletAllocation` with `n_components` topics.
142 ///
143 /// Defaults: `max_iter=10`, `learning_method=Batch`,
144 /// `learning_offset=10.0`, `learning_decay=0.7`,
145 /// priors=`1/n_components`, `max_doc_update_iter=100`.
146 #[must_use]
147 pub fn new(n_components: usize) -> Self {
148 Self {
149 n_components,
150 max_iter: 10,
151 learning_method: LdaLearningMethod::Batch,
152 learning_offset: 10.0,
153 learning_decay: 0.7,
154 doc_topic_prior: None,
155 topic_word_prior: None,
156 max_doc_update_iter: 100,
157 random_state: None,
158 }
159 }
160
161 /// Set the maximum number of iterations.
162 #[must_use]
163 pub fn with_max_iter(mut self, n: usize) -> Self {
164 self.max_iter = n;
165 self
166 }
167
168 /// Set the learning method.
169 #[must_use]
170 pub fn with_learning_method(mut self, m: LdaLearningMethod) -> Self {
171 self.learning_method = m;
172 self
173 }
174
175 /// Set the learning offset (online mode).
176 #[must_use]
177 pub fn with_learning_offset(mut self, v: f64) -> Self {
178 self.learning_offset = v;
179 self
180 }
181
182 /// Set the learning decay (online mode).
183 #[must_use]
184 pub fn with_learning_decay(mut self, v: f64) -> Self {
185 self.learning_decay = v;
186 self
187 }
188
189 /// Set the document-topic prior (alpha).
190 #[must_use]
191 pub fn with_doc_topic_prior(mut self, v: f64) -> Self {
192 self.doc_topic_prior = Some(v);
193 self
194 }
195
196 /// Set the topic-word prior (beta).
197 #[must_use]
198 pub fn with_topic_word_prior(mut self, v: f64) -> Self {
199 self.topic_word_prior = Some(v);
200 self
201 }
202
203 /// Set the random seed.
204 #[must_use]
205 pub fn with_random_state(mut self, seed: u64) -> Self {
206 self.random_state = Some(seed);
207 self
208 }
209
210 /// Set the maximum E-step iterations per document.
211 #[must_use]
212 pub fn with_max_doc_update_iter(mut self, n: usize) -> Self {
213 self.max_doc_update_iter = n;
214 self
215 }
216
217 /// Return the configured number of topics.
218 #[must_use]
219 pub fn n_components(&self) -> usize {
220 self.n_components
221 }
222
223 /// Return the configured maximum iterations.
224 #[must_use]
225 pub fn max_iter(&self) -> usize {
226 self.max_iter
227 }
228
229 /// Return the configured learning method.
230 #[must_use]
231 pub fn learning_method(&self) -> LdaLearningMethod {
232 self.learning_method
233 }
234
235 /// Return the configured learning offset.
236 #[must_use]
237 pub fn learning_offset(&self) -> f64 {
238 self.learning_offset
239 }
240
241 /// Return the configured learning decay.
242 #[must_use]
243 pub fn learning_decay(&self) -> f64 {
244 self.learning_decay
245 }
246
247 /// Return the configured document-topic prior, if explicitly set.
248 #[must_use]
249 pub fn doc_topic_prior(&self) -> Option<f64> {
250 self.doc_topic_prior
251 }
252
253 /// Return the configured topic-word prior, if explicitly set.
254 #[must_use]
255 pub fn topic_word_prior(&self) -> Option<f64> {
256 self.topic_word_prior
257 }
258
259 /// Return the configured random state, if any.
260 #[must_use]
261 pub fn random_state(&self) -> Option<u64> {
262 self.random_state
263 }
264}
265
266// ---------------------------------------------------------------------------
267// FittedLatentDirichletAllocation
268// ---------------------------------------------------------------------------
269
270/// A fitted LDA model holding the learned topic-word distributions.
271///
272/// Created by calling [`Fit::fit`] on a [`LatentDirichletAllocation`].
273/// Implements [`Transform<Array2<f64>>`] to compute document-topic
274/// distributions for new documents.
275#[derive(Debug, Clone)]
276pub struct FittedLatentDirichletAllocation {
277 /// Topic-word distribution (un-normalised), shape `(n_topics, n_words)`.
278 /// The `components_[k][w]` entry is proportional to the probability
279 /// of word `w` in topic `k`.
280 components_: Array2<f64>,
281 /// Document-topic prior (alpha).
282 alpha_: f64,
283 /// Topic-word prior (beta).
284 beta_: f64,
285 /// Number of iterations performed.
286 n_iter_: usize,
287 /// Maximum E-step iterations per document.
288 max_doc_update_iter_: usize,
289}
290
291impl FittedLatentDirichletAllocation {
292 /// Topic-word distribution, shape `(n_topics, n_words)`.
293 ///
294 /// Each row is a (possibly un-normalised) distribution over the
295 /// vocabulary for one topic.
296 #[must_use]
297 pub fn components(&self) -> &Array2<f64> {
298 &self.components_
299 }
300
301 /// Number of iterations performed during fitting.
302 #[must_use]
303 pub fn n_iter(&self) -> usize {
304 self.n_iter_
305 }
306
307 /// The document-topic prior used during fitting.
308 #[must_use]
309 pub fn alpha(&self) -> f64 {
310 self.alpha_
311 }
312
313 /// The topic-word prior used during fitting.
314 #[must_use]
315 pub fn beta(&self) -> f64 {
316 self.beta_
317 }
318}
319
320// ---------------------------------------------------------------------------
321// Internal: digamma approximation
322// ---------------------------------------------------------------------------
323
324/// Approximate digamma function (psi) using the asymptotic expansion.
325///
326/// For x >= 6 uses the series; for x < 6 uses the recurrence
327/// psi(x) = psi(x+1) - 1/x.
328fn digamma(x: f64) -> f64 {
329 if x <= 0.0 {
330 return f64::NAN;
331 }
332 let mut val = x;
333 let mut result = 0.0;
334 // Use recurrence to bring val >= 6.
335 while val < 6.0 {
336 result -= 1.0 / val;
337 val += 1.0;
338 }
339 // Asymptotic expansion.
340 result += val.ln() - 0.5 / val;
341 let inv2 = 1.0 / (val * val);
342 result -=
343 inv2 * (1.0 / 12.0 - inv2 * (1.0 / 120.0 - inv2 * (1.0 / 252.0 - inv2 * 1.0 / 240.0)));
344 result
345}
346
347/// Compute the E-step for a single document.
348///
349/// Given the document word counts `doc` (length V) and the current
350/// topic-word log expectations `e_log_beta` (shape K x V), compute the
351/// variational parameters `gamma` (length K, document-topic).
352///
353/// Returns the gamma vector (un-normalised document-topic distribution).
354fn e_step_doc(doc: &[f64], e_log_beta: &Array2<f64>, alpha: f64, max_iter: usize) -> Vec<f64> {
355 let n_topics = e_log_beta.nrows();
356 let n_words = e_log_beta.ncols();
357
358 // Initialise gamma uniformly.
359 let mut gamma = vec![alpha + (n_words as f64) / (n_topics as f64); n_topics];
360
361 for _iter in 0..max_iter {
362 let e_log_theta: Vec<f64> = gamma.iter().map(|&g| digamma(g)).collect();
363 let gamma_sum_dig = digamma(gamma.iter().sum::<f64>());
364
365 let mut new_gamma = vec![alpha; n_topics];
366
367 for w in 0..n_words {
368 if doc[w] < 1e-16 {
369 continue;
370 }
371 // Compute log of un-normalised phi for each topic.
372 let mut log_phi = Vec::with_capacity(n_topics);
373 let mut max_log = f64::NEG_INFINITY;
374 for k in 0..n_topics {
375 let v = e_log_theta[k] - gamma_sum_dig + e_log_beta[[k, w]];
376 log_phi.push(v);
377 if v > max_log {
378 max_log = v;
379 }
380 }
381 // Normalise in log space.
382 let mut sum_phi = 0.0;
383 let mut phi = Vec::with_capacity(n_topics);
384 for lp in &log_phi {
385 let p = (lp - max_log).exp();
386 phi.push(p);
387 sum_phi += p;
388 }
389 if sum_phi < 1e-16 {
390 sum_phi = 1e-16;
391 }
392 for k in 0..n_topics {
393 new_gamma[k] += doc[w] * phi[k] / sum_phi;
394 }
395 }
396
397 // Check convergence.
398 let mut diff = 0.0;
399 for k in 0..n_topics {
400 diff += (new_gamma[k] - gamma[k]).abs();
401 }
402 gamma = new_gamma;
403 if diff < 1e-3 {
404 break;
405 }
406 }
407
408 gamma
409}
410
411// ---------------------------------------------------------------------------
412// Trait implementations
413// ---------------------------------------------------------------------------
414
415impl Fit<Array2<f64>, ()> for LatentDirichletAllocation {
416 type Fitted = FittedLatentDirichletAllocation;
417 type Error = FerroError;
418
419 /// Fit the LDA model on a document-term matrix.
420 ///
421 /// # Errors
422 ///
423 /// - [`FerroError::InvalidParameter`] if `n_components` is zero or
424 /// any entry of the input is negative.
425 /// - [`FerroError::InsufficientSamples`] if there are zero documents or
426 /// zero words.
427 fn fit(&self, x: &Array2<f64>, _y: &()) -> Result<FittedLatentDirichletAllocation, FerroError> {
428 let (n_docs, n_words) = x.dim();
429
430 // Validate.
431 if self.n_components == 0 {
432 return Err(FerroError::InvalidParameter {
433 name: "n_components".into(),
434 reason: "must be at least 1".into(),
435 });
436 }
437 if n_docs == 0 {
438 return Err(FerroError::InsufficientSamples {
439 required: 1,
440 actual: 0,
441 context: "LatentDirichletAllocation::fit".into(),
442 });
443 }
444 if n_words == 0 {
445 return Err(FerroError::InvalidParameter {
446 name: "X".into(),
447 reason: "document-term matrix must have at least 1 word".into(),
448 });
449 }
450 // Reject NaN/Inf BEFORE the non-negativity check and the VB iterations
451 // (sklearn's `_check_non_neg_array` runs `_validate_data(force_all_finite
452 // =True)` at `_lda.py:566` before `check_non_negative` at `:572`,
453 // `utils/validation.py:147-154`).
454 reject_non_finite(x)?;
455 for &val in x {
456 if val < 0.0 {
457 return Err(FerroError::InvalidParameter {
458 name: "X".into(),
459 reason: "LDA requires non-negative entries in the document-term matrix".into(),
460 });
461 }
462 }
463
464 let n_topics = self.n_components;
465 let alpha = self.doc_topic_prior.unwrap_or(1.0 / n_topics as f64);
466 let beta = self.topic_word_prior.unwrap_or(1.0 / n_topics as f64);
467 let seed = self.random_state.unwrap_or(0);
468
469 // Initialise lambda (topic-word variational parameters) randomly.
470 let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
471 let uniform = Uniform::new(0.5, 1.5).unwrap();
472 let mut lambda = Array2::<f64>::zeros((n_topics, n_words));
473 for elem in &mut lambda {
474 *elem = uniform.sample(&mut rng) + beta;
475 }
476
477 match self.learning_method {
478 LdaLearningMethod::Batch => {
479 self.fit_batch(x, &mut lambda, alpha, beta, n_docs, n_words, n_topics);
480 }
481 LdaLearningMethod::Online => {
482 self.fit_online(
483 x,
484 &mut lambda,
485 alpha,
486 beta,
487 n_docs,
488 n_words,
489 n_topics,
490 &mut rng,
491 );
492 }
493 }
494
495 Ok(FittedLatentDirichletAllocation {
496 components_: lambda,
497 alpha_: alpha,
498 beta_: beta,
499 n_iter_: self.max_iter,
500 max_doc_update_iter_: self.max_doc_update_iter,
501 })
502 }
503}
504
505impl LatentDirichletAllocation {
506 /// Batch variational EM.
507 #[allow(clippy::too_many_arguments)]
508 fn fit_batch(
509 &self,
510 x: &Array2<f64>,
511 lambda: &mut Array2<f64>,
512 alpha: f64,
513 beta: f64,
514 n_docs: usize,
515 n_words: usize,
516 n_topics: usize,
517 ) {
518 for _outer in 0..self.max_iter {
519 // Compute E[log beta] from current lambda.
520 let e_log_beta = compute_e_log_beta(lambda, n_topics, n_words);
521
522 // Accumulate sufficient statistics.
523 let mut ss = Array2::<f64>::zeros((n_topics, n_words));
524
525 for d in 0..n_docs {
526 let doc: Vec<f64> = (0..n_words).map(|w| x[[d, w]]).collect();
527 let gamma = e_step_doc(&doc, &e_log_beta, alpha, self.max_doc_update_iter);
528
529 // Compute phi for this document and accumulate.
530 let e_log_theta: Vec<f64> = gamma.iter().map(|&g| digamma(g)).collect();
531 let gamma_sum_dig = digamma(gamma.iter().sum::<f64>());
532
533 for w in 0..n_words {
534 if doc[w] < 1e-16 {
535 continue;
536 }
537 let mut log_phi = Vec::with_capacity(n_topics);
538 let mut max_log = f64::NEG_INFINITY;
539 for k in 0..n_topics {
540 let v = e_log_theta[k] - gamma_sum_dig + e_log_beta[[k, w]];
541 log_phi.push(v);
542 if v > max_log {
543 max_log = v;
544 }
545 }
546 let mut phi = Vec::with_capacity(n_topics);
547 let mut sum_phi = 0.0;
548 for lp in &log_phi {
549 let p = (lp - max_log).exp();
550 phi.push(p);
551 sum_phi += p;
552 }
553 if sum_phi < 1e-16 {
554 sum_phi = 1e-16;
555 }
556 for k in 0..n_topics {
557 ss[[k, w]] += doc[w] * phi[k] / sum_phi;
558 }
559 }
560 }
561
562 // M-step: update lambda.
563 for k in 0..n_topics {
564 for w in 0..n_words {
565 lambda[[k, w]] = beta + ss[[k, w]];
566 }
567 }
568 }
569 }
570
571 /// Online variational Bayes (Hoffman et al. 2010).
572 #[allow(clippy::too_many_arguments)]
573 fn fit_online(
574 &self,
575 x: &Array2<f64>,
576 lambda: &mut Array2<f64>,
577 alpha: f64,
578 beta: f64,
579 n_docs: usize,
580 n_words: usize,
581 n_topics: usize,
582 _rng: &mut Xoshiro256PlusPlus,
583 ) {
584 let mut update_count = 0u64;
585
586 for _outer in 0..self.max_iter {
587 // Process each document as a mini-batch of size 1.
588 for d in 0..n_docs {
589 let doc: Vec<f64> = (0..n_words).map(|w| x[[d, w]]).collect();
590
591 let e_log_beta = compute_e_log_beta(lambda, n_topics, n_words);
592 let gamma = e_step_doc(&doc, &e_log_beta, alpha, self.max_doc_update_iter);
593
594 // Compute sufficient statistics for this document.
595 let e_log_theta: Vec<f64> = gamma.iter().map(|&g| digamma(g)).collect();
596 let gamma_sum_dig = digamma(gamma.iter().sum::<f64>());
597
598 let mut ss = Array2::<f64>::zeros((n_topics, n_words));
599 for w in 0..n_words {
600 if doc[w] < 1e-16 {
601 continue;
602 }
603 let mut log_phi = Vec::with_capacity(n_topics);
604 let mut max_log = f64::NEG_INFINITY;
605 for k in 0..n_topics {
606 let v = e_log_theta[k] - gamma_sum_dig + e_log_beta[[k, w]];
607 log_phi.push(v);
608 if v > max_log {
609 max_log = v;
610 }
611 }
612 let mut phi = Vec::with_capacity(n_topics);
613 let mut sum_phi = 0.0;
614 for lp in &log_phi {
615 let p = (lp - max_log).exp();
616 phi.push(p);
617 sum_phi += p;
618 }
619 if sum_phi < 1e-16 {
620 sum_phi = 1e-16;
621 }
622 for k in 0..n_topics {
623 ss[[k, w]] += doc[w] * phi[k] / sum_phi;
624 }
625 }
626
627 // Online update with decaying step size.
628 update_count += 1;
629 let rho = (self.learning_offset + update_count as f64).powf(-self.learning_decay);
630
631 // lambda_new = (1-rho)*lambda + rho*(beta + n_docs * ss)
632 let n_docs_f = n_docs as f64;
633 for k in 0..n_topics {
634 for w in 0..n_words {
635 let target = beta + n_docs_f * ss[[k, w]];
636 lambda[[k, w]] = (1.0 - rho) * lambda[[k, w]] + rho * target;
637 }
638 }
639 }
640 }
641 }
642}
643
644/// Compute E[log beta] from lambda (the variational parameters for topic-word).
645fn compute_e_log_beta(lambda: &Array2<f64>, n_topics: usize, n_words: usize) -> Array2<f64> {
646 let mut e_log_beta = Array2::<f64>::zeros((n_topics, n_words));
647 for k in 0..n_topics {
648 let row_sum: f64 = (0..n_words).map(|w| lambda[[k, w]]).sum();
649 let dig_sum = digamma(row_sum);
650 for w in 0..n_words {
651 e_log_beta[[k, w]] = digamma(lambda[[k, w]]) - dig_sum;
652 }
653 }
654 e_log_beta
655}
656
657impl Transform<Array2<f64>> for FittedLatentDirichletAllocation {
658 type Output = Array2<f64>;
659 type Error = FerroError;
660
661 /// Compute the document-topic distribution for new documents.
662 ///
663 /// Returns an array of shape `(n_docs, n_topics)` where each row sums
664 /// approximately to 1.
665 ///
666 /// # Errors
667 ///
668 /// - [`FerroError::ShapeMismatch`] if the number of words does not match
669 /// the vocabulary size from fitting.
670 /// - [`FerroError::InvalidParameter`] if any entry is negative.
671 fn transform(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
672 let n_words = self.components_.ncols();
673 if x.ncols() != n_words {
674 return Err(FerroError::ShapeMismatch {
675 expected: vec![x.nrows(), n_words],
676 actual: vec![x.nrows(), x.ncols()],
677 context: "FittedLatentDirichletAllocation::transform".into(),
678 });
679 }
680 // Reject NaN/Inf BEFORE the non-negativity check and the E-step (sklearn
681 // re-validates via `_check_non_neg_array` `_validate_data(force_all_finite
682 // =True)` `_lda.py:566` before `check_non_negative`, `utils/validation.py:147-154`).
683 reject_non_finite(x)?;
684 for &val in x {
685 if val < 0.0 {
686 return Err(FerroError::InvalidParameter {
687 name: "X".into(),
688 reason: "LDA requires non-negative entries".into(),
689 });
690 }
691 }
692
693 let n_docs = x.nrows();
694 let n_topics = self.components_.nrows();
695 let e_log_beta = compute_e_log_beta(&self.components_, n_topics, n_words);
696
697 let mut result = Array2::<f64>::zeros((n_docs, n_topics));
698 for d in 0..n_docs {
699 let doc: Vec<f64> = (0..n_words).map(|w| x[[d, w]]).collect();
700 let gamma = e_step_doc(&doc, &e_log_beta, self.alpha_, self.max_doc_update_iter_);
701
702 // Normalise gamma to get document-topic proportions.
703 let gamma_sum: f64 = gamma.iter().sum();
704 if gamma_sum > 1e-16 {
705 for k in 0..n_topics {
706 result[[d, k]] = gamma[k] / gamma_sum;
707 }
708 } else {
709 // Uniform fallback.
710 let uniform = 1.0 / n_topics as f64;
711 for k in 0..n_topics {
712 result[[d, k]] = uniform;
713 }
714 }
715 }
716
717 Ok(result)
718 }
719}
720
721// ---------------------------------------------------------------------------
722// Tests
723// ---------------------------------------------------------------------------
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use approx::assert_abs_diff_eq;
729 use ndarray::array;
730
731 /// Simple two-topic corpus.
732 fn two_topic_corpus() -> Array2<f64> {
733 array![
734 [5.0, 5.0, 5.0, 0.0, 0.0, 0.0],
735 [4.0, 6.0, 3.0, 0.0, 0.0, 0.0],
736 [5.0, 4.0, 6.0, 0.0, 0.0, 0.0],
737 [0.0, 0.0, 0.0, 5.0, 5.0, 5.0],
738 [0.0, 0.0, 0.0, 6.0, 4.0, 3.0],
739 [0.0, 0.0, 0.0, 4.0, 6.0, 5.0],
740 ]
741 }
742
743 #[test]
744 fn test_lda_basic_shape() {
745 let dtm = two_topic_corpus();
746 let lda = LatentDirichletAllocation::new(2).with_random_state(42);
747 let fitted = lda.fit(&dtm, &()).unwrap();
748 assert_eq!(fitted.components().dim(), (2, 6));
749 }
750
751 #[test]
752 fn test_lda_transform_shape() {
753 let dtm = two_topic_corpus();
754 let lda = LatentDirichletAllocation::new(2).with_random_state(42);
755 let fitted = lda.fit(&dtm, &()).unwrap();
756 let topics = fitted.transform(&dtm).unwrap();
757 assert_eq!(topics.dim(), (6, 2));
758 }
759
760 #[test]
761 fn test_lda_topic_proportions_sum_to_one() {
762 let dtm = two_topic_corpus();
763 let lda = LatentDirichletAllocation::new(2)
764 .with_max_iter(20)
765 .with_random_state(42);
766 let fitted = lda.fit(&dtm, &()).unwrap();
767 let topics = fitted.transform(&dtm).unwrap();
768 for i in 0..topics.nrows() {
769 let sum: f64 = topics.row(i).sum();
770 assert_abs_diff_eq!(sum, 1.0, epsilon = 1e-5);
771 }
772 }
773
774 #[test]
775 fn test_lda_topics_distinguish_groups() {
776 let dtm = two_topic_corpus();
777 let lda = LatentDirichletAllocation::new(2)
778 .with_max_iter(30)
779 .with_random_state(42);
780 let fitted = lda.fit(&dtm, &()).unwrap();
781 let topics = fitted.transform(&dtm).unwrap();
782
783 // First 3 docs should cluster on one topic, last 3 on another.
784 // Check that the dominant topic differs between the two groups.
785 let first_group_topic: Vec<usize> = (0..3)
786 .map(|i| {
787 if topics[[i, 0]] > topics[[i, 1]] {
788 0
789 } else {
790 1
791 }
792 })
793 .collect();
794 let second_group_topic: Vec<usize> = (3..6)
795 .map(|i| {
796 if topics[[i, 0]] > topics[[i, 1]] {
797 0
798 } else {
799 1
800 }
801 })
802 .collect();
803
804 // At least 2 out of 3 in each group should agree on the topic.
805 let fg_mode = if first_group_topic.iter().filter(|&&t| t == 0).count() >= 2 {
806 0
807 } else {
808 1
809 };
810 let sg_mode = if second_group_topic.iter().filter(|&&t| t == 0).count() >= 2 {
811 0
812 } else {
813 1
814 };
815
816 assert_ne!(
817 fg_mode, sg_mode,
818 "the two document groups should be assigned to different topics"
819 );
820 }
821
822 #[test]
823 fn test_lda_online_learning() {
824 let dtm = two_topic_corpus();
825 let lda = LatentDirichletAllocation::new(2)
826 .with_learning_method(LdaLearningMethod::Online)
827 .with_max_iter(10)
828 .with_random_state(42);
829 let fitted = lda.fit(&dtm, &()).unwrap();
830 assert_eq!(fitted.components().dim(), (2, 6));
831 let topics = fitted.transform(&dtm).unwrap();
832 // Each row should sum to ~1.
833 for i in 0..topics.nrows() {
834 let sum: f64 = topics.row(i).sum();
835 assert_abs_diff_eq!(sum, 1.0, epsilon = 1e-5);
836 }
837 }
838
839 #[test]
840 fn test_lda_components_non_negative() {
841 let dtm = two_topic_corpus();
842 let lda = LatentDirichletAllocation::new(2).with_random_state(42);
843 let fitted = lda.fit(&dtm, &()).unwrap();
844 for &val in fitted.components() {
845 assert!(val >= 0.0, "component should be non-negative, got {val}");
846 }
847 }
848
849 #[test]
850 fn test_lda_transform_shape_mismatch() {
851 let dtm = two_topic_corpus();
852 let lda = LatentDirichletAllocation::new(2).with_random_state(42);
853 let fitted = lda.fit(&dtm, &()).unwrap();
854 let bad = array![[1.0, 2.0, 3.0]]; // 3 words instead of 6
855 assert!(fitted.transform(&bad).is_err());
856 }
857
858 #[test]
859 fn test_lda_transform_negative_rejected() {
860 let dtm = two_topic_corpus();
861 let lda = LatentDirichletAllocation::new(2).with_random_state(42);
862 let fitted = lda.fit(&dtm, &()).unwrap();
863 let bad = array![[1.0, -1.0, 0.0, 0.0, 0.0, 0.0]];
864 assert!(fitted.transform(&bad).is_err());
865 }
866
867 #[test]
868 fn test_lda_invalid_n_components_zero() {
869 let dtm = two_topic_corpus();
870 let lda = LatentDirichletAllocation::new(0);
871 assert!(lda.fit(&dtm, &()).is_err());
872 }
873
874 #[test]
875 fn test_lda_negative_input_rejected() {
876 let dtm = array![[1.0, -1.0], [2.0, 3.0]];
877 let lda = LatentDirichletAllocation::new(1);
878 assert!(lda.fit(&dtm, &()).is_err());
879 }
880
881 #[test]
882 fn test_lda_empty_corpus() {
883 let dtm = Array2::<f64>::zeros((0, 5));
884 let lda = LatentDirichletAllocation::new(2);
885 assert!(lda.fit(&dtm, &()).is_err());
886 }
887
888 #[test]
889 fn test_lda_zero_words() {
890 let dtm = Array2::<f64>::zeros((5, 0));
891 let lda = LatentDirichletAllocation::new(2);
892 assert!(lda.fit(&dtm, &()).is_err());
893 }
894
895 #[test]
896 fn test_lda_getters() {
897 let lda = LatentDirichletAllocation::new(5)
898 .with_max_iter(20)
899 .with_learning_method(LdaLearningMethod::Online)
900 .with_learning_offset(15.0)
901 .with_learning_decay(0.5)
902 .with_doc_topic_prior(0.1)
903 .with_topic_word_prior(0.01)
904 .with_random_state(99);
905 assert_eq!(lda.n_components(), 5);
906 assert_eq!(lda.max_iter(), 20);
907 assert_eq!(lda.learning_method(), LdaLearningMethod::Online);
908 assert!((lda.learning_offset() - 15.0).abs() < 1e-10);
909 assert!((lda.learning_decay() - 0.5).abs() < 1e-10);
910 assert_eq!(lda.doc_topic_prior(), Some(0.1));
911 assert_eq!(lda.topic_word_prior(), Some(0.01));
912 assert_eq!(lda.random_state(), Some(99));
913 }
914
915 #[test]
916 fn test_lda_fitted_accessors() {
917 let dtm = two_topic_corpus();
918 let lda = LatentDirichletAllocation::new(2)
919 .with_doc_topic_prior(0.5)
920 .with_topic_word_prior(0.1)
921 .with_random_state(42);
922 let fitted = lda.fit(&dtm, &()).unwrap();
923 assert!((fitted.alpha() - 0.5).abs() < 1e-10);
924 assert!((fitted.beta() - 0.1).abs() < 1e-10);
925 assert!(fitted.n_iter() > 0);
926 }
927
928 #[test]
929 fn test_lda_single_topic() {
930 let dtm = two_topic_corpus();
931 let lda = LatentDirichletAllocation::new(1).with_random_state(42);
932 let fitted = lda.fit(&dtm, &()).unwrap();
933 let topics = fitted.transform(&dtm).unwrap();
934 assert_eq!(topics.ncols(), 1);
935 // With 1 topic, all documents should have proportion ~1.
936 for i in 0..topics.nrows() {
937 assert_abs_diff_eq!(topics[[i, 0]], 1.0, epsilon = 1e-3);
938 }
939 }
940
941 #[test]
942 fn test_digamma_basic() {
943 // digamma(1) = -gamma (Euler-Mascheroni constant) ~ -0.5772
944 let val = digamma(1.0);
945 assert!((val - (-0.5772156649)).abs() < 1e-4, "digamma(1) = {val}");
946 }
947
948 #[test]
949 fn test_digamma_large() {
950 // digamma(10) ~ 2.2517525890
951 let val = digamma(10.0);
952 assert!((val - 2.2517525890).abs() < 1e-4, "digamma(10) = {val}");
953 }
954}