CNN-LSTM实现PM2.5浓度预测:完整代码与调参经验
发布时间:2026/10/21 3:23:12 锦皓数字建站

去年秋冬我连续盯了两个多月的北京PM2.5逐小时数据。每天下午四点更新一批气象观测记录然后跑一遍预处理脚本喂给模型做未来24小时浓度预测。中间迭代了很多版本从最初的XGBoost到纯LSTM最后定格在CNN-LSTM组合上。这篇就把完整代码、关键参数、还有踩过的坑都整理出来。不管你是刚接触时序预测还是已经在用LSTM做类似任务想再加一层CNN提升效果这篇应该都能省下不少摸索时间。1. 为什么我最后选了CNN-LSTM这套组合1.1 雾霾浓度预测到底难在哪儿先聊聊这个任务本身的特性。污染物浓度预测和股票预测那种纯随机漫步不同它是有明确物理驱动机制的但又不像物理方程那样能精确求解。我实际处理北京数据时的感受是四个字非线性强。PM2.5浓度受排放源、气象条件、化学转化、区域传输共同影响。同样是北风风速4级和5级对污染物清除效果差异巨大同样是逆温冬季辐射逆温和夏季平流逆温对污染的累积速度完全不同。这种多因子耦合关系用传统线性回归很难刻画而树模型和神经网络反而更能捕捉。除了非线性还有明显的周期性一天有早晚高峰排放叠加边界层变化的双峰形态一周有工作日和周末的排放差异一年有采暖季和非采暖季的显著分界。这些周期性叠加在长程趋势上形成了一种局部突变 长程依赖 强周期的混合信号。我一开始用XGBoost特征手动构造了一堆效果也还行但有个致命问题它把每个时间点当作独立样本没有显式建模时间顺序。滞后特征虽然能提供部分记忆但窗口拉长后特征维度爆炸而且树模型对序列模式的泛化能力很弱。这时候自然想到LSTM它能记住长期依赖但纯LSTM对局部突变的响应不够敏捷——比如沙尘过境那种短时剧烈抬升LSTM经常预测得太顺滑。1.2 CNN和LSTM在这件事里各管什么CNN在时序预测里的角色很多人误解了。总以为CNN是处理图像的其实Conv1D在时间序列上非常好用。它的本质是滑动窗口内的局部特征提取器卷积核扫过连续几个时间步自动学习风速骤增伴随湿度骤降时污染物浓度往往快速下降这类局部耦合模式。LSTM则负责长程记忆。污染物浓度有很强的累积效应今天的气象条件对明天、后天的影响依然存在冷空气过境后的持续清洁时段也能被LSTM记住。CNN提取局部特征LSTM串联上下文这个分工在实际效果上是互补的。我的实测对比是纯LSTM在平稳时段表现不错但突变时段误差明显增大加上CNN之后突变时段的误差降了大概20%—30%。1.3 和XGBoost、纯LSTM、Prophet对比后我放弃了什么我把几种方法在同一份北京数据集上做了对比评价指标是RMSE和R2。给大家一份实际对比结果带有前提条件训练集都是同样的两年逐小时数据预测目标都是下一小时PM2.5浓度模型RMSEμg/m³R²突变时段表现线性回归68.30.71严重滞后几乎无反应XGBoost手工滞后特征41.20.83有反应但过冲纯LSTM单层64单元33.70.87顺滑过头峰值偏低CNN-LSTMConv1DLSTM28.50.91峰值捕捉明显改善注意RMSE受极端浓度日影响很大单看RMSE会低估模型在重污染日的表现所以要配合R²看整体拟合度。Prophet我也试过它对强周期信号拟合得好但一旦碰上连续多日静稳天气堆积污染它的趋势项就跟不上实际累积速度不太适合做这种需要多气象因子输入的任务。2. 数据准备从观测记录到模型输入张量2.1 数据字段与来源说明模型需要两类数据污染物浓度数据和气象数据。我在北京用的是逐小时PM2.5、PM10、SO₂、NO₂、CO、O₃浓度数据叠加温度、湿度、气压、风速、风向、降水量六个气象要素。一共12个字段作为候选特征。要注意不同站点数据源的口径可能不一致我最终以国控站点为主辅助气象站观测缺失字段用同一时次的多站点均值插补。时序数据最忌讳用错站点或错时区。我踩过一次坑某个数据源的时间戳是UTC另一个是东八区本地时间两表一合并每天的峰值和谷值全错位了训练出来的模型在凌晨时段系统性失真。排查了很久才发现是时区问题。所以拿到数据第一步就是统一时区明确标注时间列是本地时间还是UTC然后做对齐。2.2 缺失值处理与异常值识别逐小时数据最常见的缺失原因有两个仪器校准和通信中断。缺失比例在3%以内时用线性插值问题不大超过5%我建议不要硬插直接把缺失段标记出来让模型自己学习缺失段前后的关系。异常值处理要小心。PM2.5逐小时数据会出现一些伪跳变比如施工扬尘导致某小时PM10冲到800但PM2.5只涨了20这种是局地事件不算传感器错误不需要剔除。真正需要剔除的是明显超出物理边界的数据负值、PM2.5超过2000、相对湿度大于100且持续数小时等。我常用的处理流程是这样的时间戳对齐统一分辨率每小时一条检测连续缺失超过6小时的时间段打时间掩码标记单个缺失点用前后两小时均值线性插补物理边界过滤超出合理范围的值置为NaN再插补保存一份原始数据备份所有清洗操作走副本。import pandas as pd import numpy as np def load_and_clean(path): df pd.read_csv(path, parse_dates[time]) df.set_index(time, inplaceTrue) # 统一为小时粒度 df df.resample(1H).mean() # 物理边界 df[pm25] df[pm25].where((df[pm25] 0) (df[pm25] 1000)) df[pm10] df[pm10].where((df[pm10] 0) (df[pm10] 2000)) df[humidity] df[humidity].where((df[humidity] 0) (df[humidity] 100)) # 连续缺失6h的部分先标记 missing_streak df[pm25].isna().astype(int).groupby( (df[pm25].notna()).astype(int).cumsum() ).transform(sum) df[mask] (missing_streak 6).astype(int) # 短缺失线性插补 df df.interpolate(methodlinear, limit_directionboth) return df2.3 特征构造不仅仅是PM2.5本身只把PM2.5的历史值喂给模型是不够的。我当时做了三组额外特征第一组是时间编码。小时、星期几、是否节假日、是否采暖季。注意小时和星期几要用正弦/余弦编码而不是直接用数值否则模型会学到23点之后是0点这种跳跃关系。采暖季标记在北京特别重要11月15日到次年3月15日和非采暖季的排放基线完全不同。第二组是滞后特征。不只是PM2.5的滞后值还有气象因子的滞后值。污染物浓度的变化相对气象变化有滞后比如地面风速增大后PM2.5浓度通常滞后1—3小时才开始下降。我构造了每个气象因子的1小时、3小时、6小时滞后列。第三组是累积特征。用滑动窗口统计过去24小时、72小时的PM2.5均值。这一组特征很关键因为污染累积是慢过程短期突变是快过程模型需要同时感知两种节奏。2.4 滑动窗口切分与时间顺序划分模型输入是过去若干小时的多变量序列这个用滑动窗口切分。我最终用的窗口是48小时即用过去两天的数据预测下一个小时。太短捕捉不到累积效应太长训练样本数和计算量都变大48是一个性价比比较高的折中。切分之后还有一个重要动作按时间顺序划分训练集、验证集、测试集绝对不能随机打乱。时间序列最忌讳的就是随机切分那会造成严重的数据泄露——测试集用的是未来的数据参与训练预测效果会好得离谱但线上部署立刻崩。我按8:1:1的比例切分训练集是最早的数据测试集是最晚的数据。def make_sequences(data, target_colpm25, lookback48): X, y [], [] for i in range(lookback, len(data)): X.append(data.iloc[i-lookback:i].values) y.append(data.iloc[i][target_col]) return np.array(X), np.array(y)标准化我选择在切分之后做而且只用训练集的统计量来fit验证集和测试集用同一套MinMaxScaler去transform这是防止信息泄露的又一个关键点。3. 模型构建与参数详解3.1 网络架构设计CNN-LSTM的结构可以理解成两段式的特征浓缩过程。输入形状是(samples,lookback,features)也就是说每个样本是一个48行、n列的小矩阵。第一段是Conv1D卷积层沿着时间轴横向滑动把短期的局部模式提炼出来第二段是LSTM把经过卷积提炼后的信息按时间顺序读一遍形成长期记忆最后接全连接层输出预测值。我用的是Keras的Sequential搭建核心代码如下import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv1D, MaxPooling1D, LSTM, Dense, Dropout, BatchNormalization) from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau def build_cnn_lstm(lookback, n_features): model Sequential([ # 第一层卷积提取短期局部气象突变特征 Conv1D(filters32, kernel_size3, activationrelu, paddingsame, input_shape(lookback, n_features)), BatchNormalization(), MaxPooling1D(pool_size2), # 第二层卷积进一步压缩时间维度 Conv1D(filters64, kernel_size3, activationrelu, paddingsame), BatchNormalization(), MaxPooling1D(pool_size2), # LSTM在压缩后的序列上建模长程依赖 LSTM(64, return_sequencesFalse, dropout0.2, recurrent_dropout0.2), Dense(32, activationrelu), Dropout(0.3), # 输出单值下一小时PM2.5 Dense(1, activationlinear) ]) model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-3), losshuber, metrics[mae, tf.keras.metrics.RootMeanSquaredError()]) return model为什么要用两次卷积加池化因为48小时的窗口如果用一层卷积感受野只有kernel_size那么宽。kernel_size3意味着只看3个小时的局部组合这对捕捉短时气象突变够用但不足以覆盖过去12小时污染物累积效应这种中等尺度的模式。两层卷积叠加之后第二层卷积每个位置能感知到更大范围的局部特征——相当于先看3小时小变化再看3×412小时的中尺度趋势。参数上我没有一开始就堆很多卷积核32→64是常用起步配置再往上加收益就开始边际递减了。LSTM的return_sequencesFalse意味着我们只取最后一个时间步的隐状态作为输出。这里有个容易纠结的问题是保留序列让后面再叠一层LSTM还是直接接Dense我在实验里发现对于单步预测一层LSTM已经足够叠两层LSTM参数量翻倍但效果提升有限还容易过拟合。3.2 关键超参数选择依据lookback48这个前面说过了。kernel_size3是时间卷积的默认选择类似于图像卷积里3×3的定位。如果你把传感器采样频率提高或者数据变成10分钟粒度那kernel_size要考虑加大比如5或7因为同样是3个时间步覆盖的真实时长变短了。LSTM的units设为64。这个值取决于序列长度和任务复杂度。48小时序列里编码的信息量其实不大hidden units太大浪费算力。我试过128训练时间翻倍验证误差几乎没有下降试过32欠拟合又明显一些。64是个甜点位。Dropout我设了0.2。LSTM层里的recurrent_dropout一定要注意它是对循环连接做dropout比普通Dropout更容易导致训练不收敛。使用它的前提是数据集足够大、序列足够长数据集不到几万条的时候我更建议用普通Dropout把recurrent_dropout设为0。3.3 损失函数与评估指标损失函数我选了Huber损失而不是MSE。原因在于PM2.5浓度数据的分布是右偏的重污染日的浓度可能是平时的5到10倍。如果用MSE模型会把大量注意力放在拟合那几个重污染峰值上反而牺牲了日常时段的预测精度。Huber损失对离群点的惩罚从二次转为线性既保留了MSE在接近零点时的强收敛性又不会被极端峰值带偏。# Huber损失的delta参数默认是1.0 # 若数据标准化到[-1, 1]delta1.0是合理范围 # 若标准化到[0, 1]建议将delta设为0.5 model.compile(optimizer..., losstf.keras.losses.Huber(delta1.0))评估指标我用三个MAE平均绝对误差、RMSE均方根误差、R²。它们各自说明不同问题。MAE告诉你平均偏差多少RMSE对重污染日更敏感R²衡量模型整体解释力。只看RMSE很容易被极值干扰只看MAE又会忽视峰值预测的系统性偏差。4. 训练实操踩过的坑与调参记录4.1 数据泄露问题这个坑最隐蔽。前面说了切分要按时间顺序但就算按时间顺序切分还是有一个坑我用滑动窗口切样本时相邻两个样本的输入窗口只有1小时之隔重叠了47小时。这在训练集和验证集的边界处会泄漏。比如验证集第一个样本的起点是T0它的输入覆盖[T0-48, T0)而这个范围内有些小时同时也在训练集最后一个样本的输入窗口里。训练集和验证集信息高度重叠验证集的效果评估就失真了。解决方式是训练集和验证集之间人为断开一段缓冲期。我当时让验证集起点比训练集终点晚48小时也就是说中间空出48小时不参与任何模型的输入。这个操作切掉了不到100个样本但换来的是验证集评估结果的真实性。train_end int(len(data) * 0.8) val_start train_end lookback # 空出lookback小时的缓冲 val_end int(len(data) * 0.9) test_start val_end lookback如果你用的是TimeSeriesSplit同样要注意这个重叠问题。Keras的TimeseriesGenerator也有类似隐患用的时候要在训练和验证之间留gap。4.2 标准化与反标准化的坑标准化本身不难难的是反标准化。很多人在预测阶段直接拿标准化后的输出当最终结果忘了反标准化回真实浓度单位导致整个评估全错。正确流程是训练前用训练集的MinMaxScaler对全部特征做标准化预测时把测试样本标准化后输入模型得到一个0到1之间的预测值然后调用scaler.inverse_transform把它们映射回真实浓度。这里还牵扯到多列特征的反标准化问题。PM2.5只是12列特征里的一列如果模型输出维度是1只预测PM2.5那么反标准化时不能直接把scaler反转整个矩阵而是要单独准备一个只针对PM2.5列的scaler或者把预测值塞回完整特征矩阵的对应列再反转。# 假设features包含pm25列和目标列 pm25_scaler MinMaxScaler() # 训练时只fit目标列 pm25_scaler.fit(train_2d_data[:, [target_col_index]]) # 预测后反标准化 pred_real pm25_scaler.inverse_transform(pred_standardized.reshape(-1, 1))4.3 训练震荡与学习率策略CNN-LSTM在训练初期非常容易震荡尤其在你的数据包含重污染峰值时。我第一次训练时loss曲线像心电图前50个epoch反复上蹿下跳。排查后发现是学习率太大Adam默认的1e-3在普通分类任务上没问题但时间序列回归的梯度比分类任务更不稳定。我的做法是给Adam设置一个预热策略前5个epoch用2e-4的小学习率后面再切到1e-3。另一个更省事的方式是用Keras自带的ReduceLROnPlateau回调它会在指标连续几个epoch不下降时自动调低学习率。reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-6) early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs100, batch_size64, callbacks[reduce_lr, early_stop], verbose1 )这里restore_best_weightsTrue很关键。没有它训练结束后模型权重停留在最后一个epoch而最后一个epoch往往不是最优的。设了这个参数会回到验证集指标最好的那个epoch的权重。配合EarlyStopping的patience10模型通常在25—40个epoch之间就稳定收敛。4.4 一个容易忽略的问题特征尺度不一致PM2.5浓度在几十到几百之间波动而风速可以是0到20米/秒温度可以是-10到40度。如果不做标准化直接喂给Conv1D卷积核会把特征值绝对值大的通道当成重要通道但这不是真实重要性。MinMaxScaler把每个特征映射到[0,1]等于强制让模型按相对变化而非原始幅值来学习。有个细节风向列是环形变量270度和90度看起来差180度实际上差别很大但350度和10度只差20度数值上却差了340。如果把风向数值直接喂进去模型会学出350度和10度差异巨大的错误结论。先用sin/cos变换把风向拆成两个分量再喂给模型。5. 预测效果验证与结论5.1 单步预测结果解读在测试集上跑完我记录了三个关键数字MAE约18.6μg/m³RMSE约28.5μg/m³R²约0.91。这个结果意味着什么北京年均PM2.5浓度大约40μg/m³日常波动范围是20—100。MAE 18.6意味着平均每个小时的预测误差不到日常波动范围的三分之一对重污染预警来说已经具备参考价值。绘图验证时我特别注意两类时段冷空气过境后的快速清除时段和静稳逆温条件下的累积时段。前者模型通常能准确捕捉到浓度断崖式下跌因为CNN部分学到了风速突增湿度骤降这个局部特征组合后者表现稍差因为累积过程的速率受多种因素影响模型只能给出一个平均预期。但总体趋势是跟得住的。5.2 多步预测的扩展方式标题里写的是单步预测的完整代码但实际应用里我们往往需要未来24小时的逐小时浓度。多步预测有两种扩展方式。第一种是递归预测。把第1步的预测结果拼到输入序列末尾再预测第2步如此往复。这种方法简单但误差会随步长累积预测到第12小时之后偏差就比较大了。第二种是序列到序列seq2seq让LSTM把整段未来序列作为输出训练时需要把目标也做成一个窗口序列。后者精度更高但训练更复杂。如果你只是做空气质量日报或污染过程分析递归预测其实够用如果要上线污染预警系统我建议上seq2seq或者改造成多输出模型。多输出模型的做法是把最后一层改成多个神经元每个神经元对应未来一个小时的浓度。最后说点个人体会。这种预测模型的精度天花板不在网络结构而在数据质量和特征质量。我试过把网络做得更深、卷积核更多收益都非常有限反倒是把特征工程做扎实——尤其是气象因子的滞后效应和累积效应——效果提升非常明显。另外模型上线后一定要持续监控数据分布空气质量监测站点调整测量仪器或者气象站更换设备都会导致特征分布漂移模型精度会悄悄下降。定期用最新数据微调模型比一开始追求极端复杂的网络结构要重要得多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。