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>
This commit is contained in:
parent
b2d02306b9
commit
10340718ca
750
docs/PERF_EVIDENCE_CAPTURE_2026.md
Normal file
750
docs/PERF_EVIDENCE_CAPTURE_2026.md
Normal file
@ -0,0 +1,750 @@
|
||||
# 성능평가확인서 시험결과 캡처용 소스코드
|
||||
|
||||
> 대상 문서: `성능평가확인서_출판과제_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`, 시험셋 변형도 전부 규칙 기반 |
|
||||
|
||||
> 성적서 게재용 데이터 예시는 자서전 본문에 실명이 남아 있으므로
|
||||
> **익명화가 끝난 생활수기에서 발췌**하여 제출한다. 접수 시 시험기관에 함께 요청할 것.
|
||||
@ -84,6 +84,29 @@ def build_metrics(id2label: dict):
|
||||
return compute
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model", default="klue/roberta-large",
|
||||
@ -162,10 +185,25 @@ def main() -> int:
|
||||
print(" %-12s P=%.4f R=%.4f F1=%.4f"
|
||||
% (name, row["precision"], row["recall"], row["f1-score"]))
|
||||
|
||||
# 성적서 게재 형식 그대로 출력한다. 전 차수 성적서
|
||||
# (GERI `GERIR.CE.2511-02.009`) 9페이지 "4. 결과 측정" 과 동일한 키 구성이라
|
||||
# 이 출력을 그대로 캡처해 붙일 수 있다.
|
||||
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(
|
||||
json.dumps({"model": args.model, "f1": micro["f1-score"],
|
||||
"report": report}, ensure_ascii=False, indent=2),
|
||||
(args.out_dir / "result.json").write_text(rendered + "\n", encoding="utf-8")
|
||||
(args.out_dir / "run_meta.json").write_text(
|
||||
json.dumps({"model": args.model, "epochs": args.epochs,
|
||||
"batch_size": args.batch_size, "lr": args.lr,
|
||||
"max_length": args.max_length},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
print("\n%s 에 결과 기록" % (args.out_dir / "result.json"))
|
||||
return 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user