1. Vision Transformer与自注意力机制初探
第一次接触Vision Transformer(ViT)时,我完全被它的设计思路震撼到了。传统卷积神经网络(CNN)在图像处理领域统治多年,而ViT竟然完全抛弃了卷积操作,仅靠自注意力机制就能在图像分类任务中达到甚至超越CNN的效果。这就像用瑞士军刀代替了整个工具箱,看似简单却功能强大。
ViT的核心在于将图像视为一系列"单词"的组合。具体来说,它会将一张480x480的图片分割成16x16的小方块(共900个),每个方块展开成768维的向量。这种处理方式让我联想到小时候玩的拼图——把完整图片拆解后,模型需要自己学习如何重组这些碎片并理解整体含义。但与传统拼图不同,ViT会额外添加两个关键元素:一个是分类标记(class token),就像拼图盒上的封面图;另一个是位置编码(position embedding),相当于每个拼图块背面的编号,告诉模型它们的原始位置。
自注意力机制就像是给模型装上了"智能探照灯"。当处理第5个图像块时,这个机制会自动扫描所有其他899个图像块,判断哪些块与当前块最相关。比如在识别猫耳朵的图案时,它会自动关注到可能属于同一只猫的其他身体部位。这种全局关联能力正是CNN所欠缺的——CNN的卷积核就像管中窥豹,每次只能看到局部区域。
2. 多头自注意力机制深度解析
2.1 自注意力的数学之美
理解自注意力机制时,我习惯用图书馆检索系统来类比。假设我们要查询"深度学习"相关资料:
- 查询向量(Query)相当于我们的搜索关键词"深度学习"
- 键向量(Key)就像图书馆每本书的索引标签
- 值向量(Value)则是书籍的实际内容
计算过程分为四步:
- 将Query与所有Key进行点积,得到相关性分数
- 用softmax归一化这些分数
- 用归一化后的分数加权求和Value向量
- 最终得到包含全局信息的输出向量
用代码表示核心计算:
# 输入x的形状: [batch_size, num_patches, embed_dim] q = linear_q(x) # [B, N, D] k = linear_k(x) # [B, N, D] v = linear_v(x) # [B, N, D] attn_scores = torch.matmul(q, k.transpose(-2, -1)) / sqrt(D) # [B, N, N] attn_probs = softmax(attn_scores, dim=-1) output = torch.matmul(attn_probs, v) # [B, N, D]这个过程中最精妙的是缩放因子(sqrt(D))。当向量维度D较大时,点积结果会变得极大,导致softmax饱和。就像考试全班都考了1000分,就难以区分真实水平。缩放操作让分数保持在合理范围,确保梯度可以有效回传。
2.2 多头机制的并行智慧
单头注意力就像只用一只眼睛看世界,而多头机制则给了模型多双"眼睛"。在ViT中,通常使用12个注意力头,每个头关注不同的特征方面:
- 将输入的768维向量分割为12个64维子空间
- 在每个子空间独立计算注意力
- 最后将所有头的输出拼接起来
这就像专家组讨论:有的专家关注颜色特征,有的专注纹理,还有的分析形状。最终综合所有人的意见做出判断。实际代码中,这种并行计算通过矩阵变形优雅实现:
# 输入x形状: [B, N, C=768] qkv = linear(x).reshape(B, N, 3, num_heads, C//num_heads) # [B,N,3,12,64] q, k, v = qkv.unbind(2) # 各[B,N,12,64] attn = (q @ k.transpose(-2,-1)) * scale # [B,12,N,N] attn = attn.softmax(dim=-1) out = (attn @ v).transpose(1,2).reshape(B,N,C) # [B,N,768]我在实验中观察到,多头机制确实能提升模型性能。当把头数从12减到6时,在ImageNet上的准确率下降了约1.5%。这印证了"三个臭皮匠顶个诸葛亮"的道理——多视角分析确实能带来更全面的理解。
3. ViT的完整架构实现
3.1 Patch Embedding的魔法
ViT的第一道工序是将图像转换为序列,这个过程就像把照片撕成碎片再编号。但实际操作远比这复杂:
- 使用16x16的大步长卷积进行分块
- 每个patch展平后通过线性投影到768维空间
- 添加可学习的位置编码
这里有个容易踩的坑:位置编码是否需要插值。当测试图像尺寸与训练不同时,直接使用原位置编码会导致性能下降。我的解决方案是采用双三次插值调整位置编码网格:
pos_embed = pos_embed.view(1, h, w, -1).permute(0,3,1,2) # [1,D,h,w] pos_embed = F.interpolate(pos_embed, size=new_hw, mode='bicubic') pos_embed = pos_embed.permute(0,2,3,1).view(1, -1, D) # [1,N,D]3.2 Transformer编码器堆叠
ViT通常使用12层Transformer块,每层包含:
- 层归一化(LayerNorm)
- 多头注意力
- 残差连接
- MLP扩展层
这里MLP的设计很有讲究:先将768维特征扩展到3072维(4倍),再用GELU激活,最后投影回768维。这种"窄-宽-窄"结构就像信息先被分解再重组,能提取更丰富的特征。一个完整的Block实现如下:
class Block(nn.Module): def __init__(self, dim, num_heads, mlp_ratio=4.): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = Attention(dim, num_heads) self.norm2 = nn.LayerNorm(dim) self.mlp = Mlp(dim, hidden_dim=dim*mlp_ratio) def forward(self, x): x = x + self.attn(self.norm1(x)) # 残差连接 x = x + self.mlp(self.norm2(x)) # 残差连接 return x在实际训练中,我发现两个trick特别有用:一是使用梯度裁剪(gradient clipping)防止梯度爆炸;二是采用余弦退火学习率调度,能让模型收敛更稳定。
4. 图像分类实战技巧
4.1 数据预处理管道
ViT对数据增强比较敏感,经过多次实验,我总结出最佳组合:
- RandomResizedCrop(尺度0.8-1.0)
- HorizontalFlip
- ColorJitter(亮度0.4,对比度0.4,饱和度0.2)
- 标准化(ImageNet均值/方差)
特别要注意的是,ViT需要较大的batch size(至少256)才能发挥性能。当GPU内存不足时,可以采用梯度累积技巧:
optimizer.zero_grad() for i, (x,y) in enumerate(dataloader): loss = model(x,y) loss.backward() if (i+1) % 4 == 0: # 累积4个batch optimizer.step() optimizer.zero_grad()4.2 微调策略
当用预训练ViT做迁移学习时,我推荐分层解冻策略:
- 先冻结所有参数,只训练最后的分类头
- 逐步解冻后面的Transformer块
- 最后微调所有参数
这种"由外而内"的微调方式能有效防止灾难性遗忘。对于小数据集,还可以在Patch Embedding层后添加dropout(约0.1)防止过拟合。
在CIFAR-10上的实验表明,使用预训练ViT-Base(在ImageNet-21k上训练)只需微调1个epoch就能达到96%的准确率,而从零开始训练需要50个epoch才能达到相同效果。这充分展示了迁移学习的威力。