0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「JevってBERTじゃね?」を深掘りするゾ

0
Posted at

空前絶後のJevブーム、世界中がJevに熱狂しています。
俺もその1人。自分で日本語の判断モデル sokudan まで作っちゃったぐらいです。

でも、友人からいちばん多くもらった反応は

「それ、ただのBERTじゃね?」でした。

Jevは、TypeSafe AIが2026年9月15日に公開した、文章を生成せず選択肢の確率だけを返す判断モデルです。sokudanは筆者がその日本語版を目指して作ったオープンソースのモデルで、土台は日本語のModernBERTです。「BERTじゃね?」と言われると、半分はうなずくしかありません。

動機
答えに詰まったのは、BERTのどの性質が役に立ち、どの性質が設計を迷わせたのかを、作った側として言葉にしていなかったからです。実際、筆者はModernBERTの設定値を読んで「この並べ方は失敗する」と予想し、別の構成を本命にしました。測ると、本命のほうが大差で負けました。

問題意識
考えてほしいのは、判断モデルを作るときや選ぶときに、モデルの大きさより先に「文章をどの向きで読む仕組みか」を確かめたほうがよいのではないか、という点です。

実地の文脈
学習と計測は、コンシューマ向けハイエンド構成の一例である RTX 5090 1枚で行いました。最初の版は2日間で作っています。この記事の再現コードは、CPUだけで動きます。

この記事では、モデルを書類の読み手にたとえます。エンコーダは書類を机に全部広げてから読む審査員、デコーダは1行ずつめくりながら読み上げる朗読者です。

この記事を読むと、明日こう決められます。Jev型の判断モデルを作るか選ぶとき、土台をBERT系のエンコーダにするかLLM系のデコーダにするか。エンコーダにするなら、質問と本文をどの順に並べるか。

この記事について

対象読者

LLM のAPIを呼んで分類やラベル付けをさせたことがあり、Transformer という名前は知っているものの、BERTとLLMの違いを自分の言葉で説明できない人を想定しています。

この記事で得られること

  • 判断モデルの土台をエンコーダとデコーダのどちらにするか、判断フローで決められる
  • 質問を本文より前に置けるのはエンコーダだけ、という事実をCPUで再現できる
  • BERTからModernBERTで何が変わったか
  • 設定値からの予想が外れた実例

この記事で扱わないこと

  • Jev本体の内部構造の推測(公開されていないため)
  • sokudanの使い方(別記事で扱いました)

1. 結論から: Jevは分からない。Jev型の再現にはBERT系がよく選ばれている

冒頭で「半分はうなずくしかない」と書きました。残りの半分を確かめるには、Jev本体と、Jev型を再現しようとする公開モデルを分けて見る必要があります。

このセクションで分かること: 「JevってBERT?」という問いのうち、公開情報で答えられる部分と答えられない部分。

TypeSafe AIの発表記事は、Jevを新しいモデル構造、並列のサンプラー、RLCDという学習法の組み合わせとして紹介していますが、土台は書かれていません。「JevはBERTか」には、公開情報では答えられません。

一方、Jevと同じ形を再現した公開モデルは、土台がはっきりしています。

モデル 公開元 土台 読み方
Jev TypeSafe AI 非公開 非公開
Laya NandhaKishorM ModernBERT-large / mmBERT-base エンコーダ
sokudan 筆者 ModernBERT-Ja-310M エンコーダ
Jev Local argos1111 ModernBERT-Ja-310M エンコーダ
Kev jaredpalmer Qwen3.5 / Qwen3.8 デコーダ

再現モデルはエンコーダ派が多く、デコーダ派もいます。「JevってBERTじゃね?」は、Jev本体については確かめようがなく、Jev型の作り方としては自然な選択肢の1つ、というのが正確なところです。

sokudanではJevを測っていません。TypeSafeの利用規約(Master Customer Agreement 2.3(b))が、類似製品の開発への利用を禁じているためです。

では、なぜエンコーダ派が多いのか。それを考えるには、BERTがどういう読み方をするモデルかを押さえる必要があります。

