ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

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 日收盘出信号T1 开盘买入持有 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 等长的前瞻净收益末尾 horizon1 行为 NaN buy df[open].shift(-1) # T1 开盘买入 sell df[open].shift(-1 - horizon) # T1horizon 开盘卖出 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(dropTrue).copy() c, v g[close], g[volume] ret1 c.pct_change() out pd.DataFrame({trade_date: g[trade_date]}) for w in windows: out[fmom_{w}] c.pct_change(w) # 区间动量 out[fvol_{w}] ret1.rolling(w).std() # 已实现波动 out[fma_gap_{w}] c / c.rolling(w).mean() - 1.0 # 均线偏离 out[fvr_{w}] v / v.rolling(w).mean() # 量比 out[famp_{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: 已排序的交易日期序列 训练折与验证折之间挖掉 horizonembargo 天消除标签重叠泄漏 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 的参数空间要往「保守」方向压。深度不超过 6min_child_weight至少给到 20subsample和colsample_bytree都要小于 1学习率压到 0.03 左右配合早停。这些设置的共同目的是抑制模型对单样本的记忆。import xgboost as xgb PARAMS dict( objectivereg:squarederror, eval_metricrmse, max_depth4, # 日频数据 3~5 足够再深就是记样本 min_child_weight20, # 叶子最小样本权重抑制小样本叶子 subsample0.8, # 行采样增加折间多样性 colsample_bytree0.7, # 列采样缓解特征共线 eta0.03, # 小学习率 早停 reg_lambda2.0, # L2压系数幅度 reg_alpha0.5, # L1做一次稀疏化 tree_methodhist, # 大数据量下比 exact 快一个量级 nthread8, seed42, ) def train_xgb(X_tr, y_tr, X_va, y_va, paramsPARAMS, rounds2000): dtr xgb.DMatrix(X_tr, labely_tr) dva xgb.DMatrix(X_va, labely_va) booster xgb.train( params, dtr, num_boost_roundrounds, evals[(dva, valid)], early_stopping_rounds100, verbose_evalFalse, ) return booster, booster.best_iterationmax_depth4和min_child_weight20是这一组里最关键的两个。把它们调大训练集 RMSE 会明显下降验证集 IC 通常同步下降这是过拟合最直接的信号。eta0.03配合early_stopping_rounds100实际最优轮数往往落在 200 到 600 之间如果早停触发在 50 轮以内说明学习率偏大或者特征里有强泄漏。tree_methodhist在特征数上百、样本数上万时是必选项但要注意它默认的分箱策略对类别型特征不友好特征里如果有大量的离散取值得先手动做编码。早停用的验证集必须是最终评估之外的一折。如果拿测试集早停等于用测试集调了轮数后面的所有指标都不再可信这是黑匣子式评估最常见的来源。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(strategymedian)), # 中位数填充抗极值 (sc, StandardScaler()), # 标准化Ridge 对量纲敏感 (ridge, Ridge(alphaalpha)), # alpha 控制收缩强度 ])alpha10.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(axis1) ~np.isnan(y_all) # 3) 元学习器用线性回归 非负约束方向避免权重乱翻 meta LinearRegression(positiveTrue) meta.fit(Z[valid], y_all[valid]) print(dict(zip([xgb, lin], meta.coef_)))positiveTrue这个约束很实用。不加约束时元学习器在验证折上偶尔会给出负权重等价于「反着用某个模型」这种解在样本外极不稳定。加上非负约束后权重变成一种可信度分配通常 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, q5): 按预测值分 q 层统计每层平均前瞻收益 df df.copy() df[layer] df.groupby(date_col)[p_col].transform( lambda s: pd.qcut(s, q, labelsFalse, duplicatesdrop)) 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 天和第 T3 天的标签共享了部分未来信息。如果训练折的最后几天和验证折的最前几天的标签窗口重叠模型等于见过答案。解决训练折和验证折之间挖掉horizon embargo天。这个坑最隐蔽的地方在于它不会报错也不会让指标变难看只会让指标好看得不可信。5.3 元学习器把基模型的相关性当成了信号现象元学习器的权重在折间剧烈跳动某一折 XGBoost 权重 0.9下一折变成 0.1最终融合效果还不如单模型。原因两个基模型的 OOF 预测高度相关时元学习器的解空间是病态的微小的数据扰动就会让系数翻来覆去。加上样本量本来就少这个问题会被放大。解决加非负约束、加 L2 正则、限制只用两到三个基模型。如果两个模型的预测相关系数超过 0.9说明它们提供的信息重复没必要硬融直接留一个更稳的。5.4 停牌和涨跌停样本污染特征现象某些标的的预测值在特定日期出现极端异常回测里表现为几笔不合理的巨额收益。原因停牌期间的量价数据是复制或缺失的滚动窗口算出来的波动率接近零、量比无穷大。如果这些样本还带着正常的标签进入训练模型会学到「低波动高收益」这种伪关系。解决停牌日直接剔除或者在特征里加一个「可交易」标记并在训练时给这些样本零权重。涨跌停日同理T1 开盘买不进去的样本标签本身就是不可执行的。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, binsqs)[0] / len(base) 1e-6 c np.histogram(cur, binsqs)[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.9eta固定 0.03。把搜索空间压到这么小是因为股票数据上的验证集本身噪声很大大范围搜索找到的「最优」大概率是过拟合验证集的偶然结果。宁可少调也别调出一个只在某一段历史上成立的组合。还有一个技巧值得试把最终预测做一次横截面标准化再排序。树模型输出的绝对值分布在不同日期之间差异很大直接拿去分层会因为绝对值漂移导致层间样本数不均标准化之后分层变得稳定换手也会降下来。最后说个血泪经验。我最初做这类混合模型时花了大半时间在调 XGBoost 参数上从max_depth扫到grow_policy收益很有限真正让样本外表现稳定下来的是把切分纪律做扎实、把预处理塞进 Pipeline、把评估从 RMSE 换成 ICIR 加分层单调性这三件事。模型结构能带来的提升通常是个位数百分比而一个泄漏能让你所有努力归零且你根本看不出来。先信数据切分再信模型这个顺序别搞反。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表