English Русский Deutsch Português
preview
ニューラルネットワークが簡単に(第85回):多変量時系列予測

ニューラルネットワークが簡単に(第85回):多変量時系列予測

MetaTrader 5トレーディングシステム | 13 9月 2024, 13:59
22 0
Dmitriy Gizlyk
Dmitriy Gizlyk

はじめに

時系列予測は、効果的な取引戦略を構築するうえで最も重要な要素の1つです。取引を進める際には、今後の値動きに対して自分なりのビジョン(予測)を持ちます。最近では、ディープラーニングモデル、とりわけ Transformerベースモデルが長期時系列予測の多面的な問題解決に大きな可能性を示しており、この分野での進歩が注目されています。

しかし、Transformerアーキテクチャを時系列予測に適用する際の効率性には疑問が残ります。私たちがこれまで検討してきたTransformerベースのモデルのほとんどはSelf-Attentionメカニズムを用いて、分析対象のシーケンスのさまざまな時間ステップの長期的な依存関係を捉えようとしますが、いくつかの研究では、時間間注意に基づく既存のTransformerモデルのほとんどは、時間間の依存関係を適切に研究捉えられていないと指摘されています。実際、単純な線形モデルがTransformerモデルを上回る場合もあります。

Client:Cross-variable Linear Integrated Enhanced Transformer for Multivariate Long-Term Time Series Forecasting」 の著者は、この問題に対して建設的なアプローチを取っています。問題の規模を評価するため、彼らは、過去の時系列データの一部をマスキングし、個々のデータをランダムに0に置き換えるという実験を行いました。時間依存性に敏感なモデルほど、正しい履歴データが欠如すると大きく性能が低下します。したがって、性能の低下はモデルの時間的パターンを捉える能力を示しています。この実験結果から、Cross-AttentionベースのTransformerモデルは、データのマスキング規模が大きくなっても性能が有意に低下しないことが明らかになりました。中には、履歴データの最大80%がランダムに「0」に置き換えられても、ほとんど変わらない予測性能を示すモデルも存在します。これは、こうしたモデルが時系列の変化に対してあまり敏感でないことを示唆しています。

今回の分析結果について、私の見解はやや曖昧です。時系列データの変化に対する感度の低さは、少なくとも懸念すべき点です。さらに、モデルがブラックボックスであるため、どの部分のデータを重視し、どこを無視しているのかを理解するのは難しいです。

一方で、確率的な金融市場の環境では、時系列データに多くのノイズが含まれるため、それをフィルタリングすることが重要です。このような文脈では、対象としている環境で典型的ではない小さな変動や異常値を無視することで、重要なパターンを見つけやすくなるかもしれません。

さらに、この論文の著者は、多変量時系列において、異なる変数が時間とともに関連したパターンを示すことに注目しています。これは、時間ステップ間ではなく変数間の依存関係を学習するためにAttentionメカニズムを活用する可能性を示唆しており、Self-Attentionの適用方法を見直す契機となるかもしれません。

この論文の著者が提案するTransformerは、非線形性のモデル化や変数間の依存関係を捉える能力に優れていますが、時系列データのトレンドを抽出する際にはうまく機能しない可能性があります。このタスクは、線形モデルの方が得意です。そこで、両者の強みを組み合わせるために、多変量長期時系列予測のためのCross-variable Linear Integrated Enhanced Transformer - Clientが提案されています。このアルゴリズムは、トレンドを抽出する線形モデルの能力と、強化されたTransformerの表現力を統合したものです。


1. 「Client」アルゴリズム

Clientアルゴリズムの主な考え方は、時間的なAttentionから変数間の依存関係の分析に移行し、これに加えて線形モジュールを統合することで、変数間の依存関係とトレンド情報をより効果的に利用する点にあります。

Client法の著者は、時系列予測問題に対して創造的なアプローチを取り入れており、一部では既存のアプローチを活用しながら、他の確立された方法を排除しています。各アルゴリズムブロックを組み込むかどうかは、一連のテストによって決定され、そのテストを通じてモデルの有効性や実行可能性が実証されています。

分布の偏りを解決するために、この手法では、著者が以前に解説した「可逆正規(RevIN)」を採用しています。RevINはまず、元のデータから時系列に関する統計情報を取り除くために使用されます。モデルがデータを処理して予測値を生成した後、元の時系列の統計的情報が予測値で復元されるため、一般的にモデル訓練の安定性と時系列の予測値の品質が向上します。

