English Русский 中文 Español 日本語 Português
preview
Neuronale Netze im Trading: Zeitreihenprognose mittels adaptiver Modenzerlegung (ACEFormer)

Neuronale Netze im Trading: Zeitreihenprognose mittels adaptiver Modenzerlegung (ACEFormer)

MetaTrader 5Handelssysteme |
13 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

Der Finanzmarkt ist ein komplexes und dynamisches System, in dem jede Preisbewegung aus dem komplexen Zusammenspiel zahlreicher Faktoren resultiert. Er spiegelt praktisch alles wider, von makroökonomischen Informationsflüssen und unternehmensspezifischen Nachrichten bis hin zu emotionalen Schwankungen der Anleger und den kalten Berechnungen algorithmischer Handelsstrategien. In dieser riesigen Mischung aus Signalen, Rauschen und Verzerrungen ist das Extrahieren aussagekräftiger Informationen und das Identifizieren echter Markttrends nicht nur eine interessante Herausforderung, sondern eine strategische Notwendigkeit.

Die Fähigkeit, die Marktrichtung genau vorherzusagen, kann einen nachhaltigen Wettbewerbsvorteil bieten. Eine der größten Herausforderungen ist informationsbedingtes Rauschen – die häufigen und oft bedeutungslosen Mikroschwankungen im Preis, die durch kurzfristige Trades, Schlagzeilen oder zufällige algorithmische Aktivitäten entstehen. Diese Schwankungen hindern analytische Modelle oft daran, den zugrunde liegenden Trend zu erfassen.

Bemühungen zur Entwicklung von Vorhersagemodellen reichen bis in das späte zwanzigste Jahrhundert zurück. Frühe neuronale Netzwerkarchitekturen zeigten, dass es prinzipiell möglich war, Modelle zur Vorhersage von Marktbewegungen zu trainieren. Diese Ansätze konnten jedoch Informationen über lange Zeiträume hinweg nicht speichern und konnten Ereignisse aus der jüngeren Vergangenheit nur unzureichend berücksichtigen.

Die Einführung von LSTM-Netzwerken verbesserte diese Situation. Dank ihrer Gedächtnismechanismen waren LSTM-Modelle in der Lage, wichtige Muster über längere Zeiträume hinweg zu bewahren. Sie fanden schnell weite Verbreitung bei der Zeitreihenprognose. Dennoch ist es nicht so einfach. Finanzzeitreihen unterscheiden sich von herkömmlichen sequenziellen Daten. Sie sind unregelmäßig, oft mit ungleichmäßigen Abständen zwischen den Ticks. Sie enthalten eine große Anzahl kurzlebiger Ausschläge, die kaum aussagekräftige Informationen über den zugrunde liegenden Markttrend enthalten.

Der Hochfrequenzhandel stellt eine besonders große Herausforderung dar. Er erzeugt das, was gemeinhin als Marktrauschen bezeichnet wird – wiederholte Kursschwankungen, die in extrem kurzen Zeitintervallen auftreten. Diese Schwankungen verdecken echte Trends, machen die Daten instabiler und überlasten Vorhersagemodelle mit unbedeutenden Ereignissen. Infolgedessen können selbst hochentwickelte neuronale Architekturen beginnen, sich auf ablenkende kurzfristige Variationen zu konzentrieren, anstatt auf die Informationen, die wirklich wichtig sind.

Um diese Herausforderungen anzugehen, stellt der Artikel „An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting“ das ACEFormer-Framework vor – eine integrierte Architektur für die Analyse von Finanzzeitreihen, die speziell für Hochfrequenzhandelsumgebungen entwickelt wurde. Anstatt ein einzelnes Vorhersagemodell darzustellen, kombiniert ACEFormer mehrere komplementäre Komponenten, von denen jede eine eigene Aufgabe erfüllt: Rauschfilterung, Modellierung unregelmäßiger Zeitintervalle und selektive Aufmerksamkeit auf die informativsten Marktbewegungen.

Die erste Stufe der ACEFormer-Architektur führt eine Rauschunterdrückung der Daten durch. Dabei kommt ein modifizierter ACEEMD-Algorithmus (Alias Complete Ensemble Empirical Mode Decomposition with Adaptive Noise) zum Einsatz. Die Methode basiert auf der Empirical Mode Decomposition (EMD), enthält jedoch mehrere Verbesserungen. Dies hilft, zwei wesentliche Einschränkungen der herkömmlichen EMD zu beseitigen: den Endeffekt und das Mode-Mixing. Durch das Entfernen der ersten Intrinsic Mode Function (IMF), die den Großteil der hochfrequenten Schwingungen enthält, unterdrückt ACEEMD effektiv das Marktrauschen und bewahrt gleichzeitig die kritischen Wendepunkte, die den zugrunde liegenden Trend charakterisieren.

Nach dieser Vorfilterung werden die entrauschten Daten an das zeitbewusste Modul weitergeleitet. Da Ereignisse an Finanzmärkten in unregelmäßigen Zeitabständen auftreten, sind herkömmliche Aufmerksamkeitsmechanismen nicht gut für deren Modellierung geeignet. Um dieses Problem anzugehen, integrieren die Autoren ein Time-Aware-Modul, das Merkmalswerte verarbeitet und dabei explizit die verstrichene Zeit zwischen den Beobachtungen berücksichtigt. Dies ermöglicht es dem Modell, Ereignissequenzen besser zu erfassen und kausale Zusammenhänge besser zu erkennen.

Die resultierenden Merkmale werden anschließend von einem verbesserten Aufmerksamkeitsblock verarbeitet. Im Gegensatz zum Standard-Aufmerksamkeitsmechanismus (Attention) ist dieses Modul speziell für Finanzdaten konzipiert, bei denen es entscheidend ist, kritische Wendepunkte zu identifizieren und gleichzeitig unbedeutende Schwankungen zu ignorieren. Indem das Modell einen stärkeren Schwerpunkt auf informative Bereiche der Zeitreihe legt, verschwendet es seine Aufmerksamkeit nicht auf verrauschte Elemente und konzentriert sich auf potenziell relevante Informationen.

