English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 94 部分):优化输入序列

神经网络变得简单(第 94 部分):优化输入序列

MetaTrader 5交易系统 | 27 二月 2025, 07:48
358 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

处理时间序列时,一种常见的方法是保持时间步骤的原始排列不变。假设历史顺序是最优的。然而,大多数现有模型缺乏明确的机制,来探索每个时间序列中相距遥远片段之间的关系,而实际上也许具有很强的依赖性。例如,用于时间序列学习的基于卷积网络(CNN)的模型,只能在有限的时间窗口内捕获形态。结果就是,在分析时间序列时,若重要形态跨越较长时间窗口,则此类模型难以有效地捕获信息。深度网络的运用能提升感知域的大小,并部分解决问题。但是覆盖整个序列所需的卷积层数量也许会太大,并且模型过大会导致梯度消失问题。

当用在变换器架构模型中,长期依赖关系的有效性检测在高度取决于许多因素。这些包括序列长度、各种定位编码策略、和数据令牌化。

这些想法导致论文《分段、乱序、和拼接:一种改进时间序列表示的简单机制》的作者提出寻找历史序列最优用法的思路。是否更好的组织时间序列,以便手头任务能实现更有效的表示学习?

在本文中,作者提出了一种简单、且即用型的机制,称为分段、乱序、拼接(S3),设计用来学习如何优化时间序列的表示。顾名思义,S3 的工作原理是将时间序列分割为多个不重叠的分段,将这些分段随机排序为更优化的顺序,然后将乱序后的分段再组合成新序列。此处应注意的是,针对每个特定任务都要学习分段洗牌顺序。

除此之外,S3 还通过可学习的加权累加运算,将原始时间序列与乱序后的版本集成在一起,从而保留原始序列中的关键信息。

S3 充当一种模块化机制,设计用于与任何时间序列分析模型无缝集成,在更顺畅的训练过程得到成果,并减少误差。由于 S3 是与主干网络一同训练的,因此洗牌参数会被有目的地更新,以便适应源数据和底层模型的特征,从而更好地反映时间动态。此外,S3 可以堆叠,从而创建一个粒度较高的更细致乱序。

所提议算法只有少量需要调整的超参数,仅需一点额外的计算资源。

为了评估所提议方法的有效性,方法作者将 S3 集成到各种神经架构当中,包括基于 CNN变换器的模型。依据单变量和多元预测分类问题的各种数据集的性能评估验明,在其它条件相同的情况下,S3 的加盟导致显著改善训练模型的效率。结果表明,将 S3 集成到当下方法中,能够在分类任务中提供高达 39.59% 的性能提升。对于一维和多维时间序列预测任务,模型的效率分别可以提升 68.71% 和 51.22%。

1. S3 算法

我们来更详尽地研究所提议 S3 方法。

对于输入数据,该方法使用 多维时间序列 X,其由 T 时间步骤、及 C通道组成的,并切分为 N 个不相交的分段。

我们研究了多元时间序列的普遍情况,尽管该方法也能配合单变量时间序列工作。实际上,当通道 C 的数量等于 1 时,一维时间序列可以被认为是多维序列的特例。

该方法瞄准的是,以最优方式重新排列片段,以便形成新的序列 X',这将令我们能够更好地捕获时间序列中的主要时态关系、和依赖性。反过来,这会导致对目标任务的理解更加深入。

S3 方法作者提出了分三个阶段解决上述问题的方法:“分段”、“混合”、和“组合”。

分段模块将原始序列 X 拆分为 N 个不相交的分段,每个分段都包含 τ 个时间步骤,其中 τ = T/N。这组分段可以表示为 S = {s1, s2, . . . , sn}

这些分段被投喂到乱序模块之中,其用到混合向量 P = {p1, p2, . . . , pn},以便按照最优顺序重新排列分段。向量 P 中的每个乱序参数 pj 都与矩阵 S 中的段 sj 匹配。基本上,P 是一组由网络优化的可学习权重,它控制在重新排序的序列中分段位置和优先级。

乱序过程非常简单直观:pj 的值越高,分段 sj 在乱序后的序列中的优先级就越高。乱序后的序列 Sshuffled 可以表示为:

默认情况下,基于排序的顺序 P 的置换 S 是不可微分的,因为它涉及离散运算,并引入不连续性。软性排序方法通过分配概率来近似排序,其反映每个元素相比其它元素大了多少。虽然该近似值性质上是可微分的,但它可能会引入噪声、及不准确性,令排序变得不直观。为了达成与传统方法一样准确和直观的可微分排序和乱序,该方法的作者引入了几个中间步骤。这些步骤经由经乱序参数 P为梯度创建了一条路径。

