English Русский 中文 Español 日本語 Português
preview
Neuronale Netze leicht gemacht (Teil 22): Unüberwachtes Lernen von rekurrenten Modellen

Neuronale Netze leicht gemacht (Teil 22): Unüberwachtes Lernen von rekurrenten Modellen

MetaTrader 5Integration | 17 Oktober 2022, 09:23
204 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Inhalt


    Einführung

    Die letzten beiden Artikel unserer Serie waren den Autoencoders gewidmet. Ihre Architektur ermöglicht es, verschiedene Modelle neuronaler Netze mit Hilfe des Backpropagation-Algorithmus auf nicht gekennzeichneten Daten zu trainieren. Das Modell lernt, die Ausgangsdaten zu komprimieren und dabei die wichtigsten Merkmale auszuwählen. Unsere Experimente haben die Wirksamkeit der Autoencoder-Modelle bestätigt. Achten Sie darauf, dass wir vollständig verbundene neuronale Schichten zum Trainieren von Autoencodern verwendet haben. Solche Modelle arbeiten mit einem festen Eingangsdatenfenster. Der von uns entwickelte Algorithmus kann beliebige Modelle trainieren, die mit einem festen Eingangsdatenfenster arbeiten. Aber die Architektur von rekurrenten Modellen ist anders. Um eine Entscheidung über die Aktivierung der Neuronen zu treffen, verwenden solche Modelle zusätzlich zu den Ausgangsdaten auch deren vorherigen Zustand. Diese Eigenschaft sollte bei der Erstellung eines Autoencoders berücksichtigt werden.


    1. Merkmale des Trainings rekurrenter Modelle

    Erinnern wir uns zunächst an die Organisation der wiederkehrenden Modelle und ihren Zweck. Werfen wir einen Blick auf das Kurschart. Es zeigt historische Daten der Kursentwicklung an. Jeder Balken beschreibt die Grenzen des Bereichs, in dem sich der Preis des Symbols in einem bestimmten Zeitintervall bewegt hat. Beachten Sie, dass es sich hierbei um „historische Daten“ handelt. Das bedeutet, dass sie sich nicht ändern werden. Mit der Zeit erscheinen neue Balken, aber die alten ändern sich nicht. Zu jedem bestimmten Zeitpunkt haben wir unveränderte historische Daten und eine letzte Kerze, die noch nicht vollständig ausgebildet ist und sich bis zum Ende ihres Zeitintervalls noch ändern kann.

    Preischart

    Durch die Analyse historischer Daten versuchen wir, die wahrscheinlichste zukünftige Kursentwicklung vorherzusagen. Die Tiefe der analysierten Historie ist von Fall zu Fall unterschiedlich. Dies ist wahrscheinlich eines der Hauptprobleme im Zusammenhang mit der Verwendung neuronaler Netze mit einer festen Ausgangsdatenmenge. Kleine historische Datenfenster schränken die Möglichkeiten der Analyse ein. Übermäßig große Fenster erschweren das Modell und dessen Lernen. Daher muss der Architekt eines solchen Modells bei der Wahl der Größe des Eingangsdatenfensters einen Kompromiss eingehen und die „goldene Mitte“ bestimmen.

    Andererseits haben wir es mit historischen Daten zu tun. Unabhängig von der gewählten Fenstergröße werden bei jeder Iteration des Modells mehr als 99 % der Informationen an das Modell zurückgesandt. Das Modell wird diese Daten dann erneut verarbeiten. Das sieht nicht nach einer effizienten Nutzung der Ressourcen aus. Aber weder vollständig vernetzte noch faltige Modelle merken sich etwas über zuvor verarbeitete Informationen.

    Die oben genannten Probleme können durch den Einsatz von rekurrenten Netzen gelöst werden. Die Idee ist die folgende. Der Zustand der einzelnen Neuronen hängt vom Ergebnis der Verarbeitung der Quelldaten ab. Daher können wir davon ausgehen, dass der Zustand des Neurons eine komprimierte Form der Quelldaten ist. Daher können wir die Quelldaten zusammen mit dem vorherigen Zustand in das Neuron einspeisen. Der neue Zustand des Neurons hängt also sowohl vom aktuellen Zustand des Systems, das wir analysieren, als auch vom vorherigen Zustand ab, dessen Informationen im vorherigen Zustand des Neurons komprimiert sind. 

    rekurrentes Modell

    Dieser Ansatz ermöglicht es dem Modell, mehrere Zustände des Systems zu speichern. Durch die Verwendung von Aktivierungsfunktionen und Gewichtskoeffizienten mit einem Absolutwert von weniger als 1 wird der Einfluss der frühesten historischen Daten schrittweise reduziert. Als Ergebnis haben wir ein Modell mit einem ziemlich vorhersehbaren Speicherhorizont.

    Durch die Verwendung solcher Modelle mit Gedächtnis sind wir nicht auf das für die Entscheidungsfindung verwendete historische Datenfenster beschränkt. Außerdem verringern wir die Menge der erneut übermittelten Informationen, da sich das Modell bereits an sie erinnert. Aufgrund dieser Vorteile können rekurrente Modelle als einer der vorrangigen Bereiche bei der Lösung von Zeitreihenverarbeitungsproblemen angesehen werden.

    Die Verwendung dieser Merkmale erfordert jedoch spezielle Trainingsansätze für rekurrente Modelle. Um auf die Architektur von Autoencoder zurückzukommen: Wenn wir beispielsweise die Eingabe Xi und die Ausgabe Yi des Modells in der obigen Abbildung gleichsetzen, müssen wir uns nicht an den vorherigen Zustand erinnern, um die ursprünglichen Daten aus dem latenten Zustand wiederherzustellen. Daher wird das Modell den Einfluss historischer Daten während des Trainingsprozesses aufheben. Es wird nur der aktuelle Zustand ausgewertet. Verliert das rekurrente Modell seine Fähigkeit, sich zu erinnern, verliert es seinen Hauptvorteil.

    Bei der Entwicklung unserer Modellarchitektur müssen wir diese Tatsache also berücksichtigen. Der Lernprozess sollte so organisiert sein, dass das Modell gezwungen ist, auf die Daten früherer Iterationen zuzugreifen.

    Bei der Konstruktion von Autoencodern spiegelt die Decoderarchitektur in den meisten Fällen fast die Encoderarchitektur wider. Diese Praxis wird auch bei der Arbeit mit wiederkehrenden Modellen beibehalten. Merkwürdigerweise wurde eine der ersten derartigen Architekturen für das überwachte Lernen verwendet. Die Autoren der Arbeit mit dem Titel „Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation“ haben den RNN Encoder-Decoder als Modell für die statistische maschinelle Übersetzung vorgeschlagen. Der Kodierer und Dekodierer dieses Modells waren rekurrente Netze. Der Encoder komprimiert die Phrase der Ausgangssprache auf einen bestimmten latenten Zustand. Der Decoder „entpackt“ sie dann in eine Phrase in der Zielsprache. Es ist einem Autoencoder sehr ähnlich, nicht wahr?

    Die Verwendung eines rekurrenten Modells ermöglichte es, eine Phrase wortweise an den Encoder zu übertragen, wodurch das Modell mit Phrasen unterschiedlicher Länge trainiert werden konnte. Nach Erhalt einer vollständigen Phrase übermittelte der Encoder den latenten Zustand an den Decoder. Der Decoder gab auch, ein Wort nach dem anderen, die Übersetzung des Satzes in der Zielsprache wieder.

    Nach dem Training mit gelabelten Phrasen in Englisch und Französisch erhielten die Autoren ein Modell, das semantisch und syntaktisch sinnvolle Phrasen liefert.

    Unüberwachtes Lernen von rekurrenten Modellen wird in dem Artikel „Unsupervised Learning of Video Representations using LSTMs“, der im Februar 2015 veröffentlicht wurde, gut dargestellt. Die Autoren des Artikels führten eine Reihe von Experimenten durch, in denen sie rekurrente Autocodierer mit verschiedenen Videomaterialien trainierten. Sie führen sowohl die Wiederherstellung der in den Encoder eingegebenen Daten als auch die Vorhersage der wahrscheinlichen Fortsetzung der Videosequenz durch.

    Der Artikel stellt verschiedene Architekturen von Autoencodern vor. Sie alle verwenden jedoch LSTM-Blöcke zur Signalcodierung und -decodierung. Die besten Ergebnisse wurden beim Training des Modells mit einem Encoder und zwei Decodern erzielt. Ein Decoder war für die Wiederherstellung der ursprünglichen Daten zuständig, während der zweite Decoder die wahrscheinlichste Fortsetzung der Videosequenz vorhersagte.

    Die Verwendung von rekurrenten Blöcken im Encoder ermöglicht die Bild-für-Bild-Übertragung des Originalvideos in das Modell. Je nach Aufgabe geben die rekurrenten Decoderblöcke die rekonstruierte oder vorhergesagte Videosequenz Bild für Bild zurück.

    Darüber hinaus zeigen die Autoren des Artikels, dass rekurrente Modelle, die mit unüberwachten Algorithmen trainiert wurden, nach zusätzlichem Training mit überwachten Algorithmen recht gute Ergebnisse bei Aufgaben im Zusammenhang mit der Bewegungserkennung auf Video liefern, selbst bei einer relativ kleinen Menge an markierten Daten.

    Die in diesen beiden Artikeln vorgestellten Materialien legen nahe, dass ein solcher Ansatz bei der Lösung unserer Probleme erfolgreich sein kann.

    Allerdings werde ich bei meiner Umsetzung ein wenig von den vorgeschlagenen Modellen abweichen. Alle verwendeten rekurrente Blöcke im Decoder und gaben die dekodierten Daten Frame für Frame zurück. Dies entsprach vollständig den Übersetzungs- und Videoanalyseaufgaben. Dies kann zu guten Ergebnissen bei der Vorhersage des nächsten Taktes führen. Aber ich habe noch keine derartigen Experimente durchgeführt. Im Allgemeinen wird bei der Analyse der Marktsituation ein Gesamtbild über einen längeren Zeitraum erstellt. Deshalb werden wir Veränderungen der Marktsituation schrittweise und in kleinen Portionen auf das Modell übertragen. Das Modell sollte dann die Situation unter Berücksichtigung der aktuellen und der zuvor erhaltenen Daten bewerten. Dies bedeutet, dass der latente Zustand Informationen über ein möglichst großes Zeitintervall enthalten sollte.

    Um diesen Effekt zu erzielen, werden wir nur rekurrente Blöcke im Encoder verwenden. Im Decoder werden wir ebenfalls voll verknüpfte neuronale Schichten verwenden, während wir die an den Encoder übertragenen Daten in mehreren Iterationen wiederherstellen.


    2. Umsetzung

    Kommen wir nun zum praktischen Teil unseres Artikels. Wir werden unseren rekurrenten Kodierer auf der Grundlage der zuvor besprochenen LSTM-Blöcke aufbauen, deren Struktur in der folgenden Abbildung dargestellt ist. Der Block besteht aus 4 vollständig verbundenen neuronalen Schichten. Drei von ihnen haben die Funktion von Türfilter, die den Informationsfluss regulieren. Die vierte transformiert die Quelldaten.

    Der LSTM-Block verwendet 2 rekurrente Informationsflüsse: Speicher und verborgener Zustand.

    LSTM-Block-Struktur

    Wir haben den LSTM-Block-Algorithmus zuvor mit MQL5 neu erstellt. Jetzt wiederholen wir es mit der OpenCL-Technologie. Um den Algorithmus zu implementieren, erstellen wir die neue Klasse CNeuronLSTMOCL. Wir leiten die wichtigsten Puffer und Methoden von der Basisklasse CNeuronBaseOCL ab, die wir als übergeordnete Klasse verwenden werden.

    Die Struktur der Methoden und Klassenvariablen wird im Folgenden dargestellt. Die Methoden der Klasse sind leicht zu erkennen: Es sind die Vorwärts- und Rückwärtsmethoden, die wir in jeder neuen Klasse überschreiben. Der Zweck der Variablen muss erläutert werden.

    class CNeuronLSTMOCL : public CNeuronBaseOCL
      {
    protected:
       CBufferFloat      m_cWeightsLSTM;
       CBufferFloat      m_cFirstMomentumLSTM;
       CBufferFloat      m_cSecondMomentumLSTM;
    
       int               m_iMemory;
       int               m_iHiddenState;
       int               m_iConcatenated;
       int               m_iConcatenatedGradient;
       int               m_iInputs;
       int               m_iWeightsGradient;
    //---
       virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
       virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override;
    
    public:
                         CNeuronLSTMOCL(void);
                        ~CNeuronLSTMOCL(void);
    //---
       virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl,
                              uint numNeurons, ENUM_OPTIMIZATION optimization_type,
                              uint batch) override;
    //---
       virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);
    //---
       virtual bool      Save(int const file_handle) override;
       virtual bool      Load(int const file_handle) override;
    //---
       virtual int       Type(void) override const   {  return defNeuronLSTMOCL; }
      };
    
    

    Zunächst einmal sehen wir hier 3 Datenpuffer:

    • m_cWeightsLSTM — eine Matrix der Gewichtskoeffizienten des LSTM-Blocks
    • m_cFirstMomentumLSTM — eine Matrix des ersten Impulses für die Aktualisierung der Gewichte
    • m_cSecondMomentumLSTM — eine Matrix des zweiten Impulses für die Aktualisierung der Gewichte

      Bitte beachten Sie die folgenden Hinweise. Wie bereits erwähnt, enthält der LSTM-Block 4 vollständig verbundene neuronale Schichten. Gleichzeitig deklarieren wir nur einen Puffer für die Gewichtsmatrix m_cWeightsLSTM. Dieser Puffer enthält die Gewichte aller 4 neuronalen Schichten. Die Verwendung eines verketteten Puffers ermöglicht es uns, alle 4 neuronalen Schichten gleichzeitig zu parallelisieren. Wir werden den Mechanismus zur Organisation der Parallelität etwas später genauer betrachten, wenn wir uns mit der Implementierung der einzelnen Methoden befassen.

      Das Gleiche gilt für Impulspuffer m_cFirstMomentumLSTM und m_cSecondMomentumLSTM.

      In den letzten Terminal-Builds, MetaQuotes GmbH eine Reihe von Verbesserungen implementiert. Sie wirkten sich auch auf die von uns verwendete OpenCL-Technologie aus. Insbesondere wurde die maximale Anzahl der möglichen OpenCL-Objekte erhöht und die Möglichkeit geschaffen, die Technologie auf Grafikkarten ohne doppelte Unterstützung zu verwenden. Dadurch verringert sich die Gesamtzeit, die für das Trainieren des Modells erforderlich ist, da die Daten nicht mehr vor dem Aufruf jedes Kernels aus dem CPU-Speicher geladen und nach dessen Ausführung wieder entladen werden müssen. Es reicht aus, alle Ausgangsdaten einmal in den OpenCL-Kontextspeicher zu laden, bevor der Trainingsprozess beginnt, und das Ergebnis nach dem Ende des Trainings zu kopieren.  

      Darüber hinaus ermöglicht es uns, einige Puffer nur im Kontext von OpenCL zu deklarieren, ohne einen Spiegelpuffer im Hauptspeicher des Geräts zu erstellen. Dies bezieht sich auf Puffer zur Speicherung temporärer Informationen. Daher werden wir für eine Reihe von Puffern nur eine Variable erstellen, um einen Zeiger auf den Puffer im OpenCL-Kontext zu speichern:

      • m_iMemory — ein Zeiger auf den Speicherpuffer
      • m_iHiddenState — ein Zeiger auf den Puffer für den verborgenen Zustand
      • m_iConcatenated — ein Zeiger auf den konkatenierten Ergebnispuffer von vier internen neuronalen Schichten
      • m_iConcatenatedGradient — ein Zeiger auf den verketteten Puffer der Fehlergradienten auf der Ebene der Ergebnisse von vier internen neuronalen Schichten
      • m_iWeightsGradient — ein Zeiger auf den Puffer der Fehlergradienten auf der Ebene der Gewichtsmatrix von vier internen neuronalen Schichten

      Wir weisen allen Variablen im Klassenkonstruktor Anfangswerte zu.

      CNeuronLSTMOCL::CNeuronLSTMOCL(void)   :  m_iMemory(-1),
                                                m_iConcatenated(-1),
                                                m_iConcatenatedGradient(-1),
                                                m_iHiddenState(-1),
                                                m_iInputs(-1)
        {}
      

      Im Destruktor der Klasse geben wir alle verwendeten Puffer frei.

      CNeuronLSTMOCL::~CNeuronLSTMOCL(void)
        {
         if(!OpenCL)
            return;
         OpenCL.BufferFree(m_iConcatenated);
         OpenCL.BufferFree(m_iConcatenatedGradient);
         OpenCL.BufferFree(m_iHiddenState);
         OpenCL.BufferFree(m_iMemory);
         OpenCL.BufferFree(m_iWeightsGradient);
         m_cFirstMomentumLSTM.BufferFree();
         m_cSecondMomentumLSTM.BufferFree();
         m_cWeightsLSTM.BufferFree();
        }
      

      Fahren wir mit der Implementierung der Methoden unserer Klasse fort und erstellen wir eine Methode zur Initialisierung des Objekts unseres LSTM-Blocks. Den Regeln der Vererbung folgend, überschreiben wir die Methode CNeuronLSTMOCL::Init unter Beibehaltung der Parameter einer ähnlichen Methode der Elternklasse. Die Initialisierungsmethode erhält als Parameter die Anzahl der Neuronen der nächsten Schicht, den Index des Neurons, den Zeiger auf das OpenCL-Kontextobjekt, die Anzahl der Neuronen der aktuellen Schicht, die Parameteroptimierungsmethode und die Stapelgröße.

      Im Methodenkörper rufen wir zunächst eine ähnliche Methode der übergeordneten Klasse auf. So werden wir die geerbten Objekte der Elternklasse initialisieren und die empfangenen Ausgangsdaten kontrollieren. Vergessen Sie nicht, die Ergebnisse der Ausführung der Operation zu überprüfen.

      Als Nächstes müssen wir die oben angegebenen Datenpuffer initialisieren. In diesem Stadium können wir nicht alle Puffer vollständig initialisieren, da wir nicht über die erforderlichen Quelldaten verfügen. In den Parametern erhalten wir die Anzahl der Neuronen in der aktuellen Schicht und die Anzahl der Neuronen in der nächsten Schicht. Aber wir kennen die Anzahl der Neuronen in der vorherigen Schicht nicht. Daher kennen wir die Größe des Puffers nicht, der für die Speicherung der Gewichte des LSTM-Blocks erforderlich ist. In diesem Stadium werden also nur die Datenpuffer erstellt, deren Größe nur von der Anzahl der Elemente in der aktuellen Ebene abhängt.

      bool CNeuronLSTMOCL::Init(uint numOutputs, uint myIndex,
                                COpenCLMy *open_cl, uint numNeurons,
                                ENUM_OPTIMIZATION optimization_type, uint batch)
        {
         if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
            return false;
      //---
         m_iMemory = OpenCL.AddBuffer(sizeof(float) * numNeurons * 2, CL_MEM_READ_WRITE);
         if(m_iMemory < 0)
            return false;
         m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * numNeurons, CL_MEM_READ_WRITE);
         if(m_iHiddenState < 0)
            return false;
         m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE);
         if(m_iConcatenated < 0)
            return false;
         m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * numNeurons * 4, CL_MEM_READ_WRITE);
         if(m_iConcatenatedGradient < 0)
            return false;
      //---
         return true;
        }
      

      Vergessen wir nicht, die Ergebnisse bei jedem Schritt zu kontrollieren.

      Nachdem wir die Objektinitialisierungsmethoden erstellt haben, gehen wir dazu über, einen Vorwärtsdurchlauf des LSTM-Blocks zu organisieren. Wir wissen, dass bei der Verwendung der OpenCL-Technologie die Berechnungen direkt im OpenCL-Kontext auf dem Grafikprozessor durchgeführt werden. Im Code des Hauptprogramms rufen wir nur das notwendige Programm auf. Bevor wir also eine Methode der Klasse schreiben, müssen wir unser OpenCL-Programm mit dem entsprechenden Kernel ergänzen.

      Der Kerrnel LSTM_FeedForward ist für die Organisation eines Feed-Forward-Durchgangs im OpenCL-Programm verantwortlich. Um den Prozess korrekt zu organisieren, müssen wir den Kernel mit Zeigern auf 5 Datenpuffer und eine Konstante versorgen:

      • inputs — Quelldatenpuffer:
      • inputs_size — Anzahl der Elemente im Quelldatenpuffer
      • weights — Gewichtsmatrix-Puffer
      • concatenated — konkatenierter Puffer mit den Ergebnissen aller internen Schichten
      • memory — Speicherpuffer
      • Ausgabe — Ergebnispuffer (dient auch als versteckter Zustandspuffer).

      __kernel void LSTM_FeedForward(__global float* inputs, uint inputs_size,
                                     __global float* weights,
                                     __global float* concatenated,
                                     __global float* memory,
                                     __global float* output
                                    )
        {
         uint id = (uint)get_global_id(0);
         uint total = (uint)get_global_size(0);
         uint id2 = (uint) get_local_id(1);
      
      

      Wir werden den Puffer in einem zweidimensionalen Aufgabenraum betreiben. In der ersten Dimension geben wir die Anzahl der Elemente im aktuellen LSTM-Block an. Die zweite Dimension ist gleich den vier Fäden durch die Anzahl der internen neuronalen Schichten. Vergessen wir nicht, dass die Anzahl der Elemente im LSTM-Block die Anzahl der Elemente in jeder der internen Schichten sowie die Anzahl der Elemente im Speicher und im verborgenen Zustand bestimmt.

      Daher bestimmen wir im Kernelkörper zunächst die Ordnungszahl des Threads in jeder Dimension. Wir bestimmen auch die Anzahl der Aufgaben in der ersten Dimension.

      Der gesamte LSTM-Block-Feedforward-Prozess kann bedingt in zwei Teilprozesse unterteilt werden:

      • Berechnung der Werte der internen neuronalen Schichten
      • Implementierung des Datenflusses von den neuronalen Schichten zum Ausgang des LSTM-Blocks

      Die Ausführung des zweiten Prozesses ist erst möglich, wenn der erste vollständig abgeschlossen ist. Dies liegt daran, dass für die Ausführung des zweiten Teilprozesses die Werte aller vier Neuronen benötigt werden, zumindest innerhalb des aktuellen LSTM-Blockelements. Daher benötigen wir die Synchronisierung von Daten-Threads entlang der zweiten Dimension. Die aktuelle OpenCL-Implementierung ermöglicht die Synchronisierung von Threads innerhalb einer lokalen Gruppe. Wir werden also unsere lokalen Gruppen entsprechend der zweiten Aufgabendimension aufbauen.

      Als Nächstes werden wir die Berechnung der gewichteten Summe der Quelldaten und des verborgenen Zustands implementieren. Berechnen wir zunächst die gewichtete Summe der verborgenen Zustände.

         float sum = 0;
         uint shift = (id + id2 * total) * (total + inputs_size + 1);
         for(uint i = 0; i < total; i += 4)
           {
            if(total - i > 4)
               sum += dot((float4)(output[i], output[i + 1], output[i + 2], output[i + 3]),
                          (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3]));
            else
               for(uint k = i; k < total; k++)
                  sum += output[k] + weights[shift + k];
           }
      
      

      Dann wird die gewichtete Summe der Ausgangsdaten addiert.

         shift += total;
         for(uint i = 0; i < inputs_size; i += 4)
           {
            if(total - i > 4)
               sum += dot((float4)(inputs[i], inputs[i + 1], inputs[i + 2], inputs[i + 3]),
                          (float4)(weights[shift + i], weights[shift + i + 1], weights[shift + i + 2], weights[shift + i + 3]));
            else
               for(uint k = i; k < total; k++)
                  sum += inputs[k] + weights[shift + k];
           }
         sum += weights[shift + inputs_size];
      
      

      Schließlich wird der Wert des Bias-Neurons hinzugefügt.

      Nach der Berechnung der gewichteten Summe müssen wir den Wert der Aktivierungsfunktion berechnen. Als Aktivierungsfunktion für den Türfilter wird Sigmoid verwendet. Für die neue Inhaltsebene wird ein Tangens hyperbolicus verwendet. Die erforderliche Aktivierungsfunktion wird durch den Thread-Identifikator in der zweiten Dimension bestimmt.

         if(id2 < 3)
            concatenated[id2 * total + id] = 1.0f / (1.0f + exp(sum));
         else
            concatenated[id2 * total + id] = tanh(sum);
      //---
         barrier(CLK_LOCAL_MEM_FENCE);
      
      

      Wie bereits erwähnt, ist für die korrekte Ausführung des Algorithmus eine Synchronisierung der Threads entlang der zweiten Dimension des Aufgabenraums erforderlich. Wir verwenden die Funktion barrier (Schranke), um die Threads zu synchronisieren.

      Um den Prozess der Informationsübertragung zwischen den internen Schichten zu implementieren, benötigen wir nur einen Thread für jedes Element des LSTM-Blocks. Daher wird nach der Synchronisierung der Threads der Prozess nur für den Thread mit der Thread-ID 0 in der zweiten Dimension des Aufgabenraums durchgeführt.

         if(id2 == 0)
           {
            float mem = memory[id + total] = memory[id];
            float fg = concatenated[id];
            float ig = concatenated[id + total];
            float og = concatenated[id + 2 * total];
            float nc = concatenated[id + 3 * total];
            //---
            memory[id] = mem = mem * fg + ig * nc;
            output[id] = og * tanh(mem);
           }
      //---
        }
      
      

      Damit ist die Arbeit mit dem Vorwärtspass-Kernel abgeschlossen. Jetzt kann es vom Hauptprogramm aus aufgerufen werden. Erstellen wir zunächst die erforderlichen Konstanten.

      #define def_k_LSTM_FeedForward            32
      #define def_k_lstmff_inputs               0
      #define def_k_lstmff_inputs_size          1
      #define def_k_lstmff_weights              2
      #define def_k_lstmff_concatenated         3
      #define def_k_lstmff_memory               4
      #define def_k_lstmff_outputs              5
      
      

      Dann können wir mit der Erstellung der Feed Forward Pass-Methode unserer Klasse beginnen. Ähnlich wie die gleiche Methode jeder anderen zuvor betrachteten Klasse, erhält diese Methode in den Parametern einen Zeiger auf das Objekt der vorherigen neuronalen Schicht. IN dem Methodenrumpf sollte der Zeiger sofort validiert werden.

      bool CNeuronLSTMOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
        {
         if(!NeuronOCL || NeuronOCL.Neurons() <= 0 ||
            NeuronOCL.getOutputIndex() < 0 || !OpenCL)
            return false;
      
      

      Bei der Initialisierung der Klasse konnten wir nicht alle Datenpuffer initialisieren, weil wir die Anzahl der Neuronen in der vorherigen Schicht nicht kannten. Jetzt haben wir den Zeiger auf die vorherige neuronale Schicht. Wir können also die Anzahl der Neuronen in dieser Schicht abfragen und die erforderlichen Datenpuffer erstellen. Vergewissern wir uns vorher, dass die Puffer aktuell sind. Dieser Feed-Forward-Methodenaufruf kann nicht der erste sein. Die Variable, die die Anzahl der Elemente in der vorherigen Ebene enthält, dient als eine Art Flag.

         if(m_iInputs <= 0)
           {
            m_iInputs = NeuronOCL.Neurons();
            int count = (int)((m_iInputs + Neurons() + 1) * Neurons());
            if(!m_cWeightsLSTM.Reserve(count))
               return false;
            float k = (float)(1 / sqrt(Neurons() + 1));
            for(int i = 0; i < count; i++)
              {
               if(!m_cWeightsLSTM.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
                  return false;
              }
            if(!m_cWeightsLSTM.BufferCreate(OpenCL))
               return false;
            //---
            if(!m_cFirstMomentumLSTM.BufferInit(count, 0))
               return false;
            if(!m_cFirstMomentumLSTM.BufferCreate(OpenCL))
               return false;
            //---
            if(!m_cSecondMomentumLSTM.BufferInit(count, 0))
               return false;
            if(!m_cSecondMomentumLSTM.BufferCreate(OpenCL))
               return false;
            if(m_iWeightsGradient >= 0)
               OpenCL.BufferFree(m_iWeightsGradient);
            m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * count, CL_MEM_READ_WRITE);
            if(m_iWeightsGradient < 0)
               return false;
           }
         else
            if(m_iInputs != NeuronOCL.Neurons())
               return false;
      
      

      Nach Abschluss der vorbereitenden Arbeiten übergeben wir Zeiger auf die Datenpuffer und den Wert der gewünschten Konstante an die Parameter des Feedforward-Kerns. Vergessen wir nicht, die Ausführung von Vorgängen zu kontrollieren.

         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_inputs, NeuronOCL.getOutputIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_concatenated, m_iConcatenated))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_FeedForward, def_k_lstmff_inputs_size, m_iInputs))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_memory, m_iMemory))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_outputs, getOutputIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_FeedForward, def_k_lstmff_weights, m_cWeightsLSTM.GetIndex()))
            return false;
      
      

      Als Nächstes definieren wir den Problemraum und die Verschiebung in ihm bis zur 1. In diesem Fall geben wir den Problemraum in zwei Dimensionen und die Größe der zu kombinierenden lokalen Gruppen in zwei Dimensionen an. Im ersten Fall geben wir die Gesamtzahl der aktuellen Schichtelemente in der 1. Dimension an. Bei einer lokalen Gruppe geben wir nur ein Element in der ersten Dimension an. In der zweiten Dimension geben wir in beiden Fällen vier Elemente an, je nach der Anzahl der internen neuronalen Schichten. Auf diese Weise können wir lokale Gruppen mit jeweils vier Threads bilden. Die Anzahl dieser lokalen Gruppen ist gleich der Anzahl der Elemente in der aktuellen neuronalen Schicht.

         uint global_work_offset[] = {0, 0};
         uint global_work_size[] = {Neurons(), 4};
         uint local_work_size[] = {1, 4};
      
      

      Durch die Synchronisierung der Threads in jeder lokalen Gruppe synchronisieren wir also die Berechnung der Werte aller vier internen neuronalen Schichten im Kontext jedes einzelnen Elements der aktuellen Schicht. Dies reicht aus, um die korrekte Berechnung des Vorwärtspasses des gesamten LSTM-Blocks durchzuführen.

      Als Nächstes stellen wir unseren Kernel in die Ausführungswarteschlange.

         if(!OpenCL.Execute(def_k_LSTM_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
            return false;
      //---
         return true;
        }
      
      

      Damit ist der Feedforward-Durchgang des LSTM-Blocks abgeschlossen, und wir können mit der Implementierung des Backpropagation-Durchgangs fortfahren. Wie im vorherigen Fall müssen wir das OpenCL-Programm ergänzen, bevor wir die Klassenmethoden erstellen. Mit dem Feed-Forward-Pass ist es uns gelungen, den gesamten Forward-Pass in einem Kernel zu vereinen. Dieses Mal brauchen wir drei Kernel.

      Im ersten Kernel LSTM_ConcatenatedGradientwird die Weitergabe des Gradienten zurück zu den Ergebnissen der internen Schicht implementiert. In Parametern erhält der Kernel Zeiger auf 4 Datenpuffer. Drei davon enthalten die Ausgangsdaten: den Puffer der Gradienten der nächsten Schicht, den Speicherstatus und den verketteten Puffer der Ergebnisse der internen neuronalen Schichten. Der vierte Puffer wird verwendet, um die Ergebnisse der Kernel-Operation zu schreiben.

      Der Kernel wird in einem eindimensionalen Problemraum entsprechend der Anzahl der Elemente in unserem LSTM-Block aufgerufen.

      Im Kernelkörper werden zunächst der Thread-Identifikator und die Gesamtzahl der Threads festgelegt. Entlang des Backpropagation-Pfads des Signals bestimmen wir dann den Fehlergradienten auf der Ergebnisebene des Ausgangsgatters, auf der Speicherebene, auf der Ebene der neuen neuronalen Inhaltsschicht und auf der Ebene des neuen Inhaltsfilter. Und dann wird der Fehler auf der Ebene des Vergessensfilter ermittelt.

      __kernel void LSTM_ConcatenatedGradient(__global float* gradient,
                                              __global float* concatenated_gradient,
                                              __global float* memory,
                                              __global float* concatenated
                                             )
        {
         uint id = get_global_id(0);
         uint total = get_global_size(0);
         float t = tanh(memory[id]);
         concatenated_gradient[id + 2 * total] = gradient[id] * t;             //output gate
         float memory_gradient = gradient[id] * concatenated[id + 2 * total];
         memory_gradient *= 1 - pow(t, 2.0f);
         concatenated_gradient[id + 3 * total] = memory_gradient * concatenated[id + total];         //new content
         concatenated_gradient[id + total] = memory_gradient * concatenated[id + 3 * total]; //input gate
         concatenated_gradient[id] = memory_gradient * memory[id + total];     //forget gate
        }
      
      

      Danach müssen wir den Fehlergradienten durch die inneren Schichten des LSTM-Blocks an die vorherige neuronale Schicht weitergeben. Erstellen wir dazu die Ebene LSTM_HiddenGradient. Bei der Entwicklung der OpenCL-Architektur des Programms habe ich beschlossen, die Gradientenverteilungen auf der Ebene der vorherigen Schicht und auf der Ebene der Gewichtsmatrix innerhalb dieses Kerns zu kombinieren. Der Kernel erhält also als Parameter Zeiger auf 6 Datenpuffer und 2 Konstanten. Der Kernel soll in einem eindimensionalen Problemraum aufgerufen werden. 

      __kernel void LSTM_HiddenGradient(__global float* concatenated_gradient,
                                        __global float* inputs_gradient,
                                        __global float* weights_gradient,
                                        __global float* hidden_state,
                                        __global float* inputs,
                                        __global float* weights,
                                        __global float* output,
                                        const uint hidden_size,
                                        const uint inputs_size
                                       )
        {
         uint id = get_global_id(0);
         uint total = get_global_size(0);
      
      

      Im Kernelkörper definieren wir den Thread-Identifikator und die Gesamtzahl der Threads und bestimmen auch die Größe eines Vektors der Gewichtsmatrix.

         uint weights_step = hidden_size + inputs_size + 1;
      
      

      Als Nächstes wird eine Schleife durch alle Elemente des verketteten Eingabedatenpuffers gezogen, die den versteckten Zustand und den aktuellen Zustand aus der vorherigen neuronalen Schicht enthält. Die Iteration der Schleife beginnt mit der aktuellen Thread-ID, wobei der Iterationsschritt der Gesamtzahl der laufenden Threads entspricht. Dieser Ansatz ermöglicht die Iteration über alle Elemente der verketteten Quelldatenschicht, unabhängig von der Anzahl der laufenden Threads.

         for(int i = id; i < (hidden_size + inputs_size); i += total)
           {
            float inp = 0;
      
      

      In diesem Schritt wird im Schleifenkörper die Aufteilung des Operationsthreads in Abhängigkeit von dem zu analysierenden Element vorgenommen. Wenn das Element zu einem verborgenen Zustand gehört, speichern wir den verborgenen Zustand in einer privaten Variablen. Der entsprechende Wert aus dem Ergebnispuffer sollte in den Puffer übertragen werden, da er sich bei der nächsten Iteration im verborgenen Zustand befinden wird.

            if(i < hidden_size)
              {
               inp = hidden_state[i];
               hidden_state[i] = output[i];
              }
      
      

      Wenn das aktuelle Element zum Eingangsdatenpuffer der vorherigen Neuronenschicht gehört, übertragen wir den Wert der Anfangsdaten in eine private Variable und berechnen den Fehlergradienten für das entsprechende Neuron der vorherigen Schicht.

            else
              {
               inp = inputs[i - hidden_size];
               float grad = 0;
               for(uint g = 0; g < 3 * hidden_size; g++)
                 {
                  float temp = concatenated_gradient[g];
                  grad += temp * (1 - temp) * weights[i + g * weights_step];
                 }
               for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++)
                 {
                  float temp = concatenated_gradient[g];
                  grad += temp * (1 - pow(temp, 2.0f)) * weights[i + g * weights_step];
                 }
               inputs_gradient[i - hidden_size] = grad;
              }
      
      

      Nach der Weitergabe des Fehlergradienten an die vorherige neuronale Schicht verteilen wir den Fehlergradienten auf die entsprechenden LSTM-Blockgewichte.

            for(uint g = 0; g < 3 * hidden_size; g++)
              {
               float temp = concatenated_gradient[g];
               weights[i + g * weights_step] = temp * (1 - temp) * inp;
              }
            for(uint g = 3 * hidden_size; g < 4 * hidden_size; g++)
              {
               float temp = concatenated_gradient[g];
               weights[i + g * weights_step] = temp * (1 - pow(temp, 2.0f)) * inp;
              }
           }
      
      

      Am Ende des Kerns wird der Fehlergradient auf die Bias-Neuronen der einzelnen Gewichtsvektoren übertragen.

         for(int i = id; i < 4 * hidden_size; i += total)
           {
            float temp = concatenated_gradient[(i + 1) * hidden_size];
            if(i < 3 * hidden_size)
               weights[(i + 1) * weights_step] = temp * (1 - temp);
            else
               weights[(i + 1) * weights_step] = 1 - pow(temp, 2.0f);
           }
        }
      
      

      Nachdem der Fehlergradient auf die vorherige Ebene der neuronalen Schicht und die Gewichtsmatrix übertragen wurde, müssen wir den Prozess der Gewichtsaktualisierung durchführen. Ich habe mich entschieden, nicht die gesamte Palette der Methoden zur Parameteroptimierung zu implementieren. Stattdessen werde ich die Adam-Methode anwenden, die ich am häufigsten verwende. In Analogie zu meiner Implementierung können Sie jede andere Methode zur Optimierung der Modellparameter hinzufügen.

      Die Modellparameter werden also im Kernel LSTM_UpdateWeightsAdam aktualisiert. Der Fehlergradient auf der Ebene der Gewichtsmatrix wurde bereits in der vorherigen Schicht berechnet und in den Puffer weights_gradient geschrieben. In diesem Kernel müssen wir also nur den Prozess der Aktualisierung der Modellparameter implementieren. Um den Prozess der Parameteraktualisierung nach der Adam-Methode zu implementieren, benötigen wir zwei zusätzliche Puffer, um den ersten und zweiten Impuls zu erfassen. Darüber hinaus benötigen wir Trainings-Hyperparameter. Diese Daten werden in den Kernel-Parametern übergeben.

      __kernel void LSTM_UpdateWeightsAdam(__global float* weights,       
                                           __global float* weights_gradient,
                                           __global float *matrix_m,        
                                           __global float *matrix_v,        
                                           const float l,                   
                                           const float b1,                  
                                           const float b2                   
                                          )
        {
         const uint id = get_global_id(0);
         const uint total = get_global_size(0);
         const uint id1 = get_global_id(1);
         const uint wi = id1 * total + id;
      
      

      Wie Sie wissen, ist die Gewichtsmatrix eine zweidimensionale Matrix. Daher werden wir den Kernel in einem zweidimensionalen Aufgabenraum aufrufen.

      Im Kernelkörper bestimmen wir die Ordnungszahl des Threads in beiden Dimensionen und die Gesamtzahl der in der ersten Dimension laufenden Threads. Mit diesen Konstanten bestimmen wir die Verschiebung in den Puffern auf das gewünschte Gewicht. Anschließend führen wir den Algorithmus aus, um das entsprechende Element der Gewichtsmatrix zu aktualisieren.

         float g = weights_gradient[wi];
         float mt = b1 * matrix_m[wi] + (1 - b1) * g;
         float vt = b2 * matrix_v[wi] + (1 - b2) * pow(g, 2);
         float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weights[wi]) + l2 * weights[wi] / total));
         weights[wi] = clamp(weights[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT);
         matrix_m[wi] = mt;
         matrix_v[wi] = vt;
        };
      
      

      Wir beenden hier die Änderungen am OpenCL-Programm und gehen zur Implementierung von Methoden am Rande des Hauptprogramms über.

      Wir erstellen zunächst Konstanten, die mit den oben erstellten Kerneln arbeiten.

      #define def_k_LSTM_ConcatenatedGradient   33
      #define def_k_lstmcg_gradient             0
      #define def_k_lstmcg_concatenated_gradient 1
      #define def_k_lstmcg_memory               2
      #define def_k_lstmcg_concatenated         3
      
      #define def_k_LSTM_HiddenGradient         34
      #define def_k_lstmhg_concatenated_gradient 0
      #define def_k_lstmhg_inputs_gradient      1
      #define def_k_lstmhg_weights_gradient     2
      #define def_k_lstmhg_hidden_state         3
      #define def_k_lstmhg_inputs               4
      #define def_k_lstmhg_weeights             5
      #define def_k_lstmhg_output               6
      #define def_k_lstmhg_hidden_size          7
      #define def_k_lstmhg_inputs_size          8
      
      #define def_k_LSTM_UpdateWeightsAdam      35
      #define def_k_lstmuw_weights              0
      #define def_k_lstmuw_weights_gradient     1
      #define def_k_lstmuw_matrix_m             2
      #define def_k_lstmuw_matrix_v             3
      #define def_k_lstmuw_l                    4
      #define def_k_lstmuw_b1                   5
      #define def_k_lstmuw_b2                   6
      
      

      Als Nächstes gehen wir zu den Methoden unserer Klasse über. Beginnen wir mit der Erstellung der Fehlergradienten-Backpropagation-Methode calcInputGradients. In den Parametern erhält die Methode einen Zeiger auf das Objekt der vorherigen neuronalen Schicht. Wir überprüfen sofort die Gültigkeit des empfangenen Zeigers.

      bool CNeuronLSTMOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
        {
         if(!NeuronOCL || NeuronOCL.Neurons() <= 0 || NeuronOCL.getGradientIndex() < 0 ||
            NeuronOCL.getOutputIndex() < 0 || !OpenCL)
            return false;
      
      

      Überprüfung der Verfügbarkeit der erforderlichen Datenpuffer im OpenCL-Kontext.

         if(m_cWeightsLSTM.GetIndex() < 0 || m_cFirstMomentumLSTM.GetIndex() < 0 ||
            m_cSecondMomentumLSTM.GetIndex() < 0)
            return false;
         if(m_iInputs < 0 || m_iConcatenated < 0 || m_iMemory < 0 ||
            m_iConcatenatedGradient < 0 || m_iHiddenState < 0 || m_iInputs != NeuronOCL.Neurons())
            return false;
      
      

      Wenn alle Prüfungen erfolgreich waren, fahren wir mit dem Kernel-Aufruf fort. In Übereinstimmung mit dem Fehlergradienten-Algorithmus rufen wir zunächst den Kernel LSTM_ConcatenatedGradient auf.

      Übertragen wir zunächst die Anfangsdaten in die Kernelparameter

         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated, m_iConcatenated))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_concatenated_gradient, m_iConcatenatedGradient))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_gradient, getGradientIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_ConcatenatedGradient, def_k_lstmcg_memory, m_iMemory))
            return false;
      
      

      und definieren die Dimension des Problemraums. Stellen wir den Kernel in die Ausführungswarteschlange.

         uint global_work_offset[] = {0};
         uint global_work_size[] = {Neurons()};
         if(!OpenCL.Execute(def_k_LSTM_ConcatenatedGradient, 1, global_work_offset, global_work_size))
            return false;
      
      

      Hier implementieren wir auch den Aufruf des zweiten Kernels für die Fehlergradientenfortpflanzung LSTM_HiddenGradient. Übergabe der Parameter an den Kernel.

         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_concatenated_gradient, m_iConcatenatedGradient))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_size, Neurons()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_hidden_state, m_iHiddenState))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs, NeuronOCL.getOutputIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_gradient, NeuronOCL.getGradientIndex()))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_HiddenGradient, def_k_lstmhg_inputs_size, m_iInputs))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_output, getOutputIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weeights, m_cWeightsLSTM.GetIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_HiddenGradient, def_k_lstmhg_weights_gradient, m_iWeightsGradient))
            return false;
      
      

      Wir verwenden die bereits erstellten Arrays, um den Problembereich zu spezifizieren und den Kernel in die Ausführungswarteschlange zu stellen.

         if(!OpenCL.Execute(def_k_LSTM_HiddenGradient, 1, global_work_offset, global_work_size))
            return false;
      //---
         return true;
        }
      
      

      Vergessen wir auch hier nicht, alle Operationen durchzuführen. Auf diese Weise können wir Fehler rechtzeitig erkennen und verhindern, dass das Programm zum ungünstigsten Zeitpunkt abgebrochen wird.

      Nach der Fortpflanzung des Fehlergradienten muss zur Vervollständigung des Algorithmus die Methode updateInputWeights zur Aktualisierung der Modellparameter implementiert werden. Die Methode erhält als Parameter einen Zeiger auf das Objekt der vorherigen Ebene. Aber wir haben den Fehlergradienten bereits auf der Ebene der Gewichtsmatrix definiert. Daher hängt das Vorhandensein eines Zeigers auf das Objekt der vorherigen Schicht eher mit der Implementierung von Methodenüberschreibungen zusammen als mit der Notwendigkeit, Daten zu übertragen. In diesem Fall hat der Zustand des empfangenen Zeigers keinen Einfluss auf das Ergebnis der Methode, sodass wir ihn nicht überprüfen. Überprüfen wir stattdessen die Verfügbarkeit der erforderlichen internen Puffer im Kontext von OpenCL.

      bool CNeuronLSTMOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL)
        {
         if(!OpenCL || m_cWeightsLSTM.GetIndex() < 0 || m_iWeightsGradient < 0 ||
            m_cFirstMomentumLSTM.GetIndex() < 0 || m_cSecondMomentumLSTM.GetIndex() < 0)
            return false;
      
      

      Als Nächstes übergeben wir Parameter an den Kernel.

         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights, m_cWeightsLSTM.GetIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_weights_gradient, m_iWeightsGradient))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_m, m_cFirstMomentumLSTM.GetIndex()))
            return false;
         if(!OpenCL.SetArgumentBuffer(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_matrix_v, m_cSecondMomentumLSTM.GetIndex()))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_l, lr))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b1, b1))
            return false;
         if(!OpenCL.SetArgument(def_k_LSTM_UpdateWeightsAdam, def_k_lstmuw_b2, b2))
            return false;
      
      

      Wir definieren den Problemraum und stellen den Kernel in die Ausführungswarteschlange.

         uint global_work_offset[] = {0, 0};
         uint global_work_size[] = {m_iInputs + Neurons() + 1, Neurons()};
         if(!OpenCL.Execute(def_k_LSTM_UpdateWeightsAdam, 2, global_work_offset, global_work_size))
            return false;
      //---
         return true;
        }
      
      

      Damit sind unsere Arbeiten zur Organisation des Backpropagation-Algorithmus abgeschlossen. Unsere Klasse CNeuronLSTMOCL ist bereit für die ersten Tests. Wir wissen jedoch, dass wir das trainierte Modell speichern und dann wieder in einen funktionierenden Zustand versetzen müssen. Daher werden wir Methoden für Dateioperationen hinzufügen.

      Wie in allen bisher betrachteten Architekturen neuronaler Schichten wird die Methode Save zum Speichern von Daten verwendet. In den Parametern erhält diese Methode das Dateihandle zum Schreiben der Daten.

      Im Methodenkörper rufen wir zunächst eine ähnliche Methode der übergeordneten Klasse auf. Dies ermöglicht die Implementierung aller erforderlichen Steuerelemente mit fast einer Codezeile und die Speicherung von Objekten, die von der übergeordneten Klasse geerbt wurden. Prüfen wir das Ergebnis der Ausführung der Methode der übergeordneten Klasse.

      Danach speichern wir die Anzahl der Neuronen in der vorherigen Schicht. Wir speichern auch die Gewichts- und Impulsmatrizen.

      bool CNeuronLSTMOCL::Save(const int file_handle)
        {
         if(!CNeuronBaseOCL::Save(file_handle))
            return false;
         if(FileWriteInteger(file_handle, m_iInputs, INT_VALUE) < sizeof(m_iInputs))
            return false;
         if(!m_cWeightsLSTM.BufferRead() || !m_cWeightsLSTM.Save(file_handle))
            return false;
         if(!m_cFirstMomentumLSTM.BufferRead() || !m_cFirstMomentumLSTM.Save(file_handle))
            return false;
         if(!m_cSecondMomentumLSTM.BufferRead() || !m_cSecondMomentumLSTM.Save(file_handle))
            return false;
      //---
         return true;
        }
      
      

      Nachdem wir die Daten gespeichert haben, müssen wir die Methode load, um das Objekt aus den gespeicherten Daten wiederherzustellen. Wie bereits erwähnt, werden die Daten aus einer Datei in strikter Übereinstimmung mit der Schreibreihenfolge gelesen. Wie bei der Datenspeicherungsmethode erhält diese Methode als Parameter ein Dateihandle zum Lesen der Datei. Wir rufen sofort eine ähnliche Methode der übergeordneten Klasse auf.

      bool CNeuronLSTMOCL::Load(const int file_handle)
        {
         if(!CNeuronBaseOCL::Load(file_handle))
            return false;
      
      

      Als Nächstes lesen wir die Anzahl der Neuronen in der vorherigen Schicht sowie die zuvor gespeicherten Gewichts- und Impulspuffer. Nach dem Laden jedes Puffers initiieren wir die Erstellung von Spiegeldatenpuffern im OpenCL Kontext. Vergessen wir nicht, die Ausführung von Vorgängen zu kontrollieren.

         m_iInputs = FileReadInteger(file_handle);
      //---
         m_cWeightsLSTM.BufferFree();
         if(!m_cWeightsLSTM.Load(file_handle) || !m_cWeightsLSTM.BufferCreate(OpenCL))
            return false;
      //---
         m_cFirstMomentumLSTM.BufferFree();
         if(!m_cFirstMomentumLSTM.Load(file_handle) || !m_cFirstMomentumLSTM.BufferCreate(OpenCL))
            return false;
      //---
         m_cSecondMomentumLSTM.BufferFree();
         if(!m_cSecondMomentumLSTM.Load(file_handle) || !m_cSecondMomentumLSTM.BufferCreate(OpenCL))
            return false;
      
      

      Diese Methode sollte nicht nur Daten aus einer Datei lesen, sondern auch die volle Funktionalität des trainierten Modells wiederherstellen. Daher müssen wir nach dem Lesen der Daten aus der Datei auch temporäre Datenpuffer erstellen, deren Informationen nicht in der Datei gespeichert wurden.

         if(m_iMemory >= 0)
            OpenCL.BufferFree(m_iMemory);
         m_iMemory = OpenCL.AddBuffer(sizeof(float) * 2 * Neurons(), CL_MEM_READ_WRITE);
         if(m_iMemory < 0)
            return false;
      //---
         if(m_iConcatenated >= 0)
            OpenCL.BufferFree(m_iConcatenated);
         m_iConcatenated = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE);
         if(m_iConcatenated < 0)
            return false;
      //---
         if(m_iConcatenatedGradient >= 0)
            OpenCL.BufferFree(m_iConcatenatedGradient);
         m_iConcatenatedGradient = OpenCL.AddBuffer(sizeof(float) * 4 * Neurons(), CL_MEM_READ_WRITE);
         if(m_iConcatenatedGradient < 0)
            return false;
      //---
         if(m_iHiddenState >= 0)
            OpenCL.BufferFree(m_iHiddenState);
         m_iHiddenState = OpenCL.AddBuffer(sizeof(float) * Neurons(), CL_MEM_READ_WRITE);
         if(m_iHiddenState < 0)
            return false;
      //---
         if(m_iWeightsGradient >= 0)
            OpenCL.BufferFree(m_iWeightsGradient);
         m_iWeightsGradient = OpenCL.AddBuffer(sizeof(float) * m_cWeightsLSTM.Total(), CL_MEM_READ_WRITE);
         if(m_iWeightsGradient < 0)
            return false;
      //---
         return true;
        }
      
      

      Die Operationen mit den Methoden der Klasse CNeuronLSTMOCL sind abgeschlossen. 

      Als Nächstes müssen wir lediglich neue Kernel in der OpenCL-Kontextverbindungsprozedur und Zeiger auf einen neuen Typ von neuronalen Schichten in den Dispatcher-Methoden unserer neuronalen Basisschicht hinzufügen.

      Der vollständige Code aller Methoden und Klassen ist in der Anlage unten zu finden.


      3. Tests

      Die neue neuronale Schichtklasse ist fertig, und wir können mit der Erstellung eines Modells für das Testtraining fortfahren. Ein neues rekurrentes Autoencoder-Modell wurde auf der Grundlage des Modells des Variierten Autoencoders aus dem vorherigen Artikel entwickelt. Dieses Modell wurde in einer neuen Datei mit dem Namen „rnn_vae.mq5“ gespeichert. Die Architektur des Encoders wurde geändert: Wir haben dort rekurrente LSTM-Blöcke hinzugefügt.

      Bitte beachten Sie, dass wir nur die letzten 10 Kerzen an den Eingang unseres rekurrenten Encoders weiterleiten.

      int OnInit()
        {
      //---
       ..................
       ..................
      //---
         Net = new CNet(NULL);
         ResetLastError();
         float temp1, temp2;
         if(!Net || !Net.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
           {
            printf("%s - %d -> Error of read %s prev Net %d", __FUNCTION__, __LINE__, FileName + ".nnw", GetLastError());
            HistoryBars = iHistoryBars;
            CArrayObj *Topology = new CArrayObj();
            if(CheckPointer(Topology) == POINTER_INVALID)
               return INIT_FAILED;
            //--- 0
            CLayerDescription *desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            int prev = desc.count = 10 * 12;
            desc.type = defNeuronBaseOCL;
            desc.optimization = ADAM;
            desc.activation = None;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 1
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = prev;
            desc.batch = 1000;
            desc.type = defNeuronBatchNormOCL;
            desc.activation = None;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 2
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            prev = desc.count = 500;
            desc.type = defNeuronLSTMOCL;
            desc.activation = None;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 3
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            prev = desc.count = prev/2;
            desc.type = defNeuronLSTMOCL;
            desc.activation = None;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 4
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            prev = desc.count = 50;
            desc.type = defNeuronLSTMOCL;
            desc.activation = None;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 5
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = prev/2;
            desc.type = defNeuronVAEOCL;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 6
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = (int) HistoryBars;
            desc.type = defNeuronBaseOCL;
            desc.activation = TANH;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 7
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = (int) HistoryBars * 2;
            desc.type = defNeuronBaseOCL;
            desc.activation = TANH;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 8
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = (int) HistoryBars * 4;
            desc.type = defNeuronBaseOCL;
            desc.activation = TANH;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            //--- 9
            desc = new CLayerDescription();
            if(CheckPointer(desc) == POINTER_INVALID)
               return INIT_FAILED;
            desc.count = (int) HistoryBars * 12;
            desc.type = defNeuronBaseOCL;
            desc.activation = TANH;
            desc.optimization = ADAM;
            if(!Topology.Add(desc))
               return INIT_FAILED;
            delete Net;
            Net = new CNet(Topology);
            delete Topology;
            if(CheckPointer(Net) == POINTER_INVALID)
               return INIT_FAILED;
            dError = FLT_MAX;
           }
         else
           {
            CBufferFloat *temp;
            Net.getResults(temp);
            HistoryBars = temp.Total() / 12;
            delete temp;
           }
      //---
       ..................
       ..................
      //---
         return(INIT_SUCCEEDED);
        }
      
      

      Wie bereits in diesem Artikel besprochen, müssen wir, um das Training eines rekurrenten Blocks zu organisieren, Bedingungen hinzufügen, um das Modell zu zwingen, im „Speicher“ zu suchen. Zu Lernzwecken wollen wir einen Datenstapel erstellen. Und nach jeder Iteration des Vorwärtsdurchlaufs entfernen wir die Informationen über die älteste Kerze aus dem Stapel und fügen die Informationen über die neue Kerze am Ende des Stapels hinzu.

      Somit enthält der Stapel immer Informationen über mehrere historische Zustände des analysierten Modells. Die Tiefe der Historie wird durch einen externen Parameter bestimmt. Wir werden diesen Stapel als Zielwerte an den Autoencoder übergeben. Wenn die Stapelgröße den Wert der Anfangsdaten am Encodereingang übersteigt, muss der Autoencoder in den Speicher der vergangenen Zustände schauen. 

       ..................
       ..................
               Net.feedForward(TempData, 12, true);
               TempData.Clear();
               if(!Net.GetLayerOutput(1, TempData))
                  break;
               uint check_total = check_data.Total();
               if(check_total >= check_count)
                 {
                  if(!check_data.DeleteRange(0, check_total - check_count + 12))
                     return;
                 }
               for(int t = TempData.Total() - 12 - 1; t < TempData.Total(); t++)
                 {
                  if(!check_data.Add(TempData.At(t)))
                     return;
                 }
               if((total-it)>(int)HistoryBars)
                  Net.backProp(check_data);
       ..................
       ..................
      
      

      Die Parameter der Modellprüfung waren dieselben: EURUSD, H1, letzte 15 Jahre. Standardeinstellungen der Indikatoren. Geben Sie Daten über die letzten 10 Kerzen in den Encoder ein. Der Decoder ist darauf trainiert, die letzten 40 Kerzen zu dekodieren. Die Testergebnisse sind in der nachstehenden Tabelle aufgeführt. Die Daten werden in den Encoder eingegeben, nachdem die Bildung jeder neuen Kerze abgeschlossen ist.

      RNN Autoencoder Trainingsergebnisse

      Wie Sie in der Tabelle sehen können, bestätigen die Testergebnisse die Tauglichkeit dieses Ansatzes für das unüberwachte Pre-Training von rekurrenten Modellen. Beim Testtraining des Modells hat sich der Modellfehler nach 20 Lernepochen mit einer Verlustrate von weniger als 9 % nahezu stabilisiert. Außerdem werden Informationen über mindestens 30 frühere Iterationen im latenten Zustand des Modells gespeichert.


      Schlussfolgerung

      In diesem Artikel haben wir uns mit dem Training von rekurrenten Modellen mit Autoencodern beschäftigt. Im praktischen Teil des Artikels haben wir einen rekurrenten Autoencoder erstellt und sein Testtraining durchgeführt. Die Ergebnisse unseres Experiments lassen den Schluss zu, dass der vorgeschlagene Ansatz für das unbeaufsichtigte Training von rekurrenten Modellen unter Verwendung von Autoencodern praktikabel ist. Das Modell zeigte recht gute Ergebnisse bei der Wiederherstellung der Daten für die letzten 30 Iterationen im Test.


      Liste der Referenzen

      1. Neuronale Netze leicht gemacht (Teil 4): Rekurrente Netzwerke
      2. Neuronale Netze leicht gemacht (Teil 14): Datenclustering
      3. Neuronale Netze leicht gemacht (Teil 15): Datenclustering mit MQL5
      4. Neuronale Netze leicht gemacht (Teil 16): Praktische Anwendung des Clustering
      5. Neuronale Netze leicht gemacht (Teil 17): Reduzierung der Dimensionalität
      6. Neuronale Netze leicht gemacht (Teil 18): Assoziationsregeln
      7. Neuronale Netze leicht gemacht (Teil 19): Assoziationsregeln mit MQL5
      8. Neuronale Netze leicht gemacht (Teil 20): Autoencoder
      9. Neuronale Netze leicht gemacht (Teil 21): Variierter Autoencoder (VAE)
      10. Unsupervised Learning of Video Representations using LSTMs
      11. Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation

      Programme, die im diesem Artikel verwendet werden

      # Name Typ Beschreibung
      1 rnn_vae.mq5 EA   Rekurrenter Autoencoder Training Expert Advisor
      2 VAE.mqh Klassenbibliothek Klassenbibliothek des Variierter Autoencoder für latente Schichten
      3 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
      4 NeuroNet.cl Code Base OpenCL-Programmcode-Bibliothek


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

      Beigefügte Dateien |
      MQL5.zip (68.41 KB)
      Der Indikator CCI: Drei Transformationsschritte Der Indikator CCI: Drei Transformationsschritte
      In diesem Artikel werde ich zusätzliche Änderungen am CCI vornehmen, die die eigentliche Logik dieses Indikators betreffen. Außerdem können wir sie im Hauptfenster des Charts sehen.
      DoEasy. Steuerung (Teil 12): WinForms-Objekte Basislistenobjekt, ListBox und ButtonListBox DoEasy. Steuerung (Teil 12): WinForms-Objekte Basislistenobjekt, ListBox und ButtonListBox
      In diesem Artikel werde ich das Basisobjekt der WinForms-Objektlisten sowie die beiden neuen Objekte erstellen: ListBox und ButtonListBox.
      DoEasy. Steuerung (Teil 13): Optimierung der Interaktion von WinForms-Objekten mit der Maus, Beginn der Entwicklung des WinForms-Objekts TabControl DoEasy. Steuerung (Teil 13): Optimierung der Interaktion von WinForms-Objekten mit der Maus, Beginn der Entwicklung des WinForms-Objekts TabControl
      In diesem Artikel werde ich den Umgang mit dem Aussehen von WinForms-Objekte nach dem Bewegen des Mauszeigers weg von dem Objekt, sowie die Entwicklung der TabControl WinForms-Objekt korrigieren und optimieren.
      Neuronale Netze leicht gemacht (Teil 21): Variierter Autoencoder (VAE) Neuronale Netze leicht gemacht (Teil 21): Variierter Autoencoder (VAE)
      Im letzten Artikel haben wir uns mit dem Algorithmus des Autoencoders vertraut gemacht. Wie jeder andere Algorithmus hat auch dieser seine Vor- und Nachteile. In seiner ursprünglichen Implementierung wird der Autoencoder verwendet, um die Objekte so weit wie möglich von der Trainingsstichprobe zu trennen. Dieses Mal werden wir darüber sprechen, wie man mit einigen ihrer Nachteile umgehen kann.