ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于DWT-ARIMA与GS-XGBoost的股票预测混合模型实践

基于DWT-ARIMA与GS-XGBoost的股票预测混合模型实践

简介:面向金融预测与机器学习实践者的硕士学位论文PDF,围绕XGBoost混合模型如何提升股票价格预测精度展开,适合研究生、算法工程师和量化研究初学者参考。正文从股票预测理论与集成学习基础切入,梳理决策树、CART、GBDT、XGBoost以及AR、MA、ARMA、ARIMA等时间序列模型,并讨论特征工程、参数调优、混合模型构建及MSE、MAE、R²等评价指标,实验部分对比不同模型的预测表现与稳定性。压缩包内含1个PDF文件,大小约3.48MB,便于集中阅读与归档。已有657人学习,可作为论文写作、课程研究或股票预测项目选型的案例材料,帮助读者把握XGBoost混合建模的整体思路、实验组织方式和过拟合等局限性的讨论。

1. 把 XGBoost 直接怼在收盘价上,为什么大概率会翻车

把 XGBoost 直接套在收盘价上做回归,是多数人复现股票预测论文的第一步,然后十有八九得到一条比"明天等于今天"还差的曲线。这份《基于 XGBoost 的混合模型在股票预测中的应用研究》要解决的正是这个尴尬:单模型抓不住的东西,靠分解和分层建模补回来。它把价格序列拆成低频趋势和高频波动,低频交给 ARIMA 处理线性成分,高频交给网格搜索优化过的 XGBoost 处理非线性成分,最后重构回价格。适合已经会用 sklearn、想搞清楚时间序列混合模型到底怎么落地的人,也适合要交课程设计、需要一个能跑通的预测基线的人。后面按 DWT 分解、GS 调参、融合、验收的顺序拆,每一步都给可抄的代码和参数。

2. 拆开混合模型的骨架:DWT 分层为什么是这套方案的地基

这套模型能被复现出来,关键不在 XGBoost 本身,而在前面那道分解工序。原始收盘价是一条非平稳、带趋势、带噪声、还夹着突发跳空的序列,直接喂给任何回归器都是在让模型同时学三件互相冲突的事。离散小波变换(DWT)的价值就是把一条序列拆成"慢变量"和"快变量",让 ARIMA 和 XGBoost 各自只面对自己擅长的成分。理清这一层,后面的调参和融合才不会变成盲调。

2.1 小波基、分解层数、边界模式的选型逻辑

pywt.wavedec返回的是一个系数列表,结构是[cA_n, cD_n, cD_{n-1}, ..., cD_1],其中cA_n是第 n 层的低频近似,cD_i是第 i 层的细节。低频近似保留了价格的趋势骨架,细节层保留的是不同时间尺度上的波动。层数越高,cA越平滑,但边界效应也越明显,重构后两端容易翘起来。

选型上我一般这么定:小波基用db4或sym4,因为股票日线序列不是光滑信号,消失矩太高反而会把真实的跳空当成噪声削掉;分解层数取 3 层,日线数据一年 240 多个点,3 层大致对应 2 日、4 日、8 日尺度的波动,再往上分解出来的细节层基本都是白噪声,建模收益很低;边界模式用symmetric,它比periodization温和,periodization会把序列首尾强行接起来,遇到停牌缺口时会在末端制造虚假的周期性。

先跑通分解—重构闭环,确认重构误差接近浮点精度,再往下做任何事:

import numpy as np import pywt def dwt_decompose(series, wavelet="db4", level=3, mode="symmetric"): """把一维价格序列分解为 [cA_n, cD_n, ..., cD_1]""" arr = np.asarray(series, dtype=float) return pywt.wavedec(arr, wavelet=wavelet, mode=mode, level=level) def dwt_reconstruct(coeffs, wavelet="db4", mode="symmetric"): """把系数列表还原回时间域""" return pywt.waverec(coeffs, wavelet=wavelet, mode=mode) close = np.asarray(df["close"].values, dtype=float) coeffs = dwt_decompose(close, wavelet="db4", level=3, mode="symmetric") cA3, cD3, cD2, cD1 = coeffs recon = dwt_reconstruct(coeffs)[: len(close)] print("重构最大绝对误差:", np.max(np.abs(recon - close))) print("各层系数长度:", [len(c) for c in coeffs])