首先,我们使用 σ = Argsort(P) 获取对 P 元素排序的索引。我们有一个张量列表 S = {s1, s2, s3, ...sn},我们希望基于索引列表 σ = {σ1, σ2, ..., σn},以可微分的方式重新排序。然后我们创建一个 U 矩阵,大小为 (τ × C) × n × n,其中我们每个 si 重复 N 次。

之后,我们形成一个 Ω 矩阵,大小为 n x n,其中每行 j 在位置 k = σj 处都有一个非零元素。我们将 Ω 矩阵转换为二元矩阵,按比例因子将每个非零元素缩放为 1。该过程为梯度创建了一条在反向传播期间流经 P 的路径。

UΩ 之间执行 Hadamard 乘积,我们得到一个矩阵 V,其中每行 j 都有一个非零元素 k 等于 sk。对最后一个维度求和,并转置结果矩阵,我们得到最终的乱序后矩阵 Sshuffled

使用多维矩阵 P' 允许我们引入额外的参数,令模型能够捕获更复杂的表示。因此,S3 方法作者引入了一个超参数 λ 来判定 P' 的维度。然后,我们涵盖第一个 λ − 1 维度执行 P' 求和,得到一个一维向量 P,然后用它来计算 σ = Argsort(P) 的排列索引。

这种方式允许提升乱序参数的数量,从而捕获时间序列数据中的更复杂依赖关系,而不会影响排序操作。

在最后一步中 ,拼接模块将乱序后的片段 Sshuffled 串联起来,创建一个乱序后的序列 X'

为了保留所分析时间序列的原始顺序中存留的信息,它们按参数 w1w2 对原始序列和混合序列执行加权求和,其也经由主模型训练进行了优化。

S3 视为一个模块化级别,我们可以将它们堆叠到神经架构之中。我们将 φ 定义为超参数,来判定 S3 层数。为简单起见,并避免为每个 S3 层定义单独的分段超参数,该方法的作者定义了一个参数 θ 作为后续层中分段数量的乘数。

当多个 S3 层堆叠时,每个 l 级别从 1 到 φ 的分段、和乱序输入数据,都基于前一层输出。

S3 的所有可学习参数都与模型参数一同更新,且不会为 S3 层引入中间损失。这可确保根据特定任务和基级训练 S3 级别。

如果输入序列 X 的长度不能被分段数量 N 整除,我们从输入序列中截断前 T mod N 时间步骤,再重新排序。为了确保不会丢失任何数据,且输入和输出形状相同,我们稍后将截断的样本回加到最终 S3 层输出的开头。

该方法的原始可视化如下所示。

于此可以补充一点,基于论文中呈现的实验结果,排列参数在训练的初始阶段进行了调整。之后,它们将会固定,以后不会更改。


2. 利用 MQL5 实现

在研究过 S3 方法的理论层面之后,我们转到本文的实践部分,在其中我们利用 MQL5 中实现提议的方式。但在我们开始编写代码之前,我们参照我们现有的开发思考有关所提议方式的架构。

2.1解决方案架构


为了判定数据排序顺序,S3 方法作者用到一个可学习参数 P 的向量。在我们的函数库中,可学习的参数仅存在于神经层之中。嗯,我们可用一个神经层来生成分段优先级。在这种情况下,参数训练可由神经层内的可用方法运作。但有一个细微差别:我们需要为神经层投喂训练参数时不曾提供过的输入。状况很简单:我们将一个填充有 “1” 的固定向量输入到这样的神经层之中。

这种方式令我们能够立即解决排列 P' 的多维矩阵问题。为了更改该矩阵的维度(S3 方法的作者定义了超参数 λ),我们只需要更改原始数据向量的大小。其余功能保持不变。每个分段的单独参数的求和,已经在我们的神经层中实现。这样一个神经层的结果大小等于分段数量。

为了将分段优先级转换为概率值的域,我们将调用 SoftMax 函数。

我们将使用类似的方式,来求解原始序列和乱序后序列的影响权重参数。这次层结果大小为 2。作为该层的激活函数,我们将使用 sigmoid。

这些是可学习的参数。至于按概率升序或降序进行分段排序的算法,我们将需要实现此功能。

理论上,独立分段的优先级的排序顺序(升序或降序)无关紧要。因为我们将学习分段的排列顺序。相应地,在训练过程中,模型会按照指定的排序顺序分派优先级。于此重要的是,在模型的训练和操作期间,排序保持不变。

为了令梯度误差传播到优先向量 P,该方法的作者提出了一种相当复杂的算法,他们在其中创建多维数组,并重复输入。这会导致额外的计算成本,和内存消耗的增加。我们能否提供更高效的选项?

我们看看 S3 方法作者提出的流程、分析动作、及结果。

