☰
数据挖掘实战中的空气质量预测:从特征工程到模型选型全流程解析
2026/9/26 11:25:36 网站建设 项目流程

简介:面向数据挖掘初学者与高校相关课程实践者,这份压缩包提供了一套完整的空气质量预测实战方案,基于机器学习算法完成污染数据的建模与分析,可帮助读者快速走通从数据准备到结果输出的完整流程。包体十分紧凑,共3个文件,包含Jupyter Notebook交互式分析代码、HTML格式的结果展示页面,以及CSV格式的污染数据集,整体仅1.39MB,下载后可轻松本地运行。代码中覆盖数据探索、特征处理、模型训练与效果评估等关键环节,并提供可视化图表辅助理解算法行为;HTML页面可直接打开查看分析结果,便于对照代码快速定位每一处输出对应的操作。目前已有128人浏览学习,适合作为课程设计参考、作业模板或数据挖掘入门练手项目,尤其适合希望用轻量案例串联理论知识与动手实践的读者。

1. 数据挖掘实战到底在挖什么:从一份压缩包看清空气质量预测项目

如果你下载过那种带“数据集+代码”的数据挖掘实战压缩包,多半是冲着课设、竞赛或面试项目去的。这个标题把它应做的事说得很直接:拿到一份空气质量监测数据,用机器学习算法训练一个能预测污染物浓度的回归模型,最终交付的是一套能跑的代码和一份能讲清原理的报告。空气质量预测的价值在于它把数据挖掘最典型的一整套动作都串起来了——探索性分析、特征构造、模型选型、评估复盘,缺一环都落不了地。适合刚入门的读者照着跑通流程,也适合想做课设的人从中抽出一个完整基线再往上加东西。

2. 拿到数据先别建模:字段类型、缺失率与分布才是项目的地基

2.1 字段先分两类:污染物浓度与气象条件,建模逻辑完全不同

数据挖掘的第一步永远是搞清楚手里有什么。空气质量预测数据通常由两类字段拼成:污染物观测值(颗粒物与气态污染物浓度)和气象观测值(温度、气压、湿度、风速等)。这两类字段在项目中的角色不一样,污染物浓度是我们要预测的目标,气象条件则是驱动污染扩散的外因。

先按这个视角把字段拆开,后面做特征工程时才不会把两类变量混在一起处理。常见做法是先读取数据后直接按列名分组,而不是一把抓进模型里让算法自己消化。

import pandas as pd df = pd.read_csv('air_quality.csv', parse_dates=['datetime']) # 按业务含义拆字段组 pollutants = ['PM2.5', 'PM10', 'SO2', 'NO2', 'CO', 'O3'] meteo = ['TEMP', 'PRES', 'DEWP', 'RAIN', 'WSPM'] # 确认字段是否齐全 assert set(pollutants + meteo).issubset(df.columns), '缺少关键字段' # 时间列存成 DatetimeIndex,后面所有时序操作都依赖它 df = df.set_index('datetime').sort_index() print(df[pollutants + meteo].describe())

这段代码做的是划分字段并输出描述性统计。describe()会给出每列的均值、标准差、分位数,适合快速发现单位异常或量级离谱的列。大气污染物浓度一般是微克/立方米(μg/m³),一氧化碳是毫克/立方米(mg/m³)。sort_index()这步很关键,空气质量数据经常按站点与小时拼接,原始文件里的顺序不一定就是时间顺序,先按时间排好,后续做滞后特征时不会串位。

2.2 缺失值的最优解不是删行:按时间序列插补,异常值要设物理边界

缺失在这个场景里几乎必然出现。监测仪器故障、通信断点、校准维护都会让某几个小时没有记录。很多新手直接dropna()把行删掉,这个做法对空气质量数据伤害很大——污染物浓度有很强的时间自相关,这一小时的值跟前几小时有千丝万缕的联系,删掉一整行等于把污染过程的一截链条剪断了。

更稳妥的路子是时间感知的插补。短时间段缺失用线性插值,长时间段缺失(比如连续一整天)不能盲插,得看是否有相邻日期的同时段数据可用。

