神经网络变得轻松(第五十三部分):奖励分解·综合运用
📘

神经网络变得轻松(第五十三部分):奖励分解·综合运用

第 3/3 篇

◍ 强化学习状态与轨迹的结构落地

在 MT5 里做 RL 式盯盘,第一步是把「状态」和「轨迹」固化成结构体。下面这段定义里,SState 用 float 数组承载账户描述(偏移 4)、动作空间与奖励向量,并重载了 = 运算符做深拷贝,避免指针浅复制导致的回测串味。 STrajectory 则负责把多帧 SState 压进 Buffer_Size 长度的环形缓冲,Total 记录实际帧数,DiscountFactor 控制奖励折现。CumRevards() 方法名虽拼错,但语义是累计回报,写 EA 时别照抄拼写。 OnTick 里的奖励赋值很直白:rewards[0] 取账户向量首项,rewards[1] 用 1.0f 减次项;当买卖信号加总为 0 时,rewards[2] 扣减 atr/PrevBalance 作为无动作者惩罚,否则置 0。动作与 log_prob 乘 LogProbMultiplier 后写入 rewards[3+] 偏移区。 宏 StartTargetIteration 设为 20000,意味着策略网络大概在 2 万次迭代后切目标网;OnInit 先 LoadTotalBase(),失败直接 INIT_FAILED,模型用 Net.Load(FileName, true) 热加载。外汇与贵金属波动剧烈,这类自学习 EA 实盘前务必在策略测试器跑满历史样本,参数误用可能放大回撤。

MQL5 / C++
class="type">class="kw">float account[AccountDescr - class="num">4];
class="type">class="kw">float action[NActions];
class="type">class="kw">float rewards[NRewards];
class=class="str">"cmt">//---
 SState(class="type">void);
class=class="str">"cmt">//---
 class="type">bool Save(class="type">int file_handle);
 class="type">bool Load(class="type">int file_handle);
class=class="str">"cmt">//--- overloading
 class="type">void class="kw">operator=(class="kw">const SState &obj)
  {
   ArrayCopy(state, obj.state);
   ArrayCopy(account, obj.account);
   ArrayCopy(action, obj.action);
   ArrayCopy(rewards, obj.rewards);
  }
};
class="kw">struct STrajectory
 {
  SState States[Buffer_Size];
  class="type">int Total;
  class="type">class="kw">float DiscountFactor;
  class="type">bool CumCounted;
class=class="str">"cmt">//---
  STrajectory(class="type">void);
class=class="str">"cmt">//---
  class="type">bool Add(SState &state);
  class="type">void CumRevards(class="type">void);
class=class="str">"cmt">//---
  class="type">bool Save(class="type">int file_handle);
  class="type">bool Load(class="type">int file_handle);
};
class="type">void OnTick()
 {
class=class="str">"cmt">//---
........
........
class=class="str">"cmt">//---
  sState.rewards[class="num">0] = bAccount[class="num">0];
  sState.rewards[class="num">1] = class="num">1.0f-bAccount[class="num">1];
  vector<class="type">class="kw">float> log_prob;
  Actor.GetLogProbs(log_prob);
  if((buy_value + sell_value) == class="num">0)
    sState.rewards[class="num">2] -= (class="type">class="kw">float)(atr / PrevBalance);
  else
    sState.rewards[class="num">2] = class="num">0;
  for(class="type">class="kw">ulong i = class="num">0; i < NActions; i++)
   {
     sState.action[i] = ActorResult[i];
     sState.rewards[i + class="num">3] = log_prob[i] * LogProbMultiplier;
   }
  if(!Base.Add(sState))
     ExpertRemove();
  }
