English Русский 中文 Español 日本語 Português
preview
Neuronale Netze leicht gemacht (Teil 54): Einsatz von Random Encoder für eine effiziente Forschung (RE3)

Neuronale Netze leicht gemacht (Teil 54): Einsatz von Random Encoder für eine effiziente Forschung (RE3)

MetaTrader 5Handelssysteme | 17 Januar 2024, 11:06
244 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Einführung

Die Frage der effizienten Erkundung der Umgebung ist eines der Hauptprobleme der Methoden des Reinforcement Learning (Verstärkungslernen). Wir haben diese Frage mehr als einmal diskutiert. Jedes Mal führte eine vorgeschlagene Lösung zu einer zusätzlichen Verkomplizierung des Algorithmus. In den meisten Fällen griffen wir auf zusätzliche interne Belohnungsmechanismen zurück, um das Modell zu ermutigen, neue Aktionen zu erkunden und nach unerforschten Wegen zu suchen.

Um jedoch die Neuartigkeit von Handlungen und besuchten Zuständen zu bewerten, mussten wir zusätzliche Modelle trainieren. Es ist wichtig, darauf hinzuweisen, dass das Konzept der „Neuartigkeit der Maßnahmen“ nicht immer mit der Vollständigkeit und Einheitlichkeit der Umweltforschung übereinstimmt. In dieser Hinsicht sind Methoden, die auf der Schätzung der Entropie von Aktionen und Zuständen basieren, am attraktivsten. Aber sie setzen den trainierten Modellen ihre eigenen Grenzen. Die Verwendung der Entropie erfordert ein gewisses Verständnis der Wahrscheinlichkeiten für die Durchführung von Handlungen und den Übergang zu neuen Zuständen, was im Falle eines kontinuierlichen Raums von Handlungen und Zuständen für eine direkte Berechnung recht schwierig sein kann. Auf der Suche nach einfacheren und effektiveren Methoden empfehle ich Ihnen, sich mit dem Algorithmus Random Encoders (Zufalls-Kodierer) for Efficient Exploration (RE3) vertraut zu machen, der in dem Artikel „State Entropy Maximization with Random Encoders for Efficient Exploration“ vorgestellt wird.


1. Grundidee von RE3

Bei der Analyse von realen Fällen mit einem kontinuierlichen Raum von Aktionen und Zuständen sind wir mit einer Situation konfrontiert, in der jedes Zustands-Aktions-Paar nur einmal in der Trainingsmenge vorkommt. Die Wahrscheinlichkeit, dass ein identischer Zustand in der Zukunft eintritt, liegt nahe bei „0“. Es ist notwendig, nach Methoden zu suchen, um nahe beieinander liegende (ähnliche) Zustände und Aktionen zu gruppieren, was zur Ausbildung zusätzlicher Modelle führt. Bei der Methode BAC haben wir zum Beispiel einen Auto-Encoder (Kodierer) trainiert, um die Neuartigkeit von Zuständen und Aktionen zu bewerten.

Allerdings wird der Algorithmus durch das Training zusätzlicher Modelle etwas komplexer. Schließlich sind sowohl für die Auswahl zusätzlicher Hyperparameter als auch für das Training des Modells zusätzliche Zeit und Ressourcen erforderlich. Die Qualität der Ausbildung eines zusätzlichen Modells kann einen erheblichen Einfluss auf die Ergebnisse der Ausbildung der Hauptakteurspolitik haben.

Das Hauptziel der Methode Random Encoders for Efficient Exploration (RE3) ist es, die Anzahl der trainierten Modelle zu minimieren. Die Autoren der RE3-Methode weisen in ihrer Arbeit darauf hin, dass im Bereich der Bildverarbeitung nur Faltungsnetze in der Lage sind, einzelne Objektmerkmale und -charakteristika zu erkennen. Faltungsnetzwerke helfen dabei, die Dimension des mehrdimensionalen Raums zu reduzieren, charakteristische Merkmale hervorzuheben und die Skalierung des Originalobjekts zu bewältigen.

Hier stellt sich die berechtigte Frage, von welcher Art von Minimierung der trainierten Modelle wir sprechen, wenn wir zusätzlich auf Faltungsnetze zurückgreifen.

In diesem Zusammenhang ist das Schlüsselwort „trainiert“. Die Autoren der Methode wiesen darauf hin, dass selbst ein Faltungscodierer, der mit zufälligen Parametern initialisiert wird, effektiv Informationen über die Nähe zweier Zustände erfasst. Unten finden Sie eine Visualisierung der k-nächsten Zustände, die durch Messung der Abstände im Repräsentationsraum des Zufallscodierers (Random Encoder) und im Raum des wahren Zustands (True State) aus der Arbeit des Autors gefunden wurden.

Visualisierung der k-nächsten Zustände

Auf der Grundlage dieser Beobachtung schlagen die Autoren der RE3-Methode vor, die Zustandsentropieschätzung in einem festen Repräsentationsraum eines zufällig initialisierten Encoders während des Modelltrainings zu maximieren.

Die Methode Random Encoders for Efficient Exploration (RE3) fördert die Exploration in hochdimensionalen Beobachtungsräumen durch Maximierung der Zustandsentropie. Die Hauptidee von RE3 ist die Schätzung der Entropie mit Hilfe der geschätzten nächsten Nachbarn in einem niedrigdimensionalen Raum, der mit einem zufällig initialisierten Kodierer erhalten wird.

Die Autoren schlagen vor, den Abstand zwischen den Zuständen im Darstellungsraum f(θ) eines Zufallscodierers zu berechnen, dessen Paramete θ nach dem Zufallsprinzip initialisiert und während des gesamten Trainings festgelegt werden.

Die Motivation des Agenten ergibt sich aus der Beobachtung, dass Distanzen in einem Repräsentationsraum für Zufallscodierer bereits nützlich sind, um ähnliche Zustände zu finden, ohne dass die Repräsentation trainiert werden muss.

In diesem Fall ist die interne Belohnung proportional zur Bewertung der Zustandsentropie und wird durch die Gleichung bestimmt:

wobei yi eine Zustandsdarstellung im Raum des Zufallscodierers ist.

In der vorgestellten internen Belohnungsgleichung verwenden wir die L2-Distanznorm, die immer nicht-negativ ist. Erhöht man die Norm um „1“, so erhält man immer einen nicht negativen Logarithmuswert. Auf diese Weise erhalten wir immer eine nicht-negative interne Belohnung. Außerdem lässt sich leicht feststellen, dass bei einer ausreichenden Anzahl von nahen Zuständen die interne Belohnung nahe bei „0“ liegt.

