神经网络变得轻松(第三十二部分):分布式 Q-学习·进阶篇
📘

神经网络变得轻松(第三十二部分):分布式 Q-学习·进阶篇

第 2/3 篇

「SoftMax 层在 OpenCL 下的并行反向实现」

把 SoftMax 放到 MT5 的 OpenCL 环境里跑,核心不是公式本身,而是如何用二维全局 ID 切分多头(heads)与输出维度。下面这段内核把 h 作为头索引、i 作为输出索引,shift = h * outputs_total 直接定位每块头的起始偏移,避免多头数据在显存里交错。 内核 SoftMax_HiddenGradient 里那句 result += outputs[shift+j] * output_gr[shift+j] * ((float)(i==j) - output) 就是标准 SoftMax Jacobian 的逐项累加:当 j 等于 i 时系数为 (output - output^2),否则为 -output_i * output_j。在 GPU 上用 for 循环扫完 outputs_total 个元素,复杂度仍是 O(n^2) 但并行摊薄了。 feedForward 与 calcInputGradients 里都出现了 size = Output.Total() / iHeads,并把 global_work_size 设成 {size, iHeads}、local_work_size 设成 {size, 1}。这意味着每个头占满一个本地工作组的一维,头间并行、头内串行归约。若你改 iHeads 参数,必须同步确认 Output.Total() 能整除,否则 GPU 偏移会越界报 GetLastError()。 SoftMax_OutputGradient 则简单得多:output_gr[i] = targets[i] / (outputs[i] + 1e-37f),加 1e-37f 是为防除零。这个内核只用一维全局 ID,适合最后一层直接拿目标概率做梯度。

MQL5 / C++
__kernel class="type">void SoftMax_HiddenGradient(__global class="type">float* outputs,
 __global class="type">float* output_gr,
 __global class="type">float* input_gr)
 {
 class="type">size_t i = get_global_id(class="num">0);
 class="type">size_t outputs_total = get_global_size(class="num">0);
 class="type">size_t h = get_global_id(class="num">1);
 class="type">uint shift = h * outputs_total;
 class="type">float output = outputs[shift + i];
 class="type">float result = class="num">0;
 for(class="type">int j = class="num">0; j < outputs_total ; j++)
 result += outputs[shift + j] * output_gr[shift + j] * ((class="type">float)(i == j) - output);
 input_gr[shift + i] = result;
 }
__kernel class="type">void SoftMax_OutputGradient(__global class="type">float* outputs,
 __global class="type">float* targets,
 __global class="type">float* output_gr)
 {
 class="type">size_t i = get_global_id(class="num">0);
 output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
 }
class="type">bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
 {
 if(!OpenCL || !NeuronOCL)
 class="kw">return class="kw">false;
 class="type">uint global_work_offset[class="num">2] = {class="num">0, class="num">0};
 class="type">uint size = Output.Total() / iHeads;
 class="type">uint global_work_size[class="num">2] = { size, iHeads };
 class="type">uint local_work_size[class="num">2] = { size, class="num">1 };
 OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
 OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
 OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
 if(!OpenCL.Execute(def_k_SoftMax_FeedForward, class="num">2, global_work_offset, global_work_size, local_work_size))
 {
 printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
 class="kw">return class="kw">false;
 }
class=class="str">"cmt">//---
 class="kw">return true;
 }
class="type">bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
 {
 if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
 class="kw">return class="kw">false;
 class="type">uint global_work_offset[class="num">2] = {class="num">0, class="num">0};
 class="type">uint size = Output.Total() / iHeads;
 class="type">uint global_work_size[class="num">2] = {size, iHeads};
 OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
 OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
 OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());

◍ SoftMax 层的持久化与网络装配细节

在 OpenCL 版的 SoftMax 神经元里,反向传播结束后会调用 Execute 跑隐藏层梯度核,维度参数写死为 2。若执行失败,直接 printf 打出错误码并 return false,上层训练循环必须捕获这个 false 否则可能带着脏梯度继续更新。 Save 与 Load 负责把层状态落盘。Save 先写基类再写 iHeads 这个 uint,FileWriteInteger 返回值 ≤0 就判失败;Load 反过来读,若 iHeads≤0 则兜底置 1,避免旧模型或破损文件让多头划分变成 0 导致后续卷积或注意力头计算直接崩。 网络装配在 CNet::Create 里按 Description 数组循环。遇到 defNeuronSoftMaxOCL 类型时 new 出 CNeuronSoftMaxOCL,Init 的第二个参数填 0 表示无偏置,desc.count / desc.optimization / desc.batch 透传;之后那行高亮的 softmax.SetHeads(desc.step) 把多头数从描述结构灌进去,这一步漏掉的话 Load 虽能兜底成 1,但训练时多头并行逻辑就废了。 任何 Add 失败的分支都先把 softmax 和 temp 双删再 return,防止半初始化对象挂在数组里。开 MT5 把这段塞进自己的 OCL 网络类,断点打在 SetHeads 前后看 iHeads 是否等于 desc.step,能立刻验证多头配置有没有生效。

