取引におけるニューラルネットワーク:適応型エージェント行動のための階層的スキル発見(最終章)
はじめに
前回の記事では、HiSSD (Hierarchical and Separate Skill Discovery)フレームワークの理論的基盤について解説しました。HiSSDは、複雑かつ高度に動的な環境で動作するマルチエージェントシステムをオフライン学習するための最新のアプローチです。このフレームワークにより、エージェントは効果的な協調パターンを学習し、変化する環境へ適応できるようになります。当初、HiSSDはシミュレーション環境で検証されましたが、そのアーキテクチャと設計思想は、数秒のうちに市場環境が大きく変化する金融市場において特に有効です。このような環境では、高度なトレードエージェントが迅速かつ協調的に対応することが求められます。
HiSSDの大きな特長の一つは、高い適応性にあります。トレードでは、経済指標、市場参加者の行動、あるいはニュースなどによって市場環境が急激に変化することがあります。そのような状況でも、HiSSDによって学習したエージェントは、モデル全体を再学習することなく即座に適応できます。これは、共通スキルとタスク固有スキルから構成される二層構造のスキル分解アーキテクチャによって実現されています。共通スキルは、トレンド認識やリスク評価など、多様な状況で利用できる一般的な行動パターンを表します。一方、タスク固有スキルは、特殊あるいは高度に専門化された状況における行動を決定します。この二層構造により、HiSSDエージェントは市場レジームが変化しても、安定性と有効性を維持できます。
HiSSDのもう一つの優れた特徴は、高いスケーラビリティです。金融市場そのものがマルチエージェントシステムであり、人間のトレーダー、アルゴリズム取引システム、大規模なマーケットメーカーなど、あらゆる市場参加者が市場全体のダイナミクスに影響を与えています。このような環境では、システム内部の整合性を維持したまま拡張できることが極めて重要です。HiSSDでは、各エージェントが共有制御モジュールを介して互いに協調できる階層型アーキテクチャを採用しています。この構造は、複雑なトレード戦略を設計する際に特に有効であり、実際には、より堅牢で変化に強いトレードシステムの構築を可能にします。
HiSSDフレームワークの全体アーキテクチャを、著者らが作成した図に示します。

