☰
XGBoost时间序列预测与分类实战:特征工程与Python实现
2026/10/20 9:52:22 网站建设 项目流程

简介:这是一份基于Xgboost的时间序列预测与分类实战资源,面向机器学习和时间序列入门学习者。资源包含模型训练、模型保存、预测及评估的完整Python代码,并配有ETTh1测试数据集,可直接运行体验。压缩包共3个文件,包括2个Python脚本和1个CSV数据文件,整体仅407KB,轻量易用,适合快速上手。已有6028人学习下载,广受关注。通过该案例可掌握Xgboost在时间序列任务中的应用,理解梯度提升树如何处理时序数据,同时学习数据分析与特征工程的代码实现,并且代码附有逐行注释,方便对照理解。作者还提供模型保存功能,便于后续调用本地模型进行预测,并与实际观测值对比验证准确性。

1. 从「预测未来」到「识别模式」:一个能直接跑的 XGBoost 时间序列项目

时间序列预测的资料一搜一大把,但多数不是停在 ARIMA 的假设推导,就是直接甩给你一段跑不通的 LSTM。这套案例给了一个反直觉的结论:把预测和分类都交给 XGBoost,纯 Python 实现,机器学习生态里最常见的几个库装齐就能点击运行。项目里同时包含两个任务——用滑窗特征做回归来预测未来值,以及把序列切段后提取统计特征来判断模式属于哪一类。对刚入门 python 机器学习的同学,它是一份能直接抄作业的特征工程范本;对已经在业务里被时序数据折磨过的人来说,它又是一个不用大改就能套到自己数据上的 baseline。按顺序跑一遍,十分钟内就能看到一条完整的预测曲线和分类报告。

2. 项目与技术选型:为什么这个案例值得当蓝本

2.1 拿到手先看目录:极简结构覆盖完整流程

解压之后你会看到下面这几个文件,整个项目没有任何多余的东西:

ts-xgb-case/ ├── requirements.txt # 依赖清单 ├── data_gen.py # 数据生成脚本 ├── ts_forecast_xgb.ipynb # 时间序列预测主 Notebook ├── ts_forecast_xgb.py # 预测任务的脚本版 ├── ts_classify_xgb.ipynb # 时间序列分类主 Notebook └── ts_classify_xgb.py # 分类任务的脚本版

我一般拿到一个案例项目,先翻 requirements 再翻数据生成脚本,因为这两处能最快暴露作者的思路。这个案例的数据不是从某个渠道爬来的,而是用data_gen.py现场合成,好处很明显:任何人下载后第一次跑,结果都是一致的,不存在“数据变了模型效果对不上”的玄学问题。

Notebook 和 .py 双份提供也是我比较推崇的做法。Notebook 适合边看边跑、观察中间结果;脚本则适合直接接到自己的 pipeline 里做二次开发。两者共用同一套函数,意味着你在 Notebook 里验证过的改动,转成脚本时几乎零成本。

2.2 技术选型:为什么是 XGBoost 而不是 LSTM

很多人一听到时间序列就想到深度学习,但这个案例特意反着选,原因值得琢磨。时间序列预测本质上是回归问题,而 XGBoost 这类梯度提升树在中小规模数据上,往往比 LSTM 更容易出效果,训练速度快一个数量级,调参也更直观。

具体到这套代码里,选型逻辑是这样的:

场景因素为什么适合 XGBoost
数据规模模拟数据只有几千行,树模型不会过拟合到无法收敛
可解释性特征重要性可以直接打印,滞后 24 小时、滞后 96 小时哪个更关键一目了然
训练成本普通笔记本 CPU 上几分钟内跑完,不需要 GPU
工程落地训练好的模型可以序列化成 json 格式,部署时不需要深度学习运行时

把 LSTM 当默认方案,很多时候是惯性。这套案例的价值恰恰在于提醒你:先把特征工程做到位,XGBoost 在多数场景下已经能打。后面如果还想提升精度,再往 LSTM 或 Transformer 方向走也不迟。

2.3 数据流水线:模拟数据是怎么设计的

