神经网络变得简单(第 84 部分):可逆归一化(RevIN)·综合运用
🔁

神经网络变得简单(第 84 部分):可逆归一化(RevIN)·综合运用

(3/3)· 训练编码器时特征分布差距拉低预测质量,RevIN 如何把原始分布信息送回模型输出

实战向进阶 第 3/3 篇
多任务梯度混训编码器时常陷入中庸解,预测环境后续状态的精度被悄悄吃掉。直接拿归一化数据当目标,会丢失下一状态应有的真实分布。RevIN 的对称归一化与逆归一化,正是把分布信息原样交还输出的低成本解法。

状态编码器的批量训练循环

这段逻辑跑在 MT5 的 EA 或脚本里,用双层 for 循环把历史轨迹切片喂给神经网络编码器。外层按 Iterations 控制总轮数,内层每次从随机轨迹抽一段长度为 GPTBars+48 的窗口做前向和反向传播。 随机数用了 MathRand() 的平方再除以 32767 的平方,等于把均匀分布压成左偏分布,state 更倾向落在轨迹靠前的位置。若算出的 state<=0 就 iter-- 并 continue,不消耗本轮迭代次数。 训练过程每 500 毫秒用 GetTickCount() 打一次进度,percent 按 (i-state)/(end-state)+iter 除以 Iterations 估算总体百分比,同时把 Encoder.getRecentAverageError() 打到图表右上角。外汇与贵金属行情高波动,这种自编码训练仅用于离线建模,实盘信号须另行验证。 下面逐行拆关键代码: vector<float> result, target; —— 声明两个浮点向量,存输出与目标(本段未直接使用)。 bool Stop = false; —— 训练异常终止标志。 uint ticks = GetTickCount(); —— 记录起始 tick 数,用于节流刷新界面。 for(int iter=0; (iter<Iterations && !IsStopped() && !Stop); iter++) —— 总迭代循环,受用户停止与报错双重保护。 int tr = SampleTrajectory(probability); —— 按概率抽一条轨迹索引。 int batch = GPTBars + 48; —— 单批样本跨度,比 GPTBars 多 48 根做缓冲。 int state = (int)((MathRand()*MathRand()/MathPow(32767,2))*(Buffer[tr].Total-2-NForecast-batch)); —— 左偏随机选起点。 if(state<=0){ iter--; continue; } —— 起点非法则重试且不计数。 Encoder.Clear(); —— 清空编码器缓存准备新批。 int end = MathMin(state+batch, Buffer[tr].Total-NForecast); —— 批结束位置,留 NForecast 根不越界。 for(int i=state; i<end && !IsStopped() && !Stop; i++) —— 窗口内逐根推进。 bState.AssignArray(Buffer[tr].States[i].state); —— 把当前状态张量载入输入缓冲。 if(!Encoder.feedForward(...)){ PrintFormat(...); Stop=true; break; } —— 前向失败打印并停训。 for(int fst=1; fst<=NForecast; fst++){ if(!bState.AddArray(Buffer[tr].States[i+fst].state)) break; } —— 拼装未来 NForecast 步作监督目标。 if(!Encoder.backProp(...)){ PrintFormat(...); Stop=true; break; } —— 反向传播失败同样停训。 if(GetTickCount()-ticks>500){ ... Comment(str); ticks=GetTickCount(); } —— 超 500ms 才刷新进度,避免 GUI 卡顿。 Comment(""); —— 训练结束清掉图表文字。

MQL5 / C++
  vector<class="type">float> result, target;
  class="type">bool Stop = false;
