简介时间序列预测是机器学习在环境监测中的核心应用之一其效果依赖数据质量、特征构造与模型选择。城市空气质量预测通常涉及污染物浓度与气象数据的对齐、清洗以及周期特征编码等预处理环节。基于树模型与深度学习的混合方案可以平衡解释性与预测精度在像郑州这类具有明显季节污染特征的城市尤为有效。本文结合Python工程实践介绍从数据清洗、特征工程到LightGBM与LSTM建模的完整流程并讨论了峰值低估、季节漂移、环境差异等常见问题。通过合理的模块化设计可快速复现并迁移到其他城市的空气质量预测任务中为污染预警与决策支持提供可靠基线。1. 郑州空气质量预测为什么一个Python源码能省下你三个月的弯路拿到一个城市级别的空气质量预测任务第一反应往往不是算法选型而是数据从哪来、污染物浓度序列怎么处理、模型跑出来的结果怎么解释。郑州这个场景更有代表性既有秋冬季颗粒物浓度陡增的典型北方特征又有沙尘、静稳天气等突发扰动单纯套一个LSTM或者XGBoost很容易在换季时集体翻车。这个标题里的“模型设计源码”本质上是在给一套完整的工作流——从数据清洗到特征构造再到模型训练与评估——提供可复现的基线而不是某个单独的网络结构。适合正在做课程设计、毕业设计或者刚接手城市空气质量预测项目但不想从零开始踩数据坑的工程师。用Python把整条链路串起来好处是每个环节都能单独替换、单独调试这也是为什么这类项目几乎都选Python而不是R或MATLAB作为载体。2. 数据准备与污染特征构造把气象和浓度数据揉成模型能吃的形状2.1 郑州空气质量数据源怎么选国控站点与气象数据的对齐空气质量预测的第一步不是建模而是拿到一份时间粒度对齐、站点经纬度匹配、缺失值可控的数据集。郑州的国控站点数据可以从中国环境监测总站获取包含PM2.5、PM10、SO2、NO2、O3、CO六项污染物的小时浓度气象数据则需要从气象数据服务平台拉取温度、湿度、风速、风向、气压、降水六类要素。常见做法是以时间戳为对齐键把污染物浓度和气象观测值按小时粒度合并到同一张表里。数据清洗时优先处理两类脏数据一是站点维护导致的连续零值或恒定值这种不是真实浓度需要直接剔除二是极端沙尘天气下的PM10突增如果要做常规预测建议先标记为异常事件而不是参与训练否则模型会把沙尘误学成常规模式。import pandas as pd df_poll pd.read_csv(zhengzhou_air_hourly.csv, parse_dates[time]) df_met pd.read_csv(zhengzhou_meteo_hourly.csv, parse_dates[time]) df pd.merge(df_poll, df_met, ontime, howinner) df[pm25_lag1] df[PM2.5].shift(1) df[pm25_lag24] df[PM2.5].shift(24) df[humidity_rh] df[RH].clip(0, 100) df df.dropna(subset[PM2.5, TEMP, WIND_SPEED])这里使用等值连接就是为了让污染物和气象数据在相同时间点对齐而不是按索引硬拼。滞后特征加入滞后1小时和滞后24小时两列主要解决污染物浓度自相关性极强的问题——今天上午的PM2.5对下午的预测价值远大于十天前的数据。湿度做裁剪是因为仪器偶尔会输出超过100%的异常值不处理会让归一化后的特征分布出现长尾进而影响树模型的分裂点选择。2.2 特征工程里最容易被忽略的季节与星期编码单纯的数值特征不够空气质量有明显的周期性。郑州的采暖季从11月中旬开始一直到次年3月中旬这段时间的PM2.5浓度基线比夏季高出两到三倍不把这个先验知识告诉模型模型只能靠历史数据硬拟合。常见的做法是用正弦和余弦编码来表征小时、星期、月份三个周期而不是直接丢一个1~24的整数。小时值7和8在数值上只差1但在污染扩散条件上其实分属夜间静稳和早高峰两个完全不同的场景直接用原始整数会让树模型把“7”和“8”强行建造成相邻叶节点。用循环编码可以避免这种断裂。import numpy as np df[hour_sin] np.sin(2 * np.pi * df[hour] / 24) df[hour_cos] np.cos(2 * np.pi * df[hour] / 24) df[dow_sin] np.sin(2 * np.pi * df[dayofweek] / 7) df[dow_cos] np.cos(2 * np.pi * df[dayofweek] / 7) df[heating] df[month].apply(lambda m: 1 if m in [11, 12, 1, 2, 3] else 0) df[is_weekend] df[dayofweek].isin([5, 6]).astype(int)这个构造方式有几层意图。正弦余弦配对能让模型看到“23点和0点其实是相邻的”这个事实采暖季标志让LightGBM这类树模型在冬季分支上更容易单独拆出高浓度子群周末标记则是考虑到郑州城区周末的机动车流量和工地施工强度与工作日有明显差异这种差异在NO2和PM2.5上都会体现。特征构造完毕后再做一次针对时间顺序的划分训练集用前80%时段验证集和测试集按时间戳切后20%避免随机打散导致的数据泄露。3. 模型设计与训练方案从基线模型到LSTM的梯度下降细节3.1 为什么先跑LightGBM而不是直接上LSTM建模成本与解释性的平衡对于空气质量预测很多人一上来就选LSTM或GRU但实际落地时我建议先在同样的特征集上跑通LightGBM。原因有三个第一污染物浓度序列虽然有自相关性但气象特征和季节编码已经把大部分可解释的方差吞掉了剩下的时序残差并不像自然语言那样有复杂的长期依赖第二LightGBM的特征重要性输出可以直接用来做模型审计比如验证风速是否真的被模型当作最重要的扩散条件第三树模型对缺失值和特征尺度不敏感不需要做繁琐的归一化就能先跑出一个分数明确的上限参考。等LightGBM的R2稳定在0.7以上再引入LSTM对比这时候你能判断时序建模带来的增益是否值得那份额外的调参时间。import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit feature_cols [TEMP, WIND_SPEED, WIND_DIR, RH, PRESSURE, hour_sin, hour_cos, dow_sin, dow_cos, heating, is_weekend, pm25_lag1, pm25_lag24] X df[feature_cols].values y df[PM2.5].values tscv TimeSeriesSplit(n_splits3) for train_idx, val_idx in tscv.split(X): X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] model lgb.LGBMRegressor( n_estimators800, learning_rate0.05, num_leaves31, subsample0.8, colsample_bytree0.8, random_state42 ) model.fit(X_train, y_train, eval_set[(X_val, y_val)], callbacks[lgb.early_stopping(50)])TimeSeriesSplit在这里做的是扩展窗口验证而不是K折随机切分。每次训练集都比上一次多一段历史数据验证集始终在训练集之后这样得到的误差估计才贴近真实的“用过去预测未来”。early_stopping设为50轮是为了在验证集R2不再提升时及时停止防止树模型在一两个异常浓度日上反复学习而过拟合。num_leaves取31是个稳妥起点郑州的污染物浓度分布不是一个特别平滑的曲线叶子太少会欠拟合静稳天气下的高浓度样本太多则容易记住单次沙尘事件。3.2 LSTM输入构造滑窗张量与归一化参数的陷阱LightGBM跑通之后LSTM的价值在于直接建模连续时间步之间的依赖。构造训练样本时要用滑动窗口用过去24小时的序列数据预测未来1小时的PM2.5。关键陷阱是归一化必须在构造滑动窗口之前完成而且只能用训练集的均值与标准差。如果先切窗口再分别对每个窗口归一化等于把每个窗口单独变成零均值白噪声彻底毁掉浓度水平的绝对信息。另一个常见错误是把MinMaxScaler直接套在全量数据上再切窗口这会让验证集的信息泄露到训练过程中测试集上看似分数很高真正上线时立刻崩掉。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(df[feature_cols].values) y_scaled scaler.fit_transform(df[[PM2.5]].values) def create_sequences(X, y, window24): Xs, ys [], [] for i in range(len(X) - window): Xs.append(X[i:iwindow]) ys.append(y[iwindow]) return np.array(Xs), np.array(ys) X_seq, y_seq create_sequences(X_scaled, y_scaled) split int(len(X_seq) * 0.8) X_train_seq, X_test_seq X_seq[:split], X_seq[split:] y_train_seq, y_test_seq y_seq[:split], y_seq[split:]窗口长度24小时是一个兼顾记忆长度和训练样本量的折中。郑州的污染过程通常持续2到3天但起决定性作用的往往是最近12小时的气象变化和浓度累积24小时足够覆盖一次典型的静稳过程而不至于让样本量缩水太多。滑窗构造后LSTM输入的形状是(samples, 24, feature_count)。这里y_target取的是窗口结束点的下一时刻浓度而不是窗口内最后一小时的值也就是说模型看到截至t时刻的数据要猜的是t1小时。3.3 训练LSTM的必调参数学习率、隐藏层大小与早停策略import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model Sequential([ LSTM(64, return_sequencesTrue, input_shape(24, len(feature_cols))), Dropout(0.2), LSTM(32, return_sequencesFalse), Dropout(0.2), Dense(16, activationrelu), Dense(1) ]) model.compile(optimizertf.keras.optimizers.Adam(learning_rate0.001), lossmse, metrics[mae]) early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience5) history model.fit(X_train_seq, y_train_seq, validation_data(X_test_seq, y_test_seq), epochs100, batch_size64, callbacks[early_stop, reduce_lr])两层LSTM加上Dropout是这类任务的标准配置第一层return_sequencesTrue让第二层能继续读取完整的时序信息。隐藏层大小6432在中等规模数据集上不会太容易过拟合如果训练集只有几个月的小时数据3216会更稳健。ReduceLROnPlateau的关键作用是防止模型陷入震荡——污染物浓度序列噪声大验证集损失经常在一个平台期上下抖动如果学习率不降下来可能会永远跳不出那个局部最优点。batch_size取64是一个内存和梯度稳定性的平衡取值过小梯度更新方向抖动剧烈过大则每个epoch的更新次数太少。4. 源码架构与模块拆分把预测流程拆成能替换的四个功能块4.1 数据加载模块与配置分离改城市不用改代码一个能称为“模型设计源码”的项目不应该把数据路径、特征列表、模型参数全部硬编码在训练脚本里。我习惯拆出四个模块数据加载、特征工程、模型库、评估可视化。数据加载模块负责读原始CSV并输出统一格式的DataFrame特征工程模块接收DataFrame在内部完成滞后列、周期编码和归一化模型库模块把LightGBM和LSTM封装成两个独立的train_predict函数输入输出格式保持一致评估可视化模块统一产出R2、MAE曲线和预测对比图。当你想把这个郑州项目迁移到其他城市时只需要改配置文件和特征工程里的采暖季月份列表模型代码一行不动。# config.py DATA_PATH data/zhengzhou/raw/ FEATURE_COLS [TEMP, WIND_SPEED, WIND_DIR, RH, PRESSURE] TARGET PM2.5 LAG_HOURS [1, 24] WINDOW 24 TEST_RATIO 0.2 MODEL_SAVE_DIR models/zhengzhou/把配置单独拎出来看起来是个小动作但实际收益很大。你不需要在换数据集时去逐行搜索代码里的魔法数字凌晨三点调模型时少一次这种操作就能避免一次误改。配置里还能加上站点ID列表郑州有多个国控站点如果要按站点分别建模直接循环站点ID就能复用同一套训练流程。4.2 特征工程模块的接口设计输入DataFrame输出训练张量# feature_engineering.py def build_features(df, feature_cols, target, lag_hours(1, 24)): df df.sort_values(time).reset_index(dropTrue) for lag in lag_hours: df[f{target}_lag{lag}] df[target].shift(lag) df[hour] df[time].dt.hour df[dayofweek] df[time].dt.dayofweek df[month] df[time].dt.month df[hour_sin] np.sin(2 * np.pi * df[hour] / 24) df[hour_cos] np.cos(2 * np.pi * df[hour] / 24) df[dow_sin] np.sin(2 * np.pi * df[dayofweek] / 7) df[dow_cos] np.cos(2 * np.pi * df[dayofweek] / 7) df[heating] df[month].apply(lambda m: 1 if m in [11, 12, 1, 2, 3] else 0) feature_cols list(feature_cols) [c for c in df.columns if c.startswith((target, hour_, dow_, heating))] df df.dropna().reset_index(dropTrue) return df[feature_cols], df[target], feature_cols注意排序后的reset_index这一步很关键但常常被漏掉。如果原始CSV里的时间戳没有严格递增直接shift会拿到错误邻居的浓度值做滞后特征整个模型从源头上就是错的。特征列筛选用到startswith是为了自动把新加入的周期编码列都收进来避免每次加特征都要改接口。滞后特征生成的列名是动态的只要传入目标列名和滞后时长模块就能自动适配其他污染物比如把PM2.5换成O3做预测时这一整套特征逻辑可以直接复用。4.3 预测可视化模块怎么判断模型是“学会了”还是“背下来了”评估部分不能只打印一个R2数字必须画出预测值与真实值的时间序列对比。郑州的PM2.5浓度变化有个特点平稳时段模型误差不大一旦进入污染过程预测往往滞后1到2小时因为模型没学到“浓度开始上升”的拐点信号。可视化时把测试集的预测曲线和真实浓度曲线叠在一起能直观看到这种滞后。如果滞后出现在每次污染事件中说明模型过度依赖lag1特征需要增加风速或气压的趋势特征来辅助判断。import matplotlib.pyplot as plt def plot_prediction(y_true, y_pred, titlePM2.5 Prediction): plt.figure(figsize(14, 5)) plt.plot(y_true[:168], labelTrue, linewidth1.5) plt.plot(y_pred[:168], labelPred, linewidth1.2, alpha0.8) plt.xlabel(Hour) plt.ylabel(PM2.5 (ug/m3)) plt.title(title) plt.legend() plt.grid(alpha0.3) plt.tight_layout() plt.savefig(fresult_{title.replace( , _)}.png, dpi150)取前168小时画图是因为一周有168个小时刚好能看到完整的周循环。污染事件通常持续2到3天在一周窗口里能看到至少两次浓度爬升和消退过程足够评估模型在拐点处的表现。保存图片而不是直接plt.show是为了在服务器上跑实验时能回看结果这也是工程实现里的一个实用习惯。5. 避坑与常见问题排查郑州数据上最容易翻车的五个细节5.1 现象模型预测值在污染过程峰值处总是明显偏低郑州秋冬季的PM2.5浓度在静稳天气下可以从100微克/立方米迅速爬到250以上这个过程往往在6到12小时内完成。如果你发现模型在峰值附近预测值普遍低30到50微克这不是模型结构的问题而是损失函数的选择问题。MSE损失函数会对大误差样本施加平方惩罚但模型为了降低整体损失会倾向于把高浓度峰值“拉平”因为峰值样本占比太少拟合它们的收益抵不上拟合大多数中等浓度样本的收益。解决方法是换用Huber损失函数它对离群点的梯度是线性的而不是二次增长可以在不牺牲常规时段精度的同时提升峰值的响应能力。Keras里直接设置losshuber即可LightGBM则通过obj函数自定义Huber损失。另一个辅助方法是把PM2.5取对数后再训练这样浓度从50变到200时在数值尺度上被压缩模型不会因为个别高浓度样本的极端值分散太多注意力。但注意对数变换后要在评估阶段做指数变换还原否则RMSE算出来的单位都是“对数微克”没法跟真实业务解释。5.2 现象LSTM在验证集上R2很高但一进入供暖季就失效这种情况几乎每个做北方城市空气质量预测的人都遇到过。训练集如果是夏天到秋天的数据LSTM学到的主要是夏季的扩散和降水规律等到11月供暖开始污染物浓度基线上了一个台阶之前学到的浓度范围已经不适配。更隐蔽的问题是StandardScaler的参数是在训练集上拟合的供暖季数据进入模型时输入特征的分布已经和训练时的分布产生偏移模型在归一化后的特征空间中把冬季高浓度样本推向了训练分布的外围。解决思路有两个方向第一训练集必须完整覆盖从10月到次年3月的完整供暖周期宁可少要半年夏季数据也要保住冬季样本因为冬季数据的信息密度远高于夏季第二对模型做滚动重新训练每个月用最近12个月的数据微调一次让归一化参数和树模型的分裂点跟随季节漂移。这个坑的根源不是模型不够强而是数据的时间分布和预测目标的时间分布不一致属于典型的“训练集采样偏差”。5.3 现象代码在Windows上能跑换到Linux服务器上预测结果全变郑州本地开发用Windows跑通代码后把整套源码部署到Linux服务器上复现时发现预测结果和本地不一致。排查到最后往往是两个原因一是pandas的read_csv在Windows上默认编码是gbk或cp936而Linux上读到同样一份CSV文件时按utf-8解析失败导致时间列变成对象类型前面的merge逻辑没有正确匹配小时粒度后续特征和预测就全部错位二是csv文件里的文件路径分隔符或者时间格式内部包含不可见字符。这不是算法问题是环境差异问题。解决办法是在数据加载模块里显式指定encodingutf-8日期解析统一用pd.to_datetime(format%Y-%m-%d %H:%M:%S)强制格式不依赖pandas的自动推断。部署后先用一小段测试数据跑一次predict并保存输出再和本地输出对比每个时间点的预测值如果只有小数点后几位不同说明是浮点精度问题可以忽略如果整列趋势不一致立刻回去检查数据加载模块的编码或时间格式。5.4 现象训练时显存不足batch_size降到8依然报错LSTM模型在GPU上训练时如果输入序列长度是24、特征数是16左右batch_size64占用的显存并不大。真正让显存爆掉的原因是PyTorch或TensorFlow在默认配置下为每个张量保存了完整的计算图梯度信息如果你在训练循环里没有释放上一个batch的梯度或者数据集加载的时候一次性把整个窗口张量加载进显存而没有采用生成器模式即便batch_size很小也会因为整体数据驻留显存而报错。更典型的错误是在定义LSTM层时把return_sequencesTrue保留到最后一层导致输出三维张量(Batch, 24, hidden_size)Dense层只能作用于最后一个维度产生严重的形状错配而且这个三维张量在内存中的占用量是二维的24倍。检查一下模型summary确认最后一层LSTM的return_sequences是False再用fit的steps_per_epoch参数控制每个epoch的批次数或者改用.fit_generator配合一个每次只产出一个小批次数据的生成器就能把显存占用压下来。5.5 现象评估指标RMSE还可以但预测曲线总是比真实曲线“平滑”一截模型预测序列比真实观测序列更平滑这是时间序列预测里最常见的视觉副作用。真实PM2.5曲线有大量高频抖动比如某个小时突然吹来一阵风把浓度从180吹到140下一小时又恢复。模型在训练时学到的是条件期望也就是给定输入特征之后的平均浓度而不是某个具体时刻的瞬时值。条件期望天然包含了对高频噪声的平均。如果业务上需要更“敏锐”的预测也就是能捕捉到浓度快速下降的拐点可以用分位数损失来训练模型直接预测浓度分布的高分位边界比如预测90分位浓度而不是期望浓度。LightGBM原生支持objectivequantile并设置alpha0.9这样得到的是一个偏保守的浓度上限估计在环保管理场景中用来做污染预警反而更实用。Keras里则需要在自定义损失函数里实现分位数损失。这个选择的本质是想清楚你要的是“平均预测”还是“上限预警”两种目标对应的是完全不同的损失函数设计。提示郑州的空气质量数据里偶尔会有持续好几天的沙尘事件PM10浓度可以轻松破千。如果这类极端样本进到训练集里超过1%的比例模型会为了拟合它们而扭曲正常天气的预测表现。常规做法是设置一个合理的阈值比如PM2.5超过500的样本直接降权或剔除不要试图让模型学会沙尘因为在常规业务里预测沙尘本身不是模型的分内事。6. 上一步之外用滚动预测提升真实落地时的可用性前面训练好的模型输入是t时刻之前24小时的数据输出的是t1小时的浓度。这个设置在实际业务里不够用。郑州的空气质量预报通常需要未来24小时甚至72小时的浓度曲线多步预测的常见做法有两种一种是递归预测把t1的预测值作为特征喂回模型去预测t2这种方案误差会累积48小时后基本失真另一种是直接多输出把模型最后一层改成Dense(24)一次输出未来24小时的预测。从实测效果来看直接多输出在前12小时表现更好因为每一步都使用相同的输入特征误差不会线性累加但后12小时的精度会下降因为越远的时段输入信息的解释力越弱。还有一种介于两者之间的策略把前12小时的预测值作为额外特征连同原始输入一起预测后12小时相当于做两次级联预测。def rolling_predict(model, X_input, steps24, use_pred_as_featureTrue): preds [] current_input X_input.copy() for _ in range(steps): pred model.predict(current_input.reshape(1, current_input.shape[0], -1))[0, 0] preds.append(pred) if use_pred_as_feature: pred_feature np.ones((1, current_input.shape[0], 1)) * pred current_input np.concatenate([current_input[:, 1:, :], pred_feature], axis1) else: current_input np.concatenate([current_input[:, 1:, :], current_input[:, -1:, :]], axis1) return np.array(preds)递归预测的误差累积问题在这个函数里体现得很明显。预测下一次浓度后把它拼到输入窗口的末尾相当于用模型的输出更新输入状态一旦某一次预测偏高后续几次预测都会在这个偏高基础上继续外推形成正反馈放大。实际使用时分两步走先用直接多输出模型预测前6小时得到一个相对可靠的近期基线再用递归预测延续到24小时并把递归预测的结果与直接多输出结果做加权平均权重分配大致是近期以前者为主、远期以后者为主。验证这套滚动预测方案的方法是计算不同预测时长的R2衰减曲线如果模型在第12小时的R2还能维持在0.6以上说明预测稳定性合格。最后说一个我养成很久的习惯每次调完一组参数一定把模型结构、特征列表、loss值和当时的预测对比图打包存到一个以日期命名的目录下。郑州的空气质量随季节和年际变化很大隔几个月翻回去看历史实验记录能帮你快速定位是哪次特征改动或者数据窗口调整导致的效果下降。这个习惯比任何调参技巧都更值得建立。希望帮到你。本文还有配套的精品资源点击获取