English Русский 中文 Español Deutsch Português
preview
ニューラルネットワークが簡単に(第32部):分散型Q学習

ニューラルネットワークが簡単に(第32部):分散型Q学習

MetaTrader 5トレーディングシステム | 16 1月 2023, 13:21
419 0
Dmitriy Gizlyk
Dmitriy Gizlyk

はじめに

Q学習法については、「ニューラルネットワークが簡単に(第27回):ディープQ学習(DQN)」で紹介しました。その記事では、報酬の状態や取った行動への依存性を表すQ関数を近似しました。ただし、問題は、現実の世界が多面的であることです。現状を把握する際、必ずしもすべての影響要因を考慮できるわけではありません。したがって、システムの状態を記述する推定パラメータと、実行された行動、報酬の間には直接的な関係はありません。Q関数近似の結果、期待報酬の最確値を平均化した値しか得られません。この過程では、モデルの訓練過程で受け取った報酬の分布の全体像を見ることはできません。また、平均値は、著しいシャープな外れ値によって歪む可能性があります。2017年に2つの記事が公開されました。著者らは、受け取った報酬の値の分布を研究するアルゴリズムを提案しました。両論文ともが、アタリ社のコンピュータゲームにおいて、古典的なQ学習の結果を大幅に改善することに成功しました。


1.分散型Q学習の特徴

分散型Q学習は、本来のQ学習と同様に、行動効用関数を近似します。ここでも、期待報酬を予測するためのQ関数を近似的に求めます。主な違いは、特定の状態において完了した行動に対する単一の報酬値を近似するのではなく、期待される報酬の確率分布全体を近似する点です。もちろん、リソースに限りがあるため、個々の報酬値の発生確率を推定することはできませんが、可能な報酬の範囲を複数の範囲、すなわち分位数に分割することができます。

分位数を決定するために、追加のパラメータを導入しています。期待される報酬の範囲内の最小値(Vmin)と最大値(Vmax)、および分位数(N)です。1つの分位数に対する値の範囲は、以下の式で算出されます。

本来のQ学習が自然な報酬値の近似を意味していたのとは異なり、分散型Q学習アルゴリズムは、特定の状態で特定の行動を取ったときに報酬を受け取る確率分布を分位数内で近似するものです。問題を確率分布タスクに変換することで、Q関数の近似問題を標準的な分類問題に変換することができます。これは、損失関数の変更につながります。本来のQ学習は損失関数として標準偏差を用いますが、分散型Q学習ではLogLossを用いることになります。この関数については、以前、方策勾配の研究をしたときに考察しています。

LogLoss

こうすることで、各状態と行動のペアに対する報酬の確率分布を近似的に求めることができます。そのため、行動を選択する際に、より高い精度で期待報酬とその確率を決定することができるのです。また、平均的な報酬ではなく、特定の報酬レベルの確率を推定することができるのも利点です。これにより、システムの現在の状態から行動を実行した後にプラスとマイナスの報酬を受け取る確率を評価する際に、リスクに基づいた方法を使用することができます。

同じような状況から同じ行動をとった結果、環境からプラスとマイナスの両方の報酬が返ってきたときに、最大の効果が得られます。期待報酬の平均化を用いたオリジナルのQ学習アルゴリズムでは、このような場合、ほとんどの場合、0に近い値が得られます。その結果、行動はスキップされます。分散型Q学習アルゴリズムを利用する場合、実報酬を受け取る確率を評価することができます。リスクに基づいた方法を用いることで、正しい判断ができるようになります。

エージェントが可能な行動のいずれかをとると、環境は確実に報酬を与えることに注意してください。したがって、現在の環境の状態からとられるエージェントの行動に対して、100%の確率で報酬を受け取ることが期待されます。各エージェントの行動に関する確率の合計は1になるはずです。この結果は、可能な行動の観点からSoftMax関数を使用することで実現できます。

元のQ学習アルゴリズムのツールはすべてそのまま使用します。その中には、経験再生バッファや、将来の報酬を予測するターゲットネットモデルも含まれています。当然、将来の報酬については割引率を使用することになります。

