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

Neuronale Netze leicht gemacht (Teil 30): Genetische Algorithmen

MetaTrader 5Handelssysteme | 9 Dezember 2022, 09:31
290 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Inhalt

Einführung

Wir untersuchen weiterhin Algorithmen für die Modellschulung. Alle bisher betrachteten Algorithmen verwendeten eine analytische Methode zur Bestimmung der Richtung und Stärke der Änderungen der Modellparameter während des Lernprozesses. Damit ist die Hauptanforderung an alle Modelle festgelegt: Die Modellfunktion muss über den gesamten Wertebereich differenzierbar sein. Diese Eigenschaft ermöglichte die Anwendung der Methode des Gradientenabstiegs. Außerdem konnten wir so den Einfluss der einzelnen Modellparameter auf das Gesamtergebnis bestimmen und die Gewichtungskoeffizienten im Hinblick auf die Fehlerreduzierung korrigieren.

Es gibt jedoch eine ganze Reihe von Problemen, wenn es nicht möglich ist, die ursprüngliche Funktion zu differenzieren. Dabei kann es sich um nicht differenzierbare Funktionen oder um ein Modell handeln, das explosive oder abklingende Gradientenprobleme aufweist. Die Methoden zur Lösung dieser Probleme erweisen sich jedoch als ineffizient. In solchen Fällen greifen wir auf Methoden der evolutionären Optimierung zurück.


1. Evolutionäre Optimierungsmethoden

Evolutionäre Optimierungsverfahren werden als gradientenlose Verfahren bezeichnet. Sie ermöglichen die Optimierung von Modellen, die mit den bisher betrachteten Methoden nicht optimiert werden können. Es gibt aber auch viele andere Anwendungsmöglichkeiten. Manchmal ist es interessant zu beobachten, wie ein Modell mit Hilfe der evolutionären Methode und einer anderen Methode trainiert wird, die die Anwendung von Algorithmen für den Fehlergradientenabstieg beinhaltet.

Die Grundgedanken der Methode sind den Naturwissenschaften entlehnt. Insbesondere von Darwins Evolutionstheorie. Nach dieser Theorie ist jede Population von Lebewesen fruchtbar genug, um Nachkommen zu erzeugen und die Population zu vergrößern. Doch die begrenzten Ressourcen, die für das Leben zur Verfügung stehen, begrenzen das Populationswachstum. Hier spielt die natürliche Auslese eine wichtige Rolle. Dies bedeutet das Überleben des Stärkeren. Das heißt, diejenigen, die am besten an die Umwelt angepasst sind, überleben. So entwickelt sich die Population mit jeder Generation weiter und passt sich besser an die Umwelt an. Die Mitglieder der Population entwickeln neue Eigenschaften und Fähigkeiten, die ihnen beim Überleben helfen. Außerdem vergessen sie alles, was unwichtig ist.

Diese sehr prägnante Beschreibung der Theorie enthält jedoch keine Mathematik. Natürlich kann man die maximal mögliche Populationsgröße auf der Grundlage der Gesamtzahl der verfügbaren Ressourcen und ihres Verbrauchs berechnen. Dies hat jedoch keinen Einfluss auf die allgemeinen Grundsätze der Theorie.

Genau diese Theorie diente als Prototyp für die Entwicklung einer ganzen Familie von Evolutionsmethoden. In diesem Artikel schlage ich vor, sich mit dem genetischen Optimierungsalgorithmus vertraut zu machen. Er ist einer der grundlegenden Algorithmen der evolutionären Methoden. Der Algorithmus basiert auf zwei Hauptpostulaten der Darwinschen Evolutionstheorie: Vererbung und natürliche Selektion.

Der Kern der Methode besteht darin, jede Generation der Population zu beobachten und ihre besten Vertreter auszuwählen. Aber das Wichtigste zuerst.

Da wir die Population als Ganzes beobachten, ist die Grundvoraussetzung die Endlichkeit des Lebens jeder Generation. Ähnlich wie bei den zuvor betrachteten Algorithmen des Verstärkungslernens muss der Prozess hier endlich sein. Daher werden wir hier die gleichen Ansätze verwenden. Insbesondere wird eine Sitzung zeitlich begrenzt sein.

Wie bereits erwähnt, werden wir die gesamte Population beobachten. Im Gegensatz zu den zuvor besprochenen Algorithmen erstellen wir also nicht nur ein Modell, sondern eine ganze Population. Die Population „lebt“ gleichzeitig unter den gleichen Bedingungen. Die Populationsgröße ist ein Hyperparameter, der die Fähigkeit der Population zur Erkundung der Umwelt bestimmt. Jedes Mitglied der Population führt Maßnahmen gemäß seiner individuellen Politik durch. Je größer also die beobachtete Population ist, desto mehr unterschiedliche Strategien können wir beobachten. Dementsprechend wird die Umwelt umso besser untersucht, je besser sie ist.

Dieser Prozess ist vergleichbar mit der wiederholten zufälligen Auswahl von Aktionen des Agenten im gleichen Zustand beim Verstärkungslernen. Aber jetzt verwenden wir mehrere Agenten gleichzeitig, von denen jeder seine eigene Auswahl trifft.

Die Verwendung unabhängiger Populationsmitglieder ist für die Parallelisierung des Optimierungsprozesses praktisch. Um die Zeit für die Suche nach dem optimalen Modell zu verkürzen, wird der Optimierungsprozess häufig parallel auf mehreren Rechnern durchgeführt, wobei alle verfügbaren Ressourcen genutzt werden. In diesem Fall „lebt“ jedes Mitglied der Population in seinem eigenen Mikroprozessor-Thread. Der gesamte Optimierungsprozess wird von der Knotenmaschine gesteuert und verarbeitet, die die Ergebnisse jedes Agenten auswertet und eine neue Population erzeugt.

Die natürliche Auslese erfolgt nach dem Ende der Sitzung einer Generation. Bei diesem Verfahren werden die besten Vertreter aus der gesamten Population ausgewählt, die dann die Nachkommen hervorbringen. Das bedeutet, dass sie eine neue Generation der Population hervorbringen. Die Anzahl der besten Vertreter ist ein Hyperparameter und wird meist als Anteil an der Gesamtpopulationsgröße angegeben.

Die Kriterien für die Auswahl der besten Vertreter hängen von der Architektur des Optimierungsprozesses ab. Sie können zum Beispiel die Belohnungen nutzen, wie wir es beim Verstärkungslernen getan haben. Optional können sie eine Verlustfunktion wie beim überwachten Lernen verwenden. Dementsprechend wählen wir die Agenten mit der maximalen Gesamtbelohnung oder dem minimalen Wert der Verlustfunktion.

Beachten Sie, dass wir keinen Fehlergradienten verwenden. Daher können wir eine nicht-differenzierbare Funktion für die Auswahl der besten Vertreter verwenden.

Nach der Auswahl der Eltern für die künftigen Nachkommen müssen wir eine neue Generation der Population schaffen. Dazu wählen wir aus den ausgewählten besten Vertretern zufällig ein paar Modelle aus — sie dienen als Eltern des neuen Modells. Hat es nicht einen Symbolwert, ein Paar auszuwählen, um ein neues Modell zu schaffen?

