English Русский 中文 Español 日本語 Português
preview
Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (Fazit)

Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (Fazit)

MetaTrader 5 — Handelssysteme |
20 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einleitung

In diesem Artikel wenden wir uns nun der abschließenden Phase der Umsetzung unserer eigenen Interpretation der von den Autoren des SCNN-Frameworks vorgeschlagenen Ansätze mit MQL5 zu. SCNN (Seasonal Convolutional Neural Network) ist eine spezialisierte Architektur, die für die Analyse von Zeitreihen mit einer ausgeprägten Struktur entwickelt wurde. Das Hauptziel besteht darin, die Rohdaten in mehrere zentrale Komponenten – den langfristigen Trend, die saisonale Komponente und die kurzfristigen Komponenten – zu zerlegen und dabei auch die räumlichen Beziehungen zwischen den Variablen zu berücksichtigen. Diese Zerlegung verbessert nicht nur die Prognosequalität, sondern gewährleistet auch, dass das Modell interpretierbar ist – eine seltene, aber äußerst wertvolle Eigenschaft bei Aufgaben des algorithmischen Tradings.

SCNN basiert auf klassischen Analyseprinzipien, darunter die periodenbezogene Normalisierung und die Arbeit mit saisonalen Transformationen, kombiniert diese jedoch mit modernen Methoden der Informationsverarbeitung: einem Attention-Mechanismus, parametrischer Merkmalsprojektion und Aggregation der Ergebnisse durch Faltung. Nachfolgend ist die von den Autoren erstellte Darstellung des SCNN-Frameworks abgebildet.

In unserer bisherigen Arbeit haben wir die Struktur und den Zweck aller internen Komponenten des Modells systematisch untersucht, diese mithilfe spezieller Klassen implementiert und jedes Element der zukünftigen Architektur sorgfältig aufgebaut. Die Arbeit erwies sich als anspruchsvoll, aber das Ergebnis war die Mühe auf jeden Fall wert: Wir verfügen nun über eine Reihe vollwertiger Module, von denen jedes eine genau definierte Rolle im System spielt.

Der nächste logische Schritt besteht darin, sie zu einer einzigen, zusammenhängenden Struktur zusammenzuführen. In dieser Phase entsteht das architektonische Framework von SCNN, das genau festlegt, wie die Informationsflüsse innerhalb des Modells organisiert werden – von den Eingabedaten bis zur endgültigen Prognose. Wir gehen nun von der Vorbereitungsphase in die operative Phase über: Die Komponenten werden zum Leben erweckt, interagieren miteinander und bilden schließlich einen einzigen Analysemechanismus.

Nachdem wir nun die technische Zusammenführung abgeschlossen haben, kommen wir zum am meisten erwarteten Teil – dem Testen des Modells anhand historischer Daten. Dadurch lässt sich nicht nur die Korrektheit der Implementierung, sondern auch die praktische Robustheit des Ansatzes unter verschiedenen Marktregimes bewerten. SCNN ist nicht einfach nur eine weitere Architektur für neuronale Netze. Dies ist ein Versuch, rechnerische Genauigkeit mit Transparenz bei der Entscheidungsfindung zu verbinden.



SCNN-Encoder

Im vorherigen Artikel haben wir unsere Analyse der Architektur des SCNN-Encoders, der im Objekt CNeuronSCNNEncoder implementiert ist, abgeschlossen und das Initialisierungsverfahren für alle seine internen Komponenten im Detail untersucht. Jedes Modul – sei es Normalisierung, Transposition oder räumliche Anpassung – wurde für den Betrieb vorbereitet und so konfiguriert, dass es die Eingabedaten empfangen und verarbeiten kann. Die Struktur des Objekts ist unten dargestellt.

