English Русский 中文 Español Português
preview
取引におけるニューラルネットワーク:インテリジェントな予測パイプライン(スパース混合エキスパート)

取引におけるニューラルネットワーク:インテリジェントな予測パイプライン(スパース混合エキスパート)

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

はじめに

金融市場は、複雑でカオス的かつ高頻度で変動するシステムです。 このような環境では、大まかな近似や平均値だけでは十分に対応できません。 個々のバーや価格変動は、ファンダメンタルズに関するニュースからモメンタム取引に至るまで、数多くの要因が絡み合った結果として生じます。 だからこそ、市場の時系列データを扱うには、細かな変化に敏感で、ノイズに対して頑健であり、カオスの中から構造を見いだせる特別なアプローチが必要となります。

Time-MoEフレームワークは、まさにこのようなアーキテクチャを提供します。 これは、単に時系列データ向けに適応させたTransformerではありません。 分析対象となる過去の各時間ステップを、それぞれ固有のトークンとして扱う、全体として緊密に統合されたシステムです。 各トークンは、個々の特性と時間的な文脈を保持しながら、一連の変換処理を受けます。 このアプローチにより、モデルは高頻度データを処理し、従来の集計手法では検出できないパターンを捉えられるようになります。

処理の最初の段階は埋め込み層です。ここでは、データに非線形変換が適用されます。 これにより、価格と出来高の関係、インジケーターの方向性、直近の価格変動の強さなど、特徴量間の複雑な関係を捉えることができます。 こうして得られた隠れ表現が、以降の分析の基礎となります。

次に、トークンは一連のTransformerブロックに送られます。 ここでモデルは過去の情報を参照し、蓄積された情報に基づいて現在の状態を表現します。 これを実現するのがAttention機構です。各トークンは、それ以前のトークンと比較され、どの要素をどの程度重視するかが、手動で設定されるのではなく、学習を通じてモデル自身によって決定されます。 これにより、短期的なモメンタムと長期的なトレンドの両方を考慮できるようになります。

Time-MoEは、ノイズに対する頑健性を特に重視しています。 そのため、このアーキテクチャには、ランダムな外れ値の影響を抑え、意味のあるシグナルを際立たせるのに役立つ正規化機構が組み込まれています。 その結果、Attentionが個々の異常値に過度に集中するのではなく、より均等かつ適切に分散されるようになります。これは、ボラティリティが高く、市場ノイズの影響を受けやすい環境において特に重要です。

Time-MoEと従来のTransformerとの主な違いは、スパース混合エキスパート(Mixture-of-Experts, MoE)を使用している点にあります。 各ブロックでは、ルーターが利用可能なエキスパートの中から、現在のトークンの処理に参加する一部だけを選択します。 この仕組みにより、計算負荷を大幅に削減しながら、必要な計算資源を指数関数的に増加させることなく、モデルを拡張できます。 さらに、Time-MoEには、常に動作する共有エキスパートが1つ含まれています。このエキスパートは、誤ったルーティング判断に対するモデルの頑健性を高めます。

最後の段階は予測です。 ここでは、モデルが異なる予測期間に対する複数の予測を同時に生成します。 このアプローチにより、短期的なシグナルと長期的なトレンドを同時に考慮し、トレーダーや分析システムに幅広いシナリオを提供できます。 学習時には、モデルがすべての予測期間を対象に同時に学習するため、柔軟性が高まり、変化する市場環境にも適応しやすくなります。

以上をまとめると、Time-MoEには次のような特徴があります。

  • 各時間ステップをトークンとして扱い、細部に敏感なモデル
  • Attentionの正規化によって、ノイズや外れ値に対する頑健性を高める仕組み
  • エキスパートをスパースに利用する、拡張性の高いアーキテクチャ
  • マルチホライズン予測に対応する汎用的な予測機構

以下に、著者によるTime-MoEフレームワークの構成図を示します。

今回は、前回からの取り組みを引き継ぎ、Time-MoEフレームワークの重要な構成要素であるスパース混合エキスパート(Sparse Mixture of Experts)に焦点を当てます。 前回の記事では、SwiGLUによる埋め込みを用いてトークンと隠れ表現を形成し、モデルの基盤を段階的に構築しました。今回は、システム全体の効率性と拡張性を大きく左右する、アーキテクチャの重要な要素へと進みます。

本記事では、エキスパート群がどのように動作し、計算がどのように分配されるのかを詳しく見ていきます。 理論的な仕組みの説明にとどまらず、MQL5を用いたスパースなMoEの実装にも踏み込み、実践的な観点から解説します。


アーキテクチャの開発

スパース混合エキスパートの実装に取り掛かる前に、まず設計方針を整理しておきましょう。 これまでと同様、すべてのエキスパートを並列に動作させるという方針を維持します。これは、大規模な並列計算の考え方に適したアプローチです。 したがって、迷うことなく処理負荷の大部分をOpenCLコンテキストへ移します。 しかし、ここで重要な疑問が生じます。モデルの効率性と学習能力を維持しながら、エキスパートのスパースな活性化を具体的にどのように実現すればよいのでしょうか。

まず、重要なポイントを振り返ってみましょう。 Time-MoEの著者らは、原論文において、Transformerアーキテクチャで一般的に用いられる従来のFeedForwardブロックとは大きく異なるエキスパート構造を提案しています。 具体的には、エキスパート内部の第1層をSwiGLU変換に置き換えています。 この変更には十分な理由があります。これまでに見てきたように、SwiGLUは特徴量の処理におけるモデルの柔軟性を高め、非線形な関係をより効果的に捉えられるようにします。 幸い、この層の実装はすでに用意されています。前回の記事では、CNeuronSwiGLUOCLコンポーネントを開発しました。 したがって、並列サブモデルの数に応じて出力フィルターの数を調整することで、これを混合エキスパートの第1段階として活用できます。