首先,形成一个矩阵 U,它是原始数据的多重副本。我想排除该过程,这将降低与存储大型矩阵相关的内存消耗,以及复制数据时消耗的计算资源。

第二个矩阵 Ω 是一个二元矩阵,大部分填充为零值。非零值的数量等于所分析序列中的段数(N)。零元素的数量是 N - 1 倍。此处,我们应当使用一个稀疏矩阵,这将降低矩阵相乘时的内存消耗、和计算成本。

接下来,根据 S3 算法,进行元素级矩阵乘法,然后沿最后一个维度进行加法,及结果矩阵转置。

作为上述所有操作的结果,我们简单地得到一个乱序的原始张量。排列张量元素的简单操作将需要更少的资源,并且执行更迅捷。

作者开发了这样一种复杂的排列算法,来实现误差梯度传播到优先级向量 P。在某种程度上,这是 PyTorch 自动微分的 “陷阱”,该方法的作者在构造算法时用到它。

我们正在构建前馈和反向传播算法。这当然增加了我们构建算法的成本,但也为我们在构建流程方面提供了极大的灵活性。因此,在前馈通验中,我们能够用直接数据乱序来替换上述操作。显然,这是一种更有效的方式。

现在我们必须决定误差梯度传播问题。当输入进行乱序时,每个分段在输出张量中只参与一次。接着,整个误差梯度会被传播到相应的分段。换言之,当将误差梯度分派到输入数据时,我们需要执行分段的逆排列。这次我们将配合误差梯度张量工作。

第二个问题:如何将误差梯度传播到优先级向量。此处的算法稍微复杂一些。在前馈通验中,我们对整个分段使用一个优先级。因此,在反向传播通验中,我们必须按一个优先级收集整个分段的误差梯度。为此,我们需要将所期待分段的输入向量乘以误差梯度张量的相应分段。

此外,在构造二元矩阵 Ω 时,我们用比例因子将非零元素转换为 1。显然,为了将非零数转换为 1,您需要将其除以相同的数字、或乘以其倒数。因此,比例因子等于优先级数字的倒数。这意味着上面获得的误差梯度值必须除以分段优先级。

此处应当注意,分段优先级不应等于 “0”。调用 SoftMax 函数允许我们排除该选项。但它并不排除足够小的值,除以这些值会导致足够大的误差梯度值。

此外,在形成分段优先级的概率时,调用 SoftMax 函数可保证所有值都在 (0, 1) 范围内。显然,优先级较低的分段会收到较大的误差梯度,因为除以小于 1 的数字,会得到大于被除数的结果。

是故,这些都是这个算法中的微妙时刻。考虑到这些,我们现在转到代码中实现它。我们先从 OpenCL 关联环境端的实现开始。

2.2构建 OpenCL 内核


如常,我们从实现前馈算法开始。在 OpenCL 程序端,我们首先创建 FeedForwardS3 内核。

我想在这里提醒您,我们将在嵌套神经层中实现分段分布概率的生成,以及原始序列乱序后序列的加权求和。这意味着该内核以参数的形式接收制备的数据。

因此,我们的内核接收指向 5 个数据缓冲区的指针,和 2 个参数常量。这 3 个缓冲区包含输入:原始序列、分段概率、和权重。另外两个缓冲区用于记录内核输出。在其中一个里,我们将记下输出序列,在第二个中,我们将写入执行反向传播操作时所需的分段乱序索引。

在常量中,我们将指定一个分段的窗口大小,及序列中元素的总数。

注意,在第二个常量中,我们指定输入向量的大小,而非分段或时间步骤的数量。在分段窗口大小中,我们还指定了数组元素的数量,而非时间步骤。因此,这两个常数必须能被一个时间步骤的向量大小整除,且没有余数。

