o2o-plagiarism-ai/docs/TEST_PLAN_2026_PHASE2.md
hbyang 1bbe42127a docs: rewrite test plan in performance certificate format
성능평가확인서 서식(1.0 일반사항 / 2.0 시험 방법 / 3.0 시험 결과) 그대로
옮겨 적을 수 있게 재구성한다. 요약 성능(No.7)은 범위에서 제외해 2개 항목.

수치를 2-1년차 측정값으로 교체:
  메타 데이터 추출  F1 0.8377 (목표 0.83 달성, support 14,257)
  표절 판별 정밀도  precision 0.9840 (목표 0.97 달성)

메타 항목은 max_length 256 / epochs 5 로 재학습한 klue_ner_large_v2 결과다.
데이터 예시·예측 결과·전처리 예시는 모두 실제 실행 출력을 옮겼다.

부록(전사 대상 아님)에 시현 절차, 라벨 매핑 결함 정정 이력,
목표 달성 경과, 환경 간 재현성, 미결 사항을 남긴다.

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

21 KiB
Raw Blame History

성능 평가 확인서 — 시험결과 초안 (2-1년차)

작성: 에이아이오투오 / 2026-09-16 서식: 구미전자정보기술원 성능평가확인서_출판과제_1029 동일 구성 선행 시험: GERIR.CE.2511-02.009 (2025-11-19, 3개 항목 PASS) 범위: 요약 성능(성능지표 #7) 제외 — 2개 항목

성적서 서식 그대로 옮겨 적을 수 있도록 구성했다. 각 절의 번호·표 구성은 원본 서식과 일치하며, 수치와 데이터만 2-1년차 측정값으로 교체하였다.


표지 기재사항

항목 내용
의뢰 기업 ㈜에이아이오투오
주소 (13453) 경기도 성남시 수정구 금토로 32 ㈜KT 판교빌딩 East 504호 ~ 505호
시험품 출판콘텐츠 분석 및 공유 기술용 AI 모델
성적서 용도 출판환경 변화 대응을 위한 생성형 AI 기반 출판 콘텐츠 분석 및 공유 플랫폼 기술 개발과제 성능 평가
시험 항목 2개 항목
시험 장소 에이아이오투오 판교 연구소
시험 결과 붙임(시험결과) 참조

시 험 결 과

1.0 일반사항

1.1 제품 정보

구분 내용
시 료 명 출판콘텐츠 분석 및 공유 기술용 AI 모델
모 델 명 ai_publish_o2o.v2
시 료 수 1

1.2 제품 사진

(원본 서식과 동일하게 제품/화면 사진 삽입)


2.0 시험 방법

2.1 시험 규격

  • 2.1.1 시험규격 : 시험절차서 기준
  • 2.1.2 시험항목 : 시험절차서 2개 항목

2.2 시험 장비 정보

구분 사용 장비 용도
1 Mac book m3 pro 코드 시현 서버 접속용
2 GPU server 코드 시현용

2.2 시험 환경

2.2.1 시험 구성

① 실제 솔루션에 활용되는 AI모델들을 평가에 사용
② 시험용 PC로 실제 AI모델이 가동중인 서버에 접속

2.2.2 시험 환경 상세 정보

구분 시스템 사양 운영 SW 및 시험도구 구현 모듈
시험 PC CPU : Apple M3 pro
RAM : 18GB
SSH 터미널 없음
GPU서버 GPU : RTX 3090 24G × 2
CPU : Intel i9-12900KS (24core)
RAM : 125GB
Ubuntu 24.0.2
Pytorch 2.8
Python 3.9
Cuda 12.2
메타 데이터 추출 모듈,
표절 여부 판별 모듈

2.2.3 평가방법

순번 평가지표(성능지표) 평가방법 비고
1 메타 데이터 추출
(F1 score)
KLUE 데이터셋의 NER 데이터셋을 활용하여, 21,008개의 학습 데이터로 개발된 모델을 학습시키고, 5,000개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행 과제 성능지표 3번에 해당
2 표절 여부 판별 정밀도
(precision)
자체 제작된 실제 표절 글과, 표절이 아닌 글을 Classification하여, precision 계산
(시험 데이터 1,000건 = 표절 500건 + 비표절 500건. 모델이 학습하지 않은 작성자의 자료로 구성)
과제 성능지표 4번에 해당

3.0 시험 결과

3.1 시험 결과 요약

시험항목 목표치 결과 비고
메타 데이터 추출(F1-score) 83% 83.77 83.77% 달성
표절 여부 판별 정밀도(Precision) 97% 98.40 98.40% 달성

3.2 항목별 시험결과


3.2.1 메타 데이터 추출

3.2.1.1 시험방법

① KLUE 데이터셋의 NER(개체명 인식) 데이터셋을 활용하여 21,008개의 학습 데이터로 개발된 모델을 학습
② 학습되지 않은 5,000개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행

3.2.1.2 시험결과

시험항목 목표치 결과 비고
메타 데이터 추출 83% 83.77 달성 수치 83.77%

3.2.1.3 성능 평가 과정

1. 데이터 준비

<학습 데이터 예시(총 21008 건)>

"id": 0,
"sentence": "특히 <영동고속도로:LC> <강릉:LC> 방향 <문막휴게소:LC>에서 <만종분기점:LC>까지 <5㎞:QT> 구간에는 승용차 전용 임시 갓길차로제를 운영하기로 했다.",
"id": 1,
"sentence": "<한군데:QT>서 필름을 너무 낭비한 작품입니다.",
"id": 2,
"sentence": "하지만 이영화에는 감히 별 <5개:QT>를 주고싶다",

...

"id": 21006,
"sentence": "<해경:OG>은 시신을 인양해 <전남 해남:LC>으로 이송하는 한편 다른 실종자를 찾기 위해 수색을 계속 벌이고 있다.",
"id": 21007,
"sentence": "진짜 별그대없엇다면 <수목:DT>드라마 <1위:QT>노릴만큼 스토리탄탄하고 주연조연탄탄해요!",

<데이터 로드 코드>

# 라벨 목록은 데이터셋에서 직접 가져온다.
# KLUE NER 의 id 순서는 B-DT(0) … I-TI(11), O(12) 이다.
def label_names(dataset) -> list[str]:
    return list(dataset.features["ner_tags"].feature.names)


# KLUE NER 은 음절 단위 라벨이라 서브워드 토큰에 맞춰 정렬한다.
# 첫 서브워드에만 라벨을 주고 나머지는 -100 으로 두어 손실에서 제외한다.
def tokenize_and_align(examples, tokenizer, max_length: int):
    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

2. 모델 실행

dataset = load_dataset("klue", "ner")
labels = label_names(dataset["train"])          # 13종
label2id = {label: i for i, label in enumerate(labels)}
id2label = {i: label for label, i in label2id.items()}

tokenizer = AutoTokenizer.from_pretrained("klue/roberta-large")
model = AutoModelForTokenClassification.from_pretrained(
    "klue/roberta-large", num_labels=len(labels),
    id2label=id2label, label2id=label2id)

training_args = TrainingArguments(
    learning_rate=1e-5,
    per_device_train_batch_size=8,
    num_train_epochs=5,
    weight_decay=0.01,
    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),
)
trainer.train()

