☰
DWT-ARIMA-GSXGB混合模型:股票预测的时间序列建模与调参实践
2026/11/2 4:48:59 网站建设 项目流程

简介:围绕XGBoost混合模型开展股票价格预测的工程硕士学位论文,面向金融数据分析、机器学习建模方向的学习者与研究者,可用于梳理集成学习在非线性时序预测中的完整研究路径。压缩包内仅含1个PDF文件,约3.48MB,为论文全文,包含绪论、相关概念与理论基础、XGBoost与时间序列模型原理、混合模型设计、实验设置及结果分析等章节。已有661人学习,在股票预测与集成学习选题中具备一定参考价值。读者可从中了解特征工程、参数调优、随机森林与支持向量机等模型融合思路,以及MSE、MAE、R²等评价指标的使用方式,并参考实验数据准备、模型对比分析与过拟合讨论,为课程设计、论文写作或金融预测项目提供可复用的研究框架与实验组织方法,也可据此把握学位论文从理论铺垫到实验验证的写作结构。

1. 股票预测的实验骨架:三套模型逐层递进,先看它能跑到哪一步

量化圈里常说一句话:回测曲线越漂亮,实盘越容易让人怀疑人生。这份资源讲的正是那条漂亮曲线在被生成之前的那一段——用 XGBoost 做基学习器,外面套一层网格搜索调参,再往前接离散小波变换降噪,最后把 ARIMA 拉进来做线性残差补偿,拼成一个DWT-ARIMA-GSXGB 混合模型,用来预测股票价格。它的价值不在于给你一个能下单的信号,而在于把“树模型怎么吃时间序列”这件事拆成了三层可验证的递进:GS-XGBoost、DWT-GS-XGBoost、DWT-ARIMA-GSXGB,每一层都有构建流程、实验数据和对比口径。适合手上有行情数据、想搭一条完整预测流水线却卡在“参数怎么搜、噪声怎么降、线性部分谁来管”的人,也适合要复现同类论文的实验设计、想先摸清边界再动手的从业者。

2. 从 CART 到 GS-XGBoost:超参数搜索空间怎么划、验证集切在哪

XGBoost 被拿来预测股价,最大的误会是“它很强,所以直接上就行”。真实情况是:同一个数据集,max_depth从 4 调到 8、min_child_weight从 1 调到 10,MAE 能差出一倍。这一章先把模型为什么这么设计讲清楚,再把搜索空间和验证策略落成能跑的代码。

2.1 二阶泰勒展开加正则项,决定了它比 GBDT 更吃调参

XGBoost 的底座是 CART 回归树,往上是 GBDT 的加法模型:每轮训练一棵新树去拟合上一轮的残差。差异在于目标函数的处理方式——GBDT 只用到损失函数的一阶导数,XGBoost 做了二阶泰勒展开,同时把拟合误差之外的东西也写进了目标里:叶子节点个数乘上一个 gamma 惩罚,叶子权重平方和乘上 lambda 的一半。分裂时算增益,增益不够 gamma 就不分裂。这个设计带来的直接后果是,它天生抗噪一点,但也意味着gamma、reg_lambda这类正则参数一旦设偏,树会直接长不起来或者疯狂长。

工程上还有两个特性特别关键。一是支持列采样和行采样,相当于在 boosting 框架里塞了随机森林的方差控制手段;二是缺失值会自动学一个默认分裂方向。行情数据里停牌、节假日造成的空洞很常见,这一点省掉了不少预处理功夫。代价是参数维度变多,手工试基本没戏,必须上网格搜索。

至于为什么选 XGBoost 而不是 LSTM 那一套:这类论文场景下样本量通常只有几千个交易日,深度模型参数多、训练不稳定,而且黑匣子程度高,调参失败时你连从哪下手都不知道。树模型至少在特征重要性和分裂点上能给你反馈,这是它在中小规模表格数据上仍然是第一选择的现实原因。

2.2 搜索空间怎么划:先粗后细,两轮就够

