☰
用LSTM做PM2.5预测:从数据清洗到调参避坑全指南
2026/10/9 1:09:55 网站建设 项目流程

简介:这是一份面向深度学习与空气质量建模场景的PDF论文资料,围绕LSTM循环神经网络在PM2.5预测中的应用展开,适合机器学习、数据建模、环境科学等方向的学习者阅读。针对PM2.5浓度变化非线性强、影响因素多、传统方法难以准确预测的问题,资料提出以灰色关联度分析方法筛选气象和空气污染物指标,对数据进行平滑处理后把时间序列问题转换为监督学习问题,进而搭建多变量LSTM模型,实现对PM2.5日值浓度的准确预测。内容中系统介绍了LSTM原理、灰色关联度分析、多变量分析等关键知识点,并利用北京市2010—2017年气象与污染物数据完成仿真验证,有助于读者掌握从数据预处理、特征关联分析到循环神经网络建模的完整研究思路。资源包内含1个PDF文件,大小约1.1MB,已有176人学习浏览,是开展相关论文研读、课程设计或入门实践的良好参考资料。

1. 说到PM2.5预测,为什么我劝你先试试LSTM再折腾ARIMA

手上有小时级的PM2.5监测数据,想预测未来几小时的浓度,很多人的第一反应是ARIMA或线性回归。但真处理过重污染过程的人都知道,PM2.5序列在冬季静稳天气下会出现连续十几小时的高值平台,进入下一轮冷空气时又骤降,这种强非线性、长依赖的形态,ARIMA的差分和自回归项很难抓住。LSTM循环神经网络靠三个门控结构把“昨天的浓度走势”和“当前的气象变化”一起记在细胞状态里,天然适合做这种中等时间尺度的序列预测。这篇笔记不聊论文推导,只讲怎么用LSTM把PM2.5预测跑通:从数据构造、网络搭建到训练调参,最后把我踩过的坑一条条列出来。适合手里有至少一个月的小时级浓度数据、会用Python和Keras的环保或数据从业者,照着做能少走两个星期的弯路。

2. 造数据比调模型更决定上限:PM2.5时序数据的清洗与滑窗构造

2.1 从ARIMA到LSTM:PM2.5序列的哪些特征决定了选型

PM2.5浓度序列和股票、温度这类时间序列最大的不同在于它同时受排放源、气象扩散条件、二次生成过程的共同影响。拿冬季典型日来看,早晨和傍晚有排放高峰,但浓度不一定会准时涨,因为边界层高度和风速的变化会覆盖掉源强的规律。这样的序列是非平稳的,均值、方差都在滑移;它也是非线性的,前一天的高浓度能否在第二天持续,取决于当时的相对湿度有没有触发二次气溶胶增长。ARIMA要求序列先做差分变成平稳,差分会丢掉“高浓度平台是否正在发育”这种长程信息。RNN循环神经网络从概念上更适合,因为它把过去的隐藏状态传进当前时间步的计算。但普通RNN在时间步超过10到20的时候,反向传播的梯度会连乘衰减,导致“记不住前几天”的效应。LSTM神经网络在隐藏状态之外增加了一条细胞状态传送带,用输入门、遗忘门、输出门控制信息的写入和丢弃。对PM2.5预测来说,遗忘门可以学会“冷空气过境后的旧浓度该放下”,输入门可以决定“当前静稳天气下的累积趋势该记住多少”。这就是选型背后的根本原因——不是因为它比ARIMA炫,而是因为它能显式建模长期依赖。

2.2 小时级数据清洗:缺失值、异常值和时间戳对齐

拿到原始监测站点的CSV,最常见的格式是两列:时间戳和浓度值。第一步是先把时间戳转成datetime并设为索引,然后重采样到小时,避免有的站点上传频率是分钟级,导致时间轴不对齐。我一般用pandas的resample('H')做聚合,取每小时平均。缺失值先看连续缺失的跨度,短于3个小时的用线性插值,长于24个小时的干脆截断掉,因为长时间插值会把浓度曲线磨平,后面模型学到的全是假信号。

