神经网络变得简单(第 82 部分):常微分方程模型(NeuralODE)(基础篇)
📘

神经网络变得简单(第 82 部分):常微分方程模型(NeuralODE)(基础篇)

第 1/3 篇

用常微分方程重写神经网络的前向传播

传统神经网络按层堆叠,每层是一次离散变换;NeuralODE 把网络看成连续变换,用常微分方程(ODE)描述隐藏状态随「虚拟时间」的演化。在 MT5 里这意味着你不再固定层数,而是求解一个动态系统。 对交易者而言,这套思路适合建模价格流本身——把行情序列当作连续状态,用 ODE 求解器推断下一刻隐变量,而不是硬套固定窗口的 MLP。外汇与贵金属杠杆高、滑点突变更频繁,此类连续模型对稀疏跳变仍可能欠拟合,需用小布回测验证。 核心调用在 MQL5 中可用数值积分近似 ODE。下面这段用欧拉法走一步,是 NeuralODE 前向的最小骨架:

MQL5 / C++
class="type">class="kw">double NeuralODEStep(class="type">class="kw">double h, class="type">class="kw">double x, class="type">class="kw">double theta) {
   class="type">class="kw">double dx = -theta * x;      class=class="str">"cmt">// 简化 ODE: dx/dt = -theta*x
   class="type">class="kw">double x_next = x + h * dx; class=class="str">"cmt">// 欧拉法单步更新
   class="kw">return x_next;              class=class="str">"cmt">// 返回演化后状态
}

「连续深度模型用 ODE 求解器替代离散层」

常微分方程(ODE)模型家族把隐藏状态的导数用神经网络参数化,不再依赖固定步长的离散隐藏层序列。它的前向计算交给通用 ODE 求解器这个“黑匣子”完成,反向训练时无需窥探求解器内部操作,就能端到端优化。 这类连续深度模型最实用的特征是内存占用恒定,不会随网络“深度”线性膨胀;同时能针对每个输入信号自适应调整数值积分策略。原论文《神经常微分方程》已验证:用任意 ODE 求解器伸缩反向传播,可在较大模型中直接嵌入可训练的 ODE 模块。 对做行情序列建模的交易者来说,这意味着你可以用时间连续视角重构状态演化,而不是硬切固定窗口。外汇与贵金属波动高频且杠杆高,连续模型对噪声敏感,实盘前务必在 MT5 用历史 tick 做回测验证稳定性。

◍ 把 ODE 求解器当黑盒来反传梯度

训练常微分方程模型时,正向用前馈做微分虽然直接,但内存占用大,还会叠一层数值误差。更硬核的做法是把 ODE 求解器当成黑盒,用共轭灵敏度法算梯度:向后解一条扩展 ODE 就能拿到沿参数的梯度,复杂度随任务规模线性增长,显存压力明显更低,数值误差也能主动控住。 具体落地时,先定义标量损失 L(),输入是求解器跑出来的 z(t)。要优化它就要求 ∂L/∂θ。第一步算 a(t)=∂L/∂z(t),它的动态由另一条 ODE 给出,从终点 ∂L/∂z(t1) 反向用求解器推回去即可;过程中整条轨迹的 z(t) 可从终值倒时序重建,不用全程缓存。 顺带算第三个积分拿 ∂L/∂θ,把原始状态、共轭、偏导打包进一个向量,ODE 求解器一次调用全解完。若损失挂在中间状态上,就得把逆模导数拆成多段,每段衔接处按 ∂L/∂z(t) 校正共轭。 求解器靠容差保证结果落在真实解邻域。容差直接改模型行为:函数计算次数与耗时成正比,训练时拉高精度、实盘切低精度,就是在准确度和算力成本之间做交换。外汇与贵金属行情高阶非线性强、杠杆风险高,这类容差切换在回测与实盘的差异可能放大滑点。

在 MT5 里搭一个 NODE 层:从 OpenCL 内核到类封装

