limoXD's picture
Release v0.11 finite-selection hardening
5ee4f7e verified
Raw
History Blame Contribute Delete
9.94 kB
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, TextIO, cast
from .ajimee import (
AJIMEE_LICENSE,
AJIMEE_REPOSITORY,
AJIMEE_REVISION,
AJIMEE_SHA256,
)
from .evaluation import (
EvaluationItem,
Prediction,
PredictionProvenance,
evaluate_predictions,
)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="deberta-ime-evaluate")
parser.add_argument("--items", type=Path, required=True)
parser.add_argument("--predictions", type=Path, required=True)
parser.add_argument("--format", choices=("generic", "ajimee"), default="generic")
parser.add_argument("--dataset-name", default="unspecified")
parser.add_argument("--dataset-revision", default="unspecified")
parser.add_argument("--dataset-license", default="unspecified")
parser.add_argument("--candidate-limit", type=int, default=8)
parser.add_argument("--output-dir", type=Path, default=Path("outputs"))
parser.add_argument("--stem", default="finite_candidate_evaluation")
return parser
def run(
argv: Sequence[str] | None = None,
*,
stdout: TextIO | None = None,
) -> int:
args = _parser().parse_args(argv)
items = _load_items(args.items, item_format=args.format)
predictions = _load_predictions(args.predictions)
evaluation = evaluate_predictions(
items,
predictions,
candidate_limit=args.candidate_limit,
)
items_artifact = _artifact(args.items)
predictions_artifact = _artifact(args.predictions)
pinned_ajimee = args.format == "ajimee" and items_artifact["sha256"] == AJIMEE_SHA256
report: dict[str, Any] = {
"schema_version": 2,
"generated_at": datetime.now(UTC).isoformat(),
"status": "LOCAL_FINITE_CANDIDATE_EVALUATION",
"format": args.format,
"dataset": (
{
"name": AJIMEE_REPOSITORY,
"revision": AJIMEE_REVISION,
"license": AJIMEE_LICENSE,
}
if pinned_ajimee
else {
"name": "AJIMEE-compatible input",
"revision": "unverified-by-sha256",
"license": "unspecified",
}
if args.format == "ajimee"
else {
"name": args.dataset_name,
"revision": args.dataset_revision,
"license": args.dataset_license,
}
),
"artifacts": {
"items": items_artifact,
"predictions": predictions_artifact,
},
"evaluation": evaluation,
"claim_boundaries": [
"An empty or missing prediction abstains and preserves the input text.",
"Overcorrection is reported only for rows explicitly labelled clean.",
"Candidates are scored as supplied; this command performs no model inference.",
"AJIMEE provenance is pinned only when the item artifact SHA-256 matches.",
"Prediction provenance, reason, and margin are declarations from the "
"supplied artifact.",
],
}
args.output_dir.mkdir(parents=True, exist_ok=True)
json_path = args.output_dir / f"{args.stem}.json"
markdown_path = args.output_dir / f"{args.stem}.md"
json_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
markdown_path.write_text(_render_markdown(report), encoding="utf-8")
(stdout or sys.stdout).write(
json.dumps(
{"ok": True, "json": str(json_path), "markdown": str(markdown_path)},
ensure_ascii=False,
indent=2,
)
+ "\n"
)
return 0
def _load_items(path: Path, *, item_format: str) -> tuple[EvaluationItem, ...]:
payload = _load_json_array(path)
items: list[EvaluationItem] = []
for row_number, raw_item in enumerate(payload, start=1):
if not isinstance(raw_item, dict):
raise ValueError(f"item row {row_number}: expected an object")
if item_format == "ajimee":
item_id = raw_item.get("index")
input_text = raw_item.get("input")
references = raw_item.get("expected_output")
label: object = "unspecified"
else:
item_id = raw_item.get("id")
input_text = raw_item.get("input")
references = raw_item.get("references")
label = raw_item.get("label", "unspecified")
if not isinstance(item_id, str) or not isinstance(input_text, str):
raise ValueError(f"item row {row_number}: id and input must be strings")
if (
not isinstance(references, list)
or not references
or not all(isinstance(reference, str) for reference in references)
):
raise ValueError(
f"item row {row_number}: references must be a non-empty string list"
)
if label not in ("clean", "typo", "unspecified"):
raise ValueError(f"item row {row_number}: invalid label")
items.append(
EvaluationItem(
item_id=item_id,
input_text=input_text,
references=tuple(cast(list[str], references)),
label=label,
)
)
return tuple(items)
def _load_predictions(path: Path) -> tuple[Prediction, ...]:
payload = _load_json_array(path)
predictions: list[Prediction] = []
for row_number, raw_prediction in enumerate(payload, start=1):
if not isinstance(raw_prediction, dict):
raise ValueError(f"prediction row {row_number}: expected an object")
item_id = raw_prediction.get("id", raw_prediction.get("index"))
candidates = raw_prediction.get("candidates")
if not isinstance(item_id, str):
raise ValueError(f"prediction row {row_number}: id must be a string")
if not isinstance(candidates, list) or not all(
isinstance(candidate, str) and bool(candidate) for candidate in candidates
):
raise ValueError(f"prediction row {row_number}: candidates must be a string list")
provenance = raw_prediction.get("provenance", "unspecified")
if provenance not in ("provider", "deberta", "lfm", "rule", "unspecified"):
raise ValueError(f"prediction row {row_number}: invalid provenance")
reason = raw_prediction.get("reason")
if reason is not None and not isinstance(reason, str):
raise ValueError(f"prediction row {row_number}: reason must be a string")
margin = raw_prediction.get("margin")
if margin is not None and (
isinstance(margin, bool) or not isinstance(margin, (int, float))
):
raise ValueError(f"prediction row {row_number}: margin must be a number")
predictions.append(
Prediction(
item_id=item_id,
candidates=tuple(cast(list[str], candidates)),
provenance=cast(PredictionProvenance, provenance),
reason=reason,
margin=None if margin is None else float(margin),
)
)
return tuple(predictions)
def _load_json_array(path: Path) -> list[object]:
payload: object = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, list):
raise ValueError(f"{path}: expected a JSON array")
return cast(list[object], payload)
def _artifact(path: Path) -> dict[str, str | int]:
data = path.read_bytes()
return {
"path": str(path),
"sha256": hashlib.sha256(data).hexdigest(),
"size_bytes": len(data),
}
def _render_markdown(report: dict[str, Any]) -> str:
dataset = report["dataset"]
evaluation = report["evaluation"]
metrics = evaluation["metrics"]
overcorrection = metrics["overcorrection_rate"]
rendered_overcorrection = (
"not available (no explicit clean rows)"
if overcorrection is None
else f"{overcorrection:.2%}"
)
reported_margin = metrics["mean_reported_margin"]
rendered_margin = (
"not reported"
if reported_margin is None
else f"{reported_margin:.4f} ({metrics['reported_margin_rows']} rows)"
)
provenance = (
", ".join(
f"{name}={count}" for name, count in metrics["declared_provenance_counts"].items()
)
or "not reported"
)
return "\n".join(
[
"# Finite-candidate evaluation",
"",
f"Generated: {report['generated_at']}",
f"Dataset: {dataset['name']} @ {dataset['revision']}",
f"Dataset license/boundary: {dataset['license']}",
f"Rows: {evaluation['rows']}",
f"Candidate limit: {evaluation['candidate_limit']}",
"",
f"- Effective Acc@1: {metrics['effective_acc_at_1']:.2%}",
f"- Candidate Recall@k: {metrics['candidate_recall_at_k']:.2%}",
f"- Mean MinCER: {metrics['mean_min_cer']:.2%}",
f"- Abstention: {metrics['abstention_rate']:.2%}",
f"- Overcorrection: {rendered_overcorrection}",
f"- Accepted candidate misses: {metrics['accepted_candidate_miss_rows']}",
f"- Selection errors: {metrics['selection_error_rows']}",
f"- Declared provenance: {provenance}",
f"- Mean declared margin: {rendered_margin}",
"",
"An abstention preserves the input. No model inference was performed. "
"Provenance/reason/margin are artifact declarations, not independently "
"verified facts.",
"",
]
)
def main() -> None:
raise SystemExit(run())
if __name__ == "__main__":
main()