"""운영자 판정과 대조해 밴드 임계(기본 70/30)가 맞는지 스윕한다. **왜 decision_stats 가 아니라 이것인가** `decision_stats` 의 교차표는 제출물의 **현재** riskBand 를 쓴다. 그런데 `_rescore_submission` 이 14개 지점에서 점수를 덮어쓰고 폴더 리로드마다 큐 전체를 리스코어하므로, 판정한 뒤 이미지를 더 넣으면 이미 판정된 케이스의 밴드가 바뀌어 교차표가 소급 변동한다. 판정 시점의 점수를 보존하는 것은 `decisionSnapshot` 뿐이다. 그래서 이 스크립트는 스냅샷에서 (판정 시점 점수, 판정) 쌍만 뽑아 임계를 스윕한다. **제외되는 행** — 제외 사유를 반드시 함께 읽어야 한다: unreviewed 아직 판정 안 함 no_snapshot 판정은 있으나 스냅샷이 없음 (스냅샷 도입 이전) legacy_pre_stamp 스코어러 스탬프 도입 이전 행. 원점수를 복원할 수 없다 **읽는 법** over = high 로 분류했는데 운영자가 승인한 건수 → 과탐. 많으면 high 임계가 낮다. under = low 로 분류했는데 운영자가 반려한 건수 → 미탐. 0 이 아니면 medium 임계가 높다. 분모(high_n / low_n)가 5 미만인 줄은 해석하지 않는다. 특히 low_n 이 0 이면 under 는 언제나 0 이므로 "미탐 없음"이 아니라 "측정 불가"다 — 코퍼스에 저위험으로 떨어지는 이미지가 섞이지 않으면 이 열은 영원히 0 이다. 사용법: python scripts/sweep_thresholds.py data/corpus.sqlite3 """ from __future__ import annotations import argparse import json import sqlite3 import sys from collections import Counter HIGH_CANDIDATES = (50, 60, 70, 80) MEDIUM_CANDIDATES = (20, 30, 40) MIN_DENOMINATOR = 5 def collect(db_path: str): conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row pairs = [] skipped = Counter() try: for row in conn.execute("select payload from submissions"): submission = json.loads(row["payload"]) decision = submission.get("decisionStatus", "unreviewed") if decision == "unreviewed": skipped["unreviewed"] += 1 continue snapshot = submission.get("decisionSnapshot") if not snapshot: skipped["no_snapshot"] += 1 continue if snapshot.get("scorerVersion") == "legacy": skipped["legacy_pre_stamp"] += 1 continue score = snapshot.get("riskScore") if score is None: skipped["no_score_in_snapshot"] += 1 continue pairs.append( { "id": str(submission.get("id", "")), "score": int(score), "band": snapshot.get("riskBand"), "decision": decision, "scorerVersion": snapshot.get("scorerVersion"), } ) finally: conn.close() return pairs, skipped def sweep(pairs): results = [] for high in HIGH_CANDIDATES: for medium in MEDIUM_CANDIDATES: if medium >= high: continue high_rows = [p for p in pairs if p["score"] >= high] low_rows = [p for p in pairs if p["score"] < medium] results.append( { "high": high, "medium": medium, "highN": len(high_rows), "overDetection": sum(1 for p in high_rows if p["decision"] == "approved"), "lowN": len(low_rows), "underDetection": sum(1 for p in low_rows if p["decision"] == "rejected"), } ) return results def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("db", help="판정이 쌓인 SQLite 경로") parser.add_argument("--json", action="store_true") args = parser.parse_args() pairs, skipped = collect(args.db) results = sweep(pairs) if args.json: print(json.dumps({"pairs": pairs, "skipped": dict(skipped), "sweep": results}, ensure_ascii=False)) return 0 # 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력). print("usable pairs: %d skipped: %s" % (len(pairs), dict(skipped))) if not pairs: print("") print("no decisions to sweep yet - see docs/operations/calibration-corpus.md") return 0 versions = Counter(p["scorerVersion"] for p in pairs) print("scorer versions in sample: %s" % dict(versions)) if len(versions) > 1: print("WARNING: mixed scorer versions - scores are not comparable across them") print("") print("%-18s %6s %6s %6s %6s" % ("thresholds", "highN", "over", "lowN", "under")) for item in results: flag = "" if item["highN"] >= MIN_DENOMINATOR and item["lowN"] >= MIN_DENOMINATOR else " (n<%d)" % MIN_DENOMINATOR print( "high>=%-3d med>=%-3d %6d %6d %6d %6d%s" % ( item["high"], item["medium"], item["highN"], item["overDetection"], item["lowN"], item["underDetection"], flag, ) ) return 0 if __name__ == "__main__": sys.exit(main())