class CNeuronSCNNEncoder   :  public CNeuronTransposeOCL
  {
protected:
   CNeuronPeriodNorm       cLongNorm;
   CNeuronTransposeVRCOCL  cSeasonTransp;
   CNeuronPeriodNorm       cSeasonNorm;
   CNeuronTransposeVRCOCL  cUnSeasonTransp;
   CNeuronPeriodNorm       cShortNorm;
   CNeuronAdaptSpatialNorm cAdaptSpatNorm;
   CNeuronBaseOCL          cConcatenated;
   CNeuronSwiGLUOCL        cProjection;
   CNeuronTransposeOCL     cTranspose;
   CNeuronConvOCL          caFusion[2];
   CNeuronBaseOCL          cFusionOut;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNNEncoder(void) {};
                    ~CNeuronSCNNEncoder(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNNEncoder; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

Heute setzen wir die begonnene Arbeit fort und widmen uns nun der Entwicklung einer Schlüsselkomponente – dem Algorithmus für den Vorwärtsdurchlauf. Dies bestimmt, wie die Daten nacheinander alle Schichten des Modells durchlaufen, transformiert und aggregiert werden und schließlich die Ausgabedarstellung bilden.

Die Implementierung des Vorwärtsdurchlaufs in der Methode feedForward veranschaulicht das koordinierte Zusammenspiel des gesamten Systems.

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

Die Eingabedaten durchlaufen zunächst einen Block zur Langzeitnormalisierung, in dem über einen langen Zeitraum akkumulierte Unterschiede bei Offset und Skalierung beseitigt werden. Dadurch wird eine einheitliche Grundlage für die weitere Analyse geschaffen.

Die verarbeiteten Daten werden anschließend entlang der Kette an den Block zur Extraktion der saisonalen Komponente weitergeleitet. Wie bereits im vorherigen Artikel erläutert, erfolgt die Transposition hier mit einer festgelegten Schrittweite, die der saisonalen Periode entspricht. Dadurch werden die Elemente der Zeitreihe nach Zyklusphase in Sequenzen gruppiert, wodurch sich stabile saisonale Muster effektiver erkennen lassen. Die resultierende Struktur wird periodenweise normalisiert, wodurch die Aussagekraft saisonaler Schwankungen verstärkt wird. Durch die inverse Transposition werden die Daten dann für die nachfolgenden Verarbeitungsschritte aufbereitet.

   if(!cSeasonTransp.FeedForward(cLongNorm.AsObject()))
      return false;
   if(!cSeasonNorm.FeedForward(cSeasonTransp.AsObject()))
      return false;
   if(!cUnSeasonTransp.FeedForward(cSeasonNorm.AsObject()))
      return false;

Nach der saisonalen Aufbereitung folgt die Phase der Extraktion der kurzfristigen Komponente. In diesem Schritt extrahieren wir im Wesentlichen lokale, schnelle Schwankungen, die wichtige Informationen über jüngste Veränderungen in der Marktdynamik enthalten können.

   if(!cShortNorm.FeedForward(cUnSeasonTransp.AsObject()))
      return false;

Der letzte Schritt in der Vorverarbeitungsphase ist die Anwendung der räumlichen Normalisierung. Diese Phase ist entscheidend für die Ausrichtung der aus den verschiedenen Komponenten – der langfristigen, der saisonalen und der kurzfristigen – gewonnenen Informationen. Die räumliche Normalisierung ermöglicht es, die Skalierung und den relativen Einfluss der Eingabevariablen anzupassen und so Verzerrungen zu beseitigen, die durch Unterschiede in der Dynamik der Merkmale entstehen. Das Hauptmerkmal dieses Schritts besteht darin, dass er unter Berücksichtigung räumlicher Abhängigkeiten implementiert wird und einen Attention-Mechanismus nutzt, wodurch die resultierende Darstellung besonders aussagekräftig und informativ ist.

   if(!cAdaptSpatNorm.FeedForward(cShortNorm.AsObject()))
      return false;

Bevor zur nächsten Stufe übergegangen wird, müssen die Ergebnisse aller Module – einschließlich der normalisierten Daten und der berechneten statistischen Parameter – zu einem einzigen kohärenten Tensor zusammengefasst werden. Dies ist ein äußerst wichtiger Schritt, der die spätere Integrität des Informationsflusses innerhalb des Modells gewährleistet.

Es ist hervorzuheben, dass zwar die Dimensionalität der Zeitreihen selbst nach der Normalisierung erhalten bleibt, wir uns jedoch bewusst dafür entschieden haben, die statistischen Parameter (Mittelwerte und Standardabweichungen) nicht auf die gesamte Länge der Zeitreihe auszuweiten. Diese Entscheidung wurde getroffen, um Speicher zu sparen – schließlich liefert die Wiederholung desselben Werts über die gesamte Länge hinweg keine zusätzlichen Informationen. Diese Optimierung führt jedoch zu einer Nichtübereinstimmung der Dimensionen zwischen der Zeitreihe und den entsprechenden statistischen Größen.

Dennoch behalten wir hinsichtlich der Anzahl der analysierten univariaten Sequenzen weiterhin ein gemeinsames Raster bei – und genau das ist der entscheidende Punkt. Genau darauf stützen wir uns bei der Konkatenation. Alle Operationen werden nacheinander über diese univariaten Segmente hinweg ausgeführt, wodurch die Korrektheit der endgültigen Zusammenstellung gewährleistet und Verzerrungen in der Datenstruktur verhindert werden.

Ganz zu Beginn der Zusammenführungsphase bilden wir den ersten konkatenierten Block, der die Roh-Eingabedaten sowie die Ausgabe des Moduls zur langfristigen Normalisierung enthält. Auf diese Weise lässt sich der Kontext der ursprünglichen Zeitreihe beibehalten und gleichzeitig durch Informationen über langfristige Trends ergänzen, die im Rahmen der Normalisierung ermittelt wurden. Darüber hinaus fügen wir diesen Daten die berechneten statistischen Parameter (Mittelwerte und Standardabweichungen) hinzu, die als Indikatoren für Skalierung und Variabilität dienen.

   uint windows[3] = {NeuronOCL.Neurons() / iWindow,
                      cLongNorm.GetPeriod()*cLongNorm.GetUnits(),
                      2 * cLongNorm.GetUnits()
                     };
   if(!Concat(NeuronOCL.getOutput(), cLongNorm.getOutput(), cLongNorm.GetMeanSTDevs().getOutput(),
              cConcatenated.getOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

Im nächsten Schritt erweitern wir unseren Tensor anhand der aus der saisonalen Komponente gewonnenen Informationen.

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cSeasonNorm.getOutput(),
              cSeasonNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

Nach den saisonalen Informationen werden nacheinander Daten aus der kurzfristigen und der räumlichen Komponente zum Tensor hinzugefügt. In dieser Phase folgen wir weiterhin der Logik der Kombination entlang univariater Sequenzen und erweitern den Tensor schrittweise um neue Merkmale.

   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cShortNorm.getOutput(),
              cShortNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[0] = windows[0] + windows[1] + windows[2];
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   if(!cConcatenated.SwapOutputs() ||
      !Concat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getOutput(),
              cAdaptSpatNorm.GetMeanSTDevs().getOutput(), cConcatenated.getOutput(),
              windows[0], windows[1], windows[2], iWindow))
      return false;

Die kurzfristige Komponente versorgt das Modell mit aktuellen Kontextinformationen – lokalen Schwankungen, Spitzen und Mikromustern –, die für eine hohe Prognosesensitivität besonders wichtig sind. Die räumliche Normalisierung wiederum schließt die Vorbereitungsphase ab, indem sie für zusätzliche Stabilität sorgt und die Rohdaten über alle Variablen hinweg glättet.

Auf diese Weise schaffen wir eine aussagekräftige und ausgewogene Darstellung der Zeitreihe, in der jede Komponente in einheitlicher Form dargestellt wird. Dieser kombinierte Tensor wird an die Projektionsschicht weitergeleitet, deren Aufgabe es ist, unter Berücksichtigung des Prognosehorizonts eine gewichtete Darstellung der Eingabedaten zu erzeugen. In dieser Phase werden die Daten an die erforderliche Ausgabedimensionalität angepasst, um sie für künftige Analyseaufgaben vorzubereiten. Genau hier wird der Grundstein für die Erstellung einer aussagekräftigen Prognose gelegt, die sowohl die historische Tiefe als auch die Struktur der erwarteten Veränderungen berücksichtigen kann.

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

Die in den vorangegangenen Schritten gewonnenen Darstellungen einzelner univariater Sequenzen werden im Fusion-Modul an die Struktur einer multimodalen Zeitreihe angepasst. Hier verlagert sich die Darstellung von univariaten Sequenzen hin zu Zeitschritten: Der Tensor wird transponiert, und die weitere Verarbeitung erfolgt dann entlang der Zeitachse.

In dieser Phase werden zwei aufeinanderfolgende Faltungsschichten verwendet. Die erste davon nutzt die TANH-Aktivierungsfunktion, wodurch es möglich wird, eine reichhaltige nichtlineare Darstellung jedes Merkmals zu erlernen – im Wesentlichen eine normalisierte Form des Merkmalssignals. Die zweite Schicht, die SIGMOID verwendet, fungiert als Gate: Sie bestimmt die Bedeutung jedes Merkmals innerhalb des zeitlichen Kontexts, indem sie die erhaltenen Werte weich gewichtet.

Das Endergebnis ergibt sich aus der elementweisen Multiplikation der Ausgabewerte der beiden Schichten, wodurch es möglich ist, Rauschen effektiv herauszufiltern und die wichtigsten Merkmale der Zeitreihe hervorzuheben – und zwar bereits im ausgerichteten Raum der Prognoseaufgabe.

   if(!cTranspose.FeedForward(cProjection.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].FeedForward(cTranspose.AsObject()))
         return false;
   if(!ElementMult(caFusion[0].getOutput(), caFusion[1].getOutput(), cFusionOut.getOutput()))
      return false;
//---
   return CNeuronTransposeOCL::feedForward(cFusionOut.AsObject());
  }

In der letzten Phase des Vorwärtsdurchlaufs werden die Daten einer inversen Transposition unterzogen, wodurch sie von der zeitlichen Darstellung wieder in die ursprüngliche Struktur univariater Sequenzen zurückgeführt werden. Dadurch wird die Konsistenz zwischen den Eingabe- und Ausgabeformaten des Encoders gewährleistet.

Somit handelt es sich bei der feedForward-Methode um eine sorgfältig strukturierte und gut durchdachte Architektur, in der jede Komponente eine genau definierte Funktion erfüllt. Von der Normalisierung und Zerlegung bis hin zu Projektionen und multimodaler Aggregation – der gesamte Prozess zielt darauf ab, eine reichhaltige, strukturierte und informative Beschreibung der Zeitreihe zu erzeugen.

Nachdem wir nun die Analyse des Forward-Pass-Algorithmus abgeschlossen haben, ist es sinnvoll, zu einem ebenso wichtigen Schritt überzugehen – dem Rückwärtsdurchlauf. Genau in diesem Moment beginnt das Modell zu lernen: Die am Ausgang berechneten Gradientenwerte werden schrittweise durch die gesamte Kette von Operationen zurückgeführt, wodurch die internen Parameter jedes Moduls angepasst werden können. Die Methode calcInputGradients setzt diesen Prozess um und gewährleistet dabei eine strikte Reihenfolge sowie die Konsistenz mit der Architektur des Vorwärtsdurchlaufs.

bool CNeuronSCNNEncoder::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
//---
   if(!CNeuronTransposeOCL::calcInputGradients(cFusionOut.AsObject()))
      return false;
   if(!ElementMultGrad(caFusion[0].getOutput(), caFusion[0].getGradient(),
                       caFusion[1].getOutput(), caFusion[1].getGradient(),
                       cFusionOut.getGradient(), caFusion[0].Activation(), caFusion[1].Activation()))
      return false;
   if(!cTranspose.CalcHiddenGradients(caFusion[0].AsObject()))
      return false;

Der Rückwärtsdurchlauf beginnt auf dem Top-Level des Modells, wo die aus der nächsten Schicht des Modells erhaltenen Gradienten als Eingabe verwendet werden – oder aus der Verlustfunktion, falls es sich um die Ausgabe selbst handelt. Die im Modul Fusion verwendeten Faltungsblöcke werden zuerst verarbeitet. Es ist wichtig zu verstehen, dass wir während des Vorwärtsdurchlaufs zwei parallele Faltungskanäle verwendet haben: einen mit der TANH-Aktivierungsfunktion zur Erzeugung eines nichtlinearen Merkmals und einen mit SIGMOID, der als Gate fungiert und den Wichtigkeitsgrad des Merkmals bestimmt. Am Ausgang wurden diese Kanäle elementweise multipliziert, um die endgültige Darstellung zu bilden.

Im Rückwärtsdurchlauf erfordert diese Multiplikationsoperation eine besondere Behandlung. Die Methode ElementMultGrad teilt die Gradienten korrekt wieder in beide Zweige auf und berücksichtigt dabei die spezifischen Eigenschaften der jeweiligen Aktivierungsfunktion. Dies ist für die Genauigkeit der Berechnungen von entscheidender Bedeutung, da in dieser Phase festgelegt wird, wie die Faltungsgewichte angepasst werden sollen.

Als Nächstes übergeben wir die Gradienten an das Modul Transpose, das – zur Erinnerung – im Vorwärtsdurchlauf für die Neuausrichtung der Tensorachsen zuständig war: von der Darstellung univariater Sequenzen hin zu Zeitschritten. Genau in dieser Form wurden die Daten in beide Faltungsschichten des Moduls Fusion eingespeist; daher besteht die Aufgabe des Rückwärtsdurchlaufs darin, die von beiden Zweigen kommenden Gradienten korrekt zu kombinieren.

Der erste Schritt besteht darin, den Fehlergradienten von einer der Faltungsschichten weiterzuleiten. Anstatt die Daten des gesamten Tensors zu kopieren, ersetzen wir lediglich den Zeiger auf den Datenpuffer. Auf diese Weise können wir den Speicherbereich effizient wechseln und die resultierenden Werte ohne unnötigen Ressourcenaufwand speichern. Anschließend führen wir mit dem ersetzten Zeiger den Rückwärtsdurchlauf der zweiten Faltungsschicht durch.

   CBufferFloat* temp = cTranspose.getGradient();
   if(!cTranspose.SetGradient(cTranspose.getPrevOutput(), false) ||
      !cTranspose.CalcHiddenGradients(caFusion[1].AsObject()) ||
      !SumAndNormilize(temp, cTranspose.getGradient(), temp, cTranspose.GetCount(),
                                                              false, 0, 0, 0, 1) ||
      !cTranspose.SetGradient(temp, false))
      return false;

Anschließend werden die beiden Gradientenströme elementweise addiert, wodurch sich der kombinierte Einfluss beider Zweige auf den endgültigen Fehler widerspiegelt. Abschließend werden die Pufferzeiger in ihren ursprünglichen Zustand zurückversetzt, wodurch sichergestellt wird, dass die Datenstruktur für die weitere Gradientenpropagation korrekt bleibt. Dieser Ansatz gewährleistet die Konsistenz des Modells und eine effiziente Speicherverwaltung während des Trainings.

Der Fehler wird anschließend an den Projection-Block weitergeleitet, der für die Gewichtung der Informationen vor den Faltungsmodulen zuständig ist. Der daraus resultierende Gradient wird an den Concatenated-Block weitergeleitet, wo eine der technisch komplexesten Phasen beginnt – die rückwärtsgerichtete Dekonkatenation.

   if(!cProjection.CalcHiddenGradients(cTranspose.AsObject()))
      return false;
   if(!cConcatenated.CalcHiddenGradients(cProjection.AsObject()))
      return false;

Zur Erinnerung: Während des Vorwärtsdurchlaufs haben wir schrittweise Daten aus verschiedenen Normalisierern kombiniert: langfristigen, saisonalen, kurzfristigen und räumlichen. Darüber hinaus wurden neben den normalisierten Daten auch statistische Parameter wie Mittelwerte und Standardabweichungen in den kombinierten Tensor aufgenommen.

Nun ist die Aufgabe genau umgekehrt – den konkatenierten Tensor wieder in seine Bestandteile zu entpacken. Dazu wird die sequenzielle DeConcat-Prozedur verwendet, bei der die Fenstergrößen für jede Komponente streng nach dem vorherigen Layout berechnet werden. Dieser Schritt erfordert besondere Genauigkeit, da bereits der kleinste Fehler bei der Fenstergröße zu einer Fehlausrichtung oder zum Verlust von Daten führen kann.

   uint windows[3] = {0};
   windows[1] = cAdaptSpatNorm.GetUnits();
   windows[2] = 2 * cAdaptSpatNorm.GetUnits();
   windows[0] = cConcatenated.Neurons() / iWindow - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cAdaptSpatNorm.getGradient(),
                cAdaptSpatNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cShortNorm.GetPeriod() * cShortNorm.GetUnits();
   windows[2] = 2 * cShortNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getGradient(), cShortNorm.getPrevOutput(),
                cShortNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cSeasonNorm.GetPeriod() * cSeasonNorm.GetUnits();
   windows[2] = 2 * cSeasonNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(cConcatenated.getPrevOutput(), cSeasonNorm.getPrevOutput(),
                cSeasonNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getGradient(), windows[0], windows[1], windows[2], iWindow))
      return false;
   windows[1] = cLongNorm.GetPeriod() * cLongNorm.GetUnits();
   windows[2] = 2 * cLongNorm.GetUnits();
   windows[0] = windows[0] - windows[1] - windows[2];
   if(!DeConcat(NeuronOCL.getPrevOutput(), cLongNorm.getPrevOutput(),
                cLongNorm.GetMeanSTDevs().getGradient(),
                cConcatenated.getPrevOutput(), windows[0], windows[1], windows[2], iWindow))
      return false;

An dieser Stelle sollte ein wichtiger technischer Aspekt hervorgehoben werden. Mit Ausnahme der räumlichen Normalisierung waren alle Normalisierungsmodule nicht die letzte Stufe in der Datenvorbereitungskette. Ihre Ergebnisse wurden als Eingabedaten für die nachfolgenden Verarbeitungsschritte verwendet. Das bedeutet, dass jedes dieser Module zum Zeitpunkt des Rückwärtsdurchlaufs den Fehlergradienten nicht von einer einzigen Quelle, sondern von mehreren Quellen erhält. Um den Beitrag aller nachfolgenden Operationen korrekt zu berücksichtigen, können wir den Gradienten nicht einfach überschreiben, wie es bei einfacheren Architekturen üblich ist. Stattdessen wird ein Backup-Puffer verwendet: Jedes Modul speichert dort seinen aktuellen Gradienten, bevor es einen neuen erhält.

Somit wird der Rückwärtsdurchlauf durch die Normalisierungskette mithilfe der Gradientenakkumulation in speziellen Puffern implementiert. Dadurch wird sichergestellt, dass der Einfluss jeder Modellkomponente genau rekonstruiert wird, und Informationsverlust wird verhindert.

Nach erfolgreicher Dekonkatenation erhält jedes der Normalisierungsmodule – beginnend mit dem kurzfristigen cShortNorm, gefolgt vom saisonalen cSeasonNorm und schließlich dem langfristigen cLongNorm – den entsprechenden Anteil des Fehlergradienten, der den Einfluss der Datenverarbeitungskette im Vorwärtsdurchlauf widerspiegelt. Wie wir jedoch bereits angemerkt haben, war jeder dieser Normalisierer an mehr als nur dem Datenaufbereitungsprozess beteiligt. Daher würde es nicht ausreichen, den Gradienten einfach entlang eines einzigen Pfades zurückzuleiten.

In der Praxis wird dies wie folgt umgesetzt. Nachdem der Normalisierer den Fehlergradienten von dem Objekt erhalten hat, das seine Daten verwendet, fügen wir ihn den Werten hinzu, die bereits während der Dekonkatenationsphase ermittelt wurden. Somit summieren wir in jeder Phase sorgfältig Informationen aus mehreren Quellen. Erst nach diesem Aggregationsschritt wird der resultierende Gradient entlang der Kette an die vorherige Schicht weitergeleitet.

   if(!cShortNorm.CalcHiddenGradients(cAdaptSpatNorm.AsObject()) ||
      !SumAndNormilize(cShortNorm.getGradient(), cShortNorm.getPrevOutput(),
                       cShortNorm.getGradient(), cShortNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cUnSeasonTransp.CalcHiddenGradients(cShortNorm.AsObject()))
      return false;
   if(!cSeasonNorm.CalcHiddenGradients(cUnSeasonTransp.AsObject()) ||
      !SumAndNormilize(cSeasonNorm.getGradient(), cSeasonNorm.getPrevOutput(),
                       cSeasonNorm.getGradient(), cSeasonNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
   if(!cSeasonTransp.CalcHiddenGradients(cSeasonNorm.AsObject()))
      return false;
   if(!cLongNorm.CalcHiddenGradients(cSeasonTransp.AsObject()) ||
      !SumAndNormilize(cLongNorm.getGradient(), cLongNorm.getPrevOutput(),
                       cLongNorm.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;

Dieser Ansatz stellt sicher, dass keine der im Vorwärtsdurchlauf hergestellten Informationsverbindungen während des Rückwärtsdurchlaufs verloren geht oder verzerrt wird. Gerade diese Präzision und Sorgfalt bei der Übertragung von Gradienten ist einer der Faktoren, die zur Stabilität und hohen Interpretierbarkeit des SCNN-Frameworks unter realen Marktbedingungen beitragen.

Der Rückwärtsdurchlauf wird durch die Berechnung der Gradienten direkt am Eingang – dem NeuronOCL-Objekt – abgeschlossen. Dieser Schritt ist von entscheidender Bedeutung, da hier alle Informationsströme – nachdem sie eine komplexe Kette von Transformationen und Normalisierungen durchlaufen haben – zusammenlaufen.

   if(!NeuronOCL.CalcHiddenGradients(cLongNorm.AsObject()) ||
      !SumAndNormilize(NeuronOCL.getGradient(), NeuronOCL.getPrevOutput(),
                       NeuronOCL.getGradient(), cLongNorm.GetPeriod(), false, 0, 0, 0, 1))
      return false;
//---
   return true;
  }

Es ist wichtig zu beachten, dass an dieser Stelle zwei wesentliche Informationsströme zusammenlaufen: einer aus einem komplexen System von Normalisierungen und der andere aus der Projektionsschicht, die für die Bildung des Merkmalsraums für die Prognose verantwortlich ist. Ihre korrekte Aggregation gewährleistet die Kontinuität des Gradientenflusses und die korrekte Aktualisierung der Modellgewichte während des Trainings.

Somit implementiert die Methode calcInputGradients nicht bloß einen technischen Schritt rückwärts entlang der Kette, sondern ein ausgefeiltes, modulares und strukturiertes Rückwärtsdurchlauf-Verfahren. Jeder Vorgang darin entspricht genau den vorherigen Schritten des Vorwärtsdurchlaufs, wodurch die SCNN-Encoder-Architektur kohärent, symmetrisch und leicht interpretierbar ist.

Die Optimierung der trainierbaren Parameter des Encoders CNeuronSCNNEncoder erfolgt durch die Delegation der Zuständigkeit an die entsprechenden internen Module, von denen jedes seine eigenen Gewichte enthält und seine eigene Aktualisierungslogik implementiert. Die Methode updateInputWeights überträgt die Steuerung nacheinander an diese Module und ermöglicht so ein zentralisiertes und zugleich modulares Management des Trainingsprozesses.

bool CNeuronSCNNEncoder::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   if(!cAdaptSpatNorm.UpdateInputWeights(cShortNorm.AsObject()))
      return false;
   if(!cProjection.UpdateInputWeights(cConcatenated.AsObject()))
      return false;
   for(uint i = 0; i < caFusion.Size(); i++)
      if(!caFusion[i].UpdateInputWeights(cTranspose.AsObject()))
         return false;
//---
   return true;
  }

Dank seines modularen Aufbaus bleibt der Algorithmus zur Aktualisierung der Gewichte einfach und logisch, ohne den Code mit unnötigen Abhängigkeiten zu überfrachten.

Der vollständige Quellcode der Klasse CNeuronSCNNEncoder, einschließlich der Implementierung aller wichtigen Methoden, ist im Anhang enthalten. Dank seiner transparenten Struktur und der detaillierten modularen Gliederung kann dieser Code als Grundlage für weitere Experimente und Anpassungen dienen.



Top-Level-Modul

Die Autoren des SCNN-Frameworks schlagen vor, eine hierarchische Architektur zu verwenden, in der mehrere Encoder zu einem einzigen Stack angeordnet sind. Dieser Ansatz zielt darauf ab, die Ausdruckskraft des Modells zu erhöhen und dadurch die Prognosequalität zu verbessern. Jeder nachfolgende Encoder in einer solchen Kette arbeitet nicht isoliert, sondern nutzt die Ergebnisse des vorhergehenden Encoders als Eingabedaten. Die Verwendung von Residualverbindungen liefert zusätzlichen Kontext für die Analyse. Dadurch kann das Modell einen breiteren Kontext berücksichtigen, eine tiefgreifendere Analyse durchführen und sowohl lokale als auch langfristige Abhängigkeiten innerhalb einer Zeitreihe erfassen.

Diese Flexibilität hat jedoch ihren Preis. Beim Übergang von einer Schicht zur nächsten tritt ein ganz bestimmtes Problem auf – nämlich der Unterschied in der Tensorgröße zwischen Eingabe und Ausgabe. Der springende Punkt ist, dass wir am Ausgang jedes SCNN-Encoders univariate Sequenzen in einem kompatiblen Format erhalten, die jedoch um einen festgelegten Prognosehorizont erweitert sind.

An dieser Stelle stellt sich eine schwierige Herausforderung. Einerseits müssen die Daten für die nächste Schicht ohne Prognosewerte bereitgestellt werden – andernfalls würden wir Informationen aus der Zukunft nach vorn weiterreichen, was nicht akzeptabel ist. Andererseits können wir diese Prognosewerte nicht einfach verwerfen, da sie für die abschließende Summierung der Ergebnisse aller Schichten benötigt werden. Einfach ausgedrückt: Wir müssen sowohl berücksichtigen, was das Modell bereits gesehen hat, als auch, was es vorhergesagt hat, ohne dabei die zeitliche Struktur der Daten zu stören.

Um dieses Problem zu lösen, werden wir ein Top-Level-Objekt erstellen – CNeuronSCNN. Dies ist ein übergeordnetes Steuerelement, das die gesamte Stack-Struktur der Encoder kapselt. Seine Aufgabe besteht nicht lediglich darin, die Encoder nacheinander aufzurufen, sondern sicherzustellen, dass die gesamte Architektur korrekt funktioniert: Daten zwischen den Schichten zu übertragen, deren Größen aufeinander abzustimmen, Prognosen zu sammeln sowie das Training und den Rückwärtsdurchlauf zu steuern. Dieses Element dient als Bindeglied zwischen der Berechnungslogik des Modells und der architektonischen Konsistenz.

Die Struktur des neuen Objekts ist unten dargestellt.

class CNeuronSCNN    :  public   CNeuronBaseOCL
  {
protected:
   CLayer            cLayers;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL)  override;
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL)  override;

public:
                     CNeuronSCNN(void) {};
                    ~CNeuronSCNN(void) {};
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                          uint units_count, uint variables, uint forecast,
                          uint season_period, uint short_period, uint layers,
                          ENUM_OPTIMIZATION optimization_type, uint batch);
   //---
   virtual bool      Save(const int file_handle) override;
   virtual bool      Load(const int file_handle) override;
   //---
   virtual int       Type(void) override const  {  return defNeuronSCNN; }
   virtual void      TrainMode(bool flag) override;
   virtual void      SetOpenCL(COpenCLMy *obj) override;
   //---
   virtual bool      WeightsUpdate(CNeuronBaseOCL *source, float tau) override;
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) override { }
  };

