
神经网络变得简单(第 90 部分):时间序列的频率插值(FITS)
概述
时间序列分析在金融市场的决策管理中扮演重要作用。金融的时间序列数据通常是复杂和动态的,其处理需要高效的方法。
在时间序列分析的高级研究之内,已开发了多种复杂的模型和方法。然而,这些模型往往是计算密集型的,令它们不太适合在动态金融市场条件下所用。这就是,当决策的时机至关重要时,它们难以得到应用。
此外,如今越来越多的管理决策是由移动设备做出的,而其资源亦有限。这一事实对于制定此类决策的模型提出了额外的要求。
在该上下文中,在频域中表示时间序列能提供所观察形态更高效、更紧凑地表示。例如,频谱数据和高振幅频率分析能有助于识别重要特征。
在之前的文章中,我们讨论了 FEDformer 方法,其使用频域来从时间序列中查找形态。不过,该方法所用的变换器很难称为轻量级模型。论文《FITS:依据 10k 参数为时间序列建模》提出了一种时间序列频率插值的方法(频率插值时间序列 - FITS),替代需要大量计算成本的复杂模型。它是时间序列分析和预测的紧凑、而高效的解决方案。FITS 使用频域插值来扩展所分析时间段的窗口,从而能够在无需大量计算开销的情况下高效提取时态特征。
FITS 方法的作者强调他们的方法有以下优点:
- FITS 是一种轻量级模型,配以少量参数,令其成为作用于资源有限设备上的理想选择。
- FITS 使用复杂的神经网络来收集有关信号幅度和相位的信息,从而提高了时间序列数据分析的效率。
1. FITS 算法
频域中的时间序列分析能够将信号分解为正弦分量的线性组合,而不会丢失数据。这些分量中的每一个都具有唯一的频率、初始相位、和振幅。虽然预测时间序列可能是一项具有挑战性的任务,但预测单一正弦分量相对简单,因为它只需要基于时移调整正弦波的相位。以这种方式平移的正弦波,经线性组合,来获取所分析时间序列的预测值。
这种方式令我们能够有效地保留所分析时间序列窗口的频率特性。它还维护时间窗口和预测范围之间的语义序列。
然而,在时域中预测每个正弦分量可能非常耗费劳力。为解决这个问题,FITS 方法的作者提议使用复频域,它提供了更紧凑、及信息更丰富的数据表示。
快速傅里叶变换(FFT) 可有效地将离散时间序列信号从时域转换到复频域。在傅里叶分析中,复频域由一个序列表示,其中每个频率分量都由一个复数表征。该复数反映了分量的振幅和相位,提供了完整的描述。频率分量的振幅表示该分量在时域中原始信号的幅度、或强度。对比之下,相位示意该分量引入的时移、或延迟。数学上,与频率分量相关的复数可以表示为按照给定振幅和相位的复指数元素:
其中 X(f) 是与频率为 f 的频率分量相关联的复数,
|X(f)| 是分量的幅度,
θ(f) 是分量的相位。
在复平面中,指数元素可以表示一个向量,元素长度等于振幅、角度等于相位:
因此,频域中的复数提供了一种简洁而优雅的方式,来表示傅里叶变换中每个频率分量的幅度和相位。
信号的时移对应于频域中的相移。在复频域中,这种相移能够表示为复指数的单位元素乘以对应的相位。平移信号仍具有 |X(f)| 的振幅,且相位在时域显示线性平移。
因此,幅度缩放和相移能够同步表达为复数的乘法。
基于较长时间序列在其频率表示中提供了较高的频率解析度这一事实,FITS 方法的作者在训练模型扩展时间序列区段时,往输入数据的分析窗口的频率表示里进行插值。他们提议采用单一复线性层来训练这类插值。结果就是,在插值期间,该模型学会将振幅缩放和相移作为复数的乘法。在 FITS 算法中,快速傅里叶变换被用来将时间序列段投影到复频域之中。插值后,使用逆 FFT 将频域表示反向投影到时域表示之中。
然而,这些区段的平均值导致的结果,会在其复频域表示中有一个非常大的零频率分量。为了解决这个问题,接收到的信号通过可逆归一化(RevIN)传递,这允许我们获得一个零均值的实例。
此外,该方法的作者采用低通滤波器(LPF)补充 FITS,从而降低模型尺寸。低通滤波器可有效去除高于指定截止频率的高频分量,压缩模型表示,同时保留重要的时间序列信息。
尽管在频域中运算,但 FITS 是在时域中进行训练,且经逆快速傅里叶变换后,使用标准损失函数,如均方误差(MSE)。这提供了一种通用的方式,可以适配各种后续的时间序列问题。
在预测任务中,FITS 会生成一个可回逆性分析窗口,伴同计划横向范围。这样控制能够涵盖预测和可回逆性分析,并鼓励模型准确地重造可回逆性分析窗口。在引用的论文中进行的分析表明,在某些状况下,后知后觉和预测监控的结合能导致性能的提升。
对于重造任务,FITS 基于指定的子采样率从输入时间序列区段进行子采样。然后,它执行频率插值,这允许将欠采样区段恢复至其原始形式。因此,应用直接有损控制以确保准确的信号重造。
为了控制模型结果张量的长度,该方法的作者引入了一个插值率,表示为 η,即模型结果张量所需大小与原始数据张量的相应大小的比率。
值得注意的是,当应用低通滤波器(LPF)时,复数层的输入数据张量的大小对应于 LPF 的截止频率(COF)。执行频率插值后,复频表示为按结果张量的所需大小填充零。在应用逆 FFT 之前,引入一个额外的零作为零频率分量的代表。
将 LPF 包含在 FITS 中的主要目的是压缩模型体量,同时保留重要信息。LPF 通过舍弃高于给定截止频率(COF)的频率分量来达成这一点,从而产生更简洁的频域表示。LPF 保留了时间序列中的相关信息,同时舍弃超出模型学习能力的分量。这可确保保留输入时间序列中很大一部分有意义的内容。该方法作者进行的实验表明,即使仅保留频域中原始表示的四分之一,滤波后的信号展现出最小的失真。甚至,经 LPF 滤波的高频分量典型情况下包含的噪声,本质上与有效时间序列的建模无关。
此处的艰巨任务是选择一个合适的截止频率(COF)。为了解决这个问题,FITS 的作者提议一种基于主频率谐波含量的方法。谐波是主频率的整数倍,在塑造时间序列信号的波形中扮演重要角色。截止频率较之这些谐波,我们保留了与信号结构和周期性相关的对应频率分量。这种方式利用频率之间的内在关系来提取有意义的信息,同时抑制噪声和不必要的高频分量。
原作者对 FITS 方法的可视化如下所示。
2. 利用 MQL5 实现
我们已研究了 FITS 方法的理论层面。现在我们可以转到利用 MQL5 具体实现所提议的方式。
如常,我们将采用所提议方法,由于我们正在解决的问题的特殊性,我们的实现将与作者的算法愿景有所不同。
2.1FFT 实现
从上面阐述的方法理论描述可以看出,它是基于直接和逆快速傅里叶分解的。使用快速傅里叶分解,我们首先将所分析信号转换为频域,然后将所预测序列返回至时间序列表示。在这种情况下,我们可以看到快速傅里叶变换的两个主要优点:
- 与其它类似转换相比的运算速度
- 通过直接变换来表达逆变换的能力
这里应当注意,在我们的任务框架内,我们需要实现多元时间序列的 FFT。在实践中,它是应用于多元序列中每个时间序列单元的 FFT。
我们实现中的大多数数学运算都转移到 OpenCL。这令我们能够将依据大量独立数据的类似运算,分派到多个并行线程上执行。这减少了执行作所需的时间。故此,我们将在 OpenCL 端执行快速傅里叶分解运算。在每个并行线程中,我们将执行时间序列单元的分解。
我们将起草的的算法,会以 FFT 内核形式执行运算。在内核参数中,我们将传递 4 个数据数组的指针。此处,我们用两个数组来存储输入数据和运算结果。一个数组包含复数值的实部(信号幅度),第二个则包含虚部(其相位)。
不过,请注意,我们不会始终将信号的虚部投喂到内核。例如,在分解输入时间序列时,我们就没有这部分。在这种状况下,解决方案十分简单:我们将用零值替换缺失的数据。为不传递一个填充为零值的单独缓冲区,我们将在内核参数中创建一个 input_complex 标志。
第二点需要注意的是,我们用于 FFT 的 Cooley-Tukey 算法仅针对长度为 2 的幂数的序列。这个条件施加了严格的制约。不过,该约束事关所分析信号的准备。如果我们用零值填充序列的缺失元素,该方法工作优秀。再者,为避免不必要地复制数据,并重新格式化时间序列,我们将向内核参数添加两个变量:input_window 和 output_window。在第一个变量中,我们示意所分析序列的实际长度,在第二个变量中,我们示意分解结果向量的大小,即 2 的幂。在这种情况下,我们谈论的是序列单元的大小。
另一个参数 reverse 指示运算的方向:正变换或逆变换。
__kernel void FFT(__global float *inputs_re, __global float *inputs_im, __global float *outputs_re, __global float *outputs_im, const int input_window, const int input_complex, const int output_window, const int reverse ) { size_t variable = get_global_id(0);
在内核主体中,我们首先定义一个线程标识符,它将指向我们正在分析的单元序列。于此,我们还将定义数据缓冲区中的偏移量、和其它必要的常量。
const ulong N = output_window; const ulong N2 = N / 2; const ulong inp_shift = input_window * variable; const ulong out_shift = output_window * variable;
在下一步中,我们按特定顺序对输入数据重新排序,这将令我们能够稍微优化 FFT 算法。
uint target = 0; for(uint position = 0; position < N; position++) { if(target > position) { outputs_re[out_shift + position] = (target < input_window ?inputs_re[inp_shift + target] : 0); outputs_im[out_shift + position] = ((target < input_window && input_complex) ? inputs_im[inp_shift + target] : 0); outputs_re[out_shift + target] = inputs_re[inp_shift + position]; outputs_im[out_shift + target] = (input_complex ?inputs_im[inp_shift + position] : 0); } else { outputs_re[out_shift + position] = inputs_re[inp_shift + position]; outputs_im[out_shift + position] = (input_complex ?inputs_im[inp_shift + position] : 0); } unsigned int mask = N; while(target & (mask >>= 1)) target &= ~mask; target |= mask; }
接下来是数据的直接变换,其在嵌套循环系统中执行。在外部循环中,我们为长度为 2、4、8 和 ...n 构建 FFT 迭代。
float real = 0, imag = 0; for(int len = 2; len <= (int)N; len <<= 1) { float w_real = (float)cos(2 * M_PI_F / len); float w_imag = (float)sin(2 * M_PI_F / len);
在循环主体中,我们为循环长度上每个点的参数旋转定义一个乘数,并组织一个嵌套循环,迭代涵盖正分析序列中的模块。
for(int i = 0; i < (int)N; i += len) { float cur_w_real = 1; float cur_w_imag = 0;
此处,我们声明当前相位旋转的变量,并组织另一个涵盖模块中元素的嵌套循环。
for(int j = 0; j < len / 2; j++) { real = cur_w_real * outputs_re[out_shift + i + j + len / 2] - cur_w_imag * outputs_im[out_shift + i + j + len / 2]; imag = cur_w_imag * outputs_re[out_shift + i + j + len / 2] + cur_w_real * outputs_im[out_shift + i + j + len / 2]; outputs_re[out_shift + i + j + len / 2] = outputs_re[out_shift + i + j] - real; outputs_im[out_shift + i + j + len / 2] = outputs_im[out_shift + i + j] - imag; outputs_re[out_shift + i + j] += real; outputs_im[out_shift + i + j] += imag; real = cur_w_real * w_real - cur_w_imag * w_imag; cur_w_imag = cur_w_imag * w_real + cur_w_real * w_imag; cur_w_real = real; } } }
在循环主体中,我们首先修改正分析元素,然后更改当前相位变量的值,以便下一次迭代。
请注意,缓冲区元素的修改是“就地”执行的,不会分配额外的内存。
循环系统迭代完成后,我们检查 reverse 标志的值。如果我们执行的是逆变换,我们将重排结果缓冲区中的数据。在这种情况下,我们将得到的数值除以序列中的元素数量。
if(reverse) { outputs_re[0] /= N; outputs_im[0] /= N; outputs_re[N2] /= N; outputs_im[N2] /= N; for(int i = 1; i < N2; i++) { real = outputs_re[i] / N; imag = outputs_im[i] / N; outputs_re[i] = outputs_re[N - i] / N; outputs_im[i] = outputs_im[N - i] / N; outputs_re[N - i] = real; outputs_im[N - i] = imag; } } }
2.2组合预测分布的实部和虚部
上面所述内核允许执行直接和逆快速傅里叶分解,其完全涵盖了我们的需求。但是 FITS 方法中还有一点应当加以关注。该方法的作者使用复杂的神经网络来插值数据。有关复杂神经网络的详细介绍,我建议您阅读文章《复值神经网络勘察》。在本实现中,我们将用已有的神经层类,这些类将分开插入实部和虚部,然后根据以下公式将它们组合:
为执行这运算,我们将创建 ComplexLayer 内核。内核算法十分简单。我们只在二维中标识一个线程,该线程指向一行和一列矩阵。我们判定数据缓冲区中偏移,并执行简单的数学运算。
__kernel void ComplexLayer(__global float *inputs_re, __global float *inputs_im, __global float *outputs_re, __global float *outputs_im ) { size_t i = get_global_id(0); size_t j = get_global_id(1); size_t total_i = get_global_size(0); size_t total_j = get_global_size(1); uint shift = i * total_j + j; //--- outputs_re[shift] = inputs_re[shift] - inputs_im[shift]; outputs_im[shift] = inputs_im[shift] + inputs_re[shift]; }
ComplexLayerGradient 反向传播内核的构造方式类似。您可以学习附件中的代码。
我们在 OpenCL 程序端的运算到此结束。
2.3创建 FITS 方法类
在结束 OpenCL 程序内核的工作后,我们转到主程序,在其中我们将创建 CNeuronFITSOCL 类,以便实现 FITS 方法作者提议的方式。新类将从神经层基类 CNeuronBaseOCL 派生而来。新类的结构如下所示。
class CNeuronFITSOCL : public CNeuronBaseOCL { protected: //--- uint iWindow; uint iWindowOut; uint iCount; uint iFFTin; uint iIFFTin; //--- CNeuronBaseOCL cInputsRe; CNeuronBaseOCL cInputsIm; CNeuronBaseOCL cFFTRe; CNeuronBaseOCL cFFTIm; CNeuronDropoutOCL cDropRe; CNeuronDropoutOCL cDropIm; CNeuronConvOCL cInsideRe1; CNeuronConvOCL cInsideIm1; CNeuronConvOCL cInsideRe2; CNeuronConvOCL cInsideIm2; CNeuronBaseOCL cComplexRe; CNeuronBaseOCL cComplexIm; CNeuronBaseOCL cIFFTRe; CNeuronBaseOCL cIFFTIm; CBufferFloat cClear; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, bool reverse = false); virtual bool ComplexLayerOut(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im); virtual bool ComplexLayerGradient(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronFITSOCL(void) {}; ~CNeuronFITSOCL(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint count, float dropout, 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 defNeuronFITSOCL; } virtual void SetOpenCL(COpenCLMy *obj); virtual void TrainMode(bool flag); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
新类的结构包含相当多的内部神经层对象的声明。在上下文中所宣称的模型简单性,这会引出一定的不和谐。不过,请注意,我们仅训练 4 个负责数据插值的嵌套神经层(cInsideRe* 和 cInsideIm*)的参数。其余对象充当中间数据缓冲区。在实现这些方法时,我们将研究它们的目的。
还有,要留意,我们有两个 CNeuronDropoutOCL 层。在本实现中,我未用到 LFP,这涉及判定某个截止频率。此处,我想起了 FEDformer 方法作者的实验,他们谈及一组频率特性采样的效率。由此,我决定使用 Dropout 层将一定数量的随机频率特性设置为零。
我们把所有内部对象声明为静态,如此我们就可将类构造函数和析构函数留空。对象和所有局部变量都在 Init 方法中初始化。如常,在方法参数中,我们指定变量,允许所需对象结构成为唯一地判定。于此我们有单元输入和输出数据序列(window 和 window_out)的窗口大小,单元时间序列的数量(count),以及归零频率特性的比例(dropout)。注意,我们正在构建一个统一的层,源数据和结果的窗口大小可以是任何正数,无需参考 FFT 算法的需求。正如我们所见,指定的算法要求输入大小等于 2 的幂之一。
bool CNeuronFITSOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint count, float dropout, ENUM_OPTIMIZATION optimization_type, uint batch) { if(window <= 0) return false; if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_out * count, optimization_type, batch)) return false;
在方法主体中,我们首先运行一个小的控制模块,在其中检查输入窗口的大小(必须为正数),并调用同名的父类方法。如您所知,父类方法实现了继承对象的附加控制和初始化。
控制模块成功通过之后,我们将收到的参数保存在局部变量当中。
//--- Save constants
iWindow = window;
iWindowOut = window_out;
iCount = count;
activation=None;
我们在判定直接和逆 FFT 张量的大小时,取大于所获参数、且最接近的 2 的幂次方数值。
//--- Calculate FFT and iFFT size int power = int(MathLog(iWindow) / M_LN2); if(MathPow(2, power) != iWindow) power++; iFFTin = uint(MathPow(2, power)); power = int(MathLog(iWindowOut) / M_LN2); if(MathPow(2, power) != iWindowOut) power++; iIFFTin = uint(MathPow(2, power));
接踵而至的模块用于初始化嵌套对象。cInputs* 对象用作直接 FFT 的输入数据缓冲区。它们的大小等于给定模块输入处的单元序列大小、与所分析序列数量的乘积。
if(!cInputsRe.Init(0, 0, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cInputsIm.Init(0, 1, OpenCL, iFFTin * iCount, optimization, iBatch)) return false;
记录直接傅里叶分解结果的对象 cFFT* 具有类似的大小。
if(!cFFTRe.Init(0, 2, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cFFTIm.Init(0, 3, OpenCL, iFFTin * iCount, optimization, iBatch)) return false;
接下来我们声明 Dropout 对象。它们的大小等同于之前那个。
if(!cDropRe.Init(0, 4, OpenCL, iFFTin * iCount, dropout, optimization, iBatch)) return false; if(!cDropIm.Init(0, 5, OpenCL, iFFTin * iCount, dropout, optimization, iBatch)) return false;
对于序列插值,我们将使用具有一个隐藏层的 MLP,并在层之间使用 tanh 激活。在模块的输出端,我们根据逆 FFT 模块的需求接收数据。
if(!cInsideRe1.Init(0, 6, OpenCL, iFFTin, iFFTin, 4*iIFFTin, iCount, optimization, iBatch)) return false; cInsideRe1.SetActivationFunction(TANH); if(!cInsideIm1.Init(0, 7, OpenCL, iFFTin, iFFTin, 4*iIFFTin, iCount, optimization, iBatch)) return false; cInsideIm1.SetActivationFunction(TANH); if(!cInsideRe2.Init(0, 8, OpenCL, 4*iIFFTin, 4*iIFFTin, iIFFTin, iCount, optimization, iBatch)) return false; cInsideRe2.SetActivationFunction(None); if(!cInsideIm2.Init(0, 9, OpenCL, 4*iIFFTin, 4*iIFFTin, iIFFTin, iCount, optimization, iBatch)) return false; cInsideIm2.SetActivationFunction(None);
我们将插值结果合并到 cComplex* 对象之中。
if(!cComplexRe.Init(0, 10, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cComplexIm.Init(0, 11, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false;
根据 FITS 方法,插值序列经由逆傅里叶分解,期间,频率特性被变换到时间序列。我们将结果写入 cIFFT 对象。
if(!cIFFTRe.Init(0, 12, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cIFFTIm.Init(0, 13, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false;
此外,我们将声明一个零值的辅助缓冲区,我们将用其来补充缺失值。
if(!cClear.BufferInit(MathMax(iFFTin, iIFFTin)*iCount, 0)) return false; cClear.BufferCreate(OpenCL); //--- return true; }
所有嵌套对象成功初始化后,我们完成该方法。
下一步是实现类功能。但在直接转入前馈和反向传播方法之前,我们需要做一些准备工作,来实现将上面构建的内核放入执行队列的功能。这样的内核具有类似的算法。在本文的框架内,我们将只研究调用快速傅里叶变换内核 CNeuronFITSOCL::FFT 的方法。
bool CNeuronFITSOCL::FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, bool reverse = false) { uint global_work_offset[1] = {0}; uint global_work_size[1] = {iCount};
在方法参数中,我们将传递 4 个必需数据缓冲区(2 个用于输入数据,2 个用于结果)的指针,以及一个运算方向的标志。
在方法主体中,我们定义了任务空间。此处,我们所用的一维问题空间,按欲分析序列的数量。
然后我们将参数传递给内核。首先,我们将传递源数据缓冲区指针。
if(!OpenCL.SetArgumentBuffer(def_k_FFT, def_k_fft_inputs_re, inp_re.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FFT, def_k_fft_inputs_im, (!!inp_im ? inp_im.GetIndex() : inp_re.GetIndex()))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
注意,我们允许缺乏信号虚部缓冲区的情况下启动内核。如您所知,为此,我们在内核中用到 input_complex 标志。不过,未把所有必要参数传递给内核的话,我们将得到一个运行时错误。因此,由于没有虚部缓冲区,我们指定一个指向信号实部缓冲区的指针,并在相应的标志里指定 false。
if(!OpenCL.SetArgument(def_k_FFT, def_k_fft_input_complex, int(!!inp_im))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
然后我们将传递结果缓冲区指针。
if(!OpenCL.SetArgumentBuffer(def_k_FFT, def_k_fft_outputs_re, out_re.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FFT, def_k_fft_outputs_im, out_im.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
我们还传递输入和输出窗口的大小。后者是 2 的幂。请注意,我们计算窗口大小,而非从常量中获取它们。这是因为事实上,我们将针对直接和逆傅里叶变换采用这种方法,这将依据不同的缓冲区执行,相应地,输入和输出窗口亦有所不同。
if(!OpenCL.SetArgument(def_k_FFT, def_k_fft_input_window, (int)(inp_re.Total() / iCount))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FFT, def_k_fft_output_window, (int)(out_re.Total() / iCount))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
作为最后一个参数,我们传递一个标志,指示是否使用逆变换算法。
if(!OpenCL.SetArgument(def_k_FFT, def_k_fft_reverse, int(reverse))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
把内核放入执行队列当中。
if(!OpenCL.Execute(def_k_FFT, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
在每个阶段,我们都要控制运算过程,并将所执行运算的逻辑值返回给调用者。
CNeuronFITSOCL::ComplexLayerOut 和 CNeuronFITSOCL::ComplexLayerGradient 方法,在其中调用同名内核,是在类似原理上构建。您能在附件中找到它们。
准备工作完成后,我们转到构造前馈通验算法,如 CNeuronFITSOCL::feedForward 方法中所述。
在参数中,该方法接收指向前一级神经层对象的指针,其内传递输入数据。在方法主体中,我们立即检查接收到的指针。
bool CNeuronFITSOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
FITS 需要初步归一化数据。我们假设数据归一化是在前面的神经层执行的,并在该类中省略了这一步。
我们使用直接快速傅里叶变换将获得的数据转换为频域响应。为此,我们调用相应的方法(其算法如上所示)。
//--- FFT if(!FFT(NeuronOCL.getOutput(), NULL, cFFTRe.getOutput(), cFFTIm.getOutput(), false)) return false;
我们使用 Dropout 层消弱得到的频率特性。
//--- DropOut if(!cDropRe.FeedForward(cFFTRe.AsObject())) return false; if(!cDropIm.FeedForward(cFFTIm.AsObject())) return false;
之后,我们按预测值的大小进行频率特性插值。
//--- Complex Layer if(!cInsideRe1.FeedForward(cDropRe.AsObject())) return false; if(!cInsideRe2.FeedForward(cInsideRe1.AsObject())) return false; if(!cInsideIm1.FeedForward(cDropIm.AsObject())) return false; if(!cInsideIm2.FeedForward(cInsideIm1.AsObject())) return false;
我们组合信号的实部和虚部的单独插值。
if(!ComplexLayerOut(cInsideRe2.getOutput(), cInsideIm2.getOutput(), cComplexRe.getOutput(), cComplexIm.getOutput())) return false;
我们把逆分解的输出信号返回到时域。
//--- iFFT if(!FFT(cComplexRe.getOutput(), cComplexIm.getOutput(), cIFFTRe.getOutput(), cIFFTIm.getOutput(), true)) return false;
请注意,生成的预测序列也许会超出我们必须传递给后续神经层的序列大小。因此,我们将从信号的实部选择所需的模块。
//--- To Output if(!DeConcat(Output, cIFFTRe.getGradient(), cIFFTRe.getOutput(), iWindowOut, iIFFTin - iWindowOut, iCount)) return false; //--- return true; }
不要忘记在每个步骤控制结果。所有迭代完成后,我们将运算执行的逻辑结果返回给调用方。
实现前馈通验后,我们转到构造反向传播方法。CNeuronFITSOCL::calcInputGradients 方法根据误差梯度对最终结果的影响,将误差梯度传播到所有内部对象、及前一层。
bool CNeuronFITSOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
在参数中,该方法接收一个指向前一层对象的指针,我们必须将误差梯度传递给该对象。在方法主体中,我们立即检查接所接收指针的相关性。
我们从下一层得到的误差梯度已经存储在 Gradient 缓冲区之中。不过,它仅包含信号的真实部分,且仅包含给定的预测深度。我们需要的误差梯度,来自逆变换的总信号横向中的实部和虚部。为了生成此类数据,我们是从两个假设进行:
- 在前馈通验期间,在逆傅里叶变换模块的输出处,我们期待获得离散的时间序列值。在这种情况下,信号的实部对应于所需的时间序列,虚部等于(或接近)“0”。因此,虚部的误差等同取其符号相反的数值。
- 由于我们没有关于超出给定计划横向范围的预测值正确性的信息,我们就简单地忽略可能的偏差,并认为它们的误差为 “0”。
//--- Copy Gradients if(!SumAndNormilize(cIFFTIm.getOutput(), GetPointer(cClear), cIFFTIm.getGradient(), 1, false, 0, 0, 0, -1)) return false;
if(!Concat(Gradient, GetPointer(cClear), cIFFTRe.getGradient(), iWindowOut, iIFFTin - iWindowOut, iCount)) return false;
另请注意,误差梯度以时间序列的形式体现。不过,预测是在频域中进行的。因此,我们还需要将误差梯度转换至频域。在这个运算中,我们采用快速傅里叶变换。
//--- FFT if(!FFT(cIFFTRe.getGradient(), cIFFTIm.getGradient(), cComplexRe.getGradient(), cComplexIm.getGradient(), false)) return false;
我们在实部和虚部的 2 个 MLP 之间分派频率特性。
//--- Complex Layer if(!ComplexLayerGradient(cInsideRe2.getGradient(), cInsideIm2.getGradient(), cComplexRe.getGradient(), cComplexIm.getGradient())) return false;
然后,我们通过 MLP 分派误差梯度。
if(!cInsideRe1.calcHiddenGradients(cInsideRe2.AsObject())) return false; if(!cInsideIm1.calcHiddenGradients(cInsideIm2.AsObject())) return false; if(!cDropRe.calcHiddenGradients(cInsideRe1.AsObject())) return false; if(!cDropIm.calcHiddenGradients(cInsideIm1.AsObject())) return false;
通过 Dropout 层,我们将误差梯度传播到直接傅里叶变换模块的输出。
//--- Dropout if(!cFFTRe.calcHiddenGradients(cDropRe.AsObject())) return false; if(!cFFTIm.calcHiddenGradients(cDropIm.AsObject())) return false;
现在我们需要将误差梯度从频域转换到时间序列。此运算采用逆变换执行。
//--- IFFT if(!FFT(cFFTRe.getGradient(), cFFTIm.getGradient(), cInputsRe.getGradient(), cInputsIm.getGradient(), true)) return false;
最后,我们仅把实部误差梯度的必要部分传递给前一层。
//--- To Input Layer if(!DeConcat(NeuronOCL.getGradient(), cFFTIm.getGradient(), cFFTRe.getGradient(), iWindow, iFFTin - iWindow, iCount)) return false; //--- return true; }
与往常一样,我们控制执行方法体中所有作的过程,最后我们将作正确性的逻辑值返回给调用者。
误差梯度传播过程之后是模型参数的更新。该过程在 CNeuronFITSOCL::updateInputWeights 方法中实现。如前所述,在类中声明的众多对象中,只有 MLP 层包含学习参数。故此,我们将在下面的方法里调整该层的参数。
bool CNeuronFITSOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cInsideRe1.UpdateInputWeights(cDropRe.AsObject())) return false; if(!cInsideIm1.UpdateInputWeights(cDropIm.AsObject())) return false; if(!cInsideRe2.UpdateInputWeights(cInsideRe1.AsObject())) return false; if(!cInsideIm2.UpdateInputWeights(cInsideIm1.AsObject())) return false; //--- return true; }
在搭配文件操作方法工作时,我们还需要考虑这样一个事实,即我们有大量不包含可训练参数的内部对象。没有意义去存储相当大量没有价值的信息。因此,在数据保存方法 CNeuronFITSOCL::Save 中,我们首先调用父类同名方法。
bool CNeuronFITSOCL::Save(const int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) return false;
之后,我们保存架构常量。
//--- Save constants if(FileWriteInteger(file_handle, int(iWindow)) < INT_VALUE) return false; if(FileWriteInteger(file_handle, int(iWindowOut)) < INT_VALUE) return false; if(FileWriteInteger(file_handle, int(iCount)) < INT_VALUE) return false; if(FileWriteInteger(file_handle, int(iFFTin)) < INT_VALUE) return false; if(FileWriteInteger(file_handle, int(iIFFTin)) < INT_VALUE) return false;
并保存 MLP 对象。
//--- Save objects if(!cInsideRe1.Save(file_handle)) return false; if(!cInsideIm1.Save(file_handle)) return false; if(!cInsideRe2.Save(file_handle)) return false; if(!cInsideIm2.Save(file_handle)) return false;
我们加入更多的 Dropout 模块对象。
if(!cDropRe.Save(file_handle)) return false; if(!cDropIm.Save(file_handle)) return false; //--- return true; }
就是这样。其余对象仅包含数据缓冲区,其中的信息仅在一次前-后向通验运行中相关。因此,我们不会存储它们,因之节省磁盘空间。然而,一切都有其成本:我们无奈将数据加载方法 CNeuronFITSOCL::Load 的算法复杂化。
bool CNeuronFITSOCL::Load(const int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) return false;
在该方法中,我们首先镜像数据保存方法:
- 调用父类同名方法。
- 加载常数。控制到达数据文件的末尾。
//--- Load constants if(FileIsEnding(file_handle)) return false; iWindow = uint(FileReadInteger(file_handle)); if(FileIsEnding(file_handle)) return false; iWindowOut = uint(FileReadInteger(file_handle)); if(FileIsEnding(file_handle)) return false; iCount = uint(FileReadInteger(file_handle)); if(FileIsEnding(file_handle)) return false; iFFTin = uint(FileReadInteger(file_handle)); if(FileIsEnding(file_handle)) return false; iIFFTin = uint(FileReadInteger(file_handle)); activation=None;
- 读取 MLP 和 Dropout 参数。
//--- Load objects if(!LoadInsideLayer(file_handle, cInsideRe1.AsObject())) return false; if(!LoadInsideLayer(file_handle, cInsideIm1.AsObject())) return false; if(!LoadInsideLayer(file_handle, cInsideRe2.AsObject())) return false; if(!LoadInsideLayer(file_handle, cInsideIm2.AsObject())) return false; if(!LoadInsideLayer(file_handle, cDropRe.AsObject())) return false; if(!LoadInsideLayer(file_handle, cDropIm.AsObject())) return false;
现在我们需要初始化缺失的对象。此处,我们重复类初始化方法中的一些代码。
//--- Init objects if(!cInputsRe.Init(0, 0, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cInputsIm.Init(0, 1, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cFFTRe.Init(0, 2, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cFFTIm.Init(0, 3, OpenCL, iFFTin * iCount, optimization, iBatch)) return false; if(!cComplexRe.Init(0, 8, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cComplexIm.Init(0, 9, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cIFFTRe.Init(0, 10, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cIFFTIm.Init(0, 11, OpenCL, iIFFTin * iCount, optimization, iBatch)) return false; if(!cClear.BufferInit(MathMax(iFFTin, iIFFTin)*iCount, 0)) return false; cClear.BufferCreate(OpenCL); //--- return true; }
我们描述新 CNeuronFITSOCL 类的方法,及其算法的工作至此结束。您可在附件中找到该类及其所有方法的完整代码。附件还包含本文中用到的所有程序。现在我们转到研究模型训练架构。
2.4模型架构
所提议 FITS 方法用于时间序列分析和预测。您或许已经猜到了,我们将在环境状态编码器中采用所提议方法。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; }
数据在批量归一化层中进行预处理。这令数据具有可比性,并提升了模型训练过程的稳定性。
//--- 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 = defNeuronTransposeOCL; descr.count = HistoryBars; descr.window = BarDescr; if(!encoder.Add(descr)) { delete descr; return false; }
在该阶段,准备工作可以认为已经完成,我们能转到单元时间序列的分析和预测。我们在新类的对象中实现该过程。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFITSOCL; descr.count = BarDescr; descr.window = HistoryBars; descr.activation = None; descr.window_out = NForecast; if(!encoder.Add(descr)) { delete descr; return false; }
在我们的类主体中,我们几乎已实现了整个提议的 FITS 过程。在神经层的输出端,我们有预测值。故此,我们只需要将预测值的张量转置到预期结果的维度上。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = BarDescr; descr.window = NForecast; if(!encoder.Add(descr)) { delete descr; return false; }
我们还需要加上之前删除的输入数据统计分布的参数。
//--- layer 5 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; } //--- return true; }
如您所见,正如 FITS 方法的作者所承诺的那样,分析和预测环境后续状态的模型非常简约。同时,我们对模型架构所做的更改对于输入数据的体量、亦或格式完全没有影响。我们也未更改模型输出的格式。因此,我们能在不进行修改的情况下使用之前创建的扮演者和评论者模型架构。此外,我们能用以前构建的 EA 与环境和模型训练进行交互,以及之前收集的训练数据集。我们仅需更改的是指向环境状态的潜在表示层的指针。
#define LatentLayer 3
您可在附件中找到此处用到的所有程序的完整代码。是时候测试了。
3. 测试
我们领略了 FITS 方法,并利用 MQL5 针对所提议方式的实现做了严谨的工作。现在是时候采用真实的历史数据来检验我们的工作成果了。如前,我们将依据 EURUSD 历史数据,在 H1 时间帧中训练和测试模型。为了训练模型,我们使用了 2023 年全年的历史数据。为了测试已训练模型,我们使用了 2024 年 1 月的数据。
模型训练过程在上一篇文章中进行了介绍。我们首先训练环境状态编码器,以便预测后续状态。然后,我们迭代训练扮演者的行为政策,以便达成最大化盈利。
正如预期,编码器模型非常轻巧。学习过程相对快捷和平滑。尽管其规模较小,但该模型的性能可与上一篇文章中讨论的 FEDformer 模型相媲美。此处值得注意的是,该模型的规模几乎小了 84 倍。
但扮演者政策训练阶段令人失望。该模型只能在某些历史区间展现出盈利能力。在下面的余额图中,除了测试部分之外,我们看到本月前 10 天的增长相当迅速。但第二个十年正在亏损,很少有盈利交易。第三个十年盈利和亏损交易之间接近对等。
总体上,我们的月收入很小。在此可注意到,最大和平均盈利交易的规模超过了相应的亏损指标。然而,盈利交易的数量不到一半,这抵消了平均盈利交易的优势。
于此可以注意到,测试结果部分证实了 FEDformer 方法作者的结论:由于输入数据中没有明确的周期性,DFT 无法判定趋势变化的时刻。
结束语
在本文中,我们讨论了一种用于时间序列分析和预测的新 FITS 方法。该方法的主要特点是针对频率特性领域的时间序列进行分析和预测。由于该方法使用直接和逆快速傅里叶变换算法,故我们可在模型的输入和输出处运用熟悉的离散时间序列操作。该功能允许在使用时间序列分析和预测的许多领域实现所提议的轻量级架构。
在本文的实践部分,我们利用 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/14913


