English Русский 中文 Español 日本語 Português
preview
Neuronale Netze leicht gemacht (Teil 47): Kontinuierlicher Aktionsraum

Neuronale Netze leicht gemacht (Teil 47): Kontinuierlicher Aktionsraum

MetaTrader 5Handelssysteme | 16 November 2023, 11:47
203 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

In unserem vorherigen Artikel haben wir den Agenten nur darauf trainiert, die Handelsrichtung zu bestimmen. Der Handlungsspielraum des Agenten war auf nur 4 Optionen beschränkt:

  • Kaufen, 
  • Verkaufen, 
  • Halten/Warten,
  • Schließen aller Positionen.

Hier gibt es keine Kapital- und Risikomanagementfunktionen. Wir haben bei allen Handelsgeschäften die Mindestlosgröße verwendet. Dies reicht zwar aus, um Trainingsansätze zu bewerten, aber nicht, um eine Handelsstrategie zu entwickeln. Eine profitable Handelsstrategie muss einfach einen Geldmanagement-Algorithmus haben.

Darüber hinaus müssen wir, um eine stabile Handelsstrategie zu entwickeln, die Risiken kontrollieren. Auch dieser Block fehlt in unseren Entwürfen. Der EA bewertete die Marktsituation bei jeder neuen Handelskerze und traf eine Entscheidung über eine Handelsoperation. Aber jeder kommende Balken birgt Risiken für unser Konto. Kursbewegungen innerhalb eines Balkens können sich nachteilig auf unseren Saldo auswirken. Aus diesem Grund ist es immer empfehlenswert, Stop-Loss zu verwenden. Dieser einfache Ansatz ermöglicht es uns, die Risiken pro Handel zu begrenzen.


1. Merkmale des kontinuierlichen Aktionsraumtrainings

Es ist logisch, dass wir bei der Ausbildung des Agenten und der Entwicklung seiner Handelspolitik diese Merkmale berücksichtigen müssen. Hier stellt sich jedoch die Frage, wie das Modell so trainiert werden kann, dass es das Volumen der Transaktion und die Schlussstände der Position vorhersagt. Dies lässt sich leicht mit Algorithmen des überwachten Lernens erreichen, bei denen wir die vom Lehrer vorgegebenen Zielwerte angeben können. Es gibt jedoch einige Komplikationen bei der Verwendung von Algorithmen des verstärkten Lernens.

Wie Sie sich vielleicht erinnern, haben wir zuvor zwei Ansätze für das Training von Verstärkungsmodellen verwendet: Belohnungsvorhersage und die Wahrscheinlichkeit, die maximale Belohnung zu erhalten.

Eine Möglichkeit, dieses Problem zu lösen, besteht darin, diskrete Werte für alle Parameter einer Handelsoperation zu definieren und für jede der möglichen Optionen eine eigene Aktion zu erstellen. Dies wird uns ermöglichen, einige Aspekte des Kapital- und Risikomanagements zu berücksichtigen.

Dieser Ansatz ist jedoch nicht ohne Nachteile. Die Auswahl diskreter Transaktionsparameter erfordert einige Arbeit in der Phase der Datenaufbereitung. Ihre Wahl wird immer ein Kompromiss zwischen der Anzahl der Optionen und einer ausreichenden Flexibilität bei der Entscheidungsfindung des Agenten sein. In diesem Fall kann sich die Anzahl der Kombinationen möglicher Aktionen erheblich erhöhen, was zu einem komplexeren Modell führt und die Trainingszeit verlängert. Schließlich müssen Sie während des Trainings die Belohnung für jede der möglichen Handlungen studieren.

Wenn wir zum Beispiel nur 3 diskrete Werte für das Handelsvolumen, 3 Stop-Loss-Levels und 5 Take-Profit-Levels nehmen, dann brauchen wir 90 Elemente, um den Aktionsraum in 2 Handelsrichtungen zu definieren (3 * 3 * 5 * 2 = 90). Vergessen wir auch nicht die Aktionen des Haltens und Schließens einer Position. Es gibt bereits 92 Optionen in der Palette der möglichen Agentenaktionen.

Einverstanden, diese verringerte Handlungsfreiheit des Agenten führt zu einem erheblichen Anstieg der Zahl der Neuronen am Ausgang des Modells. Und das Hinzufügen von jedem diskreten Wert eines der Parameter der Transaktion führt zu einer Erhöhung der Anzahl der Neuronen in Progression.

Darüber hinaus kann das Training eines komplexeren Modells zusätzliche Beispiele aus der Trainingsmenge erfordern, mit allen sich daraus ergebenden Konsequenzen.

Es gibt aber auch andere Ansätze, so genannte Algorithmen zum Training eines Agenten in einem kontinuierlichen Handlungsraum. Ein mit solchen Algorithmen trainierter Agent kann Aktionen aus einem kontinuierlichen Bereich von Werten auswählen. Dies ermöglicht eine flexiblere und genauere Verwaltung der Transaktionsparameter, einschließlich des Handelsvolumens, der Stop-Loss- und Take-Profit-Niveaus.

Einer der beliebtesten Algorithmen für das Training eines Agenten in einem kontinuierlichen Handlungsraum ist Deep Deterministic Policy Gradient (DDPG). Bei DDPG besteht das Modell aus zwei neuronalen Netzen: Actor (Akteur) and Critic (Kritiker). Der Actor sagt die optimale Aktion auf der Grundlage des aktuellen Zustands voraus, und der Critic bewertet diese Aktion. Eine ähnliche Lösung haben wir bereits in dem Artikel „Der Algorithmus Advantage Actor Critic“ gesehen. Bei diesen Algorithmen gibt es Ähnlichkeiten in den Ansätzen, aber der Unterschied liegt im Actor-Trainingsalgorithmus.

Bei DDPG wird ein Actor mit Hilfe von Gradient Lifting trainiert, um eine deterministische Strategie zu optimieren. Der Actor sagt die optimale Aktion auf der Grundlage des aktuellen Zustands direkt voraus, anstatt die Wahrscheinlichkeitsverteilung der Aktionen zu modellieren, wie es beim vorteilhaften Actor-Critic -Algorithmus der Fall ist.

Das Training eines Actors in DDPG erfolgt durch die Berechnung des Gradienten der Wertfunktion des Critics in Bezug auf die Aktionen des Actors und die Verwendung dieses Gradienten zur Aktualisierung der Parameter des Actors. Das hört sich etwas kompliziert an, aber es ermöglicht dem Actor, die optimale Aktion zu finden, die die Punktzahl des Critics maximiert.

Es ist wichtig zu beachten, dass sich DDPG auf Algorithmen außerhalb des Regelwerks bezieht. Das Modell wird anhand von Daten trainiert, die aus früheren Interaktionen mit der Umgebung stammen, unabhängig von der aktuellen Entscheidungsstrategie. Diese wichtige Eigenschaft des Algorithmus ermöglicht seinen Einsatz in komplexen und stochastischen Umgebungen, in denen die Vorhersage der Dynamik der Umgebung schwierig oder ungenau sein kann. Beim Testen des EDL-Algorithmus stießen wir auf eine schlechte Qualität der Finanzmarktprognosen.