Innerhalb von CNeuronSCNN befindet sich ein Objekt vom Typ CLayer, das im Wesentlichen als Container für alle verschachtelten Encoder dient. Die Klasse enthält ein einziges eingebettetes CLayer-Objekt, wodurch wir den Konstruktor und den Destruktor leer lassen und die Speicherverwaltung der Laufzeitumgebung überlassen können.

Um das gesamte System in Betrieb zu nehmen, müssen Sie die Architektur aus ihren Komponenten korrekt zusammenstellen. Diese Aufgabe wird in der Methode Init behandelt, in der die gesamte dynamische Struktur des CNeuronSCNN-Objekts erstellt und konfiguriert wird.

bool CNeuronSCNN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                       uint units_count, uint variables, uint forecast,
                       uint season_period, uint short_period, uint layers,
                       ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, (units_count + forecast)*variables,
                                                                   optimization_type, batch))
      return false;
   SetActivationFunction(None);

Alles beginnt hier mit der Basisinitialisierung über die gleichnamige Methode in der übergeordneten Klasse, in der die Basisschnittstellen der neuronalen Schicht angelegt werden. Als Nächstes wird die Aktivierungsfunktion deaktiviert – sie wird auf dieser Ebene nicht benötigt, da die Encoder selbst bereits für die erforderliche Nichtlinearität und die Datenvorverarbeitung sorgen.

