news 2026/10/1 9:08:50

4400张灾害图像分类:迁移学习实战与避坑全记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
4400张灾害图像分类:迁移学习实战与避坑全记录

简介:这份数据集面向深度学习与计算机视觉方向的开发者及学习者,尤其适合正在开展图像分类实践、灾害场景识别研究的人群。资源包含洪水、地震、火灾、台风四个类别,共约4400张已标注自然灾害图像;jpg图片按类别归档,json文件记录分类标签,py可视化脚本可快速预览样本分布。压缩包共2000个文件,整体160.05MB,结构简洁,便于纳入自定义训练流程。数据已预先划分好训练集与测试集,可直接送入CNN等分类网络开展训练与评估;json文件中的类别定义也方便后续扩展或修改,运行show.py即可直观核对四类样本形态与数量,适合作为入门分类实验、课程设计或网络改进研究的基准数据。目前已有241人学习下载,对需要构建灾害识别模型、探索CNN分类网络改进方案的读者来说,是一份可直接落地的数据支撑。

1. 4400张标注灾害图像,能把图像分类做到什么程度

先给结论:地震、台风这类自然灾害图像分类数据集,标注量在4400张,是一个“刚好能跑迁移学习、但不足以让你在类间平衡上偷懒”的量级。它的典型应用场景是灾情快速评估、无人机/监控图像的分拣归档,以及给后续目标检测任务做类别预筛。灾害图像的“图像分类”难点并不在分类本身,而在同一类别内部尺度差异极大——同样是“台风”,可能是漫天乌云的远景,也可能是房屋屋顶被掀翻的特写。这种差异会让模型在训练集上轻松跑到95%以上,到了新场景却直接翻车。适合用这个数据集的人有三类:正在做灾情评估系统原型验证的算法工程师,需要给大量历史灾害照片做自动归档的行业从业者,以及想从零走一遍数据标注到模型部署全流程的学生或转行者。4400张意味着你不必从零训练,但也意味着数据清洗、划分策略和验证方式必须认真对待,否则后面每一步都在给模型挖坑。

2. 拿到数据集先别训练:目录、标注与类别分布的验货脚本

很多人拿到数据集的第一反应是直接写训练脚本,这是最容易后悔的做法。灾害图像数据集的标注质量参差不齐,类别分布可能极不均衡,目录里可能混着打不开的损坏文件或重复图片。花二十分钟做一次验货,能省下后面几天的排错时间。

2.1 两种常见组织方式:按目录分类与CSV标注,怎么解析

自然灾害图像分类数据集的常见组织方式一般是两种。第一种是按目录分类,形如train/地震/xxx.jpg、train/台风/xxx.jpg,类别名就是文件夹名,这种格式对 PyTorch 的ImageFolder最友好,但你没法附带额外的元信息(比如拍摄时间、地点、事件编号)。第二种是图片平铺在images/目录下,另外提供一个 CSV 或 JSON 标注文件,每行记录文件名, 类别, 可选的事件ID或地区。后一种更常见于需要防数据泄漏的场景。

先写个脚本把两种格式都摸清楚:

from pathlib import Path from collections import Counter import pandas as pd # 场景A:按目录分类。root目录下是 train/val/test 三个split,每个split下是类别文件夹 root = Path("natural_disaster_dataset") img_exts = {".jpg", ".jpeg", ".png", ".webp"} def count_by_dir(split_dir: Path) -> Counter: cnt = Counter() for cls_dir in split_dir.iterdir(): if not cls_dir.is_dir(): continue n = sum(1 for p in cls_dir.rglob("*") if p.suffix.lower() in img_exts) cnt[cls_dir.name] += n return cnt total = Counter() for split in ["train", "val", "test"]: c = count_by_dir(root / split) total += c print(f"[{split}] 类别数={len(c)},总样本={sum(c.values())}") for cls, n in total.most_common(): print(f"{cls}: {n}张,占比{n / sum(total.values()) * 100:.1f}%")