2. BERTとは何か: 書類を机に全部広げてから読む審査員

前節で、Jev型の再現にはBERT系のエンコーダがよく使われていると分かりました。理由はBERTの読み方にあります。

このセクションで分かること: BERTの2つの特徴と、それがJev型の「空欄に答えを書かせる」作りにどうつながるか。

BERTは、Googleが2018年に発表した言語モデルで、Transformerのうち文章を読む側(エンコーダ)だけを使います。特徴は2つあります。

1つ目は双方向に読むことです。各トークン(文字や単語の断片)は、全層で左右両方の文脈を参照します。机に書類を全部広げた審査員には、後ろの行も見えています。

2つ目は、穴埋め問題で事前学習することです。入力の15%を選んで主に [MASK] という空欄に置き換え、元の語を当てさせます(MLM)。2文が続いているかを当てる課題(NSP)も解き、分類には先頭の [CLS] という表紙のトークンを使います。

この2つの違いを、読み手の位置で書くと次のようになります。記号を順に導入します。

  • $n$(系列長、整数): 書類の行数
  • $i$(見る側のトークンの位置、整数): 審査員がいま読んでいる行
  • $j$(見られる側のトークンの位置、整数): その行を読むときに参照してよい行
\mathrm{Encoder}:\ j \in \{0, \dots, n-1\} \qquad \mathrm{Decoder}:\ j \le i

この式が言っているのは、エンコーダはどの行からでも全行を参照でき、デコーダは自分より後ろの行を参照できない、ということです。朗読者はまだめくっていないページを読めません。

実験A: 空欄は、後ろに置いた本文を見られるか

同じ重みを、双方向のままと、因果マスク(後ろを隠すマスク)をかけた状態で比べます。位置5を空欄、位置30を本文に見立て、本文を1トークン書き換えたときに空欄の出力が変わるかを測ります。PyTorch と Transformers では、is_decoder=True を渡すだけで因果マスクに切り替わります。

import torch
from transformers import BertConfig, BertModel

torch.manual_seed(0)
cfg = dict(vocab_size=1000, hidden_size=64, num_hidden_layers=4,
           num_attention_heads=4, intermediate_size=128)
enc = BertModel(BertConfig(**cfg)).eval()                  # 双方向(BERT)
dec = BertModel(BertConfig(**cfg, is_decoder=True)).eval()  # 因果マスク(デコーダ相当)
dec.load_state_dict(enc.state_dict())                       # 重みは完全に同じ

MARK, STATE = 5, 30          # 空欄の位置 / 書き換える本文トークンの位置
ids = torch.randint(10, 1000, (1, 40))
ids2 = ids.clone()
ids2[0, STATE] = (ids[0, STATE] + 1) % 1000     # 本文を1トークンだけ書き換える

@torch.no_grad()
def shift(model, pos):
    a = model(ids).last_hidden_state[0, pos]
    b = model(ids2).last_hidden_state[0, pos]
    return (a - b).abs().max().item()

for name, m in [("エンコーダ(双方向)", enc), ("デコーダ(因果)", dec)]:
    print(f"{name}: 空欄の変化 {shift(m, MARK):.2e} / 末尾の変化 {shift(m, 39):.2e}")
エンコーダ(双方向): 空欄の変化 2.53e-03 / 末尾の変化 2.80e-03
デコーダ(因果): 空欄の変化 0.00e+00 / 末尾の変化 2.80e-03

変化の大きさが小さいのは、重みがランダムだからです。見るべきは0かどうかで、デコーダでは空欄の出力が完全に0のまま動きませんでした。

エンコーダなら空欄を本文より前に置けますが、デコーダでは後ろに置くしかありません。実際、sokudanとLayaは質問を前に、デコーダのKevは本文の後ろに置いています。

ただしこれは、全層が全体を見る元のBERTの話です。sokudanの土台は、そうではありません。

3. ModernBERTとは何か: 虫眼鏡と、3回に1回の見渡し

