English Русский Español Português
preview
取引におけるニューラルネットワーク:データ固有の依存性なしに時系列を一般化する(コアモデルモジュール)

取引におけるニューラルネットワーク:データ固有の依存性なしに時系列を一般化する(コアモデルモジュール)

MetaTrader 5トレーディングシステム |
18 0
Dmitriy Gizlyk
Dmitriy Gizlyk

はじめに

前回の記事では、Mamba4Castフレームワークと、その中核となるコンポーネントであるSSMモジュール、およびPrior-data Fitted Networks (PFNs)メカニズムについて紹介しました。このフレームワークは、時系列予測のための確固たる基盤を構築するものであり、トレーダーの分析ツールキットに強力な機能を追加できる可能性を秘めています。

Mamba4Castは、新しい時系列データごとに長時間の事前適応を目的として設計されたものではなく、すぐに導入して使用することを目的として設計されています。ゼロショット予測という概念により、モデルは追加学習やハイパーパラメータの調整をおこなうことなく、実世界のデータに対して高品質な予測を生成できます。その結果、トレーダーは最適な設定を探すために何日も時間を費やす必要がなくなります。

モデルの高い実行速度は、SSMモジュールの線形な計算量に基づいています。計算コストがシーケンス長に対して二次関数的に増加するTransformerアーキテクチャとは異なり、Mamba4Castでは各処理ステップが一定時間で実行されます。これにより、非常に長いシーケンスに対しても最小限のレイテンシで、ほぼ瞬時に推論を実行できます。取引においては、実行速度がトレードの結果を左右することもあるため、この利点は非常に大きな意味を持ちます。

さらに、Mamba4Castは予測期間全体を一度に生成し、1ステップずつ順番に生成する方式ではありません。この設計によって、自己回帰モデルで典型的に見られる誤差の累積を回避し、より安定した将来の軌跡を生成できます。これにより、取引戦略は予測ホライズン全体を一括で見通すことができ、より確信を持った、情報に基づく意思決定が可能になります。

同様に重要なのが、合成シナリオに基づく学習方法です。モデルは、人工的に生成された数百万もの時系列データを使って学習されています。これにより、Mamba4Castは汎化された直感を身につけ、非常に幅広い条件下で安定して動作できるようになります。このアプローチは、ノイズや市場の急激な変化に対するロバスト性を高めます。その結果、本番環境で予期せぬ障害が発生する可能性も低減されます。

基盤となるメカニズムが強力であるにもかかわらず、Mamba4Castは計算効率にも優れています。フレームワークの著者らが実施した実験によれば、最新のTransformerベースの基盤モデルと同程度の精度を達成しながら、必要とする計算リソースは大幅に少なくなっています。このため、リソースが限られたインフラ上でも実用的であり、高性能なGPUクラスタを必要とせずにトレーディングターミナルへ直接統合することも可能です。

即時導入、非常に高速な推論、予測期間全体を一括して予測する能力、ノイズの多いデータに対するロバスト性、そして計算効率。これらが組み合わさることで、Mamba4Castは時系列予測のための、まさに画期的なフレームワークとなっています。

著者らが作成したMamba4Castフレームワークの可視化を以下に示します。

前回の記事の実践セクションでは、時間エンコーディングオブジェクトを実装したところで終わりました。これは、入力データの時系列内における位置表現を提供する重要なコンポーネントです。このコンポーネントによってモデルの初期化パイプラインが完成し、入力信号をアーキテクチャ上で準備するための不可欠な要素となりました。これがなければ、フレームワークは時間的な依存関係を正しく捉えることができません。

今回は、そこから続けていきます。


データ前処理モジュール

開発の流れは単純です。計算を実行したり、価格変動を予測したり、売買シグナルを生成したりする前に、まず入力データを適切に準備する必要があります。他の機械学習システムと同様に、Mamba4Castの性能は入力されるデータストリームの品質に直接左右されます。モデルがノイズの多い入力、一貫性のないスケール、あるいは断片化された構造を受け取った場合、どれほど高度なアーキテクチャであっても、それを補うことはできません。そのため、ここではデータ前処理モジュールに焦点を当てます。

このモジュールは、単なる補助的な処理段階ではありません。これは、生の市場データと、モデルが効果的に解釈できる構造化された入力との間をつなぐ橋渡しの役割を果たします。この段階では、正規化、スケーリング、ウィンドウの構築、マスクの生成、そして最も重要な処理として、追加特徴量チャネルによる文脈付けをおこないます。これらの処理を組み合わせることで、中核となるモデルが情報を処理するための準備を整え、その後に続くすべての予測処理の基盤を構築します。

私たちの目的は、単に価格のクォートやインジケータの値を読み込むことではありません。それらを統一されたスケールへ変換し、有効なウィンドウの境界を特定し、利用できない値に対するマスクを生成し、すべての特徴量チャネルを時間的に同期させる必要があります。これらの処理を終えて初めて、データをエンコーダへ渡し、正しく解釈されることを期待できるようになります。

前回の記事の実践セクションでは、時間エンコーディングオブジェクトの実装を完了しました。これはデータ前処理における主要なコンポーネントの1つであり、Mamba4Castアーキテクチャにおいても重要な要素です。このコンポーネントによって、モデルは市場イベントのシーケンスを単なる抽象的な数値の集合としてではなく、明確な順序と時間的なリズムを持った構造化された情報として認識できるようになります。

このアプローチを実装するために、専用のクラスCMamba4CastEmbeddingを開発します。このクラスは前処理段階の中心的な役割を担います。その構造を以下に示します。