各フィルターには、それぞれ独立した学習可能なパラメータが割り当てられています。 つまり、それぞれが独立したエキスパートモデルとして機能します。 出力をエキスパートごとにグループ化すれば、1つの入力に対して複数の独立したエキスパートが並列に動作するという、必要な構造を実現できます。 処理の第2段階では、これまでに実装したマルチウィンドウ畳み込みを適用できます。 この層は、各エキスパート内部の情報を整理・統合し、後続の集約処理に備える役割を果たします。

ここまでの設計は、理にかなっているように見えます。 しかし、重要な問題が1つ残っています。ここまで説明したアルゴリズムには、MoEの中核となる要素であるスパース性が欠けています。 確かに、すべてのエキスパートの出力に活性化マスクを適用すれば、必要な結果を得ることはできます。 しかし、マスクによって出力への寄与を抑制しているだけでは、すべてのエキスパートで計算が実行され続けます。 この方法は小規模なモデルであれば許容できるかもしれませんが、システムを拡張するにつれて非効率になります。エキスパート数の増加、次元数の拡大、ネットワークの深層化が重なると、計算負荷は大幅に増大するためです。

そこで、重要な疑問が浮かび上がります。アルゴリズムのどの段階で、どのようにスパース性を導入すべきなのでしょうか。

一見すると、各段階で使用されないエキスパートを無効化することが最善の解決策に思えるかもしれません。そうすれば、計算負荷を大幅に削減できるからです。 しかし、ここには、慎重に考えるべき問題があります。 MoEの基本的な考え方は、異なるエキスパートに異なるサブタスクを学習させ、 それぞれに固有の専門化を身につけさせることです。 では、特定の状況でどのエキスパートを動作させるかは、誰が決めるのでしょうか。 その役割を担うのがルーターです。ルーターは入力データに基づいて、活性化するモデルの一部を選択します。

問題が生じるのは、ルーターが学習の早い段階でごく少数のエキスパートに偏り、その後、入力の状況にかかわらず同じエキスパートばかりを選択するようになった場合です。 このようなモデルは、初期段階では良好な結果を示すかもしれません。しかし、代替の経路をまったく試さなくなるため、適応能力が制限されてしまいます。 選択されないエキスパートは、ほかの状況で有効に機能する可能性があっても、その能力を示す機会を得られません。 これにより、局所的な最適への固定化効果が生まれます。ルーターは特定のエキスパートのみを認識し、他のエキスパートを試すことなく、同じ選択パターンに固着してしまいます。 これにより、モデルが利用できる処理経路の多様性が損なわれ、汎化能力も低下します。

したがって、私たちの目標は、ルーターに単にエキスパートを選択させることではありません。入力データの特性に応じて、選択戦略そのものを適応させるように学習させることです。 当初はそれほど効果的でない場合でも、モデルがほかのエキスパートの挙動を探索できるような仕組みを整える必要があります。 そうすることで、モデルはタスクの割り当てをより柔軟に学習し、全体的な予測精度と変化する市場環境への適応力を高められるようになります。

計算効率と学習時のエキスパートの利用範囲とのバランスを取るため、今回はMoEブロックの第2層でのみエキスパートをスパースに利用し、この層を出力の集約処理と組み合わせることにします。 このアプローチにより、アーキテクチャを簡素化して計算負荷を抑えながら、ルーターの学習に必要な柔軟性を維持できます。

この設計で重要になるのが、誤差勾配の伝播方法です。 私たちは、使用されなかったエキスパートを直接学習させるという方針は採用しません。MoEの目的は、異なるエキスパートを異なるタスクに特化させることにあるためです。 一方で、モデルが同じエキスパートの組み合わせに固着するのを防ぐ必要があります。そのため、勾配をルーターに伝播させ、現在の選択が必ずしも最適ではない可能性を学習に反映させます。 これにより、ルーティングの仕組み自体を学習させ、現在のエキスパートの組み合わせによる予測が不十分だった場合に、別の組み合わせを選択できるようにすることを目指します。

したがって、あるケースで2つのエキスパートだけが活性化されていたとしても、ルーターは、ほかのエキスパートを選択する可能性も考慮しながら、選択方針を学習していきます。 これは単なる技術的な工夫ではありません。入力データの特徴と、適切なエキスパートの組み合わせとの関係を、モデルが段階的に学習できるようにするための重要な仕組みです。



マスク付きウィンドウ畳み込み

基本的な方針が定まったので、ここからは実際に手を動かし、理論から実装へと進みましょう。 実装の最初の段階では、重要なコンポーネントである第2層のMoEオブジェクトを開発します。このオブジェクトは、エキスパートのスパースな活性化と、その出力の集約を担当します。 モデルの中核となるこのコンポーネントには、すべてのエキスパートのデータが入力されますが、各処理では、そのうちの一部のエキスパートだけが活性化されます。

アクティブなエキスパートの選択機能は、ルーターという独立したモジュールに分離しました。 ルーターの役割は、入力データを分析し、各トークンについて、どのエキスパートを処理に参加させるかを指定する活性化マスクを生成することです。 このマスクは、分析対象の特徴量を格納したメインテンソルとともに本オブジェクトに渡され、現在のステップで実行する計算の構成を決定します。

ここで重要な前提があります。すべてのエキスパートは同じアーキテクチャを持ち、固定次元の出力を返します。 ただし、各時点で実際に動作するモデルの数は限られており、通常は出力空間の次元数に比べてはるかに少数です。 残りのエキスパートは休止モードにあり、計算に参加せず、計算資源も消費しません。

このアプローチには、主に2つの利点があります。

  1. 計算負荷の大幅な削減: モデルの規模を拡大し、エキスパート数を増やす際に、特に重要な利点となります。
  2. 専門化の促進: 各エキスパートが、特定の限定されたタスク領域の学習に集中できます。 その結果、モデルは各エキスパートに分担された本来的な専門知識を形成します。

