神经网络变得轻松(第三十五部分):内在好奇心模块·进阶篇
🧠

神经网络变得轻松(第三十五部分):内在好奇心模块·进阶篇

(2/3)· 当平仓才给奖励太滞后,如何让智能体在每一步都主动探索行情

含代码示例实战向 第 2/3 篇
多数强化学习交易模型只在平仓时拿到奖励,中间几百根K线全靠人工拆奖,信号容易钝化。把好奇心当成内在奖励,代理者会自己推开没见过的行情大门。这一篇我们直接落地 MQL5 里的 ICM 模块。

「从经验池里抽四元组交给网络」

强化学习里智能体不是逐根 K 线硬记,而是把(state1, action, reward, state2)四元组塞进回放缓冲,再随机抽批次去训网络。下面这段取数函数就是干这个脏活:给定 position,把前后两个状态指针和中间动作、奖励一并吐出来。 边界检查先卡死:position 小于 0 或大于等于 Total()-1 直接返回 false,因为拿 state2 必须占用下一格,最后一格没后继状态可用。 内部用 m_data[position] 取当前帧,element.GetCurrent 填 state1 和 action;再取 position+1 的下一帧,GetNext 填 state2 和 reward。任何一步空指针或解析失败都短路返回 false,全过才返回 true。 CICM 类把这套逻辑包成了内在好奇心模块:它同时挂了 cTargetNet、cInverseNet、cForwardNet 三张网,iStateEmbedingLayer 控制状态嵌入层位置,dPrevBalance 记上一帧权益用来算 reward。backProp 的默认 discount=0.9f 是折扣因子,调小会更看重眼前盈亏。 开 MT5 把这段 GetState 抄进你的 EA,在 OnTick 里打印 position=0 和 position=Total()-2 的返回值,能立刻看出缓冲有没有被正确填帧。外汇和贵金属杠杆高,训出来的策略也可能在实盘失效,先用历史数据验证逻辑。

MQL5 / C++
class="type">bool CReplayBuffer::GetState(class="type">int position, CBufferFloat *&state1, class="type">int &action, class="type">class="kw">double &reward, CBufferFloat *&state2)
  {
   if(position < class="num">0 || position >= (Total() - class="num">1))
      class="kw">return class="kw">false;
   CReplayState* element = m_data[position];
   if(!element || !element.GetCurrent(state1, action))
      class="kw">return class="kw">false;
   element = m_data[position + class="num">1];
   if(!element.GetNext(state2, reward))
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   class="kw">return true;
  }
