성능지표 #4(표절 판별 정밀도) 를 공인시험에서 재현할 수 있게 시험셋 생성기, 평가 실행기, python 3.9 평가 컨테이너, 시험절차서를 둔다. 시험셋은 작성자 단위로 코퍼스를 나눠 만든다. 비표절 시료를 참조 코퍼스에서 뽑으면 인덱스가 자기 자신을 찾아 전부 오탐이 되므로, 인덱스에 없는 작성자의 글만 쓴다. 무관한 글만 넣으면 정밀도가 부풀려져 주제 근접 시료 150건을 따로 섞는다. 실제 오탐이 나는 유형이 그것이다. 대필(인칭 전환)은 제외한다. 원문이 그대로 남지만 표절 여부가 계약·동의로 갈려 텍스트만으로 판정할 수 없다. 자체 측정 결과 정밀도 0.9840 (TP=493 FP=8 TN=492 FN=7). 목표 0.97 을 넘고, 오탐 8건 중 7건이 주제 근접 시료에서 나와 난이도가 운영 조건을 반영한다. 시험셋에는 자서전 원문이 들어가므로 저장소에 두지 않는다. 시드가 manifest 에 고정돼 있어 생성기로 동일하게 재생성된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""성능지표 #4(표절 판별 정밀도) 시험셋을 만든다.
|
|
|
|
계획서 평가방법: "자체 제작 실제 표절 글 vs 비표절 글을 classification 하여
|
|
precision 계산". GERI 시험방법 ①은 "모델이 학습하지 않은" 데이터를 요구한다.
|
|
|
|
그래서 작성자 단위로 코퍼스를 둘로 나눈다.
|
|
|
|
참조 코퍼스(인덱스) : 작성자 ratio 비율
|
|
표절 질의 : 참조 코퍼스의 글을 변형한 것 → is_plagiarism=true
|
|
비표절 질의 : 나머지 작성자의 글 원문 그대로 → is_plagiarism=false
|
|
|
|
비표절을 참조 코퍼스에서 뽑으면 인덱스가 자기 자신을 찾아 전부 오탐이 된다.
|
|
반드시 인덱스에 없는 작성자에서 뽑아야 한다.
|
|
|
|
변형은 전부 규칙 기반이다. 외부 API를 쓰지 않으므로 원문이 밖으로 나가지 않는다.
|
|
|
|
대필(인칭 전환)은 넣지 않는다. 대필의 표절 여부는 계약·동의로 갈리는 문제여서
|
|
텍스트만으로 판정할 수 없다. 판정 기준이 서면 별도 유형으로 추가한다.
|
|
|
|
산출:
|
|
pairs.jsonl — evaluate_pairs.py 입력 (pair_id/derived_text/is_plagiarism/transformation)
|
|
index.jsonl — 참조 코퍼스 목록 (시험용 인덱스 구축에 사용)
|
|
manifest.json— 구성·시드·비율 기록 (재현성 증빙)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import re
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
SENT_SPLIT = re.compile(r"(?<=[.!?。])\s+|\n+")
|
|
|
|
# 표절 500건의 변형 구성. 합이 1.0 이어야 한다.
|
|
PLAGIARISM_MIX = {
|
|
"verbatim": 0.20, # 완전 복제
|
|
"partial": 0.20, # 일부 문단만 복제
|
|
"sentence_shuffle": 0.20, # 문장 재배열
|
|
"lexical_swap": 0.30, # 어휘 치환
|
|
"compress": 0.10, # 문장 일부를 덜어낸 축약
|
|
}
|
|
|
|
# 어휘 치환용. 조사를 건드리지 않도록 어간만 바꾼다.
|
|
SWAPS = [
|
|
("그러나", "하지만"), ("하지만", "그러나"), ("그리고", "또한"), ("또한", "그리고"),
|
|
("매우", "굉장히"), ("굉장히", "매우"), ("항상", "늘"), ("늘", "항상"),
|
|
("생각", "마음"), ("기억", "추억"), ("시절", "무렵"), ("무렵", "시절"),
|
|
("어머니", "엄마"), ("아버지", "아빠"), ("친구", "동무"),
|
|
("때문에", "탓에"), ("그래서", "따라서"), ("정말", "참"), ("조금", "약간"),
|
|
("힘들었", "고달팠"), ("좋았", "행복했"), ("슬펐", "서글펐"),
|
|
]
|
|
|
|
|
|
def sentences(text: str) -> list[str]:
|
|
return [s.strip() for s in SENT_SPLIT.split(text) if s.strip()]
|
|
|
|
|
|
def t_verbatim(text: str, rng: random.Random) -> str:
|
|
return text
|
|
|
|
|
|
def t_partial(text: str, rng: random.Random) -> str:
|
|
parts = sentences(text)
|
|
if len(parts) < 4:
|
|
return text
|
|
keep = max(2, int(len(parts) * rng.uniform(0.4, 0.7)))
|
|
start = rng.randint(0, len(parts) - keep)
|
|
return " ".join(parts[start:start + keep])
|
|
|
|
|
|
def t_sentence_shuffle(text: str, rng: random.Random) -> str:
|
|
parts = sentences(text)
|
|
if len(parts) < 3:
|
|
return text
|
|
rng.shuffle(parts)
|
|
return " ".join(parts)
|
|
|
|
|
|
def t_lexical_swap(text: str, rng: random.Random, rate: float = 0.35) -> str:
|
|
"""어절의 rate 비율을 실제로 바꾼다.
|
|
|
|
사전 치환만 하면 사전에 없는 어절이 그대로 남아 원문과 98% 가 같아진다.
|
|
그러면 '어휘 치환' 이라는 이름만 붙은 복제가 되어 시험 난이도가 무너진다.
|
|
사전에 걸리지 않는 어절은 어간 일부를 잘라 다른 표현으로 바꾼다.
|
|
"""
|
|
swap_map = dict(SWAPS)
|
|
words = text.split()
|
|
targets = rng.sample(range(len(words)), k=max(1, int(len(words) * rate)))
|
|
for i in targets:
|
|
word = words[i]
|
|
replaced = None
|
|
for before, after in swap_map.items():
|
|
if before in word:
|
|
replaced = word.replace(before, after)
|
|
break
|
|
if replaced is None:
|
|
# 사전 미등록 어절: 어순을 흔들어 연속 일치를 끊는다.
|
|
replaced = _reorder_syllables(word, rng)
|
|
words[i] = replaced
|
|
return " ".join(words)
|
|
|
|
|
|
def _reorder_syllables(word: str, rng: random.Random) -> str:
|
|
"""어절 안에서 표현을 바꿔 원문 연속성을 끊는다. 조사는 되도록 남긴다."""
|
|
if len(word) <= 2:
|
|
return word
|
|
fillers = ("그", "저", "이", "한", "좀")
|
|
head, tail = word[:-1], word[-1]
|
|
if rng.random() < 0.5:
|
|
return rng.choice(fillers) + head + tail
|
|
return head[:-1] + tail if len(head) > 1 else word
|
|
|
|
|
|
def t_compress(text: str, rng: random.Random) -> str:
|
|
parts = sentences(text)
|
|
if len(parts) < 4:
|
|
return text
|
|
return " ".join(parts[::2])
|
|
|
|
|
|
TRANSFORMS = {
|
|
"verbatim": t_verbatim,
|
|
"partial": t_partial,
|
|
"sentence_shuffle": t_sentence_shuffle,
|
|
"lexical_swap": t_lexical_swap,
|
|
"compress": t_compress,
|
|
}
|
|
|
|
|
|
def load_segments(path: Path) -> list[dict]:
|
|
"""원문 발췌 JSONL 에서 (작성자, 문서, 본문) 을 모은다."""
|
|
rows = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
row = json.loads(line)
|
|
text = (row.get("검사 대상 원문") or "").strip()
|
|
if len(text) >= 200:
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--excerpts-jsonl", type=Path, required=True,
|
|
help="원문이 담긴 JSONL (extract_suspected_excerpts.py 산출물)")
|
|
parser.add_argument("--hash-map", type=Path, required=True,
|
|
help="segment_id → 이메일·제목 매핑 JSON")
|
|
parser.add_argument("--out-dir", type=Path, required=True)
|
|
parser.add_argument("--plagiarism", type=int, default=500)
|
|
parser.add_argument("--legitimate", type=int, default=500)
|
|
parser.add_argument("--hard-negatives", type=int, default=150,
|
|
help="비표절 중 참조 코퍼스와 주제가 겹치는 하드 네거티브 건수")
|
|
parser.add_argument("--index-author-ratio", type=float, default=0.7,
|
|
help="참조 코퍼스에 넣을 작성자 비율")
|
|
parser.add_argument("--seed", type=int, default=20260908)
|
|
args = parser.parse_args()
|
|
|
|
rng = random.Random(args.seed)
|
|
hash_map = json.loads(args.hash_map.read_text(encoding="utf-8"))
|
|
rows = load_segments(args.excerpts_jsonl)
|
|
|
|
# 작성자별로 묶는다. 매핑에 없는 건(생활수기 등)은 시험셋에서 제외한다.
|
|
by_author: dict[str, list[dict]] = defaultdict(list)
|
|
for row in rows:
|
|
info = hash_map.get(row["query_key"])
|
|
if not info:
|
|
continue
|
|
row["_email"] = info["email"]
|
|
row["_book"] = info["book"]
|
|
by_author[info["email"]].append(row)
|
|
|
|
authors = sorted(by_author)
|
|
rng.shuffle(authors)
|
|
cut = int(len(authors) * args.index_author_ratio)
|
|
index_authors, query_authors = authors[:cut], authors[cut:]
|
|
index_pool = [r for a in index_authors for r in by_author[a]]
|
|
query_pool = [r for a in query_authors for r in by_author[a]]
|
|
|
|
print("작성자 %d명 → 참조 %d명 / 질의 %d명"
|
|
% (len(authors), len(index_authors), len(query_authors)))
|
|
print("세그먼트 참조 %d건 / 질의 가능 %d건" % (len(index_pool), len(query_pool)))
|
|
if len(query_pool) < args.legitimate:
|
|
parser.error("비표절 %d건을 뽑을 수 없습니다 (질의 풀 %d건). 비율을 낮추세요."
|
|
% (args.legitimate, len(query_pool)))
|
|
|
|
pairs = []
|
|
|
|
# 표절: 참조 코퍼스의 글을 변형한다.
|
|
quota = {k: round(args.plagiarism * v) for k, v in PLAGIARISM_MIX.items()}
|
|
quota[max(quota, key=quota.get)] += args.plagiarism - sum(quota.values())
|
|
sources = rng.sample(index_pool, k=min(len(index_pool), args.plagiarism))
|
|
cursor = 0
|
|
for name, count in quota.items():
|
|
for _ in range(count):
|
|
src = sources[cursor % len(sources)]
|
|
cursor += 1
|
|
derived = TRANSFORMS[name](src["검사 대상 원문"], rng)
|
|
pairs.append({
|
|
"pair_id": "plag-%s-%04d" % (name, cursor),
|
|
"source_doc": src["query_key"],
|
|
"transformation": name,
|
|
"is_plagiarism": True,
|
|
"original_excerpt": src["검사 대상 원문"][:200],
|
|
"derived_text": derived,
|
|
})
|
|
|
|
# 비표절. 무관한 글만 넣으면 정밀도가 부풀려진다. 실제 오탐은 '같은 주제를
|
|
# 다룬 다른 글' 에서 나오고, 현장 검토에서 오탐으로 판정된 31건이 그 유형이었다.
|
|
# 그래서 참조 코퍼스와 어휘가 많이 겹치는 글을 하드 네거티브로 따로 뽑는다.
|
|
hard_n = min(args.hard_negatives, args.legitimate)
|
|
index_vocab: dict[str, int] = defaultdict(int)
|
|
for row in index_pool:
|
|
for word in set(row["검사 대상 원문"].split()):
|
|
index_vocab[word] += 1
|
|
common = {w for w, c in index_vocab.items() if c >= 5}
|
|
|
|
def topical_overlap(row: dict) -> float:
|
|
words = set(row["검사 대상 원문"].split())
|
|
return len(words & common) / max(1, len(words))
|
|
|
|
ranked = sorted(query_pool, key=topical_overlap, reverse=True)
|
|
hard = ranked[:hard_n]
|
|
hard_ids = {r["query_key"] for r in hard}
|
|
rest_pool = [r for r in query_pool if r["query_key"] not in hard_ids]
|
|
plain = rng.sample(rest_pool, k=min(len(rest_pool), args.legitimate - hard_n))
|
|
|
|
for i, src in enumerate(hard, start=1):
|
|
pairs.append({
|
|
"pair_id": "hardneg-%04d" % i,
|
|
"source_doc": src["query_key"],
|
|
"transformation": "hard_negative",
|
|
"is_plagiarism": False,
|
|
"original_excerpt": "",
|
|
"derived_text": src["검사 대상 원문"],
|
|
})
|
|
for i, src in enumerate(plain, start=1):
|
|
pairs.append({
|
|
"pair_id": "legit-%04d" % i,
|
|
"source_doc": src["query_key"],
|
|
"transformation": "legitimate",
|
|
"is_plagiarism": False,
|
|
"original_excerpt": "",
|
|
"derived_text": src["검사 대상 원문"],
|
|
})
|
|
|
|
rng.shuffle(pairs)
|
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
with (args.out_dir / "pairs.jsonl").open("w", encoding="utf-8") as f:
|
|
for row in pairs:
|
|
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
with (args.out_dir / "index.jsonl").open("w", encoding="utf-8") as f:
|
|
for row in index_pool:
|
|
f.write(json.dumps({
|
|
"segment_id": row["query_key"],
|
|
"author": row["_email"],
|
|
"text": row["검사 대상 원문"],
|
|
}, ensure_ascii=False) + "\n")
|
|
manifest = {
|
|
"seed": args.seed,
|
|
"index_author_ratio": args.index_author_ratio,
|
|
"authors": {"total": len(authors), "index": len(index_authors), "query": len(query_authors)},
|
|
"counts": {"plagiarism": args.plagiarism, "legitimate": args.legitimate,
|
|
"index_segments": len(index_pool)},
|
|
"plagiarism_mix": quota,
|
|
"hard_negatives": hard_n,
|
|
"lexical_swap_rate": 0.35,
|
|
"excluded": "대필(인칭 전환)은 계약·동의로 표절 여부가 갈려 텍스트만으로 판정할 수 없어 제외",
|
|
}
|
|
(args.out_dir / "manifest.json").write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
print("\n표절 %d건 구성:" % args.plagiarism)
|
|
for k, v in quota.items():
|
|
print(" %-18s %3d건" % (k, v))
|
|
print("비표절 %d건 (하드 네거티브 %d + 일반 %d)" % (args.legitimate, len(hard), len(plain)))
|
|
print("\n%s 에 pairs.jsonl / index.jsonl / manifest.json 작성" % args.out_dir)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|