Bei der Erstellung eines neuen Modells werden alle seine Parameter als Chromosom betrachtet. Jedes einzelne Gewicht ist ein eigenes Gen, das von einem der Elternteile vererbt wird.

Die Vererbungsalgorithmen mögen unterschiedlich sein, aber sie beruhen alle auf zwei Regeln:

  • Jedes Gen wechselt seinen Platz nicht
  • Der Elternteil für jedes Gen wird nach dem Zufallsprinzip ausgewählt

Wir können die Eltern für jedes Mitglied der Population der neuen Generation nach dem Zufallsprinzip auswählen oder ein Paar von Agenten mit spiegelbildlicher Vererbung von Genen schaffen.

Der Vorgang wird zyklisch wiederholt, bis die neue Generation der Population vollständig aufgefüllt ist. Zuvor ausgewählte Eltern werden nicht in die neue Generation der Population aufgenommen. Sie werden nach der Erzeugung der Nachkommenschaft gelöscht.

Für die neue Generation starten wir eine neue Sitzung und wiederholen den Optimierungsprozess.

Achten Sie darauf, dass ich absichtlich „Optimierung“ und nicht „Lernen“ sage. Der oben beschriebene Prozess hat wenig Ähnlichkeit mit dem Lernen. Das ist die natürliche Auslese im Prozess der Evolution. Wie wir wissen, gibt es im Laufe der Evolution verschiedene Mutationen, die jedoch nicht sehr häufig sind. Aber sie sind ein integraler Bestandteil des evolutionären Prozesses. Daher werden wir auch eine gewisse Unsicherheit in unseren Optimierungsprozess einbeziehen.

Das mag seltsam klingen. Im Optimierungsprozess basiert fast alles auf einer Zufallsauswahl. Zunächst wird die erste Population nach dem Zufallsprinzip erzeugt. Dann wählen wir die Eltern nach dem Zufallsprinzip aus. Und schließlich kopieren wir die Modellparameter nach dem Zufallsprinzip. Aber hinter all dieser Zufälligkeit steckt keine Neuheit. Wir fügen also die Neuheit durch Mutation hinzu.

Fügen wir einen weiteren Hyperparameter in den Optimierungsprozess ein, der für eine gewisse Mutation verantwortlich ist. Sie gibt die Wahrscheinlichkeit an, mit der wir zufällige Gene in die neuen Nachkommen einfügen, anstatt sie zu kopieren. Mit anderen Worten: Anstatt von den Eltern zu erben, erhält jedes neue Mitglied der Population ein Zufallsgen mit der Wahrscheinlichkeit des Mutationsparameters. Neben der Vererbung durch die Eltern wird also in jeder neuen Generation etwas Neues hinzukommen. Dies ist die größte Ähnlichkeit mit unserer Entwicklung.


2. Implementierung mittels MQL5

Nachdem wir uns mit den theoretischen Aspekten der Algorithmen beschäftigt haben, kommen wir nun zum praktischen Teil unseres Artikels. Wir werden den betrachteten Algorithmus mit MQL5 implementieren. Natürlich enthält der vorgestellte Algorithmus praktisch keine Mathematik. Aber es hat noch etwas anderes — einen klar aufgebauten Algorithmus von Aktionen. Dies werden wir verwenden.

Das Modell, das wir bisher entwickelt haben, ist nicht geeignet, um solche Probleme zu lösen. Beim Aufbau der Klasse unserers CNet für die Arbeit mit neuronalen Netzen erwarteten wir die Verwendung von ausschließlich linearen Modellen. Dieses Mal müssen wir den Parallelbetrieb mehrerer linearer Modelle implementieren. Es gibt 2 Möglichkeiten, dieses Problem zu lösen.

Die erste Variante ist weniger arbeitsintensiv für den Programmierer, aber ressourcenintensiver: Wir können einfach ein dynamisches Array von Objekten erstellen, in dem wir mehrere identische Modelle erstellen. Dann werden wir abwechselnd Modelle aus dem Array extrahieren und eines nach dem anderen verarbeiten. Bei dieser Variante wird die gesamte Arbeit jedes einzelnen Modells im Rahmen der bestehenden Funktionalität umgesetzt. Wir müssen lediglich die Methoden zur Auswahl der Eltern und zur Erzeugung einer neuen Generation sowie den Prozess der Agentenauswahl implementieren.

Zu den Nachteilen dieser Methode gehören ein hoher Ressourcenverbrauch und die Notwendigkeit, eine große Anzahl von zusätzlichen Objekten zu erstellen. Für jeden Agenten müssen wir eine eigene Instanz der Klasse für die Arbeit mit dem OpenCL-Kontext erstellen. Dazu erstellen wir einen eigenen Kontext, eine Kopie des Programms und Objekte aller Kernel. Dies ist akzeptabel, wenn mehrere Computer parallel verwendet werden. Andernfalls führt die Schaffung zusätzlicher Objekte zu einer ineffizienten Nutzung der Ressourcen und schränkt die Größe der Population stark ein. Dies wirkt sich wiederum negativ auf die Ergebnisse des Optimierungsprozesses aus.

Deshalb habe ich beschlossen, unseren Unterricht für die Arbeit mit neuronalen Netzwerkmodellen zu modifizieren. Um aber die Arbeitsentwicklung nicht zu unterbrechen, erstelle ich eine neue Klasse CNetGenetic mit öffentlich abgeleitet von der Klasse CNet.

class CNetGenetic : public CNet
  {
protected:
   uint              i_PopulationSize;
   vector            v_Probability;
   vector            v_Rewards;
   matrixf           m_Weights;
   matrixf           m_WeightsConv;

   //---
   bool              CreatePopulation(void);
   int               GetAction(CBufferFloat * probability);
   bool              GetWeights(uint layer);
   float             NextGenerationWeight(matrixf &array, uint shift, vector &probability);
   float             GenerateWeight(uint total);

public:
                     CNetGenetic();
                    ~CNetGenetic();
   //---
   bool              Create(CArrayObj *Description, uint population_size);
   bool              SetPopulationSize(uint size);
   bool              feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              Rewards(CArrayFloat *targetVals);
   bool              NextGeneration(double quantile, double mutation, double &average, double &mamximum);
   bool              Load(string file_name, uint population_size, bool common = true);
   bool              SaveModel(string file_name, int model, bool common = true);
   //---
   bool              CopyModel(CArrayLayer *source, uint model);
   bool              Detach(void);
  };

Ich werde die Zwecke der Methode zusammen mit ihrer Verwendung erläutern. Schauen wir uns nun die Variablen an:

  • i_PopulationSize — Größe der Population
  • v_Probability — Vektor der Wahrscheinlichkeiten für die Auswahl eines Modells als Elternteil
  • v_Rewards — Vektor der von jedem einzelnen Modell angesammelten Gesamtbelohnungen
  • m_Weights — Matrix zur Erfassung der Parameter aller Modelle
  • m_WeightsConv — eine ähnliche Matrix zur Aufzeichnung aller Parameter der neuronalen Faltungsschichten

