English Русский 中文 Español Deutsch Português
preview
ニューラルネットワークが簡単に(第35回):ICM(Intrinsic Curiosity Module、内発的好奇心モジュール)

ニューラルネットワークが簡単に(第35回):ICM(Intrinsic Curiosity Module、内発的好奇心モジュール)

MetaTrader 5トレーディングシステム | 27 4月 2023, 11:17
247 0
Dmitriy Gizlyk
Dmitriy Gizlyk

内容


はじめに

強化学習アルゴリズムの研究を続けます。これまで学んできたように、すべての強化学習アルゴリズムは、エージェントが何らかの行動を取ることで、ある環境状態から別の環境状態に移行するたびに、環境から報酬を得るというパラダイムで成り立っています。その結果、エージェントは受け取る報酬を最大化するような行動方策を構築するよう努めます。強化学習法の検討を始めるにあたり、モデル訓練の目標を達成するために重要な役割の1つである報酬方策を明確に構築することの重要性を述べました。

ただし、現実のほとんどの場面で、すべての行動に報酬が伴うわけではありません。行動と報酬の間には、長短さまざまな時間差があり得ます。1つの報酬を受け取るのにいくつもの行動が必要なこともあります。そのような場合、報酬総額を構成要素に分割し、行動から報酬までのエージェントの全経路に配置しました。これはかなり複雑な過程で、慣例と妥協に満ちています。

取引もそのような作業の1つです。エージェントは、タイミングよく正しい方向にポジションを開かなければなりません。そして、ポジションの収益性が最大になる瞬間を待ちます。その後、ポジションを決済し、操作結果をロックします。したがって、ポジションが決済された時点で、口座残高の変動という形で報酬を受け取ることになります。先に検討したアルゴリズムでは、この報酬を、銘柄価格の変化量の倍数に相当する量のステップ(1ステップはローソク足1本の時間)に分配しました。しかし、その正しさはどうでしょうか。各ステップにおいて、エージェントは取引操作をおこなう、取引操作をおこなわないことを決定するなどの行動を取りました。取引しないという判断も、エージェントが選んで取る行動なのです。つまり、それぞれの行動が全体の結果にどれだけ貢献しているかという問題があります。 

報酬方策やモデルの訓練過程を整理するための他の方法はあるのでしょうか。


1.好奇心とは、学びたいという衝動のこと

生き物の行動に目を向けてください。動物や鳥は、食べ物という報酬を得るまでに長い距離を移動することができます。人間は、それぞれの行動に対して報酬を受け取るわけではありません。人間の学習原理は多面的なものです。学びの原動力のひとつに「好奇心」があります。目の前に閉ざされた扉があるとき、開いて中を覗かせるのは好奇心です。これが人間の本性です。

私たちの脳は、何らかの行動をするときに、その影響の結果を1、2歩先まですでに予測するように設計されています。時にはそれ以上に。さて、私たちは望む結果を得るために、何らかの行動を取ります。そして、その結果を期待値と比較することで、自分の行動を修正するのです。また、ゲームであればこそ、試行錯誤を繰り返すことができることもわかっています。現実の世界では、1歩引いて同じ状況を繰り返す可能性はありません。新しい試みのたびに、新しい結果が生まれます。そのため、行動を起こす前に、これまでの経験をすべて分析します。その経験をもとに、自分にとって正しいと思われる行動を選択するのです。

私たちは慣れない状況に陥ると、そこを探索し、環境を記憶しようとします。そうするときに、将来的にどのようなメリットがあるのか、考えないかもしれません。私たちは、自分の行動に対してすぐに報われるわけではありません。将来役に立つかもしれない経験を得るだけです。

これまでにも、できるだけ環境を探索する必要性や、それまでに得た経験の活用と環境の研究のバランスについて述べてきました。ɛ-greedy戦略では、目新しいハイパーパラメータまで導入しています。ただし、ハイパーパラメーターは定数です。今の目的は、状況に応じてモデルが独自に新奇性のレベルを管理するように訓練することです。

Curiosity-driven Exploration by Self-supervised Prediction」の著者は、アルゴリズム作成時にそのような方法を適用しようとしました。本記事は2017年5月に掲載されたものです。この手法は、好奇心を、モデルがその行動の結果を予測する能力の誤差として形成することに基づいています。好奇心は、過去に取ったことのない行動についてより高くなります。記事では、3つの大きな課題を探っています。

  1. 希少な外発的報酬:好奇心により、エージェントは環境とのインタラクションを少なくしてゴールに到達することができる
  2. 外発的報酬のない訓練:好奇心は、環境から外発的な報酬が得られない場合でも、エージェントを効率的に環境を探索するように駆り立てる
  3. 見えないシナリオへの一般化:これまでの経験で得た知識は、ゼロから始めるよりもはるかに早く新しい場所を開拓するのに役立つ

著者らは、かなりシンプルなアイデアを提案しました。外的報酬reに、好奇心の指標となる、環境の探索を促すような内的報酬riを加えます。この混合物はその後、訓練のためにエージェントに提供されます。報酬のスケール因子は、外発的報酬と内発的報酬の影響を調整するために使用することができます。このような因子は、モデルのハイパーパラメータとなります。

