English Русский Español Português
preview
交易中的神经网络:智能预测流程(稀疏混合专家)

交易中的神经网络:智能预测流程(稀疏混合专家)

MetaTrader 5 — 交易系统 |
9 0
Dmitriy Gizlyk
Dmitriy Gizlyk

引言

金融市场是一个复杂、混沌且高频的系统。粗略近似和平均值在这里都行不通。每一根K线、每一次价格波动,都由大量因素共同驱动,涵盖基本面消息到动量交易行为。正因如此,处理市场时间序列需要一套特殊的方案:既要对微小细节保持敏感,又要对噪声具备鲁棒性,还能在混沌之中识别内在结构。

Time-MoE框架正是这样一套架构。它并不只是一个适配时间序列的Transformer,而是一个内部紧密协同的一体化系统,其中,历史数据的每个时间步长都被视作独立token。这些token经过一系列变换,同时保留自身特征与时序上下文信息。该机制使得模型能够处理高频数据,捕捉传统聚合方法无法识别的模式。

处理流程的第一阶段是嵌入层,数据在此经过非线性变换。这有助于捕捉特征之间的复杂关系,例如价格与成交量之间的关系、指标的方向,或最近一次价格波动的强弱。生成的隐向量表征将作为后续分析的基础。

接下来,token会依次进入一系列Transformer模块。在模块内,模型回溯历史,基于累积信息构建当前时刻的表征。需要依靠注意力机制实现:每个token与历史token逐一比对,各条信息的权重并非人工设定,而是由模型在训练中自学习得到。这使得模型可以同时兼顾短期动量与长期趋势。

Time-MoE对噪声格外鲁棒。为此,架构内置归一化机制,用来平滑随机异常值、放大有效信号。注意力权重不会过度聚焦于个别异常点,而是更均衡、合理地分布,这在市场波动与噪声环境下尤为关键。

Time-MoE与经典Transformer最核心的区别在于引入稀疏混合专家(Mixture-of-Experts,MoE)。在每个模块内,路由器仅挑选一部分专家参与当前token的计算。该设计大幅降低计算开销,使得模型扩容时不会带来算力需求的指数级增长。除此之外,Time-MoE还包含一个始终保持激活的共享专家,保障模型在路由决策出错时仍具备稳定性。

最后一步是预测。模型会同时生成多个不同时间跨度的预测结果。该方案可同时兼顾短期信号与长期趋势,为交易者或分析系统输出多情景研判。训练阶段模型会同步学习全部预测周期,具备更强的灵活性,更好地适配不断变化的市场环境。

因此,Time-MoE具备如下特性:

  • 对细节敏感,以单个时间步长作为token进行建模;
  • 通过注意力归一化,对噪声与异常值鲁棒;
  • 基于稀疏专家机制,支持模型规模扩展;
  • 一套通用的多时间尺度预测机制。

作者绘制的Time-MoE框架结构图如下。

本篇我们继续之前的研究工作,聚焦Time-MoE框架的核心组件 —— 稀疏混合专家(Sparse Mixture of Experts)。在前一篇文章中,我们分步搭建模型基础,借助SwiGLU嵌入完成构建token与隐藏表征;现在进入这一架构中最关键、最具代表性的部分,在很大程度上决定了整个系统的计算效率与可扩展性。

在本文中,我们将详细讲解专家组的运行机制与计算分配逻辑。我们不只是阐述理论框架,而是基于MQL5完成稀疏MoE的实际代码实现,聚焦实现要点。


架构开发

在开始实现稀疏混合专家之前,我们先梳理核心思路。和之前一样,我们采用所有专家并行运算的设计理念,高度契合大规模并行计算范式。因此,我们把绝大部分计算负载放到 OpenCL上下文里执行。但这里存在一个关键问题:如何实现专家的稀疏激活,同时保证模型的计算效率与可训练性?

首先,我们来回顾一个核心要点。Time-MoE论文原作者提出的专家架构,与Transformer中的经典前馈 (FeedForward)模块有显著区别。具体而言,是将专家内部第一层替换为 SwiGLU变换。该改动具备充分合理性:正如前文所述, SwiGLU能提升模型特征处理能力,更好地捕捉非线性关系。幸运的是,我们已经实现了该层:在前一篇文章中开发了CNeuronSwiGLUOCL组件。现在,我们可以直接将它用作混合专家的第一层,并根据并行子模型数量扩展输出通道数。

每个滤波器都有自己可训练的参数,本质上就是一个独立专家模型。如果按照专家数量对输出结果分组,就能得到我们所需的结构:一个输入对应一组并行工作的独立专家。在计算的第二阶段,我们引入前文已实现过的多窗口卷积。该层用于在每个专家内部整合信息,为后续结果聚合做准备。

