
神经网络变得简单(第 89 部分):频率增强分解变换器(FEDformer)
概述
时间序列的长期预测是一个解决各种应用问题时长期存在的问题。基于变换器的模型展现出有前景的结果。不过,高计算复杂度和内存需求,令其难以运用变换器为长序列建模。这引发了众多研究,致力于降低变换器算法的计算成本。
尽管基于变换器的时间序列预测方法取得了进展,但在某些情况下,它们在捕捉时间序列分布的共同特征时失效。论文《FEDformer:频率增强分解变压器进行长期序列预测》 的作者为解决这个问题做了一次尝试。它们取时间序列的实际数据,与得自原义变换器的预测值进行比较。以下是该论文的截屏。
您可看到,预测时间序列的分布与实情非常不同。预期值和预测值之间的差异能用变换器中的点关注来解释。由于每个时间步骤得到的预测都是单独、且无依赖的,这就像模型无法作为一个整体预留时间序列的全局属性和统计值。为了解决这个问题,本文的作者开创了两个思路。
首先是运用季节性趋势分解方式,其已在时间序列分析中被广泛使用。该论文的作者提出了一种特殊的模型架构,其可有效地把预测的分布逼近实情。
第二个思路是在变换器算法中实现傅里叶分析。我们可以分析其频率特征,取代变换器应用到序列的时间测量。这有助于变换器更好地捕获时间序列的全局属性。
所提议思路的组合会在频率增强分解变换器模型 FEDformer 中实现。
与 FEDformer 相关的最重要的问题之一,是在傅里叶分析中应当用哪个频率分量子集来代表时间序列。在这般分析中,低频分量更经常被保留,而高频分量则被舍弃。不过,这也许与时间序列预测不对应,因为时间序列趋势里的某些变化与重要事件相关联。只需删除信号中的所有高频分量即可丢弃信息中的这些部分。该方法的作者接受了这样一个事实,即时间序列通常具有基于傅里叶基的未知稀疏表示。他们的理论分析表明,随机选择的频率分量子集,包括低频和高频两者,都可提供更好的时间序列表现。这一观察结果已被广泛的实证研究所确认。
除了提高长期预测的效率外,变换器与频率分析的结合还能将计算成本从二次方降低到线性复杂度。
论文作者将他们的成就总结如下:
1. 他们提出了一种信号分解架构,搭配改进的频率响应的变换器,并利用智能系统进行季节性趋势分解,以便更好地捕获时间序列的全局属性。
2. 他们提出了变换器架构中的傅里叶增强模块和小波浪增强模块,允许遵循研究中的频率特征来捕获时间序列的重要结构。它们可以替代自关注和交叉关注模块。
3. 通过随机选择固定的傅里叶分量数量,所提议模型达成了线性计算复杂度、及内存成本。这种选择方法的有效性已在理论和实证上得到证明。
4. 在不同领域的六个基线数据集上进行的实证表明,所提议模型在多变量和单变量预测方面,把最先进方法的性能分别提高了 14.8% 和 22.6%。
1. FEDformer 算法
该方法的作者提出了 2 个版本d的 FEDformer 模型。一种利用傅里叶基来分析时间序列的频率特征。第二种是基于小波浪的运用,它允许组合时域和频域两者进行分析。
预测长期时间序列是一个序列到序列的问题。我们指派初始数据序列的大小表示为 I,预测序列的大小为 O。设 D 表示描述序列一种状态的向量大小。然后我们将大小为 I*D 的张量投喂到编码器之中,解码器则被投喂矩阵 (I/2+O)*D。
如上所述,该方法的作者在其中引入季节性趋势分解和分布的分析,来改进变换器架构。更新后的变换器具有深度分解架构,包括频率响应分析单元(FEB)、频率增强关注度模块(FEA)、混合专家分解模块(MOEDecomp)。
FEDformer 编码器使用类似于变换器编码器的多级结构。它的一个单独的模块能按以下数学表达式表示:
此处 Sen 表示在 MOEDecomp 分解模块中,从原始数据中提取的季节性分量。
对于 FEB 模块,该方法的作者提出了两个不同的版本(FEB-f 和 FEB-w),分别使用离散傅里叶变换机制(DFT),以及离散小波浪变换(DWT)实现。在该实现中,它们取代了自关注模块。
解码器也采用多级结构,就像编码器一样。但其组成模块的架构要更广博,由以下公式描述:
Sde 和 Tde 表示 MOEDecomp 分解模块之后的季节性和趋势分量。Wl 充当提取趋势的投影。像是 FEB,FEA 有两个不同的版本(FEA-f 和 FEA-w),分别通过 DFT 和 DWT 投影实现。FEA 配以关注度设计实现,取代了原版变换器的交叉关注度模块。
最终预测是两个经优调分解分量的汇总。使用 WS 矩阵将季节性分量投影到目标测量值。
所提议的 FEDformer 模型使用离散傅里叶变换(DFT),它能把所分析序列分解为其组成谐波(正弦分量)。为了提升模型的效率,FEDformer 的作者用到了快速傅里叶变换(FFT)。
如前所述,该方法采用傅里叶基的随机子集,且子集的尺度受标量限制。在 DFT 和逆 DFT(IDFT) 运算之前选择模式索引,可以进一步调整计算的复杂度。
含有傅里叶变换(FEB-f)的扩展频率范围模块可由编码器和解码器两者所用。FEB-f 模块的源数据首先进行线性投影,然后从时域变换到频域响应。M 谐波是从获得的频率特性中随机采样的。之后,将选定的频率特征乘以参数化内核的矩阵,其由随机参数初始化,并在模型训练过程中进行调整。在执行逆傅里叶变换之前,先把完整的频率响应维度结果填充零值,之后会将所分析序列返回到时域。论文作者提供的 FEB-f 模块的原始可视化如下所示。
使用离散傅里叶变换(FEA-f) 的频率响应关注度模块应用了规范的变换器方式,但略有增加。源数据将转换为 Query、Key 和 Value 表现形式。使用交叉关注度时,Query 来自解码器,而 Key 和 Value 来自编码器。然而,在 FEA-f 中,我们使用傅里叶变换来转换 Query、Key 和 Value,并在频域执行类似的规范关注度机制。于此,如 FEB-f 模块一般,为了分析,我们随机采样 M 谐波。关注度操作的结果填充零值至原始序列的大小,并执行逆傅里叶变换。作者可视化中的 FEA-f 结构如下所示。
傅里叶变换创建信号的频域表示,而小波浪变换能够在频域和时域两者中表示信号,从而有效地访问有关原始信号的本地化信息。多小波浪变换结合了正交多项式和小波浪的优点。信号的多小波浪表示能通过多尺度张量积和多小波浪基获得。注意,不同尺度的基与张量积相关。FEDformer 方法的作者采用非标准小波浪表示,来降低模型的复杂性。
FEB-w 架构与 FEB-f 的不同之处在于递归机制:原始数据递归分解为 3 个部分,每个部分都单独处理。对于小波浪分解,该方法的作者提出了勒让德(Legendre)小波浪基分解的固定矩阵。三个 FEB-f 模块分别用于处理得到的高频部分、低频部分和小波浪分解的剩余部分。每次迭代都会创建一个已处理的高频张量、一个已处理的低频张量、和一个原始低频张量。这是一种自顶而下的方式,分解步骤把信号按 1/2 间隔。在不同的分解迭代期间,三组 FEB-f 模块会被一同使用。关于小波浪重造,该方法的作者也用递归创建输出张量。
FEA-w 包含分解阶段和重造阶段,类似于 FEB-w。此处 FEDformer 的作者保持了重造阶段不变。唯一的区别是分解阶段。使用相同的矩阵将信号分解为 Query、Key 和 Value 实体。如上所示,FEB-w 模块包含三个进行信号处理的 FEB-f 模块。FEB-f 可以被视为自我关注机制的替代品。该方法的作者用了一种简单的方法,使用小波浪分解来创建频率增强的交叉关注度,将每个 FEB-f 替换为 FEA-f 模块。此外,还多加了一个 FEA-f 模块来处理最琐碎的残留物。
由于经常观察到的复杂周期形态均含有趋势分量,在合并固定窗口平均值时,可能难以从实际数据中提取趋势。为了克服这个问题,开发了分解模块(MOEDecomp)。它包含一组不同平均大小的过滤器,从原始信号中提取多重趋势分量,以及一组数据相关权重,并将它们组合成结果趋势。
FEDformer 方法的完整算法在作者的原始可视化中表现如下。
2. 利用 MQL5 实现
我们已研究了所提议 FEDformer 方法的理论层面。我必须承认,我们的实现会与原义相去甚远。我们将采用提议的方式,但不会完全照搬所提议算法。对此,我个人有若干个信念。
首先,我们需要决定我们将使用哪个基:DFT 或 DWT。这个问题相当复杂、且模棱两可。但我们将做得更简单。我们看看原始论文中所述方法的测试结果。
注意 “Exchange” 列。我们不会去详论该模型究竟测试了哪些数据,但使用 DWT 模型具有明显的优越性。或许,若输入数据中没有清晰的周期性,则 DFT 无法判定趋势变化时刻。实际上,该方法忽略了输入数据的时间分量。DWT 分析两个维度上的信号,这样就能够提供更准确的预测数据。我认为在这种状况下,选择 DWT 是显而易见的。
2.1实现 DWT
我们已经根据实现基础做出了决定。现在我们从在函数库中实现小波浪分解开始。为此,我们创建一个新对象 CNeuronLegendreWavelets。
我们稍微考虑一下正在创建的对象架构。如上所述,对于小波浪分解,该方法的作者提议使用勒让德(Legendre)小波浪基分解的固定矩阵。换言之,为了分解信号,我们仅需将信号向量乘以小波浪基矩阵。
在我们的输入数据序列中,我们必须分析多模时间序列的若干个并行信号。对于每个单位时间序列,我们将采用相同的基矩阵。
该过程非常类似于具有多个过滤器的卷积。但在这种情况下,滤波器矩阵的角色是由小波浪基矩阵执行的。逻辑上,我们可以创建一个新对象作为卷积层的后继对象。经深思熟虑的方式,我们可以通过仅覆盖其中的一对,来充分利用继承的方法。
class CNeuronLegendreWavelets : public CNeuronConvOCL { protected: virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } public: CNeuronLegendreWavelets(void) {}; ~CNeuronLegendreWavelets(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronLegendreWavelets; } };
在上述新类 CNeuronLegendreWavelets 的结构中,您仅能看到 3 个被覆盖的方法,其中一个是返回预定义常量的类标识符 Type。
第二点,上面已经提到过,我们用到一个固定的基础小波浪矩阵。因此,我们的类中将没有可训练的参数,并且 updateInputWeights 方法由“存根”重新定义。
事实上,我们仅需用到类对象初始化方法 Init。在新方法中,我们不声明任何局部变量或对象。在初始化方法中,我们仅需填充基础小波浪矩阵。
该方法的作者提议使用勒让德多项式作为小波浪。我已选择了 9 个这样的多项式,它们的可视化如下所示。
如您所见,由图上表现的多项式,我们可以描述相当广泛的频率范围。
另请注意,所呈现的多项式的可接受值范围为 [0, 1]。这非常方便。我们为所分析序列的窗口长度定义为 1。然后我们将范围除以序列中的元素数量。以这种方式,我们定义了序列的两个相邻元素之间的时间步长,而我们最初是用固定步长形成。于此,所收集初始数据的时间帧无关紧要。我们分析了原始序列可见窗口内的信号频率特征。
此处,我们面临着在模型设计阶段判定序列中元素数量的问题。在创建基矩阵之前,我们需要指定其维度。在此阶段,我们仅有已选过滤器的数量。仅在初始化模型时,我们才会知道所分析序列的窗口大小。事实上,我们有两种选项来摆脱这种状况:
- 我们可以判定基础小波浪矩阵的严格维度,并立即在其中填充数值。在矩阵之前使用一个可训练的卷积层,将允许我们配以任何大小的原始序列工作。
- 创建一个通用算法,在模型初始化阶段针对任何大小的初始数据填充基础小波浪矩阵。
第一个选项允许我们以任何可用的方式按固定值填充矩阵。我们甚至可在网站上找到我们感兴趣的基础小波浪的系数。但是我们如何在准确性和性能之间判定“中庸之道”呢?甚至,在不同的任务中,对预测准确性的要求可能会有很大差异。
以我观点,第二个选项看起来更适合我们的目的。为了实现它,我们将为选定的多项式创建公式作为宏替换。以下是其中的一些(附件中提供了完整清单):
#define Legendre4(x) (70*pow(x,4) - 140*pow(x,3) + 90*pow(x,2) - 20*x + 1) #define Legendre6(x) (924*pow(x,6) - 2772*pow(x,5) + 3150*pow(x,4) - 1680*pow(x,3) + \ 420*pow(x,2) - 42*x + 1) #define Legendre8(x) (12870*pow(x,8) - 51480*pow(x,7) + 84084*pow(x,6) - 72072*pow(x,5) + \ 34650*pow(x,4) - 9240*pow(x,3) + 1260*pow(x,2) - 72*x + 1)
使用这些宏替换,我们可以获得任何离散值的多项式值。准备工作完成后,我们可以继续描述新类 CNeuronLegendreWavelets::Init 对象的初始化算法。
在方法的参数中,我们传递对象架构的关键参数:
bool CNeuronLegendreWavelets::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint step, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window, step, 9, units_count, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类的相同方法。
注意,在新类的初始化方法参数中,我们只接收正在分析的序列窗口大小,及序列中的元素数量。在调用父类的相关方法时,我们需要加上窗口步长,和过滤器的数量。正如我们之前决定的过滤器数量,我们将有 9 个。至于所分析窗口的步长,它将等于所分析窗口。
父类的方法成功初始化后,我们在卷积参数矩阵里填充随机值。但我们需要用小波浪的基本参数来填充它。故此,我们首先用零值填充权重矩阵。这是一个非常重要的点,因为我们需要重置指定的乖离参数。
WeightsConv.BufferInit(WeightsConv.Total(), 0);
然后在循环中,我们用基础小波浪的数值填充矩阵:
for(uint i = 0; i < iWindow; i++) { uint shift = i; float k = float(i) / iWindow; if(!WeightsConv.Update(shift, Legendre4(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre6(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre8(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre10(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre12(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre16(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre18(k))) return false; shift += iWindow + 1; if(!WeightsConv.Update(shift, Legendre20(k))) return false; }
将已填充矩阵传输到 OpenCL 关联环境内存:
if(!!OpenCL) if(!WeightsConv.BufferWrite()) return false; //--- return true; }
完成方法执行。
在这个实现中,我们从父类继承了对象正确操作所需的所有剩余功能。因此,我们完成了该类的工作,并转进。
2.2 FED-w 模块
下一阶段可被认为是上升一个档次。我们将创建自己的 FED-w 模块愿景。其功能在 CNeuronFEDW 类中实现。该类的结构如下所示。
class CNeuronFEDW : public CNeuronBaseOCL { protected: //--- uint iWindow; uint iCount; //--- CNeuronLegendreWavelets cWavlets; CNeuronBatchNormOCL cNorm; CNeuronSoftMaxOCL cSoftMax; CNeuronConvOCL cFF[2]; CNeuronBaseOCL cReconstruct; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); //--- virtual bool Reconsruct(CBufferFloat* inputs, CBufferFloat *outputs); public: CNeuronFEDW(void) {}; ~CNeuronFEDW(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint count, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronFEDW; } virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
您可以看到,与之前类相比,该类的架构更加复杂。它声明了 2 个局部变量来存储关键参数。此外,我们在此声明了一整串内部对象。我们将在实现过程中看到其目的。所有对象都声明为静态。这允许我们将类的构造函数和析构函数“留空”。
所有嵌套对象的初始化都在 CNeuronFEDW::Init 方法中执行。对象架构参数将传递给该方法。其中包括的基本参数,可见数据窗口(window)大小,和所分析单元序列的数量(count)。
bool CNeuronFEDW::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * count, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类的相关方法。之后,我们把初始化对象的架构参数保存在局部变量之中:
iWindow = window; iCount = count;
然后,我们按照使用它们的顺序初始化内部对象。
最初,我们计划从接收到的原始数据中提取频率特性。为此,我们使用上面创建的 CNeuronLegendreWavelets 类的实例:
if(!cWavlets.Init(0, 0, OpenCL, iWindow, iWindow, iCount, optimization, iBatch)) return false; cWavlets.SetActivationFunction(None);
与作者提议的方法相比,我们正在创建的 FED-w 模块大大简化。我决定不用 DFT 模块。在我看来,隔离时间成分的频率分析可能会起反作用,并降低预测的品质。因此,存在关于使用 DFT 的适当性问题。但这是我个人的看法,也许是错误的。
甚至,剔除相当劳力密集型的 FFT 过程,在模型训练和运算期间将显著降低计算资源成本。
话虽如此,我决定朝着改进模型性能前进,同时接受预测品质可能恶化的风险。
我首先使用批量归一化层,来归一化小波浪分解后得到的数据:
if(!cNorm.Init(0, 1, OpenCL, 9 * iCount, 1000,optimization)) return false; cNorm.SetActivationFunction(None);
然后,我估算用到的每个滤波器的份额。为此,我调用 SoftMax 函数将得到的数据转换至概率子空间。
if(!cSoftMax.Init(0, 1, OpenCL, 9 * iCount, optimization, iBatch)) return false; cSoftMax.SetHeads(iCount); cSoftMax.SetActivationFunction(None);
请注意,我们单独估算每个单一通道。
然后,我们通过将原始时间序列与我们的小波浪基矩阵进行逆卷积,自概率表示中重造原始时间序列。结果将保存在创建的嵌套基准层当中:
if(!cReconstruct.Init(0, 2, OpenCL, iWindow, optimization, iBatch)) return false; cReconstruct.SetActivationFunction(None);
可以看出,上述操作形成了一种轮回:时间序列→小波浪分解→归一化→概率表示→时间序列。但是我们在输出中得到的是输入时间序列的相当平滑的表示,其中我们要经过一种数字滤波器。结果就是,我们获得了相当有效的数据过滤,只有最少的可训练参数,仅在批量归一化层中体现。在我们的实现中,这个模块替代了自关注。
此处要注意的重要事情是,我们本质上是用预定义的小波浪替换模型的可训练参数。这令我们的模型更容易理解,与可训练参数的 “黑匣子”相反,但灵活性较低。这也给模型架构师带来了额外的负担,他们需要寻找最佳小波浪来解决给定的问题。这就是为什么我要将小波浪多项式放入一个单独的宏替换模块之中。这种方式将允许我们试验不同的小波浪,并找到最优的那个。
但我们回到我们的类初始化方法。数字滤波器模块后随 FeedForward 模块,这在变换器架构中很常见。在此,我们用一个不变的 2-层 MLP,层之间搭配 LReLU。如前,为了实现独立的通道处理,我们使用卷积层对象:
if(!cFF[0].Init(0, 3, OpenCL, iWindow, iWindow, 4 * iWindow, iCount, optimization, iBatch)) return false; cFF[0].SetActivationFunction(LReLU); if(!cFF[1].Init(0, 4, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iCount, optimization, iBatch)) return false; SetActivationFunction(None);
在初始化方法的最后,我们规划了误差梯度缓冲区的替换,以便把不必要的数据复制操作最小化:
if(Gradient != cFF[1].getGradient()) SetGradient(cFF[1].getGradient()); //--- return true; }
初始化对象的工作完成后,我们转去实现所提议模型的前馈通验。从上述对所计划过程的描述中,值得强调的是将所获概率逆卷积到时间序列。
“逆卷积”在我们的实现中听起来像是新事物。不过,我们很久以前就已实现了这个过程。使用逆卷积,我们在卷积层中传播误差梯度。但现在我们需要在前馈通验内实现指定的过程。
难点在于我们类的所有方法都使用固定的数据缓冲区列表。这令我们无需考虑在创建模型期间用到的数据缓冲区。我们只需要提供一个指向对象的指针,而所有数据缓冲区都已经在方法中写入。“缺点”是我们不能使用反向传播方法来实现前馈通验内的算法。不过,我们可以创建一个新方法,在其中我们将用先前创建的内核,并向其传递正确的缓冲区和参数。
这就是我们要做的。我们创建 CNeuronFEDW::Reconstruct 方法,在该方法的参数中,我们将传递指向所获概率和重造序列缓冲区的指针:
bool CNeuronFEDW::Reconsruct(CBufferFloat *sequence, CBufferFloat *probability) { uint global_work_offset[1] = {0}; uint global_work_size[1]; global_work_size[0] = sequence.Total();
在方法主体中,我们定义任务空间,并将所有必要的参数传递到内核:
if(!OpenCL.SetArgumentBuffer(def_k_CalcHiddenGradientConv, def_k_chgc_matrix_w, cWavlets.GetWeightsConv().GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_CalcHiddenGradientConv, def_k_chgc_matrix_g, probability.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_CalcHiddenGradientConv, def_k_chgc_matrix_o, probability.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_CalcHiddenGradientConv, def_k_chgc_matrix_ig, sequence.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_outputs, probability.Total())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_step, (int)iWindow)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_window_in, (int)iWindow)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_window_out, (int)9)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_activation, (int)None)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_CalcHiddenGradientConv, def_k_chgc_shift_out, (int)0)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
之后,我们将内核放入执行队列之中:
if(!OpenCL.Execute(def_k_CalcHiddenGradientConv, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
此刻,准备工作已完成,我们可以继续描述我们类的前馈通验方法 CNeuronFEDW::feedForward。如常,在前馈方法的参数中,我们传递一个指向模型上一层对象的指针,其中包含必要的输入数据:
bool CNeuronFEDW::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cWavlets.FeedForward(NeuronOCL.AsObject())) return false;
在方法主体中,我们首先将获得的序列分解为其组成频率特征。为此,我们调用嵌套的 cWavlets 对象的前馈通验方法。
接下来,根据提议算法,我们将获得的数据归一化,并将它们转换到概率子空间:
if(!cNorm.FeedForward(cWavlets.AsObject())) return false; if(!cSoftMax.FeedForward(cNorm.AsObject())) return false;
然后我们恢复时间序列:
if(!Reconsruct(cReconstruct.getOutput(), cSoftMax.getOutput())) return false;
进一步的算法类似于经典的变换器。我们添加并归一化输入、及重造的时间序列:
if(!SumAndNormilize(NeuronOCL.getOutput(), cReconstruct.getOutput(), cReconstruct.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
我们经由 FeedForward 模块传播数据:
if(!cFF[0].FeedForward(cReconstruct.AsObject())) return false; if(!cFF[1].FeedForward(cFF[0].AsObject())) return false;
之后,我们重新求和,并归一化来自两个数据流的时间序列:
if(!SumAndNormilize(cFF[1].getOutput(), cReconstruct.getOutput(), getOutput(), iWindow, true, 0, 0, 0, 1)) return false; //--- return true; }
前馈通验已准备就绪,我们转到构建反向通验方法。我们从创建一个梯度误差分布方法 CNeuronFEDW::calcInputGradients 开始:
bool CNeuronFEDW::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
在方法主体中,我们首先检查参数中接收的指向前一层对象指针的正确性。如果指针不正确,则运作该方法的操作就没有意义。
如您所记,在类初始化方法中,我们替换了误差梯度数据缓冲区。现在我们可以立即转到搭配 FeedForward 模块工作。
if(!cFF[0].calcHiddenGradients(cFF[1].AsObject())) return false; if(!cReconstruct.calcHiddenGradients(cFF[0].AsObject())) return false;
类似于前馈通验中的数据流,在反向传播通验中,我们还跨越两个并行数据流之间分派误差梯度。在该阶段,我们将来自两个流的误差梯度求和。
if(!SumAndNormilize(Gradient, cReconstruct.getGradient(), cReconstruct.getGradient(), iWindow, false)) return false;
接下来,我们需要通过逆卷积运算传播误差梯度。显然,这是一个简单的卷积运算。不过,有一个问题。卷积层的前馈方法不能搭配误差梯度缓冲区工作。这一次,我们将用到一个小技巧:我们临时将层的结果缓冲区替换为它们的梯度缓冲区。在这种情况下,我们首先保存指向所替换数据缓冲区的指针:
CBufferFloat *temp_r = cReconstruct.getOutput(); if(!cReconstruct.SetOutput(cReconstruct.getGradient(), false)) return false; CBufferFloat *temp_w = cWavlets.getOutput(); if(!cWavlets.SetOutput(cSoftMax.getGradient(), false)) return false;
我们执行卷积层的前馈通验:
if(!cWavlets.FeedForward(cReconstruct.AsObject())) return false;
并将数据缓冲区返回到它们的原始位置:
if(!cWavlets.SetOutput(temp_w, false)) return false; if(!cReconstruct.SetOutput(temp_r, false)) return false;
接下来,我们将误差梯度传播回上一层:
if(!cNorm.calcHiddenGradients(cSoftMax.AsObject())) return false; if(!cWavlets.calcHiddenGradients(cNorm.AsObject())) return false; if(!NeuronOCL.calcHiddenGradients(cWavlets.AsObject())) return false;
并对两个数据流的误差梯度求和:
if(!SumAndNormilize(NeuronOCL.getGradient(), cReconstruct.getGradient(), NeuronOCL.getGradient(), iWindow, false)) return false; //--- return true; }
切记要控制操作的执行。然后我们完成该方法。
误差梯度传播到模型的所有元素之后,后跟模型的可训练参数优化。对象参数优化功能在 CNeuronFEDW::updateInputWeights 方法中实现。该方法的算法非常简单,故我们只需调用嵌套对象的同名方法,并依照被调用方法的逻辑结果来检查结果。
bool CNeuronFEDW::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cFF[0].UpdateInputWeights(cReconstruct.AsObject())) return false; if(!cFF[1].UpdateInputWeights(cFF[0].AsObject())) return false; if(!cNorm.UpdateInputWeights(cWavlets.AsObject())) return false; //--- return true; }
请注意,在该方法中,我们只配合那些包含可训练参数的对象工作。
针对构造新类方法的算法,我们的研究到此结束。您可在附件中找到所讨论类、及其所有方法的完整代码。附件还包含本文中用到的所有程序的完整代码。
请注意,我们仅参照提议的 FEDformer 算法,创建了我们自己的状态编码器的愿景。但我们彻底省略了解码器。这样做是有意为之,因为我们的任务有原则,即产生盈利交易策略。看似这也许很奇怪,但我们并没有努力尽可能地准确预测环境的后续状态。这些状态只是间接地影响我们扮演者的工作。如果我们要针对后续状态构建一个符合规则的清晰算法,我们就需要对即将到来的价格走势进行更准确的预测。不过,我们以不同方式来构建扮演者的政策。
我们训练编码器来预测环境的未来状态,以便获得编码器信息最丰富的隐藏状态。反过来,扮演者提取编码器的隐藏状态,它本质上是扮演者的组成部分,并分析环境的当前状态。然后,基于扮演者对环境当前状态的分析,它构建自己的政策。
此处的细微之处,我们需要理解。因此,我们不会把过多资源花费在分解编码器的隐藏状态,以获得环境未来状态的最准确预测。
2.3模型架构
我们的模型构建模块,经构造对象之后,我们转到描述模型整体架构。在这项工作中,我决定结合看似完全不同的方法。甚至可以说它们正在竞争。在使用上一篇文章中研究的 TiDE 方法之前,我决定用所提议方式,利用时间序列的小波浪分解,进行主要输入数据处理。因此,这些修改会影响环境状态编码器的架构,其已在 CreateEncoderDescriptions 方法中表述。
bool CreateEncoderDescriptions(CArrayObj *encoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; }
在方法主体中,如常,我们首先检查接收指针与记录模型架构的动态数组的相关性,并在必要时创建新对象的实例。
为了获得输入数据,我们使用基本的全连接神经层对象。
//--- Encoder encoder.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(!encoder.Add(descr)) { delete descr; return false; }
该模型如常接收“原始”输入数据。我们在批量数据归一化层对数据进行预处理:
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 10000; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
然后,我们转置输入数据,如此后续操作执行选用指标单一序列的独立分析:
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = HistoryBars; descr.window = BarDescr; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们用到一个包含 10 个 FED-w 层的模块:
//--- layer 3-12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFEDW; descr.count = BarDescr; descr.window = HistoryBars; descr.activation = None; for(int i = 0; i < 10; i++) if(!encoder.Add(descr)) { delete descr; return false; }
紧接着,我们添加一个完全连接的时间序列编码器:
//--- layer 13 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTiDEOCL; descr.count = BarDescr; descr.window = HistoryBars; descr.window_out = NForecast; descr.step = 4; { int windows[] = {HistoryBars, 2 * EmbeddingSize, EmbeddingSize, 2 * EmbeddingSize, NForecast}; if(ArrayCopy(descr.windows, windows) <= 0) return false; } descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,如前,我们使用卷积层来校正预测值的乖离:
//--- layer 14 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = BarDescr; descr.window = NForecast; descr.step = NForecast; descr.window_out = NForecast; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
我们将预测值转置为输入数据的表示形式:
//--- layer 15 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = BarDescr; descr.window = NForecast; if(!encoder.Add(descr)) { delete descr; return false; }
我们返回输入时间序列的统计参数:
//--- layer 16 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = BarDescr * NForecast; descr.activation = None; descr.optimization = ADAM; descr.layers = 1; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
如您所见,这些修改仅影响编码器的内部架构。因此,我们只需要将指针改到编码器的潜在状态层即可提取数据。而扮演者和评论者架构保持不变。
#define LatentLayer 14
此外,我们不需要对环境交互 EA、或模型训练 EA 进行任何修改。您可在附件中找到它们的完整代码。算法说明请参考上一篇文章。
3. 测试
在本文中,我们领略了 FEDformer 方法,其将时间序列分析转换为频率特性领域。这是一个非常有趣、且有前途的方法。我们为利用 MQL5 实现所提议方式,已做了相当多的工作。
我想再次提请注意这一事实,即这篇文章提出了我自己对所提议方法的看法,这与源论文中对该方法的描述有很大不同。相应地,从模型测试结果中得出的结论仅适用于本实现,不能完全外推到原始方法。
如上所述,这些修改仅影响编码器的内部架构。这意味着我们可以使用以前收集的训练数据集来训练模型。
我要提醒您,对于离线模型训练,我们使用与环境交互的预收集轨迹。该数据集基于 2023 年全年的真实历史数据。训练品种: EURUSD,H1 时间帧。为在 MetaTrader 5 策略测试器中测试已训练模型,我采用了 2023 年 1 月的历史数据。
在第一步中,我们在训练环境状态编码器时,遵照后续环境状态的实际衡量值、与其预测值之间的最小化误差。在编码器中,仅分析和预测不依赖于扮演者动作的环境状态。因此,我们在不更新训练数据集的情况下执行编码器的全面训练。
以我的主观看来,在这个阶段,预测后续环境状态的品质已经提高。学习过程中降低的误差证明了这一点。不过,我没有对实际值和预测值进行图形化比较,以便详细分析它们的品质。
在第二个迭代阶段,我们训练扮演者的政策之时,与评论者模型训练并行。这样就能给出扮演者动过的最可能评估。在这个阶段,扮演者动作评估的准确性对我们来说至关重要。因此,我们交替训练模型及更新训练数据集的过程,同时考虑到当前的扮演者政策。
经过上述多次迭代后,我设法训练了一个扮演者行为政策,其应在训练和测试期间产生利润。测试结果如下所示。
如您所见,余额图维持总体上升趋势。同时,图表上可以清楚地识别出 4 个趋势:2 个可盈利,2 个无盈利。积极的一面是,盈利趋势具有更大的潜力。这允许积累足够的盈利,以避免在亏损期间损失您的本金。然而,这种平衡是非常微妙的。在测试期间,盈利因子仅为 1.02,盈利交易的份额略低于 46%。
总体而言,该模型展现出潜力,但需要更多的工作来最大程度地减少亏损期。
结束语
在本文中,我们讨论了 FEDformer 方法,其已被提议进行长期时间序列预测。它包括一个具有低秩频率近似,和混合分解的关注度机制,以便控制分布漂移。
在实践部分,我们利用 MQL5 实现了我们所提议方法的愿景。已使用真实历史数据训练和测试模型。测试结果证明了所研究模型的潜力。但与此同时,也有一些要点需要额外关注。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | ResearchRealORL.mq5 | 智能交易系统 | 运用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | 智能交易系统 | 模型训练 EA |
4 | StudyEncoder.mq5 | 智能交易系统 | 编码训练 EA |
5 | Test.mq5 | 智能交易系统 | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14858