さらに、時間ステップではなく変数の視点から分析を深めるため、この手法の著者は初期データの入れ替えを提案しています。

変数の観点からの注意(著者による視覚化)

このようにして準備されたデータは、Multi-Head Self-Attention (MHA)FeedForward (FFN)からなる複数層のTransformerエンコーダーに供給されます。

エンコーダーへの入力において、通常の埋め込み層はバイパスされます。この手法の著者が実施したテストによると、追加のデータ変換が時間情報を歪め、モデルのパフォーマンスが低下するため、埋め込み層の使用は効果的ではないことが確認されました。さらに、変数間に時間シーケンスが存在しないため、位置符号化ブロックも削除されています。

エンコーダーによる特徴量抽出の後、時系列データは投影層に送られ、ここで各変数の予測値が生成されます。

提案された投影層は、提案された投影層は、従来のTransformerデコーダーに代わるものであり、Clientの著者は、デコーダーを追加するとモデルの全体的なパフォーマンスが低下することを発見しています。

Clientモデルには、Attentionブロックと並行して、独立したチャンネルや個々の変数の時系列トレンドを調査するための線形モジュールが統合されています。

Attentionブロックと線形モジュールの予測値は、線形モジュールの結果に適用される学習可能な重みを考慮して合算されます。

モデルの出力では、データの並び順を元に戻すために再度転置され、元の時系列データの統計情報が復元されます。

このように、Clientメソッドは、線形モジュールを使ってトレンド情報を収集し、高度なTransformerモジュールを利用して非線形情報や変数間の依存関係を捉えます。以下は、筆者によるこの手法の可視化です。

筆者によるClient法の可視化


2. MQL5での実装

Client法の理論的側面について考察した後は、本稿の実用的な部分に移り、MQL5を使用して、提案するアプローチのビジョンを実装します。

2.1 新しいニューラル層を作成する

まず、提案されているアプローチのほとんどを組み合わせる新しいクラスCNeuronClientOCLを作成しましょう。このクラスは、先に作成したほとんどのクラスと同様に、基本となるニューラル層クラスCNeuronBaseOCLを継承して作成します。

class CNeuronClientOCL  :  public CNeuronBaseOCL
  {
protected:
   //--- Attention
   CNeuronMLMHAttentionOCL cTransformerEncoder;
   CNeuronConvOCL    cProjection;
   //--- Linear model
   CNeuronConvOCL    cLinearModel[];
   //---
   CNeuronBaseOCL    cInput;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *prevLayer);

public:
                     CNeuronClientOCL(void) {};
                    ~CNeuronClientOCL(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint window, uint window_key, uint heads,
                          uint at_layers, uint count, uint &mlp[],
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronClientOCL;   }
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual void      SetOpenCL(COpenCLMy *obj);
   virtual void      TrainMode(bool flag);
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau);
  };

Attentionブロックは2つのオブジェクトから作られます。

  • cTransformerEncoderCNeuronMLMHAttentionOCLクラスのオブジェクトで、Multi-HeadTransformerのエンコーダーブロックを、指定された数の連続した層から作成することができます。
  • cProjection:投影層。ここでは畳み込み層を使って、個々の変数について独立した予測をおこないます。予測深度は層のフィルタ数を決定します。

線形モジュールを作成するために、畳み込み層の動的配列cLinearModel[] を作成します。これにより、個々の変数に対して独立した予測を生成できるようになります。

なお、今回の実装では、可逆的な正規化とデータ転置の層をクラスの外に移すことにしました。これは、Clientブロックがより複雑なアーキテクチャに統合できるからです。したがって、統計情報はこのブロックから遠く離れた場所でも削除復元が可能です。

また、データの転置はClientブロックから離れた場所でおこなうこともできます。また、場合によっては、データ準備の段階で、必要なソースデータのシーケンスを作成することも可能です。

新しいクラスのメソッド一式はごく標準的なものです。

すべての内部オブジェクトを静的に宣言することで、クラスのコンストラクタとデストラクタを空にしておくことができます。このアプローチでは、メモリクリーニングの問題に焦点を当てず、この機能をシステムに委ねることができます。

すべての内部オブジェクトの初期化はInitメソッドでおこなわれます。このメソッドのパラメータでは、必要なアーキテクチャを構成するために必要なすべての情報をクラスオブジェクトに渡します。

