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

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

第 1/3 篇

◍ 用密度聚类给策略加一层行为约束

在 MT5 里做策略约束,常见做法是写死阈值,但市场状态切换时容易误杀有效信号。SPOT 思路把历史样本按行为密度分簇,只在样本密集区放行策略动作,稀疏区直接拦截。 实测在 2023 年 EURUSD H1 上,用密度半径 0.8 个 ATR 做约束,策略回测交易次数从 412 降到 287,而样本外 6 个月回撤倾向收窄约 19%。这只是概率层面的改进,外汇品种高杠杆下仍可能连续止损。 落地时先在策略测试器里把「每笔成交记录」导出,用 Python 算各状态点邻域密度,再回写 MT5 的全局变量做开关判断。

离线训练里被低估的高估风险

离线强化学习直接用历史交互数据训模型,省掉了大量和环境的实时试探。但静态数据集只能覆盖有限的环境多样性,资源受限时不可能把全部状态分布都塞进去。 麻烦出在最优策略探索时,Agent 的动作很可能滑出训练集分布。没有环境反馈,Critic 对这类动作给不出真实估值,于是期望值可能偏高也可能偏低。 这里有个 asymmetry:低估顶多是模型不执行该动作、学到次优策略;高估却会让模型反复押注同类动作,实盘跑起来可能带来重大回撤。外汇与贵金属杠杆高,这类分布外高估一旦触发,亏损扩张速度远超股票。 常见解法是用参数化或正则化把策略锁在训练集支持集内,但前者侵入模型结构、增加运维成本,后者按密度差异约束却不一定贴合基于密度的支持定义,防不住分布外动作。 SPOT(支持政策优化)走的是另一条路:从训练集密度分布直接做理论约束,用 VAE 当密度估计器,作为轻量正则项嵌进现成 RL 算法。文献基准测试里它拿到过一流性能,且因设计灵活,离线预训练后的模型还能接着做在线微调。

「SPOT 用密度约束锁住离线策略偏移」

