aditya0103's picture
Tasks 5-7: eval harness, FastAPI backend, Paper & Ink UI- src/eval/ — precision/recall/F1 harness with type-aware comparators,micro/macro F1, CSV + markdown reports, --model benchmark flag- src/api/ — FastAPI backend with /extract, /schemas, /health,request-ID middleware, typed error envelope, injectable extractor- ui/ — Vite + React + TS + Tailwind + Motion + React Three FiberPaper & Ink editorial UI with 3D paper hero, dark/light mode,confidence inkwell, wax-stamp metrics, kinetic typography- 95 passing tests (up from 54); UI is a separate npm workspace
557ab38
Raw
History Blame Contribute Delete
6.06 kB
"""Metrics aggregation: TP/FP/FN counters, precision/recall/F1.
Field-level classification per (doc, field):
- TP: both non-null AND comparator matches
- FP: prediction non-null AND (truth is null OR comparator fails)
- FN: truth non-null AND (prediction is null OR comparator fails)
- TN: both null (NOT counted — trivial)
Note: a "wrong" prediction counts as BOTH FP and FN by convention. This
matches how NER / structured-extraction leaderboards score mismatches
(over-count once as a wrong prediction and once as a missed truth).
At the document level, `exact_match` is True iff every field in the doc is
TP or TN.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from src.eval.comparators import compare
from src.eval.flatten import FieldMap
@dataclass
class FieldStat:
"""Aggregate stats for one field across all docs."""
field: str
field_type: str
tp: int = 0
fp: int = 0
fn: int = 0
tn: int = 0
@property
def support(self) -> int:
"""Number of docs where truth was non-null (denominator for recall)."""
return self.tp + self.fn
@property
def precision(self) -> float:
denom = self.tp + self.fp
return self.tp / denom if denom else 0.0
@property
def recall(self) -> float:
denom = self.tp + self.fn
return self.tp / denom if denom else 0.0
@property
def f1(self) -> float:
p, r = self.precision, self.recall
return 2 * p * r / (p + r) if (p + r) else 0.0
def to_dict(self) -> dict[str, Any]:
return {
"field": self.field,
"field_type": self.field_type,
"tp": self.tp,
"fp": self.fp,
"fn": self.fn,
"tn": self.tn,
"support": self.support,
"precision": round(self.precision, 4),
"recall": round(self.recall, 4),
"f1": round(self.f1, 4),
}
@dataclass
class DocStat:
"""Per-document stats."""
doc_id: str
fields_correct: int = 0
fields_total: int = 0
exact_match: bool = False
per_field: list[dict[str, Any]] = field(default_factory=list)
latency_ms: float = 0.0
cost_usd: float = 0.0
error: str | None = None
def score_doc(
doc_id: str,
predicted: FieldMap,
truth: FieldMap,
) -> tuple[DocStat, dict[str, tuple[int, int, int, int]]]:
"""Score one document. Returns (DocStat, {field -> (tp, fp, fn, tn)})."""
# Union of paths — every field either side reported.
all_paths = sorted(set(predicted) | set(truth))
per_field_counts: dict[str, tuple[int, int, int, int]] = {}
stat = DocStat(doc_id=doc_id)
fields_ok = 0
fields_scored = 0
exact = True
for path in all_paths:
p_val, p_type = predicted.get(path, (None, "exact"))
t_val, t_type = truth.get(path, (None, "exact"))
field_type = t_type if path in truth else p_type
p_null = p_val is None
t_null = t_val is None
if p_null and t_null:
per_field_counts[path] = (0, 0, 0, 1) # TN — trivial
continue
fields_scored += 1
matched, score = compare(p_val, t_val, field_type)
if matched and not p_null and not t_null:
per_field_counts[path] = (1, 0, 0, 0)
fields_ok += 1
outcome = "TP"
elif p_null and not t_null:
per_field_counts[path] = (0, 0, 1, 0)
exact = False
outcome = "FN"
elif t_null and not p_null:
per_field_counts[path] = (0, 1, 0, 0)
exact = False
outcome = "FP"
else:
# both non-null but comparator says no
per_field_counts[path] = (0, 1, 1, 0)
exact = False
outcome = "MISMATCH"
stat.per_field.append(
{
"field": path,
"field_type": field_type,
"predicted": _stringify(p_val),
"truth": _stringify(t_val),
"outcome": outcome,
"score": round(score, 3),
}
)
stat.fields_correct = fields_ok
stat.fields_total = fields_scored
stat.exact_match = exact and fields_scored > 0
return stat, per_field_counts
def aggregate(
per_doc_counts: list[dict[str, tuple[int, int, int, int]]],
field_types: dict[str, str],
) -> dict[str, FieldStat]:
"""Sum per-doc counts into FieldStat objects keyed by field path."""
stats: dict[str, FieldStat] = {}
for doc_counts in per_doc_counts:
for path, (tp, fp, fn, tn) in doc_counts.items():
if path not in stats:
stats[path] = FieldStat(field=path, field_type=field_types.get(path, "exact"))
s = stats[path]
s.tp += tp
s.fp += fp
s.fn += fn
s.tn += tn
return stats
def micro_macro(stats: dict[str, FieldStat]) -> dict[str, float]:
"""Compute micro-F1 (pool all counts) and macro-F1 (mean of per-field F1)."""
if not stats:
return {"micro_precision": 0, "micro_recall": 0, "micro_f1": 0, "macro_f1": 0}
tp = sum(s.tp for s in stats.values())
fp = sum(s.fp for s in stats.values())
fn = sum(s.fn for s in stats.values())
micro_p = tp / (tp + fp) if (tp + fp) else 0.0
micro_r = tp / (tp + fn) if (tp + fn) else 0.0
micro_f1 = 2 * micro_p * micro_r / (micro_p + micro_r) if (micro_p + micro_r) else 0.0
supported = [s for s in stats.values() if s.support > 0]
macro_f1 = sum(s.f1 for s in supported) / len(supported) if supported else 0.0
return {
"micro_precision": round(micro_p, 4),
"micro_recall": round(micro_r, 4),
"micro_f1": round(micro_f1, 4),
"macro_f1": round(macro_f1, 4),
}
def _stringify(v: Any) -> str:
if v is None:
return ""
if isinstance(v, float):
return f"{v:.4f}".rstrip("0").rstrip(".")
return str(v)