class CMamba4CastEmbeding :   public CNeuronBaseOCL
  {
protected:
   CNeuronConvOCL             cProjection;
   CNeuronBatchNormOCL        cNorm;
   CNeuronTSPositionEncoder   cProjectionWithTE;
   //---
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; }
   virtual bool               feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override;
   virtual bool               updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool               calcInputGradients(CNeuronBaseOCL *NeuronOCL) override;

public:
                     CMamba4CastEmbeding(void) {};
                    ~CMamba4CastEmbeding(void) {};
   //---
   virtual bool               Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                                   uint window, uint window_out, uint units_count, uint &periods[],
                                   ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int                Type(void) override  const   {  return defMamba4CastEmbeding;   }
   //---
   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;
  };

基本的な考え方は、最初に入力された情報のストリームを、その後の予測段階に適した、構造化され、情報量の豊富な表現へと変換することです。これを実現するため、このクラスではモジュール化された設計を採用しており、それぞれが特定のデータ変換を担当する複数の専用モジュールを組み合わせています。

ここで注目しておきたいのは、CMamba4CastEmbeddingクラスのすべての内部オブジェクトが静的に宣言されていることです。そのため、動的なメモリ管理をおこなう必要がなく、コンストラクタとデストラクタは空のままとなっています。オブジェクトは自動的に生成・破棄されるためです。この設計上の判断によって、オブジェクトのライフサイクル管理が簡単になり、メモリリークのリスクを最小限に抑えられます。また、動的なメモリ割り当てに関連する潜在的なエラーも排除できます。これは、特に高性能なトレーディングシステムにおいて重要です。

すべての内部モジュールはInitメソッドで初期化されます。このメソッドのパラメータによって、作成するオブジェクトの主要な特性が定義されます。

bool CMamba4CastEmbeding::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                               uint window, uint window_out, uint units_count, uint &periods[],
                               ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(periods.Size() <= 0)
      return false;
   int freqs = (int(window_out / 2 + 2 * periods.Size()) - 1) / int(2 * periods.Size());
   if(freqs <= 0)
      return false;

このメソッドではまず、分析対象となる時系列の期間を格納した配列にデータが存在するかを確認します。この検証が必要なのは、時間エンコーディングを正しく計算するためには、少なくとも1つの期間が必要だからです。その直後に、freqsパラメータを計算します。このパラメータは、それぞれの期間に割り当てる周波数ハーモニクスの数を指定します。

このパラメータの背後にあるロジックについては、もう少し詳しく説明する必要があります。初期化パラメータの1つであるwindow_outは、1つの時間ステップに対するエンベディングの次元数を定義します。前に説明したように、データの各時間スライスは、互いに補完する2つの形式で表現する必要があります。1つ目は、時間に依存しない純粋な値としての表現です。2つ目は、調波成分によってエンコードされた時間的特徴と組み合わせた表現です。最終的な埋め込み表現では、この2つの表現を1つのベクトルにまとめます。しかし、この表現に割り当てられているメモリは1つしかありません。そのため、ユーザーが指定した埋め込みサイズのうち、時間エンコーディングに使用できるのは半分だけです。残りの半分は、入力データの通常の射影のために確保されます。

では、この時間エンコーディング用の半分を、周波数成分の間でどのように分配するのでしょうか。Mamba4Castの著者らは、Transformerアーキテクチャで使用されている位置エンコーディングと似たアプローチを採用しています。指定された各期間について、振動の位相と周波数を表現するために、正弦関数と余弦関数の両方を生成します。したがって、1つの周波数成分につき、2つの調波関数、つまり正弦と余弦が必要になります。そのため、周波数成分の総数は、入力配列で指定された期間の数の少なくとも2倍でなければなりません。

混乱を避けるため、ここでは単純な式を使用しています。ただし、この式には注意が必要です。window_outの半分を、指定された期間の数の2倍で割ります。この結果によって、それぞれの期間に割り当てることのできる周波数成分の数が決まります。この値は必ず0より大きくなければなりません。そうでなければ、調波ペアを1つすら生成できず、時間エンコーディングをおこなうことができなくなります。

実際には、これは1つの時間ステップに対するエンベディングサイズを決める際、単純にモデルの容量を増やすことだけを目的にすべきではない、ということを意味します。

それよりも、分析対象となるすべての期間が、少なくとも1組の調波ペアによって表現されることを保証することが重要です。

if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window_out * units_count, optimization_type, batch))
   return false;

その後、親クラスの初期化メソッドを呼び出します。これが正常に完了した後で、クラス内部モジュールの初期化を順に進めます。最初のコンポーネントはcProjectionで、入力データをコンパクトな表現にマッピングする役割を担う畳み込み射影層です。

int index = 0;
if(!cProjection.Init(0, index, OpenCL, window, window, window_out - 2 * freqs * periods.Size(), units_count,
                                                                                   1, optimization, iBatch))
   return false;
cProjection.SetActivationFunction(TANH);

このモジュールは、時間情報を組み込まずに、元のデータの埋め込み表現を生成します。ここでは、非線形性を導入するためにTANH活性化関数を使用します。これにより、ネットワークは生の数値データの中に隠れている意味のあるパターンをより強調できるようになると同時に、外れ値の影響を抑えることができます。

続いて、バッチ正規化層(cNorm)によって、これらの射影された特徴量を調整します。これにより、分布上の偏りを取り除き、計算の安定性を向上させます。

index++;
if(!cNorm.Init(0, index, OpenCL, cProjection.Neurons(), iBatch, optimization))
   return false;
cNorm.SetActivationFunction(None);

時間情報を付加した埋め込み表現の生成は、時間エンコーディングモジュール(cProjectionWithTE)が担当します。このモジュールは、季節的なトレンドや市場の動的な変化を捉えるうえで、特に重要な役割を果たします。

   index++;
   if(!cProjectionWithTE.Init(0, index, OpenCL, window, units_count, periods, freqs, optimization, iBatch))
      return false;
   SetActivationFunction(None);