Anschließend wird der cLayers-Container geleert und für die Aufnahme der erforderlichen Objekte vorbereitet.

   cLayers.Clear();
   cLayers.SetOpenCL(OpenCL);
   CNeuronSCNNEncoder* encoder = NULL;
   CNeuronBaseOCL* residual = NULL;
   for(uint l = 0; l < layers; l++)
     {
      encoder = new CNeuronSCNNEncoder();
      if(!encoder)
         return false;
      if(!encoder.Init(0, l, OpenCL, units_count, variables, forecast, season_period,
                                                  short_period, optimization, iBatch) ||
         !cLayers.Add(encoder))
         return false;
      encoder.SetActivationFunction(None);
      if((l + 1) == layers)
         break;

In einer Schleife über die Anzahl der Schichten (den Parameter layers) werden nacheinander Encoder vom Typ CNeuronSCNNEncoder erstellt. Für jede Schicht wird ein Encoder-Objekt erstellt und mit den folgenden Parametern initialisiert: Eingabedimensionalität, Anzahl der Variablen, Prognoselänge und saisonale Perioden. Wenn die Initialisierung erfolgreich ist, wird das Objekt dem Layer-Container hinzugefügt.

Doch damit ist die Arbeit noch nicht getan. Um Residualverbindungen zwischen den Schichten zu erhalten, werden zusätzliche Schichten zwischen den Encodern eingefügt, die als eine Art Ausrichtungspuffer dienen. Diese Zwischenblöcke übernehmen die Ausgaben der vorgeschalteten Encoder und verwerfen die Prognosewerte, wodurch die Kontinuität des Datenflusses zwischen den Ebenen gewährleistet wird. Hier wird die Aktivierungsfunktion erneut deaktiviert – schließlich erfüllen diese Blöcke eher eine Hilfsfunktion als eine rechnerische Aufgabe.

      residual = new CNeuronBaseOCL();
      if(!residual)
         return false;
      if(!residual.Init(0, l, OpenCL, units_count * variables, optimization, iBatch) ||
         !cLayers.Add(residual))
         return false;
      residual.SetActivationFunction(None);
     }
   if(!SetGradient(encoder.getGradient(), true))
      return false;