실행 로그:

학습 21008건 / 평가 5000건
라벨 13종 (데이터셋 기준): B-DT, I-DT, B-LC, I-LC, B-OG, I-OG, B-PS, I-PS, B-QT, I-QT, B-TI, I-TI, O

3. 테스트 데이터 예시

"id": 0,
"sentence": "<경찰:OG>은 또 성매매 알선 자금을 관리한 <박:PS>씨의 딸(<32:QT>)과 성매매 여성 <김:PS>모(<33:QT>)씨 등 <16명:QT>을 같은 혐의로 불구속 입건했다.",

예측 결과 예시

"original_tags": ["B-OG", "I-OG", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O",
"O", "O", "O", "O", "O", "O", "O", "O", "O", "B-PS", "O", "O", "O", "O", "O",
"B-QT", "I-QT", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "B-PS", "O", "O",
"B-QT", "I-QT", "O", "O", "O", "O", "O", "B-QT", "I-QT", "I-QT", "O", "O", "O",
"O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O"]

"predicted_tags": ["B-OG", "I-OG", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O",
"O", "O", "O", "O", "O", "O", "O", "O", "O", "B-PS", "O", "O", "O", "O", "O",
"B-QT", "I-QT", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "B-PS", "O", "O",
"B-QT", "I-QT", "O", "O", "O", "O", "O", "B-QT", "I-QT", "I-QT", "O", "O", "O",
"O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O", "O"]

