• 介绍
  • 用例
  • 数据
  • 数据探索
    • 绘制时间序列
    • 季节性图
    • 自相关图
  • 基准模型(Baseline)
  • 使用 LightGBM 的多步预测
  • 预测器
  • 回测(Backtesting)
    • 日历与气象特征
    • 具有周期性的特征
    • 特征交互
    • 分类特征
    • 使用外生变量评估模型
  • 超参数搜索
  • 特征选择
  • 概率预测:置信区间(Prediction intervals)
  • 模型可解释性(Model explainability)
    • 模型特定的特征重要性
    • SHAP 值
  • XGBoost、CatBoost、HistGradientBoostingRegressor
  • XGBoost
  • HistGradientBoostingRegressor
  • CatBoost
  • 结论
  • 会话信息
  • 引用


更多关于时间序列预测,参见 cienciadedatos.net


介绍

梯度提升模型因其在回归与分类等众多任务中的卓越表现而广受机器学习社区的欢迎。尽管这些模型在传统上较少用于时间序列预测,但在该领域同样非常有效。将梯度提升用于预测的主要优势包括:

  • 在自回归变量之外,能够轻松纳入外生变量。

  • 能够刻画变量之间的非线性关系。

  • 具有高可扩展性,可处理大规模数据。

  • 某些实现允许直接使用分类变量,无需额外的编码(如 one-hot 编码)。

尽管如此,基于机器学习的预测仍存在一些挑战,使得分析人员对其采用持谨慎态度,主要包括:

  • 将数据转换为可用于回归问题的形式。

  • 取决于所需的预测步长(预测视窗),可能需要使用迭代过程,每个新的预测依赖于前一步的预测。

  • 模型验证需要专门的策略,如回测(backtesting)、逐步前向验证(walk-forward validation)或时间序列交叉验证,无法使用传统的随机交叉验证。

skforecast 为上述挑战提供了自动化的解决方案,简化了将机器学习模型应用于时间序列预测并进行验证的流程。该库支持多种先进的梯度提升模型,包括 XGBoostLightGBMCatBoostscikit-learn 的 HistGradientBoostingRegressor。本文将展示如何使用它们来构建精确的预测模型。为确保顺畅的学习体验,首先进行数据探索;随后按步骤讲解建模过程:先从采用 LightGBM 回归器的递归式模型开始,再扩展到包含外生变量与多种编码策略的模型;最后展示其他梯度提升实现的使用方法,包括 XGBoost、CatBoost 与 scikit-learn 的 HistGradientBoostingRegressor。

梯度提升模型因其在广泛应用场景(回归与分类)中取得优异效果而在机器学习社区广受欢迎。尽管这些模型在预测任务中传统上并不常见,但在该领域同样非常有效。将梯度提升用于时间序列预测的主要优势包括:

  • 在自回归变量之外,能够轻松纳入外生变量。

  • 能够刻画变量之间的非线性关系。

  • 具有高可扩展性,可处理大规模数据。

  • 部分实现允许直接处理分类变量,无需额外编码(例如 one-hot 编码)。

尽管具有上述优势,将机器学习模型用于预测也面临一些挑战,常使分析人员对其采用持谨慎态度,主要包括:

  • 将数据变换为可用于回归建模的形式。

  • 根据所需预测步数(预测视窗),可能需要使用迭代过程,每个新预测依赖于前一步的预测。

  • 模型验证需要专门策略,如回测(backtesting)、逐步前向验证(walk-forward validation)或时间序列交叉验证,无法使用传统的随机交叉验证。

skforecast 为上述挑战提供了自动化解决方案,使将机器学习模型应用于预测问题并进行验证变得更为便捷。该库支持多种先进的梯度提升模型,包括 XGBoostLightGBMCatBoostscikit-learn 的 HistGradientBoostingRegressor。本文将展示如何使用它们构建精确的预测模型。为确保顺畅的阅读体验,我们将先进行数据探索,然后逐步讲解建模流程:从使用 LightGBM 回归器的递归式模型开始,随后加入外生变量与不同编码策略;最后演示 XGBoost、CatBoost 与 scikit-learn 的 HistGradientBoostingRegressor 的使用方法。

✎ 注意

机器学习模型并不总能优于统计学习模型,如 ARARIMA指数平滑。哪种方法更好,主要取决于具体用例的特征。可参见 ARIMA 与 SARIMAX 以了解统计模型。

✎ 注意

更多关于将梯度提升用于预测的示例,参见 使用机器学习预测能源需求全局预测模型

用例

共享单车是一种流行的短期出行服务,允许用户在多个站点之间借还车辆。站点配备带锁定功能的专用停车桩,仅通过计算机控制释放。该系统运营的核心挑战之一是需要在网络内调度与再平衡单车,确保每个站点既有可借车辆也有可还车位。

为提升调度计划与执行质量,本文拟构建一个能够预测未来 36 小时用户数量的模型。这样,每天 12:00,运营方即可提前获知当日剩余 12 小时及次日 24 小时的预期用车量。

为便于展示,当前示例仅针对单个站点建模,但该方法可轻松扩展到多站点场景,参见 global multi-series forecasting,可在更大规模上提升共享单车系统的管理效率。

本文所用到的主要依赖如下。

# 数据处理
# ==============================================================================
import numpy as np
import pandas as pd
from astral.sun import sun
from astral import LocationInfo
from skforecast.datasets import fetch_dataset
from feature_engine.datetime import DatetimeFeatures
from feature_engine.creation import CyclicalFeatures
from feature_engine.timeseries.forecasting import WindowFeatures

# 绘图
# ==============================================================================
import matplotlib.pyplot as plt
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.graphics.tsaplots import plot_pacf
from skforecast.plot import plot_residuals, calculate_lag_autocorrelation
import plotly.graph_objects as go
import plotly.io as pio
import plotly.offline as poff
pio.templates.default = "seaborn"
poff.init_notebook_mode(connected=True)
plt.style.use('seaborn-v0_8-darkgrid')

# 建模与预测
# ==============================================================================
import xgboost
import lightgbm
import catboost
import sklearn
import shap
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
from catboost import CatBoostRegressor
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.preprocessing import (
    OneHotEncoder,
    OrdinalEncoder,
    FunctionTransformer,
    PolynomialFeatures,
)
from sklearn.feature_selection import RFECV
from sklearn.pipeline import make_pipeline
from sklearn.compose import make_column_transformer, make_column_selector

import skforecast
from skforecast.recursive import ForecasterEquivalentDate, ForecasterRecursive
from skforecast.model_selection import (
    TimeSeriesFold,
    OneStepAheadFold,
    bayesian_search_forecaster,
    backtesting_forecaster,
)
from skforecast.preprocessing import RollingFeatures
from skforecast.feature_selection import select_features
from skforecast.metrics import calculate_coverage

# 警告配置
# ==============================================================================
import warnings
warnings.filterwarnings('once')

color = '\033[1m\033[38;5;208m' 
print(f"{color}版本 skforecast: {skforecast.__version__}")
print(f"{color}版本 scikit-learn: {sklearn.__version__}")
print(f"{color}版本 lightgbm: {lightgbm.__version__}")
print(f"{color}版本 xgboost: {xgboost.__version__}")
print(f"{color}版本 catboost: {catboost.__version__}")
print(f"{color}版本 pandas: {pd.__version__}")
print(f"{color}版本 numpy: {np.__version__}")
版本 skforecast: 0.19.0
版本 scikit-learn: 1.7.2
版本 lightgbm: 4.6.0
版本 xgboost: 3.1.1
版本 catboost: 1.2.8
版本 pandas: 2.3.3
版本 numpy: 2.3.4

数据

本文所用数据为华盛顿特区共享单车系统在 2011–2012 年间的逐小时使用量。除每小时用户数外,还提供了气象条件与节假日信息。原始数据来自 UCI 机器学习仓库,并已进行预清洗(代码),主要变更如下:

  • 使用更具描述性的列名。

  • 统一气象变量的分类标签,将 heavy_rainrain 合并。

  • 对温度、湿度与风速进行反归一化处理。

  • 创建 date_time 变量并将其设为索引。

  • 采用向前填充法填补缺失值。

# 数据下载
# ==============================================================================
data = fetch_dataset('bike_sharing', raw=True)
╭───────────────────────────────── bike_sharing ──────────────────────────────────╮
│ Description:                                                                    │
│ Hourly usage of the bike share system in the city of Washington D.C. during the │
│ years 2011 and 2012. In addition to the number of users per hour, information   │
│ about weather conditions and holidays is available.                             │
│                                                                                 │
│ Source:                                                                         │
│ Fanaee-T,Hadi. (2013). Bike Sharing Dataset. UCI Machine Learning Repository.   │
│ https://doi.org/10.24432/C5W894.                                                │
│                                                                                 │
│ URL:                                                                            │
│ https://raw.githubusercontent.com/skforecast/skforecast-                        │
│ datasets/main/data/bike_sharing_dataset_clean.csv                               │
│                                                                                 │
│ Shape: 17544 rows x 12 columns                                                  │
╰─────────────────────────────────────────────────────────────────────────────────╯
# 数据预处理(设置索引与频率)
# ==============================================================================
data = data[['date_time', 'users', 'holiday', 'weather', 'temp', 'atemp', 'hum', 'windspeed']]
data['date_time'] = pd.to_datetime(data['date_time'], format='%Y-%m-%d %H:%M:%S')
data = data.set_index('date_time')
if pd.__version__ < '2.2':
    data = data.asfreq('H')
else:
    data = data.asfreq('h')
data = data.sort_index()
data.head()
users holiday weather temp atemp hum windspeed
date_time
2011-01-01 00:00:00 16.0 0.0 clear 9.84 14.395 81.0 0.0
2011-01-01 01:00:00 40.0 0.0 clear 9.02 13.635 80.0 0.0
2011-01-01 02:00:00 32.0 0.0 clear 9.02 13.635 80.0 0.0
2011-01-01 03:00:00 13.0 0.0 clear 9.84 14.395 75.0 0.0
2011-01-01 04:00:00 1.0 0.0 clear 9.84 14.395 75.0 0.0

