intel-mac-local-llm / source /kernel /cards_build.py
miutti's picture
Source: kernel, desktop app, tools (snapshot of the GitHub repo)
df41178 verified
Raw History Blame Contribute Delete
17.4 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""単語カード生成器 (cards_build.py)
設計思想: 掛け算(ニューラルネットの行列積)を一切使わない。
共起回数を「数えるだけ」で、言葉のブレを吸収する疎ベクトル(単語カード)を作る。
分布仮説に基づくカウントベース意味表現。GPU不要・学習不要。
- Python 3.14 標準ライブラリのみ (numpy 等の外部ライブラリは使わない)
- 形態素解析器は使わない。日本語は文字N-gram(2〜4文字)、英数字は単語単位
- メモリ16GBのIntel Macでも動くよう、逐次読み込み + 定期的な枝刈りで抑える
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
import unicodedata
from collections import Counter, defaultdict
# ----------------------------------------------------------------------
# 設定値
# ----------------------------------------------------------------------
# 対象とする拡張子
TEXT_EXTS = (".txt", ".md")
# 日本語N-gramの長さ範囲
NGRAM_MIN = 2
NGRAM_MAX = 4
# ペア表が肥大化したときに枝刈りを始める閾値(メモリ保護)
MAX_PAIRS = 6_000_000
# 1行あたりに切り出すトークン数の上限(異常に長い行への保険)
MAX_SPANS_PER_LINE = 200_000
# ----------------------------------------------------------------------
# トークナイズ
# ----------------------------------------------------------------------
def _is_word_char(ch: str) -> bool:
"""英数字(ASCII語を構成する文字)かどうか。"""
return ch.isascii() and (ch.isalnum() or ch == "_")
def _is_hiragana(ch: str) -> bool:
"""ひらがなかどうか(1文字語として採らないため)。"""
return "ぁ" <= ch <= "ゟ"
def _is_jp_char(ch: str) -> bool:
"""日本語として N-gram 対象にする文字かどうか。
ひらがな・カタカナ・漢字・全角英数などの「文字」を対象にし、
句読点や記号・空白は区切りとして扱う。
"""
if ch.isspace():
return False
if ch.isascii():
return False
cat = unicodedata.category(ch)
# L*(文字) と Nd(数字) のみ通す。P*(記号) M* S* は区切り扱い。
return cat.startswith("L") or cat == "Nd"
def spans(text: str):
"""テキストから (開始位置, 終了位置, 語) のリストを作る。
- 英数字の連続 → 1語(小文字化)
- 日本語文字の連続 → その中の 2〜4 文字のN-gramを全位置で切り出す
位置情報を持たせるのは、共起を「文字距離」で測り、かつ
重なり合うN-gram同士を共起としてカウントしないためである。
"""
out = []
n = len(text)
i = 0
while i < n:
ch = text[i]
if _is_word_char(ch):
j = i
while j < n and _is_word_char(text[j]):
j += 1
out.append((i, j, text[i:j].lower()))
i = j
elif _is_jp_char(ch):
j = i
while j < n and _is_jp_char(text[j]):
j += 1
seg = text[i:j]
seg_len = j - i
for k in range(seg_len):
# 1文字語: ひらがな1文字は助詞などノイズになるので除外し、
# 漢字・カタカナ・全角英数の1文字だけ語として認める
# (「猫」「犬」「石」「川」など1文字語を拾うために必要)
if not _is_hiragana(seg[k]):
out.append((i + k, i + k + 1, seg[k]))
for L in range(NGRAM_MIN, NGRAM_MAX + 1):
if k + L > seg_len:
break
out.append((i + k, i + k + L, seg[k:k + L]))
i = j
else:
i += 1
if len(out) > MAX_SPANS_PER_LINE:
break
return out
# ----------------------------------------------------------------------
# 入力ファイルの収集
# ----------------------------------------------------------------------
def iter_files(paths):
"""パス(ファイル or ディレクトリ)のリストから対象ファイルを再帰的に列挙。"""
seen = set()
for p in paths:
p = os.path.abspath(p)
if os.path.isdir(p):
for root, dirs, files in os.walk(p):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for f in sorted(files):
if f.lower().endswith(TEXT_EXTS):
fp = os.path.join(root, f)
if fp not in seen:
seen.add(fp)
yield fp
elif os.path.isfile(p):
if p not in seen:
seen.add(p)
yield p
def iter_lines(paths):
"""全対象ファイルの行を逐次返す(メモリに全文を載せない)。"""
for fp in iter_files(paths):
try:
with open(fp, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if line:
yield line
except OSError as e:
print(f"[warn] 読めません: {fp}: {e}", file=sys.stderr)
# ----------------------------------------------------------------------
# 本体: 共起カウント → PPMI → 上位top_k
# ----------------------------------------------------------------------
def build(corpus_paths, out_path="cards.json", window=4, min_count=3, top_k=150) -> dict:
"""コーパスから単語カード(PPMI疎ベクトル)を作り JSON に保存する。
corpus_paths : テキストファイル or ディレクトリのリスト(.txt .md を再帰探索)
out_path : 保存先 JSON
window : 共起とみなす文字距離(語のスパン間の隙間がこの文字数以内)
min_count : この回数未満しか出ない語は捨てる
top_k : 1語あたり残す共起相手の数(疎ベクトルの次元)
戻り値は保存した辞書そのもの。
"""
t0 = time.time()
# --- パス1: 語の出現回数を数える ------------------------------------
uni = Counter()
n_lines = 0
n_chars = 0
for line in iter_lines(corpus_paths):
n_lines += 1
n_chars += len(line)
for _, _, w in spans(line):
uni[w] += 1
# min_count 未満を捨てる(ここで語彙がぐっと減る)
vocab = {w: c for w, c in uni.items() if c >= min_count}
del uni
if not vocab:
raise ValueError("語彙が空です。コーパスが小さすぎるか min_count が大きすぎます。")
# --- パス2: 共起回数を数える ----------------------------------------
# pair[(a, b)] = 回数 (a < b の順に正規化して1回だけ持つ)
pair = Counter()
ctx_total = Counter() # 共起カウント上の各語の周辺度数
total_pairs = 0
for line in iter_lines(corpus_paths):
sp = [(s, e, w) for (s, e, w) in spans(line) if w in vocab]
m = len(sp)
for i in range(m):
si, ei, wi = sp[i]
for j in range(i + 1, m):
sj, ej, wj = sp[j]
# 位置は開始位置の昇順ではないので、隙間は絶対値で見る
if sj - ei > window:
# 開始位置が単調非減少なので、これ以降はさらに遠い
if sj - ei > window + NGRAM_MAX:
break
continue
# スパンが1文字でも重なるN-gram同士は共起にしない
if sj < ei and ej > si:
continue
if wi == wj:
continue
key = (wi, wj) if wi < wj else (wj, wi)
pair[key] += 1
ctx_total[wi] += 1
ctx_total[wj] += 1
total_pairs += 1
# メモリ保護: 大きくなりすぎたら回数1のペアを捨てる
if len(pair) > MAX_PAIRS:
pair = Counter({k: v for k, v in pair.items() if v > 1})
if total_pairs == 0:
raise ValueError("共起が1件も取れませんでした。window を広げてください。")
# --- PPMI 化 ---------------------------------------------------------
# PMI(a,b) = log2( P(a,b) / (P(a) * P(b)) )
# = log2( c(a,b) * N / (c(a) * c(b)) ) ※ N は共起総数(片側基準)
# 負の値は 0 に切る(PPMI)。
N = float(total_pairs * 2) # ctx_total は1ペアにつき両側を数えているため
vecs_raw = defaultdict(list)
for (a, b), c in pair.items():
if c < min_count and c < 2:
continue
ca = ctx_total.get(a, 0)
cb = ctx_total.get(b, 0)
if ca == 0 or cb == 0:
continue
# 掛け算は使うが行列積ではない。ここは素直に確率比を対数で取る。
pmi = math.log2((c * N) / (ca * cb))
if pmi <= 0.0:
continue
vecs_raw[a].append((b, pmi))
vecs_raw[b].append((a, pmi))
del pair
# --- 上位 top_k だけ残す --------------------------------------------
vecs = {}
for w, items in vecs_raw.items():
items.sort(key=lambda kv: kv[1], reverse=True)
vecs[w] = {k: round(v, 4) for k, v in items[:top_k]}
elapsed = time.time() - t0
data = {
"meta": {
"version": 1,
"window": window,
"min_count": min_count,
"top_k": top_k,
"ngram": [NGRAM_MIN, NGRAM_MAX],
"n_lines": n_lines,
"n_chars": n_chars,
"vocab_size": len(vecs),
"total_pairs": total_pairs,
"build_seconds": round(elapsed, 3),
},
"vecs": vecs,
}
if out_path:
tmp = out_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False)
os.replace(tmp, out_path)
return data
# ----------------------------------------------------------------------
# 使う側
# ----------------------------------------------------------------------
def _bigrams(s: str) -> set:
"""文字bigram集合(未知語のフォールバック照合用)。"""
if not s:
return set()
# 前後に境界記号を付ける。こうしないと1文字語(「猫」)の bigram 集合が
# 「子猫」などと一切重ならず、フォールバックが機能しない。
t = "" + s + ""
return {t[i:i + 2] for i in range(len(t) - 1)}
class Cards:
"""生成済みカードを読み込んで、語の近さを測るためのクラス。"""
def __init__(self, vecs: dict, meta: dict | None = None):
self.vecs = vecs
self.meta = meta or {}
# 疎ベクトルのノルムを前計算(コサイン類似度の分母)
self._norm = {w: math.sqrt(sum(v * v for v in d.values())) or 1.0
for w, d in vecs.items()}
# 未知語フォールバック用の bigram インデックス
self._bi_index = None
# -- 読み込み --------------------------------------------------------
@classmethod
def load(cls, path) -> "Cards":
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return cls(data.get("vecs", {}), data.get("meta", {}))
# -- 未知語の解決 ----------------------------------------------------
def _build_bi_index(self):
idx = defaultdict(list)
for w in self.vecs:
for bg in _bigrams(w):
idx[bg].append(w)
self._bi_index = idx
def resolve(self, word: str) -> str | None:
"""語彙に無い語を、文字bigramのJaccard係数が最大の既知語に寄せる。"""
if word in self.vecs:
return word
if not word:
return None
if self._bi_index is None:
self._build_bi_index()
qb = _bigrams(word)
# 候補を bigram インデックスで絞る(全語走査を避ける)
cand = Counter()
for bg in qb:
for w in self._bi_index.get(bg, ()):
cand[w] += 1
best, best_s = None, 0.0
for w, common in cand.items():
wb = _bigrams(w)
union = len(qb) + len(wb) - common
if union <= 0:
continue
s = common / union
if s > best_s:
best, best_s = w, s
return best
# -- 類似度 ----------------------------------------------------------
def _vec(self, word: str):
w = self.resolve(word)
if w is None:
return None, None
return self.vecs[w], self._norm[w]
def similarity(self, a: str, b: str) -> float:
"""疎ベクトルのコサイン類似度。共通キーだけを足し合わせる。"""
va, na = self._vec(a)
vb, nb = self._vec(b)
if va is None or vb is None:
return 0.0
# 小さい方を走査して共通キーだけ計算する
if len(va) > len(vb):
va, vb = vb, va
dot = 0.0
get = vb.get
for k, x in va.items():
y = get(k)
if y is not None:
dot += x * y
if dot <= 0.0:
return 0.0
return dot / (na * nb)
def nearest(self, word: str, candidates: list, threshold: float = 0.15):
"""候補リストの中から最も意味の近いものを返す。(候補, スコア)。"""
best, best_s = None, 0.0
for c in candidates:
s = self.similarity(word, c)
if s > best_s:
best, best_s = c, s
if best is None or best_s < threshold:
return (None, 0.0)
return (best, best_s)
def neighbors(self, word: str, n: int = 10) -> list:
"""意味の近い語トップn(確認・デバッグ用)。[(語, スコア), ...]"""
w = self.resolve(word)
if w is None:
return []
vw = self.vecs[w]
nw = self._norm[w]
# 共通キーを持ちうる語だけを転置的に集める(全語走査を避ける)
cands = set()
for k in vw:
kv = self.vecs.get(k)
if kv:
cands.update(kv.keys())
cands.discard(w)
out = []
for c in cands:
vc = self.vecs.get(c)
if not vc:
continue
small, large = (vw, vc) if len(vw) <= len(vc) else (vc, vw)
dot = 0.0
get = large.get
for k, x in small.items():
y = get(k)
if y is not None:
dot += x * y
if dot > 0.0:
out.append((c, dot / (nw * self._norm[c])))
out.sort(key=lambda kv: kv[1], reverse=True)
return out[:n]
# ----------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="共起カウントだけで単語カードを作る")
sub = ap.add_subparsers(dest="cmd", required=True)
p_build = sub.add_parser("build", help="コーパスからカードを生成")
p_build.add_argument("paths", nargs="+", help="テキストファイル/ディレクトリ")
p_build.add_argument("--out", default="cards.json")
p_build.add_argument("--window", type=int, default=4)
p_build.add_argument("--min-count", type=int, default=3)
p_build.add_argument("--top-k", type=int, default=150)
p_near = sub.add_parser("near", help="意味の近い語を表示")
p_near.add_argument("word")
p_near.add_argument("--cards", default="cards.json")
p_near.add_argument("-n", type=int, default=10)
p_sim = sub.add_parser("sim", help="2語の類似度")
p_sim.add_argument("a")
p_sim.add_argument("b")
p_sim.add_argument("--cards", default="cards.json")
args = ap.parse_args(argv)
if args.cmd == "build":
data = build(args.paths, out_path=args.out, window=args.window,
min_count=args.min_count, top_k=args.top_k)
m = data["meta"]
size = os.path.getsize(args.out) if os.path.exists(args.out) else 0
print(f"語彙数 {m['vocab_size']} / 共起 {m['total_pairs']} / "
f"{m['build_seconds']}秒 / {size:,} バイト -> {args.out}")
return 0
if args.cmd == "near":
cards = Cards.load(args.cards)
w = cards.resolve(args.word)
if w is None:
print("語彙にありません")
return 1
if w != args.word:
print(f"(未知語のため『{w}』として扱います)")
for word, score in cards.neighbors(w, args.n):
print(f"{score:.4f}\t{word}")
return 0
if args.cmd == "sim":
cards = Cards.load(args.cards)
print(f"{cards.similarity(args.a, args.b):.4f}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())