target_cols = pollutants + meteo # 先统计缺失率,缺失超过30%的列要单独评估 missing_ratio = df[target_cols].isnull().mean().sort_values(ascending=False) print(missing_ratio) # 短时段缺失用线性插补,方向双向填充 df[target_cols] = df[target_cols].interpolate(method='linear', limit=6, limit_direction='both') # 填完再做一次物理合理性检查 phys_limits = {'PM2.5': (0, 1000), 'PM10': (0, 2000), 'SO2': (0, 1000), 'NO2': (0, 1000), 'CO': (0, 50), 'O3': (0, 800)} for col, (low, high) in phys_limits.items(): df[col] = df[col].clip(lower=low, upper=high)

interpolate(method='linear')是默认的线性插值,limit=6表示最多连续插补 6 个点的空洞,超过这个长度就不补,宁可让模型在训练时忽略这些行,也不要补出一段完全虚构的污染过程。接着用clip()把超出物理边界的值拉回合理区间,例如 PM2.5 超过 1000 的通常是仪器故障或录入错误。这一步是纯经验法则,不同城市、不同时期的限值可以按需求调整。

2.3 半小时就能跑完的EDA脚本:分布形态与相关矩阵

做过一千次数据挖掘项目的人都会告诉你,建模前最值得花时间的地方是看分布和相关矩阵。污染物浓度分布通常是右偏的——大多数时间污染不高,偶尔出现极端高值。如果不做任何变换直接喂给线性模型,预测结果会被极端值拽着走,R² 虚高但实际没学会规律。

import matplotlib.pyplot as plt import seaborn as sns df_selected = df[pollutants + meteo].dropna() # 对数变换让分布更接近高斯形态,注意先加一个小偏移避免log(0) df_log = df_selected[pollutants].apply(lambda x: np.log1p(x)) fig, axes = plt.subplots(2, 3, figsize=(14, 8)) for i, col in enumerate(pollutants): row, col_idx = divmod(i, 3) df_log[col].hist(bins=50, ax=axes[row, col_idx]) axes[row, col_idx].set_title(f'log({col})') plt.tight_layout() plt.show() corr = df_selected[pollutants + meteo].corr() sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', vmin=-1, vmax=1) plt.show()

相关矩阵会直接告诉你哪些气象变量跟污染物浓度强相关。通常风速与污染物呈负相关,气压与颗粒物浓度正相关,温度和臭氧的关系则随季节变化。打开热力图后,你还能发现 PM2.5 与 PM10 高度共线——这属于正常的物理关联,建模时不需要刻意处理,但如果特征数膨胀,后续树模型会因为这个冗余而分散分裂点,可以视情况保留其中一个。

3. 把时间信息变成能喂给机器学习的特征:滞后、滑动窗口与周期编码

3.1 小时与季节不能当整数用:循环编码还原周期属性

机器学习算法面对“小时”和“月份”这类整数特征时会默认它们是有序的——23 比 1 大,12 月比 1 月大。但时间本身就是循环的,23 点过后是 0 点,12 月过后是 1 月。如果直接把这个数字喂给模型,算法会错误地学到“越晚越如何”,实际上是周期性变化。

解决方式是把时间字段拆成两个分量:sin和cos。这样任何一个时间点都能映射到圆周上的唯一位置,模型既可以学到“夜间的污染往往偏高”,又不会被“23 点 > 1 点”这种假大小关系误导。

import numpy as np df['hour'] = df.index.hour df['dayofweek'] = df.index.dayofweek df['month'] = df.index.month # 周期编码:小时按24小时循环,星期按7天循环,月份按12个月循环 periods = {'hour': 24, 'dayofweek': 7, 'month': 12} for col, period in periods.items(): df[f'{col}_sin'] = np.sin(2 * np.pi * df[col] / period) df[f'{col}_cos'] = np.cos(2 * np.pi * df[col] / period)

加上周期分量后,原始整数列可以保留也可以删掉,树模型保留整数列影响不大,线性模型建议只保留_sin和_cos。循环编码最大的收益在捕捉“早晚高峰”和“秋冬污染季”这类周期性规律,同时不会把周期性强行拗成单调趋势。

3.2 污染有惯性:滞后特征与滑动窗口是提升预测分的关键