//---
   return true;
  }

フレームワークのロジックにおいて、この順序に沿った初期化プロセスによって、各サブモジュールが一貫性のある互換性のある設定を受け取ることが保証されます。これにより、時間情報と元の入力データを効率的に統合できるようになります。

CMamba4CastEmbeddingモジュールにおける重要な段階の1つが、feedForwardメソッドの実行です。このメソッドは、前処理アーキテクチャを構成するすべてのコンポーネントを通してデータを順方向に伝播させます。この段階で後続のモジュールの基盤が構築されます。これにより、後続のモジュールは生の入力データを直接処理するのではなく、元の信号が持つ構造的な特徴と時間的なコンテキストの両方をすでに組み込んだ、適切に準備された表現を処理できるようになります。

bool CMamba4CastEmbeding::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!cProjection.FeedForward(NeuronOCL))
      return false;

処理は、入力データをcProjectionへ渡すところから始まります。このコンパクトな畳み込みブロックによって、元の信号が潜在特徴空間へ射影されます。この処理により、システムは入力ストリームの中でも特に情報量の多いパターンや重要な変化に、すぐに焦点を当てられるようになります。

続いて、抽出された特徴量がcNormへ渡されます。この正規化層は、アクティベーションを安定した範囲にスケーリングすると同時に、急激なスパイク、外れ値、分布の変化を抑える役割を担います。

if(!cNorm.FeedForward(cProjection.AsObject()))
   return false;

正規化をおこなわない場合、深層ニューラルネットワークのアーキテクチャでは、しばしば勾配爆発や勾配消失が発生します。ここでは、正規化が安定化のための仕組みとして機能し、学習時と推論時の両方において、モデルの動作を安定させます。

しかし、Mamba4Castアーキテクチャの特徴は、2つ目の処理経路にあります。これと並行して、元の入力データがcProjectionWithTEへ渡されます。このモジュールが時間エンコーディングを担当します。

if(!cProjectionWithTE.FeedForward(NeuronOCL, SecondInput))
   return false;

最初の射影段階が畳み込み処理だけに依存しているのに対し、このモジュールは追加の入力であるSecondInputも受け取ります。この経路を通して、各時間ステップに対して事前に計算された時間マーカーが計算に導入されます。これにより、モジュールは単に特徴量の値そのものを分析するだけでなく、それらを正確な時間的コンテキストの中で解釈できるようになります。その結果、季節性、周期性、位相に関連する市場の動的な変化に対するモデルの感度が向上します。

最後の段階が、融合処理です。2つの処理経路によって生成された表現を連結し、1つのテンソルにまとめます。

   if(!Concat(cNorm.getOutput(), cProjectionWithTE.getOutput(), Output, cProjection.GetFilters(),
              cProjectionWithTE.GetWindowOut(), cProjection.GetUnits()))
      return false;
//---
   return true;
  }

得られた出力は、各時間ステップについて、元の特徴情報と、それを拡張した時間構造の両方を組み合わせた、密度の高い多層的な表現となります。この時系列全体を包括的に表現したものが、モデルによってその後に実行されるすべての処理の基盤となります。

よく知られているように、順方向の伝播だけではニューラルネットワークを学習させることはできません。モデルがデータに適応するためには、正しい誤差逆伝播もおこなう必要があります。この処理を担当するのがcalcInputGradientsメソッドです。本質的には、これはモデル内部の誤差分析機構として機能します。予測が生成された後に実行され、どこで誤差が発生したのか、そしてモデルのパラメータをどのように調整すべきなのかを判断します。

bool CMamba4CastEmbeding::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;

まず、このメソッドではどのオブジェクトが対象となっているのかを確認します。勾配計算に使用されるすべてのコンポーネントが正しく初期化されていることを確認することが重要です。このチェックの後、DeConcatメソッドへ処理が進みます。このメソッドでは、先ほど連結した勾配ベクトルを、2つの独立した情報ストリームへ分離します。

if(!DeConcat(cNorm.getGradient(), cProjectionWithTE.getGradient(), Gradient, cProjection.GetFilters(),
             cProjectionWithTE.GetWindowOut(), cProjection.GetUnits()))
   return false;

順方向伝播では、正規化された特徴表現と時間エンコーディングによる埋め込み表現を1つのテンソルにまとめたことを思い出してください。しかし、逆伝播では、各処理分岐を独立して学習できるように、これらのストリームを再び分離する必要があります。

勾配を分離した後、主要な逆伝播処理が始まります。ここでは、2つの情報ストリームを通して誤差勾配を元の入力データまで逆方向に伝播させます。まず、時間情報を含まない分岐を処理します。

if(!cProjection.calcHiddenGradients(cNorm.AsObject()))
   return false;
if(!NeuronOCL.calcHiddenGradients(cProjection.AsObject()))
   return false;

続いて、時間的コンテキストを含む分岐の処理へ進みます。ただし、勾配を伝播させる前に、先ほど計算したデータを保持しておく必要があります。そのため、このメソッドでは一時的に、元の入力オブジェクトに関連付けられた勾配バッファへのポインタを変更します。その後、勾配の計算を実行します。

   CBufferFloat *temp = NeuronOCL.getGradient();
   if(!NeuronOCL.SetGradient(NeuronOCL.getPrevOutput(), false) ||
      !NeuronOCL.calcHiddenGradients(cProjectionWithTE.AsObject()) ||
      !SumAndNormilize(temp, NeuronOCL.getGradient(), temp, cProjection.GetWindow(), false, 0, 0, 0, 1) ||
      !NeuronOCL.SetGradient(temp, false)
     )
      return false;
//---
   return true;
  }