前節で、エンコーダは空欄を本文より前に置いても本文を参照できると確かめました。ところがsokudanが使ったModernBERTは、全体を見る層が3層に1層しかありません。前節の結論がそのまま使えるかは、この構造しだいです。

このセクションで分かること: BERTからModernBERTで何が変わったかと、局所注意の窓が実際に何トークンかを自分で確かめる方法。

ModernBERTは、Answer.AIとLightOnらが2024年12月に発表したエンコーダです。BERTの読み方はそのままに、LLMで定着した改良を取り込んでいます。

項目 BERT(2018) ModernBERT(2024)
位置の扱い 学習する位置埋め込み、512トークンまで RoPE、8,192トークンまで
注意の範囲 全層で全体 3層に1層が全体、残りは128トークンの窓
活性化関数 GELU GeGLU
事前学習 MLM 15%、NSPあり MLM 30%、NSPなし
学習データ BooksCorpusと英語Wikipedia コードを含む2兆トークン

sokudanが使ったModernBERT-Ja-310Mは、SB Intuitionsが日本語と英語の4.09兆トークンで学習したものです。

実験C: 設定値だけで、3つのモデルの骨格を数える

torch.device("meta") の中で作ると、重みのメモリを確保せずに形だけ作れます。

import torch
from transformers import BertConfig, BertModel, ModernBertConfig, ModernBertModel

SPECIAL = dict(pad_token_id=3, bos_token_id=1, eos_token_id=2,
               cls_token_id=6, sep_token_id=4)
models = {
    "BERT-base": lambda: BertModel(BertConfig(), add_pooling_layer=False),
    "ModernBERT-base": lambda: ModernBertModel(ModernBertConfig()),
    "ModernBERT-Ja-310M": lambda: ModernBertModel(ModernBertConfig(
        vocab_size=102400, hidden_size=768, intermediate_size=3072,
        num_hidden_layers=25, num_attention_heads=12, local_attention=128,
        global_attn_every_n_layers=3, max_position_embeddings=8192, **SPECIAL)),
}
for name, build in models.items():
    with torch.device("meta"):                    # 形だけ作り、メモリは確保しない
        m = build()
    cfg = m.config
    types = getattr(cfg, "layer_types", None) or ["full_attention"] * cfg.num_hidden_layers
    n_full = types.count("full_attention")
    n = sum(p.numel() for p in m.parameters())
    print(f"{name:19s} 層{cfg.num_hidden_layers:3d} 全体を見る層{n_full:3d} "
          f"語彙{cfg.vocab_size:7,d} 位置上限{cfg.max_position_embeddings:6,d} パラメータ{n:,}")
モデル 層 全体を見る層 語彙 位置の上限 パラメータ
BERT-base 12 12 30,522 512 108,891,648
ModernBERT-base 22 8 50,368 8,192 149,014,272
ModernBERT-Ja-310M 25 9 102,400 8,192 314,611,968

ModernBERT-Ja-310Mの値は、sokudanに記録した実物のパラメータ数と1の位まで一致しました。BERT論文の110Mは、プーラー層などを含めた概数です。注目してほしいのは、ModernBERT-Ja-310Mで全体を見る層が25層中9層しかない点です。

局所注意の窓は、何トークンか

局所注意の層を式で書くと、記号を1つ足すだけで済みます。

  • $w$(片側の窓幅、整数): 虫眼鏡の半径。ModernBERTでは64
\mathrm{Local}:\ |i - j| \le w

この式が言っているのは、局所の層では前後64行しか見えない、ということです。設定の local_attention: 128 は窓の全幅です。

実験B: 各層で、1つのトークンから何個先まで見えているか

注意の重みが0より大きいキーの数を、層ごとに数えます。

import torch
from transformers import ModernBertConfig, ModernBertModel

