ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

天气图像分类实战:数据集划分检查、PyTorch加载与ResNet18训练指南

天气图像分类实战:数据集划分检查、PyTorch加载与ResNet18训练指南 简介4种自然天气分类图像数据集是面向图像分类任务的一套可直接使用的数据资源适合机器学习、深度学习初学者练习天气识别模型也可用于迁移学习或教学演示。数据集已完成训练集与测试集划分train下901张、test下224张图片按类别分文件夹存放使用PyTorch的ImageFolder即可直接读取无需额外整理。资源共1128个文件以jpg图片为主体另含少量jpeg、png图片以及1个可视化展示脚本、1个json配置文件压缩包整体约91.93MB。可视化脚本无需修改即可运行能随机展示一张图片并保存结果便于快速检查各类别样本。目前已有173人学习使用适合需要开箱即用的天气图像数据集、希望节省数据预处理时间的图像分类项目开发者。1. 一个已划分好的 4 种自然天气分类图像数据集拿到手先检查的三件事做自然天气分类任务时卡住人的往往不是模型而是数据晴天、雨天、雪天、阴天四类图像混在一起标注口径不一更麻烦的是很多公开数据集没把 train/val/test 划好训练时还得自己面对类别不均衡和同源图片混入的问题。这篇要讲的是一个已经按标准方式划分好的 4 种自然天气分类图像数据集每个类别一个子目录划分固定标签映射明确。拿到这种「已做数据集划分」的图片分类数据集第一件事不是换网络而是核三样东西划分粒度、类别比例、单类样本量然后快速跑一个 baseline。它适合刚入门需要真实数据的初学者也适合一周内要交差一个天气分类可复现实验的工程师。2. 读透划分与目录train/val/test 的比例和标签映射怎么核在这个数据集被丢进 DataLoader 之前我会花十分钟把目录结构完整看一遍。图片分类任务里的「已做数据集划分」本质上是一份关于文件系统的约定。目录结构写得越规范后面代码里需要硬编码的东西就越少换机器、换人接手时翻车的概率也越低。2.1 标准目录结构长什么样每类一个子目录的约定常见的做法是把根目录分成 train、val、test 三个子目录每个子目录下再按类别建子目录图片按类放进对应的文件夹。这种结构可以直接交给torchvision.datasets.ImageFolder使用weather_dataset/ ├── train/ │ ├── cloudy/ │ │ ├── 00001.jpg │ │ └── ... │ ├── rain/ │ ├── shine/ │ └── snow/ ├── val/ │ ├── cloudy/ │ └── ... └── test/ ├── cloudy/ └── ...这里的类别名 cloudy、rain、shine、snow 是英文目录名。实际项目里也可能见到 sunny、rainy、snowy 这类命名甚至带中文目录名。我的建议是保留英文目录名做标签映射中文名只在文档里标注否则后续写绘图脚本、导出报表还要过一层编码转换纯属给自己加活。把目录结构看完后我一般会顺手确认一件事每个类别子目录下是不是只有图片文件有没有混入Thumbs.db、.DS_Store、desktop.ini这类系统文件。ImageFolder 会忽略非图片后缀但 find 统计数量时很容易把这些隐藏文件算进去导致你误以为某个类有更多样本。这一步不花时间却能让后面的统计脚本少一次莫名其妙的排查。2.2 用两行命令复核划分每类图片数量的统计脚本目录结构看完接下来就是量化。我会先跑一个 bash 循环把每个 split 下每个类别的图片数量全部打出来for split in train val test; do echo $split for cls in cloudy rain shine snow; do count$(find weather_dataset/$split/$cls -type f \( -name *.jpg -o -name *.png \) | wc -l) echo $cls: $count done done这段脚本的逻辑很直白外层循环遍历 train、val、test 三个划分内层循环遍历四个类别目录用find配合-name只统计 jpg 和 png 文件wc -l输出数量。注意我用了\( -name *.jpg -o -name *.png \)把两个后缀条件包起来这比单独find -name *.jpg更稳因为有些数据集里混着两种格式。参数说明如果数据集里另有*.jpeg或*.bmp在后缀条件里对应加上即可。输出结果里如果某个类在 train 里有几千张在 val 里却只有几十张就要警惕这个划分是否按类别做过均衡——小验证集的指标波动会大到让你误判模型好坏。2.3 划分比例的分层逻辑为什么 val/test 要从每类内部抽讲一个大家容易忽略的点划分比例不是从总数里切一刀那么简单。自然天气数据集里四类样本量天然不均衡晴天和阴天的图片往往比雨天雪天更容易采集。如果只做全局随机划分样本少的那一两类在验证集里的数量可能只有个位数算出来的准确率根本没有统计意义。「已做数据集划分」的价值就在这里它通常采用的是按类别分层的划分方式先按类把样本打散再从每一类内部按固定比例抽 train/val/test。这样无论晴天占六成还是雨天只占一成四个类别在三个划分里的占比基本一致。拿到数据集后我会把 2.2 的统计结果整理成一张小表直接比对每类在 train 和 val 中的占比差异超过 2% 的话后面做类别加权时就得格外小心。注意测试集的设计目的是「只跑一次」。用测试集反复调参会让它形同虚设这也是做数据集划分时最容易违反的纪律。3. 用 PyTorch 写一个可复现的加载器ImageFolder 与三套增强的取舍目录读透之后数据加载就是下一个技术决策点。常见的做法是直接用torchvision.datasets.ImageFolder因为它的目录约定恰好就是我们前面看到的「每类一个子目录」结构。它的优点是不用自己写__getitem__和标签映射缺点是很多细节是隐式的比如类别索引按字母序生成、标签从 0 排到 3。我会把加载逻辑做成三层先加载并打印信息再做增强最后统一封装进 DataLoader。3.1 最小可跑的加载代码ImageFolder 一行生成类别索引from pathlib import Path from torchvision import datasets, transforms data_root Path(./weather_dataset) # 统一预处理缩放、转张量、ImageNet 归一化 base_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 直接加载 train / valImageFolder 按子目录名生成类别索引 train_set datasets.ImageFolder(data_root / train, transformbase_transform) val_set datasets.ImageFolder(data_root / val, transformbase_transform) # 打印类别映射确认标签索引和目录名的对应关系 print(train_set.class_to_idx) # 统计每个类别的样本数核对是否和 2.2 的 bash 结果一致 for cls in sorted(train_set.classes): idx train_set.class_to_idx[cls] cnt sum(1 for _, label in train_set.samples if label idx) print(f{cls}: {cnt})这段代码里最容易被忽视的是class_to_idx。ImageFolder 会按目录名的字母序生成索引cloudy 排在 rain 之前所以顺序大概率是{cloudy: 0, rain: 1, shine: 2, snow: 3}。你的标注文件、混淆矩阵图、分类报告里如果用了不同的类序后面所有输出都会对不上。参数说明Resize((224, 224))是配合预训练模型的固定输入尺寸Normalize的均值方差用的是 ImageNet 统计值后续加载预训练权重时这套归一化参数必须保留否则迁移效果会明显变差。打印class_to_idx是调试阶段必做的一步别凭直觉猜类别顺序。3.2 训练集和验证集的 transform 为什么要分开天气数据的增强边界天气分类和其他图像分类不太一样模型需要学习的特征分布在颜色、光照、纹理三个维度上每个类别的差异往往很微妙。晴天和阴天的区别可能只是对比度和色温雨天除了雨丝纹理还有整体的灰蓝色调。所以数据增强不能做太狠否则会把「类别差异」增强掉。# 训练集适度随机增强聚焦光照与裁剪 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) # 验证/测试集只做确定性变换 val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])训练集和验证集用两套 transform是分类任务里最基本的纪律但因为太基础反而经常有人出错。曾经见过有人把 RandomResizedCrop 也用在验证集上结果 val 曲线抖得像心电图——因为每一轮验证都在看不同裁剪位置的图片。参数说明RandomResizedCrop的scale(0.6, 1.0)比默认的(0.08, 1.0)温和很多因为天气分类是场景级任务不需要像细粒度分类那样靠极端裁剪区分品种ColorJitter的幅度也偏保守brightness 0.2、saturation 0.1 只做微扰动。如果数据集中阴天和晴天本身就在亮度上高度重叠增强幅度再大就会让模型彻底分不清这两类。3.3 批量加载前先做三件小事统计类别、抽看样本、查损坏图片加载器写好了先别急着训练。我会把训练集图片以九宫格形式画出来看一遍重点看三点每类图片是不是真的符合常识rain 类里有没有混入雪天、分辨率跨度大不大、有没有灰度图或者带水印的图。这一步用 matplotlib 几行就能做但它对后面判断模型行为帮助巨大——比如模型 val_acc 卡在 85% 上不去你第一反应是调参但也许只是因为 rain 类里混了一堆阴天图。import matplotlib.pyplot as plt import torchvision.utils as vutils import numpy as np # 取训练集前 16 张图做成网格预览 sample_loader DataLoader(train_set, batch_size16, shuffleTrue) images, labels next(iter(sample_loader)) grid vutils.make_grid(images, nrow4, normalizeTrue) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.title( | .join(train_set.classes[i] for i in labels[:4])) plt.axis(off) plt.show()另外还要检查图片完整性。数据在搬运、压缩、解压过程中出现截断 jpg 的概率不低这类图片会在训练时触发OSError: image file is truncated轻则打断训练重则让 DataLoader 卡死。稳妥的做法是在加载之前先用 PIL 全量扫一遍from PIL import Image import os corrupted [] for img_path, _ in train_set.samples: try: with Image.open(img_path) as im: im.load() except Exception as e: corrupted.append((img_path, str(e))) print(f损坏图片 {len(corrupted)} 张) # 对损坏文件执行 Image.open(fp).verify() 也能查但 verify 后不能直接 load这里im.load()会把图片真正解码进内存比verify()更容易暴露数据截断问题。查出的损坏文件不要直接在原目录里删先挪到一个_corrupted目录暂存确认不是误报再处理——这是做数据清理时最稳妥的习惯。4. 训练一个 4 分类 baseline模型选型、调参与指标设定数据加载这一关过了训练部分反而简单。四分类天气分类属于典型的场景分类任务在公开榜单上那些 96%、97% 的准确率绝大多数来自预训练模型加一点点微调而不是从零设计网络。所以这一章的目标是给出一套能复现的 baseline模型选型、训练参数、评估指标一次讲清楚。4.1 选型理由为什么 ResNet18 是天气四分类的稳妥起点选 base 模型时我一般不会直接上 ResNet50 或 EfficientNet而是先用 ResNet18。原因有两个第一这个任务数据量只有几千张属于小样本场景模型过大反而更容易过拟合第二天气分类的特征集中在颜色和全局纹理上ResNet18 的特征提取能力在这个难度下已经够用训练速度快迭代试错成本低。import torch import torch.nn as nn from torchvision import models # 加载 ImageNet 预训练权重 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 替换最后一层全连接为 4 分类输出 num_features model.fc.in_features model.fc nn.Linear(num_features, 4) # 冻结主干参数只训练 fc 层 for name, param in model.named_parameters(): if not name.startswith(fc.): param.requires_grad False这段代码的关键点在于冻结主干。预训练模型在 ImageNet 上已经学到了通用的边缘、颜色、纹理特征用在天气分类上不需要再大动干戈。只训练最后一层全连接参数量小几分钟就能训完一个 epoch且在小数据集上不容易过拟合。参数说明weightsmodels.ResNet18_Weights.IMAGENET1K_V1是 torchvision 当前推荐的加载方式旧写法pretrainedTrue已经不推荐。如果你不想每次都从网络下载权重可以提前把权重文件放到缓存目录torchvision 会自动复用。4.2 冻结主干与解冻策略从分类头过渡到全量微调只冻结主干训出来的模型有一个上限fc 层学到的特征组合是主干特征的线性映射而主干特征是从 ImageNet 学来的不一定最贴合「天空 天气」这个场景。所以我的做法分两步走先用 1e-3 的学习率训练 fc 层 10 个 epoch把分类头调到稳定再解冻主干用更小的学习率继续训练让主干特征向天气数据偏移。from torch.optim.lr_scheduler import CosineAnnealingLR # 第一步只训练 fc 层 optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3 ) # 训练 10 个 epoch 后解冻全部参数 for param in model.parameters(): param.requires_grad True # 第二步全量微调学习率调低一个量级 optimizer torch.optim.AdamW(model.parameters(), lr1e-4) scheduler CosineAnnealingLR(optimizer, T_max10)这里有一个很常见的坑解冻后如果直接沿用 1e-3 的学习率主干参数会在前几步就被冲乱loss 瞬间飙升。所以全量微调时学习率必须降下来一般比分类头低 5 到 10 倍。余弦退火在这一步的价值在于它让学习率在最后几个 epoch 衰减到接近零模型有机会在 loss 曲面里找到更深的极小点。4.3 学习率、epochs 与 batch_size一套能直接用的参数组合我自己常用的参数组合是batch_size 32、初始学习率 1e-3fc 阶段、微调学习率 1e-4、epochs 通常 20 到 30。显存方面224 输入加 ResNet188G 显存完全够跑 32 的 batch 甚至还能往上加CPU 机器也能跑但慢建议num_workers4以上。train_loader DataLoader( train_set, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue, ) val_loader DataLoader( val_set, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue, ) # 训练循环骨架 epochs 20 best_val_acc 0.0 for epoch in range(epochs): model.train() running_loss 0.0 for x, y in train_loader: x, y x.cuda(), y.cuda() optimizer.zero_grad() out model(x) loss nn.CrossEntropyLoss()(out, y) loss.backward() optimizer.step() running_loss loss.item() * x.size(0) model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for x, y in val_loader: x, y x.cuda(), y.cuda() out model(x) val_correct (out.argmax(1) y).sum().item() val_total y.size(0) val_acc val_correct / val_total print(fepoch {epoch1:02d} | loss {running_loss/len(train_set):.4f} | val_acc {val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_weather_model.pth)代码里best_val_acc这段是重点它保存的是验证集上表现最佳的权重而不是最后一个 epoch 的权重。天气分类数据量小验证集准确率在最后几个 epoch 往往会上下波动取 best 而不是 last 是这类小样本训练的标准习惯。参数说明shuffleTrue在训练时是必须的否则每个 epoch 看到的是同一条样本顺序梯度更新方向会周期性震荡验证集不需要 shuffle。pin_memoryTrue在 GPU 训练时能减少数据传输时间但如果你的机器内存吃紧可以关掉。5. 避坑清单从目录读取到训练结束的 5 个翻车现场这一章是我自己在这个数据集方向上踩过的坑的汇总。每条都按「现象 → 原因 → 解决」写希望能给你省掉几天的排查时间。5.1 坑一ImageFolder 的类别索引和你预想的不一致现象训练正常验证准确率也很高但画出混淆矩阵后完全错位——模型预测的「rain」对应的是实际标签里的「shine」。原因ImageFolder 按目录名字母序生成类别索引class_to_idx是{cloudy: 0, rain: 1, shine: 2, snow: 3}。如果团队里有人用中文目录名重命名过或者从压缩包解压后目录顺序变化索引顺序就会和你的标注文件对不上。解决任何用到标签的地方第一件事打印train_set.class_to_idx用这一份输出作为唯一标准。写分类报告、画混淆矩阵时全部通过class_to_idx做映射不要硬编码[cloudy, rain, shine, snow]这个顺序。5.2 坑二验证集的 transform 里混进了随机增强现象val_loss 曲线震荡剧烈val_acc 忽高忽低看起来像是模型没有收敛。原因有人图省事直接把train_transform复用给验证集。RandomResizedCrop 每次裁的位置不同ColorJitter 每次的颜色偏移也不同验证集的数据每轮都在变指标自然不稳定。解决验证和测试统一使用确定性变换Resize((224, 224)) ToTensor Normalize。这条纪律适用于所有分类任务和数据集无关。发现指标异常波动时第一个排查的就是这个。5.3 坑三只看整体准确率忽略了类别不均衡的遮蔽效应现象整体 val_acc 达到 90%表面看不错但拆开看每类准确率雪天只有 60%雨天 70%晴天和阴天把整体准确率拉了上去。原因天气数据集中晴天图片往往最多。模型只要把晴天和阴天分对整体准确率就不会太低小类错多少都被大类的正确预测掩盖了。解决训练时关注 macro-F1 而不是整体准确率每个 epoch 都打印classification_report。如果类别不均衡明显给 CrossEntropyLoss 传入按样本量反比计算的权重。在 PyTorch 里就是torch.nn.CrossEntropyLoss(weightclass_weights)权重的计算建议放到 4.3 的统计代码之后自动完成。5.4 坑四雪天和阴天互相误判调参压不下去现象混淆矩阵里 snow 和 cloud 的互误判稳定在 20% 上下怎么调学习率、加增强都没有明显改观。原因雪天和阴天在 RGB 颜色空间中的分布高度重叠都是低饱和、中高亮度区域。神经网络在这个特征空间里只能靠纹理细节区分而 Resize 到 224 会把雪地里的细小颗粒纹理磨平等于把鉴别性信息提前丢了。解决先把输入尺寸从 224 提到 256 试一轮看混淆矩阵有没有变化。如果还不行把训练图换成 LAB 颜色空间输入L 通道保留亮度、A/B 通道保留色度对比往往比在 RGB 里硬调更快见效。这个问题的本质是特征空间的边界问题而不是模型容量的边界问题。5.5 坑五模型把背景当成了天气特征现象训练集里晴天图片的背景大多是城市建筑验证集里换成山地晴天准确率骤降。原因这是数据采集偏差不是模型过拟合。模型学到的是「有这种楼 晴天」而不是「天空是什么样的 晴天」。Grad-CAM 可视化能看到模型激活区域集中在建筑物上而不是天空区域。解决检查训练集里每类图片的背景多样性。如果某类图片大量是同一个拍摄场景找额外的自然图片补充该类别。天气分类的数据增强救不了这种偏差只能从数据多样性层面解决。这也是为什么我在第 2 章反复强调要先抽看样本——背景单一的问题在九宫格预览阶段就能看出来。6. 把模型边界看清楚Grad-CAM、混淆矩阵与划分一致性校验准确率只是一个数字真正能指导下一步决策的是模型的错误模式。这一章给三个进阶验证手段每个都能回答一个具体问题模型在看哪里、错在哪两类、划分是否可靠。第一个手段是 Grad-CAM。它回答的问题是「模型决策时到底看图像的哪个区域」。实现方式是在 ResNet18 的layer4最后一层卷积累积注册前向和反向 hook用梯度加权特征图得到热力图forward_maps [] grad_maps [] def forward_hook(module, input, output): forward_maps.append(output) def backward_hook(module, grad_input, grad_output): grad_maps.append(grad_output[0]) h1 model.layer4[-1].register_forward_hook(forward_hook) h2 model.layer4[-1].register_full_backward_hook(backward_hook) model.eval() x, y next(iter(val_loader)) x x[:1].cuda().requires_grad_(True) out model(x) model.zero_grad() out[0, y[0]].backward() weights grad_maps[0].mean(dim(2, 3), keepdimTrue) cam (weights * forward_maps[0]).sum(dim1).relu() cam cam.squeeze().cpu().detach().numpy() # 双线性插值到 224x224 后叠加到原图上这段代码的关键是register_full_backward_hook拿到的是 layer4 输出端的梯度用梯度在通道维度上求均值得到每个通道的重要性再和该层的前向特征做加权求和。最后把热力图缩放到输入尺寸叠加在原图上就能看到模型的注意力位置。第二个手段是导出一份完整的分类报告和误分类样本清单。classification_report看每类的 precision、recall、f1-score而误分类样本清单则能告诉你「被错分的图长什么样」from sklearn.metrics import classification_report report classification_report(y_true, y_pred, target_namesval_set.classes, digits3) print(report) misclassified [ (path, y_true[i], y_pred[i]) for i, (path, _) in enumerate(val_set.samples) if y_true[i] ! y_pred[i] ] pd.DataFrame(misclassified, columns[path, true, pred]).to_csv(misclassified.csv, indexFalse)第三个手段是划分一致性校验。我的习惯是训练结束后把 val 和 test 的预测类别分布分别画出来看两者是否接近。如果 val 和 test 的分布明显分离说明划分不够随机或者两组数据来自不同的采集场景。这个校验只要统计每个类别的预测占比就能做却能直接定位「是不是划分本身有问题」——比盲目调参高效得多。这三样工具组合起来一个 4 分类天气模型的边界就被描述得很清楚了哪些类容易混、模型看的是不是关键区域、划分是否可信。我每次拿到新数据集都会按这套流程走一遍也算是我自己的血泪经验。模型准确率低不可怕可怕的是你连它为什么低都不知道。这套验证流程能帮你把「黑匣子」撬开一道缝希望帮到你。本文还有配套的精品资源点击获取
返回列表