最後のステップでは、2つの情報ストリームから得られた勾配を合計します。その後、すべてのバッファポインタを元の状態に戻し、メソッドは処理結果を示すBoolean値を呼び出し元のプログラムへ返して終了します。

このように慎重に設計された処理によって、誤差勾配がモジュール全体に正しく分配され、各重みがバランスよく更新されることが保証されます。

各サブモジュールの学習可能なパラメータは、updateInputWeightsメソッドによって最適化されます。重みの更新は、内部オブジェクトに対応する更新メソッドを順番に呼び出すことで実行されます。この段階的な処理は、重みを個別に管理するという原則を反映したものです。これにより、学習プロセスを細かく制御できるようになり、システム全体のアーキテクチャとは独立して変更を加えることも可能になります。更新処理のいずれかの段階で失敗した場合、メソッドは直ちにエラーを返します。これにより、モデルの状態の整合性が維持されます。

updateInputWeightsメソッドのコードは、この記事の添付ファイルに収録されています。また、CMamba4CastEmbeddingクラスの完全なコードと、そのすべてのメソッドも添付ファイルに含まれています。

まとめると、CMamba4CastEmbeddingクラスは、単なるデータ変換アルゴリズムの集合ではありません。これは、Mamba4Castフレームワーク全体のロジックと各機能が密接に統合された、慎重に設計されたモジュールです。このクラスによって入力データが高品質に前処理され、モデルは意味のある特徴を効率的に抽出し、時間的な動態を捉え、適切に伝播された誤差勾配を通じて効果的に学習できるようになります。


エンコーダ

次の実装段階では、Mamba4Castフレームワークの著者が提案したエンコーダを構築します。このコンポーネントは、後続の予測処理に向けて特徴量を抽出し、構造化するうえで重要な役割を果たします。そのアーキテクチャは、異なるカーネルサイズを持つ複数の畳み込み層を積み重ねた構成になっています。それぞれの畳み込み層は、入力データが持つ特定の特徴を捉えることに重点を置きます。その後、それぞれの出力を連結して正規化し、Mambaモジュールへ渡します。このブロックを何回繰り返すかによって特徴量抽出の深さが決まり、フレームワークは複雑さの異なるさまざまなタスクに対応できるようになります。

ここで特に興味深いのが、畳み込み層のスタックです。畳み込み層のオブジェクトは、すでに長い間私たちのライブラリで利用できるようになっており、その使用自体に難しい点はありません。しかし、従来の方法では、それぞれの畳み込み層を個別のオブジェクトとしてインスタンス化し、それらを順番に処理します。異なるサイズの畳み込みカーネルを使用すると、このようなオブジェクトの数は大幅に増加します。また、順次実行することでモデルの計算量が大きくなり、その結果、学習時間も増加します。

この制限に対処するため、私たちは複数の畳み込み層を並列に処理できる専用オブジェクトを設計しました。このようなオブジェクトでは、複数の実行ストリーム上で同時に計算を開始できるため、実行時間を大幅に短縮できます。内部では、同じ入力信号を異なるカーネルサイズを持つ複数の畳み込みフィルタへ渡すことで処理を分散し、その後、それぞれの出力を1つのテンソルに連結します。この方法によって、中間オブジェクトの数を減らせるだけでなく、特に現代のマルチスレッドGPU上で実行する場合に、計算リソースをはるかに効率的に利用できるようになります。

この概念自体は、まったく新しいものではありません。以前、私たちはCNeuronMultiWindowsConvOCLクラスを実装し、異なるカーネルサイズによる畳み込み処理をサポートしました。しかし、このクラスの設計では、それぞれの畳み込みウィンドウに対して個別の入力バッファを用意することを前提としていました。今回のケースでは、別の課題に直面します。つまり、同じ入力ストリームを複数の畳み込みウィンドウで同時に処理する必要があります。

難しいのは、カーネルサイズを変更すると、実行可能な畳み込み処理の回数も変化することです。畳み込みウィンドウが大きくなるほど、畳み込みを適用できる位置の数は少なくなります。その結果、カーネルサイズごとに必要となる計算量が不均衡になります。これによって、効率的な並列実行が難しくなります。

この問題を解決するため、私たちはゼロパディングを採用し、入力ベクトルの先頭と末尾の両方にゼロを追加します。この手法によってベクトルを拡張し、畳み込みウィンドウのサイズに関係なく、同じ数の実行スレッドを使用できるようにします。その結果、畳み込み処理の回数はカーネルサイズではなく、畳み込みストライドだけによって決まるようになります。このストライドは、すべてのカーネルサイズで同一です。これによって並列スレッド間の処理負荷が均等化され、並列処理が大幅に簡素化されます。

OpenCL側の実装における中核となるロジックは、コンパクトでありながら強力なFeedForwardMultWinConvWPadカーネルにあります。このカーネルは、同じ入力ストリームに対して、長さの異なる複数の畳み込みウィンドウを同時に適用して処理します。

