miutti's picture
Source: kernel, desktop app, tools (snapshot of the GitHub repo)
df41178 verified
Raw History Blame Contribute Delete
10.1 kB
# -*- coding: utf-8 -*-
"""chokkan2 ── 直感役の作り直し(2026-09-22)。確率をまともにする三段: 事後確率 → 較正 → 保留。
なぜ作り直すか(Codex の設計 dougu/kekka/shinsa_sekkei2.md):
・いまの chokkan.py は softmax 回帰の生の値を自信として使っている。未知語・クラス頻度・証拠の量を
知らないので、「知らない文ほど自信満々」になり得る。線も 1/7 の校正用 1回で決めていた。
・ここでは 文字 n-gram の **Dirichlet-multinomial 単純ベイズ**(共役事前分布)にする。
未知の n-gram には事前分布が効くので、小さい教材でも確率が暴れない。
・較正は **交差検証の外(out-of-fold)** の予測で温度 T を決める(自分の学習点で較正しない)。
・保留は 2つ: 自信 < 線、または 証拠が薄い(見たことのある n-gram の割合が低い)。
標準ライブラリだけ。重みは JSON(chokkan2_omomi.json)。使い方は chokkan.py と同じ:
chokkan2.kimeru("5分はかって") → {"用件": "タイマー", "自信": 0.93, "証拠": 0.88, "上位": [...], "ms": 0}
python3 chokkan2.py --train … 学習して保存 --kuraberu … いまの chokkan と比べる
"""
from __future__ import annotations
import json, math, os, random, re, time, unicodedata
HERE = os.path.dirname(os.path.abspath(__file__))
OMOMI = os.path.join(HERE, "chokkan2_omomi.json")
ZATSUDAN = "雑談"
_Q = re.compile(r"[「『\"“](.*?)[」』\"”]")
_NUM = re.compile(r"[0-9]+(?:[.,][0-9]+)?")
NGRAM = (2, 3, 4, 5) # 文字 n-gram の長さ(1 は情報が薄いので入れない)
def _seiri(text: str) -> str:
s = unicodedata.normalize("NFKC", text or "").lower()
s = _Q.sub("「q」", s)
s = _NUM.sub("0", s)
return re.sub(r"\s+", "", s)
def tokuchou(text: str) -> dict:
"""文字 n-gram の **回数**(単純ベイズなので回数のまま。長さの効きは推論で割って消す)"""
t = "^" + _seiri(text) + "$"
f = {}
for n in NGRAM:
for i in range(len(t) - n + 1):
g = t[i:i + n]
f[g] = f.get(g, 0) + 1
return f
class Model:
"""Dirichlet-multinomial 単純ベイズ。alpha は事前分布の強さ、T は温度、sen は線、shouko は証拠の下限。"""
def __init__(self, classes=None, kazu=None, goukei=None, mae=None, V=0, alpha=0.2, T=1.0, sen=0.5, shouko=0.0, nagasa=0.5):
self.classes = list(classes or [])
self.kazu = kazu or {} # n-gram → [回数 × クラス数]
self.goukei = goukei or [] # クラスごとの総回数
self.mae = mae or [] # log 事前確率
self.V = V # 語彙の大きさ
self.alpha, self.T, self.sen, self.shouko, self.nagasa = alpha, T, sen, shouko, nagasa
def _logp(self, f: dict, alpha=None):
"""log p(文|用件)。長さで割ってから温度をかけるのは kakuritsu 側"""
al = self.alpha if alpha is None else alpha
C = len(self.classes)
z = [0.0] * C
shiru = 0
n = 0
for g, x in f.items():
row = self.kazu.get(g)
n += x
if row is not None:
shiru += x
for c in range(C):
cnt = row[c] if row is not None else 0
z[c] += x * math.log((cnt + al) / (self.goukei[c] + al * self.V))
return z, (shiru / n if n else 0.0), n
def kakuritsu(self, f: dict, T=None, alpha=None):
z, shouko, n = self._logp(f, alpha)
# 長さの効きをならす(長文ほど確率が 0/1 に張り付くのを防ぐ)。nagasa=1 で完全に平均、0 で生
w = 1.0 / max(1.0, n) ** self.nagasa
z = [self.mae[c] + z[c] * w for c in range(len(self.classes))]
T = self.T if T is None else T
m = max(z)
e = [math.exp((x - m) / T) for x in z]
s = sum(e)
return [x / s for x in e], shouko
# ── 学習 ──
def fit(self, rei: list):
self.classes = sorted({c for _, c in rei})
C = len(self.classes)
idx = {c: i for i, c in enumerate(self.classes)}
self.kazu = {}
self.goukei = [0] * C
kensuu = [0] * C
for t, c in rei:
y = idx[c]
kensuu[y] += 1
for g, x in tokuchou(t).items():
row = self.kazu.get(g)
if row is None:
row = self.kazu[g] = [0] * C
row[y] += x
self.goukei[y] += x
self.V = len(self.kazu)
# 事前確率: 件数そのままだと雑談(10倍ある)に寄るので √件数 でならす
w = [math.sqrt(k) for k in kensuu]
s = sum(w)
self.mae = [math.log(x / s) for x in w]
return self
def save(self, path=OMOMI):
tmp = path + ".tmp"
json.dump({"classes": self.classes, "kazu": self.kazu, "goukei": self.goukei, "mae": self.mae, "V": self.V,
"alpha": self.alpha, "T": self.T, "sen": self.sen, "shouko": self.shouko, "nagasa": self.nagasa,
"made": time.strftime("%Y-%m-%d %H:%M")}, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, path)
@classmethod
def load(cls, path=OMOMI):
d = json.load(open(path, encoding="utf-8"))
return cls(d["classes"], d["kazu"], d["goukei"], d["mae"], d["V"], d["alpha"], d["T"], d["sen"], d["shouko"], d.get("nagasa", 0.5))
_MODEL = None
def model() -> Model:
global _MODEL
if _MODEL is None:
_MODEL = Model.load()
return _MODEL
def kimeru(text: str, sen: float = None, shouko: float = None) -> dict:
"""頼み文 → {"用件": 名前 or None, "自信": p, "証拠": 0〜1, "上位": [(名, p)×3], "ms": ms}"""
t0 = time.monotonic()
m = model()
p, sho = m.kakuritsu(tokuchou(text))
o = sorted(range(len(p)), key=lambda i: -p[i])
na, pa = m.classes[o[0]], p[o[0]]
sen = m.sen if sen is None else sen
shouko = m.shouko if shouko is None else shouko
out = {"用件": None, "自信": round(pa, 3), "証拠": round(sho, 3),
"上位": [(m.classes[i], round(p[i], 3)) for i in o[:3]], "ms": int((time.monotonic() - t0) * 1000)}
if na != ZATSUDAN and pa >= sen and sho >= shouko:
out["用件"] = na
return out
# ── 学習の全体(交差検証で alpha・nagasa・温度・線を決める)──
def _oof(rei, K=5, alpha=0.2, nagasa=0.5, seed=0):
"""out-of-fold の (確率, 証拠, 正解) を返す。較正と線決めはこれだけを使う"""
rnd = random.Random(seed)
by = {}
for t, c in rei:
by.setdefault(c, []).append(t)
fold = {}
for c, ts in by.items():
ts = list(ts)
rnd.shuffle(ts)
for i, t in enumerate(ts):
fold[(t, c)] = i % K
out = []
for k in range(K):
gaku = [(t, c) for (t, c) in rei if fold[(t, c)] != k]
test = [(t, c) for (t, c) in rei if fold[(t, c)] == k]
m = Model(alpha=alpha, nagasa=nagasa).fit(gaku)
for t, c in test:
p, sho = m.kakuritsu(tokuchou(t), T=1.0)
out.append((p, sho, m.classes.index(c), m.classes))
return out
def gakushuu(nozoku=None, seed=0, quiet=False, K=5):
import chokkan as C1
rei = C1.kyouzai(nozoku, quiet=quiet)
best = None
for alpha in (0.05, 0.1, 0.2, 0.5, 1.0):
for nagasa in (0.3, 0.5, 0.7, 1.0):
oof = _oof(rei, K, alpha, nagasa, seed)
nll = -sum(math.log(max(p[y], 1e-12)) for p, _, y, _ in oof) / len(oof)
if best is None or nll < best[0]:
best = (nll, alpha, nagasa, oof)
nll, alpha, nagasa, oof = best
# 温度: out-of-fold の NLL 最小
def nll_at(T):
s = 0.0
for p, _, y, _ in oof:
z = [math.log(max(x, 1e-300)) / T for x in p]
mx = max(z)
e = [math.exp(x - mx) for x in z]
s -= math.log(max(e[y] / sum(e), 1e-12))
return s / len(oof)
T = min((0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0, 2.5, 3.0, 4.0), key=nll_at)
# 線と証拠の下限: 雑談の誤発動 0 のまま 道具を最も拾う(out-of-fold で)
def naosu(p, T):
z = [math.log(max(x, 1e-300)) / T for x in p]
mx = max(z)
e = [math.exp(x - mx) for x in z]
s = sum(e)
return [x / s for x in e]
bestsen = None
for sen in (0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95):
for sho_min in (0.0,): # 証拠の下限は 独立した物差し2 で 149問中 3問しか拾えず(移らない)→ 使わない(2026-09-22 実測)
ok = go = 0
for p, sho, y, cls in oof:
q = naosu(p, T)
i = max(range(len(q)), key=lambda j: q[j])
deru = cls[i] != ZATSUDAN and q[i] >= sen and sho >= sho_min
if cls[y] == ZATSUDAN:
go += deru
else:
ok += (deru and i == y)
# 誤発動を最小に、同じなら道具を多く拾う方(誤発動 0 が無い教材でも決まる)
if bestsen is None or (go, -ok) < (bestsen[3], -bestsen[2]):
bestsen = (sen, sho_min, ok, go)
m = Model(alpha=alpha, nagasa=nagasa).fit(rei)
m.T, m.sen, m.shouko = T, bestsen[0], bestsen[1]
m.save()
if not quiet:
print(" 教材 %d文・用件 %d・n-gram %d種 alpha=%.2f 長さ%.1f T=%.2f 線=%.2f 証拠≧%.1f (OOF NLL %.3f・OOF 道具 %d拾い・雑談の誤発動 %d)"
% (len(rei), len(m.classes), m.V, alpha, nagasa, T, m.sen, m.shouko, nll, bestsen[2], bestsen[3]))
global _MODEL
_MODEL = Model.load()
return _MODEL
if __name__ == "__main__":
import sys
if "--train" in sys.argv:
gakushuu()
else:
for t in sys.argv[1:] or ["ちょっと5分はかって", "今日は疲れた"]:
print(t, "→", kimeru(t))