
简介针对木薯叶病虫害识别这一典型图像分类课题这套基于Transformer模型的Python源码专为深度学习课程设计、期末大作业等场景打造难度适中且经过助教审定下载解压后即可直接运行。压缩包共12个文件其中6个Python脚本构成项目主线分别承担训练入口、全局变量管理、GPU环境设置、模型结构定义和数据读取等职责另有5个pyc为编译生成的字节码缓存1个Markdown文档用于说明项目结构与运行方式整体仅11KB轻量精炼便于快速把握完整代码脉络。该项目已有199人学习浏览适合需要在图像分类方向完成课设、理解Transformer落地流程的学习者参考。下载后既可对照源码梳理自注意力机制在农业病害识别中的建模思路也可直接改参复现训练可帮助巩固PyTorch工程化能力是性价比很高的高分大作业模板。1. 木薯叶病虫害分类这个题目为什么值得从Transformer源码入手木薯叶病虫害分类是典型的“数据量不大、类别不平衡、野外拍摄环境杂乱”的植保图像任务而带“高分项目”字样的Python源码包往往意味着作者已经把Vision TransformerViT这条链路完整串起来了图像分块、位置编码、Transformer编码器、分类头、训练与评估脚本。对正在做课程设计、毕业设计或者想入门视觉Transformer的人来说拿到这类项目源码核心价值不是那个评分而是你能沿着代码把“Patch Embedding怎么把一张叶片照片变成一串Token”这类概念真正落地。这篇文章不假装你手头已经有那份zip里的全部代码只按最常见的工程实现讲清这套东西怎么跑通、参数在哪调、哪些地方最容易翻车。2. Transformer在木薯叶分类上的选型逻辑从全局特征到小样本迁移2.1 为什么木薯叶病害分类不用纯CNN而要用Transformer木薯叶片的病害表现往往不是集中在某一个点。褐斑病可能同时出现在叶缘和叶脉附近花叶病的褪绿斑纹则呈不规则分布叶片整体纹理被打乱。这类“多处分散、互相印证”的特征卷积神经网络需要在深层多次堆叠才能把远距离信息关联起来而Transformer的核心机制是自注意力它从第一层开始就能让任意两个图像块直接交互。对木薯叶这种病斑位置分散的目标全局建模的起点决定了它比纯CNN更合适。但这里有一个很现实的坑Transformer参数多从零训练需要几十万张图打底木薯叶数据集通常只有几千张。所以实际工程里很少有人直接从头训一个ViT更常见的是加载ImageNet预训练权重把最后的分类头换成木薯叶病虫害类别做微调。这种做法在课程设计里叫“迁移学习”在工程里叫“微调底座模型”本质一样让模型先学会看纹理、形状、边缘这些通用视觉概念再花少量数据适配叶片病害的特定模式。选型时还需要知道另一个Transformer变体Swin Transformer。Swin做了层次化设计和窗口注意力对高分辨率图像更友好如果你的木薯叶图片单张超过512像素、样本量超过一万可以优先考虑Swin但如果只是几千张224大小的图经典ViT结构加上预训练权重反而是最好上手、最容易复现的方案。项目标题既然写的是“Transformer模型”最常见的落地选择就是ViT或其轻量变体。2.2 图像分块、位置编码与CLS TokenViT怎么把叶片照片变成一串Token理解ViT的输入构造是改代码之前必须迈过的坎否则你连num_patches为什么是196都算不明白。一张224×224的RGB图像按16×16的Patch大小切分会得到(224/16)×(224/16)14×14196个图像块。每个Patch内含3×16×16768个像素值经过一个线性映射层变成一个768维的向量这就是“Token”。196个Token按顺序排列就好比把一句话拆成了196个“词”。但注意力机制本身不感知顺序所以需要加入位置编码。ViT里常用的是可学习的一维位置编码与Token相加后一起进入Transformer编码器。与此同时序列开头会拼接一个特殊的CLS Token它的初始向量与图片内容无关但在注意力层里会不断聚合其他196个Token的信息最终这个CLS Token对应的输出向量就是整张图的全局表示接一个全连接层就能做分类。一般在动手实现时我会先用torchsummary打印模型结构确认PatchEmbedding输出的张量形状是不是[B, 196, 768]如果改了图片尺寸或Patch大小num_patches必须同步修改这个数值是模型定义的一部分不是运行时自动推断的。2.3 准备木薯叶数据集解压zip、目录结构与类别数量核查拿到项目zip后第一步不是急着跑训练而是把数据整理成PyTorch标准目录train/、val/、test/三个大目录每个大目录下面是类别目录类别目录里放对应图片。以下命令适用于Linux或WSL环境Windows下用Python的zipfile模块也能完成同样的解压操作unzip cassava_leaf.zip -d ./cassava_leaf cd cassava_leaf find . -type f -name *.jpg | wc -l for d in train/*/; do echo $d $(ls $d | wc -l); doneunzip的-d参数指定解压目标目录避免把一堆文件直接炸到当前目录里。find统计总图片数用来判断数据集规模是不是符合Transformer迁移学习的下限。最后的for循环按类别统计图片数量这一步非常关键如果某个类别的图片只有两位数训练时Loss会被多数类带偏需要在后面的CrossEntropyLoss里设置class_weight。我一般还会用python -c from PIL import Image; print(Image.open(xxx.jpg).size)随机抽几张图看尺寸。木薯叶图片很多来自田间手机拍摄尺寸不一。ViT要求固定输入尺寸统一Resize到224×224会损失比例信息所以更推荐用RandomResizedCrop做训练增强验证集用CenterCrop这样既保证尺寸一致又保留一定的缩放不变性。数据集整理好后记得把类别名和索引的对应关系写到一个classes.txt训练脚本和推理脚本都要读同一个映射文件避免“训练时用的类别顺序”和“推理时用的类别顺序”不一致。这种不一致是静默错误模型不会报错但预测结果会整体错位。3. 用PyTorch实现基于Transformer的木薯叶分类从模型搭建到训练调参3.1 核心模型代码自己写一个ViT分类器再换成预训练版本为了让你彻底看懂Transformer在图像上是怎么运作的先给一个最小可运行的ViT实现。实际项目里可以直接替换成预训练权重但结构理解不能跳过。import torch import torch.nn as nn class PatchEmbedding(nn.Module): def __init__(self, img_size224, patch_size16, in_channels3, embed_dim768): super().__init__() self.num_patches (img_size // patch_size) ** 2 # 用卷积实现分块线性映射卷积核大小等于patch大小步长也等于patch大小 self.proj nn.Conv2d(in_channels, embed_dim, kernel_sizepatch_size, stridepatch_size) def forward(self, x): # x: [B, 3, 224, 224] - [B, 768, 14, 14] x self.proj(x) # 展平并转置 [B, 768, 196] - [B, 196, 768] return x.flatten(2).transpose(1, 2) class TransformerEncoderBlock(nn.Module): def __init__(self, embed_dim768, num_heads12, ff_dim3072, dropout0.1): super().__init__() self.norm1 nn.LayerNorm(embed_dim) self.attn nn.MultiheadAttention(embed_dim, num_heads, dropoutdropout, batch_firstTrue) self.norm2 nn.LayerNorm(embed_dim) self.ffn nn.Sequential( nn.Linear(embed_dim, ff_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff_dim, embed_dim), nn.Dropout(dropout), ) def forward(self, x): # 注意这里使用的是Pre-LN结构训练更稳定 x x self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0] x x self.ffn(self.norm2(x)) return x class ViTForCassava(nn.Module): def __init__(self, num_classes5, img_size224, patch_size16, embed_dim768, num_heads12, num_layers8, ff_dim3072, dropout0.1): super().__init__() self.patch_embed PatchEmbedding(img_size, patch_size, 3, embed_dim) self.cls_token nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed nn.Parameter(torch.zeros(1, self.patch_embed.num_patches 1, embed_dim)) self.blocks nn.Sequential(*[ TransformerEncoderBlock(embed_dim, num_heads, ff_dim, dropout) for _ in range(num_layers) ]) 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], dim1) # [B, 197, 768] x x self.pos_embed # 位置编码与Token直接相加 x self.blocks(x) x self.norm(x) return self.head(x[:, 0]) # 只取CLS Token对应的输出这段代码里值得说明的参数有三个。num_heads是注意力头数12个头意味着模型把768维特征分成12组每组64维独立做注意力理论上能捕捉不同类型的病害模式。ff_dim是前馈网络的中间维度ViT论文里设为embed_dim的4倍这个值太小会削弱非线性表达能力太大则显存压力陡增。num_layers是编码器层数8层在这个任务上已经是平衡点12层的完整ViT-Base在小数据集上容易过拟合。不过实际训练时我强烈建议直接用torchvision里带预训练权重的ViT而不是从零跑上面这个模型。代码如下from torchvision.models import vit_b_16, ViT_B_16_Weights model vit_b_16(weightsViT_B_16_Weights.IMAGENET1K_V1) # 替换分类头把原来1000类输出换成木薯叶病虫类别数 model.heads.head nn.Linear(model.heads.head.in_features, num_classes)vit_b_16的预训练权重来自ImageNet-1Kheads.head.in_features在默认结构里是768替换后模型参数会整体加载只有新的分类头是随机初始化的。这一步是迁移学习的关键前几层通用特征直接复用后面语义特征微调适配。3.2 训练脚本数据增强、类别权重和学习率调度一起调木薯叶数据量小增强策略直接决定验证集准确率的上限。下面是一个可用的数据加载和训练配置from torchvision import transforms from torch.utils.data import DataLoader from torch.utils.data.dataset import Dataset train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), # 模拟不同距离拍摄 transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), # 田间光照变化 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])RandomResizedCrop的scale参数我特意设成(0.6, 1.0)因为木薯叶照片里叶片往往不是占满全图的裁掉一部分背景反而能让模型聚焦叶片纹理。ColorJitter用于模拟晴天、阴天、晨昏不同光照条件这是田间数据集最常见的分布偏移来源之一。import torch.nn as nn class_counts torch.tensor([1500, 800, 600, 300, 200], dtypetorch.float32) class_weights 1.0 / class_counts class_weights class_weights / class_weights.sum() * num_classes criterion nn.CrossEntropyLoss(weightclass_weights.to(device)) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-2) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)类别权重的计算思路是先对每个类别的样本数取倒数再归一化。这样样本少的类别贡献的Loss更大模型不会为了把多数类学会而牺牲少数类。AdamW的lr1e-4是ViT微调的经验起点预训练权重下的新分类头可以用5e-4但主干的特征已经足够通用学习率太大会把预训练知识冲掉。调度器选余弦退火训练后期学习率缓慢降低让损失在收敛点附近稳定下降。外层训练循环按常规写法即可但要注意每个epoch之后必须在验证集上评估一次而不是只看训练Loss。Transformer的训练曲线比CNN更敏感经常出现训练Loss下降正常、验证集指标抖动很大的情况这时候优先检查是否过拟合再考虑加大weight_decay或缩小学习率。3.3 评估与导出准确率会骗人还要看混淆矩阵和单类召回率木薯叶病虫害类别的样本量天然不平衡单一准确率没有说服力。训练结束后要计算每个类别的召回率和F1并输出混淆矩阵看看模型到底把哪两类病害搞混了。from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: preds model(images.to(device)).argmax(dim1).cpu() all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(classification_report(all_labels, all_preds)) print(confusion_matrix(all_labels, all_preds)) torch.save(model.state_dict(), cassava_vit_b16.pth)classification_report会输出每个类别的precision、recall、F1。如果发现“花叶病”的召回率只有0.4说明相当一部分花叶病图片被分到了其他类别这时候不要盲目加数据先翻出混淆矩阵看它跟哪个类别混淆针对性补充对应类别的训练样本或调整增强策略。模型保存用的state_dict()只存参数不包含结构定义加载前必须声明同一个模型类否则无法加载。4. 避坑木薯叶分类训练中高频踩坑点与排查顺序4.1 现象训练准确率99%验证集一动就掉训练集Loss降到0.1以下验证集准确率却只有70%出头这是典型的过拟合。Transformer参数量大几千张图很容易被它“背”下来。我先看weight_decay是不是1e-2以上再看增强策略是不是太弱。常见做法是把RandomResizedCrop的scale下限继续调低到0.4打开RandomErasing或者CutMix用遮挡类增强强行让模型学会看局部纹理而不是记整张图的固定样子。4.2 现象验证集表现不错换一批田间照片就崩这个坑最隐蔽原因往往是数据泄漏。很多人做数据集时把同一株木薯的不同叶片照片随机分到了训练集和验证集田间拍摄时同一株的叶片背景、光照高度相似模型记住的是“这张图的背景”而不是“病害特征”。解决方法是按拍摄植株或拍摄批次划分数据集而不是按单张图片随机划分。如果有Exif信息或文件名前缀能标识拍摄批次优先按这个维度分组。4.3 现象训练Loss不降或者直接NaNLoss完全不降先检查学习率。ViT对学习率很敏感1e-3在ResNet上可能没问题在ViT上大概率不收敛。我习惯先把损失函数换成不带weight的普通交叉熵跑5个epoch看是否下降如果下降了说明问题出在类别权重计算上检查权重是否归一化正确。出现NaN十有八九是输入图片里有损坏文件读出来的张量存在异常值解决方法是数据加载时加Image.open的异常捕获把无法解码的图片直接跳过。4.4 现象小数据集上Transformer打不过ResNet这是最现实的问题几千张图、没有预训练权重ViT就是打不过ResNet。解决办法有两个方向。一是换用预训练ViT即使ImageNet上没有木薯叶通用纹理特征依然有效这是最优先的路线。二是数据量实在小到几百张干脆退回ResNet50做baseline等收集数据后再上Transformer工程上不丢人课程设计答辩反而能讲出“从CNN到ViT的迁移演进”这种完整故事。5. 最后一公里用“没见过的照片”验证模型是否真的可用验证集准确率是模型在干净数据上的表现不能代表它在真实场景里的水平。我会在训练结束后专门留出一步推理验证用手机在田间或对着室外叶片照片拍几张图光照、角度、背景都和数据集里的不一样直接丢给模型预测。这一步能暴露两个问题背景干扰和尺度泛化。写一个简单的单图推理脚本加载模型后对输入图片做与验证集完全相同的预处理import torch from PIL import Image from torchvision import transforms model vit_b_16(weightsNone) model.heads.head nn.Linear(model.heads.head.in_features, 5) model.load_state_dict(torch.load(cassava_vit_b16.pth, map_locationcpu)) model.eval() img Image.open(field_photo.jpg).convert(RGB) transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) inputs transform(img).unsqueeze(0) with torch.no_grad(): probs torch.softmax(model(inputs), dim1) top1 probs.argmax().item() confidence probs.max().item() print(分类结果, class_names[top1], 置信度, round(confidence, 4))这段代码里最容易出错的是预处理不一致训练时用Resize(256)CenterCrop(224)推理就必须保持一致如果换成了Resize(224)模型输入分布改变预测结果会明显变差但不会报错。另一个容易被忽略的是map_locationcpu如果你的训练脚本在GPU上保存权重没有这个参数一台没有CUDA的机器直接加载会报错。验证通过之后我还会做一件看似额外但很有用的事把置信度低于0.5的预测结果单独导出到一张表格逐张人工看是什么原因——是病斑太小、是叶片卷曲还是背景里有其他植株的叶子干扰。这类错误样本攒得多了比单纯凑数据更能指导下一轮增强策略。这是我自己一直保留的习惯每次项目都靠这步救回来不少精度。希望帮到你。本文还有配套的精品资源点击获取