//---
   return true;
  }

Es ist wichtig zu beachten, dass der letzte Encoder im Stack keine nachfolgende Hilfsschicht erhält. Sein Gradient wird als maßgeblicher Gradient für die gesamte oberste Schicht verwendet, wodurch ein zusätzlicher Datenkopiervorgang entfällt.

Somit wandelt sich die Methode Init von einem einfachen Initialisierer in eine Art Objekt-Builder, der die gesamte Stack-Struktur der Encoder zur Laufzeit zusammenstellt und dabei jedes Detail sorgfältig anpasst, unter Berücksichtigung der Größenausrichtung, der Prognosehorizonte und der Steuerung des Gradientenflusses. Dank dieser gut durchdachten Architektur ist es nicht nur möglich, das Modell effektiv zu trainieren, sondern es auch auf reale Aufgaben zu skalieren – ganz gleich, ob es sich dabei um kurzfristige Preisprognosen oder um die langfristige Analyse saisonaler Trends handelt.

Sobald die Initialisierungsphase abgeschlossen ist – d. h., wenn die Struktur des mehrschichtigen SCNN-Modells aufgebaut und betriebsbereit ist –, beginnt der entscheidende Schritt: die Durchführung eines Vorwärtsdurchlaufs. An dieser Stelle beginnt das Modell, seine Hauptfunktion zu erfüllen: die Verarbeitung von Rohdaten, die Erstellung von Prognosen und die Sammlung von Informationen auf allen Ebenen.

bool CNeuronSCNN::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!Output.Fill(0))
      return false;
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronSCNNEncoder* current = NULL;
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total();

Zunächst muss überprüft werden, ob der Zeiger auf die empfangenen Quelldaten gültig ist. Fehlen die Daten, wird die Ausführung abgebrochen. Anschließend wird der Ergebnispuffer geleert, um zu verhindern, dass sich Artefakte aus früheren Iterationen ansammeln. Als Nächstes wird die Variable inputs mit einem Zeiger auf eine externe Datenquelle initialisiert – diejenige, die wir als Eingabe an den Encoder übergeben. Dann beginnt der Durchlauf durch alle Schichten.

Die Modellarchitektur ist so konzipiert, dass sich die Schichten abwechseln: SCNN-Encoder befinden sich an geraden Positionen, während Puffer für Residualverbindungen, die als einfache neuronale Objekte implementiert sind, an ungeraden Positionen liegen. Deshalb hat die Schleife eine Schrittweite von zwei: Ein einzelner Durchlauf umfasst sowohl den Encoder als auch den zugehörigen Residualblock.

   for(int l = 0; l < layers; l += 2)
     {
      current = cLayers[l];
      if(!current ||
         !current.FeedForward(inputs) ||
         !SumAndNormilize(Output, current.getOutput(), Output, current.GetCount(), false, 0, 0, 0, 1))
         return false;
      if((l + 1) == layers)
         break;

Bei jeder Schleifeniteration rufen wir den aktuellen Encoder ab, rufen dessen FeedForward-Methode auf und fügen anschließend die Ausgabe des Moduls zur resultierenden Prognose hinzu. So nimmt bereits während des Vorwärtsdurchlaufs die kumulative Summe aller schichtbasierten Prognosen Gestalt an.

Wenn dies jedoch nicht die letzte Schicht ist, bleibt noch weitere Arbeit zu erledigen. Wir rufen das nächste (ungerade) Objekt ab – den Puffer für Residualverbindungen. Seine Aufgabe besteht darin, die Dimensionen der Eingabedaten für den nächsten Encoder anzupassen und dabei die Redundanz zu beseitigen, die durch das Hinzufügen von Prognosewerten entsteht. Diese Aufgabe übernimmt die DeConcat-Methode, die den Prognoseanteil vom bereinigten Verlauf trennt und die Daten wieder in das reine Verlaufsformat bringt, das der nächste Encoder benötigt. Anschließend werden die neuen Eingaben mit den vorherigen summiert, wodurch der Effekt von Residualverbindungen entsteht, und danach normalisiert. Jede Schicht arbeitet nicht isoliert, sondern unter Berücksichtigung der Ergebnisse der vorherigen Schichten und erhält dadurch eine reichhaltigere Darstellung der Zeitreihe.

      uint variables = current.GetWindow();
      uint dimension = inputs.Neurons() / variables;
      uint forecast = current.GetCount() - dimension;
      residual = cLayers[l + 1];
      if(!residual)
         return false;
      if(!DeConcat(residual.getOutput(), current.getPrevOutput(), current.getOutput(),
                   dimension, forecast, variables) ||
         !SumAndNormilize(residual.getOutput(), inputs.getOutput(), residual.getOutput(),
                          dimension, true, 0, 0, 0, 1))
         return false;
      inputs = residual;
     }
//---
   return true;
  }

Die nach der Dekonkatenation und Normalisierung gewonnenen Daten werden an den nächsten Encoder weitergeleitet, und dieser Vorgang wird fortgesetzt, bis der Durchlauf durch alle Ebenen abgeschlossen ist. Das Ergebnis ist eine aggregierte Prognose, die aus der gemeinsamen Arbeit aller Encoder des Blocks hervorgeht.

