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    pub fn new() -> Self {
28        Self {
29            max_n: 4,
30            char_level: false,
31            smoothing: false,
32        }
33    }
34
35    /// 使用 BLEU-n(默认 4)
36    pub fn with_max_n(mut self, n: usize) -> Self {
37        self.max_n = n.max(1);
38        self
39    }
40
41    /// 字符级分词:中文等无空格语言按字符切(否则整句成一个 token,BLEU 失效)
42    pub fn with_char_level(mut self, v: bool) -> Self {
43        self.char_level = v;
44        self
45    }
46
47    /// 开启平滑:某阶 n-gram 无匹配时给小值而非整体归零,短句不被一刀切
48    pub fn with_smoothing(mut self, v: bool) -> Self {
49        self.smoothing = v;
50        self
51    }
52}
53
54/// 分词:默认按空白切分并小写化;char_level 时按字符切(中文用)。
55fn tokenize(s: &str, char_level: bool) -> Vec<String> {
56    if char_level {
57        s.chars()
58            .filter(|c| !c.is_whitespace())
59            .map(|c| c.to_lowercase().collect::<String>())
60            .collect()
61    } else {
62        s.split_whitespace().map(|w| w.to_lowercase()).collect()
63    }
64}
65
66fn ngrams(tokens: &[String], n: usize) -> HashMap<Vec<String>, usize> {
67    let mut m = HashMap::new();
68    if tokens.len() < n {
69        return m;
70    }
71    for i in 0..=tokens.len() - n {
72        let g: Vec<String> = tokens[i..i + n].to_vec();
73        *m.entry(g).or_insert(0) += 1;
74    }
75    m
76}
77
78#[async_trait]
79impl Evaluator for Bleu {
80    async fn eval(
81        &self,
82        _input: &str,
83        prediction: &str,
84        reference: &str,
85    ) -> Result<Score, EvalError> {
86        let pred = tokenize(prediction, self.char_level);
87        let ref_t = tokenize(reference, self.char_level);
88        let plen = pred.len();
89        let rlen = ref_t.len();
90        if plen == 0 || rlen == 0 {
91            return Ok(Score::new(0.0).with_label("empty"));
92        }
93
94        let mut log_precisions: Vec<f64> = Vec::new();
95        for n in 1..=self.max_n {
96            let pred_grams = ngrams(&pred, n);
97            let ref_grams = ngrams(&ref_t, n);
98            let mut matches = 0usize;
99            let mut total = 0usize;
100            for (g, &c) in &pred_grams {
101                total += c;
102                let r = ref_grams.get(g).copied().unwrap_or(0);
103                matches += c.min(r);
104            }
105            if total == 0 {
106                // 该阶无 n-gram(预测太短没产生):平滑时跳过不惩罚,否则归零
107                if self.smoothing {
108                    continue;
109                }
110                return Ok(Score::new(0.0).with_label("no_ngram_match"));
111            }
112            let p = if matches == 0 {
113                if self.smoothing {
114                    // 平滑:0 匹配给小值,避免 log(0) 把整体归零
115                    0.5 / total as f64
116                } else {
117                    return Ok(Score::new(0.0).with_label("no_ngram_match"));
118                }
119            } else {
120                matches as f64 / total as f64
121            };
122            log_precisions.push(p.ln());
123        }
124
125        let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
126        // brevity penalty:预测比参考短则惩罚
127        let bp = if plen > rlen {
128            1.0
129        } else {
130            (1.0 - rlen as f64 / plen as f64).exp()
131        };
132        let bleu = bp * geo_mean.exp();
133        Ok(Score::new(bleu.clamp(0.0, 1.0)).with_label("bleu"))
134    }
135
136    fn name(&self) -> &str {
137        "bleu"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[tokio::test]
146    async fn test_bleu_identical() {
147        let ev = Bleu::new();
148        let s = ev
149            .eval("", "the cat sat on the mat", "the cat sat on the mat")
150            .await
151            .unwrap();
152        assert!((s.value - 1.0).abs() < 1e-9);
153    }
154
155    #[tokio::test]
156    async fn test_bleu_partial() {
157        let ev = Bleu::new();
158        let s = ev
159            .eval("", "the cat sat on the mat", "the cat sat on a mat")
160            .await
161            .unwrap();
162        assert!(s.value > 0.0 && s.value < 1.0);
163    }
164
165    #[tokio::test]
166    async fn test_bleu_no_match() {
167        let ev = Bleu::new();
168        let s = ev
169            .eval(
170                "",
171                "completely different words here",
172                "the cat sat on the mat",
173            )
174            .await
175            .unwrap();
176        assert!((s.value - 0.0).abs() < 1e-9);
177    }
178
179    #[tokio::test]
180    async fn test_bleu_empty() {
181        let ev = Bleu::new();
182        let s = ev.eval("", "", "ref").await.unwrap();
183        assert!((s.value - 0.0).abs() < 1e-9);
184    }
185
186    #[tokio::test]
187    async fn test_bleu_brevity_penalty() {
188        // 预测比参考短,即使词都匹配,bleu 也被惩罚 < 1
189        let ev = Bleu::new().with_max_n(1);
190        let s = ev
191            .eval("", "the cat", "the cat sat on the mat")
192            .await
193            .unwrap();
194        assert!(s.value < 1.0);
195    }
196
197    #[tokio::test]
198    async fn test_bleu_char_level_chinese() {
199        // 中文无空格,默认分词整句成一个 token;字符级才能算 n-gram
200        let ev = Bleu::new().with_char_level(true).with_max_n(2);
201        let s = ev.eval("", "猫坐在垫子上", "猫坐在垫子上").await.unwrap();
202        assert!((s.value - 1.0).abs() < 1e-9);
203    }
204
205    #[tokio::test]
206    async fn test_bleu_smoothing_avoids_zero() {
207        // 短句(词数 < 4)默认 BLEU-4 因高阶 n-gram 缺失归零
208        let strict = Bleu::new();
209        let s = strict.eval("", "the cat", "the cat").await.unwrap();
210        assert!((s.value - 0.0).abs() < 1e-9);
211        // 开启平滑后不为零
212        let smooth = Bleu::new().with_smoothing(true);
213        let s2 = smooth.eval("", "the cat", "the cat").await.unwrap();
214        assert!(s2.value > 0.0);
215    }
216}