Wie die Praxis zeigt, bietet die Messung des Abstands zwischen den Zuständen in einem festen Repräsentationsraum stabilere interne Belohnungen, da sich der Abstand zwischen den Zustandspaaren während des Trainings nicht ändert.

Um Entfernungen im latenten Raum zu berechnen, ist es rechnerisch effizient, niedrigdimensionale Zustandsrepräsentationen in einem Erfahrungswiedergabepuffer zu speichern, während man mit der Umgebung interagiert. Dadurch entfällt die Notwendigkeit, hochdimensionale Zustände über den Encoder zu verarbeiten, um bei jeder Modellaktualisierung Darstellungen zu erhalten. Außerdem kann so der Abstand vor allen Zustandsaufzeichnungen und nicht vor einer einzelnen Probe aus einer Minibatch berechnet werden. Dieses Verfahren bietet eine stabile und genaue Entropieschätzung mit hoher Recheneffizienz.

Im Allgemeinen kann die RE3-Methode verwendet werden, um einen Agenten in Echtzeit zu trainieren, wobei der Agent eine Strategie lernt, die auf der Maximierung externer Belohnungen aus der Umgebung basiert. Interne Belohnungen regen den Agenten dazu an, die Umwelt zu erkunden.

wobei β ein Temperaturverhältnis ist, das das Gleichgewicht zwischen Forschung und Nutzung≥0) bestimmt.

Die Autoren der Methode schlagen vor, β während des gesamten Trainings exponentiell zu verringern, um den Agenten zu ermutigen, sich mit fortschreitendem Training mehr auf externe Belohnungen aus der Umgebung zu konzentrieren.

wobei p eine Abnahmerate ist.

Während die interne Belohnung gegen „0“ konvergiert, wenn mehr ähnliche Zustände während des Trainings gesammelt werden, fanden die Autoren der Methode heraus, dass die Verringerung von β die Leistung empirisch stabilisiert.

Darüber hinaus kann die RE3-Methode verwendet werden, um einen Agenten darauf vorzubereiten, einen hochdimensionalen Umgebungsraum zu erforschen, wenn es keine externen Belohnungen gibt. Anschließend kann die Strategie des Agenten weiter trainiert werden, um spezifische Probleme zu lösen.

Nachfolgend finden Sie die Visualisierung der RE3-Methode durch den Autor.

Autors Visualisierung der Methode

Der Artikel „State Entropy Maximization with Random Encoders for Efficient Exploration“ stellt die Ergebnisse verschiedener Tests vor, die die Effizienz der Methode belegen. Wir werden unsere Version des vorgeschlagenen Algorithmus implementieren und seine Effizienz für die Lösung unserer Aufgaben bewerten.


2. Implementierung mit MQL5

Während wir mit der Umsetzung dieser Methode beginnen, sollten wir gleich darauf hinweisen, dass wir den Algorithmus des Autors nicht vollständig kopieren werden. Wie immer werden wir die Hauptgedanken der Methode verwenden und sie mit bereits erwogenen Ansätzen kombinieren. Hier werden wir ein gewisses Konglomerat aus den aktuellen und zuvor untersuchten Algorithmen schaffen.

Wir werden unsere Implementierung auf der Grundlage von Algorithmen aus der Actor-Critic-Familie (Akteur und Kritiker) aufbauen. Um einen Faltungscodierer zu erstellen, fügen wir seine Beschreibung der Methode zur Beschreibung von Modellarchitekturen hinzu.

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

Wir werden eine stochastische Agentenpolitik in einem kontinuierlichen Handlungsraum trainieren. Wie in früheren Artikeln werden wir den Actor mit Algorithmen aus der Actor-Critic-Familie trainieren. Da wir die Ansätze der RE3-Methode verwenden werden, um die Entropiekomponente der Belohnung zu schätzen, können wir das Akteursmodell vereinfachen. In diesem Fall werden wir die Actor-Architektur aus dem Artikel „Behavior-Guided Actor-Critic“ nachbilden.

//--- Actor
   actor.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   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;
     }
//--- 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;
     }
//--- 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;
   prev_count = descr.count = 128;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = AccountDescr;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- 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;
     }
//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!actor.Add(descr))
     {
      delete descr;
      return false;
     }

Wie bisher wird unser Critic nicht über den Block der Vorverarbeitung von Quelldaten verfügen. Wir werden den latenten Zustand unseres Akteurs als Input für den Kritiker verwenden. Wir werden auch die die Aufteilung der Belohnung (reward dekomposition) verwenden, wobei wir die Anzahl der Belohnungspunkte leicht reduzieren. Anstelle von 6 separaten Elementen der Entropiekomponenten für jede Aktion wird es nur ein Element der internen Belohnung geben.

//+------------------------------------------------------------------+
//| Rewards structure                                                |
//|   0     -  Delta Balance                                         |
//|   1     -  Delta Equity ( "-" Drawdown / "+" Profit)             |
//|   2     -  Penalty for no open positions                         |
//|   3     -  Mean distance                                         |
//+------------------------------------------------------------------+

Daraus ergibt sich die folgende kritische Architektur.

//--- Critic
   critic.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = LatentCount;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   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 = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = NRewards;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!critic.Add(descr))
     {
      delete descr;
      return false;
     }

Als Nächstes müssen wir die Architektur des Faltungscodierers (convolutional encoder) beschreiben. Hier liegt der erste Unterschied zu der beschriebenen Methode. Die RE3-Methode bietet eine interne Belohnung, die auf einer Schätzung des Abstands zwischen latenten Zustandsdarstellungen beruht. Im Gegensatz dazu werden wir eine latente Darstellung der Paare „Zustand-Aktion“ verwenden, die sich in der Größe der Encoder-Quelldatenschicht widerspiegelt.

//--- Convolution
   convolution.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars * BarDescr) + AccountDescr + NActions;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }

Unser Encoder-Modell ist nicht trainiert, und es macht keinen Sinn, eine Schicht zur Normalisierung von Batch-Daten zu verwenden. Wir werden jedoch eine voll verknüpfte Schicht verwenden, an deren Ausgang wir vergleichbare Daten erhalten, die von Faltungsschichten verarbeitet werden können.

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 512;
   descr.window = prev_count;
   descr.step = NActions;
   descr.optimization = ADAM;
   descr.activation = SIGMOID;
   if(!convolution.Add(descr))
     {по
      delete descr;
      return false;
     }

Anschließend wird die Dimensionalität der Daten durch 3 aufeinander folgende Faltungsschichten reduziert. Ihre Aufgabe ist es, charakteristische Merkmale zur Erkennung ähnlicher Zustände und Handlungen zu bestimmen.

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