这里要盯两个输出。第一个是重构误差,正常情况下应该在1e-10量级,如果误差到了小数点后两位,说明level给得太高或者序列长度不是2**level的整数倍。第二个是系数长度,pywt在symmetric模式下每层会补边,所以cA3的长度通常比len(close)/8稍长,后面做预测时一定要按原始长度截断,否则拼回去会错位,这个错位在图上表现为整条预测曲线平移几格,非常难查。

阈值去噪是可选项,但对高频层有用。软阈值比硬阈值平滑,不会在重构后留下台阶状伪影:

def denoise_detail(cD, mode="soft"): """对细节系数做通用阈值收缩,sigma 用 MAD 稳健估计""" sigma = np.median(np.abs(cD)) / 0.6745 thr = sigma * np.sqrt(2 * np.log(max(len(cD), 2))) return pywt.threshold(cD, thr, mode=mode), thr cD1_dn, thr1 = denoise_detail(cD1)

0.6745是正态分布下 MAD 与标准差之间的换算常数,别写成 0.67 或者 0.68,虽然影响很小,但复现别人结果时对不上数会怀疑到自己写的整个流程。阈值公式里的sqrt(2*log(n))是通用阈值(universal threshold),n 取系数长度;如果高频层本身能量就很低,这个阈值可能把整层系数压成 0,遇到这种情况就把阈值乘个 0.5 的缩放因子,或者干脆跳过这一层去噪。

参数汇总一下,方便对照实验:

参数建议值取值范围影响
waveletdb4 / sym4db2~db8, sym2~sym8基函数越光滑,边界振铃越明显
level32~5层数过高会放大小样本的边界效应
modesymmetricsymmetric / periodization / zero决定序列首尾的延拓方式
阈值规则universaluniversal / minimax决定细节层被压缩的程度
threshold modesoftsoft / hardhard 会在重构后留台阶

2.2 ARIMA 只吃低频近似分量:定阶的完整流程

分解完,低频近似cA3是趋势项,交给 ARIMA。这一步最常见的错误是拿原始价格去定阶,然后又把阶数套到cA3上——两者的自相关结构完全不同。正确顺序是先对cA3做 ADF 检验确定差分阶数 d,再用 AIC 网格搜 p 和 q。

import warnings import pandas as pd import statsmodels.api as sm from statsmodels.tsa.stattools import adfuller def adf_pvalue(x): stat, p, *_ = adfuller(np.asarray(x, dtype=float), autolag="AIC") return stat, p def infer_d(x, max_d=2, alpha=0.05): """逐阶差分直到 ADF 拒绝单位根,返回最小可行 d""" cur = np.asarray(x, dtype=float) for d in range(max_d + 1): stat, p = adf_pvalue(cur) if p < alpha: return d, stat, p cur = np.diff(cur) return max_d, stat, p def order_by_aic(x, d, p_max=5, q_max=5): best_aic, best_order = np.inf, None for p in range(p_max + 1): for q in range(q_max + 1): if p == 0 and q == 0: continue try: with warnings.catch_warnings(): warnings.simplefilter("ignore") model = sm.tsa.ARIMA(np.asarray(x, dtype=float), order=(p, d, q)) res = model.fit() if res.aic < best_aic: best_aic, best_order = res.aic, (p, d, q) except Exception: continue return best_order, best_aic d_opt, stat, p_val = infer_d(cA3) order, aic = order_by_aic(cA3, d_opt, p_max=5, q_max=5) print("ADF stat=%.3f p=%.4f d=%d 最优阶=%s AIC=%.2f" % (stat, p_val, d_opt, order, aic))