主な新規性は、この固有報酬を生成するICMブロックのアーキテクチャにあります。ICMは、3つの独立したモデルを含んでいます。

  • エンコーダー
  • 逆モデル
  • 順モデル

その後に続く2つのシステム状態と実行された行動がモジュールに入力されます。行動はワンホットベクトルとして符号化されます。行動は、モジュールの外側と内側の両方でエンコードすることができます。モジュールに入力されたシステム状態は、エンコーダーを用いて符号化されます。エンコーダーは、システムの状態を記述するテンソルの次元を小さくすることと、データをフィルタリングすることを目的としています。著者らは、システムの状態を記述するすべての特徴を3つのグループに分けています。

  1. エージェントの影響を受ける特徴
  2. エージェントに影響を受けず、エージェントに影響を与える特徴
  3. エージェントの影響を受けず、エージェントに影響を与えない特徴

エンコーダーは、最初の2つのグループに焦点を当て、3番目のグループの影響を中和するのに役立つはずです。

逆モデルは、符号化された2つの後続の状態を受け取り、状態間を通過するために実行される行動を決定するために学習します。エンコーダーと一緒に逆モデルを訓練することで、最初の2つのグループの特徴を区別することができます。LogLossは、逆モデルの損失関数として使用されます。

順モデルは、符号化された現在の状態と実行された行動に基づいて、次の状態を予測するように学習します。好奇心を測定するのは予測の質です。MSEで計算される予測誤差は、内発的報酬となります。

ICM

不思議に思われるかもしれませんが、順モデルの誤差が大きくなると、訓練しているDQNモデルの内発的報酬も大きくなります。アイデアはモデルがより多くのアクションを実行するように促すことですが、その結果は不明です。このように、モデルは環境を探索することになります。環境を探索するにつれ、モデルの好奇心は低下し、DQNは外発的報酬を最大化します。

ICMは、これまで説明したどのモデルにも使用することができます。また、モデルの収束性を高めるために、これまでに研究されたすべてのアーキテクチャーのソリューションを使用することも忘れてはなりません。

本方法論の著者がおこなった実用的なテストでは、ゲームレベルの最後に報酬があるコンピュータゲームにおいて、本アルゴリズムの有効性が示されました。さらに、このモデルは、新しいゲームレベルに移行する際に、以前に得た経験を利用することができるという、一般化能力を示しています。特に興味深いのは、テクスチャーが変化したり、ノイズが加わったりしても、このモデルが良好な性能を発揮することです。つまり、モデルは主要なものを識別し、さまざまなノイズを無視することを学習するのです。これにより、様々な環境状態におけるモデルの安定性が向上します。


2.MQL5を用いた内発的好奇心ブロック

ここまで、方法論の理論的側面について簡単に考察してきました。では、実践編に移りましょう。この部分は、MQL5を使ってその手法を実装します。実装を進める前に、いくつかの理由から、これまで検討されてきた方法を使用しないことをご了承ください。

まず変わるのは、報酬方策です。もっと実情に近づくことにしました。外発的報酬は口座残高の変化となります。エクイティの変化ではなく、残高であることにご注意ください。このような報酬は滅多にないことだと思いますが、この問題を解決するために、新しい手法を適用しています。

残高の変化という報酬に限定されるが、同時に各エージェントの行動は取引操作として表現できるため、取引口座の状態を特徴づける変数をシステムの状態記述に追加する必要があります。また、ポジションのオープンと決済、各ポジションの累積浮動利益も監視する必要があります。

EAのコードに各ポジションのトラッキングを実装しないために、モデルの訓練過程をストラテジーテスターに移すことにしました。ストラテジーテスターでモデルに操作をさせます。すると、口座状況やポジションのポーリング機能を使うことで、ストラテジーテスターから必要な情報を得ることができます。

そのため、経験再生のためのメモリバッファを作る必要があります。このようなバッファを作る理由については、「ニューラルネットワークが簡単に(第27回):ディープQ学習(DQN)」稿でお話ししました。従来は、訓練期間の全銘柄履歴をバッファとして使用していましたが、口座の状態データを追加するため無理になりました。そこで、プログラム内部に累積経験バッファを実装します。

さらに、EAで複数のポジションを同時に建てることができるようにします(反対方向のものも含む)。これにより、エージェントの行動可能空間が変化します。エージェントは、4つの行動を取ることができるようになります。

0:買う

1:売る

2:すべてのポジションを決済する

3:順番をスキップして、適切な状態を待つ

まずは経験再生バッファを実装するところから開発を始めましょう。


2.1.経験再生ブロック

経験再生バッファは、常に記録を追加できるようにする必要があります。毎回、以下のようなデータパッケージが追加される予定です。

  • 環境状態記述テンソル
  • 取られた行動
  • 受け取られた外発的報酬

そして、バッファを実装する最も適切な方法は、動的なオブジェクト配列を使用することです。個々のレコードには、上記の情報を持つオブジェクトが含まれます。

