From 100d9e220bb35879c7a99e5c433c5f0c09947766 Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 14:22:30 +0200 Subject: [PATCH] Balance positives during precision correction --- .../test_failure_driven_yolo_sampling.py | 34 +++++++++++++++++++ scripts/build_failure_driven_yolo_sampling.py | 10 +++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_failure_driven_yolo_sampling.py b/backend/tests/test_failure_driven_yolo_sampling.py index 22fd6fad..b2f02e32 100644 --- a/backend/tests/test_failure_driven_yolo_sampling.py +++ b/backend/tests/test_failure_driven_yolo_sampling.py @@ -91,3 +91,37 @@ def test_sampling_can_use_calibration_before_test_is_opened() -> None: ) assert len(paths) == 3 assert metadata["failure_evidence_source"] == "calibration" + + +def test_precision_correction_can_balance_positive_and_negative_tiles() -> None: + manifest = {"samples": [{"sample_slug": "train-fl", "split": "train", "region": "flanders"}]} + summary = { + "tiles": [ + {"sample_slug": "train-fl", "split": "train", "label_count": 2, "image_path": "/tmp/fl-pos.png"}, + {"sample_slug": "train-fl", "split": "train", "label_count": 0, "image_path": "/tmp/fl-neg.png"}, + ] + } + assessment = { + "status": "continue_training_loop", + "gates": { + "min_region_f1": 0.45, + "min_region_precision": 0.5, + "min_region_recall": 0.4, + "max_pure_empty_false_positives": 0, + }, + "calibration": { + "regions": {"flanders": {"f1": 0.46, "precision": 0.45, "recall": 0.46}} + }, + } + + paths, metadata = MODULE.build_sampling( + summary=summary, + manifest=manifest, + assessment=assessment, + precision_positive_repeat=2, + negative_repeat=3, + ) + + assert paths.count(str(Path("/tmp/fl-pos.png").resolve())) == 2 + assert paths.count(str(Path("/tmp/fl-neg.png").resolve())) == 3 + assert metadata["precision_positive_repeat"] == 2 diff --git a/scripts/build_failure_driven_yolo_sampling.py b/scripts/build_failure_driven_yolo_sampling.py index c1c5d6ba..73c54060 100644 --- a/scripts/build_failure_driven_yolo_sampling.py +++ b/scripts/build_failure_driven_yolo_sampling.py @@ -35,10 +35,11 @@ def build_sampling( assessment: dict[str, Any], positive_repeat: int = 3, negative_repeat: int = 4, + precision_positive_repeat: int = 1, ) -> tuple[list[str], dict[str, Any]]: if assessment.get("status") != "continue_training_loop": raise ValueError("Failure-driven sampling requires a failed assessment") - if positive_repeat < 1 or negative_repeat < 1: + if positive_repeat < 1 or negative_repeat < 1 or precision_positive_repeat < 1: raise ValueError("Repeat factors must be positive") samples = {item["sample_slug"]: item for item in manifest["samples"]} @@ -78,6 +79,10 @@ def build_sampling( repeat = 1 if tile["label_count"] > 0 and region in weak_recall_regions: repeat = positive_repeat + elif tile["label_count"] > 0 and region in weak_precision_regions: + # Precision-only correction still needs positive examples to avoid + # shifting the classifier toward background and sacrificing recall. + repeat = precision_positive_repeat if tile["label_count"] == 0 and (background_failed or region in weak_precision_regions): repeat = negative_repeat path = str(Path(tile["image_path"]).resolve()) @@ -97,6 +102,7 @@ def build_sampling( "background_gate_failed": background_failed, "positive_repeat": positive_repeat, "negative_repeat": negative_repeat, + "precision_positive_repeat": precision_positive_repeat, "source_train_tile_count": sum( 1 for tile in summary["tiles"] @@ -119,6 +125,7 @@ def main() -> int: parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--positive-repeat", type=int, default=3) parser.add_argument("--negative-repeat", type=int, default=4) + parser.add_argument("--precision-positive-repeat", type=int, default=1) args = parser.parse_args() summary = json.loads(args.summary.read_text(encoding="utf-8")) @@ -130,6 +137,7 @@ def main() -> int: assessment=assessment, positive_repeat=args.positive_repeat, negative_repeat=args.negative_repeat, + precision_positive_repeat=args.precision_positive_repeat, ) args.output_dir.mkdir(parents=True, exist_ok=True) train_list = args.output_dir / "train-failure-driven.txt"