为便于模型训练、超参数搜索与预测精度评估,将数据划分为三个集合:训练集、验证集与测试集。

# 训练-验证-测试 划分
# ==============================================================================
end_train = '2012-04-30 23:59:00'
end_validation = '2012-08-31 23:59:00'
data_train = data.loc[: end_train, :]
data_val   = data.loc[end_train:end_validation, :]
data_test  = data.loc[end_validation:, :]

print(f"训练集日期 : {data_train.index.min()} --- {data_train.index.max()}  (n={len(data_train)})")
print(f"验证集日期 : {data_val.index.min()} --- {data_val.index.max()}  (n={len(data_val)})")
print(f"测试集日期 : {data_test.index.min()} --- {data_test.index.max()}  (n={len(data_test)})")
训练集日期 : 2011-01-01 00:00:00 --- 2012-04-30 23:00:00  (n=11664)
验证集日期 : 2012-05-01 00:00:00 --- 2012-08-31 23:00:00  (n=2952)
测试集日期 : 2012-09-01 00:00:00 --- 2012-12-31 23:00:00  (n=2928)

数据探索

对时间序列进行可视化探索有助于识别趋势、模式与季节性,从而指导选择更合适的预测模型。

绘制时间序列

完整时间序列

# 时间序列的交互式图
# ==============================================================================
fig = go.Figure()
fig.add_trace(go.Scatter(x=data_train.index, y=data_train['users'], mode='lines', name='训练集'))
fig.add_trace(go.Scatter(x=data_val.index, y=data_val['users'], mode='lines', name='验证集'))
fig.add_trace(go.Scatter(x=data_test.index, y=data_test['users'], mode='lines', name='测试集'))
fig.update_layout(
    title  = '用户数量',
    xaxis_title="时间",
    yaxis_title="用户数",
    legend_title="分区:",
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    legend=dict(orientation="h", yanchor="top", y=1, xanchor="left", x=0.001)
)
#fig.update_xaxes(rangeslider_visible=True)
fig.show()
Jan 2011Apr 2011Jul 2011Oct 2011Jan 2012Apr 2012Jul 2012Oct 2012Jan 201302004006008001000
分区:训练集验证集测试集用户数量时间用户数
# 带缩放窗口的静态时序图
# ==============================================================================
zoom = ('2011-08-01 00:00:00', '2011-08-15 00:00:00')
fig = plt.figure(figsize=(8, 4))
grid = plt.GridSpec(nrows=8, ncols=1, hspace=0.1, wspace=0)
main_ax = fig.add_subplot(grid[1:3, :])

data_train['users'].plot(ax=main_ax, label='训练集', alpha=0.5)
data_val['users'].plot(ax=main_ax, label='验证集', alpha=0.5)
data_test['users'].plot(ax=main_ax, label='测试集', alpha=0.5)
min_y = min(data['users'])
max_y = max(data['users'])
main_ax.fill_between(zoom, min_y, max_y, facecolor='blue', alpha=0.5, zorder=0)
main_ax.set_title(f'用户数量: {data.index.min()}, {data.index.max()}', fontsize=10)
main_ax.set_xlabel('')
main_ax.legend(loc='lower center', ncol=3, bbox_to_anchor=(0.5, -0.8))
zoom_ax = fig.add_subplot(grid[5:, :])
data.loc[zoom[0]: zoom[1]]['users'].plot(ax=zoom_ax, color='blue', linewidth=1)
zoom_ax.set_title(f'用户数量: {zoom}', fontsize=10)
zoom_ax.set_xlabel('')
plt.subplots_adjust(hspace=1)
plt.show();

季节性图

季节性图有助于识别时间序列中的季节模式与趋势。其构建方式是:对每个季节在不同时间的取值进行汇总(如取均值/中位数等),并按时间绘制对比。

# 年度、每周与日内季节性
# ==============================================================================
fig, axs = plt.subplots(2, 2, figsize=(8, 5), sharex=False, sharey=True)
axs = axs.ravel()

# 每月用户数分布
data['month'] = data.index.month
data.boxplot(column='users', by='month', ax=axs[0], flierprops={'markersize': 3, 'alpha': 0.3})
data.groupby('month')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[0])
axs[0].set_ylabel('用户数')
axs[0].set_title('按月的用户数分布', fontsize=9)

# 每周中不同工作日的用户数分布
data['week_day'] = data.index.day_of_week + 1
data.boxplot(column='users', by='week_day', ax=axs[1], flierprops={'markersize': 3, 'alpha': 0.3})
data.groupby('week_day')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[1])
axs[1].set_ylabel('用户数')
axs[1].set_title('按星期的用户数分布', fontsize=9)

# 一天中不同小时的用户数分布
data['hour_day'] = data.index.hour + 1
data.boxplot(column='users', by='hour_day', ax=axs[2], flierprops={'markersize': 3, 'alpha': 0.3})
data.groupby('hour_day')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[2])
axs[2].set_ylabel('用户数')
axs[2].set_title('按小时的用户数分布', fontsize=9)

# 工作日-小时二维均值(周内平均用户数的小时曲线)
mean_day_hour = data.groupby(["week_day", "hour_day"])["users"].mean()
mean_day_hour.plot(ax=axs[3])
axs[3].set(
    title       = "一周内的平均用户数",
    xticks      = [i * 24 for i in range(7)],
    xticklabels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
    xlabel      = "日期与小时",
    ylabel      = "用户数"
)
axs[3].title.set_size(9)

fig.suptitle("季节性图", fontsize=12)
fig.tight_layout()

可以清晰看到工作日与周末之间存在显著差异。同时也存在明显的日内模式,不同时间段的用户数量不同。

自相关图

自相关图是识别自回归模型阶数的有用工具。自相关函数(ACF)衡量时间序列与其不同滞后项之间的相关性;偏自相关函数(PACF)在控制较短滞后项影响后,衡量序列与其滞后项之间的相关性。这些图可用于确定应纳入自回归模型的滞后项。

# 自相关图(ACF)
# ==============================================================================
fig, ax = plt.subplots(figsize=(5, 2))
plot_acf(data['users'], ax=ax, lags=72)
plt.show()
# 偏自相关图(PACF)
# ==============================================================================
fig, ax = plt.subplots(figsize=(5, 2))
plot_pacf(data['users'], ax=ax, lags=72, method='ywm')
plt.show()
# 绝对偏自相关值最高的前 10 个滞后
# ==============================================================================
calculate_lag_autocorrelation(
    data    = data['users'],
    n_lags  = 60,
    sort_by = "partial_autocorrelation_abs"
).head(10)
lag partial_autocorrelation_abs partial_autocorrelation autocorrelation_abs autocorrelation
0 1 0.845220 0.845220 0.845172 0.845172
1 2 0.408349 -0.408349 0.597704 0.597704
2 23 0.355669 0.355669 0.708470 0.708470
3 22 0.343601 0.343601 0.520804 0.520804
4 25 0.332366 -0.332366 0.711256 0.711256
5 10 0.272649 -0.272649 0.046483 -0.046483
6 17 0.241984 0.241984 0.057267 -0.057267
7 19 0.199286 0.199286 0.159897 0.159897
8 21 0.193404 0.193404 0.373666 0.373666
9 3 0.182068 0.182068 0.409680 0.409680

自相关分析结果显示:未来的用户数量与前几个小时以及前几天的用户数量显著相关。也就是说,若已知过去某些时段的用户数,将对预测未来的用户数具有参考价值。

基准模型(Baseline)

在处理预测问题时,建立一个基准模型非常重要。它通常是一个非常简单的模型,可作为参考以评估更复杂模型是否值得实现。

Skforecast 允许使用 ForecasterEquivalentDate 轻松创建基准模型。该模型也称为季节性朴素预测(Seasonal Naive Forecasting),其思想是直接返回上一季节相同时段观察到的值(例如,上一周同一工作日、前一天的同一小时等)。

基于前述探索性分析,本文的基准模型为:使用“前一天同一小时”的值来预测每个小时。

面对预测问题时,建立一个基准模型非常重要。它通常是一个非常简单的模型,用来评估更复杂模型是否值得实现。

Skforecast 提供了类 ForecasterEquivalentDate 来轻松创建基准模型。该模型也称为“季节性朴素预测”,其做法是在预测某一时刻时直接返回上一季节相同时段的观测值(例如上一周的同一工作日、前一天的同一小时等)。

基于前面的探索性分析,本文选用的基准模型为:使用“前一天的同一小时”的值来预测每个小时。

✎ 注意

在下面的代码单元中,将训练一个基准预测器,并通过回测流程评估其预测能力。如果你对该概念还不熟悉,不必担心,本文后续章节会详细解释。目前只需知道:回测过程包括使用一定数量的数据训练模型,并在未见过的数据上评估模型的预测能力。该误差指标将用作后续更复杂模型的参考。
# 基准模型:使用“前一天同一小时”的值
# ==============================================================================
forecaster = ForecasterEquivalentDate(
                 offset    = pd.DateOffset(days=1),
                 n_offsets = 1
             )

# 训练预测器
# ==============================================================================
forecaster.fit(y=data.loc[:end_validation, 'users'])
forecaster

ForecasterEquivalentDate

General Information
  • Estimator: NoneType
  • Offset:
  • Number of offsets: 1
  • Aggregation function: mean
  • Window size: 24
  • Creation date: 2025-11-28 00:01:52
  • Last fit date: 2025-11-28 00:01:52
  • Skforecast version: 0.19.0
  • Python version: 3.13.9
  • Forecaster id: None
Training Information
  • Training range: [Timestamp('2011-01-01 00:00:00'), Timestamp('2012-08-31 23:00:00')]
  • Training index type: DatetimeIndex
  • Training index frequency:

🛈 API Reference    🗎 User Guide

# 回测
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric_baseline, predictions = backtesting_forecaster(
                                   forecaster = forecaster,
                                   y          = data['users'],
                                   cv         = cv,
                                   metric     = 'mean_absolute_error'
                               )
metric_baseline
mean_absolute_error
0 91.668716