把参数一股脑丢进网格是新手最容易犯的错,组合数会爆炸。常见做法是分两轮:第一轮用较大的learning_rate快速定位max_depth和min_child_weight的量级,第二轮在最优点附近缩小步长,同时把学习率降下来、树的棵数加上去。

下面这张表是我一般会用的搜索区间,具体值要按数据量调整:

参数控制什么首轮搜索区间调大的代价
max_depth树深,决定特征交互阶数3 / 5 / 7过拟合,训练变慢
min_child_weight叶子节点最小样本权重和1 / 5 / 10欠拟合,树长不深
learning_rate每棵树的贡献缩放0.05 / 0.1需要更多树,训练变慢
subsample行采样比例0.8 / 1.0偏差上升,方差下降
colsample_bytree列采样比例0.8 / 1.0同上
gamma分裂所需最小增益0 / 1 / 5分裂变少,容易欠拟合
reg_lambda叶子权重 L2 惩罚1 / 5 / 10权重被压扁

首轮这组配置大约是 3×3×2×2×2 = 72 个组合,配合 5 折时序交叉验证,一共 360 次训练。用tree_method="hist"的话,几千行数据在普通笔记本上十几分钟能跑完,不至于让人等到放弃。

2.3 特征构造与训练循环:别把钱花在错误的标签上

论文里实验的预测目标是收盘价,指标是 MAE、MAPE、RMSE 这一类。但实际动手时有个绕不开的问题:树模型的输出空间被训练集的目标值范围限死了,它没法外推到一个历史上从没出现过的价格。股价长期向上漂移,直接回归价格,模型在测试集后段会系统性低估。我一般的处理是改成预测下一日收益率,再把收益率还原成价格去算指标——两种口径都能报,但后者在树模型上才站得住。

import numpy as np import pandas as pd import xgboost as xgb from sklearn.model_selection import TimeSeriesSplit, GridSearchCV def build_features(close, lags=(1, 2, 3, 5, 10), windows=(5, 10, 20)): df = pd.DataFrame({"close": close}) # 用收益率而不是原始价格做特征,避免非平稳序列把树模型带偏 df["ret1"] = df["close"].pct_change() for l in lags: df[f"lag_ret{l}"] = df["ret1"].shift(l) for w in windows: df[f"ma{w}"] = df["close"].rolling(w).mean() / df["close"] - 1 df[f"std{w}"] = df["ret1"].rolling(w).std() df[f"mom{w}"] = df["close"] / df["close"].shift(w) - 1 # 标签用下一日收益率,避免树模型对价格做外推 df["target"] = df["close"].shift(-1) / df["close"] - 1 return df.dropna() # 时间序列必须用 TimeSeriesSplit,训练集永远在验证集左边 tscv = TimeSeriesSplit(n_splits=5) base = xgb.XGBRegressor( n_estimators=1200, objective="reg:squarederror", tree_method="hist", random_state=42, n_jobs=-1, ) param_grid = { "max_depth": [3, 5, 7], "min_child_weight": [1, 5, 10], "learning_rate": [0.05, 0.1], "subsample": [0.8, 1.0], "colsample_bytree": [0.8, 1.0], } search = GridSearchCV( base, param_grid, cv=tscv, scoring="neg_mean_absolute_error", # sklearn 是最大化打分,所以取负 MAE n_jobs=-1, verbose=1, ) search.fit(X_train, y_train) print(search.best_params_, -search.best_score_)

代码里几个参数值得单独说。TimeSeriesSplit(n_splits=5)是把序列切成 5 段,第 k 段当验证集时,训练集只取它前面的部分,这是时序数据唯一正确的验证方式。scoring="neg_mean_absolute_error"是因为 sklearn 的交叉验证统一向“越大越好”靠,MAE 越小越好,所以要取负。n_estimators先固定一个偏大的值,等参数定下来之后再单独用早停确定真实需要的棵数,否则把树的数量也放进网格,组合数会再翻几倍。

