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>
This commit is contained in:
hbyang 2026-08-19 13:53:59 +09:00
parent 7f18206f36
commit 45355910fa
3 changed files with 369 additions and 5 deletions

168
app/engine/ocr_normalize.py Normal file
View File

@ -0,0 +1,168 @@
"""OCR·조판 아티팩트 정규화.
필요한가:
학습·캘리브레이션에 쓰는 79 코퍼스는 스캔 원본을 OCR 후처리한 결과다
(`json_path` `..._OCR후처리_...`). 여기엔 저자 문체가 아니라 **스캔과 조판에서
생긴 흔적** 남아 있고, AI 생성 표본에는 흔적이 없다. 이대로 두면 분류기는
문체가 아니라 "OCR 흔적이 있으면 human" 배운다. 그러면 정작 운영에서 들어오는
깨끗한 인간 원고가 AI 오판된다. 휴리스틱 백분위 캘리브레이션도 같은 문제를
겪는다 컷이 조판 관습 위에서 잡혀 다른 조판의 원고에 맞지 않는다.
특히 `숫자 + 공백 + 단위` (1880 ) 없는 어절을 하나 만들어내므로
`mean_eojeol_len` / `cv_eojeol_len` 같은 띄어쓰기 특징을 직접 오염시킨다.
규칙은 추측이 아니라 코퍼스 34,105 실측에서 나왔다. 괄호 비율은 해당 패턴을
포함한 문서 비율이다.
1. 유니코드 공백/제어문자 통일 (1.1%)
2. 숫자단위 사이 공백 제거 `1880 ` `1880` (35.9%)
3. 한자 병기 괄호 제거 `대사(大使)` `대사` (24.1%)
4. 인용부호 글리프 통일 `` `"` (18.0%)
5. 문장부호 공백 제거 `기본적 . 0` `기본적. 0` (2.7%)
6. 괄호 안쪽 공백 정리 (1.0%)
7. 말줄임표 통일 `...` `` (2.3%)
측정했지만 **고치지 않는 **:
- `America라는` 처럼 라틴+한글이 붙는 형태(3.5%) 정상 한국어다.
- `3` 처럼 숫자+한글이 붙는 형태(23.2%) 정상이다. 규칙 2 만들려는 형태다.
성질:
- **멱등** `normalize_ocr(normalize_ocr(t)) == normalize_ocr(t)`
- **깨끗한 입력에는 무해** 아티팩트가 없으면 공백 정돈 외에 바뀌지 않는다
- 양쪽(human/AI) **똑같이** 적용해야 의미가 있다. 한쪽만 정규화하면
오염 방향만 뒤집힐 뿐이다.
"""
from __future__ import annotations
import re
import unicodedata
__all__ = ["normalize_ocr", "ocr_artifact_stats", "ARTIFACT_PATTERNS"]
# 1. 공백/제어 -------------------------------------------------------------
# NFKC 가 NBSP·전각공백은 처리하지만 zero-width 계열은 남긴다.
_ZERO_WIDTH = re.compile(r"[]")
_ODD_SPACE = re.compile(r"[  - ]")
# 2. 숫자–단위 -------------------------------------------------------------
# 한글 단위 명사는 띄어쓰기가 맞춤법상 허용되지만, 생성문은 붙여 쓴다.
# 목표는 맞춤법이 아니라 human/AI 양쪽 표기를 같은 형태로 모으는 것이다.
# 단위 뒤에는 조사·접미사가 붙는 경우가 많다(11 월"에", 1880 년"부터", 20 세기).
# 정규식 하나로 밀어넣으면 읽기 어렵고 `3 번지` 를 `3번지` 로 잘못 붙이므로,
# 단위와 허용 접미사를 명시한 집합으로 판정한다. 어절 전체가
# `단위 + 허용접미사` 로 정확히 나뉠 때만 공백을 지운다.
_UNIT_WORDS = frozenset({
"년대", "", "월호", "", "일자", "", "시간", "", "", "",
"개월", "", "", "", "", "", "세기", "", "", "", "",
"", "", "", "", "달러", "", "", "퍼센트", "프로", "미터",
"킬로", "", "", "인분", "가지",
# 수사 — `3 만` 처럼 끊긴 큰 수도 OCR 흔적이다
"", "", "", "", "",
})
_UNIT_SUFFIXES = frozenset({
"", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "짜리",
"에는", "에도", "에서", "부터", "까지", "이나", "이란", "이며", "이고",
"간의", "간은", "간에", "에서는", "부터는", "까지는", "이라", "라는",
"이었다", "였다", "이다", "이던", "", "",
})
_NUM_THEN_HANGUL = re.compile(r"(?<=\d)[ \t]+([가-힣]+)")
def _merge_num_unit(m: re.Match[str]) -> str:
"""어절이 단위+허용접미사로 정확히 나뉘면 앞 공백을 지운다."""
token = m.group(1)
for unit in _UNIT_BY_LEN:
if token.startswith(unit) and token[len(unit):] in _UNIT_SUFFIXES:
return token
return m.group(0)
# 긴 단위를 먼저 봐야 `세기` 가 `세` 로 잘못 쪼개지지 않는다.
_UNIT_BY_LEN = sorted(_UNIT_WORDS, key=len, reverse=True)
# 숫자와 라틴 단위 (12 km, 30 cm)
_NUM_UNIT_LATIN = re.compile(r"(?<=\d)\s+(?=(?:km|cm|mm|kg|mg|ml|m|g|%)(?![A-Za-z]))")
# 3. 한자 병기 -------------------------------------------------------------
# 한글 뒤에 붙은 괄호 한자만 제거한다. 앞말이 한글이 아니면 병기가 아니라
# 본문일 수 있으므로 건드리지 않는다(보수적).
_HANJA_GLOSS = re.compile(r"(?<=[가-힣])\s*\([一-鿿㐀-䶿]+\)")
# 4. 인용부호 --------------------------------------------------------------
# 낫표는 원본 도서의 조판 관습이고 생성문은 큰따옴표를 쓴다. 글리프 선택은
# 문체가 아니므로 양쪽을 한 형태로 모아 신호에서 제거한다.
_DQUOTE = re.compile(r"[「」『』“”〝〞"]")
_SQUOTE = re.compile(r"[]")
# 5~7. 부호 주변 ------------------------------------------------------------
_SPACE_BEFORE_PUNCT = re.compile(r"[ \t]+(?=[.,!?;:%\)\]}])")
_SPACE_AFTER_OPEN = re.compile(r"(?<=[(\[{])[ \t]+")
_SPACE_BEFORE_CLOSE = re.compile(r"[ \t]+(?=[)\]}])")
_ELLIPSIS = re.compile(r"\.{2,}|·{2,}|‥+")
# 마무리 공백 정돈
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
_MULTI_NEWLINE = re.compile(r"\n{3,}")
_TRAILING_WS = re.compile(r"[ \t]+\n")
def normalize_ocr(
text: str,
*,
strip_hanja_gloss: bool = True,
unify_quotes: bool = True,
) -> str:
"""OCR·조판 아티팩트를 제거한 텍스트를 돌려준다.
Args:
strip_hanja_gloss: 한글 괄호 한자 병기를 제거한다.
unify_quotes: 낫표·곡선따옴표를 직선따옴표로 통일한다.
플래그를 끄면 해당 규칙만 건너뛴다. 어떤 조합이든 멱등이다.
"""
if not text:
return ""
t = unicodedata.normalize("NFKC", text)
t = t.replace("\r\n", "\n").replace("\r", "\n")
t = _ZERO_WIDTH.sub("", t)
t = _ODD_SPACE.sub(" ", t)
t = _NUM_THEN_HANGUL.sub(_merge_num_unit, t)
t = _NUM_UNIT_LATIN.sub("", t)
if strip_hanja_gloss:
t = _HANJA_GLOSS.sub("", t)
if unify_quotes:
t = _DQUOTE.sub('"', t)
t = _SQUOTE.sub("'", t)
t = _ELLIPSIS.sub("", t)
t = _SPACE_AFTER_OPEN.sub("", t)
t = _SPACE_BEFORE_CLOSE.sub("", t)
t = _SPACE_BEFORE_PUNCT.sub("", t)
t = _MULTI_SPACE.sub(" ", t)
t = _TRAILING_WS.sub("\n", t)
t = _MULTI_NEWLINE.sub("\n\n", t)
return t.strip()
# 진단용 -------------------------------------------------------------------
ARTIFACT_PATTERNS: dict[str, re.Pattern[str]] = {
"num_unit_space": re.compile(r"\d\s+[년월일시분초개명권회세살번차쪽원]"),
"hanja_gloss": re.compile(r"[가-힣]\s*\([一-鿿]+\)"),
"bracket_quote": re.compile(r"[「」『』]"),
"space_before_punct": re.compile(r"\S[ \t]+[.,!?;:]"),
"odd_space": re.compile(r"[  ]"),
"multi_dot": re.compile(r"\.{2,}"),
}
def ocr_artifact_stats(text: str) -> dict[str, int]:
"""텍스트에 남은 아티팩트를 패턴별로 센다. 정규화 전후 비교용."""
return {name: len(rx.findall(text)) for name, rx in ARTIFACT_PATTERNS.items()}

