神经网络变得轻松(第五十部分):软性扮演者-评价者(模型优化)·进阶篇
📘

神经网络变得轻松(第五十部分):软性扮演者-评价者(模型优化)·进阶篇

第 2/3 篇

分位网络的反向梯度与软更新内核

在 MT5 的 OpenCL 内核里,SAC_OutputGradient 负责把输出梯度按分位(quantile)拆回给分位值和分位点各自的反传梯度。每个 action 对应一段长度为 count_quants 的偏移,内核用 get_global_id(0) 拿到并行序号,再逐分位比对前向输出与激活后的 quant 是否相等,相等才写梯度,否则清零——这意味着前向和反向必须严格同激活路径,否则梯度全 0。 激活分支里 case 0 走 tanh、case 1 走 sigmoid(1/(1+exp(-quant)))、case 2 对负值乘 0.01f 做 leaky 式压制;这些选择在外汇小时线回测中会影响分位分布尾部厚度,贵金属跳空时 case 2 的 0.01 系数可能让下方分位梯度衰减过快。 SoftUpdateAdam 内核接着做目标网络软更新,传入 target、source 以及一阶矩 matrix_m、二阶矩 matrix_v,典型用法是以 0.005 量级的 tau 把在线网络慢拷给目标网络,避免训练振荡。开 MT5 把这段内核塞进自定义指标或 EA 的 CL 上下文,改 case 2 的 0.01f 为 0.1f 对比 EURUSD 的 quantile 带宽,能直接看到梯度回流差异。 外汇与贵金属杠杆高、滑点随机,任何网络输出只代表条件概率倾向,实盘前请用历史数据在策略测试器跑通内核编译。

MQL5 / C++
__kernel class="type">void SAC_OutputGradient(__global class="type">class="kw">float* quantiles,
                                     __global class="type">class="kw">float* delta_taus,
                                     __global class="type">class="kw">float* output_gr,
                                     __global class="type">class="kw">float* quantiles_gr,
                                     __global class="type">class="kw">float* taus_gr,
                                     __global class="type">class="kw">float* output,
                                     class="kw">const class="type">int count_quants,
                                     class="kw">const class="type">int activation
                                     )
  {
  class="type">size_t action = get_global_id(class="num">0);
  class="type">int shift = action * count_quants;
  for(class="type">int i = class="num">0; i < count_quants; i++)
    {
      class="type">class="kw">float quant = quantiles[shift + i];
      class="kw">switch(activation)
        {
         case class="num">0:
           quant = tanh(quant);
           class="kw">break;
         case class="num">1:
           quant = class="num">1 / (class="num">1 + exp(-quant));
           class="kw">break;
         case class="num">2:
           if(quant < class="num">0)
             quant = quant * class="num">0.01f;
           class="kw">break;
        }
      if(output[i] == quant)
        {
         class="type">class="kw">float gradient = output_gr[action];
         quantiles_gr[shift + i] = gradient * delta_taus[shift + i];
         taus_gr[shift + i] = gradient * quant;
        }
      else
        {
         quantiles_gr[shift + i] = class="num">0;
         taus_gr[shift + i] = class="num">0;
        }
    }
  }
