use faer::Mat;
use num_traits::ToPrimitive;
use crate::prelude::*;
pub mod metrics;
pub mod vb;
pub use vb::lda_bound;
use metrics::{LdaMetrics, lda_metrics};
use vb::fit_vb;
const LDA_DOC_SEED_MULT: u64 = 0x9E37_79B9_7F4A_7C15;
const LDA_INIT_SHAPE: f64 = 100.0;
const LDA_INIT_SCALE: f64 = 0.01;
const LDA_PHI_EPS: f64 = 1e-100;
pub const DEFAULT_TOP_TOPICS_COH: usize = 5;
pub const DEFAULT_MIN_TOPICS_COH: usize = 5;
#[derive(Clone, Copy, Debug, Default)]
pub enum LdaLearning {
#[default]
Batch,
Online {
batch_size: usize,
tau0: f64,
kappa: f64,
n_epochs: usize,
},
}
pub fn parse_lda_learning(s: &str, batch_size: usize, n_epochs: usize) -> Option<LdaLearning> {
match s.to_lowercase().as_str() {
"batch" | "full" => Some(LdaLearning::Batch),
"online" | "stochastic" | "minibatch" => Some(LdaLearning::Online {
batch_size,
tau0: 10.0,
kappa: 0.7,
n_epochs,
}),
_ => None,
}
}
#[derive(Clone, Copy, Debug)]
pub struct LdaParams<F: BixverseFloat> {
pub alpha: F,
pub alpha_by_topic: bool,
pub eta: F,
pub eta_by_topic: bool,
pub max_iter: usize,
pub tol: F,
pub inner_max_iter: usize,
pub inner_tol: F,
pub check_every: usize,
pub learning: LdaLearning,
pub seed: u64,
}
impl<F: BixverseFloat> LdaParams<F> {
#[allow(clippy::too_many_arguments)]
pub fn new(
alpha: F,
alpha_by_topic: bool,
eta: F,
eta_by_topic: bool,
max_iter: usize,
tol: F,
inner_max_iter: usize,
inner_tol: F,
check_every: usize,
learning: LdaLearning,
seed: u64,
) -> Self {
Self {
alpha,
alpha_by_topic,
eta,
eta_by_topic,
max_iter,
tol,
inner_max_iter,
inner_tol,
check_every,
learning,
seed,
}
}
pub fn resolve_priors(&self, k: usize) -> Result<(F, F), MethodsErrors> {
let k_f = F::from_usize(k).unwrap();
let alpha = if self.alpha_by_topic {
self.alpha / k_f
} else {
self.alpha
};
let eta = if self.eta_by_topic {
self.eta / k_f
} else {
self.eta
};
for (name, value) in [("alpha", alpha), ("eta", eta)] {
if value <= F::zero() || !value.is_finite() {
return Err(MethodsErrors::LdaInvalidHyperparameter {
name: name.to_string(),
value: value.to_f64().unwrap_or(f64::NAN),
});
}
}
Ok((alpha, eta))
}
}
impl<F: BixverseFloat> Default for LdaParams<F> {
fn default() -> Self {
Self {
alpha: F::from_f64(50.0).unwrap(),
alpha_by_topic: true,
eta: F::from_f64(0.1).unwrap(),
eta_by_topic: false,
max_iter: 150,
tol: F::from_f64(1e-3).unwrap(),
inner_max_iter: 100,
inner_tol: F::from_f64(1e-3).unwrap(),
check_every: 10,
learning: LdaLearning::default(),
seed: 555,
}
}
}
#[derive(Clone, Debug)]
pub struct LdaResult<F: BixverseFloat> {
pub cell_topic: Mat<F>,
pub topic_region: Mat<F>,
pub bound: F,
pub perplexity: F,
pub n_iter: usize,
pub converged: bool,
}
#[derive(Clone, Debug)]
pub struct LdaSweepEntry<F: BixverseFloat> {
pub k: usize,
pub model: LdaResult<F>,
pub metrics: LdaMetrics<F>,
}
#[derive(Clone, Debug)]
pub struct LdaSweepResult<F: BixverseFloat> {
pub entries: Vec<LdaSweepEntry<F>>,
pub combined_score: Vec<F>,
pub best_k: usize,
}
pub struct LdaCorpus<F: BixverseFloat> {
pub(crate) csr: CompressedSparseData2<F>,
pub(crate) csc: CompressedSparseData2<F>,
pub(crate) n_docs: usize,
pub(crate) n_terms: usize,
pub(crate) total_tokens: f64,
pub(crate) doc_lengths: Vec<F>,
}
impl<F: BixverseFloat + BixverseNumeric> LdaCorpus<F> {
pub fn new<T, U>(matrix: &CompressedSparseData2<T, U>) -> Result<Self, MethodsErrors>
where
T: BixverseNumeric + ToPrimitive,
U: BixverseNumeric,
{
let (n_docs, n_terms) = matrix.shape();
if matrix.get_nnz() == 0 || n_docs == 0 || n_terms == 0 {
return Err(MethodsErrors::LdaEmptyMatrix);
}
let cast = cast_counts::<T, U, F>(matrix)?;
let (csr, csc) = match cast.cs_type {
CompressedSparseFormat::Csr => {
let csc = cast.transform();
(cast, csc)
}
CompressedSparseFormat::Csc => {
let csr = cast.transform();
(csr, cast)
}
};
let total_tokens = csr.data.iter().map(|v| v.to_f64().unwrap_or(0.0)).sum();
let doc_lengths = (0..n_docs)
.map(|d| {
let start = csr.indptr[d] as usize;
let end = csr.indptr[d + 1] as usize;
csr.data[start..end].iter().fold(F::zero(), |a, b| a + *b)
})
.collect();
Ok(Self {
csr,
csc,
n_docs,
n_terms,
total_tokens,
doc_lengths,
})
}
}
fn cast_counts<T, U, F>(
matrix: &CompressedSparseData2<T, U>,
) -> Result<CompressedSparseData2<F>, MethodsErrors>
where
T: BixverseNumeric + ToPrimitive,
U: BixverseNumeric,
F: BixverseFloat + BixverseNumeric,
{
let mut data = Vec::with_capacity(matrix.data.len());
for v in &matrix.data {
let f = F::from_f64(v.to_f64().ok_or(MethodsErrors::LdaNonFinite)?)
.ok_or(MethodsErrors::LdaNonFinite)?;
if !f.is_finite() || f < F::zero() {
return Err(MethodsErrors::LdaNonFinite);
}
data.push(f);
}
Ok(CompressedSparseData2::from_parts(
data,
matrix.indices.clone(),
matrix.indptr.clone(),
None,
matrix.cs_type,
matrix.shape,
))
}
pub fn lda_fit<T, U, F>(
matrix: &CompressedSparseData2<T, U>,
k: usize,
params: Option<LdaParams<F>>,
verbose: usize,
) -> Result<LdaResult<F>, MethodsErrors>
where
T: BixverseNumeric + ToPrimitive,
U: BixverseNumeric,
F: BixverseFloat + BixverseNumeric + BixverseSimd,
{
let params = params.unwrap_or_default();
let corpus = LdaCorpus::new(matrix)?;
fit_vb(&corpus, k, ¶ms, parse_verbosity_level(verbose))
}
pub fn lda_k_sweep<T, U, F>(
matrix: &CompressedSparseData2<T, U>,
ks: &[usize],
params: Option<LdaParams<F>>,
top_topics_coh: Option<usize>,
verbose: usize,
) -> Result<LdaSweepResult<F>, MethodsErrors>
where
T: BixverseNumeric + ToPrimitive,
U: BixverseNumeric,
F: BixverseFloat + BixverseNumeric + BixverseSimd,
{
if ks.is_empty() {
return Err(MethodsErrors::LdaInvalidTopicCount {
requested: 0,
max_available: 0,
});
}
let params = params.unwrap_or_default();
let verbosity = parse_verbosity_level(verbose);
let top_topics_coh = top_topics_coh.unwrap_or(DEFAULT_TOP_TOPICS_COH);
let corpus = LdaCorpus::new(matrix)?;
let mut entries = Vec::with_capacity(ks.len());
for &k in ks {
if verbosity.normal_verbosity() {
println!("LDA: fitting k = {k}");
}
let model = fit_vb(&corpus, k, ¶ms, verbosity)?;
let metrics = lda_metrics(&corpus, &model, top_topics_coh)?;
entries.push(LdaSweepEntry { k, model, metrics });
}
let (combined_score, best_k) = metrics::select_best_k(&entries, DEFAULT_MIN_TOPICS_COH);
Ok(LdaSweepResult {
entries,
combined_score,
best_k,
})
}