English Русский 中文 Español Deutsch Português
preview
ニューラルネットワークが簡単に(第69回):密度に基づく行動方策の支持制約(SPOT)

ニューラルネットワークが簡単に(第69回):密度に基づく行動方策の支持制約(SPOT)

MetaTrader 5トレーディングシステム | 10 6月 2024, 11:13
177 0
Dmitriy Gizlyk
Dmitriy Gizlyk

はじめに

オフライン強化学習は、環境との相互作用から収集されたデータに基づいてモデルの訓練を可能にします。これにより、環境との相互作用のプロセスを大幅に減らすことができます。さらに、環境モデリングの複雑さを考えると、複数の調査エージェントからリアルタイムでデータを収集し、そのデータを使用してモデルを訓練することもできます。

同時に、静的な訓練データセットを使用すると、利用可能な環境情報が著しく減少します。リソースに限りがあるため、訓練データセットに含まれる環境の多様性をすべて保存することはできません。

しかし、エージェントの最適な方策を学習する過程で、エージェントの行動が訓練データセットの分布を超える可能性が高いです。明らかに、環境からのフィードバックがないため、このような行動に対する真の評価は得られません。訓練データセットのデータ不足のため、Criticも適切な評価を生成できません。この場合、期待値は高くも低くもなります。

高い期待値は低い期待値よりもはるかに危険だと言わなければなりません。過小評価された推定値では、モデルはこれらの行動の実行を拒否する可能性があり、これは最適でないエージェント方策の学習につながります。過大評価の場合、モデルは似たような動作を繰り返す傾向があり、操作中に大きな損失につながる可能性があります。したがって、オフライン訓練の信頼性を確保するためには、訓練データセット内でエージェントの方策を維持することが重要になります。

この問題を解決するための様々なオフライン強化学習法は、エージェントの方策が訓練データセットの支持セット内で行動を実行するように制約するパラメータ化や正則化を使用します。詳細な構築は通常、エージェントモデルに干渉し、運用コストの増加につながり、確立されたオンライン強化学習手法を完全に使用できなくなります。正則化手法は、学習された方策と訓練データセットとの間の不一致を減らしますが、これは密度に基づく支持の定義を満たさない可能性があり、その結果、分布の外側への作用を効果的に回避できません。

この文脈で、「Supported Policy Optimization for Offline Reinforcement Learning」で紹介されているSupported Policy OpTimization (SPOT)法の適用可能性を検討することを提案します。その方法は、訓練データセットの密度分布に基づく方策制約の理論的定式化から直接導かれます。SPOTはVAE(Variational AutoEncoder)に基づく密度推定器を使用しています。これはシンプルでありながら効果的な正則化要素で、既製の強化学習アルゴリズムに組み込むことができます。SPOTは、標準的なオフラインRLベンチマークでクラス最高のパフォーマンスを達成します。その柔軟な設計のおかげで、SPOTを使用してオフラインで事前に訓練したモデルを、オンラインで微調整することもできます。


1.Supported Policy OpTimization (SPOT)アルゴリズム

支持制約をおこなうことは、オフライン強化学習におけるエラーを軽減するための典型的な手法です。そして、支持制約は行動戦略の密度に基づいて形式化することができます。Supported Policy OpTimization法の著者は、行動密度の明示的な推定の観点から、正則化アルゴリズムを提案しています。SPOTは、密度支持制約の理論的定式化から直接導かれる正則化項を含んでいます。正則化要素は、訓練データセットの密度を学習する条件付き変分オートエンコーダ(conditional variational auto-encoder:CVAE)を使用します。

最適Q関数から最適戦略を抽出する方法と同様に、支持された最適戦略も貪欲な選択を使用して回復することができます。

関数近似の場合、これは制約付き戦略最適化問題に相当します。

支持を制約するために他の手法で使用される特定のエージェント方策のパラメータ化や発散ペナルティとは異なり、SPOTの著者は、訓練データセットの密度を制約として直接使用することを提案しています。

ここでは、表記を簡単にするためにϵ=logϵとしています。

行動密度に基づく制約は、支持制約の文脈では単純明快です。この手法の著者は、確率的尤度関数の代わりに、数学的に便利な対数尤度関数を使用することを提案しています。

その結果、状態空間の各点で行動戦略の密度が以下のように制約されるという追加の制約が課されます。このような問題を解くことは、制約が多く無限大にさえなるため、現実的には不可能です。その代わりに、SPOTアルゴリズムの作者は、平均的な行動密度を考慮した発見的な近似を用いています。

制約付き最適化問題を無制約最適化問題に変換してみましょう。このため、制約項をペナルティとして扱います。従って、方策学習目的は次のようになります。

ここでλはラグランジュ乗数です。