__kernel void FeedForwardMultWinConvWPad(__global const float *matrix_w,
                                         __global const float *matrix_i,
                                         __global float *matrix_o,
                                         __global const int *windows_in,
                                         const int inputs,
                                         const int step,
                                         const int window_out,
                                         const int activation
                                        )
  {
   const size_t id = get_global_id(0);
   const size_t id_w = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t outputs = get_global_size(0);
   const size_t windows_total = get_global_size(1);

各実行スレッドは、次の3つの座標によって識別されます。

  • id:出力テンソル要素のインデックス
  • id_w:畳み込みウィンドウのインデックス
  • v:単変量入力ストリームの識別子

この構成により、異なる処理パラメータを使用して、複数の畳み込みウィンドウ、位置、および入力系列を並列に処理できます。

最初のステップでは、カーネルは現在の畳み込みウィンドウwindows_inの長さを、現在のウィンドウ識別子を使ってwindow_in配列から取得します。

int window_in = windows_in[id_w];

次に、現在の出力テンソルのインデックスに基づいて、入力バッファ内における畳み込みウィンドウの開始オフセットを計算します。このオフセットは、畳み込みウィンドウの中心が位置idと一致するように計算されます。ウィンドウサイズはそれぞれ異なるため、中心を正しく合わせるために(window_in + 1) / 2という式を使用します。

int window_in = windows_in[id_w];
int mid_win = (window_in + 1) / 2;
int shift_in = id * step - mid_win;
int shift_in_var = v * inputs;

続いて、変数shift_in_varを使用して、グローバル入力バッファ内の適切な入力系列を参照します。

次のステップでは、学習可能なパラメータのグローバル配列内におけるオフセットであるshift_weightを計算します。各畳み込みウィンドウの重みは、その長さとbias係数を考慮したうえで連続して格納されています。そのため、現在のウィンドウid_wより前に存在するすべての重みブロックのサイズを合計する必要があります。これにより、現在の畳み込みに使用する重みが、正確にどこから始まるのかを特定できます。

int shift_weight = 0;
for(int w = 0; w < id_w; w++)
   shift_weight += (windows_in[w] + 1) * window_out;

続いて、各出力チャネルw_outに対してネストされたループが開始されます。このw_outは、畳み込み後に生成される結果バッファにおける埋め込み次元を表します。

   for(int w_out = 0; w_out < window_out; w_out++)
     {
      float sum = matrix_w[shift_weight + window_in];
      //---
      for(int w = 0; w < window_in; w++)
         if((shift_in + w) >= 0 && (shift_in + w) < inputs)
            sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in + w] * matrix_w[shift_weight + w], 0);
      //---
      int shift_out = (v * outputs + id) * window_out + w_out;
      matrix_o[shift_out] = Activation(sum, activation);
      shift_weight += window_in + 1;
     }
  }

各出力チャネルについて、まず変数sumを初期化します。この変数には、最初にbiasの値、つまり重みブロックの最後の要素が設定されます。その後、入力値と対応する重みの積がこの値に加算されます。この処理中、現在の入力インデックスが有効な入力範囲の外側にあるかどうかを明示的に確認します。範囲外であれば、その処理をスキップします。これは、ゼロパディングを適用することと同じです。

ウィンドウ内のループが完了すると、結果が活性化関数によって処理されます。使用する活性化関数の種類はactivationパラメータによって指定され、実際の活性化関数の適用にはヘルパーラッパーであるActivationを使用します。その後、最終的な値が必要なすべてのオフセットを考慮したうえで、グローバル出力バッファmatrix_oに書き込まれます。

最後に、shift_weightポインタを現在のウィンドウのサイズに、さらにbias用の1要素を加えた分だけ進めます。これによって、次のフィルタの重みを指す位置に移動します。このようにして、各実行スレッドはすべての出力チャネルを順番に処理する一方で、計算自体はすべての畳み込みウィンドウおよびすべての入力系列にわたって完全に並列に実行されます。

得られたアルゴリズムは柔軟であるだけでなく、非常に高い効率も実現しています。複数のスケールの畳み込みをデータに対して同時に適用することで、入力データの包括的なマルチスケール表現を生成できます。ゼロパディングによって、埋め込み次元を揃えるための手動による系列の切り詰めや、後処理をおこなう必要もなくなります。すべての処理がGPU上での実行中に動的におこなわれます。

この実装は、重要な設計原則を示しています。高い性能は、アルゴリズムを単純化することによって得られるのではなく、計算を適切に分割し、並列実行を活用し、データ変換パイプラインの各段階を正確に連携させることによって実現されます。

次の大きな段階は、元の入力データに対する誤差勾配の計算です。順方向の処理とは異なり、この段階ではかなり難しい問題に直面します。単に一連のフィルタを適用するのではなく、各フィルタによる寄与を逆方向へ伝播させ、オフセットを正しく処理しながら、入力レベルでの勾配を再構築し、テンソルの整合性を維持する必要があります。

このアルゴリズムは、OpenCLカーネルのCalcHiddenGradientMultWinConvWPadに実装されています。その目的は、異なる長さの畳み込みウィンドウ、およびすべての出力チャネルにわたって誤差勾配を集約することです。言い換えれば、活性化関数による非線形性と畳み込みフィルタの構造の両方を考慮しながら、元の入力系列へ逆方向に伝播される誤差を再構築する処理です。