import pandas as pd import numpy as np # 读取原始数据,假设只有两列:time, pm25 df = pd.read_csv('obs_pm25.csv', parse_dates=['time']) df.set_index('time', inplace=True) # 重采样为小时均值,同时确保连续小时索引 df = df.resample('H').mean() # 连续缺失不超过3小时,用线性插值 df['pm25'] = df['pm25'].interpolate(limit=3, limit_area='inside') # 超过24小时的缺失段:直接置为NaN并整段删除前后,避免插值污染 group_nan = df['pm25'].isna().astype(int).groupby( df['pm25'].notna().cumsum() ).sum() long_nan_idx = group_nan[group_nan > 24].index df.loc[df['pm25'].notna().cumsum().isin(long_nan_idx), 'pm25'] = np.nan df = df.dropna(subset=['pm25']) # 异常值:用滚动中位数加3倍MAD剔除 rolling_med = df['pm25'].rolling(window=24, center=True).median() mad = (df['pm25'] - rolling_med).abs().rolling(window=24, center=True).median() df['pm25'] = df['pm25'].mask( (df['pm25'] - rolling_med).abs() > 3 * 1.4826 * mad + 50, np.nan ) df['pm25'] = df['pm25'].interpolate(limit=3)

这段代码的逻辑是:先做时间对齐和小时聚合,再用interpolate(limit=3)只填小缺口。检测长缺失段时,我用了一个小技巧——用notna().cumsum()给连续非空段编号,再统计每个编号里的NaN数量。异常值剔除用的是滚动中位数加MAD,比固定3σ稳健,因为PM2.5本身就可能出现300以上的真实高值,不能用全局均值和方差去套。参数1.4826是把MAD换算成标准差的系数,后面加50是为了防止重污染日里滚动中位数滞后,把真实峰值误杀。

2.3 滑窗构造样本:滞后窗口与预测步长怎么匹配

模型吃的不是单条记录,而是“过去24小时 → 未来1小时”这样的样本对。构造滑窗时,最重要的决定是滞后窗口长度和预测步长。我的默认起点是lookback=24,预测horizon=1,也就是用过去一天预测下一个小时。如果要做未来6小时预测,可以先以horizon=1训练出一个基准模型,再评估滚动预测效果,而不是一开始就构造多步标签。

def make_sequences(data, lookback=24, horizon=1): X, y = [], [] for i in range(lookback, len(data) - horizon + 1): X.append(data[i - lookback:i]) y.append(data[i + horizon - 1]) return np.array(X), np.array(y) # 归一化必须在切分之后,只对训练集fit,验证集/测试集用同一套参数 from sklearn.preprocessing import StandardScaler scaler = StandardScaler() train_data = scaler.fit_transform(train_raw.reshape(-1, 1)) test_data = scaler.transform(test_raw.reshape(-1, 1)) X_train, y_train = make_sequences(train_data.flatten(), lookback=24) X_test, y_test = make_sequences(test_data.flatten(), lookback=24)

这里要特别强调两个容易翻车的点。第一,make_sequences返回的X是二维[样本数, 时间步],后面送进LSTM前还要reshape成[样本数, 时间步, 特征数],特征数在单变量场景是1。第二,归一化必须在时间序列切分之后,只对训练集fit_transform,验证集和测试集用同一个transform。如果你先对全序列做了标准化再切分,验证集的最小值、最大值已经泄漏进训练集,模型在验证集上的RMSE会虚低,线下指标一片大好,一上线就现原形。这个坑在时序预测里太常见了,我写它的时候都是血泪。

3. 搭一个能跑的LSTM:网络结构、输入形状与四个必调参数

3.1 一个能跑的最小LSTM:Sequential搭法

用Keras搭一个能跑的LSTM模型,不需要一上来就上双向或深层。单变量PM2.5预测,我的起始配置是两层LSTM加一个Dense输出,其中第一层返回完整的序列给第二层,第二层只返回最后一步。这个结构比起一层LSTM,能多学一层非线性变换,但训练量不会爆炸。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam model = Sequential([ LSTM(units=64, return_sequences=True, input_shape=(lookback, 1), name='lstm_1'), Dropout(0.2), LSTM(units=32, return_sequences=False, name='lstm_2'), Dropout(0.2), Dense(units=1, name='output') ]) model.compile( optimizer=Adam(learning_rate=0.001), loss='mae', metrics=['mse'] )

这里input_shape是两个整数,(lookback, 1),别写成包含batch的三维。第一层return_sequences=True很关键,因为第二层LSTM需要完整的序列输入,如果这里写成False,第二层收到的就只有一个最后一次的隐藏状态,序列信息被压扁了,我见过好几个人在这翻车。Dropout(0.2)加在两层之间的遗留dropout,只对输入层起作用;如果要用循环dropout,需要在LSTM的recurrent_dropout参数里单独指定。我一般先把recurrent_dropout留在0,因为它会强制模型用CPU实现某些算子,训练慢很多,等模型收敛了再开不迟。