将基准模型的 MAE 作为参考,用于判断更复杂模型是否值得实施。

使用 LightGBM 的多步预测

LightGBM 是一种高效的随机梯度提升实现,已成为机器学习领域的标杆库之一。LightGBM 既提供自有 API,也兼容 scikit-learn API,因此可无缝与 skforecast 配合使用。

首先,使用过去取值(滞后项)与移动平均作为特征,训练一个 ForecasterRecursive 模型。随后,向模型中加入外生变量并评估性能改进。鉴于梯度提升模型的超参数众多,本文使用 bayesian_search_forecaster() 执行贝叶斯搜索,以同时优化超参数与滞后项。最后,通过回测流程评估模型的预测能力。

预测器

# 创建预测器
# ==============================================================================
window_features = RollingFeatures(stats=["mean"], window_sizes=24 * 3)
forecaster = ForecasterRecursive(
                estimator       = LGBMRegressor(random_state=15926, verbose=-1),
                lags            = 24,
                window_features = window_features
             )

# 训练预测器
# ==============================================================================
forecaster.fit(y=data.loc[:end_validation, 'users'])
forecaster

ForecasterRecursive

General Information
  • Estimator: LGBMRegressor
  • Lags: [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24]
  • Window features: ['roll_mean_72']
  • Window size: 72
  • Series name: users
  • Exogenous included: False
  • Weight function included: False
  • Differentiation order: None
  • Creation date: 2025-11-28 00:01:53
  • Last fit date: 2025-11-28 00:01:57
  • Skforecast version: 0.19.0
  • Python version: 3.13.9
  • Forecaster id: None
Exogenous Variables
    None
Data Transformations
  • Transformer for y: None
  • Transformer for exog: None
Training Information
  • Training range: [Timestamp('2011-01-01 00:00:00'), Timestamp('2012-08-31 23:00:00')]
  • Training index type: DatetimeIndex
  • Training index frequency:
Estimator Parameters
    {'boosting_type': 'gbdt', 'class_weight': None, 'colsample_bytree': 1.0, 'importance_type': 'split', 'learning_rate': 0.1, 'max_depth': -1, 'min_child_samples': 20, 'min_child_weight': 0.001, 'min_split_gain': 0.0, 'n_estimators': 100, 'n_jobs': None, 'num_leaves': 31, 'objective': None, 'random_state': 15926, 'reg_alpha': 0.0, 'reg_lambda': 0.0, 'subsample': 1.0, 'subsample_for_bin': 200000, 'subsample_freq': 0, 'verbose': -1}
Fit Kwargs
    {}

🛈 API Reference    🗎 User Guide

# 预测
# ==============================================================================
forecaster.predict(steps=10)
2012-09-01 00:00:00    108.331027
2012-09-01 01:00:00     68.562982
2012-09-01 02:00:00     33.499525
2012-09-01 03:00:00     10.027583
2012-09-01 04:00:00      3.037563
2012-09-01 05:00:00     17.162543
2012-09-01 06:00:00     51.059825
2012-09-01 07:00:00    146.940053
2012-09-01 08:00:00    344.320596
2012-09-01 09:00:00    439.738683
Freq: h, Name: pred, dtype: float64

回测(Backtesting)

为了稳健地估计模型的预测能力,需要执行回测。回测会在测试集的每个观测点上按生产环境相同的流程生成预测,并与真实值对比。

强烈建议查阅 backtesting_forecaster() 文档以深入理解其能力,从而充分利用其潜力来分析模型的预测性能。

# 在测试集上进行回测
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric, predictions = backtesting_forecaster(
                          forecaster = forecaster,
                          y          = data['users'],
                          cv         = cv,
                          metric     = 'mean_absolute_error'
                      )
predictions.head()
fold pred
2012-09-01 00:00:00 0 108.331027
2012-09-01 01:00:00 0 68.562982
2012-09-01 02:00:00 0 33.499525
2012-09-01 03:00:00 0 10.027583
2012-09-01 04:00:00 0 3.037563
# 回测误差
# ==============================================================================
metric
mean_absolute_error
0 76.464247

该自回归模型的 MAE 低于基准模型。

到目前为止,仅使用了时间序列自身的滞后项作为特征。然而,也可以将其他变量作为特征一起使用。这些变量称为外生变量(features),合理使用它们可以提升模型的预测能力。需要特别注意的是:在进行预测时,外生变量的取值必须是已知的。常见外生变量包括日历衍生变量(如星期、月份、年份、节假日)、气象变量(如温度、湿度、风速)以及经济变量(如通胀、利率)等。

警告

进行预测时,外生变量必须是已知的。例如,若使用温度作为外生变量,则在预测时必须已知下一小时的温度值,否则无法进行预测。

使用气象变量时需谨慎。在生产环境中,未来的气象条件并非真实可知,而是由气象服务提供的预测。由于这些也是预测,它们会将误差引入你的预测模型,可能导致模型表现变差。提前预估该问题的一种方式(注意是“预估”而非“避免”),是:在训练与验证模型时,使用当时可获得的“天气预报”而非“记录到的真实天气”。

接下来将基于日历、日出日落时间、温度与节假日信息创建外生变量。这些变量会被添加到训练集、验证集与测试集中,并在自回归模型中作为特征使用。

日历与气象特征

# 日历特征
# ==============================================================================
features_to_extract = [
    'month',
    'week',
    'day_of_week',
    'hour'
]
calendar_transformer = DatetimeFeatures(
    variables           ='index',
    features_to_extract = features_to_extract,
    drop_original       = True,
)
calendar_features = calendar_transformer.fit_transform(data)[features_to_extract]

# 日照特征(基于日出日落)
# ==============================================================================
location = LocationInfo(
    name      = 'Washington DC',
    region    = 'USA',
    timezone  = 'US/Eastern',
    latitude  = 40.516666666666666,
    longitude = -77.03333333333333
)
sunrise_hour = [
    sun(location.observer, date=date, tzinfo=location.timezone)['sunrise'].hour
    for date in data.index
]
sunset_hour = [
    sun(location.observer, date=date, tzinfo=location.timezone)['sunset'].hour
    for date in data.index
]
sun_light_features = pd.DataFrame({
                        'sunrise_hour': sunrise_hour,
                        'sunset_hour': sunset_hour}, 
                        index = data.index
                     )
sun_light_features['daylight_hours'] = (
    sun_light_features['sunset_hour'] - sun_light_features['sunrise_hour']
)
sun_light_features["is_daylight"] = np.where(
    (data.index.hour >= sun_light_features["sunrise_hour"])
    & (data.index.hour < sun_light_features["sunset_hour"]),
    1,
    0,
)

# 节假日特征
# ==============================================================================
holiday_features = data[['holiday']].astype(int)
holiday_features['holiday_previous_day'] = holiday_features['holiday'].shift(24)
holiday_features['holiday_next_day'] = holiday_features['holiday'].shift(-24)

# 温度的滚动窗口统计
# ==============================================================================
wf_transformer = WindowFeatures(
    variables   = ["temp"],
    window      = ["1D", "7D"],
    functions   = ["mean", "max", "min"],
    freq        = "h",
)
temp_features = wf_transformer.fit_transform(data[['temp']])


# 合并全部外生变量
# ==============================================================================
assert all(calendar_features.index == sun_light_features.index)
assert all(calendar_features.index == temp_features.index)
assert all(calendar_features.index == holiday_features.index)
df_exogenous_features = pd.concat([
    calendar_features,
    sun_light_features,
    temp_features,
    holiday_features
], axis=1)

# 由于移动平均等计算,序列起始会产生缺失值;同时由于 holiday_next_day,序列末尾也会有缺失。
df_exogenous_features = df_exogenous_features.iloc[7 * 24:, :]
df_exogenous_features = df_exogenous_features.iloc[:-24, :]
df_exogenous_features.head(3)
month week day_of_week hour sunrise_hour sunset_hour daylight_hours is_daylight temp temp_window_1D_mean temp_window_1D_max temp_window_1D_min temp_window_7D_mean temp_window_7D_max temp_window_7D_min holiday holiday_previous_day holiday_next_day
date_time
2011-01-08 00:00:00 1 1 5 0 7 16 9 0 7.38 8.063333 9.02 6.56 10.127976 18.86 4.92 0 0.0 0.0
2011-01-08 01:00:00 1 1 5 1 7 16 9 0 7.38 8.029167 9.02 6.56 10.113333 18.86 4.92 0 0.0 0.0
2011-01-08 02:00:00 1 1 5 2 7 16 9 0 7.38 7.995000 9.02 6.56 10.103571 18.86 4.92 0 0.0 0.0

具有周期性的特征

某些日历维度(如小时、星期等)具有周期性。例如,一天中的小时从 0 到 23 循环。可用多种方式处理此类特征,各有优缺点:

  • 直接将其作为数值特征,不做变换。这样避免新增大量特征,但会施加不正确的线性顺序。例如,一天中的第 23 小时与次日第 0 小时在线性表示上相距甚远,但实际上仅相差 1 小时。

  • 将周期性特征视作分类变量以避免线性顺序,但这可能会丢失变量固有的周期信息。

  • 通常更优的一种方法是用正弦/余弦进行周期编码,仅生成两列新特征,既能更准确地表达周期,也能保持特征的自然顺序,避免线性排序带来的问题。

✎ 注意

关于周期特征处理方式的更详细说明,见 时间序列中的周期特征
# 日历与日照特征的周期编码
# ==============================================================================
features_to_encode = [
    "month",
    "week",
    "day_of_week",
    "hour",
    "sunrise_hour",
    "sunset_hour",
]
max_values = {
    "month": 12,
    "week": 52,
    "day_of_week": 7,
    "hour": 24,
    "sunrise_hour": 24,
    "sunset_hour": 24,
}
cyclical_encoder = CyclicalFeatures(
    variables     = features_to_encode,
    max_values    = max_values,
    drop_original = False
)

