TL;DR
- 前回、Jev 系 OSS の Laya を日本語 300 件で測ったら順序尺度と真偽判定が多数決以下だった。なので日本語版を作った
- sokudan-ja-310m v0.1 を公開した。同じ 300 件で、3 つのプリミティブ全部で Laya を上回り、真偽判定は多数決も超えた(3 シード平均、未知スキーマ評価)
- 初日は真偽判定が「文面を読まずに事前分布を吐く」モデルになって公開を見送った
- 2 日目、データを設計し直しても直らなかった。原因は私が書いた設計だった。 Claude Code が A/B を回して、私の cross-attention 設計を Laya と同じ joint encoding が完敗させた。設計は撤回した
- Laya の「選択肢の先頭を絶対に選ばない」位置バイアスは、sokudan には出ていない
- モデル、コード、評価セット、学習データの作り方、負けた設計の記録、全部公開
モデル: https://huggingface.co/GeneLab/sokudan-ja-310m
コード: https://github.com/hiroki-abe-58/sokudan
前回の記事: https://qiita.com/GeneLab_999/items/a504c7559d23361b4bac
何を作ったか
TypeSafe 社の Jev と同じ「System One モデル」の日本語版。テキストを生成せず、判断対象のテキスト(state)と型付きの質問を受け取って、単一のフォワードパスで型付きの回答と確率を返す。
| 質問タイプ | 何を聞くか | 返るもの |
|---|---|---|
| choice | 選択肢から 1 つ選ぶ | 選択、各選択肢の確率、confidence |
| score | 順序尺度で採点 | スコア、分布、confidence |
| bool(noul) | この文は真か | P(true) |
- バックボーン:
sbintuitions/modernbert-ja-310m(MIT)、フルファインチューン - 選択肢ごとに
<mask>トークンを置き、その位置の隠れ状態をスコアリングして質問内で softmax。回答空間はリクエスト時に決まるので、新しいスキーマに再学習は要らない - score は多クラス softmax ではなく、選択肢数が動的でも CDF の単調性が構造的に保証される cumulative link(累積 softplus)で出す
- 学習データは全部ゴールドラベル付きの合成データ。Laya の出力も Jev の出力も使っていない
- RTX 5090 で 1 シードの学習が 661 秒。推論は 1 問 p50 11.9ms、229 問/秒
結果(bench_ja 300 件、未知スキーマ、3 シード平均 ± SD)
| choice acc | score RPS ↓ | score acc | bool acc | bool AUROC | |
|---|---|---|---|---|---|
| sokudan-ja-310m | 0.847 ± 0.009 | 0.090 ± 0.023 | 0.763 ± 0.088 | 0.788 ± 0.010 | 0.789 ± 0.043 |
| laya-multilingual | 0.747 | 0.232 | 0.443 | 0.543 | 0.523 |
| 多数決 | 0.380 | 0.197 | 0.460 | 0.703 | — |
| ランダム | 0.253 | 0.201 | 0.403 | 0.513 | — |
bench_ja のスキーマ(部署ルーティング / 緊急度 / 解約示唆)は学習データに一切含めていない。「解約を示唆しているか」という質問は、モデルにとって初見。
seed 間の振れ幅は正直に書く。 score acc は seed ごとに 0.663 / 0.800 / 0.827 で、平均 0.763 はどのシードの実測値でもない。HF で配布している重みは seed 0 で、その score acc は 0.663(3 本中最低)、bool AUROC は 0.837(3 本中最高)。seed 0 は 3 本を学習する前から配布用として決めてあったもので、結果を見て選んでいない。seed 1 / 2 も同じリポジトリの revision として置いてある。
Laya の位置バイアスは出ていない
前回、Laya の score は「提示された選択肢の先頭を 300 件中 0〜1 件しか選ばない」ことが 5 条件で分かった。同じ 5 条件を sokudan で回した。
| 条件 | 提示した選択肢 | Laya 第 1 選択肢 | sokudan 第 1 選択肢(3 シード平均) | sokudan acc |
|---|---|---|---|---|
| A 原文 | 急がない / 早めに / 業務が止まっている | 0 / 300 | 92.0 ± 12.5 | 0.761 |
| B 逆順 | 業務が止まっている / 早めに / 急がない | 0 / 300 | 81.3 ± 19.7 | 0.788 |
| C 言い換え | 低 / 中 / 高 | 1 / 300 | 97.7 ± 19.1 | 0.697 |
| D 言い換えの逆順 | 高 / 中 / 低 | 1 / 300 | 91.7 ± 12.4 | 0.733 |
| E 4 段階 | 全く急がない / 急がない / 早めに / 業務が止まっている | 0 / 300 | 45.3 ± 25.5 | 0.427 |
全条件で第 1 選択肢を通常の頻度で選んでいる。ただし E(4 段階)で精度が 0.427 に落ちるのは残った弱点で、Limits に書いた。
追記: この位置バイアスは日本語固有ではなかった。 公開後に英語の評価セット(290 件、同じ 3 スキーマ)で同じ 5 条件を回したところ、laya-multilingual は英語でも 5 条件すべてで第 1 選択肢を 0 / 290 しか選ばず、bool AUROC 0.355(0.5 未満 = 順位が反転)、score RPS 0.340(ランダム 0.197 より悪い)だった。英語は laya-multilingual の学習言語なので「読めていない」では説明がつかない。一方、英語版の laya は英語で第 1 選択肢を 22〜26% 選び、正常に動く。つまり laya-multilingual というチェックポイントが壊れている。データと再現手順は docs/baseline_en.md。
2 日間で何が起きたか
ここからが本題。結果より、途中の失敗と、それをどう見つけたかの方が価値がある。
Day 1: 文面を読まないモデルができた
初日の bool は AUROC 0.513。多数決以下。診断で **「state を空にすると bool の精度が上がる」**という結果が出た。文面を読まない方がマシ、つまり質問文だけ見て事前分布を吐いている。
原因は学習データ。bool の質問が「この文書はカテゴリ X に該当するか」「緊急度は k 以上か」のような、choice や score から機械的に派生させたものばかりで、意図や態度を読み取る種類の質問が一つもなかった。「解約を示唆しているか」は初見の技能で、持っていない技能は転移しない。
Day 2 午前: 「文面を読め」を教えるデータ
ラベル条件付き生成のプロンプトに、文書ごとに潜在属性を仕込んだ。「不満を表明している」「期限に言及している」「返金を求めている」「断るつもりをにおわせる」——読まないと分からない属性を 24 個。生成条件がそのままゴールドになる。
Claude Code はこれを 3 段に分けた。表層(S: 数字を含む、添付に言及)、明示的態度(E: 不満を表明、感謝を述べる)、非明示の意図(I: 我慢の限界をにおわせる、断るつもりをにおわせる)。「表層特徴の検出を覚えるだけでは、昨日の失敗を一段上で繰り返す」という理由で、I 段を 8 個置いた。私の指示より良い設計だった。
生成の途中で、Claude Code が検算で見つけたものが 2 つある。
- I 段の属性は互いに含意する。 「返事の遅さを遠回しに責める」文書は、false に振ったはずの「落胆」「我慢の限界」も含意してしまう。独立に 50/50 で振ると嘘のラベルができる。1 文書 1 属性に制限した。学習ペアは 15,000 から 4,250 に減った
- 検算で不一致を捨てると、難しい事例だけが消える。 破棄は false 側に偏るので、残ったデータは「常にはい」を教える。属性ごとに 40〜60% へ再バランスした
それと、前日の leak チェックの穴も見つかった。「解約」「解除」という語が別スキーマの選択肢の説明として学習データの質問文に 718 行 / 868 行入っていた。bool 質問そのものは未出現なので未知スキーマの主張は保てるが、「語彙が未見」ではなかった。前回の記事に追記した。面白いのは、その語彙があっても bool は転移しなかったこと。語彙じゃなくて技能の問題だ、という仮説を逆側から支持している。
Day 2 午後: それでも全滅、そして設計を疑う
データを作り直して学習したら、held-out(未知スキーマ × 未知文書)の bool AUROC は 0.509。全部チャンス。しかも choice も score も初日より悪化した。
ここで Claude Code が出した切り分けが決定的だった。学習した属性を未知文書に当てても、読めているのは 14 属性中 4 つだけ。しかも上位 2 つは「数字を含む」「期限に言及」——語彙で解けるもの。態度や意図の属性は学習済みでも 0.5。つまり未知スキーマに転移しない以前に、学習したはずの意図推論をそもそも獲得できていない。
これは設計の匂いがした。私の設計は、質問と state を別々にエンコードして、2 層の cross-attention head で合流させるものだった。「state を 1 回だけエンコードして全質問にブロードキャストできるので、質問数が増えても速い」が売りで、Jev の公式ドキュメントが書いている性質に構造的に寄せたつもりだった。
だがこの設計では、「不満を表明しているか」という質問に条件付けられた読みは head の 2 層だけでやることになる。Laya のように質問と state を 1 系列に入れれば、backbone の全層が質問条件付きで state を読む。数字や期限は語彙的手がかりなので head でも拾える。含みや態度は合成が要る。
A/B を回した。同じデータ、同じ配合、同じ epoch、同じ学習率。違いはエンコーディングだけ。
| cross-attention(私の設計) | joint(Laya と同じ配置) | |
|---|---|---|
| held-out bool AUROC(未知スキーマ) | 0.506 | 0.872 |
| うち I 段(非明示の意図) | 0.486 | 0.786 |
| うち E 段(明示的態度) | — | 0.995 |
| 学習済み属性 × 未知文書 | 14 属性中 4 つが 0.7 超 | 14 属性すべて 0.96〜1.00 |
| epoch あたりの学習時間 | 464 秒 | 330 秒 |
完敗。しかも速い。
リークでないことは state 差し替えで確認した。joint で state を空にすると 0.872 → 0.485、シャッフルすると 0.504。全部チャンスに落ちる。モデルは文面を読んでいる。 初日と正反対。
「ModernBERT は 3 層に 1 層しか global attention がなく、local の窓は 128 トークンなので、遠くに置いた質問ブロックは state を見られない。だから joint は不利」——これが私の元の論証だった。理屈は合っている。ただし実データの平均系列長は 160 トークンで、私が想定した「位置 3000 の質問ブロック」は一度も生じなかった。前提が違った。
joint で唯一落ちた属性は「文末が問いかけで終わるか」(0.688)。これは位置の問題で、local attention の窓に刺さる。私の懸念は、位置依存の質問と長い state では今も生きている。それは Limits に書いた。
設計は撤回した。「state のエンコードは質問数によらず 1 回」というレイテンシの主張も取り下げた。v0.1 は joint で出す。
Limits(正直に)
- bench_ja では true を過少予測する。 平均 P(true) が 0.125、gold の正例率は 0.297。AUROC 0.789 で順位付けは機能するが、閾値 0.5 は低すぎる。held-out val ではこの偏りは出ていないので、bench_ja の「解約を示唆」という質問に固有の可能性が高い。利用者の事前確率に合わせて閾値を置くこと。温度スケーリングでは補正されない(シフトしない)
-
較正は score を悪化させる。 温度較正で choice ECE 0.147 → 0.092、bool ECE 0.202 → 0.129 と改善するが、score RPS は 0.090 → 0.149 に悪化する。合成 val でフィットした温度が bench_ja の score 分布に合っていない。既定は未較正で、
temperatures.jsonを同梱。自前の val で再フィットすることを推奨 -
4 段階の条件で落ちる。 位置バイアス表の E 条件で acc 0.427。ただし held-out val を K 別に分解すると K 自体が原因ではなく、E 条件のラベル設計の問題(詳細は
docs/benchmarks.md) - 長い state で落ちる。 local attention 128 のため。400〜800 トークンの長文で測ったところ、held-out bool AUROC が短文の 0.904 から 0.730 まで低下した。位置依存の質問も弱く、held-out の「文末が問いかけか」が 0.688
-
レイテンシは質問数に比例する。 joint は質問ごとに state を再エンコードする。レスポンスの
usage.backbone_passesに回数が出る - 単一の評価セット、300 件。 学習データも bench_ja も同じローカル LLM(Qwen3 30B-A3B)が書いた合成データ。実データでの性能は未検証
- seed 0 の score acc は 0.663。 3 シード平均の 0.763 ではない
Claude Code との働き方について
2 日間で Claude Code が回した学習は 7 本、テストは 534 件、見つけて直したバグは 10 個以上。そのうち「測らなければ見つからなかった」ものが 5 つある。キャッシュキーの sort_keys=True がスキーマランダム化を静かに無効化していた件、[CLS] と書いた設計書に対して実際のトークンは <s> だった件、I 段属性の相互含意、検算破棄の false 偏り、そして cross-attention 設計そのもの。
設計書に書いたルールで効いたのは 3 つ。
- 数字を捏造しない。未測定なら TBD。 推定値が一度も混ざらなかった
- ゲートを割ったら止まる。 多数決以下のチェックポイントは 3 回止まった。3 回とも公開しなくてよかった
- bench_ja でチューニングしない。 2 日目は held-out val で反復して、bench_ja は最後に 1 回だけ見た
設計書に書かなかったのに Claude Code がやったことが 2 つ。
一つは、自分の仮説を実測で否定すること。 「bool の配合が原因」と立てた仮説は、配合を直した s2b が悪化して自分で否定した。「表層属性は易しい床」と置いたゲートの前提は、表層の「文末が問いかけか」0.688 が非明示の意図 0.786 を下回って自分で否定し、「位置の問題であって難易度の問題ではなかった」と書き直した。
もう一つは、設計者の設計を疑うこと。 「データではなく学習側を疑え」と自分でゲートに書き、A/B を提案し、負けた設計の論証が「どこで前提を外したか」まで書いた。私が書いた設計書を、私が渡した Claude Code が否定した。
これは Claude Code の成果であって、私の成果ではない。私の成果は、止まれと書いたことと、否定を受け入れたことだけ。
公開したもの
- モデル: https://huggingface.co/GeneLab/sokudan-ja-310m(seed 0 が main、seed 1 / 2 は revision)
- コード: https://github.com/hiroki-abe-58/sokudan(Apache-2.0)
- bench_ja 300 件(CC BY 4.0、評価専用、学習に混ぜないでほしい)
-
docs/benchmarks.md: 全数値、シード別生値、位置バイアス、state 差し替え、A/B -
docs/architecture.md: 採用した設計と、撤回した設計の記録
Jev 本体との比較は、TypeSafe の利用規約(Master Customer Agreement 2.3(b))が同サービスを類似製品の開発に用いることを禁じているため、行っていない。
Laya の作者には、laya-multilingual の位置バイアスが英語でも再現することを報告する(公式パッケージでの再現確認済み)。次は v0.2(学習文書を 4,833 → 12,618 に)と、実データでの検証。
参考
- TypeSafe 公式ドキュメント: https://docs.typesafe.ai
- Laya モデルカード: https://huggingface.co/convaiinnovations/laya
- ModernBERT-Ja: https://huggingface.co/sbintuitions/modernbert-ja-310m
- ModernBERT 論文: https://arxiv.org/abs/2412.13663