torch.manual_seed(0)
cfg = ModernBertConfig(
    vocab_size=1000, hidden_size=64, intermediate_size=128,
    num_hidden_layers=6, num_attention_heads=4,
    local_attention=128, global_attn_every_n_layers=3,   # ModernBERT と同じ並び
    max_position_embeddings=8192, attn_implementation="eager",
    pad_token_id=3, bos_token_id=1, eos_token_id=2, cls_token_id=6, sep_token_id=4,
)
model = ModernBertModel(cfg).eval()
print("sliding_window =", cfg.sliding_window)

L, Q = 512, 256                       # 系列長 / 観察するトークンの位置
ids = torch.randint(10, 1000, (1, L))
with torch.no_grad():
    out = model(ids, output_attentions=True)

for i, att in enumerate(out.attentions):          # att: (1, heads, L, L)
    seen = (att[0, :, Q] > 0).any(dim=0).nonzero().flatten()
    kind = "global" if cfg.layer_types[i] == "full_attention" else "local"
    print(f"層{i} {kind:6s} 見えるキー {len(seen):3d}個 範囲 {seen.min().item()}〜{seen.max().item()}")
層 種類 位置256から見えるキー 範囲
0、3 global 512個 0〜511
1、2、4、5 local 129個 192〜320

局所の層では、前後64ずつ計129個しか見えていません。審査員はふだん虫眼鏡で近所を読み、3回に1回だけ全体を見渡しています。

ここまでのまとめ

  • BERT系のエンコーダは双方向に読むので、空欄を本文より前に置いても本文を参照できる
  • ModernBERTは3層に1層だけが全体を見て、残りの層は前後64トークンしか見ない
  • 空欄と根拠が64トークン以上離れると、直接つながるのは全体を見る層だけ

4. ModernBERTでJev型を組む: 選択肢の数だけ空欄を置く

前節までで、BERT系は空欄を本文より前に置けること、ModernBERTは3層に1層しか全体を見ないことが分かりました。sokudanの入力の並べ方は、この2つの事実の両方から決まっています。

このセクションで分かること: エンコーダでJev型を作る最小構成と、空欄を選択肢の採点に使う理由。

sokudanは、1つの質問を次の1本の系列にしてモデルに通します(joint encoding)。

選択肢の横には、事前学習で使った空欄 <mask> をそのまま置きます。空欄の位置は「ここに何が入るか」を表すよう事前学習されているので、新しい特殊トークンを足すより有利です。たとえで言えば、選択肢ごとの空欄に審査員がどれだけ強くハンコを押したがっているかを、採点器が読み取ります。

実験D: 空欄を読む採点器を1枚足した最小構成

import torch
from torch import nn
from transformers import ModernBertConfig, ModernBertModel

BOS, EOS, MASK = 1, 2, 5          # <s>, </s>, <mask> に当たる ID(ModernBERT-Ja と同じ並び)
torch.manual_seed(0)
backbone = ModernBertModel(ModernBertConfig(
    vocab_size=1000, hidden_size=64, intermediate_size=128, num_hidden_layers=6,
    num_attention_heads=4, local_attention=128, global_attn_every_n_layers=3,
    pad_token_id=3, bos_token_id=BOS, eos_token_id=EOS, cls_token_id=6, sep_token_id=4,
)).eval()
scorer = nn.Linear(64, 1)                          # 768->1 を小さくしたもの
nn.init.normal_(scorer.weight, std=0.002); nn.init.zeros_(scorer.bias)

def encode(instr, options, state):
    """<s> 指示 </s><s> 選択肢1 <mask> 選択肢2 <mask> ... </s><s> 本文 </s>"""
    ids, marks = [BOS, *instr, EOS, BOS], []
    for opt in options:
        ids += opt
        marks.append(len(ids))                    # 位置は組み立てながら記録する
        ids.append(MASK)
    ids += [EOS, BOS, *state, EOS]
    return torch.tensor([ids]), torch.tensor(marks)

ids, marks = encode([11, 12], [[21, 22], [23], [24, 25, 26]], list(range(100, 260)))
with torch.no_grad():
    h = backbone(ids).last_hidden_state[0]        # (系列長, 隠れ次元)
    logits = scorer(h[marks]).squeeze(-1)         # 空欄ごとに1つの数
    probs = logits.softmax(-1)
