feat: sweep exact-span threshold from 3 to 9 eojeol
상무님 요청대로 어절 기준을 3~9로 바꿔가며 재판정한다. 기준값은 검색 후보를 바꾸지 않고 채택 여부만 정하므로, 후보 판정재료를 한 번 남겨두면 7회 실행 없이 오프라인에서 전부 계산할 수 있다. 27자로 재계산한 값이 실제 실행 결과 85건과 일치해 방식을 검증했다. 배치 스크립트에 하드코딩돼 있던 80 을 걷어낸다. --min-exact-span 을 줘도 이 줄이 설정을 읽지 않아 값이 적용되지 않았다. 겹침률 측정도 바로잡는다. 앞서 7어절 0% 로 봤던 것은 4만 쌍에서 히트가 0~1건이라 해상도가 없었고, 그 1건조차 같은 문서 내부의 반복이었다. 15만 쌍으로 늘리고 엔진과 같이 자기 문서 쌍을 빼면 9어절에서도 4.0% 다. 겹침이 사라지는 지점은 없으므로 '오탐 0 인 최저점' 논리는 성립하지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4fab1089e
commit
bf3bd5b99b
6087
reports/batch_dedup_sweep.jsonl
Normal file
6087
reports/batch_dedup_sweep.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -389,6 +389,66 @@ def build_duplicate_sheet(wb: Workbook, excerpts: list[dict], records: dict[str,
|
|||||||
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
|
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict]) -> None:
|
||||||
|
"""어절 기준을 3~9로 바꿔가며 재판정한 결과.
|
||||||
|
|
||||||
|
기준값은 검색 후보를 바꾸지 않고 채택 여부만 정하므로, 한 번 실행해 남긴
|
||||||
|
후보 판정재료로 모든 기준을 다시 계산할 수 있다.
|
||||||
|
"""
|
||||||
|
sheet = wb.create_sheet("어절 기준 비교")
|
||||||
|
sheet.append(["어절 기준별 탐지 건수와 겹침률"])
|
||||||
|
sheet.cell(row=1, column=1).font = Font(bold=True, size=12)
|
||||||
|
for line in (
|
||||||
|
"연속 일치 기준을 3~9어절로 바꿔가며 다시 판정한 결과입니다.",
|
||||||
|
"겹침률은 서로 다른 문서의 원고를 임의로 15만 쌍 짝지어, 그 기준만큼 연속으로 겹치는 비율을 잰 값입니다.",
|
||||||
|
"임의 조합이라 표절 관계일 가능성이 낮으므로 오탐 지표로 봅니다. 다만 각 쌍이 실제로 무관한지 검증한 값은 아닙니다.",
|
||||||
|
):
|
||||||
|
sheet.append([line])
|
||||||
|
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
|
||||||
|
|
||||||
|
sheet.append([])
|
||||||
|
header = ["어절", "글자 수", "탐지 건수", "연속일치로만 걸린 건",
|
||||||
|
"임의 조합 겹침률", "6087건 대조 시 겹칠 확률", "판단"]
|
||||||
|
sheet.append(header)
|
||||||
|
for index in range(1, len(header) + 1):
|
||||||
|
cell = sheet.cell(row=sheet.max_row, column=index)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = HEADER_FILL
|
||||||
|
for item in sweep:
|
||||||
|
cumulative = item["누적"]
|
||||||
|
verdict = ("사용 불가" if cumulative >= 90 else
|
||||||
|
"위험" if cumulative >= 30 else
|
||||||
|
"판단 필요" if cumulative >= 10 else "권장 구간")
|
||||||
|
sheet.append([
|
||||||
|
f"{item['어절']}어절", item["자"], item["탐지"], item["연속일치만"],
|
||||||
|
item["쌍당"] / 100, cumulative / 100, verdict,
|
||||||
|
])
|
||||||
|
sheet.cell(row=sheet.max_row, column=5).number_format = "0.00000%"
|
||||||
|
sheet.cell(row=sheet.max_row, column=6).number_format = "0.0%"
|
||||||
|
|
||||||
|
sheet.append([])
|
||||||
|
# 어절 기준을 아무리 바꿔도 유사도·커버리지만으로 걸리는 건은 그대로다.
|
||||||
|
base = sum(
|
||||||
|
1 for row in rows
|
||||||
|
if any(c["combined"] >= SCORE_MIN or c["coverage"] >= COVERAGE_MIN
|
||||||
|
for c in (row.get("후보 판정재료") or []))
|
||||||
|
)
|
||||||
|
sheet.append([f"기준과 무관하게 걸리는 건 (유사도 0.65 또는 커버리지 30% 충족): {base}건"])
|
||||||
|
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
|
||||||
|
for line in (
|
||||||
|
"어절 기준을 아무리 낮춰도 이 건수는 줄지 않습니다. 어절 기준이 더하는 몫이 '연속일치로만 걸린 건'입니다.",
|
||||||
|
"3어절과 4어절은 임의 조합의 99% 이상이 겹쳐, 침해 신호로 쓸 수 없습니다. 겹친 표현은 '할 수 있는', '할 수 있을 것' 같은 상투어였습니다.",
|
||||||
|
"8어절과 9어절은 탐지 건수가 같습니다. 더 올려도 얻는 것이 없다는 뜻입니다.",
|
||||||
|
"겹침률이 0이 되는 지점은 9어절까지 없습니다. 따라서 '오탐이 사라지는 값'이 아니라 탐지력과 오탐의 교환으로 정해야 합니다.",
|
||||||
|
"정확한 오탐률은 사람이 표본을 판별해 정답을 만든 뒤에만 산출할 수 있습니다.",
|
||||||
|
):
|
||||||
|
sheet.append([line])
|
||||||
|
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
|
||||||
|
for index, width in enumerate((12, 10, 12, 20, 18, 24, 12), start=1):
|
||||||
|
sheet.column_dimensions[get_column_letter(index)].width = width
|
||||||
|
|
||||||
|
|
||||||
SCORE_MIN = 0.65
|
SCORE_MIN = 0.65
|
||||||
SPAN_MIN = 80
|
SPAN_MIN = 80
|
||||||
COVERAGE_MIN = 0.30
|
COVERAGE_MIN = 0.30
|
||||||
@ -465,9 +525,12 @@ COMPARE_COLUMNS = [
|
|||||||
|
|
||||||
def build_compare_sheet(wb: Workbook, first: dict[str, dict], second: dict[str, dict]) -> None:
|
def build_compare_sheet(wb: Workbook, first: dict[str, dict], second: dict[str, dict]) -> None:
|
||||||
sheet = wb.create_sheet("1차_vs_2차 비교")
|
sheet = wb.create_sheet("1차_vs_2차 비교")
|
||||||
flagged_first = {key for key, row in first.items() if row["침해 의심"]}
|
# 중복 제거 회차는 검사 대상이 줄어 키 집합이 다르다. 양쪽에 다 있는
|
||||||
flagged_second = {key for key, row in second.items() if row["침해 의심"]}
|
# 건만 비교해야 없는 키를 참조하지 않는다.
|
||||||
shared = sorted(set(first) & set(second))
|
shared_keys = set(first) & set(second)
|
||||||
|
flagged_first = {k for k in shared_keys if first[k]["침해 의심"]}
|
||||||
|
flagged_second = {k for k in shared_keys if second[k]["침해 의심"]}
|
||||||
|
shared = sorted(shared_keys)
|
||||||
deltas = [(second[key]["결합유사도"] or 0) - (first[key]["결합유사도"] or 0) for key in shared]
|
deltas = [(second[key]["결합유사도"] or 0) - (first[key]["결합유사도"] or 0) for key in shared]
|
||||||
mean_delta = sum(deltas) / len(deltas) if deltas else 0
|
mean_delta = sum(deltas) / len(deltas) if deltas else 0
|
||||||
|
|
||||||
@ -527,6 +590,9 @@ def main() -> int:
|
|||||||
parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL")
|
parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL")
|
||||||
parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)")
|
parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)")
|
||||||
parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항")
|
parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항")
|
||||||
|
parser.add_argument("--sweep-json", type=Path, help="어절 기준 비교 시트용 데이터")
|
||||||
|
parser.add_argument("--dup-batch-jsonl", type=Path,
|
||||||
|
help="중복 원고 시트를 만들 때 참조할 이전 회차 배치 JSONL")
|
||||||
parser.add_argument("--no-excerpt-sheet", action="store_true",
|
parser.add_argument("--no-excerpt-sheet", action="store_true",
|
||||||
help="원문 대조 시트는 만들지 않고 중복 원고 시트만 붙인다")
|
help="원문 대조 시트는 만들지 않고 중복 원고 시트만 붙인다")
|
||||||
parser.add_argument("--excerpts-jsonl", type=Path,
|
parser.add_argument("--excerpts-jsonl", type=Path,
|
||||||
@ -550,7 +616,12 @@ def main() -> int:
|
|||||||
]
|
]
|
||||||
if not args.no_excerpt_sheet:
|
if not args.no_excerpt_sheet:
|
||||||
build_excerpt_sheet(wb, excerpts, records)
|
build_excerpt_sheet(wb, excerpts, records)
|
||||||
build_duplicate_sheet(wb, excerpts, records)
|
dup_records = load(args.dup_batch_jsonl) if args.dup_batch_jsonl else records
|
||||||
|
build_duplicate_sheet(wb, excerpts, dup_records)
|
||||||
|
if args.sweep_json:
|
||||||
|
build_sweep_sheet(
|
||||||
|
wb, json.loads(args.sweep_json.read_text(encoding="utf-8")), rows,
|
||||||
|
)
|
||||||
build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None)
|
build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None)
|
||||||
build_summary_sheet(wb, rows, backend=backend, notes=args.note)
|
build_summary_sheet(wb, rows, backend=backend, notes=args.note)
|
||||||
if args.compare_jsonl:
|
if args.compare_jsonl:
|
||||||
|
|||||||
@ -49,9 +49,11 @@ def main():
|
|||||||
store=CorpusStore(a.database); vectors,meta,model=build_or_load(store,a.index_dir,a.model,device,a.batch_size)
|
store=CorpusStore(a.database); vectors,meta,model=build_or_load(store,a.index_dir,a.model,device,a.batch_size)
|
||||||
get_settings.cache_clear(); settings=get_settings().model_copy(update={"corpus_db_path":str(a.database),"persistent_index_dir":"/nonexistent","use_persistent_index":False,"use_llm_legal_judge":False})
|
get_settings.cache_clear(); settings=get_settings().model_copy(update={"corpus_db_path":str(a.database),"persistent_index_dir":"/nonexistent","use_persistent_index":False,"use_llm_legal_judge":False})
|
||||||
# 법령/태그/규칙 엔진만 재사용한다. CPU 운영 인덱스를 변경하지 않는다.
|
# 법령/태그/규칙 엔진만 재사용한다. CPU 운영 인덱스를 변경하지 않는다.
|
||||||
|
span_min=a.min_exact_span or settings.persistent_min_exact_span
|
||||||
|
SCORE_MIN=settings.persistent_similarity_threshold; COVERAGE_MIN=settings.persistent_min_coverage
|
||||||
if a.min_exact_span:
|
if a.min_exact_span:
|
||||||
settings=settings.model_copy(update={"persistent_min_exact_span":a.min_exact_span})
|
settings=settings.model_copy(update={"persistent_min_exact_span":a.min_exact_span})
|
||||||
LOG.info("min_exact_span=%d (기본값 대신 적용)",a.min_exact_span)
|
LOG.info("판정 기준: 유사도>=%.2f | 연속일치>=%d자 | 커버리지>=%.2f",SCORE_MIN,span_min,COVERAGE_MIN)
|
||||||
detector=PlagiarismDetector(settings); groups=store.document_source_groups(); records=store.get_segments(meta["segment_ids"]); items=build_query_plan(store)
|
detector=PlagiarismDetector(settings); groups=store.document_source_groups(); records=store.get_segments(meta["segment_ids"]); items=build_query_plan(store)
|
||||||
# 중복 제거: 검사 대상과 색인 후보에서 동시에 뺀다. 한쪽만 빼면 지운 원고가
|
# 중복 제거: 검사 대상과 색인 후보에서 동시에 뺀다. 한쪽만 빼면 지운 원고가
|
||||||
# 여전히 상대로 잡혀 중복이 결과에 남는다.
|
# 여전히 상대로 잡혀 중복이 결과에 남는다.
|
||||||
@ -73,15 +75,20 @@ def main():
|
|||||||
seg=records[meta["segment_ids"][int(i)]]; ev,ints,longest=_evidence_spans(item.text,seg.text); intervals.extend(ints); doc_intervals.setdefault(seg.document_id,[]).extend(ints); relem, reels=(seg.lemmas or extract_lemmas(seg.text)), (seg.elements or detector._extractor.extract(seg.text).model_dump()); es=_element_similarities(qe, type(qe)(**reels)); combined=.30*float(scores[int(i)])+.45*lemma_overlap_ratio(ql,relem)+.15*es["characters"]+.10*es["motifs"]
|
seg=records[meta["segment_ids"][int(i)]]; ev,ints,longest=_evidence_spans(item.text,seg.text); intervals.extend(ints); doc_intervals.setdefault(seg.document_id,[]).extend(ints); relem, reels=(seg.lemmas or extract_lemmas(seg.text)), (seg.elements or detector._extractor.extract(seg.text).model_dump()); es=_element_similarities(qe, type(qe)(**reels)); combined=.30*float(scores[int(i)])+.45*lemma_overlap_ratio(ql,relem)+.15*es["characters"]+.10*es["motifs"]
|
||||||
hits.append((combined,seg,float(scores[int(i)]),lemma_overlap_ratio(ql,relem),es,ev,longest))
|
hits.append((combined,seg,float(scores[int(i)]),lemma_overlap_ratio(ql,relem),es,ev,longest))
|
||||||
hits.sort(key=lambda x:x[0],reverse=True); matches=[]
|
hits.sort(key=lambda x:x[0],reverse=True); matches=[]
|
||||||
|
# 기준값은 검색 후보를 바꾸지 않으므로, 상위 후보의 판정재료를 남겨두면
|
||||||
|
# 어절 기준을 바꿔가며 다시 돌리지 않고 오프라인에서 재판정할 수 있다.
|
||||||
|
candidates=[{"seg":s2.segment_id,"doc":s2.document_id,"combined":round(c,4),
|
||||||
|
"longest":l,"coverage":round(_covered_length(doc_intervals.get(s2.document_id,[]))/max(1,len(item.text)),4)}
|
||||||
|
for c,s2,_,_,_,_,l in hits[:5]]
|
||||||
for combined,seg,textsim,lemmasim,es,ev,longest in hits[:5]:
|
for combined,seg,textsim,lemmasim,es,ev,longest in hits[:5]:
|
||||||
coverage=_covered_length(doc_intervals.get(seg.document_id,[]))/max(1,len(item.text));
|
coverage=_covered_length(doc_intervals.get(seg.document_id,[]))/max(1,len(item.text));
|
||||||
if combined < .65 and longest < 80 and coverage < .30: continue
|
if combined < SCORE_MIN and longest < span_min and coverage < COVERAGE_MIN: continue
|
||||||
sh=SimilarityHit(seg.segment_id,str(seg.metadata.get("document_title",seg.document_id)),combined,textsim,lemmasim,es,[])
|
sh=SimilarityHit(seg.segment_id,str(seg.metadata.get("document_title",seg.document_id)),combined,textsim,lemmasim,es,[])
|
||||||
match=detector._to_match(sh,True,None,None).model_copy(update={"source_document_id":seg.document_id,"source_segment_id":seg.segment_id,"matched_coverage":round(coverage,4),"longest_span":longest,"evidence_spans":ev,"match_reasons":["embedding_score" if combined>=.65 else "exact_span_or_coverage"]})
|
match=detector._to_match(sh,True,None,None).model_copy(update={"source_document_id":seg.document_id,"source_segment_id":seg.segment_id,"matched_coverage":round(coverage,4),"longest_span":longest,"evidence_spans":ev,"match_reasons":["embedding_score" if combined>=.65 else "exact_span_or_coverage"]})
|
||||||
matches.append(match)
|
matches.append(match)
|
||||||
coverage=_covered_length(intervals)/max(1,len(item.text)); legal=detector._legal_engine.assess(max_similarity=matches[0].similarity if matches else 0,coverage=coverage,longest_span=max((m.longest_span for m in matches),default=0),legal_tags=[t.tag for m in matches for t in m.tags],query_text=item.text,evidence=[])
|
coverage=_covered_length(intervals)/max(1,len(item.text)); legal=detector._legal_engine.assess(max_similarity=matches[0].similarity if matches else 0,coverage=coverage,longest_span=max((m.longest_span for m in matches),default=0),legal_tags=[t.tag for m in matches for t in m.tags],query_text=item.text,evidence=[])
|
||||||
class R: pass
|
class R: pass
|
||||||
r=R(); r.matches=matches; r.is_infringement=bool(matches); r.confidence=round(matches[0].similarity if matches else (hits[0][0] if hits else 0),4); r.legal_risk=legal; r.score_semantics=type("S",(),{"union_coverage":round(coverage,4)})(); rows.append(result_row(item,r,retrieval_backend=f"KoSimCSE GPU ({a.model}, {device})"))
|
r=R(); r.matches=matches; r.is_infringement=bool(matches); r.confidence=round(matches[0].similarity if matches else (hits[0][0] if hits else 0),4); r.legal_risk=legal; r.score_semantics=type("S",(),{"union_coverage":round(coverage,4)})(); row=result_row(item,r,retrieval_backend=f"KoSimCSE GPU ({a.model}, {device})"); row["후보 판정재료"]=candidates; rows.append(row)
|
||||||
if n%a.progress_every==0: LOG.info("progress processed=%d/%d",n,len(items))
|
if n%a.progress_every==0: LOG.info("progress processed=%d/%d",n,len(items))
|
||||||
a.out_jsonl.write_text("\n".join(json.dumps(r,ensure_ascii=False) for r in rows)+"\n",encoding="utf-8")
|
a.out_jsonl.write_text("\n".join(json.dumps(r,ensure_ascii=False) for r in rows)+"\n",encoding="utf-8")
|
||||||
build_xlsx(rows,a.out_xlsx,backend=f"KoSimCSE GPU ({a.model}, {device})")
|
build_xlsx(rows,a.out_xlsx,backend=f"KoSimCSE GPU ({a.model}, {device})")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user