Um den Kodierer zu vervollständigen, verwenden wir eine vollständig verknüpfte Schicht und reduzieren die versteckte Darstellung der Daten auf eine bestimmte Dimension.

//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!convolution.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

Bitte beachten Sie, dass wir LReLU in allen neuronalen Schichten (außer der ersten) verwendet haben, um Neuronen zu aktivieren. Da der Ergebnisbereich der Aktivierungsfunktion nicht begrenzt ist, können die Objekte so genau wie möglich in Gruppen eingeteilt werden.

Nachdem wir die Architektur unserer Modelle beschrieben haben, wollen wir nun ein wenig über den Erfahrungswiedergabepuffer sprechen. Die Autoren der Methode schlagen vor, gleichzeitig mit dem Standarddatensatz eine latente Repräsentation des Zustands im Erfahrungswiedergabepuffer zu speichern. Dem stimme ich zu. Es ist durchaus logisch, die latente Repräsentation des Zustands einmal zu berechnen und sie anschließend im Trainingsprozess zu verwenden, ohne dass sie bei jeder Iteration neu berechnet werden muss.

Wenn wir in unserer Abfolge von Aktionen zum ersten Mal die Trainingsdatensammlung EA „...\RE3\Research.mq5“ starten, sind noch keine vortrainierten Modelle gespeichert. Das Akteursmodell wird vom EA erstellt und mit Zufallsparametern gefüllt. Wir können auch ein zufälliges Kodierermodell erstellen. Durch den parallelen Start mehrerer EA-Instanzen im Optimierungsmodus des Strategietesters wird jedoch für jeden EA-Durchlauf ein Encoder erzeugt. Das Problem ist, dass wir in jedem Durchgang einen zufälligen Kodierer erhalten, dessen latente Repräsentation nicht mit ähnlichen Repräsentationen in anderen Durchgängen vergleichbar sein wird. Dies verstößt völlig gegen die Ideen und Grundsätze der RE3-Methode.

Ich sehe zwei mögliche Lösungen:

  • vorläufige Erstellung und Speicherung von Modellen vor dem ersten Start des EA „...\RE3\Research.mq5“
  • Erzeugen eines Kodierers und von Kodierdarstellungen im Hauptteil der Modell-Trainings-EA „...\RE3\Study.mq5“.

Ich habe mich bei meiner Umsetzung für die zweite Option entschieden. Daher werden wir keine Änderungen an den Datenspeicherstrukturen und der dem EA für die Trainingsmustersammlung „...\RE3\Research.mq5“ vornehmen. Den vollständigen Code finden Sie im Anhang.

Als Nächstes arbeiten wir an der Modelltraining-EA „...\RE3\Study.mq5“. Hier erstellen wir Objekte für 6 Modelle, während wir nur 3 von ihnen trainieren werden. Bei Zielmodellen wenden wir eine sanfte Aktualisierung der Parameter unter Verwendung des ꚍ-Verhältnisses an.

CNet                 Actor;
CNet                 Critic1;
CNet                 Critic2;
CNet                 TargetCritic1;
CNet                 TargetCritic2;
CNet                 Convolution;

Bei der EA-Initialisierungsmethode laden wir die Trainingsmenge und die vortrainierten Modelle. Wenn die Modelle nicht geladen werden können, werden neue Modelle mit zufälligen Parametern erstellt.

int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }
//--- load models
   float temp;
   if(!Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !Critic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true) ||
      !Convolution.Load(FileName + "CNN.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic1.Load(FileName + "Crt1.nnw", temp, temp, temp, dtStudied, true) ||
      !TargetCritic2.Load(FileName + "Crt2.nnw", temp, temp, temp, dtStudied, true))
     {
      CArrayObj *actor = new CArrayObj();
      CArrayObj *critic = new CArrayObj();
      CArrayObj *convolution = new CArrayObj();
      if(!CreateDescriptions(actor, critic, convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!Actor.Create(actor) || !Critic1.Create(critic) || !Critic2.Create(critic) ||
         !Convolution.Create(convolution))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      if(!TargetCritic1.Create(critic) || !TargetCritic2.Create(critic))
        {
         delete actor;
         delete critic;
         delete convolution;
         return INIT_FAILED;
        }
      delete actor;
      delete critic;
      delete convolution;
      //---
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), 1.0f);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), 1.0f);
      StartTargetIter = StartTargetIteration;
     }
   else
      StartTargetIter = 0;

Wie im vorigen Artikel verlängern wir bei der Erstellung neuer Modelle die Zeit für die Verwendung der Zielmodelle. Auf diese Weise können wir die Zielmodelle vorab trainieren, bevor wir sie zur Schätzung der nachfolgenden Agentenzustände und -aktionen verwenden.

Hier übertragen wir alle Modelle in einen einzigen OpenCL-Kontext.

//---
   OpenCL = Actor.GetOpenCL();
   Critic1.SetOpenCL(OpenCL);
   Critic2.SetOpenCL(OpenCL);
   TargetCritic1.SetOpenCL(OpenCL);
   TargetCritic2.SetOpenCL(OpenCL);
   Convolution.SetOpenCL(OpenCL);

Vor dem Training überprüfen wir die Übereinstimmung der Architekturen der verwendeten Modelle.

   Actor.getResults(Result);
   if(Result.Total() != NActions)
     {
      PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total());
      return INIT_FAILED;
     }
//---
   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();
   Critic1.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;
     }

Am Ende der Methode wird ein Hilfspuffer angelegt und ein Ereignis des Modelltrainings erzeugt.

   Gradient.BufferInit(AccountDescr, 0);
//---
   if(!EventChartCustom(ChartID(), 1, 0, 0, "Init"))
     {
      PrintFormat("Error of create study event: %d", GetLastError());
      return INIT_FAILED;
     }
//---
   return(INIT_SUCCEEDED);
  }

Bei der EA-Deinitialisierungsmethode aktualisieren wir die Parameter der Zielmodelle und speichern die Trainingsergebnisse.

