神经网络在交易中的应用:多元时间序列的双重聚类(终篇)
概述
我们继续致力于实现 DUET 框架作者提出的多元时间序列双重聚类方法,这代表了一种强大的金融市场预测工具。DUET 结合了时间和通道聚类,能够适应复杂且不断变化的市场动态模式,同时克服了传统方法易出现过拟合和灵活性有限的局限性。
DUET 包含几个关键模块,每个模块都发挥着至关重要的作用。数据处理的第一阶段包括数据归一化和异常值剔除,这有助于提高模型的稳健性。
接下来是时间聚类,它会把时间序列按动态特征划分为若干相似的组。这使得该模型能够考虑到市场过程中的阶段变化,这在分析高度波动的资产时尤为重要。
通道聚类用于从众多市场因素中识别出最重要的变量。金融数据中包含大量噪声和冗余信息,这阻碍了准确预测的实现。DUET 分析参数之间的相关性,并剔除不显著的部分,从而将计算资源集中在关键特征上。频域信号分析和潜在特征提取机制使模型对随机市场波动不那么敏感。
数据融合模块将从时间和通道聚类模块获得的信息进行同步,形成对分析环境状态的统一表示。这一阶段基于掩码注意力机制,该机制使模型能够专注于最相关的特征,同时最大限度地减少非代表性数据的影响。因此, DUET 对动态变化表现出很高的鲁棒性,并提高了长期预测性能。
最终的预测模块使用聚合特征来计算时间序列的未来值。这一阶段依赖于先进的神经网络方法,这些方法能够捕捉市场指标之间的非线性依赖关系。DUET 架构的灵活性使其能够动态适应各种条件,无需手动调整参数。
下面提供了 DUET 框架的原始可视化图。