想把神经 ODE 跑在 MT5,核心是把 ODE 函数拆成可在 GPU 上并行的前馈与反向内核。原文给出的 CNeuronNODEOCL 类继承自全连接层基类,额外持有 3 维数据描述:iDimension(单状态特征嵌入维度)、iVariables(特征数)、iLenth(序列状态数)。ODE 函数用 2 个全连接层夹 ReLU 表达,但每个特征给独立权重矩阵,所以内层不再走卷积,而是显式声明数据缓冲区与对应内核。 前馈端 FeedForwardNODEF 在 3 维任务空间启动,参数传 3 个数据缓冲区和 3 个变量;线程先定位 3 维偏移,再把初始向量乘权重求和,时间戳不作为张量拼接、只当步长参数传入,结果过激活后写回。反向端 HiddenGradientNODEF 同空间启动,累加源数据误差梯度,但时间戳是常量、不传播梯度,仅用激活导数缩放后写回。 ODE 求解选了 5 阶 Dorman-Prince,k1..k6 计算只差数值系数。作者把缺失系数乘 0,统一用 FeedForwardNODEInpK 内核(一维空间)算所有 ki,matrix_beta 存乘数;反向用 HiddenGradientNODEInpK 同 beta 传播,并写零值防重复累加。权重更新另写 NODEF_UpdateWeightsAdam 内核,3 维空间跑,收集所有 ki 的梯度按 Adam 更新——这是能直接抄进自己 EA 的落点。 类初始化 CNeuronNODEOCL::Init 只在 OpenCL 端建 ki 与调整后数据缓冲区(不占主存),指针存数组、大小为 ki 数 3 倍以攒梯度;权重矩阵按 2 层建,另建 alpha 步长、源调整、ODE 解等常量乘数缓冲。前馈顶层 feedForward 仅循环调 CalculateInputK / CalculateKBuffer / CalculateOutput,逻辑很薄。反向 calcInputGradients 倒序循环:先按 ODE 解因子分派梯度,再逐 ki 过 2 层与 1 层传播,临时缓冲累加后归一求均值,最后 updateInputWeights 调 Adam 内核。 外汇与贵金属行情高阶非线性,这类自定义层仅提供建模可能,实盘前务必在 MT5 策略测试器用历史 Tick 验证梯度收敛与过拟合倾向。

「权重落盘只存最小集」

在 CNeuronNODEOCL 的 Save 方法里,只把 cWeights 集合和 3 个判定类架构的常量写进文件,其余对象状态靠加载时重建。父类同名方法先调用,再落本地集合与常量,文件体积压到最小。 代价在加载侧:读回保存数据后,必须依据这 3 个架构参数把缺失的对象逐一 new 出来。省磁盘的逻辑直接转嫁为启动时的重建开销,跑 MT5 时若模型层深,初始化延迟会肉眼可见地变长。 新层接进编码器时,在 CreateDescriptions 里先校验 3 个动态数组指针,缺则新建;原始环境数据过批量常规化、嵌入层、卷积层生成状态嵌入,补位置编码后再叠上下文引导分析层,最后挂 2 层 NODEOCL。扮演者 / 评论者模型原样复用,旧 EA 无需改动即可加载。 外汇与贵金属行情高波动,ODE 层增加参数量后过拟合概率倾向上升,实盘前请用历史数据回测验证稳定性。

