English Русский Español Deutsch 日本語 Português
preview
交易中的神经网络:时间序列的分段线性表示

交易中的神经网络:时间序列的分段线性表示

MetaTrader 5交易系统 | 17 三月 2025, 09:32
203 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

大多数情况下,当我们谈论有关时间序列的表示时,呈现在我们眼前的数据是按时间顺序记录的一连串点。不过,随着初始信息量的提升,其分析的复杂性也会提升,这会降低使用可用信息的效率。这在金融市场工作时尤其重要,因为花费额外的时间来分析信息,并做出决策可能会导致利润损失的风险提升,有时甚至会亏损。这就是降低数据维度的优势能发挥特殊作用的地方,以此提高其智能分析的效率、及有效性。降低数据维度的一种方式是时间序列的分段线性表示。

时间序列的分段线性表示是一种利用涵盖小间隔的线性函数逼近时间序列的方法。在本文中,我们将讨论时间序列的双向分段线性表示(BPLR)算法,该算法在论文《时间序列的双向分段线性表示及其在集体异常检测中的应用》中提出。提出该方法是为了解决在时间序列中查找异常相关的问题。

时间序列异常检测是时间序列数据挖掘的一个主要子领域。其目的是识别整个数据集中的意外行为。由于异常通常是因不同机制引发的,故没有特定的准则来检测它们。在实践中,表现出预期行为的数据更倾向于吸引更多关注,而异常数据往往被当作噪声,且通常被忽略或剔除。不过,异常可能包含有用的信息,因此检测此类异常可能很重要。准确的异常检测有助于减轻各个领域中不必要的不利影响,譬如环境、工业、金融、等等。

时间序列中的异常可以分为以下三个类别:

  1. 点异常:一个数据点相对于其它数据点被视为异常。这些异常往往是由测量错误、传感器故障、数据切入错误、或其它意外事件引起的‘;
  2. 上下文异常:一个数据点在特定上下文中被当作异常,但并非如此;
  3. 集体异常:时间序列中表现出异常行为的子序列。这是一项相当艰难的任务,因为若在单独分析时,不能将此类反常现象视为异常。取而代之,该群体的集体行为则是异常。

集体异常可提供有关正在分析的系统、或进程的宝贵信息,因为它们或许示意需要解决的群级问题。因此,检测集体异常在网络安全、金融、和医疗保健等许多领域都是一项重要任务。BPLR 方法的作者专注于识别集体异常的工作。

使用原始数据进行异常检测时,高维的时间序列数据需要大量的计算资源。不过,为了提高异常检测的性能,典型的方式包括两个阶段:首先执行降维,然后使用距离测量,在转换后的表示子空间中执行任务。因此,该方法的作者提出了一种新的双向分段线性表示(BPLR)算法。该方法能将输入的时间序列转换为低维表达式形式,从而适合高效分析。

该论文还提出了一种新的基于分段积分(PI)思路的相似性测量算法。它以相对较低的计算开销执行高效的相似性衡量计算。


1. 算法

基于所提议 BPLR 方法的异常检测包括两个阶段:

  1. 表示时间序列
  2. 衡量相似度

在转向描述 BPLR 算法之前,我想强调一下,该方法是为了解决异常检测问题而开发的。假设分析的时间序列具有一定的周期性,其大小可以通过实验、或从先验知识获得。因此,整个输入时间序列被切分为不重叠的子序列,其大小等于原始数据的预期周期。通过比较得到的子序列,该方法的作者尝试找到异常区域。接下来,我们描述一种表示一个子序列的算法,其对于所分析时间序列的所有元素都是重复的。

为了执行表示时间序列的任务,我们需要在每个子序列中找到多个分段点集。然后,我们需要将输入子序列转换到一个线性分段集。

首先,为了找到将子序列拆分为单独分段的最适合点,我们要识别所有可能的趋势转折点(TTP)。该方法作者识别 6 种趋势转折点变体。

子序列的第一个和最后一个元素被自动视为趋势转折点。