void OnDeinit(const int reason)
  {
//---
   TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
   TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
   Actor.Save(FileName + "Act.nnw", 0, 0, 0, TimeCurrent(), true);
   TargetCritic1.Save(FileName + "Crt1.nnw", Critic1.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   TargetCritic2.Save(FileName + "Crt2.nnw", Critic2.getRecentAverageError(), 0, 0, TimeCurrent(), true);
   Convolution.Save(FileName + "CNN.nnw", 0, 0, 0, TimeCurrent(), true);
   delete Result;
  }

Der eigentliche Prozess des Modelltrainings ist in der Prozedur Train organisiert. Der Algorithmus unterscheidet sich jedoch geringfügig von ähnlichen Verfahren der zuvor betrachteten EAs.

Zunächst zählen wir die Gesamtzahl der Zustände in der Trainingsmenge. Wie Sie wissen, wird die Anzahl der Zustände in jedem einzelnen Durchgang in der Variablen Total gespeichert. Wir werden eine Schleife einrichten und die Gesamtsumme der Werte der angegebenen Variablen aus jedem Durchgang sammeln.

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();
//---
   int total_states = Buffer[0].Total;
   for(int i = 1; i < total_tr; i++)
      total_states += Buffer[i].Total;

Wir benötigen den resultierenden Wert, um Matrizen aus einer komprimierten Darstellung von Zustands-Aktions-Paaren und den gesammelten tatsächlichen Belohnungen aus der Umwelt zu deklarieren.

   vector<float> temp;
   Convolution.getResults(temp);
   matrix<float> state_embedding = matrix<float>::Zeros(total_states,temp.Size());
   matrix<float> rewards = matrix<float>::Zeros(total_states,NRewards);

Als Nächstes werden wir ein Schleifensystem einrichten, in dem wir latente Repräsentationen für alle Zustands-Aktions-Paare aus der Trainingsmenge erstellen. Hier sammeln wir zunächst die Originaldaten in einem einzigen Datenpuffer.

   for(int tr = 0; tr < total_tr; tr++)
     {
      for(int st = 0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);
         float PrevBalance = Buffer[tr].States[MathMax(st,0)].account[0];
         float PrevEquity = Buffer[tr].States[MathMax(st,0)].account[1];
         State.Add((Buffer[tr].States[st].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[2]);
         State.Add(Buffer[tr].States[st].account[3]);
         State.Add(Buffer[tr].States[st].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[st].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[st].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         State.AddArray(Buffer[tr].States[st].action);

Dann rufen wir den Vorwärtsdurchlauf des Faltungsencoders auf.

         if(!Convolution.feedForward(GetPointer(State),1,false,NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            ExpertRemove();
            return;
           }

Das erhaltene Ergebnis wird in der entsprechenden Zeile der Zustands- und Aktionseinbettungsmatrix gespeichert. Wir speichern die entsprechende externe Belohnung in der Belohnungsmatrix mit der gleichen Stringnummer. Danach erhöhen wir den Zähler der aufgezeichneten Zeilen.

         Convolution.getResults(temp);
         state_embedding.Row(temp,state);
         temp.Assign(Buffer[tr].States[st].rewards);
         rewards.Row(temp,state);
         state++;

Der Zeitaufwand für diesen Prozess hängt von der Größe der Trainingsstichprobe ab und kann erheblich sein. Daher fügen wir in den Schleifenkörper die Informationsmeldung ein, um den Prozess visuell zu kontrollieren.

         if(GetTickCount() - ticks > 500)
           {
            string str = StringFormat("%-15s %6.2f%%", "Embedding ", state * 100.0 / (double)(total_states));
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

Sobald die Iterationen des Schleifensystems abgeschlossen sind, passen wir die Matrixgrößen an die Anzahl der gespeicherten Zeilen an.

   if(state != total_states)
     {
      rewards.Resize(state,NRewards);
      state_embedding.Reshape(state,state_embedding.Cols());
      total_states = state;
     }

Damit ist die Vorbereitungsphase abgeschlossen. Es ist nun an der Zeit, direkt mit dem Training der Modelle zu beginnen. Auch hier wird ein Trainingszyklus mit der vom Nutzer in den externen EA-Parametern angegebenen Anzahl von Iterationen durchgeführt.

   vector<float> rewards1, rewards2;
   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2));
      if(i < 0)
        {
         iter--;
         continue;
        }

Im Schleifenkörper werden nach dem Zufallsprinzip ein Durchgang und ein Zustand für die aktuelle Iteration des Modelltrainings ausgewählt. Dann prüfen wir, ob die Zielmodelle verwendet werden müssen.

Wenn unser Trainingsprozess den Schwellenwert für die Verwendung von Zielmodellen erreicht hat, erzeugen wir Post-State-Eingaben für einen Vorwärtsdurchlauf durch diese Modelle.

      vector<float> reward, target_reward = vector<float>::Zeros(NRewards);
      reward.Assign(Buffer[tr].States[i].rewards);
      //--- Target
      if(iter >= StartTargetIter)
        {
         State.AssignArray(Buffer[tr].States[i + 1].state);
         float PrevBalance = Buffer[tr].States[i].account[0];
         float PrevEquity = Buffer[tr].States[i].account[1];
         Account.Clear();
         Account.Add((Buffer[tr].States[i + 1].account[0] - PrevBalance) / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[1] / PrevBalance);
         Account.Add((Buffer[tr].States[i + 1].account[1] - PrevEquity) / PrevEquity);
         Account.Add(Buffer[tr].States[i + 1].account[2]);
         Account.Add(Buffer[tr].States[i + 1].account[3]);
         Account.Add(Buffer[tr].States[i + 1].account[4] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[5] / PrevBalance);
         Account.Add(Buffer[tr].States[i + 1].account[6] / PrevBalance);
         double x = (double)Buffer[tr].States[i + 1].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_W1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         x = (double)Buffer[tr].States[i + 1].account[7] / (double)PeriodSeconds(PERIOD_D1);
         Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
         //---
         if(Account.GetIndex() >= 0)
            Account.BufferWrite();

Wir erinnern uns daran, dass wir eine Beschreibung des Ausgangszustands der Umgebung und der Aktionen des Agenten benötigen, um die Zielmodelle der Kritiker direkt passieren zu können. Hier gibt es zwei Punkte, für die wir einen direkten Durchgang des Akteurs benötigen:

  • Kritiker verfügen nicht über eine Vorverarbeitungseinheit für die Quelldaten (sie verwenden keine latente Darstellung des Akteurs);
  • Das Zielmodell des Kritikers bewertet den nachfolgenden Zustand unter Berücksichtigung der aktuellen Politik des Akteurs (die Generierung eines neuen Vektors von Aktionen ist notwendig).

Daher führen wir zunächst einen Vorwärtsdurchgang des Akteurs durch.

         if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Dann rufen wir die Methoden der direkten Passage von zwei Modellen der Zielkritiker.

         if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
            !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Als Nächstes müssen wir in Übereinstimmung mit dem Soft-Actor-Critic-Algorithmus ein Zielmodell mit einer minimalen Schätzung des späteren Zustands auswählen. In meiner Implementierung habe ich eine einfache Summierung von Belohnungselementen verwendet. Wenn Ihr Modell jedoch unterschiedliche Gewichtungsverhältnisse für einzelne Elemente der Belohnungsfunktion vorsieht, können Sie das Vektorprodukt aus den Modellergebnissen und dem Vektor der Gewichtungsverhältnisse verwenden.

         TargetCritic1.getResults(rewards1);
         TargetCritic2.getResults(rewards2);
         if(rewards1.Sum() <= rewards2.Sum())
            target_reward = rewards1;
         else
            target_reward = rewards2;

Dann ziehen wir die tatsächliche Belohnung, die wir durch die Interaktion mit der Umwelt erhalten haben, von den vorhergesagten Ergebnissen des gewählten Modells ab und passen den Diskontierungsfaktor an.

         for(ulong r = 0; r < target_reward.Size(); r++)
            target_reward -= Buffer[tr].States[i + 1].rewards[r];
         target_reward *= DiscFactor;
        }

Auf diese Weise erhielten wir im Vektor target_reward für jedes Belohnungselement die Varianz zwischen dem vom Kritiker vorhergesagten Ergebnis und der tatsächlichen Belohnung aus der Umgebung. Wie kann dies hilfreich sein?

Wie Sie sich vielleicht erinnern, speichert der Erfahrungswiederholungspuffer für jedes Paar „Zustands-Aktions“ den kumulativen Betrag der Belohnung bis zum Ende der Passage unter Berücksichtigung des Diskontierungsfaktors. Diese Gesamtbelohnung wird auf der Grundlage der vom Agenten bei der Interaktion mit der Umgebung angewandten Strategien kumuliert.

Wir haben die Kosten des obigen Zustands-Aktions-Paares unter Berücksichtigung der aktuellen Politik des Agenten vorhergesagt und die Schätzung desselben Zustands unter Berücksichtigung der Aktion aus dem Erfahrungswiedergabepuffer abgezogen. Der Vektor target_reward enthält nun die Auswirkung der Politikänderung des Akteurs auf den Zustandswert.

Beachten Sie, dass es sich um eine Änderung des Zustandswertes handelt. Schließlich hängt es praktisch nicht vom Agenten ab. Allerdings können die Maßnahmen in ein und demselben Staat je nach der verwendeten Politik unterschiedlich sein.

Nachdem wir die Auswirkungen einer Änderung der Aktionspolitik des Akteurs auf das Gesamtergebnis bewertet haben, gehen wir zum Trainingsblock der Kritiker über. Es ist die Qualität ihrer Ausbildung, die sich auf die Korrektheit der Weitergabe des Fehlergradienten an die Aktionen des Akteurs auswirkt.

Hier bereiten wir auch Umweltbeschreibungsdaten vor, die historische Daten zu Preisbewegungen und Indikatoren enthalten. Wir bereiten die Kontostandsdaten auch als separaten Puffer auf.

      //--- Q-function study
      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);
      double x = (double)Buffer[tr].States[i].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_MN1);
      Account.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_W1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      x = (double)Buffer[tr].States[i].account[7] / (double)PeriodSeconds(PERIOD_D1);
      Account.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0));
      if(Account.GetIndex() >= 0)
         Account.BufferWrite();