モデルの訓練は、オリジナルのQ学習の原理に基づいています。この過程自体は、ベルマン方程式に基づいています。

ベルマン方程式

前述のように、訓練中のモデルの「凍結」コピーですターゲットネットを用いて、将来の報酬の予測値を評価することになります。その使用法について、説明したいと思います。

強化学習やQ学習の特徴のひとつは、最良の結果を得るための行動戦略を構築できることです。戦略構築を可能にするために、ベルマン方程式には未来の状態の値が含まれています。実際、環境の将来の状態の評価には、その状態からセッション終了までの最大限の報酬が含まれるはずです。この指標がなければ、モデルは現在の新しい状態への遷移に対する期待報酬を予測するようにしか訓練されないでしょうが、

その過程を反対側から見てみましょう。本当の意味での完全な報酬はセッションが終わるまでありません。そこで、2つ目のニューラルネットワークを使って、欠損データを予測します。2つのモデルを並行して訓練するのを避けるため、重みを凍結した訓練可能なモデルのコピーを使用して、将来の状態から報酬を予測します。訓練していないモデルからの予測は正確でしょうか。ほとんどの場合、完全にランダムなものになるでしょうが、訓練モデルのターゲットにランダムな値を導入することで、環境の認識を歪め、訓練を誤った方向に導いてしまうのです。

初期段階でターゲットネットの使用を除外することで、現在の遷移に対する報酬をある程度正確に予測するモデルを訓練することができます。このモデルでは戦略を立てられないでしょうが、これは学習の第一段階に過ぎません。一歩先まで合理的に予測できるモデルがあれば、それをターゲットネットとして使うことができます。その後、2歩先の戦略を構築するためのモデルを追加で訓練することができます。

ターゲットネットの段階的な更新と、合理的な将来状態の予測値を用いたこの方法により、モデルは正しい戦略を構築することができるようになります。こうすることで、目的の結果を得ることができるのです。

将来の報酬の価値に対する割引率について、もう少し補足しておきたいと思います。これは、戦略構築におけるモデル先見性を管理するためのツールです。このハイパーパラメータは、構築される戦略の種類に大きく影響します。1に近い係数を使用すると、モデルはロング戦略を構築するように指示されます。この場合、モデルは長期投資のための戦略を構築することになります。

逆に、このパラメータが減少し、値が0に近くなると、モデルは将来の報酬を忘れ、短期的に利益を上げることに注意を払わざるを得なくなります。そこで、モデルはスキャルピング戦略を構築することになります。もちろん、ポジション保持時間は使用する時間枠に影響されます。

以上を整理してみます。

  1. 分散型Q学習法は、古典的なQ学習を基とし、それを補完するものである。
  2. モデルとしてニューラルネットワークが使用される。
  3. 訓練の過程では、状態と行動のペアに応じて、新しい状態への遷移に伴う期待報酬の確率分布を近似的に決定する。
  4. 分布は、固定された報酬範囲の分位数で表される。
  5. 分位数の数と取り得る値の範囲はハイパーパラメータで決定される。
  6. それぞれの可能な行動に対する分布は、同じ確率ベクトルで表される。
  7. 確率分布を正規化するために、個々の行動のコンテキストでSoftMax関数を使用する。
  8. モデルはベルマン方程式に基づいて訓練される。
  9. 確率論法で問題を解くには、損失関数としてLogLossを使用する必要がある。
  10. 学習過程を安定化させるために、オリジナルのQ学習アルゴリズムのヒューリスティック(ターゲットネット、経験再生バッファ)を使用する。

いつものように、理論的な部分に続いて、MQL5を使った方法の実践的な実装がおこなわれます。


2.MQL5を使用した実装

MQL5を使った分散型Q学習法の実装に進む前に、作業計画を立ててみましょう。理論部で述べたように、この手法はオリジナルのQ学習アルゴリズムに基づいています。このアルゴリズムは以前にも実装したことがあります。したがって、以前に使用したものを基にエキスパートアドバイザー(EA)を作成することができます。

