Spaces:
Running
Running
| """W1.a — matched-supervision-budget curve: REAL-F1 vs gold labels, ours vs Baran. | |
| OUR k-label arm (deterministic label calibration, no retraining): k gold-labeled cells | |
| per dataset (sampled from that dataset's real error set, seed-controlled) are used ONLY | |
| to validate/expand the planner's committed accept set — never to peek at any other gold: | |
| * VALIDATE: a label that refutes a committed mapping entry (raw -> wrong canon) | |
| overrides it with the labeled repair (raw -> gold); | |
| * EXPAND: a label whose surface form the planner abstained on adds the label-confirmed | |
| mapping (the product loop: the k labels are k resolved review flags); | |
| * two labels giving the SAME surface form DIFFERENT golds are contradictory evidence | |
| for a column-level mapping -> both dropped (abstain stays). | |
| A mapping repairs every occurrence of the labeled surface form in its column, so one | |
| label can fix more than its own cell. tau-lowering is a no-op on this path: the shipped | |
| heuristic commits its full accept set at k=0 (there is no sub-tau reserve to admit). | |
| k=0 IS the shipped pipeline (money_table.json "grounded (ours)" REAL-F1) — recomputed | |
| here only as a pipeline-identity assertion, not a new number. | |
| Baran points (same slice, same churn-neutral score): 20 labels = existing | |
| eval/results/baran_raha.json; 0/5 labels = CSVs from | |
| uv run --no-project --python 3.10 --with "raha==1.26" --with "numpy<2" \ | |
| --with "pandas<2.1" --with "scikit-learn<1.4" \ | |
| python eval/run_baran.py --n-labels {0,5} --seeds 0,1 --out eval/results/baran_n{0,5} | |
| uv run python -m eval.label_curve # -> eval/results/label_curve.json | |
| uv run --with matplotlib python -m eval.label_curve --plot # -> docs/paper/fig_label_curve.pdf | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import copy | |
| import json | |
| import random | |
| from pathlib import Path | |
| from scrubdata.executor import apply_plan | |
| from scrubdata.planner import mock_plan | |
| from .metrics import _cell_equal | |
| from .run_real_multi import RAHA, _cell_only, _raha_pair, _sem_equal, score | |
| RESULTS = Path(__file__).resolve().parent / "results" | |
| PAPER = Path(__file__).resolve().parent.parent / "docs" / "paper" | |
| KS = (0, 5, 20) | |
| LABEL_SEEDS = (0, 1, 2) # seed 0 = primary (spec); 1,2 = sampling variance | |
| def sample_labels(dirty, clean, k: int, seed: int) -> list[tuple[int, str, str, str]]: | |
| """k gold-labeled error cells: (row, col, dirty_surface, gold). Deterministic: | |
| error set enumerated column-major (as score() walks it), sampled with seed.""" | |
| n = min(len(dirty), len(clean)) | |
| errs = [(i, j) for j in range(len(dirty.columns)) for i in range(n) | |
| if not _cell_equal(dirty.iat[i, j], clean.iat[i, j])] | |
| sel = random.Random(seed).sample(errs, min(k, len(errs))) | |
| return [(i, dirty.columns[j], str(dirty.iat[i, j]).strip(), str(clean.iat[i, j])) | |
| for i, j in sel] | |
| def calibrate_plan(plan: dict, labels: list[tuple[int, str, str, str]]) -> dict: | |
| """Apply the k labels to the plan's accept set (VALIDATE/EXPAND/contradiction rules | |
| from the module docstring). Touches ONLY the labeled surface forms.""" | |
| out = copy.deepcopy(plan) | |
| by_col: dict[str, dict[str, set]] = {} | |
| for _, col, raw, gold in labels: | |
| by_col.setdefault(col, {}).setdefault(raw, set()).add(gold) | |
| cols = {c.get("name"): c for c in out.setdefault("columns", [])} | |
| for col, repairs in by_col.items(): | |
| repairs = {r: next(iter(g)) for r, g in repairs.items() if len(g) == 1} | |
| if not repairs: | |
| continue # all contradictory -> abstain | |
| c = cols.get(col) | |
| if c is None: | |
| c = {"name": col, "detected_semantic_type": "categorical", | |
| "issues": ["label_confirmed_errors"], "operations": []} | |
| out["columns"].append(c) | |
| cols[col] = c | |
| op = next((o for o in c.setdefault("operations", []) | |
| if o.get("op") == "canonicalize_categories"), None) | |
| if op is None: | |
| op = {"op": "canonicalize_categories", "mapping": {}, | |
| "rationale": "k-label calibration: label-confirmed repairs"} | |
| c["operations"].append(op) | |
| m = op.setdefault("mapping", {}) | |
| for raw, gold in repairs.items(): | |
| if raw in m and _sem_equal(m[raw], gold): | |
| continue # VALIDATE: label confirms entry | |
| m[raw] = gold # override refuted / EXPAND abstained | |
| return out | |
| def run_ours(ks=KS, label_seeds=LABEL_SEEDS) -> dict: | |
| """Label-efficiency curve for the shipped deterministic pipeline; macro REAL-F1.""" | |
| per_ds: dict[str, dict] = {} | |
| points = [] | |
| plans, pairs = {}, {} | |
| for name, _ in RAHA: | |
| pairs[name] = _raha_pair(name) | |
| plans[name] = mock_plan(pairs[name][0]) # deterministic; computed once | |
| for k in ks: | |
| seeds = [0] if k == 0 else list(label_seeds) | |
| seed_macro, seed_rows = [], [] | |
| for s in seeds: | |
| rows = {} | |
| for name, _ in RAHA: | |
| dirty, clean = pairs[name] | |
| plan = plans[name] if k == 0 else \ | |
| calibrate_plan(plans[name], sample_labels(dirty, clean, k, s)) | |
| cleaned, _ = apply_plan(dirty, _cell_only(plan)) | |
| m = score(dirty, clean, cleaned) | |
| rows[name] = {kk: m[kk] for kk in ("f1", "precision", "recall", "damage")} | |
| macro = sum(r["f1"] for r in rows.values()) / len(rows) | |
| seed_macro.append(macro) | |
| seed_rows.append(rows) | |
| detail = ", ".join(f"{n} {r['f1']:.3f}" for n, r in rows.items()) | |
| print(f" ours k={k} seed={s}: macro REAL-F1 {macro:.3f} ({detail})", flush=True) | |
| mu = sum(seed_macro) / len(seed_macro) | |
| sd = (sum((x - mu) ** 2 for x in seed_macro) / len(seed_macro)) ** 0.5 | |
| points.append({"k": k, "macro_f1": mu, "macro_f1_sd": sd, | |
| "macro_f1_per_seed": seed_macro, "label_seeds": seeds, | |
| "per_dataset_seed0": seed_rows[0]}) | |
| if k == 0: | |
| per_ds = seed_rows[0] | |
| shipped = json.load(open(RESULTS / "money_table.json"))[0]["real_f1"] | |
| drift = abs(points[0]["macro_f1"] - shipped) | |
| if drift > 1e-9: # planner code moved since money_table.json | |
| print(f" NOTE: k=0 at HEAD {points[0]['macro_f1']:.4f} != money_table " | |
| f"{shipped:.4f} — money_table.json is stale (re-run needed for paper)") | |
| return {"system": "ScrubData (grounded heuristic + k-label calibration)", | |
| "label_use": "validate/expand accept set only; no retraining, no extra gold", | |
| "points": points, "shipped_k0_money_table": shipped, | |
| "k0_drift_vs_money_table": drift, "per_dataset_k0": per_ds} | |
| def baran_point(repaired_dir: Path, n_labels: int) -> dict: | |
| """Score one Baran budget from its repaired CSVs under the identical protocol.""" | |
| csvs = sorted(repaired_dir.glob("*_seed*_repaired.csv")) if repaired_dir.exists() else [] | |
| if not csvs: | |
| return {"k": n_labels, "status": "cannot_run", | |
| "reason": f"no repaired CSVs in {repaired_dir} (run crashed or not run)"} | |
| import collections | |
| import pandas as pd | |
| per_seed = collections.defaultdict(dict) | |
| for p in csvs: | |
| name, seed = p.stem.rsplit("_repaired", 1)[0].rsplit("_seed", 1) | |
| dirty, clean = _raha_pair(name) | |
| out = pd.read_csv(p, dtype=str, keep_default_na=False) | |
| m = score(dirty, clean, out) | |
| per_seed[int(seed)][name] = {kk: m[kk] for kk in ("f1", "precision", "recall", "damage")} | |
| seed_macro = [sum(r["f1"] for r in rows.values()) / len(rows) | |
| for rows in per_seed.values()] | |
| mu = sum(seed_macro) / len(seed_macro) | |
| sd = (sum((x - mu) ** 2 for x in seed_macro) / len(seed_macro)) ** 0.5 | |
| return {"k": n_labels, "status": "ok", "macro_f1": mu, "macro_f1_sd": sd, | |
| "macro_f1_per_seed": seed_macro, "n_seeds": len(per_seed), | |
| "per_dataset_seed0": per_seed.get(0, {})} | |
| def assemble(out: Path) -> dict: | |
| print("ScrubData arm:", flush=True) | |
| ours = run_ours() | |
| baran20 = json.load(open(RESULTS / "baran_raha.json")) | |
| baran = {"system": "Baran (oracle error positions + k gold-labeled tuples)", | |
| "protocol_note": baran20["protocol_note"], | |
| "points": [baran_point(RESULTS / "baran_n0", 0), | |
| baran_point(RESULTS / "baran_n5", 5), | |
| {"k": 20, "status": "ok", "macro_f1": baran20["real_f1"], | |
| "macro_f1_sd": None, "ci95": baran20["real_f1_ci"], | |
| "macro_f1_per_seed": baran20["real_f1_per_seed"], | |
| "n_seeds": baran20["n_seeds"], | |
| "source": "eval/results/baran_raha.json (existing 3-seed run)"}]} | |
| result = { | |
| "metric": "churn-neutral REAL-F1 (eval.run_real_multi.score), " | |
| "macro over the 5 Raha real-error datasets", | |
| "datasets": [n for n, _ in RAHA], | |
| "ours": ours, "baran": baran, | |
| "honesty": "k-label arm touches only the k sampled (cell, gold) pairs per " | |
| "dataset; Baran additionally receives oracle error positions at " | |
| "every k (disclosed asymmetry in its favor).", | |
| } | |
| json.dump(result, open(out, "w"), indent=1) | |
| print(f"-> {out}") | |
| return result | |
| def plot(result: dict, out: Path) -> None: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| fig, ax = plt.subplots(figsize=(5.2, 3.4)) | |
| o = result["ours"]["points"] | |
| ax.errorbar([p["k"] for p in o], [p["macro_f1"] for p in o], | |
| yerr=[p["macro_f1_sd"] for p in o], marker="o", ms=5, lw=1.6, | |
| color="#2f6f5e", capsize=3, label="ScrubData (zero-config; labels only\ncalibrate the accept set)") | |
| bp = [p for p in result["baran"]["points"] if p.get("status") == "ok"] | |
| bx, by = [p["k"] for p in bp], [p["macro_f1"] for p in bp] | |
| ax.errorbar(bx, by, yerr=[p.get("macro_f1_sd") or 0 for p in bp], marker="s", ms=5, | |
| lw=1.6, ls="--", color="#b3433b", capsize=3, | |
| label="Baran (oracle error positions\n+ k labeled tuples)") | |
| b0 = result["baran"]["points"][0] | |
| if b0.get("status") == "cannot_run": | |
| ax.scatter([0], [0], marker="x", s=60, color="#b3433b", zorder=5) | |
| ax.annotate("Baran cannot run\nwithout labels", xy=(0, 0), xytext=(1.2, 0.10), | |
| fontsize=8, color="#b3433b", | |
| arrowprops=dict(arrowstyle="->", lw=0.8, color="#b3433b")) | |
| elif b0.get("macro_f1", 1) < 0.01: | |
| ax.annotate("at 0 labels Baran runs but\nrepairs nothing (F1 0.000)", | |
| xy=(0, b0["macro_f1"]), xytext=(2.6, 0.05), fontsize=8, | |
| color="#b3433b", | |
| arrowprops=dict(arrowstyle="->", lw=0.8, color="#b3433b")) | |
| ax.set_xlabel("gold-labeled cells per dataset (supervision budget)") | |
| ax.set_ylabel("REAL-F1 (macro, 5 Raha datasets)") | |
| ax.set_xticks([0, 5, 20]) | |
| ax.set_ylim(-0.04, 1.0) | |
| ax.legend(fontsize=8, loc="upper left") | |
| ax.grid(alpha=0.25, lw=0.5) | |
| fig.tight_layout() | |
| fig.savefig(out) | |
| fig.savefig(out.with_suffix(".png"), dpi=200) | |
| print(f"-> {out}") | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--plot", action="store_true", | |
| help="render docs/paper/fig_label_curve.pdf from the JSON") | |
| ap.add_argument("--out", default=str(RESULTS / "label_curve.json")) | |
| args = ap.parse_args() | |
| out = Path(args.out) | |
| if args.plot: | |
| plot(json.load(open(out)), PAPER / "fig_label_curve.pdf") | |
| else: | |
| assemble(out) | |
| if __name__ == "__main__": | |
| main() | |