Der Deep Deterministic Policy Gradient Algorithmus basiert auf den Kernprinzipien des Deep Q-Network (DQN) und beinhaltet viele seiner Ansätze, einschließlich des Erfahrungswiedergabepuffers und des Zielmodells. Schauen wir uns den Algorithmus genauer an.

Wie bereits erwähnt, besteht das Modell aus 2 neuronalen Netzen: Actor und Critic. Der Actor erhält den Zustand der Umgebung als Eingabe. Am Ausgang des Actors erhalten wir die Aktion aus einer kontinuierlichen Verteilung von Werten. In unserem Fall werden wir das Transaktionsvolumen, die Stop-Loss und Take-Profit bilden. Je nach Modellarchitektur und Problemstellung können wir absolute oder relative Werte verwenden. Um den Grad der Erkundung der Umgebung zu erhöhen, kann der generierten Aktion ein gewisses Rauschen Lärm hinzugefügt werden.

Wir führen die vom Actor gewählte Aktion aus und begeben uns in einen neuen Zustand der Umgebung. Als Reaktion auf unsere Handlungen erhalten wir von der Umgebung eine Belohnung.

Wir sammeln die Datensätze „Zustand - Aktion - Neuer Zustand - Belohnung“ im Playback-Puffer der Erfahrungen. Dies ist eine typische Vorgehensweise bei Algorithmen des verstärkten Lernens (reinforcement learning).

Wie beim DQN wählen wir ein Paket für das Training des Modells aus dem Erfahrungswiedergabepuffer aus. Die Zustände aus diesem Trainingsdatenpaket werden in den Eingang des Actors eingespeist. Bevor wir die Parameter ändern, werden wir höchstwahrscheinlich eine Aktion erhalten, die der im Erfahrungswiedergabepuffer gespeicherten ähnelt. Aber anders als der Vorteil Actor-Critic liefert Actor keine Wahrscheinlichkeitsverteilung, sondern eine Aktion aus einer kontinuierlichen Verteilung.

Um den Wert einer bestimmten Aktion zu bewerten, übermitteln wir den aktuellen Zustand und die generierte Aktion an den Critic. Auf der Grundlage der erhaltenen Daten sagt der Critic die Belohnung voraus, genau wie beim herkömmlichen DQN.

Ähnlich wie beim DQN wird der Critic darauf trainiert, die Standardabweichung zwischen der vorhergesagten Belohnung und der tatsächlichen Belohnung aus dem Erfahrungswiedergabepuffer zu minimieren. Um eine ganzheitliche Politik zu entwickeln, wird das Zielnetzmodell verwendet. Da aber die Auswertung des Folgezustandes das Setzen von Daten aus dem Zustand und der Aktion erfordert, werden wir auch das Zielmodell des Actors verwenden, um aus dem Folgezustand eine Aktion zu bilden.

Der Clou von DDPG ist, dass wir keine Ziel-Ausgangswerte verwenden, um den Actor zu trainieren. Stattdessen nehmen wir einfach den Fehlergradientenwert des Critic-Modells über unsere Aktion und leiten ihn weiter durch das Actor-Modell.

Während wir also die Q-Funktion des Critics trainieren, verwenden wir den Fehlergradienten über die Aktion, um die Aktionen des Agenten zu optimieren. Man kann sagen, dass der Actor ein integraler Bestandteil der Q-Funktion ist. Das Training der Q-Funktion führt zur Optimierung der Actor-Funktion.

Dabei ist jedoch zu beachten, dass wir beim Training des Critic seine Parameter für eine möglichst korrekte Bewertung des Zustands-Aktionspaares optimieren. Während des Trainings des Actors optimieren wir seine Parameter, um die vorhergesagte Belohnung zu erhöhen, wobei alle anderen Dinge gleich bleiben.

Die Autoren der Methode empfehlen eine sanfte Aktualisierung der Zielmodelle. Ein einfaches Ersetzen des Zielmodells durch ein trainiertes Modell in einer bestimmten Häufigkeit wird durch eine Neuberechnung der Parameter des Zielmodells ersetzt, wobei die Aktualisierungsrate für die Parameter des trainierten Modells berücksichtigt wird. Den Autoren zufolge verlangsamt dieser Ansatz die Aktualisierung der Zielmodelle, erhöht aber die Stabilität des Trainings.


2. Implementierung mittels MQL5

Nach einer theoretischen Einführung in die Deep Deterministic Policy Gradient (DDPG)-Methode wenden wir uns nun ihrer praktischen Umsetzung mit MQL5 zu. Wir beginnen damit, die sanfte Aktualisierung der Zielmodelle zu organisieren. Die Funktion der gewichteten Summierung von 2 Parametern selbst ist nicht kompliziert, aber es gibt 2 Punkte.

Zunächst muss die Operation mit allen Modellparametern durchgeführt werden. Da der Betrieb jedes einzelnen Parameters völlig unabhängig von anderen Parametern desselben Modells ist, können sie problemlos parallel ausgeführt werden.

Zweitens werden alle Operationen zum Trainieren und Betreiben von Modellen im Kontext von OpenCL durchgeführt. Datenkopiervorgänge zwischen Kontextspeicher und Hauptspeicher sind recht teuer. Wir haben uns immer bemüht, sie zu minimieren. Es ist logisch, dass auch die Parameter im Rahmen von OpenCL neu berechnet werden sollten.

2.1. Sanfte Aktualisierung der Zielmodelle

Zunächst erstellen wir den SoftUpdate-Kernel zur Durchführung der Operationen. Der Kernel-Algorithmus ist recht einfach. In den Kernelparametern übergeben wir Zeiger auf 2 Datenpuffer (Parameter des Ziel- und des trainierten Modells) und den Aktualisierungsfaktor als Konstante.

__kernel void SoftUpdate(__global float *target, 
                         __global const float *source, 
                         const float tau
                        )
  {
   const int i = get_global_id(0);
   target[i] = target[i] * tau + (1.0f - tau) * source[i];
  }

Wir werden nur einen Parameter in jedem einzelnen Thread aktualisieren. Daher ist die Anzahl der Threads gleich der Anzahl der zu aktualisierenden Parameter.

Als Nächstes müssen wir den Prozess auf der Seite des Hauptprogramms anordnen.

Ich möchte Sie daran erinnern, dass unsere Modellparameter je nach Art der neuronalen Schicht auf verschiedene Objekte verteilt sind. Das bedeutet, dass wir jeder Klasse eine Methode zur Aktualisierung der Parameter hinzufügen müssen, um die Arbeit der neuronalen Schicht zu organisieren. Schauen wir uns das Beispiel der Basisklasse der neuronalen Schicht CNeuronBaseOCL an.