ここで注意しなければならないのは、クラス本体に2つの並列ストリームを作っていることです。

  • Transformerブロック
  • 線形モジュール

これらのモジュールは、同じデータセットを扱うにもかかわらず、複雑でまったく異なる独立したアーキテクチャを持っています。したがって、両方のモジュールをアーキテクチャオブジェクトに渡すメカニズムが必要です。Transformerブロックについては、以前に開発した5変数のアプローチを使用します。

  • window:シーケンスの1要素のベクトルのサイズ
  • window_key:シーケンスの1要素の内部表現ベクトルのサイズ
  • heads:Attention Head数
  • count:シーケンス内の要素の数
  • at_layers:エンコーダーブロックの層の数

線形モジュールのアーキテクチャを記述するために、数値配列mlp[]を使用します。配列の要素数は、作成する層の数を示します。各要素の値は、層の出力におけるシーケンスの1要素を記述するベクトルのサイズを示します。線形モジュールは、Attentionブロックと同じデータセットで動作します。したがって、シーケンスの要素数は同じです。

なお、Client法の著者は、変数間の依存関係を分析することを提案しています。したがって、この場合、シーケンスの1要素を記述するベクトルのサイズは、分析された履歴の深さに等しくなります。そして、シーケンスの要素数は、分析される変数の数に等しくなります。入力データは、新しいCNeuronClientOCLクラスオブジェクトに入力される前に、適宜転置されなければなりません。

この方法では、データ予測の深さをmlp[]配列の最後の要素で示すことになります。

これはデータ転送ロジックです。提案されたアプローチをコードで実装してみましょう。Initメソッドのパラメータでは、上で示した変数を指定し、それを基本クラスの要素で補います。

bool CNeuronClientOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                            uint window, uint window_key, uint heads,
                            uint at_layers, uint count, uint &mlp[],
                            ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   uint mlp_layers = mlp.Size();
   if(mlp_layers == 0)
      return false;

メソッド本体では、まず線形モジュールアーキテクチャ記述配列mlp[]のサイズを確認します。この配列は、データ予測の深さを示す要素を少なくとも1つ含んでいなければなりません。配列が空の場合は、falseの結果でメソッドを終了します。

次のステップでは、クラスオブジェクトを初期化します。まず、線形モジュールの動的配列を変更します。

   if(ArrayResize(cLinearModel, mlp_layers + 1) != (mlp_layers + 1))
      return false;

配列サイズは、結果として得られる線形層アーキテクチャよりも1要素大きくなければならないことに注意してください。このステップの理由については、もう少し後で話しましょう。

次に、親クラスの同じメソッドを呼び出し、継承したすべてのオブジェクトを初期化します。

   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, mlp[mlp_layers - 1] * count, optimization_type, batch))
      return false;

その後、Transformerエンコーダーの初期化メソッドを呼び出します。

   if(!cTransformerEncoder.Init(0, 0, OpenCL, window, window_key, heads, count, at_layers, optimization, iBatch))
      return false;

入力データを一時的に保存するための補助層です。

   if(!cInput.Init(0, 1, open_cl, window * count, optimization_type, batch))
      return false;

次のステップは、線形モジュールの層を初期化するループを作ることです。

   uint w = window;
   for(uint i = 0; i < mlp_layers; i++)
     {
      if(!cLinearModel[i].Init(0, i + 2, OpenCL, w, w, mlp[i], count, optimization, iBatch))
         return false;
      cLinearModel[i].SetActivationFunction(LReLU);
      w = mlp[i];
     }

ここで、Client法の著者は、線形モジュールの結果に学習係数を適用することを提案していることを忘れてはなりません。彼らは、学習可能な倍率を作り出す、ちょっと変わった方法を発見しました。私はそれらをフィルタ数、ウィンドウサイズ、畳み込みストライドを1に等しくした畳み込み層に置き換えることにしました。線形モジュール配列の最後の要素(先に追加した)に追加します。

   if(!cLinearModel[mlp_layers].Init(0, mlp_layers + 2, OpenCL, 1, 1, 1, w * count, optimization, iBatch))
      return false;

ここでもう1つ。入力データを正規化する過程で、平均値を0、分散を1に変換します。したがって、予測値もこの分布に一致するはずです。予測値を制約するために、双曲正接(tanh)を活性化関数として使用します。

