Skip to content
Published on

みんなのためのAI 第4回 — 137万パラメータで画像に文をつける、そして第3回のバグがここになかった理由

シェア
Authors

はじめに — キャプショニングは VQA と何が違うのか

第3回の VQA は画像と質問を受け取って短い答えを出しました。今回は質問なしで 画像だけを見て文を作る キャプショニングです。

[スニーカーの画像]  ->  "a photo of a sneaker"
[ズボンの画像]      ->  "trousers on white background"

違いは出力の長さにあります。VQA の答えは6文字でしたが、キャプションは40文字です。長くなると構造を変える必要があります。第3回は画像・質問・答えを1つのシーケンスにつなげましたが、今回は エンコーダとデコーダを分離 します。

データ — 画像に文をつける方法

Fashion-MNIST にはキャプションがありません。ラベル (0〜9) だけです。そこでラベルを文に変えるテンプレートを作りました。

ARTICLES = ["t-shirt", "trousers", "a pullover", "a dress", "a coat",
            "a sandal", "a shirt", "a sneaker", "a bag", "an ankle boot"]
TEMPLATES = ["a photo of {}", "this looks like {}", "{} on white background"]

1つの画像に3種類のテンプレートのどれかをランダムに付けます。こうするとモデルは 文全体を丸暗記する代わりに構造を学ぶ 必要が出てきます。"a photo of" で始まれば品名が来て、品名で始まれば "on white background" が来る、という具合です。

冠詞もわざと不規則にしました。t-shirttrousers には冠詞がなく、an ankle boot だけが an を使います。モデルがこれを当てられるかも確認できます。

構造 — エンコーダとデコーダを分ける

class Captioner(nn.Module):
    def __init__(self):
        super().__init__()
        self.cnn = nn.Sequential(
            nn.Conv2d(1, 32, 3, 2, 1), nn.GELU(),
            nn.Conv2d(32, 64, 3, 2, 1), nn.GELU(),
            nn.Conv2d(64, 128, 3, 2, 1), nn.GELU(),
        )
        self.proj = nn.Linear(128, 160)
        self.emb = nn.Embedding(V, 160)
        self.pos = nn.Embedding(CAP, 160)
        layer = nn.TransformerDecoderLayer(160, 5, 640, batch_first=True, norm_first=True)
        self.dec = nn.TransformerDecoder(layer, 3)
        self.head = nn.Linear(160, V)
        self.register_buffer("m", torch.triu(torch.full((CAP, CAP), float("-inf")), 1))

    def forward(self, img, cap_in):
        mem = self.proj(self.cnn(img).flatten(2).transpose(1, 2))
        T = cap_in.shape[1]
        h = self.emb(cap_in) + self.pos(torch.arange(T, device=cap_in.device))
        return self.head(self.dec(h, mem, tgt_mask=self.m[:T, :T]))

核心は self.dec(h, mem, ...) の1行です。nn.TransformerDecoder は引数を2つ取ります。

  • h — これまでに生成したキャプションのトークン
  • mem — CNN が作った16個の画像ベクトル (メモリ)

デコーダ層の中では2種類のアテンションが順に起きます。まず self-attention でキャプションが自分自身の過去を見ます (だから tgt_mask が必要です)。次に cross-attention でキャプションの各位置が16個の画像ベクトルを見ます。

この2つ目が重要です。"sneaker" という単語を生成するとき、モデルは画像のどの領域を見るべきかを自分で選びます。第3回のようにすべてを1つのシーケンスに入れると画像とテキストが同じアテンションを分け合いますが、ここでは 役割が分離されてそれぞれ最適化 されます。キャプションが長くなるほどこの分離が有利になります。

第3回のバグがここになかった理由

第3回で正解率を7.5%に落とした犯人は、正解シーケンスを作る関数でした。2つのコードを並べてみます。

第3回 (バグあり)

def tok(s, L):
    ids = [enc[c] for c in s][:L]
    return ids + [0] * (L - len(ids))

a_full = torch.tensor([tok(s, AL - 1) + [1] for s in ans])   # パディング後に EOS

第4回 (正常)

def tok(s):
    ids = [enc[c] for c in s][:CAP - 1] + [1]   # EOS を先に付けて
    return ids + [0] * (CAP - len(ids))          # その後パディング

"even" を入れたときの結果を比べるとこうなります。

結果"n" の次のマスの正解
第3回[e, v, e, n, PAD, EOS]PAD → 損失から除外
第4回[e, v, e, n, EOS, PAD]EOS → 損失に含まれる

