神经网络变得简单(第 59 部分):控制二分法(DoC)(基础篇)
📘

神经网络变得简单(第 59 部分):控制二分法(DoC)(基础篇)

第 1/3 篇

「用控制二分法给神经网络调权重」

在 MT5 里做价格行为建模时,常遇到一个尴尬:模型要么对历史拟合过头,要么对未来完全失准。控制二分法(Degree of Control,DoC)的思路是,把网络输出的控制权按阈值切成两段,一段交给模型自由决策,一段强制按规则约束,从而压住过拟合倾向。 具体实现上,我们先算模型输出与基准信号的偏离度,当偏离小于预设 DoC 阈值时放行原输出,超过则截断到边界。下例用归一化后的预测值做二分处理,阈值 0.2 意味着只让 ±20% 区间内的自由决策生效。 外汇与贵金属杠杆高、跳空频繁,这套机制只能降低拟合风险,不保证胜率;建议你在 EURUSD H1 上先跑一遍,看截断前后的 equity 曲线抖动有没有收敛。

MQL5 / C++
class="type">class="kw">double ApplyDoC(class="type">class="kw">double pred, class="type">class="kw">double base, class="type">class="kw">double doc)
{
   class="type">class="kw">double dev = pred - base;
   if(MathAbs(dev) <= doc) class="kw">return pred;      class=class="str">"cmt">// 偏离在控制阈值内,保留模型输出
   class="kw">return base + (dev > class="num">0 ? doc : -doc);     class=class="str">"cmt">// 超阈值则截断到边界,限制失控
}

随机环境里强化学习的软肋

金融市场由经济基本面、新闻、地缘事件与技术面等多重诱因交织,交易者平时只能观测到其中一小部分,剩余部分被归入环境随机性。这种结构让行情分析天然困难,也解释了为什么很多策略在样本外容易失效。 我们上一篇文章实测发现,决策转换器(Decision Transformer)虽用自回归方式对「状态—动作—期望奖励—实际回报」序列建模,但在高随机行情中,训练好的策略与预期未来结果之间仍会出现明显偏差。 类似短板并非个例。2022 年 10 月谷歌团队提出「控制二分法」(Control as Hybrid)思路,正是试图缓解这类随机扰动下策略漂移的问题。外汇与贵金属杠杆高、跳空频繁,直接套用这类模型前建议先在 MT5 历史数据上做分段回测。

◍ DoC 怎么把可控与随机拆开

控制二分法原本是斯多葛学派的逻辑底子:事分两类,一类完全在你手心里,一类你再怎么动作也拦不住。DoC 方法把这个思路搬进强化学习,明确切开策略能管的(动作策略)和管不了的(环境随机性),不让随机噪声污染隐藏状态 z(τ)。 在决策变换器里,R1 代表“在途回报”,是智能体选动作的向导,类似 GCRL 里的目标设定。但作者强调,z 不能塞进任何关于未来 Rt 或 St+1 的信息——那些在历史上根本未知。于是目标里加了互信息约束,用对比训练逼 z 跟未来奖励、状态脱钩。

训练时引入能量函数参数化的条件分布 ω(rtτ0:t-1, st, at),再通过拉格朗日比率合并,最小化最终目标来联合训 π 和 z(τ)。操作阶段要挑高期望奖励的 z,做法是:从先验 p(zs0) 抽大值候选,用值函数 V(z) 排位,选最高的喂给策略。训 p 时会对 q(zτ) 用停止梯度,免得先验被 q 正则化带偏。

原文作者在多种随机环境里验证了 DoC 的明显优势,样本量不小。外汇与贵金属属高风险品种,这类方法在实盘随机性下仅“可能”改善策略稳定性,建议开 MT5 用历史 tick 复现对比训练再下结论。

「用 MQL5 把控制二分法塞进决策转换器」

这一节把控制二分法(DoC)落地到 MQL5,核心思路是在上一篇文章的 Decision Transformer(DT)代码上续做:保留基本思想,但用完全参数化分位数函数(FQF)去近似先验分布 p(z|s0),从而避免每次从训练集采样一大批潜在状态再逐一估值。作者明确说这是对原方法的个人解释,部分实现会偏离原始方案。 两个模型共用一套架构底子:扮演者(agent)吃一整根 H1 柱线的可变状态,经批量归一、嵌入、多头稀疏注意力、卷积降维,最后接 VAE 潜在层制造策略随机性;潜在表示模型结构类似,但输入少一个元素——序列里只分析 4 个实例,而 agent 看 5 个,靠嵌入层窗口数组变量自动控制,不必手改每根柱线。 数据收集 EA(DoC_Research.mq5)只在新柱线开盘时跑。OnTick 里先补价格历史与指标读数,再拼账户状态、持仓、时间戳和上一动作;随后调一次潜在模型前向,把生成的 z 表示塞回 agent 输入缓冲,才跑 agent 前向出最优动作。H1 时间帧是主观选法,但训练与实盘必须同品种同周期,换品种或周期得重训。 训练 EA(DoC_Study.mq5)离线读经验回放。外循环按批次抽轨迹,内循环按交互时序喂数据,迭代长度为堆栈三倍以防空栈;潜在模型用 FQF 层预测未来奖励,此处弃用 CAGrad 以免概率分布失真,agent 政策训练则照 DT 原样并启用 CAGrad 提速收敛。 别把 FQF 当黑盒直接抄 FQF 在这里同时承担概率与定量分布,若你在自建模型时漏掉目标模型更新周期设置,训练会多出无用计算。原文把潜在模型目标更新周期设得很大,因为训练阶段不打算用 FQF 提供的目标模型——这一行不写,显存和 CPU 都白烧。

