Skip to main content

lc_evaluation/
bleu.rs

1//! BLEU 评测器:经典机器翻译/文本生成指标。
2//!
3//! n-gram 精率的几何平均 + 短句惩罚(brevity penalty)。
4//! 完全相同为 1.0,无任何 n-gram 匹配为 0.0。
5
6use async_trait::async_trait;
7use std::collections::HashMap;
8
9use super::{EvalError, Evaluator, Score};
10
11/// BLEU 评测器(默认 BLEU-4)。
12pub struct Bleu {
13    max_n: usize,
14    /// 字符级分词(中文等无空格语言用,每个字符一个 token)
15    char_level: bool,
16    /// 平滑:某阶 n-gram 无匹配时不直接归零,短句更友好
17    smoothing: bool,
18}
19
20impl Default for Bleu {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl Bleu {
27    /// 创建默认 BLEU-4 评测器。
28    pub fn new() -> Self {
29        Self {
30            max_n: 4,
31            char_level: false,
32            smoothing: false,
33        }
34    }
35
36    /// 使用 BLEU-n(默认 4)
37    pub fn with_max_n(mut self, n: usize) -> Self {
38        self.max_n = n.max(1);
39        self
40    }
41
42    /// 字符级分词:中文等无空格语言按字符切(否则整句成一个 token,BLEU 失效)
43    pub fn with_char_level(mut self, v: bool) -> Self {
44        self.char_level = v;
45        self
46    }
47
48    /// 开启平滑:某阶 n-gram 无匹配时给小值而非整体归零,短句不被一刀切
49    pub fn with_smoothing(mut self, v: bool) -> Self {
50        self.smoothing = v;
51        self
52    }
53
54    /// corpus 级 BLEU:跨多条样例聚合 n-gram 匹配计数,再整体算几何均值 +
55    /// corpus 级 brevity penalty。
56    ///
57    /// P2-1:句子级 BLEU 的 brevity penalty 会把短句一刀切(如 BLEU-4 下
58    /// "the cat" 直接归零);corpus 聚合用总长度算惩罚、各阶 n-gram 计数合并,
59    /// 短句的匹配仍能贡献低阶精度。开启 `with_smoothing` 后某阶无匹配给小值
60    /// 而非整体归零。
61    ///
62    /// `predictions` 与 `references` 长度必须一致(逐条对应),否则 panic。
63    pub fn corpus_bleu(&self, predictions: &[&str], references: &[&str]) -> f64 {
64        assert_eq!(
65            predictions.len(),
66            references.len(),
67            "predictions and references sample counts do not match"
68        );
69        if predictions.is_empty() {
70            return 0.0;
71        }
72        let mut total = vec![0usize; self.max_n];
73        let mut matches = vec![0usize; self.max_n];
74        let mut pred_len = 0usize;
75        let mut ref_len = 0usize;
76        for (pred, reference) in predictions.iter().zip(references) {
77            let pred_t = tokenize(pred, self.char_level);
78            let ref_t = tokenize(reference, self.char_level);
79            pred_len += pred_t.len();
80            ref_len += ref_t.len();
81            for n in 1..=self.max_n {
82                let pred_grams = ngrams(&pred_t, n);
83                let ref_grams = ngrams(&ref_t, n);
84                for (g, &c) in &pred_grams {
85                    total[n - 1] += c;
86                    let r = ref_grams.get(g).copied().unwrap_or(0);
87                    matches[n - 1] += c.min(r);
88                }
89            }
90        }
91        if pred_len == 0 {
92            return 0.0;
93        }
94        let mut log_precisions: Vec<f64> = Vec::new();
95        for n in 0..self.max_n {
96            let t = total[n];
97            let m = matches[n];
98            let p = if t == 0 {
99                // 该阶整条语料都没有 n-gram(所有预测都太短):平滑时跳过不惩罚,否则归零
100                if self.smoothing {
101                    continue;
102                }
103                return 0.0;
104            } else if m == 0 {
105                if self.smoothing {
106                    // 平滑:0 匹配给小值,避免 log(0) 把整体归零
107                    0.5 / t as f64
108                } else {
109                    return 0.0;
110                }
111            } else {
112                m as f64 / t as f64
113            };
114            log_precisions.push(p.ln());
115        }
116        if log_precisions.is_empty() {
117            return 0.0;
118        }
119        let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
120        // corpus 级 brevity penalty:总预测长度 vs 总参考长度
121        let bp = if pred_len > ref_len {
122            1.0
123        } else {
124            (1.0 - ref_len as f64 / pred_len as f64).exp()
125        };
126        (bp * geo_mean.exp()).clamp(0.0, 1.0)
127    }
128}
129
130/// 分词:默认按空白切分并小写化;char_level 时按字符切(中文用)。
131fn tokenize(s: &str, char_level: bool) -> Vec<String> {
132    if char_level {
133        s.chars()
134            .filter(|c| !c.is_whitespace())
135            .map(|c| c.to_lowercase().collect::<String>())
136            .collect()
137    } else {
138        s.split_whitespace().map(|w| w.to_lowercase()).collect()
139    }
140}
141
142fn ngrams(tokens: &[String], n: usize) -> HashMap<Vec<String>, usize> {
143    let mut m = HashMap::new();
144    if tokens.len() < n {
145        return m;
146    }
147    for i in 0..=tokens.len() - n {
148        let g: Vec<String> = tokens[i..i + n].to_vec();
149        *m.entry(g).or_insert(0) += 1;
150    }
151    m
152}
153
154#[async_trait]
155impl Evaluator for Bleu {
156    async fn eval(
157        &self,
158        _input: &str,
159        prediction: &str,
160        reference: &str,
161    ) -> Result<Score, EvalError> {
162        let pred = tokenize(prediction, self.char_level);
163        let ref_t = tokenize(reference, self.char_level);
164        let plen = pred.len();
165        let rlen = ref_t.len();
166        if plen == 0 || rlen == 0 {
167            return Ok(Score::new(0.0).with_label("empty"));
168        }
169
170        let mut log_precisions: Vec<f64> = Vec::new();
171        for n in 1..=self.max_n {
172            let pred_grams = ngrams(&pred, n);
173            let ref_grams = ngrams(&ref_t, n);
174            let mut matches = 0usize;
175            let mut total = 0usize;
176            for (g, &c) in &pred_grams {
177                total += c;
178                let r = ref_grams.get(g).copied().unwrap_or(0);
179                matches += c.min(r);
180            }
181            if total == 0 {
182                // 该阶无 n-gram(预测太短没产生):平滑时跳过不惩罚,否则归零
183                if self.smoothing {
184                    continue;
185                }
186                return Ok(Score::new(0.0).with_label("no_ngram_match"));
187            }
188            let p = if matches == 0 {
189                if self.smoothing {
190                    // 平滑:0 匹配给小值,避免 log(0) 把整体归零
191                    0.5 / total as f64
192                } else {
193                    return Ok(Score::new(0.0).with_label("no_ngram_match"));
194                }
195            } else {
196                matches as f64 / total as f64
197            };
198            log_precisions.push(p.ln());
199        }
200
201        let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
202        // brevity penalty:预测比参考短则惩罚
203        let bp = if plen > rlen {
204            1.0
205        } else {
206            (1.0 - rlen as f64 / plen as f64).exp()
207        };
208        let bleu = bp * geo_mean.exp();
209        Ok(Score::new(bleu.clamp(0.0, 1.0)).with_label("bleu"))
210    }
211
212    fn name(&self) -> &str {
213        "bleu"
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[tokio::test]
222    async fn test_bleu_identical() {
223        let ev = Bleu::new();
224        let s = ev
225            .eval("", "the cat sat on the mat", "the cat sat on the mat")
226            .await
227            .unwrap();
228        assert!((s.value - 1.0).abs() < 1e-9);
229    }
230
231    #[tokio::test]
232    async fn test_bleu_partial() {
233        let ev = Bleu::new();
234        let s = ev
235            .eval("", "the cat sat on the mat", "the cat sat on a mat")
236            .await
237            .unwrap();
238        assert!(s.value > 0.0 && s.value < 1.0);
239    }
240
241    #[tokio::test]
242    async fn test_bleu_no_match() {
243        let ev = Bleu::new();
244        let s = ev
245            .eval(
246                "",
247                "completely different words here",
248                "the cat sat on the mat",
249            )
250            .await
251            .unwrap();
252        assert!((s.value - 0.0).abs() < 1e-9);
253    }
254
255    #[tokio::test]
256    async fn test_bleu_empty() {
257        let ev = Bleu::new();
258        let s = ev.eval("", "", "ref").await.unwrap();
259        assert!((s.value - 0.0).abs() < 1e-9);
260    }
261
262    #[tokio::test]
263    async fn test_bleu_brevity_penalty() {
264        // 预测比参考短,即使词都匹配,bleu 也被惩罚 < 1
265        let ev = Bleu::new().with_max_n(1);
266        let s = ev
267            .eval("", "the cat", "the cat sat on the mat")
268            .await
269            .unwrap();
270        assert!(s.value < 1.0);
271    }
272
273    #[tokio::test]
274    async fn test_bleu_char_level_chinese() {
275        // 中文无空格,默认分词整句成一个 token;字符级才能算 n-gram
276        let ev = Bleu::new().with_char_level(true).with_max_n(2);
277        let s = ev.eval("", "猫坐在垫子上", "猫坐在垫子上").await.unwrap();
278        assert!((s.value - 1.0).abs() < 1e-9);
279    }
280
281    #[tokio::test]
282    async fn test_bleu_smoothing_avoids_zero() {
283        // 短句(词数 < 4)默认 BLEU-4 因高阶 n-gram 缺失归零
284        let strict = Bleu::new();
285        let s = strict.eval("", "the cat", "the cat").await.unwrap();
286        assert!((s.value - 0.0).abs() < 1e-9);
287        // 开启平滑后不为零
288        let smooth = Bleu::new().with_smoothing(true);
289        let s2 = smooth.eval("", "the cat", "the cat").await.unwrap();
290        assert!(s2.value > 0.0);
291    }
292
293    /// P2-1: corpus 级 BLEU,完全相同语料 = 1.0。
294    #[test]
295    fn test_corpus_bleu_identical() {
296        let ev = Bleu::new();
297        let v = ev.corpus_bleu(
298            &["the cat", "the dog sat on the mat"],
299            &["the cat", "the dog sat on the mat"],
300        );
301        assert!((v - 1.0).abs() < 1e-9);
302    }
303
304    /// P2-1: corpus 聚合下,短句"the cat"不再因缺 4-gram 把整体归零。
305    #[tokio::test]
306    async fn test_corpus_bleu_short_sentence_aggregated() {
307        // 句子级 strict BLEU-4:"the cat" 无 4-gram,直接归零
308        let strict = Bleu::new();
309        let s = strict.eval("", "the cat", "the cat").await.unwrap();
310        assert!((s.value - 0.0).abs() < 1e-9);
311        // corpus 级:短句的匹配贡献低阶精度,整体不再为零
312        let v = strict.corpus_bleu(
313            &["the cat", "the dog sat on the mat"],
314            &["the cat", "the dog sat on the mat"],
315        );
316        assert!((v - 1.0).abs() < 1e-9);
317    }
318
319    /// P2-1: 某阶全语料无匹配时,strict 归零;平滑给小值而非整体归零。
320    #[test]
321    fn test_corpus_bleu_smoothing() {
322        let preds = &["the cat", "completely different"];
323        let refs = &["the cat", "the dog"];
324        let strict = Bleu::new();
325        let v0 = strict.corpus_bleu(preds, refs);
326        assert!((v0 - 0.0).abs() < 1e-9, "strict 应为 0,实际 {v0}");
327        let smooth = Bleu::new().with_smoothing(true);
328        let v1 = smooth.corpus_bleu(preds, refs);
329        assert!((v1 - 0.5).abs() < 1e-9, "平滑后应为 0.5,实际 {v1}");
330    }
331
332    /// P2-1: 空语料返回 0.0,不 panic。
333    #[test]
334    fn test_corpus_bleu_empty() {
335        let v = Bleu::new().corpus_bleu(&[], &[]);
336        assert!((v - 0.0).abs() < 1e-9);
337    }
338}