計算処理の大部分をOpenCLコンテキストに移すことで、GPUやその他の互換デバイスの並列計算能力を効率的に活用できるようになりました。 ここでは、第2層のMoEにおける順伝播カーネルに焦点を当てます。このカーネルは、選択されたエキスパートだけをスパースに活性化し、その出力を集約します。

カーネルのパラメータとして、すべてのエキスパートの重み、入力データ、活性化マスクに加え、ウィンドウの構造や次元を定義する複数のパラメータを受け取ります。

__kernel void FeedForwardMaskMultWinConv(__global const float *matrix_w,
                                         __global const float *matrix_i,
                                         __global const float *masks,
                                         __global float *matrix_o,
                                         const int inputs,
                                         const int window_in,
                                         const int windows_total,
                                         const int activation
                                        )
  {
   const size_t u = get_global_id(0);
   const size_t w = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t units = get_global_size(0);
   const size_t window_out = get_global_size(1);
   const size_t variables = get_global_size(2);

各計算スレッドは、シーケンス内の位置、トークンの要素、および変数によって、結果テンソル内の特定の要素に対応します。 このように要素単位でアドレスを指定することで、高い並列性を実現できます。

ここで重要なのは、事前に次のような前提を置いている点です。各順伝播において、アクティブなエキスパートの数は、結果テンソルにおけるトークンの次元数よりも大幅に少ないという前提です。 これは、カーネル内でのタスクの割り当て方法を決定する重要なポイントです。 そこで、タスク空間をトークンの要素ごとに分割し、カーネル内部のループでアクティブなエキスパートを順番に処理します。 この方法により、エキスパートのスパースな活性化を維持しながら、計算資源を効率的に活用できます。

カーネル本体では、まずタスク空間内の計算スレッドを特定し、データバッファ内で処理対象となる要素のオフセットを算出します。

   const int shift_in = u * window_in * windows_total;
   const int shift_in_var =  v * units * window_in * windows_total;
   const int shift_out = (u + v * units) * window_out + w;
   const int shift_mask = (u + v * units) * windows_total;
   const int shift_weight = (v * window_out * windows_total + w) * (window_in + 1);
   const int step_weight = window_out * (window_in + 1);

次に、ループ構造を設定します。 外側のループでは、すべてのエキスパートを順に処理し、活性化マスクを使って各エキスパートがアクティブかどうかを確認します。 このウィンドウ内でアクティブでないエキスパートについては、その寄与をスキップします。これにより、不要な計算を省き、リソースを節約できます。 アクティブなエキスパートについては、入力データの重み付き和を計算し、バイアスを加えます。 最終的に、得られた値を累積し、活性化関数に通します。

float sum = 0;
for(int w_in = 0; w_in < windows_total; w_in++)
  {
   float m = IsNaNOrInf(masks[shift_mask + w_in], 0);
   if(m < FLT_EPSILON)
      continue;
   const int shift_in_loc = shift_in + w_in * window_in;
   const int shift_weight_loc = shift_weight + w_in * step_weight;
   for(int i = 0; i < window_in; i++)
      if((shift_in_loc + i) < (inputs / variables))
         sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc + i], 0) * 
                matrix_w[shift_weight_loc + i] * m;
   sum += matrix_w[shift_weight_loc + window_in] * m;
 }

集約処理では、アクティブなエキスパートの出力を単純に合計するのではなく、各出力に対応するマスク値を乗算する点に注意してください。 マスクが0と1のみで構成されるバイナリマスキングの場合、この方法による追加の利点はありません。 しかし、この仕組みには重要な利点があります。それは、加重集約を実装できることです。 マスクに単なるフラグではなく、実際の重み係数が含まれていれば、ソフト選択や確率的ルーティングに至るまで、結果に対する各エキスパートの寄与を制御できます。

得られた値は活性化関数に通され、結果バッファに格納されます。

 matrix_o[shift_out] = Activation(sum, activation);
}

アクティブなエキスパートが特徴量の部分空間を処理し、その結果がマスクによって重み付けされる順伝播を実行した後、次の重要な段階である誤差勾配の逆伝播に移ります。 この段階では、学習中に調整できるよう、各アクティブモデルの入力データだけでなく、活性化マスク自体にも誤差勾配を適切に伝播させる必要があります。

この課題を解決するため、並列計算の枠組みでこれらすべての処理を行うOpenCLカーネル CalcHiddenGradientMaskMultWinConvを開発しました。

__kernel void CalcHiddenGradientMaskMultWinConv(__global const float *matrix_w,
                                                __global const float *matrix_i,
                                                __global float *matrix_ig,
                                                __global const float *matrix_og,
                                                __global const float *masks,
                                                __global float *masks_g,
                                                const int outputs,
                                                const int window_in,
                                                const int window_out,
                                                const int activation
                                               )
  {
   const size_t u = get_global_id(0);
   const size_t w_in = get_global_id(1);
   const size_t v = get_global_id(2);
   const size_t units = get_global_size(0);
   const size_t windows_total = get_global_size(1);
   const size_t variables = get_global_size(2);

このカーネルは、重み、入力データ、出力層の誤差勾配、マスク、および入力データとマスクの勾配を書き込むためのバッファを受け取ります。 OpenCLコンテキスト内の各ワークアイテムは、トークン、エキスパート、変数のそれぞれの組み合わせを個別に担当します。 カーネル本体では、まずタスク空間のすべての次元に沿ってワークアイテムの位置を特定します。 次に、データバッファ内のオフセットを算出します。

const int shift_in = (u + v * units) * window_in * windows_total + w_in * window_in;
const int shift_out = u * window_out;
const int shift_out_var = v * units * window_out;
const int shift_mask = (u + v * units) * windows_total + w_in;
const int shift_weight = (v * window_out * windows_total + w_in * window_out) * (window_in + 1);

最初のステップでは、誤差勾配を入力データまで逆伝播させます。 まず、マスクを確認します。指定されたトークンに対して対応するエキスパートがアクティブでない場合、入力データの勾配バッファにゼロを書き込むだけで済み、不要な計算を避けられます。 一方、エキスパートが計算に参加していた場合は、勾配の伝播を開始します。

const float m = IsNaNOrInf(masks[shift_mask], 0);
for(int i = 0; i < window_in; i++)
  {
   float sum = 0;
   if(m >= FLT_EPSILON)
     {
      for(int out = 0; out < window_out; out++)
        {
         if((shift_out + out) >= (outputs / variables))
            continue;
         sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] *
                           matrix_w[shift_weight + out * (window_in + 1) + i] *
                           m, 0);
         sum += IsNaNOrInf(matrix_w[shift_weight + out * (window_in + 1) + window_in] *
                           m, 0);
        }
     }
   matrix_ig[shift_in + i] = Deactivation(sum, matrix_i[shift_in + i], activation);
  }