Da wir die Parameter der aktuellen neuronalen Schicht aktualisieren werden, müssen wir nur einen Zeiger auf die neuronale Schicht des trainierten Modells und den Aktualisierungskoeffizienten in den Methodenparametern übergeben.

bool CNeuronBaseOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau)
  {
   if(!OpenCL || !Weights || !source || !source.Weights)
      return false;

Im Hauptteil der Methode prüfen wir die Gültigkeit des empfangenen Zeigers auf das Objekt der neuronalen Schicht. Zusammen mit ihm werden wir die Zeiger auf die notwendigen internen Objekte überprüfen.

Hier überprüfen wir die Übereinstimmung zwischen den Typen der beiden neuronalen Schichten und den Dimensionen der Parametermatrizen.

   if(Type() != source.Type())
      return false;
   if(Weights.Total() != source.Weights.Total())
      return false;

Nach erfolgreicher Übergabe des Kontrollblocks organisieren wir die Übergabe der Parameter an den Kernel.

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {Weights.Total()};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, Weights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, source.getWeightsIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Wir stellen den Kernel in die Ausführungswarteschlange. Vergessen wir nicht, den Prozess bei jedem Schritt zu kontrollieren.

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

Beenden der Ausführung der Methode.

Da alle Objekte, die die Arbeit der neuronalen Schichten verschiedener Architekturen in unserer Klasse organisieren, von der Basisklasse CNeuronBaseOCL geerbt werden, erben auch alle Klassen die erstellte Methode. Es erlaubt uns aber nur, die Gewichtsmatrix der Basisklasse zu aktualisieren. Wir sollten die Methode in allen Klassen, die zusätzliche interne optimierbare Objekte hinzufügen, außer Kraft setzen. In der Faltungsschicht CNeuronConvOCL haben wir zum Beispiel eine Matrix von Faltungsparametern hinzugefügt. Um sie zu aktualisieren, wird die Methode WeightsUpdate außer Kraft gesetzt. Um das Überschreiben von geerbten Methoden zu unterstützen, werden alle Methodenparameter unverändert gelassen.

bool CNeuronConvOCL::WeightsUpdate(CNeuronBaseOCL *source, float tau)
  {
   if(!CNeuronBaseOCL::WeightsUpdate(source, tau))
      return false;

Wir wiederholen nicht den gesamten Block von Steuerelementen im Hauptteil der Methode. Stattdessen rufen wir die Methode der übergeordneten Klasse auf und überprüfen das Ergebnis der Operationen.

Als Nächstes erhalten wir in den Parametern den Zeiger auf das Objekt der Basisklasse des neuronalen Netzes. Dies geschieht absichtlich. Wenn Sie den Typ der übergeordneten Klasse angeben, können Sie einen Zeiger an jeden seiner Nachkommen übergeben. Das ist es, was wir brauchen, um eine virtuelle Methode in allen geerbten Klassen einzurichten.

Das Problem ist jedoch, dass wir in diesem Zustand keinen Zugriff auf die Faltungsgewichtsmatrix der in den Parametern erhaltenen Schicht haben. Es gibt einfach kein solches Objekt in der übergeordneten Klasse. Sie erscheint nur in der Klasse der Faltungsschichten. Wir haben keinen Zweifel, dass der Zeiger auf die Faltungsschicht in den Parametern übergeben wird. In der Methode der übergeordneten Klasse wurde die Übereinstimmung zwischen den Typen der aktuellen neuronalen Schicht und den in den Parametern angegebenen Typen überprüft. Um mit diesem Objekt der Faltungsebene zu arbeiten, müssen wir nur den resultierenden Zeiger dem Objekt der dynamischen Faltungsebene zuweisen. Dann überprüfen wir die Einhaltung der Matrixgrößen.

   CNeuronConvOCL *temp = source;
   if(WeightsConv.Total() != temp.WeightsConv.Total())
      return false;

Als Nächstes wiederholen wir den Vorgang der Datenübertragung und der Aufnahme des Kernels in die Ausführungswarteschlange. Beachten Sie, dass nur die verwendeten Datenpufferobjekte geändert werden.

   uint global_work_offset[1] = {0};
   uint global_work_size[1] = {WeightsConv.Total()};
   ResetLastError();
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_target, WeightsConv.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_SoftUpdate, def_k_su_source, temp.WeightsConv.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_SoftUpdate, def_k_su_tau, (float)tau))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.Execute(def_k_SoftUpdate, 1, global_work_offset, global_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError());
      return false;
     }
//---
   return true;
  }

In ähnlicher Weise erstellen wir Methoden in allen anderen Klassen von neuronalen Schichten, in die wir Objekte mit optimierten Parametern einfügen. Ich werde nicht den vollständigen Code der Klassenmethoden angeben. Sie finden sie in der Anlage.

Der Betriebsalgorithmus unserer Bibliothek ermöglicht dem Nutzer keinen direkten Zugriff auf die neuronalen Schichten des Modells. Der Nutzer arbeitet immer mit der obersten Klasse des neuronalen Netzmodells. Daher werden wir nach dem Hinzufügen von Methoden zu den Klassen der neuronalen Schicht eine Methode mit demselben Namen in unserer Modellklasse CNet::WeightsUpdate erstellen. In den Parametern erhält die Methode einen Zeiger auf das trainierte neuronale Netz und den Aktualisierungskoeffizienten. Im Hauptteil der Methode wird der Zyklus der Suche durch alle neuronalen Netze des Modells und der Aufruf von Methoden zur Aktualisierung der neuronalen Schicht organisiert. Der Algorithmus ist recht einfach. Es macht keinen Sinn, den Code in dem Artikel anzugeben. Sie finden sie in der Anlage.

2.2. Datenaustausch zwischen Actor und Critic

Nachdem wir die Aktualisierung der Modelle veranlasst haben, gehen wir direkt zum Training des Modells über. Unser Modell ist eine Art Symbiose des DDPG-Algorithmus mit bereits untersuchten Ansätzen. Insbesondere habe ich mich entschieden, einen einzigen Block der Vorverarbeitung der Quelldaten für beide neuronalen Netze (Actor und Critic) zu verwenden.

Der Actor trifft eine Entscheidung über die optimale Aktion auf der Grundlage des erhaltenen Zustands der Umgebung. Der Critic erhält als Input eine Beschreibung des Zustands der Umgebung und der Handlung des Actors. Auf der Grundlage der erhaltenen Daten erstellt er eine Prognose über die zu erwartende Belohnung (bewertet die Handlung des Actors). Wie wir sehen können, erhalten der Actor und der Critic eine Beschreibung der Umgebung. Um wiederholte Vorgänge zu minimieren, wurde beschlossen, einen Block für die Vorverarbeitung der Quelldaten in den Hauptteil des Actors aufzunehmen. Der Critic soll aus dem latenten Zustand des Actors eine komprimierte Darstellung des Zustands der Umgebung vermitteln. Um den Datentransfer zwischen dem Actor und dem Critic auf der Seite des Hauptprogramms zu minimieren, wurde beschlossen, zusätzliche Vorwärts- und Rückwärtsdurchgangs-Methoden zu schaffen, bei denen nicht einzelne Datenpuffer, sondern direkt Zeiger auf das Quelldatenmodell und die Kennung der Schicht mit den Quelldaten übertragen werden.

