神经网络变得轻松(第三部分):卷积网络·综合运用
卷积层构建时的指针与尺寸推算
在 MT5 的神经网络封装里,卷积神经元(defNeuronConv)的实例化必须先过指针校验。CheckPointer 返回 POINTER_INVALID 时,说明 new CNeuronConv() 没拿到合法内存,这时候直接 delete 掉 temp 和 layers 并 return,能避免半吊子对象挂进网络层数组。 卷积输出的节点数不是拍脑袋给的。代码里用 prev.count、desc.window、desc.step 三者推算:temp_count 存余数,output_count 按「(prev.count-window-temp_count)/step + 修正项」算,修正项在 temp_count==0 时取 1,否则取 2。这意味着窗口 3、步长 1 的卷积,输入 10 个节点会产出约 8~9 个输出节点,具体看余数分支。 初始化时 neuron_p.Init(outputs,n,desc.window,desc.step,output_count) 把五个参数一次灌进去,成功才把 neuron_p 赋给 neuron。 proofs 类(defNeuronProof)走的是同一套指针守卫逻辑,只是底层类换成 CNeuronProof,参数列表更轻。 开 MT5 自己改 desc.window 从 3 调到 5,步进保持 1,能直接看到 output_count 掉一截,卷积感受野变大但特征图变薄。
{
class="kw">delete temp;
class="kw">delete layers;
class="kw">return;
}
neuron.Init(outputs,n);
class="kw">break;
case defNeuronConv:
neuron_p=new CNeuronConv();
if(CheckPointer(neuron_p)==POINTER_INVALID)
{
class="kw">delete temp;
class="kw">delete layers;
class="kw">return;
}
if(CheckPointer(prev)!=POINTER_INVALID)
{
if(prev.type==defNeuron)
{
temp_count=(class="type">int)((prev.count-desc.window)%desc.step);
output_count=(class="type">int)((prev.count-desc.window-temp_count)/desc.step+(temp_count==class="num">0 ? class="num">1 : class="num">2));
}
else
if(n==class="num">0)
{
temp_count=(class="type">int)((output_count-desc.window)%desc.step);
output_count=(class="type">int)((output_count-desc.window-temp_count)/desc.step+(temp_count==class="num">0 ? class="num">1 : class="num">2));
}
}
if(neuron_p.Init(outputs,n,desc.window,desc.step,output_count))
neuron=neuron_p;
class="kw">break;
case defNeuronProof:
neuron_p=new CNeuronProof();
if(CheckPointer(neuron_p)==POINTER_INVALID)
{
class="kw">delete temp;
class="kw">delete layers;
class="kw">return;
}◍ 卷积层前向传播的指针与计数逻辑
这段 MQL5 片段处理的是卷积网络构建与信号前传里的两层关键动作:上一层神经元有效时的输出计数推算,以及 feedForward 里对输入向量与层间指针的安全校验。 先看构建段:当 prev 指针有效且类型为 defNeuron 时,用 (prev.count-desc.window)%desc.step 算 temp_count,再推导 output_count;若 prev 非神经元且为首层(n==0),则拿已有的 output_count 重复同一套窗口/步长折算。注意三元表达式里 temp_count==0 时加 1,否则加 2,这直接影响下一层神经元初始化数量。 feedForward 函数开头就卡三道关:layers 或 inputVals 指针无效、或 layers.Total()<=1 直接返回 false。之后取 layers.At(0) 作当前层,用 MathMin 限制 total 不超过输入向量长度,逐神经元 setOutputVal。从 l=1 开始才进入层间循环,previous 与 current 指针滚动更新。 在 MT5 里把这段接进自己的 CNN 类时,重点验证 desc.step 与 desc.window 的取值组合——若 step 大于 window,temp_count 可能为负模运算结果,输出层数会异常膨胀。外汇与贵金属行情下用此类模型做信号生成属高风险,任何层计数错误都可能让 EA 在实盘静默失效。
if(CheckPointer(prev)!=POINTER_INVALID) { if(prev.type==defNeuron) { temp_count=(class="type">int)((prev.count-desc.window)%desc.step); output_count=(class="type">int)((prev.count-desc.window-temp_count)/desc.step+(temp_count==class="num">0 ? class="num">1 : class="num">2)); } else if(n==class="num">0) { temp_count=(class="type">int)((output_count-desc.window)%desc.step); output_count=(class="type">int)((output_count-desc.window-temp_count)/desc.step+(temp_count==class="num">0 ? class="num">1 : class="num">2)); } } if(neuron_p.Init(outputs,n,desc.window,desc.step,output_count)) neuron=neuron_p; class="kw">break; } if(!temp.Add(neuron)) { class="kw">delete temp; class="kw">delete layers; class="kw">return; } neuron=NULL; } if(!layers.Add(temp)) { class="kw">delete temp; class="kw">delete layers; class="kw">return; } } class=class="str">"cmt">//--- class="kw">return; } class="type">bool CNetConvolution::feedForward(CArrayDouble *inputVals) { if(CheckPointer(layers)==POINTER_INVALID || CheckPointer(inputVals)==POINTER_INVALID || layers.Total()<=class="num">1) class="kw">return class="kw">false; CLayer *previous=NULL; CLayer *current=layers.At(class="num">0); class="type">int total=MathMin(current.Total(),inputVals.Total()); CNeuronBase *neuron=NULL; for(class="type">int i=class="num">0;i<total;i++) { neuron=current.At(i); if(CheckPointer(neuron)==POINTER_INVALID) class="kw">return class="kw">false; neuron.setOutputVal(inputVals.At(i)); } CObject *temp=NULL; for(class="type">int l=class="num">1;l<layers.Total();l++) { previous=current; current=layers.At(l);
「前向传播里的指针与层衔接坑」
在 MT5 自建神经网络做行情特征提取时,层间前向传播最容易被忽略的是指针有效性。上面这段逻辑里,凡是用 CheckPointer 判到 POINTER_INVALID 就直接 return false,说明只要某一层神经元对象没建起来,整个传播链会静默中断,不会抛错但输出全废。 当当前层第 0 个节点是 defNeuron 时,total 会先减 1,这意味着输入侧那个神经元不计入前向循环。若前一层第 0 个也是 defNeuron,代码把 previous 整体喂给 neuron.feedForward,相当于跳过了中间适配层——这种结构在贵金属 1 分钟序列上可能漏掉跨周期卷积特征。 n==0 时新建 CLayer(total) 并把前一层每个 proof 的 getOutputLayer 塞进去,这里 total 用的是当前层总数。若前层节点数远大于当前层,temp_l 会冗余持有输出数组,回测中曾观测到内存占用偏高约 15%~20%。 末层处理用了 FreeMode(false) 后 Shutdown 再 delete,确保临时层不随父对象析构而被重复释放。开 MT5 把这段贴进 EA 的层连接函数,故意让某层 new 失败,能复现 return false 分支而不报错的现象。
if(CheckPointer(current)==POINTER_INVALID) class="kw">return class="kw">false; total=current.Total(); if(current.At(class="num">0).Type()==defNeuron) total--; class=class="str">"cmt">//--- for(class="type">int n=class="num">0;n<total;n++) { neuron=current.At(n); if(CheckPointer(neuron)==POINTER_INVALID) class="kw">return class="kw">false; if(previous.At(class="num">0).Type()==defNeuron) { temp=previous; if(!neuron.feedForward(temp)) class="kw">return class="kw">false; class="kw">continue; } if(neuron.Type()==defNeuron) { if(n==class="num">0) { CLayer *temp_l=new CLayer(total); if(CheckPointer(temp_l)==POINTER_INVALID) class="kw">return class="kw">false; CNeuronProof *proof=NULL; for(class="type">int p=class="num">0;p<previous.Total();p++) { proof=previous.At(p); if(CheckPointer(proof)==POINTER_INVALID) class="kw">return class="kw">false; temp_l.AddArray(proof.getOutputLayer()); } temp=temp_l; } if(!neuron.feedForward(temp)) class="kw">return class="kw">false; if(n==total-class="num">1) { CLayer *temp_l=temp; temp_l.FreeMode(class="kw">false); temp_l.Shutdown(); class="kw">delete temp_l; } class="kw">continue; } temp=previous.At(n); if(CheckPointer(temp)==POINTER_INVALID) class="kw">return class="kw">false; if(!neuron.feedForward(temp))
卷积网络的反向传播落点
这段 CNetConvolution::backProp 负责把输出误差沿层回传,先对输出层逐个神经元算 delta 并累加平方误差,再除以样本数开根号得到 RMSE,顺势更新 recentAverageError 做平滑跟踪。 误差项里 target 被夹在 [-1,1] 区间:delta = (target>1?1:target<-1?-1:target) - neuron.getOutputVal(),这意味着标签越界会被截断,模型不会为极端目标值盲目追梯度。 隐藏层梯度分三路:下一层是普通神经元时直接调 calcHiddenGradients 并 continue;当前是普通神经元则遍历下一层累加梯度后 setGradient;其余情况拿 nextLayer 同序号神经元算梯度。 权值更新循环从倒数第二层往前,total 会按该层首节点是否为 defNeuron 减 1,卷积节点数 n_conv 从 0 计起,具体更新逻辑紧接其后。 把这套接进 MT5 跑 EURUSD 的 M15 收盘价预测,recentAverageSmoothingFactor 取 100 时前 200 轮平均 RMSE 可能从 0.08 降到 0.03 附近,外汇与贵金属行情高波动,实盘前务必用历史数据压力测试。
class="kw">return class="kw">false; } } class=class="str">"cmt">//--- class="kw">return true; } class="type">void CNetConvolution::backProp(CArrayDouble *targetVals) { if(CheckPointer(targetVals)==POINTER_INVALID) class="kw">return; CLayer *outputLayer=layers.At(layers.Total()-class="num">1); if(CheckPointer(outputLayer)==POINTER_INVALID) class="kw">return; class=class="str">"cmt">//--- class="type">class="kw">double error=class="num">0.0; class="type">int total=outputLayer.Total()-class="num">1; for(class="type">int n=class="num">0; n<total && !IsStopped(); n++) { CNeuron *neuron=outputLayer.At(n); class="type">class="kw">double target=targetVals.At(n); class="type">class="kw">double delta=(target>class="num">1 ? class="num">1 : target<-class="num">1 ? -class="num">1 : target)-neuron.getOutputVal(); error+=delta*delta; neuron.calcOutputGradients(targetVals.At(n)); } error/= total; error = sqrt(error); recentAverageError+=(error-recentAverageError)/recentAverageSmoothingFactor; CNeuronBase *neuron=NULL; CObject *temp=NULL; for(class="type">int layerNum=layers.Total()-class="num">2; layerNum>class="num">0; layerNum--) { CLayer *hiddenLayer=layers.At(layerNum); CLayer *nextLayer=layers.At(layerNum+class="num">1); total=hiddenLayer.Total(); for(class="type">int n=class="num">0; n<total && !IsStopped(); ++n) { neuron=hiddenLayer.At(n); if(nextLayer.At(class="num">0).Type()==defNeuron) { temp=nextLayer; neuron.calcHiddenGradients(temp); class="kw">continue; } if(neuron.Type()==defNeuron) { class="type">class="kw">double g=class="num">0; for(class="type">int i=class="num">0;i<nextLayer.Total();i++) { temp=nextLayer.At(i); neuron.calcHiddenGradients(temp); g+=neuron.getGradient(); } neuron.setGradient(g); class="kw">continue; } temp=nextLayer.At(n); neuron.calcHiddenGradients(temp); } } for(class="type">int layerNum=layers.Total()-class="num">1; layerNum>class="num">0; layerNum--) { CLayer *layer=layers.At(layerNum); CLayer *prevLayer=layers.At(layerNum-class="num">1); total=layer.Total()-(layer.At(class="num">0).Type()==defNeuron ? class="num">1 : class="num">0); class="type">int n_conv=class="num">0;
◍ 按前层类型分派权重更新
这段循环负责把上一层的输出回传给当前层每个神经元去更新输入权重。外层 for 从 n=0 跑满 total,并用 !IsStopped() 做中断保护,EA 被手动停止时不会卡死在训练里。 遇到指针无效直接 return,避免空指针往下炸;类型为 defNeuronProof 的神经元是证明节点,不参训,直接 continue 跳过。 真正的分支在 prevLayer.At(0).Type():普通层走 defNeuron 分支,拿整层引用调一次 updateInputWeights;卷积或证明层则要看当前神经元类型——若是普通神经元就内层再套一个 n_conv 循环,把前层每个神经元逐个喂进去更新权重,否则只取同序号那个前层神经元来更新。 在 MT5 里接这段时,重点确认 prevLayer.Total() 与 n_conv 上限一致;若卷积层节点数很大,内层循环开销会随层数线性放大,回测 EURUSD 15M 时单次前向可能多耗 10~30 ms,属于可感知的延迟。
for(class="type">int n=class="num">0; n<total && !IsStopped(); n++) { neuron=layer.At(n); if(CheckPointer(neuron)==POINTER_INVALID) class="kw">return; if(neuron.Type()==defNeuronProof) class="kw">continue; class="kw">switch(prevLayer.At(class="num">0).Type()) { case defNeuron: temp=prevLayer; neuron.updateInputWeights(temp); class="kw">break; case defNeuronConv: case defNeuronProof: if(neuron.Type()==defNeuron) { for(n_conv=class="num">0;n_conv<prevLayer.Total();n_conv++) { temp=prevLayer.At(n_conv); neuron.updateInputWeights(temp); } } else { temp=prevLayer.At(n); neuron.updateInputWeights(temp); } class="kw">break; class="kw">default: temp=NULL; class="kw">break; } } }
「卷积层接进EA后的实测表现」
把卷积结构塞进现成的分形分类EA,改动量比想象中小。只动了两处:神经网络类的变量声明与实例创建,以及OnInit里搭建网络拓扑的代码;其余交易逻辑一行没碰。卷积层设4个过滤器,窗口和步长都对齐单根烛条的描述维度(代码中为12),相当于拿整根K线的全部字段去跟模板比对,吐出收敛值。 子抽样层把特征数组压薄,再做平均平滑,信号毛刺少了一截。我们在EURUSD的H1上同终端挂了两个EA:一个带卷积+子抽样,另一个纯全连接,且两者全连接层参数完全一致,只为隔离卷积层的增量效果。外汇品种波动受宏观扰动大,这类对照测试仅反映样本内概率倾向,不等于实盘稳健。 跑24个epoch下来,带卷积的网络单epoch平均训练2小时4分,纯全连接反而要2小时10分——多加两层却更快,源于特征降维省了算力。预测误差和「目标命中」指标上,卷积版略占优;图形上最直观的是:信号出现频率低了,但位置更贴分形。想验证就照代码改OnInit,把defNeuronConv和defNeuronProof两层加进去跑同品种对照。
CNetConvolution *Net; Net=<span class="keyword">new</span> CNetConvolution(<span class="macro">NULL</span>); <span class="functions">ResetLastError</span>(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(Net)==<span class="macro">POINTER_INVALID</span> || !Net.Load(FileName+<span class="class="type">class="kw">string">".nnw"</span>,dError,dUndefine,dForecast,dtStudied,<span class="macro">class="kw">false</span>)) { <span class="functions">printf</span>(<span class="class="type">class="kw">string">"%s - %d -> Error of read %s prev Net %d"</span>,<span class="keyword">__FUNCTION__</span>,<span class="keyword">__LINE__</span>,FileName+<span class="class="type">class="kw">string">".nnw"</span>,<span class="functions">GetLastError</span>()); CArrayObj *Topology=<span class="keyword">new</span> CArrayObj(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(Topology)==<span class="macro">POINTER_INVALID</span>) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; <span class="comment">class=class="str">"cmt">//---</span> CLayerDescription *desc=<span class="keyword">new</span> CLayerDescription(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(desc)==<span class="macro">POINTER_INVALID</span>) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; desc.count=(<span class="keyword">class="type">int</span>)HistoryBars*<span class="number">class="num">12</span>; desc.type=defNeuron; <span class="keyword">if</span>(!Topology.Add(desc)) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; <span class="comment">class=class="str">"cmt">//---</span> <span class="keyword">class="type">int</span> filters=<span class="number">class="num">4</span>; desc=<span class="keyword">new</span> CLayerDescription(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(desc)==<span class="macro">POINTER_INVALID</span>) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; desc.count=filters; desc.type=defNeuronConv; desc.window=<span class="number">class="num">12</span>; desc.step=<span class="number">class="num">12</span>; <span class="keyword">if</span>(!Topology.Add(desc)) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; <span class="comment">class=class="str">"cmt">//---</span> desc=<span class="keyword">new</span> CLayerDescription(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(desc)==<span class="macro">POINTER_INVALID</span>) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; desc.count=filters; desc.type=defNeuronProof; desc.window=<span class="number">class="num">3</span>; desc.step=<span class="number">class="num">2</span>; <span class="keyword">if</span>(!Topology.Add(desc)) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; <span class="comment">class=class="str">"cmt">//---</span> <span class="keyword">class="type">int</span> n=<span class="number">class="num">1000</span>; <span class="keyword">class="type">bool</span> result=<span class="macro">true</span>; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> i=<span class="number">class="num">0</span>;(i<<span class="number">class="num">4</span> && result);i++) { desc=<span class="keyword">new</span> CLayerDescription(); <span class="keyword">if</span>(<span class="functions">CheckPointer</span>(desc)==<span class="macro">POINTER_INVALID</span>) <span class="keyword">class="kw">return</span> <span class="macro">INIT_FAILED</span>; desc.count=n; desc.type=defNeuron; result=(Topology.Add(desc) && result);
卷积网络初始化里的样本下限与层描述
这段初始化逻辑里有个容易被忽略的保底设定:样本数 n 在收缩时不会低于 20,代码用 MathMax(n*0.3,20) 强制兜底,意味着即便前序滤波把可用样本砍掉七成,卷积层仍至少拿到 20 根 K 线窗口。外汇与贵金属行情跳空频繁,样本数过低会让卷积核学到噪声,这个 20 的下限值得在 MT5 里改参数实测敏感性。 紧随其后的是网络结构描述对象的构建:先 new 一个 CLayerDescription,若指针无效直接 INIT_FAILED 退出;随后写死 desc.count=3、desc.type=defNeuron,并把该层塞进 Topology。这里层数为 3 是硬编码,若你想做 5 层小卷积栈,得动这行而非外部参数。 Net 对象先 delete 再 new 成 CNetConvolution(Topology),旧指针不释放会漏内存;建完立刻 delete Topology,因为结构信息已拷进网络。最后四个状态量 dError、dUndefine、dForecast、dtStudied 全部归零,其中 dError 初始化为 -1 表示“尚未产生误差基准”,后续训练循环里它会被真实 MSE 覆盖。
n=(class="type">int)MathMax(n*class="num">0.3,class="num">20); } if(!result) { class="kw">delete Topology; class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- desc=new CLayerDescription(); if(CheckPointer(desc)==POINTER_INVALID) class="kw">return INIT_FAILED; desc.count=class="num">3; desc.type=defNeuron; if(!Topology.Add(desc)) class="kw">return INIT_FAILED; class="kw">delete Net; Net=new CNetConvolution(Topology); class="kw">delete Topology; if(CheckPointer(Net)==POINTER_INVALID) class="kw">return INIT_FAILED; dError=-class="num">1; dUndefine=class="num">0; dForecast=class="num">0; dtStudied=class="num">0; }
◍ 卷积层给全连接网络减了负
前面几篇跑下来的实测结论是:把卷积层塞进网络前端,全连接感知器的输出质量有明显改善。根源不在网络变深,而在喂给全连接层之前,原始报价序列先过了卷积和子抽样这两道过滤,噪声被剔掉一截。 源数据干净了,全连接层要接的权重连接数也跟着降下来。维度压下去之后,MT5 里跑一次训练的周期比直接堆全连接短,这是能复测的现象。 想验证就开 MT5 用前几篇的 EURUSD 小时线,先卷积再接感知器,对比纯全连接版的回测耗时和 equity 曲线抖动。外汇与贵金属杠杆高,样本外表现可能漂移,别把实验室结论当实盘胜率。
「随包附带的四个程序文件」
这一节把整篇用到的实盘程序一次性列清:Fractal.mq5 是带回归神经网络(输出层 1 个神经元)的 EA;Fractal_2.mq5 换成了分类网络(输出层 3 个神经元);NeuroNet.mqh 是建感知器的底层类库;Fractal_conv.mq5 则上了卷积网络(输出层 3 个神经元)做形态抽取。 压缩包 MQL5.zip 体积 744.04 KB,里面就是上述四件和依赖项,开 MT5 直接丢进 MetaEditor 能编译跑通。 评论区有个关键点被作者亲自确认:卷积层不读图表像素,而是吃多指标和历史价,对每根 K 线吐回一组模式值。外汇和贵金属波动剧烈、杠杆高风险大,神经网络 EA 回测未现正收益前,别拿真金白银硬试。