拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

XGBoost与LSTM融合的污染物浓度预测实战

XGBoost与LSTM融合的污染物浓度预测实战

简介:面向空气质量预测方向的数据爱好者与机器学习初学者,这套资源以北京35个站点未来两天PM2.5、PM10、O3浓度预测为场景,利用XGBoost与LSTM两类模型,完整走通数据清洗、特征工程、模型训练与效果评估流程。包内共7个文件,三个ipynb文件分别承担数据预处理、XGBoost建模与LSTM建模,一个py脚本用于模型效果评估,另附三个CSV数据集,涵盖站点监测、天气观测与网格气象数据,便于按步骤复现实验。整个压缩包约9.95MB,轻量易用,适合在校学生、初级算法工程师快速上手,也便于课堂演示和自学演练。已有734人学习,可见其实用性。读者可同时获得完整源码、整理好的数据集以及分阶段笔记,能较快理清特征筛选、模型调参与结果对比的关键点,也能减少环境配置和数据清洗方面的踩坑时间。

1. 污染物浓度预测为什么值得用XGBoost和LSTM一起做

做空气质量预测的人大多有这种体会:单一模型跑到最后,误差曲线总卡在一个平台上不去。用XGBoost能拿到稳定的特征重要性排序,却抓不住污染物浓度在凌晨和早晚高峰的时序惯性;换成LSTM,序列记忆是有了,可遇到气象突变时又容易跟着异常值一起飘。把这两种模型串起来做污染物浓度预测,本质上是让树模型负责“这小时和上一小时差多少”的回归拟合,让循环神经网络负责“过去十几个小时是怎么演变的”的时序记忆——两者互补,比单模型在RMSE上低 10%~20% 是常见结果。

这篇笔记面向手里已经有监测数据、想跑通一套完整 Python 源码的从业者,从数据切分讲到 XGBoost 基线、LSTM 时序建模,再到融合与验证。你会拿到能直接改路径就跑的代码,也会看到我在真实数据上踩过的几个坑。

2. 先备料:拿到监测数据后做特征工程与样本切分

2.1 数据长什么样:浓度序列加气象外生变量

污染物浓度预测的输入通常是两类数据:一是目标污染物自身的历史浓度序列,比如 PM2.5、NO2 或 O3 的逐小时监测值;二是外生变量,常见的是温度、湿度、风速、风向、气压这些气象观测值。部分场景还会加入时间特征——小时、星期几、是否节假日。

我自己一般会把数据整理成一张宽表,每一行是一个时间点,列依次是time, pm25, temp, humi, wind_speed, pressure, hour, weekday。这样后面做特征工程和样本切分都方便。数据来源无论是国控站点 CSV 还是气象站导出的 Excel,第一步都统一转成这种格式,列名用英文小写加下划线,避免后面编码问题。

import pandas as pd df = pd.read_csv('air_quality_hourly.csv', parse_dates=['time']) df = df.sort_values('time').reset_index(drop=True) # 统一列名,缺失值先用前向填充顶住 df.rename(columns={ 'PM2.5': 'pm25', 'TEM': 'temp', 'RH': 'humi', 'WSPD': 'wind_speed', 'PRES': 'pressure' }, inplace=True) # 时间特征 df['hour'] = df['time'].dt.hour df['weekday'] = df['time'].dt.weekday # 缺失值处理:先向前填充,再线性插值补中间空洞 df = df.interpolate(method='linear', limit_direction='both') print(df.isnull().sum())

这里有个细节:interpolate在浓度序列中间有连续缺失时比fillna(0)靠谱得多,因为污染物浓度是连续变化的物理量,线性插值至少不会制造假的数值跳变。但要注意limit_direction='both'只对边界缺失有效,如果中间是一整段停机维护造成的空洞,线性插值会画出虚假的直线,这种情况应该直接删掉那几行,而不是补出来。

2.2 特征滞后与窗口构造:让模型看见“昨天”

树模型和 LSTM 对“历史信息”的利用方式不同。XGBoost 需要我们把过去几小时的浓度作为显式特征喂给它,比如pm25_lag1表示上一小时浓度,pm25_lag24表示昨天同一时刻的浓度。LSTM 则更希望输入是一个时间窗口序列,把连续多个时间步的原始终端进去,由循环结构自己提炼时序依赖。

我常用的构造方式是同时准备两份数据。一份是扁平特征表给 XGBoost,包含滞后 1、2、3、24 小时的浓度和气象数据;另一份是三维张量给 LSTM,形状是(样本数, 时间步长, 特征数),时间步长通常取 12 或 24。

