English Русский 中文 Español 日本語 Português
preview
Neuronale Netze leicht gemacht (Teil 32): Verteiltes Q-Learning

Neuronale Netze leicht gemacht (Teil 32): Verteiltes Q-Learning

MetaTrader 5Handelssysteme | 23 Januar 2023, 14:57
249 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

Die Q-Learning-Methode haben wir in dem Artikel „Neuronale Netze leicht gemacht (Teil 27): Tiefes Q-Learning (DQN)“ kennengelernt. In diesem Artikel haben wir eine Näherung der Q-Funktion gemacht, die eine Funktion der Abhängigkeit der Belohnung vom Zustand des Systems und der durchgeführten Aktion ist. Das Problem ist jedoch, dass die reale Welt sehr vielschichtig ist. Bei der Bewertung des Ist-Zustandes können nicht immer alle Einflussfaktoren berücksichtigt werden. Daher gibt es keine direkte Beziehung zwischen den geschätzten Parametern, die den Systemzustand beschreiben, der durchgeführten Aktion und den Belohnungen. Als Ergebnis der Q-Funktions-Näherung erhalten wir nur den gemittelten wahrscheinlichsten Wert der erwarteten Belohnung. Bei diesem Prozess sehen wir nicht die gesamte Verteilung der Belohnungen, die wir während des Modelltrainings erhalten haben. Außerdem wird der Durchschnittswert durch starke Ausreißer verzerrt. Im Jahr 2017 wurden zwei Artikel veröffentlicht. Ihre Autoren schlugen Algorithmen vor, um die Verteilung der Werte der erhaltenen Belohnungen zu untersuchen. In beiden Artikeln ist es den Autoren gelungen, die Ergebnisse des klassischen Q-Learnings in Atari-Computerspielen deutlich zu verbessern.


1. Merkmale des verteilten Q-Learnings

Das verteilte Q-Lernen approximiert wie das ursprüngliche Q-Lernen die Handlungsnutzenfunktion. Auch hier werden wir die Q-Funktion zur Vorhersage der erwarteten Belohnung approximieren. Der Hauptunterschied besteht darin, dass wir nicht einen einzelnen Belohnungswert für die abgeschlossene Aktion in einem bestimmten Zustand approximieren, sondern die gesamte Wahrscheinlichkeitsverteilung der erwarteten Belohnung. Natürlich können wir aufgrund der begrenzten Ressourcen nicht die Wahrscheinlichkeit des Auftretens jedes einzelnen Belohnungswertes schätzen. Aber wir können den Bereich der möglichen Belohnungen in mehrere Bereiche, d. h. Quantile, aufteilen.

Zur Bestimmung der Quantile werden zusätzliche Parameter eingeführt. Dabei handelt es sich um die minimalen (Vmin) und maximalen (Vmax) Werte im Bereich der erwarteten Belohnungen sowie um die Anzahl der Quantile (N). Die folgende Formel wird verwendet, um den Wertebereich für ein Quantil zu berechnen.

Im Gegensatz zur ursprünglichen Q-Learning-Methode, die eine Annäherung an den natürlichen Belohnungswert implizierte, nähert sich der Algorithmus des verteilten Q-Learnings der Wahrscheinlichkeitsverteilung, eine Belohnung innerhalb eines Quantils zu erhalten, wenn eine bestimmte Aktion in einem bestimmten Zustand ausgeführt wird. Durch die Umwandlung des Problems in die Aufgabe der Wahrscheinlichkeitsverteilung können wir das Problem der Q-Funktionsannäherung in ein Standard-Klassifizierungsproblem umwandeln. Dies führt zu einer Änderung der Verlustfunktion. Das ursprüngliche Q-Learning verwendet die Standardabweichung als Verlustfunktion, aber die Methode des verteilten Q-Learnings wird LogLoss verwenden. Wir haben diese Funktion bereits bei der Untersuchung von Policy Gradient vorgestellt.

LogLoss

Auf diese Weise können wir die Wahrscheinlichkeitsverteilung der Belohnung für jedes Zustands-Aktions-Paar annähernd bestimmen. Daher können wir bei der Auswahl der Aktion die erwartete Belohnung und ihre Wahrscheinlichkeit mit einem höheren Grad an Genauigkeit bestimmen. Ein weiterer Vorteil ist die Möglichkeit, die Wahrscheinlichkeiten eines bestimmten Belohnungsniveaus anstelle der durchschnittlichen Belohnung zu schätzen. Dies ermöglicht die Anwendung eines risikobasierten Ansatzes bei der Bewertung der Wahrscheinlichkeit, nach der Durchführung einer Aktion aus dem aktuellen Zustand des Systems positive und negative Belohnungen zu erhalten.

Die größte Wirkung wird erzielt, wenn die Umwelt für dieselbe Handlung in ähnlichen Situationen sowohl positive als auch negative Belohnungen zurückgibt. Mit dem ursprünglichen Q-Learning-Algorithmus, der eine Mittelwertbildung der erwarteten Belohnung verwendet, würden wir in solchen Fällen meistens einen Wert nahe 0 erhalten. Infolgedessen wird die Aktion übersprungen. Bei Verwendung des verteilten Q-Learning-Algorithmus können wir die Wahrscheinlichkeit des Erhalts echter Belohnungen bewerten. Die Anwendung eines risikobasierten Ansatzes wird helfen, die richtige Entscheidung zu treffen.

Achten Sie darauf, dass die Umgebung eine Belohnung vergibt, wenn der Agent eine der möglichen Aktionen ausführt. Daher erwarten wir für jede Aktion des Agenten, die er ausgehend vom aktuellen Zustand der Umgebung ausführt, mit 100%iger Wahrscheinlichkeit eine Belohnung. Die Summe der Wahrscheinlichkeiten für jede Agentenaktion sollte gleich 1 sein. Dieses Ergebnis kann durch die Verwendung der SoftMax-Funktion in Bezug auf mögliche Aktionen erreicht werden.

