
简介这份实战案例面向时间序列预测与机器学习初学者围绕 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(shuffleTrue)理由看起来也充分——样本量小随机切能保证分布均匀。问题在于时序样本之间高度自相关随机切会让第 100 天的样本进训练集、第 99 天的进验证集模型等于提前看了答案验证分数漂亮得离谱一上线就原形毕露。血泪经验是任何带时间戳的任务切分必须沿时间轴单向推进。我一般准备三种切法按任务阶段换着用。第一种是单点留出留最后 20% 做验证适合快速迭代和出第一版结果第二种是TimeSeriesSplit多折每折训练集都比上一折长用来观察模型在不同时间段的稳定性第三种是滚动回测每预测一天就把真实值并回训练集再往前挪一步最接近线上真实调用方式成本也最高通常放在上线前跑一次。前两种切法的代码长这样import pandas as pd from sklearn.model_selection import TimeSeriesSplit def holdout_split(df, date_colds, valid_ratio0.2, gap0): 按时间顺序切一刀gap 用来在训练和验证之间留缓冲 df df.sort_values(date_col).reset_index(dropTrue) 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_splits5, test_size28, gap7): 扩窗滚动切分每折验证集紧跟在训练集后面 tscv TimeSeriesSplit(n_splitsn_splits, test_sizetest_size, gapgap) 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_coly, date_colds, lags(1, 2, 3, 7, 14, 28), windows(7, 14, 28)): df df.sort_values(date_col).reset_index(dropTrue) out df.copy() # 1) 滞后值lag_k 表示 k 个时间步之前的目标值 for lag in lags: out[flag_{lag}] out[target_col].shift(lag) # 2) 滚动统计先 shift(1) 再 rolling避免把当前点算进去 base out[target_col].shift(1) for w in windows: out[froll_mean_{w}] base.rolling(w).mean() out[froll_std_{w}] base.rolling(w).std() out[froll_min_{w}] base.rolling(w).min() out[froll_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_colds): 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_colds, freqD, target_coly): 把缺失日期补成完整网格并标记哪些点是补出来的 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_coly): report {} report[nan_ratio] df[feature_cols].isna().mean().sort_values(ascendingFalse) corr df[feature_cols [target_col]].corr()[target_col].drop(target_col) report[top_corr] corr.abs().sort_values(ascendingFalse).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_estimators500 到 3000拟合更充分易过拟合配早停上限给足learning_rate0.01 到 0.1收敛快方差大首轮 0.05精调降到 0.02max_depth4 到 8能学更复杂交互特征少取 4 到 5特征多取 6 到 7min_child_weight3 到 20叶子更保守数据噪声大时往上加subsample0.7 到 0.9行采样抗过拟合0.8 起步colsample_bytree0.6 到 0.9列采样抗共线性滞后列多时取 0.6 到 0.7reg_lambda0.1 到 10L2 正则压极端值1.0 起步reg_alpha0 到 1L1 正则稀疏化特征冗余时加到 0.5调参顺序我固定为先定learning_rate0.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_coly): # 从训练集尾部再切 15% 做早停监控测试集完全不参与 cut int(len(train) * 0.85) fit_part, es_part train.iloc[:cut], train.iloc[cut:] params dict( objectivereg:squarederror, n_estimators3000, learning_rate0.03, max_depth6, min_child_weight5, subsample0.8, colsample_bytree0.75, reg_lambda1.0, reg_alpha0.0, tree_methodhist, random_state42, ) model xgb.XGBRegressor( **params, early_stopping_rounds100, eval_metricmae, ) model.fit( fit_part[feature_cols], fit_part[target_col], eval_set[(es_part[feature_cols], es_part[target_col])], verboseFalse, ) pred model.predict(valid[feature_cols]) return model, predearly_stopping_rounds100表示连续 100 棵树验证集 MAE 没改善就停数据量小可以降到 50数据量大可以提到 200。tree_methodhist是必开的默认的精确切分在几万行以上会慢到怀疑人生直方图近似对时序任务几乎无损。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, eps1e-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_colds, target_coly): 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(lag1) 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, horizon7, q_low0.3, q_high0.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因为未来值不存在必须删掉。这一步在滑动窗口拼接时特别容易漏导致最后几行的标签是 NaNXGBClassifier会直接报错或者静默丢弃样本。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_collabel, num_class3): w compute_sample_weight(class_weightbalanced, ytrain[target_col]) clf xgb.XGBClassifier( objectivemulti:softprob, num_classnum_class, n_estimators1500, learning_rate0.05, max_depth5, min_child_weight3, subsample0.8, colsample_bytree0.8, reg_lambda1.0, tree_methodhist, eval_metricmlogloss, early_stopping_rounds80, random_state42, ) clf.fit( train[feature_cols], train[target_col].astype(int), sample_weightw, eval_set[(valid[feature_cols], valid[target_col].astype(int))], verboseFalse, ) 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, digits3)) macro_f1 f1_score(y_true, y_pred, averagemacro) 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 或 sMAPEMAPE 只作为参考如果业务方坚持看百分比误差就在评估前过滤掉真实值小于某个下限的样本并明确说明过滤规则。别为了让 MAPE 好看去改预测值那是本末倒置。6. 进阶验证滚动回测和上线前的最后一道关单次留出验证只能回答「模型在某一段历史上表现如何」回答不了「模型每天更新一次、每次预测未来 7 天长期跑下来会怎样」。这两件事差别很大前者是静态切片后者是动态过程误差会沿着时间累积。我上线前必跑的滚动回测长这样def walk_forward_backtest(df, feature_cols, target_coly, date_colds, horizon7, n_rounds8): 每轮用截止日之前的数据训练预测未来 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最后补一句实操习惯回测的时候把每轮的预测值和真实值都落盘不要只存指标。误差出问题时指标告诉你有问题明细才能告诉你问题在哪一天、哪一个特征区间。从那以后我每次交付时序模型都强制走一遍滚动回测加明细落盘宁可多花半小时也不想在业务方拿着某一天的离谱预测来问的时候只能打开一个黑匣子干瞪眼。希望帮到你。本文还有配套的精品资源点击获取