scrubdata / eval /label_curve.py
OpenAI Codex
deploy: add sponsor:openai tag (Best Use of Codex) + Codex-hardened build
16dc556
Raw
History Blame Contribute Delete
11.9 kB
"""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()