Skip to content

Commit bb6d2da

Browse files
committed
better evaluation
1 parent bd8585d commit bb6d2da

1 file changed

Lines changed: 159 additions & 45 deletions

File tree

squeez/training/evaluate.py

Lines changed: 159 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,126 @@
1-
"""Evaluation script for tool output extraction model.
1+
"""Evaluation script for squeez tool output extraction model.
22
33
Metrics:
4-
- Line-level Precision/Recall/F1 (vs ground truth relevant lines)
5-
- ROUGE-L between generated and reference filtered output
6-
- Compression ratio (output tokens / input tokens)
4+
- Span Exact Match: fraction of samples where predicted lines == reference lines exactly
5+
- Span Precision/Recall/F1: line-level set overlap between predicted and reference
6+
- Empty Accuracy: correctly predicting empty vs non-empty relevant_lines
7+
- ROUGE-L: token-level overlap between concatenated predicted and reference lines
8+
- Compression ratio: output lines / input lines
79
"""
810

911
import argparse
1012
import json
1113
import logging
12-
import re
1314
import statistics
1415

1516
logger = logging.getLogger(__name__)
1617

1718

18-
def extract_line_numbers(text: str) -> set[int]:
19-
"""Extract line numbers from filtered output."""
20-
line_nums = set()
21-
for line in text.split("\n"):
22-
match = re.match(r"^(\d+):", line)
23-
if match:
24-
line_nums.add(int(match.group(1)))
25-
return line_nums
19+
def _parse_relevant_lines(text: str) -> list[str]:
20+
"""Parse relevant_lines from model output (JSON or raw text).
21+
22+
Handles:
23+
- Valid JSON: {"relevant_lines": ["line1", "line2"]}
24+
- Raw text fallback: split by newlines
25+
"""
26+
text = text.strip()
27+
try:
28+
data = json.loads(text)
29+
lines = data.get("relevant_lines", [])
30+
if isinstance(lines, list):
31+
return [str(line).strip() for line in lines if str(line).strip()]
32+
except (json.JSONDecodeError, TypeError, AttributeError):
33+
pass
34+
35+
# Fallback: treat each non-empty line as a span
36+
return [line.strip() for line in text.split("\n") if line.strip()]
2637

2738

28-
def compute_line_level_metrics(predicted: str, reference: str) -> dict[str, float]:
29-
"""Compute line-level precision, recall, and F1."""
30-
pred_lines = extract_line_numbers(predicted)
31-
ref_lines = extract_line_numbers(reference)
39+
def compute_span_metrics(predicted: list[str], reference: list[str]) -> dict[str, float]:
40+
"""Compute span-level precision, recall, F1 using set overlap on normalized lines."""
41+
pred_set = set(predicted)
42+
ref_set = set(reference)
3243

33-
if not ref_lines:
34-
return {"precision": 0.0, "recall": 0.0, "f1": 0.0}
44+
if not ref_set and not pred_set:
45+
return {"precision": 1.0, "recall": 1.0, "f1": 1.0, "exact_match": 1.0}
3546

36-
tp = len(pred_lines & ref_lines)
37-
precision = tp / len(pred_lines) if pred_lines else 0.0
38-
recall = tp / len(ref_lines) if ref_lines else 0.0
47+
if not ref_set or not pred_set:
48+
return {"precision": 0.0, "recall": 0.0, "f1": 0.0, "exact_match": 0.0}
49+
50+
tp = len(pred_set & ref_set)
51+
precision = tp / len(pred_set)
52+
recall = tp / len(ref_set)
3953
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
54+
exact_match = 1.0 if pred_set == ref_set else 0.0
4055

4156
return {
4257
"precision": round(precision, 4),
4358
"recall": round(recall, 4),
4459
"f1": round(f1, 4),
60+
"exact_match": exact_match,
4561
}
4662

4763

64+
def compute_partial_overlap(predicted: list[str], reference: list[str]) -> float:
65+
"""Compute partial overlap ratio using character-level intersection.
66+
67+
For each reference line, find the best matching predicted line (substring match)
68+
and compute the fraction of reference characters covered.
69+
"""
70+
if not reference:
71+
return 1.0 if not predicted else 0.0
72+
if not predicted:
73+
return 0.0
74+
75+
total_chars = 0
76+
matched_chars = 0
77+
78+
for ref_line in reference:
79+
total_chars += len(ref_line)
80+
best = 0
81+
for pred_line in predicted:
82+
# Check substring containment both ways
83+
if ref_line in pred_line or pred_line in ref_line:
84+
best = max(best, min(len(ref_line), len(pred_line)))
85+
else:
86+
# Character-level overlap via set intersection on character bigrams
87+
ref_bigrams = (
88+
{ref_line[i : i + 2] for i in range(len(ref_line) - 1)}
89+
if len(ref_line) > 1
90+
else {ref_line}
91+
)
92+
pred_bigrams = (
93+
{pred_line[i : i + 2] for i in range(len(pred_line) - 1)}
94+
if len(pred_line) > 1
95+
else {pred_line}
96+
)
97+
if ref_bigrams:
98+
overlap = len(ref_bigrams & pred_bigrams) / len(ref_bigrams)
99+
best = max(best, int(overlap * len(ref_line)))
100+
matched_chars += best
101+
102+
return round(matched_chars / total_chars, 4) if total_chars > 0 else 0.0
103+
104+
105+
def compute_empty_accuracy(predicted: list[str], reference: list[str]) -> dict[str, float | str]:
106+
"""Check if model correctly predicts empty vs non-empty.
107+
108+
Returns category (true_positive, true_negative, false_positive, false_negative)
109+
and whether correct.
110+
"""
111+
ref_empty = len(reference) == 0
112+
pred_empty = len(predicted) == 0
113+
114+
if ref_empty and pred_empty:
115+
return {"category": "true_negative", "correct": 1.0}
116+
elif ref_empty and not pred_empty:
117+
return {"category": "false_positive", "correct": 0.0}
118+
elif not ref_empty and pred_empty:
119+
return {"category": "false_negative", "correct": 0.0}
120+
else:
121+
return {"category": "true_positive", "correct": 1.0}
122+
123+
48124
def _lcs_length(x: list[str], y: list[str]) -> int:
49125
"""Compute longest common subsequence length."""
50126
m, n = len(x), len(y)
@@ -67,8 +143,8 @@ def compute_rouge_l(predicted: str, reference: str) -> float:
67143
return 0.0
68144

69145
lcs = _lcs_length(pred_tokens, ref_tokens)
70-
precision = lcs / len(pred_tokens) if pred_tokens else 0.0
71-
recall = lcs / len(ref_tokens) if ref_tokens else 0.0
146+
precision = lcs / len(pred_tokens)
147+
recall = lcs / len(ref_tokens)
72148

73149
if precision + recall == 0:
74150
return 0.0
@@ -100,7 +176,6 @@ def evaluate_model(
100176
101177
Returns:
102178
Dict with aggregate metrics
103-
104179
"""
105180
import torch
106181
from transformers import AutoModelForCausalLM, AutoTokenizer
@@ -115,6 +190,9 @@ def evaluate_model(
115190
)
116191
model.eval()
117192

