☰
ViT论文深度解读:架构原理、实验分析到PyTorch复现避坑
2026/10/22 16:34:36 网站建设 项目流程

最近又把 ViT 这篇《An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale》翻出来读了一遍。原因很简单:这两年 Transformer 在视觉领域已经快成默认起点了,从检测到分割再到视频理解,到处都能看到它的影子,但很多朋友讨论问题的时候,其实对原始论文里的设计动机并不清楚。我自己做图像分类项目从 CNN 迁到 Vision Transformer 也踩过不少坑,回头重读原文才发现不少细节论文里其实早就有提示,只是当初读得太快没当回事。

这篇笔记想把论文从动机、架构、实验到复现踩坑完整过一遍。如果你正准备第一次读 ViT,或者已经用过 ViT 但不清楚里面每个部件为什么存在、训练时该怎么设参数,那这篇文章应该能帮你省下不少时间。我不打算做逐段的论文翻译,而是把关键设计拆开讲透,再加上实操层面的经验,争取让你读完既能理解论文,也知道怎么做实验。

1. 论文到底在解决什么问题

1.1 为什么非要用 Transformer 处理图像

ViT 论文的核心主张,用作者的话来说就是:图像识别并不一定要依赖 CNN 里的那些卷积操作,一个纯粹基于自注意力的架构,只要给它足够多的数据去训练,就能在 ImageNet 这种大规模基准上达到甚至超过 CNN 的效果。这句话放到今天看好像没什么稀奇,Transformer 都在做各种视觉任务了,但你要回到 2020 年,当时的主流共识还是"卷积天然适合图像,Transformer 只是 NLP 的玩具"。

CNN 的成功建立在两个归纳偏置上:局部性和平移等变性。卷积核只感知局部区域,通过堆叠层来扩大感受野;同一套卷积核在图像不同位置共享,所以猫在图片左上角还是右下角都能被认出。这种先验让 CNN 在数据量有限时也能学得很好,但反过来也限制了它的上限——它天生就看不到全局关系,或者说得更准确一点,要看全局需要把感受野堆得足够大,效率很不划算。

Transformer 几乎没有这种归纳偏置。自注意力机制随便来两个位置都能直接计算依赖关系,第一层就可以看到整张图;它也不假设位置平移等变,所以需要一个额外的位置编码把"像素在哪"告诉模型。用大白话讲,CNN 像一个靠本地邻居消息传话的网络,而 ViT 直接把所有人拉进一个会议室,让信息自由流动。这样更灵活,但代价就是模型得从纯数据里把图像结构重新学一遍,这也是为什么 ViT 在大数据上能超过 CNN,在中小数据上反而打不过。

1.2 从图像到词序列:patch 的直觉

NLP 里的 Transformer 拿到的输入是一串 token,每个 token 是文本里的一个词或者子词。图像怎么往这个框架里套?这是整篇论文设计中最关键的一次决策。

作者尝试过几乎像素级别地把每个像素当作一个 token,但这有两个不可接受的问题。一是序列长度爆炸,一张 224x224 的图就有 50176 个像素,注意力复杂度是序列长度的平方,根本算不动。二是单个像素的语义太弱,一个像素本身学不到什么有意义的特征,靠它去构建全局关系缺乏中间层的结构支撑。所以作者提出了 patch embedding 的思路:把一张图切成一个个 16x16 的小块,每个小块展平后做一次线性映射,就得到一个 token。

16x16 这个数字不是随便选的。在 224 分辨率下,16x16 的小块恰好生成 196 个 token(224/16 等于 14,14x14 等于 196),和 NLP 场景里常见的序列长度在数量级上对得上。同时,16x16 的 patch 已经包含了足够的局部纹理和结构信息,相当于把"像素级"的原始输入提升到了"语义颗粒度"的输入,模型第一层就能在局部结构和全局关系之间找一个平衡点。这个思路本质上和我们看马赛克拼图很像:每一块虽然只是局部图案,但拼到一起能看出完整的画面,而且你一眼扫过去最先建立的是整体轮廓而非单块细节。

1.3 ViT 与 CNN 的本质差异

