简介:这是一套基于Xgboost的时间序列预测与分类实战案例,面向机器学习入门及时间序列分析学习者,用Python实现从数据探索、特征工程到模型训练、保存与预测的完整流程。压缩包共3个文件,含2个Python脚本和1个CSV测试数据集,整体仅407KB,轻量易上手,点击即可运行。案例覆盖ETTh1数据集的实战处理,Python脚本中提供逐步注释,可清晰理解梯度提升树在时间序列任务中的建模思路、特征构造与预测输出;同时包含时间序列分类部分和模型保存模块,训练后的模型可保存至本地,用于生成未来数值序列并与实际观测值对比评估精度。目前已有6027人学习下载,适合希望快速搭建完整时间序列项目、掌握Xgboost实际应用技巧的读者。
1. 时间序列预测模型实战案例:为什么XGBoost能把一维序列变成能跑的模型
拿XGBoost做时间序列预测,绕不开一个尴尬现实:预测模型要的是二维表格,时间序列是一维的。这套实战案例把中间那段路全部补齐了——数据、脚本、特征工程、训练、评估一应俱全,解压后顺着主脚本点击即可运行,预测和分类两个任务都能直接看效果。它不是给你讲理论,而是给你一条能跑的基线:先跑通,再替换成自己的数据,省掉从零搭环境、拼代码的时间。适合两类人:一类刚接触时序预测、需要模板上手的开发者,另一类是业务上已经攒了一堆带时间戳的数据、想用XGBoost快速验证可行性的从业者。依赖也很干净,pandas、numpy、xgboost三个库就够,环境不挑版本。
2. 特征工程才是主线:滑窗滞后、周期编码与多步目标切分
XGBoost本质上是梯度提升树,叶子节点输出的是一个常数,它只能从“当前已知的特征”里去猜目标值的走向。所以时序预测落到XGBoost之前,必做的一件事是重构样本:把一条一维的序列切成很多个小窗口,每个窗口看成一行样本,窗口末尾的下一个时刻值作为标签。这一步叫滑窗,也叫滞后特征构造,是整个项目里决定预测精度的第一道关卡,比后面调参的影响大得多。
2.1 滑窗特征构造:从一维序列到二维矩阵
最常见的做法是用pandas的shift函数把序列整体下移,每一列就是一个滞后特征。下面这段代码就是这套项目里造滞后特征的核心逻辑:
import pandas as pd import numpy as np def make_lag_features(series, lags=None): if lags is None: lags = [1, 2, 3, 6, 12, 24] df = pd.DataFrame({'y': series}) for lag in lags: df[f'lag_{lag}'] = df['y'].shift(lag) df.dropna(inplace=True) return dfshift是pandas里把整列数据往下搬的方法,shift(1)表示把t时刻的值挪到t+1时刻的行上。经过这一步,每一行的lag_1读到的就是上一小时的观测值,lag_24读到的是24小时前的值,模型拿到这些“历史已知量”来预测当前行的y值。dropna会把开头没有完整滞后的几行丢掉,因为那些行里全是空值。
lags这个列表怎么定,直接影响模型上限。如果数据有明显的24小时周期,lag_24必须有;有周周期就加168;工作日和周末差异大,那lag_24和lag_168同时保留要比只留lag_1有意义得多。我一般先按业务周期放几个关键点,再补一两个短滞后捕捉局部惯性,不建议一口气塞几十个高度相关的滞后特征,树模型虽然能做特征选择,但冗余特征多了会稀释每棵树的深度。
2.2 时间特征编码:周期变量别直接喂原始数值
滞后特征只覆盖了“过去是什么”,模型还不知道“现在是几点、星期几”。时间特征就是把这些信息补进去。这套项目里对小时和星期都做了周期编码,核心代码是这样:
df['hour'] = df.index.hour df['weekday'] = df.index.weekday df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24) df['weekday_sin'] = np.sin(2 * np.pi * df['weekday'] / 7) df['weekday_cos'] = np.cos(2 * np.pi * df['weekday'] / 7)很多人会问,树模型不是能直接切分0到23这24个小时吗?确实能切,但直接喂原始整数的代价是:模型要花好几层分裂才能表达出“0点和23点其实挨得很近”这个事实。sin和cos把小时映射到一个二维圆上,0点和23点的距离天然就很近,模型不必自己学这个周期结构。星期几同理,用数值1到7会暗含7比1大的错误顺序,sin/cos展开之后就没有这个问题了。
如果业务里还碰到节假日、促销日这种特殊日期,那不是一个周期能覆盖的,最稳妥的做法是加一列0/1标志位。注意不要把这些标志位也做滞后处理,节假日的标签在预测时如果还没发生,是不能进特征的。
2.3 多步预测目标切分:递归预测与直接预测的取舍
单步预测只需要把y定义为下一时刻的值,但大家拿到这类项目通常是想预测未来好几步。这套项目里默认的思路是递归预测:训练一个单步模型,预测出t+1的值之后,把这个预测值拼回序列尾部,再继续预测t+2。实现成本最低,但代价是误差滚雪球。如果horizon拉长,预测曲线往往会慢慢变成一条水平线,这在后面的避坑章节里会细说。
另一种思路是直接预测,一个模型直接预测h步之后的值,代价是每个步长都要训练一个模型。我一般会先用递归方式跑通基线,因为脚本改动小;如果horizon超过5步而且精度明显崩了,再切到直接预测。直接预测的标签切分逻辑如下:
def make_multi_step_data(series, n_lags=24, horizon=6): X, y = [], [] for i in range(n_lags, len(series) - horizon): X.append(series[i - n_lags:i]) y.append(series[i + horizon]) return np.array(X), np.array(y)这个函数里,每个样本的输入是过去24个点,标签是当前窗口之后第6个点的值。horizon参数就是你要预测几步之后的目标,换成1就是常规的单步预测。注意循环的终止范围是len(series)减去horizon,否则最后几行标签会越界。这类函数在项目脚本里通常已经写好了,你只需要改horizon和n_lags两个参数就能切换预测尺度。
3. 训练与调参:时序切分、早期停止、分类目标与滚动验证
特征表和标签就位之后,训练环节反而比想象中简单。但这块有一个原则性的问题:时序数据的切分方式,直接决定你的实验结果是可信还是自嗨。很多人在这套项目上跑出来的分数高得惊人,最后发现是切分方式错了。训练代码怎么写不是重点,重点是先定好切分策略,再看参数。
3.1 时序切分与训练参数:不是随便切一刀
绝对不能直接用train_test_split做随机切分。时序数据的相邻样本之间高度自相关,随机切分等于把同一个时间片段的数据同时扔进训练集和测试集,模型相当于背了答案,测试分数好看,一上真实数据就原形毕露。正确的做法是按时间顺序切分,前一段训练、后一段验证。主脚本里的训练逻辑大概是这样的:
from xgboost import XGBRegressor split_idx = int(len(df) * 0.8) train_df = df.iloc[:split_idx] test_df = df.iloc[split_idx:] X_train = train_df.drop(columns=['y']) y_train = train_df['y'] X_test = test_df.drop(columns=['y']) y_test = test_df['y'] model = XGBRegressor( n_estimators=2000, max_depth=4, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, early_stopping_rounds=50, random_state=42 ) model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)这里做了两件关键的事。第一,按位置切分,前80%训练、后20%验证,保持时间顺序完全不被破坏。第二,配置了early_stopping_rounds=50,模型每迭代一轮都在验证集上计算损失,如果连续50轮损失都没有刷新最低值就直接停,不需要真的跑满2000棵树。这样出来的n_estimators是模型自己决定的,不是拍脑袋定的。
参数表里几个值的含义值得记一下。max_depth控制树的深度,时序数据噪声大,4层左右的浅树反而比深树稳。learning_rate是每棵树的贡献权重,0.05配2000棵上限是标准做法,梯度小所以不容易过拟合。subsample和colsample_bytree分别控制每棵树用多少比例的样本和特征,都设0.8相当于给树加了一层随机性,能降低过拟合。这套组合在大多数业务时序上表现都不错,可以当默认配置。
一个需要注意的细节:这里测试集同时扮演了早停评估集的角色,模型选择已经偷看了测试集的损失,所以最终分数会有轻微乐观偏差。想要一个真正干净的评估,应该再往后留一段完全没参与过的数据做最终验证。
3.2 分类目标切换:换一个objective等于换了一类问题
这套项目有意思的地方在于,预测做完之后,同一个特征工程可以直接切到分类任务。区别只在两处:标签y的构造方式,以及模型换成了XGBClassifier。分类场景通常是你关注“某个异常事件是否会发生”,而不是数值本身是多少。比如下一个时刻的值是否显著高于过去24小时的均值,这里给出的标签构造方式如下:
from xgboost import XGBClassifier df['label'] = (df['y'].shift(-1) > df['y'].rolling(24).mean()).astype(int) # ……特征构造与切分逻辑与回归相同…… model = XGBClassifier( n_estimators=2000, max_depth=4, learning_rate=0.05, scale_pos_weight=10, eval_metric='auc', early_stopping_rounds=50, random_state=42 ) model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)shift(-1)表示取下一时刻的值,和前面滞后特征里的shift方向正好相反。它把未来一期的数据和当前24小时均值做比较,超过均值记为1,否则记为0。注意这个标签本身没有泄漏问题,因为标签只是训练时要拟合的目标,不参与特征构造。
scale_pos_weight是处理不平衡的关键参数。如果正类样本只占5%,不处理的话模型会直接全预测成负类,因为那样准确率也有95%。设成10意味着给每个正类样本10倍的损失权重,让模型在训练时不得不多关注少数类。具体取值可以先统计正负样本比例,比如1:20就设20,但实操中10到20之间效果差异不大,我一般先设10看召回率再微调。
评估指标也要跟着换。分类场景里准确率没有区分度,eval_metric改成auc更适合排序场景。但如果后面要做业务决策,最好再加上precision和recall一起看,后面避坑章节会专门讲这个。
3.3 时序交叉验证:用TimeSeriesSplit代替K折
等模型跑通了,下一步是让结果更可信。很多人拿到这套项目之后直接改数据就跑,但一份数据只能切出一次训练测试集,模型的稳定度完全看不出来。这时候需要用时序交叉验证,sklearn里的TimeSeriesSplit正好干这个事:
from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(X): X_tr, X_val = X[train_idx], X[val_idx] y_tr, y_val = y[train_idx], y[val_idx] model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False)TimeSeriesSplit和K折最大的区别是它不打乱顺序。第1折用前20%训练、后20%验证,第2折用前40%训练、后20%验证,依次往后推。每一折的验证集都严格落在训练集之后,不存在未来信息泄漏到过去的问题。
使用时序交叉验证主要看两个东西:每一折的误差是否稳定,以及早停轮数是否一致。如果某一段时间的分数明显差,通常说明那一段数据里有新的行为模式,模型没见过。这种情况既不是特征不够也不是参数不好,而是业务规律本身在漂移,需要考虑加时间特征或者缩短训练窗口。
4. 避坑与排查:数据泄漏、误差累积与类别不平衡
这一章是全项目里最值得反复看的部分。我拆过不少类似的时序项目,模型在训练集上表现完美、一上真实环境就翻车的案例见过太多次。下面这几条不是理论推演,是实打实踩过的坑,每一条都按现象、原因、解决三个步骤拆开讲。
4.1 数据泄漏:线下分数虚高的头号原因
先说最致命的一个。现象是:测试集MAE小得离谱,几乎接近零,特征重要性里有一两个特征占绝对主导,但你把模型接到实时数据上预测,输出完全不能用。我见过有人在项目里直接写train_test_split随机切分,出来的R2高达0.99,还以为是XGBoost强大,其实是数据泄漏。
原因在于时序数据的相邻样本高度自相关。随机切分时,同一个时间片的样本既进了训练集又进了测试集,模型等于在考试时看到了答案。还有一种更隐蔽的泄漏,发生在特征构造阶段:有人会用“当天全量均值”“未来差分”当特征,这些数值在预测时刻根本拿不到,属于未来信息混进了训练集。
解决的思路只有一条:特征构造和切分都要做“时间守卫”。切分一律用按时间顺序的方式,特征构造写成一个统一的函数,入参只允许截至t时刻的数据窗口。我自己的习惯是在代码里加一个断言,确保所有shift参数方向正确,再检查一遍没有shift(-1)的特征混进来。
4.2 递归多步预测的误差累积:预测曲线变成水平线
第二种翻车现场是:单步预测误差不大,但用递归方式预测未来20步,画出来的曲线越往后越平,最后变成一条水平线。原因是递归预测把上一步的预测值当成下一步的真实输入,模型本身就有误差,这个误差被当成特征再次输入之后,误差会越滚越大。随着步数增加,模型能用的有效信息越来越少,输出自然被拉向训练集的均值。
解决这个问题,先看单步误差有多大。如果单步MAE已经很高,那多步预测再怎么救也救不回来。如果单步误差尚可,说明问题出在递归策略本身,这时候建议切成直接预测,每个horizon训练一个独立模型。另一种折中做法是每预测几步就用真实值回填一次,这在有滞后标注数据的场景里很实用。评估的时候也别只看单步指标,画一条不同horizon下的误差曲线,能直观看到误差拐点出现在第几步。
4.3 分类场景的类别不平衡:AUC高但召回率是零
分类任务里最常见的坑是:模型预测结果几乎全是正常类,AUC看着有0.9,但recall是0。原因是异常事件在数据里占比太低,比如不到1%,树模型在线性分裂时发现全预测成正常类的损失更小,于是干脆躺平。AUC这个指标在极端不平衡下本身也会虚高,因为它衡量的是排序能力,不是实际分类能力。
解决思路分几层。第一层是scale_pos_weight,把正类权重拉上来,这在前面已经写过。第二层是换评估方式,重点看precision-recall曲线,而不是只看AUC和准确率。第三层如果还不够,可以考虑对少数类做重采样,但时序数据做SMOTE要非常小心,不能把时间窗口内的样本随机插值,那样会制造出真实世界不存在的过渡序列,我一般不太推荐在这类项目里用。先调权重和评估指标,大多数业务场景能撑住。
5. 残差自相关检查:验证模型真正学会了时序规律
训练完模型、拿到一个不错的测试集分数,很多人就觉得项目结束了。但还有一个很便宜的验证手段,能直接看出模型有没有把时序信息学干净——检查预测残差的自相关性。残差是真实值和预测值的差,如果模型已经把所有规律都学到了,残差应该像白噪声一样没有规律;如果残差里还残留明显的波动模式,说明模型漏掉了一部分时序结构,还有提升空间。
from statsmodels.graphics.tsaplots import plot_acf import matplotlib.pyplot as plt residual = y_test - model.predict(X_test) plot_acf(residual, lags=24) plt.show()画出来的自相关图里,横轴是滞后阶数,纵轴是自相关系数,上下两条虚线是置信区间。如果lag1或lag2的自相关明显超出虚线范围,说明残差里还藏着短期惯性没被模型捕获,最常见的处理是再补几个短滞后特征,或者把目标值换成一阶差分后再建模。如果lag24附近有尖峰,说明周期信号没学全,需要检查周期特征有没有完整进入模型。
从那以后我每次换数据集,第一件事就是跑一遍残差自相关图,再做任何调参。这个方法几乎零成本,却能把模型是不是在“死记硬背”照出原形。某次线上预测效果不佳,排查到最后,就是靠这张图发现节日效应被漏掉了。从那以后这个习惯就固化下来了:固定随机种子、统一特征构造函数、先画残差图再谈调参。希望帮到你。
本文还有配套的精品资源,点击获取