確率論的手法を用いるには、モデルの目標値を送信するブロックを変更する必要があります。

モデルの出力では、SoftMax関数を用いてデータを正規化する必要があります。この機能については、すでに方策勾配記事で紹介し、実装しています。その際、確率の正規化もおこないました。その際に、行動を選択する確率を使いました。データはニューラル層全体で正規化しました。ここで、各行動の分布の確率を個別に正規化する必要があります。つまり、先に作成したCNeuronSoftMaxOCLクラスをそのままの形で使用することはできません。

ある選択肢は、新しいクラスを作成するか、既存のクラスを変更することです。2番目を使用することにしました。以前作成したクラスの構成は以下の通りです。

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }

public:
                     CNeuronSoftMaxOCL(void) {};
                    ~CNeuronSoftMaxOCL(void) {};
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);   
   virtual bool      calcOutputGradients(CArrayFloat *Target, float& error) override;
   //---
   virtual int       Type(void) override  const   {  return defNeuronSoftMaxOCL; }
  };

まず、正規化可能なベクトルの数iHeadsを格納する変数と、このパラメータを指定するメソッドSetHeadsを追加します。デフォルトでは、1ベクトルを指定します。これは、層全体のデータの正規化に相当します。

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;
.........
.........
public:
                     CNeuronSoftMaxOCL(void) : iHeads(1) {};
                    ~CNeuronSoftMaxOCL(void) {};
.........
.........
   virtual void      SetHeads(int heads)  { iHeads = heads; }
.........
.........
  };

ご存知のように、新しい変数を追加しても、クラスメソッドのロジックは変わりません。次に、メソッドのアルゴリズムを修正します。私たちが興味があるのは主にフィードフォワードとバックプロパゲーションの方法です。フィードフォワードパスはfeedForwardメソッドに実装されています。このメソッドは、OpenCLプログラムの対応するカーネルを呼び出すための補助的なアルゴリズムを実装しているに過ぎないことに注意してください。すべての計算はOpenCLのコンテキスト側で、マルチスレッドモードでおこなわれます。そのため、カーネルの実行キューへの配置に関する操作を変更する前に、OpenCL側のプログラムを変更する必要があります。

考えてみましょう。SoftMax関数の具体的な特徴は、結果ベクトル全体の和が1になるようにデータを正規化することです。この関数の数式は次のようになります。

SoftMax

ご覧の通り、データはソースデータベクトル全体の指数値の合計を使用して正規化されています。ローカルデータ配列を使って、同じカーネル内の別々のスレッド間でデータを転送します。これにより、OpenCLコンテキスト側で関数のマルチスレッド実装を作成することができます。今回作成したアルゴリズムは、1次元の問題空間で実行されます。1つのベクトル内のデータを正規化するものです。新しいアルゴリズムの問題点を解決するためには、初期データの全容をいくつかの等しい部分に分割し、それぞれの部分を別々に正規化する必要があります。ここで難しいのは、そのような部分の数がわからないことですが、

良い面もあります。個々のブロックは、それぞれ独立して正規化することができます。これは、私たちが考えるマルチスレッドコンピューティングに完全に適合しています。分散データの正規化には、先に作成したカーネルのインスタンスを追加で実行します

ソースデータバッファと結果バッファの総量を対応するブロックに振り分けるだけでよいのです。前回は、1次元のタスク空間でカーネルを起動しました。OpenCLの技術は、3次元のタスク空間の利用を可能にします。この場合、3次元は必要ありません。とにかく、2次元目で正規化ブロックを特定すればいいのです。

このように、タスク空間の次元をもう1つ増やすことで、先に作成したSoftMax_FeedForwardクラスの分散正規化を可能にします。まだカーネルのコードに変更を加える必要がありますが、これらの変化は小さなものにとどまるでしょう。カーネルアルゴリズムに、2次元目のタスク空間の処理を追加する必要があります。

