- Published on
人人可懂的 AI 第4篇 — 用137万参数给图像配句子,以及第3篇的 bug 为何没出现在这里
- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 图像描述与 VQA 有何不同
第3篇的 VQA 接收图像和问题,给出简短答案。本篇没有问题, 只看图像就写出一句话 。
[运动鞋图像] -> "a photo of a sneaker"
[裤子图像] -> "trousers on white background"
差别在输出长度。VQA 的答案是六个字符,字幕是四十个。变长了,结构就得变。第3篇把图像、问题、答案连成一条序列;这里我们 把编码器和解码器分开 。
数据 — 怎样给图像配上句子
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"]
每张图随机配三种模板之一。这样模型就必须 学结构,而不是把整句背下来 :以 "a photo of" 开头后面跟品名,以品名开头后面跟 "on white background"。
冠词也故意做得不规则。t-shirt 和 trousers 不带冠词,只有 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, ...) 这一行。nn.TransformerDecoder 接收两个输入:
h—— 目前已生成的字幕 tokenmem—— CNN 产出的 16 个图像向量 (memory)
在每个解码层内部,两种注意力依次发生。先是 self-attention ,字幕查看自己的过去 —— 这正是需要 tgt_mask 的原因。然后是 cross-attention ,字幕的每个位置查看那 16 个图像向量。
第二种很关键。生成 "sneaker" 这个词时,模型自己决定该看图像的哪个区域。像第3篇那样把一切塞进同一条序列,图像和文本就共用同一份注意力;而这里 职责被分开,各自独立优化 。字幕越长,这种分离越划算。
第3篇的 bug 为何没出现在这里
让第3篇跌到 7.5% 的元凶,是构造目标序列的函数。把两段并排看。
第3篇 (有 bug)
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 → 计入损失 |
只差一个顺序。第4篇先加 EOS 再填充,所以 EOS 总是落在答案结束的位置。模型能学会停下来,也就没碰上那个 bug。
同一个人同一天写的两段代码,只有一段错了。 这类错误在评审中很难抓到,指标也看不见 —— 第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。字幕只有 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"。说明它学的是三种结构,而不是背下一句。
冠词是对的。 an ankle boot 的 an、a pullover 的 a,以及 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 才躲过去。 要摆脱运气,就把构造目标的函数输出一次看看。 取一个批次调用 print,十秒钟的事。
下一篇转向输出是 图像本身 的情形 —— 给黑白照片上色。在那里我们会看到损失函数的选择如何改变结果的性格。
🧠 理解度自测
1. nn.TransformerDecoder 内部的两种注意力各自在做什么?
第一种是 self-attention,正在生成的字幕参照自己此前的 token —— 因果掩码正是用在这里。第二种是 cross-attention,字幕的每个位置参照 CNN 产出的 16 个图像向量,让模型自行决定写某个词时该看图像的哪个区域。
2. 为什么要混用三种字幕模板?
只用一种的话,模型可以把整句背下来。混用模板迫使它学结构:"a photo of" 之后是品名,品名之后是 "on white background"。同一张 trousers 图像生成了不同模板,证实了这一点。
3. 第3篇的 EOS bug 为什么没出现在第4篇的代码里?
第3篇用 tok(s, AL-1) + [1],先填充再追加 EOS,于是填充挤在了答案和 EOS 之间。第4篇用 [enc[c] for c in s][:CAP-1] + [1],先加 EOS 再填充,EOS 总落在答案结束处。只差一个顺序。
4. 错误集中在衬衫类的那 9%,该算作模型缺陷吗?
不能全算。Fashion-MNIST 中 shirt、t-shirt、coat、pullover 在 28×28 灰度下连人都难以分辨。这更接近数据所携带信息量的上限,也不是把模型做大就能解决的那类错误。