まず、カーネルはすべての出力チャネルを順に処理し、入力データの各要素が全体の誤差にどの程度寄与するかを計算します。 得られた値は、入力データ層の活性化関数の導関数を用いて補正され、グローバルデータバッファ内の対応する要素に格納されます。

次に、2番目のステップであるマスクへの誤差勾配の伝播をおこないます。 ここでは、現在のエキスパートが結果に与える影響を考慮しながら、そのエキスパートの出力においてすべてのチャネルの寄与を集約します。これにより、そのエキスパートを選択したことがどれほど有効だったかを示すフィードバック信号を生成します。

 float sum = 0;
 for(int out = 0; out < window_out; out++)
   {
    int shift_weight_loc = out * (window_in + 1) + shift_weight;
    float temp = matrix_w[shift_weight_loc + window_in];
    for(int i = 0; i < window_in; i++)
       temp += IsNaNOrInf(matrix_i[shift_in + i], 0) * matrix_w[shift_weight_loc + i];
    sum += IsNaNOrInf(temp * matrix_og[shift_out_var + shift_out + out], 0);
   }
 masks_g[shift_mask] = IsNaNOrInf(sum, 0);
}

マスクがバイナリであっても、この値はルーターが将来より適切な選択を行うのに役立ちます。また、必要に応じて、重み付き活性化に用いる重みスコアとして解釈することもできます。

この仕組みにより、すべてのルーティング決定を勾配によって調整でき、必要になれば休止状態のエキスパートも活性化できる、真に学習可能なアーキテクチャが実現します。 これにより、スパース混合エキスパートは、効率的かつ柔軟に、そして真にインテリジェントに動作できるようになります。

次の重要なステップは、モデルのパラメータを更新することです。 この段階で、モデルは受け取った誤差信号に基づいて重みを調整し、学習を進めます。 この処理を実装するために、スパース混合エキスパートの特性に合わせて設計され、Adam法による最適化をサポートする専用のOpenCLカーネル「UpdateWeightsMaskMultWinConvAdam」を使用します。

これまでと同様に、学習に関わる各重みは独立して処理できるため、計算の大部分をOpenCLコンテキストにオフロードします。

__kernel void UpdateWeightsMaskMultWinConvAdam(__global float *matrix_w,
                                               __global const float *matrix_og,
                                               __global const float *matrix_i,
                                               __global const float *masks,
                                               __global float *matrix_m,
                                               __global float *matrix_v,
                                               const int windows_total,
                                               const int inputs,
                                               const int outputs,
                                               const float l,
                                               const float b1,
                                               const float b2
                                              )
  {
   const size_t id_in = get_global_id(0);
   const size_t id_out = get_global_id(1);
   const size_t id_v = get_global_id(2);
   const size_t window_in = get_global_size(0) / windows_total - 1;
   const size_t window_out = get_global_size(1);
   const size_t variables = get_global_size(2);

カーネルの処理は、3つの座標に沿って構成されます。id_in は入力次元とウィンドウ内の位置に対応し、id_outは出力フィルタのインデックスに対応し、id_vはバッチ内の現在の変数に対応します。 この3次元構成により、エキスパートのすべてのパラメータを網羅し、並列に処理できます。

ここで重要なのは、構造上の前提を意図的に採用している点です。すべてのエキスパートが同一の解析ウィンドウを使用し、結果テンソルも同じ形状を持つと仮定しています。 これにより、システム全体を2次元行列として表現できます。一方の軸はフィルタ数を、もう一方の軸は、すべてのエキスパートにわたる解析ウィンドウ内の要素数の合計を表します。 この簡略化により、メモリ内の重みに効率的かつコンパクトにアドレス指定できるようになります。これは、パラメータを一括更新する際に特に重要です。

カーネル内では、まずタスク空間のすべての次元に沿って各計算スレッドの位置を特定します。 次に、解析対象の要素に対応する各データバッファ内のオフセットを算出します。

const int w_id = id_in / (window_in + 1);
const int shift_in = id_in - w_id;
const int step_in = window_in * windows_total;
const int units = outputs / window_out;
const int shift_in_var = id_v * inputs;
const int shift_out_var = id_v * outputs;
const int shift_mask_var = id_v * units * windows_total;
const int shift_weight = ((id_v * windows_total + w_id) * window_out + id_out) *
                         (window_in + 1) + id_in % (window_in + 1);
const bool bias = (id_in % (window_in + 1) == window_in);

次に、エキスパートが処理したすべてのトークン(units)を順に走査します。 各フラグメントについて、マスクを使って対応するエキスパートがアクティブだったかどうかを確認します。

float grad = 0;
for(int u = 0; u < units; u++)
  {
   const int shift_in_loc = shift_in + u * step_in;
   if(shift_in < inputs)
      continue;
   float m = IsNaNOrInf(masks[shift_mask_var + u * windows_total + w_id], 0);
   if(m < FLT_EPSILON)
      continue;
   float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0));
   grad += IsNaNOrInf(inp * m * matrix_og[shift_out_var + u * window_out + id_out], 0);
  }

