diff --git a/README.md b/README.md index de08041..526b856 100644 --- a/README.md +++ b/README.md @@ -99,4 +99,5 @@ python -m pytest tests/operator_gui/ - [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md) - [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md) - [오프라인 설치 절차](docs/operations/offline-install.md) +- [캘리브레이션 코퍼스 절차](docs/operations/calibration-corpus.md) — 실이미지로 밴드 임계를 검증하는 법 - [구조 다이어그램 4종](docs/diagrams/) — 시스템 구성 · 증거 데이터 흐름 · 운영자 심사 · 제출물 상태 전이 (**내부 문서**, 납품물 아님) diff --git a/docs/INDEX.md b/docs/INDEX.md index 7406ed0..7f262e0 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,5 +1,7 @@ # Project INDEX +- 2026-09-22 · **캘리브레이션 측정 도구 신설 + 구조적 거동 첫 실측.** `SCORER_VERSION` 이 v1→v4 로 갔는데 v3(제공자별 풀링)·v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다 — 8/8 평가의 224조합 하네스가 저장소에 없고(그 문서의 두 수치 174/224=77.7% 와 82.6% 가 서로 안 맞아 격자가 달랐음을 시사) 과거와 비교 가능한 재현이 불가능해, 격자를 명시적으로 고정한 `scripts/score_distribution.py`(432조합)를 새 기준선으로 만들었다. **실측 결과(v4)**: 신뢰 출처 미등록 시 high 92% · 100점 포화 80% · **웹 신호만으로 high 13/24**, 신뢰 출처 등록 시 high 63% · 포화 38% · 웹 전용 high 0/24. 드러난 것 — v2 가 세운 "웹 존재만으로 high 불가" 원칙이 깨져 있다(presence 풀 60 + entity 풀 20 = 80, 두 풀의 **합**은 캡되지 않음), 그리고 **v3 의 제공자별 풀링이 이를 키웠다**(웹 전용 high 4/12 → 9/12, 전체 high 88% → 97%). 대응 후보(웹 유래 총합 상한)는 미적용 — 적용하면 또 한 번의 미측정 변경이 되므로 결정 후 진행한다. 함께 `scripts/sweep_thresholds.py`(판정 시점 `decisionSnapshot.riskScore` 기준 임계 스윕, 가짜 판정 6건으로 동작 검증)와 `docs/operations/calibration-corpus.md`(이미지 구성·쿼터 준비·투입·판정·측정 절차) 신설. **실이미지 코퍼스와 판정은 사람이 해야 한다** — 판정을 내가 만들면 측정이 성립하지 않는다. **ruff clean · pytest 645 passed** + - 2026-09-21 · **반려 키보드 완결** — 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다(8/8 평가 P2-10). `R` 이 메모 필드에 포커스만 주고, 입력 중에는 전역 단축키가 막혀서 거기서부터는 **마우스로만 제출**할 수 있는 구조였다 — 법적으로 가장 중요한 경로가 가장 느렸다. `R` → 사유 선택 모드(프리셋 1~9 번호 표시) → 숫자 1타로 반려 완료, 자유 서술은 메모 안에서 `Ctrl+Enter`. 화면의 번호가 곧 단축키가 되도록 프리셋 칩에 키 배지를 붙였다(외울 필요 없음). **숫자는 사유 선택 모드 안에서만 의미를 갖는다** — 모드 밖에서 잘못 누른 키 하나로 반려가 나가면 안 되고, 그 가드 순서를 테스트로 고정했다. 실렌더(Playwright): `R`→`2` 로 `{"decision":"rejected","memo":"라이선스 확인 불가로 반려"}`, 메모+`Ctrl+Enter` 로 `{"decision":"rejected","memo":"자유 서술 사유"}`, 콘솔 에러 0. 반려 후 승인·보류와 같이 다음 미심사 케이스로 이동한다. **ruff clean · pytest 645 passed**(정적 3건) - 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1) diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index d5b0f1b..6641082 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -220,7 +220,8 @@ app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 |---|---|---| | `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 | | src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 | -| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 | +| 웹 신호만으로 high 도달 | `analysis/risk_scoring.py` | v2 설계 원칙은 "웹 존재만으로 high 불가"인데 presence 풀(60) + entity 풀(20) = 80 이라 합이 캡되지 않는다. v3 의 제공자별 풀링이 이를 키웠다 — 웹 전용 조합의 high 가 4/12 → 9/12(9/22 실측, `scripts/score_distribution.py`). 대응 후보는 웹 유래 총합 상한이고 적용 시 `SCORER_VERSION` 을 올려야 한다 | +| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 — 절차는 `docs/operations/calibration-corpus.md`, 확정 판단은 `scripts/sweep_thresholds.py` | | 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 | | 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 | | 다이어그램에 한글 폰트 미번들 (내부 문서라 low) | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). **내부 문서로 확정(9/21)** 되어 현재는 low — 사내 머신에는 한글 폰트가 있다. 인수물로 승격하면 blocker | diff --git a/docs/operations/calibration-corpus.md b/docs/operations/calibration-corpus.md new file mode 100644 index 0000000..9810f24 --- /dev/null +++ b/docs/operations/calibration-corpus.md @@ -0,0 +1,134 @@ +# 캘리브레이션 코퍼스 — 실이미지로 밴드 임계를 검증하는 절차 + +이 프로젝트의 가장 큰 미검증 항목은 **위험도 밴드 임계(70/30)가 실제 판정과 한 번도 +대조된 적이 없다**는 것이다. 판정이 0건이라 `decision_stats` 도 빈 표를 낸다. +이 문서는 그 공백을 메우는 절차다. + +두 가지를 구분한다. + +| 대상 | 도구 | 사람 필요? | +|---|---|---| +| 스코어러의 **구조적 거동** (어떤 신호 조합이 어느 밴드로 가나) | `scripts/score_distribution.py` | 아니오 | +| **임계가 맞는지** (밴드 vs 운영자 판단) | `scripts/sweep_thresholds.py` | **예 — 실이미지 + 실판정** | + +전자는 지금 당장 돌릴 수 있고 스코어러를 바꿀 때마다 돌려야 한다. 후자는 아래 절차로 +코퍼스를 만든 뒤에만 의미가 있다. + +## 1. 이미지 구성이 성패를 가른다 + +기술이 아니라 여기서 갈린다. 현재 밴드 분포에 **low 가 0건**이고, 저위험으로 떨어지는 +이미지가 섞이지 않으면 미탐 지표(`lowBandRejectionRate`)의 분모가 0 이 된다. +그런데 `_ratio` 가 `0/0 → 0.0` 을 돌려주므로 화면에는 **"미탐 0%"로 표시된다** — +측정된 0 과 측정 불가가 구분되지 않는다(`store_workflow.py:398-399`). + +40장 기준 권장 구성: + +| 군 | 장수 | 기대 밴드 | 넣는 이유 | +|---|---|---|---| +| 자사 제작·오리지널 (웹에 없음) | 10 | low | **미탐 지표의 유일한 분모** | +| 라이선스 구매·계약처 제공분 | 8 | low~medium | 신뢰 출처 기능도 함께 검증 | +| 명백한 무단 사용 후보 (뉴스 사진·연예인·캐릭터) | 12 | high | 과탐 지표의 분모 | +| 애매한 것 (웹에 있으나 출처 불명, SNS 유통) | 8 | 경계 | **임계가 실제로 가르는 지점** | +| 위 중 2~3장을 리사이즈·재저장한 재제출 | 4 | — | 선례 승계·중복 탐지 검증 | + +## 2. 준비 — 안 하면 코퍼스가 오염된다 + +**네이버 일일 한도.** `COPYRIGHTER_NAVER_DAILY_LIMIT=100` 인데 자동 검색이 장당 최대 +5콜(이미지 3 + 블로그 1 + 웹 1)이라 20장째에 고갈된다. 고갈분은 `SEARCH_SKIPPED` +**0점으로 조용히** 처리되므로(`risk_scoring.py` 가 skipped 를 운영 상태로 보고 건너뛴다) +앞쪽과 뒤쪽 이미지의 증거 밀도가 달라진다. + +``` +.env: COPYRIGHTER_NAVER_DAILY_LIMIT=400 + COPYRIGHTER_GOOGLE_DAILY_LIMIT=200 +``` + +**Google Custom Search 는 현재 꺼져 있다.** `GOOGLE_CUSTOM_SEARCH_CX` 는 있는데 +`GOOGLE_CUSTOM_SEARCH_API_KEY` 가 없어 어댑터가 `None` 이다(`env_clients.py:243-246`). +이 상태로 돌리면 `google_search` 증거가 0행이다. 키를 넣거나, **증거원 2종 +(Vision+Naver)으로 간다고 코퍼스 메타에 명시**한다. 키를 넣으면 +`COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100` 도 같이 넣는다 — 없으면 로컬 +게이트가 무제한이라 구글 무료 한도를 막아주지 못한다. + +**DB 를 분리한다.** 기존 제출물은 스탬프 도입 이전 행이라 판정해도 스냅샷에 +`scorerVersion='legacy'` 로 찍혀 스윕에서 제외된다. + +```powershell +python run_copyrighter_server.py --db data/corpus.sqlite3 --images C:/corpus +``` + +## 3. 투입 + +**파일명을 새로 매긴다.** 파일명 stem 이 곧 제출 id 이고, 같은 stem 이면 +**조용히 1건으로 병합된다**(`image_store.py:94-101` 이 알려진 한계로 명시). 매니페스트 +(`submissions.json`)는 필요 없다 — 폴더를 재귀 스캔한다. HEIC/TIFF 는 미지원이다. + +```powershell +# 중복 검사 (결과가 비어야 한다) +Get-ChildItem C:/corpus -Recurse -File | Group-Object BaseName | Where-Object { $_.Count -gt 1 } + +# 10~15장씩 나눠서 — seed 가 write lock 을 잡는다 +Invoke-RestMethod -Method Post -Uri http://127.0.0.1:9500/api/submissions/reload +``` + +reload 는 멱등이다(분석된 건은 건너뛴다). 다만 **LLM 서술이 seed 1회당 20건 캡** +(`store_constants.py` `MAX_LLM_SUMMARIES_PER_SEED`)이라 40장이면 reload 를 2~3번 더 +호출해야 전부 채워진다. + +투입 시 자동으로 도는 것: 내부 분석(지문·얼굴·출처) → Vision 웹탐지 장당 1콜 → +검색결과 유사도 비교 → 얼굴 크롭 생성 → 네이버 자동 검색 → 재스코어링 → LLM 서술. +**얼굴 크롭의 외부 전송은 시드에 없다** — 케이스별 재분석을 눌러야 발생한다 +(`COPYRIGHTER_GOOGLE_FACE_CROP_SEARCH=true` 면 장당 Vision 최대 4콜을 더 쓴다). + +## 4. 판정 + +- **근거로 쓴 증거를 '사용'으로 표시한다.** 안 하면 내보내기의 `evidenceUsed` 가 + 빈칸이라 "무엇을 보고 판정했나"가 사라진다. +- **판정 중에 새 이미지를 넣지 않는다.** 폴더 리로드마다 전체 리스코어가 돌아 + 이미 판정한 케이스의 밴드가 움직이고 교차표가 소급 변동한다. +- 키보드: `A` 승인 · `H` 보류 · `R`→숫자 반려 · `N` 다음 미심사. + +## 5. 측정 + +```powershell +# 분모부터 — 콘솔 좌측 'S 통계' 에서 highBandDecided / lowBandDecided +# 각 5건 미만이면 비율을 해석하지 않는다 (콘솔은 색만 죽이고 숫자는 그대로 보여준다) + +python scripts/sweep_thresholds.py data/corpus.sqlite3 +``` + +스윕은 `decisionSnapshot.riskScore`(판정 시점 각인)를 쓴다. `decision_stats` 의 교차표는 +**현재** riskBand 기준이라 리스코어에 흔들리므로 확정 판단에 쓰지 않는다. + +읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다). +`under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다). + +## 참고 — 2026-09-22 시점의 구조적 거동 (판정 없이 측정한 것) + +`scripts/score_distribution.py` 로 432조합을 전수 계산한 결과(스코어러 v4): + +| | 신뢰 출처 미등록 | 신뢰 출처 등록 | +|---|---|---| +| high | 92% | 63% | +| medium | 6% | 29% | +| low | 0% | 7% | +| 100점 포화 | 80% | 38% | +| 웹 신호만으로 high | 13/24 | 0/24 | + +**이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의 +92% 가 high"라는 뜻이 아니며, 임계가 맞는지도 말해주지 않는다. 구조적 거동의 +기준선일 뿐이고, 스코어러를 바꿀 때 전후 비교에 쓴다. + +여기서 드러난 것 두 가지: + +1. **웹 신호만으로 high 에 도달하는 조합이 있다.** 2026-08-09 v2 가 세운 설계 원칙은 + "웹 존재만으로는 high 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데, + presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개 + 만으로 high 가 된다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않는다. +2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용 + 조합의 high 가 4/12 → 9/12 로, 전체 high 비율이 88% → 97% 로 올라간다. 교차 확증이 + high 에 도달해야 한다는 v3 의 의도대로 동작한 결과지만, 총량 통제가 없다. + +대응 후보(미적용, 결정 필요): 웹 유래 점수 총합에 상한을 두어 제공자 수와 무관하게 +웹 신호만으로는 high 에 닿지 않게 한다. 적용하면 `SCORER_VERSION` 을 올리고 이 표를 +다시 측정해야 한다. diff --git a/scripts/score_distribution.py b/scripts/score_distribution.py new file mode 100644 index 0000000..5d5b3ae --- /dev/null +++ b/scripts/score_distribution.py @@ -0,0 +1,224 @@ +"""위험도 스코어러의 밴드 분포를 신호 조합 전수로 측정한다. + +**이 스크립트가 재는 것과 재지 않는 것** + +재는 것: 스코어러의 구조적 거동. 어떤 신호 조합이 어느 밴드로 떨어지는지, +100점 포화가 얼마나 되는지, "웹 신호만으로 high 에 도달하는가"(v2 이후의 설계 +원칙)가 지켜지는지. + +재지 않는 것: **임계가 맞는지.** 이 격자는 신호 축을 균등 샘플링하므로 실제 큐의 +분포가 아니다. 임계 검증은 실이미지 + 운영자 판정으로만 가능하고 그것은 +scripts/sweep_thresholds.py 가 담당한다. + +**왜 만들었나**: SCORER_VERSION 이 v1→v2→v3→v4 로 갔는데 v3(제공자별 풀링)와 +v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다. 2026-08-08 평가가 쓴 +224조합 하네스는 저장소에 남아 있지 않고, 그 문서의 두 수치(174/224=77.7% 와 +82.6%)가 서로 맞지 않아 격자가 달랐음을 시사한다 — 즉 과거 숫자와 비교 가능한 +재현은 불가능하다. 그래서 격자를 여기에 **명시적으로** 고정해 새 기준선으로 삼는다. +앞으로 스코어러를 바꾸면 이 스크립트를 돌려 전후를 비교한다. + +사용법: + python scripts/score_distribution.py + python scripts/score_distribution.py --json +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import sys +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from rights_filter.analysis.risk_scoring import SCORER_VERSION, RiskScorer # noqa: E402 +from rights_filter.domain.records import Evidence, EvidenceSource # noqa: E402 + +TRUSTED_HOST = "trusted.example" +OUTSIDE_HOST = "outside.example" + +# --- 신호 축 ----------------------------------------------------------------- +# +# 각 축은 스코어러가 실제로 구분하는 입력만 담는다. provider 를 명시하는 이유는 +# v3 부터 풀과 dedup 버킷이 제공자별로 갈리기 때문이다 - 같은 match 타입이라도 +# 제공자가 다르면 점수가 달라진다. + +WEB_PRESENCE = { + "none": [], + "full": [("full", 0.9)], + "full+partial": [("full", 0.9), ("partial", 0.8)], + "all4": [("full", 0.9), ("partial", 0.8), ("page", 0.8), ("visual", 0.6)], +} +ENTITY_COUNT = {"0": 0, "1": 1, "3": 3} +FACE = {"none": None, "background": "background", "identifiable": "identifiable"} +FINGERPRINT = {"none": None, "weak": 0.5, "strong": 0.95} +NAVER = {"none": False, "promoted": True} +SECOND_PROVIDER = {"none": False, "cse_page": True} + + +def build_evidence(presence, entities, face, fingerprint, naver, second, host): + evidence = [] + for match, confidence in WEB_PRESENCE[presence]: + evidence.append( + Evidence( + source=EvidenceSource.WEB_DETECTION, + reason=f"vision {match}", + confidence=confidence, + data={"match": match, "provider": "google", "host": host}, + ) + ) + for index in range(ENTITY_COUNT[entities]): + evidence.append( + Evidence( + source=EvidenceSource.WEB_DETECTION, + reason=f"entity {index}", + confidence=0.9, + data={"match": "entity", "entity": f"E{index}", "provider": "google"}, + ) + ) + if SECOND_PROVIDER[second]: + # 독립 제공자가 따로 찾은 게시본. v3 의 제공자별 풀이 이것을 Vision 과 + # 합산하지 않고 따로 세는지가 여기서 드러난다. + evidence.append( + Evidence( + source=EvidenceSource.WEB_DETECTION, + reason="cse page", + confidence=0.35, + data={"match": "page", "provider": "google_custom_search", "host": host}, + ) + ) + if FACE[face]: + evidence.append( + Evidence( + source=EvidenceSource.FACE_PERSON, + reason="face", + confidence=1.0, + data={"tier": FACE[face]}, + ) + ) + if FINGERPRINT[fingerprint] is not None: + evidence.append( + Evidence( + source=EvidenceSource.FINGERPRINT, + reason="knowledge match", + confidence=1.0, + data={"similarity": FINGERPRINT[fingerprint]}, + ) + ) + if NAVER[naver]: + evidence.append( + Evidence( + source=EvidenceSource.NAVER_SEARCH, + reason="naver promoted", + confidence=0.8, + data={"promoted": True}, + ) + ) + return evidence + + +def combinations(): + return itertools.product( + WEB_PRESENCE, ENTITY_COUNT, FACE, FINGERPRINT, NAVER, SECOND_PROVIDER + ) + + +def measure(trusted: bool): + """trusted=True 면 모든 웹 행이 등록된 신뢰 출처에 게시된 것으로 본다.""" + host = TRUSTED_HOST if trusted else OUTSIDE_HOST + scorer = RiskScorer(frozenset({TRUSTED_HOST}) if trusted else None) + + bands = Counter() + saturated = 0 + web_only_high = 0 + web_only_total = 0 + rows = [] + + for presence, entities, face, fingerprint, naver, second in combinations(): + evidence = build_evidence(presence, entities, face, fingerprint, naver, second, host) + result = scorer.score(evidence) + bands[result.band] += 1 + if result.score == 100: + saturated += 1 + + # "웹 신호만" = 인물·지식DB·네이버가 전부 없는 조합. v2 가 세운 설계 + # 원칙은 이 부류가 high 에 도달하지 않는다는 것이다. + if face == "none" and fingerprint == "none" and naver == "none": + web_only_total += 1 + if result.band == "high": + web_only_high += 1 + + rows.append( + { + "presence": presence, + "entities": entities, + "face": face, + "fingerprint": fingerprint, + "naver": naver, + "second": second, + "score": result.score, + "band": result.band, + } + ) + + total = sum(bands.values()) + return { + "total": total, + "bands": {band: bands.get(band, 0) for band in ("high", "medium", "low")}, + "bandPct": { + band: round(100 * bands.get(band, 0) / total, 1) for band in ("high", "medium", "low") + }, + "saturated": saturated, + "saturatedPct": round(100 * saturated / total, 1), + "webOnlyHigh": web_only_high, + "webOnlyTotal": web_only_total, + "rows": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="전체 행을 JSON 으로 출력") + args = parser.parse_args() + + plain = measure(trusted=False) + trusted = measure(trusted=True) + + if args.json: + print(json.dumps({"scorer": SCORER_VERSION, "plain": plain, "trusted": trusted}, ensure_ascii=False)) + return 0 + + # 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력). + print("scorer %s | grid %d combinations" % (SCORER_VERSION, plain["total"])) + print("") + print("%-22s %8s %8s" % ("", "default", "trusted")) + for band in ("high", "medium", "low"): + print( + "%-22s %7d%% %7d%%" + % (band, plain["bandPct"][band], trusted["bandPct"][band]) + ) + print( + "%-22s %7d%% %7d%%" + % ("saturated (==100)", plain["saturatedPct"], trusted["saturatedPct"]) + ) + print("") + print( + "web-signal-only high : %d/%d (default) | %d/%d (trusted)" + % ( + plain["webOnlyHigh"], + plain["webOnlyTotal"], + trusted["webOnlyHigh"], + trusted["webOnlyTotal"], + ) + ) + print("") + print("counts default: %s" % plain["bands"]) + print("counts trusted: %s" % trusted["bands"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_thresholds.py b/scripts/sweep_thresholds.py new file mode 100644 index 0000000..bd0a285 --- /dev/null +++ b/scripts/sweep_thresholds.py @@ -0,0 +1,144 @@ +"""운영자 판정과 대조해 밴드 임계(기본 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())