
第一次接触 ViT 的时候很多人都会愣一下一张图被切成小方块排成一条序列然后塞进 Transformer。这和卷积网络“滑动窗口、逐层提取特征”的思路完全不一样。更让人困惑的是Transformer 本身并不关心 token 的先后顺序那这些 patch 之间的空间位置关系怎么保留如果你也有这种疑惑这不奇怪。我自己第一次看 ViT 的结构图时脑子里冒出来的问题是这不就是把二维图像破坏了重新拼成“一维文本”吗模型怎么可能学会“左上角”和“右下角”的关系后来真正去复现、去可视化、去调参才慢慢意识到ViT 不是简单地把卷积换成了注意力机制而是把视觉问题重新定义成了“序列建模”问题。理解它的关键不在于记住它用了多少层、多少个 head而在于想清楚三件事图像是怎么变成序列的位置编码是如何作为“空间记忆”注入进去的以及注意力分数如何让模型“看见”全局。这篇文章我想用动画和可视化的思路把这三件事拆开讲清楚再落回工程实践中说说真实使用 ViT 时那些不看一眼就很容易翻车的细节。1. 从“卷积扫图”到“切块排队”ViT 到底在做什么1.1 图像变成“句子”的那一步ViT 的做法可以拆成三步。第一步把一张大小为 H×W×C 的图片按照固定大小切成很多块。比如最常见的 patch size 是 16×16。如果输入是 224×224 的彩色图那么就会得到 (224/16)×(224/16)14×14196 个 patch。第二步把每一个 patch 展平成一个一维向量然后通过一个线性投影层映射为 hidden dimension 是 D 的向量。这一步在代码里通常用一个PatchEmbedding模块完成。在 PyTorch 里常见的实现等价于一个Conv2d(in_chans, embed_dim, kernel_sizepatch_size, stridepatch_size)。你可能会觉得奇怪ViT 不是不用卷积吗这里实际上是用卷积来实现 patch 的切分和线性投影只是它没有像 CNN 那样跨窗口滑动卷积。所以准确地说ViT 在输入端往往还是借用了卷积层用来把图像切块并压缩成 token。第三步在这个 patch 序列的最前面额外加一个可学习的 [class] token它用来汇总整张图的信息。经过 Transformer encoder 之后[class] token 对应的输出会被送入分类头得到最终的分类结果。这样一来一张图片就变成了一个长度为 197 的“句子”前面 196 个单词是图像块最后严格说是最前面还有一个用来做分类的聚合 token。每一个 patch 的 embedding 就是这个“单词”的特征向量。很多教程会把这一步画得非常漂亮一张狗狗图片变成一排彩色小方块。但我觉得更重要的是理解这一步背后的动机Transformer 是为处理序列设计的而自注意力机制天然可以建模任意两个向量之间的关系。想让 Transformer 处理图片最直接的方式就是把图片组织成一组向量。ViT 选择的组织方式就是“均匀切块展开排队”。1.2 失去先验之后模型靠什么重新学习空间卷积网络天生拥有两个很强的先验局部性——相邻像素可能属于同一物体平移等变性——物体出现在图像不同位置检测器可以共享同一套权重。ViT 把这套先验全部放弃了。把一个 patch 和一个远处的 patch 放进序列里模型最开始并不知道谁和谁物理上是靠近的。它只知道输入序列里第 5 个 patch 和第 150 个 patch看起来是两个来自不同位置的向量。那它怎么重新学习空间关系答案有两个位置编码和自注意力。位置编码给每个 patch 加上一个“位置向量”告诉模型“我是第几行的第几列”。自注意力则允许每一个 patch 和所有其他 patch 直接交互通过训练去学习“哪些 patch 之间应该建立更紧密的联系”。这带来一个重要的判断ViT 并不比 CNN 更“聪明”它只是把归纳偏置从手工设定变成了从数据中学习。代价是训练需要更多的数据、更强的数据增强和更长的调参时间。如果数据量不够ViT 可能学不到合理的空间关系效果反而不如 ResNet。但如果数据量足够这种“少先验、强表达”的模型往往能学到比卷积更灵活的模式。所以我们后面讨论的一切——位置编码、注意力可视化、训练稳定性——本质上都是在回答一个问题在“丢掉先验”的前提下如何让模型更快、更稳地学会二维图像结构。2. 位置编码ViT 的“空间记忆”也是新手最容易忽略的坑2.1 自注意力天生“不认位置”Transformer 的自注意力公式是[ Attention(Q,K,V) softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]其中 Q、K、V 都是输入序列经过矩阵变换得到的。这个公式本身对序列顺序没有感知。如果把输入序列的两行交换一下计算结果里除了顺序不同每个 token 对应的内容不会变化。这是 Transformer 架构的固有特性置换不变性。对于文本来说这种特性也不能接受。所以原始 Transformer 引入了位置编码。对于图像来说这个问题更严重因为图像的空间结构是二维的切块后排成一维序列本身已经损失了一部分空间信息如果位置编码再不注入那模型只能把图像当成一个“词袋”完全无法区分物体左右上下。因此用一个通俗的类比来说位置编码是 ViT 的“空间记忆”。它负责在把 patch 排队之后重新告诉模型每个 patch 原来的二维坐标。2.2 绝对位置编码、相对位置编码与二维版本ViT 里出现过的位置编码方案主要有几类。最常见的是可学习绝对位置编码。直接初始化一个形状为[num_tokens, hidden_dim]的位置编码矩阵和 patch embedding 逐元素相加。这个矩阵会随着模型一起训练最终学习到一种适合任务的编码方式。ViT 原论文用的就是这种。它简单可靠但缺点是当输入分辨率改变时token 数量变化原有的位置编码长度不够需要插值或者截断这往往会导致性能下降。另一种是原始 Transformer 里使用的正弦余弦位置编码它不需要学习直接用不同频率的三角函数生成。它的优点是具有一定的外推能力但视觉任务里很多实践表明可学习位置编码在预训练数据上通常表现得更好。还有很多改进模型使用相对位置编码比如 Swin Transformer 等。相对位置编码不关心 patch 的绝对坐标而是建模两个 patch 之间的相对位置关系。这样更容易泛化到不同分辨率也更符合直觉——物体在左上角还是右下角可能不重要重要的是这些 patch 之间的相对排列。另外如果希望编码二维结构可以使用二维位置编码。比如分别生成行位置编码和列位置编码再拼接或相加。这样模型可以更容易区分“同一行但不同列”的 patch 和“同一列但不同行”的 patch。这里有一个很容易误解的细节位置编码是加到 patch embedding 向量上的不是拼接成一个更长的向量。很多人第一次实现时会问是不是应该把它 concat 到特征后面不是。相加是原论文和大多数实现的选择这种方式不改变特征维度也能让模型通过微调自行决定位置信息的权重。2.3 分辨率改变时位置编码插值可能让模型“迷路”实际使用中做完预训练和微调后经常需要换一个输入分辨率。例如模型原本在 224×224 上训练现在你想在 384×384 上做 fine-tune。输入尺寸变大后patch 数量从 196 变成 576。之前的位置编码矩阵只有 196 行不够用了。常见做法是对位置编码做插值比如双线性插值把它变成 576 行。但插值会破坏位置编码原有的相对关系尤其是一维插值扩展成二维时要特别小心插值方向。如果你直接使用现成代码这一步可能已经替你处理了但依然值得知道它的存在。一个更稳妥的工程建议是如果你要换输入分辨率先做小规模验证对比不同插值方式和是否 fine-tune 位置编码的影响。如果允许尽量保持和预训练一致的分辨率如果必须换就加入一段足够短的 warmup让模型重新适应新的位置分布。另外当你发现一个微调模型在验证集上效果明显下降时不要只怀疑数据增强先检查一下输入分辨率和位置编码是否发生了不匹配。这是一个很隐蔽但常见的坑。3. 把注意力画出来用动画看 ViT 在“看”哪里3.1 每层每个头都是一张“视线热力图”理解了 patch 和位置编码接下来就到了最有趣的部分注意力可视化。在 ViT 的 Transformer encoder 里每一层都有多个注意力头。每个头都会计算一个注意力矩阵形状大概是[batch, heads, seq_len, seq_len]。对于 [class] token 这个位置我们可以取出它对所有 patch token 的注意力权重获得一个长度为 196 的向量。把这 196 个值对应到原始图像的 patch 坐标上画成热力图就能看到模型在处理这张图片时重点“看”了哪些区域。如果把不同层、不同 head 的热力图做成连续动画或者把同一层在不同输入图片下的注意力图串起来你会看到一种很有意思的变化前几层的注意力往往比较分散会关注局部纹理和边缘中后层开始聚焦到物体主体和语义关键区域有些 head 会专门关注背景有些 head 会关注主体和背景的边界。这就是标题里“动画”的价值——你不是在看一堆矩阵数字而是在看模型如何通过注意力逐步“聚焦”到一个类别判断。3.2 一个可以跑的可视化思路如果你想自己动手常见做法是使用 HuggingFace Transformers 加载一个 ViT 分类模型然后在 forward 时设置output_attentionsTrue拿到每一层的注意力矩阵。下面给出的是一个通用示意代码具体接口可能依赖你的库版本以本地实际 API 为准from transformers import ViTForImageClassification, ViTImageProcessor import torch import numpy as np import matplotlib.pyplot as plt model ViTForImageClassification.from_pretrained(google/vit-base-patch16-224) processor ViTImageProcessor.from_pretrained(google/vit-base-patch16-224) # 假设 image 是已读取的 PIL 图片 inputs processor(imagesimage, return_tensorspt) with torch.no_grad(): outputs model(**inputs, output_attentionsTrue) attentions outputs.attentions # tuple of layers # attentions[0].shape [batch, num_heads, seq_len, seq_len] # 取最后一层第一个 batch第一个 head 的 [CLS] token 对所有 patch 的注意力 last_attn attentions[-1][0, 0, 0, 1:] # 排除 [CLS] 自身 last_attn last_attn.detach().numpy() # 尺寸是 14x14因为 224/1614 grid_size int(np.sqrt(last_attn.shape[0])) attn_map last_attn.reshape(grid_size, grid_size) # 上采样到原图大小然后叠加显示 attn_map np.kron(attn_map, np.ones((16, 16))) plt.imshow(image) plt.imshow(attn_map, alpha0.6, cmapjet) plt.axis(off) plt.show()这段代码只是一个最基础的例子。如果你想制作动画可以把多个 head 的热力图放到不同子图里用matplotlib.animation或保存成 GIF也可以遍历多张输入图片观察模型在不同样本上的注意力变化。还有一种更高级的做法叫 Attention Rollout基本思想是把多层注意力矩阵连续累积相乘估计信息从输入层流动到最后层时原始输入 patch 受到的整体影响。这种方法比只看最后一层更接近“模型到底依赖哪些输入”但需要谨慎使用因为多层非线性并不仅仅是矩阵乘法。3.3 从动画里能读到什么又不能读到什么注意力可视化能帮助我们快速形成对模型的直觉。比如如果模型在关注明显错误的区域比如分类“狗”时只盯着背景草地那就要怀疑训练数据有偏或者模型没有学到合理特征。如果注意力图非常均匀、没有重点那可能是模型容量不足、训练不充分或者位置编码没有正确注入。但必须说明一个边界注意力权重高并不等价于模型做出该决策的原因。深度学习模型的预测来自多层非线性变换的叠加注意力只是中间状态。研究者已经发现某些注意力头可能对结果影响不大某些低注意力区域反而关键。所以可视化更适合作为调试辅助而不是严格的因果解释。不过即便只是作为 debug 工具意识到这一点也值得了。它让黑盒变得不再那么像黑盒。4. 从 Demo 到工程ViT 落地时的真实问题和排查链路4.1 先判断你的场景适不适合 ViT没有“万能模型”。ViT 的优点是有强大的全局建模能力在数据量大、任务复杂、需要建模长距离依赖时通常比 CNN 更容易达到更高上限。但它的代价是训练不稳定、对数据量更敏感、推理成本更高。如果你的任务属于以下情况优先考虑 CNN 或混合模型数据集只有几千到几万张图片且没有大规模预训练权重可用。任务具有很强局部性比如检测小目标、像素级分割直接使用原始 ViT 作为主干可能不合适。算力和显存受限部署环境不允许跑大的 Transformer。项目迭代节奏快需要快速稳定的 baselineResNet 系列往往更省心。反过来如果以下条件成立ViT 是很有竞争力的选项你能用上大规模预训练权重例如 ImageNet-21k 或更大规模上预训练的 ViT。数据量足够大或者可以通过增强手段把量补足。任务本身需要跨区域特征交互比如图像分类、大尺度目标识别、图像检索。在选型时不要只看榜单上的准确率还要考虑训练成本、推理延迟、内存占用和调试复杂度。从工程经验看先把一个小型 CNNs 或 ResNet 跑通 baseline再切换到 ViT 做实验对比是一种更稳妥的增量思路。4.2 最容易踩的四个坑预处理、输入尺寸、位置编码和优化器第一预处理不一致。ViT 通常沿用 ImageNet 的归一化参数mean 约为 [0.485, 0.456, 0.406]std 约为 [0.229, 0.224, 0.225]。如果你丢失了这一步直接把像素缩放到 0 到 1模型效果会大打折扣。很多“为什么我的 ViT 不收敛”的问题最后查出来都是预处理写错了。第二输入尺寸和位置编码不匹配。前面已经提到分辨率改变会带来位置编码插值问题。如果你使用预训练权重最好保持与预训练相同的输入尺寸或者确认代码是否正确处理了位置编码插值。第三优化器与学习率。ViT 对优化器更敏感常见配置是 AdamW基础学习率一般设得比 ResNet 小很多。当你调整 batch size 时学习率也要按比例调整同时需要注意 warmup。刚开始训练时如果 loss 不下降先别动网络结构先检查学习率和优化器参数。第四输出聚合方式。虽然 ViT 原论文使用 [class] token 做分类但很多版本的实现也支持 global average pooling。不同输出头在不同数据集上的表现可能不同。如果你在微调时拿到的模型 API 默认使用了 GAP不要想当然地以为是 [class] token。这个细节会影响微调效果。4.3 一套可复用的效果排查链路在实际项目中我一般会按下面这个顺序排查问题。你可以直接复制成自己的检查清单。第一步看训练 loss。如果 loss 不下降先检查输入输出是否对齐预处理是否正确、标签是否有错、学习率是否过小或过大、位置编码是否和输入尺寸匹配。这一阶段不需要看太多理论直接用一条样本做单步 forward 和 backward确认梯度正常更新。第二步看验证集 loss 和 accuracy。如果训练 loss 下降正常但验证集很差问题通常出在过拟合或泛化不足。这时要检查数据增强、Dropout、Weight Decay以及是否使用了正确的预训练初始化。第三步看注意力可视化。如果指标没有明显异常但模型表现不符合预期可以把注意力图画出来观察模型是否关注了有意义的区域。如果注意力图乱且无规律很可能是位置编码没有生效或者输入预处理不对。第四步看小样本表现。拿一个明显可分的子集比如只取两个类别各一百张图做小规模训练。如果这个小任务都训练不好问题大概率在模型配置或数据管线而不是数据量不够。第五步对照基准实验。换用 ResNet 或简单 CNN 跑同一个任务如果 CNN 表现正常而 ViT 不正常思考是不是数据量、优化器或位置编码的原因。如果 ViT 表现更好也要记录清楚优势来源避免“因为用了 Transformer 所以更好”的误判。这套链路的核心思想是从“能否收敛”到“是否过拟合”再到“是否学到合理特征”逐层隔离问题。不要一上来就调 head 数、depth、patch size也不要盲目堆叠数据增强。多数问题都出在最基础的输入和配置上。结尾聊到这里再回头看 ViT它已经没有一开始那么神秘了。图像被切成 patch变成序列位置编码把二维坐标重新注入到序列里自注意力让每个 patch 可以跨越距离建立关联。而动画可视化只是把这三个过程抽象成肉眼可见的形式帮助我们理解模型在做什么。但如果你真的要把 ViT 用在自己的项目里我更建议你不要只停留在理解原理。先跑一个最小的分类 demo把注意力图画出来亲手看看模型关注哪些区域。然后换一个输入分辨率观察位置编码插值带来的影响。再调整学习率和数据增强记录 loss 和指标的变化。这些经验是任何论文和官方代码都没有办法直接教给你的。ViT 真正改变的不是“用注意力代替卷积”这个表面选择而是让视觉任务变成了一种序列建模问题。它带来的不仅是性能天花板更是一种新的思考方式当二维图像被重新组织成一串有结构的 token很多原来在 CNN 里理所当然的设计都需要重新问一遍为什么。想清楚这个问题你才算真正看懂了 ViT。