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 {
28 Self {
29 max_n: 4,
30 char_level: false,
31 smoothing: false,
32 }
33 }
34
35 pub fn with_max_n(mut self, n: usize) -> Self {
37 self.max_n = n.max(1);
38 self
39 }
40
41 pub fn with_char_level(mut self, v: bool) -> Self {
43 self.char_level = v;
44 self
45 }
46
47 pub fn with_smoothing(mut self, v: bool) -> Self {
49 self.smoothing = v;
50 self
51 }
52
53 pub fn corpus_bleu(&self, predictions: &[&str], references: &[&str]) -> f64 {
63 assert_eq!(
64 predictions.len(),
65 references.len(),
66 "predictions 与 references 样例数不一致"
67 );
68 if predictions.is_empty() {
69 return 0.0;
70 }
71 let mut total = vec![0usize; self.max_n];
72 let mut matches = vec![0usize; self.max_n];
73 let mut pred_len = 0usize;
74 let mut ref_len = 0usize;
75 for (pred, reference) in predictions.iter().zip(references) {
76 let pred_t = tokenize(pred, self.char_level);
77 let ref_t = tokenize(reference, self.char_level);
78 pred_len += pred_t.len();
79 ref_len += ref_t.len();
80 for n in 1..=self.max_n {
81 let pred_grams = ngrams(&pred_t, n);
82 let ref_grams = ngrams(&ref_t, n);
83 for (g, &c) in &pred_grams {
84 total[n - 1] += c;
85 let r = ref_grams.get(g).copied().unwrap_or(0);
86 matches[n - 1] += c.min(r);
87 }
88 }
89 }
90 if pred_len == 0 {
91 return 0.0;
92 }
93 let mut log_precisions: Vec<f64> = Vec::new();
94 for n in 0..self.max_n {
95 let t = total[n];
96 let m = matches[n];
97 let p = if t == 0 {
98 if self.smoothing {
100 continue;
101 }
102 return 0.0;
103 } else if m == 0 {
104 if self.smoothing {
105 0.5 / t as f64
107 } else {
108 return 0.0;
109 }
110 } else {
111 m as f64 / t as f64
112 };
113 log_precisions.push(p.ln());
114 }
115 if log_precisions.is_empty() {
116 return 0.0;
117 }
118 let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
119 let bp = if pred_len > ref_len {
121 1.0
122 } else {
123 (1.0 - ref_len as f64 / pred_len as f64).exp()
124 };
125 (bp * geo_mean.exp()).clamp(0.0, 1.0)
126 }
127}
128
129fn tokenize(s: &str, char_level: bool) -> Vec<String> {
131 if char_level {
132 s.chars()
133 .filter(|c| !c.is_whitespace())
134 .map(|c| c.to_lowercase().collect::<String>())
135 .collect()
136 } else {
137 s.split_whitespace().map(|w| w.to_lowercase()).collect()
138 }
139}
140
141fn ngrams(tokens: &[String], n: usize) -> HashMap<Vec<String>, usize> {
142 let mut m = HashMap::new();
143 if tokens.len() < n {
144 return m;
145 }
146 for i in 0..=tokens.len() - n {
147 let g: Vec<String> = tokens[i..i + n].to_vec();
148 *m.entry(g).or_insert(0) += 1;
149 }
150 m
151}
152
153#[async_trait]
154impl Evaluator for Bleu {
155 async fn eval(
156 &self,
157 _input: &str,
158 prediction: &str,
159 reference: &str,
160 ) -> Result<Score, EvalError> {
161 let pred = tokenize(prediction, self.char_level);
162 let ref_t = tokenize(reference, self.char_level);
163 let plen = pred.len();
164 let rlen = ref_t.len();
165 if plen == 0 || rlen == 0 {
166 return Ok(Score::new(0.0).with_label("empty"));
167 }
168
169 let mut log_precisions: Vec<f64> = Vec::new();
170 for n in 1..=self.max_n {
171 let pred_grams = ngrams(&pred, n);
172 let ref_grams = ngrams(&ref_t, n);
173 let mut matches = 0usize;
174 let mut total = 0usize;
175 for (g, &c) in &pred_grams {
176 total += c;
177 let r = ref_grams.get(g).copied().unwrap_or(0);
178 matches += c.min(r);
179 }
180 if total == 0 {
181 if self.smoothing {
183 continue;
184 }
185 return Ok(Score::new(0.0).with_label("no_ngram_match"));
186 }
187 let p = if matches == 0 {
188 if self.smoothing {
189 0.5 / total as f64
191 } else {
192 return Ok(Score::new(0.0).with_label("no_ngram_match"));
193 }
194 } else {
195 matches as f64 / total as f64
196 };
197 log_precisions.push(p.ln());
198 }
199
200 let geo_mean = log_precisions.iter().sum::<f64>() / log_precisions.len() as f64;
201 let bp = if plen > rlen {
203 1.0
204 } else {
205 (1.0 - rlen as f64 / plen as f64).exp()
206 };
207 let bleu = bp * geo_mean.exp();
208 Ok(Score::new(bleu.clamp(0.0, 1.0)).with_label("bleu"))
209 }
210
211 fn name(&self) -> &str {
212 "bleu"
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[tokio::test]
221 async fn test_bleu_identical() {
222 let ev = Bleu::new();
223 let s = ev
224 .eval("", "the cat sat on the mat", "the cat sat on the mat")
225 .await
226 .unwrap();
227 assert!((s.value - 1.0).abs() < 1e-9);
228 }
229
230 #[tokio::test]
231 async fn test_bleu_partial() {
232 let ev = Bleu::new();
233 let s = ev
234 .eval("", "the cat sat on the mat", "the cat sat on a mat")
235 .await
236 .unwrap();
237 assert!(s.value > 0.0 && s.value < 1.0);
238 }
239
240 #[tokio::test]
241 async fn test_bleu_no_match() {
242 let ev = Bleu::new();
243 let s = ev
244 .eval(
245 "",
246 "completely different words here",
247 "the cat sat on the mat",
248 )
249 .await
250 .unwrap();
251 assert!((s.value - 0.0).abs() < 1e-9);
252 }
253
254 #[tokio::test]
255 async fn test_bleu_empty() {
256 let ev = Bleu::new();
257 let s = ev.eval("", "", "ref").await.unwrap();
258 assert!((s.value - 0.0).abs() < 1e-9);
259 }
260
261 #[tokio::test]
262 async fn test_bleu_brevity_penalty() {
263 let ev = Bleu::new().with_max_n(1);
265 let s = ev
266 .eval("", "the cat", "the cat sat on the mat")
267 .await
268 .unwrap();
269 assert!(s.value < 1.0);
270 }
271
272 #[tokio::test]
273 async fn test_bleu_char_level_chinese() {
274 let ev = Bleu::new().with_char_level(true).with_max_n(2);
276 let s = ev.eval("", "猫坐在垫子上", "猫坐在垫子上").await.unwrap();
277 assert!((s.value - 1.0).abs() < 1e-9);
278 }
279
280 #[tokio::test]
281 async fn test_bleu_smoothing_avoids_zero() {
282 let strict = Bleu::new();
284 let s = strict.eval("", "the cat", "the cat").await.unwrap();
285 assert!((s.value - 0.0).abs() < 1e-9);
286 let smooth = Bleu::new().with_smoothing(true);
288 let s2 = smooth.eval("", "the cat", "the cat").await.unwrap();
289 assert!(s2.value > 0.0);
290 }
291
292 #[test]
294 fn test_corpus_bleu_identical() {
295 let ev = Bleu::new();
296 let v = ev.corpus_bleu(
297 &["the cat", "the dog sat on the mat"],
298 &["the cat", "the dog sat on the mat"],
299 );
300 assert!((v - 1.0).abs() < 1e-9);
301 }
302
303 #[tokio::test]
305 async fn test_corpus_bleu_short_sentence_aggregated() {
306 let strict = Bleu::new();
308 let s = strict.eval("", "the cat", "the cat").await.unwrap();
309 assert!((s.value - 0.0).abs() < 1e-9);
310 let v = strict.corpus_bleu(
312 &["the cat", "the dog sat on the mat"],
313 &["the cat", "the dog sat on the mat"],
314 );
315 assert!((v - 1.0).abs() < 1e-9);
316 }
317
318 #[test]
320 fn test_corpus_bleu_smoothing() {
321 let preds = &["the cat", "completely different"];
322 let refs = &["the cat", "the dog"];
323 let strict = Bleu::new();
324 let v0 = strict.corpus_bleu(preds, refs);
325 assert!((v0 - 0.0).abs() < 1e-9, "strict 应为 0,实际 {v0}");
326 let smooth = Bleu::new().with_smoothing(true);
327 let v1 = smooth.corpus_bleu(preds, refs);
328 assert!((v1 - 0.5).abs() < 1e-9, "平滑后应为 0.5,实际 {v1}");
329 }
330
331 #[test]
333 fn test_corpus_bleu_empty() {
334 let v = Bleu::new().corpus_bleu(&[], &[]);
335 assert!((v - 0.0).abs() < 1e-9);
336 }
337}