Skip to main content

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}