__kernel void FeedForwardS3(__global float* inputs,
                            __global float* probability,
                            __global float* weights,
                            __global float* outputs,
                            __global float* positions,
                            const int window,
                            const int total
                           )
  {
   int pos = get_global_id(0);
   int segments = get_global_size(0);

我们计划基于被分析序列中的分段数量在一维任务空间中启动内核。在内核主体中,我们立即识别当前流,并基于正在运行的任务数量判定分段总数。

对于总输入大小不是某个分段的窗口大小倍数的情况,我们将分段总数减少 1。

   if((segments * window) > total)
      segments--;

在下一步中,我们对分段优先级进行排序,从而判定其顺序。不过,我们不会以纯粹的形式组织排序算法。取而代之,我们将判定所分析分段在序列中的位置。为了判定 1 个元素的位置,我们只需遍历 1 次分段概率向量。不过,在向量排序时,我们需要遍历若干次概率向量,并同步计算线程。

在此,我们将算法拆分为 2 个分支,具体取决于当前线程的索引。第一个分支是一般情况,如果当前线程索引小于段数,则适用第一个分支。考虑到第一个线程的索引等于 0,给定条件的公式也许看似很奇怪。早前,当研究输入大小不是分段窗口大小倍数的情况时,我们减少了分段数量的变量值。在这种情况下,最后的线程将遵循算法的第 2 个分支来判定分段位置。

通常,为了判定与当前作线程对应的分段位置,我们在局部常量中将其优先级固定。我们运行一个循环,从第一个到当前分段,其中我们计数优先级小于、或等于当前分段的元素数量。对于降序排序的情况,我们判定优先级大于、或等于当前分段的元素数量。

然后我们组织一个循环,从下一分段到最后一个分段,其中我们添加优先级约束较低的元素数量(按降序排序时约束更多)。

在完成两个循环的运算之后,我们得到当前分段在整个序列中的位置。

   int segment = 0;
   if(pos < segments)
     {
      const float prob = probability[pos];
      for(int i = 0; i < pos; i++)
        {
         if(probability[i] <= prob)
            segment++;
        }
      for(int i = pos + 1; i < segments; i++)
        {
         if(probability[i] < prob)
            segment++;
        }
     }

将遍历优先级向量的拆分到 2 个循环,完成有 2 个或更多具有相同优先级的元素的特殊情况。在这种情况下,将优先分派给原始序列中较早的元素。好的,我们可以构造一个内含一次循环的算法,但在这种情况下,在比较优先级之前,我们必须在每次迭代时检查该分段位于原始序列中当前分段之前,亦或之后。

在算法的第二条特殊情况分支中,我们只需赋值分段数字等于其在序列中的顺序。在上述特殊情况下,所有完整的分段将被混合,最后一个(未完成)的分段将保留在其位置。

   else
      segment = pos;

现在我们已经判定了分段在乱序后的序列中的位置,我们可以移动它。为此,我们定义输入和输出缓冲区中的偏移量。

   const int shift_in = segment * window;
   const int shift_out = pos * window;

我们立即在相应的缓冲区中保存确定位置。

   positions[pos] = (float)segment;

我们不要忘记原始序列和混合序列的加权总和。当然,为了避免不必要的数据复制到结果缓冲区,我们将立即保存来自原始序列和混合序列中 2 个分段的加权和。为此,我们将权重参数保存在局部常数之中。

   const float w1 = weights[0];
   const float w2 = weights[1];

我们创建一个迭代,其循环次数等于一个分段窗口大小,其中我们参考权重对 2 个序列的元素求和,并将获得的值保存在结果缓冲区之中。

   for(int i = 0; i < window; i++)
     {
      if((shift_in + i) >= total || (shift_out + i) >= total)
         break;
      outputs[shift_out + i] = w1 * inputs[shift_in + i] + w2 * inputs[shift_out + i];
     }
  }

构建前馈通验内核后,我们转到反向传播通验的工作。于此,我们开始构建 InsideGradientS3 内核,其中我们将误差梯度分派到前一层的级别、及分段的优先级。在内核参数中,我们添加指向相应误差梯度缓冲区的指针,这些值会被添加到之前研究过的缓冲区之中。

__kernel void InsideGradientS3(__global float* inputs,
                               __global float* inputs_gr,
                               __global float* probability,
                               __global float* probability_gr,
                               __global float* weights,
                               __global float* outputs_gr,
                               __global float* positions,
                               const int window,
                               const int total
                              )
  {
   size_t pos = get_global_id(0);

内核将根据所分析序列中的分段数量在一维任务空间中启动。在内核主体中,我们立即标识当前的操作线程。在这种情况下,我们不需要判定分段的总数。

接下来,我们从数据缓冲区加载在前馈通验期间判定的常量。

   int segment = (int)positions[pos];
   float prob = probability[pos];
   const float w1 = weights[0];
   const float w2 = weights[1];

之后,我们在判定数据缓冲区中的偏移量。

   const int shift_in = segment * window;
   const int shift_out = pos * window;

我们将为中间数据声明局部变量。

   float grad = 0;
   float temp = 0;

在下一步中,我们创建一个循环,迭代次数等于分段窗口大小,于其中我们收集分段优先级的误差梯度。

   for(int i = 0; i < window; i++)
     {
      if((shift_out + i) >= total)
         break;
      temp = outputs_gr[shift_out + i] * w1;
      grad += temp * inputs[shift_in + i];

同时,我们将误差梯度传送到前一层缓冲区。在前馈通验期间,我们对原始序列和乱序后的序列求和。因此,每个输入元素都应从 2 个具有相应权重的线程接收误差梯度。

      inputs_gr[shift_in + i] = temp + outputs_gr[shift_in + i] * w2;
     }

在把分段优先级误差梯度写入相应的数据缓冲区之前,我们将输出值除以当前分段的概率。

   probability_gr[segment] = grad / prob;
  }

