English Русский 中文 Español 日本語 Português
preview
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)

MetaTrader 5Handelssysteme | 14 November 2023, 10:40
236 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

„Zielgerichtetes Verstärkungslernen“ klingt ein wenig ungewöhnlich oder sogar seltsam. Denn das Grundprinzip des Verstärkungslernens zielt darauf ab, die Gesamtbelohnung während der Interaktion des Agenten mit der Umwelt zu maximieren. In diesem Zusammenhang geht es jedoch darum, ein bestimmtes Ziel in einer bestimmten Phase oder innerhalb eines bestimmten Szenarios zu erreichen.

Wir haben bereits die Vorteile der Aufteilung eines Gesamtziels in Teilaufgaben erörtert und Methoden untersucht, um einem Agenten verschiedene Fähigkeiten beizubringen, die zum Erreichen des Gesamtergebnisses beitragen. In diesem Artikel schlage ich vor, dieses Problem aus einem anderen Blickwinkel zu betrachten. Wir sollten nämlich einen Agenten darauf trainieren, selbständig eine Strategie und eine Fähigkeit zu wählen, um eine bestimmte Teilaufgabe zu erfüllen.


1. GCRL-Merkmale

Zielgerichtetes Verstärkungslernen (GCRL) ist eine Reihe komplexer Verstärkungslernprobleme. Wir trainieren den Agenten, um in bestimmten Szenarien unterschiedliche Ziele zu erreichen. Zuvor haben wir dem Agenten beigebracht, je nach dem aktuellen Zustand der Umgebung die eine oder andere Aktion zu wählen. Im Falle von GCRL wollen wir den Agenten so trainieren, dass seine Aktion nicht nur durch den aktuellen Zustand, sondern auch durch eine bestimmte Teilaufgabe in diesem Stadium bestimmt wird. Mit anderen Worten, zusätzlich zu dem Vektor, der den aktuellen Zustand beschreibt, sollten wir dem Agenten in irgendeiner Weise eine Teilaufgabe angeben, die er zu jedem bestimmten Zeitpunkt erfüllen muss. Es ist sehr ähnlich wie bei der Ausbildung von Fähigkeiten, wenn wir dem Agenten zu jedem Zeitpunkt eine Fähigkeit angeben. Der Hinweis auf die Fertigkeit „eine Position eröffnen“ oder die Aufgabe „eine Position eröffnen“ scheint ein Wortspiel zu sein. Hinter diesen Worten verbergen sich jedoch Unterschiede in den Ansätzen zur Ausbildung von Agenten.

Beim Verstärkungslernen ist der Engpass immer die Belohnungsfunktion. Genau wie beim konventionellen Verstärkungstraining wird beim Training von Fertigkeiten eine einzige objektive Belohnungsfunktion verwendet. Die Angabe der zu verwendenden Fertigkeit sollte den Zustand der Umgebung ergänzen und dem Agenten helfen, sich darin zurechtzufinden.

Bei der Verwendung von GCRL-Ansätzen führen wir spezifische Teilaufgaben ein. Ihre Leistung sollte sich in der Belohnung widerspiegeln, die der Agent erhält. Sie ähnelt der internen Belohnung eines Diskriminators, basiert aber auf klaren, messbaren Indikatoren, die auf das Erreichen eines bestimmten Ziels (Lösung einer Teilaufgabe) ausgerichtet sind.

Um diesen schmalen Grat zu verstehen, schauen wir uns ein Beispiel für die Eröffnung einer Position in beiden Ansätzen an. Beim Trainieren von Fertigkeiten haben wir den aktuellen Zustand der Umgebung und den Vektor des Kontostands mit fehlenden offenen Positionen an den Planer weitergegeben. Auf diese Weise konnte der Planer den Vektor zur Beschreibung der Fähigkeiten bestimmen, der an den Agenten weitergegeben werden sollte, um eine Entscheidung zu treffen. Wie Sie sich erinnern, haben wir eine Veränderung des Kontostands als Belohnung verwendet. Es ist erwähnenswert, dass wir während der gesamten Ausbildung des Agenten die gleiche Belohnung anwenden. Außerdem wirkt sich die Eröffnung einer Position nicht unmittelbar auf die Veränderung des Saldos aus. Die Ausnahme sind mögliche Provisionen für die Eröffnung einer Position. Aber im Allgemeinen erhalten wir eine Belohnung, wenn wir eine Position mit Verzögerung eröffnen.

Im Falle von GCRL führen wir neben der globalen Zielbelohnung eine zusätzliche Belohnung für das Erreichen einer bestimmten Teilaufgabe ein. Wir können zum Beispiel eine Belohnung für die Eröffnung einer Position einführen oder umgekehrt Geldstrafen verhängen, bis der Agent eine Position eröffnet. Hier müssen wir einen ausgewogenen Ansatz für die Bildung einer solchen Belohnung wählen. Sie sollte die möglichen Gewinne und Verluste aus dem Handelsgeschäft selbst nicht übersteigen. Andernfalls wird der Agent einfach Positionen eröffnen und „Punkte sammeln“, während der Kontostand gegen 0 tendiert.

Außerdem sollte die Belohnung von der jeweiligen Aufgabe abhängen. Wir belohnen die Eröffnung einer Position und bestrafen das Ausbleiben einer solchen Aktion nur, wenn wir die Aufgabe „Eröffnung einer Position“ stellen. Bei der Suche nach einem Ausstiegspunkt aus einer Position können wir im Gegenteil eine Strafe für eine zusätzliche offene Position sowie für das Halten einer Position über einen längeren Zeitraum einführen.

Bei der Bildung eines Vektors zur Beschreibung der Aufgabenstellung für GCRL ist es wichtig, bestimmte Anforderungen zu berücksichtigen. Der Vektor sollte ausdrücklich die Teilaufgabe angeben, die der Agent zu einem bestimmten Zeitpunkt erfüllen soll.

Der Aufgabenbeschreibungsvektor kann je nach Kontext und Spezifika der Aufgabe verschiedene Elemente enthalten. Bei der Eröffnung einer Position kann der Beschreibungsvektor beispielsweise Informationen über den Zielwert, das Handelsvolumen, die Preislimits oder andere mit der Eröffnung einer Position verbundene Parameter enthalten. Diese Elemente sollten für den Agenten klar und verständlich sein, damit er die jeweilige Teilaufgabe richtig interpretieren kann.

Darüber hinaus sollte der Aufgabenbeschreibungsvektor hinreichend informativ sein, damit der Agent Entscheidungen treffen kann, die maximal auf die Erfüllung dieser Teilaufgabe ausgerichtet sind. Dies kann die Einbeziehung zusätzlicher Daten oder kontextbezogener Informationen erfordern, die dem Agenten helfen, genauer zu verstehen, wie er handeln muss, um das Ziel zu erreichen.

Es sollte eine ausgeprägte logische, aber nicht mathematische Beziehung zwischen dem Vektor der Teilaufgabenbeschreibung und dem gewünschten Ergebnis bestehen. Wir können einen normalen One-Hot-Vektor verwenden. Jedes Element des Vektors entspricht einer separaten Teilaufgabe. Der Vektor wird zusammen mit einer Beschreibung des aktuellen Zustands der Umgebung an den Agenten weitergegeben. Die Hauptsache ist, dass der Agent die Teilaufgabe klar interpretieren und seine internen Verbindungen zwischen der Teilaufgabe und der Belohnung herstellen kann. In diesem Zusammenhang sollten wir auf die Belohnung achten. Die zusätzlich eingeführte Belohnung sollte auf eine bestimmte Teilaufgabe abgestimmt sein.