到这里逻辑看起来十分通顺。但仍存在一个重要缺陷:上述算法缺少最核心的元素 —— 稀疏性。当然,我们可以用激活掩码乘以所有专家的输出,得到正确结果。然而,即便专家贡献被掩码屏蔽,所有专家仍然完整执行计算。小规模模型尚可接受,但随着系统扩容,专家数量增加、向量维度提升和网络加深,都会导致算力开销暴涨。

由此引出了核心问题:应当在算法的哪个环节、以何种方式引入稀疏机制?

乍看之下,最优方案似乎是直接禁用未被选中的专家的全部计算,这能极大降低算力消耗。但这里还隐藏着一个更为微妙也更为关键的问题。MoE的核心理念就是让不同专家学习不同的子任务,与此同时,各自发挥专业化。但是,谁来决定在给定情况下哪些专家处于激活状态?答案是由路由器读取输入数据,挑选一部分专家激活。

这种方案的风险在于路由器过早收敛:它总是固守同一小批专家反复使用,而不顾上下文的变化。这类模型在训练初期效果尚可,但会丧失自适应能力,因为模型不会尝试其他专家路径。在这种情况下,未被选中的专家永远没有机会在其他场景中证明自己的价值。这就形成了局部最优舒适区效应:路由器只信赖少数偏好专家,不再尝试备选方案,陷入固定模式。最终造成模型多样性下降,泛化能力退化。

因此我们的训练目标不只是让路由器学会挑选专家,而是让它根据输入数据特征自适应调整选择策略。我们需要构建训练环境,让模型主动探索其他专家的表现,即便这些专家初期效果较差。只有这样,模型才能学会更灵活地分配任务,提升整体预测精度,以及应对市场变化的能力。

为平衡计算效率与训练覆盖度,我们决定仅在MoE模块的第二层实现专家稀疏调用,并将该层与结果聚合流程结合。该方案简化架构、降低计算量,同时保留路由器训练所需的灵活性。

这套设计的重点在于误差梯度的传播机制。我们刻意放弃对未激活专家直接训练的理念 —— MoE的本质就是让不同专家专精不同任务。但为了避免模型一直锁定同一批专家,我们把梯度回传给路由器,向路由器反馈当前专家组合预测效果不佳的信号。路由算法由此获得训练:当下次预测时,如果当前专家组效果差,就可以尝试激活另一组专家。

因此,即便某一次推理只激活两名专家,路由器依然可以学习到其他专家也具备被选中的可能性。这不仅仅是一个技术手段,而是强大的自适应机制:模型逐步学习输入数据特征与最优专家激活组合之间的内在关联。



掩码窗口卷积

主要方案已经确定,现在我们卷起袖子,从理论走向实际实现。在实现的第一阶段,需要开发一个核心组件:第二层MoE对象,负责专家的稀疏激活以及各专家输出结果的聚合。该组件将作为模型中的核心连接环节:所有专家的数据都要经过它,但在单次计算中,只有少数专家处于激活状态。

我们将激活专家的选择逻辑独立封装为一个模块 —— 路由器。路由器的任务是分析输入数据,生成激活掩码,以此决定每个token应激活哪些专家参与计算。该掩码与待分析的主特征张量一同传入本对象,确定当前计算步骤的专家激活配置。

重要前提假设:所有专家架构完全一致,输出结果维度固定。但在任意时刻,仅有少量模型处于激活状态,其数量通常远小于输出空间维度。其余专家保持休眠模式,不参与实际计算,从而避免额外的计算资源消耗。

该方案具备两大核心优势:

  1. 大幅降低计算负载:在模型扩容、专家数量增加时尤为关键。
  2. 增强专家的专业化能力:每个专家专注学习其对应的窄子任务空间。最终,模型会形成分散在各个专家之中、真正意义上的专家知识。

我们将绝大多数计算迁移至OpenCL上下文,充分利用 GPU以及其他兼容设备的并行计算能力。本节重点讲解第二层MoE层的前向计算内核,该内核实现选中专家的稀疏激活,并完成专家输出结果聚合。

内核参数包含全部专家权重、输入数据、激活掩码,以及若干用于定义窗口结构与张量维度的参数。

