☰
ViT图像分类实战:基于Pytorch从零实现Vision Transformer
2026/10/21 10:49:45 网站建设 项目流程

简介:VisionTransformer-Pytorch 是一份基于 PyTorch 的视觉 Transformer 实现资源,面向有一定深度学习基础、希望快速在图像分类任务中应用 ViT 模型的开发者。项目采用类似 EfficientNet 的简洁 API 设计,通过 pip 安装后即可加载 ViT-B_16 预训练权重,省去自行搭建与训练的时间。压缩包共 14 个文件,以 9 个 Python 源文件为主,涵盖核心模型、ResNet 辅助模块、图像处理工具、JAX 权重转换脚本与测试用例;另有 README 与许可证文件说明使用方式及授权信息,整体仅 25KB,轻量易读。资源包含从 JAX 预训练权重迁移至 PyTorch 的转换工具,便于获得官方参数;同时提供示例脚本与测试代码,帮助理解 ViT 的输入输出细节。目前已有 1163 人学习,适合作为 ViT 实现参考或快速集成到视觉任务的起点。 我最近把一个图像分类项目全部重写了一遍,核心模型从ResNet换成了VisionTransformer,代码全部基于Pytorch实现。折腾了大概一周时间,把ViT从零到一完整搭了出来,包括数据管线、模型结构、训练脚本、推理加速,整个过程踩了不少坑,也积累了一些实际经验。这篇就把这个项目的完整思路和实现细节拆开讲讲,给正在入门或准备上手ViT的朋友一个可以直接参考的实操方案。

这个项目最核心的事情是:把VisionTransformer(简称ViT)用Pytorch从底层代码开始实现,并在实际数据集上完成训练和验证。它能解决的核心问题是图像分类任务中的特征提取和长距离依赖建模,区别于传统CNN的局部感受野机制,ViT通过自注意力机制能直接建模图像任意两个位置之间的依赖关系。适合对Transformer架构有了解、想把它迁移到视觉任务上的读者,也适合刚学完Pytorch基础、想通过一个完整项目串联起数据加载、模型搭建、训练验证全流程的朋友。

1. ViT的架构拆解:为什么要把图像变成序列

1.1 核心思路:图像分块与序列化

VisionTransformer的核心操作其实很简单:把一张图片切成固定大小的patch,然后把每个patch当作一个“词向量”送入Transformer编码器。我以ViT-B/16为例说明,输入图片尺寸是224x224,patch size设为16x16,那么图片会被切分成(224/16)² = 196个patch,加上一个用于分类的[CLS] token,序列长度就是197。

这里有个非常关键的细节:每个patch在送入Transformer之前需要经过一个线性映射,把16x16x3(RGB三通道)的原始像素展平成一个768维的向量。我第一遍实现时很自然就想用nn.Linear来做这个映射,但实际写的时候发现直接用nn.Conv2d更高效——用一个in_channels=3, out_channels=768, kernel_size=16, stride=16的卷积,一步就把切分和线性投影同时完成了。这在算力上比先切patch再逐个做线性变换要省很多,也是很多开源实现里普遍采用的做法。

1.2 位置编码与[CLS] Token的设计逻辑

序列化之后遇到的一个问题是:Transformer本身是置换不变的,它不知道序列里哪个patch在前哪个在后。为了让模型能感知patch的空间位置,必须给每个token加上位置编码。ViT原文使用的是可学习的1D位置编码,直接初始化一个nn.Parameter,形状是(197, 768),和token embedding直接相加。

[CLS] token的设计是从BERT里继承过来的思路:在序列最前面拼接一个特殊的可学习向量,经过Transformer编码器后,用这个位置对应的输出向量作为整张图片的全局特征表示,再接到分类头上。为什么不直接用所有token的平均池化?我试验过两者的差异,[CLS] token在训练中会主动聚合全图信息,收敛速度和最终精度都比平均池化稍好一点,这可能是因为它在注意力层中承担了类似“信息汇聚中心”的角色,梯度信号也更集中。