Die Ausgestaltung der CNet::feedForward Vorwärtsdurchgangs-Methode wird dabei berücksichtigt. Die Methodenparameter sehen die Übergabe von 2 Zeigern auf neuronale Netze (Haupt- und zusätzliche Quelldaten) und 2 Identifikatoren der neuronalen Schichten in diesen Netzen vor.

bool CNet::feedForward(CNet *inputNet, int inputLayer=-1, CNet *secondNet = NULL, int secondLayer = -1)
  {
   if(!inputNet || !opencl)
      return false;

Den Parametern wurden Standardwerte hinzugefügt, sodass wir die Methode verwenden können, indem wir nur einen Zeiger auf das Hauptdatenmodell der Quelle übergeben.

Im Methodenrumpf wird der empfangene Zeiger auf das Hauptdatenmodell der Quelle überprüft. Wenn keine Daten vorhanden sind, wird die Methode mit einem negativen Ergebnis beendet.

Als Nächstes überprüfen wir die ID der neuronalen Schicht im Haupteingabedatenmodell. Wurde sie aus irgendeinem Grund nicht angegeben, so wird die letzte neuronale Schicht des Modells verwendet.

   if(inputLayer<0)
      inputLayer=inputNet.layers.Total()-1;

In der nächsten Phase organisieren wir den Zugang zu weiteren Daten. Wir erstellen einen Null-Zeiger auf das Datenpuffer-Objekt und prüfen die Relevanz des Zeigers auf das Modell der zusätzlichen Quelldaten.

   CBufferFloat *second = NULL;
   bool del_second = false;
   if(!!secondNet)
     {
      if(secondLayer < 0)
         secondLayer = secondNet.layers.Total() - 1;
      if(secondNet.GetOpenCL() != opencl)
        {
         secondNet.GetLayerOutput(secondLayer, second);
         if(!!second)
           {
            if(!second.BufferCreate(opencl))
              {
               delete second;
               return false;
              }
            del_second = true;
           }
        }
      else
        {
         if(secondNet.layers.Total() <= secondLayer)
            return false;
         CLayer *layer = secondNet.layers.At(secondLayer);
         CNeuronBaseOCL *neuron = layer.At(0);
         second = neuron.getOutput();
        }
     }

Wenn wir einen gültigen Zeiger auf das Modell der zusätzlichen Quelldaten haben, haben wir 2 Möglichkeiten für die Entwicklung von Ereignissen:

  1. Wenn das zusätzliche Quelldatenmodell und das aktuelle Modell in unterschiedlichen OpenCL-Kontexten geladen werden, müssen wir die Daten in jedem Fall neu laden. Wir kopieren die Daten aus der entsprechenden Datenmodellschicht in einen neuen Puffer und erstellen einen Puffer im gewünschten Kontext.
  2. Beide Modelle befinden sich im gleichen OpenCL-Kontext. Die Daten sind bereits im Kontextspeicher vorhanden. Wir müssen nur den Zeiger auf den Ergebnispuffer der gewünschten neuronalen Schicht kopieren.

Nachdem wir den Puffer mit den zusätzlichen Quelldaten erhalten haben, gehen wir zum Modell der Hauptquelldaten über. Wie oben prüfen wir, ob die Modelle in den Speicher desselben OpenCL-Kontextes geladen sind. Wenn nicht, kopieren wir einfach die Originaldaten in den Puffer und rufen die zuvor entwickelte Vorwärtsdurchgangs-Methode auf.

   if(inputNet.opencl != opencl)
     {
      CBufferFloat *inputs;
      if(!inputNet.GetLayerOutput(inputLayer, inputs))
        {
         if(del_second)
            delete second;
         return false;
        }
      bool result = feedForward(inputs, 1, false, second);
      if(del_second)
         delete second;
      return result;
     }

Wenn sich beide Modelle im gleichen OpenCL-Kontext befinden, wird die Quelldatenschicht durch die angegebene neuronale Schicht des Quelldatenmodells ersetzt.

   CLayer *layer = inputNet.layers.At(inputLayer);
   if(!layer)
     {
      if(del_second)
         delete second;
      return false;
     }
   CNeuronBaseOCL *neuron = layer.At(0);
   layer = layers.At(0);
   if(!layer)
     {
      if(del_second)
         delete second;
      return false;
     }
   if(layer.At(0) != neuron)
      if(!layer.Update(0, neuron))
        {
         if(del_second)
            delete second;
         return false;
        }

Danach folgt der Zyklus der Aufzählung aller neuronalen Schichten, gefolgt vom Aufruf der Vorwärtsdurchgangmethoden.

   for(int l = 1; l < layers.Total(); l++)
     {
      layer = layers.At(l);
      neuron = layer.At(0);
      layer = layers.At(l - 1);
      if(!neuron.FeedForward(layer.At(0), second))
        {
         if(del_second)
            delete second;
         return false;
        }
     }
//---
   if(del_second)
      delete second;
   return true;
  }

Nach Abschluss der Schleife wird die Methode mit einem positiven Ergebnis beendet.

Lassen Sie uns die Methode CNet::backProp auf ähnliche Weise erstellen. Der vollständige Code befindet sich im Anhang.

Bei der Ausbildung des Critics werden wir beide Methoden anwenden. Aber um den Actor zu trainieren, brauchen wir eine weitere Methode für den Rückwärtsdurchgang. Bei der Rückwärtsdurchgangs-Methode wird nämlich zunächst die Abweichung der Vorwärtsdurchgangs-Ergebnisse von den Zielwerten ermittelt, bevor der Fehlergradient durch die neuronalen Schichten geleitet wird. Mit der DDPG-Methode entfällt dieser Prozess für den Actor. Für die praktische Umsetzung dieses Algorithmus wurde die Methode CNet::backPropGradient geschaffen.

In den Methodenparametern übergeben wir Zeiger auf 2 Datenpuffer: zusätzliche Quelldaten und den Fehlergradienten an sie. Beide Puffer haben Standardwerte, sodass wir die Methode ohne Angabe von Parametern ausführen können.