这段代码做的事情很简单:遍历每个 split 下的类别目录,统计每类图片数量并打印占比。参数说明:rglob("*")是为了兼容“类别目录下还有子目录”的情况,有些数据集把同一事件的照片放在子目录里;img_exts集合决定了哪些后缀被计入,如果数据集混入了 TIFF 或 BMP,需要手动补充后缀。这里最值得关注的是占比那一列——如果最大的类别占了60%以上,后面训练时必须处理不平衡,而不是直接套用默认的交叉熵损失。

如果拿到的是 CSV 标注,解析逻辑不一样:

import pandas as pd from pathlib import Path df = pd.read_csv("labels.csv", sep=",") # 有些数据集用\t分隔,注意sep参数 print(df.head(10)) # 先看列名:filepath? label? event_id? print(df["label"].value_counts(normalize=True)) # 类别占比 # 检查标注文件里有多少路径实际存在 missing = [p for p in df["filepath"] if not Path(p).exists()] print(f"标注中存在但文件缺失的记录数: {len(missing)}")

CSV 格式的坑通常在分隔符和列名上。sep=","遇到文件名里含逗号的数据集会解析错位,所以第一行输出前先df.head()确认结构,比直接跑训练踏实得多。value_counts(normalize=True)直接输出各类别占比,一眼看出有没有类别被标注成空目录或只有一两张的极端情况。

2.2 用脚本核对类别分布与文件完整性

验货的第二步是核对文件完整性,这一步比很多人都想象的重要。真实的数据集在打包、压缩、传输过程中可能丢失文件、损坏图片、或者混入非图片文件。PyTorch 的 DataLoader 遇到坏图只会在读取时报错,而且报错信息往往不指明确切文件,排查成本极高。

from PIL import Image from pathlib import Path root = Path("natural_disaster_dataset") corrupted = [] for p in root.rglob("*"): if p.suffix.lower() not in {".jpg", ".jpeg", ".png"}: continue try: with Image.open(p) as img: img.load() # 强制解码,验证文件是否真的能读 if img.width < 32 or img.height < 32: corrupted.append((str(p), "尺寸过小")) except Exception as e: corrupted.append((str(p), str(e))) for path, reason in corrupted[:20]: print(f"{reason}: {path}") print(f"问题文件总数: {len(corrupted)}")

这段脚本的核心是img.load()——它不只是打开文件头,而是真正解码像素数据。有些图片预览正常,但解码到一半会报Image file is truncated,这类文件在训练时会随机中断流程。尺寸过滤条件width < 32 or height < 32针对的是缩略图或损坏后残留的小尺寸文件,灾害数据集中偶尔会有从网页爬下来的低分辨率缩略图混入,这些图对分类训练没有正向帮助,反而会干扰模型学习。

我一般会把这个脚本的结果保存成日志,然后把问题文件移动到_rejected/目录而不是直接删除。原因是后面如果发现某个类别数量异常偏少,可能还需要回过头来检查被拒文件是不是被误判了。

2.3 顺带检查“时间与事件”维度,防止后面验证集失效

这一点是灾害图像数据集特有的坑,而且是最容易被忽略的。普通的图像分类数据集(比如 CIFAR、ImageNet)每张图片彼此独立,随机划分训练集和验证集没问题。灾害图像不一样——同一个台风事件拍出来的几百张照片在背景、光照、拍摄设备上高度相似,如果这些照片被同时分进训练集和验证集,模型在验证集上的表现会虚高。后面真正部署到新场景时,面对的是没见过的背景和拍摄条件,准确率会断崖式下跌。

如果你拿到的 CSV 里有event_id或date字段,值得做一次快速检查:

# 检查每个事件下的样本分布,为后续按事件分组划分做准备 if "event_id" in df.columns: event_stats = df.groupby("event_id")["label"].value_counts().unstack(fill_value=0) print(event_stats) # 输出每个事件涉及多少个类别、每类多少张 print(event_stats.sum(axis=1).describe())