Wir werden weiterhin alle Werkzeuge des ursprünglichen Q-Learning-Algorithmus verwenden. Dazu gehören der Erfahrungswiederholungspuffer und das Zielnetzmodell zur Vorhersage künftiger Belohnungen. Natürlich werden wir einen Diskontfaktor für zukünftige Belohnungen verwenden.

Das Modelltraining basiert auf den Prinzipien des ursprünglichen Q-Learnings. Das Verfahren selbst basiert auf der Bellman-Gleichung.

Bellman-Gleichung

Wie bereits erwähnt, werden wir die vorhergesagten Werte zukünftiger Belohnungen mit dem Target Net auswerten, das eine „eingefrorene“ Kopie des trainierten Modells ist. Ich möchte mich mit den Ansätzen zu ihrer Verwendung befassen.

Eines der Merkmale des Verstärkungslernens und des Q-Learnings ist die Fähigkeit, Handlungsstrategien zu entwickeln, um das bestmögliche Ergebnis zu erzielen. Um die Entwicklung einer Strategie zu ermöglichen, enthält die Bellman-Gleichung einen Wert für den zukünftigen Zustand. Die Bewertung des zukünftigen Zustands der Umgebung sollte nämlich die maximal mögliche Belohnung von diesem Zustand bis zum Ende der Sitzung einschließen. Ohne diese Metrik würde das Modell nur darauf trainiert werden, die erwartete Belohnung für den aktuellen Übergang in einen neuen Zustand vorherzusagen.

Aber betrachten wir den Prozess einmal von der anderen Seite. Eine wirkliche Belohnung gibt es erst am Ende der Sitzung. Daher verwenden wir ein zweites neuronales Netz, um die fehlenden Daten vorherzusagen. Um zu vermeiden, dass zwei Modelle parallel trainiert werden, verwenden wir eine Kopie des trainierbaren Modells mit eingefrorenen Gewichten, um Belohnungen aus dem zukünftigen Zustand vorherzusagen. Werden die Vorhersagen eines untrainierten Modells genau sein? Höchstwahrscheinlich werden sie völlig zufällig sein. Aber durch die Einführung von Zufallswerten für die Ziele des Trainingsmodells verzerren wir die Wahrnehmung der Umgebung und führen das Training in die falsche Richtung.

Indem wir die Verwendung des Zielnetzes in der Anfangsphase ausschließen, können wir das Modell darauf trainieren, die Belohnung für den aktuellen Übergang mit einer gewissen Genauigkeit vorherzusagen. Nun, das Modell wird nicht in der Lage sein, eine Strategie zu entwickeln. Dies ist jedoch nur die erste Stufe des Lernens. Wenn wir ein Modell haben, das in der Lage ist, vernünftige Vorhersagen einen Schritt voraus zu machen, können wir es als Zielnetz verwenden. Danach können wir das Modell zusätzlich trainieren, um eine Strategie zwei Schritte voraus zu entwickeln.

Dieser Ansatz mit der schrittweisen Aktualisierung des Zielnetzes und mit der Verwendung vernünftiger Vorhersagewerte für den zukünftigen Zustand wird es dem Modell ermöglichen, die richtige Strategie zu entwickeln. Auf diese Weise können wir das gewünschte Ergebnis erzielen.

Ich möchte noch ein paar Worte über den Abzinsungsfaktor für den Wert zukünftiger Belohnungen hinzufügen. Dies ist das Werkzeug zur Verwaltung der Modellvorausschau bei der Strategieentwicklung. Dieser Hyperparameter hat einen großen Einfluss auf die Art der zu entwickelnden Strategie. Die Verwendung eines Koeffizienten nahe bei 1 weist das Modell an, Kauf-Strategien zu entwickeln. In diesem Fall wird das Modell Strategien für langfristige Investitionen entwickeln.

Im Gegenteil, eine Verringerung dieses Parameters und Werte, die näher bei 0 liegen, zwingen das Modell dazu, künftige Gewinne zu vergessen und sich mehr auf kurzfristige Gewinne zu konzentrieren. Das Modell wird also eine Scalping-Strategie entwickeln. Natürlich wird die Haltezeit der Position durch den verwendeten Zeitrahmen beeinflusst.

Fassen wir das Ganze zusammen.

  1. Die Methode des verteilten Q-Learnings basiert auf dem klassischen Q-Learning und ergänzt es.
  2. Als Modell wird ein neuronales Netz verwendet.
  3. Beim Training nähern wir uns der Wahrscheinlichkeitsverteilung der erwarteten Belohnung für den Übergang zu einem neuen Zustand in Abhängigkeit vom Zustands-Aktions-Paar.
  4. Die Verteilung wird durch eine Reihe von Quantilen einer festen Vergütungsspanne dargestellt.
  5. Die Anzahl der Quantile und der Bereich der möglichen Werte werden durch Hyperparameter bestimmt.
  6. Die Verteilung für jede mögliche Aktion wird durch denselben Wahrscheinlichkeitsvektor dargestellt.
  7. Um die Wahrscheinlichkeitsverteilung zu normalisieren, verwenden wir die SoftMax-Funktion im Zusammenhang mit jeder einzelnen Aktion.
  8. Das Modell wird auf der Grundlage der Bellman-Gleichung trainiert.
  9. Der probabilistische Ansatz zur Lösung des Problems erfordert die Verwendung von LogLoss als Verlustfunktion.
  10. Um den Lernprozess zu stabilisieren, verwenden wir Heuristiken des ursprünglichen Q-Learning-Algorithmus (Zielnetz, Erfahrungswiedergabepuffer).

