交易中的神经网络:使用小波变换和多任务注意力的模型
概述
资产回报预测是金融领域一个广泛研究的话题。预测回报的挑战是由若干因素引至的。首先,影响资产回报的变量众多,以及大型稀疏矩阵中的低信噪比,令传统计量经济学模型很难提取有意义的信息。其二,预测特征与资产回报之间的功能关系仍不明确,这给捕捉它们之间的非线性结构带来了挑战。
近年来,深度学习已成为量化投资中不可或缺的工具,特别是在完善多因素策略方面,由其构成了理解金融资产价格走势的基础。通过自动化特征学习、和捕获金融市场数据中的非线性关系,深度学习算法可以有效地发现复杂形态,从而提升预测准确性。全球研究界认识到深度神经网络,譬如递归神经网络(RNN)、和卷积神经网络(CNN),预测股票和期货价格方面的潜力。然而,虽然 RNN 和 CNN 都已被广泛运用,但更深层次的神经架构,即提取和构造顺序市场和信号信息仍未得到充分探索。这为深度学习应用于股票市场的深入推动开启了机会。
今天,我们讲述多任务-Stockformer 框架,其在论文《Stockformer:基于小波变换和多任务自注意力网络的价格-成交量因子股票选择模型》中提出。尽管名称与之前讨论的 StockFormer 框架相似,但这两个模型并不相关 — 除了它们的共同目标:为金融市场交易生成可盈利的股票投资组合。
多任务-Stockformer 框架构建了基于小波变换和自注意力机制的多任务股票预测模型。
多任务-Stockformer 算法
多任务-Stockformer 框架的架构分为三个模块:流分离模块、双频时空编码器、和双频解码器。分析后的历史资产数据 𝒳 ∈ RT1×N×362 会在流分离模块中进行处理。在该阶段,资产回报张量经离散小波变换,被分解为高频和低频分量,而趋势特征、以及量加比保持不变。然后,这些分量沿着最后一个维度与信号的未改变部分串联起来。
低频分量代表长期趋势,而高频分量则捕捉短期波动和突发事件。它们分别表示为 𝒳l, 𝒳h ∈ RT1×N×362。接下来,𝒳h 和 𝒳l 两者都经由全连接层线性变换到维度 RT1×N×D。此处的 T1 表示所分析历史数据的深度。
双频时空编码器设计用于表示这些不同的时间序列形态:低频特征被投喂到时态注意力模块(表示为 tatt)之中,而高频特征则由扩张的因果卷积层(表示为 conv)处理。然后这些输出会被输入到图形注意力网络(表示为 gat)之中。与基于图形的信息交互,令模型能够捕获资产和时间之间的复杂关系、及依赖关系。在本模块中,空间图形 Aspa 和时态图形 Atem 通过全连接层和张量转译运算变换为多维嵌入,分别表示 ρspa,ρtem ∈ RT1×N×D,然后使用加法和图形注意力运算将其与 𝒳l,tatt, 𝒳h,conv 组合,生成 𝒳l,gat, 𝒳h,gat ∈ RT1×N×D。双频时空编码器由 L 层组成,旨在有效表示低频和高频波浪的多尺度时空形态。最后,在双频解码器中,预测器生成 𝒴l,f,𝒴h,f ∈ RT2×N×D,这些数据经由 融合注意力 交互进行聚合,从而获得双尺度时态形态的潜在表示。单独的全连接层(回归层 FC1 和分类层 FC2)生成多任务输出,包括股票回报预测(回归输出,表示为 reg),和股票趋势预测概率(分类输出,表示为 cla)。
此外,还推导出低频分量的回归值、和趋势预测概率,改进了低频信号的学习过程。
作者提供的多任务-Stockformer 框架可视化如下所示。