df_exogenous_features = cyclical_encoder.fit_transform(df_exogenous_features)
df_exogenous_features.head(3)
month week day_of_week hour sunrise_hour sunset_hour daylight_hours is_daylight temp temp_window_1D_mean ... week_sin week_cos day_of_week_sin day_of_week_cos hour_sin hour_cos sunrise_hour_sin sunrise_hour_cos sunset_hour_sin sunset_hour_cos
date_time
2011-01-08 00:00:00 1 1 5 0 7 16 9 0 7.38 8.063333 ... 0.120537 0.992709 -0.974928 -0.222521 0.000000 1.000000 0.965926 -0.258819 -0.866025 -0.5
2011-01-08 01:00:00 1 1 5 1 7 16 9 0 7.38 8.029167 ... 0.120537 0.992709 -0.974928 -0.222521 0.258819 0.965926 0.965926 -0.258819 -0.866025 -0.5
2011-01-08 02:00:00 1 1 5 2 7 16 9 0 7.38 7.995000 ... 0.120537 0.992709 -0.974928 -0.222521 0.500000 0.866025 0.965926 -0.258819 -0.866025 -0.5

3 rows × 30 columns

特征交互

在许多情况下,外生变量并非孤立起作用,其对目标变量的影响取决于其他变量的取值。例如,温度对用电需求的影响取决于一天中的时间。可以通过构造交互项(现有变量相乘得到)来捕捉变量间的相互作用。使用 scikit-learn 的 PolynomialFeatures 可以方便地生成此类交互特征。

# 外生变量之间的交互项
# ==============================================================================
transformer_poly = PolynomialFeatures(
                        degree           = 2,
                        interaction_only = True,
                        include_bias     = False
                    ).set_output(transform="pandas")
poly_cols = [
    'month_sin', 
    'month_cos',
    'week_sin',
    'week_cos',
    'day_of_week_sin',
    'day_of_week_cos',
    'hour_sin',
    'hour_cos',
    'sunrise_hour_sin',
    'sunrise_hour_cos',
    'sunset_hour_sin',
    'sunset_hour_cos',
    'daylight_hours',
    'is_daylight',
    'holiday_previous_day',
    'holiday_next_day',
    'temp_window_1D_mean',
    'temp_window_1D_min',
    'temp_window_1D_max',
    'temp_window_7D_mean',
    'temp_window_7D_min',
    'temp_window_7D_max',
    'temp',
    'holiday'
]
poly_features = transformer_poly.fit_transform(df_exogenous_features[poly_cols])
poly_features = poly_features.drop(columns=poly_cols)
poly_features.columns = [f"poly_{col}" for col in poly_features.columns]
poly_features.columns = poly_features.columns.str.replace(" ", "__")
assert all(poly_features.index == df_exogenous_features.index)
df_exogenous_features = pd.concat([df_exogenous_features, poly_features], axis=1)
df_exogenous_features.head(3)
month week day_of_week hour sunrise_hour sunset_hour daylight_hours is_daylight temp temp_window_1D_mean ... poly_temp_window_7D_mean__temp_window_7D_min poly_temp_window_7D_mean__temp_window_7D_max poly_temp_window_7D_mean__temp poly_temp_window_7D_mean__holiday poly_temp_window_7D_min__temp_window_7D_max poly_temp_window_7D_min__temp poly_temp_window_7D_min__holiday poly_temp_window_7D_max__temp poly_temp_window_7D_max__holiday poly_temp__holiday
date_time
2011-01-08 00:00:00 1 1 5 0 7 16 9 0 7.38 8.063333 ... 49.829643 191.013631 74.744464 0.0 92.7912 36.3096 0.0 139.1868 0.0 0.0
2011-01-08 01:00:00 1 1 5 1 7 16 9 0 7.38 8.029167 ... 49.757600 190.737467 74.636400 0.0 92.7912 36.3096 0.0 139.1868 0.0 0.0
2011-01-08 02:00:00 1 1 5 2 7 16 9 0 7.38 7.995000 ... 49.709571 190.553357 74.564357 0.0 92.7912 36.3096 0.0 139.1868 0.0 0.0

3 rows × 306 columns

分类特征

在 LightGBM(以及其他梯度提升框架)中引入分类变量有多种方式:

  • 先将分类值转换为数值(如 one-hot 或序数编码),这对所有机器学习模型都适用。

  • 也可使用 LightGBM 的原生分类支持,无需预处理:将变量存为 Pandas 的 category 类型,并设置 categorical_features='auto',或通过传入列名列表显式指定哪些特征为分类变量。

没有一种方法对所有情况都更好。一般规则是:

  • 当分类变量基数很高(取值很多)时,优先考虑原生分类支持而非 one-hot。

  • 使用 one-hot 后,往往需要更多的分裂点(更深的树)才能达到原生处理一次分裂即可获得的等效划分。

  • 当将一个分类变量 one-hot 成多列虚拟变量时,其重要性会被“稀释”,使得特征重要性分析更难解释。

# 将分类变量存为 category 类型
# ==============================================================================
data["weather"] = data["weather"].astype("category")

独热编码(One-Hot Encoding)

scikit-learn 的 ColumnTransformer 提供了强大的方式,在一个对象中定义并对特定特征应用变换。可将该对象通过 transformer_exog 传入预测器。

✎ 注意

也可以在预测器之外对整个数据集进行变换。但务必确保仅用训练数据“学习”变换参数,以避免信息泄漏;预测时同样需对输入数据应用相同变换。因此,建议将变换包含在预测器内部,让其自动处理。这有助于保持一致性并降低出错概率。
# 独热编码转换器
# ==============================================================================
one_hot_encoder = make_column_transformer(
    (
        OneHotEncoder(sparse_output=False, drop='if_binary'),
        make_column_selector(dtype_include=['category', 'object']),
    ),
    remainder="passthrough",
    verbose_feature_names_out=False,
).set_output(transform="pandas")  
# 创建带外生变量变换器的预测器
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator        = LGBMRegressor(random_state=15926, verbose=-1),
                lags             = 72,
                window_features  = window_features,
                transformer_exog = one_hot_encoder
             )

为了检查数据如何被变换,可使用 create_train_X_y 生成预测器用于训练的矩阵,从而了解训练过程中的具体数据处理步骤。

# 查看训练矩阵
# ==============================================================================
exog_features = ['weather']         
X_train, y_train = forecaster.create_train_X_y(
                        y    = data.loc[:end_validation, 'users'],
                        exog = data.loc[:end_validation, exog_features]
                   )
X_train.head(3)
lag_1 lag_2 lag_3 lag_4 lag_5 lag_6 lag_7 lag_8 lag_9 lag_10 ... lag_67 lag_68 lag_69 lag_70 lag_71 lag_72 roll_mean_72 weather_clear weather_mist weather_rain
date_time
2011-01-04 00:00:00 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 72.0 77.0 ... 1.0 1.0 13.0 32.0 40.0 16.0 43.638889 1.0 0.0 0.0
2011-01-04 01:00:00 5.0 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 72.0 ... 2.0 1.0 1.0 13.0 32.0 40.0 43.486111 1.0 0.0 0.0
2011-01-04 02:00:00 2.0 5.0 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 ... 3.0 2.0 1.0 1.0 13.0 32.0 42.958333 1.0 0.0 0.0

3 rows × 76 columns

以上 One-Hot 编码策略仅用于教学演示;在本文其余部分,将使用原生分类支持。

分类特征的原生实现

梯度提升库(XGBoostLightGBMCatBoostHistGradientBoostingRegressor)通常假设输入类别被编码为从 0 到 n_categories-1 的整数。在实际数据中,分类变量常以字符串形式出现,因此需要一个中间转换步骤。两种选择:

  • 将含分类变量的列设为 category 类型。该结构内部包含类别数组与整数代码数组(codes),整数指向类别数组中的真实值。本质上是数值数组加映射。模型可自动识别这类列并访问内部代码。适用于 XGBoost、LightGBM、CatBoost。

  • 使用 OrdinalEncoder 先将分类值转换为整数,然后显式指定哪些特征应作为分类特征处理。

在 skforecast 中使用“自动检测”时,需要先把分类变量编码为整数再转回 category 类型,因为 skforecast 在内部预测流程中使用数值 numpy 数组以加速计算。

警告

四种梯度提升框架(LightGBMscikit-learn 的 HistogramGradientBoostingXGBoostCatBoost)均可在模型内部直接处理分类特征,但各自的配置、优点与潜在问题不同。强烈建议参考 skforecast 的用户指南以获得详细理解。

在生产环境中,强烈建议避免依赖 pandas 的 category 类型列进行“自动检测”。尽管 pandas 为此类列提供内部编码,但不同数据集之间不一致,且会随类别集合变化而变化。部署时务必确保分类编码的一致性。详见 Native implementation for categorical features
# Transformer:序数编码 + 转为 category 类型
# ==============================================================================
pipeline_categorical = make_pipeline(
    OrdinalEncoder(
        dtype=int,
        handle_unknown="use_encoded_value",
        unknown_value=-1,
        encoded_missing_value=-1
    ),
    FunctionTransformer(
        func=lambda x: x.astype('category'),
        feature_names_out= 'one-to-one'
    )
)

transformer_exog = make_column_transformer(
    (
        pipeline_categorical,
        make_column_selector(dtype_include=['category', 'object']),
    ),
    remainder="passthrough",
    verbose_feature_names_out=False,
).set_output(transform="pandas")

在使用 LGBMRegressor 创建预测器时,需要通过 fit_kwargs 指定如何处理分类列。这是因为 categorical_feature 参数只在 LGBMRegressorfit 方法中设置,而不是在初始化时传入。

# 创建预测器:自动检测分类特征
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator        = LGBMRegressor(random_state=15926, verbose=-1),
                lags             = 72,
                window_features  = window_features,
                transformer_exog = transformer_exog,
                fit_kwargs       = {"categorical_feature": "auto"}
             )

本文其余部分将采用该策略。

使用外生变量评估模型

接下来重新训练预测器,这次将外生变量一并作为特征纳入。对分类特征采用原生实现进行处理。