上に示した損失関数のわかりやすい正則化項は、訓練データセットの収集に使用された行動方策にアクセスする必要がありますが、私たちが持っているのは、この方策によって生み出されたオフラインのデータだけです。様々な密度推定法を用いて、任意の点における確率密度を明示的に推定することができます。変分オートエンコーダ(VAE)は、最も優れたニューラル密度推定モデルの1つです。この手法の著者は、密度推定器として条件付き変分オートエンコーダを使用することにしました。VAEを訓練した後、それを単純に下界として使用することができます。

上で紹介した一般的なフレームワークは、最小限の修正で様々な強化学習アルゴリズム上に構築することができます。論文の著者は、TD3を基本アルゴリズムとしています。


2.MQL5を使用した実装

Supported Policy Optimizationの理論的側面を検討した後、MQL5を使用した実装に移ります。Real-ORLメソッドに関する記事の駅ー(EA)をベースにモデルを実装します。使用されている基本モデルは、SPOTの作者が利用しているTD3に近いSoft Actor-Criticの手法に基づいていることを思い出してください。ただし、私たちのモデルは、以前の記事で取り上げた多くの方法によって補完されることになります。

まず、SPOT法は、訓練セットのデータ密度に基づいてエージェントの方策の正則化を追加することに注意すべきです。この正則化は、エージェント方策のオフライン訓練の段階で適用されます。環境との相互作用のプロセスには影響を与えません。従って、EAを収集し、テストする訓練データセットはそのまま使用されます。添付ファイルをご覧ください。

すぐにモデル訓練EAに移ることができます。ただし、方策の訓練を始める前に、訓練データセット密度関数のオートエンコーダを訓練する必要があることに注意すべきです。そこで、学習過程を2段階に分けることにします。オートエンコーダは別のEA「...\SPOT\StudyCVAE.mq5」で学習します。

2.1 密度モデル訓練

密度モデル訓練EAの構築を始める前に、まず何をどのように訓練するかについて説明しましょう。SPOT法の作者は、訓練データセットの密度を調べるために拡張オートエンコーダを使用することを提案しました。現実的な観点からはどうなのでしょうか。

データを圧縮復元するオートエンコーダの特性についてはすでに述べました。また、ニューラルネットワークは、訓練データセットと似たような環境でしか安定して動作しないことも述べました。その結果、訓練データセットの分布からかけ離れた初期データをモデルに投入すると、その演算結果はランダムな値に近くなります。そのため、データ解読エラーが大幅に増加します。オートエンコーダモデルのこれらの特性の組み合わせを利用します。

訓練データセットにあるエージェントの行動分布でオートエンコーダを訓練します。エージェントを訓練する過程で、更新されたエージェント方策によって提案された行動をオートエンコーダに入力します。データ解読の誤差は、訓練データセットの分布からの予測行動の距離を間接的に示します。

これで、オートエンコーダのアーキテクチャに適合する機能を、ある程度理解することができました。しかし、訓練データセットにおけるエージェントの行動の存在を理解するのに十分なのでしょうか。異なる環境条件下で同じ行動をとれば、まったく正反対の結果になることはよく理解しています。したがって、異なる環境状態における行動の分布を抽出するために、オートエンコーダを訓練する必要があります。したがって、オートエンコーダの入力には「状態-行動」ペアを入力しなければならないという結論に達します。この場合、オートエンコーダの出力には、入力に供給されたエージェント行動を受け取ることが期待されます。

「状態-行動」ペアをオートエンコーダの入力に与えるとき、その潜在的な状態には、状態と行動に関する圧縮された情報があることを期待します。しかし、行動のみをデコードするようにオートエンコーダを訓練することで、高い確率で、環境の状態に関する情報を無視するようにオートエンコーダを訓練することになります。また、希望する行動を送信するために、潜伏状態の全サイズを使用します。これでは結局、ステートレス行動のエンコードとデコードという状況に戻ってしまいます。これは非常に望ましくないことです。したがって、オートエンコーダの注意を、元の「状態-行動」データの両方のコンポーネントに集中させることが重要です。この結果を得るために、この手法の著者は拡張オートエンコーダを使用しており、そのアーキテクチャは、データをデコードするための特定のキーを入力するようになっています。このキーは、潜在表現とともにデコーダの入力に送られます。ここでは、環境の状態をキーとします。

したがって、フィードフォワードパスの入力として3つのテンソルを受け取るオートエンコーダモデルを構築しなければなりません。

  • 環境状態(エンコーダ入力)
  • エージェント行動(エンコーダ入力)
  • 環境状態(デコーダ入力キー)

以前は、2つのテンソルの初期データだけでモデルを構築していました。次に、3つのテンソルから初期データを実装しなければなりません。この問題はいくつかの方法で解決できます。

まず、状態と行動のペアを1つのテンソルにまとめます。そうすると、キーはソースデータの2番目のテンソルとなり、これは先ほどソースデータの2つのテンソルを使用したモデルに適合します。しかし、異種の環境データとエージェントの行動を組み合わせることは、モデルのパフォーマンスに悪影響を及ぼし、生の環境データを前処理する能力を制限する可能性があります。