实现 MQL5 版本
以上只是 多任务-Stockformer 框架的简述。该框架相当复杂。我相信,在实现期间,逐步熟悉其各个算法会更有效。我们将从生料数据的流分离模块开工。
信号分解模块
为了将所分析信号切分为低频和高频分量,该框架的作者建议使用离散小波变换。不同于傅里叶分解,小波变换不仅能够捕获频率内容,还能够捕获信号的结构。这令其对于金融市场分析更具优势,其中不仅是频率,且信号的顺序也很重要。
之前,我们在构建 FEDformer 框架时已用过离散小波变换,但后来我们只提取低频分量。现在我们也需要高频分量。无论如何,我们能够复用已有的开发项目。
离散小波变换,本质上,是一种配以某个小波作为滤波器的卷积运算。这允许我们使用卷积层算法作为基本功能。应当注意的是,在变换中,我们采用静态小波,其参数在训练过程中不会变化。因此,我们必须禁用对象参数的优化机制。
考虑到上述情况,我们创建一个新对象,用离散小波变换 CNeuronLegendreWaveletsHL 来提取高频和低频信号分量。
class CNeuronLegendreWaveletsHL : public CNeuronConvOCL { protected: virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } public: CNeuronLegendreWaveletsHL(void) {}; ~CNeuronLegendreWaveletsHL(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, uint filters, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) override; //--- virtual int Type(void) const { return defNeuronLegendreWaveletsHL; } //--- virtual uint GetFilters(void) const {return (iWindowOut / 2); } virtual uint GetVariables(void) const {return (iVariables); } virtual uint GetUnits(void) const {return (Neurons() / (iVariables * iWindowOut)); } };
如前所述,离散小波变换是配以小波滤波器的卷积。这令我们能够在构建算法时充分利用父卷积层类功能。覆盖初始化方法就足够了,用小波数据替换随机滤波器参数。
不过,所用小波滤波器是静态的。因此,我们以无操作实现,来覆盖参数优化方法 updateInputWeights。
新对象的初始化在 Init 方法中执行。如常,该方法从外部程序接收一组常量,唯一标识正在创建的对象架构。这些包括:
- window — 所分析窗口的大小;
- step — 所分析窗口的步长;
- units_count — 每个序列的卷积运算数量;
- filters — 用到的滤波器数量;
- variables — 所分析多模态时间序列中的单个序列数量。
bool CNeuronLegendreWaveletsHL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, uint filters, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window, step, 2 * filters, units_count, variables, optimization_type, batch)) return false;
在方法主体内,我们立即将收到的参数传递至父类的同名方法。不过,请注意,在调用父类方法时,我们把滤波器数量加倍。这是因为需要创建滤波器来提取低频和高频分量。
父类方法成功完成后,我们填充零值来清除缓冲区,并在低频和高频滤波器的元素之间定义缓冲区中的恒定偏移量。
WeightsConv.BufferInit(WeightsConv.Total(), 0); const uint shift_hight = (iWindow + 1) * filters;
然后,我们规划一个嵌套循环系统,以便生成所需数量的滤波器。于此,我们用递归生成勒让德(Legendre)小波,并用高阶小波依次填充滤波矩阵。
for(uint i = 0; i < iWindow; i++) { uint shift = i; float k = float(2.0 * i - 1.0) / iWindow; for(uint f = 1; f <= filters; f++) { float value = 0; switch(f) { case 1: value = k; break; case 2: value = (3 * k * k - 1) / 2; break; default: value = ((2 * f - 1) * k * WeightsConv.At(shift - (iWindow + 1)) - (f - 1) * WeightsConv.At(shift - 2 * (iWindow + 1))) / f; break; }
对于所分析窗口的每个元素,我们创建一个内部循环来生成滤波器元素。在该循环中,我们首先生成低频滤波器的相应元素。
然后,我们创建另一个嵌套循环,其中我们将生成的元素传播至多模态序列所有自变量的滤波器。同时,我们基于相应已形成的低频滤波器元素,增加了高频滤波器元素。
for(uint v = 0; v < iVariables; v++) { uint shift_var = 2 * shift_hight * v; if(!WeightsConv.Update(shift + shift_var, value)) return false; if(!WeightsConv.Update(shift + shift_var + shift_hight, MathPow(-1.0f, float(i))*value)) return false; }
然后我们调整偏移量,指向下一个滤波器元素,并继续循环系统的下一次迭代。
shift += iWindow + 1;
}
}
该初始化方法的其余部分不同于我们之前验证的。到目前为止,我们尚未在对象初始化期间使用 OpenCL 代码片段。该方法是一个例外。在此,我们归一化获得的小波滤波器。
if(!!OpenCL) { if(!WeightsConv.BufferWrite()) return false; uint global_work_size[] = {iWindowOut * iVariables}; uint global_work_offset[] = {0}; OpenCL.SetArgumentBuffer(def_k_NormilizeWeights, def_k_norm_buffer, WeightsConv.GetIndex()); OpenCL.SetArgument(def_k_NormilizeWeights, def_k_norm_dimension, (int)iWindow + 1); if(!OpenCL.Execute(def_k_NormilizeWeights, 1, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel %s Normalize: %s", __FUNCSIG__, error); return false; } } //--- return true; }
参数归一化成功后,该方法执行完毕,向调用程序返回布尔结果。
所呈现对象及其所有方法的完整代码可在附件中找到。
值得注意的是,信号分解模块的功能超出了离散小波变换的范畴,尽管这仍然是其核心组件。我们计划在模型中使用该对象,其输入是多模态时间序列的二维张量,维度为 {柱线, 指标值}。为了正确操作,离散小波变换需要转置输入数据。这可在数据输入对象之前,于外部完成。但我们的目标是尽可能构建用户友好的对象。因此,我们用到之前创建的离散小波变换对象作为其父类,略微扩展其功能,创建一个流分解模块对象 CNeuronDecouplingFlow。
class CNeuronDecouplingFlow : public CNeuronLegendreWaveletsHL { protected: CNeuronTransposeOCL cTranspose; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronDecouplingFlow(void) {}; ~CNeuronDecouplingFlow(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, uint filters, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronDecouplingFlow; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual void SetOpenCL(COpenCLMy *obj) override; };
在新对象内,我们添加了一个数据预置换层,并在 Init 方法中重新定义了外部参数 units_count,令其更加用户友好。此处,units_count 表示所分析序列长度(柱线数量),而 variables 对应于所分析的指标数量。
我们来研究在 Init 方法中的实现该方式。在方法主体中,我们首先基于原始序列长度、卷积窗口大小、和步长重新计算单一序列的卷积运算次数。然后,我们按这些调整后的参数调用父类 Init 方法。
bool CNeuronDecouplingFlow::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, uint filters, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { uint units_out = (units_count - window + step) / step; if(!CNeuronLegendreWaveletsHL::Init(numOutputs, myIndex, open_cl, window, step, units_out, filters, variables, optimization_type, batch)) return false;
父方法成功执行后,我们初始化数据转置层。
if(!cTranspose.Init(0, 0, OpenCL, units_count, variables, optimization, iBatch)) return false; //--- return true; }
该方法在结束处返回逻辑结果至调用程序。
前馈和反向传播算法直截了当。举例,在前向通验中,输入数据首先被转置,然后将生成的张量传递给相应的父类方法。
bool CNeuronDecouplingFlow::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cTranspose.FeedForward(NeuronOCL)) return false; if(!CNeuronLegendreWaveletsHL::feedForward(cTranspose.AsObject())) return false; //--- return true; }
因此,我们在此不会纠缠于它们。附件中提供了该对象、及其所有方法的完整代码。
关于信号分解模块的输出:构造的运算流不允许神经层返回两个单独的张量。因此,低频和高频滤波器的结果作为单个张量输出。由此,输出类似于四维张量 {Variables, Units, [Low, High], Filters}。双频时空编码器按计划分离为不同的数据流。
在构建了流分解模块后,我们继续实现双频时空编码器,它由三个主要组件组成:时态注意力、扩张因果卷积、和带有图形注意力网络的时态槽(Struc2Vec)。
多任务-Stockformer 框架的作者为低频和高频组件规划了两个独立流。这些流在架构上有所不同,令模型能够分别专注趋势和季节性分量。
低频分量被投喂到时空注意力模块之中,以便捕获长期、低频趋势、及全局序列关系。
高频分量由扩张因果卷积层处理,重点专注局部形态、高频波动、及突发事件。
这种双流建模方式有望提升复杂金融序列的预测精度。
对于时空注意力模块,我们能够利用现有的基于变换器的编码器对象。然而,扩张因果卷积算法需要自定义实现。
扩张因果卷积层
该框架提出了一种扩张因果卷积,即在定义的步骤跳过输入值的一维卷积。形式上,对于序列 x ∈ RT 和滤波器 f ∈ RJ,时间步长 t 处的扩张因果卷积定义为:

此处的 c 是扩张因子。高频分量的扩张因果卷积表示为:
![]()
在最初的实现中,还有另一个超参数 — 扩张因子。它是恒定的。不过,贯穿序列的依赖元素间固定的距离。此外,在原始算法中,该因子在不同序列中均匀应用。
在我们的实现中,我们稍微修改了这个架构。我们并未引入固定的跳步,而是引入了 分段、打散、缝合(Segment, Shuffle, Stitch — S3) 算法,后随一个标准卷积层。
S3 允许模型学习输入分段的自适应排列,令网络能够发现高频分量中的依赖关系。堆叠多个 S3 模块进一步强化了模型捕获复杂高频交互的能力。
我们在对象 CNeuronDilatedCasualConv 中实现了这种方式。该算法是线性的。因此,我们使用 CNeuronRMAT 类作为父对象,其为线性算法实现提供了基本功能和接口。新对象的结构如下所示。
class CNeuronDilatedCasualConv : public CNeuronRMAT { public: CNeuronDilatedCasualConv(void) {}; ~CNeuronDilatedCasualConv(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint dimension, uint units_count, uint variables, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronDilatedCasualConv; } };
从新对象的结构可见,选择“正确”的父类令我们将自己限制在 Init 方法中指定对象架构。所有其它功能都已在父类方法中实现,意味着我们已成功继承。
在 Init 方法中,我们从外部程序接收一组常量,这些常量唯一地定义了正在创建对象的架构:
- window — 所分析窗口的大小;
- step — 所分析窗口的步长;
- dimension — 单个序列元素向量的维度;
- units_count — 序列中的元素数量;
- variables — 在多模态序列中分析的元素数量;
- layers — 卷积层的数量。
重点要注意这些变量如何使用。首先,回忆一下所分析输入数据的张量维度。如前所述,信号分解模块输出一个四维张量 {Variables, Units, [Low, High], Filters}。沿第三维分离高频和低频分量后,只剩下一个值,有效地把张量变为三维 {Variables, Units, Filters}。
在 OpenCL 关联环境中,我们是与一维数据缓冲区打交道。缓冲区分解为维度是概念性的,但遵循相应的数值序列。
理解张量维数,令我们能够将它们与从外部程序接收到的参数进行匹配。显然,variables 对应于第一个维度(Variables),units_count 指定沿第二个维度(Units)的序列长度,dimension 定义最后一个维度 Filters。这些参数共同确定了输入数据的生料张量维度。
此外,所分析窗口大小(window)、及其步骤(step)是按第二个维度(Units)的单位指定。例如,如果 window = 2,则卷积将处理来自输入缓冲区的 2 * dimension 个元素。
按照这样理解,我们就能回到对象初始化方法的算法了。
bool CNeuronDilatedCasualConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint dimension, uint units_count, uint variables, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, 1, optimization_type, batch)) return false;
如常,在方法主体中,第一步是调用父类同名方法,其中规划了继承对象的必要控制和初始化。此刻,我们遇到了两个问题。首先,父类 CNeuronRMAT 中对象的结构与我们的需要有明显区别。因此,我们不能直接从父类调用该方法,而是从基础全连接层调用该方法。您或许还记得,在我们的函数库中,它是创建所有神经层的基础。
然而,还有第二个问题 — 在卷积期间,结果张量的大小会变化。目前,我们还没有最终维度来指定基本接口的大小。由此,我们以标称单一输出元素初始化基本接口。
接下来,我们清除存储指向内部对象指针的动态数组,并准备辅助变量。
cLayers.Clear(); cLayers.SetOpenCL(OpenCL); uint units = units_count; CNeuronConvOCL *conv = NULL; CNeuronS3 *s3 = NULL;
准备工作完成后,我们继续直接构造我们的对象架构。为此,我们规划一个迭代次数等于内部层数的循环。
for(uint i = 0; i < layers; i++) { s3 = new CNeuronS3(); if(!s3 || !s3.Init(0, i*2, OpenCL, dimension, dimension*units*variables, optimization, iBatch) || !cLayers.Add(s3)) { if(!!s3) delete s3; return false; } s3.SetActivationFunction(None);
在循环中,我们首先初始化 S3 对象。不过,应当注意的是,该对象仅以一维张量操作。因此,为了避免序列元素表示向量中出现“缝隙”,分段大小必须是该向量维数的倍数。在这种情况下,我们将它们设置为相等。同时,序列长度被指定为完整的张量大小,同时参考全部所分析变量。
对象初始化成功后,我们将其指针添加到存储指向内部对象指针的动态数组中,并禁用激活函数。
接下来初始化卷积层。在开始初始化新对象之前,我们计算正创建层的卷积操作次数,并将该数值保存在局部变量之中。在前一步指定分析序列维度时,采用了该特定变量值。由此,在下一次循环迭代中,我们将创建一个更新大小的 S3 对象。
conv = new CNeuronConvOCL(); units = MathMax((units - window + step) / step, 1); if(!conv || !conv.Init(0, i * 2 + 1, OpenCL, window * dimension, step * dimension, dimension, units, variables, optimization, iBatch) || !cLayers.Add(conv)) { if(!!conv) delete conv; return false; } conv.SetActivationFunction(GELU); }
不同于 S3 对象,我们所用的卷积层能够跨单变量序列进行操作。这令我们不仅可以单独对每个单位时间序列进行卷积,还可对单变量序列应用不同的滤波器,令它们的分析完全独立。
在卷积层输出中,我们使用 GELU 激活函数,替代框架作者建议的 ReLU。
我们将指向初始化对象的指针添加到动态数组之中,并继续下一次循环迭代,来创建后续层。
对象的所有内部层在成功初始化之后,我们再次调用基础全连接层的初始化方法,来创建正确的外部接口缓冲区,指定我们模块中最后一个内部层的大小。
if(!CNeuronBaseOCL::Init(numOutputs, myIndex, OpenCL, conv.Neurons(), optimization_type, batch)) return false;
最后,我们将指向外部接口缓冲区的指针替换为最后一个内部层的相应缓冲区。
if(!SetGradient(conv.getGradient(), true) || !SetOutput(conv.getOutput(), true)) return false; SetActivationFunction((ENUM_ACTIVATION)conv.Activation()); //--- return true; }
我们复制激活函数指针,将运算的逻辑结果返回给调用程序,并结束方法执行。
您或许已注意到,在该模块架构内,我们用到的内部对象,搭配不同维度的张量运行。最初,S3 层在整个数据缓冲区中重新排列元素,无需考虑单变量序列。在这种情况下,在单变量序列之间“打散”元素是完全可能的。一方面,我们并未把元素重排约束在单变量序列的边界内。另一方面,排列序列是基于训练数据集的学习。如果模型识别出不同单元序列的元素之间的依赖关系,这或许有提高模型性能的潜力。观察学习成果将会非常有趣。
文章篇幅已接近极限,但我们的工作并未就此结束。我们将在系列的下一篇文章中继续。
结束语
在这项工作中,我们探索了多任务-Stockformer 框架,这是一种创新的股票选择模型,它结合了小波变换与多任务自注意力模块。小波变换的运用可以识别市场数据的时态和频率特征,而自注意力机制则确保对所分析因素之间的复杂相互作用进行精确建模。
在实践部分,我们利用 MQL5 实现了我们自己对所提议框架的各个模块的解释。在后续工作中,我们将完成所讨论框架的实现,并评估所实现方式在真实历史数据下的有效性。
参考
文章中所用程序
| # | 名称 | 类型 | 说明 |
|---|---|---|---|
| 1 | Research.mq5 | 智能系统 | 收集样本的智能系统 |
| 2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real-ORL 方法收集样本的智能系统 |
| 3 | Study.mq5 | 智能系统 | 模型训练 EA |
| 4 | Test.mq5 | 智能系统 | 模型测试智能系统 |
| 5 | Trajectory.mqh | 类库 | 系统状态和模型架构描述结构 |
| 6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
| 7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/16747
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
从基础到中级:浮点数
在MQL5中构建自优化智能交易系统(EA)(第五部分):自适应交易规则
将您自己的 LLM 集成到 EA 中(第 5 部分):使用 LLM 开发和测试交易策略(三)—— 适配器微调
精通日志记录(第五部分):通过缓存和轮转优化处理程序