Spaces:
Running
Running
File size: 13,057 Bytes
16dc556 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | """Improved real-data north-star — fixes the biases of single-dataset `repair_recall`.
repair_recall on hospital alone is biased: (1) ONE dataset (overfit to its quirks),
(2) RECALL-ONLY so it rewards over-correction (v5: recall .42 / precision .16),
(3) CONVENTION-sensitive (penalizes case/format differences that are semantically right),
(4) ABSTAIN-blind (punishes correctly declining on uncertain values).
This harness fixes all four:
* MULTI-DATASET (Raha suite) -> macro-average, not one table.
* F1 (recall AND precision) + DAMAGE rate (clean cells we corrupted) -> over-correction
is penalized, not rewarded.
* CONVENTION-NORMALIZED correctness (case/whitespace-insensitive) -> measures the right
VALUE, not surface convention.
* an ADVERSARIAL ABSTAIN slice -> the system must fix real typos but LEAVE trap values
(ambiguous / not-a-typo) untouched. Abstaining correctly scores; wrong-merging doesn't.
uv run python -m eval.run_real_multi
"""
from __future__ import annotations
import random
import pandas as pd
from scrubdata.executor import apply_plan
from scrubdata.planner import mock_plan
from .metrics import _cell_equal
DATASETS = ["hospital", "beers", "flights", "rayyan"]
RAW = "https://raw.githubusercontent.com/BigDaMa/raha/master/datasets"
def _cell_only(plan: dict) -> dict:
"""Strip row-count-changing table ops (dedup / drop-empty-rows) so the cleaned output
stays row-aligned with the reference — we are measuring CELL-level cleaning here."""
p = dict(plan)
p["table_operations"] = [o for o in plan.get("table_operations", [])
if o.get("op") not in ("drop_exact_duplicates", "drop_empty_rows")]
return p
def _sem_equal(a, b) -> bool:
"""Semantic equality: numeric-tolerant (via _cell_equal) OR case/whitespace-folded.
Separates the right VALUE from surface convention (Birmingham == birmingham)."""
if _cell_equal(a, b):
return True
return str(a).strip().lower() == str(b).strip().lower()
def _fetch(name: str):
import urllib.request
from pathlib import Path
base = Path(__file__).resolve().parent.parent / "data" / "real" / name
base.mkdir(parents=True, exist_ok=True)
out = []
for fn in ("dirty.csv", "clean.csv"):
p = base / fn
if not p.exists():
urllib.request.urlretrieve(f"{RAW}/{name}/{fn}", p)
out.append(pd.read_csv(p, dtype=str, keep_default_na=False))
return out
def score(dirty: pd.DataFrame, clean: pd.DataFrame, out: pd.DataFrame) -> dict:
"""Precision/recall/F1 (convention-normalized) + damage rate.
Churn-neutral: a rewrite that is sem-equal to the INPUT but does not restore the
gold (pure case/whitespace churn) counts as NOTHING — not a change, not a fix, not
damage. Otherwise bulk convention-rewrites of clean columns inflate precision (the
'-case match' ablation artifact). Fixing an error also requires actually ACTING:
a case-injected error left untouched is sem-equal to gold but is NOT a fix."""
n = min(len(dirty), len(out), len(clean))
errors = fixed = changed = good_changes = clean_cells = damage = errors_abstained = 0
for j, col in enumerate(dirty.columns):
present = col in out.columns
for i in range(n):
dv, cv = dirty.iat[i, j], clean.iat[i, j]
ov = out.iloc[i][col] if present else dv
err = not _cell_equal(dv, cv) # benchmark error (raw)
chg = present and not _cell_equal(ov, dv) # we changed it
raw_ok = present and _cell_equal(ov, cv) # exactly restored gold
sem_ok = _sem_equal(ov, cv) # right value (convention-tolerant)
if chg and _sem_equal(ov, dv) and not raw_ok:
chg = False # churn: ignore entirely
if err:
errors += 1
if raw_ok or (sem_ok and chg):
fixed += 1 # real restoration or semantic fix
elif not chg:
errors_abstained += 1 # left an error untouched
else:
clean_cells += 1
if chg and not sem_ok:
damage += 1 # corrupted a clean cell
if chg:
changed += 1
if sem_ok:
good_changes += 1
recall = fixed / errors if errors else 0.0
precision = good_changes / changed if changed else 1.0
f1 = 2 * recall * precision / (recall + precision) if (recall + precision) else 0.0
return {"f1": f1, "recall": recall, "precision": precision,
"damage": damage / clean_cells if clean_cells else 0.0,
"_errors": errors, "_changed": changed, "_fixed": fixed}
def abstain_slice(planner, seed: int = 3) -> dict:
"""Adversarial: a city column with real typos (must fix) + TRAP values (must leave).
Tests that the system abstains rather than wrong-merges."""
rng = random.Random(seed)
canon = ["Chicago", "Boston", "Houston", "Phoenix", "Dallas"]
col, gold, kind = [], [], []
for c in canon: # clean anchors
for _ in range(8):
col.append(c); gold.append(c); kind.append("clean")
typos = {"Chcago": "Chicago", "Bostton": "Boston", "Houston": "Houston"}
for t, g in typos.items(): # real typos -> must FIX
col.append(t); gold.append(g); kind.append("typo")
# traps must NOT be single-edit variants of any reference entity (else mapping them
# is arguably CORRECT and the trap mis-scores grounding): garbage strings + one real
# rare city (must stay; catches freq-cluster over-merge into the dominant values).
traps = ["Xqzzyville", "Qwortelby", "Zzanthor Flats", "Carmel"]
for t in traps:
col.append(t); gold.append(t); kind.append("trap")
idx = list(range(len(col))); rng.shuffle(idx)
col = [col[i] for i in idx]; gold = [gold[i] for i in idx]; kind = [kind[i] for i in idx]
df = pd.DataFrame({"city": col})
cleaned, _ = apply_plan(df, _cell_only(planner(df)))
out = cleaned["city"].tolist() if "city" in cleaned.columns else col
typo_fixed = sum(1 for o, g, k in zip(out, gold, kind) if k == "typo" and _sem_equal(o, g))
trap_left = sum(1 for o, g, k in zip(out, gold, kind) if k == "trap" and _sem_equal(o, g))
n_typo = kind.count("typo"); n_trap = kind.count("trap")
return {"typo_recall": typo_fixed / n_typo, "abstain_accuracy": trap_left / n_trap,
"_typos": n_typo, "_traps": n_trap}
def _mean(xs):
xs = list(xs)
return sum(xs) / len(xs) if xs else 0.0
# ---- the validation SUITE: Raha real-error pairs + injected harvested domains ----
RAHA = [("hospital", "health"), ("beers", "beverages"), ("flights", "travel"),
("rayyan", "citations"), ("movies_1", "entertainment")]
# diverse subset of the 20+ harvested clean domains (cached locally)
SUITE_DOMAINS = ["restaurants", "business", "jobs", "complaints", "film", "transport",
"education", "music", "contractors", "alcohol-bars", "vehicles",
"sf-business", "la-business", "real-estate", "food-inspections"]
INJECT = {"typo": "canonicalization", "ocr": "canonicalization",
"case": "format", "whitespace": "format"}
def _raha_pair(name):
dirty, clean = _fetch(name)
if len(dirty) > 2200: # movies_1: subsample for speed
dirty, clean = dirty.head(2000).reset_index(drop=True), clean.head(2000).reset_index(drop=True)
return dirty, clean
def _injected_pair(path, error_type, seed):
import pandas as _pd
from .inject import inject
clean = _pd.read_csv(path, dtype=str, keep_default_na=False, nrows=600, on_bad_lines="skip")
dirty = inject(clean, error_type, seed)
return (dirty, clean) if dirty is not None else None
def build_suite(seed: int = 7):
"""List of specs: {name, domain, error_type, source, load() -> (dirty, clean) | None}.
source 'real' = curated real-error benchmarks; 'injected' = seeded synthetic errors on
harvested clean domains. Report both slices — injected typos are in-distribution for
frequency clustering (canonical always present+dominant by construction), so the
grounding claim lives in the REAL slice."""
from pathlib import Path
specs = [{"name": n, "domain": d, "error_type": "mixed", "source": "real",
"load": (lambda n=n: _raha_pair(n))} for n, d in RAHA]
import json
src = {s["name"]: s["domain"] for s in
json.load(open("training/unpaired_sources.json"))}
cache = Path("data/real/cache")
for fname, domain in src.items():
if domain not in SUITE_DOMAINS:
continue
p = cache / f"{fname}.csv"
if not p.exists():
continue
for et, group in INJECT.items():
specs.append({"name": f"{domain}:{et}", "domain": domain, "error_type": group,
"source": "injected",
"load": (lambda p=p, et=et: _injected_pair(p, et, seed))})
return specs
def evaluate_suite(planner, seed: int = 7) -> dict:
"""Run a planner over the whole suite at one injection seed; return the double-macro."""
import collections
rows = []
for spec in build_suite(seed=seed):
try:
loaded = spec["load"]()
except Exception: # noqa: BLE001
continue
if loaded is None:
continue
dirty, clean = loaded
cleaned, _ = apply_plan(dirty, _cell_only(planner(dirty)))
m = score(dirty, clean, cleaned)
rows.append({**spec, "f1": m["f1"], "damage": m["damage"]})
by_et = collections.defaultdict(list)
by_dom = collections.defaultdict(list)
by_src = collections.defaultdict(list)
for r in rows:
by_et[r["error_type"]].append(r["f1"])
by_dom[r["domain"]].append(r["f1"])
by_src[r.get("source", "injected")].append(r["f1"])
et_macro = _mean(_mean(v) for v in by_et.values())
dom_macro = _mean(_mean(v) for v in by_dom.values())
north = (2 * et_macro * dom_macro / (et_macro + dom_macro)) if (et_macro + dom_macro) else 0.0
ab = abstain_slice(planner)
return {"north": north, "et_macro": et_macro, "dom_macro": dom_macro,
"real": _mean(by_src.get("real", [])), "injected": _mean(by_src.get("injected", [])),
"damage": _mean(r["damage"] for r in rows), "abstain": ab["abstain_accuracy"],
"n": len(rows), "by_et": {k: _mean(v) for k, v in by_et.items()}}
def main(seeds=(7, 17, 27), out: str | None = None) -> None:
from scrubdata.baselines import openrefine_fingerprint_plan, openrefine_knn_plan
systems = [("grounded (ours)", mock_plan),
("OpenRefine fingerprint", openrefine_fingerprint_plan),
("OpenRefine kNN", openrefine_knn_plan),
("no-op", lambda df: {"table_operations": [], "columns": [], "flags": []})]
print("\n=== Cleaning north-star — WIDE validation suite (Raha + injected harvested), "
f"{len(seeds)} seeds ===\n")
print(f"{'system':<24}{'NORTH*':>9}{'±95%CI':>9}{'REAL-F1':>9}{'INJ-F1':>8}"
f"{'damage':>8}{'abstain':>9}")
print("-" * 76)
table = []
for name, planner in systems:
norths, results = [], []
for s in seeds:
r = evaluate_suite(planner, seed=s)
norths.append(r["north"]); results.append(r)
mean_n = _mean(norths)
# 95% CI ~ 1.96 * std / sqrt(n)
var = _mean([(x - mean_n) ** 2 for x in norths])
ci = 1.96 * (var ** 0.5) / (len(norths) ** 0.5)
last = results[-1]
row = {"system": name, "north": mean_n, "north_ci": ci,
"real_f1": _mean(r["real"] for r in results),
"real_f1_per_seed": [r["real"] for r in results],
"inj_f1": _mean(r["injected"] for r in results),
"damage": _mean(r["damage"] for r in results),
"abstain": _mean(r["abstain"] for r in results),
"n_datasets": last["n"], "seeds": list(seeds)}
table.append(row)
print(f"{name:<24}{mean_n:>9.3f}{ci:>9.3f}{row['real_f1']:>9.3f}"
f"{row['inj_f1']:>8.3f}{row['damage']:>8.3f}{row['abstain']:>9.3f}", flush=True)
if out:
import json
json.dump(table, open(out, "w"), indent=1)
print(f"rows written to {out}")
print(f"\nNORTH* = harmonic mean(error-type macro, domain macro) over {last['n']} datasets. "
"Double-macro + damage + abstain = un-gameable; hospital is 1 dataset of many. "
"The money result: grounded vs the tool people actually use (OpenRefine).")
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", type=str, default=None, help="write table rows to JSON")
main(out=ap.parse_args().out)
|