简介这份资源面向计算机视觉初学者与图像分类实践者提供一套已完成划分的森林树叶图像分类数据集可直接用于模型训练与算法验证。数据共176个类别按文件夹组织data目录下分train与test两个子集训练集14,755张、测试集3,598张配合ImageFolder即可加载无需额外预处理也可作为YOLOv5分类任务的数据源。压缩包内共2000个文件以1998张jpg图像为主另含1个py可视化脚本与1个json字典文件整体约133.63MBjson记录176种树叶的类别映射py脚本随机抽取4张图片即可展示并保存到当前目录无需修改即可运行。目前已有744人学习下载适合希望快速上手图像分类、验证网络结构或开展迁移学习实验的读者目录结构清晰便于按类别检索与复现。1. 176类森林树叶图像分类数据集从拿到手到跑出第一个baseline森林树叶识别这件事听起来像是植物学家的活儿但真正动手做的时候你会发现它本质上是一个细粒度图像分类问题——176个类别类间差异可能只是叶缘锯齿的深浅、叶脉走向的弧度甚至同一棵树不同朝向的叶片在光照下呈现出的纹理都不一样。我拿到这个数据集的第一反应是终于不用自己扛着相机去林子里拍叶子了。已做数据集划分意味着train/val/test已经分好省去了最容易被做错的一步——按类别分层抽样。这个数据集适合谁想做细粒度分类发论文的研究生、要落地林业巡检或植物识别的工程师、以及拿它当图像分类教学案例的高校老师。176类不算多但足以让你把ResNet、EfficientNet、ViT这些主干网络跑一遍对比也足够暴露过拟合、类别不均衡、预处理不一致这些经典坑。接下来我会按“先看清数据长什么样、再选模型、再训练调参、最后避坑”的顺序把整个流程拆开讲。2. 先摸清176类森林树叶数据集的底细目录结构、类别分布与预处理2.1 拿到数据集先别急着写DataLoader很多人拿到一个图像分类数据集第一件事就是torchvision.datasets.ImageFolder一把梭然后直接开训。这种做法在类别均衡、图像质量统一的数据集上没问题但森林树叶数据集往往有几个隐藏特征类别样本数可能从几十到几百不等、图像尺寸不统一、部分类别存在明显的光照或背景差异。你如果不在训练前把这些摸清楚后面loss不降或者val_acc震荡的时候根本不知道是模型问题还是数据问题。我一般会先跑一段统计脚本把每个类别的样本数、图像尺寸分布、通道模式RGB还是带alpha全部打出来。下面这段代码可以直接抄import os from pathlib import Path from PIL import Image from collections import defaultdict DATA_ROOT Path(./forest_leaves_176) # 替换成你的实际路径 SPLITS [train, val, test] for split in SPLITS: split_dir DATA_ROOT / split if not split_dir.exists(): print(f[跳过] {split} 目录不存在) continue class_counts defaultdict(int) size_stats defaultdict(int) mode_stats defaultdict(int) for cls_dir in sorted(split_dir.iterdir()): if not cls_dir.is_dir(): continue for img_path in cls_dir.glob(*): if img_path.suffix.lower() not in {.jpg, .jpeg, .png, .bmp}: continue class_counts[cls_dir.name] 1 try: with Image.open(img_path) as im: size_stats[im.size] 1 mode_stats[im.mode] 1 except Exception as e: print(f[损坏] {img_path}: {e}) counts list(class_counts.values()) print(f\n {split} ) print(f类别数: {len(class_counts)}) print(f总图片数: {sum(counts)}) print(f每类最少: {min(counts)}, 最多: {max(counts)}, 均值: {sum(counts)/len(counts):.1f}) print(f尺寸分布(top5): {sorted(size_stats.items(), keylambda x: -x[1])[:5]}) print(f通道模式: {dict(mode_stats)})这段脚本做三件事统计每个split的类别数和样本数、统计图像尺寸分布、统计通道模式。逻辑很直白但输出信息量很大。如果train和val的类别数不一致说明划分有问题如果尺寸分布极其分散说明预处理阶段必须统一resize如果出现RGBA或L模式说明要强制转RGB否则后续归一化会报错。参数方面DATA_ROOT指向数据集根目录SPLITS按实际划分命名调整常见的是train/val/test也有train/valid/test的写法。2.2 类别不均衡的判断与处理策略176类森林树叶数据集大概率存在长尾分布——常见树种样本多稀有树种样本少。你跑完上面的统计脚本后如果发现最多类别和最少类别的样本数差距超过5倍就要考虑处理策略了。常见做法有三种一是加权采样用WeightedRandomSampler给少数类更高采样概率二是损失加权在CrossEntropyLoss里传weight参数三是数据增强倾斜对少数类做更强的增强。我一般会先用加权采样的方式跑一版baseline因为它不改变损失函数的形式调起来直观。代码大概长这样import torch from torch.utils.data import WeightedRandomSampler from collections import Counter # 假设train_dataset是ImageFolder实例 targets [s[1] for s in train_dataset.samples] class_counts Counter(targets) num_classes len(class_counts) # 每个类别的权重取倒数样本越少权重越高 class_weights {cls: 1.0 / count for cls, count in class_counts.items()} sample_weights [class_weights[t] for t in targets] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) train_loader torch.utils.data.DataLoader( train_dataset, batch_size32, samplersampler, # 注意用了sampler就不能再设shuffleTrue num_workers4, pin_memoryTrue )这里的关键参数是num_samples一般设成和训练集总样本数一致保证每个epoch看到的图片数量和正常训练差不多。replacementTrue表示有放回采样少数类会被反复抽到。注意用了sampler之后shuffle必须去掉否则PyTorch会报错。这个方案的好处是实现简单坏处是少数类被重复采样后可能过拟合所以后续要配合早停或者更强的数据增强。2.3 预处理流水线训练和验证必须分开写图像分类里最容易翻车的地方之一就是训练和验证用了同一套预处理。训练阶段需要随机裁剪、随机翻转、颜色抖动这些增强手段验证阶段只需要resize和归一化。如果你把增强也用到验证集上val_acc会剧烈震荡你根本判断不了模型到底有没有收敛。我一般会写两个transformfrom torchvision import transforms # 训练增强随机性越强泛化越好但训练时间也越长 train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.3), # 树叶方向不固定垂直翻转也合理 transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.2, hue0.05), transforms.RandomRotation(degrees30), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 验证/测试只做确定性的resize和归一化 val_tf 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)意思是随机裁取原图60%到100%的区域再resize到224。这个范围对树叶数据集比较合适因为叶片在图像中的占比本身就有大有小。RandomVerticalFlip的概率我给了0.3而不是0.5因为虽然树叶方向不固定但垂直翻转后的叶脉纹理和自然状态还是有差异给太高可能引入不自然的样本。归一化的mean和std用的是ImageNet的统计值如果你从头训练而不是用预训练权重可以改成自己数据集的统计值但用预训练权重时必须保持一致。3. 选主干网络与训练策略从ResNet到ViT176类怎么选3.1 先用ResNet-50跑通再考虑换模型我见过太多人一上来就上ViT或者Swin Transformer结果训练半天不收敛最后连baseline都没跑出来。176类森林树叶数据集图像数量如果在一万到几万之间ResNet-50配合预训练权重是最稳的起点。原因很简单ResNet-50在ImageNet上预训练后浅层已经学到了通用的边缘和纹理特征树叶分类恰好高度依赖纹理和形状迁移效果通常很好。用timm库加载预训练模型最方便import timm import torch.nn as nn model timm.create_model( resnet50, pretrainedTrue, num_classes176 # 直接替换分类头 ) # 如果显存不够可以冻结前几层 # for name, param in model.named_parameters(): # if layer1 in name or layer2 in name: # param.requires_grad False device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)timm.create_model的num_classes参数会自动替换最后的全连接层不需要你手动改model.fc。如果你显存紧张可以冻结layer1和layer2只训练后面的层但这样做的前提是你的数据集和ImageNet差异不能太大。树叶数据集和ImageNet的自然图像分布还算接近冻结浅层通常没问题。3.2 学习率、优化器和调度器的参数怎么设训练策略这块我一般用AdamW配合余弦退火学习率从3e-4开始。为什么不用SGD因为SGD对学习率太敏感176类细粒度分类的loss曲面比较复杂AdamW的自适应学习率更容易在初期快速下降。但AdamW的权重衰减要设对我一般用0.05比默认的0.01大一些因为细粒度分类容易过拟合。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR optimizer optim.AdamW( model.parameters(), lr3e-4, weight_decay0.05 ) # 余弦退火从3e-4降到1e-6共跑50个epoch scheduler CosineAnnealingLR( optimizer, T_max50, eta_min1e-6 ) criterion nn.CrossEntropyLoss(label_smoothing0.1)label_smoothing0.1是我强烈建议加的。176类分类里有些类别的边界本身就很模糊硬标签会让模型过度自信label smoothing相当于给目标分布加了一点噪声能提升泛化。T_max设成总epoch数eta_min是最小学习率一般设成初始学习率的1/100到1/1000。3.3 什么时候该换ViT或EfficientNetResNet-50跑通之后如果你发现val_acc卡在某个值上不去了可以考虑换模型。EfficientNet-B3在参数量和精度之间平衡得比较好适合显存有限的场景。ViT-B/16需要更多的数据才能发挥优势如果你的森林树叶数据集总图片数超过5万可以试试ViT如果只有一两万张ViT很容易过拟合不如用ResNet-50加更强的数据增强。换模型的时候注意一点不同模型的输入尺寸可能不同。ResNet-50默认224EfficientNet-B3是300ViT-B/16是224。你如果换了模型但没改transform里的resize尺寸精度会掉得莫名其妙。我一般会把输入尺寸和模型名绑定成一个配置字典避免这种低级错误。4. 训练循环与验证把每个epoch的指标都记下来4.1 训练循环里必须记录的东西训练循环不是只跑loss.backward()就完事了。我一般会在每个epoch结束后记录训练loss、训练acc、验证loss、验证acc、当前学习率、以及每个类别的验证acc。最后一项特别重要——176类里如果有几个类别acc一直是0说明模型根本没学到这些类的特征可能是样本太少或者标注有问题。import torch from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in tqdm(loader, desc训练): imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total imgs.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 class_correct defaultdict(int) class_total defaultdict(int) for imgs, labels in tqdm(loader, desc验证): imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) loss criterion(outputs, labels) total_loss loss.item() * imgs.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total imgs.size(0) for p, l in zip(preds.cpu().tolist(), labels.cpu().tolist()): class_total[l] 1 if p l: class_correct[l] 1 per_class_acc { cls: class_correct[cls] / class_total[cls] for cls in class_total } return total_loss / total, correct / total, per_class_accevaluate函数里用torch.no_grad()装饰器关闭梯度计算节省显存。per_class_acc字典记录了每个类别的准确率训练结束后可以排序看看哪些类别最差。如果某些类别acc长期低于0.3要么是样本太少要么是这些类之间的视觉差异确实太小需要考虑用更强的特征提取器或者引入注意力机制。4.2 早停与模型保存策略我一般会保存验证acc最高的那个epoch的权重而不是最后一个epoch的。因为176类分类训练到后期验证acc可能会波动最后一个epoch不一定是最好的。早停的patience设10到15个epoch如果连续这么多个epoch验证acc没有提升就停。best_acc 0.0 patience 12 wait 0 for epoch in range(1, 51): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc, per_class_acc evaluate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch}: train_loss{train_loss:.4f} train_acc{train_acc:.4f} fval_loss{val_loss:.4f} val_acc{val_acc:.4f} lr{scheduler.get_last_lr()[0]:.6f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) wait 0 print(f - 保存最优模型val_acc{best_acc:.4f}) else: wait 1 if wait patience: print(f早停触发最优val_acc{best_acc:.4f}) break这段代码里scheduler.step()放在验证之后调用因为CosineAnnealingLR是按epoch更新的。torch.save只保存state_dict而不是整个模型这样加载的时候更灵活不会因为代码结构变化而失败。5. 避坑与排查176类森林树叶分类里最容易翻车的5件事5.1 现象训练loss正常下降但验证acc一直在0.5%左右原因验证集的类别索引和训练集对不上。ImageFolder是按文件夹名称的字母顺序分配类别索引的如果train和val的文件夹名称不完全一致或者某个类别在val里缺失索引就会错位。解决用同一份class_to_idx映射。最稳妥的做法是把train和val合并成一个ImageFolder再手动划分或者显式检查两个数据集的class_to_idx是否一致assert train_dataset.class_to_idx val_dataset.class_to_idx, 类别映射不一致5.2 现象训练到第10个epoch左右验证loss突然飙升原因学习率太大模型在局部最优附近震荡后跳出去了。AdamW的初始学习率3e-4对某些数据集可能偏大。解决把初始学习率降到1e-4或者加warmup。warmup的做法是前5个epoch线性增加学习率之后再按余弦退火下降。timm库自带CosineLRScheduler支持warmup可以直接用。5.3 现象某些类别的准确率始终为0原因这些类别的样本数太少加权采样后虽然被抽到的次数多了但每次都是同样的几张图模型记住了但没泛化。解决对这些类别做离线增强把样本数扩增到至少每类100张。增强手段包括旋转、裁剪、颜色抖动、加噪声。如果扩增后还是不行考虑把这些类别合并到相近类别或者直接标记为“难类”单独分析。5.4 现象GPU显存够但训练速度很慢原因num_workers设成了0数据加载在主进程里串行执行GPU一直在等数据。解决把num_workers设成4或8同时开pin_memoryTrue。如果还是慢检查是不是用了太复杂的增强比如RandAugment可以先用简单的翻转裁剪跑通再逐步加增强。5.5 现象测试集准确率比验证集低很多原因验证集被“偷看”了。如果你根据验证集的表现反复调参、换模型、改增强验证集实际上变成了训练集的一部分测试集才是真正的泛化评估。解决在最终评估之前不要碰测试集。调参全部在验证集上做最后只跑一次测试集。如果测试集和验证集差距超过5个百分点说明验证集太小或者分布和测试集不一致需要重新划分。6. 进阶技巧用混淆矩阵和Grad-CAM定位176类里的“重灾区”跑完baseline之后你手里有了一个val_acc大概在70%到85%之间的模型具体取决于数据集难度和训练策略。这时候不要急着换更大的模型先用混淆矩阵看看哪些类别之间互相混淆。176类的混淆矩阵很大直接画出来看不清我一般会先找出混淆最严重的20对类别再针对性地分析。import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix import seaborn as sns # 收集所有验证集的预测结果 all_preds, all_labels [], [] model.eval() with torch.no_grad(): for imgs, labels in val_loader: imgs imgs.to(device) outputs model(imgs) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) # 找出混淆最严重的类别对 confusion_pairs [] for i in range(len(cm)): for j in range(len(cm)): if i ! j and cm[i][j] 0: confusion_pairs.append((cm[i][j], i, j)) confusion_pairs.sort(reverseTrue) print(混淆最严重的前10对类别) for count, i, j in confusion_pairs[:10]: print(f 类别{i} - 类别{j}: {count}次)这段代码输出的是“真实类别i被预测成类别j”的次数。拿到这些类别对之后你可以把对应的图片调出来肉眼看看通常会发现两种可能要么这两个类别的叶片在视觉上确实极其相似要么标注本身就有问题。如果是前者可以考虑用更细粒度的特征比如叶脉纹理或者引入度量学习如果是后者需要清洗标注。另一个我常用的工具是Grad-CAM它能告诉你模型在分类时“看”了图片的哪个区域。如果模型关注的是背景而不是叶片本身说明数据集的背景泄漏了类别信息——比如某些类别的图片都是在同一种背景下拍的。这种情况在森林树叶数据集里不常见但一旦出现模型在真实场景下的泛化能力会很差。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取ResNet-50的最后一个卷积层 target_layers [model.layer4[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) # 取一张验证集图片 input_tensor val_dataset[0][0].unsqueeze(0).to(device) grayscale_cam cam(input_tensorinput_tensor, targetsNone) visualization show_cam_on_image( input_tensor.squeeze().cpu().permute(1, 2, 0).numpy(), grayscale_cam[0], use_rgbTrue )target_layers指定你要可视化的层ResNet-50一般用layer4[-1]也就是最后一个残差块的最后一层。targetsNone表示用模型预测的类别作为目标你也可以传入真实标签看看模型在正确类别上的关注区域。最后说一个我自己的习惯每次跑完一个数据集我都会把最优模型的per_class_acc排序把最差的10个类别截图保存到一个文件夹里。过一段时间回头看你会发现这些“难类”往往有共同的视觉特征——要么是叶片形状特别接近要么是拍摄角度单一。针对这些类做定向增强或者收集更多数据比盲目换模型有效得多。希望帮到你。本文还有配套的精品资源点击获取