补一个非常容易踩的坑:位置编码是加到序列维度上的,一定要在patch embedding之后、送入Encoder之前加。我第二版代码里把位置编码加错了位置,导致训练loss怎么都降不下去,排查了很久才发现是维度broadcast出了问题。

2. 环境搭建与Pytorch安装要点

2.1 创建干净的运行环境

动手写代码之前,先把环境搞定,这一步看似基础但最容易出问题。我的建议是使用conda创建一个独立的虚拟环境,不要直接装在base环境里,不然后面不同项目之间的依赖冲突会让人非常崩溃。创建命令很简单:

conda create -n vit python=3.10 conda activate vit

Python版本我建议选3.9或3.10,这两个版本对Pytorch各版本的支持最稳妥。太新的Python版本(比如3.13刚出来时)可能会导致部分依赖库还没有对应的wheel包,装起来很麻烦。

2.2 GPU版Pytorch的安装细节

GPU版本的安装是很多人卡壳的地方。核心原则是:先确认你的CUDA版本,再安装对应的Pytorch版本,顺序不能反。查看CUDA版本用:

nvidia-smi

如果显示的是CUDA 11.8,那么对应的安装命令是:

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

如果国内下载速度太慢,我建议优先使用清华镜像源,或者设置pip超时时间加长:

pip install torch torchvision torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple

这里有个实际经验:很多人会纠结到底下载哪个CUDA版本,其实只要满足Pytorch要求版本 <= 驱动支持的CUDA版本这个条件就可以。比如驱动支持CUDA 12.4,那么你装cu118、cu121、cu124对应的Pytorch都能跑起来,Pytorch会通过自带的CUDA runtime运行,不依赖系统全局的CUDA。我当初装的时候直接选了大版本匹配的cu118版本,实测跑ViT-B/16训练和推理都没问题。

注意:装完Pytorch后一定要验证GPU是否可用,用python -c "import torch; print(torch.cuda.is_available())",输出True才说明环境没问题。很多人在这步输出False,通常是Pytorch装成了CPU版本,卸载重装GPU版即可。

3. 核心模块的代码实现与参数解读

3.1 Patch Embedding层

直接看代码,先把patch embedding层的实现贴出来:

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 self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: (batch_size, 3, 224, 224) x = self.proj(x) # (batch_size, embed_dim, 14, 14) x = x.flatten(2) # (batch_size, embed_dim, 196) x = x.transpose(1, 2) # (batch_size, 196, embed_dim) return x

这里flatten(2)是把(14, 14)两个空间维度展平成196,然后transpose(1, 2)把序列维度放到中间,得到(batch, seq_len, embed_dim),这个形状是Transformer Encoder的标准输入格式。我第一版代码写的是x = x.view(x.size(0), x.size(1), -1),效果和flatten(2)一样,但flatten更直观,后续维护也更清晰。

3.2 Multi-Head Self-Attention实现

自注意力是ViT的核心计算模块。它的思想可以这样理解:对序列里的每个token,通过三个不同的线性投影分别得到Query、Key、Value三个向量,然后计算Query和所有Key的点积得到注意力权重,再用softmax归一化,最后用权重加权求和Value。多头注意力就是把embedding维度切成多份,每个头独立做注意力计算,最后拼接起来。

class MultiHeadSelfAttention(nn.Module): def __init__(self, embed_dim=768, num_heads=12, dropout=0.1): super().__init__() self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.qkv = nn.Linear(embed_dim, embed_dim * 3) self.attn_drop = nn.Dropout(dropout) self.proj = nn.Linear(embed_dim, embed_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[0], qkv[1], qkv[2] attn = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5) attn = attn.softmax(dim=-1) attn = self.attn_drop(attn) x = (attn @ v).transpose(1, 2).reshape(B, N, C) x = self.proj(x) return x

这里有个关键的缩放因子self.head_dim ** -0.5,很多新手会忽略它。这个缩放是为了防止点积结果过大导致softmax进入饱和区、梯度趋近于零。我在第一次实现时忘了加这个缩放,训练时发现attention分布很快就变得非常尖锐,模型几乎不更新了,加上之后训练立刻变得稳定。

