神经网络变得轻松(第三十五部分):内在好奇心模块
内容
概述
我们继续研究强化学习算法。 正如我们之前所知晓的,所有强化学习算法都建立在每次代理者通过执行某些动作,从一个环境状态过渡到另一个环境状态时,从环境中获得奖励的范式基础上。 反过来,代理者努力以所得的最大化奖励方式构建其动作政策。 在开始研究强化学习方法时,我们提到了建立明确的奖励政策的重要性,该政策在实现模型训练目标方面起着关键作用之一。
但在大多数现实生活中,每个动作并非都伴随奖励。 动作和奖励之间可能存在时间滞后,时长各不相同。 有时取决于动作的次数才能获得一个奖励。 在这种情况下,我们将总奖励分成几个组成部分,并将它们分配到代理者从行动到奖励的整个路径上。 这是一个相当复杂的过程,遍布惯例和妥协。
交易是这些任务之一。 代理者必须在正确的时刻在正确的方向上开仓。 然后它应该等待持仓盈利能力达到最大值的那一刻。 之后,它应该平仓,从而锁定操作结果。 故此,我们在平仓时,会以账户余额变化的形式获得奖励。 在前面研究的算法中,我们将此奖励分布在步骤之间(一步是一根烛条的时间间隔),其金额等于品种价格变化的倍数。 但这有多正确呢? 在每个步骤中,代理者执行一个动作,譬如执行交易操作,或做出不执行操作的决定。 因此,不交易的决定也是代理者选择实施的动作。 那么,就存在每个行动对整体结果有多大贡献的问题。
是否有其它方法来组织奖励策略,和模型训练过程?
1. 好奇心是学习的冲动
看看生物的行为。 动物和鸟类在获取食物形式的奖励之前能够长途跋涉。 人类并不会因为他们的每一个动作而获得奖励。 人类的学习原则是多方面的。 学习的驱动力之一是好奇心。 当您面前有一扇紧闭的大门时,是好奇心让您推开它,并向内查看。 这就是人性。
我们的大脑经过设计,如此,当我们执行某些动作时,我们已经能预测到其 1-2 步结果的影响。 有时甚至更多。 好吧,我们执行任何动作都是为了获得所需的结果。 然后,通过将结果与我们的期望进行比较,我们纠正我们的动作。 我们也知道,只有在游戏里,我们才能反复尝试。 在现实生活中,不可能退后一步,再重复同样的情况。 每次新的尝试都会产生一个新的结果。 所以,在采取任何动作之前,我们会分析以前获得的所有经验。 基于这些经验,我们选择对我们来说正确的动作。
当我们进入一个陌生的环境时,我们会尝试探索它,并记住环境。 在这样做的时候,我们可能不会考虑这样做在未来会带来什么好处。 我们的动作不会立即得到回报。 我们只获得将来可能有用的经验。
我们之前已经提到需要尽可能多地探索环境,以及在采取以前获得的经验与研究环境之间进行进行平衡。 我们甚至在 ɛ-贪婪策略中引入了新颖性超参数。 但超参数是一个常量。 我们现在的目的是训练模型,并根据情况自行管理新颖性级别。
文章 “好奇心驱动的自我监督预测探索” 的作者尝试在创建算法时应用这种方式。 这篇文章发表于 2017 年 5 月。 该方法基于好奇心的形成,作为预测其动作后果的模型能力的误差。 对于以前没有亲身经历过的行为,好奇心更高。 该文探讨了三大挑战:
- 罕见的外在奖励。 好奇心允许智慧体以较少的与环境的交互来实现其目标。
- 没有外在奖励的训练。 好奇心促使智慧体有效地探索环境,即使环境没有外在奖励。
- 泛化到不可见的场景。 基于以往经验中获取的知识,比从头开始更有助于代理者更快地探索新的地方。
作者提出了一个相当简单的想法:在外部奖励 re 中,我们添加了一些内在奖励 ri,这将是好奇心的衡量标准,它将鼓励对环境的探索。 然后,这样的鸡尾就能提供给代理者进行训练。 奖励缩放因子可用于调整外在和内在奖励的影响。 这些因素是模型的超参数。
主要的新颖之处在于产生这种内在奖励的 ICM 模块的架构。 内在好奇心模块包含三个独立的模型:
- 编码器
- 逆向模型
- 正向模型
两个后续系统状态和执行的动作被输入到模块之中。 该动作被编码为独热向量。 该动作可在模块外部和内部进行编码。 输入到模块当中的系统状态经编码器进行编码。 编码器旨在减少描述系统状态的张量的维数,并过滤数据。 作者将描述系统状态的所有特征分为三组:
- 受代理者影响的人。
- 不受代理者影响,但影响代理者。
- 不受代理者影响,也不会影响代理者。
编码器应该有助于关注前两组,并抵消第三组的影响。
逆向模型接收 2 个后续状态的编码状态,并学习判定在状态转换间所执行的动作。 逆向模型的训练与编码器配合,可区分前 2 组特征。 LogLoss 逆向模型的损失函数。
前向模型学习根据编码的当前状态和所执行动作预测下一个状态。 好奇心的衡量标准是预测的质量。 MSE 计算的预测误差是一种内在奖励。
也许这看起来很奇怪,但随着前向模型误差的增加,我们正在训练的 DQN 模型的内在奖励也会增加。 这个思路是鼓励模型执行更多动作,而其结果是未知的。 因此,该模型将探索环境。 正如我们探索环境,模型的好奇心降低,则 DQN 最大化外在奖励。
内在好奇心模块可与我们到目前为止讨论的任何模型配合运用。 而且,我们不要忘记使用所有以前研究过的解决方案架构来改善模型收敛性。
方法作者进行的实际测试表明,该算法在计算机游戏中确有实效,并能在游戏关卡结束时获得奖励。 此外,该模型还展示了泛化的能力 — 在进入新的游戏关卡时,它可以依靠以前获得的经验。 特别有趣的是,当纹理发生变化且噪点添加时,模型的表现依然良好。 也就是说,模型学会了识别主要事物,并忽略了各种噪音。 这样就提高了模型在各种环境状态下的稳定性。
2. 以 MQL5 实现的内在好奇心模块
我们已经简要地研究了该方法的理论方面。 现在我们继续文章的实施部分。 在这一部分中,我们将利用 MQL5 实现该方法。 在继续实施之前,请注意,出于多种原因,我们不会采用前面研究过的方法。
首先要改变的是奖励政策。 我决定更贴近真实情况。 外在奖励将是账户余额的变化。 请注意,这是余额,而非净值变化。 我明白这样的奖励可能非常罕见,但我们应用新方法来解决这个问题。
由于我们仅限于余额变化形式的奖励,但同时,每个代理者动作都可体现为交易操作,因此我们必须在系统状态描述中添加表征交易账户状态的变量。 我们还必须监控开仓和平仓,以及每笔持仓的累计浮动利润。
为了实现在 EA 代码中无需跟踪每笔仓位,我决定将模型训练过程转移到策略测试器当中。 我们让模型在策略测试器中执行操作。 然后,通过调用账户状态和持仓轮询函数,我们可以从策略测试器获取所有必要的信息。
因此,我们需要为经验重演创建一个内存缓冲区。 我们在文章“神经网络变得轻松(第 二十七 部分):深度 Q-学习(DQN)”中讨论了创建这种缓冲区的原因。 以前,我们使用训练期间的整个品种历史记录作为缓冲区。 但现在这样做是不可能的,因为我们添加了帐户状态数据。 故此,我们将在程序内实现累积经验缓冲区。
此外,我们的 EA 能够同时开立多笔持仓,包括相反方向的持仓。 这将更改可能的代理者动作的空间。 代理者能够执行四个动作:
0 — 买入
1 — 卖出
2 — 全部持仓平仓
3 — 跳过一轮,等待合适的状态
我们开始从实现经验重演缓冲区开发。
2.1. 经验重演模块
经验重演缓冲区允许不断添加记录。 每次我们都会添加一个完整的数据包,其中包括:
- 环境状态描述张量
- 正在采取的动作
- 获得的外在奖励
实现缓冲区的最合适方法是利用动态对象数组。 每条记录都包含一个带有上述信息的对象。
为了更好地组织缓冲区中的每个单独记录,我们将创建派生自 CObject 基类的 CReplayState 类。 在该类中,我们用一个静态数据缓冲区对象,和两个变量来存储数据、采取的动作和奖励。
请注意,代理者所是从当时状态。 且过渡该状态后它获得相应奖励。 即,由于在上一步中执行的动作,这是从上一个状态过渡到当前状态的奖励。 尽管奖励和动作已添加到同一记录中的缓冲区中,但它们实际上属于不同的时间段。
class CReplayState : public CObject { protected: CBufferFloat cState; int iAction; double dReaward; public: CReplayState(CBufferFloat *state, int action, double reward); ~CReplayState(void) {}; bool GetCurrent(CBufferFloat *&state, int &action); bool GetNext(CBufferFloat *&state, double &reward); };
在类的构造函数中,我们获取所有必要的信息,并将其复制到类变量和内部对象之中。
CReplayState::CReplayState(CBufferFloat *state, int action, double reward) { cState.AssignArray(state); iAction = action; dReaward = reward; }
由于我们用的是静态数据缓冲区对象,因此我们的类析构函数保持为空。
我们再向类中添加另外两个方法,用以访问保存的数据 GetCurrent 和 GetNext。 在第一种情况下,我们返回状态和动作。 在第二种情况下,我们返回动作和奖励。
bool CReplayState::GetCurrent(CBufferFloat *&state, int &action) { action = iAction; double reward; return GetNext(state, reward); }
这两种方法的算法都非常简单。 我们稍后会看到如何使用它们。
bool CReplayState::GetNext(CBufferFloat *&state, double &reward) { reward = dReaward; if(!state) { state = new CBufferFloat(); if(!state) return false; } return state.AssignArray(GetPointer(cState)); }
创建单个记录对象后,我们继续创建经验缓冲区 CReplayBuffer,作为对象动态数组 CArrayObj 类的继承者。 在 EA 操作期间,该类会不断由新的状态更新。 为了避免内存溢出,我们取 iMaxSize 变量值来限制其最大尺寸。 我们还将添加 SetMaxSize 方法来管理缓冲区大小。 我们不会在该类的主体中创建其它对象和变量。 这就是为什么构造函数和析构函数是空的。
class CReplayBuffer : protected CArrayObj { protected: uint iMaxSize; public: CReplayBuffer(void) : iMaxSize(500) {}; ~CReplayBuffer(void) {}; //--- void SetMaxSize(uint size) { iMaxSize = size; } bool AddState(CBufferFloat *state, int action, double reward); bool GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2); bool GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2); int Total(void) { return CArrayObj::Total(); } };
为了将记录添加到缓冲区,我们将调用 AddState 方法。 该方法在参数中接收新的记录数据,包括状态张量、动作和外在奖励。
在方法主体中,我们检查指向系统状态缓冲区对象的指针。 如果指针检查成功,我们将创建一个新的记录对象,并将其添加到动态数组之中。 动态数组的主要操作是调用父类方法实现的。
之后,我们检查当前缓冲区大小。 如有必要,我们会删除最陈旧的对象,从而令缓冲区大小与指定的缓冲区大小最大值一致。
bool CReplayBuffer::AddState(CBufferFloat *state, int action, double reward) { if(!state) return false; //--- if(!Add(new CReplayState(state, action, reward))) return false; while(Total() > (int)iMaxSize) Delete(0); //--- return true; }
为了从缓冲区提取数据,我们将创建两个方法:GetRendomState 和 GetState。 第一个方法从缓冲区返回随机状态,第二个方法返回缓冲区中指定索引处的状态。 在第一个方法的主体中,我们只在缓冲区大小内生成一个随机数,并调用第二个方法来提取所生成索引处的数据。
bool CReplayBuffer::GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2) { int position = (int)(MathRand() * MathRand() / pow(32767.0, 2.0) * (Total() - 1)); return GetState(position, state1, action, reward, state2); }
如果您查看第二个方法 GetState 的算法,您会注意到请求的数据数量和以前保存的数据数量有差异。 保存时,我们收到一个系统状态,而现在请求了两个环境状态张量。
我们记住 Q-学习过程是如何组织的。 训练基于四个数据对象:
- 环境的当前状态
- 由代理者执行的动作
- 环境的下一个状态
- 在环境状态之间过渡的奖励
因此,我们需要从经验缓冲区中提取系统的两种后续状态。 此外,我们还保存了分析状态中的动作,以及过渡到相同状态的奖励。 因此,我们需要从一条记录中提取状态和动作,并从下一条记录中提取环境状态和奖励。 这就是我们规划上述 GetCurrent 和 GetNext 方法的方式。
现在,我们来看一下 GetState 方法的实现。 首先,在方法主体中,我们检查所需检索条目的指定索引。 它必须至少为 0,而最多则为缓冲区中倒数第二条记录的索引。 这是因为我们需要两个后续记录的数据。
接下来,我们调用 GetCurrent 提取指定索引处的记录。 然后我们调用 GetNext 方法提取下一条记录。 操作结果将返回到调用者程序。
bool CReplayBuffer::GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2) { if(position < 0 || position >= (Total() - 1)) return false; CReplayState* element = m_data[position]; if(!element || !element.GetCurrent(state1, action)) return false; element = m_data[position + 1]; if(!element.GetNext(state2, reward)) return false; //--- return true; }
经验缓冲区特定于指定的训练区间,保存其数据并无价值。 因此,无需为上面讨论的类创建文件操作方法。
2.2. 内在好奇心模块(ICM)
创建经验缓冲区后,我们继续实现内在好奇心模块算法。 如前面的理论部分所述,该模块用到三种模型:编码器、逆向模型和直向模型。 在我的实现中,我没有坚守作者提出的架构。 为了节省资源,我没有为固有好奇心模块创建单独的编码器。
原版体系结构意味着创建一个类似于训练 DQN 模型中所用的编码器。 我决定利用训练模型的现有编码器对信号进行编码。 当然,这需要模型的同步,和模型反向传播方法的一些补充。 不过,这将减少创建和训练附加编码器所需的内存和计算资源的消耗。
此外,我希望更精细地调整 DQN模型的编码器的形式,从而获得额外的利润。
为了实现该算法,我们创建一个新的 CICM 神经网络调度程序类,该类继承了我们的基准 CNet 神经网络调度类。 在类主体中添加了三个内部变量:
- iMinBufferSize — 启动训练模型所需的经验缓冲区的最小尺寸。
- iStateEmbedingLayer — 我们正在训练的模型的神经层的编号,我们将从中读取环境的编码状态。 这是完成模型编码器的神经层。
- dPrevBalance — 记录账户余额最后状态的变量。 我们将用它来确定外在奖励。
此外,我们将声明四个内部对象。 其中包括经验累积缓冲区的一个对象和三个神经网络对象:cTargetNet,cInverseNet 和 cForwardNet。
我们正在使用的 Q-学习,和 Target Net 是这种学习方法的主要支柱之一。
class CICM : protected CNet { protected: uint iMinBufferSize; uint iStateEmbedingLayer; double dPrevBalance; //--- CReplayBuffer cReplay; CNet cTargetNet; CNet cInverseNet; CNet cForwardNet; virtual bool AddInputData(CArrayFloat *inputVals); public: CICM(void); CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse); bool Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse); int feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true); bool backProp(int batch, float discount = 0.9f); int getAction(void); int getSample(void); float getRecentAverageError() { return recentAverageError; } bool Save(string file_name, bool common = true); bool Save(string dqn, string forward, string invers, bool common = true); virtual bool Load(string file_name, bool common = true); bool Load(string dqn, string forward, string invers, uint state_layer, bool common = true); //--- virtual int Type(void) const { return defICML; } virtual bool TrainMode(bool flag) { return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag) && cInverseNet.TrainMode(flag)); } virtual bool GetLayerOutput(uint layer, CBufferFloat *&result) { return CNet::GetLayerOutput(layer, result); } //--- virtual bool UpdateTarget(string file_name); virtual void SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; } virtual void SetBufferSize(uint min, uint max); };
在之前的文章中,我们已为神经网络模型操作创建了基准调度类的类似子类,并且新类的方法集与之前重写的方法几乎相同。 我们详述在重写的方法里所做的主要修改。 我们从模型创建方法 Create 开始。 以前创建的传递模型体系结构描述的过程,并未提供嵌套模型的创建。 为了不在此子过程里进行全局修改,我决定在 Create 方法参数中添加另外两个模型的描述。 在方法主体中,我们将按顺序调用所有用到模型的相关方法。 每个模型都会收到所需的体系结构定义。 请记住控制被调用方法的执行。
bool CICM::Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse) { if(!CNet::Create(Description)) return false; if(!cForwardNet.Create(Forward)) return false; if(!cInverseNet.Create(Inverse)) return false; cTargetNet.Create(NULL); //--- return true; }
请注意,调用此方法后,需要指定主模型神经层的数量,以便读取状态嵌入。 该操作是调用 SetStateEmbedingLayer 方法实现的。
virtual void SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
不同于以前相似的类,我们调用父类的前馈验算,在这种情况下,我们需要修改前馈验算的组织。
我们修改了返回类型。 以前,该方法返回操作执行后的布尔值,且我们调用 CNet::getResults 方法来获取前馈结果。 这是因为返回的结果是张量。 这次,新类的前馈方法将返回所选操作的离散值。 用户仍然可以选择贪婪策略,或来自概率分布中的动作样本。 它由另一个 sample 参数负责。
int CICM::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true) { if(!AddInputData(inputVals)) return -1; //--- if(!CNet::feedForward(inputVals, window, tem)) return -1; double balance = AccountInfoDouble(ACCOUNT_BALANCE); double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance); dPrevBalance = balance; int action = (sample ? getSample() : getAction()); if(!cReplay.AddState(inputVals, action, reward)) return -1; //--- return action; }
为了保持模型操作的一贯方式,在当前状态的描述张量中,我们期待仅从调用程序接收品种市场状态的指示。 但我们的新模型还需要有关帐户状态的信息。 我们将此信息添加到 AddInputData 方法的结果张量当中。 只有在成功添加必要的信息后,我们才会调用父类的前馈方法。
我们还有一些创新。 接下来,我们应将新数据添加到经验缓冲区。 为此,我们首先定义过渡到当前状态的外在奖励。 如上提议,我们用余额变化作为外在奖励。
接下来,我们根据用户选择的策略判定代理者的下一个动作。 然后我们将所有这些数据传递到经验累积缓冲区。 一旦完成上述所有操作后,我们将选定的代理者动作返回给调用者程序。
请注意,我们在每一步都要控制过程。 如果在任何步骤中发生错误,该方法将向调用方程序返回 -1。 因此,在规划代理者动作的可能空间时,无比考虑到这一点,或更改返回值,以便调用方可以清楚地区分错误状态与代理者的动作。
下一步是修改 backProp 方法。 该方法经历了最戏剧性的变化。 首先,我们有一组完全改变的参数。 它们不再包含以往的目标值张量。 新方法在参数中仅接收更新包的大小,以及折扣系数。
在方法主体中,我们首先检查经验缓冲区的大小。 仅当模型积累了足够的经验时,方法才能执行更进一步的操作。
请注意,如果经验不足,我们则以 true 结果退出。 而仅当操作执行错误时,才应返回 false 值。 这允许模型如常执行进一步的操作。
bool CICM::backProp(int batch, float discount = 0.900000f) { //--- if(cReplay.Total() < (int)iMinBufferSize) return true; if(!UpdateTarget(TargetNetFile)) return false;
此外,在开始模型训练过程之前,请确保更新目标网络。 因为其编码器将用于在转换后获取环境状态嵌入。
接下来,我们多做一点准备工作,并声明几个内部变量和对象,这些变量和对象将用于中间数据存储。
CLayer *currentLayer, *nextLayer, *prevLayer; CNeuronBaseOCL *neuron; CBufferFloat *state1, *state2, *targetVals = new CBufferFloat(); vector<float> target, actions, st1, st2, result; 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;
在主模型的前馈验算成功执行后,我们将实现运行前向模型前馈验算的准备工作。 在此,我们提取当前系统状态的嵌入,并创建所执行动作的独热向量。
//--- unload state embedding if(!GetLayerOutput(iStateEmbedingLayer, state1)) return false; //--- prepare a one-hot action vector and concatenate with the current state vector getResults(target); actions = vector<float>::Zeros(target.Size()); actions[action] = 1; if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1)) return false;
运行前向模型的前馈验算之后,预测下一个状态嵌入。
//--- forward net feed forward pass - next state prediction if(!cForwardNet.feedForward(targetVals, 1, false)) return false;
接下来,我们实现目标网络前馈,并提取下一个状态嵌入。
//--- feed forward if(!cTargetNet.feedForward(state2, 1, false)) return false; //--- unload the state embedding and concatenate with the "current" state embedding if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2)) return false;
我们将连续状态的两个嵌入组合成一个张量,并调用逆向模型的前馈验算方法。
//--- inverse net feed forward - defining the performed action. if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, 1, false)) return false;
接下来运行前向模型和逆向模型的反向传播方法。我们已为它们准备好了目标值,形式为下一个状态嵌入和独热执行动作向量。
//--- inverse net backpropagation if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals)) return false; //--- forward net backpropagation if(!cForwardNet.backProp(state2)) return false;
接下来,我们回到主模型的操作。 在此,我们通过添加内在好奇心奖励,以及由目标网络预测的预期未来奖励来调整奖励。
//--- reward adjustment cForwardNet.getResults(st1); state2.GetData(st2); reward += (MathPow(st2 - st1, 2)).Sum(); cTargetNet.getResults(targetVals); target[action] = (float)(reward + discount * targetVals.Maximum()); if(!targetVals.AssignArray(target)) return false;
准备好目标奖励后,我们可以运行主 DQN 模型的后向验算。 但有一个警告。 除了从预测奖励传播误差梯度外,我们还需要将逆向模型的误差梯度添加到状态嵌入模块之中。 为此,我们应该在运行主模型的反向传播验算之前,将误差梯度数据从逆向模型的源数据层复制到主模型嵌入层的误差梯度缓冲区。 这是因为整个算法的构建方式是,每次后向验算时,我们只需覆盖缓冲区中的数据。 故此,我们需要在误差梯度传播过程中打一个楔子。 为此,我们必须完全重写主模型反向传播验算的代码。
此处,我们首先判定模型的奖励预测误差,调用最后一个神经层的 calcOutputGradients 方法,该方法判定模型输出处的误差梯度。
//--- backpropagation pass of the model being trained { getResults(result); float error = result.Loss(target, LOSS_MSE); //--- currentLayer = layers.At(layers.Total() - 1); if(CheckPointer(currentLayer) == POINTER_INVALID) return false; neuron = currentLayer.At(0); if(!neuron.calcOutputGradients(targetVals, error)) return false; //--- backPropCount++; recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (float)backPropCount);
在此,我们将计算模型的平均预测误差。
下一步是将误差梯度传播到模型的所有神经层。 为此,我们将创建一个循环,对模型的所有神经层进行反向迭代,并针对所有神经层顺序调用 calcHiddenGradients 方法。 如您所记,此方法负责贯穿神经层传播误差梯度。
//--- Calc Hidden Gradients int total = layers.Total(); for(int layerNum = total - 2; layerNum >= 0; layerNum--) { nextLayer = currentLayer; currentLayer = layers.At(layerNum); neuron = currentLayer.At(0); if(!neuron.calcHiddenGradients(nextLayer.At(0))) return false;
在主模型训练子流程中,到目前为止,我们一直在完全重复同一父类方法的算法。 在这一点上,我们必须对算法进行小幅调整。
我们将添加一个条件来检查所分析神经层是否是系统状态编码器的输出。 如果检查成功,我们将逆向模型的误差梯度值添加到从下一个神经层获得的误差梯度之中。
我用到之前创建的 MatrixSum 内核来添加两个张量。 若要了解有关此内核的更多信息,请参阅文章“神经网络变得轻松(第八部分):关注机制”。
if(layerNum == iStateEmbedingLayer) { CLayer* temp = cInverseNet.layers.At(0); CNeuronBaseOCL* inv = temp.At(0); uint global_work_offset[1] = {0}; uint global_work_size[1]; global_work_size[0] = neuron.Neurons(); opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, neuron.getGradientIndex()); opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, inv.getGradientIndex()); opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, neuron.getGradientIndex()); opencl.SetArgument(def_k_MatrixSum, def_k_sum_dimension, 1); opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1); if(!opencl.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel MatrixSum: %d", GetLastError()); return false; } } }
为了正确执行此动作,需注意两个要点。
首先,逆向模型的反向传播方法必须将误差梯度传播到源数据层。 为此目的,在贯穿隐藏层传播梯度的循环中,必须用到条件 layerNum >= 0。
//--- Calc Hidden Gradients int total = layers.Total(); for(int layerNum = total - 2; layerNum >= 0; layerNum--) {
其次,在声明逆向模型的架构时,我们指定了类似于状态嵌入接收层的激活方法的结果级别激活方法。 此动作在前馈验算期间没有作用,但它在反向传播验算期间通过激活函数的导数调整误差梯度。
进一步的步骤类似于父类的反向传播算法。 传播误差梯度之后,我们更新主模型所有神经层的权重矩阵。
//--- prevLayer = layers.At(total - 1); for(int layerNum = total - 1; layerNum > 0; layerNum--) { currentLayer = prevLayer; prevLayer = layers.At(layerNum - 1); neuron = currentLayer.At(0); if(!neuron.UpdateInputWeights(prevLayer.At(0))) return false; } //--- for(int layerNum = 0; layerNum < total; layerNum++) { currentLayer = layers.At(layerNum); CNeuronBaseOCL *temp = currentLayer.At(0); if(!temp.TrainMode()) continue; if((layerNum + 1) == total && !temp.getGradient().BufferRead()) return false; break; } } }
请注意,我们只更新主学习模型的权重矩阵。 前向模型和逆向模型参数在执行相应模型的反向传播方法时被更新。
在最后,删除在方法中创建的辅助对象,并完成方法操作,返回正面结果。
delete state1; delete state2; delete targetVals; //--- return true; }
我想对文件操作方法说几句话。 由于我们在此算法中使用了多个模型,因此出现了有关如何保存训练模型的问题。 我在这里看到两个选项。 我们可将所有模型保存在一个文件当中,也可将每个模型保存在单独的文件之中。 我建议将模型保存在单独的文件之中,因为这提供了更多的操作自由。 我们可将经过训练的 DQN 模型下载到单独的文件当中,然后与前面讨论的模型一起使用。 我们还可以加载所有三个模型,并使用本文中讨论的方法。 唯一的不便就是每次都需要在主模型中指定状态嵌入层。 但我们可在训练中试验每个模型的架构,从而达到最佳结果。
我不会在此详细讲述如何处理文件的算法。 您可在附件中找到所有用到的程序、类及其方法的代码。
3. 测试
我们已创建了一个类,可运用内在好奇心方法组织 Q-学习模型。 现在我们将创建一个智能系统来训练和测试模型。 如上所述,新模型将在策略测试器中进行训练。 这与以前所用的方法有着本质区别。 故此,模型训练智能系统经历了重大变化。
ICM-learning.mq5 EA 是为测试而创建的。 为了描述市场状况,我们采用了具有相似参数的相同指标。 因此,EA 的外部参数实际上保持不变。 这是指全局变量和类的声明相同。
EA 初始化方法与以前的 EA 中几乎相同。 唯一的区别是没有生成学习过程启动事件。 这是因为我们已经完全删除了之前所有 EA 中所用的 “Train” 模型训练函数。
训练模型的整个过程被转移到方法 OnTick。 由于我们的模型经过训练,可以基于收盘的烛条分析行情,因此我们将仅在新烛条开盘时运行学习过程。 为此,在 OnTick 方法主体中,我们首先检查烛条新开盘事件。 如果结果是肯定的,我们则继续进一步行动。
void OnTick() { if(!IsNewBar()) return;
接下来,加载历史数据;其数量等于分析窗口的大小。
int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates); if(!ArraySetAsSeries(Rates, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return; } //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh();
创建当前市场状况的描述。 此过程遵循我们在之前研究的 EA 中所用的类似过程的算法。
State1.Clear(); for(int b = 0; b < (int)HistoryBars; b++) { float open = (float)Rates[b].open; TimeToStruct(Rates[b].time, sTime); float rsi = (float)RSI.Main(b); float cci = (float)CCI.Main(b); float atr = (float)ATR.Main(b); float macd = (float)MACD.Main(b); float sign = (float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) continue; //--- if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) || !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) || !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) || !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } }
一旦加载历史记录完毕,并生成市场状况描述后,调用模型的前馈方法,并检查结果。
在我们的新实现中,feedForward 方法返回代理者动作。 根据结果,执行交易操作。
switch(StudyNet.feedForward(GetPointer(State1), 12, true, true)) { case 0: Trade.Buy(Symb.LotsMin(), Symb.Name()); break; case 1: Trade.Sell(Symb.LotsMin(), Symb.Name()); break; case 2: for(int i=PositionsTotal()-1;i>=0;i--) if(PositionGetSymbol(i)==Symb.Name()) Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER)); break; }
请注意,在构建模型时,我们讨论过四个代理者动作。 此处,我们只看到三个动作的分析,以及执行相应交易操作。 事实是,第四个动作是在等待更合适的市场状况,而无需执行交易操作。 故此,我们不处理此动作。
在方法结束时,调用模型的反向传播方法。
StudyNet.backProp(Batch, DiscountFactor);
//---
}
您可能已注意到,在训练过程中,我们从未保存已训练的模型。 保存经训练模型的过程已移至 EA 的逆初方法之中。
void OnDeinit(const int reason) { //--- StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true); }
为了在 EA 优化模式下启用模型训练,我在完成每次优化器验算后,重复类似的保存过程。
void OnTesterPass() { StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true); }
请注意,优化过程应仅在一个活动内核上运行。 否则,并行线程将删除其它代理者的数据。 这将彻底抵消多代理者的作用。
为了训练 EA,所有模型都是利用 NetCreator 工具创建的。 应该补充的是,若要在策略测试器中启用 EA 操作,模型文件必须位于终端公共目录 'Terminal\Common\Files' 之中,因为每个代理者都在自己的沙箱中运行,因此它们只能通过公共终端文件夹交换数据。
策略测试器中的训练比以前的虚拟训练方法需要花费更长的时间。 出于这个原因,我将模型训练期缩短到 10 个月。. 其余测试参数保持不变。 这次同样,我采用基于 EURUSD 的 H1 时间帧。 指标采用默认参数。
老实说,我期待的学习过程将从本金亏损开始。 但在第一次验算时,模型显示的结果接近 0。 然后它甚至在第二次验算中赚取了一些盈利。 该模型执行了 330 笔交易,超过 98% 的操作是盈利的。
结束语
在本文中,我们讨论了内在好奇心模型的操作。 该技术能够在外在奖励很少的情况下,也能用强化学习方法成功进行模型训练。 这是指金融交易。 内在好奇心技术令模型能够彻底探索环境,并找到实现目标的最佳方法。 即使环境需要多个连续操作才会返回一次奖励,它也有效。
在本文的实施部分,我们以 MQL5 实现了所介绍的技术。 基于上述工作,我们可以得出结论,这种方法可以在交易中产生预期的结果。
尽管所呈现的 EA 可以执行交易操作,但它尚未准备好在真实交易中运用。 该 EA 仅用于评估目的。 在实盘运用之前,需要在所有可能的条件下进行细致改进和全面测试。
参考
- 神经网络变得轻松(第二十六部分):强化学习
- 神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)
- 神经网络变得轻松(第二十八部分):政策梯度算法
- 神经网络变得轻松(第三十二部分):分布式 Q-学习
- 神经网络变得轻松(第三十三部分):分布式 Q-学习中的分位数回归
- 神经网络变得轻松(第三十四部分):全部参数化的分位数函数
- 好奇心驱动的自我监督预测探索
本文中用到的程序
# | 发行 | 类型 | 说明 |
---|---|---|---|
1 | ICM-learning.mq5 | EA | 模型训练 EA |
2 | ICM.mqh | 类库 | 模型规划类库 |
3 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
4 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/11833