Skip to main content

sklears_svm/
text_classification.rs

1//! Text classification specific kernels and utilities for SVM
2//!
3//! This module provides specialized kernels and preprocessing utilities for text classification
4//! tasks using Support Vector Machines. It includes:
5//! - N-gram kernels for text similarity
6//! - String kernels for sequence comparison
7//! - TF-IDF integration for text preprocessing
8//! - Document similarity kernels
9//! - Text preprocessing utilities
10
11use std::cmp::Reverse;
12use std::collections::HashMap;
13
14#[cfg(feature = "parallel")]
15#[allow(unused_imports)]
16use rayon::prelude::*;
17use scirs2_core::ndarray::{Array2, ArrayView1};
18
19use crate::kernels::Kernel;
20use sklears_core::error::{Result, SklearsError};
21
22/// N-gram kernel for text classification
23///
24/// The n-gram kernel computes similarity between documents based on the number
25/// of shared n-grams (subsequences of n consecutive characters or words).
26/// This kernel is particularly effective for text classification tasks.
27///
28/// K(x, y) = Σ_{g ∈ N-grams} φ_g(x) * φ_g(y)
29///
30/// where φ_g(x) is the count (or normalized count) of n-gram g in document x.
31///
32/// References:
33/// - Lodhi, H. et al. (2002). Text classification using string kernels.
34/// - Cancedda, N. et al. (2003). Kernel methods for document analysis.
35#[derive(Debug, Clone)]
36pub struct NGramKernel {
37    /// N-gram size (e.g., 2 for bigrams, 3 for trigrams)
38    pub n: usize,
39    /// Whether to normalize by document length
40    pub normalize: bool,
41    /// Whether to use character-level n-grams (true) or word-level (false)
42    pub char_level: bool,
43    /// Case sensitivity
44    pub case_sensitive: bool,
45    /// Minimum frequency threshold for n-grams
46    pub min_freq: usize,
47    /// Maximum number of n-grams to consider
48    pub max_features: Option<usize>,
49}
50
51impl Default for NGramKernel {
52    fn default() -> Self {
53        Self {
54            n: 3,
55            normalize: true,
56            char_level: true,
57            case_sensitive: false,
58            min_freq: 1,
59            max_features: Some(10000),
60        }
61    }
62}
63
64impl NGramKernel {
65    /// Create a new n-gram kernel
66    pub fn new(n: usize) -> Self {
67        Self {
68            n,
69            ..Default::default()
70        }
71    }
72
73    /// Set normalization
74    pub fn with_normalize(mut self, normalize: bool) -> Self {
75        self.normalize = normalize;
76        self
77    }
78
79    /// Set character level
80    pub fn with_char_level(mut self, char_level: bool) -> Self {
81        self.char_level = char_level;
82        self
83    }
84
85    /// Set case sensitivity
86    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
87        self.case_sensitive = case_sensitive;
88        self
89    }
90
91    /// Set minimum frequency
92    pub fn with_min_freq(mut self, min_freq: usize) -> Self {
93        self.min_freq = min_freq;
94        self
95    }
96
97    /// Set maximum features
98    pub fn with_max_features(mut self, max_features: Option<usize>) -> Self {
99        self.max_features = max_features;
100        self
101    }
102
103    /// Extract n-grams from text
104    pub fn extract_ngrams(&self, text: &str) -> Result<HashMap<String, usize>> {
105        let processed_text = if self.case_sensitive {
106            text.to_string()
107        } else {
108            text.to_lowercase()
109        };
110
111        let mut ngrams = HashMap::new();
112
113        if self.char_level {
114            // Character-level n-grams
115            let chars: Vec<char> = processed_text.chars().collect();
116            for window in chars.windows(self.n) {
117                let ngram: String = window.iter().collect();
118                *ngrams.entry(ngram).or_insert(0) += 1;
119            }
120        } else {
121            // Word-level n-grams
122            let words: Vec<&str> = processed_text.split_whitespace().collect();
123            for window in words.windows(self.n) {
124                let ngram = window.join(" ");
125                *ngrams.entry(ngram).or_insert(0) += 1;
126            }
127        }
128
129        // Filter by minimum frequency
130        ngrams.retain(|_, &mut count| count >= self.min_freq);
131
132        Ok(ngrams)
133    }
134
135    /// Compute kernel value between two texts
136    pub fn compute_text_similarity(&self, text1: &str, text2: &str) -> Result<f64> {
137        let ngrams1 = self.extract_ngrams(text1)?;
138        let ngrams2 = self.extract_ngrams(text2)?;
139
140        let mut dot_product = 0.0;
141        let mut norm1 = 0.0;
142        let mut norm2 = 0.0;
143
144        // Compute dot product and norms
145        for (ngram, &count1) in &ngrams1 {
146            norm1 += (count1 as f64).powi(2);
147            if let Some(&count2) = ngrams2.get(ngram) {
148                dot_product += (count1 as f64) * (count2 as f64);
149            }
150        }
151
152        for &count2 in ngrams2.values() {
153            norm2 += (count2 as f64).powi(2);
154        }
155
156        if self.normalize {
157            let norm_product = norm1.sqrt() * norm2.sqrt();
158            if norm_product > 0.0 {
159                Ok(dot_product / norm_product)
160            } else {
161                Ok(0.0)
162            }
163        } else {
164            Ok(dot_product)
165        }
166    }
167
168    /// Convert texts to feature vectors
169    pub fn texts_to_features(&self, texts: &[String]) -> Result<(Array2<f64>, Vec<String>)> {
170        // Extract all n-grams from all texts
171        let mut all_ngrams = HashMap::new();
172        for text in texts {
173            let ngrams = self.extract_ngrams(text)?;
174            for (ngram, count) in ngrams {
175                *all_ngrams.entry(ngram).or_insert(0) += count;
176            }
177        }
178
179        // Filter and sort n-grams
180        let mut ngram_features: Vec<(String, usize)> = all_ngrams
181            .into_iter()
182            .filter(|(_, count)| *count >= self.min_freq)
183            .collect();
184
185        // Sort by frequency (descending) and limit features
186        ngram_features.sort_by_key(|item| Reverse(item.1));
187        if let Some(max_features) = self.max_features {
188            ngram_features.truncate(max_features);
189        }
190
191        let feature_names: Vec<String> = ngram_features
192            .iter()
193            .map(|(name, _)| name.clone())
194            .collect();
195        let num_features = feature_names.len();
196
197        // Create feature matrix
198        let mut feature_matrix = Array2::zeros((texts.len(), num_features));
199
200        for (text_idx, text) in texts.iter().enumerate() {
201            let ngrams = self.extract_ngrams(text)?;
202            let mut text_norm = 0.0;
203
204            // Fill feature vector
205            for (feat_idx, feature_name) in feature_names.iter().enumerate() {
206                let count = ngrams.get(feature_name).copied().unwrap_or(0) as f64;
207                feature_matrix[[text_idx, feat_idx]] = count;
208                text_norm += count * count;
209            }
210
211            // Normalize if requested
212            if self.normalize && text_norm > 0.0 {
213                let norm = text_norm.sqrt();
214                for feat_idx in 0..num_features {
215                    feature_matrix[[text_idx, feat_idx]] /= norm;
216                }
217            }
218        }
219
220        Ok((feature_matrix, feature_names))
221    }
222}
223
224impl Kernel for NGramKernel {
225    fn compute(&self, x: ArrayView1<f64>, y: ArrayView1<f64>) -> f64 {
226        // For pre-computed feature vectors, just compute dot product
227        // Manual dot product to avoid recursion limit issues
228        x.iter().zip(y.iter()).map(|(a, b)| a * b).sum()
229    }
230
231    fn parameters(&self) -> HashMap<String, f64> {
232        let mut params = HashMap::new();
233        params.insert("n".to_string(), self.n as f64);
234        params
235    }
236}
237
238/// String kernel for sequence comparison
239///
240/// The string kernel computes similarity between strings based on the number
241/// of common subsequences. This is useful for comparing DNA sequences, protein
242/// sequences, or any other string data.
243///
244/// K_n(s, t) = Σ_{u∈Σ^n} φ_u(s) * φ_u(t)
245///
246/// where φ_u(s) is the number of occurrences of subsequence u in string s.
247///
248/// References:
249/// - Lodhi, H. et al. (2002). Text classification using string kernels.
250/// - Shawe-Taylor, J. & Cristianini, N. (2004). Kernel Methods for Pattern Analysis.
251#[derive(Debug, Clone)]
252pub struct StringKernel {
253    /// Maximum subsequence length
254    pub max_length: usize,
255    /// Decay factor for distant matches
256    pub lambda: f64,
257    /// Whether to normalize
258    pub normalize: bool,
259}
260
261impl Default for StringKernel {
262    fn default() -> Self {
263        Self {
264            max_length: 5,
265            lambda: 0.5,
266            normalize: true,
267        }
268    }
269}
270
271impl StringKernel {
272    /// Create a new string kernel
273    pub fn new(max_length: usize, lambda: f64) -> Self {
274        Self {
275            max_length,
276            lambda,
277            normalize: true,
278        }
279    }
280
281    /// Compute string kernel between two strings
282    pub fn compute_string_similarity(&self, s1: &str, s2: &str) -> f64 {
283        let chars1: Vec<char> = s1.chars().collect();
284        let chars2: Vec<char> = s2.chars().collect();
285
286        let mut kernel_value = 0.0;
287
288        // Dynamic programming approach for computing string kernel
289        for length in 1..=self.max_length {
290            kernel_value += self.compute_subsequence_kernel(&chars1, &chars2, length);
291        }
292
293        if self.normalize {
294            let norm1 = self.compute_string_norm(&chars1);
295            let norm2 = self.compute_string_norm(&chars2);
296            let norm_product = norm1 * norm2;
297            if norm_product > 0.0 {
298                kernel_value / norm_product.sqrt()
299            } else {
300                0.0
301            }
302        } else {
303            kernel_value
304        }
305    }
306
307    /// Compute subsequence kernel for a specific length
308    fn compute_subsequence_kernel(&self, s1: &[char], s2: &[char], length: usize) -> f64 {
309        if length == 0 {
310            return 1.0;
311        }
312
313        let n1 = s1.len();
314        let n2 = s2.len();
315
316        if n1 < length || n2 < length {
317            return 0.0;
318        }
319
320        // Dynamic programming table
321        let mut dp = vec![vec![0.0; n2 + 1]; n1 + 1];
322
323        // Base case
324        for i in 0..=n1 {
325            for j in 0..=n2 {
326                if length == 1 {
327                    dp[i][j] = if i > 0 && j > 0 && s1[i - 1] == s2[j - 1] {
328                        self.lambda.powi(2)
329                    } else {
330                        0.0
331                    };
332                }
333            }
334        }
335
336        // Fill DP table for longer subsequences
337        if length > 1 {
338            let _prev_kernel = self.compute_subsequence_kernel(s1, s2, length - 1);
339
340            for i in 1..=n1 {
341                for j in 1..=n2 {
342                    if s1[i - 1] == s2[j - 1] {
343                        // Characters match, consider all previous positions
344                        let mut sum = 0.0;
345                        for ii in 0..i {
346                            for jj in 0..j {
347                                let dist1 = i - ii - 1;
348                                let dist2 = j - jj - 1;
349                                let decay = self.lambda.powi((dist1 + dist2 + 2) as i32);
350                                sum += decay
351                                    * self.compute_subsequence_kernel(
352                                        &s1[0..ii + 1],
353                                        &s2[0..jj + 1],
354                                        length - 1,
355                                    );
356                            }
357                        }
358                        dp[i][j] = sum;
359                    }
360                }
361            }
362        }
363
364        dp[n1][n2]
365    }
366
367    /// Compute string norm for normalization
368    fn compute_string_norm(&self, s: &[char]) -> f64 {
369        let mut norm = 0.0;
370        for length in 1..=self.max_length {
371            norm += self.compute_subsequence_kernel(s, s, length);
372        }
373        norm
374    }
375}
376
377/// TF-IDF (Term Frequency-Inverse Document Frequency) preprocessor
378///
379/// TF-IDF is a numerical statistic that reflects how important a word is to a document
380/// in a collection of documents. It increases proportionally to the number of times
381/// a word appears in the document but is offset by the frequency of the word in the corpus.
382///
383/// TF-IDF(t,d,D) = TF(t,d) × IDF(t,D)
384///
385/// where:
386/// - TF(t,d) = (Number of times term t appears in document d) / (Total number of terms in d)
387/// - IDF(t,D) = log(Total number of documents / Number of documents containing term t)
388#[derive(Debug, Clone)]
389pub struct TfIdfVectorizer {
390    /// Minimum document frequency (ignore terms that appear in fewer documents)
391    pub min_df: usize,
392    /// Maximum document frequency (ignore terms that appear in more documents)
393    pub max_df: f64,
394    /// Maximum number of features
395    pub max_features: Option<usize>,
396    /// N-gram range (min_n, max_n)
397    pub ngram_range: (usize, usize),
398    /// Whether to use character-level n-grams
399    pub char_level: bool,
400    /// Case sensitivity
401    pub case_sensitive: bool,
402    /// Learned vocabulary
403    vocabulary: Option<HashMap<String, usize>>,
404    /// Document frequencies
405    doc_frequencies: Option<HashMap<String, usize>>,
406    /// Number of documents
407    n_docs: usize,
408}
409
410impl Default for TfIdfVectorizer {
411    fn default() -> Self {
412        Self {
413            min_df: 1,
414            max_df: 1.0,
415            max_features: None,
416            ngram_range: (1, 1),
417            char_level: false,
418            case_sensitive: false,
419            vocabulary: None,
420            doc_frequencies: None,
421            n_docs: 0,
422        }
423    }
424}
425
426impl TfIdfVectorizer {
427    /// Create a new TF-IDF vectorizer
428    pub fn new() -> Self {
429        Self::default()
430    }
431
432    /// Set minimum document frequency
433    pub fn with_min_df(mut self, min_df: usize) -> Self {
434        self.min_df = min_df;
435        self
436    }
437
438    /// Set maximum document frequency
439    pub fn with_max_df(mut self, max_df: f64) -> Self {
440        self.max_df = max_df;
441        self
442    }
443
444    /// Set maximum features
445    pub fn with_max_features(mut self, max_features: Option<usize>) -> Self {
446        self.max_features = max_features;
447        self
448    }
449
450    /// Set n-gram range
451    pub fn with_ngram_range(mut self, min_n: usize, max_n: usize) -> Self {
452        self.ngram_range = (min_n, max_n);
453        self
454    }
455
456    /// Set character level
457    pub fn with_char_level(mut self, char_level: bool) -> Self {
458        self.char_level = char_level;
459        self
460    }
461
462    /// Set case sensitivity
463    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
464        self.case_sensitive = case_sensitive;
465        self
466    }
467
468    /// Fit the vectorizer on a corpus
469    pub fn fit(&mut self, documents: &[String]) -> Result<()> {
470        self.n_docs = documents.len();
471
472        // Extract all terms and their document frequencies
473        let mut term_doc_freq = HashMap::new();
474        let mut vocabulary = HashMap::new();
475
476        for document in documents {
477            let terms = self.extract_terms(document)?;
478            let unique_terms: std::collections::HashSet<String> = terms.into_iter().collect();
479
480            for term in unique_terms {
481                *term_doc_freq.entry(term.clone()).or_insert(0) += 1;
482                vocabulary.insert(term, 0); // Will set proper indices later
483            }
484        }
485
486        // Filter terms by document frequency
487        let max_df_count = (self.max_df * self.n_docs as f64) as usize;
488
489        let filtered_terms: Vec<String> = term_doc_freq
490            .iter()
491            .filter(|(_, &df)| df >= self.min_df && df <= max_df_count)
492            .map(|(term, _)| term.clone())
493            .collect();
494
495        // Sort terms and limit by max_features
496        let mut terms_with_freq: Vec<(String, usize)> = filtered_terms
497            .iter()
498            .map(|term| {
499                (
500                    term.clone(),
501                    *term_doc_freq.get(term).expect("key not found"),
502                )
503            })
504            .collect();
505
506        terms_with_freq.sort_by_key(|item| Reverse(item.1)); // Sort by frequency (descending)
507
508        if let Some(max_features) = self.max_features {
509            terms_with_freq.truncate(max_features);
510        }
511
512        // Create final vocabulary
513        let mut final_vocabulary = HashMap::new();
514        let mut final_doc_freq = HashMap::new();
515
516        for (idx, (term, freq)) in terms_with_freq.iter().enumerate() {
517            final_vocabulary.insert(term.clone(), idx);
518            final_doc_freq.insert(term.clone(), *freq);
519        }
520
521        self.vocabulary = Some(final_vocabulary);
522        self.doc_frequencies = Some(final_doc_freq);
523
524        Ok(())
525    }
526
527    /// Transform documents to TF-IDF matrix
528    pub fn transform(&self, documents: &[String]) -> Result<Array2<f64>> {
529        let vocabulary = self
530            .vocabulary
531            .as_ref()
532            .ok_or_else(|| SklearsError::NotFitted {
533                operation: "transform".to_string(),
534            })?;
535
536        let doc_frequencies =
537            self.doc_frequencies
538                .as_ref()
539                .ok_or_else(|| SklearsError::NotFitted {
540                    operation: "transform".to_string(),
541                })?;
542
543        let n_features = vocabulary.len();
544        let mut tfidf_matrix = Array2::zeros((documents.len(), n_features));
545
546        for (doc_idx, document) in documents.iter().enumerate() {
547            let terms = self.extract_terms(document)?;
548            let mut term_counts = HashMap::new();
549
550            // Count term frequencies
551            for term in &terms {
552                if vocabulary.contains_key(term) {
553                    *term_counts.entry(term.clone()).or_insert(0) += 1;
554                }
555            }
556
557            let total_terms = terms.len() as f64;
558
559            // Compute TF-IDF for each term
560            for (term, count) in term_counts {
561                if let (Some(&term_idx), Some(&doc_freq)) =
562                    (vocabulary.get(&term), doc_frequencies.get(&term))
563                {
564                    let tf = count as f64 / total_terms;
565                    let idf = (self.n_docs as f64 / doc_freq as f64).ln();
566                    let tfidf = tf * idf;
567
568                    tfidf_matrix[[doc_idx, term_idx]] = tfidf;
569                }
570            }
571        }
572
573        Ok(tfidf_matrix)
574    }
575
576    /// Fit and transform in one step
577    pub fn fit_transform(&mut self, documents: &[String]) -> Result<Array2<f64>> {
578        self.fit(documents)?;
579        self.transform(documents)
580    }
581
582    /// Extract terms from a document
583    fn extract_terms(&self, document: &str) -> Result<Vec<String>> {
584        let processed_doc = if self.case_sensitive {
585            document.to_string()
586        } else {
587            document.to_lowercase()
588        };
589
590        let mut terms = Vec::new();
591
592        if self.char_level {
593            // Character-level n-grams
594            let chars: Vec<char> = processed_doc.chars().collect();
595            for n in self.ngram_range.0..=self.ngram_range.1 {
596                for window in chars.windows(n) {
597                    let term: String = window.iter().collect();
598                    terms.push(term);
599                }
600            }
601        } else {
602            // Word-level n-grams
603            let words: Vec<&str> = processed_doc.split_whitespace().collect();
604            for n in self.ngram_range.0..=self.ngram_range.1 {
605                for window in words.windows(n) {
606                    let term = window.join(" ");
607                    terms.push(term);
608                }
609            }
610        }
611
612        Ok(terms)
613    }
614
615    /// Get feature names
616    pub fn get_feature_names(&self) -> Result<Vec<String>> {
617        let vocabulary = self
618            .vocabulary
619            .as_ref()
620            .ok_or_else(|| SklearsError::NotFitted {
621                operation: "get_feature_names".to_string(),
622            })?;
623
624        let mut features = vec![String::new(); vocabulary.len()];
625        for (term, &idx) in vocabulary {
626            features[idx] = term.clone();
627        }
628
629        Ok(features)
630    }
631}
632
633/// Document similarity kernel based on cosine similarity
634///
635/// This kernel computes the cosine similarity between document vectors,
636/// which is commonly used in information retrieval and text classification.
637///
638/// K(d1, d2) = cos(θ) = (d1 · d2) / (||d1|| × ||d2||)
639#[derive(Debug, Clone)]
640pub struct DocumentSimilarityKernel {
641    /// TF-IDF vectorizer
642    pub vectorizer: TfIdfVectorizer,
643    /// Precomputed document vectors
644    document_vectors: Option<Array2<f64>>,
645    /// Similarity threshold for sparse kernels
646    similarity_threshold: f64,
647    /// Whether to normalize vectors
648    normalize_vectors: bool,
649}
650
651impl DocumentSimilarityKernel {
652    /// Create a new document similarity kernel
653    pub fn new(vectorizer: TfIdfVectorizer) -> Self {
654        Self {
655            vectorizer,
656            document_vectors: None,
657            similarity_threshold: 0.1,
658            normalize_vectors: true,
659        }
660    }
661
662    /// Fit the kernel on a corpus
663    pub fn fit(&mut self, documents: &[String]) -> Result<()> {
664        let vectors = self.vectorizer.fit_transform(documents)?;
665        self.document_vectors = Some(vectors);
666        Ok(())
667    }
668
669    /// Compute similarity between two document indices
670    pub fn compute_document_similarity(&self, doc1_idx: usize, doc2_idx: usize) -> Result<f64> {
671        let vectors = self
672            .document_vectors
673            .as_ref()
674            .ok_or_else(|| SklearsError::NotFitted {
675                operation: "compute_similarity".to_string(),
676            })?;
677
678        if doc1_idx >= vectors.nrows() || doc2_idx >= vectors.nrows() {
679            return Err(SklearsError::InvalidInput(
680                "Document index out of bounds".to_string(),
681            ));
682        }
683
684        let vec1 = vectors.row(doc1_idx);
685        let vec2 = vectors.row(doc2_idx);
686
687        let dot_product = vec1.dot(&vec2);
688        let norm1 = vec1.dot(&vec1).sqrt();
689        let norm2 = vec2.dot(&vec2).sqrt();
690
691        if norm1 > 0.0 && norm2 > 0.0 {
692            Ok(dot_product / (norm1 * norm2))
693        } else {
694            Ok(0.0)
695        }
696    }
697
698    /// Get the document vector matrix
699    pub fn get_document_vectors(&self) -> Option<&Array2<f64>> {
700        self.document_vectors.as_ref()
701    }
702}
703
704impl Kernel for DocumentSimilarityKernel {
705    fn compute(&self, x: ArrayView1<f64>, y: ArrayView1<f64>) -> f64 {
706        // Manual dot products to avoid recursion limit issues
707        let dot_product: f64 = x.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
708        let norm_x: f64 = x.iter().map(|a| a * a).sum::<f64>().sqrt();
709        let norm_y: f64 = y.iter().map(|b| b * b).sum::<f64>().sqrt();
710
711        if norm_x > 0.0 && norm_y > 0.0 {
712            dot_product / (norm_x * norm_y)
713        } else {
714            0.0
715        }
716    }
717
718    fn parameters(&self) -> HashMap<String, f64> {
719        let mut params = HashMap::new();
720        params.insert(
721            "similarity_threshold".to_string(),
722            self.similarity_threshold,
723        );
724        params.insert(
725            "normalize_vectors".to_string(),
726            if self.normalize_vectors { 1.0 } else { 0.0 },
727        );
728        params
729    }
730}
731
732#[allow(non_snake_case)]
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn test_ngram_kernel_basic() {
739        let kernel = NGramKernel::new(2).with_char_level(true);
740
741        let text1 = "hello";
742        let text2 = "hello";
743        let similarity = kernel
744            .compute_text_similarity(text1, text2)
745            .expect("operation should succeed");
746        assert_eq!(similarity, 1.0); // Identical texts should have similarity 1.0
747
748        let text3 = "world";
749        let similarity2 = kernel
750            .compute_text_similarity(text1, text3)
751            .expect("operation should succeed");
752        assert!(similarity2 < 1.0); // Different texts should have lower similarity
753    }
754
755    #[test]
756    fn test_ngram_extraction() {
757        let kernel = NGramKernel::new(2).with_char_level(true);
758        let ngrams = kernel
759            .extract_ngrams("hello")
760            .expect("operation should succeed");
761
762        assert!(ngrams.contains_key("he"));
763        assert!(ngrams.contains_key("el"));
764        assert!(ngrams.contains_key("ll"));
765        assert!(ngrams.contains_key("lo"));
766        assert_eq!(ngrams.len(), 4);
767    }
768
769    #[test]
770    fn test_word_level_ngrams() {
771        let kernel = NGramKernel::new(2).with_char_level(false);
772        let ngrams = kernel
773            .extract_ngrams("hello world test")
774            .expect("operation should succeed");
775
776        assert!(ngrams.contains_key("hello world"));
777        assert!(ngrams.contains_key("world test"));
778        assert_eq!(ngrams.len(), 2);
779    }
780
781    #[test]
782    fn test_string_kernel() {
783        let kernel = StringKernel::new(3, 0.5);
784
785        let s1 = "abc";
786        let s2 = "abc";
787        let similarity = kernel.compute_string_similarity(s1, s2);
788        assert!(similarity > 0.0);
789
790        let s3 = "xyz";
791        let similarity2 = kernel.compute_string_similarity(s1, s3);
792        assert!(similarity2 < similarity);
793    }
794
795    #[test]
796    fn test_tfidf_vectorizer() {
797        let mut vectorizer = TfIdfVectorizer::new().with_min_df(1).with_ngram_range(1, 1);
798
799        let documents = vec![
800            "hello world".to_string(),
801            "world test".to_string(),
802            "hello test".to_string(),
803        ];
804
805        let result = vectorizer.fit_transform(&documents);
806        assert!(result.is_ok());
807
808        let matrix = result.expect("operation should succeed");
809        assert_eq!(matrix.nrows(), 3);
810        assert!(matrix.ncols() > 0);
811    }
812
813    #[test]
814    fn test_tfidf_feature_names() {
815        let mut vectorizer = TfIdfVectorizer::new().with_min_df(1).with_ngram_range(1, 1);
816
817        let documents = vec!["hello world".to_string(), "world test".to_string()];
818
819        vectorizer
820            .fit(&documents)
821            .expect("model fitting should succeed");
822        let feature_names = vectorizer
823            .get_feature_names()
824            .expect("operation should succeed");
825
826        assert!(feature_names.contains(&"hello".to_string()));
827        assert!(feature_names.contains(&"world".to_string()));
828        assert!(feature_names.contains(&"test".to_string()));
829    }
830
831    #[test]
832    fn test_document_similarity_kernel() {
833        let vectorizer = TfIdfVectorizer::new().with_min_df(1).with_ngram_range(1, 1);
834
835        let mut kernel = DocumentSimilarityKernel::new(vectorizer);
836
837        let documents = vec![
838            "hello world".to_string(),
839            "hello test".to_string(),
840            "completely different text".to_string(),
841        ];
842
843        kernel
844            .fit(&documents)
845            .expect("model fitting should succeed");
846
847        // Documents 0 and 1 should be more similar (both contain "hello")
848        let sim_01 = kernel
849            .compute_document_similarity(0, 1)
850            .expect("operation should succeed");
851        let sim_02 = kernel
852            .compute_document_similarity(0, 2)
853            .expect("operation should succeed");
854
855        assert!(sim_01 > sim_02);
856    }
857
858    #[test]
859    fn test_texts_to_features() {
860        let kernel = NGramKernel::new(2)
861            .with_char_level(true)
862            .with_normalize(true);
863
864        let texts = vec!["hello".to_string(), "world".to_string()];
865
866        let result = kernel.texts_to_features(&texts);
867        assert!(result.is_ok());
868
869        let (features, feature_names) = result.expect("operation should succeed");
870        assert_eq!(features.nrows(), 2);
871        assert!(features.ncols() > 0);
872        assert_eq!(features.ncols(), feature_names.len());
873    }
874
875    #[test]
876    fn test_case_sensitivity() {
877        let kernel_sensitive = NGramKernel::new(2)
878            .with_char_level(true)
879            .with_case_sensitive(true);
880
881        let kernel_insensitive = NGramKernel::new(2)
882            .with_char_level(true)
883            .with_case_sensitive(false);
884
885        let text1 = "Hello";
886        let text2 = "hello";
887
888        let sim_sensitive = kernel_sensitive
889            .compute_text_similarity(text1, text2)
890            .expect("operation should succeed");
891        let sim_insensitive = kernel_insensitive
892            .compute_text_similarity(text1, text2)
893            .expect("operation should succeed");
894
895        assert!(sim_insensitive > sim_sensitive);
896        assert_eq!(sim_insensitive, 1.0); // Should be identical when case-insensitive
897    }
898}