pictor_eval/meteor.rs
1//! METEOR (lexical subset) — exact-match only.
2//!
3//! This is Denkowski & Lavie (2014)'s METEOR, restricted to exact word
4//! matching: **no stemming, no synonymy (WordNet), no paraphrase tables**.
5//! This limits absolute scores relative to a full METEOR implementation but
6//! preserves the core algorithmic shape:
7//!
8//! 1. Align candidate tokens to reference tokens (exact match, greedy).
9//! 2. Compute precision `P` and recall `R` over matched tokens.
10//! 3. Combine via harmonic-mean with α=0.9 default:
11//! `F = P·R / (α·P + (1-α)·R)`
12//! (α→1 weights recall, α→0 weights precision).
13//! 4. Apply fragmentation penalty on the number of *chunks* of consecutive
14//! matches in the candidate that align to consecutive reference positions:
15//! `pen = γ · (chunks / matches)^β` (γ=0.5, β=3 by default).
16//! 5. Final score: `score = (1 - pen) · F`.
17//!
18//! Multi-reference: we compute METEOR against each reference and take the
19//! maximum (standard protocol).
20//!
21//! The limitation is documented here for transparency; if stemming/synonymy
22//! is needed in the future, it can be layered on top of [`align_tokens`].
23
24use crate::rouge::{tokenize, TokenSeq};
25
26/// METEOR score breakdown.
27#[derive(Debug, Clone)]
28pub struct MeteorScore {
29 /// Final METEOR in `[0, 1]`.
30 pub score: f32,
31 /// Matcher precision `matches / |candidate|`.
32 pub precision: f32,
33 /// Matcher recall `matches / |reference|`.
34 pub recall: f32,
35 /// Fragmentation factor in `[0, γ]` (higher = more fragmented).
36 pub fragmentation: f32,
37}
38
39/// METEOR configuration.
40#[derive(Debug, Clone)]
41pub struct MeteorConfig {
42 /// Weight for precision vs recall in the F-mean. Standard α = 0.9.
43 pub alpha: f32,
44 /// Penalty weight for fragmentation (γ, default 0.5).
45 pub gamma: f32,
46 /// Fragmentation exponent (β, default 3.0).
47 pub beta: f32,
48}
49
50impl Default for MeteorConfig {
51 fn default() -> Self {
52 Self {
53 alpha: 0.9,
54 gamma: 0.5,
55 beta: 3.0,
56 }
57 }
58}
59
60/// Compute METEOR between a candidate and a single reference.
61pub fn meteor(candidate: &str, reference: &str, cfg: &MeteorConfig) -> MeteorScore {
62 let cand = tokenize(candidate);
63 let refs = tokenize(reference);
64 meteor_tokens(&cand, &refs, cfg)
65}
66
67/// Compute METEOR from pre-tokenised sequences.
68pub fn meteor_tokens(
69 candidate: &TokenSeq,
70 reference: &TokenSeq,
71 cfg: &MeteorConfig,
72) -> MeteorScore {
73 if candidate.is_empty() && reference.is_empty() {
74 return MeteorScore {
75 score: 1.0,
76 precision: 1.0,
77 recall: 1.0,
78 fragmentation: 0.0,
79 };
80 }
81 if candidate.is_empty() || reference.is_empty() {
82 return MeteorScore {
83 score: 0.0,
84 precision: 0.0,
85 recall: 0.0,
86 fragmentation: 0.0,
87 };
88 }
89
90 // Align tokens: list of (cand_idx, ref_idx) pairs.
91 let alignment = align_tokens(candidate, reference);
92 let matches = alignment.len();
93
94 if matches == 0 {
95 return MeteorScore {
96 score: 0.0,
97 precision: 0.0,
98 recall: 0.0,
99 fragmentation: 0.0,
100 };
101 }
102
103 let p = matches as f32 / candidate.len() as f32;
104 let r = matches as f32 / reference.len() as f32;
105
106 let denom = cfg.alpha * p + (1.0 - cfg.alpha) * r;
107 let f_mean = if denom > 0.0 { (p * r) / denom } else { 0.0 };
108
109 // Count chunks in the alignment (consecutive in both candidate and reference).
110 let chunks = count_chunks(&alignment);
111 let frag = (chunks as f32) / (matches as f32);
112 let pen = cfg.gamma * frag.powf(cfg.beta);
113
114 let score = ((1.0 - pen) * f_mean).clamp(0.0, 1.0);
115 MeteorScore {
116 score,
117 precision: p,
118 recall: r,
119 fragmentation: pen,
120 }
121}
122
123/// Compute METEOR against multiple references; returns max.
124pub fn meteor_multi(candidate: &str, references: &[&str], cfg: &MeteorConfig) -> MeteorScore {
125 if references.is_empty() {
126 return MeteorScore {
127 score: 0.0,
128 precision: 0.0,
129 recall: 0.0,
130 fragmentation: 0.0,
131 };
132 }
133 let mut best: Option<MeteorScore> = None;
134 for r in references {
135 let s = meteor(candidate, r, cfg);
136 best = match best.take() {
137 None => Some(s),
138 Some(b) => {
139 if s.score > b.score {
140 Some(s)
141 } else {
142 Some(b)
143 }
144 }
145 };
146 }
147 best.unwrap_or(MeteorScore {
148 score: 0.0,
149 precision: 0.0,
150 recall: 0.0,
151 fragmentation: 0.0,
152 })
153}
154
155// ──────────────────────────────────────────────────────────────────────────────
156// Internal — alignment + chunks
157// ──────────────────────────────────────────────────────────────────────────────
158
159/// Greedy left-to-right alignment: for each candidate token (in order), match
160/// it to the first unclaimed reference position whose token equals it.
161///
162/// Returns `(cand_idx, ref_idx)` pairs in candidate order.
163pub fn align_tokens(candidate: &TokenSeq, reference: &TokenSeq) -> Vec<(usize, usize)> {
164 let mut used = vec![false; reference.len()];
165 let mut out: Vec<(usize, usize)> = Vec::new();
166
167 for (ci, ctok) in candidate.iter().enumerate() {
168 for (ri, rtok) in reference.iter().enumerate() {
169 if !used[ri] && ctok == rtok {
170 used[ri] = true;
171 out.push((ci, ri));
172 break;
173 }
174 }
175 }
176 out
177}
178
179/// Count the number of chunks — maximal runs where both candidate and
180/// reference indices are consecutive.
181///
182/// We sort the alignment by candidate index, then walk it: each pair
183/// continues the current chunk iff `ci == prev_ci + 1 && ri == prev_ri + 1`.
184fn count_chunks(alignment: &[(usize, usize)]) -> usize {
185 if alignment.is_empty() {
186 return 0;
187 }
188 let mut sorted = alignment.to_vec();
189 sorted.sort_by_key(|&(ci, _)| ci);
190
191 let mut chunks = 1usize;
192 for w in sorted.windows(2) {
193 let (pc, pr) = w[0];
194 let (nc, nr) = w[1];
195 if !(nc == pc + 1 && nr == pr + 1) {
196 chunks += 1;
197 }
198 }
199 chunks
200}