Es gibt aber auch andere Ansätze zur Bildung eines Vektors zur Beschreibung von Teilaufgaben. Wenn eine Kombination vieler Faktoren erforderlich ist, um eine einzelne Teilaufgabe zu beschreiben, können wir ein separates Modell verwenden, um einen solchen Vektor zu bilden, analog zu den Methoden für das Training von Fähigkeiten. Ein solches Modell kann mit verschiedenen Auto-Encodern oder einer anderen verfügbaren Methode trainiert werden.

Wie Sie sehen, sind beide Ansätze sehr leistungsfähig und ermöglichen es uns, unterschiedliche Probleme zu lösen. Jede dieser Methoden hat jedoch ihre Schwächen. Es ist kein Zufall, dass verschiedene Synergien zwischen den beiden Ansätzen auftreten, die es ermöglichen, einen noch stabileren Algorithmus zu entwickeln. In der Tat haben wir beim Trainieren von Fähigkeiten Abhängigkeiten zwischen dem aktuellen Zustand der Umgebung und der Fähigkeit des Agenten (Aktionspolitik) hergestellt. Die Verwendung zusätzlicher Werkzeuge, die auf die Erledigung einer bestimmten Teilaufgabe ausgerichtet sind, hilft bei der Anpassung der Agentenstrategie, um ein optimales Ergebnis zu erzielen.

Ein solcher Ansatz ist die adaptive, variationale GCRL (aVGCRL). Die Idee dahinter ist, dass in einem stochastischen Umfeld die Verteilung der einzelnen Fähigkeiten nicht gleichmäßig ist. Außerdem kann sie sich je nach dem Zustand der Umwelt ändern. In bestimmten Staaten wird es eine Abhängigkeit von bestimmten Fähigkeiten geben, für die die Streuung der Verteilung minimal ist. Gleichzeitig ist die Wahrscheinlichkeit, dass andere Fertigkeiten in denselben Staaten eingesetzt werden, nicht so eindeutig, und die Streuung ihrer Verteilung ist deutlich höher. In anderen Umweltzuständen ist die Varianz der Kompetenzverteilung wahrscheinlich dramatisch anders. Dieser Effekt lässt sich beobachten, wenn wir uns die latente Darstellung der Varianzen des Variations-Autocodierers ansehen, den wir im vorigen Artikel zum Trainieren des Schedulers (Planers) verwendet haben. Eine logische Lösung wäre es, sich auf explizite Abhängigkeiten zu konzentrieren. Die Autoren der aVGCRL-Methode schlagen vor, den Abweichungsfehler für jede Fähigkeit vom Zielwert durch die Streuung der Verteilung zu dividieren. Es liegt auf der Hand, dass der Einfluss des Fehlers umso größer ist, je kleiner die Varianz ist und je mehr sich die entsprechenden Gewichtungskoeffizienten während des Trainingsprozesses ändern. Gleichzeitig führt die Zufälligkeit der anderen Fähigkeiten nicht zu einem signifikanten Ungleichgewicht im allgemeinen Modell.


2. Implementierung mittels MQL5

Gehen wir nun zur Implementierung der GCRL-Methode über, um sie noch besser kennenzulernen. Wir werden eine Art Symbiose der beiden betrachteten Methoden schaffen, obwohl wir alles in einem einzigen Modell zusammenfassen werden.

Im vorangegangenen Artikel haben wir 2 Modelle erstellt: einen Planer in Form eines variablen Auto-Encoders und einen Agenten. Im Gegensatz zu früheren Ansätzen erhielt der Agent nur den latenten Zustand des Autodecoders, der nach unserer Logik alle notwendigen Informationen hätte enthalten müssen. Der Test zeigte, dass das Training des Agenten, den vom Auto-Encoder vorhergesagten Zustand zu erreichen, nicht das gewünschte Ergebnis brachte. Dies kann auf die unzureichende Qualität der Vorhersagebedingungen zurückzuführen sein.

Gleichzeitig ermöglichte die Verwendung klassischer Belohnungsansätze eine Verbesserung des Agenten-Trainings mit Hilfe eines zuvor trainierten Schedulers.

In dieser Arbeit haben wir beschlossen, auf ein separates Training des Variations-Autokodierers zu verzichten und den Kodierer direkt in das Agentenmodell zu integrieren. Es sei darauf hingewiesen, dass dieser Ansatz in gewisser Weise gegen die Grundsätze des Trainings eines Auto-Encoders verstößt. Der Hauptgedanke bei der Verwendung eines Auto-Encoders ist ja die Datenkompression ohne Bezug auf eine bestimmte Aufgabe. Aber jetzt stehen wir nicht mehr vor der Aufgabe, einen Encoder zu trainieren, um mehrere Probleme mit denselben Quelldaten zu lösen.

Außerdem liefern wir nur den aktuellen Zustand der Umgebung an den Encoder-Eingang. In unserem Fall handelt es sich um historische Daten über die Kursentwicklung des Instruments und die Parameter der analysierten Indikatoren. Mit anderen Worten: Wir schließen Informationen über den Kontostatus aus. Wir gehen davon aus, dass der Planer (in diesem Fall der Encoder) die zu verwendende Fähigkeit auf der Grundlage historischer Daten bildet. Dies kann eine Politik der Arbeit in einem steigenden, fallenden oder stagnierenden Markt sein.

Auf der Grundlage der Informationen über den Kontostatus erstellen wir eine Teilaufgabe für den Agenten, um nach einem Ein- oder Austrittspunkt zu suchen.

Die Unterteilung des Modells in Scheduler und Agent ist absolut willkürlich. Schließlich werden wir ein Modell bilden. Wie bereits erwähnt, liefern wir jedoch nur historische Daten an den Encoder-Eingang. Das bedeutet, dass wir in der Mitte des Modells Informationen über die zugewiesene Teilaufgabe hinzufügen müssen. Das haben wir bisher nicht getan. Dies ist keine völlig neue Lösung. Sie ist uns schon einmal begegnet. Für diese Fälle haben wir 2 Modelle erstellt.

Der erste Teil wurde mit einem Modell gelöst, dann kombinierten wir die Ausgabe des ersten Modells mit neuen Daten und speisten sie in die Eingabe des zweiten Modells ein. Diese Lösung ist einfacher zu handhaben, hat aber einen entscheidenden Nachteil. Dies führt zu redundanter Kommunikation zwischen dem Hauptprogramm und dem OpenCL-Kontext. Wir müssen die Ergebnisse des ersten Modells aus dem Kontext holen und sie für das zweite Modell neu laden. Das Gleiche gilt für den Fehlergradienten beim Rückwärtsgang. Bei Verwendung eines einzigen Modells entfallen diese Vorgänge. Es stellt sich jedoch die Frage, ob neue Informationen in einer separaten Phase des Modellbetriebs hinzugefügt werden sollen.

Um dieses Problem zu lösen, werden wir einen neuen Typ von neuronaler Schicht CNeuronConcatenate erstellen. Wie zuvor beginnen wir mit der Arbeit an jeder neuen neuronalen Schichtklasse, indem wir die erforderlichen Kernel im OpenCL-Programm erstellen. Zunächst haben wir den Kernel des Vorwärtsdurchgangs Concat_FeedForward erstellt. Alle Kernel wurden auf der Grundlage ähnlicher Kernel der Basisschicht der voll verbundenen neuronalen Schicht erstellt. Der Hauptunterschied besteht in der Hinzufügung von zusätzlichen Puffern und Parametern für den zweiten Informationsstrom.

