神经网络变得轻松(第三部分):卷积网络·进阶篇
「神经元基类的骨架与虚函数占位」
在 MT5 里用 C++ 风格搭神经网络,第一步往往是先定义一个神经元基类 CNeuronBase,把后续各层都要用的状态和接口先框死。下面这段声明里能看到它持有的核心字段:alpha 是学习率系数,outputVal 存当前输出,m_myIndex 记录自身在层内的序号,gradient 留给反向传播算梯度,Connections 是指向连接数组的指针。 基类里大量使用 virtual 函数并返回 false 或固定值,本质是「占位」。比如 feedForward 直接 return false,activationFunction 恒返回 1.0,说明基类不实现具体计算,只给派生类留重写入口。你在 EA 里若直接调用基类这些方法,网络是不会真正前向传播的。 对外暴露的接口已经比较完整:setOutputVal / getOutputVal 管输入输出值,setGradient / getGradient 管梯度,Init 接收输出数和自身索引做初始化,Save / Load 负责把连接持久化到文件句柄。注意 Load 这里直接转调 Connections.Load,说明权重恢复依赖连接对象自己实现。 打开 MT5 的 MetaEditor,新建一个 mqh 把这段结构抄进去,先别急着写算法——_confirm 编译能过、虚表能建,再往下填各层的具体激活函数。外汇与贵金属杠杆高,拿这类自研模型跑实盘前务必先用历史数据做离线验证。
class="type">class="kw">double alpha; class="type">class="kw">double outputVal; class="type">uint m_myIndex; class="type">class="kw">double gradient; CArrayCon *Connections; class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CLayer *prevLayer) { class="kw">return class="kw">false; } class="kw">virtual class="type">bool calcHiddenGradients( CLayer *&nextLayer) { class="kw">return class="kw">false; } class="kw">virtual class="type">bool updateInputWeights(CLayer *&prevLayer) { class="kw">return class="kw">false; } class="kw">virtual class="type">class="kw">double activationFunction(class="type">class="kw">double x) { class="kw">return class="num">1.0; } class="kw">virtual class="type">class="kw">double activationFunctionDerivative(class="type">class="kw">double x) { class="kw">return class="num">1.0; } class="kw">virtual CLayer *getOutputLayer(class="type">void) { class="kw">return NULL; } class="kw">public: CNeuronBase(class="type">void); ~CNeuronBase(class="type">void); class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex); class=class="str">"cmt">//--- class="kw">virtual class="type">void setOutputVal(class="type">class="kw">double val) { outputVal=val; } class="kw">virtual class="type">class="kw">double getOutputVal() { class="kw">return outputVal; } class="kw">virtual class="type">void setGradient(class="type">class="kw">double val) { gradient=val; } class="kw">virtual class="type">class="kw">double getGradient() { class="kw">return gradient; } class=class="str">"cmt">//--- class="kw">virtual class="type">bool feedForward(CObject *&SourceObject); class="kw">virtual class="type">bool calcHiddenGradients( CObject *&TargetObject); class="kw">virtual class="type">bool updateInputWeights(CObject *&SourceObject); class=class="str">"cmt">//--- class="kw">virtual class="type">bool Save( class="type">int class="kw">const file_handle); class="kw">virtual class="type">bool Load( class="type">int class="kw">const file_handle) { class="kw">return(Connections.Load(file_handle)); } class=class="str">"cmt">//---
基类怎么把前向和梯度甩给不同对象
CNeuronBase 里 feedForward 和 calcHiddenGradients 都接 CObject*,靠 Type() 分流:源或目标若是 defLayer 就直接调层接口,若是 defNeuronConv / defNeuronProof 则转去取输出层再喂。这样同一入口能兼容层与神经元两种上游,写 EA 时不用在外层判断类型。 看 feedForward 的 switch:defLayer 分支把 SourceObject 强转 CLayer* 后调重载;神经元类分支则取 temp_n.getOutputLayer() 再递归。CheckPointer 返回 POINTER_INVALID 时直接 false 退出,避免空指针崩 MT5 回测。 calcHiddenGradients 多一层自身 Type() 判断:当自己是普通 defNeuron,就调目标的 calcInputGradients 并传 m_myIndex;否则把自己当临时节点,取输出层传给目标算输入梯度。这种双 switch 结构在卷积类接证明类神经元时才会走 default 分支。 CNeuronProof 公开继承 CNeuronBase,保护成员挂了 OutputLayer、iWindow、iStep,说明证明神经元要记住输出层和滑窗参数。它重写了 feedForward(CLayer*) 与 calcHiddenGradients(CLayer&),构造析构留空壳,具体窗口怎么卷要靠后续实现。
class="kw">virtual class="type">int Type(class="type">void) class="kw">const { class="kw">return defNeuronBase; } }; class="type">bool CNeuronBase::feedForward(CObject *&SourceObject) { class="type">bool result=class="kw">false; class=class="str">"cmt">//--- if(CheckPointer(SourceObject)==POINTER_INVALID) class="kw">return result; CLayer *temp_l; CNeuronProof *temp_n; class="kw">switch(SourceObject.Type()) { case defLayer: temp_l=SourceObject; result=feedForward(temp_l); class="kw">break; case defNeuronConv: case defNeuronProof: temp_n=SourceObject; result=feedForward(temp_n.getOutputLayer()); class="kw">break; } class=class="str">"cmt">//--- class="kw">return result; } class="type">bool CNeuronBase::calcHiddenGradients(CObject *&TargetObject) { class="type">bool result=class="kw">false; class=class="str">"cmt">//--- if(CheckPointer(TargetObject)==POINTER_INVALID) class="kw">return result; class=class="str">"cmt">//--- CLayer *temp_l; CNeuronProof *temp_n; class="kw">switch(TargetObject.Type()) { case defLayer: temp_l=TargetObject; result=calcHiddenGradients(temp_l); class="kw">break; case defNeuronConv: case defNeuronProof: class="kw">switch(Type()) { case defNeuron: temp_n=TargetObject; result=temp_n.calcInputGradients(GetPointer(this),m_myIndex); class="kw">break; class="kw">default: temp_n=GetPointer(this); temp_l=temp_n.getOutputLayer(); temp_n=TargetObject; result=temp_n.calcInputGradients(temp_l); class="kw">break; } class="kw">break; } class=class="str">"cmt">//--- class="kw">return result; } class CNeuronProof : class="kw">public CNeuronBase { class="kw">protected: CLayer *OutputLayer; class="type">int iWindow; class="type">int iStep; class="kw">virtual class="type">bool feedForward(CLayer *prevLayer); class="kw">virtual class="type">bool calcHiddenGradients( CLayer *&nextLayer); class="kw">public: CNeuronProof(class="type">void){}; ~CNeuronProof(class="type">void);
◍ 滑动窗口神经元的实现细节
CNeuronProof 这个类在 MQL5 神经网络框架里干一件事:把前一层的时间序列按滑动窗口做均值池化,再交给 OutputLayer 存结果。它的 Init 接收 numOutputs、myIndex、window、step、output_count 几个参数,window 和 step 直接决定特征抽取的疏密程度。 看 feedForward 的具体循环:total 被算成 prevLayer.Total()-iWindow+1,也就是能滑多少次;外层循环 i 从 0 以 iStep 为步长递增,内层把窗口内 iWindow 个神经元的输出值累加后除以 iWindow,写进 OutputLayer 的第 i/iStep 个节点。若 prevLayer 总长是 100、window=10、step=5,那 OutputLayer 大约会落 19 个均值特征。 [CODE] virtual bool Init(uint numOutputs,uint myIndex,int window, int step, int output_count); //--- virtual CLayer *getOutputLayer(void) { return OutputLayer; } virtual bool calcInputGradients( CLayer *prevLayer) ; virtual bool calcInputGradients( CNeuronBase *prevNeuron, uint index) ; //--- methods for working with files virtual bool Save( int const file_handle) { return(CNeuronBase::Save(file_handle) && OutputLayer.Save(file_handle)); } virtual bool Load( int const file_handle) { return(CNeuronBase::Load(file_handle) && OutputLayer.Load(file_handle)); } virtual int Type(void) const { return defNeuronProof; } }; bool CNeuronProof::feedForward(CLayer *prevLayer) { if(CheckPointer(prevLayer)==POINTER_INVALID) return false; int total=prevLayer.Total()-iWindow+1; CNeuron *temp; for(int i=0;(i<=total && result);i+=iStep) { double sum=0; for(int j=0;j<iWindow;j++) { temp=prevLayer.At(i+j); if(CheckPointer(temp)==POINTER_INVALID) continue; sum+=temp.getOutputVal(); } temp=OutputLayer.At(i/iStep); if(CheckPointer(temp)==POINTER_INVALID) return false; temp.setOutputVal(sum/iWindow); } //--- return true; } bool CNeuronProof::calcHiddenGradients( CLayer *&nextLayer) {
| if(CheckPointer(nextLayer)==POINTER_INVALID | CheckPointer(OutputLayer)==POINTER_INVALID | OutputLayer.Total()<=0) |
|---|
return false; gradient=0; int total=OutputLayer.Total(); CNeuron *temp; for(int i=0;i<total;i++) { temp=OutputLayer.At(i); if(CheckPointer(temp)==POINTER_INVALID) return false; temp.setGradient(temp.sumDOW(nextLayer)); } //--- return true; } bool CNeuronProof::calcInputGradients(CLayer *prevLayer) { [/CODE] 上面这段里,Init 是虚函数,说明子类可重写;getOutputLayer 直接返回内部 OutputLayer 指针,方便外部读取池化结果。Save 和 Load 把基类与输出层一起序列化,意味着你训练完的滑窗神经元可以整体落盘,下次 MT5 加载不用重训。 calcHiddenGradients 里先把 gradient 清 0,再遍历 OutputLayer 的每个节点,用 temp.sumDOW(nextLayer) 把下一层的误差反传回来写进梯度。注意它检查了 OutputLayer.Total()<=0 就直接退出,所以空输出层不会误算。外汇与贵金属行情用这类网络做特征压缩时,仍属高风险,窗口参数过拟合可能让样本外表现倾向恶化。 改 iWindow 和 iStep 是两个最直接的旋钮:window 调大平滑强但滞后明显,step 调大特征稀疏但计算轻。开 MT5 把这两参数接上 EURUSD 的 M5 收盘序列跑一遍,就能看出池化后特征对原序列的跟踪误差大概落在什么区间。
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 output_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 class="kw">const file_handle) { class="kw">return(CNeuronBase::Save(file_handle) && OutputLayer.Save(file_handle)); } class="kw">virtual class="type">bool Load( class="type">int class="kw">const file_handle) { class="kw">return(CNeuronBase::Load(file_handle) && OutputLayer.Load(file_handle)); } class="kw">virtual class="type">int Type(class="type">void) class="kw">const { class="kw">return defNeuronProof; } }; class="type">bool CNeuronProof::feedForward(CLayer *prevLayer) { if(CheckPointer(prevLayer)==POINTER_INVALID) class="kw">return class="kw">false; class="type">int total=prevLayer.Total()-iWindow+class="num">1; CNeuron *temp; for(class="type">int i=class="num">0;(i<=total && result);i+=iStep) { class="type">class="kw">double sum=class="num">0; for(class="type">int j=class="num">0;j<iWindow;j++) { temp=prevLayer.At(i+j); if(CheckPointer(temp)==POINTER_INVALID) class="kw">continue; sum+=temp.getOutputVal(); } temp=OutputLayer.At(i/iStep); if(CheckPointer(temp)==POINTER_INVALID) class="kw">return class="kw">false; temp.setOutputVal(sum/iWindow); } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronProof::calcHiddenGradients( CLayer *&nextLayer) { if(CheckPointer(nextLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID || OutputLayer.Total()<=class="num">0) class="kw">return class="kw">false; gradient=class="num">0; class="type">int total=OutputLayer.Total(); CNeuron *temp; for(class="type">int i=class="num">0;i<total;i++) { temp=OutputLayer.At(i); if(CheckPointer(temp)==POINTER_INVALID) class="kw">return class="kw">false; temp.setGradient(temp.sumDOW(nextLayer)); } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CNeuronProof::calcInputGradients(CLayer *prevLayer) {
「卷积层反向传播里的指针与窗口切片」
在 MT5 自建神经网络里,卷积神经元反向算梯度前必须先确认前层与输出层指针有效。下面这段判断若 prevLayer 或 OutputLayer 任一为 POINTER_INVALID 就直接 return false,避免后续空指针访问把 EA 跑崩。 如果前层首个神经元类型不是 defNeuron,说明当前挂在 proof 层上,需要借 m_myIndex 取出对应 CNeuronProof 并替换 prevLayer 为其输出层,每一步都重新 CheckPointer。这一层套一层的兜底,是卷积结构复用全连接代码时的典型坑。 真正算梯度时,total 取前层神经元数,对每个 prevNeuron 用 iWindow 与 iStep 切出输出层索引区间 [start, stop)。prev_gradient 累加 outputNeuron.getGradient()/iWindow,再 setGradient 回写。注意 start 做了 (start-start%iStep)/iStep 的下取整对齐,stop 同理加 1,区间边界容易写错。 CNeuronConv 类里 param 默认 0.01,即 PReLU 的负半轴斜率;Type() 返回 defNeuronConv 供上层判别。激活与导数、updateInputWeights 都是虚函数,意味着你改一版卷积核更新逻辑只需重写这一类,不用动基类循环。 把下面代码贴进 MT5 的 .mqh 里单步跟踪,重点看 prev_gradient 在 iWindow=3、iStep=1 时是否如预期被 3 除摊薄,外汇与贵金属模型训练波动大,过拟合概率偏高,验证请先用历史样本小批量跑。
if(CheckPointer(prevLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID) class="kw">return class="kw">false; if(prevLayer.At(class="num">0).Type()!=defNeuron) { CNeuronProof *temp=prevLayer.At(m_myIndex); if(CheckPointer(temp)==POINTER_INVALID) class="kw">return class="kw">false; prevLayer=temp.getOutputLayer(); if(CheckPointer(prevLayer)==POINTER_INVALID) class="kw">return class="kw">false; } CNeuronBase *prevNeuron, *outputNeuron; class="type">int total=prevLayer.Total(); for(class="type">int i=class="num">0;i<total;i++) { prevNeuron=prevLayer.At(i); if(CheckPointer(prevNeuron)==POINTER_INVALID) class="kw">continue; class="type">class="kw">double prev_gradient=class="num">0; class="type">int start=i-iWindow+iStep; start=(start-start%iStep)/iStep; class="type">class="kw">double stop=(i-i%iStep)/iStep+class="num">1; for(class="type">int out=(class="type">int)fmax(class="num">0,start);out<(class="type">int)fmin(OutputLayer.Total(),stop);out++) { outputNeuron=OutputLayer.At(out); if(CheckPointer(outputNeuron)==POINTER_INVALID) class="kw">continue; prev_gradient+=outputNeuron.getGradient()/iWindow; } prevNeuron.setGradient(prev_gradient); } class=class="str">"cmt">//--- class="kw">return true; } class CNeuronConv : class="kw">public CNeuronProof { class="kw">protected: class="type">class="kw">double param; class=class="str">"cmt">//PReLU param class="kw">virtual class="type">bool feedForward(CLayer *prevLayer); class="kw">virtual class="type">bool calcHiddenGradients(CLayer *&nextLayer); class="kw">virtual class="type">class="kw">double activationFunction(class="type">class="kw">double x); class="kw">virtual class="type">bool updateInputWeights(CLayer *&prevLayer); class="kw">public: CNeuronConv() : param(class="num">0.01) { }; ~CNeuronConv(class="type">void) { }; class=class="str">"cmt">//--- class="kw">virtual class="type">bool calcInputGradients(CLayer *prevLayer) ; class="kw">virtual class="type">bool calcInputGradients(CNeuronBase *prevNeuron, class="type">uint index) ; class="kw">virtual class="type">class="kw">double activationFunctionDerivative(class="type">class="kw">double x); class="kw">virtual class="type">int Type(class="type">void) class="kw">const { class="kw">return defNeuronConv; } }; class="type">bool CNeuronConv::updateInputWeights(CLayer *&prevLayer) {
卷积层权重回传与结构描述类
卷积网络训练时,权重更新依赖前层输出与输出层梯度的累积乘积。下面这段回传逻辑先校验指针有效性,再按窗口遍历连接,累计 delta 后套用学习率 eta 与动量 alpha 修正权重。 若 prevLayer 或 OutputLayer 指针无效直接返回 false,避免空指针崩溃。循环上限为 iWindow,且每次判断 IsStopped() 以便 EA 被终止时及时跳出;单条连接 con 取自 Connections.At(n),指针异常则 continue 跳过。 内层对 OutputLayer.Total() 个神经元求和:prev 取 prevLayer 中第 n*iStep+i 个,out 逆序取输出层神经元,二者任一指针无效就跳过,否则 delta 累加 prev.getOutputVal()*out.getGradient()。最终 con.weight 增加量 = eta*delta(delta 非 0 时)加上 alpha*con.deltaWeight(上一刻增量非 0 时),这是标准带动量的梯度下降。 CLayerDescription 作为层配置载体,继承 CObject,构造时给 type 默认 defNeuron、count=0、window=1、step=1。CNetConvolution 析构直接 delete layers 释放层数组,MT5 里跑自行卷积网时注意析构是否漏掉连接数组,否则可能内存泄漏。外汇与贵金属品种上套这类模型预测,属高风险尝试,样本外表现可能显著衰减。
if(CheckPointer(prevLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID) class="kw">return class="kw">false; CConnection *con; for(class="type">int n=class="num">0; n<iWindow && !IsStopped(); n++) { con=Connections.At(n); if(CheckPointer(con)==POINTER_INVALID) class="kw">continue; class="type">class="kw">double delta=class="num">0; class="type">int total_i=OutputLayer.Total(); CNeuron *prev, *out; for(class="type">int i=class="num">0;i<total_i;i++) { prev=prevLayer.At(n*iStep+i); out=OutputLayer.At(total_i-i-class="num">1); if(CheckPointer(prev)==POINTER_INVALID || CheckPointer(out)==POINTER_INVALID) class="kw">continue; delta+=prev.getOutputVal()*out.getGradient(); } con.weight+=con.deltaWeight=(delta!=class="num">0 ? eta*delta : class="num">0)+(con.deltaWeight!=class="num">0 ? alpha*con.deltaWeight : class="num">0); } class=class="str">"cmt">//--- class="kw">return true; } class CLayerDescription : class="kw">public CObject { class="kw">public: CLayerDescription(class="type">void); ~CLayerDescription(class="type">void){}; class=class="str">"cmt">//--- class="type">int type; class="type">int count; class="type">int window; class="type">int step; }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ CLayerDescription::CLayerDescription(class="type">void) : type(defNeuron), count(class="num">0), window(class="num">1), step(class="num">1) {} class CNetConvolution { class="kw">public: CNetConvolution(CArrayObj *Description); ~CNetConvolution(class="type">void) { class="kw">delete layers; }
◍ 卷积网络的构造与存取接口
把网络结构从描述对象里落地,核心在 CNetConvolution 构造函数:它遍历 CArrayObj 里的每一层描述,为每层 new 出 CLayer,并按下一层神经元数决定当前层输出连接数。若 Description 指针无效或 Total()<=0,直接 return,不抛错但网络为空,调用前务必确认描述数组已填充。
类里暴露的 feedForward / backProp / getResults 是训练与推理三段式;getRecentAverageError 直接回传 recentAverageError,平滑因子由静态量 recentAverageSmoothingFactor 控制,默认训练时误差曲线会更平缓。
Save / Load 用 common=true 写到终端公共目录,可把 error、forecast、time 一并落盘,方便小布这类 AIGC 工具隔日续训。外汇与贵金属行情下用卷积网做特征提取仍属高风险,过拟合可能导致样本外概率显著劣化。
构造循环里那句 desc.count+(i>0 && desc.type==defNeuron ? 1 : 0) 值得盯:非首层神经元层会多分配 1 个偏置节点,漏看这一步容易在手动改网络宽度时算错总参数量。
class="type">bool feedForward( CArrayDouble *inputVals); class="type">void backProp( CArrayDouble *targetVals); class="type">void getResults(CArrayDouble *&resultVals) ; class="type">class="kw">double getRecentAverageError() { class="kw">return recentAverageError; } class="type">bool Save( class="type">class="kw">string file_name, class="type">class="kw">double error, class="type">class="kw">double undefine, class="type">class="kw">double forecast, class="type">class="kw">datetime time, class="type">bool common=true); class="type">bool Load( class="type">class="kw">string file_name, class="type">class="kw">double &error, class="type">class="kw">double &undefine, class="type">class="kw">double &forecast, class="type">class="kw">datetime &time, class="type">bool common=true); class=class="str">"cmt">//--- class="kw">static class="type">class="kw">double recentAverageSmoothingFactor; class="kw">virtual class="type">int Type(class="type">void) class="kw">const { class="kw">return defNetConv; } class="kw">private: CArrayLayer *layers; class="type">class="kw">double recentAverageError; }; CNetConvolution::CNetConvolution(CArrayObj *Description) { if(CheckPointer(Description)==POINTER_INVALID) class="kw">return; class=class="str">"cmt">//--- class="type">int total=Description.Total(); if(total<=class="num">0) class="kw">return; class=class="str">"cmt">//--- layers=new CArrayLayer(); if(CheckPointer(layers)==POINTER_INVALID) class="kw">return; CLayer *temp; CLayerDescription *desc=NULL, *next=NULL, *prev=NULL; CNeuronBase *neuron=NULL; CNeuronProof *neuron_p=NULL; class="type">int output_count=class="num">0; class="type">int temp_count=class="num">0; for(class="type">int i=class="num">0;i<total;i++) { prev=desc; desc=Description.At(i); if((i+class="num">1)<total) { next=Description.At(i+class="num">1); if(CheckPointer(next)==POINTER_INVALID) class="kw">return; } else next=NULL; class="type">int outputs=(next==NULL || next.type!=defNeuron ? class="num">0 : next.count); temp=new CLayer(outputs); for(class="type">int n=class="num">0;n<(desc.count+(i>class="num">0 && desc.type==defNeuron ? class="num">1 : class="num">0));n++) { class="kw">switch(desc.type) { case defNeuron: neuron=new CNeuron(); if(CheckPointer(neuron)==POINTER_INVALID)