空气质量预测与一般表格分类的最大区别在于样本之间有时间依赖。今晚的 PM2.5 浓度大概率延续昨晚的状态,而不是随机跳变。把这种惯性显式地编码成特征列,就是滞后特征和滑动窗口均值。

这里踩过一个很深的分寸:滞后项用第几个小时的滞后。对于逐小时数据,滞后 1 到 24 小时都有物理意义,滞后 1 到 3 小时代表污染团短时滞留,滞后 24 小时代表日循环周期。滑动窗口均值则代表过去一天或一周的趋势。

# 滞后特征:对污染物做1小时、3小时、24小时滞后 for col in pollutants: df[f'{col}_lag1'] = df[col].shift(1) df[f'{col}_lag3'] = df[col].shift(3) df[f'{col}_lag24'] = df[col].shift(24) # 滑动窗口:过去24小时均值与过去168小时(一周)均值 for col in pollutants: df[f'{col}_roll24'] = df[col].rolling(window=24).mean() df[f'{col}_roll168'] = df[col].rolling(window=168).mean() # 气象特征也可以加短时滑动,风速在污染扩散中的作用有滞后性 df['WSPM_roll6'] = df['WSPM'].rolling(window=6).mean()

滑动窗口的min_periods参数值得注意。默认情况下窗口内只要有足够的非空值就会计算,但数据开头那段由于窗口不满,结果全是 NaN。训练时这些行会被丢弃,量不大可以接受;如果只是想看趋势而非直接喂模型,可以设min_periods=12放宽要求,避免头部大量缺失。

滞后特征做完后,数据集维度从十来个字段膨胀到四十多个,这是正常的,树模型能够消化这个规模。真正要警惕的是滞后特征带来的泄露风险,这一点放在后面避坑章节单独讲。

3.3 切分训练集与验证集:必须先按时间切,再考虑要不要shuffle

拿到特征后的第一直觉往往是train_test_split(test_size=0.2, random_state=42),这个常规操作在时间序列预测里是错的。随机切分会把 1 月和 7 月的数据同时放进训练集和验证集,模型在训练时已经“看过”未来天气模式,验证分数虚高得吓人,真实部署时完全达不到这个水平。

# 按时间顺序切分,注意不能用随机切分 train = df[df.index < '2017-06-01'] valid = df[(df.index >= '2017-06-01') & (df.index < '2017-09-01')] # 丢弃含NaN的行(滞后特征产生) train = train.dropna() valid = valid.dropna() X_train = train[feature_cols] y_train = train['PM2.5'] X_valid = valid[feature_cols] y_valid = valid['PM2.5']

切分点怎么选?一般按业务目标来。如果要预测夏季污染,就用春季做训练集,夏季做验证集;要预测秋季重污染,就用夏秋季交接点前做训练。严格一点的方案会保留最后一段同季节数据,并且过渡期至少留 24 小时的滞后余量,防止验证集开头因无法构造滞后特征而丢样本。

4. 从线性回归到XGBoost:三个模型横向对比,把选型理由落到实处

4.1 为什么拟合优度不是唯一标准:MAE、RMSE 与 R² 各有用处

先更新一个认识:空气质量预测是回归任务,分类任务里常用的 F1、精确率、召回率在这里用不上。回归最常看三个指标:MAE、RMSE、R²。MAE 是平均绝对误差,直白易懂,单位与预测目标一致,适合向非技术方解释;RMSE 对误差做了平方,放大了大误差样本的影响,适合用来揪出极端污染时段;R² 是决定系数,描述模型解释了多少方差,但单看 R² 会掩盖误差分布不均匀的问题。

实践中的组合用法是:报告 R² 作为总体拟合度,同时看 MAE 判断平均误差水平,RMSE 与 MAE 的比值如果明显大于 1.2,说明有少数时段的预测误差特别大,模型对重污染过程把握不足。

from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score def evaluate(y_true, y_pred): mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) r2 = r2_score(y_true, y_pred) return {'MAE': mae, 'RMSE': rmse, 'R2': r2}

4.2 一份基准训练脚本:线性回归、随机森林与XGBoost同台对比