In den Kernelparametern Concat_FeedForward sehen wir eine einzelne Gewichtsmatrix, 2 Tensoren mit Quelldaten, einen Ergebnisvektor und 3 numerische Parameter (Größen der Quelldatentensoren und Aktivierungsfunktion-ID)

__kernel void Concat_FeedForward(__global float *matrix_w,
                                 __global float *matrix_i1,
                                 __global float *matrix_i2,
                                 __global float *matrix_o,
                                 int inputs1,
                                 int inputs2,
                                 int activation
                                )

Wie zuvor starten wir den Kernel in einem eindimensionalen Aufgabenraum, der auf der Anzahl der Neuronen in unserer Schicht basiert, die mit der Größe des Ergebnispuffers identisch ist. Im Kernelkörper definieren wir die Thread-ID und deklarieren die notwendigen lokalen Variablen. Hier bestimmen wir den Offset im Puffer für die Gewichtskoeffizienten. Bitte beachten Sie, dass wir für jedes Neuron am Ausgang der Schicht die Anzahl der Gewichte gleich der Gesamtgröße von 2 Quelldatenpuffern und 1 Bayes'schen Bias-Neuron definieren.

  {
   int i = get_global_id(0);
   float sum = 0;
   float4 inp, weight;
   int shift = (inputs1 + inputs2 + 1) * i;

Als Nächstes wird ein Zyklus zur Berechnung der gewichteten Summe von 1 Quelldatenpuffer eingerichtet. Dieser Prozess ist völlig identisch mit dem im Kern einer voll verknüpften neuronalen Schicht.

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

Nach Abschluss der Schleifeniterationen wird die Verzerrung in der Gewichtsmatrix um die Größe von 1 Quelldatenpuffer angepasst. Außerdem erstellen wir einen ähnlichen Zyklus für 2 Quelldatenpuffer.

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

Am Ende des Kerns fügen wir ein Bayes'sches Verzerrungselement hinzu und aktivieren die resultierende Summe. Dann speichern wir den resultierenden Wert im entsprechenden Element des Ergebnispuffers.

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

Genau der gleiche Ansatz wurde bei der Modifizierung der Kernel für die Rückwärtsdurchgänge und der Aktualisierung der Gewichtsmatrix verwendet. Sie können sich mit ihnen in NeuroNet_DNG\NeuroNet.cl vertraut machen (dem Artikel beigefügt).

Nachdem wir die Kernel erstellt haben, arbeiten wir an dem Code für die Klasse CNeuronConcatenate im Hauptprogramm weiter. Der Satz von Klassenmethoden ist ziemlich standardisiert:

  • Konstruktor CNeuronConcatenate und Destruktor ~CNeuronConcatenate
  • Initialisierung der ersten neuronalen Schicht
  • Vorwärtsdurchgang feedForward
  • Fehlergradientenverteilung calcHiddenGradients
  • Aktualisierung der Gewichtsmatrix updateInputWeights
  • Identifizierung des Typobjekts
  • Arbeiten mit Speichern und Laden von Dateien.

class CNeuronConcatenate   :  public CNeuronBaseOCL
  {
protected:
   int               i_SecondInputs;
   CBufferFloat     *ConcWeights;
   CBufferFloat     *ConcDeltaWeights;
   CBufferFloat     *ConcFirstMomentum;
   CBufferFloat     *ConcSecondMomentum;

public:
                     CNeuronConcatenate(void);
                    ~CNeuronConcatenate(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                          uint inputs1, uint inputs2, ENUM_OPTIMIZATION optimization_type, uint batch);
   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   virtual bool      calcHiddenGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient);
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput);
   //--- methods for working with files
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronConcatenate; }
   virtual void      SetOpenCL(COpenCLMy *obj);
  };

Zusätzlich deklarieren wir in der Klasse eine Variable zur Erfassung der Größe zusätzlicher Quelldaten und 4 Datenpuffer: Gewichts- und Momentenmatrizen für verschiedene Methoden zur Optimierung von Gewichtskoeffizienten. Die neuen Puffer werden verwendet, um den Kommunikationsprozess mit der vorherigen neuronalen Schicht und den neuen Quelldaten zu organisieren. Die Datenübertragung an die nachfolgende neuronale Schicht erfolgt über die Elternklasse der vollständig verbundenen neuronalen Schicht CNeuronBaseOCL.

Wir initialisieren die Datenpuffer im Klassenkonstruktor.

CNeuronConcatenate::CNeuronConcatenate(void) : i_SecondInputs(0)
  {
   ConcWeights = new CBufferFloat();
   ConcDeltaWeights = new CBufferFloat();
   ConcFirstMomentum = new CBufferFloat();
   ConcSecondMomentum = new CBufferFloat;
  }

Im Destruktor der Klasse werden die Daten geleert und die Objekte gelöscht.

CNeuronConcatenate::~CNeuronConcatenate()
  {
   if(!!ConcWeights)
      delete ConcWeights;
   if(!!ConcDeltaWeights)
      delete ConcDeltaWeights;
   if(!!ConcFirstMomentum)
      delete ConcFirstMomentum;
   if(!!ConcSecondMomentum)
      delete ConcSecondMomentum;
  }

Die Angabe der Größe aller notwendigen Datenpuffer ist in der Init-Objektinitialisierungsmethode geregelt. Die Methode erhält die erforderlichen Ausgangsdaten in den Parametern:

  • numOutputs — Anzahl der Neuronen in der nächsten Schicht
  • open_cl — Zeiger auf das OpenCL-Kontextbehandlungsobjekt
  • numNeurons — Anzahl der Neuronen in der aktuellen Schicht
  • numInputs1 — Anzahl der Elemente in der vorherigen Schicht
  • numInputs2 — Anzahl der Elemente im zusätzlichen Quelldatenpuffer
  • optimization_type — Parameter Optimierungsmethode ID.
bool CNeuronConcatenate::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, 
                              uint numInputs1, uint numInputs2, ENUM_OPTIMIZATION optimization_type, uint batch)
  {
   if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch))
      return false;

Im Hauptteil der Methode rufen wir anstelle eines Kontrollblocks eine ähnliche Methode der übergeordneten Klasse auf und überprüfen das Ergebnis der Operationen. In der übergeordneten Klasse sind die grundlegenden Steuerelemente bereits implementiert, sodass wir sie nicht wiederholen müssen. Darüber hinaus implementiert die Methode der Elternklasse die Initialisierung aller geerbten Objekte und Variablen. Daher müssen wir nur den Prozess der Initialisierung der hinzugefügten Objekte im Körper dieser Methode organisieren.