Somit ist die feedForward-Methode ein sorgfältig strukturierter Prozess, bei dem jeder Schritt darauf abzielt, die Informationen anzureichern, Verzerrungen zu beseitigen und eine ausgewogene Prognose vorzubereiten. Damit setzt sie die gesamte Philosophie der Stack-Architektur in die Praxis um: die Analyse schrittweise zu vertiefen, ohne den historischen Kontext zu verlieren, und dabei die Beiträge jeder Ebene zu berücksichtigen.

Nach Abschluss des Vorwärtsdurchlaufs gehen wir zu einer ebenso wichtigen Phase über – dem Rückwärtsdurchlauf. Hier werden die Gradienten von der Ausgabeschicht zurück zu den Eingabedaten propagiert, wodurch die Modellparameter optimiert werden können. Der Prozess wird in der calcInputGradients-Methode implementiert.

bool CNeuronSCNN::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL)
      return false;
   if(!PrevOutput.Fill(0))
      return false;
//---
   CNeuronBaseOCL* inputs = NULL;
   CNeuronSCNNEncoder* current = cLayers[-1];
   CNeuronBaseOCL* residual = NULL;
   int layers = cLayers.Total() - 2;

Der Vorgang beginnt mit grundlegenden Prüfungen: Es wird überprüft, ob ein Zeiger auf das externe NeuronOCL-Objekt vorhanden ist, und der Hilfspuffer PrevOutput wird geleert. Als Nächstes wird die Anzahl der Schichten festgelegt, die beim Training zum Einsatz kommen. Hierbei ist es wichtig zu verstehen, dass die Zählung in umgekehrter Reihenfolge erfolgt und beim letzten Encoder beginnt, da der Rückwärtsdurchlauf vom Ausgang des Modells zu seinem Eingang verläuft.

Eine Schleife mit dem Schritt „-1“ durchläuft alle Schichten, einschließlich Objekten für Residualverbindungen, und für jede Schicht wird je nach Schichttyp die entsprechende Logik ausgeführt.

   for(int l = layers; l >= 0; l--)
      switch(cLayers[l].Type())
        {
         case defNeuronBaseOCL:
            inputs = cLayers[l];
            if(!inputs ||
               !inputs.CalcHiddenGradients(current))
               return false;
            if(!!residual)
               if(!SumAndNormilize(inputs.getGradient(), residual.getGradient(), inputs.getGradient(),
                                   current.GetWindow(), false, 0, 0, 0, 1))
                  return false;
            residual = inputs;
            break;

Wenn es sich um ein Basisobjekt CNeuronBaseOCL handelt, führen wir zwei Aktionen aus. Zunächst berechnen wir die Gradienten der verborgenen Schichten auf Grundlage des aktuellen Encoders. Anschließend addieren wir, sofern ein vorheriger Residualblock vorhanden ist (die Variable residual), dessen Gradienten zu den aktuellen Gradienten hinzu und passen so den weitergegebenen Wert an. Dies gewährleistet einen kontinuierlichen Informationsfluss zwischen den Schichten und trägt dazu bei, Verluste zu vermeiden, die durch die Residualstruktur der Architektur verursacht werden.

Ist die aktuelle Schicht jedoch ein SCNN-Encoder, wird die Logik komplexer. Zunächst stellen wir sicher, dass das residual-Objekt bereits definiert wurde. Wenn nicht, fahren wir einfach fort. Andernfalls rufen wir das Objekt ab, das zwei Positionen später angeordnete ist, also dasjenige, das während des Vorwärtsdurchlaufs Daten an den Eingang des aktuellen Encoders geliefert hat. Dadurch lässt sich die Datenstruktur genau rekonstruieren.

 case defNeuronSCNNEncoder:
    current = cLayers[l];
    if(!residual)
       break;
    inputs = cLayers[l + 2];
    if(!inputs)
       return false;
    if(!Concat(residual.getGradient(), PrevOutput, current.getGradient(),
               residual.Neurons() / current.GetWindow(),
               current.GetCount() - residual.Neurons() / current.GetWindow(),
               current.GetWindow()) ||
       !SumAndNormilize(current.getGradient(), inputs.getGradient(), current.getGradient(),
                        current.GetWindow(), false, 0, 0, 0, 1))
       return false;
    break;
 default:
    return false;
    break;
}

Anschließend stellen wir mithilfe der Funktion Concat die Form des Gradienten wieder her: Nullwerte aus dem PrevOutput, die dem Prognoseteil entsprechen, werden an den aktuellen Gradienten der Residualschicht angehängt. Dies ist notwendig, da die Daten während des Vorwärtsdurchlaufs in historische Teile und Prognoseteile aufgeteilt wurden und diese Struktur nun im Fehlergradienten genau wiedergegeben werden muss. Anschließend werden sie mit dem Gradienten aus dem nachfolgenden Encoder summiert – dies ist ein wichtiger Punkt, da die Information über zwei Pfade fließen kann und beide zu einem gemeinsamen Gradienten zusammengeführt werden müssen.

Nachdem der Durchlauf durch alle Schichten abgeschlossen ist, wird der letzte Schritt ausgeführt: die Weitergabe des Gradienten an die äußerste Ebene. Hier erhält NeuronOCL den Gradienten vom unteren SCNN-Encoder, und falls eine Residualschicht vorhanden ist, werden die Gradienten abschließend summiert.

   if(!NeuronOCL.CalcHiddenGradients(current))
      return false;
   if(!!residual)
      if(!SumAndNormilize(NeuronOCL.getGradient(), residual.getGradient(), NeuronOCL.getGradient(),
                          current.GetWindow(), false, 0, 0, 0, 1))
         return false;
//---
   return true;
  }

Somit verkörpert die Methode calcInputGradients den Kerngedanken hinter der Stack-Architektur: Jede Schicht kann ihre Parameter nicht nur auf Grundlage ihres eigenen Fehlers anpassen, sondern auch danach, wie sie sich auf das Modell als Ganzes auswirkt. Dies ermöglicht eine Feinabstimmung und eine hohe Empfindlichkeit gegenüber den Eigenschaften von Zeitreihen.

Sobald die Gradienten über alle Schichten der Stack-Architektur hinweg berechnet wurden, beginnt die letzte Phase des Trainings – die Aktualisierung der Gewichte. Hier wird die gesamte zuvor geleistete Arbeit umgesetzt: Wir wandeln die gesammelten Informationen über den Fehler in Anpassungen der trainierbaren Parameter des Modells um.

Die Methode updateInputWeights ist dafür zuständig, die Gewichte aller trainierbaren Blöcke im Modell Schritt für Schritt zu aktualisieren. Zunächst wird die Variable inputs mit einem Zeiger auf eine externe Datenquelle initialisiert: ein NeuronOCL-Objekt.

bool CNeuronSCNN::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
  {
   CNeuronBaseOCL* inputs = NeuronOCL;
   CNeuronBaseOCL* current = NULL;
//---
   for(int l = 0; l < cLayers.Total(); l++)
     {
      current = cLayers[l];
      if(!current)
         return false;
      if(current.Type() == defNeuronSCNNEncoder)
         if(!current.UpdateInputWeights(inputs))
            return false;
      inputs = current;
     }
//---
   return true;
  }

Anschließend durchläuft die Methode alle Schichten des Modells. Bei jeder Schleifeniteration wird der Schichttyp überprüft. Relevant sind hier nur SCNN-Encoder, da sie die Komponenten sind, die trainierbare Parameter enthalten. Wenn die aktuelle Schicht diesem Typ entspricht, wird die Methode UpdateInputWeights aufgerufen, und die Eingabedaten werden an sie übergeben. Dadurch können die Gewichte auf der Grundlage des berechneten Gradienten korrekt angepasst werden. Wenn die Aktualisierung erfolgreich ist, ersetzen wir den Zeiger in der Variablen inputs, damit er als Eingabe für die nächste Schicht dienen kann. Dadurch bleibt die für den Vorwärtsdurchlauf und den Rückwärtsdurchlauf charakteristische logische Kontinuität erhalten.