选型的基本思路是:先跑一个线性回归作为底线,确保后续模型的提升是真实的,而不是数据本身太简单;然后跑随机森林,检验非线性特征交互的作用;最后跑 XGBoost,看梯度提升相比 bagging 集成是否有增量。三者用同一特征集、同一验证集,这样才有的比。

from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor import xgboost as xgb feature_cols = [c for c in train.columns if c not in ('PM2.5', 'PM10_original')] # 线性回归:基线中的基线,跑通流程用 lr = LinearRegression() lr.fit(X_train, y_train) y_pred_lr = lr.predict(X_valid) print('LinearRegression:', evaluate(y_valid, y_pred_lr)) # 随机森林:树模型对特征尺度不敏感,不用标准化 rf = RandomForestRegressor( n_estimators=300, max_depth=12, min_samples_leaf=2, random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_valid) print('RandomForest:', evaluate(y_valid, y_pred_rf))

随机森林的n_estimators=300是经验值,这个规模下再调大收益很小而训练时间直线上升。max_depth=12限制每棵树的高度,防止单棵树学得太细导致过拟合。min_samples_leaf=2要求叶节点至少两个样本,相当于给树的末端加了平滑。这三个参数可以先定下,后续用网格搜索微调。XGBoost 是这个行业的常客,对它做一次手动的参数粗调是值得的。

xgb_model = xgb.XGBRegressor( n_estimators=300, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, random_state=42, early_stopping_rounds=20, eval_metric='mae' ) xgb_model.fit( X_train, y_train, eval_set=[(X_valid, y_valid)], verbose=False ) y_pred_xgb = xgb_model.predict(X_valid) print('XGBoost:', evaluate(y_valid, y_pred_xgb))

learning_rate=0.05每次迭代只走一小步,配合 300 棵树正好。subsample=0.8每次迭代随机抽 80% 样本,colsample_bytree=0.8每棵树只用 80% 特征,这两项共同降低方差。early_stopping_rounds=20的作用是当验证集连续 20 轮没有提升就提前终止,实测中能节省近一半的训练时间。如果你在跑这个项目时发现 XGBoost 没有明显强于随机森林,先别急着加大深度,先检查上一章的时间切分是否真的隔离了未来数据。

4.3 用TimeSeriesSplit做交叉验证:更接近真实部署的评估方式

普通的 KFold 交叉验证在时间序列上不适用。真正该用的是TimeSeriesSplit,它保证训练集永远在验证集之前。下面的脚本会输出五次验证的误差分布,比只跑一次验证集更能反映模型对不同季节的适应性。

from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import RandomForestRegressor tscv = TimeSeriesSplit(n_splits=5, gap=24) mae_list = [] X_all = df[feature_cols].dropna() y_all = df['PM2.5'].loc[X_all.index] for train_idx, valid_idx in tscv.split(X_all): X_tr, X_va = X_all.iloc[train_idx], X_all.iloc[valid_idx] y_tr, y_va = y_all.iloc[train_idx], y_all.iloc[valid_idx] model = RandomForestRegressor(n_estimators=200, max_depth=10, min_samples_leaf=3, random_state=42, n_jobs=-1) model.fit(X_tr, y_tr) pred = model.predict(X_va) mae_list.append(mean_absolute_error(y_va, pred)) print('5-fold MAE:', mae_list) print('MAE mean:', np.mean(mae_list), 'std:', np.std(mae_list))

gap=24表示训练集与验证集之间留 24 小时的空隙,防止滞后特征把验证集末尾的信息泄漏给训练集。五次验证的 MAE 如果波动很大,说明模型在不同季节表现不均,后面需要按季节拆分数据进一步定位。

5. 四个高频踩坑点:特征泄漏、假拟合与残差里藏着的隐患

5.1 随机切分堆出来的高R²是假象:验证集里混进了未来数据

现象:随机切分时验证集 R² 高达 0.95,换成按时间切分后骤降到 0.82,以为代码写错了,反复检查后才发现是切分方式的问题。

原因:随机切分把所有时间片混在一起,模型在训练阶段已经见过验证集时段的气象条件和污染走势。空气质量数据的季节性极强,冬季高污染样本在训练集和验证集里都能找到,模型不是学会了预测,而是学会了复制邻近时段的数值。