这段代码按event_id分组,列出每个事件里各类别的样本数。describe()输出的均值和中位数可以帮你判断“大部分事件只有几十张”还是“少数事件占了绝大多数数据”。如果后者成立,后面训练时的划分方式需要从随机划分改成按事件分组划分,具体做法在第4章会展开。现在只要确认这个维度的信息存在即可——很多数据集根本不提供 event_id,这时候只能在后续训练中用特征相似度检查数据泄漏,成本高得多。

3. 用迁移学习跑通基线:ResNet50在4400张上的完整训练流程

验完货之后就可以跑第一个模型。我的建议是:第一版不要追求花哨,直接拿 ImageNet 预训练的 ResNet50 做迁移学习,跑通流程、拿到可信的评估数字,再谈优化。4400张数据训练一个全量 ResNet50 会严重过拟合,但微调最后几层加上适当的数据增强,完全可以得到一个可用的基线。

3.1 划分策略:分层划分比随机划分更靠得住

划分数据是最容易被轻视的步骤,但它直接决定你后面所有评估数字是否可信。4400张数据如果随机切分,小类别可能全部掉进验证集或训练集,导致验证集里某个类别只有几张图,指标震荡剧烈。分层划分(Stratified Split)保证每个类别在训练集、验证集、测试集中的比例与原数据集一致。

import numpy as np from sklearn.model_selection import StratifiedShuffleSplit # df.columns = ["filepath", "label"],已经过验货清洗 X = df[["filepath"]].values y = df["label"].values # 第一层:分出测试集20%,剩下80%用于训练+验证的后续划分 sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_val_idx, test_idx = next(sss.split(X, y)) # 第二层:从剩余80%里再分出12.5%作为验证集(占全量10%) X_train_val = X[train_val_idx] y_train_val = y[train_val_idx] sss2 = StratifiedShuffleSplit(n_splits=1, test_size=0.125, random_state=42) train_idx, val_idx = next(sss2.split(X_train_val, y_train_val)) train_df = df.iloc[train_val_idx[train_idx]] val_df = df.iloc[train_val_idx[val_idx]] test_df = df.iloc[test_idx] print(f"训练集 {len(train_df)},验证集 {len(val_df)},测试集 {len(test_df)}") print("训练集类别分布:", train_df["label"].value_counts().to_dict())

test_size=0.2再加上第二轮划分时乘上的0.125,最终得到 70% / 10% / 20% 的划分比例。4400张的情况下,测试集大约 880 张,验证集 440 张,这个量级足够让评估指标稳定。random_state=42保证可复现——同一个数据集、同一个随机种子,训练结果才有可比性,这是之后做任何实验对比的前提。这里建议把划分结果保存成 CSV 文件,后续所有实验都复用同一份划分,不要每次运行都重新切,否则不同实验之间的指标差异会混入数据划分的随机性。

3.2 数据增强:针对灾害场景的扰动参数怎么设

灾害图像和普通场景图像有个明显区别:拍摄环境不可控,亮度、清晰度、拍摄角度变化极大。同一场地震,无人机航拍是俯视图,地面拍摄是平视图,手机照片可能带强烈的暗角或运动模糊。数据增强的目标就是模拟这种多样性。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.RandomHorizontalFlip(), 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]), ])

逐项说明参数选择。Resize((256, 256))之后接RandomResizedCrop(224, scale=(0.7, 1.0)),先放大再随机裁剪一个区域,模拟同一场景的不同取景范围,scale下限设为0.7是因为灾害主体在画面中的占比浮动大——如果裁剪范围太激进,可能会裁掉实际的损毁区域,模型学到的是“碎砖纹理”而不是“倒塌建筑”。ColorJitter(brightness=0.3, contrast=0.3)模拟不同天气和光照条件下的色彩变化,但饱和度扰动只设 0.2,原因是灾害图像的色彩本身偏灰暗,过度扰动饱和度会让模型更难区分正常建筑和废墟。验证集不用数据增强,只做缩放和中心裁剪,这是为了保证评估时的输入一致性——验证集加随机扰动会让指标每次运行都不一样,没法做比较。

3.3 冻结与微调:4400张规模下ResNet50的参数组合

