聊起空气质量预测,北京的PM2.5浓度是很多人绕不开的话题。我去年花了两个多月时间,从数据抓取到模型调参,把一套基于CNN-LSTM的雾霾浓度预测完整代码跑通了,效果在可接受范围之内。今天这篇就用一次真实的项目复盘的方式,把完整流程、代码细节、踩过的坑全部摊开讲清楚,希望能给正在做时序预测或者环境数据建模的朋友一些可以直接抄走的经验。这套代码能解决什么问题?简单说:给你过去一段时间的小时级PM2.5浓度以及气温、气压、风速、风向、降雨等气象数据,模型能够预测未来几小时的污染浓度走势。项目适合两类人看:一类是深度学习入门者,想搞懂CNN和LSTM怎么配合处理时间序列;另一类是环境科学或数据分析方向的人,想知道这种混合模型在实际污染物预测任务里到底能跑出什么效果、有哪些坑。1. 项目设计与技术选型思路1.1 为什么是CNN-LSTM而不是单纯某个模型刚开始拿到这个题目的时候,我第一反应想的是直接用LSTM做。毕竟PM2.5浓度是标准的时间序列,LSTM天生就是干这个的。但实际跑下来以后发现一个问题——单用LSTM对突发性的浓度变化反应偏慢,模型倾向于把上一时刻的浓度值直接平滑地带到下一时刻,预测曲线整体守成有余、锐度不足。后来换了个思路:把CNN加进去做前置特征提取。CNN在图像领域很强势,但在时间序列上它干的是另一件事——用一维卷积核去扫描输入窗口,把局部变化模式识别出来。打个比方:LSTM像一个记忆力很好的人,能记住前几天的污染走势,但眼神不太好;CNN就像一个视力极好的观察员,能在短窗口里迅速发现“这阵子风速在加大、湿度在上升、浓度在下降”这类局部变换特征。两个人配合,观察员先扫一遍,把有价值的局部模式提炼出来,记忆员再基于这些模式去推演长程规律。这套配合在物理意义上也说得通。雾霾的形成和消散不是线性过程,它有积累效应也有突变节点,比如冷锋过境前后的浓度骤降、早晚高峰时段的短时攀升。CNN抓的是这种短程模式特征,LSTM负责的是跨小时甚至跨天的趋势记忆。两者各管一段,职责边界清楚。1.2 数据集与评估基准的选择刚开始我图省事,想直接爬其他平台已经清洗好的数据。后来发现单一数据源不太够用,就手动合并了两部分:污染物浓度数据来自某公开环境监测站点数据库的逐小时记录,气象数据来自对应的地面气象观测站。时间跨度取了大约4年,记录频率是每小时一条,字段包括PM2.5浓度、PM10、SO₂、NO₂、CO、O₃、气温、气压、露点、风速、风向、累积降雨量。选数据集的时候有两条经验值得说:第一,时间跨度要足够长。至少跨过一个完整年,这样才能覆盖北方冬季供暖季的高污染时段和夏季的清洁时段,模型才能学到季节规律。第二,数据要尽量在“同一地理尺度”上。北京的空气污染在空间上分布很不均匀,如果污染物数据是城区的站点,气象数据却取了郊区站点,风向上的错位会影响模型学习。我最后尽量选了同一坐标系下能关联上的数据源,保证时序记录能对齐。评估指标上,回归任务我不建议只看一个。RMSE、MAE、R²三个指标要放一起看。RMSE对大的预测误差特别敏感,能反映出模型在重污染时段是否出现离谱的偏差;MAE给出的是平均绝对误差,更贴近日常感知;R²给出的是模型对浓度方差的解释比例。后面在评估章节我会详细展开这三者各自怎么看。1.3 代码结构怎么组织才不容易翻车代码组织这块,我踩过一整个重写的坑。最开始我把数据清洗、模型构建、训练、绘图全写在一个脚本里,结果换一组参数就要全局翻找,调试成本极高。第二个版本我做了模块化拆分,按下面的结构组织:air_quality_project/ ├── data/ │ ├── raw/ # 原始数据存放 │ └── processed/ # 清洗后的数据 ├── src/ │ ├── data_preprocessing.py # 加载与清洗 │ ├── build_dataset.py # 构造滑动窗口样本 │ ├── model.py # CNN-LSTM模型定义 │ ├── train.py # 训练流程 │ └── evaluate.py # 评估与绘图 ├── models/ # 保存训练好的权重 └── results/ # 图表与指标输出这样的好处有几个:换数据集时只动data_preprocessing.py,调网络结构只动model.py,实验记录集中在一个目录,复盘容易。虽然项目初期多了些文件跳转成本,但后续调参的效率提升绝对值回票价。2. 环境准备与数据预处理细节2.1 依赖库与版本选择跑这套代码,建议直接用Anaconda建一个独立环境,别把系统Python环境搞乱了。我用的核心依赖和版本如下:Python 3.9TensorFlow 2.10(Keras内置)NumPy 1.21Pandas 1.4scikit-learn 1.1Matplotlib 3.5TensorFlow版本的选择有个讲究:2.10及之前的版本对Windows原生支持比较友好,不需要额外配WSL;2.11开始,Windows版本默认走DirectML,有些环境反而会出现莫名其妙的算子兼容问题。如果你用的是Linux服务器,那2.10到2.15问题都不大。安装建议直接一条命令装完:pip install tensorflow==2.10 numpy pandas scikit-learn matplotlibCPU训练能不能跑?能跑,就是慢。这个项目的数据量在几万条样本这个量级,网络参数不算大,CPU训练一个epoch大概要一两分钟。如果有NVIDIA显卡,装上CUDA和cuDNN之后,同一个epoch能压到十几秒,整体效率提升非常明显。2.2 数据清洗与缺失值处理原始CSV数据下载下来以后,第一件事不是急着灌进模型,而是先做缺失值和异常值的处理。我拿到的数据里,缺失值类型有两种:一种是整行记录为空,另一种是某个字段单独为空。前者直接删除或者用邻域均值填充,后者要看字段的重要性分开处理。处理缺失值,我反复用过几种方案,最后稳定下来的是分字段处理:PM2.5浓度缺失:不超过连续3小时,用前后两小时线性插值风速、风向缺失:用当天同时段的滑动平均值填充气压、气温缺失:优先用气象站同期记录拟合补全连续缺失超过6小时:直接删除对应时间段,不做插值,因为这段时间的插值结果对人眼是平滑的,但对模型来讲就是造假数据风向这个字段容易踩坑。原始记录里风向可能是0到360的角度值,也可能是16方位字符串。如果用角度值,要考虑角度数据的环形特性,0度和360度其实是同一个方向,直接喂给模型,模型会误以为两者差异极大。我用的是把风向拆成两个分量:风向正弦和风向余弦,这样既保留了方向信息,又消除了0/360断裂问题。2.3 归一化与滑动窗口样本构造归一化方法的选择直接影响训练效果。我对比过StandardScaler和MinMaxScaler,最后选的是MinMaxScaler,把数值缩放到0到1之间。原因很简单:PM2.5浓度和气象要素的数值尺度差异太大,温度可以在-10到40之间浮动,浓度可能冲到300以上,不归一化的话模型会把注意力全放在数值大的特征上。但要特别提醒一个数据泄漏的坑:MinMaxScaler的fit操作只能在训练集上做,然后用训练集上计算出的最小值、最大值去transform验证集和测试集。如果先对整个数据集做fit再切分,相当于验证集和测试集的信息提前泄露给了模型,评估指标会虚高,部署到真实环境后立刻露馅。滑动窗口的设计是整个预处理的核心环节。预测任务定义为:用前T小时的观测数据预测未来H小时的PM2.5浓度。我尝试过多种窗口组合,最终T选了72,H选了1,也就是看过去三天的数据,预测下一个小时。这样定义的好处是训练样本的构造比较直接,模型学习的是“从长窗口提炼特征到短期输出”的映射关系。样本构造的Python代码大致如下:def create_sequences(data, target_idx, lookback=72): X, y = [], [] for i in range(lookback, len(data)): X.append(data[i-lookback:i]) y.append(data[i, target_idx]) return np.array(X), np.array(y)这里注意data必须是经过归一化的完整二维数组,target_idx是PM2.5浓度所在的列索引。这样每一个样本都是一个形状为(72, 特征数)的矩阵,标签是对应时刻的浓度值。2.4 数据划分要有时间观念机器学习标准做法是随机打乱划分训练集、验证集、测试集,但这个套路在时间序列上是大忌。PM2.5浓度有强自相关性,今天的数据和昨天强相关,如果把明天的样本混进训练集、今天的样本放进测试集,模型等于直接偷看了答案。我的划分策略是按时间顺序切分:前70%作为训练集,中间15%作为验证集,最后15%作为测试集。这样测试集永远在时间上晚于训练集,模型面对的是真正意义上的“未来数据”,评估结果才可信。3. 模型搭建与训练配置3.1 核心网络结构解析模型结构的确定不是一蹴而就的,我至少改了四版。第一版用一层Conv1D加一层LSTM,训练稳定但精度一般;第二版加深了CNN部分,用了两层卷积加一层池化,结果发现池化会把时域信息过度压缩,反而丢掉了关键的浓度峰值特征;第三版保留两层卷积但去掉了池化,改用步长控制序列长度,效果开始好转;第四版也就是最终版,把CNN输出接入Dropout再接LSTM,再加一层全连接,就稳定定型了。最终版结构如下表所示:层类型参数/说明InputInputLayer输入形状(72, 特征数)Conv1D_1一维卷积64个卷积核,核大小3,ReLU激活Conv1D_2一维卷积32个卷积核,核大小3,ReLU激活Dropout随机失活丢弃率0.2LSTM长短期记忆64个隐藏单元,返回序列关闭Dropout随机失活丢弃率0.2Dense_1全连接32个神经元,ReLU激活Dense_2全连接1个神经元,线性激活一维卷积核大小选3,代表每次扫描相邻三个时刻的特征,这个设计是卷积神经网络在时间序列上的常用做法。卷积核数量我对比过32、64、128三档,64在效果和训练速度的平衡点上最好。第一层卷积用64个核是为了从原始特征空间里提取足够丰富的局部模式;第二层用32个核做进一步压缩提炼。关于卷积之后是否接池化层,我说一下我的判断:MaxPooling1D在图像任务里很常用,因为它能压缩空间尺寸、保留主要特征。但在时间序列里,池化会直接抹掉短窗口内的小幅波动,而污染浓度的短期尖峰恰恰是预测中最需要留意的信息。所以我在CNN和LSTM之间不插池化层,让卷积输出的完整时域长度全部交给LSTM处理。3.2 训练参数与回调机制训练参数的配置决定了模型是收敛还是震荡。我的配置如下:model.compile( optimizer=Adam(learning_rate=0.001), loss='mse', metrics=['mae'] ) early_stop = EarlyStopping( monitor='val_loss', patience=15, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=0.00005 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[early_stop, reduce_lr] )学习率初始设0.001,这是Adam优化器最常用的起点。我试过从0.01开始,结果训练前期Loss震荡得很厉害,收敛速度反而更慢。回调机制里,EarlyStopping的restore_best_weights参数相当关键。如果不开启,训练在验证集Loss不再下降时停止,但保存的权重可能是最后一个epoch的,而不是验证集Loss最低的那个epoch的,最终模型精度会打折扣。ReduceLROnPlateau的作用是当验证Loss连续5个epoch不下降时,把学习率减半,让模型在接近最优解时能够更精细地调整参数。这个机制帮助我在后期训练中明显减少了Loss的反复波动。训练轮数设100是上限,实际训练中大约在第40到60轮之间触发早停。因为数据本身有周期性规律,加上Dropout抑制过拟合,这个收敛速度在我的预期之内。3.3 模型结构可视化与形状核对在正式训练之前,还有一步容易跳过但很值得做的事情:打印模型结构,核对每一层的输出形状。model.summary()summary里要重点看两个地方:LSTM层的输入形状是否为(None, 72, 32),其中最后一个维度是第二层卷积的输出通道数;Dense层的输出形状是否为(None, 1)。形状匹配不上主要是因为卷积步长设置不对导致序列长度发生了变化,下面常见问题章节会专门展开。模型训练完成后,我还习惯把训练过程的Loss曲线画出来。一个健康的训练曲线应该是:训练Loss和验证Loss平缓下降,两者之间的差距不大。如果训练Loss持续下降但验证Loss在某个点之后掉头向上,那就是过拟合的典型信号。4. 评估指标与预测结果分析4.1 RMSE、MAE、R²到底怎么用训练完成后,我用测试集做了最终评估,三个核心指标的结果如下:RMSE:约18.9 μg/m³MAE:约12.4 μg/m³R²:约0.87单独看一个指标意义有限,三个指标放在一起才能反映出模型的全貌。RMSE对异常值敏感,它把大误差通过平方放大了。重污染时段的预测如果偏离真实值很多,RMSE会迅速拉高。测试集RMSE在18.9左右,意味着模型在部分时段会出现接近40到50 μg/m³的单点偏差,而这些时段大概率对应的是浓度剧烈变化的污染过程。MAE是更贴近日常体验的指标,12.4 μg/m³的平均绝对误差,对于空气质量预测任务来说属于中等偏可用的水平。如果应用场景只需要知道大致污染级别(优、良、轻度、中度),这个精度足够区分出主要等级;但如果要精确到每小时的浓度值做健康建议,误差还是偏大。R²达到0.87,含义是模型解释了测试集浓度方差的大约87%。剩余13%的方差来自模型没有捕捉到的因素。PM2.5浓度受到排放源变动、二次气溶胶生成过程等复杂因素影响,这部分的随机性很难用常规气象和浓度历史数据完全解释。4.2 预测曲线怎么读才有意义光看一堆数字不够直观,我习惯把测试集上连续一周的真实值和预测值画到同一张图里。从曲线上能观察到的现象很有价值:在浓度平缓变化的时段,预测曲线和真实曲线几乎重合,这说明模型对稳态污染过程的学习效果好;在浓度快速上冲或下坠的转折点,预测曲线往往慢半拍,也就是预测滞后现象。比如某天傍晚风力突然增强,真实浓度从180快速降到80,模型可能还停留在150左右。这个滞后规律在几乎所有时序预测模型里都存在。原因是模型做预测时,最依赖的还是最近几个小时的浓度惯性,外部气象突变的信息要等下一时刻才能反映到输入里。所以当你看到预测曲线在转折点偏差大,别急着否定模型,先看看转折点之外平稳时段的拟合效果。我还做了一个误差分布统计,发现预测误差的分布近似正态,但右尾明显偏长,也就是存在少数大幅低估的样本。这部分样本集中在极重污染时段,比如PM2.5超过300的情况下,误差能达到80以上。这说明模型在极端情形下的外推能力有限,而这恰恰也是整个领域公认的难点。5. 完整预测流程与代码复用经验5.1 加载模型做单步预测模型训练好以后,model.save('pm25_cnn_lstm.h5')保存整个模型。对新数据做预测时,流程是:加载新数据,套用之前preprocessing和归一化的流程,构造最后一个窗口的输入,然后调用model.predict。预测代码的骨架大致如下:# 假设 df 是最近的原始观测数据,已经是清洗后的DataFrame # preprocess和feature_engineering函数复用训练阶段的逻辑 data_matrix = feature_engineering(df) last_window = data_matrix[-lookback:] last_window_scaled = scaler.transform(last_window) input_batch = np.expand_dims(last_window_scaled, axis=0) pred_scaled = model.predict(input_batch)[0, 0] pred_value = scaler.inverse_transform([[pred_scaled]])[0, 0]注意一个细节:scaler是在训练阶段fit在训练集上的,预测阶段只能直接transform,不能再fit一次,否则归一化的基准已经变了,预测结果没有可比性。5.2 换数据集复用的三个坑如果想把这套代码迁移到其他城市或者其他污染物预测上,直接换数据大概率会翻车,需要特别检查这几点。第一是特征对齐。不同城市的气象数据字段名可能不一样,单位也可能不同,比如风速有的是m/s,有的是km/h。加载数据以后先打印列名,逐项确认单位一致再进模型。第二是时间粒度。这套代码是按小时数据定制的,如果新数据是日均值,窗口72就完全没有意义。日均数据需要的窗口要重新实验确定,一般30到60天比较合理。第三是本地气候差异。北京的污染规律受秋冬季供暖和地形影响很大,搬到全年湿度偏高、污染季节性不明显的地区,模型精度会显著下降。这种情况下不要指望代码原封不动跑出相同效果,建议重新做数据探索,必要时调整卷积核数和LSTM单元数。6. 常见问题与调试实录6.1 训练Loss居高不下训练过程中最常碰到的就是Loss下降很慢甚至不降。我先排查数据预处理,检查归一化是否漏了某一列,原始数据里是否存在极大的异常值,这些都会让模型无所适从。数据没有问题的话,下一步看学习率,把初始学习率从0.001调到0.0001,多数情况下能解决Loss震荡的问题。还有一种情况是输入特征包含了完全没处理过的非数值列,比如风向字符串,模型无法处理就直接崩溃,这个在预处理阶段就要拦截。6.2 预测曲线滞后严重前面提到滞后是标配现象,但滞后严重到一定程度说明模型结构有问题。如果预测值几乎就是把上一小时的真实值平移过来,说明模型没有学到特征和结果之间的因果关系。我的处理办法是强化CNN部分的特征提取——增加卷积核数量或者再堆一层卷积,同时检查LSTM隐藏单元数是否偏小导致记忆容量不足。另一个有效手段是缩短预测步长,先把下一小时预测准,再逐步尝试多步预测。6.3 维度报错和显存不足问题Keras报错中最常见的就是形状不匹配。ValueError: Input 0 of layer "conv1d" is incompatible with the layer——这类问题十有八九是输入样本的最后一个维度跟卷积层的输入通道数不一致。解决方法是打印X_train.shape,确认形态是(样本数, 72, 特征数),再回头核对模型第一层的输入input_shape=(72, 特征数)。显存不足的报错在Windows本地环境也比较常见。一个batch_size为64的模型训练,会一次性加载全部训练样本,如果电脑内存只有8G很可能直接卡死。处理方法:把batch_size降到32或者16,同时把数据切分成块,不要一次性把全部序列加载到内存里。用tf.data.Dataset做按批加载,可以在很大程度上缓解内存压力。6.4 过拟合问题模型参数多、训练数据相对有限,过拟合基本躲不掉。我在保留Dropout的同时,一开始也用了L2正则化,但实测效果一般反而拉低精度。比较有效的一个操作是减小LSTM隐藏单元数,从128降到64,验证Loss明显改善。另一个实用的方法是在数据层面做增强,把训练数据按小时粒度做小幅度时间平移,相当于扩充了样本量。6.5 训练速度过慢CPU上训练这个模型做实验确实比较煎熬。我的建议是先用小数据集跑通流程,比如只用一年的数据、窗口缩到24小时,确认代码没问题、指标合理,再切换到完整数据训练。这样单次实验循环的时间能从半小时压缩到几分钟,调试效率翻倍。常见问题速查表为了方便排查,我把上面提到的高频问题整理成一张表,遇到问题时直接对照查:现象可能原因解决方案Loss不下降特征未归一化、有异常值检查预处理,改用更小学习率预测曲线滞后CNN特征提取不足增加卷积层或核数,缩短预测步长维度报错输入形状与模型不匹配打印X_train.shape核对input_shape内存溢出batch过大、数据全量加载降低batch,使用tf.data.Dataset按批读取验证Loss回升模型过拟合增大Dropout率、减少LSTM单元数训练慢CPU有限、数据量大先小数据集跑通再全量训练另外记录一个自己在调整过程中使用的经验:调节模型结构时,一次只改一个变量,不要同时调整卷积层数量和LSTM单元数。否则模型表现发生变化时,你根本判断不出是哪一个改动起的作用。我后期所有实验都建立在一个简单的表格上,每一行记录网络结构、关键超参数、训练Loss、验证Loss、测试集RMSE和R²。想要系统性地调优,这样的实验管理习惯很重要。关于多步预测的一点扩展思考上面的代码做的是单步预测,也就是用过去72小时预测未来1小时。在实际应用场景里,用户往往想知道未来12小时甚至24小时的浓度走势。把单步模型改造成多步预测,有几个方案可以做:第一种是递归预测,把预测出的下一小时浓度作为输入,继续预测再下一小时,这种方式误差会累积,预测步长越远越不靠谱;第二种是直接输出多步,把模型最后一层改成输出序列,比如一次输出未来12小时的浓度值,这种方式需要重新设计标签结构,但可以有效避免误差累积;第三种是seq2seq结构,用编码器LSTM读完整个窗口,再用解码器LSTM逐时刻生成预测序列,我在后续迭代中尝试过这条路。我个人试下来,多步预测的准确率随着预测步长增加下降得很快,到6小时以后,模型的预测结果基本只有参考价值,精确度已经不适合做决策支撑了。所以建议不要一开始就追求24小时预测,先把1到3小时的短时预报做扎实,再逐步扩展预测时长。实际部署时的另一个问题是模型更新频率。空气质量预测模型不建议训练一次用一年,污染源结构、气象规律都在变化。我的做法是每周用最新数据做一次增量训练,把最近一周的真实观测加入到训练集,重新走一遍训练流程,更新模型权重。这个习惯也值得所有跑时序模型的朋友参考。
聊起空气质量预测,北京的PM2.5浓度是很多人绕不开的话题。我去年花了两个多月时间,从数据抓取到模型调参,把一套基于CNN-LSTM的雾霾浓度预测完整代码跑通了,效果在可接受范围之内。今天这篇就用一次真实的项目复盘的方式,把完整流程、代码细节、踩过的坑全部摊开讲清楚,希望能给正在做时序预测或者环境数据建模的朋友一些可以直接抄走的经验。这套代码能解决什么问题?简单说:给你过去一段时间的小时级PM2.5浓度以及气温、气压、风速、风向、降雨等气象数据,模型能够预测未来几小时的污染浓度走势。项目适合两类人看:一类是深度学习入门者,想搞懂CNN和LSTM怎么配合处理时间序列;另一类是环境科学或数据分析方向的人,想知道这种混合模型在实际污染物预测任务里到底能跑出什么效果、有哪些坑。1. 项目设计与技术选型思路1.1 为什么是CNN-LSTM而不是单纯某个模型刚开始拿到这个题目的时候,我第一反应想的是直接用LSTM做。毕竟PM2.5浓度是标准的时间序列,LSTM天生就是干这个的。但实际跑下来以后发现一个问题——单用LSTM对突发性的浓度变化反应偏慢,模型倾向于把上一时刻的浓度值直接平滑地带到下一时刻,预测曲线整体守成有余、锐度不足。后来换了个思路:把CNN加进去做前置特征提取。CNN在图像领域很强势,但在时间序列上它干的是另一件事——用一维卷积核去扫描输入窗口,把局部变化模式识别出来。打个比方:LSTM像一个记忆力很好的人,能记住前几天的污染走势,但眼神不太好;CNN就像一个视力极好的观察员,能在短窗口里迅速发现“这阵子风速在加大、湿度在上升、浓度在下降”这类局部变换特征。两个人配合,观察员先扫一遍,把有价值的局部模式提炼出来,记忆员再基于这些模式去推演长程规律。这套配合在物理意义上也说得通。雾霾的形成和消散不是线性过程,它有积累效应也有突变节点,比如冷锋过境前后的浓度骤降、早晚高峰时段的短时攀升。CNN抓的是这种短程模式特征,LSTM负责的是跨小时甚至跨天的趋势记忆。两者各管一段,职责边界清楚。1.2 数据集与评估基准的选择刚开始我图省事,想直接爬其他平台已经清洗好的数据。后来发现单一数据源不太够用,就手动合并了两部分:污染物浓度数据来自某公开环境监测站点数据库的逐小时记录,气象数据来自对应的地面气象观测站。时间跨度取了大约4年,记录频率是每小时一条,字段包括PM2.5浓度、PM10、SO₂、NO₂、CO、O₃、气温、气压、露点、风速、风向、累积降雨量。选数据集的时候有两条经验值得说:第一,时间跨度要足够长。至少跨过一个完整年,这样才能覆盖北方冬季供暖季的高污染时段和夏季的清洁时段,模型才能学到季节规律。第二,数据要尽量在“同一地理尺度”上。北京的空气污染在空间上分布很不均匀,如果污染物数据是城区的站点,气象数据却取了郊区站点,风向上的错位会影响模型学习。我最后尽量选了同一坐标系下能关联上的数据源,保证时序记录能对齐。评估指标上,回归任务我不建议只看一个。RMSE、MAE、R²三个指标要放一起看。RMSE对大的预测误差特别敏感,能反映出模型在重污染时段是否出现离谱的偏差;MAE给出的是平均绝对误差,更贴近日常感知;R²给出的是模型对浓度方差的解释比例。后面在评估章节我会详细展开这三者各自怎么看。1.3 代码结构怎么组织才不容易翻车代码组织这块,我踩过一整个重写的坑。最开始我把数据清洗、模型构建、训练、绘图全写在一个脚本里,结果换一组参数就要全局翻找,调试成本极高。第二个版本我做了模块化拆分,按下面的结构组织:air_quality_project/ ├── data/ │ ├── raw/ # 原始数据存放 │ └── processed/ # 清洗后的数据 ├── src/ │ ├── data_preprocessing.py # 加载与清洗 │ ├── build_dataset.py # 构造滑动窗口样本 │ ├── model.py # CNN-LSTM模型定义 │ ├── train.py # 训练流程 │ └── evaluate.py # 评估与绘图 ├── models/ # 保存训练好的权重 └── results/ # 图表与指标输出这样的好处有几个:换数据集时只动data_preprocessing.py,调网络结构只动model.py,实验记录集中在一个目录,复盘容易。虽然项目初期多了些文件跳转成本,但后续调参的效率提升绝对值回票价。2. 环境准备与数据预处理细节2.1 依赖库与版本选择跑这套代码,建议直接用Anaconda建一个独立环境,别把系统Python环境搞乱了。我用的核心依赖和版本如下:Python 3.9TensorFlow 2.10(Keras内置)NumPy 1.21Pandas 1.4scikit-learn 1.1Matplotlib 3.5TensorFlow版本的选择有个讲究:2.10及之前的版本对Windows原生支持比较友好,不需要额外配WSL;2.11开始,Windows版本默认走DirectML,有些环境反而会出现莫名其妙的算子兼容问题。如果你用的是Linux服务器,那2.10到2.15问题都不大。安装建议直接一条命令装完:pip install tensorflow==2.10 numpy pandas scikit-learn matplotlibCPU训练能不能跑?能跑,就是慢。这个项目的数据量在几万条样本这个量级,网络参数不算大,CPU训练一个epoch大概要一两分钟。如果有NVIDIA显卡,装上CUDA和cuDNN之后,同一个epoch能压到十几秒,整体效率提升非常明显。2.2 数据清洗与缺失值处理原始CSV数据下载下来以后,第一件事不是急着灌进模型,而是先做缺失值和异常值的处理。我拿到的数据里,缺失值类型有两种:一种是整行记录为空,另一种是某个字段单独为空。前者直接删除或者用邻域均值填充,后者要看字段的重要性分开处理。处理缺失值,我反复用过几种方案,最后稳定下来的是分字段处理:PM2.5浓度缺失:不超过连续3小时,用前后两小时线性插值风速、风向缺失:用当天同时段的滑动平均值填充气压、气温缺失:优先用气象站同期记录拟合补全连续缺失超过6小时:直接删除对应时间段,不做插值,因为这段时间的插值结果对人眼是平滑的,但对模型来讲就是造假数据风向这个字段容易踩坑。原始记录里风向可能是0到360的角度值,也可能是16方位字符串。如果用角度值,要考虑角度数据的环形特性,0度和360度其实是同一个方向,直接喂给模型,模型会误以为两者差异极大。我用的是把风向拆成两个分量:风向正弦和风向余弦,这样既保留了方向信息,又消除了0/360断裂问题。2.3 归一化与滑动窗口样本构造归一化方法的选择直接影响训练效果。我对比过StandardScaler和MinMaxScaler,最后选的是MinMaxScaler,把数值缩放到0到1之间。原因很简单:PM2.5浓度和气象要素的数值尺度差异太大,温度可以在-10到40之间浮动,浓度可能冲到300以上,不归一化的话模型会把注意力全放在数值大的特征上。但要特别提醒一个数据泄漏的坑:MinMaxScaler的fit操作只能在训练集上做,然后用训练集上计算出的最小值、最大值去transform验证集和测试集。如果先对整个数据集做fit再切分,相当于验证集和测试集的信息提前泄露给了模型,评估指标会虚高,部署到真实环境后立刻露馅。滑动窗口的设计是整个预处理的核心环节。预测任务定义为:用前T小时的观测数据预测未来H小时的PM2.5浓度。我尝试过多种窗口组合,最终T选了72,H选了1,也就是看过去三天的数据,预测下一个小时。这样定义的好处是训练样本的构造比较直接,模型学习的是“从长窗口提炼特征到短期输出”的映射关系。样本构造的Python代码大致如下:def create_sequences(data, target_idx, lookback=72): X, y = [], [] for i in range(lookback, len(data)): X.append(data[i-lookback:i]) y.append(data[i, target_idx]) return np.array(X), np.array(y)这里注意data必须是经过归一化的完整二维数组,target_idx是PM2.5浓度所在的列索引。这样每一个样本都是一个形状为(72, 特征数)的矩阵,标签是对应时刻的浓度值。2.4 数据划分要有时间观念机器学习标准做法是随机打乱划分训练集、验证集、测试集,但这个套路在时间序列上是大忌。PM2.5浓度有强自相关性,今天的数据和昨天强相关,如果把明天的样本混进训练集、今天的样本放进测试集,模型等于直接偷看了答案。我的划分策略是按时间顺序切分:前70%作为训练集,中间15%作为验证集,最后15%作为测试集。这样测试集永远在时间上晚于训练集,模型面对的是真正意义上的“未来数据”,评估结果才可信。3. 模型搭建与训练配置3.1 核心网络结构解析模型结构的确定不是一蹴而就的,我至少改了四版。第一版用一层Conv1D加一层LSTM,训练稳定但精度一般;第二版加深了CNN部分,用了两层卷积加一层池化,结果发现池化会把时域信息过度压缩,反而丢掉了关键的浓度峰值特征;第三版保留两层卷积但去掉了池化,改用步长控制序列长度,效果开始好转;第四版也就是最终版,把CNN输出接入Dropout再接LSTM,再加一层全连接,就稳定定型了。最终版结构如下表所示:层类型参数/说明InputInputLayer输入形状(72, 特征数)Conv1D_1一维卷积64个卷积核,核大小3,ReLU激活Conv1D_2一维卷积32个卷积核,核大小3,ReLU激活Dropout随机失活丢弃率0.2LSTM长短期记忆64个隐藏单元,返回序列关闭Dropout随机失活丢弃率0.2Dense_1全连接32个神经元,ReLU激活Dense_2全连接1个神经元,线性激活一维卷积核大小选3,代表每次扫描相邻三个时刻的特征,这个设计是卷积神经网络在时间序列上的常用做法。卷积核数量我对比过32、64、128三档,64在效果和训练速度的平衡点上最好。第一层卷积用64个核是为了从原始特征空间里提取足够丰富的局部模式;第二层用32个核做进一步压缩提炼。关于卷积之后是否接池化层,我说一下我的判断:MaxPooling1D在图像任务里很常用,因为它能压缩空间尺寸、保留主要特征。但在时间序列里,池化会直接抹掉短窗口内的小幅波动,而污染浓度的短期尖峰恰恰是预测中最需要留意的信息。所以我在CNN和LSTM之间不插池化层,让卷积输出的完整时域长度全部交给LSTM处理。3.2 训练参数与回调机制训练参数的配置决定了模型是收敛还是震荡。我的配置如下:model.compile( optimizer=Adam(learning_rate=0.001), loss='mse', metrics=['mae'] ) early_stop = EarlyStopping( monitor='val_loss', patience=15, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=0.00005 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[early_stop, reduce_lr] )学习率初始设0.001,这是Adam优化器最常用的起点。我试过从0.01开始,结果训练前期Loss震荡得很厉害,收敛速度反而更慢。回调机制里,EarlyStopping的restore_best_weights参数相当关键。如果不开启,训练在验证集Loss不再下降时停止,但保存的权重可能是最后一个epoch的,而不是验证集Loss最低的那个epoch的,最终模型精度会打折扣。ReduceLROnPlateau的作用是当验证Loss连续5个epoch不下降时,把学习率减半,让模型在接近最优解时能够更精细地调整参数。这个机制帮助我在后期训练中明显减少了Loss的反复波动。训练轮数设100是上限,实际训练中大约在第40到60轮之间触发早停。因为数据本身有周期性规律,加上Dropout抑制过拟合,这个收敛速度在我的预期之内。3.3 模型结构可视化与形状核对在正式训练之前,还有一步容易跳过但很值得做的事情:打印模型结构,核对每一层的输出形状。model.summary()summary里要重点看两个地方:LSTM层的输入形状是否为(None, 72, 32),其中最后一个维度是第二层卷积的输出通道数;Dense层的输出形状是否为(None, 1)。形状匹配不上主要是因为卷积步长设置不对导致序列长度发生了变化,下面常见问题章节会专门展开。模型训练完成后,我还习惯把训练过程的Loss曲线画出来。一个健康的训练曲线应该是:训练Loss和验证Loss平缓下降,两者之间的差距不大。如果训练Loss持续下降但验证Loss在某个点之后掉头向上,那就是过拟合的典型信号。4. 评估指标与预测结果分析4.1 RMSE、MAE、R²到底怎么用训练完成后,我用测试集做了最终评估,三个核心指标的结果如下:RMSE:约18.9 μg/m³MAE:约12.4 μg/m³R²:约0.87单独看一个指标意义有限,三个指标放在一起才能反映出模型的全貌。RMSE对异常值敏感,它把大误差通过平方放大了。重污染时段的预测如果偏离真实值很多,RMSE会迅速拉高。测试集RMSE在18.9左右,意味着模型在部分时段会出现接近40到50 μg/m³的单点偏差,而这些时段大概率对应的是浓度剧烈变化的污染过程。MAE是更贴近日常体验的指标,12.4 μg/m³的平均绝对误差,对于空气质量预测任务来说属于中等偏可用的水平。如果应用场景只需要知道大致污染级别(优、良、轻度、中度),这个精度足够区分出主要等级;但如果要精确到每小时的浓度值做健康建议,误差还是偏大。R²达到0.87,含义是模型解释了测试集浓度方差的大约87%。剩余13%的方差来自模型没有捕捉到的因素。PM2.5浓度受到排放源变动、二次气溶胶生成过程等复杂因素影响,这部分的随机性很难用常规气象和浓度历史数据完全解释。4.2 预测曲线怎么读才有意义光看一堆数字不够直观,我习惯把测试集上连续一周的真实值和预测值画到同一张图里。从曲线上能观察到的现象很有价值:在浓度平缓变化的时段,预测曲线和真实曲线几乎重合,这说明模型对稳态污染过程的学习效果好;在浓度快速上冲或下坠的转折点,预测曲线往往慢半拍,也就是预测滞后现象。比如某天傍晚风力突然增强,真实浓度从180快速降到80,模型可能还停留在150左右。这个滞后规律在几乎所有时序预测模型里都存在。原因是模型做预测时,最依赖的还是最近几个小时的浓度惯性,外部气象突变的信息要等下一时刻才能反映到输入里。所以当你看到预测曲线在转折点偏差大,别急着否定模型,先看看转折点之外平稳时段的拟合效果。我还做了一个误差分布统计,发现预测误差的分布近似正态,但右尾明显偏长,也就是存在少数大幅低估的样本。这部分样本集中在极重污染时段,比如PM2.5超过300的情况下,误差能达到80以上。这说明模型在极端情形下的外推能力有限,而这恰恰也是整个领域公认的难点。5. 完整预测流程与代码复用经验5.1 加载模型做单步预测模型训练好以后,model.save('pm25_cnn_lstm.h5')保存整个模型。对新数据做预测时,流程是:加载新数据,套用之前preprocessing和归一化的流程,构造最后一个窗口的输入,然后调用model.predict。预测代码的骨架大致如下:# 假设 df 是最近的原始观测数据,已经是清洗后的DataFrame # preprocess和feature_engineering函数复用训练阶段的逻辑 data_matrix = feature_engineering(df) last_window = data_matrix[-lookback:] last_window_scaled = scaler.transform(last_window) input_batch = np.expand_dims(last_window_scaled, axis=0) pred_scaled = model.predict(input_batch)[0, 0] pred_value = scaler.inverse_transform([[pred_scaled]])[0, 0]注意一个细节:scaler是在训练阶段fit在训练集上的,预测阶段只能直接transform,不能再fit一次,否则归一化的基准已经变了,预测结果没有可比性。5.2 换数据集复用的三个坑如果想把这套代码迁移到其他城市或者其他污染物预测上,直接换数据大概率会翻车,需要特别检查这几点。第一是特征对齐。不同城市的气象数据字段名可能不一样,单位也可能不同,比如风速有的是m/s,有的是km/h。加载数据以后先打印列名,逐项确认单位一致再进模型。第二是时间粒度。这套代码是按小时数据定制的,如果新数据是日均值,窗口72就完全没有意义。日均数据需要的窗口要重新实验确定,一般30到60天比较合理。第三是本地气候差异。北京的污染规律受秋冬季供暖和地形影响很大,搬到全年湿度偏高、污染季节性不明显的地区,模型精度会显著下降。这种情况下不要指望代码原封不动跑出相同效果,建议重新做数据探索,必要时调整卷积核数和LSTM单元数。6. 常见问题与调试实录6.1 训练Loss居高不下训练过程中最常碰到的就是Loss下降很慢甚至不降。我先排查数据预处理,检查归一化是否漏了某一列,原始数据里是否存在极大的异常值,这些都会让模型无所适从。数据没有问题的话,下一步看学习率,把初始学习率从0.001调到0.0001,多数情况下能解决Loss震荡的问题。还有一种情况是输入特征包含了完全没处理过的非数值列,比如风向字符串,模型无法处理就直接崩溃,这个在预处理阶段就要拦截。6.2 预测曲线滞后严重前面提到滞后是标配现象,但滞后严重到一定程度说明模型结构有问题。如果预测值几乎就是把上一小时的真实值平移过来,说明模型没有学到特征和结果之间的因果关系。我的处理办法是强化CNN部分的特征提取——增加卷积核数量或者再堆一层卷积,同时检查LSTM隐藏单元数是否偏小导致记忆容量不足。另一个有效手段是缩短预测步长,先把下一小时预测准,再逐步尝试多步预测。6.3 维度报错和显存不足问题Keras报错中最常见的就是形状不匹配。ValueError: Input 0 of layer "conv1d" is incompatible with the layer——这类问题十有八九是输入样本的最后一个维度跟卷积层的输入通道数不一致。解决方法是打印X_train.shape,确认形态是(样本数, 72, 特征数),再回头核对模型第一层的输入input_shape=(72, 特征数)。显存不足的报错在Windows本地环境也比较常见。一个batch_size为64的模型训练,会一次性加载全部训练样本,如果电脑内存只有8G很可能直接卡死。处理方法:把batch_size降到32或者16,同时把数据切分成块,不要一次性把全部序列加载到内存里。用tf.data.Dataset做按批加载,可以在很大程度上缓解内存压力。6.4 过拟合问题模型参数多、训练数据相对有限,过拟合基本躲不掉。我在保留Dropout的同时,一开始也用了L2正则化,但实测效果一般反而拉低精度。比较有效的一个操作是减小LSTM隐藏单元数,从128降到64,验证Loss明显改善。另一个实用的方法是在数据层面做增强,把训练数据按小时粒度做小幅度时间平移,相当于扩充了样本量。6.5 训练速度过慢CPU上训练这个模型做实验确实比较煎熬。我的建议是先用小数据集跑通流程,比如只用一年的数据、窗口缩到24小时,确认代码没问题、指标合理,再切换到完整数据训练。这样单次实验循环的时间能从半小时压缩到几分钟,调试效率翻倍。常见问题速查表为了方便排查,我把上面提到的高频问题整理成一张表,遇到问题时直接对照查:现象可能原因解决方案Loss不下降特征未归一化、有异常值检查预处理,改用更小学习率预测曲线滞后CNN特征提取不足增加卷积层或核数,缩短预测步长维度报错输入形状与模型不匹配打印X_train.shape核对input_shape内存溢出batch过大、数据全量加载降低batch,使用tf.data.Dataset按批读取验证Loss回升模型过拟合增大Dropout率、减少LSTM单元数训练慢CPU有限、数据量大先小数据集跑通再全量训练另外记录一个自己在调整过程中使用的经验:调节模型结构时,一次只改一个变量,不要同时调整卷积层数量和LSTM单元数。否则模型表现发生变化时,你根本判断不出是哪一个改动起的作用。我后期所有实验都建立在一个简单的表格上,每一行记录网络结构、关键超参数、训练Loss、验证Loss、测试集RMSE和R²。想要系统性地调优,这样的实验管理习惯很重要。关于多步预测的一点扩展思考上面的代码做的是单步预测,也就是用过去72小时预测未来1小时。在实际应用场景里,用户往往想知道未来12小时甚至24小时的浓度走势。把单步模型改造成多步预测,有几个方案可以做:第一种是递归预测,把预测出的下一小时浓度作为输入,继续预测再下一小时,这种方式误差会累积,预测步长越远越不靠谱;第二种是直接输出多步,把模型最后一层改成输出序列,比如一次输出未来12小时的浓度值,这种方式需要重新设计标签结构,但可以有效避免误差累积;第三种是seq2seq结构,用编码器LSTM读完整个窗口,再用解码器LSTM逐时刻生成预测序列,我在后续迭代中尝试过这条路。我个人试下来,多步预测的准确率随着预测步长增加下降得很快,到6小时以后,模型的预测结果基本只有参考价值,精确度已经不适合做决策支撑了。所以建议不要一开始就追求24小时预测,先把1到3小时的短时预报做扎实,再逐步扩展预测时长。实际部署时的另一个问题是模型更新频率。空气质量预测模型不建议训练一次用一年,污染源结构、气象规律都在变化。我的做法是每周用最新数据做一次增量训练,把最近一周的真实观测加入到训练集,重新走一遍训练流程,更新模型权重。这个习惯也值得所有跑时序模型的朋友参考。