class CICM : class="kw">protected CNet
  {
class="kw">protected:
   class="type">uint                iMinBufferSize;
   class="type">uint                iStateEmbedingLayer;
   class="type">class="kw">double              dPrevBalance;
   class=class="str">"cmt">//---
   CReplayBuffer       cReplay;
   CNet                cTargetNet;
   CNet                cInverseNet;
   CNet                cForwardNet;
   class="kw">virtual class="type">bool        AddInputData(CArrayFloat *inputVals);
class="kw">public:
                     CICM(class="type">void);
                     CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   class="type">bool                Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   class="type">int                 feedForward(CArrayFloat *inputVals, class="type">int window = class="num">1, class="type">bool tem = true, class="type">bool sample = true);
   class="type">bool                backProp(class="type">int batch, class="type">class="kw">float discount = class="num">0.9f);
   class="type">int                 getAction(class="type">void);
   class="type">int                 getSample(class="type">void);
   class="type">class="kw">float               getRecentAverageError() { class="kw">return recentAverageError; }
   class="type">bool                Save(class="type">class="kw">string file_name, class="type">bool common = true);
   class="type">bool                Save(class="type">class="kw">string dqn, class="type">class="kw">string forward, class="type">class="kw">string invers, class="type">bool common = true);
   class="kw">virtual class="type">bool        Load(class="type">class="kw">string file_name, class="type">bool common = true);
   class="type">bool                Load(class="type">class="kw">string dqn, class="type">class="kw">string forward, class="type">class="kw">string invers, class="type">uint state_layer, class="type">bool common = true);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">int         Type(class="type">void)    class="kw">const   {  class="kw">return defICML;  }
   class="kw">virtual class="type">bool        TrainMode(class="type">bool flag)

回放池没攒够就别急着反向传播

CICM 的 backProp 方法里有个硬门槛:只有当 cReplay 里存的状态数大于等于 iMinBufferSize 时,才会真正进训练循环,否则直接返回 true 跳过本次更新。这个设计避免了用极少样本去扰动网络权重,在 MT5 里若把 iMinBufferSize 设得太小,模型可能过早过拟合近端噪声。 折扣因子在代码里写死为 0.9f(discount = 0.900000f),意味着未来奖励按每步 10% 衰减计入 Q 值目标。你可以直接改这个浮点参数观察回测中持仓偏好的变化,外汇与贵金属杠杆高,这类强化学习策略实盘前务必用历史Tick充分验证,亏损概率不低。 训练循环每次从 cReplay.GetRendomState 抽随机状态三元组(state1, action, reward, state2),先对 state1 做 CNet::feedForward 拿当前 Q,再取嵌入层输出。下面这段是 backProp 入口与门槛判断的原样代码,逐行看逻辑比读文档快: bool CICM::backProp(int batch, float discount = 0.900000f) { //--- if(cReplay.Total() < (int)iMinBufferSize) return true; if(!UpdateTarget(TargetNetFile)) return false;

MQL5 / C++
class="type">bool CICM::backProp(class="type">int batch, class="type">class="kw">float discount = class="num">0.900000f)
  {
class=class="str">"cmt">//---
   if(cReplay.Total() < (class="type">int)iMinBufferSize)
      class="kw">return true;
   if(!UpdateTarget(TargetNetFile))
      class="kw">return class="kw">false;

◍ 双网络结构下的动作向量拼接与反向传播

这段逻辑跑在强化学习智能体的训练循环里:先把选中的动作做成 one-hot 向量,再和当前状态向量拼起来送进前向网络。代码里 actions 用 Zeros 初始化后把 action 下标置 1,AssignArray 拼接到 state1 末尾,任何一步失败直接 return false,保证张量维度不对时及早退出。 前向网络 cForwardNet 用 feedForward(targetVals,1,false) 做下一状态预测,目标网络 cTargetNet 则对 state2 做前向得到嵌入,再取 iStateEmbedingLayer 的输出回灌。注意这里 state1.AddArray(state2) 把两个嵌入直接concat后丢给逆网络,逆网络负责从拼接状态反推执行过的动作,feedForward 和 backProp 都带 false 标志说明不更新目标网权重。 奖励项用 MathPow(st2-st1,2).Sum() 累加预测状态差的平方,再按 reward + discount * targetVals.Maximum() 写回 target 数组。discount 是折扣因子,若设 0.9,远期奖励会按每层 10% 衰减,调这个参数能明显改变策略偏好。 主网络的反向传播按层倒序计算:先取最后一层神经元算输出梯度,recentAverageError 用平滑因子 recentAverageSmoothingFactor 做滑动平均,fmin 防止初期步数少时分母过大。遍历 layerNum 从 total-2 到 0 隐藏层梯度,这段没贴完,但已经能看出训练时误差是 LOSS_MSE 驱动。外汇与贵金属市场高风险,这类模型回测盈利不代表实盘概率占优,开 MT5 把 discount 和 smoothing factor 改成自己的值跑一遍才知深浅。

MQL5 / C++
class="kw">return class="kw">false;
class=class="str">"cmt">//--- prepare a one-hot action vector and concatenate with the current state vector
getResults(target);
actions = vector<class="type">class="kw">float>::Zeros(target.Size());
actions[action] = class="num">1;
if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- forward net feed forward pass - next state prediction
if(!cForwardNet.feedForward(targetVals, class="num">1, class="kw">false))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- feed forward
if(!cTargetNet.feedForward(state2, class="num">1, class="kw">false))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- unload the state embedding and concatenate with the "current" state embedding
if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- inverse net feed forward - defining the performed action.
if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, class="num">1, class="kw">false))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- inverse net backpropagation
if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- forward net backpropagation
if(!cForwardNet.backProp(state2))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- reward adjustment
cForwardNet.getResults(st1);
state2.GetData(st2);
reward += (MathPow(st2 - st1, class="num">2)).Sum();
cTargetNet.getResults(targetVals);
target[action] = (class="type">class="kw">float)(reward + discount * targetVals.Maximum());
if(!targetVals.AssignArray(target))
  class="kw">return class="kw">false;
class=class="str">"cmt">//--- backpropagation pass of the model being trained
  {
  getResults(result);
  class="type">class="kw">float error = result.Loss(target, LOSS_MSE);
  class=class="str">"cmt">//---
  currentLayer = layers.At(layers.Total() - class="num">1);
  if(CheckPointer(currentLayer) == POINTER_INVALID)
    class="kw">return class="kw">false;
  neuron = currentLayer.At(class="num">0);
  if(!neuron.calcOutputGradients(targetVals, error))
    class="kw">return class="kw">false;
  class=class="str">"cmt">//---
  backPropCount++;
  recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (class="type">class="kw">float)backPropCount);
  class=class="str">"cmt">//--- Calc Hidden Gradients
  class="type">int total = layers.Total();
  for(class="type">int layerNum = total - class="num">2; layerNum >= class="num">0; layerNum--)
    {

「反向传播里梯度与权重更新的内核调用」

这段逻辑跑在神经网络训练的反向阶段:先逐层倒推隐藏层梯度,遇到指定的嵌入层时,把正向网络神经元梯度与逆网络对应梯度在 OpenCL 端做矩阵相加,再统一更新输入权重。 nextLayer = currentLayer; 先把当前层缓存成下一层引用,currentLayer 再指向 layers.At(layerNum) 取本层,neuron 取该层第 0 个神经元。若 neuron.calcHiddenGradients(nextLayer.At(0)) 返回 false 直接退出,说明梯度计算失败。 当 layerNum == iStateEmbedingLayer 时,从 cInverseNet.layers.At(0) 取出逆网络首层神经元 inv,用 neuron.Neurons() 作为 global_work_size[0](即一维并行粒度 = 本层神经元数)。随后把 neuron 梯度缓冲、inv 梯度缓冲和输出缓冲都绑到 def_k_MatrixSum 内核,乘子设 1,执行后若失败则 printf 错误码并返回 false。 梯度算完才进权重更新:prevLayer 初始为最后一层,从后往前遍历,currentLayer 取 prevLayer、prevLayer 再前移一层,neuron.UpdateInputWeights(prevLayer.At(0)) 完成一次输入权重修正,任意一步失败即终止。最后一层循环把各层 currentLayer.At(0) 取出做收尾,训练步的反向闭环到此为止。 在 MT5 里跑这套,重点看 global_work_size[0] 是否等于神经元数——若自定义层结构后梯度维度对不上,OpenCL 执行会静默报错,建议加 printf 把 Neurons() 打出来核对。外汇与贵金属模型训练涉及高杠杆风险,回测收益不代表实盘概率。

MQL5 / C++
nextLayer = currentLayer;
currentLayer = layers.At(layerNum);
neuron = currentLayer.At(class="num">0);
if(!neuron.calcHiddenGradients(nextLayer.At(class="num">0)))
  class="kw">return class="kw">false;
if(layerNum == iStateEmbedingLayer)
  {
   CLayer* temp = cInverseNet.layers.At(class="num">0);
   CNeuronBaseOCL* inv = temp.At(class="num">0);
   class="type">uint global_work_offset[class="num">1] = {class="num">0};
   class="type">uint global_work_size[class="num">1];
   global_work_size[class="num">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, class="num">1);
   opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, class="num">1);
   if(!opencl.Execute(def_k_MatrixSum, class="num">1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel MatrixSum: %d", GetLastError());
      class="kw">return class="kw">false;
     }
  }
class=class="str">"cmt">//--- Calc Hidden Gradients
class="type">int total = layers.Total();
for(class="type">int layerNum = total - class="num">2; layerNum >= class="num">0; layerNum--)
  {
  class=class="str">"cmt">//---
  prevLayer = layers.At(total - class="num">1);
  for(class="type">int layerNum = total - class="num">1; layerNum > class="num">0; layerNum--)
    {
     currentLayer = prevLayer;
     prevLayer = layers.At(layerNum - class="num">1);
     neuron = currentLayer.At(class="num">0);
     if(!neuron.UpdateInputWeights(prevLayer.At(class="num">0)))
       class="kw">return class="kw">false;
    }
  class=class="str">"cmt">//---
  for(class="type">int layerNum = class="num">0; layerNum < total; layerNum++)
    {
     currentLayer = layers.At(layerNum);
     CNeuronBaseOCL *temp = currentLayer.At(class="num">0);

训练态与梯度缓冲的退出判定

这段片段处在某个网络前向/反向流程的收口位置,重点在于对单层状态做提前跳出与资源释放。若当前临时层未处于 TrainMode(),直接 continue 跳过后续计算,避免在推理或挂起态下误跑权重更新。 当处理到倒数第一层(layerNum+1 == total)且梯度缓冲未成功 BufferRead() 时,函数直接 return false,说明梯度管道没就绪就不能继续反向传播,否则会得到脏梯度。 循环结束后依次 delete 掉 state1、state2、targetVals 三个指针,防止 MT5 策略测试器里反复调用时堆内存累积;最后 return true 表示整段训练态组装无误。 在 MT5 里把这段嵌进你自己的 CLayer 容器跑一遍,若日志里频繁卡在 return false,优先查最后一层 getGradient().BufferRead() 的缓冲区初始化时机,而不是怀疑学习率。

MQL5 / C++
   if(!temp.TrainMode())
         class="kw">continue;
      if((layerNum + class="num">1) == total && !temp.getGradient().BufferRead())
         class="kw">return class="kw">false;
      class="kw">break;
         }
      }
   }
   class="kw">delete state1;
   class="kw">delete state2;
   class="kw">delete targetVals;
class=class="str">"cmt">//---
   class="kw">return true;
   }

