神经网络变得轻松(第四部分):循环网络·进阶篇
(2/3)· 从 LSTM 结构到可训练循环层的代码落地,解决窗口外形态被忽略的痛点
◍ 用 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 可能学不到跨周期形态。
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 上用小窗口回测。
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 预测只反映历史模式倾向,不能当作方向保证。
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)和数组生命周期没写漏。
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 终端内存占用倾向翻倍且不会自动回收。
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;