Die Methode updateInputWeights schließt die Trainingsschleife ab und ermöglicht es jedem Encoder, sich an die Fehler des Modells anzupassen und zum gesamten Optimierungsprozess beizutragen. Alles ist sauber und pragmatisch umgesetzt, ohne unnötige Komplexität, und entspricht dabei voll und ganz der Logik der Architektur.

Der vollständige Code für die Klasse CNeuronSCNN und alle ihre Methoden ist im Anhang enthalten.



Modellarchitektur

Nachdem wir die Logik hinter den Objekten beschrieben haben, die zum Aufbau des SCNN-Frameworks verwendet werden, ist der nächste naheliegende Schritt, uns eingehender mit der Architektur des Modells selbst zu befassen, da diese Architektur darüber entscheidet, wie genau und robust das System Muster aus den Daten extrahieren kann. Hier nimmt der Aufbau der Pipeline-Konfiguration des neuronalen Netzes in der Methode `CreateDescriptions` Gestalt an.

Diese Methode ist für die Erstellung und das Befüllen von Schichtbeschreibungen zuständig – der Bausteine, aus denen anschließend das Berechnungsmodell gebildet wird. Einfach ausgedrückt: Hier werden die Schichten des künftigen neuronalen Netzes Stein für Stein angelegt – von der Eingabedatenschicht über die Encoder bis hin zu den Prognosezweigen. Zu Beginn sehen wir die Erstellung und Initialisierung von sechs Containern: einen für den Encoder des Hauptumgebungszustands, drei für verschiedene Varianten der Prognosemodelle und zwei für die Actor- und Critic-Zweige, die bei Reinforcement-Learning-Aufgaben zum Einsatz kommen.

Der Encoder des Umgebungszustands selbst beginnt mit einer einfachen, vollverbundenen Schicht, die einen aus historischen Bars erzeugten Eingabedatenvektor empfängt.

bool CreateDescriptions(CArrayObj *&encoder,
                        CArrayObj *&forecast1,
                        CArrayObj *&forecast2,
                        CArrayObj *&forecast3,
                        CArrayObj *&actor,
                        CArrayObj *&critic
                       )
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!forecast1)
     {
      forecast1 = new CArrayObj();
      if(!forecast1)
         return false;
     }
   if(!forecast2)
     {
      forecast2 = new CArrayObj();
      if(!forecast2)
         return false;
     }
   if(!forecast3)
     {
      forecast3 = new CArrayObj();
      if(!forecast3)
         return false;
     }
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }
//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   uint prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Anschließend wird eine Batch-Normalisierungsschicht hinzugefügt, in die während der Trainingsphase Rauschen eingebracht wird; dies wirkt als Regularisierer und verbessert die Robustheit des Modells gegenüber Rauschen in den Daten.

//--- Layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormWithNoise;
   descr.count = prev_count;
   descr.batch = BatchSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatDiff;
   prev_count = descr.count = HistoryBars;
   descr.layers = BarDescr;
   descr.step = 1;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Als Nächstes folgt die Konkatenationsschicht CNeuronConcatDiff für Differenzmerkmale erster Ordnung, die dem Netzwerk hilft, lokale Veränderungen besser zu erfassen.

Die Schicht CMamba4CastEmbeding verdient als modernes Architekturelement besondere Beachtung. Sie extrahiert verborgene Merkmale, indem sie mehrere Zeitfenster (in diesem Fall tägliche und monatliche) berücksichtigt, und erzeugt so Embeddings, die zeitliche Harmonische berücksichtigen. An dieser Stelle denkt das neuronale Netzwerk erstmals über Saisonalität und langfristige Trends nach.

//--- Layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defMamba4CastEmbeding;
   prev_count = descr.count = HistoryBars;
   descr.window = 2 * BarDescr;
   uint prev_out = descr.window_out = NSkills;
     {
      uint temp[] = {PeriodSeconds(PERIOD_D1), PeriodSeconds(PERIOD_MN1)};
      if(ArrayCopy(descr.windows, temp) < (int)temp.Size())
         return false;
     }
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- Layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = prev_count;
   prev_count = descr.window = prev_out;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   prev_out = descr.count;

Anschließend werden die Embeddings in univariate Sequenzdarstellungen transponiert.

Den krönenden Abschluss des gesamten Environment State Encoder-Designs bildet die Integration des zuvor beschriebenen Moduls – des Stacks von SCNN-Encodern. Gerade dieser Block – der mit vier Verschachtelungsebenen aufgebaut und sorgfältig darauf abgestimmt ist, saisonale und kurzfristige Muster zu erkennen – bildet den Kern der intelligenten Informationsverarbeitung.

//--- Layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSCNN;
   descr.variables = prev_count;
   {
      uint temp[]={prev_out,NForecast,SeasonPeriod,ShortPeriod};
      if(ArrayCopy(descr.windows,temp)<(int)temp.Size())
        return false;
   }
   descr.count=descr.windows[0]+descr.windows[1];
   descr.layers=4;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
   uint variables=descr.variables;
   uint count=descr.count;

Hier findet mehr statt als nur eine einfache Datenumwandlung. Dieses Modul übernimmt die zentrale analytische Last: Es extrahiert nicht nur Merkmale, sondern strukturiert sie auch zu mehrschichtigen verborgenen Darstellungen, von denen jede die beobachteten Muster auf ihrem eigenen Maßstab verallgemeinert. Am Ausgang erhalten wir Prognosevektoren, die bereits mit Residualverbindungen und dem Gedächtnis früherer Stufen angereichert wurden.

Im Wesentlichen werden in dieser Komponente rohe Eingabedaten in eine höherstufige Beschreibung des Marktzustands umgewandelt. Hier werden abstrakte Verhaltensmuster in numerische Strukturen umgewandelt, die entweder von Prognosemodulen oder von den Steuerungszweigen des Modells interpretiert werden können. Und genau an diesem Punkt wird die Architektur vollständig – sie vollzieht den Übergang von der Aufbereitung und Normalisierung hin zur Analyse, Interpretation und Entscheidungsfindung.

An dieser Stelle sollte ein wichtiger technischer Aspekt hervorgehoben werden. Am Ausgang des Stacks von SCNN-Encodern erhalten wir ein Array aus univariaten Sequenzen – dies ist für nachfolgende Prognosemodelle praktisch und logisch. Es eignet sich ideal zur Erstellung von Prognosen über einen vorgegebenen Prognosehorizont. Es stellt sich jedoch heraus, dass dies im Kontext der Analyse, die die Module Actor und Critic durchführen werden, nicht ganz angemessen ist.

Tatsache ist, dass der Agent, um Entscheidungen treffen zu können, nicht nur jede einzelne Prognose kennen muss, sondern sie auch als zusammenhängende zeitliche Struktur verstehen muss – als eine Art multimodale Dynamik, die sowohl kurzfristige als auch langfristige Aspekte des Systemverhaltens umfasst. Dies erfordert eine andere Art der Datenorganisation: Die Zeitschritte müssen sequenziell geordnet werden, wobei ihre Wechselbeziehungen erhalten bleiben.

Genau aus diesem Grund fügen wir eine weitere Transpositionsschicht hinzu. Dieser Schritt wandelt unseren Tensor aus einer Menge univariater Prognosen in ein Format um, das den Zeitschritten einer multimodalen Sequenz entspricht. Jeder Zeitpunkt hat Zugriff auf alle Prognosevariablen, die aus verschiedenen Kanälen erfasst wurden. Dadurch können der Actor und der Critic die Dynamik der Umgebung im Zeitverlauf als zusammenhängendes Ganzes wahrnehmen und nicht als isolierte Fragmente.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronTransposeOCL;
   descr.count = variables;
   prev_count = descr.window = count;
   descr.batch = BatchSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

Somit vervollständigt diese letzte Transformationsschicht die Encoder-Architektur und spielt eine Schlüsselrolle dabei, eine geeignete Schnittstelle zwischen dem Prognosemechanismus und den Entscheidungsmodulen sicherzustellen.

