
ニューラルネットワークが簡単に(第47回):連続行動空間
はじめに
前回の記事では、取引方向を決定するためだけにエージェントを訓練しました。エージェントの行動範囲は4つの選択肢に限られていました。
- 買う
- 売る
- 維持/待機
- すべてのポジションを閉じる
ここには資本とリスク管理の機能は見られません。すべての取引で最小ロットを使用しました。これは訓練アプローチを評価するには十分ですが、取引戦略を構築するには十分ではありません。収益性の高い取引戦略には、絶対に資金管理アルゴリズムが必要です。
さらに、安定した取引戦略を立てるためには、リスクを管理する必要があります。このブロックも私たちのデザインにはありません。EAは、新しい取引ローソク足ごとに市場の状況を評価し、取引操作を決定します。しかし、今後予定されているすべてのバーでは、口座にとってリスクが伴います。バー内での値動きが、残高に悪影響を及ぼす可能性があります。そのため、ストップロスの利用が常に推奨されています。このシンプルなアプローチにより、取引ごとのリスクを抑えることができます。
1.連続行動空間訓練の特徴
エージェントを訓練し、その取引方策を構築する際に、これらの特徴を考慮する必要があるのは論理的です。しかし、ここで疑問が生じます。取引量とポジションの終値レベルを予測するモデルをどのように訓練するかということです。これは教師あり学習アルゴリズムを使えば、教師が提供する目標値を指定することで簡単に実現できますが、強化学習アルゴリズムを使う場合、いくつかの複雑な問題があります。
覚えておいでかもしれませんが、以前、強化モデルの訓練に報酬予測と最大報酬を受け取る確率という2つのアプローチを使いました。
この問題を解決する1つの方法として、取引操作のすべてのパラメータに離散値を定義し、可能なオプションごとに個別の行動を作成することが考えられます。これによって、資本とリスク管理のいくつかの側面を考慮に入れることができるようになりますが、
このアプローチに欠点がないわけではありません。個別のトランザクションパラメータを選択するには、データ準備段階でいくつかの作業が必要です。その選択は常に、選択肢の多さとエージェントの意思決定における十分な柔軟性との妥協点となります。この場合、可能な行動の組み合わせの数が大幅に増えるため、モデルが複雑になり、学習時間が長くなります。結局のところ、訓練中は、可能な行動のそれぞれに対する報酬を研究する必要があります。
たとえば、取引量、3つのストップロスレベル、5つのテイクプロフィットレベルについて3つの離散値だけを取るとすると、2つの取引方向の行動スペースを定義するために90 (3 * 3 * 5 * 2 = 90)の要素が必要になります。また、ポジションの保有と決済の動作も忘れてはなりません。エージェントの行動範囲には、すでに92の選択肢があります。
このようなエージェントの行動の自由度の低さは、モデル出力のニューロン数の大幅な増加につながります。取引パラメータの各離散値を追加すると、進行中のニューロン数が増加します。
さらに、より複雑なモデルを訓練するためには、訓練セットの例を増やす必要があり、その結果、さまざまな問題が発生します。
ただし、連続的な行動空間でエージェントを訓練するためのアルゴリズムと呼ばれるアプローチもあります。このようなアルゴリズムによって訓練されたエージェントは、連続的な値の範囲から行動を選択することができます。これにより、取引量、ストップロス、テイクプロフィットレベルなどの取引パラメータをより柔軟かつ正確に管理することができます。
連続行動空間でエージェントを訓練するための最も一般的なアルゴリズムの1つは、Deep Deterministic Policy Gradient (DDPG)です。DDPGでは、モデルは、ActorとCriticの2つのニューラルネットワークで構成されています。Actorは現在の状態から最適な行動を予測し、Criticはその行動を評価します。似たような解決策は、「有利なActor-Criticアルゴリズム」という記事ですでに見ています。これらのアルゴリズムでは、アプローチに共通点がありますが、Actorの訓練アルゴリズムに違いがあります。
DDPGでは、決定論的なポリシーを最適化するために、勾配リフティングを用いてActorを訓練します。Actorは、有利なActor-Criticアルゴリズムのように行動の確率分布をモデル化するのではなく、現在の状態に基づいて最適な行動を直接予測します。
DDPGにおけるActorの学習は、Actorの行動に対するCritic値関数の勾配を計算し、この勾配を用いてActorのパラメータを更新することでおこなわれます。少し複雑に聞こえますが、ActorはCriticのスコアを最大化する最適な行動を見つけることができます。
重要なのは、DDPGがオフポリシーアルゴリズムを指していることです。モデルは、現在の意思決定戦略に関係なく、過去の環境との相互作用から得られたデータで訓練されます。このアルゴリズムの重要な特性により、環境のダイナミクスを予測することが困難であったり、不正確であったりするような、複雑で確率的な環境でも使用することができます。EDLアルゴリズムをテストした際、金融市場予測の質が低いことに遭遇しました。
DDPG (Deep Deterministic Policy Gradient)アルゴリズムは、DQN (Deep Q-Network)の基本原理に基づいており、経験再生バッファやターゲットモデルなど、そのアプローチの多くを取り入れています。アルゴリズムを詳しく見てみましょう。
前述のように、このモデルは、ActorとCriticの2つのニューラルネットワークで構成されています。Actorは環境の状態を入力として受け取ります。Actorの出力では、連続的な値の分布から行動を得ます。この場合、取引量、ストップロス、テイクプロフィットレベルを設定します。モデルアーキテクチャと問題文に応じて、絶対値または相対値を使用することができます。環境の探索レベルを上げるために、生成される行動にノイズを加えることができます。
Actorが選択した行動を実行し、環境の新しい状態に移動します。私たちがとった行動に応じて、環境から報酬を受け取ります。
「状態-行動-新しい状態-報酬」のデータセットを経験値再生バッファに集めます。これは強化学習アルゴリズムの場合の典型的な行動です。
DQNと同様に、経験値再生バッファからモデルを訓練するためのパッケージを選択します。この訓練データパッケージからの状態は、Actorの入力に供給されます。パラメータを変更する前に、おそらく経験値再生バッファに保存されているものと同様の行動が得られるでしょう。しかし、利点であるActor-Criticとは異なり、Actorは確率分布ではなく、連続分布からの行動を返します。
与えられた行動の価値を評価するために、現在の状態と生成された行動をCriticに送信します。Criticは受け取ったデータに基づいて、従来のDQNと同じように報酬を予測します。
DQNと同様に、Criticは経験再生バッファから予測された報酬と実際の報酬の間の標準偏差を最小化するように訓練されます。総合的な政策を構築するために、ターゲットネットモデルが使われます。しかし、その後の状態の評価には、状態と行動のデータを設定する必要があるので、Actorのターゲットモデルも使って、その後の状態から行動を形成することになります。
DDPGのハイライトは、Actorの訓練に目標出力値を使わないことです。その代わりに、行動に対するCriticモデルの誤差勾配値を取り、それをさらにActorモデルに通すだけです。
このように、CriticのQ関数を学習する一方で、Agentの行動を最適化するために、行動に対する誤差勾配を利用します。ActorはQ関数の不可欠な一部だと言えます。Q関数の訓練は、Actor関数の最適化につながります。
しかし、ここで注意しなければならないのは、Criticを訓練する過程で、状態-動作のペアを最も正しく評価するために、そのパラメータを最適化することです。Actorを訓練する間、予測される報酬が増えるように、他の条件がすべて同じであるように、Actorのパラメータを最適化します。
この手法の著者は、ターゲットモデルをソフトに更新することを推奨しています。ある頻度でターゲットモデルを訓練済みモデルに置き換える単純な置き換えは、訓練済みモデルのパラメータに対する更新率を考慮したターゲットモデルのパラメータの再計算によって置き換えられます。著者らによれば、このアプローチはターゲットモデルの更新を遅くしますが、訓練の安定性を高めます。
2.MQL5を使用した実装
DDPG (Deep Deterministic Policy Gradient)法を理論的に紹介したので、MQL5を使った実践的な実装に移りましょう。まずは、対象モデルのソフトアップデートの準備から始めます。2つのパラメータの重み付き和の関数自体は複雑ではありませんが、ポイントが2つあります。
まず、この操作はすべてのモデルパラメータで実行されなければなりません。各パラメータの動作は、同じモデルの他のパラメータから完全に独立しているため、簡単に並列実行することができます。
次に、モデルの訓練と操作のためのすべての操作は、OpenCLのコンテキストで実行されます。コンテキストメモリとメインメモリ間のデータコピー操作はかなり高価です。常にそれを最小限に抑える努力をしてきました。OpenCLの文脈では、パラメータも再計算されるべきなのは論理的です。
2.1.ターゲットモデルのソフトアップデート
まず、操作を実行するためのSoftUpdateカーネルを作成します。カーネルのアルゴリズムは非常にシンプルです。カーネルパラメータには、2つのデータバッファ(ターゲットモデルと訓練済みモデルのパラメータ)へのポインタと、定数としての更新係数を渡します。
__kernel void SoftUpdate(__global float *target, __global const float *source, const float tau ) { const int i = get_global_id(0); target[i] = target[i] * tau + (1.0f - tau) * source[i]; }
それぞれのスレッドで1つのパラメータだけを更新します。したがって、スレッド数は更新されるパラメータの数に等しくなります。
次に、メインプログラムの側にプロセスを配置しなければなりません。
モデルパラメータは、ニューラル層のタイプによって異なるオブジェクトに分散されていることを思い出してください。つまり、ニューラル層の作業を整理するために、各クラスにパラメータを更新するメソッドを追加する必要があります。CNeuronBaseOCLニューラル層の基本クラスの例を見てみましょう。
現在のニューラル層のパラメータを更新するので、メソッドのパラメータに学習済みモデルのニューラル層へのポインタと更新係数を渡すだけでよくなります。
bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(!OpenCL || !Weights || !source || !source.Weights) return false;
メソッド本体では、受け取ったニューラル層オブジェクトへのポインタの有効性を確認します。それとともに、必要な内部オブジェクトへのポインタを確認します。
ここでは、2つのニューラル層の種類とパラメータ行列の次元の対応を確認します。
if(Type() != source.Type()) return false; if(Weights.Total() != source.Weights.Total()) return false;
コントロールブロックの受け渡しに成功したら、カーネルへのパラメータの受け渡しを準備します。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {Weights.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, Weights.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, source.getWeightsIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
カーネルを実行キューに入れます。すべての段階でプロセスを管理することを忘れてはなりません。
if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
メソッドの実行を完了します。
このクラスでは、さまざまなアーキテクチャのニューラル層の作業を配置するオブジェクトはすべてCNeuronBaseOCL基底クラスから継承されているので、作成されたメソッドはすべてのクラスで継承されますが、これでは基本クラスの重み行列を更新することしかできません。内部の最適化可能な補助オブジェクトを追加するすべてのクラスで、このメソッドをオーバーライドすべきです。例えば、CNeuronConvOCL畳み込み層では、畳み込みパラメータの行列を追加しました。これを更新するには、WeightsUpdateメソッドをオーバーライドします。継承されたメソッドのオーバーライドをサポートするため、すべてのメソッドのパラメータはそのままです。
bool CNeuronConvOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau) { if(!CNeuronBaseOCL::WeightsUpdate(source, tau)) return false;
メソッド本体では、コントロールのブロック全体を繰り返しません。代わりに、親クラスのメソッドを呼び出し、操作の結果を確認します。
次に、パラメータで、ニューラルネットワークのベースクラスのオブジェクトへのポインタを受け取ります。これは意図的におこなわれています。親クラスの型を指定することで、その子孫クラスにポインタを渡すことができます。これは、すべての継承クラスに仮想メソッドを配置するために必要なものです。
しかし問題は、この状態ではパラメータで得られた層の畳み込み重み行列にアクセスできないことです。親クラスにはそのようなオブジェクトは存在しません。これは畳み込み層のクラスにのみ現れます。畳み込み層へのポインタがパラメータとして渡されることは間違いません。親クラスのメソッドでは、現在のニューラル層の型とパラメータで得られた型の対応を確認しました。この畳み込み層オブジェクトを扱うには、結果のポインタを動的畳み込み層オブジェクトに代入すればよいのです。その後、行列サイズの適合性を確認します。
CNeuronConvOCL *temp = source; if(WeightsConv.Total() != temp.WeightsConv.Total()) return false;
次に、データを転送し、カーネルを実行キューに入れる手順を繰り返します。適用されたデータバッファオブジェクトのみが変更されることにご注意ください。
uint global_work_offset[1] = {0}; uint global_work_size[1] = {WeightsConv.Total()}; ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsConv.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, temp.WeightsConv.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
同様にして、ニューラル層の他のすべてのクラスにメソッドを作成し、最適化されたパラメータを持つオブジェクトを追加しました。クラスメソッドの完全なコードは割愛します。添付ファイルにあります。
ライブラリの動作アルゴリズムでは、ユーザーはモデルのニューラル層に直接アクセスすることはできません。ユーザーは常にニューラルネットワークモデルの最上位クラスで作業します。したがって、ニューラル層クラスにメソッドを追加した後、CNet::WeightsUpdateモデルクラスに同じ名前のメソッドを作成します。パラメータでは、訓練済みニューラルネットワークへのポインタと更新係数を受け取ります。メソッド本体では、モデルのすべてのニューラルネットワークを検索し、ニューラル層を更新するメソッドを呼び出すサイクルを準備します。アルゴリズムはいたってシンプルです。記事の中でそのコードを提供する意味はありません。添付ファイルをご覧ください。
2.2.ActorとCritic間のデータ交換
モデルの更新を手配した後は、そのままモデル訓練の準備に進みます。モデルは、DDPGアルゴリズムと先行研究のアプローチとの一種の共生です。特に、両方のニューラルネットワーク(ActorとCritic)に対して、ソースデータの予備処理を1つのブロックでおこなうことにしました。
Actorは、得られた環境の状態に基づいて最適な行動を決定します。Criticは、環境の状態とActorの行動の説明を入力として受け取ります。受け取ったデータに基づいて、期待報酬の予測をおこないます(Actorの行動を評価する)。このように、ActorとCriticは環境の説明を受けます。繰り返しの操作を最小限にするため、Actor本体にソースデータの事前処理のブロックを整理することにしました。Criticは、Actorの潜在的な状態から環境の状態の圧縮された表現を伝えるべきです。メインプログラム側のActorとCritic間のデータ転送量を最小限に抑えるため、個々のデータバッファではなく、ソースデータモデルへのポインタとソースデータを持つ層の識別子を直接転送するフォワードパスとバックワードパスのメソッドを追加で作成することにしました。
CNet::feedForwardフォワードパスメソッドの準備を検討します。メソッドのパラメータは、ニューラルネットワークへの2つのポインタ(メインと追加のソースデータ)と、これらのネットワーク内のニューラルネットワーク層の2つの識別子を転送することを規定しています。
bool CNet::feedForward(CNet *inputNet, int inputLayer=-1, CNet *secondNet = NULL, int secondLayer = -1) { if(!inputNet || !opencl) return false;
パラメータにデフォルト値が追加され、メインソースデータモデルへのポインタを1つ渡すだけでメソッドを使用できるようになりました。
メソッド本体では、受け取ったメインソースデータモデルへのポインタを確認します。データがない場合は、falseでメソッドを終了します。
次に、メイン入力データモデルのニューラル層のIDを確認します。何らかの理由で指定されていない場合は、モデルの最後のニューラル層を使用します。
if(inputLayer<0) inputLayer=inputNet.layers.Total()-1;
次の段階では、追加データにアクセスするための作業を手配します。データバッファオブジェクトへのNULLポインタを作成します。追加ソースデータのモデルへのポインタの関連性を確認します。
CBufferFloat *second = NULL; bool del_second = false; if(!!secondNet) { if(secondLayer < 0) secondLayer = secondNet.layers.Total() - 1; if(secondNet.GetOpenCL() != opencl) { secondNet.GetLayerOutput(secondLayer, second); if(!!second) { if(!second.BufferCreate(opencl)) { delete second; return false; } del_second = true; } } else { if(secondNet.layers.Total() <= secondLayer) return false; CLayer *layer = secondNet.layers.At(secondLayer); CNeuronBaseOCL *neuron = layer.At(0); second = neuron.getOutput(); } }
追加ソースデータのモデルへの有効なポインタがある場合、イベントの開発には2つの選択肢があります。
- 追加のソースデータモデルと現在のモデルが異なるOpenCLコンテキストに読み込まれている場合、どのような場合でもデータを再読み込みしなければなりません。対応するデータモデル層から新しいバッファにデータをコピーし、必要なコンテキストのバッファを作成します。
- どちらのモデルも同じOpenCLコンテキストにあります。データはすでにコンテキストメモリに存在しています。目的のニューラル層の結果バッファへのポインタをコピーするだけでよくなります。
追加ソースデータのバッファを受け取った後、メインソースデータのモデルに移ります。上記と同様に、モデルが同じOpenCLコンテキストのメモリに読み込まれているかどうかを確認します。読み込まれていない場合は、単に元のデータをバッファにコピーし、先に開発したフォワードパスメソッドを呼び出します。
if(inputNet.opencl != opencl) { CBufferFloat *inputs; if(!inputNet.GetLayerOutput(inputLayer, inputs)) { if(del_second) delete second; return false; } bool result = feedForward(inputs, 1, false, second); if(del_second) delete second; return result; }
両方のモデルが同じOpenCLコンテキストにある場合、ソースデータ層をソースデータモデルから指定されたニューラル層に置き換えます。
CLayer *layer = inputNet.layers.At(inputLayer); if(!layer) { if(del_second) delete second; return false; } CNeuronBaseOCL *neuron = layer.At(0); layer = layers.At(0); if(!layer) { if(del_second) delete second; return false; } if(layer.At(0) != neuron) if(!layer.Update(0, neuron)) { if(del_second) delete second; return false; }
その後、すべてのニューラル層を列挙し、フォワードパスメソッドを呼び出すというサイクルを準備します。
for(int l = 1; l < layers.Total(); l++) { layer = layers.At(l); neuron = layer.At(0); layer = layers.At(l - 1); if(!neuron.FeedForward(layer.At(0), second)) { if(del_second) delete second; return false; } } //--- if(del_second) delete second; return true; }
ループの反復が完了したら、メソッドをtrueで終了します。
同様の方法でCNet::backPropメソッドを作ってみましょう。そのコードは添付ファイルにあります。
Criticを訓練する際には、この2つのメソッドを使いますが、Actorを訓練するためには、別のリバースパスの方法が必要です。実は、バックワードパス法では、誤差勾配をニューラル層に通す前に、まずフォワードパス結果の目標値からの偏差を求めます。DDPG法では、Actorはこのプロセスを省くことができます。このアルゴリズムの実用的な実装のために、CNet::backPropGradientメソッドが作成されました。
メソッドのパラメータには、2つのデータバッファへのポインタ(追加ソースデータと誤差勾配)が渡されます。どちらのバッファもデフォルト値を持っているので、パラメータを指定せずにメソッドを実行できます。
bool CNet::backPropGradient(CBufferFloat *SecondInput = NULL, CBufferFloat *SecondGradient = NULL) { if( ! layers || ! opencl) return false; CLayer *currentLayer = layers.At(layers.Total() - 1); CNeuronBaseOCL *neuron = NULL; if(CheckPointer(currentLayer) == POINTER_INVALID) return false;
メソッド本体では、まずニューラル層の動的配列のオブジェクトへのポインタとOpenCLコンテキストの関連性を確認します。必要なローカル変数を宣言しましょう。
次に、モデルのすべてのニューラル層に誤差勾配を分配するループを配置します。
//--- Calc Hidden Gradients int total = layers.Total(); for(int layerNum = total - 2; layerNum >= 0; layerNum--) { CLayer *nextLayer = currentLayer; currentLayer = layers.At(layerNum); if(CheckPointer(currentLayer) == POINTER_INVALID) return false; neuron = currentLayer.At(0); if(!neuron || !neuron.calcHiddenGradients(nextLayer.At(0), SecondInput, SecondGradient)) return false; }
処理を準備する際には、誤差勾配がすでに最後のニューラル層のバッファにあると仮定します。これはDDPGアルゴリズム(Critic誤差勾配に基づいたAgent動作)によって提供されます。誤差勾配の有無はコントロールできません。メソッドを適用するのはユーザーの責任です。
誤差勾配を分配した後、重み付け係数行列を更新します。
CLayer *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), SecondInput)) return false; }
ここで、ニューラル層のメソッドでは、カーネルを実行キューに入れるだけで、その後のフォワードパスを実行する前に、リバースパスの操作が完了していることを確認する必要があります。この確信を得るために、重み行列の最後のカーネル更新の結果を読み込みます。
bool result=false; for(int layerNum = 0; layerNum < total; layerNum++) { currentLayer = layers.At(layerNum); CNeuronBaseOCL *temp = currentLayer.At(0); if(!temp) continue; if(!temp.TrainMode() || !temp.getWeights()) continue; if(!temp.getWeights().BufferRead()) continue; result=true; break; } //--- return result; }
これで、ライブラリのメソッドとクラスの更新作業は終了です。完全なEAコードは添付ファイルにあります。
2.3.モデル訓練EAの作成
次に、DDPGアルゴリズムを使ったモデルの作成と訓練に移ります。訓練はDDPG\Study.mq5 EAで実装されます。
すでに述べたように、作成されるモデルはDDPGとこれまでに議論されたアプローチの要素を組み合わせたものとなります。これは、モデルのアーキテクチャに反映されます。アーキテクチャを記述するためにCreateDescriptions関数を作成しましょう。
パラメータでは、Actorニューラル層とCriticニューラル層のアーキテクチャを記述するオブジェクトを記録するための2つの動的配列へのポインタを受け取ります。関数本体では、受け取ったポインタの妥当性を確認し、必要であれば新しい配列オブジェクトを作成します。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
まずはActorのアーキテクチャーについて説明します。ここではGCRLの開発を利用し、2つのソースデータのストリームを持つモデルを構築します。Actorの意思決定は、環境の現状(過去のデータ)に基づいておこなわれます。そのために適切なサイズのソースデータ層を作成します。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!actor.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(!actor.Add(descr)) { delete descr; return false; }
生データはバッチ正規化層で処理され、畳み込み層のブロックを通過します。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count - 1; descr.window = 2; descr.step = 1; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = 8; descr.step = 8; descr.window_out = 8; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
次に、2つの全結合層でデータを圧縮します。これらのことから、以前使われていたエンコーダを思い出すかもしれません。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
取引の方向性とストップロス/テイクプロフィットレベルを決定するには、市場の状況を判断すれば十分かもしれません。しかし、資金管理機能としては十分ではありません。この段階では、モデル問題を記述するときと同じように、口座の状態に関する情報を追加します。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 256; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
この層のIDと、その結果のベクトルのサイズを覚えておきます。この層から、環境の状態の潜在的表現をCriticの初期データとします。
次に来るのは、全結合層からの意思決定ブロックです。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 256; descr.activation = LReLU; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Actorの出力には、取引量、ストップロス、テイクプロフィット(買い3要素、売り3要素)を表す6要素の全結合層があります。
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 6; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
簡略化した形では、ポジションを閉じたり、適切なエントリ/エグジットポイントを待ったりする行動の要素は追加しません。ポジションはストップロスまたはテイクプロフィットで決済することを想定しています。取引指標のいずれかが不正確な値であった場合、取引はおこなわれなかったことになります。
Criticモデルは、現在の環境状態とActorの行動を用いて報酬を予測します。ここでの場合、情報の流れはどちらもActorモデルから来ますが、ニューラル層は異なり、それに応じてデータバッファも異なります。ニューラルデータ連結層を使って、2つのデータストリームを連結します。これはCriticモデルのアーキテクチャに以下のように反映されます。最初のデータストリーム(現在の状態の潜在的表現)をソースデータ層に転送します。この層のサイズは、データを取る予定のActorニューラル層のサイズに対応させます。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = 256; descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
データは別のモデルの内部状態から来るので、データの正規化層をスキップすることができます。
次に、連結層を使って2つの情報のストリームを結合します。追加データのサイズは、Actor結果層のサイズに等しくなります。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = 128; descr.window = prev_count; descr.step = 6; descr.optimization = ADAM; descr.activation = LReLU; if(!critic.Add(descr)) { delete descr; return false; }
次に、2つの全結合層からなる決定ブロックが来ます。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 128; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; }
Criticの出力には、活性化関数を持たない1要素の全結合層が使用されています。ここで、予想される報酬を得ることを期待します。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 1; descr.optimization = ADAM; descr.activation = None; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
将来、環境状態の潜在的表現の層の識別子と混同しないように、マクロ置換の形で定数を定義します。
#define LatentLayer 6
モデルのアーキテクチャが決まったので、次はEAのアルゴリズムに取りかかります。まず、EAを初期化するためのOnInitメソッドを作成します。メソッドの冒頭で、前回と同様に、指標と取引操作のオブジェクトを初期化します。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ 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;
次に、事前に訓練されたモデルの読み込みを試みます。モデルが存在しなければ、作り始めます。
ここで、1つのニュアンスに注意を払う必要があります。以前は訓練モデルを作成し、それをターゲットモデルに完全にコピーしていたが、今回は訓練モデルとターゲットモデルをランダムなパラメータで初期化します。しかも、どちらのモデルも同じアーキテクチャを採用しています。
//--- load models float temp; if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true) || !TargetActor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) || !TargetCritic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic)) { delete actor; delete critic; return INIT_FAILED; } if(!Actor.Create(actor) || !Critic.Create(critic) || !TargetActor.Create(actor) || !TargetCritic.Create(critic)) { delete actor; delete critic; return INIT_FAILED; } delete actor; delete critic; //--- }
次に、すべてのモデルを1つのOpenCLコンテキストに転送します。これにより、モデル間で情報を転送する際、物理的なコピーなしにデータバッファへのポインタを操作できるようになります。
COpenCLMy *opencl = Actor.GetOpenCL(); Critic.SetOpenCL(opencl); TargetActor.SetOpenCL(opencl); TargetCritic.SetOpenCL(opencl);
続いて、モデルアーキテクチャの適合性を監視するブロックが続きます。
Actor.getResults(Result); if(Result.Total() != 6) { PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 6, Result.Total()); return INIT_FAILED; } ActorResult = vector<float>::Zeros(6); //--- 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.GetLayerOutput(LatentLayer, Result); int latent_state = Result.Total(); Critic.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; }
グローバル変数を初期化し、メソッドを終了します。
PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); FirstBar = true; Gradient.BufferInit(AccountDescr, 0); Gradient.BufferCreate(opencl); //--- return(INIT_SUCCEEDED); }
各エピソードが終了するごとに、ターゲットモデルが更新されることにしました。そのため、この関数はEAの非初期化メソッドに含まれています。まず、ターゲットモデルを更新し、保存します。訓練済みモデルではなく、ターゲットモデルを保存しているため、1つのエピソードに対するモデルの再訓練は最低限にします。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- TargetActor.WeightsUpdate(GetPointer(Actor), Tau); TargetCritic.WeightsUpdate(GetPointer(Critic), Tau); TargetActor.Save(FileName + "Act.nnw", Actor.getRecentAverageError(), 0, 0, TimeCurrent(), true); TargetCritic.Save(FileName + "Crt.nnw", Critic.getRecentAverageError(), 0, 0, TimeCurrent(), true); delete Result; }
モデルを訓練する実際のプロセスは、行動フローで実行されます。今回の場合、履歴ウォークスルーモードのストラテジーテスターでモデルを訓練します。再生バッファは作りません。その役割は、ストラテジーテスター自身が担うことになります。このように、学習プロセス全体がOnTick関数の中に配置されています。
関数の冒頭で、新しいローソクを開くイベントを確認します。その後、指標のデータと、商品の価格の動きに関する履歴データをバッファで更新します。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return; //--- 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(); Symb.Refresh(); Symb.RefreshRates();
データ作成プロセスは、以前に説明したEAから完全に移行されています。ここでそれを説明する意味はありません。EAのすべてのコードト関数は添付ファイルでご覧ください。
初期データを準備した後、訓練済みモデルのフォワードパスが以前に実行されたかどうかを確認します。フォワードパスがあれば、リバースパスをおこないます。現在の状態を評価するために、ターゲットモデルのフォワードパスを実行します。まず、ターゲットとなるActorモデルのフォワードパスを実行します。形成された行動を考慮し、Criticのターゲットモデルのダイレクトパスを実行します。その結果得られた値に、口座残高の変化という形で、システムの実際の報酬を加えます。また、ポジションがない場合は、Actorに積極的な取引を促すためにペナルティを課し、まずCritic、次にActorのリバースパスを呼び出します。
if(!FirstBar) { if(!TargetActor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) return; if(!TargetCritic.feedForward(GetPointer(TargetActor), LatentLayer, GetPointer(TargetActor))) return; TargetCritic.getResults(Result); float reward = (float)(account[0] - PrevBalance + Result[0]); if(account[0] == PrevBalance) if((buy_value + sell_value) == 0) reward -= 1; Result.Update(0, reward); if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(PrevAccount), GetPointer(Gradient))) return; }
Criticのリバースパスでは、ターゲット値のバッファとActorモデルへのポインタを渡す更新されたbackPropメソッドを使用することにご注意ください。同時に、潜在層の識別子は示しません。なぜなら、以前に(ダイレクトパス中に)オブジェクトを置き換えたからです。
Actorのリバースパスには、Criticのリバースパスからの勾配がモデル内を伝播するbackPropGradientメソッドを使用します。
CriticとActorのリバースパスをおこなうことで、モデルのQ関数を最適化することができます。
次に、訓練済みモデルのフォワードパスをおこないます。
if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account))) return; if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor))) return;
ここで、次の点に注目する価値があります。Q関数を訓練する過程では、期待報酬の予測の質を向上させるだけです。Actorの行動の収益性を高めるための訓練はしていません。この目的のために、DDPGアルゴリズムは、予測される報酬を増加させる方向にActorのパラメータを更新します。この時点では、誤差勾配をCriticに渡しているだけで、パラメータを更新していないことは注目に値します。したがって、TrainModeフラグをfalseに設定することにより、Critic重み行列の更新を無効にします。Actorのリバースパスの後、フラグをtrueに戻します。
if(!FirstBar) { Critic.getResults(Result); Result.Update(0, Result.At(0) + MathAbs(Result.At(0) * 0.0001f)); Critic.TrainMode(false); if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient))) return; Critic.TrainMode(true); }
次のバーで操作するための値をグローバル変数に保存します。
FirstBar = false; PrevAccount.AssignArray(GetPointer(Account)); PrevAccount.BufferCreate(Actor.GetOpenCL()); PrevBalance = account[0]; PrevEquity = account[1];
あとはActorの操作結果を読み解き、取引操作をおこなうだけです。この例では、取引量と取引レベルの絶対値を提供するようにActorを訓練します。データを正規化し、水準を具体的な価格値に変換するだけです。
vector<float> temp; Actor.getResults(temp); float delta = MathAbs(ActorResult - temp).Sum(); ActorResult = temp; //--- double min_lot = Symb.LotsMin(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); double buy_lot = MathRound((double)ActorResult[0] / min_lot) * min_lot; double sell_lot = MathRound((double)ActorResult[3] / min_lot) * min_lot; double buy_tp = NormalizeDouble(Symb.Ask() + ActorResult[1], Symb.Digits()); double buy_sl = NormalizeDouble(Symb.Ask() - ActorResult[2], Symb.Digits()); double sell_tp = NormalizeDouble(Symb.Bid() - ActorResult[4], Symb.Digits()); double sell_sl = NormalizeDouble(Symb.Bid() + ActorResult[5], Symb.Digits()); //--- if(ActorResult[0] > min_lot && ActorResult[1] > stops && ActorResult[2] > stops && buy_sl > 0) Trade.Buy(buy_lot, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); if(ActorResult[3] > min_lot && ActorResult[4] > stops && ActorResult[5] > stops && sell_tp > 0) Trade.Sell(sell_lot, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
前述したように、適切な状況を待つというActorの個別の行動は用意していません。その代わりに、無効な取引パラメータ値を使用します。したがって、取引要求を送信する前に、受信したパラメータが正しいかどうかを確認します。
もう1点、考慮されているアルゴリズムにはないが、私が追加した点があります。これは、検討されている方法と矛盾するものではありません。Actorの訓練ポリシーにいくつかの制限を加えるだけです。こうすることで、建てたポジションの量と取引レベルの大きさに、何らかの枠組みを導入したかったのです。
不正確な、あるいは誇張された取引パラメータを受け取った場合、指定された範囲内でランダムな目標値のベクトルを形成し、教師あり学習法に似たActorのリバースパスを実行します。私の考えでは、これはActorの仕事の結果を指定された範囲内に戻すものです。
if(temp.Min() < 0 || MathMax(temp[0], temp[3]) > 1.0f || MathMax(temp[1], temp[4]) > (Symb.Point() * 5000) || MathMax(temp[2], temp[5]) > (Symb.Point() * 2000)) { temp[0] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5)); temp[3] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5)); temp[1] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel())); temp[4] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel())); temp[2] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel())); temp[5] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel())); Result.AssignArray(temp); Actor.backProp(Result, GetPointer(PrevAccount), GetPointer(Gradient)); } }
もちろん、拘束活性化関数(シグモイドなど)を使うこともできますが、それでは可能な値の範囲を厳しく制限することになります。その上、訓練中にすぐに限界値に達してしまい、モデルのさらなる訓練が遅れることもありました。
すべての操作を終えたら、次のティックを待つ待機モードに入ります。
EAのすべてのコードと記事で使用したすべてのプログラムは、添付ファイルにあります。
3.検証
モデル訓練EAの作業が完了したので、その結果を確認する段階に移ります。前回と同様、モデルは2023年初頭からのEURUSD H1の履歴データで訓練されます。すべての指標とモデルの訓練パラメータはデフォルト値を使用しました。
リアルタイムでモデルを訓練することで、独自の調整をおこない、複数のエージェントを並行して使用することを防ぎます。したがって、EAアルゴリズムが正しく動作するかどうかの最初の確認は、シングルランモードでおこないました。その後、低速最適化モードが選択され、1つの局所最適化エージェントのみが起動されました。
訓練の反復回数を調整するために、EAアルゴリズムでは使用されない外部パラメータAgentが追加されました。
約3000回のパスの後、訓練セットで利益を生み出せるモデルを得ることができました。5ヶ月の訓練期間中、モデルは334件の取引をおこない、そのうちの84%以上が黒字でした。その結果、当初資本金の33%の利益を得ました。同時に、残高の減少幅は1%未満、資産では7.6%だでした。プロフィットファクターは26を超え、リカバリーファクターは3.16に達しました。下のグラフは、残高が増加傾向にあることを示しています。残高の線はほとんど常に資産の線より下にあります。これはポジションが正しい方向に開かれていることを示しています。同時に、保証金に対する負担は約20%です。これはかなり高い数字ですが、累積利益を上回るものではありません。
残念なことに、EAの成果は訓練セット以外では控えめであることが判明しました。
結論
本稿では、連続的な行動空間における強化学習の適用を検討し、DDPG (Deep Deterministic Policy Gradient)法を紹介しました。このアプローチは、取引を成功させるための重要な側面である資本とリスクを管理するためのエージェントを訓練する新たな機会を開くものです。
モデルを訓練するためのEAを開発し、テストしました。取引の方向性を予測するだけでなく、取引量、ストップロス、テイクプロフィットレベルも決定します。これにより、エージェントはより効率的に投資を管理することができます。
テストでは、訓練セットで利益を生み出すモデルを訓練することができました。残念ながら、訓練セット以外で同様の結果を得るには、提供された訓練では不十分でした。実装のボトルネックは、モデルのオンライン訓練です。ここでは、環境調査のレベルを向上させ、モデルの訓練時間を短縮するために、複数のエージェントを並行して使用することができません。
得られた結果から、訓練セット以外でも安定した動作ができるようにモデルを訓練することが可能であると期待できます。
参考文献リスト
記事で使用されているプログラム
# | 名前 | 種類 | 詳細 |
---|---|---|---|
1 | Study.mq5 | EA | エージェント訓練 EA |
2 | Test.mq5 | EA | モデルテストEA |
3 | NeuroNet.mqh | クラスライブラリ | ニューラルネットワークを作成するためのクラスのライブラリ |
4 | NeuroNet.cl | コードベース | OpenCLプログラムコードライブラリ |
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/12853





- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索