English Русский 中文 Español Deutsch Português
preview
ニューラルネットワークが簡単に(第39回):Go-Explore、探検への異なるアプローチ

ニューラルネットワークが簡単に(第39回):Go-Explore、探検への異なるアプローチ

MetaTrader 5エキスパートアドバイザー | 21 11月 2023, 08:57
203 0
Dmitriy Gizlyk
Dmitriy Gizlyk

はじめに

強化学習における環境探索というテーマを続けます。本連載の以前の記事では、モデルのアンサンブルにおける好奇心と不一致を通して環境を探索するアルゴリズムについてすでに見てきました。どちらのアプローチも内因的報酬を利用することで、エージェントが新しい領域を探索しながらも、同じような状況で異なる行動をとるように動機付けます。しかし問題は、環境がより良く探索されるにつれて、内因性報酬が減少することです。報酬が稀な複雑なケースや、エージェントが報酬を得るまでにペナルティを受ける可能性がある場合、このアプローチはあまり効果的ではないかもしれません。この記事では、環境を研究するためのちょっと変わったアプローチ、「Go-Explore」アルゴリズムに触れてみることを提案します。


1.Go-Exploreアルゴリズム

Go-Exploreは、大きな行動空間と状態空間を持つ複雑な問題の最適解を見つけるために設計された強化学習アルゴリズムです。このアルゴリズムはAdrien Ecoffetによって開発され、「Go-Explore: a New Approach for Hard-Exploration Problems」で紹介されています。

進化的アルゴリズムと機械学習技術を駆使して、複雑で難解な問題の最適解を効率的に見つけ出す。

このアルゴリズムは、「ベース探索」と呼ばれる多数のランダムなパスを探索することから始まります。そして、進化的アルゴリズムを使って、見つかった最適解を保存し、それらを組み合わせて新しいパスを作ります。そして、これらの新しいパスは以前の最適解と比較され、より優れていれば保存されます。このプロセスは最適解が見つかるまで繰り返されます。

また、Go-Exploreは「レコーダー」と呼ばれるテクニックを使い、発見された最適解を保存し、新しいパスを作成するために再利用します。これにより、アルゴリズムが単にランダムなパスを探索し続けるよりも、より良い解を見つけることができます。

Go-Exploreの主な利点のひとつは、他の強化学習アルゴリズムが失敗するような複雑で難解な問題でも最適解を見つけることができることです。また、他のアルゴリズムでは困難なスパース報酬の下でも効率的に学習することができます。

全体として、Go-Exploreは強化学習問題を解くための強力なツールであり、ロボット工学、コンピュータゲーム、人工知能一般を含む様々な分野で効果的に適用することができます。

Go-Exploreの主なアイデアは、有望な状態を記憶して戻ることです。これは、報酬の数が限られている場合に効果的な操作をおこなうための基本です。このアイデアは非常に柔軟で幅広いため、さまざまな方法で実施することができます。 

多くの強化学習アルゴリズムとは異なり、Go-Exploreは目標問題を直接解くことに重点を置くのではなく、目標状態の達成につながる状態空間の関連する状態と行動を見つけることに重点を置きます。これを実現するために、このアルゴリズムには検索と再利用という2つの主要なフェーズがあります。

最初の段階は、状態空間のすべての状態を通過し、訪れた各状態を状態「マップ」に記録することです。この後、アルゴリズムは訪問した各状態をより詳細に調査し、他の興味深い状態につながる可能性のある行動に関する情報を収集し始めます。

2番目の段階は、以前に学習した状態や行動を再利用して、新しい解を見つけることです。このアルゴリズムは、最も成功した軌跡を保存し、それを使用して、より良い解につながる新しい状態を生成します。

Go-Exploreアルゴリズムは次のように動作します。

  1. 例のアーカイブを集める:エージェントはゲームを開始し、各成果を記録し、アーカイブに保存します。アーカイブには、状態そのものを保存する代わりに、特定の状態を達成するに至った行動の説明が含まれています。
  2. 反復的な探査:各反復で、エージェントはアーカイブからランダムな状態を選択し、この状態からゲームを再生します。達成した新しい状態はすべて保存され、その状態に至った行動の説明とともにアーカイブに追加されます。
  3. 例に基づいた学習:復探索の後、アルゴリズムはある種の強化学習アルゴリズムを使って、収集した例から学習します。
  4. 繰り返し:アルゴリズムは、望ましい性能レベルに達するまで、反復探索と例に基づいた学習を繰り返します。

Go-Exploreアルゴリズムの目標は、高レベルのパフォーマンスを達成するために必要なゲームリプレイの回数を最小限に抑えることです。これにより、エージェントは例のデータベースを使用して大きな状態空間を探索することができます。これにより、学習プロセスが高速化し、より良いパフォーマンスが達成されます。

Go-Exploreは強力で効率的なアルゴリズムであり、複雑な強化学習問題を解くのに適しています。


2.MQL5を使用した実装