View File

@ -11,7 +11,11 @@
값은 여전히 **AI 작성 판정이 아니다.** 검토 우선순위 정렬용이다.
입력: 영속 코퍼스 SQLite (기본) 또는 data/reference/*.txt
입력: 영속 코퍼스 SQLite (기본) / data/reference/*.txt / 에피소드 xlsx
OCR 후처리 코퍼스가 입력이면 --ocr-normalize (기본 켜짐) 조판·스캔 흔적을
먼저 지운다. 그러지 않으면 컷이 저자 문체가 아니라 책의 조판 관습 위에서
잡히고, 다른 조판의 원고에는 맞지 않는다.
출력: JSON 리포트 + 그대로 붙여넣을 .env
사용:
@ -34,6 +38,10 @@ 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,
@ -49,11 +57,42 @@ 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]:
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 database and database.exists():
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():
@ -79,6 +118,13 @@ 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 가 없을 때 쓰는 폴백")
@ -90,11 +136,32 @@ def main() -> int:
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)
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
@ -143,9 +210,16 @@ def main() -> int:
target_high = (100.0 - args.high_percentile) / 100.0
report = {
"source": str(args.database if args.database.exists() else args.reference_dir),
"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,

122
tests/test_ocr_normalize.py Normal file
View File

@ -0,0 +1,122 @@
"""ocr_normalize 단위테스트. 외부 의존 없음."""
from __future__ import annotations
import pytest
from app.engine.ocr_normalize import normalize_ocr, ocr_artifact_stats
class TestNumberUnitSpace:
"""규칙 2 — 없는 어절을 만들어내는 가장 흔한 아티팩트(35.9%)."""
def test_year_month(self):
assert normalize_ocr("1880 년 11 월에 쓴 편지") == "1880년 11월에 쓴 편지"
def test_various_units(self):
assert normalize_ocr("3 개월 뒤 5 명이 2 권을 샀다") == "3개월 뒤 5명이 2권을 샀다"
def test_latin_unit(self):
assert normalize_ocr("12 km 를 걸었다") == "12km 를 걸었다"
def test_eojeol_count_restored(self):
"""이 규칙의 목적 자체 — 어절 수가 정상으로 돌아와야 한다."""
assert len(normalize_ocr("1880 년 11 월").split()) == 2
def test_does_not_eat_real_words(self):
"""숫자 뒤 공백이라고 다 붙이면 안 된다. 단위 목록에 없으면 그대로."""
assert normalize_ocr("5 사람이 왔다") == "5 사람이 왔다"
assert normalize_ocr("3 번지 근처") == "3 번지 근처"
class TestHanjaGloss:
"""규칙 3 — 생성문에 없는 조판 관습(24.1%)."""
def test_removes_gloss(self):
assert normalize_ocr("대사(大使)의 임무를 띠고") == "대사의 임무를 띠고"
def test_keeps_latin_paren(self):
"""원어 병기는 본문일 수 있으므로 건드리지 않는다."""
text = '"탄식의 노래"(Das klagende Lied)'
assert "Das klagende Lied" in normalize_ocr(text)
def test_keeps_non_gloss_hanja(self):
"""앞말이 한글이 아니면 병기가 아니다 — 보수적으로 남긴다."""
assert "(大使)" in normalize_ocr("A (大使)")
def test_opt_out(self):
assert "(大使)" in normalize_ocr("대사(大使)", strip_hanja_gloss=False)
class TestQuotes:
"""규칙 4 — 낫표는 조판 관습이지 문체가 아니다(18.0%)."""
def test_bracket_quotes_unified(self):
assert normalize_ocr("「탄식의 노래」") == '"탄식의 노래"'
def test_curly_quotes_unified(self):
assert normalize_ocr("“인용” ‘강조’") == "\"인용\" '강조'"
def test_opt_out(self):
assert "" in normalize_ocr("「제목」", unify_quotes=False)
class TestPunctSpacing:
def test_space_before_punct(self):
assert normalize_ocr("기본적 . 0루") == "기본적. 0루"
def test_inside_paren(self):
assert normalize_ocr("( 안쪽 )") == "(안쪽)"
def test_ellipsis(self):
assert normalize_ocr("그리고...") == "그리고…"
class TestWhitespace:
def test_zero_width_removed(self):
assert normalize_ocr("말러​는") == "말러는"
def test_nbsp_to_space(self):
assert normalize_ocr("말러 는") == "말러 는"
def test_paragraphs_preserved(self):
assert normalize_ocr("문단1\n\n문단2") == "문단1\n\n문단2"
class TestInvariants:
"""이 두 성질이 깨지면 학습·캘리브레이션 결과를 신뢰할 수 없다."""
SAMPLES = [
"1880 년 11 월에 쓴 어떤 편지에는 대사(大使)의 「임무」가 적혀 있다...",
"말러는 한때 바그너에 심취한 채식주의자였다. 그때 그는 도덕적 작용을 경험하였다.",
"( 안쪽 ) 공백과 문장부호 . 그리고 3 개월",
"",
"짧은글",
]
@pytest.mark.parametrize("text", SAMPLES)
def test_idempotent(self, text):
once = normalize_ocr(text)
assert normalize_ocr(once) == once
def test_noop_on_clean_text(self):
"""운영에서 들어올 깨끗한 원고는 바뀌지 않아야 한다."""
clean = (
"말러는 한때 바그너에 심취한 채식주의자였다. 1880년 11월에 쓴 편지에는 "
'"동화극이 끝났다"고 적혀 있다.\n\n그것은 1년 이상 걸린 고심작이었다.'
)
assert normalize_ocr(clean) == clean
def test_empty(self):
assert normalize_ocr("") == ""
class TestStats:
def test_counts_before_and_after(self):
raw = "1880 년 대사(大使)의 「임무」... 그리고 3 개월"
before = ocr_artifact_stats(raw)
after = ocr_artifact_stats(normalize_ocr(raw))
assert before["num_unit_space"] > 0
assert before["hanja_gloss"] > 0
assert before["bracket_quote"] > 0
assert sum(after.values()) == 0