下一步是判定找到的每个 TTP 的重要性。作为 TTP 重要性的衡量标准,该方法的作者提议使用来自子序列平均值的偏差。

然后,根据 TTP 的重要性进行排序。从 TTP1 开始,依据两个方向上的最高重要性,以迭代方式判定这些分段:TTP1 之前、和之后。在这种情况下,将引入一个额外的超参数 δß 来判定分段的品质。超参数定义了序列点与分段线的最大允许偏差。

为了判定前一分段的起点,我们从当前所分析 TTP1 开始,以相反的顺序迭代遍历序列的元素,而 TTP1 和分段开头的候选元素之间的所有元素都不会远离 δß。一旦找到超出该阈值的点后,搜索停止,并保存分区。如果之前发现的 TTP 落入分区覆盖区域之内,则它们会被删除。

类似地,我们从 TTP1 之后的方向搜索分段的结尾。由于是在极值之前、和之后的方向上搜索分段,故方法被称为双向。

在判定了两个分段的终点后,据下一个重要性极值重复操作。当数组中没有了未处理的趋势转折点时,迭代将终止。

两个子序列的相似性,是基于所分析序列的分段形成的形状区域来判定的。

为了解决异常检测问题,该方法作者创建了一个距离矩阵 Mdist。然后,对于每个单独的子序列,他们计算所分析时间序列与其它子序列的总偏差 Di。在实际中,Di 表示矩阵 Mdist 中第 i 行的元素之和。如果子序列的总偏差与其余子序列的相关平均值不同,则认为该子序列异常。

在他们的论文中,BPLR 方法作者讲述了合成数据与真实数据的实验结果,其展现出所提议方案的有效性。


2. 利用 MQL5 实现

我们已讨论了旨在寻找时间序列的异常子序列的 BPLR 方法的理论表述。在本文的实践部分,我们将在 MQL5 中实现我们对所提议方法的愿景。请注意,我们只采用所提议方案的一部分。

在我们的应用领域框架内,我们不会寻找时间序列异常。金融市场是极具动态性和多面性,故在任意两个不相交的子序列之间,我们预期会出现重大偏差。

另一方面,将时间序列表示为分段线性序列的替代表示可能非常实用。在之前的文章中,我们已经讨论过数据分段的益处。不过,判定分段大小的问题仍有重大关联。为此目的,我们始终使用相等的分段大小。与其对比,分段线性表示方法允许使用动态分段大小,具体取决于所分析输入时间序列,这可协助解决提取不同尺度的时间序列特征的任务。同时,无论分段大小如何,分段线性表示都具有固定大小,这令其便于分析。

该算法的另一个值得注意的部分是分段的表示。“分段线性表示”这个名字示意线段的表示是线性函数:

结果就是,我们在该分段的时间间隔内明确指示出主要趋势的方向。甚至,压缩数据的能力是一个额外的奖赏,有助于降低模型复杂度。

当然,我们不会将所分析时间序列切分到子序列。我们将整个初始数据集表示为分段线性序列。我们的模型基于对所呈现数据的分析,必须得出结论,并提供“唯一正确”的解。

我们先来在 OpenCL 端构建一个程序。

2.1OpenCL 端的实现


如您所知,为了优化模型训练和运营成本,我们已将大部分计算转移到 OpenCL 设备的关联环境当中,这令我们能够在多维空间中规划计算。当前实现在这方面也不例外。

为了实现所分析时间序列的分段,我们创建了 PLR 内核。

