简介:这份天气分类数据集面向计算机视觉入门与进阶学习者,以及需要开展图像分类实验的学生和开发者,可用于CNN模型训练、迁移学习对比与数据增强等场景。资源共包含2000个文件,以7987张jpg图像为主体,另附1个py脚本与1个png图片,压缩包约676.81MB,训练集7180张、测试集808张,覆盖cloudy、dew、fogsmog、frost、glaze、hail、lightning、rain、rainbow、rime、sandstorm、shine、snow、sunrise共14个类别,目录按train_data与test_data划分,便于直接加载训练与评估。已有1130人学习下载,作者使用CNN模型取得96.3%的最好结果,说明数据质量与类别区分度较好。读者可基于该数据集复现分类流程、调整网络结构与超参数,并借助脚本快速搭建训练入口,适合作为课程作业、竞赛练习或论文实验的基准数据。
1. 天气分类数据集共9000个样本:从零训练一个能用的天气识别模型
手上有个天气分类数据集,共9000个样本,四个类别——多云、雨天、晴天、日出。这个量级不大不小,刚好卡在一个尴尬的位置:从头训练容易过拟合,直接套用预训练模型又觉得"杀鸡用牛刀"。但实际工程里,这恰恰是最常见的场景——你不可能每次都拿到百万级数据,更多时候就是几千到一万张图,要求你在有限资源下把准确率做到能上线的水平。
这篇文章要解决的问题很具体:拿到这个9000样本的天气分类数据集后,怎么划分、怎么增强、选什么模型、参数怎么调、训练过程中怎么判断模型是不是在"假学"。适合有基本PyTorch基础、想快速跑通一个完整图像分类流程的读者。不涉及任何复杂部署,目标就是让你在单卡上把验证集准确率做到90%以上,并且知道每一步为什么这么做。
2. 数据划分与增强:9000样本怎么切才不翻车
2.1 按类别分层划分,别用随机切分
9000个样本如果按7:2:1划分,训练集6300、验证集1800、测试集900。听起来合理,但如果你直接random.shuffle然后切片,很可能出现某个类别在验证集里只有几十张的情况。天气分类四个类别理论上应该均衡,但实际数据集中"晴天"和"多云"往往偏多,"日出"可能偏少。
正确做法是按类别分层抽样。用sklearn.model_selection.train_test_split的stratify参数,保证每个子集的类别比例和原始一致。
import os import shutil from sklearn.model_selection import train_test_split # 假设数据结构:data/多云/*.jpg, data/雨天/*.jpg, ... data_dir = "data" classes = ["cloudy", "rainy", "sunny", "sunrise"] all_files, all_labels = [], [] for idx, cls in enumerate(classes): cls_dir = os.path.join(data_dir, cls) for fname in os.listdir(cls_dir): if fname.lower().endswith((".jpg", ".png", ".jpeg")): all_files.append(os.path.join(cls_dir, fname)) all_labels.append(idx) # 先切出测试集,再从剩余切验证集 X_train, X_test, y_train, y_test = train_test_split( all_files, all_labels, test_size=0.1, stratify=all_labels, random_state=42 ) X_train, X_val, y_train, y_val = train_test_split( X_train, y_train, test_size=0.2, stratify=y_train, random_state=42 ) print(f"训练集: {len(X_train)}, 验证集: {len(X_val)}, 测试集: {len(X_test)}")这段代码的关键在stratify=all_labels,它确保每个子集中四个类别的比例与原始数据集一致。random_state=42是为了可复现,换个数结果会不同但分布规律一致。注意先切测试集再切验证集,避免测试集信息泄露到验证集。
2.2 增强策略要匹配天气场景
天气分类的图像增强不能照搬ImageNet那套。翻转、裁剪、颜色抖动都可以用,但有几个细节:
- 水平翻转:可以,天气场景没有方向性
- 垂直翻转:不建议,天空不会跑到下面
- 颜色抖动:慎用,天气分类高度依赖颜色和亮度特征,过度抖动会让"晴天"和"多云"混淆
- RandomResizedCrop:可以用,但scale范围别设太激进,0.7到1.0比较稳
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])ColorJitter里hue只给了0.05,因为色相变化太大会把日出的暖色调变成冷色调。Normalize用的是ImageNet统计量,如果你用预训练模型就必须保持一致,从头训练的话可以用数据集自身的均值和标准差,但差别不大。
提示:增强只作用于训练集,验证集和测试集只用Resize+CenterCrop+Normalize。这个坑每年都有人踩。
3. 模型选型:9000样本该用ResNet还是EfficientNet
3.1 预训练权重几乎总是更好的起点
9000样本从头训练一个ResNet-50,训练集6300张,每个epoch只能看到6300次样本。ResNet-50有2500万参数,这个数据量根本喂不饱,训练loss能降下去但验证loss很快反弹——典型的过拟合。
用预训练权重是标准做法。ImageNet上预训练的模型已经学会了边缘、纹理、颜色分布等底层特征,你只需要微调高层语义部分。实际测试中,同样9000样本,预训练ResNet-18比从头训练的ResNet-50验证准确率高8到12个百分点。
import torch import torch.nn as nn from torchvision import models def build_model(num_classes=4, model_name="resnet18", pretrained=True): if model_name == "resnet18": model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT if pretrained else None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) elif model_name == "efficientnet_b0": model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.DEFAULT if pretrained else None) in_features = model.classifier[1].in_features model.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model替换分类头时加了Dropout(0.3),这是小数据集上的常规操作。ResNet18_Weights.DEFAULT是新版torchvision的写法,旧版用pretrained=True,效果一样但新写法更明确。
3.2 冻结策略:先冻后解,分阶段微调
直接全量微调不是不行,但小数据集上容易把预训练学到的特征"冲掉"。更稳的做法是分两阶段:
第一阶段:冻结backbone,只训练分类头。学习率设1e-3,跑5个epoch。这一步让随机初始化的分类头先收敛到一个合理位置,不会产生大梯度回传破坏预训练特征。
第二阶段:解冻全部参数,用更小的学习率1e-4微调。跑15到20个epoch,配合余弦退火调度。
def set_backbone_grad(model, requires_grad): for name, param in model.named_parameters(): if "fc" not in name and "classifier" not in name: param.requires_grad = requires_grad # 阶段一 model = build_model() set_backbone_grad(model, False) optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) # 阶段二(训练5个epoch后) set_backbone_grad(model, True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)filter(lambda p: p.requires_grad, ...)这行很重要,否则优化器会把冻结参数也加进去,虽然不更新但浪费显存。阶段切换时重新构建优化器,因为参数组的requires_grad状态变了。
3.3 批次大小与学习率的搭配
9000样本,batch_size设32或64都行。32的话一个epoch约197步,64约98步。小数据集建议用32,梯度更新更频繁,收敛更稳。
学习率和batch_size的关系:batch_size翻倍,学习率可以适当放大1.5到2倍,但微调场景下不建议超过1e-3。我一般用1e-4起步,如果loss下降太慢再调到3e-4。
4. 训练循环与验证:怎么判断模型是不是在假学
4.1 训练循环里必须记录的东西
很多人训练时只看loss,这是不够的。至少记录:训练loss、训练准确率、验证loss、验证准确率、当前学习率。这四个指标能告诉你模型是在真学还是假学。
import torch from torch.utils.data import DataLoader, Dataset from PIL import Image class WeatherDataset(Dataset): def __init__(self, file_list, label_list, transform=None): self.file_list = file_list self.label_list = label_list self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): img = Image.open(self.file_list[idx]).convert("RGB") label = self.label_list[idx] if self.transform: img = self.transform(img) return img, label def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: 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) correct += (outputs.argmax(1) == 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 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / totalmodel.train()和model.eval()必须成对出现,前者启用Dropout和BatchNorm的更新,后者固定它们。@torch.no_grad()在验证时关闭梯度计算,省显存也加速。
4.2 过拟合与欠拟合的判断信号
- 训练loss降、验证loss也降:正常学习
- 训练loss降、验证loss先降后升:过拟合,需要加正则或早停
- 训练loss不降、验证loss不降:欠拟合,学习率太小或模型容量不够
- 训练准确率很高、验证准确率很低且差距持续扩大:严重过拟合
9000样本用预训练ResNet-18,正常情况下第3到5个epoch验证准确率就能到85%以上,10个epoch内到90%。如果20个epoch还在80%徘徊,检查数据标签有没有错、增强是不是太狠、学习率是不是设错了。
注意:验证集准确率波动2%以内是正常的,别看到一次下降就调参。看趋势,不看单点。
4.3 早停与模型保存
best_acc = 0.0 patience = 7 counter = 0 for epoch in range(25): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f"Epoch {epoch+1}: train_loss={train_loss:.4f} train_acc={train_acc:.4f} " f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") counter = 0 else: counter += 1 if counter >= patience: print(f"Early stopping at epoch {epoch+1}") breakpatience=7意味着验证准确率连续7个epoch没提升就停。这个值别设太小,余弦退火过程中准确率可能先平后升。保存的是state_dict()而不是整个模型,加载时先构建模型结构再load_state_dict。
5. 避坑与排查:9000样本训练中最容易翻车的5个地方
5.1 验证准确率远高于测试准确率
现象:验证集92%,测试集只有78%。
原因:验证集和测试集划分时没有分层,或者验证集被重复用于调参导致信息泄露。更隐蔽的原因是数据集中存在同一场景的连拍图片,随机划分时相似图片同时进入训练和验证集。
解决:按类别分层划分,并且检查是否有重复或高度相似的图片。可以用感知哈希去重,或者按拍摄时间/地点分组划分。如果数据集里同一场景有多张图,用GroupShuffleSplit而不是普通train_test_split。
5.2 训练loss正常但验证loss是NaN
现象:前几个epoch正常,突然验证loss变成nan。
原因:验证集中有损坏图片,或者某张图片尺寸异常导致预处理出错。也可能是学习率太大导致权重爆炸。
解决:在Dataset的__getitem__里加try-except,跳过无法读取的图片。同时用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)做梯度裁剪。检查学习率是否超过1e-3。
5.3 某个类别准确率特别低
现象:多云、雨天、晴天都在90%以上,日出只有60%。
原因:日出样本量太少,或者日出和多云在视觉上确实难分。也可能是标注错误,把日出标成了多云。
解决:先看混淆矩阵,确认是跟哪个类别混淆。如果是样本量问题,对少数类做过采样或加类别权重。如果是标注问题,人工抽查几十张。nn.CrossEntropyLoss(weight=class_weights)可以给少数类更高权重。
5.4 模型在验证集上表现好但实际用起来很差
现象:验证集90%,但拿手机拍的照片测试,准确率掉到60%。
原因:训练数据都是专业相机拍摄的高质量图片,手机照片的色调、分辨率、噪点分布完全不同。这是域偏移问题。
解决:在增强里加入模拟手机拍摄的变换——降低分辨率、加高斯噪声、调整白平衡。或者收集少量手机拍摄的图片做微调。没有这个条件的话,至少把Resize的插值方式从默认的bilinear改成bicubic,对低质量图片更友好。
5.5 训练速度慢得离谱
现象:一个epoch要跑十几分钟。
原因:num_workers设成了0,数据加载是单线程的。或者图片分辨率太大,224的输入实际读的是4000x3000的原图。
解决:DataLoader设num_workers=4或8,pin_memory=True。在Dataset里先做一次离线Resize,把原图缩到256x256存下来,训练时直接读小图。这个操作能把训练速度提升3到5倍。
train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True )drop_last=True在训练时丢弃最后一个不满batch的数据,避免BatchNorm在只有一两张图时统计量不准。
6. 进阶技巧:用混淆矩阵和Grad-CAM定位模型到底在看哪里
训练完模型,准确率90%以上,但你真的知道模型在学什么吗?我习惯做两件事:画混淆矩阵,跑Grad-CAM。
混淆矩阵能告诉你类别之间的混淆模式。比如"多云"和"雨天"互相混淆多,说明模型对云层厚度和亮度的区分不够敏感。这时候可以针对性加强这两类的数据增强,或者引入注意力机制。
from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) outputs = model(imgs) all_preds.extend(outputs.argmax(1).cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt="d", xticklabels=classes, yticklabels=classes) plt.savefig("confusion_matrix.png") print(classification_report(all_labels, all_preds, target_names=classes))Grad-CAM更直观,它把模型最后卷积层的梯度回传到特征图上,生成热力图叠加在原图上。如果模型判断"晴天"时关注的是天空区域,说明学对了;如果关注的是图片角落的水印,那就翻车了。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layers = [model.layer4[-1]] # ResNet18的最后一个卷积块 cam = GradCAM(model=model, target_layers=target_layers) grayscale_cam = cam(input_tensor=img_tensor.unsqueeze(0)) visualization = show_cam_on_image(img_np, grayscale_cam[0], use_rgb=True)跑完Grad-CAM后我发现一个血泪教训:模型有时候会依赖图片的亮度均值来判断类别,而不是真正的天气特征。这意味着如果测试图片整体偏暗,模型会倾向预测"雨天"。解决办法是在增强里加入亮度归一化,或者用更激进的ColorJitter让模型学会忽略全局亮度。
最后一个习惯:每次训练完,把验证集里预测错误的图片单独存一个文件夹,肉眼过一遍。十次里有八次能发现标注错误或者数据质量问题。这个动作花不了几分钟,但比调参管用得多。希望帮到你。
本文还有配套的精品资源,点击获取