神经网络变得简单(第 66 部分):离线学习中的探索问题(基础篇)
📘

神经网络变得简单(第 66 部分):离线学习中的探索问题(基础篇)

第 1/3 篇

离线强化学习里的探索困境

在把强化学习搬进 MT5 做离线训练时,最棘手的是探索与利用的失衡。智能体只能从既有历史 tick 与成交序列里回放,无法像在线环境那样实时试错,于是那些训练集没覆盖到的状态动作对,价值估计会系统性偏低。 以 MQL5 里常见的经验回放缓冲为例,若缓冲区只装了 1.2 万根 EURUSD H1 棒线,其中仅有约 3.7% 的样本出现在波动率突增时段,agent 在这类状态下的 Q 值更新几乎靠外推,偏差可能被放大数倍。 这种离线设定下,盲目加噪声做探索反而容易让策略在贵金属与外汇这类高杠杆品种上过拟合到极端行情,实盘触发时亏损概率明显抬升。更稳的做法是先做状态覆盖度诊断,再决定要不要引入保守的约束策略。

「离线强化学习的数据瓶颈」

在离线强化学习里,环境信息被训练集大小死死卡住。多数训练数据只在任务的一个窄子空间内采集,导致智能体对环境模式的认知先天残缺,而找最优解偏偏需要尽可能全的环境覆盖。 之前在线学习里好用的探索策略,搬到离线往往失灵——因为离线没有实时环境可探,只能靠已有数据硬啃。更麻烦的是,训练后代理的决策常会越出训练子集,越界后的结果基本不可预测,所以才有先把初步模型跑出的轨迹回灌进数据集、再调学习的做法。 在线训环境模型能缓解这个问题,但现实中常因成本过高或根本不可行而放弃;扩训练集又受资源和调研成本限制。ExORL 论文《不要改变算法,要改变数据》给出的结论很硬:离线 RL 里,数据收集方式对最终结果的影响量级,和选什么算法、用什么模型结构基本相当。 对外汇、贵金属这类高波动市场的量化尝试而言,这套结论提醒我们:历史样本窄、分布偏,策略再花哨也可能过拟合,先审视数据覆盖比调参更划算。

◍ ExORL 把力气花在采数而非新算法

ExORL 这套离线强化学习思路,作者压根没提出新学习算法或模型结构,核心赌注在「训练数据怎么采」。他们用五种学习方法做对照,专门评估数据集内容对最终策略的影响,等于把变量锁死在数据侧。 整个流程拆成三段:先靠无监督算法收集未标记探索数据,每个 episode 用基于历史的交互策略 π 跑完,落盘成 (St, At, St+1) 序列,直到数据集被硬件或资源上限填满;接着用给定或训练的奖励函数给元组重新打标,甚至支持反向 RL;最后拿离线 RL 算法在标注集上随机抽元组训练,完全不碰线上环境,训完才进真实环境测。 采数阶段他们实测了 9 种无监督算法:随机基线、ICM/分歧/RND 这类最大化预测误差的、APT/Proto-RL 这类估状态覆盖的、以及 DIAYN/SMM/APS 这类学多技能的。论文可视化里也确认了这 9 类都在实验矩阵内。 作者在五类离线 RL 算法上出了结果:行为克隆打底,三种带防外推机制的离线算法,加经典 TD3 当在线改离线的基线。结论很直白——换探索性数据后,以前靠防外推保命的复杂算法优势没了,TD3 在 ExORL 无标记数据上反而更稳;理想情况是算法能自动适配数据集,把两类好处都吃上。

用 MQL5 搭 ExORL 探索框架的落地细节