__kernel void FeedForwardMaskMultWinConv(__global const float *matrix_w,
                                         __global const float *matrix_i,
                                         __global const float *masks,
                                         __global float *matrix_o,
                                         const int inputs,
                                         const int window_in,
                                         const int windows_total,
                                         const int activation
                                        )
  {
   const size_t u = get_global_id(0);
   const size_t w = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t units = get_global_size(0);
   const size_t window_out = get_global_size(1);
   const size_t variables = get_global_size(2);

每个计算线程对应结果张量中的一个特定元素:按序列位置、token分量以及变量进行映射。这种逐点寻址方式能够实现高度并行计算。

需要重点强调的是,我们预先设定了一个前提:每次前向传播时,激活专家的数量远小于结果张量内token的维度。这是决定内核内部任务分配逻辑的关键。我们按照token元素划分任务空间,并在内核内部通过循环遍历所有已激活专家。该方案可以高效利用计算资源,同时保留专家激活的稀疏特性。

在内核主体代码中,我们先定位任务空间内当前计算线程,再计算数据缓冲区中待处理元素的内存偏移量。

   const int shift_in = u * window_in * windows_total;
   const int shift_in_var =  v * units * window_in * windows_total;
   const int shift_out = (u + v * units) * window_out + w;
   const int shift_mask = (u + v * units) * windows_total;
   const int shift_weight = (v * window_out * windows_total + w) * (window_in + 1);
   const int step_weight = window_out * (window_in + 1);

接下来,我们搭建循环结构。外层循环遍历全部专家,并通过激活掩码判断专家的工作状态。如果某个专家在当前窗口未激活,则跳过其计算贡献,消除冗余运算、节约算力资源。对于处于激活状态的专家,基于输入数据计算加权和,并加上偏置项。累加得到最终数值,再送入激活函数处理。

float sum = 0;
for(int w_in = 0; w_in < windows_total; w_in++)
  {
   float m = IsNaNOrInf(masks[shift_mask + w_in], 0);
   if(m < FLT_EPSILON)
      continue;
   const int shift_in_loc = shift_in + w_in * window_in;
   const int shift_weight_loc = shift_weight + w_in * step_weight;
   for(int i = 0; i < window_in; i++)
      if((shift_in_loc + i) < (inputs / variables))
         sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc + i], 0) * 
                matrix_w[shift_weight_loc + i] * m;
   sum += matrix_w[shift_weight_loc + window_in] * m;
 }

请注意,在聚合过程中,我们并非简单对激活专家的输出直接求和;而是将每个专家的输出乘以对应的掩码值。在二值掩码场景下,掩码仅由0和1构成,此时该操作不会带来额外值。然而,该方法为我们保留了一个重要扩展能力:实现加权聚合。如果掩码中存储的不只是开关标记,而是实际权重系数,我们就可以控制每个专家对最终结果的贡献程度,甚至支持软选择与概率式路由。

计算得到的结果送入激活函数处理,随后存入结果缓冲区。

 matrix_o[shift_out] = Activation(sum, activation);
}

完成前向传播后(激活专家处理各自特征子空间,输出结果经掩码加权),我们进入第二个重要阶段:误差梯度的反向传播。在该阶段,我们需要精细地反向传递误差,不仅要传递至每个激活模型的输入数据,还要传递到激活掩码本身,从而能够在训练过程中对掩码进行更新调整。

为实现该逻辑,我们开发了OpenCL内核函数:CalcHiddenGradientMaskMultWinConv,在并行计算框架内完成上述全部计算。

__kernel void CalcHiddenGradientMaskMultWinConv(__global const float *matrix_w,
                                                __global const float *matrix_i,
                                                __global float *matrix_ig,
                                                __global const float *matrix_og,
                                                __global const float *masks,
                                                __global float *masks_g,
                                                const int outputs,
                                                const int window_in,
                                                const int window_out,
                                                const int activation
                                               )
  {
   const size_t u = get_global_id(0);
   const size_t w_in = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t units = get_global_size(0);
   const size_t windows_total = get_global_size(1);
   const size_t variables = get_global_size(2);

该内核接收权重、输入数据、输出层误差梯度、掩码,以及用于写入输入数据梯度和掩码梯度的缓冲区。OpenCL上下文里的每个工作项(work-item),负责一组独立组合:单个token、单个专家和单个变量。在内核主体代码中,首先沿着任务空间的所有维度定位当前工作项。接下来,计算各个数据缓冲区对应的内存偏移量。

const int shift_in = (u + v * units) * window_in * windows_total + w_in * window_in;
const int shift_out = u * window_out;
const int shift_out_var = v * units * window_out;
const int shift_mask = (u + v * units) * windows_total + w_in;
const int shift_weight = (v * window_out * windows_total + w_in * window_out) * (window_in + 1);

第一步,将误差梯度反向传播至输入数据层。该过程首先检查掩码:如果对应专家对当前token处于未激活状态,则直接向输入数据梯度缓冲区写入零值,避免算力浪费。反之,如果该专家参与了本次计算,则开始执行梯度反向传播。

const float m = IsNaNOrInf(masks[shift_mask], 0);
for(int i = 0; i < window_in; i++)
  {
   float sum = 0;
   if(m >= FLT_EPSILON)
     {
      for(int out = 0; out < window_out; out++)
        {
         if((shift_out + out) >= (outputs / variables))
            continue;
         sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] *
                           matrix_w[shift_weight + out * (window_in + 1) + i] *
                           m, 0);
         sum += IsNaNOrInf(matrix_w[shift_weight + out * (window_in + 1) + window_in] *
                           m, 0);
        }
     }
   matrix_ig[shift_in + i] = Deactivation(sum, matrix_i[shift_in + i], activation);
  }

