![神经网络变得轻松(第二十二部分):递归模型的无监督学习](https://c.mql5.com/2/49/Neural_Networks_Easy_014_600x314.jpg)
神经网络变得轻松(第二十二部分):递归模型的无监督学习
内容
概述
在我们系列文章的最后两篇里专门讨论了自动编码器。 它们的体系架构令反向传播算法基于未标记的数据上训练各种神经网络模型成为可能。 该模型学习在选择主要特征时压缩初始数据。 我们的实验已确认了自动编码器模型的有效性。 请注意,我们采用的是完全连接的神经层来训练自动编码器。 此类模型有固定的输入数据窗口。 我们构建完成的算法可以训练采用固定输入数据窗口运行的任何模型。 但是递归模型的体系结构是不同的。 为了决定神经元的激活,除了初始数据外,这些模型还要用到它们以前的状态。 构建自动编码器时应考虑此特性。
1. 递归模型的训练特点
我们从回顾递归模型的组织及其目的开始。 看看价格图表。 它显示相对于价格走势的历史数据。 每根柱线都是针对特定时间间隔内品种价格波动的范围边界的描述。 请注意,这是“历史数据”。 这意味着它们不会再改变。 随着时间的推移会出现新柱线,但旧柱线不会再变化。 在每个特定的时间点,我们都有不变的历史数据,和最后一根蜡烛,它还没有完全成形,随时能够变化,直到它的时间间隔关闭。
通过分析历史数据,我们尝试预测未来最有可能的价格走势。 历史数据的分析深度在每种情况下都有所区别。 这大概是与神经网络所用的固定初始数据量相关的主要问题之一。 较小的历史数据窗口限制了分析的可能性。 过度的窗口会令模型及其学习复杂化。 因此,在选择输入数据窗口的大小时,此类模型的架构师必须妥协并遵循“中庸之道”。
另一方面,我们正在应对历史数据。 无论我们如何选择窗口大小,在每次模型迭代中,我们都会将 99% 以上的信息重新传输到它。 之后,模型也将重新处理此数据。 这看起来不像是对资源的有效利用。 但遗憾的是,完全连接和卷积模型都不记得以前处理过的任何信息。
上述问题可以经由递归网络来解决。 思路如下。 每个神经元的状态取决于源数据的处理结果。 因此,我们可以假设神经元的状态是源数据的压缩形式。 故此,我们可以将源数据与先前的状态一起馈送到神经元当中。 如此这般,神经元的新状态将取决于我们正在分析的系统当前状态和先前状态,而信息是有关在神经元的先前状态中哪些被压缩。
此方法令模型能够记忆系统的若干个状态。 以权重系数绝对值小于 1 的激活函数,逐渐降低最早历史数据的影响。 结果就是,我们得到的模型具有相当的可预测记忆界线。
运用这种带有记忆的模型,我们制定决策时,不必再受限于所取用的历史数据窗口。 此外,我们减少了重新传输的信息量,因为模型已经记住它了。 出于这些优点,递归模型可被视为解决时间序列处理问题的高优先级领域之一。
然而,需要特殊方式来运用这些功能进行递归模型训练。 例如,回到自编码器的架构,如果我们把上图中模型的输入 Xi 和输出 Yi 等同起来,那么为了从潜伏状态恢复原始数据,就没有必要记住之前的状态了。 因此,该模型将排除训练过程中历史数据的影响。 它只会评估当前状态。 如果递归模型失去了记忆能力,它就失去了其主要优势。
既如此,在开发模型架构时,我们必须要考虑到这一事实。 学习过程应进行规划,这样模型就会强制访问以前迭代的数据。
在自动编码器构造中,解码器架构在大多数情况下几乎是编码器架构的镜像。 当运作递归模型时保留了相同的措施。 奇怪的是,最早期的这种架构之一曾被用于监督学习。 题为 “利用 RNN 编码器/解码器学习短语表达 — 用于统计机器翻译” 的论文作者提出了RNN 编码器-解码器模型,来进行统计机器翻译。 该模型的编码器和解码器就是递归网络。 编码器将源语言的短语压缩为某个潜伏状态。 然后解码器将其“解包”还原为目标语言的短语。 它与自动编码器非常相似,是不是?
使用递归模型可以一次一个单词地将短语传输到编码器,这令基于不同长度的短语来训练模型成为可能。 在收到完整的短语后,编码器再把潜伏状态传输到解码器。 解码器再一次一个单词地给出目标语言的短语翻译。
基于英语和法语的标记短语进行训练之后,作者获得了一个能够返回语义和语法上有意义的短语的模型。
递归模型的无监督学习在发表于 2015 年 2 月的文章 “使用 LSTMs 的视频表现无监督学习” 中已有了很好的阐述。 文章作者基于各种视频素材进行了一系列实验,训练递归自动编码器。 执行的实验包含两个方面:数据输入到编码器后再恢复,以及视频序列可能延续的预测。
该文章介绍了自动编码器的各种架构。 但它们都利用的是 LSTM 模块进行信号编码和解码。 投注结果是采用 1 个编码器和 2 个解码器训练模型时获得的。 一个解码器负责恢复原始数据,第二个解码器预测视频序列最有可能的延续。
在编码器中利用循环模块,能够将原始视频逐帧传输到模型之中。 取决于任务的不同,递归解码器模块返回逐帧重建或预测的视频序列。
此外,该文作者表明,在与视频运动识别相关的任务中,采用无监督算法预训练的递归模型,再采用监督算法进行额外训练后,即使基于相对少量的标记数据,也能提供相当优异的结果。
这两篇文章中提供的资料表明,这种方式可以成功地解决我们的问题。
然而,我在实现中所做的将与建议的模型略有偏差。 它们都要用到解码器中的循环模块,并返回逐帧解码数据。 这完全符合翻译和视频分析任务。 这也许会在预测下一根柱线时给出不错的结果。 但我还没有做过任何这方面的实验。 在一般情况下,在分析市场形势时,我们评估它,并作为涵盖相当长时间间隔的完整图片。 因此,我们将逐步小幅度地把市场形势的变化转移到模型之中。 然后,模型应参考当前和以前得到的数据评估形势。 这意味着潜伏状态应包含有关尽可能宽的时间间隔的信息。
为了达到这种效果,我们在编码器中仅用递归模块。 在解码器中,我们还将采用完全连接神经层,且在若干次迭代中还原传输到编码器的数据。
2. 实现
接下来,我们进入本文的实践部分。 我们将基于前面讨论的 LSTM 模块构建递归编码器,其结构如下图所示。 该模块由 4 个完全连接神经层组成。 其中三个执行门的功能规范信息流。 第四个转换源数据。
LSTM 模块使用 2 个递归信息流:记忆和隐藏状态。
我们之前曾用 MQL5 重新创建了 LSTM 模块算法。 现在我们将用 OpenCL 技术重复它。 为了实现该算法,我们来创建一个新的 CNeuronLSTMOCL 类。 主要缓冲区和方法集继承自基类 CNeuronBaseOCL,我们将其当作父类。
方法和类变量的结构如下所示。 类方法非常容易识别:这些是我们在每个新类中覆盖的前馈和后馈方法。 变量的目的需要略加解释。
class CNeuronLSTMOCL : public CNeuronBaseOCL { protected: CBufferFloat m_cWeightsLSTM; CBufferFloat m_cFirstMomentumLSTM; CBufferFloat m_cSecondMomentumLSTM; int m_iMemory; int m_iHiddenState; int m_iConcatenated; int m_iConcatenatedGradient; int m_iInputs; int m_iWeightsGradient; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronLSTMOCL(void); ~CNeuronLSTMOCL(void); //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual int Type(void) override const { return defNeuronLSTMOCL; } };
首先,我们在这里看到 3 个数据缓冲区:
- m_cWeightsLSTM — LSTM 模块的权重系数矩阵
- m_cFirstMomentumLSTM — 更新权重的第一个动量的矩阵
- m_cSecondMomentumLSTM — 更新权重的第二个动量的矩阵
以下几点需要注意。 如上所述,LSTM 模块包含 4 个完全连接神经层。 与此同时,我们只为权重矩阵 m_cWeightsLSTM 声明了一个缓冲区。 此缓冲区将包含所有 4 个神经层的权重。 使用级联缓冲区将允许我们同时并行化所有 4 个神经层。 稍后在研究每种方法的实现时,我们会更详细地研究并行规划机制。
这同样适用于动量缓冲区 m_cFirstMomentumLSTM 和 m_cSecondMomentumLSTM。
在最新的终端版本中,MetaQuotes Ltd 实现了一定数量的改进。 它们还影响到我们所用的 OpenCL 技术。 特别是,他们增加了 OpenCL 对象的最大可能数量,并增加了无需双精度支持情况下在显卡上使用该技术的可能性。 这将降低训练模型所需的总时间,因为现在不需要在调用每个内核之前从 CPU 内存加载数据,或在执行后将其往回卸载。 在开始训练过程之前,将所有初始数据一次性加载到 OpenCL 关联内存当中,并在训练结束后复制结果就足够了。
甚至,它允许我们仅在 OpenCL 关联中声明一些缓冲区,而无需在设备的主内存中创建镜像缓冲区。 这是指用于存储临时信息的缓冲区。 因此,对于一定数量的缓冲区,我们只要创建一个变量来存储指向 OpenCL 关联之中缓冲区的指针:
- m_iMemory — 指向内存缓冲区的指针
- m_iHiddenState — 指向隐藏状态缓冲区的指针
- m_iConcatenated — 指向四个内部神经层的串联结果缓冲区的指针
- m_iConcatenatedGradient — 指向四个内部神经层结果级别的误差梯度级联缓冲区的指针
- m_iWeightsGradient — 指向四个内部神经层的权重矩阵级别的误差梯度缓冲区的指针。
我们将初始值分配给类构造函数中的所有变量。
CNeuronLSTMOCL::CNeuronLSTMOCL(void) : m_iMemory(-1), m_iConcatenated(-1), m_iConcatenatedGradient(-1), m_iHiddenState(-1), m_iInputs(-1) {}
在类析构函数中,我们释放所有使用的缓冲区。
CNeuronLSTMOCL::~CNeuronLSTMOCL(void) { if(!OpenCL) return; OpenCL.BufferFree(m_iConcatenated); OpenCL.BufferFree(m_iConcatenatedGradient); OpenCL.BufferFree(m_iHiddenState); OpenCL.BufferFree(m_iMemory); OpenCL.BufferFree(m_iWeightsGradient); m_cFirstMomentumLSTM.BufferFree(); m_cSecondMomentumLSTM.BufferFree(); m_cWeightsLSTM.BufferFree(); }
继续实现我们的类方法,我们来创建一个初始化 LSTM 模块对象的方法。 遵循继承规则,我们将覆盖 CNeuronLSTMOCL::Init 方法,同时保留父类当中类似方法的参数。 初始化方法将在参数中接收下一层的神经元数量、神经元的索引、指向 OpenCL 关联对象的指针、当前层的神经元数量、参数优化方法、和批量大小。
在方法主体中,我们首先调用父类的类似方法。 因此,我们将初始化父类的继承对象,并控制接收的初始数据。 不要忘记检查操作的执行结果。
接下来,我们需要初始化上面声明的数据缓冲区。 在此阶段,我们无法完全初始化所有缓冲区,因为我们还没有所需的源数据。 在参数中,我们接收当前层中的神经元数量和下一层中的神经元数量。 但我们不知道前一层的神经元数量。 因此,我们不知道存储 LSTM 权重模块所需的缓冲区大小。 那么,在此阶段,我们仅创建那些其大小仅取决于当前层中元素数量的数据缓冲区。
bool CNeuronLSTMOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch)) return false; //--- m_iMemory = OpenCL.AddBuffer(sizeof(float) * numNeurons * 2, CL_MEM_READ_WRITE); if(m_iMemory < 0) return false; m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * numNeurons, CL_MEM_READ_WRITE); if(m_iHiddenState < 0) return false; m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE); if(m_iConcatenated < 0) return false; m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE); if(m_iConcatenatedGradient < 0) return false; //--- return true; }
不要忘记控制每一步的结果。
创建对象初始化方法后,继续规划 LSTM 模块的前馈传递。 如您所知,通过运用 OpenCL 技术,可直接在 GPU 上的 OpenCL 关联环境中执行计算。 在主程序的代码中,我们仅需调用必要的程序。 因此,在编写类的方法之前,我们要用相应的内核来补充我们的 OpenCL 程序。
LSTM_FeedForward 内核将负责在 OpenCL 程序中规划前馈传递。 为了正确规划流程,我们需要为内核提供 5 个数据缓冲区的指针,和一个常量:
- inputs — 源数据缓冲区:
- inputs_size — 源数据缓冲区中的元素数量
- weights — 权重矩阵缓冲区
- concatenated — 含所有内层结果的串联缓冲区
- memory — 记忆缓冲区
- output — 结果缓冲区(也用作隐藏状态缓冲区)。
__kernel void LSTM_FeedForward(__global float* inputs, uint inputs_size, __global float* weights, __global float* concatenated, __global float* memory, __global float* output ) { uint id = (uint)get_global_id(0); uint total = (uint)get_global_size(0); uint id2 = (uint) get_local_id(1);
我们将在二维任务空间中运作缓冲区。 在第一个维度中,我们将指明当前 LSTM 模块中的元素数量。 第二个维度等于四个线程的内部神经层数目。 请注意,LSTM 模块中的元素数目决定了每个内层中的元素数量,以及记忆中的元素数量和隐藏状态。
因此,在内核主体中,我们首先确定线程在每个维度上的序号。 我们还确定第一维度中的任务数量。
整个 LSTM 模块前馈过程可以有条件地分为两个子过程:
- 计算内部神经层的值
- 实现从神经层到 LSTM 模块输出的数据流
在第一个进程彻底完成之前,第二个进程是不可能执行的。 这是因为第二个子进程的执行需要所有四个神经元的值,至少要在当前的 LSTM 模块元素之中。 因此,我们需要沿第二个维度同步数据线程。 OpenCL 的当前实现允许在本地组中进行线程同步。 因此,我们将根据任务的第二个维度构建我们的本地组。
接下来,我们将实现源数据和隐藏状态的权重累加和的计算。 首先,计算隐藏状态的权重累加和。
float sum = 0; uint shift = (id + id2 * total) * (total + inputs_size + 1); for(uint i = 0; i < total; i += 4) { if(total - i > 4) sum += dot((float4)(output[i], output[i + 1], output[i + 2], output[i + 3]), (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3])); else for(uint k = i; k < total; k++) sum += output[k] + weights[shift + k]; }
然后加上初始数据的权重累加和。
shift += total; for(uint i = 0; i < inputs_size; i += 4) { if(total - i > 4) sum += dot((float4)(inputs[i], inputs[i + 1], inputs[i + 2], inputs[i + 3]), (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3])); else for(uint k = i; k < total; k++) sum += inputs[k] + weights[shift + k]; } sum += weights[shift + inputs_size];
最后,加上偏置神经元的值。
计算权重累加和之后,我们需要计算激活函数的值。 Sigmoid 用作门的激活函数。 双曲正切用于新内容层。 所需的激活函数将由第二个维度中的线程标识符判定。
if(id2 < 3) concatenated[id2 * total + id] = 1.0f / (1.0f + exp(sum)); else concatenated[id2 * total + id] = tanh(sum); //--- barrier(CLK_LOCAL_MEM_FENCE);
如上所述,为了正确执行算法,需要沿任务空间的第二个维度同步线程。 我们将调用 barrier 函数来同步线程。
为了实现内层之间的信息传递过程,我们只需要 LSTM 模块的每个元素有一个线程。 因此,线程同步后,该进程仅执行任务空间第二维度中线程 ID 为 0 的线程。
if(id2 == 0) { float mem = memory[id + total] = memory[id]; float fg = concatenated[id]; float ig = concatenated[id + total]; float og = concatenated[id + 2 * total]; float nc = concatenated[id + 3 * total]; //--- memory[id] = mem = mem * fg + ig * nc; output[id] = og * tanh(mem); } //--- }
这样就完成了正向传递内核的工作。 现在,可以从主程序调用它。 首先,创建所需的常量。
#define def_k_LSTM_FeedForward 32 #define def_k_lstmff_inputs 0 #define def_k_lstmff_inputs_size 1 #define def_k_lstmff_weights 2 #define def_k_lstmff_concatenated 3 #define def_k_lstmff_memory 4 #define def_k_lstmff_outputs 5
然后我们可以开始创建类的前馈传递方法。 与先前研究过的任何其它类的相同方法类似,此方法在参数中接收指向前一个神经层对象的指针。 在方法主体中,我们应该第一时间验证指针。
bool CNeuronLSTMOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || NeuronOCL.Neurons() <= 0 || NeuronOCL.getOutputIndex() < 0 || !OpenCL) return false;
当初始化类时,我们无法初始化所有的数据缓冲区,因为我们尚不知晓前一层中的神经元数量。 现在我们有了指向前一个神经层的指针。 那么,我们就可以请求该层中的神经元数量,并创建所需的数据缓冲区。 在执行此操作之前,请确保更早之前尚无该缓冲区。 此前馈方法调用可能不是第一个。 包含前一层中元素数量的变量将作为一个标志。
if(m_iInputs <= 0) { m_iInputs = NeuronOCL.Neurons(); int count = (int)((m_iInputs + Neurons() + 1) * Neurons()); if(!m_cWeightsLSTM.Reserve(count)) return false; float k = (float)(1 / sqrt(Neurons() + 1)); for(int i = 0; i < count; i++) { if(!m_cWeightsLSTM.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier)) return false; } if(!m_cWeightsLSTM.BufferCreate(OpenCL)) return false; //--- if(!m_cFirstMomentumLSTM.BufferInit(count, 0)) return false; if(!m_cFirstMomentumLSTM.BufferCreate(OpenCL)) return false; //--- if(!m_cSecondMomentumLSTM.BufferInit(count, 0)) return false; if(!m_cSecondMomentumLSTM.BufferCreate(OpenCL)) return false; if(m_iWeightsGradient >= 0) OpenCL.BufferFree(m_iWeightsGradient); m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * count, CL_MEM_READ_WRITE); if(m_iWeightsGradient < 0) return false; } else if(m_iInputs != NeuronOCL.Neurons()) return false;
完成准备工作之后,将指向数据缓冲区的指针和所需常量的值传递给前馈内核的参数。 记住要控制操作的执行。
if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_inputs, NeuronOCL.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_concatenated, m_iConcatenated)) return false; if(!OpenCL.SetArgument(def_k_LSTM_FeedForward, def_k_lstmff_inputs_size, m_iInputs)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_memory, m_iMemory)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_outputs, getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_weights, m_cWeightsLSTM.GetIndex())) return false;
接下来,我们定义问题空间,和直至第一次迭代前的偏移量。 在这种情况下,我们需指定二维问题空间,以及组合在二维空间中的局部组大小。 在第一种情况下,我们指定第一维中当前层元素的总数。 对于本地组,我们在第一维中指定仅一个元素。 在第二个维中,在这两种情况下,我们根据内部神经层的数量为它们都指示四个元素。 这允许我们为四个线程的每一个创建本地组。 这种局部组的数量应等于当前神经层中的元素数量。
uint global_work_offset[] = {0, 0}; uint global_work_size[] = {Neurons(), 4}; uint local_work_size[] = {1, 4};
因此,通过同步每个局部组中的线程,我们就能依据当前层的每个单独元素的关联,同步计算所有四个内部神经层的数值。 这足以实现整个 LSTM 模块前馈传递的正确计算。
接下来,我们将内核放入执行队列中。
if(!OpenCL.Execute(def_k_LSTM_FeedForward, 2, global_work_offset, global_work_size, local_work_size)) return false; //--- return true; }
LSTM 模块的前馈传递到此结束,我们可以继续实现反向传播传递。 与前面的情况一样,我们需要在创建类方法之前补充 OpenCL 程序。 配合前馈传递,我们设法将整个前馈传递合并到一个内核当中。 这次我们需要三个内核。
在第一个内核 LSTM_ConcatenatedGradient 中,我们将实现梯度传播返回内层结果。 在参数中,内核接收指向 4 个数据缓冲区的指针。 其中三个将包含初始数据:来自下一层的梯度缓冲区,记忆状态,和内部神经层结果的串联缓冲区。 第四个缓冲区将用于写入内核操作的结果。
内核将根据 LSTM 模块中的元素数量在一维问题空间中调用。
在内核主体中,我们首先定义线程标识符和线程总数。 然后,沿着信号的反向传播路径移动,我们判定在输出门的结果级别、记忆级别、新内容神经层级别、新内容门级别、等处的误差梯度。 然后判定在遗忘门级别处的误差。
__kernel void LSTM_ConcatenatedGradient(__global float* gradient, __global float* concatenated_gradient, __global float* memory, __global float* concatenated ) { uint id = get_global_id(0); uint total = get_global_size(0); float t = tanh(memory[id]); concatenated_gradient[id + 2 * total] = gradient[id] * t; //output gate float memory_gradient = gradient[id] * concatenated[id + 2 * total]; memory_gradient *= 1 - pow(t, 2.0f); concatenated_gradient[id + 3 * total] = memory_gradient * concatenated[id + total]; //new content concatenated_gradient[id + total] = memory_gradient * concatenated[id + 3 * total]; //input gate concatenated_gradient[id] = memory_gradient * memory[id + total]; //forget gate }
之后,我们需要将误差梯度通过 LSTM 模块的内层传播到前一级神经层。 为此,创建 LSTM_HiddenGradient 级别。 在开发程序的 OpenCL 架构时,我决定把梯度分布组合到前一层的级别,以及该内核中权重矩阵的级别。 如此,内核在参数中接收指向 6 个数据缓冲区的指针,和 2 个常量。 内核将在一维问题空间中调用。
__kernel void LSTM_HiddenGradient(__global float* concatenated_gradient, __global float* inputs_gradient, __global float* weights_gradient, __global float* hidden_state, __global float* inputs, __global float* weights, __global float* output, const uint hidden_size, const uint inputs_size ) { uint id = get_global_id(0); uint total = get_global_size(0);
在内核实体中,定义线程标识符和线程总数。 此外,判定权重矩阵的一个向量的大小。
uint weights_step = hidden_size + inputs_size + 1;
接下来,循环遍历级联输入数据缓冲区的所有元素,其中包括隐藏状态和从前一级神经层接收的当前状态。 循环迭代从当前线程 ID 开始,而循环迭代步长等于正在运行的线程总数。 此方式迭代能够覆盖串联的源数据层的所有元素,无所谓正在运行的线程数量。
for(int i = id; i < (hidden_size + inputs_size); i += total) { float inp = 0;
在此步骤中,在循环实体中,我们根据要分析的元素实现操作线程的划分。 如果元素属于隐藏状态,则将隐藏状态保存在私密变量之中。 来自结果缓冲区中的相关值应传输到缓冲区,因为在下一次迭代时它将处于隐藏状态。
if(i < hidden_size)
{
inp = hidden_state[i];
hidden_state[i] = output[i];
}
如果当前元素属于前一层神经元的输入数据缓冲区,则将初始数据的数值转移到私密变量之中,并计算前一层对应神经元的误差梯度。
else { inp = inputs[i - hidden_size]; float grad = 0; for(uint g = 0; g < 3 * hidden_size; g++) { float temp = concatenated_gradient[g]; grad += temp * (1 - temp) * weights[i + g * weights_step]; } for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++) { float temp = concatenated_gradient[g]; grad += temp * (1 - pow(temp, 2.0f)) * weights[i + g * weights_step]; } inputs_gradient[i - hidden_size] = grad; }
将误差梯度传播到前一级神经层后,将误差梯度分布到相应的 LSTM 模块权重。
for(uint g = 0; g < 3 * hidden_size; g++) { float temp = concatenated_gradient[g]; weights[i + g * weights_step] = temp * (1 - temp) * inp; } for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++) { float temp = concatenated_gradient[g]; weights[i + g * weights_step] = temp * (1 - pow(temp, 2.0f)) * inp; } }
在内核的末尾,将误差梯度传播到每个权重向量的偏置神经元。
for(int i = id; i < 4 * hidden_size; i += total) { float temp = concatenated_gradient[(i + 1) * hidden_size]; if(i < 3 * hidden_size) weights[(i + 1) * weights_step] = temp * (1 - temp); else weights[(i + 1) * weights_step] = 1 - pow(temp, 2.0f); } }
将误差梯度传播回前一级神经层和权重矩阵之后,我们需要实现权重更新过程。 我决定不实现全方位的参数优化方法。 取而代之,我将实现我最常使用的 Adam 方法。 通过模仿我的实现,您可以添加任何其它方法来优化模型参数。
因此,模型参数将于 LSTM_UpdateWeightsAdam 内核中更新。 在权重矩阵级别的误差梯度已在前一层中进行计算,并已写入 weights_gradient 缓冲区。 故此,在该内核中,我们只需要实现更新模型参数的过程。 为了实现经由 Adam 方法实现参数更新过程,我们需要两个额外的缓冲区来记录第一个和第二个动量。 此外,我们还要训练超参数。 此数据将在内核参数中传递。
__kernel void LSTM_UpdateWeightsAdam(__global float* weights, __global float* weights_gradient, __global float *matrix_m, __global float *matrix_v, const float l, const float b1, const float b2 ) { const uint id = get_global_id(0); const uint total = get_global_size(0); const uint id1 = get_global_id(1); const uint wi = id1 * total + id;
如您所知,权重矩阵是一个二维矩阵。 因此,我们将在二维任务空间中调用内核。
在内核实体中,判定两个维度中线程的序号,以及在第一个维度中运行的线程总数。 依据这些常量,判定缓冲区中距所需权重的偏移量。 接下来,运行算法来更新权重矩阵中的相应元素。
float g = weights_gradient[wi]; float mt = b1 * matrix_m[wi] + (1 - b1) * g; float vt = b2 * matrix_v[wi] + (1 - b2) * pow(g, 2); float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weights[wi]) + l2 * weights[wi] / total)); weights[wi] = clamp(weights[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT); matrix_m[wi] = mt; matrix_v[wi] = vt; };
我们在此完成对 OpenCL 程序的修改,然后继续在主程序方面实现方法。
我们首先为操控上面创建的内核创建常量。
#define def_k_LSTM_ConcatenatedGradient 33 #define def_k_lstmcg_gradient 0 #define def_k_lstmcg_concatenated_gradient 1 #define def_k_lstmcg_memory 2 #define def_k_lstmcg_concatenated 3 #define def_k_LSTM_HiddenGradient 34 #define def_k_lstmhg_concatenated_gradient 0 #define def_k_lstmhg_inputs_gradient 1 #define def_k_lstmhg_weights_gradient 2 #define def_k_lstmhg_hidden_state 3 #define def_k_lstmhg_inputs 4 #define def_k_lstmhg_weeights 5 #define def_k_lstmhg_output 6 #define def_k_lstmhg_hidden_size 7 #define def_k_lstmhg_inputs_size 8 #define def_k_LSTM_UpdateWeightsAdam 35 #define def_k_lstmuw_weights 0 #define def_k_lstmuw_weights_gradient 1 #define def_k_lstmuw_matrix_m 2 #define def_k_lstmuw_matrix_v 3 #define def_k_lstmuw_l 4 #define def_k_lstmuw_b1 5 #define def_k_lstmuw_b2 6
接下来,我们继续讨论我们的类方法。 我们从创建误差梯度反向传播方法 calcInputGradients 开始。 在参数中,该方法接收指向前一级神经层对象的指针。 立即检查所接收指针的有效性。
bool CNeuronLSTMOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || NeuronOCL.Neurons() <= 0 || NeuronOCL.getGradientIndex() < 0 || NeuronOCL.getOutputIndex() < 0 || !OpenCL) return false;
检查 OpenCL 关联中所需数据缓冲区的可用性。
if(m_cWeightsLSTM.GetIndex() < 0 || m_cFirstMomentumLSTM.GetIndex() < 0 || m_cSecondMomentumLSTM.GetIndex() < 0) return false; if(m_iInputs < 0 || m_iConcatenated < 0 || m_iMemory < 0 || m_iConcatenatedGradient < 0 || m_iHiddenState < 0 || m_iInputs != NeuronOCL.Neurons()) return false;
如果所有检查都成功,则继续内核调用。 根据误差梯度算法,我们首先调用 LSTM_ConcatenatedGradient 内核。
首先,将初始数据传输到内核参数。
if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated, m_iConcatenated)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated_gradient, m_iConcatenatedGradient)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_gradient, getGradientIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_memory, m_iMemory)) return false;
定义问题空间的维度。 将内核放入执行队列之中。
uint global_work_offset[] = {0}; uint global_work_size[] = {Neurons()}; if(!OpenCL.Execute(def_k_LSTM_ConcatenatedGradient, 1, global_work_offset, global_work_size)) return false;
在此,我们还针对误差梯度传播实现了调用第二个内核 LSTM_HiddenGradient。 将参数传递给内核。
if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_concatenated_gradient, m_iConcatenatedGradient)) return false; if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_size, Neurons())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_state, m_iHiddenState)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs, NeuronOCL.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_gradient, NeuronOCL.getGradientIndex())) return false; if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_size, m_iInputs)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_output, getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weeights, m_cWeightsLSTM.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weights_gradient, m_iWeightsGradient)) return false;
使用已创建的数组来指定问题空间,并将内核放入执行队列之中。
if(!OpenCL.Execute(def_k_LSTM_HiddenGradient, 1, global_work_offset, global_work_size)) return false; //--- return true; }
同样,不要忘记实现所有操作。 这样就令您能够及时跟踪误差,并防止程序在最不合时宜的时刻紧急终止。
误差梯度经传播之后,为了完成算法,我们需要实现 updateInputWeights 方法来更新模型参数。 该方法在参数中接收指向前一层对象的指针。 但我们已在权重矩阵级别定义了误差梯度。 因此,指向前一层对象的指针的存在,更多是与方法覆盖的实现有关,而非数据传输的需要。 在这种情况下,接收指针的状态不会影响方法结果,故我们不用检查它。 取而代之,在 OpenCL 关联中验证所需内部缓冲区的可用性。
bool CNeuronLSTMOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || m_cWeightsLSTM.GetIndex() < 0 || m_iWeightsGradient < 0 || m_cFirstMomentumLSTM.GetIndex() < 0 || m_cSecondMomentumLSTM.GetIndex() < 0) return false;
接下来,将参数传递给内核。
if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights, m_cWeightsLSTM.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights_gradient, m_iWeightsGradient)) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_m, m_cFirstMomentumLSTM.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_v, m_cSecondMomentumLSTM.GetIndex())) return false; if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_l, lr)) return false; if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b1, b1)) return false; if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b2, b2)) return false;
定义问题空间,并将内核放入执行队列之中。
uint global_work_offset[] = {0, 0}; uint global_work_size[] = {m_iInputs + Neurons() + 1, Neurons()}; if(!OpenCL.Execute(def_k_LSTM_UpdateWeightsAdam, 2, global_work_offset, global_work_size)) return false; //--- return true; }
我们规划反向传播算法的工作至此完毕。 我们的 CNeuronLSTMOCL 类已为第一次测试准备就绪。 但我们知道我们需要保存已训练模型,然后恢复其工作状态。 因此,我们要添加文件操作的方法。
与之前所研究的所有神经层体系结构一样,利用 Save 方法保存数据。 在参数中,该方法接收数据写入文件的句柄。
在方法主体中,我们首先调用父类的类似方法。 这几乎就能用一行代码实现所有必要的控件,并保存从父类继承的对象。 检查父类方法执行结果。
之后,保存上一层的神经元数量。 还要保存权重和动量矩阵。
bool CNeuronLSTMOCL::Save(const int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) return false; if(FileWriteInteger(file_handle, m_iInputs, INT_VALUE) < sizeof(m_iInputs)) return false; if(!m_cWeightsLSTM.BufferRead() || !m_cWeightsLSTM.Save(file_handle)) return false; if(!m_cFirstMomentumLSTM.BufferRead() || !m_cFirstMomentumLSTM.Save(file_handle)) return false; if(!m_cSecondMomentumLSTM.BufferRead() || !m_cSecondMomentumLSTM.Save(file_handle)) return false; //--- return true; }
数据保存之后,我们需要创建 load 方法,从保存的数据中恢复对象。 如前所述,严格按照写入顺序从文件中读取数据。 与在数据保存方法中一样,此方法在参数中接收所要读取文件的句柄。 我们立即调用父类的类似方法。
bool CNeuronLSTMOCL::Load(const int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) return false;
接下来,我们读取前一层中的神经元数量,以及早前保存的权重和动量缓冲区。 加载每个缓冲区之后,在 OpenCL 关联中启动镜像数据缓冲区的创建。 记住要控制操作的执行。
m_iInputs = FileReadInteger(file_handle); //--- m_cWeightsLSTM.BufferFree(); if(!m_cWeightsLSTM.Load(file_handle) || !m_cWeightsLSTM.BufferCreate(OpenCL)) return false; //--- m_cFirstMomentumLSTM.BufferFree(); if(!m_cFirstMomentumLSTM.Load(file_handle) || !m_cFirstMomentumLSTM.BufferCreate(OpenCL)) return false; //--- m_cSecondMomentumLSTM.BufferFree(); if(!m_cSecondMomentumLSTM.Load(file_handle) || !m_cSecondMomentumLSTM.BufferCreate(OpenCL)) return false;
此方法不仅应从文件中读取数据,还应还原已训练模型的全部功能。 因此,从文件中读取数据之后,我们还必须创建临时数据缓冲区,存储未保存到文件中的一些有关信息。
if(m_iMemory >= 0) OpenCL.BufferFree(m_iMemory); m_iMemory = OpenCL.AddBuffer(sizeof(float) * 2 * Neurons(), CL_MEM_READ_WRITE); if(m_iMemory < 0) return false; //--- if(m_iConcatenated >= 0) OpenCL.BufferFree(m_iConcatenated); m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE); if(m_iConcatenated < 0) return false; //--- if(m_iConcatenatedGradient >= 0) OpenCL.BufferFree(m_iConcatenatedGradient); m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE); if(m_iConcatenatedGradient < 0) return false; //--- if(m_iHiddenState >= 0) OpenCL.BufferFree(m_iHiddenState); m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * Neurons(), CL_MEM_READ_WRITE); if(m_iHiddenState < 0) return false; //--- if(m_iWeightsGradient >= 0) OpenCL.BufferFree(m_iWeightsGradient); m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * m_cWeightsLSTM.Total(), CL_MEM_READ_WRITE); if(m_iWeightsGradient < 0) return false; //--- return true; }
CNeuronLSTMOCL 类方法的操作已完成。
接下来,我们只需在 OpenCL 关联环境连接过程中添加新的内核,并在基准神经层的调度程序方法中添加指向新类型神经层的指针。
下面的附件中提供了所有方法和类的完整代码。
3. 测试
新的神经层类已准备就绪,我们可以继续创建测试训练的模型。 基于上一篇文章中的变分自动编码器模型构建了一个新的循环自动编码器模型。 该模型已被保存到一个名为 “rnn_vae.mq5” 的新文件当中。 编码器架构业已发生了变化:我们在里边添加了递归 LSTM 模块。
请注意,我们仅将最后 10 根烛条馈送到递归编码器的输入。
int OnInit() { //--- .................. .................. //--- Net = new CNet(NULL); ResetLastError(); float temp1, temp2; if(!Net || !Net.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false)) { printf("%s - %d -> Error of read %s prev Net %d", __FUNCTION__, __LINE__, FileName + ".nnw", GetLastError()); HistoryBars = iHistoryBars; CArrayObj *Topology = new CArrayObj(); if(CheckPointer(Topology) == POINTER_INVALID) return INIT_FAILED; //--- 0 CLayerDescription *desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; int prev = desc.count = 10 * 12; desc.type = defNeuronBaseOCL; desc.optimization = ADAM; desc.activation = None; if(!Topology.Add(desc)) return INIT_FAILED; //--- 1 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = prev; desc.batch = 1000; desc.type = defNeuronBatchNormOCL; desc.activation = None; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 2 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; prev = desc.count = 500; desc.type = defNeuronLSTMOCL; desc.activation = None; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 3 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; prev = desc.count = prev/2; desc.type = defNeuronLSTMOCL; desc.activation = None; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 4 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; prev = desc.count = 50; desc.type = defNeuronLSTMOCL; desc.activation = None; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 5 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = prev/2; desc.type = defNeuronVAEOCL; if(!Topology.Add(desc)) return INIT_FAILED; //--- 6 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = (int) HistoryBars; desc.type = defNeuronBaseOCL; desc.activation = TANH; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 7 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = (int) HistoryBars * 2; desc.type = defNeuronBaseOCL; desc.activation = TANH; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 8 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = (int) HistoryBars * 4; desc.type = defNeuronBaseOCL; desc.activation = TANH; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; //--- 9 desc = new CLayerDescription(); if(CheckPointer(desc) == POINTER_INVALID) return INIT_FAILED; desc.count = (int) HistoryBars * 12; desc.type = defNeuronBaseOCL; desc.activation = TANH; desc.optimization = ADAM; if(!Topology.Add(desc)) return INIT_FAILED; delete Net; Net = new CNet(Topology); delete Topology; if(CheckPointer(Net) == POINTER_INVALID) return INIT_FAILED; dError = FLT_MAX; } else { CBufferFloat *temp; Net.getResults(temp); HistoryBars = temp.Total() / 12; delete temp; } //--- .................. .................. //--- return(INIT_SUCCEEDED); }
正如本文前面所讨论的,为了规划递归模块的训练,我们需要添加条件来强制模型查看“记忆”。 出于学习目的,我们创建一个数据堆栈。 在前馈传递的每次迭代之后,我们从堆栈中删除有关最久远烛条的信息,并将有关新烛条的信息添加到堆栈末尾。
因此,堆栈将始终包含有关所分析模型的若干个历史状态的信息。 历史深度将由外部参数确定。 我们将此堆栈作为目标值传递给自动编码器。 如果堆栈大小超过编码器输入端的初始数据值,则自动编码器将不得不查看过去状态的记忆。
.................. .................. Net.feedForward(TempData, 12, true); TempData.Clear(); if(!Net.GetLayerOutput(1, TempData)) break; uint check_total = check_data.Total(); if(check_total >= check_count) { if(!check_data.DeleteRange(0, check_total - check_count + 12)) return; } for(int t = TempData.Total() - 12 - 1; t < TempData.Total(); t++) { if(!check_data.Add(TempData.At(t))) return; } if((total-it)>(int)HistoryBars) Net.backProp(check_data); .................. ..................
模型测试参数相同:EURUSD,H1,过去 15 年。 默认指标设置。 把最近 10 根蜡烛的数据输入到编码器。 已经过训练的解码器,可以解码最后 40 根蜡烛。 测试结果如下图所示。 在每根新形成的蜡烛完毕后,数据被输入编码器。
正如您在图表中所看到的,测试结果证实了这种方法对于递归模型的无监督预训练的可行性。 在模型的测试训练过程中,经过 20 个学习世代,模型误差几乎稳定下来,亏损率小于 9%。 此外,有关至少 30 个先前迭代的信息存储在模型的潜伏状态当中。
结束语
在本文中,我们讨论了运用自动编码器进行递归模型训练。 在本文的实践部分,我们创建了一个递归自动编码器,并针对它执行了测试训练。 我们的实验结果能够让我们得出结论,利用自动编码器针对递归模型进行无监督训练的倡议方法是可行的。 该模型在测试中恢复过去 30 次迭代的数据时显示出相当优异的结果。
参考文献列表
- 神经网络变得轻松(第四部分):循环网络
- 神经网络变得轻松(第十四部分):数据聚类
- 神经网络变得轻松(第十五部分):利用 MQL5 进行数据聚类
- 神经网络变得轻松(第十六部分):聚类运用实践
- 神经网络变得轻松(第十七部分):降低维度
- 神经网络变得轻松(第十八部分):关联规则
- 神经网络变得轻松(第十九部分):使用 MQL5 的关联规则
- 神经网络变得轻松(第二十部分):自动编码器
- 神经网络变得轻松(第二十一部分):变分自动编码器(VAE)
- 使用 LSTMs 的视频表现无监督学习
- 利用 RNN 编码器/解码器学习短语表达 — 用于统计机器翻译
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | rnn_vae.mq5 | EA | 递归自动编码器训练智能系统 |
2 | VAE.mqh | 类库 | 变分自动编码器潜伏层类库 |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
…
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/11245
![学习如何基于柴金(Chaikin)振荡器设计交易系统](https://c.mql5.com/2/48/why-and-how__1.png)
![数据科学与机器学习(第 06 部分):梯度下降](https://c.mql5.com/2/47/data_science_articles_series__1.png)
![从头开始开发智能交易系统(第 25 部分):提供系统健壮性(II)](https://c.mql5.com/2/48/development__1.png)