☰
Vision Transformer(ViT)原理与PyTorch实现:从patch到自注意力
2026/10/22 8:06:15 网站建设 项目流程

Vision Transformer(ViT)这几年在图像分类领域已经不是新鲜词了,但很多人第一次接触它的时候,都会被“把图片切成 patch 再当序列处理”这个思路绕晕。老实说,我第一次看 ViT 论文的时候也琢磨了很久——明明 CNN 在处理图像上已经那么成熟了,为什么还要用一个原本给 NLP 用的 Transformer 来做图像分类?这篇文章我就从实际干活的角度,把 ViT 的原理、实现、训练避坑一次性讲清楚。内容覆盖从 patch embedding 到 encoder 的完整流程,也包含我自己的 PyTorch 实现经验和调参心得,适合已经会用 CNN 做分类、想搞懂 ViT 内部机制的人群,也适合准备在项目里实际用 ViT 的开发者。

1. ViT 到底解决了什么问题

1.1 从 CNN 到 Transformer 的思路跃迁

CNN 处理图像的核心假设是局部性(locality)和空间不变性(translation invariance)。卷积核只在局部区域滑动,通过堆叠层数逐步扩大感受野。这个设计在 ImageNet 这种大规模数据集上被验证了十几年,非常可靠。但它的缺点是明显的:要捕获图像中相距很远的两个区域的关系,必须把网络做得非常深,或者依赖空洞卷积、特征金字塔这些额外设计。长距离依赖建模对 CNN 来说始终是个绕弯子的事情。

Transformer 的思路则完全不同。自注意力机制让任意两个位置之间可以直接建立联系,一步到位,不存在感受野慢慢扩大的过程。ViT 把图像切成一个个 patch,把每个 patch 当作一个“词”,然后用标准 Transformer encoder 去建模 patch 与 patch 之间的全局关系。换句话说,图像被“翻译”成了一段序列,剩下的交给注意力机制去发现哪里重要、哪里和哪里相关。

1.2 一句话概括 ViT 的做法

ViT 的工作流程可以压缩成四步:先把图像切成固定大小的 patch(比如 16x16),然后通过线性投影把每个 patch 变成一个向量;接着加上位置编码,让模型知道每个 patch 在图像里的位置;然后送入多层 Transformer encoder,让 patch 之间充分交换信息;最后取出序列最前面的 class token,接一个分类头输出类别概率。

整个模型几乎原封不动地搬了 NLP 里 BERT 的架构,唯一的图像相关改动就是最前面的 patch embedding。这也是 ViT 最“反直觉”的地方:一个完全不懂图像先验结构的模型,在足够大的数据集上训练之后,居然能媲美甚至超过精心设计的 CNN。这也是为什么很多人说 ViT 证明了“先验知识不是必须的,数据量才是”。

1.3 适合谁来读这篇

如果你已经用 ResNet、EfficientNet 做过图像分类,想了解为什么 ViT 在大规模数据上表现更好;或者你手头有个分类任务,想在项目里尝试 ViT 但不知道从哪下手;又或者你在读 ViT 论文时对 patch embedding、position embedding、class token 这些概念似懂非懂,想看到一个能跑通的实现——这篇文章就是写给你的。我会尽量不堆公式,用代码和实验结果说话。

2. ViT 架构逐层拆解

2.1 Patch Embedding:把图片切成“单词”

Patch embedding 是整个 ViT 里唯一跟图像强相关的部分,也是理解 ViT 的关键一步。假设输入图像是 224x224x3,patch size 设为 16,那么图像会被切成(224/16) × (224/16) = 14 × 14 = 196个 patch。每个 patch 原本是一个 16x16x3 = 768 维的张量,经过一层线性投影后变成一个长度为 D 的向量。

这个线性投影在实现上就是一个卷积层,kernel size 和 stride 都等于 patch size。这一步很多人第一次看代码时会困惑:明明论文说的是“线性投影”,为什么代码里是 Conv2d?因为Conv2d(in_channels=3, out_channels=D, kernel_size=16, stride=16)的数学本质就是把每个 16x16x3 的窗口展平后做矩阵乘法,和 Linear 等价,但用卷积实现效率高得多,还顺便处理了 batched input。