ここでの実装では、これまで検討されてきたものとは異なり、アルゴリズム全体を1つのプログラムにまとめることはしない。Go-Exploreアルゴリズムの各段階は非常に異なっているため、各段階ごとに別々のプログラムを作成した方が効率的でしょう。

2.1.第1段階:探索

まず、アルゴリズムの最初の段階である、環境を探索し、例のアーカイブを収集するプログラムを作成します。実装を始める前に、構築するアルゴリズムの基本を決める必要があります。

環境の調査を始める際には、可能な限りすべての状態を探る必要があります。この段階では、最適な戦略を見つけるという目標は設定しません。奇妙に思われるかもしれませんが、ここでは戦略や最適化政策を求めているわけではないので、ニューラルネットワークを使用しません。これが第2段階の課題となります。この段階では、複数のエージェントに対してランダムな行動を実行し、各エージェントが訪れるすべてのシステム状態を記録するだけです。

しかし、この方法では、無関係なランダムな状態の束を得ることになります。環境探索についてはどうでしょう。各エージェントが各状態から1つの行動だけを実行し、他の行動のプラス面とマイナス面を学習しない場合、どのように役立つのでしょうか。だからこそ、アルゴリズムの第2段階が必要なのです。ランダムに、あるいは事前に定義された方針で、アーカイブから状態を選択します。この状態になるまで、すべてのステップを繰り返します。そして、探索中のエピソードが終わるまで、エージェントの行動を再びランダムに決定します。また、例のアーカイブに新しい状態を追加しました。

この2つのアルゴリズムが、最初の段階である「探索」を構成しています。

もう一点、ご注目ください。効果的な調査のためには、複数のエージェントを使う必要があります。ここでは、複数の独立したエージェントを並列に実行するために、ストラテジーテスターのマルチスレッドオプティマイザを使用します。各パスの結果に基づいて、エージェントは蓄積された状態アーカイブを汎化のために単一のセンターに転送します。

アルゴリズムの要点が決まったところで、実装に進みます。その状態と達成への道筋を記録する仕組みを作ることから作業を始めます。各反復の結果を渡すために、ストラテジーテスターでは任意の型の配列を使用することができますが、文字列値や動的配列を使うような複雑な構造を含むべきではありません。つまり、動的配列を使ってシステムのパスや状態を記述することはできません。すぐに寸法を決める必要があります。プログラム構成に柔軟性を持たせるため、主要な値を定数に出力します。これらの定数で、分析された履歴のバー単位の深さ(HistoryBars)とパスバッファのサイズ(Buffer_Size)を決定します。ご自分の特定の問題に適した独自の値を使用することができます。 

#define                    HistoryBars  20            
#define                    Buffer_Size  600
#define                    FileName     "GoExploer"

さらに、例のアーカイブを記録するためのファイル名を即座に示します。

データはセル構造フォーマットで記録されます。構造体内に2つの配列を作成します。1つは状態達パス(action)を記述するための整数の配列です。 2つ目は実数配列用で、達成された状態(state)の説明を記録します。静的データ配列を使わなければならないので、エージェントが作ったパスの大きさを示すtotal_actions変数を導入します。さらに、状態の重みの値を記録する実数変数を追加します。これは、その後の探索のための状態の選択に優先順位をつけるために使用されます。

//+------------------------------------------------------------------+
//| Cell                                                             |
//+------------------------------------------------------------------+
struct Cell
  {
   int               actions[Buffer_Size];
   float             state[HistoryBars * 12 + 9];
   int               total_actions;
   float             value;
//---
                     Cell(void);
//---
   bool              Save(int file_handle);
   bool              Load(int file_handle);
  };

作成した変数と配列は、構造体のコンストラクタで初期化します。構造体を作成する際、パス配列に「-1」の値を入れます。また、状態配列と変数をゼロ値で埋めます。

Cell::Cell(void)
  {
   ArrayInitialize(actions, -1);
   ArrayInitialize(state, 0);
   value = 0;
   total_actions = 0;
  }

収集した状態は、忘れずに例のアーカイブファイルに保存しなければなりません。したがって、ファイルを扱うためのメソッドを作る必要があります。データの保存方法は、すでにおなじみのアルゴリズムに基づいています。作成したクラスのデータを記録するために複数回使用しました。

このメソッドは、データを記録するファイルのハンドルをパラメータとして受け取り、すぐにその値を確認します。不正なハンドルを受け取った場合は、falseの結果でメソッドを終了します。

コントロールブロックの通過に成功したら、構造体を識別するために「999」をファイルに書き込みます。この後、変数と配列の値を保存します。配列のデータを書き込む前に、後で配列を正しく読み取るために、配列の次元を指定する必要があります。ディスクスペースを節約するために、actions配列全体ではなく、実際のパスデータだけを保存します。total_actions変数の値はすでに保存してあるので、この配列のサイズの指定は省略します。state'配列を保存する際には、まず配列のサイズを指定し、それからその内容を保存します。各作業のプロセスを必ず管理してください。すべてのデータの保存に成功したら、trueの結果でメソッドを終了します。