MQL5 / C++
if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, class="num">2, global_work_offset, global_work_size))
  {
    printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
    class="kw">return class="kw">false;
  }
class=class="str">"cmt">//---
  class="kw">return true;
}
class="type">bool CNeuronSoftMaxOCL::Save(class="kw">const class="type">int file_handle)
  {
  if(!CNeuronBaseOCL::Save(file_handle))
      class="kw">return class="kw">false;
  if(FileWriteInteger(file_handle, iHeads) <= class="num">0)
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CNeuronSoftMaxOCL::Load(class="kw">const class="type">int file_handle)
  {
  if(!CNeuronBaseOCL::Load(file_handle))
      class="kw">return class="kw">false;
  iHeads = (class="type">uint)FileReadInteger(file_handle);
  if(iHeads <= class="num">0)
      iHeads = class="num">1;
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">void CNet::Create(CArrayObj *Description)
  {
class=class="str">"cmt">//--- 循环体内对 SoftMaxOCL 的处理
    if(!!opencl)
      {
       CNeuronSoftMaxOCL *softmax = NULL;
       class="kw">switch(desc.type)
         {
          case defNeuronSoftMaxOCL:
            softmax = new CNeuronSoftMaxOCL();
            if(!softmax)
              {
               class="kw">delete temp;
               class="kw">return;
              }
            if(!softmax.Init(outputs, class="num">0, opencl, desc.count, desc.optimization, desc.batch))
              {
               class="kw">delete softmax;
               class="kw">delete temp;
               class="kw">return;
              }
            softmax.SetHeads(desc.step);
            if(!temp.Add(softmax))
              {
               class="kw">delete softmax;
               class="kw">delete temp;
               class="kw">return;
              }
            softmax = NULL;
            break;
         }
      }
  }

初始化里先卡死动作分布边界

这段 OnInit 的逻辑核心,是先给双神经网络(StudyNet / TargetNet)加载 .nnw 权重文件,任何一次 Load 或 TrainMode 失败都直接 INIT_FAILED,避免后续拿空模型跑行情。 加载完后从 StudyNet 第 0 层取输出,用 TempData.Total() 除以 12 反推 HistoryBars,再用 TempData.Total() / Actions 算 action_dist。Actions 在外部写死为 3,所以 action_dist 实际等于样本总数除以 3。 action_dist 若小于等于 0 会返回 INIT_PARAMETERS_INCORRECT,这是硬边界:你改了 Step 或样本量导致整除出 0,EA 根本起不来。action_midle 取 (action_dist+1)/2,作为后续奖励映射的中轴。 训练循环里,TargetNet 用 State2 做前馈,取结果 reshape 成 Actions×action_dist 的矩阵,用 ArgMax(1) 减中轴再乘 Step 和 DiscountFactor 得到 add 偏移量。奖励直接取单根 K 线 close-open,非负时按 (2*reward+add[0])/Step+action_midle 夹取到 [0, action_dist-1] 区间写进 Rewards。外汇与贵金属杠杆高,这套映射若 Step=5e-4 配错品种波动,奖励索引容易越界,开 MT5 前先核对品种点值。

MQL5 / C++
class="type">int                Actions   = class="num">3;
class="kw">input class="type">class="kw">double         Step = class="num">5e-4;
class="type">int OnInit()
  {
class=class="str">"cmt">//---
   class="type">float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false))
      class="kw">return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
   if(!StudyNet.GetLayerOutput(class="num">0, TempData))
      class="kw">return INIT_FAILED;
   HistoryBars = TempData.Total() / class="num">12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= class="num">0)
      class="kw">return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + class="num">1) / class="num">2;
class=class="str">"cmt">//---
   class="kw">return(INIT_SUCCEEDED);
  }
