English Русский 中文 Español 日本語 Português
preview
Neuronale Netze leicht gemacht (Teil 38): Selbstüberwachte Erkundung bei Unstimmigkeit (Self-Supervised Exploration via Disagreement)

Neuronale Netze leicht gemacht (Teil 38): Selbstüberwachte Erkundung bei Unstimmigkeit (Self-Supervised Exploration via Disagreement)

MetaTrader 5Experten | 5 Oktober 2023, 10:24
304 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

Das Erkundungsproblem ist ein großes Hindernis beim Verstärkungslernen, insbesondere in Fällen, in denen der Agent seltene und verzögerte Belohnungen erhält, was es schwierig macht, eine effektive Strategie zu formulieren. Eine der möglichen Lösungen für dieses Problem besteht darin, „intrinsische“ Belohnungen auf der Grundlage eines Modells der Umwelt zu erzeugen. Einen ähnlichen Algorithmus haben wir bei der Untersuchung des Moduls für intrinsische Neugierde gesehen. Die meisten der entwickelten Algorithmen sind jedoch nur im Zusammenhang mit Computerspielen untersucht worden. Außerhalb von stillen simulierten Umgebungen ist das Training von Vorhersagemodellen jedoch aufgrund der stochastischen Natur der Interaktionen zwischen Akteuren und Umgebung eine Herausforderung. Zu den Ansätzen zur Lösung des Problems der Umweltstochastik gehört der Algorithmus, den Deepak Pathak in seinem Artikel „Self-Supervised Exploration via Disagreement“ vorgeschlagen hat.

Dieser Algorithmus basiert auf einer selbstlernenden Methode, bei der der Agent die während der Interaktion mit der Umwelt erhaltenen Informationen nutzt, um „intrinsische“ Belohnungen zu generieren und seine Strategie zu aktualisieren. Der Algorithmus basiert auf der Verwendung mehrerer Agentenmodelle, die mit der Umgebung interagieren und verschiedene Prognosen erstellen. Wenn die Modelle nicht übereinstimmen, wird dies als „interessantes“ Ereignis betrachtet, und der Agent hat einen Anreiz, diesen Bereich der Umgebung zu erkunden. Auf diese Weise schafft der Algorithmus Anreize für den Agenten, neue Bereiche der Umgebung zu erkunden, und ermöglicht ihm, genauere Vorhersagen über künftige Belohnungen zu treffen.


1. Algorithmus der Erkundung durch Unstimmigkeiten

Eine Erkundung auf Basis von Unstimmigkeiten ist eine Methode des Reinforcement Learning, die es einem Agenten ermöglicht, seine Umgebung zu erkunden, ohne sich auf externe Belohnungen zu verlassen, sondern indem er mit Hilfe eines Ensembles von Modellen neue, unerforschte Gebiete findet.

In dem Artikel „Self-Supervised Exploration via Disagreement“ beschreiben die Autoren diesen Ansatz und schlagen eine einfache Methode vor: Training eines Ensembles von Vorwärtsdynamikmodellen und Ermutigung des Agenten, den Handlungsraum zu erkunden, in dem die maximale Inkonsistenz oder Varianz zwischen den Vorhersagen der Modelle im Ensemble besteht.

Anstatt also Aktionen zu wählen, die den größten erwarteten Gewinn bringen, wählt der Agent Aktionen, die die Unstimmigkeit zwischen den Modellen im Ensemble maximieren. Dies ermöglicht es dem Agenten, Regionen des Zustandsraums zu erkunden, in denen die Modelle des Ensembles nicht übereinstimmen und in denen es wahrscheinlich neue und unerforschte Regionen der Umgebung gibt.

In diesem Fall konvergieren alle Modelle des Ensembles zum Mittelwert, was letztlich die Streuung des Ensembles verringert und dem Agenten genauere Vorhersagen über den Zustand der Umwelt und die möglichen Folgen von Handlungen ermöglicht.

Darüber hinaus ermöglicht der Algorithmus der Erkundung durch Unstimmigkeit dem Agenten, erfolgreich mit der Stochastizität der Interaktion mit der Umwelt umzugehen. Die Ergebnisse der von den Autoren des Artikels durchgeführten Experimente haben gezeigt, dass der vorgeschlagene Ansatz die Exploration in stochastischen Umgebungen tatsächlich verbessert und die bisher existierenden Methoden der intrinsischen Motivation und der Unsicherheitsmodellierung übertrifft. Darüber hinaus haben sie festgestellt, dass ihr Ansatz auf überwachtes Lernen ausgeweitet werden kann, bei dem der Wert eines Beispiels nicht auf der Grundlage der Grundwahrheit, sondern auf der Grundlage des Zustands des Ensembles von Modellen bestimmt wird.

Somit stellt der Algorithmus der Exploration durch Unstimmigkeit einen vielversprechenden Ansatz zur Lösung des Explorationsproblems in stochastischen Umgebungen dar. Sie ermöglicht es dem Agenten, die Umgebung effizienter zu erkunden, ohne auf externe Belohnungen angewiesen zu sein, was in realen Anwendungen, in denen externe Belohnungen begrenzt oder kostspielig sein können, besonders nützlich ist.

Darüber hinaus kann der Algorithmus in einer Vielzahl von Kontexten angewendet werden, einschließlich der Arbeit mit hochdimensionalen Daten wie Bildern, bei denen die Messung und Maximierung der Modellunsicherheit eine besondere Herausforderung darstellen kann.

Die Autoren des Artikels demonstrierten die Effektivität des vorgeschlagenen Algorithmus bei verschiedenen Problemen, darunter Robotersteuerung, Atari-Spiele und Labyrinth-Navigationsaufgaben. Als Ergebnis ihrer Forschung zeigten sie, dass der Algorithmus der Exploration durch Unstimmigkeit andere Explorationsmethoden in Bezug auf Geschwindigkeit, Konvergenz und Lernqualität übertrifft.

Somit stellt dieser Ansatz der Erkundung durch Unstimmigkeit einen wichtigen Schritt auf dem Gebiet des Verstärkungslernens dar, der den Agenten helfen kann, die Umwelt besser und effizienter zu erkunden und bessere Ergebnisse bei verschiedenen Aufgaben zu erzielen.

Betrachten wir nun den vorgeschlagenen Algorithmus.

Im Prozess der Interaktion mit der Umwelt bewertet der Agent den aktuellen Zustand Xt und führt, geleitet von seiner internen Strategie, eine Aktion At durch. Infolgedessen ändert sich der Zustand der Umgebung in einen neuen Zustand Xt+1. Ein Satz solcher Daten wird in einem Erfahrungswiedergabepuffer gespeichert, den wir verwenden, um ein Ensemble dynamischer Modelle zu trainieren, die den zukünftigen Zustand der Umgebung vorhersagen.

Um in der Anfangsphase eine unabhängige Bewertung des zukünftigen Umweltzustands zu erhalten, werden alle Gewichtsmatrizen der dynamischen Modelle im Ensemble mit Zufallswerten gefüllt. Während des Trainingsprozesses erhält jedes Modell seinen eigenen zufälligen Satz von Trainingsdaten aus dem Erfahrungswiedergabepuffer.

Jedes Modell in unserem Ensemble ist darauf trainiert, den nächsten Zustand der realen Umgebung vorherzusagen. Teile des Zustandsraums, die vom Agenten gut erforscht wurden, haben genügend Daten gesammelt, um alle Modelle zu trainieren, was zu einer Konsistenz zwischen den Modellen führt. Wenn die Modelle trainiert sind, sollte sich dieses Merkmal auf unbekannte, aber ähnliche Teile des Zustandsraums verallgemeinern lassen. Bei Regionen, die neu und unerforscht sind, werden jedoch alle Modelle einen hohen Vorhersagefehler aufweisen, da sie noch nicht mit solchen Beispielen trainiert wurden. Infolgedessen gibt es eine Unstimmigkeit bei der Vorhersage des nächsten Zustands. Wir nutzen daher diese Unstimmigkeit als intrinsische Belohnung für die politische Ausrichtung. Konkret ist die intrinsische Belohnung Ri definiert als die Varianz in der Ausgabe der verschiedenen Modelle im Ensemble.

