1. 为什么非要把Transformer、LSTM、SVM塞进同一个模型
1.1 单一模型各自的天花板在哪
做股票价格预测这个方向的人,大概率都经历过"单独用一个模型"的阶段。我在这个项目之前,分别用LSTM和SVM单独跑过同一份数据,当时的结果相当冷静地提醒了我:没有任何单一模型能同时吃下股价序列里的全部结构。
先说SVM。支持向量机在样本量不大、特征维度高的场景下非常能打,尤其擅长在高维空间里找决策边界。可它本质上是静态模型,处理带时间顺序的序列数据时,得靠人工手动构造特征窗口。早期我做SVM预测,用的是过去20天的收盘价、成交量、技术指标拼成一个特征向量,效果一开始还行,但换了一轮行情风格就明显掉链子。原因不复杂:股价数据是强非平稳、强噪声的时间序列,手工特征很难覆盖到不同时间尺度上的依赖关系。
再说LSTM。长短期记忆网络专门为序列建模设计,门控机制让它可以记住较长时间范围内的信息,在时间序列预测里长期是主流选择。但LSTM有它自己的瓶颈:单方向传播时的长期依赖传递会有衰减,即便加了peephole、双向机制,对"全局依赖"的捕捉依然不充分。最典型的场景是,当某段走势的驱动因素在很久之前就已经出现,而中间又被大量无关噪声间隔开,LSTM的记忆往往会逐渐被"冲淡"。
最后是Transformer。自注意力机制让每个时间步都能直接和所有历史位置建立联系,在捕捉全局依赖上比RNN结构天然有优势。但Transformer的问题也明显:参数多、极其依赖数据量,在股票这种低信噪比、样本量有限的场景里,单独硬train一个Transformer很容易过拟合,把噪声当成规律学进去。
所以当我把三者的特性摆在一起时,想法就很自然了:既然没有单兵全能选手,那就让它们打配合。SVM负责高维空间里做稳健决策,LSTM负责在时间轴上梳理顺序依赖,Transformer负责跨位置的全局信息提取。关键是配合方式要设计好,不能简单把三个模型串在一起就完事。
1.2 三者怎么配合:我最终采用的混合架构
混合模型大体上分成两条技术路线:并行集成和串行级联。
并行集成的思路是让三个模型独立预测,再把结果做加权融合。这个方案的好处是工程上简单,各模型之间互不干扰,哪个模型出问题也不会"传染"给别的模型。缺点是它根本没有利用到三个模型在不同抽象层次上的互补关系——三者是平行关系,而不是上下游关系。
串行级联的思路则是把前一个模型的输出作为后一个模型的输入,形成一条完整的流水线。我最后采用的是"Transformer编码器 → LSTM → SVM"的串行结构,用一句话概括就是:
Transformer先把原始序列转成富含全局依赖关系的特征表示,LSTM在这个特征表示上继续提取时间顺序模式,最后由SVM在融合特征空间里给出最终预测。
这个架构的设计逻辑是层层递进的。原始股票数据经过标准化之后,首先进入Transformer编码器。自注意力机制在这里做的是全局特征提取——它不关心位置远近,只要序列中某两个时间点之间存在强相关性,注意力权重就会把它们关联起来。这正好补上了LSTM对长期依赖捕捉的短板。
Transformer输出的特征序列再进入LSTM层。有人可能会问:Transformer不是已经捕捉了全局依赖吗,为什么还要LSTM?这里涉及一个关键差异:Transformer的自注意力对位置的建模相对粗糙,位置编码只是一个"附加标记",很难像LSTM那样通过门控机制对信息随时间变化的过程进行精细建模。LSTM在这个位置承担的是"时间顺序精修"的角色,把Transformer粗提取的全局特征进一步加工成更适配预测目标的时序特征。
最后接SVM,本质上是把这个混合模型的最后一步当作一个高维空间中的回归/分类问题来处理。LSTM输出的特征向量被压缩、拼接后,作为SVM的输入。SVM在相对高维的特征空间中找最优超平面,对噪声的鲁棒性比直接用全连接层Softmax要好,在小样本场景下尤其明显。
1.3 第一版方案的失败教我重新理解"混合"
这里必须坦白:我最初一版方案不是这样的。当时我的想法比较简单粗暴——把Transformer、LSTM、SVM三者做简单的输出平均:
final_pred = 0.33 * pred_transformer + 0.33 * pred_lstm + 0.34 * pred_svm;结果那版的效果甚至不如单独跑一个LSTM,原因事后想明白了:三个模型的能力边界高度重叠,单独预测出来的残差模式也很相似,简单的平均只是把三个"差不多的错误"平均了一下,并没有真正互补。更麻烦的是,SVM这种静态模型用的是滑动窗口输入,和序列模型的时序粒度都不一样,强行对齐预测点再平均,等于硬把一个低时间分辨率的结果和一个高时间分辨率的结果放在同一个维度上打架。
那版失败之后,我才重新梳理了三个模型各自的定位,把方案从"并行平均"彻底改成了"串行级联"。这个改动看似不大,但最终在测试集上的方向准确率提升非常明显。我后文会具体展开各版本融合策略的对比,这里先记住一个结论:
混合模型的价值不在"模型多",而在"分工清晰"。每个模型都要做自己最擅长的事,然后把上一环节的结果交给下一环节继续加工,而不是让所有模型各自为战。
写到这里,如果你对这个架构的合理性还有疑虑,我建议你带着一个问题往下看:Transformer的特征表示到底是直接喂给LSTM,还是可以先做压缩再做时间建模?我在第4章会讲到这个细节,它直接影响最终的预测质量。
2. 数据预处理比模型本身更决定预测上限
2.1 数据来源与基础字段的处理思路
股票价格预测项目里,数据的质量直接决定模型效果的上限,而模型只是在逼近这个上限。我用的数据是以日线级别的个股行情为主,字段包含开盘价、最高价、最低价、收盘价、成交量、成交额这些最基础的六项。没有用分钟线,因为分钟线的噪声水平太高,且以我当时的算力,日线级别已经足够验证混合架构的有效性。
数据获取上我是在MATLAB里直接完成导入的。如果你有Wind终端或者Choice接口,可以用MATLAB的Datafeed Toolbox去拉;如果只是个人研究,历史上很多公开数据集也可以用readtable直接读CSV。关键不是来源,而是字段的完整性和时间区间的连续性。
拿到原始行情之后,第一步先处理的是缺失值和停牌数据。A股个股经常因为停牌出现连续多日没有交易记录,这些缺口如果直接在序列里保留,模型会误以为时间连续,但实际上中间隔了很长时间。我第一版直接drop掉空行,结果模型在缺口附近疯狂出错,后来改成:先识别停牌区间,用前向填充的方式补上行情的"交易状态标记",时间特征保持原样,让模型自己学会"这里有一段空窗期"这件事。
2.2 七个关键预处理步骤,一步不能省
整个预处理流程我拆成了七步,每步都有它存在的理由,缺一步后面模型效果都会打折。这里直接列出来,后面对容易出错的步骤单独展开讲。
- 剔除或修复异常交易数据:比如某天收盘价突然出现极端异常值,先用中位数或前后均值平滑掉,避免单个错误数据污染整个归一化统计量。
- 复权处理:股票分红送股会导致价格出现"断层式"跳变,不复权的数据会让模型误以为这是真实的行情波动。我统一使用前复权价格,保证历史价格序列的连续性。
- 价格转对数收益率:大多数预测模型直接拟合原价格,但原价格是非平稳序列,容易让模型学到"价格永远在涨"这种假规律。对数收益率r_t = ln(P_t / P_{t-1})在数学上是可加性的,统计性质更平稳,也更适合作为模型输入。
- 构造技术指标特征:这里我加了相对强弱指标RSI、移动平均线MA5/MA10/MA20与收盘价的乖离率、成交量变化率等。这些手工特征虽然朴素,但可以给Transformer的注意力机制提供一些"领域先验",让自注意力更容易找到重点位置。
- Z-Score标准化:把所有特征分别做零均值单位方差标准化。关于标准化,有个极易踩的坑是:必须只用训练集的均值和标准差,去转换验证集和测试集,而不是在整个数据集上直接算。整个数据集一起算等价于把未来信息泄漏到了训练过程里。
- 滑动窗口样本构建:用过去60个交易日的数据预测下一个交易日的收盘价方向或收益率。这个窗口长度我对比过30、60、90三个版本,60的效果最均衡。窗口太短,Transformer的全局优势发挥不出来;窗口太长,样本量迅速稀释,过拟合风险增加。
- 训练集/验证集/测试集切分:严格按照时间顺序切分,不能随机打乱。我用的比例是训练集70%、验证集15%、测试集15%,时间上依次向后排列。
2.3 防数据泄漏的几个容易忽略的细节
数据泄漏是时间序列预测里最隐蔽的坑,它的表现往往是:训练集指标和验证集指标都很好,一上测试集或者实盘就崩。我在这类项目上吃的亏不少,总结了三个最容易忽略又最致命的细节。
第一个是前面提到的标准化统计量泄漏。很多教程喜欢用zscore把整列数据一次标准化,这在普通机器学习任务里没问题,但时间序列里就等于让模型在训练时"偷看"了未来数据的均值方差。我后来专门写了一个函数,只接受训练集的统计量作为参数:用[train_norm, mu, sigma] = zscore(train_data)得到mu和sigma,然后对验证集和测试集手动(data - mu) / sigma。
第二个是样本重叠泄漏。滑动窗口以60天为长度往前滑,相邻两个样本之间有59天的数据是重叠的。这在模型训练本身不算泄漏,但如果做样本级别的随机打乱,就属于把时间上高度相关的样本同时放进了训练和预测阶段,使得验证集的评估虚高。我最终的处理方式是:训练集内部可以随机洗牌(有利于SGD收敛),但验证集和测试集必须按时间顺序逐窗口评估,绝不重排。
第三个是数据去趋势的方向。预测的目标变量到底选原始价格、一阶差分还是对数收益率,这个问题看似简单但后果差异很大。我对比下来,对数收益率作为预测目标比直接预测价格要稳定得多,原因是价格本身非平稳,模型拟合目标复杂;而收益率相对平稳,SVM在高维空间里给它划回归超平面也容易得多。最终预测价格时再通过收益率的累积还原即可,后文代码部分会有完整实现。
做完这七步,特征矩阵的维度大概是[样本数, 60, 特征维度],我的特征维度是10(包含4个价格字段、1个成交量、5个技术指标)。这个输入格式刚好可以对齐Transformer编码器的序列输入要求。
3. Transformer编码器在Matlab里的落地实现
3.1 Matlab深度学习工具箱的现实情况
先说明一个现实情况:Matlab不像PyTorch和TensorFlow那样有开箱即用的torch.nn.TransformerEncoder,至少在R2023a之前,深度学习工具箱里没有一个直接叫"Transformer层"的模块。虽然R2023a开始引入了一些Transformer相关的支持(transformerNetwork),但如果你需要灵活控制多头注意力的头数、嵌入维度等超参数,并且要把Transformer的输出接给自定义的LSTM和SVM流程,最稳妥的方式还是用自定义层 +dlarray手写核心模块。
这个"手写"并没有听起来那么可怕,Transformer的核心就两块:多头自注意力(Multi-Head Self-Attention)和前馈网络(Feed-Forward Network),每一块在Matlab里用基础的矩阵运算都能实现。我用自定义层的方式封装了一个transformerEncoderLayer,这样它就能像lstmLayer那样被直接放入layerGraph使用,训练时也能享受自动微分。
3.2 自注意力机制:Q、K、V的代码级拆解
自注意力的核心思想是让序列里的每个位置,都能根据自己的"查询向量(Query)"去序列的其他位置收集"键值对(Key-Value)"信息,通过相似度加权求和得到输出。一句话翻译成工程语言就是三次线性变换加一个注意力权重计算。下面是我在自定义层里实现缩放点积注意力的核心代码:
function Y = selfAttention(X, Wq, Wk, Wv, Wo, numHeads, scale) % X: [seqLen, numFeatures, numSamples] 的 dlarray % Wq, Wk, Wv, Wo: 线性投影权重 % numHeads: 注意力头数 % scale: 缩放因子,通常为 1/sqrt(d_k) [seqLen, featureDim, numSamples] = size(X, 1, 2, 3); headDim = featureDim / numHeads; % 线性投影 Q = pagemtimes(X, Wq); % [seqLen, featureDim, numSamples] K = pagemtimes(X, Wk); V = pagemtimes(X, Wv); % 按头拆分维度并调整形状 Q = reshape(Q, seqLen, numHeads, headDim, numSamples); K = reshape(K, seqLen, numHeads, headDim, numSamples); V = reshape(V, seqLen, numHeads, headDim, numSamples); % 计算注意力分数:Q * K^T / scale scores = pagemtimes(permute(Q, [3, 2, 1, 4]), ... permute(K, [3, 2, 4, 1])); scores = scores ./ scale; % Softmax 归一化 weights = softmax(scores, 'DataFormat', 'SSCB'); % 加权求和 context = pagemtimes(weights, permute(V, [3, 2, 1, 4])); context = permute(context, [3, 2, 1, 4]); % 合并多头 context = reshape(context, seqLen, numHeads * headDim, numSamples); % 输出投影 Y = pagemtimes(context, Wo); end这段代码看着长,但逻辑是一条直线:投影、拆头、算注意力权重、加权求和、合并头、再投影。需要特别提醒的是scale这个缩放因子,它的存在是为了防止点积结果过大导致softmax进入饱和区,梯度趋近于零。Transformer原论文用的是1/sqrt(d_k),其中d_k是每个头的维度。我在实践里计算过,当特征维度是128、分成8个注意力头时,每个头的维度是16,scale就是1/4。
还有一个小细节值得注意:Matlab的pagemtimes是做批量矩阵乘法的关键函数。多头注意力里,不同注意力头之间的运算是相互独立的,这是天然的并行维度。用reshape把head维度拆分出来后,可以借助pagemtimes一次把所有头的注意力矩阵都算出来,而不是写一个循环逐个头去算。我第一次用循环写,跑了500个训练迭代耗时惨不忍睹,改成批量运算后速度提升了5倍以上。
3.3 位置编码:Transformer里最容易被忽略的细节
Transformer本身没有任何关于"顺序"的先天感知能力。它对每个位置的输入做的是同样的线性变换,如果把序列里两个位置的输入换一下顺序,注意力输出不会发生任何变化。没有位置编码的Transformer,面对股票数据这种顺序极其敏感的序列,基本等同于把"第1天到第60天"和"洗牌之后的任意60天"混为一谈。
我用的是经典的正弦位置编码,维度与输入特征维度保持一致,加到输入序列上:
function PE = positionalEncoding(seqLen, featureDim) PE = zeros(seqLen, featureDim); for pos = 1:seqLen for i = 0:featureDim/2-1 PE(pos, 2*i+1) = sin(pos / (10000^(2*i/featureDim))); PE(pos, 2*i+2) = cos(pos / (10000^(2*i/featureDim))); end end PE = dlarray(single(PE), 'SC'); end位置编码的加法要放在进入Transformer编码器之前:X = X + PE。我在实验里对比过"加位置编码"和"不加位置编码"两个版本,测试集上的RMSE差距非常明显,加了之后预测曲线在拐点处的跟随性明显更好。而如果换成可学习位置嵌入,由于股票数据的样本量不算大,反而容易在训练后期过拟合到训练集的长度分布上。所以正弦位置编码这种固定的、不参与训练的位置信息注入方式,在这个场景里反而更稳。
关于Positional Encoding还有一些进阶玩法,比如可相对位置编码(Relative Position Encoding),它让注意力在局部窗口内更敏感。我在后期测试过一种简化版——只保留最近10个交易日的相对位置编码,对短期趋势预测有一定增益,但整体幅度不算大。考虑到代码复杂度,这里还是以经典正弦编码为主。
4. LSTM与SVM在Matlab中的实现与角色分工
4.1 LSTM层:让全局特征产生"时间记忆"
Transformer编码器输出的特征序列,接下来要进入LSTM层。这里有一个我当时纠结良久的问题:Transformer输出的特征序列是直接喂给LSTM,还是先经过一个平均池化或者全连接压缩,再进入LSTM?
直接喂的好处是信息无损,LSTM可以在全部时间步上继续建模;坏处是输入维度如果太大,LSTM的参数数量会跟着膨胀,训练起来又慢又容易过拟合。我最终的方案是:在Transformer和LSTM之间加一个fullyConnectedLayer做特征压缩,把每个时间步的特征从128维压缩到32维。这个压缩层是全连接共享的,也就是说60个时间步共享同一套压缩参数,不会破坏序列结构。
LSTM的配置我用的是:
lstmLayer(64, 'OutputMode', 'last', 'NumHiddenUnits', 64)这里OutputMode选'last',因为我们的目标是预测下一交易日的收益率,只需要序列最后一个时间步的隐状态就够了。在这个项目里我也试过'sequence'输出模式再接一个全连接层,但效果不如'last'直接。原因在于:'sequence'模式输出的是一整个序列的隐状态,后面SVM需要把这个序列摊平成向量,维度过大且大部分位置的信息是冗余的,反而混淆了SVM的分类边界。
关于单向LSTM和双向LSTM的对比,我实验下来很有意思:双向LSTM在验证集上的拟合精度略好,但一到测试集就掉得比单向更厉害。原因不复杂——双向LSTM多出来的那部分"反向信息"其实是把未来的信息往前带,这在训练集上当然表现更好,但在预测未来时根本不存在所谓"未来反向",所以本质是一种隐式的前视泄漏。用单向LSTM,训练和预测时信息流的方向一致,结果反而更可靠。
4.2 SVM:终端决策器为什么选它
在Transformer和LSTM把序列数据加工成特征向量之后,最后一步的任务是:给定一个经过深度网络提取的高维特征向量,预测下一个交易日的收益率(回归任务)或者涨跌方向(分类任务)。
SVM在这个位置有它不可替代的价值。首先,深度网络的输出特征通常带有复杂的非线性分布,SVM通过核函数可以把特征映射到更高维的空间里寻找线性判别边界,这比直接接一层全连接线性层对非线性边界的刻画能力更强。其次,股票数据噪声大、样本数量相对有限,SVM对异常值不那么敏感,它对那些"让神经网络振荡的极端样本"有天然的容错。
我在Matlab里用fitrsvm做回归(连续收益率预测),用fitcsvm做分类(涨跌方向判断):
svmModel = fitrsvm(X_train_svm, Y_train_svm, ... 'KernelFunction', 'rbf', ... 'BoxConstraint', 1, ... 'KernelScale', 'auto', ... 'Standardize', true, ... 'OptimizeHyperparameters', 'auto', ... 'HyperparameterOptimizationOptions', struct('AcquisitionFunctionName', 'expected-improvement-plus', 'MaxObjectiveEvaluations', 30));BoxConstraint这个参数对应SVM对误分类的惩罚力度,C越大,模型越倾向于把所有训练样本都分对,但也越容易过拟合。我最终调参发现,在特征维度32到64这个区间时,C=1是一个稳健的起点。KernelScale控制RBF核函数的宽度,gamma值越大,核函数影响范围越小,决策边界也就越复杂。我让它自动优化,最终落在0.7附近,这说明特征分布相对紧凑,不需要特别窄的核。
4.3 从LSTM到SVM的衔接:向量化不能想当然
从LSTM到SVM的衔接环节,藏着这个项目最容易被忽略的维度陷阱。
LSTM的'last'输出是一个形状为[numHiddenUnits, 1, numSamples]的dlarray,也就是64维的隐状态向量。但我在SVM这一步加了一个额外设计:不只把最后一个时间步的隐状态喂给SVM,而是把LSTM在最后5个时间步的隐状态接上Transformer编码器在那5个时间步的平均池化特征,三者拼接成一个"复合特征向量"。
为什么这么做?因为最后一个时间步只能反映"当前的记忆状态",而股价的趋势判断往往需要同时知道"最近的演化轨迹"。后5步的隐状态近似构成了最近一段时间的动态指纹,SVM在这组特征上能更稳健地区分不同行情模式。
拼接时要注意,LSTM的隐状态必须先用extractdata提取出来转成普通数组,再通过cell2mat或者直接[a, b, c]横向拼接。SVM的fitrsvm接受的是[numSamples, numFeatures]形状的矩阵,而LSTM输出的隐状态形状是[numFeatures, 1, numSamples],直接喂给SVM会当场报维度错误。我当时犯过这个错误,定位了大半个小时才发现是维度转置漏了。
拼接后的SVM特征向量维度是:LSTM最后5步隐状态(5×64) + Transformer编码器最后5步平均特征(5×128) + 原始标准化特征的最后5步(5×10),再加一个当前步的RSI和乖离率(2维)。总计约1000维左右。这个维度对SVM来说略高,但配合RBF核和标准化,并没有出现严重的过拟合,因为在拼接之前所有特征都已经过了深层网络的压缩,不是原始噪声直接灌入。
5. 三模型融合策略:从"投票"到"级联"的演进
5.1 我迭代过的四版融合方案
前面提到,我第一版是简单平均,效果差。之后我认真设计了三版改进方案,逐步逼近最终的级联结构,这里把演化过程完整记录下来。
第一版:简单平均集成。直接平均三个模型的预测值,效果差,原因是三个模型的误差模式高度相关,平均没有带来互补收益。
第二版:加权平均集成。在验证集上用网格搜索找到最优权重,让三个模型的预测值做加权组合。这个方案比简单平均好一些,但提升有限,因为本质上还是"在错误空间里做折中",没有改变模型之间的信息流结构。
第三版:Stacking集成。把三个模型的预测结果作为特征,训练一个元模型(我用的是逻辑回归)来决定最终输出。这个方案能学到三个模型在不同行情下的相对优势,验证集效果提升比较明显。但它在遇到行情风格切换时会失效,因为逻辑回归的融合规则是静态的,而"哪种模型更准"这件事和行情特征强相关。
第四版:级联+残差修正。Transformer → LSTM → SVM的串行结构,每一级产出的不是最终预测,而是对上一级特征的精炼表示;SVM作为最终决策器,直接在精炼特征上预测。这版的效果比第三版又上了一个台阶,关键是它对特征空间的利用更加充分,SVM看到的不是三个分立的预测值,而是一个深度融合的特征表达。
用一张表来对比这几版的测试集效果:
| 融合方案 | MAE | RMSE | 方向准确率 |
|---|---|---|---|
| 第一版:简单平均 | 0.0231 | 0.0312 | 52.7% |
| 第二版:加权平均 | 0.0215 | 0.0298 | 54.1% |
| 第三版:Stacking | 0.0187 | 0.0261 | 57.3% |
| 第四版:级联+残差修正 | 0.0162 | 0.0235 | 61.8% |
第四版的提升不是某一个模型的功劳,而是信息流结构优化的结果。第一版到第二版是"融合策略的优化",第三版到第四版是"表征方式的优化",后者带来的收益明显更大。
5.2 残差修正:SVM后面的"最后一公里"
第四版里有个细节值得单独拿出来讲——残差修正。具体做法是:先用Transformer+LSTM的级联结构预测出一个基准收益率y_pred_base,然后用SVM去拟合"真实收益与基准收益之间的差值",也就是残差:
r_true = y_true - y_pred_base SVM对融合特征进行拟合,输出 r_svm final_pred = y_pred_base + r_svm这个设计背后的逻辑是:Transformer+LSTM已经捕捉了序列里主要的趋势成分,但会有系统性的偏差,SVM的任务不是从头预测,而是预测"哪里没猜准"。
我把这个残差修正的版本和"不做残差修正,SVM直接预测最终收益率"的版本做了对比,发现残差修正版本的RMSE降低了约12%。原因很务实——让SVM直接拟合原始收益率,它需要和Transformer+LSTM竞争对趋势的主导权,两个学习器在特征空间里实际上是"互相打架"的;而让SVM拟合残差,它就专注学习深度模型遗漏的部分,两个模型之间形成了真正意义上的分工。
5.3 为什么融合部分必须配合嵌套交叉验证
混合模型最容易翻车的地方在于融合阶段过拟合。第四版的级联结构参数众多——Transformer的注意力头数、特征维度、层数,LSTM的隐状态维度,SVM的C和gamma,再加上这些模型之间层层传递的中间特征——如果直接在验证集上调参,很容易调出"验证集专用的模型"。
我用的方法是嵌套交叉验证:外层循环按时间切分数据,保证不同折之间没有时间重叠;内层循环做超参数搜索,找到每个外层折上的最优参数组合。这样每个测试折的评估结果,用的都是在完全不接触该折数据的情况下选出的参数。耗时确实翻了好几倍,但只有这样才能让人相信测试集上的指标不是调参调出来的幻觉。
嵌套交叉验证在Matlab里没有现成函数,我手动用两层for循环实现。外层折数设为5,内层参数搜索用bayesopt代替网格搜索,能节省不少时间。跑一次完整的嵌套交叉验证大概需要3-4小时,但换来的是对模型泛化能力的真实估计,这笔时间花得值。
6. 完整训练流程、评估指标与调参血泪史
6.1 分阶段训练与端到端训练的选择
混合模型通常有两种训练思路:端到端训练和分阶段训练。端到端训练把整个级联结构当成一个大网络,从输入到SVM的损失函数做一个整体反向传播;分阶段训练则是先训好Transformer+LSTM,固定其参数之后再训练SVM。
我在实践里发现,端到端训练在Matlab里实现起来极其别扭,因为SVM不是基于梯度下降的模型,无法直接嵌入trainNetwork的反向传播链路。就算用可微的SVM替代品,也容易导致训练不稳定。最终我采用的是"分阶段训练+微调"的策略。
第一阶段:单独训练Transformer+LSTM级联部分,预测目标是对数收益率,损失函数用均方误差。这个阶段输出的就是y_pred_base。
第二阶段:把第一阶段模型的输出特征离线保存下来,和原始特征、技术指标特征拼接后,训练SVM做残差修正。
第二阶段和第一阶段之间有一个微调周期:等SVM训练好之后,我会回到第一阶段的模型,在损失函数里加上一个正则项,让深度模型的中间特征更适配SVM的需求。这个微调的幅度很小,只跑两到三个epoch就停,防止深度模型在微调过程中过拟合。
两个阶段的训练周期分别是:第一阶段大约80个epoch收敛,学习率初始0.001,每30个epoch衰减为原来的0.1;第二阶段SVM的参数搜索用30次贝叶斯优化。整个训练在单张入门级GPU上大约需要40分钟,CPU上跑则要接近4小时。
6.2 评估指标:别让RMSE骗了你
股票预测的评估指标,我用了五个维度,每一个都有自己的盲区,组合起来才能反映模型真实水平:
- MAE(平均绝对误差):最直观的平均误差水平,单位与收益率相同。
- RMSE(均方根误差):放大较大误差的影响,对"偶尔离谱的预测"更敏感。
- MAPE(平均绝对百分比误差):误差相对于真实值的比例,适合衡量相对精度。
- R²(决定系数):模型解释的方差比例,越接近1越好。在股价这类高噪声数据上,R²能到0.1以上就算不错,别被一些论文里"R²=0.9"的数据骗了,那多半是用了未来信息或者样本划分不合理。
- 方向准确率(Direction Accuracy):预测涨跌方向和真实涨跌方向一致的比例。对股票预测来说,这个指标往往比精确的收益率误差更重要,因为交易决策本质上只关心方向。
在最终测试集上,我这套模型五个指标的表现大约是:MAE 0.0162、RMSE 0.0235、MAPE 1.87%、R² 0.153、方向准确率61.8%。
R²只有0.153看起来很低,但我特意把它列出来,是为了说明一个现实:日线级别的股票价格预测,收益率本身信噪比极低,能够解释15%的方差已经不算差了。如果你在论文里看到R²超过0.5,第一步应该怀疑数据预处理是否引入了泄漏,而不是惊叹模型有多厉害。
6.3 调参过程中最值得记录的三次教训
第一次是学习率过大导致的NaN灾难。Transformer对学习率极其敏感,我一开始沿用LSTM的0.01初始学习率,第一个epoch就开始输出NaN。后来才知道Transformer的层归一化在前向传播初期对参数变化特别敏感,大学习率会让激活值直接溢出。把初始学习率降到0.001,并加了一个预热阶段——前5个epoch从0.0001线性升到0.001——问题才解决。在Matlab里可以通过learningRateSchedule自定义学习率调度,或者直接用Adam优化器配合ResetInputNormalization参数。
第二次是SVM的Standardize参数忘了开。拼接到SVM的特征里,原始价格量纲特征和LSTM隐状态特征的数值范围差了上百倍,如果不做标准化,SVM的RBF核距离计算会被大数值特征完全主导。这个错误的典型表现是:验证集精度不高,而且调KernelScale怎么调都不灵敏。把Standardize设为true,同时确认前面的Tensor和LSTM特征也都做了Z-Score归一化,SVM性能立刻上了一个台阶。
第三次是批量大小和序列长度的相互制约。Transformer的训练对批量大小极其敏感。批量太小,梯度估计的噪声大,注意力权重的分布不稳定;批量太大,显存占用高,且小样本场景下容易陷入锐利的局部极小值。我在60天序列窗口下,最终批量大小设为32,在性能和稳定性之间取得平衡。如果序列窗口更长,比如90天,批量大小要相应调小到16,否则显存会不够。
这三条经验,几乎每个跑深度混合模型的人都可能遇到。调参的过程没有想象中那么多"神来一笔",绝大多数时间就是在这些基础环节上反复试错,把每个细节调整到合理范围。
7. 完整代码结构与关键片段解析
7.1 项目目录结构与核心文件职责
这里给出一个可以直接照搬的项目目录结构。每个人的文件组织习惯不同,但下面这套结构是我多次迭代后觉得最顺手的:
StockHybridModel/ ├── README.md # 项目说明 ├── config.m # 超参数配置脚本 ├── data/ │ ├── load_stock_data.m # 数据加载 │ └── preprocess_data.m # 数据预处理七步 ├── models/ │ ├── transformerEncoderLayer.m # 自定义Transformer层 │ ├── positionalEncoding.m # 正弦位置编码 │ ├── buildDeepFeatureExtractor.m # 构建Transformer+LSTM │ └── trainSVMResidual.m # SVM残差修正训练 ├── train/ │ ├── train_main.m # 主训练脚本 │ ├── train_stage1.m # 阶段一训练 │ └── train_stage2.m # 阶段二训练 ├── evaluate/ │ ├── evaluate_metrics.m # 五项评估指标 │ └── plot_predictions.m # 预测与真实值对比图 └── utils/ └── split_sequence.m # 滑动窗口样本构建每个文件的任务边界尽量单一,这样调试的时候不需要在一个上千行的脚本里翻来翻去找问题。我自己最深有体会的一点是:config.m一定要把所有超参数集中管理,不要散落在各个脚本里。每次实验迭代要记录参数版本,否则三天后你就忘了当前结果是用哪组参数跑出来的。
7.2 主训练脚本:分阶段训练的完整流水线
下面这个函数是第二阶段训练的核心,也是整个项目里最关键的一段代码。它负责在已经训练好的深度特征提取器之上,完成SVM残差修正模型的训练和验证:
function [svmModel, metrics] = train_stage2(X_cell, Y_raw, featExtractor, config) % X_cell : {训练特征cell, 验证特征cell, 测试特征cell} % Y_raw : 原始对数收益率序列(用于残差计算) % featExtractor : 已经训练好的第一阶段深度特征提取器(dlnetwork) % config : 超参数配置 % 阶段二的高层思路: % (1) 用已有深度特征提取器推出基准预测 y_pred_base % (2) 用同一特征提取器导出SVM需要的深度融合特征 % (3) 计算残差:r = y_true - y_pred_base % (4) 训练SVM拟合残差 % (5) 最终预测 = y_pred_base + svm(r) X_train_feature = extractDeepFeatures(featExtractor, X_cell{1}); X_val_feature = extractDeepFeatures(featExtractor, X_cell{2}); X_test_feature = extractDeepFeatures(featExtractor, X_cell{3}); % 基准预测 Y_pred_base_train = predictBase(featExtractor, X_cell{1}); Y_pred_base_val = predictBase(featExtractor, X_cell{2}); Y_pred_base_test = predictBase(featExtractor, X_cell{3}); % 残差 R_train = Y_raw{1} - Y_pred_base_train; R_val = Y_raw{2} - Y_pred_base_val; R_test = Y_raw{3} - Y_pred_base_test; % SVM: 用深度融合特征拟合残差 svmModel = fitrsvm(X_train_feature, R_train, ... 'KernelFunction', 'rbf', ... 'BoxConstraint', config.svm_box_constraint, ... 'KernelScale', 'auto', ... 'Standardize', true); % 验证集上评估 R_val_hat = predict(svmModel, X_val_feature); Y_val_final = Y_pred_base_val + R_val_hat; % 最终评估 finalPreds = Y_pred_base_test + predict(svmModel, X_test_feature); metrics = evaluate_metrics(Y_raw{3}, finalPreds); end整个流程非常直观:先算出深度模型的基准预测,再让SVM去学残差,最后把两者相加得到最终预测。extractDeepFeatures函数的作用是把LSTM层最后5个时间步的隐状态、Transformer编码器特征和原始标准化特征全部拼接起来,这一步在4.3节已经讲过。
7.3 常见报错与排查清单
写这个项目过程中,我前前后后踩了十来个坑,大部分是环境配置和维度问题。挑几个高频的列成一张排查表,希望帮你省掉几个小时的排查时间:
| 错误现象 | 根本原因 | 解决方式 |
|---|---|---|
dlarray维度不匹配 | 序列输入形状与层期望不一致 | 检查数据格式,Transformer层输入要求SCB格式,size(X)应为[seqLen, featureDim, batchSize] |
softmax报维度错误 | 注意力分数的DataFormat未指定 | 在softmax调用中显式指定'DataFormat', 'SSCB' |
| LSTM输出维度与期望不符 | OutputMode设置错误 | 预测任务选'last',特征提取选'sequence' |
fitrsvm报输入维度错误 | LSTM输出未转置或未extractdata | 用extractdata提取并用reshape转为[numSamples, numFeatures] |
| 第一个epoch就NaN | 学习率过大,Transformer训练不稳定 | 降低学习率,添加学习率预热 |
| 训练和预测信息不一致 | Matthew效应(验证集指标好但测试集差) | 检查是否引入未来信息,按时间切分数据 |
| 预测曲线严重滞后 | 直接用原价格做预测目标 | 改为预测对数收益率,再还原价格 |
每一个报错背后都是真实踩过坑留下的痕迹。尤其是"预测曲线滞后"这个现象,很多人会误以为是模型不够复杂、需要换更强的模型,其实往往是目标变量的选择问题——直接预测原价格会让模型倾向于输出上一时刻价格附近的数值,导致预测曲线比真实曲线滞后一拍。换成收益率目标后,这个滞后问题基本消失。
写到这里,整个项目从架构设计、数据预处理、三大模型的实现、融合策略到完整训练流程都已经完整过了一遍。如果你打算复现这套方案,我建议先从第2章的数据预处理开始动手,把特征质量打牢,再做第3章的Transformer自定义层,逐步推进,避免一上来就堆整个混合模型,出了问题很难定位。我在实际跑实验的过程中,最大的体会是"混合"二字不是模型越豪华越好,而是每个模块各司其职、接口清晰。Transformer负责全局特征,LSTM负责时序精修,SVM负责残差决策,三者之间通过特征拼接和残差传递严格衔接,才让整个模型的预测能力超过任何一个单独成员。