あるステップでエキスパートが休止モードにある場合(マスクの値がほぼゼロの場合)、誤差勾配の計算にリソースを費やさず、次のトークンへと進みます。 これは、スパース学習における重要な特性の1つです。

アクティブなエキスパートについては、誤差勾配を計算します。具体的には、入力データに出力テンソル(matrix_og)の対応する位置にある誤差値とマスクを乗算します。

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

次に、Adamによる最適化処理を適用します。1次モーメント(mt)と2次モーメント(vt)を更新し、勾配を正規化したうえで、重みの値を調整します。 すべての更新処理にはclamp関数を適用し、重みの値が許容範囲内に収まるように制限します。

最後に、更新後の値をグローバルバッファに格納します。

 matrix_w[shift_weight] = weight;
 matrix_m[shift_weight] = mt;
 matrix_v[shift_weight] = vt;
}

したがって、各重みは最終結果への寄与に応じて更新され、実際の計算に参加した場合にのみ更新されます。 この構成により、高い計算効率を確保できるだけでなく、不適切なタイミングでは休止状態を維持し、その知識が本当に必要なときには積極的に学習する、高度な専門エキスパートを訓練できます。

OpenCL側の計算ロジックがすべて実装できたので、次のステップでは、メインプログラム内でプロセス全体を構成します。 ここでは、適切なカーネルの呼び出しとマスクの処理を担うオブジェクトを作成し、設定します。 この役割を担うのが、CNeuronConvOCLを継承した専用クラスCNeuronMaskMultiWinConvです。 新しいオブジェクトの構造を以下に示します。

class CNeuronMaskMultiWinConv    :  public CNeuronConvOCL
  {
protected:
   uint              iWindowsTotal;
   //---
   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 *NeuronOCL) override { return false; }
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput,
                       CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override;

public:
                     CNeuronMaskMultiWinConv(void) {};
                    ~CNeuronMaskMultiWinConv(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                          uint windows_total, uint window_out, uint units_count, uint variables,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override const  {  return defNeuronMaskMultiWinConv;   }
   //--- methods for working with files
   virtual bool      Save(int const file_handle) override;
   virtual bool      Load(int const file_handle) override;
  };

このクラスは、モデルと低レベルの計算パイプラインをつなぐ役割を果たします。 データを適切に準備してOpenCLコンテキストに渡し、必要なカーネルを起動して、その結果を取得します。

CNeuronMaskMultiWinConvオブジェクトの構造では、意図的に新しい内部コンポーネントを宣言していません。 必要な機能はすべて親クラスのCNeuronConvOCLにすでに備わっており、OpenCL側の計算を構成するにはそれで十分だからです。 このアプローチにより、アーキテクチャの明快さを保ち、冗長な要素を避けながら、すべてのリソースを一元的に管理できます。

レイヤーは、オーバーライドしたInitメソッド内で初期化します。ここでは、全体のロジックに影響を与えず、主要なパラメータのみを調整します。

bool CNeuronMaskMultiWinConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                                   uint windows_total, uint window_out, uint units_count, uint variables,
                                   ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   uint win = window * windows_total + MathMax(windows_total, 1) - 1;
   if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, win, win, window_out, units_count, variables, optimization_type, batch))
      return false;
   iWindowsTotal = windows_total;
   iWindow = window;
//---
   return true;
  }

具体的には、並列エキスパートの数を決定するiWindowsTotalの値を設定します。 この値は、カーネル内でオフセットを生成し、アドレスを計算する際に使用されます。

初期化メソッドで最も重要なのは、処理対象となるウィンドウの幅を正しく計算することです。 各エキスパートはそれぞれ固定長のウィンドウを処理するため、これらを単一の入力空間に統合します。 これにより、元データに対する統合分析ウィンドウのサイズが決まります。 この計算によって、境界条件によってウィンドウサイズがゼロになることも防ぎます。

次に、更新したパラメータを渡して親クラスの同名メソッドに処理を委譲し、このメソッドから戻ります。

このレイヤーの順伝播および逆伝播メソッドには、独自の計算ロジックは実装されていません。これらのメソッドは、前述のOpenCLカーネルを呼び出すための処理を担います。 具体的には、引数の準備、パラメータの受け渡し、対応するカーネルの実行キューへの登録をおこないます。

この場合の処理手順は標準的です。マスクを含むデータバッファへのポインタやその他のパラメータを渡し、必要なワーク次元を指定してOpenCL関数を呼び出します。 この手順については、すでによくご存じだと思います。 そのため、ここでは各メソッドを詳しく分析する必要はないでしょう。各メソッドの詳細については、各自で確認することをお勧めします。 CNeuronMaskMultiWinConvクラスとそのすべてのメソッドの完全なソースコードは、添付ファイルに含まれています。



スパース混合エキスパート

次の重要な段階は、スパース混合エキスパートのモジュールを構築することです。 本実装では、そのアーキテクチャを CNeuronTimeMoESparseExpertsクラスとして定義し、基底クラスCNeuronBaseOCLを継承しています。 このオブジェクトには、専門エキスパートと共有エキスパートの動作を起動し、連携させるために必要な主要コンポーネントがすべて含まれています。

class CNeuronTimeMoESparseExperts   :  public CNeuronBaseOCL
  {
protected:
   CNeuronSwiGLUOCL        cExpertsIn;
   CNeuronSwiGLUOCL        cSharedIn;
   CNeuronMaskMultiWinConv cExpertsOut;
   CNeuronConvOCL          cSharedOut;
   CNeuronTopKGates        cMasks;
   CNeuronConvOCL          cSharedGates;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL)          override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)   override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)   override;