Wie immer folgt auf den theoretischen Teil die praktische Umsetzung des Ansatzes mit MQL5.


2. Implementierung mittels MQL5

Bevor wir mit der Implementierung der verteilten Q-Learning-Methode unter Verwendung von MQL5 beginnen, sollten wir einen Arbeitsplan aufstellen. Wie im theoretischen Teil erwähnt, basiert die Methode auf dem ursprünglichen Q-Learning-Algorithmus. Wir haben diesen Algorithmus bereits früher implementiert. Daher können wir einen Expert Advisor erstellen, der auf dem zuvor verwendeten basiert.

Die Anwendung des probabilistischen Ansatzes erfordert Änderungen in dem Block, in dem die Zielwerte des Modells übermittelt werden.

Am Ausgang des Modells müssen wir die Daten mit der Funktion SoftMax normalisieren. Wir haben diese Funktion bereits kennengelernt und in dem Artikel über Policy Gradient implementiert. In diesem Artikel haben wir auch die Wahrscheinlichkeiten normalisiert. Damals haben wir die Wahrscheinlichkeiten für die Wahl von Aktionen verwendet. Die Daten wurden innerhalb der gesamten neuronalen Schicht normalisiert. Nun müssen wir die Wahrscheinlichkeiten der Verteilung für jede Aktion einzeln normalisieren. Dies bedeutet, dass wir die zuvor erstellte Klasse CNeuronSoftMaxOCL nicht in ihrer reinen Form verwenden können.

Wir haben also 2 Möglichkeiten. Wir können eine neue Klasse erstellen oder eine bestehende Klasse ändern. Ich habe mich für die zweite Möglichkeit entschieden. Die Struktur der zuvor erstellten Klasse war wie folgt:

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; }

public:
                     CNeuronSoftMaxOCL(void) {};
                    ~CNeuronSoftMaxOCL(void) {};
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);   
   virtual bool      calcOutputGradients(CArrayFloat *Target, float& error) override;
   //---
   virtual int       Type(void) override  const   {  return defNeuronSoftMaxOCL; }
  };

Zunächst fügen wir eine Variable hinzu, um die Anzahl der normalisierbaren Vektoren iHeads und die Methode zur Angabe dieses Parameters, SetHeads, zu speichern. Standardmäßig wird 1 Vektor angegeben. Dies entspricht der Normalisierung der Daten innerhalb der gesamten Ebene.

class CNeuronSoftMaxOCL    :  public CNeuronBaseOCL
  {
protected:
   uint              iHeads;
.........
.........
public:
                     CNeuronSoftMaxOCL(void) : iHeads(1) {};
                    ~CNeuronSoftMaxOCL(void) {};
.........
.........
   virtual void      SetHeads(int heads)  { iHeads = heads; }
.........
.........
  };

Wie Sie wissen, ändert das Hinzufügen einer neuen Variablen nichts an der Logik der Klassenmethoden. Als Nächstes sollten wir den Algorithmus der Methoden ändern. Wir sind vor allem an den Ansätzen der Vorwärts- und Rückwärtspropagation interessiert. Der Vorwärtsdurchlauf ist in der Methode feedForward implementiert. Bitte beachten Sie, dass diese Methode nur einen Hilfsalgorithmus für den Aufruf des entsprechenden Kerns des OpenCL-Programms implementiert. Alle Berechnungen werden auf der OpenCL-Kontextseite im Multithreading-Modus durchgeführt. Bevor wir also Änderungen an den Operationen vornehmen, die mit der Platzierung des Kernels in der Ausführungswarteschlange zusammenhängen, müssen wir Änderungen auf der OpenCL-Seite des Programms vornehmen.

Denken wir nach. Die Besonderheit der SoftMax-Funktion besteht darin, dass die Daten so normalisiert werden, dass die Summe des gesamten Ergebnisvektors gleich 1 ist. Die mathematische Formel der Funktion ist unten dargestellt.

SoftMax

Wie Sie sehen können, werden die Daten anhand der Summe der Exponentialwerte des gesamten Quelldatenvektors normalisiert. Mit Hilfe eines lokalen Datenarrays werden Daten zwischen verschiedenen Threads desselben Kernels übertragen. Dies ermöglicht die Erstellung einer Multithreading-Implementierung der Funktion auf der OpenCL-Kontextseite. Der von uns entwickelte Algorithmus läuft in einem eindimensionalen Problemraum. Er normalisiert Daten innerhalb eines einzelnen Vektors. Um die Probleme des neuen Algorithmus zu lösen, müssen wir das gesamte Volumen der Ausgangsdaten in mehrere gleiche Teile aufteilen und jeden Teil separat normalisieren. Die Schwierigkeit dabei ist, dass wir die Anzahl dieser Teile nicht kennen.

Aber es gibt auch eine gute Seite der Medaille. Jeder einzelne Block kann unabhängig voneinander normalisiert werden. Dies entspricht voll und ganz unserem Konzept des Multi-Thread-Computings. Für die verteilte Datennormalisierung können wir also zusätzliche Instanzen des zuvor erstellten Kernels ausführen.

Wir müssen lediglich das Gesamtvolumen der Quelldatenpuffer und der Ergebnispuffer in entsprechende Blöcke aufteilen. Zuvor haben wir den Kernel im eindimensionalen Aufgabenraum gestartet. Die OpenCL-Technologie ermöglicht die Nutzung des dreidimensionalen Aufgabenraums. In diesem Fall brauchen wir die dritte Dimension nicht. Auf jeden Fall können wir die zweite Dimension verwenden, um den Normalisierungsblock zu identifizieren.