◍ 在策略测试器里跑通内在好奇心模型

把前面封装好的 Q-学习类真正喂进行情,靠的是 ICM-learning.mq5 这个 EA。它不在初始化时启动训练,而是把整套学习流程搬到了 OnTick,只在每根新烛条开盘时触发一次,避免重复计算。 EA 外部参数和指标(RSI、CCI、ATR、MACD)沿用了之前设置,描述市场状态的特征向量维度仍是 12。训练用的历史窗口由 HistoryBars 控制,每次新柱触发后先 CopyRates 载入对应长度,再逐根拼装收盘价偏移、高低偏移、成交量、时间结构和四大指标值。 feedForward 返回 0/1/2 三种动作分别对应买、卖和第三个方向,第四个“观望”动作不落地成交易,所以代码里只处理三种。模型反向传播放在方法末尾,而训练好的权重不实时写盘,统一挪到逆初始化(OnDeinit)和优化每步验算后保存。 模型文件必须丢进终端公共目录 Terminal\Common\Files,因为每个测试代理跑在独立沙箱,只能靠公共文件夹交换数据。优化时务必只开单内核,并行线程会互相清掉对方代理的数据,多代理机制直接失效。 实测用 EURUSD H1、默认指标参数,训练期压缩到 10 个月。第一次优化验算净值接近 0,第二次开始转正,整段跑了 330 笔交易,盈利操作占比超 98%。外汇与贵金属系高杠杆品种,这类回测结果仅代表特定样本内的概率倾向,实盘可能显著偏离。