2.2 Position Embedding:给 patch 加上顺序感

自注意力机制本身是“排列不变”的,也就是说,把 196 个 patch 的顺序打乱,模型输出的结果完全一样。这对图像分类来说是不可接受的,所以必须把位置信息显式注入。ViT 的默认做法是给每个 patch 向量直接加上一个可学习的 position embedding。

这个 embedding 的维度是(num_patches + 1, D),多出来的 1 是给 class token 用的。它有几种备选方案:用 sin-cos 的固定编码、用 2D 的相对位置编码,或者干脆用可学习的 1D 编码。ViT 论文里做了对比实验,结论是可学习 1D 编码在 ImageNet 上效果最好,而且实现最简单。我自己的实验也验证了这一点——在中小规模数据集上,几种位置编码的差距很小,没必要在这个地方花太多心思。

2.3 Transformer Encoder 内部结构

ViT 用的 encoder block 和原始 Transformer 论文里的 encoder 几乎一致,每个 block 由两部分组成:多头自注意力(MSA)和多层感知机(MLP),中间用 LayerNorm 和残差连接串起来。

有个细节值得注意:ViT 用的是 Pre-LN 结构,也就是先做 LayerNorm 再做 Attention,这和原始 Transformer 的 Post-LN 不一样。Pre-LN 的梯度传播更稳定,训练大模型时不容易爆炸。MLP 部分有一个固定的通道扩展比例,通常是 4 倍。比如 embedding 维度是 768,MLP 内部的隐藏层就是 3072。激活函数用的是 GELU,比 ReLU 更平滑,在 Transformer 类模型里是标配。

self-attention 的计算过程说起来不复杂:每个 patch 向量经过三个不同的线性投影,得到 Query、Key、Value;然后用 Query 和所有 Key 做点积,除以维度的平方根做缩放,再过 softmax 得到注意力权重;最后用权重对 Value 加权求和。多头就是把这个过程分成 H 个头并行做,每个头关注不同的关系模式,最后拼接起来再投影一次。

2.4 Classification Head 与 class token 的设计

分类任务需要一个固定长度的输出向量。ViT 的做法是在 patch 序列的最前面拼接一个可学习的 class token,它也是一个 D 维向量,初始值随机。这个 token 和其他 patch 一样经过所有 encoder block,最终在输出层取出它的向量,接一个 LayerNorm 和一个全连接层,输出类别数。

为什么要单独搞一个 class token,而不是直接对所有 patch 的输出去全局池化?论文的解释是:class token 相当于模型自己学习出来的一个“内容聚合器”,它在训练中逐渐学会从其他 patch 那里收集全局信息。从实验结果看,class token 和全局平均池化(GAP)的效果差不多,但 class token 和预训练模型的兼容性更好,因为预训练权重就是按这个结构训练出来的。如果你要用开源预训练权重做微调,建议保留 class token 结构,别擅自改成 GAP。

3. 从零实现 ViT 关键模块

3.1 Patch Embedding 的代码实现

这一段我直接放可以跑的 PyTorch 代码,逻辑和官方实现一致,但去掉了不必要的抽象,方便你读懂每一行。