首先,内核遍历全部输出通道,计算输入数据的每个元素对总误差的贡献。所得数值结合输入数据层激活函数的导数进行修正,随后存入全局数据缓冲区对应的位置。

接下来执行第二步:向掩码反向传播误差梯度。这一步在当前专家的输出层面聚合所有通道的贡献,考量该专家对最终结果的影响,并生成反馈信号,用以表征选用该专家的实际效果好坏。

 float sum = 0;
 for(int out = 0; out < window_out; out++)
   {
    int shift_weight_loc = out * (window_in + 1) + shift_weight;
    float temp = matrix_w[shift_weight_loc + window_in];
    for(int i = 0; i < window_in; i++)
       temp += IsNaNOrInf(matrix_i[shift_in + i], 0) * matrix_w[shift_weight_loc + i];
    sum += IsNaNOrInf(temp * matrix_og[shift_out_var + shift_out + out], 0);
   }
 masks_g[shift_mask] = IsNaNOrInf(sum, 0);
}

即便掩码为二值类型,该数值依然可以辅助路由器在后续做出更合理的选择;必要时,该值还能作为权重得分,用于加权激活计算。

依托这套机制,我们得到了一个真正可训练的架构:所有路由决策都能够通过梯度进行调整,也可以在有需要时唤醒休眠专家。这使得稀疏混合专家模型能够高效、灵活且真正智能地运行。

接下来的关键步骤是更新模型参数。模型正是在此环节,依据接收的误差信号调整权重,完成学习。为实现该过程,我们使用独立的OpenCL内核:UpdateWeightsMaskMultWinConvAdam,该内核针对稀疏混合专家的特性定制,支持使用Adam优化算法。

和之前一样,我们将大部分计算交由OpenCL上下文处理,因为在训练过程中可以独立计算每一个权重。

__kernel void UpdateWeightsMaskMultWinConvAdam(__global float *matrix_w,
                                               __global const float *matrix_og,
                                               __global const float *matrix_i,
                                               __global const float *masks,
                                               __global float *matrix_m,
                                               __global float *matrix_v,
                                               const int windows_total,
                                               const int inputs,
                                               const int outputs,
                                               const float l,
                                               const float b1,
                                               const float b2
                                              )
  {
   const size_t id_in = get_global_id(0);
   const size_t id_out = get_global_id(1);
   const size_t id_v = get_global_id(2);
   const size_t window_in = get_global_size(0) / windows_total - 1;
   const size_t window_out = get_global_size(1);
   const size_t variables = get_global_size(2);

该内核的运行逻辑按照三个坐标维度组织:id_in对应输入维度以及窗口内位置;id_out对应输出滤波器索引;id_v对应批次内的当前变量。这种三维组织形式可以完整覆盖专家模型的全部参数,支持对参数做并行处理。

这里需要重点说明的是,我们预先设定了一个架构前提:所有专家使用相同的分析窗口,输出张量的形状保持一致。这意味着整个系统可以等价转换为二维矩阵:一个轴代表滤波器数量,另一个轴代表所有专家的分析窗口内元素总数。这一简化让权重的内存寻址既高效又紧凑,在批量参数更新阶段尤为重要。

在内核内部,首先沿着任务空间的全部维度定位每一个计算线程。接下来,计算待处理元素在各个数据缓冲区中的内存偏移量。

const int w_id = id_in / (window_in + 1);
const int shift_in = id_in - w_id;
const int step_in = window_in * windows_total;
const int units = outputs / window_out;
const int shift_in_var = id_v * inputs;
const int shift_out_var = id_v * outputs;
const int shift_mask_var = id_v * units * windows_total;
const int shift_weight = ((id_v * windows_total + w_id) * window_out + id_out) *
                         (window_in + 1) + id_in % (window_in + 1);
const bool bias = (id_in % (window_in + 1) == window_in);

接下来,我们按顺序遍历所有专家处理过的tokens (单元)。对于每个这样的数据片段,利用掩码判断对应的专家是否被激活。

float grad = 0;
for(int u = 0; u < units; u++)
  {
   const int shift_in_loc = shift_in + u * step_in;
   if(shift_in < inputs)
      continue;
   float m = IsNaNOrInf(masks[shift_mask_var + u * windows_total + w_id], 0);
   if(m < FLT_EPSILON)
      continue;
   float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0));
   grad += IsNaNOrInf(inp * m * matrix_og[shift_out_var + u * window_out + id_out], 0);
  }

