
ニューラルネットワークが簡単に(第37回):スパースアテンション(Sparse Attention)
はじめに
前回は、アテンションメカニズムをアーキテクチャーに用いたリレーショナルモデルについて説明しました。このモデルを使ってエキスパートアドバイザー(EA)を作成し、出来上がったEAは良い結果を示しました。しかし、モデルの学習率が以前の実験に比べて低くなっていることに気づきました。これは、モデルで使用されているトランスフォーマーブロックが、多数の演算を実行するかなり複雑なアーキテクチャーソリューションであるためです。これらの演算数は、解析シーケンスのサイズが大きくなるにつれて2次関数的に増加し、メモリ消費量とモデル訓練時間の増加につながります。
しかし、モデルを改善するために利用できるリソースには限りがあることは認識しています。そのため、品質の低下を最小限に抑えながらモデルを最適化する必要があります。
1.スパースアテンション(Sparse Attention)
モデルの性能の最適化について語るときはまず、そのハイパーパラメータに注目する必要があります。このようなパラメータのセットは、リソースの消費とモデルの品質を考慮して最適なものでなければなりません。ある閾値を超えて層のニューロン数を増やしても、実質的にはモデルの質の向上にはつながりません。ニューラル層の数についても同じことが言えます。しかし、最適なハイパーパラメータのセットは、特定のタスクとその複雑さに依存します。
これはすべて、マルチヘッドセルフアテンションブロックのアテンションヘッドの数に当てはまります。良い結果を得るためには2ヘッドで十分な場合もありますが、これはすべての問題に対して最適な値ではありません。すべてのハイパーパラメータは、特定のタスクとモデルアーキテクチャごとに実験的に選択されなければなりません。
この記事では、セルフアテンションブロックの操作回数を減らすためのアーキテクチャアプローチについて説明します。しかし、アルゴリズムの最適化に進む前に、セルフアテンションブロックがどのように機能するかを覚えておくことが重要です。
まず、シーケンスの各要素に対するQuery、Key、Valueの3つのエンティティを計算します。この目的のために、シーケンス要素を記述するベクトルは、対応する重み行列と乗算されます。次に、Query行列に転置されたKey行列を乗算し、シーケンスの要素間の依存係数を求めます。その後、これらの係数は、SoftMax関数を使って正規化されます。
依存関係係数を正規化した後、Valueエンティティの行列を乗算して、シーケンスの各要素の出力値を取得します。これらの出力値は、問題の文脈における各要素の重要性を考慮した要素値の重み付き合計です。
配列要素数が増加すると、アテンションメカニズムを使用するアルゴリズムにおける演算がより複雑になります。これは、各段階で、エンティティの計算、行列の乗算、依存係数の正規化といった操作が、シーケンスの各要素に対しておこなわれるためです。
シーケンスの要素が多すぎると、計算時間と計算資源のコストが大幅に増加します。アルゴリズムを最適化し、各ステージでの計算回数を減らすためにはさまざまな手法を用いることができますが、その1つがスパースアテンションです。この手法は、2019年4月に発表された論文「Generating Long Sequences with Sparse Transformers」でRewon Child氏によって提案されました。
スパースアテンションは、シーケンスの要素を処理するのに必要な計算量を減らすためにアテンションメカニズムを最適化する技術です。
この手法のアイデアは、シーケンス間のアテンション係数を計算する際に、シーケンスの最も重要な要素のみを考慮に入れることです。従って、シーケンス内のすべての要素のペアについてアテンション係数を計算する代わりに、最も重要なペアだけを選択します。
スパースアテンション法の利点の1つは、シーケンスの要素を処理するのに必要な計算回数を大幅に削減できることです。これは、計算回数が非常に多くなるような大きなシーケンスを処理する場合に特に重要です。
さらに、スパースアテンションは、アテンションメカニズムがシーケンスの全要素に均等にアテンションを分散させ、リソースの非効率的な使用とアルゴリズムの速度を低下させる「すべてへのアテンション」問題に対抗するのに役立ちます。
スパースアテンションを実装する際には、さまざまなアプローチを用いることができます。1つは、シーケンスをブロックに分割し、各ブロック内の要素間および異なるブロックの要素間でのみアテンションを計算する方法です。この場合、計算回数を減らすために、距離の近い要素だけを考慮することができます。
もう1つのアプローチは、類似性に基づいてシーケンス内の最も重要な要素を選択することです。これは、異なるクラスタリング手法を使用することによっておこなうことができます。
3つ目のアプローチは、ヒューリスティックやアルゴリズムを使って、頻度、重要性、文脈などに基づいて、シーケンス中の最も重要な要素を選択することです。
著者は、スパースアテンションが効果的に機能するためには、シーケンス要素をブロックに分配するアルゴリズムを使用する必要があり、これにより各アテンションヘッドに異なるブロック構造が提供されると指摘しています。このアプローチにより、シーケンスの各要素の影響をより完全に判断し、アルゴリズムの効率を向上させることができます。
スパースアテンションは、機械翻訳、テキスト生成、感情分析など、機械学習や自然言語処理のさまざまな分野に応用できます。上記の論文では、この手法の著者が、テキスト、画像、音声録音に対するアルゴリズムの適用結果を紹介しています。
さらに、スパースアテンションは、シーケンスを処理する際に、より正確な結果を達成するために、他のアテンションエンジン最適化技術と効果的に組み合わせることができます。
その有効性にもかかわらず、スパースアテンション法には欠点もあります。その1つは、シーケンス内の最も重要な要素の選択が不正確で、情報の損失につながる可能性があることです。そのため、特定のタスクごとに適切な手法を選択し、アルゴリズムのパラメータを慎重に調整する必要があります。
私は、スパースアテンション法が金融市場分析に関連する問題の解決に役立つと考えています。金融銘柄の相場の履歴を分析する場合、多くの場合、かなり深くデータを分析する必要がありますが、この履歴の個々の要素だけが現在の状況に影響を与えることも多くなります。スパースアテンション法を使うことで、調査するデータの重要なブロックを選択するための計算リソースの量を減らすことができます。また、この手法は、さらなる操作から重要でない要素を排除するのに役立ち、金融市場分析の効率を高めます。
ただし、金融市場の相場は可変的な構造を持っているため、分析順序の要素のブロックを固定して作業することはできません。モデルの学習プロセスを高速化するには、シーケンス全体から最も重要な要素の 20% のみを取り出す「80/20」パレートの法則のヒューリスティックを使用できます。各要素の重要性は、先に述べた最初の2つの式で計算される要素間の依存係数に基づいて決定されます。最初の反復の後、データの正規化の前に、すでに、シーケンスの最も重要な要素を正確に識別し、その後の操作から残りの要素を除外することが可能です。これにより、正規化の段階とセルフアテンションブロックの結果を決定する段階での操作数を減らすことができます。
各アテンションヘッドは独自の行列を使ってQueryとKeyを決定するので、選択される要素は各アテンションヘッドで異なる可能性が高くなります。
アルゴリズムを最適化するための主な方向性が決まったので、次はMQL5言語を使った実装に移りましょう。
2.MQL5を使用した実装
提案する手法を実装するために、新しいニューラル層クラスCNeuronMLMHSparseAttentionを作成します。もちろん、すべてのクラスメソッドを新たに作り直すわけではありません。代わりに、既存のCNeuronMLMHAttentionOCLクラスを継承します。そしてここで、提案された最適化を実装するために、どのクラスメソッドとOpenCLプログラムカーネルを変更する必要があるかを分析しましょう。
先に述べたように、アルゴリズムへの最初の変更は、依存係数を決定するブロックに関するものです。これらの値は、MHAttentionScoreカーネルのダイレクトパスで得られます。実装では、指定されたカーネルをMHSparseAttentionScoreに置き換えます。
親クラスのカーネルパラメータでは、2つのデータバッファへのポインタを渡しています。ソースデータとして、Query、Key、Valueエンティティのテンソルを連結したものと、依存係数の形式で演算結果を書き込むためのバッファです。データバッファに加えて、内部エンティティの次元もカーネルに渡されました。次に、スパース係数「sparse」を追加します。この値には0から1の範囲の値を渡します。この値は、選択された配列要素のうち、解析された要素に最大の影響を与える要素の割合を示します。
__kernel void MHSparseAttentionScore(__global float *qkv, ///<[in] Matrix of Querys, Keys, Values __global float *score, ///<[out] Matrix of Scores int dimension, ///< Dimension of Key float sparse ///< less than 1.0 coefficient of sparse ) { int q = get_global_id(0); int h = get_global_id(1); int units = get_global_size(0); int heads = get_global_size(1); //---
新しいカーネルは、親クラスのカーネルと同様に、2 次元のタスク空間で動作します。最初の次元は分析される配列要素の序数を示し、2番目の次元は使用されるアテンションヘッドに対応します。カーネル本体では、実行中のスレッドのグローバル識別子を直ちにローカル変数に保存します。
次に、必要なローカル変数を宣言し、分析対象の要素に対するデータバッファのオフセットを決定する、ちょっとした準備作業をおこないます。
int shift_q = dimension * (h + 3 * q * heads); int shift_s = units * (h + q * heads); int active_units = (int)max((float)(units * sparse), min((float)units, 3.0f)); //--- float koef = sqrt((float)dimension); if(koef < 1) koef = 1; float sum = 0.0f; float min_s = 0.0f; float max_s = 0.0f;
また、選択された要素の絶対値も決定します。選択する重要な配列要素の数を決定する際、制限を設けていることに注意してください。これは、小さなシーケンスを使用する際に、アテンションブロックの不必要な無効化を避けるのに役立ちます。最大依存係数は、ほとんどの場合、分析された要素によって、その要素自身のKeyのために生成されることがわかっています。
次に、分析された要素のQueryベクトルとKey行列を掛け合わせるループを実装します。ループ本体では、結果のベクトルの最大値と最小値も決定します。
for(int k = 0; k < units; k++) { float result = 0; int shift_k = dimension * (h + heads * (3 * k + 1)); for(int i = 0; i < dimension; i++) { if((dimension - i) > 4) { result += dot((float4)(qkv[shift_q + i], qkv[shift_q + i + 1], qkv[shift_q + i + 2], qkv[shift_q + i + 3]), (float4)(qkv[shift_k + i], qkv[shift_k + i + 1], qkv[shift_k + i + 2], qkv[shift_k + i + 3])); i += 3; } else result += (qkv[shift_q + i] * qkv[shift_k + i]); } score[shift_s + k] = result; if(k == 0) min_s = max_s = result; else { max_s = max(max_s, result); min_s = min(min_s, result); } }
得られた値とシーケンスの対応する要素との間の依存関係を保持するために、最も重要な要素を選択するためにベクトルをソートしません。その代わりに、依存係数の有意範囲の下限を、配列の「重要な」要素の必要数が得られるまで、繰り返し増やしていきます。この機能は以下のループで実装されます。
int count = units; float temp = max_s; while(count > active_units) { count = 0; for(int k = 0; k < units; k++) { float value = score[shift_s + k]; if(value < min_s) continue; count++; if(value < temp && value > min_s) temp = value; } if(count > active_units) min_s = temp; }
有意範囲を決定した後、次のステップであるデータの正規化に移ります。これは2つのステップからなります。最初のステップでは、前のステップで求めた依存度の指数値を計算します。次に、これらの値を合計で割ります。ただし、私たちが定義した重要性の範囲については覚えておく必要があります。そこで、この範囲外の要素の依存係数をゼロに設定し、以降の演算から除外します。これは指数計算と正規化ステップの両方に適用されます。
if(max_s == 0.0f) max_s = 1.0f; for(int k = 0; k < units; k++) { float value = score[shift_s + k]; if(value < min_s) { score[shift_s + k] = 0.0f; continue; } value = exp(value / max_s / koef); score[shift_s + k] = value; sum += value; } for(int k = 0; (k < units && sum > 1); k++) { temp = score[shift_s + k]; if(temp == 0.0f) continue; score[shift_s + k] = temp / sum; } }
指定されたカーネルの操作の結果、解析された数列の選択された要素について、少数の非ゼロ依存係数のみが得られました。また、依存係数がゼロのシーケンスの要素は、それ以降のフォワードパスやバックワードパスから除外します。
次のステップは、アテンションブロックの出力を得ることです。これをおこなうには、Self-Attentionアルゴリズムに従って、正規化された依存係数のScore行列に、エンティティのValue行列を乗算する必要があります。この操作はMHSparseAttentionOutカーネルに実装されています。このカーネルでは、実行される演算の数を減らすために、依存係数がゼロかどうかも確認します。
3つのデータバッファへのポインタは、カーネルパラメータで渡されます。Query、Key、Valueエンティティのテンソルを、依存係数のScore行列とともに連結したものが、実行される演算のソースデータとなります。操作の結果はOutバッファに書き込まれます。シーケンスの1要素のKeyベクトルの次元もパラメータとして渡されます。すでに見たように、multi-headed attentionクラスの内部エンティティQuery、Key、Valueには同じ次元のベクトルを使用します。
__kernel void MHSparseAttentionOut(__global float *scores, ///<[in] Matrix of Scores __global float *qkv, ///<[in] Matrix of Values __global float *out, ///<[out] Output tensor int dimension ///< Dimension of Value ) { int u = get_global_id(0); int units = get_global_size(0); int h = get_global_id(1); int heads = get_global_size(1);
このカーネルは、前のカーネルと同様、2次元のタスク空間で呼び出され、シーケンス要素やアテンションヘッドに従って、別々の操作の流れに分けられます。カーネルの最初に、スレッド識別子をローカル変数に保存します。
次に、データバッファのオフセットを定義します。
int shift_s = units * (h + heads * u); int shift_out = dimension * (h + heads * u);
その後、依存係数のベクトルにValue行列を乗算するために、入れ子になったループのシステムを作成します。ここで、冗長な操作を排除するために、依存係数ゼロのチェックを挿入します。
for(int d = 0; d < dimension; d++) { float result = 0; for(int v = 0; v < units; v ++) { float cur_score = scores[shift_s + v]; if(cur_score == 0) continue; int shift_v = dimension * (h + heads * (3 * v + 2)) + d; result += cur_score * qkv[shift_v]; } out[shift_out + d] = result; } }
これで、新しいクラスのフォワードパスカーネルを使った作業は終了です。では、バックワードパス部分の変更範囲を見てみましょう。
セルフアテンションブロックのフィードバックワードパスは、MHAttentionInsideGradientsカーネルに実装されました。このアルゴリズムにより、既存のカーネルの複製を作成することなく、それに沿って必要な制御点を追加することができます。私は、構築されたアルゴリズムとそれに加えられたコントロールポイントを見ることを提案します。
カーネルパラメータには、5つのデータバッファへのポインタを渡します。
- Query、Key、Valueのエンティティのテンソルを連結したもの(qkv)
- Query、Key、Valueエンティティのエラー勾配を書き込むための連結テンソル(qkv_g)
- 依存係数の行列(scores)
- 依存係数行列レベルの誤差勾配を書き込むための行列(scores_g)
- 現在のアテンションヘッドブロックの出力レベルでの誤差勾配のテンソル。
__kernel void MHAttentionInsideGradients(__global float *qkv, __global float *qkv_g, __global float *scores, __global float *scores_g, __global float *gradient, int dimension) { int u = get_global_id(0); int h = get_global_id(1); int units = get_global_size(0); int heads = get_global_size(1); float koef = sqrt((float)dimension); if(koef < 1) koef = 1;
この誤差勾配分布のカーネルを、先に考察したものと同様に、2次元の問題空間における誤差勾配分布カーネルと呼ぶことにします。1つ目の次元は、分析対象の配列要素を特定します。2つ目の次元は、現在のアテンションヘッドを示します。必要な要素へのデータバッファのオフセットを決定するのに役立つのは、これらの識別子です。したがって、カーネルの最初に、これらのスレッド識別子をローカル変数に保存します。
さらに、カーネルアルゴリズムは条件付きで2つのブロックに分けられます。まず、依存係数行列のレベルで誤差勾配を定義します。ここでは、シーケンスの解析された要素の依存係数ベクトルの勾配を収集するループを実装します。依存係数がゼロのシーケンスの未使用要素は最終結果に影響を与えないので、それらの誤差勾配はゼロになるはずです。したがって、ループ本体では、まず現在の依存係数を確認します。ヌル値が検出された場合は、単に次の要素に移ります。
すべてのデータバッファの要素を格納するグローバルメモリへのアクセスは、比較的高価な操作であることに注意することが重要です。ここでの場合、シーケンス係数行列レベルの誤差勾配のベクトルは一時的な記憶であり、他のカーネルでは使用されません。ヌル値を書き込むことは、あまり意味のない不要な操作だからです。
//--- Calculating score's gradients uint shift_s = units * (h + u * heads); for(int v = 0; v < units; v++) { float s = scores[shift_s + v]; if(s <= 0) continue; float sg = 0; int shift_v = dimension * (h + heads * (3 * v + 2)); int shift_g = dimension * (h + heads * v); for(int d = 0; d < dimension; d++) sg += qkv[shift_v + d] * gradient[shift_g + d]; scores_g[shift_s + v] = sg * (s < 1 ? s * (1 - s) : 1) / koef; } barrier(CLK_GLOBAL_MEM_FENCE);
次のステップでは、エラー勾配を内部のQuery、Key、Valueエンティティに分配します。まずデータバッファのオフセットを決定し、次にエラー勾配を収集するためのループシステムを作成します。
ここでは、入れ子になったループの中で従属係数を確認し、もしヌル値が見つかれば、単純に次の要素に移ります。これにより、無駄な操作を省くことができます。
//--- Calculating gradients for Query, Key and Value uint shift_qg = dimension * (h + 3 * u * heads); uint shift_kg = dimension * (h + (3 * u + 1) * heads); uint shift_vg = dimension * (h + (3 * u + 2) * heads); for(int d = 0; d < dimension; d++) { float vg = 0; float qg = 0; float kg = 0; for(int l = 0; l < units; l++) { float sg = scores[shift_s + l]; if(sg <= 0) continue; uint shift_q = dimension * (h + 3 * l * heads) + d; uint shift_k = dimension * (h + (3 * l + 1) * heads) + d; uint shift_g = dimension * (h + heads * l) + d; //--- vg += gradient[shift_g] * sg; sg = scores_g[shift_s + l]; kg += sg * qkv[shift_q]; qg += sg * qkv[shift_k]; } qkv_g[shift_qg + d] = qg; qkv_g[shift_kg + d] = kg; qkv_g[shift_vg + d] = vg; } }
このカーネルのすべての反復が完了すると、Query、Key、Valueの各エンティティのレベルで誤差勾配が得られ、それが対応する重み行列と前のニューラル層に分配されます。
これでOpenCLプログラムのカーネルに関する作業は完了し、メインプログラムのコード作業に移ります。2つのカーネルを追加しました。したがって、メインプログラムにカーネルコールを追加する必要があります。まず、カーネルにアクセスするための定数を作成します。
ここでは、2つのカーネルと1つのパラメータ定数だけを扱うための定数を作成していることに注意してください。既存のカーネルを基に作成し、基本カーネルのパラメータ構造をほぼ完全に繰り返しました。したがって、カーネルの動作中は、既存の定数を使用することができます。スパースパラメータを示す定数のみを作成します。
#define def_k_MHSparseAttentionScore 44 ///< Index of the kernel of the multi-heads sparse attention neuron // to calculate score matrix (#MHSparseAttentionScore) #define def_k_mhas_sparse 3 ///< less than 1.0 coefficient of sparse //--- #define def_k_MHSparseAttentionOut 45 ///< Index of the kernel of the multi-heads sparse attention neuron // to calculate multi-heads out matrix (#MHSparseAttentionOut)
次に、OpenCLコンテキストでカーネルの生成を実装する必要があります。コンテキスト内のアクティブなカーネルの総数を46に増やし、カーネル作成メソッドを呼び出す必要があります。
opencl.SetKernelsCount(46); if(!opencl.KernelCreate(def_k_MHSparseAttentionScore, "MHSparseAttentionScore")) { PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__); return false; } if(!opencl.KernelCreate(def_k_MHSparseAttentionOut, "MHSparseAttentionOut")) { PrintFormat("Error of create kernell: %d line %d", GetLastError(), __LINE__); return false; }
CNetニューラルネットワークのディスパッチクラスの3つのメソッドで、OpenCLコンテキストでカーネルを作成するための上記の操作を繰り返す必要があることに注意してください。これはあまり便利ではないため、将来的には、これらの操作を別のメソッドに移すつもりです。
bool Create(CArrayObj *Description); bool Load(string file_name, float &error, float &undefine, float &forecast, datetime &time, bool common = true); ///< Load method. @param[in] file_name File name to save @param[out] error Average error ///< @param[out] undefine Undefined percent @param[out] Forecast percent ///< @param[out] time Last study time @param[in] common Common flag virtual bool Load(const int file_handle);
次のステップでは、すぐに新しいクラスのメソッドを作成します。新しいCNeuronMLMHSparseAttentionニューラルネットワーククラスの機能は、親クラスCNeuronMLMHAttentionOCLをほぼ繰り返しています。そのため、継承されたメソッドを使用するようにします。主な違いは、スパースアテンションの作り方に関連しています。この部分では、スパースレベルを格納する新しい内部変数m_dSparseを作成します。
不要なメソッドの書き換えで作業を複雑にしないために、クラスのコンストラクタとデストラクタは空にしておきました。新しいクラスには新しいオブジェクトを作らず、スパースパラメータを扱うためにオーバーロードされたスパースメソッドを作ります。メソッドをオーバーロードする機能によって、同じ名前のメソッドを異なる機能に使用することができます。パラメータに値を指定すると、パラメータの値をメソッドに渡します。パラメータを指定しないと、メソッドは以前に保存した値を返します。
class CNeuronMLMHSparseAttention : public CNeuronMLMHAttentionOCL { protected: float m_dSparse; //--- virtual bool AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true); ///< \brief Multi-heads attention scores method of calling kernel ::MHAttentionScore(). virtual bool AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out); ///< \brief Multi-heads attention out method of calling kernel ::MHAttentionOut(). public: CNeuronMLMHSparseAttention(void) : m_dSparse(0.3f) {}; ~CNeuronMLMHSparseAttention(void) {}; //--- void Sparse(float value) { m_dSparse = value;} float Sparse(void) { return m_dSparse; } virtual int Type(void) const { return defNeuronMLMHSparseAttentionOCL; } ///< Identificatory of class.@return Type of class //--- methods for working with files virtual bool Save(int const file_handle); ///< Save method @param[in] file_handle handle of file @return logical result of operation virtual bool Load(int const file_handle); ///< Load method @param[in] file_handle handle of file @return logical result of operation };
Typeオブジェクトの仮想識別メソッドをオーバーライドすることも忘れてはいけません。
publicメソッドに関しては、ファイルを扱うメソッド(SaveとLoad)もオーバーライドする必要があります。これらの手法のアルゴリズムは非常にシンプルです。これらのメソッドでは、まず親クラスの同名のメソッドを呼び出します。このメソッドでは、すべての制御点がすでに定義され、継承された変数やオブジェクトの保存と読み込みのアルゴリズムが実装されています。呼び出されたメソッドを実行した論理的な結果を確認するだけでよくなります。親クラスのメソッドが正常に実行された後、実行中のメソッドの機能に応じて、スパースパラメータの値を保存するまたは読み込みます。
bool CNeuronMLMHSparseAttention::Save(const int file_handle) { if(!CNeuronMLMHAttentionOCL::Save(file_handle)) return false; if(FileWriteFloat(file_handle, m_dSparse) < sizeof(float)) return false; //--- return true; }
新クラスの操作に必要なpublicメソッドの検討はこれで終わりです。ただし、このクラスの主な機能は、ニューラル層のアルゴリズムを作成することであるので、フィードフォワードとバックプロパゲーションのパスに戻りましょう。この機能を実現するためにOpenCLプログラムカーネルを最新化しました。
ニューラルネットワークの機能を説明する際、手法の議論に使っていた通常の構成から少し外れることにします。今回はフォワードパスではなく、バックパスから始めます。バックプロパゲーションパスのための新しいカーネルは作成していません。親クラスで使用されていたカーネルを修正しただけです。親クラスの機能を継承することで、前述のMHAttentionInsideGradientsカーネルを呼び出すアルゴリズムも継承しています。つまり、親クラスのcalcInputGradientsバックワードパスメソッドを使うだけで、エラー勾配を伝搬させることができます。訓練済みパラメータの更新に関する機能については、特に変更はなく、親クラスのメソッドupdateInputWeightsを使用することもできます。
フィードフォワード方式に話を移しましょう。親クラスのフィードフォワードアルゴリズムを構築する際、分岐したアルゴリズム全体を1つのメソッド本体にまとめるのではなく、構造化されたディスパッチメソッドfeedForwardを作成し、その中で個々の機能を実行するためのメソッドをセルフアテンションアルゴリズムに従って順次呼び出しています。このアプローチのおかげで、フィードフォワード法を完全に書き直す必要がなくなり、2つの新しいカーネルを呼び出すメソッドを再定義するだけでよくなります。メソッドはAttentionScoreとAttentionOutです。
bool CNeuronMLMHAttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false; //--- for(uint i = 0; (i < iLayers && !IsStopped()); i++) { //--- Calculate Queries, Keys, Values CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4)); CBufferFloat *qkv = QKV_Tensors.At(i * 2); if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, qkv, iWindow, 3 * iWindowKey * iHeads, None)) return false; //--- Score calculation CBufferFloat *temp = S_Tensors.At(i * 2); if(IsStopped() || !AttentionScore(qkv, temp, true)) return false; //--- Multi-heads attention calculation CBufferFloat *out = AO_Tensors.At(i * 2); if(IsStopped() || !AttentionOut(qkv, temp, out)) return false; //--- Attention out calculation temp = FF_Tensors.At(i * 6); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9)), out, temp, iWindowKey * iHeads, iWindow, None)) return false; //--- Sum and normilize attention if(IsStopped() || !SumAndNormilize(temp, inputs, temp)) return false; //--- Feed Forward inputs = temp; temp = FF_Tensors.At(i * 6 + 1); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors.At(i * 6 + 2); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 2), temp, out, 4 * iWindow, iWindow, activation)) return false; //--- Sum and normilize out if(IsStopped() || !SumAndNormilize(out, inputs, out)) return false; } //--- return true; }
継承のルールを守るため、どちらのメソッドも親クラスのメソッドと同様のパラメータを受け取ります。メソッドのパラメータを変更すると、オーバーロードされたメソッドができてしまうからです。ただし、親クラスのメソッドをオーバーライドする必要があります。メソッドのオーバーロードでは、システムはメソッドが呼び出されたときに指定されたパラメータに従ってそのうちの1つを選択し、メソッドのオーバーライドでは、システムは継承階層に従って、最後にオーバーライドされたメソッドを使用します。したがって、メソッドをオーバーライドするように設定した場合のみ、継承されたfeedForwardメソッドから呼び出されたとき、システムはクラスのオーバーライドされたメソッドにアクセスします。
AttentionScoreメソッドのパラメータは、Query、Key、Valueエンティティのテンソルを連結したものと、依存係数の行列の2つのバッファのオブジェクトへのポインタを受け取ります。さらに、マスクフラグはメソッドのパラメータに渡されます。ここではこのフラグを使用しませんが、上記の理由から、パラメータに残してあります。
メソッド本体では、受け取ったポインタが適切かどうかを即座に確認します。また、OpenCLコンテキストで動作するオブジェクトの関連性も確認します。オブジェクトポインタ自体に加えて、OpenCLコンテキストで作成されたデータバッファの存在を確認します。指定されたすべての制御点を無事に通過して初めて、カーネルを実行キューに入れるプロセスの編成に進むことができます。
私たちが作成したカーネルはすべて、2次元の問題空間で使用するために計画されたものです。次に、global_work_sizeタスク空間とglobal_work_offsetタスク空間のオフセットを記述する配列を作成する必要があります。両方の配列のサイズは、問題空間と一致していなければなりません。2次元の問題空間を作成するために、それぞれ2つの要素からなる2つの配列を作成します。
最初の配列の要素には、分析されたシーケンスの要素の総数と、アテンションヘッドの数を示します。配列内の要素の位置は次元を表します。値はスレッド数を示します。このように、各アテンションヘッドに対するシーケンスの各要素は、操作を実行するための独自の独立したスレッドを受け取ることになります。一般的に、シーケンスのすべての要素に対する操作は、並列スレッドで(技術的に可能な限り)同時に実行されます。
タスク空間のオフセットは必要ないので、2番目の配列の要素をゼロ値で埋めます。
bool CNeuronMLMHSparseAttention::AttentionScore(CBufferFloat *qkv, CBufferFloat *scores, bool mask = true) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || CheckPointer(scores) == POINTER_INVALID) return false; //--- if(qkv.GetIndex() < 0) return false; if(scores.GetIndex() < 0) return false; //--- uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = iUnits; global_work_size[1] = iHeads; OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_qkv, qkv.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionScore, def_k_mhas_score, scores.GetIndex()); OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_dimension, (int)iWindowKey); OpenCL.SetArgument(def_k_MHSparseAttentionScore, def_k_mhas_sparse, (float)m_dSparse); if(!OpenCL.Execute(def_k_MHSparseAttentionScore, 2, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel %s: %s", __FUNCSIG__, error); return false; } //--- return true; }
次のステップは、パラメータをカーネルに渡すことです。これには、SetArgumentBufferメソッドとSetArgumentメソッドを使用します。最初のメソッドは、データバッファへのポインタを渡すのに使用され、もう1つは、離散値の伝送に使用されます。メソッドのパラメータでは、カーネル識別子、渡されるパラメータのシリアル番号(OpenCLプログラム内の0から始まるカーネルパラメータのシーケンスに対応)、および渡される値を示します。
ここで注意しなければならないのは、渡される値の型と、カーネルで指定されているパラメータの型です。タイプが一致しない場合、カーネル実行エラーが発生する可能性があります。
準備作業が終わったら、Executeメソッドを呼び出してカーネルを実行キューに送ります。メソッドのパラメータで、カーネル識別子、タスク空間の次元、以前に作成したタスク空間記述配列を示します。
また、カーネルキューイング方式の実行結果も確認します。カーネルのキューイング時にエラーが発生した場合、エラーに関する情報を要求し、端末ログに表示します。
カーネルが正常に実行キューに追加されたら、真の結果でメソッドを完了します。
AttentionOutメソッドで同様のアルゴリズムを繰り返し、2番目のカーネルを呼び出します。
bool CNeuronMLMHSparseAttention::AttentionOut(CBufferFloat *qkv, CBufferFloat *scores, CBufferFloat *out) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(qkv) == POINTER_INVALID || CheckPointer(scores) == POINTER_INVALID || CheckPointer(out) == POINTER_INVALID) return false; uint global_work_offset[2] = {0, 0}; uint global_work_size[2]; global_work_size[0] = iUnits; global_work_size[1] = iHeads; if(qkv.GetIndex() < 0) return false; if(scores.GetIndex() < 0) return false; if(out.GetIndex() < 0) return false; //--- OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_qkv, qkv.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_score, scores.GetIndex()); OpenCL.SetArgumentBuffer(def_k_MHSparseAttentionOut, def_k_mhao_out, out.GetIndex()); OpenCL.SetArgument(def_k_MHSparseAttentionOut, def_k_mhao_dimension, (int)iWindowKey); if(!OpenCL.Execute(def_k_MHSparseAttentionOut, 2, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel %s: %s", __FUNCSIG__, error); return false; } //--- return true; }
これで、新しいニューラルネットワーククラスの研究は終わりです。しかし、もう1点残っています。モデルの操作を実装するディスパッチメソッドに、新しいクラスの処理を追加する必要があります。
まず、CNet::Createメソッドに新しいタイプのニューラル層を作成するブロックを追加します。
case defNeuronMLMHSparseAttentionOCL: neuron_sparseattention = new CNeuronMLMHSparseAttention(); if(CheckPointer(neuron_sparseattention) == POINTER_INVALID) { delete temp; return false; } if(!neuron_sparseattention.Init(outputs, 0, opencl, desc.window, desc.window_out, desc.step, desc.count, desc.layers, desc.optimization, desc.batch)) { delete neuron_sparseattention; delete temp; return false; } neuron_sparseattention.SetActivationFunction(desc.activation); neuron_sparseattention.Sparse(desc.probability); if(!temp.Add(neuron_sparseattention)) { delete neuron_mlattention_ocl; delete temp; return false; } neuron_sparseattention = NULL; break;
CLayer::CreateElementメソッドに新しい層の型を追加します。
case defNeuronMLMHSparseAttentionOCL: if(CheckPointer(OpenCL) == POINTER_INVALID) return false; temp_mlat_ocl = new CNeuronMLMHSparseAttention(); if(CheckPointer(temp_mlat_ocl) == POINTER_INVALID) result = false; if(temp_mlat_ocl.Init(iOutputs, index, OpenCL, 1, 1, 1, 1, 0, ADAM, 1)) { m_data[index] = temp_mlat_ocl; return true; } break;
また、新しい型をニューラルネットワークの基底クラスのフィードフォワードディスパッチメソッドに追加します。
bool CNeuronBaseOCL::FeedForward(CObject *SourceObject) { if(CheckPointer(SourceObject) == POINTER_INVALID) return false; //--- CNeuronBaseOCL *temp = NULL; switch(SourceObject.Type()) { case defNeuronBaseOCL: case defNeuronProofOCL: case defNeuronConvOCL: case defNeuronAttentionOCL: case defNeuronMHAttentionOCL: case defNeuronMLMHAttentionOCL: case defNeuronMLMHSparseAttentionOCL: case defNeuronDropoutOCL: case defNeuronBatchNormOCL: case defNeuronVAEOCL: case defNeuronLSTMOCL: case defNeuronSoftMaxOCL: temp = SourceObject; return feedForward(temp); break; } //--- return false; }
関連するbackrpopagationメソッドCNeuronBaseOCL::calcHiddenGradients(CObject*TargetObject)で操作を繰り返します。
case defNeuronMLMHAttentionOCL: case defNeuronMLMHSparseAttentionOCL: mlat = TargetObject; if(!bTrain && !mlat.TrainMode()) return true; temp = GetPointer(this); return mlat.calcInputGradients(temp);
すべてのクラスとそのメソッドの完全なコードは、添付ファイルにあります。
3.テスト
新しいニューラル層クラスの作業が完了したら、MetaTrader 5プラットフォームの取引ストラテジーテスターで、構築したアルゴリズムのテストに進みます。取引ストラテジーテスターは、過去のデータを使用して取引EAや指標をテストすることができます。構築したアルゴリズムの動作をテストするために、履歴データを通過する過程でモデルを直接訓練する小さな取引EAを作成します。以前取り上げたアルゴリズムをテストする際に、すでに同様のEAを作成しています。今回は、前回のEAを基として使用します。このEAでは、EAのモデルアーキテクチャにあるmulti-headed attentionニューラル層を、新たに作成したスパースアテンション層に置き換えます。
前回の記事では、内発的好奇心ブロックを使って、完全にパラメータ化された分位関数アルゴリズムを使用した関係強化学習モデルをテストしました。このようなモデルを実現するために、(モデル、フォワードモデル、逆モデル)の3つのモデルの組み合わせが作成されました。最初のモデルではアテンションブロックを使用したので、このブロックを修正します。他の2モデルのアーキテクチャーに変更はありません。
モデルのアーキテクチャはCreateDescriptions関数で記述されます。モデルを単純化するため、再帰的LSTMブロックの使用を削除することにしました。これらの層は全結合層に取って代わられました。つまり、訓練モデルは以下のような構造になっています。
モデル入力では、分析履歴の各バーを記述する12個の要素と、現在の口座状態を記述する9個の要素を持つ初期データの層を作成しました。
//--- Model Description.Clear(); CLayerDescription *descr; //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (int)(HistoryBars * 12 + 9); descr.window = 0; descr.activation = None; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
続いて、データの正規化層が続きます。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
この後、畳み込み層と完全連結層の2つのブロックが続きます。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count - 2; descr.window = 3; descr.step = 1; descr.window_out = 6; descr.activation = LReLU; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 100; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = 50; descr.window = 2; descr.step = 2; descr.window_out = 4; descr.activation = LReLU; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 100; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
圧縮されたデータはアテンションブロックによって分析されます。ここでは新しいスパースアテンション層を使用します。圧縮されたデータ全体を5要素からなる20のブロックに分割しました。各ブロックは、分析対象のシーケンスの1つの要素を表します。データを分析するために、4つのアテンションヘッドを使用し、各アテンションヘッドで最も重要な配列要素の30%を選択します。分析は、同じようなパラメータを持つ2つの連続した層でおこなわれます。これはlayersパラメータで示されるべきです。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHSparseAttentionOCL; descr.count = 20; descr.window = 5; descr.step = 4; descr.window_out = 8; descr.layers = 2; descr.probability = 0.3f; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
EAは、完全にパラメータ化された分位関数のブロックで取引を実行するかどうかを決定します。4つのアクションのいずれかを決定することができます。
- 買う
- 売る
- すべての取引を決済
- 取引しない
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFQF; descr.count = 4; descr.window_out = 32; descr.optimization = ADAM; if(!Description.Add(descr)) { delete descr; return false; }
完全なEAコードは添付ファイル「SparseRL-learning.mq5」に記載されています。
2023年3月のEURUSDH1履歴データを使用して、モデルを訓練し、EAをテストしました。学習プロセスにおいて、EAはテスト期間中に利益を示しましたが、しかし、利益が得られたのは、平均的な利益取引の規模が平均的な損失取引の規模よりも大きかったからです。勝ちポジションと負けポジションの数はほぼ同じでした。その結果、プロフィットファクターは1.12、リカバリーファクターは1.01となりました。
結論
この記事では、スパースアテンションのメカニズムを研究し、そのアルゴリズムをクラスライブラリに追加しました。モデルテストの結果、ある程度の利益を上げることができました。これは、このようなアーキテクチャを取引ソリューションの構築に利用できる可能性を示しています。ただし、この記事で紹介されているモデルは、情報提供とテストのみを目的としていることにご注意ください。
このモデルを実際の取引状況で使用するには、その有効性と相場変動に対する耐性について、より詳細な分析をおこなう必要があります。また、最適な結果を得るためには、モデルのハイパーパラメータをより慎重に調整する必要があります。
金融市場の取引にどのようなモデルを使っても、常に損失のリスクを伴うことを忘れてはなりません。したがって、実際の取引にモデルを使用する前に、その動作原理を慎重に研究し、考えられるリスクを評価する必要があります。
にもかかわらず、スパースアテンションメカニズムは、取引モデルを構築するための有用なツールとなることができます。
参照文献
- スパース変換器による長いシーケンス
- AttentionIsAllYouNeed
- ニューラルネットワークが簡単に(第8部):アテンションメカニズム
- ニューラルネットワークが簡単に(第10部):Multi-HeadAttention
- ニューラルネットワークが簡単に(第11部):GPTについて
- ニューラルネットワークが簡単に(第35回):内因性好奇心モジュール
- ニューラルネットワークを簡単に(第36回):関係強化学習
記事で使用されているプログラム
# | ファイル名 | タイプ | 詳細 |
---|---|---|---|
1 | SparseRL-learning.mq5 | EA | モデルを訓練するEA |
2 | ICM.mqh | クラスライブラリ | モデル編成クラスライブラリ |
3 | NeuroNet.mqh | クラスライブラリ | ニューラルネットワークを作成するためのクラスのライブラリ |
4 | NeuroNet.cl | コードベース | OpenCLプログラムコードライブラリ |
MetaQuotes Ltdによってロシア語から翻訳されました。
元の記事: https://www.mql5.com/ru/articles/12428




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