О качестве проверки мы до сих пор знали только «механизм жив»: находит подброшенный фрагмент. Продукт при этом продаёт процент заимствований, за который никто не ручался — неизвестно было ни сколько списываний система пропускает, ни как часто обвиняет невиновных. Бенчмарк делает из документов корпуса «студенческие работы» четырёх видов и гоняет их через настоящий путь L1. Первый замер на проде: дословно 100% найдено лёгкий рерайт 100% найдено сильный рерайт 0% — это работа L3/L4, не L1 оригинал 0% ложных обвинений То есть основа работает как задумано. Показательно другое: из 20 взятых документов в замер попали 8 — у остальных нет полного текста нужной длины. Узкое место не алгоритм, а глубина корпуса. Запускать после изменения порогов и параметров winnowing — иначе непонятно, улучшение сделано или ухудшение. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
9.4 KiB
Python
181 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Замер качества детекции на реальном корпусе: что находим и что выдумываем.
|
||
|
||
Зачем: до сих пор о качестве проверки мы знали только «механизм жив» — находит
|
||
подброшенный фрагмент. Этого мало. Продукт продаёт процент заимствований, и
|
||
нужно знать две вещи: сколько списываний система пропускает (полнота) и как
|
||
часто обвиняет невиновных (точность). Без этих чисел непонятно, что улучшать —
|
||
пороги, глубину корпуса или алгоритм.
|
||
|
||
Как устроен замер. Берём документы из самого корпуса и делаем из них
|
||
«студенческие работы» четырёх видов:
|
||
|
||
дословно — фрагмент скопирован как есть (обязан находиться);
|
||
лёгкий рерайт — выброшено каждое 10-е слово (обязан находиться);
|
||
сильный рерайт— выброшено каждое 3-е слово (L1 не обязан; это работа L3/L4);
|
||
оригинал — текст, которого в корпусе нет (обязан НЕ находиться).
|
||
|
||
Считается путь L1 — тот же, что в index.extract_and_check: фрагментация,
|
||
winnowing по каждому фрагменту, поиск по отпечаткам с порогом
|
||
EXACT_FRAGMENT_THRESHOLD. Уровни L3/L4 сюда не входят намеренно: они требуют
|
||
GPU-воркера и на порядок медленнее, а мерить нужно в первую очередь основу.
|
||
|
||
Запуск (в контейнере worker-indexer):
|
||
cd /home/user/anti-plagiarism
|
||
docker compose -f docker-compose.prod.yml exec -T worker-indexer python - \\
|
||
< scripts/ops/detection_benchmark.py
|
||
... python - --docs 40 < scripts/ops/detection_benchmark.py
|
||
|
||
Ничего не пишет в базу — только читает.
|
||
"""
|
||
|
||
import argparse
|
||
import random
|
||
|
||
|
||
def make_cases(text: str, words_taken: int) -> dict[str, str]:
|
||
"""Сделать из текста источника четыре «студенческие работы»."""
|
||
words = text.split()
|
||
start = len(words) // 3
|
||
chunk = words[start : start + words_taken]
|
||
|
||
return {
|
||
"дословно": " ".join(chunk),
|
||
"лёгкий рерайт": " ".join(w for i, w in enumerate(chunk) if i % 10),
|
||
"сильный рерайт": " ".join(w for i, w in enumerate(chunk) if i % 3),
|
||
}
|
||
|
||
|
||
def original_text(rnd: random.Random, words_taken: int) -> str:
|
||
"""Текст, которого заведомо нет в корпусе — проверка на ложные обвинения."""
|
||
topics = [
|
||
"вчера вечером я варил гречку с грибами и вспоминал поездку на озеро",
|
||
"мой сосед купил подержанный велосипед и красит его в оранжевый цвет",
|
||
"бабушка печёт пироги с капустой по субботам и зовёт всех соседей",
|
||
"кот запрыгнул на подоконник и уронил горшок с геранью на пол",
|
||
"в субботу мы чинили забор и обсуждали цены на доски и гвозди",
|
||
]
|
||
out: list[str] = []
|
||
while len(out) < words_taken:
|
||
out.extend(rnd.choice(topics).split())
|
||
return " ".join(out[:words_taken])
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--docs", type=int, default=25, help="сколько документов взять для замера")
|
||
ap.add_argument("--words", type=int, default=400, help="сколько слов «списывать»")
|
||
ap.add_argument("--seed", type=int, default=20260905, help="зерно случайности (для повторяемости)")
|
||
args = ap.parse_args()
|
||
|
||
from app.algorithms.winnowing import winnow
|
||
from app.config import settings
|
||
from app.db import db_session, get_minio
|
||
from app.fragments import split_into_fragments
|
||
from app.models import Document, Fingerprint
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy import text as sql_text
|
||
|
||
rnd = random.Random(args.seed)
|
||
|
||
# Берём документы с реальной глубиной: по аннотации в 30 отпечатков мерить
|
||
# нечего, и такой замер сказал бы больше о корпусе, чем об алгоритме
|
||
with db_session() as s:
|
||
rows = s.execute(sql_text("""
|
||
SELECT d.id, d.source, d.minio_key, d.abstract
|
||
FROM documents d
|
||
JOIN (SELECT doc_id, count(*) c FROM fingerprints
|
||
GROUP BY doc_id HAVING count(*) > 800) f ON f.doc_id = d.id
|
||
WHERE d.source <> 'user_submission'
|
||
ORDER BY random() LIMIT :n
|
||
"""), {"n": args.docs}).all()
|
||
|
||
print(f"документов в замере: {len(rows)}, «списываем» по {args.words} слов")
|
||
print(f"порог срабатывания фрагмента: {settings.EXACT_FRAGMENT_THRESHOLD}%\n")
|
||
|
||
def find_source(work_text: str) -> set[int]:
|
||
"""Прогнать «работу» через L1 и вернуть найденные документы-источники."""
|
||
found: set[int] = set()
|
||
frags = split_into_fragments(
|
||
work_text,
|
||
window=settings.FRAGMENT_WINDOW_WORDS,
|
||
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
||
)
|
||
with db_session() as s:
|
||
for frag in frags:
|
||
fp = winnow(frag["text"])
|
||
if not fp:
|
||
continue
|
||
hit = s.execute(
|
||
select(Fingerprint.doc_id, func.count(Fingerprint.id).label("c"))
|
||
.join(Document, Document.id == Fingerprint.doc_id)
|
||
.where(
|
||
Fingerprint.hash_value.in_(list(fp)),
|
||
Document.source != "user_submission",
|
||
)
|
||
.group_by(Fingerprint.doc_id)
|
||
.order_by(func.count(Fingerprint.id).desc())
|
||
.limit(1)
|
||
).first()
|
||
if hit and hit[1] / len(fp) * 100 >= settings.EXACT_FRAGMENT_THRESHOLD:
|
||
found.add(hit[0])
|
||
return found
|
||
|
||
stats: dict[str, dict[str, int]] = {}
|
||
minio = get_minio()
|
||
|
||
for doc_id, _source, minio_key, abstract in rows:
|
||
# Полный текст, если он сохранён; иначе то, что есть в базе
|
||
body = abstract or ""
|
||
if minio_key:
|
||
try:
|
||
obj = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
|
||
body = obj.read().decode("utf-8", errors="replace")
|
||
obj.close()
|
||
obj.release_conn()
|
||
except Exception:
|
||
pass
|
||
if len(body.split()) < args.words * 2:
|
||
continue
|
||
|
||
for case_name, work in make_cases(body, args.words).items():
|
||
found = find_source(work)
|
||
bucket = stats.setdefault(case_name, {"всего": 0, "нашли верно": 0, "не нашли": 0})
|
||
bucket["всего"] += 1
|
||
if doc_id in found:
|
||
bucket["нашли верно"] += 1
|
||
else:
|
||
bucket["не нашли"] += 1
|
||
|
||
# Контроль на ложные обвинения
|
||
found = find_source(original_text(rnd, args.words))
|
||
bucket = stats.setdefault("оригинал", {"всего": 0, "ложно обвинён": 0, "чисто": 0})
|
||
bucket["всего"] += 1
|
||
if found:
|
||
bucket["ложно обвинён"] += 1
|
||
else:
|
||
bucket["чисто"] += 1
|
||
|
||
print(f"{'случай':16} {'всего':>6} {'найден':>8} {'пропущен':>10} доля найденных")
|
||
for case in ("дословно", "лёгкий рерайт", "сильный рерайт"):
|
||
b = stats.get(case)
|
||
if not b:
|
||
continue
|
||
share = b["нашли верно"] / b["всего"] * 100 if b["всего"] else 0
|
||
print(f"{case:16} {b['всего']:>6} {b['нашли верно']:>8} {b['не нашли']:>10} {share:5.1f}%")
|
||
|
||
b = stats.get("оригинал")
|
||
if b:
|
||
fp_rate = b["ложно обвинён"] / b["всего"] * 100 if b["всего"] else 0
|
||
print(f"\nоригинальные тексты: {b['всего']}, ложных обвинений {b['ложно обвинён']} "
|
||
f"({fp_rate:.1f}%)")
|
||
|
||
print("\nЧитать так: «дословно» и «лёгкий рерайт» — обязанность L1, доля должна быть "
|
||
"близка к 100%. «Сильный рерайт» L1 брать не обязан, это работа L3/L4. "
|
||
"Ложные обвинения должны быть нулевыми.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|