o2o-plagiarism-ai/scripts/calibrate_ai_detector_cuts.py
hbyang 45355910fa feat: OCR·조판 아티팩트 정규화와 백분위 컷 캘리브레이션
학습·캘리브레이션에 쓰는 79권 코퍼스는 OCR 후처리본이라 저자 문체가 아닌
스캔·조판 흔적이 남아 있다. AI 생성 표본에는 그 흔적이 없으므로, 그대로 두면
분류기가 문체가 아니라 "OCR 흔적이 있으면 human"을 배운다. 그러면 운영에서
들어오는 깨끗한 인간 원고가 AI로 오판된다.

규칙은 추측이 아니라 코퍼스 34,105건 실측에서 뽑았다. 표본 3,000건 기준
숫자+공백+단위 94.8%, 한자 병기 99.4%, 낫표 100% 제거.
정상 한국어인 `America라는`·`3년` 형태는 건드리지 않는다.

숫자-단위 규칙은 정규식으로 밀어넣으면 `3 번지`를 `3번지`로 잘못 붙이므로
단위·접미사 화이트리스트로 판정한다.

멱등성과 "깨끗한 입력에 무해" 두 불변식을 테스트로 고정했다. 후자가 깨지면
운영 원고가 학습 코퍼스와 다르게 처리되어 정규화 자체가 새 편향이 된다.

calibrate_ai_detector_cuts.py 에 xlsx 입력과 OCR 정규화(기본 켜짐)를 추가했다.
79권 3,000건 실측 결과 low_cut 0.4286 / high_cut 0.5298, 인간 원고 기준
예상 high 비율 2.03%(설계 목표 2%)로 포화 없이 잡혔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:53:59 +09:00

280 lines
12 KiB
Python

#!/usr/bin/env python3
"""등록 코퍼스로 AI 의심도 컷을 백분위 캘리브레이션한다.
왜 이렇게 하나:
AI 생성 표본이 없으면 "AI일 확률"은 측정할 수 없다. 그래서 점수의 의미를
바꾼다. 등록 코퍼스는 전부 인간 저작이므로, 그 점수 분포의 상위 백분위를
컷으로 잡으면 결과는 **"우리 코퍼스의 인간 저작물 대비 얼마나 이례적인 문체인가"**
가 된다. 라벨도 지표도 필요 없고, "상위 2% 이례적 문체"라는 진술은 정확도를
측정하지 않아도 참이다. 덤으로 'high' 배지 비율이 정의상 고정되어 검토 부하가
예측 가능해진다.
이 값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다.
입력: 영속 코퍼스 SQLite (기본) / data/reference/*.txt / 에피소드 xlsx
OCR 후처리 코퍼스가 입력이면 --ocr-normalize (기본 켜짐) 로 조판·스캔 흔적을
먼저 지운다. 그러지 않으면 컷이 저자 문체가 아니라 그 책의 조판 관습 위에서
잡히고, 다른 조판의 원고에는 맞지 않는다.
출력: 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.ocr_normalize import ( # noqa: E402
normalize_ocr,
ocr_artifact_stats,
)
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,
xlsx: Path | None = None,
text_column: str = "에피소드",
book_column: str = "book_name",
) -> list[str]:
"""코퍼스에서 텍스트를 표본 추출. 전량 처리는 느리므로 기본은 표본이다."""
items: list[tuple[int, str]] = []
if xlsx and xlsx.exists():
import pandas as pd
frame = pd.read_excel(xlsx, sheet_name=0)
if text_column not in frame.columns:
raise SystemExit(
f"'{text_column}' 컬럼이 없습니다. 있는 컬럼: {list(frame.columns)}"
)
# 표본 키에 도서명을 섞어야 한 책이 표본을 독점하지 않는다.
has_book = book_column in frame.columns
seen: set[str] = set()
for idx in range(len(frame)):
text = frame[text_column].iloc[idx]
if not isinstance(text, str) or not text.strip():
continue
norm_key = " ".join(text.split())
if norm_key in seen: # 완전 중복 에피소드 제거 (34,105 → 31,560)
continue
seen.add(norm_key)
book = str(frame[book_column].iloc[idx]) if has_book else ""
items.append((_stable_pick(f"{book}:{idx}"), text))
logger.info(
"xlsx 에피소드 %d건 로드(중복 제거 후): %s", len(items), xlsx.name
)
elif 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("--xlsx", type=Path, default=None,
help="에피소드 xlsx (지정 시 최우선 입력)")
ap.add_argument("--text-column", default="에피소드")
ap.add_argument("--book-column", default="book_name",
help="표본을 도서에 고르게 퍼뜨리는 데 쓰는 그룹 컬럼")
ap.add_argument("--no-ocr-normalize", action="store_true",
help="OCR·조판 아티팩트 정규화를 끈다(기본은 켜짐)")
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,
xlsx=args.xlsx, text_column=args.text_column, book_column=args.book_column,
)
if not texts:
logger.error("표본이 0건입니다.")
return 2
# OCR·조판 흔적을 먼저 지운다. 이걸 건너뛰면 컷이 저자 문체가 아니라
# 그 책의 조판 관습 위에서 잡힌다.
ocr_before: dict[str, int] = {}
ocr_after: dict[str, int] = {}
if not args.no_ocr_normalize:
for text in texts:
for key, value in ocr_artifact_stats(text).items():
ocr_before[key] = ocr_before.get(key, 0) + value
texts = [normalize_ocr(text) for text in texts]
for text in texts:
for key, value in ocr_artifact_stats(text).items():
ocr_after[key] = ocr_after.get(key, 0) + value
removed = sum(ocr_before.values()) - sum(ocr_after.values())
logger.info(
"OCR 정규화: 아티팩트 %d건 → %d건 (%d건 제거)",
sum(ocr_before.values()), sum(ocr_after.values()), removed,
)
# 실제로 품사 특징이 쓰였는지 확인 (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.xlsx if args.xlsx and args.xlsx.exists()
else args.database if args.database.exists()
else args.reference_dir
),
"n_scored": len(scores),
"n_skipped_short": skipped,
"ocr_normalized": not args.no_ocr_normalize,
"ocr_artifacts_before": ocr_before,
"ocr_artifacts_after": ocr_after,
"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())