简介:面向具备Python基础与数据分析、机器学习背景的研发和技术人员,这份实践方案围绕重庆火灾点分析与预测展开,内容覆盖多源数据导入与准备、逐年逐月火点频次统计与可视化、气象因素关联分析,以及结合注意力机制和CNN的LSTM模型构建、训练与评估,并给出火险等级评估和改进建议,可用于自然灾害预警系统开发中的数据驱动火灾预测场景。资源为1个docx文档,压缩包约18KB,便于按步骤查阅与修改。文档中提供可运行的Python代码示例与逐步解释,涵盖数据预处理、特征工程、时间序列划分、模型参数调整等关键环节,提示根据实际数据灵活改动。目前已有56人学习,适合需要系统性参考火灾预测建模流程的读者。
1. 重庆火灾点预测的数据链路:多源时序与深度模型如何衔接
重庆的火点集中在夏季伏旱和秋季秸秆焚烧两个窗口,温度骤升、湿度骤降这类短期气象突变往往先于火点出现 3 到 7 天,单独看某一张表很难发现规律,把火灾记录、人口密度、土地利用、植被覆盖、气象观测五类数据放到同一条时间轴上之后,才有完整的建模基础。这套 Python 实现的重庆火灾点分析与预测方案,前端用热力图和相关性检验摸清火点频次的年季分布,后端用结合注意力机制的 CNN+LSTM 模型滚动预测未来火点频次,再按预测值划分火险等级。整个链路从多源 CSV 到预警等级输出是闭环的,适合有数据分析基础、正在做自然灾害预警系统原型的研发人员。下面按数据处理、可视化验证、模型构建、评估校准四个环节逐层拆开,所有代码都有可替换的参数位。
2. 多源数据合并与时间对齐:人口、土地利用、植被、气象特征入库
2.1 数据文件与字段语义
多源数据合并是整条链路里最不性感但最影响结果的一环。原始方案里涉及的五个 CSV 角色各不相同,实际项目里文件名可能不一样,但字段角色基本可以对应到下面这张表:
| 文件 | 典型字段 | 在 pipeline 中的角色 |
|---|---|---|
| fire_data.csv | date, fire_point, location | 事件主体,生成目标值与频次统计 |
| population_data.csv | date, population | 人口密度,火源活动的静态特征 |
| land_use_data.csv | date, land_use_type | 建设用地、林地、农田分布 |
| vegetation_data.csv | date, vegetation_type | 植被覆盖类型,可燃物属性 |
| weather_data.csv | date, temperature, humidity, wind_speed | 气象驱动,时序特征主力 |
要提醒一句:如果 fire_data 的每一行代表一条火点记录,那么 groupby 之后 count 得到的是周期内火点的条数;如果 fire_point 本身存的是像元数或频次数值,就应该用 sum 而不是 count。原方案里 fire_point 承担目标列的角色,按“一行一条记录”理解最容易和后面的统计代码对上。
2.2 left join 合并策略与时间粒度对齐
多表合并的关键是关联键的粒度。原始方案用 date 做关联键、left join 保留全部火灾记录,这个选择是对的,因为火灾记录是事件主体,人口、土地利用、植被是环境快照,用内连接会把没有火灾记录的日期全部删掉,模型就失去了“什么条件下没有火点”的负样本,训练出来的模型会系统性高估火点概率。
import pandas as pd fire_data = pd.read_csv('fire_data.csv') population_data = pd.read_csv('population_data.csv') land_use_data = pd.read_csv('land_use_data.csv') vegetation_data = pd.read_csv('vegetation_data.csv') weather_data = pd.read_csv('weather_data.csv') merged_data = pd.merge(fire_data, population_data, on='date', how='left') merged_data = pd.merge(merged_data, land_use_data, on='date', how='left') merged_data = pd.merge(merged_data, vegetation_data, on='date', how='left') merged_data = pd.merge(merged_data, weather_data, on='date', how='left')这几行 merge 本身不难,难点在 key 的粒度一致性。date 如果带小时,而人口和土地利用是日级数据,左连接会匹配不到,产生大量 NaN;date 如果混用了时区,俄就要先统一时区再做关联。操作上建议先统一格式,再截断到日级:
merged_data['date'] = pd.to_datetime(merged_data['date']) merged_data['date'] = merged_data['date'].dt.date2.3 缺失值处理与时间特征提取
原方案对缺失值统一用 fillna(0),代码里的fillna(0, inaxis=0)还有个笔误,inaxis 应该是 axis。这里要特别说明:人口、气温这类数值列填 0,等于告诉模型“这一天人口为零、气温为零”,这在业务语义上是错的,会让后面相关性分析失真。比较稳的做法是数值列做线性插值,类别列做前向填充:
numeric_cols = ['population', 'temperature', 'humidity', 'wind_speed'] merged_data[numeric_cols] = merged_data[numeric_cols].interpolate( method='linear', limit_direction='both' ) categorical_cols = ['land_use_type', 'vegetation_type'] merged_data[categorical_cols] = merged_data[categorical_cols].fillna(method='ffill') merged_data[categorical_cols] = merged_data[categorical_cols].fillna('unknown')注意新版 pandas 已经把fillna(method='ffill')标记为弃用,推荐直接写成df.ffill(),上面的写法兼容旧版本,跑起来会有 FutureWarning,但不影响结果。类别列前向填充后如果仍有空值,补一个 unknown 兜底,避免模型输入出现 NaN。最后从 date 里拆出 year 和 month:
merged_data['year'] = merged_data['date'].dt.year merged_data['month'] = merged_data['date'].dt.month这两个派生列有两个用处:一是为后面的分组热力图提供聚合维度,二是保留火点频次里的年周期和季节周期信号,让模型知道当前样本落在哪个时间位置。
3. 火点频次的时空可视化:热力图、趋势与气象相关性交叉验证
3.1 逐年逐月火点频次的统计口径
数据合并完先别急着建模。区域火灾的时空聚集性很强,先用可视化确认火点集中分布在哪几个月、哪几年异常,比直接调参重要得多。原始代码用 groupby + count 生成逐年逐月频次,再转透视表画热力图:
fire_frequency = merged_data.groupby(['year', 'month'])['fire_point'].count().reset_index() pivot_table = fire_frequency.pivot_table( index='year', columns='month', values='fire_point', aggfunc='sum', fill_value=0 ) import matplotlib.pyplot as plt import seaborn as sns plt.figure(figsize=(12, 8)) sns.heatmap(pivot_table, cmap='YlOrRd', annot=True, fmt='d') plt.title('Fire Points Frequency from 2008 to 2020') plt.xlabel('Month') plt.ylabel('Year') plt.show()这里要留意 pandas 的 API 变化。旧版本的pivot("year", "month", "fire_point")可以用位置参数,新版本要求显式传 index、columns、values,而且当 index 出现重复值时 pivot 会直接抛错,pivot_table 则靠 aggfunc 完成聚合。fill_value=0 是第二个细节:不存在的年-月组合会被透视表填成 NaN,热力图会显示成白格,实际业务含义是“这年这个月没有火点记录”,填 0 才符合语义。
count 统计的是 fire_point 列的非空值数量,如果某行 fire_point 本身是 NaN,这一行不会计入频次,所以 groupby 前要确保目标列没有缺口,否则热力图会出现偏低的月份。
3.2 年度趋势与季节聚集特征
热力图能看出季节簇,但看不出整体是在上升还是下降。要看趋势需要换成分年折线图:
yearly_fire_frequency = merged_data.groupby('year')['fire_point'].count() plt.figure(figsize=(10, 6)) yearly_fire_frequency.plot(kind='line', marker='o') plt.title('Yearly Fire Points Trend from 2008 to 2020') plt.xlabel('Year') plt.ylabel('Fire Points Frequency') plt.show()折线图出现明显断崖或尖峰时,要怀疑数据采集口径变化,比如更换了卫星传感器或调整了火点判识阈值,这类突变不是气候变化或人为因素导致的,深度学习模型很难学出来,也不应该去学。实际做法是把异常年份单独标记出来,在训练集里过滤掉再做模型,避免模型把数据质量问题当成规律拟合。重庆本地还有个特征值得注意,7 到 10 月是全年火点最密集的窗口,如果热力图显示火点散布到了冬春两季,大概率是混入了非火灾热源,需要回看原始样本排查。
3.3 气象驱动的散点与相关性验证
原始方案里的温度-火点散点图适合看趋势,但要下结论还缺一步:相关性系数。火灾频次是典型的零膨胀分布,无火日远多于有火日,把所有散点画在一起时,坐标原点附近会堆成一大片,人眼很难判断真实关系。比较合理的做法是聚合到月份粒度,同时对比温度和湿度两个方向,因为火点频次通常和温度正相关、和湿度负相关,两个特征一起看能交叉验证:
monthly_weather = merged_data.groupby('month')[['temperature', 'humidity']].mean() monthly_fire = merged_data.groupby('month')['fire_point'].count() monthly_combined = pd.concat([monthly_weather, monthly_fire], axis=1) print(monthly_combined.corr())月份粒度只有 12 个点,corr 的输出只能做方向参考,不能做显著性判断。如果这里看到 temperature 与 fire_point 的正相关系数较高、humidity 为负,说明气象特征值得进模型;如果某个特征的相关系数接近 0,它在神经网络里大概率贡献有限,可以考虑剔除。这一步的价值是把特征筛选前置,而不是等到模型训练完再做特征重要性分析,省掉不少试错成本。
4. 构建 Conv1D+Attention+LSTM 模型:从归一化、滑窗到维度对齐
4.1 归一化:特征与目标需要独立 MinMaxScaler
模型部分先讲归一化,因为这是整段代码里最容易复制错的地方。原始方案用同一个 MinMaxScaler 对特征和目标分别 fit_transform,实际运行时隐患很大:特征列和目标列的量纲、分布完全不同,公用一个 scaler 会把 fire_point 压到和 population 相同的数值区间,反归一化时很难对齐。更稳的做法是给特征和目标各建一个 scaler:
from sklearn.preprocessing import MinMaxScaler feature_cols = ['population', 'temperature', 'humidity', 'wind_speed'] target_col = 'fire_point' feature_scaler = MinMaxScaler() target_scaler = MinMaxScaler() scaled_features = feature_scaler.fit_transform(merged_data[feature_cols]) scaled_target = target_scaler.fit_transform(merged_data[[target_col]])fit_transform 只应该在训练段执行一次,测试段一律用训练好的 scaler 做 transform。原因是 scaler 内部的 min/max 是训练数据的统计量,测试集一旦参与 fit,等于把未来的极值信息泄漏给训练过程,验证指标会被高估,部署到新数据时还会因为极值超界发生特征压缩失真。
4.2 滑动窗口生成与时间序列切分
时间序列模型不能随机打乱样本,一旦打乱,未来信息就会混进训练集,预测结果立刻失真。标准的做法是按时间顺序切 8:2,再用 time_steps 窗口把连续序列切成监督学习样本:
import numpy as np train_size = int(len(scaled_features) * 0.8) train_features = scaled_features[:train_size] train_target = scaled_target[:train_size] test_features = scaled_features[train_size:] test_target = scaled_target[train_size:] def create_sequences(data, target, time_steps=10): X, y = [], [] for i in range(len(data) - time_steps): X.append(data[i:i + time_steps]) y.append(target[i + time_steps]) return np.array(X), np.array(y) train_X, train_y = create_sequences(train_features, train_target, time_steps=10) test_X, test_y = create_sequences(test_features, test_target, time_steps=10)time_steps=10 表示用最近 10 个时间片预测下一个时间片的值。这个参数的合理取值要看数据的自相关长度:如果火点频次和前 7 天的气象条件相关性最强,窗口取 7 到 14 比较合适;窗口太小,模型拿不到完整的气象演变过程;窗口太大,会把无关的历史噪声卷进来,训练时间也跟着翻倍。create_sequences 循环里len(data) - time_steps作为上界,生成的样本数会比原始行数少 time_steps 个,这是正常现象,不需要补样本。
4.3 网络结构:卷积提取局部模式、注意力定位关键时间步
原始结构是 CNN → LSTM → Attention → LSTM → Dense。设计意图很明确:Conv1D 提取相邻时间步的局部变化模式,比如连续三天高温低湿这类组合信号;第一层 LSTM 学习时间依赖;注意力层对时间步加权,突出对预测贡献最大的窗口;第二层 LSTM 聚合后输出频次。但原代码有两个地方在真实环境里跑不通。
第一个是 Flatten 的位置问题。Conv1D 输出是三维张量(batch, steps, filters),MaxPooling1D 只压缩时间步维度,仍然是三维。原代码在池化后接 Flatten,会把张量压成二维,而 LSTM 要求三维输入(batch, time_steps, features),这里直接报维度错误。常见做法是去掉 Flatten,保留三维形状让卷积输出直接进 LSTM。
第二个是AttentionWithContext不是 Keras 内置层,需要自己继承 Layer 实现可训练权重。这里给一个能直接跑通、保留原始设计意图的替代结构:
from keras.models import Model from keras.layers import Input, Conv1D, MaxPooling1D, LSTM, Dense, Multiply inputs = Input(shape=(time_steps, train_X.shape[2])) x = Conv1D(filters=32, kernel_size=3, activation='relu', padding='same')(inputs) x = MaxPooling1D(pool_size=2)(x) lstm_out = LSTM(units=64, return_sequences=True)(x) attn_weights = Dense(1, activation='sigmoid')(lstm_out) attn_context = Multiply()([lstm_out, attn_weights]) x = LSTM(units=32)(attn_context) outputs = Dense(1)(x) model = Model(inputs, outputs) model.compile(optimizer='adam', loss='mse') model.summary()这个改法里,Dense(1, activation='sigmoid') 对每个时间步输出一个 0 到 1 的权重,Multiply 把权重乘回 LSTM 的输出,实现“重要时间步放大、无关时间步压低”的效果。整体结构和原方案一致,但不需要自定义层。padding='same' 也必须保留,否则 Conv1D 会把时间步从 10 缩到 8,再经过池化就只剩 4 步,信息损失过大。各层输出形状如下:
| 层 | 输出形状 | 作用 |
|---|---|---|
| Input | (None, 10, 4) | 10 个时间步 x 4 个特征 |
| Conv1D(32, kernel_size=3) | (None, 10, 32) | 提取局部跨日变化模式 |
| MaxPooling1D(2) | (None, 5, 32) | 压缩时间分辨率 |
| LSTM(64, return_sequences=True) | (None, 5, 64) | 对每个时间步编码时序信息 |
| Dense(1, sigmoid) + Multiply | (None, 5, 64) | 注意力权重加权 |
| LSTM(32) | (None, 32) | 聚合为向量 |
| Dense(1) | (None, 1) | 回归输出 |
注意:这里的注意力实现是简化变体,用于把链路跑通;生产环境建议用 keras 内置的 Attention 层或自行实现带打分函数的注意力机制。
4.4 训练参数与验证曲线判读
训练参数不多,但每个都有讲究。epochs 开到 100 是因为 LSTM 收敛慢,但同时一定要盯验证集 loss,配合早停防止过拟合:
from keras.callbacks import EarlyStopping early_stop = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) history = model.fit( train_X, train_y, epochs=100, batch_size=32, validation_data=(test_X, test_y), callbacks=[early_stop], verbose=1 )batch_size=32 适合中等规模数据,样本量小可以降到 16。patience=10 表示验证 loss 连续 10 个 epoch 没有改善就停止训练,同时恢复最佳权重。这里验证集其实对应未来时间段的数据,正符合时间序列预测场景:我们就是要看模型在“没见过的未来”上表现如何。
验证 loss 曲线如果出现先降后升的 U 型,说明模型开始背训练集了,早停会自动把权重回滚到拐点位置;如果验证 loss 从一开始就不降,问题基本出在前面几步,要么是特征没归一化,要么是滑动窗口造出来的样本太少,LSTM 根本没学到东西。
5. 预测评估与火险等级阈值校准:用验证集反推分级界限
5.1 MAE/MAPE 与 RMSE 的互补
原始方案用 RMSE 作为唯一评估指标,RMSE 对大幅误差特别敏感,而火灾频次分布本身倾斜,偶尔几次大幅漏报会主导误差值,掩盖常见场景的真实表现。建议把 MAE 和 MAPE 一起打印:
from sklearn.metrics import mean_squared_error, mean_absolute_error predictions = model.predict(test_X) pred_inv = target_scaler.inverse_transform(predictions) test_inv = target_scaler.inverse_transform(test_y) rmse = np.sqrt(mean_squared_error(test_inv, pred_inv)) mae = mean_absolute_error(test_inv, pred_inv) mape = np.mean(np.abs((test_inv - pred_inv) / (test_inv + 1e-6))) * 100 print(f'RMSE: {rmse:.2f}, MAE: {mae:.2f}, MAPE: {mape:.2f}%')反归一化一定要用 target_scaler,不能用训练特征用的 scaler,否则数值范围完全对不上。MAPE 计算时给分母加了 1e-6 做平滑,避免测试集里出现 0 火点导致除零;如果测试集里 0 值特别多,MAPE 会被异常放大,这时以 RMSE 和 MAE 为主更稳妥。
5.2 用混淆矩阵思维校准分级阈值
原方案里pred < 10判 Low、pred < 50判 Medium、否则 High,这个阈值是经验值,缺少验证依据。更合理的做法是在验证集上枚举候选阈值,把阈值二分类化,看每个阈值下的漏报率,再反推业务上可接受的分级界限:
actual_binary = (test_inv.flatten() > 20).astype(int) for threshold in [5, 10, 20, 30, 50]: pred_binary = (pred_inv.flatten() > threshold).astype(int) tp = np.sum((pred_binary == 1) & (actual_binary == 1)) fn = np.sum((pred_binary == 0) & (actual_binary == 1)) if (tp + fn) > 0: recall = tp / (tp + fn) print(f'阈值 {threshold:>2d}:火警召回率 {recall:.2f}')这里的 actual_binary 用 20 作为“真实大火点日”的判定线,threshold 是“预测值超过多少就预警”的启动线。召回率代表真实高火点日里有多少天被模型提前抓到,预警系统里漏报的代价远大于误报,所以阈值应该向召回率高的方向倾斜。实际观察预测值会发现 LSTM 在回归任务里普遍偏保守,预测分布向均值收缩,高火点日容易被低估,这时阈值定太高会漏报、定太低会天天报警。校准完阈值,再回头对照 MAE 值的分布看高火险月份是否被压到可接受范围,整套评估闭环才算真正落地。
本文还有配套的精品资源,点击获取