迁移学习的核心问题是“冻结哪些层、微调哪些层”。4400张数据量不算大,我的建议是第一轮冻结骨干网络的大部分层,只训练分类头和最后两个残差块(layer4),等验证集指标稳定后再解冻更多层做微调。这样做的原因是低层特征(边缘、颜色、纹理)在 ImageNet 上已经学得很好,灾害图像和自然图像在这些基础特征上没有本质差异,需要调整的是更高层的语义特征组合。

import torch import torch.nn as nn from torchvision import models num_classes = len(train_df["label"].unique()) device = "cuda" if torch.cuda.is_available() else "cpu" model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) # 冻结全部参数,然后只解冻 layer3 和 layer4 for param in model.parameters(): param.requires_grad = False for param in model.layer3.parameters(): param.requires_grad = True for param in model.layer4.parameters(): param.requires_grad = True # 替换分类头,新分类头默认 requires_grad=True model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) # 用两组学习率:分类头学快一点,骨干层学慢一点 optimizer = torch.optim.SGD([ {"params": model.fc.parameters(), "lr": 1e-3}, {"params": model.layer3.parameters(), "lr": 1e-4}, {"params": model.layer4.parameters(), "lr": 1e-4}, ], momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) criterion = nn.CrossEntropyLoss()

scheduler选了CosineAnnealingLR,T_max=30对应训练轮数——余弦退火的动机是把学习率平滑地从初始值降到最低,在后期让模型在小学习率下做精细调整,这比固定学习率更不容易在末尾来回震荡。weight_decay=1e-4是经验值,太小起不到约束作用,太大会让已学到的低层特征被过度衰减。Loss 先用默认的CrossEntropyLoss,不做类别加权,因为第一版基线需要看到真实的过拟合程度和类别表现,直接加权会掩盖不平衡问题的严重程度。等基线跑出来发现某类召回率低,再针对性地引入第4章的加权方案。

训练循环本身用标准写法即可,但有一个细节值得注意:每个 epoch 结束后在验证集上的评估不能用loss做唯一判断,要同时记录每类的召回率。4400张的小数据集上,loss 可能稳定下降但某个类别已经崩了——比如“山体滑坡”类被全部预测成“地震”,因为两者在灰度纹理上高度相似。这类问题只有看分类别指标才能发现。

3.4 评估指标:不平衡数据里accuracy会骗人

灾害图像分类数据集的类别分布天然不平衡:地震和台风的样本量可能是山体滑坡的十倍。这种场景下整体 accuracy 会掩盖一切问题——假设模型把“山体滑坡”全部预测成“地震”,只要地震类占多数,准确率依然能到90%以上,但这个系统在实际上是完全不可用的。

import torch from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) class_names = list(train_df["label"].unique()) print(classification_report(all_labels, all_preds, target_names=class_names)) print(confusion_matrix(all_labels, all_preds))

classification_report会输出每个类别的 precision、recall、F1,这是判断模型是否“偏科”的最快方式。我通常用 macro-F1 作为总指标——它把每个类别的F1取平均,不受类别样本量影响。一个 macro-F1 在 0.85 但 accuracy 在 0.93 的模型,比一个 accuracy 在 0.96 但小类别召回率只有 0.1 的模型可靠得多。confusion_matrix用来找“混淆对”——如果地震和山体滑坡互相误报严重,后面就要考虑是否把这两个类别合并,或者给模型增加区分这两个类别的专项数据。

4. 把准确率做成可用系统:类别加权、事件分组与阈值决策

基线跑通之后,接下来做的事情是把模型从“能分类”推向“能用于实际判断”。这一章里的四个调整是灾害图像分类从实验室走向业务的关键,彼此独立,但建议按顺序做。

4.1 类别不平衡的三档解法:从class weight到Focal Loss

如果第3章的 basline 暴露了小类别召回率低的问题,第一档解法是给交叉熵损失加类别权重。权重设置的原则是不平衡越严重,权重越大,但也不是简单地用逆频率——逆频率会把小类别的权重拉得非常大,导致模型在小类别上过拟合,把大量正常样本误判成灾害。

