- Published on
みんなのためのAI 第3回 — 損失0.0017なのに正解率7.5%、犯人はパディング1マスだった
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 指標が完璧なときが最も危ない
- 実験設計
- モデル構造 — 2種類の入力を1つのシーケンスに
- 1回目の結果 — 損失0.0017、正解率7.5%
- 犯人探し — 出力を直接見る
- 修正 — 1行
- 修正後の結果
- 何を学んだか
- 🧠 理解度チェッククイズ
- 参考資料
はじめに — 指標が完璧なときが最も危ない
今回の目標は VQA (Visual Question Answering) です。画像と質問を一緒に入れると答えを返すモデルです。
[手書き数字の画像] + "is it even or odd?" -> "odd"
[手書き数字の画像] + "how many holes?" -> "zero"
結果から申し上げると、最初に学習させたモデルは 損失0.0017、正解率7.5% でした。損失だけ見ればほぼ完璧に収束したのに、正解率はランダムに当てるより低かったのです。
この記事の半分はモデルを作る話で、残り半分は その7.5%の正体を突き止める話 です。結論を先に言うと、モデルには何の落ち度もありませんでした。
実験設計
MNIST の画像1枚に、4種類の質問をランダムに付けました。
| 質問 | 答えの形 | 例 |
|---|---|---|
| what digit is this? | 数字の名前 | seven |
| is it even or odd? | even / odd | even |
| is it bigger than four? | yes / no | no |
| how many holes? | 穴の数の名前 | one |
最後の質問が面白いところです。数字の8は穴が2つ、0と4と6と9は1つ、残りは0個です。同じ画像に対して 何を尋ねるかによって違う答えを出す 必要があるので、モデルが実際に質問を読んでいるかを検証できます。
モデル構造 — 2種類の入力を1つのシーケンスに
画像は CNN で圧縮して16個のトークンにし、質問は文字埋め込みに変えて後ろにつなげます。すると全体が1つのシーケンスになり、トランスフォーマーが処理できます。
class VQA(nn.Module):
def __init__(self):
super().__init__()
self.cnn = nn.Sequential(
nn.Conv2d(1, 32, 3, 2, 1), nn.GELU(), # 28 -> 14
nn.Conv2d(32, 64, 3, 2, 1), nn.GELU(), # 14 -> 7
nn.Conv2d(64, 128, 3, 2, 1), nn.GELU(), # 7 -> 4
)
self.img_proj = nn.Linear(128, 192)
self.emb = nn.Embedding(V, 192)
self.pos = nn.Embedding(64, 192)
layer = nn.TransformerEncoderLayer(192, 6, 768, batch_first=True, norm_first=True)
self.tr = nn.TransformerEncoder(layer, 3)
self.head = nn.Linear(192, V)
CNN が 28×28 の画像を 4×4×128 に縮めると、これを展開して16個のベクトルとして扱います。各ベクトルが画像の一領域を担当する 視覚トークン です。今日の大規模マルチモーダルモデルも根本的には同じ方式です。
マスクを区間ごとに変える
ここがこのモデルの核心です。シーケンスは3つの区間に分かれます。
[画像トークン16個][質問トークン24個][回答トークン6個]
前の2つの区間は互いに自由に見てかまいません。質問を理解するには画像全体を見る必要があり、画像を解釈するには質問を知る必要があるからです。しかし回答区間は 自分より前だけ を見なければなりません。そうしてこそ1文字ずつ生成できます。
T = h.shape[1]
A0 = 16 + q.shape[1] # 回答が始まる位置
m = torch.zeros(T, T, device=h.device)
m[:, A0:] = float("-inf") # まず回答区間を誰にも見せない
idx = torch.arange(T, device=h.device)
m[idx.unsqueeze(0) <= idx.unsqueeze(1)] = 0 # 自分自身と過去は許可
m[:A0, A0:] = float("-inf") # 前の区間が回答を先読みするのだけ再遮断
画像・質問の区間は双方向、回答区間は単方向です。1つのアテンションの中で2つのルールが共存します。
1回目の結果 — 損失0.0017、正解率7.5%
10分学習した後の記録です。
[ 603.7s] SUMMARY {"name": "03-image-text-to-text", "steps": 82120,
"final_loss": 0.0017, "vqa_acc": 0.075, "params": 1475870}
82,120ステップ、損失0.0017。これなら学習は完璧にできたという意味です。ところが正解率が 7.5% です。
答えの種類を数えれば、ランダムに当ててもこれより出ます。even/odd は2つに1つ、yes/no も2つに1つなので期待値はずっと高いはずです。 損失と正解率がこれほど食い違うなら、どちらかが嘘をついています。
犯人探し — 出力を直接見る
指標が互いに矛盾するとき、最も速い道はモデルが実際に何を吐くかを見ることです。
Q: how many holes? (正解 zero) -> A: zeroe
Q: is it even or odd? (正解 even) -> A: evene
Q: what digit is this? (正解 one) -> A: oneeo
Q: is it bigger than four? (正解 no) -> A: nonrn
Q: is it even or odd? (正解 odd) -> A: oddee
お分かりでしょうか。 モデルは毎回正解を当てていました。 zero, even, one, no, odd がすべて入っています。ただ答えを書き終えた後に止まれずゴミ文字を付け足し、完全一致で採点したため全部不正解扱いになったのです。
つまり7.5%は モデルの実力ではなく採点の結果 でした。モデルは「何を答えるか」は完璧に学び、「いつ止まるか」だけ学べなかったのです。
なぜ止まれなかったのか
正解シーケンスを作るコードはこうでした。
def tok(s, L):
ids = [enc[c] for c in s][:L]
return ids + [0] * (L - len(ids)) # 0 = PAD
a_full = torch.tensor([tok(s, AL - 1) + [1] for s in ans]) # 1 = EOS
AL が6のとき "even" を入れるとどうなるでしょうか。
tok("even", 5) -> [e, v, e, n, PAD]
+ [EOS] -> [e, v, e, n, PAD, EOS]
答えと EOS の間にパディングが割り込みました。 そして損失計算からパディングは除外されます。
loss = F.cross_entropy(..., ignore_index=0) # PAD は無視
その結果、位置4 (答えが終わったすぐ次のマス) の正解は PAD であり、PAD は損失から抜けるので そのマスには学習信号が一度も与えられませんでした。 推論時、モデルはそこで何をすべきか学んだことがないので、何でも吐きます。
損失が0.0017まで落ちたこともこれで説明できます。全6マスのうち実際に採点されるのは4〜5マスだけで、しかも答えが短く種類も少ないので覚えやすかったのです。 損失は「採点対象のマス」でのみ計算されるので、採点しないマスの問題は損失に現れません。
修正 — 1行
EOS を答えのすぐ後ろに付け、その後をパディングで埋めます。
def tok_ans(s, L):
"""回答用: EOS を答えの「すぐ後ろ」に付け、その後をパディングする。"""
ids = [enc[c] for c in s][:L - 1] + [1]
return ids + [0] * (L - len(ids))
tok_ans("even", 6) -> [e, v, e, n, EOS, PAD]
これで "n" の次のマスの正解が EOS になり、EOS はパディングではないので損失に含まれます。モデルが 「ここで終わる」を学ぶ機会 ができました。
修正後の結果
[ 603.1s] SUMMARY {"name": "03-image-text-to-text", "steps": 82030,
"final_loss": 0.00075, "vqa_acc": 0.995, "params": 1475870}
| 項目 | 修正前 | 修正後 |
|---|---|---|
| 最終損失 | 0.0017 | 0.00075 |
| VQA 正解率 | 0.075 | 0.995 |
| パラメータ | 1,475,870 | 1,475,870 |
| 学習時間 | 603.7秒 | 603.1秒 |
モデルも、ハイパーパラメータも、学習時間もそのままです。正解の作り方を1行変えただけで 7.5%が99.5%になりました。
修正後の出力です。
Q: is it even or odd? (正解 odd) -> A: odd
Q: how many holes? (正解 zero) -> A: zero
Q: is it bigger than four? (正解 no) -> A: no
Q: what digit is this? (正解 nine) -> A: nine
何を学んだか
損失は自分が見るものしか見ません。 ignore_index で除外したマスは損失に影響しないので、そこが壊れていても損失は静かです。マスキングや無視インデックスを使うときは「何が除外されているか」を必ず確認すべきです。
指標が食い違ったら出力を見てください。 損失0.0017と正解率7.5%は同時に真ではありえません。このときハイパーパラメータをいじるのは時間の無駄で、実際の出力数行が即座に答えをくれます。 zeroe を見た瞬間に問題の在り処が明らかになります。
完全一致の採点は厳しく、だからこそ有用です。 もし「正解が出力に含まれれば正解」と緩く測っていたら、このモデルは最初から100%が出て、バグは永遠に見つからなかったでしょう。厳しい採点がバグを暴きました。
シリーズ次回は 画像に文章を付けるキャプショニング を扱います。その回のコードには同じバグがなかったのですが、なぜなかったのかも一緒に見ます。
🧠 理解度チェッククイズ
1. 損失が0.0017なのに正解率が7.5%のとき、最初にやるべきことは何でしょうか。
モデルの実際の出力を目で見ることです。2つの指標が同時に真ではありえないので、どちらかが誤って測定されています。ハイパーパラメータを変えたり長く学習させたりするのは、原因を見つける前には意味がありません。この事例では出力5行を見ただけで即座に原因が明らかになりました。
2. [e, v, e, n, PAD, EOS] と [e, v, e, n, EOS, PAD] の決定的な違いは何でしょうか。
前者は "n" の次のマスの正解が PAD ですが、PAD は ignore_index で損失から除外されるので、そのマスに学習信号がありません。後者はそのマスの正解が EOS で、EOS は損失に含まれるので、モデルは「ここで答えが終わる」を学びます。
3. 採点を「正解が出力に含まれれば正解」と緩くしていたらどうなったでしょうか。
最初からほぼ100%が出たはずです。モデルの出力 zeroe の中には zero が入っていますから。するとバグを発見できないまま「うまく動く」と結論づけ、このモデルを実際に使うと答えの後ろにゴミが付いて出てきたでしょう。厳しい採点がバグを暴きました。
4. 画像区間と回答区間に異なるアテンションマスクを与える理由は何でしょうか。
画像と質問は互いをすべて参照してこそ理解できるので双方向に開けておきます。一方、回答は1文字ずつ生成するので、自分より後ろを見ると正解が漏れます。そのため因果マスクは回答区間にのみ適用します。