bool Cell::Save(int file_handle)
  {
   if(file_handle <= 0)
      return false;
   if(FileWriteInteger(file_handle, 999) < INT_VALUE)
      return false;
   if(FileWriteFloat(file_handle, value) < sizeof(float))
      return false;
   if(FileWriteInteger(file_handle, total_actions) < INT_VALUE)
      return false;
   for(int i = 0; i < total_actions; i++)
      if(FileWriteInteger(file_handle, actions[i]) < INT_VALUE)
         return false;
   int size = ArraySize(state);
   if(FileWriteInteger(file_handle, size) < INT_VALUE)
      return false;
   for(int i = 0; i < size; i++)
      if(FileWriteFloat(file_handle, state[i]) < sizeof(float))
         return false;
//---
   return true;
  }

Loadファイルからデータを読み込むメソッドも同様です。これは、データの書き込み順序を厳密に維持しながら、データの読み取り操作を実行します。メソッドの完全なコードは、以下の添付ファイルにあります。

システムの1つの状態とそれを達成する方法を記述するための構造を作成した後、Go-Exploreアルゴリズムの最初の段階を実装するためのエキスパートアドバイザー(EA)の作成に移ります。このEAをFaza1.mq5と呼ぶことにしよう。市場の状況を分析することなく、ランダムな行動をおこないますが、それでもシステムの状態を表す指標を使用します。そのため、以前のEAからパラメータを移転します。外部変数Startは、例のアーカイブからの状態を示すために使用されます。この話はまた後ほど。

input ENUM_TIMEFRAMES      TimeFrame   =  PERIOD_H1;
input int                  Start =  100;
//---
input group                "---- RSI ----"
input int                  RSIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   RSIPrice    =  PRICE_CLOSE;   //Applied price
//---
input group                "---- CCI ----"
input int                  CCIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   CCIPrice    =  PRICE_TYPICAL; //Applied price
//---
input group                "---- ATR ----"
input int                  ATRPeriod   =  14;            //Period
//---
input group                "---- MACD ----"
input int                  FastPeriod  =  12;            //Fast
input int                  SlowPeriod  =  26;            //Slow
input int                  SignalPeriod =  9;            //Signal
input ENUM_APPLIED_PRICE   MACDPrice   =  PRICE_CLOSE;   //Applied price
      bool                 TrainMode = true;

外部パラメータを指定した後、グローバル変数を作成します。ここでは、システムの状態を記述する構造体の配列を2つ作成します。最初の構造体(Base)は、現在のパスの状態を記録するために使用されます。2つ目の構造体(Total)は、例の完全なアーカイブを記録するために使用されます。

ここでは、取引操作の実行や過去のデータのロードのためのオブジェクトも宣言します。以前使われていたものとまったく同じです。

現在のアルゴリズムでは、以下を作成します。

  • action_count:操作のカウンタ
  • actions:セッション中に実行された行動を記録するための配列
  • StartCell:探索を開始するための状態記述構造体
  • bar:EAを起動してからのステップ数
各変数と配列の機能は、アルゴリズムの実装中に確認することになります。

Cell                 Base[Buffer_Size];
Cell                 Total[];
CSymbolInfo          Symb;
CTrade               Trade;
//---
MqlRates             Rates[];
CiRSI                RSI;
CiCCI                CCI;
CiATR                ATR;
CiMACD               MACD;
//---
int action_count = 0;
int actions[Buffer_Size];
Cell StartCell;
int bar = -1;

OnInit関数では、まず指標と取引操作オブジェクトを初期化します。この機能は、先に説明したEAとまったく同じです。

int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }
//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;

次に、EAの以前の操作中に作成された可能性のある例のアーカイブを読み込もうとします。ここではどちらの選択肢も受け入れられます。サンプルアーカイブを読み込むことができたら、外部変数Startで指定されたインデックスを持つ要素を読み込もうとします。そのような要素がなければ、ランダムな要素を1つ取り、それをStartCell構造体にコピーします。これが私たちの探求の出発点です。例のデータベースが読み込まれていない場合は、最初から調査を開始します。

//---
   if(LoadTotalBase())
     {
      int total = ArraySize(Total);
      if(total > Start)
         StartCell = Total[Start];
      else
        {
         total = (int)(((double)MathRand() / 32768.0) * (total - 1));
         StartCell = Total[total];
        }
     }
//---
   return(INIT_SUCCEEDED);
  }

EAのコードを変更することなく、さまざまなシナリオを整理できるように、探索の出発点を作るためにこのような広範なシステムを使用しました。

すべての操作が完了したら、INIT_SUCCEEDEDの結果でEA初期化関数を終了します。

例のアーカイブを読み込むには、LoadTotalBase関数を使用しました。初期化プロセスの説明を終えるために、そのアルゴリズムを考えてみましょう。この関数にはパラメータはありません。代わりに、以前に定義したファイル名定数FileNameを使用します。

このファイルは、アルゴリズムの第1段階と第2段階の両方で使用されることにご注意ください。これが、状態記述構造ファイルでFileName定数を宣言した理由です。