Im Klassenkonstruktor werden nur die oben genannten Variablen initialisiert. Hier legen wir die Standardpopulationsgröße fest und rufen die Methode zur Änderung der entsprechenden Variablen auf.

CNetGenetic::CNetGenetic() :  i_PopulationSize(100)
  {
   SetPopulationSize(i_PopulationSize);
  }

Diese Klasse verwendet keine Instanzen von anderen Objekten. Daher bleibt der Destruktor der Klasse leer.

Wir haben bereits die Methode zum Festlegen der SetPopulationSize Größe der Population. Der Algorithmus ist sehr einfach. In den Parametern erhält die Methode die Größe der Population. Im Hauptteil der Methode speichern wir den erhaltenen Wert in der entsprechenden Variablen und initialisieren den Vektor der Wahrscheinlichkeiten und Belohnungen mit Nullwerten.

bool CNetGenetic::SetPopulationSize(uint size)
  {
   i_PopulationSize = size;
   v_Probability = vector::Zeros(i_PopulationSize);
   v_Rewards = vector::Zeros(i_PopulationSize);
//---
   return true;
  }

Als Nächstes wollen wir uns die Initialisierungsmethode des Klassenobjektes Create anschauen. In Analogie zu einer ähnlichen Methode der Elternklasse erhält die Methode als Parameter einen Zeiger auf das Beschreibungsobjekt eines Bearbeiters. Wir fügen auch die Populationsgröße hinzu.

bool CNetGenetic::Create(CArrayObj *Description, uint population_size)
  {
   if(CheckPointer(Description) == POINTER_INVALID)
      return false;
//---
   if(!SetPopulationSize(population_size))
      return false;
   CNet::Create(Description);
   return CreatePopulation();
  }

Im Methodenrumpf wird zunächst die Gültigkeit des empfangenen Zeigers auf das Modellarchitekturbeschreibungsobjekt geprüft. Nach erfolgreicher Validierung rufen wir die bereits bekannte Methode zur Angabe der Populationsgröße auf.

Anschließend rufen wir eine ähnliche Methode der übergeordneten Klasse auf, in der ein Agent gemäß der erhaltenen Beschreibung erstellt wird und in der alle weiteren Objekte initialisiert werden.

Und schließlich rufen wir die Methode CreatePopulation zur Erstellung der Population auf, in der die Population durch Kopieren des zuvor erstellten Modells bevölkert wird. Schauen wir uns den Algorithmus dieser Methode genauer an.

Zu Beginn der Methode wird die Anzahl der neuronalen Schichten im erstellten Modell überprüft. Es müssen mindestens zwei Schichten vorhanden sein.

bool CNetGenetic::CreatePopulation(void)
  {
   if(!layers || layers.Total() < 2)
      return false;

Anschließend speichern wir den Zeiger auf die neuronale Schicht der Quelldaten in einer lokalen Variablen.

   CLayer *layer = layers.At(0);
   if(!layer || !layer.At(0))
      return false;
//---
   CNeuronBaseOCL *neuron_ocl = layer.At(0);
   int prev_count = neuron_ocl.Neurons();

Achten Sie darauf, dass die erste neuronale Schicht nur zur Aufzeichnung der Quelldaten verwendet wird. Alle Agenten unserer Population werden mit denselben Quelldaten arbeiten. Es ist daher nicht sinnvoll, die Schicht der Quelldaten nach der Anzahl der Bearbeiter in der Grundgesamtheit zu kopieren. Die Verdoppelung der neuronalen Schichten beginnt mit der nächsten neuronalen Schicht mit dem Index 1.

Erinnern wir uns an die Struktur der Objekte unserer neuronalen Netze. Die Klasse CNet ist für die Organisation der Arbeit des Modells auf der obersten Ebene zuständig. Sie enthält eine Instanz des CArrayLayer Objekts eines dynamischen Arrays von neuronalen Schichten. In diesem dynamischen Array speichern wir Zeiger auf Objekte verschachtelter dynamischer Arrays direkt aus der neuronalen Schicht CLayer. Dort schreiben wir die Zeiger auf Neuronenobjekte CNeuronBaseOCL und andere.

CNet -> CArrayLayer -> CLayer -> CNeuronBaseOCL

Diese Struktur wurde ursprünglich erstellt, als wir die Berechnungsprozesse mit MQL5 auf der CPU implementierten. Jedes einzelne Neuron war ein separates Objekt. Später, als wir die Berechnungen mit dieser Technologie auf den Grafikprozessor OpenCL Technologie übertragen haben, waren wir gezwungen, Datenpuffer zu verwenden. Eigentlich wurde jede neuronale Schicht durch ein CNeuronBaseOCL-Neuron ausgedrückt, das die Funktionalität der neuronalen Schicht erfüllte. Das Gleiche gilt für die Verwendung anderer Arten von Neuronen.

Somit enthält jedes Objekt der neuronalen Schicht CLayer nur noch ein Neuronenobjekt. Bisher haben wir die Architektur der Datenspeicherung nicht geändert, um die Kompatibilität mit früheren Versionen zu wahren. Diese Tatsache hat jetzt eine andere Bedeutung. Wir fügen dem dynamischen Array CLayer einfach die erforderliche Anzahl von Objekten hinzu, um die gesamte Population unserer Agenten zu speichern. Innerhalb eines Modells haben wir also parallele Objekte der neuronalen Schichten aller Agenten unserer Population. Wir müssen also nur ihre Arbeit entsprechend dem entsprechenden Agentenindex ausführen.

Nach dieser Logik erstellen wir dann eine Schleife, um neuronale Schichten zu duplizieren. In dieser Schleife durchlaufen wir alle neuronalen Schichten unseres Modells und fügen in jeder Schicht die erforderliche Anzahl von Neuronen hinzu, die dem zuvor erstellten ersten Neuron entsprechen.

Im Schleifenkörper überprüfen wir zunächst die Gültigkeit des Zeigers auf die zuvor erstellte neuronale Schicht.

   for(int i = 1; i < layers.Total(); i++)
     {
      layer = layers.At(i);
      if(!layer || !layer.At(0))
         return false;
      //---

Dann erhalten wir eine Beschreibung der Neuronenarchitektur.

      neuron_ocl = layer.At(0);
      CLayerDescription *desc = neuron_ocl.GetLayerInfo();
      int outputs = neuron_ocl.getConnections();

Erstellen wir ähnliche Objekte und füllen die neuronale Schicht bis zur erforderlichen Populationsgröße. Zu diesem Zweck müssen wir eine weitere verschachtelte Schleife erstellen.

      for(uint n = layer.Total(); n < i_PopulationSize; n++)
        {
         CNeuronConvOCL *neuron_conv_ocl = NULL;
         CNeuronProofOCL *neuron_proof_ocl = NULL;
         CNeuronAttentionOCL *neuron_attention_ocl = NULL;
         CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
         CNeuronDropoutOCL *dropout = NULL;
         CNeuronBatchNormOCL *batch = NULL;
         CVAE *vae = NULL;
         CNeuronLSTMOCL *lstm = NULL;
         switch(layer.At(0).Type())
           {

            case defNeuron:
            case defNeuronBaseOCL:
               neuron_ocl = new CNeuronBaseOCL();
               if(CheckPointer(neuron_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_ocl.Init(outputs, n, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_ocl))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl = NULL;
               break;

            case defNeuronConvOCL:
               neuron_conv_ocl = new CNeuronConvOCL();
               if(CheckPointer(neuron_conv_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_conv_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.window_out,
                                                           desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_conv_ocl))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl = NULL;
               break;

            case defNeuronProofOCL:
               neuron_proof_ocl = new CNeuronProofOCL();
               if(!neuron_proof_ocl)
                  return false;
               if(!neuron_proof_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.count,
                                                                   desc.optimization, desc.batch))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_proof_ocl))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl = NULL;
               break;

            case defNeuronAttentionOCL:
               neuron_attention_ocl = new CNeuronAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMHAttentionOCL:
               neuron_attention_ocl = new CNeuronMHAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMLMHAttentionOCL:
               neuron_mlattention_ocl = new CNeuronMLMHAttentionOCL();
               if(CheckPointer(neuron_mlattention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_mlattention_ocl.Init(outputs, n, opencl, desc.window, desc.window_out,
                                               desc.step, desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_mlattention_ocl))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl = NULL;
               break;

Der Algorithmus für das Hinzufügen von Objekten ist ähnlich wie das Erstellen eines neuen Objekts in der übergeordneten Klasse.

Sobald alle Elemente der Population eines neuronalen Netzes hinzugefügt wurden, passen wir die Größe der Layer an die Populationsgröße an und löschen das Neuronenbeschreibungsobjekt.

        }
      if(layer.Total() > (int)i_PopulationSize)
         layer.Resize(i_PopulationSize);
      delete desc;
     }
//---
   return true;
  }

