
神经网络变得轻松(第三十八部分):凭借分歧进行自我监督探索
概述
探索问题是强化学习的一大障碍,尤其代理者得到的奖励很稀有且有滞后的处境下,这令制定有效策略变得困难。 这个问题的可能解决方案之一是基于环境模型产生“内在”奖励。 我们在研究内在好奇心模块时曾见过类似的算法。 然而,大多数已创建算法仅在计算机游戏的关联背景下进行了研究。 但在静默模拟环境之外,由于代理者-环境交互的随机性质,训练预测模型颇具挑战性。 在解决环境随机性问题的方式中,Deepak Pathak 在他的文章“凭借分歧进行自我监督探索”中提出了一种算法。
该算法基于自学习方法,其中代理者利用与环境交互期间获得的信息来生成“内在”奖励,并更新其策略。 该算法基于使用若干个代理模型,这些模型与环境交互,并生成各种预测。 如果模型有分歧,则将其视为“有趣”事件,并且激励代理者去探索环境空间。 以这种方式,该算法激励代理者探索环境的新区域,并令其对未来的奖励做出更准确的预测。
1. 凭借分歧探索算法
基于分歧的探索是一种强化学习方法,允许代理者在不依赖外部奖励的情况下探索环境,但更倾向于使用模型融汇寻找新的、未探索的区域。
在“凭借分歧进行自我监督探索”一文中,作者讲述了这种方式,并提出了一个简单的方法:训练前向动态模型的融汇,并鼓励代理者探索动作空间,其在融汇当中模型预测之间存在最大不一致或方差。
因此,代理者并非选择产生最大预期奖励的动作,代理者选择的是融汇当中模型之间分歧最大的动作。 这令代理者探索状态空间的区域,其中融汇当中的模型有分歧,以及可能存在新的和未探索的环境区域。
在这种情况下,融汇当中的所有模型都收敛到均值,最终减少融汇的差距,并为代理者提供有关环境状态和动作可能后果的更准确预测。
此外,凭借分歧进行探索的算法允许代理者成功应对与环境交互的随机性。 本文作者进行的实验结果表明,所提出的方式真实改进了随机环境中的探索,并且优于先前存在的内在动机和不确定性建模方法。 此外,他们观察到这些方式可以扩展到监督学习,其中样本的值不是基于真实标签,而是基于模型融汇的状态来判定的。
故此,凭借分歧进行探索的算法是解决随机环境探索问题的一种有前途的方法。 它允许代理者更有效地探索环境,而不必依赖外部奖励,这在外部奖励可能有限或成本不菲的实际应用程序中尤其实用。
甚而,该算法可以应用于各种环境,包括操控高维数据,譬如图像等,其中测量和最大化模型的不确定性可能特别具有挑战性。
本文的作者证明了所提出的算法在若干个问题中的有效性,包括机器人控制、雅达利游戏、和迷宫导航任务。 作为他们的研究结果,他们表明,凭借分歧进行探索的算法在速度、收敛性和学习品质方面优于其它探索方法。
因此,这种凭借分歧进行探索的方式代表了强化学习领域的重要一步,它可以帮助代理者更好、更有效地探索环境,并在各种任务中取得更好的结果。
我们来研究一下提议的算法。
在与环境交互的过程中,代理者评估当前状态 Xt ,并在其内部策略的指导下执行一些动作 At。 结果就是,环境的状态更改为新的状态 Xt+1。 一组此类数据存储在体验回放缓冲区之中,我们用它来训练预测未来环境状态的动态模型融汇。
为了在初始阶段保持对未来环境状态的独立评估,融汇当中动态模型的所有权重矩阵都填充了随机值。 在训练过程中,每个模型都会从体验回放缓冲区接收自己的随机训练数据集。
我们融汇当中的每个模型都经过训练,从而预测真实环境的下一个状态。 代理者从已充分探索的状态空间部分收集到足够的数据来训练所有模型,成果在模型之间保持一致。 由于模型已训练过,此功能应泛化到状态空间中不熟悉但相似的部分。 不过,对于所有模型,新的和未探索的区域仍有很高的预测误差,因为它们尚未基于此类样本进行过训练。 结果就是,我们在预测下一个状态方面存在分歧。 因此,我们将这种分歧作为政策方向的内在奖励。 具体来说,内在奖励 Ri 定义为融汇当中不同模型输出的方差。
请注意,在上面的公式中,内在奖励不依赖于系统的未来状态。 稍后在实现此方法时,我们会用到此性质。
在随机场景的情况下,给定足够数量的样本,动态预测模型必须学习预测随机样本的平均值。 以这种方式,融汇当中输出的离散将降低,从而防止代理者卡在所研究随机局部最小值。 请注意,这与基于预测误差的目标不同,后者经历足够多的样本后稳定在平均值。 均值与单个真实随机状态不同,并且预测误差仍然很高,这令代理者始终对随机行为感兴趣。
当使用所提议的算法时,代理者与环境交互的每个步骤不仅提供了有关从环境收到的奖励的信息,而且还提供了更新代理者内部模型所需的信息,即该模型在执行动作时环境状态如何变化。 这令代理者能提取有关环境的有价值信息,即使没有明确的外部奖励。
内在奖励 iR 被用于训练代理者的政策,其是计算融汇当中不同模型输出的方差。 模型输出之间的分歧越大,内在奖励的价值就越高。 这令代理者去探索状态空间的新区域,其中下一个状态的预测是不确定的,并学习根据这些数据制定更好的决策。
代理者依据在与环境交互过程中收集的数据进行在线训练。 同时,在代理者与环境的每次交互后,都会更新模型的融汇,这令代理者可以在每一步更新其关于环境的内部模型,并获得对未来环境状态的更准确的预测。
2. 利用 MQL5 实现
在我们的实现中,我们不会完全重复所提议的算法,而只会运用它的主要思想,并调整它们来适配我们的任务。
我们做的第一件事就是要求一组动态模型来预测压缩(隐藏)的系统状态,类似于内在好奇心模型。 这将允许我们压缩动态模型和融汇整体的大小。
第二点是,要判定内在奖励,我们不需要知道系统的真实状态,而是需要知道动态融汇模型的预测值。 这令我们能够依据预测性奖励来刺激后续学习,还可以做出实时动作决策。 我们不会在训练代理者的策略时通过引入内在组件来扭曲外部奖励,而是允许它针对最大化外部奖励立即构建策略。 这是我们的主要目标。
然而,为了在学习过程中最大限度地学习环境,在选择代理者的动作时,我们将在预测奖励中累加动态模型针对每个可能的代理者动作的预测分歧方差。
这就引出了另一点:为了并行计算每个动作后的预测状态,我们要求动态模型根据当前状态为我们提供每个可能的代理者动作的预测,并根据可能的动作数量增加每个模型的结果层的大小。
现在我们已经定义了主要的工作方向,我们可以继续实现算法。 第一个问题是如何实现动态模型的集合。 我们之前创建的所有模型都是线性的。 可以在一个子进程和一个神经层中利用 OpenCL 工具组织并行计算。 目前还无法实现多个模型的并行计算。 为若干个模型创建计算序列会导致训练模型所花费时间显著增加。
为了解决这个问题,我决定使用我们针对多关注者的并行计算组织方法。 那一次,我们将来自所有关注者的数据组合成单个张量,并在 OpenCL 中的任务空间级别将它们划分。
我们现在不会重新制作整个函数库来解决这些问题。 在这个阶段,未来系统状态的预测值的特定准确性对于我们来说并不重要。 模型融汇能相对同步工作就足够了。 因此,在动态预测模型中,我们将使用全连接层。
首先,我们将创建 OpenCL 程序内核来组织此功能。 前馈内核 FeedForwardMultiModels 与类似的基本全连接层内核几乎相同。 但也有细微的区别。
内核参数保持不变。 它有三个数据缓冲区(权重矩阵、源数据和结果张量),以及两个常量:源数据层的大小,和激活函数。 但之前,我们指定前一层的完整大小等源数据层的大小。 现在我们期望收到当前模型的元素数量。
__kernel void FeedForwardMultiModels(__global float *matrix_w, __global float *matrix_i, __global float *matrix_o, int inputs, int activation ) { int i = get_global_id(0); int outputs = get_global_size(0); int m = get_global_id(1); int models = get_global_size(1);
在内核主体中,我们首先识别当前线程。 您可以在此处注意到问题空间出现了第二个维度,该维度标识当前模型。 问题的整体维度则指示融汇的大小。
接下来,我们声明必要的局部变量,并在数据缓冲区中定义偏移量,同时考虑正在计算的神经元和融汇之中的当前模型。
float sum = 0; float4 inp, weight; int shift = (inputs + 1) * (i + outputs * m); int shift_in = inputs * m; int shift_out = outputs * m;
计算神经元状态和激活函数的实际数学部分保持不变。 我们只在数据缓冲区中添加了偏移调整。
for(int k = 0; k <= inputs; k = k + 4) { switch(inputs - k) { case 0: inp = (float4)(1, 0, 0, 0); weight = (float4)(matrix_w[shift + k], 0, 0, 0); break; case 1: inp = (float4)(matrix_i[shift_in + k], 1, 0, 0); weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0); break; case 2: inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], 1, 0); weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0); break; case 3: inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2], 1); weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]); break; default: inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2], matrix_i[shift_in + k + 3]); weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]); break; } float d = dot(inp, weight); if(isnan(sum + d)) continue; sum += d; }
一旦参数中指定的激活函数的值计算之后,结果则保存到 matrix_o 数据缓冲区之中。
if(isnan(sum)) sum = 0; switch(activation) { case 0: sum = tanh(sum); break; case 1: sum = 1 / (1 + exp(-sum)); break; case 2: if(sum < 0) sum *= 0.01f; break; default: break; } matrix_o[shift_out + i] = sum; }
该解决方案允许我们在一个内核中并行计算融汇之中所有模型某一层的数值。 当然,它有一个局限性:此处融汇之中所有模型的架构都是相同的,唯一的区别在于加权系数。
反向验算的情况略有不同。 该算法提供依据一组不同的训练数据集上训练融汇之中的动态模型。 我们不会为每个模型创建单独的训练包。 取而代之,在每次后向验算时,我们只从融汇中随机选择一个模型进行训练。 对于其它模型,我们将零梯度传递给前一层。 这些就是我们针对 CalcHiddenGradientMultiModels 层内的梯度分布内核算法所做的修改。
基础全连接神经层的类似内核在其参数中接收指向四个数据缓冲区的指针和两个变量。 这是权重矩阵的张量,和前一层结果的张量,用于计算激活函数的导数。 还有 2 个梯度缓冲区:当前和之前的神经层。 第一个包含接收到的误差梯度,第二个用于记录内核的结果,并将误差梯度传递到前一个神经层。 在变量中,我们指示当前层中的神经元数量,和前一层的激活函数。 对于指定的参数,我们添加训练模型的标识符,我们将在主程序一端随机选择该标识符。
__kernel void CalcHiddenGradientMultiModels(__global float *matrix_w, __global float *matrix_g, __global float *matrix_o, __global float *matrix_ig, int outputs, int activation, int model ) {
在内核主体中,我们首先识别线程。 与前馈内核一样,我们使用一个二维问题空间。 在第一个维度中,我们标识单个模型中的流,第二个维度则指示融汇中的模型。 为了收集误差梯度,我们在前一层神经元的关联环境中运行一个内核。 每个线程在一个神经元上从各个方向收集误差梯度。
int i = get_global_id(0); int inputs = get_global_size(0); int m = get_global_id(1); int models = get_global_size(1);
请注意,我们只会将梯度分布在一个模型上,但我们将为整个融汇启动多个线程。 这是由于需要重置其它模型的误差梯度。 在下一步中,我们检查是否需要为特定模型更新梯度。 如果我们只需要重置梯度,那么我们只执行这个函数,并退出内核,无需执行不必要的操作。
//--- int shift_in = inputs * m; if(model >= 0 && model != m) { matrix_ig[shift_in + i] = 0; return; }
此处,我们留下一个小观察孔,将来可能会用到。 如果您指定一个负数作为更新模型编号,则将计算融汇中所有模型的梯度。
接下来,我们声明局部变量,并在数据缓冲区中定义偏移量。
//--- int shift_out = outputs * m; int shift_w = (inputs + 1) * outputs * m; float sum = 0; float out = matrix_o[shift_in + i]; float4 grad, weight;
接下来是误差梯度分布的数学部分,它完全重复了基本全连接神经元的类似功能。 当然,我们要在数据缓冲区中添加必要的偏移量。 运算结果将保存到前一层的梯度缓冲区之中。
for(int k = 0; k < outputs; k += 4) { switch(outputs - k) { case 1: weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], 0, 0, 0); grad = (float4)(matrix_g[shift_out + k], 0, 0, 0); break; case 2: grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], 0, 0); weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 0, 0); break; case 3: grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 0); weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], matrix_w[shift_w + (k + 2) * (inputs + 1) + i], 0); break; default: grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], matrix_g[shift_out + k + 3]); weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], matrix_w[shift_w + (k + 2) * (inputs + 1) + i], matrix_w[shift_w + (k + 3) * (inputs + 1) + i]); break; } sum += dot(grad, weight); } if(isnan(sum)) sum = 0; switch(activation) { case 0: out = clamp(out, -1.0f, 1.0f); sum = clamp(sum + out, -1.0f, 1.0f) - out; sum = sum * max(1 - pow(out, 2), 1.0e-4f); break; case 1: out = clamp(out, 0.0f, 1.0f); sum = clamp(sum + out, 0.0f, 1.0f) - out; sum = sum * max(out * (1 - out), 1.0e-4f); break; case 2: if(out < 0) sum *= 0.01f; break; default: break; } matrix_ig[shift_in + i] = sum; }
接下来,我们必须修改权重矩阵更新内核 UpdateWeightsAdamMultiModels。 与误差梯度分布内核一样,我们在基础全连接层的现有内核参数添加一个模型标识符。
请注意,基础神经层的类似内核已经在二维任务空间中运行。 与此同时,我们不需要对非更新模型执行任何操作。 因此,我们只调用一个模型的内核,且我们将使用模型标识符参数来判定数据缓冲区中的偏移量。 否则,内核算法保持不变。 您可以在附件中找到整个算法。
程序的 OpenCL 端工作至此完成。 接下来,我们继续处理 MQL5 函数库的代码。 在此,我们将创建一个新的类 CNeuronMultiModel,作为基类 CNeuronBaseOCL 的后代。
类中的方法集合非常标准,包括用于类初始化、操控文件、前馈和反向传播验算的方法。 我们还引入了两个新变量,我们将在其中记录融汇中的模型数量,和需要训练的模型标识符。 后者将随着每次验算而变化。
class CNeuronMultiModel : public CNeuronBaseOCL { protected: int iModels; int iUpdateModel; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); public: CNeuronMultiModel(void){}; ~CNeuronMultiModel(void){}; virtual bool Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, int models); virtual void SetActivationFunction(ENUM_ACTIVATION value) { activation = value; } //--- virtual bool calcHiddenGradients(CNeuronBaseOCL *NeuronOCL); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronMultiModels; } };
在类中,我们不会创建新的内部对象,因此类的构造函数和析构函数保持为空。 我们开始创建 Init 类初始化方法。 该方法会从参数里接收:
- numInputs — 模型前一层神经元的数值
- open_cl — 指向 OpenCL 对象的指针
- numNeurons — 模型层中的神经元数值
- models — 融汇中的模型数值。
bool CNeuronMultiModel::Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, int models) { if(CheckPointer(open_cl) == POINTER_INVALID || numNeurons <= 0 || models <= 0) return false;
在方法主体中,我们立即检查指向 OpenCL 对象的指针是否相关,以及是否正确指定了层和融合的维度。 之后,我们将必要的常量保存到内部变量当中。
OpenCL = open_cl;
optimization = ADAM;
iBatch = 1;
iModels = models;
请注意,我们仅为 Adam 方法创建了权重矩阵更新内核。 因此,我们将指定此方法来优化模型,无论在参数中得到什么。
在此之后,我们创建缓冲区来记录神经层和误差梯度的结果。 请注意,所有缓冲区的大小与融汇中的模型数量成比例增加。 在初始化阶段,缓冲区初始化为零值。
//--- if(CheckPointer(Output) == POINTER_INVALID) { Output = new CBufferFloat(); if(CheckPointer(Output) == POINTER_INVALID) return false; } if(!Output.BufferInit(numNeurons * models, 0.0)) return false; if(!Output.BufferCreate(OpenCL)) return false; //--- if(CheckPointer(Gradient) == POINTER_INVALID) { Gradient = new CBufferFloat(); if(CheckPointer(Gradient) == POINTER_INVALID) return false; } if(!Gradient.BufferInit((numNeurons + 1)*models, 0.0)) return false; if(!Gradient.BufferCreate(OpenCL)) return false;
接下来,我们使用随机值初始化权重矩阵缓冲区。 缓冲区大小必须足够大,以便存储当前神经层中所有融汇模型的权重。
//--- if(CheckPointer(Weights) == POINTER_INVALID) { Weights = new CBufferFloat(); if(CheckPointer(Weights) == POINTER_INVALID) return false; } int count = (int)((numInputs + 1) * numNeurons * models); if(!Weights.Reserve(count)) return false; float k = (float)(1 / sqrt(numInputs + 1)); for(int i = 0; i < count; i++) { if(!Weights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier)) return false; } if(!Weights.BufferCreate(OpenCL)) return false;
实现 Adam 优化方法需要创建两个数据缓冲区来记录时刻 1 和 2。 指定缓冲区的大小类似于权重矩阵的大小。 在初始化阶段,我们用零值初始化这些缓冲区。
//--- if(CheckPointer(DeltaWeights) != POINTER_INVALID) delete DeltaWeights; //--- if(CheckPointer(FirstMomentum) == POINTER_INVALID) { FirstMomentum = new CBufferFloat(); if(CheckPointer(FirstMomentum) == POINTER_INVALID) return false; } if(!FirstMomentum.BufferInit(count, 0)) return false; if(!FirstMomentum.BufferCreate(OpenCL)) return false; //--- if(CheckPointer(SecondMomentum) == POINTER_INVALID) { SecondMomentum = new CBufferFloat(); if(CheckPointer(SecondMomentum) == POINTER_INVALID) return false; } if(!SecondMomentum.BufferInit(count, 0)) return false; if(!SecondMomentum.BufferCreate(OpenCL)) return false; //--- return true; }
不要忘记在每个阶段监控操作过程。 上述所有操作全部成功后,我们完成该方法。
初始化之后,我们迈入 feedForward 方法。 在参数中,该方法仅接收指向前一个神经层对象的指针。 在方法主体中,我们立即检查收到的指针的相关性。
bool CNeuronMultiModel::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID) return false;
为了执行神经层算法提供的所有前馈操作,我们已经在 OpenCL 程序中创建了一个内核。 现在我们需要将必要的数据传输到内核,并调用其执行。
首先,我们定义问题空间。 之前,我们曾决定使用二维问题空间。 在第一个维度中,我们指示一个模型输出的神经元数量,在第二个维度中,我们指定此类模型的数量。 当初始化类时,我们没有将神经元的数量保存在一个模型的层中。 因此,现在,为了判定问题空间第一维的大小,我们将层输出处的神经元总数除以融汇中的模型数量。 第二个维度更容易。 在此,我们有一个单独的变量,其中包含融汇中的模型数量。
uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = Output.Total() / iModels; global_work_size[1] = iModels;
定义任务空间之后,我们将必要的初始化数据传递给内核参数。 请务必检查操作执行结果。
if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_w, getWeightsIndex())) { printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_i, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_o, Output.GetIndex())) { printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_inputs, NeuronOCL.Neurons() / iModels)) { printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_activation, (int)activation)) { printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__); return false; }
请注意,我们使用新内核新创建的 ID 来指定内核。 为了指定参数,我们采用基础全连接层的相应内核的标识符。 这可以通过保存所有内核参数及其序列来实现。
传递完所有参数后,我们所要做的就是将内核发送到执行队列。
if(!OpenCL.Execute(def_k_FFMultiModels, 2, global_work_offset, global_work_size)) { printf("Error of execution kernel FeedForward: %d", GetLastError()); return false; } //--- return true; }
我们检查所有操作的结果,并退出该方法。
接下来,我们迈入基于反向传播方法操作。 首先,我们来看一下误差梯度分布方法 calcHiddenGradients。 与直接传递一样,在方法参数中,我们收到指向前一个神经层对象的指针。 在方法的主体中,我们要立即检查收到的指针的相关性。
bool CNeuronMultiModel::calcHiddenGradients(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID) return false;
下一步是定义问题空间。 这里的一切都类似于前馈方法。
uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = NeuronOCL.Neurons() / iModels; global_work_size[1] = iModels;
然后我们将初始化数据传递给内核参数。
if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_w, getWeightsIndex())) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_g, getGradientIndex())) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_o, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_ig, NeuronOCL.getGradientIndex())) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_outputs, Neurons() / iModels)) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_activation, NeuronOCL.Activation())) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; }
如您所见,这是一个相当标准的算法,用于组织 OpenCL 程序内核的工作,我们已经不止一次实现了该算法。 但是,传递模型标识符进行训练存在细微差别。 我们必须选择一个随机的模型编号进行训练。 为此,我们将使用伪随机数生成器。 但是,不要忘记,对于此模型,我们必须在下一步更新权重矩阵。 因此,我们将生成的随机模型标识符保存到之前创建的 iUpdateModel 变量之中。 我们可以在更新权重矩阵时采用其数值。
iUpdateModel = (int)MathRound(MathRand() / 32767.0 * (iModels - 1)); if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_model, iUpdateModel)) { printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__); return false; }
成功传递所有参数后,我们将内核发送到执行队列,并完成方法。
if(!OpenCL.Execute(def_k_HGMultiModels, 2, global_work_offset, global_work_size)) { printf("Error of execution kernel CalcHiddenGradient: %d", GetLastError()); return false; } //--- return true; }
更新权重矩阵的算法完全重复了准备和排队内核的步骤,不包含任何套路。 因此,我不会在这里赘述。 其完整代码可在附件中找到。
为了操控文件,我们用到 Save 和 Load 方法。 它们的算法非常简单。 在新类中,我们只创建两个变量:融汇中的模型数量,和训练模型的标识符。 只有第一个变量包含我们需要保存的超参数。 保存所有继承的对象和变量的过程在父类的方法中已有规划。 该类还提供必要的控制。 因此,为了保存数据,我们只需要先调用父类的类似方法,然后只保存一个超参数的值。
bool CNeuronMultiModel::Save(const int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) return false; if(FileWriteInteger(file_handle, iModels) <= 0) return false; //--- return true; }
从文件加载的数据以类似的方式组织。
至此我们针对新类代码的工作完毕。 其所有方法的完整代码可以在附件中找到。
但是在使用该类之前,我们需要在函数库代码中执行更多操作。 首先,我们需要创建常量来标识内核和添加的参数。
#define def_k_FFMultiModels 46 ///< Index of the kernel of the multi-models neuron to calculate feed forward #define def_k_HGMultiModels 47 ///< Index of the kernel of the multi-models neuron to calculate hiden gradient #define def_k_chg_model 6 ///< Number of model to calculate #define def_k_UWMultiModels 48 ///< Index of the kernel of the multi-models neuron to update weights #define def_k_uwa_model 9 ///< Number of model to update
然后我们添加:
- 在 CNet::Create 方法中创建神经层新类型的模块
- CLayer::CreateElement 方法的新层类型
- 神经网络基类的前馈调度方法中的新类型
- 反向传播调度方法 CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject) 的新类型。
我们构建了一个用于多个独立全连接层并行操作的类,这令我们能够创建模型的融汇。 但这只是一部分,而不是凭借分歧进行研究的整个算法。 为了实现完整的算法,我们将创建一个新的 CEVD 模型类,类似于内在好奇心模块。 该类的结构有许多相似之处。 这可以从方法和变量的名称中看到。 我们看到体验回放缓冲区 CReplayBuffer。 有两个内部模型 cTargetNet 和 cForwardNet,但没有逆模型。 作为 cForwardNet,我们将使用一个模型融汇。 与往常一样,差异在于细节。
//+------------------------------------------------------------------+ //| Exploration via Disagreement | //+------------------------------------------------------------------+ class CEVD : protected CNet { protected: uint iMinBufferSize; uint iStateEmbedingLayer; double dPrevBalance; bool bUseTargetNet; bool bTrainMode; //--- CNet cTargetNet; CReplayBuffer cReplay; CNet cForwardNet; virtual bool AddInputData(CArrayFloat *inputVals); public: CEVD(); CEVD(CArrayObj *Description, CArrayObj *Forward); bool Create(CArrayObj *Description, CArrayObj *Forward); ~CEVD(); int feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true); bool backProp(int batch, float discount = 0.999f); int getAction(int state_size = 0); float getRecentAverageError() { return recentAverageError; } bool Save(string file_name, bool common = true); bool Save(string dqn, string forward, bool common = true); virtual bool Load(string file_name, bool common = true); bool Load(string dqn, string forward, uint state_layer, bool common = true); //--- virtual int Type(void) const { return defEVD; } virtual bool TrainMode(bool flag) { bTrainMode = flag; return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag));} virtual bool GetLayerOutput(uint layer, CBufferFloat *&result) { return CNet::GetLayerOutput(layer, result); } //--- virtual void SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; } virtual void SetBufferSize(uint min, uint max); };
我们添加 bTrainMode 变量,从而将算法分为操作和训练过程。 我们添加了 bUseTargetNet 标志,因为我们消除了在每个模型更新包之前不断更新 cTargetNet。 我们还对方法的算法进行了修改。 但首事首做。
前馈方法和判定代理者动作的方法现在将算法拆分为操作和训练过程。 这是因为在训练期间,我们希望迫使代理者尽可能多地探索环境。 在运营过程中,与之对比,我们希望消除不必要的风险,只遵循内部政策。 我们来看看这是如何实现的。
前馈方法的开头重复了相应的内在好奇心模块方法。 在参数中,我们得到系统的初始状态。 我们用账户状态和开仓数据对其进行补充。 然后我们调用训练模型的前馈方法。
int CEVD::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true) { if(!AddInputData(inputVals)) return -1; //--- if(!CNet::feedForward(inputVals, window, tem)) return -1;
但随后动作选择算法分为 2 条支流:训练和操作。 在训练模式下,我们从训练的模型中读取环境的隐藏(压缩)状态,并通过动态模型的融汇执行前馈传递。 我要提醒您,与内部好奇心模块不同,我们查看的状态预测不是针对一个特定动作,而是一次性查看所有可能动作的范围。 只有在融汇的前向验算成功之后,我们才调用判定最佳动作的方法。 我们稍后再去熟悉该方法。
int action = -1; if(bTrainMode) { CBufferFloat *state; //if(!GetLayerOutput(1, state)) // return -1; if(!GetLayerOutput(iStateEmbedingLayer, state)) return -1; if(!cForwardNet.feedForward(state, 1, false)) { delete state; return -1; } double balance = AccountInfoDouble(ACCOUNT_BALANCE); double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance); dPrevBalance = balance; action = getAction(state.Total()); delete state; if(action < 0 || action > 3) return -1; if(!cReplay.AddState(inputVals, action, reward)) return -1; }
一旦动作成功定义后,我们将状态集添加到体验回放缓冲区。
在操作模式下,我们不执行不必要的操作,而只是根据代理者的内在策略判定最优动作,并完成方法。
else action = getAction(); //--- return action; }
判定最优动作的算法也分为两条分支:训练和操作。
int CEVD::getAction(int state_size = 0) { CBufferFloat *temp; //--- get the result of the trained model. CNet::getResults(temp); if(!temp) return -1;
在方法开始时,我们加载训练模型的前向验算结果。 然后,对于模型训练,我们依据动态模型融汇针对每个可能动作所做预测得到的方差值来调整该值。 为此,我们首先将融汇的结果上传到向量中,然后将向量转换为矩阵。 在成果矩阵中,每个单独的行将表示对于单独动作的预测系统状态。 我们的矩阵包含来自所有融汇模型的预测。 为了方便处理结果,我们将矩阵水平划分为若干个较小尺寸的相等矩阵。 此类矩阵的数量将等于融汇中的模型数量。 每个这样的矩阵都将含有与我们的代理者可能动作范围相对应的行维度。
现在我们可以使用矩阵运算,首先找到对应单个状态组件的每个单独动作的平均值矩阵。 然后我们可以计算预测矩阵与平均值的偏差的方差。 我们将每个动作的平均方差添加到训练模型的预测奖励值当中。 在这一点上,我们可以采用一个因子来平衡勘探和开发。 为了最大限度地探索环境,我们可以只用预测值的方差,而不关注预期奖励。 以这种方式,我们激励模型尽可能多地从环境中学习,而不会影响代者理的策略。
//--- in training mode, make allowances for "curiosity" if(bTrainMode && state_size > 0) { vector<float> model; matrix<float> forward; cForwardNet.getResults(model); forward.Init(1, model.Size()); forward.Row(model, 0); temp.GetData(model); //--- int actions = (int)model.Size(); forward.Reshape(forward.Cols() / state_size, state_size); matrix<float> ensemble[]; if(!forward.Hsplit(forward.Rows() / actions, ensemble)) return -1; matrix<float> means = ensemble[0]; int total = ArraySize(ensemble); for(int i = 1; i < total; i++) means += ensemble[i]; means = means / total; for(int i = 0; i < total; i++) ensemble[i] -= means; means = MathPow(ensemble[0], 2.0); for(int i = 1 ; i < total; i++) means += MathPow(ensemble[i], 2.0); model += means.Sum(1) / total; temp.AssignArray(model); }
在模型操作过程中,我们不做任何调整,而是基于最大化预期回报的原则判定最优动作。
//--- return temp.Argmax(); }
附件中提供了该方法的完整代码。
我们来进一步讨论逆验算方法。 为了消除模型操作期间不必要的迭代,在模型训练标志缺失的情况下,后向验算方法会立即完成其工作。 这允许您快速从模型训练模式切换到测试模式,而无需修改 EA 代码。
bool CEVD::backProp(int batch, float discount = 0.999000f) { //--- if(cReplay.Total() < (int)iMinBufferSize || !bTrainMode) return true;
经由控制模块后,我们创建必要的局部变量。
//--- CBufferFloat *state1, *state2, *targetVals = new CBufferFloat(); vector<float> target, actions, st1, st2, result; matrix<float> forward; double reward; int action;
在准备工作之后,我们按照方法参数中指定的封包大小组织一个模型训练周期。
//--- training loop in the batch size for(int i = 0; i < batch; i++) { //--- get a random state and the buffer replay if(!cReplay.GetRendomState(state1, action, reward, state2)) return false; //--- feed forward pass of the training model ("current" state) if(!CNet::feedForward(state1, 1, false)) return false;
在循环主体中,我们首先从体验回放缓冲区获取一组随机状态,依据结果状态执行贯穿训练模型的前馈验算。
getResults(target); //--- unload state embedding if(!GetLayerOutput(iStateEmbedingLayer, state1)) return false; //--- target net feed forward if(!cTargetNet.feedForward(state2, 1, false)) return false;
在训练模型上执行前馈验算之后,我们保存结果和隐藏状态。
使用目标网络,我们以类似的方式获得后续系统状态的嵌入。
//--- reward adjustment if(bUseTargetNet) { cTargetNet.getResults(targetVals); reward += discount * targetVals.Maximum(); } target[action] = (float)reward; if(!targetVals.AssignArray(target)) return false; //--- backpropagation pass of the model being trained CNet::backProp(targetVals);
如有必要,我们将系统的外部奖励调整为预测的目标网络值,并执行训练模型的反向传播验算。
在下一步中,我们采用上面获得的两个后续状态的嵌入来训练模型融汇。
//--- forward net feed forward pass - next state prediction if(!cForwardNet.feedForward(state1, 1, false)) return false; //--- download "future" state embedding if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2)) return false;
首先,我们依据第一个状态嵌入执行贯穿模型融汇前馈验算。
然后,我们下载前馈暗算的结果,并基于它们准备目标值,方法是将完美动作的向量替换为由目标网络获得的后续状态的嵌入。
为此,我们将模型融汇直接传递的结果转换为矩阵,其列数等于状态的嵌入。 矩阵包含整个模型融汇的结果。 因此,我们实现一个循环,并将预测状态替换为所有融汇模型中对应完美动作的目标状态。
//--- prepare targets for forward net cForwardNet.getResults(result); forward.Init(1, result.Size()); forward.Row(result, 0); forward.Reshape(result.Size() / state2.Total(), state2.Total()); int ensemble = (int)(forward.Rows() / target.Size()); //--- copy the target state to the ensemble goals matrix state2.GetData(st2); for(int r = 0; r < ensemble; r++) forward.Row(st2, r * target.Size() + action);
乍一看,替换所有模型中的目标状态,与在不同数据上训练融汇模型的想法背道而驰。 但我要提醒您,我们在 CNeuronMultiModel 类的向后验算方法中组织了随机模型选择。 在这个阶段,我们不知道哪个模型将进行训练。 因此,我们为所有模型准备目标值。 稍后才选择训练模型。
//--- backpropagation pass of foward net targetVals.AssignArray(forward); cForwardNet.backProp(targetVals); } //--- delete state1; delete state2; delete targetVals; //--- return true; }
在训练循环主体的迭代结束时,我们使用准备好的数据通过动态正向模型的融汇执行反向验算。 请注意,在准备目标值时,我们仅更改了单个动作的目标值。 我们将其余部分保留在预测值水平。 这允许我们在执行反向传播验算时,仅获得特定动作的误差梯度。 在其它方向上,我们希望得到零误差。
循环迭代成功完成后,我们删除不必要的对象,并终止方法。
该类的其余方法的构造类似于内在好奇心模块的相应方法。 它们的完整代码可以在附件中找到。
3. 测试
必要的类及其方法创建完毕之后,我们迈入测试已完成的工作。 为了测试所创建类的功能,我们将创建一个智能系统,EVDRL-learning.mq5。 和以前一样,我们将基于前面文章中的那个创建智能系统。 这次我们不会对训练模型的架构进行更改。 取而代之,我们会修改正在使用的模型类。 我们用凭借分歧进行探索来代替内在好奇心模块。
//+------------------------------------------------------------------+ //| Includes | //+------------------------------------------------------------------+ #include "EVD.mqh" ........... ........... ........... ........... //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ CEVD StudyNet;
我们还要针对模型架构的描述方法进行更改。 我们将删除逆向模型架构的描述,并对正向模型的架构进行更改。 最后一个值得细说。 以前,对于前向模型,我们使用了一个带有一个隐藏层的感知器。 我们为融汇模型创建一个类似的体系结构。
当解决问题是一种直接途径时,我们必须创建一个初始数据层,其缓冲区大小足以容纳所有模型,以及为我们新的模型融汇类 CNeuronMultiModel 创建两个连续的层。 但请注意,所有融汇模型都采用相同的系统状态。 这意味着为了维护这样的融汇,我们需要每次在源数据层中重复一组数据,次数则与融汇中的模型一样多。 在我看来,这是对 OpenCL 关联环境内存的低效使用,这会导致花费大量额外时间去连接源数据缓冲区,同时增加从设备的 RAM 传输大量数据到 OpenCL 关联环境内存所花费的时间。
安排所有模型访问一个仅包含系统状态副本的小型数据缓冲区会更有效。 但是,在创建 CNeuronMultiModel 类的前馈方法时,我们没有提供这样的选项。
我们来看一下基本的全连接神经层的架构。 在这一层中,每个神经元都有自己的权重向量,独立于这一层中的其它神经元。 在实践中,这是一个神经元大小的独立模型的融汇。 这意味着我们可以使用一个基本的全连接神经层作为融汇中所有模型的隐藏层。 我们只需要实现一个足够大小的神经层,为我们融汇中的所有模型提供数据。
因此,对于我们的 Forward 模型融汇,我们创建了一个包含 100 个元素的源数据层。 这是我们从主模型接收的系统状态的压缩表示的大小。 在这种情况下,我们不添加动作向量,因为我们希望从模型中接收整个可能动作范围的预测状态。
接下来,我们将使用 5 个模型的融汇。 作为一个隐藏层,我们创建了一个由 1000 个元素组成的完全连接的神经层(每个模型 200 个神经元)。
接下来是我们的新模型融汇层。 在此,我们指定以下神经层描述:
- 神经网络类型 (descr.type) defNeuronMultiModels;
- 每个模型的神经元数量 (descr.count) 400 (100 个元素描述四种可能动作状态中的每一种;
- 前一层的神经元数为 1 个模型 (descr.window ) 200;
- 融汇中的模型数量( (descr.step) 5;
- 激活函数 (descr.activation) TANH (双曲正切,必须对应于主模型中嵌入层的激活函数);
- 优化方法 (descr.optimization) ADAM (这种类型的神经层唯一可能的方法)。
bool CreateDescriptions(CArrayObj *Description, CArrayObj *Forward) { //--- ........... ........... //--- if(!Forward) { Forward = new CArrayObj(); if(!Forward) return false; } //--- Model ........... ........... ........... ........... //--- Forward Forward.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 100; descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!Forward.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 1000; descr.activation = TANH; descr.optimization = ADAM; if(!Forward.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMultiModels; descr.count = 400; descr.window = 200; descr.step = 5; descr.activation = TANH; descr.optimization = ADAM; if(!Forward.Add(descr)) { delete descr; return false; } //--- return true; }
我们在不更改条件的情况下训练和测试了模型:EURUSD 货币对、H1 时间帧、默认指标参数。
根据测试训练结果,我可以说训练一组模型比训练单个 Forward 模型需要更多的时间。 在这种情况下,您可以观察到模型最初执行操作时如何的混乱。 在学习过程中,这种随机性会降低。
总体而言,该模型能够在测试期间获利。
结束语
在训练强化模型时,从环境中学习仍然是一个重要问题。 本文提出了解决这个问题的另一种方法:通过分歧进行探索。 代理者依据它在与环境交互的过程中,采用策略优化方法收集到的数据在线学习。 与此同时,在代理者与环境的每次交互后,模型的融汇都会更新,这令代理者可以在每一步更新其内部环境模型,并获得对未来环境状态的更准确的预测。
我们已创建了一个模型,并利用 MetaTrader 5 策略测试器中的真实数据对其进行了测试。 该模式在测试期间产生了利润。 结果表明,朝该方向的进一步开发具有良好的前景。 与此同时,该模型在相当短的时间内进行了训练和测试。 若要在实际交易中运用该模型,需要依据扩充的历史数据进行额外的模型训练。
参考
本文中用到的程序
# | 发行 | 类型 | 说明 |
---|---|---|---|
1 | EVDRL-learning.mq5 | EA | 训练模型的智能系统 |
2 | EVD.mqh | 类库 | 通过分歧库类进行探索 |
2 | ICM.mqh | 类库 | 内在好奇心模块库类 |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
…
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/12508