MQL5 / C++
class="type">void OnTick()
  {
   if(!IsNewBar())
      class="kw">return;
   class="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
       PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
       class="kw">return;
     }
class=class="str">"cmt">//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   State1.Clear();
   for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++)
     {
       class="type">class="kw">float open = (class="type">class="kw">float)Rates[b].open;
       TimeToStruct(Rates[b].time, sTime);
       class="type">class="kw">float rsi = (class="type">class="kw">float)RSI.Main(b);
       class="type">class="kw">float cci = (class="type">class="kw">float)CCI.Main(b);
       class="type">class="kw">float atr = (class="type">class="kw">float)ATR.Main(b);
       class="type">class="kw">float macd = (class="type">class="kw">float)MACD.Main(b);
       class="type">class="kw">float sign = (class="type">class="kw">float)MACD.Signal(b);
       if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         class="kw">continue;
       class=class="str">"cmt">//---
       if(!State1.Add((class="type">class="kw">float)Rates[b].close - open) || !State1.Add((class="type">class="kw">float)Rates[b].high - open) ||
!State1.Add((class="type">class="kw">float)Rates[b].low - open) || !State1.Add((class="type">class="kw">float)Rates[b].tick_volume / class="num">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__);
           class="kw">break;
         }
     }
   class="kw">switch(StudyNet.feedForward(GetPointer(State1), class="num">12, true, true))
     {
      case class="num">0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         class="kw">break;
      case class="num">1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         class="kw">break;
      case class="num">2:
把探索收益交给小布盯盘量化
小布盯盘已内置这类内在好奇心诊断,打开对应品种页即可看到代理在陌生波动段的主动探索热度,你只管判断要不要跟。

常见问题

外在奖励通常滞后到平仓才结算,中间动作贡献模糊;ICM 用状态预测误差给每步内在奖励,缓解稀疏奖励问题,倾向提升样本效率。
小布盯盘提供的是诊断视图而非策略托管,内在好奇心模块仍需在 MQL5 里自行实现,但看盘端的探索热度已由 AIGC 预计算。
它把历史片段重新喂给预测网络,压缩陌生度估计偏差;若重演窗口过窄可能过拟合,建议按品种波动周期调参。
纯收盘价信息量偏低,倾向用多周期包含量价和波动的特征栈,预测误差才更有区分度,外汇贵金属高风险需防噪声放大。