神经网络变得轻松(第八部分):关注机制
内容
概述
在之前的文章中,我们已经测试了组织规划神经网络的各种选项。 其中包括借鉴来的图像处理算法的卷积网络[ 3 ],以及递归神经网络[ 4 ],这些神经网络不仅处理重要的数值序列,还有它们在源数据集合中的位置。
全连接和卷积神经网络具有固定的输入序列大小。 递归神经网络通过转移先前迭代中的隐藏状态,可稍微扩展所分析序列。 但是它们的有效性也随着序列的递增而降低。 在 2014 年,出于机器翻译的目的,第一次提出了关注机制。 该机制的目的在于判定并高亮显示与目标翻译词最相关的源句子(上下文)的区块。 这种直观的方法极大地提高了神经网络翻译文本的质量。
1. 关注机制
分析烛条品种图表时,我们定义了趋势和倾向,并判定出它们的交易范围。 这意味着,我们从总体图中选择一些对象,然后将注意力集中在这些对象上。 我们知晓对象会影响未来的价格行为。 为了实现这种方法,早在 2014 年,开发人员就提出了第一种算法,该算法可以分析输入和输出序列元素[8] 之间的依赖性,并高亮显示。 所提议的算法称为“泛关注机制”。 最初提议将其用在递归网络机器翻译模型之中,以便解决长句子翻译中的长期记忆问题。 这种方式大大改善了之前研究的基于 LSTM 模块[ 4 ] 的递归神经网络的结果。
采用递归网络的经典机器翻译模型由两个模块组成:编码器和解码器。 第一个模块将源语言中的输入序列编码为上下文向量,第二个模块将结果上下文解码为目标语言中的单词序列。 当输入序列的长度递增时,第一个单词对最终句子上下文的影响会递减。 后果就是,翻译品质下降。 采用 LSTM 模块略微增加了模型的效能,但它们仍然有限。
一般性关注机制的作者建议采用附加层来累积输入序列的所有循环模块的隐藏状态。 进而,在序列解码期间,该机制应评估输入序列每个元素对输出序列当前字词的影响,并向解码器建议与上下文最相关的部分。
该机制操作算法包括以下迭代:
1. 创建编码器的隐藏状态,并将其累积在关注模块当中。
2. 评估每个编码器元素的隐藏状态与解码器最后一个隐藏状态之间的配对依赖性。
3. 将结果得分合并为一个向量,并利用 Softmax 函数对其进行常规化。
4. 所有编码器的隐藏状态乘以它们对应的对齐分数,以便计算上下文向量。
5. 解码上下文向量,并将结果值与解码器的先前状态组合。
迭代重复所有操作,直至收到句子结束信号为止。
所提议机制能够在输入序列长度有限的情况下解决问题,并采用递归神经网络提高了机器翻译的品质。 该方法变得愈发流行,故进一步创建了它的变体。 在 2012 年,Minh-Thang Luong 在他的文章[ 9 ] 中提出了关注方法的新变化。 新方式的主要区别是采用三个函数来计算依赖度,以及在解码器中使用关注机制的要点。
上述模型采用递归模块,其训练在计算上是昂贵的。 在 2017 年 6 月,在文章[ 10 ] 中提出了另一种变体。 这是新的体系结构变换器(Transformer)神经网络,该体系结构未采用递归模块,而是采用新的自关注算法。 与先前讲述的算法不同,自关注分析一个序列内的配对依赖性。 变换器展现出更好的测试结果。 如今,此模型及其派生物已在许多模型中运用,包括 GPT-2 和 GPT-3。 我们来更详细地研究自关注算法。
2. 自关注机制
变换器架构基于类似架构的顺序编码器和解码器模块。 每个模块包含若干不同权重矩阵的相同层。
每个编码器层包含 2 个内层:自关注和前馈。 前馈层包括两个完全连接神经元层,内层含有 ReLU 激活函数。 每层都按相同权重应用于序列的所有元素,如此即可在并行线程中并发独立计算序列的所有元素。
解码器层拥有类似的结构,但它含有额外的自关注,可分析输入和输出序列之间的依存关系。
自关注机制本身包括若干个迭代动作,这些动作应用于序列的每个元素。
1. 首先,我们计算 Query、Key 和 Value 向量。 这些向量由序列的每个元素乘以相应的矩阵 WQ、WK 和 WV 得到的。
2. 接着,判断序列元素之间的配对依赖性。 为此,将 Query 向量乘以序列中所有元素的 Key 向量。 重复迭代序列中每个元素的 Query 向量。 迭代的结果就是,我们得到大小为 N*N 的 Score 矩阵,其中 N 是序列的大小。
3. 下一步是在每个 Query 的上下文中,将结果值除以 Key 向量维度的平方根,并利用 Softmax 函数对其进行常规化。 由此,我们得到了序列元素之间的配对依赖系数。
4. 将每个 Value 向量乘以相应的依赖系数,从而得到调整后的元素值。 此迭代的目的是聚焦于相关元素,并降低不相关数值的影响。
5. 接下来,把每个元素的所有调整后的 Value 向量汇总。 该操作的结果将是“自关注”层的输出值向量。
每层的迭代结果被添加到输入序列当中,并遵照公式进行常规化。
[ 11 ] 文章中更详细地讨论了神经网络层的常规化。
3. 实现
我建议在我们的实现中采用“自关注”机制。 我们来研究实现选项。
3.1. 升级卷积层
我们从自关注算法的第一个动作开始 — 计算 Query、Key 和 Value 向量。 输入一个数据矩阵,其内包含所分析序列每根柱线特征。 逐根提取烛条的特征,然后将它们乘以权重矩阵,从而得到一个向量。 在我看来,这类似于文章[ 3 ] 中研究的卷积层。 然而,在这种情况下,输出不是数字,而是固定大小的向量。 为了解决这一难题,我们升级负责神经网络卷积层操作的 CNeuronConvOCL 类。 加入 iWindowOut 变量来存储输出向量的尺寸。 在类方法中实现相应的修改。
class CNeuronConvOCL : public CNeuronProofOCL { protected: uint iWindowOut; //--- CBufferDouble *WeightsConv; CBufferDouble *DeltaWeightsConv; CBufferDouble *FirstMomentumConv; CBufferDouble *SecondMomentumConv; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); public: CNeuronConvOCL(void) : iWindowOut(1) { activation=LReLU; } ~CNeuronConvOCL(void); virtual bool Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl,uint window, uint step, uint window_out, uint units_count, ENUM_OPTIMIZATION optimization_type); //--- virtual bool SetGradientIndex(int index) { return Gradient.BufferSet(index); } //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); virtual int Type(void) const { return defNeuronConvOCL; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); };
在 OpenCL 内核 FeedForwardConv 之中,添加一个获取输出矢量尺寸的参数。 另外,在卷积层输出之处,在通用向量中添加输出向量已处理片段的偏移量计算,并实现额外的循环遍历输出层元素。
__kernel void FeedForwardConv(__global double *matrix_w, __global double *matrix_i, __global double *matrix_o, int inputs, int step, int window_in, int window_out, uint activation) { int i=get_global_id(0); int w_in=window_in; int w_out=window_out; double sum=0.0; double4 inp, weight; int shift_out=w_out*i; int shift_in=step*i; for(int out=0;out<w_out;out++) { int shift=(w_in+1)*out; int stop=(w_in<=(inputs-shift_in) ? w_in : (inputs-shift_in)); for(int k=0; k<=stop; k=k+4) { switch(stop-k) { case 0: inp=(double4)(1,0,0,0); weight=(double4)(matrix_w[shift+k],0,0,0); break; case 1: inp=(double4)(matrix_i[shift_in+k],1,0,0); weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],0,0); break; case 2: inp=(double4)(matrix_i[shift_in+k],matrix_i[shift_in+k+1],1,0); weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],0); break; case 3: inp=(double4)(matrix_i[shift_in+k],matrix_i[shift_in+k+1],matrix_i[shift_in+k+2],1); weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]); break; default: inp=(double4)(matrix_i[shift_in+k],matrix_i[shift_in+k+1],matrix_i[shift_in+k+2],matrix_i[shift_in+k+3]); weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]); break; } sum+=dot(inp,weight); } switch(activation) { case 0: sum=tanh(sum); break; case 1: sum=1/(1+exp(-clamp(sum,-50.0,50.0))); break; case 2: if(sum<0) sum*=0.01; break; default: break; } matrix_o[out+shift_out]=sum; } }
请不要忘记,调用该内核时要启用传递附加参数。
bool CNeuronConvOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(OpenCL)==POINTER_INVALID || CheckPointer(NeuronOCL)==POINTER_INVALID) return false; uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=Output.Total()/iWindowOut; OpenCL.SetArgumentBuffer(def_k_FeedForwardConv,def_k_ffc_matrix_w,WeightsConv.GetIndex()); OpenCL.SetArgumentBuffer(def_k_FeedForwardConv,def_k_ffc_matrix_i,NeuronOCL.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_FeedForwardConv,def_k_ffc_matrix_o,Output.GetIndex()); OpenCL.SetArgument(def_k_FeedForwardConv,def_k_ffc_inputs,NeuronOCL.Neurons()); OpenCL.SetArgument(def_k_FeedForwardConv,def_k_ffc_step,iStep); OpenCL.SetArgument(def_k_FeedForwardConv,def_k_ffc_window_in,iWindow); OpenCL.SetArgument(def_k_FeedForwardConv,def_k_ffс_window_out,iWindowOut); OpenCL.SetArgument(def_k_FeedForwardConv,def_k_ffc_activation,(int)activation); if(!OpenCL.Execute(def_k_FeedForwardConv,1,global_work_offset,global_work_size)) { printf("Error of execution kernel FeedForwardProof: %d",GetLastError()); return false; } //--- return Output.BufferRead(); }
类似的修改已在内核,重计算梯度的方法 (calcInputGradients) 和更新权重矩阵的方法 (updateInputWeights) 里实现。 附件中提供了所有方法和函数的完整代码。
3.2. 自关注模块类
现在,我们继续实现自关注方法本身。 为了对其进行描述,创建 CNeuronAttentionOCL 类。 由于我们的所有操作均要针对每个元素重复执行,并独立执行,因此我们将其中一些操作移至更时髦的卷积层当中。 在我们的关注模块内,创建卷积层 Querys,Key,Values,它将负责创建相应的向量,以及传递梯度,并更新权重矩阵。 前馈模块也采用卷积层 FF1 和 FF2 来实现。 Score 矩阵数值将保存到 Score 缓冲区中;关注方法的结果将保存到基类 AttentionOut 的内部神经元层当中。
此处,请注意关注算法的输出与整个 Self-Attention 类的输出之间的差异。 前一个发生在调整 Value 向量数值并执行 Self-Attention 算法之后;它已被保存在 AttentionOut 当中。 第二个是在处理 FeedForward 之后获得的 - 它被保存在基类的 Output 缓冲区中。
class CNeuronAttentionOCL : public CNeuronBaseOCL { protected: CNeuronConvOCL *Querys; CNeuronConvOCL *Keys; CNeuronConvOCL *Values; CBufferDouble *Scores; CNeuronBaseOCL *AttentionOut; CNeuronConvOCL *FF1; CNeuronConvOCL *FF2; //--- uint iWindow; uint iUnits; //--- virtual bool feedForward(CNeuronBaseOCL *prevLayer); virtual bool updateInputWeights(CNeuronBaseOCL *prevLayer); public: CNeuronAttentionOCL(void) : iWindow(1), iUnits(0) {}; ~CNeuronAttentionOCL(void); virtual bool Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl, uint window, uint units_count, ENUM_OPTIMIZATION optimization_type); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); //--- virtual int Type(void) const { return defNeuronAttentionOCL; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); };
在变量 iWindows 和 iUnits 中,我们分别保存输出窗口的大小,和输出序列中的元素数。
该类将在 Init 方法中被初始化。 该方法将从参数中接收元素的序号,指向 COpenCL 对象的指针,窗口大小,元素数和优化方法。 在方法的开头,调用父类的相关方法。
bool CNeuronAttentionOCL::Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl,uint window,uint units_count,ENUM_OPTIMIZATION optimization_type) { if(!CNeuronBaseOCL::Init(numOutputs,myIndex,open_cl,units_count*window,optimization_type)) return false;
然后,声明并初始化卷积网络类的实例,以便计算 Querys,Keys 和 Values 向量。
//--- if(CheckPointer(Querys)==POINTER_INVALID) { Querys=new CNeuronConvOCL(); if(CheckPointer(Querys)==POINTER_INVALID) return false; if(!Querys.Init(0,0,open_cl,window,window,window,units_count,optimization_type)) return false; Querys.SetActivationFunction(TANH); } //--- if(CheckPointer(Keys)==POINTER_INVALID) { Keys=new CNeuronConvOCL(); if(CheckPointer(Keys)==POINTER_INVALID) return false; if(!Keys.Init(0,1,open_cl,window,window,window,units_count,optimization_type)) return false; Keys.SetActivationFunction(TANH); } //--- if(CheckPointer(Values)==POINTER_INVALID) { Values=new CNeuronConvOCL(); if(CheckPointer(Values)==POINTER_INVALID) return false; if(!Values.Init(0,2,open_cl,window,window,window,units_count,optimization_type)) return false; Values.SetActivationFunction(None); }
进而在算法中,声明 Scores 缓冲区。 注意缓冲区的大小 - 缓冲区必须有足够的内存来存储边长等于序列元素数量的方形矩阵。
if(CheckPointer(Scores)==POINTER_INVALID) { Scores=new CBufferDouble(); if(CheckPointer(Scores)==POINTER_INVALID) return false; } if(!Scores.BufferInit(units_count*units_count,0.0)) return false; if(!Scores.BufferCreate(OpenCL)) return false;
此外,声明神经元的 AttentionOut 层。 这个层将用作存储“自关注”结果的缓冲区。 于此同时,它将用作 FeedForward 模块的输入层。 其大小等于窗口宽度乘以元素数的乘积。
if(CheckPointer(AttentionOut)==POINTER_INVALID) { AttentionOut=new CNeuronBaseOCL(); if(CheckPointer(AttentionOut)==POINTER_INVALID) return false; if(!AttentionOut.Init(0,3,open_cl,window*units_count,optimization_type)) return false; AttentionOut.SetActivationFunction(None); }
初始两个化卷积层的实例,以便实现 FeedForward 模块。 请注意,第一个实例(隐藏层)输出的窗口有 2 倍宽,并具有 LReLU 激活函数(带有“泄漏”的 ReLU)。 至于于第二层(FF2),采用 SetGradientIndex 方法将梯度缓冲区替换为父类的梯度缓冲区。 通过复制缓冲区,我们剔除了复制数据的需求。
if(CheckPointer(FF1)==POINTER_INVALID) { FF1=new CNeuronConvOCL(); if(CheckPointer(FF1)==POINTER_INVALID) return false; if(!FF1.Init(0,4,open_cl,window,window,window*2,units_count,optimization_type)) return false; FF1.SetActivationFunction(LReLU); } //--- if(CheckPointer(FF2)==POINTER_INVALID) { FF2=new CNeuronConvOCL(); if(CheckPointer(FF2)==POINTER_INVALID) return false; if(!FF2.Init(0,5,open_cl,window*2,window*2,window,units_count,optimization_type)) return false; FF2.SetActivationFunction(None); FF2.SetGradientIndex(Gradient.GetIndex()); }
在方法末尾保存关键参数。
iWindow=window; iUnits=units_count; activation=FF2.Activation(); //--- return true; }
3.3. 自关注前馈
接下来,我们研究 CNeuronAttentionOCL 类的 feedForward 方法。 该方法从参数里接收指向前一层神经网络的指针。 因此,首先,检查所收指针的有效性。
bool CNeuronAttentionOCL::feedForward(CNeuronBaseOCL *prevLayer) { if(CheckPointer(prevLayer)==POINTER_INVALID) return false;
在深入处理数据之前,把输入数据常规化。 作者并未提供“自关注”机制的这一步。 不过,我基于测试结果而添加了它,以防止在 Score 矩阵常规化阶段溢出。 已创建了一个特殊的内核来常规化数据。 在 feedForward 方法中调用它。
{ uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=1; OpenCL.SetArgumentBuffer(def_k_Normilize,def_k_norm_buffer,prevLayer.getOutputIndex()); OpenCL.SetArgument(def_k_Normilize,def_k_norm_dimension,prevLayer.Neurons()); if(!OpenCL.Execute(def_k_Normilize,1,global_work_offset,global_work_size)) { printf("Error of execution kernel Normalize: %d",GetLastError()); return false; } if(!prevLayer.Output.BufferRead()) return false; }
我们看一看常规化内核。 在内核的开头,计算常规化序列第一个元素的偏移量。 然后,我们计算常规化序列和标准偏差的平均值。 在内核末尾,更新缓冲区中的数据。
__kernel void Normalize(__global double *buffer, int dimension) { int n=get_global_id(0); int shift=n*dimension; double mean=0; for(int i=0;i<dimension;i++) mean+=buffer[shift+i]; mean/=dimension; double variance=0; for(int i=0;i<dimension;i++) variance+=pow(buffer[shift+i]-mean,2); variance=sqrt(variance/dimension); for(int i=0;i<dimension;i++) buffer[shift+i]=(buffer[shift+i]-mean)/(variance==0 ? 1 : variance); }
源数据常规化之后,计算 Querys、Keys 和 Values 向量。 为此,调用卷积层类相应实例的 FeedForward 方法(之前已研究过该方法)。
if(CheckPointer(Querys)==POINTER_INVALID || !Querys.FeedForward(prevLayer)) return false; if(CheckPointer(Keys)==POINTER_INVALID || !Keys.FeedForward(prevLayer)) return false; if(CheckPointer(Values)==POINTER_INVALID || !Values.FeedForward(prevLayer)) return false;
沿着自关注算法深入前进,计算 Score 矩阵。 在 GPU 上运行 OpenCL 执行计算。 在主程序方法中实现内核调用。 被调用的线程数等于该类中的单元数。 每个线程将按其窗口大小操作。 换言之,每个线程取其自身一个元素的 Query 向量,并将其与序列中所有元素的 Key 向量进行匹配。
{ uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_AttentionScore,def_k_as_querys,Querys.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionScore,def_k_as_keys,Keys.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionScore,def_k_as_score,Scores.GetIndex()); OpenCL.SetArgument(def_k_AttentionScore,def_k_as_dimension,iWindow); if(!OpenCL.Execute(def_k_AttentionScore,1,global_work_offset,global_work_size)) { printf("Error of execution kernel AttentionScore: %d",GetLastError()); return false; } if(!Scores.BufferRead()) return false; }
在内核的开头,利用 'querys' 和 'score' 数组判定初始元素的偏移量。 计算系数来减少获得的数值。 常规化数值时,我们需要把变量归零,以便计算数量。 接着,实现一个循环遍历 Key 矩阵的所有元素,同时计算相应的依赖性。 请注意,我们正在研究的内核,结合了得分矩阵的计算和常规化阶段。 因此,在计算 Query 和 Key 向量的乘积之后,将结果值除以系数,并计算所获数值的指数。 所得的指数应保存在矩阵中,并加到总和当中。 在循环的末尾,实现第二个循环,在其中,前一个循环中保存的所有数值均除以计算出的指数之和。 内核输出将包含计算出的常规化 Score 矩阵。
__kernel void AttentionScore(__global double *querys, __global double *keys, __global double *score, int dimension) { int q=get_global_id(0); int shift_q=q*dimension; int units=get_global_size(0); int shift_s=q*units; double koef=sqrt((double)(units*dimension)); if(koef<1) koef=1; double sum=0; for(int k=0;k<units;k++) { double result=0; int shift_k=k*dimension; for(int i=0;i<dimension;i++) result+=(querys[shift_q+i]*keys[shift_k+i]); result=exp(result/koef); score[shift_s+k]=result; sum+=result; } for(int k=0;k<units;k++) score[shift_s+k]/=sum; }
我们继续研究自关注算法。 Score 矩阵进行常规化之后,有必要针对获得的数值调整 Values 向量,并在输入序列元素的上下文中对所获向量求和。 在“自关注”模块的输出中,将所获数值累计后加到输入序列之中。 所有这些迭代均会被合并到下一个 AttentionOut 内核中。 在主程序代码中实现内核调用。 请注意,该内核将通过两种方式与一组线程共同运行:按序列的元素(iUnits),和按每个元素的特征值(iWindow)。 结果值将保存到 AttentionOut 层的输出缓冲区。
{ uint global_work_offset[2]={0,0}; uint global_work_size[2]; global_work_size[0]=iUnits; global_work_size[1]=iWindow; OpenCL.SetArgumentBuffer(def_k_AttentionOut,def_k_aout_scores,Scores.GetIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionOut,def_k_aout_inputs,prevLayer.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionOut,def_k_aout_values,Values.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionOut,def_k_aout_out,AttentionOut.getOutputIndex()); if(!OpenCL.Execute(def_k_AttentionOut,2,global_work_offset,global_work_size)) { printf("Error of execution kernel Attention Out: %d",GetLastError()); return false; } double temp[]; if(!AttentionOut.getOutputVal(temp)) return false; }
在内核主体中,判定输入和输出序列得向量中所处理元素的偏移量。 然后,组织一个循环,Scores 与相应 Value 数值相乘并求和。 一旦循环迭代完成,就将求和结果加到从神经网络的上一层接收到的输入向量之中。 将结果写入输出缓冲区。
__kernel void AttentionOut(__global double *scores, __global double *values, __global double *inputs, __global double *out) { int units=get_global_size(0); int u=get_global_id(0); int d=get_global_id(1); int dimension=get_global_size(1); int shift=u*dimension+d; double result=0; for(int i=0;i<units;i++) result+=scores[u*units+i]*values[i*dimension+d]; out[shift]=result+inputs[shift]; }
至此,可认为“自关注”算法完成。 现在,我们只需要使用上述方法对结果数据进行常规化。 唯一的区别在于常规化缓冲区。
{ uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=1; OpenCL.SetArgumentBuffer(def_k_Normilize,def_k_norm_buffer,AttentionOut.getOutputIndex()); OpenCL.SetArgument(def_k_Normilize,def_k_norm_dimension,AttentionOut.Neurons()); if(!OpenCL.Execute(def_k_Normilize,1,global_work_offset,global_work_size)) { printf("Error of execution kernel Normalize: %d",GetLastError()); return false; } double temp[]; if(!AttentionOut.getOutputVal(temp)) return false; }
进而,根据 Transformer 编码器算法,我们将序列的每个元素通过一个完全连接的隐藏层神经网络传递。 在此过程中,将相同的权重矩阵应用于序列的所有元素。 我已利用时髦的的卷积层类实现了此过程。 在方法代码中,我依次调用卷积类相应实例的 FeedForward 方法。
if(!FF1.FeedForward(AttentionOut)) return false; if(!FF2.FeedForward(FF1)) return false;
为了完成前馈过程,必须将完全连接网络传递的结果与自关注机制的结果相加。 为此目的,我创建了两个向量得附加内核,在前馈方法的末尾调用该内核。
{ uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix1,AttentionOut.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix2,FF2.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix_out,Output.GetIndex()); OpenCL.SetArgument(def_k_MatrixSum,def_k_sum_dimension,iWindow); if(!OpenCL.Execute(def_k_MatrixSum,1,global_work_offset,global_work_size)) { printf("Error of execution kernel MatrixSum: %d",GetLastError()); return false; } if(!Output.BufferRead()) return false; } //--- return true; }
在内核内部组织一个简单的循环,在其中对传入的矢量值逐元素求和。
__kernel void SumMatrix(__global double *matrix1, __global double *matrix2, __global double *matrix_out, int dimension) { const int i=get_global_id(0)*dimension; for(int k=0;k<dimension;k++) matrix_out[i+k]=matrix1[i+k]+matrix2[i+k]; }
附件中提供了所有方法和函数的完整代码。
3.4. 自关注反馈
前馈推算后接反馈,在此期间将误差将被馈送到神经网络的较低层次,并调整权重矩阵来选择最佳结果。 该类使用文章 5 中所述的父类方法,接收来自神经网络上一个完全连接层的误差梯度。 用于馈送误差梯度的深层机制需要进行重大改进,这是鉴于内部架构的复杂性。
为了将误差梯度传递到内部卷积层和先前的网络神经层,我们来创建 calcInputGradients 方法。 该方法从参数中接收指向前一层神经元的指针。 与往常一样,首先要检查所接收指针的有效性。 然后,以相反的顺序依次调用前馈 FF2 和 FF1 模块的卷积层方法。 我们使用缓冲区替换,因此内部 FF2 层使用父类的方法直接从下一个神经网络层接收误差梯度。
bool CNeuronAttentionOCL::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(CheckPointer(prevLayer)==POINTER_INVALID) return false; //--- if(!FF2.calcInputGradients(FF1)) return false; if(!FF1.calcInputGradients(AttentionOut)) return false;
鉴于在前馈推算的输出里,我们汇总了前馈和自关注的结果,因此误差梯度也来自两个分支。 因此,从 FF1 获得的误差梯度应与从下一神经网络层获得的误差梯度相加。 向量求和内核如上所述。 因此,我们加上其调用。
{ uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix1,AttentionOut.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix2,Gradient.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix_out,AttentionOut.getGradientIndex()); OpenCL.SetArgument(def_k_MatrixSum,def_k_sum_dimension,iWindow); if(!OpenCL.Execute(def_k_MatrixSum,1,global_work_offset,global_work_size)) { printf("Error of execution kernel MatrixSum: %d",GetLastError()); return false; } double temp[]; if(AttentionOut.getGradient(temp)<=0) return false; }
在下一步中,将误差梯度散播到 Querys、Keys 和 Values。 误差梯度将传递给 AttentionIsideGradients 内核中的向量。 在下面的方法中,利用二维的一组线程来调用它。
{ uint global_work_offset[2]={0,0}; uint global_work_size[2]; global_work_size[0]=iUnits; global_work_size[1]=iWindow; OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_gradient,AttentionOut.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_keys,Keys.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_keys_g,Keys.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_querys,Querys.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_querys_g,Querys.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_values,Values.getOutputIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_values_g,Values.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_AttentionGradients,def_k_ag_scores,Scores.GetIndex()); if(!OpenCL.Execute(def_k_AttentionGradients,2,global_work_offset,global_work_size)) { printf("Error of execution kernel AttentionGradients: %d",GetLastError()); return false; } double temp[]; if(Keys.getGradient(temp)<=0) return false; }
内核从参数中接收指向数据缓冲区的指针。 维度是在内核开始时由线程数量,或运行线程确定的。 然后我们计算校正因子,并遍历序列的所有元素。 在循环内部,我们首先计算 Value 向量上的误差梯度乘以相应 Score 向量上的误差梯度。 请注意,误差梯度要除以 2。 这是因为我们在上一步中对其进行了汇总,从而令误差加倍。 现在我们将其除以 2 得到平均值。
__kernel void AttentionIsideGradients(__global double *querys,__global double *querys_g, __global double *keys,__global double *keys_g, __global double *values,__global double *values_g, __global double *scores, __global double *gradient) { int u=get_global_id(0); int d=get_global_id(1); int units=get_global_size(0); int dimension=get_global_size(1); double koef=sqrt((double)(units*dimension)); if(koef<1) koef=1; //--- double vg=0; double qg=0; double kg=0; for(int iu=0;iu<units;iu++) { double g=gradient[iu*dimension+d]/2; double sc=scores[iu*units+u]; vg+=sc*g;
接着,组织一个嵌套循环来定义 Score 矩阵元素上的梯度。 之后,计算 Querys 和 Keys 向量的元素梯度。 在外部循环末尾,将计算出的梯度分配给相应的全局缓冲区。
//--- double sqg=0; double skg=0; for(int id=0;id<dimension;id++) { sqg+=values[iu*dimension+id]*gradient[u*dimension+id]/2; skg+=values[u*dimension+id]*gradient[iu*dimension+id]/2; } qg+=(scores[u*units+iu]==0 || scores[u*units+iu]==1 ? 0.0001 : scores[u*units+iu]*(1-scores[u*units+iu]))*sqg*keys[iu*dimension+d]/koef; //--- kg+=(scores[iu*units+u]==0 || scores[iu*units+u]==1 ? 0.0001 : scores[iu*units+u]*(1-scores[iu*units+u]))*skg*querys[iu*dimension+d]/koef; } int shift=u*dimension+d; values_g[shift]=vg; querys_g[shift]=qg; keys_g[shift]=kg; }
接下来,我们必须推算来自 Querys、Keys 和 Values 向量的误差梯度。 请注意,由于所有向量都是相同初始数据乘以不同矩阵而获得的,因此误差梯度也应该累加。 我没有为累加误差梯度单独分配缓冲区。 不过,在计算梯度时累加值需要附加复杂的代码,以及缓冲区归零跟踪。 我决定使用现有方法来计算误差梯度,并进一步累加 AttentionOut 层的梯度缓冲区中的数值。
if(!Querys.calcInputGradients(prevLayer)) return false; //--- { uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix1,AttentionOut.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix2,prevLayer.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix_out,AttentionOut.getGradientIndex()); OpenCL.SetArgument(def_k_MatrixSum,def_k_sum_dimension,iWindow); if(!OpenCL.Execute(def_k_MatrixSum,1,global_work_offset,global_work_size)) { printf("Error of execution kernel MatrixSum: %d",GetLastError()); return false; } double temp[]; if(AttentionOut.getGradient(temp)<=0) return false; } //--- if(!Keys.calcInputGradients(prevLayer)) return false; //--- { uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix1,AttentionOut.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix2,prevLayer.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix_out,AttentionOut.getGradientIndex()); OpenCL.SetArgument(def_k_MatrixSum,def_k_sum_dimension,iWindow); if(!OpenCL.Execute(def_k_MatrixSum,1,global_work_offset,global_work_size)) { printf("Error of execution kernel MatrixSum: %d",GetLastError()); return false; } double temp[]; if(AttentionOut.getGradient(temp)<=0) return false; } //--- if(!Values.calcInputGradients(prevLayer)) return false; //--- { uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=iUnits; OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix1,AttentionOut.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix2,prevLayer.getGradientIndex()); OpenCL.SetArgumentBuffer(def_k_MatrixSum,def_k_sum_matrix_out,prevLayer.getGradientIndex()); OpenCL.SetArgument(def_k_MatrixSum,def_k_sum_dimension,iWindow+1); if(!OpenCL.Execute(def_k_MatrixSum,1,global_work_offset,global_work_size)) { printf("Error of execution kernel MatrixSum: %d",GetLastError()); return false; } double temp[]; if(prevLayer.getGradient(temp)<=0) return false; } //--- { uint global_work_offset[1]={0}; uint global_work_size[1]; global_work_size[0]=1; OpenCL.SetArgumentBuffer(def_k_Normilize,def_k_norm_buffer,prevLayer.getGradientIndex()); OpenCL.SetArgument(def_k_Normilize,def_k_norm_dimension,prevLayer.Neurons()); if(!OpenCL.Execute(def_k_Normilize,1,global_work_offset,global_work_size)) { printf("Error of execution kernel Normalize: %d",GetLastError()); return false; } double temp[]; if(prevLayer.getGradient(temp)<=0) return false; } //--- return true; }
误差梯度输入到之前的层级后,利用 updateInputWeights 方法校正权重矩阵。 该方法十分简单。 它调用嵌套卷积层的相应方法。
bool CNeuronAttentionOCL::updateInputWeights(CNeuronBaseOCL *prevLayer) { if(!Querys.UpdateInputWeights(prevLayer)) return false; if(!Keys.UpdateInputWeights(prevLayer)) return false; if(!Values.UpdateInputWeights(prevLayer)) return false; if(!FF1.UpdateInputWeights(AttentionOut)) return false; if(!FF2.UpdateInputWeights(FF1)) return false; //--- return true; }
3.5. 神经网络基类的变化
我们已经完成了与关注模块类相关的工作。 现在,我们针对神经网络基类进行一些补充。 首先,将常量添加到 define 模块中,以便操控新内核。
#define def_k_FeedForwardConv 7 #define def_k_ffc_matrix_w 0 #define def_k_ffc_matrix_i 1 #define def_k_ffc_matrix_o 2 #define def_k_ffc_inputs 3 #define def_k_ffc_step 4 #define def_k_ffc_window_in 5 #define def_k_ffс_window_out 6 #define def_k_ffc_activation 7 //--- #define def_k_CalcHiddenGradientConv 8 #define def_k_chgc_matrix_w 0 #define def_k_chgc_matrix_g 1 #define def_k_chgc_matrix_o 2 #define def_k_chgc_matrix_ig 3 #define def_k_chgc_outputs 4 #define def_k_chgc_step 5 #define def_k_chgc_window_in 6 #define def_k_chgc_window_out 7 #define def_k_chgc_activation 8 //--- #define def_k_UpdateWeightsConvMomentum 9 #define def_k_uwcm_matrix_w 0 #define def_k_uwcm_matrix_g 1 #define def_k_uwcm_matrix_i 2 #define def_k_uwcm_matrix_dw 3 #define def_k_uwcm_inputs 4 #define def_k_uwcm_learning_rates 5 #define def_k_uwcm_momentum 6 #define def_k_uwcm_window_in 7 #define def_k_uwcm_window_out 8 #define def_k_uwcm_step 9 //--- #define def_k_UpdateWeightsConvAdam 10 #define def_k_uwca_matrix_w 0 #define def_k_uwca_matrix_g 1 #define def_k_uwca_matrix_i 2 #define def_k_uwca_matrix_m 3 #define def_k_uwca_matrix_v 4 #define def_k_uwca_inputs 5 #define def_k_uwca_l 6 #define def_k_uwca_b1 7 #define def_k_uwca_b2 8 #define def_k_uwca_window_in 9 #define def_k_uwca_window_out 10 #define def_k_uwca_step 11 //--- #define def_k_AttentionScore 11 #define def_k_as_querys 0 #define def_k_as_keys 1 #define def_k_as_score 2 #define def_k_as_dimension 3 //--- #define def_k_AttentionOut 12 #define def_k_aout_scores 0 #define def_k_aout_values 1 #define def_k_aout_inputs 2 #define def_k_aout_out 3 //--- #define def_k_MatrixSum 13 #define def_k_sum_matrix1 0 #define def_k_sum_matrix2 1 #define def_k_sum_matrix_out 2 #define def_k_sum_dimension 3 //--- #define def_k_AttentionGradients 14 #define def_k_ag_querys 0 #define def_k_ag_querys_g 1 #define def_k_ag_keys 2 #define def_k_ag_keys_g 3 #define def_k_ag_values 4 #define def_k_ag_values_g 5 #define def_k_ag_scores 6 #define def_k_ag_gradient 7 //--- #define def_k_Normilize 15 #define def_k_norm_buffer 0 #define def_k_norm_dimension 1
此外,添加神经元新类的常量。
#define defNeuronAttentionOCL 0x7887
在描述神经网络各层的 CLayerDescription 类中,添加一个字段,用于在输出向量窗口中指定神经元的数量。
class CLayerDescription : public CObject { public: CLayerDescription(void); ~CLayerDescription(void) {}; //--- int type; int count; int window; int window_out; int step; ENUM_ACTIVATION activation; ENUM_OPTIMIZATION optimization; };
在 CNet 神经网络类的构造函数中,添加新类来初始化操控 OpenCL 的类实例。
CNet::CNet(CArrayObj *Description) { if(CheckPointer(Description)==POINTER_INVALID) return; //--- .......... .......... .......... //--- next=Description.At(1); if(next.type==defNeuron || next.type==defNeuronBaseOCL || next.type==defNeuronConvOCL || next.type==defNeuronAttentionOCL) { opencl=new COpenCLMy(); if(CheckPointer(opencl)!=POINTER_INVALID && !opencl.Initialize(cl_program,true)) delete opencl; } else { if(CheckPointer(opencl)!=POINTER_INVALID) delete opencl; }
进而在构造函数主体中,添加代码来初始化关注神经元的新类。
if(CheckPointer(opencl)!=POINTER_INVALID) { CNeuronBaseOCL *neuron_ocl=NULL; CNeuronConvOCL *neuron_conv_ocl=NULL; CNeuronAttentionOCL *neuron_attention_ocl=NULL; switch(desc.type) { case defNeuron: case defNeuronBaseOCL: neuron_ocl=new CNeuronBaseOCL(); if(CheckPointer(neuron_ocl)==POINTER_INVALID) { delete temp; return; } if(!neuron_ocl.Init(outputs,0,opencl,desc.count,desc.optimization)) { delete neuron_ocl; delete temp; return; } neuron_ocl.SetActivationFunction(desc.activation); if(!temp.Add(neuron_ocl)) { delete neuron_ocl; delete temp; return; } neuron_ocl=NULL; break; case defNeuronConvOCL: neuron_conv_ocl=new CNeuronConvOCL(); if(CheckPointer(neuron_conv_ocl)==POINTER_INVALID) { delete temp; return; } if(!neuron_conv_ocl.Init(outputs,0,opencl,desc.window,desc.step,desc.window_out,desc.count,desc.optimization)) { delete neuron_conv_ocl; delete temp; return; } neuron_conv_ocl.SetActivationFunction(desc.activation); if(!temp.Add(neuron_conv_ocl)) { delete neuron_conv_ocl; delete temp; return; } neuron_conv_ocl=NULL; break; case defNeuronAttentionOCL: neuron_attention_ocl=new CNeuronAttentionOCL(); if(CheckPointer(neuron_attention_ocl)==POINTER_INVALID) { delete temp; return; } if(!neuron_attention_ocl.Init(outputs,0,opencl,desc.window,desc.count,desc.optimization)) { delete neuron_attention_ocl; delete temp; return; } neuron_attention_ocl.SetActivationFunction(desc.activation); if(!temp.Add(neuron_attention_ocl)) { delete neuron_attention_ocl; delete temp; return; } neuron_attention_ocl=NULL; break; default: return; break; } }
在构造函数的末尾添加新内核的初始化。
if(CheckPointer(opencl)==POINTER_INVALID) return; //--- create kernels opencl.SetKernelsCount(16); opencl.KernelCreate(def_k_FeedForward,"FeedForward"); opencl.KernelCreate(def_k_CalcOutputGradient,"CalcOutputGradient"); opencl.KernelCreate(def_k_CalcHiddenGradient,"CalcHiddenGradient"); opencl.KernelCreate(def_k_UpdateWeightsMomentum,"UpdateWeightsMomentum"); opencl.KernelCreate(def_k_UpdateWeightsAdam,"UpdateWeightsAdam"); opencl.KernelCreate(def_k_AttentionGradients,"AttentionIsideGradients"); opencl.KernelCreate(def_k_AttentionOut,"AttentionOut"); opencl.KernelCreate(def_k_AttentionScore,"AttentionScore"); opencl.KernelCreate(def_k_CalcHiddenGradientConv,"CalcHiddenGradientConv"); opencl.KernelCreate(def_k_CalcInputGradientProof,"CalcInputGradientProof"); opencl.KernelCreate(def_k_FeedForwardConv,"FeedForwardConv"); opencl.KernelCreate(def_k_FeedForwardProof,"FeedForwardProof"); opencl.KernelCreate(def_k_MatrixSum,"SumMatrix"); opencl.KernelCreate(def_k_UpdateWeightsConvAdam,"UpdateWeightsConvAdam"); opencl.KernelCreate(def_k_UpdateWeightsConvMomentum,"UpdateWeightsConvMomentum"); opencl.KernelCreate(def_k_Normilize,"Normalize"); //--- return; }
在 CNeuronBase 类的派发器方法中添加神经元新类的处理。
bool CNeuronBaseOCL::FeedForward(CObject *SourceObject) { if(CheckPointer(SourceObject)==POINTER_INVALID) return false; //--- CNeuronBaseOCL *temp=NULL; switch(SourceObject.Type()) { case defNeuronBaseOCL: case defNeuronConvOCL: case defNeuronAttentionOCL: temp=SourceObject; return feedForward(temp); break; } //--- return false; } bool CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject) { if(CheckPointer(TargetObject)==POINTER_INVALID) return false; //--- CNeuronBaseOCL *temp=NULL; CNeuronAttentionOCL *at=NULL; CNeuronConvOCL *conv=NULL; switch(TargetObject.Type()) { case defNeuronBaseOCL: temp=TargetObject; return calcHiddenGradients(temp); break; case defNeuronConvOCL: conv=TargetObject; temp=GetPointer(this); return conv.calcInputGradients(temp); break; case defNeuronAttentionOCL: at=TargetObject; temp=GetPointer(this); return at.calcInputGradients(temp); break; } //--- return false; }
附件中提供了所有方法和函数的完整代码。
4. 测试
上述所有修改完成后,我们可在神经网络里加入新的神经元类,并测试新的体系结构。 我已创建了一个名为 Fractal_OCL_Attention 的测试 EA,它与以前的 EA 相比仅在神经网络的体系结构上有所不同。 同样,第一层由写入初始数据的基本神经元组成,每根历史柱线包含 12 个特征值。 第二层则声明为含有希格玛激活函数,和 36 个神经元输出窗口的改良卷积层。 该层执行嵌入函数,并常规化原始数据。 接下来是带有自关注机制的两层编码器。 三个完全连接的神经元层完善了神经网络。
CLayerDescription *desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=(int)HistoryBars*12; desc.type=defNeuronBaseOCL; desc.optimization=ADAM; desc.activation=TANH; if(!Topology.Add(desc)) return INIT_FAILED; //--- desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=(int)HistoryBars; desc.type=defNeuronConvOCL; desc.window=12; desc.step=12; desc.window_out=36; desc.optimization=ADAM; desc.activation=SIGMOID; if(!Topology.Add(desc)) return INIT_FAILED; //--- bool result=true; for(int i=0; (i<2 && result); i++) { desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=(int)HistoryBars; desc.type=defNeuronAttentionOCL; desc.window=36; desc.optimization=ADAM; desc.activation=None; result=Topology.Add(desc); } if(!result) { delete Topology; return INIT_FAILED; } //--- desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=200; desc.type=defNeuron; desc.activation=TANH; desc.optimization=ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=200; desc.type=defNeuron; desc.activation=TANH; desc.optimization=ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) return INIT_FAILED; desc.count=3; desc.type=defNeuron; desc.activation=SIGMOID; desc.optimization=ADAM; if(!Topology.Add(desc)) return INIT_FAILED;
完整的 EA 代码可在附件中找到。
我在相同条件下执行了 EA 测试:EURUSD,H1 时间帧,连续 20 根烛条的数据馈入网络,并采用过去两年的历史进行训练,参数更新则利用 Adam 方法。
智能交易系统初始化时的随机权重为 -1 到 1,不包括零值。 经历 25 个迭代的测试后,EA 展现出 35-36% 的误差,命中率为 22-23%
结束语
在本文中,我们研究关注机制。 我们已创建了一个“自关注”模块,并依据历史数据对其进行了测试。 最终的智能交易系统在减少神经网络操作误差,以及“命中”预测结果方面展现出相当平滑的结果。 获得的结果表明,这种方式可以实用。 不过,需要额外的工作来改善结果。 作为深入开发选项,您可以考虑采用若干个权重不同的并行关注线程。 在文章 10 里,这种方式称为“多重关注”。
参考
- 神经网络变得轻松
- 神经网络变得轻松(第二部分):网络训练和测试
- 神经网络变得轻松(第三部分):卷积网络
- 神经网络变得轻松(第四部分):循环网络
- 神经网络变得轻松(第五部分):OpenCL 中的多线程计算
- 神经网络变得轻松(第六部分):神经网络学习率实验
- 神经网络变得轻松(第七部分):自适应优化方法
- 通过共同学习对齐和翻译的神经机器翻译
- 基于关注神经机器翻译的有效方法
- 关注就是您所需要的全部
- 层常规化
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Fractal_OCL_Attention.mq5 | 智能交易系统 | 含有采用自关注机制的分类神经网络(输出层中有 3 个神经元)的智能交易系统 |
2 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
3 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/8765