print("系列長", ids.shape[1], "空欄の位置", marks.tolist(),
      "空欄から本文末尾までの距離", ids.shape[1] - 1 - marks.min().item())
print("初期の確率", [round(p, 4) for p in probs.tolist()],
      "一様からのずれ", f"{(probs - 1/len(marks)).abs().max():.1e}")
系列長 177 空欄の位置 [7, 9, 13] 空欄から本文末尾までの距離 169
初期の確率 [0.3333, 0.3333, 0.3333] 一様からのずれ 6.3e-07

採点器の重みを小さな値で初期化したので、学習前の確率はほぼ一様です。0で初期化すると全選択肢の勾配がそろい、選択肢が永久に分かれません。

ここで、空欄から本文末尾までの距離が169トークンになっている点に注目してください。虫眼鏡の半径64を超えています。筆者は設計の段階で、まさにこの距離を問題視しました。

5. 作って分かったこと: 設定値からの予想は2回外れた

前節の最小構成では、空欄が本文の大半から64トークン以上離れていました。筆者は設定値から「1本の系列では、質問は本文をほとんど見られない」と予想しました。その予想とsokudanの実測を並べます。

このセクションで分かること: 設定値からの予想がなぜ外れたかと、エンコーダで入力の並べ方を決めるときに測るべきもの。

予想 試したこと 指標 比較元 試した後
1本の系列では本文が見えない 質問と本文を別系列にし、注意2層でつなぐ 未知の質問へのAUROC 0.872 0.506
窓128が位置に依存する判断を弱くしている 窓を1024に広げる 文末が問いかけかの正解率 0.634 0.535
空欄と根拠の距離が遠いと弱い 本文を先、質問を後ろに並べる 400〜799トークン帯のAUROC(3シード平均) 0.845 0.885

数値はすべてsokudanのdocsにある実測で、再現手順も同じ場所にあります。

uv run python scripts/train.py --encoding separate      # 1行目の比較
uv run python scripts/train.py --local-attention 1024   # 2行目
uv run python scripts/train.py --input-order state_first  # 3行目

1つ目の外れ: 別系列にしたほうが大差で負けた

いちばん驚いたのは1行目です。本命の別系列は、未知の質問に対してAUROC 0.506で、ほぼ当てずっぽうでした。1本の系列は0.872で、1エポックも464秒対330秒で速かったのです。

原因は実データの長さでした。平均の系列長は160トークン(本文134、質問26)で、窓の128とほぼ同じです。予想の前提にした「数千トークン先に質問がある」状況は起きていませんでした。一方で別系列の構成は、質問と本文のやり取りを注意の層2枚だけに押し込めます。1本の系列なら25層すべてを使えます。代わりに、N問のリクエストではモデルをN回通すことになり、「本文は1回だけ読めばよい」という当初の主張は撤回しました。

2つ目の外れ: 窓を広げたら悪くなった

2行目では窓を128から1024に広げ、位置に依存する判断が0.634から0.535に下がりました。局所の層は事前学習で前後64トークンの距離しか見ておらず、窓を広げると、学習で見ていない距離の位置情報(RoPE)を扱うことになります。筆者はこれを、仮説の反証ではなく介入手段の失敗として扱いました。

3行目では構造に触れず、並べ方だけを変えました。エンコーダならどちらの順でも空欄は本文を参照できます(実験A)。長い本文の帯は0.845から0.885に改善しましたが、「暗に断っているか」の判断が0.799から0.722に下がり、採用は見送りました。並べ方は自由に選べますが、結果は並べ方で変わります。

3つ目の発見: 表紙のトークンが存在しなかった

