textmachine/eval/role_topology/qe_guard.py

128 lines
9.3 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
"""Ф0.6 (часть) — КАЛИБРОВКА ВНЕШНЕГО ГЕЙТА АРМА G на реальных редакторских правках. $0.
Арм G принимает правку редактора, только если внешний гейт видит улучшение (guarded APE,
max(draft, edited)). Шаг 3 (`qe_power.py`) показал, что парный QE уверенно ловит ПОРЧУ: инверсия
полярности даёт медиану Δ +1.49 при направлении 66/72, p<1e-5. Но это половина вопроса. Вторая
половина — цена ошибки в другую сторону: сколько ХОРОШИХ правок гейт отвергнет, потому что не
увидел в них улучшения. Арм, который режет половину полезной редактуры, бесполезен независимо от
того, как хорошо он ловит порчу.
Норма, по которой это делается ДО прогона, а не после: «Сначала померить ИНСТРУМЕНТ, потом им
мерить» (эксп-20 §7 п.0). Гейт арма G — инструмент, и его поведение на реальных правках обязано
быть известно до того, как на нём построен вердикт о топологии.
Материал. `~/books/gu-zhenren/labels/raw/corpus.jsonl` несёт для одной единицы ВСЕ ТРИ текста:
`source` (zh), `draft` (черновик) и `final` (после редактора). То есть реальная редакторская правка
уже сделана и лежит — её не надо покупать. Сегменты выравниваются тем же кодом, что в шаге 2.
Что печатается и как читается:
Δ = QE(final) QE(draft) при одном и том же источнике. Шкала перевёрнута ⇒ Δ<0 значит «редактор
улучшил по мнению гейта», Δ>0 — «ухудшил».
Гейт арма G принял бы правку при Δ < 0. Доля принятых и есть его пропускная способность.
⚠ Что этот замер НЕ говорит. Он не утверждает, что правки с Δ>0 плохи: QE — оценка адекватности
перевода источнику, а редактор по ратифицированному мандату D32.1(г) покупает СТИЛЬ, а не верность
(«ось решения для черновика — верность, не стиль; стиль редактор переписывает»). Стилевое улучшение
при неизменной верности даёт Δ≈0, и гейт его отвергнет — это не ошибка гейта, а его конструктивная
слепота, и именно её величину надо знать заранее.
Запуск (офлайн, $0): ~/.venvs/tm-qe/bin/python eval/role_topology/qe_guard.py [n_units]
"""
from __future__ import annotations
import json
import os
import statistics
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from align import align, split_ru, split_zh # noqa: E402
from qe_bench import QE, MODEL # noqa: E402
OUT = Path.home() / "books" / "role-topology"
CORPUS = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
def main(n_units: int) -> None:
units = [json.loads(l) for l in CORPUS.read_text(encoding="utf-8").splitlines() if l.strip()]
units = [u for u in units
if (u.get("source") or "").strip() and (u.get("draft") or "").strip()
and (u.get("final") or "").strip()]
# Берётся ПРЕФИКС, а не случайная выборка: `Math.random` в этом харнессе сделал бы прогон
# невоспроизводимым, а вопрос замера не требует случайности — требуется объявленное n.
units = units[:n_units]
qe = QE()
rows: list[dict] = []
unit_rows: list[dict] = []
skipped = 0
for idx, u in enumerate(units):
zs = split_zh(u["source"])
ds, fs = split_ru(u["draft"]), split_ru(u["final"])
# Черновик и финал выравниваются с источником НЕЗАВИСИМО: редактор меняет число
# предложений (эксп-20 §4: 13→6 абзацев), и общее выравнивание было бы подгонкой.
pd = [(a, b) for a, b in align(zs, ds) if a and b]
pf = [(a, b) for a, b in align(zs, fs) if a and b]
# ⚠ СОПОСТАВЛЕНИЕ ПО ПЕРЕСЕЧЕНИЮ, а не по точному совпадению группы zh-индексов.
# Первая редакция требовала совпадения кортежа и теряла 596 сегментов из 1143 — причём
# СИСТЕМАТИЧЕСКИ: группировка расходится ровно там, где редактор слил или разбил
# предложения, то есть там, где он и работал. Клейм на такой выборке был бы смещён в
# сторону консервативных правок. Здесь берётся объединение пересекающихся групп.
for si, di in pd:
sset = set(si)
hit = [(a, b) for a, b in pf if sset & set(a)]
if not hit:
skipped += 1
continue
s = " ".join(zs[i] for i in sorted(sset | {i for a, _ in hit for i in a}))
d = " ".join(ds[i] for i in di)
f = " ".join(fs[i] for i in sorted({i for _, b in hit for i in b}))
if d == f:
continue # редактор сегмент не тронул — решать нечего
rows.append(dict(unit=idx, draft=qe.score(s, d), final=qe.score(s, f),
dlen=len(d), flen=len(f)))
# ЕДИНИЦА ЦЕЛИКОМ — замер без выравнивания вовсе, поэтому без потерь по построению.
# Арм G может гейтить и на этой гранулярности; какая из двух работает, решает замер.
unit_rows.append(dict(unit=idx,
draft=qe.score(u["source"], u["draft"]),
final=qe.score(u["source"], u["final"])))
if not rows:
print("сравнимых сегментов не набралось")
return
deltas = [r["final"] - r["draft"] for r in rows]
better = sum(1 for x in deltas if x < 0)
same = sum(1 for x in deltas if abs(x) < 0.25)
print(f"КАЛИБРОВКА ГЕЙТА АРМА G — модель {MODEL}")
print(f"единиц {len(units)} · изменённых редактором сегментов {len(rows)} · "
f"без пары после выравнивания {skipped}\n")
print(f"медиана Δ (final draft) {statistics.median(deltas):+.3f}")
print(f"редактор УЛУЧШИЛ по гейту {better}/{len(rows)} = {better / len(rows):.0%}")
print(f"гейт видит паритет (|Δ|<0.25) {same}/{len(rows)} = {same / len(rows):.0%}")
print(f"\n{'решение гейта':>28s}{'сегментов':>11s}{'доля':>8s}")
print("-" * 47)
for label, cond in (("принять правку (Δ<0)", lambda x: x < 0),
("принять с запасом (Δ<0.5)", lambda x: x < -0.5),
("отвергнуть (Δ>0)", lambda x: x > 0),
("отвергнуть уверенно (Δ>0.5)", lambda x: x > 0.5)):
k = sum(1 for x in deltas if cond(x))
print(f"{label:>28s}{k:11d}{k / len(rows):8.0%}")
ud = [r["final"] - r["draft"] for r in unit_rows]
if ud:
ub = sum(1 for x in ud if x < 0)
print(f"\nЕДИНИЦА ЦЕЛИКОМ (без выравнивания, потерь нет по построению), n={len(ud)}:")
print(f" медиана Δ {statistics.median(ud):+.3f} · редактор улучшил {ub}/{len(ud)} "
f"= {ub / len(ud):.0%}")
(OUT / f"qe-guard-{'xl' if 'XL' in MODEL else 'large'}.json").write_text(
json.dumps({"segments": rows, "units": unit_rows}, ensure_ascii=False), encoding="utf-8")
print("\nЧитать вместе с шагом 3: гейт ловит порчу (Δ +1.49 на инверсии, 66/72), но его\n"
"решение по РЕАЛЬНОЙ правке — это доля выше, и она определяет, что арм G вообще купит.")
if __name__ == "__main__":
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
main(int(sys.argv[1]) if len(sys.argv) > 1 else 25)