public:
                     CNeuronTimeMoESparseExperts(void) {};
                    ~CNeuronTimeMoESparseExperts(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                          uint window_out, uint units_count, uint variables, uint experts,
                          uint topK, ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual int       Type(void) override const {   return defNeuronTimeMoESparseExperts; }
   //--- methods for working with files
   virtual bool      Save(int const file_handle)   override;
   virtual bool      Load(int const file_handle)   override;
   //---
   virtual void      SetOpenCL(COpenCLMy *obj)     override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      TrainMode(bool flag) override;
  };

CNeuronTimeMoESparseExpertsクラスの構造には、いくつかの内部オブジェクトが含まれており、それぞれがスパース混合エキスパートの仕組みにおいて、明確に定められた役割を担っています。 これらのメソッドの動作アルゴリズムを構築していく中で、各オブジェクトの内部ロジックについても順を追って確認していきます。 この段階で重要なのは、次の設計上の方針です。すべての内部オブジェクトは、動的なメモリ割り当てを行わず、静的に宣言されています。 このアプローチにより、リソース管理が簡素化されるだけでなく、クラスのコンストラクタとデストラクタを空のままにできます。 初期化処理はすべてInitメソッドに実装されています。

bool CNeuronTimeMoESparseExperts::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window,
                                       uint window_out, uint units_count, uint variables, uint experts,
                                       uint topK, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables,
                                                                 optimization_type, batch))
      return false;
   SetActivationFunction(None);

メソッドのパラメータには、作成するオブジェクトのアーキテクチャを明確に定義するための重要な定数が一式含まれています。 ここでは、入力トークンと出力トークンのサイズ、エキスパートの総数とTop-Kマスクによって選択されるエキスパート数、使用する最適化手法、変数の数、および計算ブロックの数をすべて指定します。

これらのパラメータの一部は、親クラスの同名メソッドに渡されます。 このメソッドには、アーキテクチャ設定の基本的な検証や、OpenCLコンテキストを含むグローバルインターフェースの初期化がすでに実装されています。 このように、各階層がそれぞれの責任範囲を担う明確な構造を構築し、アーキテクチャのすべての要素を連携させて、統一された一貫性のあるシステムを形成します。

親クラスレベルでの初期化が正常に完了したら、次に内部コンポーネントの設定へと進みます。ここから、混合エキスパートそのものの構造が形作られていきます。

この処理チェーンの最初のオブジェクトは、SwiGLU機能を実装するcExpertsIn です。 これは入力データ処理の最初の段階を担い、有用な信号を強調するとともに、各エキスパートに渡す埋め込み表現を生成する、いわば前処理フィルタとして機能します。 この層のフィルタ数はエキスパート数に比例して増加するため、各エキスパートはタスクを十分に表現できる、豊かな表現を得ることができます。

int index = 0;
if(!cExpertsIn.Init(0, index, OpenCL, window, window, window_out * experts, units_count,
                                                       variables, optimization, iBatch))
   return false;
index++;
if(!cSharedIn.Init(0, index, OpenCL, window, window, window_out, units_count, variables,
                                                                  optimization, iBatch))
   return false;

同様に、共有エキスパートの最初の処理層を担うcSharedInコンポーネントを初期化します。 専門エキスパートとは異なり、ここではフィルタの次元数を拡張する必要はありません。むしろ、単一のエキスパートと同等の次元数に固定します。 これは、共有エキスパートがタスク空間全体をカバーし、他のエキスパートと競合するのではなく、意思決定の基準となる汎用的な視点を提供する役割を担うためです。

この役割にSwiGLUを採用することで、非線形な選択性がもたらされ、特定のエキスパートに関連する可能性のあるトークンを、そうでないトークンからより明確に区別できるようになります。

次に、専門エキスパートと共有エキスパートのそれぞれについて、第2の処理層を構築します。 ここでも、アーキテクチャは先に確立した設計方針に厳密に従い、各ブロックがスパースな混合エキスパート機構全体の中で、それぞれ固有の役割を果たすように構成します。

専門エキスパートからなる混合エキスパートには、先ほど作成したCNeuronMaskMultiWinConvモジュールを使用します。このモジュールは、マスク付きウィンドウによる畳み込みを実装するものです。 各エキスパートに固有のマスクに基づいて入力特徴量をフィルタリングすることで、対象を絞った活性化を実現し、エキスパート間の役割分担を明確にします。

index++;
if(!cExpertsOut.Init(0, index, OpenCL, window_out, experts, window, units_count,
                                               variables, optimization, iBatch))
   return false;
cExpertsOut.SetActivationFunction(None);
index++;
if(!cSharedOut.Init(0, index, OpenCL, window_out, window_out, window, units_count,

                                                 variables, optimization, iBatch))
   return false;
cSharedOut.SetActivationFunction(None);

一方、共有エキスパートには、標準的な畳み込み層であるCNeuronConvOCLを使用します。

アクティブなエキスパートのマスクを生成するオブジェクトには、DUETフレームワークでも使用したCNeuronTopKGatesモジュールを採用します。 このコンポーネントは、スパース化の仕組みにおいて重要な役割を果たします。入力トークンを分析し、関連性の高いエキスパートを、あらかじめ定められた数だけ選択します。

つまり、CNeuronTopKGatesは、Top-Kの原理に基づいて活性化マスクを生成し、関連性の低いチャネルの値を厳密にゼロにします。 この手法により、モデルの高い表現力を維持しながら、計算負荷を大幅に軽減できます。 アクティブにするエキスパートの数(topK)はパラメータとして指定され、対象タスクの特性に応じて容易に調整できます。