3.3 Transformer Encoder与ViT主体

一个完整的Encoder Layer由自注意力、MLP和两个LayerNorm组成,采用了残差连接的思路。MLP的结构是两层全连接,中间用GELU激活函数:

class TransformerBlock(nn.Module): def __init__(self, embed_dim=768, num_heads=12, mlp_ratio=4.0, dropout=0.1): super().__init__() self.norm1 = nn.LayerNorm(embed_dim) self.attn = MultiHeadSelfAttention(embed_dim, num_heads, dropout) self.norm2 = nn.LayerNorm(embed_dim) hidden_dim = int(embed_dim * mlp_ratio) self.mlp = nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_dim, embed_dim), nn.Dropout(dropout) ) def forward(self, x): x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x

注意这里的顺序是x + attn(norm(x)),也就是Pre-LN结构,先归一化再进注意力模块。这和原始Transformer的Post-LN(先注意力再归一化)有区别。Pre-LN在训练中更稳定,能支持更大的学习率和更深的网络深度,ViT官方实现用的也是这个结构,我建议照做。

最后把整个ViT主体组装起来:PatchEmbed、CLS token、位置编码、多个TransformerBlock按顺序堆叠、LayerNorm和分类头。我这里用num_layers=12配置了12层Encoder,这也是ViT-Base的标准深度。

4. 训练配置、数据准备与迁移学习技巧

4.1 数据预处理与增强策略

ViT在小数据集上比CNN更容易过拟合,所以数据增强非常关键。我用的CIFAR-10数据集本身只有5万张训练图,如果不做增强,ViT-Base的参数量(8600万)远远超过数据量,模型很快就在训练集上跑到99%准确率,测试集却只有70%出头。

我采用的增强策略是:随机水平翻转、随机裁剪(crop到224x224)、RandAugment(一个自动数据增强方法,随机组合多种图像变换)。实测下来RandAugment的提升最大,能提高约3-4个百分点的测试精度。这里有个容易忽略的地方:验证集的预处理不需要增强,但需要做和训练集相同的resize和归一化操作,归一化的均值和方差要提前统计数据集得到,直接用ImageNet的默认值也能跑,但精度会略差一点。

4.2 训练超参设置

ViT的优化配置和CNN有差异,我从实际项目中验证过的参数配置如下:

超参数数值说明
optimizerAdamW比Adam多了权重衰减解耦,更适合Transformer
base learning rate3e-4过大会导致attention训练不稳定
weight decay0.05ViT对weight decay比较敏感
batch size256多卡可扩大到1024,需同步调大lr
warmup epochs10让学习率从0线性升到目标值
total epochs100足够观察到收敛趋势
lr schedulecosine decay配合warmup效果最好

这里重点说一下AdamW和warmup。AdamW把权重衰减从L2正则里解耦出来,直接作用在参数更新上,对Transformer架构有更好的泛化效果,这在ViT的训练中几乎是标配。Warmup则是让模型在训练初期用很小的学习率先“稳住”,避免注意力矩阵在初始化阶段就被更新到不理想的方向,我的经验是10个epoch的warmup比较合理,太短起不到作用,太长会拖慢收敛。

4.3 模型冻结与迁移学习

很多实用场景下,我们不一定要从头训练ViT。我建议直接加载在ImageNet上预训练好的权重,然后在自己的小数据集上做微调,这样能把训练时间从几天缩短到几小时。加载预训练权重的代码很简单,可以从Pytorch官方或timm库获取:

import timm model = timm.create_model('vit_base_patch16_224', pretrained=True) model.head = nn.Linear(768, num_classes)

微调时有个很有用的技巧:先把整个模型冻结(requires_grad=False),只训练分类头和位置编码(因为新任务输入尺寸可能变化,位置编码需要重新插值),跑几个epoch之后再把全部参数解冻进行全量微调。这样分阶段训练的好处是,前期让新初始化的分类头先适应特征提取器,后面解冻时不会因为分类头的随机梯度太大而破坏预训练特征。我在CIFAR-10上按这个方法微调,100个epoch从头训练能达到94%左右,而迁移学习只训练30个epoch就能到96%,差距非常明显。

