ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XGBoost混合模型股票预测:Stacking、防泄漏与落地避坑

XGBoost混合模型股票预测:Stacking、防泄漏与落地避坑

简介:围绕XGBoost混合模型在股票价格预测中的应用,这份工程硕士学位论文面向金融机器学习初学者、软件工程与模式识别方向的研究生,以及需要复现股票预测实验的开发者,系统讨论单一模型难以刻画市场非线性关系时的集成建模思路。压缩包仅含1个PDF文件,约3.48MB,完整呈现绪论、相关概念与理论基础、集成学习与时间序列模型、XGBoost建模、混合模型设计、实验评价与结果分析等章节。读者可从中了解决策树、CART、GBDT到XGBoost的原理脉络,以及AR、MA、ARMA、ARIMA等时间序列方法的适用边界;还能参考特征工程、参数调整、与随机森林、支持向量机等模型集成的方案,并借助MSE、MAE、R²等指标对比预测效果。论文同时讨论过拟合等局限与深度集成、动态权重调整等改进方向,已有657人学习,适合用作课程论文、毕业设计或股票预测实验的框架参考。

1. 股票预测里,XGBoost 混合模型到底在混什么

单跑一个 XGBoost 做日频股票预测,是很多人入门的第一个动作:特征扔进去,reg:squarederror一开,训练集拟合得像模像样,一到样本外,RankIC 掉到小数点后第二位,分层收益的单调性也没了。问题不在树不够多,而在于树模型擅长的是有明确边界的非线性交互,股票收益率却是低信噪比、强非平稳、几乎没有稳定边界的东西。混合模型的出发点就是把预测任务拆开:让树模型处理交互和分段效应,让线性或时序支路处理单调性、趋势和残差结构,最后由一个元学习器决定信谁多一点。这套思路适合已经能把单模型跑通、但卡在样本外衰减上的从业者,也适合想把因子研究和机器学习接起来的人。下面的路线、参数和踩坑记录,都是按能复现的口径写的。

2. 三条混合路线:残差修正、特征分工、Stacking 怎么选

2.1 为什么单靠 XGBoost 在日频股票上会撞墙

树模型的输出是一堆叶子的分段常数,本质上是阶梯函数。它对「特征落在哪个区间」极其敏感,但对「特征往哪个方向连续走」几乎没有外推能力。股票预测里最典型的一类信号恰恰是连续的:动量在某个区间内单调增强,波动率放大到一定程度后收益分布变厚尾。这类关系用树去切,会被切成几十个格子,每一格在训练集里都够样本,在样本外全都靠不住。

第二个问题是信噪比。日频横截面上,可解释的收益差异通常只有千分之几的量级,而噪声是它的几十倍。XGBoost 的加法树结构在低信噪比数据上有很强的记忆倾向:max_depth稍大一点、min_child_weight稍小一点,模型就开始记住某只股票在某段行情里的偶然模式。表现出来就是训练集 RMSE 一路下降,验证集 IC 横盘甚至下滑。

第三个问题是非平稳。特征分布会随时间漂移,今天是「低波动占优」,两个月后变成「高换手占优」。一棵在三年数据上长出来的树,叶子里的样本均值反映的是这三年的平均市场状态,市场风格一切换,这些叶子均值就集体失效。线性模型至少还有一个系数符号可以解释,树模型连这个抓手都没有,这也是很多人觉得 XGBoost 预测股票像个黑匣子的原因。

把这三件事摆在一起,混合模型的价值就清楚了:用线性支路兜住单调性和外推,用树支路抓交互和分段,再用一层融合把两者的误差结构互补掉。

2.2 三种混合范式的对比与选型

从业者常见的做法有三类,差异在于「混在哪一层」。

范式怎么混适合的场景主要失效模式
残差修正先线性/时序模型拟合,XGBoost 学残差有强基线、残差结构明显基线错了,残差学成噪声
特征分工手工把特征分成线性组和交互组,分别喂两个模型,加权平均因子体系清晰、可解释性要求高分组靠拍脑袋,权重难定
Stacking多个基模型出 OOF 预测,元学习器融合样本量够、切分严谨元层过拟合、泄漏放大