infer_d的 alpha 取 0.05,max_d卡在 2,因为 d 再高就是在对噪声差分,预测出来会剧烈震荡。p_max和q_max都给 5,一共 35 个组合,每个组合都是一次完整拟合,在几千条日线数据上跑一两分钟能出结果。这里必须用warnings.catch_warnings兜住收敛告警,否则控制台会被 "Maximum Likelihood optimization failed to converge" 刷满,真正的报错反而看不见。

有一个细节值得单独说:statsmodels的ARIMA在p+q+d组合下偶尔会给出 AIC 极低但系数不显著的模型,尤其在序列尾部有跳空时。所以定完阶之后,我习惯再看一眼res.summary()里的P>|z|,如果大部分系数都不显著,就把p和q各降一档,宁可欠拟合也别过拟合——ARIMA 在这套混合模型里只负责趋势,趋势预测偏一点,后面 XGBoost 补残差还能救回来;趋势被过拟合带偏,后面全歪。

2.3 高频细节交给 XGBoost:特征和标签的对齐规则

高频细节cD1(或者cD1 + cD2)是波动项,交给 GS-XGBoost。这里的核心不是模型,而是对齐:第 t 期的特征,预测的是第 t+1 期的细节分量值。方向搞反就是典型的未来函数,线下指标漂亮得离谱,上线一跑全崩。

特征集我一般控制在十几维,太多维在几百条样本上纯属自找过拟合:

特征组具体字段计算方式备注
滞后收益率ret_lag1~5close.pct_change().shift(k)必须 shift,别用当期的
趋势偏离ma5_dev, ma20_devclose / close.rolling(n).mean() - 1反映偏离均线程度
波动率vol_5, vol_20收益率滚动标准差波动聚集性的代理
强弱指标rsi_14经典 RSI 递推取值 0~100,做归一化
量能vol_ratio成交量 / 成交量 5 日均值无量纲
振幅amp(high - low) / pre_close日内波动幅度
def build_features(df, lags=(1, 2, 3, 5, 10)): out = df.copy().sort_index() ret = out["close"].pct_change() for k in lags: out[f"ret_lag{k}"] = ret.shift(k) for n in (5, 20): out[f"ma{n}_dev"] = out["close"] / out["close"].rolling(n).mean() - 1 out[f"vol{n}"] = ret.rolling(n).std() delta = out["close"].diff() up = delta.clip(lower=0).rolling(14).mean() down = (-delta.clip(upper=0)).rolling(14).mean() out["rsi_14"] = 100 - 100 / (1 + up / (down + 1e-9)) / 100 out["vol_ratio"] = out["volume"] / out["volume"].rolling(5).mean() out["amp"] = (out["high"] - out["low"]) / out["close"].shift(1) out = out.dropna() return out feat = build_features(df) feat["y_detail"] = pd.Series(cD1[: len(feat)], index=feat.index).shift(-1) # 预测下一期细节 feat = feat.dropna(subset=["y_detail"])

三个点必须强调。第一,pct_change()之后一定要shift(k),k从 1 开始,因为当期收益率在收盘时才知道,用它预测当期是自欺欺人。第二,RSI 那一行的分母加了1e-9,否则连续跌停段会出现除零,inf会顺着特征矩阵传到 XGBoost 里,报错信息只说数据里有非有限值,不会告诉你哪一列。第三,y_detail用shift(-1)把第 t+1 期的分量挪到第 t 行,这个方向只要写反,模型就是在用明天预测今天。

3. GS-XGBoost 调参:网格搜索的搜索空间与算力预算

XGBoost 的参数多,但真正影响这套混合模型上限的只有四五个。盲目开一个大网格,跑一整天,最后收益可能还不如把交叉验证方式改对。这部分按"先定搜索空间、再定验证策略、最后拼代码"三步走。

3.1 参数敏感度排序与网格区间设定