# data_gen.py 核心逻辑 import numpy as np import pandas as pd def make_series_data(n_points=1000, freq='H'): t = np.arange(n_points) trend = 0.03 * t # 线性趋势项 seasonal = 10 * np.sin(2 * np.pi * t / 24) # 24 小时周期项 rng = np.random.default_rng(42) # 固定随机种子,保证可复现 noise = rng.normal(0, 1.0, n_points) # 噪声项 y = 50 + trend + seasonal + noise ts_index = pd.date_range('2024-01-01 00:00', periods=n_points, freq=freq) df = pd.DataFrame({'timestamp': ts_index, 'value': y}) return df

这段生成逻辑里藏着三个常见的时间序列成分:趋势、周期、噪声。trend让序列均值随时间缓慢爬升,seasonal模拟一天之内的周期波动,noise则是真实数据里不可避免的随机扰动。freq='H'表示数据是小时粒度,这也是后面为什么滞后特征取 96 的依据——96 小时正好是 4 天。

模拟数据的用处不是替代真实数据,而是当你调试特征工程和模型参数时,有一个“已知答案”的测试场。你能清楚地看到模型有没有捕捉到 24 小时周期,这比直接拿乱糟糟的真实数据上来调参要高效得多。

3. 预测模块拆解:一个请求从进入到返回的完整链路

3.1 滑窗:滞后特征是怎么变成表格的

时间序列预测落地的第一步,是把一维序列改造成监督学习需要的二维表格。这套案例里用的方法是滑窗构造滞后特征,也叫 lag features。核心代码如下:

def create_lag_features(df, col='value', lags=(1, 2, 3, 6, 12, 24, 48, 96)): df = df.copy() for lag in lags: df[f'lag_{lag}'] = df[col].shift(lag) # shift 产生的前 96 行为 NaN,直接丢掉 df = df.dropna().reset_index(drop=True) return df

这里有三个要点值得展开。第一,shift(lag)是把整列往下挪 lag 行,挪完之后第 t 行的lag_24值就是第 t-24 行的原始值,也就是“24 小时前的观测”。这和滑窗的直觉完全一致:模型看到的是过去 24 小时、48 小时、96 小时各自发生了什么。第二,滞后的窗口不是随便选的,案例里的 24 和 96 对应小时粒度下的一天和四天,如果你的数据是分钟级,滞后可能要取 60、1440 这类值。第三,dropna()会把前 96 行全部删掉,这是常规做法,但代价是样本量减少,后面避坑章节会专门讨论这个问题。

为什么滞后特征对树模型这么重要?因为 XGBoost 本质上是学习特征到目标的映射关系,它不会自己“看到”序列的前后顺序。你必须把时序信息显式地编码成特征,模型才知道昨天和前天的值对今天有影响。

3.2 时间特征:把日历拆成模型能用的数值

光有滞后特征还不够,真实序列里往往还有“几点钟生意好”“周末和周一不一样”这类规律。这需要把时间戳拆成周期特征:

def create_time_features(df, ts_col='timestamp'): df = df.copy() dt = pd.to_datetime(df[ts_col]) df['hour'] = dt.hour # 0-23,捕捉日内周期 df['dayofweek'] = dt.dayofweek # 0-6,捕捉周内周期 df['month'] = dt.month # 1-12,捕捉月度趋势 return df

这里有个常见的争议点:hour是数值 0 到 23,模型会不会认为 23 比 22 大、比 0 更接近?在这个数据量级下,XGBoost 做的是基于阈值的切分,这种单调性影响通常不致命,所以直接用整数问题不大。如果换了线性模型或者你观察到边界点的预测误差特别大,再考虑用np.sin(2 * np.pi * hour / 24)这种周期编码替换。

时间特征加上滞后特征之后,最终喂给模型的特征矩阵就是这样:

# 组装训练集 df = make_series_data(n_points=2000) df = create_lag_features(df) # 先做滞后特征 df = create_time_features(df) # 再补时间特征 features = [c for c in df.columns if c not in ('timestamp', 'value')] print(df[features].head())

注意先后顺序:先shift再补时间特征,这样dropna()之后时间特征仍然和有效样本对齐,不会出现索引错位。