カーネルパラメータは変更されません。パラメータには、データバッファへのポインタと、データ正規化ベクトル1個のサイズを渡します。

__kernel void SoftMax_FeedForward(__global float *inputs,
                                  __global float *outputs,
                                  const uint total)
  {
   uint i = (uint)get_global_id(0);
   uint l = (uint)get_local_id(0);
   uint h = (uint)get_global_id(1);
   uint ls = min((uint)get_local_size(0), (uint)256);
   uint shift_head = h * total;

カーネル本体では、スレッドIDを両次元でリクエストします。これらは、現在のスレッドの作業量と、処理中の要素へのデータバッファのオフセットを定義します。1次元目は、データ正規化アルゴリズムにおいてスレッドがどの位置にあるかを示します。2次元目によって、データバッファのオフセットを特定します。上のコードで、追加された行を強調表示してみました。

次に、カーネルアルゴリズムは、初期データの指数値を合計する第1段のループを持ちます。正規化されるソースデータブロックの最初の要素にジャンプするための調整を追加します(コード内で強調表示)。

グローバルソースデータバッファのオフセットのみを使用していることに注意してください。ローカルデータ配列の場合は無視します。これは、各ワークグループが孤立して動作し、独自のローカルデータ配列を使用するためです。

   __local float temp[256];
   uint count = 0;
   if(l < 256)
      do
        {
         uint shift = shift_head + count * ls + l;
         temp[l] = (count > 0 ? temp[l] : 0) + (shift < ((h + 1) * total) ? exp(inputs[shift]) : 0);
         count++;
        }
      while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);

前のブロックでは、ローカル配列の要素に全体の一部を集めました。この後、ローカル配列の値の総和を連結するループが続きます。ここでは、ローカル配列のみを扱います。この処理は、タスクスペースの2次元目とは全く無関係であり、変化しません。

   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(l < 256)
         temp[l] += (l < count && (l + count) < total ? temp[l + count] : 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   float sum = temp[0];

カーネルの最後に、初期データを正規化し、その結果の値を結果バッファに保存します。ここでは、最初のループと同様に、グローバルデータバッファで事前に計算したオフセットを使用します。

   if(sum != 0)
     {
      count = 0;
      while((count * ls + l) < total)
        {
         uint shift = shift_head + count * ls + l;
         if(shift < ((h + 1) * total))
            outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f);
         count++;
        }
     }
  }

前の層SoftMax_HiddenGradientに対して勾配分布のあるカーネルに変更を加える際にも、同様の方法を用います。カーネルの一般的なアルゴリズムを変更することなく、グローバルデータバッファにオフセットを追加します。

__kernel void SoftMax_HiddenGradient(__global float* outputs,
                                     __global float* output_gr,
                                     __global float* input_gr)
  {
   size_t i = get_global_id(0);
   size_t outputs_total = get_global_size(0);
   size_t h = get_global_id(1);
   uint shift = h * outputs_total;
   float output = outputs[shift + i];
   float result = 0;
   for(int j = 0; j < outputs_total ; j++)
      result += outputs[shift + j] * output_gr[shift + j] * ((float)(i == j) - output);
   input_gr[shift + i] = result;
  }

基準分布からの偏差を決定するSoftMax_OutputGradientカーネルは変更する必要がありません。このカーネルにおけるオフセットは、特定の要素がどのブロックの一部かに関係なく、シーケンス内の特定の要素に対して決定されるからです。

__kernel void SoftMax_OutputGradient(__global float* outputs,
                                     __global float* targets,
                                     __global float* output_gr)
  {
   size_t i = get_global_id(0);
   output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
  }

これでOpenCLプログラム側の動作は完了です。CNeuronSoftMaxOCLクラスのコードに戻りましょう。まず、フィードフォワードカーネルの変更から始めました。同様に、クラスのメソッドにも変更を加えてみましょう。

カーネルのパラメータの追加や変更は行っていません。したがって、データ準備のアルゴリズムとカーネルコールは変更されません。変更点は、タスクスペースの指定方法のみです。

