神经网络变得轻松(第十三部分):批次常规化(基础篇)
📘

神经网络变得轻松(第十三部分):批次常规化(基础篇)

第 1/3 篇

「批次常规化让神经网络训练更稳」

在 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 的均值是否在迭代中收敛——外汇与贵金属杠杆高,模型过拟合会放大实盘回撤,仅作技术验证勿直接上资金。

MQL5 / C++
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 仅接收文件句柄做序列化,模型持久化时记得自己管理文件打开与关闭。

MQL5 / C++
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 里加日志确认缓冲区状态。

MQL5 / C++
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;

常见问题

有可能。每层输入分布随参数更新而漂移会拖慢收敛,引入批次常规化可缓解该现象,让训练更稳。
两者机制不同,BatchNorm 依赖批次统计,需保证批大小合理,过小批次会让归一化噪声变大、效果变差。
可以。小布能按你的批大小配置生成归一化层接口并监控损失波动,把重复调参劳动交给他,你只看决策。
需分配均值、方差、缩放与偏移参数缓冲区,并在核函数内按批次维度归约求统计量后再做仿射变换。
小批次的均值方差估计偏差大,归一化后引入额外噪声,可能放大梯度不稳定,建议实测不同批大小选稳健值。