MQL5 / C++
class="type">bool CreateDescriptions(CArrayObj *agent, CArrayObj *rtg)
  {
class=class="str">"cmt">//---
   CLayerDescription *descr;
class=class="str">"cmt">//---
   if(!agent)
     {
      agent = new CArrayObj();
      if(!agent)
         class="kw">return false;
     }
class=class="str">"cmt">//--- Agent
   agent.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))

强化学习智能体的逐层网络装配

在 MT5 里搭一个面向价格序列的 RL agent,网络结构不是一次性声明的,而是靠 CLayerDescription 逐层 new 出来再 Add 进 agent。基础层先把输入维度算清楚:prev_count = NRewards + BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions,优化器统一挂 ADAM,激活留 None。 第一层做批归一化,descr.batch = 1000 意味着每个小批量喂 1000 条样本;这一层节点数和输入一致,不压缩也不扩张。第二层是 Embedding,把 HistoryBars 长度的原始特征映射到 EmbeddingSize 维,windows 数组按 {BarDescr*NBarInPattern, AccountDescr, TimeDescription, NActions, NRewards} 切分各子特征窗。 第三层多头稀疏注意力最吃算力:count 被拉到 prev_count*5,window_out=32、layers=8、step=4,probability=Sparse 表示只保留部分注意力连接,显存占用可能比稠密版低一个量级。第四层卷积用 LReLU,window_out=4、step=prev_wout,把注意力输出再做一次局部聚合。 后面三層回到 Base 全连接:LatentCount 节点,激活从 LReLU 切到 TANH,逐步把表征压进动作空间。任何一层 Add 失败就 delete descr 并 return false,所以开 MT5 跑这段代码时,若日志里 agent 构建中断,优先查某一层 descr 字段越界或 agent 容器已满。

MQL5 / C++
   class="kw">return false;
   descr.type = defNeuronBaseOCL;
   class="type">int prev_count = descr.count = (NRewards + BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">1
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = class="num">1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">2
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronEmbeddingOCL;
   prev_count = descr.count = HistoryBars;
      {
         class="type">int temp[] = {BarDescr*NBarInPattern,AccountDescr,TimeDescription,NActions,NRewards};
         ArrayCopy(descr.windows,temp);
      }
   class="type">int prev_wout = descr.window_out = EmbeddingSize;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">3
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronMLMHSparseAttentionOCL;
   prev_count = descr.count = prev_count*class="num">5;
   descr.window = prev_wout;
   descr.step = class="num">4;
   descr.window_out = class="num">32;
   descr.layers = class="num">8;
   descr.probability = Sparse;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">4
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronConvOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   descr.window_out = class="num">4;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!rtg.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">5
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">6
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
         class="kw">delete descr;
         class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">7
   if(!(descr = new CLayerDescription()))

◍ 把策略网络与回报网络叠到一块

上面的片段在 agent 里续接了三层:第 7 层用 LReLU 的潜变量层(LatentCount 个节点),第 8 层是 2*NActions 宽、SIGMOID 激活的输出层,第 9 层直接挂 defNeuronVAEOCL 做变分重构,只留 NActions 个节点。三层都走 ADAM,任何一次 Add 失败就 delete 描述符并回 false,内存不会漏。 rtg 这条回报网络是单独建的 CArrayObj,Clear 之后从输入层重搭:输入维度 = BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions,无激活直接进网络。紧接着一层 defNeuronBatchNormOCL 做批量归一,batch 写死 1000,这一步对训练稳定性影响肉眼可见。 真正吃算力的是后面三层。Embedding 层把 HistoryBars 作为节点数,用 ArrayCopy 把四个窗口尺寸(BarDescr*NBarInPattern、AccountDescr、TimeDescription、NActions)塞进 descr.windows,输出宽度由 EmbeddingSize 决定。再往后一层多头稀疏注意力把上一层层宽乘 4,window_out 设 32、叠 8 个子层、step 为 4,概率走 Sparse——在 MT5 里把这几个数调小,显存占用和单步推理延迟会明显降下来,但策略表达力可能跟着打折。

MQL5 / C++
   class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">8
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = class="num">2 * NActions;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">9
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- RTG
   if(!rtg)
      {
       rtg = new CArrayObj();
       if(!rtg)
          class="kw">return false;
      }
class=class="str">"cmt">//---
   rtg.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!rtg.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">1
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = class="num">1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!rtg.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">2
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronEmbeddingOCL;
   prev_count = descr.count = HistoryBars;
      {
       class="type">int temp[] = {BarDescr*NBarInPattern,AccountDescr,TimeDescription,NActions};
       ArrayCopy(descr.windows,temp);
      }
   prev_wout = descr.window_out = EmbeddingSize;
   if(!rtg.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">3
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronMLMHSparseAttentionOCL;
   prev_count=descr.count = prev_count*class="num">4;
   descr.window = prev_wout;
   descr.step = class="num">4;
   descr.window_out = class="num">32;
   descr.layers = class="num">8;
   descr.probability = Sparse;
   descr.optimization = ADAM;
   if(!rtg.Add(descr))
      {
       class="kw">delete descr;
       class="kw">return false;
      }

常见问题

因为随机噪声被混进了可控信号里,权重更新被干扰。先用控制二分法把环境和自身可控部分拆开,再喂给网络。
它把状态分成两类:一类由智能体动作直接决定,一类由外部环境随机生成,分别建模后再合并决策,避免乱调权重。
小布可读取品种波动结构,标注随机占比高的时段,提醒你哪些行情先别让智能体硬学,先切分可控部分。
会,若不分清可控层,回报噪声会回传污染策略。按文中逐层装配,先独立训练再叠加更稳。
可以拿文内结构手画信号流,先用历史K线标出可控动作点,看随机段是否真的绕开了权重更新。