
神经网络变得简单(第 92 部分):频域和时域中的自适应预测
概述
时域和频域是分析时间序列数据的两种基本表现形式。在时域中,分析侧重于振幅随时间的变化,从而允许识别信号中的局部依赖关系和瞬态。相反,频域分析旨在依据频率分量来表示时间序列,以供深入透视数据的全局依赖关系和频谱特征。结合这两个领域的优势是解决在实时序列中混合不同周期形态问题的一种颇有前景的方式。此处的问题是如何有效地结合时域和频域的优点。
相比时域的成就,频域仍有许多未探索的区域。在最近的文章中,我们已见识过一些使用频域的示例,能更好地处理全局时间序列依赖关系。频域中的直接预测允许使用更多的频谱信息来提高预测时间序列的准确性。不过,在频域中进行直接频谱预测还有一些问题。这些问题之一是正在分析的已知数据的频谱,与正在研究的时间序列的整个频谱之间的频率特征可能存在不匹配,这是运用离散傅里叶变换(DFT)造成的结果。这种不匹配令其很难在跨越整个源数据频谱中准确表示有关特定频率的信息,从而导致预测不准确。
另一个问题是如何有效地提取有关频率合成的信息。提取频谱特征是一项具有挑战性的任务,因为频谱内成群出现的谐波序列包含大量信息。
在论文《ATFNet:自适应时频融合网络进行长期时间序列预测》中提出了 ATFNet 方法,可作为上述问题的解决方案。它包括时域和频域模块,同时处理局部和全局依赖关系。此外,论文中还提出了一种新的加权机制,可在两个模块之间动态分派权重。
该方法的作者提出了一种主谐波序列的能量加权,其有能力基于原始数据所展现的周期性等级,在时域和频域中为模块生成相应的权重。这令我们能够在处理具有不同周期性形态的时间序列时有效地利用这两个领域的优势。
此外,该方法的作者引入了一个扩展的 DFT,把原始数据的离散频率频谱与完整的时间序列对齐,这提高了特定频率表示的准确性。
该方法的作者实现了频域关注度机制,并提出了复谱关注度(CSA)。这种方式允许从不同的频率响应组合中收集信息,提供了一种有效手段来引出对频域表示的关注。
本文演示了基于 8 个真实数据集上的实验结果,根据这些结果,ATFNet 展现出颇具前景的结果,并且在许多数据集上优于其它新潮时间序列预测方法。
1. ATFNet 算法
ATFNet 方法的作者采用了一种与频道无关的制程,其可防止来自不同频道的频谱混杂。由于频道也许具有不同的全局形态,因此混杂它们的频谱也许会对模型的性能产生负面影响。
T-模块直接在时域中处理输入单变量时间序列。故其输出结果是所分析时间序列的未来预测值。
该方法的作者运用扩展离散傅里叶变换(DFT) 将原始单变量时间序列数据转换到频域,生成扩展的频谱。然后再运用逆 DFT(iDFT)将频谱转换回时域。结果如是,F-模块基于频率特征返回时间序列的预测值。
运用自适应权重将 T-模块和 F-模块的预测结果结合到一起,以便获得所分析时间序列的最终预测值结果。这些权重是基于主导谐波序列的能量权重判定的。
通常,该算法可以表示如下:
使用传统的 DFT 也许会导致原始数据的频谱,与整个所分析序列的频谱之间的频率不匹配。因此,建立在分析小块初始数据基础上的预测模型,也许无法获得有关整个所分析时间序列的频率特征的完整和准确的信息。其结果就是构造完整时间序列时,预测准确性较低。
为了解决这个问题,该方法的作者提出了一种扩展的 DFT,它克服了由所分析源数据长度强加的限制。这令我们能够获得原始频谱,它对应于整个序列的 DFT 频率组。具体而言,ATFNet 方法的作者把原始的复指数基替换为完整全序列的 DFT 基:
因此,我们得到了一个长度为 L + T 的频率特征频谱,它与完整的所分析序列(初始 + 预测数据)的 DFT 频谱对齐。对于实时序列,输出频谱的共轭对称性是 DFT 的一个重要属性。利用这个属性,我们可以通过只参考原始数据频谱的前半部分来降低计算成本,因为后半部分提供的是冗余信息。
F-模块的架构基于变换器编码器,其所有参数都具有复数值。F-模块中的所有计算都在复数域中执行。
此外,该方法的作者使用 RevIN 来处理频率特征 F 的原始频谱。尽管 RevIN 最初是为了消除时域中的分布漂移而开发的,但该方法的作者发现用它处理频域中的频谱也很有效。这种方式允许将具有不同全局特征的序列频谱转换到可比较的分布。在分析之前,对频率特征 F 进行归一化。处理完数据之后,我们回头把频率分布的统计特征加上。
由于频域频谱中少有时间依赖性,故该方法的作者未在 F-模块中使用定位编码。
此外,该方法的作者采用了一种改进的多头关注度机制。对于每个头 h = 1, 2, ..., H,内置频谱 Fd 按经训练投影得到的频谱度量值进行投影。之后,在每个头上执行关注度的复标量积。
ATFNet 还用到具有残差连接的 LayerNorm 和前馈层,类似于变换器,其会扩展到复数域。
在 M 编码器层之后,关注度模块的工作成果被线性投影到整个序列的横截面上。使用 iDFT 将得到的频率特征投影到时域之中。最后的 T 点(预测的一部分)被接受为 F-模块的最终结果。
应当注意的是,F-模块使用复值神经网络(CVNN)的完整架构。
T-模块负责捕获时间序列中的局部依赖关系,而这些在时域中更容易处理。在该模块中,作者用到我们已经熟悉的时间序列分片方法。PatchTST 是一种直观且高效的方法,可在时间序列中捕获局部依赖关系。它还采用 RevIN 来解决分布乖离问题。
周期性时间序列持续展现在其频域频谱中至少存在一个谐波群,其中主导谐波群展现出频谱能量的高度集中。相反,在能量分布更均匀的非周期性时间序列的频谱中罕有观察到这种特征。ATFNet 方法的作者表明,频谱中主谐波序列内的能量集中程度能够反映时间序列的周期性。主谐波序列的能量,与频谱总能量的比值,可作为定量评估能量集中度的度量值。直观上,当时间序列展现出更明显的周期性结构时,它可以分解成分量。因此,这样的时间序列在主谐波序列内具有更高的能量集中度。
基于这一属性,ATFNet 作者采用主谐波序列的能量分数作为定量评估时间序列周期性程度的指标。为了识别主谐波序列,最重要的任务是判定基础频率。于此有不同的方式可供采用:
- 一种朴素方法,即标识具有最高振幅值的频率作为基础频率。
- 基于规则的音阶检测算法。
- 数据驱动的音阶检测算法。
ATFNet 算法允许我们采用任意方式来判定基础频率。该方法的作者考虑该分量时会配合其谐波,并计算总能量 Eh。然后,通过计算主频的能量与频谱总能量的比值,来判定 F-模块的权重。
在本文中,该方法的作者进行了一连串实验,以便评估各种方法进行主频率判定的有效性。他们得出的结论是,就结果的准确性、与计算成本的比率而言,朴素方法处于领先地位。它在大多数实际时间序列数据集中表现出值得称道的准确性,同时保持了较低的计算成本。
相反,替代方式会受到计算复杂性问题的阻碍。此外,数据驱动的方法需要已标记步骤数据,这往往难以获得,这对其实际运用构成了严重的障碍。因此,在他们的实验中,ATFNet 方法的作者默认使用朴素方法来检测基础频率。
AFTNet 方法的原始可视化呈现如下。
2. 实现基本复数运算
在之前的文章中,我们曾讨论一点复数。它们便于用来描述频率特征的频谱。我们使用实部表示信号幅度,用虚部表示相移。然而,在从 DFT 收到复数形式的信号后,我们分别处理实部和虚部。然后,使用 iDFT,我们以这种方式把获得的频率特征转换到时域。
尽管把实部和虚部作为单独实体进行分析的方式实现起来很简单,然该方式并非最优。ATFNet 作者详细试验了处理复数的两种方式,并得出结论,将实部和虚部作为独立实体进行分析会导致信息丢失。因此,为了实现所提议方法,我们需要修改关注度模块来处理复数。
不幸的是,OpenCL 不支持复数。因此,我们必须自行实现复代数的基本运算。
如上所述,复数由实部和虚部组成:
其中 a 是实部,
b 是虚部,
i 是虚部。
为了在 OpenCL 端保存复数,便捷方法是用 2 个 float2 元素的向量。
复数的加法和减法几乎完全重复了 OpenCL 向量运算中实现的那些。因此,我们现在不再讨论它们。
但是复数的乘法稍微复杂一些。
为了实现该运算,我们将在 OpenCL 中创建 ComplexMul 函数。
float2 ComplexMul(const float2 a, const float2 b) { float2 result = 0; result.x = a.x * b.x - a.y * b.y; result.y = a.x * b.y + a.y * b.x; return result; }
该函数取两个 float2 向量作为参数,且返回结果也按相同格式。以这种方式,我们创建了一些东西,非常相似于配合复数变量正确运算。
复数除法的形式更复杂:
为了执行运算,我们创建了 ComplexDiv 函数。
float2 ComplexDiv(const float2 a, const float2 b) { float2 result = 0; float z = pow(b.x, 2) + pow(b.y, 2); if(z > 0) { result.x = (a.x * b.x + a.y * b.y) / z; result.y = (a.y * b.x - a.x * b.y) / z; } return result; }
复数的绝对值是一个实数,即表示频率分量的能量:
我们在 ComplexAbs 函数中实现这一点。
float ComplexAbs(float2 a) { return sqrt(pow(a.x, 2) + pow(a.y, 2)); }
提取复数平方根的公式稍微复杂一些:
为了实现它,我们来创建另一个函数 ComplexSqrt。
float2 ComplexSqrt(float2 a) { float2 result = 0; float z = ComplexAbs(a); result.x = sqrt((z + a.x) / 2); result.y = sqrt((z - a.x) / 2); if(a.y < 0) result.y *= (-1); //--- return result; }
在实现自关注算法时,我们调用 SoftMax 函数对依赖系数进行归一化。为了在复数域实现它,我们需要复数的指数:
在代码中,我们实现的函数如下:
float2 ComplexExp(float2 a) { float2 result = exp(clamp(a.x, -20.0f, 20.0f)); result.x *= cos(a.y); result.y *= sin(a.y); return result; }
3. 复数关注度层
我们的准备工作已筹备完毕,并实现了复数的基本数学运算。现在我们转进到下一步,其中我们将创建一个能运算复数的关注度神经层类:CNeuronComplexMLMHAttention。
我们创建的新类基于类似的针对实数值的关注度层 CNeuronMLMHAttentionOCL 类。这种方式的优点是,我们可以最大限度地重用已经存在、和预配置的顶层功能。故此,我们仅需在较低层重新定义方法,以便能够处理复数值。新类的结构如下所示。
class CNeuronComplexMLMHAttention : public CNeuronMLMHAttentionOCL { protected: virtual bool ConvolutionForward(CBufferFloat *weights, CBufferFloat *inputs, CBufferFloat *outputs, uint window, uint window_out, ENUM_ACTIVATION activ, uint step = 0); virtual bool AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = false); virtual bool AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out); virtual bool ConvolutuionUpdateWeights(CBufferFloat *weights, CBufferFloat *gradient, CBufferFloat *inputs, CBufferFloat *momentum1, CBufferFloat *momentum2, uint window, uint window_out, uint step = 0); virtual bool ConvolutionInputGradients(CBufferFloat *weights, CBufferFloat *gradient, CBufferFloat *inputs, CBufferFloat *inp_gradient, uint window, uint window_out, uint activ, uint shift_out = 0, uint step = 0); virtual bool AttentionInsideGradients(CBufferFloat *qkv, CBufferFloat *qkv_g, CBufferFloat *scores, CBufferFloat *gradient); virtual bool SumAndNormilize(CBufferFloat *tensor1, CBufferFloat *tensor2, CBufferFloat *out, int dimension, bool normilize = true, int shift_in1 = 0, int shift_in2 = 0, int shift_out = 0, float multiplyer = 0.5f); public: CNeuronComplexMLMHAttention(void) {}; ~CNeuronComplexMLMHAttention(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronComplexMLMHAttentionOCL; } };
值得注意的是新类的结构,它并未声明单个内部对象或变量。在实现功能的过程中,我们将用到继承的对象和变量。
甚而,类结构仅在受保护模块中声明了覆盖的方法。所有方法都已在先前的父类中声明。不过,没有高层的前馈和反向传播方法(feedForward、calcInputGradients 和 updateInputWeights),我们通常会在其中构建类的算法。这是因为我们完全保全了父类算法的动作顺序。不过,为了处理复数值,我们需要将数据缓冲区的大小加倍,因为要把复数值的虚部加到每个实数值之中。此外,我们需要在算法中实现复数运算。如您所知,我们在 OpenCL 端执行几乎所有的数学运算。因此,除了重新定义较低层的方法外,我们还必须对 OpenCL 程序内核进行修改。
3.1类初始化方法
每个类的工作都从其初始化开始。如上所述,我们不会在新类中声明任何嵌套对象或变量。这就是构造函数和析构函数为空的原因。继承对象的初始化在 Init 方法中执行。如常,在该方法的参数中,我们从调用者那里接收定义类架构的主要常量。如您所见,方法参数的结构与父类的类似方法相同。这并不令人惊讶。因为我们完全保留了基本的关注度算法、及父类的结构。不过,我们将彻底重写方法本身。
bool CNeuronComplexMLMHAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, 2 * window * units_count, optimization_type, batch)) return false;
在方法主体中,如常,我们调用父类的初始化方法。请注意以下两点:
- 我们并非直接调用父类的初始化方法 CNeuronMLMHAttentionOCL,而是基础神经层基类 CNeuronBaseOCL 的初始化方法。原因在于 CNeuronMLMHAttentionOCL 类的嵌套对象不需要初始化,因为我们必须重新定义所有缓冲区,提升其大小,如此才能存储复数。
- 调用父类方法时,我们将层的大小提升 2 倍。这是因为我们期待层运算结果要按复数值形式。
确保检查父类方法的运算逻辑结果。
在成功初始化继承自神经层基类的对象之后,我们保存所创建层架构的主要参数。
iWindow = window; iWindowKey = fmax(window_key, 1); iUnits = units_count; iHeads = fmax(heads, 1); iLayers = fmax(layers, 1);
在下一步中,我们计算所有正在创建的缓冲区大小。我们需要存储复数值。因此,与父类相比,所有缓冲区都提升了 2 倍。
uint num = 2 * 3 * iWindowKey * iHeads * iUnits; //Size of QKV tensor uint qkv_weights = 2 * 3 * (iWindow + 1) * iWindowKey * iHeads; //Size of weights' matrix of QKV tenzor uint scores = 2 * iUnits * iUnits * iHeads; //Size of Score tensor uint mh_out = 2 * iWindowKey * iHeads * iUnits; //Size of multi-heads self-attention uint out = 2 * iWindow * iUnits; //Size of our tensore uint w0 = 2 * (iWindowKey + 1) * iHeads * iWindow; //Size W0 tensor uint ff_1 = 2 * 4 * (iWindow + 1) * iWindow; //Size of weights' matrix 1-st feed forward layer uint ff_2 = 2 * (4 * iWindow + 1) * iWindow; //Size of weights' matrix 2-nd feed forward layer
然后我们根据所创建嵌套关注度层的数量来组织一个循环。在其主体中,我们初始化嵌套对象。
for(uint i = 0; i < iLayers; i++) { CBufferFloat *temp = NULL;
于此,我们首先创建一个迭代 2 次的嵌套循环,在其中我们初始化对象,以便记录前馈通验数据,和相应的误差梯度。
for(int d = 0; d < 2; d++) { //--- Initilize QKV tensor temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(num, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Tensors.Add(temp)) return false;
首先,我们创建一个 Query、Key 和 Value 实体的串联缓冲区。接下来,根据自关注度算法,我们需要一个缓冲区来写入依赖系数矩阵。
//--- Initialize scores temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(scores, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!S_Tensors.Add(temp)) return false;
以下缓冲区将存储多头关注度的结果:
//--- Initialize multi-heads attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(mh_out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!AO_Tensors.Add(temp)) return false;
然后,我们将大小降至输入级别。
//--- Initialize attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false;
关注度模块后面是 FeedForward 模块,由 2 个全连接层组成:
//--- Initialize Feed Forward 1 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(4 * out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false; //--- Initialize Feed Forward 2 if(i == iLayers - 1) { if(!FF_Tensors.Add(d == 0 ? Output : Gradient)) return false; continue; } temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false; }
我们已初始化了缓冲区来存储前馈通验结果、及相应的误差梯度。不过,为了执行这些运算,我们需要可学习的权重参数。首先,我们填充生成 Query、Key 和 Value 实体的可学习参数矩阵:
//--- Initilize QKV weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(qkv_weights)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < qkv_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
然后我们生成多头关注度降维层的参数:
//--- Initilize Weights0 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(w0)) return false; for(uint w = 0; w < w0; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
我们添加 FeedForward 模块参数:
//--- Initilize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_1)) return false; for(uint w = 0; w < ff_1; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; //--- temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_2)) return false; k = (float)(1 / sqrt(4 * iWindow + 1)); for(uint w = 0; w < ff_2; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
在模型参数训练期间,我们需要缓冲区来记录训练时刻。此类缓冲区的数量取决于学习方法所用参数。
for(int d = 0; d < (optimization == SGD ? 1 : 2); d++) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(qkv_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false; temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(w0, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; //--- Initilize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(ff_1, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(ff_2, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; } } //--- return true; }
确保检查该方法每次迭代的结果,因为在模型的训练和运算期间,必要的缓冲区即使缺少一个,也会导致严重错误。
所有嵌套对象初始化之后,我们终止该方法,并返回执行运算的逻辑值给调用者。
3.2前馈通验
类初始化之后,我们继续组织前馈通验。即使我们从父类继承了高层算法,我们仍然需要处理低层的前馈方法。我们将按照自关注算法的顺序安排操作。
层的输入数据首先转换为 Query、Key 和 Value 实体。为了在父类中生成它们,我们使用了卷积层的前向通验内核。在该实现中,我们将遵循相同的方式。此外,为了配合复数变量工作,我们需要在 OpenCL 程序端创建一个名为 FeedForwardComplexConv 的新内核。
在内核参数中,我们将传递 3 个数据缓冲区的指针:训练参数矩阵、输入数据、和写入结果的缓冲区。
__kernel void FeedForwardComplexConv(__global float2 *matrix_w, __global float2 *matrix_i, __global float2 *matrix_o, int inputs, int step, int window_in, int activation ) { size_t i = get_global_id(0); size_t out = get_global_id(1); size_t w_out = get_global_size(1);
注意,在主程序端,我们仍然使用 float 类型的数据缓冲区,但大小增加。在 OpenCL 程序端的内核中,我们为数据缓冲区指定类型 float2。这正是我们上面在创建复数变量函数时所用的数据类型。
在方法主体中,我们标识二维任务空间中的当前线程。第一个维度示意结果序列中的元素,第二个维度示意用到的过滤器。在我们的例子中,它将指示在实体串联向量中描述正在分析的序列的一个元素所在位置。
基于获得的数据,我们判定数据缓冲区中的偏移量:
int w_in = window_in; int shift_out = w_out * i; int shift_in = step * i; int shift = (w_in + 1) * out; int stop = (w_in <= (inputs - shift_in) ? w_in : (inputs - shift_in));
接下来,我们创建一个循环来计算向量的乘积:
float2 sum = matrix_w[shift + w_in]; for(int k = 0; k <= stop; k ++) sum += ComplexMul(matrix_i[shift_in + k], matrix_w[shift + k]);
注意,为了计算 2 个复数的乘积,我们调用上面创建的 ComplexMul 函数。我们使用基本向量运算来求和。
此外,由于我们已把数据缓冲区声明为 float2 向量类型,故我们能将它们作为正常浮点数据缓冲区访问,无需偏移调整。在每次运算中,都会从缓冲区中提取两个元素:复数的实部和虚部。
接下来,我们检查计算出的数值。在变量溢出的情况下,我们将其值更改为 0:
if(isnan(sum.x) || isnan(sum.y) || isinf(sum.x) || isinf(sum.y)) sum = (float2)0;
现在我们只需要计算激活函数,并将数值保存到结果缓冲区。
switch(activation) { case 0: sum = ComplexTanh(sum); break; case 1: sum = ComplexDiv((float2)(1, 0), (float2)(1, 0) + ComplexExp(-sum)); break; case 2: if(sum.x < 0) sum.x *= 0.01f; if(sum.y < 0) sum.y *= 0.01f; break; default: break; } matrix_o[out + shift_out] = sum; }
为了在主程序端调用上面创建的内核,我们将覆盖 CNeuronComplexMLMHAttention::ConvolutionForward 方法。注意,我们正在重写该方法,而非创建一个新的。因此,重要的是保留父类的类似方法的完整参数结构。只有覆盖该方法才允许我们从父类的顶层前馈通验方法调用该方法,而无需对其进行任何调整。
bool CNeuronComplexMLMHAttention::ConvolutionForward(CBufferFloat *weights, CBufferFloat *inputs, CBufferFloat *outputs, uint window, uint window_out, ENUM_ACTIVATION activ, uint step = 0) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(weights) == POINTER_INVALID || CheckPointer(inputs) == POINTER_INVALID || CheckPointer(outputs) == POINTER_INVALID) return false;
在方法主体中,我们首先检查接收到的指针与对象的相关性。然后我们检查 OpenCL 关联环境端是否有数据缓冲区。
if(weights.GetIndex() < 0) return false; if(inputs.GetIndex() < 0) return false; if(outputs.GetIndex() < 0) return false; if(step == 0) step = window;
控制模块成功通过之后,我们为内核定义任务空间,及在其中的偏移量:
uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = outputs.Total() / (2 * window_out); global_work_size[1] = window_out;
然后我们将所有必要的参数传递给内核,同时控制操作的执行:
if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardComplexConv, def_k_ffc_matrix_w, weights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardComplexConv, def_k_ffc_matrix_i, inputs.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardComplexConv, def_k_ffc_matrix_o, outputs.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardComplexConv, def_k_ffc_inputs, (int)(inputs.Total() / 2))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardComplexConv, def_k_ffc_step, (int)step)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardComplexConv, def_k_ffc_window_in, (int)window)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardComplexConv, def_k_ffс_window_out, (int)window_out)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardComplexConv, def_k_ffc_activation - 1, (int)activ)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
之后,我们将内核放入执行队列,并完成该方法:
if(!OpenCL.Execute(def_k_FeedForwardComplexConv, 2, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel %s: %s", __FUNCSIG__, error); return false; } //--- return true; }
将内核放入执行队列的算法非常统一。任务空间的大小、和变量的变化可能会有所不同。为了节省您的时间,并减少文章篇幅,我们不会赘述内核排队方法。它们的完整代码可在附件中找到。我们来详述构造这些内核的算法。
我们继续遵循自关注算法。定义 Query、Key 和 Value 实体之后,我们转贷定义依赖系数。为了定义这些,我们需要将 Query 矩阵乘以转置的 Key 矩阵。调用 SoftMax 函数对结果矩阵进行归一化。
所描述的功能在 ComplexMHAttentionScore 内核中执行,该内核将从 CNeuronComplexMLMHAttention::AttentionScore 方法调用。
__kernel void ComplexMHAttentionScore(__global float2 *qkv, __global float2 *score, int dimension, int mask ) { int q = get_global_id(0); int h = get_global_id(1); int units = get_global_size(0); int heads = get_global_size(1);
在参数中,内核接收指向 2 个数据缓冲区的指针。实体的串联缓冲区作为输入。以及写入结果的缓冲区。
指定的内核在二维任务空间中运行。第一个维度定义 Query 矩阵的一行,第二个维度定义一个激活关注度头。因此,正运行内核的每个单独实例都会执行运算,在 1 个关注度头内计算依赖系数矩阵的 1 行。
在内核主体中,我们在任务空间的两个维度中标识当前线程,并判定数据缓冲区的偏移量:
int shift_q = dimension * (h + 3 * q * heads); int shift_s = units * (h + q * heads);
然后我们定义数据归一化因子:
float2 koef = (float2)(sqrt((float)dimension), 0); if(koef.x < 1) koef.x = 1;
创建一个循环来计算依赖系数:
float2 sum = 0; for(int k = 0; k < units; k++) { if(mask > 0 && k > q) { score[shift_s + k] = (float2)0; continue; }
此处应当注意的是,所提议算法实现了数据掩码,其允许限制所谓的“前瞻”。该模型仅分析与先前令牌的依赖系数。对于后续令牌,依赖系数设置为 0,如此模型在训练期间不能接收“来自未来”的信息。该功能是由 mask 标志启用的,该标志在内核参数中传递。
接下来,在嵌套循环中,我们计算依赖向量的下一个元素时要将 2 个向量相乘。
float2 result = (float2)0; int shift_k = dimension * (h + heads * (3 * k + 1)); for(int i = 0; i < dimension; i++) result += ComplexMul(qkv[shift_q + i], qkv[shift_k + i]);
我们计算乘积结果的指数:
result = ComplexExp(ComplexDiv(result, koef));
有必要定义变量溢出:
if(isnan(result.x) || isnan(result.y) || isinf(result.x) || isinf(result.y)) result = (float2)0;
我们把结果写入结果缓冲区,并将其添加到总和中,以便进行后续的归一化。
score[shift_s + k] = result; sum += result; }
在内核的末尾,我们把依赖系数矩阵的已计算行归一化:
if(ComplexAbs(sum) > 0) for(int k = 0; k < units; k++) score[shift_s + k] = ComplexDiv(score[shift_s + k], sum); }
以这种方式获得的依赖系数的 Score 矩阵是为了计算关注度模块的结果。于此,我们需要将获得的系数矩阵乘以 Value 实体矩阵。这项工作在 ComplexMHAttentionOut 内核中完成。与上一个类似,这个内核工作在相同的 2-维任务空间。
__kernel void ComplexMHAttentionOut(__global float2 *scores, __global float2 *qkv, __global float2 *out, int dimension ) { int u = get_global_id(0); int units = get_global_size(0); int h = get_global_id(1); int heads = get_global_size(1);
在内核主体中,我们识别任务空间中的当前线程,并判定数据缓冲区的偏移量:
int shift_s = units * (h + heads * u); int shift_out = dimension * (h + heads * u);
之后,我们创建一个嵌套循环系统来执行数学运算,将 Value 矩阵乘以对应的依赖系数行:
for(int d = 0; d < dimension; d++) { float2 result = (float2)0; for(int v = 0; v < units; v++) { int shift_v = dimension * (h + heads * (3 * v + 2)) + d; result += ComplexMul(scores[shift_s + v], qkv[shift_v]); } out[shift_out + d] = result; } }
然后,多头关注度的结果被合并到一个张量之中,并在维度上降至输入数据张量的大小。然后我们执行 FeedForward 模块的操作。 这些操作使用上述 FeedForwardComplexConv 内核执行。我们针对执行前馈通验操作的内核算法的描述到此结束。您可在附件中查看所有内核的完整代码,以及调用它们的方法。
3.3实现反向传播通验
前馈通验功能已准备就绪。接下来,我们继续实现反向传播算法。该工作类似于上面前馈通验的行事。我们利用从父类继承的高层算法,并覆盖低层方法。
如上所述,我们不会考虑将内核放入执行队列的方法算法。它们都是一样的。我们更关注 OpenCL 程序端的内核算法分析。
前馈通验中最常用的是 FeedForwardComplexConv 内核。这是我们在不同阶段都用到的通用模块。自然地,我们精确地从内核开始构造反向传播算法,以便通过指定的模块传播误差梯度。我们在 CalcHiddenGradientComplexConv 内核中实现了该功能。
__kernel void CalcHiddenGradientComplexConv(__global float2 *matrix_w, __global float2 *matrix_g, __global float2 *matrix_o, __global float2 *matrix_ig, int outputs, int step, int window_in, int window_out, int activation, int shift_out ) { size_t i = get_global_id(0); size_t inputs = get_global_size(0);
内核根据输入数据缓存区中的元素数量,在一维任务空间中运行。给定内核的每个线程都会从所有元素中收集受所分析输入元素影响的误差梯度。
在内核主体中,我们识别当前线程,并判定数据缓冲区的偏移量。我们还声明了必要的局部变量:
float2 sum = (float2)0; float2 out = matrix_o[shift_out + i]; int start = i - window_in + step; start = max((start - start % step) / step, 0); int stop = (i + step - 1) / step; if(stop > (outputs / window_out)) stop = outputs / window_out;
之后,我们创建一个循环系统。在它们主体中,我们将收集总误差梯度,同时考虑到所分析元素对整体结果的影响:
for(int h = 0; h < window_out; h ++) { for(int k = start; k < stop; k++) { int shift_g = k * window_out + h; int shift_w = (stop - k - 1) * step + i % step + h * (window_in + 1); if(shift_g >= outputs || shift_w >= (window_in + 1) * window_out) break; sum += ComplexMul(matrix_g[shift_out + shift_g], matrix_w[shift_w]); } }
退出循环后,我们检查变量是否溢出:
if(isnan(sum.x) || isnan(sum.y) || isinf(sum.x) || isinf(sum.y)) sum = (float2)0;
我们还通过激活函数的导数来调整获得的误差梯度:
switch(activation) { case 0: sum = ComplexMul(sum, (float2)1.0f - ComplexMul(out, out)); break; case 1: sum = ComplexMul(sum, ComplexMul(out, (float2)1.0f - out)); break; case 2: if(out.x < 0.0f) sum.x *= 0.01f; if(out.y < 0.0f) sum.y *= 0.01f; break; default: break; } matrix_ig[i] = sum; }
我们将最终结果保存在上一层的误差梯度缓冲区之中。
接下来,我们研究通过关注度模块 ComplexMHAttentionGradients 传播误差梯度的内核。该内核呈现为一个相当复杂的算法,根据已判定误差梯度值的实体数量有条件地分成 3 个模块。
__kernel void ComplexMHAttentionGradients(__global float2 *qkv, __global float2 *qkv_g, __global float2 *scores, __global float2 *gradient) { size_t u = get_global_id(0); size_t h = get_global_id(1); size_t d = get_global_id(2); size_t units = get_global_size(0); size_t heads = get_global_size(1); size_t dimension = get_global_size(2);
在这个内核里执行了很多操作。为了减少整体执行时间,在模型训练期间,我们尝试并行执行与单个变量相关的数值计算。为了令线程识别更透明和直观,我们为该内核创建了一个 3-维任务空间。与关注度模块前馈通验方法一样,此处我们使用序列元素和关注度头。任务空间的第三个维度用于在描述序列元素的张量中元素定位。因此,尽管有大量的运算,但每个单独的线程仅将 3 个值写入结果缓冲区。在本例中,它是 Query、Key 和 Value 实体的误差梯度的串联缓冲区。
在内核主体中,我们首先识别任务空间中的线程,并判定数据缓冲区的偏移量:
float2 koef = (float2)(sqrt((float)dimension), 0); if(koef.x < 1) koef.x = 1; //--- init const int shift_q = dimension * (heads * 3 * u + h); const int shift_k = dimension * (heads * (3 * u + 1) + h); const int shift_v = dimension * (heads * (3 * u + 2) + h); const int shift_g = dimension * (heads * u + h); int shift_score = h * units; int step_score = units * heads;
然后我们判定 Value 矩阵的所分析元素的误差梯度。我们将关注度模块输出处的误差梯度张量的单独列,乘以依赖系数矩阵的相应列:
//--- Calculating Value's gradients float2 sum = (float2)0; for(int i = 0; i < units; i++) sum += ComplexMul(gradient[(h + i * heads) * dimension + d], scores[shift_score + u + i * step_score]); qkv_g[shift_v + d] = sum;
在下一步中,我们判定 Query 实体的所分析元素的误差梯度。该实体对结果没有直接影响。只有经由依赖系数矩阵的间接影响。因此,我们首先需要找到依赖系数矩阵中对应行的误差梯度。调用 SoftMax 函数进行数据归一化,令该过程进一步复杂化。
//--- Calculating Query's gradients shift_score = h * units + u * step_score; float2 grad = 0; float2 grad_out = gradient[shift_g + d]; for(int k = 0; k < units; k++) { float2 sc_g = (float2)0; float2 sc = scores[shift_score + k]; for(int v = 0; v < units; v++) sc_g += ComplexMul( ComplexMul(scores[shift_score + v], ComplexMul(qkv[dimension * (heads * (3 * v + 2) + h)], grad_out)), ((float2)(k == v, 0) - sc) );
为单个依赖系数找到的误差梯度乘以 Key 实体矩阵的相应元素。将结果值相加,从而累积总误差梯度:
grad += ComplexMul(ComplexDiv(sc_g, koef), qkv[dimension * (heads * (3 * k + 1) + h) + d]); }
我们将累积的误差梯度写入结果缓冲区:
qkv_g[shift_q + d] = grad;
类似地,我们定义了 Key 实体元素的误差梯度,其也经由依赖系数矩阵对结果产生间接影响。不过,这次我们配合指定矩阵的列工作:
//--- Calculating Key's gradients grad = 0; for(int q = 0; q < units; q++) { shift_score = h * units + q * step_score; float2 sc_g = (float2)0; float2 sc = scores[shift_score + u]; float2 grad_out = gradient[dimension * (heads * q + h) + d]; for(int v = 0; v < units; v++) sc_g += ComplexMul( ComplexMul(scores[shift_score + v], ComplexMul(qkv[dimension * (heads * (3 * v + 2) + h)], grad_out)), ((float2)(u == v, 0) - sc) ); grad += ComplexMul(ComplexDiv(sc_g, koef), qkv[dimension * (heads * 3 * q + h) + d]); } qkv_g[shift_k + d] = grad; }
我们针对复数关注度层功能中的反向传播通验内核构造算法的讨论到此结束。所呈现类的所有方法和内核的完整代码均可在附件中找到。
结束语
在本文中,我们讨论了构造 ATFNet 方法的理论层面,其结合了在频域和时域中预测时间序列的方法。
在本文的实践部分,我们做了很多使用数运算构造关注度层的相关工作。然而,这只是所提议方法的 F-模块的一个对象。在下一篇文章中,我们将继续构建 ATFNet 方法的算法。我们还将看到其搭配真实数据的操作结果。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/14996


