serve a promoted model at the post-processing it was gated on

scripts/evaluate_belgium_building_candidate.py freezes its post-processing
before the protected test — NMS IoU and a containment threshold selected during
calibration, defaulting to 1.0. The runtime applied a hardcoded 0.85, so a
promoted candidate was served under suppression its evaluation never measured
and dropped detections the gate had counted. Neither report showed the
difference. That constant was mine, added without noticing the evaluation
pipeline already had a tuned value for the same concept.

Containment is now configuration, recorded on every run beside the duplicate
IoU threshold, so an operator can serve a candidate at the value it was gated
at and afterwards see which value produced a given score.

Two runs that post-processed differently produced different candidate sets from
the same model output, so the comparison endpoint refuses to rank them. Runs
recorded before those values were persisted carry none, and absence is not
treated as a difference.

The remaining gap is deliberate and documented rather than closed: the gate
scores the model on its protected test set, the workbench scores the whole
pipeline including coverage clipping and the tile-edge filter. A promoted
candidate will not reproduce its gate F1 exactly, and pretending otherwise
would be the worse answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 21:15:05 +02:00
co-authored by Claude Opus 5
parent 1a1a9af6e7
commit c8d32a4801
8 changed files with 207 additions and 8 deletions
@@ -53,6 +53,20 @@ class DetectionComparisonService:
if len(populations) > 1:
reasons.append("different_evaluated_population")
# Two runs that suppressed duplicates differently produced different
# candidate sets from the same model output, so their scores describe
# different pipelines. Runs from before these values were recorded
# carry none; absence is not a difference.
post_processing = {
(
entry.get("containment_suppression_threshold"),
entry.get("duplicate_iou_threshold"),
)
for entry in entries
}
if len(post_processing) > 1:
reasons.append("different_post_processing")
return {
"comparable": not reasons,
"blocking_reasons": reasons,
@@ -137,6 +151,7 @@ class DetectionComparisonService:
)
run = db.get(AnalysisRun, analysis_run_id)
parameters = (run.parameters_json if run and isinstance(run.parameters_json, dict) else {}) or {}
run_result = (run.result_json if run and isinstance(run.result_json, dict) else {}) or {}
coverage = result.get("coverage") if isinstance(result.get("coverage"), dict) else {}
curve = result.get("precision_recall_curve") or {}
@@ -149,6 +164,10 @@ class DetectionComparisonService:
"reference_dataset_id": reference_dataset_id,
"coverage_mode": coverage.get("mode"),
"reference_evaluated_count": coverage.get("reference_evaluated_count"),
# Recorded on the run itself, so two runs that suppressed
# duplicates differently cannot be ranked against each other.
"containment_suppression_threshold": run_result.get("containment_suppression_threshold"),
"duplicate_iou_threshold": run_result.get("duplicate_iou_threshold"),
}
)
rows.append(
@@ -166,6 +185,8 @@ class DetectionComparisonService:
"f1_at_run_threshold": result.get("f1_score"),
"precision_at_run_threshold": result.get("precision"),
"recall_at_run_threshold": result.get("recall"),
"containment_suppression_threshold": run_result.get("containment_suppression_threshold"),
"duplicate_iou_threshold": run_result.get("duplicate_iou_threshold"),
}
)
+12 -6
View File
@@ -1024,6 +1024,7 @@ class DetectionService:
filtered_candidates = DetectionService._suppress_duplicate_candidates(
edge_filtered_candidates,
iou_threshold=float(settings.yolo_duplicate_iou_threshold),
containment_threshold=float(settings.yolo_containment_nms_threshold),
)
persisted: list[Detection] = []
for candidate in filtered_candidates:
@@ -1061,7 +1062,7 @@ class DetectionService:
"suppressed_detection_count": len(candidates) - len(filtered_candidates),
"tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates),
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
"containment_suppression_threshold": DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD,
"containment_suppression_threshold": float(settings.yolo_containment_nms_threshold),
"runtime_model_provenance": runtime_model_provenance.as_dict(),
}
@@ -1094,12 +1095,20 @@ class DetectionService:
# two halves barely intersect and IoU alone never suppresses them. Overlap
# measured against the smaller box catches that case; the threshold is
# deliberately strict so that terraced houses stay separate detections.
# Fallback only. The served value is configuration, so a promoted model can
# be run at the threshold its evaluation froze.
CONTAINMENT_SUPPRESSION_THRESHOLD = 0.85
@staticmethod
def _suppress_duplicate_candidates(candidates: list[dict[str, Any]], iou_threshold: float) -> list[dict[str, Any]]:
def _suppress_duplicate_candidates(
candidates: list[dict[str, Any]],
iou_threshold: float,
containment_threshold: float | None = None,
) -> list[dict[str, Any]]:
if iou_threshold <= 0 or len(candidates) < 2:
return candidates
if containment_threshold is None:
containment_threshold = DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD
ordered = sorted(
candidates,
@@ -1123,10 +1132,7 @@ class DetectionService:
if DetectionService._geometry_iou(geometry, other) >= iou_threshold:
duplicate = True
break
if (
DetectionService._geometry_containment(geometry, other)
>= DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD
):
if DetectionService._geometry_containment(geometry, other) >= containment_threshold:
duplicate = True
break
if not duplicate: