简介:基于CNN网络(LeNet+ViT)的苹果树叶片病害分类识别系统,配套设计报告与说明文档,面向深度学习课程设计、毕业设计或图像分类项目实践,也适合具备一定Python基础的学生参考学习。压缩包共47个文件,包含14个Python源码(涵盖LeNet与ViT的训练、测试、配置及ArcFaceLoss实现)、3个Keras模型权重、1个Jupyter数据分析脚本、设计报告PDF与说明文档MD,以及19张图表,整体大小约17.35MB,目录按LeNet、ViT和文档模块区分,便于检索。目前已有43人学习/下载。资源基于Kaggle FGVC8(CVPR 2021)植物病理学数据集,完整覆盖数据预处理、多标签分类问题转换、ArcFaceLoss与CrossEntropyLoss损失函数对比、模型训练与测试流程,并给出LeNet约65%、ViT约60%的准确率结果及数据集裁剪效果评估。设计报告和说明文档详细记录项目结构、实验思路与运行方式,便于快速复现和二次开发,是课设/毕设的高质量参考资料。
1. 从苹果叶片病害到CNN与VIT的分类选型
第一次跑通Kaggle FGVC8(CVPR2021)苹果叶片病害数据集时,最让我意外的不是模型效果,而是标签格式:同一张叶片上可能同时出现锈病和黑腐病,数据集的Disease列里会用组合标签表达这种叠加情况。也就是说,这个课设表面上写着病害分类,实际做的是多标签分类,单纯套CrossEntropyLoss会直接在训练阶段出错。这份资源把LeNet和Vision Transformer两条路线完整实现了一遍,LeNet稳定在65%准确率,自搭建的ViT也能到60%,还附带ArcFaceLoss和CrossEntropyLoss的对比实验以及设计报告。对正在准备课设、毕设,或者想从零跑通一个CNN和Transformer对比实验的读者来说,最值得拆的是它的数据处理链路和模型头部的设计差异。
2. LeNet网络结构与多标签数据预处理
处理多标签分类,第一步不是选模型,而是把标签从字符串变成模型能学的张量。FGVC8的train.csv里,每行记录一张图片对应的Disease名称,同一张图可能映射到多个病害名称。如果沿用单标签思维直接对类别做argmax,黑腐病和锈病同时出现时会直接丢掉一半的真值。这套项目采用multi-hot编码,每个类别一个输出维度,病害叠加的图会在对应多个维度上同时置1。
2.1 数据加载与multi-hot编码
data_preprocessing.py里做标签编码的核心逻辑,是先把csv里所有标签拆词去重,建立类别索引表,再逐行映射:
def build_class_index(csv_path): all_labels = set() df = pd.read_csv(csv_path) for row in df.itertuples(): for label in str(row.Disease).replace(",", " ").split(): all_labels.add(label.strip()) return sorted(all_labels) def encode_label(labels_text, class_index): vec = torch.zeros(len(class_index), dtype=torch.float32) for label in labels_text.replace(",", " ").split(): label = label.strip() if label in class_index: vec[class_index[label]] = 1.0 return vecbuild_class_index遍历整个csv收集全部独立标签;encode_label对每一行生成一个与类别数等长的0/1向量。注意这里没有把multiple_diseases这类组合标签二次拆分成原始病害,因为在FGVC8里它本身就是一个标注类别。如果自己改写成"healthy置0,同时给具体病害置1",会改变数据原始分布,设计报告里就得多解释一层,所以原项目保持了multi-hot原样。
数据加载部分,输入尺寸统一为224x224,与后续ViT对齐;图片读取用PIL打开转RGB后resize,没有用RandomResizedCrop,因为多标签场景下随机裁剪太狠容易把病灶区域裁掉,数据增强只用了随机水平翻转和ColorJitter。
2.2 LeNet结构重建与全连接层调整
LeNet-5是最经典的卷积神经网络结构之一,原本为32x32灰度手写数字设计,两个卷积块加三个全连接,参数总量很小。直接搬来处理224x224的三通道叶片图,第一个问题就是全连接层维度爆炸:原始输入经过两次步长2的池化后,特征图边长降到原图四分之一,展平后维度接近五万,老结构根本接不上。项目里的处理方式是保留卷积块,在全连接之前插入AdaptiveAvgPool2d:
class LeNet(nn.Module): def __init__(self, num_classes, in_channels=3): super(LeNet, self).__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 6, kernel_size=5, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.avgpool = nn.AdaptiveAvgPool2d((4, 4)) self.classifier = nn.Sequential( nn.Linear(16 * 4 * 4, 120), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(120, 84), nn.ReLU(inplace=True), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = self.avgpool(x) x = torch.flatten(x, 1) return self.classifier(x)自适应池化的意义在于:无论输入尺寸怎么换,全连接层拿到的向量长度始终是1644=256,比写死维度再反推池化核参数要省心得多。训练时模型最后一层直接输出类别数的logits,forward里不加sigmoid,把sigmoid交给BCEWithLogitsLoss内部处理。新手最容易犯的错就是先手动sigmoid再传给BCEWithLogitsLoss,两者叠加导致收敛极慢,loss长期徘徊在0.7附近不降。
2.3 训练配置与准确率观察
LeNet的训练入口是main_train.py,通过--model参数区分跑哪套网络。CONFIG.py里集中管理数据路径、图片尺寸、学习率和epoch数,复现时只需要在命令行覆盖参数:
python main_train.py \ --model lenet \ --data_dir data/FGVC8 \ --batch_size 64 \ --epochs 50 \ --lr 1e-3 \ --loss bce常用超参数参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| batch_size | 64 | 224x224输入下单卡无压力 |
| epochs | 50 | 第35轮附近容易出现验证集过拟合 |
| lr | 1e-3 | Adam默认从这个量级起步 |
| weight_decay | 1e-4 | 叶片纹理复杂,不加正则很容易过拟合 |
| img_size | 224 | 与ViT保持一致,便于横向对比 |
这套配置下LeNet验证集准确率能稳定落在63%~68%之间,资源里记录的65%是常规水平。需要说明的是,这个准确率用的是严格匹配:一张图的multi-hot预测必须与标签完全一致才算对。如果改成单标签Top-1命中,数字会再高一大截,所以看到65%不要觉得是模型写得有问题,多标签评估本来就比单分类严苛。
3. Vision Transformer的结构拆解与训练观察
ViT的核心思想可以拆成三个词:Patch、Token、Attention。把224x224的叶片图切成16x16的小块,每块拉直后经过线性投影变成token,196个patch token加上一个分类专用的CLS token组成197个向量的序列,再送进标准的Transformer Encoder。模型不再像CNN那样用卷积核扫过整张图,而是让每个patch直接和所有其他patch做注意力交互。
3.1 Patch Embedding与位置编码
VIT/models.py里的PatchEmbedding用一层卷积同时完成切patch和线性投影:卷积核大小等于patch_size,步长也等于patch_size,卷积输出通道数就是embedding维度。等效于把每个16x16x3的patch展开成768维向量再乘一个投影矩阵:
class PatchEmbedding(nn.Module): def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=576): super().__init__() self.grid_size = img_size // patch_size self.num_patches = self.grid_size ** 2 self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): x = self.proj(x) # [B, D, 14, 14] x = x.flatten(2) # [B, D, 196] x = x.transpose(1, 2) # [B, 196, D] return xembed_dim设置为576而不是ViT-Base的768,是为了在单卡上把显存压住。12层Transformer加768维,在224x224输入下显存占用很高,课设场景一般会把深度降到6层、embedding降到576甚至512。
因为自注意力是置换等变的,token顺序被打乱结果不变,所以必须显式加入位置编码。ViT实现里,位置编码初始化为零向量,维度是1x197xembed_dim,和cat了CLS token之后的序列对齐:
class ViT(nn.Module): def __init__(self, num_classes, patch_size=16, embed_dim=576, depth=6, num_heads=8): super().__init__() self.patch_embed = PatchEmbedding(224, 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)) encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=embed_dim * 4, dropout=0.1, activation="gelu", batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=depth) self.head = nn.Linear(embed_dim, num_classes) def forward(self, x): x = self.patch_embed(x) cls = self.cls_token.expand(x.size(0), -1, -1) x = torch.cat([cls, x], dim=1) x = x + self.pos_embed x = self.encoder(x) return self.head(x[:, 0])CLS token是ViT和CNN在分类头上最大的结构差异。CNN通常靠全局平均池化把特征图压成一维,ViT则是在序列前面放一个可学习的向量,让它在若干层自注意力中不断聚合所有patch的信息,最后只在这一位接全连接分类头。病害区域小、分布分散时,CLS token理论上能比池化保留更多全局上下文。
3.2 多头自注意力与CNN卷积的归纳偏置对比
CNN和ViT最本质的差异在归纳偏置。卷积核只有3x3或5x5的局部感受野,但它在空间上滑动,靠堆叠层数逐步扩大感受野。ViT第一层就能看见整张图,因为自注意力的attention矩阵是197x197的稠密矩阵,每个patch都要和全部patch计算相似度。
在苹果叶片病害这个小规模数据集上,这个差异直接体现为数据效率:CNN默认假设邻近像素语义相近,几十个epoch就能把纹理特征学好;ViT把"邻近像素相近"这个先验也交给数据去学,数据量不够时训练曲线会很毛糙,需要更长的warmup、更小的学习率。所以ViT在这份数据上跑出60%准确率并不算差,它是在没有使用任何预训练权重的前提下从头训练的结果,和LeNet的65%对比起来才有参考价值。
训练命令:
python main_train.py \ --model vit \ --batch_size 32 \ --epochs 60 \ --lr 5e-4 \ --loss bce \ --vit_depth 6 \ --vit_embed_dim 576ViT的batch_size降到32,是因为自注意力矩阵随序列长度平方增长,197个token的注意力图在梯度回传时占用显存不小。如果显存仍然吃紧,可以加梯度累积,用--grad_accum 4等效模拟batch_size 128。
3.3 ViT训练过程中的关键调参
最值得说的调参点是学习率warmup。ViT在这个数据上前10个epoch的loss下降明显慢于LeNet,一上来就用5e-4,前几个batch的loss容易跳到NaN。我一般会在VIT/main_train.py里加前5个epoch的warmup逻辑:学习率从1e-5线性升到5e-4,再按余弦方式衰减。复现时注意logs目录下loss曲线的形状:如果前10个epoch还在高位横盘,不要急着加epoch,先检查学习率是不是直接给大了。
第二个观察是过拟合比LeNet更早。ViT参数总量比LeNet大几倍,训练到30个epoch左右验证集指标就开始滞涨甚至回落,训练loss却还在下降。缓解方案是加大Dropout和weight_decay,或者把patch_size从16改成8。但patch减小后token数量会从196变成784,注意力矩阵膨胀到784x784,显存和训练时间都会明显上涨,所以项目最终保留16这个折中值。
4. ArcFaceLoss与CrossEntropyLoss的对比实验
这个项目里最有报告价值的部分,不是两个模型本身,而是ArcFaceLoss和CrossEntropyLoss的横向对比。很多做课设的读者第一次接触ArcFace是在人脸识别里,不知道它也能用在植物病害这种细粒度分类上。两种损失的本质区别在于监督信号的作用位置:CrossEntropy直接约束分类得分,ArcFace在角度空间里对类中心与特征之间的夹角加margin。
4.1 两种损失函数的适用边界
CrossEntropyLoss计算的是预测概率和真实标签的交叉熵,它只要求目标类得分高于其他类,特征分布没有显式约束,类别多时类间混叠比较常见。ArcFace则先把特征向量和分类权重都做L2归一化,让两者的点积等于夹角余弦,再在目标类夹角上加上一个margin,迫使同类样本在单位超球面上聚拢。用人话说:CrossEntropy让模型选得对,ArcFace让模型分得开。
但ArcFace原生是单标签设计,直接拿来做多标签病害识别会有两个坑。第一个坑是标签维度:multi-hot里有多个1,不能直接用CrossEntropyLoss需要的long型标签索引。第二个坑是margin作用方式:负类夹角不动,正类夹角被推开,在softmax里是为了让目标得分相对更突出,如果换成交叉熵或BCE这种逐维度独立优化的损失,需要对每个输出维度单独判断正负。
4.2 多标签ArcFace的工程实现
ArcLoss.py里的实现思路,是把ArcFace的角度margin和BCE的逐标签独立性组合在一起:每个类别拥有独立的类中心向量,正类样本在余弦空间套上margin,负类保持不变,最后用BCEWithLogits做多标签损失:
# ArcLoss.py — 多标签 ArcFace 变体 class ArcFaceLoss(nn.Module): def __init__(self, feat_dim, num_classes, margin=0.3, scale=32): super().__init__() self.margin = margin self.scale = scale self.weight = nn.Parameter(torch.randn(feat_dim, num_classes)) def forward(self, features, labels): w = F.normalize(self.weight, dim=0) # [D, C] 每一列是一个类中心 x = F.normalize(features, dim=1) # [B, D] 归一化特征 cos_theta = torch.mm(x, w) # [B, C] 余弦相似度 theta = torch.acos(cos_theta.clamp(-1 + 1e-7, 1 - 1e-7)) target_mask = labels.bool() target_logits = torch.cos(theta + self.margin) # 正类加 margin logits = torch.where(target_mask, target_logits, cos_theta) return F.binary_cross_entropy_with_logits(self.scale * logits, labels)normalize(dim=0)是对每一列做归一化,对应每个类中心向量;normalize(dim=1)是对每个样本特征归一化。acos把余弦值反解成角度后加上margin,再cos回去,就得到带角度间隔的logits。scale=32把余弦值从[-1,1]放大到[-32,32],让sigmoid输出的概率分布更尖锐,避免梯度消失。torch.where根据multi-hot掩码决定哪个位置取带margin的logits。这个变体和官方人脸识别ArcFace的区别,在于最后没有接softmax,而是用sigmoid加BCE,这样能同时处理多个正类标签。
4.3 损失函数对比结果与复现建议
从logs目录下的训练曲线看,两个损失函数的对比实验有几个稳定现象。一个是ArcFace收敛更慢,前15个epoch几乎看不到提升,CrossEntropy在同样时间已经跑到55%附近;另一个是ArcFace的最终准确率受随机种子影响大,同一套超参数下训练三次,最好和最差能差出3到4个百分点。
| 对比维度 | CrossEntropyLoss | ArcFaceLoss |
|---|---|---|
| 收敛速度 | 快,10轮内见效 | 慢10~15个epoch |
| 最终准确率 | 稳定 | 方差较大 |
| 多标签适配 | 需要改BCEWithLogits | 逐类margin,工程稍复杂 |
| 特征可分性 | 类间有混叠 | 类中心更分散 |
如果目标只是课设里的准确率指标,CrossEntropy(改成BCE)是更省事的方案。ArcFace的价值不在那一个准确率数字上,而在特征分布的可解释性:把最后一层特征用PCA降到二维画散点图,ArcFace组的同类样本明显聚得更紧,这个现象写进设计报告里,比单纯贴两个准确率更有说服力。
5. 数据集裁剪评估与模型验证技巧
5.1 裁剪尺寸对精度的实际影响
FGVC8原始图片里叶片并不居中,背景、土壤和相邻叶片都会进入画面。项目专门做了数据裁剪评估,做法是给每个叶片外接框裁剪后缩放成不同尺寸训练对照组。我复现时的经验是:用224x224做基准,放大到288后LeNet准确率能小幅提升;如果裁剪框太紧裁到病灶边缘,准确率反而会掉1%以上。评估脚本通过CONFIG.py的img_size字段控制:
for size in 224 256 288; do python main_train.py \ --model lenet \ --img_size $size \ --batch_size 64 \ --epochs 30 \ --log_dir logs/lenet_crop_$size done比较各log_dir下的val_acc曲线,差距在1%以内说明裁剪没丢关键信息,超过3%说明外接框太紧,把黑腐病斑或锈斑边缘切掉了。这类对照实验在答辩时非常实用,直接展示了数据预处理对模型性能的影响程度。
5.2 多标签模型的验证指标
多标签分类不能只看准确率,至少要看严格匹配准确率、宏平均F1和汉明损失。main_test.py里可以这样算:
def exact_match_accuracy(y_true, y_pred): return (y_true == y_pred).all(dim=1).float().mean().item() def macro_f1(y_true, y_pred): eps = 1e-9 tp = (y_true.bool() & y_pred.bool()).sum(dim=0).float() fp = ((~y_true.bool()) & y_pred.bool()).sum(dim=0).float() fn = (y_true.bool() & (~y_pred.bool())).sum(dim=0).float() precision = tp / (tp + fp + eps) recall = tp / (tp + fn + eps) f1 = 2 * precision * recall / (precision + recall + eps) return f1.mean().item()exact_match_accuracy要求模型输出的multi-hot预测与标签张量完全一致才算对;macro_f1对每个病害类别单独算F1后取平均,类别不平衡时比准确率更能反映真实效果。main_test.py加载模型时,建议保留验证集上loss最低的检查点而不是最后一个epoch,训练后期过拟合几乎必然发生,best.pth通常能带来1%到2%的测试集提升。
多标签预测还有一个容易被忽略的调优点:sigmoid输出的阈值不一定就是0.5。类别不均衡时0.5往往过于保守,可以在验证集上从0.3到0.7扫一遍,取宏F1最高的点:
best_th, best_f1 = 0.5, 0.0 for th in np.arange(0.3, 0.7, 0.05): pred = (probs > th).float() f1 = macro_f1(labels, pred) if f1 > best_f1: best_th, best_f1 = th, f1把扫出来的阈值写回CONFIG.py,作为main_test.py的--threshold参数传入,测试阶段就按调过的阈值输出。再把阈值扫描结果和裁剪对照表补进设计报告的验证部分,整个项目从数据处理到损失函数再到阈值调优就都有数据支撑了。
本文还有配套的精品资源,点击获取