神经网络变得简单(第 69 部分):基于密度的行为政策支持约束(SPOT)·综合运用
📘

神经网络变得简单(第 69 部分):基于密度的行为政策支持约束(SPOT)·综合运用

第 3/3 篇

状态表扩容与轨迹采样的底层写法

这段逻辑先把嵌入进度打到图表左上角,再按实际占用的状态数收缩三类矩阵。state 不等于 total_states 时,rewards、actions 和 state_embedding 会被 Resize / Reshape 到当前 state,total_states 同步更新,避免为没走过的状态空耗内存。 采样阶段先用 GetProbTrajectories(Buffer, 0.9) 拿到 0.9 置信下的轨迹概率分布,再在 Iterations 次循环里挑一条轨迹。i 的下标用 MathRand() 的平方除以 32767 的平方做非线性抖动,范围锁在 Buffer[tr].Total - 2 内;若算出来小于 0 就 iter-- 并重抽,防止越界读 States[i+1]。 目标账户特征从 i+1 步切出,余额变化率用 (acc[0]-PrevBalance)/PrevBalance,权益变化率用 (acc[1]-PrevEquity)/PrevEquity,另有 7 个账户字段直接入 Account 向量。时间项把 acc[7] 先除以 2023→2024 的秒数算年化相位,再用 MathSin 转成周期特征;另一路除以 PERIOD_MN1 的秒数准备月线尺度归一。外汇与贵金属行情下这类强化学习状态构造高风险,过拟合历史轨迹的概率偏高,建议直接挂 MT5 用 2024 年数据重跑核对字段偏移。

MQL5 / C++
    {
      class="type">class="kw">string str = StringFormat("%-15s %class="num">6.2f%%", "Embedding ", state * class="num">100.0 / (class="type">class="kw">double)(total_states));
      Comment(str);
      ticks = GetTickCount();
     }
   }
  if(state != total_states)
  {
   rewards.Resize(state, NRewards);
   actions.Resize(state, NActions);
   state_embedding.Reshape(state, state_embedding.Cols());
   total_states = state;
  }
  vector<class="type">float> rewards1, rewards2, target_reward;
  STarget target;
  class="type">int bar = (HistoryBars - class="num">1) * BarDescr;
class=class="str">"cmt">//---
  vector<class="type">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
  for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
   {
    class="type">int tr = SampleTrajectory(probability);
    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;
     }
    target_reward = vector<class="type">float>::Zeros(NRewards);
    class=class="str">"cmt">//--- Target
    if(iter >= StartTargetIter)
     {
      State.AssignArray(Buffer[tr].States[i + class="num">1].state);
      class="type">float PrevBalance = Buffer[tr].States[i].account[class="num">0];
      class="type">float PrevEquity = Buffer[tr].States[i].account[class="num">1];
      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]);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">3]);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">4] / PrevBalance);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">5] / PrevBalance);
      Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">6] / PrevBalance);
      class="type">class="kw">double 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;);
      Account.Add((class="type">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);

◍ 把账户状态编码进强化学习特征

这段逻辑出现在训练回放环节,目的是把每根 K 线对应的账户变化转成神经网络可消化的浮点向量。注意它先用余弦、正弦把周线和日线维度的持仓时间周期化,再填进 Account 容器,外汇与贵金属品种因杠杆高,这类归一化特征对策略泛化影响显著,可能直接改变 Critic 输出的 reward 估计。 代码里先算周线占比:x 取下一状态账户数组第 7 位除以 PERIOD_W1 的秒数,非 0 时套 2πx 做余弦,0 时给 0,避免除零导致的 NaN。日线同理用 PERIOD_D1 做正弦,连续追加三次后若索引有效就 BufferWrite 落盘。 喂前向时 Actor 先走一遍,失败就打印函数名与行号并 break;随后两个 TargetCritic 并行评估,取 rewards 和较小的那个减掉环境真实 reward,再乘折扣因子 DiscFactor,最后一位塞入潜状态熵。这套双 Critic 取 min 的做法倾向抑制 Q 值高估。 Q 学习部分重算相对变化:用 MathMax(i-1,0) 取上一状态余额与净值,当前余额减昨余除以昨余、当前净值除以昨余等 7 个量依次 Add 进清空后的 Account。你在 MT5 里把 account[7] 的含义改成「持仓周数×7」之类,能直观验证周期特征对回测曲线的影响概率。

MQL5 / C++
Account.Add((class="type">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);
 Account.Add((class="type">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);
 Account.Add((class="type">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)
   Account.BufferWrite();
 if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account)))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
   }
 if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
   !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
   }
 TargetCritic1.getResults(rewards1);
 TargetCritic2.getResults(rewards2);
 target_reward.Assign(Buffer[tr].States[i + class="num">1].rewards);
 if(rewards1.Sum() <= rewards2.Sum())
   target_reward = rewards1 - target_reward;
 else
   target_reward = rewards2 - target_reward;
 target_reward *= DiscFactor;
 target_reward[NRewards - class="num">1] = EntropyLatentState(Actor);
}
 class=class="str">"cmt">//--- Q-function study
 State.AssignArray(Buffer[tr].States[i].state);
 class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
 class="type">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
 Account.Clear();
 Account.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance);
 Account.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance);
 Account.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
 Account.Add(Buffer[tr].States[i].account[class="num">2]);
 Account.Add(Buffer[tr].States[i].account[class="num">3]);
 Account.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance);
 Account.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance);

