神经网络变得轻松(第五十三部分):奖励分解(基础篇)
📘

神经网络变得轻松(第五十三部分):奖励分解(基础篇)

第 1/3 篇

「把奖励拆开喂给神经网络」

在 MT5 中用强化学习做交易系统,最大的坑不是网络结构,而是奖励函数把整笔交易的盈亏直接当信号。Dmitriy Gizlyk 在 2024 年 4 月公开的实验显示,这种「整段奖励」让策略在 894 次迭代内收敛极慢,且对滑点敏感。 把奖励按持仓阶段分解——开仓、持有、平仓各给独立梯度,网络能更快分辨「哪一步做对了」。这属于价格行为学里「动作—反馈」链的可微化改造,贵金属与外汇杠杆品种尤需注意:高频分解会放大过拟合概率,务必在策略测试器里用真实点差回测。 实操上,开 MT5 的 MetaEditor,把原 reward 标量改成数组传入,是验证该思路的最低成本动作。

◍ 把奖励拆开看才看得清

强化学习里所有训练算法本质上都在追一个东西:环境给的累计奖励。奖励函数就是那个指挥棒,但它给的信号常常模棱两可,代理者未必真读懂了你的意图。 实践中我们往奖励函数里塞红利和惩罚:鼓励探索就加探索奖,防止躺平就加不作为罚。可模型结构长什么样、罚多少、奖多少,至今还是架构师拍脑袋的主观决定。 训练时哪怕设计得再精细,代理者也可能跑偏。这时候与其猜,不如把总奖励拆成若干分量,逐个看每个分量对行为的影响。 拆开分析能直接定位是哪一层信号出了问题,进而调架构、调训练流程或改奖励函数。对做自动化交易模型的人来说,这比盲目调参更省时间,也更能解释模型为什么在某段行情里乱做。外汇与贵金属市场高波动、高杠杆,任何模型信号都只是概率倾向,实盘前务必在 MT5 用历史数据验证。

把奖励拆开训练能看清每个信号

在强化学习里,代理拿到的奖励常常是多个分量加总出来的。传统做法是让模型从这一团复合奖励里学一个复杂的重要性函数;而数值分解让模型分别学每个分量的重要性函数,单个函数的形式往往更简单,也更容易诊断哪路信号在起作用。 这种拆法能套进扮演者-评论者家族等多种算法。具体改造只有两步:改 Q-函数模型,让输出对每个奖励分量都给一个元素;再用基础 Q-函数学习算法去更新每个分量。离散和连续动作空间都能这么干。 但要注意,原文作者在软性扮演者-评论者(SAC)里直接上「迎头正对」式分解时,训练结果比原版 SAC 还差。问题出在更新时用两个目标模型各自分量的最小值——这会让模型失衡。实践里更稳的做法是取总分最低的那种模型,用它的分量估值去训。 假设奖励函数是各分量的线性加和,Q-函数也就继承了这种线性结构。权重 Wi 默认取 1,但因为权重是从 Q-函数里取的,你可以不改目标预测就换任意权重组合,借此评估不同侧重下的策略表现。外汇与贵金属市场高波动、高杠杆,这类实验结论仅代表回测环境下的概率倾向,实盘须自担风险。 多准则优化天然有梯度冲突、高曲率、梯度量级差的问题。原文作者建议接冲突防范梯度下降(CAGrad):把多任务梯度换成各任务梯度的加权和,解一个带收敛系数 c∈[0,1) 的优化问题,每步更盯着最坏估值去改。开 MT5 接自己写的 SAC 时,先试总分最小那条路径,再挂 CAGrad,比裸分解更可能跑平原版。

「把 SAC+DICE 拆成奖励分量来跑」

在 MT5 里落地奖励分解版 SAC+DICE,核心不是继承旧类,而是新建 CNet_SAC_D_DICE,把原来存拉格朗日系数的一维变量换成长度等于奖励分量数的向量。新增的 fQWeights 装每个分量的权重,fCAGrad_C 记 CAGrad 收敛率;构造函数里所有向量先按单位长度初始化,权重若不是单一值,必须等 Create 跑完再调 SetQWeights,否则会被覆盖。 训练主逻辑收在 Study 方法,但参数从单个 reward 换成 Rewards 向量,并删掉 ActionsLogProbab——熵对数已经并进奖励分解里了。实测中目标模型对后续状态的估值会虚高,远超实际奖励,导致主模型和副本互相适配却脱离环境。折中方案是分两阶段:先用实际累积奖励、不碰目标模型;迭代到阈值后再引入目标模型平滑估值差。 经验回放和文件读写也跟着改。Trajectory.mqh 里评论者输出神经元数 = 动作数 + 3:索引 0 是余额相对变化,1 是净值相对变化(负为回撤),再加一个空仓惩罚,熵对数随动作向量走。状态结构里用 rewards 数组替掉 log_prob,缺分量就写 0,保证数据有效。 Study EA 的 Train 方法靠 StartTargetIter 控制阶段切换:新建模型时该变量等于常量,加载预训练模型则置 0,首轮就用目标模型。循环里未达阈值就只填当前状态、后续传 NULL,并手动把熵分量的累积和剥掉;过阈值再补后续状态、清掉累积只留当前奖励。外汇与贵金属市场高波动,这类离线与仿真训练结果仅代表历史样本倾向,实盘可能失效。

