textmachine/eval/pkg7/rubric_probe.py

207 lines
11 KiB
Python
Raw 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
"""Полигон, пакет-7 (A2): проба ИЗМЕРИМОСТИ критериев рубрики «нормально переведённый термин».
Рубрика без работающей измерялки — это мнение. Скрипт берёт три критерия, про которые
правдоподобно, что они машинные, и проверяет их НА ПОДПИСАННОМ ВЛАДЕЛЬЦЕМ сиде — то есть на
эталоне, а не на выдумке. Что сходится с эталоном, то в фазе B можно мерить бесплатно; что не
сходится — честно уезжает к человеку.
Критерии в пробе:
M1 морфологическая пригодность — склоняется ли dst по-русски, устойчив ли род; сверка
рукописных `decl.forms` сида с формами, которые порождает pymorphy3 (расхождение = либо
ошибка сида, либо предел автомата — обе находки полезны);
M2 транскрипционная норма — латиница/цифры внутри dst, недопустимые для кириллического
таргета сочетания, а также согласие с фонотактикой Палладия из боевого langpack;
M3 внутренняя однозначность банка — один src с несколькими dst и один dst у нескольких src
(коллизии, которые терминологу придётся разрешать по D39.42 п.1).
$0: pymorphy3 (локальный словарь) + stdlib. Ни одного сетевого вызова.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
import yaml
try:
import pymorphy3
except ImportError: # pragma: no cover
pymorphy3 = None
CASES = ["gent", "datv", "accs", "ablt", "loct"]
LATIN = re.compile(r"[A-Za-z]")
DIGIT = re.compile(r"[0-9]")
def head_index(morph, tokens: list[str], typ: str) -> int:
"""Индекс склоняемой вершины словосочетания.
Правило РАЗНОЕ по типу терма, и это само по себе требование к рубрике:
- имя/прозвище («Фан Юань», «Гуюэ Мочэнь», «матушка Шэнь»): вершина — ПОСЛЕДНИЙ
компонент антропонима… кроме случая «нарицательное + оним» («матушка Шэнь»), где
склоняется нарицательное, а оним стоит неизменным;
- нарицательное словосочетание («деревня Гуюэ», «море истинной ци», «Четвёртый глава
рода»): вершина — ПЕРВОЕ существительное, остальное — зависимое.
Первая версия скрипта брала последний токен всегда и на «Монах Цветочного Вина» склоняла
«Вина» — цифра согласия вышла заниженной по МОЕЙ вине, а не по вине сида.
"""
if not tokens:
return 0
def is_noun(tok: str) -> bool:
if not morph:
return False
p = morph.parse(tok)[0]
return str(p.tag.POS) in ("NOUN", "ADJF", "PRTF")
lower_common = [i for i, t in enumerate(tokens) if t[:1].islower()]
if typ in ("name", "nickname"):
# «матушка Шэнь» — нарицательное впереди строчной буквой ⇒ склоняем его
if lower_common and lower_common[0] == 0:
return 0
return len(tokens) - 1
for i, t in enumerate(tokens):
if is_noun(t):
return i
return 0
def m1_morphology(morph, dst: str, declared: list[str], invariant: bool, typ: str = "") -> dict:
"""Склоняемость, род и сверка объявленных форм с порождёнными."""
tokens = dst.split()
hi = head_index(morph, tokens, typ)
head = tokens[hi] if tokens else dst
parses = morph.parse(head) if morph else []
if not parses:
return {"parsed": False, "head": head}
p = parses[0]
# Слово, которого нет в словаре, pymorphy3 разбирает предсказателем — вердикт по нему
# держать наравне со словарным нельзя, поэтому факт помечается отдельно.
guessed = not getattr(p.methods_stack[0][0], "__class__", type(None)).__name__.startswith("Dictionary")
gen = {}
for c in CASES:
f = p.inflect({c})
if f:
parts = list(tokens)
parts[hi] = f.word
gen[c] = " ".join(parts)
declinable = len(set(gen.values())) > 1
dec_set = {d.strip().lower() for d in declared}
gen_set = {g.lower() for g in gen.values()}
return {
"parsed": True,
"head": head,
"guessed": guessed,
"pos": str(p.tag.POS),
"gender": str(p.tag.gender),
"animacy": str(p.tag.animacy),
"declinable_by_morph": declinable,
"declared_invariant": invariant,
# Расхождение №1: сид говорит «склоняется», автомат не склоняет (или наоборот)
"invariance_conflict": (invariant and declinable) or (not invariant and not declinable),
"declared_forms": sorted(dec_set),
"generated_forms": sorted(gen_set),
"forms_agree": bool(dec_set) and dec_set == gen_set,
"forms_declared_not_generated": sorted(dec_set - gen_set),
"forms_generated_not_declared": sorted(gen_set - dec_set),
}
def m2_translit(dst: str, pack_dir: Path | None) -> dict:
bad = []
if LATIN.search(dst):
bad.append("латиница внутри dst")
if DIGIT.search(dst):
bad.append("цифра внутри dst")
if pack_dir:
pt = pack_dir / "palladius-phonotactics.txt"
if pt.exists():
for line in pt.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) >= 2 and parts[0] and parts[0] in dst.lower():
bad.append(f"фонотактика Палладия: «{parts[0]}» → ожидается «{parts[1]}»")
return {"violations": bad}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--seed", required=True, type=Path)
ap.add_argument("--langpack-pair", type=Path, help="каталог пары langpack (для Палладия)")
ap.add_argument("--out", type=Path)
a = ap.parse_args()
if pymorphy3 is None:
print("pymorphy3 не установлен — критерий M1 не измеряется", file=sys.stderr)
morph = pymorphy3.MorphAnalyzer() if pymorphy3 else None
doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {}
terms = doc.get("terms") or []
rows, by_src, by_dst = [], defaultdict(set), defaultdict(set)
for t in terms:
src, dst = t.get("src", ""), (t.get("dst") or "")
if not dst:
continue
decl = t.get("decl") or {}
r = {
"src": src, "dst": dst, "type": t.get("type", ""), "status": t.get("status", ""),
"M1": m1_morphology(morph, dst, decl.get("forms") or [], bool(decl.get("invariant")), t.get("type", "")),
"M2": m2_translit(dst, a.langpack_pair),
}
rows.append(r)
by_src[src].add(dst)
by_dst[dst.lower()].add(src)
n = len(rows)
parsed = [r for r in rows if r["M1"].get("parsed")]
guessed = [r for r in rows if r["M1"].get("guessed")]
with_forms = [r for r in rows if r["M1"].get("declared_forms")]
agree = [r for r in with_forms if r["M1"]["forms_agree"]]
# Вердикт по неизменяемости имеет силу ТОЛЬКО когда вершина словарная: на угаданной
# («гу», «Чилянь», «ци») автомат выдумывает парадигму, и его «склоняется» ничего не значит.
conflict = [r for r in rows
if r["M1"].get("invariance_conflict") and not r["M1"].get("guessed")]
conflict_guessed = [r for r in rows
if r["M1"].get("invariance_conflict") and r["M1"].get("guessed")]
m2bad = [r for r in rows if r["M2"]["violations"]]
coll_src = {k: v for k, v in by_src.items() if len(v) > 1}
coll_dst = {k: v for k, v in by_dst.items() if len(v) > 1}
print(f"термов с dst: {n}")
print(f"M1 разобрано pymorphy3: {len(parsed)}/{n}; из них ВЕРШИНА УГАДАНА "
f"предсказателем (нет в словаре): {len(guessed)}")
print(f"M1 объявлены формы в сиде: {len(with_forms)}; из них СОВПАЛИ с порождёнными: "
f"{len(agree)} ({len(agree) / len(with_forms):.3f})" if with_forms else "M1 форм нет")
print(f"M1 конфликт «invariant vs склоняемость», вершина СЛОВАРНАЯ (вердикт имеет силу): "
f"{len(conflict)}")
print(f"M1 то же, но вершина УГАДАНА (вердикт силы НЕ имеет, показан справочно): "
f"{len(conflict_guessed)}")
print(f"M2 нарушений (латиница/цифра/фонотактика): {len(m2bad)}")
print(f"M3 один src → несколько dst: {len(coll_src)} {dict(list(coll_src.items())[:5])}")
print(f"M3 один dst → несколько src: {len(coll_dst)} {dict(list(coll_dst.items())[:5])}")
print("\nM1: где рукописные формы РАЗОШЛИСЬ с автоматом (первые 15):")
for r in [r for r in with_forms if not r["M1"]["forms_agree"]][:15]:
m = r["M1"]
print(f" {r['src']}{r['dst']} сид={m['forms_declared_not_generated']} "
f"автомат={m['forms_generated_not_declared']}")
print("\nM1: конфликт объявленной неизменяемости (первые 15):")
for r in conflict[:15]:
m = r["M1"]
print(f" {r['src']}{r['dst']} invariant={m['declared_invariant']} "
f"склоняем_автоматом={m['declinable_by_morph']} pos={m['pos']} род={m['gender']}")
if a.out:
a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())