神经网络变得简单(第 57 部分):随机边际扮演者-评论者(SMAC)(基础篇)
「用随机边际把 Actor-Critic 搬进 MT5」
强化学习里的 Actor-Critic 直接在行情序列上训,容易过拟合到噪声。SMAC(Stochastic Marginal Actor-Critic)的思路是:把状态边际做随机扰动,让评论者评估的是「扰动后边际分布下的期望回报」,而非单条轨迹,策略更新因此更稳。 在 MT5 里验证这一点,最便宜的做法是用 CTrade 跑一个随机扰动后的样本外窗口。下面这段代码演示如何对每个新柱用 MathRand 注入边际噪声,再交给策略判定: [CODE] #include <Trade/Trade.mqh> CTrade trade; //+------------------------------------------------------------------+ void OnTick() { static datetime last_bar=0; if(TimeCurrent()>=last_bar+iTime(Symbol(),PERIOD_CURRENT,0)) { last_bar=TimeCurrent(); double noise=(MathRand()%100)/100.0-0.5; // 注入 [-0.5,0.5] 边际噪声 double signal=iCustom(Symbol(),PERIOD_CURRENT,"SMAC_Filter",0,0)+noise; if(signal>0.3) trade.Buy(0.1); if(signal<-0.3) trade.Sell(0.1); } } [/CODE] 逐行看:MathRand()%100 取 0–99 整数,除以 100 减 0.5 得到对称噪声;iCustom 调用名为 SMAC_Filter 的指标,加噪声后阈值 0.3 才触发。外汇与贵金属杠杆高、滑点突变频繁,这类随机边际可能降低过拟合概率,但样本外仍可能连续回撤,需自行用 2023 年 XAUUSD 的 M15 数据跑 3 个月以上确认。
class="macro">#include <Trade/Trade.mqh> CTrade trade; class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class="kw">static class="type">class="kw">datetime last_bar=class="num">0; if(TimeCurrent()>=last_bar+iTime(Symbol(),PERIOD_CURRENT,class="num">0)) { last_bar=TimeCurrent(); class="type">class="kw">double noise=(MathRand()%class="num">100)/class="num">100.0-class="num">0.5; class=class="str">"cmt">// 注入 [-class="num">0.5,class="num">0.5] 边际噪声 class="type">class="kw">double signal=iCustom(Symbol(),PERIOD_CURRENT,"SMAC_Filter",class="num">0,class="num">0)+noise; if(signal>class="num">0.3) trade.Buy(class="num">0.1); if(signal<-class="num">0.3) trade.Sell(class="num">0.1); } }
◍ 用潜在变量给策略加随机性
做自动交易系统时,光靠最大熵让智能体随便试动作,实践里往往只能训出只会在一个动作附近抖动的弱策略——因为要算策略熵并塞进训练目标,复杂度直接卡死。 一种更省事的思路是往策略里塞潜在变量,让智能体自己推理模型里的随机性,输入来自观测、环境和拿不到的奖励信号。这样策略能覆盖更多场景,也兼容历史观测数据。 但坑在于:带潜在变量的策略没法用朴素公式直接算熵,瞎估熵会搞崩优化;而且高方差的熵最大化更新,分不清是局部随机抖动还是真的多模态探索。 《潜在状态边际化作为改进探索的低成本方法》给了出路:用低成本边际化潜在状态,在完全/部分可观测环境里都能更稳地探索。把这套随机估算接进 actor-critic,就得到了随机边际 actor-critic(SMAC),主要贡献是给了部分可观测下用潜在变量的动机、几个降方差估算法,以及 SMAC 本身。外汇与贵金属市场高杠杆高风险,这类算法在 MT5 上跑之前务必用历史 tick 数据先做离线回测。
SMAC 用潜变量给策略松绑
| 随机边际扮演者-评论者(SMAC)的核心改动,是在原有策略里塞进一个依赖观测 x_t 的潜变量 s_t。作者用分解高斯分布分别参数化 π(a_t | s_t) 与 q(s_t | x_t),只多一个随机节点,抽样和密度估算依旧便宜,却让策略表达能力明显提升,能覆盖更宽的最优动作集合——早期奖励信息匮乏时尤其好用。 |
|---|
潜变量带来麻烦:最大熵训练要对熵准确估值,但边际化难、梯度方差变大,朴素估算器会去最大化熵泛函的上限,逼着变分分布远离真实后验 q(s_t|a<t,x≤t),误差无界。原文实验里,优化期间熵估值爆出极端大数,严重高估真实熵,直接把策略训崩。 解法是用 K+1 次从 q 里抽 s_t,第一个 s_t⁰ 拿来选动作,其余 K 个只用于估边际熵下限;该估算器随 K 单调增,极限下无偏。SMAC 据此把边际熵下限最大化,算法骨架仍是经验回放 + actor-critic:评论者最小化带熵估 H̃ 的 TD 误差,扮演者用当前状态熵估更新,两者基本同朴素 SAC,改进主要来自潜变量带来的结构化探索。外汇/贵金属行情里套这类 RL 策略,过拟合与实盘漂移风险极高,参数务必在 MT5 历史Tick上先跑回放验证。
「在MT5里搭SMAC的演员-评论者骨架」
把论文里的随机边际扮演者-评论者(SMAC)落到 MQL5,重点不是复刻原版,而是把它塞进前文讨论过的 NNM 算法架构。核心改动只有一处:扮演者(Actor)的数据预处理模块(编码器)输出端要加一个随机节点,用来生成潜在状态分布。 原 SMAC 拆成三个模型——q(潜在状态)、π(扮演者)、Q(评论者)。我们跳过独立 q 模型,把编码器留在扮演者内部。CreateDescriptions 方法里,串联层大小被设成潜在表象的两倍,因为要同时输出均值和方差,紧接其后才是变分自编码器的潜在状态层。评论者架构基本不动,只是把熵分量作为奖励函数分解后的新增元素,奖励元素数量常数 +1。 模型间数据传递靠 LatentLayer 常量里的层 ID。扮演者改了架构,评论者要重定向到新的神经层,只改这个常量数值即可,不用动其它代码。这样一来,开 MT5 把附件 EA 加载进策略测试器,先核对 LatentLayer 指向的层数,就能验证重定向是否生效。 潜在状态熵本来用 K+1 编码器样本估算,我们在 NNM 里改用核范数(nuclear norm)。一段 Python 小脚本验证了:均值恒定、方差从 1 折减到趋近 0 时,核范数算出的内部奖励同步下降,行为和原熵估计一致。这意味着可以直接用矩阵奇异值替代采样熵,省掉重复前向验算。 MQL5 实现上,SamplLatentStates 常量控制抽样数。一次扮演者前向验算后,用重参数化技巧从训练好的倒数第二层(均值+方差缓冲区)抽出所需隐藏状态:建零填充大矩阵、首行写分布参数、列累积求和把首行复制到余行,再垂直拆成均值/方差两矩阵,乘标准正态分布即得样本。底部补当前编码器值供评论者使用。 训练循环里,经验回放先经随机卷积编码器编码,删冗余行;按 Iterations 采样轨迹,评论者前向加 SMAC 熵估值,扮演者用 CAGrad 优化、ForecastAccount 预测账户态。KNNReward 方法单独算奖励:求预测态与缓冲区样本的 k-近邻,按距离反比调权,末两位填核范数熵与潜在状态熵。整套改完,EA 训练日志会打印进度,参数由 Tau 软更新。
<span class="keyword">class="type">bool</span> CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *convolution) { <span class="comment">class=class="str">"cmt">//--- </span> CLayerDescription *descr; <span class="comment">class=class="str">"cmt">//--- </span> <span class="keyword">if</span>(!actor) { actor = <span class="keyword">new</span> CArrayObj(); <span class="keyword">if</span>(!actor) <span class="keyword">class="kw">return</span> <span class="macro">class="kw">false</span>; } <span class="keyword">if</span>(!critic) { critic = <span class="keyword">new</span> CArrayObj();
◍ Actor 网络的逐层组装逻辑
在 MT5 的 OpenCL 神经网络框架里,Actor 的搭建从两个防御性判空开始:若 critic 或 convolution 容器未就绪就直接 new,失败则返回 false,避免后续空指针崩在 GPU 内核里。 输入层用 defNeuronBaseOCL,神经元数 = HistoryBars * BarDescr,激活函数设 None、优化器 ADAM,相当于把原始 K 线窗口原样送进网络。 第一层接 BatchNorm(defNeuronBatchNormOCL),batch 写死 1000,对输入做归一化,能让外汇小时线这类量纲差异大的特征训练更稳定。 第二层开始卷:defNeuronConvOCL,窗口 = HistoryBars、步长 = HistoryBars,输出窗口压缩到 HistoryBars/2,激活 LReLU;第三层再卷一次,窗口接上层输出、最终 window_out 锁在 8,这是把长序列压成短向量的关键。 第四、五层回到全连接 BaseOCL,维度都收在 LatentCount,LReLU 激活;第六层用 Concatenate 把两倍 LatentCount 拼上账户状态(step=AccountDescr),最后 SIGMOID 输出动作概率。整套层数与维度若改 HistoryBars 或 LatentCount,需同步改窗口推导,否则编译能过、推理会偏。
if(!critic) class="kw">return class="kw">false; } if(!convolution) { convolution = new CArrayObj(); if(!convolution) class="kw">return class="kw">false; } class=class="str">"cmt">//--- Actor actor.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; class="type">int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = class="num">1000; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronConvOCL; prev_count = descr.count = BarDescr; descr.window = HistoryBars; descr.step = HistoryBars; class="type">int prev_wout = descr.window_out = HistoryBars / class="num">2; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">3 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; descr.window_out = class="num">8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">5 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">6 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronConcatenate; descr.count = class="num">2 * LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID;
Actor 与 Critic 的尾段网络拼装
上面这段把 actor 网络从第 7 层一路堆到第 11 层,紧接着又初始化了 critic 的输入层与第 1、2 层。注意第 10 层输出节点数写死为 2 * NActions,激活用 SIGMOID,这意味着策略头同时产出动作均值与方差两路,做连续动作空间的概率建模。 actor 第 7、11 层都是 defNeuronVAEOCL 类型,中间 8、9 层是带 LReLU 的 defNeuronBaseOCL,隐层宽度统一等于 LatentCount。每一层 new 出来若 Add 失败就立刻 delete 并 return false,这种写法能避免 MT5 终端里神经网络对象泄漏导致的内存堆积。 critic 这边先 Clear 再起头:输入层节点数 = LatentCount、激活 None;第 1 层用 defNeuronConcatenate 把状态潜变量与动作拼接,window = prev_count、step = NActions,说明它按动作维度滑动拼接,而非简单 stack。 NRewards 宏定为 5,代表奖励信号用了 5 路回报估计,在外汇或贵金属这种高杠杆品种上,多路奖励容易放大过拟合,回测漂亮不等于实盘能跑,杠杆风险得自己扛。
if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">7 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronVAEOCL; descr.count = LatentCount; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">8 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">9 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">10 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; descr.count = class="num">2 * NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">11 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class="macro">#define NRewards class="num">5 class=class="str">"cmt">//Number of rewards class=class="str">"cmt">//--- Critic critic.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return class="kw">false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = NActions; descr.optimization = ADAM; descr.activation = LReLU; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription()))