textmachine/eval/pkg7/mine_nonhan.py

285 lines
16 KiB
Python
Raw Permalink 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 (A3): майнинг кандидатов-терминов для НЕ-ханьских книг (en, ja).
Зачем отдельный скрипт. Боевой `internal/miner` строит кандидатов ТОЛЬКО из ханьских рун
(`miner_substrate.go:44-63`, `isMinerHan`), поэтому на английском он по построению отдаёт пустую
дельту, а на японском видит лишь кандзи — катакана, где у исэкая живёт большая часть жанровых
терминов, ему невидима. Этот скрипт — не замена движку, а СБОР ФАКТУРЫ ТРЕБОВАНИЙ к будущему
generic-майнеру (хвост №8 D39.37): какие каналы нужны, что каждый ловит, чем платим.
Три НЕЗАВИСИМЫХ канала (считаются и отчитываются раздельно — их согласие не подмешивается в один
балл, чтобы не собрать ложную сходимость):
ORTHO орфографическая улика языка: en — заглавная НЕ в начале предложения; ja — руны катаканы,
n-граммы кандзи, «одинокая короткая строка» (уплощённая рубя/эмфаза дампа syosetu).
DISP рассеяние: в скольких главах встречается (термин книги живёт по всей книге, реплика — нет).
CONTRAST над-представленность против ОПОРНОГО корпуса. en — опора берётся из ДРУГОЙ английской
книги стенда (кросс-книжный контраст: дешёвая и честная замена «общего корпуса»);
ja/кандзи — опоры НЕТ. Первая версия скрипта пробовала подставить сюда jieba-словарь
(общий zh) и получила вырожденное ранжирование: для слова, которого в опоре нет,
лапласова вероятность постоянна, поэтому «над-представленность» вырождается в
log2(частоты) и наверх всплывают 彼女/見/気 — обычная японская лексика. Канал кандзи
поэтому отдан БОЕВОМУ майнеру (он считает ожидание по независимости символов,
`miner_substrate.go:197 charIndepRel`, а это работает и на японском), а здесь остаётся
ИНВЕНТАРЁМ класса — частоты без ранга, чтобы видеть объём, а не выдавать порядок за
результат. Катаканы в zh-опоре нет вообще, у неё канала CONTRAST не существует.
$0: ни одного сетевого вызова, ни одной модели, ни одной внешней зависимости (только stdlib).
"""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
# --- алфавиты ----------------------------------------------------------------------------------
KATAKANA = re.compile(r"[ァ-ヺヽヾㇰ-ㇿ][ァ-ヺーヽヾㇰ-ㇿ・]*")
HAN_RUN = re.compile(r"[㐀-䶿一-鿿豈-﫿]+")
HIRAGANA = re.compile(r"[ぁ-ゟ]")
# Латиница с диакритикой: François, Ilsæ, Ashdrinker — обрезать по [A-Za-z] значит плодить огрызки
# («jean-fran»), что первый прогон и сделал.
EN_TOKEN = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿÆæŒœ][A-Za-zÀ-ÖØ-öø-ÿÆæŒœ'\-]*")
POSSESSIVE = re.compile(r"[']s$")
EN_INFIX = {"of", "the", "de", "du", "la", "le", "von", "van", "des", "di", "da", "and", "à", "san"}
# Английские местоимения и стяжения пишутся с заглавной по грамматике, а не потому, что это имя.
# Первый прогон без этого списка склеил «Miss Steele He», «Anastasia Im», «Kate I» — заглавное
# местоимение приклеилось к настоящему имени и породило фантомный многословный «термин».
EN_PRONOUN = {
"i", "he", "she", "it", "we", "they", "you", "him", "her", "his", "hers", "them", "their",
"me", "my", "mine", "our", "ours", "your", "yours", "its",
"i'm", "im", "i've", "ive", "i'd", "id", "i'll", "ill",
}
EN_STOP = {
"i", "a", "an", "and", "but", "or", "so", "then", "there", "this", "that", "these", "those",
"he", "she", "it", "we", "you", "they", "his", "her", "my", "your", "our", "their", "what",
"when", "where", "why", "how", "who", "if", "as", "at", "by", "for", "from", "in", "into",
"of", "on", "to", "with", "yes", "no", "oh", "ok", "okay", "well", "not", "do", "did", "does",
"is", "are", "was", "were", "be", "been", "am", "have", "has", "had", "will", "would", "can",
"could", "should", "shall", "may", "might", "must", "one", "two", "all", "some", "any",
"very", "just", "now", "here", "after", "before", "again", "still", "even", "only", "also",
"like", "up", "down", "out", "over", "under", "about", "because", "while", "though",
"although", "however", "maybe", "mr", "mrs", "ms", "dr", "sir", "madam", "s", "t", "est",
} | EN_INFIX | EN_PRONOUN
def load_chunks(path: Path) -> list[dict]:
rows = []
with path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def load_zh_contrast(path: Path) -> Counter:
"""jieba `word freq POS` → Counter. Тот же артефакт, что читает боевой майнер."""
c: Counter = Counter()
with path.open(encoding="utf-8", errors="replace") as f:
for line in f:
parts = line.split()
if len(parts) >= 2 and parts[1].isdigit():
c[parts[0]] = max(c[parts[0]], int(parts[1]))
return c
# --- канал ORTHO: английский --------------------------------------------------------------------
def en_candidates(rows: list[dict]) -> tuple[Counter, dict[str, set[int]], Counter, Counter]:
"""(cap_mid, главы, multiword-freq в том же счётчике, tok_total).
Улика — заглавная НЕ в начале предложения/строки. Строки, набранные ЦЕЛИКОМ прописными
(колонтитул, шмуцтитул, эпиграф капслоком), из улики исключены: там заглавная ничего не значит,
и первый прогон именно на них поймал «forever/dead/god» в верхушку списка.
"""
cap_mid: Counter = Counter()
tok_total: Counter = Counter()
chapters: dict[str, set[int]] = defaultdict(set)
multi: Counter = Counter()
multi_ch: dict[str, set[int]] = defaultdict(set)
for r in rows:
for raw_line in r["source"].split("\n"):
letters = [c for c in raw_line if c.isalpha()]
all_caps_line = bool(letters) and all(c.isupper() for c in letters)
for sent in re.split(r"(?<=[.!?])\s+", raw_line):
toks = [POSSESSIVE.sub("", m.group(0)) for m in EN_TOKEN.finditer(sent)]
toks = [t for t in toks if t]
if not toks:
continue
for i, t in enumerate(toks):
low = t.lower()
tok_total[low] += 1
if i > 0 and t[0].isupper() and not all_caps_line:
cap_mid[low] += 1
chapters[low].add(r["chapter"])
if all_caps_line:
continue
i = 0
while i < len(toks):
if i > 0 and toks[i][0].isupper() and toks[i].lower() not in EN_PRONOUN:
j, last_cap = i, i
while j + 1 < len(toks):
nxt = toks[j + 1]
if nxt[0].isupper() and nxt.lower() not in EN_PRONOUN:
j += 1
last_cap = j
elif (nxt.lower() in EN_INFIX and j + 2 <= len(toks) - 1
and toks[j + 2][0].isupper()):
j += 1
else:
break
if last_cap > i:
phrase = " ".join(toks[i:last_cap + 1])
multi[phrase] += 1
multi_ch[phrase].add(r["chapter"])
i = last_cap + 1
else:
i += 1
for p, c in multi.items():
cap_mid[p] = c
tok_total[p] = c
chapters[p] = multi_ch[p]
return cap_mid, chapters, cap_mid, tok_total
# --- канал ORTHO: японский ----------------------------------------------------------------------
def ja_candidates(rows: list[dict], ngram_max: int = 6):
freq: dict[str, Counter] = {"katakana": Counter(), "kanji": Counter(), "loneline": Counter()}
chaps: dict[str, dict[str, set[int]]] = {k: defaultdict(set) for k in freq}
for r in rows:
src = r["source"]
for m in KATAKANA.finditer(src):
w = m.group(0).strip("・ー")
if len(w) >= 2:
freq["katakana"][w] += 1
chaps["katakana"][w].add(r["chapter"])
for m in HAN_RUN.finditer(src):
run = m.group(0)
L = len(run)
for n in range(1, ngram_max + 1):
for i in range(0, L - n + 1):
g = run[i:i + n]
freq["kanji"][g] += 1
chaps["kanji"][g].add(r["chapter"])
for line in src.split("\n"):
s = line.strip()
if 1 <= len(s) <= 8 and not HIRAGANA.search(s) and HAN_RUN.fullmatch(s):
freq["loneline"][s] += 1
chaps["loneline"][s].add(r["chapter"])
return freq, chaps
# --- канал CONTRAST ------------------------------------------------------------------------------
def contrast_scores(book: Counter, ref: Counter) -> dict[str, float]:
"""log2 над-представленности с лапласовым сглаживанием; опора — частоты опорного корпуса."""
nb, nr = sum(book.values()) or 1, sum(ref.values()) or 1
denom = nr + len(ref) + 1
out = {}
for w, f in book.items():
pb = f / nb
pr = (ref.get(w, 0) + 1) / denom
out[w] = math.log2(pb / pr)
return out
def emit(rows_out: list[dict], out: Path, note: str) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as f:
for r in rows_out:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"{out}: {len(rows_out)} кандидатов [{note}]", file=sys.stderr)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="inp", required=True, type=Path)
ap.add_argument("--lang", required=True, choices=["en", "ja"])
ap.add_argument("--ref", type=Path, help="JSONL опорной книги (кросс-книжный контраст, en)")
ap.add_argument("--zh-contrast", type=Path, help="jieba dict как приблизительная опора для кандзи (ja)")
ap.add_argument("--min-freq", type=int, default=5)
ap.add_argument("--min-chapters", type=int, default=2)
ap.add_argument("--top", type=int, default=200, help="ТОП НА КАНАЛ, не суммарно")
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
rows = load_chunks(a.inp)
result: list[dict] = []
if a.lang == "en":
cap_mid, chapters, _, tok_total = en_candidates(rows)
ref_cap: Counter = Counter()
if a.ref:
ref_cap, _, _, _ = en_candidates(load_chunks(a.ref))
cs = contrast_scores(cap_mid, ref_cap) if ref_cap else {}
for w, f in cap_mid.items():
if f < a.min_freq or w in EN_STOP:
continue
nch = len(chapters[w])
capratio = f / max(tok_total[w], 1)
if nch < a.min_chapters and f < a.min_freq * 3:
continue
if capratio < 0.5:
continue
ch_list = ["ORTHO"]
if nch >= a.min_chapters:
ch_list.append("DISP")
if cs.get(w, 0) >= 3:
ch_list.append("CONTRAST")
result.append({
"src": w, "freq": f, "chapters": nch, "cap_ratio": round(capratio, 3),
"contrast_log2": round(cs[w], 2) if w in cs else None,
"channels": ch_list, "words": len(w.split()), "channel": "en-proper",
})
result.sort(key=lambda r: (-(r["contrast_log2"] or 0), -r["freq"], r["src"]))
result = result[: a.top] if a.top else result
else:
freq, chaps = ja_candidates(rows)
# Канал кандзи здесь СОЗНАТЕЛЬНО без контраста (см. шапку): ранг по нему не выдаём.
cs: dict[str, float] = {}
per_channel: dict[str, list[dict]] = {}
for channel in ("katakana", "kanji", "loneline"):
fl = a.min_freq if channel != "loneline" else 2
bucket = []
for w, f in freq[channel].items():
if f < fl:
continue
nch = len(chaps[channel][w])
if nch < a.min_chapters:
continue
ch_list = ["ORTHO:" + channel]
if nch >= a.min_chapters:
ch_list.append("DISP")
score = cs.get(w) if channel == "kanji" else None
if score is not None and score >= 3:
ch_list.append("CONTRAST")
bucket.append({
"src": w, "freq": f, "chapters": nch, "channel": channel, "len": len(w),
"contrast_log2": round(score, 2) if score is not None else None,
"channels": ch_list,
})
key = (lambda r: (-(r["contrast_log2"] or -99), -r["freq"], r["src"])) if channel == "kanji" \
else (lambda r: (-r["freq"], r["src"]))
bucket.sort(key=key)
per_channel[channel] = bucket[: a.top] if a.top else bucket
print(f" канал {channel}: всего {len(bucket)} прошло пороги, взято {len(per_channel[channel])}",
file=sys.stderr)
for channel in ("katakana", "kanji", "loneline"):
result.extend(per_channel[channel])
note = (f"lang={a.lang} chunks={len(rows)}"
+ (f" ref={a.ref.name}" if a.ref else "")
+ (f" zh-contrast={a.zh_contrast.name}" if a.zh_contrast else " zh-contrast=НЕТ"))
emit(result, a.out, note)
return 0
if __name__ == "__main__":
raise SystemExit(main())