Zunächst wird eine Matrix mit Gewichtskoeffizienten mit Zufallswerten erstellt und initialisiert, um den Datenaustausch mit der vorherigen neuronalen Schicht zu organisieren. Bitte beachten Sie, dass die Größe der Gewichtsmatrix ausreichend ist, um die Arbeit mit der vorherigen Schicht und dem zusätzlichen Quelldatenpuffer zu organisieren. Das ist genau der Ansatz, den wir uns bei der Entwicklung des Kernels des Vorwärtsdurchgangs vorgestellt haben. Nun halten wir uns bei der Erstellung von Klassenmethoden auf der Seite des Hauptprogramms daran.

   i_SecondInputs = (int)numInputs2;
   if(!ConcWeights)
     {
      ConcWeights = new CBufferFloat();
      if(!ConcWeights)
         return false;
     }
   int count = (int)((numInputs1 + numInputs2 + 1) * numNeurons);
   if(!ConcWeights.Reserve(count))
      return false;
   float k = (float)(1.0 / sqrt(numNeurons + 1.0));
   for(int i = 0; i < count; i++)
     {
      if(!ConcWeights.Add((2 * GenerateWeight()*k - k)*WeightsMultiplier))
         return false;
     }
   if(!ConcWeights.BufferCreate(OpenCL))
      return false;

Als Nächstes werden je nach der in den Parametern angegebenen Methode zur Aktualisierung der Gewichtskoeffizienten die Momentpuffer initialisiert. Wie Sie sich vielleicht erinnern, verwenden wir einen Momentpuffer für die SGD. Bei der Verwendung der Adam-Methode werden 2 Momentpuffer initialisiert. Wir löschen ungenutzte Objekte, wodurch wir die verfügbaren Ressourcen effizienter nutzen können.

   if(optimization == SGD)
     {
      if(!ConcDeltaWeights)
        {
         ConcDeltaWeights = new CBufferFloat();
         if(!ConcDeltaWeights)
            return false;
        }
      if(!ConcDeltaWeights.BufferInit(count, 0))
         return false;
      if(!ConcDeltaWeights.BufferCreate(OpenCL))
         return false;
      if(!!ConcFirstMomentum)
         delete ConcFirstMomentum;
      if(!!ConcSecondMomentum)
         delete ConcSecondMomentum;
     }
   else
     {
      if(!!ConcDeltaWeights)
         delete ConcDeltaWeights;
      //---
      if(!ConcFirstMomentum)
        {
         ConcFirstMomentum = new CBufferFloat();
         if(CheckPointer(ConcFirstMomentum) == POINTER_INVALID)
            return false;
        }
      if(!ConcFirstMomentum.BufferInit(count, 0))
         return false;
      if(!ConcFirstMomentum.BufferCreate(OpenCL))
         return false;
      //---
      if(!ConcSecondMomentum)
        {
         ConcSecondMomentum = new CBufferFloat();
         if(!ConcSecondMomentum)
            return false;
        }
      if(!ConcSecondMomentum.BufferInit(count, 0))
         return false;
      if(!ConcSecondMomentum.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

Wir beenden die Arbeit mit den Initialisierungsmethoden der Klasse und gehen zur Organisation der Hauptfunktionen über. Zunächst erstellen wir die Methode für die Durchgänge feedForward. Im Gegensatz zu den Direktübergabe-Methoden aller zuvor betrachteten Klassen erhält diese Methode in ihren Parametern 2 Zeiger auf Objekte: die vorherige neuronale Schicht und einen zusätzlichen Quelldatenpuffer. Das ist nicht weiter verwunderlich, denn dies ist das Hauptunterscheidungsmerkmal der entstehenden Klasse. Dieser Ansatz erfordert jedoch zusätzliche Arbeit auf der Seite des Hauptprogramms außerhalb der erstellten Klasse. Darüber werden wir später noch sprechen.

bool CNeuronConcatenate::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput)
  {
   if(!OpenCL || !NeuronOCL || !SecondInput)
      return false;

Im Hauptteil der Methode wird zunächst die Relevanz der empfangenen Zeiger geprüft. Außerdem werden wir das Vorhandensein des Zeigers auf ein Objekt für die Arbeit mit dem OpenCL-Kontext überprüfen. Wenn mindestens ein Zeiger fehlt, wird die Methode mit einem negativen Ergebnis beendet.

Als Nächstes wird die Größe des zusätzlichen Datenpuffers überprüft. Sie sollte eine ausreichende Anzahl von Elementen enthalten. Bitte beachten Sie, dass wir eine größere Puffergröße angeben können. Während der Arbeit werden jedoch nur die ersten Elemente aus dem Puffer in der bei der Initialisierung der Klasse angegebenen Menge verwendet.

   if(SecondInput.Total() < i_SecondInputs)
      return false;
   if(SecondInput.GetIndex() < 0 && !SecondInput.BufferCreate(OpenCL))
      return false;

Dann prüfen wir, ob der Zeiger auf den Datenpuffer im OpenCL-Kontext vorhanden ist, und erstellen bei Bedarf einen neuen Puffer.

Beachten Sie, dass wir nur dann einen neuen Puffer anlegen, wenn es im Kontext keinen Zeiger auf den Datenpuffer gibt. Ist sie vorhanden, werden die Daten nicht erneut in den Kontext geladen. Wir glauben, dass das Vorhandensein eines Zeigers auf das Vorhandensein von Daten im Kontext hinweist. Wenn sich also der Inhalt des Puffers auf der Seite des Hauptprogramms ändert, ist es notwendig, die Daten in den Kontext zu kopieren. Es liegt in der Verantwortung des Nutzers, dafür zu sorgen, dass die Daten im Kontextspeicher auf dem neuesten Stand sind.

Als Nächstes übergeben wir Zeiger auf die Datenpuffer und die notwendigen Konstanten an die Kernel-Parameter. Dieses Verfahren ist für alle Kernel identisch. Es ändern sich nur die Bezeichner von Kerneln, Parametern und Zeigern auf die entsprechenden Datenpuffer. Alle mathematischen Operationen sollten im Kernel selbst auf der OpenCL-Programmseite angegeben werden.

   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_w, ConcWeights.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i1, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_i2, SecondInput.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_ConcatFeedForward, def_k_cff_matrix_o, Output.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs1, (int)NeuronOCL.Neurons()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_inputs2, (int)i_SecondInputs))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_ConcatFeedForward, def_k_cff_activation, (int)activation))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }

Am Ende der Methodenoperationen geben wir die Aufgabenbereiche an, um den Kernel auszuführen und ihn in die Ausführungswarteschlange zu stellen.

   uint global_work_offset[1] = {0};
   uint global_work_size[1];
   global_work_size[0] = Output.Total();
   if(!OpenCL.Execute(def_k_ConcatFeedForward, 1, global_work_offset, global_work_size))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__);
      return false;
     }
//---
   return true;
  }

Hier ist es sehr wichtig, die Korrektheit der Spezifikation des aufgerufenen Kernels in jeder Phase zu kontrollieren, ebenso wie die Puffer-ID und deren Inhalt. Natürlich sollten wir nicht vergessen, die Korrektheit der Vorgänge bei jedem Schritt zu kontrollieren.

Die Methoden zur Verteilung der Fehlergradienten und zur Aktualisierung der Gewichtsmatrix basieren auf einem ähnlichen Algorithmus, und Sie können sich mit ihnen im Anhang vertraut machen. Es ist lediglich zu beachten, dass bei der Verteilung des Fehlergradienten ein Puffer von Fehlergradienten auf der Ebene der zusätzlichen Quelldaten hinzugefügt wird. In dieser Arbeit werden wir seine Daten nicht herunterladen und verwenden. Sie kann jedoch in Zukunft erforderlich sein, wenn der Vektor zusätzlicher Ausgangsdaten durch das zweite Modell erzeugt wird.