bool CNet::backPropGradient(CBufferFloat *SecondInput = NULL, CBufferFloat *SecondGradient = NULL)
  {
   if(
! layers || 
! opencl)
      return false;
   CLayer *currentLayer = layers.At(layers.Total() - 1);
   CNeuronBaseOCL *neuron = NULL;
   if(CheckPointer(currentLayer) == POINTER_INVALID)
      return false;

Im Hauptteil der Methode wird zunächst die Relevanz der Zeiger auf die Objekte des dynamischen Arrays der neuronalen Schichten und den OpenCL-Kontext überprüft. Deklarieren wir die notwendigen lokalen Variablen.

Dann arrangieren wir die Schleife zur Verteilung des Fehlergradienten auf alle neuronalen Schichten des Modells.

//--- Calc Hidden Gradients
   int total = layers.Total();
   for(int layerNum = total - 2; layerNum >= 0; layerNum--)
     {
      CLayer *nextLayer = currentLayer;
      currentLayer = layers.At(layerNum);
      if(CheckPointer(currentLayer) == POINTER_INVALID)
         return false;
      neuron = currentLayer.At(0);
      if(!neuron || !neuron.calcHiddenGradients(nextLayer.At(0), SecondInput, SecondGradient))
         return false;
     }

Bitte beachten Sie, dass wir bei der Anordnung des Prozesses davon ausgehen, dass sich der Fehlergradient bereits im Puffer der letzten neuronalen Schicht befindet. Dies wird durch den DDPG-Algorithmus (Critic error gradient based on Agent actions) gewährleistet. Es gibt keine Kontrolle für das Vorhandensein eines Fehlergradienten. Die Anwendung der Methode liegt in der Verantwortung des Nutzers.

Nach der Verteilung des Fehlergradienten werden die Matrizen der Gewichtungskoeffizienten aktualisiert.

   CLayer *prevLayer = layers.At(total - 1);
   for(int layerNum = total - 1; layerNum > 0; layerNum--)
     {
      currentLayer = prevLayer;
      prevLayer = layers.At(layerNum - 1);
      neuron = currentLayer.At(0);
      if(!neuron.UpdateInputWeights(prevLayer.At(0), SecondInput))
         return false;
     }

Hier sollten wir uns daran erinnern, dass wir bei den Methoden der neuronalen Schicht nur Kernel in die Ausführungswarteschlange stellen. Bevor wir jedoch einen weiteren Vorwärtsdurchlauf durchführen, müssen wir sicher sein, dass der Rückwärtsdurchlauf abgeschlossen ist. Um dieses Vertrauen zu gewinnen, werden wir die Ergebnisse der letzten Kernel-Aktualisierung der Gewichtsmatrix laden.

   bool result=false;
   for(int layerNum = 0; layerNum < total; layerNum++)
     {
      currentLayer = layers.At(layerNum);
      CNeuronBaseOCL *temp = currentLayer.At(0);
      if(!temp)
        continue; 
      if(!temp.TrainMode() || !temp.getWeights())
         continue;
      if(!temp.getWeights().BufferRead())
         continue;
      result=true;
      break;
     }
//---
   return result;
  }

Damit ist unsere Arbeit an der Aktualisierung der Methoden und Klassen unserer Bibliothek abgeschlossen. Der vollständige Code des EAs befindet sich im Anhang.

2.3. Erstellen eines EAs für das Modelltraining EA

Als Nächstes werden wir das Modell mit dem DDPG-Algorithmus erstellen und trainieren. Die Ausbildung wird in der EA „DDPG\Study.mq5“ durchgeführt.

Wie wir bereits erwähnt haben, wird das erstellte Modell Elemente der DDPG und der zuvor diskutierten Ansätze kombinieren. Dies wird sich in der Architektur unseres Modells widerspiegeln. Lassen Sie uns die Funktion CreateDescriptions erstellen, um die Architektur zu beschreiben.

In den Parametern erhält die Funktion Zeiger auf 2 dynamische Arrays zur Aufnahme von Objekten, die die Architektur der neuronalen Schichten Actor und Critic beschreiben. Im Hauptteil der Funktion prüfen wir die Relevanz der empfangenen Zeiger und erstellen gegebenenfalls neue Array-Objekte.

bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic)
  {
//---
   CLayerDescription *descr;
//---
   if(!actor)
     {
      actor = new CArrayObj();
      if(!actor)
         return false;
     }
   if(!critic)
     {
      critic = new CArrayObj();
      if(!critic)
         return false;
     }

Wir beginnen mit der Beschreibung der Architektur des Actors. Hier verwenden wir die GCRL-Entwicklungen und erstellen ein Modell mit 2 Quelldatenströmen. Die Entscheidungsfindung des Actors basiert auf dem aktuellen Zustand der Umgebung (historische Daten). Wir werden eine Quelldatenebene mit der entsprechenden Größe erstellen.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Die Rohdaten werden von einer Batch-Normalisierungsschicht verarbeitet und durch einen Block von Faltungsschichten geleitet.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 1;
   descr.window = 2;
   descr.step = 1;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = 8;
   descr.step = 8;
   descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Anschließend werden die Daten in 2 vollständig verknüpften Schichten komprimiert. All dies mag Sie an den früher verwendeten Encoder erinnern.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Eine Einschätzung der Marktlage kann ausreichen, um die Handelsrichtung und die Stop-Loss/Take-Profit-Niveaus zu bestimmen. Für die Geldverwaltungsfunktionen reicht das jedoch nicht aus. In diesem Stadium fügen wir Informationen über den Zustand des Kontos hinzu, genau wie bei der Formulierung des Modellproblems.

//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 256;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Wir merken uns die ID dieser Ebene und die Größe des Vektors mit ihren Ergebnissen. Von dieser Ebene werden wir die latente Repräsentation des Zustands der Umgebung als Ausgangsdaten für den Critic übernehmen.

Als Nächstes folgt der Entscheidungsblock aus vollständig verknüpften Schichten.

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Am Ausgang des Actors befindet sich eine vollständig verbundene Schicht mit 6 Elementen, die das Transaktionsvolumen, den Stop-Loss und den Take-Profit darstellen (3 Elemente für den Kauf und 3 für den Verkauf).

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 6;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

In einer vereinfachten Form fügen wir keine Elemente für die Aktionen des Schließens von Positionen und des Wartens auf einen geeigneten Einstiegs-/Ausstiegspunkt hinzu. Wir gehen davon aus, dass die Positionen mit Stop-Loss oder Take-Profit geschlossen werden. Die Ausgabe falscher Werte für einen der Transaktionsindikatoren entspricht dem Fehlen einer Handelsoperation.

Das Modell des Critics verwendet den aktuellen Zustand der Umgebung und die Aktion des Actors, um Belohnungen vorherzusagen. In unserem Fall kommen beide Informationsströme aus dem Actorsmodell, wenn auch aus unterschiedlichen neuronalen Schichten und dementsprechend aus unterschiedlichen Datenpuffern. Wir werden eine neuronale Datenverkettungsschicht verwenden, um zwei Datenströme zu kombinieren. Dies wird sich in der Architektur des Critic-Modells wie folgt widerspiegeln. Wir übertragen den ersten Datenstrom (latente Darstellung des aktuellen Zustands) in die Quelldatenschicht. Die Größe dieser Schicht sollte der Größe der neuronalen Schicht des Actors entsprechen, von der wir Daten übernehmen wollen.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = 256;
   descr.window = 0;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Die Daten stammen aus dem internen Zustand eines anderen Modells und wir können die Daten-Normalisierungsschicht überspringen.

Als Nächstes verwenden wir eine Verkettungsschicht, um 2 Informationsströme zu kombinieren. Die Größe der zusätzlichen Daten ist gleich der Größe der Actor-Ergebnisschicht.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 128;
   descr.window = prev_count;
   descr.step = 6;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Dann folgt der Entscheidungsblock, der aus 2 vollständig verbundenen Schichten besteht.

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Die voll verknüpfte Schicht mit 1 Element ohne Aktivierungsfunktion wird am Critic-Ausgang verwendet. Hier erwarten wir, dass wir die vorhergesagte Belohnung erhalten.

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 1;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Um in Zukunft nicht mit dem Bezeichner der Schicht der latenten Repräsentation des Umgebungszustands verwechselt zu werden, werden wir eine Konstante in Form einer Makro-Substitution definieren.

#define                    LatentLayer  6

Nachdem wir uns nun für die Architektur der Modelle entschieden haben, gehen wir zur Arbeit an dem EA-Algorithmus über. Zunächst erstellen wir die Methode OnInit zur Initialisierung des EA. Zu Beginn der Methode initialisieren wir wie zuvor die Objekte der Indikatoren und Handelsoperationen.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }
//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;

