
神经网络变得轻松(第三十七部分):分散关注度
概述
在上一篇文章中,我们讨论了在其架构中使用关注度机制的关系模型。 我们用此模型创建了一个智能系统,成果 EA 展现出良好的结果。 然而,我们注意到,与我们前的实验相比,该模型的学习率较低。 这是因为模型中所用的变换器模块是一个相当复杂的架构解决方案,需执行大量操作。 随着分析序列大小的增加,这些操作的数量以二次级数飙涨,导致内存消耗和模型训练时间增加。
不过,我们认识到能用于改进模型的资源有限。 故此,需要牺牲最小的品质来优化模型。
1. 分散关注度
当我们谈论优化模型的性能时,我们首先需要注意它的超参数。 考虑到资源消耗和模型品质,此类参数集应该是最优的。 在某个阈值后增加层中的神经元数量,实际上不会导致模型品质的提升。 神经层的数量也是如此。 不过,最优超参数集取决于特定任务及其复杂度。
所有这些都适用于多观察者自我关注度模块中的关注者数量。 有时两名关注者就足以获得良好的结果,但这并不是所有问题的最优值。 所有超参数,必须依据每个特定任务和模型架构经实验筛选。
本文讨论的体系结构方式,出于减少自我关注度模块中操作数量。 不过,在迈入优化算法之前,重要的是要记住自我关注度模块的工作原理。
首先,它计算三个实体:序列中每个元素的查询(Query)、键(Key)和值(Value)。 为此目的,描述序列元素的向量乘以相应的权重矩阵。 然后,我们将 Query 矩阵乘以转置的 Key 矩阵,得到序列元素之间的依赖系数。 然后使用 SoftMax 函数对这些系数进行常规化。
依赖系数常规化之后,我们将它们乘以 Value 实体矩阵,从而得到序列中每个元素的输出值。 这些输出值是元素值的加权总和,并考虑了每个元素在问题上下文中的重要性。
序列元素数量的增加导致使用关注度机制的算法中计算操作复杂性增加。 这是因为在每个阶段,需针对序列的每个元素执行实体计算、矩阵乘法和依赖系数常规化的操作。
如果序列包含的元素太多,这可能会导致计算时间和计算资源成本激增。 为了优化算法,并减少每个阶段的计算次数,我们可以运用各种方法,其一是分散关注度。 该方法由 Rewon Child 在 2019 年 4 月发表的文章《使用分散变换器生成长序列》中提出。
分散关注度是一种优化关注度机制,从而减少处理序列元素所需的计算量的技术。
该方法的思路是在计算序列元素之间的关注度系数时,仅参考序列中最重要的元素。 因此,取代计算序列中所有元素对的关注度系数,我们只需选择最重要的元素对。
分散关注度方法的优点之一,是它可以明显减少处理序列元素所需的计算量。 这在处理计算量非常庞大的大型序列时尤其重要。
此外,分散关注度可有助于解决“关注一切”问题,当关注度机制将注意力均匀地分配给序列的所有元素时,这会导致资源的低效利用,并减慢算法的速度。
实现分散关注度时可以采用多种方式。 一种是将序列分解为区块,并仅计算每个区块内的元素之间、以及不同区块的元素之间的关注度。 在这种情况下,只需考虑距离最近的元素,从而减少计算次数。
另一种方式是根据元素的相似性,选择序列中最重要的元素。 这可以通过运用不同的聚类分析方法来完成。
第三种方式则是运用一些启发式和算法来选择序列中最重要的元素,例如基于它们的频率、重要性或上下文。
作者指出,为了令分散关注度有效地工作,有必要采用一种将序列元素分布到区块中的算法,该算法为每位关注者提供不同的区块结构。 这种方式将令您能够更全面地判定序列中每个元素的影响度,并提高算法的效率。
分散关注度可应用于机器学习和自然语言处理的各个领域,包括机器翻译、文本生成、情感分析、及更多。 在上面提及的文章中,该方法的作者介绍了文本、图像和录音的算法应用结果。
此外,稀分散关注度可以与其它关注度引擎优化技术有效结合,在处理序列时获得更准确的结果。
尽管它有效,但分散关注度方法也有其缺点。 其一是序列中最重要的元素的选择可能不正确,这可能导致信息丢失。 因此,有必要为每个特定任务选择相应的的方法,并仔细调整算法参数。
我相信分散关注度方法对于解决金融市场分析相关的问题很实用。 在分析金融品种报价的历史时,我们通常需要在可参考深度分析数据,但往往只有历史当中的单个元素会影响当前情况。 在选择重要数据区块进行研究时,使用分散关注度方法将减少计算资源量。 该方法还有助于剔除未来操作中无关紧要的因素,从而提高金融市场分析的效率。
然而,金融市场报价具有可变结构,因此我们无法在分析序列中操控固定的元素区块。 为了加快模型学习过程,我们可以采用启发式 “80/20” 帕累托(Pareto)规则,其中我们仅从整个序列中获取 20% 最重要元素。 元素的重要性是根据元素之间的依赖系数判定的,这些系数由前面讲述的两个公式计算。 在第一次迭代之后,在数据常规化之前,可以准确地识别序列中最重要的元素,然后从进一步的操作中排除其余的元素。 这减少了常规化阶段的操作次数,并确定了自我关注度模块的结果。
由于每个关注者都用自己唯一的矩阵来判定查询和键,因此每个关注者所选取的元素可能会有所不同。
现在我们已经确定了优化算法的主要方向,我们可以迈入以 MQL5 语言实现该算法。
2. 利用 MQL5 实现
为了实现所提出的方法,我们将创建一个新的神经层类 CNeuronMLMHSparseAttention。 当然,我们不会重新创建所有类方法。 取而代之,我们将继承现有的 CNeuronMLMHAttentionOCL 类。 在此,我们分析一下需要修改哪些类方法和 OpenCL 程序内核,以便实现提议的优化。
如前所述,我们对算法的第一处修改涉及判定依赖系数的模块。 这些值是在 MHAttentionScore 内核中直接验算期间获得的。 对于我们的实现,我们将用 MHSparseAttentionScore 替换指定的内核。
在父类的内核参数中,我们将指针传递给 2 个数据缓冲区:Query、Key 和 Value 实体的级联张量作为源数据,以及保存以依赖系数形式的操作结果的缓冲区。 除了数据缓冲区之外,内部实体的维度也要传递给内核。 现在我们将添加分散系数 “sparse”。 我们将传递一个从 0 到 1 范围内的值,它指示对分析元素影响最大的选定序列元素的比例。
__kernel void MHSparseAttentionScore(__global float *qkv, ///<[in] Matrix of Querys, Keys, Values __global float *score, ///<[out] Matrix of Scores int dimension, ///< Dimension of Key float sparse ///< less than 1.0 coefficient of sparse ) { int q = get_global_id(0); int h = get_global_id(1); int units = get_global_size(0); int heads = get_global_size(1); //---
这个新内核,就像父类的内核一样,将在二维任务空间中运转。 第一个维度将指示正在分析的序列元素的序号,第二个维度将对应于所使用的关注者。 在内核主体中,我们立即把正在运行的线程的全局标识符保存到局部变量之中。
接下来,我们将做一些准备工作,其中我们将声明必要的局部变量,并判定正在分析的元素在数据缓冲区中对应的偏移量。
int shift_q = dimension * (h + 3 * q * heads); int shift_s = units * (h + q * heads); int active_units = (int)max((float)(units * sparse), min((float)units, 3.0f)); //--- float koef = sqrt((float)dimension); if(koef < 1) koef = 1; float sum = 0.0f; float min_s = 0.0f; float max_s = 0.0f;
我们还判定所选元素的绝对值。 请注意,在判定要选择的重要序列元素的数量时,我设置了一个限制:不能少于三个元素。 若在使用小序列时,这将有助于我们避免不必要的关注度模块禁用。 我们知道,最大依赖系数几乎总是据所分析元素其自身的键生成。
接下来,我们实现一个循环,在其中,我们将所分析元素的 Query 向量乘以 Key 矩阵。 在循环体中,我们还将判定结果向量的最大值和最小值。
for(int k = 0; k < units; k++) { float result = 0; int shift_k = dimension * (h + heads * (3 * k + 1)); for(int i = 0; i < dimension; i++) { if((dimension - i) > 4) { result += dot((float4)(qkv[shift_q + i], qkv[shift_q + i + 1], qkv[shift_q + i + 2], qkv[shift_q + i + 3]), (float4)(qkv[shift_k + i], qkv[shift_k + i + 1], qkv[shift_k + i + 2], qkv[shift_k + i + 3])); i += 3; } else result += (qkv[shift_q + i] * qkv[shift_k + i]); } score[shift_s + k] = result; if(k == 0) min_s = max_s = result; else { max_s = max(max_s, result); min_s = min(min_s, result); } }
为了保护所获数值与序列相应元素之间的依赖关系,我们不会为了选择最重要的元素而进行向量排序。 取而代之,我们将迭代增加依赖系数重要性范围的下限,直到我们获得序列中所需数量的“重要”元素。 此功能将在以下循环中实现。
int count = units; float temp = max_s; while(count > active_units) { count = 0; for(int k = 0; k < units; k++) { float value = score[shift_s + k]; if(value < min_s) continue; count++; if(value < temp && value > min_s) temp = value; } if(count > active_units) min_s = temp; }
判定重要性范围后,我们迈入下一步,即数据常规化,它由两个步骤组成。 在第一步中,我们计算在前一步中得到的依赖级别的指数值。 接下来,我们将这些数值除以总数。 但我们应该记住我们已定义的重要性范围。 如此,我们将此范围之外元素的依赖系数清零,从而将它们排除在将来的操作之外。 这适用于指数计算和常规化两个步骤。
if(max_s == 0.0f) max_s = 1.0f; for(int k = 0; k < units; k++) { float value = score[shift_s + k]; if(value < min_s) { score[shift_s + k] = 0.0f; continue; } value = exp(value / max_s / koef); score[shift_s + k] = value; sum += value; } for(int k = 0; (k < units && sum > 1); k++) { temp = score[shift_s + k]; if(temp == 0.0f) continue; score[shift_s + k] = temp / sum; } }
作为指定内核的操作结果,我们仅从所分析序列中得到少量非零依赖系数的选定元素,供我们进一步处理。 我们还从进一步的正向验证和后向验算中排除了依赖系数为零的序列元素。
下一步是获取关注度模块输出。 为此,根据自我关注度算法,我们需要将常规化依赖系数的 “Score” 矩阵乘以实体的 “Value” 矩阵。 此操作在 MHSparseAttentionOut 内核中实现。 在此内核中,我们还检查零依赖系数,从而减少需执行的操作次数。
在内核参数中传递指向 3 个数据缓冲区的指针。 Query、Key 和 Value 实体的级联张量,以及依赖系数的 “Score” 矩阵是即将执行操作的源数据。 操作结果则会被写入 Out 缓冲区。 序列中一个元素的 Key 向量的维度也在参数中传递。 正如我们已经看到的,我们对多关注者类中的内部实体 Query、Key 和 Value 采用相同维度的向量。
__kernel void MHSparseAttentionOut(__global float *scores, ///<[in] Matrix of Scores __global float *qkv, ///<[in] Matrix of Values __global float *out, ///<[out] Output tensor int dimension ///< Dimension of Value ) { 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++) { float result = 0; for(int v = 0; v < units; v ++) { float cur_score = scores[shift_s + v]; if(cur_score == 0) continue; int shift_v = dimension * (h + heads * (3 * v + 2)) + d; result += cur_score * qkv[shift_v]; } out[shift_out + d] = result; } }
我们的新类内核前向验算的工作至此结束。 现在我们来看一下向后验算部分的修改范围。
Self-Attention 模块的向后验算是在 MHAttentionInsideGradients 内核中实现的。 该算法允许您沿现有内核添加必要的控制点,而无需创建它的副本。 我建议查看构建的算法和添加到其中的控制点。
在内核参数中,我们将传递 5 个数据缓冲区的指针:
- Query、Key 和 Value 实体(qkv)的级联张量
- 用于写入 Query、Key 和 Value 实体误差梯度的级联张量(qkv_g)
- 依赖系数(scores)矩阵
- 保存依赖系数级别矩阵(scores_g)对应误差梯度的矩阵
- 当前关注者模块输出级别的误差梯度张量。
__kernel void MHAttentionInsideGradients(__global float *qkv, __global float *qkv_g, __global float *scores, __global float *scores_g, __global float *gradient, int dimension) { int u = get_global_id(0); int h = get_global_id(1); int units = get_global_size(0); int heads = get_global_size(1); float koef = sqrt((float)dimension); if(koef < 1) koef = 1;
我们将在问题的二维空间中调用误差梯度分布内核,就像之前研究过的那些个一样。 一个维度将标识正在分析的序列元素。 第二个维度将指示当前的关注者。 正是这些标识符将帮助我们判定数据缓冲区中距所需元素的偏移量。 因此,在内核开始处,我们会把这些线程标识符保存到局部变量之中。
进而,内核算法有条件地分为两个区块。 在第一个当中,我们在依赖系数矩阵的级别上定义误差梯度。 此处,我们实现了一个循环,收集梯度序列中被分析元素的依赖系数向量。 由于序列中依赖系数为零的无用元素不会影响最终结果,因此它们的误差梯度应为零。 故此,在循环体中,我们首先检查当前的依赖系数。 当检测到空值时,我们简单地转到下一个元素。
重点要注意的是,访问存储所有数据缓冲区元素的全局内存是一项相对昂贵的操作。 在我们的例子中,序列系数级别矩阵的误差梯度向量是临时存储的,不会在其它内核里用到。 我们甚至不会把 null 值写入其中,因为它只是一个不必要的操作,没有多大用处。
//--- Calculating score's gradients uint shift_s = units * (h + u * heads); for(int v = 0; v < units; v++) { float s = scores[shift_s + v]; if(s <= 0) continue; float sg = 0; int shift_v = dimension * (h + heads * (3 * v + 2)); int shift_g = dimension * (h + heads * v); for(int d = 0; d < dimension; d++) sg += qkv[shift_v + d] * gradient[shift_g + d]; scores_g[shift_s + v] = sg * (s < 1 ? s * (1 - s) : 1) / koef; } barrier(CLK_GLOBAL_MEM_FENCE);
在下一步中,我们将误差梯度分布到内部 Query、Key 和 Value 实体。 我们首先判定数据缓冲区中的偏移量,然后创建一个循环系统来收集误差梯度。
于此,在一个嵌套循环内,我们检查依赖系数,如果我们找到一个空值,我们只需移到下一个元素。 这剔除了不必要的操作。
//--- Calculating gradients for Query, Key and Value uint shift_qg = dimension * (h + 3 * u * heads); uint shift_kg = dimension * (h + (3 * u + 1) * heads); uint shift_vg = dimension * (h + (3 * u + 2) * heads); for(int d = 0; d < dimension; d++) { float vg = 0; float qg = 0; float kg = 0; for(int l = 0; l < units; l++) { float sg = scores[shift_s + l]; if(sg <= 0) continue; uint shift_q = dimension * (h + 3 * l * heads) + d; uint shift_k = dimension * (h + (3 * l + 1) * heads) + d; uint shift_g = dimension * (h + heads * l) + d; //--- vg += gradient[shift_g] * sg; sg = scores_g[shift_s + l]; kg += sg * qkv[shift_q]; qg += sg * qkv[shift_k]; } qkv_g[shift_qg + d] = qg; qkv_g[shift_kg + d] = kg; qkv_g[shift_vg + d] = vg; } }
在这个内核的所有迭代完成后,我们在 Query、Key 和 Value 实体级别获得误差梯度,然后将其分发到相应的权重矩阵,和前一个神经层。
如此一来,我们完成了 OpenCL 程序内核的工作,并迈入处理主程序的代码。 我们已添加了两个内核。 因此,我们需要在主程序中添加内核调用。 首先,我们创建用于访问内核的常量。
请注意,我们正在创建操控两个内核的常量,和仅有的一个参数常量。 我们已基于现有内核创建了内核,并且几乎完全重复了基本内核参数的结构。 因此,在内核的操作过程中,我们可以使用现有的常量。 我们只创建一个常量来指示分散参数。
#define def_k_MHSparseAttentionScore 44 ///< Index of the kernel of the multi-heads sparse attention neuron // to calculate score matrix (#MHSparseAttentionScore) #define def_k_mhas_sparse 3 ///< less than 1.0 coefficient of sparse //--- #define def_k_MHSparseAttentionOut 45 ///< Index of the kernel of the multi-heads sparse attention neuron // to calculate multi-heads out matrix (#MHSparseAttentionOut)
接下来,我们需要在 OpenCL 关联环境中实现内核的创建。 我们需要将关联环境中活动内核的总数增加到 46 个,并调用内核创建方法。
opencl.SetKernelsCount(46); if(!opencl.KernelCreate(def_k_MHSparseAttentionScore, "MHSparseAttentionScore")) { PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__); return false; } if(!opencl.KernelCreate(def_k_MHSparseAttentionOut, "MHSparseAttentionOut")) { PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__); return false; }
请注意,我们必须在 CNet 神经网络类的三种调度方法中重复上述操作,以便在 OpenCL 关联环境中创建内核。 这不是很方便。 如此,在将来,我计划将这些操作移到单独的方法当中。
bool Create(CArrayObj *Description); bool Load(string file_name, float &error, float &undefine, float &forecast, datetime &time, bool common = true); ///< Load method. @param[in] file_name File name to save @param[out] error Average error ///< @param[out] undefine Undefined percent @param[out] Forecast percent ///< @param[out] time Last study time @param[in] common Common flag virtual bool Load(const int file_handle);
在我们的下一步工作中,我们将直接移到创建新类的方法。 我们的新神经网络类 CNeuronMLMHSparseAttention 的功能在很大程度上重复了父类 CNeuronMLMHAttentionOCL的功能。 因此,我们将尝试使用继承的方法。 主要区别与分散关注度的创建有关。 在这一部分中,我们将创建一个新的内部变量 m_dSparse 来存储分散度级别。
为了不因重写非必要方法而令工作复杂化,我将类构造函数和析构函数留空。 我们不会在新类中创建新对象,并且依据分散参数操作,我们将创建重载的 Sparse 方法。 重载方法的能力允许您将同名的方法用于不同的功能:依据参数中的值,将参数值传递给方法;如果未指定参数,该方法将返回以前保存的值。
class CNeuronMLMHSparseAttention : public CNeuronMLMHAttentionOCL { protected: float m_dSparse; //--- virtual bool AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true); ///< \brief Multi-heads attention scores method of calling kernel ::MHAttentionScore(). virtual bool AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out); ///< \brief Multi-heads attention out method of calling kernel ::MHAttentionOut(). public: CNeuronMLMHSparseAttention(void) : m_dSparse(0.3f) {}; ~CNeuronMLMHSparseAttention(void) {}; //--- void Sparse(float value) { m_dSparse = value;} float Sparse(void) { return m_dSparse; } virtual int Type(void) const { return defNeuronMLMHSparseAttentionOCL; } ///< Identificatory of class.@return Type of class //--- methods for working with files virtual bool Save(int const file_handle); ///< Save method @param[in] file_handle handle of file @return logical result of operation virtual bool Load(int const file_handle); ///< Load method @param[in] file_handle handle of file @return logical result of operation };
不要忘记重写 Type 对象的虚拟标识方法。
至于公开方法,我们还应该重写操控文件的方法:Save 和 Load。 这些方法的算法非常简单。 在这些方法中,我们首先调用父类同名方法,其中已经定义了所有控制点,并实现了保存和加载继承变量和对象的算法。 我们只需要检查执行所调用方法的逻辑结果。 成功执行父类方法后,我们保存或读取分散参数的值,具体取决于运行方法的功能。
bool CNeuronMLMHSparseAttention::Save(const int file_handle) { if(!CNeuronMLMHAttentionOCL::Save(file_handle)) return false; if(FileWriteFloat(file_handle, m_dSparse) < sizeof(float)) return false; //--- return true; }
我们已经研究完毕新类操作的公开方法。 但该类的主要功能是创建神经层算法。 因此,我们回到前馈和反向传播验算。 我们针对 OpenCL 程序内核进行了与时俱进的改造,从而启用此功能。
在讲述神经网络功能时,我将稍微偏离我们用来讨论方法的通常结构。 这次我不会从前向验算开始,而是从后向开始。 我们尚未为反向传播验算创建新的内核。 我们仅修改了父类中用到的现有内核。 通过继承父类的功能,我们还继承了调用上面讨论的 MHAttentionInsideGradients 内核的算法。 这意味着现在我们可以简单地使用父类的 calcInputGradients 后向验算方法来验算误差梯度。 至于与更新训练参数相关的功能,我们没有做任何更改,也可以调用父类方法 updateInputWeights。
我们移到前馈方法。 在构造父类的前馈算法时,我们没有将整个分支算法组合在一个方法的主体当中。 取而代之,我们创建了一个结构化调度方法 feedForward,其中我们根据自我关注度算法按顺序调用执行单个功能的方法。 鸣谢这种方式,现在我们不需要完全重写前馈方法。 我们只需要重新定义调用两个新内核的方法。 这些方法是 AttentionScore 和 AttentionOut。
bool CNeuronMLMHAttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false; //--- for(uint i = 0; (i < iLayers && !IsStopped()); i++) { //--- Calculate Queries, Keys, Values CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4)); CBufferFloat *qkv = QKV_Tensors.At(i * 2); if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, qkv, iWindow, 3 * iWindowKey * iHeads, None)) return false; //--- Score calculation CBufferFloat *temp = S_Tensors.At(i * 2); if(IsStopped() || !AttentionScore(qkv, temp, true)) return false; //--- Multi-heads attention calculation CBufferFloat *out = AO_Tensors.At(i * 2); if(IsStopped() || !AttentionOut(qkv, temp, out)) return false; //--- Attention out calculation temp = FF_Tensors.At(i * 6); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out, temp, iWindowKey * iHeads, iWindow, None)) return false; //--- Sum and normilize attention if(IsStopped() || !SumAndNormilize(temp, inputs, temp)) return false; //--- Feed Forward inputs = temp; temp = FF_Tensors.At(i * 6 + 1); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors.At(i * 6 + 2); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), temp, out, 4 * iWindow, iWindow, activation)) return false; //--- Sum and normilize out if(IsStopped() || !SumAndNormilize(out, inputs, out)) return false; } //--- return true; }
为了保留继承规则,这两个方法均接收类似于父类方法的参数。 这一点极端重要,因为更改了方法参数就需要创建重载方法。 但我们需要覆盖父类的方法。 在方法重载中,当调用方法时,系统根据指定的参数选择其中一个,而在方法重写中,系统遵循继承层次结构,并使用最后一个重写的方法。 故此,只有当我们设置为覆盖方法时,当从继承的 feedForward 方法调用时,系统才会访问我们类的重写方法。
AttentionScore 方法从其参数中接收指向两个缓冲区对象的指针:Query、Key、Value 实体的级联张量和依赖系数矩阵。 此外,在方法参数中还要传递掩码标志。 我们不使用此标志;由于上述原因,将其保留在参数中。
在方法主体中,我们立即检查收到的指针是否相关。 我们还检查操控 OpenCL 关联环境的对象相关性。 除了对象指针本身之外,我们还检查 OpenCL 关联环境中是否存在已创建的数据缓冲区。 只有在成功传递所有指定的控制点后,我们才能继续组织将内核放入执行队列的过程。
我们创建的所有内核都计划用于二维问题空间。 现在我们需要创建描述 global_work_size 任务空间和 global_work_offset 任务空间偏移量的数组。 两个数组的大小必须与问题空间匹配。 为了创建一个二维问题空间,我们创建了两个数组,每个数组有 2 个元素。
在第一个数组中的元素,我们指示所分析序列的元素总数和关注者的数量。 元素在数组中的位置表示维度。 其值代表线程标号。 因此,序列的每个元素会针对每个关注者接收自己单独的线程,来执行操作。 通常,针对序列所有元素的操作将在并发线程中同时执行(在技术上尽可能)。
我们将用零值填充第二个数组的元素,因为我们不需要任务空间中的偏移量。
bool CNeuronMLMHSparseAttention::AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || CheckPointer(scores) == POINTER_INVALID) return false; //--- if(qkv.GetIndex() < 0) return false; if(scores.GetIndex() < 0) return false; //--- uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = iUnits; global_work_size[1] = iHeads; OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_qkv, qkv.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_score, scores.GetIndex()); OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_dimension, (int)iWindowKey); OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_sparse, (float)m_dSparse); if(!OpenCL.Execute(def_k_MHSparseAttentionScore, 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; }
下一步是将参数传递给内核。 我们调用 SetArgumentBuffer 和 SetArgument 方法来做到这一点。 第一个将指针传递给数据缓冲区。 第二个则用于传输离散值。 在方法参数中,我们指示内核标识符、要传递的参数的序列号(对应于 OpenCL 程序中以 0 开头的内核参数序列),和所传递的值。
在此,您应该注意传递的数值类型,和内核中指定的参数类型。 如果类型不匹配,您也许会收到内核执行错误。
一旦准备工作完成,我们调用 Execute 方法将内核发送到执行队列。 在方法参数中,我们指示内核标识符、任务空间的维度、以及之前创建的任务空间描述数组。
我们还检查内核队列方法的执行结果。 如果排队的内核时发生错误,请求有关错误的信息,并将其显示在终端日志当中。
如果内核已成功添加到执行队列中,则方法完成后以 true 作为结果。
在 AttentionOut 方法中重复类似的算法,调用第二个内核。
bool CNeuronMLMHSparseAttention::AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || CheckPointer(scores) == POINTER_INVALID || CheckPointer(out) == POINTER_INVALID) return false; uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = iUnits; global_work_size[1] = iHeads; if(qkv.GetIndex() < 0) return false; if(scores.GetIndex() < 0) return false; if(out.GetIndex() < 0) return false; //--- OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_qkv, qkv.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_score, scores.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_out, out.GetIndex()); OpenCL.SetArgument(def_k_MHSparseAttentionOut, def_k_mhao_dimension, (int)iWindowKey); if(!OpenCL.Execute(def_k_MHSparseAttentionOut, 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; }
我们针对新神经网络类的工作到此完毕。 但还有留有一点。 我们需要将新类的处理添加到正在实现的模型操作的调度方法之中。
首先,我们在 CNet::Create 方法中添加一个模块,创建一个新类型的神经层。
case defNeuronMLMHSparseAttentionOCL: neuron_sparseattention = new CNeuronMLMHSparseAttention(); if(CheckPointer(neuron_sparseattention) == POINTER_INVALID) { delete temp; return false; } if(!neuron_sparseattention.Init(outputs, 0, opencl, desc.window, desc.window_out, desc.step, desc.count, desc.layers, desc.optimization, desc.batch)) { delete neuron_sparseattention; delete temp; return false; } neuron_sparseattention.SetActivationFunction(desc.activation); neuron_sparseattention.Sparse(desc.probability); if(!temp.Add(neuron_sparseattention)) { delete neuron_mlattention_ocl; delete temp; return false; } neuron_sparseattention = NULL; break;
将新的层类型添加到 CLayer::CreateElement 方法。
case defNeuronMLMHSparseAttentionOCL: if(CheckPointer(OpenCL) == POINTER_INVALID) return false; temp_mlat_ocl = new CNeuronMLMHSparseAttention(); if(CheckPointer(temp_mlat_ocl) == POINTER_INVALID) result = false; if(temp_mlat_ocl.Init(iOutputs, index, OpenCL, 1, 1, 1, 1, 0, ADAM, 1)) { m_data[index] = temp_mlat_ocl; return true; } break;
此外,将新类型添加到神经网络基类的前馈调度方法之中。
bool CNeuronBaseOCL::FeedForward(CObject *SourceObject) { if(CheckPointer(SourceObject) == POINTER_INVALID) return false; //--- CNeuronBaseOCL *temp = NULL; switch(SourceObject.Type()) { case defNeuronBaseOCL: case defNeuronProofOCL: case defNeuronConvOCL: case defNeuronAttentionOCL: case defNeuronMHAttentionOCL: case defNeuronMLMHAttentionOCL: case defNeuronMLMHSparseAttentionOCL: case defNeuronDropoutOCL: case defNeuronBatchNormOCL: case defNeuronVAEOCL: case defNeuronLSTMOCL: case defNeuronSoftMaxOCL: temp = SourceObject; return feedForward(temp); break; } //--- return false; }
在相关的反向传播方法 CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject) 中重复该操作。
case defNeuronMLMHAttentionOCL: case defNeuronMLMHSparseAttentionOCL: mlat = TargetObject; if(!bTrain && !mlat.TrainMode()) return true; temp = GetPointer(this); return mlat.calcInputGradients(temp);
附件中提供了所有类及其方法的完整代码。
3. 测试
完成新神经层类的工作后,我们可以继续在 MetaTrader 5 平台的交易策略测试器中测试已构建的算法。 交易策略测试器允许基于历史数据测试智能系统和指标。 为了测试已构建算法的操作,我们将创建一个小型交易 EA,它将在传递历史数据的过程中直接训练模型。 在测试之前讨论的算法时,我们已经创建了类似的 EA。 这一次,我们将采用来自上一篇文章中的 EA 作为基础。 在这个 EA 中,我们将 EA 模型架构中的多个关注者神经层替换为新创建的分散关注度层。
在上一篇文章中,我们测试了一个关系强化学习模型,该模型使用了一个完全参数化的分位数函数算法,其内使用一个好奇心模块。 为了实现这样的模型,我们创建了 3 个模型的组合:模型、前向和逆向。 我们在第一个模型中用到了关注度模块。 如此,我们将修改此模块。 其它两个模型的架构保持不变。
模型的体系结构在 CreateDescriptions 函数中描述。 为了简化模型,我决定删除递归 LSTM 模块的使用。 它们已由完全连接层所取代。 如此,训练模型具有以下体系结构。
在模型输入时,我们创建了一个初始数据层,其中包含 12 个元素来描述分析历史的每根柱线,以及 9 个元素来描述当前账户状态。
//--- Model Description.Clear(); CLayerDescription *descr; //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (int)(HistoryBars * 12 + 9); descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
接下来是数据常规化层。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
接下来是 2 个连续的卷积模块,和全连接层。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count - 2; descr.window = 3; descr.step = 1; descr.window_out = 6; descr.activation = LReLU; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 100; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 50; descr.window = 2; descr.step = 2; descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 100; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
压缩的数据由关注度模块进行分析。 此处,我们使用新的分散关注度层。 我们将整个压缩的数据序列切分成 20 个区块,每个区块 5 个元素。 每个区块代表序列中的一个元素正在分析。 为了分析数据,我们将使用 4 个关注者,在每个关注者中选择 30% 的最重要序列元素。 分析将在具有相似参数的 2 个连续层中进行。 这应该在 “layers” 参数中指示。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; descr.count = 20; descr.window = 5; descr.step = 4; descr.window_out = 8; descr.layers = 2; descr.probability = 0.3f; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
智能系统会决定是否在完全参数化的分位数函数模块中执行交易。 EA 可以决定采取以下 4 项操作之一:
- 买入
- 卖出
- 所有交易平仓
- 不交易
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = 4; descr.window_out = 32; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
完整的 EA 代码在附件中提供:SparseRL-learning.mq5。
我们采用 2023 年 3 月的 EURUSD H1 历史数据训练模型,并测试 EA。 在学习过程中,EA 在测试期间展示出盈利。 然而,获得的利润是因为平均盈利交易的规模大于平均亏损交易的规模。 但输赢仓位的数量大致相同。 结果就是,盈利因子为 1.12,恢复因子为 1.01。
结束语
在本文中,我们研究了分散关注度机制,并将其算法添加到我们的类库之中,之后我们基于历史数据对其进行了测试。 作为模型测试的结果,我们产生了一些盈利,这表明使用这种架构来构建交易解决方案的潜力。 然而,应该注意的是,本文中介绍的模型仅用于信息和测试目的。
若要在实盘交易条件下运用此模型,您必须对其有效性和对市场波动的抵抗力进行更详细的分析。 还需要更仔细地调整模型的超参数,从而获得最优结果。
您应该永远记住,运用任何模型进行金融市场交易总会涉及亏损风险。 因此,在运用任何模型进行真实交易之前,您必须仔细研究其工作原理,并评估可能的风险。
尽管如此,分散关注度机制可以成为构建交易模型的有用工具。
参考
- 使用分散变换器生成长序列
- 关注度就是您所需要的全部
- 神经网络变得轻松(第八部分):关注度机制
- 神经网络变得轻松(第十部分):多关注者
- 神经网络变得轻松(第十一部分):自 GPT 获取
- 神经网络变得轻松(第三十五部分):内在好奇心模块
- 神经网络变得轻松(第三十六部分):关系强化学习
本文中用到的程序
# | 发行 | 类型 | 说明 |
---|---|---|---|
1 | SparseRL-learning.mq5 | EA | 训练模型的智能系统 |
2 | ICM.mqh | 类库 | 模型组织类库 |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/12428

