
交易中的神经网络:用于时间序列预测的轻量级模型
引言
预测未来价格走势对于制定有效的交易策略至关重要。实现准确预测通常需要使用强大且复杂的深度学习模型。
精确的长期时间序列预测的基础在于数据中固有的周期性和趋势。此外,长期以来我们观察到货币对的价格变动与特定的交易时段密切相关。例如,如果将日时间序列在一天中的特定时间进行离散化,每个子序列都表现出相似或连续的趋势。在这种情况下,原始序列的周期性和趋势将被分解和转换。周期性模式被转换为子序列间的动态,而趋势模式则被重新解释为子序列内在特征。这种分解为开发用于长期时间序列预测的轻量级模型开辟了新途径,这是论"文SparseTSF:使用1千个参数对长期时间序列预测进行建模”中探讨的方法。
在他们的工作中,作者首次研究了如何利用周期性和数据分解来构建专门的轻量级时间序列预测模型。这种方法使他们能够提出SparseTSF,这是一种用于长期时间序列预测的极其轻量级的模型。
作者提出了一种跨周期稀疏预测的技术方法。首先,将输入数据划分为恒定周期性序列。然后对每个降采样的子序列进行预测。这样,原始的时间序列预测问题就简化为预测周期间趋势的问题。
这种方法提供了两个优势:
- 有效分离数据的周期性和趋势,使模型能够稳定地识别和捕捉周期性特征,同时专注于预测趋势变化。
- 模型参数尺寸的极大压缩,显著降低了对计算资源的需求。
1. SparseSTF算法
长期时间序列预测(LTSF)的目标是使用先前观察到的多变量时间序列数据预测未来值。LTSF的主要目标是延长预测范围H,因为这为实际应用提供了更全面和先进的理念。然而,延长预测范围通常会增加训练模型的复杂性。为了解决这个问题,SparseTSF的作者专注于开发不仅极其轻量级,而且稳健高效的模型。
最近在LTSF方面的进展导致了在处理多变量时间序列数据时向独立通道预测的转变。这种策略通过关注数据集中的单个单变量时间序列,简化了预测过程,减少了通道间依赖的复杂性。因此,近年来当代模型的主要关注点已经转向通过建模单变量序列中的长期依赖性,包括周期性和趋势,来实现高效预测。
鉴于预测数据通常表现出一致的先验周期性,SparseTSF的作者提出了跨周期稀疏预测,以增强长期序列依赖性的提取,同时减少模型参数复杂性。所提出的解决方案利用单个线性层来模拟LTSF任务。
假设时间序列Xt的长度为L,已知周期性为w。所提出算法的第一步是将原始序列降采样为w个长度为n (n=L/w)的子序列。然后对这些子序列应用具有共享参数的预测模型。这个操作产生了w个预测子序列,每个长度为m (m=H/w),它们共同形成了长度为H的完整预测序列。
直观上,这种预测过程类似于具有稀疏间隔w的滑动预测,由具有固定周期w的参数共享的全连接层执行。这可以被解释为模型在周期上执行稀疏滑动预测。
从技术角度来看,降采样过程相当于将原始数据Xt的张量重塑为一个n*w 的矩阵,然后转置为一个w*n的矩阵。稀疏滑动轨迹预测等价于将大小为n*m的线性层应用于矩阵的最终维度。操作结果是一个w*m矩阵。
在上采样期间,我们执行逆操作:转置w*m矩阵,然后重新格式化为长度为H的完整预测序列。
然而,所提出的方法面临两个问题:
- 信息丢失,因为仅使用每个周期的一个数据点进行预测,而其他数据点被忽略。
- 对异常值的敏感性增加,因为降采样子序列中的极端值可以直接影响预测。
为了缓解这些问题,SparseTSF的作者在执行稀疏预测之前引入了一个滑动聚合步骤。每个聚合数据点结合了周期内周围点的信息,解决了第一个问题。此外,由于聚合值本质上代表了周围点的加权平均值,它减轻了异常值的影响,从而解决了第二个问题。
从技术上讲,这种滑动数据聚合可以使用带有零填充的卷积层来实现。
时间序列数据通常在训练和测试数据集之间表现出分布变化。在原始数据和预测序列之间进行简单的归一化策略可以帮助缓解这个问题。在SparseTSF算法中,采用了一种简单的归一化策略:在将输入数据输入模型之前,从序列的均值中减去,然后将均值加回到结果预测中。
SparseTSF方法作者提供的可视化图形如下所示。
2. 用MQL5来实现
在考虑了SparseTSF方法的理论方面之后,让我们继续使用MQL5实现所提出的方法。作为我们库的一部分,我们将创建一个新类,CNeuronSparseTSF。
2.1创建SparseTSF类
我们的新类将从基类CNeuronBaseOCL继承核心功能。CNeuronSparseTSF类的结构如下所示。
class CNeuronSparseTSF : public CNeuronBaseOCL { protected: CNeuronConvOCL cConvolution; CNeuronTransposeOCL acTranspose[4]; CNeuronConvOCL cForecast; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSparseTSF(void) {}; ~CNeuronSparseTSF(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint sequence, uint variables, uint period, uint forecast, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronSparseTSF; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
在新类的构造中,我们将添加2个卷积层。其中一个执行数据聚合的角色,第二个预测后续序列。此外,我们将使用一组转置来重新格式化数据。所有添加的内部对象都是静态声明的,这使得类的构造函数和析构函数保持“空”。类对象的初始化是在Init方法中执行的。
在初始化方法的参数中,我们传递创建对象的主要参数:
- sequence — 初始数据序列的长度
- variables — 分析的多变量时间序列中的单变量序列的数量
- period — 输入数据的周期性
- forecast — 预测的深度
bool CNeuronSparseTSF::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint sequence, uint variables, uint period, uint forecast, ENUM_OPTIMIZATION optimization_type, uint batch ) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, forecast * variables, optimization_type, batch)) return false;
在方法的主体中,像往常一样,我们调用具有相同名称的父类方法。这个方法已经实现了初始化继承对象和变量的过程。
请注意,在调用父类的初始化方法时,我们指定了层大小等于预测深度和多模态数据中单元序列数量的乘积。
在成功初始化继承的对象和变量后,我们继续进行添加的内部对象的初始化阶段。我们将按照前馈传递的顺序初始化它们。现在,特别注意我们正在处理的数据张量的维度。
在层的输入中,我们期望接收一个维度为Lv的输入数据张量,其中L是输入序列的长度,v是多模态源数据中单元序列的数量。如本文第一部分所述,SparseTSF方法在预测独立单元序列的范式中工作。为了实现这样的过程,我们将输入数据矩阵转置为vL矩阵。
if(!acTranspose[0].Init(0, 0, OpenCL, sequence, variables, optimization, iBatch)) return false;
接下来,我们计划使用卷积层聚合输入数据。在此操作中,我们将在原始数据的2个周期内执行卷积,步长为1个周期。为了保持维度,卷积滤波器的数量等于周期大小。
if(!cConvolution.Init(0, 1, OpenCL, 2 * period, period, period, sequence / period, variables, optimization, iBatch)) return false; cConvolution.SetActivationFunction(None);
请注意,我们在独立的单元序列内执行数据聚合。
SparseTSF算法的下一步是原始数据的离散化。在此阶段,该方法的作者建议改变维度并转置原始数据的张量。在我们的情况下,我们正在处理一维数据缓冲区。改变原始数据的维度纯粹是声明性的;它不涉及在内存中重新排列数据。然而,对于转置则不能这样说。因此,我们继续初始化下一层以进行数据转置。
if(!acTranspose[1].Init(0, 2, OpenCL, variables * sequence / period, period, optimization, iBatch)) return false;
使用第二个数据转置层可能看起来有点奇怪。乍一看,它执行的操作是原始数据先前转置的逆操作。但这并不完全正确。我已经强调了上述数据的维度。我们的数据缓冲区的总大小保持不变:L*v。只有在通过声明改变数据矩阵的维度后,我们才能说其大小等于(v * L/w) * w,其中w是初始数据的周期性。我们将其转置为w * (L/w * v)。执行此操作后,我们的数据缓冲区将显示原始数据周期性的各个独立阶段的序列,同时考虑原始数据的单元序列的独立性。
从图形上看,两个阶段的数据转置结果可以表示如下:
然后我们使用卷积层独立预测给定规划范围内单元序列的输入数据周期内的各个步骤。
if(!cForecast.Init(0, 3, OpenCL, sequence / period, sequence / period, forecast / period, variables, period, optimization, iBatch)) return false; cForecast.SetActivationFunction(TANH);
请注意,分析的源数据窗口的大小及其步长是"sequence / period",卷积滤波器的数量是"forecast / period"。这使我们能够在一次传递中获得整个规划范围的预测值。在这种情况下,我们为分析数据的每个周期步骤使用单独的滤波器。
由于我们打算使用归一化数据,我们使用双曲正切作为预测值的激活函数。这允许我们将预测结果限制在[-1, 1]的范围内。
接下来,我们需要将预测值转换为所需的序列。我们使用两个连续的数据转置层执行此操作,它们执行值的逆置换操作。
if(!acTranspose[2].Init(0, 4, OpenCL, period, variables * forecast / period, optimization, iBatch)) return false; if(!acTranspose[3].Init(0, 5, OpenCL, variables, forecast, optimization, iBatch)) return false;
为了避免不必要的数据复制,我们组织替换当前层的结果缓冲区和误差梯度。
if(!SetOutput(acTranspose[3].getOutput()) || !SetGradient(acTranspose[3].getGradient()) ) return false; //--- return true; }
在每次迭代中,我们检查操作的结果。方法操作的最终逻辑结果是返回给调用者的。
请注意,在对象初始化过程中,我们没有保存我们正在创建的层的架构参数。在这种情况下,我们只需要将适当的参数传递给嵌套对象。它们的架构唯一定义了类的运行,因此无需额外存储接收到的参数。
初始化类对象后,我们继续创建前馈方法CNeuronSparseTSF::feedForward,在其中我们构建SparseTSF方法的算法以及内部对象之间的数据传输。
在前馈方法的参数中,我们接收一个指向前一层对象的指针,其中包含原始数据。
bool CNeuronSparseTSF::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!acTranspose[0].FeedForward(NeuronOCL)) return false;
由于我们将使用先前创建的嵌套对象的方法重新创建算法,我们不会添加接收到的指针的验证。我们只是将其传递给第一个数据转置层的前馈方法,在其中已经实现了类似的检查以及与数据缓冲区中数据排列相关的主要功能操作。我们只检查调用方法的操作的逻辑结果。
接下来,我们通过调用卷积层的前馈方法来执行数据聚合。
if(!cConvolution.FeedForward(acTranspose[0].AsObject())) return false;
根据SparseTSF算法,聚合的数据与原始数据相加。然而,为了保持数据的一致性,我们将原始数据的转置版本与聚合结果相加。
if(!SumAndNormilize(cConvolution.getOutput(), acTranspose[0].getOutput(), cConvolution.getOutput(), 1, false)) return false;
下一步,我们调用下一个数据转置层的前馈方法,完成原始序列的离散化过程。
if(!acTranspose[1].FeedForward(cConvolution.AsObject())) return false;
之后,我们使用第二个嵌套卷积层预测分析时间序列的最可能延续。
if(!cForecast.FeedForward(acTranspose[1].AsObject())) return false;
让我提醒您,子序列的预测是基于对初始数据给定周期内各个步骤的分析进行的。在这种情况下,我们对多模态输入时间序列的每个单元序列进行独立预测。对于输入数据周期的每个封闭循环步骤,我们使用单独的训练参数。
使用两个连续的数据转置层将预测值重新排列成所需的预期输出序列顺序。
if(!acTranspose[2].FeedForward(cForecast.AsObject())) return false; if(!acTranspose[3].FeedForward(acTranspose[2].AsObject())) return false; //--- return true; }
当然,对于在数据缓冲区中重新排序预测值的步骤,我们可以创建一个新的内核,并用单个内核调用来替换两个转置层。这将通过消除不必要的数据传输操作来提供一些性能改进。然而,考虑到模型的大小,预期的性能提升是微不足道的。在这个实验中,我们选择简化程序代码并减少程序员的工作量。
重要的是要注意,前向传播方法的操作以执行嵌套对象的前向传播方法结束。同时,我们不将值传输到当前层的结果缓冲区,这是从父类继承的。然而,我们模型的后续层无法访问嵌套对象,并在我们的层的结果缓冲区上操作。为了弥补这种明显的数据流缺口,我们在初始化我们的类时替换了结果和误差梯度缓冲区。结果,我们层的结果缓冲区收到了指向最后一个转置层的结果缓冲区的指针。因此,通过执行最终的转置操作,我们有效地将数据写入结果缓冲区,消除了对象之间不必要的数据传输操作。
像往常一样,在每个阶段,我们验证操作的结果并将最终的逻辑结果返回给调用程序。
这样,我们就完成了SparseTSF方法的前向传播的实现,并继续构建反向传播算法。在这里,我们需要根据它们对结果的影响将误差梯度分配给所有参与者,并调整模型参数以最小化分析的多模态时间序列的预测误差。
第一步是开发分配误差梯度的方法:CNeuronSparseTSF::calcInputGradients。与前向传播一样,方法参数包括指向前一层对象的指针,我们将根据原始数据对模型输出的影响记录误差梯度。
bool CNeuronSparseTSF::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!acTranspose[2].calcHiddenGradients(acTranspose[3].AsObject())) return false;
我们将按照前馈操作但以相反的顺序分配误差梯度。如您所知,由于数据缓冲区的指针替换,从下一个模型层接收到的误差梯度最终进入最后一个内部转置层的缓冲区。这使我们能够直接处理内部对象,而无需额外的数据传输操作。
首先,我们通过两个转置层传递误差梯度,以实现梯度所需的离散化。
if(!cForecast.calcHiddenGradients(acTranspose[2].AsObject())) return false;
如有必要,我们将通过数据预测层的激活函数的导数来调整获得的梯度。
if(cForecast.Activation() != None && !DeActivation(cForecast.getOutput(), cForecast.getGradient(), cForecast.getGradient(), cForecast.Activation())) return false;
之后,我们将误差梯度传播到聚合数据的水平。
if(!acTranspose[1].calcHiddenGradients(cForecast.AsObject())) return false; if(!cConvolution.calcHiddenGradients(acTranspose[1].AsObject())) return false;
然后我们通过聚合层传播误差梯度。
if(!acTranspose[0].calcHiddenGradients(cConvolution.AsObject())) return false;
在聚合数据时,我们通过将聚合数据和原始序列相加来使用残差关系。因此,误差梯度也通过2个数据流传递,我们对2个误差梯度缓冲区的值求和。
if(!SumAndNormilize(cConvolution.getGradient(), acTranspose[0].getGradient(), acTranspose[0].getGradient(), 1, false)) return false;
之后我们将获得的误差梯度传播到前一层,并在必要时根据激活函数的导数进行调整。
if(!NeuronOCL || !NeuronOCL.calcHiddenGradients(acTranspose[0].AsObject())) return false; if(NeuronOCL.Activation() != None && !DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), NeuronOCL.getGradient(), NeuronOCL.Activation())) //--- return true; }
在方法的最后,我们将执行的操作的逻辑结果返回给调用程序。
在根据它们对最终结果的影响将误差梯度分配给我们模型的所有对象后,我们需要调整模型参数以最小化数据预测误差。这一功能在CNeuronSparseTSF::updateInputWeights方法中执行。这里一切都很简单。我们的新类只包含2个内部卷积层,包含可训练参数。如您所知,数据转置不使用可训练参数。因此,作为调整模型参数过程的一部分,我们只需要调用嵌套卷积层的同名方法,并检查调用方法的操作的逻辑值。整个参数调整过程已经内置在内部对象中。
bool CNeuronSparseTSF::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cConvolution.UpdateInputWeights(acTranspose[0].AsObject())) return false; if(!cForecast.UpdateInputWeights(acTranspose[1].AsObject())) return false; //--- return true; }
这就完成了我们新CNeuronSparseTSF类的主要功能方法的描述。此类的所有辅助方法都遵循您从本系列的前几篇文章中熟悉的逻辑。因此,我们不会在本文中详细讨论。您可以在附件中找到新类的所有方法的完整代码。
2.2可训练模型的架构
我们已经在新类CNeuronSparseTSF中实现了SparseTSF算法的主要方法,在MQL5中。现在我们需要将新类的对象实现到我们的模型中。我认为很明显,如果环境状态,我们将在Encoder模型中使用时间序列预测算法。该模型的架构在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_MINI; 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 = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
该层之后是新的SparseTSF方法层。
if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSparseTSF; descr.count = HistoryBars; descr.window = BarDescr;
让我提醒您,在此系列中,我们使用H1时间段的历史数据来训练和测试我们的模型。 在这些条件下,我们将设置初始数据周期的大小等于24,这对应于1个日历日。
descr.step = 24; descr.window_out = NForecast; descr.activation = None; descr.optimization = ADAM_MINI; if(!encoder.Add(descr)) { delete descr; return false; }
这里需要注意的是,所考虑模型的使用不仅限于H1时间段。然而,在相同条件下测试和训练不同的模型可以让我们评估模型的性能,同时最大限度地减少外部因素的影响。
尽管看似简单,SparseTSF方法相当复杂且不需要人为的干预或调整。为了获得即将到来的价格运动的预期预测,我们只需要添加在批量归一化层中提取的原始数据的分布指标。
//--- layer 3 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 4 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; }
如您所见,环境状态Encoder模型的架构相当简洁。这与SparseTSF方法的作者所声明的轻量化是一致的。
我们没有对先前文章中的Actor和Critic做进行了复制,未做任何更改。对于训练模型和与环境交互的程序也同样如此。因此,我们将不会在本文的框架内详细讨论它们。本文中使用的所有程序的完整代码都包含在附件中。
3. 测试
在本文的前几节中,我们考虑了SparseTSF方法的理论方面,并使用MQL5实现了方法作者提出的途径。现在是时候评估所提出途径在预测即将到来的价格运动方面的效果,使用真实的历史数据了。我们还需要检查使用获得的预测来构建有效的Actor行动策略的可能性。
在构建新模型的过程中,我们没有对输入数据的结构和预期的预测结果进行任何更改。因此,我们可以使用之前工作中的环境交互和模型训练程序,而无需任何修改。此外,我们可以使用先前收集的训练数据集对模型进行初始训练。所以,我们使用先前训练模型的经验回放缓冲区来训练环境状态Encoder。
如您所记得的,环境状态Encoder仅使用价格运动和分析指标的值,这些值不依赖于Actor的行为。因此,Encoder将训练数据集中一个历史区间的所有传递视为相同。这意味着我们可以在不需要更新训练数据集的情况下训练Encoder模型。并且所提出的模型的轻量级特性,使得显著减少训练Encoder所需的资源和时间成为可能。
我们不能说模型训练过程产生了对后续状态的非常精确的预测。但是,总的来说,预测的质量与需要更多资源和时间来训练的更复杂的模型相当。所以,我们可以说我们部分实现了预期的结果。
第二阶段是基于获得的预测值训练Actor策略。在这个阶段,我们执行模型的迭代训练,并定期更新训练数据集,这使我们能够拥有一个具有与当前Actor策略相近行为的、真实奖励的、最新行为分布的训练数据集。我必须承认,在这个阶段,我感到惊喜——乍一看,对未来价格运动的预测似乎并不令人印象深刻,但事实证明,这些预测对于构建能够产生利润的Actor策略非常有信息量,无论是在训练还是测试数据集上。
我们使用2023年全年的EURUSD的H1时间段的历史数据来训练模型。所有分析指标的参数都设置为默认值。然后,训练好的模型在保持所有其他参数相同的情况下,在2024年1月的历史数据上进行了测试。这样,我们紧密地将模型测试与现实世界条件对齐。
训练模型的测试结果如下。
在测试期间,模型执行了81笔交易。空头和多头头寸的分布几乎相等:分别为42和39。超过60%的交易以盈利结束,导致盈利因子为1.33。
SparseTSF方法的一个显著特点是以原始数据周期内的单独步骤为单位预测数据。作为提醒,在训练的环境状态Encoder模型中,我们分析了具有24小时周期的每小时数据。当以小时为单位查看时,这一方面使得模型的盈利性特别有趣。
在所呈现的图表中,我们观察到在欧洲时段的前半段,从9:00到12:00几乎没有任何损失。平均交易持续时间为1小时6分钟,表明进入交易和实现盈亏之间的延迟最小。最高盈利性发生在美国时段开始时(15:00 - 16:00)。
结论
在本文中,我们介绍了SparseTSF方法,该方法由于其轻量级架构和高效的资源使用,在时间序列预测方面显示出优势。参数数量的最小化使得所提出的模型特别适用于计算资源有限且决策时间短的应用。
SparseTSF允许分析具有给定周期的时间序列中的各个步骤,为每个单元序列进行独立预测。这为模型提供了高度的灵活性和适应性。
在文章的实践部分中,我们使用MQL5实现了所提出的途径,训练了模型,并在真实的历史数据上进行了测试。结果,我们得到了一个能够在训练和测试数据集上产生利润的模型,表明所提出途径的有效性。
然而,我想再次提醒您,本文中提出的程序仅旨在展示所提出途径及其使用的一种实现变体。所提出的程序尚未准备好在真正的金融市场中使用。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/15392


