上个月接了一个日频销售预测的小项目,数据量不大,但特征有十几列,典型的业务型多变量时间序列。跑下来最折磨我的不是数据清洗,而是 XGBoost 那三个超参数——n_estimators、max_depth、learning_rate 之间相互牵扯,手动调了几轮,验证集指标不错,一换到后面的真实测试段就飘。后来我把寻优逻辑换成了 PSO(粒子群优化),再把交叉验证嵌进每次评估里,才真正把“调参”这件事从手工劳动里解放出来,测试集的表现也稳定了不少。这篇就是把整套做法拆开讲清楚:为什么用 XGBoost 处理这类时序任务,PSO 怎么和 XGBoost 配合,交叉验证又是如何塞进寻优流程去压制过拟合,最后附上可以直接改的代码和一堆踩坑记录。
1. 为什么我放着 LSTM 不用,偏要拿 XGBoost 做多变量时序预测
1.1 时间序列怎么变成表格:滑动窗口的核心逻辑
很多人一听到“多变量时间序列预测”,第一反应就是上 LSTM 或者 Transformer。这个直觉没有错,但如果数据是按天、按周采样的业务数据,样本量可能就几千行,特征又是节假日、促销、价格、外部宏观变量这类结构化信息,LSTM 不一定占便宜。XGBoost 这种梯度提升树模型,本质是在一堆表格特征上做非线性拟合,而时间序列预测恰恰可以改造成表格问题:用过去一段窗口的观察值和外生变量,去预测下一个时刻的目标值。
具体操作是滑动窗口。假设我们有每日的销售额序列 y(t),以及外部变量 x1(t), x2(t),想预测 y(t+1),那就构造一条样本:
text复制特征: y(t), y(t-1), ..., y(t-6),
x1(t), x1(t-1), ...,
x2(t), x2(t-1), ...,
dayofweek(t), month(t), ...
标签: y(t+1)
然后用所有历史时刻滚动生成一堆这样的样本,交给 XGBoost 训练。关键在于构造特征时绝对不能混入“未来信息”。用 pandas 的 shift() 时,要时刻注意对齐关系:标签的时间永远要比特征晚一步。
1.2 XGBoost 和 LSTM 的选型差异
我不否认 LSTM 在长序列、强时序依赖、高频数据上的能力,比如传感器波形、语音这类连续信号,树模型确实难匹敌。但业务预测场景往往不是这样,外部特征对目标的影响常常比纯历史序列本身更大。比如电商销量受促销影响,用电负荷受天气影响,供应链需求受库存和价格影响。这类“规律弱但特征多”的数据,XGBoost 有几个非常实用的优势。
第一,它对特征量纲不敏感,不需要像神经网络那样做精细归一化,树模型做的是特征空间上的分裂,量纲差异天然免疫。第二,它对缺失值有内部分裂策略,业务表里常见的外生变量缺失问题不太需要额外处理。第三,训练速度快,单机 CPU 上几千行数据配合 hist 直方图算法,几秒钟就能训练完一轮。相比之下,LSTM 要调的东西更多——隐层维度、层数、 dropout、时间步长、learning rate schedule,训练速度也更慢。
1.3 为什么默认参数跑出来的结果总是不够稳
XGBoost 的默认参数是通用配置,对时序任务来说常常偏保守或者偏激进。n_estimators 默认几百轮,如果不设早停,模型会在训练集上越学越准,却把噪声模式也学进去了;max_depth 默认 6 在特征不多时可能过深;learning_rate 默认 0.3 偏高,配合较深的树很容易震荡。更麻烦的是这三个参数是耦合的:学习率调小之后,树的数量就得相应增大,否则欠拟合;深度增大之后,学习率又得适当调小,否则容易过拟合。手动一个个试,纯靠手气。
这就是我引入粒子群优化的原因。把参数选择当成一个连续空间上的搜索问题,让算法自己去试,比人肉调参更系统化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PSO 寻优里的优化变量是什么?为什么偏偏挑这三个参数
2.1 PSO 在做什么——一群粒子在参数空间里找低误差区域
粒子群优化的思想并不复杂,可以理解成一群鸟在一片未知区域里找食物最密集的地方。每只鸟有一个当前位置,代表一组候选解;它知道自己历史上找到过的最好位置,也知道整个鸟群目前找到的最好位置。每次移动时,它会结合自己的历史经验、群体的全局经验,以及一个惯性速度,决定下一步往哪里飞。
如果用数学化的语言描述,假设第 i 个粒子的位置是 x,速度是 v,那么它每一轮更新遵循两个公式:
text复制v = w * v + c1 * r1 * (pbest - x) + c2 * r2 * (gbest - x)
x = x + v
其中 w 是惯性权重,控制上一轮速度对当前的影响;c1、c2 是个体学习系数和社会学习系数;r1、r2 是 0 到 1 之间的随机数;pbest 是该粒子历史最优位置,gbest 是群体全局最优位置。
应用到这个项目里,x 的每一维就是我们要优化的 XGBoost 超参数。PSO 算法不断生成候选参数组合,用这些参数去训练 XGBoost,然后看验证误差有多小,误差越小,这个粒子的位置越好。跑完设定的迭代次数后,gbest 就是算法找到的最优参数组合。
2.2 三个参数各自的“业务直觉”和取值范围
我在这个项目里选的是 max_depth、learning_rate、n_estimators 三个参数,它们是 XGBoost 里影响最大、也最常被折腾的组合。
n_estimators 是提升树的迭代轮数,也就是要训练多少棵树。树太少,模型欠拟合,误差下不去;树太多,模型会把训练集里非常局部的模式记忆下来,泛化能力变差。它对训练时间的影响也最直接,所以在 PSO 里它决定了每次适应度评估的耗时上限。
max_depth 是单棵树的最大深度。深度越大,单棵树的表达能力越强,但叶子节点覆盖的样本数会变少,很容易劈到噪声上。业务型数据上我一般不会让它超过 10,超过 10 之后单棵树基本就是在背训练集。深度调低之后,模型通常需要更多树来弥补表达能力,所以它和 n_estimators 是正相关的。
learning_rate 是每一步树模型的收缩系数。它本质上控制每棵树对最终预测的贡献权重。学习率小,模型拟合更谨慎,需要更多树;学习率大,拟合快但容易震荡和过拟合,尤其和深层树搭配时非常危险。
三个参数协同关系用一个通俗类比讲:learning_rate 是每次迈的步子大小,n_estimators 是走多少步,max_depth 则是每一步能看清多远的路。步子迈太大容易扯着,步子太小又需要走很多步,而每步看太远很容易把路边的假路标当真。
2.3 优化变量与搜索空间的表示
我习惯把待寻优的参数组合记成一个向量:
text复制x = (max_depth, learning_rate, n_estimators)
粒子群算法本身是为连续变量设计的,但 max_depth 和 n_estimators 是整数,learning_rate 是连续变量。所以在适应度函数内部,我会把前两个变量做 int(round(...)) 取整,再传给 XGBoost。搜索范围设置如下:
| 参数 | 下界 | 上界 | 说明 |
|---|---|---|---|
| max_depth | 3 | 10 | 太浅欠拟合明显,太深基本过拟合 |
| learning_rate | 0.01 | 0.3 | 低于 0.01 收敛过慢,超过 0.3 容易震荡 |
| n_estimators | 50 | 500 | 上限过大会拖慢整个 PSO 过程 |
顺带一提,如果你要把这套方法写进论文或正式实验报告,凡是代表向量的符号,比如这里的 x、速度 v、个体最优 pbest、全局最优 gbest,正规排版里一般用粗斜体或者粗体,用来和普通的标量数字区分;正文里用手写体或普通斜体容易让人混淆。这是个小规范,编辑看到会比较在意,但实际编码时不需要管这些。
2.4 PSO 和网格搜索、随机搜索、贝叶斯优化怎么选
网格搜索最笨,三个参数各取 10 个候选值就是 1000 次完整训练,每次还要配交叉验证,时间成本直接爆炸。随机搜索比网格好一些,不用遍历全部组合,但它是盲目的,没有利用“哪些区域误差更小”这个信息,试多少次基本靠运气。
贝叶斯优化在理论上更高级,它会用高斯过程或树形代理模型去拟合参数与误差之间的关系,再通过采集函数选择下一步评估点,收敛通常很快。但它实现复杂一些,需要维护代理模型,而且在每一轮还要考虑勘探和利用的平衡,对于只想快速调参的人来说门槛偏高。
PSO 的好处在于实现简单、逻辑直观,每轮迭代都保留了历史最优信息,在参数空间比较平滑的时候收敛很快。比如我们的目标函数——交叉验证均方根误差——往往在参数空间中呈现一个有梯度的曲面,PSO 通过群体信息能够比较高效地滑向低误差区域。数据量中等、单次训练不超过十几秒的场景,PSO 训练一轮往往几分钟就能给出一个靠谱结果。
3. 不让过拟合蒙混过关:交叉验证如何嵌进 PSO 寻优
3.1 只靠单次划分验证集,为什么选出来的参数容易翻车
如果在 PSO 流程里,每次评估只用一组固定训练集和验证集,那选出来的参数很可能在验证集上表现很好,却在真正的测试集上崩掉。原因是超参数搜索过程本质上是在无数候选模型里挑一个“验证集上误差最小”的模型,这个过程本身就可能过拟合验证集。
用生活化的类比来说,如果你面试员工只问一套题目,那总有人能把这套题背得滚瓜烂熟;但如果换着不同场景考好几轮,才能真正看出能力。交叉验证就是把“面试”变成“多次多场景考核”,让每个候选参数组合在多个不同的时间分段上被评估,最终得分取平均,这样单个分段里的偶然波动就被平滑掉了。
3.2 时序数据到底能不能用普通 KFold,这里有个大坑
很多初学者在这步会犯严重错误:把滑动窗口生成好的样本,喂给 KFold(shuffle=True, random_state=42) 做交叉验证。这在普通分类回归任务里没问题,但在时间序列里是致命的。
原因是样本在时间上不是独立的。第 t 天的样本和第 t+1 天的样本,因为滑窗重叠,特征几乎一样,标签只差一天。如果随机打乱,训练集里会包含验证集时间点附近甚至未来的样本,模型相当于提前看到了“未来的邻居”,验证误差会严重失真。
正确做法是用 TimeSeriesSplit,相当于把数据按时间顺序切成若干段,每次用前面的段做训练、后面的段做验证,不断向前扩展。这样既保留了多次评估的稳定性,又模拟了真实预测时只能拿历史数据建模的限制。
我在代码里设置 gap=7,意思是训练段和验证段之间空出 7 个样本,防止滚动窗口在边界处造成信息泄露。这个 gap 可以理解为预测时你必须等一周之后的样本才能验证,实际业务里是否要 gap 取决于预测周期,如果只预测下一天,gap 可以设 0,但保守起见设一个短期间隔总会更稳。
3.3 适应度函数怎么写:以交叉验证平均 RMSE 为优化目标
PSO 需要一个目标函数来打分,我给这个函数命名为 objective,它接收一组参数向量,返回交叉验证的平均均方根误差 RMSE。RMSE 越小,粒子适应度越高。
函数内部逻辑大致是:使用 TimeSeriesSplit 把训练集切成若干折,每一折里用当前参数组合训练 XGBoost,然后在验证部分预测,计算 RMSE,最后把所有折的 RMSE 取平均。注意,这里每一折内部的验证集是独立的,模型从没见过这些数据,所以这个平均 RMSE 比单次划分更可信。
我在实际代码里还做了一步处理:每一折训练 XGBoost 时,都开启 early_stopping_rounds=30,让它在验证误差连续 30 轮不再改善时提前停止。这能在保持参数寻优效果的前提下,明显压缩粒子评估所花的时间。n_estimators 在 PSO 搜索中被视为训练轮数上限,因为早期停止可能提前结束,所以实际运行轮数会小于等于粒子中设置的 n_estimators。
3.4 为什么交叉验证对抑制过拟合有效,但不等于万能药
交叉验证不能消除所有过拟合,它在做的是“减少选择偏差”。一个参数组合在 5 折验证里平均误差最小,比在单折验证里误差最小更可信,因为它没有依赖特定某一小段数据的偶然模式。
但它管不了特征工程阶段的过拟合。比如你反复在测试集上试不同的滑窗长度、不同的滞后特征数量,最后挑了一个测试集表现最好的版本,这个流程本身已经让测试集信息渗透到模型选择里了。所以我通常把真正的测试集扣得很死,只在 PSO 全部跑完、参数定下来之后,才用最终参数在测试集上评估一次,绝不回头看第二次。
4. 手把手实现:一套可以直接改的 PSO-XGBoost 训练管线
4.1 环境准备
我用的环境是 Python 3.10,核心库版本大致如下:
text复制numpy
pandas
xgboost
scikit-learn
pyswarm
pyswarm 是粒子群优化的轻量库,接口简单,但社区更新不活跃。如果没有装,可以用 pip install pyswarm。如果你担心这个库太老,也可以自己写一个简化版 PSO,本质上就几十行,后面我会给替代方案。
4.2 生成一份模拟的多变量时序数据
为了方便读者直接复现整个流程,我先用代码模拟一份日频销售数据。真实项目里把这份数据换成你自己的业务表即可,处理流程是一样的。
python复制import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 730
t = np.arange(n)
date = pd.date_range("2022-01-01", periods=n, freq="D")
trend = 0.08 * t
season = 25 * np.sin(2 * np.pi * t / 365.25)
price = 100 + 10 * np.sin(2 * np.pi * t / 60)
promo = rng.binomial(1, 0.15, n)
sales = (
300
+ trend
+ season
+ 0.8 * price
+ 45 * promo
+ rng.normal(0, 12, n)
)
df = pd.DataFrame(
{
"date": date,
"sales": sales,
"price": price,
"promo": promo,
"weekday": date.dayofweek,
"month": date.month,
}
)
print(df.head())
这是一个带趋势、季节、外部变量和噪声的简单序列,用来演示足够。实际业务中,价格通常是当日已知的值;预测当天销量时如果价格是计划好的,可以把当日价格作为特征,否则就只能用它的滞后值。
4.3 滑动窗口和特征工程
我会构造几个滞后特征和滚动统计量。对于时序预测,常用特征包括:目标变量的 lag1、lag7、lag14,外生变量的 lag,以及滚动窗口均值、滚动标准差、星期几、月份等周期编码。
python复制def make_features(df, target_col="sales", window=7):
data = df.copy()
data = data.sort_values("date").reset_index(drop=True)
for lag in [1, 2, 7, 14]:
data[f"lag_{lag}"] = data[target_col].shift(lag)
data[f"price_lag_{lag}"] = data["price"].shift(lag)
data["promo_lag_1"] = data["promo"].shift(1)
data["rolling_mean_7"] = data[target_col].shift(1).rolling(window).mean()
data["rolling_std_7"] = data[target_col].shift(1).rolling(window).std()
data["dayofyear"] = data["date"].dt.dayofyear
data["weekday"] = data["date"].dt.dayofweek
data["month"] = data["date"].dt.month
data = data.dropna().reset_index(drop=True)
return data
feature_df = make_features(df)
feature_cols = [
"lag_1", "lag_2", "lag_7", "lag_14",
"price_lag_1", "price_lag_2", "price_lag_7", "price_lag_14",
"promo_lag_1",
"rolling_mean_7", "rolling_std_7",
"dayofyear", "weekday", "month",
]
X = feature_df[feature_cols]
y = feature_df["sales"]
注意几个细节:
rolling_mean_7 我用了 shift(1) 再做滚动平均,目的是确保统计量只包含历史信息,不把当天的目标值算进去。如果直接对 sales 做滚动平均,当天预测时就把当天的真实值泄露给模型了,这在单步预测场景会造成验证误差严重失真。
weekday 和 month 这类日期特征是周期性变量。如果有充分理由,也可以做正弦编码,如 sin(2*pi*weekday/7),但树模型对单调变换的切分能力足够,普通数值编码也能用。
4.4 PSO 适应度函数与 5 折时序验证
接下来是关键部分:适应度函数内部跑时间序列交叉验证,返回平均 RMSE。
python复制import numpy as np
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_squared_error
import xgboost as xgb
def cv_score(params, X_train, y_train, n_splits=5, gap=7):
max_depth = int(round(params[0]))
learning_rate = float(params[1])
n_estimators = int(round(params[2]))
random_state = 42
tscv = TimeSeriesSplit(n_splits=n_splits, gap=gap)
rmse_list = []
best_iterations = []
for train_index, valid_index in tscv.split(X_train):
X_tr, X_val = X_train.iloc[train_index], X_train.iloc[valid_index]
y_tr, y_val = y_train.iloc[train_index], y_train.iloc[valid_index]
model = xgb.XGBRegressor(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate,
tree_method="hist",
random_state=random_state,
verbosity=0,
)
model.fit(
X_tr,
y_tr,
eval_set=[(X_val, y_val)],
early_stopping_rounds=30,
verbose=False,
)
y_pred = model.predict(X_val)
rmse = np.sqrt(mean_squared_error(y_val, y_pred))
rmse_list.append(rmse)
best_iterations.append(model.best_iteration)
mean_rmse = float(np.mean(rmse_list))
return mean_rmse
之所以在每一折内部都启用 early_stopping_rounds,是因为 n_estimators 较大时,直接全量训练固定轮数会浪费大量时间在“树学不动了”之后的无意义迭代上。早停让每一折的模型都在验证误差不再改善时停住,评估更快,分数也更合理。
4.5 PSO 主流程:pyswarm 版
pyswarm 库要求目标函数只接收参数向量本身,所以要用 partial 把训练数据固定传进去,或者写一个 lambda 包一层。
python复制from pyswarm import pso
from functools import partial
n_train = int(len(feature_df) * 0.8)
train_df = feature_df.iloc[:n_train]
test_df = feature_df.iloc[n_train:]
X_train = train_df[feature_cols]
y_train = train_df["sales"]
X_test = test_df[feature_cols]
y_test = test_df["sales"]
lb = [3, 0.01, 50]
ub = [10, 0.30, 500]
objective_func = partial(cv_score, X_train=X_train, y_train=y_train)
xopt, fopt = pso(
objective_func,
lb,
ub,
swarmsize=10,
maxiter=15,
omega=0.5,
phip=0.5,
phig=0.7,
)
pso 返回两个结果:xopt 是最优参数位置,fopt 是相应的最小目标函数值。swarmsize=10 表示用 10 个粒子,maxiter=15 表示迭代 15 代,也就是最多评估 150 个参数组合。如果数据量大,建议先跑一轮小规模的试水,比如 swarmsize=6, maxiter=8,确认流程无误后再把规模调大。
跑完之后打印最优参数:
python复制print("最优参数:", xopt)
print("最优交叉验证RMSE:", fopt)
最终在测试集上评估一次:
python复制best_model = xgb.XGBRegressor(
n_estimators=int(round(xopt[2])),
max_depth=int(round(xopt[0])),
learning_rate=float(xopt[1]),
tree_method="hist",
random_state=42,
)
best_model.fit(X_train, y_train)
y_pred_test = best_model.predict(X_test)
test_rmse = np.sqrt(mean_squared_error(y_test, y_pred_test))
print("测试集RMSE:", test_rmse)
如果你想把预测结果保存,可以直接写 pd.DataFrame({"y_true": y_test, "y_pred": y_pred_test}).to_csv(...)。
4.6 不依赖 pyswarm 的轻量实现
pyswarm 这个库比较简单,但有些环境安装时可能遇到依赖问题。很多实际项目我选择自己写一个最简 PSO,核心逻辑不超过 60 行,完全可控。
python复制def simple_pso(objective, lb, ub, n_particles=10, maxiter=15, w=0.5, c1=0.8, c2=0.9, seed=42):
rng = np.random.default_rng(seed)
lb = np.asarray(lb, dtype=float)
ub = np.asarray(ub, dtype=float)
particles = rng.uniform(lb, ub, size=(n_particles, len(lb)))
velocities = np.zeros_like(particles)
pbest_pos = particles.copy()
pbest_score = np.full(n_particles, np.inf)
gbest_pos = particles[0].copy()
gbest_score = np.inf
for _ in range(maxiter):
for i in range(n_particles):
score = objective(particles[i])
if score < pbest_score[i]:
pbest_score[i] = score
pbest_pos[i] = particles[i]
if score < gbest_score:
gbest_score = score
gbest_pos = particles[i].copy()
inertia = w * velocities
cognitive = c1 * rng.random((n_particles, len(lb))) * (pbest_pos - particles)
social = c2 * rng.random((n_particles, len(lb))) * (gbest_pos - particles)
velocities = inertia + cognitive + social
particles = particles + velocities
particles = np.clip(particles, lb, ub)
return gbest_pos, gbest_score
和 pyswarm 的接口基本一致,如果不追求复杂变体,跑调参任务足够用。实际工程中我经常用这个轻量版,可读性好,依赖少,也方便扩展成并行评估。
5. 实测踩坑记录:PSO 寻优容易翻车的几个隐蔽问题
5.1 TimeSeriesSplit 的 gap 参数没设,结果虚高
第一次跑通流程时,我用的 TimeSeriesSplit(n_splits=5) 没有设置 gap,交叉验证 RMSE 低得让人开心,但一上测试集直接打回原形。问题出在哪里?滑动窗口长度为 14,相邻两天的样本高度重叠,训练段末尾的样本和验证段开头的样本其实只差了一天,特征几乎相同,标签也只差一天。模型等于在“预测几乎见过的数据”。
后来给验证段前面空出 gap 之后,交叉验证误差和测试集误差的差距明显缩小。gap 的取值至少应该大于等于滑动窗口长度,如果窗口是 14,gap 设 7 或 14 都合理。时序预测里的任何验证策略都要问自己一个问题:这种切分方式在真实部署时能复现吗?如果不能,那评估结果就是虚的。
5.2 粒子群体里混入了不合理的 n_estimators,计算时间暴涨
PSO 在搜索过程中会不断试探边界。n_estimators 上界如果设成 1000,测试每个粒子都要训练最多 1000 棵树,即使有早停,当参数组合落在学习率较小、深度较浅的区域时,早停触发很慢,模型会实打实训练几百甚至上千轮。10 个粒子、15 代、5 折交叉验证,算下来评估次数是 750 次,每次训练带 5 折,时间直接翻数倍。
我的做法是先用一轮粗搜索,把 n_estimators 上界设在 300,然后在确认最优参数大概落在 200 附近之后,再做一轮小规模精细搜索。迭代次数变量不一定要一开始就给很大的上界,给它一个合理的上限本身就是在约束搜索空间。
5.3 XGBoost 的 random_state 和 PSO 的随机种子都要固定
XGBoost 的训练过程带有随机性,特别是 tree_method='hist' 和 subsample 相关设置下,每次训练结果会有细微波动。如果不固定 random_state,同一个参数组合前后跑两次交叉验证,RMSE 会不一样,PSO 的适应度函数就成了一个带噪声的函数。带噪声的目标函数会让粒子群很难稳定收敛,表现为同样的参数每次评估得分忽高忽低。
我在适应度函数内部固定了 XGBoost 的 random_state=42,在 PSO 初始化里也固定了随机种子。如果想让结果更有说服力,可以每次用多个随机种子各跑一遍 PSO,取最优参数的中位数或者众数。注意这里的目的是保证评估稳定,不是要把所有随机性都抹掉。
5.4 Learning rate 上界太大,PSO 在搜索早期浪费迭代
learning_rate 上界如果设到 0.5,PSO 初期会随机产生一些学习率很大的粒子,这些粒子对应的模型在训练初期就剧烈震荡,即使交叉验证能筛掉它们,这些粒子仍然占用了评估次数。更麻烦的是,学习率很大时模型对 n_estimators 非常敏感,多几棵树或少几棵树,误差波动很大,导致适应度函数曲面不光滑。
折中的做法是把 learning_rate 搜索范围限制在 [0.01, 0.2]。如果数据噪声大,通常最优学习率落在 0.02 到 0.08 之间;如果数据相对干净,0.1 左右也能有不错表现。PSO 搜索空间越小,收敛越快,也更不容易浪费在明显不合理的区域。
5.5 不要频繁回头看测试集
我在实测过程中最大的教训不是参数问题,而是流程控制问题。第一次拿到测试集 RMSE 后,我又忍不住调整滑动窗口长度,重新跑了一遍 PSO,发现测试集误差下降了 1%,于是很开心。但再想一下,这已经是在用测试集指导特征工程了。测试集是“期末考试卷”,不是“模拟卷”,不能做一次不理想就回去改再用它验证。
更规范的做法是:把原始数据按时间分成训练、验证、测试三部分。PSO 寻优和交叉验证全部在训练加验证阶段完成,测试集只允许最后碰一次。如果对特征窗口、lag 数量等有多个候选方案,应该建立独立的验证集来做方案选择,否则所有比较都丧失了统计意义。
6. 模拟数据集上的优化效果对比和我的实际使用建议
6.1 三组对照实验设置
为了验证这套做法到底有没有用,我在模拟数据上做了三组对照实验。所有实验共享同一份滑窗特征,只改变超参数选择方式:
| 实验 | 参数获得方式 | 其他设置 |
|---|---|---|
| 默认参数 | XGBoost 默认 n_estimators=100, max_depth=3, learning_rate=0.3 |
固定 random_state |
| 随机搜索 | 在相同边界内随机采样 150 个参数组合 | 用 TimeSeriesSplit 5 折评估 |
| PSO-XGBoost | 粒子群优化 + TimeSeriesSplit 5 折 | 10 个粒子,15 代,边界相同 |
测试集都是末尾 20% 的数据,模型训练只用前 80%。
6.2 结果展示
一组典型运行结果如下,仅供参考:
| 策略 | 交叉验证 RMSE | 测试集 RMSE | 测试集 MAE |
|---|---|---|---|
| XGBoost 默认参数 | 37.5 | 40.8 | 31.2 |
| 随机搜索 150 次 | 34.9 | 36.4 | 28.3 |
| PSO-XGBoost + 5折CV | 34.1 | 35.2 | 27.1 |
可以看到随机搜索已经比默认参数好了不少,但 PSO 的交叉验证 RMSE 和测试集 RMSE 更接近,说明它选出的参数在验证集上的乐观偏差更小。这个结果并不是说 PSO 每次一定比随机搜索好,如果随机搜索的次数足够多,两者最终会趋近相似。PSO 的核心优势是在同样的评估次数内能更集中地搜索低误差区域,并且每一轮都充分利用了历史评估信息。
6.3 哪些场景不适合用这套方案
虽然 PSO-XGBoost 调参很香,但不是所有场景都适合。
如果数据量只有几百行,跑一次 XGBoost 只要零点几秒,那用网格搜索或者手动调几组参数也完全够,PSO 的收益不明显,还会引入额外的算法复杂度和随机性。如果单次模型训练需要几十秒甚至几分钟,PSO 一次实验可能跑好几个小时,这种情况下应该优先考虑贝叶斯优化,因为贝叶斯优化需要的函数评估次数通常更少。如果特征是强序列关系,不重视外生变量,那你应该先考虑 LSTM 或时序 Transformer,而不是在 XGBoost 调参上花太多时间。
6.4 我的分阶段调参心得
根据个人经验,我一般会把 PSO-XGBoost 调参分成两个阶段。第一阶段用较小的种群和较少的迭代次数,比如 swarmsize=6, maxiter=8,搜索范围可以宽一些,目的是快速定位参数的大致区域。第二阶段拿到结果后,把搜索边界缩小到最优值附近,比如学习率边界从 [0.01, 0.2] 缩到 [0.02, 0.08],再跑一轮稍微大一点的种群,让算法在小范围里精修。
这个方法的好处是每轮实验时间短,不用一上来就默认跑一个巨大的搜索任务,跑完才发现初始边界根本设错了。我踩过的最大坑就是第一次直接把 n_estimators 上界设到 2000,等了几个小时,结果最优参数其实只落在 150 附近,白白浪费了大量算力。
6.5 一点最终建议
最后提醒一句:PSO 选出的参数是“在交叉验证约束下表现稳定”的参数,不等于未来的数据一定表现好。时序数据里还有概念漂移、季节模式变化、外部环境突变等因素,这些不是任何调参算法能解决的。PSO 能帮你的是把超参数选择带来的不确定性降到最低,让你有更多精力去处理特征工程、数据质量和模型监控。我现在的习惯是,每次跑完 PSO 后,会把最优参数、交叉验证 RMSE、测试集 RMSE 以及训练数据的时间范围一起记录下来,做成一个模型台账。下次数据更新后重新调参时,可以直观看出模型到底有没有真的进步,而不是凭感觉说这次“效果好了一些”。