接下来要分清 ViT 和 CNN 到底差在哪儿,这个理解决定了你训练和使用它们的方式。

  • CNN 的感受野是从小到大逐层扩大的,浅层看边角,深层看形状,天然符合"由局部到整体"的视觉处理习惯。但 CNN 对这种先验的依赖很强,一旦某个特征超出了现有感受野,就要继续堆层,运算量很快就上去了。

  • ViT 每一层都是全局交互。任意两个 patch 之间可以直接建立依赖,不管它们在图上隔多远。等于第一层就已经把"全局信息和局部信息"混合在一起,后面层继续提炼关系。这既是优势也是劣势——没有 CNN 那种平滑的渐进式抽象,所以它对数据量特别贪婪。

  • CNN 的平移等变性让它在小数据上表现稳定,ViT 没有这个免费午餐,所以论文里反复强调要在超大规模数据上预训练。数据量不够时,ViT 学到的关系实际上是"偷懒"的,它可能硬记住了一些训练集特有的伪模式,泛化性自然就差了。

我用一个项目举例。之前做一个商品图像分类任务,训练集只有不到十万张图,直接从头训 ViT,结果验证集准确率比同条件下的 ResNet 低了大概三个点,而且 loss 波动明显。后来换成熟预训练权重做微调,同一个 ViT 反而比 ResNet 高了两个点。这个体验和论文里的表 1 结论完全一致:性能表现跟随预训练数据规模的曲线走,数据少,CNN 赢;数据多,Transformer 赢。

2. 关键架构细节拆解

2.1 Patch Embedding 的实现与数学本质

假设输入图像为 x,尺寸是 H×W×C。ViT 的 patch embedding 过程可以分三步走。

第一步,把 H×W 的平面切成若干 P×P 的小块。如果 H 和 W 都等于 224,P 等于 16,那么每一维上有 14 个小块,总块数 N 等于 14×14=196。每个小块原始维度是 16×16×3=768。

第二步,把每个小块展平成向量。展平后的维度是 768,对应每个 patch 的所有像素值排在一起。

第三步,用同一个可学习的线性映射把 768 维向量投影到模型宽度 D 维度。论文默认设置 D 等于 768,这一步等于一个全连接层,输入维度 768,输出维度 768。如果你选更大的模型,比如 ViT-Large,那 D 可能是 1024,这一步的输出维度就变成 1024。

公式层面,等价于 x_patch 乘以一个形状为 (P²C, D) 的权重矩阵,再加一个可学习的偏置。在 PyTorch 里,最干净的做法是用一个 Conv2d 层来实现:

import torch import torch.nn as nn class PatchEmbed(nn.Module): def __init__(self, in_channels=3, patch_size=16, embed_dim=768): super().__init__() self.patch_size = patch_size # 卷积核大小和步长都设为 patch_size,效果等价于切 patch + 线性投影 self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: (B, 3, H, W) -> (B, embed_dim, H/P, W/P) -> (B, N, embed_dim) x = self.proj(x) x = x.flatten(2).transpose(1, 2) return x

这里用卷积一步实现切块和线性投影,是因为当一个卷积核的尺寸和步长恰好等于 patch_size,且输入通道到输出通道的映射是线性时,它所做的运算和"展平 patch 再过全连接"是完全等价的。理解这一点对后续做分辨率适配很有帮助,因为改输入分辨率时,只需要保证 H 和 W 都能被 patch_size 整除就行。

2.2 位置编码与 CLS token 的作用

Transformer 本身对输入的序列顺序不敏感,打乱 patch 的顺序之后,自注意力计算出来的结果完全一样。但图像的空间信息显然是重要的——上面和下面对模型的意义完全不同。所以 ViT 加入了一个可学习的 1D 位置编码,形状为 (N+1, D),这里的 N+1 是 196 个 patch token 加 1 个分类 token。