如果某个专家在当前计算步处于休眠状态(掩码值趋近于0),我们不会耗费资源计算误差梯度,直接处理下一个token。这正是稀疏学习的核心特性之一。

对于处于激活状态的专家,我们计算误差梯度:将输入数据乘以输出张量(matrix_og)对应的误差值,再与掩码相乘。

float mt = IsNaNOrInf(clamp(b1 * matrix_m[shift_weight] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0);
float vt = IsNaNOrInf(clamp(b2 * matrix_v[shift_weight] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f);
float weight = clamp(matrix_w[shift_weight] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);

接下来,我们执行一步Adam优化:更新一阶矩(mt)与二阶矩(vt),对梯度做归一化处理,并调整权重参数。所有更新都会经过截断(clamp)函数,该函数将权重值限制在允许区间内。

我们把计算得到的结果存入全局缓冲区。

 matrix_w[shift_weight] = weight;
 matrix_m[shift_weight] = mt;
 matrix_v[shift_weight] = vt;
}

因此,每个权重都会严格按照其对最终结果的贡献进行更新,并且仅当该权重参与了实际计算时才会更新。这种架构设计保障了很高的计算效率,同时能够训练高度专业化的专家模块:这些专家在不适用的场景下保持休眠,而当需要用到其知识时,则会主动学习。

OpenCL端的计算逻辑全部实现后,接下来我们进入下一环节 —— 在主程序中对整套流程进行组织调度。在这一步,会创建并配置负责调用对应内核、处理掩码的对象。该功能由专用类CNeuronMaskMultiWinConv实现,此类继承自CNeuronConvOCL。该新对象的结构如下。

class CNeuronMaskMultiWinConv    :  public CNeuronConvOCL
  {
protected:
   uint              iWindowsTotal;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second)override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
                       CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override;

public:
                     CNeuronMaskMultiWinConv(void) {};
                    ~CNeuronMaskMultiWinConv(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                          uint windows_total, uint window_out, uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override const  {  return defNeuronMaskMultiWinConv;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
  };

该类充当模型与底层计算流水线之间的桥梁。它的任务是做好数据预处理,将数据送入OpenCL上下文,启动所需内核,并取回计算结果。

在CNeuronMaskMultiWinConv对象的结构设计中,我们刻意不新增内部成员。父类CNeuronConvOCL已经提供全部所需组件,足以完成OpenCL端的计算调度。这种设计让架构简洁无冗余,同时实现所有资源的集中管理。

该层在重写的Init方法中完成初始化,我们仅调整关键参数,不改动整体逻辑。

bool CNeuronMaskMultiWinConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                                   uint windows_total, uint window_out, uint units_count, uint variables,
                                   ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   uint win = window * windows_total + MathMax(windows_total, 1) - 1;
   if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, win, win, window_out, units_count, variables, optimization_type, batch))
      return false;
   iWindowsTotal = windows_total;
   iWindow = window;
//---
   return true;
  }

具体来说,我们设置iWindowsTotal的值,该参数用于指定并行专家的数量。在内核生成偏移量与计算内存地址时,会用到该数值。

初始化方法的核心工作是正确计算待处理窗口的宽度。由于每个专家都在各自固定长度的窗口上运算,我们将所有窗口合并为统一输入空间。由此得到合并后对应原始数据的分析窗口尺寸。该计算确保即使在边界情况下,窗口大小也不会为0。

接下来,我们调用父类的同名方法,传入更新后的参数,完成本方法的执行并返回。

该层的前向传播与反向传播方法内部没有独立的计算逻辑,仅负责为上文所述OpenCL内核提供调度支持。这类方法的职责是准备入参、传递参数,并将对应的内核加入执行队列。

此处流程是标准的:传入各类数据缓冲区(含掩码)的指针与其他参数,再调用OpenCL函数并设置所需的工作维度。相信您对这套流程已经十分熟悉。因此,这里无需对每个方法展开深入分析,相关方法可供自行研读。CNeuronMaskMultiWinConv类及其全部方法的完整源代码见附件。



稀疏混合专家

我们工作的下一个重要阶段是搭建稀疏混合专家模块。在本实现中,该模块架构由CNeuronTimeMoESparseExperts类定义,此类继承自基类CNeuronBaseOCL。该对象包含启动、协调专用专家与共享专家运行所需的全部核心组件。

class CNeuronTimeMoESparseExperts   :  public CNeuronBaseOCL
  {
protected:
   CNeuronSwiGLUOCL        cExpertsIn;
   CNeuronSwiGLUOCL        cSharedIn;
   CNeuronMaskMultiWinConv cExpertsOut;
   CNeuronConvOCL          cSharedOut;
   CNeuronTopKGates        cMasks;
   CNeuronConvOCL          cSharedGates;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL)          override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)   override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)   override;