from sklearn.utils.class_weight import compute_class_weight import torch # 计算每个类别的样本数 class_counts = train_df["label"].value_counts().sort_index() # 方法一:直接逆频率 weights = 1.0 / class_counts.values # 方法二:中位数频率平衡,抑制极端权重 median_count = np.median(class_counts.values) weights = median_count / class_counts.values # 做归一化控制幅度 weights = weights / weights.sum() * len(weights) class_weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

compute_class_weight是 sklearn 提供的便捷函数,但它默认返回严格逆频率。我更推荐median_count / class_counts这种中位数频率平衡,因为它不会让最大类别和最小类别的权重差距超过一个数量级。如果类别数量悬殊达到 30 倍以上,直接逆频率会让模型把训练重点完全放在小类别上,破坏整体精度。

如果做了类别加权后小类别仍然不达标,第二档是换 Focal Loss。Focal Loss 会降低“易分类样本”的损失权重,让模型更关注那些置信度不高的难样本。这在灾害图像里很有用,因为大量样本根本不是“明确的可分类对象”——半倒塌的建筑从某个角度看可能像正常建筑。

import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.alpha = alpha # 类别权重,优先用中位数频率平衡后的值 self.gamma = gamma def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce_loss) # 当前样本的预测置信度 focal = (1 - pt) ** self.gamma * ce_loss if self.alpha is not None: alpha = self.alpha.to(logits.device) focal = focal * alpha[targets] return focal.mean()

gamma=2.0是论文里的默认值,实际使用中我会在 1.0 到 2.5 之间调。gamma 越大,模型越聚焦于难样本,但过大容易让小噪声样本主导训练。Focal Loss 的前提是样本类别不平衡且难易程度差异大,如果你的数据集整体类别分布还算均匀,它的收益反而不如 class weight 明显,不必盲目上。

4.2 按事件分组划分,避免同一次灾害的数据泄漏

第2章检查过 event_id 之后,这里要落地实现。如果同一事件的照片同时出现在训练集和验证集,验证集的作用就失效了——模型不是在做泛化,而是在“回忆”同一个场景里的背景特征。

from sklearn.model_selection import GroupShuffleSplit # df 必须包含 event_id 列,用于标识同一次灾害事件 groups = df["event_id"].values gss_train = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_val_idx, test_idx = next(gss_train.split(df, df["label"], groups=groups)) # 验证集也要按事件组划分,保证同一事件的照片不会跨 train/val gss_val = GroupShuffleSplit(n_splits=1, test_size=0.125, random_state=42) train_idx, val_idx = next( gss_val.split(df.iloc[train_val_idx], df.iloc[train_val_idx]["label"], groups=df.iloc[train_val_idx]["event_id"]) )

这段代码与第3章分层划分的区别在于GroupShuffleSplit的分组对象是event_id,而不是单个样本。它保证同一事件的所有照片被分到同一侧,训练集和验证集之间不存在来自同一事件的相似图片。注意这里test_size的语义已经变了——它控制的是事件组的比例,而不是样本的比例,所以验证集的样本量可能偏离 10% 不少,要做好心理准备。如果某个事件的照片特别多(比如一次台风拍摄了800张),它会在测试集里占很高比例,导致测试结果被这一事件主导。这种情况可以考虑按事件做上限截断,或者在事件维度上重新加权评估指标。

严格按事件分组会让验证集指标比随机划分低 3 到 8 个百分点,这是正常的,不要因为指标下降就改回随机划分。下降的那部分才是模型真实泛化能力的检测。

4.3 ViT / EfficientNet 对照:什么时候值得换骨干网络

ResNet50 作为基线稳妥,但它不是唯一选择。EfficientNet 在同等计算量下精度更高,ViT 在数据量足够时分类表现更好,但两个网络在 4400 张数据下的表现都需要实测验证。