In der letzten Stufe wird ein vollverbundenes neuronales Netzwerk verwendet. Es aggregiert die extrahierten Merkmale und erstellt die endgültige Vorhersage der zukünftigen Preisrichtung. Folglich umfasst die ACEFormer-Architektur die gesamte Prognose-Pipeline – von der Rauschunterdrückung und zeitlichen Modellierung bis hin zur aufmerksamkeitsbasierten Merkmalsextraktion und endgültigen Vorhersage.


Der ACEFormer-Algorithmus

Der ACEFormer-Algorithmus ist ein mehrstufiges Framework zur Verarbeitung von Zeitreihendaten mit dem Ziel, Preisbewegungen an Finanzmärkten präzise vorherzusagen. Sein Kernprinzip ist die sequentielle und adaptive Unterdrückung von Marktgeräuschen, gefolgt von der Extraktion informativer Merkmale und der Generierung von Prognosen, die langfristige Markttrends berücksichtigen. Dieser Ansatz ist besonders effektiv in Hochfrequenzhandelsumgebungen, in denen aussagekräftige Signale oft durch zahlreiche zufällige Schwankungen und Marktgeräusche verdeckt werden.

Der Prozess beginnt mit der Eingabezeitreihe 𝑆={𝑠1,𝑠2,…,𝑠𝑛}, wobei jeder Vektor 𝑠𝑖 den Preis, das Handelsvolumen und andere Marktindikatoren enthält, die zum Zeitschritt 𝑖 beobachtet wurden. Um die Daten für das Modelltraining vorzubereiten, wird die Sequenz um ein Zero-Padding-Segment der Länge 𝑝 erweitert, was es dem Modell ermöglicht, zukünftige Zeitschritte vorherzusagen. Dies setzt das Modell in die Lage, zukünftige 𝑝 Zeitschritte vorherzusagen, trotz des Fehlens expliziter Informationen über die Zukunft in den Quelldaten 𝐷=[𝑠1,𝑠2,…,𝑠𝑛,0,0,…,0] ∈ 𝑅(𝑛+𝑝)×𝑑, wobei 𝑑 die Anzahl der Merkmale ist. Diese Nullen helfen dem Modell, die Sequenzstruktur zu bewahren und zukünftige Werte vorherzusagen.

Die nächste Stufe glättet das Signal mithilfe von Faltungsfiltern. Zwei Faltungsfilter, 𝑓 und 𝑔, werden nacheinander angewendet, um zufällige Schwankungen zu reduzieren und die Eingabesequenz zu stabilisieren. Dieser Vorverarbeitungsschritt unterdrückt vorübergehende Ausschläge und verbessert die Qualität der Daten, die den nachfolgenden Stufen des Modells präsentiert werden.

Nach der anfänglichen Glättungsphase wird der adaptive Empirical Mode Decomposition-Algorithmus (ACEEMD) angewendet, um hochfrequentes Rauschen zu unterdrücken. Der Prozess beginnt mit dem Addieren und Subtrahieren von Gaußschem Rauschen 𝑛𝑖(𝑡) von jedem Element der Eingabezeitreihe, wodurch zwei neue Sequenzen erzeugt werden: 𝑝𝑒𝑖(𝑡)=𝑥(𝑡)+𝑛𝑖(𝑡) und 𝑝𝑚𝑖(𝑡)=𝑥(𝑡)−𝑛𝑖(𝑡).

Jede dieser Sequenzen wird anschließend mittels Empirical Mode Decomposition (EMD) zerlegt, wobei die erste Intrinsic Mode Function (IMF) extrahiert wird.

Die entsprechenden IMF-Komponenten, die aus beiden Zerlegungen gewonnen wurden, werden dann summiert. Diese Komponente wird vom ursprünglichen Signal subtrahiert, was die entrauschte Zeitreihe 𝑟1(𝑡)=𝑥(𝑡)−IMF1(𝑡) liefert. Die entrauschte Zeitreihe wird dann an die nachfolgenden Verarbeitungsschritte weitergeleitet.

Um dem Modell zu ermöglichen, die zeitliche Abfolge von Ereignissen zu erfassen, wird eine Positionskodierung zur Eingaberepräsentation hinzugefügt, wodurch Informationen über die Position der Daten in der Sequenz erhalten bleiben. Anschließend wird eine lineare Projektion der Daten durchgeführt.

Ein charakteristisches Merkmal der ACEFormer-Architektur ist ihr probabilistisches Aufmerksamkeitsmodul, das eine zentrale Rolle bei der Verbesserung der Generalisierungsfähigkeit des Modells spielt. Probabilistische Aufmerksamkeit ist eine recheneffiziente Variante des herkömmlichen Self-Attention-Mechanismus, die viele irrelevante Aufmerksamkeitsverbindungen eliminiert. Anstatt die Aufmerksamkeit über die gesamte Sequenz zu berechnen, konzentriert sich der Mechanismus ausschließlich auf die informativsten Zeitschritte. Um dies zu erreichen, wird zunächst für jede Position ein Wichtigkeitswert geschätzt. In ACEFormer ist dieser Wert als die maximale Projektion jeder Query (Abfrage) auf eine zufällig ausgewählte Teilmenge von Keys (Schlüssel) definiert. Nach der Normalisierung werden die informativsten Positionen ausgewählt. Die Self-Attention-Berechnung wird nur für diese Teilmenge durchgeführt. Folglich wird die Aufmerksamkeit nicht über die vollständige Sequenz berechnet, sondern über eine kompakte Teilmenge, die mit hoher Wahrscheinlichkeit die informativsten zeitlichen Ereignisse enthält.

Das Modul für probabilistische Aufmerksamkeit in ACEFormer ist nicht nur eine technische Optimierung, sondern eine strategische Designentscheidung. Es ermöglicht dem Modell, sich effektiver an dynamische Marktbedingungen anzupassen, bei denen sich die Bedeutung einzelner Abhängigkeiten im Zeitverlauf kontinuierlich ändert. Dieser Ansatz erzeugt robustere und zuverlässigere Prognosen bei der Arbeit mit verrauschten und hochvolatilen Finanzdaten.