バッファ内の個々のレコードを整理するために、CObject基本クラスから派生したCReplayStateクラスを作成します。このクラスでは、静的データバッファオブジェクトと2つの変数を使って、データ、取った行動、報酬を保存します。

エージェントは、現在の状態から行動を取ることに注意してください。そして、この状態に移行することで報酬を受け取ります。つまり、前のステップで取った行動により、前の状態から現在の状態に遷移するための報酬です。報酬と行動は同じレコードでバッファに追加されますが、実際には異なる区間に属します。

class CReplayState : public CObject
  {
protected:
   CBufferFloat      cState;
   int               iAction;
   double            dReaward;

public:
                     CReplayState(CBufferFloat *state, int action, double reward);
                    ~CReplayState(void) {};
   bool              GetCurrent(CBufferFloat *&state, int &action);
   bool              GetNext(CBufferFloat *&state, double &reward);
  };

クラスコンストラクタでは、必要な情報を取得し、クラス変数や内部オブジェクトにコピーします。

CReplayState::CReplayState(CBufferFloat *state, int action, double reward)
  {
   cState.AssignArray(state);
   iAction = action;
   dReaward = reward;
  }

静的データバッファオブジェクトを使用しているため、クラスのデストラクタは空のままです。

保存されたデータにアクセスするために、このクラスに2つのメソッドGetCurrentGetNextを追加してみましょう。1つ目は状態と行動を返します。2つ目は行動と報酬を返します。

bool CReplayState::GetCurrent(CBufferFloat *&state, int &action)
  {
   action = iAction;
   double reward;
   return GetNext(state, reward);
  }

両メソッドのアルゴリズムは非常に単純です。その使い方については、もう少し後で見ていくことにしましょう。

bool CReplayState::GetNext(CBufferFloat *&state, double &reward)
  {
   reward = dReaward;
   if(!state)
     {
      state = new CBufferFloat();
      if(!state)
         return false;
     }
   return state.AssignArray(GetPointer(cState));
  }

単一のレコードのオブジェクトを作成した後、オブジェクトの動的配列のCArrayObjクラスの継承者として経験バッファCReplayBufferを作成します。このクラスは、EA運用中に常に新しい状態に更新される予定です。また、メモリのオーバーフローを避けるために、最大サイズをiMaxSize変数値に制限します。また、バッファサイズを管理するためにSetMaxSizeメソッドを追加します。クラス本体には、他のオブジェクトや変数を作りません。そのため、コンストラクタとデストラクタは空になっています。

class CReplayBuffer : protected CArrayObj
  {
protected:
   uint              iMaxSize;
public:
                     CReplayBuffer(void) : iMaxSize(500) {};
                    ~CReplayBuffer(void) {};
   //---
   void              SetMaxSize(uint size)   {  iMaxSize = size; }
   bool              AddState(CBufferFloat *state, int action, double reward);
   bool              GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   bool              GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat*& state2);
   int               Total(void) { return CArrayObj::Total(); }
  };

バッファにレコードを追加するために、AddStateメソッドを使用します。このメソッドは、状態テンソル、行動、外発的報酬を含む新しい記録データをパラメータで受け取ります。

メソッド本体では、システム状態バッファのオブジェクトへのポインタを確認します。ポインタを確認できたら、新しいレコードオブジェクトを作成し、動的配列に追加します。動的配列の主な操作は、親クラスのメソッドを使用して実装されます。

その後、現在のバッファサイズを確認します。必要に応じて、最も古いオブジェクトを削除して、指定されたバッファサイズの最大値に合わせます。

bool CReplayBuffer::AddState(CBufferFloat *state, int action, double reward)
  {
   if(!state)
      return false;
//---
   if(!Add(new CReplayState(state, action, reward)))
      return false;
   while(Total() > (int)iMaxSize)
      Delete(0);
//---
   return true;
  }

バッファからデータを取得するために、GetRendomStateGetStateの2つのメソッドを作成します。最初のメソッドはバッファからランダムな状態を返し、2番目のメソッドはバッファの指定されたインデックスにある状態を返します。1つ目のメソッドの本体では、バッファサイズ内の乱数を生成し、2つ目のメソッドを呼び出して、生成されたインデックスを持つデータを取得するのみです。

bool CReplayBuffer::GetRendomState(CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   int position = (int)(MathRand() * MathRand() / pow(32767.0, 2.0) * (Total() - 1));
   return GetState(position, state1, action, reward, state2);
  }

2番目のGetStateメソッドのアルゴリズムを見ると、要求されたデータと過去に保存されたデータの数の違いに気づくはずです。保存時には1つのシステム状態を受け取っていたのが、現在は2つの環境状態テンソルが要求されます。

Q学習がどのように構成されているのかを思い出してみましょう。訓練は、4つのデータオブジェクトに基づいておこなわれます。

  • 環境の現在の状態
  • エージェントが取った行動
  • 次の環境の状態
  • 環境の状態遷移の報酬

したがって、経験バッファからシステムの2つの後続状態を抽出する必要があります。また、分析した状態の行動と、同じ状態に遷移したときの報酬を保存していました。そのため、あるレコードから状態と行動を抽出し、次のレコードから環境状態と報酬を抽出する必要があります。これが、上記のGetCurrentメソッドとGetNextメソッドを整理した理由です。