Indem wir also eine weitere Dimension des Aufgabenraums hinzufügen, ermöglichen wir die verteilte Normalisierung in der zuvor erstellten Klasse SoftMax_FeedForward. Wir müssen noch Änderungen am Kernel-Code vornehmen. Aber diese Änderungen werden geringfügig sein. Wir müssen dem Kernel-Algorithmus die Verarbeitung der zweiten Aufgabenraumdimension hinzufügen.

Die Kernelparameter bleiben unverändert. In den Parametern übergeben wir Zeiger auf Datenpuffer und die Größe eines Datennormalisierungsvektors.

__kernel void SoftMax_FeedForward(__global float *inputs,
                                  __global float *outputs,
                                  const uint total)
  {
   uint i = (uint)get_global_id(0);
   uint l = (uint)get_local_id(0);
   uint h = (uint)get_global_id(1);
   uint ls = min((uint)get_local_size(0), (uint)256);
   uint shift_head = h * total;

Im Kernelkörper werden die Thread-IDs in beiden Dimensionen abgefragt. Sie definieren den Arbeitsaufwand für den aktuellen Thread und die Offsets in den Datenpuffern zu den zu verarbeitenden Elementen. Die erste Dimension zeigt an, wo sich der Thread im Daten-Normalisierungsalgorithmus befindet. Mit der zweiten Dimension bestimmen wir den Offset in den Datenpuffern. Im obigen Code habe ich die hinzugefügten Zeilen hervorgehoben.

Der Kernel-Algorithmus verfügt über eine Schleife der ersten Stufe, in der die Exponentialwerte der Ausgangsdaten summiert werden. Anpassung hinzufügen, um zum ersten Element des zu normalisierenden Quelldatenblocks zu springen (im Code hervorgehoben).

Beachten Sie, dass wir nur den Offset für den globalen Quelldatenpuffer verwenden. Wir ignorieren sie für das lokale Datenfeld. Das liegt daran, dass jede Arbeitsgruppe isoliert arbeitet und ihr eigenes lokales Datenfeld verwendet.

   __local float temp[256];
   uint count = 0;
   if(l < 256)
      do
        {
         uint shift = shift_head + count * ls + l;
         temp[l] = (count > 0 ? temp[l] : 0) + (shift < ((h + 1) * total) ? exp(inputs[shift]) : 0);
         count++;
        }
      while((count * ls + l) < total);
   barrier(CLK_LOCAL_MEM_FENCE);

Im vorherigen Block haben wir Teile der Gesamtsumme in den Elementen eines lokalen Arrays gesammelt. Es folgt eine Schleife, in der die Gesamtsumme der lokalen Array-Werte konsolidiert wird. Hier arbeiten wir nur mit einem lokalen Array. Dieser Prozess ist absolut unabhängig von der zweiten Dimension unseres Aufgabenraums und bleibt unverändert.

   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(l < 256)
         temp[l] += (l < count && (l + count) < total ? temp[l + count] : 0);
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   float sum = temp[0];

Am Ende des Kernels normalisieren wir die Ausgangsdaten und speichern den resultierenden Wert im Ergebnispuffer. Wie in der ersten Schleife verwenden wir auch hier den zuvor berechneten Offset in den globalen Datenpuffern.

   if(sum != 0)
     {
      count = 0;
      while((count * ls + l) < total)
        {
         uint shift = shift_head + count * ls + l;
         if(shift < ((h + 1) * total))
            outputs[shift] = exp(inputs[shift] / 10) / (sum + 1e-37f);
         count++;
        }
     }
  }

Wir verwenden einen ähnlichen Ansatz, wenn wir Änderungen am Kernel mit Gradientenverteilung an der vorherigen Schicht SoftMax_HiddenGradient vornehmen. Hinzufügen eines Offsets in den globalen Datenpuffern ohne Änderung des allgemeinen Algorithmus des Kernels.

__kernel void SoftMax_HiddenGradient(__global float* outputs,
                                     __global float* output_gr,
                                     __global float* input_gr)
  {
   size_t i = get_global_id(0);
   size_t outputs_total = get_global_size(0);
   size_t h = get_global_id(1);
   uint shift = h * outputs_total;
   float output = outputs[shift + i];
   float result = 0;
   for(int j = 0; j < outputs_total ; j++)
      result += outputs[shift + j] * output_gr[shift + j] * ((float)(i == j) - output);
   input_gr[shift + i] = result;
  }

Am SoftMax_OutputGradient-Kernel, der die Abweichung von der Referenzverteilung bestimmt, müssen keine Änderungen vorgenommen werden. Der Grund dafür ist, dass der Offset in diesem Kernel für ein bestimmtes Element in der Sequenz bestimmt wird, unabhängig davon, zu welchem Block ein bestimmtes Element gehört.

__kernel void SoftMax_OutputGradient(__global float* outputs,
                                     __global float* targets,
                                     __global float* output_gr)
  {
   size_t i = get_global_id(0);
   output_gr[i] = targets[i] / (outputs[i] + 1e-37f);
  }

Damit ist der Betrieb auf der OpenCL-Programmseite abgeschlossen. Kehren wir zum Code unserer Klasse CNeuronSoftMaxOCL zurück. Wir haben mit Änderungen am Feed Forward-Kernel begonnen. In ähnlicher Weise können wir Änderungen an den Methoden unserer Klasse vornehmen.

Wir haben in den Kerneln keine Parameter hinzugefügt oder geändert. Daher bleiben der Datenaufbereitungsalgorithmus und der Kernel-Aufruf unverändert. Die einzigen Änderungen betreffen die Art und Weise, wie der Aufgabenbereich festgelegt wird.