Infolgedessen ermöglicht die probabilistische Aufmerksamkeit dem ACEFormer-Modell, sich auf wirklich informative Muster zu konzentrieren und gleichzeitig irrelevante Abhängigkeiten sowie zufällige Marktschwankungen herauszufiltern. Dies verbessert die Fähigkeit des Modells, aussagekräftige Beziehungen zu extrahieren und genaue Prognosen zu erstellen, insbesondere für die Vorhersage der zukünftigen Preisrichtung auf Finanzmärkten.

Im Anschluss an die probabilistische Aufmerksamkeit werden die resultierenden Merkmalsdarstellungen durch eine Faltungsschicht und eine max-pooling-Operation verarbeitet. Diese Komponenten verbessern die lokale Merkmalsextraktion weiter und optimieren die Darstellung wichtiger Muster durch das Modell. Die Faltungsoperation betont die Bereiche der Zeitreihe, die die informativsten Signale für die nachfolgende Vorhersage enthalten.

Die letzte Datenverarbeitungsstufe verwendet einen herkömmlichen Self-Attention-Mechanismus. Dieses Modul ermöglicht es jedem Element der Sequenz, den globalen Kontext einzubeziehen, wodurch das Modell Abhängigkeiten zwischen Ereignissen erfassen kann, die durch lange zeitliche Intervalle getrennt sind.

Um Prognosewerte für einen gegebenen Planungshorizont zu erhalten, wird ein vollverbundenes Netzwerk verwendet.

Insgesamt besteht der ACEFormer-Algorithmus aus mehreren Stufen, beginnend mit der Rauschunterdrückung und endend mit der Erstellung genauer Prognosen. Jede Stufe trägt zur Fähigkeit des Modells bei, mit verrauschten und hochvolatilen Finanzzeitreihen umzugehen, langfristige Markttrends zu identifizieren und zukünftige Preisbewegungen mit hoher Genauigkeit vorherzusagen.

Nachfolgend ist die Visualisierung des ACEFormer-Frameworks der Autoren dargestellt.



Die Implementierung in MQL5

Nachdem wir die theoretischen Grundlagen des ACEFormer-Frameworks untersucht haben, können wir nun zu dessen praktischer Implementierung in MQL5 übergehen. Wir beginnen mit dem probabilistischen Attention-Modul. Es ist eine der Kernkomponenten der Architektur, die eine hohe Recheneffizienz bietet und gleichzeitig die Qualität der Datenrepräsentationen bewahrt.

Bevor wir die Implementierungsdetails besprechen, lohnt es sich, den konzeptionellen Vorteil der probabilistischen Attention zu wiederholen. Dieser Mechanismus stellt einen Kompromiss zwischen Vorhersagegenauigkeit und Recheneffizienz dar. Im Gegensatz zur herkömmlichen Attention, die die gesamte Sequenz verarbeitet, konzentriert sich die probabilistische Attention selektiv auf die informativsten Elemente. Diese Strategie reduziert sowohl den Speicherverbrauch als auch den Rechenaufwand erheblich, ohne die Modellqualität zu beeinträchtigen, insbesondere bei der Verarbeitung langer Sequenzen.

Die in diesem Artikel vorgestellte Implementierung ist in drei nacheinander ausgeführte Kernel unterteilt. Jeder Kernel führt eine spezifische Aufgabe aus: von der Schätzung der Wichtigkeit über die Auswahl der besten Queries bis hin zur Berechnung der endgültigen kontextuellen Repräsentationen. Lassen Sie uns diese Pipeline Schritt für Schritt untersuchen.

Die erste Stufe schätzt die Wichtigkeit jeder Query. Diese Operation wird vom ProbAttentionQueryImp-Kernel ausgeführt. Der Kernel erhält die folgenden Eingaben:

  • die Query-Matrix (querys);
  • die kombinierte Matrix aus Keys und Values (keys_values);
  • das Index-Array index_keys, das die abgetasteten Key-Indizes speichert, die mit jeder Query verknüpft sind.

In diesem Zusammenhang beziehen sich die Keys auf zufällig ausgewählte Elemente, die zur Schätzung der Query-Wichtigkeit verwendet werden. Der Sampling-Prozess wird ausschließlich zur statistischen Auswertung eingesetzt und nicht zur Berechnung der endgültigen Attention-Scores. Sein Zweck ist es zu messen, wie stark jede Query auf eine repräsentative Teilmenge von Keys reagiert.

__kernel void ProbAttentionQeuryImp(__global const float* querys,
                                    __global const float2* __attribute__((aligned(8))) keys_values,
                                    __global const float* index_keys,
                                    __global float* querys_imp,
                                    const int dimension
                                   )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t ind_k = get_local_id(1);
   const size_t total_ind = get_local_size(1);
   const size_t id_h = get_global_id(2);
   const size_t total_h = get_global_size(2);

Der Kernel wird über einen dreidimensionalen Aufgabenraum ausgeführt, wobei jede Dimension einen eigenen Zweck bei der Organisation der parallelen Berechnung erfüllt. Die erste Dimension umfasst die Sequenz der Queries. Die zweite entspricht der Anzahl der abgetasteten Keys, die mit jeder Query verknüpft sind. Diese Anzahl kann je nach Modellkonfiguration und Analysetiefe variieren. Die dritte Dimension repräsentiert die Attention-Heads – unabhängige Verarbeitungseinheiten, die gleichzeitig verschiedene Aspekte der Eingabesequenz analysieren.

Besondere Aufmerksamkeit sollte dem Verhalten der Attention-Heads gewidmet werden. Jeder Head arbeitet auf seiner eigenen, unabhängig abgetasteten Teilmenge von Keys. Dieses Design ermöglicht mehrere komplementäre Ansichten derselben Sequenz, wodurch jeder Head unterschiedliche Beziehungen und strukturelle Muster entdecken kann. Dadurch wird die Gesamtarchitektur robuster: Wenn ein Head einen wichtigen Bereich der Sequenz unterschätzt, kann ein anderer ihn möglicherweise dennoch erfassen. Gemeinsam erzeugen die Attention-Heads eine reichhaltigere und ausdrucksstärkere Repräsentation des ursprünglichen Signals, was die Qualität des Attention-Mechanismus und den Informationsgehalt des resultierenden Kontextes erheblich verbessert.