2つ目のオプションは、元データの3つのテンソルを持つモデルを扱うメソッドを追加することです。これは手間のかかるプロセスであり、特定のタスクごとにメソッドを延々と作り続けることになりかねません。これではライブラリが煩雑になり、理解も維持も難しくなります。

この記事では、私にとって最もシンプルに思える3番目の選択肢を選択してみます。エンコーダとデコーダのモデルを別々に作ります。それぞれ、初期データの2つのテンソルを扱います。その実装は、私たちが以前に開発した手法に完全に準拠しています。

これは理論的な判断です。それでは、オートエンコーダモデルのアーキテクチャの説明に移りましょう。これはCreateCVAEDescriptionsメソッドでおこなわれます。エンコーダとデコーダの2つのモデルのアーキテクチャを組み立てるために、2つの動的配列へのポインタをメソッドに入力します。メソッド本体では、受け取ったポインタを確認し、必要であれば新しい動的配列オブジェクトのインスタンスを生成します。

bool CreateCVAEDescriptions(CArrayObj *encoder, CArrayObj *decoder)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         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 = defNeuronConvOCL;
   prev_count = descr.count = HistoryBars;
   descr.window = BarDescr;
   descr.step = BarDescr;
   int prev_wout = descr.window_out = BarDescr / 2;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

このようにして得られた環境状態の埋め込みは、エージェントの行動のベクトルと組み合わされます。

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

次に、2つの全結合層を使用してデータを圧縮します。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

エンコーダの出力で、変分オートエンコーダの内部層を使用して確率的潜在表現を作成します。

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = EmbeddingSize;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

以下はデコーダのアーキテクチャの説明です。モデルの入力はエンコーダによって生成された潜在表現です。

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

このテンソルを環境状態ベクトルとすぐに連結します。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = EmbeddingSize;
   descr.window = prev_count;
   descr.step = (HistoryBars * BarDescr);
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

エンコーダには、環境の状態を表す未処理の生データを入力し、バッチ正規化層で一次処理をおこないました。しかし、デコーダではそのような正規化をおこなう機会がありません。データを2回正規化しないことにしました。その代わり、訓練や運用の過程で、正規化後のエンコーダからデータを取ることにします。これにより、デコーダをもう少しシンプルにし、データ処理時間を短縮することができます。

次に、全結合層を使用して、受け取った初期データから行動ベクトルを再構成します。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

オートエンコーダのアーキテクチャを説明した後、このオートエンコーダを訓練するEAの構築に移ります。前述のように、2つのモデルを訓練します。エンコーダとデコーダです。

CNet                 Encoder;
CNet                 Decoder;

プログラムのOnInit初期化メソッドでは、まず訓練データセットを読み込みます。データの読み込みに失敗した場合、モデルを訓練することができなくなります。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

次に、事前に訓練されたモデルを読み込み、必要に応じてランダムなパラメータで初期化された新しいモデルを生成します。

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new CVAE");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      if(!CreateCVAEDescriptions(encoder,decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder) || !Decoder.Create(decoder))
        {
         delete encoder;
         delete decoder;
         return INIT_FAILED;
        }
         delete encoder;
         delete decoder;
     }

そして、両方のモデルを1つのOpenCLコンテキストに移動させ、メインプログラムのメモリにダンプすることなく、モデル間でデータを交換できるようにします。

   OpenCL = Encoder.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);

ここでは、読み込まれた(あるいは作成された)モデルのアーキテクチャに対して、必要最小限の制御をおこないます。操作の結果を必ず確認します。

   Encoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), 
                                                                                          (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Encoder.getResults(Result);
   int latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

次に、モデルの訓練過程を開始するためのイベントの作成を初期化します。その後、INIT_SUCCEEDEDの結果でプログラムの初期化メソッドを完了します。

   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

OnDeinitプログラムの初期化解除メソッドでは、訓練済みモデルを保存し、プログラム内で作成されたオブジェクトのメモリをクリアします。

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true);
   Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   delete Result;
   delete OpenCL;
  }

一般的な端末カタログには、すべてのモデルが保存されます。これにより、端末とストラテジーテスターの両方でプログラムを使用できるようになります。

モデルの訓練過程はTrainメソッドで実行されます。メソッド本体では、まず必要なローカル変数を作成します。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
   int bar = (HistoryBars - 1) * BarDescr;