では、GetStateメソッドの実装を見てみましょう。まず、メソッド本体で、取得するエントリの指定されたインデックスを確認します。少なくとも0でなければならず、最大でもバッファ内の最後尾のレコードのインデックスでなければなりません。これは、後続の2つのレコードのデータが必要だからです。

次に、指定されたインデックスを持つレコードのGetCurrentを呼び出します。そして、次のレコードに移り、GetNextメソッドを呼び出します。演算結果は呼び出し元のプログラムに返されます。

bool CReplayBuffer::GetState(int position, CBufferFloat *&state1, int &action, double &reward, CBufferFloat *&state2)
  {
   if(position < 0 || position >= (Total() - 1))
      return false;
   CReplayState* element = m_data[position];
   if(!element || !element.GetCurrent(state1, action))
      return false;
   element = m_data[position + 1];
   if(!element.GetNext(state2, reward))
      return false;
//---
   return true;
  }

経験バッファは特定の訓練セッションに固有のものであり、そのデータを保存することに価値はありません。したがって、上述したクラスのファイル操作メソッドを作成する必要はありません。


2.2.ICM(Intrinsic Curiosity Module、内発的好奇心モジュール)

経験バッファを作成した後、ICMアルゴリズムの実装に進みます。理論的な部分で先に述べたように、このモジュールでは、エンコーダーモデル、逆モデル、順モデルの3つのモデルを使用しています。私の実装では、著者が提示したアーキテクチャにこだわりませんでした。リソースを節約するため、ICM用に別のエンコーダーを作成することはしませんでした。

元のアーキテクチャでは、訓練用DQNモデルで使用されるものと同様のエンコーダーを作成することになります。そこで、訓練用モデルの既存のエンコーダーを使って信号を符号化することにしました。もちろん、そのためにはモデルの同期と、モデルの誤差逆伝播メソッドにいくつかの追加をおこなう必要があります。しかし、これにより、追加エンコーダーの作成と訓練に必要なメモリとコンピューティングリソースの消費を削減することができます。

また、DQNモデルのエンコーダーをより細かくチューニングすることで、さらなる利益を得ることができると期待しています。

アルゴリズムを実装するために、基盤となるCNetニューラルネットワークディスパッチャクラスを継承したCICMニューラルネットワークディスパッチャクラスを新規に作成します。クラス本体には3つの内部変数が追加されています。

  • iMinBufferSize:モデルの訓練を開始するために必要な経験バッファの最小サイズ
  • iStateEmbedingLayer:環境の符号化された状態を読み取る、訓練するモデルのニューラル層の番号。モデルのエンコーダーを完成させるニューラル層
  • dPrevBalance:口座残高の最後の状態を記録するための変数。外発的報酬を決定するために使用

さらに、4つの内部オブジェクトを宣言します。これには、経験蓄積バッファのオブジェクト1つと、cTargetNet、cInverseNet、cForwardNetの3つのニューラルネットワークのオブジェクトが含まれます。

私たちが採用したQ学習法において、Target Netは大きな柱の1つです。

class CICM : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   //---
   CReplayBuffer     cReplay;
   CNet              cTargetNet;
   CNet              cInverseNet;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CICM(void);
                     CICM(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   bool              Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse);
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true); 
   bool              backProp(int batch, float discount = 0.9f);
   int               getAction(void);      
   int               getSample(void);
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, string invers, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, string invers, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defICML;   }
   virtual bool      TrainMode(bool flag)
            { return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag) && cInverseNet.TrainMode(flag)); } 
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result) 
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual bool      UpdateTarget(string file_name);
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

ニューラルネットワークモデルの操作のための基盤となるディスパッチャクラスの同様の子クラスはすでに以前の記事で作成しており、新しいクラスのメソッドセットは、以前にオーバーライドしたメソッドとほぼ同じです。オーバーライドされたメソッドに加えられた主な変更点をつらつらと説明しましょう。まずは、モデル作成メソッドCreateから見ていきましょう。先に作成したモデルアーキテクチャ記述を渡す手順では、ネストしたモデルは作成できません。このサブ過程をグローバルに変更しないために、Createメソッドのパラメータに、さらに2つのモデルの記述を追加することにしました。メソッド本体では、使用するすべてのモデルについて、関連するメソッドを順次呼び出していくことになります。各モデルは、必要なアーキテクチャの説明を受け取ります。呼び出されたメソッドの実行を制御することを忘れないでください。

bool CICM::Create(CArrayObj *Description, CArrayObj *Forward, CArrayObj *Inverse)
  {
   if(!CNet::Create(Description))
      return false;
   if(!cForwardNet.Create(Forward))
      return false;
   if(!cInverseNet.Create(Inverse))
      return false;
   cTargetNet.Create(NULL);
//---
   return true;
  }

なお、このメソッドを呼び出した後、状態埋め込みを読み込むために、メインモデルのニューラル層の番号を指定する必要があります。この操作は、SetStateEmbedingLayerメソッドを呼び出すことで実現されます。

   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }

これまでの類似クラスでは、親クラスのフィードフォワードパスを使用していましたが、今回はフィードフォワードパスの構成を変更する必要がありました。

戻り型を変更しました。以前はメソッド操作の実行結果をブーリアン値で返していましたが、フィードフォワードの結果を得るためにCNet::getResultsメソッドを使用しました。これは、結果のテンソルが返されたためです。今回、新しいクラスのフィードフォワードメソッドは、選択された行動の離散値を返します。ユーザーは、貪欲な戦略か、確率分布からの行動のサンプリングのどちらかを選択することができます。追加のsampleパラメータがそれを担っています。

int CICM::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true, bool sample = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;
   double balance = AccountInfoDouble(ACCOUNT_BALANCE);
   double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
   dPrevBalance = balance;
   int action = (sample ? getSample() : getAction());
   if(!cReplay.AddState(inputVals, action, reward))
      return -1;
//---
   return action;
  }

モデル操作の一般的なアプローチを維持するために、現在の状態記述テンソルでは、呼び出しプログラムから銘柄の市場状態の指示だけを受け取ることを想定していますが、私たちの新しいモデルは、口座状態に関する情報も必要とします。AddInputDataメソッドで、この情報を結果のテンソルに追加することになります。必要な情報を追加できて初めて、親クラスのフィードフォワードメソッドを呼び出します。

まだまだ工夫が必要です。次に、経験バッファに新しいデータを追加しましょう。そのために、まず現在の状態への遷移に対する外発的報酬を定義します。前述したように、残高の変化を外発的報酬として利用しています。

次に、ユーザーが選択した戦略に従って、エージェントの次の行動を決定します。そして、このデータをすべて経験蓄積バッファに渡します。上記の操作がすべて完了したら、選択したエージェント行動を呼び出し元のプログラムに返します。

各ステップで過程を制御していることに注目してください。いずれかのステップでエラーが発生した場合、このメソッドは呼び出し元のプログラムに-1を返します。したがって、可能なエージェントの行動の空間を整理する場合、これを考慮するか、呼び出し側がエラー状態とエージェントの行動を明確に分離できるように戻り値を変更します。

次に、backPropメソッドを修正します。このメソッドは、最も劇的な変化を遂げました。まず、パラメータが完全に変わりました。目標値テンソルは含まれなくなりました。新しいメソッドでは、アップデートパッケージのサイズとディスカウントファクターのみをパラメータとして受け取ります。

メソッド本体では、まず経験バッファの大きさを確認します。さらにメソッド操作をおこなうには、モデルが十分な経験を積んでいる必要があります。

なお、経験値が足りない場合は、trueの結果で終了します。falseは、操作の実行エラーが発生した場合にのみ返します。これにより、モデルはそれ以降の操作を通常通り実行することができます。

bool CICM::backProp(int batch, float discount = 0.900000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize)
      return true;
   if(!UpdateTarget(TargetNetFile))
      return false;

また、モデルの訓練課程を開始する前に、必ずTarget Netを更新してください。なぜなら、そのエンコーダーは、遷移後の環境状態の埋め込みを取得するために使用されるからです。

次に、少し準備作業をして、いくつかの内部変数と中間データ保存の役割を果たすオブジェクトを宣言することにします。

   CLayer *currentLayer, *nextLayer, *prevLayer;
   CNeuronBaseOCL *neuron;
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   double reward;
   int action;

準備作業の後、モデルの訓練ループを実行します。ループの繰り返し回数は、パラメータで指定されたモデル更新のバッチサイズと同じです。

ループ本体では、まず経験バッファから、選択された行動と受け取った報酬という2つの連続したシステム状態からなるデータセットをランダムに1つ抽出します。その後、訓練中のモデルのフィードフォワードパスを実装します。