예측 결과를 문장에 적용하면 다음과 같다(정답과 일치, 음절 70/70).

<경찰:OG>은 또 성매매 알선 자금을 관리한 <박:PS>씨의 딸(<32:QT>)과 성매매 여성
<김:PS>모(<33:QT>)씨 등 <16명:QT>을 같은 혐의로 불구속 입건했다.

4. 결과 측정

{
    "precision": 0.8281132204783771,
    "recall": 0.8475135021393,
    "f1_score": 0.8377010537992235,
    "detailed_report": {
        "DT": {
            "precision": 0.8244274809160306,
            "recall": 0.8559688581314879,
            "f1-score": 0.8399015918958032,
            "support": 2312
        },
        "LC": {
            "precision": 0.7096774193548387,
            "recall": 0.7356579745300182,
            "f1-score": 0.7224343675417661,
            "support": 1649
        },
        "OG": {
            "precision": 0.7518427518427518,
            "recall": 0.7781851512373968,
            "f1-score": 0.7647887323943662,
            "support": 2182
        },
        "PS": {
            "precision": 0.8486238532110092,
            "recall": 0.8602987777274785,
            "f1-score": 0.8544215288611545,
            "support": 4418
        },
        "QT": {
            "precision": 0.9098196392785571,
            "recall": 0.9197080291970803,
            "f1-score": 0.9147370699460916,
            "support": 3151
        },
        "TI": {
            "precision": 0.892226148409894,
            "recall": 0.926605504587156,
            "f1-score": 0.9090909090909091,
            "support": 545
        },
        "micro avg": {
            "precision": 0.8281132204783771,
            "recall": 0.8475135021393,
            "f1-score": 0.8377010537992235,
            "support": 14257
        },
        "macro avg": {
            "precision": 0.8206488699484287,
            "recall": 0.8486381470841563,
            "f1-score": 0.8341687141001656,
            "support": 14257
        },
        "weighted avg": {
            "precision": 0.8301704609178703,
            "recall": 0.8475135021393,
            "f1-score": 0.8384339185288747,
            "support": 14257
        }
    }
}

결과 : 평균 f1 -score 83.77%


3.2.2 표절 여부 판별 정확도

3.2.2.1 시험방법

① 모델이 학습하지 않은, 자체 제작된 표절 글 데이터 준비
② 표절 판별 알고리즘에 대한 전처리 진행
③ 데이터 표절 여부의 precision 점수 계산

3.2.2.2 시험결과

시험항목 목표치 결과 비고
표절 여부 판별 정확도 97% 98.40 달성 수치 98.40%

3.2.2.3 성능 평가 과정

1. 데이터 준비

모델이 학습하지 않은 데이터임을 작성자 단위 분리로 보장한다.

전체 작성자 290명
  ├─ 203명 → 참조 코퍼스(검색 인덱스)에 적재
  └─  87명 → 시험 질의로만 사용 (인덱스에 미포함)

비표절 데이터 예시 :

"pair_id": "legit-0001",
"text": "(인덱스에 포함되지 않은 작성자의 원문 — 게재용 예시는 익명화 완료 자료로 대체)"

...
비표절 데이터 총 500건
  ├─ 주제 근접 시료(하드 네거티브) 150건
  └─ 일반 350건

표절 데이터 예시 :

"pair_id": "plag-verbatim-0067",
"transformation": "verbatim",
"original_excerpt": "저는 어렸을 때부터 연애를 여러 번 해봤습니다. 어린 시절의 연애는 큰 의미
                     없이 가볍게 시작했었죠. 하지만 고등학교에 진학하고 나서 시작한 연애는 …",
"derived_text":     "저는 어렸을 때부터 연애를 여러 번 해봤습니다. 어린 시절의 연애는 큰 의미
                     없이 가볍게 시작했었죠. 하지만 고등학교에 진학하고 나서 시작한 연애는 …"

...
표절 데이터 총 500건
  완전복제 100 / 부분복제 100 / 문장재배열 100 / 어휘치환 150 / 축약 50