それから訓練ループを作成します。

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = int((MathRand() * MathRand() / MathPow(32767, 2)) * (total_tr));
      int i = int((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
         continue;

なお、最近の研究とは異なり、ここでは軌道の優先度付けはおこなっていません。これは完全に意識的で意図的なステップです。というのも、この段階では、訓練データセットにおける真のデータ密度を調べることに努めているからです。軌跡の優先度付けの使用は、優先度の高い軌跡に有利に情報を歪める可能性があります。そのため、軌道とその中の状態を一様にサンプリングします。

軌跡と状態をサンプリングした後、訓練データセットから環境の状態とエージェントの行動の記述バッファに書き入れます。.

      State.AssignArray(Buffer[tr].States[i].state);
      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();

通常、「環境記述」という概念には、口座の状態や未決済ポジションを表すベクトルを含めます。なぜなら、ポジションを建てるか保有するかは市場の状態によって決まるからです。口座状況の分析は、リスクを管理し、ポジションの大きさを決定するためにおこなわれます。この段階では、個々の市場状況における行動の密度を研究することに限定し、リスク管理モデルには焦点を当てないことにしました。

初期データバッファを準備した後、オートエンコーダのフィードフォワードパスを実行します。上述したように、エンコーダへのポインタをデコーダ入力で2回送ります。この場合、モデルの出力を主な入力データストリームとして使用します。入力データの追加ストリームについては、エンコーダのバッチ正規化層の結果を削除します。必ずすべてのプロセスを監視してください。

      if(!Encoder.feedForward(GetPointer(State), 1,false, GetPointer(Actions)) ||
         !Decoder.feedForward(GetPointer(Encoder), -1, GetPointer(Encoder),1))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

オートエンコーダを訓練する過程で、その結果を分析したり処理したりする必要はありません。目標の値を指定するだけでよく、これにはエージェントの行動ベクトルを使用します。これは、以前にエンコーダに入力したのと同じベクトルです。つまり、すでに結果バッファが用意されていて、両方のオートエンコーダモデルのバックプロパゲーションメソッドを呼び出しています。

      if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
         !Encoder.backPropGradient(GetPointer(Actions), GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

エンコーダは、デコーダから受け取ったエラー勾配に基づいてパラメータを更新します。また、エンコーダ用に別の目標バッファを生成する必要もありません。

これで、オートエンコーダ訓練の1反復の操作は完了です。私たちがすべきことは、操作の進捗状況をユーザーに知らせ、モデル訓練ループの次の反復に移ることだけです。

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

エンコーダではエラーは計算されないので、ここではデコーダのエラー情報のみを表示します。

オートエンコーダ訓練ループのすべての反復が成功したら、チャートのコメントフィールドをクリアし、EAを終了するプロセスを開始します。

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

EAの完全なコードは、添付ファイルに記載されています。この記事で使用されているすべてのプログラムもそこで紹介されています。

2.2 エージェント方策の訓練

密度モデルの訓練後、エージェント訓練EA「...\SPOT\Study.mq5」に進みます。エージェントの訓練過程はほとんど変わりません。行動方策を規則正しくするという点では、わずかに補足されただけでした。すべての訓練済みモデルのアーキテクチャも変更せずにコピーしました。というわけで、EA 「...\SPOT\Study.mq5」のメソッドの一部だけを見てみましょう。全コードは添付ファイルにあります。

エージェントの方策訓練アルゴリズムの変更がどんなに小さなものであっても、それはより高度に訓練されたオートエンコーダモデルに関わります。プログラムに加える必要があります。

STrajectory          Buffer[];
CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;
CNet                 Encoder;
CNet                 Decoder;

OnInitプログラム初期化メソッドでは、前回同様、訓練データセットを読み込み、演算の実行を制御します。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

そして、訓練済みモデルをロードする前に、オートエンコーダを読み込みます。モデルを読み込めない場合は、ユーザーに通知し、INIT_FAILEDの結果で初期化メソッドを終了します。

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Cann't load CVAE");
      return INIT_FAILED;
     }

事前に訓練されたモデルがない場合は、ランダムなパラメータで新しいモデルを作成しません。訓練されていないモデルは学習過程を歪めるだけで、そのような訓練の結果は予測不可能だからです。

一方、フラグを追加して、訓練済みオートエンコーダモデルがない場合、以前のように、エージェントの行動を正則化せずにエージェントの方策を訓練することもできます。実際の問題に取り組むときは、おそらくこうするでしょう。しかし今回は、正則化の働きを評価しているため、プログラムを中断させることは、ヒューマンファクターを制御するための追加的なポイントとして機能します。

次に、訓練済みモデルを読み込み、必要に応じてランダムなパラメータで初期化された新しいモデルを作成します。

   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;

   if(!Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new Encoder model");
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
     }

新しいモデルの読み込みや初期化に成功したら、それらを1つのOpenCLコンテキストに移動します。また、学習モデルでは、パラメータ更新モードを無効にします。つまり、この段階では、オートエンコーダの下流での追加訓練はおこないません。

   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);
   Encoder.SetOpenCL(OpenCL);
   Decoder.SetOpenCL(OpenCL);
   Encoder.TrainMode(false);
   Decoder.TrainMode(false);

ここで1つ注意しなければならないのは、ランダムエンコーダも訓練されていないが、その訓練モードフラグを変更していないことです。その必要はありません。学習モード変更メソッドは、未使用のバッファを削除しません。そのため、メモリはクリアされません。バックプロパゲーションアルゴリズムを制御するフラグを変更するだけです。プログラム中では、エンコーダのバックプロパゲーションメソッドは呼び出されません。つまり、ランダムエンコーダの訓練フラグを変更しても、その効果はゼロに近くなります。オートエンコーダの場合、状況は少し異なります。これについては後ほど、Trainモデルの訓練手法で検討します。さて、EAを初期化するメソッドに戻りましょう。

モデルを作成し、それらを1つのOpenCLコンテキストに転送した後、そのアーキテクチャとプログラムで使用される定数とのコンプライアンスについて最小限の制御をおこないます。

まず、Actorの結果層のサイズがエージェントの行動ベクトルのサイズと一致しているかどうかを確認します。

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }

Actorの初期データのサイズは、環境の状態を記述するベクトルのサイズに対応しなければなりません。

   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

また、Actorの潜在層のサイズとCriticのソースデータバッファの対応関係も確認します。

   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic1.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

オートエンコーダのエンコーダとデコーダのモデルについても同様の確認をおこないます。

   Decoder.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the Decoder does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }

   Encoder.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), 
                                                                                          (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

   Encoder.getResults(Result);
   latent_state = Result.Total();
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Decoder doesn't match result of Encoder (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

これでモデルの準備作業は終了です。補助バッファを初期化し、学習過程を開始するためのイベントを生成しましょう。

   Gradient.BufferInit(AccountDescr, 0);
//---
   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

そして、EAを初期化するメソッドを、肯定的な結果で完了させます。

訓練の過程でオートエンコーダモデルのパラメータを変更することはないので、プログラム終了後にパラメータを保存する必要はありません。したがって、OnDeinitメソッドに変更はありません。添付ファイルにコードがあります。次に、モデルを訓練するプロセスに移ります。では、Trainメソッドについて考えてみましょう。

Actor方策訓練メソッドのアルゴリズムは、上述の密度モデル訓練メソッドよりも包括的で複雑です。もう少し詳しく考えてみましょう。

このメソッドの最初に、いくつかのローカル変数と行列を用意します。これは、後でモデルの訓練過程で使用します。 

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;
   vector<float> temp, next;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states, temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states, NRewards);
   matrix<float> actions = matrix<float>::Zeros(total_states, NActions);

次に、経験再生バッファからすべての状態の埋め込みを生成するためのループを作成します。このシステムの外側のループは、訓練データセットの軌跡を繰り返し処理します。内側のループは、エージェントが軌道を通過する間に訪れた環境状態を繰り返し処理します。

   int state = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);

ループの本体では、訓練サンプルから環境の特定の状態を記述するベクトルを読み込みます。口座状況と未決済ポジションの説明を補足します。

         float PrevBalance = Buffer[tr].States[MathMax(st - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st - 1, 0)].account[1];
         State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[2]);
         State.Add(Buffer[tr].States[st].account[3]);
         State.Add(Buffer[tr].States[st].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[6] / PrevBalance);

 ここでは、タイムスタンプのハーモニクスをバッファに追加します。

         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(vector<float>::Zeros(NActions));

エージェントが取った行動に関係なく状態を評価するために、バッファの残りにゼロ値を書き入れます。

ソースデータバッファを正常に満たした後、ランダムエンコーダのフィードフォワードパスメソッドを呼び出します。

         if(!Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

その結果を埋め込み行列に保存します。

         Convolution.getResults(temp);
         if(!state_embedding.Row(temp, state))
            continue;

同時に、完了した行動や、その後のトランジションの結果受け取った報酬も保存します。

         if(!temp.Assign(Buffer[tr].States[st].action) ||
            !actions.Row(temp, state))
            continue;
         if(!temp.Assign(Buffer[tr].States[st].rewards) ||
            !next.Assign(Buffer[tr].States[st + 1].rewards) ||
            !rewards.Row(temp - next * DiscFactor, state))
            continue;

すべてのエンティティをローカル行列に追加できたら、処理された状態のカウンタをインクリメントします。状態の埋め込み処理の進捗状況をユーザーに知らせ、ループの次の反復に移ります。

         state++;
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

ループのすべての反復が正常に完了した後、必要であればローカル行列のサイズを、実際に使用されるデータのサイズに合わせて調整します。

   if(state != total_states)
     {
      rewards.Resize(state, NRewards);
      actions.Resize(state, NActions);
      state_embedding.Reshape(state, state_embedding.Cols());
      total_states = state;
     }

そして、次の準備作業に移ります。この準備作業では、いくつかのローカル変数を用意し、モデル訓練過程において訓練データセットから軌跡をサンプリングする優先度を決定します。

   vector<float> rewards1, rewards2, target_reward;
   STarget target;
   int bar = (HistoryBars - 1) * BarDescr;
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

この時点で準備作業は完了し、そのままモデルの訓練に移ります。そのために、EAの外部パラメータで指定された反復回数で訓練ループを作成します。

ループの本体では、優先度を考慮して軌道をサンプリングし、その軌道上の状態をランダムに選択します。

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
        {
         iter--;
         continue;
        }

次に、SAC法に従って、エピソード終了までの期待報酬を計算する必要があります。そのために、Criticの目標モデルを使用します。ただし、これらの操作は、事前に訓練されたモデルのみを使用して実行します。そのため、操作を開始する前に、最低限必要な予備訓練の反復回数が終了しているかどうかを確認します。

      target_reward = vector<float>::Zeros(NRewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         State.AssignArray(Buffer[tr].States[i + 1].state);

制御の受け渡しに成功したら、初期データバッファに、その後の環境の状態を記述します。

これとは別に、口座のステータスと未決済ポジションを記述したバッファを作成します。

         float PrevBalance = Buffer[tr].States[i].account[0];
         float PrevEquity = Buffer[tr].States[i].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i + 1].account[2]);
         Account.Add(Buffer[tr].States[i + 1].account[3]);
         Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);