Sobald die Datenaufbereitung abgeschlossen ist, führen wir einen Vorwärtsduchlauf des Akteurs durch.

      if(!Actor.feedForward(GetPointer(State), 1, false, GetPointer(Account)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Diesmal nehmen wir jedoch nur eine latente Repräsentation des Umweltzustands des Akteurs. Wir verwenden die Aktion des Agenten aus dem Erfahrungswiedergabepuffer. Schließlich werden wir genau für diese Handlung von der Umwelt belohnt.

Und mit diesen Daten führen wir einen direkten Durchlauf der beiden Kritiker durch.

      Actions.AssignArray(Buffer[tr].States[i].action);
      if(Actions.GetIndex() >= 0)
         Actions.BufferWrite();
      //---
      if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
         !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Als Nächstes müssen wir Zielwerte generieren und einen Rückwärtsdurchlauf der Kritiker durchführen. Wir haben ähnliche Operationen bereits mehrfach durchgeführt. Normalerweise würden wir in dieser Phase die tatsächliche Belohnung aus dem Erfahrungswiederholungspuffer an die Auswirkungen der geänderten Politik anpassen und den resultierenden Wert als Zielwert an beide Kritiker-Modelle weitergeben. In dieser Implementierung verwenden wir jedoch aufgeteilte Belohnungen. Im vorigen Artikel haben wir den Algorithmus Conflict-Averse Gradient Descent (CAGrad) verwendet, um den Fehlergradienten zu korrigieren. Wir korrigierten die Abweichung der Werte in der Methode CNet_SAC_D_DICE::CAGrad und speicherten die erhaltenen Werte direkt in den Fehlergradientenpuffer der neuronalen Ergebnisschicht. Derzeit haben wir keine Möglichkeit, direkt auf den Gradientenpuffer der letzten neuronalen Schicht der Modelle zuzugreifen, und wir benötigen Zielwerte.

Führen wir eine kleine Datenmanipulation durch, um Zielwerte zu erhalten, die mit der Methode des konfliktarmen Gradientenabstiegs korrigiert wurden. Zunächst generieren wir Zielwerte aus den verfügbaren Daten. Dann subtrahieren wir die vom Kritiker vorhergesagten Werte von ihnen und erhalten so eine Abweichung (Fehler). Korrigieren wir die resultierende Abweichung mit der bereits bekannten CAGrad-Methode. Addieren Sie den vorhergesagten Wert des Kritikers, den wir zuvor abgezogen haben, zu dem Ergebnis.

So erhalten wir einen Zielwert, der mit der Methode des konfliktfreien Gradientenabstiegs angepasst wird. Ein solcher Zielwert ist jedoch nur für ein einziges Kritiker-Modell relevant. Für das zweite Modell des Kritikers müssen wir die Operationen unter Berücksichtigung der vorhergesagten Werte wiederholen.

Nach der Durchführung des Critics-Backpasses führen wir einen partiellen Rückwärtsdurchlauf des Akteurs durch, um den Fehlergradienten über den Datenvorverarbeitungsblock zu verteilen.

      Critic1.getResults(rewards1);
      Result.AssignArray(CAGrad(reward + target_reward - rewards1) + rewards1);
      if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      Critic2.getResults(rewards2);
      Result.AssignArray(CAGrad(reward + target_reward - rewards2) + rewards2);
      if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Auf die Aktualisierung der Parameter für die Kritiker folgt der Block zur Aktualisierung der Akteurspolitik. In Übereinstimmung mit dem Algorithmus Soft Actor-Critic wird ein Kritiker mit einer minimalen Zustandsschätzung verwendet, um die Parameter des Akteurs zu aktualisieren. Wir werden einen Kritiker mit dem geringsten durchschnittlichen Fehler verwenden, der möglicherweise eine korrektere Übertragung des Fehlergradienten ermöglicht.

      //--- Policy study
      CNet *critic = NULL;
      if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
         critic = GetPointer(Critic1);
      else
         critic = GetPointer(Critic2);

Hier führen wir die RE3-Methode in unseren Ausbildungsprozess ein. Wir sammeln in einem einzigen Datenpuffer Beschreibungen des analysierten Zustands der Umgebung, des Zustands des Kontos und der gewählten Aktion des Agenten, unter Berücksichtigung der aktualisierten Politik. Ich möchte Sie daran erinnern, dass wir bei der Aktualisierung der Parameter der Kritiker einen direkten Durchgang des Akteurs durchgeführt haben.

Führen Sie anschließend einen direkten Durchlauf des ausgewählten Kritikers durch. Dieses Mal bewerten wir die Aktionen des Akteurs im analysierten Zustand unter Berücksichtigung der aktualisierten Politik. Rufen Sie einen direkten Durchlauf unseres Encoders auf, um eine Einbettung des Paares aus dem analysierten Zustand und der Aktion des Akteurs mit der aktualisierten Politik zu erhalten.

      Actor.getResults(rewards1);
      State.AddArray(GetPointer(Account));
      State.AddArray(rewards1);
      if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
         !Convolution.feedForward(GetPointer(State)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }

Auf den Vorwärtsdurchgang folgt ein Rückwärtsdurchgang der Modelle. Wir müssen also wieder die Zielwerte des Kritikers bilden. Aber dieses Mal müssen wir die Algorithmen CAGrad und RE3 kombinieren. Außerdem haben wir nicht die richtigen Zielwerte für den analysierten Zustand und die Aktion des Akteurs mit der aktualisierten Politik.

Wir haben die Definition des Zielwertes mit RE3-Ansätzen in eine separate KNNReward-Funktion ausgelagert. Wir werden uns den Algorithmus etwas später ansehen. Die Anpassung der aufgeteilten Belohnung erfolgt nach dem Algorithmus, der im Block „Aktualisierung der Parameter der Kritiker“ beschrieben ist.

      Convolution.getResults(rewards1);
      critic.getResults(reward);
      reward += CAGrad(KNNReward(7,rewards1,state_embedding,rewards) - reward);
      //---
      Result.AssignArray(reward + target_reward);

Als Nächstes müssen wir nur den Trainingsmodus des Critic deaktivieren und nacheinander die Rückpassmethoden für den Critic und den Actor aufrufen. Wir sollten auch nicht vergessen, die Ergebnisse der Operationen zu überprüfen.

      critic.TrainMode(false);
      if(!critic.backProp(Result, GetPointer(Actor)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         critic.TrainMode(true);
         break;
        }
      critic.TrainMode(true);

Nach der Aktualisierung der Akteurspolitik schalten wir den Kritiker wieder in den Trainingsmodus für das Modell.

Am Ende des Modelltrainingszyklus aktualisieren wir die Parameter der Zielmodelle und informieren den Nutzer über den Trainingsfortschritt.

      //--- Update Target Nets
      TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
      TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      //---
      if(GetTickCount() - ticks > 500)
        {
         string str = StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic1", 
                                     iter * 100.0 / (double)(Iterations), Critic1.getRecentAverageError());
         str += StringFormat("%-15s %5.2f%% -> Error %15.8f\n", "Critic2", 
                                     iter * 100.0 / (double)(Iterations), Critic2.getRecentAverageError());
         Comment(str);
         ticks = GetTickCount();
        }
     }

Nach Abschluss aller Iterationen des Modelltrainings löschen wir das Kommentarfeld, zeigen die Trainingsergebnisse im Protokoll an und leiten die Beendigung des EA ein.

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError());
   ExpertRemove();