from torchvision import models # 方案一:EfficientNet-B0,相比ResNet50参数更少,适合小数据集 model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1) model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) # 方案二:ViT-B/16,Transformer结构,需要更多数据但特征更全局 model = models.vit_b_16(weights=models.ViT_B_16_Weights.IMAGENET1K_V1) model.heads.head = nn.Linear(model.heads.head.in_features, num_classes)

我的判断标准是:如果类别数在 6 到 10 个之间,先跑 EfficientNet-B0 做对照,它的参数量只有 ResNet50 的一半左右,过拟合风险更低。如果发现模型出现“对损毁纹理过拟合、对整体结构理解不足”——典型现象是废墟和正常建筑在局部纹理相似时误判——再上 ViT,因为 Transformer 的全局注意力机制能更好地整合建筑结构和损毁区域的空间关系。但 4400 张数据对 ViT 来说偏少,需要依赖更强的数据增强和正则化,否则 ViT 在验证集上的表现可能反而不如 ResNet50。我一般会把三个模型各跑一遍,把所有结果放在一张表里对比 macro-F1、每类召回率和推理时间,用数据和业务场景做决定。泛泛地说“ViT 更好”在灾害图像上不成立。

4.4 置信度阈值与决策:把模型输出交给业务

分类模型的输出不是一个类别,而是一组概率。直接把argmax的结果当作最终判断,在灾害场景里很危险——低置信度的预测可能是错判,而这种错判在紧急情况下会带来实际影响。更合理的做法是用置信度做分级决策。

model.eval() threshold = 0.75 # 可调参数,根据验证集上的置信度-精度曲线确定 with torch.no_grad(): logits = model(images) probs = torch.softmax(logits, dim=1) conf, pred = torch.max(probs, dim=1) # 把低置信度样本单独标记出来,不参与自动分类 uncertain_mask = conf < threshold print(f"本次批次不确定样本: {uncertain_mask.sum().item()}/{len(conf)}") # 这些样本应该进入人工复核队列,而不是直接给一个硬标签

threshold=0.75是一个起点值,更严谨的做法是在验证集上绘制“置信度 vs 准确率”曲线,找到准确率开始下降的拐点作为阈值。这个阈值的意义是:模型告诉我们“它不确定”这件事本身是有价值的信息。在 4400 张这样的小数据集上,模型的不确定性往往集中在训练样本覆盖不足的场景,把这些样本挑出来做人工复核,比盲目相信模型判断更可靠。

5. 灾害图像分类的避坑记录:六个常见翻车现象与修复

这一章的每一条都来自真实项目里的血泪经验。现象写得具体,是因为只有你能对着自己的数据集找到相同症状,修复方案才有意义。

5.1 验证集95%,新照片直接翻车

现象:模型在验证集上准确率 95%,拿到一批新拍摄的灾害照片,准确率掉到 60% 出头,错误集中在“地震”和“正常建筑”之间。

原因:灾害图像数据集的两个特点放大了过拟合——同一事件的照片在背景、拍摄设备、天气条件上高度相似,随机划分时这些高度相似的照片被同时分进训练集和验证集;同时数据量只有 4400 张,模型很容易记住训练集里的背景特征(比如某场地震的特定街区),而不是损毁本身。

解决:第一步,按第4.2节改成 event_id 分组划分,让验证集只包含模型没见过的灾害事件。第二步,数据增强要加 RandomAffine(degrees=10, translate=(0.1, 0.1)),让模型对拍摄角度和画面偏移不敏感。第三步,如果还不行,把训练集的背景多样性做可视化——随机抽 100 张训练图片看背景是否过于集中,如果集中,需要补充更多不同地区、不同季节的正常建筑照片作为负类。

5.2 模型学的是“救援车”而不是“废墟”

现象:模型把“地震”类别的召回率做到 0.9,但错误分析时发现,它判断地震的依据是画面里的救援卡车、帐篷和消防员,而不是倒塌的建筑物。

原因:灾害数据集的天然偏见——救援现场的车辆和设备出现在大部分地震照片里,模型用最简单的视觉线索(车轮、帐篷形状)代替了真正的结构化特征。这在用 Grad-CAM 可视化时看得非常清楚,模型的注意力集中在车辆上而不是建筑裂缝上。

