
交易中的神经网络:TEMPO 方法的实施结果
概述
在上一篇文章中,我们领略了 TEMPO 方法的理论层面,其提出了一种运用预训练语言模型解决时间序列预测问题的原始方式。此处是该所提议算法的主要创新的简要回顾。
TEMPO 方法建立在预先训练的语言模型的基础上。现实中,该方法作者在他们的实验中用到了预先训练的 GPT-2。该方法的主要思想在于利用模型在初步训练期间获得的知识来预测时间序列。于此,当然,值得在语音和时间序列之间勾画出不明显的并肩之处。本质上,我们的语音是一条声音时间序列,以字母记录。不同的语调由标点符号传达。
长语言模型(LLM),例如 GPT-2,在一个大型数据集(通常以多种语言)上进行预训练,并学会单词时态序列中的大量不同依赖关系,而这正是我们希望在时间序列预测时所用。但字母和单词的序列,与正在分析的时间序列数据有很大不同。我们一直说,对于任何模型的正确操作,维护训练和测试数据集中的数据分布非常重要。这也涉及模型运行期间所分析数据。任何语言模型都无法配合我们习惯的纯文本形式工作。首先,它经由嵌入(编码)阶段,在此期间,文本被转换为特定的数字代码(隐藏状态)。然后,该模型对这些编码数据进行运算,在输出阶段,它会为后续字母和标点符号生成概率。然后,使用最可能的符号来构造人类可读的文本。
TEMPO 方法取该属性的优点。在时间序列预测模型的训练过程中,语言模型的参数被“冻结”,而原始数据参数转换到嵌入,与模型兼容,并得到优化。TEMPO 方法作者提出了一种综合方式,最大限度地令模型获得实用信息。首先,将所分析时间序列分解为其基本分量 — 例如趋势、季节性、及其它。然后,每个分量都被分段,并转换为语言模型可以解释的嵌入。为了进一步朝理想方向引导模型(例如,趋势或季节性分析),作者引入了一个“软性提示”系统。
总体上,该方式强化了模型的可解释性,能够更好地理解不同分量如何影响未来值的预测。
该方法的原始可视化如下所示。
1. 模型架构
所提议模型架构相当复杂,伙同多条分支,以及在输出处聚合的并行数据流。在我们现有的线性模型框架内实现这样的算法招致重大挑战。为了解决这个问题,我们开发了一种集成方式,将整个算法封装在单一模块当中,作为单层实现有效地功能。虽然这种方式在一定程度上限制了用户搭配不同模型复杂性进行试验的能力(因为模块的结构灵活性受到 CNeuronTEMPOOCL 类中 Init 方法参数的限制),但它也明显简化了构建新模型的过程。用户不需要潜浸架构错综复杂的细节。取而代之,它们只需要指定几个关键参数即可构造健壮而复杂的模型架构。在我们的视野中,这种权衡对于大多数用户来说更实际。
此外,需要考虑的一个关键层面是 TEMPO 方法作者用到预先训练的 GPT-2 语言模型进行实验。当以 Python 实现该目的时,可通过 Hugging Face 等函数库访问此类模型。不过,在我们的实现中,我们未用到预先训练的语言模型。取而代之,我们将其替换为交叉注意力模块,其将与主模型一起训练。
TEMPO 方法被定位为时间序列预测模型。因此,如同类似情况,我们将提议技术集成到我们的环境状态编码器模型之中。该模型架构在 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; }
如是提醒,该模型以原始形式接收一个原生输入数据张量,完全如同从终端获取。在我们之前的所有模型中,下一步涉及应用批量归一化层来执行初步数据处理,并将数值带至可比较的尺度。
不过,在这种情况下,我们故意排除了批量归一化层。令人惊讶的是,这个决定源于 TEMPO 方法架构本身。如上面的可视化效果所示,原始数据立即被定向到分解块,其中分析的时间序列被分解为三个基本组成部分:趋势、季节性和残差。对于每个单变量时间序列这种分解都独立发生 — 即,依据多模态时间序列的每个所分析参数。每个单变量序列中数值的可比性,是数据固有的性质确保。
首先,从原生数据中提取趋势分量。在我们的实现中,我们使用时间序列的分段线性表示来达成这一点。如您所知,该方法的算法启用可比较分段提取,与归一化过程期间典型发生的原生数据分布的伸缩和偏移无关。
接下来,我们从原始数据中减去趋势分量,并判定季节性分量。这是用离散傅里叶变换完成的,其将信号分解为频谱,令我们能够基于振幅来识别最显耀的周期性依赖关系。就像趋势提取,频率分解也对数据缩放和偏移不敏感。
最后,通过从原始数据中减去先前提取的两个分量来获得残差分量。
此刻,这就变得显而易见,从模型设计的角度来看,初步数据归一化没有提供额外的益处。甚至,在该阶段应用归一化会引入额外的计算开销,这本身就是不可取的。
现在,研究下一阶段。TEMPO 方法作者引入了所提取分量的归一化,这对于多模态数据的后续操作显然是必不可少的。这就浮现出一个问题:我们能否修改归一化方式?具体来说,我们是否可在分解之前,针对原生输入数据归一化,然后省却各个分量的归一化?毕竟,原生数据体量比所提取分量的合并尺寸小三倍。在我看来,答案很可能是“不”。
为了概括这一点,我们取一个抽象的时间序列图,并高亮其关键趋势。显而易见,趋势分量封装了信息的主体。
季节性分量由趋势线周围的波浪状波动组成,其幅度明显低于趋势本身。
代表其它变化的残差分量具有更低的振幅,主要反应噪声。然而,这种噪音不容忽视,因它捕捉到外部影响,诸如新闻事件、和其它表现出非系统性的未审计因素。
在分解之前对原生数据进行归一化,将解决独立的单变量序列之间的可比性问题。不过,它不会解决提取的分量本身之间的可比性问题。因此,为了模型稳定性,最好在分解后在分量级别应用归一化。
基于这个推理,我们排除了原生输入数据的批量归一化层。取而代之,我们在输入数据层之后,立即引入新的 TEMPO 方法模块。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTEMPOOCL; descr.count = HistoryBars; descr.window = BarDescr; descr.step = NForecast;
我们指定所分析多模态序列的大小,单一时间序列的数量也在其中,而计划的横向范围使用先前指定的常量。
在准备本文时作为实验的一部分,我指定了 4 个注意力头。
descr.window_out = 4;
我还在注意力模块中设置了 4 个嵌套层。
descr.layers = 4;
于处我要提醒您,这些参数用在 2 个嵌套的注意力模块之中:
- 频域注意力模块,用于识别独立单个序列的频率特性之间的依赖关系,以及
- 交叉注意力模块,用于检测时间序列中的依赖关系。
接下来,我们指定归一化批次大小,及模型优化方法。
descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
此刻,模型可以被认为是完整的,因为在 CNeuronTEMPOOCL 模块的输出处,我们得到了所分析时间序列的期望预测值。但我们加点最后的润色 — 预测时间序列的频率匹配层 CNeuronFreDFOCL。
//--- layer 2 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; }
如是结果,我们得到了一个简短的模型架构,以 3 个神经层的形式出现。然而,它背后隐藏着一套复杂的集成算法。毕竟,我们知道“冰山一角”之下,CNeuronTEMPOOCL,隐藏了 24 个嵌套层,其中 12 个包含可训练的参数。甚至,这些嵌套层中有 2 个是注意力单元,为此我们特意创建了一个四层自注意力架构,每层有 4 个注意力头。这令我们的模型真正复杂而深入。
我们将用获得的即将到来的价格走势预测值来训练参与者的行为政策。于此,我们在很大程度上保留了前几篇文章中的架构,但由于环境状态编码器的复杂性,以及训练它的预期成本增加,我决定减少参与者和评论者模型的交叉注意力模块中的嵌套层数。如是提醒,这些模型的架构描述在 CreateDescriptions 方法中讲述,在参数中我们传递 2 个动态数组指针。故此,我们将把模型架构的描述写入这些数组当中。
bool CreateDescriptions(CArrayObj *&actor, CArrayObj *&critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
在方法主体中,我们检查收到的指针相关性,并在必要时创建新的对象实例。
首先,我们描述参与者架构,我们向该架构输入描述账户状态的张量。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
请注意,此处我们谈论的是账户的状态,而非环境。术语 “环境状态”是指价格走势动态,和所分析指标的参数。在“账户状态”中,我们包括账户余额的当前值、持仓量、和方向,以及它们累积的盈利或亏损。
我们用一个基本全连接层将在模型输入处接收到的信息转换为隐藏状态。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,我们用一个交叉注意力模块,将当前账户状态与从环境状态编码器获取的即将到来的价格走势预测值进行比较。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLCrossAttentionMLKV; { int temp[] = {1, NForecast}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, BarDescr}; ArrayCopy(descr.windows, temp); } { int temp[] = {4, 2}; ArrayCopy(descr.heads, temp); } descr.layers = 4; descr.step = 1; descr.window_out = 32; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
此处,重点是要专注一个关键方面 — 从状态评分编码器获取的数值子空间。虽然我们之前采用了相同的方式,但当时并没有引起任何担忧。那么,有了什么变化?
正如他们所说,“魔鬼在细节中”。以前,我们在环境状态编码器的输入处用到批量归一化层,将原生数据转换为可比较的格式。在模型的输出处,我们应用 CNeuronRevINDenormOCL 层来逆转换,将数据恢复到其原始子空间。至于参与者和评论者,我们在应用偏移和伸缩操作返至原始数据子空间之前,以可比形式配合预测值的隐藏表示工作。这确保了后续分析依赖于一致且可解释的数据,令模型更容易处理。
然而,在 CNeuronTEMPOOCL 的情况下,如前所述,我们故意省却了输入数据的初步归一化。如是结果,该模型现在输出非归一化的预测价格走势,这或许会令参与者和评论者的任务复杂化,从而降低其有效性。一种潜在的解决方案是在后续使用它们之前针对所预测时间序列值进行归一化。达成该目的的最简单方式应当是引入一个具有单个归一化层的小型预处理模型。不过,我们没有实现该步骤。
此外,我要提醒您,我们在 CNeuronTEMPOOCL 模块的输出处把简单地将三个预测组成部分(趋势、季节性等)相加,替换成未用激活函数的卷积层。这就是用所获数据的加权总和,替换了简单的求和。
if(!cSum.Init(0, 24, OpenCL, 3, 3, 1, iVariables, iForecast, optimization, iBatch)) return false; cSum.SetActivationFunction(None);
将模型参数的最大值限制为小于 1,允许我们在模型输出中排除显而易见的较大值。
#define MAX_WEIGHT 1.0e-3f
当然,这种方式本身就限制了环境状态编码器的精确度。毕竟,我们如何将实际指标值,譬如 RSI(范围从 0 到 100),与绝对值低于 1 的预测结果保持一致?在这种情况下,当使用 MSE 作为损失函数时,预测值很可能会达到最大可能级别。为了解决这个问题,我们在环境状态编码器的输出端引入了 CNeuronFreDFOCL 频率对齐模块。该模块对数据缩放不太敏感,并能令模型学习即将到来的价格走势的结构,在这种背景下,这比绝对值更重要。
我承认,这个提议的解决方案并非立竿见影,且或许有点难以把控。然而,其有效性的终极评估,将基于我们模型的实际结果。
现在,回到我们的参与者架构:在交叉注意力模块之后,我们采用一个由三个完全连接层组成的感知器来制定决策。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
在其输出处,我们将随机性添加到参与者政策之中。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
然后,我们校准所适配解决方案的频率特征。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NActions; descr.count = 1; descr.step = int(false); descr.probability = 0.7f; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
评论者架构几乎完全重复了上面讲述的参与者架构。只有细微的差异。特别是,我们投喂给模型的输入,并非账户状态,而是参与者的动作张量。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
在模型的输出处,我们未用到随机性,而是对所提议行动给出清晰的评估。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = NRewards; descr.count = 1; descr.step = int(false); descr.probability = 0.7f; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
您可在附件中找到用到的所有模型架构解决方案的完整描述。
2. 模型训练
从上述训练模型架构描述中可以看出,TEMPO 方法的实现,并未改变原始数据的结构、或训练模型的结果。因此,我们可以自信地使用以前收集的训练数据集进行模型初始训练。进而,我们也能继续使用之前开发的环境交互和模型训练程序来收集数据、训练模型、及更新训练数据集。
为了与环境交互和收集训练数据,我们用到两个程序:
- “...\Experts\TEMPO\ResearchRealORL.mq5” — 基于一组历史真实交易收集数据。该方法在参考文章中有详细说明。
- “...\Experts\TEMPO\Research.mq5” — 智能交易系统,主要设计用于分析预训练政策的性能,并在当前政策环境内更新训练数据集。这允许基于真实的奖励反馈优调参与者的政策。不过,该 EA 也能基于搭配随机参数初始化的参与者行为政策收集初始训练数据集。
无论我们是否已经收集了环境交互数据,我们都可在 MetaTrader 5 策略测试器中启动上述任何智能交易系统,以此创建新的训练数据集、或更新现有数据集。
收集的训练数据首先用于训练环境状态编码器,以便预测未来价格走势。为此,我们在 MetaTrader 5 中以实时模式运行 “...\Experts\TEMPO\StudyEncoder.mq5” EA。
重点要注意的是,在训练期间,环境状态编码器仅根据价格动态、及所分析指标进行操作,这些都不受个体操作的影响。因此,据同一历史分段上通验的所有训练数据集,对于模型都雷同。因此,在编码器训练期间更新训练数据集不会提供额外信息。因此,我们必须有耐心,训练模型直到我们获得满意的结果。
再次,我要强调,如早前所论,由于我们的架构方式的特殊性,现阶段我们并未期待“低”误差值。不过,我们仍然致力于尽可能误差最小化,当预测误差稳定在狭窄范围内时停止训练过程。
第二阶段涉及参与者和评论者模型的迭代训练。在此阶段,我们使用 “...\Experts\TEMPO\Study.mq5” EA,它也在实时模式下运行。这一次,我们“冻结”环境状态编码器参数,且并行训练两个模型(参与者和评论者)。
评论者从训练数据集中学习环境奖励函数,从训练数据集中映射预测环境状态和个体的动作,从而评估预期奖励。该阶段遵循监督学习原则,因为已执行动作的实际奖励被存储在训练数据集当中。
然后,参与者基于评论者的反馈优化其策略,旨在最大限度地提高整体盈利能力。
这个过程是迭代的,因为参与者的动作子空间在训练期间会发生变化。为了维护相关性,我们需要更新训练数据集,以便在新调整的动作子空间中捕获真实的奖励。这令评论者能够改进奖励函数,并对参与者动为提供更准确的评估,从而引导政策调整朝着理想的方向发展。
为了更新训练数据集,我们重新运行 “...\Experts\TEMPO\Research.mq5” EA 的缓慢优化过程。
在该阶段,人们或许会质疑将状态得分编码器与其它模型分开训练的必要性。一方面,预训练的状态得分编码器提供最可能的市场走势,有效地充当数字滤波器,降低原生数据中的噪声。此外,我们使用的计划横向范围明显短于所分析历史的深度。这意味着编码器还可以压缩数据,以供后续分析,潜在地提高参与者和评论者模型的效率。
另一方面,我们真的需要预测未来价格走势吗?我们之前已强调过,更重要的是对当前状态的清晰解释,允许个体选择精度最高的优化动作。为了探索这个问题,我们开发了另一个训练 EA: “...\Experts\TEMPO\Study2.mq5”。该程序基于 “...\Experts\TEMPO\Study.mq5”。因此,我们将只关注它的直接模型训练方法:Train。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9); //--- vector<float> result, target, state; bool Stop = false;
在方法主体中,我们首先基于通验的总盈利能力,生成从经验回放缓冲区中选择轨迹的概率向量。之后,我们初始化必要的局部变量。
此刻,我们完成了准备工作,并规划模型训练循环。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast)); if(i <= 0) { iter --; continue; } state.Assign(Buffer[tr].States[i].state); if(MathAbs(state).Sum() == 0) { iter --; continue; }
在循环主体中,我们从经验回放缓冲区中抽样一条轨迹,并从其上随机选择一个环境状态。
我们将所选环境状态的描述从训练样本传输到数据缓冲区,并执行环境状态编码器的前馈通验。
bState.AssignArray(state); //--- State Encoder if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们从经验回放缓冲区中获取个体在与环境交互时依选定状态执行的动作,并由评论者对其评估。
//--- Critic bActions.AssignArray(Buffer[tr].States[i].action); if(bActions.GetIndex() >= 0) bActions.BufferWrite(); Critic.TrainMode(true); if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
注意,经验回放缓冲区包含这些动作的实际评估,我们可以调整评论者学习的奖励函数,将误差最小化。为此,我们从经验回放缓冲区中提取收到的实际奖励,并执行评论者的反向传播通验。
result.Assign(Buffer[tr].States[i + 1].rewards); target.Assign(Buffer[tr].States[i + 2].rewards); result = result - target * DiscFactor; Result.AssignArray(result); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在该步骤中,我们添加了环境状态编码器的反向传播通验,以便将模型的注意力吸引到参考点,从而更准确的评估动作。
下一步是调整参与者的政策。首先,从经验回放缓冲区中,我们准备与之前选择的环境状态对应的帐户状态的描述。
//--- Policy float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; bAccount.Clear(); bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance); bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); bAccount.Add(Buffer[tr].States[i].account[2]); bAccount.Add(Buffer[tr].States[i].account[3]); bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance); double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
我们运行参与者的前馈通验,生成一个参考了当前政策的动作向量。
//--- Actor if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
之后,我们禁用评论者的训练模式,并评估由参与者生成的作。
Critic.TrainMode(false); if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们分 2 个阶段调整参与者的政策。首先,我们检查当前通验的有效性。如果在与环境交互的过程中,该通验被证明是可盈利的,那么我们将根据存储在经验回放缓冲区中的动作,调整参与者的动作政策。
if(Buffer[tr].States[0].rewards[0] > 0) if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder), LatentLayer) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
同时,我们还调整了环境状态编码器参数,以便识别能对参与者政策有效性产生影响的数据点。
在参与者政策训练的第二阶段,我们将提请评论者来指示个体行动的调整方向,以便提高盈利能力/降低 1% 的无盈利能力。为此,我们将参与者动作的当前评级提高 1%。
Critic.getResults(Result); for(int c = 0; c < Result.Total(); c++) { float value = Result.At(c); if(value >= 0) Result.Update(c, value * 1.01f); else Result.Update(c, value * 0.99f); }
我们用获得的结果作为评论者反向传播通验的参考。如是提醒,在该阶段,我们禁用了评论者的学习过程。因此,在执行反向传播通验时,其参数不会被调整。但参与者将收到一个误差梯度。我们将能够调整参与者的参数,朝向提升其政策的有效性。
if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) || !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -1, true) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们只需要通知用户模型训练进度,并转到循环的下一次迭代。
if(GetTickCount() - ticks > 500) { double percent = double(iter + i) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Critic", percent, Critic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
一旦训练过程完结,我们清除品种图表上的注释字段。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", Critic.getRecentAverageError()); ExpertRemove(); //--- }
我们将模型训练结果输出到终端日志,并初始化 EA 终止过程。
该智能系统的完整代码,以及本文准备时用到的所有程序,均在附件中提供。
3. 测试
开发和训练阶段完结后,我们到达了关键阶段:已训练模型的实际评估。
这些模型依据 EURUSD 金融工具 2023 年全年的 H1 时间帧历史数据上进行训练。所有指标参数均设置为默认值。
已训练模型测试依据 2024 年 1 月的历史数据进行,同时保持所有其它参数不变。这种方式可确保尽可能接近实际操作条件。
在第一阶段,我们训练了环境状态编码器模型。以下是 24 小时内实际和预测价格走势的可视化,预测步长为 1 小时,对应于 H1 时间帧上的第二天。分析时采用相同的时间帧。
从呈现的图表中,我们可以观察到生成的预测普遍捕捉到即将到来走势的主要方向。它甚至设法在时间和方向上与某些局部极值保持一致。resembling然而,预测的价格轨迹似乎更平滑,类似于在金融工具价格图表上绘制的趋势线。
在第二阶段,我们训练了参与者和评论者模型。我们不会评估评论者对行为所作评估的准确性。因为它的主要任务是将参与者政策训练引向正确的方向。取而代之,我们关注的是参与者在测试期间学会政策的盈利能力。参与者在策略测试器中的表现如下所示。
在测试期间(2024 年 1 月),该参与者执行了 68 笔交易,其中一半以盈利了结。更重要的是,最大和平均盈利交易都超过了亏损交易(分别为 91.08 和 24.61 比之 -69.85 和 -17.84),整体盈利为 23%。
然而,净值图表显示其围绕余额线上方和下方存在明显波动。这最初引发了对“亏损守仓”和延迟平仓的担忧。值得注意的是,在这些时刻,存款负载接近 100%,表明风险敞口过大。超过 20% 的最大回撤进一步证实了这一点。
接下来,我们针对参与者的政策进行了额外训练,并调整了环境状态编码器的参数。重点要强调,这种优调是在未更新训练数据集的情况下完成的。换言之,训练基础保持不变。然而,这个过程产生了负面影响。模型性能恶化:交易数量降低,盈利交易百分比下降到 45%,整体盈利能力下降,净值回撤超过 25%。
有趣的是,预测价格走势轨迹的准确性也发生了变化。
我的观点是,当我们开始优化环境状态编码器的参数,以期与参与者和评论者的目标保持一致时,我们就会在 编码器的输出处引入额外的噪声。在初始训练阶段,预测模型在输入数据和结果之间有明确的对应关系,令其能够有效地学习和普适化形态。不过,从参与者和评论者接收的误差梯度会引入冲突噪声,因为模型会尝试基于环境状态编码器提供的数据将误差最小化。如是结果,编码器不再当作原生数据的过滤器,导致所有模型的有效性降低。
结束语
我们探索了一种创新而复杂的时间序列预测方法,即 TEMPO,其作者提议使用预训练的语言模型进行时间序列预测任务。所提议算法引入了一种新的时间序列分解方式,提高了学习数据表示的效率。
我们在 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 程序代码库 |
9 | Study2.mq5 | 智能系统 | 训练参与者和评论者模型的智能系统,配以编码器参数调整 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/15469


