Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Smoke Signal — Stage 12: Confidence Recalibration | |
| ================================================== | |
| Recomputes confidence thresholds from the full gold set using a simple | |
| precision/recall threshold analysis per region class. | |
| Outputs: | |
| - manifest/confidence_calibration.json | |
| - training/runs/<RUN_ID>_recalibration.json | |
| - manifest/run_log.csv entry (governance) | |
| """ | |
| import argparse | |
| import csv | |
| import json | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| ROOT = Path(__file__).resolve().parents[1] | |
| MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv" | |
| CALIBRATION_JSON = ROOT / "manifest" / "confidence_calibration.json" | |
| RUN_LOG_CSV = ROOT / "manifest" / "run_log.csv" | |
| GOLD_FILE = ROOT / "gold" / "gold_corrections.jsonl" | |
| RUNS_DIR = ROOT / "training" / "runs" | |
| ELIGIBLE_RIGHTS = {"public-domain", "licensed-owned", "controlled-internal"} | |
| BLOCKED_RIGHTS = {"unknown", "excluded"} | |
| RUN_LOG_FIELDS = [ | |
| "run_id", | |
| "date", | |
| "operator", | |
| "config_version", | |
| "schema_version", | |
| "source_batch", | |
| "pages_processed", | |
| "errors", | |
| "cost_usd", | |
| "output_path", | |
| "notes", | |
| ] | |
| def utc_now() -> datetime: | |
| return datetime.now(timezone.utc) | |
| def utc_iso() -> str: | |
| return utc_now().isoformat().replace("+00:00", "Z") | |
| def ensure_run_dirs() -> None: | |
| RUNS_DIR.mkdir(parents=True, exist_ok=True) | |
| def ensure_run_log() -> None: | |
| RUN_LOG_CSV.parent.mkdir(parents=True, exist_ok=True) | |
| if RUN_LOG_CSV.exists(): | |
| return | |
| with open(RUN_LOG_CSV, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS) | |
| writer.writeheader() | |
| def append_run_log(row: Dict[str, str]) -> None: | |
| ensure_run_log() | |
| with open(RUN_LOG_CSV, "a", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS) | |
| writer.writerow({k: row.get(k, "") for k in RUN_LOG_FIELDS}) | |
| def load_manifest() -> Dict[str, Dict[str, str]]: | |
| out: Dict[str, Dict[str, str]] = {} | |
| if not MANIFEST_CSV.exists(): | |
| return out | |
| with open(MANIFEST_CSV, newline="", encoding="utf-8") as f: | |
| for row in csv.DictReader(f): | |
| book_id = str(row.get("book_id", "")).strip() | |
| if book_id: | |
| out[book_id] = row | |
| return out | |
| def load_gold_records(path: Path) -> List[Dict]: | |
| rows: List[Dict] = [] | |
| if not path.exists(): | |
| return rows | |
| with open(path, encoding="utf-8") as f: | |
| for idx, line in enumerate(f, start=1): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| rec = json.loads(line) | |
| rec["_line"] = idx | |
| rows.append(rec) | |
| except json.JSONDecodeError: | |
| continue | |
| return rows | |
| def parse_bool(value) -> Optional[bool]: | |
| if isinstance(value, bool): | |
| return value | |
| if value is None: | |
| return None | |
| text = str(value).strip().lower() | |
| if text in {"true", "1", "yes", "y"}: | |
| return True | |
| if text in {"false", "0", "no", "n"}: | |
| return False | |
| return None | |
| def parse_confidence(value) -> Optional[float]: | |
| try: | |
| conf = float(value) | |
| return max(0.0, min(1.0, conf)) | |
| except (TypeError, ValueError): | |
| return None | |
| def infer_was_correct(rec: Dict) -> Optional[bool]: | |
| explicit = parse_bool(rec.get("was_correct")) | |
| if explicit is not None: | |
| return explicit | |
| final_text = str(rec.get("final_text", "")).strip() | |
| raw_text = str(rec.get("raw_text", rec.get("raw_ocr", ""))).strip() | |
| if final_text and raw_text: | |
| return final_text == raw_text | |
| return None | |
| def precision_recall_at_threshold(rows: List[Tuple[float, int]], threshold: float) -> Dict[str, float]: | |
| tp = fp = fn = tn = 0 | |
| for conf, label in rows: | |
| pred = 1 if conf >= threshold else 0 | |
| if pred == 1 and label == 1: | |
| tp += 1 | |
| elif pred == 1 and label == 0: | |
| fp += 1 | |
| elif pred == 0 and label == 1: | |
| fn += 1 | |
| else: | |
| tn += 1 | |
| precision = tp / (tp + fp) if (tp + fp) else 0.0 | |
| recall = tp / (tp + fn) if (tp + fn) else 0.0 | |
| return { | |
| "tp": tp, | |
| "fp": fp, | |
| "fn": fn, | |
| "tn": tn, | |
| "precision": precision, | |
| "recall": recall, | |
| } | |
| def f_beta(precision: float, recall: float, beta: float) -> float: | |
| if precision <= 0 and recall <= 0: | |
| return 0.0 | |
| beta2 = beta * beta | |
| denom = (beta2 * precision) + recall | |
| if denom <= 0: | |
| return 0.0 | |
| return (1 + beta2) * (precision * recall) / denom | |
| def select_thresholds( | |
| rows: List[Tuple[float, int]], | |
| auto_precision_target: float, | |
| auto_recall_floor: float, | |
| review_recall_target: float, | |
| review_precision_floor: float, | |
| quarantine_gap: float, | |
| ) -> Dict: | |
| unique_thresholds = sorted({round(conf, 4) for conf, _ in rows}) | |
| if not unique_thresholds: | |
| return { | |
| "auto_accept": 0.85, | |
| "review": 0.60, | |
| "quarantine": 0.35, | |
| "metrics": {}, | |
| } | |
| # Add boundary values so we can always compute a fallback. | |
| thresholds = sorted(set([0.0, 1.0] + unique_thresholds)) | |
| # Auto-accept: highest threshold meeting strict precision target. | |
| auto_t = None | |
| for t in thresholds: | |
| m = precision_recall_at_threshold(rows, t) | |
| if m["precision"] >= auto_precision_target and m["recall"] >= auto_recall_floor: | |
| auto_t = t | |
| if auto_t is None: | |
| # Fallback: maximize F0.5 to prioritize precision. | |
| auto_t = max(thresholds, key=lambda t: f_beta( | |
| precision_recall_at_threshold(rows, t)["precision"], | |
| precision_recall_at_threshold(rows, t)["recall"], | |
| beta=0.5, | |
| )) | |
| # Review threshold: below/at auto threshold, try to capture most true positives. | |
| review_candidates = [t for t in thresholds if t <= auto_t] | |
| review_t = None | |
| for t in review_candidates: | |
| m = precision_recall_at_threshold(rows, t) | |
| if m["recall"] >= review_recall_target and m["precision"] >= review_precision_floor: | |
| review_t = t | |
| break | |
| if review_t is None: | |
| # Fallback: maximize F1 while respecting t <= auto_t. | |
| review_t = max(review_candidates, key=lambda t: f_beta( | |
| precision_recall_at_threshold(rows, t)["precision"], | |
| precision_recall_at_threshold(rows, t)["recall"], | |
| beta=1.0, | |
| )) | |
| review_t = min(review_t, auto_t) | |
| quarantine_t = max(0.0, review_t - quarantine_gap) | |
| quarantine_t = min(quarantine_t, review_t) | |
| # Round for readability and stable diffs. | |
| auto_t = round(float(auto_t), 3) | |
| review_t = round(float(review_t), 3) | |
| quarantine_t = round(float(quarantine_t), 3) | |
| # Guarantee monotonic order. | |
| if review_t > auto_t: | |
| review_t = auto_t | |
| if quarantine_t > review_t: | |
| quarantine_t = review_t | |
| return { | |
| "auto_accept": auto_t, | |
| "review": review_t, | |
| "quarantine": quarantine_t, | |
| "metrics": { | |
| "auto": precision_recall_at_threshold(rows, auto_t), | |
| "review": precision_recall_at_threshold(rows, review_t), | |
| "quarantine": precision_recall_at_threshold(rows, quarantine_t), | |
| }, | |
| } | |
| def build_region_rows( | |
| gold_records: List[Dict], | |
| manifest: Dict[str, Dict[str, str]], | |
| rights_class_filter: Optional[str], | |
| ) -> Tuple[Dict[str, List[Tuple[float, int]]], Dict[str, int]]: | |
| by_region: Dict[str, List[Tuple[float, int]]] = {} | |
| counters = { | |
| "input": len(gold_records), | |
| "used": 0, | |
| "skipped_missing_book": 0, | |
| "skipped_missing_manifest": 0, | |
| "skipped_blocked_rights": 0, | |
| "skipped_rights_filter": 0, | |
| "skipped_missing_confidence": 0, | |
| "skipped_missing_label": 0, | |
| } | |
| for rec in gold_records: | |
| book_id = str(rec.get("book_id", "")).strip() | |
| if not book_id: | |
| counters["skipped_missing_book"] += 1 | |
| continue | |
| manifest_row = manifest.get(book_id) | |
| if not manifest_row: | |
| counters["skipped_missing_manifest"] += 1 | |
| continue | |
| rights = str(manifest_row.get("rights_class", "unknown")).strip().lower() | |
| if rights in BLOCKED_RIGHTS or rights not in ELIGIBLE_RIGHTS: | |
| counters["skipped_blocked_rights"] += 1 | |
| continue | |
| if rights_class_filter and rights != rights_class_filter: | |
| counters["skipped_rights_filter"] += 1 | |
| continue | |
| conf = parse_confidence(rec.get("confidence")) | |
| if conf is None: | |
| counters["skipped_missing_confidence"] += 1 | |
| continue | |
| was_correct = infer_was_correct(rec) | |
| if was_correct is None: | |
| counters["skipped_missing_label"] += 1 | |
| continue | |
| # Positive class = OCR output was correct. | |
| label = 1 if was_correct else 0 | |
| region_class = str(rec.get("region_class", "narration")).strip() or "narration" | |
| by_region.setdefault(region_class, []).append((conf, label)) | |
| by_region.setdefault("_default", []).append((conf, label)) | |
| counters["used"] += 1 | |
| return by_region, counters | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Smoke Signal — Stage 12: Recalibration") | |
| parser.add_argument("--gold-file", default=str(GOLD_FILE), help="Path to gold corrections JSONL") | |
| parser.add_argument( | |
| "--rights-class", | |
| default=None, | |
| choices=sorted(ELIGIBLE_RIGHTS), | |
| help="Optional rights class filter", | |
| ) | |
| parser.add_argument("--operator", default="codex", help="Operator for governance log") | |
| parser.add_argument("--auto-precision-target", type=float, default=0.98) | |
| parser.add_argument("--auto-recall-floor", type=float, default=0.20) | |
| parser.add_argument("--review-recall-target", type=float, default=0.90) | |
| parser.add_argument("--review-precision-floor", type=float, default=0.60) | |
| parser.add_argument("--quarantine-gap", type=float, default=0.20) | |
| parser.add_argument("--min-samples-per-class", type=int, default=10) | |
| parser.add_argument("--run-id", default=None, help="Optional explicit run id") | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| ensure_run_dirs() | |
| run_id = args.run_id or f"SS-CAL-{utc_now().strftime('%Y%m%d-%H%M%S')}" | |
| gold_path = Path(args.gold_file).expanduser().resolve() | |
| if not gold_path.exists(): | |
| raise FileNotFoundError(f"Gold file not found: {gold_path}") | |
| manifest = load_manifest() | |
| if not manifest: | |
| raise RuntimeError("Manifest is empty. Cannot enforce rights controls.") | |
| gold_records = load_gold_records(gold_path) | |
| if not gold_records: | |
| raise RuntimeError("No valid gold records found.") | |
| by_region, counters = build_region_rows(gold_records, manifest, args.rights_class) | |
| if counters["used"] == 0: | |
| raise RuntimeError(f"No usable records for recalibration after filtering. Counters: {counters}") | |
| calibration: Dict[str, Dict] = {} | |
| report_regions: Dict[str, Dict] = {} | |
| for region_class, rows in by_region.items(): | |
| if len(rows) < args.min_samples_per_class and region_class != "_default": | |
| # Too little data for a reliable per-class threshold; defer to default. | |
| continue | |
| selected = select_thresholds( | |
| rows=rows, | |
| auto_precision_target=args.auto_precision_target, | |
| auto_recall_floor=args.auto_recall_floor, | |
| review_recall_target=args.review_recall_target, | |
| review_precision_floor=args.review_precision_floor, | |
| quarantine_gap=args.quarantine_gap, | |
| ) | |
| corrections = sum(1 for _, label in rows if label == 0) | |
| calibration[region_class] = { | |
| "auto_accept": selected["auto_accept"], | |
| "review": selected["review"], | |
| "quarantine": selected["quarantine"], | |
| "corrections": corrections, | |
| } | |
| report_regions[region_class] = { | |
| "samples": len(rows), | |
| "correct": sum(1 for _, label in rows if label == 1), | |
| "incorrect": sum(1 for _, label in rows if label == 0), | |
| "thresholds": calibration[region_class], | |
| "metrics": selected["metrics"], | |
| } | |
| # Ensure required defaults exist for runtime readers. | |
| if "_default" not in calibration: | |
| calibration["_default"] = { | |
| "auto_accept": 0.85, | |
| "review": 0.60, | |
| "quarantine": 0.35, | |
| "corrections": 0, | |
| } | |
| default_entry = calibration["_default"] | |
| for cls in ["narration", "dialogue-speech-bubble", "caption", "title", "sign-label"]: | |
| if cls not in calibration: | |
| calibration[cls] = dict(default_entry) | |
| CALIBRATION_JSON.parent.mkdir(parents=True, exist_ok=True) | |
| with open(CALIBRATION_JSON, "w", encoding="utf-8") as f: | |
| json.dump(calibration, f, indent=2, ensure_ascii=False) | |
| report = { | |
| "run_id": run_id, | |
| "generated_at": utc_iso(), | |
| "config_version": "ss_confidence_calibration_v0.1", | |
| "schema_version": "ss_confidence_calibration_report_v1", | |
| "gold_file": str(gold_path), | |
| "rights_class_filter": args.rights_class, | |
| "counters": counters, | |
| "regions": report_regions, | |
| "output_file": str(CALIBRATION_JSON), | |
| } | |
| report_path = RUNS_DIR / f"{run_id}_recalibration.json" | |
| with open(report_path, "w", encoding="utf-8") as f: | |
| json.dump(report, f, indent=2, ensure_ascii=False) | |
| append_run_log( | |
| { | |
| "run_id": run_id, | |
| "date": utc_now().date().isoformat(), | |
| "operator": args.operator, | |
| "config_version": "ss_confidence_calibration_v0.1", | |
| "schema_version": "ss_confidence_calibration_report_v1", | |
| "source_batch": args.rights_class or "auto", | |
| "pages_processed": str(counters["used"]), | |
| "errors": str( | |
| counters["skipped_missing_book"] | |
| + counters["skipped_missing_manifest"] | |
| + counters["skipped_blocked_rights"] | |
| + counters["skipped_rights_filter"] | |
| + counters["skipped_missing_confidence"] | |
| + counters["skipped_missing_label"] | |
| ), | |
| "cost_usd": "", | |
| "output_path": str(CALIBRATION_JSON.relative_to(ROOT)), | |
| "notes": json.dumps( | |
| { | |
| "auto_precision_target": args.auto_precision_target, | |
| "review_recall_target": args.review_recall_target, | |
| "regions_calibrated": sorted(report_regions.keys()), | |
| }, | |
| ensure_ascii=False, | |
| ), | |
| } | |
| ) | |
| print(f"Recalibration complete: {CALIBRATION_JSON}") | |
| print(f"Report: {report_path}") | |
| print(f"Governance log updated: {RUN_LOG_CSV}") | |
| if __name__ == "__main__": | |
| main() | |