基于迁移学习的天气识别:小数据集图像分类实战与调优指南

发布时间:2026/10/20 4:34:25
基于迁移学习的天气识别:小数据集图像分类实战与调优指南
前两篇我们把图像分类的基础流程走了一遍从数据加载到模型训练都过了手。这次我不想再停留在“跑通代码就行”的阶段选了一个更有画面感的小项目来继续练手——天气识别。项目本身不复杂给一张照片判断它属于晴天、阴天、雨天、雪天等类别。难点在于天气的视觉特征边界比较模糊阴天和雨天之间、晨雾和晴天之间都可能让人眼都犯难更不用说模型了。但正因如此这个项目用来深入理解数据增强、迁移学习和模型调优性价比极高。适合刚学完卷积神经网络、想找个真实任务巩固一下的读者也适合准备做图像分类毕设或竞赛练手的同学。1. 先拆需求天气识别到底在解决什么问题1.1 任务定性标准的多类别图像分类天气识别本质上是一个图像分类问题输入是一张RGB图片输出是若干个天气类别之一。常见的做法是把任务拆成六个类别晴天sunny、多云cloudy、阴天overcast、雨天rainy、雪天snowy、有雾foggy。有些公开的天气数据集还会加入暴风雨、冰雹等类别但核心套路都一样把图像映射成类别概率分布。表面上看这个任务比猫狗分类难不了多少。但真正做起来会发现天气识别的难点在“语义边界”上。晴天和多云很多时候只是云量多少的差异阴天和雨天最直观的区别可能只是地面有没有湿、有没有雨丝雪天则要看有没有白色积雪区域。模型如果只学到“天空亮不亮”这种粗粒度特征很容易把强光的阴天当成晴天把地面反光的雨天当成晴天。所以这个项目真正训练的不是“看图说话”而是“从图像中提取能区分相近类别的细粒度特征”。还有一类特殊情况是图像里根本没有天气主角。比如拍的是室内窗口外的风景、夜晚的灯光场景、或者有人物遮挡画面大面积区域模型的表现会明显下降。这就是为什么数据准备阶段不能只关注“数量够不够”还要看“分布是否覆盖真实场景”。1.2 技术选型为什么不用手写特征也不从零训练 CNN如果是五年前做天气识别常规思路会走手工特征路线计算颜色直方图、纹理特征比如局部二值模式、天空区域分割然后扔给SVM等传统分类器。这条路能跑但有两个问题特征设计严重依赖经验每换一种天气风格都要重新调而且到了复杂场景特征表达力不够准确率天花板很低。从零训练一个卷积神经网络也不是不行但它需要的数据量通常远大于我们手上能拿到的几百张每类。天气数据集的规模参差不齐一个常用公开数据集每个类别也就几百到一千多张图从零训练CNN在这个量级上非常容易过拟合训练出来的模型泛化能力会很差。所以这次我选了迁移学习路线直接用ImageNet上预训练好的模型作为特征提取器只训练任务相关的“头部”部分。ImageNet预训练模型见过海量真实图像的通用纹理、边缘、形状特征这些低级特征对天气识别同样有效。我只需要让模型把“这些纹理组合起来表示什么天气”这件事学会就行。这个方法最大的好处是小数据量也能稳住训练收敛速度快而且在CPU上也能完成调试流程。2. 数据准备小数据集怎么做出能用的效果2.1 数据来源与目录组织方式天气识别没有统一的官方标准数据集实践中最常用的方式是去开源数据集平台下载公开的天气图像集合。这类数据集的标签一般由文件名或目录名区分。下载之后要做的第一件事不是急着写模型而是先把目录整理成 TensorFlow 能直接读取的结构。我习惯的组织方式是这样的weather_dataset/ ├── train/ │ ├── sunny/ │ ├── cloudy/ │ ├── overcast/ │ ├── rainy/ │ ├── snowy/ │ └── foggy/ ├── val/ │ └── ... └── test/ └── ...训练集、验证集、测试集三者严格分开谁也别越界。这里有个大多数教程不会特意强调的点验证集必须从训练集之外独立划分而且最好按“来源”划分而不是按“文件名随机划分”。比如同一个视频抽帧出来的连续帧随机划到训练集和验证集里会造成验证损失虚低模型真实表现没那么好。我遇到过一个天气数据集里面很多同一场景的连拍图如果随机划分测试准确率能到97%以上换到完全没见过的场景图直接掉到80%出头。这一点务必注意。如果下载的数据集没有现成的验证集可以用train_test_split之类的工具手动划分。但划分前最好看一眼数据是否存在文件层面的顺序规律先shuffle再切分。每个类别的样本量也值得留意天气数据集经常类别不均衡雪天和雾天样本偏少。看到这种分布时最好在训练策略上做应对后面第7节会细说。2.2 预处理与数据增强让模型看更多的“同一张图”数据加载阶段我用keras.preprocessing.image.ImageDataGenerator来处理虽然新版的 Keras 也支持在model.fit里直接做预处理但ImageDataGenerator的流式加载对小数据集和内存有限的机器更友好。基础操作就两个把所有图片缩放到统一尺寸然后做归一化。from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1.0 / 255.0, rotation_range20, width_shift_range0.2, height_shift_range0.2, shear_range0.2, zoom_range0.2, horizontal_flipTrue, brightness_range[0.8, 1.2], fill_modenearest ) val_datagen ImageDataGenerator(rescale1.0 / 255.0) IMG_SIZE (224, 224) BATCH_SIZE 32 train_generator train_datagen.flow_from_directory( weather_dataset/train, target_sizeIMG_SIZE, batch_sizeBATCH_SIZE, class_modecategorical ) val_generator val_datagen.flow_from_directory( weather_dataset/val, target_sizeIMG_SIZE, batch_sizeBATCH_SIZE, class_modecategorical, shuffleFalse )归一化为什么把像素值从 0~255 缩放到 0~1因为预训练模型在 ImageNet 训练时用的就是这种尺度保持尺度一致才能让预训练权重正常发挥作用。这里顺便提醒MobileNetV2 这类模型在 Keras 里单独用preprocess_input做预处理会更严谨但很多 Keras 模型的include_topFalse版本内部并不强制要求统一用rescale也可以稳定跑。数据增强参数不是拍脑袋定的。rotation_range20表示图片在正负20度范围内随机旋转因为实际拍摄的天气照片不总是水平端正的。width_shift_range和height_shift_range模拟构图偏移让天空不一定总在画面上方。brightness_range是我特别加的晴天、阴天本质就是亮度差异但模型不能只依赖亮度判断否则很容易把“过亮的阴天”误判成“晴天”。把亮度随机调高调低模型被迫去找形状、纹理、色彩之间的组合特征而不是偷懒只盯亮度。有一个细节容易被忽略ImageDataGenerator增强只作用于训练集验证集一律不做增强。验证集必须保持原始分布才能真实反映模型泛化能力。另外fill_modenearest表示旋转或平移后露出的空白区域用最近邻像素填充这比黑色填充做出来的图自然不少。3. 模型搭建把预训练主干和可训练头部接起来3.1 主干网络选择MobileNetV2 凭什么合适迁移学习的“主干”选择我第一反应不是 ResNet50 也不是 VGG16而是 MobileNetV2。原因很实际天气识别最终部署环境往往不是高端GPU服务器可能是树莓派、手机端或边缘设备MobileNetV2 的参数量只有约 350 万比 ResNet50 的 2500 万少了一个数量级但提取通用图像特征的能力足够胜任这个任务。本项目不是目标检测、不是语义分割而是相对直接的图像分类不需要特别深的网络。加载主干的方式是去掉原模型的分类层只保留卷积特征提取部分from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.layers import GlobalAveragePooling2D, Dense, Dropout from tensorflow.keras.models import Model base_model MobileNetV2( input_shape(224, 224, 3), include_topFalse, weightsimagenet ) base_model.trainable False这里include_topFalse去掉了 ImageNet 自带的 1000 类分类层。weightsimagenet加载预训练权重。还有一个常被忽略的参数pooling如果不在后面手动加池化层MobileNetV2 输出的特征图是三维张量比如 7×7×1280直接接 Dense 层会把参数撑得很大。我在后面手动接了GlobalAveragePooling2D效果是把每个通道的空间信息平均成一个数值把 7×7×1280 压成 1280 维向量参数量骤减还能抑制过拟合。3.2 分类头设计全连接层到底该多厚分类头是唯一需要训练的部分。一个常见误区是“全连接层越多越准”对小数据量任务来说这往往是过拟合的加速器。天气识别的类别特征差异不算特别刁钻两层全连接就够用甚至某些情况下只接一层也能出结果。x GlobalAveragePooling2D()(base_model.output) x Dropout(0.5)(x) x Dense(128, activationrelu)(x) x Dropout(0.3)(x) outputs Dense(6, activationsoftmax)(x) model Model(inputsbase_model.input, outputsoutputs)第一个Dropout(0.5)用在高维特征到低维特征的过渡点。GlobalAveragePooling 输出的是 1280 维向量直接接 Dense 层容易让模型“背”训练集Dropout 会随机丢掉一半神经元输出强迫模型学到的特征更冗余、更稳健。中间的Dense(128, activationrelu)起到抽象特征组合的作用比如把“天空亮度、云层纹理、地面反光”组合成“刚下过雨”的概念。最后输出层用softmax得到 6 个类别的概率分布。我在实验中发现如果数据集每个类别只有 300 张左右中间层从 128 换成 256 并不能提升准确率反而会让验证集损失提前反弹。数据量少的时候分类头的宽度宁小勿大。如果后来把数据量扩充到每类上千张再把 128 调成 256 也不迟。4. 训练配置优化器、学习率与损失函数背后的逻辑4.1 超参数不是玄学每一项都有目的训练配置我按“两阶段策略”来设计。第一阶段冻结主干只训练分类头第二阶段解冻部分主干以低学习率做微调。这个策略背后是迁移学习的基本逻辑预训练模型提取的底层特征足够通用先让新分类头学会用这些特征此时如果让主干参与训练梯度反向传播很容易把预训练权重冲坏前期训练也很难稳定收敛。第一阶段配置长这样from tensorflow.keras.optimizers import Adam model.compile( optimizerAdam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) history model.fit( train_generator, steps_per_epochtrain_generator.samples // BATCH_SIZE, epochs15, validation_dataval_generator, validation_stepsval_generator.samples // BATCH_SIZE, callbacks[ tf.keras.callbacks.EarlyStopping(patience3, restore_best_weightsTrue), tf.keras.callbacks.ModelCheckpoint(weather_base.h5, save_best_onlyTrue) ] )优化器选 Adam 而不是 SGD因为 Adam 自带自适应学习率分类头这种随机初始化的参数用 SGD 需要精细调学习率和动量新手很容易调崩Adam 对学习率的敏感性低很多1e-3 这个默认量级在第一阶段比较稳。损失函数选categorical_crossentropy因为class_modecategorical产生的标签是 one-hot 编码训练目标是让模型输出的概率分布贴近 one-hot 分布。如果你的标签是整数记得换成sparse_categorical_crossentropy否则会被 Keras 报维度错误。steps_per_epoch为什么用samples // BATCH_SIZE而不是samples / BATCH_SIZE这是为了避免最后一个不完整 batch 在部分情况下导致精度问题取整除是最稳妥的写法。4.2 第二阶段微调什么时候解冻、解冻多少层第一阶段的训练等到验证损失连续几轮不降后进入第二阶段。核心操作是把主干网络的一部分解冻用更小的学习率继续训练base_model.trainable True # 冻结 MobileNetV2 的前面大部分层只让最后 30 层参与微调 for layer in base_model.layers[:-30]: layer.trainable False model.compile( optimizerAdam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy] ) history_finetune model.fit( train_generator, steps_per_epochtrain_generator.samples // BATCH_SIZE, epochs10, validation_dataval_generator, validation_stepsval_generator.samples // BATCH_SIZE, callbacks[ tf.keras.callbacks.EarlyStopping(patience2, restore_best_weightsTrue), tf.keras.callbacks.ModelCheckpoint(weather_finetune.h5, save_best_onlyTrue) ] )学习率从 1e-3 降到 1e-5不是随手写的。微调阶段的梯度要足够“温和”否则预训练权重会被大幅度改动丢失 ImageNet 上积累的通用特征。经验法则微调学习率通常是主训练学习率的十分之一到百分之一。解冻最后 30 层而不是全解冻因为 MobileNetV2 靠后的层更偏向任务相关的高层语义特征更适合被调整前面层负责边缘、纹理等通用特征保持不变反而更安全。两阶段跑下来我的结果对比很有意思第一阶段验证准确率大概在 88% 左右微调之后能超过 93%。这说明预训练主干提取的特征虽然好但底层通用特征和天气识别的任务相关性仍有一些差距微调帮模型把高层特征往“天气风格”方向拉近了一步。5. 训练监控与效果诊断别只看准确率5.1 训练曲线怎么读Loss 比 accuracy 诚实训练过程中我会同时盯 loss 和 accuracy但loss才是判断模型状态的主要指标。准确率是离散的正确率从 0.98 跳到 0.99 只是“对的样本多了一个”信息量太低。Loss 是连续平滑的能更灵敏地反映模型置信度变化。文字的 loss 曲线图谁都看得懂但真实训练里会出现几个典型状态第一训练 loss 持续下降、验证 loss 到了某个点开始反弹这是过拟合的信号。第二训练 loss 和验证 loss 同步下降但间距一直很大这通常说明数据分布不一致或增强强度过弱。第三训练 loss 不降反升大概率是学习率设置过大导致梯度震荡。我在天气项目里最常遇到的是第一个状态小数据集加上比较宽的模型基本过拟合缓解手段无非是增强更强、Dropout 更高、模型更窄、早停更早。早停回调我用过很多次它的作用是在验证指标不再改善时提前终止训练避免过拟合继续加深。patience3表示连续 3 个 epoch 验证 loss 不降就停。restore_best_weightsTrue会让模型回到验证指标最好的那一步权重别小看这个参数默认 False 的话训练结束后保留的是最后一轮的权重大概率已经过了最佳点。5.2 混淆矩阵模型到底错在哪几类只有准确率数字是不够的。天气识别里不同类别的错误代价不一样把雨天认成阴天或许还能接受把雨雪路面认成晴天则可能影响后续决策。我习惯在测试集上打印分类报告和混淆矩阵import numpy as np from sklearn.metrics import classification_report, confusion_matrix val_generator.reset() pred model.predict(val_generator, stepsval_generator.samples // BATCH_SIZE) pred_classes np.argmax(pred, axis1) true_classes val_generator.classes[:len(pred_classes)] print(classification_report(true_classes, pred_classes, target_nameslist(val_generator.class_indices.keys()))) print(confusion_matrix(true_classes, pred_classes))实际跑出来的混淆矩阵基本遵循直觉多云和阴天互认程度最高雨天和阴天也经常混。这两个混淆是数据本身的规律——多云和阴天在视觉上本来就是连续渐变的关系。面对这个问题我的应对不是一味调模型结构而是回到数据层面增加每一类别的典型样本、检查标签是否准确、适当增强亮度扰动让模型不过度依赖天空灰度。还有一个经常被忽略的检查val_generator在调用predict前要reset()否则迭代器位置不对预测结果和标签对不上。这个 bug 很隐蔽我踩过一次花了大半天排查。6. 部署与应用单张图片天气预测的完整封装训练完成后模型最终目的是要拿出去用。最简单的部署方式是把模型保存成 HDF5 文件写一个推理函数输入任意单张图片输出天气类别和概率。这一步看着简单里面藏着不少细节。from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing import image import numpy as np model load_model(weather_finetune.h5) class_names [sunny, cloudy, overcast, rainy, snowy, foggy] def predict_weather(img_path): img image.load_img(img_path, target_size(224, 224)) img_array image.img_to_array(img) img_array img_array / 255.0 img_array np.expand_dims(img_array, axis0) probs model.predict(img_array, verbose0)[0] top_idx np.argmax(probs) return class_names[top_idx], float(probs[top_idx])target_size(224, 224)必须和训练时一致。如果训练用的是 224推理时用 256 再中心裁剪结果会受预处理方式影响所以我选择最省事的办法直接压缩到 224。图片颜色空间也需注意load_img默认加载 RGB如果某张测试图是 RGBA 或有特殊色彩配置最好先统一转成 RGB。这里有个经验推理阶段的预处理必须和训练阶段完全一致。训练时做了rescale1/255推理阶段就必须手动做同样缩放如果训练时用了tf.keras.applications.mobilenet_v2.preprocess_input推理也要保持一致。预处理不一致导致精度明显下降的问题在图像分类里非常常见排查时先看这里。部署时还有一点值得提model.predict在单张图上会输出一个形状(1, 6)的数组index 0代表第一个类别。类别索引跟train_generator.class_indices的字典顺序一致所以类名列表不能自己乱写最好从训练保存的字典里取避免索引错位。7. 常见问题与避坑清单这个项目做完之后我整理了几个出现频率最高的问题。每一个都是从实际运行中踩出来的记录在这里希望对读者有点帮助。第一个问题是类别不平衡。雪天、雾天的样本数量如果只有晴天的一半模型天然偏向样本多的类别。最简单的缓解方式是给少数类更高的 loss 权重。可以在model.compile之外用class_weight参数传一个权重字典。但更好的做法是回到数据本身先把少数类样本补足再不行再考虑加权。第二个问题是过拟合。气象照片里同一个场景的连续帧很容易造成信息重复模型“背”住的是场景而不是天气特征。应对方案就是训练集和验证集严格按场景隔离增加数据增强强度分类头做深一点 Dropout。我试过把rotation_range从 20 加到 40、width_shift_range从 0.2 加到 0.3测试集准确率有 2 到 3 个百分点的提升。第三个问题是显存不够。很多模型在 4GB 显存的小显卡上跑不动 batch size 32 的 MobileNetV2 微调。这个时候可以把 batch size 降到 16 或 8同时把steps_per_epoch相应地调大。如果还是不够用tf.keras.mixed_precision开混合精度能在基本不掉点的情况下省出不少显存。这个方法我用在微调阶段效果还不错。第四个问题是中文路径和中文文件名。flow_from_directory对中文路径处理偶发异常图片文件名如果带空格或特殊字符也可能读取失败。我的习惯是所有图片统一重命名成纯英文数字格式目录路径也保持纯英文。这听起来很土但能省掉大量无谓的踩坑时间。第五个问题是预训练权重下载失败。国内网络环境下Keras 下载 ImageNet 权重经常中断。解决思路是先把权重文件下载到本地再指定weightspath/to/mobilenet_v2_weights.h5加载。或者直接换用较小的预训练模型比如 MobileNetV1 的权重包更小下载成功率更高。如果多次下载失败也可以换个时间段再试但更可靠的是给keras配置离线权重路径。最后一个细节是训练完保存模型时把class_indices一并保存。我在推理阶段遇到过索引错位问题就是因为重新加载模型后没有对应 class 和索引的映射关系。通常用下面的方式导出import json with open(class_indices.json, w) as f: json.dump(train_generator.class_indices, f)推理时再读取这个文件把索引反查成类名这个习惯能避免很多部署阶段的低级错误。我个人在实际操作中体会最深的一件事这类小体量图像分类项目模型结构带来的收益终究有限真正拉开差距的是数据划分够不够干净、预处理和推理流程是否一致、增强策略是否针对任务做了调整。如果你正打算把这个项目扩展下去不妨先试试在原始数据里加入多时段和多地点的照片或者在分类头里加入注意力模块观察变化你会发现天气识别这个“小项目”能挖出来的东西远比想象中多。