class="type">void Train(class="type">void)
  {
class=class="str">"cmt">//---
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
     {
class=class="str">"cmt">//---
      for(class="type">int batch = class="num">0; batch < (Batch * UpdateTarget); batch++)
        {
class=class="str">"cmt">//---
         vectorf add = vectorf::Zeros(Actions);
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), class="num">12, true))
               class="kw">return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(class="num">1, temp.Size());
            if(!target.Row(temp, class="num">0) || !target.Reshape(Actions, action_dist))
               class="kw">return;
            add = DiscountFactor * (target.ArgMax(class="num">1) - action_midle) * Step;
           }
         Rewards.BufferInit(Actions * action_dist, class="num">0);
         class="type">class="kw">double reward = Rates[i].close - Rates[i].open;
         if(reward >= class="num">0)
           {
            class="type">int rew = (class="type">int)fmax(fmin((class="num">2 * reward + add[class="num">0]) / Step + action_midle, action_dist - class="num">1), class="num">0);
            if(!Rewards.Update(rew, class="num">1))

「奖励分桶的偏移写法」

这段逻辑把连续 reward 压进离散动作桶,核心是用 fmax/fmin 做截断,再叠 action_dist 偏移区分多组奖励。 先看空头分支:第一桶用 -5*reward 配 add[1],除 Step 后加 action_midle,下限锁 0、上限锁 action_dist-1,再补 action_dist 写入 Rewards;任意 Update 失败立即 return,避免脏数据进表。 多头分支对称处理:首桶用 5*reward 配 add[0],第二桶用 -2*reward 配 add[1] 并偏移 action_dist,第三桶用 reward+add.Max() 偏移 2*action_dist。三组系数(5 / -2 / 1)和偏移(0 / 1x / 2x action_dist)直接决定 RL 样本在桶里的分布密度,调 Step 可改分辨率。 开 MT5 把这段代码贴进 EA 的奖励更新段,故意让某次 Rewards.Update 返回 false,能看到后续桶全部跳过——印证了早退逻辑。外汇与贵金属杠杆高,这类样本构造错误可能让模型学到偏态,回测前务必单步跟一遍。

MQL5 / C++
    class="kw">return;
    rew = (class="type">int)fmax(fmin((-class="num">5 * reward + add[class="num">1]) / Step + action_midle, action_dist - class="num">1), class="num">0) + action_dist;
    if(!Rewards.Update(rew, class="num">1))
     class="kw">return;
    rew = (class="type">int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - class="num">1), class="num">0) + class="num">2 * action_dist;
    if(!Rewards.Update(rew, class="num">1))
     class="kw">return;
     }
    else
     {
    class="type">int rew = (class="type">int)fmax(fmin((class="num">5 * reward + add[class="num">0]) / Step + action_midle, action_dist - class="num">1), class="num">0);
    if(!Rewards.Update(rew, class="num">1))
     class="kw">return;
    rew = (class="type">int)fmax(fmin((-class="num">2 * reward + add[class="num">1]) / Step + action_midle, action_dist - class="num">1), class="num">0) + action_dist;
    if(!Rewards.Update(rew, class="num">1))
     class="kw">return;
    rew = (class="type">int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - class="num">1), class="num">0) + class="num">2 * action_dist;
    if(!Rewards.Update(rew, class="num">1))
     class="kw">return;
     }

◍ 用两周样本外数据压一下模型成色

训练好的网络结构固定为:3 层卷积做数据预处理,接 3 个各含 1000 神经元的全连接隐藏层,再经 45 神经元决策层(买卖持三类动作各 15 个)与 SoftMax 输出概率分布。训练吃的是 EURUSD 的 H1 历史两年数据,指标和参数与前面几篇完全一致。 为验证不是过拟合,模型拿到策略测试器里跑过去两周、且未参与训练的样本。测试 EA 基本复制了原 Q-learning 版本,只多嵌了一个 GetAction 函数,负责把模型对当前状态的概率估算转成具体动作。 函数先取概率缓冲区放进矩阵并 reshape 成「行数=可能动作数」的表格,再逐动作算最可能奖励分位数。若买、卖预期回报相等,挑获奖概率高的;否则贪心选预期回报最大的。 两周最低手数跑下来账面利润约 20 美元,余额曲线明显向上,胜率近 56%。但外汇和高贵金属属高风险,这仅是样本外回测,EA 还没碰过真实盘,不能直接拿来实盘。 代码里 prob[0]==prob[1] 的判定就是买卖回报打平时走概率优先分支;任何一步缓冲区取数失败都直接 return -1,调用层要自己兜底。

MQL5 / C++
class="type">int GetAction(CBufferFloat* probability)
  {
  vectorf prob;
  if(!probability.GetData(prob))
      class="kw">return -class="num">1;
  matrixf dist = matrixf::Zeros(class="num">1, prob.Size());
  if(!dist.Row(prob, class="num">0))
      class="kw">return -class="num">1;
  if(!dist.Reshape(Actions, prob.Size() / Actions))
      class="kw">return -class="num">1;
  prob = dist.ArgMax(class="num">1);
  if(prob[class="num">0] == prob[class="num">1])
     {
       if(prob[class="num">2] > prob[class="num">0])
         class="kw">return class="num">2;
       if(dist[class="num">0, (class="type">int)prob[class="num">0]] >= dist[class="num">1, (class="type">int)prob[class="num">1]])
         class="kw">return class="num">0;
       else
         class="kw">return class="num">1;
     }
class=class="str">"cmt">//---
   class="kw">return (class="type">int)prob.ArgMax();
  }

常见问题

重点核对全局工作项数与特征向量长度的对齐,以及梯度累加时的局部内存屏障;建议先在小批量样本上单步打印各缓冲区尺寸。
检查持久化时是否连同偏置和温度系数一起写出,装配时按原网络拓扑顺序读取,别漏掉输出层映射表。
可以,小布能定时拉取你的动作概率分布快照,发现长期贴边界就推送提醒,省去你手动翻日志。
会,偏移错位让正奖励被重复计数;用两周样本外数据回测时重点看夏普和回撤,而不是只看累计收益。
有,但需调高探索率并松开边界 clamp,再用样本外数据压两周确认分布扩散,外汇贵金属高风险需谨慎验证。