設計書にはBERTの書き方で [CLS] 指示 [SEP] 選択肢 [SEP] 本文 [SEP] と書いていました。ところがModernBERT-Ja-310Mのトークナイザは、語彙に <cls> を持ちながら実際には <s> … </s> で文を包みます。モデルカードにも、NSPを学習していないので文頭には <s> を付けるとあります。そのまま <cls> を置くと、事前学習で見ていない並びに空欄を置くことになるので、sokudanではトークナイザに短い文を通して包み方を聞き、その並びを使っています。

6. 判断フロー: エンコーダにするか、デコーダにするか

前節で、設定値だけでは結果を予想できず、実データの長さと並べ方で結果が決まると分かりました。これを判断の手順にまとめます。

このセクションで分かること: Jev型の判断モデルの土台と、入力の並べ方を決める手順。

1件に質問が多い場合も考慮が要ります。sokudanのようなエンコーダは質問の数だけモデルを通し、Kevは本文を1回読んでキャッシュを使い回します。本文の長さの目安はsokudanの学習データ(平均134トークン)とKevの記載によるものなので、最後は自分のデータで長さの帯ごとに測ってください。

7. トラブルシューティング

症状 原因 対処
デコーダで質問を先頭に置くと本文を無視する 因果マスクで後ろが見えない 本文の後ろに質問を置く
ModernBERT-Jaの [CLS] 位置の表現が役に立たない トークナイザは <cls> を出さない トークナイザに包み方を聞き、その並びを使う
ModernBertConfig に global_attn_every_n_layers がない transformers 5系は layer_types で層の種類を持つ config.layer_types を読む
窓を広げたら精度が落ちた 学習していない距離を扱う 窓は事前学習の値のまま使う
長い本文で精度が落ちる 空欄と根拠が窓より遠い 本文を前に置く並びを測る

用語集

用語 意味 たとえ
エンコーダ 系列全体を双方向に読むTransformer 机に書類を全部広げる審査員
デコーダ 後ろを隠して読むTransformer 1行ずつめくる朗読者
MLM 一部を空欄にして元の語を当てる事前学習 穴埋め問題
NSP 2文が続いているかを当てる事前学習 書類の綴じ順の確認
RoPE 位置を回転で表す位置の埋め込み方 行番号の付け方
局所注意 前後の決まった範囲だけを見る注意 虫眼鏡
joint encoding 質問と本文を1本の系列にして読む方式 質問票と書類を同じ机に並べる
AUROC はい・いいえの順位付けの正しさ。0.5が当てずっぽう 審査の序列の確かさ

学習ロードマップ

Attention と Self-Attention から入り、実験Dを自分のデータで学習させる順がおすすめです。

まとめ

「JevってBERTじゃね?」への答えは、Jev本体は分からない、Jev型の作り方としてはBERT系が自然な選択肢の1つ、です。BERT系を選ぶ一番の理由は、空欄をどこに置いても本文を参照できる双方向の読み方にあります。

一方で、設定値を読んだだけの予想は2回外れました。決め手は、実データの長さと並べ方を変えた実測でした。仕組みを理解したうえで、最後は自分のデータで測ってください。

参考文献

  • Devlin et al., "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding", arXiv:1810.04805(邦題訳: BERT 言語理解のための深い双方向Transformerの事前学習)。BERTの原論文

  • Warner et al., "Smarter, Better, Faster, Longer: A Modern Bidirectional Encoder for Fast, Memory Efficient, and Long Context Finetuning and Inference", arXiv:2412.13663(邦題訳: より賢く、良く、速く、長く 高速・省メモリ・長文の微調整と推論のための現代的な双方向エンコーダ)。ModernBERTの論文

  • SB Intuitions, ModernBERT-Ja-310M。日本語ModernBERTのモデルカード

  • TypeSafe AI, "Introducing System One Models & Jev"(邦題訳: System One モデルとJevの紹介)。Jevの発表記事

  • sokudan。5章の実測値の出典(docs/architecture.md ほか)

  • Laya と Kev。エンコーダ派とデコーダ派の公開実装

関連記事

計測環境: Python 3.12、torch 2.14.1(CPU)、transformers 5.18.0、メモリ4GBのLinux。重みはランダムで、ダウンロード不要です。


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?