MQL5 / C++
class CNeuronNODEOCL      :  class="kw">public CNeuronBaseOCL
  {
class="kw">protected:
   class="type">uint                iDimension;
   class="type">uint                iVariables;
   class="type">uint                iLenth;
   class="type">int                 iBuffersK[];
   class="type">int                 iInputsK[];
   class="type">int                 iMeadl[];
   CBufferFloat        cAlpha;
   CBufferFloat        cTemp;
   CCollection         cBeta;
   CBufferFloat        cSolution;
   CCollection         cWeights;
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        CalculateKBuffer(class="type">int k);
   class="kw">virtual class="type">bool        CalculateInputK(CBufferFloat* inputs, class="type">int k);
   class="kw">virtual class="type">bool        CalculateOutput(CBufferFloat* inputs);
   class="kw">virtual class="type">bool        feedForward(CNeuronBaseOCL *NeuronOCL);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        CalculateOutputGradient(CBufferFloat* inputs);
   class="kw">virtual class="type">bool        CalculateInputKGradient(CBufferFloat* inputs, class="type">int k);
   class="kw">virtual class="type">bool        CalculateKBufferGradient(class="type">int k);
   class="kw">virtual class="type">bool        updateInputWeights(CNeuronBaseOCL *NeuronOCL);
class="kw">public:
                     CNeuronNODEOCL(class="type">void) {};
                    ~CNeuronNODEOCL(class="type">void) {};
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        Init(class="type">uint numOutputs, class="type">uint myIndex, COpenCLMy *open_cl,
                             class="type">uint dimension, class="type">uint variables, class="type">uint lenth,
                             ENUM_OPTIMIZATION optimization_type,
                             class="type">uint batch);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool        calcInputGradients(CNeuronBaseOCL *prevLayer);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">int         Type(class="type">void)  const   {  class="kw">return defNeuronNODEOCL;  }
   class=class="str">"cmt">//--- methods for working with files
   class="kw">virtual class="type">bool        Save(class="type">int const file_handle);

◍ 把前向传播丢进 GPU 内核

神经 ODE 的推理瓶颈在每层全连接矩阵乘,MT5 自带的 OpenCL 封装让这段计算能直接丢到显卡跑。下面这个内核把权重矩阵、输入张量、输出张量都声明为 __global float*,由 get_global_id 拆出三维并行索引,单次调用就能覆盖 batch 内所有变量和所有时间步。 权重偏移里 (dimension+2) 这个跨度很关键:除了 dimension 个主权重,还塞了 bias 与步长系数,所以 weight_shift 要乘 (dimension+2) 而不是 dimension。sum 初值取 matrix_w[dimension+1+weight_shift] 加 matrix_w[dimension+weight_shift]*step,就是 ODE 的显式欧拉项,step 对应积分 h。 激活函数用整型枚举分流:0 是 tanh,1 是 clamp 到 ±20 的 sigmoid,2 是带 0.01 负半轴的 leaky 整流。isnan 兜底把脏数归零,避免显卡浮点溢出把整张 output tensor 污染。外汇与贵金属行情跳空多,这类数值保护在实盘 EA 里不是可选项。 复制进 .cl 文件后,用 COpenCLMy 的 SetOpenCL 把上下文绑进你的 CNet 派生类,Load(file_handle) 读入权重,MT5 策略测试器里开 OpenCL 就能看到 FeedForwardNODEF 的占用。调 step 从 0.01 到 0.1 区间试,梯度爆炸概率会随 h 放大而明显上升。

MQL5 / C++
class="kw">virtual class="type">bool      Load(class="type">int const file_handle);
class="kw">virtual class="type">void      SetOpenCL(COpenCLMy *obj);
};
__kernel class="type">void FeedForwardNODEF(__global class="type">float *matrix_w,                class=class="str">"cmt">///<[in] Weights matrix 
                              __global class="type">float *matrix_i,                class=class="str">"cmt">///<[in] Inputs tensor
                              __global class="type">float *matrix_o,                class=class="str">"cmt">///<[out] Output tensor
                              class="type">int dimension,                           class=class="str">"cmt">///< input dimension
                              class="type">float step,                             class=class="str">"cmt">///< h
                              class="type">int activation                          class=class="str">"cmt">///< Activation type(class="macro">#ENUM_ACTIVATION)
                              )
  {
  class="type">int d = get_global_id(class="num">0);
  class="type">int dimension_out = get_global_size(class="num">0);
  class="type">int v = get_global_id(class="num">1);
  class="type">int variables = get_global_size(class="num">1);
  class="type">int i = get_global_id(class="num">2);
  class="type">int lenth = get_global_size(class="num">2);
  class="type">int shift = variables * i + v;
  class="type">int input_shift = shift * dimension;
  class="type">int output_shift = shift * dimension_out + d;
  class="type">int weight_shift = (v * dimension_out + d) * (dimension + class="num">2);
  class="type">float sum = matrix_w[dimension + class="num">1 + weight_shift] + matrix_w[dimension + weight_shift] * step;
  for(class="type">int w = class="num">0; w < dimension; w++)
      sum += matrix_w[w + weight_shift] * matrix_i[input_shift + w];
  if(isnan(sum))
      sum = class="num">0;
  class="kw">switch(activation)
     {
     case class="num">0:
         sum = tanh(sum);
         break;
     case class="num">1:
         sum = class="num">1 / (class="num">1 + exp(-clamp(sum, -class="num">20.0f, class="num">20.0f)));
         break;
     case class="num">2:
         if(sum < class="num">0)
            sum *= class="num">0.01f;
         break;
     class="kw">default:
         break;
     }
  matrix_o[output_shift] = sum;
  }
__kernel class="type">void HiddenGradientNODEF(__global class="type">float *matrix_w,                class=class="str">"cmt">///<[in] Weights matrix

常见问题

把离散的层堆叠换成用 ODE 求解器从输入状态积分到输出状态,深度变成积分时间,不再是一层一层算。
用伴随灵敏度法,把反向过程也写成一个 ODE 和前向一起解,不用存中间激活,显存占用小很多。
小布可以读你的权重最小集和训练曲线,标出验证集漂移明显的区间,提醒你降秩或加扰动。
只存 ODE 函数里的可学习参数和初始状态,求解器配置不存,载入时重建,文件小且不易版本错乱。
先用 CPU 单步积分跑一小批样本对拍,误差低于 1e-4 再切 GPU,避免内核写错难排查。