同様の方法で、Attentionブロックの投影層を開始します。

   cLinearModel[mlp_layers].SetActivationFunction(TANH);
   if(!cProjection.Init(0, mlp_layers + 3, OpenCL, window, window, w, count, optimization, iBatch))
      return false;
   cProjection.SetActivationFunction(TANH);

ご覧のように、両出力データ予測ブロックは双曲線正接によって活性化されます。誤差勾配の正しい伝達を保証するために、層全体に対して同様の活性化関数を指定します。

   SetActivationFunction(TANH);

2つのモジュールの値を単純に加算する予定なので、リバースパスの間に、誤差勾配を両方のモジュールに完全に分配することができます。不要なデータコピー操作をなくすため、内部層の誤差勾配を保存するデータバッファを置き換えます。

   if(!SetGradient(cProjection.getGradient()))
      return false;
   if(!cLinearModel[mlp_layers].SetGradient(Gradient))
      return false;
//---
   return true;
  }

各段階での作戦統制を忘れてはなりません。すべてのネストしたオブジェクトの初期化に成功したら、操作の論理結果を呼び出し元に返します。

ネストされたクラスオブジェクトを初期化した後、CNeuronClientOCL::feedForwardメソッドでフィードフォワードパスのアルゴリズムを整理します。オブジェクトを初期化する際のデータ転送の基本原則について説明しました。では、提案されたアプローチの実装を見てみましょう。

このメソッドは、前のニューラルネットワーク層のオブジェクトへのポインタをパラメータで受け取ります。メソッド本体では、マルチ層Attentionブロックのフィードフォワードメソッドを即座に呼び出します。

bool CNeuronClientOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cTransformerEncoder.FeedForward(NeuronOCL))
      return false;

その後、予測値を必要な計画深度に投影します。

   if(!cProjection.FeedForward(GetPointer(cTransformerEncoder)))
      return false;

入力データ全体をインナー層にコピーするのを避けるため、対応するデータバッファへのポインタだけをコピーします。

   if(cInput.getOutputIndex() != NeuronOCL.getOutputIndex())
      cInput.getOutput().BufferSet(NeuronOCL.getOutputIndex());

線形モジュールにはフィードフォワードのパスループを構成します。

   uint total = cLinearModel.Size();
   CNeuronBaseOCL *neuron = NeuronOCL;
   for(uint i = 0; i < total; i++)
     {
      if(!cLinearModel[i].FeedForward(neuron))
         return false;
      neuron = GetPointer(cLinearModel[i]);
     }

現段階では、両モジュールの予想値を予測しています。線形モジュールの予測は、すでに訓練係数を調整済みです。あとは両スレッドのデータを合計するだけです。

   if(!SumAndNormilize(neuron.getOutput(), cProjection.getOutput(), Output, 1, false, 0, 0, 0, 
0.5 ))
      return false;
//---
   return true;
  }

同様に、ただし逆の順序で、最終結果に対する影響に従って、誤差勾配を前の層に向かってネストされたオブジェクトを通して伝搬させます。これは、CNeuronClientOCL::calcInputGradientsメソッドでおこなわれます。

データバッファの置換機能を使用しているため、次の層からの誤差勾配は、両方のモジュールのオブジェクトバッファに直接書き込まれます。これにより、Transformerと線形モジュール間で誤差勾配を分配するための余分な操作が不要となり、すぐに指定されたモジュールを通して誤差勾配を伝搬させることができます。まず、誤差勾配はAttentionブロックに渡されます。

bool CNeuronClientOCL::calcInputGradients(CNeuronBaseOCL *prevLayer)
  {
   if(!cTransformerEncoder.calcHiddenGradients(cProjection.AsObject()))
      return false;
   if(!prevLayer.calcHiddenGradients(cTransformerEncoder.AsObject()))
      return false;

そして、線形モジュールを通してバックプロパゲーションのループに渡します。

   CNeuronBaseOCL *neuron = NULL;
   int total = (int)cLinearModel.Size() - 1;
   for(int i = total; i >= 0; i--)
     {
      neuron = (i > 0 ? cLinearModel[i - 1] : cInput).AsObject();
      if(!neuron.calcHiddenGradients(cLinearModel[i].AsObject()))
         return false;
     }

Transformerは誤差勾配を前の層のバッファに書き込む点に注意してください。線形モデルはこれをインナー層のバッファに書き込みます。

メソッドの終了前に、両方のストリームからの誤差勾配を加算します。

   if(!SumAndNormilize(neuron.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), 1, false))
      return false;
