Skip to main content

lc_evaluation/
bleu.rs

1//! BLEU evaluator: the classic machine-translation / text-generation metric.
2//!
3//! Geometric mean of n-gram precisions + a brevity penalty.
4//! Identical text scores 1.0; no n-gram overlap scores 0.0.
5
6use async_trait::async_trait;
7use std::collections::HashMap;
8
9use super::{EvalError, Evaluator, Score};
10
11/// BLEU evaluator (BLEU-4 by default).
12pub struct Bleu {
13    max_n: usize,
14    /// Character-level tokenization (for whitespace-less languages such as Chinese; one token per char)
15    char_level: bool,
16    /// Smoothing: an n-gram order with no match gets a small value instead of a hard zero, friendlier for short sentences
17    smoothing: bool,
18}
19
20impl Default for Bleu {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl Bleu {
27    /// Creates the default BLEU-4 evaluator.
28    pub fn new() -> Self {
29        Self {
30            max_n: 4,
31            char_level: false,
32            smoothing: false,
33        }
34    }
35
36    /// Uses BLEU-n (default 4)
37    pub fn with_max_n(mut self, n: usize) -> Self {
38        self.max_n = n.max(1);
39        self
40    }
41
42    /// Character-level tokenization: whitespace-less languages such as Chinese split per char (otherwise the whole sentence becomes one token and BLEU breaks)
43    pub fn with_char_level(mut self, v: bool) -> Self {
44        self.char_level = v;
45        self
46    }
47
48    /// Enables smoothing: an order with no n-gram match gets a small value instead of a whole-zero, so short sentences are not cut off wholesale
49    pub fn with_smoothing(mut self, v: bool) -> Self {
50        self.smoothing = v;
51        self
52    }
53
54    /// Corpus-level BLEU: aggregates n-gram match counts across examples, then computes a single
55    /// geometric mean + corpus-level brevity penalty.
56    ///
57    /// P2-1: a sentence-level brevity penalty cuts short sentences off wholesale (e.g. "the cat"
58    /// scores a hard zero under BLEU-4); corpus aggregation computes the penalty from total lengths
59    /// and merges per-order n-gram counts, so short sentences still contribute low-order precision.
60    /// With `with_smoothing`, an order with no match gets a small value instead of a whole zero.
61    ///
62    /// `predictions` and `references` must have the same length (one-to-one), otherwise
63    /// [`EvalError::LengthMismatch`](crate::EvalError::LengthMismatch) is returned.
64    pub fn corpus_bleu(&self, predictions: &[&str], references: &[&str]) -> Result<f64, EvalError> {
65        if predictions.len() != references.len() {
66            return Err(EvalError::LengthMismatch {
67                predictions: predictions.len(),
68                references: references.len(),
69            });
70        }
71        if predictions.is_empty() {
72            return Ok(0.0);
73        }
74        let mut total = vec![0usize; self.max_n];
75        let mut matches = vec![0usize; self.max_n];
76        let mut pred_len = 0usize;
77        let mut ref_len = 0usize;
78        for (pred, reference) in predictions.iter().zip(references) {
79            let pred_t = tokenize(pred, self.char_level);
80            let ref_t = tokenize(reference, self.char_level);
81            pred_len += pred_t.len();
82            ref_len += ref_t.len();
83            for n in 1..=self.max_n {
84                let pred_grams = ngrams(&pred_t, n);
85                let ref_grams = ngrams(&ref_t, n);
86                for (g, &c) in &pred_grams {
87                    total[n - 1] += c;
88                    let r = ref_grams.get(g).copied().unwrap_or(0);
89                    matches[n - 1] += c.min(r);
90                }
91            }
92        }
93        if pred_len == 0 {
94            return Ok(0.0);
95        }
96        let mut log_precisions: Vec<f64> = Vec::new();
97        for n in 0..self.max_n {
98            let t = total[n];
99            let m = matches[n];
100            let p = if t == 0 {
101                // no n-gram at this order across the whole corpus (all predictions too short): with smoothing skip (no penalty), otherwise zero
102                if self.smoothing {
103                    continue;
104                }
105                return Ok(0.0);
106            } else if m == 0 {
107                if self.smoothing {
108                    // smoothing: 0 matches get a small value, avoiding log(0) zeroing the whole result
109                    0.5 / t as f64
110                } else {
111                    return Ok(0.0);
112                }
113            } else {
114                m as f64 / t as f64
115            };
116            log_precisions.push(p.ln());
117        }
118        if log_precisions.is_empty() {
119            return Ok(0.0);
120        }
121        let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
122        // corpus-level brevity penalty: total prediction length vs total reference length
123        let bp = if pred_len > ref_len {
124            1.0
125        } else {
126            (1.0 - ref_len as f64 / pred_len as f64).exp()
127        };
128        Ok((bp * geo_mean.exp()).clamp(0.0, 1.0))
129    }
130}
131
132/// Tokenizes: splits on whitespace and lowercases by default; with char_level splits per char (for Chinese).
133fn tokenize(s: &str, char_level: bool) -> Vec<String> {
134    if char_level {
135        s.chars()
136            .filter(|c| !c.is_whitespace())
137            .map(|c| c.to_lowercase().collect::<String>())
138            .collect()
139    } else {
140        s.split_whitespace().map(|w| w.to_lowercase()).collect()
141    }
142}
143
144fn ngrams(tokens: &[String], n: usize) -> HashMap<Vec<String>, usize> {
145    let mut m = HashMap::new();
146    if tokens.len() < n {
147        return m;
148    }
149    for i in 0..=tokens.len() - n {
150        let g: Vec<String> = tokens[i..i + n].to_vec();
151        *m.entry(g).or_insert(0) += 1;
152    }
153    m
154}
155
156#[async_trait]
157impl Evaluator for Bleu {
158    async fn eval(
159        &self,
160        _input: &str,
161        prediction: &str,
162        reference: &str,
163    ) -> Result<Score, EvalError> {
164        let pred = tokenize(prediction, self.char_level);
165        let ref_t = tokenize(reference, self.char_level);
166        let plen = pred.len();
167        let rlen = ref_t.len();
168        if plen == 0 || rlen == 0 {
169            return Ok(Score::new(0.0).with_label("empty"));
170        }
171
172        let mut log_precisions: Vec<f64> = Vec::new();
173        for n in 1..=self.max_n {
174            let pred_grams = ngrams(&pred, n);
175            let ref_grams = ngrams(&ref_t, n);
176            let mut matches = 0usize;
177            let mut total = 0usize;
178            for (g, &c) in &pred_grams {
179                total += c;
180                let r = ref_grams.get(g).copied().unwrap_or(0);
181                matches += c.min(r);
182            }
183            if total == 0 {
184                // no n-gram at this order (prediction too short): with smoothing skip (no penalty), otherwise zero
185                if self.smoothing {
186                    continue;
187                }
188                return Ok(Score::new(0.0).with_label("no_ngram_match"));
189            }
190            let p = if matches == 0 {
191                if self.smoothing {
192                    // smoothing: 0 matches get a small value, avoiding log(0) zeroing the whole result
193                    0.5 / total as f64
194                } else {
195                    return Ok(Score::new(0.0).with_label("no_ngram_match"));
196                }
197            } else {
198                matches as f64 / total as f64
199            };
200            log_precisions.push(p.ln());
201        }
202
203        let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
204        // brevity penalty: penalize when the prediction is shorter than the reference
205        let bp = if plen > rlen {
206            1.0
207        } else {
208            (1.0 - rlen as f64 / plen as f64).exp()
209        };
210        let bleu = bp * geo_mean.exp();
211        Ok(Score::new(bleu.clamp(0.0, 1.0)).with_label("bleu"))
212    }
213
214    fn name(&self) -> &str {
215        "bleu"
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[tokio::test]
224    async fn test_bleu_identical() {
225        let ev = Bleu::new();
226        let s = ev
227            .eval("", "the cat sat on the mat", "the cat sat on the mat")
228            .await
229            .unwrap();
230        assert!((s.value - 1.0).abs() < 1e-9);
231    }
232
233    #[tokio::test]
234    async fn test_bleu_partial() {
235        let ev = Bleu::new();
236        let s = ev
237            .eval("", "the cat sat on the mat", "the cat sat on a mat")
238            .await
239            .unwrap();
240        assert!(s.value > 0.0 && s.value < 1.0);
241    }
242
243    #[tokio::test]
244    async fn test_bleu_no_match() {
245        let ev = Bleu::new();
246        let s = ev
247            .eval(
248                "",
249                "completely different words here",
250                "the cat sat on the mat",
251            )
252            .await
253            .unwrap();
254        assert!((s.value - 0.0).abs() < 1e-9);
255    }
256
257    #[tokio::test]
258    async fn test_bleu_empty() {
259        let ev = Bleu::new();
260        let s = ev.eval("", "", "ref").await.unwrap();
261        assert!((s.value - 0.0).abs() < 1e-9);
262    }
263
264    #[tokio::test]
265    async fn test_bleu_brevity_penalty() {
266        // prediction shorter than reference: even with all words matching, bleu is penalized below 1
267        let ev = Bleu::new().with_max_n(1);
268        let s = ev
269            .eval("", "the cat", "the cat sat on the mat")
270            .await
271            .unwrap();
272        assert!(s.value < 1.0);
273    }
274
275    #[tokio::test]
276    async fn test_bleu_char_level_chinese() {
277        // Chinese has no spaces; default tokenization makes the whole sentence one token; char-level is required for n-grams
278        let ev = Bleu::new().with_char_level(true).with_max_n(2);
279        let s = ev.eval("", "猫坐在垫子上", "猫坐在垫子上").await.unwrap();
280        assert!((s.value - 1.0).abs() < 1e-9);
281    }
282
283    #[tokio::test]
284    async fn test_bleu_smoothing_avoids_zero() {
285        // a short sentence (word count < 4) zeroes out under default BLEU-4 due to missing high-order n-grams
286        let strict = Bleu::new();
287        let s = strict.eval("", "the cat", "the cat").await.unwrap();
288        assert!((s.value - 0.0).abs() < 1e-9);
289        // with smoothing enabled it is no longer zero
290        let smooth = Bleu::new().with_smoothing(true);
291        let s2 = smooth.eval("", "the cat", "the cat").await.unwrap();
292        assert!(s2.value > 0.0);
293    }
294
295    /// P2-1: corpus-level BLEU, identical corpora = 1.0.
296    #[test]
297    fn test_corpus_bleu_identical() {
298        let ev = Bleu::new();
299        let v = ev
300            .corpus_bleu(
301                &["the cat", "the dog sat on the mat"],
302                &["the cat", "the dog sat on the mat"],
303            )
304            .unwrap();
305        assert!((v - 1.0).abs() < 1e-9);
306    }
307
308    /// P2-1: under corpus aggregation, the short sentence "the cat" no longer zeroes the whole result for missing 4-grams.
309    #[tokio::test]
310    async fn test_corpus_bleu_short_sentence_aggregated() {
311        // sentence-level strict BLEU-4: "the cat" has no 4-gram, hard zero
312        let strict = Bleu::new();
313        let s = strict.eval("", "the cat", "the cat").await.unwrap();
314        assert!((s.value - 0.0).abs() < 1e-9);
315        // corpus-level: the short sentence's matches contribute low-order precision, so the whole is no longer zero
316        let v = strict
317            .corpus_bleu(
318                &["the cat", "the dog sat on the mat"],
319                &["the cat", "the dog sat on the mat"],
320            )
321            .unwrap();
322        assert!((v - 1.0).abs() < 1e-9);
323    }
324
325    /// P2-1: when an order has no match across the whole corpus, strict zeroes out; smoothing gives a small value instead of a whole zero.
326    #[test]
327    fn test_corpus_bleu_smoothing() {
328        let preds = &["the cat", "completely different"];
329        let refs = &["the cat", "the dog"];
330        let strict = Bleu::new();
331        let v0 = strict.corpus_bleu(preds, refs).unwrap();
332        assert!((v0 - 0.0).abs() < 1e-9, "strict 应为 0,实际 {v0}");
333        let smooth = Bleu::new().with_smoothing(true);
334        let v1 = smooth.corpus_bleu(preds, refs).unwrap();
335        assert!((v1 - 0.5).abs() < 1e-9, "平滑后应为 0.5,实际 {v1}");
336    }
337
338    /// P2-1: an empty corpus returns 0.0, no panic.
339    #[test]
340    fn test_corpus_bleu_empty() {
341        let v = Bleu::new().corpus_bleu(&[], &[]).unwrap();
342        assert!((v - 0.0).abs() < 1e-9);
343    }
344
345    /// S6: mismatched prediction/reference counts return LengthMismatch instead of panicking.
346    #[test]
347    fn test_corpus_bleu_length_mismatch_returns_err() {
348        let ev = Bleu::new();
349        let err = ev.corpus_bleu(&["a", "b"], &["a"]).unwrap_err();
350        assert!(matches!(
351            err,
352            EvalError::LengthMismatch {
353                predictions: 2,
354                references: 1
355            }
356        ));
357    }
358}