Bitte beachten Sie, dass in der obigen Formel die intrinsische Belohnung nicht vom zukünftigen Zustand des Systems abhängt. Wir werden diese Eigenschaft später bei der Implementierung dieser Methode verwenden.

Im Falle eines stochastischen Szenarios muss das dynamische Vorhersagemodell bei einer ausreichenden Anzahl von Stichproben lernen, den Mittelwert der stochastischen Stichproben vorherzusagen. Auf diese Weise verringert sich die Streuung der Ergebnisse im Ensemble und verhindert, dass der Agent in stochastischen lokalen Minima der Studie stecken bleibt. Dies unterscheidet sich von den auf Vorhersagefehlern basierenden Zielen, die sich nach einer ausreichenden Anzahl von Stichproben auf den Mittelwert einpendeln. Der Mittelwert weicht von den einzelnen wahren Zufallszuständen ab, und der Vorhersagefehler bleibt hoch, sodass der Agent stets an stochastischem Verhalten interessiert ist.

Bei Verwendung des vorgeschlagenen Algorithmus liefert jeder Schritt der Interaktion des Agenten mit der Umwelt nicht nur Informationen über die von der Umwelt erhaltene Belohnung, sondern auch über die Informationen, die notwendig sind, um das interne Modell des Agenten darüber zu aktualisieren, wie sich der Zustand der Umwelt bei der Durchführung von Aktionen verändert. Dies ermöglicht es dem Agenten, wertvolle Informationen über die Umgebung zu gewinnen, auch wenn es keine ausdrückliche externe Belohnung gibt.

Modellpräsentation aus dem Originalartikel

Die intrinsische Belohnung iR wird verwendet, um die Strategie des Agenten zu trainieren, die als Varianz der Ergebnisse der verschiedenen Modelle im Ensemble berechnet wird. Je größer die Abweichung zwischen den Ergebnissen der Modelle ist, desto höher ist der Wert der intrinsischen Belohnung. Dies ermöglicht es dem Agenten, neue Bereiche des Zustandsraums zu erkunden, in denen die Vorhersage des nächsten Zustands unsicher ist, und zu lernen, auf der Grundlage dieser Daten bessere Entscheidungen zu treffen.

Der Agent wird online anhand von Daten trainiert, die er bei der Interaktion mit der Umgebung sammelt. Gleichzeitig wird das Modell-Ensemble nach jeder Interaktion des Agenten mit der Umwelt aktualisiert, was es dem Agenten ermöglicht, sein internes Modell über die Umwelt bei jedem Schritt zu aktualisieren und genauere Vorhersagen über den zukünftigen Zustand der Umwelt zu erhalten.

2. Implementierung mittels MQL5

In unserer Implementierung werden wir den vorgeschlagenen Algorithmus nicht vollständig wiederholen, sondern nur seine Hauptideen verwenden und sie an unsere Aufgaben anpassen.

Als Erstes werden wir ein Ensemble dynamischer Modelle auffordern, den komprimierten (verborgenen) Systemzustand vorherzusagen, ähnlich wie beim Modell der intrinsischen Neugierde. Auf diese Weise können wir die Größe der dynamischen Modelle und des Ensembles als Ganzes komprimieren.

Der zweite Punkt ist, dass wir zur Bestimmung der intrinsischen Belohnung nicht den wahren Zustand des Systems kennen müssen, sondern die vorhergesagten Werte der dynamischen Ensemblemodelle. Auf diese Weise können wir prädiktive Belohnungen nicht nur als Anreiz für späteres Lernen nutzen, sondern auch für Handlungsentscheidungen in Echtzeit. Wir werden die externen Belohnungen nicht verzerren, indem wir eine intrinsische Komponente einführen, wenn wir die Strategie des Agenten trainieren, sondern wir werden ihm erlauben, sofort eine Strategie zur Maximierung der externen Belohnungen zu entwickeln. Dies ist unser Hauptziel.

Um jedoch das Lernen der Umwelt während des Lernprozesses zu maximieren, addieren wir bei der Wahl der Aktion eines Agenten zur vorhergesagten Belohnung die Varianz der Unstimmigkeit in den Vorhersagen der dynamischen Modelle für jede mögliche Aktion des Agenten.

Dies bringt uns zu einem weiteren Punkt: Um die Vorhersagezustände nach jeder Aktion parallel zu berechnen, werden wir unsere dynamischen Modelle bitten, uns Vorhersagen für jede mögliche Agentenaktion auf der Grundlage des aktuellen Zustands zu geben, wobei die Größe der Ergebnisschicht jedes Modells entsprechend der Anzahl der möglichen Aktionen erhöht wird.

Nachdem wir nun die wichtigsten Arbeitsrichtungen festgelegt haben, können wir uns an die Umsetzung des Algorithmus machen. Die erste Frage ist, wie man ein Ensemble von dynamischen Modellen implementiert. Alle von uns bisher erstellten Modelle waren linear. Paralleles Rechnen kann mit OpenCL-Tools innerhalb eines Subprozesses und einer neuronalen Schicht organisiert werden. Derzeit ist es nicht möglich, mehrere Modelle parallel zu berechnen. Die Erstellung einer Abfolge von Berechnungen für mehrere Modelle führt zu einer erheblichen Verlängerung der Zeit, die für das Training des Modells benötigt wird.

Um dieses Problem zu lösen, habe ich beschlossen, die Methode der Organisation des parallelen Rechnens zu verwenden, die wir für die mehrköpfige Aufmerksamkeit verwendet haben. Damals haben wir die Daten aller Aufmerksamkeitsköpfe in einzelne Tensoren zusammengefasst und sie auf der Ebene des Aufgabenraums in OpenCL aufgeteilt.

Wir werden jetzt nicht unsere gesamte Bibliothek umgestalten, um solche Probleme zu lösen. In diesem Stadium ist die besondere Genauigkeit der vorhergesagten Werte des zukünftigen Systemzustands für uns nicht wichtig. Die relativ synchrone Arbeit der Modelle würde ausreichen. Daher werden wir in dynamischen Prognosemodellen vollständig verbundene Schichten verwenden.

Zunächst werden wir OpenCL-Programmkerne erstellen, um diese Funktionalität zu organisieren. Der FeedForward-Kernel FeedForwardMultiModels ist fast identisch mit dem ähnlichen Kernel der Basisschicht mit vollem Zusammenhang. Aber es gibt leichte Unterschiede.

Die Kernelparameter blieben unverändert. Er hat drei Datenpuffer (Gewichtsmatrix, Quelldaten und Ergebnistensoren) sowie zwei Konstanten: die Größe der Quelldatenschicht und die Aktivierungsfunktion. Zuvor haben wir jedoch die gesamte Größe der vorherigen Ebene als Größe der Quelldatenebene angegeben. Jetzt erwarten wir, dass wir die Anzahl der Elemente des aktuellen Modells erhalten.