Ausführungsthreads sind entlang der zweiten Dimension in Arbeitsgruppen organisiert. Um die Kommunikation zwischen parallelen Threads innerhalb jeder Arbeitsgruppe zu erleichtern, wird ein gemeinsames Array im lokalen Speicher des OpenCL-Geräts zugewiesen.

__local float temp[LOCAL_ARRAY_SIZE][2];
const int ls = min((int)total_ind, (int)LOCAL_ARRAY_SIZE);

Der nächste Schritt besteht darin, die Offsets innerhalb der Eingabepuffer zu berechnen, die dem aktuellen Ausführungsthread entsprechen. Der Offset in den Query-Puffer wird direkt aus der Thread-Kennung in der ersten Dimension bestimmt. Der Offset in den Key-Puffer wird berechnet, indem zunächst der abgetastete Key-Index aus dem Indizierungspuffer abgerufen und dieser Index dann in den entsprechenden Pufferoffset umgewandelt wird.

const int shift_q = dimension * (id_q * total_h + id_h);
const int id_k = index_keys[total_ind * id_q * total_h + ind_k * total_h + id_h];
const int shift_k = dimension * (id_k * total_h + id_h);

Für jedes Query–Key-Paar berechnet der Kernel deren Skalarprodukt, das deren Ähnlichkeitsgrad misst. Dieser Vorgang besteht aus einer elementweisen Multiplikation, gefolgt von der Akkumulation der resultierenden Produkte.

   float sum = 0;
#pragma unroll
   for(int d = 0; d < dimension; d++)
      sum += IsNaNOrInf(querys[shift_q + d] * keys_values[shift_k + d].s0, 0);

Anschließend berechnen die Threads unter Verwendung des gemeinsamen lokalen Speicher-Arrays kooperativ sowohl die Summe als auch das Maximum dieser Skalarprodukte innerhalb jeder Arbeitsgruppe. Diese parallele Reduktion erzeugt effizient aggregierte Statistiken für jede abgetastete Teilmenge.

   int id_t = ind_k % ls;
#pragma unroll
   for(int i = 0; i < total_ind; i += ls)
     {
      if(i <= ind_k || (i + ls) > ind_k)
        {
         temp[id_t][0] = IsNaNOrInf((i == 0 ? 0 : temp[id_t][0]) + sum, 0);
         temp[id_t][1] = (i == 0 ? IsNaNOrInf(sum, MIN_VALUE) : fmax(temp[id_t][1], IsNaNOrInf(sum, MIN_VALUE)));
         barrier(CLK_LOCAL_MEM_FENCE);
        }
     }
   int count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(ind_k < count && (ind_k + count) < ls)
        {
         temp[ind_k][0] += temp[ind_k + count][0];
         temp[ind_k + count][0] = 0;
         temp[ind_k][1] = fmax(temp[ind_k + count][1], temp[ind_k][1]);
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

Der Wichtigkeitswert der aktuellen Query wird dann als Differenz zwischen dem maximalen und dem durchschnittlichen Skalarprodukt berechnet. Je größer dieser Wert ist, desto informativer wird die Query eingestuft. Die resultierenden Wichtigkeitswerte werden im Ausgabepuffer querys_imp gespeichert.

 if(ind_k == 0)
    querys_imp[id_q * total_h + id_h] = IsNaNOrInf(temp[0][1] - temp[0][0] / total_ind, MIN_VALUE);
}

Die nächste Stufe wählt die informativsten Queries aus. Diese Aufgabe wird vom TopKImportanceToIndex-Kernel ausgeführt. Anstatt sich auf rechenintensive Sortieralgorithmen zu verlassen, verwendet die Implementierung eine einfache, aber robuste Ranking-Strategie.

Für jede Query zählt der Kernel parallel die Anzahl der Queries mit höheren Wichtigkeitswerten. Wenn diese Anzahl kleiner als der angegebene Schwellenwert top_k ist, wird die aktuelle Query in die endgültige Indexliste aufgenommen. Obwohl dieser Ansatz unkompliziert ist, eignet er sich besonders gut für die GPU-Ausführung, da er nur minimale Synchronisation erfordert und nicht von zusätzlichen Datenstrukturen abhängt.

__kernel void TopKImportanceToIndex(__global const float* importance,
                                   __global float* indexes,
                                   const int top_k
                                  )
  {
   const size_t id_q = get_global_id(0);
   const size_t total_q = get_global_size(0);
   const size_t id_h = get_global_id(1);
   const size_t total_h = get_global_size(1);
//---
   float imp = importance[id_q * total_h + id_h];
   int pos = 0;
#pragma unroll
   for(int i = 0; i < total_q; i++)
     {
      if(i == id_q)
         continue;
      float val = importance[i * total_h + id_h];
      if(val > imp || (i < id_q && val >= imp))
         pos++;
      if(pos >= top_k)
         break;
     }
//---
   if(pos < top_k)
      indexes[pos * total_h + id_h] = (float)id_q;
  }

Die dritte und letzte Stufe berechnet den Aufmerksamkeitsmechanismus selbst. Diese Operation wird durch den QIndexAttention-Kernel implementiert. Sein Zweck ist es, die endgültige kontextuelle Repräsentation für jede ausgewählte Query zu generieren.

Der Kernel erhält die vollständigen Sätze der Vektoren von Query, Key und Value als Eingabe. Wie bereits früher erörtert, ist eine wichtige Designentscheidung, das Erstellen zusätzlicher Kopien der ausgewählten Query-Teilmenge zu vermeiden. Dies ist wichtig, um die Speichernutzung zu reduzieren und die Recheneffizienz zu verbessern. Stattdessen arbeitet der Kernel über einen Indexpuffer, der Referenzen auf die informativsten Queries enthält, die während der vorherigen Stufe identifiziert wurden.

