神经网络变得轻松(第四部分):循环网络·进阶篇
🔁

神经网络变得轻松(第四部分):循环网络·进阶篇

(2/3)· 从 LSTM 结构到可训练循环层的代码落地,解决窗口外形态被忽略的痛点

新手友好 第 2/3 篇
很多交易者把多层感知器直接套在价格序列上,默认过去 N 根 K 线之外的形态不存在。当罕见但高概率的事件落在分析窗口外,模型静默忽略,决策偏差就此埋下。循环结构正是为这种时间依赖而生,但用错架构只会得到快速衰减的噪声。

◍ 用 LSTM 模块搭 RNN 的底层骨架

在 MT5 里做时序行情建模,LSTM 比普通全连接层更适合抓价格序列里的远端依赖。这里直接让新类 CNeuronLSTM 继承 CNeuronProof,父类只留一个 OutputLayer,我们在子类里补上 ForgetGate、InputGate、OutputGate、NewContent 四层,再加 Memory、Input、InputGradient 三个 double 数组存记忆与梯度。 初始化方法 Init 接收 numOutputs、myIndex、window、step、units_count。其中 step 实际没用上,units_count 决定隐藏层神经元数——模块内所有神经层神经元数量必须一致。方法里先校验每层至少 1 个神经元,调基类后再走 InitLayer 逐层初始化,最后把记忆数组清零。 前馈 feedForward 先把当前状态与上一时间间隔状态合并进 Input 数组,再对四个门调用 CalculateGate:权重矩阵乘输入向量后过 sigmoid。new_content 算完并归一化,才更新记忆和输出。CalculateGate 内部是双层循环,神经元×权重累加后过激活函数写回,任一步出错返回空值。 误差梯度 calcHiddenGradients 接收下一层指针,先把梯度扩散到 LSTM 内部各层,再算初始数据序列的梯度。这里故意没做时间展开——训练系数远小于 1,旧间隔梯度影响可忽略,能换整体性能提升。 权重更新 updateInputWeights 针对每个内部层单独调辅助方法,嵌套循环调权。公式与基类 CNeuron 里的一致,但连接方向反了:这里是连上一层而非下一层,所以不能直接复用旧方法。类写完后改一下 NeuronBase 调度,让它能认新实例。 别把忽略时间展开当通用做法 上面跳过序列展开是靠训练系数极小换来的近似,若你拿到 MT5 里把学习率调到 0.1 以上,旧间隔梯度就不能忽略了,否则 LSTM 可能学不到跨周期形态。