解决:第一步,做数据清洗:统计训练集里每张图片是否包含明显的车辆或帐篷(可以用简单目标检测器辅助),把这类图片单独建一个 “rescue_logistics” 类别,或者直接从训练集里删除,避免模型把“车辆出现”等同于“地震发生”。第二步,扩充负类样本,让正常的建筑场景和车辆出现在“正常”类别里。如果数据集不允许增加外部数据,退而求其次是类别加权时降低“地震”类的权重,强行抑制模型对高频特征的依赖。

5.3 小类别召回率个位数,accuracy却很高

现象:山体滑坡类别只有 160 张训练样本,测试集召回率 5%,但整体准确率仍然有 92%,因为山体滑坡在所有测试样本里只占 8%。

原因:交叉熵损失默认对所有样本一视同仁,模型为了让总体损失最小化,策略是放弃小类别,把所有样本都预测到大类别上。这个问题的细心之处在于:它不是模型坏了,而是优化目标本身和业务目标不匹配。

解决:先按第4.1节加中位数频率平衡的类别权重,把山体滑坡的损失权重提上去。观察两轮训练后的召回率变化——如果提升不明显,再换 Focal Loss,gamma 从 2.0 开始。如果小类别仍然起不来,检查一下这个小类别的样本质量:是不是标注有噪声、或者各类别间纹理过于相似。最后手段是对小类别做过采样(复制样本到与实际数量 1:1),数据增强里对小类别单独加扰动。

5.4 雨天正常街景被误报为“台风”或“内涝”

现象:验证集里大量“正常”类别的图片被预测成“台风”,这些图片的共同特点是阴天、雨水、路面反光,出现在台风季前后。

原因:模型的判断依据是“天气暗淡+路面反光”这个强视觉线索,而不是“房屋损坏、树木倒伏”这些结构性损毁特征。这和第5.2条同源——模型总倾向于找最容易区分的表面特征,而灾害图像里这些表面特征往往和天气相关。

解决:把 ColorJitter 增强加强为 brightness=0.4、contrast=0.4,强迫模型不能依赖固定的光照模式。同时检查“正常”类别里的训练样本:如果正常类别全部来自晴朗天气,模型当然会把“阴天和暗光”当成灾害特征。从公开数据集里补充一些阴天、雨天的正常街景图片到“正常”类别,让这个类别的特征多样性覆盖到灾害天气的视觉范围。

5.5 测试集划分太小,训练五次差四个点

现象:同一个人,同一个代码,同一个随机种子,连续训练五次,验证集 accuracy 在 0.88 到 0.92 之间波动。

原因:4400 张数据按 70/10/20 划分后,验证集只有 440 张,小类别在验证集里可能只有 10 到 20 张。每张图的误判都会让指标跳动 0.5% 到 1%,如果误判恰好集中在小类别,macro-F1 的波动会更明显。

解决:两个方向。第一,把验证集和测试集合并成 30%,用 5 折交叉验证评估模型稳定性,报告均值和标准差;第二,固定随机种子并多次训练取中位数,不要只跑一次就下结论。交叉验证在 4400 张的数据量下是更可靠的评估方式,只是要接受训练时间变成原来的五倍,但这对判断一个模型到底行不行是必要的投入。最终的模型用全部数据重新训练一遍,再用单独的测试集做最终评估。

5.6 一张图同时存在两种灾害,粗粒度标签打架

现象:台风引发了洪水,一张照片里房屋被泡在水里,屋顶也被掀翻。这个样本在数据集中被标成“台风”,但模型预测成“洪水”,训练时不断在这两个类别之间震荡,两个类别的指标都不稳定。

原因:灾害图像分类数据集的粗粒度标签假设“一张图只有一个类别”,但自然灾害的实际场景往往是链式发生的——地震引发海啸,台风引发洪水和滑坡。强行为一个多灾害样本分配单一标签,标签本身就存在噪声。