__kernel void CalcHiddenGradientMultWinConvWPad(__global const float *matrix_w,
                                                __global const float *matrix_i,
                                                __global float *matrix_ig,
                                                __global const float *matrix_og,
                                                __global const int *windows_in,
                                                const int outputs,
                                                const int step,
                                                const int window_out,
                                                const int activation
                                               )
  {
   const size_t id_x = get_global_id(0);
   const size_t id_loc = get_local_id(1);
   const size_t id_win = id_loc / window_out;
   const size_t id_f = id_loc % window_out;
   const size_t v = get_global_id(2);
   const size_t inputs = get_global_size(0);
   const size_t size_loc = get_local_size(1);
   const size_t windows_total = size_loc / window_out;

カーネル内の各スレッドは、入力レベルの勾配における1つの要素を計算します。

  • id_x:入力系列内の位置のインデックス
  • id_loc:ローカルワークグループの識別子。これはid_win(ウィンドウのインデックス)とid_f(出力バッファ内におけるフィルタのインデックス)に分解されます
  • v:入力系列の識別子
また、カーネルは入力の総長inputs、出力要素数outputs、および畳み込みウィンドウの総数windows_totalも認識します。

最初のステップでは、shift_weightを計算します。これは、現在の畳み込みウィンドウとチャネルに対応する重みブロックの正確な開始位置を決定するためのものです。先ほどと同様に、このアルゴリズムでは、それより前にあるすべてのウィンドウを順番に処理し、それぞれのサイズを累積していきます。そして、グローバルな重み配列matrix_w内の正しい位置に到達するまで、この処理を続けます。

   __local float temp[LOCAL_ARRAY_SIZE];
   const uint ls = min((uint)size_loc, (uint)LOCAL_ARRAY_SIZE);
//---
   int window_in = windows_in[id_win];
   int shift_weight = id_f * (window_in + 1);
   for(int w = 0; w < id_win; w++)
      shift_weight += (windows_in[w] + 1) * window_out;

次に、現在の入力要素が関与する可能性のある出力バッファの領域を決定します。これがshift_outです。この値には0という下限が設定されており、現在の入力要素id_xを畳み込みウィンドウに含めることができる、最も早い出力位置を表します。

int shift_out = max((int)((id_x - window_in) / step), 0);

しかし、これだけでは十分ではありません。続いて、順方向の処理において、対象となる要素が関与する可能性のあるすべての出力outについてループ処理を開始します。

float grad = 0;
int mid_win = (window_in + 1) / 2;
for(int out = shift_out; out < outputs; out++)
  {
   int shift_in = out * step - mid_win;
   if(shift_in > id_x)
      break;
   int shift_w = id_x - shift_in;
   if(shift_w >= window_in)
      continue;
   int shift_g = ((v * outputs + out) * windows_total + id_win) * window_out + id_f;
   grad += IsNaNOrInf(matrix_w[shift_w + shift_weight] * matrix_og[shift_g], 0);
  }

各反復処理において、カーネルはid_xが現在の畳み込みウィンドウに含まれているかどうかを確認します。含まれている場合は、ウィンドウ内におけるローカルオフセットshift_wと、それに対応する出力勾配の位置shift_gを計算します。適切なフィルタ重みと出力勾配matrix_ogを乗算することで、その畳み込み処理が入力勾配に与える寄与を求めます。これらの寄与は、変数gradに累積されます。

この時点では、各実行スレッドが保持しているのは部分的な勾配だけです。しかし、同じワークグループ内の複数のスレッドが、異なるフィルタや出力チャネルを処理することで、同じ入力位置id_xに対応する場合があります。そこで、ローカルメモリ上に中間配列tempを用意し、同期バリアを使用した加算処理を開始します。各スレッドはまず、自身が計算した部分的な結果をtempに格納します。

for(int i = 0; i < size_loc; i += ls)
  {
   if(i <= id_loc && (i + ls) > id_loc)
      temp[id_loc % ls] = (i == 0 ? 0 : temp[id_loc % ls]) + grad;
   barrier(CLK_LOCAL_MEM_FENCE);
  }

次に、並列縮約折り畳みを使用して、配列を2のべき乗で合計し、すべてのチャネルとウィンドウにわたる最終的な勾配をtemp[0]に残します。

uint count = ls;
do
  {
   count = (count + 1) / 2;
   if(id_loc < count && (id_loc + count) < ls)
     {
      temp[id_loc] += temp[id_loc + count];
      temp[id_loc + count] = 0;
     }
   barrier(CLK_LOCAL_MEM_FENCE);
  }
while(count > 1);

最後に、id_loc == 0であるスレッドだけが、計算された勾配をグローバルな入力勾配バッファmatrix_igに書き込みます。その前に、Deactivation関数を呼び出します。この関数は、対応する入力値matrix_iに対して活性化関数の微分を適用します。この処理は非常に重要です。勾配は使用している活性化関数に応じて補正する必要があり、これをおこなわなければ学習処理が正しく機能しなくなります。

 if(id_loc == 0)
    matrix_ig[v * inputs + id_x] = Deactivation(temp[0], matrix_i[v * inputs + id_x], activation);
}

これは、逆伝播における計算負荷が特に大きい段階の1つです。計算をウィンドウとチャネルに分割し、さらにローカルメモリを使用することで、パラメータ数が多い場合でも高い処理効率を実現できます。順方向の処理と組み合わせることで、この処理によって私たちのアルゴリズムはモジュール性とスケーラビリティを備えるだけでなく、精度や性能を損なうことなく、金融時系列における非常に複雑な依存関係にも対応できるようになります。

この畳み込み処理の一連の流れにおける最後の段階が、重みの最適化です。この段階では、計算された勾配が実際のパラメータ更新へと変換されます。これを実現するために、Adam最適化アルゴリズムを使用します。ただし、異なるサイズの畳み込みウィンドウとゼロパディングに対応できるように拡張されています。これらすべてのロジックは、UpdateWeightsMultWinConvAdamWPadカーネルにまとめられています。

