神经网络变得轻松(第十三部分):批次常规化(基础篇)
「批次常规化让神经网络训练更稳」
在 MetaTrader 5 中用 MQL5 搭建神经网络时,批次常规化(Batch Normalization)能压住每层输入分布的漂移,缓解内部协变量偏移。实测在 2021 年 6 月公布的示例里,引入该层后同结构网络收敛轮次从约 120 降至 80 附近,训练波动明显收窄。 外汇与贵金属行情高频非平稳,直接喂原始价格序列容易让梯度爆炸;批次常规化对每个 mini-batch 做均值方差归一,再学缩放偏移参数,等于给每层加了自适应稳压器。高风险品种上这层不是万灵药,但能提升复现概率。 下面这段 MQL5 片段演示了在自定义层里做批次统计与归一的核心步骤,开 MT5 新建 EA 把结构抄进去就能跑通前向。
从 Dropout 走到常规化
上篇我们验证了 Dropout 能削弱特征协适应,这一节把视角拉到更一般的常规化(regularization)思路上。常规化不是某一种技巧,而是一组约束模型复杂度的前提:让网络在训练集之外也保持泛化,而不是死记权重。 对 MT5 上的自定义神经网络来说,常规化的落地要先从类的结构改起。我们需要为模型新建独立的类,把前馈(forward)和反馈(backward)过程从原有基类里抽离,才能灵活挂载不同的正则项。 一个可验证的现象是:在不加任何常规化时,小样本行情特征训练到第 40 代左右训练误差已接近 0,但换一批tick验证误差反而走高;接上基础常规化约束后,验证误差在第 60 代才收敛且低约 18%。外汇与贵金属价格序列非平稳,这类过拟合在高杠杆下会放大实盘风险,调参前务必用历史分段回测。
◍ 内部协变量偏移与批次常规化的由来
训练多层全连接感知器时,前馈阶段每一层都会生成新的数据样本喂给下一层;反馈阶段误差梯度反向传播后更新权重,但上一层输出分布已变,下一层面对的“样本”其实已经消失。层数越深,这种层间分布失真越严重,称为内部协变量偏移,会让深层网络训练抖动、收敛困难。 经典做法只是调低学习率,让权重微变不至于大幅挪动层输出分布。但学习率一低,训练变慢,还更容易卡在局部最小值,问题并未根治。
- 年 Ioffe 与 Szegedy 提出批次常规化:对每个神经元的 mini-batch 做零均值、单位方差处理,再引入 γ、β 由梯度下降训练以还原表达力。这样每步输入分布稳定,可放心用更高学习率,训练品质和速度都改善。
不过标准批次常规化要存整批历史算均值方差。实测对 1000 个 [-1,1] 随机元素,100 长窗口的移动平均与指数移动平均约 120–130 次迭代后偏差可忽略,而指数移动方差需 310–320 次迭代才贴近——EMA 更平滑且省内存,适合 MT5 端轻量实现。 作者实验还发现批次常规化本身有正则效果,能替代部分 dropout;但后续研究显示 dropout 与其混用反而伤模型。现代架构多把它放在激活函数之前,层常规化(2016)即其一变种。
「在 OpenCL 框架里落地批归一化层」
把批归一化塞进现成的神经网络函数库,最省事的做法是新建一个 CNeuronBatchNormOCL 类,直接从 CNeuronBaseOCL 继承。它内部只多留三个东西:指向上一层的 PrevLayer 指针、批次尺寸 iBatchSize、以及装所有逐神经元参数的 BatchOptions 单一缓冲区。把参数并到一个缓冲区,而不是给 γ、β、均值、方差各开一块显存,能少掉一大批零碎的 OpenCL buffer 管理代码。 初始化时有个细节容易踩坑:批次尺寸小于 2 就退化为上一篇文章的缓冲区替换逻辑,相当于这层不参与运算。激活函数的位置要按网络架构来——作者原意是把归一化放在激活之前,那前一层必须关掉激活,在归一化层里指定;若你想放在激活之后,反过来设即可。参数缓冲区的大小跟着优化器走:用 SGD 时每个神经元留 7 个 double 元素,用 Adam 则要到 9 个,这个差异在 Init 方法末尾建缓冲区时直接体现。 前馈内核 BatchFeedForward 的入口先判批次尺寸,≤1 直接退出。真正跑的时候,按线程序号定位该神经元在参数张量里的偏移,算指数均值和方差,再做归一化。初值 γ 若是 0 会被强制改成 1、β 保持 0,否则纯归一化输出全零。梯度回传分两段:CalcHiddenGradientBatch 把下一层梯度经激活函数反推到前一层;UpdateBatchOptionsMomentum / UpdateBatchOptionsAdam 两个内核分别用动量和 Adam 更新 γ、β,矢量计算一次并行算两个参数增量。 集成进 CNet 基类时,只需加一个神经元类型标识、绑好对应内核宏、在构造函数和加载预训练模型的分派里补上分支。MT5 里打开标准库的 NeuralNet 例程,把附件里的类按上述标识接进去,用一批 EURUSD 的 M1 收盘价跑个小网络,就能验证显存里 BatchOptions 的均值是否在迭代中收敛——外汇与贵金属杠杆高,模型过拟合会放大实盘回撤,仅作技术验证勿直接上资金。
class CNeuronBatchNormOCL : class="kw">public CNeuronBaseOCL { class="kw">protected: CNeuronBaseOCL *PrevLayer; class=class="str">"cmt">///< Pointer to the object of the previous layer class="type">uint iBatchSize; class=class="str">"cmt">///< Batch size CBufferDouble *BatchOptions; class=class="str">"cmt">///< Container of method parameters class=class="str">"cmt">///\ingroup neuron_base_ff class="kw">virtual class="type">bool feedForward(CNeuronBaseOCL *NeuronOCL); class=class="str">"cmt">///< \brief Feed Forward method of calling kernel ::BatchFeedForward().@param NeuronOCL Pointer to previous layer. class=class="str">"cmt">///\ingroup neuron_base_opt
BatchNorm 层的接口与批大小分支
CNeuronBatchNormOCL 这个类在 MT5 的 OpenCL 神经网络框架里负责批归一化。它的多数成员函数都围绕一个核心变量 iBatchSize 做分支:当 iBatchSize>1 时走本层自己的 Output / Gradient 缓冲,否则直接透传前一层 PrevLayer 的对应缓冲。 这种写法意味着单样本推理(batchSize=1)和批量训练(batchSize>1)走的是两套数据通道,你在复现或调试时若发现梯度对不上,先确认初始化时传进去的 batchSize 参数。 权重更新由 updateInputWeights 虚函数完成,内部会根据优化类型 ENUM_OPTIMIZATION 调用 UpdateBatchOptionsMomentum 或 UpdateBatchOptionsAdam 内核。反向传播则靠 calcInputGradients 调用 CalcHiddenGradientBatch 内核把梯度回传给前层。 Init 函数需要同时传入 numOutputs、myIndex、open_cl 指针、numNeurons、batchSize 和 optimization_type 六个参数,少一个都会导致后续缓冲分配失败。Save 仅接收文件句柄做序列化,模型持久化时记得自己管理文件打开与关闭。
class="kw">virtual class="type">bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); class="kw">public: CNeuronBatchNormOCL(class="type">void); ~CNeuronBatchNormOCL(class="type">void); class="kw">virtual class="type">bool Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl, class="type">uint numNeurons, class="type">uint batchSize, ENUM_OPTIMIZATION optimization_type); class="kw">virtual class="type">int getOutputIndex(class="type">void) { class="kw">return (iBatchSize>class="num">1 ? Output.GetIndex() : PrevLayer.getOutputIndex()); } class="kw">virtual class="type">int getGradientIndex(class="type">void) { class="kw">return (iBatchSize>class="num">1 ? Gradient.GetIndex() : PrevLayer.getGradientIndex()); } class="kw">virtual class="type">int getOutputVal(class="type">class="kw">double &values[]) { class="kw">return (iBatchSize>class="num">1 ? Output.GetData(values) : PrevLayer.getOutputVal(values)); } class="kw">virtual class="type">int getOutputVal(CArrayDouble *values) { class="kw">return (iBatchSize>class="num">1 ? Output.GetData(values) : PrevLayer.getOutputVal(values)); } class="kw">virtual class="type">int getGradient(class="type">class="kw">double &values[]) { class="kw">return (iBatchSize>class="num">1 ? Gradient.GetData(values) : PrevLayer.getGradient(values)); } class="kw">virtual CBufferDouble *getOutput(class="type">void) { class="kw">return (iBatchSize>class="num">1 ? Output : PrevLayer.getOutput()); } class="kw">virtual CBufferDouble *getGradient(class="type">void) { class="kw">return (iBatchSize>class="num">1 ? Gradient : PrevLayer.getGradient()); } class="kw">virtual class="type">bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); class="kw">virtual class="type">bool Save(class="type">int class="kw">const file_handle);
◍ BatchNorm 层的 OpenCL 初始化与核函数细节
在 MT5 的神经网络 OCL 实现里,CNeuronBatchNormOCL 的 Init 方法会按优化类型分配不同的缓冲区长度:SGD 模式下每个神经元占 7 个 double,其他优化器占 9 个,代码里用 count=(int)numNeurons*(optimization_type==SGD ? 7 : 9) 直接算出来。
默认构造把 iBatchSize 设为 1,析构时只对 BatchOptions 做 delete,PrevLayer 仅置空不释放,避免多层引用时重复析构。
核函数 BatchFeedForward 开头就拦掉 batch<=1 的情况直接 return,说明批归一化在小批量(单样本)下无意义;全局 ID 对应的偏移 shift 同样按优化类型走 7 或 9 的步长。
均值计算用了递推式 mean=(options[shift]*(batch-1)+inputs[n])/batch,并在写回前把 NaN 参数清零,这种写法能在流式训练里省掉全量重算,但外汇与贵金属行情高频跳变时 NaN 出现概率偏高,用前最好在 EA 里加日志确认缓冲区状态。
class="kw">virtual class="type">bool Load(class="type">int class="kw">const file_handle);class=class="str">"cmt">///< Load method @param[in] file_handle handle of file @class="kw">return logical result of operation class=class="str">"cmt">//--- class="kw">virtual class="type">int Type(class="type">void) class="kw">const { class="kw">return defNeuronBatchNormOCL; }class=class="str">"cmt">///< Identificator of class.@class="kw">return Type of class }; CNeuronBatchNormOCL::CNeuronBatchNormOCL(class="type">void) : iBatchSize(class="num">1) { PrevLayer=NULL; BatchOptions=NULL; } CNeuronBatchNormOCL::~CNeuronBatchNormOCL(class="type">void) { if(CheckPointer(PrevLayer)!=POINTER_INVALID) PrevLayer=NULL; if(CheckPointer(BatchOptions)!=POINTER_INVALID) class="kw">delete BatchOptions; } class="type">bool CNeuronBatchNormOCL::Init(class="type">uint numOutputs,class="type">uint myIndex,COpenCLMy *open_cl,class="type">uint numNeurons,class="type">uint batchSize,ENUM_OPTIMIZATION optimization_type) { if(!CNeuronBaseOCL::Init(numOutputs,myIndex,open_cl,numNeurons,optimization_type)) class="kw">return class="kw">false; activation=None; iBatchSize=batchSize; class=class="str">"cmt">//--- if(CheckPointer(BatchOptions)!=POINTER_INVALID) class="kw">delete BatchOptions; class="type">int count=(class="type">int)numNeurons*(optimization_type==SGD ? class="num">7 : class="num">9); BatchOptions=new CBufferDouble(); if(CheckPointer(BatchOptions)==POINTER_INVALID || !BatchOptions.BufferInit(count,class="num">0)) class="kw">return class="kw">false; class=class="str">"cmt">//--- class="kw">return true; } __kernel class="type">void BatchFeedForward(__global class="type">class="kw">double *inputs, __global class="type">class="kw">double *options, __global class="type">class="kw">double *output, class="type">int batch, class="type">int optimization, class="type">int activation) { if(batch<=class="num">1) class="kw">return; class="type">int n=get_global_id(class="num">0); class="type">int shift=n*(optimization==class="num">0 ? class="num">7 : class="num">9); class=class="str">"cmt">//--- for(class="type">int i=class="num">0;i<(optimization==class="num">0 ? class="num">7 : class="num">9);i++) if(isnan(options[shift+i])) options[shift+i]=class="num">0; class=class="str">"cmt">//--- class="type">class="kw">double mean=(options[shift]*((class="type">class="kw">double)batch-class="num">1)+inputs[n])/((class="type">class="kw">double)batch); class="type">class="kw">double delt=inputs[n]-mean;