関数本体では、まずデータを読み込むためにファイルを開き、ハンドル値に基づいて操作結果を確認します。

ファイルが正常に開かれたら、例のアーカイブの要素数を読み込みます。データを読み込む配列のサイズを変更し、ファイルからデータを読み込むループを実装します。各構造体を読み込むには、先に作成したシステム状態保存構造体のLoadメソッドを使用します。

各反復で、操作のプロセスをコントロールします。どのオプションでも、関数を終了する前に、前に開いていたファイルを必ず閉じてください。

bool LoadTotalBase(void)
  {
   int handle = FileOpen(FileName + ".bd", FILE_READ | FILE_BIN | FILE_COMMON);
   if(handle < 0)
      return false;
   int total = FileReadInteger(handle);
   if(total <= 0)
     {
      FileClose(handle);
      return false;
     }
   if(ArrayResize(Total, total) < total)
     {
      FileClose(handle);
      return false;
     }
   for(int i = 0; i < total; i++)
      if(!Total[i].Load(handle))
        {
         FileClose(handle);
         return false;
        }
   FileClose(handle);
//---
   return true;
  }

EAの初期化アルゴリズムを作成した後、OnTickティック処理メソッドに移ります。このメソッドは、EAのチャートに新しいティックイベントが発生するとターミナルから呼び出されます。新しいローソク足のオープニングイベントを処理するだけでよくなります。このような制御をおこなうには、IsNewBar関数を使用します。以前のEAを完全にコピーしているので、ここではそのアルゴリズムについては触れません。

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

次に、EA開始からステップ数を増やし、その値を探査開始前のステップ数と比較します。まだ探索開始の状態に到達していなければ、目標状態へのパスから次の行動を取り、それを実行します。その後、新しいローソク足が開くのを待ちます。

   bar++;
   if(bar < StartCell.total_actions)
     {
      switch(StartCell.actions[bar])
        {
         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;
        }
      return;
     }

探索開始の状態に達した後、前のパスを現在のエージェントの行動の配列にコピーします。

   if(bar == StartCell.total_actions)
      ArrayCopy(actions, StartCell.actions, 0, 0, StartCell.total_actions);

そして、指標の過去のデータを更新します。

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

その後、システム状態の現在の記述の配列を作成します。このファイルには、指標や価格の過去のデータ、口座状況やポジションに関する情報を記録します。口座ステータスやポジションに関する情報も含まれます。

   float state[249];
   MqlDateTime sTime;
   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;
      //---
      state[b * 12] = (float)Rates[b].close - open;
      state[b * 12 + 1] = (float)Rates[b].high - open;
      state[b * 12 + 2] = (float)Rates[b].low - open;
      state[b * 12 + 3] = (float)Rates[b].tick_volume / 1000.0f;
      state[b * 12 + 4] = (float)sTime.hour;
      state[b * 12 + 5] = (float)sTime.day_of_week;
      state[b * 12 + 6] = (float)sTime.mon;
      state[b * 12 + 7] = rsi;
      state[b * 12 + 8] = cci;
      state[b * 12 + 9] = atr;
      state[b * 12 + 10] = macd;
      state[b * 12 + 11] = sign;
     }
//---
   state[240] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   state[240 + 1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
   state[240 + 2] = (float)AccountInfoDouble(ACCOUNT_MARGIN_FREE);
   state[240 + 3] = (float)AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
   state[240 + 4] = (float)AccountInfoDouble(ACCOUNT_PROFIT);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   int total = PositionsTotal();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
     }
   state[240 + 5] = (float)buy_value;
   state[240 + 6] = (float)sell_value;
   state[240 + 7] = (float)buy_profit;
   state[240 + 8] = (float)sell_profit;

その後、ランダムな行動を実行します。

//---
   int act = SampleAction(4);
   switch(act)
     {
      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;
     }

そして、現在の状態を、現在のエージェントの訪問済み状態の配列に保存します。

なお、現在の状態までのステップ数は、探索開始時点の状態までのステップ数と、探索中のランダムなステップ数の合計を示します。すでに例のアーカイブに保存されているので、探査を開始する前に状態を保存しました。同時に、各状態へのフルパスを保存する必要があります。

状態値としては、口座資産の変化の逆数を示します。これを、探査の優先順位を決めるガイドラインとして使用します。この優先順位付けの目的は、損失を最小限に抑えるための手段を見つけることです。これにより、全体的な利益が増加する可能性があります。さらに、Go-Exploreアルゴリズムの第2段階で方策を訓練する際に、後でこの値の逆数を報酬として使用することができます。

//--- copy cell
   actions[action_count] = act;
   Base[action_count].total_actions = action_count+StartCell.total_actions;
   if(action_count > 0)
     {
      ArrayCopy(Base[action_count].actions, actions, 0, 0, Base[action_count].total_actions+1);
      Base[action_count - 1].value = Base[action_count - 1].state[241] - state[241];
     }
   ArrayCopy(Base[action_count].state, state, 0, 0);
//---
   action_count++;
  }

現在の状態に関するデータを保存した後、ステップカウンタを増やし、次のローソク足を待ちます。

