Skip to content

Latest commit

 

History

History
347 lines (251 loc) · 17.8 KB

File metadata and controls

347 lines (251 loc) · 17.8 KB

アーキテクチャ

SOKUDAN_SPEC.md §6 の設計と、その根拠。 数値はすべて本機での実測です(docs/gate_a.md / docs/baseline_ja.md)。


1. 全体像

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

1.1 撤回した設計判断 — separate encoding(旧 §6.1 / §6.2)

当初は 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 に残してあります。 **「試したが、意図推論が未知スキーマに転移しなかった」**という測定結果として残すものです。

1.2 撤回した主張 — 「state は 1 リクエストにつき 1 回だけエンコード」

旧 §6.2 は、state を 1 回エンコードして全質問にブロードキャストすることを 構造的なレイテンシ上の利点として主張していました。joint ではこれは成立しません。

joint は質問ごとに state を再エンコードします。したがって N 問のリクエストは backbone を N 回通り、レイテンシは質問数に比例します。 1 問あたりのコストは separate より小さい(上表)ものの、N に対する定数倍は消えました。

質問側エンコードのキャッシュ(§5.2)も joint では効きません。 エンコードが state に依存するため、同じスキーマでもリクエストごとに別物になります。


2. なぜ v1 の「質問パッキング」を捨てたか(当時の論証。§1.1 で部分的に撤回)

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 見えない
...

この設計は実装しても静かに劣化するだけなので破棄しました。


3. separate encoding の設計(v0.1 では不採用。§1.1)

state と question を別々の短い系列として流します。どちらも短いので、 local/global の交替が事前学習時と同じ条件で働き、マスク改造は一切不要です。

順序不変性は構造として保証される

質問は互いに別の行に入っているので、そもそも見えません。 マスクで「見えないことにする」のではなく、構造として存在しない。 test_model.py::test_questions_do_not_see_each_other で、 4問まとめて流した結果と1問ずつ流した結果が一致することを確認しています。

head の初期化(§6.2)

部位 初期化 理由
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(完全一致)。

RoPE を head に入れていない

backbone が既に RoPE を適用済みなので、H_q は head に届く時点で位置情報を持っています。 head は生の系列を再エンコードするのではなく、その表現を精製する層です。

ただしこれは、コピーした attention 重みが「学習時に隣にあった位置信号なし」で動くことを意味します。 したがってコピーは「正確な継続」ではなく初期化です。§6.2 が主張しているのもそこまでです。 後日のアブレーション候補として記録します。


4. score を多クラス softmax にしない理由と、動的 K の定式化

実測された問題

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 が壊れます

base_k — K 非依存の重みで「どの K でも初期一様」を作る

初期分布を厳密に一様にするには、カットポイントが 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)を与えています。

損失は RPS

CE は「隣に外す」と「両端に外す」を同じ罰にします。順序尺度には不適です。 K-1 で正規化しているので、K の違う質問を混ぜたバッチでも比較可能です。


5. encoding(§5.2)

state:    <s> {state} </s>
question: <s> {instructions} </s><s> {opt1}: {desc1} <mask> {opt2}: {desc2} <mask> ... </s>

question 側に state を含めません。 これが v1 との最大の違いです。

[CLS] / [SEP] は役割名であって、トークン名ではない

§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] テンプレートを持つ 別のバックボーンに差し替えても、そのまま正しく動きます。

マーカーは事前学習の <mask> を流用

tokenizer.mask_token_id から読みます。ハードコードしません。 新しい special token を足して埋め込みをリサイズすると、事前学習の事前分布を捨てることになります。

マーカー位置は「返す」もので、再計算しない

マーカー位置は id の連結によって構築され、レンダリング済み文字列を検索していません。 検索方式だと、選択肢の説明文にマスク文字列が含まれた瞬間に壊れます (test_encoding.py にその罠のテストがあります)。

質問が長すぎる場合は例外を投げます。切り詰めると選択肢が黙って消えるためです。

キャッシュ

質問側のエンコード結果は、スキーマとトークナイザだけで決まるので完全に再利用可能です。 キャッシュキーで sort_keys を使ってはいけません——criteria は順序付きで、 その順序がどのマーカーがどの選択肢かを決めます。ソートすると {"a":…,"b":…} と {"b":…,"a":…} が同じキーになり、 学習時に意図的にシャッフルしたスキーマ(§7.2)が マーカーの並びが違うエンコード結果を引いてしまいます。テストで固定しています。


6. Gate A: FlashAttention-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。


7. 作らなかったもの

項目 状態 理由
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)

8. 後日のアブレーション候補

実測で裏が取れていないもの。やる前に主張しない。

  1. head の層数(2 vs 4)。cross-attention が head の数層に閉じ込められるリスク(§6.2)
  2. head 内 RoPE の有無。コピーした重みが位置信号なしで動いている点(§3)
  3. スキーマランダム化あり/なしの未知スキーマ汎化(§7.2、§14.4-3 で当日は切った)
  4. ordinal_weight(CE 項と RPS 項のスケール差)
  5. cross-attention を backbone 後半層にも差し込む案(§6.2 のリスク項)
  6. FA2 をビルドできた場合の速度差(docs/gate_a.md の TODO)