解决:严格使用TimeSeriesSplit或按时间点切分。诊断方法很简单——把训练集和验证集的日期范围打出来,如果两者有重叠,切分方式就是错的。

5.2 缺失值全部用全局均值填充,污染走势被彻底拉平

现象:填充后的数据在描述统计里看着“干净”了,但模型预测值普遍向均值收缩,重污染时段的预测明显偏低。

原因:全局均值填充破坏了时间序列的连续性。凌晨和傍晚的 PM2.5 天然不同,把某个时段的空缺填成整体平均值,等于强行把这段走势抹平。树模型和线性模型都会把这个均值当成该时段的基准值,从而产生系统性低估。

解决:对短时段缺失用线性插值或前向填充,对长时间段缺失考虑按同期历史数据建模填充。如果缺失集中在某个站点或某个月,宁可丢弃这些时段也不要全局填充。

5.3 只看R²不看残差,模型在重污染时段的实际误差被平均掩盖

现象:R² 达到 0.85,报告写得漂亮,但看预测曲线时发现中度污染以上时段预测值整体偏低,模型对高值样本几乎没有区分度。

原因:R² 是整体指标,会被大量普通时段的高预测精度拉高。空气质量数据里大多数时间是优良或轻度污染,这些样本误差小,贡献了大量分数;重污染样本占比小,其误差被平均掩盖。

解决:训练后一定要画残差图,横轴是真实值,纵轴是误差。如果残差呈现明显的漏斗状或者真实值越大误差越大,说明模型对高污染过程学习不足,应该检查是否要加大重污染样本的权重,或针对高值时段构造额外的滞后特征。

5.4 滞后特征构造过头,把目标值直接泄漏给了模型

现象:特征列里包含PM2.5_lag1之后模型效果特别好,MAE 低到不真实,换了一个时段的数据后表现大跌。

原因:df['PM2.5_lag1'] = df['PM2.5'].shift(1)本身是合法的特征,但如果训练集和验证集切分前没有 dropna,验证集开头的样本会引用到训练集末尾的目标值。更隐蔽的是,如果数据里存在重复索引或排序错误,shift会串行到未来数据上。

解决:构造完滞后特征后,统一 dropna,并检查特征列与目标列之间是否有任何一列引用了目标列的未来值或同刻值。一个实用的检查方法:训练一个仅含全部滞后特征的模型,若其验证集 MAE 异常低,立刻怀疑泄漏。

6. 复盘时最该做的两个进阶动作:残差分段统计与重污染加权验证

项目做到这里,预测流程已经能跑通,但算不算“做透”还有距离。我一般会在交付前做一次残差分段统计——把验证集按真实 PM2.5 浓度分成五档(优、良、轻度、中度、重污染),分别计算 MAE 和 RMSE。这步能直接暴露模型在哪一档上力不从心。

import numpy as np df_valid = valid.dropna().copy() df_valid['pred'] = xgb_model.predict(df_valid[feature_cols]) df_valid['abs_err'] = (df_valid['PM2.5'] - df_valid['pred']).abs() bins = [0, 35, 75, 115, 150, 500] labels = ['优', '良', '轻度', '中度', '重度'] df_valid['level'] = pd.cut(df_valid['PM2.5'], bins=bins, labels=labels) seg_stats = df_valid.groupby('level')['abs_err'].agg(['mean', 'max', 'count']) print(seg_stats)

如果重污染档的误差明显偏大,可以考虑两条路:一是对重污染样本加大损失权重,XGBoost 中可通过scale_pos_weight或自定义样本权重实现;二是在特征层补充重污染过程的持续性特征,例如过去 72 小时的污染物累积量而非仅 24 小时。

分段统计之外,我习惯再画一张时间序列对比图,横轴是时间,纵轴是预测值和真实值两条曲线。图比任何指标都更能说明问题——你会直观看到模型在污染过程起峰时是不是慢半拍,以及夜间逆温时段是否系统性偏低。这是我自己的经验:把所有评估交付物做完后,项目才算真正能拿得出手。数据挖掘实战说到底是踏实看数据、反复查泄漏、敢于承认模型边界的过程。如果上面的步骤能帮你少走一轮弯路,很希望这篇实践笔记能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询