__kernel void PLR(__global const float *inputs,
                  __global float *outputs,
                  __global int *isttp,
                  const int transpose,
                  const float min_step
                 )
  {
   const size_t i = get_global_id(0);
   const size_t lenth = get_global_size(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);

在内核参数中,我们计划将传递 3 个数据缓冲区的指针:

  • inputs
  • outputs
  • isttp – 记录趋势转折点的服务缓冲区

此外,我们将添加 2 个常量:

  • transpose – 指示需要转置输入和输出的标志
  • min_step – 注册 TTP 子序列元素的最小偏差

我们将根据所分析序列中的元素数量、和多维时间序列中单变量序列的数量,在 2-维任务空间中调用内核。相应地,在内核主体中,我们立即识别任务空间中的当前流,然后我们定义输入缓存区中的偏移量常数。

//--- constants
   const int shift_in = ((bool)transpose ? (i * variables + v) : (v * lenth + i));
   const int step_in = ((bool)transpose ? variables : 1);

经过一些准备工作,我们判定在所分析元素的位置是否存在 TTP。所分析时间序列的极值点自动接收趋势转折点的状态,因为它们是该分段的先验极值点。

   float value = inputs[shift_in];
   bool bttp = false;
   if(i == 0 || i == lenth - 1)
      bttp = true;

在某些情况下,我们首先查找序列当前元素之前,所分析序列里与最小所需值最接近的偏差。同时,我们保存在迭代间隔中的最小值和最大值。

   else
     {
      float prev = value;
      int prev_pos = i;
      float max_v = value;
      float max_pos = i;
      float min_v = value;
      float min_pos = i;
      while(fmax(fabs(prev - max_v), fabs(prev - min_v)) < min_step && prev_pos > 0)
        {
         prev_pos--;
         prev = inputs[shift_in - (i - prev_pos) * step_in];
         if(prev >= max_v && (prev - min_v) < min_step)
           {
            max_v = prev;
            max_pos = prev_pos;
           }
         if(prev <= min_v && (max_v - prev) < min_step)
           {
            min_v = prev;
            min_pos = prev_pos;
           }
        }

然后,按类似的方式,我们依据最小所需偏差寻找下一个元素。

      //---
      float next = value;
      int next_pos = i;
      while(fmax(fabs(next - max_v), fabs(next - min_v)) < min_step && next_pos < (lenth - 1))
        {
         next_pos++;
         next = inputs[shift_in + (next_pos - i) * step_in];
         if(next > max_v && (next - min_v) < min_step)
           {
            max_v = next;
            max_pos = next_pos;
           }
         if(next < min_v && (max_v - next) < min_step)
           {
            min_v = next;
            min_pos = next_pos;
           }
        }

我们检查当前值是否为极值。

      if(
         (value >= prev && value > next) ||
         (value > prev && value == next) ||
         (value <= prev && value < next) ||
         (value < prev && value == next)
      )
         if(max_pos == i || min_pos == i)
            bttp = true;
     }

但在此我们应当记住,当依据最小所需偏差搜索元素时,我们可从序列的若干个元素中收集数值走廊,形成某个极值平台。因此,仅当元素是此类走廊中的极值时,元素才会收到 TTP 标志。

我们保存得到的标志,并清除输出缓冲区。此处我们还同步了局部线程组。

//---
   isttp[shift_in] = (int)bttp;
   outputs[shift_in] = 0;
   barrier(CLK_LOCAL_MEM_FENCE);

我们需要同步线程,以确保在进一步操作开始之前,当前单变量时间序列的所有线程都已记录了它们存在 TTP 与否的标志。

进一步的操作仅由标记了 TTP 的线程执行。其余线程不满足指定条件,实际上会终止。

此处,我们将首先计算当前极值的位置。为此,我们计算元素当前位置的正标志数量,并将输入缓冲区内前一个的 TTP 位置保存在局部变量之中。

//--- calc position
   int pos = -1;
   int prev_in = 0;
   int prev_ttp = 0;
   if(bttp)
     {
      pos = 0;
      for(int p = 0; p < i; p++)
        {
         int current_in = ((bool)transpose ? (p * variables + v) : (v * lenth + p));
         if((bool)isttp[current_in])
           {
            pos++;
            prev_ttp = p;
            prev_in = current_in;
           }
        }
     }

之后,我们将判定当前分段趋势的线性近似参数。

//--- cacl tendency
   if(pos > 0 && pos < (lenth / 3))
     {
      float sum_x = 0;
      float sum_y = 0;
      float sum_xy = 0;
      float sum_xx = 0;
      int dist = i - prev_ttp;
      for(int p = 0; p < dist; p++)
        {
         float x = (float)(p);
         float y = inputs[prev_in + p * step_in];
         sum_x += x;
         sum_y += y;
         sum_xy += x * y;
         sum_xx += x * x;
        }
      float slope = (dist * sum_xy - sum_x * sum_y) / (dist > 1 ? (dist * sum_xx - sum_x * sum_x) : 1);
      float intercept = (sum_y - slope * sum_x) / dist;

将获得的结果保存在输出缓冲区之中。

      int shift_out = ((bool)transpose ? ((pos - 1) * 3 * variables + v) : (v * lenth + (pos - 1) * 3));
      outputs[shift_out] = slope;
      outputs[shift_out + 1 * step_in] = intercept;
      outputs[shift_out + 2 * step_in] = ((float)dist) / lenth;
     }

在此,我们通过 3 个参数来描述每个获得的分段:

  • slope ― 趋势线斜率;
  • intercept — 趋势线在输入子空间中的偏移量;
  • dist — 分段长度。

也许应当对分段持续时间(长度)的表示说几句话。您也许已经猜到了,在这种情况下,将序列长度指定为整数值并非最佳结果。因为对于模型的有效运行,归一化的数据表示格式是首选。因此,我决定将分段持续时间表示为所分析单变量时间序列总大小的分数。故此,我们将一个分段中的元素数量除以整个单变量时间序列的元素数量。为了不落入整数运算的“陷阱”,我们首先将分段中的元素数量从 int 转换为 float 类型。

此外,我们将为最后一个分段创建一个单独的操作分支。这是因为我们不知道在任意给定时间点,将要形成的分段数量。假设,在时间序列元素的显著波动、以及每个元素中都可能存在趋势转折点的情况下,我们可以得到 3 倍以上的数值,替代压缩。当然,这种情况不太可能发生,不过,最好避免数据量增加。同时,我们不想丢失数据。

因此,我们凭 MQL5 中时间序列表示的先验知识出发,并理解所分析数据的结构:最新的时间数据位于我们时间序列的开头。故此,我们会更加关注它们。所分析窗口末尾时的数据,发生在历史记录的早期,因此对后续事件的影响较小。无论如何,我们并不排除这种影响。

因此,为了写入结果,我们所用的数据缓冲区大小,类似于输入时间序列张量的大小。这就允许我们写入比序列长度小 3 倍的分段(3 个元素写入 1 个分段)。我们预计这个数量绰绰有余。不过,我们仍谨慎行事,如果有更多分段,我们会将最后分段数据合并为一个,从而避免数据丢失。

   else
     {
      if(pos == (lenth / 3))
        {
         float sum_x = 0;
         float sum_y = 0;
         float sum_xy = 0;
         float sum_xx = 0;
         int dist = lenth - prev_ttp;
         for(int p = 0; p < dist; p++)
           {
            float x = (float)(p);
            float y = inputs[prev_in + p * step_in];
            sum_x += x;
            sum_y += y;
            sum_xy += x * y;
            sum_xx += x * x;
           }
         float slope = (dist * sum_xy - sum_x * sum_y) / (dist > 1 ? (dist * sum_xx - sum_x * sum_x) : 1);
         float intercept = (sum_y - slope * sum_x) / dist;
         int shift_out = ((bool)transpose ? ((pos - 1) * 3 * variables + v) : (v * lenth + (pos - 1) * 3));
         outputs[shift_out] = slope;
         outputs[shift_out + 1 * step_in] = intercept;
         outputs[shift_out + 2 * step_in] = ((float)dist) / lenth;
        }
     }
  }

