news 2026/9/13 9:44:38

苹果叶片病害多标签分类:LeNet与ViT对比及ArcFace损失实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
苹果叶片病害多标签分类:LeNet与ViT对比及ArcFace损失实践

简介:基于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 vec

build_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_size64224x224输入下单卡无压力
epochs50第35轮附近容易出现验证集过拟合
lr1e-3Adam默认从这个量级起步
weight_decay1e-4叶片纹理复杂,不加正则很容易过拟合
img_size224与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 x

embed_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 576

ViT的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个百分点。

对比维度CrossEntropyLossArcFaceLoss
收敛速度快,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参数传入,测试阶段就按调过的阈值输出。再把阈值扫描结果和裁剪对照表补进设计报告的验证部分,整个项目从数据处理到损失函数再到阈值调优就都有数据支撑了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 9:42:20

零基础实现苹果级3D网页交互:Spline工具全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 9:42:09

OI 中浮点累加的误差补偿:Kahan 求和算法原理、实现与实战

OI 中浮点累加的误差补偿:Kahan 求和算法原理、实现与实战 【免费下载链接】OI-wiki :star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法) 项目地址: https://gitcode.com/GitHub_Trending/oi/OI-w…

作者头像 李华
网站建设 2026/9/13 9:39:14

存算一体SoC如何解决AI边缘部署的实时性瓶颈

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华