//--- training loop in the batch size
   for(int i = 0; i < batch; i++)
     {
      //--- get a random state and the buffer replay
      if(!cReplay.GetRendomState(state1, action, reward, state2))
         return false;
      //--- feed forward pass of the training model ("current" state)
      if(!CNet::feedForward(state1, 1, false))
         return false;

メインモデルのフィードフォワードパスが成功した後、順モデルのフィードフォワードパスを実行するための準備作業を実施します。ここでは、現在のシステム状態の埋め込みを抽出し、実行された行動のワンホットベクトルを作成します。

      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- prepare a one-hot action vector and concatenate with the current state vector
      getResults(target);
      actions = vector<float>::Zeros(target.Size());
      actions[action] = 1;
      if(!targetVals.AssignArray(actions) || !targetVals.AddArray(state1))
         return false;

その後、順モデルのフィードフォワードパスを、次の状態の埋め込みを予測しながら実行します。

      //--- forward net feed forward pass - next state prediction
      if(!cForwardNet.feedForward(targetVals, 1, false))
         return false;

次に、Target Netフィードフォワードを実装し、次の状態の埋め込みを抽出します。

      //--- feed forward
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;
      //--- unload the state embedding and concatenate with the "current" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

その結果、連続する状態の2つの埋め込みを1つのテンソルに結合し、逆モデルのフィードフォワードパスメソッド呼び出します。

      //--- inverse net feed forward - defining the performed action.
      if(!state1.AddArray(state2) || !cInverseNet.feedForward(state1, 1, false))
         return false;

次に、順モデルと逆モデルに対して誤差逆伝播メソッドを実行します。 そのための目標値は、次の状態の埋め込みと、取られる行動のワンショットベクトルの形で、すでに用意されています。

      //--- inverse net backpropagation
      if(!targetVals.AssignArray(actions) || !cInverseNet.backProp(targetVals))
         return false;
      //--- forward net backpropagation
      if(!cForwardNet.backProp(state2))
         return false;

次に、メインモデルでの操作に戻ります。ここでは、内発的好奇心による報酬と、Target Netによって予測される将来の期待報酬を加えて報酬を調整します。

      //--- reward adjustment
      cForwardNet.getResults(st1);
      state2.GetData(st2);
      reward += (MathPow(st2 - st1, 2)).Sum();
      cTargetNet.getResults(targetVals);
      target[action] = (float)(reward + discount * targetVals.Maximum());
      if(!targetVals.AssignArray(target))
         return false;

目標となる報酬を準備したら、メインのDQNモデルのバックワードパスを実行します。1つ注意点があります。予測報酬からの誤差勾配を伝播することに加え、逆モデルの誤差勾配を状態埋め込みブロックに追加する必要があります。そのためには、メインモデルの誤差逆伝播パスを実行する前に、逆モデルのソースデータ層からメインモデルの埋め込み層の誤差勾配バッファに誤差勾配データをコピーする必要があります。これは、アルゴリズム全体が、後方へのパスごとに、バッファ内のデータを単純に上書きするように構築されているためです。そこで、誤差勾配の伝搬過程にくさびを打ち込む必要があります。そのためには、メインモデルの誤差逆伝播パスのコードを完全に書き換えます。

ここでは、まずモデルの報酬予測誤差を求め、最後のニューラル層のcalcOutputGradientsメソッドを呼び出し、モデル出力時の誤差勾配を決定します。

      //--- backpropagation pass of the model being trained
        {
         getResults(result);
         float error = result.Loss(target, LOSS_MSE);
         //---
         currentLayer = layers.At(layers.Total() - 1);
         if(CheckPointer(currentLayer) == POINTER_INVALID)
            return false;
         neuron = currentLayer.At(0);
         if(!neuron.calcOutputGradients(targetVals, error))
            return false;
         //---
         backPropCount++;
         recentAverageError += (error - recentAverageError) / fmin(recentAverageSmoothingFactor, (float)backPropCount);

ここでは、モデルの平均予測誤差を算出します。

次に、誤差の勾配をモデルのすべてのニューラル層に伝搬させるというステップを踏みます。そのために、モデルのすべてのニューラル層に対して逆反復をおこない、すべてのニューラル層に対してcalcHiddenGradientsメソッドを順次呼び出すループを作成することになります。ご存じのように、このメソッドはニューラル層を通して誤差勾配を伝播させる役割を担っています。

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {
            nextLayer = currentLayer;
            currentLayer = layers.At(layerNum);
            neuron = currentLayer.At(0);
            if(!neuron.calcHiddenGradients(nextLayer.At(0)))
               return false;

メインとなるモデル訓練サブ過程では、このステップまで同じ親クラスメソッドのアルゴリズムを完全に繰り返しています。このとき、アルゴリズムを少し調整する必要があります。

解析したニューラル層がシステム状態エンコーダーの出力であるかどうかを確認する条件を追加します。確認が成功すれば、次のニューラル層から得られる誤差勾配に、逆モデルから得られる誤差勾配の値を加えることになります。

先に作成したMatrixSumカーネルを使って、2つのテンソルを加算してみました。このカーネルの詳細については、「ニューラルネットワークが簡単に(第8回):アテンションメカニズム」稿をご覧ください。

            if(layerNum == iStateEmbedingLayer)
              {
               CLayer* temp = cInverseNet.layers.At(0);
               CNeuronBaseOCL* inv = temp.At(0);
               uint global_work_offset[1] = {0};
               uint global_work_size[1];
               global_work_size[0] = neuron.Neurons();
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, neuron.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, inv.getGradientIndex());
               opencl.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, neuron.getGradientIndex());
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_dimension, 1);
               opencl.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1);
               if(!opencl.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size))
                 {
                  printf("Error of execution kernel MatrixSum: %d", GetLastError());
                  return false;
                 }
              }
           }

この動作を正しくおこなうために、2つのポイントに注意してください。