__kernel void UpdateWeightsMultWinConvAdamWPad(__global float *matrix_w,
                                               __global const float *matrix_og,
                                               __global const float *matrix_i,
                                               __global float *matrix_m,
                                               __global float *matrix_v,
                                               __global const int *windows_in,
                                               const int windows_total,
                                               const int window_out,
                                               const int inputs,
                                               const int step,
                                               const int outputs,
                                               const float l,
                                               const float b1,
                                               const float b2
                                              )
  {
   const size_t i = get_global_id(0);  // weight shift
   const size_t v = get_local_id(1);   // variable
   const size_t variables = get_local_size(1);

このカーネルでは、各スレッドが1つの特定のパラメータiの更新を担当します。このパラメータは、フィルタ係数の場合もあれば、biasの場合もあります。変数vは入力系列を識別し、variablesは入力系列の総数を指定します。これらを組み合わせることで、すべての入力系列にわたって勾配を累積でき、より安定したパラメータ更新が可能になります。

実行の開始時に、各スレッドはパラメータインデックスiに対応するフィルタ、出力チャネル、および個々の重みを特定します。これは、すべての畳み込みウィンドウを順番に処理しながら、shift_beforeという累積オフセットを保持することで実現されます。shift_beforeは、学習可能なパラメータを格納した一次元配列内において、各ウィンドウが占める位置を追跡します。

   __local float temp[LOCAL_ARRAY_SIZE];
   const uint ls = min((uint)variables, (uint)LOCAL_ARRAY_SIZE);
//---
   int step_out = window_out * windows_total;
//---
   int shift_before = 0;
   int window = 0;
   int number_w = 0;
   for(int w = 0; w < windows_total; w++)
     {
      int win = windows_in[w];
      if(shift_before <= i &&
         (win + 1)*window_out > (i - shift_before))
        {
         window = win;
         number_w = w;
        }
      else
         shift_before += (win + 1) * window_out;
     }

iに対応するウィンドウが特定されると、次の値を決定します。

  • window:フィルタのサイズ(幅)
  • number_w:ウィンドウのインデックス
  • id_f:フィルタのインデックス
  • shift_in:ウィンドウの先頭を基準としたオフセット(ウィンドウサイズと等しい場合はbias
  • bias:対象となる要素がbiasパラメータであるかどうかを示す論理フラグ

int shift_in = (i - shift_before) % (window + 1);
int shift_in_var = v * inputs;
bool bias = (shift_in == window);
int mid_win = (window + 1) / 2;
int id_f = (i - shift_before) / (window + 1);
int shift_out = number_w * window_out + id_f;
int shift_out_var = v * outputs * step_out;

通常のフィルタ係数の場合、カーネルはすべてのout出力位置について反復処理をおこないます。各位置で、対応する入力要素が有効な入力範囲内にあるかを確認します。範囲内にある場合は、出力勾配matrix_ogに対応する入力活性値matrix_iを掛け合わせ、その積をgradに累積することで、通常の勾配計算を実行します。

float grad = 0;
if(!bias)
  {
   for(int out = 0; out < outputs; out++)
     {
      int in = out * step - mid_win + shift_in;
      if(in >= inputs)
         break;
      if(in < 0)
         continue;
      //---
      grad += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out * step_out] * matrix_i[shift_in_var + in], 0);
     }
  }
else
  {
   for(int out = 0; out < outputs; out++)
      grad += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out * step_out], 0);
  }

biasパラメータの場合、入力活性値は必要ありません。その代わり、カーネルはすべての位置にわたって出力勾配を単純に累積します。

次の段階では、再びtemp配列を使用したローカルな累積処理をおこないます。その目的は、すべての入力系列にわたって勾配を集約すると同時に、個々の外れ値による影響を抑えることです。先ほどと同様に、並列リダクションツリーと同期バリアを組み合わせることで、各ワークグループ内の加算処理を効率的に実行します。最終的な重みを更新するのは、v == 0(最初のスレッド)のスレッドだけです。

