1use async_trait::async_trait;
7use std::collections::HashMap;
8
9use super::{EvalError, Evaluator, Score};
10
11pub struct Bleu {
13 max_n: usize,
14 char_level: bool,
16 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 {
29 Self {
30 max_n: 4,
31 char_level: false,
32 smoothing: false,
33 }
34 }
35
36 pub fn with_max_n(mut self, n: usize) -> Self {
38 self.max_n = n.max(1);
39 self
40 }
41
42 pub fn with_char_level(mut self, v: bool) -> Self {
44 self.char_level = v;
45 self
46 }
47
48 pub fn with_smoothing(mut self, v: bool) -> Self {
50 self.smoothing = v;
51 self
52 }
53
54 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 if self.smoothing {
103 continue;
104 }
105 return Ok(0.0);
106 } else if m == 0 {
107 if self.smoothing {
108 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 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
132fn 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 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 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 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 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 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 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 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 #[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 #[tokio::test]
310 async fn test_corpus_bleu_short_sentence_aggregated() {
311 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 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 #[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 #[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 #[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}