在上一篇文章的实践部分,我们介绍了时间聚类模块的实现。我们将继续这项工作,并着手构建通道聚类模块。
通道聚类模块
通道聚类模块解决了在预测多元时间序列时正确考虑通道间关系的问题。在这里, DUET 框架的作者采用了度量学习,旨在对频域中的通道进行聚类。
CCM 的一个关键方面是数据在频域中的表示。为了实现这一目标,使用快速傅里叶变换( FFT )将时间序列分解为频率分量。因此,信号在频域中进行分析,此时通道间关系变得更加明确。许多在传统分析中不可见的隐藏依赖关系只有在转换为频域后才会显现出来,这使得该方法对于复杂的时间序列特别有价值。
通道间关系使用可学习的距离度量进行评估。以频谱的幅度表示作为基本度量,并使用改进的马氏距离度量来计算距离。该方法不仅考虑通道之间的成对距离,还考虑它们在频谱空间中的相关性。
在计算通道之间的距离后,会形成一个关系矩阵,其中系数被归一化到 [0, 1] 的范围内。这种归一化处理能够识别出最重要的连接,同时消除弱连接和噪声波动。
为了进行最终的信息过滤,我们构建了一个二值通道掩码矩阵。此过程基于概率抽样,其中每个通道都被分配了一个用于预测的有用性概率。这种机制允许将数据中的不确定性纳入考量,并避免了硬性阈值处理。因此,该模型会自动排除不重要的通道,从而显著提高可解释性并减少信息冗余。
在本项工作范围内,我们实现了一个稍微简化的通道聚类模块版本。离散傅里叶变换算法之前已作为 FITS 框架的一部分实现,并且已在我们的库中提供。与马氏距离度量不同,本文采用了一种基于频率振幅分量之间向量距离的更简单的方法。这样既保留了频率分析的优势,又降低了计算复杂度,简化了算法。
将时间序列转换为频域后,计算每个通道的振幅谱范数。然后,计算成对距离以形成通道间关系矩阵。为了在后续分析中消除弱依赖性,我们进行了归一化处理,以抑制噪声并缩放距离。因此,只保留通道之间显著的相关性。基于这个矩阵,构建了一个关系的概率模型。每个通道都被赋予一个重要性权重,以反映其对其他系列的影响。
所描述的算法在 OpenCL 端的 MaskByDistance 内核中实现。内核参数包括指向三个数据缓冲区的指针。前两个缓冲区包含以分析信号的实部和虚部形式存在的输入数据,而第三个缓冲区用于存储结果。在本例中,它包含通道掩码矩阵。
__kernel void MaskByDistance(__global const float *buf_real, __global const float *buf_imag, __global float *mask, const int dimension ) { const size_t main = get_global_id(0); const size_t slave = get_local_id(1); const int total = (int)get_local_size(1);
在内核主体中,首先在二维执行空间中识别当前线程。第一个维度对应于被分析的通道,第二个维度对应于被比较的通道。工作组是沿着第二个维度组建的。
接下来,创建一个本地内存数组,用于同一工作组内线程之间的数据交换。
__local float Temp[LOCAL_ARRAY_SIZE]; int ls = min((int)total, (int)LOCAL_ARRAY_SIZE);
然后确定全局数据缓冲区中的偏移量。
const int shift_main = main * dimension; const int shift_slave = slave * dimension; const int shift_mask = main * total + slave;
在完成准备步骤后,计算过程从一个循环开始,该循环用于计算两个频率幅度向量之间的距离。
//--- calc distance float dist = 0; if(main != slave) { #pragma unroll for(int d = 0; d < dimension; d++) dist += pow(ComplexAbs((float2)(buf_real[shift_main + d], buf_imag[shift_main + d])) - ComplexAbs((float2)(buf_real[shift_slave + d], buf_imag[shift_slave + d])), 2.0f); dist = sqrt(dist); }
注意,线程矩阵中存在对角元素。不出所料,在这种情况下,算法会计算两个相同向量之间的距离,而这个距离显然等于零。因此,跳过距离计算循环,直接赋予一个零值。
接下来,我们需要对数值进行归一化处理。为此,让我们实现一个算法来找到工作组内的最大距离。首先,循环将各个线程子组的最大值收集到本地数组的元素中。
//--- Look Max #pragma unroll for(int i = 0; i < total; i += ls) { if(i <= slave && (i + ls) > slave) Temp[slave % ls] = fmax((i == 0 ? 0 : Temp[slave % ls]), IsNaNOrInf(dist, 0)); barrier(CLK_LOCAL_MEM_FENCE); }
然后,我们确定局部数组元素中的最大值。
int count = ls; do { count = (count + 1) / 2; if(slave < count && (slave + count) < ls) { if(Temp[slave] < Temp[slave + count]) Temp[slave] = Temp[slave + count]; Temp[slave + count] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
之后,在工作组内,通过除以最大值来对频率幅度向量之间的距离进行归一化处理。
//--- Normalize if(Temp[0] > 0) dist /= Temp[0];
正如预期的那样,所有归一化距离现在都在 [0, 1] 的范围内。数值 1 对应于最远的通道。然而,由于这些通道的影响应尽可能小,因此将归一化距离的倒数存储在输出缓冲区中。
//--- result mask[shift_mask] = 1 - IsNaNOrInf(dist, 1); }
至此,内核实现部分就结束了。
需要注意的是,这种实现方式有一个重要特点。所描述的算法不包含可学习的参数,因为频率幅度向量之间的距离是一个固定量,与其他因素无关。这使我们能够省去反向传播过程,从而减少优化开销。
下一步是在主程序端组织通道聚类模块的功能。为此,我们创建了一个新的类 CNeuronChanelMask ,其结构如下所示。
class CNeuronChanelMask : public CNeuronBaseOCL { //--- protected: uint iUnits; uint iFFTdimension; CBufferFloat cbFFTReal; CBufferFloat cbFFTImag; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, bool reverse = false); virtual bool Mask(void); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) { return true; } public: CNeuronChanelMask(void) {}; ~CNeuronChanelMask(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronChanelMask; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
在这个结构中,在少量的内部数值对象中,我们只观察到两个缓冲区,用于存储被分析信号的频率分量的实部和虚部。在实现类虚方法的过程中,我们将更详细地讨论它们的使用。
这些对象直接声明在类中,这样我们就可以让构造函数和析构函数为空。这些已声明和已继承对象的初始化是在 Init 方法中执行的。
bool CNeuronChanelMask::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(window <= 0) return false; if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_count * units_count, optimization_type, batch)) return false;
该方法参数包含几个常量,用于定义对象的架构:
- window — 分析序列的长度
- units_count — 通道数
需要注意的是,该对象的输出预期是一个方形通道掩码矩阵。它的尺寸取决于通道数,与序列长度无关。然而,为了对输入数据进行正确的预处理,需要知道序列长度。因此,首先对参数进行验证,之后调用父类的相应方法,其中已实现了对继承接口的初始化。
在成功执行父方法后,会存储这些常量。
//--- Save constants
iUnits = units_count;
activation = None;
重要的是要记住,之前实现的快速傅里叶变换(FFT)算法仅支持长度为 2 的幂次的序列。一般来说,这不成问题,因为序列可以用零来填充。然而,我们首先需要确定最近的 2 的幂次方。
//--- Calculate FFT dimension int power = int(MathLog(window) / M_LN2); if(MathPow(2, power) != window) power++; iFFTdimension = uint(MathPow(2, power));
只有在完成上述步骤后,才会为实频分量和虚频分量临时存储的缓冲区分配足够的大小并进行初始化。
if(!cbFFTReal.BufferInit(iFFTdimension * iUnits, 0) || !cbFFTReal.BufferCreate(OpenCL)) return false; if(!cbFFTImag.BufferInit(iFFTdimension * iUnits, 0) || !cbFFTImag.BufferCreate(OpenCL)) return false; //--- return true; }
初始化完成后,我们继续执行 CNeuronChanelMask::feedForward 方法中实现的前向传播算法。这一阶段相对简单。
bool CNeuronChanelMask::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!FFT(NeuronOCL.getOutput(), NULL, GetPointer(cbFFTReal), GetPointer(cbFFTImag), false)) return false; //--- return Mask(); }
该方法接收一个指向输入数据对象的指针,并立即对其进行验证。随后,输入数据被转换为频率分量,并调用前文所述核函数的封装器方法。执行结果将返回给调用程序。
内核入队方法遵循前面描述的模式,不再赘述。
如前所述,本例中不考虑反向传播。对应的方法被总是返回 true 的存根所重载。这种方法能够将新对象无缝集成到现有的模型架构中。
至此,通道聚类模块算法的实现完成。本文附件中提供了该类及其方法的完整源代码。
DUET 区块
现阶段,我们已经构建了时间聚类模块和通道聚类模块。这两个模块并行运行,以两种表示形式分析多元时间序列:时域和频域。所有获得的结果都在融合模块中进行组合,该模块使用掩码注意力机制整合有关通道依赖性的信息。这样既可以对各个通道的预测结果进行调整,又能考虑到检测到的相关性。融合算法根据通道间依赖性权重调整每个通道的影响。因此,最终预测变得更加稳健,模型也更不容易受到过拟合和随机噪声的影响。
在实践中,我们使用改进的自注意力机制,其中从时间聚类模块获得的依赖系数乘以通道聚类模块生成的掩码。之后才使用 Softmax 函数对权重进行归一化。

所提出的算法在 CNeuronDUET对象中实现,该对象结合了上述三个模块的功能。新类的结构如下所示。
class CNeuronDUET : public CNeuronTransposeOCL { protected: uint iWindowKey; uint iHeads; //--- CNeuronTransposeOCL cTranspose; CNeuronMoE cExperts; CNeuronConvOCL cQKV; CNeuronBaseOCL cQ; CNeuronBaseOCL cKV; CNeuronChanelMask cMask; CBufferFloat cbScores; CNeuronBaseOCL cMHAttentionOut; CNeuronConvOCL cPooling; CNeuronBaseOCL cResidual; CNeuronMHFeedForward cFeedForward; //--- virtual bool AttentionOut(void); virtual bool AttentionInsideGradients(void); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); public: CNeuronDUET(void) {}; ~CNeuronDUET(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint units_out, uint experts, uint top_k, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronDUET; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; //--- virtual void TrainMode(bool flag) { bTrain = flag; cExperts.TrainMode(bTrain); } };
值得注意的是,在这种情况下,父类是一个数据转置层。这是由源数据的结构决定的。
该模型以矩阵形式表示的多变量时间序列作为输入,其中每一行对应于所分析系统的单独时间步长。然而,上述所有模块都是在单变量时间序列上运行的。这也适用于数据融合模块。为确保数据处理正确,需将输入数据转换为适合分析的格式。所有运算完成后,结果将转换回原始表示形式。最后一步由父类处理,这样可以确保数据结构的一致性,并简化模块集成。
在上述类结构中,可以观察到相当多的内部对象,它们在构建算法中发挥着重要作用。在实现类虚方法的过程中,我们将更详细地研究它们的功能。目前需要注意的是,所有对象都是直接在类中声明的,这使得构造函数和析构函数可以保持为空。所有对象(包括继承对象)的初始化都在 Init 方法中执行。
bool CNeuronDUET::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint units_out, uint experts, uint top_k, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, window, units_out, optimization_type, batch)) return false;
初始化方法接收一组常量,这些常量定义了对象的架构。这些参数的结构大家已经很熟悉了。其中一些被用于构建时间和通道聚类模块,而另一些则与注意力模块相关。应特别注意 units_out 参数,该参数定义了所需的输出序列长度。
在方法体内,我们首先调用父类的相应方法,其中已经实现了对某些参数的验证以及对继承接口的初始化。
接下来,将所需的参数存储在内部变量中。
iWindowKey = MathMax(window_key, 1); iHeads = MathMax(heads, 1);
然后,我们继续初始化内部对象。如前所述,在进行分析之前,必须对输入数据进行转置。此功能由一个专用对象处理。
int index = 0; if(!cTranspose.Init(0, index, OpenCL, units_count, window, optimization, iBatch)) return false;
之后,初始化时间聚类模块和通道聚类模块。
index++; if(!cExperts.Init(0, index, OpenCL, units_count, units_out, window, experts, top_k, optimization, iBatch)) return false; index++; if(!cMask.Init(0, index, OpenCL, units_count, window, optimization, iBatch)) return false;
接下来,我们来看数据融合模块的对象。它本质上代表了一个经过修改的注意力模块。首先,我们初始化负责生成查询、键和值实体的对象。在这种情况下,使用单个卷积层并行生成所有三个实体。
index++; if(!cQKV.Init(0, index, OpenCL, units_out, units_out, iHeads * iWindowKey * 3, window, 1, optimization, iBatch)) return false;
我们还添加了两个对象,用于将这些实体拆分为单独的张量。
index++; if(!cQ.Init(0, index, OpenCL, cQKV.Neurons() / 3, optimization, iBatch)) return false; index++; if(!cKV.Init(0, index, OpenCL, cQ.Neurons() * 2, optimization, iBatch)) return false;
注意力系数存储在数据缓冲区中。
if(!cbScores.BufferInit(cMask.Neurons()*iHeads, 0) || !cbScores.BufferCreate(OpenCL)) return false;
请注意,在所有情况下,对象尺寸的指定都考虑了转置的输入矩阵。
接下来,我们初始化多头注意力输出对象。
index++; if(!cMHAttentionOut.Init(0, index, OpenCL, cQ.Neurons(), optimization, iBatch)) return false;
然后,我们初始化一个卷积层以进行降维,并合并来自不同注意力头的输出。
index++; if(!cPooling.Init(0, index, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, units_out, window, 1, optimization, iBatch)) return false; cPooling.SetActivationFunction(None);
然后,我们添加一个对象来存储残差连接的结果。
index++; if(!cResidual.Init(0, index, OpenCL, cPooling.Neurons(), optimization, iBatch)) return false; cResidual.SetActivationFunction(None);
按照原有架构,应该会有一个标准的前馈模块。但是,我们用从 StockFormer 框架中借鉴的多头变体来替换它。
index++; if(!cFeedForward.Init(0, index, OpenCL, units_out, 4 * units_out, window, 1, heads, optimization, iBatch)) return false; //--- return true; }
该方法最后将操作的逻辑结果返回给调用者。
下一步是在 CNeuronDUET::feedForward 方法中实现前向传递。
bool CNeuronDUET::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cTranspose.FeedForward(NeuronOCL)) return false;
该方法接收一个指向输入数据对象的指针,该指针会立即传递给内部数据转置对象的相应方法。所有后续操作均在转置后的数据上进行。
首先,将数据传递给时间聚类模块,以获得单变量序列的预测结果。
if(!cExperts.FeedForward(cTranspose.AsObject())) return false;
然后,将转置后的输入传递给通道聚类模块。
if(!cMask.FeedForward(cTranspose.AsObject())) return false;
接下来,我们组织数据融合模块的前向传递。查询、键和值实体由时间聚类模块的输出生成。
if(!cQKV.FeedForward(cExperts.AsObject())) return false;
然后将输出结果分成两个张量。
if(!DeConcat(cQ.getOutput(), cKV.getOutput(), cQKV.getOutput(), iWindowKey, 2 * iWindowKey, cQKV.GetUnits())) return false;
然后,调用带掩码的多头自注意力封装方法。
if(!AttentionOut()) return false;
多头注意力机制的输出被投影以匹配时间聚类模块输出的维度。
if(!cPooling.FeedForward(cMHAttentionOut.AsObject())) return false;
然后将残差连接添加到得到的值上。
if(!SumAndNormilize(cExperts.getOutput(), cPooling.getOutput(), cResidual.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
多头前馈块作为具有内置残差连接的独立模块实现。因此,只需调用其对应的方法,并将之前操作的结果作为输入传递即可。
if(!cFeedForward.FeedForward(cResidual.AsObject())) return false;
最后,将结果转换回原始数据表示形式。这里我们使用了父类的功能。
return CNeuronTransposeOCL::feedForward(cFeedForward.AsObject());
}
我们将执行操作的逻辑结果返回给调用程序,并完成方法的执行。
在完成了前馈过程的实现后,我们继续实现反向传播算法。如你所知,这涉及两个方法:
- 根据内部对象和输入数据对最终结果的贡献,将误差梯度分布在这些对象和输入数据之间 — calcInputGradients
- 优化模型参数以最小化总体误差 — updateInputWeights
在此实现中,DUET 块的所有可训练参数都包含在内部对象中,因此我们只需调用这些对象的相应方法。更为关键的问题是,如何在内部对象和输入数据之间正确分配误差梯度。
calcInputGradients 方法接收指向输入数据对象的指针。这是前馈过程中使用的同一个对象。但这一次,它必须填充相应的梯度值。
bool CNeuronDUET::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
显然,数据只能传递给有效对象,因此我们立即检查指针的有效性。否则,进一步的操作将毫无意义。
如你所知,梯度分布严格遵循前向传播的信息流,但顺序相反。由于前向传播过程以调用父类方法结束,反向传播过程则以调用父类方法开始。这次,我们称之为误差梯度分布法。
if(!CNeuronTransposeOCL::calcInputGradients(cFeedForward.AsObject())) return false;
接下来,梯度通过多头前馈模块进行传播。
if(!cPooling.calcHiddenGradients(cFeedForward.AsObject())) return false;
然后,将得到的梯度分布到各个注意力头上。
if(!cMHAttentionOut.calcHiddenGradients(cPooling.AsObject())) return false;
下一步是调用包装方法,将错误分配到掩码自注意力机制中的查询、键和值。
if(!AttentionInsideGradients()) return false;
将结果合并成一个张量。
if(!Concat(cQ.getGradient(), cKV.getGradient(), cQKV.getGradient(), iWindowKey, 2 * iWindowKey, iCount)) return false;
如有必要,可使用激活函数的导数调整这些值。
if(cQKV.Activation() != None) if(!DeActivation(cQKV.getOutput(), cQKV.getGradient(), cQKV.getGradient(), cQKV.Activation())) return false;
然后将梯度传递给时间聚类模块。
if(!cExperts.calcHiddenGradients(cQKV.AsObject()) || !DeActivation(cExperts.getOutput(), cExperts.getPrevOutput(), cPooling.getGradient(), cExperts.Activation()) || !SumAndNormilize(cExperts.getGradient(), cExperts.getPrevOutput(), cExperts.getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
值得注意的是,时间聚类模块的输出也被用于残差连接。因此,梯度也必须沿着这条路径传播。为了实现这一点,首先使用时间聚类模块激活函数的导数来调整注意力块输出端的梯度,然后将两条路径的梯度相加。
接下来,梯度通过时间聚类模块进行传播。
if(!cTranspose.calcHiddenGradients(cExperts.AsObject())) return false;
最后,它被传递回输入数据层。
return prevLayer.calcHiddenGradients(cTranspose.AsObject());
}
我们将执行操作的逻辑结果返回给调用程序,并完成方法的执行。
请注意,在梯度传播过程中,通道聚类模块中并无信息流动。如前所述,该模块不包含反向传播过程,在此阶段,我们只是排除不必要的操作。
至此,我们对实现 DUET 框架作者所提出的方法的算法的讨论就结束了。所有所述对象及其方法的完整源代码见文章附件。
模型架构
下一阶段是将已开发的组件集成到可训练模型的架构中。为此,我们接下来将描述模型的架构设计。
与之前的工作一样,我们采用多任务学习方法,同时训练两个模型: Actor 模型和预测未来运动方向概率的模型。后者的架构完全借鉴了前人的工作,因此本文重点介绍 Actor 架构。CreateDescriptions 方法中给出了两种模型的架构。
bool CreateDescriptions(CArrayObj *&actor, CArrayObj *&probability) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; }
在方法参数中,我们接收指向两个动态对象的指针,这两个对象用于存储模型架构的描述。在方法体内,我们会立即验证收到的指针,并在必要时创建对象的新实例。
Actor 模型架构以全连接层开始,该层用作输入数据接口。它必须足够大,才能容纳全部的分析信息量。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
正如 DUET 框架的作者所建议的那样,接下来是一个批量归一化层,旨在标准化输入数据并最大限度地减少异常值的影响。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,我们使用之前构建的两个连续的 DUET 块。然而,数据分割的方法已经进行了修改。本实验摒弃了传统的数据分割方法,而是采用了在多维相空间中表示数据的方式。该方法受 Attraos 框架的启发,能够更准确地对时间序列中的复杂依赖关系进行建模,从而提高可解释性。在第一层中,使用 5 分钟的步长。
在时间聚类模块中,初始化了 16 个并行编码器。对于每个聚类,选择 4 个最合适的聚类。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDUET; descr.window = BarDescr * 5; // 5 min { int temp[] = {HistoryBars / 5, HistoryBars / 5, 16, 4}; // {Units in (24), Units out (24), Experts, Top K} if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.window_out = 256; descr.step = 4; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在第二个 DUET 块中,相位表示步长增加到 15,而所有其他参数保持不变。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDUET; descr.window = BarDescr * 15; // 15 min { int temp[] = {HistoryBars / 15, HistoryBars / 15, 16, 4}; // {Units in (8), Units out (8), Experts, Top K} if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.window_out = 256; descr.step = 4; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
请注意,在数据处理过程中,张量大小保持不变。然而,随后的卷积层将序列长度减少了三倍。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = HistoryBars / 3; descr.window = BarDescr * 3; descr.step = descr.window; int prev_window = descr.window_out = BarDescr; descr.activation = SoftPlus; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来是包含 3 个完全连接层的决策模块。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.batch = 1e4; descr.activation = TANH; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = TANH; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = SoftPlus; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
然后是批量归一化层。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在 Actor 的输出端,我们添加了一个风险管理模块。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMacroHFTvsRiskManager; //--- Windows { int temp[] = {3, 15, NActions, AccountDescr}; //Window, Stack Size, N Actions, Account Description if(ArrayCopy(descr.windows, temp) < int(temp.Size())) return false; } descr.count = 10; descr.window_out = 64; descr.step = 4; // Heads descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = NActions / 3; descr.window = 3; descr.step = 3; descr.window_out = 3; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
两种模型的完整架构图可在附件中找到。附件中还包括环境交互和模型训练程序,这些程序是从之前的工作中复用而来,未做任何修改。
测试
我们已经开展了大量工作,以实现我们对 DUET 框架中提出的方法的解释,并使用 MQL5 将其集成到可训练模型中。现在我们可以进入关键阶段:在真实的历史数据上测试已实现的解决方案。
对于模型训练,我们使用 2024 年全年 EURUSD 货币对 M1 时间周期的历史数据集。在数据采集过程中,指标参数保持默认值。
模型训练分两个阶段进行。首先,批量大小设置为 1,以便在每次迭代时从训练数据集中随机选择一个状态。这有助于模型适应不同的条件。然而,这并不足以确保风险管理模块的正常运作。因此,在第二阶段,批处理大小增加到 60,从而可以考虑 60 个环境状态序列和相应的 Actor 动作。这使得训练过程更加稳定高效。
使用 2025 年 1 月至 2 月的历史数据对训练好的模型进行测试。所有设置均被保留,确保对预测质量进行客观评估。测试结果如下所示。


在测试期间,该模型执行了 53 笔交易,其中超过 56% 的交易以盈利状态平仓。值得注意的是,每笔盈利交易的利润几乎是亏损交易的两倍。这使得利润因子为 2.44。
结论
在这项工作中,我们探索了 DUET 框架,该框架的作者将频域分析、度量学习和概率滤波结合起来,用于多元时间序列分析。这些组件可以提高预测质量,增强模型对噪声的鲁棒性。
在实践部分,我们使用 MQL5 实现了我们对所提出方法的解释,并将其集成到一个模型中。我们使用真实的历史数据对模型进行了训练,并在样本外数据上对其进行了测试。所得结果证明了该模型的潜力。然而,在实际交易环境中部署之前,有必要在更具代表性的数据集上对模型进行训练,并进行全面测试。
参考
本文中用到的程序
| # | 名称 | 类型 | 描述 |
|---|---|---|---|
| 1 | Research.mq5 | EA | 样本采集 EA |
| 2 | ResearchRealORL.mq5 | EA | 使用 Real-ORL 方法采集样本的 EA |
| 3 | Study.mq5 | EA | 模型训练 EA |
| 4 | Test.mq5 | EA | 模型测试 EA |
| 5 | Trajectory.mqh | 类库 | 系统状态和模型架构描述结构 |
| 6 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
| 7 | NeuroNet.cl | 代码库 | OpenCL 程序代码 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/17487
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
博弈论方法在交易算法中的应用
你应该了解的 MQL5 向导技巧(第67部分):使用 TRIX 和威廉百分比范围的形态
数据科学与机器学习(第四十二部分):使用Python中的ARIMA模型进行外汇时间序列预测 —— 您需要了解的一切
确定性振荡搜索(DOS)