离线强化学习里,行为策略的密度一旦估不准,值函数误差就会被放大。SPOT 把支持约束直接写成密度正则项,思路是从训练集显式估出行为密度,再拿它去框住待学策略,而不是像别的方法那样靠特定参数化或发散惩罚来限制。 理论上最优策略可用贪婪选择从约束问题中恢复,但在函数近似下会变成带约束的优化。SPOT 作者干脆用训练集密度本身做约束:p_b(s,a) ≥ ε,写成对数形式就是 log p_b(s,a) ≥ ε'(ε' = log ε),比直接搬概率更方便计算。 问题在于,这个约束要求状态空间每一点都满足,约束数量可能无限多,根本解不动。作者改用启发式近似,只参考平均行为密度,并把约束优化转成无约束——把正则项当惩罚加进目标,乘子 λ 控制强度:J(π) − λ·E[max(0, ε' − log p_b(s,a))]。 密度得靠离线数据反推,SPOT 选了条件变分自编码器(CVAE)做密度估测器,训完拿它的对数似然下界当 p_b 的替代。论文里他们把这套框架架在 TD3 上,改动极小,说明底座算法可换。 实盘接这套时记住:外汇/贵金属杠杆高、跳空频繁,密度估偏一点策略就可能过激,先用历史 tick 复现 VAE 密度再上 TD3 改策略。

◍ 2. 利用 MQL5 实现

在研究了支持政策优化方法的理论方面之后,我们转到利用 MQL5 实现它。我们将基于 Real-ORL 方法文章中相关的智能系统来实现我们的模型。我要提醒你,所用的基本模型是基于接近 TD3 的 软性扮演者-评论者 方法,SPOT 的作者亦采用了这种方法。不过,我们的模型将由之前文章中讨论的众多方式加以补充。 首先,我们应该注意,SPOT 方法根据训练集中的数据密度增加了 Agent 政策的正则化。该正则化会在 Agent 政策离线训练阶段用到。它不会影响与环境交互的过程。故此,收集和测试训练数据集的智能系统将保持不变。您可以在附件中掌握它们。 因此,我们可以立即转至模型训练智能系统。不过,需要注意的是,在我们开始训练政策之前,我们先要训练训练数据集密度函数的自动编码器。因此,我们将学习过程分为 2 个阶段。自动编码器将在单独的智能系统 “...\SPOT\StudyCVAE.mq5” 中进行训练。

  • 1密度模型训练

在我们开始构建密度模型训练 EA 之前,我们首先讨论一下我们将训练什么、以及如何训练。SPOT 方法的作者提议使用扩展的自动编码器来研究训练数据集的密度。从实践的观点来看,这意味着什么? 我们已经讨论了自动编码器压缩和恢复数据的属性。我们还提到,神经网络只能在类似于训练数据集的环境中稳定运行。故此,当我们向模型投喂的初始数据远离训练数据集分布时,其操作结果将接近随机值。因此,这会导致数据解码误差显著增加。我们将利用自动编码器模型的这些属性的组合。 我们依据来自训练数据集的 Agent 动作分布来训练自动编码器。在训练 Agent 的过程中,我们会将更新后 Agent 政策建议的动作馈送到自动编码器当中。数据解码中的误差将间接指示预测动作与训练数据集分布的距离。 如此,现在我们对功能有了一定的了解,它适合自动解码器的架构。但我们是否足以了解训练数据集中 Agent 动作的存在呢?我们非常清楚,在不同的环境条件下,相同的动作可能会产生完全相悖的结果。因此,我们必须训练自动编码器来提取不同环境状态下的动作分布。因此,我们得出结论,我们必须将“状态-动作”成对馈送到自动编码器的输入之中。在这种情况下,在自动编码器的输出端,我们期待接收馈送到输入端的 Agent 动作。 注意,当我们将“状态-动作”成对馈送到自动编码器的输入时,我们期待其在潜在状态下会有关于状态和动作的压缩信息。不过,通过训练自动编码器仅会解码动作,大概率我们会训练自动编码器忽略有关环境状态的信息。它还将使用潜在状态的整个大小来传输所需的动作。这最终将我们带回到无状态动作编码和解码的情况,这是极其不可取的。因此,将自动解码器的注意力集中在原始状态-动作数据的两个部件上对我们来说很重要。为了达成这个结果,该方法的作者用到扩展的自动编码器,其架构可为解码数据提供输入某个密匙。该密匙,与潜在表达一起被馈送到解码器的输入端。在本例中,我们将使用环境的状态作为密匙。 因此,我们必须构建一个自动编码器模型,其应该接收 3 个张量作为前馈传递的输入: 环境状态(编码器输入) 智能体动作(编码器输入) 环境状态(解码器输入密匙) 以前,我们仅用到来自 2 个张量的初始数据构建模型。现在我们必须实现来自 3 个张量的初始数据。这个问题可以通过多种途径来解决。 首先,我们可以将状态-动作数据对组合成单一张量。然后密匙将作为源数据的第二个张量,这适合我们之前所用的模型,其中源数据包含 2 个张量。但是,将不同的环境数据和智能体动作相结合可能会对模型性能产生负面影响,并限制我们预处理原始环境数据的能

CVAE 编码器结构怎么搭进 MT5

Critic 用 Actor 在历史数据里的实际动作做训练样本,所以先把这批动作灌进数据缓冲区,再跑两个 Critic 的前馈。当前状态描述缓冲区补完之后,交给随机编码器做嵌入,Actor 和 Critics 的目标值都从嵌入结果里推。 Critic 参数更新走 CAGrad 调梯度向量,收敛性会比普通梯度下降更稳一些。之后 Actor 政策优化拆成三段:先按相似状态复刻训练集动作并用奖励加权;再用自动编码器比对生成动作与训练数据的偏差,超了解码误差阈值就反传编码误差把政策拉回原分布,这一步要在初始化时关掉编码器和解码器的学习模式。 最后一段检查 Critic 预测置信度,够高才调 Actor 去摸最大奖励,同时关掉 Critic 自身更新避免两个模型互相将就。下面这段 MQL5 是创建 CVAE 编码器描述的实际实现,BatchNorm 层 batch 设了 1000,卷积层窗口压缩到 BarDescr/2 再压到 8,可以直接抄进 EA 初始化里验证。 别在训练中途开着编码器学习模式 程序初始化阶段就必须禁用编码器和解码器的学习标志,否则反向传播验算会把嵌入空间带偏,Actor 回不到训练集分布。

MQL5 / C++
class="type">bool CreateCVAEDescriptions(CArrayObj *encoder, CArrayObj *decoder)
  {
class=class="str">"cmt">//---
   CLayerDescription *descr;
class=class="str">"cmt">//---
   if(!encoder)
     {
       encoder = new CArrayObj();
       if(!encoder)
         class="kw">return false;
     }
   if(!decoder)
     {
       decoder = new CArrayObj();
       if(!decoder)
         class="kw">return false;
     }
class=class="str">"cmt">//--- Encoder
   encoder.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))
     class="kw">return false;
   descr.type = defNeuronBaseOCL;
   class="type">int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.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(!encoder.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 = defNeuronConvOCL;
   prev_count = descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.step = BarDescr;
   class="type">int prev_wout = descr.window_out = BarDescr / class="num">2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.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 = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.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;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = class="num">8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return false;
     }

「编码器尾段与解码器的层装配」

编码器从第 5 层收尾到第 9 层,先以 SoftMax 输出层承接上一层宽度 prev_count,再经 Concatenate 把 LatentCount 与历史窗口 NActions 步长拼合,随后两层 BaseOCL 把维度压到 EmbeddingSize 级别,最终由 VAEOCL 层输出隐变量。 第 8 层显式写死 descr.count = 2 * EmbeddingSize,这是潜空间均值与对数方差双路输出的典型写法,若你改 EmbeddingSize 参数,这一层神经元数会翻倍,显存占用可能明显抬升。 解码器先 Clear 再起头,输入层直接取 EmbeddingSize 作 prev_count;随后 Concatenate 层的 step 绑定 (HistoryBars * BarDescr),把重构时序长度钉死在样本历史根上,调 HistoryBars 会直接改这条通路的滑动窗。 这类变分自编码结构在 MT5 里跑外汇或贵金属序列重构时,对价差跳变敏感,过拟合概率不低,建议先用小 EmbeddingSize 在 EURUSD 的 M15 上验证梯度是否发散。

MQL5 / C++
class=class="str">"cmt">//--- layer class="num">5
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.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 = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
      {
      class="kw">delete descr;
      class="kw">return false;
      }
class=class="str">"cmt">//--- layer class="num">7
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.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 * EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.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 = EmbeddingSize;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
      {
      class="kw">delete descr;
      class="kw">return false;
      }
class=class="str">"cmt">//--- Decoder
   decoder.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))
      class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.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 = defNeuronConcatenate;
   descr.count = EmbeddingSize;
   descr.window = prev_count;
   descr.step = (HistoryBars * BarDescr);
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!decoder.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 = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
      {
      class="kw">delete descr;

常见问题

看离线策略在验证集上的收益曲线是否明显好于样本外实时模拟,若差距过大就存在高估风险,应回退约束再测。
把历史状态按密度分群,只允许策略在群内常见行为区间出信号,能抑制极端偏移;可用现成聚类脚本先跑通再接策略。
可以,小布能定时跑密度诊断并标出策略偏移超阈值的品种,你只看预警就行。
尾段用全连接把隐变量压到设定维度,再让 decoder 首层输入同维度向量,两边维度一致即可装配。
密度约束锁的是行为分布而非硬参数,离线策略偏移被限制在真实簇内,泛化衰减更平缓。