3.3 训练与评估:按时间切分,不能随机切分

模型训练部分使用的代码很直观,但有一个原则必须守住——切分训练集和测试集时,绝对不能使用train_test_split的默认随机模式,必须按时间顺序切分:

from xgboost import XGBRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error train_size = int(len(df) * 0.8) train_df = df.iloc[:train_size] # 前 80% 时间 test_df = df.iloc[train_size:] # 后 20% 时间 X_train, y_train = train_df[features], train_df['value'] X_test, y_test = test_df[features], test_df['value'] model = XGBRegressor( n_estimators=300, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, random_state=42, ) model.fit( X_train, y_train, eval_set=[(X_test, y_test)], early_stopping_rounds=20, verbose=False, )

这里early_stopping_rounds=20的作用是,当测试集上的误差连续 20 轮不再下降时,训练提前终止,防止过拟合。参数上,learning_rate=0.05配合n_estimators=300是标准的保守组合,预测任务通常不需要调出惊人精度,稳定更重要。subsample和colsample_bytree分别控制样本采样和特征采样比例,本质上是在给模型加随机性来增强泛化。

评估阶段除了输出 RMSE 和 MAE,还应该做一件事:把预测值和真实值画在同一个坐标系里。只看指标容易自欺,图能直观告诉你模型是不是滞后了一拍、是不是低估了峰值。案例里这两步都有现成代码,跑完 Notebook 最后一个 cell 就能看到对比曲线。

pred = model.predict(X_test, iteration_range=(0, model.best_iteration + 1)) rmse = mean_squared_error(y_test, pred, squared=False) mae = mean_absolute_error(y_test, pred) print(f'RMSE: {rmse:.3f}, MAE: {mae:.3f}')

4. 时间序列分类的实现:从连续序列到样本集

4.1 分段与标注:分类任务的数据结构

预测任务的样本是一条一条的时序记录,而分类任务的数据单位变成了一段序列。案例里把长度为 1000 的序列切成若干段,每段长度固定,然后给每段打上标签。

def make_segments(df, seg_len=48, step=24): segments = [] labels = [] values = df['value'].values for start in range(0, len(values) - seg_len, step): seg = values[start:start + seg_len] segments.append(seg) # 用该段最后 6 个点的均值相比前 6 个点的均值来定义标签 if seg[-6:].mean() > seg[:6].mean(): labels.append(1) # 上行段 else: labels.append(0) # 下行段 return segments, labels

seg_len=48是每段的长度,step=24是滑动的步长,也就是说相邻两段有 24 个点的重叠。这样做的目的是增加样本数量,因为分类器需要足够多样本才能学出稳定的边界。标签的定义用了“段尾均值 vs 段首均值”这种简化规则:如果后 6 个点的均值比前 6 个点高,就标记为上行段,否则为下行段。

实际操作中,标签往往来自业务规则,比如设备故障前后的振动波形标记为异常,或者按交易行为的模式给人打标。这套案例的标注方式虽然简单,但完整演示了“序列到样本再到标签”的转换流程,换成你自己的标注逻辑时,只需要改这一处。

4.2 从序列到特征:用统计量描述一段形状

分段之后,每段序列长度是 48,如果你直接把 48 个原始值展平成 48 维特征,也不是不行,但样本量不大时容易过拟合。案例做法是压缩成统计特征:

import numpy as np def extract_stats_features(seg): x = np.arange(len(seg)) slope, intercept = np.polyfit(x, seg, 1) # 一元线性回归拟合趋势 return { 'mean': seg.mean(), 'std': seg.std(), 'min': seg.min(), 'max': seg.max(), 'ptp': seg.max() - seg.min(), 'slope': slope, } # 批量提取 feature_list = [] for seg in segments: feature_list.append(extract_stats_features(seg)) X_seg = pd.DataFrame(feature_list) y_seg = np.array(labels)

np.polyfit(x, seg, 1)是对这段序列做一元线性拟合,返回的slope就是整体趋势的斜率。配合mean、std、min、max这几个描述统计量,一段 48 个点的序列就被压缩成了 6 维特征。