__kernel class="type">void SoftUpdateAdam(__global class="type">class="kw">float *target,
                             __global class="kw">const class="type">class="kw">float *source,
                             __global class="type">class="kw">float *matrix_m,
                             __global class="type">class="kw">float *matrix_v,

◍ Adam 软更新在 OpenCL 内核里的落地

这段内核函数把 Adam 优化器的动量累积搬到了 GPU 上做并行软更新:每个线程拿全局 id 对应一条权重,先取一阶动量 m 与二阶动量 v,再按 b1、b2 做指数滑动平均。梯度 g 用 source[i] 减当前权重算,m 和 v 的更新公式与标准 Adam 一致,只是把学习率换成了 tau 乘动量比。 delta 的计算做了除零保护:v 为 0 时分母取 1.0f,避免 sqrt(0) 引发 NaN。只有当 delta 与梯度同号(delta*g>0)才把权重朝目标推,并用 clamp 限制在 ±MAX_WEIGHT 内,这一步能压住外汇样本里偶发的梯度尖刺。 WeightsUpdateAdam 是 CPU 侧的调度封装:先校验 OpenCL 句柄、源层类型与权重总数一致,再设 global_work_size 为 Weights.Total()。五个 SetArgumentBuffer 分别绑 target、source、一阶与二阶动量缓冲以及 tau 标量,任何一步失败就 printf 出错函数名与行号并返回 false,方便在 MT5 策略测试器里直接定位。 实盘接这套前先确认 MAX_WEIGHT 宏在你的头文件里不是默认 1.0——贵金属波动大时权重被夹太死会拖慢收敛,调 tau 建议从 0.001 起在 EURUSD 的 M15 上跑回测看动量曲线。

MQL5 / C++
class="kw">const class="type">class="kw">float tau,
              class="kw">const class="type">class="kw">float b1,
              class="kw">const class="type">class="kw">float b2
              )
  {
  class="kw">const class="type">int i = get_global_id(class="num">0);
  class="type">class="kw">float m, v, weight;
  m = matrix_m[i];
  v = matrix_v[i];
  weight=target[i];
  class="type">class="kw">float g = source[i] - weight;
  m = b1 * m + (class="num">1 - b1) * g;
  v = b2 * v + (class="num">1 - b2) * pow(g, class="num">2);
  class="type">class="kw">float delta = tau * m / (v != class="num">0.0f ? sqrt(v) : class="num">1.0f);
  if(delta * g > class="num">0)
     target[i] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT);
  matrix_m[i] = m;
  matrix_v[i] = v;
  }
class="type">bool CNeuronBaseOCL::WeightsUpdateAdam(CNeuronBaseOCL *source, class="type">class="kw">float tau)
  {
  if(!OpenCL || !source)
     class="kw">return class="kw">false;
  if(Type() != source.Type())
     class="kw">return class="kw">false;
  if(!Weights || Weights.Total() == class="num">0)
     class="kw">return true;
  if(!source.Weights || Weights.Total() != source.Weights.Total())
     class="kw">return class="kw">false;
  class="type">uint global_work_offset[class="num">1] = {class="num">0};
  class="type">uint global_work_size[class="num">1] = {Weights.Total()};
  ResetLastError();
  if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_target, getWeightsIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
     }
  if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_source, source.getWeightsIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
     }
  if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_m, getFirstMomentumIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
     }
  if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdateAdam, def_k_sua_matrix_v, getSecondMomentumIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
     }
  if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_tau, (class="type">class="kw">float)tau))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
     }

「GPU 上的 Adam 软更新与行情特征拼装」

在 MT5 的 OpenCL 封装里,Adam 优化器的软更新靠内核参数逐个塞入。下面这段把一阶矩衰减系数 b1、二阶矩衰减系数 b2 作为 float 写进 def_k_SoftUpdateAdam 内核,任一步 SetArgument 失败就打印函数名、错误码和行号并退出,避免脏状态继续跑。 Execute 调用只发 1 个 work-group(global_work_offset 与 global_work_size 控制覆盖范围),内核返回 true 才代表本次权重滑动更新完成。若你改了 b1/b2 的默认值,必须同步确认内核签名里的 def_k_sua_b1 / def_k_sua_b2 偏移量没错位,否则 GPU 端读到的会是相邻内存的脏数。 行情侧的特征向量按 BarDescr=9、AccountDescr=12 两条宏固定长度拼。单根 bar 贡献 9 维:收盘减开盘、最高减开盘、最低减开盘、tick_volume/1000.0、小时、星期几、月份、外加 rsi/cci/atr/macd/sign 里实际取了哪几个由上下文决定,但前缀 4 个价差与 3 个时间分量必然占头 7 位。 账户侧用 Rates[0].time 分别除以年、月、周、日周期秒数再做正余弦变换,把绝对时间压成周期信号。注意 D'2024.01.01'-D'2023.01.01' 是硬编码的 365 天秒数近似,遇闰年会产生约 0.27% 的频率漂移,回测跨年品种时倾向手动换成 PeriodSeconds(PERIOD_Y1) 类的动态值。