//---
  }

Wir haben bereits den Algorithmus der Trainingsmethode Train des Modells besprochen. Um den Prozess vollständig zu verstehen, ist es notwendig, den Algorithmus der Funktionen CAGrad und KNNReward zu analysieren. Der Algorithmus der ersten Methode wurde vollständig von der gleichnamigen Methode übernommen, die im vorherigen Artikel besprochen wurde. Sie finden sie in der Anlage. Ich schlage vor, sich auf den zweiten Funktionsalgorithmus zu konzentrieren. Außerdem unterscheidet er sich von dem oben beschriebenen ursprünglichen Algorithmus.

In ihren Parametern erhält die Funktion KNNReward die Anzahl der zu analysierenden Nachbarn, den Einbettungsvektor des gewünschten Zustands, die Zustandseinbettungsmatrix aus der Trainingsmenge und die Belohnungsmatrix. Ich möchte Sie daran erinnern, dass die Zustandseinbettungsmatrizen aus dem Erfahrungs- und Belohnungswiedergabepuffer zeilenweise synchronisiert werden. Wir werden diesen wichtigen Punkt später ausnutzen.

Das Ergebnis der Funktionsoperationen wird als ein Vektor von Werten der entsprechenden Belohnungen zurückgegeben.

vector<float> KNNReward(ulong k, vector<float> &embedding, matrix<float> &state_embedding, matrix<float> &rewards)
  {
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      return vector<float>::Zeros(0);
     }

Im Hauptteil der Funktion wird zunächst die Größe der Einbettung des analysierten Zustands und der erstellten Einbettungen des Erfahrungswiedergabepuffers überprüft.

Als Nächstes bestimmen wir den Abstand zwischen den Einbettungsvektoren. Dazu subtrahieren wir den Wert des entsprechenden Elements der Beschreibung des analysierten Zustands von jeder Einbettungsspalte der Zustände des Erlebniswiedergabepuffers. Dann quadrieren wir die resultierenden Werte.

   ulong size = embedding.Size();
   ulong states = state_embedding.Rows();
   ulong rew_size = rewards.Cols();
   matrix<float> temp = matrix<float>::Zeros(states,size);
//---
   for(ulong i = 0; i < size; i++)
      temp.Col(MathPow(state_embedding.Col(i) - embedding[i],2.0f),i);

Wir extrahieren die Quadratwurzel aus der zeilenweisen Summe und setzen den resultierenden Vektor in die erste Spalte der Matrix.

   temp.Col(MathSqrt(temp.Sum(1)),0);

Wir haben also die Abstände zwischen dem gewünschten Zustand und den Beispielen aus dem Erfahrungswiedergabepuffer in der ersten Spalte unserer Matrix erhalten.