def make_lag_features(df, target='pm25', lags=[1, 2, 3, 24]): df = df.copy() for lag in lags: df[f'{target}_lag{lag}'] = df[target].shift(lag) # 删除前 24 行,因为滞后特征里会出现 NaN return df.dropna().reset_index(drop=True) df_lag = make_lag_features(df) print(df_lag.head(3))

2.3 时间序列切分不能乱 shuffle:按时间顺序切

很多人在这一步翻车。普通回归任务随机切分训练集和测试集没问题,但时间序列数据一旦 shuffle,模型就偷偷看到了未来的信息,测试集上的表现会虚高。正确做法是按时间顺序切成三段:训练集、验证集、测试集,验证集用来调超参和早停,测试集放在最后模拟“未知未来”。

我会按 7:2:1 切,但这里有个关键点——切分点必须落在“完整一天”上,避免把同一天的数据切进训练和测试两个集合里。污染物浓度的日周期性很强,如果中午 12 点的数据进了训练集、下午 1 点进了测试集,那测试集就失真了。

train_size = int(len(df_lag) * 0.7) val_size = int(len(df_lag) * 0.2) test_size = len(df_lag) - train_size - val_size train_df = df_lag.iloc[:train_size] val_df = df_lag.iloc[train_size:train_size + val_size] test_df = df_lag.iloc[train_size + val_size:] print(f'train: {len(train_df)}, val: {len(val_df)}, test: {len(test_df)}')

3. XGBoost做基线:三行代码跑起来,再调四个关键参数

3.1 为什么先跑XGBoost而不是直接上LSTM

每次做预测项目,我习惯先拿 XGBoost 打底。原因有三:一是训练快,CPU 上几分钟就能出一版结果,方便快速验证特征是否有效;二是它能直接输出特征重要性,帮助判断哪些变量对污染物浓度影响最大,有没有冗余;三是 XGBoost 对缺失值和异常值的鲁棒性比线性模型好很多,拿到脏数据也能先跑通流程。

XGBoost 在这里的角色不是“最终模型”,而是基线与特征筛选工具。如果 XGBoost 的 RMSE 已经不错,说明特征里有足够的信息量;如果 XGBoost 表现很差,那大概率是特征工程没做好,这时候调 LSTM 也不会化腐朽为神奇。

import xgboost as xgb from sklearn.metrics import mean_squared_error feature_cols = ['temp', 'humi', 'wind_speed', 'pressure', 'hour', 'weekday', 'pm25_lag1', 'pm25_lag2', 'pm25_lag3', 'pm25_lag24'] X_train = train_df[feature_cols] y_train = train_df['pm25'] X_val = val_df[feature_cols] y_val = val_df['pm25'] X_test = test_df[feature_cols] y_test = test_df['pm25'] model_xgb = xgb.XGBRegressor( n_estimators=300, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, early_stopping_rounds=20, random_state=42 ) model_xgb.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False) y_pred_xgb = model_xgb.predict(X_test) rmse_xgb = mean_squared_error(y_test, y_pred_xgb, squared=False) print(f'XGBoost RMSE: {rmse_xgb:.2f}')

3.2 四个必调参数:树深、学习率、子采样、早停

XGBoost 参数很多,但做污染物浓度预测这类中等规模表格数据,真正影响结果的主要是四个:

max_depth控制树深度。浓度预测的特征一般是几十个到上百个,深度设到 4~6 就够。太深容易记住滞后特征里的噪声,我见过有人设到 12,训练集 RMSE 很低但测试集一塌糊涂。

learning_rate和学习率配合n_estimators一起看。学习率 0.05 时,300~500 棵树是比较稳的组合;学习率调到 0.1 就要把树数降到 150 左右,否则过拟合。

subsample和colsample_bytree是防止过拟合的双保险。分别控制样本采样和特征采样比例,0.7~0.9 之间常见。污染物数据往往有站点特异性和季节特异性,这两个参数能帮模型不那么“死记硬背”。

early_stopping_rounds不是调参参数,是训练控制参数。设 20 表示验证集误差连续 20 轮没改善就停止训练。这个参数能省大量时间,也能自动确定最佳树数,不需要手动调n_estimators到底。

# 用早停后的最佳迭代次数重新查看特征重要性 importance = pd.Series(model_xgb.feature_importances_, index=feature_cols).sort_values(ascending=False) print(importance)

这里feature_importances_输出的是增益占比,不是次数占比。数值表示每个特征对模型损失的贡献比例,可以用来判断哪些特征值得留、哪些可以丢。比如如果weekday的贡献不足 0.01,说明星期几对这个站点的浓度影响微弱,可以去掉减少噪音。