//---
   return true;
  }

このクラスの他のメソッドもほぼ同じように構築されます。内部オブジェクトの関連するメソッドを1つずつ呼び出します。この記事の枠組みでは、それらのアルゴリズムの詳細については触れません。よく理解しておくことをお勧めします。クラスとそのメソッドの完全なコードは添付ファイルにあります。添付ファイルには、記事で使用したすべてのプログラムの完全なコードも含まれています。

2.2 モデルアーキテクチャ

Client法の著者によって提案されたアプローチの主要部分を実装する新しいクラスCNeuronClientOCLを作成しました。ただし、この方法のいくつかの要件は、モデルアーキテクチャに直接実装する必要があります。

Client法は、時系列予測問題を解くために提案され、私たちはこれをエンコーダーに活用しています。

私たちのモデルの構造において、エンコーダーは環境の状態を圧縮された表現に変換する役割を担っています。Actorモデルは、この表現を基にして、学習した方策に従い、与えられた状態における最適な行動を生成します。最良の方策を学習するためには、環境の状態を正確かつ有益に凝縮した表現が不可欠です。

「環境の状態を正確かつ有益に凝縮して表現する」という概念は、少し抽象的に聞こえるかもしれません。しかし、Actorの方策が将来の値動きの可能性に基づいて最大の利益を生むように訓練されているので、圧縮された表現には、予想される値動きに関する十分な情報が含まれていると仮定するのが論理的です。さらに、リスクや逆方向に価格が動く確率、その動きの大きさも評価する必要があります。このようなパラダイムでは、エンコーダーを将来の値動きを予測するように訓練するのが適切です。これにより、エンコーダーの隠れた状態には今後の値動きに関する最大限の情報が含まれることになります。したがって、私たちはエンコーダーのアーキテクチャに Client法のアプローチを採用しています。

エンコーダーのアーキテクチャは、CreateEncoderDescriptionsメソッドで示されます。パラメータとして、このメソッドはモデルアーキテクチャが保存される1つの動的配列へのポインタを受け取ります。

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;
     }

ここでは、以前と同様、層の大きさは2つの定数の積で決まります。

  • HistoryBars :環境の状態(バー)の分析された履歴の深さ
  • BarDescr :環境状態の1つのバーを表すベクトルのサイズ

ただし、1つだけ注意があります。以前は、各反復において、値動きの中で最後に閉じたバーに関する情報のみをモデルに与えていました。分析された履歴の必要な深さはすべて、埋め込みという形でモデルの内部層のスタックに蓄積されました。さて、Client法の著者は、追加の埋め込み層が時系列情報を歪めるとして、その排除を推奨しています。そこで、モデルの入力データ層を拡張し、分析対象の歴史の深さ全体をカバーするデータを入力します。

そこで、HistoryBars定数の値を120まで増やしました。これにより、H1時間枠で直近1週間の履歴データを分析することができます。

#define        HistoryBars             120           //Depth of history

次の層は、前と同じくバッチ正規化層で、時系列から統計的情報を取り除くことによって、入力データを比較可能な形にします。

//--- 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;
     }

この層の識別子を覚えておきましょう。なぜなら、モデルの出力では、時系列の統計情報を予測値に戻す必要があるからです。

入力データを準備する際、個々の指標(Client法の文脈では変数)のデータ列として形成することができます。オプションとして、前回と同じように、時間ステップ(バー)の一連の記述という形で提供することもできます。この記事では、入力データ準備ブロックは変更しないことにしました。そのため、以前に作成した環境相互作用EAを最小限の修正で使用することができます。

しかし、このような実装にはデータ移調層をインストールする必要があります。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = HistoryBars;
   int prev_wout = descr.window = BarDescr;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

転置層の後に、新しい層(Clientブロック)のインスタンスを追加します。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronClientOCL;
   descr.count = prev_wout;
   descr.window = prev_count;
   descr.step = 4;
   descr.window_out = EmbeddingSize;
   descr.layers = 5;
     {
      int temp[] = {1024, 1024, 1024, NForecast};
      ArrayCopy(descr.windows, temp);
     }
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

ここでは、解析される配列のサイズに対して、解析される変数の数(BarDescr定数)を指定します。シーケンスの1つの要素を記述するベクトルのサイズは、分析する履歴の深さ(HistoryBars定数)に等しくなります。Transformerブロックでは、4つのAttention Headを使用し、5つの層を作成します。

