Skip to content

필사 모드: 人人可懂的 AI 第3篇 — 损失0.0017却只有7.5%正确率,元凶是一格填充

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 指标完美的时候最危险

本篇的目标是 VQA (Visual Question Answering):把图像和问题一起输入,模型给出答案。

[手写数字图像] + "is it even or odd?"  ->  "odd"
[手写数字图像] + "how many holes?"      ->  "zero"

先说结果:第一次训练出的模型是 损失 0.0017、正确率 7.5% 。只看损失几乎完美收敛,正确率却比随机猜测还低。

本文一半在讲怎么造这个模型,另一半在讲 追查那 7.5% 究竟是什么 。先剧透结论:模型没有任何问题。

实验设计

给每张 MNIST 图像随机配上四类问题之一。

问题答案形式示例
what digit is this?数字名称seven
is it even or odd?even / oddeven
is it bigger than four?yes / nono
how many holes?洞数的名称one

最后一个问题很有意思。数字 8 有两个洞,0、4、6、9 各有一个,其余没有。因为同一张图像 要根据问的内容给出不同答案 ,我们得以验证模型是否真的在读问题。

模型结构 — 把两种输入放进同一条序列

图像经 CNN 压缩成 16 个 token,问题按字符嵌入后接在其后。整体便成为一条序列,可以交给 Transformer 处理。

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 个向量。每个向量负责图像的一个区域,也就是 视觉 token 。今天的大型多模态模型在根本上是同一套做法。

按区间给不同的掩码

这是本模型的核心。序列分为三段。

[图像 token 16][问题 token 24][回答 token 6]

前两段可以彼此自由查看:理解问题需要看到整张图,解释图像需要知道问题是什么。但回答段只能看 自己之前 的位置,这样才能逐字生成。

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")     # 只把前缀偷看回答的路重新堵上

图像与问题段是双向的,回答段是单向的。两套规则共存于同一次注意力运算中。

第一次结果 — 损失 0.0017、正确率 7.5%

训练十分钟后的记录。

[  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 二选一,yes/no 也是二选一。 当损失和正确率如此矛盾时,一定有一个在撒谎。

找出元凶 — 直接看输出

指标互相矛盾时,最快的路径是看模型究竟吐出了什么。

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

看出来了吗。 模型每一次都答对了。 zeroevenonenoodd 全都在里面。它只是写完答案后停不下来,又追加了垃圾字符,而完全匹配的评分把它们全判成了错。

所以 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 也就说得通了。六格中真正被评分的只有四五格,而且答案短、种类少,很容易记住。 损失只在被评分的位置上计算,所以不评分的位置出问题时,损失是沉默的。

修复 — 一行

把 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.00170.00075
VQA 正确率0.0750.995
参数量1,475,8701,475,870
训练时间603.7秒603.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%,bug 会永远潜伏下去。严格的评分把它揪了出来。

下一篇讲 给图像配句子的图像描述 。那一篇的代码没有同样的 bug,我们也会一起看看为什么没有。

🧠 理解度自测

1. 损失 0.0017 而正确率 7.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。那样 bug 就不会被发现,结论会是"运行良好",而实际使用时每个答案后面都拖着垃圾。严格的评分暴露了 bug。

4. 为什么图像段和回答段要用不同的注意力掩码?

图像和问题必须互相参照才能被理解,所以保持双向。而回答是逐字生成的,看到后面就会泄漏答案。因此因果掩码只施加于回答段。

参考资料

현재 단락 (1/97)

本篇的目标是 **VQA** (Visual Question Answering):把图像和问题一起输入,模型给出答案。

작성 글자: 0원문 글자: 4,167작성 단락: 0/97