Pandas+Sklearn实现PM2.5预测:时间特征工程与防泄露指南
先说个容易被忽略的事实用Sklearn和Pandas做PM2.5预测真正决定模型上限的从来不是某个神级算法而是你有没有把时间这个维度处理干净。我之前用一套小时级空气监测数据搭建预测流程第一次跑随机森林就拿到R²0.86本来挺高兴结果排查特征时发现自己在构造滞后特征的过程中不小心把未来信息带进了训练集——典型的数据泄露修正之后分数立刻掉了不少。这个项目最终要做到的事就是基于Pandas完成监测数据的清洗、缺失值处理、时间切分再用Sklearn训练一组回归模型预测未来1小时PM2.5浓度并且让这套流程能快速迁移到其他城市、其他站点的数据上。整体方法不复杂但里面这几个关键步骤每一个都不能省省了后面必返工。1. 这类预测先把问题定义清楚单步回归、时间边界和那批带时戳的监测数据1.1 预测目标未来1小时浓度拿到一份空气监测数据通常不是那种规整的一行一个样本的表格。基础数据长这样站点在固定时间间隔多数是每小时或每3小时记录一批污染物浓度和气象参数常见字段包括PM2.5、PM10、SO₂、NO₂、CO、O₃、温度、气压、湿度、风速、风向、站点编号。字段再多最后都要整理成同一时间轴上的宽表。预测任务在这里定义得很具体用t时刻及t时刻之前的所有已知信息预测t1时刻的PM2.5浓度。就是单步回归。不是预测未来24小时不做多步递推先把这一步做稳后续要扩展成多步预测也有基础。单步预测的好处是标签构造简单把整条时间序列往后平移一位就是目标值。时间PM2.5温度湿度风速目标t1的PM2.508:008215.2612.19609:009616.0572.48810:008816.8552.97411:007417.5523.468这张表就是一个窗口视角每一行特征对应的是当前时刻观测值模型要学习的是这些观测值到下一时刻浓度的映射关系。1.2 为什么定成回归问题而不是分类问题有人会把预测改成分类优、良、轻度污染、中度污染、重度污染、严重污染。分类做起来也不难但我不推荐作为第一版。原因很简单分类把连续浓度离散化之后阈值附近的预测误差会被放大成完全不同的类别。比如真实浓度是76模型预测74按国标分级一个落在良、一个落在轻度污染分类系统会认为你错得很离谱但回归模型只会看到MAE2的极小误差这个误差本身是完全可以接受的。回归输出连续值后续想转成任何阈值都能灵活处理。比如业务需求是未来一小时PM2.5是否会超过100回归预测值可以直接和100比较也可以进一步用残差分布计算超限概率可塑性远高于上来就分类的方案。1.3 时间性是这里最大的隐藏变量普通机器学习任务默认样本之间独立同分布但这种假设在空气质量数据上完全不成立。PM2.5在短时间间隔内高度自相关这一小时的浓度大概率接近上一小时一天之内有明显日周期早高峰、午间光化学反应、夜间积聚各有各的模式特殊事件比如沙尘、烟花燃放又会让浓度出现剧烈跳变。这意味着随机切分训练集和测试集是错误操作。如果按随机顺序打散那么测试集里会出现大量与训练集相邻时刻的相似样本模型等于见过了一半答案验证分数会虚高。所以后面所有操作的核心原则只有一条时间顺序是铁律一切切分、验证、特征构造都不能越过这条边界。2. 数据处理的关键动作缺失值修补、时间切分和Pandas里直接上手的整理代码2.1 读入数据与时间索引这一阶段的目标是把原始CSV整理成一个连续的、按时间升序排列的DataFrame。代码不长但有几个点必须做对import pandas as pd import numpy as np df pd.read_csv(air_quality.csv, parse_dates[date]) df df.set_index(date).sort_index() # 只看某个站点的数据避免多个站点混合 df df[df[station] A站].drop(columns[station])parse_dates[date]会把日期字符串解析成真正的时间类型这样后续的滞后、滚动窗口操作才能按时间顺序正确计算。sort_index()看起来是个不起眼的动作但原始数据很可能不是严格按时间排列的一旦顺序乱了shift的结果就会出错后面所有特征都白算。这里还建议把站点筛选提前做掉。多站点数据混在一起直接训练模型会被迫学习站点差异预测特定站点时反而更差。第一版就聚焦单站点逻辑最简单效果也最好分析。2.2 缺失值处理不是无脑插值监测设备定期校准、通信故障都会导致数据出现空洞。PM2.5的缺失值不能图省事直接删也不能全局均值填充。删掉整段样本会丢失连续性而全局均值填充等于把未来的统计信息倒灌进过去后面特征工程一做就出问题。我的处理策略是分缺口长度区别对待# 负值通常是传感器故障先置为缺失 pm_cols [pm25, pm10, so2, no2, co, o3] for col in pm_cols: df.loc[df[col] 0, col] np.nan # 短缺口线性插值 df df.interpolate(methodlinear, limit6, limit_areainside) # 长缺口用前向填充兜底 df df.ffill() # 目标列仍有缺失的直接剔除 df df.dropna(subset[pm25])短缺口指连续缺失不超过6小时线性插值足够合理因为污染物浓度在几小时内通常不会剧烈变化。长缺口则要看情况如果某个站点连续停测十多个小时靠前后插值补出的数值已经不太可信这时候宁可让前面填不上来的部分保持为空最后统一删除。实际操作里我见过最稳妥的做法是直接删掉目标列有缺失的行因为预测模型对特征少许缺失容忍度还行但标签必须是真实观测值插值出来的标签只会把模型训练偏。2.3 时间切分训练集和测试集必须按时间顺序这一步最容易被新手忽略。很多人习惯直接用train_test_split它的默认参数是shuffleTrue会把样本随机打乱。这么做放在普通表格数据没问题放在时间序列上就是灾难。正确切法split_idx int(len(df) * 0.7) train df.iloc[:split_idx] test df.iloc[split_idx:] # 再加一段作为最终验证集 val_start int(len(train) * 0.8) train_part train.iloc[:val_start] val_part train.iloc[val_start:]如果数据覆盖时间较长且跨年份更好的方式是按年份留一验证用前两年训练用第三年的对应季节做验证。这样能验证模型在季节变化上的稳定性。但无论哪种切法原则都一样前面的数据进训练集后面的数据进测试集顺序不能乱。等下还要说交叉验证也得换成时间序列专用的方式。普通KFold是随机分组的TimeSeriesSplit才符合用过去预测未来的逻辑后面模型评估那部分再细讲。3. 特征工程里真正决定模型上限的三件事滞后特征、气象因子与防未来信息泄露的时点对齐3.1 滞后特征为什么是PM2.5预测的灵魂PM2.5浓度是一个典型的自相关过程。上一小时浓度高下一小时大概率也不会突然掉下来夜里浓度积聚到高点早上一旦出现逆温层可能持续高位。这种持续性必须用滞后特征让模型感知到。我常用的滞后阶数是1、3、6、24lag1捕捉最直接的短期惯性lag3对应3小时左右的污染团过境过程lag6刻画半天的污染积累或扩散节奏lag24捕捉去年同期式的日周期因为PM2.5浓度受通勤、光化学过程影响存在明显的24小时节律。df[pm25_lag1] df[pm25].shift(1) df[pm25_lag3] df[pm25].shift(3) df[pm25_lag6] df[pm25].shift(6) df[pm25_lag24] df[pm25].shift(24)这组特征是整套特征集里的主心骨。后面调优时你会看到加入lag24这一列对模型的提升往往比换一个模型幅度还大。3.2 滑动窗口和气象因子怎么加除了单个滞后点还需要统计量。滑动窗口能告诉模型过去一段时间整体的均值水平和波动程度。比如过去24小时均值是40当前是60说明浓度正在上升过去24小时均值是90当前是60说明在下降。波动程度标准差则能反映气象过程是否剧烈。df[pm25_mean_24h] df[pm25].rolling(24).mean().shift(1) df[pm25_std_6h] df[pm25].rolling(6).std().shift(1) df[pm25_max_3h] df[pm25].rolling(3).max().shift(1)注意这里每个滚动统计量后面都跟了一个.shift(1)。为什么要加因为rolling窗口默认包含当前时刻而当前时刻的PM2.5是输入特征不是预测目标这个边界本身没问题但如果不加shift当我们在做特征工程时当前时刻的统计量还没有完全尘埃落定——严格来说rolling(24).mean()在t时刻使用的是t-23到t这24个点都是已知信息所以不加shift也不算未来泄露。不过我在实践中倾向于统一加shift(1)把当前时刻剔除在外只保留严格的历史信息窗口。这样做能避免很多边界上的糊涂账。气象因子里温度、湿度、气压、风速直接作为连续特征用。风向不是角度直接入模而是拆成sin和cos两个分量这样角度360度和0度在数值上就不会被错误地区分为远距离。湿度对PM2.5的影响尤其明显高湿环境下二次颗粒物生成加快风速大则有利于扩散这些关系树模型都能自动学不用我们手写规则。df[wind_dir_sin] np.sin(np.deg2rad(df[wind_dir])) df[wind_dir_cos] np.cos(np.deg2rad(df[wind_dir]))3.3 最常见又最隐蔽的坑时点对齐与数据泄露数据泄露是我在这个项目中反复强调的问题。它的表现形式有好几种第一种填充缺失时用了全局统计量。比如某列缺失了直接用整列均值去填。这个均值是整段时间的平均值包含了未来信息模型在训练时偷看到了未来分布测试时表现就会被高估。正确做法是只用该时间点之前的历史数据计算填充值或者干脆用相邻站点数据。第二种构造特征时用到了未来的观测。比如想构造过去3小时变化率写代码时一不注意用了shift(-1)之类把未来1小时的值卷了进来造成标签和数据重叠。这种情况其实比大家想象中更常见因为shift的方向、对齐顺序很容易写反。第三种训练集和测试集的shift没有连续衔接。如果对训练集单独做一次shift对测试集单独做一次shift那么测试集的第一行样本会丢失训练集最后一小时的信息导致特征缺失。正确做法是在全量数据上完成所有滞后和窗口特征构造然后再按时间切分数据集。这样切分后的边界数据是完整的。给一个自查方法构造完特征后打印一下每一列最后几行的值确认滞后列里没有出现未来的数值。再用一个简单的相关性检查一个可靠的特征应该与目标相关但绝不能是等于或近似等于目标本身。3.4 特征缩放的正确姿势线性模型对特征尺度敏感树模型不敏感。但因为模型比较可能要同时评估这两种类型我统一做一个标准化处理。关键是scaler只能fit在训练集上from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)如果先对整个X做fit再切分scaler就看到了测试集的均值和方差这也算一种轻微的未来信息泄露。别小看这一步重新训练之后实际预测时的数据分布和训练时不一致分数会掉得让你莫名其妙。4. 五组模型速测记录从线性回归到梯度提升的取舍4.1 一个能直接跑的速测脚本当特征集准备好之后我不急着调参先做一轮速测把所有候选模型在同一条时间序列交叉验证上跑一遍看谁的基础表现最好。Sklearn里能用的回归模型很多我固定测这几个from sklearn.linear_model import LinearRegression, Ridge from sklearn.ensemble import RandomForestRegressor, HistGradientBoostingRegressor from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import TimeSeriesSplit, cross_validate models { linear: Pipeline([ (scale, StandardScaler()), (lr, LinearRegression()) ]), ridge: Pipeline([ (scale, StandardScaler()), (ridge, Ridge(alpha1.0)) ]), rf: RandomForestRegressor( n_estimators300, max_depth16, min_samples_leaf2, n_jobs-1 ), hgb: HistGradientBoostingRegressor( max_iter300, learning_rate0.05, max_depth5 ), } tscv TimeSeriesSplit(n_splits5) for name, model in models.items(): results cross_validate( model, X, y, cvtscv, scoring(r2, neg_mean_absolute_error), n_jobs-1 ) print(name, R2:, round(results[test_r2].mean(), 4), MAE:, round(-results[test_neg_mean_absolute_error].mean(), 4))这里为什么用TimeSeriesSplit而不是KFold因为KFold随机分折会允许模型用未来的数据去预测过去的数据分数虚高且不符合实际部署时的逻辑。TimeSeriesSplit每次都把前面的数据当训练、后面的当测试严格模拟用历史预测未来的真实场景。如果你见过有人用随机交叉验证在时间序列上跑出漂亮分数那基本可以断定分数里掺了水分。4.2 模型表现的典型排序与原因在一份特征工程做得干净的中小型监测数据集上我实测多次表现一般呈现这种格局模型R²MAE说明LinearRegression0.8511.6强baseline只要特征里有足够滞后项Ridge0.8511.5比线性回归略稳缓解多重共线性RandomForest0.899.3能抓住非线性对异常值更稳HistGradientBoosting0.918.1表格数据上通常最强但需要控制迭代次数树模型占优的本质在于它能自动寻找特征之间的交互作用。线性回归在风速1且湿度80这种组合下只能通过风速和湿度各自的线性系数累加而梯度提升树会把这种组合当成一个新条件分别学习不同组合下的响应。气象因子和污染物浓度之间的关系本来就高度非线性树模型天生贴合。随机森林在这个场景下表现也不差而且调参压力小。它的问题在于对训练集尾部的极端污染事件拟合不够细腻——预测值容易被平均化峰值被低估。梯度提升树因为不断拟合残差对这类极端高值更敏感但也更需要在迭代次数、学习率之间找平衡否则容易在训练集上过拟合。4.3 为什么先在Sklearn里测而不是直接上深度模型PM2.5预测任务的可用数据量常见监测数据集通常在几万到几十万行规模这个量级对Sklearn的树模型是完全够用的。神经网络模型在这种结构化表格数据上往往要把特征做大量embedding和归一化处理还要处理序列窗口设计调试成本高收益不一定比梯度提升树更大。除非你的目标是做未来24小时的多步预测并且手上有多年多点位的海量数据否则Sklearn这套流程已经是性价比极高的方案。它能让你在一个晚上就完成特征验证、模型选型、误差分析三个环节而不是把时间耗在搭网络结构上。5. 评估指标不是R²就完事分浓度区间的误差、残差图和一次完整调优记录5.1 R²、MAE、RMSE分别看什么R²是最常见的回归指标但它只反映模型对目标方差的解释比例不能真实反映我预测的数值和真实值差多少。PM2.5浓度分布严重右偏大量时间在低位波动偶尔有几天冲高R²很容易被整体均值水平拉开导致分数看起来很高但每小时的误差其实不小。我自己的评估习惯是三个指标一起看各司其职R²模型整体解释力适合横向比较模型MAE平均绝对误差业务上最直观直接翻译成预测浓度平均差多少μg/m³RMSE对更大误差更敏感适合发现是不是偶尔在极端事件上翻车。同样MAE8的两个模型一个RMSE12一个RMSE16说明后者在重污染时段可能出现了个别离谱偏差这种模型拿去发预警系统关键时刻会掉链子。5.2 分浓度区间的误差更贴近业务一版模型跑完之后我做的第一件事不是为了更好的R²疯狂调参而是把预测结果按真实浓度分段分别统计误差。因为预警系统最关心的是高浓度时段的可靠性。示例真实浓度区间样本量MAE相对误差0-50优38205.221%50-100良29608.912%100-150轻度污染118014.811%150以上中重度污染24024.113%从这张表能读出两层信息。第一绝对误差随浓度上升变大这是正常的因为波动范围更大第二低浓度段的相对误差反而更高这是因为低位时传感器噪声和小幅波动占比更大。如果做预警系统重点盯的是150以上区间对MAE5这种低位波动其实不必过度焦虑。5.3 一次完整调优记录从Baseline到基本能用的版本用模拟记录推演一次典型的迭代过程你可以直观感受每类特征和模型改动的贡献阶段改动MAER²基线线性回归仅lag118.20.68加滞后lag3/lag6/lag2413.60.79加窗口mean_24h/std_6h12.10.82换随机森林默认300棵树9.80.88加气象温度湿度风速风向9.00.90换HGB调低学习率到0.037.80.92这条路径里的关键拐点是第二步加lag24之后模型第一次感知到了日周期分数提升非常明显。其次是最后一步从随机森林切到梯度提升对尾部高值样本的拟合明显改善。5.4 预测值修正和可视化验证树模型输出的预测值理论上不会为负但线性模型在极端低值样本上确实会输出负数。处理方式很简单y_pred np.clip(y_pred, 0, None)。负数浓度没有物理意义截断到0并不会伤害误差统计。验证阶段一定要画图。最简单的三张图真实vs预测散点图看有没有系统性低估散点整体偏到对角线下方或高估时间轴上画一条真实曲线和一条预测曲线看模型是不是跟得上浓度突变的节拍残差直方图看残差在0附近是否对称分布如果长期偏正说明模型系统性低估。有一次我画完时间轴曲线才发现模型在每天早上8点到10点的高峰段误差特别大其他时段都正常。后来排查原因是这个时段存在交通排放突增单点气象特征不够需要在特征里加入节假日标记或者上一小时的变化率。这种问题光看R²根本发现不了。6. 从演示到部署前要处理的几件麻烦事模型保存、滚动更新与漂移监控6.1 把模型和预处理流程一起保存模型跑通之后很多人习惯直接把model对象存成pickle但实际部署时容易漏掉scaler。正确做法是把整个预处理和模型包成一个Pipeline一并保存from sklearn.pipeline import Pipeline import joblib final_pipeline Pipeline([ (scale, scaler), (model, best_model) ]) joblib.dump(final_pipeline, pm25_predictor.joblib) # 新数据进来后直接这样调用 loaded joblib.load(pm25_predictor.joblib) y_pred loaded.predict(X_new)这样预测接口只需要喂入原始特征缩放、预测都自动完成不会出现部署环境里忘了import StandardScaler导致的取不到模型问题。6.2 新数据进来时滚动窗口怎么维护真正上线之后每次预测前需要构造当前样本的滞后特征。不可能每次都读一遍全量历史数据重算那样效率太低。一个定长队列就能解决from collections import deque history deque(maxlen24) # 保存最近24小时真实PM2.5 def predict_next(new_observation): history.append(new_observation[pm25]) if len(history) 24: return None feature { pm25_lag1: history[-2], pm25_lag3: history[-4], pm25_lag6: history[-7], pm25_lag24: history[0], temp: new_observation[temp], humidity: new_observation[humidity], # ... 其余特征 } return loaded.predict(pd.DataFrame([feature]))[0]这个逻辑里要额外注意一点lag1取值应该取上一时刻的真实观测而不是上一时刻模型的预测值。如果用预测值滚动回填误差会累积几个周期之后预测结果就和真实浓度完全脱节了。6.3 重训练周期与漂移监控模型上线后不是一劳永逸。PM2.5的污染机制随季节变化明显冬季采暖期、春季沙尘、夏季光化学污染每个时段的特征关系都不一样。我见过一个模型冬天效果好春天一到测数据就明显偏离谱的案例。原因就是春季沙尘天浓度骤升旧模型没见过这种模式。比较实用的做法是固定重训周期比如每周一次利用最近两个月的数据重新训练。同时在线上维护一个残差监控residuals [] def on_new_observation(true_value, prediction): residuals.append(true_value - prediction) if len(residuals) 7 * 24: # 保留近7天 residuals.pop(0) mean_res np.mean(residuals) std_res np.std(residuals) # 超过历史残差均值2倍标准差触发重训提醒 if abs(mean_res) 2 * std_res: trigger_retraining()触发重训的条件可以再加一个连续多个点预测值都系统性偏低这样可以对突发污染事件迅速做出反应。模型冻结时间越长漂移风险越大这套机制是部署阶段最不能省的一环。项目收尾之后我自己的体会是模型代码永远是最简单的部分真正决定这套预测能不能用起来的是那些围绕数据边界和业务对接的细节。最后再多说一个我后来一直在用的改进方向预测精确浓度值不如预测超限概率实用。我在回归输出上套了一层简单计算用历史残差的标准差构造置信区间计算未来1小时PM2.5超过某个阈值的概率。业务决策本质上都是阈值判断你给出未来一小时超100的概率是72%比单纯给出预测值102要好用得多。把回归值转成带概率的预警信号整套系统的实用价值会立刻不一样。