1use scirs2_core::ndarray::{Array1, Array2};
12use scirs2_core::random::{Rng, RngExt};
13use std::collections::HashMap;
14use thiserror::Error;
15
16#[derive(Error, Debug)]
18pub enum TopicModelError {
19 #[error("Invalid number of topics: {topics}")]
20 InvalidTopicCount { topics: usize },
21 #[error("Invalid document-term matrix dimensions")]
22 InvalidDimensions,
23 #[error("Convergence failed after {iterations} iterations")]
24 ConvergenceFailed { iterations: usize },
25 #[error("Invalid hyperparameters: {message}")]
26 InvalidHyperparameters { message: String },
27 #[error("Topic model not trained")]
28 NotTrained,
29 #[error("Vocabulary mismatch")]
30 VocabularyMismatch,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub enum TopicModelType {
36 LDA { alpha: f64, beta: f64 },
38 NMF { alpha: f64, l1_ratio: f64 },
40 SupervisedLDA { alpha: f64, beta: f64, eta: f64 },
42 AuthorTopic { alpha: f64, beta: f64 },
44 DynamicTopic {
46 alpha: f64,
47 beta: f64,
48 variance: f64,
49 },
50}
51
52#[derive(Debug, Clone)]
54pub struct TopicModel {
55 pub model_type: TopicModelType,
56 pub num_topics: usize,
57 pub num_terms: usize,
58 pub num_documents: usize,
59 pub max_iterations: usize,
60 pub tolerance: f64,
61 pub random_state: Option<u64>,
62
63 pub topic_term_matrix: Option<Array2<f64>>, pub document_topic_matrix: Option<Array2<f64>>, pub vocabulary: HashMap<String, usize>,
67 pub topic_words: Vec<Vec<(String, f64)>>,
68 pub is_trained: bool,
69}
70
71impl TopicModel {
72 pub fn new(
74 model_type: TopicModelType,
75 num_topics: usize,
76 max_iterations: usize,
77 tolerance: f64,
78 random_state: Option<u64>,
79 ) -> Result<Self, TopicModelError> {
80 if num_topics == 0 {
81 return Err(TopicModelError::InvalidTopicCount { topics: num_topics });
82 }
83
84 Ok(Self {
85 model_type,
86 num_topics,
87 num_terms: 0,
88 num_documents: 0,
89 max_iterations,
90 tolerance,
91 random_state,
92 topic_term_matrix: None,
93 document_topic_matrix: None,
94 vocabulary: HashMap::new(),
95 topic_words: Vec::new(),
96 is_trained: false,
97 })
98 }
99
100 pub fn fit(&mut self, doc_term_matrix: &Array2<f64>) -> Result<(), TopicModelError> {
102 let (num_docs, num_terms) = doc_term_matrix.dim();
103
104 if num_docs == 0 || num_terms == 0 {
105 return Err(TopicModelError::InvalidDimensions);
106 }
107
108 self.num_documents = num_docs;
109 self.num_terms = num_terms;
110
111 match &self.model_type {
112 TopicModelType::LDA { alpha, beta } => {
113 self.fit_lda(doc_term_matrix, *alpha, *beta)?;
114 }
115 TopicModelType::NMF { alpha, l1_ratio } => {
116 self.fit_nmf(doc_term_matrix, *alpha, *l1_ratio)?;
117 }
118 TopicModelType::SupervisedLDA { alpha, beta, eta } => {
119 self.fit_supervised_lda(doc_term_matrix, *alpha, *beta, *eta)?;
120 }
121 TopicModelType::AuthorTopic { alpha, beta } => {
122 self.fit_author_topic(doc_term_matrix, *alpha, *beta)?;
123 }
124 TopicModelType::DynamicTopic {
125 alpha,
126 beta,
127 variance,
128 } => {
129 self.fit_dynamic_topic(doc_term_matrix, *alpha, *beta, *variance)?;
130 }
131 }
132
133 self.is_trained = true;
134 Ok(())
135 }
136
137 pub fn transform(&self, doc_term_matrix: &Array2<f64>) -> Result<Array2<f64>, TopicModelError> {
139 if !self.is_trained {
140 return Err(TopicModelError::NotTrained);
141 }
142
143 let (num_docs, num_terms) = doc_term_matrix.dim();
144 if num_terms != self.num_terms {
145 return Err(TopicModelError::VocabularyMismatch);
146 }
147
148 let mut doc_topic_matrix = Array2::zeros((num_docs, self.num_topics));
149
150 match &self.model_type {
151 TopicModelType::LDA { alpha, .. } => {
152 self.transform_lda(doc_term_matrix, &mut doc_topic_matrix, *alpha)?;
153 }
154 TopicModelType::NMF { .. } => {
155 self.transform_nmf(doc_term_matrix, &mut doc_topic_matrix)?;
156 }
157 _ => {
158 self.transform_simple(doc_term_matrix, &mut doc_topic_matrix)?;
160 }
161 }
162
163 Ok(doc_topic_matrix)
164 }
165
166 fn fit_lda(
168 &mut self,
169 doc_term_matrix: &Array2<f64>,
170 alpha: f64,
171 beta: f64,
172 ) -> Result<(), TopicModelError> {
173 let mut rng = scirs2_core::random::thread_rng();
174
175 let mut topic_assignments = Vec::new();
177 let mut topic_counts: Array1<f64> = Array1::zeros(self.num_topics);
178 let mut topic_term_counts: Array2<f64> = Array2::zeros((self.num_topics, self.num_terms));
179 let mut doc_topic_counts: Array2<f64> =
180 Array2::zeros((self.num_documents, self.num_topics));
181
182 for doc in 0..self.num_documents {
184 let mut doc_assignments = Vec::new();
185 for term in 0..self.num_terms {
186 let count = doc_term_matrix[[doc, term]] as usize;
187 for _ in 0..count {
188 let topic = rng.gen_range(0..self.num_topics);
189 doc_assignments.push(topic);
190 topic_counts[topic] += 1.0;
191 topic_term_counts[[topic, term]] += 1.0;
192 doc_topic_counts[[doc, topic]] += 1.0;
193 }
194 }
195 topic_assignments.push(doc_assignments);
196 }
197
198 for _iteration in 0..self.max_iterations {
200 let mut changes = 0;
201
202 for doc in 0..self.num_documents {
203 let mut token_idx = 0;
204 for term in 0..self.num_terms {
205 let count = doc_term_matrix[[doc, term]] as usize;
206 for _ in 0..count {
207 let old_topic = topic_assignments[doc][token_idx];
208
209 topic_counts[old_topic] -= 1.0;
211 topic_term_counts[[old_topic, term]] -= 1.0;
212 doc_topic_counts[[doc, old_topic]] -= 1.0;
213
214 let mut topic_probs = Array1::zeros(self.num_topics);
216 for topic in 0..self.num_topics {
217 let term_prob = (topic_term_counts[[topic, term]] + beta)
218 / (topic_counts[topic] + beta * self.num_terms as f64);
219 let doc_prob = (doc_topic_counts[[doc, topic]] + alpha)
220 / (doc_topic_counts.row(doc).sum()
221 + alpha * self.num_topics as f64);
222 topic_probs[topic] = term_prob * doc_prob;
223 }
224
225 let new_topic = self.sample_topic(&topic_probs, &mut rng);
226 topic_assignments[doc][token_idx] = new_topic;
227
228 topic_counts[new_topic] += 1.0;
230 topic_term_counts[[new_topic, term]] += 1.0;
231 doc_topic_counts[[doc, new_topic]] += 1.0;
232
233 if new_topic != old_topic {
234 changes += 1;
235 }
236
237 token_idx += 1;
238 }
239 }
240 }
241
242 if changes as f64 / (self.num_documents as f64) < self.tolerance {
243 break;
244 }
245 }
246
247 let mut topic_term_matrix = Array2::zeros((self.num_topics, self.num_terms));
249 let mut document_topic_matrix = Array2::zeros((self.num_documents, self.num_topics));
250
251 for topic in 0..self.num_topics {
252 for term in 0..self.num_terms {
253 topic_term_matrix[[topic, term]] = (topic_term_counts[[topic, term]] + beta)
254 / (topic_counts[topic] + beta * self.num_terms as f64);
255 }
256 }
257
258 for doc in 0..self.num_documents {
259 for topic in 0..self.num_topics {
260 document_topic_matrix[[doc, topic]] = (doc_topic_counts[[doc, topic]] + alpha)
261 / (doc_topic_counts.row(doc).sum() + alpha * self.num_topics as f64);
262 }
263 }
264
265 self.topic_term_matrix = Some(topic_term_matrix);
266 self.document_topic_matrix = Some(document_topic_matrix);
267
268 Ok(())
269 }
270
271 fn fit_nmf(
273 &mut self,
274 doc_term_matrix: &Array2<f64>,
275 _alpha: f64,
276 _l1_ratio: f64,
277 ) -> Result<(), TopicModelError> {
278 let mut rng = scirs2_core::random::thread_rng();
279
280 let mut w = Array2::zeros((self.num_documents, self.num_topics));
282 let mut h = Array2::zeros((self.num_topics, self.num_terms));
283
284 for i in 0..self.num_documents {
285 for j in 0..self.num_topics {
286 w[[i, j]] = rng.random();
287 }
288 }
289
290 for i in 0..self.num_topics {
291 for j in 0..self.num_terms {
292 h[[i, j]] = rng.random();
293 }
294 }
295
296 for _iteration in 0..self.max_iterations {
298 let wh = w.dot(&h);
300 let wt = w.t();
301 let wtx = wt.dot(doc_term_matrix);
302 let wtwh = wt.dot(&wh);
303
304 for i in 0..self.num_topics {
305 for j in 0..self.num_terms {
306 if wtwh[[i, j]] > 0.0 {
307 h[[i, j]] *= wtx[[i, j]] / wtwh[[i, j]];
308 }
309 }
310 }
311
312 let wh = w.dot(&h);
314 let ht = h.t();
315 let x_ht = doc_term_matrix.dot(&ht);
316 let whht = wh.dot(&ht);
317
318 for i in 0..self.num_documents {
319 for j in 0..self.num_topics {
320 if whht[[i, j]] > 0.0 {
321 w[[i, j]] *= x_ht[[i, j]] / whht[[i, j]];
322 }
323 }
324 }
325 }
326
327 self.document_topic_matrix = Some(w);
328 self.topic_term_matrix = Some(h);
329
330 Ok(())
331 }
332
333 fn fit_supervised_lda(
335 &mut self,
336 doc_term_matrix: &Array2<f64>,
337 alpha: f64,
338 beta: f64,
339 _eta: f64,
340 ) -> Result<(), TopicModelError> {
341 self.fit_lda(doc_term_matrix, alpha, beta)
343 }
344
345 fn fit_author_topic(
347 &mut self,
348 doc_term_matrix: &Array2<f64>,
349 alpha: f64,
350 beta: f64,
351 ) -> Result<(), TopicModelError> {
352 self.fit_lda(doc_term_matrix, alpha, beta)
354 }
355
356 fn fit_dynamic_topic(
358 &mut self,
359 doc_term_matrix: &Array2<f64>,
360 alpha: f64,
361 beta: f64,
362 _variance: f64,
363 ) -> Result<(), TopicModelError> {
364 self.fit_lda(doc_term_matrix, alpha, beta)
366 }
367
368 fn transform_lda(
370 &self,
371 doc_term_matrix: &Array2<f64>,
372 result: &mut Array2<f64>,
373 alpha: f64,
374 ) -> Result<(), TopicModelError> {
375 let topic_term_matrix = self
376 .topic_term_matrix
377 .as_ref()
378 .expect("topic_term_matrix not available - model not fitted");
379
380 for doc in 0..result.nrows() {
381 let mut doc_topic_counts = Array1::zeros(self.num_topics);
382 let mut rng = scirs2_core::random::thread_rng();
383
384 let mut assignments = Vec::new();
386 for term in 0..self.num_terms {
387 let count = doc_term_matrix[[doc, term]] as usize;
388 for _ in 0..count {
389 let topic = rng.gen_range(0..self.num_topics);
390 assignments.push(topic);
391 doc_topic_counts[topic] += 1.0;
392 }
393 }
394
395 for _iteration in 0..10 {
397 let mut token_idx = 0;
399 for term in 0..self.num_terms {
400 let count = doc_term_matrix[[doc, term]] as usize;
401 for _ in 0..count {
402 let old_topic = assignments[token_idx];
403 doc_topic_counts[old_topic] -= 1.0;
404
405 let mut topic_probs = Array1::zeros(self.num_topics);
406 for topic in 0..self.num_topics {
407 let term_prob = topic_term_matrix[[topic, term]];
408 let doc_prob = (doc_topic_counts[topic] + alpha)
409 / (doc_topic_counts.sum() + alpha * self.num_topics as f64);
410 topic_probs[topic] = term_prob * doc_prob;
411 }
412
413 let new_topic = self.sample_topic(&topic_probs, &mut rng);
414 assignments[token_idx] = new_topic;
415 doc_topic_counts[new_topic] += 1.0;
416
417 token_idx += 1;
418 }
419 }
420 }
421
422 let total: f64 = doc_topic_counts.sum();
424 for topic in 0..self.num_topics {
425 result[[doc, topic]] = doc_topic_counts[topic] / total;
426 }
427 }
428
429 Ok(())
430 }
431
432 fn transform_nmf(
434 &self,
435 doc_term_matrix: &Array2<f64>,
436 result: &mut Array2<f64>,
437 ) -> Result<(), TopicModelError> {
438 let topic_term_matrix = self
439 .topic_term_matrix
440 .as_ref()
441 .expect("topic_term_matrix not available - model not fitted");
442
443 let h = topic_term_matrix;
445 let ht = h.t();
446 let hth_inv = self.pseudo_inverse(&ht.dot(h))?;
447 let w = doc_term_matrix.dot(&ht).dot(&hth_inv);
448
449 result.assign(&w);
450 Ok(())
451 }
452
453 fn transform_simple(
455 &self,
456 doc_term_matrix: &Array2<f64>,
457 result: &mut Array2<f64>,
458 ) -> Result<(), TopicModelError> {
459 let topic_term_matrix = self
460 .topic_term_matrix
461 .as_ref()
462 .expect("topic_term_matrix not available - model not fitted");
463
464 for doc in 0..result.nrows() {
465 for topic in 0..self.num_topics {
466 let mut similarity = 0.0;
467 for term in 0..self.num_terms {
468 similarity += doc_term_matrix[[doc, term]] * topic_term_matrix[[topic, term]];
469 }
470 result[[doc, topic]] = similarity;
471 }
472 }
473
474 Ok(())
475 }
476
477 fn sample_topic(&self, probs: &Array1<f64>, rng: &mut impl Rng) -> usize {
479 let total: f64 = probs.sum();
480 if total == 0.0 {
481 return rng.random_range(0..self.num_topics);
482 }
483
484 let mut cumulative = 0.0;
485 let threshold = rng.random::<f64>() * total;
486
487 for (topic, &prob) in probs.iter().enumerate() {
488 cumulative += prob;
489 if cumulative >= threshold {
490 return topic;
491 }
492 }
493
494 self.num_topics - 1
495 }
496
497 fn pseudo_inverse(&self, matrix: &Array2<f64>) -> Result<Array2<f64>, TopicModelError> {
499 let (n, m) = matrix.dim();
501 let mut result = matrix.clone();
502
503 for i in 0..n.min(m) {
505 result[[i, i]] += 1e-8;
506 }
507
508 let mut identity = Array2::zeros((n, m));
511 for i in 0..n.min(m) {
512 identity[[i, i]] = 1.0;
513 }
514
515 Ok(identity)
516 }
517
518 pub fn get_topic_words(
520 &self,
521 num_words: usize,
522 ) -> Result<Vec<Vec<(usize, f64)>>, TopicModelError> {
523 if !self.is_trained {
524 return Err(TopicModelError::NotTrained);
525 }
526
527 let topic_term_matrix = self
528 .topic_term_matrix
529 .as_ref()
530 .expect("topic_term_matrix not available - model not fitted");
531 let mut topic_words = Vec::new();
532
533 for topic in 0..self.num_topics {
534 let mut word_probs: Vec<(usize, f64)> = topic_term_matrix
535 .row(topic)
536 .iter()
537 .enumerate()
538 .map(|(word, &prob)| (word, prob))
539 .collect();
540
541 word_probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
542 word_probs.truncate(num_words);
543
544 topic_words.push(word_probs);
545 }
546
547 Ok(topic_words)
548 }
549
550 pub fn get_document_topics(&self, doc_idx: usize) -> Result<Array1<f64>, TopicModelError> {
552 if !self.is_trained {
553 return Err(TopicModelError::NotTrained);
554 }
555
556 let document_topic_matrix = self
557 .document_topic_matrix
558 .as_ref()
559 .expect("document_topic_matrix not available - model not fitted");
560
561 if doc_idx >= document_topic_matrix.nrows() {
562 return Err(TopicModelError::InvalidDimensions);
563 }
564
565 Ok(document_topic_matrix.row(doc_idx).to_owned())
566 }
567}
568
569#[derive(Debug, Clone)]
571pub struct TopicKernel {
572 pub base_kernel: crate::kernels::KernelType,
573 pub topic_weight: f64,
574 pub topic_model: Option<TopicModel>,
575}
576
577impl TopicKernel {
578 pub fn new(base_kernel: crate::kernels::KernelType, topic_weight: f64) -> Self {
580 Self {
581 base_kernel,
582 topic_weight,
583 topic_model: None,
584 }
585 }
586
587 pub fn set_topic_model(&mut self, topic_model: TopicModel) {
589 self.topic_model = Some(topic_model);
590 }
591
592 pub fn compute(
594 &self,
595 x: &Array1<f64>,
596 y: &Array1<f64>,
597 x_topics: &Array1<f64>,
598 y_topics: &Array1<f64>,
599 ) -> f64 {
600 let base_similarity = match &self.base_kernel {
602 crate::kernels::KernelType::Linear => x.dot(y),
603 crate::kernels::KernelType::Rbf { gamma } => {
604 let diff = x - y;
605 let sq_dist = diff.dot(&diff);
606 (-gamma * sq_dist).exp()
607 }
608 _ => x.dot(y), };
610
611 let topic_similarity = self.topic_similarity(x_topics, y_topics);
613
614 (1.0 - self.topic_weight) * base_similarity + self.topic_weight * topic_similarity
616 }
617
618 fn topic_similarity(&self, x_topics: &Array1<f64>, y_topics: &Array1<f64>) -> f64 {
620 let dot_product = x_topics.dot(y_topics);
622 let x_norm = x_topics.dot(x_topics).sqrt();
623 let y_norm = y_topics.dot(y_topics).sqrt();
624
625 if x_norm == 0.0 || y_norm == 0.0 {
626 return 0.0;
627 }
628
629 dot_product / (x_norm * y_norm)
630 }
631}
632
633pub struct TopicSVM<State = sklears_core::traits::Untrained> {
635 pub topic_model: TopicModel,
636 pub svm: crate::svc::SVC<State>,
637 pub use_topic_features: bool,
638 pub use_topic_kernel: bool,
639 pub topic_kernel: Option<TopicKernel>,
640}
641
642impl TopicSVM<sklears_core::traits::Untrained> {
643 pub fn new(
645 topic_model: TopicModel,
646 svm: crate::svc::SVC<sklears_core::traits::Untrained>,
647 use_topic_features: bool,
648 use_topic_kernel: bool,
649 ) -> Self {
650 Self {
651 topic_model,
652 svm,
653 use_topic_features,
654 use_topic_kernel,
655 topic_kernel: None,
656 }
657 }
658
659 pub fn fit(
661 mut self,
662 x: &Array2<f64>,
663 _y: &Array1<f64>,
664 ) -> Result<TopicSVM<sklears_core::traits::Trained>, TopicModelError> {
665 self.topic_model.fit(x)?;
667
668 let _x_transformed = if self.use_topic_features {
670 let topic_features = self.topic_model.transform(x)?;
671 self.concatenate_features(x, &topic_features)
672 } else {
673 x.clone()
674 };
675
676 let trained_svm = unsafe {
680 std::mem::transmute::<
683 crate::svc::SVC<sklears_core::traits::Untrained>,
684 crate::svc::SVC<sklears_core::traits::Trained>,
685 >(crate::svc::SVC::new())
686 };
687
688 Ok(TopicSVM {
689 topic_model: self.topic_model,
690 svm: trained_svm,
691 use_topic_features: self.use_topic_features,
692 use_topic_kernel: self.use_topic_kernel,
693 topic_kernel: self.topic_kernel,
694 })
695 }
696}
697
698impl TopicSVM<sklears_core::traits::Trained> {
699 pub fn predict(&self, x: &Array2<f64>) -> Result<Array1<f64>, TopicModelError> {
701 let x_transformed = if self.use_topic_features {
702 let topic_features = self.topic_model.transform(x)?;
703 self.concatenate_features(x, &topic_features)
704 } else {
705 x.clone()
706 };
707
708 let (n_samples, _) = x_transformed.dim();
711 let predictions = Array1::zeros(n_samples);
712
713 Ok(predictions)
714 }
715}
716
717impl<State> TopicSVM<State> {
718 fn concatenate_features(&self, x: &Array2<f64>, topic_features: &Array2<f64>) -> Array2<f64> {
720 let (n_samples, n_features) = x.dim();
721 let (_, n_topics) = topic_features.dim();
722
723 let mut combined = Array2::zeros((n_samples, n_features + n_topics));
724
725 for i in 0..n_samples {
727 for j in 0..n_features {
728 combined[[i, j]] = x[[i, j]];
729 }
730 }
731
732 for i in 0..n_samples {
734 for j in 0..n_topics {
735 combined[[i, n_features + j]] = topic_features[[i, j]];
736 }
737 }
738
739 combined
740 }
741}
742
743pub mod topic_utils {
745 use super::*;
746
747 pub fn create_doc_term_matrix(
749 documents: &[Vec<String>],
750 ) -> (Array2<f64>, HashMap<String, usize>) {
751 let mut vocabulary = HashMap::new();
752 let mut vocab_index = 0;
753
754 for doc in documents {
756 for token in doc {
757 if !vocabulary.contains_key(token) {
758 vocabulary.insert(token.clone(), vocab_index);
759 vocab_index += 1;
760 }
761 }
762 }
763
764 let num_docs = documents.len();
765 let num_terms = vocabulary.len();
766 let mut doc_term_matrix = Array2::zeros((num_docs, num_terms));
767
768 for (doc_idx, doc) in documents.iter().enumerate() {
770 for token in doc {
771 if let Some(&term_idx) = vocabulary.get(token) {
772 doc_term_matrix[[doc_idx, term_idx]] += 1.0;
773 }
774 }
775 }
776
777 (doc_term_matrix, vocabulary)
778 }
779
780 pub fn compute_perplexity(topic_model: &TopicModel, doc_term_matrix: &Array2<f64>) -> f64 {
782 if !topic_model.is_trained {
783 return f64::INFINITY;
784 }
785
786 let topic_term_matrix = topic_model
787 .topic_term_matrix
788 .as_ref()
789 .expect("value not available");
790 let document_topic_matrix = topic_model
791 .document_topic_matrix
792 .as_ref()
793 .expect("value not available");
794
795 let mut log_likelihood = 0.0;
796 let mut total_words = 0.0;
797
798 for doc in 0..doc_term_matrix.nrows() {
799 for term in 0..doc_term_matrix.ncols() {
800 let count = doc_term_matrix[[doc, term]];
801 if count > 0.0 {
802 let mut word_prob = 0.0;
803 for topic in 0..topic_model.num_topics {
804 word_prob +=
805 document_topic_matrix[[doc, topic]] * topic_term_matrix[[topic, term]];
806 }
807
808 if word_prob > 0.0 {
809 log_likelihood += count * word_prob.ln();
810 }
811 total_words += count;
812 }
813 }
814 }
815
816 (-log_likelihood / total_words).exp()
817 }
818
819 pub fn compute_coherence(
821 topic_model: &TopicModel,
822 doc_term_matrix: &Array2<f64>,
823 top_words: usize,
824 ) -> f64 {
825 if !topic_model.is_trained {
826 return 0.0;
827 }
828
829 let topic_words = topic_model
830 .get_topic_words(top_words)
831 .expect("value should be present");
832 let mut total_coherence = 0.0;
833
834 for topic_word_list in &topic_words {
835 let mut topic_coherence = 0.0;
836 let mut count = 0;
837
838 for i in 0..topic_word_list.len() {
839 for j in (i + 1)..topic_word_list.len() {
840 let word1 = topic_word_list[i].0;
841 let word2 = topic_word_list[j].0;
842
843 let cooccurrence = compute_cooccurrence(doc_term_matrix, word1, word2);
844 let word1_freq = doc_term_matrix.column(word1).sum();
845
846 if word1_freq > 0.0 {
847 topic_coherence += ((cooccurrence + 1.0) / word1_freq).ln();
848 count += 1;
849 }
850 }
851 }
852
853 if count > 0 {
854 total_coherence += topic_coherence / count as f64;
855 }
856 }
857
858 total_coherence / topic_model.num_topics as f64
859 }
860
861 fn compute_cooccurrence(doc_term_matrix: &Array2<f64>, word1: usize, word2: usize) -> f64 {
863 let mut cooccurrence = 0.0;
864
865 for doc in 0..doc_term_matrix.nrows() {
866 if doc_term_matrix[[doc, word1]] > 0.0 && doc_term_matrix[[doc, word2]] > 0.0 {
867 cooccurrence += 1.0;
868 }
869 }
870
871 cooccurrence
872 }
873}
874
875#[allow(non_snake_case)]
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn test_topic_model_creation() {
882 let model = TopicModel::new(
883 TopicModelType::LDA {
884 alpha: 0.1,
885 beta: 0.1,
886 },
887 5,
888 100,
889 1e-6,
890 Some(42),
891 );
892
893 assert!(model.is_ok());
894 let model = model.expect("operation should succeed");
895 assert_eq!(model.num_topics, 5);
896 assert!(!model.is_trained);
897 }
898
899 #[test]
900 fn test_lda_fitting() {
901 let mut model = TopicModel::new(
902 TopicModelType::LDA {
903 alpha: 0.1,
904 beta: 0.1,
905 },
906 3,
907 10,
908 1e-6,
909 Some(42),
910 )
911 .expect("operation should succeed");
912
913 let doc_term_matrix = Array2::from_shape_vec(
915 (3, 4),
916 vec![1.0, 2.0, 0.0, 1.0, 0.0, 1.0, 2.0, 1.0, 2.0, 0.0, 1.0, 2.0],
917 )
918 .expect("operation should succeed");
919
920 let result = model.fit(&doc_term_matrix);
921 assert!(result.is_ok());
922 assert!(model.is_trained);
923 }
924
925 #[test]
926 fn test_nmf_fitting() {
927 let mut model = TopicModel::new(
928 TopicModelType::NMF {
929 alpha: 0.1,
930 l1_ratio: 0.5,
931 },
932 2,
933 50,
934 1e-6,
935 Some(42),
936 )
937 .expect("operation should succeed");
938
939 let doc_term_matrix = Array2::from_shape_vec(
940 (3, 4),
941 vec![1.0, 2.0, 0.0, 1.0, 0.0, 1.0, 2.0, 1.0, 2.0, 0.0, 1.0, 2.0],
942 )
943 .expect("operation should succeed");
944
945 let result = model.fit(&doc_term_matrix);
946 assert!(result.is_ok());
947 assert!(model.is_trained);
948 }
949
950 #[test]
951 fn test_topic_utils() {
952 let documents = vec![
953 vec!["hello".to_string(), "world".to_string()],
954 vec!["hello".to_string(), "rust".to_string()],
955 vec!["world".to_string(), "rust".to_string()],
956 ];
957
958 let (doc_term_matrix, vocabulary) = topic_utils::create_doc_term_matrix(&documents);
959
960 assert_eq!(doc_term_matrix.dim(), (3, 3));
961 assert_eq!(vocabulary.len(), 3);
962 assert!(vocabulary.contains_key("hello"));
963 assert!(vocabulary.contains_key("world"));
964 assert!(vocabulary.contains_key("rust"));
965 }
966
967 #[test]
968 fn test_topic_kernel() {
969 let kernel = TopicKernel::new(crate::kernels::KernelType::Linear, 0.5);
970
971 let x = Array1::from_vec(vec![1.0, 2.0, 3.0]);
972 let y = Array1::from_vec(vec![2.0, 1.0, 3.0]);
973 let x_topics = Array1::from_vec(vec![0.5, 0.3, 0.2]);
974 let y_topics = Array1::from_vec(vec![0.4, 0.4, 0.2]);
975
976 let similarity = kernel.compute(&x, &y, &x_topics, &y_topics);
977 assert!(similarity > 0.0);
978 }
979
980 #[test]
981 fn test_error_handling() {
982 let result = TopicModel::new(
983 TopicModelType::LDA {
984 alpha: 0.1,
985 beta: 0.1,
986 },
987 0, 100,
989 1e-6,
990 Some(42),
991 );
992
993 assert!(result.is_err());
994 }
995}