Nachdem wir die Methoden unserer Klasse CNeuronConcatenate erstellt haben, sollten wir uns darum kümmern, dass ein zusätzlicher Puffer mit den Quelldaten des Nutzers vom Hauptprogramm an eine bestimmte neuronale Schicht übertragen wird. Im Allgemeinen ist der Prozess so organisiert, dass der Nutzer nach der Erstellung eines Modells nur mit 2 Methoden arbeitet: Vorwärts- und Rückwärtsdurchgang des Modells als Ganzes. Die Nutzer haben keine Kontrolle über die Datenübertragung zwischen den neuronalen Schichten. Der gesamte Prozess findet „unter der Haube“ unserer Bibliothek statt. Daher sollte der Nutzer in der Lage sein, eine Vorwärtsdurchgangs-Methode aufzurufen und in ihren Parametern 2 Datenpuffer anzugeben. Danach sollte das Modell die Daten selbständig in die entsprechenden Informationsflüsse verteilen.

In diesem Stadium planen wir, nur eine Schicht mit Datenzusatz zu verwenden. Um den Prozess nicht durch eine zusätzliche Nachverfolgung zu verkomplizieren, an welche neuronale Schicht zusätzliche Quelldaten zu übertragen sind, wurde beschlossen, den Zeiger auf den Puffer an alle neuronalen Schichten zu übergeben. Die Entscheidung über die Verwendung wird auf der Ebene der Klasse selbst getroffen.

Wir werden nicht näher darauf eingehen, einen Parameter in mehreren Methoden entlang der Kette hinzuzufügen. Der vollständige Code aller Methoden und Funktionen findet sich im Anhang. Bleiben wir bei einem Detail: Obwohl die Direktübergabemethoden aller Klassen identische Namen haben und als virtuell deklariert sind, ist es nicht möglich, Methoden in geerbten Klassen vollständig neu zu definieren, wenn man in einigen Klassen einen Parameter hinzufügt, während er in anderen fehlt. Um die Vererbung zu erhalten, müssten wir die Vorwärts- und Rückwärtspassmethoden aller zuvor erstellten Klassen neu erstellen. Das haben wir nicht getan. Stattdessen haben wir den Dispatch-Methoden der zugrunde liegenden neuronalen Schicht lediglich eine zusätzliche Kontrolle hinzugefügt. Schauen wir uns das Beispiel der Methode des direkten Durchlaufs an.

In den Parametern der Versandmethode CNeuronBaseOCL::FeedForward fügen wir einen Zeiger auf den Datenpuffer hinzu und weisen ihm einen Standardwert zu. Mit diesem Trick können wir die Methode auch dann anwenden, wenn wir nur einen Zeiger auf die vorherige neuronale Schicht haben. Dies ist nützlich, wenn die Bibliothek für zuvor erstellte Modelle verwendet wird, und ermöglicht die Kompilierung zuvor erstellter Programme ohne Änderungen.

Als Nächstes überprüfen wir den Typ der aktuellen neuronalen Schicht. Wenn wir uns in einer Klasse für die Kombination von Daten aus zwei Threads befinden, rufen wir die entsprechende Vorwärtsdurchgangs-Methode auf. Andernfalls verwenden wir den zuvor erstellten Algorithmus. Nachstehend ist nur ein Teil des Methodencodes mit Änderungen aufgeführt. Außerdem hat sich der Methodencode nicht geändert. Der vollständige Code der Methode CNeuronBaseOCL::FeedForward ist im Anhang zu finden. Dort finden Sie auch modifizierte Rückwärtsdurchgangs-Dispatch-Methoden. Zusätzliche Puffer mit auf Null gesetzte Standardzeigern wurden ebenfalls hinzugefügt.