これで環境を探索するエージェントアルゴリズムを構築しました。次に、すべてのエージェントからデータを収集するプロセスを、例のアーカイブとして整理する必要があります。そのためには、テスト終了後、各エージェントは収集したデータを汎化センターに送らなければなりません。この機能をOnTesterメソッドで整理します。ストラテジーテスターは、各パスの完了時にこれを呼び出します。

利益の出るパスだけを残すことにしました。これにより、例アーカイブのサイズが大幅に縮小され、学習プロセスがスピードアップします。可能な限り高い精度で方策を訓練したいのであれば、そしてリソースに制限がないのであれば、すべてのパスを保存することができます。これは、方策がより良い環境を探るのに役立つでしょう。

まずはパスの採算性を確認します。必要であれば、FrameAdd関数を使ってデータを送信します。

//+------------------------------------------------------------------+
//| Tester function                                                  |
//+------------------------------------------------------------------+
double OnTester()
  {
//---
   double ret = 0.0;
//---
   double profit = TesterStatistics(STAT_PROFIT);
   action_count--;
   if(profit > 0)
      FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), action_count, profit, Base);
//---
   return(ret);
  }

送信する前に、ステップ数を1減らすことに注意してください。最後のアクションの結果は私たちには分からないからです。

共通の例アーカイブにデータを収集するプロセスを整理するために、3つの関数を使います。まず、最適化プロセスを初期化する際、サンプルアーカイブが以前に作成されていれば、それを読み込みます。この操作はOnTesterInit関数内でおこなわれます。

//+------------------------------------------------------------------+
//| TesterInit function                                              |
//+------------------------------------------------------------------+
void OnTesterInit()
  {
//---
   LoadTotalBase();
  }

次に、OnTesterPass関数で各パスを処理します。ここでは、利用可能なすべてのフレームからデータを収集し、共通のサンプルアーカイブの配列に追加します。FrameNext関数は次のフレームを読み込みます。データの読み込みに成功した場合はtrueを返します。フレームデータの読み込みにエラーがあれば、falseを返します。このプロパティを使うことで、データを読み込んで共通の配列に追加するループを構成することができます。

//+------------------------------------------------------------------+
//| TesterPass function                                              |
//+------------------------------------------------------------------+
void OnTesterPass()
  {
//---
   ulong pass;
   string name;
   long id;
   double value;
   Cell array[];
   while(FrameNext(pass, name, id, value, array))
     {
      int total = ArraySize(Total);
      if(name != MQLInfoString(MQL_PROGRAM_NAME))
         continue;
      if(id <= 0)
         continue;
      if(ArrayResize(Total, total + (int)id, 10000) < 0)
         return;
      ArrayCopy(Total, array, total, 0, (int)id);
     }
  }

最適化プロセスが終了すると、OnTesterDeinit関数が呼ばれます。ここではまず、状態の説明の「値」の降順でデータベースを並び替えます。これにより、損失が最大となる要素を配列の先頭に移動させることができます。

//+------------------------------------------------------------------+
//| TesterDeinit function                                            |
//+------------------------------------------------------------------+
void OnTesterDeinit()
  {
//---
   bool flag = false;
   int total = ArraySize(Total);
   printf("total %d", total);
   Cell temp;
   Print("Start sorting...");
   do
     {
      flag = false;
      for(int i = 0; i < (total - 1); i++)
         if(Total[i].value < Total[i + 1].value)
           {
            temp = Total[i];
            Total[i] = Total[i + 1];
            Total[i + 1] = temp;
            flag = true;
           }
     }
   while(flag);
   Print("Saving...");
   SaveTotalBase();
   Print("Saved");
  }

その後、SaveTotalBaseメソッドを使ってサンプルアーカイブをファイルに保存します。そのアルゴリズムは、前述のLoadTotalBaseメソッドに似ています。すべての関数の完全なコードは添付ファイルにあります。

これで、第1段階のEAに関する作業は終了しました。コンパイルして、ストラテジーテスターに移動します。Faza1.ex5 EA、銘柄、テスト期間(ここでは訓練)、すべてのオプションで低速最適化を選択します。

EAは1つのパラメータ「Start」に対して最適化されます。これは、実行中のエージェントの数を決定するために使用されます。最初の段階では、少数のエージェントでEAを立ち上げました。これにより、例の初期アーカイブを作成するための迅速なパスが得られます。

最適化の第1段階を終えた後、テストエージェントの数を増やします。次の打ち上げに向けて2つのアプローチがあります。最も不採算な状態での最良の行動を見つけようとするならば、Startパラメータの最適化区間は0から示されるべきです。ランダムな状態を選択するために、パラメータ最適化の初期値を意図的に大きく設定し、探索の出発点としました。パラメータの最終的な最適化値は、起動されるエージェントの数に依存します。Steps列の値は、最適化(訓練)プロセス中に起動されたエージェントの数に対応します。

2.2.第2段階:例を用いた方策の訓練

第1段階のEAが例のデータベースを作る作業をしている間に、第2段階のEAの作業に移ります。