また、同じバッファにタイムスタンプハーモニックスも追加します。

         double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         //---
         if(Account.GetIndex() >= 0)
            Account.BufferWrite();

収集されたデータは、Actorのフィードフォワードパスを完成させるのに十分です。

         if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

次のような環境の状態で、学習中のActorモデルに対してフィードフォワードパスメソッドを呼び出しています。これにより、更新された方策に従ってActorの行動が生成されます。従って、目標Criticは、エピソードが終わるまで、更新された方策から期待される報酬を評価します。

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

対象となる2つのCriticから受けたスコアの最小値を、その後の作戦における期待値として使用します。

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         target_reward.Assign(Buffer[tr].States[i + 1].rewards);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1 - target_reward;
         else
            target_reward = rewards2 - target_reward;
         target_reward *= DiscFactor;
         target_reward[NRewards - 1] = EntropyLatentState(Actor);
        }

次のステップでは、Criticを養成します。その評価の正しさを保証するために、訓練は実際の行動と訓練データセットからの報酬の比較に基づいています。私たちのモデルでは、Actorを使用して環境の状態を前処理していることを思い出してください。したがって、前回と同様に、サンプリングされた環境の状態を記述した初期データバッファを入力します。

      //--- Q-function study
      State.AssignArray(Buffer[tr].States[i].state);

口座状況と未決済ポジションをバッファに書き入れます。

      float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
      float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
      Account.Clear();
      Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
      Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
      Account.Add(Buffer[tr].States[i].account[2]);
      Account.Add(Buffer[tr].States[i].account[3]);
      Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
      Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);

タイムスタンプの状態を追加します。

      double x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1);
      Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

次に、Actorのフィードフォワードパスを実行します。

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

この段階では、オートエンコーダのフィードフォワードパスを実行するためのデータ一式が揃っています。今できることは後回しにしません。そこで、エンコーダとデコーダのフィードフォワードメソッドを呼び出すことにします。

      if(!Encoder.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CNet *)GetPointer(Actor)) ||
         !Decoder.feedForward(GetPointer(Encoder), -1, GetPointer(Encoder), 1))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

上述したように、Criticは訓練データセットに含まれるActorの実際の行動に対して訓練されます。そこで、それらをデータバッファに読み込み、両方のCriticのフィードフォワードメソッドを呼び出します。

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

次に、現在の状態記述バッファに必要なデータを補い、ランダムエンコーダを使用して分析された状態の埋め込みを実行します。

      if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<float>::Zeros(NActions)) ||
         !Convolution.feedForward((CBufferFloat *)GetPointer(State), 1, false, (CBufferFloat *)NULL))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

埋め込み結果に基づいて、ActorとCriticsの目標値を生成します。

      Convolution.getResults(temp);
      target = GetTargets(Quant, temp, state_embedding, rewards, actions);

その後、Criticsパラメータを更新します。前に見たように、CAGradメソッドはモデルの収束を改善するために勾配ベクトルを調整するために使用されます。

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Criticモデルを更新できたら、Actor方策の最適化に移ります。このプロセスは3つのブロックに分けられます。最初のブロックでは、類似した状態で実行された訓練データセットの行動から収集され、受け取った報酬によって重み付けされた特定の行動を繰り返すようにエージェントの方策を調整します。

      //--- Policy study
      Actor.getResults(rewards1);
      Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1);
      if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

