textmachine/eval/role_topology/detect_word.py

261 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Ф0.4 детектор №1 — «ВЫДУМАННОЕ СЛОВО» (класс k3 размеченного набора пакета-6).
Зачем он существует. Эксп-20 §3.3 намерил, что точечная починка по флагу снимает дефект за
$0.0002 — но мерил при ИДЕАЛЬНОЙ детекции: флаги брались из посадок, а не от детектора. Промт
эксп-21 (строка 13) это и называет главной оговоркой: «детекторов „выдуманное слово“ и „смысловая
инверсия“ не существует». Арм C фазы 2б без такого детектора неизмерим — его «реальный» режим
некому питать. Здесь строится прототип ПОЛИГОНА (не движковое решение: пин строки 46 «Hunspell в
движок не строить» не задет — это Python-зонд, как весь eval/).
Земля. `~/books/gu-zhenren/labels/labels/k3.jsonl` — 2177 размеченных словотипов из шести реальных
прогонов, 10 из них помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне
этого экспа ⇒ второй контур по отношению ко мне выполнен построением, а не моей рукой.
БАЗЛАЙН, который надо бить, лежит там же в `metrics.json`: боевые чекеры дают на k3
**precision 1.0, recall 0.1** (ловят 1 дефект из 10).
Устройство. Слои проверяются НАКОПИТЕЛЬНО, и каждый печатается отдельной строкой — норма D39.46(а)
(«у каждого измеряемого фактора обязан быть арм БЕЗ него») применена к слоям детектора: вклад
каждого виден, а не заявлен.
С0 нет в словаре pymorphy3 — сырой сигнал
С1 + банк/канон книги OK — подписанное имя и его склонённые формы не выдуманы
С2 + палладиевская транскрипция OK — транскрипция ВНЕ банка тоже не выдуманное слово
С3 + декомпозиция OK — продуктивная деривация/сложение (высоко+рангового)
Разделяющий принцип, на котором стоит С3. Выдуманные слова этого класса — ПОРЧА одного реального
слова (вперди←впереди, подамлю←подавлю): они не разбираются на известные части. Легитимные
незнакомые словарю слова — наоборот, СЛОЖЕНИЯ и приставочные производные известных частей. То есть
разделяет не редкость слова, а разложимость. Этот принцип пар-нейтрален: приставки и соединительные
гласные — данные русской ЦЕЛИ (`internal/lang/data/target-ru.txt` держит ту же категорию фактов),
а не факт о китайской книге ⇒ ревью-вопрос §0.1 («заработает ли на паре, которой в репо нет»)
отвечается «да»: сменится файл цели, не код. Источники С1/С2 тоже данные: банк книги и
пар-таблица Палладия (`configs/langpacks/zh-ru/palladius.txt`).
⚠ Первая редакция С3 была ЧИСТЫМ УБЫТКОМ и это поймано исполнением, а не рассуждением: жадный
цикл «известное начало + любой хвост» гасил 6 настоящих дефектов из 10 (силённый → «сил»+«ённый»),
уводя recall 0.90→0.30. Цикл снят; снятие приставки требует, чтобы ХВОСТ был известным словом.
Запуск (офлайн, $0): eval/.venv/bin/python eval/role_topology/detect_word.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "exp16"))
import pymorphy3 # noqa: E402
import yaml # noqa: E402
from palladius import is_palladius_token # noqa: E402
LABELS = Path.home() / "books" / "gu-zhenren" / "labels" / "labels" / "k3.jsonl"
_MORPH = pymorphy3.MorphAnalyzer()
# Русские приставки и первые части сложений. Данные ЦЕЛИ (ru), не книги и не пары: для →de/→en
# сменится этот список, Go-кода в этом прототипе нет вовсе. Порядок — длинные первыми (жадный съём).
PREFIXES = sorted([
"высоко", "низко", "средне", "полу", "сверх", "около", "противо", "лже", "псевдо", "квази",
"анти", "супер", "ультра", "архи", "экстра", "много", "мало", "перво", "едино", "обще",
"само", "взаимо", "равно", "разно", "все", "одно", "двух", "трёх", "трех", "четырёх",
"четырех", "пяти", "шести", "семи", "восьми", "девяти", "десяти", "пере", "пред", "через",
"чрез", "около", "недо", "небез", "небес", "меж", "между", "сопро", "разо", "воз", "вос",
"низ", "нис", "без", "бес", "раз", "рас", "из", "ис", "над", "под", "при", "про", "пре",
"трудно", "легко", "быстро", "медленно", "древне", "ново", "старо", "прото",
"не", "по", "за", "на", "об", "от", "до", "вы", "в", "с", "у", "о",
], key=len, reverse=True)
# Продуктивные словообразовательные суффиксы русского. ЗАКРЫТЫЙ список — в этом его смысл:
# произвольный хвост превращает слой в «известное начало + что угодно», а это ровно та редакция,
# которая гасила настоящие дефекты. Данные ЦЕЛИ, не пары и не книги.
SUFFIXES = ("ов", "ев", "ск", "лив", "чив", "ист", "оват", "еват", "аст", "аль", "ичн", "иан",
"енн", "онн", "ическ", "ическ", "инск", "овск", "евск", "ушк", "юшк", "оньк", "еньк")
def known(word: str) -> bool:
"""Слово есть в морфологическом словаре (любой разбор — словарный, а не предсказанный)."""
return any(p.is_known for p in _MORPH.parse(word))
def translit_shape(word: str, stem_min_syllables: int = 2) -> bool:
"""Форма палладиевской транскрипции — с учётом РУССКОГО СКЛОНЕНИЯ поверх неё.
Голая проверка на слоги ломается на «цунях»/«чжэнем»: русское окончание не является
палладиевским слогом. Поэтому пробуем и сам токен, и его усечения на 13 знака с конца —
ровно длина русской флексии. Усечённый стем обязан остаться ≥3 знаков, иначе от слова
остаётся огрызок, который сегментируется чем угодно.
`stem_min_syllables` — сколько слогов требовать от УСЕЧЁННОГО стема, и это не косметика:
2 (по умолчанию) — для суждения о ПРОИЗВОЛЬНОМ слове. Порог 1 там слишком щедр: замер на
k3 показал, что он всасывает выдуманные слова («подамлю»).
1 — для кандидата, о котором УЖЕ известно, что он имя (прописная + нет в словаре).
Односложные транскрипции — норма ономастики (Чжэн · Фан · Сун), и порог 2 давал 24
ложных срабатывания на одном «Чжэна» в палладий-линте батареи.
Разные пороги для разных вопросов — осознанное решение, а не расхождение.
"""
if is_palladius_token(word, min_syllables=1):
return True
w = word.lower()
return any(len(w) - k >= 3 and is_palladius_token(w[:-k], min_syllables=stem_min_syllables)
for k in (1, 2, 3))
def decomposes(word: str) -> bool:
"""Слово = продуктивная приставка/первая часть + ИЗВЕСТНОЕ СЛОВО.
Требование «хвост известен» — несущее, а не косметическое: без него слой гасит настоящие
дефекты (см. баннер). Хвост берётся длиной ≥4, чтобы «си»+«лённый» и подобные обрезки не
считались разбором. Соединительная гласная (о/е в «высок-О-ранговый») и орфографический
ъ/ь на шве («трёх-Ъ-ярусном») снимаются одной попыткой каждая — иначе «женьшеневого» и
«первокамень» не собираются. Рекурсия не заводится: цепочки приставок в этом классе нет,
а каждая лишняя степень свободы стоит recall.
"""
w = word.lower().replace("ё", "е")
for p in PREFIXES:
if not w.startswith(p):
continue
tail = w[len(p):]
for t in (tail, tail.lstrip("ъь"), "о" + tail, "е" + tail):
if len(t) >= 4 and known(t):
return True
# Суффиксальная деривация от известного корня: «женьшен-евого» ← женьшень. В отличие от снятой
# первой редакции хвост здесь не произвольный, а из закрытого списка продуктивных суффиксов —
# именно это не даёт слою съесть «сил-ённый».
for cut in range(len(w) - 3, 3, -1):
head, suf = w[:cut], w[cut:]
if not any(suf.startswith(s) for s in SUFFIXES):
continue
if known(head) or known(head + "ь") or known(head + "й") or known(head + "а"):
return True
return False
def verdict(word: str, bank: frozenset[str]) -> tuple[bool, str]:
"""(флаг «выдуманное», причина-непропуска). Причина печатается — вердикт обязан быть читаемым."""
# Составное через дефис считается выдуманным, только если ОБЕ части незнакомы: «тёмно-зелёный»
# словарь целиком не знает, но обе половины законны. Проверять по частям — не послабление,
# а единственный способ не флагать нормальное словообразование русского.
if "-" in word:
parts = [p for p in word.split("-") if len(p) >= 2]
if parts and all(known(p) or translit_shape(p) or decomposes(p) for p in parts):
return False, "составное из известных частей"
if known(word):
return False, "в словаре"
if word.lower() in bank:
return False, "строка банка/канона книги"
if translit_shape(word):
return False, "палладиевская транскрипция"
if decomposes(word):
return False, "разложимо на известные части"
return True, "-"
def score(rows: list[dict], layers: int, bank: frozenset[str]) -> dict:
"""Прогон детектора при УРЕЗАННОМ числе слоёв — так меряется вклад каждого слоя, а не заявляется."""
tp = fp = fn = tn = 0
fp_words, fn_words = [], []
for r in rows:
w = r["word"]
flag = not known(w)
if flag and layers >= 1 and w.lower() in bank:
flag = False
if flag and layers >= 2 and translit_shape(w):
flag = False
if flag and layers >= 3 and decomposes(w):
flag = False
gold = r["label"] == "defect"
if flag and gold:
tp += 1
elif flag and not gold:
fp += 1
fp_words.append(w)
elif not flag and gold:
fn += 1
fn_words.append(w)
else:
tn += 1
return dict(tp=tp, fp=fp, fn=fn, tn=tn, fp_words=fp_words, fn_words=fn_words,
precision=(tp / (tp + fp) if tp + fp else None),
recall=(tp / (tp + fn) if tp + fn else None))
def load_bank() -> frozenset[str]:
"""Вайтлист имён/термов книги: dst и склонённые формы из сида + подписанные строки глоссария.
Берутся ОТДЕЛЬНЫЕ кириллические слова, а не строки целиком: банк хранит «Фан Юаня», а детектор
судит словотип. `decl.forms` сида закрывает падежи там, где они выписаны; чего в сиде нет,
подхватывает слой С2 (форма транскрипции).
"""
words: set[str] = set()
def eat(s: str) -> None:
for w in re.findall(r"[А-Яа-яЁё]+", s or ""):
if len(w) >= 2:
words.add(w.lower())
seed = Path.home() / "books" / "gu-zhenren" / "guzhenren-seed-v2.yaml"
if seed.exists():
data = yaml.safe_load(seed.read_text(encoding="utf-8")) or {}
for t in data.get("terms", []) or []:
eat(t.get("dst", ""))
for f in ((t.get("decl") or {}).get("forms") or []):
eat(f)
k6 = LABELS.parent / "k6.jsonl"
if k6.exists():
for line in k6.read_text(encoding="utf-8").splitlines():
if line.strip():
r = json.loads(line)
if r.get("label") == "ok":
eat(r.get("dst", ""))
return frozenset(words)
def main() -> None:
rows = [json.loads(l) for l in LABELS.read_text(encoding="utf-8").splitlines() if l.strip()]
pos = sum(1 for r in rows if r["label"] == "defect")
bank = load_bank()
print(f"земля: {len(rows)} словотипов, дефектов {pos}, чистых {len(rows) - pos}")
print(f"вайтлист банка/канона: {len(bank)} словоформ")
print("базлайн боевых чекеров (labels/metrics.json): precision 1.0000 recall 0.1000 fp 0\n")
print(f"{'слой':38s}{'tp':>4s}{'fp':>5s}{'fn':>4s}{'precision':>11s}{'recall':>9s}")
print("-" * 71)
names = ["С0 нет в словаре", "С1 + банк/канон", "С2 + палладий", "С3 + декомпозиция"]
last = None
for i, nm in enumerate(names):
s = score(rows, i, bank)
p = f"{s['precision']:.4f}" if s["precision"] is not None else ""
r = f"{s['recall']:.4f}" if s["recall"] is not None else ""
print(f"{nm:38s}{s['tp']:4d}{s['fp']:5d}{s['fn']:4d}{p:>11s}{r:>9s}")
last = s
print("\nвыжившие ложные срабатывания:", ", ".join(sorted(last["fp_words"])) or "нет")
print("пропущенные дефекты:", ", ".join(sorted(last["fn_words"])) or "нет")
# Ущерб от ложного флага — ОТДЕЛЬНОЕ число, как требует промт: ремонт по ложному флагу правит
# здоровый текст, то есть портит. Считаем в терминах арма C: сколько починок будет ложными.
n_flag = last["tp"] + last["fp"]
if n_flag:
print(f"\nущерб: из {n_flag} флагов ЛОЖНЫХ {last['fp']} "
f"({last['fp'] / n_flag:.0%}) — столько починок арма C правили бы здоровое слово")
# ВЫДЕЛЕННАЯ ВАЛИДАЦИЯ. Слои выше настраивались, глядя на те же 2177 строк, на которых и
# меряются, — это подгонка, и одних in-sample чисел мало. Посадки k1 пробы 18 построены ЧУЖОЙ
# сессией под другую задачу и в настройке не участвовали ⇒ годятся как held-out.
# ⚠ Набор ЛЕГЧЕ живого: посадки сконструированы заведомо не-словами, а реальные ошибки моделей
# бывают ближе к настоящему слову. Читать как верхнюю границу, не как оценку прода.
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
import editor_wire_probe as WP # noqa: E402,PLC0415
plants = [w for rows in WP.DEFECTS.values() for cls, old, new, _, _ in rows if cls == "k1"
for w in new.split() if w not in old.split()]
hit = sum(1 for w in plants if verdict(w, bank)[0])
print(f"\nвыделенная валидация (посадки k1 пробы 18, в настройке не участвовали): "
f"{hit}/{len(plants)} = {hit / len(plants):.2f}")
out = Path.home() / "books" / "role-topology"
out.mkdir(parents=True, exist_ok=True)
(out / "detect-word-k3.json").write_text(
json.dumps({**last, "holdout_plants": [hit, len(plants)]}, ensure_ascii=False, indent=1),
encoding="utf-8")
if __name__ == "__main__":
main()