Zunächst definieren wir die Dimension eines Datennormalisierungsvektors. Sie lässt sich leicht ermitteln, indem die Größe des Ergebnispuffers durch die Anzahl der zu normalisierenden Vektoren geteilt wird. Wir speichern den resultierenden Wert in einer lokalen Variable size. Hier füllen wir auch das Array global_work_size des globalen Aufgabenbereichs. In der ersten Dimension geben wir die Größe eines oben berechneten Normalisierungsvektors an. In der zweiten Dimension ist die Anzahl dieser Vektoren anzugeben.

Um die Synchronisierung von Threads und den Datenaustausch zwischen Threads zu ermöglichen, haben wir zuvor eine Arbeitsgruppe geschaffen, die dem globalen Aufgabenbereich entspricht. Das liegt daran, dass wir die Daten innerhalb des gesamten Datenpuffers normalisiert haben. Jetzt ist die Situation ein wenig anders. Wir müssen mehrere einzelne Blöcke im Datenpuffer normalisieren. Bei der Erstellung des Feed-Forward-Kerns haben wir festgestellt, dass die Arbeit mit dem lokalen Datenfeld unverändert blieb. Dies wurde dadurch möglich, dass die Normalisierung der einzelnen Vektoren in einer separaten Arbeitsgruppe durchgeführt werden sollte. In diesem Fall müssen wir also ein separates Array für den lokalen Gruppenaufgabenbereich local_work_size erstellen.

Die Dimensionen des globalen und des lokalen Aufgabenraums müssen identisch sein. Daher müssen wir einen zweidimensionalen lokalen Aufgabenraum definieren. Die Anzahl der globalen Threads muss ein Vielfaches der Anzahl der lokalen Threads in jeder einzelnen Aufgabenraumdimension sein.

Zuvor haben wir den globalen Frageraum durch einen normalisierbaren Vektor in der ersten Dimension und die Anzahl solcher Vektoren in der zweiten Dimension spezifiziert. In jeder Arbeitsgruppe soll nur ein Vektor normalisiert werden. Logischerweise sollten wir die Größe eines normalisierbaren Vektors in der ersten Dimension des lokalen Aufgabenraums angeben. Wir werden in der zweiten Dimension 1 angeben. Dies entspricht einem Vektor.

Nachfolgend finden Sie den geänderten Code der feedForward-Methode. Alle Änderungen werden hervorgehoben. Wie Sie sehen können, gibt es nicht so viele Änderungen. Es ist jedoch sehr wichtig, alle wichtigen Punkte zu berücksichtigen.

bool CNeuronSoftMaxOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!OpenCL || !NeuronOCL)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = { size, iHeads };
   uint local_work_size[2] = { size, 1 };
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_inputs, NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_FeedForward, def_k_softmaxff_outputs, getOutputIndex());
   OpenCL.SetArgument(def_k_SoftMax_FeedForward, def_k_softmaxff_total, size);
   if(!OpenCL.Execute(def_k_SoftMax_FeedForward, 2, global_work_offset, global_work_size, local_work_size))
     {
      printf("Error of execution kernel SoftMax FeedForward: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

Ähnliche Änderungen wurden an der Methode vorgenommen, die den Fehlergradienten an die vorherige Schicht weitergibt: calcInputGradients. Aber in diesem Fall haben wir keine Arbeitsgruppen gebildet.

bool CNeuronSoftMaxOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID)
      return false;
   uint global_work_offset[2] = {0, 0};
   uint size = Output.Total() / iHeads;
   uint global_work_size[2] = {size, iHeads};
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_input_gr, NeuronOCL.getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_output_gr, getGradientIndex());
   OpenCL.SetArgumentBuffer(def_k_SoftMax_HiddenGradient, def_k_softmaxhg_outputs, getOutputIndex());
   if(!OpenCL.Execute(def_k_SoftMax_HiddenGradient, 2, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel SoftMax InputGradients: %d", GetLastError());
      return false;
     }
//---
   return true;
  }

Das Hinzufügen der verteilten Normalisierung ist ein Entwurfsmerkmal und sollte sich in den Dateiverarbeitungsmethoden widerspiegeln. Fahren wir mit der Klasse CNeuronSoftMaxOCL fort. Wir haben noch keine Dateimethoden für diese Klasse erstellt. Die Funktionalität ähnlicher Methoden der Elternklasse war ausreichend. Aber das Hinzufügen einer neuen Variablen, deren Werte für eine korrekte Wiederherstellung des Objektbetriebs gespeichert werden müssen, erfordert eine Neudefinition solcher Methoden.

Wie immer beginnen wir mit der Methode zur Datensicherung Save. Der Algorithmus ist recht einfach. Die Methode erhält als Parameter das Handle der Datei zum Schreiben der Daten. Normalerweise beginnen solche Methoden mit der Überprüfung der Korrektheit des empfangenen Handles. Wir werden keinen Block von Kontrollen erstellen. Stattdessen rufen wir eine ähnliche Methode der übergeordneten Klasse auf und übergeben das empfangene Handle an diese. Mit diesem Ansatz lösen wir zwei Aufgaben in einer Zeile des Codes. Alle erforderlichen Steuerelemente sind bereits in der Methode der übergeordneten Klasse implementiert. Das bedeutet, dass sie eine Kontrollfunktion ausübt. Außerdem wird das Speichern aller geerbten Objekte und Variablen implementiert. Daher wird auch die Datensicherungsfunktion ausgeführt. Wir brauchen nur das Ergebnis der Methode der übergeordneten Klasse zu überprüfen, um den Ausführungsstatus der angegebenen Funktionalität herauszufinden.