4. LSTM补上时序记忆:构造时间步样本与训练循环

4.1 把表格数据变成时间步样本

XGBoost 每个样本是独立的,即便加了滞后特征,它对昨天同一时刻的浓度变化的利用也只是“特征值”而不是“演变过程”。LSTM 的输入需要是连续窗口,让模型看到浓度从昨天中午到今天中午是怎么一步步变化的。

构造 LSTM 样本时,我用时间步长 24,意思是拿过去 24 小时的数值预测下一个小时的浓度。这个 24 不是拍脑袋定的,它对应一天的周期,能让模型学到“今天的浓度和昨天同一时刻相似”这类规律。特征窗口包含浓度滞后序列和同期气象数据,LSTM 会自己决定哪些时刻更重要,不需要我们显式构造滞后项。

import numpy as np from sklearn.preprocessing import MinMaxScaler # 归一化:LSTM 对输入尺度敏感,必须缩放到 0~1 feature_list = ['pm25', 'temp', 'humi', 'wind_speed', 'pressure', 'hour', 'weekday'] scaler = MinMaxScaler() scaled_data = scaler.fit_transform(df[feature_list].values) def create_sequences(data, seq_len=24): X, y = [], [] for i in range(seq_len, len(data)): X.append(data[i - seq_len:i]) y.append(data[i, 0]) # 第 0 列是 pm25 return np.array(X), np.array(y) X_seq, y_seq = create_sequences(scaled_data, seq_len=24) print(X_seq.shape, y_seq.shape) # (样本数, 24, 7)

注意这里有几个细节。第一,y取的是data[i, 0],因为feature_list第 0 列是pm25,目标变量必须在第 0 列,后续反归一化才方便。第二,hour和weekday也被一起归一化了,这没问题,LSTM 能把它们当作循环特征来理解周期性。第三,切分要在序列构造之后做,不能切完再构造序列,否则会有穿越。

4.2 搭建 LSTM 网络:两层结构加 Dropout

LSTM 的层数、神经元数与数据量直接相关。污染物浓度数据量一般是几千到几万条,两层 LSTM 是稳妥选择——单层往往表达能力不足,三层以上在小数据上又容易过拟合。

第一层设 64 个神经元,返回序列给第二层;第二层设 32 个神经元,只返回最终输出。每层之间加 Dropout 0.2,抑制过拟合。输出层是单节点线性激活,因为回归问题不需要压缩到 0~1。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping model_lstm = Sequential([ LSTM(64, return_sequences=True, input_shape=(X_seq.shape[1], X_seq.shape[2])), Dropout(0.2), LSTM(32, return_sequences=False), Dropout(0.2), Dense(1) ]) model_lstm.compile(optimizer='adam', loss='mse', metrics=['mae']) model_lstm.summary()

4.3 训练参数与反归一化

训练时我用batch_size=32、epochs=100搭配早停。batch_size太小会造成梯度震荡,太大则收敛慢,32 是中小数据集的常见起点。损失函数用 MSE,因为均方误差对大误差的惩罚更重,这对污染物浓度预测是合理的——浓度突变往往是污染事件,宁可保守也别离谱。

早停设置在验证集损失上,patience=10。污染物数据噪声大,验证损失曲线跳跃明显,patience 太短容易在局部低点停下。

early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True) history = model_lstm.fit( X_train_seq, y_train_seq, validation_data=(X_val_seq, y_val_seq), batch_size=32, epochs=100, callbacks=[early_stop], verbose=1 ) # 反归一化:只还原目标变量那一列 y_pred_lstm = model_lstm.predict(X_test_seq) # 构造一个和 scaled_data 同形状的数组,只取第 0 列做 inverse_transform y_pred_full = np.zeros((len(y_pred_lstm), scaled_data.shape[1])) y_pred_full[:, 0] = y_pred_lstm[:, 0] y_pred_inv = scaler.inverse_transform(y_pred_full)[:, 0] rmse_lstm = mean_squared_error(y_test_actual, y_pred_inv, squared=False) print(f'LSTM RMSE: {rmse_lstm:.2f}')

这段反归一化是新手最容易卡住的地方。scaler.inverse_transform需要输入和原数据有相同的列数,所以先造一个空数组,把预测值填到第 0 列,再整体转换,最后取第 0 列得到真实尺度的预测值。

5. XGBoost与LSTM的5个高频踩坑:现象、原因与解决

5.1 测试集 RMSE 低得离谱,原来是归一化泄漏

现象:LSTM 在验证集上表现正常,测试集 RMSE 比训练集还低,明显不对劲。