Ändern wir die Dimension unserer Matrix und fügen wir die entsprechenden Reward-Elemente aus dem Erfahrungswiedergabepuffer zu den angrenzenden Spalten hinzu.

   temp.Resize(states,1 + rew_size);
   for(ulong i = 0; i < rew_size; i++)
      temp.Col(rewards.Col(i),i + 1);

Als Ergebnis dieser Operationen erhalten wir eine Belohnungsmatrix, deren erste Spalte den Abstand zum gewünschten Zustand im komprimierten Einbettungsraum enthält.

Wie Sie sich vielleicht erinnern, ist in diesem Fall der gewünschte Zustand der analysierte Zustand mit der Aktion des Akteurs gemäß der aktualisierten Richtlinie.

Um nun die interne Belohnung für eine bestimmte Aktion des Akteurs zu bestimmen, müssen wir die k-nächsten Nachbarn ermitteln. Es ist ganz logisch, dass wir sie leicht finden können, wenn wir die resultierende Matrix in absteigender Reihenfolge der Entfernungen sortieren. Um die Werte vollständig zu sortieren, sind jedoch mehrere aufeinanderfolgende Durchläufe über den gesamten Abstandsvektor erforderlich. Gleichzeitig ist es nicht notwendig, die Matrix vollständig zu sortieren. Unsere Aufgabe ist es, nur k Mindestwerte zu finden. Ihre Reihenfolge in der kleinen Matrix der Ergebnisse ist für uns nicht so wichtig. Wir brauchen also nur einen Durchgang entlang des Entfernungsvektors.

Wir kopieren nur die ersten k Zeilen in unsere Ergebnismatrix. Wir bestimmen den maximalen Abstand und die Position des Elements mit dem maximalen Abstand in der kleinen Matrix. Als Nächstes führen wir einen Suchzyklus durch die verbleibenden Zeilen der ursprünglichen Matrix durch. Im Hauptteil der Schleife überprüfen wir nacheinander den Abstand zum analysierten Zustand und den maximalen Abstand in unserer Ergebnismatrix. Wenn wir einen näheren Zustand finden, speichern wir ihn in der Zeile mit dem maximalen Abstand in unserer Ergebnismatrix. Dann aktualisieren wir den Wert des maximalen Abstands und seine Position in der Matrix der minimalen Abstände.

   matrix<float> min_dist = temp;
   min_dist.Resize(k,rew_size + 1);
   float max = min_dist.Col(0).Max();
   ulong max_row = min_dist.Col(0).ArgMax();
   for(ulong i = k; i < states; i++)
     {
      if(temp[i,0] >= max)
         continue;
      min_dist.Row(temp.Row(i),max_row);
      max = min_dist.Col(0).Max();
      max_row = min_dist.Col(0).ArgMax();
     }

Wir wiederholen die Iterationen, bis alle Zeilen unserer Matrix von Entfernungen und Belohnungen vollständig aufgezählt sind. Nach einer vollständigen Suche in der Minimalentfernungsmatrix min_dist erhalten wir k Minimalentfernungen(k nächste Nachbarn) mit entsprechenden Belohnungen aus dem Erfahrungswiedergabepuffer. Sie sind zwar nicht sortiert, aber das brauchen wir nicht, um die interne Belohnung zu berechnen. 

   vector<float> t = vector<float>::Ones(k);
   vector<float> ri = MathLog(min_dist.Col(0) + 1.0f);

In diesem Stadium verfügen wir über alle Daten, um die interne Belohnung (Entropie) der zu analysierenden Handlung zu bestimmen. Aber wir haben immer noch eine offene Frage über den Zielwert der Belohnung für den analysierten Zustand und die Aktion. Hier lohnt es sich, noch einmal auf die erhaltenen k nächsten Nachbarn zu achten. Schließlich haben wir sie mit den entsprechenden Belohnungen versehen. Unser gesamter Prozess der Ausbildung von Modellen basiert auf Statistiken über Zustände, Aktionen und erhaltene Belohnungen. Daher sind die k-ächsten Nachbarn unsere repräsentative Stichprobe, und die Relevanz ihrer Belohnungen für die gewünschte Aktion ist direkt proportional zum Einbettungsabstand.

Daher definieren wir die Zielbelohnung als den entfernungsgewichteten Durchschnitt der Belohnungen der k-nächsten Nachbarn.

   t = (t - ri) / k;
//---
   vector<float> result = vector<float>::Zeros(rew_size);
   for(ulong i = 0; i < rew_size - 1; i++)
      result[i] = (t * min_dist.Col(i + 1)).Sum();

Im Bereich der Entropiekomponente der Belohnungsfunktion schreiben wir den Durchschnittswert als Logarithmus der Entfernungen nach der RE3-Methode.

   result[rew_size - 1] = ri.Mean();
//---
   return (result);
  }

Wir haben den Vektor der dekomponierten Belohnungszielwerte vollständig definiert und geben den Ergebnisvektor an das aufrufende Programm zurück.

Damit ist unser Überblick über die Methoden und Funktionen des EA-Modells „...\RE3\Study.mq5“ abgeschlossen. Den vollständigen Code dieses EA und alle im Artikel verwendeten Programme finden Sie im Anhang.


3. Test

Die oben vorgestellte Implementierung kann wohl kaum als RE3-Methode (Random Encoders for Efficient Exploration) in ihrer reinen Form bezeichnet werden. Wir nutzten jedoch die grundlegenden Ansätze dieses Algorithmus und ergänzten sie mit unserer Vision von bereits untersuchten Algorithmen. Jetzt ist es an der Zeit, die Ergebnisse anhand echter historischer Daten zu bewerten.

Wie zuvor werden Training und Test der Modelle für die ersten 5 Monate des Jahres 2023 von EURUSD H1 durchgeführt. Alle Indikatorparameter werden standardmäßig verwendet. Das Anfangsguthaben beträgt 10.000 USD.

Ich möchte noch einmal wiederholen, dass die Ausbildung von Modellen ein iterativer Prozess ist. Zunächst starten wir den EA im Strategietester zur Interaktion mit der Umgebung „...\RE3\Research.mq5“ und zum Sammeln von Trainingsbeispielen.


Hier verwenden wir einen langsamen Optimierungsmodus mit erschöpfender Parametersuche, der es uns ermöglicht, den Erlebniswiedergabepuffer mit den unterschiedlichsten Daten zu füllen. Dies ermöglicht ein möglichst umfassendes Verständnis der Natur der Modellumgebung.

Die gesammelten Trainingsbeispiele werden von dem Modell-Trainings-EA „...\RE3\Study.mq5“ beim Training von Kritiker und Akteur verwendet.