ExORL 离线强化学习思路在 MT5 里落地时,核心矛盾是:DWSL 原本按优势权衡某状态 S 下的行动,而 ExORL 要求智能体行为最大多样化。我的做法是让 Encoder 额外吃 Actor 行动向量,把源数据层撑大,状态嵌入改为「按行动定位」,从而用最近状态-行动对的距离当内部奖励,逼智能体去探没去过的子空间。基础 DWSL 结构不动,改动集中在 Trajectory.mqh 的 CreateDescriptions 与 ResearchExORL.mq5。 ResearchExORL.mq5 这个 EA 只干一件事:填训练集。外部参数沿用基础 EA 跟环境交互;OnInit 里初始化指标、加载预训练模型(Critic 可单独加载,Actor/Encoder 缺失就用随机权重),并把所有模型塞进同一个 OpenCL 上下文同步跑,减少主存与显存间拷贝。为压低探索成本,只留 1 个 Critic 且禁用其训练模式——所有并行探索 Actor 共用它,保证训练条件一致。 并行靠策略测试器优化参数「代理编号」,文件名后缀 "Ex"+编号。每个探索 Actor 随机初始化后跑同一段历史,却因各自学习路径不同扩出不同子空间;全局变量标记已加载轨迹及其嵌入矩阵,避免重复。CreateEmbeddings 方法无参返回状态嵌入矩阵:加载轨迹库失败就返空,成功则双层循环把历史价、指标、账户态、未结头寸、谐波时间向量、Actor 行动拼成张量送 Encoder.feedForward,嵌完缩矩阵清轨迹缓冲。 内部奖励由 ResearchReward 给:传入最近状态-行动数量、分析态嵌入、嵌入矩阵,用 DWSL 作者的 LogSumExp 算距离软估计,核规范算法生成奖励,外在奖励元素恒为 0——这符合 ExORL 无控探索设定。OnTick 每根烛形跑一次反向传播,不经验验回放缓冲;先建环境态缓冲、加账户态与时间谐波、Actor.feedForward 得行动,分别测多空交互结果入轨迹,此时奖励向量外在填值内部为 0(留待第二阶段重估)。 学探索 Actor 时顺序很刁:先给当前态补账户态与行动送 Encoder 得嵌入;首次加载历史轨迹库编码(无论成败都置加载标志);矩阵空就只加当前嵌入等下一根,有数据才调 ResearchReward 生成内部奖励,「之后」才把当前嵌入追加进矩阵——若提前加会双重计算失真。奖励送出后连调 Critic 前馈+反向传播(因不训 Critic 只传梯度)再跑 Actor 反向传播。 Train 方法改动不大但关键:编码历史态时行动位填零向量,消去已完成操作对嵌入的影响,退回上篇 DWSL 实现;奖励矩阵仍填行动优势值。训练循环采样轨迹后,若需回合末奖励则填后续态缓冲,由 2 个目标 Critic 取低值预期奖励加熵,Critic 用实际交互行动训,Actor 前馈只用来形潜在态。外汇/贵金属历史回测属高风险,并行探索结论仅表征可能倾向,实盘前务必 MT5 策略测试器复跑。

「训练循环里的数据流向与模型更新次序」

把训练集里的实际操作搬进数据缓冲区后,先对 Critics 做前馈传递;随后把账户状态和一个零向量拼到环境状态描述后面,用来顶替 Actor 原本的动作,再据此生成环境分析的嵌入向量。这套嵌入正是 Critics 反向传播所需目标结构的来源。 由于误差梯度要用 CAGrad 做向量修正,模型只能按顺序逐个训练,不能并行。Actor 基础策略先用 DWSL 算法反复试动作,并按该动作对最终结果的贡献加权;只有当 Critics 对动作的评价置信度足够高时,才进入第二阶段——把 Actor 输出往回报增大的方向推。 一轮迭代结束会同步目标模型参数,并向用户推送学习进度,接着进入下一轮。整个循环跑完后清掉图表注释,把结果写进日志并终止 EA。 附件代码里 CreateDescriptions 负责搭建网络描述:卷积层输入维度 = HistoryBars*BarDescr + AccountDescr + NActions,优化器用 ADAM;外部输入参数中 RSIPeriod 与 CCIPeriod 默认都是 14,TimeFrame 默认 PERIOD_H1,MinProfit 默认 10。开 MT5 把这段挂到 EA 里,调一下 RSIPeriod 就能看嵌入维度变化。

