ViT(Vision Transformer)这几年从论文火到工程落地,我身边不少做图像识别、检测分割甚至医学影像的同行,都在把卷积网络往Transformer这边迁。核心思想就一句话:把一张图片切成一块块小patch,当作一串“图像词条”,然后丢给Transformer的注意力机制去建模全局关系。这篇文章我会从设计动机、逐层架构、参数细节、训练技巧一路讲到踩坑记录,尽量把“为什么这么做”和“实际怎么用”都讲透,适合已经懂一点深度学习、想系统理解ViT原理,或者正准备在项目里试用ViT的同学。
1. 为什么视觉任务要引入Transformer
1.1 卷积网络的局部瓶颈
卷积神经网络(CNN)统治视觉任务多年,它的核心假设是局部性:一个卷积核只看感受野内的一小块区域,靠层层堆叠逐渐扩大视野。这种设计有个潜在问题——想建立远距离像素之间的关系,必须堆足够的层,或者靠空洞卷积、特征金字塔这类“补丁”手段。一旦目标在空间上隔得远,信息传递路径就很长,梯度也容易衰减。
Transformer走的是另一条路。它的注意力机制天然是全局的:任何一个token,可以在一层内直接和所有其他token计算相关性。放到图像里,就意味着左上角的像素可以在一开始就和右下角的像素交互,长距离依赖一下子被压缩成了一次矩阵乘法。用生活类比,CNN像逐层看局部地图,Transformer是直接给你一张全球航线图。
当然,全局注意力不是免费的。它假设输入是一维序列,而图像天然是二维且高分辨率的。如果直接拿每个像素当token,一张224x224的图就有50176个token,计算量是句子任务完全没法比的。ViT的突破口在于:不按像素,按patch切图。一个16x16的patch作为一个token,224x224的图切成196个token,序列长度瞬间降到可接受范围。这个设计是整个ViT能否落地的关键。
1.2 ViT在视觉任务中的定位
ViT最初主要用于图像分类,后来被迅速扩展到目标检测(作为骨干网络)、语义分割、视频理解、多模态对齐等场景。它和CNN不是非此即彼的关系,更多时候是互补:CNN强在局部纹理和结构先验,ViT强在全局建模和尺度不敏感性。对于那种背景复杂、目标尺度差异大的数据,ViT往往比传统CNN更容易直接拿全局上下文;对于小数据集、边缘设备上的任务,CNN依然有训练稳定和推理高效的优势。
ViT的另一层价值在于“统一架构”。NLP那边用Transformer,CV这边也用Transformer,多模态模型终于可以共享同一个骨干结构,不用再为不同模态维护完全不同的特征提取器。很多跨模态任务之所以能省事,很大程度上是因为视觉特征可以用和文本一样的序列化方式表达。
2. ViT核心架构逐层拆解
2.1 Patch Embedding:把图片切成“词条”
ViT的输入处理分三步:切patch、拉平、线性投影。
假设输入是[B, C, H, W],其中B是batch size,C是通道数,H和W是图像高宽。Patch Size用P表示,经典配置是P=16。那么一张图会被切成N = H/P × W/P个patch。对于224x224的输入,N = 14×14 = 196。
每个patch的形状是[C, P, P],把它拉平成向量C × P × P,这个长度在RGB三通道下是 3×16×16 = 768。然后经过一个线性层,把768维映射到模型的隐藏维度D。这就是Patch Embedding。实现上,这一步经常用一个卷积层来做:卷积核大小等于patch size,步长也等于patch size,输出通道数设为D。这样不仅等价,而且计算效率更高,还能利用卷积在局部特征提取上的天然优势。
这里有个细节值得注意:patch重叠与否完全由步长决定。ViT原版用不重叠切分,后续很多改进版本(比如带重叠patch的变体)反而在检测分割任务上表现更好,因为边缘信息不会丢失。如果你要自己改实现,建议先从不重叠版本开始。
2.2 Position Embedding:告诉模型空间顺序
Transformer本身没有顺序感,注意力计算是集合操作,把patch顺序打乱,结果完全不变。但图像的空间结构对理解内容是必须的,所以需要加位置编码。
ViT原版用的是可学习的1D位置编码,形状是[1, N+1, D],其中多出来的1是给CLS token用的。这里有个经常被问的问题:为什么不用2D位置编码?论文做过对比,1D和2D效果差别很小。因为patch本身已经是局部区域,模型会自己学到一种近似二维的位置关系。真正影响大的不是编码形式,而是“有没有”以及“预训练和微调的尺寸一致性问题”。
实际使用中,如果微调时的输入分辨率变了,patch数量也会变,比如224变成384,patch数量从196变成576。预训练的位置编码长度是196+1,直接加不上。常见做法是对位置编码做插值(比如双线性插值),把196个位置插值到576个位置。这种做法能work,但要注意:插值会引入一点噪声,微调时需要更多epoch来适应。另一个做法是把位置编码设计成连续的傅里叶特征或相对位置编码,从源头避免插值,但这属于进阶玩法。
2.3 Transformer Encoder与多头注意力的计算细节
ViT的主体就是堆叠N层的标准Transformer Encoder,每层包含两个子层:多头自注意力(MSA)和MLP块,两个子层之间用LayerNorm和残差连接包住。
先看单头注意力的计算。输入序列X(形状[B, N+1, D])分别乘三个权重矩阵Wq、Wk、Wv,得到Q、K、V。注意力权重矩阵是Q和K的点积除以根号下D_head,再经过softmax归一化,最后和V相乘。写成公式就是:
Attention(Q,K,V) = softmax(QK^T / √d_k) V
多头就是把D维拆成h个头,每个头单独做注意力,最后拼接起来再过一个输出投影。头数h在ViT-Base里是12,每个头维度d_k = D/h = 768/12 = 64。多头的意义在于:不同的头可以关注不同关系类型,有的头负责局部纹理,有的头关注全局形状,有的头甚至学会跟踪物体边界。
MLP块一般用两层全连接加GELU激活,中间隐藏维度是D的4倍,即3072。这构成了ViT主要参数量来源。注意力层的参数来自QKV三个矩阵和输出投影,而MLP的参数是8×D²(两层:D→4D和4D→D)。以D=768为例,单层MLP参数量大约768×3072×2 ≈ 472万,比注意力层大不少。所以ViT的参数量对隐藏维度极其敏感,改D要谨慎,往往牵一发动全身。
2.4 CLS Token与分类头:序列的“浓缩袋”
ViT在输入patch序列前面额外追加一个可学习的class token,初始值也是随机初始化。这个token不携带任何具体图像内容,但在每一层注意力计算时,它会和所有的patch token做交互,相当于一个“读遍全场后汇总信息”的池化位。最后一层输出时,只取CLS token对应的向量过分类头(一个线性层或MLP)得到类别概率。
为什么不用全局平均池化(GAP)?作者在实现里用过两种方式,CLS token效果和GAP差不多,甚至略好。CLS token的好处是它可以动态分配权重去聚合信息——它不只是简单平均,而是通过学习决定哪些patch更重要。实际经验里,在没有位置编码干扰的更复杂任务上,CLS token比GAP更稳定。如果你用ViT做特征提取然后接自己的任务头,建议保留CLS token对应的输出向量,而不是把所有token平均,后者会丢失部分任务相关的选择性特征。
最后分类头一般是一个LayerNorm加一个全连接层。如果你要做的是度量学习(比如人脸识别),通常不直接接分类头,而是取CLS token输出经过L2归一化作为embedding,再接ArcFace这类损失。这个和CNN时代用瓶颈特征类似,但VIT的全局语义通常更紧凑。
2.5 参数量与计算量的简单估算
以ViT-Base/16为例,输入224x224,patch size=16,序列长度196+1=197,隐藏维度D=768,编码器层数L=12,头数12,MLP隐藏维度3072。总参数量约8600万,其中Encoder占绝大部分。
计算量上,一次前向的FLOPs约175亿(17.5GFLOPs)。其中注意力的计算量是2×N²×D,序列长度N=197时约几百MFLOPs,反而不大;大头在MLP的矩阵乘法上,因为4×D×N×D这个因子很大。这也是为什么很多加速方法会把MLP替换或剪枝——注意力虽然被讨论得多,但真正的计算和显存压力往往在MLP和Patch Embedding后的投影。这一点和很多人的直觉相反,我是实测过后才体会到的。
3. 关键设计选择与超参解析
3.1 patch size、层数和隐藏维度的取舍
ViT系列官方给出几个经典配置:Tiny、Small、Base、Large、Huge。最常用的分级是:
| 配置 | Patch Size | 层数L | 隐藏维度D | 头数 | MLP维度 | 参数量 |
|---|---|---|---|---|---|---|
| ViT-S/16 | 16 | 12 | 384 | 6 | 1536 | 约2200万 |
| ViT-B/16 | 16 | 12 | 768 | 12 | 3072 | 约8600万 |
| ViT-L/16 | 16 | 24 | 1024 | 16 | 4096 | 约3.04亿 |
| ViT-H/14 | 14 | 32 | 1280 | 16 | 5120 | 约6.32亿 |
Patch size的选择是精度和计算量的平衡。patch越小,序列越长,能表达更细粒度的空间信息,但计算量随N²增长(注意力部分)。patch=16是性价比比较高的默认值;patch=8在密集预测任务上有优势,但推理明显变慢;patch=32省计算,但分类精度会下降几个点。我的建议:先跑patch=16,如果任务对细节敏感(比如医学影像小病灶分割),再跑patch=8和patch=14做对比。
隐藏维度D决定了模型的容量。D翻倍,参数量大约翻4倍(因为MLP和QKV投影都是平方量级)。所以在显存有限时,优先减少层数而不是盲目降低D。小数据集上建议用ViT-S或自蒸馏出来的小型变体,直接用ViT-L基本必过拟合。
3.2 为什么能抛弃卷积:归纳偏置与让数据说话
CNN自带归纳偏置:局部性、平移等变性、层级结构。这些先验让小样本学习容易,但也限制了模型对大范围空间关系的建模灵活性。ViT几乎没有视觉归纳偏置,开局只靠一个可学习的patch投影,所有结构都靠数据学。
这带来了一个有趣现象:在ImageNet级别的大规模数据上,ViT能超过同量级CNN;但在中小规模数据集上,ViT常常打不过ResNet。原因很简单——没有归纳偏置意味着模型要自己从数据里去发现规律,数据量不够就学不到。所以有一类工作是“给ViT注入轻量级CNN偏置”,比如把Patch Embedding改成卷积块、在注意力里加入局部窗口偏置,效果和纯ViT相比在小数据上提升明显。
实操上,如果你只有几万张图,想用Transformer结构,两条路:第一,用在大规模数据集上预训练好的ViT权重做微调;第二,使用带偏置的变体(比如在qkv上加入卷积位置编码的版本)。千万别从零随机初始化训练ViT-B,我见过很多新手上手就这么干,结果训练loss死活降不下去,回头怀疑代码写错了,其实是数据量和模型容量不匹配。
3.3 迁移学习:预训练权重、分辨率调整与位置编码插值
ViT实战最常用的路径是:拿一个在大规模图像数据集上预训练好的权重,微调到自己的任务。微调时有几个关键点:
第一,分类头要换掉。预训练的分类头是针对原来类别数的,你需要去掉最后一层,换上你自己的分类头。注意ViT的LayerNorm和CLS token都是预训练状态的一部分,最好保留,不要重新初始化。第二,优化器要换。预训练通常用AdamW,微调推荐用SGD(momentum=0.9)或者AdamW但学习率调低一个量级,比如原学习率5e-4,微调用5e-5起步,配合cosine decay。第三,数据增强强度要降。ViT在ImageNet上预训练用了很强的增强(RandAugment、MixUp、CutMix),微调时如果还用同样强度,权重会被过度扰动,反而学不好。
关于分辨率调整,前面提到位置编码插值。具体做法是:把预训练的pos_embed(形状[1, 197, 768])先拆掉CLS token对应的那个向量,剩下[1, 196, 768],然后把它reshape成[1, 14, 14, 768],插值到目标尺寸(比如[1, 24, 24, 768]),再拉平回[1, 576, 768],最后拼回CLS token的向量。插值方法用双线性即可。有一点要留意:插值后模型在初期可能出现震荡,建议微调时把前几个epoch的learning rate设得很低,或者用一个warmup阶段来过渡。
4. 从原理到落地:训练一个ViT的完整流程
4.1 数据准备与增强策略
ViT对数据增强比CNN更敏感。基于我自己在多个数据集上的实验,最稳定的一个增强组合是:RandomResizedCrop(尺度0.08到1.0,宽高比3/4到4/3)、随机水平翻转、RandAugment(强度10左右)、RandomErasing,后面按需加MixUp和CutMix。
MixUp和CutMix对ViT的收敛有奇效,因为它们强迫模型在全局层面混合信息——这正好发挥Transformer全局建模的优势。我常用的比例是MixUp系数0.8、CutMix系数1.0,两者按概率各半触发。CutMix尤其重要,因为它保留原始图像的局部结构,对ViT学习局部细节帮助很大。
另一个容易被忽略的点是归一化。图像输入要用数据集的均值和标准差做标准化,这个和CNN时代一样。但ViT对输入尺度的连续性更敏感,训练和推理的插值方式最好一致。也就是说,训练时用双线性缩放,推理时也用双线性缩放,别训练用双线性、推理用最近邻,这种低层不一致会导致指标掉点。
4.2 优化器与学习率调度
ViT训练的默认选项是AdamW,betas取(0.9, 0.999),weight decay一般偏大。预训练阶段weight decay可以到0.1甚至0.3,微调阶段建议降到0.05左右。学习率需要配合batch size调整,经验法则:batch size 1024时,峰值学习率约1e-3;batch size 512时,峰值约5e-4。过高的学习率会让注意力矩阵出现NaN,过低的则训练进展极慢。
学习率调度强烈建议用warmup+cosine decay。warmup步数通常取总步数的5%到10%。warmup存在的意义是:Transformer在初始化阶段对学习率极其敏感,一上来就用大学习率,LayerNorm会把它放大,容易震荡。我遇到过好几次loss在warmup阶段不降反升,把warmup步数加长、学习率调低后就稳定了。如果显存不够用大batch,可以考虑梯度累积,等效batch变换后注意同步调整学习率。
还有一个实操技巧:对偏置项和LayerNorm参数单独设置低weight decay,甚至设为0。这能减少后期微调时的漂移。很多开源实现里会单独传一个parameter group给optimizer,细节看代码就能发现。
4.3 损失函数与蒸馏:小模型也能获得大模型能力
ViT做分类,最常用的损失函数是交叉熵。但如果你想把大ViT的能力迁移到小ViT或轻量模型上,蒸馏技术非常关键。蒸馏的核心是让小模型的输出分布去拟合大模型的输出分布,而不是死死咬住one-hot标签。具体做法:大模型生成soft label(温度参数T通常取3到5,把logits软化),小模型用KL散度去逼近这个分布。温度越高,soft label包含的类间相似性信息越丰富,小模型能学到的“为什么这张图像猫而不像狗”的知识就越多。
这里有一个细节很多人没注意:蒸馏时要选好匹配层。如果教师模型和学生模型的隐藏维度不同,不能直接对特征图做L2损失。可行方案是只在最后的logits层做蒸馏,或者用一层线性投影把学生特征映射到教师维度后再做相似度损失。后者的效果好一些,因为它能传递中间层的结构化语义,但实现复杂度也更高。我建议先在logits层蒸馏,跑通后再加特征层,因为特征层蒸馏的学习率需要更低,不然学生模型的底层表征会被打乱。
另外,自蒸馏也不失为一种可靠方案:自己训练一个大ViT,然后蒸馏回一个同架构但更小的ViT。比直接从零训小模型通常能提高一到两个点。这种做法在我做移动端模型部署时用的很多。
5. 常见问题与排查技巧实录
5.1 显存溢出与序列太长怎么办
ViT最消耗显存的地方之一是注意力矩阵[B, heads, N, N]。当输入分辨率大、N达到1024甚至4096时,单卡可能直接OOM。解决办法按优先级排列:第一,减小batch size;第二,用混合精度训练,显存立刻减半甚至更多;第三,切换到窗口注意力或稀疏注意力变体,把全局注意力限制在局部窗口内;第四,实在要全局建模,可以用Flash Attention思想——不显式存储完整注意力矩阵,在线计算softmax和加权求和,把中间态省掉。前两个是日常最常用的,后两个属于工程优化。
需要明确的是,ViT的显存瓶颈常常不在注意力矩阵,而在MLP激活值。因为MLP的中间维度是D的4倍,激活值很大。如果显存不足,还可以在MLP里加dropout或者减少MLP倍率(比如从4降到2),代价是模型表达能力略降。拿一个我调过的例子来说:输入448分辨率,ViT-B,单卡12G,开启混合精度后勉强训练,再把MLP倍率降到3后,显存占用从11.8G降到9.2G,精度只掉了0.3个点。
5.2 小数据集上ViT训不动怎么办
这是被问得最多的一个问题。现象是:在小数据集(几千到几万张图)上,ViT从零训练,accuracy一直在低位徘徊,完全不如同量级ResNet。原因我前面说了,主要是缺少归纳偏置。
解决方案实测有效的前三名:
- 用预训练ViT权重做微调,这是性价比最高的操作。哪怕是在通用大规模图像数据集上预训练的,迁移过来也比从零训练好得多。
- 使用强数据增强,特别是CutMix和MixUp,能有效缓解全局Transformer在小数据上的过拟合。
- 降低模型容量,换ViT-Tiny或ViT-S,或者把patch size加大(从16改到32),减少序列长度和参数量。
还有一种做法是“混合架构”:用CNN做浅层stem,把特征图下采样后再送入Transformer。这种设计保留了CNN的低层纹理提取优势,又拿到了Transformer的全局建模能力,在中小数据集上比其他方案更耐造。如果你要研发一个实际项目,且数据规模不大,可以考虑这条路。
5.3 位置编码到底学到什么
很多人好奇位置编码内部到底是什么。我做过可视化实验,把训练好的位置编码按余弦相似度排列,会发现它学到了一种近似二维平滑的结构:相邻patch的位置编码很相似,远处patch的编码逐渐不同。这印证了ViT的位置编码确实在编码空间坐标,但不是以人类能直观理解的坐标网格形式,而是某种嵌入空间的距离关系。
有一个有意思的现象:如果把预训练模型的位置编码全部随机重置后固定,模型性能并不会崩塌,甚至微调一会就能恢复到接近原来的水平。这说明ViT对精确位置信息的依赖比想象中弱,位置编码更多是提供一个初始的“顺序提示”。不过实际使用中不要因此就省掉位置编码——在位置编码缺失的情况下,模型会把patch顺序当成人格无关的袋子来处理,性能会出现系统性下降。
5.4 推理加速与模型压缩的几条路径
ViT部署到实际产品时,常遇到推理速度不达标的问题。可以考虑这几个方向:
- 剪枝:注意力头冗余度很高,剪掉一部分头对精度影响很小;MLP维度也可以按重要性剪枝。剪枝后需要微调恢复。
- 量化:INT8量化对ViT是可行的,但要注意LayerNorm和softmax对量化误差较敏感。实操上,把LayerNorm和最后的分类头保留FP16或FP32,只对卷积和线性层做INT8量化,效果更稳。
- Token剪枝:推理时动态去掉一些信息量低的patch token,可以减少注意力计算量。这个技巧尤其适合图像中有大量背景的场景,比如监控画面,背景patch往往占用一半以上计算量。
- 知识蒸馏:把大ViT蒸馏到小ViT或CNN,前面已经说过,这是部署环节最有效的压缩方式之一。
就我自己的部署经验,一个ViT-B模型在CPU上跑224x224单张图片,优化后可以和同规模的CNN打个平手,但在GPU上,如果batch够大,ViT的并行优势会更明显。所以如果你服务端有GPU且并发较高,ViT的性价比会进一步提升。
最后说点经验之谈
ViT让我印象最深的一点是“规模效应”。它在小规模数据上不是那么惊艳,但一旦预训练数据量和模型规模上去,它的提升曲线比CNN走得更远。所以做实验时不要单看一两个小数据集上的结果,有条件最好先在中等规模数据上把预训练长度练足,再判断模型能力。另一个体会是工程细节比理论更磨人——同样的ViT,数据增强策略、学习率调度、混合精度设置不同,效果差好几个点,绝不是跑通就完事。我后面还会专门整理一下ViT变体和分布式训练的相关经验,下次再聊。