index++;
if(!cMasks.Init(0, index, OpenCL, window, units_count, experts, topK, optimization, iBatch))
   return false;

CNeuronTopKGatesオブジェクトが単なるバイナリマスクを返すだけではない点は重要です。 このオブジェクトは、選択されたチャネルに対して正規化された確率分布も生成します。 これにより、最終出力に対する各エキスパートの寄与度を柔軟に調整できます。 アクティブなエキスパートは、それぞれ異なる確信度に応じて計算に寄与し、その確率は最終的なマスクの重みに明示的に反映されます。

このアプローチにより、アーキテクチャのコンパクトさと計算効率が向上するだけでなく、過学習に対する頑健性も高まります。 各エキスパートは無作為に選ばれるのではなく、その専門性が必要とされる場合にのみ使用されます。これは、ノイズを含む時系列データやマルチモーダルな時系列データを扱ううえで、特に重要です。

このオブジェクトの構成を締めくくるのが、共有エキスパート用のゲートモジュール cSharedGatesです。 このモジュールは、モデルの最終出力に対して共有エキスパートがどの程度寄与するかを決定します。 そのために、時空間パターンを抽出するように構成された標準的な畳み込み層を使用します。 個々のエキスパートに適用するマスクとは異なり、ここではシグモイド活性化関数を用いて、0から1までの連続的な値を生成します。

   index++;
   if(!cSharedGates.Init(0, index, OpenCL, window, window, window, units_count, variables,
                                                                    optimization, iBatch))
      return false;
   cSharedGates.SetActivationFunction(SIGMOID);
//---
   return true;
  }

このアプローチでは、単純な二値のオン/オフ判定をおこなうのではなく、その時点のコンテキストに応じて、共有エキスパートの寄与度を柔軟に調整できます。 シグモイド関数は、いわば柔軟な減衰器として機能します。全般的なパターンに対する確信度が高い場合、出力値は1に近づき、信号が弱い場合には、対応するマスクによって出力が抑制されます。 これにより、アーキテクチャにおける局所的な特徴を捉える構成要素と、汎化を担う構成要素との相互作用を、きめ細かく制御できます。

すべての反復処理が正常に完了したら、処理結果を表す真偽値を呼び出し元のプログラムに返し、メソッドを終了します。

続いて、次の重要な段階として、feedForwardメソッドにおける順伝播の実装に移ります。 ここで、スパース混合エキスパートモジュールの動作原理が全体として明らかになります。

bool CNeuronTimeMoESparseExperts::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cExpertsIn.FeedForward(NeuronOCL))
      return false;

まず、cExpertsIn層で順伝播を実行します。この層では、入力信号を専門エキスパートの数に応じて拡張し、複数の専門的な処理経路に対応する表現空間へと変換します。 これと並行して、cSharedIn層も実行します。この層は、より汎用的な共有エキスパートの第1処理層として機能します。

if(!cSharedIn.FeedForward(NeuronOCL))
   return false;

次に、2つのルーティング経路が有効化されます。 cSharedGates層では、共有エキスパートの寄与度を決定するマスクを計算します。ここでは、シグモイド活性化関数を用いて、段階的な重要度マスクを生成します。 一方、cMasksオブジェクトは、最も関連性の高いエキスパートを選択し、離散的なTop-Kマスクを生成する専用モジュールです。 このモジュールは、非アクティブなチャネルを除外するだけでなく、選択された処理経路間で正規化された重み分布も返します。

if(!cSharedGates.FeedForward(NeuronOCL))
   return false;
if(!cMasks.FeedForward(NeuronOCL))
   return false;

この段階で、出力の生成が始まります。 cExpertsOutモジュールを使用して、マスクが適用されたエキスパートブロックに対して畳み込み演算を実行します。各アクティブフィルタには固有の重みが割り当てられており、すべての処理は事前に生成されたマスクに基づいておこなわれます。

if(!cExpertsOut.FeedForward(cExpertsIn.AsObject(), cMasks.getOutput()))
   return false;

共有エキスパートについては、cSharedOutを使用して標準的な畳み込み演算を実行します。その後、その出力を、あらかじめcSharedGatesで計算しておいた重要度マスクに応じてスケーリングします。 このスケーリングは、要素ごとの乗算によっておこなわれます。

if(!cSharedOut.FeedForward(cSharedIn.AsObject()))
   return false;
if(!ElementMult(cSharedOut.getOutput(), cSharedGates.getOutput(), cSharedOut.getPrevOutput()))
   return false;