MQL5 / C++
 if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b1, (class="type">class="kw">float)b1))
    {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
    }
  if(!OpenCL.SetArgument(def_k_SoftUpdateAdam, def_k_sua_b2, (class="type">class="kw">float)b2))
    {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      class="kw">return class="kw">false;
    }
  if(!OpenCL.Execute(def_k_SoftUpdateAdam, class="num">1, global_work_offset, global_work_size))
    {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      class="kw">return class="kw">false;
    }
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, class="type">class="kw">float tau)
  {
  if(optimization == ADAM)
      class="kw">return WeightsUpdateAdam(source, tau);
class=class="str">"cmt">//---
........
........
      State.Add((class="type">class="kw">float)Rates[b].close - open);
      State.Add((class="type">class="kw">float)Rates[b].high - open);
      State.Add((class="type">class="kw">float)Rates[b].low - open);
      State.Add((class="type">class="kw">float)Rates[b].tick_volume / class="num">1000.0f);
      State.Add((class="type">class="kw">float)sTime.hour);
      State.Add((class="type">class="kw">float)sTime.day_of_week);
      State.Add((class="type">class="kw">float)sTime.mon);
      State.Add(rsi);
      State.Add(cci);
      State.Add(atr);
      State.Add(macd);
      State.Add(sign);
  class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
  Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
class="macro">#define                 BarDescr           class="num">9           class=class="str">"cmt">//Elements for class="num">1 bar description
class="macro">#define                 AccountDescr  class="num">12              class=class="str">"cmt">//Account description

SAC 模型的两轮训练与样本库迭代

做模型改造后真正进训练,第一步是用 Research.mq5 跟环境交互攒数据。我弃了对冲,只做成交量预测差异大的方向,并给预测成交量降档。奖励主体原用账户余额相对变化,值稀薄远低于 1,而熵分量初期在 8–12 波动,量级悬殊,所以把熵除以余额并引入 LogProbMultiplier 约简率来填豁口。 第一轮数据采集跑 EURUSD H1,策略测试器全参数枚举 2023 年前 5 个月,起始资金 10000 美元,收了 200 次验算、超 50 万条「状态→动作→新状态→奖励」。这阶段没预训练模型,每次验算随机初始化,不更新参数,200 次全随机独立且都不盈利。 真正训练在 Study.mq5:账户状态向量补时间戳;评论者训练时从目标奖励剔掉熵分量,且训练扮演者时禁用在用评论者的参数更新,只传梯度,逼出客观评论者;偶数/奇数迭代交替 Critic1、Critic2,目标模型只更新一个。首轮循环 10 万次(默认),仅覆盖样本库 1/5,别指望出显著结果。 删掉随机样本库后重跑环境研究 EA 优化,但偏移代理者迭代值防缓存加载。新库用预训练模型探索,动作落在学习概率分布内,只留最后一次验算。第二轮 Study.mq5 把迭代提到 50 万次。 测模型切到 Test.mq5,用 2023 前 5 个月跑 10 次验算,盈利上限约 1/4–1/5;回 Research.mq5 设最小盈利门槛只存好验算,优化模式再跑 100 次。逐渐抬高外部参数里的盈利标准反复训,直到盈利不再随训练提升。若随机政策下几次完美验算结果几乎一致,说明已逼近确定性策略——外汇贵金属波动剧烈、杠杆高风险大,这种确定性在某些任务里可能更稳,但不等于普适优解。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert tick function                                             |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTick()
  {
class=class="str">"cmt">//---
.........
.........
class=class="str">"cmt">//---
   class="type">class="kw">float atr = class="num">0;
   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;
      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);
      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">//---
      class="type">int shift = b * BarDescr;
      sState.state[shift] = (class="type">class="kw">float)(Rates[b].close - open);
      sState.state[shift + class="num">1] = (class="type">class="kw">float)(Rates[b].high - open);
      sState.state[shift + class="num">2] = (class="type">class="kw">float)(Rates[b].low - open);
      sState.state[shift + class="num">3] = (class="type">class="kw">float)(Rates[b].tick_volume / class="num">1000.0f);
      sState.state[shift + class="num">4] = rsi;
      sState.state[shift + class="num">5] = cci;
      sState.state[shift + class="num">6] = atr;
      sState.state[shift + class="num">7] = macd;
      sState.state[shift + class="num">8] = sign;
     }
   State.AssignArray(sState.state);
class=class="str">"cmt">//---
........
........
class=class="str">"cmt">//---
   Account.Clear();
   Account.Add((class="type">class="kw">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance));
   Account.Add((class="type">class="kw">float)(sState.account[class="num">1] / PrevBalance));

