SOKUDAN_SPEC.md §6 の設計と、その根拠。 数値はすべて本機での実測です(
docs/gate_a.md/docs/baseline_ja.md)。
v0.1 は joint encoding です。 2026-09-21 の A/B(§1.1)で separate を測って負けたため、 当初の設計判断を撤回しました。
[CLS] instructions [SEP] options+markers [SEP] state [SEP]
|
[backbone 25層] ... 質問ごとに1回(state も毎回入る)
|
marker positions を抜き出す
|
[scorer 768->1]
|
choice / bool -> 質問内 softmax
score -> 動的 K の cumulative link
decision head はありません。質問と state の相互作用は backbone の 25 層すべてが担います。
| モジュール | パラメータ数(実測) |
|---|---|
backbone (modernbert-ja-310m) |
314,611,968 |
| scorer | 768 |
| ordinal head | 1,538 |
| 合計 | 314,614,274 |
当初は state と question を別系列にし、cross-attention head で繋ぐ設計でした。
根拠は「modernbert-ja-310m は local_attention: 128、global_attn_every_n_layers: 3 なので、
位置 3000 の質問ブロックは 3 層に 2 層で state が見えない」というものです(§2 に残してあります)。
同一データ・同一配合・同一 epoch・同一 head LR で両方を学習した結果、逆でした。
| separate (s2b) | joint (s2c) | |
|---|---|---|
| val choice acc | 0.349 | 0.691 |
| val score RPS↓ | 0.204 | 0.185 |
| val bool acc | 0.526 | 0.930 |
| val bool AUROC | 0.529 | 0.984 |
| held-out AUROC(未知スキーマ × 未知文書) | 0.506 | 0.872 |
| held-out I 段(非明示の意図) | 0.486 | 0.786 |
| 定常 epoch 時間(2,808 steps) | 464 秒 | 330 秒 |
元の論証が外していた点: 実データの平均系列長は 160 トークン(state 134 + 質問 26)でした。 local attention の窓 128 とほぼ同じで、「位置 3000 の質問ブロック」という状況が一度も起きません。 懸念していた機構が、このデータ分布では発火しませんでした。
一方 separate は、質問と state の相互作用を head の 2 層の cross-attention だけに通します。 しかもその head は backbone からコピーした重みを RoPE なしで動かしています(§3 の既知の弱点)。 joint は同じ相互作用を 25 層ぶん使えます。差の大きさはこれで説明がつきます。
速度も joint が勝ちました(1.41 倍)。separate は backbone を 2 回(state 側・質問側)呼び、 さらに head を通します。joint は 1 回で済み head もありません。
separate の実装は sokudan/model/sokudan.py と --encoding separate に残してあります。
**「試したが、意図推論が未知スキーマに転移しなかった」**という測定結果として残すものです。
旧 §6.2 は、state を 1 回エンコードして全質問にブロードキャストすることを 構造的なレイテンシ上の利点として主張していました。joint ではこれは成立しません。
joint は質問ごとに state を再エンコードします。したがって N 問のリクエストは backbone を N 回通り、レイテンシは質問数に比例します。 1 問あたりのコストは separate より小さい(上表)ものの、N に対する定数倍は消えました。
質問側エンコードのキャッシュ(§5.2)も joint では効きません。 エンコードが state に依存するため、同じスキーマでもリクエストごとに別物になります。
v1 は state の後ろに全質問を連結し、ブロック対角マスクで分離する設計でした。
sbintuitions/modernbert-ja-310m の config.json を一次情報で確認した実値:
| キー | 実値 |
|---|---|
local_attention |
128 |
global_attn_every_n_layers |
3 |
num_hidden_layers |
25 |
hidden_size |
768 |
max_position_embeddings |
8192 |
25層のうち global attention は 3層に1層だけで、残りは 128 トークンの スライディングウィンドウしか見えません。したがって:
- 位置 3000 に置かれた質問ブロックは、local 層から state がまったく見えない
- 情報が 3層に1回しか流れないボトルネックになる
- 質問が系列のどこに落ちるかで挙動が変わるので、v1 が主張した 「質問順序に対する不変性」はそもそも成立しない
図にすると、v1 の質問ブロックが state を見られるのはこの層だけです:
層 0 local(128) 質問 ← state 見えない
層 1 local(128) 質問 ← state 見えない
層 2 GLOBAL 質問 ← state 見える ← 3層に1回だけ
層 3 local(128) 質問 ← state 見えない
...
この設計は実装しても静かに劣化するだけなので破棄しました。
state と question を別々の短い系列として流します。どちらも短いので、 local/global の交替が事前学習時と同じ条件で働き、マスク改造は一切不要です。
質問は互いに別の行に入っているので、そもそも見えません。
マスクで「見えないことにする」のではなく、構造として存在しない。
test_model.py::test_questions_do_not_see_each_other で、
4問まとめて流した結果と1問ずつ流した結果が一致することを確認しています。
| 部位 | 初期化 | 理由 |
|---|---|---|
| self-attn / MLP / norm | backbone の最後の2層をコピー | マーカーは <mask> トークン。ランダム初期化は、これから読もうとしている位置に乗っている MLM 事前分布を捨てることになる |
cross-attn 出力射影 Wo |
ゼロ | 学習開始時点で head は question 表現の恒等写像。state の情報は勾配に従って徐々に混ざる(adapter / ControlNet と同じ発想) |
scorer Linear(768→1) |
小分散 (std=0.002) | 初期の softmax をほぼ一様にする。ゼロにはしない——全選択肢が同一ロジット・同一勾配になり、選択肢が永久に分化しない |
ゼロ初期化が効いていることは実測で確認しています: 初期状態で state をまったく別のものに差し替えても、head の出力の差は 0.0(完全一致)。
backbone が既に RoPE を適用済みなので、H_q は head に届く時点で位置情報を持っています。
head は生の系列を再エンコードするのではなく、その表現を精製する層です。
ただしこれは、コピーした attention 重みが「学習時に隣にあった位置信号なし」で動くことを意味します。 したがってコピーは「正確な継続」ではなく初期化です。§6.2 が主張しているのもそこまでです。 後日のアブレーション候補として記録します。
docs/baseline_ja.md §6.2 で、laya-multilingual に同一 300 件・スキーマだけ変えた
5 条件を投げた結果:
| 条件 | 提示した選択肢 | 提示順の argmax 件数 |
|---|---|---|
| A 原文 | 急がない / 早めに / 業務が止まっている | [0, 167, 133] |
| B 逆順 | 業務が止まっている / 早めに / 急がない | [0, 50, 250] |
| C 言い換え | 低 / 中 / 高 | [1, 291, 8] |
| D 言い換えの逆順 | 高 / 中 / 低 | [1, 0, 299] |
| E 4段階 | 全く急がない / … / 業務が止まっている | [0, 78, 37, 185] |
5条件すべてで提示順の第1選択肢が 300件中 0〜1件。 同じ「急がない」が、先頭だと 0 件、末尾だと 250 件。 マーカーロジットに対する制約なしの softmax は、こういう死んだスロットを許してしまいます。
K はリクエスト時に決まるので、固定 K の CORAL は使えません。マーカーから直接読みます:
b_k = softplus(w · h_k) ≥ 0、レベルごと
a = v · h_pool 質問全体から1つ
P(y > k) = sigmoid(base_k + a - Σ_{j≤k} b_j)
p_k = P(y > k-1) - P(y > k)
b_k ≥ 0なので累積和は単調非減少 → CDF は構造的に単調。 罰則でも事後のソートでもなく、どんな重みでも成り立ちます (test_model.pyはnormal(0, 3)の重みで検証)P(y > K-1) = 0は定義として固定します。ここを学習させるとΣ p_k = 1 - P(y > K-1)が 1 に足りず、再正規化が必要になって この定式化が依存している telescoping が壊れます
初期分布を厳密に一様にするには、カットポイントが logit((K-1-k)/K) である必要があり、
これは K に依存します。K 非依存の重みだけでは作れません
(実際、最初の実装は K=2 で最大確率 0.85、K=7 で 0.74 になりました)。
そこで閾値を閉形式の一様ベース + 学習される単調な補正に分けました。 ベースは k について非増加なので単調性は保たれ、初期状態では補正がほぼ 0 なので どの K でも 1/K を 5e-3 以内で出します。 cross-attn のゼロ初期化と同じ発想——中立な関数から始めて、勾配で動かす。
最初の実装は cut.bias = -5.0 で、softplus(-5) = 0.0067 により初期分布を
厳密に一様(max|p - 1/K| が 5e-3 以内)にしていました。これは失敗でした。
softplus の微分は左裾で小さく、softplus'(-5) = 0.0067。
つまりこのパラメータに届く勾配が 約150倍減衰します。実測:
| パラメータ | 初期値 | 930ステップ後 |
|---|---|---|
cross_attn.Wo(隣で学習中) |
0.0 | 1.98 |
head.scorer |
0.055 | 0.118 |
ordinal.cut.bias |
-5.0 | -4.999(0.001 しか動かない) |
「学習が遅い」より悪い結果になります。 全ての b_k ≈ 0 だと閾値は base_k + a に
潰れ、スカラー a 1つの1パラメータ族になります。これは最下位と最上位の間で
確率質量をスライドできるだけで、中間レベルにピークを作れません。
3段階尺度で「自信を持ってレベル1」と言えない、ということです。
検証 RPS は学習前 0.2169 → 学習後 0.2153 で、実質的に動きませんでした (同じ学習で choice は 0.213→0.624、bool は 0.477→0.761)。
cut_bias = -1.0 に変更しました。 softplus(-1) = 0.313、勾配スケール 0.269 で
約40倍改善。代償は初期分布が「厳密に一様」から「ほぼ一様」になることです:
| K | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|
初期 max_p |
0.578 | 0.406 | 0.339 | 0.300 | 0.269 | 0.242 |
一様値 1/K |
0.500 | 0.333 | 0.250 | 0.200 | 0.167 | 0.143 |
厳密な一様性と学習可能な spacing はトレードオフの関係にあります。
§6.2 が求めているのは「初期から自信満々だと proper scoring rule の勾配が暴れる」ことの
回避であり、その意図は max_p ≤ 0.58 で満たされています。厳密性は満たされません。
cut_bias=-5.0 は今もオプションとして残してあり、
test_model.py::test_an_exactly_uniform_start_is_still_available_but_untrainable が
この両立不能性をテストとして固定しています(同じ発見を二度しないために)。
さらに、ordinal head は 1,538 パラメータで score の出力全体を制御するボトルネック
なので、独立した学習率グループ(ordinal_learning_rate、既定 1e-3)を与えています。
CE は「隣に外す」と「両端に外す」を同じ罰にします。順序尺度には不適です。
K-1 で正規化しているので、K の違う質問を混ぜたバッチでも比較可能です。
state: <s> {state} </s>
question: <s> {instructions} </s><s> {opt1}: {desc1} <mask> {opt2}: {desc2} <mask> ... </s>
question 側に state を含めません。 これが v1 との最大の違いです。
§5.2 は BERT 式に [CLS] … [SEP] と書いていますが、このバックボーンでは:
tokenizer.cls_token_id -> 6 (<cls>)
tokenizer.sep_token_id -> 4 (<sep>)
tokenizer("あ") -> [1, 1516, 2] ['<s>', 'あ', '</s>']
tokenizer("あ", "い") -> [1, …, 2, 1, …, 2] ペアは <s> … </s> を繰り返す
<cls> と <sep> は語彙にはありますが、post-processor は一度も出力しません。
つまりモデルはその形の系列を事前学習で見ていません。
そこにマーカーを置くと、これから読もうとしている <mask> 位置を
未知の文脈に置くことになります——§5.2 が「新しい special token を足すな」と
警告しているのと同じ失敗です。
そこでトークナイザに実際に何を付けるか聞いて(SpecialTokenLayout)、
その通りに使っています。本物の [CLS]/[SEP] テンプレートを持つ
別のバックボーンに差し替えても、そのまま正しく動きます。
tokenizer.mask_token_id から読みます。ハードコードしません。
新しい special token を足して埋め込みをリサイズすると、事前学習の事前分布を捨てることになります。
マーカー位置は id の連結によって構築され、レンダリング済み文字列を検索していません。
検索方式だと、選択肢の説明文にマスク文字列が含まれた瞬間に壊れます
(test_encoding.py にその罠のテストがあります)。
質問が長すぎる場合は例外を投げます。切り詰めると選択肢が黙って消えるためです。
質問側のエンコード結果は、スキーマとトークナイザだけで決まるので完全に再利用可能です。
キャッシュキーで sort_keys を使ってはいけません——criteria は順序付きで、
その順序がどのマーカーがどの選択肢かを決めます。ソートすると
{"a":…,"b":…} と {"b":…,"a":…} が同じキーになり、
学習時に意図的にシャッフルしたスキーマ(§7.2)が
マーカーの並びが違うエンコード結果を引いてしまいます。テストで固定しています。
attn_implementation="sdpa" を既定にしています。理由は §4.1 の想定(sm_120 の wheel がない)
ではなく、CUDA バージョンの不一致でした:
RuntimeError: The detected CUDA version (13.1) mismatches the version
that was used to compile PyTorch (12.8).
sdpa フォールバックの実測(ModernBERT-Ja-310M、bf16、batch 32):
| seq_len | sdpa ms/mean | eager ms/mean | sdpa は何倍速いか |
|---|---|---|---|
| 128 | 20.96 | 23.61 | 1.13x |
| 256 | 39.71 | 51.78 | 1.30x |
| 512 | 79.63 | 131.79 | 1.66x |
| 1024 | 173.95 | 378.76 | 2.18x |
unpadding 経路が使えないので、長さでバケット化した padding でバッチを組んでいます。
torch.compile は当日は無効です(§6.2: 可変長・可変選択肢数で再コンパイルが起きるため)。
詳細は docs/gate_a.md。
| 項目 | 状態 | 理由 |
|---|---|---|
| act / escalate ヘッド | 作らない | §6.4: 交差適合による学習信号を用意できないなら作らない。confidence 閾値で運用する |
| RLCD (Stage 3) | 後日 | §14.3。蒸留+温度較正で足りる可能性が高く、アブレーションごと後日 |
| head 2層 vs 4層のアブレーション | 後日 | §14.3。当日は 2層固定 |
| ONNX / TensorRT | 後日 | §14.3 |
| JGLUE データ | 不採用 | ライセンスの一次確認に時間を要するため、合成データのみに限定(§14.4-4) |
実測で裏が取れていないもの。やる前に主張しない。
- head の層数(2 vs 4)。cross-attention が head の数層に閉じ込められるリスク(§6.2)
- head 内 RoPE の有無。コピーした重みが位置信号なしで動いている点(§3)
- スキーマランダム化あり/なしの未知スキーマ汎化(§7.2、§14.4-3 で当日は切った)
ordinal_weight(CE 項と RPS 項のスケール差)- cross-attention を backbone 後半層にも差し込む案(§6.2 のリスク項)
- FA2 をビルドできた場合の速度差(
docs/gate_a.mdの TODO)