193+
if tokenizer.pad_token is None:
194+
tokenizer.pad_token = tokenizer.eos_token
195+
118196
# Load eval data
119197
samples = []
120198
with open(eval_file) as f:
@@ -126,19 +204,29 @@ def evaluate_model(
126204
logger.info(f"Evaluating on {len(samples)} samples")
127205

128206
all_metrics = {
129-
"line_precision": [],
130-
"line_recall": [],
131-
"line_f1": [],
207+
"span_precision": [],
208+
"span_recall": [],
209+
"span_f1": [],
210+
"exact_match": [],
211+
"partial_overlap": [],
212+
"empty_accuracy": [],
132213
"rouge_l": [],
133214
"compression": [],
134215
}
135216

217+
empty_confusion = {
218+
"true_positive": 0,
219+
"true_negative": 0,
220+
"false_positive": 0,
221+
"false_negative": 0,
222+
}
223+
136224
for i, sample in enumerate(samples):
137225
prompt = sample["prompt"]
138-
reference = sample["response"]
226+
reference_raw = sample["response"]
139227

140228
# Generate
141-
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=4096)
229+
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=16384)
142230
inputs = {k: v.to(model.device) for k, v in inputs.items()}
143231

144232
with torch.no_grad():
@@ -150,26 +238,47 @@ def evaluate_model(
150238
pad_token_id=tokenizer.pad_token_id,
151239
)
152240