__kernel void FeedForwardMultiModels(__global float *matrix_w,
                                     __global float *matrix_i,
                                     __global float *matrix_o,
                                     int inputs,
                                     int activation
                                    )
  {
   int i = get_global_id(0);
   int outputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

Im Kernelkörper identifizieren wir zunächst den aktuellen Thread. Sie können hier das Auftreten einer zweiten Dimension des Problemraums erkennen, die das aktuelle Modell identifiziert. Die Gesamtgröße der Probleme gibt an, wie groß das Ensemble ist.

Als Nächstes deklarieren wir die notwendigen lokalen Variablen und definieren den Offset in den Datenpuffern, wobei wir das zu berechnende Neuron und das aktuelle Modell im Ensemble berücksichtigen.

   float sum = 0;
   float4 inp, weight;
   int shift = (inputs + 1) * (i + outputs * m);
   int shift_in = inputs * m;
   int shift_out = outputs * m;

Der eigentliche mathematische Teil der Berechnung des Neuronenzustands und der Aktivierungsfunktion blieb unverändert. Wir haben nur den Offset-Abgleich in den Datenpuffern hinzugefügt.

   for(int k = 0; k <= inputs; k = k + 4)
     {
      switch(inputs - k)
        {
         case 0:
            inp = (float4)(1, 0, 0, 0);
            weight = (float4)(matrix_w[shift + k], 0, 0, 0);
            break;
         case 1:
            inp = (float4)(matrix_i[shift_in + k], 1, 0, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
            break;
         case 2:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], 1, 0);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
            break;
         case 3:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2], 1);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
         default:
            inp = (float4)(matrix_i[shift_in + k], matrix_i[shift_in + k + 1], matrix_i[shift_in + k + 2],
                                                                                                  matrix_i[shift_in + k + 3]);
            weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
            break;
        }
      float d = dot(inp, weight);
      if(isnan(sum + d))
         continue;
      sum += d;
     }

Sobald der Wert der in den Parametern angegebenen Aktivierungsfunktion berechnet ist, wird das Ergebnis im Datenpuffer matrix_o gespeichert.

   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         sum = tanh(sum);
         break;
      case 1:
         sum = 1 / (1 + exp(-sum));
         break;
      case 2:
         if(sum < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_o[shift_out + i] = sum;
  }

Diese Lösung ermöglicht es uns, den Wert einer Schicht aller Modelle des Ensembles in einem Kernel parallel zu berechnen. Natürlich gibt es eine Einschränkung: Hier ist die Architektur aller Modelle im Ensemble identisch, die einzigen Unterschiede liegen in den Gewichtungskoeffizienten.

Beim Rückwärtsdurchgang ist die Situation ein wenig anders. Der Algorithmus sieht vor, dynamische Modelle in einem Ensemble auf einem anderen Satz von Trainingsdaten zu trainieren. Wir werden nicht für jedes Modell ein eigenes Trainingspaket erstellen. Stattdessen wird bei jedem Rückwärtsdurchlauf nur ein zufällig ausgewähltes Modell aus dem Ensemble trainiert. Bei anderen Modellen wird der Nullgradient an die vorherige Schicht weitergegeben. Dies sind die Änderungen, die wir am Gradientenverteilungs-Kernel-Algorithmus innerhalb der Schicht CalcHiddenGradientMultiModels vornehmen werden.

Ein ähnlicher Kernel der grundlegenden vollständig verbundenen neuronalen Schicht erhält in seinen Parametern Zeiger auf vier Datenpuffer und zwei Variablen. Dies ist der Tensor der Gewichtsmatrix und der Tensor der Ergebnisse der vorherigen Schicht zur Berechnung der Ableitung der Aktivierungsfunktion. Außerdem gibt es 2 Gradientenpuffer: die aktuelle und die vorherige neuronale Schicht. Die erste enthält die empfangenen Fehlergradienten, und die zweite wird verwendet, um die Ergebnisse des Kernels aufzuzeichnen und den Fehlergradienten an die vorherige neuronale Schicht zu übertragen. In Variablen geben wir die Anzahl der Neuronen in der aktuellen Schicht und die Aktivierungsfunktion der vorherigen Schicht an. Zu den angegebenen Parametern fügen wir den Bezeichner des trainierten Modells hinzu, den wir zufällig auf der Seite des Hauptprogramms auswählen werden.

__kernel void CalcHiddenGradientMultiModels(__global float *matrix_w,
                                            __global float *matrix_g,
                                            __global float *matrix_o,
                                            __global float *matrix_ig,
                                            int outputs,
                                            int activation,
                                            int model
                                           )
  {
   

Im Kernelkörper identifizieren wir zunächst den Thread. Wie beim Feed-Forward-Kernel verwenden wir einen zweidimensionalen Problemraum. In der ersten Dimension identifizieren wir den Fluss innerhalb eines einzelnen Modells, und die zweite Dimension zeigt das Modell im Ensemble an. Um Fehlergradienten zu sammeln, führen wir einen Kernel im Kontext der Neuronen der vorherigen Schicht aus. Jeder Thread sammelt Fehlergradienten aus allen Richtungen auf einem einzigen Neuron.

   int i = get_global_id(0);
   int inputs = get_global_size(0);
   int m = get_global_id(1);
   int models = get_global_size(1);

Bitte beachten Sie, dass wir den Gradienten nur auf ein Modell verteilen, aber Threads für das gesamte Ensemble starten werden. Dies ist darauf zurückzuführen, dass der Fehlergradient anderer Modelle zurückgesetzt werden muss. Im nächsten Schritt wird geprüft, ob der Gradient für ein bestimmtes Modell aktualisiert werden muss. Wenn wir nur den Gradienten zurücksetzen müssen, führen wir nur diese Funktion aus und verlassen den Kernel, ohne unnötige Operationen durchzuführen.

//---
   int shift_in = inputs * m;
   if(model >= 0 && model != m)
     {
      matrix_ig[shift_in + i] = 0;
      return;
     }

Hier lassen wir ein kleines Schlupfloch für eine mögliche zukünftige Nutzung. Wenn Sie eine negative Zahl als zu aktualisierende Modellnummer angeben, wird der Gradient für alle Modelle im Ensemble berechnet.

Als Nächstes deklarieren wir lokale Variablen und definieren Offsets in den Datenpuffern.

//---
   int shift_out = outputs * m;
   int shift_w = (inputs + 1) * outputs * m;
   float sum = 0;
   float out = matrix_o[shift_in + i];
   float4 grad, weight;

Es folgt der mathematische Teil der Fehlergradientenverteilung, der die ähnliche Funktionalität des grundlegenden voll vernetzten Neurons vollständig wiederholt. Natürlich fügen wir den notwendigen Offset in den Datenpuffern hinzu. Das Ergebnis der Operationen wird im Gradientenpuffer der vorherigen Schicht gespeichert.

   for(int k = 0; k < outputs; k += 4)
     {
      switch(outputs - k)
        {
         case 1:
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], 0, 0, 0);
            grad = (float4)(matrix_g[shift_out + k], 0, 0, 0);
            break;
         case 2:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], 0, 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 0, 0);
            break;
         case 3:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 0);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i],
                                                                           matrix_w[shift_w + (k + 2) * (inputs + 1) + i], 0);
            break;
         default:
            grad = (float4)(matrix_g[shift_out + k], matrix_g[shift_out + k + 1], matrix_g[shift_out + k + 2], 
                                                                                                 matrix_g[shift_out + k + 3]);
            weight = (float4)(matrix_w[shift_w + k * (inputs + 1) + i], matrix_w[shift_w + (k + 1) * (inputs + 1) + i], 
                              matrix_w[shift_w + (k + 2) * (inputs + 1) + i], matrix_w[shift_w + (k + 3) * (inputs + 1) + i]);
            break;
        }
      sum += dot(grad, weight);
     }
   if(isnan(sum))
      sum = 0;
   switch(activation)
     {
      case 0:
         out = clamp(out, -1.0f, 1.0f);
         sum = clamp(sum + out, -1.0f, 1.0f) - out;
         sum = sum * max(1 - pow(out, 2), 1.0e-4f);
         break;
      case 1:
         out = clamp(out, 0.0f, 1.0f);
         sum = clamp(sum + out, 0.0f, 1.0f) - out;
         sum = sum * max(out * (1 - out), 1.0e-4f);
         break;
      case 2:
         if(out < 0)
            sum *= 0.01f;
         break;
      default:
         break;
     }
   matrix_ig[shift_in + i] = sum;
  }

Als Nächstes müssen wir den Kernel UpdateWeightsAdamMultiModels zur Aktualisierung der Gewichtsmatrix ändern. Wie beim Kernel für die Fehlergradientenverteilung fügen wir den bestehenden Kernel-Parametern der Basisschicht mit vollständiger Vernetzung eine Modellkennung hinzu.

