神经网络变得轻松(第四十八部分):降低 Q-函数高估的方法(基础篇)
📘

神经网络变得轻松(第四十八部分):降低 Q-函数高估的方法(基础篇)

第 1/3 篇

给 Q 函数高估做减法

在离轨强化学习里,Q 函数常被高估,原因来自自举(bootstrap)与目标网络之间的偏差累积。Dmitriy Gizlyk 在 2024-02-23 发布的 MT5 文章中指出,这种高估会让策略在外汇与贵金属这种高杠杆、高噪声环境里过早锁仓,回测漂亮、实盘拉胯。 一种直接做法是双 Q 学习:用两套参数网络交替提供目标值,使最大值算子不再总落在同一套过乐观估计上。在 MT5 里可以借由独立初始化的两个神经网络对象实现,每次更新只让其中一个网络参与目标计算。 外汇与贵金属波动受事件驱动明显,这种方法只能降低高估概率,不保证策略盈利。建议开 MT5 用下方结构先跑 EURUSD 的 M15 历史,观察 reward 曲线是否更平稳。

「从 DDPG 到更稳的 Critic 训练」

上篇里我们跑通的 DDPG 代理已经不只是猜涨跌方向,它能直接给出开仓规模、止损与止盈价位,把仓位和风控一起学了。但 DDPG 沿用了 Q-learning 一系的毛病:Critic 对 Q 值的估计会偏高,训练误差一层层叠上去,代理最后容易落到次优策略。 具体机制是,Critic 靠和环境交互回来的奖励学 Q 函数,Actor 只盯着 Critic 给的动作估值去最大化期望奖励。所以 Critic 的拟合质量直接决定 Actor 实际决策水平——估值飘了,仓位和止损再漂亮也是建在沙子上。 外汇和贵金属这种高杠杆连续行情里,Q 值高估会被放大成实盘回撤。开 MT5 接自己行情回测时,建议先单独盯 Critic 的 TD-error 曲线,别等 Agent 下单才发现问题。

◍ 用 TD3 给 Q 值高估降温

在离散动作模型套到连续动作空间求解时,DQN 一脉常出现 Q-函数值被高估的现象,训练稳定性直接受损。2018 年 2 月发表的「解决扮演者—评论者方法中的函数逼近误差」给出了一条落地路径:孪生延迟深度判定性策略梯度(TD3),它本质是 DDPG 的延续并做了三处训练安排上的改动。 TD3 首先塞进第二个评论者,两者用随机参数分别初始化、基于同一批数据并行训练。由于起点不同,它们对 Q 值的偏离方向不会完全同步——某一刻一个高估、另一个可能低估,即便都高估,误差幅度也常有大小差。作者据此用两者预测的最小值去更新,把高估的累积误差压到最低。 第二个改动是延迟更新扮演者。神经网络本就是迭代降误差的过程,扮演者输出会进评论者训练集;扮演者少动几次,评论者样本的随机性就降下来,估值更稳,反过来扮演者吃到更准的评论者信号,也少做无用更新。 第三个改动是在目标动作里加轻微噪声做平滑,假设相近动作应得相近奖励。这给评论者学习引入了正则化,把导致 Q 值高估的尖峰磨平。三者都只动训练流程,不碰模型架构,开 MT5 接 RL 框架时可直接照此排程验证。

用 MQL5 把 TD3 拆成三个 EA 跑通