まず、1つのデータ正規化ベクトルの次元を定義します。これは、結果バッファサイズを正規化するベクトル数で割るだけで簡単に求めることができます。その結果の値をローカル変数sizeに保存します。ここでは、グローバルタスク空間のglobal_work_size配列も埋めています。1次元目には、上記で算出した正規化ベクトル1個のサイズを示します。また、2次元目には、そのようなベクトルの個数を示します。

スレッドの同期やスレッド間のデータ交換を可能にするため、あらかじめグローバルタスク空間と同等のワーキンググループを作成しています。これは、データバッファ全体の中でデータを正規化したためです。今は少し状況が変わってきています。データバッファ内の複数の個別ブロックを正規化する必要があります。フィードフォワードカーネルを構築する際、ローカルデータ配列の作業がそのまま残っていることに気がつきました。これは、各ベクトルの正規化を別のワーキンググループに分離することを計画したことで実現しました。そこで、この場合は、ローカルグループのタスク空間local_work_size用に別の配列を作成する必要があります。

グローバルタスク空間とローカルタスク空間の次元は同じでなければなりません。そのため、2次元のローカルタスク空間を定義する必要があります。グローバルスレッドの数は、個々のタスク空間の次元におけるローカルスレッドの数の倍数でなければならなりません。

前回は、1次元目の正規化可能なベクトルと2次元目のそのベクトル数でグローバルな質問空間を指定しました。各ワーキンググループでは、1つのベクトルだけを正規化する予定です。論理的には、ローカルタスク空間の1次元目の正規化可能なベクトル1個の大きさを示せばよくなります。2次元に1を表示することにします。これは1つのベクトルに相当します。

以下は、feedForwardメソッドの修正コードです。すべての変更点が強調表示されています。ご覧の通り、変更点はそれほど多くありませんが、すべてのポイントを考慮することは非常に重要です。

bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = { size, iHeads };
   uint local_work_size[2] = { size, 1 };
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
   OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
   if(!OpenCL.Execute(def_k_SoftMax_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

誤差勾配を前の層に伝搬させるメソッド(calcInputGradients)にも同様の変更が加えられていますが、この場合はワーキンググループを作りませんでした。

bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = {size, iHeads};
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
   if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

分散正規化の追加は設計上の特徴であり、ファイル操作方法に反映させる必要があります。続いて、CNeuronSoftMaxOCLクラスについて説明します。このクラスのファイルメソッドは、これまで作成していません。親クラスの類似メソッドの機能で十分でした。ただし、オブジェクトの動作を正しく回復するために値を保存する必要がある新しい変数を追加した場合、そのようなメソッドを再定義する必要があります。

いつものように、データ保存メソッドSaveから始めます。そのアルゴリズムは非常に単純です。このメソッドは、データを書き込むためのファイルハンドルをパラメータとして受け取ります。通常、このようなメソッドは、受け取ったハンドルが正しいかどうかを確認することから始まります。コントロールのブロックは作りません。代わりに、親クラスの同様のメソッドを呼び出し、受け取ったハンドルを渡します。このメソッドでは、1行のコードで2つのタスクを解決することになります。必要な制御はすべて親クラスのメソッドに実装済みです。つまり、それが制御機能を果たすということです。さらに、すべての継承されたオブジェクトと変数の保存を実装しています。そのため、データ保存機能も実行されます。親クラスのメソッドの結果を確認するだけで、指定した機能の実行状態を知ることができるのです。

親クラスのメソッドの実行が成功したら、新しい変数の値を保存して、メソッドを完了します。

bool CNeuronSoftMaxOCL::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iHeads) <= 0)
      return false;
//---
   return true;
  }

データ読み取りメソッド(CNeuronSoftMaxOCL)も同様の操作順序をとり、さらに、正規化可能なメソッドの最小数を制御します。

bool CNeuronSoftMaxOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   iHeads = (uint)FileReadInteger(file_handle);
   if(iHeads <= 0)
      iHeads = 1;
//---
   return true;
  }