まず、逆モデルの誤差逆伝播メソッドは、誤差の勾配をソースデータ層に伝播させる必要があります。そのためには、隠れ層に勾配を伝播させるループの中で、layerNum >= 0という条件を使用しなければなりません。

         //--- Calc Hidden Gradients
         int total = layers.Total();
         for(int layerNum = total - 2; layerNum >= 0; layerNum--)
           {

第二に、逆モデルのアーキテクチャを宣言する際に、状態埋め込み受信層の活性化メソッドと同様に結果レベルの活性化メソッドを指定します。この行動はフィードフォワードパス中は効果がありませんが、誤差逆伝播パス中は活性化関数の微分によって誤差の勾配を調整します。

さらなる手順は、親クラスの誤差逆伝播アルゴリズムと同様です。誤差勾配を伝播させた後、メインモデルの全ニューラル層の重み行列を更新します。

         //---
         prevLayer = layers.At(total - 1);
         for(int layerNum = total - 1; layerNum > 0; layerNum--)
           {
            currentLayer = prevLayer;
            prevLayer = layers.At(layerNum - 1);
            neuron = currentLayer.At(0);
            if(!neuron.UpdateInputWeights(prevLayer.At(0)))
               return false;
           }
         //---
         for(int layerNum = 0; layerNum < total; layerNum++)
           {
            currentLayer = layers.At(layerNum);
            CNeuronBaseOCL *temp = currentLayer.At(0);
            if(!temp.TrainMode())
               continue;
            if((layerNum + 1) == total && !temp.getGradient().BufferRead())
               return false;
            break;
           }
        }
     }

更新するのは主な学習モデルの重み行列だけであることにご注意ください。順モデルと逆モデルのパラメータは、対応するモデルの誤差逆伝播メソッドを実行する際に更新されます。

最後に、メソッド内部で作成された補助オブジェクトを削除し、メソッド操作を肯定的に終了します。

   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

ファイルの操作方法について一言加えたいと思います。このアルゴリズムでは複数のモデルを使用しているため、訓練したモデルをどのように保存するかという問題が発生します。ここには2つの選択肢があります。すべてのモデルを1つのファイルに保存するか、各モデルを別々のファイルに保存するかです。モデルを別ファイルに保存しておくと、行動の自由度が増すのでおすすめです。訓練したDQNモデルを別のファイルにダウンロードし、先に説明したモデルとともに使用することができます。また、3つのモデルをすべて読み込んで、今回取り上げた方法を使うこともできます。不便なのは、メインモデルの状態埋め込み層を毎回指定する必要があることです。しかし、最適な結果を得るために、訓練で個々のモデルのアーキテクチャを実験することはできます。

ここでは、ファイルを扱うためのアルゴリズムの説明は割愛します。使用したすべてのプログラム、クラスのコードとそのメソッドは、添付ファイルに記載されています。


3.テスト

ICM法を用いたQ学習モデルを整理するためのクラスを作成しました。次に、モデルを訓練してテストするためのエキスパートアドバイザー(EA)を作成します。前述の通り、新しいモデルはストラテジーテスターで訓練されます。これは、これまで使われていた手法とは根本的に異なるものなので、EAのモデル訓練は大きく変更されました。

テスト用にICM-learning.mq5 EAが作成されています。市場の状況を表現するために、同じようなパラメータを持つ同じ指標を使用しました。したがって、EAの外部パラメータは実質的に変化しませんでした。グローバル変数やクラスの宣言も同様です。

EAの初期化メソッドは、これまでのEAとほぼ同じです。唯一の違いは、学習過程開始イベントが生成されないことです。これは、これまでのEAで使われていた「Train」というモデル訓練関数を完全に削除したためです。

モデル訓練の全過程は、OnTickメソッドに移されます。このモデルは、閉じたローソク足に基づいて市場を分析するように訓練されているので、新しいローソク足が開いたときにのみ学習過程を実行します。そのために、OnTickメソッド本体で、まず、新しいローソク足のオープニングイベントを確認します。そして、その結果が肯定的であれば、さらなる行動に進みます。

void OnTick()
  {
   if(!IsNewBar())
      return;

次に、履歴データを読み込みますが、その量は分析ウィンドウサイズと同じです。

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

現在の市場の状況について説明を作成します。この過程は、以前に検討したEAで使用した同様の過程のアルゴリズムを踏襲しています。

   State1.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) ||
         !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) ||
         !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
         !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
     }

履歴が読み込まれ、市場の状況説明が生成されたら、モデルのフィードフォワードメソッドを呼び出し、結果を確認します。

新しい実装では、feedForwardメソッドはエージェントの行動を返します。その結果に応じて、取引操作を実行します。

   switch(StudyNet.feedForward(GetPointer(State1), 12, true, true))
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i=PositionsTotal()-1;i>=0;i--)
            if(PositionGetSymbol(i)==Symb.Name())
              Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

モデルを構築する際に、4つのエージェント行動について説明したことに注目してください。ここでは、たった3つの行動を分析し、それに対応する取引操作を実行する様子をご覧ください。実は、4番目の行動とは、取引操作を実行せずに、より適切な相場状況を待機していることです。そのため、この行動は取り扱いません。

メソッドの最後に、モデルの誤差逆伝播メソッドを呼び出します。

   StudyNet.backProp(Batch, DiscountFactor);
//---
  }

訓練の過程で、訓練されたモデルを保存しないことにお気づきかと思います。訓練済みモデルの保存処理は、EAの非初期化メソッドに移行しました。