# 选择要纳入模型的外生变量
# ==============================================================================
exog_features = []
# 选择以 _sin 或 _cos 结尾的列
exog_features.extend(df_exogenous_features.filter(regex='_sin$|_cos$').columns.tolist())
# 选择以 temp_ 开头的列
exog_features.extend(df_exogenous_features.filter(regex='^temp_.*').columns.tolist())
# 选择以 holiday_ 开头的列
exog_features.extend(df_exogenous_features.filter(regex='^holiday_.*').columns.tolist())
exog_features.extend(['temp', 'holiday', 'weather'])

df_exogenous_features = df_exogenous_features.filter(exog_features, axis=1)
# 合并目标变量与外生变量到同一 DataFrame
# ==============================================================================
data = data[['users', 'weather']].merge(
            df_exogenous_features,
            left_index=True,
            right_index=True,
            how='inner' # 仅使用所有变量都可用的日期
        )
data = data.astype({col: np.float32 for col in data.select_dtypes("number").columns})
data_train = data.loc[: end_train, :].copy()
data_val   = data.loc[end_train:end_validation, :].copy()
data_test  = data.loc[end_validation:, :].copy()
# 带外生变量的回测(测试集)
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))

metric, predictions = backtesting_forecaster(
                        forecaster    = forecaster,
                        y             = data['users'],
                        exog          = data[exog_features],
                        cv            = cv,
                        metric        = 'mean_absolute_error',
                        n_jobs        = 'auto',
                        verbose       = False,
                        show_progress = True
                     )
metric
mean_absolute_error
0 48.663656

引入外生变量作为特征提升了模型的预测能力。

超参数搜索

超参数调优是构建高效机器学习模型的关键步骤。上一节中的 ForecasterRecursive 使用了前 24 个滞后项,并采用默认超参数的 LGBMRegressor。但这些设置未必最优。为寻找更好的配置,本文使用 bayesian_search_forecaster() 进行 贝叶斯搜索。搜索仍基于与之前相同的回测流程,但每次以不同的超参数组合与滞后项训练模型。需要特别注意:超参数搜索必须在验证集上完成,测试集绝不能参与。

搜索流程如下:

  1. 仅使用训练集训练模型。
  2. 通过回测在验证集上评估模型。
  3. 选取误差最低的超参数与滞后组合。
  4. 使用训练集+验证集,以最佳组合重新训练最终模型。

遵循以上步骤可以在避免过拟合的同时获得经过优化的模型。

✎ 注意

基于回测(TimeSeriesFold)的验证在搜索超参数时可能耗时很长。更快的替代方案是采用基于一步预测的验证策略(OneStepAheadFold)。尽管该策略速度更快,但相较回测可能不那么准确。关于两种策略的优劣分析,参见 backtesting vs one-step-ahead
# 超参数搜索
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator        = LGBMRegressor(random_state=15926, verbose=-1),
                lags             = 72,
                window_features  = window_features,
                transformer_exog = transformer_exog,
                fit_kwargs       = {"categorical_feature": "auto"}
             )

# 滞后项网格
lags_grid = [48, 72, [1, 2, 3, 23, 24, 25, 167, 168, 169]]

# 回归器超参数搜索空间
def search_space(trial):
    search_space  = {
        'n_estimators'    : trial.suggest_int('n_estimators', 300, 1000, step=100),
        'max_depth'       : trial.suggest_int('max_depth', 3, 10),
        'min_data_in_leaf': trial.suggest_int('min_data_in_leaf', 25, 500),
        'learning_rate'   : trial.suggest_float('learning_rate', 0.01, 0.5),
        'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1),
        'max_bin'         : trial.suggest_int('max_bin', 50, 250),
        'reg_alpha'       : trial.suggest_float('reg_alpha', 0, 1),
        'reg_lambda'      : trial.suggest_float('reg_lambda', 0, 1),
        'lags'            : trial.suggest_categorical('lags', lags_grid)
    } 
    return search_space

# 训练/验证折叠
cv_search = TimeSeriesFold(steps = 36, initial_train_size = len(data_train))

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster    = forecaster,
    y             = data.loc[:end_validation, 'users'], # 不使用测试集数据
    exog          = data.loc[:end_validation, exog_features],
    cv            = cv_search,
    search_space  = search_space,
    metric        = 'mean_absolute_error',
    n_trials      = 20, # 若需更全面的搜索可增大该值
    return_best   = True
)

best_params = results_search['params'].iat[0]
best_params = best_params | {'random_state': 15926, 'verbose': -1}
best_lags   = results_search['lags'].iat[0]
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [  1   2   3  23  24  25 167 168 169] 
  Parameters: {'n_estimators': 300, 'max_depth': 8, 'min_data_in_leaf': 84, 'learning_rate': 0.03208367008069202, 'feature_fraction': 0.6966856646946507, 'max_bin': 141, 'reg_alpha': 0.22914980380651362, 'reg_lambda': 0.1591771216434959}
  Backtesting metric: 55.01654818651542
# 搜索结果
# ==============================================================================
results_search.head(3)
lags params mean_absolute_error n_estimators max_depth min_data_in_leaf learning_rate feature_fraction max_bin reg_alpha reg_lambda
0 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 300, 'max_depth': 8, 'min_dat... 55.016548 300.0 8.0 84.0 0.032084 0.696686 141.0 0.229150 0.159177
1 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 400, 'max_depth': 8, 'min_dat... 57.497662 400.0 8.0 30.0 0.110618 0.814603 197.0 0.219161 0.014016
2 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 400, 'max_depth': 8, 'min_dat... 57.891986 400.0 8.0 53.0 0.111434 0.805030 191.0 0.044970 0.222861

由于将 return_best 设为 True,预测器对象会自动更新为搜索到的最优配置,并在完整数据集上完成训练。随后即可用于对新数据进行预测。

在使用验证集找到最佳超参数组合后,接下来评估该模型在测试集上的泛化能力。

# 回测模型
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric, predictions = backtesting_forecaster(
                        forecaster = forecaster,
                        y          = data['users'],
                        exog       = data[exog_features],
                        cv         = cv,
                        metric     = 'mean_absolute_error'
                     )
metric
mean_absolute_error
0 46.937169
# 预测值 vs 真实值(绘图)
# ======================================================================================
fig = go.Figure()
trace1 = go.Scatter(x=data_test.index, y=data_test['users'], name="测试集", mode="lines")
trace2 = go.Scatter(x=predictions.index, y=predictions['pred'], name="预测", mode="lines")
fig.add_trace(trace1)
fig.add_trace(trace2)
fig.update_layout(
    title="测试集:真实值 vs 预测值",
    xaxis_title="日期时间",
    yaxis_title="用户数",
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    legend=dict(orientation="h", yanchor="top", y=1.1, xanchor="left", x=0.001)
)
fig.show()
Sep 92012Sep 23Oct 7Oct 21Nov 4Nov 18Dec 2Dec 16Dec 3002004006008001000
测试集预测测试集:真实值 vs 预测值日期时间用户数

特征选择

特征选择旨在从原始特征集中选出对建模最有用的子集。妥善的特征选择可降低过拟合、提升精度并缩短训练时间。由于 skforecast 的底层回归器遵循 scikit-learn API,可借助 select_features() 直接使用 scikit-learn 的特征选择方法。两种常见方法是 递归特征消除(RFE)序列特征选择(SFS)

💡 提示

特征选择有助于提升模型表现,但其计算开销较大。由于目标是寻找“最优特征子集”而非“最优模型”,无需使用全量数据或复杂模型。建议先用较小的数据子集与简单模型筛选特征,再用完整数据与更复杂配置训练最终模型。
# 创建预测器
# ==============================================================================
estimator = LGBMRegressor(
    n_estimators = 100,
    max_depth    = 5,
    random_state = 15926,
    verbose      = -1
)
forecaster = ForecasterRecursive(
    estimator        = estimator,
    lags             = [1, 2, 3, 23, 24, 25, 167, 168, 169],
    window_features  = window_features,
    transformer_exog = transformer_exog,
    fit_kwargs       = {"categorical_feature": "auto"}
)

# 带交叉验证的递归特征消除(RFECV)
# ==============================================================================
warnings.filterwarnings("ignore", message="X does not have valid feature names.*")
selector = RFECV(
    estimator = estimator,
    step      = 1,
    cv        = 3,
)
selected_lags, selected_window_features, selected_exog = select_features(
    forecaster      = forecaster,
    selector        = selector,
    y               = data_train['users'],  
    exog            = data_train[exog_features],
    select_only     = None,
    force_inclusion = None,
    subsample       = 0.5,
    random_state    = 123,
    verbose         = True,
)
Recursive feature elimination (RFECV)
-------------------------------------
Total number of records available: 11327
Total number of records used for feature selection: 5663
Number of features available: 99
    Lags            (n=9)
    Window features (n=1)
    Exog            (n=89)
Number of features selected: 48
    Lags            (n=9) : [1, 2, 3, 23, 24, 25, 167, 168, 169]
    Window features (n=1) : ['roll_mean_72']
    Exog            (n=38) : ['weather', 'week_sin', 'day_of_week_sin', 'hour_sin', 'hour_cos', 'poly_month_sin__week_cos', 'poly_month_sin__day_of_week_sin', 'poly_month_sin__hour_sin', 'poly_month_sin__hour_cos', 'poly_month_cos__week_sin', 'poly_week_sin__day_of_week_sin', 'poly_week_sin__day_of_week_cos', 'poly_week_sin__hour_sin', 'poly_week_sin__hour_cos', 'poly_week_sin__sunrise_hour_cos', 'poly_week_cos__day_of_week_sin', 'poly_week_cos__day_of_week_cos', 'poly_week_cos__hour_sin', 'poly_week_cos__hour_cos', 'poly_week_cos__sunset_hour_cos', 'poly_day_of_week_sin__day_of_week_cos', 'poly_day_of_week_sin__hour_sin', 'poly_day_of_week_sin__hour_cos', 'poly_day_of_week_sin__sunrise_hour_sin', 'poly_day_of_week_cos__hour_sin', 'poly_day_of_week_cos__hour_cos', 'poly_hour_sin__hour_cos', 'poly_hour_sin__sunrise_hour_sin', 'poly_hour_sin__sunrise_hour_cos', 'poly_hour_sin__sunset_hour_sin', 'poly_hour_sin__sunset_hour_cos', 'poly_hour_cos__sunrise_hour_sin', 'poly_hour_cos__sunset_hour_sin', 'temp_window_1D_mean', 'temp_window_1D_max', 'temp_window_7D_mean', 'temp', 'holiday']