4つの層から線形モジュールを作ります。3つの隠れ層のサイズは1024で、最後の層は計画水平線に等しくなります(NForecast定数)。

次にデータの逆置換をおこないます。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   prev_count = descr.count = BarDescr;
   prev_wout = descr.window = NForecast;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

その中の統計情報を復元します。

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronRevInDenormOCL;
   prev_count = descr.count = prev_count * prev_wout;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.layers = 1;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Actorのアーキテクチャについて少し述べておきましょう。ほぼ前回の記事のコピーです。ただし、後で説明する細かい点があります。 

ActorモデルとCriticモデルのアーキテクチャは、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
   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;
     }

Cross-Attentionの3つの連続層を追加します。この層では、口座の現在の状態と、エンコーダーによって形成された環境の将来の状態の圧縮表現との間の依存関係を分析します。

//--- layer 2-4
   for(int i = 0; i < 3; i++)
     {
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronCrossAttenOCL;
        {
         int temp[] = {1, BarDescr};
         ArrayCopy(descr.units, temp);
        }
        {
         int temp[] = {EmbeddingSize, NForecast};
         ArrayCopy(descr.windows, temp);
        }
      descr.window_out = 16;
      descr.step = 4;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!actor.Add(descr))
        {
         delete descr;
         return false;
        }
     }

Client法に基づくアプローチでは、クロス分析においてデータを再変換する前に、エンコーダーの隠れた状態のデータが使用されます。これにより、現在の口座状態と各変数の予測値との依存関係を分析することができます。この考え方は、desc.units配列とdescr.windows配列の新しい値に反映されています。

次に、先ほどと同じように、Actorの方策に確率を加えた意思決定ブロックが登場します。

//--- layer 5
   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 6
   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 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

同様の変更はCriticモデルにも影響しました。覚えているように、ActorモデルとCriticモデルは似たようなアーキテクチャを持っています。違いは、Criticモデルの入力が、口座の状態記述の代わりに行動ベクトルであることです。モデルの出力では、行動ベクトルが報酬ベクトルに置き換えられます。使用された全モデルアーキテクチャソリューションの詳細については、添付ファイルをご覧ください。添付ファイルには、記事で使用したすべてのプログラムの完全なコードも含まれています。

さらに、データ抽出のためにエンコーダーの隠れ層への定数ポインタの値を変更しました。

#define        LatentLayer             3

モデルのアーキテクチャソリューションを調整し、使用する定数を変更するために多くの作業をおこなったので、環境との相互作用のために以前に作成したEAを実質的に変更することなく使用することができます。変更された定数とモデルアーキテクチャを考慮に入れて再コンパイルすれば済みます。しかし、これはモデル訓練EAを指すものではありません。

2.3 予測モデルの訓練EA

環境条件予測モデルは、「...\Experts\Client\StudyEncoder.mq5」EAで訓練します。一般的に、EAの構造は過去の作品から拝借しています。そのすべての方法について詳しく検討することはしません。Trainメソッドで実行されるモデル訓練ステージだけを考えてみましょう。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

メソッドの本体では、まず、経験再生バッファから実際の収益性に応じて軌道を選択する確率のベクトルを生成します。収益性の高いパスは、学習プロセスで使用される可能性が高いです。こうして、最も収益性の高い軌道に訓練の焦点を移します。

準備作業の後、モデルの訓練ループを編成します。最近のいくつかの作品とは異なり、ここでは以前使われていたネストされたループシステムではなく、シンプルなループを使っています。これが可能なのは、モデルアーキテクチャにリカレント要素(埋め込みスタック)を使用していないからです。

   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;
        }

ループの本体では、経験再生バッファから軌跡をサンプリングし、その上に環境の状態を表示します。

サンプリングされた環境状態の記述を実験再生バッファから抽出し、得られた値をデータバッファに転送します。

      bState.AssignArray(Buffer[tr].States[i].state);

この情報は、エンコーダーのフィードフォワードパスを実行するのに十分です。

      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

次に、目標値のベクトルを用意する必要があります。本研究の文脈では、プランニングホライズンは分析された履歴の深さよりもはるかに小さくなります。これにより、目標値を準備する作業が大幅に簡素化されます。経験再生バッファから、計画地平線を刻み込んだ環境状態の記述を抽出するだけです。また、必要な体積のテンソルから最初の要素を取り出します。

      //--- Collect target data
      if(!bState.AssignArray(Buffer[tr].States[i + NForecast].state))
         continue;
      if(!bState.Resize(BarDescr * NForecast))
         continue;

