基于VGG的自然灾害图像分类:迁移学习与Grad-CAM实战

发布时间:2026/10/12 0:31:06
基于VGG的自然灾害图像分类:迁移学习与Grad-CAM实战
简介这份资源面向图像识别与机器学习方向的初学者及进阶开发者聚焦自然灾害场景的自动分类任务帮助读者理解如何用VGG卷积神经网络完成从数据预处理到模型训练与评估的完整流程。压缩包共29个文件约1.54MB包含5个Python脚本、2个Jupyter Notebook、7张示例图片以及配置文件、CSV数据表、日志与说明文档等覆盖数据清洗、可视化、单通道分类、模型评估指标记录等模块目录结构清晰便于按环节查阅。已有70人学习下载。读者可从中获得一套可复用的灾害图像分类实践方案包括数据增强与归一化思路、VGG特征提取与CNN分类器搭建方法、召回率与Kappa等评估指标的记录方式以及数据可视化脚本适合用于课程设计、项目练手或灾害识别相关课题的快速起步。1. 基于 VGG 的自然灾害图像分类从一张航拍图说起你手里有一批灾后航拍图想快速分出哪些区域是洪水、哪些是山火、哪些是地震废墟。人工一张张看眼睛看花了也标不完几千张。基于 VGG 的自然灾害图像分类要解决的就是这件事拿一个在 ImageNet 上预训练过的 VGG 网络用少量标注好的灾害图像做迁移学习让模型自动把输入图片分到对应灾种。它适合有几百到几千张标注图、算力只有一张消费级显卡的团队也适合想入门图像分类算法的新手拿它练手。森林图像分类、遥感灾损评估这类场景用 VGG 做基线往往比从头训一个 CNN 更稳。下面把我实际做这套方案时踩过的路讲清楚。2. VGG 为什么还值得拿来分类自然灾害图像2.1 VGG 的结构特点与选它的理由VGG 的核心设计非常规整全部用 3×3 小卷积核堆叠每经过一段卷积就接一次最大池化把特征图缩小一半通道数从 64 一路翻倍到 512。这种「深而窄」的结构带来的好处是特征提取层次清晰——浅层卷积抓边缘和纹理深层卷积抓语义块。自然灾害图像里洪水的水面反光、山火的烟雾纹理、地震废墟的碎块边缘恰好都是这种层次化特征能覆盖的。选 VGG 而不是更新的模型理由有三条。第一它的结构简单到可以手写出来调试时每一层的输出尺寸都能心算出问题好定位。第二ImageNet 预训练权重到处都有迁移学习时冻结卷积层只训分类头几百张图就能出效果。第三它的参数量集中在最后三个全连接层想压缩模型时改这里最直接。最新的图像分类模型比如 ConvNeXt、ViT 精度确实更高但在灾害图像这种样本量不大、类别间差异明显的任务上VGG 的性价比反而突出。2.2 迁移学习冻结哪些层、替换哪一层直接拿 VGG 的原始分类头1000 类来用是不行的必须换成自己的类别数。常见做法是保留全部卷积层和第一个全连接层把最后那个 1000 维输出层换成 N 维N 是你的灾种数。冻结策略上我一般先冻结所有卷积层只训分类头 5 到 10 个 epoch等 loss 降下来再解冻最后两个卷积块做微调。这样做的原因是卷积层学到的通用特征边缘、纹理对灾害图像同样有效而分类头需要重新学「哪些特征组合对应洪水」。import torch import torch.nn as nn from torchvision import models # 加载预训练 VGG16weights 用默认的 ImageNet 权重 vgg models.vgg16(weightsmodels.VGG16_Weights.DEFAULT) # 冻结所有卷积层参数迁移学习初期只训分类头 for param in vgg.features.parameters(): param.requires_grad False # 替换分类头原为 1000 类改为 4 类洪水/山火/地震/正常 vgg.classifier[6] nn.Linear(4096, 4) # 只把分类头的参数交给优化器 optimizer torch.optim.Adam(vgg.classifier.parameters(), lr1e-3)这段代码的关键在requires_grad False和classifier[6]两处。冻结卷积层后反向传播不会更新它们的权重显存占用和训练时间都大幅下降。classifier[6]是 VGG 分类器的最后一层输入维度 4096输出改成你的类别数。学习率设 1e-3 是因为只训分类头可以激进一点等解冻卷积层微调时要降到 1e-4 甚至 1e-5否则预训练权重会被破坏。2.3 数据准备灾害图像的目录结构与增强策略数据按类别分文件夹放这是 PyTorchImageFolder能直接读的格式。每个类别至少准备 150 到 200 张太少的话分类头学不动。图像统一缩放到 224×224这是 VGG 的标准输入尺寸不改的话全连接层的维度对不上。# 目录结构示例 dataset/ ├── flood/ # 洪水约 300 张 ├── wildfire/ # 山火约 250 张 ├── earthquake/ # 地震废墟约 200 张 └── normal/ # 正常场景约 300 张增强策略上灾害图像不适合用太激进的随机裁剪因为灾种的关键特征可能就在图像边缘。我一般用随机水平翻转、小角度旋转±15 度、颜色抖动亮度/对比度 0.2。颜色抖动对洪水识别有帮助因为不同光照下水面颜色差异大。验证集只做缩放和归一化不做增强否则评估结果不可比。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), 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]) ])归一化用的均值和标准差是 ImageNet 的统计值因为预训练权重就是在这个分布上学的保持一致才能让卷积层输出的特征分布匹配。这一步不做的话训练 loss 会震荡得厉害收敛慢很多。3. 训练流程与关键参数怎么定3.1 两阶段训练先训头再微调整个训练分两个阶段。第一阶段冻结卷积层只训分类头学习率 1e-3跑 10 个 epoch。第二阶段解冻最后两个卷积块features[24:]学习率降到 1e-4再跑 15 到 20 个 epoch。这样安排是因为分类头随机初始化时梯度很大如果同时更新卷积层预训练权重会被冲乱。# 第一阶段结束后解冻最后两个卷积块 for param in vgg.features[24:].parameters(): param.requires_grad True # 重新设置优化器学习率降一个数量级 optimizer torch.optim.Adam([ {params: vgg.features[24:].parameters(), lr: 1e-4}, {params: vgg.classifier.parameters(), lr: 1e-4} ])features[24:]是 VGG16 的第四个和第五个卷积块这两块学到的特征更偏向语义微调它们能让模型适应灾害图像的特殊纹理。前面的浅层卷积保持冻结因为边缘和纹理特征是通用的没必要动。3.2 学习率、batch size 与 epoch 的取值依据学习率方面第一阶段 1e-3 是 Adam 的常用起点如果 loss 在前 3 个 epoch 不降降到 5e-4。第二阶段 1e-4 是微调预训练模型的安全值再高容易把卷积核权重带偏。batch size 取决于显存VGG16 在 224×224 输入下8GB 显存大概能跑 batch size 1612GB 能跑 32。batch size 太小比如 4会让 batch norm 统计不准loss 波动大。epoch 数不是固定的看验证集准确率。我一般设 early stopping验证集 loss 连续 5 个 epoch 不降就停。实际跑下来20 到 30 个 epoch 基本收敛。如果验证集准确率卡在某个值上不去先检查数据标注有没有错再考虑加数据或换更强的增强。# 训练循环核心逻辑 for epoch in range(30): vgg.train() for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs vgg(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() # 验证阶段 vgg.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) outputs vgg(imgs) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch}, Val Acc: {correct/total:.4f})验证阶段必须调vgg.eval()否则 batch norm 会用当前 batch 的统计量导致评估结果不稳定。torch.no_grad()关掉梯度计算省显存也加速。3.3 类别不平衡的处理加权损失与重采样灾害图像天然不平衡——正常场景的图好找地震废墟的图难凑。如果正常类有 1000 张而地震类只有 150 张模型会倾向于把大多数图预测成正常。解决办法有两个给损失函数加类别权重或者对少数类过采样。# 按类别样本数计算权重样本越少权重越高 class_counts [300, 250, 200, 300] # 对应 flood/wildfire/earthquake/normal total sum(class_counts) weights [total / (len(class_counts) * c) for c in class_counts] class_weights torch.tensor(weights, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)权重的计算逻辑是总样本数除以类别数乘以该类样本数这样少数类的 loss 会被放大模型不敢忽略它。过采样则是在 DataLoader 里用WeightedRandomSampler让每个 batch 里各类别比例接近。两种方法选一种就行同时用可能过拟合少数类。4. 避坑与排查我踩过的五个坑4.1 验证集准确率虚高实际预测全错现象训练时验证集准确率到 95%但拿新图预测结果乱七八糟。原因验证集和训练集来自同一批数据只是随机划分图像之间高度相似比如同一场洪水的连续航拍帧模型记住了特定场景而不是灾种特征。解决按场景或地理位置划分数据集同一场灾害的图要么全在训练集要么全在验证集。如果数据来源单一至少做 5 折交叉验证看方差。4.2 Loss 降到 0.2 就不动了准确率卡在 60%现象训练 loss 正常下降但验证准确率死活上不去。原因学习率在第二阶段没降下来或者解冻的层太多。VGG16 有 13 个卷积层如果全部解冻而学习率还是 1e-3预训练权重会被破坏。解决只解冻最后两个卷积块学习率降到 1e-4。如果还不行检查数据标注——我遇到过把「洪水退去后的泥地」标成「洪水」的情况模型学混了。4.3 显存不够batch size 只能设 4现象8GB 显存跑 VGG16batch size 设 8 就 OOM。原因VGG16 的全连接层参数量巨大第一个全连接层是 25088×4096显存主要耗在这里。解决把图像尺寸从 224 降到 192 或 160全连接层输入维度会相应减小。或者用梯度累积——batch size 设 4累积 4 次梯度再更新等效 batch size 16。# 梯度累积示例 accum_steps 4 optimizer.zero_grad() for i, (imgs, labels) in enumerate(train_loader): outputs vgg(imgs) loss criterion(outputs, labels) / accum_steps loss.backward() if (i 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()4.4 预测结果全是同一类现象模型对任何输入都输出「洪水」。原因类别权重没设或者少数类样本太少导致模型直接放弃。解决先打印训练集各类别数量如果某类少于 100 张要么补数据要么用加权损失。另外检查 DataLoader 的 shuffle 是否开启不 shuffle 的话模型会按顺序学到类别分布。4.5 推理速度太慢单张图要 200ms现象部署时发现 VGG16 推理太慢满足不了实时需求。原因全连接层计算量大且没有做推理优化。解决把模型转成 ONNX 或 TorchScript用torch.jit.trace导出。如果还慢考虑把 VGG 的全连接层换成全局平均池化参数量从 1.2 亿降到 1500 万精度掉 1 到 2 个点但速度翻倍。# 用 TorchScript 加速推理 vgg.eval() example torch.randn(1, 3, 224, 224).to(device) traced torch.jit.trace(vgg, example) traced.save(vgg_disaster.pt) # 加载时直接跑不用再建模型 loaded torch.jit.load(vgg_disaster.pt) output loaded(example)5. 进阶技巧用 Grad-CAM 看模型到底在看哪里模型训完了准确率也还行但你怎么知道它是在看洪水的水面还是在看图片角落的水印Grad-CAM 能把模型决策时关注区域热力图叠回原图这是验证模型是否学到正确特征的最直接手段。我一般会在验证集里挑几张预测正确的和几张预测错误的图分别跑 Grad-CAM对比热力图分布。import cv2 import numpy as np # 注册钩子拿目标层的梯度 gradients [] activations [] def save_gradient(module, grad_in, grad_out): gradients.append(grad_out[0]) def save_activation(module, input, output): activations.append(output) # 挂在最后一个卷积块上 target_layer vgg.features[28] target_layer.register_forward_hook(save_activation) target_layer.register_full_backward_hook(save_gradient) # 前向 反向 vgg.eval() output vgg(img_tensor) pred_class output.argmax(dim1).item() vgg.zero_grad() output[0, pred_class].backward() # 计算 Grad-CAM grad gradients[0].cpu().data.numpy()[0] # (512, 14, 14) act activations[0].cpu().data.numpy()[0] # (512, 14, 14) weights np.mean(grad, axis(1, 2)) # (512,) cam np.sum(weights[:, None, None] * act, axis0) cam np.maximum(cam, 0) cam cv2.resize(cam, (224, 224)) cam cam / cam.max()这段代码的关键在weights np.mean(grad, axis(1, 2))——对每个通道的梯度在空间维度上取平均得到该通道的重要性权重再用权重对特征图加权求和。np.maximum(cam, 0)是 ReLU只保留对目标类有正贡献的区域。最后归一化到 0 到 1叠回原图看。热力图出来后重点看两件事。第一高亮区域是否落在灾害主体上——洪水图应该亮在水面山火图应该亮在火焰和烟雾。如果亮在天空或地面说明模型学到了虚假相关。第二预测错误的图热力图往往亮在背景上这说明模型被背景带偏了需要补充背景多样的训练数据。我自己的习惯是每训完一个版本固定抽 20 张验证图跑 Grad-CAM存成对比图。如果新版本的热力图比旧版本更集中在灾害主体上即使准确率只涨了 1 个点我也认为这个版本更可靠。反过来准确率涨了但热力图散了多半是过拟合了训练集的背景。这套流程帮我省了很多「准确率虚高但上线翻车」的后悔药。希望帮到你。本文还有配套的精品资源点击获取