在大多数情况下,我们预计有更少的分段,然后我们在结果缓冲区的最后一个元素里填充零值。

此处需要注意的是,上面讲述的算法不包含可训练的参数,故可在初始数据的先期准备阶段使用。这并不意味着反向传播过程、及误差梯度分布的存在。不过,在我们的工作中,我们会在我们的模型中实现这个算法。有因于此,我们需要实现一个反向传播算法,将来自后续神经层的误差梯度传播到之前的神经层。由于没有可学习的参数,故也没有针对它们的优化算法。

因此,作为反向传播算法实现的一部分,我们将创建误差梯度分布内核 PLRGradient。

__kernel void PLRGradient(__global float *inputs_gr,
                          __global const float *outputs,
                          __global const float *outputs_gr,
                          const int transpose
                         )
  {
   const size_t i = get_global_id(0);
   const size_t lenth = get_global_size(0);
   const size_t v = get_global_id(1);
   const size_t variables = get_global_size(1);

在内核参数中,我们还将传递给 3 个数据缓冲区的指针。不过,这次我们有 2 个误差梯度缓冲区(在输入和输出级别),以及一个当前层的前馈结果缓冲区。此外,我们将在内核参数中加上已经熟悉的数据转置标志。该标志是在判定数据缓冲区中的偏移量时所用。

我们将在相同的 2-维任务空间中调用内核。第一个维度受时间序列大小的限制,而第二个维度受到多模态源数据中单变量时间序列数量的限制。在内核主体中,我们首先从各个维度识别任务空间中的当前线程。

接下来,我们为数据缓冲区中的偏移量定义常量。

//--- constants
   const int shift_in = ((bool)transpose ? (i * variables + v) : (v * lenth + i));
   const int step_in = ((bool)transpose ? variables : 1);
   const int shift_out = ((bool)transpose ? v : (v * lenth));
   const int step_out = 3 * step_in;

但准备工作尚未完成。接下来,我们需要找到包含所分析输入元素的分段。为了找到它,我们运行一个循环,在循环主体中,我们将从先一个开始遍历整个分段大小。我们将重复循环迭代,直到找到包含所需输入数据元素的分段。

//--- calc position
   int pos = -1;
   int prev_in = 0;
   int dist = 0;
   do
     {
      pos++;
      prev_in += dist;
      dist = (int)fmax(outputs[shift_out + pos * step_out + 2 * step_in] * lenth, 1);
     }
   while(!(prev_in <= i && (prev_in + dist) > i));

在所有循环迭代之后,我们得到:

  • pos — 输入数据包含所需元素的分段索引
  • prev_in — 输入数据缓冲区中第一个分段元素的偏移量
  • dist — 分段中的元素数量

为了计算前馈的一阶导数运算,我们还需要分段元素的位置之和,以及它们的平方值之和。

//--- calc constants
   float sum_x = 0;
   float sum_xx = 0;
   for(int p = 0; p < dist; p++)
     {
      float x = (float)(p);
      sum_x += x;
      sum_xx += x * x;
     }

此刻,准备工作已经完成,我们能转到计算误差梯度。首先,我们提取斜率和偏移量的误差梯度。

//--- get output gradient
   float grad_slope = outputs_gr[shift_out + pos * step_out];
   float grad_intercept = outputs_gr[shift_out + pos * step_out + step_in];

现在,我们回顾一下在前馈通验中所用的公式,以便计算趋势线的垂直偏移。

线段的斜率值用于计算偏移。因此,有必要调整斜率误差梯度,同时考虑到其对偏移调整的影响。为此,我们找到偏移函数相对于斜率的导数。

我们将得到的数值乘以偏移误差梯度,并将结果添加到斜率误差梯度之中。

//--- calc gradient
   grad_slope -= sum_x / dist * grad_intercept;

现在,我们转向判定斜率的公式。

在这种情况下,分母是一个常数,我们可用它来调整斜率误差梯度。

   grad_slope /= fmax(dist * sum_xx - sum_x * sum_x, 1);

最后,我们看看输入数据在这两个公式中的影响。

其中 1 ≤ j ≤ N

使用这些公式,我们判定输入数据级别的误差梯度。

   float grad = grad_intercept / dist;
   grad += (dist * (i - prev_in) - sum_x) * grad_slope;
   if(isnan(grad) || isinf(grad))
      grad = 0;

我们将结果保存在输入数据梯度缓冲区的相应元素当中。

//--- save result
   inputs_gr[shift_in] = grad;
  }