전체 데이터 총 1000건

재현성 확보를 위해 구성 비율과 난수 시드를 manifest.json 에 사전 기록하였다.

{
  "seed": 20260908,
  "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
}

2. 전처리 알고리즘

글 구성요소에 대한 전처리 과정

코드

# 공통 표현 제거 — 매칭된 자리를 공백으로 치환
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()


# 개체명 마스킹 — 인명/지명/날짜/숫자
_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:
    masked = _DATE_PATTERN.sub("[DATE]", text)
    masked = _NUM_PATTERN.sub("[NUM]", masked)
    tokens = _kiwi().tokenize(masked)          # 형태소 분석
    parts = [(t.start, t.start + t.len, "[PERSON]")
             for t in tokens if t.tag == "NNP"]   # 고유명사
    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)


# 내용어 lemma 추출 — 어미·조사 변경형을 기본형으로 환원
def extract_lemmas(text: str, min_length: int = 1) -> list[str]:
    tokens = _get_kiwi().tokenize(text)
    lemmas = []
    for t in tokens:
        if not any(t.tag.startswith(p) for p 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

예시

[전처리 전] 1978년 3월, 나는 춘천초등학교에 입학했다. 어머니께서 지어주신 새 옷을 입고 교문을 들어섰다.
[전처리 후] [DATE] 3월, 나는 [PERSON] . 어머니께서 지어주신 새 옷을 입고 교문을 들어섰다.
[lemma   ] ['어머니', '짓다', '옷', '입다', '교문', '들어서다']

연월일이 [DATE], 고유명사 "춘천초등학교" 가 [PERSON] 으로 치환되고, 자서전 빈출 표현인 "에 입학했다" 가 공통 표현 사전에 걸려 제거되었다. 남은 내용어는 어미·조사를 떼고 기본형(짓다, 입다, 들어서다)으로 환원된다.

3. 표절 여부 판별

세 조건 중 ①과 ②를 함께 충족하는 후보만 채택한다.

조건 기준값
① 결합 유사도 ≥ 0.65
최장 연속 일치 ≥ 35자 (9어절) — 필수
③ 문서 단위 커버리지 ≥ 0.30
reasons = []
if h.score >= threshold:
    reasons.append("score_threshold")
if provenance_hit:
    if provenance_hit.longest_span >= settings.persistent_min_exact_span:
        reasons.append("exact_span")
    if document_coverage.get(provenance_hit.document_id, 0.0) >= settings.persistent_min_coverage:
        reasons.append("coverage")
if not reasons:
    continue
# 유사도만 넘고 그대로 겹친 구간이 없는 후보는 채택하지 않는다.
if settings.require_exact_span_evidence and "exact_span" not in reasons:
    continue

②를 필수로 두는 근거 — 같은 주제를 다룬 글은 의미 유사도가 함께 상승한다. 어절 기준 3~9 스윕 및 문서쌍 148,240 쌍 전수 대조 결과, 4어절 이하는 임의 조합의 99% 이상에서 겹침이 발생하여 신호로 쓸 수 없고, 9어절(35자)부터 잔존하는 겹침은 실제 유사 원고로 확인되었다.

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

4. 모델 결과

판정 기준: 유사도>=0.65 | 연속일치>=35자 | 커버리지>=0.30 | 연속일치 필수=True

==============================================================
표절 여부 판별 정밀도 (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

오탐 8건 중 7건이 주제 근접 시료(하드 네거티브)에서 발생하여, 시험 난이도가 실제 운영 조건을 반영하고 있음을 확인하였다.

최종 결과 : precision 98.40% 달성



※ 부록 — 성적서 전사 대상 아님 (내부 참고용)

A. 시현 절차

A.1 메타 데이터 추출 (성능지표 #3)

cd ~/data2/demo/o2o-plagiarism-ai

# 학습 + 평가 (약 19분, RTX 3090 1장)
docker run --rm --gpus '"device=0"' -v $PWD:/w -w /w -e HF_HOME=/w/.cache/hf o2o-ner:latest \
  python scripts/train_klue_ner.py --model klue/roberta-large \
  --out-dir data/models/klue_ner_large_v2 \
  --epochs 5 --batch-size 8 --lr 1e-5 --max-length 256

# 학습 완료 모델로 평가만 재실행
docker run --rm --gpus '"device=0"' -v $PWD:/w -w /w -e HF_HOME=/w/.cache/hf o2o-ner:latest \
  python scripts/train_klue_ner.py --eval-only --out-dir data/models/klue_ner_large_v2

# 데이터 예시 열람 (성적서 게재용)
docker run --rm -v $PWD:/w -w /w -e HF_HOME=/w/.cache/hf o2o-ner:latest \
  python scripts/show_klue_ner_samples.py --head 3 --tail 2

산출물: data/models/klue_ner_large_v2/result.json

A.2 표절 여부 판별 (성능지표 #4)

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

산출물: data/eval/testset_v2/result.json

B. 라벨 매핑 결함 정정 이력 (2026-09-16)

scripts/train_klue_ner.py 가 라벨 목록을 ["O"] + [B-/I- ...] 로 재구성해 써서 데이터셋의 실제 순서(B-DT(0) … I-TI(11), O(12))와 한 칸씩 어긋나 있었다.

tokenize_and_align 은 데이터셋 id 를 그대로 라벨로 넘기는데 build_metrics 가 어긋난 순서로 해석하면서, 실제 O 구간이 I-TI 엔티티로 집계되었다.

구분 잘못된 매핑 올바른 매핑
micro support 67,056 (실측 62,760, max_length 절단분 차이) 14,257 (실제 엔티티 수)
roberta-large F1 (128/3ep) 0.8750 0.8123

O 구간은 길고 예측이 쉬워 엔티티로 계수하면 점수가 크게 부풀려진다. 전 차수 성적서(GERIR.CE.2511-02.009)의 support 62,760 / F1 81% 도 같은 상태였다.

수정 — 라벨 목록을 데이터셋에서 직접 가져와 유일한 출처로 삼는다(label_names()). 모델 자체는 데이터셋 id 공간에서 일관되게 학습되어 재학습이 불필요하며, 잘못된 것은 지표 해석뿐이었다.

C. 목표 달성 경과

구성 max_length epochs micro F1 목표 0.83
v1 (잘못된 매핑 기준) 128 3 0.8750 무효
v1 (올바른 매핑 재측정) 128 3 0.8123 미달
v2 (본 차수 적용) 256 5 0.8377 달성

max_length 128 → 256 으로 긴 문장의 절단 손실을 제거하고 epoch 를 5 로 늘렸다. 태그별로 전 구간 개선되었으며, 특히 약세였던 LC(0.6752 → 0.7224)와 OG(0.7119 → 0.7648) 가 올랐다.

태그 v1 (올바른 매핑) v2 support
DT 0.8216 0.8399 2,312
LC 0.6752 0.7224 1,649
OG 0.7119 0.7648 2,182
PS 0.8332 0.8544 4,418
QT 0.9093 0.9147 3,151
TI 0.8889 0.9091 545

D. 환경 간 재현성 확인 (성능지표 #4)

동일 시험셋을 서로 다른 두 환경에서 실행한 결과가 완전히 일치하였다.

환경 precision recall TP / FP / TN / FN
개발 PC (macOS, Python 3.14) 0.9840 0.9860 493 / 8 / 492 / 7
시험 서버 (RTX 3090, Python 3.9 컨테이너) 0.9840 0.9860 493 / 8 / 492 / 7

E. 미결 사항

항목 내용
평가방법 문구 변경 계획서 p.23 은 "약 11,000개 학습 / 2,000여개 테스트" 로 기재. 본 차수는 21,008 / 5,000 을 사용하므로 접수 시 시험기관 협의 필요
시험 데이터 게재 표절 항목 시험 데이터는 개인 자서전 기반. 게재용 예시는 익명화 완료 생활수기로 대체 요청
No.3 측정 주체 성과물 목록상 요소 추출이 오투오와 고려대 양쪽에 걸쳐 있어 컨소시엄 확인 필요
전 차수 성적서 GERIR.CE.2511-02.009 의 81% 도 B 항 결함 기준. 컨소시엄·시험기관 상의 필요
요약 성능(No.7) 본 문서 범위 외. 정답셋 미구축, 지표 정의(recall/f1) 불일치 미해결