确定棵数的那一步用验证集早停,成本很低:

model = xgb.XGBRegressor( n_estimators=3000, learning_rate=0.03, max_depth=best["max_depth"], min_child_weight=best["min_child_weight"], subsample=best["subsample"], colsample_bytree=best["colsample_bytree"], objective="reg:squarederror", tree_method="hist", early_stopping_rounds=100, # 验证集 100 轮不降就停 eval_metric="mae", ) model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=False) print(model.best_iteration)

early_stopping_rounds给 100 是比较稳的取值,太小容易被局部波动打断,太大会把训练时间拖长。best_iteration拿到的就是这一折数据上比较合适的树数量,如果做滚动预测,每一折都要重新定一次,不能拿全局最优值一把梭。

3. 离散小波变换接在预测前面:db4、分解层数与边界效应

降噪这件事在很多论文里被写成一句“采用离散小波变换进行数据降噪处理”,实际操作起来参数不少:小波基选哪个、分解到第几层、阈值怎么定、重构后长度对不齐怎么办。这一章按能复现的顺序过一遍。

3.1 小波分解在价格序列上到底切出了什么

离散小波变换把一条序列拆成两部分:近似分量(低频、趋势)和细节分量(高频、波动)。跟傅里叶变换相比,小波的优势是时频局部化——它不假设信号在整个时间轴上频率恒定,而是同时在时间和频率两个维度上做分解。股价这种既有长期趋势又有突发事件冲击的序列,恰好符合这个假设。

论文里选的是 db4(Daubechies 4 阶)。这一族小波紧支撑、正交,消失矩是 4,意思是它能“吃掉”不超过 3 阶的多项式趋势而不产生响应,对价格里那种缓慢漂移比较友好。换成 db1(也就是 Haar)分解会非常生硬,细节分量抖得厉害;换成消失矩更高的 db8,边界畸变又更明显。db4 是个折中位,也是这类金融时序降噪里出现频率最高的一个。

3.2 分解、软阈值、重构:一段能直接接进流水线的代码

最常见的降噪流程是:做多层分解,对细节系数做软阈值收缩,再用处理后的系数重构回去,得到一条去噪序列,然后才喂给特征工程。

import numpy as np import pywt def dwt_denoise(x, wavelet="db4", level=4, mode="symmetric"): """对一维价格序列做小波降噪,返回等长序列""" x = np.asarray(x, dtype=float) # 1. 多层分解,coeffs[0] 是近似分量,后面全是各层细节分量 coeffs = pywt.wavedec(x, wavelet, mode=mode, level=level) # 2. 用第一层细节系数的 MAD 估计噪声标准差,这是通用阈值的标准做法 sigma = np.median(np.abs(coeffs[-1])) / 0.6745 uthresh = sigma * np.sqrt(2 * np.log(len(x))) # 3. 只对细节分量做软阈值,近似分量原样保留 coeffs[1:] = [pywt.threshold(c, uthresh, mode="soft") for c in coeffs[1:]] # 4. 重构,wavedec 重构后长度可能多 1,截断对齐 rec = pywt.waverec(coeffs, wavelet, mode=mode) return rec[: len(x)]

几个细节要盯住。sigma用第一层细节系数的中位数绝对偏差除以 0.6745,这是把 MAD 换算成正态分布标准差的常数,比直接算方差稳健,不容易被极端跳空带跑。阈值公式sigma * sqrt(2*log(n))是通用阈值,n 是序列长度,样本越多阈值越大。mode="symmetric"是边界延拓方式,重构时首尾的失真比默认的zero模式小很多,代价是严格意义上不保能量,但对预测任务来说这个损失可以忽略。

软阈值和硬阈值的选择也值得说一句。硬阈值是把小于阈值的系数直接置零,保留大系数原值;软阈值是把所有系数朝零的方向收缩一个阈值量。硬阈值重构出来的信号更锐利,但容易在跳变点产生伪吉布斯振荡;软阈值连续性好、更平滑,代价是大幅值也被压了一截。价格序列上我一般用软阈值,因为后续树模型对平滑噪声的容忍度比对待伪振荡高。

3.3 层数、边界模式,以及那个绕不开的因果性问题

分解层数不是一个可以随手填的数字。pywt.dwt_max_level(len(x), wavelet.dec_len)会给你一个理论上限,但实际用的时候一般取 3 到 5 层——层数太浅,低频趋势提不干净;层数太深,每一层能用的样本数减半,边界效应会从两端向中间蚕食,重构出来的曲线首尾会出现明显的翘尾,而股价序列的首尾恰恰是你最想预测的位置。

还有一个更隐蔽的问题:小波分解是全局变换。你拿整段序列做完分解再重构,重构出来的第 t 个点,它的值依赖了 t 之后的数据点。如果后面再按时间切训练集和测试集,测试集的信息其实已经通过这次全局变换渗透进训练集了。这就是一个典型的未来函数,指标会好看到不真实。

正确做法是滚动分解:对每一个预测时点 t,只取 t 之前的一段窗口做 DWT,用重构后的窗口末端做特征。窗口长度一般取 128 或 256 个交易日,太短重构不稳,太长边界效应吃掉的区间变多。这种做法慢,但它是唯一能把因果性守住的写法:

def rolling_dwt_feature(close, window=128, level=4): """滚动窗口做 DWT,每个时点只用历史数据""" out = np.full(len(close), np.nan) for t in range(window, len(close)): seg = close[t - window : t] # 严格不含 t 本身及其之后的数据 denoised = dwt_denoise(seg, level=level) out[t] = denoised[-1] # 只取重构序列的最后一个点 return out

慢是慢了,但比指标虚高、上线后崩盘要划算得多。真嫌慢,可以每隔 5 个交易日重算一次,中间用前值填充,精度损失很小。

4. DWT-ARIMA-GSXGB 的拼接:线性残差给谁、非线性给谁

到这一步,单模型的瓶颈就很明显了:ARIMA 在白噪声检验能过的那部分线性自相关上表现稳定,但股价里的非线性、交互、突发跳变它一概看不见;XGBoost 反过来,特征给得足的情况下非线性拟合很强,可是对“昨天的价格线性地影响今天”这种结构反而不敏感。混合模型的动机就来自这里——让两个模型各管一段。

4.1 分工逻辑:先线性后非线性,还是同时上

混合的方式有好几种。最简单的做法是把两个模型的预测加权平均,权重靠验证集调;更常见也更有解释力的是残差式组合:先用 ARIMA 拟合并预测,得到预测值y_arima,再算出残差e = y - y_arima,把这串残差当成新的标签交给 XGBoost 去学,最后y_hat = y_arima + e_hat。这样做的逻辑前提是:ARIMA 已经吃掉了序列里的线性结构,剩下的残差里如果还有可学的成分,那大概率是非线性的,正好是树模型的菜。

这个前提值得反复强调。如果 ARIMA 的残差做完 Ljung-Box 检验已经接近白噪声,说明线性部分被榨干了,这时候再往上叠 XGBoost 收益很有限;如果残差里还有明显的自相关或者跟特征的相关性,那叠加才有意义。所以动手之前先跑一次残差检验,比闷头调参省时间。

4.2 ARIMA 定阶:ADF 定 d,AIC 定 p 和 q

d用单位根检验定。ADF 检验不拒绝原假设说明序列非平稳,做一次一阶差分再检验,通常到 d=1 就平稳了,股价这种数据极少需要 d=2,真到了 d=2 反而要怀疑数据本身(比如没做复权,或者混了不同标的)。p和q可以先看 ACF、PACF 的截尾拖尾形态圈定一个范围,再在该范围里按 AIC 最小挑。样本量大时千万别直接对全区间做auto_arima式搜索,容易搜出 p、q 都很高的模型,参数量上去了但样本外一点没改善。

另外要注意,ARIMA 的输入应该是做过去噪之后的那条序列,而不是原始价格。既然上一步已经用 DWT 处理过,那么 ARIMA 拟合的是denoised,残差也要相应地用y - y_arima算,其中y到底是原始值还是去噪值,必须在实验设计里定死并写清楚,中途换口径是最容易让人对不上数的原因。

4.3 滚动一步预测与残差拼接的完整写法

ARIMA 这部分最容易翻车的地方是一次性forecast(steps=n)把整个测试集预测完。那样做等于让模型在预测第 1 天的时候就已经知道了第 n 天之前的全部真实值,误差会被严重低估。正确做法是滚动一步:每预测一天,把当天的真实值追加进历史,重新拟合并预测下一天。

import numpy as np from statsmodels.tsa.arima.model import ARIMA def walk_forward_arima(y, order, start, refit_every=5): """滚动一步预测,refit_every 控制重拟合频率以节省时间""" hist = list(y[:start]) preds = [] model_fit = ARIMA(hist, order=order).fit() for i, t in enumerate(range(start, len(y))): # 每 refit_every 步重拟合一次,中间用已有模型外推一步 if i % refit_every == 0: model_fit = ARIMA(hist, order=order).fit() preds.append(float(model_fit.forecast(1)[0])) hist.append(y[t]) # 追加真实值,而不是预测值 return np.array(preds) # 1) ARIMA 拿到线性预测 arima_pred = walk_forward_arima(y_denoised, order=(2, 1, 2), start=split_idx) # 2) 残差交给 XGBoost 学非线性部分 resid = y_denoised[split_idx:] - arima_pred # 3) 残差特征里要把 ARIMA 预测值本身也放进去,否则树模型不知道当前处在什么价位 X_resid = np.column_stack([X_test, arima_pred]) resid_model = xgb.XGBRegressor( n_estimators=600, max_depth=4, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, objective="reg:squarederror", tree_method="hist", ) resid_model.fit(X_resid_train, resid_train) # 4) 最终预测 = 线性部分 + 非线性残差修正 final_pred = arima_pred + resid_model.predict(X_resid)

refit_every=5是个实用参数。每步都重拟合 ARIMA 在几千个样本上要跑很久,隔 5 步重拟合一次,精度损失通常在 1% 以内,速度能快四五倍。hist.append(y[t])这一行是因果性的关键,追加的必须是真实观测值,模型再预测下一步时用的历史就是干净的。第三步把arima_pred作为额外特征塞进残差模型,是因为残差的量级跟当前价格水平强相关,不给这个信息,树模型学出来的修正项在不同价位上会系统偏。

5. 避坑与排查:复现时真翻过的五个车

下面这几条都是我在复现同类混合模型时实际踩过的,按「现象 → 原因 → 解决」写,出现频率从高到低排。

5.1 数据切分与信息泄漏:两条最致命的

踩坑一:用随机 KFold 切时序数据。现象是交叉验证的 MAE 低到 0.6%,换成按时间滚动预测立刻翻到 1.8%,怎么调参都追不回来。原因是随机打乱之后,模型在训练时见过测试时点前后的样本,而股价序列的自相关很强,等于把答案提前漏给了它。解决办法只有一个:换成TimeSeriesSplit,或者手写滚动窗口,让训练集的最后一个时点永远早于验证集的第一个时点。已经跑完的实验如果用的是 KFold,结论一律作废重跑。

踩坑二:小波分解在切分之前做。现象是 DWT 那一层加进去之后指标提升特别夸张,但把分解挪到切分之后,提升几乎消失。原因是全局 DWT 重构时用到了整段序列,测试期的信息通过变换渗进了训练期。解决办法是按第 3.3 节那样做滚动窗口分解,每个时点只用窗口内的历史数据。这个坑隐蔽性极高,因为代码跑起来不报错、指标还很好看,只有做时间穿越测试才能发现。

5.2 模型口径与调参:两条容易自欺的

踩坑三:预测目标和评价指标对不上。现象是训练日志里写着“对分类结果进行评价”,实验章节报的却是 MAE、RMSE 这类回归指标。原因是目标函数在reg:squarederror和binary:logistic之间摇摆,模型学的是价格,评估用的却是涨跌方向,或者反过来。解决办法是在实验设计阶段就把口径定死:预测收盘价用回归目标配 MAE/RMSE,预测涨跌方向用二分类目标配准确率/AUC,两套指标不要混在同一张对比表里。真要两个都报,就训练两个模型,别指望一个模型两头讨好。

踩坑四:网格搜索在测试集上做。现象是最终报告的指标比交叉验证的最好成绩还高,换个时间段一测就崩。原因是搜索时把测试集也纳入了候选评估,参数实际上“看过”测试集。解决办法是把数据切成训练、验证、测试三段,网格搜索只在训练集内部用验证集做,测试集从头到尾只跑一次,跑完不管结果好坏都记录在案,不许回头改参数。

5.3 数据质量:一条最容易被忽略的

踩坑五:没做复权,也没剔停牌。现象是模型在某些日期附近误差突然放大几十倍,特征重要性里lag_ret1一枝独秀。原因是除权除息造成的价格断层被当成了真实跌幅,停牌日的空值又被前值填充成了“价格纹丝不动”。解决办法是统一用复权价,停牌日直接剔除而不是填充,缺失的交易日不要用插值补,让时间索引保持真实间隔。这条不解决,前面所有调参都是白费。

排查的时候可以固定一条自查流程:先确认数据是复权的、时间索引连续无重复;再确认特征构造里没有shift(-n)的越界;然后确认切分方式是按时间的;最后才看指标和参数。顺序颠倒过来,你会花大量时间在调参上,而问题根本不在模型。

6. 三套模型拉到一张表里比:指标口径与方向准确率的验证

复现这类论文,最容易糊弄自己的地方就是“只报一个 RMSE”。价格序列的持续性极强,一个“明天的收盘价等于今天的收盘价”的朴素预测,在很多区间上的 RMSE 都很有竞争力。所以对比表里必须有一行 baseline,不然你没法判断模型的提升是真学到了东西,还是仅仅因为价格本身变化不大。

我一般会把三套模型加一个朴素基线放在同一张表里,指标口径统一,数值自己跑出来填:

模型MAEMAPE(%)RMSER²方向准确率(%)
朴素基线(昨日收盘)待填待填待填待填—
GS-XGBoost待填待填待填待填待填
DWT-GS-XGBoost待填待填待填待填待填
DWT-ARIMA-GSXGB待填待填待填待填待填

方向准确率要单独算,因为它和误差指标衡量的是两件事:模型可能把价格预测得很准,但涨跌方向猜对率只有 50%,那它对交易决策毫无价值。算法很简单,比较sign(y_hat_t - y_{t-1})和sign(y_t - y_{t-1}),一致就记 1,最后取平均。这一步别用预测值之间的差去比,那会引入额外的累积误差。

def direction_accuracy(y_true, y_pred, y_prev): # 用预测值与上一日真实值的相对变化判断方向,避免累积误差 true_dir = np.sign(y_true - y_prev) pred_dir = np.sign(y_pred - y_prev) return float(np.mean(true_dir == pred_dir)) * 100

跑完这张表之后,重点看的不是哪一行数字最小,而是层级之间有没有单调改善。如果 DWT 那一层加进去之后 MAE 反而升了、方向准确率也没动,那说明降噪在这个数据集上没起作用,可能是分解层数不对,也可能是这条序列本身就够干净。混合模型那一层如果提升不到 5%,也要回头看看 ARIMA 残差的 Ljung-Box 检验结果——残差已经是白噪声了,再叠一层就是自欺。

我自己的习惯是:每次改完模型结构,先把这张表和朴素基线一起打印出来,只要有一项指标没跑赢基线,就不往下推进下一步。这个习惯救过我很多次,因为流程里任何一处泄漏,最终都会在这张表上露出“好得不正常”的马脚。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询