2番目の段階では、オートエンコーダの結果を使用し、生成されたエージェントの行動が訓練データからどの程度ずれているかを確認します。Actionのデコーディングの誤差が閾値を超えた場合、Actorの方策を訓練データセットの分布に戻そうとします。そのために、オートエンコーダのバックプロパゲーションパスを実行し、エンコード誤差は、Criticから誤差勾配を渡すのと同様に、誤差勾配としてActorに直接渡されます。エンコーダとデコーダの学習モードをプログラム初期化の段階で無効にしたのは、この操作を安全に実行するためです。

      Decoder.getResults(rewards2);
      if(rewards2.Loss(rewards1, LOSS_MSE) > MeanCVAEError)
        {
         Actions.AssignArray(rewards1);
         if(!Decoder.backProp(GetPointer(Actions), GetPointer(Encoder), 1) ||
            !Encoder.backPropGradient((CNet*)GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
        }

Actorの方策を訓練する次の段階で、Criticの予測の信頼性を確認します。予測が十分に信頼できるものであれば、Actorの方策を、最も可能性の高い最大報酬に向けて調整します。この段階では、モデルの相互適応の影響を避けるために、Criticパラメータ更新モードも無効にします。 

      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);
      if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy)
        {
         if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         critic.getResults(rewards1);
         Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
         critic.TrainMode(false);
         if(!critic.backProp(Result, GetPointer(Actor)) ||
            !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            critic.TrainMode(true);
            break;
           }
         critic.TrainMode(true);
        }

次に、対象となるCriticモデルを更新する必要があります。

      //--- Update Target Nets
      if(iter >= StartTargetIter)
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
        }
      else
        {
         TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1);
         TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1);
        }