順序1つの違いです。第4回は EOS を先に付けて後からパディングしたので、答えが終わる位置に必ず EOS が置かれます。モデルは「ここで止まる」を学べたので、同じバグに遭いませんでした。

同じ人間が同じ日に書いたコードなのに、片方だけが間違っていました。 この種のミスはレビューで捕まえにくく、指標にも現れません。 第3回で見たとおり損失はむしろ良く見えたのですから。出力を直接見る以外に方法はありません。

学習ログ

[   12.6s] params {"params": 1367262, "cap_len": 40}
[   13.3s] step      0 loss=3.7440
[   14.2s] step    100 loss=0.1660
[  608.4s] SUMMARY {"steps": 79504, "final_loss": 0.0468, "caption_acc": 0.91}

100ステップで損失が3.74から0.166に落ちました。1秒あまりです。キャプションが30通りの組み合わせ (10品目 × 3テンプレート) しかないので、構造をすぐ把握したのです。

結果

テストセット300枚に対して 生成されたキャプションに正解の品名が入っているか で採点しました。結果は91%です。

[an ankle boot] -> an ankle boot on white background
[a pullover]    -> a photo of a pullover
[trousers]      -> trousers on white background
[trousers]      -> this looks like trousers
[a shirt]       -> a photo of a shirt
[a coat]        -> a photo of a coat

いくつか注目すべき点があります。

テンプレートが混ざって出ます。 同じ trousers に対して一度は "trousers on white background"、別の回には "this looks like trousers" が出ました。1つを暗記したのではなく3つの構造をすべて身につけたということです。

冠詞を当てます。 an ankle bootana pullovera、そして trousers には冠詞なしまで正確です。文字単位で生成しているのにこれを当てるのは、品名の最初の文字と冠詞の関係を学習したからです。

間違えた9%はほとんどシャツ系です。 Fashion-MNIST の shirt, t-shirt, coat, pullover は 28×28 のグレースケールでは区別が困難です。人が見ても紛らわしい項目なので、この誤りはモデルの欠陥というよりデータの限界に近いものです。

まとめ

項目
パラメータ1,367,262 (1.37M)
学習時間608.4秒
ステップ79,504
最終損失0.0468
キャプションラベル的中率0.91

エンコーダ・デコーダの分離は出力が長くなるときに値打ちを発揮します。cross-attention のおかげで生成中の各単語が画像の必要な部分だけを選んで見られますし、画像表現と言語表現が互いに干渉しません。

そして第3回との対比がこの回のもう一つの収穫です。同じミスをするところでしたが、たまたま EOS を先に付けたので避けられました。 偶然に頼らないためには、正解シーケンスを作る関数を一度出力してみる習慣が必要です。 バッチを1つ取り出して print するのに10秒あれば足ります。

次回は出力がテキストではなく 画像そのものである場合 — 白黒写真に色をつける問題を扱います。そこでは損失関数の選択が結果の性格を変えてしまうことを見ることになります。

🧠 理解度チェッククイズ

1. nn.TransformerDecoder の中で起きる2つのアテンションはそれぞれ何をしますか。

1つ目は self-attention で、生成中のキャプションが自分自身の以前のトークンを参照します。因果マスクが必要なのはこの部分です。2つ目は cross-attention で、キャプションの各位置が CNN の作った16個の画像ベクトルを参照します。どの単語を書くときに画像のどの領域を見るかをモデルが自分で選びます。

2. キャプションのテンプレートを3種類混ぜた理由は何ですか。

1つだけだとモデルが文全体を丸暗記できてしまいます。複数のテンプレートを混ぜると、"a photo of" の次には品名が来て品名の次には "on white background" が来る、といった構造を学ばなければなりません。実際に同じ trousers の画像に対して異なるテンプレートが生成されたことで確認できました。

3. 第3回の EOS バグが第4回のコードにはなぜなかったのですか。

第3回は tok(s, AL-1) + [1] でパディングを先に埋めてから EOS を付けたため、答えと EOS の間にパディングが挟まりました。第4回は [enc[c] for c in s][:CAP-1] + [1] で EOS を先に付けてからパディングしたので、答えが終わる位置に必ず EOS が置かれます。順序1つの違いです。

4. 間違えた9%が主にシャツ系に偏っているのを、モデルの欠陥と見るべきでしょうか。

全面的にそう見るのは難しいです。Fashion-MNIST の shirt, t-shirt, coat, pullover は 28×28 のグレースケール解像度では人が見ても区別が困難です。データが含む情報量の限界に近く、モデルを大きくしても解決しない種類の誤りです。

参考資料