feat: add precision test harness and 2-1년차 test plan
성능지표 #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>
This commit is contained in:
parent
018dbc4a8f
commit
0223abd145
4
.gitignore
vendored
4
.gitignore
vendored
@ -55,3 +55,7 @@ logs/
|
|||||||
reports/excerpts_*.jsonl
|
reports/excerpts_*.jsonl
|
||||||
reports/침해판별_원문대조_*.xlsx
|
reports/침해판별_원문대조_*.xlsx
|
||||||
reports/침해판별_의심건검토_*.xlsx
|
reports/침해판별_의심건검토_*.xlsx
|
||||||
|
|
||||||
|
# 시험셋에는 자서전 원문이 들어간다. 시드가 manifest.json 에 고정돼 있어
|
||||||
|
# build_plagiarism_testset.py 로 언제든 동일하게 재생성할 수 있다.
|
||||||
|
data/eval/
|
||||||
|
|||||||
19
Dockerfile.eval
Normal file
19
Dockerfile.eval
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 성능지표 평가 전용 이미지.
|
||||||
|
# 계획서가 평가환경을 python 3.9 로 못박아 두었고(성능지표 #3/#4/#7),
|
||||||
|
# 시험 서버의 시스템 파이썬은 3.6.9 라 컨테이너로 맞춘다. GPU 는 쓰지 않는다.
|
||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app ./app
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY data/taxonomy ./data/taxonomy
|
||||||
|
COPY data/precedents ./data/precedents
|
||||||
|
COPY data/autobiography ./data/autobiography
|
||||||
|
COPY data/reference ./data/reference
|
||||||
|
|
||||||
|
ENV USE_LLM_LEGAL_JUDGE=false
|
||||||
|
CMD ["python", "scripts/run_precision_eval.py", "--testset", "data/eval/testset_v2"]
|
||||||
156
docs/PRECISION_TEST_PROCEDURE.md
Normal file
156
docs/PRECISION_TEST_PROCEDURE.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# 성능지표 #4 정밀도 시험 절차서
|
||||||
|
|
||||||
|
> 표절 여부 판별 정밀도(precision). 계획서 p.23 성능지표 4번.
|
||||||
|
> 2-1년차 목표 **97%**. 1-2년차(2025-11-19, GERI `GERIR.CE.2511-02.009`)에서
|
||||||
|
> 95% 목표를 95.2% 로 통과한 항목의 후속 시험.
|
||||||
|
|
||||||
|
## 0. 시험 당일 준비 (전날까지 끝낼 것)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/data2/demo/o2o-plagiarism-ai # 시험 서버 기준
|
||||||
|
git pull # 최신 판정 규칙 반영
|
||||||
|
docker build -f Dockerfile.eval -t o2o-plagia-eval:py39 .
|
||||||
|
```
|
||||||
|
|
||||||
|
평가환경은 계획서가 **Ubuntu 22.04 / python 3.9 / GPU 불필요** 로 못박아 두었다.
|
||||||
|
서버 시스템 파이썬은 3.6.9 이므로 **반드시 컨테이너로 실행한다.**
|
||||||
|
|
||||||
|
준비물 확인:
|
||||||
|
|
||||||
|
| 항목 | 경로 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| 시험셋 | `data/eval/testset_v2/pairs.jsonl` | 1,000줄 |
|
||||||
|
| 참조 코퍼스 | `data/eval/testset_v2/index.jsonl` | 4,033줄 |
|
||||||
|
| 구성 명세 | `data/eval/testset_v2/manifest.json` | 시드·비율 기록 |
|
||||||
|
| 평가 스크립트 | `scripts/run_precision_eval.py` | — |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wc -l data/eval/testset_v2/pairs.jsonl data/eval/testset_v2/index.jsonl
|
||||||
|
cat data/eval/testset_v2/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 시험 데이터 설명 (시험관에게 먼저 보여줄 것)
|
||||||
|
|
||||||
|
GERI 시험방법 ① "모델이 학습하지 않은, 자체 제작된 표절 글 데이터" 요건을
|
||||||
|
작성자 단위 분리로 충족한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
전체 작성자 290명
|
||||||
|
├─ 203명 → 참조 코퍼스(인덱스)에 적재
|
||||||
|
└─ 87명 → 시험 질의로만 사용 (인덱스에 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 라벨 | 건수 | 구성 |
|
||||||
|
|---|---|---|
|
||||||
|
| 표절 | 500 | 참조 코퍼스의 글을 변형. verbatim 100 / partial 100 / sentence_shuffle 100 / lexical_swap 150 / compress 50 |
|
||||||
|
| 비표절 | 500 | 인덱스에 없는 작성자의 글 원문. **하드 네거티브 150** + 일반 350 |
|
||||||
|
|
||||||
|
**하드 네거티브**는 참조 코퍼스와 어휘가 많이 겹치는 글을 골라 넣었다. 실제
|
||||||
|
오탐이 나는 유형이기 때문이다(현장 검토에서 오탐 판정된 31건이 이 유형이었다).
|
||||||
|
|
||||||
|
**대필(인칭 전환)은 시험셋에서 제외**했다. 같은 사건을 당사자와 대필자가 각각
|
||||||
|
기술한 글은 원문이 그대로 남지만, 표절 여부는 계약·동의로 갈려 텍스트만으로
|
||||||
|
판정할 수 없다. 판정 기준이 서면 별도 유형으로 추가한다.
|
||||||
|
|
||||||
|
재현성: `manifest.json` 에 난수 시드(20260908)와 비율이 기록돼 있어 같은
|
||||||
|
시험셋을 다시 만들 수 있다.
|
||||||
|
|
||||||
|
```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 "동일"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 판정 기준 설명
|
||||||
|
|
||||||
|
세 조건 중 **①과 ②를 함께** 요구한다. `require_exact_span_evidence=true`.
|
||||||
|
|
||||||
|
| 조건 | 기준값 | 설정 키 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 결합유사도 | ≥ 0.65 | `persistent_similarity_threshold` |
|
||||||
|
| ② **최장 연속 일치** | **≥ 35자 (9어절)** | `persistent_min_exact_span` |
|
||||||
|
| ③ 문서 커버리지 | ≥ 0.30 | `persistent_min_coverage` |
|
||||||
|
|
||||||
|
②는 필수다. 유사도만 높고 그대로 겹친 구간이 없는 후보는 채택하지 않는다.
|
||||||
|
같은 주제를 다룬 글은 임베딩 유사도가 함께 오르기 때문이다.
|
||||||
|
|
||||||
|
35자의 근거는 실측이다. 어절 3~9 스윕과 문서쌍 148,240 쌍 전수 대조 결과,
|
||||||
|
4어절 이하는 임의 조합의 99% 이상이 겹쳐 신호가 되지 못하고, 9어절부터 남는
|
||||||
|
겹침은 실제 유사 원고였다. 상세는 결과보고 워크북 `어절 기준 비교` 시트.
|
||||||
|
|
||||||
|
```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)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 평가 실행 — 이 한 줄
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \
|
||||||
|
python scripts/run_precision_eval.py --testset data/eval/testset_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
소요 약 10분(1,000건). 진행률이 100건 단위로 출력된다.
|
||||||
|
|
||||||
|
출력 형식:
|
||||||
|
|
||||||
|
```
|
||||||
|
시험 코퍼스: 문서 203개 / 세그먼트 4033개
|
||||||
|
시험 인덱스 구축 완료: data/eval/testset_v2/runtime/index
|
||||||
|
판정 기준: 유사도>=0.65 | 연속일치>=35자 | 커버리지>=0.30 | 연속일치 필수=True
|
||||||
|
진행 1000/1000
|
||||||
|
|
||||||
|
==============================================================
|
||||||
|
표절 여부 판별 정밀도 (precision) : 0.9xxx [목표 0.97]
|
||||||
|
재현율 (recall) : 0.9xxx
|
||||||
|
F1 : 0.9xxx
|
||||||
|
TP=xxx FP=xx TN=xxx FN=xx
|
||||||
|
|
||||||
|
[변형 유형별]
|
||||||
|
verbatim n= 100 P=x.xxx R=x.xxx TP=.. FP=.. TN=.. FN=..
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
정밀도 = TP / (TP + FP). 목표 0.97 이면 예측 양성 500건 기준 **오탐 15건까지**
|
||||||
|
허용된다.
|
||||||
|
|
||||||
|
결과는 `data/eval/testset_v2/result.json` 에 기록된다. 성적서 첨부용으로 쓴다.
|
||||||
|
|
||||||
|
## 4. 시험관이 물을 만한 것
|
||||||
|
|
||||||
|
**"시험셋을 직접 만들었으면 유리하게 만든 것 아닌가"**
|
||||||
|
→ 구성 비율·시드가 `manifest.json` 에 사전 기록돼 있고 재생성으로 동일 결과가
|
||||||
|
나온다. 하드 네거티브 150건을 일부러 섞어 난이도를 높였다.
|
||||||
|
|
||||||
|
**"변형이 원문과 얼마나 다른가"**
|
||||||
|
→ 문자 일치율과 최장 연속 일치를 유형별로 계측해 두었다. `lexical_swap` 은
|
||||||
|
어절의 35% 를 실제로 치환해 최장 연속 일치가 48자 수준이다(판정 기준 35자).
|
||||||
|
|
||||||
|
**"연속 일치를 필수로 하면 재현율이 떨어지지 않나"**
|
||||||
|
→ 실데이터 7,680건 재판정 결과 새로 놓치는 건은 0건이었다. 정밀도 지표이므로
|
||||||
|
계획서도 "재현율을 희생해서라도 오탐을 줄이는 쪽" 으로 잡도록 적고 있다
|
||||||
|
(`docs/SCOPE_AND_METRICS.md`).
|
||||||
|
|
||||||
|
**"원문이 외부로 나가는가"**
|
||||||
|
→ 나가지 않는다. `USE_LLM_LEGAL_JUDGE=false`, 시험셋 변형도 전부 규칙 기반이며
|
||||||
|
외부 API 를 쓰지 않는다.
|
||||||
|
|
||||||
|
## 5. 성적서 게재용 예시
|
||||||
|
|
||||||
|
성적서에는 시험 데이터 원문 예시가 인쇄된다(작년 성적서 9페이지 참조).
|
||||||
|
자서전은 본문에 실명이 남아 있으므로, **게재 예시는 익명화가 끝난 생활수기에서
|
||||||
|
뽑아 제출한다.** 접수 시 시험기관에 함께 요청할 것.
|
||||||
|
|
||||||
|
## 6. 실패 시 대응
|
||||||
|
|
||||||
|
| 증상 | 조치 |
|
||||||
|
|---|---|
|
||||||
|
| 정밀도 < 0.97 | `result.json` 의 `by_transformation` 에서 FP 가 몰린 유형 확인. 하드 네거티브에서 나오면 `persistent_min_exact_span` 을 40~45자로 올려 재실행 |
|
||||||
|
| 인덱스 구축 실패 | `data/eval/testset_v2/runtime` 삭제 후 재실행 |
|
||||||
|
| 재현율 급락 | 기준을 과하게 올린 것. 35자로 되돌리고 유사도 임계값만 조정 |
|
||||||
222
docs/TEST_PLAN_2026_PHASE2.md
Normal file
222
docs/TEST_PLAN_2026_PHASE2.md
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
# 2-1년차 현장시험 및 검증 — 시험절차서 (초안)
|
||||||
|
|
||||||
|
> 작성: 에이아이오투오 / 초안 일자 2026-09-14
|
||||||
|
> 선행 시험: 구미전자정보기술원 `GERIR.CE.2511-02.009` (2025-11-19 시험, 3개 항목 PASS)
|
||||||
|
> 근거: (협약용) 연구개발계획서 PART 2, 2-4. 연구개발 성능지표 및 평가방법 (p.23~24)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.0 일반사항
|
||||||
|
|
||||||
|
### 1.1 제품 정보
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 시 료 명 | 출판콘텐츠 분석 및 공유 기술용 AI 모델 |
|
||||||
|
| 모 델 명 | `ai_publish_o2o.v2` |
|
||||||
|
| 시 료 수 | 1 |
|
||||||
|
| 성적서 용도 | 솔루션 성능 결과 증빙용 |
|
||||||
|
|
||||||
|
### 1.2 시험 항목 — 3개 항목
|
||||||
|
|
||||||
|
전 차수와 동일한 3개 항목을 2-1년차 목표치로 재시험한다.
|
||||||
|
|
||||||
|
| No | 성능지표 | 단위 | 1-2년차 결과 | **2-1년차 목표** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 3 | 메타 데이터 추출 (F1-score) | % | 81 (PASS) | **83 이상** |
|
||||||
|
| 4 | 표절 여부 판별 정밀도 (Precision) | % | 95.2 (PASS) | **97** |
|
||||||
|
| 7 | 요약 성능 (N-gram ROUGE score) | % | 63 (PASS) | **65** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.0 시험 방법
|
||||||
|
|
||||||
|
### 2.1 시험 규격
|
||||||
|
|
||||||
|
- 시험규격: 시험절차서 기준
|
||||||
|
- 시험항목: 시험절차서 3개 항목
|
||||||
|
|
||||||
|
### 2.2 시험 장비 정보
|
||||||
|
|
||||||
|
| 구분 | 사용 장비 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 노트북 (시험관 지참) | 코드 시현 서버 접속용 |
|
||||||
|
| 2 | GPU server | 코드 시현용 |
|
||||||
|
|
||||||
|
### 2.3 시험 환경
|
||||||
|
|
||||||
|
#### 2.3.1 시험 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
① 실제 솔루션에 활용되는 AI 모델들을 평가에 사용
|
||||||
|
② 시험용 PC 로 실제 AI 모델이 가동중인 GPU 서버에 접속
|
||||||
|
③ 성능 평가를 GPU 서버에서 진행
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3.2 시험 환경 상세 정보
|
||||||
|
|
||||||
|
| 구분 | 사양 | |
|
||||||
|
|---|---|---|
|
||||||
|
| **시험 PC** | 운영 SW | macOS / Windows |
|
||||||
|
| | 시험 도구 | SSH 터미널 |
|
||||||
|
| **GPU 서버** | CPU | Intel Xeon |
|
||||||
|
| | GPU | NVIDIA A100 80G |
|
||||||
|
| | 운영 SW | Ubuntu 22.04 |
|
||||||
|
| | 시험 도구 | Python 3.9 (컨테이너), PyTorch 2.2.2+cu121, transformers 4.39.3 |
|
||||||
|
| | 구현 모듈 | 메타 데이터 추출 모듈, 표절 여부 판별 모듈, 요약 모듈 |
|
||||||
|
|
||||||
|
> **평가환경 고정 근거** — 계획서 p.24 가 항목별 평가환경을 명시하고 있다.
|
||||||
|
> No.3·No.4 는 `python 3.9`, No.7 은 `A100 GPU / python 3.9 / pytorch 2.2.2+cu121 /
|
||||||
|
> transformers 4.39.3`. 서버 시스템 파이썬 버전과 무관하게 **평가는 전용 컨테이너에서
|
||||||
|
> 수행**하여 규격을 충족한다.
|
||||||
|
|
||||||
|
### 2.3.3 평가방법
|
||||||
|
|
||||||
|
| 순번 | 평가지표 (성능지표) | 평가방법 | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 메타 데이터 추출<br>(F1-score) | KLUE 데이터셋의 NER 데이터셋을 활용하여, 약 11,000개의 학습 데이터로 개발된 모델을 학습시키고, 2,000여개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행 | 과제 성능지표 3번에 해당 |
|
||||||
|
| 2 | 표절 여부 판별 정밀도<br>(precision) | 자체 제작된 실제 표절 글과, 표절이 아닌 글을 Classification 하여, precision 계산 | 과제 성능지표 4번에 해당 |
|
||||||
|
| 3 | 요약 성능<br>(N-gram ROUGE score) | 자체 제작된 요약 데이터셋을, 요약 모델에 테스트하여 N-gram ROUGE score 계산 | 과제 성능지표 7번에 해당 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.0 시험 항목별 세부 절차
|
||||||
|
|
||||||
|
### 3.1 표절 여부 판별 정밀도 (성능지표 #4)
|
||||||
|
|
||||||
|
#### 3.1.1 시험방법
|
||||||
|
|
||||||
|
```
|
||||||
|
① 모델이 학습하지 않은, 자체 제작된 표절 글 데이터 준비
|
||||||
|
② 표절 판별 알고리즘에 대한 전처리 진행
|
||||||
|
③ 데이터 표절 여부의 precision 점수 계산
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 시험 데이터 구성 — 총 1,000건
|
||||||
|
|
||||||
|
모델이 학습하지 않은 데이터임을 **작성자 단위 분리**로 보장한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
전체 작성자 290명
|
||||||
|
├─ 203명 → 참조 코퍼스(검색 인덱스)에 적재
|
||||||
|
└─ 87명 → 시험 질의로만 사용 (인덱스에 미포함)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 라벨 | 건수 | 구성 |
|
||||||
|
|---|---|---|
|
||||||
|
| 표절 | 500 | 참조 코퍼스의 글을 변형<br>완전복제 100 / 부분복제 100 / 문장재배열 100 / 어휘치환 150 / 축약 50 |
|
||||||
|
| 비표절 | 500 | 인덱스 미포함 작성자의 글 원문<br>**주제 근접 시료 150** + 일반 350 |
|
||||||
|
|
||||||
|
- **주제 근접 시료(하드 네거티브) 150건**: 참조 코퍼스와 어휘가 많이 겹치는 글.
|
||||||
|
실제 오탐이 발생하는 유형이므로 난이도 확보를 위해 의도적으로 포함한다.
|
||||||
|
- **대필(인칭 전환) 유형은 제외**: 같은 사건을 당사자와 대필자가 각각 기술한 글은
|
||||||
|
원문이 그대로 남으나, 표절 여부가 계약·동의로 갈려 텍스트만으로 판정할 수 없다.
|
||||||
|
- **재현성**: 구성 비율과 난수 시드를 `manifest.json` 에 사전 기록하여, 동일 시험셋을
|
||||||
|
재생성할 수 있다.
|
||||||
|
|
||||||
|
#### 3.1.3 판정 조건
|
||||||
|
|
||||||
|
| 조건 | 기준값 | |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 결합유사도 | ≥ 0.65 | 어휘 0.45 + 표현 0.30 + 문자 0.15 + 모티프 0.10 |
|
||||||
|
| ② **최장 연속 일치** | **≥ 35자 (9어절)** | **필수 조건** |
|
||||||
|
| ③ 문서 단위 커버리지 | ≥ 0.30 | |
|
||||||
|
|
||||||
|
②를 필수로 둔다. 유사도만 높고 그대로 겹친 구간이 없는 후보는 채택하지 않는다.
|
||||||
|
같은 주제를 다룬 글은 의미 유사도가 함께 상승하기 때문이다.
|
||||||
|
|
||||||
|
**35자 산출 근거** — 어절 기준 3~9 스윕 및 문서쌍 148,240 쌍 전수 대조 결과,
|
||||||
|
4어절 이하는 임의 조합의 99% 이상에서 겹침이 발생하여 신호로 쓸 수 없고,
|
||||||
|
9어절부터 잔존하는 겹침은 실제 유사 원고로 확인되었다.
|
||||||
|
|
||||||
|
#### 3.1.4 시현 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 시험셋 구성 확인
|
||||||
|
cat data/eval/testset_v2/manifest.json
|
||||||
|
wc -l data/eval/testset_v2/pairs.jsonl # 1000
|
||||||
|
|
||||||
|
# ② 판정 기준 확인
|
||||||
|
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)"
|
||||||
|
|
||||||
|
# ③ 평가 실행 (약 10분)
|
||||||
|
docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \
|
||||||
|
python scripts/run_precision_eval.py --testset data/eval/testset_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
산출물: `data/eval/testset_v2/result.json` (precision / recall / F1 / 변형 유형별 분해)
|
||||||
|
|
||||||
|
#### 3.1.5 사전 측정 결과 (자체 검증)
|
||||||
|
|
||||||
|
| 항목 | 수치 |
|
||||||
|
|---|---|
|
||||||
|
| 정밀도 (precision) | **0.9840** |
|
||||||
|
| 재현율 (recall) | 0.9860 |
|
||||||
|
| F1 | 0.9850 |
|
||||||
|
| TP / FP / TN / FN | 493 / 8 / 492 / 7 |
|
||||||
|
|
||||||
|
목표 0.97 대비 여유가 있으며, 오탐 8건 중 7건이 주제 근접 시료에서 발생하여
|
||||||
|
난이도가 실제 운영 조건을 반영하고 있음을 확인하였다.
|
||||||
|
|
||||||
|
### 3.2 메타 데이터 추출 F1 (성능지표 #3)
|
||||||
|
|
||||||
|
KLUE NER 학습 11,000건 / 테스트 2,000건으로 상대 평가. 평가 스크립트
|
||||||
|
`scripts/eval_metadata_f1.py`, 평가환경 python 3.9.
|
||||||
|
|
||||||
|
> **확인 필요** — 성과물 목록상 요소 추출이 오투오(콘텐츠 요소 분석 모듈)와
|
||||||
|
> 고려대(출판콘텐츠추출모델 sLLM) 양쪽에 걸쳐 있다. 측정 주체를 컨소시엄에
|
||||||
|
> 확인한 뒤 본 절차서를 확정한다.
|
||||||
|
|
||||||
|
### 3.3 요약 성능 ROUGE (성능지표 #7)
|
||||||
|
|
||||||
|
#### 3.3.1 시험방법
|
||||||
|
|
||||||
|
```
|
||||||
|
① 자체 제작된 요약 데이터셋 준비
|
||||||
|
② 요약 모델에 학습되지 않은 데이터 1,000건에 대해 모델 요약 진행
|
||||||
|
③ 생성된 결과물에 대한 N-ROUGE score 측정
|
||||||
|
```
|
||||||
|
|
||||||
|
평가 스크립트 `scripts/eval_rouge.py`. ROUGE 는 **recall 기준**으로 계산한다
|
||||||
|
(수식 분모가 참조 n-gram 수). 다중 참조를 전제한다.
|
||||||
|
|
||||||
|
> **선행 과제** — 정답셋 본 구축 전, 파일럿 20건으로 사람 간 일치도(IAA)를 측정하여
|
||||||
|
> 사람 상한을 먼저 확인한다(`eval_rouge.py --iaa`). 상한이 목표 65 미만이면 규격
|
||||||
|
> 조정이 필요하며, 파일럿 단계에서의 조정은 추가 비용이 발생하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.0 시험 데이터 게재에 관한 요청사항
|
||||||
|
|
||||||
|
전 차수 성적서에는 시험 데이터 원문 예시가 게재되었다(성적서 9p, 13p).
|
||||||
|
본 차수 표절 항목의 시험 데이터는 개인 자서전을 기반으로 하므로, **성적서 게재용
|
||||||
|
예시는 익명화가 완료된 생활수기 자료로 대체**하여 제출한다. 접수 시 시험기관에
|
||||||
|
함께 요청한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.0 일정 (안)
|
||||||
|
|
||||||
|
| 단계 | 내용 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 접수 | 시험 신청 및 시험절차서 제출 | |
|
||||||
|
| 사전 협의 | 시험 항목·환경·데이터 게재 범위 확정 | 4.0 항 포함 |
|
||||||
|
| 시험 | 3개 항목 현장 시험 (1일) | 에이아이오투오 판교 연구소 |
|
||||||
|
| 성적서 발행 | | 시험 후 약 5일 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.0 준비 현황
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| 표절 시험셋 1,000건 | **완료** (`data/eval/testset_v2/`) |
|
||||||
|
| 표절 평가 스크립트 | **완료** (`scripts/run_precision_eval.py`) |
|
||||||
|
| 평가 전용 컨테이너 (python 3.9) | **완료** (`Dockerfile.eval`) |
|
||||||
|
| 표절 사전 측정 | **완료** (0.9840) |
|
||||||
|
| 메타 추출 — 측정 주체 확인 | 미완 (컨소시엄 문의 필요) |
|
||||||
|
| 요약 정답셋 — 파일럿 20건 | 미완 |
|
||||||
|
| 요약 정답셋 — 본 구축 300건 | 미완 |
|
||||||
|
| 시험 서버 확정 (A100 환경) | 미완 |
|
||||||
284
scripts/build_plagiarism_testset.py
Normal file
284
scripts/build_plagiarism_testset.py
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
"""성능지표 #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())
|
||||||
158
scripts/run_precision_eval.py
Normal file
158
scripts/run_precision_eval.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""성능지표 #4 정밀도 평가를 끝까지 실행한다.
|
||||||
|
|
||||||
|
build_plagiarism_testset.py 가 만든 index.jsonl 로 시험용 코퍼스와 인덱스를
|
||||||
|
세우고, pairs.jsonl 을 질의해 precision 을 계산한다. 운영 코퍼스와 인덱스는
|
||||||
|
건드리지 않는다.
|
||||||
|
|
||||||
|
python scripts/run_precision_eval.py --testset data/eval/testset_20260908
|
||||||
|
|
||||||
|
시험관 입회 시 이 명령 한 줄로 재현된다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.core.config import get_settings # noqa: E402
|
||||||
|
from app.engine.detector import PlagiarismDetector # noqa: E402
|
||||||
|
from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def build_corpus(rows: list[dict], db_path: Path) -> None:
|
||||||
|
"""시험용 참조 코퍼스를 만든다. 작성자 하나를 문서 하나로 둔다."""
|
||||||
|
if db_path.exists():
|
||||||
|
db_path.unlink()
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
store = CorpusStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
by_author: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
by_author[row["author"]].append(row)
|
||||||
|
|
||||||
|
documents, segments = [], []
|
||||||
|
for index, (author, items) in enumerate(sorted(by_author.items()), start=1):
|
||||||
|
document_id = "testdoc:%04d" % index
|
||||||
|
documents.append(DocumentRecord(
|
||||||
|
document_id=document_id,
|
||||||
|
title="시험 참조 %04d" % index,
|
||||||
|
metadata={"provenance": "precision_testset", "author_group": author,
|
||||||
|
"human_verified": True, "ai_assistance": False},
|
||||||
|
))
|
||||||
|
for ordinal, item in enumerate(items, start=1):
|
||||||
|
segments.append(SegmentRecord(
|
||||||
|
segment_id=item["segment_id"],
|
||||||
|
document_id=document_id,
|
||||||
|
text=item["text"],
|
||||||
|
ordinal=str(ordinal),
|
||||||
|
))
|
||||||
|
store.upsert_documents(documents)
|
||||||
|
added, skipped = store.add_segments(segments)
|
||||||
|
print("시험 코퍼스: 문서 %d개 / 세그먼트 %d개 (중복 %d건 제외)"
|
||||||
|
% (len(documents), added, skipped))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--testset", type=Path, required=True)
|
||||||
|
parser.add_argument("--work-dir", type=Path,
|
||||||
|
help="시험 코퍼스·인덱스를 둘 경로 (기본: <testset>/runtime)")
|
||||||
|
parser.add_argument("--skip-build", action="store_true",
|
||||||
|
help="이미 만든 코퍼스·인덱스를 재사용")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
work = args.work_dir or (args.testset / "runtime")
|
||||||
|
db_path = work / "corpus.sqlite3"
|
||||||
|
index_dir = work / "index"
|
||||||
|
|
||||||
|
index_rows = [json.loads(l) for l in
|
||||||
|
(args.testset / "index.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||||
|
pairs = [json.loads(l) for l in
|
||||||
|
(args.testset / "pairs.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||||
|
|
||||||
|
if not args.skip_build:
|
||||||
|
build_corpus(index_rows, db_path)
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
settings = get_settings().model_copy(update={
|
||||||
|
"corpus_db_path": str(db_path),
|
||||||
|
"persistent_index_dir": str(index_dir),
|
||||||
|
"use_persistent_index": True,
|
||||||
|
"use_llm_legal_judge": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not args.skip_build:
|
||||||
|
from app.engine.persistent_index import PersistentCorpusIndex
|
||||||
|
index_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stats = PersistentCorpusIndex(db_path, index_dir).sync()
|
||||||
|
print("시험 인덱스 구축 완료: %s (%s)" % (index_dir, stats))
|
||||||
|
|
||||||
|
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
|
||||||
|
if key == "tp":
|
||||||
|
tp += 1
|
||||||
|
elif key == "fn":
|
||||||
|
fn += 1
|
||||||
|
elif key == "fp":
|
||||||
|
fp += 1
|
||||||
|
else:
|
||||||
|
tn += 1
|
||||||
|
if i % 100 == 0:
|
||||||
|
print(" 진행 %d/%d" % (i, len(pairs)))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print("표절 여부 판별 정밀도 (precision) : %.4f [목표 0.97]" % precision)
|
||||||
|
print("재현율 (recall) : %.4f" % recall)
|
||||||
|
print("F1 : %.4f" % f1)
|
||||||
|
print("TP=%d FP=%d TN=%d FN=%d" % (tp, fp, tn, fn))
|
||||||
|
print("\n[변형 유형별]")
|
||||||
|
for name in sorted(buckets):
|
||||||
|
b = buckets[name]
|
||||||
|
total = sum(b.values())
|
||||||
|
p = b["tp"] / (b["tp"] + b["fp"]) if (b["tp"] + b["fp"]) else 0.0
|
||||||
|
r = b["tp"] / (b["tp"] + b["fn"]) if (b["tp"] + b["fn"]) else 0.0
|
||||||
|
print(" %-18s n=%4d P=%.3f R=%.3f TP=%d FP=%d TN=%d FN=%d"
|
||||||
|
% (name, total, p, r, b["tp"], b["fp"], b["tn"], b["fn"]))
|
||||||
|
result_path = args.testset / "result.json"
|
||||||
|
result_path.write_text(json.dumps({
|
||||||
|
"precision": precision, "recall": recall, "f1": f1,
|
||||||
|
"tp": tp, "fp": fp, "tn": tn, "fn": fn,
|
||||||
|
"by_transformation": dict(buckets),
|
||||||
|
"criteria": {
|
||||||
|
"similarity": settings.persistent_similarity_threshold,
|
||||||
|
"min_exact_span": settings.persistent_min_exact_span,
|
||||||
|
"min_coverage": settings.persistent_min_coverage,
|
||||||
|
"require_exact_span_evidence": settings.require_exact_span_evidence,
|
||||||
|
},
|
||||||
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print("\n%s 에 결과 기록" % result_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Reference in New Issue
Block a user