bool CNeuronBaseOCL::FeedForward(CObject *SourceObject, CBufferFloat *SecondInput = NULL)
  {
   if(CheckPointer(SourceObject) == POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp = NULL;
   if(Type() == defNeuronConcatenate)
     {
      temp = SourceObject;
      CNeuronConcatenate *concat = GetPointer(this);
      return concat.feedForward(temp, SecondInput);
     }

Es gibt viele Informationen, aber der Umfang der Artikel ist begrenzt. Deshalb habe ich die Methoden der neuen Klasse CNeuronConcatenate nur kurz besprochen. Ich hoffe, dass dies keine negativen Auswirkungen auf das Verständnis von Ideen und Ansätzen hat. In jedem Fall unterscheidet sich ihr Algorithmus nicht wesentlich von ähnlichen Methoden der zuvor diskutierten Klassen. Der vollständige Code aller Methoden und Klassen befindet sich im Anhang. Wenn Sie Fragen haben, bin ich gerne bereit, sie im Forum und in persönlichen Nachrichten auf der Website zu beantworten. Wählen Sie einen für Sie geeigneten Kommunikationskanal.

Wir nähern uns der hier betrachteten GCRL-Verstärkungslernmethode und betrachten die Prozesse der Erstellung und des Trainings des Modells. Wie zuvor werden wir 3 EAs erstellen:

  • Primärsammlung von Beispielen „GCRL\Research.mq5“
  • Agentenausbildung „GCRL\StudyActor.mq5“
  • Testen der Modelloperation „GCRL\Test.mq5“

Wir werden die Modellarchitektur in der Include-Datei GCRL\Trajectory.mqh angeben.

Wie bereits erwähnt, werden wir das gesamte Modell in einem Agenten zusammenfassen. Folglich werden wir nur die Architektur eines Modells beschreiben. Im Hauptteil der Methode CreateDescriptions wird zunächst die Relevanz des Zeigers auf das dynamische Array-Objekt geprüft und gegebenenfalls ein neues Objekt erstellt. Stellen Sie sicher, dass Sie das dynamische Array löschen, bevor Sie neue Objekte zur Beschreibung der neuronalen Schichten hinzufügen.

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

Wie immer erstellen wir zuerst die Quelldatenebene. Es folgt die Normalisierungsschicht. Wir haben bereits oben erwähnt, dass die Ausgangsdaten für den Encoder nur historische Daten und Indikatorparameter sind. Dies spiegelt sich in der Größe dieser neuronalen Schichten wider.

//--- 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;
     }

Als Nächstes wiederholen wir die Encoder-Architektur aus dem vorherigen Artikel vollständig. Er besteht aus einem Block von Faltungsnetzen. Es folgen 3 vollständig verknüpfte Schichten und enden mit den Encoder-Schichten der latenten Repräsentation des Variations-Autocodierers. Dies ist eine etwas ungewöhnliche Lösung für ein vollständiges Modell. Wir haben bereits über die Konventionen der Unterteilung von Algorithmen und Modellen gesprochen. Schauen wir uns die praktischen Ergebnisse an.

//--- 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 = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   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 = 4;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronProofOCL;
   prev_count = descr.count = prev_count;
   descr.window = 4;
   descr.step = 4;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 256;
   descr.optimization = ADAM;
   descr.activation = TANH;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   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;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NSkills;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NSkills;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Die Beschreibung des Encoders ist vollständig. Fahren wir nun mit der Erstellung unseres Agenten fort. Seine Architektur beginnt mit einer Schicht, die 2 Datenströme kombiniert. Der erste Stream entspricht der Größe der Encoderergebnisse. Die zweite ist gleich der Größe des Vektors, der die Aufgabe beschreibt. Wir werden die Beschreibung des Gleichgewichtszustands als Vektor für die Beschreibung der vorliegenden Aufgabe verwenden.

Im theoretischen Teil haben wir über die Notwendigkeit der Trennbarkeit von Teilaufgaben gesprochen. In unserem vereinfachten Schema werden wir nur 2 Teilaufgaben verwenden:

  • Suche nach einem Einstiegspunkt in eine Position
  • Suche nach einem Ausstiegspunkt aus einer Position

Wir haben offene Positionen in der Struktur der Kontozustandsbeschreibung angegeben. Wenn also das Volumen der offenen Positionen „0“ ist, dann besteht die Aufgabe darin, eine Position zu eröffnen. Ansonsten suchen wir nach einem Ausstiegspunkt. Die Idee ist einfach und erinnert an die Verwendung eines One-Hot-Vektors. Der einzige Unterschied ist das Volumen der offenen Positionen. Sie wird selten gleich „1“ sein, da wir die Mindestlosgröße verwenden und die gleichzeitige Eröffnung mehrerer Positionen erlauben.

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

Wir verwenden relative Einheiten, wenn wir den Zustand eines Kontos beschreiben. Wir erwarten, dass ihr Wert nahe bei den normalisierten Daten liegt. Daher wird die Ebene der Stapelnormalisierung hier nicht verwendet.

Es folgen der Entscheidungsblock mit 2 vollständig verbundenen Schichten und der Block mit der vollständig parametrisierten FQF-Quantilfunktion. Wie Sie sehen können, haben wir einen ähnlichen Entscheidungsblock in dem Agenten aus dem vorherigen Artikel verwendet. Dort haben wir bereits die wichtigsten Eigenschaften und Merkmale der Lösungen der einzelnen neuronalen Schichten erörtert.

//--- layer 11
   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 12
   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 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronFQF;
   descr.count = NActions;
   descr.window_out = 32;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Nach der Beschreibung der Architektur des Modells wird ein Roboter zum Sammeln der primären Beispieldatenbank „GCRL\Research.mq5“ erstellt. Der Algorithmus dieses EA wird von einem Artikel zum anderen praktisch unverändert übernommen. Eine detaillierte Betrachtung dieser Frage würde den Rahmen dieses Artikels sprengen. Der vollständige Code des EAs befindet sich im Anhang. Wir werden nur kurz auf die Änderungen eingehen, die sich durch die Anwendung der GCRL-Methode ergeben.

Zunächst sei daran erinnert, dass einer der Nachteile der letzten Modelle die langfristige Beibehaltung offener Stellen war. Wir können feststellen, dass unser Vektor zur Beschreibung des Kontostandes das Volumen der offenen Positionen und den kumulierten Gewinn in jeder Richtung enthält. Es gibt jedoch keine Hinweise auf den Zeitpunkt der Eröffnung von Positionen. Wenn wir einem Agenten beibringen wollen, diesen Prozess zu steuern, dann sollten wir ihm einen geeigneten Bezugspunkt geben.

Im Aktionsbereich unseres Agenten gibt es nur die Möglichkeit, alle Positionen zu schließen. Daher sehe ich keine Notwendigkeit, den Zeitpunkt der offenen Kauf- und Verkaufspositionen zu trennen. Wir wollen einen gemeinsamen Parameter für alle Positionen einführen. Gleichzeitig wollten wir einen Parameter schaffen, der nicht nur von der Zeit abhängt, sondern auch vom Volumen der Position, dem kumulierten Gewinn oder Verlust.

Wir schlagen vor, als Indikator die Summe der absoluten Werte der kumulierten Gewinne/Verluste, gewichtet nach der Dauer der offenen Position, zu verwenden. So können wir den Indikator an den Zeitpunkt der Positionseröffnung, das Volumen und die Marktvolatilität (indirekt über den Gewinn) anpassen. Die Verwendung des absoluten Werts des Gewinns ermöglicht es uns, den sich gegenseitig absorbierenden Einfluss von profitablen und unprofitablen Positionen zu eliminieren. 

 Unter Berücksichtigung der obigen Ausführungen werden wir den Prozess der Beschreibung des Kontostandes anpassen, der in der OnTick-Methode des EA durchgeführt wird.

In den ersten beiden Elementen der Beschreibung des Kontostands werden der Kontostand und die Eigenkapitalkennzahlen gespeichert. Um die Menge der Informationen zu reduzieren und ihre Qualität zu verbessern, haben wir auf die Angabe von Margenindikatoren verzichtet, da ihr Informationsgehalt im Kontext der aktuellen Aufgabe gering ist. Ich schließe jedoch nicht aus, dass sie in späteren Arbeiten hinzugefügt werden.

Die Zeit für die Eröffnung von Positionen wird in Sekunden berücksichtigt, und wir arbeiten mit dem H1-Zeitrahmen. Bestimmen wir gleich den Multiplikator für die Anpassung der Positionsgültigkeitsdauer in Stunden. Hier werden wir eine Variable hinzufügen, um die Strafe für das Halten einer Position anhand der obigen Gleichung zu berechnen. Wir wollen jedoch nicht, dass die Haltegebühr die Erträge aus der Position übersteigt. Zu diesem Zweck legen wir fest, dass wir jede Stunde eine Strafe von 1/10 des aufgelaufenen Gewinns verhängen. Die Verwendung des absoluten Werts des Gewinns in der obigen Gleichung ermöglicht es uns, sowohl profitable als auch unprofitable Positionen zu bestrafen.

Wir speichern die aktuelle Zeit in einer lokalen Variablen und starten die Schleife zur Suche nach offenen Positionen. Im Schleifenkörper berechnen wir das Volumen der offenen Positionen und den kumulierten Gewinn/Verlust in jeder Richtung sowie eine Gesamtstrafe für das Halten einer Position.

   sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   double position_discount = 0;
   double multiplyer = 1.0 / (60.0 * 60.0 * 10.0);
   int total = PositionsTotal();
   datetime current = TimeCurrent();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
      position_discount -= (current - PositionGetInteger(POSITION_TIME)) * multiplyer*MathAbs(PositionGetDouble(POSITION_PROFIT));
     }
   sState.account[2] = (float)buy_value;
   sState.account[3] = (float)sell_value;
   sState.account[4] = (float)buy_profit;
   sState.account[5] = (float)sell_profit;
   sState.account[6] = (float)position_discount;

Nach Abschluss der Schleifenwiederholungen speichern wir die resultierenden Werte in den entsprechenden Array-Elementen, um sie in die Beispieldatenbank zu schreiben.

Bevor wir die Daten an unser Modell weitergeben, werden wir sie in ein Feld für relative Einheiten umwandeln.

   State.AssignArray(sState.state);
   Account.Clear();
   float PrevBalance = (Base.Total <= 0 ? sState.account[0] : Base.States[Base.Total - 1].account[0]);
   float PrevEquity = (Base.Total <= 0 ? sState.account[1] : Base.States[Base.Total - 1].account[1]);
   Account.Add((sState.account[0] - PrevBalance) / PrevBalance);
   Account.Add(sState.account[1] / PrevBalance);
   Account.Add((sState.account[1] - PrevEquity) / PrevEquity);
   Account.Add(sState.account[2]);
   Account.Add(sState.account[3]);
   Account.Add(sState.account[4] / PrevBalance);
   Account.Add(sState.account[5] / PrevBalance);
   Account.Add(sState.account[6] / PrevBalance);