Sobald alle Iterationen des Schleifensystems abgeschlossen sind, erhalten wir die gesamte Population innerhalb unserer einzigen Modellinstanz und beenden die Methode mit einem positiven Ergebnis.

Der vollständige Code dieser Methode und der gesamten Klasse ist im Anhang verfügbar.

Nachdem wir mit den Methoden die Klassenobjekte CNetGenetic initialisiert haben, geht es weiter mit der Beschreibung der Methode für den Vorwärtsdurchgang. Ihr Name und ihre Parameter entsprechen denen der Methode der übergeordneten Klasse. Sie enthält einen Zeiger auf das dynamische Array-Objekt der Quelldaten sowie Parameter für die Erstellung von Zeitstempeln der Quelldaten.

Im Methodenrumpf überprüfen wir die Gültigkeit des erhaltenen Zeigers und der verwendeten internen Objekte.

bool CNetGenetic::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(CheckPointer(layers) == POINTER_INVALID || CheckPointer(inputVals) == POINTER_INVALID || layers.Total() <= 1)
      return false;

Vorbereiten der lokalen Variablen.

   CLayer *previous = NULL;
   CLayer *current = layers.At(0);
   int total = MathMin(current.Total(), inputVals.Total());
   CNeuronBase *neuron = NULL;
   if(CheckPointer(opencl) == POINTER_INVALID)
      return false;
   CNeuronBaseOCL *neuron_ocl = current.At(0);
   CBufferFloat *inputs = neuron_ocl.getOutput();
   int total_data = inputVals.Total();
   if(!inputs.Resize(total_data))
      return false;

Verschiebt Quelldaten in den Puffer der Quelldatenschicht und schreibt sie in den Kontext von OpenCL. Falls notwendig fügen wir einen Zeitstempel hinzu.

   for(int d = 0; d < total_data; d++)
     {
      int pos = d;
      int dim = 0;
      if(window > 1)
        {
         dim = d % window;
         pos = (d - dim) / window;
        }
      float value = pos / pow(10000, (2 * dim + 1) / (float)(window + 1));
      value = (float)(tem ? (dim % 2 == 0 ? sin(value) : cos(value)) : 0);
      value += inputVals.At(d);
      if(!inputs.Update(d, value))
         return false;
     }
   if(!inputs.BufferWrite())
      return false;

Danach erstellen wir ein System von Schleifen, um den Vorwärtsdurchlauf für alle Agenten der analysierten Population zu implementieren. In der äußeren Schleife werden die neuronalen Schichten in aufsteigender Reihenfolge durchlaufen. In der verschachtelten Schleife werden die Agenten durchlaufen.

Bitte beachten Sie, dass wir bei der Angabe des Neurons der vorhergehenden Schicht die Entsprechung der Agenten klar kontrollieren müssen. Jeder Agent arbeitet in seiner eigenen Vertikale von Neuronen, die durch die Seriennummer des Neurons in der Schicht bestimmt wird. Gleichzeitig haben wir die ursprüngliche Datenschicht nicht dupliziert. Daher wird bei der Angabe des Index des entsprechenden Neurons der vorherigen Schicht zunächst die Seriennummer der neuronalen Schicht selbst überprüft. Für die Quelldatenschicht ist die Seriennummer des Neurons der vorherigen Schicht immer 0. Bei allen anderen Schichten entspricht sie der Seriennummer des Agenten.

Da alle Agenten absolut unabhängig sind, können wir Operationen für alle Agenten gleichzeitig durchführen.

   for(int l = 1; l < layers.Total(); l++)
     {
      previous = current;
      current = layers.At(l);
      if(CheckPointer(current) == POINTER_INVALID)
         return false;
      //---
      for(uint n = 0; n < i_PopulationSize; n++)
        {
         CNeuronBaseOCL *current_ocl = current.At(n);
         if(!current_ocl.FeedForward(previous.At(l == 1 ? 0 : n)))
            return false;
         continue;
        }
     }
//---
   return true;
  }

Natürlich bietet die Verwendung einer Schleife nicht die volle Parallelität der Berechnungen. Gleichzeitig werden wir aber ähnliche Iterationen für alle Agenten nacheinander durchführen. Dadurch wird die Verwendung von generierten Quelldaten für alle Bearbeiter ermöglicht. Dies wiederum senkt die Kosten für die Aufbereitung der Quelldaten für jeden einzelnen Bearbeiter.

Vergessen wir nicht, die Ergebnisse bei jedem Schritt zu kontrollieren. Sobald alle Iterationen des Systems aus verschachtelten Schleifen abgeschlossen sind, beenden wir die Methode.

Im genetischen Algorithmus gibt es keine Backpropagation mit Fehlergradient. Wir müssen jedoch die Leistung der Modelle bewerten. In diesem Artikel werde ich den Agenten aus dem vorherigen Artikel optimieren, den wir mit dem Policy-Gradienten-Algorithmus trainiert haben. Um die Leistung der Modelle zu optimieren, werden wir die Gesamtbelohnung des Modells pro Sitzung maximieren. Deshalb müssen wir jedem Agenten nach jeder Aktion seine Belohnung zurückgeben. Wie Sie sich erinnern, hängt die Belohnung von der gewählten Aktion ab. Jeder Agent führt seine eigene Aktion durch. Zuvor haben wir vom Agenten die Wahrscheinlichkeitsverteilung für die Durchführung von Aktionen erhalten, eine Aktion aus dieser Verteilung ausgewählt und die entsprechende Belohnung an den Agenten zurückgegeben. Jetzt haben wir viele solcher Agenten. Um diese Iterationen nicht für jeden einzelnen Agenten im externen Programm zu wiederholen, verpacken wir sie in eine eigene Belohnungsmethode. Das externe Programm (Umgebung) übergibt die Belohnung für alle möglichen Aktionen in seinen Parametern. Dieser Ansatz ermöglicht es uns, jede Aktion nur einmal zu bewerten, unabhängig von der Anzahl der eingesetzten Agenten.

