use std::collections::HashMap;
pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a < 1e-9 || norm_b < 1e-9 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
}
#[derive(Debug, Clone)]
pub struct TopicModel {
pub topic_id: u64,
pub centroid: Vec<f32>,
pub member_count: u64,
pub total_weight: f64,
pub label: String,
}
impl TopicModel {
pub fn coherence(&self) -> f64 {
self.member_count as f64 / (1.0 + self.total_weight)
}
}
#[derive(Debug, Clone)]
pub struct TopicAssignment {
pub doc_id: u64,
pub topic_id: u64,
pub confidence: f32,
pub assigned_at_secs: u64,
}
#[derive(Debug, Clone)]
pub struct ModellerConfig {
pub max_topics: usize,
pub new_topic_threshold: f32,
pub centroid_learning_rate: f32,
}
impl Default for ModellerConfig {
fn default() -> Self {
Self {
max_topics: 20,
new_topic_threshold: 0.5,
centroid_learning_rate: 0.1,
}
}
}
#[derive(Debug, Clone)]
pub struct TopicModellerStats {
pub total_documents: u64,
pub total_topics: usize,
pub avg_topic_size: f64,
pub largest_topic_members: u64,
}
pub struct SemanticTopicModeller {
pub topics: HashMap<u64, TopicModel>,
pub assignments: Vec<TopicAssignment>,
pub next_topic_id: u64,
pub config: ModellerConfig,
}
impl SemanticTopicModeller {
pub fn new(config: ModellerConfig) -> Self {
Self {
topics: HashMap::new(),
assignments: Vec::new(),
next_topic_id: 0,
config,
}
}
pub fn assign(&mut self, doc_id: u64, embedding: Vec<f32>, now_secs: u64) -> TopicAssignment {
if self.topics.is_empty() {
return self.create_topic(doc_id, embedding, now_secs);
}
let (best_id, best_sim) = self
.topics
.iter()
.map(|(id, t)| (*id, cosine_sim(&t.centroid, &embedding)))
.fold((0u64, f32::NEG_INFINITY), |(bi, bs), (id, s)| {
if s > bs {
(id, s)
} else {
(bi, bs)
}
});
let below_threshold = best_sim < self.config.new_topic_threshold;
let can_create = self.topics.len() < self.config.max_topics;
if below_threshold && can_create {
self.create_topic(doc_id, embedding, now_secs)
} else {
let lr = self.config.centroid_learning_rate;
let topic = self
.topics
.get_mut(&best_id)
.expect("best_id must exist in topics map");
for (c, e) in topic.centroid.iter_mut().zip(embedding.iter()) {
*c = (1.0 - lr) * (*c) + lr * e;
}
topic.member_count += 1;
topic.total_weight += best_sim as f64;
let assignment = TopicAssignment {
doc_id,
topic_id: best_id,
confidence: best_sim,
assigned_at_secs: now_secs,
};
self.assignments.push(assignment.clone());
assignment
}
}
pub fn topic(&self, topic_id: u64) -> Option<&TopicModel> {
self.topics.get(&topic_id)
}
pub fn assignments_for_doc(&self, doc_id: u64) -> Vec<&TopicAssignment> {
let mut result: Vec<&TopicAssignment> = self
.assignments
.iter()
.filter(|a| a.doc_id == doc_id)
.collect();
result.sort_by_key(|b| std::cmp::Reverse(b.assigned_at_secs));
result
}
pub fn top_topics(&self, k: usize) -> Vec<&TopicModel> {
let mut topics: Vec<&TopicModel> = self.topics.values().collect();
topics.sort_by_key(|b| std::cmp::Reverse(b.member_count));
topics.truncate(k);
topics
}
pub fn relabel(&mut self, topic_id: u64, label: String) -> bool {
match self.topics.get_mut(&topic_id) {
Some(t) => {
t.label = label;
true
}
None => false,
}
}
pub fn stats(&self) -> TopicModellerStats {
let total_documents = self.assignments.len() as u64;
let total_topics = self.topics.len();
let avg_topic_size = if total_topics == 0 {
0.0
} else {
let sum: u64 = self.topics.values().map(|t| t.member_count).sum();
sum as f64 / total_topics as f64
};
let largest_topic_members = self
.topics
.values()
.map(|t| t.member_count)
.max()
.unwrap_or(0);
TopicModellerStats {
total_documents,
total_topics,
avg_topic_size,
largest_topic_members,
}
}
fn create_topic(&mut self, doc_id: u64, embedding: Vec<f32>, now_secs: u64) -> TopicAssignment {
let topic_id = self.next_topic_id;
self.next_topic_id += 1;
let model = TopicModel {
topic_id,
label: format!("topic_{}", topic_id),
centroid: embedding,
member_count: 1,
total_weight: 1.0,
};
self.topics.insert(topic_id, model);
let assignment = TopicAssignment {
doc_id,
topic_id,
confidence: 1.0,
assigned_at_secs: now_secs,
};
self.assignments.push(assignment.clone());
assignment
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_modeller() -> SemanticTopicModeller {
SemanticTopicModeller::new(ModellerConfig::default())
}
fn unit_vec(dim: usize, val: f32) -> Vec<f32> {
let norm = (val * val * dim as f32).sqrt();
if norm < 1e-9 {
vec![0.0; dim]
} else {
vec![val / norm; dim]
}
}
#[test]
fn test_new_starts_empty() {
let m = default_modeller();
assert!(m.topics.is_empty());
assert!(m.assignments.is_empty());
assert_eq!(m.next_topic_id, 0);
}
#[test]
fn test_assign_first_document_creates_topic() {
let mut m = default_modeller();
let emb = unit_vec(4, 1.0);
let a = m.assign(1, emb, 1000);
assert_eq!(m.topics.len(), 1);
assert_eq!(a.topic_id, 0);
assert_eq!(a.doc_id, 1);
assert_eq!(a.assigned_at_secs, 1000);
}
#[test]
fn test_assign_similar_joins_existing_topic() {
let mut m = default_modeller();
let emb1 = vec![1.0_f32, 0.0, 0.0, 0.0];
m.assign(1, emb1, 100);
let emb2 = vec![0.99_f32, 0.141, 0.0, 0.0];
let a2 = m.assign(2, emb2, 200);
assert_eq!(m.topics.len(), 1, "should still be one topic");
assert_eq!(a2.topic_id, 0);
}
#[test]
fn test_assign_dissimilar_creates_new_topic() {
let mut m = default_modeller();
let emb1 = vec![1.0_f32, 0.0, 0.0, 0.0];
m.assign(1, emb1, 100);
let emb2 = vec![0.0_f32, 1.0, 0.0, 0.0];
let a2 = m.assign(2, emb2, 200);
assert_eq!(m.topics.len(), 2);
assert_ne!(a2.topic_id, 0);
}
#[test]
fn test_max_topics_no_new_creation() {
let config = ModellerConfig {
max_topics: 2,
new_topic_threshold: 0.5,
centroid_learning_rate: 0.1,
};
let mut m = SemanticTopicModeller::new(config);
m.assign(1, vec![1.0, 0.0, 0.0], 100);
m.assign(2, vec![0.0, 1.0, 0.0], 200);
assert_eq!(m.topics.len(), 2);
let a3 = m.assign(3, vec![0.0, 0.0, 1.0], 300);
assert_eq!(m.topics.len(), 2, "no new topic should be created");
assert!(m.topics.contains_key(&a3.topic_id));
}
#[test]
fn test_centroid_updated_via_learning_rate() {
let config = ModellerConfig {
max_topics: 5,
new_topic_threshold: 0.5,
centroid_learning_rate: 0.5,
};
let mut m = SemanticTopicModeller::new(config);
m.assign(1, vec![1.0, 0.0], 100);
let emb2 = vec![0.8_f32, 0.6]; m.assign(2, emb2.clone(), 200);
let topic = m.topics.get(&0).expect("topic 0 should exist");
assert!((topic.centroid[0] - 0.9).abs() < 1e-5);
assert!((topic.centroid[1] - 0.3).abs() < 1e-5);
}
#[test]
fn test_member_count_increments_on_join() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99, 0.141], 200);
let topic = m.topics.get(&0).expect("topic 0");
assert_eq!(topic.member_count, 2);
}
#[test]
fn test_total_weight_accumulates() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99_f32, 0.141], 200);
let topic = m.topics.get(&0).expect("topic 0");
assert!(topic.total_weight > 1.0);
}
#[test]
fn test_confidence_one_for_new_topic() {
let mut m = default_modeller();
let a = m.assign(1, vec![1.0, 0.0], 100);
assert!((a.confidence - 1.0).abs() < 1e-6);
}
#[test]
fn test_confidence_equals_similarity_for_existing() {
let mut m = default_modeller();
let emb1 = vec![1.0_f32, 0.0];
m.assign(1, emb1.clone(), 100);
let emb2 = vec![0.6_f32, 0.8]; let a2 = m.assign(2, emb2.clone(), 200);
let expected_sim = cosine_sim(&emb1, &emb2);
assert!((a2.confidence - expected_sim).abs() < 1e-5);
}
#[test]
fn test_topic_some_for_existing() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
assert!(m.topic(0).is_some());
}
#[test]
fn test_topic_none_for_unknown() {
let m = default_modeller();
assert!(m.topic(999).is_none());
}
#[test]
fn test_assignments_for_doc_sorted_desc() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(1, vec![0.99, 0.141], 300);
m.assign(1, vec![0.98, 0.2], 200);
let doc_assignments = m.assignments_for_doc(1);
assert_eq!(doc_assignments.len(), 3);
assert_eq!(doc_assignments[0].assigned_at_secs, 300);
assert_eq!(doc_assignments[1].assigned_at_secs, 200);
assert_eq!(doc_assignments[2].assigned_at_secs, 100);
}
#[test]
fn test_assignments_for_doc_filters_correctly() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.0, 1.0], 200);
let doc1_assignments = m.assignments_for_doc(1);
assert_eq!(doc1_assignments.len(), 1);
assert_eq!(doc1_assignments[0].doc_id, 1);
}
#[test]
fn test_top_topics_sorted_by_member_count_desc() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99, 0.141], 110);
m.assign(3, vec![0.98, 0.2], 120);
m.assign(10, vec![0.0, 1.0], 200);
let top = m.top_topics(10);
assert_eq!(top.len(), 2);
assert!(top[0].member_count >= top[1].member_count);
}
#[test]
fn test_top_topics_capped_at_k() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.0, 1.0], 200);
let top = m.top_topics(1);
assert_eq!(top.len(), 1);
}
#[test]
fn test_relabel_sets_label() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
let ok = m.relabel(0, "science".to_string());
assert!(ok);
assert_eq!(m.topic(0).expect("topic 0").label, "science");
}
#[test]
fn test_relabel_false_for_unknown() {
let mut m = default_modeller();
assert!(!m.relabel(99, "ghost".to_string()));
}
#[test]
fn test_stats_total_documents() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99, 0.141], 200);
let s = m.stats();
assert_eq!(s.total_documents, 2);
}
#[test]
fn test_stats_total_topics() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.0, 1.0], 200);
let s = m.stats();
assert_eq!(s.total_topics, 2);
}
#[test]
fn test_stats_avg_topic_size() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99, 0.141], 110);
m.assign(3, vec![0.98, 0.2], 120);
m.assign(10, vec![0.0, 1.0], 200);
let s = m.stats();
assert!((s.avg_topic_size - 2.0).abs() < 1e-6);
}
#[test]
fn test_stats_avg_topic_size_empty() {
let m = default_modeller();
let s = m.stats();
assert_eq!(s.avg_topic_size, 0.0);
}
#[test]
fn test_stats_largest_topic_members() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
m.assign(2, vec![0.99, 0.141], 110);
m.assign(3, vec![0.98, 0.2], 120);
m.assign(10, vec![0.0, 1.0], 200);
let s = m.stats();
assert_eq!(s.largest_topic_members, 3);
}
#[test]
fn test_stats_largest_topic_members_empty() {
let m = default_modeller();
let s = m.stats();
assert_eq!(s.largest_topic_members, 0);
}
#[test]
fn test_topic_coherence_formula() {
let t = TopicModel {
topic_id: 0,
centroid: vec![1.0],
member_count: 4,
total_weight: 3.0,
label: "t".to_string(),
};
assert!((t.coherence() - 1.0).abs() < 1e-9);
}
#[test]
fn test_cosine_sim_identical() {
let v = vec![1.0_f32, 2.0, 3.0];
assert!((cosine_sim(&v, &v) - 1.0).abs() < 1e-6);
}
#[test]
fn test_cosine_sim_orthogonal() {
let a = vec![1.0_f32, 0.0];
let b = vec![0.0_f32, 1.0];
assert!(cosine_sim(&a, &b).abs() < 1e-6);
}
#[test]
fn test_cosine_sim_zero_vector() {
let a = vec![0.0_f32, 0.0];
let b = vec![1.0_f32, 0.0];
assert!(cosine_sim(&a, &b).abs() < 1e-6);
}
#[test]
fn test_default_label_format() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0], 100);
let topic = m.topic(0).expect("topic 0");
assert_eq!(topic.label, "topic_0");
}
#[test]
fn test_next_topic_id_increments() {
let mut m = default_modeller();
m.assign(1, vec![1.0, 0.0, 0.0], 100);
m.assign(2, vec![0.0, 1.0, 0.0], 200);
m.assign(3, vec![0.0, 0.0, 1.0], 300);
assert_eq!(m.next_topic_id, 3);
assert_eq!(m.topics.len(), 3);
}
}
#[derive(Debug, Clone)]
pub struct TopicWord {
pub word: String,
pub probability: f64,
}
#[derive(Debug, Clone)]
pub struct LdaTopic {
pub id: usize,
pub top_words: Vec<TopicWord>,
pub coherence: f64,
}
#[derive(Debug, Clone)]
pub struct DocumentTopics {
pub doc_id: String,
pub topic_distribution: Vec<f64>,
pub dominant_topic: usize,
}
#[derive(Debug, Clone)]
pub struct ModelDocument {
pub doc_id: String,
pub word_counts: HashMap<String, u32>,
}
#[derive(Debug, Clone)]
pub struct TopicModelConfig {
pub n_topics: usize,
pub n_top_words: usize,
pub alpha: f64,
pub beta: f64,
pub max_iter: u32,
pub seed: u64,
}
impl Default for TopicModelConfig {
fn default() -> Self {
Self {
n_topics: 10,
n_top_words: 10,
alpha: 0.1,
beta: 0.01,
max_iter: 50,
seed: 42,
}
}
}
#[derive(Debug, Clone)]
pub struct TopicModelResult {
pub topics: Vec<LdaTopic>,
pub doc_topic_distributions: Vec<DocumentTopics>,
pub perplexity: f64,
pub n_docs: usize,
pub n_words: usize,
pub iterations_run: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TopicModelError {
InsufficientDocuments {
min: usize,
got: usize,
},
EmptyVocabulary,
InvalidConfig(String),
}
impl std::fmt::Display for TopicModelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientDocuments { min, got } => {
write!(f, "insufficient documents: need at least {min}, got {got}")
}
Self::EmptyVocabulary => write!(f, "empty vocabulary"),
Self::InvalidConfig(msg) => write!(f, "invalid config: {msg}"),
}
}
}
impl std::error::Error for TopicModelError {}
#[derive(Debug, Clone)]
pub struct TopicModelerStats {
pub n_topics: usize,
pub vocabulary_size: usize,
pub avg_topic_coherence: f64,
pub dominant_topic_distribution: Vec<usize>,
}
#[inline]
fn xorshift64(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
pub struct TopicModeler {
pub config: TopicModelConfig,
pub vocabulary: Vec<String>,
pub word_index: HashMap<String, usize>,
}
impl TopicModeler {
pub fn new(config: TopicModelConfig) -> Self {
Self {
config,
vocabulary: Vec::new(),
word_index: HashMap::new(),
}
}
fn validate_config(&self) -> Result<(), TopicModelError> {
if self.config.n_topics == 0 {
return Err(TopicModelError::InvalidConfig(
"n_topics must be >= 1".to_string(),
));
}
if self.config.alpha <= 0.0 {
return Err(TopicModelError::InvalidConfig(
"alpha must be > 0".to_string(),
));
}
if self.config.beta <= 0.0 {
return Err(TopicModelError::InvalidConfig(
"beta must be > 0".to_string(),
));
}
if self.config.seed == 0 {
return Err(TopicModelError::InvalidConfig(
"seed must be non-zero".to_string(),
));
}
Ok(())
}
fn build_vocabulary(&mut self, documents: &[ModelDocument]) {
let mut words: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for doc in documents {
for word in doc.word_counts.keys() {
words.insert(word.clone());
}
}
self.vocabulary = words.into_iter().collect();
self.word_index = self
.vocabulary
.iter()
.enumerate()
.map(|(i, w)| (w.clone(), i))
.collect();
}
pub fn fit(
&mut self,
documents: &[ModelDocument],
) -> Result<TopicModelResult, TopicModelError> {
self.validate_config()?;
let n_docs = documents.len();
if n_docs < self.config.n_topics {
return Err(TopicModelError::InsufficientDocuments {
min: self.config.n_topics,
got: n_docs,
});
}
self.build_vocabulary(documents);
let vocab_size = self.vocabulary.len();
if vocab_size == 0 {
return Err(TopicModelError::EmptyVocabulary);
}
let k = self.config.n_topics;
let alpha = self.config.alpha;
let beta = self.config.beta;
let v_beta = vocab_size as f64 * beta;
let mut doc_tokens: Vec<Vec<usize>> = Vec::with_capacity(n_docs);
for doc in documents {
let mut tokens: Vec<usize> = Vec::new();
for (word, &count) in &doc.word_counts {
if let Some(&wi) = self.word_index.get(word) {
for _ in 0..count {
tokens.push(wi);
}
}
}
doc_tokens.push(tokens);
}
let mut doc_topic_counts: Vec<Vec<u32>> = vec![vec![0u32; k]; n_docs];
let mut topic_word_counts: Vec<Vec<u32>> = vec![vec![0u32; vocab_size]; k];
let mut topic_counts: Vec<u32> = vec![0u32; k];
let mut topic_assignments: Vec<Vec<usize>> = Vec::with_capacity(n_docs);
let mut rng_state: u64 = self.config.seed;
for (d, tokens) in doc_tokens.iter().enumerate() {
let mut assignments: Vec<usize> = Vec::with_capacity(tokens.len());
for &wi in tokens {
let t = (xorshift64(&mut rng_state) as usize) % k;
assignments.push(t);
doc_topic_counts[d][t] += 1;
topic_word_counts[t][wi] += 1;
topic_counts[t] += 1;
}
topic_assignments.push(assignments);
}
let mut probs: Vec<f64> = vec![0.0; k];
for _iter in 0..self.config.max_iter {
for d in 0..n_docs {
let n_tokens = doc_tokens[d].len();
for pos in 0..n_tokens {
let wi = doc_tokens[d][pos];
let old_t = topic_assignments[d][pos];
doc_topic_counts[d][old_t] = doc_topic_counts[d][old_t].saturating_sub(1);
topic_word_counts[old_t][wi] = topic_word_counts[old_t][wi].saturating_sub(1);
topic_counts[old_t] = topic_counts[old_t].saturating_sub(1);
let mut cumulative = 0.0_f64;
for t in 0..k {
let doc_factor = doc_topic_counts[d][t] as f64 + alpha;
let word_factor = topic_word_counts[t][wi] as f64 + beta;
let norm_factor = topic_counts[t] as f64 + v_beta;
let p = doc_factor * word_factor / norm_factor;
cumulative += p;
probs[t] = cumulative;
}
let total = cumulative;
let u = ((xorshift64(&mut rng_state) as f64) / (u64::MAX as f64)) * total;
let new_t = probs[..k].iter().position(|&cp| u <= cp).unwrap_or(k - 1);
topic_assignments[d][pos] = new_t;
doc_topic_counts[d][new_t] += 1;
topic_word_counts[new_t][wi] += 1;
topic_counts[new_t] += 1;
}
}
}
let n_top = self.config.n_top_words.min(vocab_size);
let mut topics: Vec<LdaTopic> = Vec::with_capacity(k);
for t in 0..k {
let total_t = topic_counts[t] as f64 + v_beta;
let mut word_probs: Vec<(usize, f64)> = (0..vocab_size)
.map(|w| {
let p = (topic_word_counts[t][w] as f64 + beta) / total_t;
(w, p)
})
.collect();
word_probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_words: Vec<TopicWord> = word_probs[..n_top]
.iter()
.map(|&(wi, prob)| TopicWord {
word: self.vocabulary[wi].clone(),
probability: prob,
})
.collect();
topics.push(LdaTopic {
id: t,
top_words,
coherence: 0.0, });
}
let mut doc_topic_distributions: Vec<DocumentTopics> = Vec::with_capacity(n_docs);
for d in 0..n_docs {
let n_d: u32 = doc_topic_counts[d].iter().sum();
let denom = n_d as f64 + k as f64 * alpha;
let dist: Vec<f64> = (0..k)
.map(|t| (doc_topic_counts[d][t] as f64 + alpha) / denom)
.collect();
let dominant = dist
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
doc_topic_distributions.push(DocumentTopics {
doc_id: documents[d].doc_id.clone(),
topic_distribution: dist,
dominant_topic: dominant,
});
}
let mut corpus_word_counts: HashMap<String, u32> = HashMap::new();
for doc in documents {
for (word, &count) in &doc.word_counts {
*corpus_word_counts.entry(word.clone()).or_insert(0) += count;
}
}
for topic in &mut topics {
topic.coherence = Self::coherence_score_inner(topic, &corpus_word_counts);
}
let perplexity = Self::compute_perplexity(
documents,
&doc_topic_distributions,
&topic_word_counts,
&topic_counts,
&self.word_index,
vocab_size,
beta,
v_beta,
);
Ok(TopicModelResult {
topics,
doc_topic_distributions,
perplexity,
n_docs,
n_words: vocab_size,
iterations_run: self.config.max_iter,
})
}
pub fn transform(
&mut self,
documents: &[ModelDocument],
result: &TopicModelResult,
) -> Result<Vec<DocumentTopics>, TopicModelError> {
self.validate_config()?;
if documents.is_empty() {
return Ok(Vec::new());
}
if result.topics.is_empty() {
return Err(TopicModelError::InvalidConfig(
"result contains no topics".to_string(),
));
}
let k = result.topics.len();
let alpha = self.config.alpha;
let topic_word_prob: Vec<HashMap<&str, f64>> = result
.topics
.iter()
.map(|topic| {
topic
.top_words
.iter()
.map(|tw| (tw.word.as_str(), tw.probability))
.collect()
})
.collect();
let mut rng_state: u64 = self.config.seed.wrapping_add(1);
let mut output: Vec<DocumentTopics> = Vec::with_capacity(documents.len());
for doc in documents {
let tokens: Vec<&str> = doc
.word_counts
.iter()
.flat_map(|(w, &c)| std::iter::repeat_n(w.as_str(), c as usize))
.collect();
let n_tokens = tokens.len();
if n_tokens == 0 {
let uniform = 1.0 / k as f64;
output.push(DocumentTopics {
doc_id: doc.doc_id.clone(),
topic_distribution: vec![uniform; k],
dominant_topic: 0,
});
continue;
}
let mut dt_counts: Vec<u32> = vec![0u32; k];
let mut assignments: Vec<usize> = Vec::with_capacity(n_tokens);
for _ in 0..n_tokens {
let t = (xorshift64(&mut rng_state) as usize) % k;
assignments.push(t);
dt_counts[t] += 1;
}
let mut probs: Vec<f64> = vec![0.0; k];
for _iter in 0..self.config.max_iter {
for pos in 0..n_tokens {
let word = tokens[pos];
let old_t = assignments[pos];
dt_counts[old_t] = dt_counts[old_t].saturating_sub(1);
let mut cumulative = 0.0_f64;
for t in 0..k {
let doc_factor = dt_counts[t] as f64 + alpha;
let word_prob = topic_word_prob[t].get(word).copied().unwrap_or(1e-10_f64);
let p = doc_factor * word_prob;
cumulative += p;
probs[t] = cumulative;
}
let u = ((xorshift64(&mut rng_state) as f64) / (u64::MAX as f64)) * cumulative;
let new_t = probs[..k].iter().position(|&cp| u <= cp).unwrap_or(k - 1);
assignments[pos] = new_t;
dt_counts[new_t] += 1;
}
}
let n_d: u32 = dt_counts.iter().sum();
let denom = n_d as f64 + k as f64 * alpha;
let dist: Vec<f64> = (0..k)
.map(|t| (dt_counts[t] as f64 + alpha) / denom)
.collect();
let dominant = dist
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
output.push(DocumentTopics {
doc_id: doc.doc_id.clone(),
topic_distribution: dist,
dominant_topic: dominant,
});
}
Ok(output)
}
pub fn coherence_score(topic: &LdaTopic, corpus_word_counts: &HashMap<String, u32>) -> f64 {
Self::coherence_score_inner(topic, corpus_word_counts)
}
fn coherence_score_inner(topic: &LdaTopic, corpus_word_counts: &HashMap<String, u32>) -> f64 {
let words = &topic.top_words;
if words.len() < 2 {
return 0.0;
}
let mut sum = 0.0_f64;
let mut count = 0usize;
for (i, wi) in words.iter().enumerate() {
let count_i = corpus_word_counts.get(&wi.word).copied().unwrap_or(0) as f64;
for wj in words.iter().skip(i + 1) {
let count_j = corpus_word_counts.get(&wj.word).copied().unwrap_or(0) as f64;
let co = count_i.min(count_j);
sum += ((co + 1.0) / (count_j + 1.0)).ln();
count += 1;
}
}
if count == 0 {
0.0
} else {
sum / count as f64
}
}
pub fn most_similar_topics(topic_a: usize, topic_b: usize, result: &TopicModelResult) -> f64 {
if topic_a >= result.topics.len() || topic_b >= result.topics.len() {
return 0.0;
}
let ta = &result.topics[topic_a];
let tb = &result.topics[topic_b];
let mut all_words: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for tw in &ta.top_words {
all_words.insert(tw.word.as_str());
}
for tw in &tb.top_words {
all_words.insert(tw.word.as_str());
}
let map_a: HashMap<&str, f64> = ta
.top_words
.iter()
.map(|tw| (tw.word.as_str(), tw.probability))
.collect();
let map_b: HashMap<&str, f64> = tb
.top_words
.iter()
.map(|tw| (tw.word.as_str(), tw.probability))
.collect();
let mut dot = 0.0_f64;
let mut norm_a = 0.0_f64;
let mut norm_b = 0.0_f64;
for word in &all_words {
let pa = map_a.get(word).copied().unwrap_or(0.0);
let pb = map_b.get(word).copied().unwrap_or(0.0);
dot += pa * pb;
norm_a += pa * pa;
norm_b += pb * pb;
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom < 1e-12 {
0.0
} else {
(dot / denom).clamp(-1.0, 1.0)
}
}
pub fn top_documents_for_topic(
topic_id: usize,
n: usize,
result: &TopicModelResult,
) -> Vec<&DocumentTopics> {
if result.topics.is_empty() {
return Vec::new();
}
let mut docs: Vec<&DocumentTopics> = result
.doc_topic_distributions
.iter()
.filter(|d| topic_id < d.topic_distribution.len())
.collect();
docs.sort_by(|a, b| {
b.topic_distribution[topic_id]
.partial_cmp(&a.topic_distribution[topic_id])
.unwrap_or(std::cmp::Ordering::Equal)
});
docs.truncate(n);
docs
}
pub fn stats(result: &TopicModelResult) -> TopicModelerStats {
let n_topics = result.topics.len();
let avg_topic_coherence = if n_topics == 0 {
0.0
} else {
let sum: f64 = result.topics.iter().map(|t| t.coherence).sum();
sum / n_topics as f64
};
let mut dominant_topic_distribution: Vec<usize> = vec![0usize; n_topics];
for doc in &result.doc_topic_distributions {
if doc.dominant_topic < n_topics {
dominant_topic_distribution[doc.dominant_topic] += 1;
}
}
TopicModelerStats {
n_topics,
vocabulary_size: result.n_words,
avg_topic_coherence,
dominant_topic_distribution,
}
}
#[allow(clippy::too_many_arguments)]
fn compute_perplexity(
documents: &[ModelDocument],
doc_dists: &[DocumentTopics],
topic_word_counts: &[Vec<u32>],
topic_counts: &[u32],
word_index: &HashMap<String, usize>,
vocab_size: usize,
beta: f64,
v_beta: f64,
) -> f64 {
let k = topic_word_counts.len();
let mut log_likelihood = 0.0_f64;
let mut total_tokens: u64 = 0;
for (d, doc) in documents.iter().enumerate() {
let dist = &doc_dists[d].topic_distribution;
for (word, &count) in &doc.word_counts {
if count == 0 {
continue;
}
let wi_opt = word_index.get(word);
let p_word_doc = match wi_opt {
None => 1e-10,
Some(&wi) => {
let p: f64 = (0..k)
.map(|t| {
let p_topic = dist[t];
let p_word_topic = (topic_word_counts[t][wi] as f64 + beta)
/ (topic_counts[t] as f64 + v_beta);
p_topic * p_word_topic
})
.sum();
if p <= 0.0 {
1e-10
} else {
p
}
}
};
log_likelihood += count as f64 * p_word_doc.ln();
total_tokens += count as u64;
}
let _ = vocab_size; }
if total_tokens == 0 {
return f64::INFINITY;
}
(-log_likelihood / total_tokens as f64).exp()
}
}
#[cfg(test)]
mod lda_tests {
use crate::topic_modeler::{
xorshift64, LdaTopic, ModelDocument, TopicModelConfig, TopicModelError, TopicModeler,
TopicWord,
};
use std::collections::HashMap;
fn simple_doc(id: &str, words: &[(&str, u32)]) -> ModelDocument {
let mut word_counts = HashMap::new();
for &(w, c) in words {
word_counts.insert(w.to_string(), c);
}
ModelDocument {
doc_id: id.to_string(),
word_counts,
}
}
fn make_corpus() -> Vec<ModelDocument> {
vec![
simple_doc(
"d1",
&[("rust", 5), ("code", 4), ("compile", 3), ("memory", 3)],
),
simple_doc(
"d2",
&[("rust", 4), ("compiler", 5), ("code", 3), ("type", 3)],
),
simple_doc(
"d3",
&[("rust", 3), ("memory", 4), ("safe", 5), ("type", 2)],
),
simple_doc(
"d4",
&[("forest", 5), ("tree", 4), ("leaf", 3), ("nature", 3)],
),
simple_doc(
"d5",
&[("tree", 4), ("forest", 3), ("river", 5), ("nature", 3)],
),
simple_doc(
"d6",
&[("leaf", 3), ("nature", 4), ("river", 5), ("forest", 2)],
),
]
}
fn default_config_2topics() -> TopicModelConfig {
TopicModelConfig {
n_topics: 2,
n_top_words: 4,
alpha: 0.1,
beta: 0.01,
max_iter: 30,
seed: 42,
}
}
#[test]
fn test_new_empty_vocab() {
let m = TopicModeler::new(TopicModelConfig::default());
assert!(m.vocabulary.is_empty());
assert!(m.word_index.is_empty());
}
#[test]
fn test_fit_n_docs() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert_eq!(result.n_docs, 6);
}
#[test]
fn test_fit_n_words() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert_eq!(result.n_words, 12);
}
#[test]
fn test_fit_n_topics() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert_eq!(result.topics.len(), 2);
}
#[test]
fn test_fit_iterations_run() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert_eq!(result.iterations_run, 30);
}
#[test]
fn test_doc_topic_distribution_sums_to_one() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for doc_dist in &result.doc_topic_distributions {
let sum: f64 = doc_dist.topic_distribution.iter().sum();
assert!((sum - 1.0).abs() < 1e-9, "sum={sum}");
}
}
#[test]
fn test_dominant_topic_in_range() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for doc_dist in &result.doc_topic_distributions {
assert!(doc_dist.dominant_topic < 2);
}
}
#[test]
fn test_topic_top_words_count() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for topic in &result.topics {
assert_eq!(topic.top_words.len(), 4);
}
}
#[test]
fn test_top_words_probabilities_positive() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for topic in &result.topics {
for tw in &topic.top_words {
assert!(
tw.probability > 0.0,
"word={} prob={}",
tw.word,
tw.probability
);
}
}
}
#[test]
fn test_top_words_sorted_descending() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for topic in &result.topics {
let probs: Vec<f64> = topic.top_words.iter().map(|tw| tw.probability).collect();
for i in 1..probs.len() {
assert!(probs[i - 1] >= probs[i], "not sorted at {i}");
}
}
}
#[test]
fn test_perplexity_finite_positive() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert!(result.perplexity.is_finite(), "perplexity not finite");
assert!(result.perplexity > 0.0);
}
#[test]
fn test_fit_insufficient_documents() {
let mut m = TopicModeler::new(TopicModelConfig {
n_topics: 5,
..Default::default()
});
let corpus = vec![
simple_doc("d1", &[("hello", 1)]),
simple_doc("d2", &[("world", 1)]),
];
let err = m.fit(&corpus).unwrap_err();
assert_eq!(
err,
TopicModelError::InsufficientDocuments { min: 5, got: 2 }
);
}
#[test]
fn test_fit_empty_vocabulary() {
let mut m = TopicModeler::new(TopicModelConfig {
n_topics: 1,
..Default::default()
});
let corpus = vec![simple_doc("d1", &[])];
let err = m.fit(&corpus).unwrap_err();
assert_eq!(err, TopicModelError::EmptyVocabulary);
}
#[test]
fn test_invalid_config_n_topics_zero() {
let mut m = TopicModeler::new(TopicModelConfig {
n_topics: 0,
..Default::default()
});
let corpus = make_corpus();
let err = m.fit(&corpus).unwrap_err();
matches!(err, TopicModelError::InvalidConfig(_));
}
#[test]
fn test_invalid_config_alpha_zero() {
let mut m = TopicModeler::new(TopicModelConfig {
alpha: 0.0,
..Default::default()
});
let corpus = make_corpus();
let err = m.fit(&corpus).unwrap_err();
matches!(err, TopicModelError::InvalidConfig(_));
}
#[test]
fn test_invalid_config_beta_zero() {
let mut m = TopicModeler::new(TopicModelConfig {
beta: 0.0,
..Default::default()
});
let corpus = make_corpus();
let err = m.fit(&corpus).unwrap_err();
matches!(err, TopicModelError::InvalidConfig(_));
}
#[test]
fn test_coherence_single_word_zero() {
let topic = LdaTopic {
id: 0,
top_words: vec![TopicWord {
word: "rust".to_string(),
probability: 0.5,
}],
coherence: 0.0,
};
let corpus: HashMap<String, u32> = HashMap::new();
let score = TopicModeler::coherence_score(&topic, &corpus);
assert_eq!(score, 0.0);
}
#[test]
fn test_coherence_multiword_finite() {
let topic = LdaTopic {
id: 0,
top_words: vec![
TopicWord {
word: "rust".to_string(),
probability: 0.4,
},
TopicWord {
word: "code".to_string(),
probability: 0.3,
},
TopicWord {
word: "memory".to_string(),
probability: 0.2,
},
],
coherence: 0.0,
};
let mut corpus = HashMap::new();
corpus.insert("rust".to_string(), 10u32);
corpus.insert("code".to_string(), 8u32);
corpus.insert("memory".to_string(), 5u32);
let score = TopicModeler::coherence_score(&topic, &corpus);
assert!(score.is_finite());
}
#[test]
fn test_coherence_empty_corpus_finite() {
let topic = LdaTopic {
id: 0,
top_words: vec![
TopicWord {
word: "a".to_string(),
probability: 0.5,
},
TopicWord {
word: "b".to_string(),
probability: 0.5,
},
],
coherence: 0.0,
};
let corpus: HashMap<String, u32> = HashMap::new();
let score = TopicModeler::coherence_score(&topic, &corpus);
assert!(score.is_finite());
}
#[test]
fn test_most_similar_topics_identical() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let sim = TopicModeler::most_similar_topics(0, 0, &result);
assert!((sim - 1.0).abs() < 1e-9, "sim={sim}");
}
#[test]
fn test_most_similar_topics_in_range() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let sim = TopicModeler::most_similar_topics(0, 1, &result);
assert!((-1.0..=1.0).contains(&sim), "sim={sim}");
}
#[test]
fn test_most_similar_topics_out_of_range() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let sim = TopicModeler::most_similar_topics(0, 99, &result);
assert_eq!(sim, 0.0);
}
#[test]
fn test_top_documents_for_topic_count() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let top = TopicModeler::top_documents_for_topic(0, 3, &result);
assert_eq!(top.len(), 3);
}
#[test]
fn test_top_documents_for_topic_sorted() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let top = TopicModeler::top_documents_for_topic(0, 6, &result);
for i in 1..top.len() {
assert!(
top[i - 1].topic_distribution[0] >= top[i].topic_distribution[0],
"not sorted at {i}"
);
}
}
#[test]
fn test_stats_n_topics() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let stats = TopicModeler::stats(&result);
assert_eq!(stats.n_topics, 2);
}
#[test]
fn test_stats_vocab_size() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let stats = TopicModeler::stats(&result);
assert_eq!(stats.vocabulary_size, result.n_words);
}
#[test]
fn test_stats_dominant_distribution_sum() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let stats = TopicModeler::stats(&result);
let total: usize = stats.dominant_topic_distribution.iter().sum();
assert_eq!(total, 6);
}
#[test]
fn test_transform_result_count() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let new_docs = vec![
simple_doc("new1", &[("rust", 3), ("code", 2)]),
simple_doc("new2", &[("forest", 4), ("tree", 3)]),
];
let inferred = m.transform(&new_docs, &result).expect("transform failed");
assert_eq!(inferred.len(), 2);
}
#[test]
fn test_transform_distributions_sum() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let new_docs = vec![simple_doc("new1", &[("rust", 3), ("code", 2)])];
let inferred = m.transform(&new_docs, &result).expect("transform failed");
let sum: f64 = inferred[0].topic_distribution.iter().sum();
assert!((sum - 1.0).abs() < 1e-9, "sum={sum}");
}
#[test]
fn test_transform_empty_doc_uniform() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let new_docs = vec![simple_doc("empty", &[])];
let inferred = m.transform(&new_docs, &result).expect("transform failed");
let expected = 0.5_f64; for &p in &inferred[0].topic_distribution {
assert!((p - expected).abs() < 1e-9, "p={p}");
}
}
#[test]
fn test_xorshift64_different_seeds() {
let mut s1 = 42u64;
let mut s2 = 123u64;
let v1 = xorshift64(&mut s1);
let v2 = xorshift64(&mut s2);
assert_ne!(v1, v2);
}
#[test]
fn test_xorshift64_deterministic() {
let mut s1 = 99u64;
let mut s2 = 99u64;
for _ in 0..100 {
assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
}
}
#[test]
fn test_fit_doc_ids_preserved() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let ids: Vec<&str> = result
.doc_topic_distributions
.iter()
.map(|d| d.doc_id.as_str())
.collect();
assert!(ids.contains(&"d1"));
assert!(ids.contains(&"d6"));
}
#[test]
fn test_topic_ids_sequential() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
for (i, topic) in result.topics.iter().enumerate() {
assert_eq!(topic.id, i);
}
}
#[test]
fn test_vocabulary_sorted() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
m.fit(&corpus).expect("fit failed");
let sorted = {
let mut v = m.vocabulary.clone();
v.sort();
v
};
assert_eq!(m.vocabulary, sorted);
}
#[test]
fn test_perplexity_reasonable() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
assert!(
result.perplexity < 1000.0,
"perplexity={}",
result.perplexity
);
}
#[test]
fn test_error_display_insufficient() {
let err = TopicModelError::InsufficientDocuments { min: 5, got: 2 };
let s = err.to_string();
assert!(s.contains("5"), "msg={s}");
assert!(s.contains("2"), "msg={s}");
}
#[test]
fn test_error_display_empty_vocab() {
let err = TopicModelError::EmptyVocabulary;
let s = err.to_string();
assert!(s.contains("empty"), "msg={s}");
}
#[test]
fn test_error_display_invalid_config() {
let err = TopicModelError::InvalidConfig("test reason".to_string());
let s = err.to_string();
assert!(s.contains("test reason"), "msg={s}");
}
#[test]
fn test_stats_avg_coherence_finite() {
let mut m = TopicModeler::new(default_config_2topics());
let corpus = make_corpus();
let result = m.fit(&corpus).expect("fit failed");
let stats = TopicModeler::stats(&result);
assert!(stats.avg_topic_coherence.is_finite());
}
}