以上で、CNeuronSoftMaxOCLクラスに関する作業を終了します。後は、正規化するベクトルの数をユーザーが指定できるようにするだけです。ニューラル層記述オブジェクトには変更を加えません。stepパラメータで正規化するベクトルの数を指定することにします。ニューラルネットワーク初期化メソッド(CNet::Create)において、SoftMax層の作成時に、作成されたCNeuronSoftMaxOCLクラスインスタンスに指定したパラメータを渡すことにします。変更点は以下のコードで強調表示されています。

void CNet::Create(CArrayObj *Description)
  {
.........
.........
//---
   for(int i = 0; i < total; i++)
     {
.........
.........
      if(!!opencl)
        {
.........
.........
         CNeuronSoftMaxOCL *softmax = NULL;
         switch(desc.type)
           {
.........
.........
            case defNeuronSoftMaxOCL:
               softmax = new CNeuronSoftMaxOCL();
               if(!softmax)
                 {
                  delete temp;
                  return;
                 }
               if(!softmax.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax.SetHeads(desc.step);
               if(!temp.Add(softmax))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax = NULL;
               break;
.........
.........
           }
        }
.........
.........
//---
   return;
  }

このメソッドを実装するために、ニューラルネットワークのアーキテクチャに他の変更を加える必要はありません。

モデル学習過程は、「DistQ-learning.mq5」EAに実装されています。EAは、オリジナルのQ学習法でモデルを訓練したQ-learning.mq5 EAを基に作成されています。

分散型Q学習アルゴリズムによれば、期待される報酬の範囲と確率分布の分位数を決定するハイパーパラメータを追加で導入する必要があります。

今回の実装案では、この問題を別の角度からアプローチしてみました。これまでのテストと同様に、モデルを作成するために NetCreatorツールを使ってモデルを作成します。分位数は、モデル演算結果を持つ層の大きさに応じて決定されます。これは、EAのActionパラメータで指定された可能な行動の数を考慮したものです。

int                  Actions     =  3; 

学習過程では、環境からの特定の報酬値とある分位値を一致させる必要があります。次のような前提で考えてみましょう。私たちが開発した報酬方針によると、プラスの報酬とマイナスの報酬の両方があり得ます。これらは、報酬と罰則と呼ぶことができます。ベクトルの中央値が報酬ゼロに相当すると仮定します。分位の大きさを物理的な報酬額で測定するために、外部パラメータStepを導入します。

input double               Step = 5e-4;

EAの他の外部パラメータは変更されません。

EA初期化関数OnInitでは、モデルの読み込みに成功した後、モデル出力のニューラル層のサイズと中央値の分位数で分位数を決定しています。

int OnInit()
  {
.........
.........
//---
   float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
      return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   if(!StudyNet.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= 0)
      return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + 1) / 2;
//---
.........
.........
//---
   return(INIT_SUCCEEDED);
  }

次に、モデル訓練関数に移ります。訓練用サンプルのデータを一切変更しないため、データ準備ブロックに変更はありません。この変更は、期待報酬を予測するための目標結果を示すブロックにのみ影響します。

まず、将来の状態コストを予測したベクトルを用意しよう。このベクトルには3つの要素が含まれ、各行動に1つの値が設定されます。ベクトルの演算を使って、ベクトルの値を計算します。まず、結果バッファターゲットネットを行行列に転送します。その後、この行列を3行の表にして、各行が1つの行動に対応するようにします。各行で、最大の確率を持つ要素を見つけます。最大要素の分位数を自然な報酬表現に変換します。

void Train(void)
  {
//---
.........
.........
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
.........
.........
      for(int batch = 0; batch < (Batch * UpdateTarget); batch++)
        {
.........
.........
//---
         vectorf add = vectorf::Zeros(Actions); 
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), 12, true))
               return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(1, temp.Size());
            if(!target.Row(temp, 0) || !target.Reshape(Actions, action_dist))
               return;
            add = DiscountFactor * (target.ArgMax(1) - action_midle) * Step;
           }