Dann versuchen wir, die vortrainierten Modelle zu laden. Wenn es sie nicht gibt, beginnen wir mit der Erstellung von Modellen.

Hier sollten wir auf eine Nuance achten. Während wir zuvor ein Trainingsmodell erstellt und es vollständig in das Zielmodell kopiert haben, initialisieren wir nun das Trainings- und das Zielmodell mit zufälligen Parametern. Außerdem verwenden beide Modelle die gleiche Architektur.

//--- load models
   float temp;
   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetActor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic.Load(FileName + "Crt.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      if(!CreateDescriptions(actor, critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic.Create(critic) ||
         !TargetActor.Create(actor) || !TargetCritic.Create(critic))
        {
         delete actor;
         delete critic;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      //---
     }

Als Nächstes werden wir alle Modelle in einen einzigen OpenCL-Kontext übertragen. Dies ermöglicht es uns, mit Zeigern auf Datenpuffer zu arbeiten, ohne dass bei der Übertragung von Informationen zwischen Modellen ein physisches Kopieren erforderlich ist.

   COpenCLMy *opencl = Actor.GetOpenCL();
   Critic.SetOpenCL(opencl);
   TargetActor.SetOpenCL(opencl);
   TargetCritic.SetOpenCL(opencl);

Es folgt ein Block zur Überwachung der Konformität von Modellarchitekturen.

   Actor.getResults(Result);
   if(Result.Total() != 6)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", 6, Result.Total());
      return INIT_FAILED;
     }
   ActorResult = vector<float>::Zeros(6);
//---
   Actor.GetLayerOutput(0, Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Input size of Actor doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr));
      return INIT_FAILED;
     }
//---
   Actor.GetLayerOutput(LatentLayer, Result);
   int latent_state = Result.Total();
   Critic.GetLayerOutput(0, Result);
   if(Result.Total() != latent_state)
     {
      PrintFormat("Input size of Critic doesn't match latent state Actor (%d <> %d)", Result.Total(), latent_state);
      return INIT_FAILED;
     }

Initialisierung der globalen Variablen und Beenden der Methode.

   PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY);
   FirstBar = true;
   Gradient.BufferInit(AccountDescr, 0);
   Gradient.BufferCreate(opencl);
//---
   return(INIT_SUCCEEDED);
  }

Wir haben festgelegt, dass die Zielmodelle nach jeder Episode aktualisiert werden sollen. Daher wurde diese Funktionalität in die EA-Deinitialisierungsmethode aufgenommen. Wir aktualisieren zunächst die Zielmodelle. Dann retten wir sie. Beachten Sie, dass wir die Zielmodelle speichern, nicht die trainierten Modelle. Wir wollen also die Umschulung des Modells auf eine einzelne Episode reduzieren.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   TargetActor.WeightsUpdate(GetPointer(Actor), Tau);
   TargetCritic.WeightsUpdate(GetPointer(Critic), Tau);
   TargetActor.Save(FileName + "Act.nnw", Actor.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic.Save(FileName + "Crt.nnw", Critic.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   delete Result;
  }

Der eigentliche Prozess der Ausbildung des Modells wird im Aktionsablauf durchgeführt. In unserem Fall werden wir das Modell im Strategietester im Modus „History Walkthrough“ (Gang durch die Geschichte) trainieren. Wir werden den Erfahrungswiederholungspuffer nicht erstellen. Diese Aufgabe wird von dem Strategietester selbst übernommen. Der gesamte Lernprozess ist also in der Funktion OnTick untergebracht.

Zu Beginn der Funktion wird geprüft, ob eine neue Kerze geöffnet wurde. Danach werden die Daten der Indikatoren und die historischen Daten über die Kursentwicklung des Instruments in den Puffern aktualisiert.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
//---
   if(!IsNewBar())
      return;
//---
   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

Der Prozess der Datenaufbereitung wurde vollständig von den zuvor besprochenen EAs übernommen. Es hat keinen Sinn, dies hier zu beschreiben. Den vollständigen EA-Code und alle seine Funktionen finden Sie im Anhang.

Nach der Aufbereitung der Ausgangsdaten wird geprüft, ob zuvor ein Vorwärtsdurchlauf des trainierten Modells stattgefunden hat. Wenn es einen Vorwärtsdurchgang gibt, führen wir einen Rückwärtsdurchgang durch. Um den aktuellen Zustand zu bewerten, führen wir einen Vorwärtsdurchlauf des Zielmodells durch. Beachten Sie, dass wir zunächst einen Vorwärtsdurchlauf des Ziel-Actorsmodells durchführen. Wir führen einen direkten Durchlauf des Zielmodells von Critic unter Berücksichtigung der gebildeten Aktion durch. Wir addieren zu dem resultierenden Wert die tatsächliche Belohnung des Systems in Form einer Veränderung des Kontostands. Wenn es keine offenen Positionen gibt, fügen wir eine Strafe hinzu, um den Actor zu ermutigen, aktiv zu handeln und den umgekehrten Pass zuerst vom Critic und dann vom Actor zu verlangen.

   if(!FirstBar)
     {
      if(!TargetActor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
         return;
      if(!TargetCritic.feedForward(GetPointer(TargetActor), LatentLayer, GetPointer(TargetActor)))
         return;
      TargetCritic.getResults(Result);
      float reward = (float)(account[0] - PrevBalance + Result[0]);
      if(account[0] == PrevBalance)
         if((buy_value + sell_value) == 0)
            reward -= 1;
      Result.Update(0, reward);
      if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(PrevAccount), GetPointer(Gradient)))
         return;
     }

