"""Phase 4: Final test-set evaluation + LLM zero-shot comparison. Run ONCE after Phase 3 is complete. Touches test.jsonl for the first time. Steps: 1. Eval checkpoints/best/ on test split → results/test_final.json 2. Run zero-shot LLM baseline on a sample → results/llm_comparison.json 3. Print the comparison table Usage: python scripts/run_phase4_eval.py python scripts/run_phase4_eval.py --skip-llm # skip LLM call (no API key) python scripts/run_phase4_eval.py --llm-sample 100 """ from __future__ import annotations import argparse import json import logging import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from rich.console import Console from rich.table import Table from finner.config import RESULTS_DIR, CHECKPOINTS_DIR, SPLITS_DIR, settings from finner.data.split import load_jsonl from finner.eval.metrics import compute_seqeval from finner.eval.run_eval import evaluate_checkpoint, _to_serializable logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") console = Console() def run_test_eval() -> dict: console.print("\n[bold cyan]Step 1: Final test-set evaluation (touching test split for the first time)[/bold cyan]") metrics = evaluate_checkpoint( checkpoint_path=CHECKPOINTS_DIR / "best", split="test", save_name="test_final", ) console.print(f"\n[bold green]Final test entity-F1: {metrics['overall_f1']:.4f}[/bold green]") return metrics def run_llm_baseline(sample_size: int) -> dict: console.print(f"\n[bold cyan]Step 2: Zero-shot LLM baseline ({sample_size} examples)[/bold cyan]") console.print("[yellow]Note: This uses the LLM API for comparison ONLY. Not in the core inference path.[/yellow]") from finner.eval.llm_baseline import run_llm_baseline as _run test_examples = load_jsonl(SPLITS_DIR / "test.jsonl") t0 = time.perf_counter() baseline_result = _run(test_examples, sample_size=sample_size) elapsed = time.perf_counter() - t0 llm_metrics = compute_seqeval(baseline_result["flat_labels"], baseline_result["flat_preds"]) result = { "llm_model": settings.llm_model, "llm_provider": settings.llm_provider, "llm_f1": llm_metrics["overall_f1"], "llm_precision": llm_metrics["overall_precision"], "llm_recall": llm_metrics["overall_recall"], "llm_per_class": llm_metrics["per_class"], "llm_avg_latency_ms": baseline_result["avg_latency_ms"], "llm_total_examples": baseline_result["total_examples"], "llm_total_elapsed_s": elapsed, } out = RESULTS_DIR / "llm_comparison.json" with open(out, "w") as f: json.dump(_to_serializable(result), f, indent=2) console.print(f"Saved → {out}") return result def merge_comparison(test_metrics: dict, llm_result: dict | None) -> None: """Load best-val F1 from runs.jsonl and merge into llm_comparison.json.""" runs_path = RESULTS_DIR / "runs.jsonl" best_val_f1 = 0.0 if runs_path.exists(): runs = [json.loads(l) for l in open(runs_path) if l.strip()] if runs: best_val_f1 = max(r["best_val_entity_f1"] for r in runs) comparison = { "model_name": settings.model_name, "model_params_M": 108, "model_f1": test_metrics["overall_f1"], "model_per_class": test_metrics["per_class"], "best_val_f1": best_val_f1, } if llm_result: comparison.update(llm_result) out = RESULTS_DIR / "llm_comparison.json" with open(out, "w") as f: json.dump(_to_serializable(comparison), f, indent=2) # Print comparison table table = Table(title="Fine-tuned Encoder vs Zero-shot LLM", show_header=True) table.add_column("System", style="bold") table.add_column("entity-F1", justify="right") table.add_column("Latency/sent", justify="right") table.add_column("Cost/1k sent", justify="right") table.add_column("Params", justify="right") table.add_row( "Fine-tuned BERT", f"[bold green]{test_metrics['overall_f1']:.4f}[/bold green]", "~50ms (CPU)", "~free (local)", "108M", ) if llm_result: table.add_row( f"Zero-shot {llm_result.get('llm_model', 'LLM')}", f"{llm_result.get('llm_f1', 0):.4f}", f"{llm_result.get('llm_avg_latency_ms', 0):.0f}ms", "~$0.25 (API)", ">> 1B", ) console.print(table) def main(): parser = argparse.ArgumentParser() parser.add_argument("--skip-llm", action="store_true", help="Skip LLM API call") parser.add_argument("--llm-sample", type=int, default=100, help="Number of test examples for LLM eval") args = parser.parse_args() test_metrics = run_test_eval() llm_result = None if not args.skip_llm: try: llm_result = run_llm_baseline(args.llm_sample) except Exception as exc: console.print(f"[red]LLM baseline failed: {exc}[/red]") console.print("[yellow]Run with --skip-llm to skip this step.[/yellow]") merge_comparison(test_metrics, llm_result) console.print("\n[bold green]✓ Phase 4 complete.[/bold green]") if __name__ == "__main__": main()