Es ist erwähnenswert, dass die Key- und Value-Token zusammen in einem einzigen Datenpuffer gespeichert werden. Diese Anordnung vereinfacht Speicherzugriffsmuster und verbessert die Cache-Ausnutzung. Konkret verwendet die Implementierung den Vektortyp float2, wobei die erste Komponente den Key und die zweite den entsprechenden Value repräsentiert. Die Behandlung von jedem Key–Value-Paar als eine einzige logische Einheit reduziert den Speicherzugriffs-Overhead und führt gleichzeitig zu einer kompakteren und effizienteren Implementierung.

__kernel void QIndexAttention(__global const float *q,
                              __global const float2* kv,
                              __global float *scores,
                              __global const float *indexes,
                              __global float *out,
                              const int dimension,
                              const int heads_kv
                             )
  {
//--- init
   const int ind_q = get_global_id(0);
   const int k = get_local_id(1);
   const int h = get_global_id(2);
   const int total_q = get_global_size(0);
   const int total_k = get_local_size(1);
   const int heads = get_global_size(2);

Auch dieser Kernel arbeitet in einem dreidimensionalen Aufgabenraum. Die erste Query-Dimension arbeitet jedoch nur mit der ausgewählten Teilmenge der informativsten Token. Die zweite Dimension Keys deckt die vollständige Sequenz ab. Wie zuvor werden Ausführungs-Threads entlang der zweiten Dimension in Arbeitsgruppen gruppiert.

Innerhalb des Kernels wird der aktuelle Ausführungs-Thread über alle Dimensionen des Aufgabenraums hinweg identifiziert. Diese Identifikatoren werden dann verwendet, um die entsprechenden Offsets in die Eingabepuffer zu berechnen.

const int h_kv = h % heads_kv;
const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h);
const int shift_kv = dimension * (heads_kv * k + h_kv);
const int shift_s = total_k * (ind_q *  heads + h) + k;

Beachten Sie, dass der Kernel vor der Berechnung des Offsets in den Query-Puffer zunächst den entsprechenden Query-Index aus dem Puffer abruft, der die ausgewählten Elemente mit hoher Wichtigkeit enthält.

Ein gemeinsames Array wird dann im lokalen Speicher zugewiesen, um den Datenaustausch zwischen Threads zu ermöglichen, die zur selben Arbeitsgruppe gehören.

__local float temp[LOCAL_ARRAY_SIZE];
const uint ls = min((uint)total_k, (uint)LOCAL_ARRAY_SIZE);

Die erste Rechenstufe bewertet die Skalarprodukte zwischen den Query- und Key-Vektoren und erzeugt ein Array von Zwischenwerten, die üblicherweise als raw scores bezeichnet werden. Diese Scores quantifizieren die Relevanz jedes Query–Key-Paares und dienen als Grundlage für die nachfolgende Aufmerksamkeitsberechnung.

//--- Score
   float score = 0;
   if(q_id >= 0)
     {
#pragma unroll
      for(int d = 0; d < dimension; d++)
         score += IsNaNOrInf(q[shift_q + d] * kv[shift_kv + d].s0, 0);
     }

Um Berechnungen zu stabilisieren und die numerische Stabilität zu verbessern, wird die Softmax-Normalisierung in einer modifizierten Form implementiert. Innerhalb jeder Arbeitsgruppe wird zunächst der Maximalwert unter allen Scores identifiziert.

//--- max of score
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? score : fmax(temp[k % ls], score));
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   uint count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
         temp[k] = fmax(temp[k + count], temp[k]);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);

Jeder Score wird dann durch Subtraktion dieses Maximalwerts verschoben. Diese Transformation verhindert einen exponentiellen Überlauf, indem sichergestellt wird, dass jeder Exponent kleiner oder gleich null ist. Folglich bleiben die Exponentialwerte im Intervall von 0 bis 1.

score = IsNaNOrInf(exp(score - temp[0]), 0);

Die Exponentialwerte werden dann summiert und jeder Wert wird durch die resultierende Gesamtsumme geteilt, wodurch die Rohwerte in endgültige Gewichte umgewandelt werden.