Beachten Sie, dass wir für den Critic-Reverse-Pass die aktualisierte Methode backProp verwenden, die den Puffer der Zielwerte und den Zeiger auf das Actor-Modell übergibt. Gleichzeitig geben wir die Kennung der latenten Schicht nicht an, da wir die Objekte zuvor (während des direkten Durchgangs) ersetzt haben.

Für den Rückwärtsdurchlauf des Actors verwenden wir die Methode backPropGradient, bei der der Gradient aus dem Rückwärtsdurchlauf des Critics durch das Modell propagiert wird.

Durch einen umgekehrten Durchlauf des Critic und des Actor können wir die Q-Funktion unseres Modells optimieren.

Als Nächstes führen wir den Vorwärtsdurchlauf durch das trainierte Modell durch.

   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;
   if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
      return;

Hier lohnt es sich, auf folgenden Aspekt zu achten: Beim Training der Q-Funktion verbessern wir nur die Qualität der Vorhersage der erwarteten Belohnung. Wir trainieren den Actor jedoch nicht, um die Rentabilität seines Handelns zu erhöhen. Zu diesem Zweck sieht der DDPG-Algorithmus vor, die Parameter des Actors in Richtung einer Erhöhung der vorhergesagten Belohnung zu aktualisieren. Es sei darauf hingewiesen, dass wir zu diesem Zeitpunkt den Fehlergradienten durch den Critic leiten, aber nicht seine Parameter aktualisieren. Daher deaktivieren wir die Aktualisierung die Gewichtsmatrizen von Critics, indem wir das TrainMode-Flag auf „false“ setzen. Nach dem Rückwärtsdurchlauf des Actors setzen wir das Positions-Flag auf „true“ zurück.

   if(!FirstBar)
     {
      Critic.getResults(Result);
      Result.Update(0, Result.At(0) + MathAbs(Result.At(0) * 0.0001f));
      Critic.TrainMode(false);
      if(!Critic.backProp(Result, GetPointer(Actor)) || !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
         return;
      Critic.TrainMode(true);
     }

Wir speichern den Wert für die Operationen im nächsten Balken in globalen Variablen.

   FirstBar = false;
   PrevAccount.AssignArray(GetPointer(Account));
   PrevAccount.BufferCreate(Actor.GetOpenCL());
   PrevBalance = account[0];
   PrevEquity = account[1];

Dann müssen wir nur noch die Ergebnisse der Arbeit des Actors entschlüsseln und die Handelsoperationen durchführen. In diesem Beispiel trainieren wir den Actor darauf, absolute Werte des Handelsvolumens und der Handelsstufen zu liefern. Wir normalisieren lediglich die Daten und wandeln die Niveaus in spezifische Preiswerte um.

   vector<float> temp;
   Actor.getResults(temp);
   float delta = MathAbs(ActorResult - temp).Sum();
   ActorResult = temp;
//---
   double min_lot = Symb.LotsMin();
   double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point();
   double buy_lot = MathRound((double)ActorResult[0] / min_lot) * min_lot;
   double sell_lot = MathRound((double)ActorResult[3] / min_lot) * min_lot;
   double buy_tp = NormalizeDouble(Symb.Ask() + ActorResult[1], Symb.Digits());
   double buy_sl = NormalizeDouble(Symb.Ask() - ActorResult[2], Symb.Digits());
   double sell_tp = NormalizeDouble(Symb.Bid() - ActorResult[4], Symb.Digits());
   double sell_sl = NormalizeDouble(Symb.Bid() + ActorResult[5], Symb.Digits());
//---
   if(ActorResult[0] > min_lot && ActorResult[1] > stops && ActorResult[2] > stops && buy_sl > 0)
      Trade.Buy(buy_lot, Symb.Name(), Symb.Ask(), buy_sl, buy_tp);
   if(ActorResult[3] > min_lot && ActorResult[4] > stops && ActorResult[5] > stops && sell_tp > 0)
      Trade.Sell(sell_lot, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);

Ich möchte Sie daran erinnern, dass wir keine gesonderte Aktion des Actors vorgesehen haben, um auf eine geeignete Situation zu warten. Stattdessen verwenden wir ungültige Werte für die Handelsparameter. Daher überprüfen wir die Richtigkeit der empfangenen Parameter, bevor wir eine Handelsanfrage senden.

Ein weiterer Punkt ist erwähnenswert, der in dem betrachteten Algorithmus nicht vorgesehen ist, aber von mir hinzugefügt wurde. Sie steht nicht im Widerspruch zu der erwogenen Methode. Sie führt lediglich einige Einschränkungen in die Ausbildungspolitik des Actors ein. Auf diese Weise wollte ich einen gewissen Rahmen für das Volumen der eröffneten Position und die Größe der Handelsstufen schaffen.

Wenn ich falsche oder überhöhte Transaktionsparameter erhielt, bildete ich einen Vektor zufälliger Zielwerte innerhalb der vorgegebenen Grenzen und führte einen umgekehrten Durchlauf des Actors durch, ähnlich wie bei überwachten Lernmethoden. Meiner Meinung nach sollte dies die Ergebnisse der Arbeit des Actors in den angegebenen Grenzen wiedergeben.

   if(temp.Min() < 0 || MathMax(temp[0], temp[3]) > 1.0f || MathMax(temp[1], temp[4]) > (Symb.Point() * 5000) ||
      MathMax(temp[2], temp[5]) > (Symb.Point() * 2000))
     {
      temp[0] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5));
      temp[3] = (float)(Symb.LotsMin() * (1 + MathRand() / 32767.0 * 5));
      temp[1] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel()));
      temp[4] = (float)(Symb.Point() * (MathRand() / 32767.0 * 500.0 + Symb.StopsLevel()));
      temp[2] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel()));
      temp[5] = (float)(Symb.Point() * (MathRand() / 32767.0 * 200.0 + Symb.StopsLevel()));
      Result.AssignArray(temp);
      Actor.backProp(Result, GetPointer(PrevAccount), GetPointer(Gradient));
     }
  }

Natürlich könnte man alternativ auch eine einschränkende Aktivierungsfunktion (z. B. ein Sigmoid) verwenden. Aber dann würden wir den Bereich der möglichen Werte stark einschränken. Außerdem konnten wir während des Trainings schnell Grenzwerte erreichen, die das weitere Training des Modells verlangsamten.

Nachdem alle Vorgänge abgeschlossen sind, gehen wir in den Wartemodus für den nächsten Tick.

Der vollständige Code des EA und aller im Artikel verwendeten Programme ist im Anhang verfügbar.


3. Test

Nach Abschluss der Arbeiten an dem EA für das Modelltraining geht es an die Überprüfung der Ergebnisse der geleisteten Arbeit. Wie zuvor wird das Modell auf historischen Daten des EURUSD H1 von Anfang 2023 trainiert. Für alle Indikatoren und Trainingsparameter des Modells wurden Standardwerte verwendet.