Scikit-learn 的 RFECV 会先用初始特征集训练模型并获得每个特征的重要性(例如通过 coef_feature_importances_)。随后在每一轮迭代中移除最不重要的特征,并通过交叉验证评估剩余特征的模型性能。该过程会持续进行,直至继续删除特征无法提升(或开始降低)模型表现,或达到 min_features_to_select 限制。

最终结果是一个在复杂度与预测力之间折衷的最优特征子集,该子集由交叉验证过程确定。

使用筛选得到的最佳特征子集重新训练并评估预测器。

# 使用已选特征创建预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator        = LGBMRegressor(**best_params),
    lags             = selected_lags,
    window_features  = window_features,
    transformer_exog = transformer_exog,
    fit_kwargs       = {"categorical_feature": "auto"}
)

# 带外生变量的回测(测试集)
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric_lgbm, predictions = backtesting_forecaster(
    forecaster = forecaster,
    y          = data['users'],
    exog       = data[selected_exog],
    cv         = cv,
    metric     = 'mean_absolute_error'
)
metric_lgbm
mean_absolute_error
0 46.816304

模型表现与使用全部特征时相近,但结构更为简洁,训练更快且更不易过拟合。本文后续将仅使用最重要的特征进行训练。

# 更新外生变量集合
# ==============================================================================
exog_features = selected_exog

概率预测:置信区间(Prediction intervals)

预测区间(prediction interval)是在给定置信水平下,目标变量真实值可能落入的区间。Skforecast 提供多种概率预测方法:

下面的代码演示如何使用一致性预测(conformal prediction)生成预测区间。

  • 使用 prediction_interval() 为未来 n 步生成区间。

  • 使用 backtesting_forecaster() 为整个测试集生成区间。

其中 interval 参数控制期望的覆盖概率。本例中设为 [5, 95],对应理论覆盖率 90%。

# 创建并训练预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator        = LGBMRegressor(**best_params),
    lags             = [1, 2, 3, 23, 24, 25, 167, 168, 169],
    window_features  = window_features,
    transformer_exog = transformer_exog,
    fit_kwargs       = {"categorical_feature": "auto"},
    binner_kwargs    = {"n_bins": 5}
)
forecaster.fit(
    y    = data.loc[:end_train, 'users'],
    exog = data.loc[:end_train, exog_features],
    store_in_sample_residuals = True
)
# 预测区间
# ==============================================================================
# 由于模型在训练时使用了外生变量,进行预测时也必须提供相应的外生变量。
predictions = forecaster.predict_interval(
    exog     = data.loc[end_train:, exog_features],
    steps    = 24,
    interval = [5, 95],
    method   = 'conformal',
)
predictions.head()
pred lower_bound upper_bound
2012-05-01 00:00:00 24.743503 0.856561 48.630445
2012-05-01 01:00:00 9.341821 1.411828 17.271813
2012-05-01 02:00:00 5.365651 -2.564341 13.295644
2012-05-01 03:00:00 5.284053 -2.645939 13.214045
2012-05-01 04:00:00 8.790559 0.860566 16.720551

默认情况下,预测区间基于样本内残差(训练集残差)计算,这可能导致区间偏窄(过于乐观)。为减轻该问题,可通过回测在验证集上获得样本外残差,并使用 set_out_sample_residuals() 将其注入预测器。

如果在调用 set_out_sample_residuals() 时同时提供预测值,则在自助重抽样过程中可以按预测值的范围进行残差分箱(binned residuals)。这有助于在尽量收窄区间的同时提高覆盖率。

# 在验证集上回测以获得样本外残差
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_train]))
_, predictions_val = backtesting_forecaster(
    forecaster = forecaster,
    y          = data.loc[:end_validation, 'users'],
    exog       = data.loc[:end_validation, exog_features],
    cv         = cv,
    metric     = 'mean_absolute_error'
)
# 样本外残差的分布
# ==============================================================================
residuals = data.loc[predictions_val.index, 'users'] - predictions_val['pred']
print(pd.Series(np.where(residuals < 0, 'negative', 'positive')).value_counts())
plt.rcParams.update({'font.size': 8})
_ = plot_residuals(
        y_true = data.loc[predictions_val.index, 'users'],
        y_pred = predictions_val['pred'],
        figsize=(7, 4)
    )
positive    1770
negative    1182
Name: count, dtype: int64
# 在预测器中存储样本外残差
# ==============================================================================
forecaster.set_out_sample_residuals(
    y_true = data.loc[predictions_val.index, 'users'],
    y_pred = predictions_val['pred']
)

随后在测试集上运行 backtesting 以估计预测区间。将 use_in_sample_residuals=False 以便使用前面存储的样本外残差,并通过 use_binned_residuals=True 按预测值范围选择用于自助重抽样的残差。

# 使用样本外残差在测试集上进行带预测区间的回测
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric, predictions = backtesting_forecaster(
   forecaster              = forecaster,
   y                       = data['users'],
   exog                    = data[exog_features],
   cv                      = cv,
   metric                  = 'mean_absolute_error',
   interval                = [5, 95],  # 90% 预测区间
   interval_method         = 'conformal',
   use_in_sample_residuals = False,  # 使用样本外残差
   use_binned_residuals    = True,   # 根据预测值范围选择(分箱)残差
)
predictions.head(5)
fold pred lower_bound upper_bound
2012-09-01 00:00:00 0 140.339322 73.871088 206.807556
2012-09-01 01:00:00 0 106.689339 40.221105 173.157573
2012-09-01 02:00:00 0 75.945487 39.435761 112.455214
2012-09-01 03:00:00 0 39.113992 2.604266 75.623719
2012-09-01 04:00:00 0 14.350930 3.606948 25.094912
# 预测区间 vs 真实值(绘图)
# ==============================================================================
fig = go.Figure([
    go.Scatter(name='预测值', x=predictions.index, y=predictions['pred'], mode='lines'),
    go.Scatter(
        name='真实值', x=data_test.index, y=data_test['users'], mode='lines',
    ),
    go.Scatter(
        name='上界', x=predictions.index, y=predictions['upper_bound'], mode='lines',
        marker=dict(color="#444"), line=dict(width=0), showlegend=False
    ),
    go.Scatter(
        name='下界', x=predictions.index, y=predictions['lower_bound'], marker=dict(color="#444"),
        line=dict(width=0), mode='lines', fillcolor='rgba(68, 68, 68, 0.3)', fill='tonexty', showlegend=False
    )
])
fig.update_layout(
    title="测试集:真实值 vs 预测值(含预测区间)",
    xaxis_title="日期时间",
    yaxis_title="用户数",
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    hovermode="x",
    legend=dict(orientation="h", yanchor="top", y=1.1, xanchor="left", x=0.001),
    # 初始 x 轴缩放范围:10 月 1 日至 10 日
    xaxis=dict(range=['2012-10-01', '2012-10-10'])
)
fig.show()
Oct 12012Oct 2Oct 3Oct 4Oct 5Oct 6Oct 7Oct 8Oct 9Oct 1002004006008001000
真实值预测值测试集:真实值 vs 预测值(含预测区间)日期时间用户数
# 预测区间覆盖率(测试集)
# ==============================================================================
coverage = calculate_coverage(
              y_true       = data.loc[end_validation:, "users"],
              lower_bound  = predictions["lower_bound"],
              upper_bound  = predictions["upper_bound"]
            )
area = (predictions['upper_bound'] - predictions['lower_bound']).sum()
print(f"区间总面积: {round(area, 2)}")
print(f"预测区间覆盖率: {round(100 * coverage, 2)} %")
区间总面积: 589673.4
预测区间覆盖率: 87.19 %

在测试集上观察到的覆盖率略低于理论期望的覆盖率(90%)。这意味着区间包含真实值的概率低于预期。

✎ 注意

如需更详细了解 skforecast 提供的概率预测功能,请参见: 基于机器学习的概率预测

模型可解释性(Model explainability)

由于许多现代机器学习模型(如集成方法)具有复杂性,它们常被视为“黑箱”,难以解释某一预测为何产生。可解释性技术旨在揭示这些模型的内部机制,帮助建立信任、提升透明度,并满足不同行业的合规需求。增强模型可解释性不仅有助于理解模型行为,还能识别偏差、改进模型表现,并让相关方基于机器学习洞见做出更明智的决策。

Skforecast 兼容多种主流的模型可解释方法:模型特定的特征重要性、SHAP 值与偏依赖图(PDP)

# 创建并训练预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator        = LGBMRegressor(**best_params),
    lags             = [1, 2, 3, 23, 24, 25, 167, 168, 169],
    window_features  = window_features,
    transformer_exog = transformer_exog,
    fit_kwargs       = {"categorical_feature": "auto"}
)
forecaster.fit(
    y    = data.loc[:end_validation, 'users'],
    exog = data.loc[:end_validation, exog_features]
)

模型特定的特征重要性

# 提取特征重要性
# ==============================================================================
importance = forecaster.get_feature_importances()
importance.head(10)
feature importance
0 lag_1 1014
8 lag_169 561
7 lag_168 523
4 lag_24 431
1 lag_2 367
6 lag_167 366
2 lag_3 335
14 hour_cos 314
46 temp 295
13 hour_sin 293

警告

get_feature_importances() 仅在预测器的回归器包含 coef_feature_importances_ 属性时才会返回数值,这与 scikit-learn 的默认行为一致。

SHAP 值

SHAP(SHapley Additive exPlanations)值是解释机器学习模型的常用方法,能够直观且定量地理解变量及其取值如何影响预测结果。

在 skforecast 中,只需两个关键元素即可生成基于 SHAP 的解释:

  • 预测器内部的回归器(estimator)。

  • 由时间序列构建、用于拟合预测器的训练矩阵。