class="macro">#define StartTargetIteration class="num">20000
class="type">int OnInit()
 {
class=class="str">"cmt">//---
  ResetLastError();
  if(!LoadTotalBase())
   {
     PrintFormat("Error of load study data: %d", GetLastError());
     class="kw">return INIT_FAILED;
   }
class=class="str">"cmt">//--- load models
  if(!Net.Load(FileName, true))
   {
     CArrayObj *actor = new CArrayObj();

初始化与训练循环的裸代码骨架

这段片段把强化学习智能体的初始化和单步训练摊开了写,没封装任何花哨接口。先建 critic 数组对象,两次失败判断都直接删 actor/critic 并返回 INIT_FAILED,说明内存和资源泄漏在 MT5 EA 里必须手动兜底。 Net.Create(actor, critic, critic, critic, LatentLayer) 这一行透露网络结构:四个角色里三个复用 critic 指针,潜在层单独传入,属于典型的 actor-critic 变体。StartTargetIter 根据是否热启动赋不同值,冷启动直接置 0。 训练函数 Train 里用 MathRand()/32767.0 做均匀采样选轨迹,再用 MathRand()*MathRand()/32767^2 做偏置采样选步长——平方分母让 i 偏向 0,小步长被过度采样的概率更高。 账户特征构建那段把 8 个 account 字段归一化:余额变化率、权益占比、权益变化率,再到第 7 字段除以 2023–2024 年毫秒数做时间尺度压缩。外汇与贵金属市场高杠杆,这类特征若直接接实盘,过拟合历史样本的概率偏高,建议先开 MT5 用历史缓冲跑通内存释放路径。

MQL5 / C++
CArrayObj *critic = new CArrayObj();
if(!CreateDescriptions(actor, critic))
  {
   class="kw">delete actor;
   class="kw">delete critic;
   class="kw">return INIT_FAILED;
  }
if(!Net.Create(actor, critic, critic, critic, LatentLayer))
  {
   class="kw">delete actor;
   class="kw">delete critic;
   class="kw">return INIT_FAILED;
  }
class="kw">delete actor;
class="kw">delete critic;
StartTargetIter = StartTargetIteration;
 }
 else
   StartTargetIter = class="num">0;
class=class="str">"cmt">//---
 if(!EventChartCustom(ChartID(), class="num">1, class="num">0, class="num">0, "Init"))
  {
   PrintFormat("Error of create study event: %d", GetLastError());
   class="kw">return INIT_FAILED;
  }
class=class="str">"cmt">//---
 class="kw">return(INIT_SUCCEEDED);
 }