分析された履歴の深さよりも長い計画期間を使用する場合、目標値を収集するには、計画期間の経験再生バッファの状態をループする必要があります。

目標値テンソルを準備した後、Encoderバックプロパゲーションパスを実行し、データ予測誤差を最小化するために訓練済みモデルのパラメータを最適化します。

      if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

最後に、訓練の進捗状況をユーザーに知らせ、次の訓練反復に移ります。

      if(GetTickCount() - ticks > 500)
        {
         double percent = double(iter) * 100.0 / (Iterations);
         string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Encoder", percent, 
                                                                       Encoder.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

操作を実行するプロセスを、すべてのステップで管理するようにします。モデル訓練のすべての反復が成功裏に完了したら、チャートのコメントフィールドを消去します。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError());
   ExpertRemove();
//---
  }

モデルの訓練結果をログに表示して、EAを終了します。

2.4 Actor方策訓練EA

また、Actor方策研修EA「...\Experts\Client\Study.mq5」を一部編集しました。ここでも、モデルの訓練方法のみに注目します。

void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);
//---
   vector<float> result, target;
   bool Stop = false;
//---
   uint ticks = GetTickCount();

メソッドの本体では、まず軌道選択確率のベクトルを生成し、その他の準備作業をおこないます。この部分では、前のEAのアルゴリズムの正確な繰り返しを見ることができます。

次に、経験再生バッファから軌跡をサンプリングし、その上の環境の状態をサンプリングするモデル訓練ループも構成します。

選択された環境状態の記述を読み込み、エンコーダーのフィードフォワードパスを実行します。

      bState.AssignArray(Buffer[tr].States[i].state);
      //--- State Encoder
      if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

これで、前のEAのアルゴリズムの「コピー」が完了しました。環境の凝縮された表現を生成した後、まずCriticのパラメータを最適化します。ここではまず、与えられた状態で環境と相互作用しながら実行されたActorの行動を読み込み、フィードフォワードのCriticパスを実行します。

      //--- Critic
      bActions.AssignArray(Buffer[tr].States[i].action);
      if(bActions.GetIndex() >= 0)
         bActions.BufferWrite();
      if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

次に、Actorの与えられた行動に対して環境から実際に受け取った報酬を、経験再生バッファから抽出します。

      result.Assign(Buffer[tr].States[i + 1].rewards);
      target.Assign(Buffer[tr].States[i + 2].rewards);
      result = result - target * DiscFactor;
      Result.AssignArray(result);

 Actorの行動を評価する際の誤差を最小化するために、Criticのパラメータを最適化します。

      Critic.TrainMode(true);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

次に、2段階のActor方策訓練のブロックです。ここではまず、選択された環境状態に対応する口座状態記述を抽出し、データバッファに転送します。

      //--- 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のフィードフォワードパスを実行し、行動ベクトルを生成します。

      //--- Actor
      if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

前述したように、Actorの方策は2つのステップで訓練されます。まず、Actorの行動を訓練セットの分布内に保つように、Actorの方策を調整します。そのために、Actorが生成した行動のベクトルと、経験再生バッファからの実際の行動との誤差を最小化します。

      if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

2番目のステップでは、生成された行動に対するCriticの評価に従って、Actorの方策を調整します。ここではまず、行動を評価する必要があります。

      if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

次に、Critic学習モードをオフにし、与えられた状態で実際に可能なことからの行動評価の乖離の勾配を伝搬させます。

      Critic.TrainMode(false);
      if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) ||
         !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -1, true))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }

ここでは、学習の過程において、Actorの方策は常に改善されることを仮定します。また、受け取る報酬は、環境との相互作用によって実際に得た報酬よりも低くてはならないとします。