実装では、論文の著者が提案した第2段階の方策訓練プロセスから少し逸脱しました。この記事では、方策の訓練にシミュレーション法を用いることを提案しています。これは強化学習アルゴリズムに改良を加えたアプローチを用いています。別のセクションで、エージェントは、例のアーカイブから成功した戦略の行動を繰り返すように訓練され、標準的な強化学習アプローチが適用されます。最初の段階では、「教師」のデモンストレーションが最大です。エージェントは「教師」よりも悪い結果を出してはなりません。訓練が進むにつれて「教師」の間隔は短くなります。エージェントは、教師の戦略を最適化することを学ばなければなりません。

実装では、この段階を2段階に分けました。最初の段階では、教師あり学習と同様の方法でエージェントを訓練します。しかし、正しい行動を指定しません。その代わりに、予想報酬値を調整します。この段階では、Faza2.mq5 EAを作成します。

EAコードでは、システムの状態を記述する要素と、完全にパラメータ化されたFQFモデルのクラスを追加します。

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "Cell.mqh"
#include "..\RL\FQF.mqh"
//+------------------------------------------------------------------+
//| Input parameters                                                 |
//+------------------------------------------------------------------+
input int                  Iterations =  100000;

最小限の外部パラメータしか持ちません。モデル訓練の反復回数のみを示します。

グローバルパラメータの中で、モデルクラス、状態記述オブジェクト、報酬の配列を宣言します。また、サンプルアーカイブを読み込むための配列を宣言する必要があります。

CNet                 StudyNet;
//---
float                dError;
datetime             dtStudied;
bool                 bEventStudy;
//---
CBufferFloat         State1;
CBufferFloat         *Rewards;

Cell                 Base[];

EAの初期化メソッドでは、まず例のアーカイブをアップロードします。この場合、これは重要なポイントのひとつです。サンプルアーカイブの読み込みにエラーがあれば、モデルを訓練するためのソースデータが得られません。したがって、読み込みエラーが発生した場合は、INIT_FAILEDの結果で関数を終了します。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!LoadTotalBase())
      return(INIT_FAILED);
//---
   if(!StudyNet.Load(FileName + ".nnw", dError, dError, dError, dtStudied, true))
     {
      CArrayObj *model = new CArrayObj();
      if(!CreateDescriptions(model))
        {
         delete model;
         return INIT_FAILED;
        }
      if(!StudyNet.Create(model))
        {
         delete model;
         return INIT_FAILED;
        }
      delete model;
     }
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   bEventStudy = EventChartCustom(ChartID(), 1, 0, 0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

サンプルアーカイブを読み込んだ後、訓練のためにモデルを初期化します。いつものように、まずは事前に訓練されたモデルを読み込んでみます。何らかの理由でモデルを読み込めなかった場合は、ランダムな重みで新しいモデルの作成を初期化します。モデルの説明は、CreateDescriptions関数で指定します。

モデルの初期化に成功したら、カスタムイベントを作成してモデルの訓練プロセスを開始します。教師あり学習でも同じアプローチを使用しました。。

ここでEAの初期化関数を完了します。

このEAでは、過去の価格データや指標を読み込むためのオブジェクトを作成していないことに注意してください。学習プロセス全体が例に基づいています。例アーカイブは、口座やポジションに関する情報を含む、システム状態のすべての記述を保存します。

作成したカスタムイベントはOnChartEvent関数で処理されます。ここでは、予想されるイベントの発生だけを確認し、モデル学習関数を呼び出します。

//+------------------------------------------------------------------+
//| ChartEvent function                                              |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
  {
//---
   if(id == 1001)
      Train();
  }

実際のモデル訓練プロセスはTrain関数に実装されています。この関数にはパラメータはありません。関数本体では、まず例のアーカイブのサイズを決定し、システム開始からのミリ秒数をローカル変数に保存します。この値を使用して、モデルの訓練の進捗状況を定期的にユーザーに通知します。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total = ArraySize(Base);
   uint ticks = GetTickCount();

ちょっとした準備作業の後、モデルの訓練ループを組織します。ループの繰り返し回数は、外部変数の値に対応します。また、ユーザーの要求に応じて、ループを強制的に中断し、プログラムを終了することも提供します。これはIsStopped関数でおこなうことができます。ユーザーがプログラムを閉じると、指定された関数はtrueを返します。

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = 0;
      int count = 0;
      int total_max = 0;
      i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total - 1));
      State1.AssignArray(Base[i].state);
      if(IsStopped())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         return;
        }

ループ本体では、アーカイブからランダムに1つの例を選択し、その状態をデータバッファにコピーします。次に、モデルのフィードフォワードパスを実行します。

      if(!StudyNet.feedForward(GetPointer(State1), 12, true))
         return;

次に、現在の例で実行された行動を取得し、フィードフォワード結果をローフし、実行された行動の報酬を更新します。

      int action = Base[i].total_actions;
      if(action < 0)
        {
         iter--;
         continue;
        }
      action = Base[i].actions[action];
      if(action < 0 || action > 3)
         action = 3;
      StudyNet.getResults(Rewards);
      if(!Rewards.Update(action, -Base[i].value))
         return;

