简介:这份面向时间序列预测与机器学习学习者的实战资料包,围绕XGBoost在时序预测与时序分类中的应用展开,适合希望从数据分析、特征工程到模型训练与评估完整走通一遍的学习者。包内共3个文件,以2个Python脚本和1个CSV数据集为主,脚本分别承担建模与辅助操作,数据文件可直接用于案例复现,压缩包约407KB,体量轻便、即点即用。相关内容已有6033人学习下载,具备较好的参考基础。读者可从中获得模型训练与本地保存、输入特征选择、未来数值序列生成、预测值与实际观测值对比评估等完整流程,并能结合逐步注释理解数据分析与特征工程代码,适合作为机器学习与时序预测入门的实操样例。
1. 从两个真实需求切入:预测和分类其实是两份活
某公司的运维同事周一早上丢过来一份按小时采集的设备传感器与能耗记录,需求有两句话:下周每小时负荷大概多少,以及未来几个小时内设备会不会进异常区间。前者是典型的时间序列预测,后者是时间序列分类,听起来像一件事,落到代码里却是两套样本构造方式、两套评估口径。
很多人第一反应是上深度模型,结果环境配两天,训练跑一晚上,指标还不如一个参数调对的 XGBoost。这份 Python 实战案例把两条链路拆成两个可以直接点击运行的 notebook,从数据加载、特征工程、训练、回测到结果可视化全部铺好,属于那种打开 Jupyter 从头往下点就能出图的项目,适合刚接触机器学习、想拿一份能跑的模板改造成自己业务的新手,也适合想看看别人怎么把时序问题转成表格问题的熟手。
2. 项目骨架与技术选型:从目录结构到第一行能跑通的代码
2.1 目录结构与文件职责
这套案例没有把逻辑全堆在 notebook 里,而是把可复用的部分抽到了 src 下,notebook 只负责串联和展示。这种组织方式在二次开发时很省事——你改特征只要动 features.py,换模型只要动 train_*.py,notebook 不用重写。
timeseries-xgb-demo/ ├── data/ │ ├── raw/ # 原始 csv,按小时采的示例数据 │ └── processed/ # 特征工程后的宽表,可缓存加速 ├── notebooks/ │ ├── 01_forecast_regression.ipynb # 时间序列预测(回归)全流程 │ └── 02_classify_window.ipynb # 时间序列分类全流程 ├── src/ │ ├── config.py # 路径、列名、滞后阶数等统一配置 │ ├── data_loader.py # 读取 + 时间索引补齐 │ ├── features.py # 滞后、滑窗、日历特征、切分 │ ├── train_regressor.py # XGBRegressor 训练与早停 │ ├── train_classifier.py # XGBClassifier 训练与不平衡处理 │ └── evaluate.py # MAE/RMSE/MAPE、混淆矩阵、F1 ├── outputs/ │ ├── figures/ # 预测曲线、特征重要性、混淆矩阵 │ └── models/ # 落盘的模型文件 └── requirements.txt| 文件 | 职责 | 二次开发时最常改的地方 |
|---|---|---|
| config.py | 统一列名、滞后阶数、切分比例 | 换数据集时改 TIME_COL / TARGET_COL |
| data_loader.py | 读取、去重、时间索引补齐 | 换数据格式、改重采样频率 |
| features.py | 特征构造与时间切分 | 加特征、调窗口 |
| train_regressor.py | 回归训练与早停 | 调参、换目标变换 |
| train_classifier.py | 分类训练与权重 | 调类别权重、改标签定义 |
| evaluate.py | 指标与图表 | 改评估口径、加业务指标 |
这份表不是给你背的,是给你改的。我的习惯是拿到任何一份时序模板,先看 config.py 里有哪些常量被硬编码,这些常量就是后面最可能踩坑的地方——列名、频率、滞后阶数一旦写死,换个数据集必然报错。
2.2 环境依赖与运行方式
依赖不多,核心就是 pandas、numpy、scikit-learn、xgboost 加 matplotlib,notebook 环境用 jupyter。版本上建议锁死,XGBoost 1.x 和 2.x 在早停参数的位置上有变化,这在后面避坑那章会单独说。
# 建议单独建虚拟环境,避免和系统里的老版本打架 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # requirements.txt 大致内容: # pandas>=2.0 # numpy>=1.24 # scikit-learn>=1.3 # xgboost>=2.0 # matplotlib>=3.7 # jupyterlab>=4.0 jupyter lab启动后浏览器里会列出 notebooks 目录,点开 01_forecast_regression.ipynb,用 Kernel 菜单里的 Restart & Run All 一把跑完,正常情况下一两分钟内就能看到预测曲线和特征重要性图。这一步看着简单,但它其实是整个项目的验收标准:能一把跑通,说明数据路径、依赖版本、缓存文件都是对的;跑不通,八成是相对路径问题或者 data/raw 里少了 csv。
提示:notebook 里所有路径都基于项目根目录写的相对路径,必须在根目录启动 jupyter,在子目录里启动会找不到 data/raw。
2.3 数据加载与统一入口
时间序列最容易翻车的地方不是模型,是数据本身不规整。缺小时、重复时间戳、乱序,这三样只要有一样,后面的滞后特征就会整体错位一列,模型指标看着还行,实际预测全歪。所以 data_loader.py 第一件事不是读数据,是修时间轴。
# src/data_loader.py import pandas as pd from src.config import DATA_RAW, TIME_COL, TARGET_COL def load_series(filename: str = "sensor_hourly.csv") -> pd.DataFrame: df = pd.read_csv(DATA_RAW / filename) df[TIME_COL] = pd.to_datetime(df[TIME_COL]) # 先排序再去重,重复时间戳只保留最后一条 df = df.sort_values(TIME_COL).drop_duplicates(subset=[TIME_COL]) # 关键一步:补成严格等间隔的时间轴,缺的小时用 NaN 占位 full_index = pd.date_range(df[TIME_COL].min(), df[TIME_COL].max(), freq="h") df = (df.set_index(TIME_COL) .reindex(full_index) .rename_axis(TIME_COL) .reset_index()) # 短缺口线性插值,连续超过 3 小时的空洞保留 NaN,建模阶段再丢 df[TARGET_COL] = df[TARGET_COL].interpolate(limit=3, limit_direction="both") return df这段代码里三个参数值得说清楚。freq="h"是数据频率,换成 15 分钟数据就写"15min",写错会导致整条时间轴被重新采样成小时,数据量直接对不上。interpolate(limit=3)的 3 表示最多连续补 3 个点,超过就放弃,原因是长时间停机造成的缺口用插值补出来的值属于凭空捏造,喂给模型只会让它学到假规律。limit_direction="both"是为了让序列首尾的孤立空值也能补上,否则第一天和最后一天的样本会因为 NaN 被整行删掉。
读完之后 notebook 里会先做一次 describe 和缺失率打印,这一步建议保留,换数据集时能第一时间发现单位不对(比如负荷从 kW 变成 W,数值差 1000 倍,模型照样能训,但 MAPE 会变得毫无意义)。
3. 特征工程:把一条时间序列压成 XGBoost 能吃的宽表
XGBoost 本质是树模型,它不认识时间顺序,只认识一行一行的特征向量。所以这一步的全部工作就是回答一个问题:站在 t 时刻,我手上能合法拿到哪些信息,用来预测 t+1。
3.1 滞后特征与滑窗统计
滞后特征是时序任务里性价比最高的一类特征。当前小时负荷和前一小时、前一天同一小时、前一周同一小时强相关,把这三个点直接搬成列,树模型就能自己学出周期结构。
# src/features.py import pandas as pd def add_lag_features(df: pd.DataFrame, target_col: str, lags=(1, 2, 3, 24, 48, 168)) -> pd.DataFrame: out = df.copy() for lag in lags: out[f"{target_col}_lag_{lag}"] = out[target_col].shift(lag) return out def add_rolling_features(df: pd.DataFrame, target_col: str, windows=(6, 24, 168)) -> pd.DataFrame: out = df.copy() # 先 shift(1) 再 rolling:窗口里绝不能包含当前时刻的真实值 shifted = out[target_col].shift(1) for w in windows: out[f"{target_col}_roll_mean_{w}"] = shifted.rolling(w).mean() out[f"{target_col}_roll_std_{w}"] = shifted.rolling(w).std() out[f"{target_col}_roll_max_{w}"] = shifted.rolling(w).max() return out滞后阶数lags=(1, 2, 3, 24, 48, 168)对应的是小时数据的近邻、日周期、双日周期和周周期,这是按小时采样的默认配置。如果你的数据是 15 分钟粒度,24 这个数就不再是一天,得改成 96;这是换数据时第一个要改的地方。滑窗的三个窗口分别代表近 6 小时、近 1 天、近 7 天的统计量,均值刻画趋势,标准差刻画波动,最大值用来兜住尖峰。
shift(1)这一行是整段代码里最容易被删掉又最致命的一行。如果不 shift,rolling(6).mean()会把当前时刻的真实值算进窗口,等于把答案提前告诉了模型,训练集指标漂亮得离谱,上线就露馅。这类信息泄漏在前面的章节里不会有任何报错,属于典型的黑匣子问题。
3.2 时间索引特征与周期编码
滞后特征解决“过去什么样”,日历特征解决“现在是什么时候”。很多负荷曲线在工作日和周末是两个形状,在凌晨和傍晚是两个量级,这些规律树模型能学,但前提是你把 hour、weekday 这些信息显式喂进去。
import numpy as np import pandas as pd def add_calendar_features(df: pd.DataFrame, time_col: str) -> pd.DataFrame: out = df.copy() ts = pd.to_datetime(out[time_col]) out["hour"] = ts.dt.hour out["weekday"] = ts.dt.weekday # 0=周一 out["is_weekend"] = (ts.dt.weekday >= 5).astype(int) out["day_of_month"] = ts.dt.day # 周期编码:让 23 点和 0 点在数值上相邻,而不是相差 23 out["hour_sin"] = np.sin(2 * np.pi * out["hour"] / 24) out["hour_cos"] = np.cos(2 * np.pi * out["hour"] / 24) out["weekday_sin"] = np.sin(2 * np.pi * out["weekday"] / 7) out["weekday_cos"] = np.cos(2 * np.pi * out["weekday"] / 7) return outsine/cosine编码是这里唯一需要解释的设计。树模型对单个数值做的是阈值切分,hour 直接当整数用也能切出“大于 18”这样的规则,但它永远切不出“23 点和 0 点是一伙的”这种跨边界的规律。用正余弦把小时映射到单位圆上,23 点和 0 点在二维平面上就是挨着的,模型一次切分就能覆盖。缺点是解释性变差,所以 hour 原始列我一般也保留,两套并存,让模型自己挑。
注意:
is_weekend这类布尔标记在某些编码下会被读成 bool 类型,转成 int 更稳妥,否则部分版本的 XGBoost 在建 DMatrix 时会提示类型不一致。
3.3 防泄漏切分与目标构造
切分这一步决定了整个评估结果可不可信。随机切分在时序任务里是错的,因为随机打散之后,训练集里会混进“未来”的样本,模型实际上是在用未来的数据预测过去,验证集指标虚高,线上直接用会翻车。
def time_split(df: pd.DataFrame, time_col: str, valid_ratio: float = 0.15, test_ratio: float = 0.15): df = df.sort_values(time_col).reset_index(drop=True) n = len(df) n_test = int(n * test_ratio) n_valid = int(n * valid_ratio) train = df.iloc[: n - n_valid - n_test] valid = df.iloc[n - n_valid - n_test: n - n_test] test = df.iloc[n - n_test:] return train, valid, test这个函数就是按时间顺序切三段,前 70% 训练、中间 15% 做验证和早停、最后 15% 做测试。验证集的作用是给早停用,测试集只在最后跑一次,跑完就冻结,不要根据测试集结果反复调参,那样测试集就变成了第二个验证集,指标同样不可信。
切分之后还有一步:把带 NaN 的前 168 行丢掉,因为最早的样本没有足够的滞后值。这一步看起来是清数据,实际是在确认你的训练集起点没有混进填充值。我在项目里习惯在切分前打印一下每列的缺失率,如果某个滞后特征缺失率明显高于预期,说明数据中间有大的时间断档。
4. 建模链路:回归预测与分类识别两条线的核心代码
预测和分类在这套案例里共用同一套特征工程,分叉点在两处:一个是目标变量长什么样,一个是评估看什么指标。回归看连续的负荷值,分类看未来窗口的状态标签。
4.1 回归链路:XGBRegressor 与早停
回归任务的样本是这样构造的:第 t 行所有特征作为输入,第 t+1 行的目标值作为标签。这个 t+1 就是 config 里的 HORIZON,改成 6 就变成预测未来第 6 个小时,特征代码完全不用动,只是标签整体再 shift 一次。
import xgboost as xgb import pandas as pd from src.config import TARGET_COL, RANDOM_STATE def build_regression_xy(df: pd.DataFrame, feature_cols: list, horizon: int = 1): data = df.copy() data["y"] = data[TARGET_COL].shift(-horizon) # 标签前移,对齐到当前时刻 data = data.dropna(subset=feature_cols + ["y"]) return data[feature_cols], data["y"], data def train_regressor(X_train, y_train, X_valid, y_valid): model = xgb.XGBRegressor( n_estimators=3000, learning_rate=0.03, max_depth=6, min_child_weight=3, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, objective="reg:squarederror", tree_method="hist", early_stopping_rounds=100, random_state=RANDOM_STATE, ) model.fit( X_train, y_train, eval_set=[(X_train, y_train), (X_valid, y_valid)], verbose=False, ) return modelshift(-horizon)是负号,这是很多人第一次写会搞反的地方:我们要让当前行的标签等于未来第 horizon 行的值,所以是往上提,负号代表向前取。写成正号的话,模型就变成了用现在预测过去,训练损失能降得很低,预测出来全是滞后一拍的曲线。
参数上learning_rate=0.03配n_estimators=3000是典型的“小步慢跑”,靠早停决定实际用多少棵树。max_depth=6是时序表格任务的常用起点,太深容易记住噪声,太浅学不出周期交互。subsample和colsample_bytree都设 0.8,用行采样和列采样增加随机性,防止模型过度依赖某几个强滞后特征。tree_method="hist"是在大数据量下的加速选项,几万行数据用默认的 exact 也能跑,但换到几十万行差距会很明显。
early_stopping_rounds=100表示验证集损失连续 100 轮没改善就停。这里有个版本差异:XGBoost 1.x 里这个参数写在fit()里,2.x 之后移到构造函数里,两种写法混用会直接抛参数错误的异常。
4.2 分类链路:滑窗样本与不平衡处理
分类任务的输入不再是单行特征,而是一段窗口。因为判断“设备未来是否异常”,光看当前这一小时没意义,得看最近一天的走势。
import numpy as np def build_classification_samples(series: np.ndarray, labels: np.ndarray, window: int = 24, horizon: int = 6): X, y = [], [] for i in range(window, len(series) - horizon): X.append(series[i - window:i]) # 过去 window 个点的取值 y.append(labels[i + horizon]) # 未来第 horizon 个点的状态标签 return np.asarray(X), np.asarray(y)window=24表示用最近 24 小时作为一条样本,horizon=6表示预测 6 小时后的状态。窗口拉长能提供更多上下文,但样本数会变少,且窗口内的数据维度会膨胀成 24 列,需要配合降维或者直接用统计量替代原始序列。这套案例采用的是“窗口内取均值、标准差、最大最小、斜率”四个统计量的做法,把 24 维压回 6 维左右,再拼上前面算好的日历特征,喂给分类器。
标签分布通常是偏的,正常状态远多于异常状态,直接训练会让模型倾向于全判正常,准确率 95%,但一个异常都抓不到。处理方式是给样本加权。
from sklearn.utils.class_weight import compute_sample_weight import xgboost as xgb def train_classifier(X_train, y_train, X_valid, y_valid, num_class: int = 3): weights = compute_sample_weight(class_weight="balanced", y=y_train) clf = xgb.XGBClassifier( n_estimators=1000, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.8, objective="multi:softprob", num_class=num_class, eval_metric="mlogloss", tree_method="hist", early_stopping_rounds=50, random_state=42, ) clf.fit(X_train, y_train, sample_weight=weights, eval_set=[(X_valid, y_valid)], verbose=False) return clfcompute_sample_weight(class_weight="balanced")会按类别频率的倒数给每条样本加权,少数类样本权重被抬高,模型在损失函数里就不敢忽略它们。objective="multi:softprob"输出的是每个类别的概率,方便后面做阈值调整;如果只要硬标签,用multi:softmax更省事,但会丢掉概率信息,不推荐。
4.3 评估口径:回测指标与混淆矩阵
回归和分类的评估不能只看一个数。回归看 MAE、RMSE、MAPE 三个,分类看混淆矩阵、每类 F1,两个任务都要画时间轴上的预测对比图,因为数字指标看不出相位偏移。
import numpy as np from sklearn.metrics import mean_absolute_error, mean_squared_error, f1_score def regression_report(y_true, y_pred): mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mask = np.abs(y_true) > 1e-6 mape = np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100 return {"MAE": round(mae, 3), "RMSE": round(rmse, 3), "MAPE(%)": round(mape, 2)} def classification_report(y_true, y_pred, labels=None): return { "macro_f1": round(f1_score(y_true, y_pred, average="macro"), 4), "weighted_f1": round(f1_score(y_true, y_pred, average="weighted"), 4), "per_class_f1": f1_score(y_true, y_pred, average=None, labels=labels).round(3).tolist(), }MAPE那个 mask 是为了避开真值为 0 的点,负荷类数据凌晨可能有 0,直接除会得到 inf。分类这边坚持看 macro F1 而不是 accuracy,原因是类别不平衡时 accuracy 会说谎;macro F1 把每个类平等对待,少数类漏检会立刻把分数拉下来。
预测对比图建议画最近 200 个点的真实值与预测值叠加,再单独画一张残差分布。残差如果出现明显的周期性波动,说明模型没学到某个周期,通常是滞后阶数没覆盖到;残差如果集中在某一端,说明目标做过变换但忘了还原。
5. 避坑与排查:这五处最容易翻车
5.1 用随机切分替代时间切分
现象:训练集和验证集指标都很好,R² 到 0.95 以上,上线后预测曲线整体滞后一拍,误差翻几倍。
原因:train_test_split(shuffle=True)把未来样本混进了训练集,模型在训练时已经见过相邻时间点,等于开卷考试。
解决:改用按时间顺序切分的time_split,训练集永远在验证集之前,验证集永远在测试集之前。如果要做交叉验证,用TimeSeriesSplit,不要用 KFold。
5.2 滑窗特征忘记 shift
现象:训练损失异常低,特征重要性里排第一的永远是某个 rolling 特征,测试集直接崩。
原因:rolling(w).mean()默认窗口包含当前行,当前时刻的真实目标值被算进了特征,属于标签泄漏。
解决:所有滚动统计先shift(1)再 rolling,把所有滞后类特征统一在同一个函数里生成,避免有人手写漏掉。改完之后第一件事是看特征重要性排序有没有变得“合理”。
5.3 目标做差分后忘记还原
现象:模型指标看着不错,但画出来的预测曲线振幅特别小,像个平移过的平均值。
原因:为了平稳性对目标做了差分,训练和评估都在差分空间里做,最后忘了cumsum还原回原始量纲。
解决:把还原逻辑封装成一个函数,inverse_diff(pred, last_value),在评估和出图之前强制调用一次。我一般会在 notebook 里单独留一个 cell 打印还原前后的前 10 个值,肉眼比对。
5.4 早停参数放错位置导致版本不兼容
现象:换台机器装完依赖,代码一行没改,fit()直接抛unexpected keyword argument 'early_stopping_rounds'。
原因:XGBoost 2.0 之后早停参数从fit()移到了构造函数,新旧写法不通用,而 requirements 里只写了xgboost没锁版本。
解决:requirements 里锁主版本号,同时在代码里做一次兼容判断,或者干脆统一按新版本写法,把early_stopping_rounds写进XGBRegressor(...)的构造参数里。这类问题属于血泪经验,换个环境就复发。
5.5 notebook 执行顺序混乱导致结果不可复现
现象:同一份 notebook,别人跑出来的指标和你差一大截,你自己重启内核再跑,结果又变了。
原因:中间手动改过参数、删过 cell,但没重启内核,变量还留在内存里,后面的 cell 用的是旧对象。
解决:交付前一律 Kernel → Restart & Run All,跑通再提交。更稳的做法是把训练逻辑全部收进 src 下的 py 文件,notebook 只做调用和展示,这样变量状态污染的范围会小很多。
6. 二次开发:换数据、换模型、换任务的改造顺序
拿到这套模板之后,改造顺序我建议固定成三步:先换数据验证链路,再换模型做对照,最后换任务定义。第一步只改 config.py 里的列名和频率,跑通再说,这一步能暴露 80% 的路径和格式问题。
| 改造方向 | 具体动作 | 需要同步改的地方 |
|---|---|---|
| 换数据频率 | 小时改 15 分钟 | LAGS、ROLL_WINDOWS、freq 三处一起改 |
| 换预测步长 | 单步改多步 | HORIZON 调整,或改成多输出直接预测 |
| 换模型 | XGBoost 换 LightGBM | train_*.py 里的模型构造,特征代码不动 |
| 换任务 | 回归改分类 | 标签定义 + 评估函数 + 类别权重 |
| 加业务指标 | 比如超限时长 | evaluate.py 里新增统计函数 |
多步预测是最值得动手改的一块。最简单的做法是递归:先预测 t+1,把预测值填回序列,再预测 t+2。这种做法误差会累积,步数一多就发散。更稳的是直接多输出:把标签构造成[y_{t+1}, y_{t+2}, ..., y_{t+h}],用MultiOutputRegressor包一层,或者对每个步长单独训一个模型。步长少的时候我倾向后者,模型之间互不干扰,出问题好定位。
验证改造是否有效,别只看指标数字。我的做法是固定一个时间窗口,把改造前后的预测曲线画在同一张图上,再叠加真实值,肉眼比对相位和幅度。指标提升 5% 但曲线整体右移一格,这种“提升”在业务上是负收益。
# 快速对照两次实验的曲线,比看单个指标靠谱 import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(12, 4)) ax.plot(idx, y_true, label="actual", color="black", linewidth=1.2) ax.plot(idx, pred_baseline, label="baseline", alpha=0.8) ax.plot(idx, pred_new, label="new", alpha=0.8) ax.legend() ax.set_title("forecast comparison on hold-out window") plt.tight_layout() plt.savefig("outputs/figures/compare.png", dpi=150)那段代码没什么技术含量,但它救过我两次。一次是指标涨了但曲线在周末整体偏低,查下去发现是新加的特征里有个周末标记编码反了;另一次是两条曲线几乎重合,指标却差很多,最后发现是评估时索引没对齐,错位了两行。
从那以后我每次改完特征或者换完模型,都强制先画这张对照图,再去看指标表。指标是给汇报用的,曲线是给自己看的。希望帮到你。
本文还有配套的精品资源,点击获取