public:
                     CNeuronTimeMoESparseExperts(void) {};
                    ~CNeuronTimeMoESparseExperts(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                          uint window_out, uint units_count, uint variables, uint experts,
                          uint topK, ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override const {   return defNeuronTimeMoESparseExperts; }
   //--- methods for working with files
   virtual bool      Save(int const file_handle)   override;
   virtual bool      Load(int const file_handle)   override;
   //---
   virtual void      SetOpenCL(COpenCLMy *obj)     override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      TrainMode(bool flag) override;
  };

在CNeuronTimeMoESparseExperts类的结构中,可以看到若干内部对象,每个对象在稀疏混合专家机制内承担明确的功能。在后续构建这些方法的算法逻辑时,我们会逐步理解它们的内部原理。本阶段需要重点说明一项架构设计:所有内部对象均采用静态声明,不使用动态内存分配。该方案不仅简化资源管理,还能让类的构造函数与析构函数保持为空。全部初始化过程都在Init方法中实现。

bool CNeuronTimeMoESparseExperts::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                                       uint window_out, uint units_count, uint variables, uint experts,
                                       uint topK, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables,
                                                                 optimization_type, batch))
      return false;
   SetActivationFunction(None);

该方法的入参包含一组关键常量,能够明确地定义待创建对象的架构。相关配置均在此处指定:输入与输出token的尺寸、专家总数、 Top-K掩码选中的专家数量、所使用的优化器类型,以及变量数量和计算块数量。

部分参数会传递至父类的同名方法。父类中已经实现了架构参数的基础校验,以及包含OpenCL上下文在内的全局接口初始化。通过这种方式,我们搭建出清晰的层级结构:每一层负责自身范围内的任务,所有架构组件组合在一起,形成一个统一、内聚的整体。

父类层面初始化成功完成后,我们继续配置内部组件,混合专家模块自身的结构就此开始构建。

该组件链中的第一个对象为cExpertsIn,用于实现SwiGLU功能。它作为输入数据处理的第一阶段,相当于一个前置过滤器,用于放大有效信号,并生成嵌入向量,分发给各个专家。该层的过滤器数量与专家数量成比例缩放,保证每个专家都能获得表达能力充足的任务表征。

int index = 0;
if(!cExpertsIn.Init(0, index, OpenCL, window, window, window_out * experts, units_count,
                                                       variables, optimization, iBatch))
   return false;
index++;
if(!cSharedIn.Init(0, index, OpenCL, window, window, window_out, units_count, variables,
                                                                  optimization, iBatch))
   return false;

我们以相同方式初始化cSharedIn组件,它是共享专家的首个处理层。与专用专家不同,此处无需对过滤器维度做缩放;相反,其维度固定为单个专家对应的大小。这是因为共享专家覆盖全部任务空间,它的作用不是和其他专家相互竞争,而是提供一种通用视角,相当于决策的基准参考。

将SwiGLU用于该环节可以提供非线性选择能力,放大不同token之间的差异,从而筛选出与特定专家潜在相关的token。

接下来,我们为专用专家与共享专家构建第二层处理模块。该部分架构严格遵循前文确定的设计逻辑:每个模块在整体稀疏混合专家机制中承担专属职责。

针对专用混合专家,我们采用前文实现的CNeuronMaskMultiWinConv模块,该模块实现了带掩码窗口的卷积运算。该组件依据各专家独立的掩码对输入特征做筛选,实现定向激活,并让专家之间形成清晰的职责划分。

index++;
if(!cExpertsOut.Init(0, index, OpenCL, window_out, experts, window, units_count,
                                               variables, optimization, iBatch))
   return false;
cExpertsOut.SetActivationFunction(None);
index++;
if(!cSharedOut.Init(0, index, OpenCL, window_out, window_out, window, units_count,

                                                 variables, optimization, iBatch))
   return false;
cSharedOut.SetActivationFunction(None);

而对于共享专家,我们则使用标准的CNeuronConvOCL卷积层。

我们选用CNeuronTopKGates模块作为生成激活专家掩码的组件,此模块在DUET框架中已介绍过。该组件在稀疏化机制中起到关键作用:它分析输入token,并严格筛选出数量有限、相关性最高的专家。

换言之,CNeuronTopKGates按照Top-K原理生成激活掩码,将无关通道的值直接置零。该方法可以大幅降低计算开销,同时保持模型强大的表征能力。激活专家数量(topK)由参数指定,可根据具体任务特性灵活调整。

index++;
if(!cMasks.Init(0, index, OpenCL, window, units_count, experts, topK, optimization, iBatch))
   return false;