3.2 关键参数:units、dropout、batch_size、时间步

这里列一个参数对照表,标注我的习惯值和建议范围,方便你直接抄作业。

参数习惯值搜索范围影响
units(第一层)6432~128隐藏状态维度。太小学不进去,太大在小时级数据上容易过拟合
units(第二层)3216~64压缩信息,负责把两层学到的特征投射到输出
Dropout0.20.1~0.4输入层随机丢弃,缓解过拟合
recurrent_dropout00~0.3对循环连接做dropout,能防过拟合但训练慢
batch_size6432~256影响梯度稳定性。小时级数据通常不用太大,显存不是瓶颈时64够用
learning_rate0.0010.0005~0.003Adam的默认值对标准化后的序列基本靠谱,不收敛再降
lookback2412~72输入窗口。太长不会简单变好,可能引入无关噪声

关于lookback有个常见误解:不是把过去144小时全塞进去,模型就一定能记得更远。LSTM的长期记忆是靠门学出来的,如果输入窗口里大部分时间都是平静期,关键的高浓度起涨点反而被淹没。我习惯先用24小时跑出基线,再试着增加到48、72小时,看验证集MAE是否真的下降。有一次我加到168小时,验证集反而变差了,原因是夜间浓度低值重复出现,模型学会了用平均窗内的低值来压低输出,把真正要预测的高值平滑掉了。

3.3 输入输出形状与层之间的匹配关系

形状不匹配是刚入门的人报错最多的地方。LSTM层期望三维输入:(batch, timesteps, features)。在单变量场景下,make_sequences得到的二维X需要扩维成X_train.reshape(-1, lookback, 1)。输出形状取决于最后一层的配置:return_sequences=False时,LSTM输出的形状是(batch, units),可以直接接一个Dense(1);如果return_sequences=True再接Dense,那Dense会作用在每一个时间步上,输出(batch, timesteps, 1),这通常不是你要的结果,除非在做seq2seq。

# 训练前强制检查形状 print('X_train shape:', X_train.shape) # (样本数, 24) print('y_train shape:', y_train.shape) # (样本数,) # 给LSTM需要的第三维 X_train = X_train.reshape(X_train.shape[0], lookback, 1) X_test = X_test.reshape(X_test.shape[0], lookback, 1) model.build(input_shape=(None, lookback, 1)) model.summary()

model.summary()会列出每一层输出的shape,这是排查形状问题最快的方法。我在模型build之后再打印,能确认(None, 24, 1)正常送进第一层LSTM。这里的None代表batch维度,Keras在训练时填充实际值。如果你的输入特征不止PM2.5,比如把温度、湿度拼进来,那第三维就是特征数,input_shape=(lookback, n_features),后面我会专门讲多变量输入。这一阶段出现ValueError: Input 0 of layer lstm is incompatible with the layer,十有八九是特征维没对齐,先把sumary()和实际X.shape对一遍,比翻报错日志快得多。

4. 训练与评估:损失函数、早停和验证集上真实的RMSE

4.1 损失函数与评估指标:MAE还是MSE?

PM2.5预测评估里有个现实矛盾:环境监测用MBE、R²,业务上关心的是“中度污染过程是否提前6小时报出”。如果损失函数定为MSE,模型会把全部注意力放在减小少数大误差样本(比如重污染峰值)上,结果平时预测很准,一遇到污染过程就被拉偏。如果定为MAE,模型更关注中位数附近的误差,对峰值的惩罚不足,但整体偏差更小,人工读曲线也更舒服。我的做法是:训练损失用MAE,同时用RMSE做人工复核指标,因为RMSE单位更直观,而且能放大那些“预测曲线整体偏移”的系统性错误。

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping( monitor='val_loss', patience=15, restore_best_weights=True, verbose=1 ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=0.00001, verbose=1 ) model.compile( optimizer=Adam(learning_rate=0.001), loss='mae', metrics=['mse', 'mae'] )

在第3章定义的模型编译里,我把指标加上了mse。这里说明一下训练日志的读法:loss是MAE,mse是中间指标,val_loss是验证集MAE。我一般不看训练集loss,因为模型在训练集上很容易到0.1以下,但那只是记住样本。真正决定能不能上线的是val_loss有没有持续下降。如果训练集loss一路暴跌,验证集loss在第5个epoch就开始反弹,那就是过拟合,早停会在patience=15后停止并恢复最好的权重。

4.2 早停与学习率衰减:防止在验证集上过拟合