我的经验排序是:n_estimators与learning_rate强耦合,放第一组;max_depth与min_child_weight决定树的复杂度,放第二组;subsample和colsample_bytree是行、列采样,主要作用是降方差,收益通常在 1% 以内;reg_lambda和reg_alpha是 L2/L1 正则,只有在前几组定下来之后调才有意义,提前调会被其他参数的噪声淹没。

在几百到几千条样本这个量级上,我用的区间是这样:

参数粗搜区间精搜区间说明
learning_rate0.05, 0.1, 0.20.03, 0.05, 0.08越小需要的树越多,训练时间线性上涨
n_estimators100, 300, 600在最优值 ±30% 内取 3 档和 learning_rate 联动,别单独调
max_depth3, 4, 63, 4, 5细节分量本身噪声大,深树容易记住噪声
min_child_weight1, 51, 3, 5样本少时调大能明显抑制过拟合
subsample0.80.7, 0.8, 0.9低于 0.6 会欠拟合
colsample_bytree0.80.7, 0.8, 1.0特征只有十几维,不必压太低
reg_lambda10.5, 1, 5L2,抑制叶子权重过大

粗搜组合数是 3×3×3×2×1×1×1 = 54,精搜在粗搜最优点附近再做 3×3×3×3 = 81 组,加起来 135 组,比一上来就开一千组要快一个数量级,结果通常不差。

有个容易被忽略的参数是objective。细节分量是连续值,用reg:squarederror;但如果先把目标转成"涨/跌"分类再去做分解建模,就得换成binary:logistic并把评价指标同步换成 AUC。混用会导致GridSearchCV选出来的最优参数实际上是按错误的损失挑的。

3.2 用 TimeSeriesSplit 替代 KFold,别让验证集偷看未来

这是整套流程里最容易被跳过、又最容易毁掉结果的一步。KFold(shuffle=True)在时间序列上做交叉验证,等于让模型用"未来"的样本训练、用"过去"的样本验证,得到的验证分数会系统性偏高。正确的做法是TimeSeriesSplit,训练集始终在验证集之前。

sklearn较新版本的TimeSeriesSplit支持gap参数,用来在训练集和验证集之间留出隔离带。因为我们的标签用了shift(-1),第 t 行包含了 t+1 的信息,如果不留 gap,验证集第一行的信息会从训练集最后一行漏出来。

from sklearn.model_selection import TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=5, test_size=60, gap=1) for fold, (tr, va) in enumerate(tscv.split(X), 1): print("fold %d: 训练 %d 条 [%s ~ %s] | 验证 %d 条 [%s ~ %s]" % ( fold, len(tr), X.index[tr[0]].date(), X.index[tr[-1]].date(), len(va), X.index[va[0]].date(), X.index[va[-1]].date()))

n_splits=5是把序列切成 6 段,前 5 段逐步做训练集;test_size=60固定每折验证 60 条,避免最后几折验证集过小导致分数抖动;gap=1就是那个隔离带。跑完把每折的日期打出来看一眼,如果验证段的日期出现在训练段之前,说明数据没有按时间排序,先把sort_index()补上再谈调参。

3.3 完整的网格搜索代码与耗时估算

把前面的搜索空间和验证策略拼起来,就是可以直接跑的调参脚本:

import xgboost as xgb from sklearn.model_selection import GridSearchCV, TimeSeriesSplit from sklearn.metrics import make_scorer, mean_squared_error def rmse(y_true, y_pred): return float(np.sqrt(mean_squared_error(y_true, y_pred))) rmse_scorer = make_scorer(rmse, greater_is_better=False) tscv = TimeSeriesSplit(n_splits=5, test_size=60, gap=1) base = xgb.XGBRegressor( objective="reg:squarederror", tree_method="hist", random_state=42, n_jobs=4, ) coarse_grid = { "learning_rate": [0.05, 0.1, 0.2], "n_estimators": [100, 300, 600], "max_depth": [3, 4, 6], "min_child_weight": [1, 5], } gs = GridSearchCV( estimator=base, param_grid=coarse_grid, scoring=rmse_scorer, cv=tscv, n_jobs=-1, verbose=2, refit=True, ) gs.fit(X_train, y_train) print("粗搜最优:", gs.best_params_, "RMSE=%.5f" % (-gs.best_score_))