5. 踩坑记录与问题排查技巧

5.1 训练不收敛:损失震荡不下降

这是最常见的问题,我之前也遇到过。排查思路按顺序来:

第一,检查数据归一化是否正确,看看输入图片的像素范围是否和数据增强的预期值一致。第二,确认学习率是否过大,ViT对学习率比CNN敏感得多,CNN常用的1e-3学习率在ViT上很可能直接导致loss爆炸。第三,检查warmup是否开启,不使用warmup时,前几个iteration的loss可能会出现很大的尖峰。第四,确认attention中是否加了缩放因子,这个前面提过,漏加会导致梯度消失。

5.2 显存不足(OOM)的优化手段

ViT在训练时对显存的需求比同等级的ResNet大不少。如果你用的是12GB显存,在batch size和环境允许范围内还是可能OOM,可以从几个方向优化:

  • 降低batch size,配合梯度累积模拟大的batch
  • 开启混合精度训练(AMP),能显著减少显存占用,代码上只需要加几行:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels)
  • 使用gradient checkpointing,以少量计算换显存,torch.utils.checkpoint可以做到。

混合精度是最推荐的做法,ViT中的大部分操作对低精度并不敏感,实测混合精度下训练速度能提升30%以上,显存占用几乎减半。

5.3 Patch Size与位置编码的联动影响

这个坑比较隐蔽。当你要把ViT从一个输入尺寸迁移到另一个尺寸时,位置编码需要做插值。比如预训练模型是224x224,你想在384x384的输入上微调,patch数量会从196变成576,原始的位置编码无法直接使用,需要做2D插值:

import torch.nn.functional as F pos_embed = model.pos_embed pos_embed_interp = F.interpolate( pos_embed[:, 1:, :].reshape(1, 14, 14, 768).permute(0, 3, 1, 2), size=(24, 24), mode='bicubic' ).permute(0, 2, 3, 1).reshape(1, 576, 768) new_pos_embed = torch.cat([pos_embed[:, :1, :], pos_embed_interp], dim=1)

这里有个细节:插值时[CLS] token的位置编码不需要插值,直接保留原值,只对patch位置部分做resize。如果这个操作做错了,模型精度会大幅下降,甚至可能直接不收敛。

5.4 过拟合识别的三个信号

如何判断模型是否过拟合?我总结三个信号:训练loss持续下降但验证loss开始回升;训练精度接近100%而验证精度停滞;attention可视化中模型过度关注某个局部区域。应对策略有两个,一个是增强正则化强度(dropout、weight decay、更多增强),另一个是换更小规模的模型变体(如ViT-Small)。

这里附一个不同规模ViT的具体参数量对比,方便你根据数据集大小选型:

模型层数隐藏维度注意力头数参数量
ViT-Tiny121923570万
ViT-Small1238462200万
ViT-Base12768128600万
ViT-Large2410241630700万

我个人的经验是:小于10万张图的数据集,优先考虑ViT-Tiny或ViT-Small,配合预训练微调;数据集在10万级以上才考虑ViT-Base及以上规模,否则过拟合的风险非常高。

一个小建议:可视化Attention是理解ViT的好方法

写代码之余,我强烈建议你花点时间把attention map可视化出来。做法很简单:在forward的时候保存最后一层的attention权重,然后把每个head的注意力图resize回原始图片大小,叠加到原图上。你会很直观地看到模型在关注哪些区域,也能从中判断模型学到的特征是全局的还是碎片化的。我在调优过程中靠这个工具发现了不少问题,比如某个head的attention完全塌缩成平均分布,后来发现是dropout设置太高导致信息丢失。

这个项目做下来,我对ViT的理解比只看论文要深刻得多。从PatchEmbed到Self-Attention,每一个模块的实现细节都值得反复推敲。如果大家照着实现过程中遇到任何问题,欢迎在评论区交流,我尽量抽空回复。

本文还有配套的精品资源,点击获取

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

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

立即咨询