void OnDeinit(const int reason)
  {
//---
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

EA最適化モードでのモデル訓練を可能にするため、オプティマイザーのパスが完了するたびに、同様の保存手順を繰り返しました。

void OnTesterPass()
  {
   StudyNet.Save(FileName + ".nnw", FileName + ".fwd", FileName + ".inv", true);
  }

最適化処理はアクティブな1つのコアでのみ実行する必要があるので、ご注意ください。さもないと、並列スレッドが他のエージェントのデータを削除してしまうからです。複数のエージェントを使うことは完全になくなります。

EAを訓練するために、すべてのモデルは NetCreatorツールを使用して作成されました。なお、ストラテジーテスターでEAを動作させるためには、モデルファイルをターミナル共通ディレクトリ「TerminalCommonFiles」に配置する必要があります。各エージェントが独自のサンドボックスで動作するため、ターミナル共通フォルダを介してのみデータのやり取りができるためです。

ストラテジーテスターでの訓練には、これまでの仮想訓練アプローチに比べて、少し時間がかかります。そのため、モデルの訓練期間を10か月に短縮しました。それ以外のテストパラメータの変更はありません。今回もH1時間枠でEURUSDを使用しました。指標はデフォルトのパラメータで使用しました。

正直なところ、預金の紛失から学習が始まるのではと予想していましたが、最初のパスでは、モデルは0に近い結果を示しました。2回目のパスでは利益を得たこともありました。このモデルは330回の取引をおこない、98%以上で利益を上げています。

モデルテスト結果 モデルテスト結果


結論

今回は、ICMモデルの操作について説明しました。この技術により、外発的な報酬が少ない状況下でも、強化学習によるモデル訓練を成功させることができるようになりました。これは、金融取引のことを指しています。ICMの技術により、モデルは環境を徹底的に探索し、目標達成のための最適な方法を見つけることができます。これは、複数の連続した行動に対して1つの報酬が返ってくる環境であっても有効です。

今回の実践編では、MQL5を用いて提示された技術を実装しました。以上の作業から、この方法は取引において望ましい結果を生み出すことができると結論付けられます。

提示されたEAは取引操作をおこなうことができますが、実際の取引に使用するにはまだ早いです。EAはあくまで評価用として提示されています。実際の使用に際しては、大幅な改良とあらゆる条件下での総合的なテストが必要です。


参照文献

  1. ニューラルネットワークが簡単に(第26部):強化学習
  2. ニューラルネットワークが簡単に(第27部):ディープQ学習(DQN)
  3. ニューラルネットワークが簡単に(第28部):方策勾配アルゴリズム
  4. ニューラルネットワークが簡単に(第32回):分散型Q学習
  5. ニューラルネットワークが簡単に(第33回):分散型Q学習における分位点回帰
  6. ニューラルネットワークが簡単に(第34回):完全にパラメータ化された分位数関数
  7. Curiosity-driven Exploration by Self-supervised Prediction

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

# ファイル名 タイプ 詳細
1 ICM-learning.mq5 EA モデル訓練EA 
2 ICM.mqh クラスライブラリ モデル編成クラスライブラリ
3 NeuroNet.mqh クラスライブラリ ニューラルネットワークを作成するためのクラスのライブラリ
4 NeuroNet.cl コードベース OpenCLプログラムコードライブラリ


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

添付されたファイル |
MQL5.zip (106.2 KB)
取引における道徳的期待値 取引における道徳的期待値
この記事は、道徳的期待値についてです。取引でのその使用のいくつかの例と、その助けを借りて達成できる結果を見ていきます。
ニューラルネットワークが簡単に(第34部):FQF(Fully Parameterized Quantile Function、完全にパラメータ化された分位数関数) ニューラルネットワークが簡単に(第34部):FQF(Fully Parameterized Quantile Function、完全にパラメータ化された分位数関数)
分散型Q学習アルゴリズムの研究を続けます。以前の記事では、分散型の分位数Q学習アルゴリズムについて検討しました。最初のアルゴリズムでは、与えられた範囲の値の確率を訓練しました。2番目のアルゴリズムでは、特定の確率で範囲を訓練しました。それらの両方で、1つの分布のアプリオリな知識を使用し、別の分布を訓練しました。この記事では、モデルが両方の分布で訓練できるようにするアルゴリズムを検討します。
母集団最適化アルゴリズム:重力探索アルゴリズム(GSA) 母集団最適化アルゴリズム:重力探索アルゴリズム(GSA)
GSAは、無生物から着想を得た母集団最適化アルゴリズムです。アルゴリズムに実装されたニュートンの重力の法則のおかげで、その物体の相互作用をモデル化する高い信頼性によって、惑星系や銀河団の魅惑的なダンスを観察することができます。今回は、最も興味深く、独創的な最適化アルゴリズムの1つを考えてみます。また、宇宙物体の移動シミュレータも提示されています。
自動で動くEAを作る(第08回):OnTradeTransaction 自動で動くEAを作る(第08回):OnTradeTransaction
今回は、受注システムに関する問題を迅速かつ効率的に処理するためのイベント処理システムの使用方法について紹介します。このシステムにより、EAは必要なデータを常に検索する必要がなくなり、より速く動作するようになります。