| from __future__ import annotations |
|
|
| import math |
| import re |
| import statistics |
| from collections import Counter |
| from collections.abc import Sequence |
| from dataclasses import dataclass |
| from typing import Literal |
|
|
| EvaluationLabel = Literal["clean", "typo", "unspecified"] |
| PredictionProvenance = Literal["provider", "deberta", "lfm", "rule", "unspecified"] |
| _REASON_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,63}$") |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class EvaluationItem: |
| item_id: str |
| input_text: str |
| references: tuple[str, ...] |
| label: EvaluationLabel = "unspecified" |
|
|
| def __post_init__(self) -> None: |
| if not self.item_id: |
| raise ValueError("item_id must not be empty") |
| if not self.references or any(not reference for reference in self.references): |
| raise ValueError("references must contain non-empty strings") |
| if self.label not in ("clean", "typo", "unspecified"): |
| raise ValueError(f"unsupported evaluation label: {self.label}") |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class Prediction: |
| item_id: str |
| candidates: tuple[str, ...] |
| provenance: PredictionProvenance = "unspecified" |
| reason: str | None = None |
| margin: float | None = None |
|
|
| def __post_init__(self) -> None: |
| if not self.item_id: |
| raise ValueError("prediction item_id must not be empty") |
| if any(not candidate for candidate in self.candidates): |
| raise ValueError("prediction candidates must not contain empty strings") |
| if len(set(self.candidates)) != len(self.candidates): |
| raise ValueError("prediction candidates must be unique") |
| if self.provenance not in ("provider", "deberta", "lfm", "rule", "unspecified"): |
| raise ValueError(f"unsupported prediction provenance: {self.provenance}") |
| if self.reason is not None and not _REASON_PATTERN.fullmatch(self.reason): |
| raise ValueError("prediction reason must be a lowercase machine-readable label") |
| if self.margin is not None and not math.isfinite(self.margin): |
| raise ValueError("prediction margin must be finite") |
|
|
|
|
| def character_edit_distance(left: str, right: str) -> int: |
| if len(left) < len(right): |
| left, right = right, left |
| previous = list(range(len(right) + 1)) |
| for left_index, left_character in enumerate(left, start=1): |
| current = [left_index] |
| for right_index, right_character in enumerate(right, start=1): |
| current.append( |
| min( |
| current[-1] + 1, |
| previous[right_index] + 1, |
| previous[right_index - 1] + (left_character != right_character), |
| ) |
| ) |
| previous = current |
| return previous[-1] |
|
|
|
|
| def minimum_cer(value: str, references: Sequence[str]) -> float: |
| return min( |
| character_edit_distance(value, reference) / len(reference) for reference in references |
| ) |
|
|
|
|
| def evaluate_predictions( |
| items: Sequence[EvaluationItem], |
| predictions: Sequence[Prediction], |
| *, |
| candidate_limit: int, |
| ) -> dict[str, object]: |
| if candidate_limit < 1: |
| raise ValueError("candidate_limit must be positive") |
| if not items: |
| raise ValueError("evaluation items must not be empty") |
| item_ids = [item.item_id for item in items] |
| if len(set(item_ids)) != len(item_ids): |
| raise ValueError("duplicate item_id in evaluation items") |
| prediction_ids = [prediction.item_id for prediction in predictions] |
| if len(set(prediction_ids)) != len(prediction_ids): |
| raise ValueError("duplicate prediction item_id") |
| unknown_ids = set(prediction_ids).difference(item_ids) |
| if unknown_ids: |
| raise ValueError(f"prediction has unknown item_id: {min(unknown_ids)}") |
| for item in items: |
| if item.label == "clean" and item.input_text not in item.references: |
| raise ValueError(f"clean item input must be a reference: {item.item_id}") |
| prediction_by_id = {prediction.item_id: prediction for prediction in predictions} |
| cers: list[float] = [] |
| baseline_cers: list[float] = [] |
| correct = 0 |
| abstentions = 0 |
| candidate_recall = 0 |
| accepted_rows = 0 |
| accepted_correct = 0 |
| clean_rows = 0 |
| overcorrections = 0 |
| typo_rows = 0 |
| typo_correct = 0 |
| improved_rows = 0 |
| worsened_rows = 0 |
| unchanged_rows = 0 |
| accepted_candidate_miss_rows = 0 |
| selection_error_rows = 0 |
| provenance_counts: Counter[str] = Counter() |
| selection_reason_counts: Counter[str] = Counter() |
| reported_margins: list[float] = [] |
| for item in items: |
| prediction = prediction_by_id.get(item.item_id) |
| candidates = prediction.candidates[:candidate_limit] if prediction else () |
| if prediction is not None: |
| provenance_counts[prediction.provenance] += 1 |
| if prediction.reason is not None: |
| selection_reason_counts[prediction.reason] += 1 |
| if prediction.margin is not None: |
| reported_margins.append(prediction.margin) |
| abstentions += not candidates |
| accepted_rows += bool(candidates) |
| has_reference_candidate = any(candidate in item.references for candidate in candidates) |
| candidate_recall += has_reference_candidate |
| effective_output = candidates[0] if candidates else item.input_text |
| is_correct = effective_output in item.references |
| accepted_candidate_miss_rows += bool(candidates) and not has_reference_candidate |
| selection_error_rows += bool(candidates) and has_reference_candidate and not is_correct |
| correct += is_correct |
| accepted_correct += bool(candidates) and is_correct |
| baseline_cer = minimum_cer(item.input_text, item.references) |
| effective_cer = minimum_cer(effective_output, item.references) |
| baseline_cers.append(baseline_cer) |
| cers.append(effective_cer) |
| if effective_cer < baseline_cer: |
| improved_rows += 1 |
| elif effective_cer > baseline_cer: |
| worsened_rows += 1 |
| else: |
| unchanged_rows += 1 |
| if item.label == "clean": |
| clean_rows += 1 |
| overcorrections += effective_output != item.input_text |
| elif item.label == "typo": |
| typo_rows += 1 |
| typo_correct += is_correct |
| rows = len(items) |
| return { |
| "rows": rows, |
| "candidate_limit": candidate_limit, |
| "metrics": { |
| "effective_acc_at_1": correct / rows if rows else 0.0, |
| "candidate_recall_at_k": candidate_recall / rows if rows else 0.0, |
| "baseline_mean_min_cer": ( |
| statistics.fmean(baseline_cers) if baseline_cers else 0.0 |
| ), |
| "mean_min_cer": statistics.fmean(cers) if cers else 0.0, |
| "abstention_rate": abstentions / rows if rows else 0.0, |
| "accepted_rows": accepted_rows, |
| "accepted_accuracy": (accepted_correct / accepted_rows if accepted_rows else None), |
| "clean_rows": clean_rows, |
| "overcorrection_rate": (overcorrections / clean_rows if clean_rows else None), |
| "typo_rows": typo_rows, |
| "typo_accuracy": typo_correct / typo_rows if typo_rows else None, |
| "improved_rows": improved_rows, |
| "worsened_rows": worsened_rows, |
| "unchanged_rows": unchanged_rows, |
| "candidate_miss_rows": rows - candidate_recall, |
| "accepted_candidate_miss_rows": accepted_candidate_miss_rows, |
| "selection_error_rows": selection_error_rows, |
| "declared_provenance_counts": dict(sorted(provenance_counts.items())), |
| "selection_reason_counts": dict(sorted(selection_reason_counts.items())), |
| "reported_margin_rows": len(reported_margins), |
| "mean_reported_margin": ( |
| statistics.fmean(reported_margins) if reported_margins else None |
| ), |
| }, |
| } |
|
|