未来の状態の予測値を決めたら、モデルの目標値のバッファを用意します。まず、ちょっとした準備作業をおこないます。報酬バッファをゼロ値で満たし、システムの現在の状態からローソク足1本先の潜在的な利益を決定します。

         Rewards.BufferInit(Actions * action_dist, 0);
         double reward = Rates[i].close - Rates[i].open;

さらなるステップはローソク足の方向によって異なります。強気なローソク足の場合、買い行動にはプラスの報酬を、売り行動にはマイナスの報酬を増やすように作成します。さらに、利益喪失の罰則として、市場外状態への報酬をマイナスに設定します。そして、未来の状態の計算値を受け取った報酬に加算します。ただし、オリジナルのQ学習アルゴリズムを構築する際に、自然な表現として対象結果バッファに報酬を表示しました。今度は、各行動の報酬分位を決定し、対応する事象の1の確率を書き出します。バッファの残りの要素の確率は0になります。

         if(reward >= 0)
           {
            int rew = (int)fmax(fmin((2 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-5 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

弱気のローソク足の行動のアルゴリズムは似ていて、違いは、売買の行動に対する報酬と罰則だけです。

         else
           {
            int rew = (int)fmax(fmin((5 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-2 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

残りの関数コードは、ここで説明されていないEAのコードと同様、変更されません。完全なEAコードは添付ファイルにあります。 


3.テスト

作成されたEAを用いて、以下の構成からなるモデルを訓練しました。

  • 3つの畳み込みデータ前処理層
  • 各1000ニューロンからなる3つの完全連結隠れ層
  • 45ニューロンからなる1つの完全連結決定層(行動の3つの確率分布に対してそれぞれ15ニューロン)
  • 確率分布の正規化をおこなう1つのSoftMax層

モデルは、過去2年間のEURUSD履歴データを使用して訓練されました。使用した時間枠:H1。一連の記事を通じて、同じ指標リストと同じ指標パラメータが使用されています。

訓練したモデルは,ストラテジーテスターで過去2週間の履歴データを使用してテストされました(このデータは訓練サンプルには含まれていません)。これにより、新しいデータを使ってモデルを検証するため、純粋な実験が保証されます。

ストラテジーテスターでモデルをテストするために、「DistQ-learning-test.mq5」EAを作成しました。このEAは、オリジナルのQ学習法で訓練したモデルのテストに使用した「Q-learning-test.mq5」をほぼ完全にコピーしたものです。EAコードの変更点は、行動選択関数GetActionの追加のみです。

この関数は,パラメータとして,現在の状況に対するモデルの評価結果として得られる確率分布バッファへのポインタを受け取ります。このバッファには、すべての可能な値に対する確率分布が格納されます。データ処理をより便利にするために、バッファの値を行列に移し、行列の形式を表形式に変えてみましょう。その中の行の数は、エージェントの可能な行動の数と同じです。

次に、個々の行動に対して、最も確率の高い報酬を持つ分位数を決定します。 

int GetAction(CBufferFloat* probability)
  {
   vectorf prob;
   if(!probability.GetData(prob))
      return -1;
   matrixf dist = matrixf::Zeros(1, prob.Size());
   if(!dist.Row(prob, 0))
      return -1;
   if(!dist.Reshape(Actions, prob.Size() / Actions))
      return -1;
   prob = dist.ArgMax(1);

その後、現状で売買した場合の期待リターンを比較します。期待収益が等しい場合は、報酬を受け取る確率が最も高い行動を選択します。

   if(prob[0] == prob[1])
     {
      if(prob[2] > prob[0])
         return 2;
      if(dist[0, (int)prob[0]] >= dist[1, (int)prob[1]])
         return 0;
      else
         return 1;
     }

そうでなければ、期待報酬が最大となる行動を選択します。

//---
   return (int)prob.ArgMax();
  }

ご覧のように、この場合は最もリターンの大きい行動を選択する貪欲な戦略を使っています。

完全なEAコードは添付ファイルにあります。

MetaTrader 5のストラテジーテスターでテスト用EAを2週間稼働させ、モデルシグナルに基づく取引を行ったところ、約20ドルの利益が発生しました。すべての操作に最低ロットが設定されていました。下のグラフは、残高が明らかに増加傾向にあることを示しています。

ストラテジーテスターでのモデルテスト

分散型Q学習モデルのテスト

取引操作の統計では、ほぼ56%の操作が黒字でした。ただし、このEAはストラテジーテスターでモデルをテストすることのみを目的としており、金融市場における実際の取引には適していません。

記事で使用したすべてのプログラムのフルコードは、添付ファイルでご覧いただけます。


結論

今回は、もう1つの強化訓練アルゴリズムに迫りました。分散型Q学習です。このアルゴリズムでは、環境の特定の状態において行動を実行したときの報酬の確率的な分布を研究するモデルです。報酬の平均値を予測するのではなく、確率分布を調べることで、報酬の性質についてより多くの情報を得ることができ、モデル訓練の安定性を高めることができます。また、期待リターンの確率分布がわかると、取引操作の際にリスクをより適切に評価することができる。

MetaTrader 5のストラテジーテスターでモデルテストをおこない、この手法の潜在的な収益性を実証しました。このアルゴリズムは、さらに発展させ、取引の意思決定に利用することができる。

添付ファイルにあるすべてのプログラムおよびライブラリのコード全体を検索します。


参照文献

  1. ニューラルネットワークが簡単に(第26部):強化学習
  2. ニューラルネットワークが簡単に(第27部):ディープQ学習(DQN)
  3. ニューラルネットワークが簡単に(第28部):方策勾配アルゴリズム
  4. 強化学習における分布の視点
  5. 分位値回帰を用いた強化学習

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

# ファイル名 タイプ 詳細
1 DistQ学習.mq5 EA モデルを最適化するためのEA
2 DistQ学習-test.mq5。 EA
ストラテジーテスターでモデルをテストするためのEA
3 NeuroNet.mqh クラスライブラリ ニューラルネットワークモデルを作成するためのライブラリ
4 NeuroNet.cl コードベース
ニューラルネットワークモデルを作成するためのOpenCLプログラムコードライブラリ
NetCreator.mq5 EA モデル構築ツール
6 NetCreatotPanel.mqh  クラスライブラリ ツールを作成するためのクラスライブラリ

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

添付されたファイル |
MQL5.zip (82.71 KB)
DoEasy - コントロール(第26部):ToolTip WinFormsオブジェクトの最終確認とProgressBarの開発開始 DoEasy - コントロール(第26部):ToolTip WinFormsオブジェクトの最終確認とProgressBarの開発開始
今回は、ツールチップコントロールの開発を完了し、ProgressBar WinFormsオブジェクトの開発を開始します。オブジェクトで作業しながら、コントロールやそのコンポーネントをアニメーション化するための普遍的な機能を開発する予定です。
知っておくべきMQL5ウィザードのテクニック(第04回):線形判別分析 知っておくべきMQL5ウィザードのテクニック(第04回):線形判別分析
今日のトレーダーは哲学者であり、ほとんどの場合、新しいアイデアを探して試し、変更するか破棄するかを選択します。これは、かなりの労力を要する探索的プロセスです。この連載では、MQL5ウィザードがこの取り組みにおけるトレーダーの主力であるべきであることを示しています。
DoEasy - コントロール(第27部):ProgressBar WinFormsオブジェクトの操作 DoEasy - コントロール(第27部):ProgressBar WinFormsオブジェクトの操作
この記事では、ProgressBarコントロールの開発を続けます。特に、プログレスバーと視覚効果を管理するための機能を作成します。
データサイエンスと機械学習(第09回):K近傍法(KNN) データサイエンスと機械学習(第09回):K近傍法(KNN)
これは、訓練データセットから学習しない遅延アルゴリズムです。代わりにデータセットを保存し、新しいサンプルが与えられるとすぐに動作します。シンプルでありながら、実世界でさまざまなケースに応用されています。