153-
generated = tokenizer.decode(
241+
generated_raw = tokenizer.decode(
154242
outputs[0][inputs["input_ids"].shape[1] :],
155243
skip_special_tokens=True,
156244
)
157245

158-
# Compute metrics
159-
line_metrics = compute_line_level_metrics(generated, reference)
160-
rouge = compute_rouge_l(generated, reference)
161-
original_output = prompt.split("<|user|>")[-1].split("<|assistant|>")[0]
162-
compression = compute_compression_ratio(original_output, generated)
163-
164-
all_metrics["line_precision"].append(line_metrics["precision"])
165-
all_metrics["line_recall"].append(line_metrics["recall"])
166-
all_metrics["line_f1"].append(line_metrics["f1"])
246+
# Parse both into line lists
247+
pred_lines = _parse_relevant_lines(generated_raw)
248+
ref_lines = _parse_relevant_lines(reference_raw)
249+
250+
# Span metrics
251+
span = compute_span_metrics(pred_lines, ref_lines)
252+
all_metrics["span_precision"].append(span["precision"])
253+
all_metrics["span_recall"].append(span["recall"])
254+
all_metrics["span_f1"].append(span["f1"])
255+
all_metrics["exact_match"].append(span["exact_match"])
256+
257+
# Partial overlap
258+
partial = compute_partial_overlap(pred_lines, ref_lines)
259+
all_metrics["partial_overlap"].append(partial)
260+
261+
# Empty accuracy
262+
empty = compute_empty_accuracy(pred_lines, ref_lines)
263+
all_metrics["empty_accuracy"].append(empty["correct"])
264+
empty_confusion[empty["category"]] += 1
265+
266+
# ROUGE-L on concatenated text
267+
pred_text = "\n".join(pred_lines)
268+
ref_text = "\n".join(ref_lines)
269+
rouge = compute_rouge_l(pred_text, ref_text)
167270
all_metrics["rouge_l"].append(rouge)
271+
272+
# Compression
273+
original_output = prompt.split("<|user|>")[-1].split("<|assistant|>")[0]
274+
compression = compute_compression_ratio(original_output, pred_text)
168275
all_metrics["compression"].append(compression)
169276

170277
if (i + 1) % 10 == 0:
171278
logger.info(
172-
f" [{i + 1}/{len(samples)}] F1={line_metrics['f1']:.3f} ROUGE-L={rouge:.3f}"
279+
f" [{i + 1}/{len(samples)}] "
280+
f"F1={span['f1']:.3f} EM={span['exact_match']:.0f} "
281+
f"ROUGE-L={rouge:.3f}"
173282
)
174283

175284
# Aggregate
@@ -182,11 +291,16 @@ def evaluate_model(
182291
"stdev": round(statistics.stdev(values), 4) if len(values) > 1 else 0,
183292
}
184293

185-
logger.info("=" * 50)
294+
results["empty_confusion"] = empty_confusion
295+
results["num_samples"] = len(samples)
296+
297+
logger.info("=" * 60)
186298
logger.info("EVALUATION RESULTS")
187-
logger.info("=" * 50)
299+
logger.info("=" * 60)
188300
for key, stats in results.items():
189-
logger.info(f" {key}: mean={stats['mean']:.4f} median={stats['median']:.4f}")
301+
if isinstance(stats, dict) and "mean" in stats:
302+
logger.info(f" {key:20s}: mean={stats['mean']:.4f} median={stats['median']:.4f}")
303+
logger.info(f" {'empty_confusion':20s}: {empty_confusion}")
190304

191305
return results
192306

@@ -203,7 +317,7 @@ def build_parser(parser: argparse.ArgumentParser | None = None) -> argparse.Argu
203317
required=True,
204318
help="Path to the trained extractor model",
205319
)
206-
parser.add_argument("--eval-file", required=True, help="Path to eval.jsonl")
320+
parser.add_argument("--eval-file", required=True, help="Path to test.jsonl")
207321
parser.add_argument("--max-samples", type=int, default=None)
208322
parser.add_argument("--max-new-tokens", type=int, default=1024)
209323
return parser

0 commit comments

Comments
 (0)