这套特征工程背后是对称的思路:滞后特征关心“过去的某个具体时间点发生了什么”,统计特征关心“这段序列整体长什么样”。前者适合回归预测,后者适合分类判别。如果你想提升分类精度,可以从这个特征列表扩展,比如加上skew(偏度)、kurtosis(峰度)、或者用scipy.signal提取频域能量,这些在本案例的基础上扩展都很方便。

4.3 分类器训练与结果解读

分类部分用的还是 XGBoost,只是换成了XGBClassifier:

from xgboost import XGBClassifier from sklearn.metrics import accuracy_score, f1_score from sklearn.model_selection import train_test_split X_tr, X_te, y_tr, y_te = train_test_split( X_seg, y_seg, test_size=0.3, random_state=42, stratify=y_seg, ) clf = XGBClassifier( n_estimators=200, max_depth=4, learning_rate=0.1, random_state=42, ) clf.fit(X_tr, y_tr) y_pred = clf.predict(X_te) print(f'Accuracy: {accuracy_score(y_te, y_pred):.3f}') print(f'F1-score: {f1_score(y_te, y_pred):.3f}')

注意分类这里我用了train_test_split的随机切分,这和预测模块的按时间切分不矛盾。原因在于分段之后每个样本是独立的序列片段,不再存在“第 t 天依赖第 t-1 天”的强顺序关系。当然,如果你的多个片段来自同一条长序列,相邻片段之间仍有相关性,稳妥做法是用GroupShuffleSplit按序列 ID 分组切分,保证同一序列的片段不会同时出现在训练集和测试集里。

跑完这段代码,大概率会得到 0.9 以上的准确率。别高兴得太早,这个数字只能说明“在当前模拟数据上分类器有效”。真实场景中标签噪声大、类别不平衡,准确率往往会掉下来。案例的价值在于把全链路打通,你可以把这段代码里的模拟数据替换成自己的数据,重点观察 F1-score 的变化而不仅是准确率。

5. 跑通之后的踩坑记录:五个最容易翻车的点

5.1 用随机切分做时序预测:预测曲线像是被“剧透”了

现象:模型在测试集上 RMSE 低得离谱,但你画出预测曲线之后发现,曲线整体比真实值往后平移了一段,模型的预测好像“看到过未来”。

原因:某个版本里用了train_test_split(X, y, test_size=0.2)默认随机切分,训练集和测试集的时间段互相穿插,测试集里的历史信息出现在了训练集里。树模型对这种泄漏非常敏感,因为它会直接记住时间段的均值水平。

解决:切分前先按时间排序,然后直接用df.iloc[:train_size]和df.iloc[train_size:]切。从那以后我每次做时序项目,第一件事就是把训练集和测试集的时间范围画出来,确认没有交叉。

5.2 dropna 把冷启动样本全删了:有效训练数据少了一半

现象:原始数据 2000 行,做完滞后特征之后只剩 1904 行,感觉还行。但如果滞后窗口拉到 336(两周的小时数),dropna()之后直接没了 336 行,起步阶段的数据全被清空了。

原因:shift(lag)必然在序列开头产生 NaN,dropna()会把这些行删掉。滞后窗口越大,损失的行数越多。

解决:如果你的数据本身很长,直接丢掉问题不大;如果数据量紧张,可以保留这些行,只对非 NaN 的特征做训练,或者用前向填充把缺失的滞后值填成序列的第一个有效值。常见做法是给每个 NaN 行填 0,同时加一个lag_96_mask之类的标志特征,告诉模型这个值是填充的。

5.3 全局归一化偷看了测试集的统计量:线下指标失真

现象:加了 MinMaxScaler 之后,测试集上的误差明显变好,但换到真实环境里上线,效果立刻打回原形。

原因:归一化时在整个数据集上fit_transform,相当于把测试集的均值和方差也编码进了参数里。树模型虽然对特征缩放不敏感,但这个坑在神经网络和线性模型里会直接导致线下评估失真。

解决:在训练集上fit,再用同一组参数去transform测试集。对于 XGBoost 这棵树模型,实际上不做归一化也能跑,案例里就没做归一化,这是合理的取舍。

5.4 差分还原忘了累加:预测值整体偏低