class="type">void Train(class="type">void)
  {
  class="type">int total_tr = ArraySize(Buffer);
  class="type">uint ticks = GetTickCount();
class=class="str">"cmt">//---
  for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
   {
    class="type">int tr = (class="type">int)((MathRand() / class="num">32767.0) * (total_tr - class="num">1));
    class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2));
    if(i < class="num">0)
      {
       iter--;
       class="kw">continue;
      }
    class=class="str">"cmt">//---
    bState.AssignArray(Buffer[tr].States[i].state);
    class="type">class="kw">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
    class="type">class="kw">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
    bAccount.Clear();
    bAccount.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance);
    bAccount.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance);
    bAccount.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
    bAccount.Add(Buffer[tr].States[i].account[class="num">2]);
    bAccount.Add(Buffer[tr].States[i].account[class="num">3]);
    bAccount.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance);
    bAccount.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance);
    bAccount.Add(Buffer[tr].States[i].account[class="num">6] / PrevBalance);
    class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (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[7] 分别除以月线、周线、日线周期的秒数,再乘 2π 做正弦/余弦变换,等于把绝对时长压成 0~1 周期内的相位量。 具体看:先算 x = account[7] / PeriodSeconds(PERIOD_MN1),若 x 非 0 则取 2.0*M_PI*x 的 sin 值入 bAccount;接着同样逻辑换 PERIOD_W1 取 cos,再换 PERIOD_D1 取 sin。三个周期维度叠加,模型可能更容易捕捉不同时间尺度的账户节奏。 训练分支里,iter < StartTargetIter 时走普通 Study,用 rewards 减去下一状态的折扣回报;否则进 Target 分支,把下一状态余额相对变化 (acc[0]-PrevBalance)/PrevBalance、权益占比 acc[1]/PrevBalance 等 6 项塞进 bNextAccount。PrevBalance 为 0 直接 iter-- 并 continue,避免除零炸训练。 外汇与贵金属杠杆高,这类特征工程只是预处理一环,实盘复制前请在 MT5 策略测试器用历史 tick 验证数值稳定性。

MQL5 / C++
bAccount.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)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
   bAccount.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)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
   bAccount.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)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
   bAccount.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">//---
   bActions.AssignArray(Buffer[tr].States[i].action);
   vector<class="type">class="kw">float> rewards;
   rewards.Assign(Buffer[tr].States[i].rewards);
   class=class="str">"cmt">//---
   if(iter < StartTargetIter)
     {
      class="type">class="kw">ulong start = rewards.Size() - bActions.Total();
      for(class="type">class="kw">ulong r = start; r < rewards.Size(); r++)
        rewards[r] -= Buffer[tr].States[i + class="num">1].rewards[r] * DiscFactor;
      if(!Net.Study(GetPointer(bState), GetPointer(bAccount), GetPointer(bActions), rewards,
                    NULL, NULL, DiscFactor, Tau))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         class="kw">break;
        }
     }
   else
     {
      class=class="str">"cmt">//--- Target
      bNextState.AssignArray(Buffer[tr].States[i + class="num">1].state);
      PrevBalance = Buffer[tr].States[i].account[class="num">0];
      PrevEquity = Buffer[tr].States[i].account[class="num">1];
      if(PrevBalance == class="num">0)
        {
         iter--;
         class="kw">continue;
        }
      bNextAccount.Clear();
      bNextAccount.Add((Buffer[tr].States[i + class="num">1].account[class="num">0] - PrevBalance) / PrevBalance);
      bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">1] / PrevBalance);
      bNextAccount.Add((Buffer[tr].States[i + class="num">1].account[class="num">1] - PrevEquity) / PrevEquity);
      bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">2]);
      bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">3]);
      bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">4] / PrevBalance);

◍ 账户特征里的周期正弦编码

这段逻辑在给强化学习网络喂下一状态的账户向量,其中账户索引 5、6 两项先除以 PrevBalance 做归一化,把绝对权益压成相对比例,避免不同本金规模干扰梯度。 索引 7 疑似累计交易日或秒数类字段,代码用四种时间基底把它转成相位:年差固定 2024.01.01-2023.01.01(秒数约 31536000)、月线 PeriodSeconds(PERIOD_MN1)、周线 PERIOD_W1、日线 PERIOD_D1,各自乘 2π 后取 sin/cos。这样网络能隐式学到账户寿命在月、周、日节奏上的周期特征,而非把时间当纯线性量。 训练循环里每满 500 毫秒 tick 就抓一次 Critic1 / Critic2 的 loss 用 Comment 打印,进度按 iter*100.0/Iterations 显示百分比,误差给到 15.8f 精度。实盘或回测时若想看收敛,直接开 MT5 终端看左上角浮层即可,外汇与贵金属品种波动大,这类训练对样本分布敏感,过拟合概率不低。 循环结束清 Comment,最后再取一次双 Critic 误差并调用 TargetsUpdate(Tau) 软更新目标网络,Tau 大小直接决定目标滞后程度,调参时建议从 0.001 起扫。

MQL5 / C++
bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">5] / PrevBalance);
bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">6] / PrevBalance);
x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (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;);
bNextAccount.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)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
bNextAccount.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)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
bNextAccount.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)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
bNextAccount.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
for(class="type">class="kw">ulong r = class="num">0; r < rewards.Size(); r++)
   rewards[r] -= Buffer[tr].States[i + class="num">1].rewards[r] * DiscFactor;
if(!Net.Study(GetPointer(bState), GetPointer(bAccount), GetPointer(bActions), rewards,
               GetPointer(bNextState), GetPointer(bNextAccount), DiscFactor, Tau))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   class="kw">break;
   }
}
class=class="str">"cmt">//---
if(GetTickCount() - ticks > class="num">500)
   {
   class="type">class="kw">float loss1, loss2;
   Net.GetLoss(loss1, loss2);
   class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic1",
      iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), loss1);
   str += StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic2",
      iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), loss2);
   Comment(str);
   ticks = GetTickCount();
   }
   }
 Comment("");