次の2つの瞬間に注意してください。もし例に行動がなければ(初期状態が選択されている)、反復カウンターを減らし、新しい例を選択します。報酬を更新するときは、符号が反対の値を取ります。覚えていらっしゃいますか。状態を保存する際、資本を減らすためにプラスにしました。そして、これはマイナスポイントです。

報酬を更新した後、バックプロパゲーションをおこない、重みを更新します。

      if(!StudyNet.backProp(GetPointer(Rewards)))
         return;
      if(GetTickCount() - ticks > 500)
        {
         Comment(StringFormat("%.2f%% -> Error %.8f", iter * 100.0 / (double)(Iterations), StudyNet.getRecentAverageError()));
         ticks = GetTickCount();
        }
     }

ループの反復が終わったら、ユーザーの学習プロセス情報を更新する必要があるかどうかを確認します。この例では、チャートのコメントフィールドを0.5秒ごとに更新しています。

これでループ本体の操作は完了し、データベースからの新しい例に移ります。

ループをすべて繰り返したら、コメント欄をクリアします。その情報をログに出力し、EAのシャットダウンを開始します。

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

EAを終了する際には、その非初期化メソッドで使用済みの動的オブジェクトを削除し、学習済みモデルをディスクに保存します。

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(!!Rewards)
      delete Rewards;
//---
   StudyNet.Save(FileName + ".nnw", 0, 0, 0, 0, true);
  }

第1段階のEAが例のアーカイブを収集した後、第2段階のEAをチャート上で実行するだけで、モデルの訓練プロセスが始まります。第1段階のEAとは異なり、第2段階のEAはストラテジーテスターで実行するのではなく、実際のチャートに添付することに注意してください。EAのパラメータでは、学習プロセスのループの反復回数を示し、プロセスを監視します。

最適な結果を得るためには、第1段階と第2段階を繰り返すことができます。この場合、まず第1段階をN回繰り返し、次に第2段階をM回繰り返すことが可能です。あるいは、第1段階+第2段階の反復のループを何度か繰り返すこともできます。

方策を微調整するために、3番目のEA GE-learning.mq5を使用します。このEAでは古典的な強化学習アルゴリズムを実装しています。EAのすべての関数について詳しく説明するつもりはありません。完全なEAコードは添付ファイルにあります。ティック処理関数OnTickだけを取り上げてみましょう。

第1段階のEAと同様に、新しいローソク足のオープニングイベントのみを処理します。何もなければ、タイミングを待つ間に関数を完了させるだけです。

新しいローソク足のオープンイベントが発生すると、まず、最後の状態、実行された行動、およびエクイティの変化を経験リプレイバッファに保存します。そして、次のローソク足の変化を追跡するために、株式指標をグローバル変数に書き換えます。

void OnTick()
  {
   if(!IsNewBar())
      return;
//---
   float current = (float)AccountInfoDouble(ACCOUNT_EQUITY);
   if(Equity >= 0 && State1.Total() == (HistoryBars * 12 + 9))
      cReplay.AddState(GetPointer(State1), Action, (double)(current - Equity));
   Equity = current;

そして、価格と指標の履歴を更新します。

//---
   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();

システムの現状を説明します。ここでは、生成されたシステム状態の記述が、第1段階の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;
        }
     }
//---
   if(!State1.Add((float)AccountInfoDouble(ACCOUNT_BALANCE)) || !State1.Add((float)AccountInfoDouble(ACCOUNT_EQUITY)) ||
      !State1.Add((float)AccountInfoDouble(ACCOUNT_MARGIN_FREE)) || 
      !State1.Add((float)AccountInfoDouble(ACCOUNT_MARGIN_LEVEL)) ||
      !State1.Add((float)AccountInfoDouble(ACCOUNT_PROFIT)))
      return;
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   int total = PositionsTotal();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            return;
        }
     }
   if(!State1.Add((float)buy_value) || !State1.Add((float)sell_value) || !State1.Add((float)buy_profit) || 
      !State1.Add((float)sell_profit))
      return;

その後、フィードフォワードパスを実行します。フィードフォワードパスの結果に基づいて、行動を決定し、実行します。

   if(!StudyNet.feedForward(GetPointer(State1), 12, true))
      return;
   Action = StudyNet.getAction();
   switch(Action)
     {
      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;
     }

この場合、探査方策は使用しないことにご注目ください。学習した方策に厳格に従います。

ティック処理機能の最後に、時刻を確認します。1日1回、午前0時に、経験リプレイバッファを使ってエージェントの方策を更新します。

   MqlDateTime time;
   TimeCurrent(time);
   if(time.hour == 0)
     {
      int repl_action;
      double repl_reward;
      for(int i = 0; i < 10; i++)
        {
         if(cReplay.GetRendomState(pstate1, repl_action, repl_reward, pstate2))
            return;
         if(!StudyNet.feedForward(pstate1, 12, true))
            return;
         StudyNet.getResults(Rewards);
         if(!Rewards.Update(repl_action, (float)repl_reward))
            return;
         if(!StudyNet.backProp(GetPointer(Rewards), DiscountFactor, pstate2, 12, true))
            return;
        }
     }