这一节把 TD3 算法落到 MT5 实盘框架里,只用了原方法 3 个添加项里的 2 个——因为金融市场的随机性,目标函数没加平滑项,训练集里几乎不可能出现两个完全相同的状态。整个流程拆成研究、学习、测试三个 EA:Research 收集样本、Study 训练模型、Test 检查产出,同时交易算法和信号解释都改了。 交易端先砍掉“开仓即忘”的无限开仓逻辑,改成只维护一笔持仓,允许加仓和部分平仓。部分平仓用自建的 ClosePartial 函数按 FIFO 平掉多笔加仓,止损只在交易方向尾随、止盈可双向移动,避免逆势平移止损放大风险。Actor 输出端激活函数从 LReLU 换成 sigmoid(0~1),交易水平靠两个常量乘输出值换算成点数,外汇和贵金属杠杆高、滑点跳空频繁,这种改法在实盘里要先在策略测试器跑一遍验证。 Research EA 的 OnTick 里只调 Actor 生成动作,无预训练模型时随机初始化不加噪,加载预训练后加噪探索。输出向量按最小交易量、步长、最大缩进常量解密,不满足条件就平掉该方向持仓;持仓量大于建议就调 ClosePartial,小于就补开。每根 K 线把账户相对余额变化当奖励写进轨迹数组。 Study EA 排了 TD3 全套:6 个神经网络实例(Actor、2 Critic、3 目标),外部变量控迭代次数、UpdatePolicy(Critic 更新几次才动一次 Actor)、目标模型软更新频率。训练时目标值取两 Critic 最小值乘折扣系数加实际奖励,Critic 用 Actor 隐含状态当输入,Actor 低频更新且用 Critic1 梯度,负评价时加正常数加速脱离。Test EA 基本复用 Research,只去掉噪声、保留轨迹记录方便纠错。 下面这段是 ClosePartial 的入口判断,value 不对就直接终止,避免脏数据触发平仓。

MQL5 / C++
class="type">bool ClosePartial(class="type">ENUM_POSITION_TYPE type, class="type">class="kw">double value)
  {
   if(value <= class="num">0)

「平仓与追踪止损的底层循环」

这段逻辑干两件事:按指定手数回收同品种同方向的持仓,以及按传入的 SL/TP 对持仓做移动保护。外汇与贵金属杠杆高,部分平仓和追踪改单若触发频繁,点差与佣金会吃掉波段利润,回测时建议把成交成本写进模拟环境。 平仓循环里用 PositionsTotal() 做边界且每次成功平掉一单就把 i 自减,避免索引错位漏单;当持仓量小于等于待平量时整单平掉并扣减 value,否则走 PositionClosePartial 只削一部分。 TrailPosition 则先按品种与方向过滤,买权只在新 SL 比旧 SL 高至少一个 Point 才改,卖权反过来要求旧 SL 减新 SL 不小于一个点,保证不会朝亏损方向乱挪止损。 让小布替你跑这套 把 sl/tp 参数接上你自己的 ATR 通道输出,MT5 策略测试器里用 2023 年 XAUUSD 的 M15 跑一遍,看改单次数和净值曲线是否匹配你的风控阈值。

MQL5 / C++
  class="kw">return true;
class=class="str">"cmt">//---
   for(class="type">int i = class="num">0; (i < PositionsTotal() && value > class="num">0); i++)
     {
       if(PositionGetSymbol(i) != Symb.Name())
         class="kw">continue;
       if(PositionGetInteger(POSITION_TYPE) != type)
         class="kw">continue;
       class="type">class="kw">double pvalue = PositionGetDouble(POSITION_VOLUME);
       if(pvalue <= value)
         {
          if(Trade.PositionClose(PositionGetInteger(POSITION_TICKET)))
            {
             value -= pvalue;
             i--;
            }
         }
       else
         {
          if(Trade.PositionClosePartial(PositionGetInteger(POSITION_TICKET), value))
             value = class="num">0;
         }
     }
class=class="str">"cmt">//---
   class="kw">return (value <= class="num">0);
   }