tree_method="hist"在中小样本上比exact快得多,且精度损失可以忽略;n_jobs=-1是并行折数,但 XGBoost 内部还有自己的线程,两层并行容易把 CPU 打满反而变慢,所以我通常在XGBRegressor里把n_jobs固定成 4,让GridSearchCV去并行折。verbose=2会打印每一折的进度,粗搜 54 组 × 5 折 = 270 次拟合,几千条样本上大概几分钟能跑完;如果超过二十分钟,先检查是不是把n_estimators开到了几千。

精搜直接用粗搜最优参数做局部细化:

best = gs.best_params_ fine_grid = { "learning_rate": [max(0.01, best["learning_rate"] - 0.02), best["learning_rate"]], "n_estimators": [int(best["n_estimators"] * 0.7), best["n_estimators"], int(best["n_estimators"] * 1.3)], "max_depth": [max(2, best["max_depth"] - 1), best["max_depth"]], "reg_lambda": [0.5, 1, 5], } gs_fine = GridSearchCV(base, fine_grid, scoring=rmse_scorer, cv=tscv, n_jobs=-1, refit=True) gs_fine.fit(X_train, y_train)

int()那三处是必须的,n_estimators传入浮点数时 XGBoost 不会报错,但行为和整数不同,后面想复现就说不清了。reg_lambda只在精搜阶段出现,是因为它对结果的影响远小于前几组参数,粗搜阶段放进去只会把组合数翻三倍。

4. 三条路把模型串起来:融合写法与评价口径

单模型各自能跑之后,剩下的问题是"怎么合"。论文里给的是一条主线,但落到代码上有三种写法,各有适用边界,选错了融合方式,前面调的参就白调了。

4.1 分量重构、残差叠加、加权平均,三种融合的区别

第一种是分量级融合,也是论文的主线:DWT 分解 → 低频cA用 ARIMA 预测 → 高频cD用 GS-XGBoost 预测 → 各分量预测值相加后waverec重构。它的好处是分工干净,ARIMA 不会被高频噪声干扰;代价是两边的预测误差会直接相加,任何一边跑偏都会体现在最终曲线上。

第二种是残差级融合:先用 ARIMA 拟合整条序列,拿到拟合值,再把残差y - y_hat交给 XGBoost 学,最终预测等于 ARIMA 预测 + XGBoost 预测的残差。这种写法实现简单,不需要pywt,适合作为对照组,但残差里混着趋势误差和噪声,XGBoost 容易学串。

第三种是加权平均:两个模型的预测按验证集误差倒数加权。实现最省事,但权重是在验证集上定的,样本一变就不稳,只能当兜底方案。

我一般先用第一种跑主线,把第二种当消融实验,用来证明"分解确实带来了增益",而不是"多套了一个模型"。论文里对比GS-XGBoost、DWT-GS-XGBoost、DWT-ARIMA-GSXGB三个层级,本质就是在做这件事——每一步只加一个变量,看指标怎么变。

def hybrid_predict(close, feat_df, order, xgb_model, wavelet="db4", level=3): """分量级融合:cA 走 ARIMA,各层 cD 走 XGBoost,最后重构""" import warnings coeffs = dwt_decompose(close, wavelet=wavelet, level=level) cA, details = coeffs[0], coeffs[1:] with warnings.catch_warnings(): warnings.simplefilter("ignore") arima_res = sm.tsa.ARIMA(cA, order=order).fit() n_ahead = len(feat_df) cA_pred = arima_res.forecast(steps=n_ahead) detail_preds = [] for i, cD in enumerate(details): y_hat = xgb_model[i].predict(feat_df.values) detail_preds.append(np.asarray(y_hat, dtype=float)) # 各分量长度补齐到同一长度后相加 total = np.asarray(cA_pred, dtype=float).copy() for d in detail_preds: total[: len(d)] += d return total