早停的patience不是越大越好。PM2.5数据量通常只有几千到几万条,模型容量一大,验证集loss会出现“下降-回升-再下降”的假象。我试过patience=30,结果模型在某个局部最优附近震荡了30个epoch,最后恢复的权重还是第12轮的,白白浪费两小时。现在的习惯是patience=15配restore_best_weights=True,让Keras在训练结束时把权重回滚到验证集loss最小的位置。注意restore_best_weights默认是False,不设的话,结束后用的是最后一轮的权重,很多人的“早停失效”问题其实出在这里。

学习率衰减也要盯着val_loss用。ReduceLROnPlateau在验证集连续5个epoch不降时把学习率乘0.5,最低降到1e-5。这个组合比固定学习率跑到底稳定得多。还有一点,Keras的verbose参数在实际训练时最好设成1,让我能看到每个epoch的loss和val_loss变化,及时判断是否发散到NaN。

4.3 训练过程可视化与预测结果反标准化

训练结束后,预测值还在标准化空间里,要还原成真正的浓度再做评估和画图。这一步如果做错,后面所有的分析都没意义。

import matplotlib.pyplot as plt from sklearn.metrics import mean_absolute_error, mean_squared_error # 在测试集上预测并回退到原始量纲 y_pred_scaled = model.predict(X_test) y_pred = scaler.inverse_transform(y_pred_scaled) y_true = scaler.inverse_transform(y_test.reshape(-1, 1)) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mae = mean_absolute_error(y_true, y_pred) print(f'Test RMSE: {rmse:.2f} μg/m³, MAE: {mae:.2f} μg/m³') # 画前72小时的对比,能直观看到滞后或系统性偏差 plt.figure(figsize=(12, 5)) plt.plot(y_true[:72], label='observed', linewidth=2) plt.plot(y_pred[:72], label='predicted', linewidth=2) plt.legend() plt.title('PM2.5 LSTM Forecast vs Observed (first 72h)') plt.tight_layout() plt.savefig('lstm_forecast.png', dpi=120)

这里有个反直觉的细节:scaler.inverse_transform需要输入的形状是(样本数, 1),而model.predict返回的y_pred_scaled形状是(样本数, 1),可以直接用;但y_test是从make_sequences里取出来的,是一维形状(样本数,),所以要先reshape(-1, 1),否则报错。画图时选前72小时,差不多是3天,能看到模型在早晚峰值上的跟随程度。如果预测曲线比实测晚1到2小时,说明时间窗口里的滞后信息不够,或者模型学会了一个简单的“拿上一小时的值当预测”的懒办法——这种情况下RMSE可能看着不错,但污染过程的方向性变化完全没抓到。

5. PM2.5预测避坑:五个常见坑的现象、原因与排查

5.1 归一化泄漏:验证集虚低,上线就翻车

现象:训练时验证集RMSE是每立方米15微克,自己心里很美,结果拿去预测新数据,前三天的RMSE就要35以上。原因:在切分训练集和测试集之前就做了全局归一化,测试集的均值、方差已经混进训练集的标准化参数里,相当于模型提前“见过”了测试集的统计特征。这是时序预测的第一大坑。解决:只对训练集部分fit_transform,测试集用同一个scaler.transform。如果数据本身有季节漂移,比如要训练全年的模型,那么最好每个月滚动更新scaler,而不是用一整年的均值。更稳妥的做法还有用sklearn.preprocessing.StandardScaler单独拟合训练段,验证段每次从后面取30天,我上个月就是这样修正了一版模型,才敢给别人看数字。

5.2 预测值退化成序列均值,污染过程完全消失

现象:预测曲线几乎是一条平线,偶尔有小幅波动,RMSE在平稳段还行,但一到重污染时段,预测值卡在150左右,实测都冲250了。原因:第一,训练数据里正常天占绝大多数,模型发现“输出平均值”能最小化MAE的期望损失,就选择了这个安全策略;第二,损失函数设置不当,MSE对峰值和大值惩罚过重,反而把模型吓回去了。解决:先检查标签分布,如果重度污染占比低于5%,就要考虑加权损失或对重污染样本过采样。我常用的办法是把序列做一阶差分后作为预测目标,让模型学“变化量”而不是“绝对值”,预测时再把前一个真实值加回去。这样即使模型对绝对值有偏差,对趋势方向的捕捉也会好很多。

5.3 训练集随机打乱,时间依赖被破坏