Wir wiederholen die Iterationen des Sammelns von Trainingsbeispielen und des Trainierens von Modellen mehrere Male, bis das gewünschte Ergebnis erreicht ist.

Während der Vorbereitung des Artikels konnte ich eine Actor-Politik trainieren, die in der Lage ist, auf der Trainingsmenge Gewinne zu erzielen. Im Trainingsset zeigte der EA beeindruckende 83% profitable Handelsgeschäfte. Allerdings muss ich zugeben, dass die Zahl der durchgeführten Transaktionen sehr gering ist. Während der 5-monatigen Ausbildungszeit hat mein Akteur nur 6 Handelsgeschäfte abgeschlossen. Nur einer davon wurde mit einem relativ geringen Verlust von 18,62 USD geschlossen. Der durchschnittliche Gewinn liegt bei 114,96 USD. Infolgedessen lag der Gewinnfaktor bei über 30, während der Rückgewinnungsfaktor 4,62 betrug.

Ergebnisse des Modelltrainings Ergebnisse des Modelltrainings

Aus den Testergebnissen lässt sich schließen, dass der vorgeschlagene Algorithmus es ermöglicht, effektive Kombinationen zu finden. Eine Rentabilität von 5,5 % und 6 Handelsvorgänge in 5 Monaten sind jedoch ein recht niedriges Ergebnis. Um bessere Ergebnisse zu erzielen, sollten wir uns darauf konzentrieren, die Anzahl der durchgeführten Geschäfte zu erhöhen. Es ist jedoch zu beachten, dass eine Erhöhung der Zahl der Operationen nicht zu einer Verschlechterung der Gesamteffizienz der Strategie führt.


Schlussfolgerung

In diesem Artikel wird die Methode Random Encoders for Efficient Exploration (RE3) vorgestellt, die einen effizienten Ansatz zur Erkundung der Umgebung im Kontext des Reinforcement Learning darstellt. Diese Methode zielt darauf ab, das Problem der effizienten Erkundung komplexer Umgebungen zu lösen, was eine der größten Herausforderungen im Bereich des Deep Reinforcement Learning darstellt.

Die Hauptidee von RE3 besteht darin, die Entropie von Zuständen im Raum niedrigdimensionaler Darstellungen zu schätzen, die mit Hilfe eines zufällig initialisierten Encoders erhalten werden. Die Parameter des Encoders sind während des gesamten Trainings festgelegt. Dadurch wird die Einführung zusätzlicher Modelle und Trainingsrepräsentationen vermieden, was die Methode einfacher und rechnerisch effizienter macht.

Im praktischen Teil des Artikels habe ich meine Vision und die Umsetzung der vorgeschlagenen Methode vorgestellt. Meine Implementierung verwendet die grundlegenden Ideen des vorgeschlagenen Algorithmus, wird aber durch eine Reihe von Ansätzen aus zuvor betrachteten Algorithmen ergänzt. Dadurch konnte ein recht interessantes Modell erstellt und trainiert werden. Der Anteil der profitablen Handelsgeschäfte ist ziemlich erstaunlich, aber leider ist ihre Gesamtzahl sehr gering.

Im Allgemeinen hat das resultierende Modell Potenzial, aber es sind zusätzliche Arbeiten erforderlich, um Wege zu finden, die Anzahl der Handelsgeschäfte zu erhöhen.


Links


Programme, die im diesem Artikel verwendet werden

# Name Typ Beschreibung
1 Research.mq5 Expert Advisor Beispielsammlung EA
2 Study.mq5  Expert Advisor Trainings-EA des Agenten
3 Test.mq5 Expert Advisor Test-EA des Modells
4 Trajectory.mqh Klassenbibliothek Struktur der Systemzustandsbeschreibung
5 NeuroNet.mqh Klassenbibliothek Eine Bibliothek von Klassen zur Erstellung eines neuronalen Netzes
6 NeuroNet.cl Code Base Die Bibliothek des Programmcodes von OpenCL


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

Beigefügte Dateien |
MQL5.zip (442.11 KB)
Neuronale Netze leicht gemacht (Teil 55): Contrastive Intrinsic Control (CIC) Neuronale Netze leicht gemacht (Teil 55): Contrastive Intrinsic Control (CIC)
Das kontrastive Training ist eine unüberwachte Methode zum Training der Repräsentation. Ziel ist es, ein Modell zu trainieren, das Ähnlichkeiten und Unterschiede in Datensätzen aufzeigt. In diesem Artikel geht es um die Verwendung kontrastiver Trainingsansätze zur Erkundung verschiedener Fähigkeiten des Akteurs (Actor skills).
Brute-Force-Ansatz zur Mustersuche (Teil VI): Zyklische Optimierung Brute-Force-Ansatz zur Mustersuche (Teil VI): Zyklische Optimierung
In diesem Artikel zeige ich den ersten Teil der Verbesserungen, die es mir ermöglicht haben, nicht nur die gesamte Automatisierungskette für den Handel mit MetaTrader 4 und 5 zu schließen, sondern auch etwas viel Interessanteres zu tun. Von nun an ermöglicht mir diese Lösung, sowohl die Erstellung von EAs als auch die Optimierung vollständig zu automatisieren und die Arbeitskosten für das Finden effektiver Handelskonfigurationen zu minimieren.
Kategorientheorie in MQL5 (Teil 21): Natürliche Transformationen mit LDA Kategorientheorie in MQL5 (Teil 21): Natürliche Transformationen mit LDA
In diesem Artikel, dem 21. in unserer Reihe, geht es weiter mit einem Blick auf natürliche Transformationen und wie sie mit Hilfe der linearen Diskriminanzanalyse umgesetzt werden können. Wir stellen diese Anwendungen in einem Signalklassenformat vor, wie im vorherigen Artikel.
Entwicklung eines Replay Systems — Marktsimulation (Teil 20): FOREX (I) Entwicklung eines Replay Systems — Marktsimulation (Teil 20): FOREX (I)
Das ursprüngliche Ziel dieses Artikels ist es nicht, alle Möglichkeiten des Forex-Handels abzudecken, sondern das System so anzupassen, dass Sie zumindest ein Replay des Marktes durchführen können. Wir lassen die Simulation noch einen Moment auf sich warten. Wenn wir jedoch keine Ticks, sondern nur Balken haben, können wir mit ein wenig Aufwand mögliche Abschlüsse simulieren, die auf dem Forex-Markt passieren könnten. Dies wird der Fall sein, bis wir uns mit der Anpassung des Simulators befassen. Der Versuch, mit Forex-Daten innerhalb des Systems zu arbeiten, ohne sie zu verändern, führt zu einer Reihe von Fehlern.