这段代码里最容易出问题的是长度对齐。arima_res.forecast(steps=n)返回的是一条从cA末尾往后的预测,长度n;xgb_model.predict返回的是与测试集等长的数组;两者起点一致才能相加。如果先重构再相加,waverec会把每一层的长度补齐到 2 的幂,最后截断出来的点数可能比测试集多几行,指标算出来会莫名其妙偏小。

4.2 RMSE、MAE、MAPE、R² 各自的适用边界

论文里常用的四个指标,直接套用会有坑。

指标公式要点适用场景什么时候会骗人
RMSE均方误差开根惩罚大偏差,和价格同量纲单个极端跳空就能主导整个分数
MAE绝对误差均值稳健,看平均水平对大偏差不敏感,掩盖尾部风险
MAPE平均绝对百分比误差跨标的可比分母接近 0 时爆炸
R²1 - SS_res/SS_tot看模型解释了多大方差价格本身方差大,R² 天然虚高

最典型的骗局是 R²。收盘价序列本身波动幅度大,即使模型只会输出"昨天的价格",R² 也能到 0.95 以上。所以看 R² 的时候一定要配一个 naive 基线(明日预测值 = 今日收盘价),如果混合模型的 R² 和 naive 只差零点几,那基本等于没有预测能力。

MAPE 的坑在分母。低价股、除权日、停牌复牌当天,价格可能接近 0 或出现断崖,一个点的 MAPE 就能把整体拉到几百。我一般同时算 MAPE 和 SMAPE,两者差距巨大就说明有极端样本,需要回头看数据里有没有未复权的价格。

还有一个更贴近实战的指标是方向准确率(DA):预测的涨跌方向和实际方向一致的比例。它和 RMSE 不是一回事,RMSE 低的模型方向准确率可能只有 48%。第 6 章会展开讲怎么算。

4.3 端到端流水线:从 CSV 到指标输出

把前面所有环节串起来,主流程大概是这样:

def run_pipeline(csv_path, split_date, wavelet="db4", level=3): df = pd.read_csv(csv_path, parse_dates=["date"]).set_index("date").sort_index() feat = build_features(df) train_df = feat.loc[:split_date] test_df = feat.loc[split_date:].iloc[1:] # 跳过切分点,避免和训练集重叠 X_train, y_train = train_df.drop(columns=["y_detail"]), train_df["y_detail"] X_test, y_test = test_df.drop(columns=["y_detail"]), test_df["y_detail"] # 标准化只在训练集上 fit from sklearn.preprocessing import StandardScaler scaler = StandardScaler().fit(X_train) X_train_s, X_test_s = scaler.transform(X_train), scaler.transform(X_test) gs = GridSearchCV(xgb.XGBRegressor(objective="reg:squarederror", tree_method="hist", n_jobs=4), coarse_grid, scoring=rmse_scorer, cv=TimeSeriesSplit(n_splits=5, test_size=60, gap=1), n_jobs=-1) gs.fit(X_train_s, y_train) pred = gs.predict(X_test_s) mse = mean_squared_error(y_test, pred) return { "RMSE": np.sqrt(mse), "MAE": np.mean(np.abs(y_test - pred)), "R2": 1 - mse / np.var(y_test), "best_params": gs.best_params_, }

split_date要卡在整段数据的中后段,比如前 80% 做训练。scaler必须只在训练集上fit,否则测试集的均值和方差会漏进训练过程,这是另一个隐蔽的未来函数。test_df.iloc[1:]那一行是为了切掉切分点当行,因为shift(-1)会让训练集最后一行的标签指向测试集第一行。

5. 排查清单:复现这套混合模型时最容易翻车的五件事