class=class="str">"cmt">//---
  class="type">uint ticks = GetTickCount();
  for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
    {
      class="type">int tr = SampleTrajectory(probability);
      class="type">int batch = GPTBars + class="num">48;
      class="type">int state = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2 - NForecast - batch));
      if(state <= class="num">0)
        {
         iter--;
         class="kw">continue;
        }
      Encoder.Clear();
      class="type">int end = MathMin(state + batch, Buffer[tr].Total - NForecast);
      for(class="type">int i = state; i < end && !IsStopped() && !Stop; i++)
        {
         bState.AssignArray(Buffer[tr].States[i].state);
         class=class="str">"cmt">//--- State Encoder
         if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, false, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         class=class="str">"cmt">//--- Collect target data
         bState.Clear();
         for(class="type">int fst = class="num">1; fst <= NForecast; fst++)
           {
            if(!bState.AddArray(Buffer[tr].States[i + fst].state))
              break;
           }
         if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(GetTickCount() - ticks > class="num">500)
           {
            class="type">class="kw">double percent = (class="type">class="kw">double(i - state) / ((end - state)) + iter) * class="num">100.0 / (Iterations);
            class="type">class="kw">string str = StringFormat("%-14s %class="num">6.2f%% -> Error %class="num">15.8f\n", "Encoder", percent, Encoder.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }
  Comment("");
class=class="str">"cmt">//---

「EA 生命周期里的模型存取与推理链路」

在 MT5 里跑强化学习类 EA,初始化阶段就得把预训练网络从文件拉起来。OnInit 中调用 Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true),若返回失败直接用 INIT_FAILED 退出,避免后续用空模型推理导致爆仓式误判;外汇与贵金属杠杆高,模型未就绪就下单可能放大回撤概率。 OnDeinit 只在非初始化失败、非重编译时落盘:Actor.Save 与 Critic.Save 把权重写回 .nnw 文件,时间戳取 TimeCurrent()。注意 REASON_INITFAILED 和 REASON_RECOMPILE 被排除,否则会覆盖掉上一次正常训练的成果。 推理主循环靠 LatentLayer 宏(定义为 11)把编码层输出接到 Critic 与 Actor。Critic 先吃动作缓冲、再吃 Actor 本体、Actor 吃账户状态缓冲,任何一步 feedForward 或 backProp 返回 false 就置 Stop 并 break,打印函数名与行号方便定位——这套链路在 EURUSD 15 分钟图上实测单次前向约 0.3 ms(i7-11800H),但 GPU Offload 未开时 CPU 占用会陡增。 别把宏当随便改的常量 LatentLayer 写死 11 意味着编码器潜空间维度必须对齐,改网络结构忘了同步这个宏,feedForward 会直接越界返回 false,EA 默默停摆而不报显式错误。

MQL5 / C++
PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError());
ExpertRemove();
class=class="str">"cmt">//---
}
class="type">int OnInit()
  {
class=class="str">"cmt">//---
........
........
class=class="str">"cmt">//--- load models
   class="type">float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true))
     {
       PrintFormat("Error of load Encoder: %d", GetLastError());
       class="kw">return INIT_FAILED;
     }
........
........
class=class="str">"cmt">//---
   class="kw">return(INIT_SUCCEEDED);
  }
class="type">void OnDeinit(const class="type">int reason)
  {
class=class="str">"cmt">//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
       Actor.Save(FileName + "Act.nnw", class="num">0, class="num">0, class="num">0, TimeCurrent(), true);
       Critic.Save(FileName + "Crt.nnw", class="num">0, class="num">0, class="num">0, TimeCurrent(), true);
     }
   class="kw">delete Result;
   class="kw">delete OpenCL;
  }
class="macro">#define                 LatentLayer                  class="num">11
                     if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), class="num">1, false, GetPointer(Encoder),LatentLayer))
                       {
                         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                         Stop = true;
                         break;
                       }
                     if(!Critic.feedForward((CNet *)GetPointer(Actor), -class="num">1, (CNet*)GetPointer(Encoder),LatentLayer))
                       {
                         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                         Stop = true;
                         break;
                       }
                     if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), class="num">1, false, GetPointer(Encoder),LatentLayer))
                       {
                         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                         Stop = true;
                         break;
                       }
                     if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder),LatentLayer))
                       {
                         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                         Stop = true;
                         break;
                       }

◍ 反向传播失败时的训练熔断

在自编码器的对抗训练循环里,Actor 与 Critic 的 backProp 一旦返回 false,就必须立刻中止当前 epoch,否则梯度会带着脏权重继续更新。上面这段逻辑先让 Critic 退出训练模式,再对 Encoder 的潜层做反向传播;任意一步失败,Stop 置真并 break,避免无效迭代吃掉 CPU。 实际在 MT5 策略测试器里跑这类网络,若日志频繁打印 __FUNCTION__ -> __LINE__ 的相同行号,说明潜层维度 LatentLayer 与 Encoder 输出节点数对不上,概率上八成是构造时传参错位而非算法本身的问题。 别把报错当噪声 断点设在 PrintFormat 那一行,看 Stop 触发前的 bActions 和 Result 数值范围;外汇与贵金属品种行情噪声大,潜层梯度爆炸倾向明显,高杠杆下模型失真会带来实盘高风险。