また、学習過程の進捗状況をユーザーに知らせる必要もあります。

      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", iter * 100.0 / (double)(Iterations),
                                                                                    Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", iter * 100.0 / (double)(Iterations), 
                                                                                    Critic2.getRecentAverageError());
         str += StringFormat("%-14s %5.2f%% -> Error %15.8f\n", "Actor", iter * 100.0 / (double)(Iterations), 
                                                                                      Actor.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

モデル訓練サイクルのすべての反復が完了したら、チャートのコメントフィールドを消去します。また、モデルの訓練結果に関する情報をログに出力し、EAの終了を開始します。

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

これで、MQL5を使用したSupported Policy Optimization手法の実装に関する研究は終了となります。この記事で使用されているすべてのプログラムの完全なコードを添付ファイルでご覧ください。さて、次は実際のケースを使用して結果を確認します。


3.検証

MQL5ツールを使用してSupported Policy OpTimization (SPOT)法を実装しました。次に、作業の結果を実際にテストします。いつものことですが、この研究はメソッドの著者たちが提案した方法に対する私自身の見解を示したものであることにご注意ください。さらに、それらは他の手法で以前に作られた開発と重ね合わされています。その結果、プロセスに対する私のビジョンによって集められた様々なアイデアの集合体としてモデルを作り上げました。従って、観察される可能性のあるすべての欠点を、使用された手法のどれかに完全に投影することはできません。

前回と同様、モデルはEURUSD H1の履歴データを使用して訓練およびテストされます。すべての指標はデフォルトのパラメータで使用されています。モデルは2023年の最初の7ヶ月間のデータを使用して訓練されています。訓練済みモデルをテストするために、2023年8月からの履歴データを使用します。

前述したように、環境との相互作用のモデルはそのまま使用されます。したがって、訓練の最初の段階では、モデルのドナーとしてReal-ORL稿の一部として収集された訓練データセットを使用することができます。訓練データセットをコピーし、SPOT.bdとして保存しました。

最初の段階では、オートエンコーダを訓練します。訓練データセットには、それぞれ3591の環境状態を持つ500の軌跡が含まれています。これは合計で約180万セットの「状態-行動-報酬」に相当します。5つのオートエンコーダ訓練ループを実行し、それぞれ50万回の反復をおこないました。

オートエンコーダの初期訓練後、EA「...\SPOT\Study.mq5」でモデル訓練処理を開始します。モデルの訓練時間は、オートエンコーダの訓練時間を大幅に上回っています。

また、エージェントの方策を訓練データセット内に留めておくと、訓練データセットのパスよりも優れた結果を得る望みがなくなることにも注意すべきです。したがって、より最適な方策を得るためには、経験再生バッファを繰り返し更新し、オートエンコーダを含むモデルを更新する必要があります。

そこで、モデルの訓練過程と並行して、ストラテジーテスターでResearchExORL.mq5 EAの最適化を実行し、訓練セット以外のストラテジーを研究します。

モデルの訓練ループが完了した後、学習されたActor方策のある環境で環境を探索する「Research.mq5」EAの200パスの最適化を実行します。

更新された訓練セットに基づき、オートエンコーダの訓練を50万回繰り返します。その後、Actor方策の下流の訓練をおこないます。

何回かの訓練ループの結果、訓練期間とテスト期間中に利益を生み出せるActor方策を訓練することができました。2023年8月のモデル結果を以下に示します。

検査結果

検査結果

提示されたデータからわかるように、このストラテジーをテストした1ヶ月間、モデルは124回(ショート92回、ロング32回)の取引をおこないました。そのうち47%近くが黒字決算でした。注目すべきは、利益を上げているロングポジションとショートポジションの割合が拮抗していることです(それぞれ50%と46%)。さらに、平均利益率は平均損失率を25%上回っています。最大利益の取引は、最大損失のほぼ2倍です。一般的に、取引結果に基づくと、プロフィットファクターは1.15でした。


結論

この記事では、Supported Policy OpTimization (SPOT)法を紹介しました。これは、訓練データセットが限られている条件下でのオフライン学習の問題に対して有効な解決策です。推定された行動戦略密度をもとに方策を調整するその能力は、標準的なテストシナリオで優れたパフォーマンスを示しています。SPOTは、既存のオフラインRLアルゴリズムに簡単に統合でき、さまざまな文脈に柔軟に適用できます。モジュール構造になっているため、さまざまな学習方法で使用できます。

SPOTのユニークな特徴は、訓練セットデータの密度の明示的な推定に基づく正則化の使用です。これにより、許容可能な方策行動の正確な制御が可能になり、訓練データセットを超える外挿が効果的に防止されます。

実用的な部分では、MQL5を使用して提案された方法のビジョンを実装しました。テスト結果に基づき、この手法の有効性について結論を出すことができます。訓練の過程では、プロセスの安定性にも注目することができます。訓練の結果に基づき、Actorの行動に対する有益な戦略を見つけることができました。

ただし、Actorの方策を訓練データセット内に留めておくと、その外での研究の刺激が制限されることに注意してください。一方で、これは学習過程をより安定させます。その一方で、環境の未知の部分空間を探索する可能性が制限されます。このことから、この手法を最も効果的に使用できるのは、訓練データセットに最適なパスがない場合であると結論づけることができます。

同時に、環境の探索を刺激するために、この手法を反転させ、訓練データセット外の行動の研究を刺激してみることもできますが、これは今後の研究課題です。


参照文献

  • Supported Policy Optimization for Offline Reinforcement Learning
  • ニューラルネットワークが簡単に(第67回):過去の経験を新しい問題の解決に生かす

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

    # ファイル名 種類 詳細
    1 Research.mq5 EA コレクションEAの例
    2 ResearchRealORL.mq5
    EA
    Real-ORL法による事例収集のためのEA
    3 ResearchExORL.mq5 EA ExORL法による事例収集のためのEA
    4 Study.mq5  EA エージェント訓練EA
    5 StudyCVAE.mq5 EA
    オートエンコーダ学習EA
    6 Test.mq5 EA モデルをテストするEA
    7 Trajectory.mqh クラスライブラリ システム状態記述の構造体
    8 NeuroNet.mqh クラスライブラリ ニューラルネットワークを作成するためのクラスのライブラリ
    9 NeuroNet.cl コードベース OpenCLプログラムコードライブラリ


    MetaQuotes Ltdによってロシア語から翻訳されました。
    元の記事: https://www.mql5.com/ru/articles/13954

    添付されたファイル |
    MQL5.zip (653.77 KB)
    初心者からプロまでMQL5をマスターする(第2回):基本的なデータ型と変数の使用 初心者からプロまでMQL5をマスターする(第2回):基本的なデータ型と変数の使用
    初心者向け連載の続きです。この記事では、定数や変数を作成する方法、日付や色、その他の便利なデータを書き込む方法を見ていきます。曜日や線のスタイル(実線、点線など)を列挙する方法も学びます。変数と式はプログラミングの基本です。これらは99%のプログラムに間違いなく存在するので、理解することは非常に重要です。したがって、この記事はとてもプログラミング初心者の役に立つでしょう。必要なプログラミング知識レベル:前回の記事(冒頭のリンク参照)の範囲内で、ごく基本的なものです。
    Candlestick Trend Constraintモデルの構築(第2回):ネイティブ指標の結合 Candlestick Trend Constraintモデルの構築(第2回):ネイティブ指標の結合
    この記事では、トレンドから外れたシグナルを選別するために、MetaTrader 5指標を活用することに焦点を当てます。前回に引き続き、MQL5コードを使用してアイデアを最終的なプログラムに伝える方法を探っていきます。
    ニューラルネットワークが簡単に(第70回):閉形式方策改善演算子(CFPI) ニューラルネットワークが簡単に(第70回):閉形式方策改善演算子(CFPI)
    この記事では、閉形式の方策改善演算子を使用して、オフラインモードでエージェントの行動を最適化するアルゴリズムを紹介します。
    カスタム指標(第1回):MQL5でシンプルなカスタム指標を開発するためのステップバイステップ入門ガイド カスタム指標(第1回):MQL5でシンプルなカスタム指標を開発するためのステップバイステップ入門ガイド
    MQL5を使用してカスタム指標を作成する方法を紹介します。この入門記事では、シンプルなカスタム指標を構築するための基本を説明し、この興味深いトピックを初めて学ぶMQL5プログラマーのために、さまざまなカスタム指標をコーディングするための実践的なアプローチを示します。