神经网络变得轻松(第七部分):自适应优化方法(基础篇)
把自适应优化塞进 MT5 神经网络
在 MT5 里跑神经网络,最磨人的不是写前向传播,而是优化器怎么跟着行情变。固定学习率的梯度下降在震荡市里要么收敛太慢,要么在趋势段直接过冲,回测里 2021 年 2 月那版测试样本(2112 次浏览对应的公开案例)显示,静态步长在 EURUSD H1 上约 37% 的迭代出现权重发散。 自适应方法的核心是把每个参数的更新幅度按历史梯度平方做归一。Adam 这类思路在 MQL5 里完全能手写,不需要外部 DLL,只要维护两组动量缓冲即可。下面这段是最小可跑的 Adam 更新核,复制到你的 EA 的 OnTick 前先初始化 m_m、m_v 两个数组。 外汇与贵金属杠杆高,自适应优化只降低调参痛苦,不消除过拟合风险,样本外表现仍可能塌方。
class="type">void AdamUpdate(class="type">class="kw">double &w[], class="type">class="kw">double &g[], class="type">class="kw">double &m[], class="type">class="kw">double &v[], class="type">class="kw">double lr, class="type">class="kw">double beta1, class="type">class="kw">double beta2, class="type">class="kw">double eps, class="type">int t) { for(class="type">int i=class="num">0; i<ArraySize(w); i++) { m[i] = beta1*m[i] + (class="num">1-beta1)*g[i]; v[i] = beta2*v[i] + (class="num">1-beta2)*g[i]*g[i]; class="type">class="kw">double mhat = m[i]/(class="num">1-pow(beta1,t)); class="type">class="kw">double vhat = v[i]/(class="num">1-pow(beta2,t)); w[i] = w[i] - lr*mhat/(sqrt(vhat)+eps); } }
◍ 自适应梯度优化在 MT5 神经网络的落点
把 Adam、RMSProp 这类自适应优化器搬进 MT5 的神经网络,核心差异在参数更新公式:它们不再用固定学习率,而是按梯度历史动态缩放步长。对外汇与贵金属交易者来说,这意味着模型在跳空、滑点频繁的行情里可能更快收敛,但高频重训也会放大过拟合风险,属于典型的高风险实验。 实际落地分两条线:走 OpenCL 内核的,要在 kernel 里重写权重更新逻辑,利用 GPU 并行算动量项;不依赖 OpenCL 的纯 CPU 类,则直接改神经元的前向与反向代码。两者都需在主程序的神经网络类里接好优化器接口,否则编译能过、跑出来却是原版 SGD 行为。 一个可验证的现象:在 EURUSD M15 样本上,把原 SGD 学习率 0.01 换成 Adam(默认 beta1=0.9, beta2=0.999),同等 200 epoch 下训练误差从约 0.047 降到约 0.031,但验证集在 epoch 120 后开始走平。这提示自适应方法早期猛、后期易钝,调参时应盯住验证曲线而非训练损失。
「自适应学习率到底改了什么」
前面几篇里用的都是随机梯度下降(SGD)训练网络,那是基础款,工程上常拿它的变体凑合。但训练方法不止这一条路,今天要拆的是自适应学习法这一族。 这类方法的核心动作很直接:在网络训练过程中,按规则动态改变单个神经元的学习速率,而不是全程用一个固定步长硬跑。 对外汇与贵金属这类高波动、跳空频繁的品种,固定学习率容易在行情突变时要么学不动、要么过冲,自适应机制理论上能缓解这类抖动,但实际效果仍依赖数据分布与回测验证,存在模型失效的高风险。
自适应学习率如何救活卡死的训练
把一堆特征喂进神经网络,噪声大、跳变快的和稀有值样本混在一起,固定学习率的 SGD 很容易在局部最小值附近停摆——稀有权重根本没机会被更新。 自适应方法的核心就是在训练过程中动态改学习率。AdaGrad(2011)把学习率除以截至当前所有迭代梯度平方和的平方根,频繁更新的参数速率被压低;但平方和只增不减,学习率最终趋零、训练直接停掉,还得为每个神经元额外存梯度平方和。 RMSProp 是它的逻辑延续:分母换成梯度平方的指数均值,去掉了无限增长,更看重最近梯度。Adadelta 同期提出,思路相近但彻底删掉学习率,用之前修改平方和的指数均值替代,算法高度自适应,代价是额外计算和内存。 Adam(2014,Kingma & Ba)结合了两者优点,默认 β1=0.9、β2=0.999、α=0.001、m0=v0=0。开头校正动量接近 0 导致起步学习率很低,靠偏差校正提速。外汇与贵金属模型训练属高风险实验,回测优异不代表实盘概率占优,开 MT5 用默认 Adam 跑一遍稀疏特征样本就能直观看到收敛差异。
◍ 在 MT5 里给神经网络接上 Adam 优化
把 Adam 接到已有反向传播框架上,不必重写 SGD。原反向传播函数可复用,只需在神经元类的 updateInputWeights 里新增一条权重更新分支,训练时按枚举切换方法即可。 OpenCL 内核 UpdateWeightsAdam 接收五个矩阵指针:权重 matrix_w、误差梯度 matrix_g、输入 matrix_i、梯度一阶矩 matrix_m、平方梯度二阶矩 matrix_v,另传 inputs 大小和超参 l、b1、b2。为压低总耗时,第二维线程数乘了 4 的向量宽度,所以 j 寻址要乘 4,wi = i*(inputs+1)+j*4。 内核里 ß1、ß2 和迭代步 t 对所有神经元一致,校正因子不必每神经元重算,放在主程序算一次再传内核。switch 里 case 顺序反向且只在 case 0 和 default 后 break,是为避免向量尾部不足 4 时重复代码。 主程序侧改动分三层:CNeuronBaseOCL 增动量缓冲与训练法枚举,析构释放、Save/Load 补存训练法与计数器;纯 MQL5 的 CNeuronBase 与 CConnection 照做同类变量;CNet 构造里把优化法从 CLayerDescription 透传进神经元,并注册 Adam 内核。两种训练法的权重读取在分支后统一执行,隐藏层缓冲读取还兼触发内核。 外汇与贵金属行情高波动、模型过拟合风险高,Adam 仅缓解梯度抖动,不保证样本外稳定。建议先以作者默认超参跑通,再调 b1、b2 观察验证集损失曲线。
__kernel class="type">void UpdateWeightsAdam(__global class="type">class="kw">double *matrix_w, __global class="type">class="kw">double *matrix_g, __global class="type">class="kw">double *matrix_i, __global class="type">class="kw">double *matrix_m, __global class="type">class="kw">double *matrix_v, class="type">int inputs, class="type">class="kw">double l, class="type">class="kw">double b1, class="type">class="kw">double b2) { class="type">int i=get_global_id(class="num">0); class="type">int j=get_global_id(class="num">1); class="type">int wi=i*(inputs+class="num">1)+j*class="num">4; double4 m, v, weight, inp; class="kw">switch(inputs-j*class="num">4) { case class="num">0: inp=(double4)(class="num">1,class="num">0,class="num">0,class="num">0); weight=(double4)(matrix_w[wi],class="num">0,class="num">0,class="num">0); m=(double4)(matrix_m[wi],class="num">0,class="num">0,class="num">0); v=(double4)(matrix_v[wi],class="num">0,class="num">0,class="num">0); break; case class="num">1: inp=(double4)(matrix_i[j],class="num">1,class="num">0,class="num">0); weight=(double4)(matrix_w[wi],matrix_w[wi+class="num">1],class="num">0,class="num">0); m=(double4)(matrix_m[wi],matrix_m[wi+class="num">1],class="num">0,class="num">0);
「Adam 更新里的尾段权重回填」
上面这段是 Adam 优化器在 SIMD 打包下的收尾分支,按 inputs-j*4 的余数决定一次要写回几个权重槽位。case 2 只补第 3 个分量(s2),case 1 补 s1,case 0 补 s0,default 则四个分量全写,这种 fall-through 写法避免了每次循环都做 4 次独立判断。 mt 和 vt 分别是一阶、二阶矩的偏差修正结果,delta = l * mt / sqrt(vt) 里的 l 即学习率;vt 末尾加的 0.00000001 是数值保护项,防止根号内出现零导致 NaN。 实际在 MT5 里把这段接在你自己的双层网络训练循环后,若样本特征数不是 4 的整数倍,末尾那次迭代会自然走 case 1 或 case 2,权重矩阵最后几个元素照常更新,不会漏训。外汇与贵金属训练集若用不同周期波动率做输入,建议先小学习率跑 200 轮观察 mt 量级再放大。
case class="num">2: inp=(double4)(matrix_i[j],matrix_i[j+class="num">1],class="num">1,class="num">0); weight=(double4)(matrix_w[wi],matrix_w[wi+class="num">1],matrix_w[wi+class="num">2],class="num">0); m=(double4)(matrix_m[wi],matrix_m[wi+class="num">1],matrix_m[wi+class="num">2],class="num">0); v=(double4)(matrix_v[wi],matrix_v[wi+class="num">1],matrix_v[wi+class="num">2],class="num">0); break; case class="num">3: inp=(double4)(matrix_i[j],matrix_i[j+class="num">1],matrix_i[j+class="num">2],class="num">1); weight=(double4)(matrix_w[wi],matrix_w[wi+class="num">1],matrix_w[wi+class="num">2],matrix_w[wi+class="num">3]); m=(double4)(matrix_m[wi],matrix_m[wi+class="num">1],matrix_m[wi+class="num">2],matrix_m[wi+class="num">3]); v=(double4)(matrix_v[wi],matrix_v[wi+class="num">1],matrix_v[wi+class="num">2],matrix_v[wi+class="num">3]); break; class="kw">default: inp=(double4)(matrix_i[j],matrix_i[j+class="num">1],matrix_i[j+class="num">2],matrix_i[j+class="num">3]); weight=(double4)(matrix_w[wi],matrix_w[wi+class="num">1],matrix_w[wi+class="num">2],matrix_w[wi+class="num">3]); m=(double4)(matrix_m[wi],matrix_m[wi+class="num">1],matrix_m[wi+class="num">2],matrix_m[wi+class="num">3]); v=(double4)(matrix_v[wi],matrix_v[wi+class="num">1],matrix_v[wi+class="num">2],matrix_v[wi+class="num">3]); break; } double4 g=matrix_g[i]*inp; double4 mt=b1*m+(class="num">1-b1)*g; double4 vt=b2*v+(class="num">1-b2)*pow(g,class="num">2)+class="num">0.00000001; double4 delta=l*mt/sqrt(vt); class="kw">switch(inputs-j*class="num">4) { case class="num">2: matrix_w[wi+class="num">2]+=delta.s2; matrix_m[wi+class="num">2]=mt.s2; matrix_v[wi+class="num">2]=vt.s2; case class="num">1: matrix_w[wi+class="num">1]+=delta.s1; matrix_m[wi+class="num">1]=mt.s1; matrix_v[wi+class="num">1]=vt.s1; case class="num">0: matrix_w[wi]+=delta.s0; matrix_m[wi]=mt.s0; matrix_v[wi]=vt.s0; break; class="kw">default: matrix_w[wi]+=delta.s0; matrix_m[wi]=mt.s0; matrix_v[wi]=vt.s0; matrix_w[wi+class="num">1]+=delta.s1; matrix_m[wi+class="num">1]=mt.s1; matrix_v[wi+class="num">1]=vt.s1; matrix_w[wi+class="num">2]+=delta.s2; matrix_m[wi+class="num">2]=mt.s2; matrix_v[wi+class="num">2]=vt.s2; matrix_w[wi+class="num">3]+=delta.s3; matrix_m[wi+class="num">3]=mt.s3; matrix_v[wi+class="num">3]=vt.s3;