Files
geointel/scripts/build_detection_model_promotion_report.py
T
Codex dea65666ec
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled
Use split background reports in promotion gate
2026-07-10 02:44:11 +02:00

426 lines
17 KiB
Python

#!/usr/bin/env python3
"""Build an operator-only detection model promotion report.
The report combines persisted positive-AOI QA portfolio evidence with
hard-negative/background detection-count summaries. It does not run inference,
mutate application state, download models or change active model configuration.
"""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from statistics import mean
from typing import Any
PURE_EMPTY_CATEGORY = "pure_empty_negative"
SPARSE_CONTEXT_CATEGORY = "sparse_building_context"
@dataclass(frozen=True)
class CandidateKey:
model_asset_id: str
tile_size: int
tile_overlap: int
threshold: float
@property
def label(self) -> str:
threshold = ("%f" % self.threshold).rstrip("0").rstrip(".")
return f"{self.model_asset_id}|{self.tile_size}|{self.tile_overlap}|{threshold}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build a detection model promotion report from positive and hard-negative evidence."
)
parser.add_argument("--positive-portfolio", required=True, help="Path to calibration_evidence_portfolio.json")
parser.add_argument(
"--hard-negative-summary",
action="append",
default=[],
help="Path to hard_negative_matrix_summary.json. May be supplied multiple times.",
)
parser.add_argument(
"--background-split-summary",
action="append",
default=[],
help=(
"Path to background_corpus_split_summary.json. The pure-empty source summary is used as the "
"strict default gate; sparse-context evidence is kept as review-only context."
),
)
parser.add_argument("--output-dir", required=True, help="Directory for JSON and Markdown report output.")
parser.add_argument("--min-positive-samples", type=int, default=3)
parser.add_argument("--min-background-samples", type=int, default=3)
parser.add_argument("--min-mean-f1", type=float, default=0.25)
parser.add_argument("--max-background-detections-per-sample", type=int, default=0)
parser.add_argument(
"--default-positive-tile-size",
type=int,
default=None,
help="Tile size to use for positive portfolio runs that do not record tile_size.",
)
parser.add_argument(
"--default-positive-tile-overlap",
type=int,
default=None,
help="Tile overlap to use for positive portfolio runs that do not record tile_overlap.",
)
args = parser.parse_args()
if not args.hard_negative_summary and not args.background_split_summary:
parser.error("at least one --hard-negative-summary or --background-split-summary is required")
return args
def read_json(path: Path) -> dict[str, Any]:
if not path.is_file():
raise SystemExit(f"JSON input is not readable: {path}")
return json.loads(path.read_text(encoding="utf-8-sig"))
def as_candidate_key(
item: dict[str, Any],
*,
fallback_model_asset_id: str | None = None,
fallback_tile_size: int | None = None,
fallback_tile_overlap: int | None = None,
) -> CandidateKey | None:
model_asset_id = item.get("model_asset_id") or item.get("model_request") or fallback_model_asset_id
if not model_asset_id:
return None
try:
return CandidateKey(
model_asset_id=str(model_asset_id),
tile_size=int(item.get("tile_size") if item.get("tile_size") is not None else fallback_tile_size),
tile_overlap=int(
item.get("tile_overlap") if item.get("tile_overlap") is not None else fallback_tile_overlap
),
threshold=float(item.get("threshold")),
)
except (TypeError, ValueError):
return None
def numeric(item: dict[str, Any], key: str) -> float | None:
value = item.get(key)
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def collect_positive_runs(
portfolio: dict[str, Any],
*,
default_tile_size: int | None = None,
default_tile_overlap: int | None = None,
) -> dict[CandidateKey, list[dict[str, Any]]]:
best_by_candidate_sample: dict[tuple[CandidateKey, str], dict[str, Any]] = {}
portfolio_model_asset_id = portfolio.get("model_asset_id")
positive_runs: list[tuple[str, dict[str, Any]]] = []
for sample in portfolio.get("samples") or []:
sample_slug = str(sample.get("sample_slug") or "unknown")
for run in sample.get("runs") or []:
positive_runs.append((sample_slug, run))
for item in portfolio.get("items") or []:
sample_slug = str(item.get("sample_slug") or "unknown")
positive_runs.append((sample_slug, item))
for sample_slug, run in positive_runs:
key = as_candidate_key(
run,
fallback_model_asset_id=str(portfolio_model_asset_id) if portfolio_model_asset_id else None,
fallback_tile_size=default_tile_size,
fallback_tile_overlap=default_tile_overlap,
)
f1 = numeric(run, "f1_score")
if f1 is None:
f1 = numeric(run, "f1")
if key is None or f1 is None:
continue
enriched = dict(run)
enriched["f1_score"] = f1
enriched["sample_slug"] = sample_slug
existing = best_by_candidate_sample.get((key, sample_slug))
existing_f1 = numeric(existing or {}, "f1_score")
if existing is None or existing_f1 is None or f1 > existing_f1:
best_by_candidate_sample[(key, sample_slug)] = enriched
grouped: dict[CandidateKey, list[dict[str, Any]]] = defaultdict(list)
for (key, _sample_slug), run in best_by_candidate_sample.items():
grouped[key].append(run)
return grouped
def collect_background_runs(summary_paths: list[Path]) -> dict[CandidateKey, list[dict[str, Any]]]:
best_by_candidate_sample: dict[tuple[CandidateKey, str], dict[str, Any]] = {}
for path in summary_paths:
payload = read_json(path)
for item in payload.get("items") or []:
key = as_candidate_key(item)
sample_slug = str(item.get("sample_slug") or "unknown")
if key is None:
continue
enriched = dict(item)
enriched["source_summary_path"] = str(path)
detection_count = int(enriched.get("detection_count") or 0)
existing = best_by_candidate_sample.get((key, sample_slug))
existing_count = int((existing or {}).get("detection_count") or 0)
if existing is None or detection_count > existing_count:
best_by_candidate_sample[(key, sample_slug)] = enriched
grouped: dict[CandidateKey, list[dict[str, Any]]] = defaultdict(list)
for (key, _sample_slug), item in best_by_candidate_sample.items():
grouped[key].append(item)
return grouped
def resolve_split_source_path(source_path: Any, split_summary_path: Path) -> Path:
path = Path(str(source_path))
if path.is_file():
return path
if not path.is_absolute():
candidate = split_summary_path.parent / path
if candidate.is_file():
return candidate
return path
def collect_split_background_inputs(
split_summary_paths: list[Path],
) -> tuple[list[Path], list[dict[str, Any]]]:
strict_background_paths: list[Path] = []
context_reviews: list[dict[str, Any]] = []
for split_summary_path in split_summary_paths:
payload = read_json(split_summary_path)
source_summaries = payload.get("source_summaries") or {}
pure_empty_source = source_summaries.get(PURE_EMPTY_CATEGORY)
if not pure_empty_source:
raise SystemExit(f"Split summary has no {PURE_EMPTY_CATEGORY} source: {split_summary_path}")
strict_block = payload.get("strict_default_gate") or {}
strict_category = strict_block.get("category")
if strict_category and strict_category != PURE_EMPTY_CATEGORY:
raise SystemExit(
f"Split summary strict gate must be {PURE_EMPTY_CATEGORY}; found {strict_category}: {split_summary_path}"
)
strict_background_paths.append(resolve_split_source_path(pure_empty_source, split_summary_path))
context_block = payload.get("context_review") or {}
sparse_context_source = source_summaries.get(SPARSE_CONTEXT_CATEGORY)
if context_block or sparse_context_source:
context_entry = dict(context_block)
context_entry["source_split_summary_path"] = str(split_summary_path)
if sparse_context_source:
context_entry["source_summary_path"] = str(
resolve_split_source_path(sparse_context_source, split_summary_path)
)
context_reviews.append(context_entry)
return strict_background_paths, context_reviews
def unique_paths(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
unique: list[Path] = []
for path in paths:
marker = str(path)
if marker in seen:
continue
seen.add(marker)
unique.append(path)
return unique
def average_metric(runs: list[dict[str, Any]], metric: str) -> float | None:
values = [numeric(run, metric) for run in runs]
values = [value for value in values if value is not None]
return mean(values) if values else None
def build_decisions(args: argparse.Namespace) -> dict[str, Any]:
portfolio_path = Path(args.positive_portfolio)
positive_portfolio = read_json(portfolio_path)
direct_background_paths = [Path(path) for path in args.hard_negative_summary]
split_summary_paths = [Path(path) for path in args.background_split_summary]
split_background_paths, context_reviews = collect_split_background_inputs(split_summary_paths)
background_paths = unique_paths([*direct_background_paths, *split_background_paths])
positive = collect_positive_runs(
positive_portfolio,
default_tile_size=args.default_positive_tile_size,
default_tile_overlap=args.default_positive_tile_overlap,
)
background = collect_background_runs(background_paths)
decisions = []
for key in sorted(set(positive) | set(background), key=lambda item: item.label):
positive_runs = positive.get(key, [])
background_runs = background.get(key, [])
f1_values = [numeric(run, "f1_score") for run in positive_runs]
f1_values = [value for value in f1_values if value is not None]
background_counts = [int(run.get("detection_count") or 0) for run in background_runs]
mean_f1 = mean(f1_values) if f1_values else None
min_f1 = min(f1_values) if f1_values else None
total_background = sum(background_counts)
max_background = max(background_counts, default=None)
rejection_reasons: list[str] = []
if len(positive_runs) < args.min_positive_samples:
rejection_reasons.append("insufficient_positive_samples")
if len(background_runs) < args.min_background_samples:
rejection_reasons.append("insufficient_background_samples")
if mean_f1 is None or mean_f1 < args.min_mean_f1:
rejection_reasons.append("positive_mean_f1_below_gate")
if max_background is None or max_background > args.max_background_detections_per_sample:
rejection_reasons.append("background_false_positive_pressure")
decisions.append(
{
"candidate_key": key.label,
"model_asset_id": key.model_asset_id,
"tile_size": key.tile_size,
"tile_overlap": key.tile_overlap,
"threshold": key.threshold,
"promotion_status": "reject" if rejection_reasons else "promote_candidate",
"rejection_reasons": rejection_reasons,
"positive_sample_count": len(positive_runs),
"background_sample_count": len(background_runs),
"mean_f1": mean_f1,
"min_f1": min_f1,
"mean_precision": average_metric(positive_runs, "precision"),
"mean_recall": average_metric(positive_runs, "recall"),
"total_background_detections": total_background,
"max_background_detections": max_background,
"positive_samples": sorted(str(run.get("sample_slug")) for run in positive_runs),
"background_samples": sorted(
{
str(run.get("sample_slug")): int(run.get("detection_count") or 0)
for run in background_runs
}.items()
),
}
)
promoted = [item for item in decisions if item["promotion_status"] == "promote_candidate"]
recommended = max(
promoted,
key=lambda item: (
item["mean_f1"] if item["mean_f1"] is not None else float("-inf"),
-(item["total_background_detections"] or 0),
),
default=None,
)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"positive_portfolio_path": str(portfolio_path),
"hard_negative_summary_paths": [str(path) for path in background_paths],
"background_split_summary_paths": [str(path) for path in split_summary_paths],
"background_context_reviews": context_reviews,
"gates": {
"min_positive_samples": args.min_positive_samples,
"min_background_samples": args.min_background_samples,
"min_mean_f1": args.min_mean_f1,
"max_background_detections_per_sample": args.max_background_detections_per_sample,
},
"candidate_count": len(decisions),
"recommended_candidate": recommended,
"candidate_decisions": decisions,
}
def write_markdown(report: dict[str, Any], path: Path) -> None:
lines = [
"# Detection Model Promotion Report",
"",
f"- Generated: {report['generated_at']}",
f"- Positive portfolio: `{report['positive_portfolio_path']}`",
f"- Hard-negative summaries: {len(report['hard_negative_summary_paths'])}",
f"- Background split summaries: {len(report['background_split_summary_paths'])}",
f"- Candidates: {report['candidate_count']}",
"",
"## Gates",
"",
]
for key, value in report["gates"].items():
lines.append(f"- {key}: `{value}`")
lines.extend(["", "## Recommendation", ""])
recommended = report.get("recommended_candidate")
if recommended:
lines.append(f"- Promote candidate for operator review: `{recommended['candidate_key']}`")
else:
lines.append("- No candidate passed all positive and hard-negative gates.")
if report.get("background_context_reviews"):
lines.extend(
[
"",
"## Sparse-context review evidence",
"",
(
f"- Sparse-context review evidence: {len(report['background_context_reviews'])} "
"(not used as a default-promotion gate)"
),
]
)
for item in report["background_context_reviews"]:
lines.append(
"- "
f"`{item.get('category', SPARSE_CONTEXT_CATEGORY)}` from "
f"`{item.get('source_summary_path', 'unknown')}`; "
f"max detections `{item.get('max_detection_count')}`"
)
lines.extend(["", "## Candidate Decisions", ""])
for item in report["candidate_decisions"]:
reasons = ", ".join(item["rejection_reasons"]) or "none"
lines.extend(
[
f"### `{item['candidate_key']}`",
"",
f"- Status: `{item['promotion_status']}`",
f"- Rejection reasons: `{reasons}`",
f"- Positive samples: `{item['positive_sample_count']}`",
f"- Background samples: `{item['background_sample_count']}`",
f"- Mean F1: `{item['mean_f1']}`",
f"- Mean precision: `{item['mean_precision']}`",
f"- Mean recall: `{item['mean_recall']}`",
f"- Max background detections: `{item['max_background_detections']}`",
f"- Total background detections: `{item['total_background_detections']}`",
"",
]
)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> None:
args = parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
report = build_decisions(args)
report_path = output_dir / "detection_model_promotion_report.json"
markdown_path = output_dir / "detection_model_promotion_report.md"
report_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
write_markdown(report, markdown_path)
print("Detection model promotion report passed")
print(f"Report JSON: {report_path}")
print(f"Report Markdown: {markdown_path}")
recommended = report.get("recommended_candidate")
print(f"Recommended candidate: {recommended['candidate_key'] if recommended else 'none'}")
if __name__ == "__main__":
main()