论文里对比过几种位置编码方案:可学习的 1D 编码、可学习的 2D 编码、以及正弦余弦的 2D 编码,结果发现它们在最终精度上几乎没有差别。这一条实验结果经常被人忽略,但它很有意思:这说明模型其实可以靠内容本身判断两个 token 的位置关系,并不需要刻意把位置信息做得特别精细。就像你在一张照片中看到天空在图片上方、地面在下方,即使不给你坐标信息,你也能根据内容推断出相对位置。所以作者最终选了最简单的可学习 1D 编码,避免引入不必要的复杂度。

CLS token 的设计则直接借用了 BERT。在输入序列的最前面放一个特殊的 token,它在所有层里都参与注意力计算,最终分类的时候只取这个 token 对应的输出向量,过一层 LayerNorm 再接一个线性分类头。为什么不把 196 个 patch token 的输出做平均或最大池化?作者在实验里试过,用 CLS token 和用全局平均池化效果都非常接近,但 CLS token 有一个好处:它的角色一目了然,并且后续如果要做一些跨模态或 dual-encoder 的项目,CLS token 直接充当全局表征,用起来很方便。我自己做图文匹配的时候就是直接取 CLS token 当特征向量去算相似度,省去了池化的步骤。

2.3 多头自注意力的计算流程

自注意力是 ViT 每个 Block 的核心。设输入序列为 Z,形状是 (N+1, D)。首先通过三个独立的线性层计算出 Q、K、V,形状都是 (N+1, D):

# 伪代码,仅用于展示计算逻辑 Q = Z @ W_q # (N+1, D) @ (D, D) -> (N+1, D) K = Z @ W_k V = Z @ W_v

接着把 Q、K、V 按头数 h 拆开,每个头的维度变成 D/h。对每个头独立计算注意力权重:

# 每个头里计算 scores = Q_head @ K_head.T / sqrt(D/h) # 缩放点积注意力 weights = softmax(scores, dim=-1) output_head = weights @ V_head

最后把所有头的输出拼接回去,再过一层输出线性投影。这就是 Multi-Head Self-Attention 的完整流程。缩放因子 sqrt(D/h) 是为了防止点积结果过大导致 softmax 梯度过小,这一点在 Transformer 原始论文里就解释过,ViT 原封不动搬了过来。

每个 Block 除了注意力,还有一个 MLP 模块,由两个全连接层夹一个 GELU 激活组成。第一个全连接把维度从 D 扩到 4D,第二个再缩回 D。这个 4 倍扩展比例也是经典配置,实践证明在宽度和容量之间取了一个不错的平衡。每个 Block 都用了 Residual Connection 和 LayerNorm,顺序是 LayerNorm 在前、注意力或 MLP 在后,也就是 Pre-LN 结构。Pre-LN 的好处是训练更稳定,能支持更大的学习率,论文里从头训练 ViT 能训到 300 个 epoch 不炸,和这个结构选择密切相关。

3. 实验设计与关键结论

3.1 预训练数据规模如何改变结论

论文里最著名的一组实验是拿 ViT 在三个不同规模的数据集上训练,再对比 BiT(Big Transfer,一个大规模预训练残差网络)和基于 ResNet 的其他变体。

在中等规模数据集(相当于百万级图片的公开基准)上,ViT 只能拿到中上成绩,和同容量的 ResNet 相比并没有优势,甚至某些设置下略差。这个现象的原因前面提过:Transformer 没有空间归纳偏置,数据量不足以让模型从零学会图像结构,它就倾向于欠拟合或者过拟合。

但当预训练数据规模提升到数亿张图片级别时,情况彻底反转。ViT 的准确率超过了同等甚至更大规模的 CNN 模型,而且训练所需的算力比 CNN 更少。作者在论文里给了非常直白的解释:卷积等价于一种受限的自注意力,它只在一个局部窗口内计算,而且权重共享方式固定;如果让 Transformer 在大数据上自由学习,它可以自己学会卷积的行为,也可以发现比卷积更好的交互方式。

这个结论对实际项目有直接指导意义。我自己之前遇到过数据量不够但想强上 Transformer 的场景,正确做法不是改模型结构,而是先问自己:能不能找到一个足够大的预训练数据集,或者干脆用公开的大规模预训练权重做初始化。否则你亲手训出来的 ViT 很可能让你怀疑人生。可以说,预训练规模这个变量,比 ViT 模型本身任何超参数都重要。

