ARTICLE DETAIL

资讯详情

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

AQI预测实战:从数据清洗到模型部署的完整链路

AQI预测实战:从数据清洗到模型部署的完整链路 简介这是一份面向机器学习与数据分析初学者的《AQI分析与预测》实战资源聚焦空气质量指数回归建模任务覆盖从数据清洗、探索性分析到模型训练与评估的完整流程适合想用Python pandas及scikit-learn完成环境数据项目的学习者。压缩包共33个文件、约4.23MB核心包括两个可运行的ipynb代码、data.csv原始数据集、HTML可视化页面和Markdown说明文档另有24张png结果图目录结构清晰方便对照代码与图表理解每一步分析结论。数据以PM2.5、PM10、NO2等污染物浓度及温度、湿度、风速等气象因素为主详细演示缺失值处理、标准化、特征相关性分析等预处理动作并构建线性回归、随机森林等预测模型使用MSE、RMSE、R²等指标评估效果。已有197人学习下载对刚接触机器学习项目流程、想参考规范数据分析范式的读者具有直接参考价值。1. 重度污染天为什么总是预测不准AQI分析与预测到底在做什么城市空气质量监控大屏上AQI空气质量指数预测值在“良”到“轻度污染”区间看着还挺准可一到重污染过程就集体翻车预报值和实测值能差出两三个等级。这个现象在环境数据相关的机器学习项目里非常典型。原因大多不在模型本身而在数据处理口径和训练集分布上——重污染样本本来就少模型天然偏向预测“安全”的值。这篇笔记围绕 AQI 分析与预测的完整链路展开AQI 分指数怎么按国标算出来、监测数据怎么清洗和构造特征、线性回归和树模型各自的落地表现、以及把模型变成定时批量预测任务时一定会遇到的坑。整篇的代码和数据组织方式都按 Python pandas scikit-learn XGBoost 这一套最常见的机器学习技术栈来写适合刚接触机器学习预测项目的工程师也适合做环境数据、气象数据或智慧城市相关业务的人照着复现。2. 从监测数据到AQI分指数先复现计算口径再整理数据管道2.1 AQI不是实测值而是分段函数IAQI的计算口径很多新手拿到监测数据后第一反应是“直接用 PM2.5 浓度做回归然后再把预测浓度换成 AQI”。这个做法不是不行但会引入一层不必要的误差因为 AQI 对浓度做了分段线性变换同样的浓度误差落在不同区间AQI 误差完全不同。更稳的做法是先把 AQI 序列完整算出来再决定预测目标是 AQI 本身还是等级。AQI 的定义是 6 项污染物分指数IAQI的最大值。单污染物分指数的计算公式是分段线性插值import pandas as pd import numpy as np # IAQI分段界限表格式: (浓度下限, 浓度上限, IAQI下限, IAQI上限) # 这里只列出PM2.5和PM10完整实现需要补全SO2/NO2/CO/O3共6项 pm25_table [ (0, 35, 0, 50), (35, 75, 50, 100), (75, 115, 100, 150), (115, 150, 150, 200), (150, 250, 200, 300), (250, 350, 300, 400), (350, 500, 400, 500), ] pm10_table [ (0, 50, 0, 50), (50, 150, 50, 100), (150, 250, 100, 150), (250, 350, 150, 200), (350, 420, 200, 300), (420, 500, 300, 400), (500, 600, 400, 500), ] def calc_iaqi(conc, table): 按国标HJ 633-2012的分段线性插值公式计算单一污染物IAQI for c_lo, c_hi, i_lo, i_hi in table: if c_lo conc c_hi: return (i_hi - i_lo) / (c_hi - c_lo) * (conc - c_lo) i_lo if conc table[-1][0]: return table[-1][3] return 0 # 示例PM2.5浓度为82.5 ug/m3时IAQI约为109对应轻度污染 print(calc_iaqi(82.5, pm25_table))这段代码的核心逻辑是浓度落在哪个区间就用该区间上下限做线性插值。这里有两个必须注意的口径问题。第一个是区间边界当浓度恰好等于 35 时应该落入第二个区间所以循环判断用的是c_lo conc c_hi下闭上开。第二个是 O3 的处理O3 的 AQI 要同时计算 1 小时平均和 8 小时滑动平均两种分指数取更高的那个参与 AQI 计算日数据里很容易漏掉这一点。CO 的单位是 mg/m³其他 5 项是 μg/m³把单位统一了再套表否则结果差出几十倍。完整算 AQI 时把 6 项污染物的 IAQI 都算出来后取 max就是该小时的 AQI。我一般会在特征工程阶段把每个小时的 AQI、以及每个小时的主要污染物也就是 IAQI 最大的那一项都存下来主要污染物本身就是一个有用的特征。2.2 数据集怎么组织一张能直接读进 pandas 的监测表这个项目里“数据”指的不只是 AQI 历史值还包括气象数据因为温度、湿度、风速、气压对污染物累积和扩散影响很大没有气象特征AQI 预测的上限会很矮。常见的做法是申请公开监测站点的逐小时数据再把气象站数据按时间戳对齐合成一张宽表。# 原始数据长这样列名统一成小写时间列放到第一列 import pandas as pd df pd.read_csv(aqi_hourly.csv, parse_dates[datetime]) df df.set_index(datetime).sort_index() print(df.shape) print(df.columns.tolist()) print(df.head(3))datetime,PM25,PM10,SO2,NO2,CO,O3_8h,temp,humidity,wind_speed,pressure 2023-01-01 00:00:00,82.5,116.2,18.3,62.7,1.2,48.9,-2.4,61.3,2.1,1023.5 2023-01-01 01:00:00,88.1,120.4,17.9,63.5,1.3,49.1,-3.1,63.0,1.8,1024.1 2023-01-01 02:00:00,91.3,125.6,17.5,64.2,1.4,49.0,-3.6,65.2,1.5,1024.8这张表已经是重采样成整点小时后的数据了。原始监测数据不一定正好落在整点有的站是每 5 分钟一条有的站偶发缺测所以第一件事永远是统一时间分辨率。我一般取“小时”作为预测粒度因为 AQI 的发布口径就是整点小时值用更高频的数据只会增加对齐成本不会带来明显的精度收益。数据的时间范围也有讲究。如果要做“预测明天的 AQI”拿到的数据就必须至少包含一整年因为空气质量有很强的季节性冬天和夏天的污染成因完全不同。只有两三个月的数据模型学到的只是当前季节的规律换季就失效。数据量级上一年的逐小时数据大约是 8760 行这个规模对 pandas 来说是小数据不需要上 Spark 或数据库单机内存完全能跑。2.3 清洗和拼接时间对齐、缺失值、异常值监测数据最常见的脏数据有三种时间戳错位、浓度负值、长时间缺失。时间戳错位通常出现在手工拼接的 Excel 表里表现为某一天的数据整体偏移了几个小时肉眼难发现但会直接破坏滞后特征。浓度负值是仪器零点漂移导致应该置空而不是保留。长时间缺失则发生在设备维护期间动辄一整天的空档这时线性插值已经不合适了。import numpy as np # 1) 统一到整点小时多个站点数据同时存在时取均值聚合 df df.resample(h).mean() # 2) 浓度负值属于仪器异常直接置空 conc_cols [PM25, PM10, SO2, NO2, CO, O3_8h] for col in conc_cols: df.loc[df[col] 0, col] np.nan # 3) 短缺失6小时用线性插值长缺失先用线性插值再回填 df df.interpolate(limit6, limit_directionboth) df df.fillna(methodbfill).fillna(methodffill) # 4) 丢弃仍然存在缺失的行这些行做特征时会产生脏滞后值 df df.dropna() print(f清洗后剩余 {len(df)} 条记录缺失率 {df.isna().sum().sum()})参数limit6表示只对连续缺失不超过 6 个小时的窗口做插值超过 6 小时的完整空缺交给 bfill 和 ffill 处理。这个阈值不是拍脑袋定的6 小时以内的小缺口做线性插值不会引入明显偏差但一整天的缺测如果也用插值会把一段完全虚构的曲线塞进序列里后续构造滞后特征时这堆假数据会被模型当真。清洗完后建议顺手做一次可视化检查把 PM2.5 和 AQI 的时序曲线画出来重点看有没有“平台期”——连续多个小时完全一样的值通常是仪器卡死不是真实大气状态。平台期在插值后依然会保留需要额外检测df[PM25].diff().eq(0)连续出现 6 次以上就标记为异常。3. 特征工程和时间切分用线性回归把第一个模型跑起来3.1 滞后特征和滚动统计量AQI预测的核心就是吃惯性AQI 序列有很强的自相关性今天的 PM2.5 浓度很大程度上由过去几小时的累积决定所以滞后特征是这套方案里最重要的特征组。除了污染物浓度本身的滞后值气象特征同样要构造滞后和滚动统计量尤其是风速和湿度它们对污染物的稀释和二次生成起作用。# 污染物浓度滞后特征过去1、2、3、6、24小时 for lag in [1, 2, 3, 6, 24]: df[fPM25_lag{lag}] df[PM25].shift(lag) df[fPM10_lag{lag}] df[PM10].shift(lag) # 滚动统计量3小时均值反映短时累积24小时均值反映日循环背景 df[PM25_roll3_mean] df[PM25].rolling(3).mean() df[PM25_roll24_mean] df[PM25].rolling(24).mean() # 气象特征同样做滞后风速滞后24小时可以捕捉前一天的大风扩散效果 df[wind_speed_lag3] df[wind_speed].shift(3) df[wind_speed_lag24] df[wind_speed].shift(24) # 时间特征小时和星期用来表达交通排放的日循环和周末效应 df[hour] df.index.hour df[weekday] df.index.weekday # 生成目标列预测未来1小时的AQIshift(-1) 是标签不允许参与训练 df[target] df[AQI].shift(-1) df df.dropna()shift(1)和shift(-1)的方向必须想清楚shift(1)拿到的是上一小时的值属于历史信息shift(-1)拿到的是下一小时的值这就是要预测的标签。建模时只能把shift(1)及更早的信息放进特征矩阵shift(-1)放进目标列。代码里我把 target 和其他特征放在同一个 DataFrame 里后期切分时再严格按特征列筛选这是一个容易查半天才发现问题的隐藏坑。rolling(24)会引入未来问题吗不会滚动窗口只往后看。但要注意.rolling(24).mean()在计算当前小时时用的是“过去 24 小时”得到的值本质上还是滞后信息建模时不会泄漏。滞后 24 小时会产生 24 个 NaNdropna()会丢掉序列最开始的一天数据这是可接受的代价。3.2 时间序列切分训练、验证、测试不能随机打乱常规机器学习项目习惯用train_test_split按比例随机切分这在 AQI 预测里是严重错误。大气过程是连续演化的昨天和今天的样本高度相关随机打乱后训练集里会出现“用明天的信息预测昨天”的幻觉测试集分数会虚高到不真实。正确的做法是严格按时间顺序切三段。# 按时间切分不用 train_test_split train df.loc[2023-01-01:2023-10-31] val df.loc[2023-11-01:2023-11-30] test df.loc[2023-12-01:2023-12-31] # 特征列排除掉索引列、ID列、目标列 exclude_cols [target, AQI] feats [c for c in df.columns if c not in exclude_cols] print(f特征数量: {len(feats)}) X_train, y_train train[feats], train[target] X_val, y_val val[feats], val[target] X_test, y_test test[feats], test[target]三段切在整月边界是刻意为之——让验证集独立覆盖一个完整月的天气过程而不是随机抽零散的天。训练集用 10 个月验证集和测试集各 1 个月比例大约 8:1:1对小时级数据来说训练量已经足够。如果你手里的数据只有 3 个月可以把比例放宽到 7:2:1但一旦切出来的测试集时长少于两周评估指标会随着个别重污染过程的出现而剧烈波动说服力不足。验证集和测试集的作用要分开验证集用来在模型训练时做早停和参数筛选测试集只在最终确定方案后跑一次。如果针对测试集反复调参数测试集就变成了验证集分数就失去了客观性。3.3 线性回归基线先接受一个不完美的结果在推 XGBoost 之前先用线性回归跑一版基线目的不是追求精度而是验证特征管道和数据切分没有低级错误。如果线性回归在验证集上的 R² 都达不到 0.6大概率是特征或清洗有问题这时候换再复杂的模型也是白费。from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score model LinearRegression() model.fit(X_train, y_train) # 验证集评估 y_pred_val model.predict(X_val) rmse_val mean_squared_error(y_val, y_pred_val, squaredFalse) mae_val mean_absolute_error(y_val, y_pred_val) r2_val r2_score(y_val, y_pred_val) print(f验证集 RMSE: {rmse_val:.2f}) print(f验证集 MAE: {mae_val:.2f}) print(f验证集 R²: {r2_val:.4f})mean_squared_error(..., squaredFalse)在 scikit-learn 1.4 之后被标记为废弃建议改用root_mean_squared_error。如果你跑代码时遇到 DeprecationWarning直接换成新写法即可不影响数值结果。这三个指标配合使用RMSE 对大误差敏感能暴露极端预测失误MAE 反映平均偏移水平R² 衡量相对基线预测全部均值的改进程度。一个健康的 AQI 小时级预测基线是 R² 在 0.85 到 0.92 之间听起来高得离谱但这正是滞后特征带来的“便宜分数”——AQI 本来就是一个强自相关序列能预测准很大程度上是因为知道过去几小时的值。反过来如果线性回归在没有任何滞后特征的条件下还能有高 R²那才要怀疑数据泄漏了。4. 换上随机森林和XGBoost参数与特征重要性的落地经验4.1 树模型在这个问题上的优势非线性、特征交互、缺失值容忍度线性回归在 AQI 预测上能打是因为滞后特征已经抓住了线性主效应。但实际场景里 AQI 和气象、排放之间有明显非线性交互高温低风速时 O₃ 生成加速湿度高于 80% 时 PM2.5 吸湿增长这些规律线性模型表达不了。树模型能自动拆出这些交互而且不需要做特征归一化省掉 StandardScaler 这一步。模型对非线性的表达特征归一化缺失值处理可解释性训练速度线性回归差需人工构造交互项需要需提前填充强系数直接可读极快随机森林中可表达分段交互不需要原生支持中特征重要性快XGBoost强可表达复杂交互不需要原生支持中偏弱较快随机森林和 XGBoost 之间的取舍不是精度问题而是稳定性和调参成本。随机森林参数少默认参数跑出来的结果就接近可用XGBoost 上限更高但需要处理学习率、树深度、正则化系数之间的配合新手容易在验证集上反复横跳。我的习惯是第一版树模型用随机森林把特征管道跑顺再上 XGBoost 看增量。4.2 一组能直接用的初始参数和早停训练XGBoost 在这个规模的数据集上训练时间以秒计所以参数可以放心地往偏大的方向设配合早停来避免过拟合。下面这组初始参数我在多个城市的气象数据上都用过效果稳定适合作为第一版起点。import xgboost as xgb model xgb.XGBRegressor( n_estimators2000, # 先给足树的数量靠早停截断 learning_rate0.05, # 学习率太低很慢太高早停前学不完 max_depth5, # 深度控制单棵树的复杂度 subsample0.8, # 行采样每棵树用80%样本 colsample_bytree0.8, # 列采样每棵树用80%特征 reg_lambda1.5, # L2正则压制树对单一特征的过度依赖 random_state42, verbosity0, # 控制台不输出训练日志 ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], verboseFalse, )n_estimators给到 2000 看起来夸张但因为有early_stopping_rounds训练会在验证集指标连续多轮不再提升时自动停下最终实际树数可能只有几百棵。这里的早停参数要注意版本差异新版 XGBoost 建议把early_stopping_rounds放进fit()旧版本放在构造函数里否则会报警告。max_depth5对小时级表格数据够用了再深容易把噪声也背下来colsample_bytree0.8能保证每棵树只看 80% 的特征让重要性分布更均匀也有一点对抗特征间共线性的作用。随机森林对照版同样值得跑一遍主要是为了比较训练速度和精度曲线。随机森林没有早停机制树的棵数直接决定模型大小512 棵已经是计算量和精度的平衡点。4.3 特征重要性看看模型学的是规律还是记忆训练完之后第一件事不是看测试集分数而是看特征重要性排序。这个步骤能暴露两类问题一是模型重度依赖某一个时间特征说明序列里有隐藏周期性被模型记住了二是某些物理上不太可能起作用的特征排得异常靠前说明有泄漏。# 特征重要性XGBoost 自带的 gain 表示特征平均增益比默认的 weight 更可靠 imp pd.Series( model.feature_importances_, indexfeats ).sort_values(ascendingFalse) print(imp.head(15))feature_importances_默认的weight统计的是特征被分裂的次数容易被取值多的高基数特征虚高手动指定importance_typegain按平均信息增益排序更有参考意义。看结果时记住一个规律在加入滞后特征的场景里“AQI 滞后 1 小时”“PM2.5 滞后 1 小时”占据前两名是合理的说明自回归在起作用。但如果排第一的是“hour”小时就要检查是不是目标列和特征列错位了。另一个值得做的检查是把时间特征hour、weekday单独从特征列表里剔除重训一次对比验证集分数。如果分数几乎不变说明模型的预测能力全部来自污染物和气象的物理过程如果分数暴跌说明模型的“高精度”有一部分来自记住一周里哪天污染重——这在长期预测里是虚假的记忆换个季节就失效。5. AQI预测的5个高频踩坑点现象、原因、解决5.1 重污染样本占比太低预测结果永远偏向“良”现象整体 RMSE 看着不错每季度的重污染天全部没报出来等级命中率一统计只有 30%。原因全年数据里“优”和“良”占了大头模型优化 RMSE 时天然倾向把预测值压向样本分布密集的区间重污染样本少错得再多对整体损失贡献也小。解决一是评估时按 AQI 分级分别看 RMSE重污染段的误差单列二是训练时给重污染样本加权XGBoost 里可以用sample_weight参数三是采样时保证训练集里重污染时段不过度下采样。5.2 把“未来”的数据混进了特征测试集分数好得反常现象验证集 R² 到了 0.97 以上比正常发挥高出太多而且特征重要性里某个气象特征排名异常靠前。原因最常见的泄漏是直接把当天的实测气象“预报值”当成特征或者做滚动均值时把窗口开到了未来。某些数据集里预先算好的“当日均值”列也是泄漏源因为它在整日数据齐全之前根本不存在。解决检查所有特征列只保留shift(k)且k 1的列滚动统计统一用closedleft和shift(1)落在过去窗口上。写特征工程代码时把“特征可获取时间”和“标签时间”的差显式注释在代码里比事后排查高效得多。5.3 模型换了一个城市就失效精度掉一半现象同一份代码、同一套参数在另一个城市的数据上 R² 从 0.90 跌到 0.65。原因不同城市的污染物构成差异大。有的城市 PM2.5 主导有的城市 O₃ 主导O₃ 的季节曲线和日曲线与 PM2.5 完全不同用 A 城市训练出的特征交互关系在 B 城市不成立。解决不要跨城市直接迁移模型。常见做法是每个城市单独训练至少采用该城市一年的数据如果数据量不足一年至少用迁移学习的方式把原城市的模型作为初始版本用新城市数据微调最后几轮迭代。5.4 RMSE被个别重污染日拉爆单日误差解释了整个误差现象RMSE 远大于 MAE 的合理比例去掉最严重的一天测试样本RMSE 骤降 30%。原因MSE 对大误差做平方惩罚一次“预报重度污染结果实际爆表”的极端失败相当于几十个普通样本的损失总和。解决报告多看 MAE 和中位数误差如果业务上不希望为极端情况付出平方惩罚把 XGBoost 的损失函数从默认的回归改成reg:pseudohubererrorHuber 损失对大残差的惩罚是线性的预测会更稳。5.5 同一份代码在不同机器上跑出的分数对不上现象同事用同样数据跑出 R²0.91你这边只有 0.87特征数量一模一样。原因scikit-learn 和 XGBoost 的版本差异会导致默认参数行为变化另一个高发原因是随机数种子没固定树模型的分裂点选择带有随机性。解决在项目根目录放 requirements.txt锁住关键库版本所有带随机性的模型参数都显式传入random_state训练前用numpy.random.seed()固定全局种子。这套动作做完同一份代码在不同机器上的分数差应该能控制在 0.01 以内。6. 把模型变成定时批量预测任务滚动预测明天的AQI并监控漂移6.1 用今天已有的监测值滚动预测未来24小时训练时我们的目标列是“下一小时 AQI”但业务上更常用的口径是“预测明天一整天的 AQI”也就是从当前时刻出发滚动地用“预测值”反哺“滞后特征”。这需要写一个滚动预测脚本先加载最新的监测数据构造特征预测第一个小时然后把预测值当作滞后特征喂给下一个小时的预测。import pandas as pd import numpy as np # 已有清洗好的最新数据 latest_df训练好的模型 model # 特征函数复用训练时的管道只是 shift 的方向要调整 def build_features_for_forecast(df, horizon24): # 先构造原始特征含shift再循环外推 out df.copy() for h in range(horizon): if h 0: # 第一个预测步所有滞后特征都来自真实观测 pred model.predict(out[feats].iloc[[-1]])[0] else: # 后续步把上一小时预测值填回去 out.loc[new_time, PM25] pred # 重建该小时的滞后/滚动特征 out append_features_for_single_row(out, new_time) pred model.predict(out[feats].iloc[[-1]])[0] new_time out.index[-1] pd.Timedelta(hours1) preds.append(pred) return preds这段代码隐藏了一个绕不开的误差累积问题预测步数每多一步模型的输入里“预测值”的比例就越高误差会逐渐放大。所以滚动预测结果要比单步预测结果更保守尤其在重污染过程中。业务上如果只需要“明天平均 AQI 等级”建议把 24 小时的逐小时预测值再取平均而不是直接用某个小时的预测值下结论。6.2 模型漂移监控每周重训还是每日增量更新AQI 预测系统上线后模型漂移来得非常快——污染源变化、气象模式变化、甚至监测仪器更换都会让特征分布悄悄偏移。常见做法是每天把最新数据追加进训练集每周全量重训一次而不是每天重训避免模型被近期噪声带偏。监控指标上用滚动 7 天的 RMSE和历史值比较超过历史 P90 就触发告警。# 伪代码每周重训任务 latest_df load_clean_up_to_yesterday() df_all pd.concat([historical_df, latest_df]).drop_duplicates() train_data, val_data time_split(df_all) model train_xgboost(train_data, val_data)重训频率不是越勤越好。AQI 数据是强自相关的最近几周的噪声在全局样本里占比很低每日重训耗时不会很短精度提升却有限。每周重训加上月度归档是时间和效果之间比较平衡的选择。6.3 业务认的是等级命中率而不是RMSEAQI 预测做出来之后业务同事最关心的不是 R² 或 RMSE而是“预报的等级准不准”。环境管理部门发布的是“优、良、轻度、中度、重度、严重”6 个等级一个预测误差哪怕 RMSE 很小只要跨了等级边界就是一次预报失误。所以最终验证至少要把预测值映射回等级算一次等级命中率。def aqi_to_level(aqi): AQI等级映射边界值和国标保持一致 if aqi 50: return 优 elif aqi 100: return 良 elif aqi 150: return 轻度 elif aqi 200: return 中度 elif aqi 300: return 重度 else: return 严重 y_pred_level y_pred.apply(aqi_to_level) y_true_level y_test.apply(aqi_to_level) level_acc (y_pred_level y_true_level).mean() print(f等级命中率: {level_acc:.2%})等级命中率里有个“宽恕”现象相邻等级的误判通常被业务接受差两个等级才是真正的失败。所以除了整体命中率再算一个“误差不超过 1 级”的容忍命中率整体命中率到 60% 以上、容忍命中率到 90% 以上基本满足业务发布要求。我自己的习惯是每个季度重新看一次特征重要性和重污染日误差分布换模型之前先确认是物理过程变了还是数据质量下降了。预测这种东西翻一次车就知道哪几个环节不能偷懒了。这套链路做完剩下就是持续维护数据管道和监控漂移的事情希望帮到你。本文还有配套的精品资源点击获取
返回列表