简介:这份资源面向数据挖掘初学者与希望掌握空气质量预测建模的开发者,提供一套基于随机森林算法的完整实战方案,可用于课程作业、竞赛练手或环境数据分析场景。压缩包共3个文件,包含1个ipynb代码笔记、1个csv数据集和1个html分析报告,整体约616KB,体量轻便,便于快速上手与复现。其中csv文件为更新后的污染数据集,ipynb完整呈现从数据清洗、特征工程到随机森林建模与评估的全流程,html则固化分析结果,方便直接查看关键结论。已有129人学习下载,说明该案例在入门群体中具有一定参考价值。读者可借此掌握随机森林在回归或分类任务中的参数设置、特征重要性分析及模型评估思路,并对照代码理解数据挖掘项目的标准组织方式,适合作为空气质量预测方向的入门模板与排错参考。
1. 空气质量预测翻车记:为什么随机森林在这个场景里比神经网络更稳
去年冬天帮一个环保口的朋友做空气质量预警,他手里有三年六个监测站的逐小时数据,PM2.5、PM10、SO2、NO2、CO、O3 六项污染物浓度加上温度、湿度、风速、气压、降雨量,一共十一个特征。他一开始想上 LSTM,觉得时序问题就该用循环网络,结果折腾两周,验证集 RMSE 比线性回归还差。我让他换成随机森林回归,特征工程只做了滞后和滑动窗口,当天下午模型就跑通了,测试集 RMSE 直接降到 18.7,比 LSTM 调参两周的结果好一大截。
这不是说神经网络不行,而是空气质量预测这个场景有几个特点:样本量通常几千到几万条、特征维度不高、非线性关系明显但不需要太深的层次、缺失值和异常值多。随机森林回归算法在这种“中等规模、中等维度、噪声大”的表格数据上,往往比深度学习更省心。这篇笔记就围绕数据挖掘实战里这套“数据集+代码”的典型结构,把从数据清洗到模型调参再到特征重要性的完整链路拆开讲,适合手里有监测数据想做预测、但不想在调参上耗太久的从业者。
2. 数据到手先别急着建模:空气质量数据集的清洗与特征构造
2.1 监测站原始数据的四类脏数据
空气质量监测数据几乎不可能直接拿来训练。常见的问题有四类:第一类是缺失值,传感器故障或通信中断会导致整行或单列缺失,PM2.5 缺失率超过 15% 的站点建议直接剔除;第二类是异常值,比如 PM2.5 出现负值或超过 1000 的读数,通常是设备校准问题;第三类是时间戳重复或跳跃,同一小时出现两条记录,或者中间缺了几个小时;第四类是量纲不统一,CO 的浓度单位是 mg/m³,其他五项是 μg/m³,不统一会导致树模型分裂时偏向数值大的特征。
处理顺序很重要:先去重和补时间戳,再处理异常值,最后填缺失。很多人反过来做,先填缺失再处理异常,结果异常值被均值平滑掉了,模型学到的分布是错的。
import pandas as pd import numpy as np # 读取原始数据,假设列名为中文 df = pd.read_csv('air_quality_raw.csv', parse_dates=['监测时间']) df = df.sort_values(['站点编号', '监测时间']).reset_index(drop=True) # 第一步:去重,同一站点同一时间只保留第一条 df = df.drop_duplicates(subset=['站点编号', '监测时间'], keep='first') # 第二步:异常值处理,PM2.5 和 PM10 负值置为 NaN,超过 1000 也置为 NaN for col in ['PM2.5', 'PM10', 'SO2', 'NO2', 'CO', 'O3']: df.loc[df[col] < 0, col] = np.nan df.loc[df[col] > 1000, col] = np.nan # 第三步:CO 单位从 mg/m³ 转成 μg/m³,乘以 1000 df['CO'] = df['CO'] * 1000 # 第四步:按站点分组,用时间插值填缺失,最多向前后各填 3 小时 df = df.set_index('监测时间') df = df.groupby('站点编号').apply( lambda g: g.interpolate(method='time', limit=3, limit_direction='both') ).reset_index()这段代码的逻辑是:先去重保证时间戳唯一,再把明显不合理的读数置为 NaN 而不是直接删除,因为删除会丢掉同一行其他正常特征。CO 单位转换必须在插值之前做,否则插值出来的 CO 值量纲是错的。按站点分组插值是因为不同站点的浓度水平差异大,跨站点插值会引入偏差。limit=3表示最多连续填三个小时,超过三小时的缺失说明设备可能长时间离线,强行填会引入噪声。
2.2 滞后特征和滑动窗口:让随机森林“看见”时间
随机森林本身不处理时序,它把每一行样本当作独立同分布的数据。要让模型捕捉时间依赖,必须手动构造滞后特征和滑动窗口统计量。常见做法是:对每个污染物浓度,构造前 1 小时、前 3 小时、前 6 小时、前 12 小时、前 24 小时的滞后值;对 PM2.5 和 PM10 再加 6 小时和 24 小时的滑动均值、滑动标准差、滑动最大最小值。
这里有个容易翻车的地方:构造滞后特征时如果直接shift(1)而不按站点分组,会把 A 站点的前一小时数据拼到 B 站点的当前行上。正确做法是先groupby('站点编号')再shift。
# 按站点分组构造滞后特征 lag_hours = [1, 3, 6, 12, 24] for col in ['PM2.5', 'PM10', 'SO2', 'NO2', 'CO', 'O3']: for lag in lag_hours: df[f'{col}_lag{lag}'] = df.groupby('站点编号')[col].shift(lag) # 滑动窗口统计,窗口为 6 和 24 for col in ['PM2.5', 'PM10']: for window in [6, 24]: df[f'{col}_roll{window}_mean'] = df.groupby('站点编号')[col].transform( lambda x: x.rolling(window, min_periods=1).mean() ) df[f'{col}_roll{window}_std'] = df.groupby('站点编号')[col].transform( lambda x: x.rolling(window, min_periods=1).std() ) df[f'{col}_roll{window}_max'] = df.groupby('站点编号')[col].transform( lambda x: x.rolling(window, min_periods=1).max() ) # 气象特征也做滞后,温度和湿度对污染物的影响有延迟 for col in ['温度', '湿度', '风速', '气压']: for lag in [1, 3, 6]: df[f'{col}_lag{lag}'] = df.groupby('站点编号')[col].shift(lag) # 删除因为滞后产生的空行 df = df.dropna().reset_index(drop=True)参数说明:lag_hours选 1、3、6、12、24 是空气质量领域的经验值,1 小时捕捉短期波动,24 小时捕捉日周期。滑动窗口选 6 和 24 分别对应半日累积和全日累积。min_periods=1保证序列开头不会因为窗口不满而全是 NaN。气象特征只做 1、3、6 小时滞后,因为温度和湿度的自相关性比污染物衰减得快,24 小时前的温度和当前污染物的关系已经很弱了。
构造完这些特征后,特征数量会从 11 个膨胀到 80 个左右。随机森林对特征数量不敏感,但特征太多会拖慢训练速度,而且高度共线的滞后特征会稀释特征重要性。我一般会在构造完之后算一下特征之间的相关系数,把相关系数超过 0.95 的成对特征删掉其中一个。
3. 随机森林回归的调参逻辑:不是网格搜索越密越好
3.1 四个核心参数的作用和取值范围
随机森林回归在 scikit-learn 里主要有四个参数需要调:n_estimators、max_depth、min_samples_leaf、max_features。很多人一上来就GridSearchCV把范围设得很宽,结果跑一整天,最后提升不到 0.5%。我的经验是:先定n_estimators,再调max_depth和min_samples_leaf,最后微调max_features。
n_estimators是树的数量。树越多,模型越稳定,但超过一定数量后边际收益急剧下降。空气质量数据通常 500 棵树就足够,1000 棵和 500 棵的 RMSE 差异一般在 0.1 以内。max_depth控制每棵树的最大深度,不设的话树会一直长到叶子纯净,容易过拟合。空气质量预测里,深度设在 10 到 20 之间比较合理。min_samples_leaf是叶子节点最少样本数,这个参数对抑制过拟合比max_depth更有效,一般设在 5 到 20 之间。max_features是每次分裂时考虑的特征数,回归问题默认是n_features,但实际用sqrt(n_features)或0.5 * n_features往往更好,因为能增加树之间的多样性。
from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import TimeSeriesSplit, RandomizedSearchCV from scipy.stats import randint, uniform # 时序数据必须用 TimeSeriesSplit,不能用普通 KFold tscv = TimeSeriesSplit(n_splits=5) param_dist = { 'n_estimators': randint(300, 800), 'max_depth': randint(8, 25), 'min_samples_leaf': randint(3, 20), 'min_samples_split': randint(5, 30), 'max_features': uniform(0.3, 0.5) # 0.3 到 0.8 之间 } rf = RandomForestRegressor(random_state=42, n_jobs=-1) search = RandomizedSearchCV( rf, param_dist, n_iter=60, cv=tscv, scoring='neg_root_mean_squared_error', random_state=42, n_jobs=-1, verbose=1 ) search.fit(X_train, y_train) print(search.best_params_) print(-search.best_score_)这段代码的关键点有三个:第一,用TimeSeriesSplit而不是KFold,因为空气质量数据有时间顺序,随机打乱会让未来数据泄露到训练集;第二,用RandomizedSearchCV而不是GridSearchCV,60 次随机采样在大多数情况下能找到和网格搜索相近的最优解,但速度快一个数量级;第三,scoring用负 RMSE,因为 sklearn 的交叉验证默认是分数越大越好,RMSE 是越小越好,所以要取负。
参数说明:n_iter=60是随机搜索的迭代次数,特征维度在 80 左右时,60 次已经能覆盖大部分有希望的区域。max_features用均匀分布从 0.3 到 0.8 采样,而不是固定值,是因为不同数据集的最优值差异大,让搜索自己找。min_samples_split我一般也会带上,虽然它和min_samples_leaf有重叠作用,但一起调有时能找到更好的组合。
3.2 用 OOB 误差代替交叉验证做快速筛选
随机森林有一个其他模型没有的特性:袋外误差(OOB)。每棵树训练时大约有 36.8% 的样本没被抽到,这些样本可以用来评估模型性能,不需要单独划验证集。在调参初期,用 OOB 误差可以快速筛掉明显不好的参数组合,比交叉验证快好几倍。
# 开启 OOB 评分 rf_oob = RandomForestRegressor( n_estimators=500, max_depth=15, min_samples_leaf=8, max_features=0.5, oob_score=True, random_state=42, n_jobs=-1 ) rf_oob.fit(X_train, y_train) print(f'OOB R²: {rf_oob.oob_score_:.4f}') # 用 OOB 误差快速比较几组参数 for depth in [10, 15, 20]: for leaf in [5, 10, 15]: rf_tmp = RandomForestRegressor( n_estimators=300, max_depth=depth, min_samples_leaf=leaf, max_features=0.5, oob_score=True, random_state=42, n_jobs=-1 ) rf_tmp.fit(X_train, y_train) print(f'depth={depth}, leaf={leaf}, OOB R²={rf_tmp.oob_score_:.4f}')OOB 的局限是它只反映训练集分布下的泛化能力,如果训练集和测试集分布差异大(比如不同季节的数据),OOB 会偏乐观。所以我的做法是:先用 OOB 把参数范围缩小到两三组,再用TimeSeriesSplit做正式评估。这样既快又不至于漏掉好参数。
4. 避坑与排查:空气质量预测模型最常见的五个翻车点
4.1 现象:测试集 RMSE 很低但实际预测曲线滞后一天
原因:构造滞后特征时用了未来数据。比如在shift(-1)或者滚动窗口用了center=True,导致当前行的特征里包含了未来时刻的信息。模型在训练时“偷看”了答案,测试集评估时因为测试集也是同样构造的,所以 RMSE 看起来很好,但实际部署时没有未来数据,预测就滞后了。
解决:所有滞后和滚动操作必须用shift(正数)和rolling默认的右对齐窗口。构造完特征后,检查每一列的特征和标签的相关系数,如果某个滞后特征的相关系数异常高(比如超过 0.98),大概率是泄露了。
4.2 现象:模型在冬季数据上表现好,夏季数据上 RMSE 翻倍
原因:训练集和测试集的时间划分不合理。如果训练集只包含冬季数据,测试集包含夏季数据,模型没学过夏季的污染模式(比如 O3 在夏季高、PM2.5 在冬季高),表现自然差。
解决:划分训练集和测试集时按时间顺序,但确保训练集覆盖至少一个完整的年度周期。如果数据只有一年,用前 8 个月训练、后 4 个月测试,并且在后 4 个月里包含季节转换。更好的做法是用TimeSeriesSplit做多折评估,看模型在不同时间段的稳定性。
4.3 现象:特征重要性排名第一的是“站点编号”
原因:站点编号是类别特征,如果直接编码成数字(1、2、3、4、5、6),树模型会把它当作有序数值,分裂时可能学到“站点编号大于 3.5”这样的规则。如果不同站点的污染水平差异大,站点编号就会成为强特征,但它没有泛化能力——新站点没有对应的编号。
解决:站点编号要么做 One-Hot 编码,要么直接删除。如果站点数量多,One-Hot 会导致特征维度爆炸,更好的做法是计算每个站点的历史均值作为该站点的“基线浓度”特征,用这个连续值代替编号。
4.4 现象:调参后 RMSE 降了但特征重要性全乱了
原因:max_features设得太小。如果max_features从 0.8 降到 0.2,每次分裂只考虑 20% 的特征,重要的特征可能在某棵树里根本没被选到,导致特征重要性被稀释。随机森林的特征重要性是跨树平均的,max_features越小,方差越大。
解决:特征重要性分析时用max_features较大的模型(比如 0.6 到 0.8),或者用permutation_importance代替默认的基于不纯度的特征重要性。permutation_importance是在验证集上打乱某一列后看性能下降多少,更能反映特征的真实贡献。
4.5 现象:预测值全部集中在均值附近,极端污染事件预测不出来
原因:随机森林回归的预测是叶子节点样本的均值,对于极端值(比如 PM2.5 超过 300 的重污染天),叶子节点里的样本可能只有几个,均值被拉向正常水平。这是树模型的固有局限,不是调参能解决的。
解决:两个方向。一是对标签做变换,比如对 PM2.5 取对数后再训练,预测时再指数还原,这样模型对极端值的敏感度会提高。二是用分位数回归森林(QuantileRegressor或GradientBoostingRegressor的quantile损失),直接预测 90 分位数而不是均值。我一般先用对数变换试,如果还不够再上分位数回归。
5. 让模型真正能用:特征重要性解读与滚动预测的工程化
5.1 用 SHAP 值替代默认特征重要性做归因
默认的基于不纯度的特征重要性有一个已知偏差:它偏向于取值多的特征(比如连续特征比二值特征更容易被选中)。在空气质量预测里,滞后特征都是连续的,气象特征也是连续的,默认重要性排序看起来合理,但具体到“温度对 PM2.5 的贡献是正还是负”,默认重要性回答不了。
SHAP 值能给出每个特征对每条预测的贡献方向和大小。对随机森林回归,用TreeExplainer计算 SHAP 值,然后画 summary plot,能直观看到温度升高时 PM2.5 是升还是降、风速增大时污染物浓度是降还是升。
import shap # 用训练好的随机森林模型 explainer = shap.TreeExplainer(rf_oob) shap_values = explainer.shap_values(X_test) # 全局特征重要性 shap.summary_plot(shap_values, X_test, plot_type='bar', max_display=15) # 单条预测的归因 shap.force_plot( explainer.expected_value, shap_values[0, :], X_test.iloc[0, :], matplotlib=True )参数说明:max_display=15只显示最重要的 15 个特征,避免图太挤。force_plot里的expected_value是模型在所有样本上的平均预测值,shap_values[0, :]是第一条测试样本的 SHAP 值。如果 SHAP 值计算太慢,可以用shap.sample(X_test, 100)先采样再算。
5.2 滚动预测:用模型预测未来 24 小时的工程实现
实际业务里不会只预测当前时刻,而是要预测未来 24 小时。随机森林不能像 LSTM 那样直接输出序列,需要用滚动预测:先用当前特征预测下一小时,把预测值当作已知值填到滞后特征里,再预测下下一小时,如此循环。
def rolling_forecast(model, last_row, hours=24, feature_cols=None): """ last_row: 包含当前时刻所有特征的一行 DataFrame hours: 预测未来多少小时 返回: 未来 hours 小时的 PM2.5 预测值列表 """ predictions = [] current = last_row.copy() for h in range(hours): # 预测下一小时 pred = model.predict(current[feature_cols])[0] predictions.append(pred) # 更新滞后特征:把预测值填入 PM2.5_lag1,其他滞后特征顺移 for lag in sorted([1, 3, 6, 12, 24], reverse=True): if lag == 1: current[f'PM2.5_lag{lag}'] = pred else: prev_lag = lag // 2 if lag // 2 in [1, 3, 6, 12] else 1 current[f'PM2.5_lag{lag}'] = current.get(f'PM2.5_lag{prev_lag}', pred) # 更新滑动窗口统计(简化处理,用预测值近似) for window in [6, 24]: current[f'PM2.5_roll{window}_mean'] = np.mean( predictions[-window:] if len(predictions) >= window else predictions ) return predictions这段代码是简化版,实际工程里滞后特征的更新逻辑更复杂,因为lag3应该等于两小时前的预测值,lag6等于五小时前的预测值。更稳妥的做法是维护一个预测值队列,每次从队列里取对应位置的值。另外,气象特征在未来 24 小时是未知的,需要用天气预报数据或者 persistence 假设(假设未来气象条件不变)。如果气象预报不可用,滚动预测的误差会随时间快速累积,超过 12 小时的预测参考价值有限。
我自己的习惯是:滚动预测只做到 12 小时,超过 12 小时用日均值模型而不是逐小时模型。逐小时模型在 12 小时后的 RMSE 通常比日均值模型还差,因为误差累积已经超过了日变化的信号。这个方案值不值得做,取决于业务能接受多长的预警提前量——如果只需要提前 6 小时预警,随机森林滚动预测完全够用;如果需要提前 48 小时,得换时空图神经网络或者数值模式耦合的方法。希望帮到你。
本文还有配套的精品资源,点击获取