Im Methodenrumpf überprüfen wir zunächst die Gültigkeit der Zeiger auf den Belohnungsvektor, die wir in den Parametern und dem dynamischen Array unserer neuronalen Schichten erhalten haben.

bool CNetGenetic::Rewards(CArrayFloat *rewards)
  {
   if(!rewards || !layers || layers.Total() < 2)
      return false;

Als Nächstes extrahieren wir einen Zeiger auf die Ergebnisschicht des Agenten aus dem dynamischen Array und überprüfen die Gültigkeit des empfangenen Zeigers.

   CLayer *output = layers.At(layers.Total() - 1);
   if(!output)
      return false;

Danach erstellen wir eine Schleife, in der wir alle Agenten unserer Population abfragen und iterieren. Für jeden Agenten wird eine Aktion aus der entsprechenden Verteilung ausgewählt. Je nach gewählter Aktion erhält der Agent seine Belohnung, die zu den zuvor erhaltenen Belohnungen im Vektor v_Rewards unter dem Agentenindex addiert wird.

   for(int i = 0; i < output.Total(); i++)
     {
      CNeuronBaseOCL *neuron = output.At(i);
      if(!neuron)
         return false;
      int action = GetAction(neuron.getOutput());
      if(action < 0)
         return false;
      v_Rewards[i] += rewards.At(action);
     }

Auf der Grundlage der Bewertungsergebnisse der Agenten können wir eine Wahrscheinlichkeitsverteilung der Agenten für die Anzahl der Eltern der nächsten Generation erstellen.

   v_Probability = v_Rewards - v_Rewards.Min();
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
//---
   return true;
  }

Dann verlassen wir die Methode mit einem positiven Ergebnis. Der vollständige Code aller Methoden und Klassen ist in der Anlage unten zu finden.

Die erstellte Funktionalität reicht aus, um jede einzelne Sitzung für die analysierte Population zu implementieren und die Aktionen der Agenten auszuwerten. Wenn die Sitzung zu Ende ist, müssen wir die besten Vertreter auswählen und eine neue Generation unserer Population hervorbringen. Diese Funktionalität wird in der Methode NextGeneration implementiert. In den Parametern dieser Methode geben wir zwei Hyperparameter an: den Anteil der zu entfernenden Individuen und den Mutationsparameter. Darüber hinaus enthalten die Methodenparameter zwei Variablen, in denen wir die durchschnittliche und die maximale Belohnung der ausgewählten Agenten zurückgeben werden.

Im Hauptteil der Methode werden zunächst die Wahrscheinlichkeiten für die Auswahl von Agenten, die nicht zu den ausgewählten Agenten gehören, auf Null gesetzt. Berechnen wir dann die maximale Belohnung und den gewichteten Durchschnitt für die ausgewählten Kandidaten.

bool CNetGenetic::NextGeneration(double quantile, double mutation, double &average, double &maximum)
  {
   maximum = v_Rewards.Max();
   v_Probability = v_Rewards - v_Rewards.Quantile(quantile);
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
   average = v_Rewards.Average(v_Probability);

Vergessen wir nicht, dass wir die kürzlich hinzugefügten Vektoroperationen verwenden. Dank ihnen müssen wir keine Schleifen verwenden und der Programmcode wurde reduziert. Die Methode vector::Max() ermöglicht es, den Maximalwert des gesamten Vektors in nur einer Zeile zu bestimmen. Die Methode vector::Quantile(...) Methode gibt den Wert des angegebenen Quantils für den Vektor zurück. Wir verwenden diesen Wert, um schwache Agenten zu entfernen. Und nach der Vektorsubtraktion werden ihre Wahrscheinlichkeiten negativ.

Mit der Funktion vector::Clip(0, vector::Max()) werden alle negativen Werte des Vektors auf Null zurückgesetzt.

Außerdem normalisieren wir auf elegante Weise in einer Zeile alle Vektorwerte im Bereich zwischen 0 und 1, wobei der Gesamtwert aller Elemente 1 ist.

v_Probability = v_Probability / v_Probability.Sum();

Die Operation vector::Average(Gewichte) ermittelt den gewichteten Durchschnittswert des Vektors. Der Vektor weights enthält die Gewichte der einzelnen Elemente des Vektors. Zuvor haben wir die Wahrscheinlichkeiten der schwachen Agenten auf Null gesetzt, sodass ihre Werte bei der Berechnung des gewichteten Durchschnitts des Vektors nicht berücksichtigt werden.

Die Verwendung von Vektoroperationen reduziert also den Programmcode erheblich und erleichtert dem Programmierer die Arbeit. Besonderen Dank an das MetaQuotes-Team für diese Möglichkeiten! Ausführliche Informationen zu Vektor- und Matrixoperationen finden wir im entsprechenden Abschnitt der Dokumentation.

Aber zurück zu unserer Methode. Wir haben die Kandidaten und ihre Wahrscheinlichkeiten bestimmt. Fügen wir nun den Anteil der Mutationen zur Verteilung hinzu und berechnen wir die Wahrscheinlichkeiten neu.

   if(!v_Probability.Resize(i_PopulationSize + 1))
      return false;
   v_Probability[i_PopulationSize] = mutation;
   v_Probability = (v_Probability / (1 + mutation)).CumSum();

In diesem Stadium haben wir eine Wahrscheinlichkeitsverteilung für die Verwendung von Agenten als Eltern der nächsten Generation. Jetzt können wir direkt mit der Erzeugung einer neuen Population fortfahren. Zu diesem Zweck implementieren wir eine Schleife, in der wir jede neuronale Schicht der neuen Population erzeugen. Auf jeder Ebene der neuronalen Schicht werden wir Gewichtsmatrizen für alle Agenten auf einmal erstellen. Wir werden dies Schicht für Schicht tun.

Um aber keine neuen Objekte zu schaffen, überschreiben wir einfach die Gewichtsmatrizen der bestehenden Agenten. Bevor wir mit der Aktualisierung der Gewichte der nächsten neuronalen Schicht fortfahren, rufen wir daher zunächst die GetWeights Methode auf, in der wir die Parameter der aktuellen neuronalen Schicht aller Agenten in die eigens dafür erstellten m_Gewichte und m_GewichteConv Matrizen kopieren. Hier geben wir nur die Gewichtsmatrizen der voll verknüpften und der Faltungsschichten an, da nur diese in der Architektur des zu optimierenden Modells verwendet werden. Wenn wir andere neuronale Schichtarchitekturen verwenden würden, müssten wir entsprechende Matrizen hinzufügen, um Parameter vorübergehend zu speichern.

   for(int l = 1; l < layers.Total(); l++)
     {
      if(!GetWeights(l))
        {
         PrintFormat("Error of load weights from layer %d", l);
         return false;
        }

Nachdem wir eine Kopie der Modellparameter erhalten haben, können wir mit der Bearbeitung der Parameter in den Objekten beginnen. Zunächst erhalten wir einen Zeiger auf das Objekt der neuronalen Schicht. Dann implementieren wir eine verschachtelte Schleife durch alle unsere Agenten. In dieser Schleife extrahieren wir einen Zeiger auf die Gewichtsmatrix des entsprechenden Agenten.

      CLayer* layer = layers.At(l);
      for(uint i = 0; i < i_PopulationSize; i++)
        {
         CNeuronBaseOCL* neuron = layer.At(i);
         CBufferFloat* weights = neuron.getWeights();

Und wenn der erhaltene Zeiger gültig ist, implementieren wir eine weitere verschachtelte Schleife, in der wir alle Elemente der Gewichtsmatrix durchlaufen und sie durch die entsprechenden Parameter der Eltern ersetzen.

         if(!!weights)
           {
            for(int w = 0; w < weights.Total(); w++)
               if(!weights.Update(w, NextGenerationWeight(m_Weights, w, v_Probability)))
                 {
                  Print("Error of update weights");
                  return false;
                 }
            weights.BufferWrite();
           }

Bitte beachten Sie, dass dies ein wenig vom Grundalgorithmus abweicht. Wir haben nicht zufällig ein Elternpaar ausgewählt. Stattdessen werden wir die Gewichte von allen ausgewählten Agenten auf einmal nach dem Zufallsprinzip entsprechend ihrer Wahrscheinlichkeitsverteilung nehmen. Die Gewichte werden in der Methode NextGenerationWeight ermittelt.

Nachdem wir die Werte des nächsten Datenpuffers erzeugt haben, kopieren wir dessen Werte in den OpenCL-Kontext.

Gegebenenfalls wiederholen wir die Vorgänge für die Matrix der Faltungsschicht.

         if(neuron.Type() != defNeuronConvOCL)
            continue;
         CNeuronConvOCL* temp = neuron;
         weights = temp.GetWeightsConv();
         for(int w = 0; w < weights.Total(); w++)
            if(!weights.Update(w, NextGenerationWeight(m_WeightsConv, w, v_Probability)))
              {
               Print("Error of update weights");
               return false;
              }
         weights.BufferWrite();
        }
     }

Nach der Aktualisierung der Parameter aller Agenten setzen wir den Wert des Belohnungsakkumulationsvektors auf Null zurück, um die Rentabilität der neuen Generation korrekt zu bestimmen. Dann verlassen wir die Methode mit einem positiven Ergebnis.

   v_Rewards.Fill(0);
//---
   return true;
  }

