285 lines
16 KiB
Python
285 lines
16 KiB
Python
#!/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 I’m», «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", "i’m", "i've", "i’ve", "i'd", "i’d", "i'll", "i’ll",
|
||
}
|
||
|
||
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())
|