MQL5 / C++
class CNet_SAC_D_DICE  : class="kw">protected CNet
  {
class="kw">protected:
   CNet               cActorExploer;
   CNet               cCritic1;
   CNet               cCritic2;
   CNet               cTargetCritic1;
   CNet               cTargetCritic2;
   CNet               cZeta;

◍ SAC-DICE 网络类的成员与接口

在 MT5 里落地 SAC-DICE 这类离线强化学习策略,第一步是把网络结构封装成一个 CNet 派生类。下面这段声明给出了该类持有的核心字段:两个 CNet 指针 cNu、cTargetNu 分别指向 nu 网络及其目标网络,三个 float 向量 fLambda、fLambda_m、fLambda_v 用来存约束系数及其滑动均值与方差,iLatentLayer 标记隐层位置,fCAGrad_C 与 iCAGrad_Iters 控制冲突梯度求解的惩罚项和迭代次数,iUpdateDelay 配合计数器做延迟更新。 损失项 fLoss1、fLoss2 以及 fZeta、fQWeights 向量在注释分隔线后声明,前者供 GetLoss 回读,后者由 SetQWeights 注入外部 critic 权重。GetLogProbability 与 CAGrad 是私有方法:前者算动作对数概率,后者对传入梯度做冲突消解,返回修正后的梯度向量。 公开接口里 Create 接收 actor、critic、zeta、nu 四个 CArrayObj 与可选的 latent_layer 参数完成装配;Study 是主训练入口,吃进当前/下一状态、动作缓冲与折扣率 tau;TargetsUpdate 按 tau 软更新目标网络。SetLambda 除了拷贝 lambda,还顺手把 fLambda_m 重置成同维零向量——这个细节若漏掉,后续均值修正会直接读到脏数据。开 MT5 把这段声明贴进自定义类,先验证 Create 能否用四组空数组跑通,再决定接哪条行情流。

MQL5 / C++
  CNet          cNu;
  CNet          cTargetNu;
  vector<class="type">class="kw">float>    fLambda;
  vector<class="type">class="kw">float>    fLambda_m;
  vector<class="type">class="kw">float>    fLambda_v;
  class="type">int             iLatentLayer;
  class="type">class="kw">float           fCAGrad_C;
  class="type">int             iCAGrad_Iters;
  class="type">int             iUpdateDelay;
  class="type">int             iUpdateDelayCount;
  class=class="str">"cmt">//---
  class="type">class="kw">float           fLoss1;
  class="type">class="kw">float           fLoss2;
  vector<class="type">class="kw">float>    fZeta;
  vector<class="type">class="kw">float>    fQWeights;
  class=class="str">"cmt">//---
  vector<class="type">class="kw">float>    GetLogProbability(CBufferFloat *Actions);
  vector<class="type">class="kw">float>    CAGrad(vector<class="type">class="kw">float> &grad);
class="kw">public:
  class=class="str">"cmt">//---
                 CNet_SAC_D_DICE(class="type">void);
                ~CNet_SAC_D_DICE(class="type">void) {}
  class=class="str">"cmt">//---
  class="type">bool            Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, class="type">int latent_layer = -class="num">1);
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool    Study(CArrayFloat *State, CArrayFloat *SecondInput, CBufferFloat *Actions, vector<class="type">class="kw">float> &Rewards,
CBufferFloat *NextState, CBufferFloat *NextSecondInput, class="type">class="kw">float discount, class="type">class="kw">float tau);
  class="kw">virtual class="type">void    GetLoss(class="type">class="kw">float &loss1, class="type">class="kw">float &loss2)   {   loss1 = fLoss1; loss2 = fLoss2;   }
  class="kw">virtual class="type">bool    TargetsUpdate(class="type">class="kw">float tau);