Wir haben soweit den Algorithmus der wichtigsten Klassenmethoden besprochen, die die Grundlage des genetischen Algorithmus bilden. Es gibt jedoch auch weitere Hilfsmethoden. Ihr Algorithmus ist nicht kompliziert, und Sie können sie in der Anlage sehen. Ich möchte Ihre Aufmerksamkeit auf die Methode zum Speichern des Modells lenken. Der Punkt ist, dass die Speichermethode der übergeordneten Klasse alle Bearbeiter speichert. Damit können Sie die Optimierung weiter fortsetzen. Es ist jedoch nicht geeignet, einen einzelnen Agenten zu retten. Das Ziel der Optimierung ist jedoch, den optimalen Agenten zu finden. Um einen der besten Agenten zu speichern, erstellen wir daher die Methode SaveModel. In den Methodenparametern übergeben wir den Namen der Datei, in der das Modell gespeichert werden soll, die Seriennummer des Agenten und das Kennzeichen für das Schreiben in das Verzeichnis Common.

Im Hauptteil der Methode wird zunächst die Seriennummer des Agenten überprüft. Wenn sie nicht der Anzahl der aktiven Bearbeiter entspricht, ersetzen wir sie durch die Anzahl der Bearbeiter mit der größten Wahrscheinlichkeit. Das ist auch der Vertreter mit dem höchsten Gewinn.

bool CNetGenetic::SaveModel(string file_name, int model, bool common = true)
  {
   if(model < 0 || model >= (int)i_PopulationSize)
      model = (int)v_Probability.ArgMax();

Dann erstellen wir eine Instanz eines neuen Modellobjekts und kopieren die Parameter des gewünschten Modells in dieses Objekt.

   CNetGenetic *new_model = new CNetGenetic();
   if(!new_model)
      return false;
   if(!new_model.CopyModel(layers, model))
     {
      new_model.Detach();
      delete new_model;
      return false;
     }

Jetzt können wir einfach die Speichermethode der übergeordneten Klasse für das neue Modell aufrufen.

   bool result = new_model.Save(file_name, 0, 0, 0, 0, common);

Nach dem Speichern des Modells und vor dem Beenden der Methode müssen wir das neu erstellte Objekt löschen. Beim Kopieren der Daten haben wir jedoch keine neuen Objekte der neuronalen Schicht erstellt, sondern lediglich Zeiger auf sie verwendet. Wenn wir also ein Modellobjekt löschen, werden wir auch alle Objekte des gespeicherten Agenten in unserem allgemeinen Modell löschen. Um dies zu vermeiden, verwenden wir zunächst die Methode Detach, mit der die Objekte der neuronalen Schichten aus dem gespeicherten Modell gelöst werden. Danach können wir das in dieser Methode erstellte Modellobjekt einfach erstellen.

   new_model.Detach();
   delete new_model;
//---
   return result;
  }

Der vollständige Code aller Methoden dieser Klasse ist in der Anlage unten verfügbar. Lassen Sie uns nun mit der Erstellung des EAs Genetisch.mq5 fortfahren, in dem wir die Modelloptimierung durchführen werden. Der neue EA wird auf der Grundlage des Actor_Critic.mq5 EA aus dem vorherigen Artikel erstellt.

Wir fügen Hyperparameter in die externen Parameter des EA ein, um den neuen Prozess zu organisieren.

input int                  PopulationSize =  50;
input int                  Generations = 1000;
input double               Quantile =  0.5;
input double               Mutation = 0.01;

Wir ersetzen auch das Arbeitsobjekt des Modells.

CNetGenetic          Models;

Die Modellinitialisierung im EA ist ähnlich organisiert wie die übergeordnete Modellinitialisierung im zuvor betrachteten EA.

