简介:一套约13000张已标注的森林火灾图像分类数据集,围绕“有火/无火”二分类任务构建,面向计算机视觉与深度学习方向的研究人员、算法工程师,以及森林火情智能监测相关项目的开发者。压缩包内共2000个文件,以1998张jpg图像为主体,另含1个py可视化脚本与1个json标注配置文件,整体大小约338.73MB。数据集已预先划分训练集与测试集,按类别分别存放,训练集用于模型特征学习,测试集便于检验泛化能力,可直接进入分类模型训练与评估流程。json文件明确了两个类别定义,运行附带的show脚本可直观浏览各类样本;图像文件采用统一编号命名,便于批量处理与二次划分。资源结构清晰,既适合作为火灾图像分类与网络改进实验的数据基础,也可配合完整视觉项目流程进行算法验证与效果优化,目前已有439人学习下载。
1. 森林火灾图像分类数据集:13,000张已标注图能做的事与不能做的事
森林火灾图像分类数据集,说白了就是给你一个已经标好类别的文件夹,里面约 13,000 张图像,每张图要么是火、要么是烟、要么是正常林地。图像分类这个方向现在不缺模型,缺的是干净可复现的数据集。刚拿到这类数据的人,通常会犯两个毛病:要么不盘点直接开训,要么只盯着准确率,结果发现部署后根本不敢用。我会按一线工程习惯,从数据盘点、划分、训练、避坑一直聊到置信度阈值,目标是让你照着做一遍就能产出一个可信的基线模型。它适合谁?适合正在做火灾预警、林区监控或者相关图像识别项目,手上有这个数据集但不知道怎么训练的工程师;也适合想用一个小型真实场景数据跑通图像分类全流程的入门者。先说结论:13,000 张足够用迁移学习微调出一个可用的分类器,真正的瓶颈在标注噪声和验证集划分方式,不在模型。
2. 动手前先盘点数据:类别分布、标注格式与质量检查
图像分类数据集和检测数据集不一样,检测要读 bounding box,分类只需要图片和标号。但这个“标号”具体存在哪里,影响后面所有代码。森林火灾数据集常见的组织方式有两种:一是按文件夹名分好类,例如fire/、smoke/、normal/;二是给你一个 CSV/JSON,里面写image_path,label两列。第一种用torchvision.datasets.ImageFolder三行就能读,第二种要自己写 Dataset。我拿到任何数据集第一件事不是建模,而是先写个脚本把底数摸清:每一类有多少张、有没有损坏图片、分辨率是否统一。这是后面所有指标的地基。
2.1 先别急着训练:用脚本盘点类别和图片状态
这个脚本不复杂,但会避免你后续在训练到一半时因为一张损坏的图片而翻车。我会先列目录、统计每个子目录的图片数量,然后用 PIL 的verify()检查文件头是不是完整。图像分类数据集最容易出现的问题,不是模型选型,而是数据里混着零字节文件或截断的 JPEG,PyTorch 的 DataLoader 一读到就直接炸。
import os from collections import Counter from PIL import Image data_root = "./forest_fire_dataset" # 1. 列出所有子目录,把目录名当作类别 class_dirs = [d for d in os.listdir(data_root) if os.path.isdir(os.path.join(data_root, d))] counts = Counter() # 2. 统计每个类别的图片数 for c in class_dirs: imgs = [f for f in os.listdir(os.path.join(data_root, c)) if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp"))] counts[c] = len(imgs) print("类别分布:", dict(counts)) # 3. 检查损坏图片 bad_images = [] for c in class_dirs: for f in os.listdir(os.path.join(data_root, c)): p = os.path.join(data_root, c, f) if not f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")): continue try: with Image.open(p) as img: img.verify() except Exception as e: bad_images.append((p, str(e))) print("损坏图片数量:", len(bad_images)) for p, err in bad_images[:5]: print("损坏文件:", p, err)这段脚本的逻辑分三步:先把目录名当作类别名,统计每个目录下合法后缀的图片数量;然后用PIL.Image.verify()检查图片文件能否被解码,注意verify()只会检查文件完整性,不会把整张图加载进内存,速度很快;最后把损坏文件列出来,数量多的话需要找数据集提供方补或者直接删除。
跑完之后你应该得到类似{'fire': 5200, 'smoke': 3100, 'normal': 4700}的分布。如果某一类只有几百张,后面训练时要考虑类别权重。如果损坏图片超过几十张,建议先修数据再做任何训练,因为哪怕只有一张坏图混进ImageFolder,DataLoader可能随机崩溃且不好定位,这种“随机出现、换一个 epoch 又好了”的现象最浪费时间。
2.2 标注格式的两种常见形态:目录即标签与 CSV 映射
确认好数据完整后,就要决定怎么把图片和标签喂给模型。绝大多数公开分类数据集会直接给你目录结构,这时没有必要自己写 Dataset,直接用torchvision.datasets.ImageFolder最省事。它的底层逻辑是:每个子目录名对应一个类别,内部图片按字母序排,类别索引按目录名排序生成。一个容易踩的细节是:如果目录是normal, fire, smoke,那么class_to_idx会是{'fire': 0, 'normal': 1, 'smoke': 2},因为字母序f < n < s,所以图像分类时千万不要想当然地认为“第一个目录就是第 0 类”。
如果数据集提供的是 CSV,那就必须自己写一个轻量 Dataset。常见格式是image_path,label,label 可能是数字也可能是字符串,字符串需要先映射成整数,因为CrossEntropyLoss只接受整数标签。
import os import pandas as pd from PIL import Image import torch from torch.utils.data import Dataset class FireCSVDataset(Dataset): """读取森林火灾 CSV 标注文件的自定义 Dataset 参数: csv_path: CSV 文件路径 img_root: 图片所在的根目录,CSV 里的路径是相对这个根目录的 transform: torchvision transforms label_map: 字符串标签到整数标签的映射,如 {"normal": 0, "fire": 1} """ def __init__(self, csv_path, img_root, transform=None, label_map=None): self.df = pd.read_csv(csv_path) self.img_root = img_root self.transform = transform self.label_map = label_map or {} # 如果没有映射,就按字母序自动生成 if not self.label_map: unique_labels = sorted(self.df["label"].unique()) self.label_map = {lab: i for i, lab in enumerate(unique_labels)} def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = os.path.join(self.img_root, row["image_path"]) image = Image.open(img_path).convert("RGB") label_str = row["label"] label = self.label_map[label_str] if isinstance(label_str, str) else int(label_str) if self.transform: image = self.transform(image) return image, torch.tensor(label, dtype=torch.long)这个类只有三个方法:__init__负责读 CSV 和建立字符串到整数的映射;__len__返回样本数,DataLoader 会用它算 batch 数量;__getitem__按索引读图、读标签、做 transform,最后返回(tensor_img, tensor_label)二元组。注意一个细节:Image.open().convert("RGB")很有必要。很多森林火灾图像是 PNG,可能带 alpha 通道,直接ToTensor()会变成 4 通道输入,模型第一层卷积就报错,而且报错信息藏在不明显的位置。
参数说明:transform在第 3 章里展开;label_map最好在外部定义好传进来,不要依赖自动生成,否则训练和推理时如果 CSV 的类别顺序变了,模型输出索引就错位了。我的做法是在项目根目录固定一个labels.json存映射,训练代码和部署代码都读它,避免“训练时是 0=fire,推理时 0=normal”这种黑匣子错误。
3. 数据划分与预处理:分层切分、尺度统一和增强策略
数据盘点过后,接下来就是划分训练集、验证集和测试集。很多做图像分类的工程师会在这一步偷懒,直接shuffle=True随机切,但这个数据集只有 13,000 张,类别分布又不一定均衡,随机切最直接的后果是某一小类在验证集里只有几十张,导致 F1 波动特别大。所以我会用分层抽样,保证每个子集的类别比例和原始数据集一致。然后才是预处理和增强。顺序不要反:先划分,再做增强,因为增强后的图像属于同一个样本的多个副本,划分时不加处理会引入数据泄露。
3.1 分层划分:别让某一类全跑进测试集
如果你的数据已经是ImageFolder,最干净的方式是拿索引数组做划分。先把dataset.samples里的标签抽出来,用sklearn.model_selection.train_test_split,把stratify参数传成标签数组,random_state固定。这一步的可复现性很重要,后面无论调多少次参数,对比的模型都面对同一个验证集。
import numpy as np from sklearn.model_selection import train_test_split from torch.utils.data import Subset from torchvision import datasets dataset = datasets.ImageFolder("./forest_fire_dataset", transform=train_transform) idx = np.arange(len(dataset)) labels = [label for _, label in dataset.samples] # 和 dataset.targets 等效 train_idx, val_idx = train_test_split( idx, test_size=0.2, stratify=labels, # 保证类别比例一致 random_state=42 ) train_dataset = Subset(dataset, train_idx) val_dataset = Subset(dataset, val_idx) print("训练集大小:", len(train_dataset), "验证集大小:", len(val_dataset))这里test_size=0.2表示留出 20% 做验证集。如果你的最终目标是评估模型泛化能力,建议再从训练集里留出 10% 做测试集,即训练 72%、验证 18%、测试 10%。但很多项目只需要一个可信的验证集,因为最后会用全量数据重新训练并部署。注意:不要用“验证集”反复调阈值后还说它有最终泛化能力,严格讲阈值也是该在验证集上学的,想得到无偏指标,请再切一个测试集。
另一个重点:Subset传入的是索引,不是路径。它会去原dataset里按索引取样本,所以train_transform和val_transform要提前设置好,但Subset不会阻止你在训练时单独给子集换 transform。如果你在ImageFolder初始化时已经用了带增强的 transform,验证集也跟着增强,那验证指标就会不稳定。正确做法是先用不带 transform 的ImageFolder创建原始 dataset,划分完再给训练集和验证集分别指定 transform;或者干脆在Subset外面再包一层带 transform 的 Dataset。
3.2 预处理与增强:224 尺寸、ImageNet 归一化与有限的色彩增强
图像分类的预处理有“标准答案”吗?没有,但迁移学习里有惯用套路:把输入缩放到模型训练时的尺寸,使用 ImageNet 的均值和标准差做标准化,因为预训练权重的统计量就是基于 ImageNet 图像算出来的。13,000 张的森林火灾图像,分辨率可能差异很大,我一般会统一到 224×224,这是 ResNet 和 EfficientNet 默认输入,调换到 256 或 384 会显著增加显存占用,收益却不一定大。
from torchvision import transforms # 训练集增强:不要让火的颜色失衡 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.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(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])参数说明:RandomResizedCrop的scale=(0.6, 1.0)表示裁剪区域占原图的 60% 到 100%,比默认的(0.08, 1.0)保守,因为火灾里真正起关键作用的往往是火或烟在画面中占比较大,裁得太狠会把火苗裁掉。ColorJitter三个参数都只给了 0.2,没有给hue,这是因为色调翻转容易把“红火”变成“紫火”、“橙烟”变成“绿烟”,在语义上制造错误样本。这是我在火灾项目里实际踩过坑后才收敛的参数。
还有一点,Normalize的三个通道对应 RGB,不要觉得自己数据集是火灾图就要自己重算均值和方差。用 ImageNet 的统计量不影响模型收敛,只是让输入分布接近预训练分布;自己统计反而会让迁移学习起点偏移,效果不稳定。当然如果你从头训练,才需要按自己的数据算。
3.3 视频帧数据集必须按场景分组划分
这是森林火灾数据集的另一个特点:很多图片不是独立拍摄的,而是从监控视频中抽帧来的。如果同一段视频的相邻帧,一半在训练集、一半在验证集,那模型学到的其实是“像素级背景记忆”,而不是“火灾外观特征”。验证集指标很好看,一旦换一个林区就崩。判断方法很简单:看文件名里有没有类似scene01_0001.jpg这种前缀,有的话就要按场景分组。
from sklearn.model_selection import GroupShuffleSplit import os idx = np.arange(len(dataset)) labels = [label for _, label in dataset.samples] groups = [os.path.basename(p).split("_")[0] for p, _ in dataset.samples] gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(idx, labels, groups)) print("训练集包含哪些组:", set([groups[i] for i in train_idx])) print("验证集包含哪些组:", set([groups[i] for i in val_idx]))GroupShuffleSplit的核心是groups数组,它把每个样本归到某个组,划分时保证同一个组的样本全部进入同一个集合。如果你确认数据不是视频帧,可以跳过这步;如果不确定,宁可花一分钟做组划分,也不要为了简单随机切,最后被数据泄露坑掉几个星期。这个问题在论文里常叫“数据泄漏”,在工程里更形象的说法是“模型背答案”。
4. 训练图像分类模型:ResNet 与 EfficientNet 的迁移学习参数
到了这个环节,你手里已经有干净的训练集和验证集。选模型时,不要被“最新的图像分类模型”带偏。13,000 张的规模,Vision Transformer 和 ConvNeXt 虽然效果好,但需要更大的数据量、更长的训练时间、更精细的调度,对做火灾预警来说性价比不高。更关键的是,绝大多数开源预训练权重是在 ImageNet-1K 上得到的,而火灾图像和普通自然图像差异明显,所以迁移学习的微调策略比选哪一版模型重要得多。我一般会在 ResNet18 和 EfficientNet-B0 之间选一个起步,先把流程跑通,再考虑更重的模型。
4.1 模型选型:为什么中小规模数据集更适合 ResNet/EfficientNet
先给个对比表,方便你按硬件条件选:
| 模型 | 参数量(相对) | 输入尺寸 | 训练速度 | 我的使用建议 |
|---|---|---|---|---|
| ResNet18 | 小 | 224 | 快 | 流程验证、基线模型 |
| ResNet50 | 中 | 224/256 | 中 | 追求精度,显存有余 |
| EfficientNet-B0 | 小 | 224 | 中 | 精度和速度均衡,部署友好 |
| ViT-B/16 | 大 | 224 | 慢 | 不推荐,13,000 张不够它吃 |
这张表不是精确数据,参数多少、速度多快你可以在自己机器上跑,但选型逻辑是清楚的:数据集只有 13,000 张,模型容量太大不仅训练慢,还容易过拟合。ResNet18 作为一个 3 层卷积堆叠的深度网络,提取火灾火焰纹理、烟雾边缘已经足够;EfficientNet-B0 用了神经架构搜索得到的缩放系数,同样精度下 FLOPs 更小,如果你的推理设备是边缘盒子,它是更好的目标。ResNet50 能不能用?能,但如果你的训练集只有 6,000 张,效果未必比 ResNet18 好多少,反而显存翻倍。所以我建议先拿 ResNet18 跑通全流程,再根据验证集 F1 决定要不要换 EfficientNet-B0 或 ResNet50。
4.2 迁移学习训练脚本:冻结骨干还是全量微调?
确定模型后,直接加载 ImageNet-1K 预训练权重。PyTorch 从某个版本开始不推荐pretrained=True,而是用weights=参数,这样能避免版本兼容性问题。加载后把最后的全连接层替换成我们的类别数。那么问题来了:是冻结前面的卷积层只训练最后一层,还是全部解开一起训练?
对 13,000 张森林火灾图,我的建议是:全量微调。理由很简单,火灾图像和 ImageNet 里的自然图像分布差异大,如果只训练最后的全连接层,前面的特征提取器可能无法感知“烟雾的半透明性”和“火焰的高亮纹理”。当然全量微调也需要控制学习率,初始学习率不要超过 1e-4,否则预训练权重会被打得稀烂。
import torch import torch.nn as nn import torch.optim as optim from torchvision import models device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_classes = 3 # 根据你的类别数改 model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) # 如果想冻结前两层,做特征提取风格,可以这样: # for name, param in model.named_parameters(): # if name.startswith("conv1") or name.startswith("layer1"): # param.requires_grad = False criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)参数说明:lr=1e-4是迁移学习微调的常见起点;如果你用的是 SGD,可以设lr=1e-3,同时加momentum=0.9。AdamW 的weight_decay=1e-4控制正则强度,防止最后一层过拟合。T_max=30要和你的训练总 epoch 数一致,表示余弦退火从初始学习率降到 0 的周期。这里我把T_max=30,默认计划训 30 个 epoch。
下面是最小训练循环。评估函数在 4.3 小节给出,这里先留调用。
train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, drop_last=True) val_loader = torch.utils.data.DataLoader( val_dataset, batch_size=64, shuffle=False, num_workers=4) epochs = 30 best_acc = 0.0 for epoch in range(epochs): model.train() total_loss = 0.0 for images, labels in train_loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) scheduler.step() avg_loss = total_loss / len(train_dataset) # 调用 4.3 的 evaluate_model,返回预测结果和概率 val_labels, val_probs, val_preds = evaluate_model(model, val_loader, device, dataset.classes) acc = (val_preds == val_labels).mean() print(f"Epoch {epoch:2d} | Loss {avg_loss:.4f} | Val Acc {acc:.4f}")这个循环里值得注意的细节:drop_last=True是为了防止最后一个 batch 样本数过少导致 BatchNorm 更新不稳定,虽然 13,000 张未必能被 32 整除,但建议加上。scheduler.step()放在每个 epoch 结束后,而不是每个 batch 后,CosineAnnealingLR 默认是 epoch 粒度更新的。如果你发现验证集 loss 在后期反弹,可以把T_max调大些,或者改用ReduceLROnPlateau,它会在指标不涨时自动降学习率,更适合真实项目。
4.3 评估指标:准确率会骗人,用混淆矩阵和 F1 说话
刚才代码里留了evaluate_model的缺口。火灾检测的本质是代价不对称的分类问题:把火判成正常是重大事故,把正常判成火只是一个小告警。准确率只统计“所有预测中答对的比例”,但如果 80% 是正常类,模型全判正常也能有 80% 准确率,这对你没有任何意义。所以至少要输出每个类别的 precision、recall、F1,以及混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix import torch def evaluate_model(model, loader, device, class_names): model.eval() all_preds = [] all_labels = [] all_probs = [] with torch.no_grad(): for images, labels in loader: images = images.to(device) outputs = model(images) probs = torch.softmax(outputs, dim=1) preds = torch.argmax(outputs, dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) all_probs.extend(probs.cpu().numpy()) print(classification_report(all_labels, all_preds, target_names=class_names, digits=4)) print(confusion_matrix(all_labels, all_preds)) return (all_labels, all_probs, all_preds)这里多存了all_probs,是给第 6 章卡置信度阈值用的。分类报告里你会看到每个类别的召回率,如果“火”这个类别的召回率只有 0.6,那就别急着部署。参数说明:class_names必须和 ImageFolder 的classes顺序一致;digits=4显示四位小数,方便对比小差异。这样评估出来的 F1 才是你后续调参的目标。
5. 避坑:森林火灾图像分类的五个常见翻车点
这一章把我在实际项目中遇到的翻车点一次说清。每个坑都按“现象 → 原因 → 解决”来讲,你可以直接对照自己实验的现象看。
5.1 类别不平衡导致的“假准确率”:多数类把火情淹没了
现象:训练完看到验证准确率 94%,但翻看分类报告,火灾类的召回率只有 25%。原因是 13,000 张图里可能 80% 都是正常林地,模型只需全判成正常就能拿到高准确率,根本不需要学会火灾特征。
解决:训练前打印每类样本数,看到分布明显偏斜,就在损失函数里加类别权重。最常见的做法是用sklearn.utils.class_weight.compute_class_weight算出 balanced 权重,再传给CrossEntropyLoss(weight=...)。这个权重会放大样本量少的类别的 loss,让模型“被迫”注意它。
import numpy as np from sklearn.utils.class_weight import compute_class_weight classes = np.unique(labels) weights = compute_class_weight("balanced", classes=classes, y=labels) class_weights = torch.tensor(weights, dtype=torch.float).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)注意:加了类别权重后,学习率可能需要稍微调低,因为少数类的 loss 变大,容易震荡。另外也可以在采样层面解决,用WeightedRandomSampler,但权重方式更干净。
5.2 视频帧随机划分,模型“背答案”导致验证集虚高
现象:验证集 F1 达到 0.95,可拿到现场实际拍摄的一段视频上一测,漏报严重。原因是训练集和验证集来自同一段监控视频,模型把场景背景背下来了,而不是真的理解火。
解决:在看文件名是否有scene01、camera3这类前缀,有就按分组划分,方法已经在第 3 章给过。我在这里再强调一次,因为这是我踩过最深的一个坑。如果一个数据集是从原始视频里抽帧得到的,且没有显式提供分组信息,最简单的做法是看相邻帧的 md5,把连续相同的场景归为一组。更实用的是在划分前直接跑一个“相似度检查”:连续帧如果像素级差异很小,大概率来自同一视频。这个坑的破坏力在于它会让指标完全失真,而且很难察觉。
5.3 标注噪声:烟雾和雾气、远处的火光很难分清
现象:训练损失下降到最后几轮开始抖动,验证集上固定某几张图反复预测错。把这些图打印出来,人眼都很难判断是烟还是雾,有些是林间清晨的水雾,标成了烟雾。
原因:标注者主观性导致边界样本标签不干净。森林火灾图像分类数据集的难点不在于“熊熊大火”这种一眼就能看出的样本,而在于早期火情——一小缕烟、远处若隐若现的橙色光斑。标注规范如果不强调这些边界的判断标准,数据里就会充满错标。
解决:训练之前先用一个大模型(如预训练 ResNet 或 CLIP)对数据做一次预测,把预测置信度高但和标注不同的样本挑出来人工复核。代码思路是记录预测类别 != 真实类别且softmax 概率 > 0.8的样本,这些大概率是错标,而不是模型问题。这个流程也叫“错标清洗”,实际能清理掉 1% 到 5% 的错误标签。清理完再训练,效果立竿见影。
5.4 增强副作用:把红色增强成紫色,模型学了个寂寞
现象:训练集的 loss 降得很低,但验证集的精度始终上不去,甚至部分测试图只要稍微偏色就错判。检查增强后的图片发现,Hue抖动把火焰的红色调成紫色,把背景的绿色调成红色,模型学到的是“橙色 = 火”,而不是“火苗纹理 + 烟雾扩散”。
原因:很多默认增强模板会带ColorJitter(hue=0.2),这在通用图像分类上没问题,但对火灾语义是负优化。林区的叶子颜色和火焰颜色在 RGB 空间本来接近,再经过 hue 扰动,类别边界被彻底搅乱。
解决:把hue参数去掉,saturation也不宜超过 0.2。还有RandomChoice里的RandomGrayscale要慎用,火和烟的灰度区分度远低于彩色区分度。增强的目标是制造“朝向不变性”,而不是“颜色改变”。
5.5 验证集、测试集没分开,调参调到“自欺欺人”
现象:你在验证集上反复调阈值、改增强,最终验证集 F1 0.97,但上线后跌到 0.80。原因是验证集参与了你的决策,它已经变成了训练过程的一部分,不再代表未知数据。
解决:一开始就把数据切成三份:训练集、验证集、测试集,其中测试集只允许跑一次,用于最终评估。这个原则在竞赛里是天条,在工程里却经常被忽略。如果你手上只有 13,000 张,切片比例建议 72/18/10,不要担心少那 10% 训练数据,可信的测试集比 1% 的准确率提升重要得多。
6. 进阶:用置信度阈值卡召回率,给分类模型装上“后悔药”
火情识别宁可多报,不可漏报,但很多分类模型默认在 softmax 输出大于 0.5 时判为正类。这个阈值默认值是 0.5,它假设两类代价对称,这在消防场景里完全错误。一个更实用的做法:把验证集所有样本的模型输出概率保留下来,画一条 precision-recall 曲线,然后直接指定目标召回率(比如 95%),反推出需要的置信度阈值。这条曲线能告诉你,你想记住 95% 的火,能付出多少误报代价。
import numpy as np from sklearn.metrics import precision_recall_curve # 在验证集上得到 all_probs, all_labels # 假设二分类:fire 是索引 1 fire_probs = np.array(all_probs)[:, 1] y_true = np.array(all_labels) precision, recall, thresholds = precision_recall_curve(y_true, fire_probs) # 找召回率 >= 0.95 的最严阈值(使误报尽可能少) valid_idx = np.where(recall >= 0.95)[0] idx = valid_idx[-1] # 在满足召回率的前提下取最后一个,对应最大阈值 thr = thresholds[idx] print(f"当阈值设为 {thr:.4f},召回率 {recall[idx]:.4f},精确率 {precision[idx]:.4f}")说明:precision_recall_curve返回的 thresholds 长度比 precision 少一个,遍历时别越界。valid_idx[-1]是召回率刚跌破目标前的最后一个阈值,如果取valid_idx[0]会选一个更低也更容易误报的阈值。逻辑要么是取“满足目标召回率下最高精确率”的阈值,要么取“不超过某个召回率要求下的最大阈值”,前者更普遍。
如果你是多分类,可以对每个类别单独处理:把该类当作正类,其余当作负类,分别画 PR 曲线。对火灾这个业务,通常只关心 fire 这一类的召回率,所以整个流程只需要一个类别。
在实际部署时,我不会把阈值写死在模型代码里,而是把模型输出的概率传给一个决策函数:
def fire_alert(prob, fire_threshold=0.58): if prob >= fire_threshold: return "ALERT" return "NORMAL"这样阈值可以随着季节、时段、风险等级调整,比如白天设置 0.7 减少误报,晚上设置 0.4 提高灵敏度。不要把“调阈值”看作模型出来之后的补丁,它其实是业务需求到机器学习模型之间最直接的翻译层。如果你把这个阈值也根据验证集调好,最后一定要在测试集上重新评估一次,否则仍然属于“用验证集当测试集”的自欺欺人。
我吃过这方面的亏:当时做一个森林火灾图像分类模型,验证集准确率 98%,很快乐。部署第一天系统对着落日误报 11 次,因为落日的橙色和火焰色在 224×224 下几乎一样。后来我不再信任默认阈值,把所有误报样本拉出来统计概率分布,发现它们都集中在 0.45~0.75 之间,而真正的火情样本几乎都超过 0.8。把阈值抬到 0.82 后,漏报没有增加,误报反而少了很多。这个经验后来变成了一个习惯:任何分类模型上线前,先看验证集的置信度分布,再决定阈值,而不是直接拿argmax当输出。希望帮到你。
本文还有配套的精品资源,点击获取