我们在 OpenCL 关联环境端的工作到此结束。附件中提供了完整的 OpenCL 代码。

2.2实现新类


OpenCL 关联环境端完成操作后,我们转到主程序代码的工作。于此,我们将创建一个新类 CNeuronPLROCL,其允许我们以常规神经层的形式,在我们的模型中实现上述算法。

如同大多数类似情况,新对象将从我们的神经层基类 CNeuronBaseOCL 继承其主要功能。下面是新类的结构。

class CNeuronPLROCL  :  public CNeuronBaseOCL
  {
protected:
   bool              bTranspose;
   int               icIsTTP;
   int               iVariables;
   int               iCount;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  { return true; }

public:
                     CNeuronPLROCL(void)  : bTranspose(false) {};
                    ~CNeuronPLROCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                          uint window_in, uint units_count, bool transpose, 
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronPLROCL;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

该结构包含重新定义的标准方法集,其中附加了若干个变量。新变量的用途可顾名思义。

  • bTranspose — 指示输入和输出需要转置的标志
  • iCount — 分析当中的序列大小(历史深度)
  • iVariables — 多模态时间序列(单变量序列)所分析参数的数量

请注意,尽管我们在前馈通验内核参数中有一个辅助数据缓冲区,但我们并未在主程序端创建额外的缓冲区。此处我们仅在局部变量 icIsTTP 中保存一个指向它的指针。

我们没有内部对象,因此我们可以将类构造函数和析构函数留空。对象在 Init 方法中初始化。

bool CNeuronPLROCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, 
                         uint window_in, uint units_count, bool transpose, 
                         ENUM_OPTIMIZATION optimization_type, uint batch
                        )
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_in * units_count, optimization_type, batch))
      return false;