import torch import torch.nn as nn class PatchEmbed(nn.Module): """图像转 patch 序列:用卷积实现线性投影""" def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768): super().__init__() self.img_size = img_size self.patch_size = patch_size self.num_patches = (img_size // patch_size) ** 2 self.proj = nn.Conv2d( in_chans, embed_dim, kernel_size=patch_size, stride=patch_size ) def forward(self, x): # x: (B, 3, 224, 224) -> (B, 768, 14, 14) x = self.proj(x) # 展平成序列:flatten 后调换维度 # (B, 768, 14, 14) -> (B, 196, 768) x = x.flatten(2).transpose(1, 2) return x

注意flatten(2)把最后两个维度展平,得到(B, 768, 196),再用transpose(1, 2)换成(B, 196, 768),这样每一行就是一个 patch 的 embedding 向量,正好符合 Transformer 输入(batch, seq_len, hidden_dim)的约定。

3.2 Position Embedding 与 class token 的拼装

在把 patch 序列送进 encoder 之前,要先拼接 class token、加上位置编码。实现代码如下:

class VisionTransformer(nn.Module): def __init__(self, img_size=224, patch_size=16, in_chans=3, num_classes=1000, embed_dim=768, depth=12, num_heads=12, mlp_ratio=4.0): super().__init__() self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, embed_dim) num_patches = self.patch_embed.num_patches self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim)) nn.init.trunc_normal_(self.pos_embed, std=0.02) nn.init.trunc_normal_(self.cls_token, std=0.02) # 完整 encoder 由多个 Block 组成,见 3.3 节 self.blocks = nn.ModuleList([ Block(embed_dim, num_heads, mlp_ratio) for _ in range(depth) ]) self.norm = nn.LayerNorm(embed_dim) self.head = nn.Linear(embed_dim, num_classes) def forward(self, x): B = x.shape[0] x = self.patch_embed(x) # (B, 196, 768) cls_tokens = self.cls_token.expand(B, -1, -1) x = torch.cat([cls_tokens, x], dim=1) # (B, 197, 768) x = x + self.pos_embed for blk in self.blocks: x = blk(x) x = self.norm(x) cls_out = x[:, 0] # 取出 class token 对应的输出 return self.head(cls_out)

初始化这里有个小细节:cls_token和pos_embed用trunc_normal_初始化,标准差设为 0.02,这是 Transformer 类模型通用的初始化策略。千万别用nn.init.zeros_去初始化 pos_embed,否则前向传播时所有 patch 共享相同的位置信号,模型一开始就分不清位置,收敛会明显变慢。

3.3 Encoder Block 实现

Encoder block 是 ViT 的核心计算单元,实现了自注意力、MLP、残差连接和 LayerNorm。代码比 patch embedding 稍长,但结构非常清晰:

class Attention(nn.Module): def __init__(self, dim, num_heads=12): super().__init__() self.num_heads = num_heads self.head_dim = dim // num_heads self.scale = self.head_dim ** -0.5 self.qkv = nn.Linear(dim, dim * 3) self.proj = nn.Linear(dim, dim) def forward(self, x): B, N, C = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, self.head_dim) qkv = qkv.permute(2, 0, 3, 1, 4) q, k, v = qkv.unbind(0) # 每个都是 (B, num_heads, N, head_dim) 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(x) class MLP(nn.Module): def __init__(self, dim, mlp_ratio=4.0): super().__init__() hidden_dim = int(dim * mlp_ratio) self.fc1 = nn.Linear(dim, hidden_dim) self.act = nn.GELU() self.fc2 = nn.Linear(hidden_dim, dim) def forward(self, x): return self.fc2(self.act(self.fc1(x))) class Block(nn.Module): def __init__(self, dim, num_heads, mlp_ratio=4.0): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = Attention(dim, num_heads) self.norm2 = nn.LayerNorm(dim) self.mlp = MLP(dim, mlp_ratio) def forward(self, x): x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x

这里的缩放因子self.scale = self.head_dim ** -0.5就是论文里的1/sqrt(d_k)。它在数值上非常重要:如果不做缩放,当 head_dim 比较大时,Query 和 Key 的点积数值会很大,softmax 之后梯度趋近于零,模型很难训练。我在实验里刻意去掉过一次这个缩放,结果是一模一样的代码,loss 死活降不下去,加回来之后马上正常。

3.4 模型规模选择与参数量估算

ViT 有几种常用的规格,我整理了一个速查表,方便你在实际项目中选型:

配置embed_dimdepthnum_headsMLP隐藏层参数量(约)
ViT-Tiny192123768550万
ViT-Small38412615362200万
ViT-Base768121230728600万
ViT-Large1024241640963.07亿

ViT-Base 是论文里最常用的配置,对应vit_base_patch16_224。如果你只是做实验验证想法,建议从 ViT-Tiny 或 Small 入手,在 CPU 上也能勉强跑通 forward,显存压力小很多。我最初直接上 ViT-Base 在单张消费级显卡上跑,batch size 稍微调大一点就显存溢出,换成 Tiny 之后整个调试周期缩短了不止一半。

