textmachine/eval/role_topology/align.py

130 lines
6.4 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
"""Детерминированное выравнивание zh↔ru предложениями — $0, БЕЗ моделей и без torch.
Вынесено из `qe_segment.py` по находке самопроверки: выравнивание нужно и батарее Ф0.5, и
QE-детектору, но батарея не должна тянуть за собой 4.6 ГБ весов и отдельный venv. Модуль
импортируется обоими и проверяется `selfcheck.py` в базовом окружении полигона.
Сюда же вынесен генератор ИНВЕРСИЙ ПОЛЯРНОСТИ: он тоже детерминированный, тоже $0 и тоже нужен
двум разным потребителям (замеру мощности QE и арму починки Ф2б). Первая редакция держала его в
`qe_power.py`, который тянет torch, — и арм починки падал на импорте 4.6 ГБ весов, которые ему не
нужны вовсе. Тот же класс дефекта, что уже был пойман самопроверкой на выравнивании.
"""
from __future__ import annotations
import re
import pymorphy3
_MORPH = pymorphy3.MorphAnalyzer()
def _is_finite_verb(word: str) -> bool:
for p in _MORPH.parse(word.lower()):
if p.is_known and p.tag.POS == "VERB":
return True
return False
# Терминаторы предложений: китайские полноширинные и латинские. Многоточие 。。。/…… не разрывается.
_ZH_SPLIT = re.compile(r"(?<=[。!?;…])(?![。!?;…])")
_RU_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"(\[А-ЯЁA-Z—-])")
def split_zh(t: str) -> list[str]:
return [s.strip() for s in _ZH_SPLIT.split(t) if s.strip()]
def split_ru(t: str) -> list[str]:
out = []
for line in t.split("\n"):
line = line.strip()
if not line:
continue
out.extend(s.strip() for s in _RU_SPLIT.split(line) if s.strip())
return out
def align(src: list[str], dst: list[str]) -> list[tuple[list[int], list[int]]]:
"""Монотонное выравнивание по длине (Гейл–Чёрч). Возвращает пары индексов.
Стоимость пары — квадрат нормированного отклонения фактического отношения длин от среднего по
паре текстов. Среднее берётся ИЗ САМИХ ТЕКСТОВ, а не константой 2.2 из exp07: константа снята
на другом материале, и подставлять её сюда значит тащить чужую калибровку в новый замер.
"""
if not src or not dst:
return []
ratio = sum(len(d) for d in dst) / max(1, sum(len(s) for s in src))
INF = float("inf")
n, m = len(src), len(dst)
cost = [[INF] * (m + 1) for _ in range(n + 1)]
back: dict[tuple[int, int], tuple[int, int]] = {}
cost[0][0] = 0.0
def pair_cost(si: int, sj: int, di: int, dj: int) -> float:
ls = sum(len(x) for x in src[si:sj]) * ratio
ld = sum(len(x) for x in dst[di:dj])
if ls + ld == 0:
return 0.0
# Пропуск (1-0 / 0-1) штрафуется как полное расхождение — иначе выравнивание охотно
# «теряет» предложения, и потерянный сегмент никогда не будет пред'явлен детектору.
base = ((ls - ld) ** 2) / max(ls + ld, 1.0)
return base + (6.0 if (sj == si or dj == di) else 0.0)
for i in range(n + 1):
for j in range(m + 1):
if cost[i][j] == INF:
continue
for ds, dd in ((1, 1), (1, 2), (2, 1), (1, 0), (0, 1), (2, 2)):
ni, nj = i + ds, j + dd
if ni > n or nj > m or (ds == 0 and dd == 0):
continue
c = cost[i][j] + pair_cost(i, ni, j, nj)
if c < cost[ni][nj]:
cost[ni][nj] = c
back[(ni, nj)] = (i, j)
out: list[tuple[list[int], list[int]]] = []
cur = (n, m)
while cur != (0, 0):
prev = back.get(cur)
if prev is None:
return []
out.append((list(range(prev[0], cur[0])), list(range(prev[1], cur[1]))))
cur = prev
return out[::-1]
def flip_polarity(sent: str) -> str | None:
"""Инвертирует полярность предложения: снимает «не» при глаголе либо вставляет его.
Работает только по ЛИЧНОМУ глаголу или глаголу прошедшего времени — причастия и деепричастия
исключены сознательно: «не» при них меняет смысл иначе и часто даёт неграмматичный результат,
а неграмматичность ранкер поймает не как инверсию, а как порчу формы (другой класс).
"""
m = re.search(r"\е\s+([А-Яа-яЁё]+)", sent)
if m and _is_finite_verb(m.group(1)):
return sent[:m.start()] + m.group(1) + sent[m.end():]
for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent):
w = m.group(1)
if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"):
continue
# ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт
# «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер
# мерил бы другой класс дефекта. Поймано проверкой генератора до прогона.
if w[0].isupper():
continue
if _is_finite_verb(w):
return sent[:m.start()] + "не " + sent[m.start():]
return None
def _is_finite_verb(word: str) -> bool:
for p in _MORPH.parse(word.lower()):
if not p.is_known:
continue
if p.tag.POS == "VERB":
return True
return False