Nach der erfolgreichen Ausführung der Methode der übergeordneten Klasse speichern wir den Wert der neuen Variablen und schließen die Methode ab.

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

Die Datenlademethode CNeuronSoftMaxOCL folgt einem ähnlichen Operationsablauf. Zusätzlich kontrolliert sie die Mindestanzahl der normalisierbaren Methoden.

bool CNeuronSoftMaxOCL::Load(const int file_handle)
  {
   if(!CNeuronBaseOCL::Load(file_handle))
      return false;
   iHeads = (uint)FileReadInteger(file_handle);
   if(iHeads <= 0)
      iHeads = 1;
//---
   return true;
  }

Damit ist unsere Arbeit mit der Klasse CNeuronSoftMaxOCL abgeschlossen. Jetzt muss nur noch die Möglichkeit hinzugefügt werden, dass der Nutzer die Anzahl der zu normalisierenden Vektoren angeben kann. Wir werden keine Änderungen am Objekt zur Beschreibung der neuronalen Schicht vornehmen. Mit dem Parameter step geben wir die Anzahl der zu normalisierenden Vektoren an. In der Initialisierungsmethode des neuronalen Netzes CNet::Create wird bei der Erstellung der SoftMax-Schicht der angegebene Parameter an die erstellte Klasseninstanz CNeuronSoftMaxOCL übergeben. Die Änderungen sind im nachstehenden Code hervorgehoben.

void CNet::Create(CArrayObj *Description)
  {
.........
.........
//---
   for(int i = 0; i < total; i++)
     {
.........
.........
      if(!!opencl)
        {
.........
.........
         CNeuronSoftMaxOCL *softmax = NULL;
         switch(desc.type)
           {
.........
.........
            case defNeuronSoftMaxOCL:
               softmax = new CNeuronSoftMaxOCL();
               if(!softmax)
                 {
                  delete temp;
                  return;
                 }
               if(!softmax.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax.SetHeads(desc.step);
               if(!temp.Add(softmax))
                 {
                  delete softmax;
                  delete temp;
                  return;
                 }
               softmax = NULL;
               break;
.........
.........
           }
        }
.........
.........
//---
   return;
  }

Zur Umsetzung der Methode sind keine weiteren Änderungen an der Architektur des neuronalen Netzes erforderlich.

Der Modelllernprozess ist in dem EA „DistQ-learning.mq5“ implementiert. Die EA wurde auf der Grundlage des EAs Q-learning.mq5 erstellt, der zum Trainieren des Modells mit der ursprünglichen Q-learning-Methode verwendet wurde.

Nach dem verteilten Q-Learning-Algorithmus müssen wir zusätzliche Hyperparameter einführen, die den Bereich der erwarteten Belohnungen und die Anzahl der Quantile in der Wahrscheinlichkeitsverteilung bestimmen.

Bei der vorgeschlagenen Umsetzung bin ich dieses Problem aus einem anderen Blickwinkel angegangen. Wie bei den vorangegangenen Tests erstellen wir das Modell mit dem Hilfsmittel NetCreator. Die Anzahl der Quantile wird auf der Grundlage der Größe der Schicht mit den Ergebnissen der Modelloperation bestimmt. Dabei wird die Anzahl der möglichen Aktionen berücksichtigt, die durch den Parameter „Action“ des EA angegeben wird.

int                  Actions     =  3; 

Im Lernprozess müssen wir einen bestimmten Belohnungswert aus der Umwelt mit einem bestimmten Quantil abgleichen. Gehen wir von den folgenden Annahmen aus. Nach der von uns entwickelten Belohnungspolitik kann es sowohl positive als auch negative Belohnungen geben. Sie können als Belohnungen und Bestrafungen bezeichnet werden. Wir gehen davon aus, dass der Median des Vektors einer Belohnung von Null entspricht. Um die Größe des Quantils in Bezug auf die physische Belohnung zu messen, führen wir einen externen Parameter Step ein.

input double               Step = 5e-4;

Die anderen externen Parameter des EA bleiben unverändert.

In der EA-Initialisierungsfunktion OnInit wird nach erfolgreichem Laden des Modells die Anzahl der Quantile durch die Größe der neuronalen Schicht der Modellausgabe und die Anzahl des Medianquantils bestimmt.

int OnInit()
  {
.........
.........
//---
   float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, false))
      return INIT_FAILED;
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   if(!StudyNet.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   StudyNet.getResults(TempData);
   action_dist = TempData.Total() / Actions;
   if(action_dist <= 0)
      return INIT_PARAMETERS_INCORRECT;
   action_midle = (action_dist + 1) / 2;
//---
.........
.........
//---
   return(INIT_SUCCEEDED);
  }

Als Nächstes kommen wir zur Funktion für das Modelltraining. Der Datenaufbereitungsblock blieb unverändert, da wir keine Daten für die Trainingsstichprobe ändern. Die Änderungen betreffen nur den Block, der die Zielergebnisse für die Vorhersage der erwarteten Belohnung angibt.

Erstellen wir zunächst einen Vektor der voraussichtlichen zukünftigen Kosten. Dieser Vektor wird drei Elemente enthalten, für jede Aktion einen Wert. Wir werden Vektoroperationen verwenden, um die Werte des Vektors zu berechnen. Zunächst übertragen wir den Ergebnispuffer Target Net in eine Zeilenmatrix. Dann formatieren wir die Matrix in eine Tabelle mit 3 Zeilen, eine Zeile für jede Aktion. In jeder Zeile finden wir das Element mit der größten Wahrscheinlichkeit. Wir übersetzen die Quantile der maximalen Elemente in natürliche Belohnungsausdrücke.

void Train(void)
  {
//---
.........
.........
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
.........
.........
      for(int batch = 0; batch < (Batch * UpdateTarget); batch++)
        {
.........
.........
//---
         vectorf add = vectorf::Zeros(Actions); 
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), 12, true))
               return;
            TargetNet.getResults(TempData);
            vectorf temp;
            TempData.GetData(temp);
            matrixf target = matrixf::Zeros(1, temp.Size());
            if(!target.Row(temp, 0) || !target.Reshape(Actions, action_dist))
               return;
            add = DiscountFactor * (target.ArgMax(1) - action_midle) * Step;
           }