class="type">bool TrailPosition(class="type">ENUM_POSITION_TYPE type, class="type">class="kw">double sl, class="type">class="kw">double tp)
   {
   class="type">int total = PositionsTotal();
   class="type">bool result = true;
class=class="str">"cmt">//---
   for(class="type">int i = class="num">0; i <total; i++)
     {
       if(PositionGetSymbol(i) != Symb.Name())
         class="kw">continue;
       if(PositionGetInteger(POSITION_TYPE) != type)
         class="kw">continue;
       class="type">bool modify = false;
       class="type">class="kw">double psl = PositionGetDouble(POSITION_SL);
       class="type">class="kw">double ptp = PositionGetDouble(POSITION_TP);
       class="kw">switch(type)
         {
          case POSITION_TYPE_BUY:
             if((sl - psl) >= Symb.Point())
               {
                psl = sl;
                modify = true;
               }
             if(MathAbs(tp - ptp) >= Symb.Point())
               {
                ptp = tp;
                modify = true;
               }
             break;
          case POSITION_TYPE_SELL:
             if((psl - sl) >= Symb.Point())
               {
                psl = sl;
                modify = true;

◍ 把多指标状态塞进训练向量的收口写法

这段片段落在持仓修改判定之后,用宏把止损止盈上限钉死在 1000 点:MaxSL 与 MaxTP 均定义为 1000,意味着任何超过该幅度的挂单修改请求会被逻辑层直接拦在外部,外汇与贵金属杠杆品种里这是防止极端跳空把账户打穿的一道硬栅栏,但市场 gaps 仍可能让实际滑点超出预期,属高风险操作。 OnTick 里先判 IsNewBar 才跑后续,避免每笔报价都重算;CopyRates 拉取 HistoryBars 根 K 线后立刻 ArraySetAsSeries 反转序列,保证 b=0 对应最新柱。RSI、CCI、ATR、MACD 四个指标和 Symb 行情对象依次 Refresh,少刷一个就可能读到上一 tick 的缓存值。 特征拼装循环里,每根 bar 向 State 追加 13 个浮点字段:收盘减开盘、最高减开盘、最低减开盘三档位移,成交量除以 1000,以及小时 / 星期 / 月份三个时间分量,再叠齐四个指标主线值与 MACD 信号线。任何指标返回 EMPTY_VALUE 就 continue 跳过,防止脏数据进向量。 最后把 ACCOUNT_BALANCE 与 ACCOUNT_EQUITY 写进 sState.account 头两位,让模型输入含实时权益背景。整套拼完即 return,没有多余动作——你开 MT5 把 HistoryBars 调到 500 跑一遍,能直接看到 State 大小涨到 500×13+2。

MQL5 / C++
   }
   if(modify)
      result = (Trade.PositionModify(PositionGetInteger(POSITION_TICKET), psl, ptp) && result);
   }
class=class="str">"cmt">//---
   class="kw">return result;
   }
class="macro">#define                MaxSL          class="num">1000
class="macro">#define                MaxTP          class="num">1000
class="type">void OnTick()
  {
class=class="str">"cmt">//---
   if(!IsNewBar())
      class="kw">return;
class=class="str">"cmt">//---
   class="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      class="kw">return;
class=class="str">"cmt">//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();
   class="type">MqlDateTime sTime;
   class="type">float atr = class="num">0;
   State.Clear();
   for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++)
     {
      class="type">float open = (class="type">float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      class="type">float rsi = (class="type">float)RSI.Main(b);
      class="type">float cci = (class="type">float)CCI.Main(b);
      atr = (class="type">float)ATR.Main(b);
      class="type">float macd = (class="type">float)MACD.Main(b);
      class="type">float sign = (class="type">float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         class="kw">continue;
      class=class="str">"cmt">//---
      State.Add((class="type">float)Rates[b].close - open);
      State.Add((class="type">float)Rates[b].high - open);
      State.Add((class="type">float)Rates[b].low - open);
      State.Add((class="type">float)Rates[b].tick_volume / class="num">1000.0f);
      State.Add((class="type">float)sTime.hour);
      State.Add((class="type">float)sTime.day_of_week);
      State.Add((class="type">float)sTime.mon);
      State.Add(rsi);
      State.Add(cci);
      State.Add(atr);
      State.Add(macd);
      State.Add(sign);
     }
   sState.account[class="num">0] = (class="type">float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[class="num">1] = (class="type">float)AccountInfoDouble(ACCOUNT_EQUITY);
class=class="str">"cmt">//---

常见问题

这是 Q 函数最大化操作带来的固有高估,DDPG 单 Critic 尤其明显。可改用 TD3 的双 Critic 取小结构,让估值倾向保守。
核心是把单个 Critic 拆成两个独立网络,目标值用两者较小值;策略更新频率要低于 Critic,并加目标策略平滑噪声。
可以。小布会读取你的训练曲线与回测权益,对比 Critic 估值和真实奖励差,偏高时提示你切到 TD3 或调低学习率。
会。若平仓在环境外硬触发,状态向量要同步复位,否则 Critic 学到错误转移。建议把平仓作为环境动作一部分写进循环。
多是各指标量纲和周期没统一,噪声淹没信号。先做归一化收口,只留与持仓相关的少数派生量,再喂给网络。