261 lines
19 KiB
Python
261 lines
19 KiB
Python
#!/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:
|
||
"""Форма палладиевской транскрипции — с учётом РУССКОГО СКЛОНЕНИЯ поверх неё.
|
||
|
||
Голая проверка на слоги ломается на «цунях»/«чжэнем»: русское окончание не является
|
||
палладиевским слогом. Поэтому пробуем и сам токен, и его усечения на 1–3 знака с конца —
|
||
ровно длина русской флексии. Усечённый стем обязан остаться ≥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()
|