简介:围绕XGBoost混合模型开展股票预测研究的工程硕士学位论文,面向金融数据分析、机器学习建模及量化预测方向的学习者与研究人员,可用于理解集成学习在非线性金融时序中的应用思路。压缩包内为1个PDF文件,大小3.48MB,完整收录论文正文、摘要、目录及实验分析章节,便于按绪论、理论基础、模型构建与结果对比等模块查阅。已有661人学习,说明其在股票预测与XGBoost实践主题下具有一定参考价值。读者可从中获取特征工程、参数调整、XGBoost与随机森林/支持向量机等模型融合、MSE/MAE/R²评价指标设计及实验对比分析的完整研究框架,也能了解过拟合讨论与后续改进方向,适合作为课程论文、毕业设计或相关课题的技术参考,也为量化策略验证与模型选择提供可复用的研究路径。
1. 从“单模型调不动了”说起:这套混合预测方案解决什么问题
做股票预测的人大多经历过同一个阶段:XGBoost 把参数调到自我感觉良好,换一段行情就崩;ARIMA 拟合出来的曲线平滑得像画上去的,一遇到跳空就彻底跑偏。这套基于 XGBoost 的混合预测方案,要解决的正是“单模型覆盖不了全部规律”这个老问题。它把股价序列拆成线性成分与非线性成分两条线:先用离散小波变换做降噪与多尺度分解,再由 ARIMA 拟合线性主趋势、由网格搜索优化的 XGBoost 拟合非线性残差,最后融合输出预测值。整套东西适合正在做股票预测方向毕设或课设的人,也适合手上有日频行情数据、需要拿出一份带消融对比实验的完整方案的人。读完之后,你应该能自己搭出 GS-XGBoost、DWT-GS-XGBoost、DWT-ARIMA-GSXGB 这条逐级加模块的模型链,并且知道每一级提升到底来自哪里。
2. 总体架构设计:DWT、ARIMA 与 GS-XGBoost 的三层拼装逻辑
混合模型最容易写崩的地方不是算法本身,而是结构没想清楚就动手写代码,结果三个模块互相污染输入输出,最后连“到底哪个模块带来了提升”都说不明白。所以在动手之前,先把整体结构固定下来,后面所有实验都是在这个骨架上换零件。
2.1 三层结构:降噪层、线性层与非线性层的职责划分
我把整套方案拆成三层,每层只干一件事,层与层之间用明确的数据契约连接。
第一层是降噪与分解层,由离散小波变换(DWT)承担。原始收盘价序列里混着大量高频噪声,直接喂给任何模型都会让模型去拟合噪声。DWT 的作用是把序列分解成近似分量(低频主趋势)和细节分量(高频波动),然后对细节分量做阈值处理再重构,得到一条“去噪后的价格序列”。这一层的输出不是特征,而是一条更干净的时序曲线。
第二层是线性建模层,由 ARIMA 承担。股价序列经过一阶差分后近似平稳,ARIMA 擅长捕捉这种自相关结构,也就是“昨天涨了今天大概率还涨一点”这类线性惯性。它的输出是预测值与残差序列两部分,残差里剩下的就是线性模型解释不了的东西。
第三层是非线性建模层,由网格搜索优化的 XGBoost(GS-XGBoost)承担。它不直接预测价格,而是拿 ARIMA 的残差作为学习目标,同时把历史滞后项、滚动统计量、技术指标作为特征输入,去拟合残差中的非线性结构。
三层的关系是串行的:DWT 输出干净序列 → ARIMA 输出预测值与残差 → GS-XGBoost 输出残差预测 → 最终预测值 = ARIMA 预测值 + 残差预测值。这个结构的好处是每一层都能单独关掉做消融实验,哪一层贡献了多少一目了然。
提示:不要把 DWT 放在 ARIMA 之后,也不要把 XGBoost 直接拿去预测原始价格。前者会让 ARIMA 的差分阶数失去意义,后者会让 XGBoost 退化成一个大号的自回归器,树模型在纯外推任务上天然吃亏。
2.2 数据流与模块边界:从原始行情到预测输出的完整链路
把三层落到代码目录上,我一般会切成五个模块,每个模块一个文件,接口只暴露函数签名,中间数据全部落盘成中间文件。这么做看起来笨,但调试的时候可以把任意一个中间文件拿出来单独检查,比在一条长脚本里打断点靠谱得多。
| 模块 | 职责 | 输入 | 输出 |
|---|---|---|---|
data_loader | 读取行情、清洗缺失与停牌 | 原始 CSV | 规整的 OHLCV 表 |
dwt_filter | 小波分解、阈值去噪、重构 | 收盘价序列 | 去噪序列 + 各层分量 |
arima_model | 定阶、拟合、预测 | 去噪序列 | 预测值 + 残差序列 |
xgb_tuner | 网格搜索、训练、预测 | 残差 + 特征表 | 残差预测值 |
evaluator | 指标计算、对比、绘图 | 各模型预测结果 | 指标表 + 对比图 |
数据契约需要写死几个约定:所有序列统一按交易日升序排列并以日期为索引;所有特征只允许使用 t 时刻及之前的信息,滚动窗口的shift(1)必须显式写出来;训练集、验证集、测试集按时间切分,绝不允许随机打乱。这三条约定后面会反复提到,因为绝大多数“预测效果好得离谱”的翻车现场,都是从这里开始的。
特征表我一般固定成这么几类:价格滞后项(t-1 到 t-10 的收盘价、涨跌幅)、滚动统计量(5/10/20 日移动平均、滚动标准差)、技术指标(RSI、MACD 柱、布林带位置)、成交量衍生量(量比、换手率变化)。特征数量控制在 30 到 50 之间,太多了 XGBoost 的网格搜索会直接把你电脑跑成暖手宝。
2.3 技术选型的取舍:为什么是 XGBoost 而不是 LSTM
这个问题答辩的时候几乎必被问。我的回答通常是三条:样本量、可解释性、调参成本。
日频行情数据,一只股票十年的数据也就两千多个交易日,扣掉滚动窗口的预热期,真正能用来训练的样本不到两千条。LSTM 这类深度模型在这个样本量级上非常容易过拟合,而且训练一次要几分钟,做网格搜索基本不现实。XGBoost 在千级样本上表现稳定,训练一次几秒钟,几百组参数的网格搜索半小时内能跑完,这对毕设阶段的迭代效率是决定性的。
可解释性也是实打实的优势。XGBoost 能输出特征重要性,答辩时可以直接告诉评委“模型主要看的是 5 日均线和 20 日波动率”,而 LSTM 的门控权重很难讲成人话。至于 ARIMA 为什么还留着——因为它是这套方案里唯一能明确解释“线性惯性”的部分,把它的残差交给 XGBoost,等于让两个模型各干自己擅长的事,这也是混合模型相对单一模型的核心论点。
3. 关键模块实现:网格搜索调参与小波分解的工程细节
结构定完,接下来是三个真正需要写代码的地方:XGBoost 的网格搜索、DWT 的分解重构、ARIMA 与残差的融合。这三块每一块都有几个参数会直接决定结果好坏,下面逐个拆。
3.1 GS-XGBoost 的参数空间与网格搜索实现
网格搜索的本质是暴力枚举,所以关键不在算法,而在搜索空间的设定。参数区间设宽了,组合数爆炸;设窄了,容易错过最优点。我的经验是先粗后细两轮:第一轮用大跨度粗搜定位量级,第二轮在最优点附近用小步长细搜。
import numpy as np import xgboost as xgb from sklearn.model_selection import TimeSeriesSplit # 第一轮粗搜:跨度大、步长粗,目的是定位参数量级 coarse_grid = { "max_depth": [3, 5, 7], # 树深,日频数据一般不超过 7 "learning_rate": [0.01, 0.05, 0.1], # 学习率,小学习率配多轮迭代 "subsample": [0.7, 0.9], # 行采样,抑制过拟合 "colsample_bytree": [0.7, 0.9], # 列采样,特征多时很有用 "min_child_weight": [1, 3, 5], # 叶节点最小样本权重,越大越保守 } def grid_search_xgb(X, y, grid, n_splits=5): """按时间序列切分做交叉验证,禁止随机打乱""" tscv = TimeSeriesSplit(n_splits=n_splits) best_score, best_params = np.inf, None for max_depth in grid["max_depth"]: for lr in grid["learning_rate"]: for sub in grid["subsample"]: for col in grid["colsample_bytree"]: for mcw in grid["min_child_weight"]: scores = [] for tr_idx, va_idx in tscv.split(X): model = xgb.XGBRegressor( n_estimators=800, # 配合早停,实际不会跑满 max_depth=max_depth, learning_rate=lr, subsample=sub, colsample_bytree=col, min_child_weight=mcw, objective="reg:squarederror", early_stopping_rounds=50, random_state=42, ) model.fit( X.iloc[tr_idx], y.iloc[tr_idx], eval_set=[(X.iloc[va_idx], y.iloc[va_idx])], verbose=False, ) pred = model.predict(X.iloc[va_idx]) scores.append(np.sqrt(np.mean((pred - y.iloc[va_idx]) ** 2))) mean_score = np.mean(scores) if mean_score < best_score: best_score, best_params = mean_score, { "max_depth": max_depth, "learning_rate": lr, "subsample": sub, "colsample_bytree": col, "min_child_weight": mcw, } return best_params, best_score这段代码有几个点值得单独说明。TimeSeriesSplit是必须的,用KFold随机切分会让模型在训练时“看到未来”,交叉验证分数会漂亮得不像话,但上线预测立刻原形毕露。early_stopping_rounds=50配合验证集,能让n_estimators不必手工指定,实际训练的树数量由验证集损失决定。min_child_weight这个参数容易被忽略,它控制叶节点的最小样本权重,对抑制过拟合的作用比max_depth更柔和,日频小样本场景下建议从 3 开始试。
第二轮细搜就在best_params附近取更密的点,比如max_depth在最优值上下各取一个整数,learning_rate在最优值附近取[0.03, 0.05, 0.08]。两轮下来通常能找到一组比较稳的参数,别指望找到全局最优,能在验证集上稳定就行。
3.2 离散小波变换的分解层数与重构策略
DWT 这块有两个参数必须定:小波基和分解层数。小波基我一般用db4,它在金融时序上的平滑性和局部性比较平衡;分解层数按样本长度取,日频数据取 4 到 6 层比较常见,层数太多会让低频近似分量过于平滑,丢掉趋势拐点。
import pywt import numpy as np def dwt_denoise(series, wavelet="db4", level=4, threshold_mode="soft"): """对价格序列做小波分解,对细节系数软阈值去噪后重构""" coeffs = pywt.wavedec(series, wavelet, level=level) # 用第一层细节系数的中位数估计噪声标准差(MAD 估计,抗异常值) sigma = np.median(np.abs(coeffs[-1])) / 0.6745 thr = sigma * np.sqrt(2 * np.log(len(series))) # 通用阈值 new_coeffs = [coeffs[0]] # 近似分量原样保留,不参与阈值处理 for detail in coeffs[1:]: new_coeffs.append(pywt.threshold(detail, thr, mode=threshold_mode)) return pywt.waverec(new_coeffs, wavelet)[:len(series)]关键在最后一行那个切片。waverec重构出来的长度可能比原序列多一两个点,直接拿去和原序列做差会报长度不匹配,这个坑我踩过不止一次。阈值这里用的是通用阈值sigma * sqrt(2*ln(N)),sigma用第一层细节系数的 MAD 估计而不是标准差,因为标准差本身会被极端值带偏,而股价序列里最不缺的就是极端值。
去噪之后,务必把原始序列和去噪序列画在一张图上对比看。如果去噪后的曲线把明显的趋势拐点也抹平了,说明阈值太大或者层数太高,需要往下调。这一步不能省,纯看指标数字是看不出曲线形态问题的。
3.3 ARIMA 残差建模与混合模型的加权融合
ARIMA 的定阶我用pmdarima的auto_arima先跑一遍,拿到一个(p,d,q)的初始值,再在附近手工微调。d通常取 1,因为价格序列一阶差分后基本平稳;p和q一般不超过 5,再高就有过拟合嫌疑。
import pmdarima as pm from statsmodels.tsa.arima.model import ARIMA def fit_arima(series, seasonal=False): """先用 auto_arima 定阶,再用 statsmodels 拟合以拿到残差""" auto = pm.auto_arima( series, seasonal=seasonal, start_p=0, max_p=5, start_q=0, max_q=5, d=1, # 一阶差分,价格序列的常规操作 information_criterion="aic", stepwise=True, suppress_warnings=True, ) order = auto.order model = ARIMA(series, order=order).fit() return model.predict(start=0, end=len(series) - 1), model.resid, order融合方式有两个选择:直接相加,或者按验证集误差学习一个权重。直接相加(final = arima_pred + xgb_resid_pred)是最朴素也最稳的做法,前提是残差预测的均值接近零、没有系统性偏移。如果发现最终预测整体偏高或偏低,说明残差模型学到了偏差,这时候才考虑加权:
# 在验证集上学习融合权重 w,避免在测试集上偷看 w = np.argmin([ np.sqrt(np.mean((arima_val + w_ * xgb_val - y_val) ** 2)) for w_ in np.linspace(0, 1, 21) ]) / 20 final_pred = arima_pred + w * xgb_resid_pred权重一定要在验证集上确定,然后固定下来用到测试集,千万不要在测试集上调权重,那是标准的自欺欺人。
4. 实验设计与评价指标:让对比结果站得住脚
模型跑通只是第一步,能不能把“混合模型更好”这个结论讲扎实,取决于实验设计。这一章讲三件事:数据怎么切、指标怎么算、对比怎么做。
4.1 数据划分与滑窗构造
数据划分按时间顺序切,常见的比例是 6:2:2 或 7:1.5:1.5。我倾向于用 6:2:2,因为验证集要同时承担调参和融合权重学习两个任务,留多一点更稳。
滑窗构造要注意两点。第一,窗口长度要覆盖特征里最长的滚动统计量,比如用了 20 日均线,窗口至少 30 天起步,否则前期样本的特征全是 NaN。第二,预测步长要明确,是预测 t+1 的收盘价,还是预测 t+5 的收益率?这两个任务的难度和评价口径完全不同,写论文时必须说清楚。我一般做 t+1 的收盘价回归,因为它和 ARIMA 的原生输出口径一致,融合起来最自然。
样本量这边给个参考:单只股票十年日频数据约 2400 条,扣掉预热期和测试集,训练样本大约 1400 条,验证集约 480 条,测试集约 480 条。如果要做多只股票的横向对比,建议先在一只股票上把流程跑通,再扩展到 3 到 5 只,别一上来就上几十只,调参时间会失控。
4.2 RMSE / MAE / MAPE / R² 的口径统一
四个指标各有侧重,论文里最好四个都给,但要在正文里说明各自说明什么问题。
| 指标 | 公式要点 | 说明什么 | 注意事项 |
|---|---|---|---|
| RMSE | 均方误差开根号 | 对大误差敏感,惩罚离群预测 | 量纲与价格一致,便于直观理解 |
| MAE | 绝对误差平均 | 平均偏差水平,抗离群 | 与 RMSE 差距大说明存在极端误差 |
| MAPE | 绝对百分比误差平均 | 相对误差,跨股票可比 | 价格接近 0 时会爆炸,低价股慎用 |
| R² | 1 - SS_res/SS_tot | 拟合优度,解释方差比例 | 负值说明不如直接取均值 |
有个细节必须统一:所有指标都在还原到原始价格量纲之后再计算。如果模型是在差分序列或归一化序列上训练的,评估前一定要反变换回去,否则 RMSE 的数字会小得毫无意义,答辩时被问一句“你这个 RMSE 单位是什么”就答不上来。
另外,MAPE 在股价预测里其实争议不小,因为股价绝对值的变化幅度和百分比变化幅度不是一回事。我的做法是主指标用 RMSE 和 MAE,MAPE 作为辅助,并在论文里注明它的局限性,这样反而显得你想得清楚。
4.3 消融实验:逐层加模块的对比表
这套方案最有说服力的地方,是它能做出一条清晰的逐级对比链。我一般会跑五组:
| 实验组 | 模型 | 说明 |
|---|---|---|
| A | 原始 XGBoost | 不做任何优化,默认参数 |
| B | GS-XGBoost | 加网格搜索调参 |
| C | DWT-GS-XGBoost | 再加小波去噪 |
| D | ARIMA | 单独的时间序列基线 |
| E | DWT-ARIMA-GSXGB | 完整混合模型 |
在测试集上,通常能看到这样一条趋势:A 到 B 有提升但有限,说明调参的边际收益在递减;B 到 C 提升比较明显,说明数据质量比模型复杂度更重要;D 单独用 ARIMA 在中长期趋势上表现尚可但拐点处误差大;E 综合了 C 和 D 的优点,RMSE 和 MAE 都最低,R² 最高。
写论文时,这张表后面必须跟一段解释,说清楚每一级提升来自哪个机制,而不是只把数字一贴了事。评委最爱问的就是“你这个提升是调参调出来的还是结构带来的”,消融表就是标准答案。
5. 踩坑与排查:这套混合模型最容易翻车的五个地方
下面这五条,是我在实际跑这套流程时反复遇到的问题,每条都按“现象→原因→解决”写,照着排查能省不少时间。
第一坑:测试集指标好得离谱,RMSE 低到不符合常识。现象是模型在测试集上的误差比训练集还小,R² 接近 0.99。原因几乎一定是数据泄漏:要么特征里混进了未来信息(比如用了rolling().mean()却没shift(1)),要么标准化时用了全量数据的均值方差,要么随机打乱了时间顺序做交叉验证。解决办法是把特征构造函数单独拎出来,逐列检查是否存在t时刻之后的信息,标准化一律改成在训练集上fit、在测试集上transform。这一条排查完,指标通常会“难看”一大截,但那才是真实水平。
第二坑:ARIMA 定阶报错或者拟合不收敛。现象是auto_arima抛LinAlgError或者跑很久不结束。原因通常是序列不平稳、存在缺失值,或者d阶数设得不对。解决方法是先做 ADF 检验确认平稳性,缺失值用前向填充处理掉,d从 1 开始试,实在不行就手动指定(p,d,q)而不用自动定阶。另外,auto_arima在长序列上很慢,可以先在训练集上定阶,拿到阶数后固定下来,验证集和测试集不再重新定阶。
第三坑:小波重构后序列长度和原序列对不上。现象是waverec返回的数组比输入长一两个点,后面做减法直接报ValueError。原因是离散小波变换的边界延拓机制导致的,属于正常现象。解决办法就是代码里那句[:len(series)],或者用pywt.waverec之后配合pywt.pad做对齐。这个问题在换小波基或者换分解层数时会反复出现,建议封装成一个函数,别在每个脚本里各写一遍。
第四坑:XGBoost 的网格搜索跑了一整天还没结束。现象是参数组合数上了四位数,每个组合还要做五折交叉验证,时间直接爆炸。原因是搜索空间设得太宽,而且没有利用早停。解决办法是改成两轮搜索:第一轮粗搜控制在 100 组以内,第二轮细搜控制在 50 组以内;同时把n_estimators设大并开early_stopping_rounds,实际训练的树数量会远小于设定值。另外可以用n_jobs开多核,但注意别把机器跑死,留两个核给自己干活。
第五坑:融合之后效果反而变差。现象是 DWT-ARIMA-GSXGB 的 RMSE 比单独的 DWT-GS-XGBoost 还高。原因通常是融合权重没调好,或者 ARIMA 的残差里已经包含了 XGBoost 学不到的东西,硬加反而引入噪声。解决办法是先看残差预测的均值,如果明显偏离零,说明 XGBoost 学出了系统性偏差,需要检查特征里是否混入了和残差强相关的未来信息;如果均值接近零但融合变差,就把权重往 0 调,甚至直接退回不使用 ARIMA 的版本。混合模型不是加得越多越好,这一点要有心理准备。
6. 答辩与验收:怎么用三张表讲清这套模型的价值
到了验收这一关,评委不会逐行看你代码,他们看的是你能不能把“为什么这么做”讲清楚。我的经验是准备三张表,按顺序讲,十五分钟内能讲完。
第一张是结构表,就是第 2 章那个模块职责表,用来说明每个模块的输入输出和数据契约。讲的时候重点强调“每一层都能单独关掉”,这句话能直接引出第二张表。
第二张是消融表,五组实验的 RMSE、MAE、R² 并排列出来,每一行后面标注“相比上一组提升了多少”。讲这张表的关键不是念数字,而是解释每一级提升的机制:调参提升的是模型容量匹配度,小波去噪提升的是输入数据质量,ARIMA 融合提升的是对线性惯性的覆盖。评委听到机制解释,才会认为你理解自己的模型,而不是碰巧跑出一个好数字。
第三张是误差分布表或图,把测试集上的绝对误差按价格区间或者按波动率分组统计。这张表的作用是主动暴露局限:比如在高波动区间的误差明显更大,说明模型在极端行情下不稳。主动说出局限,比被评委问出来要好得多,而且能自然过渡到“后续可以怎么改进”。
答辩时还有几个高频问题,提前准备好答案:为什么不用 LSTM(样本量、训练成本、可解释性);小波基为什么选 db4(平滑性与局部性的平衡,且是金融时序的常用选择);网格搜索为什么不用贝叶斯优化(组合数不大时网格搜索更直观,且易于复现);融合权重为什么在验证集上定(避免在测试集上调参)。这四个问题答顺了,基本就稳了。
我自己的习惯是,每次答辩前把三张表打印出来,对着讲三遍,掐表控制在十二分钟,留三分钟缓冲。第一次做这套方案的时候,我在消融表上栽过跟头——只贴了数字没讲机制,被追问了七八分钟,场面很难看。从那以后我每次做对比实验,都强制在表后面写一段“提升来自哪里”的说明,哪怕只有两句话。希望这套结构能帮到你,少走一点弯路。
本文还有配套的精品资源,点击获取