MQL5 / C++
class="type">bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic, CArrayObj *convolution)
  {
class=class="str">"cmt">//---
   CLayerDescription *descr;
class=class="str">"cmt">//---
   if(!actor)
     {
       actor = new CArrayObj();
       if(!actor)
         class="kw">return class="kw">false;
     }
   if(!critic)
     {
       critic = new CArrayObj();
       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
........
........
class=class="str">"cmt">//--- Critic
........
........
class=class="str">"cmt">//--- Convolution
   convolution.Clear();
class=class="str">"cmt">//--- Input layer
   if(!(descr = new CLayerDescription()))
      class="kw">return class="kw">false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars * BarDescr) + AccountDescr + NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//--- layer class="num">1
........
........
class=class="str">"cmt">//---
   class="kw">return true;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Input parameters                                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">input ENUM_TIMEFRAMES       TimeFrame   =  PERIOD_H1;
class="kw">input class="type">class="kw">double                MinProfit   =  class="num">10;
class=class="str">"cmt">//---
class="kw">input group                 "---- RSI ----"
class="kw">input class="type">int                   RSIPeriod   =  class="num">14;                class=class="str">"cmt">//Period
class="kw">input ENUM_APPLIED_PRICE    RSIPrice    =  PRICE_CLOSE;      class=class="str">"cmt">//Applied price
class=class="str">"cmt">//---
class="kw">input group                 "---- CCI ----"
class="kw">input class="type">int                   CCIPeriod   =  class="num">14;                class=class="str">"cmt">//Period
class="kw">input ENUM_APPLIED_PRICE    CCIPrice    =  PRICE_TYPICAL;    class=class="str">"cmt">//Applied price
class=class="str">"cmt">//---
class="kw">input group                 "---- ATR ----"

◍ 把指标和神经网络塞进初始化

EA 启动阶段先把 RSI、CCI、ATR、MACD 四个指标按传入周期建好,并统一把历史缓冲拉到 HistoryBars 长度;任一步骤失败就返回 INIT_FAILED,MT5 终端会在专家日志里直接报初始化错误。 输入参数里 ATRPeriod 默认 14,MACD 三参数为快 12 / 慢 26 / 信号 9,价格源用 PRICE_CLOSE;这些数字直接决定状态向量的尺度,调参后必须重新跑 Load 验证模型是否匹配。 Actor、Critic、Convolution 三个 CNet 实例在 OnInit 里通过 Load 读取 .nnw 权重文件,Agent 编号默认 1,文件名拼成「前缀Act1.nnw」;若本地没有对应模型,代码会现场 new 出描述数组尝试重建,外汇与贵金属品种波动率高,权重与实盘分布偏移可能导致推理失真。 开 MT5 把这段 input 块贴进自己的 EA 头部,改 FileName 和 Agent 跑一次 OnInit,看日志是否出现 BufferResize 或 Load 失败,就能确认环境是否齐活。

MQL5 / C++
class="kw">input class="type">int                 ATRPeriod   =  class="num">14;          class=class="str">"cmt">//Period
class=class="str">"cmt">//---
class="kw">input group                "---- MACD ----"
class="kw">input class="type">int                 FastPeriod  =  class="num">12;          class=class="str">"cmt">//Fast
class="kw">input class="type">int                 SlowPeriod  =  class="num">26;          class=class="str">"cmt">//Slow
class="kw">input class="type">int                 SignalPeriod =  class="num">9;          class=class="str">"cmt">//Signal
class="kw">input ENUM_APPLIED_PRICE   MACDPrice   =  PRICE_CLOSE;  class=class="str">"cmt">//Applied price
class="kw">input class="type">int                 Agent = class="num">1;
CNet                     Actor;
CNet                     Critic;
CNet                     Convolution;
class="type">bool                     BaseLoaded;
matrix<class="type">class="kw">float>            state_embeddings;
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit()
  {
class=class="str">"cmt">//---
   if(!Symb.Name(_Symbol))
      class="kw">return INIT_FAILED;
   Symb.Refresh();
class=class="str">"cmt">//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      class="kw">return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      class="kw">return INIT_FAILED;
     }
class=class="str">"cmt">//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//--- load models
   class="type">class="kw">float temp;
   if(!Actor.Load(StringFormat("%sAct%d.nnw", FileName, Agent), temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(actor, critic, critic))

常见问题

更可能是数据里缺少足够多样的行为轨迹,离线学习无法像在线那样主动试错,瓶颈常在采集覆盖度而非单纯数量。
先把行情指标算成固定长度向量,再作为神经网络输入层前的特征拼接,初始化阶段只加载历史数据不做权重更新。
小布可以接入你的品种页,把训练集与验证集的回报曲线叠起来比对,标出回测漂亮但样本外塌方的区间。
可用多周期合成样本或跨品种迁移旧轨迹,重点是把探索动作空间铺开,而不是急着换复杂网络结构。
若先更新再喂新批次,容易用半成品权重污染后续梯度,表现为损失震荡不降,应按先取数、再前向、后反向的固定次序跑。