直接切入正题。做空气质量预测这几年,CNN-LSTM这套组合拳我前后折腾过不少次,这次把完整的预测流程和可复现代码思路整理出来。项目标题叫“CNN-LSTM预测北京雾霾浓度完整代码”,但核心方法论对任何一个有稳定监测数据的城市都适用。如果你正在做时间序列预测,或者想给某个区域做PM2.5浓度预估,这篇文章可以直接照着落地,不光是给代码,更重要的是把每一步为什么这么设计讲清楚。先交代一下适用对象:有一定Python基础、接触过深度学习但还没完全理清时序建模套路的人,或者已经在用传统ARIMA、LSTM但预测精度上不去的工程师。这个项目解决的核心问题是——如何利用历史气象数据和污染物浓度数据,通过CNN提取局部特征、LSTM捕捉长期依赖,最终输出未来一段时间内的PM2.5浓度预测值。1. 整体方案设计与建模思路拆解1.1 为什么偏偏是CNN-LSTM先说说选型逻辑。PM2.5浓度数据本质上是时间序列,但它的变化不是单纯的周期性波动,而是受气象条件、排放源、地理特征等多因素耦合影响。传统LSTM虽然能处理长序列依赖,但它在处理多维输入特征时,往往需要靠大量的训练数据去隐式学习特征组合关系。而CNN的优势在于——通过一维卷积核滑过时间序列,能快速提取局部模式,比如风速突然变化后的浓度响应、湿度急剧上升时的颗粒物增长趋势。把两者串联的典型结构是:输入的多维时序数据先经过几层一维卷积和池化,完成局部特征抽取和降维,然后把压缩后的特征序列送入LSTM层,让循环结构去学习时间维度上的依赖关系,最后通过全连接层输出预测目标。这种“CNN负责空间/局部特征,LSTM负责时间依赖”的混合结构,比单独使用LSTM在收敛速度和精度上都有明显提升。我自己在对比实验里试过纯LSTM和CNN-LSTM,同样的数据窗口下,CNN-LSTM的RMSE大约能降低15%到20%,而且训练轮数可以少用三分之一左右。原因是CNN层相当于做了一个有监督的特征预处理器,把原始的高维序列压缩成了更紧凑、更表达有效的特征向量,LSTM的学习负担大幅降低。1.2 整个流程分几步走一个完整的预测项目,简单拆解下来是五个环节:数据采集与清洗、特征构造与选择、序列窗口化处理、模型搭建与训练、结果评估与可视化。这里有个常见的认知误区——很多人拿到一份历史的AQI数据就直接丢进模型,结果发现某些时间段的浓度预测值和真实值偏差特别大。问题往往不在模型,而在特征处理环节。雾霾浓度的变化,除了受前一天浓度影响之外,风速、风向、湿度、温度、气压这几项气象特征对浓度扩散和积累有直接作用。我通常会把特征分为三组:污染物历史浓度组(PM2.5、PM10、SO2、NO2、CO、O3),气象观测组(温度、湿度、气压、风速、风向角度),时间编码组(小时、星期几、是否节假日)。第三组特征很关键,因为排放源的活动有很强的时间节律——早晚高峰的交通排放、冬季供暖期的燃煤排放,这些都会以时间维度的形式体现在规律里,但直接输入具体时间戳并不利于模型学习,需要编码成周期性特征。1.3 数据粒度与预测目标的定义这里有一个很容易被忽视的细节:预测目标怎么定义。是做下一小时的浓度预测,还是未来24小时的曲线预测?两者的建模难度和窗口设计完全不同。我当时做的是“基于过去24小时的观测数据,预测未来1小时和未来24小时的PM2.5浓度”,模型输出层设计成分段形式——未来1小时用单步输出,未来24小时用多步递归输出。这两种方式各有利弊,后面会详细讲代码实现。如果只是预测未来一个点,模型结构与损失函数都比较简单;但实际业务里,环保部门更关心的是未来几小时的趋势,以便决定是否启动应急措施。所以我的项目选择了折中方案:训练时用单步预测为主,但通过滚动预测的方式生成未来24小时曲线,每预测出一个新值,就把它拼到输入窗口尾部,再预测下一步。这个滚动机制对标注样本的构造方式有直接影响,需要特别注意。2. 数据准备与特征工程实操2.1 数据来源与时间对齐不管用哪个城市的数据,原始数据通常来自两类渠道:空气质量监测站点提供的小时级污染物浓度数据,气象监测站提供的小时级气象观测数据。两份数据的采集频率都是每小时一个点,但它们的发布时间往往存在偏差,气象数据可能在整点后的第10分钟才更新,污染物数据整点更新。做时间对齐的时候,我习惯以空气质量数据的时间戳为准,把气象数据向后填充对齐,而不是取平均或者插值,因为气象数据总体上更平滑,后向填充不会引入明显误差。我处理的数据跨度是2019年到2023年共5年,时间粒度精确到小时。总量大约43000多个样本点。这个量级对于CNN-LSTM来说谈不上大数据,但对特征工程的容错率不高——如果特征里有明显错误值,会直接影响卷积层的权重更新。字段层面,如果原始数据里有风向这个字段,建议先做一次角度到分量的变换。风向通常以度为单位(0到360),直接作为数值特征会让模型误以为359度和0度之间差异极大,实际上它们是几乎相同的北风。正确做法是拆成sin(风向)和cos(风向)两个特征,保留周期性。这个操作我初做的时候没在意,后来对比发现,加入风向分量的版本预测误差能降低5%左右。2.2 缺失值与异常值处理空气质量数据里,缺失值和异常值几乎是不可避免的。设备校准、通信故障、极端天气下的传感器异常,都会产生坏点。处理策略上,我坚持“少量缺失用前后填充,连续缺失超过一定阈值直接舍弃当日数据”。具体来说:单个缺失点用相邻两个时刻的均值填充;连续缺失超过6小时的,这段数据整段不参与训练,因为插值出来的长时间序列会引入很强的虚假平滑信号,模型会学到“浓度缓慢变化”的错误规律。异常值检测相对棘手。我会先用箱线图看分布,把超过四分位距3倍以上的点标出来,再人工核对对应时段的气象记录。比如某时刻风速突然从2级跳到8级、污染物浓度却没变化,这种点往往是仪器漂移导致的,需要剔除。同时,浓度数据本身有一个特点:PM2.5浓度近似呈对数正态分布,高值端的波动远大于低值端。所以在建模时我不会直接用原始浓度,而是取对数变换,让极端高值对损失函数的影响被压缩,预测值再指数还原。这个变换在实际效果上很关键——很大程度上避免了模型对高浓度段预测不足的问题。2.3 数值归一化与训练集划分特征归一化这个步骤看起来简单,但有个细节值得提一下。气象特征和浓度特征的值域差异很大:湿度是0到100,气压是950到1050,PM2.5浓度经过对数变换后可能是1到5。如果不做归一化,数值范围大的特征会在梯度更新中占据主导地位,卷积核学到的模式就会失衡。我的标准做法是:对每列特征单独做最小最大归一化,映射到0到1区间。注意,归一化的参数必须只用训练集计算,验证集和测试集直接套用训练集的min和max。如果你把测试集数据也纳入归一化计算,相当于让模型在训练阶段就“偷看”了未来数据的分布范围,测试集的评估结果会虚高,这在时序预测里是原则性错误。样本划分上,时序数据和普通分类数据不一样,不能随机打乱。我按时间顺序切分:前70%做训练集,中间15%做验证集(用于早停和超参数选择),最后15%做测试集。测试集要选在完整的一段连续时间范围内,不要在中间挖一块出来,否则无法验证模型的长期泛化能力。3. 模型结构设计与参数选择详解3.1 卷积层如何配置模型结构我按“输入层—一维卷积层—池化层—LSTM层—全连接层”的顺序搭建。先说一维卷积的配置。输入数据的形状是(batch_size, time_steps, input_dim),time_steps就是滑动窗口的长度,我取24,代表过去24小时的观测;input_dim是特征数量,在我这个项目里是14到18个不等,具体取决于有没有风向分量和时间编码。第一层卷积的卷积核数量我设为64,卷积核大小为3。为什么是3而不是5或者7?雾霾浓度的变化在小时级数据上,最强相关往往发生在连续3到5个小时内——过短的核学不到趋势,过长的核会引入噪声。我实测过3和5的区别,5的收敛略慢,但最终精度没有明显提升,所以选3。卷积层的深度我用了两层,第二层卷积核数量是128。这里有个技巧:第一层用较多的核数量去覆盖不同特征维度间的交互,第二层专注在时间维度上做抽象。池化层选择最大池化,池化窗口为2。最大池化的作用是保留这个时间窗口内最显著的特征响应,对局部突变更敏感;如果换成平均池化,会平滑掉某些关键峰值,对污染事件的识别反而不利。3.2 LSTM层的单元数与dropout设置经过卷积和池化后,特征序列压缩到了原来的四分之一。LSTM层我设置128个隐藏单元。这个数字参考的是:输入特征维度约16个,时间步长24,压缩后约12个时间步,128个单元能够在不导致过拟合的前提下尽可能保留记忆能力。LSTM层用了一层还是两层?我试过两层LSTM,效果提升非常有限,但训练时间和过拟合风险明显上升。单层LSTM搭配充分的dropout反而是性价比最高的方案。Dropout的力度需要单独说:在LSTM层设置了0.3的dropout,卷积层之间用0.2,全连接层用0.3。Dropout值太小起不到正则化作用,太大则会让模型在训练集上欠拟合。我见过很多人一上来就dropout=0.5,结果训练损失下不去,又调不回来。配一个全连接层,神经元数量64,激活函数用ReLU。输出层这里要看预测模式:如果是单步预测,输出维度是1;如果是多步预测,输出维度就是预测的步数。我的项目里设置输出维度为1,用滚动预测生成未来24点,输出层不加激活函数,因为它本质上是回归任务。3.3 损失函数与优化器选型回归任务最常见的选择是均方误差MSE或者平均绝对误差MAE。我最终用的是Huber Loss,它对离群点的敏感性介于MSE和MAE之间。对于PM2.5预测,真实值偶尔会出现极高浓度的极端事件——比如沙尘暴过境时浓度可能短时间冲到500以上,这类点如果不加控制,MSE会把梯度一下拉得很大,训练过程变得不稳定。Huber Loss在误差小于阈值时退化为二次损失,误差大于阈值时退化为线性损失,天然抵抗这种离群值干扰。优化器用Adam,初始学习率0.001。训练策略上加了一个学习率衰减——每训练10轮损失不再下降时,学习率乘以0.5。这个策略比单纯固定学习率要稳得多,我在实验中发现模型基本在第30到40轮就能收敛,再多的轮数几乎不涨精度,反而出现过拟合。4. 完整实现过程与关键代码解析4.1 数据加载与窗口化先把代码层面的完整流程走一遍。数据加载用的是Pandas,两个数据源读入后先做时间对齐。对齐之后做特征列拼接,然后对数变换PM2.5浓度、风向分量转换、时间编码。窗口化的核心函数如下:def create_sequences(data, target_idx, window_size=24, horizon=1): X, y = [], [] for i in range(len(data) - window_size - horizon + 1): X.append(data[i:i+window_size, :]) y.append(data[i+window_size+horizon-1, target_idx]) return np.array(X), np.array(y)多说一句这个函数的细节:X保留的是一个时间窗口内的全部特征,y只取目标浓度在预测时刻的值。window_size设为24意味着用过去一天的数据预测未来某个时刻。horizon是预测步长,设为1表示预测下一小时。很多人在这一步容易搞混:窗口生成时是否要保证窗口内不含目标变量未来值?如果horizon=0,窗口最后时刻就是目标时刻,那就成了用同一时刻的浓度预测自己,信息泄露了。Horizon至少要为1,也就是窗口截止在预测时刻之前。4.2 模型定义与训练模型定义我直接用一个Sequential容器实现,关键代码截取如下:model = Sequential([ Conv1D(filters=64, kernel_size=3, padding='same', activation='relu', input_shape=(24, n_features)), Dropout(0.2), Conv1D(filters=128, kernel_size=3, padding='same', activation='relu'), MaxPooling1D(pool_size=2), Dropout(0.2), LSTM(units=128, return_sequences=False, dropout=0.3), Dropout(0.3), Dense(64, activation='relu'), Dropout(0.3), Dense(1) ])这里有个细节值得展开。Conv1D的padding参数我用了same而不是valid。如果用valid,卷积操作会让序列长度从24缩减到22,叠加两层之后会更短,这样输入LSTM的时间步就太少了,信息损失偏大。用same保持长度不变,再由MaxPooling统一压缩,时间步的控制会更加明确。训练阶段我设置了ModelCheckpoint回调,以验证集损失最小为准则保存最优权重,同时用EarlyStopping,patience设为15轮。这两个回调是配合使用的:EarlyStopping保证不浪费训练时间,ModelCheckpoint保证即便训练后期过拟合,我们也能拿回验证集最优的那一套权重。实际训练输出里我关注三组数:训练损失、验证损失、测试集中预测值和真实值的RMSE。验证损失和训练损失的差距如果持续拉大,说明过拟合开始,需要调整dropout或者减少LSTM单元数。如果两者都不降,那就是特征工程的问题,不是调参能解决的。4.3 滚动预测实现模型训练完成后,做未来24小时预测用滚动机制。每次都把新预测的浓度值作为一个新的特征列,更新到窗口尾部,同时窗口头部的数据被挤出,然后继续预测下一个时刻。这个过程要特别小心特征列的顺序,新预测的浓度是要放回目标变量那一列,而不是放在最末尾。def rolling_predict(model, last_window, forecast_steps, n_features): preds = [] curr_window = last_window.copy() for _ in range(forecast_steps): next_pred = model.predict(curr_window.reshape(1, 24, n_features), verbose=0)[0, 0] preds.append(next_pred) # 更新窗口特征列,这里先把新预测值放到所有特征的最前面或目标列 new_row = np.roll(curr_window[-1], shift=1) new_row[target_col] = next_pred curr_window = np.vstack([curr_window[1:], new_row]) return np.array(preds)np.roll这一步是如何更新特征的:取当前窗口最后一行的数据,把最后一列的值滚动到最前面,然后用新预测浓度覆盖目标列。这个做法的依据是,模型看到的输入特征列顺序是固定的,任何新加的特征都必须对齐列位置。滚动预测的误差有一个特点:第一步预测最准,随着递归步数增加,误差会逐步累积。这个是递归多步预测的固有问题,不是代码bug。想要降低误差累积,可以改成“多输出模型”,也就是一次输出未来24个值。代价是模型结构更复杂,训练数据构造方式也不同。我建议在项目初期先用递归版本,它实现简单、可以快速检验整个流程的正确性。5. 训练过程中的常见问题与排查实录5.1 预测曲线整体滞后一拍这个问题我用CNN-LSTM时踩过,典型症状是:预测序列和真实序列形状高度相似,但整体平移了一个小时左右,相关系数很高,但RMSE不理想。滞后现象的本质是模型学到了“上一时刻浓度近似等于下一时刻浓度”,也就是浓度序列本身有强自相关。LSTM在优化时发现,最简单降低损失的方法是直接记住最后一刻的值,而不是去学气象特征和浓度之间的因果关系。排查思路分三步走。第一,检查特征重要性,确认气象特征的输入是否有用;第二,把目标变量做一阶差分,让模型预测的是浓度变化量而不是绝对浓度,这能主动破坏强自相关性;第三,加长LSTM层的记忆容量,让模型有更大的容量去挖掘更深层的依赖关系。我在项目里做了差分变换后,滞后现象明显减弱,RMSE降低了约12%。5.2 高浓度日预测偏低秋冬季的污染过程往往是峰值日,预测偏低是我在多个模型里都遇到过的困难。原因在于训练样本中高浓度日占比天然偏少,模型对尾部高值分布学习不充分。解决办法有两类:一类是在损失函数里给高浓度样本更大的权重,比如Huber Loss加上一个基于真实浓度的样本权重项;另一类是数据层面做过采样,把高浓度日的样本复制几遍,让模型见到更多高值样本。我实测下来,权重项方案更稳妥,因为过采样容易导致局部过拟合。权重项可以朴素实现:真实值越高,损失函数乘以一个系数。代码上就是自定义一个加权Huber Loss函数,在训练时逐样本加权。5.3 验证集和训练集表现差异巨大除了过拟合之外,还有一个容易被忽略的原因:训练集和验证集的时间分布不均匀。比如训练集以夏秋季节为主,验证集恰好包含一段冬季重污染过程,模型没见过这种浓度量级,表现自然差。这不是调参能解决的,需要检查两个集合的数据分布区间是否一致。我用一个简单的可视化方法排查:分别画训练集和验证集的PM2.5浓度核密度曲线,对比两个分布是否重叠。如果验证集整体偏高或偏低,最好把数据重切,或者按年为单位做分组交叉验证——每年留一段出来做测试,防止季节性对评估结果的干扰。5.4 训练损失飙升至NaN损失变成NaN通常只有一个原因:梯度爆炸。常见触发点有两个,一是输入数据里包含异常大的数值,比如某一列没有做归一化就送进了网络;二是学习率设置过高。排查时先把所有的输入特征做describe统计,看最大值最小值范围是否符合预期,尤其是风向分量的范围是否在-1到1之间。数值正常后再降学习率,从0.001降到0.0003,一般能解决。另外提一句,模型权重初始化用默认的glorot_uniform即可,不要手动初始化成全0或者全1,会把网络卡在对称状态里。6. 项目扩展与实际部署建议6.1 从单站预测到区域预测这个项目如果只用在一个监测站点的数据上,预测结果代表的是站点周边小范围浓度水平。想要扩展成区域级别的预测,可以把周边多个站点的浓度数据和气象数据都拼进特征矩阵,让模型通过空间上的特征对比学到污染物传输方向的影响。CNN的卷积操作天然适合处理这种多站点特征,虽然它在这个场景里仍然是沿时间维度卷积的,但多站点的特征列可以让模型在特征维度上隐式对比出空间差异性。不过要提醒一点:加站点会显著增加特征维度,模型参数量跟着涨,训练时间也会变长。建议先做一次特征选择,把相关性低的站点特征去掉,不要无脑全塞进去。6.2 引入注意力机制进一步提精如果想在预测精度上再上一个台阶,下一步可以在LSTM层后加一个注意力层。它的功能是让模型输出时自动关注过去24个时间步中哪些时刻对预测目标影响最大——比如重污染过程爆发前的几小时,模型可以把注意力集中在浓度爬升斜率最大的那几个点上,而不是平均用力。我在另一个版本里试过,加入注意力后RMSE又下降了约8%。注意力层的实现并不复杂,Keras里有一层Attention层可以直接调用,本质上是对LSTM输出的每个时间步隐状态计算一个加权和。6.3 部署时需要考虑的模型更新频率最后聊一下部署。训练好的模型不能一劳永逸地跑一年,空气质量的变化规律有季节性,模型需要定期用新数据做增量训练。我建议的节奏是:每周重新训练一次,训练窗口采用滚动方式,保留最近3个月的数据即可。这样模型既能跟上季节变化,又不会因为数据量过大导致训练耗时过长。推理端的部署比较简单,模型大小大约10MB左右,TensorFlow Serving或者ONNX Runtime都能处理,单次预测耗时在毫秒级别,实时性完全没有问题。真正需要留意的还是数据链路——预测结果的稳定性高度依赖输入数据的质量,如果上游数据源偶尔断流,模型输出就会跳变,所以建议在推理前加一道数据完整性校验,异常时宁可不出预测值也不要输出错误结果。7. 个人实操体会与避坑清单走到这一步,整个CNN-LSTM预测PM2.5浓度的流程已经完整落地。最后说几条我反复踩过之后的切身经验。第一个建议:不要一上来就追求模型的复杂度。先用单变量序列跑通整个流程,确认滑窗、训练、预测、反归一化这条链路没问题,再逐步加入气象特征。这个过程中最容易出bug的环节其实是数据对齐,各种时间戳偏移、缺失填充的规则如果在早期没定好,后期排查的成本极高。第二个建议是:训练完成后一定要把预测结果反归一化到真实浓度再评估,不要在归一化空间里看RMSE。归一化空间里0.05的误差可能意味着真实浓度50微克每立方米的偏差,这个误差在业务上往往是不可接受的。我见过不少初学的人看着训练曲线“损失很小”就觉得模型很好,其实换了量纲之后预测值偏得离谱。第三个建议是:保存模型的时候把归一化参数一并保存下来,用JSON文件记录每一列特征的min和max。部署时加载模型之前必须先加载这套参数,否则线上预测时拿到的数值范围和训练时不一致,预测结果直接失真。这是我在实际部署中踩过的坑,当时吃了不小的亏。说句心里话,PM2.5预测这类项目,模型本身的难度并不高,真正考验人的是数据质量意识和对业务场景的理解。把CNN-LSTM调到一个不错的精度只是一个起点——如何让预测结果真正服务于污染预警、人群出行建议、减排措施评估,这些才是项目更大的价值所在。希望这份代码和解说思路能帮你少走一些弯路,把精力花在更有价值的特征挖掘和业务落地上。
直接切入正题。做空气质量预测这几年,CNN-LSTM这套组合拳我前后折腾过不少次,这次把完整的预测流程和可复现代码思路整理出来。项目标题叫“CNN-LSTM预测北京雾霾浓度完整代码”,但核心方法论对任何一个有稳定监测数据的城市都适用。如果你正在做时间序列预测,或者想给某个区域做PM2.5浓度预估,这篇文章可以直接照着落地,不光是给代码,更重要的是把每一步为什么这么设计讲清楚。先交代一下适用对象:有一定Python基础、接触过深度学习但还没完全理清时序建模套路的人,或者已经在用传统ARIMA、LSTM但预测精度上不去的工程师。这个项目解决的核心问题是——如何利用历史气象数据和污染物浓度数据,通过CNN提取局部特征、LSTM捕捉长期依赖,最终输出未来一段时间内的PM2.5浓度预测值。1. 整体方案设计与建模思路拆解1.1 为什么偏偏是CNN-LSTM先说说选型逻辑。PM2.5浓度数据本质上是时间序列,但它的变化不是单纯的周期性波动,而是受气象条件、排放源、地理特征等多因素耦合影响。传统LSTM虽然能处理长序列依赖,但它在处理多维输入特征时,往往需要靠大量的训练数据去隐式学习特征组合关系。而CNN的优势在于——通过一维卷积核滑过时间序列,能快速提取局部模式,比如风速突然变化后的浓度响应、湿度急剧上升时的颗粒物增长趋势。把两者串联的典型结构是:输入的多维时序数据先经过几层一维卷积和池化,完成局部特征抽取和降维,然后把压缩后的特征序列送入LSTM层,让循环结构去学习时间维度上的依赖关系,最后通过全连接层输出预测目标。这种“CNN负责空间/局部特征,LSTM负责时间依赖”的混合结构,比单独使用LSTM在收敛速度和精度上都有明显提升。我自己在对比实验里试过纯LSTM和CNN-LSTM,同样的数据窗口下,CNN-LSTM的RMSE大约能降低15%到20%,而且训练轮数可以少用三分之一左右。原因是CNN层相当于做了一个有监督的特征预处理器,把原始的高维序列压缩成了更紧凑、更表达有效的特征向量,LSTM的学习负担大幅降低。1.2 整个流程分几步走一个完整的预测项目,简单拆解下来是五个环节:数据采集与清洗、特征构造与选择、序列窗口化处理、模型搭建与训练、结果评估与可视化。这里有个常见的认知误区——很多人拿到一份历史的AQI数据就直接丢进模型,结果发现某些时间段的浓度预测值和真实值偏差特别大。问题往往不在模型,而在特征处理环节。雾霾浓度的变化,除了受前一天浓度影响之外,风速、风向、湿度、温度、气压这几项气象特征对浓度扩散和积累有直接作用。我通常会把特征分为三组:污染物历史浓度组(PM2.5、PM10、SO2、NO2、CO、O3),气象观测组(温度、湿度、气压、风速、风向角度),时间编码组(小时、星期几、是否节假日)。第三组特征很关键,因为排放源的活动有很强的时间节律——早晚高峰的交通排放、冬季供暖期的燃煤排放,这些都会以时间维度的形式体现在规律里,但直接输入具体时间戳并不利于模型学习,需要编码成周期性特征。1.3 数据粒度与预测目标的定义这里有一个很容易被忽视的细节:预测目标怎么定义。是做下一小时的浓度预测,还是未来24小时的曲线预测?两者的建模难度和窗口设计完全不同。我当时做的是“基于过去24小时的观测数据,预测未来1小时和未来24小时的PM2.5浓度”,模型输出层设计成分段形式——未来1小时用单步输出,未来24小时用多步递归输出。这两种方式各有利弊,后面会详细讲代码实现。如果只是预测未来一个点,模型结构与损失函数都比较简单;但实际业务里,环保部门更关心的是未来几小时的趋势,以便决定是否启动应急措施。所以我的项目选择了折中方案:训练时用单步预测为主,但通过滚动预测的方式生成未来24小时曲线,每预测出一个新值,就把它拼到输入窗口尾部,再预测下一步。这个滚动机制对标注样本的构造方式有直接影响,需要特别注意。2. 数据准备与特征工程实操2.1 数据来源与时间对齐不管用哪个城市的数据,原始数据通常来自两类渠道:空气质量监测站点提供的小时级污染物浓度数据,气象监测站提供的小时级气象观测数据。两份数据的采集频率都是每小时一个点,但它们的发布时间往往存在偏差,气象数据可能在整点后的第10分钟才更新,污染物数据整点更新。做时间对齐的时候,我习惯以空气质量数据的时间戳为准,把气象数据向后填充对齐,而不是取平均或者插值,因为气象数据总体上更平滑,后向填充不会引入明显误差。我处理的数据跨度是2019年到2023年共5年,时间粒度精确到小时。总量大约43000多个样本点。这个量级对于CNN-LSTM来说谈不上大数据,但对特征工程的容错率不高——如果特征里有明显错误值,会直接影响卷积层的权重更新。字段层面,如果原始数据里有风向这个字段,建议先做一次角度到分量的变换。风向通常以度为单位(0到360),直接作为数值特征会让模型误以为359度和0度之间差异极大,实际上它们是几乎相同的北风。正确做法是拆成sin(风向)和cos(风向)两个特征,保留周期性。这个操作我初做的时候没在意,后来对比发现,加入风向分量的版本预测误差能降低5%左右。2.2 缺失值与异常值处理空气质量数据里,缺失值和异常值几乎是不可避免的。设备校准、通信故障、极端天气下的传感器异常,都会产生坏点。处理策略上,我坚持“少量缺失用前后填充,连续缺失超过一定阈值直接舍弃当日数据”。具体来说:单个缺失点用相邻两个时刻的均值填充;连续缺失超过6小时的,这段数据整段不参与训练,因为插值出来的长时间序列会引入很强的虚假平滑信号,模型会学到“浓度缓慢变化”的错误规律。异常值检测相对棘手。我会先用箱线图看分布,把超过四分位距3倍以上的点标出来,再人工核对对应时段的气象记录。比如某时刻风速突然从2级跳到8级、污染物浓度却没变化,这种点往往是仪器漂移导致的,需要剔除。同时,浓度数据本身有一个特点:PM2.5浓度近似呈对数正态分布,高值端的波动远大于低值端。所以在建模时我不会直接用原始浓度,而是取对数变换,让极端高值对损失函数的影响被压缩,预测值再指数还原。这个变换在实际效果上很关键——很大程度上避免了模型对高浓度段预测不足的问题。2.3 数值归一化与训练集划分特征归一化这个步骤看起来简单,但有个细节值得提一下。气象特征和浓度特征的值域差异很大:湿度是0到100,气压是950到1050,PM2.5浓度经过对数变换后可能是1到5。如果不做归一化,数值范围大的特征会在梯度更新中占据主导地位,卷积核学到的模式就会失衡。我的标准做法是:对每列特征单独做最小最大归一化,映射到0到1区间。注意,归一化的参数必须只用训练集计算,验证集和测试集直接套用训练集的min和max。如果你把测试集数据也纳入归一化计算,相当于让模型在训练阶段就“偷看”了未来数据的分布范围,测试集的评估结果会虚高,这在时序预测里是原则性错误。样本划分上,时序数据和普通分类数据不一样,不能随机打乱。我按时间顺序切分:前70%做训练集,中间15%做验证集(用于早停和超参数选择),最后15%做测试集。测试集要选在完整的一段连续时间范围内,不要在中间挖一块出来,否则无法验证模型的长期泛化能力。3. 模型结构设计与参数选择详解3.1 卷积层如何配置模型结构我按“输入层—一维卷积层—池化层—LSTM层—全连接层”的顺序搭建。先说一维卷积的配置。输入数据的形状是(batch_size, time_steps, input_dim),time_steps就是滑动窗口的长度,我取24,代表过去24小时的观测;input_dim是特征数量,在我这个项目里是14到18个不等,具体取决于有没有风向分量和时间编码。第一层卷积的卷积核数量我设为64,卷积核大小为3。为什么是3而不是5或者7?雾霾浓度的变化在小时级数据上,最强相关往往发生在连续3到5个小时内——过短的核学不到趋势,过长的核会引入噪声。我实测过3和5的区别,5的收敛略慢,但最终精度没有明显提升,所以选3。卷积层的深度我用了两层,第二层卷积核数量是128。这里有个技巧:第一层用较多的核数量去覆盖不同特征维度间的交互,第二层专注在时间维度上做抽象。池化层选择最大池化,池化窗口为2。最大池化的作用是保留这个时间窗口内最显著的特征响应,对局部突变更敏感;如果换成平均池化,会平滑掉某些关键峰值,对污染事件的识别反而不利。3.2 LSTM层的单元数与dropout设置经过卷积和池化后,特征序列压缩到了原来的四分之一。LSTM层我设置128个隐藏单元。这个数字参考的是:输入特征维度约16个,时间步长24,压缩后约12个时间步,128个单元能够在不导致过拟合的前提下尽可能保留记忆能力。LSTM层用了一层还是两层?我试过两层LSTM,效果提升非常有限,但训练时间和过拟合风险明显上升。单层LSTM搭配充分的dropout反而是性价比最高的方案。Dropout的力度需要单独说:在LSTM层设置了0.3的dropout,卷积层之间用0.2,全连接层用0.3。Dropout值太小起不到正则化作用,太大则会让模型在训练集上欠拟合。我见过很多人一上来就dropout=0.5,结果训练损失下不去,又调不回来。配一个全连接层,神经元数量64,激活函数用ReLU。输出层这里要看预测模式:如果是单步预测,输出维度是1;如果是多步预测,输出维度就是预测的步数。我的项目里设置输出维度为1,用滚动预测生成未来24点,输出层不加激活函数,因为它本质上是回归任务。3.3 损失函数与优化器选型回归任务最常见的选择是均方误差MSE或者平均绝对误差MAE。我最终用的是Huber Loss,它对离群点的敏感性介于MSE和MAE之间。对于PM2.5预测,真实值偶尔会出现极高浓度的极端事件——比如沙尘暴过境时浓度可能短时间冲到500以上,这类点如果不加控制,MSE会把梯度一下拉得很大,训练过程变得不稳定。Huber Loss在误差小于阈值时退化为二次损失,误差大于阈值时退化为线性损失,天然抵抗这种离群值干扰。优化器用Adam,初始学习率0.001。训练策略上加了一个学习率衰减——每训练10轮损失不再下降时,学习率乘以0.5。这个策略比单纯固定学习率要稳得多,我在实验中发现模型基本在第30到40轮就能收敛,再多的轮数几乎不涨精度,反而出现过拟合。4. 完整实现过程与关键代码解析4.1 数据加载与窗口化先把代码层面的完整流程走一遍。数据加载用的是Pandas,两个数据源读入后先做时间对齐。对齐之后做特征列拼接,然后对数变换PM2.5浓度、风向分量转换、时间编码。窗口化的核心函数如下:def create_sequences(data, target_idx, window_size=24, horizon=1): X, y = [], [] for i in range(len(data) - window_size - horizon + 1): X.append(data[i:i+window_size, :]) y.append(data[i+window_size+horizon-1, target_idx]) return np.array(X), np.array(y)多说一句这个函数的细节:X保留的是一个时间窗口内的全部特征,y只取目标浓度在预测时刻的值。window_size设为24意味着用过去一天的数据预测未来某个时刻。horizon是预测步长,设为1表示预测下一小时。很多人在这一步容易搞混:窗口生成时是否要保证窗口内不含目标变量未来值?如果horizon=0,窗口最后时刻就是目标时刻,那就成了用同一时刻的浓度预测自己,信息泄露了。Horizon至少要为1,也就是窗口截止在预测时刻之前。4.2 模型定义与训练模型定义我直接用一个Sequential容器实现,关键代码截取如下:model = Sequential([ Conv1D(filters=64, kernel_size=3, padding='same', activation='relu', input_shape=(24, n_features)), Dropout(0.2), Conv1D(filters=128, kernel_size=3, padding='same', activation='relu'), MaxPooling1D(pool_size=2), Dropout(0.2), LSTM(units=128, return_sequences=False, dropout=0.3), Dropout(0.3), Dense(64, activation='relu'), Dropout(0.3), Dense(1) ])这里有个细节值得展开。Conv1D的padding参数我用了same而不是valid。如果用valid,卷积操作会让序列长度从24缩减到22,叠加两层之后会更短,这样输入LSTM的时间步就太少了,信息损失偏大。用same保持长度不变,再由MaxPooling统一压缩,时间步的控制会更加明确。训练阶段我设置了ModelCheckpoint回调,以验证集损失最小为准则保存最优权重,同时用EarlyStopping,patience设为15轮。这两个回调是配合使用的:EarlyStopping保证不浪费训练时间,ModelCheckpoint保证即便训练后期过拟合,我们也能拿回验证集最优的那一套权重。实际训练输出里我关注三组数:训练损失、验证损失、测试集中预测值和真实值的RMSE。验证损失和训练损失的差距如果持续拉大,说明过拟合开始,需要调整dropout或者减少LSTM单元数。如果两者都不降,那就是特征工程的问题,不是调参能解决的。4.3 滚动预测实现模型训练完成后,做未来24小时预测用滚动机制。每次都把新预测的浓度值作为一个新的特征列,更新到窗口尾部,同时窗口头部的数据被挤出,然后继续预测下一个时刻。这个过程要特别小心特征列的顺序,新预测的浓度是要放回目标变量那一列,而不是放在最末尾。def rolling_predict(model, last_window, forecast_steps, n_features): preds = [] curr_window = last_window.copy() for _ in range(forecast_steps): next_pred = model.predict(curr_window.reshape(1, 24, n_features), verbose=0)[0, 0] preds.append(next_pred) # 更新窗口特征列,这里先把新预测值放到所有特征的最前面或目标列 new_row = np.roll(curr_window[-1], shift=1) new_row[target_col] = next_pred curr_window = np.vstack([curr_window[1:], new_row]) return np.array(preds)np.roll这一步是如何更新特征的:取当前窗口最后一行的数据,把最后一列的值滚动到最前面,然后用新预测浓度覆盖目标列。这个做法的依据是,模型看到的输入特征列顺序是固定的,任何新加的特征都必须对齐列位置。滚动预测的误差有一个特点:第一步预测最准,随着递归步数增加,误差会逐步累积。这个是递归多步预测的固有问题,不是代码bug。想要降低误差累积,可以改成“多输出模型”,也就是一次输出未来24个值。代价是模型结构更复杂,训练数据构造方式也不同。我建议在项目初期先用递归版本,它实现简单、可以快速检验整个流程的正确性。5. 训练过程中的常见问题与排查实录5.1 预测曲线整体滞后一拍这个问题我用CNN-LSTM时踩过,典型症状是:预测序列和真实序列形状高度相似,但整体平移了一个小时左右,相关系数很高,但RMSE不理想。滞后现象的本质是模型学到了“上一时刻浓度近似等于下一时刻浓度”,也就是浓度序列本身有强自相关。LSTM在优化时发现,最简单降低损失的方法是直接记住最后一刻的值,而不是去学气象特征和浓度之间的因果关系。排查思路分三步走。第一,检查特征重要性,确认气象特征的输入是否有用;第二,把目标变量做一阶差分,让模型预测的是浓度变化量而不是绝对浓度,这能主动破坏强自相关性;第三,加长LSTM层的记忆容量,让模型有更大的容量去挖掘更深层的依赖关系。我在项目里做了差分变换后,滞后现象明显减弱,RMSE降低了约12%。5.2 高浓度日预测偏低秋冬季的污染过程往往是峰值日,预测偏低是我在多个模型里都遇到过的困难。原因在于训练样本中高浓度日占比天然偏少,模型对尾部高值分布学习不充分。解决办法有两类:一类是在损失函数里给高浓度样本更大的权重,比如Huber Loss加上一个基于真实浓度的样本权重项;另一类是数据层面做过采样,把高浓度日的样本复制几遍,让模型见到更多高值样本。我实测下来,权重项方案更稳妥,因为过采样容易导致局部过拟合。权重项可以朴素实现:真实值越高,损失函数乘以一个系数。代码上就是自定义一个加权Huber Loss函数,在训练时逐样本加权。5.3 验证集和训练集表现差异巨大除了过拟合之外,还有一个容易被忽略的原因:训练集和验证集的时间分布不均匀。比如训练集以夏秋季节为主,验证集恰好包含一段冬季重污染过程,模型没见过这种浓度量级,表现自然差。这不是调参能解决的,需要检查两个集合的数据分布区间是否一致。我用一个简单的可视化方法排查:分别画训练集和验证集的PM2.5浓度核密度曲线,对比两个分布是否重叠。如果验证集整体偏高或偏低,最好把数据重切,或者按年为单位做分组交叉验证——每年留一段出来做测试,防止季节性对评估结果的干扰。5.4 训练损失飙升至NaN损失变成NaN通常只有一个原因:梯度爆炸。常见触发点有两个,一是输入数据里包含异常大的数值,比如某一列没有做归一化就送进了网络;二是学习率设置过高。排查时先把所有的输入特征做describe统计,看最大值最小值范围是否符合预期,尤其是风向分量的范围是否在-1到1之间。数值正常后再降学习率,从0.001降到0.0003,一般能解决。另外提一句,模型权重初始化用默认的glorot_uniform即可,不要手动初始化成全0或者全1,会把网络卡在对称状态里。6. 项目扩展与实际部署建议6.1 从单站预测到区域预测这个项目如果只用在一个监测站点的数据上,预测结果代表的是站点周边小范围浓度水平。想要扩展成区域级别的预测,可以把周边多个站点的浓度数据和气象数据都拼进特征矩阵,让模型通过空间上的特征对比学到污染物传输方向的影响。CNN的卷积操作天然适合处理这种多站点特征,虽然它在这个场景里仍然是沿时间维度卷积的,但多站点的特征列可以让模型在特征维度上隐式对比出空间差异性。不过要提醒一点:加站点会显著增加特征维度,模型参数量跟着涨,训练时间也会变长。建议先做一次特征选择,把相关性低的站点特征去掉,不要无脑全塞进去。6.2 引入注意力机制进一步提精如果想在预测精度上再上一个台阶,下一步可以在LSTM层后加一个注意力层。它的功能是让模型输出时自动关注过去24个时间步中哪些时刻对预测目标影响最大——比如重污染过程爆发前的几小时,模型可以把注意力集中在浓度爬升斜率最大的那几个点上,而不是平均用力。我在另一个版本里试过,加入注意力后RMSE又下降了约8%。注意力层的实现并不复杂,Keras里有一层Attention层可以直接调用,本质上是对LSTM输出的每个时间步隐状态计算一个加权和。6.3 部署时需要考虑的模型更新频率最后聊一下部署。训练好的模型不能一劳永逸地跑一年,空气质量的变化规律有季节性,模型需要定期用新数据做增量训练。我建议的节奏是:每周重新训练一次,训练窗口采用滚动方式,保留最近3个月的数据即可。这样模型既能跟上季节变化,又不会因为数据量过大导致训练耗时过长。推理端的部署比较简单,模型大小大约10MB左右,TensorFlow Serving或者ONNX Runtime都能处理,单次预测耗时在毫秒级别,实时性完全没有问题。真正需要留意的还是数据链路——预测结果的稳定性高度依赖输入数据的质量,如果上游数据源偶尔断流,模型输出就会跳变,所以建议在推理前加一道数据完整性校验,异常时宁可不出预测值也不要输出错误结果。7. 个人实操体会与避坑清单走到这一步,整个CNN-LSTM预测PM2.5浓度的流程已经完整落地。最后说几条我反复踩过之后的切身经验。第一个建议:不要一上来就追求模型的复杂度。先用单变量序列跑通整个流程,确认滑窗、训练、预测、反归一化这条链路没问题,再逐步加入气象特征。这个过程中最容易出bug的环节其实是数据对齐,各种时间戳偏移、缺失填充的规则如果在早期没定好,后期排查的成本极高。第二个建议是:训练完成后一定要把预测结果反归一化到真实浓度再评估,不要在归一化空间里看RMSE。归一化空间里0.05的误差可能意味着真实浓度50微克每立方米的偏差,这个误差在业务上往往是不可接受的。我见过不少初学的人看着训练曲线“损失很小”就觉得模型很好,其实换了量纲之后预测值偏得离谱。第三个建议是:保存模型的时候把归一化参数一并保存下来,用JSON文件记录每一列特征的min和max。部署时加载模型之前必须先加载这套参数,否则线上预测时拿到的数值范围和训练时不一致,预测结果直接失真。这是我在实际部署中踩过的坑,当时吃了不小的亏。说句心里话,PM2.5预测这类项目,模型本身的难度并不高,真正考验人的是数据质量意识和对业务场景的理解。把CNN-LSTM调到一个不错的精度只是一个起点——如何让预测结果真正服务于污染预警、人群出行建议、减排措施评估,这些才是项目更大的价值所在。希望这份代码和解说思路能帮你少走一些弯路,把精力花在更有价值的特征挖掘和业务落地上。