"""Evaluation tables for DARAG (paper Tables 3 & 4) — no ML dependencies. Two reports over a predictions JSONL (rows carrying ``gold_text``, ``raw_asr`` and any correction columns): * ``wer_report`` — paper Table 3 style: per prediction column, per split, the averaged WER/CER/term-F1/number-unit/overcorrection. Consumed by ``gate``. * ``ne_f1_table`` — **paper Table 4**: micro-F1 of code-switched NEs per method (Baseline = ``raw_asr``, +GEC = LLM/RAG ``corrected_text``, +DARAG = ``gec_pred``, +DARAG w/ ID NE = ``gec_pred_id_ne``) split into ID vs OOD. Methods whose column is absent are skipped, so the same function serves smoke and full runs. """ from __future__ import annotations import json from pathlib import Path from typing import Any from carepath_shared.normalize import normalize_text from gec.config import ID_SPLITS, OOD_SPLITS from gec.metrics import ( PairMetrics, TermConfusion, average_metrics, char_error_rate, score_correction, score_pair, split_terms, term_confusion, word_error_rate, ) # Method label -> prediction column (paper Table 4 rows). NE_F1_METHODS = [ ("Baseline", "raw_asr"), ("+GEC", "corrected_text"), ("+DARAG", "gec_pred"), ("+DARAG w/ ID NE", "gec_pred_id_ne"), ] def _row_terms(row: dict[str, Any]) -> list[str]: return row.get("gold_terms") or split_terms(row.get("cs_terms_list")) def wer_report(rows: list[dict[str, Any]], prediction_columns: list[str]) -> dict[str, Any]: report: dict[str, Any] = {} for column in prediction_columns: by_split: dict[str, list[PairMetrics]] = {} word_wers: dict[str, list[float]] = {} exact_matches: dict[str, list[bool]] = {} for row in rows: if column not in row: continue split = row.get("split", "unknown") terms = _row_terms(row) if column != "raw_asr" and row.get("raw_asr"): metric = score_correction(row["gold_text"], row["raw_asr"], row[column], terms) else: metric = score_pair(row["gold_text"], row[column], terms) by_split.setdefault(split, []).append(metric) # Vietnamese: the PairMetrics WER is syllable-level; also track a pyvi # word-segmented WER so the report shows both tokenizations. word_wers.setdefault(split, []).append( word_error_rate(row["gold_text"], row[column], segment=True) ) exact_matches.setdefault(split, []).append( normalize_text(row["gold_text"]) == normalize_text(row[column]) ) report[column] = { split: { **_summarize(average_metrics(metrics), len(metrics)), "wer_word": round(sum(word_wers[split]) / len(word_wers[split]), 4), "exact_match_accuracy": round( sum(exact_matches[split]) / len(exact_matches[split]), 4 ), } for split, metrics in sorted(by_split.items()) } return report def stratified_report( rows: list[dict[str, Any]], prediction_columns: list[str], category_key: str = "category" ) -> dict[str, Any]: """Return the existing metrics separately for every frozen-eval category.""" categories = sorted({str(row.get(category_key, "uncategorized")) for row in rows}) return { category: wer_report( [row for row in rows if str(row.get(category_key, "uncategorized")) == category], prediction_columns, ) for category in categories } def train_error_signal( rows: list[dict[str, Any]], split: str = "train", thin_threshold: float = 0.05, ) -> dict[str, Any]: """Report raw-ASR error volume on ``split`` (paper §3.2: too-few errors = weak GEC). If the ASR already transcribes the train split almost perfectly there is little for the corrector to learn, so this surfaces mean/median WER+CER and a verdict the stage-02 notebook prints. Reporting only — never blocks. """ wers = [ word_error_rate(r["gold_text"], r["raw_asr"]) for r in rows if r.get("split") == split and r.get("gold_text") and r.get("raw_asr") is not None ] cers = [ char_error_rate(r["gold_text"], r["raw_asr"]) for r in rows if r.get("split") == split and r.get("gold_text") and r.get("raw_asr") is not None ] if not wers: return {"split": split, "n": 0, "note": "no rows with raw_asr+gold_text"} mean_wer = sum(wers) / len(wers) thin = mean_wer < thin_threshold return { "split": split, "n": len(wers), "mean_wer": round(mean_wer, 4), "median_wer": round(sorted(wers)[len(wers) // 2], 4), "mean_cer": round(sum(cers) / len(cers), 4), "thin_signal": thin, "verdict": ( "THIN error signal — ASR is too accurate on train; lean on synthetic " "augmentation / perturbation N-best, or a weaker ASR for hypotheses " "(paper §3.2 ii/iii)." if thin else "OK — enough errors in train hypotheses for the GEC model to learn from." ), } def ne_f1_table( rows: list[dict[str, Any]], id_splits: tuple[str, ...] = ID_SPLITS, ood_splits: tuple[str, ...] = OOD_SPLITS, ) -> dict[str, Any]: """Micro-F1 of NEs per method, ID vs OOD (paper Table 4).""" groups = {"ID": set(id_splits), "OOD": set(ood_splits)} table: dict[str, Any] = {} for label, column in NE_F1_METHODS: present = any(column in row for row in rows) if not present: continue method_row: dict[str, Any] = {} for group_name, splits in groups.items(): confusion = TermConfusion() n = 0 for row in rows: if row.get("split") not in splits or column not in row: continue confusion = confusion + term_confusion( row["gold_text"], row[column], _row_terms(row) ) n += 1 if n: method_row[group_name] = { "n": n, "precision": round(confusion.precision, 4), "recall": round(confusion.recall, 4), "f1_micro": round(confusion.f1, 4), } if method_row: table[label] = method_row return table def aggregate_reports(reports: list[dict[str, Any]]) -> dict[str, Any]: """Combine per-seed ``wer_report`` dicts into ``{column: {split: {metric: mean/std}}}``. The paper averages every reported number over 3 seeds; this produces the mean±std table shown in stage-10, computed over whatever numeric metrics each cell carries (WER, CER, term-F1, ...). """ if not reports: return {} out: dict[str, Any] = {} for column in reports[0]: out[column] = {} for split in reports[0][column]: collected: dict[str, list[float]] = {} for report in reports: cell = report.get(column, {}).get(split) if not cell: continue for key, value in cell.items(): if isinstance(value, (int, float)): collected.setdefault(key, []).append(float(value)) out[column][split] = { key: { "mean": round(sum(xs) / len(xs), 4), "std": round((sum((x - sum(xs) / len(xs)) ** 2 for x in xs) / len(xs)) ** 0.5, 4), "n_seeds": len(xs), } for key, xs in collected.items() } return out def mean_report(reports: list[dict[str, Any]]) -> dict[str, Any]: """Flat per-seed mean in the same shape as ``wer_report`` so ``gate`` can run on it.""" aggregated = aggregate_reports(reports) return { column: { split: {key: stats["mean"] for key, stats in cell.items()} for split, cell in splits.items() } for column, splits in aggregated.items() } def render_ne_f1_table(table: dict[str, Any]) -> str: """Pretty-print the NE-F1 ablation table for notebook/CLI output.""" lines = [f"{'Method':<20} {'ID F1':>8} {'OOD F1':>8}"] lines.append("-" * 38) for label, _ in NE_F1_METHODS: if label not in table: continue row = table[label] id_f1 = row.get("ID", {}).get("f1_micro") ood_f1 = row.get("OOD", {}).get("f1_micro") lines.append( f"{label:<20} " f"{('-' if id_f1 is None else f'{id_f1:.4f}'):>8} " f"{('-' if ood_f1 is None else f'{ood_f1:.4f}'):>8}" ) return "\n".join(lines) def build_reports( input_path: Path, prediction_columns: list[str], wer_output: Path | None = None, ne_f1_output: Path | None = None, stratified_output: Path | None = None, ) -> dict[str, Any]: rows = [ json.loads(line) for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip() ] wer = wer_report(rows, prediction_columns) ne_table = ne_f1_table(rows) stratified = stratified_report(rows, prediction_columns) if wer_output: wer_output.parent.mkdir(parents=True, exist_ok=True) wer_output.write_text(json.dumps(wer, ensure_ascii=False, indent=2), encoding="utf-8") if ne_f1_output: ne_f1_output.parent.mkdir(parents=True, exist_ok=True) ne_f1_output.write_text(json.dumps(ne_table, ensure_ascii=False, indent=2), encoding="utf-8") if stratified_output: stratified_output.parent.mkdir(parents=True, exist_ok=True) stratified_output.write_text( json.dumps(stratified, ensure_ascii=False, indent=2), encoding="utf-8" ) return {"wer": wer, "ne_f1": ne_table, "stratified": stratified} def _summarize(metrics: PairMetrics, count: int) -> dict[str, Any]: return { "n": count, "wer": round(metrics.wer, 4), "cer": round(metrics.cer, 4), "term_precision": round(metrics.term_precision, 4), "term_recall": round(metrics.term_recall, 4), "term_f1": round(metrics.term_f1, 4), "number_unit_preservation": round(metrics.number_unit_preservation, 4), "overcorrection_rate": round(metrics.overcorrection_rate, 4), }