Skip to main content

simi/algo/
tfidf.rs

1//! TF-IDF + Cosine — counts word frequencies, weights them by rarity, and
2//! calculates the cosine angle between document vectors.
3//!
4//! $\cos(\theta) = \frac{A \cdot B}{|A||B|}$
5//!
6//! ## Performance
7//! O(n·m) time where n,m = unique terms in each document.
8
9use std::collections::{HashMap, HashSet};
10
11/// A TF-IDF vector representing a document.
12#[derive(Clone, Debug)]
13pub struct TfIdfVector {
14    /// Term -> (TF-IDF weight) map.
15    pub weights: HashMap<String, f64>,
16    /// Pre-computed magnitude for fast cosine.
17    pub magnitude: f64,
18}
19
20/// Build a TF-IDF vector for a document given corpus-wide IDF weights.
21///
22/// # Arguments
23/// * `tokens` - Tokenized document (individual word tokens).
24/// * `idf_weights` - Pre-computed IDF weights from the corpus.
25///
26/// Returns the TF-IDF vector with pre-computed magnitude.
27#[inline]
28pub fn build_vector(tokens: &[String], idf_weights: &HashMap<String, f64>) -> TfIdfVector {
29    // Compute term frequencies
30    let mut tf: HashMap<String, f64> = HashMap::new();
31    for token in tokens {
32        *tf.entry(token.clone()).or_insert(0.0) += 1.0;
33    }
34
35    // Normalize TF by document length
36    let doc_len = tokens.len() as f64;
37    let mut weights = HashMap::new();
38    let mut magnitude_sq = 0.0;
39
40    for (term, count) in &tf {
41        let tf_val = count / doc_len;
42        let idf = idf_weights.get(term).copied().unwrap_or(0.0);
43        let w = tf_val * idf;
44        magnitude_sq += w * w;
45        weights.insert(term.clone(), w);
46    }
47
48    TfIdfVector {
49        weights,
50        magnitude: magnitude_sq.sqrt(),
51    }
52}
53
54/// Compute IDF weights from a corpus of tokenized documents.
55///
56/// `idf(t) = ln((1 + N) / (1 + df(t))) + 1`
57/// where N = total documents, df(t) = documents containing term t.
58#[inline]
59pub fn compute_idf(documents: &[Vec<String>]) -> HashMap<String, f64> {
60    let n = documents.len() as f64;
61    let mut df: HashMap<String, usize> = HashMap::new();
62
63    for doc in documents {
64        let unique_terms: HashSet<&String> = doc.iter().collect();
65        for term in unique_terms {
66            *df.entry(term.clone()).or_insert(0) += 1;
67        }
68    }
69
70    let mut idf = HashMap::new();
71    for (term, count) in &df {
72        let idf_val = ((1.0 + n) / (1.0 + *count as f64)).ln() + 1.0;
73        idf.insert(term.clone(), idf_val);
74    }
75
76    idf
77}
78
79/// Cosine similarity between two TF-IDF vectors.
80///
81/// Returns a value in `[0.0, 1.0]` where `1.0` means identical term vectors.
82#[inline]
83pub fn cosine_similarity(a: &TfIdfVector, b: &TfIdfVector) -> f64 {
84    if a.magnitude == 0.0 || b.magnitude == 0.0 {
85        return if a.weights.is_empty() && b.weights.is_empty() {
86            1.0
87        } else {
88            0.0
89        };
90    }
91
92    // Dot product of overlapping terms
93    let dot_product: f64 = a
94        .weights
95        .iter()
96        .filter_map(|(term, w_a)| b.weights.get(term).map(|w_b| w_a * w_b))
97        .sum();
98
99    dot_product / (a.magnitude * b.magnitude)
100}
101
102/// Quick one-shot TF-IDF + Cosine similarity between two texts.
103///
104/// Tokenizes both, builds a mini-corpus of the two documents, computes
105/// IDF, builds vectors, and returns cosine similarity.
106#[inline]
107pub fn similarity(a: &str, b: &str) -> f64 {
108    let tokens_a = tokenize(a);
109    let tokens_b = tokenize(b);
110    let documents = vec![tokens_a, tokens_b];
111    let idf = compute_idf(&documents);
112    let vec_a = build_vector(&documents[0], &idf);
113    let vec_b = build_vector(&documents[1], &idf);
114    // `+ 0.0` collapses a possible IEEE-754 negative zero to positive zero so
115    // the public API never returns `-0.0` (which trips strict equality checks).
116    cosine_similarity(&vec_a, &vec_b) + 0.0
117}
118
119/// Tokenize text into lowercase words.
120#[inline]
121pub fn tokenize(text: &str) -> Vec<String> {
122    text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
123        .filter(|s| !s.is_empty())
124        .map(|s| s.to_lowercase())
125        .collect()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn identical_texts() {
134        let s = similarity("the quick brown fox", "the quick brown fox");
135        assert!((s - 1.0).abs() < 0.01, "identical should be ~1.0, got {s}");
136    }
137
138    #[test]
139    fn completely_different() {
140        let s = similarity("hello world", "completely unrelated");
141        assert!(s < 0.3, "expected low similarity, got {s}");
142    }
143
144    #[test]
145    fn partial_match() {
146        let s = similarity("the quick brown fox", "the quick blue fox");
147        assert!(s > 0.3 && s < 1.0, "expected moderate, got {s}");
148    }
149
150    #[test]
151    fn empty_strings() {
152        // Both empty → both vectors empty → cosine returns 1.0
153        let s = similarity("", "");
154        assert!((s - 1.0).abs() < f64::EPSILON);
155    }
156
157    #[test]
158    fn symmetric() {
159        let a = similarity("the cat sat on the mat", "the dog sat on the rug");
160        let b = similarity("the dog sat on the rug", "the cat sat on the mat");
161        assert!((a - b).abs() < f64::EPSILON);
162    }
163}