下面这五条,是我把这条流水线从论文搬到能跑的过程中真实踩过的。每一条都按"看到什么现象 → 为什么 → 怎么改"写,照着对号入座能省掉大半天。

5.1 回测曲线好得不真实:整段序列做 DWT 等于提前看了未来

现象:训练集 RMSE 很小,测试集 RMSE 也不大,但把预测曲线和真实曲线叠在一起看,几乎完全重合,好到不像真的。

原因:pywt.wavedec是对整条序列做卷积,每个系数的计算都依赖整段数据,第 t 个点的低频系数里含有 t 之后的信息,边界处尤其明显。用整段数据分解后再切训练集和测试集,等于把测试期的信息提前混进了训练期的特征里。

解决:改成滚动分解,每个时点只用当前及之前的数据做一次分解,取重构后最后一点的值。

def causal_dwt_one_point(hist, wavelet="db4", level=3, mode="symmetric"): """只用 hist 这一段历史做分解,返回当前时点的重构值""" coeffs = pywt.wavedec(np.asarray(hist, dtype=float), wavelet=wavelet, mode=mode, level=level) return float(pywt.waverec(coeffs, wavelet=wavelet, mode=mode)[-1])

窗口长度取2**level的整数倍(比如 level=3 就取 128 或 256),每次往后滚一格。这个写法慢,但只用在对测试段的严格评估上;训练阶段为了跑得快,可以整段分解,最后拿严格版本复核一遍指标,两个版本的差距就是你被未来信息灌的水分。

5.2 预测曲线整体滞后一天:shift 的方向写反了

现象:预测曲线形状和真实曲线几乎一样,但整体往右平移了一格,指标算出来还行,看图就知道不对。

原因:pct_change()得到的收益率本身已经是当期相对上期的变化,再shift(1)才是真正的滞后特征;而标签shift(-1)是把下一期的值提到当期。这两处只要有一处方向搞反,模型就退化成"预测当天",画出来就是那条滞后的曲线。

解决:把特征和标签的构造单独抽出来,写死断言。训练前打印X.index[0]、y.index[0],以及X.iloc[0]对应的原始日期和y.iloc[0]对应的原始日期,确认后者比前者晚一行。

5.3 训练集 R² 0.99,测试集是负数:树模型在价格水平上外推无能

现象:训练集拟合得完美,测试集 R² 直接为负,预测曲线在测试期变成一条近乎水平的直线。

原因:XGBoost 是分段常数模型,它只能输出落在训练目标值范围内的预测。测试期的价格如果超出了训练期的最大值,模型无论输入什么特征都只能逼近训练集的边界值,表现出来就是"卡住不动"。

解决:不要在价格水平上回归,改成对收益率或对分解后的分量回归。如果非要预测价格,就把趋势项剥出去交给 ARIMA,树模型只学平稳的那部分——这也是这套混合模型分层的根本理由。检查方法很简单:把y_train.min()和y_test.max()打出来,测试标签超出训练范围就是预警。

5.4 MAPE 突然飙到几百:分母接近 0 和未复权价格

现象:RMSE 看着正常,MAPE 却是个三位数,换一个标的又恢复正常。

原因:MAPE = mean(|y - y_hat| / y),y 是真实价格。遇到除权日价格断崖、停牌复牌、低价段,分母很小,单点的百分比误差能到几千,均值直接被拉爆。

解决:换用 SMAPE,或者在做指标前把价格统一成后复权序列;再不行就对价格取对数,把误差变成相对量。另外一定要把误差最大的前 10 个样本按日期打出来逐个核对,多数时候问题不在模型,在数据。

5.5 网格搜索跑了一整天,结果还不如默认参数

现象:几百组参数搜完,最优组合的验证分数只比默认值好一丁点,甚至更差。