现象:验证集loss很低,但查看预测曲线时,发现第一天的预测用到了第100天的信息,时间线完全错乱。原因:写数据加载时手滑用了train_test_split默认的shuffle=True,默认行为是随机洗牌,时间序列的全部依赖关系都被打断了。这种模型在训练时记的是样本序号,而不是时间结构。解决:要么用np.split按时间顺序切分,要么用train_test_split时强制shuffle=False。我自己的习惯是,把时间索引的最后30%作为测试集,前70%训练,验证集从训练集的最后20%里再切一次,保证验证集和测试集都严格在训练集之后。这是时间序列预测和图像分类最不一样的地方。

5.4 时间步长设得过大,模型越训练越慢,loss不降

现象:把lookback从24改成168后,训练时间涨了三四倍,但loss几乎不下降。原因:LSTM在长时序上的梯度传播仍然存在衰减,虽然比RNN强,但时间步过长时信息还是会在长路径里丢失;同时输入维度过大,非线性映射更难学。解决:不要盲目堆窗口。先画自相关系数图,看PM2.5序列的偏自相关在哪里截断——小时序列一般24小时和168小时各有周期性,但真正有预测增量的是过去12到48小时。如果确实想用更长的记忆,可以用Conv1D先把时序降维,再接LSTM,让卷积层做局部特征提取,LSTM负责长程依赖。这样lookback可以到168,训练速度还比纯LSTM快。

5.5 预测结果比实测滞后2~3小时,方向趋势对但相位不对

现象:重污染过程来时,预测曲线总是比实测晚两三个小时才抬头,等实际到峰值了,预测值还在爬坡,整个过程被右移。原因:模型学成了“用上一时刻的值来预测下一时刻”,这是时序预测最常见的退化解。尤其在小时级数据里,1阶自相关系数极高,模型发现把t-1的值原样搬到t就能得到不错误差。解决:从特征层面对抗滞后——不只是给模型过去的PM2.5,还要给它过去的气象因子(风速、风向、湿度),以及滞后1小时、6小时、24小时的浓度差分项。输入特征的多元化会让模型必须依赖多因子的组合来预测,而不是单纯地把序列平移。另外一个有效做法是,训练时把horizon从1改成3,直接预测3小时后的浓度,这样模型无法直接抄上一时刻的值,必须真正学到演变规律。

6. 进阶技巧:多变量输入、多步预测和上线前的盲测

6.1 把气象因子接进LSTM输入

单靠历史浓度做预测,本质是拿过去的自己解释未来的自己。实际情况里,偏东风,湿度上升,风速转弱,这些才是重污染过程的关键触发器。多变量输入的做法很简单:把PM2.5和气象因子分别归一化后用np.column_stack拼装,再构造滑窗。注意每个因子用各自的scaler,不要让风速和湿度共享PM2.5的标准化参数。我曾经直接把所有列丢给一个StandardScaler,结果湿度因为方差小被压缩到0.1附近,LSTM几乎读不到它的变化,白白丢了一个重要特征。

6.2 预测未来6小时的多步输出:直接预测与滚动预测

多步预测有两种常见路线。直接预测是让输出层有6个神经元,一次输出未来6小时的浓度;滚动预测是先用horizon=1的模型预测下一小时,把预测值拼到输入序列末尾,再预测再下一小时,循环6次。直接预测收敛快,但会丢失“预测的t+2建立在t+1之上”的递推关系;滚动预测更接近实际使用,但误差会累积,尤其模型本身有滞后时,滚到第6步已经是“预测的预测的预测”。我的折衷方案是:训练一个horizon=6的直接多输出模型作为基线,同时用horizon=1的模型做滚动对照,最后把两者对验证集的RMSE画在一张图里,谁小用谁。这个对比每次都能让我重新校准对模型的信任。

6.3 模型上线前必须做的盲测

最后一步,也是我以前经常跳过导致被领导追着问的一步:留出最近30天完全不参与训练、不参与验证。做法是把数据按时间拉回一条线,前70%训练,中间20%验证,最后30%盲测。验证集上的MAE再好看都不算数,只有盲测集上第一天的预测结果与真实值对比,才能证明模型对“没见过”的事件有泛化能力。我现在每次训练完都会重新检查时间顺序,确认没有哪行数据因为乱序误入训练集。上一版模型就是因为验证集一个偶然的泄漏,盲测翻了车,现在养成这个习惯后心里踏实不少。希望这些篇幅能帮你少踩几个坑,也祝你跑出的第一个LSTM预测曲线能在污染过程来临时提前抬头,而不是跟在实测身后补作业。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询