需要重点理解的是,CNeuronTopKGates对象并非只输出二值掩码。它还会在被选中的通道上生成归一化的概率分布。借此可以灵活调整每个专家对最终结果的贡献度。激活专家会以不同置信度参与计算,而这些概率会直接体现在最终掩码的权重中。

得益于这种设计,该架构不仅具备精简性与计算效率,还拥有良好的抗过拟合能力。每个专家只会在合适场景下被调用,而非随机启用。这一点在处理含噪声或多模态时间序列时尤为重要。

该对象的结构由共享专家门控模块cSharedGates补充完整。它的作用是确定共享专家对模型最终输出的贡献占比。为此,我们采用标准卷积层,用于提取时空特征模式。与各个独立专家所使用的掩码不同,此处采用Sigmoid激活函数,输出0至1区间内的连续平滑数值。

   index++;
   if(!cSharedGates.Init(0, index, OpenCL, window, window, window, units_count, variables,
                                                                    optimization, iBatch))
      return false;
   cSharedGates.SetActivationFunction(SIGMOID);
//---
   return true;
  }

该方案不会生成非开即关的二值决策,而是能够根据当前上下文,灵活调节共享专家的贡献大小。Sigmoid函数起到柔性阻尼器的作用:当模型对通用模式的置信度较高时,输出值趋近于1;当信号较弱时,对应的掩码会抑制输出结果。基于以上机制,可以精准控制架构中局部专用组件与全局泛化组件之间的交互关系。

成功执行全部迭代后,向调用方返回布尔状态值,结束本方法。

接下来,我们进入下一个关键环节:在feedForward方法中实现前向传播流程。稀疏混合专家模块的完整运行逻辑将在此处展开。

bool CNeuronTimeMoESparseExperts::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cExpertsIn.FeedForward(NeuronOCL))
      return false;

首先执行cExpertsIn层的前向传播。该层会根据专用处理通路的数量对输入信号做维度缩放,将待分析信号映射至专家空间。与此同时启动cSharedIn层,这一层是通用性更强的共享专家的第一级处理单元。

if(!cSharedIn.FeedForward(NeuronOCL))
   return false;

接下来激活两条路由分支。cSharedGates层计算共享专家的参与掩码:借助Sigmoid激活函数生成连续型权重掩码。而cMasks对象是专用模块,用于筛选相关性最高的专家,生成离散的Top-K掩码。它不仅剔除未激活通道,还会返回所选通路上归一化后的权重分布。

if(!cSharedGates.FeedForward(NeuronOCL))
   return false;
if(!cMasks.FeedForward(NeuronOCL))
   return false;

在这一阶段,开始生成输出结果。cExpertsOut模块用于在带掩码的专家块上执行卷积运算:每个激活的过滤器拥有独立权重,全部计算流程基于预先准备好的掩码执行。

if(!cExpertsOut.FeedForward(cExpertsIn.AsObject(), cMasks.getOutput()))
   return false;

对于共享专家,由cSharedOut执行标准卷积运算,随后利用cSharedGates预先算出的重要性掩码,对其输出做缩放处理。缩放操作采用逐元素乘法实现。

if(!cSharedOut.FeedForward(cSharedIn.AsObject()))
   return false;
if(!ElementMult(cSharedOut.getOutput(), cSharedGates.getOutput(), cSharedOut.getPrevOutput()))
   return false;

