干温度预测这行当的人大概都有同感:模型跑起来不难,难的是把它调到“敢用”的程度。我最早接的一版逐小时气温预测,用的是最朴素的随机森林,特征就塞了几个滞后项,评估出来 MAE 1.8℃,团队里没人觉得有问题,结果上线第一周就有使用方反馈“凌晨那几小时的曲线明显偏平”。后来花了两个多月做随机森林温度预测模型优化,从特征构造、时序验证方式、超参数搜索空间一路改到残差分析和漂移监控,MAE 压到 1.1℃ 左右,日最高温的误差也稳定在 1.5℃ 以内。这篇文章就把这轮优化拆开讲,包括特征窗口怎么定、为什么不能用普通 KFold、参数搜索该在哪个区间收手、多步预测怎么不塌,以及那些只有真跑过一遍才会遇到的坑。内容面向已经会用 sklearn 跑通随机森林、但模型效果卡在瓶颈的同行,也照顾刚接触时序回归的朋友,涉及基础的树模型概念我会顺手解释一句。
1. 先把问题定义清楚:温度预测到底在预测什么
1.1 从业务目标倒推建模目标
很多人一上来就是RandomForestRegressor().fit(X, y),数据喂进去,指标打出来,看着 R² 0.95 挺开心,但从来没问过一句:这个模型到底要服务谁?温度预测这个题目底下,其实藏着至少四种完全不同的任务,每一种对模型的偏好都不一样。
第一种是逐小时气温回填与插值,给的是历史气象数据的补全,允许用未来信息,这时候滞后项和对称窗口都能上,模型可以做得非常准;第二种是未来 24 小时逐小时预报,只能用当前及过去的信息,一步错步步错;第三种是日极值预测,只要最高温和最低温,关心的是极值出现的时间点;第四种是特定阈值预警,比如预测夜间是否会跌破某个温度,这时候关心的是分类边界而不是平均误差。
我吃过的最直接的亏就是:拿“逐小时回填”的思路去训“24 小时预报”的模型,评估指标很漂亮,上线全废。因为回填任务里 t+1 的真实值是训练特征,预报任务里它根本不存在,模型在推理时拿到的是自己上一轮的预测,误差会像滚雪球一样累积。
所以做优化的第一步不是调参,是写清楚一张任务卡:
| 任务维度 | 我最终选定的设定 | 选这个设定的理由 |
|---|---|---|
| 预测粒度 | 逐小时 | 使用方需要小时级曲线,日级汇总可以后处理 |
| 预测跨度 | 未来 24 小时 | 覆盖一个完整的日周期,业务闭环刚好一天 |
| 滚动方式 | 每 6 小时滚动重算一次 | 兼顾时效与算力,6 小时内的偏差可接受 |
| 主评估指标 | MAE | 温度误差是线性代价,平方项会放大个别异常 |
| 辅助指标 | 日最高温 MAE、日最低温 MAE | 极值才是使用方真正在意的部分 |
这张表看着简单,但它决定了后面所有的技术选择。比如我选了 MAE 做主指标,那随机森林的criterion就该考虑absolute_error,因为默认的squared_error是在优化均方误差,跟你最终考核的指标不是一回事。这一点后面还会细说。
1.2 为什么我最后留在随机森林而不是换深度模型
优化过程中我动过换模型的念头,也真的试过 LSTM 和一维卷积。结论是:在站点数量不多、历史数据只有两三年、特征以滞后项为主的中小规模场景里,随机森林的综合性价比依然很难被替代。原因有几条,值得说清楚。
第一是样本量与特征维度的比例。我手上是 3 年逐小时数据,扣除滞后窗口损失,有效样本约 26000 条,特征 50 个左右。这个量级下神经网络的表达能力发挥不出来,反而容易过拟合到年际周期上;随机森林靠 bagging 自带方差抑制,小样本表现稳得多。
第二是对特征尺度不敏感。温度、湿度、气压、风速量纲差异巨大,线性模型和神经网络都得老老实实做标准化,随机森林完全不用管。这不是小事——标准化在时序场景下会引入一个隐蔽的泄漏点(用全量数据算均值方差),少一个环节就少一个坑。
第三是特征重要性可以直接拿来跟业务方对话。我做优化时最有用的一步,是把置换重要性排前 10 的特征拉出来给使用方看,他们一眼就能判断“这个特征在你们系统里到底有没有”。深度模型给不出这种直观交代,做归因还得另外上 SHAP,成本翻倍。
第四是推理成本可控。500 棵树的森林,单次 24 步预测的耗时在毫秒级,塞进一个定时任务里毫无压力。
当然我也承认随机森林的短板:它对趋势外推无能为力。树模型本质是分段常数函数,遇到训练集里没出现过的温度区间,预测值会被“夹”在边界上。所以极端高温、极端低温那几天的误差会明显放大,这个后面在残差分析那一节专门讲怎么缓解。
1.3 一份能落地的数据字段清单
优化开始前我列了一张字段表,把所有可用的输入分成三类:目标变量、同期观测、可预报量。这个分类非常关键,因为它决定了哪些特征在推理时真的拿得到。
- 目标变量:本站逐小时气温(2 米高度)。
- 同期观测:相对湿度、露点、气压、风向风速、能见度、云量。这类变量在预报模式下需要有对应的预报值才能用,如果拿不到未来时刻的预报值,就只能用它们的滞后项。
- 可预报量:小时、星期、月份、年内日序、太阳高度角近似值。这类是纯时间派生,任何未来时刻都能算出来,属于绝对安全特征。
我把这张表贴在工位上,每次加新特征都要先问一句:这个字段在“预测明天此刻”的时候,我手上有没有?没有就只能走滞后通道。这一步做完,后面特征工程就不会跑偏。经验上,同期观测里的湿度、露点对温度预测的增益最大,气压次之,风速和云量增益较小但能改善夜间辐射降温那一段的拟合。
2. 特征工程是决定上限的那一步
2.1 滞后特征与滚动统计窗口怎么定
温度序列最核心的信息在自身的滞后项上。但“加几个滞后”这件事不是拍脑袋,得看自相关结构。我一般先画一条 ACF 曲线,看自相关在哪个滞后阶数上掉到 0.3 以下,再结合物理周期定窗口。
温度有非常强的 24 小时周期,所以真正的关键滞后不是 t-1 到 t-5,而是t-24 和 t-25。这两个特征等于直接把“昨天同一时刻”和“昨天前一小时”的温度喂给模型,配合 t-1、t-2 提供即时惯性,模型就同时拿到了短期趋势和日周期信息。我最终确定的滞后集合是:
lags = [1, 2, 3, 6, 12, 24, 25, 48, 72]解释一下每个的用途:1、2、3 抓短时惯性,6 和 12 抓半日尺度的变化,24、25 抓日周期,48 和 72 让模型能感知“前天这个点是偏暖还是偏冷”的慢变量趋势。加到 96 小时以上时,重要性基本掉到噪声区,我就停了。
滚动统计比单点滞后更重要。我用的窗口是 3、6、12、24 小时,每种窗口算三个量:均值、标准差、极差(max-min)。
for w in [3, 6, 12, 24]: df[f'temp_roll_mean_{w}'] = df['temp'].shift(1).rolling(w).mean() df[f'temp_roll_std_{w}'] = df['temp'].shift(1).rolling(w).std() df[f'temp_roll_rng_{w}'] = (df['temp'].shift(1).rolling(w).max() - df['temp'].shift(1).rolling(w).min())注意这里每一行都带了.shift(1)。这不是可选项,是生死线。如果漏了 shift,rolling(3).mean()会把当前时刻的真实温度算进去,模型立刻变成“抄答案”,线下指标飞起、线上原地爆炸。我在优化早期就在这栽过一次,训练集 MAE 做到 0.4℃,还以为自己发现了新大陆。
滚动标准差是我加进去的意外收获。它刻画的是“这一段时间温度波动剧烈不剧烈”,在天气系统过境前后这个值会明显抬升,模型据此能判断“接下来几小时可能不稳定”,比单纯的均值携带更多信息。24 小时极差对预报日温差也很有帮助。
2.2 时间特征与气象衍生特征的构造
纯时间特征是零成本且绝对安全的,一定要加满。月份、小时内时刻这两个用周期性编码而不是原始整数:
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24) df['doy_sin'] = np.sin(2 * np.pi * df['dayofyear'] / 365.25) df['doy_cos'] = np.cos(2 * np.pi * df['dayofyear'] / 365.25)为什么不用原始整数?因为原始编码里 23 点和 0 点在数值上相距 23,实际上它们在物理上相邻。周期编码把“环”摊成一个圆,距离关系就对了。树模型对周期编码的处理虽然不如线性模型那么立竿见影,但在分裂时能少切很多刀,实测对夜间时段的拟合提升有个零点几度。
气象衍生特征里我最看重三个:
露点差(温度减露点),反映空气干燥程度,夜间辐射降温的强弱跟它强相关;相对湿度与温度的交叉项,虽然树模型不能直接理解乘积,但可以通过两者的联合分裂近似,加一个显式的temp_lag1 * rh_lag1有时候反而带来冗余,我最后没加;气压三小时变压,也就是pressure - pressure.shift(3),这是天气系统移动的经典指标,对 6 到 24 小时的温度变化有预报意义。
另外补一个容易被忽略的:太阳高度角近似值。不需要精确天文计算,用小时和日序粗算一个余弦项就够:
df['solar_angle'] = np.clip( np.cos(2 * np.pi * (df['hour'] - 12) / 24) * np.sin(2 * np.pi * (df['dayofyear'] - 81) / 365.25), 0, None)这个特征等于给模型一个“现在太阳在什么高度”的连续提示,物理含义明确,对日出后升温速率的刻画帮助明显。
提示:特征总数不要无脑堆。我试过把特征从 50 扩到 90,MAE 几乎没动,训练时间翻了 1.8 倍,而且特征重要性排序被大量高共线的滞后项冲散,反而不好向使用方解释。经验值是 40 到 60 个特征之间收益最高。
2.3 数据泄漏:最容易把模型做“废”的地方
时序建模里,泄漏的花样远比想象中多。我把踩过的整理成四类,每一类都有具体的排查手法。
第一类,滚动窗口没对齐。就是上面说的shift(1)问题。排查手法很粗暴:造一个“把目标整体平移 1 小时”的假数据集,如果模型在假数据上也能拿到差不多的分数,说明你的特征里含有当前时刻信息。
第二类,标准化/填充用了全量数据。缺失值用全量均值填充、异常值用全量分位数截断,都是泄漏。正确做法是只在训练段上算统计量,然后应用到验证段。随机森林本身不需要标准化,但如果你在特征工程阶段做了填充,这一条就适用。
第三类,交叉验证用了随机划分。这是最致命的。KFold(shuffle=True)会让模型在训练时看到“未来”的样本,验证集分数虚高得离谱。我在优化初期用随机 5 折拿到 MAE 0.9℃,换成时序切分立刻掉到 1.6℃,差了快一倍。这个数差就是泄漏的量。
第四类,特征选择在划分之前做。先在全量数据上算特征重要性、筛掉一批特征,再切分训练验证,同样是泄漏。顺序永远是:切分 → 在训练段上做一切拟合 → 应用到验证段。
我给自己定了个规矩:任何在建模流程里出现的“全局操作”,都要问一句它用的是哪一段数据。这个习惯帮我在后面几次迭代里躲过了不少隐性问题。
3. 超参数调优:把调参这件事做出工程感
3.1 先确定搜索范围与基线
调参最容易犯的毛病是一上来就上大网格,几十个参数组合跑一整天,最后发现最优解跟默认值差不多。我的做法是先建基线,再做单参数敏感性扫描,最后才做组合搜索。
基线就是全默认参数:RandomForestRegressor(n_estimators=100, random_state=42),在时序验证上跑出一个分数。我这次的基线是 MAE 1.62℃。有了这个数,后面任何调参都得拿它当标尺。
单参数扫描我重点关注五个参数,每个扫 6 到 8 个值,其余保持默认:
| 参数 | 扫描范围 | 我观察到的表现 |
|---|---|---|
| n_estimators | 100 / 200 / 300 / 500 / 800 | 300 之后收益极小,500 是稳定选择 |
| max_depth | 6 / 10 / 14 / 18 / 24 / None | 14 到 18 之间最稳,None 明显过拟合 |
| min_samples_leaf | 1 / 2 / 4 / 8 / 16 | 4 到 8 明显优于 1,8 以上开始欠拟合 |
| max_features | 0.2 / 0.35 / 0.5 / 0.7 / 1.0 / 'sqrt' | 0.35 到 0.5 最优,1.0 反而变差 |
| criterion | squared_error / absolute_error | absolute_error 的 MAE 略优,RMSE 略差 |
max_features这一项值得多说两句。默认值在回归任务里是 1.0,也就是每棵树用全部特征。但温度特征之间共线性很强(t-1 和 t-2 相关系数能到 0.99),全部特征都用会让树与树之间高度相似,bagging 的降方差效果打折。把max_features设到 0.35 左右,等于强制每棵树只看一部分特征,树的多样性上来了,集成效果更好。这个参数是我这轮优化里收益最大的单项调整,单它就贡献了约 0.1℃ 的 MAE 改善。
min_samples_leaf从 1 调到 4 到 8,作用是把叶子节点的样本数下限抬起来,避免模型去拟合“某一小时某个瞬间的噪声”。温度序列的观测噪声不大但存在(传感器漂移、局地扰动),叶子太小就会把这些噪声记住。调到 4 之后,验证集曲线明显平滑了。
3.2 时间序列交叉验证的写法
不用随机 KFold 这件事说多少遍都不够。我用的是 sklearn 的TimeSeriesSplit,但默认配置有个坑:训练段和验证段是紧挨着的,边界上仍然有轻微泄漏(因为滞后特征跨越了边界)。所以要用gap参数在中间留一段空档:
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5, test_size=24 * 14, gap=48)test_size设成两周的小时数(336),是因为单周验证集在天气系统周期上不够代表性,两周能覆盖一次完整的天气过程。gap=48留出两天,正好覆盖我用到的最大滞后 48 小时,把边界泄漏掐掉。
验证集的最后一个 fold 必须是最靠近当前时间的那一段。有些人习惯把数据按时间正序切,然后取第一个 fold 当最终评估,这是错的——你等于用最早的数据评估模型,而线上跑的是最新的数据分布。正确的做法是取最后一个 fold的分数作为参考值,因为它的分布最接近未来。
我最终用的是RandomizedSearchCV而不是GridSearchCV,n_iter=60,5 折时序 CV,总共 300 次拟合。在 26000 条样本、50 个特征的数据上,单次拟合约 0.8 秒,全程 4 分钟左右跑完,这个成本完全可以接受。用随机搜索而不是网格搜索的理由很实际:参数空间里真正重要的只有 3 个维度(max_depth、min_samples_leaf、max_features),网格搜索会把大量算力浪费在不敏感的参数上。
3.3 参数影响的实际观测与经验取值
跑完搜索之后,我把最优参数记下来,同时保留了第二、第三名的组合。为什么要留备选?因为时序数据的分布会漂移,某个参数组合在今年最优,明年可能就不是。留两三个备选,等重训时可以直接对比,不用从头再搜一遍。
我这次的最优组合落在:
best_params = { 'n_estimators': 500, 'max_depth': 16, 'min_samples_leaf': 4, 'min_samples_split': 8, 'max_features': 0.4, 'bootstrap': True, 'max_samples': 0.85, 'criterion': 'absolute_error', 'n_jobs': -1, 'random_state': 42, }max_samples=0.85这个参数很多人会忽略。它控制每棵树的 bootstrap 采样比例。默认 1.0 是有放回抽满,改成 0.85 相当于每棵树少看 15% 的样本,进一步增加多样性。实测这个改动让验证集 MAE 又降了 0.03℃ 左右,收益不大但没成本。
关于criterion的选择,我给个明确建议:如果你的考核指标是 MAE,就用absolute_error;如果更在意大误差不出现,用squared_error。两者的差别在于对异常值的态度。absolute_error优化的是中位数,对极端天气那几天的容忍度更高,整体 MAE 更好看;squared_error优化均值,会主动去压那些大偏差,代价是常态段的误差略大。我这次选了前者,因为使用方更在意“大部分时候准不准”。
还有一个常被忽略的点:调参的评估指标必须和上线考核指标一致。搜索时用scoring='neg_mean_absolute_error',不要用默认的 R²。R² 在时序数据上会被方差结构带偏,一个 R² 高 0.02 的模型,MAE 可能反而更差。
4. 完整实操流程:从原始数据到可交付模型
4.1 数据清洗与对齐
原始数据的质量问题五花八门,我按处理顺序列一遍。
时间戳对齐。有些站点的观测是整点整分,有些是整点过几分,直接 merge 会错位。我的做法是统一向下取整到小时,重复的取均值,缺失的补空行。
缺失值处理。温度缺失我不用均值填,用线性插值加一个“是否插值”的标记列。标记列很重要——插值出来的值和真实观测的可信度不一样,让模型自己学这个区别。连续缺失超过 3 小时的段直接丢弃,插出来的长段没有意义。
异常值。温度异常值我用物理区间加滑动分位数双重判定:绝对值超出该站点历史 0.1% 和 99.9% 分位的,或者与前后 3 小时中位数的偏差超过 8℃ 且持续不足 1 小时的,判为可疑。可疑值不删,改成 NaN 走插值通道。删样本在时序里代价太大,会把连续的时间轴打断。
站点对齐。如果不是单站建模,多站数据的经纬度、海拔差异必须进特征,否则模型会把不同气候背景的站混在一起学。
清洗完的数据我会存一版 parquet,并记录每一版的清洗日志。这个习惯在回溯问题时救命——某次线上误差突然变大,翻日志发现是上游某个字段的单位从 hPa 改成了 Pa。
4.2 训练主流程代码
完整训练流程我封装成一个函数,输入原始 DataFrame,输出模型、特征列表和评估报告。核心骨架如下:
import numpy as np import pandas as pd from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import TimeSeriesSplit, RandomizedSearchCV from sklearn.metrics import mean_absolute_error, mean_squared_error def build_features(df): d = df.copy() # 滞后项 for l in [1, 2, 3, 6, 12, 24, 25, 48, 72]: d[f'temp_lag{l}'] = d['temp'].shift(l) # 滚动统计(务必 shift(1)) for w in [3, 6, 12, 24]: s = d['temp'].shift(1) d[f'roll_mean_{w}'] = s.rolling(w).mean() d[f'roll_std_{w}'] = s.rolling(w).std() d[f'roll_rng_{w}'] = s.rolling(w).max() - s.rolling(w).min() # 时间周期特征 d['hour_sin'] = np.sin(2*np.pi*d['hour']/24) d['hour_cos'] = np.cos(2*np.pi*d['hour']/24) d['doy_sin'] = np.sin(2*np.pi*d['dayofyear']/365.25) d['doy_cos'] = np.cos(2*np.pi*d['dayofyear']/365.25) # 气象衍生 d['dewpoint_dep'] = d['temp'] - d['dewpoint'] d['pres_3h'] = d['pressure'] - d['pressure'].shift(3) d['solar_angle'] = np.clip( np.cos(2*np.pi*(d['hour']-12)/24) * np.sin(2*np.pi*(d['dayofyear']-81)/365.25), 0, None) return d def train_pipeline(raw_df, params): d = build_features(raw_df).dropna() drop_cols = ['timestamp', 'temp'] feat_cols = [c for c in d.columns if c not in drop_cols] X = d[feat_cols].values y = d['temp'].values tscv = TimeSeriesSplit(n_splits=5, test_size=24*14, gap=48) model = RandomForestRegressor(**params) scores = [] for tr_idx, va_idx in tscv.split(X): model.fit(X[tr_idx], y[tr_idx]) pred = model.predict(X[va_idx]) scores.append(mean_absolute_error(y[va_idx], pred)) # 用全部数据重训最终模型 final = RandomForestRegressor(**params).fit(X, y) return final, feat_cols, np.mean(scores), scores几个实现细节值得说明。dropna()会吃掉前面 72 小时的数据(最大滞后 72),26000 条里损失 72 条,可以接受。如果把滞后加到 168(一周),损失就变成 168 条,同时特征维度暴涨,性价比很低。
n_jobs=-1在调参阶段很关键,5 折乘以 60 次迭代,串行跑要十几分钟,并行后 4 分钟出结果。注意n_jobs放在搜索对象上比放在模型上更有效,RandomizedSearchCV(n_jobs=-1)会并行跑参数组合,RandomForestRegressor(n_jobs=-1)只在单次拟合内部并行树构建。
最终模型我用全量数据重训了一遍。这里有个取舍:严格来说,应该保留最后一个 fold 的验证集不参与训练,用来做一次无偏评估;但为了多两个月的训练数据,我选择了全量重训,然后用最后一个 fold 的模型分数作为参考。两种做法各有道理,我的判断标准是:数据量在两三年这个级别时,多两个月的样本比多一个无偏估计更值钱。
4.3 评估与残差分析
指标只是入口,真正找到优化方向的是残差分析。我把残差按四个维度切开看:按小时、按温度区间、按季节、按天气类型。这四个切面各自暴露不同的问题。
按小时切,最容易发现问题。我第一版的残差在 5 点到 8 点这一段系统性偏正,也就是模型把清晨温度估高了。原因是日出前后的升温速率跟云量、地表湿度强相关,而这些信息在滞后项里体现不出来。补充了早晨时段的云量滞后特征和露点差之后,这一段偏差压下去一半。
按温度区间切,暴露的是树模型的边界效应。低于 5℃ 和高于 32℃ 的样本,残差明显放大,因为这两端样本少。这个没法靠特征解决,只能靠样本加权或者单独训一个极值模型。我最后的处理是给极值区间的样本加了sample_weight(权重给 1.5),让模型在分裂时更重视这部分。
按季节切,暴露的是年际漂移。同一模型在春秋季表现最好,盛夏和隆冬略差,这符合温度波动幅度的季节分布。这个差异没法消除,只能记录在案,作为季节性重训的依据。
按天气类型切,得有额外的标签(晴、阴、雨、雪)。我没有这个标签,就用“24 小时温度极差”做了一个粗糙的代理分组,发现极差大的日子误差普遍高 0.3℃ 到 0.5℃。这些日子本质上是天气系统过境,可预测性天然就低。
评估报告我会输出成一张表,包含 MAE、RMSE、MAPE(温度接近 0℃ 时 MAPE 会失真,我只在日均温上算)、R²,以及日最高/最低温的 MAE。日极值指标要单独算,直接从逐小时预测里取 daily max/min 再比对,不要用逐小时指标去近似。
4.4 多步预测与预测区间
24 步预测有两条路,我详细对比过。
递归法:训一个单步模型,预测出 t+1 后把它当成已知值,再去预测 t+2。优点是只训一个模型,缺点是误差累积严重,而且训练时的特征分布和推理时不一致(训练时滞后项都是真值,推理时是预测值)。实测下来,递归 24 步的末段 MAE 会劣化到 2.5℃ 以上。
直接多步法:为每个预测步长训一个独立模型,24 步就是 24 个模型。特征只用“当前及过去”的信息,不做递归。优点是每一步的误差不累积,缺点是模型数量和训练成本乘以 24,同时每一步的样本构造要小心对齐。
我最终用的是混合方案:前 6 步用递归,后 18 步用直接多步。理由前 6 步递归的误差累积还在可接受范围,而直接多步在前几步的优势不明显;6 步之后直接多步的优势就出来了。
直接多步的对齐是容易写错的地方。第 k 步的标签是temp.shift(-k),特征必须全部是shift(0)及以前的,不能有任何跨到未来。写完之后我会做一次断言检查:
# 验证直接多步特征无泄漏:特征最大时间偏移必须 <= 0 assert max_feature_shift <= 0, "特征中出现了未来信息"预测区间我用的是森林内部的树分布法:让 500 棵树各自预测,取 2.5% 和 97.5% 分位数作为 95% 区间。这个方法的区间偏窄(因为树之间高度相关),但胜在零额外成本。要让区间更靠谱,得用分位数回归森林或者GradientBoostingRegressor(loss='quantile')单独训上下界模型,成本高不少。我的折中方案是对树分布区间做一个经验放大系数,用验证集上的覆盖率反推,这次定的是 1.35 倍,实测覆盖率能到 93% 左右。
5. 常见问题与排查速查表
5.1 训练集完美、线上拉胯
这是最高频的问题,八成以上是泄漏。排查顺序我固定成三步。
第一步查滚动特征。把build_features单独拉出来,用一个只有 5 行的迷你数据集跑一遍,手工核对每一列的值。我曾经发现过rolling(w).mean()后面忘了shift(1)的情况,也见过rolling(w, center=True)这种把未来也算进去的写法。手工核对 5 行比看一万行代码快。
第二步查验证集划分。打印出每个 fold 的训练段和验证段的起止时间,确认训练段严格早于验证段,且中间有 gap。如果看到验证段的时间戳落在训练段中间,基本就实锤了。
第三步查全局统计量。搜索代码里的mean()、std()、quantile()、fillna(),看它们的调用对象是不是全量数据。用 pandas 的话,df.mean()就是全局,df.loc[tr_idx].mean()才是训练段。
还有一种不太常见但很隐蔽的情况:目标变量的定义不一致。训练时用的是“整点瞬时值”,线上给的可能是“小时平均值”,这两个差个零点几度很正常,但如果差异稳定存在,模型就会表现出系统性偏差而不是随机误差。判断方法是看残差的均值,如果显著不为零且方向一致,就往这个方向查。
| 现象 | 最可能原因 | 快速验证方法 |
|---|---|---|
| 线下 MAE 远低于线上 | 滚动特征未 shift | 迷你数据集手工核对 |
| 线下 R² 高但 MAE 差 | 验证集用了随机划分 | 打印 fold 时间范围 |
| 残差均值系统性偏移 | 目标定义或单位不一致 | 对比训练/线上数据字典 |
| 某些时段误差恒定偏大 | 缺失特征或采样偏差 | 按小时切残差看分布 |
5.2 预测曲线“平移”或滞后一天
预测曲线整体平移一个固定时长,是时序模型很典型的一种病。表现出来就是曲线形状对,但峰值出现的时间点总晚一两个小时。
原因通常是滞后特征的“惯性过强”。模型发现用 t-1 和 t-2 就能解释大部分方差,于是懒得去学日周期,遇到转折点就跟着惯性走。缓解手段有三个,我都试过:
一是降低短滞后的权重。具体做法是从特征集里去掉 t-1,只保留 t-2 及以后,逼模型去看更长的窗口。这个改动让峰值时刻的偏差从 1.8 小时降到 1.1 小时,但 MAE 略微上升 0.05℃,属于用精度换时机。
二是加入变化率特征。显式构造temp_lag1 - temp_lag3这样的差分项,让模型直接看到“在升还是在降”。差分特征对转折点的捕捉帮助明显。
三是加大max_features的随机性。前面说过max_features=0.4能增加树的多样性,间接减少模型对单一强特征的依赖,对缓解惯性也有正面作用。
如果平移是整整 24 小时,那就不是模型问题,是特征对齐错了——把 t-24 当成 t 用了,或者时间戳在 merge 时错位了一天。这种错误在跨时区数据处理时特别容易出现,务必用 UTC 时间戳做合并,只在展示层做本地时区转换。
5.3 特征重要性忽高忽低
跑两次随机种子不同的训练,特征重要性排序大变样,说明你的特征之间共线性太强,重要性在它们之间随机“分配”。这时候直接看 MDI(基于不纯度的默认重要性)意义不大,得换成置换重要性:
from sklearn.inspection import permutation_importance perm = permutation_importance( model, X_val, y_val, n_repeats=10, random_state=42, scoring='neg_mean_absolute_error', n_jobs=-1)置换重要性的逻辑是:把某一列的值打乱,看模型性能掉多少,掉得多说明这列重要。它对共线特征的处理比 MDI 公道得多——如果两列高度相关,打乱其中一列,另一列还能顶上,重要性就不会虚高。
我实际用下来,置换重要性的前几名是temp_lag1、roll_mean_24、temp_lag24、dewpoint_dep、hour_cos,这套排序跟业务直觉完全吻合,拿去跟使用方沟通也顺畅。MDI 的排序里则会冒出一堆roll_std和roll_rng,看着花哨但其实是被共线性顶上去的。
还有个小技巧:置换重要性算完之后,把重要性接近 0 甚至为负的特征列出来。这些特征不但没用,还在消耗max_features的配额,删掉往往能小幅提升效果。我这次删掉了 6 个这样的特征,MAE 降了 0.02℃,训练时间少了 12%。
5.4 模型上线后的漂移监控
模型训完不是终点。我给它配了一套很轻的监控:每天用最近 7 天的实测数据回算 MAE,画成一条时间线。设两条阈值线,黄色是基线 MAE 的 1.2 倍,红色是 1.5 倍。
触黄就去看残差分布有没有变形,触红就启动重训。重训不是简单重跑一遍,而是拿新旧两个模型在最近 14 天数据上做一次对比,新模型不占优就不换。这个“ challenger 对比”机制拦住过我好几次——有一次重训出来的模型指标更好,但对比发现它在极端高温日的表现明显退步,最后没让它上线。
另外我会每季度做一次全量重训加重新调参。季节性重训和全量重训是两件事:前者是滚动更新样本,参数不变;后者是连参数一起重搜。频率上,滚动更新可以做到每周,全量重搜一季一次就够,再频繁容易过拟合到短期波动上。
注意:监控的基线 MAE 要用同一套评估口径算,别拿训练集 MAE 当基线,也别拿验证集最后一个 fold 的分数当基线——它们的样本分布跟线上滚动窗口都不一样。基线应该是模型刚上线时,用最近 7 天数据实测出来的那个数。
我个人在实际操作中的体会是,随机森林这类模型的优化,八成的收益来自特征工程和验证方式,调参顶多占两成。我最开始把大量时间花在调参上,MAE 从 1.62 磨到 1.48 就卡住了;后来回头重做特征和时序切分,一下子掉到 1.2 以下。所以如果现在让我给一句建议:先把shift(1)和TimeSeriesSplit(gap=...)这两件事做对,再谈调参,顺序反了就是白费力气。