残差修正这条路最轻,适合已有 ARIMA 或线性多因子基线的团队。它的隐含假设是基线已经抓住了主要结构,剩下的残差里有可学的成分。实际做下来,股票收益的残差大部分是真噪声,XGBoost 学到的往往是微弱的波动率残差而非方向残差,所以这条路在方向预测上收益有限,在波动率预测上反而更靠谱。

特征分工介于两者之间,好处是每个子模型都能单独解释和调参,坏处是需要人工决定哪些特征归哪边,而这个决定本身就是一个超参数。如果特征体系里有明显的单调因子(估值、动量)和明显的交互因子(量价背离、波动率与换手的组合),分组会相对清晰。

Stacking 是通用性最好的一种,代价是对数据切分极其敏感。元学习器看到的输入是基模型在验证折上的预测,一旦基模型的 OOF 预测里混进了未来信息,元学习器会把这些信息当成强特征放大,线下指标会好得反常,实盘立刻打回原形。这条路的门槛不在模型,在切分纪律。

2.3 我一般选 Stacking,以及它成立的前提

默认组合是:XGBoost 作为主基模型吃全量特征,Ridge 或偏线性的模型作为第二基模型吃标准化后的连续特征,元学习器用带 L2 的线性回归而不是第二个树模型。原因很直接:如果元学习器也用树,等于让树在树的残差上再拟合一遍,两个模型的误差高度相关,融合的边际收益被吃掉,还多了一层过拟合风险。线性元学习器的作用更像是在做一个受约束的加权平均,系数可读,正负号能告诉你哪个基模型在什么阶段更可信。

这个组合成立需要三个前提。样本量方面,单标的日频三年数据大概七百多个点,做 Stacking 偏少,通常要放到横截面多标的上,或者把预测周期拉长到 5 日、10 日,让有效样本变多。特征稳定性方面,如果特征里有大量依赖财报披露的字段,季报期的缺失和跳变会污染 OOF 预测,得先做对齐。切分方面,必须有 purge 和 embargo,否则标签重叠会直接把未来信息漏进训练折。

3. 数据与特征:把量价数据做成 XGBoost 能吃的矩阵

3.1 标签构造:别拿收盘价做回归目标

用收盘价序列做回归是新手最容易踩的坑。价格是非平稳的,量纲随时间和标的完全不同,模型学到的其实是「这只股票大概多少钱」,而不是「它接下来会涨还是会跌」。正确的口径是构造一个可交易的前瞻收益:T 日收盘出信号,T+1 开盘买入,持有 N 个交易日后开盘卖出,扣掉双边成本。这样做的好处是标签和实际执行一致,回测和实盘的差距会小很多。

import numpy as np import pandas as pd def make_label(df: pd.DataFrame, horizon: int = 5, cost: float = 0.0015) -> pd.Series: """ df 需按 trade_date 升序,含 open 列 horizon: 持有天数 cost: 单边成本合计(佣金+滑点+冲击),双边按 2*cost 计 返回: 与 df 等长的前瞻净收益,末尾 horizon+1 行为 NaN """ buy = df["open"].shift(-1) # T+1 开盘买入 sell = df["open"].shift(-1 - horizon) # T+1+horizon 开盘卖出 fwd = sell / buy - 1.0 - 2 * cost # 去极值:避免个别异常值主导损失函数 lo, hi = fwd.quantile(0.01), fwd.quantile(0.99) return fwd.clip(lo, hi)

这段的关键在shift(-1)和shift(-1-horizon)的错位。如果写成close.pct_change(horizon),等于假设 T 日收盘就能成交,回测会凭空多出一段收益。clip用分位数而不是固定阈值,是因为不同标的的波动量级差很多,固定阈值在小盘股上会把有效信号一起剪掉。

如果做横截面模型,标签还要再做一步处理:在每个交易日内对所有标的的fwd做排序,转成 rank 或 zscore。这样标签里就剥离了市场整体涨跌,模型学的是「相对强弱」,而不是「明天大盘涨不涨」。这一步对 IC 的稳定性影响很大。

3.2 特征清单与实现

量价特征够用,不必一上来就上另类数据。核心是四组:动量、波动、量能、形态位置。下面是单标的版本的实现,横截面版本把groupby换成按日聚合即可。