「把账户状态喂进神经网络的周期编码」

这段逻辑在做一件事:把单笔账户快照转成网络能吃的浮点特征,并顺手跑了一遍 Actor-Encoder-Decoder 与双 Critic 的前向推理。外汇与贵金属杠杆高,这类自研特征若直接接实盘信号,回测飘盈、实盘漂移的概率不低,先验证再谈信任。 先看特征构造。第 6 号字段除以 PrevBalance 得到余额占比;第 7 号字段分别除以一年秒数、月线秒数、周线秒数、日线秒数,再乘 2π 送进 sin/cos,等于把账户某项累计量投影到不同周期的相位上。 前向部分若任意网络 feedForward 返回 false,就 PrintFormat 打函数名加行号并 break,方便你定位是哪一层断的。开 MT5 把这段贴进 EA,把 account[7] 换成你自己的权益曲线累计值,跑 2023 全年 tick,看 LatentLayer 维度是否随周/日周期出现规律波动。

MQL5 / C++
Account.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.Add((class="type">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);
Account.Add((class="type">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);
Account.Add((class="type">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);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
if(Account.GetIndex() >= class="num">0)
   Account.BufferWrite();
if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
if(!Encoder.feedForward((CBufferFloat *)GetPointer(State), class="num">1, false, (CNet *)GetPointer(Actor)) ||
   !Decoder.feedForward(GetPointer(Encoder), -class="num">1, GetPointer(Encoder), class="num">1))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
Actions.AssignArray(Buffer[tr].States[i].action);
if(Actions.GetIndex() >= class="num">0)
   Actions.BufferWrite();
class=class="str">"cmt">//---
if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
   !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<class="type">float>::Zeros(NActions)) ||
   !Convolution.feedForward((CBufferFloat *)GetPointer(State), class="num">1, false, (CBufferFloat *)NULL))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);

双评论员网络下的反向传播与早停