在参数中,该方法接收定义所创建对象架构的主要常量。在类主体中,我们首先调用父类的同名方法,其已经实现了继承对象、和变量的必要控制和初始化。

然后我们保存所创建对象的配置参数。

   iVariables = (int)window_in;
   iCount = (int)units_count;
   bTranspose = transpose;

在方法结束时,我们在 OpenCL 关联环境端创建一个辅助数据缓冲区。

   icIsTTP = OpenCL.AddBuffer(sizeof(int) * Neurons(), CL_MEM_READ_WRITE);
   if(icIsTTP < 0)
      return false;
//---
   return true;
  }

初始化对象之后,我们转到在 feedForward 方法中构造前馈通验算法。此处我们只需要调用上面创建的前馈通验内核 PLR。不过,必须创建局部组,在单独的单变量时间序列内同步线程。

bool CNeuronPLROCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL || !NeuronOCL.getOutput())
      return false;
//---
   uint global_work_offset[2] = {0};
   uint global_work_size[2] = {iCount, iVariables};
   uint local_work_size[2] = {iCount, 1};

为此,我们定义了一个 2-维全局任务空间。对于第一个维度,我们指示正在分析的序列大小,对于第二个维度,我们指示单变量时间序列的数量。我们还定义了 2-维任务空间中局部组的大小。第一个维度的大小对应于全局值,第二个维度,我们指定 1。因此,每个局部组都有自己的单变量序列。

接下来,我们只需将必要的参数传递到内核。

   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_PLR, def_k_plr_inputs, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_PLR, def_k_plr_outputs, getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_PLR, def_k_plt_isttp, icIsTTP))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_PLR, def_k_plr_transpose, (int)bTranspose))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_PLR, def_k_plr_step, (float)0.3))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

我们将内核放入执行队列之中。

