o2o-plagiarism-ai/docs/PERF_EVIDENCE_CAPTURE_2026.md
hbyang 10340718ca feat: emit KLUE NER result in scorecard JSON format
성능평가확인서 "4. 결과 측정" 은 최상위 precision/recall/f1_score 와
detailed_report 로 구성된 JSON 을 캡처해 싣는다. 기존 출력은
{model, f1, report} 라 그대로 붙일 수 없었다.

build_scorecard() 로 seqeval 리포트를 성적서 키 구성으로 재배열하고,
indent=4 JSON 을 터미널에도 출력해 캡처 한 장으로 끝나게 한다.
seqeval 의 dict 순서는 엔티티 등장 순서라 실행마다 달라지므로
태그 순서를 DT→LC→OG→PS→QT→TI 로 고정했다.

result.json 은 성적서 형식만 담고, 모델명·하이퍼파라미터는
run_meta.json 으로 분리한다.

전 차수 성적서(GERIR.CE.2511-02.009) 9p 실측값으로 형식 일치를 확인했다.

docs/PERF_EVIDENCE_CAPTURE_2026.md: 3.2.1/3.2.2 항목별 캡처 대상과
소스코드 위치를 HWP 슬롯 순서대로 정리.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:06:14 +09:00

751 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 성능평가확인서 시험결과 캡처용 소스코드
> 대상 문서: `성능평가확인서_출판과제_1029.hwp`
> 성적서번호 `GERIR.CE.2211-02.013` / 구미전자정보기술원
> 범위: **3.2.1 메타 데이터 추출**, **3.2.2 표절 여부 판별 정확도**
> (3.2.3 요약 성능은 이 문서 범위에서 제외)
HWP 본문에서 빈칸으로 남아 있는 "성능 평가 과정" 슬롯에 그대로 붙여 넣을
소스코드와 실행 화면을 항목 순서대로 정리한다. 각 절의 **캡처 대상**이
스크린샷 1장 단위다.
## 시험 환경 (2.2.2 대응)
| 구분 | 값 |
|---|---|
| 시험 PC | Apple M3 Pro / RAM 18GB / SSH 터미널 |
| GPU 서버 | RTX 3090 24G × 2 / i9-12900KS / RAM 125GB |
| OS·런타임 | Ubuntu 24.0.2 / Python 3.9 / PyTorch 2.8 / CUDA 12.2 |
| 구현 모듈 | 메타 데이터 추출 모듈, 표절 여부 판별 모듈 |
---
# 3.2.1 메타 데이터 추출 (F1-score)
시험방법 ① KLUE NER 데이터셋 약 11,000건으로 모델 학습
시험방법 ② 학습되지 않은 2,000여건 테스트 데이터로 상대 평가
주 스크립트: `scripts/train_klue_ner.py`
보조 스크립트: `scripts/eval_metadata_f1.py`
## 3.2.1.3-① 데이터 준비 — 학습 데이터 예시
**캡처 대상**: 아래 명령의 터미널 출력 (총 건수 + 샘플 레코드)
```bash
python - <<'PY'
from datasets import load_dataset
ds = load_dataset("klue", "ner")
print("학습 %d건 / 평가 %d건" % (len(ds["train"]), len(ds["validation"])))
ex = ds["train"][0]
names = ds["train"].features["ner_tags"].feature.names
print("tokens:", ex["tokens"][:40])
print("ner_tags:", [names[t] for t in ex["ner_tags"]][:40])
PY
```
HWP 원문의 `<학습 데이터 예시(총 11479 건)>` 자리에 위 출력을 넣는다.
## 3.2.1.3-② 데이터 로드 코드
**캡처 대상**: `scripts/train_klue_ner.py:30-64`
라벨 스킴 정의 (KLUE NER 태그 6종 → BIO 12개 + O = 13 labels):
```python
# scripts/train_klue_ner.py:30-34
TAGS = ["DT", "LC", "OG", "PS", "QT", "TI"]
LABELS = ["O"] + [f"{p}-{t}" for t in TAGS for p in ("B", "I")]
LABEL2ID = {label: i for i, label in enumerate(LABELS)}
ID2LABEL = {i: label for label, i in LABEL2ID.items()}
```
음절 단위 라벨을 서브워드 토큰에 정렬하는 로드 로직:
```python
# scripts/train_klue_ner.py:36-64
def tokenize_and_align(examples, tokenizer, max_length: int):
"""KLUE NER 은 음절 단위 라벨이라 서브워드 토큰에 맞춰 정렬한다.
첫 서브워드에만 라벨을 주고 나머지는 -100 으로 두어 손실에서 제외한다.
"""
encoded = tokenizer(
examples["tokens"],
is_split_into_words=True,
truncation=True,
max_length=max_length,
padding=False,
)
aligned = []
for i, tags in enumerate(examples["ner_tags"]):
word_ids = encoded.word_ids(batch_index=i)
previous = None
labels = []
for word_id in word_ids:
if word_id is None:
labels.append(-100)
elif word_id != previous:
labels.append(tags[word_id])
else:
labels.append(-100)
previous = word_id
aligned.append(labels)
encoded["labels"] = aligned
return encoded
```
## 3.2.1.3-③ 모델 실행
**캡처 대상**: `scripts/train_klue_ner.py:100-147` + 학습 진행 터미널 로그
```python
# scripts/train_klue_ner.py:105-137
dataset = load_dataset("klue", "ner")
print("학습 %d건 / 평가 %d건"
% (len(dataset["train"]), len(dataset["validation"])))
source = args.out_dir if args.eval_only else args.model
tokenizer = AutoTokenizer.from_pretrained(str(source))
model = AutoModelForTokenClassification.from_pretrained(
str(source), num_labels=len(LABELS), id2label=ID2LABEL, label2id=LABEL2ID)
encoded = dataset.map(
lambda batch: tokenize_and_align(batch, tokenizer, args.max_length),
batched=True, remove_columns=dataset["train"].column_names)
training_args = TrainingArguments(
output_dir=str(args.out_dir / "checkpoints"),
learning_rate=args.lr, # 2e-5
per_device_train_batch_size=args.batch_size, # 16
per_device_eval_batch_size=args.batch_size * 2,
num_train_epochs=args.epochs, # 3.0
weight_decay=0.01,
logging_steps=100,
save_strategy="no",
report_to=[],
fp16=torch.cuda.is_available(),
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=encoded["train"],
eval_dataset=encoded["validation"],
data_collator=DataCollatorForTokenClassification(tokenizer),
compute_metrics=build_metrics(ID2LABEL),
)
```
실행 명령 (시험관 입회 시연용):
```bash
python scripts/train_klue_ner.py --out-dir data/models/klue_ner
```
> 전 차수(`GERIR.CE.2511-02.009`) 재현이 필요하면 `--model klue/bert-base`.
> 기본값은 `klue/roberta-large` 이며 2-1년차 목표 83% 달성을 위한 설정이다.
## 3.2.1.3-④ 테스트 데이터 예시
**캡처 대상**: 아래 명령 출력
```bash
python - <<'PY'
from datasets import load_dataset
ds = load_dataset("klue", "ner", split="validation")
print("테스트 데이터 총 %d건" % len(ds))
names = ds.features["ner_tags"].feature.names
for i in (0, 1, 2):
ex = ds[i]
print("[%d] text: %s" % (i, "".join(ex["tokens"])[:60]))
print(" tags: %s" % [names[t] for t in ex["ner_tags"]][:30])
PY
```
## 3.2.1.3-⑤ 예측 결과 예시
**캡처 대상**: 예측 태그 배열 출력
HWP 원문에 이미 아래 형태의 예시가 들어가 있다. 동일 형식으로 재생성한다.
```
"original_tags": ["I-QT", "I-QT", "I-QT", "I-QT", "I-QT", "I-QT", "I-QT", "I-OG",
"I-QT", "I-QT", ... , "I-DT", "B-TI", ... , "I-QT"]
```
```bash
python - <<'PY'
import json, torch
from transformers import AutoTokenizer, AutoModelForTokenClassification
from datasets import load_dataset
path = "data/models/klue_ner"
tok = AutoTokenizer.from_pretrained(path)
model = AutoModelForTokenClassification.from_pretrained(path).eval()
ds = load_dataset("klue", "ner", split="validation")
names = ds.features["ner_tags"].feature.names
ex = ds[0]
enc = tok(ex["tokens"], is_split_into_words=True, return_tensors="pt", truncation=True)
with torch.no_grad():
pred = model(**enc).logits.argmax(-1)[0].tolist()
wid = enc.word_ids(0)
tags, seen = [], set()
for p, w in zip(pred, wid):
if w is None or w in seen:
continue
seen.add(w)
tags.append(model.config.id2label[p])
print(json.dumps({"original_tags": [names[t] for t in ex["ner_tags"]],
"predicted_tags": tags}, ensure_ascii=False))
PY
```
## 3.2.1.3-⑥ 결과 측정
> 전 차수 성적서 9페이지 "4. 결과 측정" 은 **JSON 한 덩어리**를 캡처해 실었다.
> 최상위에 micro 평균이 `precision` / `recall` / `f1_score` 로 오고, 태그별 상세가
> `detailed_report` 아래에 DT→LC→OG→PS→QT→TI→micro/macro/weighted 순으로 붙는다.
> `scripts/train_klue_ner.py` 가 **이 형식 그대로** 출력하도록 맞춰져 있다.
**캡처 대상 (1)**: 평가 함수 `scripts/train_klue_ner.py:66-84`
```python
# scripts/train_klue_ner.py:66-84
def build_metrics(id2label: dict):
from seqeval.metrics import classification_report, f1_score
def compute(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
true_labels, true_preds = [], []
for prediction, label in zip(predictions, labels):
pairs = [(p, l) for p, l in zip(prediction, label) if l != -100]
true_preds.append([id2label[int(p)] for p, _ in pairs])
true_labels.append([id2label[int(l)] for _, l in pairs])
report = classification_report(
true_labels, true_preds, output_dict=True, zero_division=0)
return {
"f1": f1_score(true_labels, true_preds, zero_division=0),
"report": report,
}
return compute
```
**캡처 대상 (2)**: 성적서 형식 변환 `scripts/train_klue_ner.py:87-107`
```python
# scripts/train_klue_ner.py:87-107
def build_scorecard(report: dict) -> dict:
"""seqeval 리포트를 전 차수 성적서와 같은 키 구성으로 재배열한다.
성적서는 최상위에 micro 평균을 precision/recall/f1_score 로 두고,
태그별 상세를 detailed_report 아래에 DT→LC→OG→PS→QT→TI 순으로 싣는다.
seqeval 의 dict 순서는 태그 등장 순서라 성적서 순서와 달라지므로 여기서 고정한다.
"""
micro = report["micro avg"]
detailed: dict[str, dict] = {}
for tag in TAGS:
if tag in report:
detailed[tag] = report[tag]
for name in ("micro avg", "macro avg", "weighted avg"):
if name in report:
detailed[name] = report[name]
return {
"precision": micro["precision"],
"recall": micro["recall"],
"f1_score": micro["f1-score"],
"detailed_report": detailed,
}
```
**캡처 대상 (3)**: 출력·기록부 `scripts/train_klue_ner.py:188-208`
```python
# scripts/train_klue_ner.py:188-208
scorecard = build_scorecard(report)
rendered = json.dumps(scorecard, ensure_ascii=False, indent=4)
print("\n" + "=" * 62)
print("4. 결과 측정")
print()
print(rendered)
print("\n결과 : 평균 f1 -score %d%%" % round(micro["f1-score"] * 100))
args.out_dir.mkdir(parents=True, exist_ok=True)
(args.out_dir / "result.json").write_text(rendered + "\n", encoding="utf-8")
```
**캡처 대상 (4)**: 실행 + 최종 JSON 출력 — **이것이 HWP 본문에 들어갈 화면**
```bash
python scripts/train_klue_ner.py --eval-only --out-dir data/models/klue_ner
# 또는 기록된 결과를 그대로 열람
cat data/models/klue_ner/result.json
```
출력 형식 (아래는 **전 차수 실측값**. 2-1년차 측정 후 이 자리를 교체한다):
```json
{
"precision": 0.8160559736973584,
"recall": 0.8067849745060547,
"f1_score": 0.8113939923241485,
"detailed_report": {
"DT": {
"precision": 0.8626870237635195,
"recall": 0.828588722428993,
"f1-score": 0.8452941402199001,
"support": 12397
},
"LC": {
"precision": 0.7572073055161723,
"recall": 0.7553675850368563,
"f1-score": 0.7562863264653191,
"support": 6997
},
"OG": {
"precision": 0.8023425167572489,
"recall": 0.7706453793914474,
"f1-score": 0.7861745849948081,
"support": 11492
},
"PS": {
"precision": 0.853834243620867,
"recall": 0.8543908500378298,
"f1-score": 0.8541124561472994,
"support": 7670
},
"QT": {
"precision": 0.7617685880582717,
"recall": 0.7730987193907924,
"f1-score": 0.7673918351662147,
"support": 16674
},
"TI": {
"precision": 0.9005210695901854,
"recall": 0.8999231140326887,
"f1-score": 0.9002219925162598,
"support": 7530
},
"micro avg": {
"precision": 0.8160559736973584,
"recall": 0.8067849745060547,
"f1-score": 0.8113939923241485,
"support": 62760
},
"macro avg": {
"precision": 0.8230601245510442,
"recall": 0.8136690617197679,
"f1-score": 0.8182468892516335,
"support": 62760
},
"weighted avg": {
"precision": 0.8165231436063907,
"recall": 0.8067849745060548,
"f1-score": 0.811516424716427,
"support": 62760
}
}
}
```
```
결과 : 평균 f1 -score 81%
```
**결과 기록 위치**
| 파일 | 내용 |
|---|---|
| `data/models/klue_ner/result.json` | 성적서 게재용 JSON (위 형식 그대로) |
| `data/models/klue_ner/run_meta.json` | 모델명·epochs·batch_size·lr·max_length (재현용) |
> `support 62760` 은 평가 세트의 엔티티 총수다. 문서 2,000여건 기준이며
> 시험방법 ②의 "학습되지 않은 2,000여건" 과 대응한다.
---
# 3.2.2 표절 여부 판별 정확도 (Precision)
시험방법 ① 모델이 학습하지 않은, 자체 제작 표절 글 데이터 준비
시험방법 ② 표절 판별 알고리즘에 대한 전처리 진행
시험방법 ③ 데이터 표절 여부의 precision 점수 계산
주 스크립트: `scripts/run_precision_eval.py`
시험셋 생성: `scripts/build_plagiarism_testset.py`
시험 절차서: `docs/PRECISION_TEST_PROCEDURE.md`
## 3.2.2.3-1 데이터 준비
**캡처 대상 (1)**: 시험셋 구성 명세 `data/eval/testset_v2/manifest.json`
```bash
wc -l data/eval/testset_v2/pairs.jsonl data/eval/testset_v2/index.jsonl
cat data/eval/testset_v2/manifest.json
```
```json
{
"seed": 20260908,
"index_author_ratio": 0.7,
"authors": { "total": 290, "index": 203, "query": 87 },
"counts": { "plagiarism": 500, "legitimate": 500, "index_segments": 4033 },
"plagiarism_mix": {
"verbatim": 100, "partial": 100, "sentence_shuffle": 100,
"lexical_swap": 150, "compress": 50
},
"hard_negatives": 150,
"lexical_swap_rate": 0.35
}
```
> **"모델이 학습하지 않은 데이터" 요건 충족 근거** — 전체 작성자 290명을
> 코퍼스 적재 203명 / 시험 질의 전용 87명으로 분리했다. 비표절 500건은
> 인덱스에 존재하지 않는 작성자의 원문이다.
**캡처 대상 (2)**: 비표절 / 표절 데이터 예시
```bash
# 비표절 데이터 예시 (총 500건)
python - <<'PY'
import json
rows = [json.loads(l) for l in open("data/eval/testset_v2/pairs.jsonl", encoding="utf-8")]
neg = [r for r in rows if not r["is_plagiarism"]]
pos = [r for r in rows if r["is_plagiarism"]]
print("비표절 데이터 총 %d건" % len(neg))
for r in neg[:2]:
print(" -", r["pair_id"], "|", r["derived_text"][:70], "...")
print("\n표절 데이터 총 %d건" % len(pos))
for r in pos[:2]:
print(" -", r["pair_id"], "(%s)" % r["transformation"])
print(" 원문 :", r["original_excerpt"][:60], "...")
print(" 변형 :", r["derived_text"][:60], "...")
print("\n전체 데이터 총 %d건" % len(rows))
PY
```
**캡처 대상 (3)**: 표절 데이터 생성 알고리즘 `scripts/build_plagiarism_testset.py:60-131`
```python
# scripts/build_plagiarism_testset.py:60-129
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 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,
}
```
시험셋 재생성 시연 (재현성 증빙):
```bash
python scripts/build_plagiarism_testset.py \
--excerpts-jsonl reports/excerpts_20260902.jsonl \
--hash-map <hash_map.json> \
--out-dir /tmp/testset_reproduce
diff <(cut -c1-80 /tmp/testset_reproduce/pairs.jsonl) \
<(cut -c1-80 data/eval/testset_v2/pairs.jsonl) && echo "동일"
```
## 3.2.2.3-2 전처리 알고리즘
HWP 원문의 `2. 전처리 알고리즘 / 글 구성요소에 대한 전처리 과정` 슬롯.
전처리는 **(가) 자서전 특화 정규화**와 **(나) 형태소 lemma 추출** 두 단계다.
### (가) 공통 표현 제거 + 개체명 마스킹
**캡처 대상**: `app/engine/autobiography_filter.py:46-105`
```python
# app/engine/autobiography_filter.py:46-52
def remove_common_patterns(text: str, patterns_path: str) -> str:
"""공통 표현 제거. 매칭된 자리를 공백으로 치환."""
result = text
for p in _common_patterns(patterns_path):
result = result.replace(p, " ")
return re.sub(r"\s+", " ", result).strip()
# app/engine/autobiography_filter.py:55-96
_TOKEN_PERSON = "[PERSON]"
_TOKEN_PLACE = "[PLACE]"
_TOKEN_DATE = "[DATE]"
_TOKEN_NUM = "[NUM]"
_DATE_PATTERN = re.compile(r"\b(19|20)\d{2}\s*년|\d{1,2}\s*월\s*\d{1,2}\s*일|\d{4}-\d{2}-\d{2}")
_NUM_PATTERN = re.compile(r"\b\d{2,}\b")
def mask_entities(text: str) -> str:
"""인명/지명/날짜/숫자 마스킹."""
# 날짜·숫자 먼저 (kiwi 토큰화 전에)
masked = _DATE_PATTERN.sub(_TOKEN_DATE, text)
masked = _NUM_PATTERN.sub(_TOKEN_NUM, masked)
# 형태소 분석 → NNP(고유명사) 마스킹
tokens = _kiwi().tokenize(masked)
parts: list[tuple[int, int, str]] = []
for t in tokens:
if t.tag == "NNP":
parts.append((t.start, t.start + t.len, _TOKEN_PERSON))
if not parts:
return masked
# 뒤에서부터 치환 (인덱스 보존)
parts.sort(key=lambda p: p[0], reverse=True)
chars = list(masked)
for start, end, repl in parts:
chars[start:end] = list(repl)
return "".join(chars)
# app/engine/autobiography_filter.py:99-105
def preprocess_for_autobiography(text: str, patterns_path: str,
enable_mask: bool = True) -> str:
"""자서전 모드 전처리 = 공통 표현 제거 + 엔티티 마스킹."""
if not text:
return text
cleaned = remove_common_patterns(text, patterns_path)
if enable_mask:
cleaned = mask_entities(cleaned)
return cleaned
```
### (나) 내용어 lemma 추출 — 구조 유사도용
**캡처 대상**: `app/engine/structural.py:25-58`
```python
# app/engine/structural.py:22-23
# 내용어 태그 - 명사/동사/형용사/부사. (조사 JK*, 어미 EF/EC/EP 등 기능어는 제외)
_CONTENT_TAGS = ("NNG", "NNP", "VV", "VA", "MAG", "VV-R", "VV-I", "VA-R", "VA-I")
# app/engine/structural.py:31-45
def extract_lemmas(text: str, min_length: int = 1) -> list[str]:
"""텍스트에서 내용어 lemma 리스트 반환 (등장 순서, 중복 포함)."""
if not text.strip():
return []
tokens = _get_kiwi().tokenize(text)
lemmas: list[str] = []
for t in tokens:
if not any(t.tag.startswith(prefix)
for prefix in ("NNG", "NNP", "VV", "VA", "MAG")):
continue
lemma = getattr(t, "lemma", None) or t.form
if len(lemma) >= min_length:
lemmas.append(lemma)
return lemmas
# app/engine/structural.py:48-58
def lemma_overlap_ratio(query_lemmas: list[str], ref_lemmas: list[str]) -> float:
"""query 기준 다중집합 교집합 비율 = |Q ∩ R (다중집합)| / |Q|."""
if not query_lemmas:
return 0.0
qc = Counter(query_lemmas)
rc = Counter(ref_lemmas)
intersection = sum((qc & rc).values())
total = sum(qc.values())
return intersection / total if total else 0.0
```
### 전처리 예시
**캡처 대상**: HWP 의 `예시` 슬롯 — 전처리 전후 대조 출력
```bash
python - <<'PY'
from app.engine.autobiography_filter import preprocess_for_autobiography
from app.engine.structural import extract_lemmas
src = ("1978년 3월, 나는 춘천초등학교에 입학했다. "
"어머니께서 지어주신 새 옷을 입고 교문을 들어섰다.")
out = preprocess_for_autobiography(src, "data/autobiography/common_patterns.txt")
print("[전처리 전]", src)
print("[전처리 후]", out)
print("[lemma ]", extract_lemmas(out)[:20])
PY
```
## 3.2.2.3-3 표절 여부 판별
**캡처 대상 (1)**: 판정 기준 — `app/engine/detector.py:296-313`
세 조건 중 **①결합유사도와 ②최장 연속 일치를 함께** 요구한다.
| 조건 | 기준값 | 설정 키 |
|---|---|---|
| ① 결합 유사도 | ≥ 0.65 | `persistent_similarity_threshold` |
| ② 최장 연속 일치 | ≥ 35자 (9어절) | `persistent_min_exact_span` |
| ③ 문서 커버리지 | ≥ 0.30 | `persistent_min_coverage` |
```python
# app/engine/detector.py:296-313
reasons: list[str] = []
if h.score >= threshold:
reasons.append("score_threshold")
if provenance_hit:
if provenance_hit.longest_span >= self.settings.persistent_min_exact_span:
reasons.append("exact_span")
# 커버리지 조건은 문서 단위 union 으로 판단한다. 세그먼트 단독
# 비율은 긴 원고에서 구조적으로 작아 조건이 성립하지 않는다.
if document_coverage.get(provenance_hit.document_id, 0.0) >= self.settings.persistent_min_coverage:
reasons.append("coverage")
if not reasons:
continue
# 유사도만 넘고 그대로 겹친 구간이 없는 후보는 채택하지 않는다.
# 같은 주제를 다룬 글은 임베딩 유사도가 함께 오르므로, 유사도 단독
# 판정은 오탐을 만든다. 실데이터 106건 중 29건이 이 경우였고 검토에서
# 전부 오탐으로 확인됐다. 반대로 이 조건을 걸어도 새로 놓치는 건은
# 없었다(미탐지 7,680건 재판정 결과 0건).
if self.settings.require_exact_span_evidence and "exact_span" not in reasons:
continue
```
기준값 확인 시연:
```bash
python -c "from app.core.config import Settings; s=Settings(); \
print(s.persistent_similarity_threshold, s.persistent_min_exact_span, \
s.persistent_min_coverage, s.require_exact_span_evidence)"
```
**캡처 대상 (2)**: 판정 루프 — `scripts/run_precision_eval.py:118-140`
```python
# scripts/run_precision_eval.py:118-140
detector = PlagiarismDetector(settings)
print("판정 기준: 유사도>=%.2f | 연속일치>=%d자 | 커버리지>=%.2f | 연속일치 필수=%s"
% (settings.persistent_similarity_threshold,
settings.persistent_min_exact_span,
settings.persistent_min_coverage,
settings.require_exact_span_evidence))
tp = fp = tn = fn = 0
buckets: dict[str, dict[str, int]] = defaultdict(lambda: {"tp": 0, "fp": 0, "tn": 0, "fn": 0})
for i, row in enumerate(pairs, start=1):
result = detector.detect(doc_id=row["pair_id"], text=row["derived_text"])
predicted = bool(result.is_infringement)
expected = bool(row["is_plagiarism"])
key = "tp" if (expected and predicted) else \
"fn" if expected else \
"fp" if predicted else "tn"
buckets[row.get("transformation", "unknown")][key] += 1
```
**캡처 대상 (3)**: precision 계산부 — `scripts/run_precision_eval.py:142-145`
```python
# scripts/run_precision_eval.py:142-145
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
```
## 3.2.2.3-4 모델 결과
**캡처 대상**: 평가 실행 명령 1줄 + 터미널 최종 출력
```bash
docker build -f Dockerfile.eval -t o2o-plagia-eval:py39 .
docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \
python scripts/run_precision_eval.py --testset data/eval/testset_v2
```
> 평가환경은 계획서가 Ubuntu 22.04 / Python 3.9 / GPU 불필요로 고정하고 있고,
> 시험 서버 시스템 파이썬은 3.6.9 이므로 **반드시 컨테이너로 실행한다.**
> 소요 약 10분(1,000건), 진행률이 100건 단위로 출력된다.
사전 측정 결과 (`data/eval/testset_v2/result.json`):
```
==============================================================
표절 여부 판별 정밀도 (precision) : 0.9840 [목표 0.97]
재현율 (recall) : 0.9860
F1 : 0.9850
TP=493 FP=8 TN=492 FN=7
[변형 유형별]
compress n= 50 P=1.000 R=1.000 TP=50 FP=0 TN=0 FN=0
hard_negative n= 150 P=0.000 R=0.000 TP=0 FP=7 TN=143 FN=0
legitimate n= 350 P=0.000 R=0.000 TP=0 FP=1 TN=349 FN=0
lexical_swap n= 150 P=1.000 R=0.960 TP=144 FP=0 TN=0 FN=6
partial n= 100 P=1.000 R=0.990 TP=99 FP=0 TN=0 FN=1
sentence_shuffle n= 100 P=1.000 R=1.000 TP=100 FP=0 TN=0 FN=0
verbatim n= 100 P=1.000 R=1.000 TP=100 FP=0 TN=0 FN=0
```
**최종 결과: precision 98.4% (목표 97% 달성)**
오탐 8건은 전부 비표절 측에서 발생했고 그중 7건이 하드 네거티브 150건에서
나왔다. 정밀도 = TP / (TP + FP) 이므로 예측 양성 501건 기준 허용 오탐은
15건이며, 실측 8건으로 기준 내에 있다.
---
# 부록 — 시험관 예상 질의 대응
| 질문 | 근거 |
|---|---|
| 시험셋을 직접 만들었으면 유리하게 만든 것 아닌가 | 구성 비율·시드(20260908)가 `manifest.json` 에 사전 기록. 재생성 시 동일 결과. 하드 네거티브 150건을 의도적으로 투입해 난이도 상향 |
| 연속 일치를 필수로 하면 재현율이 떨어지지 않나 | 실데이터 7,680건 재판정 결과 새로 놓친 건 0건. 계획서도 재현율을 희생해 오탐을 줄이는 방향으로 규정 (`docs/SCOPE_AND_METRICS.md`) |
| 35자 기준의 근거는 | 어절 3~9 스윕 + 문서쌍 148,240쌍 전수 대조 실측. 4어절 이하는 임의 조합의 99% 이상이 겹쳐 신호가 되지 못함 |
| 원문이 외부로 나가는가 | 나가지 않음. `USE_LLM_LEGAL_JUDGE=false`, 시험셋 변형도 전부 규칙 기반 |
> 성적서 게재용 데이터 예시는 자서전 본문에 실명이 남아 있으므로
> **익명화가 끝난 생활수기에서 발췌**하여 제출한다. 접수 시 시험기관에 함께 요청할 것.