解决:如果确认数据集里混入较多这类样本,有两个方案。方案一是把常伴随发生的灾害合并成组合类别,比如“台风+洪水”,让类别变成互斥事件。方案二是改成多标签分类,用BCEWithLogitsLoss替代CrossEntropyLoss,让一张图可以同时被预测为“台风”和“洪水”。后者更符合实际业务需求,但评估指标要从准确率换成每个标签的 AUC,复杂度会上一个台阶。如果项目周期紧,先用方案一合并类别,让标签自洽。

6. 用 Grad-CAM 验证模型到底在看什么

训练完模型、指标也满意了,先别急着部署。在灾害图像分类这类对判断依据有实际要求的任务里,模型“为什么这么判断”和“判断得对不对”同等重要。Grad-CAM 通过梯度计算生成热力图,把模型决策时的注意力区域可视化——你一眼就能看出来模型是在看倒塌的墙体,还是在看车道线。

import torch from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image import cv2 import numpy as np # target_layers 选择最后一个卷积层,包含最高层语义特征 # ResNet50 用 layer4[-1];EfficientNet 用 features[-1];ViT 不适用传统 Grad-CAM target_layers = [model.layer4[-1]] cam = GradCAM(model=model, target_layers=target_layers) model.eval() with torch.no_grad(): logits = model(image_tensor.unsqueeze(0).to(device)) pred_class = torch.argmax(logits, dim=1).item() # 生成热力图并叠加到原图上 targets = [ClassifierOutputTarget(pred_class)] grayscale_cam = cam(image_tensor.unsqueeze(0), targets=targets) heatmap = show_cam_on_image(np.float32(image_np) / 255.0, grayscale_cam[0], use_rgb=True) cv2.imwrite("gradcam_output.jpg", heatmap)

这段代码的核心参数是target_layers——选哪个层决定了热力图反映“低层纹理线索”还是“高层语义线索”。灾害图像上用最后一个卷积层最合适:低层特征(边缘、颜色)不足以解释模型为什么把一个场景分成地震,而最后一个卷积层已经把局部纹理抽象成了结构语义。ClassifierOutputTarget(pred_class)指定只对模型的预测类别做解释,如果你想研究“为什么没有把它预测成洪水”,可以改成ClassifierOutputTarget(洪水的类别ID),看模型在看到这张图时对洪水类别的注意力分布。

我拿到训练好的模型后,习惯性会做两件事:第一,把测试集里所有误报样本的 Grad-CAM 图集中保存下来,按错误类型分类,看模型是“看到了正确区域但判断错误”还是“注意力完全跑偏”。前者说明特征表达不够,后者说明模型被表面特征误导。第二,从每个类别抽 20 张正确预测的样本,确认模型判断依据的稳定性——如果同一类别的热力图分布差异过大,说明模型对这类别的判断路径不统一,这类样本再多也没有形成一个稳定的判别模式。

这两个检查做完,模型才能放心交给业务方。灾害图像分类这个方向的价值不在于把 accuracy 刷到 99%,而在于模型在关键时刻的判断依据是可信的、稳定的。判断依据不对,准确率再高也只是个黑匣子碰巧答对了题目。4400 张数据注定了这一行不会像大规模预训练那样“力大砖飞”,但如果能把数据验货、划分策略、类别处理和可视化验证这四件事做扎实,这套方法论可以照搬到任何一个小样本图像分类项目上。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 9:08:31

二次规划与积极集法:原理、实现与工程实战

写代码的人应该都有过这种经历&#xff1a;目标函数明明是个漂亮的凸二次函数&#xff0c;求个导、令它等于零&#xff0c;几秒钟就能写完解析解的代码&#xff0c;结果一旦加上约束&#xff0c;解出来的点直接跑到不可行域外面去了。我在做小车MPC轨迹跟踪的时候就被这个问题卡…

作者头像 李华
网站建设 2026/10/1 9:08:08

localhost:3000拒绝访问排查指南:分层定位与六种成因修复

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:07:10

JavaScript暂时性死区(TDZ)原理与实战排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:07:03

Surface重装系统:固件级恢复与驱动绑定全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华