def build_features(df: pd.DataFrame, windows=(5, 10, 20, 60)) -> pd.DataFrame: """ df: 单标的日线,升序,含 open/high/low/close/volume/amount/turnover 所有特征只使用 T 日及之前的信息 """ g = df.sort_values("trade_date").reset_index(drop=True).copy() c, v = g["close"], g["volume"] ret1 = c.pct_change() out = pd.DataFrame({"trade_date": g["trade_date"]}) for w in windows: out[f"mom_{w}"] = c.pct_change(w) # 区间动量 out[f"vol_{w}"] = ret1.rolling(w).std() # 已实现波动 out[f"ma_gap_{w}"] = c / c.rolling(w).mean() - 1.0 # 均线偏离 out[f"vr_{w}"] = v / v.rolling(w).mean() # 量比 out[f"amp_{w}"] = (g["high"].rolling(w).max() - g["low"].rolling(w).min()) / c # 区间振幅 # 形态位置:收盘价在近 60 日高低区间中的相对位置 hi60 = g["high"].rolling(60).max() lo60 = g["low"].rolling(60).min() out["pos_60"] = (c - lo60) / (hi60 - lo60 + 1e-9) # 量价背离:短期动量与短期量比的符号一致性 out["pv_div"] = np.sign(out["mom_5"]) * np.sign(out["vr_5"] - 1.0) # 收益率偏度与峰度,刻画尾部结构 out["skew_20"] = ret1.rolling(20).skew() out["kurt_20"] = ret1.rolling(20).kurt() # 换手稳定性 if "turnover" in g.columns: out["to_std_20"] = g["turnover"].rolling(20).std() out["to_z_20"] = ((g["turnover"] - g["turnover"].rolling(20).mean()) / (g["turnover"].rolling(20).std() + 1e-9)) return out

参数上的取舍值得说清楚。windows取 5/10/20/60 是覆盖一周、两周、一月、一季四个常用周期,再加窗口只会增加共线性。pos_60这类位置特征对树模型特别友好,因为它天然是分段有效的,正好落在树的强项上;而mom_w这种连续动量给线性支路更合适,这也为后面的特征分工留了口子。

偏度和峰度用 20 日窗口,是因为更短的窗口算出来的高阶矩抖动太大,更长的窗口反应太慢。这两个特征在树模型里通常能排进前二十,但要注意它们对异常值极其敏感,得在入模前统一做缩尾。

3.3 时间切分与防泄漏

随机 KFold 在时间序列上是灾难。原因有两层:一是标签本身跨越了未来 N 天,训练折里的样本和验证折里的样本在时间上重叠;二是特征里的滚动窗口也带着过去的信息,如果验证折紧贴训练折,rolling 统计量会把两折连起来。常见做法是 walk-forward 加 purge 加 embargo。

def walk_forward_splits(dates: pd.Series, n_splits: int = 5, horizon: int = 5, embargo: int = 5): """ dates: 已排序的交易日期序列 训练折与验证折之间挖掉 horizon+embargo 天,消除标签重叠泄漏 """ uniq = np.sort(dates.unique()) fold_size = len(uniq) // (n_splits + 1) for i in range(1, n_splits + 1): train_end = i * fold_size val_start = train_end + horizon + embargo val_end = min(val_start + fold_size, len(uniq)) if val_start >= len(uniq): break tr_dates = uniq[:train_end] va_dates = uniq[val_start:val_end] yield (dates.isin(tr_dates), dates.isin(va_dates))

horizon和embargo这两个参数不是随手填的。horizon必须等于标签的持有期,因为标签在时间上跨越了这么多天;embargo是为了处理特征侧的滚动窗口,理论上等于最长窗口长度,实操里取 5 到 20 天已经能压掉大部分泄漏。设得太大会浪费样本,尤其在样本本来就少的情况下,需要在这两者之间反复试。

提示:如果只做单标的,walk-forward 的折数不要贪多。五折之后每折验证集只剩一百多个点,IC 的置信区间宽到没有判断价值,不如把折数降到三折、持有期拉长到 10 日。