基于这两个组件,用户可以为其 skforecast 模型创建有洞察力且可解释的说明。这些解释可用于验证模型的可靠性,识别对预测贡献最大的关键因素,并更深入地理解输入变量与目标变量之间的内在关系。

# 用于拟合内部回归器的训练矩阵
# ==============================================================================
X_train, y_train = forecaster.create_train_X_y(
                       y    = data.loc[:end_validation, 'users'],
                       exog = data.loc[:end_validation, exog_features]
                   )
display(X_train.head(3))
display(y_train.head(3))
lag_1 lag_2 lag_3 lag_23 lag_24 lag_25 lag_167 lag_168 lag_169 roll_mean_72 ... poly_hour_sin__sunrise_hour_cos poly_hour_sin__sunset_hour_sin poly_hour_sin__sunset_hour_cos poly_hour_cos__sunrise_hour_sin poly_hour_cos__sunset_hour_sin temp_window_1D_mean temp_window_1D_max temp_window_7D_mean temp holiday
date_time
2011-01-15 01:00:00 28.0 27.0 36.0 1.0 5.0 14.0 16.0 16.0 25.0 55.736111 ... -0.066987 -0.250000 -0.066987 0.933013 -0.933013 6.594167 9.84 6.535595 6.56 0.0
2011-01-15 02:00:00 20.0 28.0 27.0 1.0 1.0 5.0 7.0 16.0 16.0 55.930556 ... -0.129410 -0.482963 -0.129410 0.836516 -0.836516 6.696667 9.84 6.530715 6.56 0.0
2011-01-15 03:00:00 12.0 20.0 28.0 1.0 1.0 1.0 1.0 7.0 16.0 56.083333 ... -0.183013 -0.683013 -0.183013 0.683013 -0.683013 6.799167 9.84 6.525833 6.56 0.0

3 rows × 48 columns

date_time
2011-01-15 01:00:00    20.0
2011-01-15 02:00:00    12.0
2011-01-15 03:00:00     8.0
Freq: h, Name: y, dtype: float32
# 创建 SHAP 解释器(基于树模型)
# ==============================================================================
explainer = shap.TreeExplainer(forecaster.estimator)

# 抽样 50% 数据以加速计算
rng = np.random.default_rng(seed=785412)
sample = rng.choice(X_train.index, size=int(len(X_train)*0.5), replace=False)
X_train_sample = X_train.loc[sample, :]
shap_values = explainer.shap_values(X_train_sample)

✎ 注意

SHAP 库包含多种解释器(explainer),各自适用于不同类型的模型。shap.TreeExplainer 适用于树模型,例如本例使用的 LGBMRegressor。更多信息参见 SHAP 文档
# SHAP 概要图(Top 10)
# ==============================================================================
shap.initjs()
shap.summary_plot(shap_values, X_train_sample, max_display=10, show=False)
fig, ax = plt.gcf(), plt.gca()
ax.set_title("SHAP 概要图")
ax.tick_params(labelsize=8)
fig.set_size_inches(10, 4.5)

SHAP 值不仅可用于解释模型的一般行为,还可作为分析单个预测的有力工具。这在试图理解某个特定预测如何产生、哪些变量产生了贡献时尤其有用。

进行此类分析,需要获取预测时刻的预测器输入值——在本例中即各滞后项。可以使用 create_predict_X 方法获取,或在 backtesting_forecaster 中启用 return_predictors=True 参数。

假设我们希望分析 backtesting 过程中日期 2012-10-06 12:00:00 的一次预测。

# 回测(返回用于预测的各特征)
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(data.loc[:end_validation]))
metric, predictions = backtesting_forecaster(
                        forecaster              = forecaster,
                        y                       = data['users'],
                        exog                    = data[exog_features],
                        cv                      = cv,
                        metric                  = 'mean_absolute_error',
                        return_predictors       = True,
                     )

通过设置 return_predictors=True,将返回一个包含以下信息的 DataFrame:预测值(pred)、所属分区(fold),以及用于生成该预测的各个滞后项与外生变量的取值。

predictions.head(3)
fold pred lag_1 lag_2 lag_3 lag_23 lag_24 lag_25 lag_167 lag_168 ... poly_hour_sin__sunrise_hour_cos poly_hour_sin__sunset_hour_sin poly_hour_sin__sunset_hour_cos poly_hour_cos__sunrise_hour_sin poly_hour_cos__sunset_hour_sin temp_window_1D_mean temp_window_1D_max temp_window_7D_mean temp holiday
2012-09-01 00:00:00 0 140.339322 174.000000 277.000000 303.0 32.0 82.0 152.0 115.0 135.0 ... 0.000000e+00 -0.000000 0.000000 1.000000 -0.965926 31.330833 36.900002 28.714643 30.340000 0.0
2012-09-01 01:00:00 0 106.689339 140.339322 174.000000 277.0 20.0 32.0 82.0 79.0 115.0 ... 1.584810e-17 -0.250000 0.066987 0.965926 -0.933013 31.433332 36.900002 28.724405 29.520000 0.0
2012-09-01 02:00:00 0 75.945487 106.689339 140.339322 174.0 5.0 20.0 32.0 38.0 79.0 ... 3.061617e-17 -0.482963 0.129410 0.866025 -0.836516 31.501667 36.900002 28.734167 28.700001 0.0

3 rows × 50 columns

# 针对单个预测的 Waterfall 图
# ==============================================================================
predictions = predictions.astype(data[exog_features].dtypes) # 确保数据类型一致
iloc_predicted_date = predictions.index.get_loc('2012-10-06 12:00:00')
shap_values_single = explainer(predictions.iloc[:, 2:])
shap.plots.waterfall(shap_values_single[iloc_predicted_date], show=False)
fig, ax = plt.gcf(), plt.gca()
fig.set_size_inches(8, 3.5)
ax_list = fig.axes
ax = ax_list[0]
ax.tick_params(labelsize=8)
ax.set
plt.show()
# 针对单个预测的 Force plot
# ==============================================================================
shap.force_plot(
    base_value  = shap_values_single.base_values[iloc_predicted_date],
    shap_values = shap_values_single.values[iloc_predicted_date],
    features    = predictions.iloc[iloc_predicted_date, 2:],
)
−419.2−219.2−19.16180.8380.8580.8780.8lag_24 = 361.9lag_167 = 686lag_168 = 682lag_1 = 622lag_169 = 663base value620.29620.29higherf(x)lower

XGBoost、CatBoost、HistGradientBoostingRegressor

自从梯度提升作为机器学习算法取得成功以来,已经出现了多种实现。除了 LightGBM 之外,还有三种实现非常流行:XGBoost、CatBoost 和 HistGradientBoostingRegressor。它们都与 skforecast 兼容。

以下各节展示如何使用这些实现构建预测模型,重点展示它们对分类特征的原生支持。本节为加速超参数搜索,采用一步前向验证策略(one-step-ahead)。

# 用于超参数搜索与回测的折叠(folds)
# ==============================================================================
cv_search = OneStepAheadFold(initial_train_size = len(data_train))
cv_backtesting = TimeSeriesFold(steps = 36, initial_train_size = len(data[:end_validation]))

XGBoost

# Transformer:序数编码 + 转为 category 类型
# ==============================================================================
pipeline_categorical = make_pipeline(
    OrdinalEncoder(
        dtype=int,
        handle_unknown="use_encoded_value",
        unknown_value=-1,
        encoded_missing_value=-1
    ),
    FunctionTransformer(
        func=lambda x: x.astype('category'),
        feature_names_out= 'one-to-one'
    )
)

transformer_exog = make_column_transformer(
    (
        pipeline_categorical,
        make_column_selector(dtype_exclude=np.number)
    ),
    remainder="passthrough",
    verbose_feature_names_out=False,
).set_output(transform="pandas")
# 创建预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator = XGBRegressor(tree_method='hist', enable_categorical=True, random_state=123),
    lags = 24,
    window_features  = window_features,
    transformer_exog = transformer_exog
)
# 超参数搜索
# ==============================================================================
# 滞后项网格
lags_grid = [48, 72, [1, 2, 3, 23, 24, 25, 167, 168, 169]]

# 回归器超参数搜索空间
def search_space(trial):
    search_space  = {
        'n_estimators'    : trial.suggest_int('n_estimators', 300, 1000, step=100),
        'max_depth'       : trial.suggest_int('max_depth', 3, 10),
        'learning_rate'   : trial.suggest_float('learning_rate', 0.01, 1),
        'subsample'       : trial.suggest_float('subsample', 0.1, 1),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.1, 1),
        'gamma'           : trial.suggest_float('gamma', 0, 1),
        'reg_alpha'       : trial.suggest_float('reg_alpha', 0, 1),
        'reg_lambda'      : trial.suggest_float('reg_lambda', 0, 1),
        'lags'            : trial.suggest_categorical('lags', lags_grid)
    } 
    return search_space

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster   = forecaster,
    y            = data.loc[:end_validation, 'users'],
    exog         = data.loc[:end_validation, exog_features],
    cv           = cv_search,
    search_space = search_space,
    metric       = 'mean_absolute_error',
    n_trials     = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
 One-step-ahead predictions are used for faster model comparison, but they may not    
 fully represent multi-step prediction performance. It is recommended to backtest the 
 final model for a more accurate multi-step performance estimate.                     
                                                                                      
 Category : skforecast.exceptions.OneStepAheadValidationWarning                       
 Location :                                                                           
 C:\Users\Joaquin\miniconda3\envs\skforecast_19_py13\Lib\site-packages\skforecast\mod 
 el_selection\_utils.py:607                                                           
 Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   
╰──────────────────────────────────────────────────────────────────────────────────────╯
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [  1   2   3  23  24  25 167 168 169] 
  Parameters: {'n_estimators': 700, 'max_depth': 8, 'learning_rate': 0.010417884143073163, 'subsample': 0.48697331780449904, 'colsample_bytree': 0.9428730581718683, 'gamma': 0.42816065165497974, 'reg_alpha': 0.4106760172394336, 'reg_lambda': 0.2579549423675435}
  One-step-ahead metric: 43.9364013671875