4. 训练配置与实验经验

4.1 数据规模是 ViT 的命门

ViT 和 CNN 最大的不同在于对数据量的敏感度。ViT 论文的核心结论是:在 ImageNet-1k(128万张图)上直接从头训练,ViT 打不过同级别的 ResNet;但在 ImageNet-21k(1400万张图)或 JFT-300M(3亿张图)上预训练之后,ViT 的表现反超 CNN。原因是 ViT 几乎没有图像先验,所有归纳偏置都得靠数据自己学出来。

这给实际项目带来的启示很直接:如果你的数据集只有几千张图,别指望从头训练 ViT 能出好效果。正确的做法是用在大规模数据集上预训练好的权重做微调。比如你可以在 HuggingFace 或官方仓库里加载 ImageNet-21k 预训练的 ViT 权重,然后在自己的数据上微调,通常几百到几千张图就能获得不错的结果。如果你的条件和数据都很紧张,那就老实回归 ResNet 系模型,没必要为了“跟上潮流”硬上 ViT。

4.2 Optimizer、学习率与 warmup 策略

ViT 训练时的优化器选择和 CNN 时代有一个显著区别:主流做法是用 AdamW,而不是带动量的 SGD。我自己对比过,在相同数据上 AdamW 的收敛速度明显更快,最终精度也略高。学习率方面,ViT 对学习率非常敏感,论文里用的基础学习率是 0.003,配合 linear warmup + cosine decay 的调度。

Warmup 这一步千万不能省。ViT 的训练初期非常脆弱,直接上大学习率很容易让 loss 冲到 NaN。我的习惯是 warmup 步数设为总步数的 5% 到 10%,比如总共训练 100 个 epoch,前 5 到 10 个 epoch 做线性 warmup。另外,weight decay 建议设大一点,ViT 的默认值是 0.05 到 0.1,远高于 ResNet 常用的 1e-4。这个参数对最终精度的影响很明显,我试过把 0.05 改成 0.01,同样训练 100 epoch,验证集准确率掉了将近 1 个点。

4.3 数据增强与正则化技巧

ViT 训练还有一个和 CNN 明显不同的地方:它对数据增强和正则化的依赖性更强。ViT 论文里用了一整套增强组合,包括 RandAugment、Mixup、CutMix、随机擦除等。如果你只是简单做随机裁剪加水平翻转,在中小数据集上很容易过拟合。

我的经验是,ViT 微调场景下,RandAugment + Mixup 这个组合性价比最高。如果你想控制实验变量,可以先不加 CutMix,因为 CutMix 和 Mixup 同时使用时需要仔细调参数,否则容易互相干扰。DropPath(Stochastic Depth)也是一个值得加的正则化项,它随机丢弃某些 block 的残差分支,对 ViT 这类深层模型很有效果,通常设 0.1 左右。还有一个容易被忽略的细节是 Label Smoothing,默认设 0.1,能有效缓解模型过度自信的问题,提升泛化能力。

5. 常见问题与排查实录

5.1 训练不收敛或者 loss 直接变 NaN

这个是我被问得最多的问题。先检查几个最常见的坑:第一,学习率是否过大,ViT 建议从 1e-4 这个量级起步,如果用到 1e-3 还得确保 warmup 足够;第二,检查数据归一化,ViT 需要和预训练时一致的 mean/std,如果用 ImageNet 预训练权重,输入必须按 ImageNet 的归一化参数来预处理,否则 patch 数值分布完全对不上;第三,检查模型输出层之前的 LayerNorm 是否存在,这个self.norm在论文结构里是明确有的,删掉之后精度会掉,严重时甚至不收敛。

还有一个我踩过的坑是混合精度训练时的 loss 溢出。如果你用 AMP,建议在 loss 缩放上做文章,或者干脆先关掉 AMP 跑一个完整 epoch 确认模型本身没问题,再开启混合精度。我遇到过精度初始化不一致导致复现不了实验的情况,排查到最后才发现是 AMP 的 loss scale 策略不同。

