File size: 13,705 Bytes
13fe504 | 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 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | """Per-error-class measurement instrument for the DataForge benchmark.
This module is the honesty foundation for the coverage work: it makes visible
*which* error classes the repair stack catches and misses, instead of a single
aggregate F1 that one strong dataset can mask. It provides:
- A transparent, documented ground-truth labeler (:func:`classify_error_cell`)
that maps each ``(dirty, clean)`` correction to a canonical error class.
- Per-class precision/recall scoring (:func:`score_repairs_by_class`).
- A coverage matrix + regression gate over benchmark records.
- Calibration utilities (ECE, precision@auto-apply) used by the calibration
layer once confidence-bearing predictions exist.
The labeler is a heuristic, and is deliberately simple and inspectable. It is
versioned (:data:`LABELER_VERSION`) so reported numbers are reproducible and any
change to the labeling rules is explicit. Per-class *recall* is objective (it
counts ground-truth cells correctly repaired); per-class *precision* is reported
only for predictions that land on a labeled ground-truth cell.
"""
from __future__ import annotations
import re
from collections import OrderedDict
from typing import Any
from dataforge.bench.core import BenchmarkRepair, ClassScore, normalize_repairs
from dataforge.datasets.real_world import GroundTruthCell
__all__ = [
"BENCH_ERROR_CLASSES",
"LABELER_VERSION",
"classify_error_cell",
"class_coverage_matrix",
"score_repairs_by_class",
"expected_calibration_error",
"precision_at_auto_apply",
"check_coverage_regression",
]
LABELER_VERSION = "v1"
# Canonical, mutually exclusive error classes the labeler assigns. Chosen to
# align with the RAHA dataset error taxonomy (typo, missing_value, formatting,
# datetime, normalization) and the DataForge detector families.
BENCH_ERROR_CLASSES: tuple[str, ...] = (
"missing_value",
"numeric",
"datetime_format",
"value_format",
"text_normalization",
"other",
)
_MISSING_SENTINELS = frozenset(
{"", "n/a", "na", "null", "none", "nan", "nil", "-", "unknown", "not available", "?"}
)
_DATE_PATTERN = re.compile(r"^\s*\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}([ T]\d{1,2}:\d{2}(:\d{2})?)?\s*$")
_ALNUM = re.compile(r"[0-9a-z]+")
def _is_missing(value: str) -> bool:
"""Return whether a value reads as missing/sentinel."""
return value.strip().lower() in _MISSING_SENTINELS
def _is_number(value: str) -> bool:
"""Return whether a value parses as a float."""
try:
float(value.replace(",", "").strip())
except (TypeError, ValueError):
return False
return True
def _looks_like_date(value: str) -> bool:
"""Return whether a value matches a common date/datetime shape."""
return bool(_DATE_PATTERN.match(value))
def _alnum_skeleton(value: str) -> str:
"""Return the lowercase alphanumeric content, ignoring punctuation/spacing."""
return "".join(_ALNUM.findall(value.lower()))
def _strip_ws(value: str) -> str:
"""Return the value with all whitespace removed."""
return re.sub(r"\s+", "", value)
def classify_error_cell(dirty_value: str, clean_value: str) -> str:
"""Classify a single dirty-to-clean correction into a canonical error class.
The rules are ordered and documented (labeler ``v1``):
1. ``missing_value`` - the dirty cell is blank/sentinel but the clean cell
holds a real value (or vice versa).
2. ``numeric`` - both dirty and clean parse as numbers.
3. ``datetime_format`` - the clean (or dirty) value looks like a date/datetime.
4. ``text_normalization`` - dirty and clean are equal after removing case and
whitespace, OR are a near-typo (Levenshtein distance <= 2).
5. ``value_format`` - same alphanumeric content but a punctuation/structure
reformat (e.g. phone "15551234567" -> "+1 (555) 123-4567"), or a large
formatting overlap not captured above.
6. ``other`` - anything else (semantic replacement, lookup correction).
Args:
dirty_value: The erroneous value.
clean_value: The ground-truth corrected value.
Returns:
One of :data:`BENCH_ERROR_CLASSES`.
"""
if _is_missing(dirty_value) != _is_missing(clean_value):
return "missing_value"
if _is_number(dirty_value) and _is_number(clean_value):
return "numeric"
if _looks_like_date(clean_value) or _looks_like_date(dirty_value):
return "datetime_format"
# Pure case/whitespace difference (no punctuation reformat).
if _strip_ws(dirty_value).lower() == _strip_ws(clean_value).lower() and _strip_ws(dirty_value):
return "text_normalization"
dirty_skeleton = _alnum_skeleton(dirty_value)
clean_skeleton = _alnum_skeleton(clean_value)
# Same alphanumeric content but a punctuation/structure reformat (e.g. phone).
if dirty_skeleton == clean_skeleton and dirty_skeleton:
return "value_format"
# Near-typo on short strings.
if _levenshtein_le(dirty_value.strip().lower(), clean_value.strip().lower(), 2):
return "text_normalization"
if dirty_skeleton and clean_skeleton and _alnum_overlap(dirty_skeleton, clean_skeleton):
return "value_format"
return "other"
def _alnum_overlap(left: str, right: str) -> bool:
"""Return whether two skeletons share most of their characters (format diff)."""
shorter, longer = (left, right) if len(left) <= len(right) else (right, left)
if not shorter:
return False
return shorter in longer or _levenshtein_le(left, right, max(2, len(longer) // 3))
def _levenshtein_le(a: str, b: str, max_distance: int) -> bool:
"""Return whether the edit distance between a and b is <= max_distance."""
if abs(len(a) - len(b)) > max_distance:
return False
previous = list(range(len(b) + 1))
for i, char_a in enumerate(a, start=1):
current = [i]
row_min = i
for j, char_b in enumerate(b, start=1):
cost = 0 if char_a == char_b else 1
value = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost)
current.append(value)
row_min = min(row_min, value)
if row_min > max_distance:
return False
previous = current
return previous[-1] <= max_distance
def score_repairs_by_class(
ground_truth: tuple[GroundTruthCell, ...] | list[GroundTruthCell],
repairs: list[BenchmarkRepair],
detected_cells: set[tuple[int, str]] | None = None,
) -> dict[str, ClassScore]:
"""Score repairs (and optionally detections) per error class.
Args:
ground_truth: Cell-level dirty-to-clean corrections.
repairs: Predicted repairs (normalized last-write-wins per cell).
detected_cells: ``(row, column)`` cells a detector flagged, regardless of
repair. When provided, detection recall is scored per class; when
``None``, detection metrics are zero (correction-only mode).
Returns:
A mapping from error class to :class:`ClassScore` for every class that
has ground-truth support.
"""
gt_class: dict[tuple[int, str], str] = {}
gt_clean: dict[tuple[int, str], str] = {}
support: OrderedDict[str, int] = OrderedDict((cls, 0) for cls in BENCH_ERROR_CLASSES)
for cell in ground_truth:
key = (cell.row, cell.column)
cls = classify_error_cell(cell.dirty_value, cell.clean_value)
gt_class[key] = cls
gt_clean[key] = cell.clean_value
support[cls] += 1
tp_by_class: dict[str, int] = dict.fromkeys(BENCH_ERROR_CLASSES, 0)
predicted_on_class: dict[str, int] = dict.fromkeys(BENCH_ERROR_CLASSES, 0)
correct_on_class: dict[str, int] = dict.fromkeys(BENCH_ERROR_CLASSES, 0)
detected_by_class: dict[str, int] = dict.fromkeys(BENCH_ERROR_CLASSES, 0)
if detected_cells is not None:
for key in detected_cells:
detected_cls = gt_class.get(key)
if detected_cls is not None:
detected_by_class[detected_cls] += 1
for repair in normalize_repairs(repairs):
key = (repair.row, repair.column)
repair_cls = gt_class.get(key)
if repair_cls is None:
continue # spurious prediction on a non-error cell; not class-attributable
predicted_on_class[repair_cls] += 1
if repair.new_value == gt_clean[key]:
correct_on_class[repair_cls] += 1
tp_by_class[repair_cls] += 1
scores: dict[str, ClassScore] = {}
for cls in BENCH_ERROR_CLASSES:
cls_support = support[cls]
if cls_support == 0 and predicted_on_class[cls] == 0 and detected_by_class[cls] == 0:
continue
tp = tp_by_class[cls]
fn = cls_support - tp
recall = tp / cls_support if cls_support else 0.0
predicted = predicted_on_class[cls]
precision = correct_on_class[cls] / predicted if predicted else 0.0
detected = detected_by_class[cls]
detection_recall = detected / cls_support if cls_support else 0.0
scores[cls] = ClassScore(
error_class=cls,
support=cls_support,
detected=detected,
detection_recall=round(detection_recall, 4),
tp=tp,
fn=fn,
recall=round(recall, 4),
predicted_on_class=predicted,
precision_on_class=round(precision, 4),
)
return scores
def class_coverage_matrix(
records: list[Any], # list[SeedBenchmarkResult]; typed loosely to avoid an import cycle
) -> dict[tuple[str, str], dict[str, ClassScore]]:
"""Collapse seed records into a ``(method, dataset) -> {class: ClassScore}`` matrix.
Deterministic methods produce identical per-class scores across seeds, so the
first OK record per ``(method, dataset)`` with ``by_class`` is used.
"""
matrix: dict[tuple[str, str], dict[str, ClassScore]] = {}
for record in records:
if getattr(record, "status", None) != "ok" or record.by_class is None:
continue
key = (record.method, record.dataset)
matrix.setdefault(key, record.by_class)
return matrix
def expected_calibration_error(samples: list[tuple[float, bool]], *, bins: int = 10) -> float:
"""Compute the Expected Calibration Error of confidence-labeled predictions.
Args:
samples: ``(confidence, was_correct)`` pairs with confidence in [0, 1].
bins: Number of equal-width confidence bins.
Returns:
ECE in [0, 1]; 0.0 for an empty input.
"""
if not samples:
return 0.0
bin_totals = [0] * bins
bin_conf = [0.0] * bins
bin_correct = [0] * bins
for confidence, correct in samples:
clamped = min(max(confidence, 0.0), 1.0)
index = min(int(clamped * bins), bins - 1)
bin_totals[index] += 1
bin_conf[index] += clamped
bin_correct[index] += 1 if correct else 0
total = len(samples)
ece = 0.0
for index in range(bins):
count = bin_totals[index]
if count == 0:
continue
avg_conf = bin_conf[index] / count
accuracy = bin_correct[index] / count
ece += (count / total) * abs(avg_conf - accuracy)
return round(ece, 4)
def precision_at_auto_apply(samples: list[tuple[bool, bool]]) -> float:
"""Precision among predictions the policy chose to auto-apply.
Args:
samples: ``(auto_applied, was_correct)`` pairs.
Returns:
correct / auto_applied, or 1.0 when nothing was auto-applied (vacuously
safe: the tool corrupted nothing).
"""
applied = [correct for auto_applied, correct in samples if auto_applied]
if not applied:
return 1.0
return round(sum(1 for c in applied if c) / len(applied), 4)
def check_coverage_regression(
records: list[Any], # list[SeedBenchmarkResult]
thresholds: dict[str, dict[str, float]],
) -> tuple[bool, list[str]]:
"""Check per-(method/dataset/class) recall floors against committed thresholds.
A class key may carry an ``@detection`` suffix to assert a *detection* recall
floor (did we flag the error); a plain class key asserts a *correction* recall
floor (did we produce the exact value).
Args:
records: Benchmark seed records carrying ``by_class``.
thresholds: ``{"method/dataset": {"error_class[@detection]": min_recall}}``.
Returns:
``(passed, failures)`` where failures describe each floor that was missed.
"""
matrix = class_coverage_matrix(records)
failures: list[str] = []
for key, class_floors in thresholds.items():
method, _, dataset = key.partition("/")
scores = matrix.get((method, dataset))
if scores is None:
failures.append(f"{key}: no benchmark record produced per-class scores")
continue
for raw_class, min_recall in class_floors.items():
error_class, _, mode = raw_class.partition("@")
score = scores.get(error_class)
if mode == "detection":
actual = score.detection_recall if score is not None else 0.0
else:
actual = score.recall if score is not None else 0.0
if actual + 1e-9 < min_recall:
failures.append(f"{key}/{raw_class}: recall {actual:.4f} < floor {min_recall:.4f}")
return (not failures), failures
|