前回の記事の実装編では、著者らが提案したフレームワークをMQL5上で実装する取り組みを開始しました。具体的には、CNeuronSkillsEncoderクラスとして実装した汎用スキルエンコーダを紹介しました。本稿では、この実装をさらに発展させ、フレームワーク全体を完成させるとともに、実際の履歴データを用いて実装した手法の有効性を検証します。
先に進む前に、HiSSDフレームワークの構成をもう一度確認しておきましょう。HiSSDは、大きくプランナー(Planner)とコントローラ(Controller)という2つの主要コンポーネントから構成されています。
プランナーでは、情報は線形に処理されます。生の入力データは共通スキルエンコーダを通過し、その後、将来の状態と期待価値を予測する予測モジュールへ入力されます。この構成は、既存のツールを利用した一般的な線形モデルとして実装できます。
一方、コントローラは、より複雑な構造を持っています。コントローラには、エージェントのアクションデコーダが含まれており、このデコーダはエージェント自身の局所観測、共通スキル、各種エンコーダによって生成されるタスク固有スキルの3つの情報源から入力を受け取ります。この構造を踏まえ、本実装ではコントローラを独立した専用オブジェクトとして実装することにしました。
コントローラオブジェクト
次の実装ステップでは、CNeuronHiSSDLowLevelControlerクラスにカプセル化されたコントローラオブジェクトを構築します。
class CNeuronHiSSDLowLevelControler: public CNeuronConvOCL { protected: uint iTaskSkills; uint iCommonSkills; //--- CNeuronSkillsEncoder cTaskSpecificSkillsEncoder; CNeuronTransposeOCL cTranspose; CNeuronBaseOCL cObservAndSkillsConcat; CNeuronBatchNormOCL cNormalizarion; CNeuronConvOCL cActionDecoder[2]; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None ) override; public: CNeuronHiSSDLowLevelControler(void) {}; ~CNeuronHiSSDLowLevelControler(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint task_skills, uint common_skills, uint n_actions, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronHiSSDLowLevelControler; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
HiSSDのコントローラ内にあるエージェントアクションデコーダは、複数の独立したエージェントが並列に動作することを前提としている点に注意が必要です。この機能は、畳み込み層を直列に配置することで実現できます。また、このデコーダはモジュールの出力段に配置されるため、最終的なデコード層の機能は親クラスへ委譲できます。このような理由から、コントローラモジュールの基底クラスとして畳み込み層オブジェクトを採用しました。
新しいオブジェクトの構造には、複数の内部コンポーネントを定義しています。それぞれの詳細な役割については、順伝播および逆伝播アルゴリズムの実装時に説明します。ここでは、これらを静的メンバーとして宣言していることだけを述べておきます。この設計により、コンストラクタとデストラクタは空のままで構いません。継承したコンポーネントを含むすべての内部オブジェクトの初期化は、Initメソッドで実行します。
これまでと同様に、初期化メソッドは、構築するオブジェクトのアーキテクチャを一意に定義する一連の定数を引数として受け取ります。
bool CNeuronHiSSDLowLevelControler::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint time_step, uint variables, uint task_skills, uint common_skills, uint n_actions, uint window, uint step, uint window_key, uint heads, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, window_key, window_key, n_actions, 1, variables, optimization_type, batch)) return false; SetActivationFunction(SIGMOID);
メソッド本体では、これまでと同様に、まず親クラスに対応するメソッドを呼び出します。ここでは、いくつか重要な点があります。
まず、親クラスの機能をエージェントアクションデコーダの最終層として利用する予定です。そのため、親クラスのメソッドには、内部デコーダコンポーネントによって処理された出力が入力として渡されます。デコーダは、各エージェントの「内部表現」を形成する並列の情報フローを構成することを目的としています。そのため、畳み込み層における入力ウィンドウサイズとストライドは、それぞれ単一エージェントの内部情報ベクトルの次元数と同じ値に設定します。
出力側では、各エージェントの行動を表すテンソルを得ることを想定しています。そのため、畳み込みフィルタ数は、単一エージェントのアクションベクトルの次元数に対応します。
さらに、もう一つ重要な点があります。各エージェントが完全に独立して学習できるようにするため、それぞれに固有の重みパラメータを割り当てる必要があります。そのため、入力系列数を1に設定し、学習対象となるエージェント数を単位系列数を表すパラメータへ移動します。この単純な工夫により、任意の数の独立したエージェントを完全に並列で動作させることができます。
続いて、クラス内で宣言した内部オブジェクトを初期化します。前述のとおり、継承したコンポーネントの初期化は、すでに呼び出した親クラスのメソッドによって実行されています。
最初に初期化するのは、前回の記事で実装した汎用スキルエンコーダを利用したタスク固有スキルエンコーダです。
int index = 0; if(!cTaskSpecificSkillsEncoder.Init(0, index, OpenCL, time_step, variables, task_skills, window, step, window_key, heads, optimization, iBatch)) return false; cTaskSpecificSkillsEncoder.SetActivationFunction(None);
続いて、必要となるアーキテクチャ定数を保存します。
iTaskSkills = task_skills; iCommonSkills = MathMax(common_skills, 1);
ここで注意すべき点は、タスク固有スキルの次元数はそのまま保持される一方で、共通スキルについては最小限の有効値のみが設定されることです。これは非常に単純な理由によります。タスク固有スキルの次元数は、先ほどスキルエンコーダの初期化時に渡しており、その初期化が正常に完了した時点で、その値の妥当性は保証されています。一方、共通スキルテンソルはプランナーによって生成されます。本実装では、プランナーは別オブジェクトであるだけでなく、独立したモデルとして実装されています。そのため、この時点では最小限の制約しか設定できません。
次に、エージェントアクションデコーダを構築します。HiSSDフレームワークでは、アクションデコーダへの入力として次の3種類の情報を利用します。
- エージェント自身の局所観測
- 共通スキル
- タスク固有のスキル
前述のとおり、共通スキルは補助的な情報ストリームを介して別モデルから供給されます。一方、タスク固有スキルは、メイン入力ストリームから得られる局所観測をもとにスキルエンコーダが生成します。したがって、必要なデータはすでにすべて揃っています。残る作業は、それらを1つの構造へ統合することだけです。
ここで重要なのは、各エージェントが自身に対応した固有のデータ表現を受け取らなければならないことです。そのため、適切な連結処理を行う必要があります。スキルテンソルは、各行が1つのエージェントのスキルベクトルに対応する行列として表現されます。この行列を行方向に連結することで、畳み込み層による並列処理に適した形式を得ることができます。
一方、局所観測については少し異なります。これまで説明してきたように、入力はマルチモーダル時系列です。各エージェントは、その中の1つの単変量系列のみを処理します。そのため、スキルテンソルと連結する前に、観測行列を単変量系列の処理に適した形式へ転置する必要があります。
index++; if(!cTranspose.Init(0, index, OpenCL, time_step, variables, optimization, iBatch)) return false;
その後、単一エージェントの入力ベクトルの次元数を計算し、連結後テンソル用のバッファを初期化します。
uint window_size = (time_step + iTaskSkills + iCommonSkills); index++; if(!cObservAndSkillsConcat.Init(0, index, OpenCL, window_size * iVariables, optimization, iBatch)) return false; cObservAndSkillsConcat.SetActivationFunction(None);
繰り返しになりますが、このデータは3つの異なる情報源から供給されます。それぞれの分布を揃えて互換性を確保するため、バッチ正規化を適用します。
index++; if(!cNormalizarion.Init(0, index, OpenCL, cObservAndSkillsConcat.Neurons(), iBatch, optimization)) return false; cNormalizarion.SetActivationFunction(None);
データの前処理が完了したら、アクションデコーダのニューラルネットワーク層を構築します。ここではループを用いて各畳み込み層を初期化します。その初期化方法は、親クラスの初期化時に説明したものと同じです。
for(uint i = 0; i < cActionDecoder.Size(); i++) { index++; if(!cActionDecoder[i].Init(0, index, OpenCL, window_size, window_size, window_key, 1, iVariables, optimization, iBatch)) return false; cActionDecoder[i].SetActivationFunction(SoftPlus); window_size = window_key; } //--- return true; }
メソッドは処理の論理結果を呼び出し元に返して終了します。
次のステップとして、feedForwardメソッド内に実装される順伝播アルゴリズムを構築します。前述のとおり、このモジュールは2つの入力ストリームを扱います。メインストリームからは環境状態を表すマルチモーダル時系列を受け取り、補助ストリームからは共通スキルテンソルを受け取ります。
bool CNeuronHiSSDLowLevelControler::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) { if(!SecondInput) return false;
まず、共通スキルテンソルへのポインタが有効であることを確認します。一方、メイン入力については明示的なチェックをおこないません。入力はそのままタスク固有スキルエンコーダへ渡されます。このエンコーダ内部には独自の検証ポイントがあります。
if(!cTaskSpecificSkillsEncoder.FeedForward(NeuronOCL)) return false;
タスク固有スキルテンソルが生成された後、環境観測テンソルを転置し、それを共通スキルおよびタスク固有スキルと行方向に連結します。
if(!cTranspose.FeedForward(NeuronOCL)) return false; if(!Concat(cTranspose.getOutput(), cTaskSpecificSkillsEncoder.getOutput(), SecondInput, cObservAndSkillsConcat.getOutput(), cTranspose.GetCount(), iTaskSkills, iCommonSkills, iVariables)) return false;
得られたデータは正規化され、その後3層構成のエージェントアクションデコーダへ入力されます。最終的に、すべてのエージェントの行動を表す連結テンソルが生成されます。
if(!cNormalizarion.FeedForward(cObservAndSkillsConcat.AsObject())) return false; CNeuronBaseOCL *neuron = cNormalizarion.AsObject(); for(uint i = 0; i < cActionDecoder.Size(); i++) { if(!cActionDecoder[i].FeedForward(neuron)) return false; neuron = cActionDecoder[i].AsObject(); } //--- return CNeuronConvOCL::feedforward(neuron); }
メソッドは処理の論理結果を呼び出し元に返して終了します。
続いて、逆伝播処理を実装します。逆伝播は、以下の2つの段階で構成されます。
- 最終出力への寄与度に応じて、すべてのコンポーネントへ誤差勾配を分配する
- モデルのパラメータを更新し、誤差を最小化する
最初の段階はcalcInputGradientsメソッドで実装します。このメソッドは、入力データストリームおよび対応する誤差勾配へのポインタを受け取り、まずそれらの妥当性を確認します。これらのポインタを即座に検証します。
bool CNeuronHiSSDLowLevelControler::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -1) { if(!NeuronOCL || !SecondGradient) return false;
誤差勾配は、順伝播とは逆の順序で伝播します。順伝播はアクションデコーダで終了するため、逆伝播もそこから開始されます。まず、畳み込み層を逆順にたどりながら勾配を伝播します。
uint total = cActionDecoder.Size(); if(total <= 0) return false; CObject *neuron = cActionDecoder[total - 1].AsObject(); //--- if(!CNeuronConvOCL::calcInputGradients(neuron)) return false; for(int i = int(total - 2); i >= 0; i--) { if(!cActionDecoder[i].calcHiddenGradients(neuron)) return false; neuron = cActionDecoder[i].AsObject(); }
その後、得られた勾配を正規化層へ通し、3つの情報源を連結したテンソルまで戻します。
if(!cNormalizarion.calcHiddenGradients(neuron)) return false; if(!cObservAndSkillsConcat.calcHiddenGradients(cNormalizarion.AsObject())) return false;
続いて、連結したテンソルを分割し、それぞれの誤差勾配を元の3つの情報ストリームへ戻します。
if(!DeConcat(cTranspose.getGradient(), cTaskSpecificSkillsEncoder.getGradient(), SecondGradient, cObservAndSkillsConcat.getGradient(), cTranspose.GetCount(), iTaskSkills, iCommonSkills, iVariables)) return false;
ここで重要なのは、各情報ストリームが異なる活性化関数を持つ可能性があることです。そのため、各ストリームについて活性化関数の有無を確認し、必要であれば対応する導関数を用いて誤差勾配を補正します。
if(SecondActivation != None) { if(!DeActivation(SecondInput, SecondGradient, SecondGradient, SecondActivation)) return false; } if(NeuronOCL.Activation() != None) { if(!DeActivation(cTranspose.getOutput(), cTranspose.getGradient(), cTranspose.getGradient(), NeuronOCL.Activation())) return false; } if(cTaskSpecificSkillsEncoder.Activation() != None) { if(!DeActivation(cTaskSpecificSkillsEncoder.getOutput(), cTaskSpecificSkillsEncoder.getGradient(), cTaskSpecificSkillsEncoder.getGradient(), cTaskSpecificSkillsEncoder.Activation())) return false; }
この時点で、補助入力ストリームへの勾配伝播はすべて完了しています。残る処理は、メイン入力へ到達する2つの経路からの勾配を統合することです。まず、タスク固有スキルエンコーダを経由して勾配を伝播します。
if(!NeuronOCL.calcHiddenGradients(cTaskSpecificSkillsEncoder.AsObject())) return false;
続いて、メイン入力ストリームの勾配バッファへのポインタを一時的に置き換え、転置モジュールを経由するもう一方の経路についても勾配を伝播します。
CBufferFloat *temp = NeuronOCL.getGradient(); if(!NeuronOCL.SetGradient(cTranspose.getPrevOutput(), false) || !NeuronOCL.calcHiddenGradients(cTranspose.AsObject()) || !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, iVariables, false, 0, 0, 0, 1) || !NeuronOCL.SetGradient(temp, false)) return false; //--- return true; }
最後に、両方の経路から得られた勾配を加算し、すべてのバッファポインタを元の状態へ戻します。
これにより、メソッドは処理結果を返して終了します。
以上で、本稿におけるHiSSDフレームワークのコントローラ実装の説明を終わります。今回紹介した新しいオブジェクトおよび各メソッドの完全なソースコードについては、添付ファイルを参照してください。
モデルアーキテクチャ
HiSSDフレームワークを構成する各コンポーネントの実装が完了したので、次に学習対象となるモデル全体のアーキテクチャについて説明します。本実装では、合計4つのモデルを学習することを想定しています。
最初のモデルは環境状態エンコーダ(Environmental State Encoder)であり、本実装ではHiSSDフレームワークにおけるプランナーの役割を担います。このモデルは教師あり学習によって学習されます。観測された環境状態からエージェントの共通スキルを生成し、その共通スキルを利用して、指定した計画期間における将来の環境状態を予測します。
ここで、HiSSDの原論文との違いに気付くかもしれません。原論文では、次の1ステップのみを予測対象としています。しかし、本研究の目的は、ポジションを建てて一定期間保有できるトレーディング方策を構築することです。そのためには、より深い分析と長期的な計画能力が必要になります。
2つ目のモデルはコントローラです。このモデルは現在の市場環境を解析し、複数のエージェントに対応する行動テンソルを生成します。
3つ目のモデルはマネージャ(Actor)です。本実装では、このモデルが口座状態を解析し、コントローラ内の各エージェントが提案した売買行動を評価したうえで、実際に取引を実行するかどうかを最終的に決定します。
4つ目のモデルは、次に起こる価格変動方向の確率を推定する予測ネットワークです。
これら4つの学習モデルのアーキテクチャは、CreateDescriptionsメソッドで定義します。
bool CreateDescriptions(CArrayObj *&encoder, CArrayObj *&task, CArrayObj *&actor, CArrayObj *&probability) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!task) { task = new CArrayObj(); if(!task) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; }
このメソッドは、それぞれのモデル構造を保存する4つの動的配列へのポインタを受け取ります。まず各ポインタの妥当性を確認し、必要に応じて対応するオブジェクトを新たに生成します。
最初に、環境状態エンコーダのアーキテクチャを定義します。従来どおり、このモデルは、生の入力データを埋め込み表現へ変換するために、十分なサイズを持つ全結合層から始まります。
//--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
モデルには、前処理されていない環境観測データが入力されます。この入力は、まずバッチ正規化層によって標準化されます。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
続いて、前回の記事で実装した汎用スキルエンコーダを配置します。この構成では、このエンコーダが共通スキルテンソルを生成する役割を担います。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSkillsEncoder; descr.count = HistoryBars; { int temp[] = {BarDescr, NSkills, 4}; // Variables, Common Skills, Heads if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.window = 8; descr.step = 1; descr.window_out = 32; prev_count = descr.windows[0]; int prev_out = descr.windows[1]; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
その後、2つの畳み込み層を用いて、共通スキルテンソルを条件として、マルチモーダル時系列を構成する各単変量系列の将来の変化を予測します。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 1; descr.window = prev_out; descr.step = prev_out; prev_out=descr.window_out = 4*NForecast; descr.layers = prev_count; descr.activation = SoftPlus; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 1; descr.window = prev_out; descr.step = prev_out; prev_out=descr.window_out = NForecast; descr.layers = prev_count; descr.activation = TANH; if(!encoder.Add(descr)) { delete descr; return false; }
ここで重要なのは、各単変量系列の予測は、単一エージェントの共通スキルベクトルを基に、あらかじめ定義した計画期間にわたって生成されるという点です。その結果、この計画ブロックの出力は、通常のマルチモーダル時系列とは異なる表現になります。そのため、一貫したデータ形式へ戻すために、転置処理をおこないます。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_out; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
続いて、逆正規化によって、変換後の表現を元のデータ分布へ戻します。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = prev_count*prev_out; descr.layers = 1; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
これで環境状態エンコーダのアーキテクチャが完成しました。次に進む前に、共通スキルテンソルを保持する潜在表現への参照を保存しておきます。
//--- Latent CLayerDescription *latent = encoder.At(LatentLayer); if(!latent) return false;
前述のとおり、2番目のモデルはコントローラです。このモデルは、同じ環境状態表現からタスク固有スキルを生成します。そのため、環境状態エンコーダの最初の2層をそのまま再利用します。
//--- Task task.Clear(); //--- Input layer if(!task.Add(encoder.At(0))) { return false; } //--- layer 1 if(!task.Add(encoder.At(1))) { return false; }
その後、先に構築したコントローラモジュールを追加することで、このモデルが完成します。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronHiSSDLowLevelControler; descr.count = HistoryBars; { int temp[] = {latent.windows[0], // Variables NSkills, // Task Skills latent.windows[1], // Common Skills NActions, // Action Space 4}; // Heads if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } descr.window = 8; descr.step = 1; descr.window_out = 32; prev_count = descr.windows[0]; prev_out = descr.windows[3]; descr.batch = 1e4; descr.optimization = ADAM; descr.activation = SIGMOID; if(!task.Add(descr)) { delete descr; return false; }
3つ目のモデルである上位マネージャ(Actor)は、口座状態ベクトルを入力として受け取ります。まず、全結合層によって、この入力を埋め込み表現へ変換します。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = AccountDescr; 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 = AccountDescr; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
その後、クロスアテンション機構を適用し、現在の口座状態と、コントローラが提案した売買行動との対応関係を学習します。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossDMHAttention; { int temp[] = {AccountDescr, // Inputs window prev_out // Cross window }; if(ArrayCopy(descr.windows, temp) < (int)temp.Size()) return false; } { int temp[] = {1, // Inputs units prev_count // Cross units }; if(ArrayCopy(descr.units, temp) < (int)temp.Size()) return false; } descr.step = 4; // Heads descr.window_out = 32; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
続いて、3層の全結合ネットワークから構成される決定ヘッドを配置します。このネットワークは、抽出された特徴量を最終的なActorの行動ベクトルへ変換します。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.batch = 1e4; descr.activation = TANH; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SoftPlus; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
最後のモデルは、将来の価格変動方向を予測するネットワークです。このモデルは、プランナーの潜在表現から抽出された共通スキルを入力として利用します。そのため、環境状態エンコーダの潜在テンソルが入力となります。
//--- Probability probability.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = latent.windows[0] * latent.windows[1]; descr.activation = latent.activation; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; }
ネットワーク本体は、多層パーセプトロン(MLP)として実装されています。2つの隠れ層はいずれも全結合層で構成され、それぞれの間には非線形活性化関数を適用することで、十分な表現能力を持たせています。最終層にはシグモイド関数を用い、価格変動方向の確率を出力します。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * LatentCount; descr.activation = SoftPlus; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = TANH; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions / 3; descr.activation = SIGMOID; descr.batch = 1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- return true; }
以上で、4つの学習モデルすべてのアーキテクチャ定義が完了します。最後に、このメソッドは処理が正常に完了したかどうかを示す論理値を返して終了します。
モデル学習
ここまでで、HiSSDに基づくシステムの主要な実装はほぼ完了しました。次に、4つのモデルを学習させます。フレームワークの著者らが提案しているように、本実装でもすべてのモデルをオフライン学習で同時に訓練します。学習には、これまでの記事で作成したトレーニングデータセットを使用します。
データセットは、2024年通年のEURUSDM1実市場データから構築しました。各種インジケータはすべてデフォルトパラメータを使用しています。
データセットの作成方法については後ほど改めて説明します。ここでは、学習処理そのものに焦点を当てます。相互に連携する4つのモデルを同時に学習させるため、エキスパートアドバイザー(EA)の構成は大幅に見直しました。本記事ではプログラム全体については説明せず、Trainメソッドのみを取り上げます。
void Train(void) { //--- vector<float> probability = vector<float>::Full(Buffer.Size(), 1.0f / Buffer.Size()); //--- vector<float> result, target, state; matrix<float> fstate = matrix<float>::Zeros(1, NForecast * BarDescr); bool Stop = false; //--- uint ticks = GetTickCount();
まず準備処理として、経験再生に保存された各軌跡に対するサンプリング確率ベクトルを生成します。初期状態では一様分布を仮定しており、すべての軌跡が同じ確率で選択されます。
しかし、各学習バッチの終了後には、この確率分布を更新します。直前に利用した軌跡の選択確率を下げ、まだ十分に利用されていない軌跡が選ばれやすくすることで、データセット全体をより均等に学習できるようにし、汎化性能の向上を図ります。
また、一時的なデータを保持するためのローカル変数もここで宣言します。
続いて、学習処理本体を構築します。ここでは入れ子のループを構成します。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch) { int tr = SampleTrajectory(probability); int start = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast - Batch)); if(start <= 0) { iter -= Batch; continue; } if( !Encoder.Clear() || !Task.Clear() || !Actor.Clear() ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } result = vector<float>::Zeros(NActions);
外側のループは学習バッチを反復処理します。各バッチでは、まず再生バッファから1本の軌跡をサンプリングし、その軌跡内でランダムな開始位置を選択します。続いて内側のループでは、その区間に含まれる環境状態を順番に処理します。
for(int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++) { if(!state.Assign(Buffer[tr].States[i].state) || MathAbs(state).Sum() == 0 || !bState.AssignArray(state)) { iter -= Batch + start - i; break; }
このループ内で、実際のモデル学習がおこなわれます。まず、データセットから選択した環境状態を入力バッファへコピーし、各モデルが利用できるようにします。
次に、現在の状態に対応するタイムスタンプベクトルを生成します。
bTime.Clear(); double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bTime.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bTime.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bTime.GetIndex() >= 0) bTime.BufferWrite();
その後、口座状態と保有ポジションに関する情報を準備します。
//--- Account float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; float profit = float(bState[0] / _Point * (result[0] - result[3])); bAccount.Clear(); bAccount.Add(1); bAccount.Add((PrevEquity + profit) / PrevEquity); bAccount.Add(profit / PrevEquity); bAccount.Add(MathMax(result[0] - result[3], 0)); bAccount.Add(MathMax(result[3] - result[0], 0)); bAccount.Add((bAccount[3] > 0 ? profit / PrevEquity : 0)); bAccount.Add((bAccount[4] > 0 ? profit / PrevEquity : 0)); bAccount.Add(0); bAccount.AddArray(GetPointer(bTime)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
ここまでで入力データの準備は完了です。続いて、4つすべてのモデルに対して順方向伝播を実行します。最初に、あらかじめ準備した環境状態バッファを入力として環境状態エンコーダを実行します。
//--- Feed Forward if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
次にコントローラを実行します。コントローラは環境状態だけでなく、エンコーダの潜在表現から生成された共通スキルも入力として利用します。
if(!Task.feedForward((CBufferFloat*)GetPointer(bState), 1, false, GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
マネージャは、口座状態ベクトルと、コントローラが生成した複数エージェントの売買候補を入力として受け取ります。
if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Task), -1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
価格方向予測モデルは、エンコーダの潜在空間から抽出された共通スキルのみを入力として使用します。
if(!Probability.feedForward(GetPointer(Encoder), LatentLayer, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
この時点で、すべてのモデルは入力データを解析し、それぞれの出力を生成しています。次の処理は、それらの出力を教師データと比較することです。では、その教師データはどこから得られるのでしょうか。
エンコーダでは、将来の環境状態を予測することが目的です。そのため、データセットから実際の将来状態を取り出し、モデル出力に対応する形式へ並べ替えて教師テンソルを構築します。
//--- Look for target target = vector<float>::Zeros(NActions); bActions.AssignArray(target); if(!state.Assign(Buffer[tr].States[i + NForecast].state) || !state.Resize(NForecast * BarDescr) || MathAbs(state).Sum() == 0) { iter -= Batch + start - i; break; } if(!fstate.Resize(1, NForecast * BarDescr) || !fstate.Row(state, 0) || !fstate.Reshape(NForecast, BarDescr)) { iter -= Batch + start - i; break; } for(int j = 0; j < NForecast / 2; j++) { if(!fstate.SwapRows(j, NForecast - j - 1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
この教師データを環境状態エンコーダへ与え、予測誤差が最小となるようにパラメータを更新します。
//--- State Encoder Result.AssignArray(fstate); if(!Encoder.backProp(Result, (CBufferFloat*)NULL, NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
同じ将来状態データは、他のモデルの教師データ生成にも利用します。ただし、その利用方法はより高度です。最適な売買行動を決定するためには、将来の価格変動だけでは十分ではありません。現在保有しているポジションの状態も考慮する必要があります。ポジションが存在する場合には、ロングとショートそれぞれについて異なる決済シグナルを生成します。
target = fstate.Col(0).CumSum(); if(result[0] > result[3]) { float tp = 0; float sl = 0; float cur_sl = float(-(result[2] > 0 ? result[2] : 1) * MaxSL * Point()); int pos = 0; for(int j = 0; j < NForecast; j++) { tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]); pos = j; if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0]) break; sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]); } if(tp > 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = float(MathMin(tp / (MaxTP * Point()), 1)); result[0] = MathMax(result[0] - result[3], 0.011f); result[5] = result[1] = tp; result[4] = result[2] = sl; result[3] = 0; bActions.AssignArray(result); } }
else { if(result[0] < result[3]) { float tp = 0; float sl = 0; float cur_sl = float((result[5] > 0 ? result[5] : 1) * MaxSL * Point()); int pos = 0; for(int j = 0; j < NForecast; j++) { tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]); pos = j; if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0]) break; sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]); } if(tp < 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = float(MathMin(-tp / (MaxTP * Point()), 1)); result[3] = MathMax(result[3] - result[0], 0.011f); result[2] = result[4] = tp; result[1] = result[5] = sl; result[0] = 0; bActions.AssignArray(result); } }
一方、ポジションを保有していない場合には、新規エントリーを目的とした教師データを生成します。まず、将来の価格変動方向と、その強さを推定します。
else { ulong argmin = target.ArgMin(); ulong argmax = target.ArgMax(); float max_sl = float(MaxSL * Point()); while(argmax > 0 && argmin > 0) { if(argmax < argmin && target[argmax] / 2 > MathAbs(target[argmin]) && MathAbs(target[argmin]) < max_sl) break; if(argmax > argmin && target[argmax] < MathAbs(target[argmin] / 2) && target[argmax] < max_sl) break; target.Resize(MathMin(argmax, argmin)); argmin = target.ArgMin(); argmax = target.ArgMax(); }
その後、取引パラメータを定義します。
if(argmin == 0 || (argmax < argmin && argmax > 0)) { float tp = 0; float sl = 0; float cur_sl = - float(MaxSL * Point()); ulong pos = 0; for(ulong j = 0; j < argmax; j++) { tp = MathMax(tp, target[j] + fstate[j, 1] - fstate[j, 0]); pos = j; if(cur_sl >= target[j] + fstate[j, 2] - fstate[j, 0]) break; sl = MathMin(sl, target[j] + fstate[j, 2] - fstate[j, 0]); } if(tp > 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = (float)MathMin(tp / (MaxTP * Point()), 1); result[0] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011)); result[5] = result[1] = tp; result[4] = result[2] = sl; result[3] = 0; bActions.AssignArray(result); } }
else { if(argmax == 0 || argmax > argmin) { float tp = 0; float sl = 0; float cur_sl = float(MaxSL * Point()); ulong pos = 0; for(ulong j = 0; j < argmin; j++) { tp = MathMin(tp, target[j] + fstate[j, 2] - fstate[j, 0]); pos = j; if(cur_sl <= target[j] + fstate[j, 1] - fstate[j, 0]) break; sl = MathMax(sl, target[j] + fstate[j, 1] - fstate[j, 0]); } if(tp < 0) { sl = (float)MathMax(MathMin(MathAbs(sl) / (MaxSL * Point()), 1), 0.01); tp = (float)MathMin(-tp / (MaxTP * Point()), 1); result[3] = float(MathMax(Buffer[tr].States[i].account[0] / 100 * 0.01, 0.011)); result[2] = result[4] = tp; result[1] = result[5] = sl; result[0] = 0; bActions.AssignArray(result); } } } } }
こうして生成された「最適な売買行動」は、マネージャの学習にのみ使用されます。
//--- Actor Policy if(!Actor.backProp(GetPointer(bActions), (CNet*)GetPointer(Task), -1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
続いて、コントローラ用の教師テンソルを作成します。最も自然なのは、先ほど生成した最適売買行動をそのまま利用することです。しかし、その行動にはポジションサイズが含まれており、これはコントローラだけでは決定できません。ポジションサイズの決定には、マネージャだけが保持している口座情報が必要だからです。そのため、本実装では絶対的な取引数量を利益確率へ置き換えます。最適な取引であれば、この利益確率は1として設定します。
//--- Agents target=result; if(target[0] > 0) target[0] = 1; if(target[3] > 0) target[3] = 1;
補正後の最適売買行動は、すべてのエージェントへ複製され、コントローラの教師データとして使用されます。
Result.Clear(); for(int i = 0; i < BarDescr; i++) { if(!Result.AddArray(target)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } } if(!Task.backProp(Result, (CNet*)GetPointer(Encoder), LatentLayer) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
ここで重要なのは、すべてのエージェントが同一の教師データを受け取る点です。それでも、各エージェントは異なる行動を学習することが期待されます。なぜなら、それぞれがマルチモーダル時系列の中の異なる単変量系列だけを観測しているからです。したがって、複数のエージェントがそれぞれ異なる局所情報から環境状態を解釈し、それらを統合することで、マネージャはより強力な判断材料を得られると期待できます。
最後に、価格方向予測モデルの教師データを生成します。ここでも将来の環境状態を利用します。将来価格の累積変化量を計算し、計画期間内で最も大きな変動を抽出します。その最大変動の方向を優先トレンドとして採用し、予測モデルの教師データとします。
//--- Probability target = vector<float>::Zeros(NActions / 3); vector<float> trend=fstate.Col(0).CumSum(); ulong argmax=MathAbs(trend).ArgMax(); if(trend[argmax] > 0) target[0] = 1; else if(trend[argmax] < 0) target[1] = 1; if(!Result.AssignArray(target) || !Probability.backProp(Result, (CNet*)GetPointer(Encoder),LatentLayer) || !Encoder.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, LatentLayer) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
重要なのは、このモデルから得られた誤差勾配も共通スキル表現へ逆伝播される点です。その結果、エンコーダのパラメータも更新され、共通スキルの中に優先トレンドに関する情報が埋め込まれるようになります。
最後に、ユーザーへ学習の進捗状況を表示し、次の学習反復へ進みます。
if(GetTickCount() - ticks > 500) { double percent = double(iter + i - start) * 100.0 / (Iterations); string str = StringFormat("%-12s %6.2f%% -> Error %15.8f\n", "Encoder", percent, Encoder.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Task", percent, Task.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-13s %6.2f%% -> Error %15.8f\n", "Probability", percent, Probability.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
すべての反復処理が終了すると、各モデルの最終的な学習誤差を表示し、EAの終了処理を実行します。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Task", Task.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability", Probability.getRecentAverageError()); ExpertRemove(); //--- }
このプログラムの完全なコードは添付ファイルに記載されています。また、添付ファイルには、学習用サンプルを収集するプログラムおよび学習済みモデルをテストするプログラムも含まれています。これらのプログラムに加えられた個別の変更点については、ぜひご自身でソースコードをご確認いただければと思います。
テスト
ここで、提案手法の有効性を実際の履歴データ上で評価する、最も重要な段階へ進みます。前述したように、モデルの学習には2024年1年間の市場データ全体を使用しました。
生成された方策の品質を客観的に評価するため、学習済みモデルをMetaTrader 5のストラテジーテスター上で検証しました。テストには、学習に使用していないアウトオブサンプルデータとして、2025年1月から3月までの期間を利用しました。比較の一貫性を維持するため、市場条件、時間足、シミュレーション設定を含むその他すべてのパラメータは変更していません。
テスト結果を以下に示します。

3か月間のテスト期間において、モデルは合計860回の取引を実行しました。そのうち340件が利益取引となり、利益取引の割合は39.53%でした。一方で、勝ちトレード1回あたりの平均利益は、負けトレード1回あたりの平均損失を約70%上回りました。その結果、勝率だけを見ると高くはありませんが、利益と損失のバランスによって、戦略全体では収益性を維持することができました。

また、注目すべき点として、テスト期間中の3か月すべてにおいて月単位で利益を確保して終了しています。
結論
本記事では、アルゴリズム取引タスクへ適応したHiSSDフレームワークについて検討しました。その中心的なアイデアである、スキルを共通スキルとタスク固有スキルへ分解する構造は、高度に変化する市場環境において有効に機能しました。この階層的な構造により、エージェントは再学習を必要とすることなく、変化する環境へ迅速に適応する能力を獲得できます。
また、本実装では金融データ特有の性質も考慮しました。学習には2024年の実際のEURUSD履歴データを使用し、評価には2025年初頭の未使用データを用いました。これにより、実際の市場環境に近い条件下でモデル性能を評価することが可能になりました。
ただし、改めて強調しておく必要があります。このようなシステムを実際のライブ取引へ投入する前には、より代表性の高いデータセットを用いた追加学習を行い、さらに異なる市場状態や複数の相場局面における包括的な検証を実施する必要があります。
参照文献
記事で使用されたプログラム
| # | 名前 | 種類 | 詳細 |
|---|---|---|---|
| 1 | Research.mq5 | EA | サンプル収集用EA |
| 2 | ResearchRealORL.mq5 | EA | Real-ORL法を用いたサンプル収集用EA |
| 3 | Study.mq5 | EA | モデル学習用EA |
| 4 | Test.mq5 | EA | モデルテスト用EA |
| 5 | Trajectory.mqh | クラスライブラリ | システム状態とモデルアーキテクチャ記述構造 |
| 6 | NeuroNet.mqh | クラスライブラリ | ニューラルネットワークを作成するためのクラスのライブラリ |
| 7 | NeuroNet.cl | コードライブラリ | OpenCLプログラムコード |
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/17739
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
取引におけるニューラルネットワーク:適応的なエージェント行動のための階層的スキル発見(HiSSD)
エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
取引におけるニューラルネットワーク:周波数領域での異常検知(最終回)
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索