◍ 把账户状态压成强化学习的特征向量

这段逻辑干的事很直接:把每一根 K 线时的账户变化,连同时间周期的正弦余弦编码,一起塞进 Account 这个特征容器,供后续 Actor 网络推理。外汇与贵金属杠杆高,这类自编码特征若训练分布偏移,实盘可能放大回撤。 先看特征拼接的前半段:权益相对前值的增长率、账户数组第 2/3 项原样入列,第 4~6 项都除以 PrevBalance 做归一。时间项用了四个尺度——年、月、周、日,分别取 MathSin 或 MathCos 把时间戳折成周期信号,避免网络直接啃绝对时间。 推理结束后,Actor.getResults 拿出动作向量 temp,用 MathAbs(ActorResult - temp).Sum() 算与上轮动作的绝对偏移 delta,再把 temp 回写进 sState.action。若 temp[0] 与 temp[3] 谁大就减谁,把双向仓位差做互斥归零,这是多空互斥的一个粗糙处理。 奖励函数里 reward 以账户权益变化率 Account[0] 打底;若买卖价值合计为 0,就扣掉 atr/PrevBalance 作为空仓惩罚。若策略输出了对数概率,再按 LogProbMultiplier 把 temp.Sum()/PrevBalance 加进 reward,最后 Base.Add 写经验池,失败则 ExpertRemove 自停。 训练循环 Train 里每轮先 Account.Clear,再从重放缓冲 Buffer[tr].States[i+1] 重新拼一套特征:余额变化率、余额占比、权益变化率、账户项原样,和在线推理用的那套口径并不完全一致,复现时得留意两套归一顺序。

MQL5 / C++
  Account.Add((class="type">class="kw">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity));
  Account.Add(sState.account[class="num">2]);
  Account.Add(sState.account[class="num">3]);
  Account.Add((class="type">class="kw">float)(sState.account[class="num">4] / PrevBalance));
  Account.Add((class="type">class="kw">float)(sState.account[class="num">5] / PrevBalance));
  Account.Add((class="type">class="kw">float)(sState.account[class="num">6] / PrevBalance));
  class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
  Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
  Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
class=class="str">"cmt">//---
  if(Account.GetIndex() >= class="num">0)
    if(!Account.BufferWrite())
       class="kw">return;
class=class="str">"cmt">//---
  vector<class="type">class="kw">float> temp;
  Actor.getResults(temp);
  class="type">class="kw">float delta = MathAbs(ActorResult - temp).Sum();
  ActorResult = temp;
class=class="str">"cmt">//---
  if(temp[class="num">0] >= temp[class="num">3])
    {
      temp[class="num">0] -= temp[class="num">3];
      temp[class="num">3] = class="num">0;
    }
  else
    {
      temp[class="num">3] -= temp[class="num">0];
      temp[class="num">0] = class="num">0;
    }
class=class="str">"cmt">//---
  class="type">class="kw">float reward = Account[class="num">0];
  if((buy_value + sell_value) == class="num">0)
      reward -= (class="type">class="kw">float)(atr / PrevBalance);
  for(class="type">class="kw">ulong i = class="num">0; i < temp.Size(); i++)
      sState.action[i] = temp[i];
  if(Actor.GetLogProbs(temp))
      reward += LogProbMultiplier * temp.Sum() / (class="type">class="kw">float)PrevBalance;
  if(!Base.Add(sState, reward))
      ExpertRemove();
}
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">//---
      Account.Clear();
      Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">1] / PrevBalance);
      Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">2]);

常见问题

在 OpenCL 内核里按分位误差对输出做符号加权再回传,注意把梯度裁剪到合理范围,避免 GPU 浮点溢出。
多半是行情特征拼接和内核启动开销没批处理,把样本按时间窗打包进一个内核调用能明显提速。
可以,小布能按你设定的品种页自动比对两轮训练的样本分布偏移,提醒你何时该重置经验回放池。
会,建议先做标准化并加噪声增强,仅保留权益曲线斜率、浮亏占比等低维稳态量,别塞原始成交流水。
倾向每训练 2~4 轮主网络后做一次小系数软更新(如 0.005),太频繁易抖、太稀易滞后,用验证集回报调。