Ich möchte daran erinnern, dass wir bei der Beschreibung der Direct-Pass-Methode betont haben, dass die Verantwortung für die Relevanz der Daten des zusätzlichen Quelldatenpuffers im OpenCL-Kontextspeicher beim Nutzer liegt. Daher wird nach der Aktualisierung des Kontoinformationspuffers dessen Inhalt in den Kontextspeicher übertragen. Erst danach rufen wir die Direct-Pass-Methode unseres Agenten auf und übergeben die Zeiger auf die beiden Datenpuffer.

   if(Account.GetIndex()>=0)
      if(!Account.BufferWrite())
         return;
   if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
      return;

Der Block für das Sampling und die Durchführung von Agentenaktionen wurde unverändert aus ähnlichen EAs übernommen und wird hier nicht weiter beschrieben.

Am Ende der Beschreibung der Änderungen an der OnTick-Funktion des EA zum Sammeln von Beispielen ist es notwendig, ein paar Worte über die Belohnungsfunktion zu verlieren. Die Grundlage unserer Belohnungsfunktion ist nach wie vor der relative Wert der Veränderungen des Kontostands. Die GCRL-Methode bietet jedoch zusätzliche Belohnungen für das Erreichen lokaler Ziele. In unserem Fall werden wir Sanktionen verwenden. Bei der Schließung von Positionen wird jedes Mal der oben berechnete Indikator der gewichteten Summe der absoluten Werte der kumulierten Gewinne und Verluste abgezogen. Auf diese Weise werden wir das Halten von Positionen mit aufgelaufenen erheblichen Gewinnen oder Verlusten so weit wie möglich bestrafen. Dies sollte den Agenten dazu bewegen, Positionen zu schließen. Gleichzeitig werden Positionen mit geringen kumulierten Gewinnen nicht mit einer hohen Strafe belegt. Dadurch kann der Agent davon ausgehen, dass sich Gewinne ansammeln werden.

   float reward = Account[0];
   if((buy_value+sell_value)>0)
     reward+=(float)position_discount;
   else
     reward-=atr;
   if(!Base.Add(sState, act, reward))
      ExpertRemove();
//---
  }

Wenn es keine offenen Positionen gibt, werden wir den Agenten ermutigen, Handel zu treiben. In diesem Fall ist eine Strafe in Höhe des aktuellen Wertes des ATR-Indikators vorgesehen.

Ansonsten hat der Algorithmus des EA keine Änderungen erfahren. Den vollständigen Code finden Sie im Anhang.

Nach Abschluss der Arbeiten am EA zur Erfassung der Beispieldatenbank „GCRL\Research.mq5“ starten wir ihn im langsamen Optimierungsmodus des Strategietesters. Kommen wir nun zum EA für das Agenten-Training „GCRL\StudyActor.mq5“.

In dieser Arbeit werden wir den Agenten nur auf Aktionen und Belohnungen trainieren, die in der Beispieldatenbank gespeichert sind. Wir werden keine prädiktiven Belohnungen für andere Aktionen berechnen, wie wir es im vorherigen Artikel getan haben. Stattdessen werden wir uns darauf konzentrieren, dem Agenten beizubringen, je nach Aufgabe eine Strategie zu entwickeln. Wir werden uns die Tatsache zunutze machen, dass unsere Beispieldatenbank Durchläufe für einen historischen Zeitraum enthält. Aber aufgrund einer Reihe zufällig ausgewählter Aktionen in der Phase des Sammelns einer Datenbank von Beispielen erhalten wir in jedem Durchlauf für einen historischen Moment einen anderen Satz offener Positionen und kumulierter Gewinne/Verluste mit unterschiedlichen Aktionen des Agenten und anschließender Belohnung. Dies bedeutet, dass wir mehrere Vorwärts- und Rückwärtsdurchläufe des Modells von einem historischen Zeitpunkt an durchführen können, wobei verschiedene lokale Aufgaben für den Agenten festgelegt werden. Das gibt uns den Effekt, einen Moment mehrmals zu wiederholen und die Umgebung zu erkunden.

Wir werden keine Ressourcen und Zeit mit der Suche nach identischen historischen Zuständen verschwenden. Machen wir uns einfach die Stationarität der historischen Daten zunutze. Schließlich ist leicht festzustellen, dass alle unsere Testagenten von einem historischen Zeitpunkt aus gestartet sind und die gleiche Anzahl von Schritten (Kerzen) „durchlaufen“ haben. Eine Ausnahme kann der Fall sein, wenn der Test aufgrund einer Unterbrechung abgebrochen wird. Aber jeder N-Schritt in allen Durchläufen wird immer einem historischen Moment entsprechen. Darauf werden wir unsere Agentenschulung aufbauen.

Wie immer wird das Modelltraining in der Funktion Train des EA „GCRL\StudyActor.mq5“ durchgeführt. Zu Beginn der Funktion quantifizieren wir sie, indem wir unsere Beispieldatenbank durchlaufen. Dann organisieren wir die erste Schleife, in der wir den Durchgang mit der maximalen Anzahl von Schritten finden. Wir speichern nicht eine bestimmte Passage, sondern nur die Anzahl der Schritte. Wir werden sie verwenden, wenn wir einen bestimmten historischen Zeitpunkt für die Ausbildung auswählen.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   int total_steps = 0;
   for(int tr = 0; tr < total_tr; tr++)
     {
      if(Buffer[tr].Total > total_steps)
         total_steps = Buffer[tr].Total;
     }

Als Nächstes werden wir ein System aus 2 verschachtelten Schleifen einrichten. Die erste basiert auf der Anzahl der Iterationen beim Modelltraining. Im Hauptteil dieser Schleife wird ein historischer Moment für diese Trainingsiteration ausgewählt. In einer verschachtelten Schleife werden wir alle uns zur Verfügung stehenden Durchgänge durchlaufen und prüfen, ob sie einen abgetasteten Zustand aufweisen.

   uint ticks = GetTickCount();
