
神经网络变得简单(第 87 部分):时间序列补片化
概述
预测在时间序列分析中扮演重要角色。深度模型在这一领域带来了显著的改进。除了成功预测未来值以外,它们还提取出可应用于其它任务(如分类和异常检测)的抽象表示。
变换器架构起源于自然语言处理(NLP)领域,在计算机视觉(CV)中展显出其优势,并成功应用于时间序列分析。其自关注机制,可自动识别时间序列元素之间的关系,已成为创建有效预测模型的基础。
随着可用于分析的数据量增长、和机器学习方法的改进,如此令开发更准确、更高效的模型来分析时间数据成为可能。然而,随着时间序列复杂性的提升,我们需要开发更高效、成本更低的分析方法,从而实现准确的预测,并识别隐藏的形态。
其中一种方法是补片时间序列变换器,PatchTST,其在文章《一个时间序列值得 64 个词:使用变换器进行长期预测》中阐述。该方法基于将时间序列划分为多个片段(补片),并用变换器预测未来值。
时间序列预测旨在理解每个时间步长数据之间的相关性。不过,单一时间步长并无语义意义。因此,提取局部语义信息对于分析数据关系非常重要。以前的大多数工作仅用源数据的时间点步长令牌。对比而言,PatchTST 通过将时间步长聚合到子序列级别的补片中,提高了局部性,并捕获到在点级别无法提供的复杂语义信息。
甚至,多变量时间序列是一个多通道信号,每个源数据令牌能够代表来自一个、或多个通道的数据。根据源数据令牌结构,变换器架构亦有不同选项。通道混合是指后一种情况,其中源数据令牌获取所有时间序列特征的向量,并将其投影到嵌入空间中,以便混合信息。另一方面,通道独立性意味着每个源数据令牌仅包含来自一个通道的信息。之前,这已被证明在卷积和线性模型中效果很好。PatchTST 展示出基于变换器模型的独立通道方式的有效性。
PatchTST 的作者强调所提议方法有以下优点:
- 降低复杂度:补片化可以降低模型的时间和空间复杂度,从而提升其在大型数据集上的效率。
- 从更长的回溯窗口改善学习:补片允许模型在覆盖更长的时间段内学习,有改善预测品质的潜力。
- 表示学习:所提议模型不仅在预测方面有效,而且有能力提取更复杂的抽象数据表示,从而提高了其普适能力。
作者论文中阐述的研究证明了所提议方法的有效性,及应用在各种时间序列分析问题的潜力。
1. PatchTST 算法
PatchTST 方法专为分析和预测多元时间序列而开发,其中所分析系统的每种状态都由参数向量描述。在这种情况下,每个时间步的描述向量大小包含相同数量的参数,其数据结构雷同。因此,我们能够根据描述系统状态的参数个数,将一般的多元时间序列划分为几个单变量时间序列。
如同我们之前研究过的方法,我们首先把模型的输入数据归一化,将它们转换为可比形式。这一步非常重要。我们已经多次讨论过,在模型输入中使用归一化数据可显著提升其训练过程的稳定性。甚至,尽管 PatchTST 方法意味着对单变量时间序列进行独立于通道的分析,但分析按一组训练参数进行的。因此,来自所有通道的分析数据采用可比形式非常重要。
下一步是补片化单变量时间序列,这允许针对局部形态进行建模,并提升模型的普适能力。在该步骤中,PatchTST 方法的作者建议按固定步骤将时间序列切分为固定大小的补片。该方法同样适用于重叠和非重叠的补片。在第一种情况下,步长小于补片大小,在第二种情况下,两个超参数相等。两种修补方法都允许探索局部语义信息。特定方法的选择在很大程度上取决于任务和所分析输入窗口的大小。
显然,补片的数量将小于时间序列的长度。补片化步长的尺寸越大,差异就越大。因此,对于不重叠的补片,在补片数量与时间序列长度之间可以达成最大差异。在这种情况下,缩减是以步长的倍数实现的。这就允许采用相同、甚至更低的内存和计算资源来分析更长的输入时间序列。
在分析较小的输入窗口时,建议使用重叠的补片,这将允许针对局部语义依赖关系进行更定性的研究。
我们为每个独立的单变量时间序列创建补片,但针对所有时间序列采用相同的补片参数。
之后,我们配以已创建补片来操作。我们为它们创建嵌入。我们添加了可训练的位置编码,并将其传递到由若干个 Vanilla 变换器编码器层组成的模块。
我们不会详述变换器架构,因为我们之前已讨论过它。请注意,变换器编码器单独分析单变量时间序列中的依赖关系。不过,分析所有单变量时间序列会采用相同的学习参数。
变换器允许从输入补片中提取抽象表示,同时考虑到它们的时间序列和上下文。因此,在编码器的输出处获得的表示包含有关补片之间的关系,以及每个补片中的形态信息。以这种方式处理的单变量时间序列表示是串联的。生成的张量可用于解决各种问题。它被馈送到“决策头”,从而生成模型的输出。
请注意,该方法的作者提议使用一个模型来解决一个输入数据集上的各种问题。这可能是搜索异常、分类、或预测覆盖不同规划范围内的后续时间序列数据。您只需要替换“决策头”,并优调(重新训练)模型。
在预测后续时间序列数据时。我们通过返回从输入数据中提取的统计特征,在模型输出处逆归一化数据。
作者对该方法的可视化如下表示。
2. 利用 MQL5 实现
我们已研究了该方法的理论层面。现在我们可以转到利用 MQL5 实施所提议方式的实现。
再次,我们将按我们对拟议方式的愿景来实现,这与原作者的思路有别。
如遵循上面的前置 PatchTST 方法的理论描述,它基于输入补片,并将多元时间序列划分为分离的单变量序列。
数据处理流程的第一步是补片化,即把输入数据划分为更小的信息块。在非重叠补片的情况下,这可被认为是将 2-维输入张量重新格式化为 3-维张量。对于重叠补片,它稍微复杂一些,因它需要复制数据。但无论如何,我们在输出处得到一个 3-维张量:“变量个数 * 补片个数 * 补片大小”。
输入数据张量的转换意味着数据复制操作。我们希望剔除不必要的操作,包括复制。因为每一次额外的操作都会消耗我们的时间和资源。
我们来关注下面的操作。操作流的下一步是数据嵌入。合乎逻辑的解决方案是将这两个操作结合。实际上,我们仅执行数据嵌入操作。不过,为了执行该操作,我们将从输入数据张量中获取单独的块,与我们的补片相对应。
我们之前曾研究过卷积层。在这些层中,我们还按给定窗口大小取一个输入块,搭配若干滤波器进行卷积操作后,我们得到所分析数据窗口到某个子空间的投影向量。看起来正是我们所需要的。但是我们之前创建的卷积层需配以一维输入张量操作。它不允许我们将独立单变量时间序列与多元时间序列的一般张量隔离。故此,我们必须创建类似的东西,但要拥有在独立单变量序列中工作的能力。
2.1OpenCL-端补片化
首先,我们要补充 OpenCL 程序,为前馈和后馈数据补片化通验创建内核,配以它们在某个嵌入子空间中的投影。我们从前馈通验内核 PatchCreate 开始。
在内核参数中,我们传递 3 个数据缓冲区的指针:inputs、weights 矩阵、和 outputs。此外,我们将往内核参数里添加 4 个常量。在这些常量中,我们将指定输入数据张量的全尺寸,以防出现超界错误。我们指定布片大小和步长。我们还将为用户提供添加激活函数的能力。
__kernel void PatchCreate(__global float *inputs, __global float *weights, __global float *outputs, int inputs_total, int window_in, int step, int activation ) { const int i = get_global_id(0); const int w = get_global_id(1); const int v = get_global_id(2); const int window_out = get_global_size(1); const int variables = get_global_size(2);
我们期待内核在 3-维任务空间中执行:补片数量、元素在所分析补片的嵌入向量中的位置、以及源数据中变量的标识符。我要提醒你,我们正在独立的单变量时间序列的框架内构造片段。
在内核主体中,我们标识了任务空间所有 3 个维度的线程。我们还判定任务空间的维度。
然后,基于接收到的数据,我们能够判定数据缓冲区到所分析元素的偏移。
const int shift_in = i * step * variables + v; const int shift_out = (i * variables + v) * window_out + w; const int shift_weights = (window_in + 1) * (v * window_out + w);
在判定输入缓存区中的偏移时,我们做出以下假设:
- 输入张量包含一个向量序列,描述环境在单独时间步的状态。换言之,输入张量是一个 2-维矩阵,其中的行包含特定时间步的环境状态描述。矩阵的列对应于描述所分析环境状态的各个参数(变量)。
- PatchTST 方法分析独立单变量时间序列。因此,描述环境状态的每个参数(变量)在向量中仅包含 1 个元素,并且彼此独立(在整个时间序列内)进行补片。
请记住这些假设。根据它们,我们需在传送数据到模型之前,在主程序端准备好输入数据。
接下来,我们规划一个循环,将片段向量乘以相应的权重向量。在循环主体中,我们控制输入数据缓存区的偏移,避免数组超界访问。
float res = weights[shift_weights + window_in]; for(int p = 0; p < window_in; p++) if((shift_in + p * variables) < inputs_total) res += inputs[shift_in + p * variables] * weights[shift_weights + p]; if(isnan(res)) res = 0;
此处注意,在访问输入张量数据时,我们采用的步长等于环境的一种状态描述中的变量数量。也就是,我们沿着输入矩阵的列移动。这符合单变量时间序列的补片化要求。
如果我们得到的向量乘法运算结果为 NaN,则将其替换为 “0”。
接下来,我们只需要执行给定的激活函数,并将结果值保存在相应的结果缓冲区之中。
switch(activation) { case 0: res = tanh(res); break; case 1: res = 1 / (1 + exp(-clamp(res, -20.0f, 20.0f))); break; case 2: if(res < 0) res *= 0.01f; break; defaultд: break; } //--- outputs[shift_out] = res; }
实现前馈通验后,我们转到构造反向传播内核。首先,我们将创建一个内核,将误差梯度传播到上一层 — PatchHiddenGradient。在内核参数中,我们将传递指向 4 个数据缓冲区的指针:
- inputs — 输入数据缓冲区 (按激活函数的导数调整误差梯度所必需的);
- inputs_gr — 输入数据级别的误差梯度缓冲区 (在本例中,用于写入结果的缓冲区);
- weights — 层的可训练参数矩阵;
- outputs_gr — 层输出级别的梯度张量 (在本例中,用于计算误差梯度的输入数据)。
此外,我们将传递 5 个常量至内核。它们的用途可以很容易地从变量的名称中猜到。
__kernel void PatchHiddenGradient(__global float *inputs, __global float *inputs_gr, __global float *weights, __global float *outputs_gr, int window_in, int step, int window_out, int outputs_total, int activation ) { const int i = get_global_id(0); const int v = get_global_id(1); const int variables = get_global_size(1);
我们正计划在 2-维任务空间中使用内核:输入序列的长度,和环境状态(变量)的所分析参数个数。
注意,在构造内核时,我们将任务空间定向在输出张量的维度上。在前馈通验中,我们定向在数据嵌入的 3-维张量。在反向通验期间,它是输入的 2-维张量,或者更确切地说是它们的误差梯度。这种方式允许将每个单独的线程配置为在内核的输出缓存区中接收单个数值。
在内核主体中,我们标识任务空间中的线程,并定义所需的维度。之后我们计算偏移。
const int w_start = i % step; const int r_start = max((i - window_in + step) / step, 0); int total = (window_in - w_start + step - 1) / step; total = min((i + step) / step, total);
之后我们规划一个嵌套循环系统来收集误差梯度。
float grad = 0; for(int p = 0; p < total; p ++) { int row = r_start + p; if(row >= outputs_total) break; for(int wo = 0; wo < window_out; wo++) { int shift_g = (row * variables + v) * window_out + wo; int shift_w = v * (window_in + 1) * window_out + w_start + (total - p - 1) * step + wo * (window_in + 1); grad += outputs_gr[shift_g] * weights[shift_w]; } }
一个输入元素会影响具有不同权重的单个布片的嵌入向量的所有元素的数值。因此,嵌套循环从单个补片的整个嵌入向量中收集误差梯度。
此外,在重叠补片的情况下,所分析输入元素数据可能会落入若干个补片的输入窗口当中。我们的嵌套循环系统的外循环用于从此类补片中收集误差梯度。
我们按激活函数的导数,针对所分析输入元素调整已收集(总体)误差梯度。
float inp = inputs[i * variables + v]; if(isnan(grad)) grad = 0; //--- switch(activation) { case 0: grad = clamp(grad + inp, -1.0f, 1.0f) - inp; grad = grad * (1 - pow(inp == 1 || inp == -1 ? 0.99999999f : inp, 2)); break; case 1: grad = clamp(grad + inp, 0.0f, 1.0f) - inp; grad = grad * (inp == 0 || inp == 1 ? 0.00000001f : (inp * (1 - inp))); break; case 2: if(inp < 0) grad *= 0.01f; break; default: break; }
我们将操作结果写入前一个神经层的误差梯度缓冲区的相应元素之中。
inputs_gr[i * variables + v] = grad; }
传播误差梯度之后,我们需要调整模型的训练参数,将误差最小化。为了实现该功能,我们将创建 PatchUpdateWeightsAdam 内核,在其中我们将利用 Adam 方法优化参数。
在内核参数中,我们将传递 5 个指向数据缓冲区的指针。除了熟悉的缓冲区 inputs、weights、和 output_gr 之外,我们还分别在权重矩阵 weights_m 和 weights_v 级别得到误差梯度的第一阶和第二阶动量辅助缓冲区。此外,我们还将在内核参数中传递学习率。
__kernel void PatchUpdateWeightsAdam(__global float *weights, __global const float *outputs_gr, __global const float *inputs, __global float *weights_m, __global float *weights_v, const int inputs_total, const float l, const float b1, const float b2, int step ) { const int c = get_global_id(0); const int r = get_global_id(1); const int v = get_global_id(2); const int window_in = get_global_size(0) - 1; const int window_out = get_global_size(1); const int variables = get_global_size(2);
由于我们的权重张量是 3-维的,因此任务空间也将以在 3-维形成:
- 补片大小 + 乖离,
- 嵌入向量大小,
- 变量数量。
此处我们遵循上面提到的逻辑,其中每个单独的线程调整 1 个可训练参数的数值。
在内核主体中,我们标识任务空间中所有 3 个维度的线程。我们还判定维度的大小。之后,我们在数据缓冲区中定义偏移常量。
const int start_input = c * variables + v; const int step_input = step * variables; const int start_out = v * window_out + r; const int step_out = variables * window_out; const int total = inputs_total / (variables * step);
运行循环,按已校正学习参数级别来收集误差梯度。
float grad = 0; for(int p = 0; p < total; p++) { int i = start_input + i * step_input; int o = start_out + i * step_out; grad += (c == window_in ? 1 : inputs[i]) * outputs_gr[0]; } if(isnan(grad)) grad = 0;
判定误差梯度后,我们转到参数校正算法。首先,我们定义第一阶和第二阶动量。
const int shift_weights = (window_in + 1) * (window_out * v + r) + c; //--- float weight = weights[shift_weights]; float mt = b1 * weights_m[shift_weights] + (1 - b1) * grad; float vt = b2 * weights_v[shift_weights] + (1 - b2) * pow(grad, 2);
然后我们计算参数调整值。
float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weight) + l2 * weight));
最后,我们将调整数据缓冲区中的数值。
if(fabs(delta) > 0) weights[shift_weights] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT); weights_m[shift_weights] = mt; weights_v[shift_weights] = vt; }
请注意,仅当参数变化值不为 “0” 时,我们才会更改数据缓冲区中的权重。从数学观点来看,当前值加上 “0” 不会更改参数。但是我们引入了一个额外的局部变量检查操作,来剔除不必要、更昂贵的访问全局数据缓冲区的操作。
我们在 OpenCL 端的工作到此结束。我们转至主程序端。
2.2数据补片化类
为了在主程序端调用上述创建的内核,并提供服务,我们创建了 CNeuronPatching 类,该类继承自所有神经层 CNeuronBaseOCL 的基类。
在类主体中,我们将声明变量来存储对象架构的主要参数,以及训练参数、和相应动量的缓冲区。我们将所有缓冲区声明为静态对象,这允许我们将类构造函数和析构函数留“空”。
class CNeuronPatching : public CNeuronBaseOCL { protected: uint iWindowIn; uint iStep; uint iWindowOut; uint iVariables; uint iCount; //--- CBufferFloat cPatchWeights; CBufferFloat cPatchFirstMomentum; CBufferFloat cPatchSecondMomentum; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronPatching(void){}; ~CNeuronPatching(void){}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window_in, uint step, uint window_out, uint count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronPatchingOCL; } virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
可覆盖的类方法集都是十分标准的。对象和类变量在 Init 方法中初始化。在参数中,该方法接收创建所需架构对象的全部信息。
bool CNeuronPatching::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window_in, uint step, uint window_out, uint count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch ) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_out * count * variables, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类的相同方法,其会针对接收的值执行最低限度的必要控制,并初始化继承对象和变量。在父类方法中执行操作的结果由返回的逻辑值控制。
执行父类方法操作成功后,我们将获得的对象架构描述的值保存在局部变量之中。
iWindowIn = MathMax(window_in, 1); iWindowOut = MathMax(window_out, 1); iStep = MathMax(step, 1); iVariables = MathMax(variables, 1); iCount = MathMax(count, 1);
初始化训练参数的缓冲区。
int total = int((window_in + 1) * window_out * variables); if(!cPatchWeights.Reserve(total)) return false; float k = float(1 / sqrt(total)); for(int i = 0; i < total; i++) { if(!cPatchWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier)) return false; } if(!cPatchWeights.BufferCreate(OpenCL)) return false;
此外,在训练参数级别初始化误差梯度动量缓冲区。
if(!cPatchFirstMomentum.BufferInit(total, 0) || !cPatchFirstMomentum.BufferCreate(OpenCL)) return false; if(!cPatchSecondMomentum.BufferInit(total, 0) || !cPatchSecondMomentum.BufferCreate(OpenCL)) return false; //--- return true; }
初始化对象之后,我们转到构造前馈方法 CNeuronPatching::feedForward。在该方法中,我们将上面创建的前馈验算内核排列。我们已经在之前的文章中多次讲述了将内核放入执行队列的过程。于此主要留意任务空间的大小、和我们所传递参数的正确指示。
正如我们在构造内核时曾提到的,在本例中,我们使用 3-维任务空间:
- 补片数量
- 1 个补片嵌入大小
- 所分析环境状态描述中参数个数
bool CNeuronPatching::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || !OpenCL) return false; //--- uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iCount, iWindowOut, iVariables};
在创建任务空间指示数组、及其中的偏移之后,我们规划将参数传递给内核的过程。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_PatchCreate, def_k_ptc_inputs, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchCreate, def_k_ptc_weights, cPatchWeights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchCreate, def_k_ptc_outputs, Output.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchCreate, def_k_ptc_activation, (int)activation)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchCreate, def_k_ptc_inputs_total, (int)NeuronOCL.Neurons())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchCreate, def_k_ptc_window_in, (int)iWindowIn)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchCreate, def_k_ptc_step, (int)iStep)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
不要忘记控制操作的正确性。成功传输所有必要的参数之后,我们将内核放入执行队列当中。
if(!OpenCL.Execute(def_k_PatchCreate, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
类似地,在 CNeuronPatching::calcInputGradients 方法中,根据误差梯度分派内核对模型最终结果的影响,在队列里把误差梯度分派内核放在前一层元素之前。PatchHiddenGradient 内核会在 2-维任务空间中调用。
bool CNeuronPatching::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || !OpenCL) return false; //--- uint global_work_offset[2] = {0, 0}; uint global_work_size[2] = {NeuronOCL.Neurons() / iVariables, iVariables};
此处应注意的是,我们将多元时间序列的输入序列的大小,定义为前一层结果缓冲区的大小与环境状态描述 1 的所分析变量数量的比率。
我要提醒您,根据 PatchTST 方法,输入应该是一个多元时间序列,其中环境的每个状态都由固定长度的向量描述。向量的每个元素都包含描述系统状态的相应参数值。
接下来,我们将参数传递到内核,并控制操作的执行。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_PatchHiddenGradient, def_k_pthg_inputs, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchHiddenGradient, def_k_pthg_inputs_gr, NeuronOCL.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchHiddenGradient, def_k_pthg_weights, cPatchWeights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchHiddenGradient, def_k_pthg_outputs_gr, Gradient.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchHiddenGradient, def_k_pthg_activation, (int)NeuronOCL.Activation())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchHiddenGradient, def_k_pthg_outputs_total, (int)iCount)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchHiddenGradient, def_k_pthg_window_in, (int)iWindowIn)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchHiddenGradient, def_k_pthg_step, (int)iStep)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchHiddenGradient, def_k_pthg_window_out, (int)iWindowOut)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
把内核放入执行队列当中。
if(!OpenCL.Execute(def_k_PatchHiddenGradient, 2, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
该类中要研究的最后一个方法是调整模型可训练参数 CNeuronPatching::updateInputWeights 的方法。该方法把 PatchUpdateWeightsAdam 内核放入队列。其算法如上所述。将内核放入执行队列的算法与上述两种方法雷同。不过,在细节上存在差异。此处用到 3-维任务空间。
bool CNeuronPatching::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || !OpenCL) return false; //--- uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iWindowIn + 1, iWindowOut, iVariables};
在第一维中,我们将 1 个贝叶斯(Bayesian)乖离元素加到补片大小。在第二维和第三维中,我们指定 1 个补片的嵌入大小,及存储在类变量中的所分析独立通道的数量。
然后我们将参数传送到内核,并控制操作的结果。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_inputs, NeuronOCL.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_outputs_gr, getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_weights, cPatchWeights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_weights_m, cPatchFirstMomentum.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_weights_v, cPatchSecondMomentum.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_inputs_total, (int)NeuronOCL.Neurons())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_l, lr)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_b1, b1)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_step, (int)iStep)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_PatchUpdateWeightsAdam, def_k_ptuwa_b2, b2)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
之后,内核被放置在执行队列之中。
if(!OpenCL.Execute(def_k_PatchUpdateWeightsAdam, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
该类还拥有文件操作方法,您能据下面附带的代码进行研究。除了文件操作之外,附件还包括创建和训练模型的所有类和方法。
我们已创建了一种生成补片嵌入的方法,这些嵌入是为独立的单变量时间序列而创建的,这些时间序列是所分析的多元时间序列的组成部分。然而,这仅是所提议 PatchTST 方法的一半。该方法的第二个重要模块是变换器,用于分析单变量时间序列中补片之间的依赖关系。请注意,依赖项的分析仅在独立通道的框架内进行。并未分析不同单变量通道元素之间的交叉依赖关系。
我们早前研究过的所有变换器架构实现选项都用到了通道混合,这与 PatchTST 方法的原理相矛盾。唯一的例外是构象异构体(Conformer)。虽然,与 PatchTST 方法作者所用的 Vanilla 变换器不同,构象异构体架构更复杂。它用到连续关注度和 NeuralODE 模块来提高模型的效率,d通常会给出积极的结果。我们的实验确认了这一点。因此,作为我实现的一部分,我大胆地将 PatchTST 作者所用的变换器替换为之前在 CNeuronConformer 类中创建的构象异构体模块实现。
2.3模型架构
在实现 PatchTST 方法的“区块”之后,我们转到创建可训练模型的架构。正在研究的方法是为预测多元时间序列而提议的。显然,我们要在环境状态编码器中实现该方法。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; }
此处值得注意的是,补片创建过程不允许使用小于 1 个输入补片的历史深度。当然,我们在每次调用时只能提供 1 个补片深度的历史数据。然后,所分析历史的整个深度将在内部堆栈中累积,就像我们之前在嵌入层中所做的那样。但这种方式有限定数量。首先,我们需要指定一个补片之间的步长,该步长等于补片本身(非重叠补片)。但实际步长等于模型调用频率。
如此,在收集训练数据、训练及操作模型的协调程序中,会有一些混乱和复杂性。
第二点是,若按该方式,当更改补片大小或步长时,我们需要重新收集训练样本。这将在训练模型过程中引入额外的约束和成本。
因此,我们采用一种更简单、更通用的方法来为模型投喂所分析历史的全部深度。补片和步长大小由相应模型层架构中的参数设置。
如常,我们向模型投喂“原始”未处理的数据,其中我们立即在批量归一化层中针对这些数据进行归一化。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,应当注意的是,在这个模型中,可训练位置编码层被放置在输入级别,而不是像以前那样放置在嵌入层。按这种方式,我想专注于特定参数的位置。使用重叠补片时,一个参数可以包含在若干补片之中。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronLearnabledPE; descr.count = prev_count; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我添加了一个 Dropout 层,我们将用其在模型训练期间遮掩单独输入值。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDropoutOCL; descr.count = prev_count; descr.probability = 0.4f; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
我将数据掩码系数设置为 40%,类似于之前的工作。
然后我们添加一个补片生成层。在我的工作中,我所用是非重叠补片,窗口大小、及步长等于 3。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPatchingOCL; descr.window = 3; prev_count = descr.count = (HistoryBars+descr.window-1)/descr.window; descr.step = descr.window; descr.layers=BarDescr; int prev_wout = descr.window_out = EmbeddingSize / 2; if(!encoder.Add(descr)) { delete descr; return false; }
此处还值得注意的是,补片嵌入分 2 个阶段形成。首先,我们生成一半大小的补片嵌入。然后,在卷积层中,我们增加补片的尺寸。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count*BarDescr; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
如您所忆,我们在输入级别实现了位置编码。因此,在生成嵌入后,我们立即将数据放入 10-层构象异构体模块之中。
//--- layer 6-16 for(int i = 0; i < 10; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConformerOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 8; descr.window_out = EmbeddingSize; descr.layers = BarDescr; if(!encoder.Add(descr)) { delete descr; return false; } }
接下来是决策头,它由 3 个全连接层组成。最后一层的大小,我们要让它足以包含历史数据的重建信息,并预测给定深度的后续状态。
//--- layer 17 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation=SIGMOID; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 18 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation=LReLU; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 19 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count=descr.count = BarDescr*(HistoryBars+NForecast); descr.activation=TANH; if(!encoder.Add(descr)) { delete descr; return false; }
在模型末尾,我们通过添加从原始数据中提取的统计指标,逆归一化重构和预测数值。
//--- layer 20 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; prev_count = descr.count = prev_count; descr.activation = None; descr.optimization = ADAM; descr.layers = 1; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
请注意,我们保留了输入数据和模型结果的大小,与上一篇文章中相同。因此,我们无需更改就能复制扮演者和评论者模型。此外,在新实验中,我们能用之前文章中的训练数据集和 EA。以这种方式,我们可以比较不同环境状态编码器架构对于扮演者政策学习结果的影响。
附件包含此处用到的所有可训练模型的完整架构描述。
3. 测试
在本文的前几节中,我们介绍了一种预测多元时间序列的新方法 PatchTST。我们已经利用 MQL5 实现了提议方法的愿景。现在是时候测试已完成的工作了。我们首先采用真实历史数据训练模型。然后,我们在 MetaTrader 5 策略测试器中,采用训练数据集之外的历史周期内测试训练好的模型。
如前,该模型依据 EURUSD H1 历史数据上进行训练。训练后的模型采用 2024 年 1 月的历史数据,配以相同的金融产品和时间帧进行测试。在收集训练样本和测试所学政策时,我们用到的指标据取默认参数。
这些模型分两个阶段进行训练。在第一步中,我们训练环境状态编码器。该模型学习分析和普适时,仅依据品种价格动态和所分析指标的多元时间序列的历史数据。该过程不考虑账户状态和持仓。因此,我们据初始训练数据集上训练模型,无需收集额外的数据,直至我们依据重建掩码数据、并预测后续状态方面获得可接受的结果。
在第二阶段,我们训练扮演者的行为政策,及评论者对该行为的正确性评估。该阶段是迭代的,包括 2 个子进程:
- 训练扮演者和评论者模型。
- 参考扮演者当前政策,收集额外的环境数据。
经过若干次扮演者训练迭代,我得到一个能够在历史训练数据和新数据上均产生利润的模型。训练模型在新数据上的结果如下所示。
余额图形很难认定是平滑增加。无论如何,在测试期间,该模型进行了 25 笔交易,其中 13 笔以盈利了结。这相当于盈利交易的 52.0%。该值接近对等。不过,最大盈利交易比最大亏损交易高出 87.2%,平均盈利交易比平均亏损交易高出 28.6%。结果就是,在测试期间,盈利因子为 1.4。
结束语
在本文中,我们讨论了一种分析和预测多维时间序列的新方法 PatchTST,它结合了数据补片化、变换器运用、及表示学习的优势。数据补片化令模型能够更好地捕获局部时间形态和上下文,从而提高分析和预测的品质。变换器的运用令我们能够从数据中提取抽象表示,同时参考它们的时间序列和内在关系。
在本文的实践部分,我们利用 MQL5 实现了我们所提议方法的愿景。我们采用真实历史数据训练了模型。然后,我们取训练样本中未包含的新数据测试了已训练扮演者政策。获得的结果表明,运用 PatchTST 方法来构建和训练可盈利模型是可行的。
PatchTST 方法是分析和预测多元时间序列的强力工具,可被成功应用到各种实际问题。
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/14798



