textmachine/eval/pkg7/build_termset.py

207 lines
9.5 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, фаза B: сборка набора термов со входом терминолога.
Вход роли ТЕРМИНОЛОГ по D39.42 п.1 — «смердженный банк + KWIC-контексты вхождений из исходника +
варианты черновиков». Здесь это собирается из РЕАЛЬНЫХ артефактов стенда, а не из выдумки:
- `books/gu-zhenren/labels/raw/corpus.jsonl` — 91 юнит из ШЕСТИ настоящих прогонов с
выровненными source/draft/final (постоянный измерительный стенд, D39.39);
- сид книги + `minirun/mined-delta.yaml` — ПОДПИСАННЫЕ владельцем dst (золотой стандарт);
- TSV боевого майнера (пакет-7 фаза A) — намайненные термы БЕЗ dst;
- JSONL-субстраты остальных книг стенда — для выборок S2S4.
ЧЕСТНОЕ ОГРАНИЧЕНИЕ, зафиксированное ДО трат: черновики существуют ТОЛЬКО у 蛊真人. У 金瓶梅,
исэкая и en-книг переводов нет, поэтому на выборках S2S4 компонента «варианты черновиков» пуста
по построению, и арм P1 там вырождается в «только KWIC». Это не правка пре-регистрации, а
названное ограничение материала.
$0: чтение файлов, ни одного вызова модели.
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
import yaml
CORPUS = Path("/home/ubuntu/books/gu-zhenren/labels/raw/corpus.jsonl")
def load_jsonl(p: Path) -> list[dict]:
return [json.loads(l) for l in p.open(encoding="utf-8") if l.strip()]
def kwic(units: list[dict], term: str, field: str, width: int, limit: int) -> list[dict]:
out = []
for u in units:
txt = u.get(field) or ""
pos = 0
while len(out) < limit:
i = txt.find(term, pos)
if i < 0:
break
a, b = max(0, i - width), min(len(txt), i + len(term) + width)
out.append({"chapter": u.get("chapter"), "run": u.get("run"),
"win": txt[a:b].replace("\n", " ")})
pos = i + len(term)
return out
def aligned_pairs(units: list[dict], term: str, limit: int, src_w: int, dst_w: int) -> list[dict]:
"""Пары «окно исходника ↔ пропорционально позиционированное окно черновика».
Выравнивание ПРОПОРЦИОНАЛЬНОЕ (доля позиции в юните), а не словное: это приближение, и оно
названо приближением. Задача пары — показать модели, ЧТО черновик сделал в этом месте, а не
дать точную привязку токена.
"""
out = []
for u in units:
src, drf = u.get("source") or "", u.get("draft") or ""
if not src or not drf:
continue
i = src.find(term)
if i < 0:
continue
frac = i / max(len(src), 1)
j = int(frac * len(drf))
a, b = max(0, i - src_w), min(len(src), i + len(term) + src_w)
c, d = max(0, j - dst_w), min(len(drf), j + dst_w)
out.append({"run": u.get("run"), "chapter": u.get("chapter"),
"src_win": src[a:b].replace("\n", " "),
"draft_win": drf[c:d].replace("\n", " ")})
if len(out) >= limit:
break
return out
def seed_terms(paths: list[Path]) -> dict[str, dict]:
out = {}
for p in paths:
if not p.exists():
continue
doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
for t in doc.get("terms") or []:
if t.get("src") and t.get("dst"):
out[t["src"]] = {"gold": t["dst"], "type": t.get("type", ""),
"note": (t.get("note") or "")[:400]}
return out
def mined_terms(tsv: Path) -> list[dict]:
rows = []
for i, line in enumerate(tsv.open(encoding="utf-8")):
if i == 0:
continue
f = line.rstrip("\n").split("\t")
if f and f[0]:
rows.append({"src": f[0], "type": f[1] if len(f) > 1 else "",
"freq": int(f[2]) if len(f) > 2 and f[2].isdigit() else 0})
return rows
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--mined-tsv", type=Path, action="append", default=[])
ap.add_argument("--chunks", action="append", default=[],
help="sample=path.jsonl для книг без черновиков (S2..S4)")
ap.add_argument("--extra-terms", action="append", default=[],
help="sample=терм,терм,... — ручной список (ловушка S5)")
ap.add_argument("--n-signed", type=int, default=15)
ap.add_argument("--n-mined", type=int, default=15)
a = ap.parse_args()
units = load_jsonl(CORPUS)
gold = seed_terms([Path("/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"),
Path("/home/ubuntu/books/gu-zhenren/minirun/mined-delta.yaml")])
items: list[dict] = []
# --- S1: подписанные термы, реально встречающиеся в корпусе (у них ЕСТЬ эталон) -----------
signed = []
for src, meta in gold.items():
occ = sum((u.get("source") or "").count(src) for u in units)
if occ:
signed.append((occ, src, meta))
signed.sort(key=lambda x: -x[0])
for occ, src, meta in signed[: a.n_signed]:
items.append({
"id": f"S1-signed-{src}", "sample": "S1", "src": src, "lang": "zh",
"gold": meta["gold"], "gold_type": meta["type"], "gold_note": meta["note"],
"occurrences": occ,
"kwic": kwic(units, src, "source", 60, 6),
"pairs": aligned_pairs(units, src, 3, 90, 130),
})
# --- S1: намайненные БЕЗ dst ---------------------------------------------------------------
seen = {i["src"] for i in items}
pool = []
for tsv in a.mined_tsv:
for r in mined_terms(tsv):
if r["src"] in gold or r["src"] in seen:
continue
occ = sum((u.get("source") or "").count(r["src"]) for u in units)
if occ:
pool.append((occ, r))
pool.sort(key=lambda x: -x[0])
picked = set()
for occ, r in pool:
if r["src"] in picked:
continue
picked.add(r["src"])
items.append({
"id": f"S1-mined-{r['src']}", "sample": "S1", "src": r["src"], "lang": "zh",
"gold": None, "gold_type": r["type"], "gold_note": "", "occurrences": occ,
"kwic": kwic(units, r["src"], "source", 60, 6),
"pairs": aligned_pairs(units, r["src"], 3, 90, 130),
})
if len(picked) >= a.n_mined:
break
# --- S2..S4: книги без черновиков ----------------------------------------------------------
for spec in a.chunks:
sample, path = spec.split("=", 1)
rows = load_jsonl(Path(path))
us = [{"source": r["source"], "chapter": r["chapter"], "run": sample} for r in rows]
for spec2 in a.extra_terms:
s2, terms = spec2.split("=", 1)
if s2 != sample:
continue
for t in [x for x in terms.split(",") if x.strip()]:
occ = sum(u["source"].count(t) for u in us)
items.append({
"id": f"{sample}-{t}", "sample": sample, "src": t,
"lang": {"S2": "zh", "S3": "ja", "S4": "en"}.get(sample, "zh"),
"gold": None, "gold_type": "", "gold_note": "", "occurrences": occ,
"kwic": kwic(us, t, "source", 60, 6),
"pairs": [], # черновиков нет по построению — названо в шапке
})
# --- S5: ловушка — подписанные dst, НАРУШАЮЩИЕ буквальность (стайл-каноны D39.21) ---------
for spec2 in a.extra_terms:
s2, terms = spec2.split("=", 1)
if s2 != "S5":
continue
for t in [x for x in terms.split(",") if x.strip()]:
occ = sum((u.get("source") or "").count(t) for u in units)
items.append({
"id": f"S5-{t}", "sample": "S5", "src": t, "lang": "zh",
"gold": gold.get(t, {}).get("gold"), "gold_type": gold.get(t, {}).get("type", ""),
"gold_note": gold.get(t, {}).get("note", ""), "occurrences": occ,
"kwic": kwic(units, t, "source", 60, 6),
"pairs": aligned_pairs(units, t, 3, 90, 130),
})
a.out.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8")
from collections import Counter
c = Counter(i["sample"] for i in items)
ng = sum(1 for i in items if i["gold"])
npair = sum(1 for i in items if i["pairs"])
print(f"собрано термов: {len(items)} по выборкам {dict(c)} с эталоном {ng} с парами черновика {npair}")
return 0
if __name__ == "__main__":
raise SystemExit(main())