int OnInit()
  {
//---
.............
.............
//---
   if(!Models.Load(MODEL + ".nnw", PopulationSize, false))
      return INIT_FAILED;
//---
   if(!Models.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   Models.getResults(TempData);
   if(TempData.Total() != Actions)
      return INIT_PARAMETERS_INCORRECT;
//---
   bEventStudy = EventChartCustom(ChartID(), 1, 0, 0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

Der Optimierungsprozess wird wie immer in der Funktion Train. Zu Beginn der Funktion legen wir, ähnlich wie bei der zuvor betrachteten EA, den Optimierungszeitraum (Training) fest.

void Train(void)
  {
//---
   MqlDateTime start_time;
   TimeCurrent(start_time);
   start_time.year -= StudyPeriod;
   if(start_time.year <= 0)
      start_time.year = 1900;
   datetime st_time = StructToTime(start_time);

Laden des Trainingsmusters.

   int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates);
   if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
      ExpertRemove();
      return;
     }
   if(!ArraySetAsSeries(Rates, true))
     {
      ExpertRemove();
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

Nach der Erstellung der Ausgangsdaten bereiten wir die lokalen Variablen vor. Wir schließen den letzten Monat aus der Trainingsstichprobe aus — er wird verwendet, um die Leistung des optimierten Modells auf neuen Daten zu testen.

   CBufferFloat* State = new CBufferFloat();
   float loss = 0;
   uint count = 0;
   uint total = bars - HistoryBars - 1;
   ulong ticks = GetTickCount64();
   uint test_size=22*24;

Als Nächstes erstellen wir ein System von verschachtelten Schleifen, um den Optimierungsprozess zu organisieren. Die äußere Schleife ist für das Zählen der Optimierungsgenerationen zuständig. Die verschachtelte Schleife zählt die Optimierungsiterationen. In diesem Fall habe ich die komplette Iteration der Trainingsstichprobe durch alle Agenten verwendet. Um jedoch die Zeit zu verkürzen, die für eine Sitzung benötigt wird, können wir Zufallsstichproben verwenden. In diesem Fall sollten wir sicherstellen, dass die Stichprobe ausreicht, um die Haupttendenzen der Ausbildungsstichprobe zu beurteilen. Natürlich kann in diesem Fall die Genauigkeit der Optimierung abnehmen. Ein wichtiger Faktor ist dabei das Gleichgewicht zwischen der Genauigkeit der Ergebnisse und den Kosten der Modelloptimierung.

   for(int gen = 0; (gen < Generations && !IsStopped()); gen ++)
     {
      for(uint i = total; i > test_size; i--)
        {
         uint r = i + HistoryBars;
         if(r > (uint)bars)
            continue;

Im Hauptteil der verschachtelten Schleife werden die Grenzen des aktuellen Musters festgelegt und ein Quelldatenpuffer erstellt.

         State.Clear();
         for(uint b = 0; b < HistoryBars; b++)
           {
            uint bar_t = r - b;
            float open = (float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            float rsi = (float)RSI.Main(bar_t);
            float cci = (float)CCI.Main(bar_t);
            float atr = (float)ATR.Main(bar_t);
            float macd = (float)MACD.Main(bar_t);
            float sign = (float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
               continue;
            //---
            if(!State.Add((float)Rates[bar_t].close - open) || !State.Add((float)Rates[bar_t].high - open) ||
               !State.Add((float)Rates[bar_t].low - open) || !State.Add((float)Rates[bar_t].tick_volume / 1000.0f) ||
               !State.Add(sTime.hour) || !State.Add(sTime.day_of_week) || !State.Add(sTime.mon) ||
               !State.Add(rsi) || !State.Add(cci) || !State.Add(atr) || !State.Add(macd) || !State.Add(sign))
               break;
           }
         if(IsStopped())
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         if(State.Total() < (int)HistoryBars * 12)
            continue;

Rufen wir nun die Feed-Forward-Methode für unsere optimierte Population auf.

         if(!Models.feedForward(State, 12, true))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Wie wir sehen können, ähnelt dieser Prozess den Vorgängen, die zuvor beim Training von Modellen durchgeführt wurden. Denn alle Unterschiede in den Prozessen sind in den Bibliotheken implementiert. Die Schnittstelle der Methoden hat sich nicht geändert. Jetzt werden wir einen Vorwärtsdurchlauf für ein Modell aufrufen. Im Hauptteil der Klasse CNetGenetic haben wir den Vorwärtsdurchlauf für alle aktiven Agenten der Population implementiert.

Als Nächstes müssen wir die aktuelle Belohnung auf die Agenten übertragen. Wie bereits erwähnt, werden wir hier nicht alle Agenten befragen. Stattdessen legen wir einen Puffer an, in dem wir eine Belohnung für jede Aktion im gegebenen Zustand angeben. Der Puffer wird in den Parametern der folgenden Methode übergeben. 

         double reward = Rates[i - 1].close - Rates[i - 1].open;
         TempData.Clear();
         if(!TempData.Add((float)(reward < 0 ? 20 * reward : reward)) ||
            !TempData.Add((float)(reward > 0 ? -reward * 20 : -reward)) ||
            !TempData.Add((float) - fabs(reward)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         if(!Models.Rewards(TempData))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

Wir verwenden die ursprüngliche Prämienpolitik ohne jegliche Änderung. So können wir die Auswirkungen des Optimierungsprozesses auf das Gesamtergebnis bewerten.

Sobald die Iterationen der Schleife, die einen Systemzustand verarbeiten, abgeschlossen sind, zeichnen wir die relevanten Informationen zur visuellen Kontrolle des Prozesses auf und fahren mit der nächsten Iteration der Schleife fort.

         if(GetTickCount64() - ticks > 250)
           {
            uint x = total - i;
            double perc = x * 100.0 / (total - test_size);
            Comment(StringFormat("%d from %d -> %.2f%% from %.2f%%", x, total - test_size, perc, 100));
            ticks = GetTickCount64();
           }
        }

Nach Beendigung einer Sitzung speichern wir die Parameter des besten Agenten.

      Models.SaveModel(MODEL+".nnw", -1, false);

Als Nächstes geht es um die Schaffung einer neuen Generation. Dies wird durch den Aufruf einer Methode erreicht — CNetGenetic::NextGeneration. Vergessen wir nicht, die Ausführung von Vorgängen zu kontrollieren.

      double average, maximum;
      if(!Models.NextGeneration(Quantile, Mutation, average, maximum))
        {
         PrintFormat("Error of create next generation: %d", GetLastError());
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      //---
      PrintFormat("Genegation %d, Average Cumulative reward %.5f, Max Reward %.5f", gen, average, maximum);
     }

Abschließend drucken wir die Informationen über die erzielten Ergebnisse in das Journal aus und fahren mit der Bewertung der neuen Generation der analysierten Population in einer neuen Iteration der Schleife fort.

Nach Abschluss des Optimierungsprozesses löschen wir die Daten und schließen den EA-Vorgang ab.

   delete State;
   Comment("");
//---
   ExpertRemove();
  }

Wie wir sehen können, hat diese Anordnung der Klasse die Arbeit auf der Seite des Hauptprogramms stark vereinfacht. In der Praxis besteht der Optimierungsprozess aus dem sequentiellen Aufruf von 3 Methoden der Klasse. Dies ist vergleichbar mit dem Training von Modellen mit den Methoden des Gradientenabstiegs. Dadurch wird auch die Gesamtzahl der Transaktionen innerhalb eines einzelnen Agenten erheblich reduziert.


3. Tests

Der Optimierungsprozess wurde mit allen bisher verwendeten Parametern getestet. Die Trainingsstichprobe sind die historischen EURUSD H1-Daten. Für den Optimierungsprozess habe ich die Historie der letzten 2 Jahre verwendet. Der EA wurde mit Standardparametern verwendet. Als Testmodell habe ich die Architekturen aus dem vorherigen Artikel mit der Suche nach der optimalen Wahrscheinlichkeitsverteilung der Entscheidungsfindung verwendet. Auf diese Weise kann das optimierte Modell in den zuvor verwendeten Expert Advisor „REINFORCE-test.mq5“ eingesetzt werden. Wie wir sehen können, ist dies der dritte Ansatz im Prozess der Ausbildung eines Modells der gleichen Architektur. Zuvor haben wir bereits ein ähnliches Modell mit den Algorithmen Policy Gradient und Actor-Critic trainiert. Umso interessanter ist es, die Optimierungsergebnisse zu beobachten.

Bei der Optimierung des Modells haben wir die Daten des letzten Monats nicht verwendet. Wir haben also einige Daten zum Testen des optimierten Modells übrig gelassen. Das optimierte Modell wurde mit dem Strategy Tester ausgeführt. Sie führte zu folgendem Ergebnis.

Optimierte Modelltest Grafik

Wie wir aus dem abgebildeten Diagramm erkennen können, haben wir einen wachsenden Saldo. Die Rentabilität ist jedoch etwas geringer als beim Training eines ähnlichen Modells mit der Actor-Critic-Methode. Sie führte auch weniger Handelsgeschäfte durch. Tatsächlich ging die Zahl der Abschlüsse um die Hälfte zurück.

Chart mit der Modell-Handelshistorie

Wenn Sie sich den Symbolchart mit den ausgeführten Geschäften ansehen, können Sie deutlich erkennen, dass das Modell versucht hat, mit dem Trend zu handeln. Ich denke, das ist ein interessantes Ergebnis. Beim Training eines ähnlichen Modells mit Gradientenmethoden versuchte das Modell, bei den meisten Bewegungen einen Handel durchzuführen. Oftmals war dies ziemlich chaotisch. Dieses Mal sehen wir eine gewisse Logik, die mit den bekannten Postulaten im Handel übereinstimmt.

Oder kommt es nur mir so vor? Sind alle meine Schlussfolgerungen „weit hergeholt“? Führen Sie Ihre Experimente durch — es wird interessant sein, die Ergebnisse zu beobachten.

Tabelle der Prüfergebnisse

Generell lässt sich feststellen, dass der Anteil profitabler Positionen im Vergleich zu einem ähnlichen Test des mit der Actor-Critic-Methode trainierten Modells um fast 1,5 % gestiegen ist. Aber die Anzahl der Geschäfte wurde um das 2fache reduziert. Gleichzeitig ist auch ein Rückgang der durchschnittlichen Gewinne und Verluste pro Position zu verzeichnen. All dies führt zu einem allgemeinen Rückgang des Handelsumsatzes sowie der Gesamtrentabilität in dem betreffenden Zeitraum. Bitte beachten Sie jedoch, dass die Tests im ersten Monat nicht als repräsentativ für den EA-Betrieb über einen längeren Zeitraum bewertet werden können. Daher empfehlen wir erneut, Ihre Modelle gründlich und umfassend zu testen, bevor Sie sie für den realen Handel einsetzen.


Schlussfolgerung

In diesem Artikel haben wir uns mit der genetischen Methode zur Optimierung von Modellen vertraut gemacht. Es kann zur Optimierung beliebiger parametrischer Modelle verwendet werden. Einer der Hauptvorteile dieser Methode ist die Möglichkeit, sie zur Optimierung nicht differenzierbarer Modelle einzusetzen. Dies ist beim Training von Modellen mit Gradientenmethoden, einschließlich verschiedener Varianten der Methode des Gradientenabstiegs, nicht möglich.

Der Artikel enthält auch die Implementierung des Algorithmus in MQL5. Wir haben das getestete Modell sogar optimiert und seine Ergebnisse im Strategy Tester beobachtet.

Anhand der Testergebnisse können wir sagen, dass das Modell recht gute Ergebnisse erzielt hat. Die Methode kann also zur Optimierung von Handelsmodellen verwendet werden. Bevor Sie sich jedoch entscheiden, das Modell auf einem echten Konto einzusetzen, müssen Sie es gründlich und umfassend testen.

Liste der Referenzen

  1. Neuronale Netze leicht gemacht (Teil 26): Reinforcement-Learning
  2. Neuronale Netze leicht gemacht (Teil 27): Tiefes Q-Learning (DQN)
  3. Neuronale Netze leicht gemacht (Teil 28): Gradientbasierte Optimierung
  4. Neuronale Netze leicht gemacht (Teil 29): Advantage Actor Critic Algorithm

Programme, die im diesem Artikel verwendet werden

# Name Typ Beschreibung
1 Genetic.mq5 EA EA zur Optimierung des Modells
2 NetGenetic.mqh Klassenbibliothek
Bibliothek zur Implementierung eines genetischen Algorithmus
3 REINFORCE-test.mq5 EA
Ein Expert Advisor zum Testen des Modells im Strategy Tester
4 NeuroNet.mqh Klassenbibliothek Bibliothek zur Erstellung neuronaler Netzmodelle
5 NeuroNet.cl Code Base
OpenCL-Programmcode-Bibliothek zur Erstellung neuronaler Netzwerkmodelle


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

Beigefügte Dateien |
MQL5.zip (680.14 KB)
Einen Expert Advisor von Grund auf entwickeln (Teil 30): CHART TRADE als Indikator? Einen Expert Advisor von Grund auf entwickeln (Teil 30): CHART TRADE als Indikator?
Heute werden wir wieder Chart Trade verwenden, aber dieses Mal wird es ein On-Chart-Indikator sein, der auf dem Chart laufen kann oder auch nicht.
Lernen Sie, wie man ein Handelssystem mit dem Accelerator Oscillator entwickelt Lernen Sie, wie man ein Handelssystem mit dem Accelerator Oscillator entwickelt
Ein neuer Artikel aus unserer Serie über die Erstellung einfacher Handelssysteme anhand der beliebtesten technischen Indikatoren. Wir werden einen neuen Indikator kennenlernen, den Accelerator Oscillator, und wir werden lernen, wie man ein Handelssystem mit ihm entwickelt.
DoEasy. Steuerung (Teil 23): Verbesserung der WinForms-Objekte TabControl und SplitContainer DoEasy. Steuerung (Teil 23): Verbesserung der WinForms-Objekte TabControl und SplitContainer
In diesem Artikel werde ich neue Mausereignisse relativ zu den Grenzen der Arbeitsbereiche von WinForms-Objekten hinzufügen und einige Mängel in der Funktionsweise der TabControl- und SplitContainer-Steuerelemente beheben.
DoEasy. Steuerung (Teil 22): SplitContainer. Ändern der Eigenschaften des erstellten Objekts DoEasy. Steuerung (Teil 22): SplitContainer. Ändern der Eigenschaften des erstellten Objekts
In diesem Artikel werde ich die Möglichkeit implementieren, die Eigenschaften und das Aussehen des neu erstellten SplitContainer-Steuerelements zu ändern.