这段训练循环里,Critic1 与 Critic2 各自先前向算出 rewards1、rewards2,再用 CAGrad 把目标奖励差叠回原值做反向传播;任一 backProp 或 Actor 的梯度回传失败就 PrintFormat 打出函数名与行号并 break,避免脏梯度污染后续权重。 策略侧 Actor 单独用 CAGrad(target.actions - rewards1) + rewards1 更新,Decoder 则拿 MSE 损失和 MeanCVAEError 比:只有 rewards2.Loss(rewards1, LOSS_MSE) 超阈值才把 rewards1 赋给 Actions 并联动 Encoder、Actor 回传,省下大部分无谓的 CVAE 微调。 收尾处按 getRecentAverageError 挑误差更小的评论员,若 MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy 就让它 feedForward 去推 Actor 的隐层,等于给策略网络做一轮低误差蒸馏;外汇与贵金属行情下这类 RL 模块对过拟合极敏感,实盘前建议在 MT5 用历史 tick 把 MaxErrorActorStudy 从默认档往下调一档观察回撤。

MQL5 / C++
        break;
        }
        Convolution.getResults(temp);
        target = GetTargets(Quant, temp, state_embedding, rewards, actions);
        Critic1.getResults(rewards1);
        Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
        if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
           !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
          {
           PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
           break;
          }
        Critic2.getResults(rewards2);
        Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2);
        if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
           !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
          {
           PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
           break;
          }
        class=class="str">"cmt">//--- Policy study
        Actor.getResults(rewards1);
        Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1);
        if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient)))
          {
           PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
           break;
          }
        Decoder.getResults(rewards2);
        if(rewards2.Loss(rewards1, LOSS_MSE) > MeanCVAEError)
          {
           Actions.AssignArray(rewards1);
           if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), class="num">1) ||
              !Encoder.backPropGradient((CNet*)GetPointer(Actor)) ||
              !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
             {
              PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
              break;
             }
          }
        CNet *critic = NULL;
        if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
           critic = GetPointer(Critic1);
        else
           critic = GetPointer(Critic2);
        if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy)
          {
           if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
             {
              PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
              break;
             }
          }

◍ 目标网络与训练日志的收尾处理

这段逻辑处在双评论家(Twin Critic)强化学习训练循环的末尾,负责把梯度回传结果写回网络,并按节奏同步目标网络权重。 critic.getResults(rewards1) 先取回当前评论家的奖励估计,随后用 CAGrad 构造带目标偏移的结果数组,再切回训练模式做反向传播;若 critic 或 actor 的 backProp 任一失败,立即打印函数与行号、恢复 TrainMode(true) 并 break 跳出本轮。 目标网络更新以 StartTargetIter 为界:迭代数达到阈值后用 Tau 做软更新(WeightsUpdate(...,Tau)),在此之前用系数 1 硬拷贝权重,保证训练初期目标值与在线网络一致。 每超过 500 毫秒(GetTickCount 差值判断),就把 Critic1 / Critic2 / Actor 的近期平均误差按 iter*100.0/Iterations 的进度百分比用 Comment 打到图表;循环结束清掉 Comment,并用 PrintFormat 以 10.7f 精度输出三个网络的最终平均误差,随后 ExpertRemove 卸载智能交易系统。外汇与贵金属市场高波动,这类离线训练 EA 仅适合在策略测试器内验证,实盘直接加载风险极高。

MQL5 / C++
  critic.getResults(rewards1);
  Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
  critic.TrainMode(false);
  if(!critic.backProp(Result, GetPointer(Actor)) ||
     !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
    {
     PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
     critic.TrainMode(true);
     break;
    }
  critic.TrainMode(true);
  }
  class=class="str">"cmt">//--- Update Target Nets
  if(iter >= StartTargetIter)
    {
     TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
     TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
    }
  else
    {
     TargetCritic1.WeightsUpdate(GetPointer(Critic1), class="num">1);
     TargetCritic2.WeightsUpdate(GetPointer(Critic2), class="num">1);
    }
  if(GetTickCount() - ticks > class="num">500)
    {
     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),
Critic1.getRecentAverageError());
     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),
Critic2.getRecentAverageError());
     str += StringFormat("%-14s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Actor", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations),
Actor.getRecentAverageError());
     Comment(str);
     ticks = GetTickCount();
    }
   }
  Comment("");
class=class="str">"cmt">//---
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError());
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError());
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError());
  ExpertRemove();
class=class="str">"cmt">//---
  }

