1- """Evaluation script for tool output extraction model.
1+ """Evaluation script for squeez tool output extraction model.
22
33Metrics:
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
911import argparse
1012import json
1113import logging
12- import re
1314import statistics
1415
1516logger = 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+
48124def _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