Achten Sie darauf, dass ein ähnlicher Kernel der neuronalen Basisschicht bereits in einem zweidimensionalen Aufgabenraum läuft. Gleichzeitig müssen wir keine Operationen mit nicht-aktualisierenden Modellen durchführen. Daher rufen wir den Kernel nur für ein Modell auf, und wir verwenden den Modellidentifizierungsparameter, um den Versatz in den Datenpuffern zu bestimmen. Ansonsten blieb der Kernel-Algorithmus unverändert. Den gesamten Algorithmus finden Sie im Anhang.

Damit ist die Arbeit an der OpenCL-Seite des Programms abgeschlossen. Als Nächstes gehen wir zur Arbeit mit dem Code unserer MQL5-Bibliothek über. Hier werden wir eine neue Klasse CNeuronMultiModel als Nachkomme unserer Basisklasse CNeuronBaseOCL erstellen.

Der Satz von Klassenmethoden ist ziemlich standardisiert und umfasst Methoden für die Klasseninitialisierung, die Arbeit mit Dateien, Feed Forward und Back Propagation Passes. Außerdem führen wir zwei neue Variablen ein, in denen wir die Anzahl der Modelle im Ensemble und die Kennung des zu trainierenden Modells festhalten. Letzteres wird sich bei jedem Rücklauf ändern.

class CNeuronMultiModel : public CNeuronBaseOCL
  {
protected:
   int               iModels;
   int               iUpdateModel;
   //---
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL); 
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL);

public:
                     CNeuronMultiModel(void){};
                    ~CNeuronMultiModel(void){};
   virtual bool      Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                                            ENUM_OPTIMIZATION optimization_type, int models);
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) {  activation = value;         }    
   //---
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL);   
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronMultiModels; }
  };

In einer Klasse werden keine neuen internen Objekte erstellt, daher bleiben der Konstruktor und der Destruktor der Klasse leer. Wir beginnen unsere Arbeit an der Erstellung von Methoden mit der Initialisierungsmethode der Klasse Init. Die Methode wird mit Parametern versehen:

  • numInputs — Anzahl der Neuronen in der vorherigen Schicht für ein Modell
  • open_cl — Zeiger auf ein OpenCL-Objekt
  • numNeurons — Anzahl der Neuronen in einer Schicht eines Modells
  • models — Anzahl der Modelle im Ensemble.

bool CNeuronMultiModel::Init(uint numInputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                             ENUM_OPTIMIZATION optimization_type, int models)
  {
   if(CheckPointer(open_cl) == POINTER_INVALID || numNeurons <= 0  || models <= 0)
      return false;

Im Hauptteil der Methode wird sofort geprüft, ob der Zeiger auf das OpenCL-Objekt relevant ist und ob die Abmessungen der Ebene und des Ensembles korrekt angegeben sind. Danach speichern wir die notwendigen Konstanten in internen Variablen.

   OpenCL = open_cl;
   optimization = ADAM;
   iBatch = 1;
   iModels = models;

Bitte beachten Sie, dass wir den Kernel für die Aktualisierung der Gewichtsmatrix nur für die Adam-Methode erstellt haben. Daher werden wir diese Methode zur Optimierung des Modells unabhängig von den erzielten Parametern anwenden.

Danach erstellen wir Puffer, um die Ergebnisse der neuronalen Schicht und Fehlergradienten aufzuzeichnen. Beachten Sie, dass die Größe aller Puffer im Verhältnis zur Anzahl der Modelle im Ensemble zunimmt. In der Anfangsphase werden die Puffer mit Nullwerten initialisiert. 

//---
   if(CheckPointer(Output) == POINTER_INVALID)
     {
      Output = new CBufferFloat();
      if(CheckPointer(Output) == POINTER_INVALID)
         return false;
     }
   if(!Output.BufferInit(numNeurons * models, 0.0))
      return false;
   if(!Output.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(Gradient) == POINTER_INVALID)
     {
      Gradient = new CBufferFloat();
      if(CheckPointer(Gradient) == POINTER_INVALID)
         return false;
     }
   if(!Gradient.BufferInit((numNeurons + 1)*models, 0.0))
      return false;
   if(!Gradient.BufferCreate(OpenCL))
      return false;

Als Nächstes wird der Puffer der Gewichtsmatrix mit Zufallswerten initialisiert. Die Puffergröße muss groß genug sein, um die Gewichte aller Ensemblemodelle innerhalb der aktuellen neuronalen Schicht zu speichern.

//---
   if(CheckPointer(Weights) == POINTER_INVALID)
     {
      Weights = new CBufferFloat();
      if(CheckPointer(Weights) == POINTER_INVALID)
         return false;
     }
   int count = (int)((numInputs + 1) * numNeurons * models);
   if(!Weights.Reserve(count))
      return false;
   float k = (float)(1 / sqrt(numInputs + 1));
   for(int i = 0; i < count; i++)
     {
      if(!Weights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!Weights.BufferCreate(OpenCL))
      return false;

Die Umsetzung der Adam-Optimierungsmethode erfordert die Einrichtung von zwei Datenpuffern, um die Momente 1 und 2 zu erfassen. Die Größe der angegebenen Puffer entspricht der Größe der Gewichtsmatrix. In der Anfangsphase initialisieren wir diese Puffer mit Nullwerten.

//---
   if(CheckPointer(DeltaWeights) != POINTER_INVALID)
      delete DeltaWeights;
//---
   if(CheckPointer(FirstMomentum) == POINTER_INVALID)
     {
      FirstMomentum = new CBufferFloat();
      if(CheckPointer(FirstMomentum) == POINTER_INVALID)
         return false;
     }
   if(!FirstMomentum.BufferInit(count, 0))
      return false;
   if(!FirstMomentum.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(SecondMomentum) == POINTER_INVALID)
     {
      SecondMomentum = new CBufferFloat();
      if(CheckPointer(SecondMomentum) == POINTER_INVALID)
         return false;
     }
   if(!SecondMomentum.BufferInit(count, 0))
      return false;
   if(!SecondMomentum.BufferCreate(OpenCL))
      return false;
//---
   return true;
  }

Vergessen Sie nicht, die Arbeitsabläufe in jeder Phase zu überwachen. Nach erfolgreichem Abschluss aller oben genannten Vorgänge ist die Methode abgeschlossen.

Nach der Initialisierung geht es weiter mit der feedForward-Methode. In den Parametern erhält diese Methode nur einen Zeiger auf das Objekt der vorherigen neuronalen Schicht. Und im Methodenrumpf prüfen wir sofort die Relevanz des empfangenen Zeigers.

bool CNeuronMultiModel::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

Für die Durchführung aller Feed-Forward-Operationen, die der Algorithmus der neuronalen Schicht vorsieht, haben wir bereits einen Kernel in dem OpenCL-Programm erstellt. Nun müssen wir die erforderlichen Daten an den Kernel übertragen und seine Ausführung aufrufen.

Zunächst definieren wir den Problemraum. Zuvor hatten wir beschlossen, einen zweidimensionalen Problemraum zu verwenden. In der ersten Dimension geben wir die Anzahl der Neuronen am Ausgang eines Modells an, in der zweiten die Anzahl dieser Modelle. Bei der Initialisierung der Klasse haben wir die Anzahl der Neuronen in einer Schicht eines Modells nicht gespeichert. Um nun die Größe der ersten Dimension des Problemraums zu bestimmen, dividieren wir die Gesamtzahl der Neuronen am Ausgang unserer Schicht durch die Anzahl der Modelle im Ensemble. Die zweite Dimension ist einfacher. Hier haben wir eine separate Variable mit der Anzahl der Modelle im Ensemble.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = Output.Total() / iModels;
   global_work_size[1] = iModels;

Nach der Definition des Aufgabenbereichs übergeben wir den Kernelparametern die erforderlichen Anfangsdaten. Vergessen wir nicht, die Ergebnisse der Ausführung der Operation zu überprüfen.

   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_i, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_FFMultiModels, def_k_ff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_inputs, NeuronOCL.Neurons() / iModels))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_FFMultiModels, def_k_ff_activation, (int)activation))
     {
      printf("Error of set parameter kernel FeedForward: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Beachten Sie, dass wir die neu erstellte ID unseres neuen Kernels verwenden, um den Kernel anzugeben. Zur Angabe der Parameter verwenden wir die Bezeichner des entsprechenden Kerns der Basisschicht, die vollständig verbunden ist. Dies ist möglich, indem alle Kernelparameter und ihre Reihenfolge gespeichert werden.

Nachdem wir alle Parameter übergeben haben, müssen wir nur noch den Kernel an die Ausführungswarteschlange senden.

   if(!OpenCL.Execute(def_k_FFMultiModels, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

Wir überprüfen die Ergebnisse aller Operationen und beenden die Methode.

Als Nächstes befassen wir uns mit Backpropagation-Methoden. Schauen wir uns zunächst die Methode calcHiddenGradients zur Verteilung der Fehlergradienten an. Wie bei der direkten Übergabe erhalten wir in den Methodenparametern einen Zeiger auf das Objekt der vorherigen neuronalen Schicht. Unmittelbar im Hauptteil der Methode prüfen wir die Relevanz des empfangenen Zeigers.

bool CNeuronMultiModel::calcHiddenGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;

Der nächste Schritt besteht darin, den Problemraum zu definieren. Hier ist alles ähnlich wie bei der Feed-Forward-Methode.

   uint global_work_offset[2] = {0, 0};
   uint global_work_size[2];
   global_work_size[0] = NeuronOCL.Neurons() / iModels;
   global_work_size[1] = iModels;

Dann übergeben wir die Ausgangsdaten an die Kernelparameter.

   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_w, getWeightsIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_g, getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_o, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_HGMultiModels, def_k_chg_matrix_ig, NeuronOCL.getGradientIndex()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_outputs, Neurons() / iModels))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_activation, NeuronOCL.Activation()))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Wie Sie sehen können, handelt es sich hier um einen ziemlich standardisierten Algorithmus zur Organisation der Arbeit des OpenCL-Programmkerns, den wir bereits mehr als einmal implementiert haben. Es gibt jedoch eine Nuance bei der Übergabe des Modellidentifikators für das Training. Wir müssen eine zufällige Modellnummer für das Training wählen. Dazu werden wir einen Pseudozufallszahlengenerator verwenden. Vergessen Sie jedoch nicht, dass wir für dieses Modell im nächsten Schritt die Gewichtsmatrix aktualisieren müssen. Daher speichern wir die resultierende zufällige Modellkennung in der zuvor erstellten Variablen iUpdateModel. Wir können seinen Wert bei der Aktualisierung der Gewichtsmatrix verwenden.

   iUpdateModel = (int)MathRound(MathRand() / 32767.0 * (iModels - 1));
   if(!OpenCL.SetArgument(def_k_HGMultiModels, def_k_chg_model, iUpdateModel))
     {
      printf("Error of set parameter kernel calcHiddenGradients: %d; line %d", GetLastError(), __LINE__);
      return false;
     }