原因有两种。一是评价口径不一致:GridSearchCV用 RMSE,最后评估用 MAE 或 MAPE,选参数的方向和验收方向不同。二是搜索空间跨度过大,learning_rate从 0.01 到 0.5、n_estimators从 100 到 5000 一起搜,大部分组合都处在明显欠拟合或过拟合的区域,把真正有效的小区间稀释了。

解决:先固定learning_rate=0.1跑一轮,确定大致量级,再按第 3 章的粗搜—精搜两段式收缩;全程只用一个评价口径;每组参数跑完把训练分和验证分一起记录,两者差距超过 30% 就直接判为过拟合,不用等全部跑完。

6. 再往上一层:把价格回归改成方向分类,用滚动回测验收

把 RMSE 压到很低,不代表这套混合模型"能用"。价格序列方差大,回归模型很容易靠"贴着上一天的价走"拿到漂亮的 RMSE,但方向准确率可能只有五成出头。真正能区分模型优劣的验收方式,是把任务改成方向分类,再用滚动回测跑一遍。

做法不复杂:把标签从连续值改成sign(y[t+1] - y[t]),去掉接近零的样本(比如涨跌幅绝对值小于 0.3% 的判为中性并剔除),objective换成binary:logistic,评价指标换成 AUC 和命中率。混合结构不用改,ARIMA 仍然预测趋势分量,它的输出可以作为 XGBoost 的一个特征喂进去,让分类器同时看到"趋势预测"和"波动特征"。

def to_direction_label(close, thr=0.003): """涨跌幅超过阈值才算方向样本,其余置为 NaN 剔除""" ret = close.pct_change().shift(-1) label = pd.Series(np.where(ret > thr, 1, np.where(ret < -thr, 0, np.nan)), index=close.index) return label def walk_forward_eval(df, feat, label, n_folds=5, min_train=500, step=40): """滚动回测:每次只用过去的数据训练,往后推 step 条,逐段累计命中率""" hits, total = [], 0 n = len(feat) fold_size = (n - min_train) // n_folds for f in range(n_folds): tr_end = min_train + f * fold_size va_end = min(tr_end + step, n) if va_end <= tr_end: break X_tr, y_tr = feat.iloc[:tr_end], label.iloc[:tr_end] X_va, y_va = feat.iloc[tr_end:va_end], label.iloc[tr_end:va_end] mask = y_tr.notna() clf = xgb.XGBClassifier(objective="binary:logistic", max_depth=4, learning_rate=0.05, n_estimators=300, subsample=0.8, colsample_bytree=0.8, eval_metric="auc", tree_method="hist") clf.fit(X_tr[mask], y_tr[mask].astype(int)) pred = clf.predict(X_va.dropna()) hits.append((pred == y_va.dropna().astype(int).values).mean()) total += len(pred) return np.array(hits), total

这段里三个参数值得调:thr=0.003是中性区间,设太小会把噪声当样本,设太大会让样本量不够;min_train=500保证第一折有足够的训练量,日线数据至少两年;step=40是每次往前推的步长,步长越小越接近真实交易节奏,但计算量线性上升。跑完看每折的命中率,如果五折都在 0.5 附近来回跳,说明特征里没有可用的方向信息,问题不在模型而在特征工程,这时候该回去补量能和振幅类特征,而不是继续调max_depth。

还有一个习惯我后来一直保留:任何一次评估,都同时打印三个数——方向命中率、naive 基线命中率(永远猜涨)、以及随机猜的期望 0.5。命中率 0.54 听起来比 0.5 高,但如果样本里上涨天数本来就占 54%,那模型等于什么都没学到。这个对照不做,很容易被自己骗过去。

从那以后我每次搭这类混合模型,都强制先跑通重构闭环、再对齐特征标签、最后用滚动回测出数,三步任何一步没过就不往下调参。这套论文的价值不在某个具体参数,而在它把"分解—分层—重构"这条路径走通并做了逐级对比,照着这条路径摆好自己的代码骨架,再对着第 5 章那五条逐个排查,能少走很多弯路。希望帮到你。

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

RELATED READING

延伸阅读

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