Kaggle自然场景图片分类实战:从数据增强到迁移学习全流程解析

📅 发布时间:2026/10/17 8:47:30
Kaggle自然场景图片分类实战:从数据增强到迁移学习全流程解析
这次要聊的就是3.4 Kaggle自然场景图片分类这个项目。把六类自然照片区分开听起来不难但真正上手跑一遍之后你会发现最花时间的不是搭网络而是搞懂数据、调好增强、选择合适的预训练权重怎么配合。我以一次完整的实战过程为主线把数据观察、预处理、模型选型、训练调参和错误分析都记录下来照着走一遍基本能复现出不错的验证精度。项目本身规模不算大很适合第一次接触图像分类、想完整走通流程的人。我用的数据是我本地整理的一份自然场景图片数据集类别包括建筑、森林、冰川、山脉、海洋和街道六类。每个类别大概有一两千张图片原始分辨率不高很多在 150 到 400 像素之间。和常见的小型场景分类数据集规模相当直接训练一个深度网络容易过拟合预训练权重加微调是更稳妥的路子。下面按实际操作的顺序来写从数据统计开始然后是预处理、模型结构、训练中遇到的几次失败最后是预测阶段能继续提升的小手段。1. 任务定义与数据观察别急着训练先搞清楚场景分类的特殊性很多人拿到图像分类项目第一件事就是翻开网络结构开跑结果模型训练出来精度上不去又回头怀疑是网络太浅。其实问题往往出在没理解任务本身。自然场景图片分类不是难在类别多而是难在类内差异大、类间相似度高。同样是“山脉”这一类晴天、阴天、有雪、无雪、远景、近景拍出来的图像在颜色、纹理、光照分布上差异极大。而“冰川”里大量出现积雪岩石“山脉”里也经常有积雪与裸露岩石两者之间的视觉边界非常模糊。“建筑”和“街道”更是重灾区一张街景里全是房屋立面分类器很难确定到底该归到哪一个。我见过不少人把这类误判归结为“模型精度不够”实际上是从数据分析阶段就没识别出这些混淆风险。我先写了一段脚本统计目录结构和数量分布网上常见的组织方式是dataset/ train/ buildings/ xxx.jpg forest/ xxx.jpg glacier/ xxx.jpg mountain/ xxx.jpg sea/ xxx.jpg street/ xxx.jpg val/ ... test/统计代码大致是这样import os from collections import Counter train_dir dataset/train class_names [buildings, forest, glacier, mountain, sea, street] stats { cls: len(os.listdir(os.path.join(train_dir, cls))) for cls in class_names } print(stats)我本地这份数据的输出结果大概是{buildings: 2568, forest: 2311, glacier: 2499, mountain: 2516, sea: 2262, street: 2311}类别数量整体均衡不存在严重的长尾问题所以当时没有做重采样或者类别加权。不过均衡不代表可以直接训练我还做了一件非常有用的事把每类随机挑几张图拼成一张大图肉眼看一遍。这一步看起来原始却是跳过一堆盲区的最快方式。当时拼完图后有三个很直观的印象很多图片的比例不是正方形有横向构图也有纵向构图直接 resize 成正方形会拉伸变形冰川和山脉的纹理确实像建筑和街道的构图也像必须靠增强策略扩大类间区分部分图片带有较强的暗角或偏色对预训练模型来说属于“分布外”样本训练时容易学偏。有了这些观察再定策略后面每一步都是有的放矢的。1.1 分类目标与评价指标这是单标签多分类任务输出六类中某一类。主流做法是让模型输出每个类别的概率通过 softmax 归一化后取最大概率对应的类别。在我这个项目里评价指标用最简单的分类准确率就够了因为类别均衡准确率基本能反映模型水平。我自己额外记录了每一类的召回率目的不是为了打分而是为了定位混淆问题。后面做错误分析时你会发现总体准确率 92% 和 93% 之间的差距很可能只来自“冰川”和“山脉”这一对类别。只看总体数字会让人误以为模型已经稳定实际上它离可用还有距离。常用框架里实现评价指标非常直接def compute_accuracy(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) out model(imgs) preds out.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / total如果做的是各种比赛最终可能还要看排行榜的提交文件格式但自己的验证阶段就按这个逻辑来。1.2 数据画像尺寸分布与读取方式我顺手统计了一下所有图片的尺寸分布发现很多图短边只有 200 多像素。对于预训练模型而言这个分辨率不算高直接放大到 224 或 256 会损失细节。但这反而是这类数据的真实状态所以与其追求高分辨率不如把模型输入尺寸定在 224让模型学习场景的全局结构特征。读取图片时当时用了常见的 Pillow 后端配合 torchvision 的 Dataset 封装。这类场景任务用传统的数据读取方式就够了不需要搞复杂的带类标的分布式读取。需要提醒的是图片文件偶尔会有损坏或格式异常最简单的防线是读取时捕获异常并跳过from PIL import Image def safe_load(path): try: img Image.open(path).convert(RGB) return img except Exception as e: print(bad image:, path, e) return None这个处理在项目初期帮我滤掉了两张损坏图。如果不加保护训练循环跑到一半突然崩溃排查起来的代价远大于提前加上这几行代码。2. 数据准备与增强把一张图变成一组真实变化的训练语料数据和增强策略决定了一个模型的上限后面的结构设计只是尽量逼近它而已。自然场景图片分类里增强的核心目标有三个一是让模型对翻转、裁剪、颜色变化不敏感二是放大冰川/山脉这类相似类别之间的纹理差异三是变相扩充数据量缓解过拟合。2.1 尺寸统一与归一化的具体做法输入尺寸我定为 224×224这是大多数预训练网络的标准输入尺寸。下面是训练集用的增强组合from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里第一行RandomResizedCrop是关键。它会随机裁剪一块区域再缩放到 224相当于让模型看到同一张图的不同构图对自然场景这种全局特征重要、局部细节也重要的任务非常有效。scale(0.6, 1.0)的意思是只保留原图 60% 到 100% 的面积太低会让裁剪区域丢掉关键场景线索。Normalize用的 mean 和 std 是通用大模型预训练时的统计值。如果你用的是带预训练权重的模型就必须用这个统计值否则相当于把输入分布弄到预训练模型的“舒适区”之外。如果是从零训练自己的网络才建议用数据本身的均值和标准差。验证集和测试集不做什么增强只统一尺寸和归一化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]) ])先放大到 256 再中心裁剪到 224是很多图像分类任务的默认做法。比直接 resize 到 224 好在多利用一点图像中心区域的信息也保留了一部分周边上下文。2.2 增强参数怎么定增强不是越多越好。参数太强会把图片变得面目全非模型学到的是“损坏图像”的特征参数太弱又起不到正则化的作用。我的经验是从比较保守的参数开始比如旋转 ±10 度、颜色抖动幅度 0.2然后根据训练集和验证集的损失差再调。之前我试过一次把旋转角度加到 30亮度抖动加到 0.5结果验证精度反而比不加强增强时低了大概两个百分点。原因是自然场景照片有明确的方向性尤其是建筑和街道旋转过大破坏了墙面、窗户、路面的几何结构模型很难学到真实的边界特征。所以这类任务里旋转角度设置到 10 到 15 度就够水平翻转几乎不带来损失可以放心开。ColorJitter 是我认为针对冰川和山脉最有用的增强之一。雪山和岩石在不同光照下颜色变化很大模型如果只记住“白色冰川、褐色山脉”这种颜色式特征遇到阴天或者晚霞场景就会翻车。适当的颜色抖动能逼着模型学习纹理和形态。2.3 训练集、验证集、测试集的划分我先按 811 把完整数据拆成训练、验证和测试三部分并在拆分时做了分层处理确保每个类别在各文件夹中的比例一致。当时没有直接用随机切分因为随机切分容易让某个类别在验证集里偏多或偏少。划分好之后的数据量大概是训练集 1 万多张验证集和测试集各 1000 多张。对小项目来说这个规模足够了训练一轮的时间也在可接受范围内。值得提一下的是测试集在比赛场景里的用法。如果项目只要求提交结果很多人会反复在测试集上验证最终精度然后根据测试集数字反推调参这其实是危险的。测试集一旦被反复使用就变成了验证集的一部分最终精度会虚高。我的做法是验证集用来调参测试集只在全部确定后跑一次留作最终参考。3. 模型选型与结构设计为什么不从零训练而是站在通用视觉特征肩上到了选模型这一步有个基础问题值得想清楚为什么这类项目普遍用预训练权重而不是从零训练原因在于数据规模。一个深度卷积网络有数百万参数想从随机初始化开始让它学会“什么是冰川、什么是山脉”需要的数据量可能要到几万甚至几十万张图片。数据不够时模型会把训练集里特有的噪声当作有效特征在验证集上表现立刻掉下来。自然场景图片分类的数据量是千到万级别离“从零训练出深度网络”还有距离。所以更理性的方案是加载在通用大型图像数据集上预训练好的权重把网络当作一个已经能提取通用视觉特征的特征提取器只替换最后一层分类输出。3.1 迁移学习的原理与常见误解迁移学习的基本思想是预训练模型在做大规模图像分类时已经学会了大量底层视觉特征比如边缘、拐角、纹理、颜色组合。这些特征是通用的不会因为目标变成“自然场景”就失效。我们要做的只是在此基础上学习“哪些特征组合属于冰川哪些属于山脉”这种更高层的语义。常见的误解是只要把最后一层换掉随便训练几轮就能得到好效果。实际上预训练模型的前面层可以保持不动但后面层和分类头必须精心调。我当时把预训练模型看成一个有分工的团队浅层管边缘纹理深层管语义形状分类头管最终决策。3.2 我选的网络结构与全连接层改造我最终选了一个标准 50 层残差结构的预训练网络也就是常说的基础残差网络模型因为它兼具速度和精度在这个数据规模上不容易过拟合。替换最后一层时是这样做的import torch.nn as nn from torchvision import models model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) model.fc nn.Linear(model.fc.in_features, 6)之后我采用了两阶段训练法。第一阶段冻结主干网络的所有参数只训练新的分类头。这一步让分类头先学会在预训练特征之上做初步划分for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True第二阶段解冻最后两个残差块和分类头用一个较小的学习率微调。这样既不会破坏浅层通用特征又能让深层结构更好地适应自然场景数据。for name, param in model.named_parameters(): if layer4 in name or fc in name: param.requires_grad True else: param.requires_grad False两阶段的好处是稳定。一上来就把所有层放开微调预训练权重很容易在最初几步就被大学习率破坏先固定主干让分类头收敛再一起微调整个训练过程会平滑很多。3.3 优化器和损失函数的选择理由损失函数用标准的交叉熵原因很简单这是多分类任务的天然选择它比较模型输出的概率分布与真实类别的 one-hot 分布。PyTorch 里的CrossEntropyLoss已经把 softmax 和交叉熵计算合在一起了所以模型最后一层输出的是原始 logits不需要手动再套 softmax。优化器我最后用了 AdamW初始学习率 1e-4。这里有个我调整过的细节迁移学习任务的初始学习率不宜太大。之前用过 1e-3 训练结果损失曲线前几个 epoch 直接冲到很高的值后面再也降不回来。预训练权重已经被训得很好了不需要太大的步长去“探索”反而是小步长微调更安全。我还用了一个轻量级的学习率衰减策略每训练几个 epoch 把学习率降为原来的 0.1配合验证集上的早停机制整体训练非常稳定。4. 训练过程与调参记录完整重放一次跑通和两次失败这一节我想把实际训练过程中踩过的坑重放一遍。很多教程只会给出最终配置但真实项目里最浪费时间的就是这些“看上去能跑、实际效果差”的配置组合。4.1 第一次失败学习率过大带来的震荡一开始我图省事没有走两阶段微调而是解冻所有层用一个 1e-3 的学习率直接开训。结果是训练集 loss 在前 3 个 epoch 快速下降到了第 4 个 epoch 突然跳高之后一直在 1.5 到 2.5 之间震荡验证精度始终停留在 70% 上下。损失曲线震荡的原因很明显预训练权重被大步长破坏。模型的前几层本来已经有了很好的边缘检测能力结果被反向传播的大梯度推离原来的状态深层网络一时半会儿修复不回来只能在乱走和稍微恢复之间反复横跳。后来我做了个小实验把前几层固定住只跑 10 个 epoch损失曲线立刻平稳下降。这直接印证了问题不是网络结构不行而是训练策略不对。4.2 第二次失败增强过强导致了欠拟合第一次跑通后我又想把精度往上提一截于是大幅增强数据随机旋转 30 度、亮度抖动 0.6、随机抹除也开得很猛。结果训练集准确率只能到 85%验证集也维持在 86% 左右。这个结果乍一看像是模型能力不足但实际上是增强强度太大每张图都被改得“超现实”模型很难从被破坏的数据里学到稳定的判别特征。我后来的判断标准是如果训练集本身都学不到 90% 以上先不要怀疑网络容量优先检查增强参数是不是过于激进。增强的目的不是把图片变得越花越好而是让模型看到同一语义在各种合理变化下依然成立。自然场景里旋转 30 度的街道图片已经违背真实拍摄习惯模型学到的特征也没有迁移价值。4.3 第三次调整与成功收敛的配置回到保守增强配置后我按下面的流程走冻结主干只用 1e-3 学习率训练分类头 5 个 epoch解冻layer4和fc用 1e-4 学习率训练 10 个 epoch再把所有层解冻用 5e-5 学习率整体微调 5 个 epoch。整套训练大约用了 20 个 epoch验证准确率最后稳定在 93% 左右测试集上也拿到了相近的结果。这里的学习率策略其实遵循一个直觉越往后训练模型越接近最优区域更新步子要越来越小。前几轮如果步子大容易跳过最优点最后几轮步子小才能在原区域精修。关键超参数记录如下表阶段冻结状态优化器学习率轮数分类头热身冻结主干AdamW1e-35深层微调解冻 layer4AdamW1e-410全模型微调全解冻AdamW5e-55批大小选择了 32。显存完全够用的时候可以适当调大但 32 在这个数据规模下能让梯度估计更稳定并且配合增强已经足够快。4.4 早停与模型保存我用验证集准确率作为早停指标每跑完一个 epoch 在验证集上评估一次。如果当前准确率超过之前的最佳值就保存一次模型权重。这样做有两个好处一是训练轮数不用拍脑袋二是中间过程即使发生过拟合波动最终留下的也是最优状态。保存模型的代码大致是这样best_acc 0.0 for epoch in range(epochs): train_one_epoch() val_acc evaluate() if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)建议不要只看训练集 loss 下降就保存模型因为训练集 loss 下降不代表验证集表现变好尤其是在增强比较强的情况下。5. 预测、混淆分析与后续还能怎么改进训练完成不代表项目结束。我通常会把测试集的预测结果拉出来分析模型到底在哪类样本上犯错这比单纯盯着准确率数字有意义得多。5.1 测试时增强带来的稳定提升当时我在预测阶段做了一件小改动让最终结果又提升了一点测试时增强英文简称 TTA。做法很简单预测时把每张图本身和水平翻转后的图都送进模型得到两组概率再取平均最后用平均后的概率做类别判断。def predict_with_tta(model, img, device): model.eval() with torch.no_grad(): prob torch.softmax(model(img.to(device)), dim1) flip_img torch.flip(img, dims[3]) prob_flip torch.softmax(model(flip_img.to(device)), dim1) final_prob (prob prob_flip) / 2 return final_prob.argmax(dim1)水平翻转不改变场景语义但对模型来说相当于看到同一个场景的另一个角度。两个概率平均后一些置信度接近的误判会被拉回正确类别。我实测下来的收益大概在 0.5 到 1 个百分点不算大但几乎零成本。如果进一步用多尺度预测比如分别用 224、256、288 尺寸输入再平均概率效果还能再提升一点但推理时间会成倍增加。为了控制篇幅和算力我当时只用了水平翻转这一项。5.2 混淆矩阵冰川与山脉、建筑与街道把预测结果按真实标签分组统计可以得到一个 6×6 的混淆矩阵。我这边最容易出错的两组是冰川与山脉。这两类的主要差异往往在冰川拥有更多的冰体纹理和更平缓的表面而山脉有明显的山脊结构。可一旦图片里都是积雪模型看到的就是大范围白色纹理很容易产生混淆。翻看具体图片时有些冰川图几乎有一半画面是雪坡和雪山非常接近。建筑与街道。街道照片的核心内容其实也是建筑物立面只是多了道路、车辆、行人等元素。模型如果过度关注墙体纹理就会把街景当建筑。针对这两类混淆后续可以做的方向有两个一是增大训练数据里这些困难样本的权重二是尝试让模型不只关注全局场景还关注中局部的对象关系比如用注意力机制强化道路区域或山脊区域的特征。很多人会直接堆更深的网络但其实对这类场景混淆先做错误分析再针对补数据更有效。5.3 后续可试的方向如果还想继续深挖我的建议是按下面的优先级往下走。先把骨干网络换大一点比如从 50 层残差结构升级到更深版本同时降低训练 batch并配合更小的学习率。更大的模型通常能捕获更细的纹理差异对冰川和山脉这对分类可能有帮助。然后是模型集成。找两到三个不同结构的预训练网络比如残差网络和轻量化网络各训一份权重预测时把概率平均。集成会把每个模型的奇奇怪怪的失误互相抵消掉一般能稳定提升一到两个点。最后想提一下实际比赛或工业落地里这类任务往往还会要求输出置信度和不确定度。我建议每个人都在训练流程里多记录一次测试集的可信样本和不可信样本看看模型在哪些天气、光线条件下不可靠。自然场景的分类问题不会只靠模型结构解决数据和错误分析才是真正的天花板。就我个人的实操体验来说这个项目最值得保留的习惯是每次调整超参数前先记录当前模型的混淆矩阵和训练曲线每次改动只变一个变量。前期我因为同时改学习率、增强参数和解冻策略出了问题时完全定位不到原因后来规规矩矩一次只改一项训练过程立刻变得可控。希望这些记录能帮你少走一段弯路。