
神经网络变得简单(第 93 部分):频域和时域中的自适应预测(终篇)
概述
在上一篇文章中,我们领略了 ATFNet 算法,它是 2 个时间序列预测模型的融合。其中一个工作在时域,并根据信号幅度的分析,为所研究时间序列构造预测值。第二个模型则配以所分析时间序列的频率特征工作,并记录其全局依赖关系、周期性、和频谱。根据该方法作者的说法,两个独立预测的自适应合并,生成了令人印象深刻的结果。
频率 F-模块的主要特点是其算法构造,完全使用复数运算。为了满足这一需求,在上一篇文章中,我们构建了 CNeuronComplexMLMHAttention 类。它完全重复了变换器 多层编码器算法,并带有多头自关注的元素。我们构建的集成关注度类是 F-模块的基础。在本文中,我们将继续实现 ATFNet 方法作者提议的方式。
1. 创建 ATFNet 类
在实现了频率 F-模块基础,即复数关注度类 CNeuronComplexMLMHAttention 之后,我们上升了一层,并创建了 CNeuronATFNetOCL 类,在其中我们将实现整个 ATFNet 算法。
我必须承认,在单个神经层类中实现像 ATFNet 这样的复数算法或许并非最优解。但是我们之前构建的顺序神经网络模型并未提供组织若干不同进程并行工作的可能性,这正是我们的情况:我们使用 T-模块和 F-模块。实现这样的功能将需要更多的全局修改。因此,我决定创建一个成本最低的解决方案,即将整个算法作为一个神经层类实现。CNeuronATFNetOCL 类结构如下所示。
class CNeuronATFNetOCL : public CNeuronBaseOCL { protected: uint iHistory; uint iForecast; uint iVariables; uint iFFT; //--- T-Block CNeuronBatchNormOCL cNorm; CNeuronTransposeOCL cTranspose; CNeuronPositionEncoder cPositionEncoder; CNeuronPatching cPatching; CLayer caAttention; CLayer caProjection; CNeuronRevINDenormOCL cRevIN; //--- F-Block CBufferFloat *cInputs; CBufferFloat cInputFreqRe; CBufferFloat cInputFreqIm; CNeuronBaseOCL cInputFreqComplex; CBufferFloat cMainFreqWeights; CNeuronBaseOCL cNormFreqComplex; CBufferFloat cMeans; CBufferFloat cVariances; CNeuronComplexMLMHAttention cFreqAtteention; CNeuronBaseOCL cUnNormFreqComplex; CBufferFloat cOutputFreqRe; CBufferFloat cOutputFreqIm; CBufferFloat cOutputTimeSeriasRe; CBufferFloat cOutputTimeSeriasIm; CBufferFloat cOutputTimeSeriasReGrad; CBufferFloat cReconstructInput; CBufferFloat cForecast; CBufferFloat cReconstructInputGrad; CBufferFloat cForecastGrad; CBufferFloat cZero; //--- virtual bool FFT(CBufferFloat *inp_re, CBufferFloat *inp_im, CBufferFloat *out_re, CBufferFloat *out_im, bool reverse = false); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); virtual bool ComplexNormalize(void); virtual bool ComplexUnNormalize(void); virtual bool ComplexNormalizeGradient(void); virtual bool ComplexUnNormalizeGradient(void); virtual bool MainFreqWeights(void); virtual bool WeightedSum(void); virtual bool WeightedSumGradient(void); virtual bool calcReconstructGradient(void); public: CNeuronATFNetOCL(void) {}; ~CNeuronATFNetOCL(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint history, uint forecast, uint variables, uint heads, uint layers, uint &patch[], ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronATFNetOCL; } //--- 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 *net, float tau); virtual void SetOpenCL(COpenCLMy *obj); virtual CBufferFloat *getWeights(void); };
在所述 CNeuronATFNetOCL 类结构中,请留意四个内部变量:
- iHistory:所分析历史的深度;
- iForecast:规划横向范围;
- iVariables:所分析变量的数量(幺正时间序列);
- iFFT:快速傅里叶分解张量(DFT)的大小。
正如我们早前所见,DFT 算法要求初始数据向量的大小等于某个 “2” 的幂。因此,我们用零值补充初始数据张量,直至所需的大小。
该方法的内部对象划分为两个模块,具体取决于它们属于 ATFNet 算法的哪个模块。在实现算法时,我们将研究它们的用途,以及类方法的功能。
所有内部对象都声明为静态,因此我们可以将 CNeuronATFNetOCL 类构造函数和析构函数留空。
1.1对象初始化
新类的内部对象的初始化在 Init 方法中执行。于此,我们遇到的第一个后继决定是,在一个类中实现整个 ATFNet 算法:我们需要从调用方传递大量参数。
实际上,在 CNeuronATFNetOCL 类中,我们必须在时间 T-模块和频率 F-模块中使用关注度机制构建两个并行多层模型。对于每个模型,我们需要指定架构。
为了解决这个问题,我们决定尽可能使用 “通用”参数,即两个模型都可以平等使用的参数。好吧,我们有用于描述输入和输出张量的参数:所分析历史的深度、幺正时间序列的数量、和计划的横向范围。这些参数在 T-模块和 F-模块中等同使用。
甚或,这两个模型都是围绕变换器的编码器构建的,并利用了配以若干层的多头自关注架构。我们决定在两个模块中使用相同数量的关注度头和编码器层。
不过,我们需要为 T-模块中用到的数据分片层传递额外的参数,而在 F-模块中没有类似参数。为了方法参数数量不会大幅增加,我决定使用一个包含 3 个元素的数组。该数组的第一个元素包含一个分片的窗口大小,第二个元素包含源数据缓冲区中该窗口的步数。在数组的最后一个元素中,我们在数据分片层的输出处写入一个补片的大小。
bool CNeuronATFNetOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint history, uint forecast, uint variables, uint heads, uint layers, uint &patch[], ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, forecast * variables, optimization_type, batch)) return false;
在方法主体中,如常,我们调用父类的同名初始化方法。请注意,对于父类方法,我们将层大小指定为所分析变量数量(幺正时间序列)、和规划横向围的乘积。换言之,我们预期 CNeuronATFNetOCL 层的输出应当是所分析时间序列延续预测的完备结果。
继承的对象初始化成功之后,我们将关键的架构参数保存到变量当中。
iHistory = MathMax(history, 1); iForecast = forecast; iVariables = variables;
然后,我们将计算用于快速傅里叶分解的张量大小。ATFNet 的作者提出了一种扩展傅里叶分解,可判定给定历史的完整时间序列的频率特征和预测数据。
uint size = iHistory + iForecast; int power = int(MathLog(size) / M_LN2); if(MathPow(2, power) < size) power++; iFFT = uint(MathPow(2, power));
下一步是初始化我们类的内部对象。我们从初始数据的预期表示开始。由于我们的模型假设幺正时间序列的分析是在时域和频域进行,因此我们希望接收一个单元时间序列矩阵作为我们层的输入。CNeuronATFNetOCL 将在输出处返回类似的预测值映射。
另一点是数据归一化。模型的两个模块都使用输入数据的归一化。区别在于 T-模块在时域中归一化,而 F-模块在频域中归一化。因此,在该实现中,我决定将非归一化数据投喂到该层中。随机特征的归一化和逆向添加则根据相应维度在独立模块内运作。
为了便于阅读和代码透明化,我们将按内部对象的模块使用、及所构造算法的顺序初始化内部对象。我们从 T-模块开始。
如上所述,非归一化的数据被输入到层中。因此,我们必须首先将获得的数据转换为可比较的形式。
//--- T-Block if(!cNorm.Init(0, 0, OpenCL, iHistory * iVariables, batch, optimization)) return false;
ATFNet方法作者未在频域中使用定位数据编码,而是在时域中分析数据时则用到它。我们加入一个定位编码层。
if(!cPositionEncoder.Init(0, 1, OpenCL, iVariables, iHistory, optimization, batch)) return false;
在构造数据分片层时,我们在其算法中构建了一种数据转置。现在,我们需要把它投喂到 CNeuronPatching 层之前准备输入。为了执行此操作,我们加入一个数据转置层。
if(!cTranspose.Init(0, 2, OpenCL, iHistory, iVariables, optimization, batch)) return false; cTranspose.SetActivationFunction(None);
接下来,我们在计算分片层输出处的补片数量时,需要基于一个分片的窗口大小、及其步数,这些数值得自方法参数,是由外部程序提供的。
uint count = (iHistory - patch[0] + 2 * patch[1] - 1) / patch[1];
在完成必要的准备工作之后,我们初始化数据分片层。
if(!cPatching.Init(0, 3, OpenCL, patch[0], patch[1], patch[2], count, iVariables, optimization, batch)) return false;
在构造 PatchTST 方法时,我们使用构象异构体作为关注度模块。在此,我们将使用相同的解决方案。在下一步中,我们将创建所需数量的 CNeuronConformer 嵌套层。
caAttention.SetOpenCL(OpenCL); for(uint l = 0; l < layers; l++) { CNeuronConformer *temp = new CNeuronConformer(); if(!temp) return false; if(!temp.Init(0, 4 + l, OpenCL, patch[2], 32, heads, iVariables, count, optimization, batch)) { delete temp; return false; } if(!caAttention.Add(temp)) { delete temp; return false; } }
分析输入时间序列的关注度模块后随一个由 3 个卷积层组成的模块,其将在独立幺正时间序列的上下文中预测整个规划深度的后续数据。
int total = 3; caProjection.SetOpenCL(OpenCL); uint window = patch[2] * count; for(int l = 0; l < total; l++) { CNeuronConvOCL *temp = new CNeuronConvOCL(); if(!temp) return false; if(!temp.Init(0, 4+layers+l, OpenCL, window, window, (total-l)*iForecast, iVariables, optimization, batch)) { delete temp; return false; } temp.SetActivationFunction(TANH); if(!caProjection.Add(temp)) { delete temp; return false; } window = (total - l) * iForecast; }
注意,在每一层中,我们指定相同数量的序列元素,等于所分析时间序列中的幺正时间序列的数量。在每个后续层中,神经层输出处的滤波器数量减少,变得与最后一层中指定的预测深度相等。
在 T-模块的输出端,我们使用 CNeuronRevINDenormOCL 层将输入时间序列的统计参数添加到预测值之中。
if(!cRevIN.Init(0, 4 + layers + total, OpenCL, iForecast * iVariables, 1, cNorm.AsObject())) return false;
此刻,我们已依据时域预测,初始化了与 T-模块相关的所有内部对象。现在我们转到处理频域 F-模块的对象。
根据 ATFNet 算法,投喂到 F-模块的输入数据由快速傅里叶分解(DFT)转换到频域。如您所记,我们早前构建的 DFT 算法的实现,将频谱写入两个数据缓冲区。一个是频谱的实部,第二个用于虚部。
//--- F-Block if(!cInputFreqRe.BufferInit(iFFT * iVariables, 0) || !cInputFreqRe.BufferCreate(OpenCL)) return false; if(!cInputFreqIm.BufferInit(iFFT * iVariables, 0) || !cInputFreqIm.BufferCreate(OpenCL)) return false;
为了便于后续处理,我们将频谱信息合并到一个缓冲区之中。
if(!cInputFreqComplex.Init(0, 0, OpenCL, iFFT * iVariables * 2, optimization, batch)) return false;
我们还需要准备一个缓冲区来写入主频率的份额。此处应当该注意的是,我们分别判定每个幺正时间序列的主频。
if(!cMainFreqWeights.BufferInit(iVariables, 0) || !cMainFreqWeights.BufferCreate(OpenCL)) return false;
我们的层输入是原始数据,它会产生完全不同的幺正时间序列频谱。为了在后续处理之前将频谱转换为可比较的形式,该方法的作者建议对频率特征进行归一化。我们将标准化数据保存在 cNormFreqComplex 层缓冲区中。
if(!cNormFreqComplex.Init(0, 1, OpenCL, iFFT * iVariables * 2, optimization, batch)) return false;
在这种情况下,我们将原始频谱的统计特征保存在相应的数据缓冲区之中。
if(!cMeans.BufferInit(iVariables, 0) || !cMeans.BufferCreate(OpenCL)) return false; if(!cVariances.BufferInit(iVariables, 0) || !cVariances.BufferCreate(OpenCL)) return false;
我们将使用复数关注度模块处理准备好的输入数据的频率特征。在上一篇文章中,我们执行了 CNeuronComplexMLMHAttention 类的大部分实现。现在我们只需要初始化指定类的内部对象。
if(!cFreqAtteention.Init(0, 2, OpenCL, iFFT, 32, heads, iVariables, layers, optimization, batch)) return false;
根据算法,在复数关注度模块中处理完输入频谱之后,我们需要执行逆向过程。首先,我们将输入频率特征的统计指标添加到处理后的频谱之中。
if(!cUnNormFreqComplex.Init(0, 1, OpenCL, iFFT * iVariables * 2, optimization, batch)) return false;
我们将频谱的实部和虚部分开。
if(!cOutputFreqRe.BufferInit(iFFT*iVariables, 0) || !cOutputFreqRe.BufferCreate(OpenCL)) return false; if(!cOutputFreqIm.BufferInit(iFFT*iVariables, 0) || !cOutputFreqIm.BufferCreate(OpenCL)) return false;
然后我们将数据返回到临时区域。
if(!cOutputTimeSeriasRe.BufferInit(iFFT*iVariables, 0) || !cOutputTimeSeriasRe.BufferCreate(OpenCL)) return false; if(!cOutputTimeSeriasIm.BufferInit(iFFT*iVariables, 0) || !cOutputTimeSeriasIm.BufferCreate(OpenCL)) return false;
为了反向传播通验的目的,我们为时间序列的实部创建一个梯度缓冲区。
if(!cOutputTimeSeriasReGrad.BufferInit(iFFT*iVariables, 0) || !cOutputTimeSeriasReGrad.BufferCreate(OpenCL)) return false;
请注意,我们不会为时间序列的虚部创建梯度缓冲区。关键是,对于时间序列,虚部的目标值为 “0”。因此,虚部的误差梯度等于具有相反符号的虚部值。在反向传播通验中,我们可把前馈通验结果缓冲区当作已处理时间序列的虚部。
请注意,在逆 DFT(iDFT)之后,我们计划接收一个处理过的完整时间序列,其中包括给定计划横向范围的重构输入数据、和预测值。为了提取预测值的所需部分,我们将完整的时间序列切分为两个缓冲区:重造数据、和预测值。
if(!cReconstructInput.BufferInit(iHistory*iVariables, 0) || !cReconstructInput.BufferCreate(OpenCL)) return false; if(!cForecast.BufferInit(iForecast*iVariables, 0) || !cForecast.BufferCreate(OpenCL)) return false;
为相应的误差梯度添加缓冲区。
if(!cReconstructInputGrad.BufferInit(iHistory*iVariables, 0) || !cReconstructInputGrad.BufferCreate(OpenCL)) return false; if(!cForecastGrad.BufferInit(iForecast*iVariables, 0) || !cForecastGrad.BufferCreate(OpenCL)) return false;
请注意,ATFNet 作者提议方法不提供据所分析时间序列输入值重造数据的偏差分析。我们添加此功能是为了尝试对复数关注度模块进行更精细的调整。潜在地,能更好地明白正分析数据将提升模型的预测品质。
此外,我们还创建了一个零值的缓冲区,用于填充输入数据、和误差梯度中的缺失值。
if(!cZero.BufferInit(iFFT*iVariables, 0) || !cZero.BufferCreate(OpenCL)) return false; //--- return true; }
不要忘记监控每个阶段的操作过程。所有声明的对象初始化完成之后,我们将方法操作的执行逻辑值返回给调用者。
1.2前馈通验
类对象的初始化完成之后,我们转到构造前馈算法。我们从在 OpenCL 程序中构建其它内核开始。
首先,思考幺正时间序列的频率响应频谱的归一化。如果我们使用以前实现的真实数据归一化算法,这可能会极大地扭曲数据。因此,我们需要在复数域中实现数据归一化。我们在 ComplexNormalize 内核中实现该功能。在内核参数中,我们将传递指向 4 个数据缓冲区的指针、及幺正序列的大小。我们在一维问题空间中使用这个内核,以幺正时间序频谱作为上下文。
__kernel void ComplexNormalize(__global float2 *inputs, __global float2 *outputs, __global float2 *means, __global float *vars, int dimension) { if(dimension <= 0) return;
注意数据缓冲区的声明。input、output 和 mean 数据缓冲区的向量类型均为 float2。我们决定在 OpenCL 端使用该类型数据来处理复数。不过,还有一个散布缓冲区声明为实数类型 float。散布示意数值与平均值的标准差。两点之间的距离是实数量化。
在方法主体中,我们检查获得的归一化向量的维度。显然,它必须大于 “0”。然后,我们在任务空间中标识当前线程,判定数据缓冲区中的偏移量,并为正在分析的序列创建复数维度表示。
size_t n = get_global_id(0); const int shift = n * dimension; const float2 dim = (float2)(dimension, 0);
接下来,我们组织一个循环,在其中判定所分析频谱的平均值。
float2 mean = 0; for(int i = 0; i < dimension; i++) { float2 val = inputs[shift + i]; if(isnan(val.x) || isinf(val.x) || isnan(val.y) || isinf(val.y)) inputs[shift + i] = (float2)0; else mean += val; } means[n] = mean = ComplexDiv(mean, dim);
我们立即将获得的结果保存在平均值缓冲区的相应元素之中。
在下一阶段,我们组织一个循环来判定所分析序列的散布程度。
float variance = 0; for(int i = 0; i < dimension; i++) variance += pow(ComplexAbs(inputs[shift + i] - mean), 2); vars[n] = variance = sqrt((isnan(variance) || isinf(variance) ? 1.0f : variance / dimension));
此处有两点需要注意。首先,尽管将平均值保存在外部数据缓冲区当中,在执行操作时,我们使用局部变量的值,因为访问位于上下文全局内存中的缓冲区元素,比访问局部内核变量要慢得多。
第二点是条理性:在计算复数序列的方差时,与实数不同,我们取复数序列元素与平均值的偏差的绝对值的平方。它是一个复数量化的绝对值,将示意实部和虚部的 2-维空间中点之间的距离。而复数量化的简单差异仅向我们示意坐标的变化。
在内核操作的最后阶段,我们组织了最后一个循环,在其中,我们对输入频谱的数据进行归一化。我们将得到的值写入结果缓冲区的相应元素之中。
float2 v=(float2)(variance, 0); for(int i = 0; i < dimension; i++) { float2 val = ComplexDiv((inputs[shift + i] - mean), v); if(isnan(val.x) || isinf(val.x) || isnan(val.y) || isinf(val.y)) val = (float2)0; outputs[shift + i] = val; } }
于此,我们还配以平均值和标准差的局部变量工作。
我们将立即创建一个逆向归一化内核 ComplexUnNormalize,其中我们返回提取的输入频谱统计指标。
__kernel void ComplexUnNormalize(__global float2 *inputs, __global float2 *outputs, __global float2 *means, __global float *vars, int dimension) { if(dimension <= 0) return;
该内核接收相同的参数集,其中包含 4 个指向数据缓冲区的指针,和一个变量。我们还计划在一维任务空间中运行内核,以获得幺正时间序列的数量。
在内核主体中,我们在任务空间中标识线程,并定义数据缓冲区中的偏移量。
size_t n = get_global_id(0); const int shift = n * dimension;
从缓冲区加载统计变量,并立即将标准差转换为复数值。
float v= vars[n]; float2 variance=(float2)((v > 0 ? v : 1.0f), 0) float2 mean = means[n];
然后在内核中仅组织数据转换循环。
for(int i = 0; i < dimension; i++) { float2 val = ComplexMul(inputs[shift + i], variance) + mean; if(isnan(val.x) || isinf(val.x) || isnan(val.y) || isinf(val.y)) val = (float2)0; outputs[shift + i] = val; } }
获得的值将被写入结果缓冲区的相应元素。
为了在主程序端调用上面创建的内核,我们调用 ComplexNormalize 和 ComplexUnNormalize 方法。它们的算法构造与之前研究的 OpenCL 程序内核排队的方法没有区别。因此,我们不再赘述这些方法。无论如何,它们都在附件中提供。
此外,为了自适应地组合时域和频域预测的结果,我们需要影响系数。ATFNet 方法的作者提议按主频率在整个频谱中的份额来判定它们。相应地,在 OpenCL 端,我们将为程序创建两个内核:
- MainFreqWeight — 判定主频率的份额;
- WeightedSum — 计算频域和时域中预测的加权和。
我们根据所分析幺正时间序列的数量,在一个一维任务空间中规划两个内核。
在 MainFreqWeight 内核参数中,我们将传递两个数据缓冲区(频率特征和结果)的指针,以及所分析序列的维度。
__kernel void MainFreqWeight(__global float2 *freq, __global float *weight, int dimension ) { if(dimension <= 0) return; //--- size_t n = get_global_id(0); const int shift = n * dimension;
在内核主体中,我们在任务空间中识别当前线程,并判定数据缓冲区中的偏移量。之后我们准备局部变量。
float max_f = 0; float total = 0; float energy;
接下来,我们运行一个循环来判定主频率、及整体频谱的能量。
for(int i = 0; i < dimension; i++) { energy = ComplexAbs(freq[shift + i]); total += energy; max_f = fmax(max_f, energy); }
为了完成内核运算,我们将主频率能量除以总频谱能量。结果值保存在输出缓存区的相应元素之中。
weight[n] = max_f / (total > 0 ? total : 1); }
用于判定时域和频域预测加权和的 WeightedSum 内核算法非常简单。在参数中,内核接收 4 个指向数据缓冲区的指针,和一个序列向量的维度(在我们的例子中,预测深度)。
__kernel void WeightedSum(__global float *inputs1, __global float *inputs2, __global float *outputs, __global float *weight, int dimension ) { if(dimension <= 0) return; //--- size_t n = get_global_id(0); const int shift = n * dimension;
在内核主体中,我们在一维任务空间中标识当前线程,并判定数据缓冲区中的偏移量:然后我们创建一个元素加权求和的循环。运算的结果将写入结果缓冲区的相应元素。
float w = weight[n]; for(int i = 0; i < dimension; i++) outputs[shift + i] = inputs1[shift + i] * w + inputs2[shift + i] * (1 - w); }
为了把内核放在主程序端的执行队列之中,我们创建了同名方法。您将在附件中找到这些代码。
准备工作完成后,我们转到构造 CNeuronATFNetOCL 类的前馈通验方法 feedForward。在该方法的参数中,以及父类的类似方法中,我们收到一个指向前一个神经层对象的指针,在这种情况下,它充当后续操作的初始数据。
bool CNeuronATFNetOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || !NeuronOCL.getOutput()) return false;
在方法主体中,我们首先检查所接收指针的相关性。于此我们还在当前对象的内部变量中保存了一个指向所获神经层结果缓冲区的指针。
if(cInputs != NeuronOCL.getOutput())
cInputs = NeuronOCL.getOutput();
接下来,我们首先在时域中对所分析时间序列的后续数据执行预测运算。归一化获得的数据。
//--- T-Block if(!cNorm.FeedForward(NeuronOCL)) return false;;
然后添加定位编码。
if(!cPositionEncoder.FeedForward(cNorm.AsObject())) return false;
转置生成的张量,并将数据拆分到模块。
if(!cTranspose.FeedForward(cPositionEncoder.AsObject())) return false; if(!cPatching.FeedForward(cTranspose.AsObject())) return false;
准备好的数据传送到关注度模块。
int total = caAttention.Total(); CNeuronBaseOCL *prev = cPatching.AsObject(); for(int i = 0; i < total; i++) { CNeuronBaseOCL *att = caAttention.At(i); if(!att.FeedForward(prev)) return false; prev = att; }
预测后续值。
total = caProjection.Total(); for(int i = 0; i < total; i++) { CNeuronBaseOCL *proj = caProjection.At(i); if(!proj.FeedForward(prev)) return false; prev = proj; }
在 T-模块的输出处,我们将输入时间序列的统计值添加到预测值之中。
if(!cRevIN.FeedForward(prev)) return false;
在得到时域中的预测值后,我们转到依据频域的工作。首先,我们将获得的时间序列转换为频率特征频谱。为此,我们使用 FFT 算法。
//--- F-Block if(!FFT(cInputs, cInputs, GetPointer(cInputFreqRe), GetPointer(cInputFreqIm), false)) return false;
在获得频谱的实部和虚部的两个缓冲区后,我们将它们组合成一个张量。
if(!Concat(GetPointer(cInputFreqRe), GetPointer(cInputFreqIm), cInputFreqComplex.getOutput(), 1, 1, iFFT * iVariables)) return false;
注意,当串联两个数据缓冲区时,我们采用 1 个元素的窗口大小。因此,我们得到了一个张量,其中相应频率特征的实部和虚部靠在一起。
我们归一化输入频率的结果张量。
if(!ComplexNormalize()) return false;
判定主频率的份额。
if(!MainFreqWeights()) return false;
我们将准备好的频率数据传递给关注度模块。此处我们仅需调用上一篇文章中创建的多层复数关注度类的前馈通验方法。
if(!cFreqAtteention.FeedForward(cNormFreqComplex.AsObject())) return false;
关注度模块成功执行后,我们将输入序列频率的统计参数返回给所处理数据。
if(!ComplexUnNormalize()) return false;
将频谱张量切分为其组成部分:实部和虚部。
if(!DeConcat(GetPointer(cOutputFreqRe), GetPointer(cOutputFreqIm), cUnNormFreqComplex.getOutput(), 1, 1, iFFT * iVariables)) return false;
将频谱转换回时间序列。
if(!FFT(GetPointer(cOutputFreqRe), GetPointer(cOutputFreqIm), GetPointer(cOutputTimeSeriasRe), GetPointer(cOutputTimeSeriasIm), true)) return false;
我认为我们应该解释一下 F-模块的上述操作。乍一看,将时间序列大量转换为频率响应,对其进行归一化,然后执行逆运算,再将数据返回到相同的时间序列,只是为了执行关注度操作,这看似很奇怪。甚至,除了关注度之外,所有这些操作都没有可训练的参数,而理论上应该返回原始时间序列。但这一切都与关注度模块有关。
我要提醒您,该方法作者提议使用扩展离散傅里叶变换。在实践中,我们简单地对完整时间序列的 DFT 使用复数指数基。但是当将原始时间序列转换至其频率特征时,我们没有预测值,只是将它们替换为零值。因此,执行逆 DFT 很可能返回接近 “0” 的预测值,这不够充分。因此,我们经归一化,将幺正时间序列的频谱化成可比较形式。在关注度模块中相互比较它们,我们尝试教导模型恢复所分析频率特征的缺失数据。
因此,在复数关注度模块的输出处,我们期待接收含有恢复缺失数据的幺正完整时间序列的频率特征的修改,及相互一致的频谱。依据修改后的频谱恢复时间序列,我们就能得到不同于零值的所分析时间序列的预测值 。
为了完成前馈通验操作,我们只需要从完整时间序列中提取预测值。
if(!DeConcat(GetPointer(cReconstructInput), GetPointer(cForecast), GetPointer(cOutputTimeSeriasReGrad), GetPointer(cOutputTimeSeriasRe), iHistory, iForecast, iFFT - iHistory - iForecast, iVariables)) return false;
并把时域和频域中做出的预测相加,同时考虑明显的系数。
//--- Output if(!WeightedSum()) return false; //--- return true; }
不要忘记监控每个阶段的操作结果。方法操作完成之后,我们将所有操作的逻辑结果返回给调用方。
1.3误差梯度分布
执行前馈通验后,我们需要将误差梯度分派给模型的所有训练参数。在我们的新类中,它们都在 T-模块和 F-模块。因此,我们需要实现一种机制,经由 T 和 F 模块传播误差梯度。然后,我们需要将来自两个流的误差梯度合并到一起,并将结果梯度传递到前一层。
与前馈通验一样,在构造 calcInputGradients 方法之前,我们需要做一些准备工作。在前馈通验期间,在 OpenCL 端,我们创建了返回统计分布值正向、逆向归一化的内核:ComplexNormalize 和 ComplexUnNormalize。在反向通验中,我们需要分别创建遵照指定操作的 ComplexNormalizeGradient 和 ComplexUnNormalizeGradient 误差梯度分布内核。
在误差梯度分布内核中,经由频率归一化模块,我们仅将得到的误差梯度除以相应频谱的标准差。
__kernel void ComplexNormalizeGradient(__global float2 *inputs_gr, __global float2 *outputs_gr, __global float *vars, int dimension) { if(dimension <= 0) return; //--- size_t n = get_global_id(0); const int shift = n * dimension; //--- float v = vars[n]; float2 variance = (float2)((v > 0 ? v : 1.0f), 0); for(int i = 0; i < dimension; i++) { float2 val = ComplexDiv(outputs_gr[shift + i], variance); if(isnan(val.x) || isinf(val.x) || isnan(val.y) || isinf(val.y)) val = (float2)0; inputs_gr[shift + i] = val; } }
我必须说,这是解决该问题的一种相当简化的方式。此处我们取平均值和标准差作为常数。事实上,它们是函数,根据梯度下降的规则,我们还需要调整它们的影响,并将误差梯度传播到模型的影响元素。但如实践所示,这些元素受初始数据的影响非常小。故此,为了降低模型训练成本,我们将省略这些操作。
梯度分布进行数据非归一化操作的内核是类似的,唯一的区别在于此处我们将得到的误差梯度乘以标准差。
__kernel void ComplexUnNormalizeGradient(__global float2 *inputs_gr, __global float2 *outputs_gr, __global float *vars, int dimension) { if(dimension <= 0) return; //--- size_t n = get_global_id(0); const int shift = n * dimension; //--- float v = vars[n]; float2 variance = (float2)((v > 0 ? v : 1.0f), 0); for(int i = 0; i < dimension; i++) { float2 val = ComplexMul(outputs_gr[shift + i], variance); if(isnan(val.x) || isinf(val.x) || isnan(val.y) || isinf(val.y)) val = (float2)0; inputs_gr[shift + i] = val; } }
接下来,我们需要实现一个内核,以便在时域和频域中的预测模块之间分派总误差梯度。我们在 WeightedSumGradient 内核中实现该功能。在参数中,该内核接收指向 4 个数据缓存区的指针,和 1 个参数,类似于相应的前馈内核。
__kernel void WeightedSumGradient(__global float *inputs_gr1, __global float *inputs_gr2, __global float *outputs_gr, __global float *weight, int dimension ) { if(dimension <= 0) return; //--- size_t n = get_global_id(0); const int shift = n * dimension;
在内核主体中,我们如往常一样,在一维任务空间中标识当前线程,并判定数据缓冲区中的偏移量。之后,我们将为频域和时域序列预测准备局部权重变量。
float w = weight[n]; float w1 = 1 - weight[n];
然后我们创建一个循环,将误差梯度传播到相应的数据缓冲区。
for(int i = 0; i < dimension; i++) { float grad = outputs_gr[shift + i]; inputs_gr1[shift + i] = grad * w; inputs_gr2[shift + i] = grad * w1; } }
上述误差梯度传播内核会被放置于主程序端相关方法内的执行队列之中。您可在附件中领会这些方法的代码。
我们应当注意的另一点是依据历史值重造时间序列的误差梯度的计算。我们将在 calcReconstructGradient 方法中实现该功能。
尽管这些操作是在 OpenCL关联环境端执行的,但为了执行指定的操作,我们不会创建新的内核。取而代之,我们将使用现成的内核基于目标值判定误差梯度。我们只需要创建一个方法,将使用 F-模块的数据缓冲区的内核推入执行队列之中。
我们使用的内核根据张量中的元素数量在一维任务空间中运行。在我们的例子中,分析向量的大小等于所分析历史深度与幺正时间序列数量的乘积。
bool CNeuronATFNetOCL::calcReconstructGradient(void) { uint global_work_offset[1] = {0}; uint global_work_size[1]; global_work_size[0] = iHistory * iVariables;
我们的目标数据包括我们在前馈传递期间从前一个神经层获得的原始数据值。在前馈通验期间,我们保存了一个指向所需数据缓冲区的指针。
if(!OpenCL.SetArgumentBuffer(def_k_CalcOutputGradient, def_k_cog_matrix_t, cInputs.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
我们判定依据经处理频谱重造数据的误差梯度。
if(!OpenCL.SetArgumentBuffer(def_k_CalcOutputGradient, def_k_cog_matrix_o, cReconstructInput.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
我们将作结果写入已恢复数据的梯度缓冲区之中。
if(!OpenCL.SetArgumentBuffer(def_k_CalcOutputGradient, def_k_cog_matrix_ig, cReconstructInputGrad.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
我们在前馈通验中未使用激活函数。
if(!OpenCL.SetArgument(def_k_CalcOutputGradient, def_k_cog_activation, (int)None)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
我们将内核推入执行队列当中,检查操作结果,并完结该方法,将所执行操作的逻辑结果返回给调用者。
if(!OpenCL.SetArgument(def_k_CalcOutputGradient, def_k_cog_error, 1)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } ResetLastError(); if(!OpenCL.Execute(def_k_CalcOutputGradient, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel CalcOutputGradient: %d", GetLastError()); return false; } //--- return true; }
准备工作完成后,我们直接进行误差梯度传播方法 calcInputGradients 的构造。
在该方法的参数中,与父类的相同方法类似,我们接收一个指向前一个神经层对象的指针,我们必须将误差梯度传播到该对象。
bool CNeuronATFNetOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL || !NeuronOCL.getGradient() || !cInputs) return false;
在方法主体中,我们立即检查所接收指针的相关性。之后,我们将从后续层获得的误差梯度分派到时域和频域中预测模块之间的 2 个流中。
//--- Output if(!WeightedSumGradient()) return false;
首先,我们经由时域预测 T-模块传播误差梯度。于此,按照与前馈通验逆反顺序,我们调用嵌套对象的相关方法。
//--- T-Block if(cRevIN.Activation() != None && !DeActivation(cRevIN.getOutput(), cRevIN.getGradient(), cRevIN.getGradient(), cRevIN.Activation())) return false; CNeuronBaseOCL *next = cRevIN.AsObject(); for(int i = caProjection.Total() - 1; i >= 0; i--) { CNeuronBaseOCL *proj = caProjection.At(i); if(!proj || !proj.calcHiddenGradients((CObject *)next)) return false; next = proj; } for(int i = caAttention.Total() - 1; i >= 0; i--) { CNeuronBaseOCL *att = caAttention.At(i); if(!att || !att.calcHiddenGradients((CObject *)next)) return false; next = att; } if(!cPatching.calcHiddenGradients((CObject*)next)) return false; if(!cTranspose.calcHiddenGradients(cPatching.AsObject())) return false; if(!cPositionEncoder.calcHiddenGradients(cTranspose.AsObject())) return false; if(!cNorm.calcHiddenGradients(cPositionEncoder.AsObject())) return false; if(!NeuronOCL.calcHiddenGradients(cNorm.AsObject())) return false;
频域预测模块中的梯度传播算法稍微复杂一些。此处,我们首先定义重造时间序列虚部的误差梯度。如早前所述,时间序列的虚部目标值为 0。因此,为了判定误差梯度,我们只需更改前馈通验结果的符号即可。
//--- F-Block if(!CNeuronBaseOCL::SumAndNormilize(GetPointer(cOutputTimeSeriasIm), GetPointer(cOutputTimeSeriasIm), GetPointer(cOutputTimeSeriasIm), iFFT*iVariables, false, 0, 0, 0, -0.5)) return false;
接下来,我们定义历史数据恢复误差梯度。
if(!calcReconstructGradient()) return false;
之后,我们将历史数据恢复误差梯度张量(在 calcReconstructGradient 方法中定义)、时间序列预测误差梯度(通过将后续层的误差梯度分成两个流获得)组合起来,并用零值补充它,直到整个序列的频谱大小。
if(!Concat(GetPointer(cReconstructInputGrad), GetPointer(cForecastGrad), GetPointer(cZero), GetPointer(cOutputTimeSeriasReGrad), iHistory, iForecast, iFFT - iHistory - iForecast, iVariables)) return false;
我们将零值附加到完整时间序列的误差梯度张量的末尾,因为我们没有超出计划横向范围的目标值数据。这意味着我们压根不能校正它们。
针对完整时间序列构造的结果误差梯度,所用的频率预测模块数据经由 FFT 转换至频域。
if(!FFT(GetPointer(cOutputTimeSeriasReGrad), GetPointer(cOutputTimeSeriasIm), GetPointer(cOutputFreqRe), GetPointer(cOutputFreqIm), false)) return false;
我们将获得的数据与误差梯度频谱的实部和虚部合并成一个张量。
if(!Concat(GetPointer(cOutputFreqRe), GetPointer(cOutputFreqIm), cUnNormFreqComplex.getGradient(), 1, 1, iFFT * iVariables)) return false;
校正数据逆归一化运算导数的误差梯度。
if(!ComplexUnNormalizeGradient()) return false;
经由复数关注度模块传播误差梯度。
if(!cNormFreqComplex.calcHiddenGradients(cFreqAtteention.AsObject())) return false;
然后通依据数据归一化函数的导数校正误差梯度。
if(!ComplexNormalizeGradient()) return false;
将频谱的实部和虚部分开。
if(!DeConcat(GetPointer(cInputFreqRe), GetPointer(cInputFreqIm), cInputFreqComplex.getGradient(), 1, 1, iFFT * iVariables)) return false;
使用 IFFT 将误差梯度返回到时域。
if(!FFT(GetPointer(cInputFreqRe), GetPointer(cInputFreqIm), GetPointer(cOutputTimeSeriasRe), GetPointer(cOutputTimeSeriasIm), false)) return false;
注意,我们获得了完整时间序列的误差梯度。但是我们只需要将历史数据的误差梯度传播到上一层。因此,我们首先针对分析涵盖的历史横向范围选择数据。
if(!DeConcat(GetPointer(cInputFreqRe), GetPointer(cOutputTimeSeriasIm), GetPointer(cOutputTimeSeriasRe), iHistory, iFFT-iHistory, iVariables)) return false;
然后,我们将获得的值添加到 T-模块的误差梯度分布结果之中。
if(!CNeuronBaseOCL::SumAndNormilize(NeuronOCL.getGradient(), GetPointer(cInputFreqRe), NeuronOCL.getGradient(), iHistory*iVariables, false, 0, 0, 0, 0.5)) return false; //--- return true; }
如常,在每次迭代中,我们都会控制执行操作的过程。所有操作成功完成后,我们将方法的逻辑结果返回给调用者。
1.4更新模型参数
模型的每个训练参数的误差梯度决定了它对整体结果的影响。在下一步中,我们调整模型参数,使之误差最小化。该功能在 updateInputWeights 方法中执行。在我们的类实现里,更新参数表示调用包含正在训练的参数的嵌套对象的同名方法。在 F-模块中,它仅是一个复数关注度类。
bool CNeuronATFNetOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { //--- F-Block if(!cFreqAtteention.UpdateInputWeights(cNormFreqComplex.AsObject())) return false;
T-模块有更多这样的对象。
//--- T-Block if(!cPatching.UpdateInputWeights(cPositionEncoder.AsObject())) return false; int total = caAttention.Total(); CNeuronBaseOCL *prev = cPatching.AsObject(); for(int i = 0; i < total; i++) { CNeuronBaseOCL *att = caAttention.At(i); if(!att.UpdateInputWeights(prev)) return false; prev = att; } total = caProjection.Total(); for(int i = 0; i < total; i++) { CNeuronBaseOCL *proj = caProjection.At(i); if(!proj.UpdateInputWeights(prev)) return false; prev = proj; } //--- return true; }
我们针对 ATFNet 方法作者提议方式的算法实现的研究到此结束。您可在附件中找到 CNeuronATFNetOCL 类的完整代码。
2. 模型架构
我们已经完成了按 ATFNet 方法实现的类方式。我们转到构建模型的架构。您也许已经猜到了,我们将在环境状态编码器中实现一个新的神经层。当然,很难引入 CNeuronATFNetOCL 类作为神经层。它为了构造一个全面的模型,实现了一个相当复杂的架构。
我们将为编码器投喂一组原始输入,就如我们对之前构造的模型所做的那样。
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; }
不过,在这种情况下,我们不会把获得的数据归一化。T-模块和 F-模块的架构中都有数据归一化。故我们跳过这一步。然而,我们的输入是根据描述环境各个状态的向量形成的。在进一步处理之前,我们转置输入,以启用幺正时间序列的分析。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = HistoryBars; descr.window = BarDescr; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们用新类来预测所分析时间序列中的后续数据。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronATFNetOCL; descr.count = BarDescr; descr.window = HistoryBars; descr.window_out = NForecast; descr.step = 8; descr.layers = 4; { int temp[] = {5, 1, 16}; ArrayCopy(descr.windows, temp); } descr.activation = None; descr.batch = 10000; if(!encoder.Add(descr)) { delete descr; return false; }
实际上,这一层包含了我们的整个模型。在其输出处,我们获得整个计划深度所需的预测值。我们只需要将它们转置到所需的维度。
//--- layer 3 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; }
为了保证预测值频谱的一致性,我们将采用 FreDF 方法的方式。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = BarDescr; descr.count = NForecast; descr.step = int(false); descr.probability = 0.8f; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
我们保持扮演者和评论者模型不变。
已训练模型的训练和测试程序也复制自之前的文章。您可在附件中自行研究代码。
3. 测试
我们为利用 MQL5 实现 ATFNet 方法作者提议的方法,已经做了相当多的工作。已完成的工作量甚至超出了一篇文章的范畴。最后,我们转到工作的最后阶段:训练和测试模型。
为了训练模型,我们将使用早前创建的 EA 来训练之前的模型。因此,以前收集的训练数据也可再利用。
模型依据整个 2023 年 H1 时间帧的 EURUSD 历史数据上进行训练。
在第一阶段,我们训练编码器模型,在 NForecast 常数判定的计划横向范围内,预测环境的后续状态。
如前,编码器模型只分析价格走势,故此在训练的第一阶段,我们不需要更新训练集。
在学习过程的第二阶段,我们寻找最优的扮演者动作政策。此处,我们运行扮演者和评论者模型的训练迭代,其交替更新训练数据集。更新训练数据集的过程令我们能够在扮演者当前政策的领域中优调环境奖励,这反过来又令我们能够优调所需的政策。
训练过程中,我们能够获得能够在训练和测试数据集上均产生盈利的扮演者政策。模型测试结果如下所示。
在测试期间,该模型进行了 31 笔交易,其中 19 笔以盈利了结。盈利交易的占比超过 61%。值得注意的是,该模型的多头和空头开仓数量几乎相等(15 对 16)。
结束语
最后两篇文章专门阐述了 ATFNet 方法,其被提议用于预测多元时间序列,并在论文《ATFNet:自适应时频融合网络进行长期时间序列预测》中讲述。ATFNet 模型结合了时域和频域模块来分析时间序列数据中的依赖关系。它使用 T-模块捕获时域中的局部依赖关系,使用 F-模块分析频域中的时间序列周期性。
ATFNet 应用主谐波序列能量加权、扩展傅里叶变换、和复数频谱关注,从而能适应输入时间序列中的周期性和频率偏移。
在本文的实践部分,我们利用 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/15024
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.


德米特里 你好!
您是如何训练和补充一年历史示例数据库的?我在您的智能交易系统(Expert Advisors)中的 bd 文件(您使用一年的历史记录)中补充新示例时遇到了问题。问题是,当该文件达到 2 GB 大小时,它显然开始被错误地保存,然后模型训练 Expert Advisor 无法读取它并给出错误。或者 bd 文件的大小开始急剧下降,每增加一个新示例就会减少几兆字节,然后训练顾问仍然会出错。如果历史记录长达一年,这种问题最多会出现在 150 个轨迹上;如果历史记录长达 7 个月,这种问题最多会出现在 250 个轨迹上。bd文件的大小 增长非常快。例如,18 个轨迹的重量接近 500 Mb。30 个轨迹则为 700 MB。
因此,为了进行训练,我们必须删除这个包含 7 个月内 230 条轨迹的文件,然后使用预先训练好的 "专家顾问 "重新创建文件。但在这种模式下,补充数据库时更新轨迹的机制不起作用。我认为这是由于 MT5 中一个线程的内存限制为 4 GB。他们在帮助中的某处提到了这一点。
有趣的是,在早期的文章中(历史记录为 7 个月,500 条轨迹的基础数据约为 1 GB)并不存在这样的问题。我的电脑资源不受限制,内存超过 32 GB,显卡内存也足够。
德米特里,你是如何在教学中考虑到这一点的?
我使用文章中的文件,未作任何修改。
因此,为了进行训练,我们必须删除这个包含 7 个月内 230 条轨迹的文件,然后使用预先训练好的 "专家顾问 "重新创建文件。但在这种模式下,补充数据库时更新轨迹的机制不起作用。我认为这是由于 MT5 中一个线程的内存限制为 4 GB。他们在帮助中的某处提到了这一点。
有趣的是,在早期的文章中(历史记录为 7 个月,500 条轨迹的基础数据约为 1 GB)并不存在这样的问题。我没有受到电脑资源的限制,因为内存超过 32 GB,显卡也有足够的内存。
德米特里,您在教学中是如何考虑到这一点的?
我使用文章中的文件,未作任何修改。
Victor,

我不知道该怎么回答您。我使用较大的文件。
嗨,我读了这篇文章,感觉很有趣,了解了一些,读完原文后会再读一遍。
我看到了这篇论文 https://www.mdpi.com/2076-3417/14/9/3797#
它声称他们在比特币 图像分类中获得了 94% 的存档率,这真的可能吗?