5.2 显存爆炸怎么处理

ViT 的显存消耗比同参数量级的 CNN 大。原因是 self-attention 需要计算(B, H, N, N)的注意力矩阵,N 是 patch 数加 1。以 ViT-Base 为例,224x224 输入对应 197 个 token,196x196 的注意力矩阵不算大,但如果你把输入图调到 384x384,N 就变成 577,注意力矩阵大小翻了将近 9 倍。这解释了为什么 ViT 在更高分辨率上推理时会突然显存吃紧。

几个实用的缓解手段:一是减小 batch size,配合梯度累积(gradient accumulation)保持等效 batch size 不变;二是用torch.utils.checkpoint做激活重计算,虽然会拖慢训练速度,但显存能省一半以上;三是如果必须做高分辨率推理,可以考虑用窗口注意力或者用 Swin Transformer 这类层级化设计,它们在处理高分辨率输入时更有优势。

5.3 在中小数据集上过拟合严重

如果你在小数据集上直接从零训练 ViT,很快就会发现训练集 loss 降得很低,但验证集准确率上不去,这就是过拟合。解决路径有两条,优先级从高到低排序:第一条是用预训练权重做微调,这是最有效的方案,没有之一;第二条是加大正则化力度,包括增强 DropPath 比例、提高 weight decay、加入更强的数据增强。

我也试过一些物理层面的技巧,比如冻结前几层 encoder block 的权重,只微调后面几层和分类头。这种方式在数据量极少(比如每类只有几十张图)的时候特别有用,相当于把 ViT 当特征提取器用,只训练最上层。实测下来,在大概 2000 张图的二分类任务上,这种“部分微调”比“全量微调”稳定得多,不容易把预训练学到的通用特征破坏掉。

5.4 和 CNN 对比时怎么做到公平

不少人在实验里会遇到一个尴尬的情况:ViT 和 ResNet 对比时表现并没有优势,甚至更差。这时候先别急着下结论,检查一下你的对比设置是否公平。第一,训练 epoch 数是否足够,ViT 通常需要 200 到 300 个 epoch 才能发挥出完整能力,用 60 个 epoch 对比对 ViT 不公平;第二,数据增强和正则化是否按各自最优配置来设置,ViT 对增强的依赖更强;第三,是否使用了预训练权重,这个差异比架构差异大得多。

如果是推理速度对比,要特别注意 ViT 的高分辨率扩展性问题,同时也要考虑实际部署平台。在 GPU 上 ViT 的矩阵运算非常高效,但在移动端或嵌入式设备上,纯卷积的 CNN 往往更占优势。选型的时候别只盯着精度数字,还是要把推理延迟、显存占用、模型大小这些工程指标一起放进表格里综合评估。

6. 两个亲测有效的使用建议

最后分享两个我实际用下来很有价值的小技巧。第一个是关于微调时的学习率分层:分类头用相对较大的学习率,比如 1e-3,而 encoder 主干用较小的学习率,比如 5e-5。原因是分类头是随机初始化的,需要学得更多;主干是从预训练来的,只需要微调。用分组学习率同时照顾两边的需求,微调初期的 loss 下降明显更平滑。

第二个技巧是关于输入 patch size 的选择。ViT 的 patch size 可以从 16 改成 8,代价是 token 数量变成原来的 4 倍,计算量显著上升,但在细粒度分类任务上准确率确实能提升。如果你做的是花卉、昆虫、车辆型号这类差距很小的分类任务,可以试试 patch16 预训练 + 微调时改用较小 patch size 的做法。不过要记住,改小 patch size 之后,position embedding 的插值不可避免,微调时最好把位置编码也一起微调,否则位置信息会不匹配。

ViT 这个架构从出现到现在已经过了好几年,中间又演化出了 Swin、DeiT、PVT 等一大堆变体,但它的核心思路——把图像变成序列、用注意力建模全局关系——至今仍然是视觉模型的主流方向之一。把 ViT 本身吃透,后面再接触任何 Transformer-based 的视觉模型都会轻松很多。希望这篇拆解能帮你省掉我当年摸索的弯路。

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

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

立即咨询