1use faer::Mat;
13use rand::SeedableRng;
14use rand::rngs::SmallRng;
15use rand::seq::SliceRandom;
16use rand_distr::{Distribution, Gamma};
17use rayon::prelude::*;
18use statrs::function::gamma::ln_gamma;
19
20use crate::prelude::*;
21use bixverse_core::core::math::special::digamma;
22
23use super::{
24 LDA_DOC_SEED_MULT, LDA_INIT_SCALE, LDA_INIT_SHAPE, LDA_PHI_EPS, LdaCorpus, LdaLearning,
25 LdaParams, LdaResult,
26};
27
28const BOUND_TERM_CHUNK: usize = 1024;
34
35pub(crate) struct ColMajor<F> {
46 data: Vec<F>,
48 rows: usize,
50}
51
52impl<F: BixverseFloat + Send + Sync> ColMajor<F> {
53 fn filled(rows: usize, cols: usize, value: F) -> Self {
65 Self {
66 data: vec![value; rows * cols],
67 rows,
68 }
69 }
70
71 #[inline]
81 pub(crate) fn col(&self, j: usize) -> &[F] {
82 &self.data[j * self.rows..(j + 1) * self.rows]
83 }
84
85 fn set_col(&mut self, j: usize, src: &[F]) {
92 self.data[j * self.rows..(j + 1) * self.rows].copy_from_slice(src);
93 }
94
95 fn from_mat(mat: faer::MatRef<F>) -> Self {
105 let (rows, cols) = (mat.nrows(), mat.ncols());
106 let mut data = Vec::with_capacity(rows * cols);
107 for j in 0..cols {
108 for i in 0..rows {
109 data.push(mat[(i, j)]);
110 }
111 }
112 Self { data, rows }
113 }
114
115 fn par_cols_mut(&mut self) -> impl IndexedParallelIterator<Item = &mut [F]> {
121 self.data.par_chunks_mut(self.rows)
122 }
123
124 fn to_mat(&self) -> Mat<F> {
130 let cols = self.data.len() / self.rows;
131 Mat::from_fn(self.rows, cols, |i, j| self.data[j * self.rows + i])
132 }
133}
134
135#[inline]
145fn exp_expected_log<F: BixverseFloat + BixverseSimd>(param: &[F], out: &mut [F]) {
146 let total = F::bxv_sum(param);
147 let norm = digamma(total);
148 for (o, p) in out.iter_mut().zip(param) {
149 *o = (digamma(*p) - norm).exp();
150 }
151}
152
153#[allow(clippy::too_many_arguments)]
175fn e_step_document<F: BixverseFloat + BixverseSimd + Send + Sync>(
176 ids: &[u32],
177 cts: &[F],
178 exp_elog_beta: &ColMajor<F>,
179 gamma_d: &mut [F],
180 exp_elog_theta_d: &mut [F],
181 alpha: F,
182 inner_max_iter: usize,
183 inner_tol: F,
184) {
185 let k = gamma_d.len();
186 let eps = F::from_f64(LDA_PHI_EPS).unwrap();
187
188 let mut acc = vec![F::zero(); k];
189 exp_expected_log(gamma_d, exp_elog_theta_d);
190
191 for _ in 0..inner_max_iter {
192 acc.iter_mut().for_each(|v| *v = F::zero());
193 for (&id, &ct) in ids.iter().zip(cts) {
194 let beta_col = exp_elog_beta.col(id as usize);
195 let phinorm = F::bxv_dot_simd(exp_elog_theta_d, beta_col) + eps;
196 F::bxv_axpy_simd(&mut acc, ct / phinorm, beta_col);
197 }
198
199 let mut change = F::zero();
200 let mut total = F::zero();
201 for ((g, a), t) in gamma_d
202 .iter_mut()
203 .zip(&acc)
204 .zip(exp_elog_theta_d.iter().copied())
205 {
206 let updated = alpha + t * *a;
207 change += (updated - *g).abs();
208 total += updated;
209 *g = updated;
210 }
211
212 exp_expected_log(gamma_d, exp_elog_theta_d);
213
214 if change < inner_tol * total {
215 break;
216 }
217 }
218}
219
220fn e_step<F: BixverseFloat + BixverseSimd + BixverseNumeric>(
240 corpus: &LdaCorpus<F>,
241 docs: &[usize],
242 exp_elog_beta: &ColMajor<F>,
243 gamma: &mut ColMajor<F>,
244 exp_elog_theta: &mut ColMajor<F>,
245 alpha: F,
246 params: &LdaParams<F>,
247) {
248 debug_assert!(
249 {
250 let mut seen = rustc_hash::FxHashSet::default();
251 docs.iter().all(|d| seen.insert(*d))
252 },
253 "e_step received duplicate document indices, which would alias the writes below"
254 );
255
256 let k = gamma.rows;
257 let csr = &corpus.csr;
258 let seed = params.seed;
259
260 let gamma_ptr = gamma.data.as_mut_ptr() as usize;
263 let theta_ptr = exp_elog_theta.data.as_mut_ptr() as usize;
264
265 docs.par_iter().for_each(|&d| {
266 let start = csr.indptr[d] as usize;
267 let end = csr.indptr[d + 1] as usize;
268 let ids = &csr.indices[start..end];
269 let cts = &csr.data[start..end];
270
271 let mut rng = SmallRng::seed_from_u64(seed ^ (d as u64).wrapping_mul(LDA_DOC_SEED_MULT));
272 let dist = Gamma::new(LDA_INIT_SHAPE, LDA_INIT_SCALE).unwrap();
273 let mut gamma_d: Vec<F> = (0..k)
274 .map(|_| F::from_f64(dist.sample(&mut rng)).unwrap())
275 .collect();
276 let mut theta_d = vec![F::zero(); k];
277
278 e_step_document(
279 ids,
280 cts,
281 exp_elog_beta,
282 &mut gamma_d,
283 &mut theta_d,
284 alpha,
285 params.inner_max_iter,
286 params.inner_tol,
287 );
288
289 unsafe {
292 let g = (gamma_ptr as *mut F).add(d * k);
293 let t = (theta_ptr as *mut F).add(d * k);
294 std::ptr::copy_nonoverlapping(gamma_d.as_ptr(), g, k);
295 std::ptr::copy_nonoverlapping(theta_d.as_ptr(), t, k);
296 }
297 });
298}
299
300fn m_step_sstats<F: BixverseFloat + BixverseSimd + BixverseNumeric>(
324 corpus: &LdaCorpus<F>,
325 doc_mask: Option<&[bool]>,
326 exp_elog_beta: &ColMajor<F>,
327 exp_elog_theta: &ColMajor<F>,
328 sstats: &mut ColMajor<F>,
329) -> f64 {
330 let csc = &corpus.csc;
331 let eps = F::from_f64(LDA_PHI_EPS).unwrap();
332
333 sstats
334 .par_cols_mut()
335 .enumerate()
336 .map(|(w, out)| {
337 out.iter_mut().for_each(|v| *v = F::zero());
338 let start = csc.indptr[w] as usize;
339 let end = csc.indptr[w + 1] as usize;
340 let beta_col = exp_elog_beta.col(w);
341
342 let mut token_ll = 0.0_f64;
343 for idx in start..end {
344 let d = csc.indices[idx] as usize;
345 if let Some(mask) = doc_mask
346 && !mask[d]
347 {
348 continue;
349 }
350 let ct = csc.data[idx];
351 let theta_col = exp_elog_theta.col(d);
352 let phinorm = F::bxv_dot_simd(theta_col, beta_col) + eps;
353 F::bxv_axpy_simd(out, ct / phinorm, theta_col);
354 token_ll += ct.to_f64().unwrap_or(0.0) * phinorm.to_f64().unwrap_or(0.0).ln();
355 }
356 token_ll
357 })
358 .sum()
359}
360
361fn apply_sstats<F: BixverseFloat + Send + Sync>(
375 sstats: &ColMajor<F>,
376 exp_elog_beta: &ColMajor<F>,
377 lambda: &mut ColMajor<F>,
378 eta: F,
379 blend: Option<(F, F)>,
380) {
381 match blend {
382 None => lambda
383 .data
384 .par_iter_mut()
385 .zip(sstats.data.par_iter())
386 .zip(exp_elog_beta.data.par_iter())
387 .for_each(|((l, s), b)| *l = eta + *s * *b),
388 Some((rho, scale)) => {
389 let one_minus = F::one() - rho;
390 lambda
391 .data
392 .par_iter_mut()
393 .zip(sstats.data.par_iter())
394 .zip(exp_elog_beta.data.par_iter())
395 .for_each(|((l, s), b)| *l = one_minus * *l + rho * (eta + scale * *s * *b));
396 }
397 }
398}
399
400fn dirichlet_bound<F: BixverseFloat + BixverseSimd>(param: &[F], prior: F) -> f64 {
423 let n = param.len();
424 let prior_f = prior.to_f64().unwrap_or(0.0);
425 let total = F::bxv_sum(param);
426 let norm = digamma(total).to_f64().unwrap_or(0.0);
427
428 let mut acc = 0.0_f64;
429 for p in param {
430 let p_f = p.to_f64().unwrap_or(0.0);
431 let e_log = digamma(*p).to_f64().unwrap_or(0.0) - norm;
432 acc += (prior_f - p_f) * e_log + ln_gamma(p_f) - ln_gamma(prior_f);
433 }
434
435 acc + ln_gamma(prior_f * n as f64) - ln_gamma(total.to_f64().unwrap_or(0.0))
436}
437
438#[allow(clippy::too_many_arguments)]
459fn variational_bound<F: BixverseFloat + BixverseSimd + Send + Sync>(
460 token_ll: f64,
461 gamma: &ColMajor<F>,
462 lambda: &ColMajor<F>,
463 docs: &[usize],
464 n_terms: usize,
465 alpha: F,
466 eta: F,
467 doc_scale: f64,
468) -> f64 {
469 let doc_part: f64 = docs
470 .par_iter()
471 .map(|&d| dirichlet_bound(gamma.col(d), alpha))
472 .sum();
473
474 let topic_part = topic_dirichlet_bound(lambda, eta, n_terms);
475
476 (token_ll + doc_part) * doc_scale + topic_part
477}
478
479fn topic_dirichlet_bound<F: BixverseFloat + BixverseSimd + Send + Sync>(
496 lambda: &ColMajor<F>,
497 eta: F,
498 n_terms: usize,
499) -> f64 {
500 let k = lambda.rows;
501 let eta_f = eta.to_f64().unwrap_or(0.0);
502
503 let mut row_sums = vec![F::zero(); k];
504 for w in 0..n_terms {
505 F::bxv_axpy_simd(&mut row_sums, F::one(), lambda.col(w));
506 }
507 let norms: Vec<f64> = row_sums
508 .iter()
509 .map(|s| digamma(*s).to_f64().unwrap_or(0.0))
510 .collect();
511
512 let acc: Vec<f64> = lambda
513 .data
514 .par_chunks(k * BOUND_TERM_CHUNK)
515 .fold(
516 || vec![0.0_f64; k],
517 |mut acc, chunk| {
518 for col in chunk.chunks_exact(k) {
519 for ((a, v), norm) in acc.iter_mut().zip(col).zip(&norms) {
520 let p = v.to_f64().unwrap_or(0.0);
521 *a += (eta_f - p) * (digamma(p) - norm) + ln_gamma(p);
522 }
523 }
524 acc
525 },
526 )
527 .reduce(
528 || vec![0.0_f64; k],
529 |mut a, b| {
530 a.iter_mut().zip(&b).for_each(|(x, y)| *x += y);
531 a
532 },
533 );
534
535 let const_part = ln_gamma(eta_f * n_terms as f64) - n_terms as f64 * ln_gamma(eta_f);
536 acc.iter()
537 .zip(&row_sums)
538 .map(|(a, total)| a + const_part - ln_gamma(total.to_f64().unwrap_or(0.0)))
539 .sum()
540}
541
542fn init_lambda<F: BixverseFloat + Send + Sync>(k: usize, n_terms: usize, seed: u64) -> ColMajor<F> {
561 let mut lambda = ColMajor::filled(k, n_terms, F::zero());
562 lambda.par_cols_mut().enumerate().for_each(|(w, col)| {
563 let mut rng =
564 SmallRng::seed_from_u64(seed ^ (w as u64).wrapping_mul(LDA_DOC_SEED_MULT) ^ 0xA5A5);
565 let dist = Gamma::new(LDA_INIT_SHAPE, LDA_INIT_SCALE).unwrap();
566 for v in col.iter_mut() {
567 *v = F::from_f64(dist.sample(&mut rng)).unwrap();
568 }
569 });
570 lambda
571}
572
573fn normalise_columns<F: BixverseFloat + BixverseSimd + Send + Sync>(buf: &mut ColMajor<F>) {
579 buf.par_cols_mut().for_each(|col| {
580 let total = F::bxv_sum(col);
581 if total > F::zero() {
582 col.iter_mut().for_each(|v| *v /= total);
583 }
584 });
585}
586
587pub(crate) fn fit_vb<F>(
600 corpus: &LdaCorpus<F>,
601 k: usize,
602 params: &LdaParams<F>,
603 verbosity: Verbosity,
604) -> Result<LdaResult<F>, MethodsErrors>
605where
606 F: BixverseFloat + BixverseNumeric + BixverseSimd,
607{
608 if k == 0 || k > corpus.n_terms {
609 return Err(MethodsErrors::LdaInvalidTopicCount {
610 requested: k,
611 max_available: corpus.n_terms,
612 });
613 }
614 let (alpha, eta) = params.resolve_priors(k)?;
615
616 let n_docs = corpus.n_docs;
617 let n_terms = corpus.n_terms;
618
619 let mut lambda = init_lambda::<F>(k, n_terms, params.seed);
620 let mut exp_elog_beta = ColMajor::filled(k, n_terms, F::zero());
621 let mut sstats = ColMajor::filled(k, n_terms, F::zero());
622 let mut gamma = ColMajor::filled(k, n_docs, alpha);
623 let mut exp_elog_theta = ColMajor::filled(k, n_docs, F::zero());
624
625 let check_every = params.check_every.max(1);
628
629 let all_docs: Vec<usize> = (0..n_docs).collect();
630 let mut previous_bound = f64::NEG_INFINITY;
631 let mut bound = f64::NEG_INFINITY;
632 let mut converged = false;
633 let mut n_iter = 0;
634
635 match params.learning {
636 LdaLearning::Batch => {
637 for iter in 0..params.max_iter {
638 n_iter = iter + 1;
639 update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
640 e_step(
641 corpus,
642 &all_docs,
643 &exp_elog_beta,
644 &mut gamma,
645 &mut exp_elog_theta,
646 alpha,
647 params,
648 );
649 let token_ll =
650 m_step_sstats(corpus, None, &exp_elog_beta, &exp_elog_theta, &mut sstats);
651 apply_sstats(&sstats, &exp_elog_beta, &mut lambda, eta, None);
652
653 let due = n_iter % check_every == 0 || n_iter == params.max_iter;
654 if due {
655 bound = variational_bound(
656 token_ll, &gamma, &lambda, &all_docs, n_terms, alpha, eta, 1.0,
657 );
658 if verbosity.normal_verbosity() {
659 println!("LDA: iteration {n_iter}, bound {bound:.4}");
660 }
661 if previous_bound.is_finite() {
662 let rel = (bound - previous_bound).abs() / previous_bound.abs().max(1.0);
663 if rel < params.tol.to_f64().unwrap_or(0.0) {
664 converged = true;
665 break;
666 }
667 }
668 previous_bound = bound;
669 }
670 }
671 }
672 LdaLearning::Online {
673 batch_size,
674 tau0,
675 kappa,
676 n_epochs,
677 } => {
678 let batch_size = batch_size.clamp(1, n_docs);
679 let mut order = all_docs.clone();
680 let mut rng = SmallRng::seed_from_u64(params.seed);
681 let mut step = 0_u64;
682
683 for epoch in 0..n_epochs {
684 order.shuffle(&mut rng);
685 for batch in order.chunks(batch_size) {
686 n_iter += 1;
687 let mut mask = vec![false; n_docs];
688 batch.iter().for_each(|&d| mask[d] = true);
689
690 update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
691 e_step(
692 corpus,
693 batch,
694 &exp_elog_beta,
695 &mut gamma,
696 &mut exp_elog_theta,
697 alpha,
698 params,
699 );
700 let token_ll = m_step_sstats(
701 corpus,
702 Some(&mask),
703 &exp_elog_beta,
704 &exp_elog_theta,
705 &mut sstats,
706 );
707
708 let rho = F::from_f64((tau0 + step as f64).powf(-kappa)).unwrap();
709 let scale =
710 F::from_usize(n_docs).unwrap() / F::from_usize(batch.len()).unwrap();
711 apply_sstats(
712 &sstats,
713 &exp_elog_beta,
714 &mut lambda,
715 eta,
716 Some((rho, scale)),
717 );
718 step += 1;
719
720 bound = variational_bound(
721 token_ll,
722 &gamma,
723 &lambda,
724 batch,
725 n_terms,
726 alpha,
727 eta,
728 n_docs as f64 / batch.len() as f64,
729 );
730 }
731
732 if verbosity.normal_verbosity() {
733 println!("LDA: epoch {}, bound estimate {bound:.4}", epoch + 1);
734 }
735 if previous_bound.is_finite() {
736 let rel = (bound - previous_bound).abs() / previous_bound.abs().max(1.0);
737 if rel < params.tol.to_f64().unwrap_or(0.0) {
738 converged = true;
739 break;
740 }
741 }
742 previous_bound = bound;
743 }
744
745 update_exp_elog_beta(&lambda, &mut exp_elog_beta, n_terms);
749 e_step(
750 corpus,
751 &all_docs,
752 &exp_elog_beta,
753 &mut gamma,
754 &mut exp_elog_theta,
755 alpha,
756 params,
757 );
758 }
759 }
760
761 if lambda.data.iter().any(|v| !v.is_finite()) || gamma.data.iter().any(|v| !v.is_finite()) {
762 return Err(MethodsErrors::LdaNonFinite);
763 }
764
765 normalise_columns(&mut gamma);
766 let cell_topic = gamma.to_mat();
767
768 let topic_region = topic_region_from_lambda(&lambda, k, n_terms);
771
772 let bound_f = F::from_f64(bound).unwrap_or(F::neg_infinity());
773 let perplexity = if corpus.total_tokens > 0.0 && bound.is_finite() {
774 F::from_f64((-bound / corpus.total_tokens).exp()).unwrap_or(F::infinity())
775 } else {
776 F::infinity()
777 };
778
779 Ok(LdaResult {
780 cell_topic,
781 topic_region,
782 bound: bound_f,
783 perplexity,
784 n_iter,
785 converged,
786 })
787}
788
789pub fn lda_bound<F>(
813 corpus: &LdaCorpus<F>,
814 lambda: faer::MatRef<F>,
815 gamma: faer::MatRef<F>,
816 alpha: F,
817 eta: F,
818) -> Result<f64, MethodsErrors>
819where
820 F: BixverseFloat + BixverseNumeric + BixverseSimd,
821{
822 let k = lambda.nrows();
823 if gamma.nrows() != k {
824 return Err(MethodsErrors::LdaDimensionMismatch {
825 expected: k,
826 got: gamma.nrows(),
827 });
828 }
829 if lambda.ncols() != corpus.n_terms {
830 return Err(MethodsErrors::LdaDimensionMismatch {
831 expected: corpus.n_terms,
832 got: lambda.ncols(),
833 });
834 }
835 if gamma.ncols() != corpus.n_docs {
836 return Err(MethodsErrors::LdaDimensionMismatch {
837 expected: corpus.n_docs,
838 got: gamma.ncols(),
839 });
840 }
841 for (name, value) in [("alpha", alpha), ("eta", eta)] {
842 if value <= F::zero() || !value.is_finite() {
843 return Err(MethodsErrors::LdaInvalidHyperparameter {
844 name: name.to_string(),
845 value: value.to_f64().unwrap_or(f64::NAN),
846 });
847 }
848 }
849
850 let lambda_buf = ColMajor::from_mat(lambda);
851 let gamma_buf = ColMajor::from_mat(gamma);
852
853 let mut exp_elog_beta = ColMajor::filled(k, corpus.n_terms, F::zero());
854 update_exp_elog_beta(&lambda_buf, &mut exp_elog_beta, corpus.n_terms);
855
856 let mut exp_elog_theta = ColMajor::filled(k, corpus.n_docs, F::zero());
857 for d in 0..corpus.n_docs {
858 let mut out = vec![F::zero(); k];
859 exp_expected_log(gamma_buf.col(d), &mut out);
860 exp_elog_theta.set_col(d, &out);
861 }
862
863 let mut sstats = ColMajor::filled(k, corpus.n_terms, F::zero());
864 let token_ll = m_step_sstats(corpus, None, &exp_elog_beta, &exp_elog_theta, &mut sstats);
865
866 let all_docs: Vec<usize> = (0..corpus.n_docs).collect();
867 Ok(variational_bound(
868 token_ll,
869 &gamma_buf,
870 &lambda_buf,
871 &all_docs,
872 corpus.n_terms,
873 alpha,
874 eta,
875 1.0,
876 ))
877}
878
879fn update_exp_elog_beta<F: BixverseFloat + BixverseSimd + Send + Sync>(
890 lambda: &ColMajor<F>,
891 out: &mut ColMajor<F>,
892 n_terms: usize,
893) {
894 let k = lambda.rows;
895 let mut row_sums = vec![F::zero(); k];
896 for w in 0..n_terms {
897 F::bxv_axpy_simd(&mut row_sums, F::one(), lambda.col(w));
898 }
899 let norms: Vec<F> = row_sums.iter().map(|s| digamma(*s)).collect();
900
901 let lambda_data = &lambda.data;
902 out.par_cols_mut().enumerate().for_each(|(w, col)| {
903 let src = &lambda_data[w * k..(w + 1) * k];
904 for ((o, v), n) in col.iter_mut().zip(src).zip(&norms) {
905 *o = (digamma(*v) - *n).exp();
906 }
907 });
908}
909
910fn topic_region_from_lambda<F: BixverseFloat + BixverseSimd + Send + Sync>(
922 lambda: &ColMajor<F>,
923 k: usize,
924 n_terms: usize,
925) -> Mat<F> {
926 let mut totals = vec![F::zero(); k];
927 for w in 0..n_terms {
928 F::bxv_axpy_simd(&mut totals, F::one(), lambda.col(w));
929 }
930 Mat::from_fn(n_terms, k, |w, topic| {
931 let total = totals[topic];
932 if total > F::zero() {
933 lambda.col(w)[topic] / total
934 } else {
935 F::zero()
936 }
937 })
938}
939
940#[cfg(test)]
945pub(crate) mod tests {
946 use super::*;
947 use crate::methods::lda::{LdaCorpus, lda_fit};
948 use approx::assert_relative_eq;
949
950 pub(crate) fn csr_from_rows(rows: &[Vec<f64>]) -> CompressedSparseData2<f64> {
960 let n_terms = rows[0].len();
961 let mut data = Vec::new();
962 let mut indices = Vec::new();
963 let mut indptr = vec![0_u32];
964 for row in rows {
965 for (w, &v) in row.iter().enumerate() {
966 if v != 0.0 {
967 data.push(v);
968 indices.push(w as u32);
969 }
970 }
971 indptr.push(data.len() as u32);
972 }
973 CompressedSparseData2::from_parts(
974 data,
975 indices,
976 indptr,
977 None,
978 CompressedSparseFormat::Csr,
979 (rows.len(), n_terms),
980 )
981 }
982
983 pub(crate) fn block_corpus(
994 docs_per_block: usize,
995 block_size: usize,
996 ) -> CompressedSparseData2<f64> {
997 let n_terms = 3 * block_size;
998 let mut rows = Vec::new();
999 for block in 0..3 {
1000 for d in 0..docs_per_block {
1001 let mut row = vec![0.0; n_terms];
1002 for t in 0..block_size {
1004 if (t + d) % block_size != 0 {
1005 row[block * block_size + t] = 1.0;
1006 }
1007 }
1008 rows.push(row);
1009 }
1010 }
1011 csr_from_rows(&rows)
1012 }
1013
1014 fn test_params() -> LdaParams<f64> {
1020 LdaParams {
1021 alpha: 0.1,
1026 alpha_by_topic: false,
1027 max_iter: 60,
1028 check_every: 1,
1029 tol: 1e-9,
1030 seed: 42,
1031 ..Default::default()
1032 }
1033 }
1034
1035 #[test]
1039 fn test_batch_bound_is_monotone() {
1040 let matrix = block_corpus(6, 5);
1041 let corpus = LdaCorpus::<f64>::new(&matrix).unwrap();
1042
1043 let mut previous = f64::NEG_INFINITY;
1044 for iter in 1..=25 {
1045 let p = LdaParams {
1046 max_iter: iter,
1047 ..test_params()
1048 };
1049 let bound = fit_vb(&corpus, 3, &p, Verbosity::Quiet).unwrap().bound;
1050 assert!(
1051 bound >= previous - 1e-6,
1052 "bound decreased at iteration {iter}: {previous} -> {bound}"
1053 );
1054 previous = bound;
1055 }
1056 }
1057
1058 #[test]
1060 fn test_recovers_disjoint_blocks() {
1061 let block_size = 5;
1062 let matrix = block_corpus(8, block_size);
1063 let model = lda_fit(&matrix, 3, Some(test_params()), 0).unwrap();
1064
1065 let mut claimed = [false; 3];
1066 for topic in 0..3 {
1067 let col = model.topic_region.col_as_slice(topic);
1068 let mass: Vec<f64> = (0..3)
1069 .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1070 .collect();
1071 let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1072 assert!(
1073 mass[best] > 0.9,
1074 "topic {topic} spread across blocks: {mass:?}"
1075 );
1076 assert!(!claimed[best], "two topics claimed block {best}");
1077 claimed[best] = true;
1078 }
1079 }
1080
1081 #[test]
1084 fn test_reproducible_across_thread_counts() {
1085 let matrix = block_corpus(6, 5);
1086 let params = test_params();
1087
1088 let run = |threads: usize| {
1089 rayon::ThreadPoolBuilder::new()
1090 .num_threads(threads)
1091 .build()
1092 .unwrap()
1093 .install(|| lda_fit(&matrix, 3, Some(params), 0).unwrap())
1094 };
1095
1096 let single = run(1);
1097 let multi = run(4);
1098 for topic in 0..3 {
1099 for (a, b) in single
1100 .topic_region
1101 .col_as_slice(topic)
1102 .iter()
1103 .zip(multi.topic_region.col_as_slice(topic))
1104 {
1105 assert_eq!(a, b);
1106 }
1107 }
1108 assert_relative_eq!(single.bound, multi.bound, max_relative = 1e-12);
1111 }
1112
1113 #[test]
1115 fn test_single_topic_is_degenerate() {
1116 let matrix = block_corpus(4, 4);
1117 let model = lda_fit(&matrix, 1, Some(test_params()), 0).unwrap();
1118 assert_eq!(model.cell_topic.nrows(), 1);
1119 for d in 0..model.cell_topic.ncols() {
1120 assert_relative_eq!(model.cell_topic[(0, d)], 1.0, epsilon = 1e-12);
1121 }
1122 let total: f64 = model.topic_region.col_as_slice(0).iter().sum();
1123 assert_relative_eq!(total, 1.0, epsilon = 1e-10);
1124 }
1125
1126 #[test]
1129 fn test_empty_document_falls_back_to_prior() {
1130 let rows = vec![
1131 vec![1.0, 1.0, 0.0, 0.0],
1132 vec![0.0, 0.0, 1.0, 1.0],
1133 vec![1.0, 1.0, 0.0, 0.0],
1134 vec![0.0; 4],
1135 ];
1136 let matrix = csr_from_rows(&rows);
1137 let model = lda_fit(&matrix, 2, Some(test_params()), 0).unwrap();
1138
1139 let empty = model.cell_topic.col_as_slice(3);
1140 assert!(empty.iter().all(|v| v.is_finite()));
1141 assert_relative_eq!(empty[0], 0.5, epsilon = 1e-9);
1142 assert_relative_eq!(empty[1], 0.5, epsilon = 1e-9);
1143 }
1144
1145 #[test]
1147 fn test_unused_term_keeps_prior_mass() {
1148 let rows = vec![
1149 vec![1.0, 1.0, 0.0, 0.0],
1150 vec![0.0, 1.0, 1.0, 0.0],
1151 vec![1.0, 0.0, 1.0, 0.0],
1152 ];
1153 let matrix = csr_from_rows(&rows);
1154 let model = lda_fit(&matrix, 2, Some(test_params()), 0).unwrap();
1155
1156 for topic in 0..2 {
1157 let col = model.topic_region.col_as_slice(topic);
1158 assert!(col[3] > 0.0 && col[3] < 0.05, "unused term got {}", col[3]);
1159 assert_relative_eq!(col.iter().sum::<f64>(), 1.0, epsilon = 1e-10);
1160 }
1161 }
1162
1163 #[test]
1165 fn test_outputs_are_normalised() {
1166 let matrix = block_corpus(5, 4);
1167 let model = lda_fit(&matrix, 3, Some(test_params()), 0).unwrap();
1168
1169 for d in 0..model.cell_topic.ncols() {
1170 let s: f64 = model.cell_topic.col_as_slice(d).iter().sum();
1171 assert_relative_eq!(s, 1.0, epsilon = 1e-10);
1172 }
1173 for topic in 0..model.topic_region.ncols() {
1174 let s: f64 = model.topic_region.col_as_slice(topic).iter().sum();
1175 assert_relative_eq!(s, 1.0, epsilon = 1e-10);
1176 }
1177 }
1178
1179 #[test]
1182 fn test_online_recovers_blocks() {
1183 let block_size = 5;
1184 let matrix = block_corpus(10, block_size);
1185 let params = LdaParams {
1186 learning: LdaLearning::Online {
1187 batch_size: 6,
1188 tau0: 10.0,
1189 kappa: 0.7,
1190 n_epochs: 40,
1191 },
1192 ..test_params()
1193 };
1194 let model = lda_fit(&matrix, 3, Some(params), 0).unwrap();
1195
1196 let mut claimed = [false; 3];
1197 for topic in 0..3 {
1198 let col = model.topic_region.col_as_slice(topic);
1199 let mass: Vec<f64> = (0..3)
1200 .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1201 .collect();
1202 let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1203 assert!(mass[best] > 0.8, "online topic {topic} spread: {mass:?}");
1204 claimed[best] = true;
1205 }
1206 assert!(claimed.iter().all(|c| *c), "online run collapsed topics");
1207 }
1208
1209 #[test]
1211 fn test_invalid_inputs_error() {
1212 let matrix = block_corpus(3, 3);
1213 assert!(matches!(
1214 lda_fit(&matrix, 0, Some(test_params()), 0),
1215 Err(MethodsErrors::LdaInvalidTopicCount { .. })
1216 ));
1217 assert!(matches!(
1218 lda_fit(&matrix, 1000, Some(test_params()), 0),
1219 Err(MethodsErrors::LdaInvalidTopicCount { .. })
1220 ));
1221
1222 let bad = LdaParams {
1223 eta: 0.0,
1224 ..test_params()
1225 };
1226 assert!(matches!(
1227 lda_fit(&matrix, 2, Some(bad), 0),
1228 Err(MethodsErrors::LdaInvalidHyperparameter { .. })
1229 ));
1230 }
1231
1232 #[test]
1235 fn test_csc_input_matches_csr() {
1236 let rows = vec![
1237 vec![1.0, 1.0, 0.0, 0.0],
1238 vec![0.0, 0.0, 1.0, 1.0],
1239 vec![1.0, 0.0, 1.0, 0.0],
1240 ];
1241 let csr = csr_from_rows(&rows);
1242 let csc = csr.transform();
1243
1244 let a = lda_fit(&csr, 2, Some(test_params()), 0).unwrap();
1245 let b = lda_fit(&csc, 2, Some(test_params()), 0).unwrap();
1246 assert_relative_eq!(a.bound, b.bound, epsilon = 1e-9);
1247 }
1248
1249 #[test]
1251 fn test_empty_matrix_errors() {
1252 let matrix = csr_from_rows(&[vec![0.0, 0.0], vec![0.0, 0.0]]);
1253 assert!(matches!(
1254 lda_fit(&matrix, 1, Some(test_params()), 0),
1255 Err(MethodsErrors::LdaEmptyMatrix)
1256 ));
1257 }
1258
1259 #[test]
1263 fn test_f32_recovers_blocks() {
1264 let block_size = 5;
1265 let dense = block_corpus(8, block_size);
1266 let data: Vec<f32> = dense.data.iter().map(|v| *v as f32).collect();
1267 let matrix: CompressedSparseData2<f32> = CompressedSparseData2::from_parts(
1268 data,
1269 dense.indices.clone(),
1270 dense.indptr.clone(),
1271 None,
1272 CompressedSparseFormat::Csr,
1273 dense.shape,
1274 );
1275
1276 let params = LdaParams::<f32> {
1277 alpha: 0.1,
1278 alpha_by_topic: false,
1279 max_iter: 60,
1280 check_every: 1,
1281 tol: 1e-7,
1282 seed: 42,
1283 ..Default::default()
1284 };
1285 let model = lda_fit(&matrix, 3, Some(params), 0).unwrap();
1286
1287 assert!(model.bound.is_finite());
1288 for topic in 0..3 {
1289 let col = model.topic_region.col_as_slice(topic);
1290 let mass: Vec<f32> = (0..3)
1291 .map(|b| col[b * block_size..(b + 1) * block_size].iter().sum())
1292 .collect();
1293 let best = (0..3).max_by(|&a, &b| mass[a].total_cmp(&mass[b])).unwrap();
1294 assert!(mass[best] > 0.9, "f32 topic {topic} spread: {mass:?}");
1295 }
1296 }
1297}