上面研究过的梯度传播内核缺少一点:依据原始序列和混合序列的权重上的误差梯度传播。为了实现此功能,我们将创建一个单独的内核 WeightGradientS3

于此应该说,我们常采用的方式,即在每个单独的线程中收集 1 个元素的误差梯度时,在这种情况下不是很有效。这是因为权重向量中的元素数量较少。如您所见,此处只有 2 个。不过,最好使用更多的并行线程来减少训练模型所花费的总时间。为了达成这种效果,我们将创建两个线程工作组,每组都会收集其参数的误差梯度。

__kernel void WeightGradientS3(__global float *inputs,
                               __global float *positions,
                               __global float *outputs_gr,
                               __global float *weights_gr,
                               const int window,
                               const int total
                              )
  {
   size_t l = get_local_id(0);
   size_t w = get_global_id(1);

相应地,内核将在 2-维任务空间中启动。第一个维度定义一个组中的并行线程数量。第二个维度示意为其收集误差梯度的参数的索引。

然后我们声明一个局部数组,每个线程都将将其部分工作保存到该数组之中。

   __local float temp[LOCAL_ARRAY_SIZE];

由于工作线程的数量不能大于所声明局部数组的大小,故我们强制限定 “workhorses” 的数量。

   size_t ls = min((uint)get_local_size(0), (uint)LOCAL_ARRAY_SIZE);

在第一阶段,每个线程独立于工作组中的其它线程收集其误差梯度份额。为此,我们运行一个循环,覆盖当前层输出端的误差梯度缓冲区的元素,从含有当前线程工作组索引的元素开始,到数组中的最后一个元素,步骤等于 “workhorses” 的数量。

在循环主体中,我们首先判定源数据缓冲区中相应元素的偏移量。该偏移量取决于我们收集误差梯度的权重索引。对于第二个权重,层误差和输入数据的梯度缓冲区偏移是相同的。

   if(l < ls)
     {
      float val = 0;
      //---
      for(int i = l; i < total; i += ls)
        {
         int shift_in = i;

对于第一个偏移量,我们首先在误差梯度缓冲区中定义一个分段。然后,从排列向量中,我们提取原始序列中的相应分段。只有这样,我们才能计算所需元素在输入缓存区中的偏移量。

         if(w == 0)
           {
            int pos = i / window;
            shift_in = positions[pos] * window + i % window;
           }

给定对应元素在两个数据缓冲区中的索引,我们计算该位置的权重误差梯度,并将其添加到累积变量当中。

         val += outputs_gr[i] * inputs[shift_in];
        }
      temp[l] = val;
     }
   barrier(CLK_LOCAL_MEM_FENCE);

在完成循环的所有迭代后,我们将误差梯度的累加和写入局部内存数组的相应元素,并为工作组线程实现同步屏障。

在第二步中,我们将局部数组的元素值求和。

   int t = ls;
   do
     {
      t = (t + 1) / 2;
      if(l < t && (l + t) < ls)
        {
         temp[l] += temp[l + t];
         temp[l + t] = 0;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(t > 1);

在内核操作结尾处,工作组的第一个线程将总误差梯度传输到全局缓冲区的相应元素。

   if(l == 0)
      weights_gr[w] = temp[0];
  }

根据对整体结果的影响,分派误差梯度至所有元素之后,我们通常会转到参数更新算法的工作。但在本文的框架内,我们已在嵌套神经层中组织了所有可训练的参数。因此,上述对象内已经提供了更新参数的算法。因此,我们此结束 OpenCL 端的操作,并转到主程序的工作。

2.3. 创建 CNeuronS3


为了在主程序端实现所提议方式,我们创建了一个新的神经层类 CNeuronS3。其结构如下所示。

class CNeuronS3   :  public CNeuronBaseOCL
  {
protected:
   uint              iWindow;
   uint              iSegments;
   //---
   CNeuronBaseOCL    cOne;
   CNeuronConvOCL    cShufle;
   CNeuronSoftMaxOCL cProbability;
   CNeuronConvOCL    cWeights;
   CBufferFloat      cPositions;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      feedForwardS3(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradientsS3(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronS3(void)   {};
                    ~CNeuronS3(void)   {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronS3;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual CLayerDescription* GetLayerInfo(void);
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

在这个类中,我们声明了 2 个变量,和 5 个嵌套对象。这些变量将存储一个分段窗口大小,及序列中的分段总数。至于嵌套对象的用途,我们在实现类方法时再研究它们。

该类的所有对象都声明为静态。这样允许我们将类的构造函数和析构函数“留空”。所有嵌套对象的初始化都在 Init 方法中执行。如常,在该方法的参数中,我们从调用者那里接收类架构的主要参数。注意以下 2 个参数:

  • window — 1 个分段的窗口大小;
  • numNeurons — 层中的神经元数量。

在这些参数中,我们指示数组元素的数量,替代时间序列的步骤。不过,它们的数值必须是描述一个时间步骤的向量大小的倍数。换言之,为了便于实现,我们构建了一个配以一维时间序列工作的类。于此,用户负责维护分段内多维时间序列的时间步骤的完整性。

bool CNeuronS3::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                     uint window, uint numNeurons, 
                     ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

在方法主体中,我们首先调用父类的相同方法,由其控制接收到的参数,并初始化继承对象。记住要控制被调用方法的执行。

继承的对象成功初始化之后,我们保存 1 个分段的窗口大小,并立即判定分段的总数。

   iWindow = MathMax(window, 1);
   iSegments = (numNeurons + window - 1) / window;

接下来,我们初始化类的内部对象。首先,我们初始化一个由单个值组成的固定神经层,它会用来生成分段排列优先级,和加权序列求和参数的输入。在此,我们首先初始化神经层,然后用单一值强制填充结果缓冲区。

   if(!cOne.Init(0, 0, OpenCL, 1, optimization, iBatch))
      return false;
   CBufferFloat *buffer = cOne.getOutput();
   if(!buffer || !buffer.BufferInit(buffer.Total(),1))
      return false;
   if(!buffer.BufferWrite())
     return false;

请注意以下两点。首先,我们创建一个 1 个神经元的层。如您所知,当我们依据实现的架构工作时,我们曾说过,给定层中的神经元数量将指示排列矩阵的维度。我看不出使用多维矩阵有什么意义。从数学观野来看,若不用中间激活函数,将若干个变量的乘积与一个常数相加的线性函数就退化为一个变量乘以所用常数的乘积。

从这个观角来看,参数的增加只会导致计算复杂度的增加,对模型效率的影响令人质疑。

另一方面,这只是我的观点。是故,您可通过实验来测试这一点。

第二点是该嵌套层的输出连接 “0” 的示意。我们计划将该对象当作 2 个神经层的初始数据。正是后续 2 个层的存在迫使我们求助于一个小技巧。我们的基础神经层的设计方式是,它只包含 1 个后续层的权重矩阵。但是我们有一个卷积神经层类,其中包含传入连接的权重系数矩阵。委婉地说,在输出端使用 1 个输入元素、及多个排列优先级,并不完全适合使用卷积层的场景。但等等。

保证一个输入元素在卷积滤波器中为我们提供 1 个可学习参数。还有,我们能够指定所需数量的卷积滤波器,来轻松提供排列向量的大小。在这种情况下,我们将只指定 1 个卷积元素。以这种方式,我们将可学习的参数传送到后续的神经层。

   if(!cShufle.Init(0, 1, OpenCL, 1, 1, iSegments, 1, optimization, iBatch))
      return false;
   cShufle.SetActivationFunction(None);

如早前所讨论,我们调用 SoftMax 函数将排列优先级变换至概率域。

   if(!cProbability.Init(0, 2, OpenCL, iSegments, optimization, iBatch))
      return false;
   cProbability.SetActivationFunction(None);
   cProbability.SetHeads(1);

我们针对序列加权求和生成参数的对象所做相同。不同的是,于此我们调用 sigmoid 作为激活函数。

   if(!cWeights.Init(0, 3, OpenCL, 1, 1, 2, 1, optimization, iBatch))
      return false;
   cWeights.SetActivationFunction(SIGMOID);

在初始化方法的末尾,我们创建一个缓冲区来记录分段排列索引。

   if(!cPositions.BufferInit(iSegments, 0) || !cPositions.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

该类包含两个新方法(feedForwardS3calcInputGradientsS3)。它们将之前创建的 OpenCL 程序内核推入执行队列当中。正如您所猜,第一种方法为前馈内核的执行排队,第二种方法为剩余的两个误差梯度分派内核排队。在之前文章中,我们已讨论过将内核推入执行队列的算法。这些方法建立在类似的算法之上,故我们现在不在研究它们。您可在附件中找到这些方法的代码。附件还包含准备文章时用到的所有程序的完整代码。

我们类的前馈算法是在 feedForward 方法中构建的。就像父类同名方法一样,该方法在参数中接收指向前一个神经层对象的指针,其中包含输入数据。

在调用将前馈内核放入队列的方法之前,我们需要准备分段排列的优先级,和原始序列乱序后的序列加权之和的参数。此处应当注意的是,所指示过程的初始数据是固定的单位值向量。因此,它们的值不依赖于初始数据,并且在模型运行期间不会改变。仅在学习过程中学习参数发生变化时,指定的值才能更改。这意味着它们仅在学习过程中才有必要重新计算。为了重新计算值,我们调用相应嵌套对象的前馈方法。

bool CNeuronS3::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(bTrain)
     {
      if(!cWeights.FeedForward(cOne.AsObject()))
         return false;
      if(!cShufle.FeedForward(cOne.AsObject()))
         return false;
      if(!cProbability.FeedForward(cShufle.AsObject()))
         return false;
     }

接下来,我们调用方法将前向通验内核放入执行队列,以便针对原始序列进行乱序。

   if(!feedForwardS3(NeuronOCL))
      return false;
//---
   return true;
  }

切记要控制操作的执行。

梯度分派法算法中并无什么异常。该方法仅在学习过程中被调用,我们不需要检查模型的当前操作模式。

在参数中,该方法接收到一个指向上一层对象的指针,其中是必须传递给该对象的误差梯度,且我们立即调用该方法对上述创建的误差梯度分派内核进行排队。

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

接下来,我们将误差梯度传递给分段乱序优先级参数层。

   if(!cShufle.calcHiddenGradients(cProbability.AsObject()))
      return false;

将误差梯度进一步传播到固定层级别是没有意义的。因此,我们将跳过该过程。我们只需为可能的激活函数校正获得的误差梯度。

   if(cWeights.Activation() != None)
      if(!DeActivation(cWeights.getOutput(), cWeights.getGradient(), cWeights.getGradient(), cWeights.Activation()))
         return false;
   if(NeuronOCL.Activation() != None)
      if(!DeActivation(NeuronOCL.getOutput(),NeuronOCL.getGradient(),NeuronOCL.getGradient(),NeuronOCL.Activation()))
         return false;
//---
   return true;
  }

注意,仅当相应对象中有了误差梯度时,我们才会调用方法停止激活误差梯度。

根据误差梯度对整体结果的影响,将其传播到模型的所有元素之后,我们需要调整模型参数,如此这般降低整体误差。这十分直截了当。为了调整 CNeuronS3 层的参数,我们只需要调用相应嵌套对象的参数更新方法即可。

bool CNeuronS3::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cWeights.UpdateInputWeights(cOne.AsObject()))
      return false;
   if(!cShufle.UpdateInputWeights(cOne.AsObject()))
      return false;
//---
   return true;
  }

新类方法的讲述到此结束。不可能在一篇文章中讲完我们新类的所有方法,但您可自行研习它们,因为所有代码都已在附件中提供。您将在那里找到该类、及其所有方法的完整代码。

2.4模型架构


创建新层的类后,我们将在模型架构中实现它。我认为很明显,我们将 CNeuronS3 类添加到环境状态编码器架构中。在本文中,我不会详解编码器架构,因其完全是从上一篇文章里复制而来的。我们只关注添加的神经层,我们将其放在源数据层之后。

我要提醒您,我们的测试模型是为了分析 H1 时间帧的历史数据而构建的。为了进行分析,我们使用最后 120 根历史柱线,每根柱线都由 9 个参数描述。

#define        HistoryBars             120           //Depth of history
#define        BarDescr                9             //Elements for 1 bar description

在准备本文时,我们实现了 3 个连续的层,将乱序后输入送至编码器。对于第一层,我们用到了 12 个时间步骤(小时)的分段。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronS3;
   descr.count = prev_count;
   descr.window = 12*BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在第二层中,我们将分段大小减少到 4 个时间步骤。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronS3;
   descr.count = prev_count;
   descr.window = 4*BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在最后一个中,我们在每个时间步骤乱序。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronS3;
   descr.count = prev_count;
   descr.window = BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

进一步的架构也完全复制自之前的文章,没有任何变化。这意味着我们没有针对环境交互、训练、和测试模型的程序算法进行任何修改,。您可在附件中找到本文用到的所有程序的完整代码。


3. 测试

我们已构造了所提议方式的算法。现在,我们转到也许是最令人兴奋的阶段 — 测试和评估结果。

如上所述,在撰写本文时,我们没有对环境交互程序进行任何更改。这意味着我们可复用以前收集的训练数据集来训练模型。

我要提醒您,为了训练模型,我们利用 MetaTrader 5 策略测试器中环境交互程序的通验记录,并依据 EURUSD 金融产品 2023 年全年的真实历史数据,H1 时间帧。

在第一步中,我们训练环境状态编码器。该模型经过训练,可预测所分析时间序列的后续 24 个元素的数据。

#define        NForecast               24            //Number of forecast

换言之,我们的模型尝试预测第二天的价格走势。在构建个体行为政策时,我们不依赖接收的预测值,而是依赖编码器的隐藏状态。因此,在训练模型时,我们对即将到来的走势的准确预测并不那么感兴趣 - 我们评估编码器在其隐藏状态下捕获和加密即将到来的价格走势的主要趋势和趋向性的能力。

经训练的编码器模型仅分析市场状态,且未考虑账户状态和持仓。因此,在模型训练期间,更新训练数据集不会提供其它信息。这允许我们在先前创建的数据集上训练模型,直到获得所需的结果。

基于第一个训练步骤的结果,我们可以评估新层对模型获得的初始数据的影响。于此,我应注意的是,该模型对乱序和原始序列的关注几乎等同。只是略微多关注一下后者。

在第一层,乱序后的序列其系数为 0.5039,原始序列的系数则为 0.5679。同时,序列几乎彻底乱序。仅有索引为 7 的分段保留在其位置。而且乱序完全是随机的。没有一对元素只是简单地交换位置。


在下一层,两个系数分别略微提升至 0.6386 和 0.6574。我不会提供排列清单,因为它已经增加了三倍。它不再包含非乱序的分段。

在第三层中,对原始序列给予了更多的关注,但乱序后的序列其系数仍然相当高。参数分别变化为 0.5064 和 0.7089。

获得的结果可通过不同的方式评估。以我观点,该模型在分段的成对比较中寻求合理性。

获得的结果非常有趣,但我们更感兴趣的是对个体最终政策的影响。编码器训练之后,我们转到训练模型的第二阶段。在该阶段,我们训练扮演者政策和评论者模型。这些模型的操作高度依赖账户状态和所分析时刻的持仓。因此,我们的学习过程将是迭代的,在训练模型、和收集有关与环境交互的额外数据之间交替。这就允许我们改进和优化个体的行为政策。

在训练过程期间,我们能够训练出一个政策,以便在训练和测试期间均能产生盈利。训练结果如下所示。

尽管获得了盈利,但资余额图表并不好看。该模型仍需要一些改进。如果您更细致地查看测试报告,则星期一和星期五两天凸显无利可图。与之对比,在周三,该模型产生了最大的盈利。


因此,将模型操作限定在一周中的某些日子将提高模型的整体盈利能力。但这个假设需要在更具代表性的数据集上进行更详细的测试。


结束语

在本文中,我们讨论了一种相当有趣的优化时间序列的方法:S3。该方法于论文《分段,乱序,和拼接:提升时间序列表示的一种简单机制》中提出。该方法的主要思想是改善时间序列表示的品质。S3 的应用可以提高分类准确性、及模型稳定性。

在本文的实践部分,我们已利用 MQL5 构建了我们所提议方式的愿景。我们使用所提议方式训练和测试了模型。结果非常有趣。


参考

文中所用程序

# 名称 类型 说明
1 Research.mq5 智能交易系统 样本收集 EA
2 ResearchRealORL.mq5
智能交易系统
运用 Real-ORL 方法收集示例的 EA
3 Study.mq5 智能交易系统 模型训练 EA
4 StudyEncoder.mq5 智能交易系统
编码训练 EA
5 Test.mq5 智能交易系统 模型测试 EA
6 Trajectory.mqh 类库 系统状态定义结构
7 NeuroNet.mqh 类库 创建神经网络的类库
8 NeuroNet.cl 代码库 OpenCL 程序代码库

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

附加的文件 |
MQL5.zip (1324.88 KB)
开发回放系统(第 58 部分):重返服务工作 开发回放系统(第 58 部分):重返服务工作
在回放/模拟器服务的开发和改进暂停之后,我们正在恢复该工作。现在我们已经放弃使用终端全局变量等资源,我们将不得不完全重组其中的一些部分。别担心,我们会详细解释这个过程,这样每个人都可以关注我们服务的发展。
使用MQL5和Python构建自优化EA(第二部分):调整深度神经网络 使用MQL5和Python构建自优化EA(第二部分):调整深度神经网络
机器学习模型带有各种可调节的参数。在本系列文章中,我们将探讨如何使用SciPy库来定制您的AI模型,使其适应特定的市场。
人工蜂巢算法(ABHA):理论及方法 人工蜂巢算法(ABHA):理论及方法
在本文中,我们将探讨2009年开发的人工蜂巢算法(ABHA)。该算法旨在解决连续优化问题。我们将研究ABHA如何从蜂群的行为中汲取灵感,其中每只蜜蜂都有独特的角色,帮助它们更有效地寻找资源。
开发回放系统(第 57 部分):了解测试服务 开发回放系统(第 57 部分):了解测试服务
需要注意的一点是:虽然服务代码没有包含在本文中,只会在下一篇文章中提供,但我会解释一下,因为我们将使用相同的代码作为我们实际开发的跳板。因此,请保持专注和耐心。等待下一篇文章,因为每一天都变得更加有趣。