Skip to main content

pictor_eval/
chrf.rs

1//! chrF and chrF++ — character n-gram F-score (Popović 2015).
2//!
3//! chrF computes the F_β (default β=2) score over character n-grams from
4//! n=1 up to n=`order` (default 6). chrF++ additionally mixes in word
5//! n-gram F-scores up to `word_order` (typical 2).
6//!
7//! All iteration is over Unicode `char`s — byte slicing is *not* used, so
8//! multi-byte UTF-8 sequences are handled correctly.
9//!
10//! ## Final score
11//!
12//! For each order `n ∈ 1..=N`:
13//! ```text
14//! p_n = |cand ∩ ref| / |cand|
15//! r_n = |cand ∩ ref| / |ref|
16//! ```
17//! (multiset intersection over character / word n-grams).
18//!
19//! Averages `P = (1/N) Σ p_n`, `R = (1/N) Σ r_n`, combined via:
20//! ```text
21//! F_β = (1 + β²) · P · R / (β² · P + R)
22//! ```
23//!
24//! For chrF++, character and word contributions are averaged with equal weight
25//! over their respective orders, following the reference implementation.
26//!
27//! Empty candidate *and* empty reference → score = 1.0.
28//! Empty candidate *or* empty reference (not both) → score = 0.0.
29
30use std::collections::HashMap;
31
32/// Character / word n-gram F-score result.
33#[derive(Debug, Clone)]
34pub struct ChrfScore {
35    /// Final F-score in `[0, 1]`.
36    pub score: f32,
37    /// Character n-gram order used.
38    pub order: usize,
39    /// β for the F-score (β>1 weights recall).
40    pub beta: f32,
41    /// Word n-gram order (0 = chrF, >=1 = chrF++).
42    pub word_order: usize,
43}
44
45/// chrF (character-n-gram F-score) default: order=6, β=2.
46pub fn chrf(candidate: &str, reference: &str) -> ChrfScore {
47    chrf_with(candidate, reference, 6, 2.0, 0)
48}
49
50/// chrF++ convenience: char order=6, word order=2, β=2.
51pub fn chrf_plus_plus(candidate: &str, reference: &str) -> ChrfScore {
52    chrf_with(candidate, reference, 6, 2.0, 2)
53}
54
55/// chrF / chrF++ with explicit parameters.
56///
57/// - `order`: maximum character n-gram order (≥ 1).
58/// - `beta`: β for F-score (typical 2.0).
59/// - `word_order`: 0 → chrF; ≥1 → mix in word n-grams up to this order (chrF++).
60pub fn chrf_with(
61    candidate: &str,
62    reference: &str,
63    order: usize,
64    beta: f32,
65    word_order: usize,
66) -> ChrfScore {
67    let order = order.max(1);
68    let cand_chars: Vec<char> = candidate.chars().collect();
69    let ref_chars: Vec<char> = reference.chars().collect();
70
71    // Handle edge cases: both empty → perfect, either empty → 0.
72    let both_empty = cand_chars.is_empty() && ref_chars.is_empty();
73    let one_empty = cand_chars.is_empty() ^ ref_chars.is_empty();
74    if both_empty {
75        return ChrfScore {
76            score: 1.0,
77            order,
78            beta,
79            word_order,
80        };
81    }
82    if one_empty {
83        return ChrfScore {
84            score: 0.0,
85            order,
86            beta,
87            word_order,
88        };
89    }
90
91    let cand_words: Vec<&str> = candidate.split_whitespace().collect();
92    let ref_words: Vec<&str> = reference.split_whitespace().collect();
93
94    // Collect per-order F-beta into a single averaged score.
95    let mut f_values: Vec<f32> = Vec::new();
96
97    // Character orders
98    for n in 1..=order {
99        if let Some(f) = order_f_beta_chars(&cand_chars, &ref_chars, n, beta) {
100            f_values.push(f);
101        } else {
102            f_values.push(0.0);
103        }
104    }
105
106    // Word orders (chrF++)
107    if word_order >= 1 {
108        for n in 1..=word_order {
109            if let Some(f) = order_f_beta_words(&cand_words, &ref_words, n, beta) {
110                f_values.push(f);
111            } else {
112                f_values.push(0.0);
113            }
114        }
115    }
116
117    let score = if f_values.is_empty() {
118        0.0
119    } else {
120        let sum: f32 = f_values.iter().sum();
121        sum / f_values.len() as f32
122    };
123
124    ChrfScore {
125        score: score.clamp(0.0, 1.0),
126        order,
127        beta,
128        word_order,
129    }
130}
131
132fn order_f_beta_chars(cand: &[char], reference: &[char], n: usize, beta: f32) -> Option<f32> {
133    if cand.len() < n || reference.len() < n {
134        return None;
135    }
136    let cand_counts = ngram_counts_char(cand, n);
137    let ref_counts = ngram_counts_char(reference, n);
138    Some(f_beta_from_counts(&cand_counts, &ref_counts, beta))
139}
140
141fn order_f_beta_words(cand: &[&str], reference: &[&str], n: usize, beta: f32) -> Option<f32> {
142    if cand.len() < n || reference.len() < n {
143        return None;
144    }
145    let cand_counts = ngram_counts_words(cand, n);
146    let ref_counts = ngram_counts_words(reference, n);
147    Some(f_beta_from_counts(&cand_counts, &ref_counts, beta))
148}
149
150fn f_beta_from_counts<K: std::hash::Hash + Eq + Clone>(
151    cand: &HashMap<K, usize>,
152    reference: &HashMap<K, usize>,
153    beta: f32,
154) -> f32 {
155    let cand_total: usize = cand.values().sum();
156    let ref_total: usize = reference.values().sum();
157    if cand_total == 0 || ref_total == 0 {
158        return 0.0;
159    }
160    let mut overlap = 0usize;
161    for (k, &v) in cand {
162        if let Some(&rv) = reference.get(k) {
163            overlap += v.min(rv);
164        }
165    }
166    if overlap == 0 {
167        return 0.0;
168    }
169    let p = overlap as f32 / cand_total as f32;
170    let r = overlap as f32 / ref_total as f32;
171    let b2 = beta * beta;
172    let denom = b2 * p + r;
173    if denom <= 0.0 {
174        0.0
175    } else {
176        ((1.0 + b2) * p * r) / denom
177    }
178}
179
180fn ngram_counts_char(chars: &[char], n: usize) -> HashMap<Vec<char>, usize> {
181    let mut counts: HashMap<Vec<char>, usize> = HashMap::new();
182    if n == 0 || chars.len() < n {
183        return counts;
184    }
185    for w in chars.windows(n) {
186        *counts.entry(w.to_vec()).or_insert(0) += 1;
187    }
188    counts
189}
190
191fn ngram_counts_words(words: &[&str], n: usize) -> HashMap<Vec<String>, usize> {
192    let mut counts: HashMap<Vec<String>, usize> = HashMap::new();
193    if n == 0 || words.len() < n {
194        return counts;
195    }
196    for w in words.windows(n) {
197        let key: Vec<String> = w.iter().map(|s| s.to_string()).collect();
198        *counts.entry(key).or_insert(0) += 1;
199    }
200    counts
201}