#!/usr/bin/env python3 """ extract_subset.py Extract samples where 'system' mode underperformed baselines, create a subset dataset for running hybrid mode, and produce a 6-mode comparison table. Usage: # Step 1: Extract bad samples and create subset dataset python extract_subset.py --dir 281_results_final --top 50 --output hybrid_subset/ # Step 2: Run hybrid mode on the subset python run_benchmark.py --input hybrid_subset/evaluation_subset.json --modes hybrid --model gpt-5-mini-2025-08-07 # Step 3: Compare all 6 modes on the subset python extract_subset.py --dir hybrid_subset --compare """ import sys import os import json import argparse from glob import glob from tabulate import tabulate # Add parent dir to path to import metrics.py current_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(current_dir, '..')) if parent_dir not in sys.path: sys.path.insert(0, parent_dir) if current_dir not in sys.path: sys.path.insert(0, current_dir) try: from metrics import MetricsCalculator except ImportError: print("ERROR: Could not import MetricsCalculator from metrics.py.") print("Please make sure metrics.py is located in the parent directory.") sys.exit(1) def calculate_error_score(item): """Calculates an absolute 'badness' score for a single evaluation result.""" score = 0.0 if item.get('pred_bias') is not None and item.get('gold_bias') is not None: score += abs(item['pred_bias'] - item['gold_bias']) * 0.15 if item.get('pred_factuality') is not None and item.get('gold_factuality') is not None: score += abs(item['pred_factuality'] - item['gold_factuality']) * 0.15 score += (1.0 - item.get('factscore_precision', 0)) * 1.5 score += (1.0 - item.get('factscore_recall', 0)) * 1.0 score += (1.0 - item.get('meteor', 0)) * 0.5 return score def aggregate_results(results): """Aggregates a list of results using the official metrics calculator.""" n = len(results) if n == 0: return None # Filter out None scores to pass to the official MetricsCalculator valid_bias_preds = [ (r.get('gold_bias'), r.get('pred_bias')) for r in results if r.get('pred_bias') is not None and r.get('gold_bias') is not None ] valid_fact_preds = [ (r.get('gold_factuality'), r.get('pred_factuality')) for r in results if r.get('pred_factuality') is not None and r.get('gold_factuality') is not None ] # Use the official EMNLP 2024 ordinal MAE logic from metrics.py bias_mae = MetricsCalculator.calculate_bias_mae( [x[0] for x in valid_bias_preds], [x[1] for x in valid_bias_preds] ) if valid_bias_preds else None fact_mae = MetricsCalculator.calculate_factuality_mae( [x[0] for x in valid_fact_preds], [x[1] for x in valid_fact_preds] ) if valid_fact_preds else None # Safely handle metrics that might be missing or None avg_fs_precision = sum(r.get('factscore_precision') or 0 for r in results) / n avg_fs_recall = sum(r.get('factscore_recall') or 0 for r in results) / n avg_error_rate = sum(r.get('error_rate') or 0 for r in results) / n avg_meteor = sum(r.get('meteor') or 0 for r in results) / n avg_rougeL = sum(r.get('rougeL') or 0 for r in results) / n # FC detection fc_applicable = [r for r in results if r.get('fact_check_hit', {}).get('status') != 'not_applicable'] fc_detection = sum(1 for r in fc_applicable if r.get('fact_check_hit', {}).get('status') == 'found') / len(fc_applicable) if fc_applicable else float('nan') return { "N": n, "FS Prec.": f"{round(avg_fs_precision * 100, 1)}%", "FS Recall": f"{round(avg_fs_recall * 100, 1)}%", "Err Rate": f"{round(avg_error_rate * 100, 1)}%", "METEOR": f"{round(avg_meteor * 100, 1)}%", "ROUGE-L": f"{round(avg_rougeL * 100, 1)}%", "Bias MAE": round(bias_mae, 2) if bias_mae is not None else "N/A", "Fact. MAE": round(fact_mae, 2) if fact_mae is not None else "N/A", "FC Det.": f"{round(fc_detection * 100, 1)}%" if str(fc_detection) != 'nan' else "N/A", } def load_results(results_dir): """Load all JSONL results from a directory, grouped by outlet name.""" jsonl_files = [ f for f in glob(os.path.join(results_dir, "*.jsonl")) if "summary" not in os.path.basename(f) ] outlets_data = {} for filepath in jsonl_files: filename = os.path.basename(filepath) model = filename.split('_')[0] with open(filepath, 'r', encoding='utf-8') as f: for line in f: if not line.strip(): continue item = json.loads(line) name = item.get('name') mode = item.get('mode', filename.replace('.jsonl', '').split('_')[-1]) if name not in outlets_data: outlets_data[name] = {} outlets_data[name][mode] = item outlets_data[name][mode]['_model'] = model outlets_data[name][mode]['_filepath'] = filepath return outlets_data def calculate_deltas(outlets_data): """Calculate delta scores: (System Error) - (Average Baseline Error). Returns list of (delta, system_score, avg_other_score, name) sorted descending. """ error_rankings = [] for name, modes_dict in outlets_data.items(): if 'system' in modes_dict: system_score = calculate_error_score(modes_dict['system']) # Calculate how all the OTHER modes did on this same sample other_scores = [ calculate_error_score(item) for mode, item in modes_dict.items() if mode != 'system' ] if other_scores: avg_other_score = sum(other_scores) / len(other_scores) # Delta = System Error - Avg Baseline Error # High positive delta means 'system' did uniquely bad delta = system_score - avg_other_score else: avg_other_score = 0.0 delta = 0.0 error_rankings.append((delta, system_score, avg_other_score, name)) else: error_rankings.append((0.0, 0.0, 0.0, name)) # Sort so the highest positive Delta (where we are worse) is at the top error_rankings.sort(key=lambda x: x[0], reverse=True) return error_rankings def cmd_extract(args): """Extract bad samples and create a subset dataset for hybrid mode.""" print(f"Loading results from: {args.dir}") outlets_data = load_results(args.dir) print(f"Found {len(outlets_data)} outlets across results") error_rankings = calculate_deltas(outlets_data) # Determine which outlets to extract if args.delta_threshold is not None: # Extract all outlets where system delta exceeds threshold selected = [(d, s, o, n) for d, s, o, n in error_rankings if d > args.delta_threshold] print(f"\nSelecting outlets with delta > {args.delta_threshold}: {len(selected)} outlets") elif args.top > 0: # Extract top N worst outlets selected = error_rankings[:args.top] print(f"\nSelecting top {args.top} worst-performing outlets") else: # Default: all outlets where system is worse (delta > 0) selected = [(d, s, o, n) for d, s, o, n in error_rankings if d > 0] print(f"\nSelecting all outlets where system underperforms (delta > 0): {len(selected)} outlets") selected_names = set(n for _, _, _, n in selected) if not selected_names: print("No outlets selected. Try adjusting --top or --delta-threshold.") return # Create output directory os.makedirs(args.output, exist_ok=True) # 1. Create subset evaluation dataset from the full dataset eval_dataset_path = os.path.join(current_dir, "evaluation_dataset.json") if not os.path.exists(eval_dataset_path): print(f"ERROR: evaluation_dataset.json not found at {eval_dataset_path}") return with open(eval_dataset_path, 'r') as f: full_dataset = json.load(f) subset_dataset = [item for item in full_dataset if item.get('name') in selected_names] subset_path = os.path.join(args.output, "evaluation_subset.json") with open(subset_path, 'w') as f: json.dump(subset_dataset, f, indent=2, default=str) print(f"\nSaved evaluation subset: {subset_path} ({len(subset_dataset)} items)") # 2. Copy existing mode results for the subset outlets copied_modes = set() for name in selected_names: for mode, item in outlets_data.get(name, {}).items(): if mode.startswith('_'): continue model = item.get('_model', 'unknown') tag = f"{model}_{mode}" outfile = os.path.join(args.output, f"{tag}.jsonl") copied_modes.add(mode) # Append this outlet's result to the mode-specific file # Remove internal keys before writing clean_item = {k: v for k, v in item.items() if not k.startswith('_')} with open(outfile, 'a') as f: f.write(json.dumps(clean_item, default=str) + "\n") print(f"Copied results for modes: {sorted(copied_modes)}") # 3. Print extraction summary print(f"\n{'='*70}") print(f"EXTRACTION SUMMARY") print(f"{'='*70}") print(f"Total outlets in results: {len(outlets_data)}") print(f"Outlets extracted: {len(selected_names)}") print(f"Output directory: {args.output}") print(f"\nTop 10 extracted outlets (by delta):") for delta, sys_score, oth_score, name in selected[:10]: print(f" - {name}") print(f" Delta: +{round(delta, 2)} (System Err: {round(sys_score, 2)} vs Others Avg: {round(oth_score, 2)})") print(f"\nNext steps:") print(f" 1. Run hybrid mode:") print(f" python run_benchmark.py --input {subset_path} --modes hybrid --model ") print(f" 2. Copy hybrid results to {args.output}/") print(f" 3. Compare all 6 modes:") print(f" python extract_subset.py --dir {args.output} --compare") def cmd_compare(args): """Compare all modes (including hybrid) on the subset.""" print(f"Loading results from: {args.dir}") outlets_data = load_results(args.dir) print(f"Found {len(outlets_data)} outlets") # Collect all modes all_modes = set() for name, modes_dict in outlets_data.items(): for mode in modes_dict: if not mode.startswith('_'): all_modes.add(mode) print(f"Modes found: {sorted(all_modes)}") # Aggregate results by (model, mode) mode_results = {} for name, modes_dict in outlets_data.items(): for mode, item in modes_dict.items(): if mode.startswith('_'): continue model = item.get('_model', item.get('model', 'unknown')) key = (model, mode) if key not in mode_results: mode_results[key] = [] mode_results[key].append(item) # Build summary table summary_rows = [] for (model, mode), results_list in mode_results.items(): agg = aggregate_results(results_list) if agg: row = {"Model": model, "Mode": mode} row.update(agg) summary_rows.append(row) summary_rows.sort(key=lambda x: (x['Model'], x['Mode'])) # Print table headers = [ "Model", "Mode", "N", "FS Prec.", "FS Recall", "Err Rate", "METEOR", "ROUGE-L", "Bias MAE", "Fact. MAE", "FC Det." ] table_data = [[r.get(h, "") for h in headers] for r in summary_rows] print(f"\n{'='*80}") print("6-MODE COMPARISON TABLE (Subset)") print(f"{'='*80}") print(tabulate(table_data, headers=headers, tablefmt="grid")) # Save comparison table tsv_path = os.path.join(args.dir, "comparison_table.tsv") with open(tsv_path, 'w') as f: f.write("\t".join(headers) + "\n") for row in table_data: f.write("\t".join(str(x) for x in row) + "\n") print(f"\nSaved to {tsv_path}") # Per-outlet breakdown for system vs hybrid (if both exist) if 'system' in all_modes and 'hybrid' in all_modes: print(f"\n{'='*80}") print("SYSTEM vs HYBRID: Per-Outlet Delta") print(f"{'='*80}") comparisons = [] for name, modes_dict in outlets_data.items(): if 'system' in modes_dict and 'hybrid' in modes_dict: sys_err = calculate_error_score(modes_dict['system']) hyb_err = calculate_error_score(modes_dict['hybrid']) improvement = sys_err - hyb_err # positive = hybrid is better comparisons.append((improvement, name, sys_err, hyb_err)) comparisons.sort(key=lambda x: x[0], reverse=True) improved = sum(1 for imp, _, _, _ in comparisons if imp > 0) degraded = sum(1 for imp, _, _, _ in comparisons if imp < 0) unchanged = sum(1 for imp, _, _, _ in comparisons if imp == 0) print(f" Hybrid improved: {improved}/{len(comparisons)} outlets") print(f" Hybrid degraded: {degraded}/{len(comparisons)} outlets") print(f" Unchanged: {unchanged}/{len(comparisons)} outlets") if comparisons: avg_improvement = sum(imp for imp, _, _, _ in comparisons) / len(comparisons) print(f" Average improvement: {round(avg_improvement, 3)}") print(f"\n Top 5 improvements:") for imp, name, sys_err, hyb_err in comparisons[:5]: print(f" {name}: {round(imp, 3)} (sys={round(sys_err, 2)} -> hyb={round(hyb_err, 2)})") print(f"\n Top 5 degradations:") for imp, name, sys_err, hyb_err in comparisons[-5:]: print(f" {name}: {round(imp, 3)} (sys={round(sys_err, 2)} -> hyb={round(hyb_err, 2)})") def main(): parser = argparse.ArgumentParser( description="Extract subset where system underperforms, run hybrid mode, compare results." ) parser.add_argument("--dir", type=str, required=True, help="Directory with existing JSONL result files") parser.add_argument("--output", type=str, default="hybrid_subset", help="Output directory for subset (default: hybrid_subset)") parser.add_argument("--top", type=int, default=0, help="Extract top N worst outlets (0 = all with delta > 0)") parser.add_argument("--delta-threshold", type=float, default=None, help="Only extract outlets with delta > this value") parser.add_argument("--compare", action="store_true", help="Compare all modes (run after hybrid eval is complete)") args = parser.parse_args() # Required imports: json, argparse, os, sys, glob, tabulate. # Note that tabulate needs to be pip installed: `pip install tabulate`. if args.compare: cmd_compare(args) else: cmd_extract(args) if __name__ == "__main__": main()