//---
   if(!OpenCL.Execute(def_k_PLR, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

不要忘记控制每个阶段的操作。在方法结束时,我们将方法结果的逻辑值返回给调用者。

calcInputGradients 误差梯度分布方法的算法遵照类似的方式构造。但与前馈通验方法不同的是,于此我们不创建本地组,且每个线程独立执行其操作。您可在下面的附件中找到本文中用到的所有程序的完整代码。

如上所述,我们创建的对象不包含可学习参数。因此,这里重新定义 updateInputWeights 参数优化方法,只是为了在实现过程中保持对象的通用结构、及其兼容性。该方法始终返回 true

实现新类方法算法的讲述到此结束。您可在附件中找到类方法的完整代码,包括本文中未介绍的方法。

2.3模型架构


我们已经实现了一种时间序列分段线性表示的算法,现在可将其添加到我们模型的架构之中。

为了测试所提议实现的有效性,我们在环境状态编码器模型结构中引入了一个新类。我必须要说,我们已研究了简化模型架构,以便评估时间序列的名义分解对单个线性趋势的影响。

如前,我们在 CreateEncoderDescriptions 方法中描述模型的架构。

bool CreateEncoderDescriptions(CArrayObj *encoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }

在参数中,该方法接收指向动态数组对象的指针,记录模型架构。在方法主体中,我们首先检查接收指针的相关性。之后,如有必要,我们创建一个动态数组的新实例。

如常,我们向模型提供有关给定历史深度的环境状态信息,而无需任何数据预处理。

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

分段线性表示算法同样适用于归一化数据和原始数据。但有几件事需要留意。

首先,在我们的实现中,我们用时间序列值的最小所需偏差参数来记录趋势转折点。毋庸置疑,需要仔细选择这个超参数来分析每个单独的时间序列。运用该算法来分析多模态时间序列,其依赖于不同分布中的单变量序列值,明显令该任务更复杂。甚至,在大多数情况下,这令全部所分析单变量序列使用一个超参数变得不可能。

其次,在模型中运用 PLR 的产物,会令归一化源数据时效率明显更高。

当然,我们可在投喂到模型之前添加 PLR 结果的归一化,但即使于此,分段数量的动态变化也令任务复杂化。

同时,在把输入数据投喂到分段线性表示层之前对其进行归一化,明显简化了以上所有要点。通过将所有单变量序列归一化为单个分布,我们能用一个超参数来分析多模态时间序列。甚而,对输入数据的分布进行归一化,令我们能够针对完全不同的输入序列使用平均超参数。 

在层的输入端收到归一化数据之后,我们在输出端对序列进行了归一化。因此,我们模型的下一层是批量归一化层。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1e4;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后,为了在单变量序列中工作,我们要转置输入数据。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = HistoryBars;
   descr.window = BarDescr;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

当然,在我们的 PLR 算法实现中,使用转置参数来替代使用数据转置层,可能会更有效。然而,在这种情况下,由于模型架构的进一步构造,我们完全使用了转置。

接下来,我们将准备好的数据拆分为线性分段。

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

我们使用 3-层 MLP 来预测给定规划横向范围的独立单变量序列。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = BarDescr;
   descr.window = HistoryBars;
   descr.step = HistoryBars;
   descr.window_out = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = BarDescr;
   descr.window = LatentCount;
   descr.step = LatentCount;
   descr.window_out = LatentCount;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   descr.count = BarDescr;
   descr.window = LatentCount;
   descr.step = LatentCount;
   descr.window_out = NForecast;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

注意,我们使用具有非重叠窗口的卷积层,来组织对独立单变量序列值的条件性独立预测。我使用 “条件性独立预测” 的定义,因为相同的加权矩阵用于构造所有单变量序列的预测轨迹。

我们将预测值转置为输入数据的表示形式。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = BarDescr;
   descr.window = NForecast;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们向它们添加分布的统计参数,这些参数在原始数据的归一化过程中被消除。

//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   descr.count = BarDescr*NForecast;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers=1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在模型的输出中,我们调用 FreDF 方法的拓展,来协调我们构造的分析时间序列的预测单变量序列的各个步骤。

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFreDFOCL;
   descr.window = BarDescr;
   descr.count =  NForecast;
   descr.step = int(true);
   descr.probability = 0.7f;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

故此,我们构建了一个环境状态编码器模型,该模型将 PLRMLP 连结在一起进行时间序列预测。


3. 测试

在本文的实践部分,我们实现了一种时间序列的分段线性表示(PLR)算法。所提议算法不包含可学习参数。取而代之,它涉及将所分析时间序列转换为替代表示形式。我们还提出了一个相当简化的时间序列预测模型,其用到所创建层 CNeuronPLROCL。现在是时候评估这些方式的有效性了。

为了训练环境状态编码器模型,来预测所分析时间序列的后续指标,我们采用了为上一篇文章收集的训练数据集。

我们采用 EURUSD 金融产品的真实历史数据,及 H1 时间帧来训练模型,这些数据收集自 2023 年全年。在环境状态编码器模型训练期间,它仅配合价格走势的历史数据和所分析指标工作。因此,我们训练模型直到获得所需的结果,而无需更新训练数据集。

说到模型训练,我想提醒该过程的稳定性。该模型学习得相当迅速,没有预测误差的急剧跳跃。

结果就是,尽管模型相对简单,但我们还是得到了相当不错的结果。例如,下面是目标和预测价格走势的比较图表。

该图表表明,该模型能够捕捉到即将到来的价格走势的主要趋势。非常值得注意的是,在 24 小时预测横向范围内,我们在预测轨迹的开始和结束处,都得到非常接近的值。仅有价格走势的预测轨迹势头,在时间上更宽广。

所分析指标的预测轨迹也显示出良好的结果。以下是预测的 RSI 值的图表。

指标的预测值略高于实际值,幅度较小,但它们在主脉冲的时间和方向上是一致的。

请注意,所提供的价格走势预测、和指标读数是指同一时期。如果您比较两个呈现的图表,您可以看到指标的预测值和实际值的主要动量,与实际价格走势的主要动量在时间上一致。


结束语

在本文中,我们讨论了以分段线性分段的形式,来替代时间序列表示的方法。在本文的实践部分,我们实现了所提议方式的一种变体。所进行的实验结果表明所研究方式的颇具潜力。


参考

  • 时间序列的双向分段线性表示,于集体异常检测的应用
  • 本系列的其它文章


  • 文章中所用程序

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

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

    附加的文件 |
    MQL5.zip (1420.3 KB)
    您应当知道的 MQL5 向导技术(第 26 部分):移动平均和赫斯特(Hurst)指数 您应当知道的 MQL5 向导技术(第 26 部分):移动平均和赫斯特(Hurst)指数
    赫斯特(Hurst)指数是时间序列长期自相关度的衡量度。据了解,它捕获时间序列的长期属性,故在时间序列分析中也具有一定的分量,即使在财经/金融时间序列之外亦然。然而,我们专注于其对交易者的潜在益处,研究如何将该计量度与移动平均线配对,从而构建潜在的稳健信号。
    使用MQL5中的动态时间规整进行模式识别 使用MQL5中的动态时间规整进行模式识别
    在本文中,我们探讨了动态时间规整(Dynamic Time Warping,DTW)作为识别金融时间序列中预测模式的一种方法。我们将深入了解其工作原理,并在纯MQL5语言中展示其实现方法。
    无政府社会优化(ASO)算法 无政府社会优化(ASO)算法
    本文中,我们将了解无政府社会优化(Anarchic Society Optimization,ASO)算法,并探讨一个基于无政府社会(一个摆脱中央权力和各种等级制度的异常社会交互系统)中参与者非理性与冒险行为的算法是如何能够探索解空间并避免陷入局部最优陷阱的。本文提出了一种适用于连续问题和离散问题的统一ASO结构。
    解构客户端交易策略的示例 解构客户端交易策略的示例
    本文使用框图来检查位于终端的 Experts\Free Robots 文件夹中的基于烛形的训练 EA 的逻辑。