Die Architektur der Prognose- und Entscheidungsmodelle wurde nahezu unverändert aus unseren früheren Projekten übernommen. Diese Komponenten haben sich bereits bewährt, daher werden wir in diesem Artikel nicht näher darauf eingehen. Denjenigen, die ein tieferes Verständnis der Architekturlösungen erlangen möchten, empfehlen wir, die im Anhang enthaltenen Unterlagen durchzusehen.

Darüber hinaus enthält der Anhang den Quellcode der Programme, die zum Trainieren und Testen der Modelle verwendet wurden. Anhand dieser Materialien lässt sich nicht nur die interne Logik hinter der Konzeption des Systems nachvollziehen, sondern auch der vollständige experimentelle Zyklus reproduzieren.



Tests

Der Trainingsprozess des Modells besteht aus zwei aufeinanderfolgenden Phasen. In der ersten, der Offline-Phase, wurde das Modell anhand historischer Daten für das Währungspaar EURUSD im H1-Zeitrahmen für das gesamte Jahr 2024 trainiert. Dieser Zeitraum umfasste eine Vielzahl unterschiedlicher Marktszenarien. Dank der Vielfalt der Daten war das Modell in der Lage, sowohl typische als auch seltene Marktsituationen zu bewältigen.

Nach Abschluss des Offline-Trainings gingen wir zur zweiten Phase über – der Online-Feinabstimmung, die unter Bedingungen erfolgte, die denen des realen Marktes so nahe wie möglich kamen. Das Training fand im MetaTrader-5-Strategietester statt, wo das Modell die kontinuierlich eingehenden Daten der Bars Schritt für Schritt analysierte. Dadurch war es nicht nur möglich, die Robustheit des Modells gegenüber Rauschen und Marktverzerrungen zu testen, sondern auch sicherzustellen, dass es sich an veränderte Bedingungen anpassen lässt. Dieser Ansatz verbesserte die Robustheit des Modells erheblich, minimierte das Risiko von Overfitting und steigerte seine Generalisierungsfähigkeit.

Der Prozess endete mit einem Test des Modells anhand völlig neuer Daten – Kursdaten für den Zeitraum von Januar bis März 2025. Alle während des Trainings ermittelten Parameter und Einstellungen wurden unverändert gespeichert. Somit liefern die erzielten Ergebnisse eine objektive Bewertung sowohl der Genauigkeit als auch der praktischen Zuverlässigkeit der vorgeschlagenen Methode. Die Testergebnisse sind im Folgenden aufgeführt.

In einem dreimonatigen Testzeitraum verzeichnete unser Modell einen Kapitalzuwachs von 100 $ auf etwa 430 $, wobei der durchschnittliche Gewinn pro Trade (13,17 $) den durchschnittlichen Verlust (11,71 $) überstieg. Der Anteil der gewinnbringenden Trades liegt bei knapp 48 %, und ein Profitfaktor von etwa 1,04 deutet darauf hin, dass das System mit einem leichten Vorteil auf der Gewinnseite arbeitet.

Gleichzeitig lag der maximale Drawdown bei über 82 %, wobei der stärkste Rückgang in der zweiten Märzdekade zu verzeichnen war. Dies war der Zeitraum mit dem größten zeitlichen Abstand zur Trainingsstichprobe. Dies deutet darauf hin, dass das Modell bei neuen Marktbedingungen Schwierigkeiten hat und nicht robust genug ist, um unerwartete Volatilitätsspitzen zu bewältigen.

Insgesamt hat der Test gezeigt, dass die SCNN-Architektur in der Lage ist, Gewinne zu erzielen und ein Gleichgewicht zwischen Risiko und Rendite aufrechtzuerhalten; für die praktische Anwendung sind jedoch zusätzliche Risikomanagementmechanismen sowie eine längere Trainingsphase erforderlich. Dadurch wird das Modell nicht nur in der Lage sein, in einem vertrauten Markt konsistent zu arbeiten, sondern sich auch sicherer an neue, unvorhersehbare Bedingungen anzupassen.



Fazit

In diesem Artikel haben wir die Entwicklung und die praktischen Tests eines SCNN-Modells für die Zeitreihenprognose abgeschlossen. Es wurde viel Arbeit geleistet: von der Untersuchung des theoretischen Konzepts der Zerlegung von Zeitreihen über den Aufbau eines vollständig trainierten Stacks aus Encodern bis hin zur Integration der vorgeschlagenen Ansätze in die Actor-Critic-Architektur.

Ein Backtest anhand von Daten für das Währungspaar EURUSD von Januar bis März 2025 bestätigte die Fähigkeit des Modells, Gewinne zu erzielen und ausgewogene Prognosen zu liefern; er zeigte jedoch auch eine Anfälligkeit gegenüber starken Marktveränderungen außerhalb der Trainingsstichprobe auf. Die tiefen Drawdowns im März unterstreichen die Notwendigkeit weiterer Arbeiten zur Optimierung des Modells.


Literaturverzeichnis


In diesem Artikel verwendete Programme

# Name Typ Beschreibung
1 Study.mq5 Expert Advisor Expert Advisor für das Offline-Modelltraining
2 StudyOnline.mq5 Expert Advisor Expert Advisor für Online-Modelltraining
3 Test.mq5 Expert Advisor Expert Advisor für Modelltests
4 Trajectory.mqh Klassenbibliothek Struktur zur Beschreibung des Systemzustands und der Modellarchitekturen
5 NeuroNet.mqh Klassenbibliothek Klassenbibliothek zum Aufbau eines neuronalen Netzes
6 NeuroNet.cl Bibliothek Code-Bibliothek für ein OpenCL-Programm


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

Beigefügte Dateien |
MQL5.zip (2956.64 KB)
Neuronale Netze im Trading: Zerlegen statt Skalieren (Fazit) Neuronale Netze im Trading: Zerlegen statt Skalieren (Fazit)
Wir laden Sie ein, einen Algorithmus kennenzulernen, mit dem eine Zeitreihe in aussagekräftige Schichten zerlegt und daraus ein sparsames Modell aufgebaut wird. Wir stellen systematisch die Architektur, die praktische Umsetzung in MQL5/OpenCL sowie Praxistests unter Verwendung historischer Marktdaten vor.
Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (Encoder) Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (Encoder)
Wir laden Sie ein, die nächste Stufe bei der Implementierung des SCNN-Frameworks zu erkunden, das Flexibilität und Interpretierbarkeit vereint und es ermöglicht, strukturelle Komponenten einer Zeitreihe präzise zu identifizieren. Der Artikel enthält eine ausführliche Erläuterung der Mechanismen der adaptiven Normalisierung und des Attention-Mechanismus, die die Robustheit des Modells gegenüber sich ändernden Marktbedingungen gewährleisten.
Marktsimulation: Positionsanzeige (VI) Marktsimulation: Positionsanzeige (VI)
In diesem Artikel werden wir eine Reihe von Verbesserungen vornehmen, um sicherzustellen, dass der Positionsindikator den tatsächlichen Zustand auf dem Handelsserver in Bezug auf offene Positionen und ihren aktuellen Zustand genau widerspiegelt. Ich möchte darauf hinweisen, dass die hier gezeigten Anwendungen keineswegs dazu gedacht sind, eines der in MetaTrader 5 verfügbaren Elemente zu ersetzen. Sie sollten zudem nicht ohne die gebotene Vorsicht und ohne Augenmaß verwendet werden, da ihr Zweck darin besteht, Lehrcode bereitzustellen – also Code, der ausschließlich dazu dient, die Funktionsweise des Systems zu erlernen. Ich bezeichne diesen Code als „Lehrcode“, weil die Verwendung von Nachrichten in manchen Fällen nicht die beste Methode ist, um bestimmte Funktionen zu implementieren.
Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (SCNN) Neuronale Netze im Trading: Zerlegung strukturierter Komponenten (SCNN)
Wir laden Sie ein, das innovative SCNN-Framework kennenzulernen, das die Zeitreihenanalyse auf eine neue Ebene hebt, indem es die Daten klar in langfristige, saisonale, kurzfristige und Residualkomponenten unterteilt. Dieser Ansatz verbessert die Prognosegenauigkeit erheblich, da er es dem Modell ermöglicht, sich an komplexe und sich verändernde Marktdynamiken anzupassen.