Nachdem wir den vorhergesagten Wert des zukünftigen Zustands bestimmt haben, können wir einen Puffer von Zielwerten für unser Modell vorbereiten. Zunächst werden wir ein wenig Vorbereitungsarbeit leisten. Wir füllen den Reward-Puffer mit Nullwerten und ermitteln den potenziellen Gewinn aus dem aktuellen Zustand des Systems für die nächste Kerze.

         Rewards.BufferInit(Actions * action_dist, 0);
         double reward = Rates[i].close - Rates[i].open;

Die weiteren Schritte hängen von der Richtung der Kerze ab. Im Falle einer Aufwärtskerze, schaffen wir eine positive Belohnung für eine Kaufaktion und eine erhöhte negative Belohnung für eine Verkaufsaktion. Darüber hinaus haben wir eine negative Belohnung für den Zustand außerhalb des Marktes als Strafe für entgangene Gewinne festgelegt. Dann addieren wir den berechneten Wert des zukünftigen Zustands zur erhaltenen Belohnung. Bei der Entwicklung des ursprünglichen Q-Learning-Algorithmus haben wir jedoch die Belohnung im Ziel-Ergebnispuffer als natürlichen Ausdruck angegeben. Dieses Mal bestimmen wir das Belohnungsquantil jeder Aktion und schreiben die Wahrscheinlichkeit von 1 für das entsprechende Ereignis auf. Die übrigen Elemente des Puffers haben eine Wahrscheinlichkeit von Null.

         if(reward >= 0)
           {
            int rew = (int)fmax(fmin((2 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-5 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

Der Algorithmus der Aktionen für eine Abwärtskerze ist ähnlich. Der einzige Unterschied ist die Belohnung und die Strafe für Kauf- und Verkaufsaktionen.

         else
           {
            int rew = (int)fmax(fmin((5 * reward + add[0]) / Step + action_midle, action_dist - 1), 0);
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((-2 * reward + add[1]) / Step + action_midle, action_dist - 1), 0) + action_dist;
            if(!Rewards.Update(rew, 1))
               return;
            rew = (int)fmax(fmin((reward + add.Max()) / Step + action_midle, action_dist - 1), 0) + 2 * action_dist;
            if(!Rewards.Update(rew, 1))
               return;
           }

Der restliche Code der Funktion bleibt unverändert, ebenso wie der gesamte hier nicht beschriebene Code des EA. Der vollständige Code des EAs befindet sich im Anhang. 


3. Tests

Der erstellte EA wurde verwendet, um das Modell zu trainieren, das aus folgenden Elementen besteht:

  • 3 Faltungsschichten zur Vorverarbeitung der Daten,
  • 3 vollständig verbundene versteckte Schichten mit je 1000 Neuronen,
  • 1 vollständig verknüpfte Entscheidungsschicht mit 45 Neuronen (15 Neuronen für jede der drei probabilistischen Verteilungen von Handlungen),
  • 1 SoftMax-Schicht zur Normalisierung von Wahrscheinlichkeitsverteilungen.

Das Modell wurde anhand der historischen Daten der letzten zwei Jahre trainiert. Verwendeter Zeitrahmen: H1. In der gesamten Artikelserie werden dieselbe Liste von Indikatoren und dieselben Indikatorparameter verwendet.

Das trainierte Modell wurde im Strategietester anhand der historischen Daten der letzten zwei Wochen getestet; diese Daten waren nicht in der Trainingsstichprobe enthalten. Dies gewährleistet ein reines Experiment, da das Modell anhand neuer Daten getestet wird.

Um das Modell im Strategietester zu testen, haben wir den EA „DistQ-learning-test.mq5“ erstellt. Der EA ist fast eine vollständige Kopie der Datei „Q-learning-test.mq5“, die zum Testen des mit der ursprünglichen Q-learning-Methode trainierten Modells verwendet wurde. Die einzige Änderung im EA-Code ist das Hinzufügen einer Aktionsauswahlfunktion GetAction.

Die Funktion erhält als Parameter einen Zeiger auf den Wahrscheinlichkeitsverteilungspuffer, der sich aus der Bewertung der aktuellen Situation durch das Modell ergibt. Dieser Puffer enthält Wahrscheinlichkeitsverteilungen über alle möglichen Werte. Um die Datenverarbeitung zu vereinfachen, verschieben wir die Pufferwerte in eine Matrix und ändern das Matrixformat in ein Tabellenformat. Die Anzahl der Zeilen darin entspricht der Anzahl der möglichen Aktionen des Agenten.

Als Nächstes bestimmen wir die Quantile mit der wahrscheinlichsten Belohnung für jede einzelne Aktion. 

int GetAction(CBufferFloat* probability)
  {
   vectorf prob;
   if(!probability.GetData(prob))
      return -1;
   matrixf dist = matrixf::Zeros(1, prob.Size());
   if(!dist.Row(prob, 0))
      return -1;
   if(!dist.Reshape(Actions, prob.Size() / Actions))
      return -1;
   prob = dist.ArgMax(1);

Danach vergleichen wir die erwartete Rendite von Kauf und Verkauf im aktuellen Zustand. Wenn die erwarteten Erträge gleich sind, wählen wir die Aktion mit der höchsten Wahrscheinlichkeit, eine Belohnung zu erhalten.

   if(prob[0] == prob[1])
     {
      if(prob[2] > prob[0])
         return 2;
      if(dist[0, (int)prob[0]] >= dist[1, (int)prob[1]])
         return 0;
      else
         return 1;
     }

Andernfalls wählen wir die Aktion mit der höchsten erwarteten Belohnung.

//---
   return (int)prob.ArgMax();
  }

Wie Sie sehen können, verwenden wir in diesem Fall eine gierige Strategie, um die Aktion mit der höchsten Rendite zu wählen.

Der vollständige Code des EAs befindet sich im Anhang.

Während der Test-EA zwei Wochen lang im MetaTrader 5-Strategietester lief und auf der Grundlage der Modellsignale handelte, erwirtschaftete er einen Gewinn von etwa 20 $. Alle Operationen hatten ein Mindestlos. Das nachstehende Schaubild zeigt einen deutlichen Aufwärtstrend beim Saldowert.

Modellversuche im Strategietester

Testen eines verteilten Q-Learning-Modells

Die Statistik der Handelsgeschäfte zeigt, dass fast 56 % der Geschäfte rentabel waren. Bitte beachten Sie jedoch, dass der EA ausschließlich zum Testen des Modells im Strategietester gedacht ist und nicht für den realen Handel an den Finanzmärkten geeignet ist.

Der vollständige Code aller in diesem Artikel verwendeten Programme ist im Anhang verfügbar.


Schlussfolgerung

In diesem Artikel haben wir einen weiteren Algorithmus für das Verstärkungstraining kennengelernt: Verteiltes Q-Learning. Mit diesem Algorithmus untersucht das Modell die Wahrscheinlichkeitsverteilung der Belohnungen bei der Ausführung einer Handlung in einem bestimmten Zustand der Umgebung. Durch die Untersuchung der Wahrscheinlichkeitsverteilung anstelle der Vorhersage des Durchschnittswerts der Belohnung können wir mehr Informationen über die Art der Belohnung erhalten und die Stabilität beim Modelltraining erhöhen. Wenn wir die Wahrscheinlichkeitsverteilung der erwarteten Rendite kennen, können wir außerdem die Risiken bei unseren Handelsgeschäften besser einschätzen.

Der Modelltest im MetaTrader 5 Strategietester zeigte die potenzielle Rentabilität des Ansatzes. Der Algorithmus kann weiterentwickelt und zur Erstellung von Handelsentscheidungen verwendet werden.

Im Anhang finden Sie den gesamten Code aller Programme und Bibliotheken.


Referenzen

  1. Neuronale Netze leicht gemacht (Teil 26): Reinforcement-Learning
  2. Neuronale Netze leicht gemacht (Teil 27): Tiefes Q-Learning (DQN)
  3. Neuronale Netze leicht gemacht (Teil 28): Policy Gradient Algorithmus
  4. A Distributional Perspective on Reinforcement Learning
  5. Distributional Reinforcement Learning with Quantile Regression

Programme, die im diesem Artikel verwendet werden

# Name Typ Beschreibung
1 DistQ-learning.mq5 EA EA zur Optimierung des Modells
2 Q-learning-test.mq5 EA
Ein Expert Advisor zum Testen des Modells im Strategy Tester
3 NeuroNet.mqh Klassenbibliothek Bibliothek zur Erstellung neuronaler Netzmodelle
4 NeuroNet.cl Code Base
OpenCL-Programmcode-Bibliothek zur Erstellung neuronaler Netzwerkmodelle
NetCreator.mq5 EA Tool für die Modellbildung
6 NetCreatotPanel.mqh  Klassenbibliothek Klassenbibliothek zur Erstellung des Tools

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

Beigefügte Dateien |
MQL5.zip (82.71 KB)
DoEasy. Steuerung (Teil 26): Fertigstellung des WinForms-Objekts ToolTip und Weiterführung der Entwicklung der ProgressBar DoEasy. Steuerung (Teil 26): Fertigstellung des WinForms-Objekts ToolTip und Weiterführung der Entwicklung der ProgressBar
In diesem Artikel werde ich die Entwicklung des ToolTip-Steuerelements abschließen und mit der Entwicklung des WinForms-Objekts der ProgressBar beginnen. Bei der Arbeit an Objekten werde ich universelle Funktionen für die Animation von Steuerelementen und deren Komponenten entwickeln.
DoEasy. Steuerung (Teil 25): Das WinForms-Objekt Tooltip DoEasy. Steuerung (Teil 25): Das WinForms-Objekt Tooltip
In diesem Artikel werde ich mit der Entwicklung des Steuerelements Tooltip (Schnellinfo) sowie neuer grafischer Primitive für die Bibliothek beginnen. Natürlich hat nicht jedes Element eine Tooltip, aber jedes grafische Objekt kann ein solches besitzen.
Nicht-lineare Indikatoren Nicht-lineare Indikatoren
In diesem Artikel werde ich versuchen, einige Möglichkeiten zur Erstellung nichtlinearer Indikatoren und deren Verwendung im Handel zu besprechen. In der MetaTrader-Handelsplattform gibt es eine ganze Reihe von Indikatoren, die nicht-lineare Ansätze verwenden.
Algorithmen zur Optimierung mit Populationen Ameisenkolonie-Optimierung (ACO) Algorithmen zur Optimierung mit Populationen Ameisenkolonie-Optimierung (ACO)
Dieses Mal werde ich den Algorithmus der Ameisenkolonie-Optimierung analysieren. Der Algorithmus ist sehr interessant und komplex. In diesem Artikel versuche ich, eine neue Art von ACO zu schaffen.