3.2 微调阶段的分辨率调整与位置编码插值

ViT 的标准做法是先在 224×224 分辨率下预训练,然后在微调阶段把输入分辨率提高到 384 或 512。这里有一个细节特别值得注意。

当分辨率提高时,patch 大小保持 16×16 不变,所以 patch 数量会变多。比如从 224 提到 384,patch 数量就从 196 变成 576。预训练时的位置编码长度为 197(196 加 CLS),现在序列长度变成 577,直接对不上。ViT 的解决办法是对预训练的位置编码做 2D 插值,再配合微调训练让模型适应新分辨率。

我第一次复现的时候踩过一个坑:想当然地对位置编码矩阵做简单的一维线性插值,结果准确率掉了好几分。后来按论文的方式,先把它 reshape 成 14×14 的 2D 形态,再双线性插值到 24×24,最后展平,效果才正常。为什么这样对了?因为图像的空间关系是二维的,对行方向和列方向分别插值,符合图像的几何直觉;直接按一维插值会把相邻行之间的距离关系搞乱,模型丢失了原有的位置先验。

微调的时候还有一个细节:分辨率提高后,序列里 patch token 之间的交互距离变了,但实际上只需要微调十几到几十个 epoch 就能收敛得不错,因为初始化权重已经包含了很强的特征表达能力。论文里给的微调学习率远小于从头训练的学习率,同时禁用了一些强正则化,比如把权重衰减调低或者不用 Mixup,以免破坏已经学好的特征。

3.3 不同模型规模下的表现对比

ViT 论文定义了几个标准模型规格:ViT-Base(D=768,12 个 Block,12 个头,参数约 86M)、ViT-Large(D=1024,24 个 Block,16 个头,参数约 307M)、ViT-Huge(D=1280,32 个 Block,16 个头,参数约 632M)。论文用 JFT 这种数亿级数据集预训练之后,在 ImageNet 上微调,ViT-Large 已经超过当时最好的 BiT 和 EfficientNet 变体,ViT-Huge 则更进一步。

一个容易忽视的点是,大模型的训练成本并不像参数数量看上去那么吓人。作者在论文中对比过训练吞吐量:同等精度下,ViT-Large 比 ResNet 系列的训练成本低了不少,因为 GPU 对矩阵乘法特别擅长,Transformer 的全局结构让它在张量核心上利用得非常充分。这一点对工程选型很有参考价值:同样算力预算下,与其堆一个超深的卷积网络,不如试试用张量并行跑 ViT,吞吐量和效果可能都更好。

4. 实操复现:配置、代码与避坑

4.1 推荐训练配置与超参数选择

从零训练一个小规模 ViT 用来做实验,我建议按下面这套配置开始,它在稳定性和效果之间比较平衡:

# 优化器:AdamW # 学习率:0.001,使用 10 个 epoch 线性 warmup # 权重衰减:0.05 # batch size:1024(如果显存不够就减小,同步调低学习率) # 训练轮数:300 epoch # 数据增强:Rand-Augment + Mixup + CutMix # 标签平滑:0.1 # 随机深度:0.1

ViT 在小数据集上从头训练经常不收敛,我把最大头的两个原因列一下:

  • Batch size 太小。ViT 对 batch size 比较敏感,batch 太小的时候,BatchNorm 或 LayerNorm 统计不稳定,容易过拟合。实测在 8 卡 GPU 上,batch size 低于 256,ViT-Base 的表现就会下滑明显。

  • 学习率太高且没有 warmup。Transformer 类模型前期梯度震荡剧烈,如果没有 warmup 直接跑大学习率,loss 很容易冲到 NaN。我一般至少留 5 到 10 个 epoch 做线性 warmup,learning rate 从 0 平缓升到目标值。

4.2 一个可用的 PyTorch 核心代码

下面是一份精简但可以训起来的 ViT 核心结构,适合二次魔改。我把分类头单独抽出来,方便你自己适配不同任务。

