简介:这份实战案例面向时间序列预测与机器学习初学者,围绕 Xgboost 算法给出可直接运行的 Python 实现,覆盖时间序列预测与时间序列分类两类任务,可用于金融、天气、股票等趋势分析场景的入门练习。压缩包共 3 个文件,以 2 个 Python 脚本和 1 个 CSV 数据集为主,脚本承担模型训练、保存与预测流程,数据文件用于示例数据验证,整体约 407KB,轻量易取。已有 6033 人学习下载,说明其在同类入门案例中具有一定参考价值。读者可从中了解数据分析、特征工程、模型训练、本地模型保存、未来数值序列生成及预测结果与实际观测值对比评估的完整流程;代码含逐步注释,便于按注释理解每一步操作,也能为后续替换数据、调整特征和复现实验提供基础模板。
1. 时间序列预测还能用 XGBoost?先说清这份资源到底能跑什么
拿到一份标着「时间序列预测模型实战案例(Xgboost)(Python)(机器学习)」的包,很多人第一反应是怀疑:时序不该上 ARIMA、Prophet 或者 LSTM 吗,树模型凑什么热闹。我在销量、流量、设备指标这类场景里反复试过,只要特征工程做到位,XGBoost 在中小规模单序列或多序列任务上经常能打赢调参调到吐的深度模型,训练还只要几分钟,这就是它最实在的价值。这份资源把回归预测和时间序列分类两条线放在同一套 Python 机器学习流程里,点开就能跑,适合想快速拿到一个可交付 baseline 的人,也适合把滑窗、特征、验证这套动作从头走一遍的新手。下面按数据切分、滑窗构造、参数设置、分类改造、踩坑排查的顺序往下拆。
2. 数据切分与滑窗构造:把时序转成监督学习的四个动作
2.1 三种切法:随机划分是第一个翻车点
XGBoost 本身不认识时间,它只认识一张行是样本、列是特征的二维表。把时序塞进这张表之前,第一件事是决定怎么切训练集和验证集。常见的错误做法是train_test_split(shuffle=True),理由看起来也充分——样本量小,随机切能保证分布均匀。问题在于时序样本之间高度自相关,随机切会让第 100 天的样本进训练集、第 99 天的进验证集,模型等于提前看了答案,验证分数漂亮得离谱,一上线就原形毕露。血泪经验是:任何带时间戳的任务,切分必须沿时间轴单向推进。
我一般准备三种切法,按任务阶段换着用。第一种是单点留出,留最后 20% 做验证,适合快速迭代和出第一版结果;第二种是TimeSeriesSplit多折,每折训练集都比上一折长,用来观察模型在不同时间段的稳定性;第三种是滚动回测,每预测一天就把真实值并回训练集再往前挪一步,最接近线上真实调用方式,成本也最高,通常放在上线前跑一次。前两种切法的代码长这样:
import pandas as pd from sklearn.model_selection import TimeSeriesSplit def holdout_split(df, date_col="ds", valid_ratio=0.2, gap=0): """按时间顺序切一刀,gap 用来在训练和验证之间留缓冲""" df = df.sort_values(date_col).reset_index(drop=True) cut = int(len(df) * (1 - valid_ratio)) train = df.iloc[: cut - gap].copy() # gap 天从训练尾部砍掉 valid = df.iloc[cut:].copy() return train, valid def rolling_folds(df, n_splits=5, test_size=28, gap=7): """扩窗滚动切分,每折验证集紧跟在训练集后面""" tscv = TimeSeriesSplit(n_splits=n_splits, test_size=test_size, gap=gap) for fold, (tr_idx, va_idx) in enumerate(tscv.split(df)): yield fold, df.iloc[tr_idx].copy(), df.iloc[va_idx].copy()valid_ratio控制留出比例,样本量小于 500 行时别低于 0.15,否则验证集抖得没法看;gap是训练集尾部和验证集之间人为留空的天数,因为滑窗特征会用到过去 N 天的数据,不留 gap 就等于让训练集最后几行的标签信息从特征侧渗进验证集。TimeSeriesSplit的test_size建议设成一个完整业务周期,比如 7 天或 28 天,否则某折验证集只覆盖周一到周三,评估结果会被周内效应带偏。
2.2 滞后特征:lag 和 rolling 决定模型上限
把一维序列变成监督学习表格,核心是三类特征:滞后值、滚动统计量、时间刻度。滞后值回答「昨天、上周、上个月同一时刻是多少」,滚动统计量回答「最近一段时间的水平和波动」,时间刻度回答「现在是星期几、几月、是否节假日」。这三类里,滚动统计量最容易做出效果,也最容易泄露未来信息,所以统一原则是先shift(1)再rolling。
import numpy as np import pandas as pd def make_ts_features(df, target_col="y", date_col="ds", lags=(1, 2, 3, 7, 14, 28), windows=(7, 14, 28)): df = df.sort_values(date_col).reset_index(drop=True) out = df.copy() # 1) 滞后值:lag_k 表示 k 个时间步之前的目标值 for lag in lags: out[f"lag_{lag}"] = out[target_col].shift(lag) # 2) 滚动统计:先 shift(1) 再 rolling,避免把当前点算进去 base = out[target_col].shift(1) for w in windows: out[f"roll_mean_{w}"] = base.rolling(w).mean() out[f"roll_std_{w}"] = base.rolling(w).std() out[f"roll_min_{w}"] = base.rolling(w).min() out[f"roll_max_{w}"] = base.rolling(w).max() # 3) 差分特征:刻画变化速率,抑制趋势造成的偏移 out["diff_1"] = out[target_col].diff(1) out["diff_7"] = out[target_col].diff(7) # 4) 变化率:目标量纲不同时比差分更稳 out["pct_1"] = out[target_col].pct_change(1).replace([np.inf, -np.inf], np.nan) return outlags的选择跟业务周期走:日频数据一般取1, 2, 3, 7, 14, 28,周频取1, 2, 4, 52。加太多远滞后列会让特征矩阵变宽、噪声变多,28 天之后的信息通常已经被roll_mean_28吸收了。windows建议跟 lags 对齐,别出现 lag 到 28 而窗口只有 7 的组合,那样模型看不到中期趋势。shift(1)是硬约束,我见过有人写df["y"].rolling(7).mean()直接当特征,训练集里分数好看到爆,一到预测下一期就崩,原因就是这个滚动窗口包含了当前时刻的标签,模型在训练时偷看了答案。
特征构造完要面对 NaN 问题。前 28 行一定是空的,用fillna(0)处理,让树模型自己学「这段数据不可用」的信号;用dropna()处理,则会丢掉所有开头行,多序列场景下每个序列都掉一截,很心疼。另外滚动标准差在窗口内只有 1 个有效值时为 NaN,同样填 0 更安全。
2.3 时间特征和缺口补齐:频率对不上,lag 全是错的
时间刻度特征不要只塞一个原始时间戳进去,XGBoost 是树模型,它对连续时间戳做的是阈值切分,切出来的区间没有业务含义。正确的做法是把周期信息拆成离散列加上周期编码:
def add_calendar_features(df, date_col="ds"): d = pd.to_datetime(df[date_col]) df = df.copy() df["weekday"] = d.dt.weekday # 0=周一 df["is_weekend"] = (d.dt.weekday >= 5).astype(int) df["month"] = d.dt.month df["day"] = d.dt.day df["quarter"] = d.dt.quarter doy = d.dt.dayofyear # 周期编码,让年末和年初在数值上相邻 df["sin_doy"] = np.sin(2 * np.pi * doy / 365.25) df["cos_doy"] = np.cos(2 * np.pi * doy / 365.25) return df def fill_time_gaps(df, date_col="ds", freq="D", target_col="y"): """把缺失日期补成完整网格,并标记哪些点是补出来的""" df = df.copy() df[date_col] = pd.to_datetime(df[date_col]) df = df.set_index(date_col).asfreq(freq).reset_index() df["is_filled"] = df[target_col].isna().astype(int) return dfweekday和is_weekend是这类任务里最廉价也最有效的两个特征,零售、流量、能耗数据几乎都吃这个。sin_doy、cos_doy解决的是 12 月 31 日和 1 月 1 日在整数上相差 364 的问题,对有明显年周期的序列很有用。asfreq补齐这一步很多人会跳过,结果某天数据缺失,shift(1)拿到的其实是两天前的值,lag 的物理含义全乱,模型在缺失点附近误差会突然抬高。
提示:补齐之后一定要保留
is_filled标记位并作为特征喂给模型,让它在缺失日附近自己降低置信度,比事后手动修预测值省事得多。
2.4 特征矩阵检查清单:上线前逐条过一遍
特征拼完别急着 fit,先跑一遍体检。我固定检查四件事:一是特征列里有没有混进目标列的原始值或者未来值;二是每列缺失比例;三是特征与目标的相关性是否出现异常高值(超过 0.98 基本可以判定泄露);四是训练集和验证集的特征分布是否漂移。
def audit_features(df, feature_cols, target_col="y"): report = {} report["nan_ratio"] = df[feature_cols].isna().mean().sort_values(ascending=False) corr = df[feature_cols + [target_col]].corr()[target_col].drop(target_col) report["top_corr"] = corr.abs().sort_values(ascending=False).head(10) report["n_rows"] = len(df) # 检查是否存在负 lag 特征(典型的未来函数) report["suspicious_cols"] = [c for c in feature_cols if "lag_-" in c or "future" in c] return report相关系数排前几名的特征要逐个回看构造逻辑,尤其是roll_mean系列,必须确认shift(1)在rolling之前。suspicious_cols只是兜底扫描,真正的未来函数往往藏得很深,比如用全量数据算的标准化均值、用全量分位数做的分箱边界,这些统计量必须只在训练集上拟合再应用到验证集,否则就是悄无声息的泄露。
| 检查项 | 判定标准 | 处理方式 |
|---|---|---|
| 单列缺失比例 | 超过 30% | 弃用该列或缩短窗口 |
| 与目标相关系数 | 绝对值大于 0.98 | 回查是否含未来信息 |
| 训练/验证均值偏移 | 差异超过 1 个标准差 | 检查是否存在趋势或结构突变 |
| 特征列数据类型 | 是否全为数值 | 类别列转 one-hot 或 ordinal |
| 时间轴连续性 | 相邻时间差是否恒定 | 用asfreq补网格并打标记 |
这张表我一般贴在脚本旁边,每次换数据集先过一遍,十分钟能省掉后面大半天的玄学排查。
3. XGBoost 回归预测实战:参数、训练与残差检查
3.1 目标函数先定,参数再谈
菜鸟最容易犯的错是先抄一套参数再回头想目标函数。时序回归的第一步应该是选objective:目标值近似正态、有对称误差的,用reg:squarederror;异常值多、只关心中位数误差的,用reg:absoluteerror;计数型目标(订单量、点击量)且方差远大于均值的,用reg:tweedie并把tweedie_variance_power设在 1.1 到 1.5 之间。目标函数选错,后面怎么调参都是白费。
接下来才是那几个真正影响结果的参数。下面这张表是我在几十个时序任务里收敛出来的经验区间,不是标准答案,但作为起点很稳:
| 参数 | 经验区间 | 调大后的影响 | 时序场景建议 |
|---|---|---|---|
n_estimators | 500 到 3000 | 拟合更充分,易过拟合 | 配早停,上限给足 |
learning_rate | 0.01 到 0.1 | 收敛快,方差大 | 首轮 0.05,精调降到 0.02 |
max_depth | 4 到 8 | 能学更复杂交互 | 特征少取 4 到 5,特征多取 6 到 7 |
min_child_weight | 3 到 20 | 叶子更保守 | 数据噪声大时往上加 |
subsample | 0.7 到 0.9 | 行采样,抗过拟合 | 0.8 起步 |
colsample_bytree | 0.6 到 0.9 | 列采样,抗共线性 | 滞后列多时取 0.6 到 0.7 |
reg_lambda | 0.1 到 10 | L2 正则,压极端值 | 1.0 起步 |
reg_alpha | 0 到 1 | L1 正则,稀疏化 | 特征冗余时加到 0.5 |
调参顺序我固定为:先定learning_rate=0.05和n_estimators上限跑早停,再调max_depth和min_child_weight,最后微调subsample、colsample_bytree和正则项。倒过来调会浪费大量时间,因为深度的变化会让最优学习率整体漂移。
3.2 训练脚本与早停:验证集不能用测试集顶替
早停是时序任务里性价比最高的一个开关,它能在训练过程中盯着验证集误差自动截断树的数量,省掉一半调n_estimators的功夫。但这里有个隐蔽的坑:很多人把最终测试集直接当eval_set传进去,模型选择过程就看到了测试集,最后的评估分数天然乐观。正确做法是从训练集尾部再切一小段做早停验证,测试集留到最后只碰一次。
import numpy as np import pandas as pd import xgboost as xgb from sklearn.metrics import mean_absolute_error, mean_squared_error def train_regressor(train, valid, feature_cols, target_col="y"): # 从训练集尾部再切 15% 做早停监控,测试集完全不参与 cut = int(len(train) * 0.85) fit_part, es_part = train.iloc[:cut], train.iloc[cut:] params = dict( objective="reg:squarederror", n_estimators=3000, learning_rate=0.03, max_depth=6, min_child_weight=5, subsample=0.8, colsample_bytree=0.75, reg_lambda=1.0, reg_alpha=0.0, tree_method="hist", random_state=42, ) model = xgb.XGBRegressor( **params, early_stopping_rounds=100, eval_metric="mae", ) model.fit( fit_part[feature_cols], fit_part[target_col], eval_set=[(es_part[feature_cols], es_part[target_col])], verbose=False, ) pred = model.predict(valid[feature_cols]) return model, predearly_stopping_rounds=100表示连续 100 棵树验证集 MAE 没改善就停,数据量小可以降到 50,数据量大可以提到 200。tree_method="hist"是必开的,默认的精确切分在几万行以上会慢到怀疑人生,直方图近似对时序任务几乎无损。eval_metric选mae还是rmse取决于业务对异常值的态度:MAE 更关注典型误差,RMSE 会放大少数大偏差,库存、排产这类怕缺货的场景我通常用 MAE 训练、用 RMSE 复核。
训练完先看model.best_iteration和model.best_score。如果最优迭代数贴着上限,说明学习率偏低或者树不够,把learning_rate往上提一档重跑;如果最优迭代数只有几十,说明模型很快就过拟合了,回头查特征泄露或者把max_depth降下来。
3.3 预测还原与指标选择:MAPE 不是万能尺
如果训练前对目标做过对数变换、差分或者标准化,预测值必须还原回原始量纲再评估,否则算出来的误差没有任何业务含义。对数变换用np.expm1还原,一阶差分用累加还原,标准化用训练集的均值和方差还原,三个顺序不能乱。
def inverse_diff(pred_last_value, pred_diff): """一阶差分预测值的累加还原""" return pred_last_value + np.cumsum(pred_diff) def evaluate(y_true, y_pred, eps=1e-6): y_true, y_pred = np.asarray(y_true), np.asarray(y_pred) mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mape = np.mean(np.abs((y_true - y_pred) / np.maximum(np.abs(y_true), eps))) * 100 smape = np.mean(2 * np.abs(y_pred - y_true) / np.maximum(np.abs(y_true) + np.abs(y_pred), eps)) * 100 wape = np.sum(np.abs(y_true - y_pred)) / np.maximum(np.sum(np.abs(y_true)), eps) * 100 return {"MAE": mae, "RMSE": rmse, "MAPE": mape, "sMAPE": smape, "WAPE": wape}MAPE 在目标值接近 0 时会爆炸,销量、流量这类数据一定有零值日,所以我把eps设成 1e-6 兜底,同时必看 WAPE。WAPE 是总绝对误差除以总真实值,对零值不敏感,也不会被小数值样本放大,做汇总层面的评估更靠谱。sMAPE 在对称性上比 MAPE 好,但两个指标一起看才能发现「整体偏差不大但某些天错得离谱」这类问题。
3.4 残差诊断:模型到底学到了什么
分数出来别急着收工,残差里藏着模型没学到的结构。我固定看三张切片:按星期聚合的残差均值、按预测值分箱的残差分布、残差的自相关系数。如果按星期聚合后某一类日子的残差均值系统性偏正或偏负,说明日历特征没被充分利用,或者该星期的样本量不足;如果残差随预测值增大而单调变化,说明目标存在异方差,该考虑对数变换或者换tweedie目标。
def residual_report(valid, pred, date_col="ds", target_col="y"): df = valid[[date_col, target_col]].copy() df["pred"] = pred df["resid"] = df[target_col] - df["pred"] df["weekday"] = pd.to_datetime(df[date_col]).dt.weekday by_week = df.groupby("weekday")["resid"].agg(["mean", "std", "count"]) # 残差一阶自相关,绝对值大于 0.3 说明还有时序结构没被吃掉 ac1 = df["resid"].autocorr(lag=1) return by_week, ac1残差自相关绝对值超过 0.3 就是一个明确信号:模型漏了某个滞后项或者趋势项。常见补救是补一个更长周期的 lag、加一个roll_mean的长窗口,或者干脆对目标做一阶差分再建模。残差标准差在验证集末尾突然变大,多半是数据结构发生了变化,这时候别硬调参,先回去确认那段时间有没有促销、故障、口径调整这类外部事件。
4. 时间序列分类:把连续曲线切成样本并做标签化
4.1 两种建模口径,选错了后面全白做
同一个数据集,时间序列分类可以有两种完全不同的问法。第一种是逐点分类:每个时间点用它的历史窗口特征,预测这个点属于哪一类,比如预测明天是高需求、中需求还是低需求。第二种是窗口分类:把一段固定长度的曲线当成一个样本,预测这段曲线的整体属性,比如这段设备振动信号属于正常、预警还是故障。两种口径的特征构造方式一样,但标签生成、样本切分和评估指标完全不同,动手前必须先想清楚业务要的是哪一种。
逐点分类的优点是样本量大、可以直接复用回归那套滑窗特征,缺点是相邻样本高度重叠,验证集必须按时间切;窗口分类的优点是每个样本独立性强,缺点是样本数量会骤减,一个 10000 点的序列按 200 步长切窗只剩 50 个样本,很容易训不动。这份资源里两条线都有对应脚本,切换口径时注意别把回归的特征矩阵直接塞进分类器,逐点分类用的 lag 特征在窗口分类里要改成窗口内的统计量,比如均值、方差、峰度、过零率。
4.2 标签构造:阈值怎么定比模型怎么调更关键
逐点分类最常见的目标是把连续值离散成三档。阈值定死值(比如销量大于 100 算高)在不同量级的序列上完全不可比,我一般用分位数或者相对变化率来定。下面这段按未来 H 步的相对变化率打三分类标签,正负阈值用训练集的分位数自适应确定:
import numpy as np import pandas as pd def make_trend_label(series, horizon=7, q_low=0.3, q_high=0.7): """按未来 horizon 步的相对变化率打三分类标签:0=跌,1=平,2=涨""" future_ret = series.shift(-horizon) / series - 1.0 train_part = future_ret.dropna() low_thr = train_part.quantile(q_low) high_thr = train_part.quantile(q_high) label = pd.cut( future_ret, bins=[-np.inf, low_thr, high_thr, np.inf], labels=[0, 1, 2], ) return label.astype("float"), low_thr, high_thrhorizon要和业务决策周期对齐,做补货决策就取补货提前期,做排班就取排班周期,取 7 只是日频数据的常用起点。q_low和q_high用分位数而不是固定百分比,是为了避免某一类样本占比不到 5%,那样模型学会的全部策略就是「一直预测中间类」。阈值必须只在训练集上算,然后套到验证集和测试集,这个顺序反了就是标准的标签泄露,验证集准确率会虚高十几个点。
标签尾部会有horizon个 NaN,因为未来值不存在,必须删掉。这一步在滑动窗口拼接时特别容易漏,导致最后几行的标签是 NaN,XGBClassifier会直接报错或者静默丢弃样本。
4.3 多分类训练与类别不平衡处理
XGBoost 做多分类用multi:softprob,输出每个类别的概率,方便后面按业务成本调阈值。类别不平衡是这个任务里的常态,三种处理方式我按优先级排:样本权重、分层采样、调类别先验。样本权重最省事也最不容易引入偏差,用compute_sample_weight一行搞定:
import xgboost as xgb from sklearn.utils.class_weight import compute_sample_weight def train_classifier(train, valid, feature_cols, target_col="label", num_class=3): w = compute_sample_weight(class_weight="balanced", y=train[target_col]) clf = xgb.XGBClassifier( objective="multi:softprob", num_class=num_class, n_estimators=1500, learning_rate=0.05, max_depth=5, min_child_weight=3, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, tree_method="hist", eval_metric="mlogloss", early_stopping_rounds=80, random_state=42, ) clf.fit( train[feature_cols], train[target_col].astype(int), sample_weight=w, eval_set=[(valid[feature_cols], valid[target_col].astype(int))], verbose=False, ) proba = clf.predict_proba(valid[feature_cols]) return clf, probanum_class必须和标签实际类别数一致,标签里出现跳号(比如只有 0 和 2)会直接报错,构造标签后要用LabelEncoder重排一遍。eval_metric用mlogloss而不是merror,因为对数损失对概率校准更敏感,早停选出来的模型在业务阈值调整时更稳。类别权重只作用于训练,验证集和测试集不加权,否则评估指标会失真。
如果三类的召回率差距超过 20 个百分点,说明光靠样本权重不够,得回到标签构造那一步看阈值是不是切得太偏。这时候有两个选择:把分位数往中间收,让类别更均衡;或者保留不均衡但按业务成本设置固定阈值,比如漏判「涨」的代价是缺货,那就把判为「涨」的概率门槛从 0.5 降到 0.35。
4.4 混淆矩阵与按类召回:别只看准确率
分类任务里准确率是最没用的指标之一,三类均衡时它还能看,一旦某类占 80%,模型全预测这一类也能拿到 80% 的准确率。我固定输出混淆矩阵、每类召回率和 macro-F1,三个一起看:
from sklearn.metrics import confusion_matrix, classification_report, f1_score def clf_report(y_true, y_pred): print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, digits=3)) macro_f1 = f1_score(y_true, y_pred, average="macro") return macro_f1混淆矩阵要重点看对角线相邻的两格。三分类任务里「跌」和「平」、「平」和「涨」互相错判是常态,因为阈值附近的样本本来就没有明确归属。如果错判集中在相邻类,说明模型学到的是连续趋势,只是边界模糊,可以接受;如果「跌」被大量判成「涨」,那就是特征方向出了问题,先回去查差分和变化率特征的符号有没有搞反。
按类召回率还要做时间切片验证。整体召回率 0.7 不代表每个季度都是 0.7,某些月份可能只有 0.4。把验证集按季度切开分别算召回,能提前发现模型在特定时期失效的问题,这个检查在季节性强的业务里几乎是必做的。
5. 避坑与排查:五个把结果做废的细节
5.1 现象:验证集分数高得离谱,上线后误差翻三倍
原因几乎都是数据泄露,但泄露的位置比想象中多。除了前面提过的随机切分和滚动特征没shift,还有两个隐蔽来源:一是标准化和分箱用了全量数据,验证集的均值方差信息渗进了训练;二是缺失值填充用了全量均值。这类泄露不会报错,分数还特别好看,是最难查的一类。
解决方式是把所有「学习型」的预处理都包进Pipeline,只在训练集上fit,再transform验证集。滚动统计特征也一样,先按时间切分,再在训练集内部做特征工程,最后把验证集拼上去用训练集末尾的历史做窗口。我习惯在脚本开头写死一句注释:任何跨行统计只能看过去,不能看未来。
5.2 现象:预测值整体偏低或偏高,误差呈现系统性偏移
原因是目标做过变换但还原时漏了一步,或者差分建模后忘了累加。对数变换、Box-Cox、一阶差分、季节性差分,每一步都要有对应的逆变换,顺序还得严格反过来。另一种情况是训练时用了log1p,预测时用了exp而不是expm1,在小数值区间会差出一个常数。
解决办法是写一个成对的变换和逆变换函数,在单元测试里跑一遍「变换再还原」的往返误差,误差超过 1e-6 就说明写错了。这个习惯看着啰嗦,但能省掉大量「模型明明没问题是数据错了」的排查时间。
5.3 现象:训练时报错或者标签全是 NaN
原因是滑窗拼接时尾部样本没有未来标签,shift(-horizon)在最后几行产生 NaN,而XGBClassifier对 NaN 标签的处理是直接抛异常或者静默丢行。另一种情况是多序列拼接时索引重复,shift跨序列取值,把 A 序列的尾巴接到 B 序列的头上。
解决办法是在构造标签后立刻dropna(subset=[label_col]),并且多序列场景一律先groupby(id)再shift,绝对不要在整张表上直接 shift。拼表时用pd.concat并重置索引,别依赖原始行号。
5.4 现象:某几天的预测误差突然特别大
原因是时间轴有缺口或者频率不一致。原始数据里缺了某天,shift(1)拿到的是两天前的值;数据从日频换成小时频,lag 的物理含义全变了。这类问题在数据量大的时候很难靠肉眼发现。
解决办法是建模前强制做一次时间轴体检:算相邻时间差的分位数,检查是否恒定;用asfreq补齐网格并打is_filled标记;补齐后重新构造特征。体检代码不超过十行,但能拦住后面百分之八十的诡异误差。
5.5 现象:MAPE 动不动上千,但 MAE 看着还行
原因是真实值里存在 0 或者接近 0 的点,除法把误差放大了几十倍。这种情况在销量、点击、故障次数这类稀疏数据里非常普遍。
解决办法是主指标换成 WAPE 或 sMAPE,MAPE 只作为参考;如果业务方坚持看百分比误差,就在评估前过滤掉真实值小于某个下限的样本,并明确说明过滤规则。别为了让 MAPE 好看去改预测值,那是本末倒置。
6. 进阶验证:滚动回测和上线前的最后一道关
单次留出验证只能回答「模型在某一段历史上表现如何」,回答不了「模型每天更新一次、每次预测未来 7 天,长期跑下来会怎样」。这两件事差别很大,前者是静态切片,后者是动态过程,误差会沿着时间累积。我上线前必跑的滚动回测长这样:
def walk_forward_backtest(df, feature_cols, target_col="y", date_col="ds", horizon=7, n_rounds=8): """每轮用截止日之前的数据训练,预测未来 horizon 天,然后窗口前移""" results = [] n = len(df) step = horizon for r in range(n_rounds): end = n - (n_rounds - r) * step train = df.iloc[:end] test = df.iloc[end:end + step] if len(test) < step: break model, pred = train_regressor(train, test, feature_cols, target_col) results.append({ "round": r, "train_end": train[date_col].iloc[-1], "mae": float(np.mean(np.abs(test[target_col].values - pred))), "wape": float(np.sum(np.abs(test[target_col].values - pred)) / np.sum(np.abs(test[target_col].values)) * 100), }) return pd.DataFrame(results)n_rounds决定回测覆盖多长的历史,一般至少覆盖两个完整业务周期,日频数据取 8 到 12 轮。step设为预测步长,保证每轮的测试段不重叠,避免同一个样本被重复计入。回测结果不要只看均值,要看波动:如果各轮 WAPE 在 8% 到 25% 之间大幅跳动,说明模型对数据分布变化很敏感,上线后需要配套监控,而不是直接把单次最优模型推上去。
| 回测观察项 | 健康区间 | 异常时的动作 |
|---|---|---|
| 各轮 WAPE 均值 | 低于业务可接受线 | 检查特征是否覆盖业务周期 |
| 各轮 WAPE 标准差 | 小于均值的 40% | 加正则、缩短特征窗口 |
| 最差轮次误差 | 不超过均值 2 倍 | 排查该时段外部事件 |
| 训练耗时 | 单轮可接受 | 降n_estimators、开hist |
最后补一句实操习惯:回测的时候把每轮的预测值和真实值都落盘,不要只存指标。误差出问题时,指标告诉你有问题,明细才能告诉你问题在哪一天、哪一个特征区间。从那以后我每次交付时序模型,都强制走一遍滚动回测加明细落盘,宁可多花半小时,也不想在业务方拿着某一天的离谱预测来问的时候,只能打开一个黑匣子干瞪眼。希望帮到你。
本文还有配套的精品资源,点击获取