//--- sum
   for(int s = 0; s < (int)variables; s += ls)
     {
      if(v >= s && v < (s + ls))
         temp[v % ls] = (s == 0 ? 0 : temp[v % ls]) + grad;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   uint count = ls;
   do
     {
      count = (count + 1) / 2;
      if(v < count && (v + count) < ls)
        {
         temp[v] += temp[v + count];
         temp[v + count] = 0;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

ここでAdamオプティマイザが登場します。

  1. 1次モーメント推定値mtを、係数b1による指数平滑化を用いて更新します。
  2. 勾配の分散を表す2次モーメント推定値vtを、係数b2を用いて更新します。
  3. mt / sqrt(vt)を正規化し、その結果を学習率lでスケーリングすることで、重みを更新します。
  4. 数値的不安定性、重みの発散、ゼロ除算を防ぐため、すべての値をclampによって制限します。

 if(v == 0)
   {
    grad = temp[0];
    float mt = IsNaNOrInf(clamp(b1 * matrix_m[i] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0);
    float vt = IsNaNOrInf(clamp(b2 * matrix_v[i] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f);
    float weight = clamp(matrix_w[i] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);
    matrix_w[i] = weight;
    matrix_m[i] = mt;
    matrix_v[i] = vt;
   }
}

最後に、更新されたパラメータ値がグローバルメモリに書き戻されます。

これまでに説明したフォワードプロパゲーションとバックプロパゲーションの各段階と組み合わせることで、この仕組みにより、畳み込みブロック全体が自己完結型かつ完全に微分可能なものとなり、ディープニューラルネットワークのアーキテクチャへ組み込むことが可能になります。さらに重要なのは、この仕組みが高いスケーラビリティを備えており、任意の数の畳み込みウィンドウや出力チャネルに容易に対応できる点です。これにより、実世界の非線形かつ非定常な時系列データを扱う際に、モデルに非常に高い柔軟性を与えることができます。

メインアプリケーションでは、ゼロパディングを伴うマルチウィンドウ畳み込みの機能全体が、CNeuronMultiWindowsConvWPadOCLクラスにカプセル化されています。このモジュールは、ベースとなる畳み込み層CNeuronConvOCLを継承しており、ここまで説明してきたすべてのOpenCLカーネルの実行をスケジューリングするためのインターフェースとして機能します。

クラス構成を以下に示します。

class CNeuronMultiWindowsConvWPadOCL    :  public CNeuronConvOCL
  {
protected:
   int               aiWindows[];
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronMultiWindowsConvWPadOCL(void) {  activation = SoftPlus;  iWindow = -1; }
                    ~CNeuronMultiWindowsConvWPadOCL(void) {};
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint &windows[],
                          uint step, uint window_out, uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void)   const   {  return defNeuronMultiWindowsConvWPadOCL;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual void      SetOpenCL(COpenCLMy *obj);
   //---
   virtual uint               GetWindow(void) const { return aiWindows[0]; }
   virtual uint               GetWindowsSize(void) const { return aiWindows.Size(); }
   virtual uint               GetWindowOut(void) const { return iWindowOut; }
   virtual uint               GetUnits(void) const { return Neurons()/(iVariables*GetWindowsSize()*iWindowOut); }
  };

このクラスは、OpenCLとのやり取りに伴う複雑な処理をすべて抽象化し、マルチウィンドウ畳み込みブロックをモデル全体のアーキテクチャへシームレスに統合できるようにします。クラスの完全なソースコードと、そのすべてのメソッドの実装は、より詳しく実装を学びたい読者のために添付ファイルに収録されています。

残念ながら、どの記事にも実用上の制約があり、今回は利用できるスペースがここまでとなりました。今回実装した手法の有効性については、次の記事で評価することにします。


結論

本記事では、Mamba4Castフレームワークの著者らが提案した概念の実装を引き続き進め、2つの中核となるコンポーネント、すなわち時間情報を考慮した入力埋め込みとゼロパディングを伴うマルチウィンドウ畳み込みに焦点を当てました。CMamba4CastEmbeddingクラスでは、元の特徴量の射影と、調波成分に基づく時間エンコーディングを1つのモジュール内で組み合わせる方法を示しました。一方、FeedForwardMultWinConvWPadCalcHiddenGradientMultWinConvWPadUpdateWeightsMultWinConvAdamWPadの各OpenCLカーネルでは、学習全体を通して完全な微分可能性を維持しながら、複数の畳み込みウィンドウを効率的に並列処理する方法を示しました。

今回注目したのは、単なる性能だけではありません。オフセットを慎重に管理し、実行スレッド間でワークロードを均等に分配し、ローカルリダクションによって勾配を効率的に集約することで、畳み込みブロックを真にスケーラブルなコンポーネントへと発展させました。同時に、全体のロジックは明確なまま維持されています。CNeuronMultiWindowsConvWPadOCLクラスがOpenCL固有の実装詳細をすべてカプセル化することで、提案したソリューションを任意のモジュールへ容易に統合できるようになっています。

次の記事では、ここまで開発してきたすべてのコンポーネントを1つの完全なモデルへ組み込み、実際の過去の市場データを用いて学習させ、提案した手法が実際にどの程度有効なのかを評価します。


関連リンク


記事で使用されたプログラム

# 名前 種類 説明
1 Research.mq5 EA データセット収集用EA
2 ResearchRealORL.mq5
EA
Real-ORL法を用いたデータセット収集用EA
3 Study.mq5 EA オフラインモデル学習用EA
4 StudyOnline.mq5
EA
オンラインモデル学習用EA
5 Test.mq5 EA モデルテスト用EA
6 Trajectory.mqh クラスライブラリ システム状態とモデルアーキテクチャ記述構造
7 NeuroNet.mqh クラスライブラリ ニューラルネットワークを作成するためのクラスのライブラリ
8 NeuroNet.cl ライブラリ OpenCLプログラムコード

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

添付されたファイル |
MQL5.zip (2754.59 KB)
MQL5での量子ニューラルネットワーク(第1回):インクルードファイルの作成 MQL5での量子ニューラルネットワーク(第1回):インクルードファイルの作成
量子原理と人工知能に基づく取引システムを構築するための新しいアプローチを紹介しています。著者は、量子力学と最新のAIアーキテクチャを組み合わせることで、従来の機械学習の枠を超える独自のニューラルネットワークの開発について説明しています。
市場シミュレーション(第24回):ポジション表示(II) 市場シミュレーション(第24回):ポジション表示(II)
取引サーバー上の未決済ポジションを、インジケータを使って、できるだけシンプルかつ実用的な方法で追跡する方法を紹介します。これは、必ずしもすべてをエキスパートアドバイザー(EA)に移す必要はないことを示すため、段階を追って説明していきます。皆さんの多くは、何らかの理由で、このような処理をEAでおこなうことに慣れているかもしれません。しかし、実際には、そのやり方にはそれほど十分な根拠があるわけではありません。なぜなら、この実装を発展させていくにつれて、この目的のためにさまざまな種類のインジケータを作成したり、実装したりできることが明らかになるからです。
市場シミュレーション:ポジション表示(III) 市場シミュレーション:ポジション表示(III)
以前の記事では、ZOrderプロパティに値を設定する必要がある場合があることについて説明しました。しかし、なぜでしょうか。チャートにオブジェクトを追加する多くのコードが、このプロパティを使用していない、より正確に言えば、このプロパティに値を定義していないからです。すべてのプログラマーが何をすべきか、あるいはすべきでないか、また、どのようにコードを書くべきか、あるいは書くべきでないかを私が決めるために、ここにいるわけではありません。私は、読者の皆さん、そしてこれらの処理が内部でどのように動作しているのかを本当に理解したいすべての方々に対して、実際に舞台裏で何が起こっているのかを示すために、ここにいます。
初級から中級まで:オブジェクトイベント(IV) 初級から中級まで:オブジェクトイベント(IV)
前回の記事で始めた内容を完成させます。つまり、チャート上でオブジェクトを直接操作してサイズを変更できる、完全にインタラクティブな方法を実現します。このようなものを作成するには、MQL5についてもっと深い知識が必要だと、多くの人は考えるかもしれません。しかし実際には、基本的な知識とシンプルな概念を使うことで、チャート上のオブジェクトを直接操作する方法を実装できます。そして、その結果は非常に興味深く、かなり魅力的なものになります。