import torch import torch.nn as nn from einops import rearrange class Attention(nn.Module): def __init__(self, dim, heads=12, dropout=0.): super().__init__() self.heads = heads self.scale = (dim // heads) ** -0.5 self.qkv = nn.Linear(dim, dim * 3) self.attn_drop = nn.Dropout(dropout) self.proj = nn.Linear(dim, dim) self.proj_drop = nn.Dropout(dropout) def forward(self, x): B, N, C = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.heads, C // self.heads) q, k, v = qkv.permute(2, 0, 3, 1, 4) attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) x = (attn @ v).transpose(1, 2).reshape(B, N, C) return self.proj_drop(self.proj(x)) class Block(nn.Module): def __init__(self, dim, heads=12, mlp_ratio=4., dropout=0.): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = Attention(dim, heads, dropout) self.norm2 = nn.LayerNorm(dim) self.mlp = nn.Sequential( nn.Linear(dim, int(dim * mlp_ratio)), nn.GELU(), nn.Dropout(dropout), nn.Linear(int(dim * mlp_ratio), dim), nn.Dropout(dropout), ) def forward(self, x): x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x class ViT(nn.Module): def __init__(self, img_size=224, patch_size=16, in_channels=3, num_classes=1000, dim=768, depth=12, heads=12, dropout=0.): super().__init__() self.patch_size = patch_size self.num_patches = (img_size // patch_size) ** 2 self.patch_embed = nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size) self.cls_token = nn.Parameter(torch.zeros(1, 1, dim)) self.pos_embed = nn.Parameter(torch.zeros(1, self.num_patches + 1, dim)) self.pos_drop = nn.Dropout(dropout) self.blocks = nn.Sequential(*[ Block(dim, heads, dropout=dropout) for _ in range(depth) ]) self.norm = nn.LayerNorm(dim) self.head = nn.Linear(dim, num_classes) def forward(self, x): B = x.shape[0] x = self.patch_embed(x) # (B, dim, H/P, W/P) x = rearrange(x, 'B D H W -> B (H W) D') cls_tokens = self.cls_token.expand(B, -1, -1) x = torch.cat([cls_tokens, x], dim=1) x = x + self.pos_embed x = self.pos_drop(x) x = self.blocks(x) x = self.norm(x[:, 0]) # 取 CLS token 输出 return self.head(x)

这份代码的 patch embedding 用 Conv2d 实现,后面直接接 Transformer Block。注意init_weights部分没有写,实操里我习惯用截断正态分布初始化所有线性层,并把 pos_embed 和 cls_token variance 设成约 0.02,否则训练前期 attention 输出方差会太大,导致 loss 下降奇慢。

4.3 ViT 比 CNN 更依赖正则化与数据增强

前文提到,ViT 缺少卷积的归纳偏置,所以它天生需要更强的正则化手段来防止过拟合。论文里的实验设置其实已经给出了答案:Rand-Augment、Mixup、CutMix、标签平滑、DropPath 一起上。

这些手段具体加多少,我建议参考原论文的做法:中等数据规模下,Mixup alpha 设为 0.8,CutMix alpha 设为 1.0,Rand-Augment 的幅度设为 9,标签平滑系数 0.1。如果你要在自定义数据集上复现,先照抄这个配置再逐步调整,比从头猜参数靠谱得多。

DropPath 在 ViT 里是一个很有用的正则化工具,它按概率在训练时随机丢弃整个残差分支,等效于在深度方向上做集成。对于深层 ViT,DropPath rate 设 0.1 到 0.3 对最终精度有明显帮助。很多蒸馏类 ViT 模型的技术报告里也都保留了这个设置,可见它是普遍有效的。

5. 常见问题与排查记录

5.1 小数据集上训练 ViT 不收敛,怎么办

这个问题我几乎每周都能在技术社区看到人问。在几千张图片的数据集上从头训 ViT,结果 loss 下降很慢或者干脆发散。这属于正常现象,而不是代码写错了。

我的建议按优先级排列:

  1. 换用预训练权重做初始化,然后在目标数据上微调,而不是从头训练。
  2. 如果用不了预训练权重,那就别再自己搭深层 Transformer,试试浅层加宽的结构,比如 6 个 Block、宽度 512,再配强数据增强。
  3. 把学习率降到 3e-4 以下,用 large batch 加 warmup,先把 loss 训到一个正常的下降曲线上再考虑提升精度。

我踩过的具体例子是,用两万张图从头训 ViT-Small,前期 loss 一直在 3 到 4 之间徘徊,降到 0.001 并给足 warmup 之后曲线才开始正常下降。ViT 对学习率的敏感程度远超 CNN,这算是它最反直觉的一点。

5.2 位置编码插值后模型掉点

上述问题通常出现在用 ImageNet 预训练权重迁移到更高分辨率输入时。此时位置编码的序列长度不匹配,需要对 pos_embed 做插值。

正确的操作分成两步:

  • 把 pos_embed 中的 CLS token 分离出来,只对剩下的 patch token 位置编码进行插值。
  • 把 patch token 位置编码 reshape 成二维网格,比如从 (14, 14) 插值到 (24, 24),使用双线性插值,再和 CLS token 位置编码拼接回去。

注意插值之后要接着微调一定轮数,让模型适应新的位置编码分布。不能插值完就不训练直接评估,那样位置信息对不上,模型精度一定受损。我自己实验里,微调 20 个 epoch 内,插值插得好不好能拉开两个点左右。

5.3 显存占用太大,训练跑不起来

ViT 在大分辨率输入下序列长度会变长,注意力矩阵的显存占用和数据规模成正比。比如输入分辨率从 224 提到 384,序列长度从 196 变成 576,注意力矩阵大小大约是原来的 3 倍,显存压力非常大。

对应解决办法有三个:

  • 用 FlashAttention 这类融合算子,它不显式构造完整注意力矩阵,而是分块计算,能把显存占用降一个量级。
  • 开启梯度检查点(gradient checkpointing),相当于用少量计算换显存,forward 的时候不保存中间激活,backward 时重新算一遍。
  • 减小 batch size 或者用混合精度训练,这也是最直接的降显存手段。

在推理阶段,如果显存也紧张,可以试试用一个较小的 patch size 替代大 patch size,但要注意这样会改变输入序列长度,和预训练权重的配置不完全兼容,必要时还得配合位置编码插值。

5.4 ViT 推理速度是否真的比 CNN 慢

这个问题要分开看。ViT 的全局自注意力在序列长度短的时候计算量不算夸张,但 patch 数量多起来之后,复杂度呈平方增长。所以在 224 输入、patch 16 的情况下,ViT-Base 的推理速度和同量级的 ResNet-50 相比不相上下,而且 GPU 上的吞吐量甚至可能更高。但到了 384 甚至 512 分辨率下,ViT 的推理成本会明显高于同精度 CNN。

工程上如果要在边缘设备上部署,最好先考虑蒸馏方案:用 ViT 做教师模型去蒸馏一个轻量 CNN 或小型 ViT,精度损失可控,速度和部署友好度大幅提升。这个方案我实际做过,效果不错,教师的全局建模能力确实能迁移到学生模型上。

6. 论文之外的个人体会

读 ViT 论文你会发现,它最大的价值不只是提出了一个新架构,而是做了一个非常清晰的实验论证:归纳偏置不是免费的,也不是越多越好。CNN 内置的局部性假设在小数据上很管用,但在大数据场景下反而可能限制了模型的上限。ViT 敢于去掉这些先验,然后用数据和算力去弥补,最终在超大规模预训练下实现了反超。这个思路对后来很多视觉架构的设计产生了深远影响,也让我在做模型选型时多了一个判断维度:你的数据量,到底撑不撑得起一个"少归纳偏置"的模型。

最后分享一个小技巧:如果你项目里数据量不多,但就是想用 Transformer 结构,可以先别急着从头训,也别急着改网络结构。先把数据增强策略堆足,再去一个大模型做蒸馏或者直接用预训练权重微调。我实验多次,发现这一套组合拳通常比你在结构上折腾来折腾去更有效。ViT 的很多成功实践,说白了都是围绕"数据喂够了吗"和"正则化做足了吗"这两个问题展开的。抓住这两点,你的 Transformer 视觉项目就成功了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询