# 带外生变量的回测(测试集)
# ==============================================================================
metric_xgboost, predictions = backtesting_forecaster(
    forecaster = forecaster,
    y          = data['users'],
    exog       = data[exog_features],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metric_xgboost
mean_absolute_error
0 48.384923

HistGradientBoostingRegressor

在使用 HistogramGradientBoosting 创建预测器时,需要在回归器实例化阶段,通过 categorical_feature 参数以列表形式显式指定哪些列是分类变量。

# Transformer:序数编码
# ==============================================================================
# 使用 ColumnTransformer 对分类特征(非数值)进行序数编码;数值特征保持不变。
# 缺失值编码为 -1;若测试集出现新类别,同样编码为 -1。
categorical_features = ['weather']
transformer_exog = make_column_transformer(
    (
        OrdinalEncoder(
            dtype=int,
            handle_unknown="use_encoded_value",
            unknown_value=-1,
            encoded_missing_value=-1
        ),
        categorical_features
    ),
    remainder="passthrough",
    verbose_feature_names_out=False,
).set_output(transform="pandas")
# 创建预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator = HistGradientBoostingRegressor(
                    categorical_features=categorical_features,
                    random_state=123
                ),
    lags = 24,
    window_features = window_features,
    transformer_exog = transformer_exog
)
# 超参数搜索
# ==============================================================================
lags_grid = [48, 72, [1, 2, 3, 23, 24, 25, 167, 168, 169]]

def search_space(trial):
    search_space  = {
        'max_iter'          : trial.suggest_int('max_iter', 300, 1000, step=100),
        'max_depth'         : trial.suggest_int('max_depth', 3, 10),
        'learning_rate'     : trial.suggest_float('learning_rate', 0.01, 1),
        'min_samples_leaf'  : trial.suggest_int('min_samples_leaf', 1, 20),
        'l2_regularization' : trial.suggest_float('l2_regularization', 0, 1),
        'lags'              : trial.suggest_categorical('lags', lags_grid)
    } 
    return search_space

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster   = forecaster,
    y            = data.loc[:end_validation, 'users'],
    exog         = data.loc[:end_validation, exog_features],
    cv           = cv_search,
    search_space = search_space,
    metric       = 'mean_absolute_error',
    n_trials     = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
 One-step-ahead predictions are used for faster model comparison, but they may not    
 fully represent multi-step prediction performance. It is recommended to backtest the 
 final model for a more accurate multi-step performance estimate.                     
                                                                                      
 Category : skforecast.exceptions.OneStepAheadValidationWarning                       
 Location :                                                                           
 C:\Users\Joaquin\miniconda3\envs\skforecast_19_py13\Lib\site-packages\skforecast\mod 
 el_selection\_utils.py:607                                                           
 Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   
╰──────────────────────────────────────────────────────────────────────────────────────╯
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [  1   2   3  23  24  25 167 168 169] 
  Parameters: {'max_iter': 1000, 'max_depth': 7, 'learning_rate': 0.038248393152946356, 'min_samples_leaf': 15, 'l2_regularization': 0.556103081042606}
  One-step-ahead metric: 39.117435458998145
# 带外生变量的回测(测试集)
# ==============================================================================
metric_histgb, predictions = backtesting_forecaster(
    forecaster = forecaster,
    y          = data['users'],
    exog       = data[exog_features],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metric_histgb
mean_absolute_error
0 46.894726

CatBoost

遗憾的是,当前版本的 skforecast 尚未与 CatBoost 对分类特征的原生处理完全兼容。该问题的根源在于:CatBoost 只接受整数形式的分类特征,而 skforecast 为了在内部预测流程中使用 numpy 数组加速计算,会将输入数据转换为浮点数。为绕过该限制,需要在使用 CatBoost 前,对分类特征先进行 one-hot 编码或标签编码。

# One-Hot 编码
# ==============================================================================
one_hot_encoder = make_column_transformer(
    (
        OneHotEncoder(sparse_output=False, drop='if_binary'),
        make_column_selector(dtype_exclude=np.number),
    ),
    remainder="passthrough",
    verbose_feature_names_out=False,
).set_output(transform="pandas")

# 创建预测器
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator = CatBoostRegressor(
                    random_state=123, 
                    silent=True, 
                    allow_writing_files=False,
                    boosting_type = 'Plain',         # 训练更快
                    leaf_estimation_iterations = 3,  # 训练更快
                ),
    lags = 24,
    window_features = window_features,
    transformer_exog = one_hot_encoder
)
# 超参数搜索
# ==============================================================================
lags_grid = [48, 72, [1, 2, 3, 23, 24, 25, 167, 168, 169]]

def search_space(trial):
    search_space  = {
        'n_estimators'  : trial.suggest_int('n_estimators', 100, 1000, step=100),
        'max_depth'     : trial.suggest_int('max_depth', 3, 10),
        'learning_rate' : trial.suggest_float('learning_rate', 0.01, 1),
        'lags'          : trial.suggest_categorical('lags', lags_grid)
    } 
    return search_space

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster   = forecaster,
    y            = data.loc[:end_validation, 'users'],
    exog         = data.loc[:end_validation, exog_features],
    cv           = cv_search,
    search_space = search_space,
    metric       = 'mean_absolute_error',
    n_trials     = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
 One-step-ahead predictions are used for faster model comparison, but they may not    
 fully represent multi-step prediction performance. It is recommended to backtest the 
 final model for a more accurate multi-step performance estimate.                     
                                                                                      
 Category : skforecast.exceptions.OneStepAheadValidationWarning                       
 Location :                                                                           
 C:\Users\Joaquin\miniconda3\envs\skforecast_19_py13\Lib\site-packages\skforecast\mod 
 el_selection\_utils.py:607                                                           
 Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   
╰──────────────────────────────────────────────────────────────────────────────────────╯
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [ 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48] 
  Parameters: {'n_estimators': 100, 'max_depth': 6, 'learning_rate': 0.4365541356963474}
  One-step-ahead metric: 38.6929349126483
# 测试集回测
# ==============================================================================
metric_catboost, predictions = backtesting_forecaster(
    forecaster = forecaster,
    y          = data['users'],
    exog       = data[exog_features],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metric_catboost
mean_absolute_error
0 50.758018

结论

  • 借助 skforecast 提供的功能,使用梯度提升模型解决预测问题非常简便。

  • 如本文所示,将外生变量作为预测因子纳入,通常能显著提升预测性能。

  • 得益于 LightGBMXGBoostHistGradientBoostingRegressor 对分类特征的原生支持,分类特征可作为外生变量直接使用而无需预处理。

metrics = pd.concat(
    [metric_baseline, metric_lgbm, metric_xgboost, metric_histgb, metric_catboost]
)
metrics.index = [
    "Baseline",
    "LGBMRegressor",
    "XGBRegressor",
    "HistGradientBoostingRegressor",
    "CatBoostRegressor",
]
metrics.round(2).sort_values(by="mean_absolute_error")
mean_absolute_error
LGBMRegressor 46.82
HistGradientBoostingRegressor 46.89
XGBRegressor 48.38
CatBoostRegressor 50.76
Baseline 91.67

✎ 注意

为便于演示,文中超参数搜索规模较小。若要获得更优结果,通常需要针对每个模型进行更为充分的搜索。

会话信息

import session_info
session_info.show(html=False)
-----
astral              3.2
catboost            1.2.8
feature_engine      1.9.3
lightgbm            4.6.0
matplotlib          3.10.8
numpy               2.3.4
optuna              4.6.0
pandas              2.3.3
plotly              6.4.0
session_info        v1.0.1
shap                0.50.0
skforecast          0.19.0
sklearn             1.7.2
statsmodels         0.14.5
xgboost             3.1.1
-----
IPython             9.7.0
jupyter_client      8.6.3
jupyter_core        5.9.1
-----
Python 3.13.9 | packaged by conda-forge | (main, Oct 22 2025, 23:12:41) [MSC v.1944 64 bit (AMD64)]
Windows-11-10.0.26100-SP0
-----
Session information updated at 2025-11-28 00:07

引用

如何引用本文档

如果您在工作中使用了本教程(或其中任一部分),请注明来源,谢谢!

基于梯度提升的时序预测: Skforecast、XGBoost、LightGBM、Scikit-learn 和 CatBoost by Joaquín Amat Rodrigo and Javier Escobar Ortiz, available under a Attribution-NonCommercial-ShareAlike 4.0 International at https://www.cienciadedatos.net/documentos/py39-forecasting-time-series-with-skforecast-xgboost-lightgbm-catboost-cn.html

如何引用 skforecast

如果您在发表工作中使用了 skforecast,欢迎引用已发布的软件条目。

Zenodo:

Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2024). skforecast (v0.19.0). Zenodo. https://doi.org/10.5281/zenodo.8382788

APA:

Amat Rodrigo, J., & Escobar Ortiz, J. (2024). skforecast (Version 0.19.0) [Computer software]. https://doi.org/10.5281/zenodo.8382788

BibTeX:

@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.19.0}, month = {11}, year = {2025}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }


喜欢这篇文章吗?您的支持很重要

您的支持将帮助我持续创作免费的教育内容。非常感谢! 😊

Become a GitHub Sponsor Become a GitHub Sponsor

Creative Commons Licence

本作品由 Joaquín Amat Rodrigo 和 Javier Escobar Ortiz 基于 署名-非商业性使用-相同方式共享 4.0 国际 许可协议授权。

允许的行为:

  • 分享:以任何媒介或格式复制、传播本作品。

  • 演绎:再创作、改编、二次开发本作品。

需遵循的条件:

  • 署名:您必须给予适当的署名,提供许可协议链接,并注明是否做出修改。署名方式应合理,但不得以任何方式暗示许可人认可您或您的使用。

  • 非商业性使用:不得将本作品用于商业目的

  • 相同方式共享:若您再创作、改编或二次开发本作品,则必须采用与原作品相同的许可协议进行共享。