forked from ChelseaKR/constituent-reconciler
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_evaluate.py
More file actions
344 lines (262 loc) · 12.3 KB
/
Copy pathtest_evaluate.py
File metadata and controls
344 lines (262 loc) · 12.3 KB
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
342
343
344
from __future__ import annotations
import json
from pathlib import Path
import pytest
from constituent_reconciler.decisions import band_pairs
from constituent_reconciler.evaluate import (
ExtractionReport,
calibrate,
cohen_kappa,
evaluate,
extraction_metrics,
f1_score,
normalize_extracted_value,
truth_pairs,
wilson_interval,
)
from constituent_reconciler.extract.base import ExtractedField
from constituent_reconciler.models import Band, Pair
def _labels(pairs: list[tuple[bool, bool]]) -> list[dict[str, object]]:
return [
{"record_id": f"R{i:03d}", "field": "email", "predicted": p, "actual": a}
for i, (p, a) in enumerate(pairs)
]
def test_wilson_zero_successes() -> None:
low, high = wilson_interval(0, 10)
assert low == 0.0
assert 0.25 < high < 0.35
def test_wilson_half() -> None:
low, high = wilson_interval(5, 10)
assert low < 0.5 < high
def test_wilson_no_trials_is_widest() -> None:
assert wilson_interval(0, 0) == (0.0, 1.0)
def test_truth_pairs_expands_clusters() -> None:
pairs = truth_pairs([["a", "b", "c"]])
assert pairs == {
frozenset(("a", "b")),
frozenset(("a", "c")),
frozenset(("b", "c")),
}
def test_cohen_kappa_perfect_agreement() -> None:
assert cohen_kappa([True, True, False], [True, True, False]) == pytest.approx(1.0)
def test_cohen_kappa_chance_agreement() -> None:
# All positives: p_expected = 1, kappa is 0.0 by the undefined-guard.
assert cohen_kappa([True, True], [True, True]) == 0.0
def test_cohen_kappa_half_agreement() -> None:
# predicted [T, F, T, F], actual [T, T, F, F] -> 2/4 agree
kappa = cohen_kappa([True, False, True, False], [True, True, False, False])
assert -0.1 < kappa < 0.1
def test_cohen_kappa_better_than_chance() -> None:
# predicted and actual agree more than chance
kappa = cohen_kappa([True, True, False, False], [True, True, False, False])
assert kappa == pytest.approx(1.0)
def test_cohen_kappa_length_mismatch_raises() -> None:
with pytest.raises(ValueError):
cohen_kappa([True], [True, False])
def test_cohen_kappa_empty_raises() -> None:
with pytest.raises(ValueError):
cohen_kappa([], [])
def test_calibrate_passes_above_gate() -> None:
# 9 true agreements, 9 false agreements, 2 disagreements: kappa = 0.80.
pairs = [(True, True)] * 9 + [(False, False)] * 9 + [(True, False), (False, True)]
report = calibrate(_labels(pairs))
assert report.n_labels == 20
assert report.kappa == pytest.approx(0.80)
assert report.threshold == pytest.approx(0.60)
assert report.passed
def test_calibrate_fails_below_gate() -> None:
# 6 true agreements, 6 false agreements, 8 disagreements: kappa = 0.20.
pairs = [(True, True)] * 6 + [(False, False)] * 6 + [(True, False), (False, True)] * 4
report = calibrate(_labels(pairs))
assert report.kappa == pytest.approx(0.20)
assert not report.passed
def test_calibrate_boundary_kappa_at_gate_passes() -> None:
# 8 true agreements, 8 false agreements, 4 disagreements: kappa = 0.60 exactly.
pairs = [(True, True)] * 8 + [(False, False)] * 8 + [(True, False), (False, True)] * 2
report = calibrate(_labels(pairs))
assert report.kappa == pytest.approx(0.60)
assert report.passed
def test_calibrate_empty_labels_raises() -> None:
with pytest.raises(ValueError):
calibrate([])
def test_calibrate_malformed_label_raises() -> None:
with pytest.raises(ValueError):
calibrate([{"record_id": "R001", "field": "email", "predicted": "yes", "actual": True}])
def test_evaluate_counts_false_merge_and_coverage() -> None:
banded = band_pairs(
[("a", "b", 0.99), ("a", "c", 0.85), ("x", "y", 0.99)],
auto_threshold=0.97,
review_threshold=0.80,
)
# Truth: a-b and a-c are duplicates; x-y is not.
report = evaluate(banded, [["a", "b"], ["a", "c"]], n_records=5)
assert report.n_true_pairs == 2
assert report.n_auto == 2
# x-y was auto-merged but is not a true duplicate: one false merge.
assert report.false_merges == 1
# a-c is a true duplicate sitting in review, so coverage misses nothing.
assert report.missed == 0
assert report.recall_coverage == 1.0
def test_evaluate_disaggregates_documented_risk_classes() -> None:
banded = band_pairs(
[("a", "b", 0.85), ("c", "d", 0.50)],
auto_threshold=0.97,
review_threshold=0.80,
)
report = evaluate(
banded,
[["a", "b"], ["c", "d"]],
n_records=4,
segments={
"hyphenated surname": [["a", "b"]],
"rural route": [["c", "d"]],
},
)
scores = {segment.name: segment for segment in report.segments}
assert scores["hyphenated surname"].coverage_recall == 1.0
assert scores["hyphenated surname"].n_surfaced == 1
assert scores["rural route"].coverage_recall == 0.0
assert scores["rural route"].n_missed == 1
assert scores["rural route"].blocking_misses == 0
def test_evaluate_rejects_segment_pair_outside_ground_truth() -> None:
with pytest.raises(ValueError, match="is not ground truth"):
evaluate(
[],
[["a", "b"]],
n_records=3,
segments={"invalid": [["a", "c"]]},
)
# ---------------------------------------------------------------------------
# Extraction metrics
# ---------------------------------------------------------------------------
def _ef(field_name: str, value: str) -> ExtractedField:
return ExtractedField(field_name=field_name, value=value, confidence=1.0)
def _label(field_name: str, value: str) -> dict[str, str]:
return {"field_name": field_name, "value": value}
def test_normalize_extracted_value_uses_canonical_normalizers() -> None:
assert normalize_extracted_value("phone", "(415) 555-0100") == "4155550100"
assert normalize_extracted_value("dob", "03/09/1988") == "1988-03-09"
assert normalize_extracted_value("first_name", " O'Brien ") == "obrien"
assert normalize_extracted_value("email", "A@Example.ORG") == "a@example.org"
def test_normalize_extracted_value_falls_back_on_unknown_or_unparseable() -> None:
# Unknown field: whitespace-collapsed casefold, not the name normalizer.
assert normalize_extracted_value("notes", " Two Words ") == "two words"
# Known field the canonical normalizer cannot parse: same fallback, so two
# identical raw strings still compare equal instead of collapsing to "".
assert normalize_extracted_value("dob", "unknown") == "unknown"
def test_extraction_metrics_perfect_match_despite_formatting() -> None:
predicted = {"a.pdf": [_ef("phone", "555.123.4567"), _ef("dob", "1970-05-12")]}
truth = {"a.pdf": [_label("phone", "(555) 123-4567"), _label("dob", "05/12/1970")]}
report = extraction_metrics(predicted, truth)
assert (report.tp, report.fp, report.fn) == (2, 0, 0)
assert report.precision == 1.0
assert report.recall == 1.0
assert report.n_docs == 1
def test_extraction_metrics_counts_fp_and_fn_per_field() -> None:
predicted = {
"a.pdf": [
_ef("first_name", "Alice"),
_ef("phone", "555-000-1111"), # wrong value: FP for phone, FN for truth
_ef("email", "stray@example.org"), # not labeled at all: FP
]
}
truth = {
"a.pdf": [
_label("first_name", "Alice"),
_label("phone", "555-123-4567"),
_label("dob", "1970-05-12"), # never predicted: FN
]
}
report = extraction_metrics(predicted, truth)
assert (report.tp, report.fp, report.fn) == (1, 2, 2)
assert report.per_field["first_name"].tp == 1
assert report.per_field["phone"].fp == 1
assert report.per_field["phone"].fn == 1
assert report.per_field["email"].fp == 1
assert report.per_field["dob"].fn == 1
assert report.precision == pytest.approx(1 / 3)
assert report.recall == pytest.approx(1 / 3)
def test_extraction_metrics_empty_truth_makes_all_predictions_fp() -> None:
predicted = {"a.pdf": [_ef("email", "x@example.org")]}
report = extraction_metrics(predicted, {})
assert (report.tp, report.fp, report.fn) == (0, 1, 0)
assert report.precision == 0.0
# No truth fields: recall is 1.0 by the empty-denominator convention, and
# the Wilson interval is the widest honest (0, 1).
assert report.recall == 1.0
assert report.recall_ci == (0.0, 1.0)
def test_extraction_metrics_no_docs_at_all() -> None:
report = extraction_metrics({}, {})
assert report.n_docs == 0
assert report.precision == 1.0
assert report.recall == 1.0
assert report.precision_ci == (0.0, 1.0)
assert report.recall_ci == (0.0, 1.0)
def test_extraction_metrics_truth_label_claimed_once() -> None:
# Two identical predictions against one label: one TP, one FP.
predicted = {"a.pdf": [_ef("email", "x@example.org"), _ef("email", "x@example.org")]}
truth = {"a.pdf": [_label("email", "x@example.org")]}
report = extraction_metrics(predicted, truth)
assert (report.tp, report.fp, report.fn) == (1, 1, 0)
def test_extraction_metrics_does_not_match_across_documents() -> None:
predicted = {"a.pdf": [_ef("email", "x@example.org")], "b.pdf": []}
truth = {"b.pdf": [_label("email", "x@example.org")]}
report = extraction_metrics(predicted, truth)
assert (report.tp, report.fp, report.fn) == (0, 1, 1)
assert report.n_docs == 2
# ---------------------------------------------------------------------------
# The committed labeled fixture meets the metrics-ledger targets
# ---------------------------------------------------------------------------
_FIXTURES = Path(__file__).resolve().parents[1] / "eval" / "fixtures" / "extraction"
def _score_committed_fixtures() -> tuple[int, ExtractionReport]:
from constituent_reconciler.extract.pdf import PdfplumberExtractor
labels = json.loads((_FIXTURES / "labels.json").read_text(encoding="utf-8"))
extractor = PdfplumberExtractor()
predicted: dict[str, list[ExtractedField]] = {}
pdf_paths = sorted(_FIXTURES.glob("*.pdf"))
for pdf_path in pdf_paths:
result = extractor.extract(pdf_path)
predicted[pdf_path.name] = [f for page in result.pages for f in page.fields]
return len(pdf_paths), extraction_metrics(predicted, labels)
def test_committed_fixture_meets_ledger_targets() -> None:
pytest.importorskip("pdfplumber", reason="pdfplumber not installed")
n_pdfs, report = _score_committed_fixtures()
# Every committed PDF must be labeled and scored.
assert n_pdfs >= 3
assert report.n_docs == n_pdfs
# The metrics-ledger REVIEW targets: keep the committed fixture honest.
assert report.precision >= 0.95
assert report.recall >= 0.90
# The planted worded-date miss keeps recall measurably below 100%.
assert report.fn >= 1
def test_eval_extraction_cli_writes_report(tmp_path: Path) -> None:
pytest.importorskip("pdfplumber", reason="pdfplumber not installed")
from constituent_reconciler.cli import main
out = tmp_path / "extraction-report.md"
exit_code = main(["eval-extraction", "--fixtures", str(_FIXTURES), "--out", str(out)])
assert exit_code == 0
content = out.read_text(encoding="utf-8")
assert "# Extraction eval report" in content
assert "Per-field breakdown" in content
assert "**MET**" in content
def test_f1_is_the_harmonic_mean() -> None:
assert f1_score(1.0, 1.0) == pytest.approx(1.0)
assert f1_score(0.5, 1.0) == pytest.approx(2 / 3)
assert f1_score(0.993, 0.771) == pytest.approx(0.868, abs=5e-4)
def test_f1_of_zero_precision_and_recall_is_zero_not_a_division_error() -> None:
assert f1_score(0.0, 0.0) == 0.0
def test_evaluate_reports_f1_consistent_with_its_precision_and_recall() -> None:
pairs = [
Pair("a", "b", 12.0, Band.AUTO),
Pair("c", "d", 9.0, Band.REVIEW),
Pair("e", "f", 9.0, Band.REVIEW),
]
report = evaluate(pairs, [["a", "b"], ["c", "d"], ["g", "h"]], n_records=8)
assert report.f1_auto == pytest.approx(f1_score(report.precision_auto, report.recall_auto))
assert report.f1_coverage == pytest.approx(
f1_score(report.precision_coverage, report.recall_coverage)
)
# One of two auto/review pairs is a true duplicate at the auto band, and the
# third true pair was never surfaced, so neither F1 is degenerate.
assert 0.0 < report.f1_auto < 1.0
assert 0.0 < report.f1_coverage < 1.0