//--- sum of exp
#pragma unroll
   for(int i = 0; i < total_k; i += ls)
     {
      if(k >= i && k < (i + ls))
         temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + score;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   count = ls;
#pragma unroll
   do
     {
      count = (count + 1) / 2;
      if(k < count && (k + count) < ls)
        {
         temp[k] += temp[k + count];
         temp[k + count] = 0;
        }
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//--- score
   if(temp[0] > 0)
      score /= temp[0];
   scores[shift_s] = score;

Schließlich werden diese Aufmerksamkeitsgewichte auf den Value-Tensor angewendet. Die gewichteten Value-Vektoren werden akkumuliert, um einen einzelnen Kontextvektor zu bilden, der die semantische Repräsentation der Eingabesequenz aus der Perspektive der aktuellen Query erfasst.

//--- out
#pragma unroll
   for(int d = 0; d < dimension; d++)
     {
      float val = kv[shift_kv + d].s1 * score;
#pragma unroll
      for(int i = 0; i < total_k; i += ls)
        {
         if(k >= i && k < (i + ls))
            temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + val;
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      //---
      uint count = ls;
#pragma unroll
      do
        {
         count = (count + 1) / 2;
         if(k < count && (k + count) < ls)
           {
            temp[k] += temp[k + count];
            temp[k + count] = 0;
           }
         barrier(CLK_LOCAL_MEM_FENCE);
        }
      while(count > 1);
      //---
      if(k == 0)
         out[dimension * (ind_q * heads + h) + d] = temp[0];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
  }

Der gesamte beschriebene Mechanismus implementiert ein kohärentes und hocheffizientes probabilistisches Aufmerksamkeitsschema. Er beginnt mit einer schnellen, approximativen Schätzung der Query-Wichtigkeit, gefolgt von der Auswahl der vielversprechendsten Queries, und schließt mit der vollständigen Berechnung ab, die nur auf einer begrenzten, aber informativen Teilmenge durchgeführt wird. Dieser Ansatz beschleunigt nicht nur die Verarbeitung langer Sequenzen, sondern bewahrt auch ein hohes Maß an Vorhersagegenauigkeit. Gleichzeitig reduziert es das Volumen der Zwischendaten und die Anzahl der Zugriffe auf den globalen Speicher erheblich.

Der bisher beschriebene Prozess deckt jedoch nur den Vorwärtsdurchlauf ab – die Phase, in der das Modell Vorhersagen aus den Eingabedaten generiert. Um das Lernen zu ermöglichen, ist es zudem erforderlich, die Backpropagation zu implementieren – was es erlaubt, die trainierbaren Parameter jeder Komponente entsprechend ihrem Beitrag zum Endergebnis des Modells zu aktualisieren.

In dieser Implementierung haben wir die bewusste architektonische Entscheidung getroffen, Gradienten ausschließlich durch den Aufmerksamkeitsmechanismus zu propagieren und die Query-Auswahlphase vom Backpropagation-Durchlauf auszuschließen. Auf den ersten Blick mag dies wie eine Vereinfachung erscheinen, ist jedoch tatsächlich eine sorgfältig abgewogene Designentscheidung, die auf der zugrunde liegenden Berechnungsstruktur basiert.

Beide oben diskutierten Phasen – die Auswahl informativer Queries und die Aufmerksamkeitsberechnung selbst – beruhen auf derselben grundlegenden Operation: dem Abgleich von Query-Tokens mit Key-Tokens. Während der Abfrageauswahlphase wird eine Stichprobe von Keys (Schlüsseln) gegen die vollständige Menge an Queries (Abfragen) evaluiert, um die Wichtigkeit jedes Elements basierend auf seiner Antwort zu schätzen. Während der Aufmerksamkeitsphase wird die Perspektive umgekehrt: Die zuvor ausgewählten Queries (Abfragen) werden gegen die vollständige Key-Sequenz (Schlüsselsequenz) evaluiert. Mit anderen Worten: Beide Phasen operieren auf denselben Entitäten, jedoch aus unterschiedlichen Blickwinkeln. Diese Symmetrie setzt uns in die Lage, redundante Berechnungen zu eliminieren und einen effizienten Gradientenfluss zu etablieren, indem die Modellparameter über einen einzigen Berechnungspfad aktualisiert werden.

Dieses Design bietet mehrere wichtige Vorteile. Erstens reduziert es die Rechenkosten, da Gradienten nur durch einen Informationspfad propagiert werden. Zweitens verbessert es die numerische Stabilität, indem potenzielle Konflikte zwischen zwei parallelen Gradientenquellen eliminiert werden. Drittens führt es zu einer saubereren und eleganteren Architektur mit weniger Abhängigkeiten, was sowohl die Implementierung als auch das Testen vereinfacht. Am wichtigsten ist, dass alle Informationen über die Wichtigkeit bereits in den Gradientensignalen enthalten sind. Folglich verbessert eine einzige Parameteraktualisierung gleichzeitig sowohl den Aufmerksamkeitsmechanismus als auch das Abfrageauswahlverfahren, wodurch die während des Trainings erlernten Informationen effektiv wiederverwendet werden.

Der QIndexAttentionGradients-Kernel implementiert die Fehlerrückführung durch den Aufmerksamkeitsmechanismus, der für die genaue Verteilung der Gradienten auf die drei Schlüsselkomponenten verantwortlich ist: Query, Key und Value. Der Berechnungsraum ist entlang dreier Dimensionen organisiert:

  • die wichtigsten Queries;
  • die Token-Dimension;
  • die Attention-Heads.
Dies bietet einen hohen Grad an Parallelität und ermöglicht eine effiziente Nutzung der GPU-Rechenressourcen.

__kernel void QIndexAttentionGradients(__global const float* q,
                                       __global float* q_g,
                                       __global const float2* kv,
                                       __global float2* kv_g,
                                       __global const float* indexes,
                                       __global const float* scores,
                                       __global const float* gradient,
                                       const int kunits, const int heads_kv
                                      )
  {
//--- init
   const int ind_q = get_global_id(0);
   const int d = get_global_id(1);
   const int h = get_global_id(2);
   const int qunits = get_global_size(0);
   const int dimension = get_global_size(1);
   const int heads = get_global_size(2);

Zu Beginn der Ausführung bestimmt jeder Thread seine Koordinaten im Aufgabenraum. Der tatsächliche Query-Index wird aus dem indexes-Array abgerufen, um die korrekte Zuordnung mit den im globalen Speicher gespeicherten Elementen herzustellen. Alle erforderlichen Speicher-Offsets werden dann berechnet.

const int h_kv = h % heads_kv;
const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f);
const int shift_q = dimension * (q_id * heads + h) + d;
const int shift_s = (ind_q * heads + h) * kunits;
const int shift_g = h * dimension + d;

Die erste Stufe berechnet die Gradienten in Bezug auf die Value-Vektoren. Diese Implementierung unterstützt die Verwendung von weniger Attention-Heads für Keys und Values (heads_kv) als alle heads, die Queries verarbeiten. Dies reduziert sowohl den Speicherverbrauch als auch die Rechenkosten bei gleichzeitiger Wahrung der Flexibilität der Architektur. Dies erfordert jedoch einen speziellen Ansatz während der Backpropagation.

Da Value-Vektoren von mehreren Attention-Heads gemeinsam genutzt werden können, müssen ihre Gradienten die Beiträge jedes Heads aggregieren, dessen Ausgabe von diesen Values abhängt. Dies stellt sicher, dass Fehlerinformationen korrekt an die Values weitergegeben werden, die an mehreren Attention-Pfaden beteiligt sind.

Für jede Value-Position iteriert der Algorithmus über alle Attention-Heads, die potenziell auf diesen Value verweisen. Innerhalb dieser Schleife wird der gewichtete Beitrag jedes Heads als Produkt aus dem Ausgabegradienten und dem normalisierten Attention-Gewicht (score) berechnet, das während des Feed-Forward-Durchlaufs erhalten wurde. Diese Beiträge werden akkumuliert und schließlich in der zweiten Komponente der float2-Struktur innerhalb des kv_g-Gradientenpuffers gespeichert.

Dieses Verfahren garantiert eine konsistente und genaue Gradientenfortpflanzung, selbst wenn die Anzahl der Heads für Keys und Values von der Anzahl der Query-Heads abweicht. Folglich kann das Modell trotz der strukturellen Asymmetrie zwischen den Attention-Komponenten korrekt trainiert werden.

//--- Calculating Value's gradients
   int step_score = kunits * heads;
   if(h < heads_kv)
     {
#pragma unroll
      for(int v = ind_q; v < kunits; v += qunits)
        {
         float grad = 0;
         for(int hq = h; hq < heads; hq += heads_kv)
           {
            int shift_score = hq * kunits + v;
            for(int g = 0; g < qunits; g++)
               grad += IsNaNOrInf(gradient[shift_g + dimension * (hq - h + g * heads)], 0) *
                       scores[shift_score + g * step_score];
           }
         int shift_v = dimension * (heads_kv * v + h) + d;
         kv_g[shift_v].s1 = IsNaNOrInf(grad, 0);
        }
     }

Die nächste Stufe berechnet die Gradienten in Bezug auf die Queries. Dieser Schritt ist etwas aufwendiger, da er die Auswertung der Ableitung der Softmax-Funktion erfordert. Für jede Query wird der Ausgabegradient abgerufen, der der aktuellen Position entspricht, woraufhin der Algorithmus zwei verschachtelte Schleifen über die Keys ausführt. Die erste Schleife berechnet den Beitrag jedes Aufmerksamkeitsgewichts, während die zweite den Einfluss jedes Key durch seinen normalisierten Wert berücksichtigt. Dieses Verfahren propagiert das Fehlersignal präzise durch die Softmax-Verteilung, während die probabilistische Struktur des Aufmerksamkeitsmechanismus bewahrt bleibt. Schließlich wird der akkumulierte Query-Gradient in den q_g-Puffer an dem zuvor berechneten Offset geschrieben.

//--- Calculating Query's gradients
   float grad = 0;
   float out_g = IsNaNOrInf(gradient[shift_g + ind_q * dimension], 0);
   int shift_kv = h_kv * dimension + d;
#pragma unroll
   for(int k = 0; (k < kunits && out_g != 0); k++)
     {
      float sc_g = 0;
      float sc = scores[shift_s + k];
      if(sc == 0)
         continue;
      for(int v = 0; v < kunits; v++)
         sc_g += scores[shift_s + v] * out_g * kv[shift_kv + v * heads_kv * dimension].s1 *
                 ((float)(k == v) - sc);
      grad += sc_g * kv[shift_kv + k * heads_kv * dimension].s0;
     }
   q_g[shift_q] = grad;

Die nachfolgende Stufe berechnet Gradienten in Bezug auf die Keys, einen der heikelsten Teile des Backpropagation-Durchlaufs. Das Ziel hierbei ist es, präzise zu bestimmen, wie jeder Key durch den Aufmerksamkeitsmechanismus zur endgültigen Vorhersage des Modells beigetragen hat.

Wie bereits erwähnt, kann die Implementierung unterschiedliche Anzahlen von Aufmerksamkeitsköpfen für Queries und Keys verwenden. Daher muss jeder Key-Gradient Beiträge von jedem Aufmerksamkeitskopf akkumulieren, an dem dieser Key beteiligt war.

Während des Feed-Forward-Durchlaufs erzeugt jedes Query–Key-Paar einen skalaren Aufmerksamkeitswert, der durch die Softmax-Funktion normalisiert wird. Die normalisierten Werte werden im scores-Puffer gespeichert. Diese Werte allein reichen jedoch nicht aus, um die Gradienten zu berechnen. Da Softmax eine nichtlineare Transformation ist, erfordert die Backpropagation die Auswertung ihrer Ableitung. Obwohl die Softmax-Ausgaben bereits berechnet und gespeichert wurden, muss die Empfindlichkeit der gesamten Funktion in Bezug auf jedes Eingabe-Logit noch berechnet werden. Diese Ableitung besteht sowohl aus diagonalen als auch aus nicht-diagonalen Termen der Softmax-Formel. Folglich muss der Algorithmus bei der Berechnung des Gradienten für einen bestimmten Key über jeden Query, der mit diesem Key assoziiert ist, iterieren und deren Beiträge akkumulieren.

Ein wesentlicher Aspekt dieses Verfahrens ist die Verwendung der ausgewählten Query-Indizes, um die korrekte Abhängigkeitskette zu rekonstruieren. Ohne diese Indizes wäre die Gradientenverteilung inkorrekt.

Der Algorithmus iteriert über jedes relevante Paar, wertet die erforderlichen Softmax-Ableitungsterme aus und multipliziert sie mit den entsprechenden Ausgangsfehlergradienten. Die Ergebnisse werden dann im Gradienten für diesen Key akkumuliert und in die erste Komponente des kv_g-Puffers geschrieben, der Gradienten für sowohl Keys als auch Values speichert.

//--- Calculating Key's gradients
   if(h < heads_kv)
     {
#pragma unroll
      for(int k = ind_q; k < kunits; k += qunits)
        {
         int shift_k = dimension * (heads_kv * k + h_kv) + d;
         grad = 0;
         for(int hq = h; hq < heads; hq++)
           {
            int shift_score = hq * kunits + k;
            float val = kv[shift_k + heads_kv * dimension].s1;
            for(int scr = 0; scr < qunits; scr++)
              {
               float sc_g = 0;
               int shift_sc = scr * kunits * heads;
               float sc = scores[shift_sc + k];
               if(sc == 0)
                  continue;
               for(int v = 0; v < kunits; v++)
                  sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] *
                          val * ((float)(k == v) - sc);
               grad += IsNaNOrInf(sc_g * 
                                  q[(hq + (int)(indexes[scr * heads + hq] + 0.001f) * heads) * dimension + d], 0);
              }
           }
         kv_g[shift_k].s0 = IsNaNOrInf(grad, 0);
        }
     }
  }

Dies schließt unsere Diskussion der probabilistischen Aufmerksamkeitsalgorithmen ab, die innerhalb des OpenCL-Programms implementiert sind. Wir haben jede Hauptphase nacheinander untersucht – von der Schätzung der Query-Wichtigkeit und der Auswahl der informativsten Elemente bis hin zur Berechnung der Aufmerksamkeit und der Implementierung des Backpropagation-Durchlaufs. Jeder Kernel wurde sorgfältig an die ACEFormer-Architektur angepasst und für eine effiziente Ausführung auf GPU-Geräten optimiert.

Die vollständige Implementierung, einschließlich des Quellcodes für alle oben beschriebenen Kernel, ist im Anhang enthalten.

Die nächste Phase unserer Arbeit wird sich auf die Implementierung der probabilistischen Aufmerksamkeitsalgorithmen innerhalb der Hauptanwendung konzentrieren. Auf dieser Ebene ist das OpenCL-Programm in die Modelllogik, die Pufferverwaltung und die Berechnungssynchronisation integriert. Der Umfang des vorliegenden Artikels hat jedoch bereits ein vernünftiges Limit erreicht, daher werden wir diese Diskussion im nächsten Artikel fortsetzen.



Schlussfolgerung

In diesem Artikel haben wir das ACEFormer-Framework vorgestellt – eine Architektur, die für die hocheffiziente Verarbeitung sequenzieller Daten unter begrenzten Rechenressourcen konzipiert ist. Ihre Hauptstärken – Modularität, Anpassungsfähigkeit und Recheneffizienz – bilden das Fundament der Implementierung.

ACEFormer bietet eine elegante Lösung für das Problem der Skalierung von Aufmerksamkeitsmechanismen für lange Sequenzen. Anstatt die gesamte Eingabesequenz erschöpfend zu verarbeiten, verwendet es einen probabilistischen Auswahlmechanismus, um die informativsten Elemente zu identifizieren. Dies reduziert den Rechenaufwand erheblich, während die Qualität nahezu auf dem gleichen Niveau bleibt. Ein solcher Ansatz ist besonders in Umgebungen wertvoll, in denen jede Mikrosekunde Ausführungszeit und jedes Megabyte Speicher wichtig sind, wie etwa bei algorithmischen Handelsplattformen.

Im praktischen Teil dieser Arbeit haben wir die Implementierung aller Hauptkomponenten der probabilistischen Aufmerksamkeit innerhalb des OpenCL-Programms detailliert untersucht. Der nächste Schritt wird die Implementierung der probabilistischen Aufmerksamkeitsalgorithmen auf Ebene der Hauptanwendung sein. Um den aktuellen Artikel jedoch fokussiert und überschaubar zu halten, werden wir hier innehalten und diese Implementierung im nächsten Artikel fortsetzen. Dort wartet eine ebenso interessante Phase des Projekts.


Referenzen


In diesem Artikel verwendete Programme

# Name Typ Beschreibung
1 Research.mq5 Expert Advisor Expert Advisor für die Datenerfassung
2 ResearchRealORL.mq5
Expert Advisor
Expert Advisor für die Datenerfassung mit der Methode Real-ORL
3 Study.mq5 Expert Advisor Expert Advisor für das Offline-Modelltraining
4 StudyOnline.mq5
Expert Advisor
Expert Advisor für das Online-Modelltraining
4 Test.mq5 Expert Advisor Expert Advisor für das Modelltests
5 Trajectory.mqh Klassenbibliothek Struktur der Beschreibung des Systemzustands und der Modellarchitektur
6 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
7 NeuroNet.cl Codebibliothek OpenCL-Programmcode

Übersetzt aus dem Russischen von MetaQuotes Ltd.
Originalartikel: https://www.mql5.com/ru/articles/18004

Beigefügte Dateien |
MQL5.zip (2720.44 KB)
Von der Grundstufe bis zur Mittelstufe: Objektereignisse (III) Von der Grundstufe bis zur Mittelstufe: Objektereignisse (III)
In diesem Artikel schaffen wir die Grundlage für die Themen der nächsten Veröffentlichung. Wir werden uns auch ansehen, wie sich ein OBJ_LABEL-Objekt zum Bearbeiten und Verschieben vollständig interaktiv machen lässt. Mit anderen Worten: Wir können sowohl den Text als auch die Position des OBJ_LABEL-Objekts ändern, ohne das Dialogfenster „Objekteigenschaften“ zu öffnen.
Entwicklung eines Multi-Currency Expert Advisors (Teil 28): Hinzufügen eines Managers zum Schließen von Positionen Entwicklung eines Multi-Currency Expert Advisors (Teil 28): Hinzufügen eines Managers zum Schließen von Positionen
Wenn Sie mehrere Strategien parallel ausführen, möchten Sie möglicherweise regelmäßig alle offenen Positionen schließen und die Strategien erneut starten. Der bestehende Code erlaubt es, dieses Verhalten nur durch manuelles Eingreifen umzusetzen. Versuchen wir, diesen Teil zu automatisieren.
Eine alternative Log-datei mit der Verwendung der HTML und CSS Eine alternative Log-datei mit der Verwendung der HTML und CSS
In diesem Artikel werden wir eine sehr einfache, aber leistungsfähige Bibliothek zur Erstellung der HTML-Dateien schreiben, dabei lernen wir auch, wie man eine ihre Darstellung einstellen kann (nach seinem Geschmack) und sehen wir, wie man es leicht in seinem Expert Advisor oder Skript hinzufügen oder verwenden kann.
Von der Grundstufe bis zur Mittelstufe: Objektereignisse (I) Von der Grundstufe bis zur Mittelstufe: Objektereignisse (I)
In diesem Artikel werden wir uns drei der sechs Ereignisse ansehen, die MetaTrader 5 generieren kann, wenn eine Änderung an einem Objekt im Chart auftritt. Diese Ereignisse sind aus Sicht der Benutzerinteraktion sehr nützlich. Der Grund dafür ist, dass wir ohne das Verständnis dieser Ereignisse viel mehr Aufwand betreiben müssten, um eine bestimmte Chart-Konfiguration beizubehalten, wenn wir versuchen, Objekte für bestimmte Zwecke zu verwalten.