class=class="str">"cmt">//---
  class="kw">virtual class="type">void    SetQWeights(vector<class="type">class="kw">float> &weights)   {   fQWeights=weights;   }
  class="kw">virtual class="type">void    SetCAGradC(class="type">class="kw">float c)                   {   fCAGrad_C=c;   }
  class="kw">virtual class="type">void    SetLambda(vector<class="type">class="kw">float> &lambda)      {   fLambda=lambda;
                                                        fLambda_m=vector<class="type">class="kw">float>::Zeros(lambda.Size());

SAC-DICE 网络的构造与延迟更新落点

这段 CNet_SAC_D_DICE 的构造把几个关键超参写死在初始化列表里:CAGrad 的约束系数 fCAGrad_C 默认 0.5f,内部迭代次数 iCAGrad_Iters 给到 15,目标网络更新延迟 iUpdateDelay 与计数器 iUpdateDelayCount 都是 100 根 K 线。也就是说,Critic 的软目标同步至少要在 100 次 tick 之后才可能发生,回测时若样本量低于这个量级,延迟机制基本没触发。 TargetsUpdateDelay(int delay) 这个函数直接把传入值同时赋给 iUpdateDelay 和 iUpdateDelayCount,意味着你调用一次就等于重置了倒计时。想让黄金 5 分钟图上的策略更快跟踪均值,把 delay 设成 20~30 比硬等 100 更现实,但外汇与贵金属杠杆高,调小延迟也可能放大过拟合风险。 Create() 里先校验 OpenCL 上下文是否打开,没开就直接返回 false 并打印提示。MT5 里若用老显卡或没勾选「允许 OpenCL」,这套网络连 Actor 都建不出来,实盘前先在策略测试器确认内核编译通过。

MQL5 / C++
fLambda_v=fLambda_m;
}
  class="kw">virtual class="type">void     TargetsUpdateDelay(class="type">int delay)      {  iUpdateDelay=delay; iUpdateDelayCount=delay; }
  class=class="str">"cmt">//---
  class="kw">virtual class="type">bool     Save(class="type">class="kw">string file_name, class="type">bool common = true);
  class="type">bool             Load(class="type">class="kw">string file_name, class="type">bool common = true);
};
CNet_SAC_D_DICE::CNet_SAC_D_DICE(class="type">void) :  fLoss1(class="num">0),
                                        fLoss2(class="num">0),
                                        fCAGrad_C(class="num">0.5f),
                                        iCAGrad_Iters(class="num">15),
                                        iUpdateDelay(class="num">100),
                                        iUpdateDelayCount(class="num">100)
  {
  fLambda = vector<class="type">class="kw">float>::Full(class="num">1, class="num">1.0e-5f);
  fLambda_m = vector<class="type">class="kw">float>::Zeros(class="num">1);
  fLambda_v = vector<class="type">class="kw">float>::Zeros(class="num">1);
  fZeta = vector<class="type">class="kw">float>::Zeros(class="num">1);
  fQWeights = vector<class="type">class="kw">float>::Ones(class="num">1);
  }
class="type">bool CNet_SAC_D_DICE::Create(CArrayObj *actor, CArrayObj *critic, CArrayObj *zeta, CArrayObj *nu, class="type">int latent_layer = -class="num">1)
  {
  ResetLastError();
class=class="str">"cmt">//---
  if(!cActorExploer.Create(actor) || !CNet::Create(actor))
     {
       PrintFormat("Error of create Actor: %d", GetLastError());
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//---
  if(!opencl)
     {
       Print("Don&class="macro">#x27;t opened OpenCL context");
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//---
  if(!cCritic1.Create(critic) || !cCritic2.Create(critic))
     {
       PrintFormat("Error of create Critic: %d", GetLastError());
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//---
  if(!cZeta.Create(zeta) || !cNu.Create(nu))
     {
       PrintFormat("Error of create function nets: %d", GetLastError());
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//---

常见问题

拆开后能看清是哪个信号在驱动策略,避免被总奖励的平均效应掩盖单因子贡献,方便定位无效或噪声奖励。
延迟更新通常落在目标网络与价值函数的同步点,降低训练方差;每次更新会让策略震荡,建议隔若干步再同步。
可以,小布能按你设定的分量结构做快速诊断,标出贡献度最高的奖励项,并提示对应品种的过拟合风险。
给冲突分量加权重衰减或条件门控,先在小样本回测里看相关性,再固定比例,不要同时拉满。
最容易漏掉延迟更新的目标缓冲区初始化,漏了会导致前几步训练用零值目标,建议构造时单独列检查项。