//---
  }

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


3.テスト

Go-Exploreアルゴリズムに従い、3つのEAを順次テストしました。

  1. ストラテジーテスターの最適化モードで第1段階のEAを数回連続して起動し、例のアーカイブを作成します。
  2. 第2段階のEAによって数回、方策訓練を繰り返します。
  3. 強化学習アルゴリズムを用いてストラテジーテスターで最終微調整をおこないます。

すべてのテストは、本連載でおこなってきたように、EURUSDの過去のデータ、時間枠H1で実施しました。指標のパラメータは、調整なしでデフォルトのまま使用されました。

テストでは、以下のスクリーンショットに示すように、非常に良い結果が得られました。

テストグラフ テスト結果表

このグラフを見ると、残高の伸びはかなり均等であることがわかります。テストデータはプロフィットファクター6.0、リカバリーファクター3.34に達しました。30回の取引のうち、22回が利益を上げ、その割合は73.3%に達しました。取引の平均利益は平均損失の2倍以上です。取引ごとの最大利益は、最大損失取引の3.5倍です。

EAは買い取引のみを実行し、大きなドローダウンなしに決済したことにご注目ください。売り取引がない理由については、さらなる研究が必要です。

テスト結果は有望ですが、短期間で得られたものです。アルゴリズムの結果を確認するためには、より長い期間にわたる追加実験が必要です。


結論

本稿では、複雑な強化学習問題を解くための新しいアプローチであるGo-Exploreアルゴリズムを紹介しました。これは、望ましいパフォーマンスをより速く達成するために、状態空間内の有望な状態を記憶し、再訪するという考え方に基づいています。Go-Exploreと他のアルゴリズムの主な違いは、ターゲットとなる問題を直接解くのではなく、関連する状態や行動を見つけることに重点を置いていることです。

3つのEAを構築し、順次実行しました。それぞれは、方策学習という共通の目標を達成するために、独自のアルゴリズム機能を実行します。ここでいう方策とは、取引戦略のことです。

このアルゴリズムは過去のデータを使ってテストされ、最高の結果を示しました。しかし、ストラテジーテスターでは短期間で結果が出されした。したがって、実際の口座でEAを使用する前に、より長い、より代表的な期間にわたって包括的なテストとモデルの訓練が必要です。


参照文献

  1. Go-Explore: a New Approach for Hard-Exploration Problems
  2. ニューラルネットワークが簡単に(第35回):内因性好奇心モジュール
  3. ニューラルネットワークが簡単に(第36回):関係強化学習
  4. ニューラルネットワークが簡単に(第37回):疎な注意
  5. ニューラルネットワークが簡単に(第38回):不一致による自己監視型探索

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

# ファイル名 種類 詳細
1 Faza1.mq5 EA 第1段階EA
2 Faza2.mql5 EA 第2段階EA
3 GE-lerning.mq5 EA 政策の微調整EA
4 Cell.mqh クラスライブラリ システム状態記述の構造
5 FQF.mqh クラスライブラリ 完全にパラメータ化されたモデルの作業を整理するためのクラスライブラリ
6 NeuroNet.mqh クラスライブラリ ニューラルネットワークを作成するためのクラスのライブラリ
7 NeuroNet.cl コードベース OpenCLプログラムコードライブラリ


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

添付されたファイル |
MQL5.zip (806.88 KB)
リプレイシステムの開発 - 市場シミュレーション(第8回):指標のロック リプレイシステムの開発 - 市場シミュレーション(第8回):指標のロック
この記事では、MQL5言語を使用しながら指標をロックする方法を見ていきます。非常に興味深く素晴らしい方法でそれをおこないます。
ニューラルネットワークが簡単に(第38回):不一致による自己監視型探索 ニューラルネットワークが簡単に(第38回):不一致による自己監視型探索
強化学習における重要な問題のひとつは、環境探索です。前回までに、「内因性好奇心」に基づく研究方法について見てきました。今日は別のアルゴリズムを見てみましょう。不一致による探求です。
ニューラルネットワークが簡単に(第40回):大量のデータでGo-Exploreを使用する ニューラルネットワークが簡単に(第40回):大量のデータでGo-Exploreを使用する
この記事では、長い訓練期間に対するGo-Exploreアルゴリズムの使用について説明します。訓練時間が長くなるにつれて、ランダムな行動選択戦略が有益なパスにつながらない可能性があるためです。
リプレイシステムの開発—市場シミュレーション(第7回):最初の改善(II) リプレイシステムの開発—市場シミュレーション(第7回):最初の改善(II)
前回の記事では、可能な限り最高の安定性を確保するために、レプリケーションシステムにいくつかの修正を加え、テストを追加しました。また、このシステムのコンフィギュレーションファイルの作成と使用も開始しました。