现象:原始序列有明显的上升趋势,你对目标列做了一阶差分y.diff()之后训练,预测结果的曲线形状对,但数值整体比真实值低一个量级。

原因:差分后的目标值是“相邻两个点的差值”,你训练出的模型预测的是增量,而不是真实值。如果不把预测的增量逐点累加回基准值,得到的预测就是在 0 附近的增量序列,而不是原始量级的值。

解决:训练时保存差分前的最后一个真实值,预测后做np.cumsum(pred) + last_value还原。这份案例的数据本身没有做差分,直接预测原始值,但很多真实序列是非平稳的,你扩展时很可能遇到这个问题。

5.5 结果不稳定:同一份代码两次运行指标不一样

现象:连续运行两次 Notebook,RMSE 相差 0.5 左右,分类准确率也上下浮动,看起来像模型性能不稳定。

原因:XGBoost 的训练过程中有随机性,比如列采样、样本采样、以及对特征的随机打断点。如果random_state没固定,每次跑出来的模型都不同;sklearn 的train_test_split也会引入额外的随机性。

解决:XGBRegressor(... random_state=42)、train_test_split(... random_state=42)、数据生成脚本里用np.random.default_rng(42),三处种子全固定,结果即可复现。这属于血泪经验,跑对比实验前不改种子,指标波动会直接淹没真实的模型差异。

6. 二次开发指南:把这份代码改成你自己的场景

6.1 换数据:只需要对齐两列

案例的make_series_data()输出的 DataFrame 只有两列:timestamp和value。换你自己的数据时,用 pandas 读入 CSV 后,把时间列和数值列改成这两个名字,或者改一下create_lag_features和create_time_features里的列名参数,其他流程不需要动。注意时间列要先pd.to_datetime()并且按时间排序,否则滞后特征全部错乱。

如果你的数据是多列(比如同时有销量、价格、天气三个维度),可以把其他列也拼进特征矩阵,只设置features列表包含哪些列即可。XGBoost 本身不要求特征等宽,多几列少几列不影响整体流程。

6.2 特征升级:节假日、滚动均值、外部变量

在案例基础上,最值得加的三个特征是滚动均值、滚动标准差和节假日标记。

# 以滚动均值示例 df['rolling_mean_24'] = df['value'].rolling(window=24).mean() df['rolling_std_24'] = df['value'].rolling(window=24).std()

滚动均值相当于给模型提供了“最近一段时间的平均水平”,比单个滞后特征更稳;滚动标准差则描述波动程度,对预测流量突刺这类场景很有用。节假日标记这类二值特征,做的时候注意对齐时区,这曾经让我卡了一整天。加入这些特征之后,模型的评估流程完全不用改,只更新features列表重新训练即可。

6.3 滚动回测:比单次切分更可信的验证方式

单次切分只能验证一个时间段的预测效果,真实业务里模型会连续预测很多天,更接近线上的是滚动回测:每次只预测未来一步,然后把新观测值并入历史窗口,继续预测下一步。

history = df['value'].values[:train_size] preds = [] for i in range(len(y_test)): # 用最新 history 构造特征 feat = np.array([history[-1], history[-2], history[-3], history[-6]]) feat = feat.reshape(1, -1) pred = model.predict(feat)[0] preds.append(pred) # 真实值进入历史,滚动作业 history = np.append(history, y_test.iloc[i])

这段代码省略了时间特征和时间索引对齐,但思路比完整实现更重要:滚动回测评估出的指标,才是模型上线后真实会遇到的场景。案例里是快速切分,适合验证流程;二次开发阶段建议升级成滚动回测,否则你很难发现“模型在 long 周期上系统性滞后”这类问题。

那次我在某零售数据集上做实验,单次切分显示误差很低,一上滚动回测立刻暴露模型对促销日预测失灵。从那以后我每次做时序项目,都强制把单次切分和滚动回测两份结果一起看,对着差异去补特征。XGBoost 模型本身不会给你惊喜,踩坑踩多了,惊喜全在特征工程里。希望这套案例的完整流程能帮你少走几步弯路,更快把预测和分类两条线跑通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询