Training des Modells

Durch das Trainieren des Modells in Echtzeit werden eigene Anpassungen vorgenommen und der Einsatz mehrerer paralleler Agenten vermieden. Daher wurden die ersten Überprüfungen der korrekten Funktionsweise des EA-Algorithmus im Modus eines Einzeldurchlaufs durchgeführt. Dann wurde der langsame Optimierungsmodus gewählt und nur 1 lokaler Optimierungsagent aktiviert.

Um die Anzahl der Trainingsiterationen zu regulieren, wurde ein externer Parameter Agent hinzugefügt, der im EA-Algorithmus nicht verwendet wird.

Verwaltung der Anzahl der Optimierungsdurchläufe

Nach etwa 3000 Durchläufen war ich in der Lage, ein Modell zu erstellen, das in der Lage war, auf der Trainingsmenge Gewinne zu erzielen. Während des Trainingszeitraums von 5 Monaten tätigte das Modell 334 Transaktionen. Mehr als 84 % von ihnen waren rentabel. Das Ergebnis war ein Gewinn von 33 % des Anfangskapitals. Gleichzeitig betrug der Drawdown vom Saldo weniger als 1 % und 7,6 % vom Kapital. Der Gewinnfaktor lag bei über 26 und der Erholungsfaktor bei 3,16. Die nachstehende Grafik zeigt einen steigenden Saldo. Die Saldenkurve liegt fast immer unter der Kapitalkurve, was darauf hindeutet, dass die Positionen in die richtige Richtung eröffnet werden. Gleichzeitig beträgt die Belastung der Einlage etwa 20 %. Dies ist ein recht hoher Betrag, der jedoch den kumulierten Gewinn nicht übersteigt.

Ergebnisse des Modelltrainings

Ergebnisse des Modelltrainings

Leider fielen die Ergebnisse der EA-Arbeit außerhalb des Trainingssets eher bescheiden aus.


Schlussfolgerung

In diesem Artikel haben wir die Anwendung von Verstärkungslernen im Kontext eines kontinuierlichen Aktionsraums untersucht und die Deep Deterministic Policy Gradient (DDPG) Methode vorgestellt. Dieser Ansatz eröffnet neue Möglichkeiten für das Training des Agenten im Umgang mit Kapital und Risiko, was ein wichtiger Aspekt für einen erfolgreichen Handel ist.

Wir haben den EA für das Training des Modells entwickelt und getestet. Es prognostiziert nicht nur die Richtung eines Handels, sondern bestimmt auch das Transaktionsvolumen, Stop-Loss und Take-Profit. Dies ermöglicht dem Agenten eine effizientere Verwaltung der Investitionen.

Während des Tests ist es uns gelungen, das Modell so zu trainieren, dass es innerhalb der Trainingsdaten Gewinne erzielt. Leider reichte das Training nicht aus, um ähnliche Ergebnisse außerhalb des Trainingssatzes zu erzielen. Der Engpass unserer Implementierung ist das Online-Training des Modells, das es nicht erlaubt, mehrere Agenten parallel einzusetzen, um das Niveau der Umgebungsforschung zu erhöhen und die Trainingszeit des Modells zu reduzieren.

Die erzielten Ergebnisse lassen hoffen, dass es möglich sein wird, das Modell für einen stabilen Betrieb außerhalb des Trainingssets zu trainieren.


Liste der Referenzen

  • Continuous Control with Deep Reinforcement Learning
  • Neuronale Netze leicht gemacht (Teil 27): Tiefes Q-Learning (DQN)
  • Neuronale Netze leicht gemacht (Teil 29): Vorteil des Actor Critic-Algorithmus
  • Neuronale Netze leicht gemacht (Teil 46): Zielgerichtetes Verstärkungslernen (GCRL)

  • Programme, die im diesem Artikel verwendet werden

    # Name Typ Beschreibung
    1 Study.mq5  Expert Advisor Agentenausbildung EA
    2 Test.mq5 Expert Advisor Modellversuche EA
    3 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
    4 NeuroNet.cl Code Base Die Bibliothek des Programmcodes von OpenCL


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

    Beigefügte Dateien |
    MQL5.zip (320.44 KB)
    Neuronale Netze leicht gemacht (Teil 48): Methoden zur Verringerung der Überschätzung von Q-Funktionswerten Neuronale Netze leicht gemacht (Teil 48): Methoden zur Verringerung der Überschätzung von Q-Funktionswerten
    Im vorigen Artikel haben wir die DDPG-Methode vorgestellt, mit der Modelle in einem kontinuierlichen Aktionsraum trainiert werden können. Wie andere Q-Learning-Methoden neigt jedoch auch DDPG dazu, die Werte der Q-Funktion zu überschätzen. Dieses Problem führt häufig dazu, dass ein Agent mit einer suboptimalen Strategie ausgebildet wird. In diesem Artikel werden wir uns einige Ansätze zur Überwindung des genannten Problems ansehen.
    Neuronale Netze leicht gemacht (Teil 46): Goal-conditioned reinforcement learning (GCRL, zielgerichtetes Verstärkungslernen) Neuronale Netze leicht gemacht (Teil 46): Goal-conditioned reinforcement learning (GCRL, zielgerichtetes Verstärkungslernen)
    In diesem Artikel werfen wir einen Blick auf einen weiteren Ansatz des Reinforcement Learning. Es wird als Goal-conditioned reinforcement learning (GCRL, zielgerichtetes Verstärkungslernen) bezeichnet. Bei diesem Ansatz wird ein Agent darauf trainiert, verschiedene Ziele in bestimmten Szenarien zu erreichen.
    Entwicklung eines Wiedergabesystems — Marktsimulation (Teil 10): Nur echte Daten für das Replay verwenden Entwicklung eines Wiedergabesystems — Marktsimulation (Teil 10): Nur echte Daten für das Replay verwenden
    Hier werden wir uns ansehen, wie wir zuverlässigere Daten (gehandelte Ticks) im Wiedergabesystem verwenden können, ohne uns Gedanken darüber zu machen, ob sie angepasst sind oder nicht.
    Neuronale Netze leicht gemacht (Teil 45): Training von Fertigkeiten zur Erkundung des Zustands Neuronale Netze leicht gemacht (Teil 45): Training von Fertigkeiten zur Erkundung des Zustands
    Das Training nützlicher Fertigkeiten ohne explizite Belohnungsfunktion ist eine der größten Herausforderungen beim hierarchischen Verstärkungslernen. Zuvor haben wir bereits zwei Algorithmen zur Lösung dieses Problems kennengelernt. Die Frage nach der Vollständigkeit der Umweltforschung bleibt jedoch offen. In diesem Artikel wird ein anderer Ansatz für das Training von Fertigkeiten vorgestellt, dessen Anwendung direkt vom aktuellen Zustand des Systems abhängt.