//---
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total_steps - 2));
      for(int tr = 0; tr < total_tr; tr++)
        {
         if(i >= (Buffer[tr].Total - 1))
            continue;

Wenn diese Bedingung erfüllt ist, trainieren wir den Agenten anhand der gespeicherten Daten und fahren mit dem nächsten Durchgang fort.

         State.AssignArray(Buffer[tr].States[i].state);
         float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i].account[2]);
         Account.Add(Buffer[tr].States[i].account[3]);
         Account.Add(Buffer[tr].States[i].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i].account[6] / PrevBalance);
         //---
         if(Account.GetIndex()>=0)
            Account.BufferWrite();
         if(!Actor.feedForward(GetPointer(State), 1, false,GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            break;
           }
         //---
      ActorResult = vector<float>::Zeros(NActions);
      ActorResult[Buffer[tr].Actions[i]] = Buffer[tr].Revards[i];
      Result.AssignArray(ActorResult);
      if(!Actor.backProp(Result, 0, NULL, 1, false,GetPointer(Account),GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         break;
        }
         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Actor", 
                                       iter * 100.0 / (double)(Iterations),
                                       Actor.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

So wird jeder einzelne Zustand von unserem Agenten in Form der Anzahl der Durchläufe mit einer anderen Formulierung der lokalen Teilaufgabe wiedergegeben. Wir wollen dem Agenten also zeigen, dass er bei seinen Aktionen nicht nur den Zustand der Umgebung, sondern auch die lokale Teilaufgabe berücksichtigen sollte. Wie Sie sich vielleicht erinnern, haben wir bei der Zusammenstellung der Beispieldatenbank eine Strafe für das Nicht-Erledigen einer lokalen Aufgabe bei jedem Schritt hinzugefügt. In jedem Durchgang gibt es nun verschiedene Belohnungen für einen historischen Zeitpunkt, die den lokalen Teilaufgaben der Durchgänge entsprechen.

Der Rest des Expert Advisor-Codes blieb unverändert. Den vollständigen Code aller in diesem Artikel verwendeten Programme finden Sie im Anhang.


3. Test

Nach Abschluss der Arbeiten an den EAs gehen wir dazu über, das Modell zu trainieren und die erzielten Ergebnisse zu testen. Wir ändern die Trainingsparameter des Modells nicht. Wie zuvor wird das Modell mit historischen EURUSD-H1-Daten trainiert. Die Indikatorparameter werden standardmäßig verwendet. Unser Agent wurde auf 4 Monate des Jahres 2023 geschult. Wir überprüften die Qualität der Schulung und die Fähigkeit des Agenten, innerhalb des Zeitraums vom 1. bis 18. Juni 2023 mit neuen Daten zu arbeiten.

Die Testergebnisse sind in den folgenden Screenshots dargestellt. Wie Sie sehen, ist es uns gelungen, bei der Erprobung des Modells einen Gewinn zu erzielen. Das Saldendiagramm zeigt Wachstumsphasen und eine flache Seitwärtsbewegung. Ich bin froh, dass es keine Abstürze gibt. Im Allgemeinen lag der Gewinnfaktor über 12 Handelstage bei 2,2 und der Erholungsfaktor bei 1,47. Der EA hat 220 Trades gemacht. Mehr als 53 % von ihnen wurden mit Gewinn abgeschlossen. Außerdem ist die durchschnittliche gewinnbringende Position fast doppelt so hoch wie die durchschnittliche Verlustposition. Leider eröffnete der EA nur Kaufpositionen. Ein ähnlicher Effekt ist uns bereits begegnet. Der angewandte Ansatz hat dieses Problem nicht gelöst.

Testdiagramm

Testergebnisse

Haltezeit der Position

Zu den positiven Aspekten der GCRL-Methode gehört die Verkürzung der Zeit, die benötigt wird, um eine Position zu halten. Während des Tests betrug die maximale Haltezeit der Position 21 Stunden und 15 Minuten. Die durchschnittliche Verweildauer in einer Position beträgt 5 Stunden und 49 Minuten. Wie Sie sich vielleicht erinnern, haben wir eine Strafe in Höhe von 1/10 des kumulierten Gewinns für jede Stunde des Haltens festgelegt, wenn die Aufgabe, eine Position zu schließen, nicht erfüllt wird. Mit anderen Worten: Nach 10 Stunden des Haltens überstieg die Strafe die Einnahmen aus der Position.


Schlussfolgerung

In diesem Artikel wird die Methode des zielgerichteten Verstärkungslernens (GCRL) vorgestellt. Eine Besonderheit dieser Methode ist die Einführung von lokalen Teilaufgaben und Belohnungen für deren Erfüllung. Auf diese Weise können wir eine globale Aufgabe in mehrere kleinere Aufgaben aufteilen und uns Schritt für Schritt an sie herantasten.

Dieser Ansatz hat eine Reihe von Vorteilen. Es reduziert die Komplexität des Lernens, indem es eine Aufgabe in kleinere, besser handhabbare Komponenten zerlegt. Dadurch wird der Entscheidungsprozess vereinfacht und die Geschwindigkeit der Agentenschulung erhöht.

Darüber hinaus trägt GCRL zur Verbesserung der Generalisierungsfähigkeit des Agenten bei. Wenn der Agent lernt, verschiedene lokale Teilaufgaben zu lösen, entwickelt er eine Reihe von Fähigkeiten und Strategien, die er in verschiedenen Kontexten anwenden kann.

Schließlich bietet GCRL Flexibilität bei der Festlegung von Zielen und Vorgaben für den Agenten. Wir können lokale Teilaufgaben je nach Bedarf und Umgebungsbedingungen auswählen und ändern. Auf diese Weise kann sich der Agent an verschiedene Situationen anpassen und seine Fähigkeiten effektiv einsetzen, um seine Ziele zu erreichen.

Wir haben die vorgestellte Methode mit MQL5 implementiert. Wir haben das Modell auch mit Daten außerhalb des Trainingssatzes trainiert und die Trainingsergebnisse überprüft. Die Testergebnisse zeigten, dass es noch ungelöste Probleme gab. Insbesondere eröffnete der EA Positionen in nur einer Richtung. Dies hinderte sie jedoch nicht daran, während des Tests einen Gewinn zu erzielen.

Außerdem hat sich die Zeit, in der die Position gehalten wird, verringert. Dies bestätigt, dass der Agent an der Lösung von 2 lokalen Aufgaben arbeitet: Öffnen und Schließen einer Position.

Die Testergebnisse sind in der Regel positiv und ermöglichen es, die Methode für die Suche nach neuen Lösungen zu nutzen.


Liste der Referenzen

  • Variational Empowerment as Representation Learning for Goal-Based Reinforcement Learning
  • Neuronale Netze leicht gemacht (Teil 43): Beherrschung von Fähigkeiten ohne Belohnungsfunktion
  • Neuronale Netze leicht gemacht (Teil 44): Erlernen von Fähigkeiten mit Blick auf die Dynamik
  • Neuronale Netze leicht gemacht (Teil 45): Ausbildung von Fähigkeiten zur Erkundung von Zuständen

  • Programme, die im diesem Artikel verwendet werden

    # Name Typ Beschreibung
    1 Research.mq5 Expert Advisor Beispielsammlung EA
    StudyActor.mq5  Expert Advisor Agentenausbildung EA
    3 Test.mq5 Expert Advisor Test-EA des Modells
    4 Trajectory.mqh Klassenbibliothek Struktur der Systemzustandsbeschreibung
    5 FQF.mqh Klassenbibliothek Klassenbibliothek zur Organisation der Arbeit eines vollständig parametrisierten Modells
    6 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
    7 NeuroNet.cl Code Base OpenCL-Programmcode-Bibliothek
    8 VAE.mqh
    Klassenbibliothek
    Klassenbibliothek für latente Schichten des Variations-Autokodierers

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

    Beigefügte Dateien |
    MQL5.zip (615.73 KB)
    Neuronale Netze leicht gemacht (Teil 47): Kontinuierlicher Aktionsraum Neuronale Netze leicht gemacht (Teil 47): Kontinuierlicher Aktionsraum
    In diesem Artikel erweitern wir das Aufgabenspektrum unseres Agenten. Der Ausbildungsprozess wird einige Aspekte des Geld- und Risikomanagements umfassen, die ein wesentlicher Bestandteil jeder Handelsstrategie sind.
    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.
    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 44): Erlernen von Fertigkeiten mit Blick auf die Dynamik Neuronale Netze leicht gemacht (Teil 44): Erlernen von Fertigkeiten mit Blick auf die Dynamik
    Im vorangegangenen Artikel haben wir die DIAYN-Methode vorgestellt, die einen Algorithmus zum Erlernen einer Vielzahl von Fertigkeiten (skills) bietet. Die erworbenen Fertigkeiten können für verschiedene Aufgaben genutzt werden. Aber solche Fertigkeiten können ziemlich unberechenbar sein, was ihre Anwendung schwierig machen kann. In diesem Artikel wird ein Algorithmus zum Erlernen vorhersehbarer Fertigkeiten vorgestellt.