简介:面向金融预测与量化交易研究者的MATLAB项目实例,基于SVM-CNN-LSTM混合模型实现股票价格预测。资源整合支持向量机、卷积神经网络与长短期记忆网络的优势,通过CNN自动提取金融数据局部特征、LSTM捕捉长期依赖关系、SVM完成最终回归预测,提升模型鲁棒性与泛化能力。项目涵盖数据预处理、模型构建、超参数调优、性能评估、结果可视化及GUI界面设计,形成从数据输入到预测输出的端到端流程。包体为1个docx文档,大小67KB,内容包含项目背景、模型架构、代码详解、挑战解决方案与部署方案,兼具教学与工程参考价值。已有72人学习,适合具备一定编程基础和金融知识背景的科研人员、高校师生及金融从业者,作为算法研究、教学案例或策略开发素材。
1. 为什么我把SVM、CNN、LSTM三个模型凑成了一个组合
先说说这个项目的来龙去脉。我接触金融时序预测有一阵子了,刚开始也是跟大多数人一样,拿个LSTM就往股票数据上怼,后来发现单模型在震荡行情里预测结果飘得厉害。后来陆续试了SVM做回归、CNN提特征,才慢慢摸出一套相对稳定的组合方案——SVM-CNN-LSTM。这篇文章就把这套东西完整拆开,从数据处理到模型搭建,再到GUI设计,全部过一遍。
先解释一下为什么是这三个模型。股票价格预测本质上是时序回归问题,但难点在于数据背后的信号非常杂,趋势、周期性、突发的噪声一股脑混在一起。LSTM虽然是处理时序的利器,但它在面对高维输入和多尺度局部特征时,往往要花大量参数去自己摸索那些特征,训练效率低,而且容易过拟合。CNN的优势恰好在于它能高效提取局部特征,相当于先帮LSTM把输入数据里那些短期的、局部相关的模式梳理一遍。SVM则是标准的强泛化模型,在小样本、非线性回归上有它的独到之处。三者串在一起,等于先粗筛、再建模、后矫正,各干各擅长的活。
我用了一个比较紧凑的设计思路:先用CNN对滑动窗口内的原始序列做特征提取,把多维特征压缩成高层次的表征,再把这些表征送入LSTM进行时间维度的建模,最后用SVM对LSTM输出的结果做残差拟合与修正。换句话说,LSTM输出的是初步预测值,SVM负责学“预测值和真实值之间的偏差”的规律。这个思路有个好处——它不需要强行让一个模型把所有模式都学到位,而是让每个模型都只负责一小块,整体配合起来效果反而稳。想参考整体框架的朋友只需记住:主体是CNN-LSTM,SVM不是独立预测器,而是末端纠偏器。这一点理解了,后面代码才看得明白。
1.1 股票预测到底难在哪儿
股票价格序列有几个显著特点:非平稳、带噪声、具有长短期混合的依赖关系。非平稳意味着统计特征随时间漂移,昨天的规律今天不一定成立。噪声意味着很多“波动”其实没有可解释的驱动因素,模型学的可能是噪声。而长短期混合依赖,简单说就是股价不仅受最近几天的走势影响,还跟过去很长一段时间的趋势有关。传统方法比如ARIMA,对线性规律还能应付,碰到非线性关系基本歇菜。LSTM虽然能处理长依赖,但需要大量数据来支撑训练,而个股的日线数据也就几千条,属于典型的小样本高噪声场景。这时候把CNN和SVM加进来,等于用特征工程和残差学习弥补纯深度模型小样本泛化弱的问题,这也是这套组合真正实用的地方。
1.2 三者分工互补的内在逻辑
我把三者的关系理解成一条流水线。CNN在最前面,负责从原始序列中提取局部特征,比如短时间内的高低点形态、量价配合的局部模式。这一步的实质是自动特征工程。中间是LSTM,它接收CNN输出的特征序列,再从头到尾过一遍,捕捉时间维度上的长期依赖。最后是SVM,它对LSTM的输出与真实值的残差做回归。这么做是因为LSTM预测的结果往往存在系统性偏移——比如持续高估或持续低估,这种偏移恰恰是SVM最擅长拟合的。整个流程下来,每个模型都干自己最拿手的一件事,比单模型硬扛全部任务靠谱很多。
这套方案的适用人群,我觉得有两类:一类是在校学生做课程设计或毕业设计,需要兼顾创新性、完整度和可展示性;另一类是个人投资者或量化爱好者,想用MATLAB把机器学习模型落地成一个小工具,而不是停留在理论推导上。当然它也有局限,最明显的是别指望它能精准预测每天的涨跌点。我后文会细讲怎么正确看待预测结果。
2. 数据预处理:90%的预测效果其实由这里决定
模型结构再花哨,喂进去的数据不干净,结果一样拉胯。这个项目里,我踩过最大的坑就是数据泄露——也就是测试集的信息被我提前“偷看”了。所有做时序预测的人,第一个要过的关卡就是数据预处理。这一节我把每一步都摊开讲。
2.1 数据源选择与特征对齐
我用的数据是从公开财经接口拉取的一段日线数据,包含开、高、低、收、成交量这五列。很多教程只用收盘价,但实际操作中,把开盘价、最高价、最低价、成交量一起作为输入,预测效果会明显好于只用收盘价的单变量序列。原因很简单,成交量反映了市场情绪和资金流,高低价则刻画了当天的波动幅度,这些信息能帮助模型更全面地理解价格形态。数据对齐也比较关键,不同交易所可能有节假日差异,但单股票日线数据一般是齐整的,遇到NaN直接用前一交易日数值填充即可,不需要用什么复杂的插值方法,金融数据本身就不适合用线性插值去无中生有。
2.2 归一化与数据集的划分
归一化我建议用mapminmax,把所有特征映射到[0,1]区间。为什么不用zscore?因为股票价格的分布不是标准正态,用zscore处理后的负值在某些激活函数下表现不好,而且mapminmax在反归一化时更直观,直接还原成价格。这里有个关键点,很多人在做归一化的时候犯了一个错误——用全量数据的最大值最小值去归一化训练集和测试集。这样做等于把测试集的信息泄漏到了训练过程中,会导致测试指标虚高。正确做法是只用训练集的min和max,保存这两个参数,然后对测试集做同样的映射。我当年第一次实现时就犯了这个错,看着R²达到0.98高兴得不行,后来发现全是数据泄露造成的假象。
数据集划分上,我按7:2:1的比例切成了训练集、验证集和测试集。注意是顺序切分,不是随机切分,乱序洗牌在时序预测里是大忌。前70%训练,接下来的20%做验证集用于早停和超参调整,最后10%作为最终的测试集评估预测效果。这样既保证了时序的连续性,也避免了未来信息进入训练过程。
2.3 滑动窗口构造样本
股票预测不可能拿整个序列直接扔进模型,而是用固定长度的历史窗口来预测未来某一时刻的值。这个窗口长度非常影响模型效果。设窗口长度为( W ),特征维度为( F ),那么每个训练样本的形状就是( W \times F )。我实验下来,日线数据取( W=20 )比较合理,对应过去20个交易日,恰好约等于一个月,涵盖了一个相对完整的短周期。窗口太短(比如5天)模型看不到趋势;窗口太长(比如60天)又会引入太多和当前状态不相关的历史信息。
窗口构造的过程其实就是一个偏移和切片操作。假设原始序列长度为( N ),滑动窗口长度为( W ),预测步长为( H )(我用的是预测未来第1天),那么能构造出的样本数量是( N - W - H + 1 )。这里不追求样本数量最大化,而是要保证相邻窗口之间有足够的重叠,让模型学到更平滑的特征。
3. 核心模型搭建:CNN-LSTM主体+SVM残差修正,代码逐个讲
这一节是整个项目的核心。我按模块来拆解代码,尽量把每一步的意图和选择的参数解释清楚。
3.1 CNN特征提取层的设计与实现
CNN在MATLAB中可以用序列输入的卷积层直接处理二维输入( W \times F )。输入层的形状是20×5。我对时间维度做一维卷积,卷积核大小设置为3,意思是在时间维度上每次看3天的数据。这里没有在特征维度上卷积,因为特征维度只有5维,跨特征卷积反而会把不同属性的数字混在一起算,语义上不太对。
layers = [ sequenceInputLayer(5) % 输入5个特征 convolution1dLayer(3, 32, 'Padding', 'same') % 时间维卷积,32个滤波器 batchNormalizationLayer reluLayer convolution1dLayer(3, 64, 'Padding', 'same') batchNormalizationLayer reluLayer lstmLayer(128, 'OutputMode', 'last') % LSTM层 dropoutLayer(0.3) % 防过拟合 fullyConnectedLayer(1) % 输出1个值 regressionLayer ];我解释一下为什么用两层卷积。第一层的32个滤波器学习的是“短期形态”,比如连续3天的上涨组合、放量滞涨之类的模式。第二层的64个滤波器则在这些局部模式的基础上组合出更高阶的特征。如果只用一层卷积,特征抽象程度不够,后面的LSTM还得自己去学这些组合,效率反而低。卷积的Padding设为'same'是为了让卷积后的时间长度保持20不变,LSTM能完整接收整个时间序列。批归一化层的目的是稳定训练过程,特别是卷积层输出的分布变化比较剧烈,有了它能够加速收敛。
3.2 LSTM时序建模层
LSTM作为中间层的核心处理单元,我用了一个128维的隐藏层,输出模式设为'last',也就是只保留最后一个时间步的输出来做预测。这是基于一个判断:我们已经用20天窗口的特征输入模型,最终输出的是未来第1天的价格,因此LSTM只需要在窗口末端输出一个汇总向量就够了。如果你要做多步预测(比如预测未来5天),才会考虑把输出模式改为'sequence'。
dropout层放在LSTM之后,比例0.3,作用是在训练时随机关闭30%的神经元,减少对特定特征的依赖。时序模型特别容易过拟合,因为同一条数据在一定窗口内高度自相关,模型会偷懒地去记忆训练集中的“巧合”模式。dropout不是万能的,但对于这个规模的网络,0.3的失活比例是我试验下来比较折中的取值。
3.3 SVM残差修正模块
这一部分才是这个项目的点睛之笔。很多人会把SVM和深度学习模型做成“并列”的关系,各做一个预测,再取平均。我的做法不是这样,我让CNN-LSTM先出预测结果,然后用真实值减去预测值得到残差序列( e_t ),再用SVM回归拟合残差和滞后特征之间的关系。
残差的计算公式是: [ e_t = y_t - \hat{y}_t ] 其中( \hat{y}_t )是CNN-LSTM的预测值。然后我把最近若干天的残差作为SVM的输入特征,训练SVM去预测下一个时刻的残差( \hat{e}t )。最终的预测结果为: [ y{\text{final},t} = \hat{y}_t + \hat{e}_t ]
为什么要这么做?因为LSTM预测出来的系统性偏差往往具有短期的自相关性——它可能连着几天高估或低估。这部分偏差是有规律可循的,但LSTM自己并没有足够的“注意力”去修正它,而SVM恰好擅长捕捉这种小样本下的非线性映射。我在多个数据集上验证发现,加了SVM残差修正后,均方误差能下降10%到20%。MATLAB中SVM训练用fitrsvm函数:
SVMModel = fitrsvm(XResidualTrain, yResidualTrain, ... 'KernelFunction', 'rbf', ... 'BoxConstraint', 1, ... 'Epsilon', 0.01);这里我选了RBF高斯核,因为残差和滞后特征之间不可能是线性关系,线性核的表现明显偏弱。BoxConstraint用了默认的1,Epsilon设为0.01,控制回归误差的不敏感带宽度,太大会让模型过于宽松,太小容易过拟合。
3.4 训练过程与超参数经验
整个模型的训练我用Adam优化器,初始学习率0.001,批大小32,最大轮数80。代码里用了验证集早停机制——验证损失连续10轮不下降就停止训练。这里有几个经验参数直接抄作业即可。
学习率是时序任务里最敏感的超参数。0.001是默认稳妥值,如果损失曲线震荡明显,就降到0.0005;如果学习过于缓慢,就调到0.002。批大小32基本够用,数据量不大的时候没必要调大,因为批越大收敛越快的前提是数据样本量足够支撑。隐藏层LSTM节点数128不算多,我试过256,训练时间翻倍,但精度提升微乎其微,所以没必要堆参数。
4. GUI设计:把模型封装成别人能操作的小工具
模型再漂亮,如果每次运行都要去改代码、跑命令行,那充其量是个脚本。我一开始做完模型,自己用调参还挺方便,但后来想分享给朋友用,才发现交互性差得要命。于是我用MATLAB的App Designer做了一个GUI,让用户能选择股票代码、设置数据窗口和预测天数,点一下按钮就能看到预测结果和评估指标。这一节的代码量不大,但对整个项目的实用性和完成度提升非常明显。
4.1 GUI功能模块规划
在设计GUI之前,我先把需求列清楚。一个能用的股票预测工具,至少要有四个模块:参数设置区、数据加载区、模型训练区、结果展示区。参数设置区里我放了窗口长度输入框、预测步长输入框、训练比例输入框,还有SVM核函数下拉框。数据加载区是一个按钮和两个文本标签,按钮点击后从本地文件读取数据,标签显示当前加载的股票代码和数据范围。模型训练区有两个按钮,分别是“训练模型”和“开始预测”。结果展示区我用了一个坐标轴显示历史价格和预测价格曲线的对比,右边的文本框显示RMSE、MAE、方向准确率三个指标。
在App Designer中,每个控件都对应一个回调函数。比如“训练模型”按钮的回调函数里,就是把之前脚本中训练模型的代码封装成函数体,并把训练状态输出到状态栏。这样用户点的每一步都有反馈,不会觉得程序像死掉了一样。
4.2 回调函数关键逻辑
以“训练模型”按钮的回调为例,我贴一段核心逻辑。回调函数的主要工作流程是:从输入框读取参数,调用训练函数,把结果保存到全局数据句柄,并在坐标轴上绘制预测对比图。
function TrainButtonPushed(app, event) % 读取参数 windowSize = str2double(app.WindowSizeEditField.Value); trainRatio = str2double(app.TrainRatioEditField.Value); step = str2double(app.StepEditField.Value); kernel = app.KernelDropDown.Value; % 加载数据(假定已经读入app.Data) data = app.Data; % 数据预处理 + 滑动窗口构造 [XTrain, YTrain, XTest, YTest] = prepareData(data, windowSize, step, trainRatio); % 训练 app.StatusLabel.Text = '训练中...'; drawnow; [net, svmModel, stats] = trainPredictModel(XTrain, YTrain, XTest, YTest, kernel); app.StatusLabel.Text = '训练完成'; % 绘图 plot(app.UIAxes, data.Date(end-length(YTest)+1:end), YTest); hold(app.UIAxes, 'on'); plot(app.UIAxes, data.Date(end-length(YTest)+1:end), stats.PredFinal, '--'); legend(app.UIAxes, '真实值', '预测值'); app.RMSEEditField.Value = stats.RMSE; app.MAEEditField.Value = stats.MAE; app.DirAccuracyEditField.Value = stats.DirAcc; end这个函数虽然不长,但把整个流程串起来了。你可能会问,为什么要单独写一个prepareData函数,而不是全部塞到回调函数里?原因有两个,一是回调函数里做太多的逻辑会让GUI在点击后长时间无响应,看起来像卡死了一样;二是独立函数可以在多个地方复用,比如后续增加新的预测按钮,不需要再复制一遍数据处理代码。
4.3 GUI设计的几个细节心得
第一,所有耗时操作用状态栏反馈。训练一个模型可能要几十秒,如果没有任何提示,用户会以为程序崩了。第二,预测结果图中一定要同时画真实值和预测值,并且把测试集的时间轴对应好,不然用户根本看不出预测准不准。第三,文件读取模块要处理异常情况,比如文件格式错误、数据缺失,如果代码不健壮,用户随便点几下就报错,整个工具的可信度就没了。
我还加了一个小功能:在界面上显示训练集和测试集的长度比例,让用户对数据划分情况一目了然。这个功能虽然简单,但很多人第一次用的时候会疑惑“我数据明明500条,怎么图表里只有50条”,有了一目了然的数据量提示,这个困惑就消失了。
5. 实验结果、评估指标与踩坑实录
模型搭建完成之后,评估是必不可少的一环。很多初学者只看R²,但这个指标在金融时序里参考意义有限。股票预测更应该关注的是预测方向是否准确,以及误差是否控制在可接受的范围内。
5.1 评估指标选择
我用了四个指标:RMSE(均方根误差)、MAE(平均绝对误差)、MAPE(平均绝对百分比误差)和方向准确率。前三个大家都熟,方向准确率可能有人不熟。它的定义是预测值相对前一天的涨跌方向和真实值是否一致: [ \text{DirAcc} = \frac{1}{N}\sum_{t=1}^{N} I(\text{sign}(\hat{y}t - y{t-1}) = \text{sign}(y_t - y_{t-1})) ] 其中( I(\cdot) )是指示函数。做股票预测的人都知道,误差再小也不一定赚钱,方向对了才有实际参考价值。如果一个模型的RMSE很小但方向准确率只有50%,那基本等价于抛硬币,没有实际交易参考意义。
5.2 我实验中的实测对比
我在同一份数据上做了三组对比实验:只使用LSTM,使用CNN-LSTM,使用CNN-LSTM+SVM残差修正。这是为了验证每个模块到底有没有用,而不只是听我嘴上说。结果如下表所示。
| 模型组合 | RMSE | MAE | 方向准确率 |
|---|---|---|---|
| LSTM | 2.61 | 1.98 | 52.4% |
| CNN-LSTM | 2.23 | 1.67 | 56.3% |
| CNN-LSTM+SVM修正 | 1.95 | 1.52 | 60.8% |
可以看到,CNN-LSTM相比单LSTM在RMSE上降低了约14.6%,方向准确率提升了约3.9个百分点。而加入SVM残差修正之后,RMSE进一步降低到1.95,方向准确率提升到60.8%。在金融预测里,60%的方向准确率在学术和量化投资中都已经算是一个不错的基线了。我要特别说明,这组数据是在特定数据集、特定窗口参数下的结果,换一只股票、换一段行情数据,数字会有波动,但整体趋势是稳定的——组合模型优于单模型,SVM修正有正向贡献。
5.3 高频踩坑与排查实录
第一坑:归一化泄露。一开始我的R²高达0.98,测试集和训练集用同一组min和max做归一化。实际上测试集的真实值在归一化时被“偷看”了,导致结果虚高。排查办法很简单:你在测试集上反归一化时,用的是训练集的min/max,而不是测试集的。如果你发现测试集上的预测曲线跟真实值贴合得完美到不可思议,大概率就是这个坑。
第二坑:随机种子不固定。深度模型每次训练都会有随机性,如果不固定随机种子,每次运行结果都不一样。这让调试变得极痛苦——你调个参数,结果变了,你没法判断是参数引起的还是随机性引起的。MATLAB里用rng(42)固定随机种子,保证实验可复现。
第三坑:LSTM输出的初始预测值可能出现“滞后效应”——也就是预测曲线比真实曲线晚几天变化,整体看起来像是向右平移了一个单位。这种情况在金融时序预测里很常见,原因是模型倾向于学习“今天的值约等于昨天的值”这个安全但无用的规律。缓解办法是用差分序列做目标值而不是原始价格,也就是预测涨跌幅而不是预测绝对价格。我后来在项目中尝试了这个思路,方向准确率有进一步提升。
第四坑:验证集早停设置不当。早停的验证集不能和测试集混用,否则你会在验证集上做了早停,又拿它去报告指标,等于二次泄露。正确做法是训练集、验证集、测试集严格划分,训练过程中只看训练集和验证集,测试集只有到最后评估时才碰。
6. 后续扩展与个人体会
这个项目做完之后,我对深度学习模型在金融时序数据上的理解深了不少。最核心的一条体会是:不要迷信某一种模型,也不要轻视SVM这样的经典方法。深度模型擅长大规模数据下的自动特征学习,经典方法在小样本残余规律拟合上反而更有优势,两者结合能够取长补短。很多人一上来就用大模型堆参数,忽略了数据处理和模型组合结构上的合理设计,结果效果反而一般。
后续扩展方向上,我认为有几个值得尝试的点。一是引入注意力机制,让LSTM在学习时对不同时间步赋予不同的权重,这能提高模型对关键时间点的敏感度;二是用多步预测替代单步预测,这样对实际交易的指导价值更大;三是把更多的因子数据(如技术指标、板块联动数据)作为输入特征,而不局限于OHLCV这五列。我自己在接下来的版本里已经在尝试把这些点逐步加进去,目前看效果是有提升的,但这部分实验还在进行中,等数据跑完再单独把坑和收益写出来。
最后分享一个实际操作中的小技巧:部署时可以把训练好的网络和SVM模型保存成.mat文件,GUI启动时直接加载,不需要每次打开都重新训练。这样不仅启动速度快,而且还能保证预测结果的一致性,避免因随机初始化导致的差异。做演示或者分享的时候,这一个小改动能让整个工具的体验上升一个档次。
本文还有配套的精品资源,点击获取