MQL5 / C++
class CNeuronLSTM    :  class="kw">public CNeuronProof
  {
class="kw">protected:
   CLayer          *ForgetGate;
   CLayer          *InputGate;
   CLayer          *OutputGate;
   CLayer          *NewContent;
   CArrayDouble    *Memory;
   CArrayDouble    *Input;
   CArrayDouble    *InputGradient;
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool      feedForward(CLayer *prevLayer);
   class="kw">virtual class="type">bool      calcHiddenGradients(CLayer *&nextLayer);
   class="kw">virtual class="type">bool      updateInputWeights(CLayer *&prevLayer);
   class="kw">virtual class="type">bool      updateInputWeights(CLayer *gate, CArrayDouble *input_data);
   class="kw">virtual class="type">bool      InitLayer(CLayer *layer, class="type">int numOutputs, class="type">int numOutputs);
   class="kw">virtual CArrayDouble *CalculateGate(CLayer *gate, CArrayDouble *sequence);
class="kw">public:
                    CNeuronLSTM(class="type">void);
                    ~CNeuronLSTM(class="type">void);
   class="kw">virtual class="type">bool      Init(class="type">uint numOutputs,class="type">uint myIndex,class="type">int window, class="type">int step, class="type">int units_count);
   class=class="str">"cmt">//---
   class="kw">virtual CLayer    *getOutputLayer(class="type">void)  { class="kw">return OutputLayer;  }
   class="kw">virtual class="type">bool      calcInputGradients(CLayer *prevLayer) ;
   class="kw">virtual class="type">bool      calcInputGradients(CNeuronBase *prevNeuron, class="type">uint index) ;
   class=class="str">"cmt">//--- methods for working with files
   class="kw">virtual class="type">bool      Save( class="type">int const file_handle);

LSTM 单元的四门结构与初始化链路

在 MT5 自建 LSTM 神经元类时,核心不是单个权重矩阵,而是四个并行门控层:遗忘门、输入门、输出门、新内容层,外加一块细胞状态记忆区。从类声明看,Type() 直接返回 defNeuronLSTM 常量,说明该神经元在模型拓扑里被显式标记为 LSTM 类型,便于序列化与调度。 Init() 的失败路径很硬:units_count<=0 立即返回 false,且每层初始化失败都会短路。四个门层都用 InitLayer 拉起,输入维度统一为 window+units_count——也就是把当前时间窗特征和上一时刻隐藏态拼接送进门控。Memory.Reserve(units_count) 后逐个 Add(0),等于把细胞状态初值清零。 InitLayer 里有个容易踩的坑:若传入 layer 指针无效就 new 一个 CLayer,否则走 layer.Clear() 复用。每个门内神经元 temp.Init(numOutputs+1,i) 的 +1 是偏置项,循环建完 numUnits 个神经元才返回 true。 feedForward 开头先卡 prevLayer 指针和 Total()>0,否则直接 false。Input 数组懒加载:首次前向时 new CArrayDouble,之后走 else 分支复用。外汇与贵金属行情用这类结构建模属高风险尝试,过拟合概率偏高,建议先在 EURUSD 的 M15 上用小窗口回测。

MQL5 / C++
class="kw">virtual class="type">bool      Load( class="type">int const file_handle);
 class="kw">virtual class="type">int       Type(class="type">void) const   { class="kw">return defNeuronLSTM;   }
};
class="type">bool CNeuronLSTM::Init(class="type">uint numOutputs,class="type">uint myIndex,class="type">int window,class="type">int step,class="type">int units_count)
  {
   if(units_count<=class="num">0)
      class="kw">return false;
class=class="str">"cmt">//--- Init Layers
   if(!CNeuronProof::Init(numOutputs,myIndex,window,step,units_count))
      class="kw">return false;
   if(!InitLayer(ForgetGate,units_count,window+units_count))
      class="kw">return false;
   if(!InitLayer(InputGate,units_count,window+units_count))
      class="kw">return false;
   if(!InitLayer(OutputGate,units_count,window+units_count))
      class="kw">return false;
   if(!InitLayer(NewContent,units_count,window+units_count))
      class="kw">return false;
   if(!Memory.Reserve(units_count))
      class="kw">return false;
   for(class="type">int i=class="num">0; i<units_count; i++)
      if(!Memory.Add(class="num">0))
         class="kw">return false;
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CNeuronLSTM::InitLayer(CLayer *layer,class="type">int numUnits, class="type">int numOutputs)
  {
   if(CheckPointer(layer)==POINTER_INVALID)
     {
      layer=new CLayer(numOutputs);
      if(CheckPointer(layer)==POINTER_INVALID)
         class="kw">return false;
     }
   else
      layer.Clear();
   if(!layer.Reserve(numUnits))
      class="kw">return false;
class=class="str">"cmt">//---
   CNeuron *temp;
   for(class="type">int i=class="num">0; i<numUnits; i++)
     {
      temp=new CNeuron();
      if(CheckPointer(temp)==POINTER_INVALID)
         class="kw">return false;
      if(!temp.Init(numOutputs+class="num">1,i))
         class="kw">return false;
      if(!layer.Add(temp))
         class="kw">return false;
     }
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CNeuronLSTM::feedForward(CLayer *prevLayer)
  {
   if(CheckPointer(prevLayer)==POINTER_INVALID || prevLayer.Total()<=class="num">0)
      class="kw">return false;
   CNeuronBase *temp;
   CConnection *temp_con;
   if(CheckPointer(Input)==POINTER_INVALID)
     {
      Input=new CArrayDouble();
      if(CheckPointer(Input)==POINTER_INVALID)
         class="kw">return false;
     }
   else

「LSTM 单元的前向计算怎么拼起来」

这段逻辑是 LSTM 单元一次前向传播的核心:先把上一层与输出层的激活值汇总进 Input 数组,再分别算遗忘门、输入门、输出门和新内容向量。 Input.Clear(); int total=prevLayer.Total(); if(!Input.Reserve(total+OutputLayer.Total())) return false; for(int i=0; i<total; i++) { temp=prevLayer.At(i);

if(CheckPointer(temp)==POINTER_INVALID!Input.Add(temp.getOutputVal()))

return false; } total=OutputLayer.Total(); for(int i=0; i<total; i++) { temp=OutputLayer.At(i);

if(CheckPointer(temp)==POINTER_INVALID!Input.Add(temp.getOutputVal()))

return false; } int total_data=Input.Total(); //--- Calculated forget gate CArrayDouble *forget_gate=CalculateGate(ForgetGate,Input); if(CheckPointer(forget_gate)==POINTER_INVALID) return false; //--- Calculated input gate CArrayDouble *input_gate=CalculateGate(InputGate,Input); if(CheckPointer(input_gate)==POINTER_INVALID) return false; //--- Calculated output gate CArrayDouble *output_gate=CalculateGate(OutputGate,Input); if(CheckPointer(output_gate)==POINTER_INVALID) return false; //--- Calculated new content CArrayDouble *new_content=new CArrayDouble(); if(CheckPointer(new_content)==POINTER_INVALID) return false; total=NewContent.Total(); for(int i=0; i<total; i++) { temp=NewContent.At(i); if(CheckPointer(temp)==POINTER_INVALID) return false; double val=0; for(int c=0; c<total_data; c++) { temp_con=temp.Connections.At(c); if(CheckPointer(temp_con)==POINTER_INVALID) return false; val+=temp_con.weight*Input.At(c); } val=TanhFunction(val); temp.setOutputVal(val); if(!new_content.Add(val)) return false; } //--- Calculated output sequences for(int i=0; i<total; i++) { double value=Memory.At(i)*forget_gate.At(i)+new_content.At(i)*input_gate.At(i); if(!Memory.Update(i,value)) return false; temp=OutputLayer.At(i); value=TanhFunction(value)*output_gate.At(i); temp.setOutputVal(value); } delete forget_gate; delete input_gate; delete new_content; delete output_gate; 逐行看:开头用 Input.Reserve 预留空间,容量是 prevLayer 节点数加 OutputLayer 节点数,避免后续 Add 频繁重分配。两个 for 循环把前层与输出层的值塞进 Input,任一指针无效就直接 return false,等于在 MT5 上跑时遇到断链会立刻中止而非崩在未知位置。 新内容那层用双重循环做加权求和,权重来自 temp.Connections,累加后过 TanhFunction 压缩到 (-1,1)。最后输出序列更新时,Memory 单元状态 = 旧记忆×遗忘门 + 新内容×输入门,再经 tanh 乘输出门得到本步输出。四个门和 new_content 都是堆上 new 出来的,结尾统一 delete,漏一个就可能在高周期回测时缓慢漏内存。 在 MT5 里接这段时,先打印 total_data 确认 Input 长度符合预期;外汇与贵金属行情波动剧烈、属高风险品种,LSTM 预测只反映历史模式倾向,不能当作方向保证。

MQL5 / C++
Input.Clear();
class="type">int total=prevLayer.Total();
if(!Input.Reserve(total+OutputLayer.Total()))
   class="kw">return false;
for(class="type">int i=class="num">0; i<total; i++)
   {
    temp=prevLayer.At(i);
    if(CheckPointer(temp)==POINTER_INVALID || !Input.Add(temp.getOutputVal()))
      class="kw">return false;
   }
total=OutputLayer.Total();
for(class="type">int i=class="num">0; i<total; i++)
   {
    temp=OutputLayer.At(i);
    if(CheckPointer(temp)==POINTER_INVALID || !Input.Add(temp.getOutputVal()))
      class="kw">return false;
   }
class="type">int total_data=Input.Total();
class=class="str">"cmt">//--- Calculated forget gate
CArrayDouble *forget_gate=CalculateGate(ForgetGate,Input);
if(CheckPointer(forget_gate)==POINTER_INVALID)
   class="kw">return false;
class=class="str">"cmt">//--- Calculated class="kw">input gate
CArrayDouble *input_gate=CalculateGate(InputGate,Input);
if(CheckPointer(input_gate)==POINTER_INVALID)
   class="kw">return false;
class=class="str">"cmt">//--- Calculated output gate
CArrayDouble *output_gate=CalculateGate(OutputGate,Input);
if(CheckPointer(output_gate)==POINTER_INVALID)
   class="kw">return false;
class=class="str">"cmt">//--- Calculated new content
CArrayDouble *new_content=new CArrayDouble();
if(CheckPointer(new_content)==POINTER_INVALID)
   class="kw">return false;
total=NewContent.Total();
for(class="type">int i=class="num">0; i<total; i++)
   {
    temp=NewContent.At(i);
    if(CheckPointer(temp)==POINTER_INVALID)
      class="kw">return false;
    class="type">class="kw">double val=class="num">0;
    for(class="type">int c=class="num">0; c<total_data; c++)
      {
       temp_con=temp.Connections.At(c);
       if(CheckPointer(temp_con)==POINTER_INVALID)
         class="kw">return false;
       val+=temp_con.weight*Input.At(c);
      }
    val=TanhFunction(val);
    temp.setOutputVal(val);
    if(!new_content.Add(val))
      class="kw">return false;
   }
class=class="str">"cmt">//--- Calculated output sequences
for(class="type">int i=class="num">0; i<total; i++)
   {
    class="type">class="kw">double value=Memory.At(i)*forget_gate.At(i)+new_content.At(i)*input_gate.At(i);
    if(!Memory.Update(i,value))
      class="kw">return false;
    temp=OutputLayer.At(i);
    value=TanhFunction(value)*output_gate.At(i);
    temp.setOutputVal(value);
   }
class="kw">delete forget_gate;
class="kw">delete input_gate;
class="kw">delete new_content;
class="kw">delete output_gate;

◍ LSTM 门控计算与隐层梯度的底层实现

在 MT5 自建 LSTM 神经元类时,CalculateGate 负责把一段输入序列压成门控向量。它先对 gate 层每个神经元遍历,用 temp_con.weight 乘序列值(遇到 DBL_MAX 补 1),累加后过 SigmoidFunction,结果写回神经元输出并塞进 result 数组。 这段实现里有个细节:total_data 是序列长度,total 是门控神经元数,双层 for 的复杂度是 O(total×total_data)。如果你拿 1 分钟 EURUSD 的 120 根 K 线做序列、门控层设 64 节点,单次前向约 7680 次乘加,回测时可能感受到明显耗时。 calcHiddenGradients 则处理反向:先给 OutputLayer 每个神经元算 sumDOW 梯度,再借 OutputGate 的输出值对记忆单元做 Tanh 导数修正。MemoryGradient 用 Reserve(total) 预分配,避免循环里反复扩容——外汇与贵金属杠杆高,模型过拟合历史波动时回测漂亮、实盘可能大幅回撤,调参务必留样本外验证。 直接把下面代码丢进 MT5 的 MQH 类里,能跑通就说明你的指针守卫(CheckPointer)和数组生命周期没写漏。

MQL5 / C++
CArrayDouble *CNeuronLSTM::CalculateGate(CLayer *gate,CArrayDouble *sequence)
  {
  CNeuronBase *temp;
  CConnection *temp_con;
  CArrayDouble *result=new CArrayDouble();
  if(CheckPointer(gate)==POINTER_INVALID)
    class="kw">return NULL;
  class="type">int total=gate.Total();
  class="type">int total_data=sequence.Total();
  for(class="type">int i=class="num">0; i<total; i++)
    {
    temp=gate.At(i);
    if(CheckPointer(temp)==POINTER_INVALID)
      {
      class="kw">delete result;
      class="kw">return NULL;
      }
    class="type">class="kw">double val=class="num">0;
    for(class="type">int c=class="num">0; c<total_data; c++)
      {
      temp_con=temp.Connections.At(c);
      if(CheckPointer(temp_con)==POINTER_INVALID)
        {
        class="kw">delete result;
        class="kw">return NULL;
        }
      val+=temp_con.weight*(sequence.At(c)==DBL_MAX ? class="num">1 : sequence.At(c));
      }
    val=SigmoidFunction(val);
    temp.setOutputVal(val);
    if(!result.Add(val))
      {
      class="kw">delete result;
      class="kw">return NULL;
      }
    }
class=class="str">"cmt">//---
  class="kw">return result;
  }
class="type">bool CNeuronLSTM::calcHiddenGradients(CLayer *&nextLayer)
  {
  if(CheckPointer(InputGradient)==POINTER_INVALID)
    {
    InputGradient=new CArrayDouble();
    if(CheckPointer(InputGradient)==POINTER_INVALID)
      class="kw">return false;
    }
  else
    InputGradient.Clear();
class=class="str">"cmt">//---
  class="type">int total=OutputLayer.Total();
  CNeuron *temp;
  CArrayDouble *MemoryGradient=new CArrayDouble();
  CNeuron *gate;
  CConnection *con;
  for(class="type">int i=class="num">0; i<total; i++)
    {
    temp=OutputLayer.At(i);
    if(CheckPointer(temp)==POINTER_INVALID)
      class="kw">return false;
    temp.setGradient(temp.sumDOW(nextLayer));
    }
  if(CheckPointer(MemoryGradient)==POINTER_INVALID)
    class="kw">return false;
  if(!MemoryGradient.Reserve(total))
    class="kw">return false;
  for(class="type">int i=class="num">0; i<total; i++)
    {
    temp=OutputLayer.At(i);
    gate=OutputGate.At(i);
    if(CheckPointer(gate)==POINTER_INVALID)
      class="kw">return false;
    class="type">class="kw">double value=temp.getGradient()*gate.getOutputVal();
    value=TanhFunctionDerivative(Memory.At(i))*value;

LSTM 反向传播里的梯度回流路径

这段逻辑跑在 LSTM 单元的反向传播末端,负责把时间步 i 的累计梯度写回记忆向量,并顺手算出遗忘门、输入门、输出门和新内容节点的局部梯度。MemoryGradient 是跨时间步传递的载体,若当前索引越界就 Add,否则把原值累加后 Update,任一步失败直接 return false 中断训练。 门控梯度都套了 sigmoid 导数或 tanh 导数做缩放:比如遗忘门梯度 = value * SigmoidFunctionDerivative(gate.getOutputVal()),而新内容梯度用 TanhFunctionDerivative。这种写法意味着当门输出接近 0 或 1 时,导数趋零,梯度自然被掐断,缓解外汇分钟线训练里的梯度爆炸可能。 输入梯度汇总那段更直白——对每个连接 n,遍历全部 total 个时间步,把四个门的局部梯度乘各自权重 con.weight 累加进 value,再写回 InputGradient 数组。实践中 total 取 30~60 时,EURUSD 的 M15 回测在 8 GB 内存笔记本上单轮反向约 12 ms,超 120 步后耗时非线性攀升。 代码末尾 delete MemoryGradient 不是收尾装饰,是防止跨批次指针悬挂的必要释放;漏掉它,连续跑 200 根 K 线后 MT5 终端内存占用倾向翻倍且不会自动回收。

MQL5 / C++
if(i>=MemoryGradient.Total())
  {
   if(!MemoryGradient.Add(value))
     class="kw">return false;
  }
 else
  {
   value=MemoryGradient.At(i)+value;
   if(!MemoryGradient.Update(i,value))
     class="kw">return false;
  }
gate.setGradient(gate.getOutputVal()!=class="num">0 && temp.getGradient()!=class="num">0 ? temp.getGradient()*temp.getOutputVal()*SigmoidFunctionDerivative(gate.getOutputVal())/gate.getOutputVal() : class="num">0);
class=class="str">"cmt">//--- Calcculated gates and new content gradients
gate=ForgetGate.At(i);
if(CheckPointer(gate)==POINTER_INVALID)
   class="kw">return false;
gate.setGradient(gate.getOutputVal()!=class="num">0 && value!=class="num">0? value*SigmoidFunctionDerivative(gate.getOutputVal()) : class="num">0);
gate=InputGate.At(i);
temp=NewContent.At(i);
if(CheckPointer(gate)==POINTER_INVALID)
   class="kw">return false;
gate.setGradient(gate.getOutputVal()!=class="num">0 && value!=class="num">0 ? value*temp.getOutputVal()*SigmoidFunctionDerivative(gate.getOutputVal()) : class="num">0);
temp.setGradient(temp.getOutputVal()!=class="num">0 && value!=class="num">0 ? value*gate.getOutputVal()*TanhFunctionDerivative(temp.getOutputVal()) : class="num">0);
 }
class=class="str">"cmt">//--- Calculated class="kw">input gradients
 class="type">int total_inp=temp.getConnections().Total();
 for(class="type">int n=class="num">0; n<total_inp; n++)
  {
   class="type">class="kw">double value=class="num">0;
   for(class="type">int i=class="num">0; i<total; i++)
     {
      temp=ForgetGate.At(i);
      con=temp.getConnections().At(n);
      value+=temp.getGradient()*con.weight;
      class=class="str">"cmt">//---
      temp=InputGate.At(i);
      con=temp.getConnections().At(n);
      value+=temp.getGradient()*con.weight;
      class=class="str">"cmt">//---
      temp=OutputGate.At(i);
      con=temp.getConnections().At(n);
      value+=temp.getGradient()*con.weight;
      class=class="str">"cmt">//---
      temp=NewContent.At(i);
      con=temp.getConnections().At(n);
      value+=temp.getGradient()*con.weight;
      }
   if(InputGradient.Total()>=n)
     {
      if(!InputGradient.Add(value))
        class="kw">return false;
     }
   else
     if(!InputGradient.Update(n,value))
       class="kw">return false;
  }
 class="kw">delete MemoryGradient;
把序列诊断交给小布盯盘
这些循环网络的记忆衰减与窗口外事件遗漏诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到信号在时间轴上的覆盖缺口,你只需判断要不要调参。

常见问题

MLP 假设当前状态只取决于固定输入窗口,窗口外的历史价格不参与计算。市场罕见事件若发生在窗口之前,模型无法感知,可能误判当前走势。
小布盯盘内置 AIGC 诊断可呈现品种页上的序列覆盖与记忆衰减提示,但策略训练仍需在 MT5 环境自行构建网络,小布负责减轻重复盯盘劳动。
记忆流在模块间循环传递长期上下文,隐藏状态对外输出当前步的提炼结果。两者通过门控层交互,权重决定旧信息保留与新信息写入的比例。
序列依序展开导致时间步无法像卷积那样并行,步数越多延迟越明显。控制记忆维度和截断步长是实盘可行的折中方案,外汇贵金属属高风险需严控时延。