
神经网络变得简单(第 77 部分):交叉协方差变换器(XCiT)
概述
变换器在分析解决各种序列问题方面展现出巨大的潜力。自关注操作是变换器的基础,它为序列中所有令牌之间提供全局互动。这令评估整个分析序列中的相互依赖关系成为可能。然而,在计算时间和内存占用方面带来了复杂度二次方暴增,令该算法难以应用于长序列。
为了解决这个问题,论文《XCiT:交叉协方差图像变换器》的作者推荐了一种“转置”版本的自关注,其操作经由特征通道,而非令牌,其中互动基于主键和查询之间的交叉协方差矩阵。结果是交叉协方差关注度(XCA),令牌数量具有线性复杂性,允许高效处理大数据序列。基于 XCA 的交叉协方差图像变换器(XCiT) 结合了传统转换器的精度和卷积架构的可扩展性。该论文通过实验确认了 XCiT 的有效性和普适性。所讲述的实验在几个视觉基准测试中展现了出色的结果,包括图像分类、对象检测、和实例分段。
1. XCiT 算法
该方法作者提议基于交叉协方差的自关注函数,该函数沿着特征维度操作,而不像经典的自关注令牌那样沿令牌维度。使用 Query、Key 和 Value 定义,交叉协方差关注度函数定义为:
其中,每个输出令牌嵌入是其在 V 中的相应令牌嵌入的 dv 特征的凸组合。关注度权重 A 是基于交叉协方差矩阵计算的。
除了基于交叉协方差矩阵构建新的关注度函数外,该方法的作者还提议通过对 Query 和 Key 矩阵进行 L2-常规化来限制它们的大小,如此常规化矩阵 Q 和 K 的长度为 N 的每一列都有单位范数。大小为 d*d 的关注度系数的交叉协方差矩阵的每个元素都在 [−1, 1] 范围内。该方法的作者指出,范数控制显著提高了学习的稳定性,尤其在学习时令牌数量可变。不过,约束范数会因删除自由度来降低操作的表述力度。因此,作者引入了一个可训练的温度参数 τ,它在调用 SoftMax 函数执行常规化之前缩放内积,从而允许更清晰、或更均匀的关注度权重分布。
此外,该方法的作者还限制了彼此互动的特征数量。他们提议将它们划分为 h 个组或“头”,类似于多目自关注令牌。对于每个“头”,该方法的作者分别应用交叉协方差关注。
对于每个“头”,它们将源数据投影 X 的权重矩阵分开训练为 Query、Key 和 Value。相应的权重矩阵收集在维度 {h * d * dq}, Wk — {h * d * dk} 和 Wv — {h * d * dv} \) 的张量 Wq 之中。它们设定 dk = dq = dv = d/h。
将注意力约束在头部有两个好处:
- 配以关注度权重的聚合值复杂度按系数 h 降低;
- 更重要的是,该方法的作者实证验明,模块对角矩阵版本更容易优化,且通常会导致结果改善。
带有 h 个“头”的经典自关注令牌具有的时间复杂度为 O(N^2 * d),以及内存为 O(hN^2 + Nd)。由于复杂度二次方暴增,则伸缩具有大量令牌的自关注序列会出问题。所提议的交叉协方差关注度克服了这个缺点,因为它的计算复杂度 O(Nd^2/h) 与令牌的数量呈线性关系。这同样适用于内存复杂度 O(d^2 / h + Nd)。
因此,作者提议的 XCA 模型在令牌数 N 较大、且特征维度 d 相对较小时,伸缩性要好得多,尤其是在将特征拆分为 h 个头的情况下。
为了构建交叉协方差变换器图像(XCiT),该方法的作者提议一种柱状架构,其可跨层维护相同的空间解析度。它们将交叉协方差关注度模块(XCA) 与 2 个后续附加模块相结合,每个都要在层内进行常规化处理。
在 XCA 模块中,补丁之间的通信仅通过共享统计信息间接运作。为了在补丁之间提供显式通信,该方法的作者在每个 XCA 模块之后添加了一个简单的局部补丁互动模块(LPI)。LPI 由两个卷积层,及它们之间的一个批量常规化层组成。作为第一层的激活函数,他们建议使用 GELU。由于其深度模块结构,LPI 的参数开销可以忽略不计,且带宽和内存开销亦微不足道。
与变换器模型中的常见情况一样,接下来添加具有逐点卷积层的前馈网络(FFN),其为含有一个带有 4d 隐藏模块的隐藏层。尽管在 XCA 模块内,分租特征之间的互动受到限制,且在 LPI 模块中的特征之间没有互动,然而 FFN 允许与所有特征互动。
与自关注令牌中包含的关注度映射不同,XCiT 中的协方差模块具有固定的大小,无论输入序列的解析度如何。SoftMax 始终搭配相同数量的元素工作,这也许解释了为什么 XCiT 模型在操控不同解析度的图像时表现更佳。XCiT 包括带有输入令牌的加法正弦位置编码。
作者对该算法的可视化如下所示。
2. 利用 MQL5 实现
在研究了交叉协方差变换器(XCiT) 的理论层面之后,我们转入利用 MQL5 实现所提议的方式实践。
2.1交叉协方差变换器类
为了实现 XCiT 模块算法,我们将创建一个新的神经层类 CNeuronXCiTOCL。作为父类,我们将使用常见的多目多层关注度类 CNeuronMLMHAttentionOCL。新类也将搭配内置的多层架构创建。
class CNeuronXCiTOCL : public CNeuronMLMHAttentionOCL { protected: //--- uint iLPIWindow; uint iLPIStep; uint iBatchCount; //--- CCollection cLPI; CCollection cLPI_Weights; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool XCiT(CBufferFloat *qkv, CBufferFloat *score, CBufferFloat *out); virtual bool BatchNorm(CBufferFloat *inputs, CBufferFloat *options, CBufferFloat *out); //--- virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool XCiTInsideGradients(CBufferFloat *qkv, CBufferFloat *qkvg, CBufferFloat *score, CBufferFloat *aog); virtual bool BatchNormInsideGradient(CBufferFloat *inputs, CBufferFloat *inputs_g, CBufferFloat *options, CBufferFloat *out, CBufferFloat *out_g, ENUM_ACTIVATION activation); virtual bool BatchNormUpdateWeights(CBufferFloat *options, CBufferFloat *out_g); public: CNeuronXCiTOCL(void) {}; ~CNeuronXCiTOCL(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint lpi_window, uint heads, uint units_count, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); //--- virtual int Type(void) const { return defNeuronXCiTOCL; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); virtual CLayerDescription* GetLayerInfo(void); virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
我要提请注意的是,在新类中,我们将最大限度地利用父类的工具。不过,我们仍然需要进行重大补充。首先,我们将为 LPI 模块添加缓冲区集合:
- cLPI – 结果和梯度缓冲区;
- cLPI_Weights – 权重矩阵和动量矩阵。
此外,对于 LPI 模块,我们需要额外的常量:
- iLPIWindow – 模块第一层的卷积窗口;
- iLPIStep – 模块第一层的卷积窗口步长;
- iBatchCount – 在数据模块批量常规化层中执行的操作数。
我们仅在第一层指定卷积参数。由于在第二层我们需要达到源数据层的大小。因为该方法的作者提议添加并常规化先前 XCA 模块的结果数据。
在该类中,所有添加的对象都被声明为静态,如此我们将层的构造函数和析构函数留空。层的主要初始化在 Init 方法中实现。在参数中,该方法接收初始化内部对象所需的所有参数。
bool CNeuronXCiTOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint lpi_window, uint heads, uint units_count, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
在方法的主体中,我们没有针对接收参数组织控制模块。取而代之,我们调用所有神经层的基类的初始化方法,其中已经实现了最少的必要控制,并初始化继承的对象。
此处的注意事项,我们调用的是基类的方法,而非父类方法。这是因为我们创建的内部层缓冲区的大小和它们的数量会有所不同。因此,为了避免重复执行相同的工作,我们将在新的初始化方法主体中初始化所有缓冲区。
首先,我们将主要参数保存到局部变量之中。
iWindow = fmax(window, 1); iUnits = fmax(units_count, 1); iHeads = fmax(fmin(heads, iWindow), 1); iWindowKey = fmax((window + iHeads - 1) / iHeads, 1); iLayers = fmax(layers, 1); iLPIWindow = fmax(lpi_window, 1); iLPIStep = 1;
请注意,我们基于序列中一个元素的描述向量的大小,和关注度的数量,重新计算内部实体的维度。这是由 XCiT 方法的作者提议的。
接下来,我们判定每个模块中缓冲区的主要维度。
//--- XCA uint num = 3 * iWindowKey * iHeads * iUnits; // Size of QKV tensor uint qkv_weights = 3 * (iWindow + 1) * iWindowKey * iHeads; // Size of weights' matrix of // QKV tensor uint scores = iWindowKey * iWindowKey * iHeads; // Size of Score tensor uint out = iWindow * iUnits; // Size of output tensor
//--- LPI uint lpi1_num = iWindow * iHeads * iUnits; // Size of LPI1 tensor uint lpi1_weights = (iLPIWindow + 1) * iHeads; // Size of weights' matrix of // LPI1 tensor uint lpi2_weights = (iHeads + 1) * 2; // Size of weights' matrix of // LPI2 tensor
//--- FF uint ff_1 = 4 * (iWindow + 1) * iWindow; // Size of weights' matrix 1-st // feed forward layer uint ff_2 = (4 * iWindow + 1) * iWindow; // Size of weights' matrix 2-nd // feed forward layer
之后,我们组织一个循环,迭代次数等于内部层数。
for(uint i = 0; i < iLayers; i++) { CBufferFloat *temp = NULL;
在循环主体中,我们首先创建过渡结果,及其梯度的缓冲区。为此,我们创建了一个嵌套循环。在循环的第一次迭代中,我们创建过渡结果的缓冲区。在第二次迭代中,我们创建相应误差梯度的缓冲区。
for(int d = 0; d < 2; d++) { //--- XCiT //--- 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 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(!AO_Tensors.Add(temp)) return false;
计算实体大小的方式按作者提议的方法,允许我们删除降低关注度模块维度的层。
接下来,我们创建 LPI 模块的缓冲区。此处,我们创建第一个卷积层结果的缓冲区。
//--- LPI //--- Initilize LPI tensor temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(lpi1_num, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI.Add(temp)) // LPI1 return false;
然后是批量常规化结果的缓冲区。
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(lpi1_num, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI.Add(temp)) // LPI Normalize return false;
该模块以第二个卷积层的结果缓冲区结束。
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI.Add(temp)) // LPI2 return false;
最后,我们创建 FeedForward 模块的结果缓冲区。
//--- 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; }
我们将按相同的顺序创建权重矩阵缓冲区。
//--- XCiT //--- Initialize 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() - 0.5f)* k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
//--- Initialize LPI1 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(lpi1_weights)) return false; for(uint w = 0; w < lpi1_weights; w++) { if(!temp.Add((GenerateWeight() - 0.5f)* k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI_Weights.Add(temp)) return false;
//--- Normalization int count = (int)lpi1_num * (optimization_type == SGD ? 7 : 9); temp = new CBufferFloat(); if(!temp.BufferInit(count, 0.0f)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI_Weights.Add(temp)) return false;
//--- Initialize LPI2 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(lpi2_weights)) return false; for(uint w = 0; w < lpi2_weights; w++) { if(!temp.Add((GenerateWeight() - 0.5f)* k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI_Weights.Add(temp)) return false;
//--- Initialize 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() - 0.5f)* 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() - 0.5f)* 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++) { //--- XCiT 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;
//--- LPI temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(lpi1_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI_Weights.Add(temp)) return false;
temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(lpi2_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!cLPI_Weights.Add(temp)) return false;
//--- FF Weights momentus 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; } } iBatchCount = 1; //--- return true; }
成功创建所有必要的缓冲区之后,我们终止该方法,并将操作逻辑结果返回给调用者。
我们已经完成了类的初始化。现在,我们继续讲述 XCiT 方法的前馈算法。如上所述,所提出的方法的实现将需要重大更改。为了实现前馈验算,我们需要在 OpenCL 程序的一侧创建一个内核,以便实现 XCA 算法。
请注意,我们在从父类 ConvolutionForward 继承的方法中接收实体。故此,我们的内核已在操控生成的 Query、Key 和 Value 实体,我们将其传输到内核作为单个缓冲区。除了它们之外,在内核参数中,我们将传递另外两个数据缓冲区的指针:关注度系数,和关注度模块结果。
__kernel void XCiTFeedForward(__global float *qkv, __global float *score, __global float *out) { const size_t d = get_local_id(0); const size_t dimension = get_local_size(0); const size_t u = get_local_id(1); const size_t units = get_local_size(1); const size_t h = get_global_id(2); const size_t heads = get_global_size(2);
我们将在 3-维任务空间中启动内核:
- 一个实体元素的维度;
- 序列长度;
- 关注度数量。
至于前两个维度,我们会将它们合并到局部工作组当中。
我们声明两个局部 2-维数组,用于写入过渡数据,并在工作组内交换信息。
const uint ls_u = min((uint)units, (uint)LOCAL_ARRAY_SIZE); const uint ls_d = min((uint)dimension, (uint)LOCAL_ARRAY_SIZE); __local float q[LOCAL_ARRAY_SIZE][LOCAL_ARRAY_SIZE]; __local float k[LOCAL_ARRAY_SIZE][LOCAL_ARRAY_SIZE];
在开始分析交叉协方差关注度之前,我们需要按照该方法的作者的提议常规化 Query 和 Key 实体。
为此,我们首先计算分组内每个参数的向量大小。
//--- Normalize Query and Key for(int cur_d = 0; cur_d < dimension; cur_d += ls_d) { float q_val = 0; float k_val = 0; //--- if(d < ls_d && (cur_d + d) < dimension && u < ls_u) { for(int count = u; count < units; count += ls_u) { int shift = count * dimension * heads * 3 + dimension * h + cur_d + d; q_val += pow(qkv[shift], 2.0f); k_val += pow(qkv[shift + dimension * heads], 2.0f); } q[u][d] = q_val; k[u][d] = k_val; } barrier(CLK_LOCAL_MEM_FENCE);
uint count = ls_u; do { count = (count + 1) / 2; if(d < ls_d) { if(u < ls_u && u < count && (u + count) < units) { float q_val = q[u][d] + q[u + count][d]; float k_val = k[u][d] + k[u + count][d]; q[u + count][d] = 0; k[u + count][d] = 0; q[u][d] = q_val; k[u][d] = k_val; } } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
然后,我们将序列中的每个元素除以沿相应维度的向量大小的平方根。
int shift = u * dimension * heads * 3 + dimension * h + cur_d; qkv[shift] = qkv[shift] / sqrt(q[0][d]); qkv[shift + dimension * heads] = qkv[shift + dimension * heads] / sqrt(k[0][d]); barrier(CLK_LOCAL_MEM_FENCE); }
现在我们的实体已经常规化了,我们可以转到定义依赖系数。为此,我们将 Query 和 Key 矩阵相乘。同时,我们取所得值的指数,并将它们相加。
//--- Score int step = dimension * heads * 3; for(int cur_r = 0; cur_r < dimension; cur_r += ls_u) { for(int cur_d = 0; cur_d < dimension; cur_d += ls_d) { if(u < ls_d && d < ls_d) q[u][d] = 0; barrier(CLK_LOCAL_MEM_FENCE); //--- if((cur_r + u) < ls_d && (cur_d + d) < ls_d) { int shift_q = dimension * h + cur_d + d; int shift_k = dimension * (heads + h) + cur_r + u; float scr = 0; for(int i = 0; i < units; i++) scr += qkv[shift_q + i * step] * qkv[shift_k + i * step]; scr = exp(scr); score[(cur_r + u)*dimension * heads + dimension * h + cur_d + d] = scr; q[u][d] += scr; } } barrier(CLK_LOCAL_MEM_FENCE);
int count = ls_d; do { count = (count + 1) / 2; if(u < ls_d) { if(d < ls_d && d < count && (d + count) < dimension) q[u][d] += q[u][d + count]; if(d + count < ls_d) q[u][d + count] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
然后我们对依赖系数进行常规化。
if((cur_r + u) < ls_d) score[(cur_r + u)*dimension * heads + dimension * h + d] /= q[u][0]; barrier(CLK_LOCAL_MEM_FENCE); }
在内核操作结束时,我们将 Value 张量乘以依赖系数。此操作的结果将保存在 XCA 关注度模块的结果缓冲区当中。
int shift_out = dimension * (u * heads + h) + d; int shift_s = dimension * (heads * d + h); int shift_v = dimension * (heads * (u * 3 + 2) + h); float sum = 0; for(int i = 0; i < dimension; i++) sum += qkv[shift_v + i] * score[shift_s + i]; out[shift_out] = sum; }
在 OpenCL 程序端创建内核后,我们转到主程序端的类中进行操作。于此,我们首先创建 CNeuronXCiTOCL::XCiT 方法,其中,我们实现调用所创建内核的算法。
bool CNeuronXCiTOCL::XCiT(CBufferFloat *qkv, CBufferFloat *score, CBufferFloat *out) { if(!OpenCL || !qkv || !score || !out) return false;
在方法参数中,我们将传递指向 3 个用到的数据缓冲区指针。在方法主体中,我们立即检查收到的指针是否相关。
然后我们定义任务空间,及其中的偏移量。
uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iWindowKey, iUnits, iHeads}; uint local_work_size[3] = {iWindowKey, iUnits, 1};
如上所述,我们沿着前两个维度将线程组合成工作组。
接下来,我们将指向数据缓冲区的指针传递给内核。
if(!OpenCL.SetArgumentBuffer(def_k_XCiTFeedForward, def_k_XCiTff_qkv, qkv.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_XCiTFeedForward, def_k_XCiTff_score, score.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_XCiTFeedForward, def_k_XCiTff_out, out.GetIndex())) return false;
把内核放入执行队列当中。
ResetLastError(); if(!OpenCL.Execute(def_k_XCiTFeedForward, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); Print(error); return false; } //--- return true; }
除了上述方法外,我们还将为批量常规化层创建一个前馈方法 CNeuronXCiTOCL::BatchNorm,其整个算法完全从 CNeuronBatchNormOCL::feedForward 方法移植而来。但我们现在不会详研其算法。我们直接转到 CNeuronXCiTOCL::feedForward 方法的分析,它代表了 XCiT 模块中前向验算算法的一般轮廓。
bool CNeuronXCiTOCL::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(4 * i - 2)); 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;
此处,我们首先形成 Query、Key 和 Value 实体。然后我们调用我们的交叉协方差关注度方法。
//--- Score calculation CBufferFloat *temp = S_Tensors.At(i * 2); CBufferFloat *out = AO_Tensors.At(i * 2); if(IsStopped() || !XCiT(qkv, temp, out)) return false;
将关注度结果添加到原始数据之中,并将结果值常规化。
//--- Sum and normalize attention if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true)) return false;
接下来是 LPI 模块。首先,我们来组织模块第一层的工作。
//--- LPI inputs = out; temp = cLPI.At(i * 6); if(IsStopped() || !ConvolutionForward(cLPI_Weights.At(i * (optimization == SGD ? 5 : 7)), inputs, temp, iLPIWindow, iHeads, LReLU, iLPIStep)) return false;
然后,我们对第一层的结果进行常规化。
out = cLPI.At(i * 6 + 1); if(IsStopped() || !BatchNorm(temp, cLPI_Weights.At(i * (optimization == SGD ? 5 : 7) + 1), out)) return false;
我们将常规化结果传递给模块的第二层。
temp = out; out = cLPI.At(i * 6 + 2); if(IsStopped() ||!ConvolutionForward(cLPI_Weights.At(i * (optimization == SGD ? 5 : 7) + 2), temp, out, 2 * iHeads, 2, None, iHeads)) return false;
然后我们再次对结果进行汇总和常规化。
//--- Sum and normalize attention if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true)) return false;
组织 FeedForward 模块。
//--- Feed Forward inputs = out; temp = FF_Tensors.At(i * 4); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 4 : 6)), inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors.At(i * 4 + 1); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 4 : 6) + 1), temp, out, 4 * iWindow, iWindow, activation)) return false;
在层的输出中,我们总结并常规化模块的结果。
//--- Sum and normalize out if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true)) return false; } iBatchCount++; //--- return true; }
我们的新交叉协方差变换器层 CNeuronXCiTOCL 的前馈验算的实现到此结束。接下来,我们转到构造反向传播算法。此处,我们还必须返回 OpenCL 程序并创建另一个内核。我们将在 XCiTInsideGradients 内核中构建 XCA 模块的反向传播算法。在内核的参数中,我们将传递指向 4 个数据缓冲区的指针:
- qkv – Query、Key 和 Value 实体的串联向量;
- qkv_g ― Query, Key 和 Value 实体的误差梯度的串联向量;
- scores – 依赖系数矩阵;
- gradient – XCA 关注度模块输出端的误差梯度张量。
__kernel void XCiTInsideGradients(__global float *qkv, __global float *qkv_g, __global float *scores, __global float *gradient) { //--- init const int q = get_global_id(0); const int d = get_global_id(1); const int h = get_global_id(2); const int units = get_global_size(0); const int dimension = get_global_size(1); const int heads = get_global_size(2);
我们计划在 3-维任务空间中启动内核。在内核的主体中,我们标识线程和任务空间。然后我们判定所分析元素在数据缓冲区中的偏移量。
const int shift_q = dimension * (heads * 3 * q + h); const int shift_k = dimension * (heads * (3 * q + 1) + h); const int shift_v = dimension * (heads * (3 * q + 2) + h); const int shift_g = dimension * (heads * q + h); int shift_score = dimension * h; int step_score = dimension * heads;
根据反向传播算法,我们首先判定 Value 张量的误差梯度。
//--- Calculating Value's gradients float sum = 0; for(int i = 0; i < dimension; i ++) sum += gradient[shift_g + i] * scores[shift_score + d + i * step_score]; qkv_g[shift_v + d] = sum;
接下来,我们定义 Query 的误差梯度。此处,我们必须首先判定系数矩阵的相应向量上的误差梯度。然后将生成的误差梯度调整为 SoftMax 函数的导数。只有在这种情况下,我们才能获得所需的误差梯度。
//--- Calculating Query's gradients float grad = 0; float val = qkv[shift_v + d]; for(int k = 0; k < dimension; k++) { float sc_g = 0; float sc = scores[shift_score + k]; for(int v = 0; v < dimension; v++) sc_g += scores[shift_score + v] * val * gradient[shift_g + v * dimension] * ((float)(k == v) - sc); grad += sc_g * qkv[shift_k + k]; } qkv_g[shift_q] = grad;
对于 Key 张量,误差梯度的判定方式类似,但与向量垂直。
//--- Calculating Key's gradients grad = 0; float out_g = gradient[shift_g]; for(int scr = 0; scr < dimension; scr++) { float sc_g = 0; int shift_sc = scr * dimension * heads; float sc = scores[shift_sc + d]; for(int v = 0; v < dimension; v++) sc_g += scores[shift_sc + v] * out_g * qkv[shift_v + v] * ((float)(d == v) - sc); grad += sc_g * qkv[shift_q + scr]; } qkv_g[shift_k + d] = grad; }
构建内核后,我们返回在主程序一侧操控我们的类。此处,我们创建 CNeuronXCiTOCL::XCiTInsideGradients 方法。在参数中,该方法接收指向所需数据缓冲区的指针。
bool CNeuronXCiTOCL::XCiTInsideGradients(CBufferFloat *qkv, CBufferFloat *qkvg, CBufferFloat *score, CBufferFloat *aog) { if(!OpenCL || !qkv || !qkvg || !score || !aog) return false;
在方法主体中,我们立即检查收到的指针是否相关。
然后我们定义一个 3-维问题空间。但这次我们不定义工作组。
uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iWindowKey, iUnits, iHeads};
我们将指向数据缓冲区的指针作为参数传递给内核。
if(!OpenCL.SetArgumentBuffer(def_k_XCiTInsideGradients, def_k_XCiTig_qkv, qkv.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_XCiTInsideGradients, def_k_XCiTig_qkv_g, qkvg.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_XCiTInsideGradients, def_k_XCiTig_scores,score.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_XCiTInsideGradients, def_k_XCiTig_gradient,aog.GetIndex())) return false;
准备工作完成后,我们只需要将内核放入执行队列即可。
ResetLastError(); if(!OpenCL.Execute(def_k_XCiTInsideGradients, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
在调度方法 CNeuronXCiTOCL::calcInputGradients 中收集的 XCiT 模块的完整向后算法。在其参数中,该方法接收指向上一层对象的指针。
bool CNeuronXCiTOCL::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(CheckPointer(prevLayer) == POINTER_INVALID) return false;
在方法的主体中,我们立即检查接收到的指针的有效性。成功通过控制点后,我们组织一个逆向迭代循环,遍历内层,并传播误差梯度。
CBufferFloat *out_grad = Gradient; //--- for(int i = int(iLayers - 1); (i >= 0 && !IsStopped()); i--) { //--- Passing gradient through feed forward layers if(IsStopped() || !ConvolutionInputGradients(FF_Weights.At(i*(optimization==SGD ? 4:6)+1), out_grad, FF_Tensors.At(i * 4), FF_Tensors.At(i * 4 + 2), 4 * iWindow, iWindow, None)) return false;
在循环的主体中,我们首先将误差梯度传递给 FeedForward 模块。
CBufferFloat *temp = cLPI.At(i * 6 + 5); if(IsStopped() || !ConvolutionInputGradients(FF_Weights.At(i * (optimization == SGD ? 4 : 6)), FF_Tensors.At(i * 4 + 1), cLPI.At(i * 6 + 2), temp, iWindow, 4 * iWindow, LReLU)) return false;
我要提醒您,在直接验算期间,我们添加了带有原始数据的块的结果。与此类似,我们在 2 个线程之间传播误差梯度。
//--- Sum and normalize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false)) return false;
接下来,我们通过 LPI 模块传播误差梯度。
out_grad = temp; //--- Passing gradient through LPI if(IsStopped() || !ConvolutionInputGradients(cLPI_Weights.At(i * (optimization == SGD ? 5 : 7) + 2), temp, cLPI.At(i * 6 + 1), cLPI.At(i * 6 + 4), 2 * iHeads, 2, None, 0, iHeads)) return false; if(IsStopped() || !BatchNormInsideGradient(cLPI.At(i * 6), cLPI.At(i * 6 + 3), cLPI_Weights.At(i * (optimization == SGD ? 5 : 7) + 1), cLPI.At(i * 6 + 1), cLPI.At(i * 6 + 4), LReLU)) return false; if(IsStopped() || !ConvolutionInputGradients(cLPI_Weights.At(i * (optimization == SGD ? 5 : 7)), cLPI.At(i * 6 + 3), AO_Tensors.At(i * 2), AO_Tensors.At(i * 2 + 1), iLPIWindow, iHeads, None, 0, iLPIStep)) return false;
再次添加误差梯度。
temp = AO_Tensors.At(i * 2 + 1); //--- Sum and normalize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false)) return false;
之后,我们通过关注度模块 XCA 传播误差梯度。
out_grad = temp; //--- Passing gradient to query, key and value if(IsStopped() || !XCiTInsideGradients(QKV_Tensors.At(i * 2), QKV_Tensors.At(i * 2 + 1), S_Tensors.At(i * 2), temp)) return false;
将其传输到源数据梯度缓冲区。
CBufferFloat *inp = NULL; if(i == 0) { inp = prevLayer.getOutput(); temp = prevLayer.getGradient(); } else { temp = FF_Tensors.At(i * 4 - 1); inp = FF_Tensors.At(i * 4 - 3); } if(IsStopped() || !ConvolutionInputGradients(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), QKV_Tensors.At(i * 2 + 1), inp, temp, iWindow, 3 * iWindowKey * iHeads, None)) return false;
不要忘记沿第二个流添加误差梯度。
//--- Sum and normalize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow)) return false; if(i > 0) out_grad = temp; } //--- return true; }
上面,我们实现了一种算法,将误差梯度传播到内层,并将其转移到前面的神经层。在反向传播操作结束时,我们需要更新模型参数。
更新我们新的交叉协方差变换器层的参数是在 CNeuronXCiTOCL::updateInputWeights 方法中实现的。与其它神经层的类似方法一样,该方法在其参数中接收指向前一层的神经层的指针。
bool CNeuronXCiTOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false; CBufferFloat *inputs = NeuronOCL.getOutput();
在方法的主体中,我们检查接收到的指针的相关性。
与误差梯度的分布类似,我们将在循环中遍历内层更新参数。
for(uint l = 0; l < iLayers; l++) { if(IsStopped() || !ConvolutuionUpdateWeights(QKV_Weights.At(l * (optimization == SGD ? 2 : 3)), QKV_Tensors.At(l * 2 + 1), inputs, (optimization==SGD ? QKV_Weights.At(l*2+1):QKV_Weights.At(l*3+1)), (optimization==SGD ? NULL : QKV_Weights.At(l*3+2)), iWindow, 3 * iWindowKey * iHeads)) return false;
首先,我们更新依据 Query、Key 和 Value 实体形成的矩阵参数。
接下来,我们更新 LPI 模块的参数。该模块包含 2 个卷积层和一个批量常规化层。
if(IsStopped() || !ConvolutuionUpdateWeights(cLPI_Weights.At(l * (optimization == SGD ? 5 : 7)), cLPI.At(l * 6 + 3), AO_Tensors.At(l * 2), (optimization==SGD ? cLPI_Weights.At(l*5+3):cLPI_Weights.At(l*7+3)), (optimization==SGD ? NULL : cLPI_Weights.At(l * 7 + 5)), iLPIWindow, iHeads, iLPIStep)) return false; if(IsStopped() || !BatchNormUpdateWeights(cLPI_Weights.At(l * (optimization == SGD ? 5 : 7) + 1), cLPI.At(l * 6 + 4))) return false; if(IsStopped() || !ConvolutuionUpdateWeights(cLPI_Weights.At(l * (optimization == SGD ? 5 : 7) + 2), cLPI.At(l * 6 + 5), cLPI.At(l * 6 + 1), (optimization==SGD ? cLPI_Weights.At(l*5+4):cLPI_Weights.At(l*7+4)), (optimization==SGD ? NULL : cLPI_Weights.At(l * 7 + 6)), 2 * iHeads, 2, iHeads)) return false;
在方法的末尾,我们有一个模块,其更新 FeedForward 模块的参数。
if(IsStopped() || !ConvolutuionUpdateWeights(FF_Weights.At(l * (optimization == SGD ? 4 : 6)), FF_Tensors.At(l * 4 + 2), cLPI.At(l * 6 + 2), (optimization==SGD ? FF_Weights.At(l*4+2):FF_Weights.At(l*6+2)), (optimization==SGD ? NULL : FF_Weights.At(l * 6 + 4)), iWindow, 4 * iWindow)) return false; //--- if(IsStopped() || !ConvolutuionUpdateWeights(FF_Weights.At(l * (optimization == SGD ? 4 : 6) + 1), FF_Tensors.At(l * 4 + 3), FF_Tensors.At(l * 4), (optimization==SGD ? FF_Weights.At(l*4+3):FF_Weights.At(l*6+3)), (optimization==SGD ? NULL : FF_Weights.At(l * 6 + 5)), 4 * iWindow, iWindow)) return false; inputs = FF_Tensors.At(l * 4 + 1); } //--- return true; }
据此,我们完成了交叉协方差变换器层 CNeuronXCiTOCL 的前馈和反向传播算法的实现。若要启用类的完整操作,我们仍然需要添加几个辅助方法。其中包括文件操作方法(Save 和 Load)。这些方法的算法并不复杂,也不包含任何与 XCiT 方法特别相关的独特之处。因此,我不会在本文中详赘述它们的算法。附件包含该类的完整代码,因此您可以自行研究。附件还包含本文中用到的所有程序。
2.2模型架构
我们转到构建训练和测试模型的智能系统。于此必须要说,在他们的论文中,该方法的作者并没有介绍模型的具体架构。本质上,所提议交叉协方差变换器可以取代我们之前在任意模型中研究过的经典变换器。因此,作为实验的一部分,我们可以从以前的文章中获取模型,并将 CNeuronMLMHAttentionOCL 层替换为 CNeuronXCiTOCL。
但我们在这里必须诚实。在上一篇文章中,我们使用了不同的关注度模块。我们特别专注使用 CNeuronMFTOCL,由于其架构特性,它不能被 CNeuronXCiTOCL 取代。
然而,即使替换一层,我们也能以某种方式评估变化。
故此,我们的测试模型的最终架构如下。
bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *endpoints, CArrayObj *probability) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!endpoints) { endpoints = new CArrayObj(); if(!endpoints) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; }
描述 1 根柱线的“原始”源数据被馈送到环境编码器的源数据层。
//--- 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 = MathMax(1000, GPTBars); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
环境状态嵌入是据已常规化数据生成的,并添加到内部堆栈之中。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; { int temp[] = {prev_count}; ArrayCopy(descr.windows, temp); } prev_count = descr.count = GPTBars; int prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
此处我们还添加了位置数据编码。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPEOCL; descr.count = prev_count; descr.window = prev_wout; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是一个图形模块,层之间具有批量常规化。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCGConvOCL; descr.count = prev_count * prev_wout; descr.window = descr.count; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count * prev_wout; descr.batch = MathMax(1000, GPTBars); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCGConvOCL; descr.count = prev_count * prev_wout; descr.window = descr.count; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们添加新的交叉协方差变换器层。我们保持序列元素和源数据窗口的数量不变。指定的参数由初始数据的张量确定。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronXCiTOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = 3; descr.layers = 1; descr.batch = MathMax(1000, GPTBars); descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
在本例中,我们用到 4 个关注度。
该方法的作者提议使用一个序列元素的描述向量大小的整数切分,来判定每个关注度数量的实体向量的大小。依据该选项,我们的 descr.window_out 参数就用不到了。故此,我们利用这一事实,在此参数中指定第一个 LPI 层的窗口大小。我们还指示了批量大小,以便常规化 LPI 模块中的数据。
编码器后跟 MFT 模块。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMFTOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = 16; descr.layers = NForecast; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
我们转置张量,将其转换为相应的形式。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout * NForecast; if(!encoder.Add(descr)) { delete descr; return false; }
环境编码器和 MFT 的结果用于解码最可能的端点。
//--- Endpoints endpoints.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = (prev_count * prev_wout) * NForecast; descr.activation = None; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = NForecast * prev_wout; descr.window = prev_count; descr.step = descr.window; descr.window_out = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = NForecast; descr.window = LatentCount * prev_wout; descr.step = descr.window; descr.window_out = 3; descr.activation = None; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; }
以及它们的概率估算。
//--- Probability probability.Clear(); //--- Input layer if(!probability.Add(endpoints.At(0))) return false; //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count * prev_wout * NForecast; descr.step = 3 * NForecast; descr.optimization = ADAM; descr.activation = SIGMOID; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NForecast; descr.activation = None; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = NForecast; descr.step = 1; descr.activation = None; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- return true; }
扮演者模型没有使用关注度层。因此,模型只被复制,而未更改。您可以自行领悟附件中所有模型的完整架构。
注意,替换环境状态编码器架构中的一层不会影响模型训练和测试过程的组织。因此,所有训练和环境互动 EA 都被复制,未更改。在我看来,这种方式查看测试结果更有趣。因为保持其它条件相同,我们可以最诚实地评估在模型架构中替换一个层的影响。
您可以在附件中找到此处用到的所有程序的完整代码。我们转到测试已构造交叉协方差变换器层 CNeuronXCiTOCL。
3. 测试
我们已经做了相当多的工作,基于论文 《XCiT:交叉协方差图像变换器》 中阐述的算法构建了一个新的交叉协方差变换器类 CNeuronXCiTOCL。如上所述,我们决定以不变的形式使用上一篇文章中的智能系统。因此,为了训练模型,我们可以使用之前收集的训练数据集。我们将文件 “MFT.bd” 重命名为 “XCiT.bd”。
如果您没有以前收集的训练数据集,则需要在训练模型之前收集该数据集。我建议首先使用文章《利用过去的经验解决新问题》 中讲述的方法从真实信号中收集数据。然后,您应该在策略测试器中用 EA “...\Experts\XCiT\Research.mq5” 的随机验算来补充训练数据集。
收集训练数据后,依据 EA “...\Experts\XCiT\Study.mq5” 训练模型。
如前,该模型依据 EURUSD H1 历史数据上进行训练。所有指标都采用默认参数。
该模型依据 2023 年前 7 个月的历史数据进行训练。于此,我们可以立即注意到测试所提出方法的有效性的初步结果。在训练过程中,我们可以看到,在相同的训练迭代下,时间成本降低了近 2%。
依据 2023 年 8 月的历史数据评估了训练模型的有效性。测试区期不包括在训练数据集之中。不过,它直接来到训练区期之后。基于训练模型的测试结果,我们得到的结果与上一篇文章中讲述的结果接近。
然而,在交易数量略有增加的背后,是盈利因子的提升。
结束语
在本文中,我们领略了交叉协方差变换器(XCiT)的新架构,它结合了变换器和卷积架构的优点。它在处理不同长度的序列时提供高精度和可扩展性。在分析具有较小令牌规模的大序列时,可以达成一定的效率。
XCiT 使用交叉协方差关注度架构来有效地对序列元素特征之间的全局互动进行建模,令其能够成功处理长序列的令牌。
该方法的作者通过实验确认了 XCiT 在若干个视觉任务上的高效率,包括图像分类、对象检测、和语义分割。
在本文的实践部分,我们利用 MQL5 实现了所提议的方法。该模型依据真实历史数据进行了训练和测试。在训练期间,对于相同数量的训练迭代,我们的训练时间略有减少。这是通过仅替换模型中的一层来实现的。
训练模型效率的略微提高,可能表明所提出的架构具有更好的泛化能力。
请不要忘记,金融市场交易是一项高风险投资。本文中介绍的所有程序仅供参考,并未针对真实交易进行优化。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 收集样本的 EA |
2 | ResearchRealORL.mq5 | 智能交易系统 | 用于使用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | 智能交易系统 | 模型训练 EA |
4 | Test.mq5 | 智能交易系统 | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态定义结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14276