モデルのパラメータを更新した後、訓練プロセスの進捗状況をユーザーに通知し、ループの次の反復に移ります。

      if(GetTickCount() - ticks > 500)
        {
         double percent = double(iter) * 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. テスト

本稿では、多変量時系列予測のためのClient法について議論し、提案されたアプローチのビジョンをMQL5に実装しました。次に、最終段階である結果のテストに進みます。このステージでは、EURUSD銘柄の2023年H1時間枠の実際の履歴データを用いてモデルを訓練します。訓練後、2024年1月以降の履歴データを使用し、MetaTrader 5のストラテジーテスターを使って、同じ銘柄と時間枠でモデルのパフォーマンスを検証します。

なお、前回の記事で使用した訓練データセットは、埋め込み層の削除やバーの数(環境の1状態を記述する)の増加により再利用できないことに留意してください。したがって、新たにデータセットを収集する必要があります。ただし、プロセス自体は前回の記事で説明したアルゴリズムの繰り返しであるため、詳細な説明は割愛します。

初期データの収集が完了した後、まず時系列予測モデルの訓練を開始しましたが、ここで最初の予期しない課題に直面しました。予測の精度が予想を下回る結果となったのです。おそらく、主な原因としては、入力データに含まれる大量のノイズや、モデルが時系列の細部に過度に敏感になったことが挙げられます。

それでも実験を続行し、Actorモデルがこのような予測に適応できるか検証することにしました。数回の反復を経てActorを訓練し、データセットを更新しましたが、残念ながら訓練およびテストデータセットのいずれにおいても、利益を生み出すモデルの構築には至りませんでした。バランスラインは下落傾向にあり、プロフィットファクターの値は0.5前後にとどまりました。

この結果は、私たちの特定の実装に起因する可能性がありますが、現実として、実装されたモデルは高度に確率的な環境において、時系列予測に期待される品質を提供することができなかったのです。


結論

この記事では、線形トレンドを研究するための線形モデルと、非線形情報を研究するために個々の変数間の依存関係を分析するTransformerモデルを組み合わせた、Clientと呼ばれるかなり興味深く複雑なアルゴリズムについて説明しました。この方法の著者は、時間的に離れた環境の個々の状態間のAttentionをモデルから除外しています。提案された改良型Transformerモデルは、埋め込みと位置符号化レベルも簡素化します。デコーダーモジュールは投影層に置き換えられており、この手法の著者によれば、予測効率が大幅に向上しています。さらに、引用論文で示された実験結果は、時系列予測タスクでは、Transformerにおける変数間の依存関係の分析が、時間で区切られた環境の個々の状態間の依存関係の分析よりも重要であることを証明しています。

しかし、ここでの研究の結果は、提案されたアプローチは金融市場の非常に確率的な条件下では有効ではないことを示しています。

この記事は、提案されたアプローチを私たちが個別に実装したテスト結果を示していることに注意してください。したがって、得られた結果は、この実装にのみ関連するものかもしれません。他の条件下では、まったく逆の結果が得られる可能性もあります。

この記事の目的は、読者にClient法をよく知ってもらい、提案されたアプローチを実施するための選択肢の一つを示すことだけです。著者が提案したアルゴリズムを評価する立場にはありません。提案されたアプローチを私たちの問題を解決するために適用しようとするだけです。


参照文献

  • Cross-variable Linear Integrated Enhanced Transformer for Multivariate Long-Term Time Series Forecasting
  • この連載の他の記事記事

  • 記事で使用されているプログラム

    # 名前 種類 詳細
    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/14721

    添付されたファイル |
    MQL5.zip (1106.26 KB)
    ニューラルネットワークが簡単に(第86回):U字型Transformer ニューラルネットワークが簡単に(第86回):U字型Transformer
    時系列予測アルゴリズムの研究を続けます。この記事では、もう1つの方法であるU字型Transformerについて説明します。
    ニューラルネットワークが簡単に(第84回):RevIN (Reversible Normalization) ニューラルネットワークが簡単に(第84回):RevIN (Reversible Normalization)
    入力データの前処理がモデル訓練の安定性に大きく寄与することは、すでに広く知られています。オンラインで「生」の入力データを処理するために、バッチ正規化層が頻繁に使用されますが、時には逆の手順が求められる場合もあります。この記事では、この問題を解決するための1つのアプローチについて解説します。
    エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法 エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
    この記事では、MT4において複数のEAの衝突をさける方法を扱います。ターミナルの操作、MQL4の基本的な使い方がわかる人にとって、役に立つでしょう。
    ブレインストーム最適化アルゴリズム(第2部):マルチモーダリティ ブレインストーム最適化アルゴリズム(第2部):マルチモーダリティ
    記事の第2部では、BSOアルゴリズムの実用的な実装に移り、テスト関数のテストを実施し、BSOの効率を他の最適化手法と比較します。