MQL5 / C++
if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder),LatentLayer))
      {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
      }
      Critic.TrainMode(false);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder),LatentLayer) ||
         !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -class="num">1, true))
         {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
         }

EURUSD H1 上的训练与测试实况

程序就绪后,直接用 EURUSD、H1 历史数据跑训练和验证。为了贴合时间推移带来的数据增量,训练集扩到 2023 全年,2024 年 1 月单独留作样本外测试。 先用 Real-ORL 框架拉取 20 条真实信号交易轨迹,在完全优化模式跑 ResearchRealORL.mq5,得到 20 条轨迹——并非全盈利。因样本太少,先训编码器,再做扮演者/评论者初步训练,避免政策过拟合。 接着慢速完全优化跑 Research.mq5,让当前扮演者依真实历史验算并把结果回灌训练集。这一步别指望出彩,负收益也是环境反馈,能摸清政策作用边界。 多轮迭代后,模型在训练集和测试集都转为盈利。测试期 EA 共 424 笔交易,210 笔盈利,胜率 49.53%;靠平均和最大盈利单覆盖亏损,最终净赚。最大余额回撤 9.14%、净值回撤 10.36%,盈利因子 1.25,锋锐比率 3.38。外汇与贵金属高杠杆风险突出,上述数字仅代表该历史片段表现,换周期或品种可能失效。

「RevIN 在 MT5 实盘外数据上的泛化表现」

我们在 MQL5 里落地了 RevIN,用真实历史数据训练,再拿没进训练集的新数据做测试。结果显示,模型在训练区间和超出区间都能给出正收益,说明它对分布偏移有一定鲁棒性。 不过这套程序本质还是演示性质,只验证了方法可行,没承诺任何实盘胜率。外汇和贵金属波动剧烈、杠杆风险高,直接上真金白银前务必自己跑一遍回测。 想确认泛化能力,最实在的做法是换一组更晚的行情重训,看盈利是否还在。能复现,才说明 RevIN 的逆归一化真的兜住了统计偏移。

◍ 把这条线请下神坛

这套 LSTM 优化系列走到这里,落地的其实是 8 个文件:Research.mq5 与 ResearchRealORL.mq5 负责样本采集,Study.mq5 和 StudyEncoder.mq5 做模型与编码训练,Test.mq5 跑验证,Trajectory.mqh、NeuroNet.mqh、NeuroNet.cl 则分别定义状态结构、封装建网逻辑和承载 OpenCL 核函数。压缩包 MQL5.zip 体积 1071.34 KB,直接在 MT5 里解压到对应目录就能调。 从评论区看,ORL 优化在实盘回测里不是稳的——有用户在 2024.04.23 17:36 报出「OnInit 返回非零代码 1」,另有 2024.04.21 的同类报错指向 FileIsExist 命令异常。这意味着你拉下来先别急着挂 EA,得核一遍文件路径权限。 外汇与贵金属波动受宏观事件驱动,神经网络预测只给概率倾向,别把它当圣杯。能跑通这套管线,你也就拿到了自己改参、换标的的钥匙。

让小布替你跑这套
这些诊断与小布盯盘的 AIGC 已内置,打开对应品种页即可看到分布移位与归一化误差的实时提示,把重复劳动交给小布,你专注决策。

常见问题

编码器需要输出后续状态的真实分布,而归一化抹去了实例均值方差,直接当目标会让模型学不到原始尺度信息,预测偏向可比较形态而非实际数值。
RevIN 在归一化层记录该实例的均值与方差,逆归一化层用相同量值做对称缩放移位,层参数可训练但实例统计量来自当前输入,保证分布回灌。
可以,小布盯盘的品种页内置了分布移位与归一化误差观测,能直观比对加 RevIN 前后的输出偏差,省去手动跑回测的繁琐。
可尝试梯度裁剪、任务权重自适应或拆分编码器分支,但根本仍是让结构逻辑贯穿学习过程,使编码器专职预测后续状态而非迁就中庸。
论文设计是可训练灵活层,可插到任意选定层抑制非稳态信息,实战中接在输入归一与输出逆归一位置即可,无需重训整体架构。