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]) -> 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 if self.smoothing {
101 continue;
102 }
103 return 0.0;
104 } else if m == 0 {
105 if self.smoothing {
106 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 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
130fn 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 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.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 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 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 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 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 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 #[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 #[tokio::test]
306 async fn test_corpus_bleu_short_sentence_aggregated() {
307 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 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 #[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 #[test]
334 fn test_corpus_bleu_empty() {
335 let v = Bleu::new().corpus_bleu(&[], &[]);
336 assert!((v - 0.0).abs() < 1e-9);
337 }
338}