原因:scaler.fit_transform用全量数据拟合了最小值和最大值,包括测试集的信息。归一化参数里混入了未来数据,等于是偷偷给模型泄露了测试集的取值范围。

解决:先切分再归一化。用训练集的数据fit,然后用训练集的参数transform验证集和测试集,保证归一化参数只来自训练数据。

scaler = MinMaxScaler() scaler.fit(train_df[feature_list].values) train_scaled = scaler.transform(train_df[feature_list].values) val_scaled = scaler.transform(val_df[feature_list].values) test_scaled = scaler.transform(test_df[feature_list].values)

5.2 XGBoost 特征重要性全是滞后项,气象特征贡献为零

现象:特征重要性排序里前几名全是pm25_lag1、pm25_lag2,温度湿度重要性接近 0。

原因:滞后项与目标变量的相关性极强,树模型每次都优先选它们做分裂。气象特征不是没用,而是被滞后项“抢了功劳”。

解决:把滞后项删掉一部分再训练一版,看气象特征的贡献是否恢复。如果想保留滞后项,就用feature_importance做参考但别直接删气象特征——在预测未来 24 小时时,滞后项会逐渐失去作用,气象特征反而成为主要信号。

5.3 LSTM 训练损失降到 0.001,预测值却是一条水平线

现象:训练过程 loss 很低,但画出预测曲线后发现模型输出几乎是一条直线,完全没有波动。

原因:损失函数用了 MSE,污染物浓度存在大量低值时段,模型发现“预测均值附近的值”比“预测准确峰值”更划算。这是回归任务里典型的均值回归问题。

解决:两种方案。一是把目标变量做差分,预测“下一小时相对上一小时的增量”,让模型不再直接预测绝对浓度,这个我试下来效果最明显;二是对峰值样本加权重,在损失函数里按真实浓度大小加权,让模型更关注高浓度时刻的拟合精度。

# 差分目标:预测增量而非绝对值 df['pm25_diff'] = df['pm25'].diff() df = df.dropna().reset_index(drop=True) # 后续建模以 pm25_diff 为目标,预测结果累加回去

5.4 时间步长从 12 改成 24,效果反而变差

现象:把 LSTM 时间步长从 12 加到 24,期望模型能学到日周期,结果验证集 RMSE 不降反升。

原因:时间步长翻倍意味着每个样本的输入维度翻倍,模型参数量相应增加,但训练样本总数没变。数据量不足时,长窗口带来的表达能力提升抵不过过拟合的风险。

解决:先跑短窗口 12 做基线,再加到 24 对比效果。如果数据量在 5000 条以上,一般 24 步是安全的;如果只有 2000 条左右,12 步是更稳妥的选择。

5.5 模型对极端污染事件预测严重偏低

现象:遇到重污染天气,真实浓度从 50 跳到 200,模型预测只有 80,严重跟不上。

原因:极端事件在数据集中占比小,模型在训练时没见够足够多样的极端样本,自然学不会这种跳跃。

解决:数据层面可以做重采样,把高浓度样本复制几份;模型层面可以调高subsample让每棵树见过的极端样本比例不降得太低;特征层面可以加入“上游站点浓度”这个外生变量,重污染是区域性的,上游站点的浓度是下游站点突变的最强预报因子。

6. 两种模型的融合策略与预测效果验证方法

融合不是简单平均。XGBoost 和 LSTM 的误差结构不同,取平均虽然能降一点 RMSE,但往往不是最优解。我常用的做法是先把两个模型的预测值作为新特征,用岭回归或线性回归学习“什么时候该信谁”。

from sklearn.linear_model import Ridge # 用训练集的预测结果训练融合权重 stack_train = np.column_stack([pred_xgb_train, pred_lstm_train]) stack_val = np.column_stack([pred_xgb_val, pred_lstm_val]) meta_model = Ridge(alpha=1.0) meta_model.fit(stack_train, y_train_actual) stack_test = np.column_stack([pred_xgb_test, pred_lstm_test]) final_pred = meta_model.predict(stack_test)

验证时不要只看 RMSE,要看三个东西:整体 RMSE、峰值时段的 RMSE、以及预测残差的自相关性。如果残差存在明显的时间相关性,说明模型没把时序信息榨干,还有改进空间。我会画一张残差分布图,按小时分组看一天中哪些时段预测偏差最大——通常早晚高峰是重灾区,这时候单独对高峰时段调权重比全局调参更有效。

最后说一个我的习惯:每跑一版模型,就把当时的特征列表、参数、RMSE 记在一个 CSV 里。半年后回头翻记录,你能清楚看到哪些调整真正有效,哪些是白费功夫。希望帮到你。

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

返回列表