在获取稀疏混合专家模块的输出,以及经过重要性掩码缩放后的共享专家输出之后,将两条信息流融合。该步骤对数据求和,并写入中间数据缓冲区。

   const int window = (int)cSharedGates.GetWindow();
   if(!SumAndNormilize(cExpertsOut.getOutput(), cSharedOut.getPrevOutput(), PrevOutput,
                       window, false, 0, 0, 0, 1))
      return false;
   if(!SumAndNormilize(NeuronOCL.getOutput(), PrevOutput, Output,
                       window, true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

但这仅仅是前向传播最后阶段的第一部分。接下来,引入残差连接,也就是神经网络上一层保存的输出值,将其与当前得到的数值相加。该技术可以保留输入信号中的关键信息,通过稳定梯度来改善模型的收敛性。

所有组件结果合并完成后,在每个token内部(沿分析窗口维度)对结果做归一化处理。该操作使数据落在一致的尺度上,从而保证输出张量能被模型后续正确使用/解释。将处理后的结果存入全局结果接口缓冲区。

由此可见,本模块的前向传播算法是高度分支化的结构。输入数据会同时送入五条不同的信息流,大幅提升模型的灵活性与自适应能力。然而,这种多通道架构在误差反向传播阶段会带来一些挑战。

在calcInputGradients方法中,误差梯度会根据各个内部组件对最终结果的贡献,精细地分配到所有组件。这就如同一位技艺精湛的指挥家,让每件乐器都能和整个乐团完美协奏。

bool CNeuronTimeMoESparseExperts::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

首先,启动向各个信息流分配误差梯度的流程:

  • 稀疏混合专家层输出(cExpertsOut)对梯度施加激活函数导数;
  • 共享专家与门控模块(cSharedOut和cSharedGates)考虑输出值与掩码之间的耦合关系,并结合激活函数完成梯度乘积运算。
if(!DeActivation(cExpertsOut.getOutput(), cExpertsOut.getGradient(), Gradient,
                                                    cExpertsOut.Activation()))
   return false;
if(!ElementMultGrad(cSharedOut.getOutput(), cSharedOut.getGradient(),
                    cSharedGates.getOutput(), cSharedGates.getGradient(),
                    Gradient, cSharedOut.Activation(), cSharedGates.Activation()))
   return false;

随后会递归调用内部对象的隐藏梯度计算函数。通过这种方式,可以逐层拆解各个组件的贡献量:从最开始的专家输入层(cExpertsIn, cSharedIn)一直追溯到原始数据层面,以此保证梯度计算完整且准确。

   if(!cExpertsIn.calcHiddenGradients(cExpertsOut.AsObject(), cMasks.getOutput(),
                                      cMasks.getGradient(), (ENUM_ACTIVATION)cMasks.Activation()))
      return false;
   if(!cSharedIn.calcHiddenGradients(cSharedOut.AsObject()))
      return false;
//---
   if(!NeuronOCL.calcHiddenGradients(cExpertsIn.AsObject()))
      return false;

需要重点关注中间缓冲区中的梯度累加过程。这里,梯度会沿着窗口内的所有token做求和运算,保证来自各个组件的信号能够正确对齐。

   if(!DeActivation(NeuronOCL.getOutput(), PrevOutput, Gradient, NeuronOCL.Activation()))
      return false;
   const int window = (int)cSharedGates.GetWindow();
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cSharedIn.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cMasks.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cSharedGates.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), NeuronOCL.getGradient(), window, false,
                                                                                       0, 0, 0, 1))
      return false;
//---
   return true;
  }

归根结底,calcInputGradients方法能够精细调度复杂的信息流,保障整个稀疏混合专家模块高效训练,让该架构不仅具备强大的建模能力,还能有效抵御误差干扰以及抑制过拟合。

模型参数更新方法采用经典思路:将更新逻辑交由内部组件自行处理。每个组件都会基于得到的梯度,执行自身的权重自适应策略。在updateInputWeights方法体中,只是按顺序调用对应的参数更新流程。因此,为避免内容重复,建议自行研读这部分代码。CNeuronTimeMoESparseExperts类的完整源代码(包含全部方法实现)详见附件。



结论

在本文中,我们详细介绍了掩码卷积模块,以及适配OpenCL环境下时序数据处理任务的稀疏混合专家模块架构。文中探讨了设备端计算配置以及与主程序交互逻辑的核心要点。我们特别关注了在分支信息流条件下如何正确分配梯度。

在下一部分中,我们将继续完善这套架构,重点搭建并训练模型,把该结构作为更复杂神经网络系统的组成部分投入使用。


参考文献


文中所用的程序

# 名称 类型 描述
1 Study.mq5 EA 用于离线模型训练的EA
2 StudyOnline.mq5
EA
用于在线模型训练的EA
3 Test.mq5 EA 用于模型测试的EA
4 Trajectory.mqh 类库 用于描述系统状态与模型架构的结构
5 NeuroNet.mqh 类库 用于构建神经网络的类库
6 NeuroNet.cl 库 OpenCL程序代码库


本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18527

附加的文件 |
MQL5.zip (2856.61 KB)
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
构建交易系统(第 1 部分):量化方法 构建交易系统(第 1 部分):量化方法
许多交易者根据短期表现来评估策略,往往过早放弃盈利的系统。然而,长期盈利能力取决于通过优化的胜率、风险回报比以及有纪律的仓位规模来实现的正期望值。这些原则可以通过 Python 中的蒙特卡洛模拟以及回测指标进行验证,以评估策略是否稳健或是否可能随时间推移而失败。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
通过套接字将 MetaTrader 的 Tick 信息从 MQL5 服务传输到 Python 应用程序 通过套接字将 MetaTrader 的 Tick 信息从 MQL5 服务传输到 Python 应用程序
有时候,并非所有东西都能用 MQL5 语言编程实现。即使有可能在 MQL5 中转换现有的高级库,那也将是一项耗时的任务。本文试图证明,我们可以通过使用套接字将 MetaTrader 服务中的买卖报价和时间等 tick 信息传输到 Python 应用程序中,从而绕过对 Windows 操作系统的依赖。