Nach erfolgreicher Übergabe aller Parameter senden wir den Kernel an die Ausführungswarteschlange und schließen die Methode ab.

   if(!OpenCL.Execute(def_k_HGMultiModels, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel CalcHiddenGradient: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

Der Algorithmus zur Aktualisierung der Gewichtungsmatrix wiederholt die Schritte der Vorbereitung und der Einordnung des Kerns in die Warteschlange vollständig und weist keine Fallstricke auf. Deshalb verzichte ich hier auf eine ausführliche Beschreibung. Der vollständige Code des EAs befindet sich im Anhang.

Um mit Dateien zu arbeiten, verwenden wir die Methoden Save & Load (Speichern und Laden). Ihr Algorithmus ist recht einfach. In der neuen Klasse legen wir nur zwei Variablen an: die Anzahl der Modelle im Ensemble und den Bezeichner des trainierten Modells. Nur die erste Variable enthält den Hyperparameter, den wir speichern müssen. Der Prozess der Speicherung aller geerbten Objekte und Variablen ist bereits in den Methoden der Elternklasse organisiert. Diese Klasse bietet auch die notwendigen Kontrollen. Um Daten zu speichern, brauchen wir also nur zuerst eine ähnliche Methode der übergeordneten Klasse aufzurufen und dann den Wert nur eines Hyperparameters zu speichern.

bool CNeuronMultiModel::Save(const int file_handle)
  {
   if(!CNeuronBaseOCL::Save(file_handle))
      return false;
   if(FileWriteInteger(file_handle, iModels) <= 0)
      return false;
//---
   return true;
  }

Das Laden von Daten aus einer Datei ist auf ähnliche Weise organisiert.

Damit ist unsere Arbeit mit dem Code der neuen Klasse abgeschlossen. Der vollständige Code aller Methoden ist im Anhang zu finden.

Doch bevor wir die Klasse verwenden können, müssen wir noch einige Aktionen in unserem Bibliothekscode durchführen. Zunächst einmal müssen wir Konstanten erstellen, um die Kernel und die hinzugefügten Parameter zu identifizieren.

#define def_k_FFMultiModels             46 ///< Index of the kernel of the multi-models neuron to calculate feed forward
#define def_k_HGMultiModels             47 ///< Index of the kernel of the multi-models neuron to calculate hiden gradient
#define def_k_chg_model                 6  ///< Number of model to calculate
#define def_k_UWMultiModels             48 ///< Index of the kernel of the multi-models neuron to update weights
#define def_k_uwa_model                 9  ///< Number of model to update

Dann fügen wir hinzu:

  • Block zur Erstellung eines neuen Typs einer neuronalen Schicht in der Methode CNet::Create
  • neuer Schichttyp für die Methode CLayer::CreateElement
  • neuer Typ in der Feed Forward Dispatch-Methode der Basisklasse des neuronalen Netzes
  • neuer Typ für die Backpropagation-Dispatch-Methode CNeuronBaseOCL::calcHiddenGradients(CObject *TargetObject).

Wir haben eine Klasse für den parallelen Betrieb mehrerer unabhängiger voll vernetzter Schichten entwickelt, mit der wir Ensembles von Modellen erstellen können. Aber das ist nur ein Teil und nicht der gesamte Algorithmus der Forschung durch Unstimmigkeit. Um den vollständigen Algorithmus zu implementieren, werden wir eine neue Klasse von CEVD-Modellen erstellen, ähnlich wie das Modul für intrinsische Neugierde. Es gibt viele Ähnlichkeiten in den Klassenstrukturen. Dies ist an den Namen der Methoden und Variablen zu erkennen. Wir sehen den Erfahrungswiedergabepuffer CReplayBuffer. Es gibt zwei interne Modelle cTargetNet und cForwardNet, aber es gibt kein inverses Modell. Wie cForwardNet werden wir ein Ensemble von Modellen verwenden. Die Unterschiede liegen, wie immer, im Detail.

//+------------------------------------------------------------------+
//| Exploration via Disagreement                                     |
//+------------------------------------------------------------------+
class CEVD : protected CNet
  {
protected:
   uint              iMinBufferSize;
   uint              iStateEmbedingLayer;
   double            dPrevBalance;
   bool              bUseTargetNet;
   bool              bTrainMode;
   //---
   CNet              cTargetNet;
   CReplayBuffer     cReplay;
   CNet              cForwardNet;

   virtual bool      AddInputData(CArrayFloat *inputVals);

public:
                     CEVD();
                     CEVD(CArrayObj *Description, CArrayObj *Forward);
   bool              Create(CArrayObj *Description, CArrayObj *Forward);
                    ~CEVD();
   int               feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              backProp(int batch, float discount = 0.999f);
   int               getAction(int state_size = 0);    
   float             getRecentAverageError() { return recentAverageError; }
   bool              Save(string file_name, bool common = true);
   bool              Save(string dqn, string forward, bool common = true);
   virtual bool      Load(string file_name, bool common = true);
   bool              Load(string dqn, string forward, uint state_layer, bool common = true);
   //---
   virtual int       Type(void)   const   {  return defEVD;   }
   virtual bool      TrainMode(bool flag) { bTrainMode = flag; return (CNet::TrainMode(flag) && cForwardNet.TrainMode(flag));}
   virtual bool      GetLayerOutput(uint layer, CBufferFloat *&result)
     { return        CNet::GetLayerOutput(layer, result); }
   //---
   virtual void      SetStateEmbedingLayer(uint layer) { iStateEmbedingLayer = layer; }
   virtual void      SetBufferSize(uint min, uint max);
  };

Wir fügen die Variable bTrainMode hinzu, um den Algorithmus in Betriebs- und Trainingsprozesse zu trennen. Wir fügen das Flag bUseTargetNet hinzu, da wir die ständige Aktualisierung von cTargetNet vor jedem Modellaktualisierungspaket eliminiert haben. Wir haben auch Änderungen am Algorithmus der Methode vorgenommen. Aber das Wichtigste zuerst.

Bei der Feed-Forward-Methode und der Methode zur Bestimmung der Agentenaktion ist der Algorithmus nun in die Prozesse Betrieb und Training unterteilt. Das liegt daran, dass wir den Agenten während des Trainings zwingen wollen, die Umgebung so weit wie möglich zu erkunden. Im Betrieb wollen wir dagegen unnötige Risiken ausschließen und nur interne Richtlinien befolgen. Schauen wir uns an, wie dies umgesetzt wird.

Der Beginn der Feed-Forward-Methode wiederholt denjenigen der entsprechenden intrinsischen Curiosity-Block-Methode. Bei den Parametern handelt es sich um den Ausgangszustand des Systems. Wir ergänzen sie mit Daten über den Kontostand und die offenen Positionen. Dann rufen wir die Feed-Forward-Methode des trainierten Modells auf.

int CEVD::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(!AddInputData(inputVals))
      return -1;
//---
   if(!CNet::feedForward(inputVals, window, tem))
      return -1;

Der Algorithmus zur Aktionsauswahl ist in zwei Ströme unterteilt: Training und Betrieb. Im Trainingsmodus lesen wir den verborgenen (komprimierten) Zustand der Umgebung aus dem trainierten Modell aus und führen einen Feed-Forward-Durchlauf durch unser Ensemble von dynamischen Modellen durch. Ich möchte Sie daran erinnern, dass wir im Gegensatz zum internen Neugiermodul die Zustandsvorhersage nicht für eine bestimmte Aktion, sondern für die gesamte Bandbreite möglicher Aktionen auf einmal betrachten. Und erst nach einem erfolgreichen Forward-Durchlauf des Ensembles rufen wir die Methode zur Bestimmung der optimalen Aktion auf. Wir werden uns mit dieser Methode etwas später vertraut machen.

   int action = -1;
   if(bTrainMode)
     {
      CBufferFloat *state;
      //if(!GetLayerOutput(1, state))
      //   return -1;
      if(!GetLayerOutput(iStateEmbedingLayer, state))
         return -1;
      if(!cForwardNet.feedForward(state, 1, false))
        {
         delete state;
         return -1;
        }
      double balance = AccountInfoDouble(ACCOUNT_BALANCE);
      double reward = (dPrevBalance == 0 ? 0 : balance - dPrevBalance);
      dPrevBalance = balance;
      action = getAction(state.Total());
      delete state;
      if(action < 0 || action > 3)
         return -1;
      if(!cReplay.AddState(inputVals, action, reward))
         return -1;
     }

Sobald die Aktion erfolgreich definiert ist, fügen wir den Zustand dem Erfahrungswiedergabepuffer hinzu.

Im Betriebsmodus führen wir keine unnötigen Aktionen durch, sondern bestimmen nur die optimale Aktion auf der Grundlage der dem Agenten innewohnenden Politik und schließen die Methode ab.

   else
      action = getAction();
//---
   return action;
  }

Der Algorithmus zur Bestimmung der optimalen Aktion ist ebenfalls in 2 Zweige unterteilt: Training und Betrieb.

int CEVD::getAction(int state_size = 0)
  {
   CBufferFloat *temp;
//--- get the result of the trained model.
   CNet::getResults(temp);
   if(!temp)
      return -1;

Zu Beginn der Methode laden wir das Ergebnis des Vorwärtsdurchlaufs des trainierten Modells. Für das Modelltraining wird dieser Wert dann um den Wert der Varianz der Vorhersagen des Ensembles der dynamischen Modelle für jede mögliche Aktion angepasst. Dazu laden wir zunächst das Ergebnis des Ensembles in einen Vektor hoch und wandeln den Vektor dann in eine Matrix um. In der sich daraus ergebenden Matrix stellt jede einzelne Zeile den vorhergesagten Systemzustand für eine einzelne Aktion dar. Unsere Matrix enthält Vorhersagen aus allen Ensemble-Modellen. Um die Verarbeitung der Ergebnisse zu erleichtern, wird die Matrix horizontal in mehrere gleich große Matrizen mit geringerer Größe unterteilt. Die Anzahl dieser Matrizen ist gleich der Anzahl der Modelle im Ensemble. Jede solche Matrix hat eine Zeilendimension, die der Bandbreite der möglichen Aktionen unseres Agenten entspricht.

Nun können wir Matrixoperationen verwenden und zunächst eine Matrix von Durchschnittswerten für jede einzelne Aktion einer einzelnen Zustandskomponente finden. Und dann können wir die Varianz der Abweichungen der Prognosematrizen vom Durchschnitt berechnen. Wir addieren die durchschnittliche Varianz für jede Aktion zu den vorhergesagten Belohnungswerten des trainierten Modells. An diesem Punkt können wir einen Faktor verwenden, um ein Gleichgewicht zwischen Erkundung und Ausbeutung herzustellen. Um die Erkundung der Umwelt zu maximieren, können wir nur die Varianz der vorhergesagten Werte verwenden, ohne uns auf die erwartete Belohnung zu konzentrieren. Auf diese Weise schaffen wir Anreize für das Modell, so viel wie möglich aus der Umwelt zu lernen, ohne die Politik des Agenten zu beeinflussen.

//--- in training mode, make allowances for "curiosity"
   if(bTrainMode && state_size > 0)
     {
      vector<float> model;
      matrix<float> forward;
      cForwardNet.getResults(model);
      forward.Init(1, model.Size());
      forward.Row(model, 0);
      temp.GetData(model);
      //---
      int actions = (int)model.Size();
      forward.Reshape(forward.Cols() / state_size, state_size);
      matrix<float> ensemble[];
      if(!forward.Hsplit(forward.Rows() / actions, ensemble))
         return -1;
      matrix<float> means = ensemble[0];
      int total = ArraySize(ensemble);
      for(int i = 1; i < total; i++)
         means += ensemble[i];
      means = means / total;
      for(int i = 0; i < total; i++)
         ensemble[i] -= means;
      means = MathPow(ensemble[0], 2.0);
      for(int i = 1 ; i < total; i++)
         means += MathPow(ensemble[i], 2.0);
      model += means.Sum(1) / total;
      temp.AssignArray(model);
     }

Während des Betriebs des Modells nehmen wir keine Anpassungen vor, sondern bestimmen die optimale Aktion auf der Grundlage des Prinzips der Maximierung der erwarteten Belohnung.

//---
   return temp.Argmax();
  }

Der vollständige Code der Methoden ist im Anhang zu finden.

Gehen wir etwas näher auf die Methode des umgekehrten Durchgangs ein. Um unnötige Iterationen während des Modellbetriebs zu vermeiden, schließt die Backward-Pass-Methode ihre Arbeit sofort ab, wenn kein Modelltrainings-Flag vorhanden ist. So können Sie schnell vom Modell-Trainingsmodus zum Testmodus wechseln, ohne den EA-Code zu ändern.

bool CEVD::backProp(int batch, float discount = 0.999000f)
  {
//---
   if(cReplay.Total() < (int)iMinBufferSize || !bTrainMode)
      return true;

Nach der Übergabe des Kontrollblocks erstellen wir die erforderlichen lokalen Variablen.

//---
   CBufferFloat *state1, *state2, *targetVals = new CBufferFloat();
   vector<float> target, actions, st1, st2, result;
   matrix<float> forward;
   double reward;
   int action;

Und nach den Vorbereitungsarbeiten organisieren wir einen Modelltrainingszyklus in der in den Methodenparametern festgelegten Paketgröße.

//--- training loop in the batch size
   for(int i = 0; i < batch; i++)
     {
      //--- get a random state and the buffer replay
      if(!cReplay.GetRendomState(state1, action, reward, state2))
         return false;
      //--- feed forward pass of the training model ("current" state)
      if(!CNet::feedForward(state1, 1, false))
         return false;

Im Schleifenkörper erhalten wir zunächst einen Satz zufälliger Zustände aus dem Erfahrungswiedergabepuffer und führen den Vorwärtsdurchlauf durch das Trainingsmodell mit dem resultierenden Zustand aus.

      getResults(target);
      //--- unload state embedding
      if(!GetLayerOutput(iStateEmbedingLayer, state1))
         return false;
      //--- target net feed forward
      if(!cTargetNet.feedForward(state2, 1, false))
         return false;

Nach der Durchführung eines Feed-Forward-Durchlaufs des Trainingsmodells werden das Ergebnis und der verborgene Zustand gespeichert.

Mit Target Net erhalten wir auf ähnliche Weise eine Einbettung des nachfolgenden Systemzustands.

      //--- reward adjustment
      if(bUseTargetNet)
        {
         cTargetNet.getResults(targetVals);
         reward += discount * targetVals.Maximum();
        }
      target[action] = (float)reward;
      if(!targetVals.AssignArray(target))
         return false;
      //--- backpropagation pass of the model being trained
      CNet::backProp(targetVals);

Falls erforderlich, passen wir die externe Belohnung des Systems an den vorhergesagten Zielnetzwert an und führen einen Backpropagation-Durchgang des Trainingsmodells durch.

Im nächsten Schritt trainieren wir ein Ensemble von Modellen unter Verwendung der oben ermittelten Einbettungen der beiden nachfolgenden Zustände.

      //--- forward net feed forward pass - next state prediction
      if(!cForwardNet.feedForward(state1, 1, false))
         return false;
      //--- download "future" state embedding
      if(!cTargetNet.GetLayerOutput(iStateEmbedingLayer, state2))
         return false;

Zunächst führen wir einen Vorwärtsdurchlauf durch das Ensemble von Modellen mit der ersten Zustandseinbettung durch.

Dann laden wir die Ergebnisse des Feed-Forward-Durchlaufs herunter und bereiten darauf basierend Zielwerte vor, indem wir den Vektor der perfekten Aktion durch die Einbettung des nachfolgenden Zustands ersetzen, den wir mit Target Net erhalten haben.

Zu diesem Zweck werden die Ergebnisse des direkten Durchlaufs des Modellensembles in eine Matrix übersetzt, deren Spaltenzahl der Einbettung des Zustands entspricht. Die Matrix enthält die Ergebnisse des gesamten Ensembles von Modellen. Daher implementieren wir eine Schleife und ersetzen den Vorhersagezustand durch den Zielzustand für die perfekte Aktion in allen Ensemblemodellen.

      //--- prepare targets for forward net
      cForwardNet.getResults(result);
      forward.Init(1, result.Size());
      forward.Row(result, 0);
      forward.Reshape(result.Size() / state2.Total(), state2.Total());
      int ensemble = (int)(forward.Rows() / target.Size());
      //--- copy the target state to the ensemble goals matrix
      state2.GetData(st2);
      for(int r = 0; r < ensemble; r++)
         forward.Row(st2, r * target.Size() + action);

Auf den ersten Blick widerspricht das Ersetzen des Zielzustandes in allen Modellen der Idee, Ensemblemodelle auf unterschiedlichen Daten zu trainieren. Ich möchte Sie jedoch daran erinnern, dass wir die zufällige Modellauswahl in der Rückwärtsdurchgangsmethode der Klasse CNeuronMultiModel organisiert haben. Zu diesem Zeitpunkt wissen wir noch nicht, welches Modell trainiert werden soll. Deshalb bereiten wir Zielwerte für alle Modelle vor. Das zu trainierende Modell wird später ausgewählt.

      //--- backpropagation pass of foward net
      targetVals.AssignArray(forward);
      cForwardNet.backProp(targetVals);
     }
//---
   delete state1;
   delete state2;
   delete targetVals;
//---
   return true;
  }

Am Ende der Iterationen im Hauptteil des Trainingszyklus führen wir einen Rückwärtsdurchlauf durch das Ensemble der dynamischen Forward-Modelle mit den vorbereiteten Daten durch. Bitte beachten Sie, dass wir bei der Erstellung der Zielwerte nur die Zielwerte der einzelnen Aktionen geändert haben. Den Rest haben wir auf der Ebene der Prognosewerte belassen. Dies ermöglicht es uns, bei der Durchführung eines Backpropagation-Durchgangs den Fehlergradienten nur für eine bestimmte Aktion zu erhalten. In anderen Richtungen erwarten wir einen Null-Fehler.

Nach erfolgreichem Abschluss der Schleifeniterationen entfernen wir überflüssige Objekte und beenden die Methode.

Die übrigen Methoden der Klasse sind ähnlich aufgebaut wie die entsprechenden Methoden des intrinsischen Kuriositätenmoduls. Der vollständige Code des EAs befindet sich im Anhang.


3. Tests

Nachdem wir die erforderlichen Klassen und ihre Methoden erstellt haben, gehen wir zum Testen der geleisteten Arbeit über. Um die Funktionalität der erstellten Klassen zu testen, werden wir einen Expert Advisor, EVDRL-learning.mq5, erstellen. Wie zuvor werden wir einen Expert Advisor erstellen, der auf dem aus den vorherigen Artikeln basiert. Dieses Mal werden wir keine Änderungen an der Architektur des Trainingsmodells vornehmen. Stattdessen werden wir die Klasse des verwendeten Modells ändern. Ersetzen wir den Baustein der intrinsischen Neugier durch einen Block der Erkundung durch Unstimmigkeit.

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "EVD.mqh"
...........
...........
...........
...........
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
CEVD                 StudyNet;

Wir werden auch Änderungen an der Methode zur Beschreibung der Architektur von Modellen vornehmen. Wir werden die Beschreibung der Architektur des inversen Modells entfernen und Änderungen an der Architektur des Vorwärtsmodells vornehmen. Der letzte Punkt ist es wert, ein wenig zu verweilen. Bisher haben wir für das Vorwärtsmodell ein Perceptron mit einer versteckten Schicht verwendet. Lassen Sie uns eine ähnliche Architektur für Ensemble-Modelle schaffen.

Wenn wir das Problem auf einfache Weise lösen wollen, müssen wir eine Schicht mit Ausgangsdaten mit einer für alle Modelle ausreichenden Puffergröße und zwei aufeinanderfolgende Schichten unserer neuen CNeuronMultiModel-Klasse des Modellensembles erstellen. Beachten Sie jedoch, dass alle Ensemble-Modelle denselben Systemzustand verwenden. Das bedeutet, dass wir zur Aufrechterhaltung eines solchen Ensembles jedes Mal einen Datensatz in der Quelldatenschicht so oft wiederholen müssen, wie es Modelle in unserem Ensemble gibt. Meiner Meinung nach ist dies eine ineffiziente Nutzung des Speichers unseres OpenCL-Kontextes, die zusätzliche Zeit für die Verkettung eines großen Puffers von Quelldaten verursacht und gleichzeitig die Zeit für die Übertragung einer großen Datenmenge vom RAM des Geräts zum OpenCL-Kontextspeicher erhöht.

Es wäre viel effizienter, alle Modelle auf einen kleinen Datenpuffer zugreifen zu lassen, der nur eine Kopie des Systemzustands enthält. Bei der Erstellung der Feed Forward-Methode unserer CNeuronMultiModel-Klasse haben wir eine solche Option jedoch nicht vorgesehen.

Schauen wir uns die Architektur unserer grundlegenden vollverknüpften neuronalen Schicht an. In dieser Schicht hat jedes Neuron seinen eigenen Gewichtsvektor, unabhängig von den anderen Neuronen in dieser Schicht. In der Praxis handelt es sich um ein Ensemble unabhängiger Modelle von der Größe eines Neurons. Das bedeutet, dass wir eine grundlegende, vollständig verbundene, neuronale Schicht als versteckte Schicht für alle Modelle in unserem Ensemble verwenden können. Wir müssen lediglich eine neuronale Schicht implementieren, die groß genug ist, um alle Modelle in unserem Ensemble mit Daten zu versorgen.

Für unser Ensemble von Forward-Modellen erstellen wir also eine Quelldatenschicht mit 100 Elementen. Dies ist die Größe der komprimierten Darstellung des Systemzustands, die wir vom Hauptmodell erhalten. In diesem Fall fügen wir keinen Aktionsvektor hinzu, da wir erwarten, vom Modell Vorhersagezustände für den gesamten Bereich möglicher Aktionen zu erhalten.

Als Nächstes werden wir ein Ensemble von 5 Modellen verwenden. Als ausgeblendete Schicht wird eine vollständig verbundene neuronale Schicht mit 1000 Elementen (200 Neuronen pro Modell) erstellt.

Darauf folgt unsere neue Modell-Ensemble-Schicht. Hier geben wir die folgende Beschreibung der neuronalen Schicht an:

  • Typ des neuronalen Netzes (descr.type)                                defNeuronMultiModels;
  • Die Anzahl der Neuronen pro Modell (descr.count)     400 (100 Elemente, um jeden der Zustände von vier möglichen Aktionen zu beschreiben;
  • Anzahl der Neuronen in der vorherigen Schicht für 1 Modell (descr.window ) 200;
  • Anzahl der Modelle im Ensemble (descr.step) 5;
  • Aktivierungsfunktion (descr.activation)                        TANH (hyperbolischer Tangens, muss der Aktivierungsfunktion der Einbettungsschicht im Hauptmodell entsprechen);
  • Optimierungsmethode (descr.optimization)                    ADAM (die einzig mögliche für diesen Typ von neuronaler Schicht).
bool CreateDescriptions(CArrayObj *Description, CArrayObj *Forward)
  {
//---
...........
...........
//---
   if(!Forward)
     {
      Forward = new CArrayObj();
      if(!Forward)
         return false;
     }
//--- Model
...........
...........
...........
...........
//--- Forward
   Forward.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 100;
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 1000;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 400;
   descr.window = 200;
   descr.step = 5;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!Forward.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Wir haben das Modell trainiert und getestet, ohne die Bedingungen zu ändern: EURUSD-Paar, H1-Zeitrahmen, Standardindikatorparameter.

Anhand der Testergebnisse kann ich sagen, dass das Training eines Ensembles von Modellen mehr Zeit erfordert als das Training eines einzelnen Forward-Modells. In diesem Fall können Sie beobachten, wie das Modell zunächst eher chaotisch vorgeht. Während des Lernprozesses nimmt diese Zufälligkeit ab.

Insgesamt konnte das Modell während des Tests einen Gewinn erzielen.

Test-Diagramm

Ergebnisse des Tests


Schlussfolgerung

Beim Training von Verstärkungsmodellen bleibt das Lernen aus der Umwelt ein wichtiges Thema. In diesem Artikel wird ein anderer Ansatz zur Lösung dieses Problems vorgestellt: Erkundung bei Unstimmigkeit. Der Agent lernt online auf der Grundlage von Daten, die er selbst bei der Interaktion mit der Umwelt sammelt, und verwendet dabei die Methode der Richtlinienoptimierung. Gleichzeitig wird nach jeder Interaktion des Agenten mit der Umwelt die Gesamtheit der Modelle aktualisiert, was es dem Agenten ermöglicht, sein internes Umweltmodell bei jedem Schritt zu aktualisieren und genauere Vorhersagen über die zukünftigen Umweltzustände zu erhalten.

Wir haben ein Modell erstellt und es mit realen Daten im MetaTrader 5 Strategie-Tester getestet. Der Modus hat bei den Tests Gewinne erzielt. Die Ergebnisse deuten darauf hin, dass eine weitere Entwicklung in dieser Richtung gute Aussichten hat. Gleichzeitig wurde das Modell innerhalb eines relativ kurzen Zeitraums trainiert und getestet. Für den Einsatz des Modells im realen Handel wäre ein zusätzliches Modelltraining mit erweiterten historischen Daten erforderlich.


Referenzen

  1. Self-Supervised Erkundung durch Unstimmigkeit
  2. Neuronale Netze leicht gemacht (Teil 35): Modul für intrinsische Neugierde
  3. Neuronale Netze leicht gemacht (Teil 36): Relationales Verstärkungslernen
  4. Neuronale Netze leicht gemacht (Teil 37): Sparse Attention (Verringerte Aufmerksamkeit)

Programme, die im diesem Artikel verwendet werden

# Name Typ Beschreibung
1 EVDRL-learning.mq5 EA Ein Expert Advisor zum Trainieren des Modells
2 EVD.mqh Klassenbibliothek Erkundung über die Bibliotheksklasse Disagreement
2 ICM.mqh Klassenbibliothek Bibliotheksklasse mit dem intrinsischen Neugiermodul
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/12508

Beigefügte Dateien |
MQL5.zip (206.95 KB)
Kategorientheorie in MQL5 (Teil 17): Funktoren und Monoide Kategorientheorie in MQL5 (Teil 17): Funktoren und Monoide
Dieser Artikel, der letzte in unserer Reihe zum Thema Funktoren, befasst sich erneut mit Monoiden als Kategorie. Monoide, die wir in dieser Serie bereits vorgestellt haben, werden hier zusammen mit mehrschichtigen Perceptrons zur Unterstützung der Positionsbestimmung verwendet.
Entwicklung eines Replay-Systems — Marktsimulation (Teil 07): Erste Verbesserungen (II) Entwicklung eines Replay-Systems — Marktsimulation (Teil 07): Erste Verbesserungen (II)
Im letzten Artikel haben wir einige Korrekturen vorgenommen und Tests zu unserem Replay System hinzugefügt, um die bestmögliche Stabilität zu gewährleisten. Wir haben auch mit der Erstellung und Verwendung einer Konfigurationsdatei für dieses System begonnen.
Kategorientheorie in MQL5 (Teil 18): Natürliches Quadrat (Naturality Square) Kategorientheorie in MQL5 (Teil 18): Natürliches Quadrat (Naturality Square)
In diesem Artikel setzen wir unsere Reihe zur Kategorientheorie fort, indem wir natürliche Transformationen, eine der wichtigsten Säulen des Fachs, vorstellen. Wir befassen uns mit der scheinbar komplexen Definition und gehen dann auf Beispiele und Anwendungen dieser Serie ein: Volatilitätsprognosen.
Kategorientheorie in MQL5 (Teil 16): Funktoren mit mehrschichtigen Perceptrons Kategorientheorie in MQL5 (Teil 16): Funktoren mit mehrschichtigen Perceptrons
In diesem Artikel, dem 16. in unserer Reihe, geht es weiter mit einem Blick auf Funktoren und wie sie mit künstlichen neuronalen Netzen implementiert werden können. Wir weichen von unserem bisherigen Ansatz der Volatilitätsprognose ab und versuchen, eine nutzerdefinierte Signalklasse zum Setzen von Ein- und Ausstiegssignalen zu implementieren.