Quality gates / quality (pull_request) Successful in 13m25s
The ROADMAP gates (macro-F1 >= 0.80, precision@coverage >= 0.90, ECE <= 0.05) were prose with no tool behind them. scripts/calibration_report.py now computes them from prediction records and refuses input it cannot honestly score: - a split is re-derived from the pseudonymous subject key with the ingestion rule, so a corpus cannot re-label its own splits; a contributor spanning two splits is refused before scoring - the threshold is selected on validation only, and selecting on test or train is refused - coverage, precision-at-coverage, macro-F1, per-class precision/recall, confusion, and expected calibration error are computed on accepted suggestions, with classes that abstained away named in excludedClasses instead of being scored as a silent zero - exit codes carry the verdict: 0 GO, 3 BLOCKED, 2 refused The release demo records the real harness against committed synthetic fixtures: a blocked three-candidate comparison, a fixture engineered to clear every gate, and a leaked corpus refused. Every number on the card is read back from the report the run wrote, and the captions state that no model was measured, because a fixture is not evidence about accuracy. The harness suite is wired into CI, check:syntax, and the release builder's own gates.
379 lines
17 KiB
Python
379 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Contributor-held-out evaluation harness for the Timmy Bristol classifier.
|
|
|
|
This is the comparison and release-gate tool for issue #29: it turns candidate predictions into the
|
|
numbers the ROADMAP gates are written in — macro-F1, per-class precision/recall, a confusion matrix,
|
|
calibration error, coverage, and precision-at-coverage — and it refuses a corpus whose splits are
|
|
not contributor-held-out or not reproducible.
|
|
|
|
Nothing here trains, scores, or diagnoses: it reads predictions that something else produced, and it
|
|
reports what those predictions would have done on held-out contributors. Every fixture used to test
|
|
it is synthetic.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
BRISTOL_CLASSES = tuple(range(1, 8))
|
|
SPLITS = ("train", "validation", "test")
|
|
# The split rule is the ingestion rule (scripts/ingest_training_photo.py): the bucket of the first
|
|
# byte of the pseudonymous subject key. Keeping it here means a split can be re-derived from the
|
|
# key alone, so a corpus cannot quietly re-label its own splits.
|
|
_TRAIN_EDGE = 205
|
|
_VALIDATION_EDGE = 230
|
|
_SHA_LIKE = re.compile(r"^[0-9a-f]{16}$")
|
|
|
|
# The ROADMAP release gates. A candidate must clear every one on its held-out split; a limit is
|
|
# inclusive, so a model exactly on the line passes.
|
|
RELEASE_GATES = {"macroF1": 0.80, "precisionAtCoverage": 0.90, "ece": 0.05}
|
|
|
|
|
|
class EvaluationError(Exception):
|
|
"""The corpus or the caller asked for a comparison that cannot be trusted."""
|
|
|
|
|
|
def expected_split(subject_key: str) -> str:
|
|
"""The split a pseudonymous subject key must belong to, by the ingestion rule."""
|
|
if not isinstance(subject_key, str) or not _SHA_LIKE.match(subject_key):
|
|
raise EvaluationError("subject key must be the 16-character pseudonymous digest")
|
|
bucket = int(subject_key[:2], 16)
|
|
if bucket < _TRAIN_EDGE:
|
|
return "train"
|
|
if bucket < _VALIDATION_EDGE:
|
|
return "validation"
|
|
return "test"
|
|
|
|
|
|
def _calibration_error(accepted: list[tuple[int, int, float]], bins: int = 10) -> tuple[float, list[dict]]:
|
|
"""Expected calibration error over the accepted suggestions, by equal-width confidence bin."""
|
|
if not accepted:
|
|
return 0.0, []
|
|
edges = [index / bins for index in range(bins + 1)]
|
|
buckets: list[list[tuple[int, int, float]]] = [[] for _ in range(bins)]
|
|
for truth, predicted, confidence in accepted:
|
|
index = min(int(confidence * bins), bins - 1)
|
|
buckets[index].append((truth, predicted, confidence))
|
|
error = 0.0
|
|
detail: list[dict] = []
|
|
for index, bucket in enumerate(buckets):
|
|
if not bucket:
|
|
continue
|
|
accuracy = sum(1 for truth, predicted, _ in bucket if truth == predicted) / len(bucket)
|
|
mean_confidence = sum(confidence for _, _, confidence in bucket) / len(bucket)
|
|
share = len(bucket) / len(accepted)
|
|
error += share * abs(accuracy - mean_confidence)
|
|
detail.append(
|
|
{
|
|
"lower": edges[index],
|
|
"upper": edges[index + 1],
|
|
"count": len(bucket),
|
|
"accuracy": accuracy,
|
|
"meanConfidence": mean_confidence,
|
|
}
|
|
)
|
|
return error, detail
|
|
|
|
|
|
def assert_contributor_held_out(records: list[dict]) -> None:
|
|
"""Refuse a corpus whose splits are not contributor-held-out and reproducible.
|
|
|
|
A split must be re-derivable from the pseudonymous subject key alone (the ingestion rule), and
|
|
one contributor's photos must all land in the same split. Otherwise a held-out score is not
|
|
held out: the model has already seen that person's body in training.
|
|
"""
|
|
splits_by_subject: dict[str, set[str]] = {}
|
|
for entry in records:
|
|
subject = entry.get("subjectKey")
|
|
if not isinstance(subject, str) or not subject:
|
|
raise EvaluationError("every prediction must carry the pseudonymous subject key")
|
|
split = entry.get("split")
|
|
if split not in SPLITS:
|
|
raise EvaluationError(f"split must be one of {', '.join(SPLITS)}: {split!r}")
|
|
splits_by_subject.setdefault(subject, set()).add(split)
|
|
for subject, splits in splits_by_subject.items():
|
|
if len(splits) > 1:
|
|
raise EvaluationError(
|
|
f"contributor {subject} leaks across {', '.join(sorted(splits))}: splits must be contributor-held-out"
|
|
)
|
|
for entry in records:
|
|
subject = entry["subjectKey"]
|
|
required = expected_split(subject)
|
|
if entry["split"] != required:
|
|
raise EvaluationError(
|
|
f"split {entry['split']!r} for contributor {subject} is not reproducible: the subject key derives {required!r}"
|
|
)
|
|
|
|
|
|
def compare(models: dict[str, list[dict]], *, min_coverage: float = 0.5, gates: dict | None = None) -> dict:
|
|
"""Compare candidate models against the release gates.
|
|
|
|
Each candidate gets one threshold, chosen on its own validation split, and is then measured on
|
|
its test split. The gates are the ROADMAP release gates; all limits are inclusive.
|
|
"""
|
|
limits = dict(gates or RELEASE_GATES)
|
|
rows: list[dict] = []
|
|
for model_id in sorted(models):
|
|
records = models[model_id]
|
|
assert_contributor_held_out(records)
|
|
test = [entry for entry in records if entry.get("split") == "test"]
|
|
validation = [entry for entry in records if entry.get("split") == "validation"]
|
|
if not test:
|
|
raise EvaluationError(f"{model_id} has no test-split predictions to measure a release on")
|
|
if not validation:
|
|
raise EvaluationError(f"{model_id} has no validation-split predictions to fix a threshold on")
|
|
selection = select_threshold(validation, min_coverage=min_coverage)
|
|
scored = evaluate(test, threshold=selection["threshold"])
|
|
failed = []
|
|
if scored["macroF1"] < limits["macroF1"]:
|
|
failed.append("macroF1")
|
|
if scored["precisionAtCoverage"] < limits["precisionAtCoverage"]:
|
|
failed.append("precisionAtCoverage")
|
|
if scored["ece"] > limits["ece"]:
|
|
failed.append("ece")
|
|
rows.append(
|
|
{
|
|
"model": model_id,
|
|
**scored,
|
|
"selectionSplit": "validation",
|
|
"evaluatedSplit": "test",
|
|
"minCoverage": min_coverage,
|
|
"gate": {"passed": not failed, "failed": failed},
|
|
}
|
|
)
|
|
ordered = sorted(
|
|
rows,
|
|
key=lambda row: (
|
|
not row["gate"]["passed"],
|
|
-row["macroF1"],
|
|
-row["precisionAtCoverage"],
|
|
row["model"],
|
|
),
|
|
)
|
|
passing = [row for row in ordered if row["gate"]["passed"]]
|
|
return {
|
|
"gates": limits,
|
|
"models": ordered,
|
|
"recommended": passing[0]["model"] if passing else None,
|
|
"verdict": "GO" if passing else "BLOCKED",
|
|
}
|
|
|
|
|
|
def select_threshold(records: list[dict], *, min_coverage: float = 0.5) -> dict:
|
|
"""Choose the abstention threshold that maximises precision at a required coverage.
|
|
|
|
Selection reads the validation split only. Fixing a threshold on the same data the release gate
|
|
is measured on would manufacture whatever precision the report needs.
|
|
"""
|
|
if not records:
|
|
raise EvaluationError("threshold selection needs validation predictions")
|
|
if not 0.0 <= min_coverage <= 1.0:
|
|
raise EvaluationError(f"a coverage floor must be between 0 and 1: {min_coverage}")
|
|
if any(entry.get("split") != "validation" for entry in records):
|
|
raise EvaluationError("threshold selection must use the validation split only")
|
|
assert_contributor_held_out(records)
|
|
|
|
candidates = {0.0}
|
|
candidates.update(
|
|
entry["prediction"]["confidence"] for entry in records if entry["prediction"] is not None
|
|
)
|
|
best: dict | None = None
|
|
for threshold in sorted(candidates):
|
|
report = evaluate(records, threshold=threshold)
|
|
# Float comparison: a coverage exactly on the floor must not be lost to rounding.
|
|
if report["coverage"] + 1e-12 < min_coverage:
|
|
continue
|
|
if best is None:
|
|
best = report
|
|
continue
|
|
more_precise = round(report["precisionAtCoverage"], 12) > round(best["precisionAtCoverage"], 12)
|
|
equally_precise_but_wider = (
|
|
round(report["precisionAtCoverage"], 12) == round(best["precisionAtCoverage"], 12)
|
|
and report["coverage"] > best["coverage"]
|
|
)
|
|
if more_precise or equally_precise_but_wider:
|
|
best = report
|
|
if best is None:
|
|
raise EvaluationError(f"no threshold reaches the {min_coverage:.0%} coverage floor on validation")
|
|
return {**best, "selectionSplit": "validation", "minCoverage": min_coverage}
|
|
|
|
|
|
def evaluate(records: list[dict], *, threshold: float = 0.0) -> dict:
|
|
"""Score one model's accepted suggestions at a fixed abstention threshold."""
|
|
accepted: list[tuple[int, int, float]] = []
|
|
abstentions = {"low-confidence": 0, "no-suggestion": 0}
|
|
for entry in records:
|
|
truth = entry["truth"]["bristolType"]
|
|
prediction = entry["prediction"]
|
|
if prediction is None:
|
|
abstentions["no-suggestion"] += 1
|
|
continue
|
|
if prediction["confidence"] < threshold:
|
|
abstentions["low-confidence"] += 1
|
|
continue
|
|
accepted.append((truth, prediction["bristolType"], prediction["confidence"]))
|
|
|
|
confusion: dict[str, int] = {}
|
|
for truth, predicted, _ in accepted:
|
|
key = f"{truth}->{predicted}"
|
|
confusion[key] = confusion.get(key, 0) + 1
|
|
|
|
truth_classes = {truth for truth, _, _ in accepted}
|
|
per_class: dict[int, dict] = {}
|
|
for label in sorted(truth_classes):
|
|
true_positive = sum(1 for truth, predicted, _ in accepted if truth == label and predicted == label)
|
|
predicted_positive = sum(1 for _, predicted, _ in accepted if predicted == label)
|
|
actual_positive = sum(1 for truth, _, _ in accepted if truth == label)
|
|
precision = true_positive / predicted_positive if predicted_positive else 0.0
|
|
recall = true_positive / actual_positive if actual_positive else 0.0
|
|
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
|
per_class[label] = {
|
|
"precision": precision,
|
|
"recall": recall,
|
|
"f1": f1,
|
|
"support": actual_positive,
|
|
"predicted": predicted_positive,
|
|
}
|
|
|
|
macro_f1 = sum(row["f1"] for row in per_class.values()) / len(per_class) if per_class else 0.0
|
|
ece, calibration_bins = _calibration_error(accepted)
|
|
truth_classes_in_split = {entry["truth"]["bristolType"] for entry in records}
|
|
return {
|
|
"threshold": threshold,
|
|
"counts": {"total": len(records), "accepted": len(accepted), "abstained": len(records) - len(accepted)},
|
|
"abstentions": abstentions,
|
|
"coverage": len(accepted) / len(records) if records else 0.0,
|
|
"precisionAtCoverage": (
|
|
sum(1 for truth, predicted, _ in accepted if truth == predicted) / len(accepted) if accepted else 0.0
|
|
),
|
|
"perClass": per_class,
|
|
"excludedClasses": sorted(truth_classes_in_split - set(per_class)),
|
|
"macroF1": macro_f1,
|
|
"ece": ece,
|
|
"calibrationBins": calibration_bins,
|
|
"confusion": dict(sorted(confusion.items())),
|
|
}
|
|
|
|
|
|
def _validate_prediction(entry: object, where: str) -> dict:
|
|
"""Normalise and bound one prediction record, or refuse it."""
|
|
if not isinstance(entry, dict):
|
|
raise EvaluationError(f"{where} is not a prediction object")
|
|
model = entry.get("model")
|
|
if not isinstance(model, str) or not model.strip():
|
|
raise EvaluationError(f"{where} needs the model that produced the prediction")
|
|
truth = entry.get("truth")
|
|
if not isinstance(truth, dict) or not isinstance(truth.get("bristolType"), int):
|
|
raise EvaluationError(f"{where} needs truth.bristolType as a whole number")
|
|
if truth["bristolType"] not in BRISTOL_CLASSES:
|
|
raise EvaluationError(f"{where} truth.bristolType {truth['bristolType']} is outside Bristol 1-7")
|
|
split = entry.get("split")
|
|
if split not in SPLITS:
|
|
raise EvaluationError(f"{where} split must be one of {', '.join(SPLITS)}")
|
|
subject = entry.get("subjectKey")
|
|
if not isinstance(subject, str) or not _SHA_LIKE.match(subject):
|
|
raise EvaluationError(f"{where} needs the 16-character pseudonymous subject key")
|
|
prediction = entry.get("prediction")
|
|
if prediction is not None:
|
|
if not isinstance(prediction, dict):
|
|
raise EvaluationError(f"{where} prediction must be an object or null")
|
|
if prediction.get("bristolType") not in BRISTOL_CLASSES:
|
|
raise EvaluationError(f"{where} predicted bristolType must be 1-7")
|
|
confidence = prediction.get("confidence")
|
|
if not isinstance(confidence, (int, float)) or isinstance(confidence, bool) or not 0.0 <= confidence <= 1.0:
|
|
raise EvaluationError(f"{where} confidence must be a number between 0 and 1")
|
|
return {
|
|
"model": model.strip(),
|
|
"subjectKey": subject,
|
|
"split": split,
|
|
"truth": {"bristolType": truth["bristolType"]},
|
|
"prediction": None
|
|
if prediction is None
|
|
else {"bristolType": prediction["bristolType"], "confidence": float(prediction["confidence"])},
|
|
}
|
|
|
|
|
|
def load_predictions(paths: list[str]) -> list[dict]:
|
|
"""Read prediction JSONL files, refusing anything the gates could not honestly be computed from."""
|
|
records: list[dict] = []
|
|
for raw_path in paths:
|
|
path = Path(raw_path)
|
|
if not path.is_file():
|
|
raise EvaluationError(f"predictions file not found: {path}")
|
|
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise EvaluationError(f"{path}:{number} is not valid JSON: {exc.msg}") from exc
|
|
records.append(_validate_prediction(entry, f"{path}:{number}"))
|
|
if not records:
|
|
raise EvaluationError("no prediction records were supplied")
|
|
return records
|
|
|
|
|
|
def group_by_model(records: list[dict]) -> dict[str, list[dict]]:
|
|
models: dict[str, list[dict]] = {}
|
|
for entry in records:
|
|
models.setdefault(entry["model"], []).append(
|
|
{key: value for key, value in entry.items() if key != "model"}
|
|
)
|
|
return models
|
|
|
|
|
|
def format_report(report: dict, *, source: str) -> str:
|
|
"""Render the comparison as a compact receipt a human can read in a terminal."""
|
|
lines = [
|
|
"TIMMY CALIBRATION REPORT",
|
|
f"source: {source}",
|
|
f"threshold: chosen on validation only · scored on test · coverage floor {report['models'][0]['minCoverage']:.0%}"
|
|
if report["models"]
|
|
else "threshold: nothing to score",
|
|
"candidates: " + ", ".join(row["model"] for row in report["models"]),
|
|
"",
|
|
f"{'model':<26}{'thr':>5}{'macroF1':>9}{'prec@cov':>10}{'coverage':>10}{'ECE':>7} gate",
|
|
]
|
|
for row in report["models"]:
|
|
gate = "PASS" if row["gate"]["passed"] else "FAIL: " + ", ".join(row["gate"]["failed"])
|
|
lines.append(
|
|
f"{row['model']:<26}{row['threshold']:>5.2f}{row['macroF1']:>9.3f}"
|
|
f"{row['precisionAtCoverage']:>10.3f}{row['coverage']:>10.2f}{row['ece']:>7.3f} {gate}"
|
|
)
|
|
limits = report["gates"]
|
|
lines += [
|
|
"",
|
|
"verdict: " + report["verdict"] + (f" — recommended {report['recommended']}" if report["recommended"] else ""),
|
|
f"gates: macroF1 >= {limits['macroF1']:.2f} · precision@coverage >= {limits['precisionAtCoverage']:.2f} · ECE <= {limits['ece']:.2f}",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Score Bristol candidates against the release gates.")
|
|
parser.add_argument("--predictions", action="append", required=True, metavar="PATH", help="prediction JSONL (repeatable)")
|
|
parser.add_argument("--min-coverage", type=float, default=0.5, help="coverage floor for threshold selection")
|
|
parser.add_argument("--json", metavar="PATH", help="write the full report as JSON here")
|
|
parser.add_argument("--source", default=None, help="what the predictions are, recorded in the receipt")
|
|
args = parser.parse_args(argv)
|
|
|
|
try:
|
|
records = load_predictions(args.predictions)
|
|
report = compare(group_by_model(records), min_coverage=args.min_coverage)
|
|
except EvaluationError as exc:
|
|
print(f"calibration harness refused: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
source = args.source or f"{len(records)} prediction records from {len(args.predictions)} file(s)"
|
|
print(format_report(report, source=source))
|
|
if args.json:
|
|
Path(args.json).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
return 0 if report["verdict"] == "GO" else 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|