4. 混合模型落地:XGBoost + 线性支路的 Stacking 实现

4.1 XGBoost 基模型的参数怎么定

股票数据上,XGBoost 的参数空间要往「保守」方向压。深度不超过 6,min_child_weight至少给到 20,subsample和colsample_bytree都要小于 1,学习率压到 0.03 左右配合早停。这些设置的共同目的是抑制模型对单样本的记忆。

import xgboost as xgb PARAMS = dict( objective="reg:squarederror", eval_metric="rmse", max_depth=4, # 日频数据 3~5 足够,再深就是记样本 min_child_weight=20, # 叶子最小样本权重,抑制小样本叶子 subsample=0.8, # 行采样,增加折间多样性 colsample_bytree=0.7, # 列采样,缓解特征共线 eta=0.03, # 小学习率 + 早停 reg_lambda=2.0, # L2,压系数幅度 reg_alpha=0.5, # L1,做一次稀疏化 tree_method="hist", # 大数据量下比 exact 快一个量级 nthread=8, seed=42, ) def train_xgb(X_tr, y_tr, X_va, y_va, params=PARAMS, rounds=2000): dtr = xgb.DMatrix(X_tr, label=y_tr) dva = xgb.DMatrix(X_va, label=y_va) booster = xgb.train( params, dtr, num_boost_round=rounds, evals=[(dva, "valid")], early_stopping_rounds=100, verbose_eval=False, ) return booster, booster.best_iteration

max_depth=4和min_child_weight=20是这一组里最关键的两个。把它们调大,训练集 RMSE 会明显下降,验证集 IC 通常同步下降,这是过拟合最直接的信号。eta=0.03配合early_stopping_rounds=100,实际最优轮数往往落在 200 到 600 之间;如果早停触发在 50 轮以内,说明学习率偏大或者特征里有强泄漏。

tree_method="hist"在特征数上百、样本数上万时是必选项,但要注意它默认的分箱策略对类别型特征不友好,特征里如果有大量的离散取值,得先手动做编码。

早停用的验证集必须是最终评估之外的一折。如果拿测试集早停,等于用测试集调了轮数,后面的所有指标都不再可信,这是黑匣子式评估最常见的来源。

4.2 线性支路:为什么用 Ridge 而不是再上一个树

线性支路的作用不是「再多一个模型」,而是「提供一种树模型给不出的误差结构」。Ridge 对连续特征给出的是全局线性映射,它的预测误差和 XGBoost 的误差相关性通常在 0.5 到 0.7 之间,留出了融合空间。如果把第二支路换成 LightGBM,两个模型的误差相关性会到 0.85 以上,融合几乎不涨点。

线性支路对输入有要求:必须标准化,必须处理共线,必须做缺失填充。

from sklearn.linear_model import Ridge from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.impute import SimpleImputer def build_linear_branch(alpha: float = 10.0) -> Pipeline: return Pipeline([ ("imp", SimpleImputer(strategy="median")), # 中位数填充,抗极值 ("sc", StandardScaler()), # 标准化,Ridge 对量纲敏感 ("ridge", Ridge(alpha=alpha)), # alpha 控制收缩强度 ])

alpha=10.0是一个起点而不是终点。特征数在 50 到 100 之间时,alpha 从 1 扫到 100,验证集 IC 会有一个明显的平台区,取平台区中间的值比取峰值更稳。中位数填充在这里比均值填充好,是因为量价特征的缺失往往和停牌、涨跌停有关,这类样本的分布本身就偏。

如果要进一步提升非线性能力,可以在 Ridge 前面加一层多项式交互,但要严格限制阶数,二阶以上在股票数据上几乎必然过拟合。

4.3 元学习器与 OOF 融合

Stacking 的核心纪律:基模型的预测必须在同一套时间切分下生成 OOF,元学习器在 OOF 上学,最终模型在全量训练集上重训。

import numpy as np from sklearn.linear_model import LinearRegression def stack_oof(X, y, dates, model_factory, splits): """ 返回每个样本的 OOF 预测(每个样本只在它被当作验证集时产生一次预测) """ oof = np.full(len(X), np.nan) for tr_mask, va_mask in splits: model = model_factory() model.fit(X[tr_mask], y[tr_mask]) oof[va_mask] = model.predict(X[va_mask]) return oof # 1) 两路基模型各自产出 OOF oof_xgb = stack_oof(X_all, y_all, d_all, lambda: XGBWrapper(PARAMS), splits) oof_lin = stack_oof(X_all, y_all, d_all, lambda: build_linear_branch(10.0), splits) # 2) 元特征矩阵:只用两路基模型的预测 Z = np.column_stack([oof_xgb, oof_lin]) valid = ~np.isnan(Z).any(axis=1) & ~np.isnan(y_all) # 3) 元学习器用线性回归 + 非负约束方向,避免权重乱翻 meta = LinearRegression(positive=True) meta.fit(Z[valid], y_all[valid]) print(dict(zip(["xgb", "lin"], meta.coef_)))

positive=True这个约束很实用。不加约束时,元学习器在验证折上偶尔会给出负权重,等价于「反着用某个模型」,这种解在样本外极不稳定。加上非负约束后,权重变成一种可信度分配,通常 XGBoost 拿 0.6 到 0.8,线性支路拿 0.2 到 0.4,随市场状态小幅波动。

valid那一行的过滤不能省。OOF 在每折边界上会有一批 NaN(被 purge 掉的样本),直接喂给元学习器会让它报错或者悄悄丢掉行,导致元学习器的训练样本和最终模型不一致。

4.4 评估:IC、分层和成本三件事一起看

只看 RMSE 在这类任务上没有意义,因为收益分布厚尾,均方误差被少数极端值主导。至少要看三个指标:RankIC 的均值和标准差、分层收益的单调性、扣成本后的净值。

from scipy.stats import spearmanr def rank_ic(y_true, y_pred): return spearmanr(y_true, y_pred).correlation def ic_series(df, date_col, y_col, p_col): """按日计算横截面 RankIC,返回序列""" return (df.groupby(date_col) .apply(lambda g: rank_ic(g[y_col], g[p_col])) .dropna()) def layered_return(df, date_col, y_col, p_col, q=5): """按预测值分 q 层,统计每层平均前瞻收益""" df = df.copy() df["layer"] = df.groupby(date_col)[p_col].transform( lambda s: pd.qcut(s, q, labels=False, duplicates="drop")) return df.groupby("layer")[y_col].mean()

评估上有两个细节容易漏。第一,IC 要看均值除以标准差,也就是 ICIR,单看均值会被少数高 IC 的月份带偏。第二,分层收益要看单调性而不只看多空差,如果中间层是乱的、只有头尾分开,说明模型抓的是极端值而不是整体排序,这种信号在实盘里换手会非常高。

成本必须进评估。日频策略双边成本按千分之一到千分之三算,5 日持有期下,如果多空组合的月换手超过 200%,成本能吃掉大半超额。很多混合模型在线下看着比单模型好,扣完成本就不剩什么了,这也是选型时最该盯的一点。

5. 避坑与排查:混合模型最容易翻车的五个地方

5.1 全样本标准化导致线下虚高

现象:离线 RankIC 稳定在 0.08 以上,换一段样本外直接掉到 0.01,重训也救不回来。

原因:标准化、缺失填充、去极值这些预处理用了全样本统计量,验证折的均值和方差里混进了未来数据。特征被「提前知道」了未来的分布,模型学到的关系在真实时序里不存在。

解决:所有预处理必须放进 Pipeline,在每个训练折内单独fit,然后transform验证折。去极值的分位数也一样,只能在训练折上算。改完之后线下 IC 通常会掉三到五成,但样本外的衰减会明显变缓。

5.2 标签重叠没做 purge

现象:交叉验证分数很高,但把模型放到完全没参与训练的最后一折上,表现断崖式下跌。

原因:5 日持有期的标签在时间上重叠,第 T 天和第 T+3 天的标签共享了部分未来信息。如果训练折的最后几天和验证折的最前几天的标签窗口重叠,模型等于见过答案。

解决:训练折和验证折之间挖掉horizon + embargo天。这个坑最隐蔽的地方在于,它不会报错,也不会让指标变难看,只会让指标好看得不可信。

5.3 元学习器把基模型的相关性当成了信号

现象:元学习器的权重在折间剧烈跳动,某一折 XGBoost 权重 0.9,下一折变成 0.1,最终融合效果还不如单模型。

原因:两个基模型的 OOF 预测高度相关时,元学习器的解空间是病态的,微小的数据扰动就会让系数翻来覆去。加上样本量本来就少,这个问题会被放大。

解决:加非负约束、加 L2 正则、限制只用两到三个基模型。如果两个模型的预测相关系数超过 0.9,说明它们提供的信息重复,没必要硬融,直接留一个更稳的。

5.4 停牌和涨跌停样本污染特征

现象:某些标的的预测值在特定日期出现极端异常,回测里表现为几笔不合理的巨额收益。

原因:停牌期间的量价数据是复制或缺失的,滚动窗口算出来的波动率接近零、量比无穷大。如果这些样本还带着正常的标签进入训练,模型会学到「低波动=高收益」这种伪关系。

解决:停牌日直接剔除,或者在特征里加一个「可交易」标记,并在训练时给这些样本零权重。涨跌停日同理,T+1 开盘买不进去的样本,标签本身就是不可执行的。

5.5 早停用错了数据集

现象:调参时验证集指标一路上升,最后确定的轮数在测试集上完全不 work。

原因:早停监控的是验证集,而验证集如果同时被用来选超参、选轮数、选特征,它事实上已经变成了训练集的一部分。多轮迭代下来,模型对验证集的过拟合会非常严重。

解决:切三段——训练、早停验证、最终测试。早停只看中间那段,测试段在整个调参过程中不打开,只在最后评估时用一次。

6. 把混合模型做稳:滚动重训、漂移监控与参数搜索边界

模型训练完之后,真正决定长期表现的是维护策略。股票数据的分布漂移是持续的,一个固定参数的 XGBoost 混合模型,如果半年不重训,IC 衰减会非常明显。我一般用滚动重训:每次用最近 3 年数据训练,按月或按季度重跑一次,重训时保留上一版的超参,只重训权重和树结构。这样既跟上了分布变化,又避免了每次重训都重新调参带来的不稳定。

漂移监控用一个很朴素的指标就够:特征分布的 PSI,按月计算当前月与训练窗口的分布差异,PSI 超过 0.25 的特征单独标记出来。如果连续两个月有超过三成特征触发,说明市场状态已经偏离训练窗口,该提前触发重训而不是等固定周期。

def psi(base: np.ndarray, cur: np.ndarray, bins: int = 10) -> float: """群体稳定性指数,衡量特征分布漂移,>0.25 视为显著漂移""" qs = np.quantile(base, np.linspace(0, 1, bins + 1)) qs[0], qs[-1] = -np.inf, np.inf b = np.histogram(base, bins=qs)[0] / len(base) + 1e-6 c = np.histogram(cur, bins=qs)[0] / len(cur) + 1e-6 return float(np.sum((c - b) * np.log(c / b)))

参数搜索的边界也该收窄。max_depth在 3 到 6 之间扫,min_child_weight在 10 到 50 之间扫,subsample和colsample_bytree固定在 0.7 到 0.9,eta固定 0.03。把搜索空间压到这么小,是因为股票数据上的验证集本身噪声很大,大范围搜索找到的「最优」大概率是过拟合验证集的偶然结果。宁可少调,也别调出一个只在某一段历史上成立的组合。

还有一个技巧值得试:把最终预测做一次横截面标准化再排序。树模型输出的绝对值分布在不同日期之间差异很大,直接拿去分层会因为绝对值漂移导致层间样本数不均;标准化之后,分层变得稳定,换手也会降下来。

最后说个血泪经验。我最初做这类混合模型时,花了大半时间在调 XGBoost 参数上,从max_depth扫到grow_policy,收益很有限;真正让样本外表现稳定下来的,是把切分纪律做扎实、把预处理塞进 Pipeline、把评估从 RMSE 换成 ICIR 加分层单调性这三件事。模型结构能带来的提升通常是个位数百分比,而一个泄漏能让你所有努力归零,且你根本看不出来。先信数据切分,再信模型,这个顺序别搞反。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进