AI 의심도 백분위 캘리브레이션 (학습·지표 없이 운영)
AI 생성 표본이 없어 정확도를 측정할 수 없는 상태에서, 규칙 기반 휴리스틱을 실제로 쓸 수 있게 만든다. 점수의 의미를 "AI일 확률"에서 "등록 코퍼스의 인간 저작물 대비 문체 이례도"로 재정의해, 라벨 없이도 참인 진술이 되도록 했다. 덤으로 high 배지 비율이 정의상 고정되어 검토 부하가 예측 가능해진다. - ai_detector: low_cut/high_cut 주입 지원. 둘 다 주어질 때만 적용하고, 적용되면 model_version 에 +corpus-percentile 을 붙여 컷 출처를 드러낸다. is_stub 은 여전히 true — 컷을 맞췄을 뿐 학습된 모델이 아니다. - calibrate_cuts/percentile/score_text_heuristic 순수 함수 추가. - scripts/calibrate_ai_detector_cuts.py: 코퍼스 표본의 점수 분포에서 백분위 컷을 산출하고 .env 두 줄을 출력한다. - 규칙 점수가 상단에서 clip 되어 p90=p98 이 되면 그 컷은 high 배지를 영원히 0건으로 만든다. 이 경우 .env 를 출력하지 않고 exit 3 으로 중단하며 saturated_share/distinct_scores 진단을 남긴다. 유효 표본 100건 미만은 exit 2. - kiwipiepy 유무가 점수를 바꾸므로 실제 사용 여부를 리포트에 기록하고 경고한다. 기본값은 그대로 비활성(AI_DETECTOR_ALLOW_HEURISTIC=false)이라 켜지 않으면 동작이 바뀌지 않는다. 컷 미설정 시에는 자리표시자임을 note 로 경고한다. 테스트 180건 통과 (신규 13건). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4461f39c7
commit
a530d2139f
@ -30,7 +30,13 @@ PERSISTENT_MIN_COVERAGE=0.30
|
|||||||
PERSISTENT_RERANK_TOP_K=20
|
PERSISTENT_RERANK_TOP_K=20
|
||||||
PRECEDENTS_PATH=./data/precedents/precedents.jsonl
|
PRECEDENTS_PATH=./data/precedents/precedents.jsonl
|
||||||
AI_DETECTOR_MODEL_PATH=./data/models/ai_detector.joblib
|
AI_DETECTOR_MODEL_PATH=./data/models/ai_detector.joblib
|
||||||
|
# 학습 모델이 없을 때 규칙 기반 점수를 낼지. true 면 is_stub=true 로 노출된다.
|
||||||
AI_DETECTOR_ALLOW_HEURISTIC=false
|
AI_DETECTOR_ALLOW_HEURISTIC=false
|
||||||
|
# 휴리스틱 컷. scripts/calibrate_ai_detector_cuts.py 출력값을 넣는다.
|
||||||
|
# 비워두면 근거 없는 자리표시자(0.40/0.70)가 쓰이므로 배지가 무의미해진다.
|
||||||
|
# 값이 없으면 아래 두 줄은 주석 처리한 채로 둘 것 (빈 값은 파싱 오류).
|
||||||
|
#AI_DETECTOR_LOW_CUT=
|
||||||
|
#AI_DETECTOR_HIGH_CUT=
|
||||||
AI_DETECTOR_USE_POS=true
|
AI_DETECTOR_USE_POS=true
|
||||||
|
|
||||||
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
# PDF VII-4 권장 보수적 임계값 (정밀도 우선)
|
||||||
|
|||||||
@ -40,6 +40,11 @@ class Settings(BaseSettings):
|
|||||||
ai_detector_model_path: str = "./data/models/ai_detector.joblib"
|
ai_detector_model_path: str = "./data/models/ai_detector.joblib"
|
||||||
ai_detector_allow_heuristic: bool = False
|
ai_detector_allow_heuristic: bool = False
|
||||||
ai_detector_use_pos: bool = True
|
ai_detector_use_pos: bool = True
|
||||||
|
# 휴리스틱 모드 의심도 구간. scripts/calibrate_ai_detector_cuts.py 가 등록
|
||||||
|
# 코퍼스(전부 인간 저작)의 점수 분포에서 백분위로 산출한다. 둘 다 설정해야
|
||||||
|
# 적용되며, 없으면 근거 없는 자리표시자(0.40/0.70)가 쓰인다.
|
||||||
|
ai_detector_low_cut: float | None = None
|
||||||
|
ai_detector_high_cut: float | None = None
|
||||||
|
|
||||||
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
# PDF VII-4 권장: 정밀도 우선 보수적 임계값
|
||||||
similarity_threshold: float = 0.85
|
similarity_threshold: float = 0.85
|
||||||
|
|||||||
@ -59,10 +59,17 @@ ENV_MODEL_PATH = "AI_DETECTOR_MODEL_PATH"
|
|||||||
DEFAULT_MODEL_PATH = "./data/models/ai_detector.joblib"
|
DEFAULT_MODEL_PATH = "./data/models/ai_detector.joblib"
|
||||||
|
|
||||||
#: 잠정 의심도 구간. 학습 시 target FPR 기준으로 재산출되어 아티팩트에 저장되며,
|
#: 잠정 의심도 구간. 학습 시 target FPR 기준으로 재산출되어 아티팩트에 저장되며,
|
||||||
#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 모델이 없을 때의 자리표시자다.
|
#: 아티팩트 값이 있으면 그쪽이 우선한다. 아래는 아무 근거 없는 자리표시자이므로,
|
||||||
|
#: 휴리스틱 모드로 운영할 때는 scripts/calibrate_ai_detector_cuts.py 로 산출한
|
||||||
|
#: 백분위 컷을 설정으로 주입할 것.
|
||||||
DEFAULT_LOW_CUT = 0.40
|
DEFAULT_LOW_CUT = 0.40
|
||||||
DEFAULT_HIGH_CUT = 0.70
|
DEFAULT_HIGH_CUT = 0.70
|
||||||
|
|
||||||
|
#: 백분위 캘리브레이션 기본값. 등록 코퍼스(전부 인간 저작) 대비 상위 10%를
|
||||||
|
#: medium, 상위 2%를 high 로 본다. "AI 확률"이 아니라 "인간 저작 대비 이례도"다.
|
||||||
|
DEFAULT_LOW_PERCENTILE = 90.0
|
||||||
|
DEFAULT_HIGH_PERCENTILE = 98.0
|
||||||
|
|
||||||
Provenance = Literal["human", "ai", "mixed", "edited", "unknown"]
|
Provenance = Literal["human", "ai", "mixed", "edited", "unknown"]
|
||||||
SuspicionLevel = Literal["low", "medium", "high"]
|
SuspicionLevel = Literal["low", "medium", "high"]
|
||||||
|
|
||||||
@ -592,6 +599,57 @@ def _heuristic_score(feats: dict[str, float]) -> float:
|
|||||||
return max(0.0, min(1.0, sum(positions) / len(positions)))
|
return max(0.0, min(1.0, sum(positions) / len(positions)))
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: Sequence[float], pct: float) -> float:
|
||||||
|
"""선형 보간 백분위. numpy 없이도 동작하도록 직접 구현한다."""
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
ordered = sorted(values)
|
||||||
|
if len(ordered) == 1:
|
||||||
|
return float(ordered[0])
|
||||||
|
pos = (len(ordered) - 1) * max(0.0, min(100.0, pct)) / 100.0
|
||||||
|
low = int(math.floor(pos))
|
||||||
|
high = int(math.ceil(pos))
|
||||||
|
if low == high:
|
||||||
|
return float(ordered[low])
|
||||||
|
return float(ordered[low] + (ordered[high] - ordered[low]) * (pos - low))
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_cuts(
|
||||||
|
human_scores: Sequence[float],
|
||||||
|
low_percentile: float = DEFAULT_LOW_PERCENTILE,
|
||||||
|
high_percentile: float = DEFAULT_HIGH_PERCENTILE,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""인간 저작 코퍼스 점수 분포 → (low_cut, high_cut).
|
||||||
|
|
||||||
|
라벨이 전혀 필요 없다. "AI가 쓴 글의 점수는 얼마인가"가 아니라 "우리 코퍼스의
|
||||||
|
인간 저작물 중 상위 몇 %인가"로 컷을 정의하기 때문이다. 그래서 정확도를
|
||||||
|
측정하지 않고도 참인 진술("등록 자서전 대비 상위 2% 이례적 문체")이 되고,
|
||||||
|
'high' 배지 비율이 정의상 고정되어 검토 부하도 예측 가능해진다.
|
||||||
|
|
||||||
|
두 백분위가 같은 값으로 뭉개지면(분포가 평평한 경우) high 를 살짝 올려
|
||||||
|
medium 구간이 사라지지 않게 한다.
|
||||||
|
"""
|
||||||
|
if not human_scores:
|
||||||
|
raise ValueError("점수 표본이 비어 있어 컷을 산출할 수 없습니다.")
|
||||||
|
if not 0.0 <= low_percentile < high_percentile <= 100.0:
|
||||||
|
raise ValueError(
|
||||||
|
f"백분위는 0 <= low < high <= 100 이어야 합니다: {low_percentile}, {high_percentile}"
|
||||||
|
)
|
||||||
|
low = percentile(human_scores, low_percentile)
|
||||||
|
high = percentile(human_scores, high_percentile)
|
||||||
|
if high <= low:
|
||||||
|
high = min(1.0, low + 0.01)
|
||||||
|
return round(low, 4), round(high, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def score_text_heuristic(text: str, use_pos: bool = True) -> float | None:
|
||||||
|
"""캘리브레이션용 단일 텍스트 점수. 너무 짧으면 None(표본에서 제외)."""
|
||||||
|
norm = normalize_text(text)
|
||||||
|
if len(norm) < HARD_MIN_CHARS:
|
||||||
|
return None
|
||||||
|
return _heuristic_score(extract_features(norm, use_pos=use_pos))
|
||||||
|
|
||||||
|
|
||||||
def _heuristic_contributions(feats: dict[str, float]) -> list[tuple[str, float]]:
|
def _heuristic_contributions(feats: dict[str, float]) -> list[tuple[str, float]]:
|
||||||
"""휴리스틱에서 어느 특징이 점수를 끌어올렸는지."""
|
"""휴리스틱에서 어느 특징이 점수를 끌어올렸는지."""
|
||||||
out: list[tuple[str, float]] = []
|
out: list[tuple[str, float]] = []
|
||||||
@ -700,12 +758,17 @@ class AiGenerationDetector:
|
|||||||
model_path: str | Path | None = None,
|
model_path: str | Path | None = None,
|
||||||
allow_heuristic: bool = False,
|
allow_heuristic: bool = False,
|
||||||
use_pos: bool = True,
|
use_pos: bool = True,
|
||||||
|
low_cut: float | None = None,
|
||||||
|
high_cut: float | None = None,
|
||||||
):
|
):
|
||||||
self.model_path = str(
|
self.model_path = str(
|
||||||
model_path or os.environ.get(ENV_MODEL_PATH) or DEFAULT_MODEL_PATH
|
model_path or os.environ.get(ENV_MODEL_PATH) or DEFAULT_MODEL_PATH
|
||||||
)
|
)
|
||||||
self.allow_heuristic = allow_heuristic
|
self.allow_heuristic = allow_heuristic
|
||||||
self.use_pos = use_pos
|
self.use_pos = use_pos
|
||||||
|
# 휴리스틱 모드에서 쓸 백분위 컷. 둘 다 주어졌을 때만 적용한다.
|
||||||
|
self.low_cut = low_cut
|
||||||
|
self.high_cut = high_cut
|
||||||
self.artifact = load_artifact(self.model_path)
|
self.artifact = load_artifact(self.model_path)
|
||||||
|
|
||||||
# -- 모드 --------------------------------------------------------------
|
# -- 모드 --------------------------------------------------------------
|
||||||
@ -720,11 +783,26 @@ class AiGenerationDetector:
|
|||||||
def model_version(self) -> str:
|
def model_version(self) -> str:
|
||||||
if self.artifact is not None:
|
if self.artifact is not None:
|
||||||
return self.artifact.model_version
|
return self.artifact.model_version
|
||||||
return self.HEURISTIC_VERSION if self.allow_heuristic else "unavailable"
|
if not self.allow_heuristic:
|
||||||
|
return "unavailable"
|
||||||
|
# 컷이 코퍼스로 캘리브레이션되었는지를 버전 문자열에 드러낸다.
|
||||||
|
return (
|
||||||
|
f"{self.HEURISTIC_VERSION}+corpus-percentile"
|
||||||
|
if self.cuts_calibrated else self.HEURISTIC_VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cuts_calibrated(self) -> bool:
|
||||||
|
"""컷이 자리표시자가 아니라 실제 코퍼스 분포에서 나왔는지."""
|
||||||
|
if self.artifact is not None:
|
||||||
|
return True
|
||||||
|
return self.low_cut is not None and self.high_cut is not None
|
||||||
|
|
||||||
def _cuts(self) -> tuple[float, float]:
|
def _cuts(self) -> tuple[float, float]:
|
||||||
if self.artifact is not None:
|
if self.artifact is not None:
|
||||||
return self.artifact.low_cut, self.artifact.high_cut
|
return self.artifact.low_cut, self.artifact.high_cut
|
||||||
|
if self.low_cut is not None and self.high_cut is not None:
|
||||||
|
return self.low_cut, self.high_cut
|
||||||
return DEFAULT_LOW_CUT, DEFAULT_HIGH_CUT
|
return DEFAULT_LOW_CUT, DEFAULT_HIGH_CUT
|
||||||
|
|
||||||
def _level(self, score: float) -> SuspicionLevel:
|
def _level(self, score: float) -> SuspicionLevel:
|
||||||
@ -862,10 +940,17 @@ class AiGenerationDetector:
|
|||||||
segments = self._score_segments(norm) if with_segments else []
|
segments = self._score_segments(norm) if with_segments else []
|
||||||
provenance = self._infer_provenance(score, segments)
|
provenance = self._infer_provenance(score, segments)
|
||||||
|
|
||||||
if self.artifact is None:
|
if self.artifact is None and self.cuts_calibrated:
|
||||||
note = (
|
note = (
|
||||||
"⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델이 아니며 "
|
"학습 모델이 아니라 언어특징 규칙 점수입니다. 'AI일 확률'이 아니라 "
|
||||||
"판정 근거로 사용할 수 없습니다. 검토 우선순위 참고용."
|
"등록 코퍼스의 인간 저작물 대비 문체 이례도이며, 검토 우선순위 "
|
||||||
|
"정렬용입니다. 판정 근거로 사용할 수 없습니다."
|
||||||
|
)
|
||||||
|
elif self.artifact is None:
|
||||||
|
note = (
|
||||||
|
"⚠️ 미검증 휴리스틱 baseline 입니다. 학습된 모델도 아니고 컷도 "
|
||||||
|
"캘리브레이션되지 않았습니다(자리표시자). 판정 근거로 사용할 수 "
|
||||||
|
"없습니다. scripts/calibrate_ai_detector_cuts.py 로 컷을 산출하세요."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
note = (
|
note = (
|
||||||
@ -1008,21 +1093,26 @@ def get_ai_detector(
|
|||||||
model_path: str | Path | None = None,
|
model_path: str | Path | None = None,
|
||||||
allow_heuristic: bool | None = None,
|
allow_heuristic: bool | None = None,
|
||||||
use_pos: bool = True,
|
use_pos: bool = True,
|
||||||
|
low_cut: float | None = None,
|
||||||
|
high_cut: float | None = None,
|
||||||
) -> AiGenerationDetector:
|
) -> AiGenerationDetector:
|
||||||
"""탐지기 인스턴스(프로세스 캐시).
|
"""탐지기 인스턴스(프로세스 캐시).
|
||||||
|
|
||||||
allow_heuristic 을 생략하면 환경변수 AI_DETECTOR_ALLOW_HEURISTIC 을 따르고,
|
allow_heuristic 을 생략하면 환경변수 AI_DETECTOR_ALLOW_HEURISTIC 을 따르고,
|
||||||
그것도 없으면 False(= 모델 없으면 unavailable) 다. 기본값을 False 로 두는
|
그것도 없으면 False(= 모델 없으면 unavailable) 다. 기본값을 False 로 두는
|
||||||
이유는, 미검증 점수가 조용히 운영에 노출되는 상황을 막기 위해서다.
|
이유는, 미검증 점수가 조용히 운영에 노출되는 상황을 막기 위해서다.
|
||||||
|
|
||||||
|
low_cut/high_cut 은 휴리스틱 모드에서만 쓰이며, 둘 다 주어져야 적용된다.
|
||||||
"""
|
"""
|
||||||
if allow_heuristic is None:
|
if allow_heuristic is None:
|
||||||
allow_heuristic = os.environ.get(
|
allow_heuristic = os.environ.get(
|
||||||
"AI_DETECTOR_ALLOW_HEURISTIC", ""
|
"AI_DETECTOR_ALLOW_HEURISTIC", ""
|
||||||
).strip().lower() in {"1", "true", "yes"}
|
).strip().lower() in {"1", "true", "yes"}
|
||||||
key = (str(model_path or ""), bool(allow_heuristic), bool(use_pos))
|
key = (str(model_path or ""), bool(allow_heuristic), bool(use_pos), low_cut, high_cut)
|
||||||
if key not in _detector_cache:
|
if key not in _detector_cache:
|
||||||
_detector_cache[key] = AiGenerationDetector(
|
_detector_cache[key] = AiGenerationDetector(
|
||||||
model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos
|
model_path=model_path, allow_heuristic=allow_heuristic, use_pos=use_pos,
|
||||||
|
low_cut=low_cut, high_cut=high_cut,
|
||||||
)
|
)
|
||||||
return _detector_cache[key]
|
return _detector_cache[key]
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,8 @@ class PlagiarismDetector:
|
|||||||
self.settings.ai_detector_model_path,
|
self.settings.ai_detector_model_path,
|
||||||
allow_heuristic=self.settings.ai_detector_allow_heuristic,
|
allow_heuristic=self.settings.ai_detector_allow_heuristic,
|
||||||
use_pos=self.settings.ai_detector_use_pos,
|
use_pos=self.settings.ai_detector_use_pos,
|
||||||
|
low_cut=self.settings.ai_detector_low_cut,
|
||||||
|
high_cut=self.settings.ai_detector_high_cut,
|
||||||
)
|
)
|
||||||
self._persistent: PersistentCorpusIndex | None = None
|
self._persistent: PersistentCorpusIndex | None = None
|
||||||
|
|
||||||
|
|||||||
@ -269,3 +269,61 @@ python -m pytest tests/test_ai_detector.py -q
|
|||||||
일치 / unavailable 시 점수 미산출 / 휴리스틱의 스텁 표기와 방향성 /
|
일치 / unavailable 시 점수 미산출 / 휴리스틱의 스텁 표기와 방향성 /
|
||||||
짧은 텍스트 거부·경고 / 구간 오프셋 유효성 / provenance 규칙 / 추론 실패 전파 /
|
짧은 텍스트 거부·경고 / 구간 오프셋 유효성 / provenance 규칙 / 추론 실패 전파 /
|
||||||
특징 불일치 아티팩트 로드 거부 / JSON 직렬화.
|
특징 불일치 아티팩트 로드 거부 / JSON 직렬화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 지표 없이 운영하기 — 백분위 컷 (권장 경로)
|
||||||
|
|
||||||
|
AI 생성 표본이 없어 정확도를 측정할 수 없을 때의 실용 경로다. **모델 학습도
|
||||||
|
성능지표도 없이** 규칙 점수만으로 검토 우선순위를 매긴다.
|
||||||
|
|
||||||
|
### 8.1 점수의 의미를 바꾼다
|
||||||
|
|
||||||
|
"AI일 확률"은 라벨 없이 측정할 수 없다. 대신 등록 코퍼스가 전부 인간 저작이라는
|
||||||
|
사실을 이용해, 그 점수 분포의 상위 백분위를 컷으로 잡는다. 그러면 결과는
|
||||||
|
|
||||||
|
> "등록 자서전 대비 상위 2% 이례적 문체 → 우선 검토"
|
||||||
|
|
||||||
|
가 되며, 이 진술은 **정확도를 측정하지 않아도 참**이다. 덤으로 `high` 배지 비율이
|
||||||
|
정의상 고정되어 검토 부하가 예측 가능해진다. 반면 "AI 확률 72%"는 어떤 근거로도
|
||||||
|
방어할 수 없다. 이 차이 때문에 백분위 컷을 권장한다.
|
||||||
|
|
||||||
|
### 8.2 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서버와 같은 환경(kiwipiepy 포함)에서 실행할 것 — 품사 특징 유무가 점수를 바꾼다
|
||||||
|
python scripts/calibrate_ai_detector_cuts.py \
|
||||||
|
--database data/runtime/corpus.sqlite3 --sample 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
출력된 두 줄을 `.env` 에 넣고 `AI_DETECTOR_ALLOW_HEURISTIC=true` 와 함께 재시작한다.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AI_DETECTOR_ALLOW_HEURISTIC=true
|
||||||
|
AI_DETECTOR_LOW_CUT=0.7024
|
||||||
|
AI_DETECTOR_HIGH_CUT=0.7352
|
||||||
|
```
|
||||||
|
|
||||||
|
`--low-percentile`(기본 90) / `--high-percentile`(기본 98) 로 배지 비율을 조절한다.
|
||||||
|
low/high 는 **둘 다** 설정해야 적용된다. 하나만 넣으면 자리표시자로 되돌아간다.
|
||||||
|
|
||||||
|
### 8.3 스크립트가 거부하는 경우
|
||||||
|
|
||||||
|
규칙 점수는 각 규칙이 [0,1] 로 clip 되므로 상단에서 **포화**할 수 있다. 포화가
|
||||||
|
심하면 p90 과 p98 이 같은 값이 되고, 그 컷을 쓰면 `high` 배지가 영원히 0건이 된다.
|
||||||
|
이 경우 스크립트는 `.env` 를 출력하지 않고 **exit 3** 으로 중단하며 진단을 남긴다
|
||||||
|
(`saturated_share`, `distinct_scores`). 대응은 `--high-percentile` 을 낮추거나,
|
||||||
|
표본을 늘리거나, 표본이 특정 도서에 치우쳤는지 확인하는 것이다.
|
||||||
|
|
||||||
|
유효 표본 100건 미만이면 백분위가 불안정하므로 **exit 2** 로 중단한다.
|
||||||
|
|
||||||
|
### 8.4 캘리브레이션해도 달라지지 않는 것
|
||||||
|
|
||||||
|
- `is_stub` 은 **여전히 `true`** 다. 컷을 맞췄을 뿐 학습된 모델이 아니다.
|
||||||
|
- `model_version` 만 `heuristic-baseline-v1` → `heuristic-baseline-v1+corpus-percentile`
|
||||||
|
로 바뀌어 컷의 출처를 드러낸다.
|
||||||
|
- AI 생성 텍스트를 실제로 구분한다는 근거는 **여전히 없다.** 이 점수가 높다는 것은
|
||||||
|
"등록된 인간 원고들과 문체 통계가 다르다"는 뜻일 뿐이며, 번역체·대필·윤문·장르
|
||||||
|
차이 모두가 같은 방향으로 점수를 올린다.
|
||||||
|
- 따라서 저자·편집자에게 노출할 때는 "AI 생성 의심도"보다 **"문체 이례도"** 로
|
||||||
|
표기하고, 검토 우선순위 정렬 용도로만 쓸 것을 권한다.
|
||||||
|
|||||||
205
scripts/calibrate_ai_detector_cuts.py
Normal file
205
scripts/calibrate_ai_detector_cuts.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""등록 코퍼스로 AI 의심도 컷을 백분위 캘리브레이션한다.
|
||||||
|
|
||||||
|
왜 이렇게 하나:
|
||||||
|
AI 생성 표본이 없으면 "AI일 확률"은 측정할 수 없다. 그래서 점수의 의미를
|
||||||
|
바꾼다. 등록 코퍼스는 전부 인간 저작이므로, 그 점수 분포의 상위 백분위를
|
||||||
|
컷으로 잡으면 결과는 **"우리 코퍼스의 인간 저작물 대비 얼마나 이례적인 문체인가"**
|
||||||
|
가 된다. 라벨도 지표도 필요 없고, "상위 2% 이례적 문체"라는 진술은 정확도를
|
||||||
|
측정하지 않아도 참이다. 덤으로 'high' 배지 비율이 정의상 고정되어 검토 부하가
|
||||||
|
예측 가능해진다.
|
||||||
|
|
||||||
|
이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다.
|
||||||
|
|
||||||
|
입력: 영속 코퍼스 SQLite (기본) 또는 data/reference/*.txt
|
||||||
|
출력: JSON 리포트 + 그대로 붙여넣을 .env 두 줄
|
||||||
|
|
||||||
|
사용:
|
||||||
|
python scripts/calibrate_ai_detector_cuts.py \
|
||||||
|
--database data/runtime/corpus.sqlite3 --sample 3000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger("calibrate-cuts")
|
||||||
|
|
||||||
|
from app.engine.ai_detector import ( # noqa: E402
|
||||||
|
DEFAULT_HIGH_PERCENTILE,
|
||||||
|
DEFAULT_LOW_PERCENTILE,
|
||||||
|
HARD_MIN_CHARS,
|
||||||
|
calibrate_cuts,
|
||||||
|
percentile,
|
||||||
|
score_text_heuristic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_pick(key: str) -> int:
|
||||||
|
"""표본 추출용 결정적 해시. 같은 코퍼스면 항상 같은 표본이 뽑힌다."""
|
||||||
|
return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:8], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def load_texts(database: Path | None, reference_dir: Path | None, sample: int) -> list[str]:
|
||||||
|
"""코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다."""
|
||||||
|
items: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
if database and database.exists():
|
||||||
|
from app.engine.provenance import CorpusStore
|
||||||
|
|
||||||
|
for seg in CorpusStore(database).iter_segments():
|
||||||
|
items.append((_stable_pick(seg.segment_id), seg.text))
|
||||||
|
logger.info("SQLite 세그먼트 %d건 로드: %s", len(items), database)
|
||||||
|
elif reference_dir and reference_dir.exists():
|
||||||
|
for path in sorted(reference_dir.glob("*.txt")):
|
||||||
|
try:
|
||||||
|
items.append((_stable_pick(path.name), path.read_text(encoding="utf-8")))
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
logger.warning("UTF-8 아님, 건너뜀: %s", path.name)
|
||||||
|
logger.info("텍스트 파일 %d건 로드: %s", len(items), reference_dir)
|
||||||
|
else:
|
||||||
|
raise SystemExit("코퍼스를 찾을 수 없습니다. --database 또는 --reference-dir 확인.")
|
||||||
|
|
||||||
|
items.sort(key=lambda kv: kv[0])
|
||||||
|
if sample > 0:
|
||||||
|
items = items[:sample]
|
||||||
|
return [text for _, text in items]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
ap.add_argument("--database", type=Path, default=Path("data/runtime/corpus.sqlite3"))
|
||||||
|
ap.add_argument("--reference-dir", type=Path, default=Path("data/reference"),
|
||||||
|
help="SQLite 가 없을 때 쓰는 폴백")
|
||||||
|
ap.add_argument("--sample", type=int, default=3000,
|
||||||
|
help="표본 수 (0이면 전량). 3000이면 백분위 추정에 충분하다.")
|
||||||
|
ap.add_argument("--low-percentile", type=float, default=DEFAULT_LOW_PERCENTILE)
|
||||||
|
ap.add_argument("--high-percentile", type=float, default=DEFAULT_HIGH_PERCENTILE)
|
||||||
|
ap.add_argument("--no-pos", action="store_true", help="품사 특징 사용 안 함")
|
||||||
|
ap.add_argument("--out", type=Path, default=Path("data/models/ai_cuts.json"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
texts = load_texts(args.database, args.reference_dir, args.sample)
|
||||||
|
if not texts:
|
||||||
|
logger.error("표본이 0건입니다.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# 실제로 품사 특징이 쓰였는지 확인 (kiwipiepy 없으면 자동 폴백된다)
|
||||||
|
from app.engine.ai_detector import extract_features
|
||||||
|
|
||||||
|
pos_used = (
|
||||||
|
not args.no_pos
|
||||||
|
and extract_features(texts[0], use_pos=not args.no_pos)["pos_available"] >= 1.0
|
||||||
|
)
|
||||||
|
if not args.no_pos and not pos_used:
|
||||||
|
logger.warning(
|
||||||
|
"kiwipiepy 를 쓸 수 없어 품사 특징 없이 채점합니다. 운영 서버와 "
|
||||||
|
"동일 조건이 아니면 컷이 맞지 않으니, 반드시 서버와 같은 환경에서 "
|
||||||
|
"실행하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
scores: list[float] = []
|
||||||
|
skipped = 0
|
||||||
|
for i, text in enumerate(texts, 1):
|
||||||
|
value = score_text_heuristic(text, use_pos=not args.no_pos)
|
||||||
|
if value is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
scores.append(value)
|
||||||
|
if i % 500 == 0:
|
||||||
|
logger.info("채점 %d/%d", i, len(texts))
|
||||||
|
|
||||||
|
logger.info("채점 완료: %d건 (%d자 미만 %d건 제외)", len(scores), HARD_MIN_CHARS, skipped)
|
||||||
|
if len(scores) < 100:
|
||||||
|
logger.error(
|
||||||
|
"유효 표본이 %d건뿐이라 백분위가 불안정합니다(최소 100). "
|
||||||
|
"--sample 을 늘리거나 코퍼스를 확인하세요.", len(scores)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
low_cut, high_cut = calibrate_cuts(scores, args.low_percentile, args.high_percentile)
|
||||||
|
|
||||||
|
distribution = {f"p{p}": round(percentile(scores, p), 4)
|
||||||
|
for p in (5, 25, 50, 75, 90, 95, 98, 99)}
|
||||||
|
expected_medium = sum(1 for s in scores if low_cut <= s < high_cut) / len(scores)
|
||||||
|
expected_high = sum(1 for s in scores if s >= high_cut) / len(scores)
|
||||||
|
|
||||||
|
# 포화 진단: 규칙 점수가 상단에서 clip 되면 p90=p98 이 되어 컷이 무의미해진다.
|
||||||
|
# 이 경우 .env 를 그대로 쓰면 high 배지가 영원히 0건이 되므로 거부한다.
|
||||||
|
top = max(scores)
|
||||||
|
saturated_share = sum(1 for s in scores if s >= top - 1e-9) / len(scores)
|
||||||
|
distinct = len(set(round(s, 4) for s in scores))
|
||||||
|
target_high = (100.0 - args.high_percentile) / 100.0
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"source": str(args.database if args.database.exists() else args.reference_dir),
|
||||||
|
"n_scored": len(scores),
|
||||||
|
"n_skipped_short": skipped,
|
||||||
|
"use_pos_requested": not args.no_pos,
|
||||||
|
"pos_actually_used": pos_used,
|
||||||
|
"low_percentile": args.low_percentile,
|
||||||
|
"high_percentile": args.high_percentile,
|
||||||
|
"low_cut": low_cut,
|
||||||
|
"high_cut": high_cut,
|
||||||
|
"distribution": distribution,
|
||||||
|
"distinct_scores": distinct,
|
||||||
|
"saturated_share": round(saturated_share, 4),
|
||||||
|
"expected_rate_on_human_corpus": {
|
||||||
|
"medium": round(expected_medium, 4),
|
||||||
|
"high": round(expected_high, 4),
|
||||||
|
"target_high": target_high,
|
||||||
|
},
|
||||||
|
"note": (
|
||||||
|
"인간 저작 코퍼스 대비 문체 이례도 컷이며 AI 작성 판정이 아니다. "
|
||||||
|
"expected_rate 는 인간 원고에서도 이 비율만큼 medium/high 가 나온다는 뜻."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if expected_high <= 0.0:
|
||||||
|
logger.error(
|
||||||
|
"이 컷을 쓰면 'high' 배지가 **영원히 0건**입니다. 점수가 상단에서 "
|
||||||
|
"포화(최고점 동률 %.1f%%, 서로 다른 점수 %d개)되어 p%.0f 와 p%.0f 가 "
|
||||||
|
"같은 값이기 때문입니다. .env 를 출력하지 않습니다.",
|
||||||
|
saturated_share * 100, distinct, args.low_percentile, args.high_percentile,
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"대응: ① --high-percentile 을 낮춰 동률 구간 아래로 컷을 내리거나 "
|
||||||
|
"② --sample 을 늘려 분포를 넓히거나 ③ 표본이 특정 도서에 치우쳤는지 "
|
||||||
|
"확인하세요. 리포트는 %s 에 저장했습니다.", args.out,
|
||||||
|
)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
if expected_high > target_high * 3 or expected_high < target_high / 3:
|
||||||
|
logger.warning(
|
||||||
|
"예상 high 비율 %.2f%% 가 목표 %.2f%% 에서 크게 벗어났습니다"
|
||||||
|
"(점수 동률 때문). 배지 비율이 설계와 다르게 나옵니다.",
|
||||||
|
expected_high * 100, target_high * 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n# .env 에 아래 두 줄을 추가하세요")
|
||||||
|
print(f"AI_DETECTOR_LOW_CUT={low_cut}")
|
||||||
|
print(f"AI_DETECTOR_HIGH_CUT={high_cut}")
|
||||||
|
print(
|
||||||
|
f"\n# 인간 원고 기준 예상 배지 비율: medium {expected_medium:.1%}, "
|
||||||
|
f"high {expected_high:.1%}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -511,3 +511,147 @@ def test_valid_artifact_roundtrip(tmp_path):
|
|||||||
det = AiGenerationDetector(model_path=path, allow_heuristic=False)
|
det = AiGenerationDetector(model_path=path, allow_heuristic=False)
|
||||||
assert det.mode == "trained"
|
assert det.mode == "trained"
|
||||||
assert det.detect(UNIFORM_TEXT, with_segments=False).score == pytest.approx(0.66)
|
assert det.detect(UNIFORM_TEXT, with_segments=False).score == pytest.approx(0.66)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 백분위 컷 캘리브레이션 (라벨 없이 인간 코퍼스만으로)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_percentile_matches_known_values():
|
||||||
|
values = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||||
|
assert ad.percentile(values, 0) == pytest.approx(0.0)
|
||||||
|
assert ad.percentile(values, 50) == pytest.approx(0.5)
|
||||||
|
assert ad.percentile(values, 100) == pytest.approx(1.0)
|
||||||
|
assert ad.percentile([], 50) == 0.0
|
||||||
|
assert ad.percentile([0.3], 90) == pytest.approx(0.3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_cuts_from_human_scores():
|
||||||
|
scores = [i / 1000 for i in range(1000)] # 0.000 ~ 0.999 균등
|
||||||
|
low, high = ad.calibrate_cuts(scores, 90, 98)
|
||||||
|
assert low == pytest.approx(0.899, abs=0.01)
|
||||||
|
assert high == pytest.approx(0.979, abs=0.01)
|
||||||
|
assert low < high
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_cuts_keeps_medium_band_when_distribution_is_flat():
|
||||||
|
low, high = ad.calibrate_cuts([0.5] * 200, 90, 98)
|
||||||
|
assert high > low, "분포가 평평해도 medium 구간이 사라지면 안 된다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_cuts_rejects_bad_input():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ad.calibrate_cuts([])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ad.calibrate_cuts([0.5], 98, 90) # low >= high
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ad.calibrate_cuts([0.5], -1, 50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrated_cuts_produce_expected_badge_rate():
|
||||||
|
"""컷의 존재 이유 — 인간 코퍼스에서 high 비율이 설계값으로 고정된다."""
|
||||||
|
scores = [i / 1000 for i in range(1000)]
|
||||||
|
low, high = ad.calibrate_cuts(scores, 90, 98)
|
||||||
|
high_rate = sum(1 for s in scores if s >= high) / len(scores)
|
||||||
|
assert high_rate == pytest.approx(0.02, abs=0.005)
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_text_heuristic_skips_short_text():
|
||||||
|
assert ad.score_text_heuristic("너무 짧다.") is None
|
||||||
|
value = ad.score_text_heuristic(UNIFORM_TEXT)
|
||||||
|
assert value is not None and 0.0 <= value <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_detector_uses_injected_cuts(tmp_path):
|
||||||
|
det = AiGenerationDetector(
|
||||||
|
model_path=tmp_path / "none.joblib", allow_heuristic=True,
|
||||||
|
low_cut=0.10, high_cut=0.20,
|
||||||
|
)
|
||||||
|
assert det.cuts_calibrated is True
|
||||||
|
assert det._cuts() == (0.10, 0.20)
|
||||||
|
assert det.model_version == "heuristic-baseline-v1+corpus-percentile"
|
||||||
|
assert det._level(0.05) == "low"
|
||||||
|
assert det._level(0.15) == "medium"
|
||||||
|
assert det._level(0.50) == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_cuts_are_ignored(tmp_path):
|
||||||
|
"""한쪽만 주면 자리표시자로 되돌아가야 한다 (반쪽 설정 방지)."""
|
||||||
|
det = AiGenerationDetector(
|
||||||
|
model_path=tmp_path / "none.joblib", allow_heuristic=True, low_cut=0.1,
|
||||||
|
)
|
||||||
|
assert det.cuts_calibrated is False
|
||||||
|
assert det._cuts() == (ad.DEFAULT_LOW_CUT, ad.DEFAULT_HIGH_CUT)
|
||||||
|
assert det.model_version == "heuristic-baseline-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncalibrated_heuristic_note_warns(tmp_path):
|
||||||
|
det = AiGenerationDetector(model_path=tmp_path / "none.joblib", allow_heuristic=True)
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
assert res.is_stub is True
|
||||||
|
assert "캘리브레이션되지 않았습니다" in res.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrated_heuristic_note_states_relative_meaning(tmp_path):
|
||||||
|
det = AiGenerationDetector(
|
||||||
|
model_path=tmp_path / "none.joblib", allow_heuristic=True,
|
||||||
|
low_cut=0.3, high_cut=0.6,
|
||||||
|
)
|
||||||
|
res = det.detect(UNIFORM_TEXT, with_segments=False)
|
||||||
|
assert res.is_stub is True, "캘리브레이션해도 학습 모델은 아니다"
|
||||||
|
assert "문체 이례도" in res.note
|
||||||
|
assert "확률" in res.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_cuts_win_over_injected_cuts():
|
||||||
|
det = _stub_detector(0.5)
|
||||||
|
det.low_cut, det.high_cut = 0.01, 0.02
|
||||||
|
assert det._cuts() == (0.40, 0.70), "학습 아티팩트가 있으면 그쪽이 우선"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_script_refuses_degenerate_corpus(tmp_path):
|
||||||
|
"""포화된 코퍼스에서 'high 배지 0건' 설정을 출력하면 안 된다."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ref = tmp_path / "ref"
|
||||||
|
ref.mkdir()
|
||||||
|
# 완전히 동일한 텍스트 200건 → 점수 전부 동률 = 포화
|
||||||
|
for i in range(200):
|
||||||
|
(ref / f"ref-{i:04d}__같은책.txt").write_text(UNIFORM_TEXT, encoding="utf-8")
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "scripts/calibrate_ai_detector_cuts.py",
|
||||||
|
"--database", str(tmp_path / "absent.sqlite3"),
|
||||||
|
"--reference-dir", str(ref), "--sample", "0",
|
||||||
|
"--out", str(tmp_path / "cuts.json")],
|
||||||
|
cwd=root, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 3, proc.stderr[-1500:]
|
||||||
|
assert "AI_DETECTOR_LOW_CUT" not in proc.stdout, "죽은 설정을 출력하면 안 된다"
|
||||||
|
assert "영원히 0건" in proc.stderr
|
||||||
|
# 진단 리포트는 남아야 한다
|
||||||
|
assert (tmp_path / "cuts.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_script_fails_on_tiny_sample(tmp_path):
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ref = tmp_path / "ref"
|
||||||
|
ref.mkdir()
|
||||||
|
for i in range(5):
|
||||||
|
(ref / f"ref-{i}__책.txt").write_text(UNIFORM_TEXT, encoding="utf-8")
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "scripts/calibrate_ai_detector_cuts.py",
|
||||||
|
"--database", str(tmp_path / "absent.sqlite3"),
|
||||||
|
"--reference-dir", str(ref), "--sample", "0",
|
||||||
|
"--out", str(tmp_path / "cuts.json")],
|
||||||
|
cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 2
|
||||||
|
assert "불안정" in proc.stderr
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user