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