feat: emit precision result in scorecard JSON format

성적서 '4. 모델 결과' 는 plagiarism_detection_performance 아래에
test_dataset / confusion_matrix / performance_metrics / threshold /
interpretation 을 담은 JSON 을 캡처해 싣는다. 그 형식으로 출력한다.

threshold 는 전 차수처럼 단일 수치가 아니라 세 조건(결합유사도 0.65 /
연속일치 35자 / 커버리지 0.30)의 조합이므로 객체로 둔다. 유사도만으로
판정하지 않는다는 점이 판정 기준의 핵심이라 수치 하나로 줄이면 오해를 준다.

interpretation 문구는 실제 confusion matrix 에서 계산한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-16 16:01:49 +09:00
parent a758423b26
commit 5669c73f10

View File

@ -58,6 +58,58 @@ def build_corpus(rows: list[dict], db_path: Path) -> None:
% (len(documents), added, skipped))
def build_scorecard(settings, tp: int, fp: int, tn: int, fn: int,
precision: float, recall: float, f1: float) -> dict:
"""성적서 "4. 모델 결과" 게재 형식으로 정리한다.
차수 성적서(GERI `GERIR.CE.2511-02.009`) 13페이지와 같은 구성이라
출력을 그대로 캡처해 붙일 있다.
threshold 단일 수치가 아니라 조건의 조합이므로 객체로 둔다.
유사도만으로 판정하지 않는다는 점이 판정 기준의 핵심이다.
"""
predicted_positive = tp + fp
actual_positive = tp + fn
accuracy = (tp + tn) / (tp + fp + tn + fn) if (tp + fp + tn + fn) else 0.0
fpr = fp / (fp + tn) if (fp + tn) else 0.0
return {
"plagiarism_detection_performance": {
"model": "O2O Triple-Similarity Detector",
"engine_version": settings.engine_version,
"test_dataset": {
"total_samples": tp + fp + tn + fn,
"plagiarism_cases": actual_positive,
"non_plagiarism_cases": fp + tn,
},
"confusion_matrix": {
"true_positive": tp,
"false_positive": fp,
"true_negative": tn,
"false_negative": fn,
},
"performance_metrics": {
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1_score": round(f1, 4),
"accuracy": round(accuracy, 4),
},
"threshold": {
"combined_similarity": settings.persistent_similarity_threshold,
"min_exact_span_chars": settings.persistent_min_exact_span,
"min_coverage": settings.persistent_min_coverage,
"require_exact_span_evidence": settings.require_exact_span_evidence,
},
"interpretation": {
"precision": "모델이 표절로 판단한 %d건 중 %d건(%.1f%%)이 실제 표절"
% (predicted_positive, tp, precision * 100),
"recall": "실제 표절 %d건 중 %d건(%.1f%%)을 정확히 탐지"
% (actual_positive, tp, recall * 100),
"false_positive_rate": "%.1f%%" % (fpr * 100),
},
}
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--testset", type=Path, required=True)
@ -138,6 +190,15 @@ def main() -> int:
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"]))
scorecard = build_scorecard(settings, tp, fp, tn, fn, precision, recall, f1)
rendered = json.dumps(scorecard, ensure_ascii=False, indent=2)
print("\n" + "=" * 62)
print("4. 모델 결과 :")
print()
print(rendered)
print("\n최종 결과 : precision %.2f%% 달성" % (precision * 100))
(args.testset / "scorecard.json").write_text(rendered + "\n", encoding="utf-8")
result_path = args.testset / "result.json"
result_path.write_text(json.dumps({
"precision": precision, "recall": recall, "f1": f1,