スパース混合エキスパートの出力と、重要度マスクによってスケーリングされた共有エキスパートの出力が得られたら、これら2つの情報経路を統合します。 この段階では、両者の出力を加算し、その結果を中間データバッファに格納します。

   const int window = (int)cSharedGates.GetWindow();
   if(!SumAndNormilize(cExpertsOut.getOutput(), cSharedOut.getPrevOutput(), PrevOutput,
                       window, false, 0, 0, 0, 1))
      return false;
   if(!SumAndNormilize(NeuronOCL.getOutput(), PrevOutput, Output,
                       window, true, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

しかし、これは順伝播の最終段階の前半にすぎません。 続いて、ニューラルネットワークの前層から引き継いだ出力、すなわち残差接続による値を、ここまでに得られた値に加算します。 この手法により、入力信号に含まれる重要な情報を保持するとともに、勾配を安定させ、モデルの収束性を向上させます。

すべての構成要素を統合した後、各トークンについて、分析ウィンドウに沿って正規化を行います。 これにより、データのスケールが統一され、出力テンソルを適切に解釈できるようになります。 正規化後の結果は、グローバルな結果インターフェースのバッファに格納されます。

ご覧のとおり、このモジュールの順伝播アルゴリズムは、非常に多くの分岐を持つ構造になっています。 入力データは5つの異なる情報経路に同時に供給されるため、モデルの柔軟性と適応性が大幅に向上します。 しかし、このようなマルチチャネル構造は、誤差逆伝播の段階で特有の課題をもたらします。

calcInputGradientsメソッドでは、最終出力への各コンポーネントの寄与に応じて、誤差勾配をすべての内部コンポーネントへと慎重に配分します。 これは、オーケストラの各楽器が全体と見事に調和するよう、巧みに指揮を執ることにも似ています。

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

まず、各情報経路に誤差勾配を伝播させる処理を実行します。

  • スパース混合エキスパート層の出力(cExpertsOut):勾配に活性化関数の導関数を適用し、誤差勾配を伝播させます。
  • 共有エキスパートとゲート(cSharedOut、cSharedGates): 出力値とマスクの相互作用を考慮し、活性化関数の導関数を適用した勾配の積を計算します。
if(!DeActivation(cExpertsOut.getOutput(), cExpertsOut.getGradient(), Gradient,
                                                    cExpertsOut.Activation()))
   return false;
if(!ElementMultGrad(cSharedOut.getOutput(), cSharedOut.getGradient(),
                    cSharedGates.getOutput(), cSharedGates.getGradient(),
                    Gradient, cSharedOut.Activation(), cSharedGates.Activation()))
   return false;

続いて、各内部オブジェクトの隠れ層の勾配を計算するため、再帰的にメソッドを呼び出します。これにより、最初のエキスパート層(cExpertsIn、cSharedIn)から生の入力データに至るまで、各コンポーネントの寄与を段階的にたどりながら、勾配を漏れなく正確に計算できます。

   if(!cExpertsIn.calcHiddenGradients(cExpertsOut.AsObject(), cMasks.getOutput(),
                                      cMasks.getGradient(), (ENUM_ACTIVATION)cMasks.Activation()))
      return false;
   if(!cSharedIn.calcHiddenGradients(cSharedOut.AsObject()))
      return false;
//---
   if(!NeuronOCL.calcHiddenGradients(cExpertsIn.AsObject()))
      return false;

特に注意が必要なのは、中間バッファにおける勾配の累積処理です。 ここでは、ウィンドウ内の各トークンにわたって勾配を合算し、処理に関与する各コンポーネントからの信号が適切に整合するようにします。

   if(!DeActivation(NeuronOCL.getOutput(), PrevOutput, Gradient, NeuronOCL.Activation()))
      return false;
   const int window = (int)cSharedGates.GetWindow();
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cSharedIn.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cMasks.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1))
      return false;
   if(!NeuronOCL.calcHiddenGradients(cSharedGates.AsObject()))
      return false;
   if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), NeuronOCL.getGradient(), window, false,
                                                                                       0, 0, 0, 1))
      return false;
//---
   return true;
  }

最終的に、calcInputGradientsメソッドは、複雑な情報フローを適切に調整し、スパース混合エキスパートモジュール全体の効率的な学習を実現します。これにより、このアーキテクチャは高い性能を発揮するだけでなく、誤差や過学習に対しても頑健なものとなります。

モデルパラメータの更新には、従来の手法を採用し、具体的な処理を各内部コンポーネントに委ねています。 各コンポーネントは、計算された勾配に基づいて、それぞれ独自の方法で重みを更新します。 updateInputWeightsメソッドの本体では、対応する処理を順番に呼び出すだけです。 そのため、説明の重複を避けるためにも、この部分については読者自身で確認することをお勧めします。 すべてのメソッドの実装を含む CNeuronTimeMoESparseExpertsクラスの完全なソースコードは、添付ファイルに含まれています。



結論

本記事では、OpenCL環境での時系列処理に適した、マスク付き畳み込みモジュールの実装とスパース混合エキスパートブロックのアーキテクチャについて詳しく解説しました。 また、デバイス側の演算設定や、メインプログラムとの連携方法についても、重要なポイントを取り上げました。 特に、分岐する情報フローにおいて、勾配を適切に分配する方法に重点を置きました。

次回は、引き続きアーキテクチャの開発を進め、より複雑なニューラルネットワークシステムの一部として、この構造を活用するモデルの構築と学習に焦点を当てます。


参考文献


本記事で使用したプログラム

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


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

添付されたファイル |
MQL5.zip (2856.61 KB)
取引におけるニューラルネットワーク:適応的周期セグメンテーション(LightGTS) 取引におけるニューラルネットワーク:適応的周期セグメンテーション(LightGTS)
時系列の内部周期性を考慮しながら、データを柔軟に分割する「適応的パッチ化」という革新的な手法をご紹介します。 さらに、異なるスケールのデータを扱う際にも、重要な意味的特性を保持できる効率的なエンコーディング手法について解説します。 これらの手法により、金融市場に特有の複雑なマルチスケールデータをより正確に処理できるようになり、予測の安定性と信頼性の大幅な向上が期待できます。
初級から中級まで:サンドボックスの操作 初級から中級まで:サンドボックスの操作
MetaTrader 5をベースプラットフォームとして、サンドボックスの内容を調べ、さらにはその中で操作をおこなうための2つの方法について解説します。 本記事の内容を理解することは、今後の記事で取り上げる内容を理解するうえで不可欠です。
エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法 エラー 146 (「トレードコンテキスト ビジー」) と、その対処方法
この記事では、MT4において複数のEAの衝突をさける方法を扱います。ターミナルの操作、MQL4の基本的な使い方がわかる人にとって、役に立つでしょう。
初級から中級まで:ランダムアクセス(II) 初級から中級まで:ランダムアクセス(II)
2つのわずかに異なるアプローチが、パフォーマンスやディスクI/Oの設計を含む実装全体の方針に、どのような大きな違いをもたらすのかを考察します。また、アプリケーション間の互換性の問題を防ぐうえで、これらのアプローチがどのように役立つのかについても解説します。