class=class="str">"cmt">//---
 class="type">class="kw">float loss1, loss2;
 Net.GetLoss(loss1, loss2);
 Net.TargetsUpdate(Tau);
 PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic1", loss1);

用 PrintFormat 和 ExpertRemove 收掉异常模型

在 MQL5 的 EA 调试里,PrintFormat 比 Print 更适合对齐多类数据。上面这段把函数名、行号、标签和浮点损失值按固定宽度打出来,能直接看出 Critic2 网络的 loss2 是否越界。 loss2 用 %10.7f 保留七位小数,配合 %-15s 左对齐标签,终端里多行日志不会错位。若发现 loss2 突然变成 nan 或暴涨,说明判别器第二轮训练已经失稳。 确认异常后调用 ExpertRemove() 立刻卸载 EA,避免在坏模型上继续跑单。外汇与贵金属市场高杠杆、价格跳变频繁,这种硬停止能防止错误信号扩散。

MQL5 / C++
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic2", loss2);
  ExpertRemove();
class=class="str">"cmt">//---
  }

「SAC+DICE 实盘前回测与样本迭代」

把奖励函数分解方案落到 MT5 里跑一遍,才能知道前面那套 SAC+DICE 结构是不是纸上谈兵。训练数据取自 2023 年前 5 个月 EURUSD H1,指标全默认,初始本金 10,000 美元,外汇与贵金属类策略本身高风险,回测盈利不代表实盘可复制。 样本收集分两阶段:先用随机参数填经验缓冲区,生成非政策的“状态→动作→新状态→奖励”集;这一步特殊在直接记录代理动作对应的环境奖励分解,而不是像旧算法那样只存聚合奖励。随后启动 ..\SAC-D&DICE\Study.mq5 做初级训练,两个评论者误差稳步下降,但一旦切到目标模型估后续状态,就会偶发混乱峰值再平滑回去。 第二阶段丢进策略测试器优化模式,用第一阶段训出的乐观扮演者跑全参搜索,单验离散度明显低于随机集。样本收集和模型更新反复迭代,直到收益不再提升、卡在局部最小才停。 训出的扮演者在训练期仅微利:28 笔交易胜率 32%,靠平均利润超平均亏损 2 倍、最大利润近最大亏损 3.5 倍,把盈利因子顶到略高于 1。训练后一个月裸跑近 20% 利润,但只成交 4 笔、1 笔获利,那笔利润是最糟亏损的 12.8 倍——样本极少,统计意义弱,EURUSD 高杠杆下这种稀疏信号随时可能反转。 模型确实能盈利,但波浪振幅大、对新数据敏感,后续大概率还要调奖励权重或重采样本,不能直接拿来跑实盘。

◍ 拆开奖励分量看代理者怎么学

前面几节把奖励函数分解接进了 SAC+DICE,MQL5 里三个 EA(Research / Study / Test)和四个类库(Trajectory、Net_SAC_D_DICE、NeuroNet、NeuroNet.cl)就是整套训练链路的实体。跑通后,模型在训练集内外都出了盈利曲线,说明分解后的奖励确实让代理者泛化得动。 但实测结果离‘好用’还远,分解的真正价值不在收益数字,而在可观测:每一路奖励分量对决策的影响都能单独拎出来量。 建议你直接改 Study.mq5 里的奖励权重,把某一分量置零再训练一轮,对比 Test.mq5 的 equity 曲线。外汇与贵金属杠杆高,RL 模型过拟合概率不低,任何分量删减都先在历史数据验,再上模拟盘。 留个念想:奖励函数不是圣经,哪一路该留哪一路该砍,得让代理者自己告诉你。

常见问题

不同周期数值量纲差异大,网络梯度易震荡难收敛;建议先做周期归一化再输入。
把时间周期转成连续周期信号,帮模型感知节律而不依赖绝对时间,提升泛化。
小布可自动跑回测并标出异常轨迹,你只需看样本迭代建议,不必手写训练循环。
用 PrintFormat 打日志后用 ExpertRemove 移除本专家,避免残态占用资源。
无统一标准,但应在多周期样本上迭代至奖励曲线平稳,再考虑模拟盘验证。