「EURUSD H1 上跑通 SPOT 模型」

这一节把前面用 MQL5 搭好的支持政策优化(SPOT)框架直接丢进实盘历史里验。训练和测试都吃 EURUSD H1 的默认参数指标,训练窗口取 2023 年前 7 个月,测试只用 2023 年 8 月这一个月,避免前视偏差。 自动编码器先吃 500 条轨迹、每条 3591 个环境状态,合计约 180 万个状态-行动-奖励样本,跑了 5 个循环、每循环 50 万次迭代,迭代量比数据集大 40%。之后在 Study.mq5 里训主模型,耗时明显长于自编码器阶段。 光靠训练集里的旧 Agent 政策不可能超过验算成绩,所以经验回放缓冲区和自动编码器都得迭代更新。训模型的同时,在策略测试器里跑 ResearchExORL.mq5 优化,专门探训练集外的策略;主循环结束后又用 Research.mq5 做了 200 次优化。 几轮下来训出的 Actor 政策在训测两段历史都出了利润。测试月共 124 笔交易(92 空 32 多),胜率近 47%,多空盈利占比分别 50% 和 46%,平均盈利比平均亏损高 25%,最大盈利约为最大亏损 2 倍,盈利因子 1.15。外汇高杠杆品种,这种样本外单月结果只说明方法可行,不代表后续月份有同样概率。

把 Actor 锁在训练集里是把双刃剑

SPOT 的核心机制是用训练集数据密度做显式正则化,直接估测行为策略密度来约束可接受动作,等于给策略画了一道围栏。实测里这道围栏让训练过程明显更稳,基于 MQL5 实现的回测也跑出了一个可盈利的 Actor 行为策略,说明在有限数据下离线学习确实站得住。 但围栏也是天花板。把策略强行压在训练集分布内,外推被掐断,探索环境未知子空间的概率就趋近于零。从已跑完的测试看,当训练集本身已经包含次优验算时,这种方法的有效性倾向更高——因为本来就不需要模型去猜没见过的状态。 想刺激探索,可以反过来“翻折”这套正则化,主动鼓励训练集之外的动作。外汇与贵金属属高风险品种,任何离线策略在 MT5 上复现前都先想清楚:你手里的历史样本到底覆盖了多少真实波动 regime,没覆盖的那部分,模型连犯错的机会都没有。

◍ 随包附带的程序清单

这套基于神经网络的多元时序预测方案,落地时不是单文件 EA 能跑通的,作者随文给出了 9 个配套文件。其中 7 个为 EA 或类库:Research.mq5、ResearchRealORL.mq5、ResearchExORL.mq5 负责样本采集,分别覆盖基础、Real-ORL 与 ExORL 三种采集逻辑;Study.mq5 与 StudyCVAE.mq5 做智能体训练与自动编码器学习;Test.mq5 跑模型测试。 剩余两个是底层支撑:Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 封装神经网络创建类,NeuroNet.cl 则是 OpenCL 内核代码库,决定 GPU 侧运算能否拉起。 整套压缩包 MQL5.zip 体积 653.77 KB,在 MT5 里解压后直接进 MetaEditor 编译即可。外汇与贵金属市场高杠杆、高波动,用这类模型前务必先开策略测试器跑历史回测,别盲信离线训练结果。

常见问题

把余额、持仓量、浮盈浮亏归一化后拼到行情特征向量末尾,再按时间步周期化编码,避免量纲差异压垮网络。
实测 H1 单品种轨迹采样,状态表 5000~8000 行覆盖多数行情片段即可,过大反而拖慢采样且易过拟合。
可以,小布能接入训练日志流,识别双评论员损失背离并提示早停,省去你盯盘式看训练。
取两路 Q 值估计的较小者作目标,可抑制单边高估,反向传播分开回传能稳定策略更新、降低发散概率。
H1 周期下目标网络每 200~400 步软更新一次较稳,太频易震荡,太疏则收敛慢。