简介:面向Transformer与深度学习初学者的ViT图像分类实践项目,基于开源“猫狗大战”数据集完成猫狗二分类任务。项目旨在帮助学习者清晰理解Vision Transformer的原理与网络结构,并掌握将注意力机制落地到计算机视觉任务中的完整流程。代码逻辑清晰、注释直观,适用于不同基础的学习者,只需替换数据集并调整类别数目参数,即可迁移至其他图像分类场景。包体共2000个文件,包含1998张jpg格式的猫狗图像与2个Python脚本,前者用于模型训练与验证,后者覆盖数据加载、模型搭建、训练及预测等核心环节;压缩包整体约218.41MB,结构简洁便于对照学习,目前已有1469人学习下载。通过该项目,读者可以直观体会ViT如何将图像切块为序列并借助Transformer编码器提取全局特征,同时获得一套可复用的分类项目模板,包括数据预处理思路、模型调用方式和参数修改要点,为后续深入计算机视觉与注意力机制打下扎实基础。
1. 从 CNN 瓶颈说起:为什么用 Vision Transformer 做猫狗分类
把一张猫图片按 16×16 像素切成小块,再像读句子一样让模型逐块学习,最后依然能把“猫”和“狗”分清楚——这就是这个项目最核心的反直觉结论。它没有用卷积核做滑动窗口,而是用 Transformer 的注意力机制对整张图的 Patch 序列建模,让模型自己决定该关注猫的耳朵还是狗的鼻子。Vision Transformer 实践项目定位在图像分类任务,落地场景就是经典的“猫狗大战”猫狗二分类,目标很单纯:用 ViT 跑通从数据加载、模型训练、权重保存到单张图片推理的完整流程。
这个项目适合两类人:一类是想把 Transformer 从序列建模迁移到计算机视觉里看看效果的深度学习初学者;另一类是已经在用 CNN 做分类、但想对比注意力机制与传统卷积网络差异的开发者。整个项目不用分布式训练,单卡就能跑,代码结构按模块拆开,方便拿去改成自己的数据集。下面从模型结构、核心代码、训练过程和常见坑位逐层拆一遍,我把复现时踩过的坑也单独整理出来了。
2. 项目概览与技术选型:模型结构、依赖与目录清单
2.1 ViT 的核心思想:图像 Patch 化后如何用注意力机制建模
Transformer 最初是给序列建模用的,核心机制是自注意力:序列里每个元素都能和所有其他元素计算相关性,从而直接捕捉长距离依赖。Vision Transformer 的出发点很简单——把图像也当成一个序列。做法是先划分固定大小的 Patch,比如 16×16,每个 Patch 展开成向量再做线性投影得到 Embedding;接着给每个 Patch 加上位置编码,送进标准 Transformer Encoder。模型不靠卷积提取局部特征,而是靠注意力机制自主学习“图像里哪些区域对分类最有意义”。
为什么猫狗分类这种不算特别复杂的任务也值得用 ViT?因为猫和狗的区别不止在耳朵、鼻子这些局部特征,整体姿态、背景、毛发纹理的组合也很重要。CNN 受限于感受野,需要堆很多卷积层才能看到全局;ViT 从第一层开始就让每个 Patch 与全局所有 Patch 做注意力交互,全局信息一步到位。当然这不是说 ViT 一定吊打 CNN,Transformer 缺少卷积的归纳偏置,需要更多数据或更强的正则化才能收敛好,但在中等规模数据集上用数据增强和预训练权重也能让它表现稳定。这个项目正好提供了一个最小样本:用 ViT 观察注意力机制如何在图像分类上工作。
2.2 项目目录结构与运行依赖
拿到源码后建议先看目录结构,不要直接开训练脚本。这个项目属于典型单包工程,模型定义、数据读取、训练推理逻辑全部拆在 src 和 scripts 里,目录大致长这样:
cats_vs_dogs_vit/ ├── src/ │ ├── __init__.py │ ├── config.py # 超参数与路径配置 │ ├── dataset.py # 数据集加载与增强 │ ├── model.py # ViT 模型定义 │ ├── engine.py # 训练与验证流程 │ └── utils.py # 日志、checkpoint、绘图工具 ├── scripts/ │ ├── train.py # 训练入口 │ ├── predict.py # 单张图片推理 │ └── visualize_attn.py # 注意力可视化 ├── data/ │ └── cats_dogs/ │ ├── train/ │ │ ├── cat/ │ │ └── dog/ │ └── val/ │ ├── cat/ │ └── dog/ ├── checkpoints/ # 模型权重保存目录 └── requirements.txt这种结构把模型、数据、流程分开,改其中一块不会牵连其他部分。依赖方面,核心是 PyTorch 和 torchvision,torchvision 负责大部分数据增强和 ImageFolder 格式的数据加载;其余还有 numpy、matplotlib、tensorboard,写在 requirements.txt 里一键安装就行。环境建议 Python 3.8 以上,CPU 也能跑通,但训练速度会慢很多,有 NVIDIA 显卡时最好安装对应 CUDA 版本的 PyTorch。
看到 train 和 val 都按子目录分类了吗?这是 torchvision.datasets.ImageFolder 的标准组织方式,它会自动把子目录名当成类别标签,不需要自己写 csv 标签文件。所以后面换数据集时,只要把图片按类别放进对应子目录,其余代码基本不用改。
2.3 参数配置与实验设计
训练超参数集中在 src/config.py,这个项目用 argparse 暴露参数,方便命令行覆盖。我整理了一份核心参数表,训练时改动最频繁的就是这些:
| 参数 | 默认值 | 说明 |
|---|---|---|
| img_size | 224 | 输入图片缩放边长 |
| patch_size | 16 | Patch 边长 |
| num_classes | 2 | 猫/狗两类 |
| embed_dim | 192 | Patch Embedding 输出维度 |
| depth | 12 | 堆叠 Transformer Block 数量 |
| num_heads | 3 | 注意力头数 |
| lr | 3e-4 | 初始学习率 |
| batch_size | 32 | 单卡 batch size |
参数逻辑需要说清楚:embed_dim 是每个 Patch 映射后的向量长度,depth 决定模型深度,num_heads 需要能被 embed_dim 整除,否则前向会直接报维度错误。img_size 和 patch_size 共同决定序列长度,也就是 num_patches=(img_size//patch_size)^2,224 和 16 的组合得到 196 个 Token,这是 ViT 计算量的主要来源。如果显存紧张,优先减小 batch_size 或 img_size;如果改 patch_size,位置编码维度会跟着变,涉及预训练权重时容易翻车,这个在避坑章节单独展开。
3. 核心模块拆解:从 Patch Embedding 到 Encoder 的完整链路
3.1 Patch Embedding:一张图如何变成一组 Token
ViT 把图像转成 Token 序列的第一步就是 Patch Embedding。这里没有用手动切图再循环 Linear 的笨办法,而是用一个 stride 等于 kernel_size 的卷积一步完成。代码如下:
import torch from torch import nn class PatchEmbed(nn.Module): """把图像切块并线性投影为 embedding""" def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=192): super().__init__() self.img_size = img_size self.patch_size = patch_size self.num_patches = (img_size // patch_size) ** 2 # 用卷积实现 patch 切块:kernel_size 和 stride 相等 self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # 输入 x: [B, 3, 224, 224] x = self.proj(x) # [B, embed_dim, 14, 14] x = x.flatten(2) # [B, embed_dim, 196] x = x.transpose(1, 2) # [B, 196, embed_dim] return x这段代码的前向流程不复杂:Conv2d 的 kernel_size 和 stride 都等于 patch_size,滑动过程中每个窗口恰好覆盖一个不重叠的 Patch,输出通道就是 embed_dim;然后 flatten(2) 把 14×14 的特征图压平成 196 个向量,最后转置成[B, num_patches, embed_dim]。为什么用卷积而不是 unfold?因为后续如果加载公开预训练权重,PyTorch 里的 timm 和原生实现也多数用相似结构,卷积写法最稳妥。
参数方面,img_size=224、patch_size=16 时 num_patches=196,这就是进入 Transformer 前的序列长度。如果你把 patch_size 改成 8,序列长度会变成 784,注意力矩阵的存储复杂度随序列长度平方增长,显存会非常紧张,所以这个值不要轻易调小。
3.2 Multi-Head Self-Attention 与 Transformer Block
Token 序列准备好后,接下来是 ViT 的主力模块:多头自注意力。每个 Token 都通过三个线性变换生成 Query、Key、Value,然后计算注意力权重并加权聚合其他 Token 的信息。核心实现如下:
class MultiHeadSelfAttention(nn.Module): def __init__(self, embed_dim=192, num_heads=3, qkv_bias=True, attn_drop=0.1): super().__init__() self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.scale = self.head_dim ** -0.5 self.qkv = nn.Linear(embed_dim, embed_dim * 3, bias=qkv_bias) self.attn_drop = nn.Dropout(attn_drop) self.proj = nn.Linear(embed_dim, embed_dim) def forward(self, x): B, N, C = x.shape # 一次线性变换生成 Q、K、V,再按头拆分 qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, self.head_dim) qkv = qkv.permute(2, 0, 3, 1, 4) # [3, B, heads, N, head_dim] q, k, v = qkv.unbind(0) attn = (q @ k.transpose(-2, -1)) * self.scale 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注意 qkv 这一句:一个 Linear 同时输出三份向量,再 reshape 成 5 维张量,unbind 之后得到独立的 q、k、v。scale 是缩放因子,防止点积结果过大导致 softmax 梯度消失;这里 head_dim=64,scale 就是 1/8。每个注意力头关注不同的图像区域,有的可能关注猫耳朵,有的关注背景轮廓,这正是注意力机制可解释性的来源。
自注意力之上还要叠加残差、LayerNorm 和前馈网络,组成完整的 Transformer Block:
class TransformerBlock(nn.Module): def __init__(self, embed_dim=192, num_heads=3, mlp_ratio=4, dropout=0.1): super().__init__() self.norm1 = nn.LayerNorm(embed_dim) self.attn = MultiHeadSelfAttention(embed_dim, num_heads) 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)) # Pre-LN 残差 x = x + self.mlp(self.norm2(x)) return x这里用的是 Pre-LayerNorm 结构,也就是先归一化再进注意力层,训练更稳定。MLP 的中间维度是 embed_dim 的 4 倍,激活函数用 GELU,比 ReLU 更平滑,在 Transformer 里是标配。每个 Block 内部都是“注意力 + 残差 + 归一化 + MLP”的组合,depth=12 就是重复堆叠 12 次。
3.3 分类 Token 与整体前向传播
有了 Patch Embedding 和 Transformer Block,最后一步是组装完整模型。ViT 在序列前额外加了一个可学习的 class token,它的初始向量不来自任何 Patch,而是在训练过程中自己学会汇总整张图的信息,最后用这个 token 的分类结果作为输出。
class VisionTransformer(nn.Module): def __init__(self, img_size=224, patch_size=16, in_channels=3, num_classes=2, embed_dim=192, depth=12, num_heads=3): super().__init__() self.patch_embed = PatchEmbed(img_size, patch_size, in_channels, 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)) self.pos_drop = nn.Dropout(0.1) self.blocks = nn.Sequential(*[ TransformerBlock(embed_dim, num_heads) for _ in range(depth) ]) self.norm = nn.LayerNorm(embed_dim) self.head = nn.Linear(embed_dim, num_classes) def forward(self, x): x = self.patch_embed(x) B, N, D = x.shape cls_token = self.cls_token.expand(B, -1, -1) x = torch.cat([cls_token, x], dim=1) # [B, N+1, D] x = x + self.pos_embed x = self.pos_drop(x) x = self.blocks(x) x = self.norm(x) cls = x[:, 0] logits = self.head(cls) return logits前向流程里最值得注意的就是x[:, 0]:class token 固定在序列开头,经过 12 层 Self-Attention 后,它已经通过注意力聚合了所有 Patch 的信息,所以只取它的向量过分类头就够了。位置编码长度是num_patches + 1,多出的 1 就是给 class token 留的位置。如果加载预训练权重时遇到维度不匹配,问题基本都出在这一行,后面避坑章节会说解决办法。整体参数量在 embed_dim=192、depth=12 时大约 500 万左右,单卡即可训练,相比动辄上亿的大模型,这个体量十分友好。
4. 训练与推理实践:从数据加载到命令行预测
4.1 数据增强与 DataLoader 的配置
数据部分用了 torchvision 的 ImageFolder,只要目录结构符合上一章说的 train/cat、train/dog 这种组织方式,读取和标签生成都自动完成。数据增强对 ViT 尤其重要,因为没有卷积先验,模型对过拟合更敏感,下面的增强组合是我在调参时比较顺手的配置:
from torchvision import transforms from torchvision.datasets import ImageFolder from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.4, 0.4, 0.4), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_dataset = ImageFolder("data/cats_dogs/train", transform=train_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True)RandomResizedCrop 每次随机裁剪一块区域再缩放到 224,等价于尺度扰动和裁剪扰动的组合;RandomHorizontalFlip 对猫狗分类是有意义的,因为猫和狗的朝向不是分类依据;ColorJitter 做亮度、对比度、饱和度扰动,提高模型对光照变化的鲁棒性。归一化用的 mean 和 std 是常规 RGB 统计值,注意 ToTensor 之后数据范围是 0 到 1,归一化要在它之后做。
验证集不要用 RandomResizedCrop 和 ColorJitter,一般只用 Resize 到 256、CenterCrop 到 224 和 Normalize,这样才能公平评估模型效果。num_workers 在 Windows 上建议设成 0 或先跑小批量测试,Linux 下设置成 4 或 8 有明显加速;pin_memory=True 配合 GPU 训练能减少数据拷贝时间。
4.2 训练主循环:损失函数、优化器与学习率调度
训练流程写在 src/engine.py 里,核心循环不长,但有几个细节直接决定能不能收敛。损失函数用 CrossEntropyLoss,优化器选用 AdamW,这是 Transformer 训练的常规组合。下面是一段最简可运行版本:
import torch import torch.nn as nn from torch.optim import AdamW model = VisionTransformer(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) logits = model(images) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() scheduler.step() # 每个 epoch 结束后做一次验证并保存 checkpoint validate(model, val_loader, criterion, epoch)weight_decay 在 AdamW 里只作用于权重矩阵,不作用于偏置和 LayerNorm 的 gamma/beta,实现上 AdamW 已经帮你处理了。clip_grad_norm_ 是给梯度裁剪,值设在 0.5 左右,防止训练前期梯度爆炸。学习率调度用余弦退火,但 ViT 在训练初期需要 warmup,常见做法是前 5 到 10 个 epoch 把学习率从 0 线性升到 3e-4,再走余弦退火,这个项目在 config.py 里已经默认开启了 warmup。
如果你是第一次跑这个项目,我建议把 epoch 设成 50 而不是 300,先看模型能不能过拟合一小批数据。如果几十步内 loss 持续下降,说明模型实现和数据链路没问题;如果 loss 纹丝不动,优先怀疑学习率太高或太低,而不是盲目加层数。
4.3 推理脚本与注意力可视化
训练完成后推理很简单:加载权重、预处理单张图片、过一次前向、取 argmax。难点在于让输出的概率有意义,所以要过 softmax:
from PIL import Image import torch def predict(image_path, model, transform, device): image = Image.open(image_path).convert("RGB") x = transform(image).unsqueeze(0).to(device) with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) pred = logits.argmax(dim=1).item() return pred, prob[0, pred].item() # 输出:0 表示猫,1 表示狗预测脚本里有个小坑:PIL 读图后要记得.convert("RGB"),否则遇到灰度图或 PNG 透明通道,输入张量的通道数会变成 1 或 4,和模型的 3 通道输入对不上。transform 必须和验证集完全一致,尤其 Normalize 的 mean 和 std,否则训练准确率很高但推理效果一塌糊涂。
项目里还有一个 visualize_attn.py,用 PyTorch 的 forward hook 把最后一层 Transformer Block 的注意力权重取出来,叠加到原图上显示热力图。具体做法是在 MultiHeadSelfAttention 前向时把 attn 保存下来,然后对多个头的权重求平均,再 resize 到原图尺寸。这样你能直观看到模型到底在关注猫的耳朵还是狗的嘴巴,这对排查错误样本非常有价值。
5. 避坑指南:ViT 训练中的血泪经验与排查思路
5.1 损失不下降:学习率与数据增强的锅
现象:训练前几个 epoch,loss 一直在 0.7 附近抖动,完全看不到下降趋势。现象描述的是二分类交叉熵在随机猜测时对应的损失值,非常典型。
原因:ViT 没有 CNN 那种局部性先验,对初始化、学习率和数据增强强度更敏感。最常见的两个诱因是学习率设到了 1e-3 以上,或者 ColorJitter 的扰动范围设置过大,导致模型根本学不到稳定的视觉特征。
解决:先把数据增强里的 ColorJitter 去掉,只保留 RandomResizedCrop 和 RandomHorizontalFlip;再把 lr 降到 1e-4 起步,打开 warmup,让学习率前 5 个 epoch 从 0 缓慢爬升。按这个组合重跑,一般十几个 epoch 能看到明显下降。
5.2 显存溢出:patch_size 调整带来的序列爆炸
现象:img_size=224、patch_size=16 时训练正常,把 patch_size 改成 8 后,刚进入第一个 epoch 就报 CUDA out of memory。
原因:patch_size 减半后,num_patches 从 196 变成 784,注意力矩阵的大小是B × num_heads × N × N,N 变成原来的 4 倍,矩阵存储量就变成 16 倍。这个项目默认只有 3 个头,显存压力相对小,但如果 embed_dim 再调大,OOM 几乎是必然。
解决:优先把 batch_size 减半或改成 16,这是成本最低的调整。如果还不行,要么保持 patch_size=16,要么把 img_size 缩到 160 或 128 重新计算位置编码。想进一步提升可以考虑稀疏注意力或线性注意力,但这就属于对模型架构的较大改动了。
5.3 验证集精度震荡:warmup 没开或 batch_size 太小
现象:训练 loss 在下降,但验证准确率每个 epoch 之间波动很大,一会 90% 一会 80%,最后保存的 checkpoint 不一定是最好的一次。
原因:Transformer 对学习率峰值很敏感,如果 warmup 阶段缺失或太短,参数前期会在最优解附近来回横跳;batch_size=16 时梯度噪声偏大,也会加剧震荡。
解决:开启 warmup 并设置至少 5 到 10 个 epoch,配合余弦退火让学习率逐步降下来;有条件的话把 batch_size 提到 64,同步调大学习率到 5e-4 左右。另外验证时建议保留每个 epoch 的准确率,用验证集最优模型替代最后一个 epoch 的权重。
5.4 预训练权重位置编码不匹配:load_state_dict 报尺寸错误
现象:想用公开预训练权重提升小数据集表现,加载时却报 size mismatch,pos_embed 和 cls_token 相关的键名全部对不上。
原因:预训练权重通常基于 224×224 输入、patch_size 16,得到的是 197 个位置编码向量;如果你把输入改成了 160×160,位置编码长度就变短了,直接加载必然失败。
解决:常见的处理办法是双线性插值把 pos_embed 的 2D 形状缩放到目标尺寸,再重新拼接 cls_token 的位置向量。加载时设strict=False,只加载匹配的层,分类头和位置编码保留随机初始化或单独处理。这个操作建议封装成工具函数,避免每次手工拼接出低级错误。
6. 二次开发思路:把猫狗分类换成你的数据集
这个项目被问得最多的改造需求就是换数据集。其实操作路径很固定:准备新数据目录,保持 train/类别和 val/类别这种 ImageFolder 结构;然后改两个地方——num_classes 改成你自己的类别数,以及让 transform 的输入尺寸适配你的图片分辨率。下面是把二分类改成四分类的最小改动示例:
from src.model import VisionTransformer model = VisionTransformer( img_size=224, patch_size=16, num_classes=4, # 改成你的类别数 embed_dim=192, depth=12, num_heads=3, )如果你的数据是灰度图,在数据集类里做一次image.convert("RGB"),让模型输入保持三通道。类别数变化后,分类头self.head的线性层参数会重新初始化,这属于正常现象,不需要额外处理。训练时记得把 config 里的 num_classes 同步改掉,否则模型调用和训练标签不一致会直接前向报错。
下一步改造是模型轻量化。如果你的数据量不大,没必要从 ViT-Base 起步,这个项目默认的 embed_dim=192 已经够用。还可以把 depth 从 12 减到 6,参数量几乎减半,训练速度更快,在猫狗分类这种任务上精度下降通常可控。想要更激进,可以换用蒸馏得到的轻量 ViT 结构,或者把 patch_size 改回 16、把 img_size 降到 160,都能明显减少计算量。
更实用的功能增强是注意力可视化。我在调试时就靠 visualize_attn.py 找出了一批训练集里的疑难样本——比如白猫在白色背景上,模型的注意力会漂到画面边缘。用最后一层所有注意力头平均后的热力图,比对模型实际关注的区域,能指引你补充针对性数据或者调整预处理。我把这个可视化函数改造成了通用的 hook 实现,换到任何 ViT 变体上都能用。
把猫狗分类换成自己的任务时,我自己的习惯是强制走一遍完整流程:先拿 100 张小样本过拟合验证模型链路,再看验证集最优权重下的注意力热图,最后才调学习率和增强策略。这样每一步出问题都能快速定位,而不是一上来就开满 300 个 epoch,跑了一天一夜才发现数据路径配错了。这套流程也推荐给你,希望帮到你。
本文还有配套的精品资源,点击获取