English Русский 中文 Español 日本語 Português
preview
Backpropagation von Neuronalen Netze mit MQL5-Matrizen

Backpropagation von Neuronalen Netze mit MQL5-Matrizen

MetaTrader 5Beispiele | 24 April 2023, 11:23
318 0
Stanislav Korotky
Stanislav Korotky

Das maschinelle Lernen und insbesondere neuronale Netze gehören schon seit langem zum Handwerkszeug des Händlers. Bei den neuronalen Netzen werden zum Teil Methoden des „überwachten Lernens“ eingesetzt, unter denen die Backpropagation Neural Networks (BPNN) eine besondere Stellung einnehmen. Es gibt viele verschiedene Modifikationen solcher Algorithmen. Sie werden zum Beispiel als Grundlage für tiefe, rekurrente und gefaltete, neuronale Netze verwendet. Es sollte uns also nicht überraschen, dass es eine Fülle von Materialien zu diesem Thema gibt (ebenso wie Artikel auf dieser Website). Heute werden wir uns diesem Thema in einer Richtung widmen, die für MQL5 relativ neu ist. Das liegt daran, dass MQL5 vor einiger Zeit neue API-Funktionen für die Arbeit mit Matrizen und Vektoren eingeführt hat. Sie ermöglichen die Durchführung von Stapelberechnungen in neuronalen Netzen, bei denen die Daten als Ganzes (in Blöcken) und nicht Element für Element verarbeitet werden.

Die Verwendung von Matrixoperationen vereinfacht die Programmanweisungen, die die Formel für die Forward- und Backpropagation des Netzwerks enthalten, erheblich. Diese Operationen werden tatsächlich in einzeilige Ausdrücke umgewandelt. Damit können wir uns auf andere wichtige Aspekte konzentrieren, um den Algorithmus zu verbessern.

In diesem Artikel werden wir kurz die Theorie der Backpropagation-Netze in Erinnerung rufen und universelle Klassen für den Aufbau von Netzen unter Verwendung dieser Theorie erstellen: Die oben genannten Formeln werden sich fast identisch im Quellcode widerspiegeln. Auf diese Weise können Anfänger alle Schritte beim Erlernen dieser Technologie durchlaufen, ohne auf Veröffentlichungen Dritter zurückgreifen zu müssen.

Wenn Sie die Theorie bereits kennen, können Sie getrost zum zweiten Teil des Artikels übergehen, in dem die praktische Verwendung von Klassen in einem Skript, einem Indikator und einem Expert Advisor behandelt wird.


Einführung in die Theorie der neuronalen Netze

Neuronale Netze bestehen aus einfachen Rechenelementen, den Neuronen, die in der Regel logisch in Schichten zusammengefasst und durch Verbindungen (Synapsen) verbunden sind, durch die das Signal läuft. Das Signal ist eine mathematische Abstraktion, die zur Darstellung von Situationen aus verschiedenen Anwendungsbereichen, einschließlich des Handels, verwendet werden kann.

Die Synapse verbindet den Ausgang eines Neurons mit dem Eingang eines anderen Neurons. Es ist gekennzeichnet durch ein Gewicht wi. Der aktuelle Zustand des Neurons ist eine gewichtete Summe der an seinen Verbindungen (Eingängen) empfangenen Signale.

Schematisches Diagramm eines Neurons

Schematische Darstellung eines Neurons

Dieser Zustand wird zusätzlich mit einer nichtlinearen Aktivierungsfunktion verarbeitet, die den Ausgangswert eines bestimmten Neurons erzeugt. Vom Ausgang geht das Signal weiter zu den Synapsen der nächsten angeschlossenen Neuronen (falls vorhanden) oder wird zu einer Komponente der Antwort des neuronalen Netzes (falls sich das aktuelle Neuron in der letzten Schicht befindet).

f1
(1)
f2
(2)

Das Vorhandensein von Nichtlinearität verbessert die Berechnungsmöglichkeiten des Netzes. Es können verschiedene Aktivierungsfunktionen verwendet werden, z. B. eine hyperbolische Tangens oder eine logistische Funktion (beides sind sogenannte S-förmige oder sigmoide Funktionen):

f3
(3)

Wie wir weiter unten sehen werden, bietet MQL5 eine ganze Reihe von integrierten Aktivierungsfunktionen. Die Wahl einer Funktion sollte auf der Grundlage des spezifischen Problems (Regression, Klassifizierung) getroffen werden. In der Regel ist es möglich, mehrere Funktionen auszuwählen und dann experimentell die optimale Funktion zu finden.

häufig verwendete Aktivierungsfunktionen

Häufig verwendete Aktivierungsfunktionen

Aktivierungsfunktionen können verschiedene Wertebereiche haben, begrenzt oder unbegrenzt. Insbesondere Sigmoid (3) bildet die Daten im Bereich [0,+1] ab, was für Klassifizierungsprobleme besser geeignet ist, während der hyperbolische Tangens die Daten im Bereich [-1,+1] abbildet, was für Regressions- und Prognoseprobleme als besser geeignet gilt.

Eine der wichtigsten Eigenschaften der Aktivierungsfunktion ist die Definition ihrer Ableitung entlang der gesamten Achse. Das Vorhandensein einer endlichen Ableitung ungleich Null ist entscheidend für den Backpropagation-Algorithmus, auf den wir später noch eingehen werden. S-förmige Funktionen erfüllen diese Anforderung. Außerdem haben die Standard-Aktivierungsfunktionen in der Regel eine recht einfache analytische Notation für die Ableitung, was ihre effiziente Berechnung garantiert. Für Sigmoid (3) erhalten wir zum Beispiel:

f4
(4)

Ein einschichtiges neuronales Netz ist in der folgenden Abbildung dargestellt.

Einschichtiges neuronales Netz

Einschichtiges neuronales Netz

Sein Funktionsprinzip lässt sich mathematisch durch die folgende Gleichung beschreiben:

f5
(5)

Offensichtlich passen alle Gewichtskoeffizienten einer Schicht in die W-Matrix, in der jedes wij-Element den Wert der i-ten Verbindung des j-ten Neurons festlegt. Der im neuronalen Netz ablaufende Prozess kann also in Matrixform geschrieben werden:

Y = F(X W) (6)

wobei X und Y die Eingangs- bzw. Ausgangssignalvektoren sind; F(V) ist die Aktivierungsfunktion, die elementweise auf die Komponenten des Vektors V angewendet wird.

Die Anzahl der Schichten (layer) und die Anzahl der Neuronen in jeder Schicht hängt von den Eingabedaten ab: ihrer Dimension, der Größe des Datensatzes, dem Verteilungsgesetz und vielen anderen Faktoren. Oft wird die Netzkonfiguration durch Ausprobieren gewählt.

Zur Veranschaulichung zeige ich das Schema eines zweischichtigen Netzes.

Zweischichtiges neuronales Netz

Zweischichtiges neuronales Netz

Kommen wir nun einen Punkt, den wir übersehen haben. Aus der Abbildung der Aktivierungsfunktionen ist ersichtlich, dass es einen bestimmten Wert von T gibt, bei dem S-förmige Funktionen eine maximale Steigung haben und Signale gut übertragen, während andere Funktionen eine charakteristische Bruchstelle (oder mehrere solcher Stellen) haben. Daher findet die Hauptarbeit jedes Neurons in der Nähe von T statt. Normalerweise ist T=0 oder liegt in der Nähe von 0, daher ist es wünschenswert, eine Möglichkeit zu haben, das Argument der Aktivierungsfunktion automatisch nach T zu verschieben.

Dieses Phänomen spiegelt sich nicht in Formel (1) wider, die wie folgt aussehen müsste:

f7
(7)

Eine solche Verschiebung wird in der Regel durch Hinzufügen eines weiteren Pseudo-Eingangs in die neuronale Schicht realisiert. Der Wert dieses Pseudoeingangs ist immer 1. Weisen wir diesem Eingang die Nummer 0 zu. Dann:

f8
(8)

wobei w0 = -T, x0 = 1.

Bei Algorithmen des überwachten Lernens haben wir Trainingsdaten, die zuvor von einem menschlichen Experten aufbereitet und markiert wurden. In diesen Daten sind die gewünschten Ausgangsvektoren mit den Eingangsvektoren verknüpft.

Der Trainingsprozess wird in den folgenden Phasen durchgeführt.

1. Initialisierung der Elemente der Gewichtsmatrix (normalerweise kleine Zufallswerte).

2. Geben Sie einen der Vektoren ein und berechnen Sie die Reaktion des Netzes — dies ist die Forwardpropagation des Signals; diese Phase wird auch beim normalen Betrieb eines trainierten Netzes verwendet.

3. Berechnen Sie die Differenz zwischen den idealen und den erzeugten Ausgangswerten, um den Netzwerkfehler zu ermitteln, und passen Sie dann die Gewichte nach einer Formel in Abhängigkeit von diesem Fehler an.

4. Fahren Sie in der Schleife ab Schritt 2 für alle Eingangsvektoren des Datensatzes fort, bis der Fehler auf das angegebene Minimum oder darunter reduziert ist (erfolgreicher Abschluss des Trainings) oder bis die vordefinierte maximale Anzahl von Trainingsschleifen erreicht ist (das neuronale Netz ist fehlgeschlagen).

Bei einem einschichtigen Netz ist die Formel zur Anpassung der Gewichte recht einfach:

f9
(9)
f10
(10)

wobei δ der Netzfehler ist (Differenz zwischen der Netzantwort und dem Ideal), t und t+1 die Nummern der aktuellen und der nächsten Iteration sind; ν die Lernrate ist, 0<ν<1; i der Eingangsindex ist; j der Index des Neurons in der Schicht ist.

Was aber, wenn es sich um ein mehrschichtiges Netz handelt? An dieser Stelle kommt die Idee der Backpropagation ins Spiel.


Der Backpropagation-Algorithmus

Eine der bekanntesten Strukturen neuronaler Netze ist die Mehrschichtstruktur, bei der jedes Neuron einer bestimmten Schicht mit allen Neuronen der vorhergehenden Schicht oder, wenn es sich um die erste Schicht handelt, mit allen Netzeingängen verbunden ist. Solche neuronalen Netze werden als vollständig verbunden bezeichnet. Für diese Struktur gibt es weitere Erläuterungen. In vielen anderen Arten von Neuronalen Netzen, insbesondere in Faltungs-NS, werden Verbindungen zwischen begrenzten Bereichen von Schichten, den so genannten Kernen, hergestellt, was die Adressierung der Netzelemente erschwert, aber die Anwendbarkeit der Rückverfolgungsmethode nicht beeinträchtigt.

Natürlich sollte die Information über den Fehler in irgendeiner Weise von den Ausgängen des Netzes zu seinen Eingängen weitergeleitet werden, wobei alle Schichten schrittweise durchlaufen werden und die „Leitfähigkeit“ der Schichten, d. h. die Gewichte, berücksichtigt werden.

Nach der Methode der kleinsten Quadrate ist die Zielfunktion des zu minimierenden Netzfehlers der folgende Wert:

f11
(11)

wobei yjpᴺ der reale Ausgangszustand des Neurons j aus der Ausgabeschicht N ist, wenn das p-te Bild eingegeben wird; djp ist der ideale (gewünschte) Ausgangszustand dieses Neurons.

Die Summierung wird für alle Neuronen der Ausgabeschicht und über alle verarbeiteten Bilder durchgeführt. Die Rate 1/2 wird nur hinzugefügt, um eine schöne Ableitung von E zu erhalten (Zweiergruppen werden reduziert), die weiter für das Training verwendet wird (siehe Gleichung (12)) und die in jedem Fall über einen wichtigen Parameter des Algorithmus gewichtet wird — die Rate (die verdoppelt oder dynamisch je nach einigen Bedingungen geändert werden kann).

Eine der effektivsten Methoden, eine Funktion zu minimieren, beruht auf folgendem Prinzip: Die besten lokalen Richtungen zu den Extrema geben die Ableitungen dieser Funktion an einem bestimmten Punkt an. Eine positive Ableitung führt zum Maximum, eine negative Ableitung zum Minimum. Natürlich können sich das Maximum und das Minimum als lokal erweisen, und es können zusätzliche Tricks erforderlich sein, um zum globalen Minimum zu gelangen, aber stellen wir dieses Problem vorerst einmal zurück.

Die beschriebene Methode ist die Gradientenabstiegsmethode. Demnach werden die Gewichte auf der Grundlage der E-Ableitung wie folgt angepasst:

f12
(12)

Dabei ist wij das Gewicht der Verbindung zwischen dem i-ten Neuron der Schicht n-1 und dem j-ten Neuron der Schicht n ist die Lernrate.

Kehren wir zur internen Struktur des Neurons zurück und ordnen wir auf ihrer Grundlage jede Berechnungsstufe der Formel (12) einer partiellen Ableitung zu:

f13
(13)

Wie zuvor ist yj die Ausgabe des Neurons j, während sj die gewichtete Summe seiner Eingangssignale ist, d. h. das Argument der Aktivierungsfunktion. Da der Faktor dyj/dsj die Ableitung dieser Funktion ist, ergibt sich daraus die Anforderung, dass die Aktivierungsfunktion für die Verwendung im betrachteten Backpropagation-Algorithmus auf der gesamten x-Achse differenzierbar sein muss.

Zum Beispiel im Fall des hyperbolischen Tangens:

f14
(14)

Der dritte Faktor in (13) ∂sj/∂wij ist gleich des Ausgabeneurons yi der vorherigen Schicht (n-1). Warum? In einem mehrschichtigen Netz geht das Signal vom Ausgang des Neurons der vorherigen Schicht zum Eingang des Neurons der aktuellen Schicht. Daher kann die Formel (1) für sj in allgemeinerer Form wie folgt umgeschrieben werden:

f15
(15)

wobei M die Anzahl der Neuronen in der Schicht n-1 ist, unter Berücksichtigung des Neurons mit einem konstanten Ausgangszustand +1, das den Offset festlegt; yi(n-1)=xij(n) ist der i-te Eingang des Neurons j der Schicht n, der mit dem Ausgang des i-ten Neurons der (n-1)-ten Schicht verbunden ist;

Was den ersten Multiplikator in (13) betrifft, so ist es logisch, ihn über die Fehlerinkremente in der benachbarten, älteren Schicht zu zerlegen (da sich die Fehlerwerte rückwärts fortpflanzen):

f16
(16)

Hier wird die Summierung für k unter den Neuronen der Schicht n+1 durchgeführt.

Die ersten beiden Faktoren in (13) für eine Schicht (mit Neuronenindizes j) werden in (16) für die nächste Schicht (mit Indizes k) als Koeffizient vor dem Gewicht wjk wiederholt.

Wir führen eine Zwischenvariable ein, die diese beiden Faktoren umfasst:

f17
(17)

Daraus ergibt sich eine rekursive Formel zur Berechnung von δj(n) der Schicht n unter Verwendung der Werte δk(n+1) der höheren Schicht n+1.

f18
(18)

Eine neue Variable für die Ausgabeschicht wird, wie zuvor, auf der Grundlage der Differenz zwischen dem erzielten und dem gewünschten Ergebnis berechnet.

f19
(19)

Im Vergleich zu (9) haben wir hier eine Ableitung der Aktivierungsfunktion. Beachten Sie, dass in der Ausgabeschicht eines Netzes je nach Aufgabe die Aktivierungsfunktion nicht vorhanden sein kann.

Jetzt können wir die Erweiterung der Formel (12) schreiben, um die Gewichte im Lernprozess anzupassen:

f20
(20)

Um dem Prozess der Gewichtsanpassung eine gewisse Trägheit zu verleihen, die starke Sprünge in der Ableitung beim Bewegen über die Oberfläche der Zielfunktion glättet, wird Formel (20) manchmal mit dem Gewicht der Gewichtsänderung bei der vorherigen Iteration ergänzt:

f21
(21)

wobei µ der Trägheitskoeffizient und t die Nummer der aktuellen Iteration ist.

Der vollständige Trainingsalgorithmus für das neuronale Netz, der das Backpropagation-Verfahren verwendet, ist also wie folgt aufgebaut:

1. Initialisierung der Gewichtsmatrizen mit kleinen Zufallszahlen.

2. Eingabe eines der Datenvektoren in die Netzeingänge und im normalen Funktionsmodus, wenn sich die Signale von den Eingängen zu den Ausgängen ausbreiten, Berechnung des gesamten NN-Ergebnisses Schicht für Schicht unter Verwendung der Formeln der gewichteten Summierung (15) und der Aktivierung f:

f22
(22)

Hier werden die Neuronen der Eingangsschicht Null nur zur Einspeisung von Eingangssignalen verwendet und haben keine Synapsen und Aktivierungsfunktionen.

f23
(23)

lq ist die q-te Komponente des in die Schicht Null eingespeisten Eingangsvektors.

3. Wenn der Netzwerkfehler kleiner als der angegebene kleine Wert ist, wird der Prozess mit Erfolg beendet. Wenn der Fehler signifikant ist, fahren wir mit den nächsten Schritten fort.

4. Berechnen der Ausgabeschicht N: δ mit Hilfe der Formel (19), sowie Wertänderungen Δw mit Hilfe der Formeln (20) oder (21).

5. Für alle anderen Schichten in umgekehrter Reihenfolge, n=N-1,...1, werden δ und Δw mit den Formeln (18) und (20) (bzw. (18) und (21)) berechnet.

6. Anpassung aller Gewichte im NN für Iteration t auf der Grundlage der vorherigen Iteration t-1.

f24
(24)

7. Wiederholen des Vorgangs in einer Schleife ab Schritt 2.

Das Diagramm der Signale im Netz, das mit dem Backpropagation-Algorithmus trainiert wird, ist in der folgenden Abbildung dargestellt.

Signale im Backpropagation-Algorithmus

Signale im Backpropagation-Algorithmus

Alle Trainingsbilder werden abwechselnd in das Netz eingespeist, damit es nicht eines „vergisst“, während es sich andere einprägt. Normalerweise geschieht dies in einer zufälligen Reihenfolge, aber da wir die Daten in Matrizen unterbringen und sie als einen einzigen Satz berechnen, werden wir ein weiteres Zufallselement in unsere Implementierung einführen, auf das wir etwas später eingehen werden.

Die Verwendung von Matrizen bedeutet, dass die Gewichte aller Schichten sowie die Eingabe- und Ziel-Trainingsdaten durch Matrizen dargestellt werden. Daher werden die obigen Formeln und dementsprechend auch die Algorithmen eine Matrixform erhalten. Mit anderen Worten, wir können nicht mit getrennten Vektoren von Eingabe- und Regenerationsdaten arbeiten, während die gesamte Schleife von Schritt 2 bis 7 sofort für den gesamten Datensatz berechnet wird. Eine solche Schleife wird als Lernepoche bezeichnet.


Übersicht der Aktivierungsfunktionen

Im Anhang des Artikels enthält das Skript AF.mq5, das im Diagramm Miniaturansichten aller in MQL5 unterstützten Aktivierungsfunktionen (in blau) und ihrer Ableitungen (in rot) anzeigt. Das Skript skaliert die Miniaturansichten automatisch, damit alle Funktionen in das Fenster passen. Wenn Sie detaillierte Bilder benötigen, empfehle ich, das Fenster zu maximieren. Ein Beispiel für ein vom Skript erzeugtes Bild ist unten abgebildet.

Die richtige Wahl einer Aktivierungsfunktion hängt vom NN-Typ und dem Problem ab. Außerdem können in einem Netz mehrere verschiedene Aktivierungsfunktionen verwendet werden. SoftMax unterscheidet sich zum Beispiel von anderen Funktionen dadurch, dass es die Ausgabewerte der Schicht nicht elementweise, sondern in gegenseitiger Verbindung verarbeitet: Sie normalisiert sie, sodass die Werte als Wahrscheinlichkeiten interpretiert werden können (ihre Summe ist 1), was bei der Mehrfachklassifizierung verwendet wird.

Dieses Thema ist sehr umfangreich und erfordert einen eigenen Artikel oder eine Reihe von Artikeln. Vorerst sollten Sie nur beachten, dass alle Funktionen sowohl Vor- als auch Nachteile haben, die unter Umständen zu einem Ausfall des Netzes führen können. Insbesondere S-förmige Funktionen sind durch das Problem des „verschwindenden Gradienten“ gekennzeichnet, wenn die Signale beginnen, auf die Sättigungsabschnitte der S-Kurve zu fallen und daher die Anpassung der Gewichte gegen Null tendiert). Bei monoton ansteigenden Funktionen besteht das Problem des explosionsartigen Wachstums des Gradienten („explodierender Gradient“, da die Gewichte ständig zunehmen, was zu numerischem Überlauf und NaN (Not A Number) führt). Je mehr Schichten das Netz hat, desto wahrscheinlicher sind diese beiden Probleme. Es gibt verschiedene Techniken zur Lösung dieser Probleme, z. B. Datennormalisierung (sowohl der Eingabe- als auch der Zwischenschichten), Netzausdünnungsalgorithmen („Dropout“), Batch-Lernen, Rauschen und andere Regulierungsmethoden. Wir werden einige von ihnen näher besprechen.

Demo-Skript mit allen Aktivierungsfunktionen

Demo-Skript mit allen Aktivierungsfunktionen


Implementierung eines neuronalen Netzes in der Klasse MatrixNet

Beginnen wir mit dem Schreiben einer neuronalen Netzwerkklasse auf der Grundlage von MQL5-Matrizen. Da das Netzwerk aus Schichten besteht, beschreiben wir Arrays von Gewichten und Ausgabewerten der Neuronen jeder Schicht. Die Anzahl der Schichten wird in der Variablen n gespeichert, während die Gewichte der Neuronen und die Signale am Ausgang jeder Schicht in den Matrizen „weights“ und „outputs“ gespeichert werden. Bitte beachten Sie, dass sich „outputs“ auf Signale an den Ausgängen der Neuronen einer beliebigen Schicht beziehen, nicht nur auf den Ausgang des Netzes. Output[i] beschreibt also auch die Zwischenschichten und sogar die Nullschicht, in die die Eingabedaten geschrieben werden.

Die Indizierung der Felder „weights“ und „outputs“ ist im folgenden Diagramm dargestellt (die Verbindungen der einzelnen Neuronen mit der Quelle der +1-Verschiebung sind der Einfachheit halber nicht gezeigt):

Indizierung von Matrix-Arrays in einem zweischichtigen Netz

Indizierung von Matrix-Arrays in einem zweischichtigen Netz

Die Zahl n schließt die Eingabeschicht nicht ein, da diese Schicht keine Gewichte benötigt.

  class MatrixNet
  {
  protected:
     const int n;
     matrix weights[/* n */];
     matrix outputs[/* n + 1 */];
     ENUM_ACTIVATION_FUNCTION af;
     ENUM_ACTIVATION_FUNCTION of;
     double speed;
     bool ready;
     ...

Unser Netz unterstützt zwei Arten von Aktivierungsfunktionen (die vom Nutzer auszuwählen sind): eine für alle Schichten außer der Ausgabeschicht (gespeichert in der Variablen „af“) und eine separate für die Ausgabeschicht (gespeichert in der Variablen „of“). Die Variable „speed“ (Geschwindigkeit) speichert die Lernrate (der Koeffizient η aus Formel (20)).

Die Variable „ready“ (fertig) enthält einen Hinweis auf eine erfolgreiche Initialisierung des N-Objekts.

Der Netzkonstruktor empfängt das Integer-Array „layers“ (Schicht), das die Anzahl und Größe aller Schichten definiert. Das Nullelement legt die Größe der Eingabe-Pseudoschicht fest, d. h. die Anzahl der Merkmale in jedem Eingabevektor. Das letzte Element legt die Größe der Ausgabeschicht fest, während alle übrigen Elemente die versteckten Zwischenschichten definieren. Es müssen mindestens zwei Schichten vorhanden sein. Die zusätzliche Methode ‚allocate‘ wurde geschrieben, um Speicher für Matrix-Arrays zuzuweisen (wir werden sie mit der Erweiterung der Klasse weiter ausbauen).

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 1; i <= n; ++i)
        {
           // NB: the weights matrix is transposed, i.e. indexes [row][column] specify [synapse][neuron]
           weights[i - 1].Init(layers[i - 1] + 1, layers[i]);
        }
        ...
     }
        
  protected:
     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ...
     }

Zur Initialisierung jeder Gewichtsmatrix wird die Größe der vorherigen Schicht layers[i - 1] als Anzahl der Zeilen genommen und eine Synapse für eine konstante einstellbare Offset-Quelle +1 hinzugefügt. Als Anzahl der Spalten wird die Größe der aktuellen Ebene layers[i] verwendet. In jeder Gewichtsmatrix bezieht sich der erste Index auf die Schicht links von der Matrix, der zweite auf die Schicht rechts davon.

Eine solche Nummerierung ermöglicht eine einfache Aufzeichnung der Multiplikation von Signalvektoren mit Schichtenmatrizen während der Forwardpropagation (normaler Netzbetrieb). Während des Backpropagation-Prozesses muss der Fehler (im Trainingsmodus) der Fehlervektor jeder höheren Schicht mit seiner transponierten Gewichtsmatrix multipliziert werden, um die Fehler für die untere Schicht neu zu berechnen.

Mit anderen Worten: Da sich die Informationen innerhalb des Netzes in zwei entgegengesetzte Richtungen bewegen — Arbeitssignale von den Eingängen zu den Ausgängen und Fehler von den Ausgängen zu den Eingängen — sollten die Gewichtungsmatrizen in einer dieser beiden Richtungen in der üblichen Form verwendet werden, während sie in der zweiten Richtung transponiert werden sollten. Für die normale Konfiguration verwenden wir die Matrixmarkierung, die die Berechnung des direkten Signals erleichtert.

Die „output“-Matrizen werden direkt gefüllt, wenn das Signal das Netz durchläuft. Die Gewichte hingegen sollten nach dem Zufallsprinzip initialisiert werden: Dazu wird am Ende des Konstruktors die Methode „randomize“ aufgerufen.

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        ...
        ready = true;
        randomize();
     }
     
     // NB: set values with appropriate distribution for specific activation functions
     void randomize(const double from = -0.5, const double to = +0.5)
     {
        if(!ready) return;
        
        for(int i = 0; i < n; ++i)
        {
           weights[i].Random(from, to);
        }
     }

Das Vorhandensein der Gewichtsmatrizen reicht aus, um den Feedforward-Durchgang vom Netzeingang zum Ausgang zu realisieren. Es ist kein großes Problem, dass die Gewichte noch nicht trainiert wurden, denn wir werden uns später mit dem Training beschäftigen.

     bool feedForward(const matrix &data)
     {
        if(!ready) return false;
        
        if(data.Cols() != weights[0].Rows() - 1)
        {
           PrintFormat("Column number in data %d <> Inputs layer size %d",
              data.Cols(), weights[0].Rows() - 1);
           return false;
        }
        
        outputs[0] = data; // input the data to the network
        for(int i = 0; i < n; ++i)
        {
           // expand each layer (except the last one) with one neuron for the bias signal
           // (there is no weight matrix to the right of the last layer, since the signal does not go further)
           if(!outputs[i].Resize(outputs[i].Rows(), weights[i].Rows()) ||
              !outputs[i].Col(vector::Ones(outputs[i].Rows()), weights[i].Rows() - 1))
              return false;
           // forward the signal from i-th layer to the (i+1)-th layer: weighted sum
           matrix temp = outputs[i].MatMul(weights[i]);
           // apply the activation function, the result is received into outputs[i + 1]
           if(!temp.Activation(outputs[i + 1], i < n - 1 ? af : of))
              return false;
        }
        
        return true;
     }

Die Anzahl der Spalten in der Eingabematrix muss mit der Anzahl der Zeilen in der Nullgewichtsmatrix minus 1 übereinstimmen (Gewichtung des Bias-Signals).

Um das Ergebnis der regulären Netzwerkoperation zu lesen, verwenden wir die Methode „getResults“. Standardmäßig wird die Zustandsmatrix der Ausgabeschicht zurückgegeben.

     matrix getResults(const int layer = -1) const
     {
        static const matrix empty = {};
        if(!ready) return empty;
        
        if(layer == -1) return outputs[n];
        if(layer < -1 || layer > n) return empty;
        
        return outputs[layer];
     }

Wir können die aktuelle Qualität des Modells mit der „Test“-Methode bewerten, indem wir nicht nur die Eingabedatenmatrix, sondern auch die Matrix mit dem gewünschten Netzverhalten einspeisen.

     double test(const matrix &data, const matrix &target, const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     { 
        if(!ready || !feedForward(data)) return NaN();
        
        return outputs[n].Loss(target, lf);
     }

Nach dem Vorwärtsdurchlauf mit der feedForward-Methode wird hier der „loss“ (Verlust) des gegebenen Typs berechnet. Standardmäßig ist dies der mittlere quadratische Fehler (LOSS_MSE), der für Regressions- und Vorhersageprobleme geeignet ist. Wenn das Netz jedoch für die Bildklassifizierung verwendet werden soll, sollten wir eine andere Art der Bewertung verwenden, z. B. die LOSS_CCE-Kreuzentropie.

Wenn ein Berechnungsfehler auftritt, gibt die Methode NaN (keine Zahl) zurück.

Kommen wir nun zur Backpropagation. Die Methode backProp beginnt ebenfalls mit der Überprüfung, ob die Größen der Zieldaten und der Ausgabeschicht übereinstimmen. Es berechnet die Ableitung der Aktivierungsfunktion für die Ausgabeschicht (falls vorhanden) und den „Verlust“ des Netzes am Ausgang relativ zu den Zieldaten.

     bool backProp(const matrix &target)
     {
        if(!ready) return false;
     
        if(target.Rows() != outputs[n].Rows() ||
           target.Cols() != outputs[n].Cols())
           return false;
        
        // output layer
        matrix temp;
        if(!outputs[n].Derivative(temp, of))
           return false;
        matrix loss = (outputs[n] - target) * temp; // all data line by line

Die Verlustmatrix enthält die Werte δ aus der Formel (19).

Anschließend wird die folgende Schleife für alle Schichten außer der Ausgangsschicht ausgeführt:

        for(int i = n - 1; i >= 0; --i) // all layers except the output in reverse order
        {
           // remove pseudo-losses in the last element which we added as an offset source
           // since it is not a neuron and further error propagation is not applicable to it
           // (we do it in all layers except the last one where the shift element was not added)
           if(i < n - 1) loss.Resize(loss.Rows(), loss.Cols() - 1);
           
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);

Hier sehen wir die exakte Formel (20): wir erhalten Gewichtszunahmen auf der Grundlage der Lernrate ηδ der aktuellen Schicht und der relevanten Ausgaben der vorherigen (unteren) Schicht.

Als Nächstes berechnen wir für jede Schicht die Formel (18), um rekursiv die restlichen die Werte δ zu erhalten: Wir verwenden wieder die Ableitung der Aktivierungsfunktion und die Multiplikation des höheren δ mit der transponierten Gewichtsmatrix. Der Index i in der Matrix outputs[] entspricht der Schicht mit den Gewichten in der (i-1)-ten Matrix weights[], da die Eingabe-Pseudo-Schicht (outputs[0]) keine Gewichte hat. Mit anderen Worten: Bei der Forwardpropagation wird die Matrix weights[0] auf outputs[0] angewendet und erzeugt outputs[1]; weights[1] erzeugt outputs[2] usw. Im Gegensatz dazu sind bei der Backpropagation die Indizes identisch: So werden beispielsweise die outputs[2] (nach der Differenzierung) mit den transponierten weights[2] multipliziert.

           if(!outputs[i].Derivative(temp, af))
              return false;
           loss = loss.MatMul(weights[i].Transpose()) * temp;

Nach der Berechnung des „Verlusts“ δ für die untere Schicht können wir die weights[i] der Matrix anpassen, indem wir sie um das zuvor erhaltene Delta korrigieren.

           weights[i] -= delta;
        }
        return true;
     }

Jetzt sind wir fast so weit, einen vollständigen Lernalgorithmus mit einer Schleife über Epochen und den Methodenaufrufen FeedForward und BackProp zu implementieren. Wir müssen jedoch zunächst auf einige theoretische Nuancen zurückkommen, die wir bisher zurückgestellt haben.


Training und Regulierung

Das NN wird mit den aktuell verfügbaren Trainingsdaten trainiert. Die Netzkonfiguration (Anzahl der Schichten, Anzahl der Neuronen in den Schichten usw.), die Lernrate und andere Merkmale werden auf der Grundlage dieser Daten ausgewählt. Daher ist es immer möglich, ein Netz zu bauen, das leistungsfähig genug ist, um einen ausreichend kleinen Fehler in den Trainingsdaten zu erzeugen. Der eigentliche Zweck der Verwendung eines neuronalen Netzes besteht jedoch darin, dass es bei zukünftigen unbekannten Daten (mit denselben impliziten Abhängigkeiten wie im Trainingsdatensatz) gute Leistungen erbringt.

Der Effekt, wenn ein trainiertes neuronales Netz bei den Trainingsdaten zu gut abschneidet, aber beim Vorwärtstest versagt, wird als Überanpassung bezeichnet. Diese Auswirkung sollte auf jede erdenkliche Weise vermieden werden. Um eine Überanpassung zu vermeiden, können wir die Regulierung verwenden. Dies setzt die Einführung einiger zusätzlicher Bedingungen voraus, die die Fähigkeit des Netzes zur Generalisierung bewerten. Es gibt viele verschiedene Möglichkeiten der Regulierung, vor allem:

  • Analyse der Leistung des trainierten Netzes auf einem zusätzlichen Validierungsdaten (anders als der Trainingsdatensatz)
  • Zufälliges Verwerfen eines Teils der Neuronen oder Verbindungen während des Trainings
  • Netzwerkbeschneidung nach dem Training
  • Einbringen von Rauschen in die Eingangsdaten
  • Künstliche Datenreproduktion
  • Schwache konstante Abnahme der Amplitude der Gewichte während des Trainings
  • Experimentelle Auswahl des Volumens und der Feinkonfiguration des Netzes, wenn das Netz noch in der Lage ist zu lernen, aber die verfügbaren Daten nicht übermäßig anpasst

Wir werden einige von ihnen in unserem Klasse einsetzen.

Zunächst werden wir nicht nur die Eingabe von Eingabe- und Ausgabe-Trainingsdaten (Parameter „data“ bzw. „target“) in die Trainingsmethode ermöglichen, sondern auch die Eingabe der Validierungsdaten (der ebenfalls aus Eingabe- und relevanten Ausgabevektoren besteht: „validation“ und „check“).

Mit fortschreitendem Training nimmt der Netzwerkfehler bei den Trainingsdaten normalerweise recht monoton ab (ich habe „normalerweise“ verwendet, weil der Prozess instabil werden kann, wenn die Lernrate oder die Netzwerkkapazität falsch gewählt wird). Berechnet man jedoch den Netzwerkfehler auf dem Validierungsdaten entlang dieses Prozesses, so wird er zunächst abnehmen (während das Netzwerk die wichtigsten Muster in den Daten aufdeckt) und dann mit der Überanpassung zunehmen (wenn sich das Netzwerk an die besonderen Merkmale des Trainingsdaten, aber nicht an den Validierungsdaten anpasst). Daher sollte der Lernprozess gestoppt werden, wenn der Validierungsfehler zu steigen beginnt. Dies ist der Ansatz des „frühen Aufhörens“.

Die Methode „train“ ermöglicht neben zwei Datensätzen die Angabe der maximalen Anzahl von Trainingsepochen, der gewünschten Genauigkeit (d.h. des durchschnittlichen Mindestfehlers, der akzeptabel ist: in diesem Fall endet das Training auch mit einer Erfolgsmeldung) und der Fehlerberechnungsmethode (lf) .

Die Lernrate („speed“) ist gleich „accuracy“, aber sie können unterschiedlich eingestellt werden, um die Flexibilität der Einstellungen zu erhöhen. Der Grund dafür ist, dass die Rate automatisch angepasst wird und der anfängliche Näherungswert daher nicht so wichtig ist.

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        if(!ready) return NaN();
        
        speed = accuracy;
        ...

Wir speichern die aktuellen Epochen-Fehlerwerte des Netzwerks in den Variablen mse und msev, für die Trainings- und Validierungsdaten. Um die Reaktion auf unvermeidliche Zufallsschwankungen auszuschließen, müssen wir die Fehler über einen bestimmten Zeitraum p mitteln, der aus der Gesamtzahl der Epochen berechnet wird. Die geglätteten Fehlerwerte werden in den Variablen msema und msevma gespeichert, und ihre vorherigen Werte werden in den Variablen msemap und msevmap gespeichert.

        double mse = DBL_MAX;
        double msev = DBL_MAX;
        double msema = 0;       // MSE averaging of the training set
        double msemap = 0;      // MSE averaging of the training set in the previous epoch
        double msevma = 0;      // MSE averaging of the validation dataset
        double msevmap = 0;     // MSE averaging of the validation dataset in the previous epoch
        double ema = 0;         // exponential smoothing factor
        int p = 0;              // EMA period
        
        p = (int)sqrt(epochs);  // empirically choose the period of the EMA averaging of errors
        ema = 2.0 / (p + 1);
        PrintFormat("EMA for early stopping: %d (%f)", p, ema);

Als Nächstes führen wir eine Schleife von Trainingsepochen durch. Wir verzichten auf die Bereitstellung von Validierungsdaten, da wir später eine andere Regulierungsmethode, Dropout, implementieren werden. Wenn es Validierungsdaten gibt, berechnen wir msev, indem wir die Methode „test“ für diesen Satz aufrufen. In jedem Fall berechnen wir mse, indem wir „test“ für die Trainingsmenge aufrufen. Der „test“ ruft die FeedForward-Methode auf und berechnet den Fehler des Netzergebnisses relativ zu den Zielwerten.

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           if(validation.Rows() && check.Rows())
           {
              // if there is validation, run it before normal pass/training
              msev = test(validation, check, lf);
              // smooth errors
              msevma = (msevma ? msevma : msev) * (1 - ema) + ema * msev;
           }
           mse = test(data, target, lf);  // enable feedForward(data) run
           msema = (msema ? msema : mse) * (1 - ema) + ema * mse;
           ...

Zunächst wird geprüft, ob der Fehlerwert eine gültige Zahl ist. Andernfalls ist das Netz ‚übergelaufen‘, oder es wurden falsche Daten eingegeben.

           if(!MathIsValidNumber(mse))
           {
              PrintFormat("NaN at epoch %d", ep);
              break; // will return NaN as error indication
           }

Wenn der neue Fehler mit einer gewissen „tolerance“, die sich aus dem Verhältnis der Größen der Trainings- und Validierungsdatensätze ergibt, größer geworden ist als der vorherige, wird die Schleife unterbrochen.

           const int scale = (int)(data.Rows() / (validation.Rows() + 1)) + 1;
           if(msevmap != 0 && ep > p && msevma > msevmap + scale * (msemap - msema))
           {
              // skip the first p epochs to accumulate values for averaging
              PrintFormat("Stop by validation at %d, v: %f > %f, t: %f vs %f", ep, msevma, msevmap, msema, msemap);
              break;
           }
           msevmap = msevma;
           msemap = msema;
           ...

Wenn der Fehler weiter abnimmt oder nicht wächst, speichern wir neue Fehlerwerte, um sie mit dem Ergebnis der nächsten Epoche zu vergleichen.

Wenn der Fehler die geforderte Genauigkeit erreicht hat, gilt das Training als abgeschlossen und wir verlassen die Schleife.

           if(mse <= accuracy)
           {
              PrintFormat("Done by accuracy limit %f at epoch %d", accuracy, ep);
              break;
           }

Außerdem wird in der Schleife die virtuelle Methode „progress“ aufgerufen, die in abgeleiteten Klassen des Netzes außer Kraft gesetzt werden kann. Das kann verwendet werden, um das Training als Reaktion auf bestimmte Nutzeraktionen zu unterbrechen. Die Standardimplementierung von „progress“ wird später gezeigt.

           if(!progress(ep, epochs, mse, msev, msema, msevma))
           {
              PrintFormat("Interrupted by user at epoch %d", ep);
              break;
           }

Wenn die Schleife nicht durch eine der oben genannten Bedingungen unterbrochen wurde, starten wir den Backpropagation-Prozess mit backProp.

           if(!backProp(target))
           {
              mse = NaN(); // error flag
              break;
           }
        }
        
        if(ep == epochs)
        {
           PrintFormat("Done by epoch limit %d with accuracy %f", ep, mse);
        }
        
        return mse;
     }

Die Standardmethode „progress“ protokolliert Lernmetriken einmal pro Sekunde.

     virtual bool progress(const int epoch, const int total,
        const double error, const double valid = DBL_MAX,
        const double ma = DBL_MAX, const double mav = DBL_MAX)
     {
        static uint trap;
        if(GetTickCount() > trap)
        {
           PrintFormat("Epoch %d of %d, loss %.5f%s%s%s", epoch, total, error,
              ma == DBL_MAX ? "" : StringFormat(" ma(%.5f)", ma),
              valid == DBL_MAX ? "" : StringFormat(", validation %.5f", valid),
              valid == DBL_MAX ? "" : StringFormat(" v.ma(%.5f)", mav));
           trap = GetTickCount() + 1000;
        }
        return !IsStopped();
     }

Wenn ‚true‘ zurückgegeben wird, wird das Training fortgesetzt, während ‚false‘ zum Abbruch der Schleife führt.

Zusätzlich zur „vorzeitigen Beendigung“ kann die MatrixNet-Klasse einige der Verbindungen nach dem Zufallsprinzip deaktivieren, ähnlich wie beim Dropout.

Bei der traditionellen Dropout-Methode werden zufällig ausgewählte Neuronen vorübergehend aus dem Netz ausgeschlossen. Die Umsetzung wäre jedoch kostspielig, da der Algorithmus Matrixoperationen verwendet. Um Neuronen aus der Schicht auszuschließen, müssten wir die Gewichtsmatrizen bei jeder Iteration neu formatieren und teilweise kopieren. Es ist viel einfacher und effizienter, die Zufallsgewichte auf 0 zu setzen, wodurch die Verbindungen unterbrochen werden. Natürlich muss das Programm zu Beginn jeder Epoche die vorübergehend deaktivierten Gewichte wieder in den vorherigen Zustand versetzen und dann zufällig neue Gewichte auswählen, die in der nächsten Epoche deaktiviert werden.

Die Anzahl der vorübergehend zurückgesetzten Verbindungen wird mit der Methode enableDropOut als Prozentsatz der Gesamtanzahl der Netzgewichte festgelegt. Standardmäßig ist die Variable dropOutRate 0, sodass der Modus deaktiviert ist.

     void enableDropOut(const uint percent = 10)
     {
        dropOutRate = (int)percent;
     }

Das Dropout-Prinzip besteht darin, den aktuellen Zustand der Gewichtsmatrizen in einem zusätzlichen Speicher zu sichern (dies wird durch die Klasse DropOutState implementiert) und zufällig ausgewählte Netzwerkverbindungen zurückzusetzen. Nach dem Training des Netzes in der so veränderten Form für eine Epoche werden die zurückgesetzten Matrixelemente aus dem Speicher wiederhergestellt, und das Verfahren wird wiederholt: andere Zufallsgewichte werden ausgewählt und zurückgesetzt, das Netz wird mit ihnen trainiert, und so weiter. Ich schlage vor, dass Sie selbst herausfinden, wie DropOutState funktioniert.


Adaptive Lernrate

Bisher wurde davon ausgegangen, dass wir eine konstante Lernrate (die Variable „speed“) verwenden, was jedoch nicht praktikabel ist (das Lernen kann bei niedrigen ‚Geschwindigkeiten‘ sehr langsam oder bei hohen „überreizt“ sein).

Eine der Formen der Lernratenanpassung wird in einer speziellen Modifikation des Backpropagation-Algorithmus verwendet. Es wird „rprop“ (Resilient Propagation) genannt. Der Algorithmus prüft für jedes Gewicht, ob das Vorzeichen der Delta-Inkremente bei der vorherigen und der aktuellen Iteration gleich ist. Wenn die Vorzeichen gleich sind, bleibt die Richtung der Steigung erhalten, und in diesem Fall kann die Geschwindigkeit für das gegebene Gewicht selektiv erhöht werden. Bei den Gewichten, bei denen sich das Vorzeichen der Steigung geändert hat, kann es besser sein, langsamer zu fahren.

Da die Matrizen alle Daten in jeder Epoche auf einmal berechnen, akkumulieren Wert und Vorzeichen des Gradienten für jedes Gewicht das Verhalten des gesamten Datensatzes (und bilden den Durchschnitt). Daher wird die Technologie genauer als „batch rprop“ bezeichnet.

Alle Codezeilen in der Klasse MatrixNet, die diese Erweiterung implementieren, werden mit den Makros BATCH_PROP bereitgestellt. Bevor Sie die Header-Datei MatrixNet.mqh in Ihren Quellcode einbinden, sollten Sie die adaptive Rate mit der folgenden Direktive aktivieren:

  #define BATCH_PROP

Achten Sie darauf, dass dieser Modus anstelle der Variablen „speed“ ein Array von „speed“-Matrizen verwendet. Außerdem müssen wir die Gewichtszunahmen der letzten Epoche in einem Array von „deltas“-Matrizen speichern.

  class MatrixNet
  {
  protected:
     ...
     #ifdef BATCH_PROP
     matrix speed[];
     matrix deltas[];
     #else
     double speed;
     #endif

Die Beschleunigungs- und Verzögerungskoeffizienten sowie die Höchst- und Mindestgeschwindigkeiten werden in 4 zusätzlichen Variablen festgelegt.

     double plus;
     double minus;
     double max;
     double min;

Wir weisen den neuen Arrays Speicher zu und setzen Standardvariablenwerte in der bereits bekannten Methode „allocate“.

     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ArrayResize(bestWeights, n);
        dropOutRate = 0;
        #ifdef BATCH_PROP
        ArrayResize(speed, n);
        ArrayResize(deltas, n);
        plus = 1.1;
        minus = 0.1;
        max = 50;
        min = 0.0;
        #endif
     }

Um andere Werte für diese Variablen festzulegen, bevor wir mit dem Training beginnen, verwenden wir die Methode setupSpeedAdjustment.

Im MatrixNet-Konstruktor werden die Matrizen „speed“ und „deltas“ initialisiert, indem das Array der „weight“-Matrizen kopiert wird — dies ist ein bequemerer Weg, um Matrizen gleicher Größe entlang der Netzwerkschichten zu erhalten. In den nächsten Schritten werden dann „speed“ und „deltas“ mit aussagekräftigen Daten gefüllt. Zu Beginn der „train“-Methode wird die Genauigkeit nicht einfach der Skalarvariablen „speed“ zugewiesen, sondern dieser Wert wird verwendet, um alle Matrizen im Array „speed“ zu füllen.

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        #ifdef BATCH_PROP
        for(int i = 0; i < n; ++i)
        {
           speed[i].Fill(accuracy); // adjust speeds on the fly
           deltas[i].Fill(0);
        }
        #else
        speed = accuracy;
        #endif
        ...
     }

Innerhalb der backProp-Methode bezieht sich der Inkrement-Ausdruck jetzt auf die Matrix der entsprechenden Schicht und nicht mehr auf einen Skalar. Unmittelbar nach Erhalt der ‚delta‘-Inkremente rufen wir die Methode ‚adjustSpeed‘ (siehe unten) auf und übergeben ihr das Produkt aus ‚delta * deltas[i]‘, um die vorherige und die neue Richtung zu vergleichen. Schließlich werden die neuen Gewichtungsinkremente in „deltas[i]“ gespeichert, um sie in der nächsten Epoche zu analysieren.

     bool backProp(const matrix &target)
     {
        ...
        for(int i = n - 1; i >= 0; --i) // all layers except the output in reverse order
        {
           ...
           #ifdef BATCH_PROP
           matrix delta = speed[i] * outputs[i].Transpose().MatMul(loss);
           adjustSpeed(speed[i], delta * deltas[i]);
           deltas[i] = delta;
           #else
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);
           #endif
           ...
        }
        ...
     }

Die Methode adjustSpeed ist recht einfach. Ein positives Vorzeichen im Element des Matrixprodukts bedeutet, dass die Steigung erhalten bleibt und die Geschwindigkeit um das „Plus“-Zeichen steigt, aber nicht mehr als der „Max“-Wert. Ein negatives Vorzeichen bedeutet eine Änderung des Gradienten, und die Geschwindigkeit nimmt um den Faktor „minus“ ab, kann aber nicht kleiner als „min“ sein.

     void adjustSpeed(matrix &subject, const matrix &product)
     {
        for(int i = 0; i < (int)product.Rows(); ++i)
        {
           for(int j = 0; j < (int)product.Cols(); ++j)
           {
              if(product[i][j] > 0)
              {
                 subject[i][j] *= plus;
                 if(subject[i][j] > max) subject[i][j] = max;
              }
              else if(product[i][j] < 0)
              {
                 subject[i][j] *= minus;
                 if(subject[i][j] < min) subject[i][j] = min;
              }
           }
        }
     }


Speichern und Wiederherstellen des besten Zustands des trainierten Netzes

Das Netz wird also in einer Schleife trainiert, und zwar in Iterationen, die „Epochen“ genannt werden: In jeder Epoche durchlaufen alle Vektoren des Trainingsdatensatzes das Netz und werden in eine Matrix eingeordnet, in der die Datensätze in Zeilen und ihre Vorzeichen in Spalten angeordnet sind. So kann beispielsweise jeder Datensatz einen Kursbalken speichern, während in den Spalten OHLC-Kurse und Volumen gespeichert werden können.

Obwohl der Prozess der Gewichtsanpassung entlang eines Gradienten erfolgt, ist er zufällig in dem Sinne, dass wir aufgrund der Ungleichmäßigkeit der Zielfunktion des zu lösenden Problems und der variablen Geschwindigkeit periodisch zu „schlechten“ Einstellungen gelangen können, bevor wir ein neues Minimum des Netzwerkfehlers finden. Wir haben keine Garantie, dass eine Erhöhung der Epochenzahl mit einer gewissen Verbesserung der Qualität des trainierten Modells und mit der Reduzierung des Netzwerkfehlers einhergeht.

In diesem Zusammenhang ist es sinnvoll, den Gesamtfehler des Netzes ständig zu überwachen: Wenn der Fehler nach der aktuellen Epoche das Minimum aktualisiert, sollten die gefundenen Gewichte gespeichert werden. Für diese Zwecke werden wir ein weiteres Array von Gewichtsmatrizen und die Struktur „Stats“ mit Lernmetriken verwenden.

  class MatrixNet
  {
     ...
  public:
     struct Stats
     {
        double bestLoss; // smallest error for all epochs
        int bestEpoch;   // index of the epoch with the minimum error
        int epochsDone;  // total number of completed epochs
     };
     
     Stats getStats() const
     {
        return stats;
     }
     
  protected:
     matrix bestWeights[];
     Stats stats;
     ...

Innerhalb der Train-Methode initialisieren wir die Struktur mit Statistiken, bevor wir die Schleife über die Epochen starten.

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        stats.bestLoss = DBL_MAX;
        stats.bestEpoch = -1;
        DropOutState state(dropOutRate);

Wenn innerhalb der Schleife ein Fehlerwert gefunden wird, der kleiner als der bekannte Mindestwert ist, speichern wir alle Gewichtsmatrizen in bestWeights.

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           ...
           const double candidate = (msev != DBL_MAX) ? msev : mse;
           if(candidate < stats.bestLoss)
           {
              stats.bestLoss = candidate;
              stats.bestEpoch = ep;
              // save best weights from 'weights'
              for(int i = 0; i < n; ++i)
              {
                 bestWeights[i].Assign(weights[i]);
              }
           }
        }
        ...

Nach dem Training ist es einfach, sowohl die endgültigen Netzgewichte als auch die besten Gewichte abzufragen.

     bool getWeights(matrix &array[]) const
     {
        if(!ready) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = weights[i];
        }
        
        return true;
     }
     
     bool getBestWeights(matrix &array[]) const
     {
        if(!ready) return false;
        if(!n || !bestWeights[0].Rows()) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = bestWeights[i];
        }
        
        return true;
     }

Diese Matrizen-Arrays können in einer Datei gespeichert werden, sodass wir später ein bereits trainiertes und einsatzbereites Netz wiederherstellen können. Dies wird in einem separaten Konstruktor durchgeführt.

     MatrixNet(const matrix &w[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(w))
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 0; i < n; ++i)
        {
           weights[i] = w[i];
           #ifdef BATCH_PROP
           speed[i] = weights[i];  // instead .Init(.Rows(), .Cols())
           deltas[i] = weights[i]; // instead .Init(.Rows(), .Cols())
           #endif
        }
        
        ready = true;
     }

Später werden wir an einem praktischen Beispiel sehen, wie man vorgefertigte Netze speichert und liest.


Visualisierung des Netzwerk-Trainingsfortschritts

Das Ergebnis der Methode „progress“, die periodische Protokolle ausgibt, ist nicht sehr klar. Daher implementiert die Datei MatrixNet.mqh auch die von MatrixNet abgeleitete Klasse MatrixNetVisual, die einen Graphen mit sich ändernden Trainingsfehlern nach Epochen anzeigt.

Die grafische Darstellung erfolgt über die Standardklasse CGraphic (verfügbar in MetaTrader 5) bzw. über eine davon abgeleitete kleine Klasse CMyGraphic.

Das Objekt dieser Klasse ist Teil von MatrixNetVisual. Außerdem gibt es im „visualisierten“ Netz ein Array mit 5 Kurven und Arrays vom Typ „double“, die für die angezeigten Linien bestimmt sind.

  class MatrixNetVisual: public MatrixNet
  {
     CMyGraphic graphic;
     CCurve *c[5];
     double p[], x[], y[], z[], q[], b[];
     ...

wobei:

  • p ist die Epochennummer (gemeinsame horizontale X-Achse für alle Kurven);
  • x ist der Fehler der Trainingsdaten (Y)
  • y ist der Fehler der Validierungsdaten (Y)
  • z ist der geglättete Validierungsfehler (Y)
  • q ist der geglättete Lernfehler (Y)
  • b ist der Punkt (Epoche) mit dem geringsten Fehler (Y)

  • Die Methode „graph“, die vom MatrixNetVisual-Konstruktor aufgerufen wird, erzeugt ein grafisches Objekt in der Größe des gesamten Fensters. Die fünf oben beschriebenen Kurven (CCurve) werden hier ebenfalls hinzugefügt.

       void graph()
       {
          ulong width = ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
          ulong height = ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
    
          bool res = false;
          const string objname = "BPNNERROR";
          if(ObjectFind(0, objname) >= 0) res = graphic.Attach(0, objname);
          else res = graphic.Create(0, objname, 0, 0, 0, (int)(width - 0), (int)(height - 0));
          if(!res) return;
    
          c[0] = graphic.CurveAdd(p, x, CURVE_LINES, "Training");
          c[1] = graphic.CurveAdd(p, y, CURVE_LINES, "Validation");
          c[2] = graphic.CurveAdd(p, z, CURVE_LINES, "Val.EMA");
          c[3] = graphic.CurveAdd(p, q, CURVE_LINES, "Train.EMA");
          c[4] = graphic.CurveAdd(p, b, CURVE_POINTS, "Best/Minimum");
          ...
       }
    
    public:
       MatrixNetVisual(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
          const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE): MatrixNet(layers, f1, f2)
       {
          graph();
       }
    
    

    In der überschriebenen Methode „progress“ werden die Argumente zu den entsprechenden double-Arrays hinzugefügt, und dann wird die Methode „plot“ aufgerufen, um das Bild zu aktualisieren.

         virtual bool progress(const int epoch, const int total,
            const double error, const double valid = DBL_MAX,
            const double ma = DBL_MAX, const double mav = DBL_MAX) override
         {
            // fill all the arrays
            PUSH(p, epoch);
            PUSH(x, error);
            if(valid != DBL_MAX) PUSH(y, valid); else PUSH(y, nan);
            if(ma != DBL_MAX) PUSH(q, ma); else PUSH(q, nan);
            if(mav != DBL_MAX) PUSH(z, mav); else PUSH(z, nan);
            plot();
            
            return MatrixNet::progress(epoch, total, error, valid, ma, mav);
         }
    
    
    

    Die Methode „Plot“ vervollständigt und zeichnet die Kurven.

       void plot()
       {
          c[0].Update(p, x);
          c[1].Update(p, y);
          c[2].Update(p, z);
          c[3].Update(p, q);
          double point[1] = {stats.bestEpoch};
          b[0] = stats.bestLoss;
          c[4].Update(point, b);
          ...
          graphic.CurvePlotAll();
          graphic.Update();
       }
    
    

    Weitere technische Details des Visualisierungsprozesses können Sie selbst erkunden. Wir werden bald sehen, wie es auf dem Bildschirm aussieht.


    Test-Skript

    Die Familie der Klassen MatrixNet sind bereit für den ersten Test. Es handelt sich um das Skript MatrixNet.mq5, bei dem die Ausgangsdaten auf der Grundlage eines bekannten analytischen Datensatzes künstlich erzeugt werden. Wir werden die Formel aus dem Hilfethema Maschinelles Lernen verwenden, die ein natives Backpropagation-Trainingsbeispiel bietet, das nicht so vielseitig ist wie unsere Klassen und daher erhebliche Programmierarbeit erfordert (vergleichen Sie die Anzahl der Zeilen mit und ohne Verwendung der Klasse unten).

    f = ((x + y + z)^2 / (x^2 + y^2 + z^2)) / 3

    Der einzige kleine Unterschied in unserer Formel besteht darin, dass der Wert durch 3 geteilt wird, wodurch die Funktion einen Bereich von 0 bis 1 erhält.

    Die Form der Funktion lässt sich anhand der folgenden Abbildung beurteilen, in der die Flächen (x<->y) für drei verschiedene z-Werte dargestellt sind: 0,05, 0,5 und 5,0.

    Testfunktion in 3 Abschnitten

    Testfunktion in 3 Abschnitten

    In den Eingabevariablen des Skripts geben wir die Anzahl der Trainingsepochen, die Genauigkeit (terminaler Fehler) und die Rauschintensität an, die wir optional zu den generierten Daten hinzufügen können (dies bringt das Experiment näher an reale Probleme heran und zeigt, wie das Rauschen es erschwert, Abhängigkeiten zu erkennen). RandomNoise ist standardmäßig 0, d. h. es gibt kein Rauschen.

      input int Epochs = 1000;
      input double Accuracy = 0.001;
      input double RandomNoise = 0.0;
    
    

    Die Versuchsdaten werden mit der Funktion CreateData erzeugt. Die Matrixparameter „data“ und „target“ werden mit den Punkten der oben beschriebenen Funktion gefüllt. Die Anzahl der Punkte ist „count“. Ein Eingabevektor (Zeile der „Daten“-Matrix) hat 3 Spalten (für x, y, z). Der Ausgangsvektor (Zeile der „Ziel“-Matrix) ist der Einzelwert von f. Die Punkte (x,y,z) werden zufällig im Bereich von -10 bis +10 erzeugt.

      bool CreateData(matrix &data, matrix &target, const int count)
      { 
         if(!data.Init(count, 3) || !target.Init(count, 1))
            return false;
         data.Random(-10, 10);
         vector X1 = MathPow(data.Col(0) + data.Col(1) + data.Col(2), 2);
         vector X2 = MathPow(data.Col(0), 2) + MathPow(data.Col(1), 2) + MathPow(data.Col(2), 2);
         if(!target.Col(X1 / X2 / 3.0, 0))
            return false;
         if(RandomNoise > 0)
         {
            matrix noise;
            noise.Init(count, 3);
            noise.Random(0, RandomNoise);
            data += noise - RandomNoise / 2;
            
            noise.Resize(count, 1);
            noise.Random(-RandomNoise / 2, RandomNoise / 2);
            target += noise;
         }
         return true; 
      }
    
    

    Die Rauschintensität in RandomNoise wird als Amplitude der zusätzlichen Streuung der korrekten Koordinaten und des für sie erhaltenen Funktionswertes festgelegt. Da der Maximalwert der Funktion 1,0 ist, wird sie durch dieses Rauschen fast unkenntlich gemacht.

    Um das neuronale Netz zu verwenden, binden wir die Header-Datei MatrixNet.mqh ein und definieren das Makro BATCH_PROP vor dieser Präprozessoranweisung, um ein beschleunigtes Lernen mit variabler Rate zu ermöglichen.

      #define BATCH_PROP
      #include <MatrixNet.mqh>
    
    

    In der Hauptfunktion des Skripts definieren wir die Netzwerkkonfiguration (die Anzahl der Schichten und ihre Größe) mithilfe des Arrays „layers“, das wir an den Konstruktor von MatrixNetVisual übergeben. Trainings- und Validierungsdatensätze werden durch zweimaligen Aufruf von CreateData erzeugt.

      void OnStart()
      {
         const int layers[] = {3, 11, 7, 1};
         MatrixNetVisual net(layers);
         matrix data, target;
         CreateData(data, target, 100);
         matrix valid, test;
         CreateData(valid, test, 25);
         ...
    
    

    In der Praxis sollten wir die Quelldaten normalisieren, Ausreißer entfernen und die Faktoren auf Unabhängigkeit prüfen, bevor wir sie an das Netz senden. Aber in diesem Fall generieren wir die Daten selbst.

    Das Modell wird mit der Methode „train“ auf den Matrizen „data“ und „target“ trainiert. Eine frühzeitige Beendigung wird eintreten, da sich die Leistung auf der gültigen/getesteten Menge verschlechtert, aber auf nicht verrauschten Daten werden wir wahrscheinlich die erforderliche Genauigkeit oder die maximalen Schleifen erreichen, je nachdem, was schneller eintritt.

         Print("Training result: ", net.train(data, target, valid, test, Epochs, Accuracy));
         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best copy on training data: ", net2.test(data, target));
               Print("Best copy on validation data: ", net2.test(valid, test));
            }
         }
    
    

    Nach dem Training fordern wir die Matrizen der besten gefundenen Gewichte an und konstruieren zur Überprüfung eine weitere Netzinstanz auf ihrer Grundlage, das Objekt net2. Danach lassen Sie das Netzwerk auf beiden Datensätzen laufen und geben die Fehlerwerte im Protokoll aus.

    Da das Skript ein Netzwerk mit Visualisierung des Lernfortschritts verwendet, starten wir eine Schleife, die auf den Befehl des Nutzers wartet, um das Skript abzuschließen, damit der Nutzer den Graphen studieren kann.

         while(!IsStopped())
         {
            Sleep(1000);
         }
      }
    
    

    Wenn das Skript mit den Standardparametern ausgeführt wird, ergibt sich ein Bild wie in der folgenden Abbildung (jeder Durchlauf unterscheidet sich von den anderen aufgrund der zufälligen Datengenerierung und Netzwerkinitialisierung).

    Netzwerk-Fehlerdynamik während des Trainings

    Fehlerdynamik im Netzwerk während des Trainings

    Die Fehler auf den Trainings- und Validierungsdaten sind als blaue bzw. rote Linien dargestellt, ihre geglätteten Versionen sind grün und gelb. Es ist deutlich zu erkennen, dass mit fortschreitendem Training alle Fehlertypen abnehmen, aber ab einem bestimmten Zeitpunkt wird der Validierungsfehler größer als der Fehler der Trainingsdaten. Am rechten Rand des Diagramms ist ein deutlicher Anstieg zu erkennen, der zu einer „vorzeitigen Beendigung“ führt. Die beste Netzkonfiguration ist eingekreist.

    Das Journal würde in etwa so aussehen:

      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 0.20296 ma(0.20296), validation 0.18167 v.ma(0.18167)
      Epoch 120 of 1000, loss 0.02319 ma(0.02458), validation 0.04566 v.ma(0.04478)
      Stop by validation at 155, v: 0.034642 > 0.034371, t: 0.016614 vs 0.016674
      Training result: 0.015707719706513287
      Best copy on training data: 0.015461956812387292
      Best copy on validation data: 0.03211748853774414
    
    

    Wenn wir beginnen, den Daten mit dem Parameter RandomNoise Rauschen hinzuzufügen, wird die Lernrate merklich sinken, und wenn zu viel Rauschen vorhanden ist, wird der Fehler des trainierten Netzes steigen oder es wird ganz aufhören zu lernen.

    So sieht zum Beispiel das Diagramm bei einem Rauschen von 3.0 aus.

    Netzfehlerdynamik beim Training mit zusätzlichem Rauschen

    Netzfehlerdynamik beim Training mit zusätzlichem Rauschen

    Dem Protokoll zufolge ist der Fehlerwert viel schlechter.

      Epoch 0 of 1000, loss 2.40352 ma(2.40352), validation 2.23536 v.ma(2.23536)
      Stop by validation at 163, v: 1.082419 > 1.080340, t: 0.432023 vs 0.432526
      Training result: 0.4244786772678285
      Best copy on training data: 0.4300476339855798
      Best copy on validation data: 1.062895214094978
    
    

    Das Hilfsmittel für neuronale Netze funktioniert also gut. Kommen wir nun zu mehr praktischen Beispielen: einem Indikator und einem Expert Advisor.


    Prognosefähiger Indikator

    Als Beispiel für einen NN-basierten Prognoseindikator soll BPNNMatrixPredictorDemo.mq5 dienen, der eine Modifikation eines bestehenden Indikators aus der CodeBase ist. Das NN ist in MQL5 implementiert, ohne die Verwendung von Matrizen, durch die Portierung einer früheren Version des gleichen Indikators aus C++ (mit detaillierter Beschreibung, einschließlich relevanter Teile der NN-Theorie).

    Der Indikator arbeitet durch die Bildung von Eingangsvektoren einer bestimmten Länge aus vergangenen Inkrementen des EMA-gemittelten Preises auf Intervallen zwischen Balken, die durch die Fibonacci-Folge (1,2,3,5,8,13,21,34,55,89,144. ...) voneinander getrennt sind. Auf der Grundlage dieser Informationen sollte der Indikator den Kursanstieg auf dem nächsten Balken (rechts von den im entsprechenden Vektor enthaltenen historischen Balken) vorhersagen. Die Größe des Vektors wird durch die vom Nutzer angegebene Größe der NN-Eingabeschicht (_numInputs) bestimmt. Die Anzahl der Schichten (bis zu 6) und deren Größe werden in anderen Eingabevariablen festgelegt.

      input int _lastBar = 0;     // Last bar in the past data
      input int _futBars = 10;    // # of future bars to predict
      input int _smoothPer = 6;   // Smoothing period
      input int _numLayers = 3;   // # of layers including input, hidden & output (2..6)
      input int _numInputs = 12;  // # of inputs (that is neurons in input 0-th layer)
      input int _numNeurons1 = 5; // # of neurons in the 1-st hidden or output layer
      input int _numNeurons2 = 1; // # of neurons in the 2-nd hidden or output layer
      input int _numNeurons3 = 0; // # of neurons in the 3-rd hidden or output layer
      input int _numNeurons4 = 0; // # of neurons in the 4-th hidden or output layer
      input int _numNeurons5 = 0; // # of neurons in the 5-th hidden or output layer
      input int _ntr = 500;       // # of training sets / bars
      input int _nep = 1000;      // Max # of epochs
      input int _maxMSEpwr = -7;  // Error (as power of 10) for training to stop; mse < 10^this
    
    

    Außerdem geben wir hier die maximale Größe des Trainingsdatensatzes (_ntr), die maximale Anzahl der Epochen (_nep) und den minimalen MSE-Fehler (_maxMSEpwr) an.

    Die Glättungslänge des EMA wird in _smoothPer angegeben.

    Standardmäßig nimmt der Indikator Trainingsdaten ab dem letzten Balken (_lastBar ist gleich 0) und macht eine Prognose für _futBars voraus (natürlich können wir, nachdem wir eine Prognose für 1 Balken am Netzwerkausgang haben, diese schrittweise in den Eingabevektor „schieben“, um mehrere nachfolgende Balken vorherzusagen). Wenn in _lastBar eine positive Zahl angegeben wird, erhalten wir eine Prognose ab der entsprechenden Anzahl von Balken in der Vergangenheit, die es uns ermöglicht, sie visuell zu bewerten, indem wir sie mit den bestehenden Kursen vergleichen.

    Der Indikator gibt 3 Puffer aus:

    • hellgrüne Linie mit den Zielwerten des Trainingsdatensatzes
    • blaue Linie mit der Netzausgabe auf dem Trainingsdatensatz
    • rote Linie für die Prognose

    Der Anwendungsteil des Indikators, der Datensätze erzeugt und die Ergebnisse visualisiert (sowohl Ausgangsdaten als auch Prognosen), hat sich nicht geändert.

    Die wichtigsten Änderungen wurden an den beiden Funktionen „Train“ und „Test“ vorgenommen: Sie delegieren nun die NN-Arbeit vollständig an die Objekte der Klasse MatrixNet. Die Funktion „Train“ trainiert das Netz auf der Grundlage der gesammelten Daten und gibt ein Array mit den Netzgewichten zurück (bei der Ausführung im Tester wird das Training nur einmal durchgeführt, bei der Online-Ausführung führt das Öffnen eines neuen Balkens zu wiederholtem Training; dies kann im Quellcode geändert werden). Die Funktion „Test“ erstellt das Netz anhand der Gewichte neu und führt eine regelmäßige einmalige Vorhersageberechnung durch. Optimaler wäre es, das Objekt des trainierten Netzes zu speichern und es zu nutzen, ohne es neu zu erstellen. Wir werden dies im nächsten Beispiel mit dem EA tun. Was den Indikator betrifft, so habe ich bewusst die ursprüngliche Codestruktur der alten Version verwendet, um den Vergleich zwischen Codierungsansätzen mit und ohne Matrizen zu erleichtern. Sie können insbesondere darauf achten, dass wir in der Matrixversion die Vektoren nicht einzeln in einer Schleife durch das Netz laufen lassen und die Datenfelder entsprechend ihrer Dimension manuell umgestalten müssen.

    Nachfolgend sehen Sie den Indikator mit den Standardeinstellungen auf dem EURUSD, H1-Chart.

    Vorhersage durch einen auf einem neuronalen Netz basierenden Indikator

    Vorhersage durch einen auf einem neuronalen Netz basierenden Indikator

    Bitte beachten Sie, dass der Indikator hier vorgestellt wird, um die Leistung eines neuronalen Netzes zu demonstrieren. In seiner derzeitigen vereinfachten Form wird er nicht für Handelsentscheidungen empfohlen.


    Speicherung von NNs in Dateien

    Die vom Markt eingehenden Quelldaten können sich schnell ändern, und einige Händler halten es für sinnvoll, das Netzwerk on-the-fly (jeden Tag, jede Sitzung usw.) anhand der neuesten Datensätze zu trainieren. Sie kann jedoch kostspielig sein und ist für mittel- und langfristige Handelssysteme, die auf der Grundlage von Tagesdaten arbeiten, nicht so relevant. In solchen Fällen ist es wünschenswert, das trainierte Netz zu speichern, damit es später schnell geladen und verwendet werden kann.

    Zu diesem Zweck haben wir im Rahmen dieses Artikels die Klasse MatrixNetStore erstellt, die in der Header-Datei MatrixNetStore.mqh definiert ist. Die Klasse enthält die Template-Methoden ‚save‘ und ‚load‘, die als M-Template-Parameter eine beliebige Klasse aus der MatrixNet-Familie erwarten (im Moment haben wir nur zwei Klassen, einschließlich MatrixNetVisual, aber Sie können die Menge erweitern, wenn Sie möchten). Beide Methoden haben ein Argument mit einem Dateinamen und arbeiten mit Standard-NN-Daten: die Anzahl der Schichten, ihre Größe, Gewichtsmatrizen und Aktivierungsfunktionen.

    So wird das Netzwerk gespeichert.

      class MatrixNetStore
      {
         static string signature;
      public:
         template<typename M> // M is a MatrixNet
         static bool save(const string filename, const M &net, Storage *storage = NULL, const int flags = 0)
         {
            // get the matrix of weights (the best weights, if any)
            matrix w[];
            if(!net.getBestWeights(w))
            {
               if(!net.getWeights(w))
               {
                  return false;
               }
            }
            // open file
            int h = FileOpen(filename, FILE_WRITE | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return false;
            // write network metadata
            FileWriteString(h, signature);
            FileWriteInteger(h, net.getActivationFunction());
            FileWriteInteger(h, net.getActivationFunction(true));
            FileWriteInteger(h, ArraySize(w));
            // write weight matrices
            for(int i = 0; i < ArraySize(w); ++i)
            {
               matrix m = w[i];
               FileWriteInteger(h, (int)m.Rows());
               FileWriteInteger(h, (int)m.Cols());
               double a[];
               m.Swap(a);
               FileWriteArray(h, a);
            }
            // if user data is provided, write it
            if(storage)
            {
              if(!storage.store(h)) Print("External info wasn't saved");
            }
            
            FileClose(h);
            return true;
         }
         ...
      };
         
      static string MatrixNetStore::signature = "BPNNMS/1.0";
    
    

    Achten Sie auf die folgenden Punkte. Am Anfang der Datei wird eine Signatur geschrieben, anhand derer die Korrektheit des Dateiformats überprüft werden kann (die Signatur kann geändert werden: die Klasse bietet dafür Methoden). Darüber hinaus ermöglicht die Methode „save“ bei Bedarf das Hinzufügen beliebiger Nutzerdaten zu den Standardinformationen über das Netz: Sie sollten einfach einen Zeiger auf ein Objekt der speziellen Speicherschnittstelle übergeben.

      class Storage
      {
      public:
         virtual bool store(const int h) = 0;
         virtual bool restore(const int h) = 0;
      };
    
    

    Ein Netzwerk kann entsprechend aus der Datei wiederhergestellt werden.

      class MatrixNetStore
      {
         ...
         template<typename M> // M is a MatrixNet
         static M *load(const string filename, Storage *storage = NULL, const int flags = 0)
         {
            int h = FileOpen(filename, FILE_READ | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return NULL;
            // check the format by signature
            const string header = FileReadString(h, StringLen(signature));
            if(header != signature)
            {
               FileClose(h);
               Print("Incorrect file header");
               return NULL;
            }
            // read standard network metadata set
            const ENUM_ACTIVATION_FUNCTION f1 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const ENUM_ACTIVATION_FUNCTION f2 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const int size = FileReadInteger(h);
            matrix w[];
            ArrayResize(w, size);
            // read weight matrices
            for(int i = 0; i < size; ++i)
            {
               const int rows = FileReadInteger(h);
               const int cols = FileReadInteger(h);
               double a[];
               FileReadArray(h, a, 0, rows * cols);
               w[i].Swap(a);
               w[i].Reshape(rows, cols);
            }
            // read user data
            if(storage)
            {
               if(!storage.restore(h)) Print("External info wasn't read");
            }
            // create a network object
            M *m = new M(w, f1, f2);
            
            FileClose(h);
            return m;
         }
    
    

    Nun sind wir bereit, zum letzten Beispiel in diesem Artikel überzugehen, zu einem Handelsroboter.


    Prognosefähiger Expert Advisor

    Als Strategie für den prognosefähigen EA TradeNN.mq5 verwenden wir ein recht einfaches Prinzip: Wir handeln in der vorhergesagten Richtung des nächsten Balkens. Unser Ziel ist es, Technologien für neuronale Netze in Aktion zu demonstrieren, nicht aber, alle vorhersehbaren Anwendungsfaktoren im Zusammenhang mit der Rentabilität zu erforschen.

    Bei den Ausgangsdaten handelt es sich um Preisinkremente für eine bestimmte Anzahl von Balken. Optional ist es möglich, nicht nur das aktuelle Symbol zu analysieren, sondern auch weitere, was es theoretisch erlaubt, Interdependenzen zu erkennen (z.B. wenn ein Ticker indirekt einem anderen „folgt“ oder deren Kombinationen). Der einzige Output des Netzwerks wird nicht als Zielpreis interpretiert. Um das System zu vereinfachen, werden wir stattdessen das Vorzeichen analysieren: positiv - kaufen, negativ - verkaufen.

    Mit anderen Worten, das Schema des Netzes ist in gewissem Sinne hybrid: Einerseits wird das Netz das Regressionsproblem lösen, andererseits werden wir wie bei der Klassifizierung eine Handelsaktion aus zwei auswählen. In Zukunft wird es möglich sein, die Anzahl der Neuronen in der Ausgabeschicht bis zur Anzahl der Handelssituationen zu erhöhen und die SoftMax-Aktivierungsfunktion anzuwenden. Um ein solches Netz zu trainieren, ist es jedoch erforderlich, die Kurse je nach Situation automatisch oder manuell zu kennzeichnen.

    Die Strategie ist absichtlich sehr einfach gehalten, um sich auf die Netzparameter und nicht auf die Strategie zu konzentrieren.

    Eine durch Kommata getrennte Liste der zu analysierenden Instrumente wird im Eingabeparameter „Symbole“ angegeben. Das Symbol des aktuellen Charts sollte an erster Stelle stehen; es befindet sich auf dem gehandelten Symbol.

      input string Symbols = "XAGUSD,XAUUSD,EURUSD";
      input int Depth = 5; // Vector size (bars)
      input int Reserve = 250; // Training set size (vectors)
    
    

    Ich habe diese Symbole als Standard gewählt, weil Silber und Gold als korrelierte Vermögenswerte angesehen werden und es (im Vergleich zu Währungen) relativ wenig aufsehenerregende Nachrichten gibt, sodass wir versuchen können, sowohl Silber gegen Gold (wie es jetzt ist) als auch Gold gegen Silber zu analysieren. Was den EURUSD betrifft, so wird dieses Paar als Grundlage für den gesamten Markt herangezogen. Das Vorhandensein von Nachrichten ist nicht wichtig, da er als Prognostiker arbeitet und es sich nicht um eine prognosefähige Variable handelt.

    Zu den wichtigsten Parametern gehört auch die Anzahl der Balken (Tiefe) für jedes Instrument, die den Vektor bilden. Wenn z. B. Symbols auf 3 Ticker und Depth auf 5 (Standard) eingestellt ist, beträgt die Gesamtgröße des Eingabevektors des Netzes 15.

    Mit dem Parameter Reserve lässt sich die Länge der Stichprobe (die Anzahl der Vektoren, die aus dem nächstliegenden Kursverlauf gebildet werden) einstellen. Der Standardwert ist 250, da unser Test den täglichen Zeitrahmen verwendet und 250 ungefähr 1 Jahr entspricht. Dementsprechend bedeutet eine Tiefe von 5 eine Woche.

    Natürlich können Sie alle Einstellungen, einschließlich des Zeitrahmens, ändern, aber auf höheren Zeitrahmen, wie D1, sind die fundamentalen Muster vermutlich ausgeprägter als spontane Marktreaktionen auf momentane Umstände.

    Bitte beachten Sie auch, dass beim Start des Testers ungefähr 1 Jahr an Kursen vorgeladen wird, sodass eine Erhöhung der Menge an angeforderten Trainingsdaten auf D1+ das Überspringen einer bestimmten Anzahl von anfänglichen Balken erfordert, um zu warten, bis sich eine ausreichende Anzahl von ihnen angesammelt hat.

    Ähnlich wie bei den vorherigen Beispielen sollten wir in den Parametern die Anzahl der Trainingsepochen und die Genauigkeit (die auch die Anfangsgeschwindigkeit ist, denn die Geschwindigkeit wird für jede Synapse dynamisch durch „rprop“ ausgewählt) angeben.

      input int Epochs = 1000;
      input double Accuracy = 0.0001; // Accuracy (and training speed)
    
    

    In diesem Expert Advisor hat das NN 5 Schichten: eine Eingabe, 3 versteckte und eine Ausgabe. Die Größe der Eingabeschicht bestimmt den Eingabevektor, und die zweite und dritte Schicht werden mit HiddenLayerFactor ausgewählt. Für die vorletzte Schicht verwenden wir eine empirische Formel (siehe Quellcode unten), damit ihre Größe zwischen der vorhergehenden und der Ausgangsschicht liegt (einzeln).

      input double HiddenLayerFactor = 2.0; // Hidden Layers Factor (to vector size)
      input int DropOutPercentage = 0; // DropOut Percentage
    
    

    Wir werden dieses Beispiel auch verwenden, um die Dropout-Regulierungsmethode zu testen: Der Prozentsatz der zufällig zurückgesetzten Gewichte wird mit dem Parameter DropOutPercentage angegeben. Validierungsstichproben sind hier nicht vorgesehen, aber wenn Sie möchten, können Sie beide Methoden kombinieren, da dies von der Klasse zugelassen wird.

    Der Parameter NetBinFileName wird verwendet, um das Netz aus einer Datei zu laden. Die Dateien werden immer relativ zum gemeinsamen Terminal-Ordner gesucht, da wir sonst, um den Expert EA im Strategietester zu testen, die Namen aller notwendigen Netzwerke im Quellcode im Voraus in der Direktive #property tester_file angeben müssten — nur so würden sie an den Agenten gesendet werden.

    Wenn der Parameter NetBinFileName leer ist, erstellt der EA ein neues Netzwerk und speichert es in einer Datei mit einem eindeutigen temporären Namen. Dies geschieht sogar während des Optimierungsprozesses, der es ermöglicht, eine große Anzahl von Netzkonfigurationen (für verschiedene Vektorgrößen, Schichten, Ausfälle und Verlaufstiefen) zu erzeugen.

      input string NetBinFileName = "";
      input int Randomizer = 0;
    
    

    Darüber hinaus ermöglicht der Randomizer-Parameter die Initialisierung des Zufallsgenerators auf unterschiedliche Weise, sodass wir viele Netzinstanzen mit denselben anderen Einstellungen trainieren können. Beachten Sie, dass jedes Netz aufgrund der Randomisierung einzigartig ist. Der Einsatz von NN-Ausschüssen, aus denen ein konsolidierter Beschluss oder eine Mehrheitsregel herausgelesen wird, ist möglicherweise eine weitere Art der Regulierung.

    Indem wir den Randomizer auf einen bestimmten Wert setzen, können wir denselben Trainingsprozess zu Debugging-Zwecken wiederholen.

    Die Preisinformationen nach Symbolen werden unter Verwendung der Struktur Closes und eines Arrays solcher CC-Strukturen gespeichert: Als Ergebnis erhalten wir so etwas wie ein Array von Arrays.

      struct Closes
      {
         double C[];
      };
         
      Closes CC[];
    
    

    Das globale Array S und die Variable Q sind für Arbeitsinstrumente und deren Anzahl reserviert. Sie werden in OnInit ausgefüllt.

      string S[];
      int Q;
         
      int OnInit()
      {
         Q = StringSplit(StringLen(Symbols) ? Symbols : _Symbol, ',', S);
         ArrayResize(CC, Q);
         MathSrand(Randomizer);
         ...
         return INIT_SUCCEEDED;
      }
    
    

    Die Funktion Calc wird verwendet, um Kurse bis zu einer bestimmten Tiefe ab einem bestimmten Balken-‘Offset‘ anzufordern. In dieser Funktion wird das CC-Array gefüllt. Wir werden später sehen, wie diese Funktion aufgerufen wird.

      bool Calc(const int offset)
      {
         const datetime dt = iTime(_Symbol, _Period, offset);
         for(int i = 0; i < Q; ++i)
         {
            const int bar = iBarShift(S[i], PERIOD_CURRENT, dt);
            // +1 for differences, +1 for model
            const int n = CopyClose(S[i], PERIOD_CURRENT, bar, Depth + 2, CC[i].C);
            
            for(int j = 0; j < n - 1; ++j)
            {
               CC[i].C[j] = (CC[i].C[j + 1] - CC[i].C[j]) /
                  SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_SIZE) * SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_VALUE);
            }
            
            ArrayResize(CC[i].C, n - 1);
         }
         
         return true;
      }
    
    

    Dann kann die spezielle Diff-Funktion für ein bestimmtes Array CC[i].C Funktion Preisinkremente berechnen, die in die Eingangsvektoren für das Netzwerk gesendet werden. Die Funktion schreibt alle Inkremente außer dem letzten in das Array d, das als Referenz übergeben wird, und gibt direkt das letzte Inkrement zurück, das der Zielwert für die Vorhersage sein wird.

      double Diff(const double &a[], double &d[])
      {
         const int n = ArraySize(a);
         ArrayResize(d, n - 1); // -1 minus the "future" model
         double overall = 0;
         for(int j = 0; j < n - 1; ++j) // left (from old) to right (toward new)
         {
            int k = n - 2 - j;
            overall += a[k];
            d[j] = overall / sqrt(j + 1);
         }
         ... // additional normalization
         return a[n - 1];
      }
    
    

    Man beachte, dass wir gemäß der Theorie des „Random Walk“ der Zeitreihen die Differenzen durch die Quadratwurzel des Abstands in Balken normalisieren (proportional zum Konfidenzintervall, wenn wir die Vergangenheit als eine bereits erarbeitete Prognose betrachten). Dies ist keine Pflichttechnik, aber die Arbeit mit NNs ähnelt oft einem Forschen.

    Das gesamte Verfahren für die Auswahl der Faktoren (nicht nur Preise, sondern auch Indikatoren, Mengen usw.) und die Aufbereitung der Daten für das Netz (Normalisierung, Kodierung) ist ein eigenes, umfangreiches Thema. Es ist wichtig, dem NN die Rechenarbeit so weit wie möglich zu erleichtern, da es sonst möglicherweise nicht in der Lage ist, die Aufgabe zu bewältigen.

    In der Hauptfunktion OnTick des EA werden alle Operationen erst nach der Eröffnung eines Balkens ausgeführt. Da der EA Kurse von verschiedenen Instrumenten analysiert, ist es notwendig, deren Balken zu synchronisieren, bevor er weiterarbeitet. Die Synchronisierung erfolgt über die Funktion Sync, die hier nicht gezeigt wird. Interessanterweise eignet sich die angewandte Synchronisierung auf der Grundlage der Funktion Sleep sogar für Tests im offenen Kursmodus. Wir werden diesen Modus später aus Gründen der Effizienz verwenden.

      void OnTick()
      {
         ...
         static datetime last = 0;
         if(last == iTime(_Symbol, _Period, 0)) return;
         ...
    
    

    Die Netzwerkinstanz wird in der Variable „run“ des Auto-Pointer-Typs (AutoPtr.mqh-Header-Datei) gespeichert. Wir brauchen also die Freigabe von Speicherplatz nicht zu kontrollieren. Die Variable „std“ wird verwendet, um die aus dem Datensatz berechnete Varianz zu speichern, die mit den oben beschriebenen Funktionen Calc und Diff ermittelt wurde. Die Varianz wird zur Normalisierung der Daten benötigt.

         static AutoPtr<MatrixNet> run;
         static double std;
    
    

    Wenn der Nutzer einen Dateinamen in NetBinFileName zum Laden angegeben hat, versucht das Programm, das Netzwerk mit LoadNet zu laden (siehe unten). Diese Funktion gibt bei Erfolg einen Zeiger auf das Netzwerkobjekt zurück.

         if(NetBinFileName != "")
         {
            if(!run[])
            {
               run = LoadNet(NetBinFileName, std);
               if(!run[])
               {
                  ExpertRemove();
                  return;
               }
            }
         }
    
    

    Wenn es ein Netz gibt, führen wir Prognosen und Handel durch: Für all dies ist TradeTest zuständig (siehe unten).

         if(run[])
         {
            TradeTest(run[], std);
         }
         else
         {
            run = TrainNet(std);      
         }
         
         last = iTime(_Symbol, _Period, 0);
      }
    
    

    Wenn noch kein Netz vorhanden ist, erzeugen wir einen Trainingsdatensatz und trainieren das Netz durch den Aufruf von TrainNet. Diese Funktion gibt ebenfalls einen Zeiger auf ein neues Netzobjekt zurück und füllt außerdem die als Referenz übergebene Variable ‚std‘ mit der berechneten Datenabweichung.

    Bitte beachten Sie, dass das Netz nur dann trainieren kann, wenn die Historie aller arbeitenden Symbole mindestens die geforderte Anzahl von Balken enthält. Bei einem Online-Chart geschieht dies höchstwahrscheinlich sofort, wenn Sie den Expert Advisor starten (es sei denn, der Nutzer hat eine exorbitante Zahl eingegeben). Im Prüfgerät ist die vorgeladene Historie in der Regel auf ein Jahr begrenzt, sodass es notwendig sein kann, den Beginn des Durchlaufs in die Vergangenheit zu verlegen. In diesem Fall haben Sie die erforderliche Anzahl von Balken, um das Netz zu trainieren.

    Eine Prüfung, ob genügend Balken vorhanden sind, wird am Anfang der OnTick-Funktion hinzugefügt, ist aber nicht im Artikel enthalten (siehe vollständiger Quellcode).

    Nachdem das Netzwerk trainiert wurde, wird der EA mit dem Handel beginnen. Für den Tester bedeutet dies, dass er eine Art Vorwärtstest des trainierten Netzes erhält. Die erhaltenen Finanzdaten können zur Optimierung verwendet werden, um die am besten geeignete Netzkonfiguration oder eine Gruppe von Netzen (mit gleicher Konfiguration) auszuwählen.

    Nachfolgend finden Sie die TrainNet-Funktion (achten Sie auf die Aufrufe Calc und Diff).

      MatrixNet *TrainNet(double &std)
      {
         double coefs[];
         matrix sys(Reserve, Q * Depth);
         vector model(Reserve);
         vector t;
         datetime start = 0;
        
         for(int j = Reserve - 1; j >= 0; --j) // loop through historical bars
         {
            // since close prices are used, we make +1 to the bar index
            if(!Calc(j + 1)) // collect data for all symbols starting with bar j to Depth bars
            {
               return NULL; // probably other symbols don't have enough history (wait)
            }
            // remember training sample start date/time
            if(start == 0) start = iTime(_Symbol, _Period, j);
          
            ArrayResize(coefs, 0);
          
            // calculate price difference for all symbols for Depth bars
            for(int i = 0; i < Q; ++i)
            {
               double temp[];
               double m = Diff(CC[i].C, temp);
               if(i == 0)
               {
                  model[j] = m;
               }
               int dest = ArraySize(coefs);
               ArrayCopy(coefs, temp, dest, 0);
            }
          
            t.Assign(coefs);
            sys.Row(t, j);
         }
         
         // normalize
         std = sys.Std() * 3;
         Print("Normalization by 3 std: ", std);
         sys /= std;
         matrix target = {};
         target.Col(model, 0);
         target /= std;
        
         // the size of layers 0, 1, 2, 3 is derived from the data, always one output
         int layers[] = {0, 0, 0, 0, 1};
         layers[0] = (int)sys.Cols();
         layers[1] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[2] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[3] = (int)fmax(sqrt(sys.Rows()), fmax(sqrt(layers[1] * layers[3]), sys.Cols() * sqrt(HiddenLayerFactor)));
         
         // create and configure the network of the specified configuration
         ArrayPrint(layers);
         MatrixNetVisual *net = new MatrixNetVisual(layers);
         net.setupSpeedAdjustment(SpeedUp, SpeedDown, SpeedHigh, SpeedLow);
         net.enableDropOut(DropOutPercentage);
    
         // train the network and display the result (error)
         Print("Training result: ", net.train(sys, target, Epochs, Accuracy));
         ...
    
    

    Wir verwenden eine Netzwerkklasse mit Visualisierung, sodass der Lernfortschritt auf dem Diagramm angezeigt wird. Nach dem Training können Sie das Bildobjekt manuell löschen, wenn Sie es nicht mehr benötigen. Das Bild wird automatisch gelöscht, wenn Sie den EA entladen.

    Als Nächstes müssen wir die besten Gewichtsmatrizen aus dem Netz auslesen. Außerdem prüfen wir, ob sich das Netz mit diesen Gewichten erfolgreich neu erstellen lässt und testen seine Leistung mit denselben Daten.

         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best result: ", net2.test(sys, target));
               ...
            }
         }
         return net;
      }
    
    

    Schließlich wird das Netz in einer Datei gespeichert, zusammen mit einem speziell vorbereiteten String, der die Trainingsbedingungen beschreibt: Verlaufsintervall, Symbolliste und Zeitrahmen, Datengröße, Netzeinstellungen.

            // the most important or all EA settings can be added to the network file
            const string context = StringFormat("\r\n%s %s %s-%s", _Symbol, EnumToString(_Period),
               TimeToString(start), TimeToString(iTime(_Symbol, _Period, 0))) + "\r\n" +
               Symbols + "\r\n" + (string)Depth + "/" + (string)Reserve + "\r\n" +
               (string)Epochs + "/" + (string)Accuracy + "\r\n" +
               (string)HiddenLayerFactor + "/" + (string)DropOutPercentage + "\r\n";
               
            // prepare a temporary file name
            const string tempfile = "bpnnmtmp" + (string)GetTickCount64() + ".bpn";
            
            // save the network and user data to a file
            MatrixNetStore store;                                   // main class unloading/loading the networks
            BinFileNetStorage writer(context, net.getStats(), std); // optional class with our information
            store.save(tempfile, *net, &writer);
            ...
    
    

    Die hier erwähnte Klasse BinFileNetStorage ist spezifisch für unseren EA. Sie verwendet die überschriebenen Speicher-/Wiederherstellungsmethoden (die übergeordnete Speicherschnittstelle), um unsere zusätzliche Beschreibung, den Normalisierungswert (der für die regelmäßige Arbeit mit neuen Daten benötigt wird) sowie die Trainingsstatistik in Form einer MatrixNet::Stats-Struktur zu verarbeiten.

    Außerdem hängt das Verhalten des EA davon ab, ob er im Optimierungsmodus läuft oder nicht. Während der Optimierung senden wir die Netzwerkdatei vom Agenten zum Terminal mit Hilfe des Frame-Mechanismus (siehe Quellcode). Solche Dateien werden im lokalen Ordner MQL5/Files/ im Unterordner mit dem Namen des EAs gespeichert.

            if(!MQLInfoInteger(MQL_OPTIMIZATION))
            {
               // set a new name in a more understandable time format, in the common folder
               string filename = "bpnnm" + TimeStamp((datetime)FileGetInteger(tempfile, FILE_MODIFY_DATE))
                  + StringFormat("(%7g)", net.getStats().bestLoss) + ".bpn";
               if(!FileMove(tempfile, 0, filename, FILE_COMMON))
               {
                  PrintFormat("Can't rename temp-file: %s [%d]", tempfile, _LastError);
               }
            }
            else
            {
               ... // the file will be sent from the agent to the terminal as a frame
            }
    
    

    In anderen Fällen (einfache Tests oder Online-Arbeiten) wird die Datei in den gemeinsamen Terminalordner verschoben. Dies geschieht, um das weitere Laden über den Parameter NetBinFileName zu vereinfachen. Um im Tester zu arbeiten, müssten wir die Direktive #property tester_file mit einem bestimmten Dateinamen angeben, der in den Parameter NetBinFileName eingegeben werden sollte, und dann müssten wir den EA neu kompilieren. Ohne diese zusätzlichen Manipulationen würde die Netzwerkdatei nicht auf den Agenten kopiert werden. Daher ist es praktischer, den gemeinsamen Ordner zu verwenden, auf den alle lokalen Agenten Zugriff haben.

    Die LoadNet-Funktion ist wie folgt implementiert:

      MatrixNet *LoadNet(const string filename, double &std, const int flags = FILE_COMMON)
      {
         BinFileNetStorage reader; // optional user data
         MatrixNetStore store;     // general metadata
         MatrixNet *net;
         std = 1.0;
         Print("Loading ", filename);
         ResetLastError();
         net = store.load<MatrixNet>(filename, &reader, flags);
         if(net == NULL)
         {
            Print("Failed: ", _LastError);
            return NULL;
         }
         MatrixNet::Stats s[1];
         s[0] = reader.getStats();
         ArrayPrint(s);
         std = reader.getScale();
         Print(std);
         Print(reader.getDescription());
         return net;
      }
    
    

    Die Funktion TradeTest ruft Calc(0) auf, um einen Vektor der tatsächlichen Kurssteigerungen zu erhalten.

      bool TradeTest(MatrixNet *net, const double std)
      {
         if(!Calc(0)) return false;
         double coefs[];
         for(int i = 0; i < Q; ++i)
         {
            double temp[];
            // difference on the 0th bar is ignored, it will be predicted
            /* double m = */Diff(CC[i].C, temp, true);
            ArrayCopy(coefs, temp, ArraySize(coefs), 0);
         }
          
         vector t;
         t.Assign(coefs);
          
         matrix data = {};
         data.Row(t, 0);
         data /= std;
         ...
    
    

    Auf der Grundlage des Vektors muss das Netz eine Vorhersage treffen. Aber vorher wird die bestehende offene Position gewaltsam geschlossen: Wir haben keine Analyse, ob die alte und die neue Richtung übereinstimmen. Die Methode ClosePosition, die zum Schließen verwendet wird, wird im Folgenden dargestellt. Dann eröffnen wir auf der Grundlage der Feed-Forward-Ergebnisse eine neue Position in der gewünschten Richtung.

         ClosePosition();
         
         if(net.feedForward(data))
         {
            matrix y = net.getResults();
            Print("Prediction: ", y[0][0] * std);
            
            OpenPosition((y[0][0] > 0) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL);
            return true;
         }
         return false;
      }
    
    

    Die Funktionen OpenPosition und ClosePosition sind ähnlich. Daher werde ich hier nur ClosePosition anzeigen.

      bool ClosePosition()
      {
         // define an empty structure
         MqlTradeRequest request = {};
         
         if(!PositionSelect(_Symbol)) return false;
         const string pl = StringFormat("%+.2f", PositionGetDouble(POSITION_PROFIT));
         
         // fill in the required fields
         request.action = TRADE_ACTION_DEAL;
         request.position = PositionGetInteger(POSITION_TICKET);
         const ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)(PositionGetInteger(POSITION_TYPE) ^ 1);
         request.type = type;
         request.price = SymbolInfoDouble(_Symbol, type == ORDER_TYPE_BUY ? SYMBOL_ASK : SYMBOL_BID);
         request.volume = PositionGetDouble(POSITION_VOLUME);
         request.deviation = 5;
         request.comment = pl;
         
         // send request
         ResetLastError();
         MqlTradeResult result[1];
         const bool ok = OrderSend(request, result[0]);
         
         Print("Status: ", _LastError, ", P/L: ", pl);
         ArrayPrint(result);
         
         if(ok && (result[0].retcode == TRADE_RETCODE_DONE
                || result[0].retcode == TRADE_RETCODE_PLACED))
         {
            return true;
         }
         
         return false;
      }
    
    

    Zeit für praktische Forschung. Lassen wir den EA im Tester mit den Standardeinstellungen auf dem Chart XAGUSD, D1 im Modes der Eröffnungspreise laufen. Wir setzen das Startdatum des Tests auf den 2022.01.01. Das bedeutet, dass das Netzwerk unmittelbar nach dem Start des EA mit dem Lernen anhand der Preise des Vorjahres 2021 beginnt und dann auf der Grundlage seiner Signale handelt. Um die Fehleränderungsgrafik nach Epochen zu sehen, führen wir den Tester im visuellen Modus aus.

    Das Protokoll enthält Einträge im Zusammenhang mit dem NN-Training.

      Sufficient bars at: 2022.01.04 00:00:00
      Normalization by 3 std: 1.3415995381755823
      15 30 30 21  1
      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 121 of 1000, loss 0.31818 ma(0.36230)
      Epoch 243 of 1000, loss 0.16857 ma(0.18029)
      Epoch 367 of 1000, loss 0.09157 ma(0.09709)
      Epoch 479 of 1000, loss 0.06454 ma(0.06888)
      Epoch 590 of 1000, loss 0.04875 ma(0.05092)
      Epoch 706 of 1000, loss 0.03659 ma(0.03806)
      Epoch 821 of 1000, loss 0.03043 ma(0.03138)
      Epoch 935 of 1000, loss 0.02721 ma(0.02697)
      Done by epoch limit 1000 with accuracy 0.024416
      Training result: 0.024416206367547762
      Best result: 0.024416206367547762
      Check-up of saved and restored copy: bpnnm202302121707(0.0244162).bpn
      Loading bpnnm202302121707(0.0244162).bpn
          [bestLoss] [bestEpoch] [trainingSet] [validationSet] [epochsDone]
      [0]      0.024         999           250               0         1000
      1.3415995381755823
         
      XAGUSD PERIOD_D1 2021.01.18 00:00-2022.01.04 00:00
      XAGUSD,XAUUSD,EURUSD
      5/250
      1000/0.0001
      2.0/0
         
      Best result restored: 0.024416206367547762
    
    

    Achten Sie auf den Wert des endgültigen Fehlers. Später werden wir den Test mit aktiviertem Dropout-Modus bei unterschiedlichen Intensitäten wiederholen und die Ergebnisse vergleichen.

    Hier ist der Handelsbericht.

    Bericht eines Beispiels von einem prognosefähigen Handel

    Bericht eines Beispiels von einem prognosefähigen Handel

    Offensichtlich verlief der Handel für den größten Teil des Jahres 2022 unbefriedigend. Auf der linken Seite, unmittelbar nach dem Jahr 2021, das als Trainingsdatensatz diente, gibt es jedoch einen kurzen profitablen Zeitraum. Wahrscheinlich blieben die vom Netz gefundenen Muster noch einige Zeit in Kraft. Wenn wir herausfinden wollten, ob dies wirklich so ist und ob die Einstellungen des Netzes oder das Trainingsset in irgendeiner Weise verändert werden sollten, um die Leistung zu verbessern, müssten wir für jedes einzelne Handelssystem umfassende Untersuchungen durchführen. Dies ist eine Menge mühsamer Arbeit, die nichts mit der internen Implementierung von Algorithmen für neuronale Netze zu tun hat. Wir werden hier nur eine minimale Analyse durchführen.

    Das Protokoll zeigt den Namen der Datei mit dem trainierten Netz an. Geben Sie ihn im Tester im Parameter NetBinFileName an, und erweitern Sie die Testzeit, beginnend mit 2021. In diesem Modus haben alle Eingabeparameter mit Ausnahme der ersten beiden (Symbole und Tiefe) keine Bedeutung.

    Der Testhandel in einem längeren Intervall zeigt die folgende Gleichgewichtsdynamik (der Trainingsdatensatz ist gelb hervorgehoben).

    Saldenkurve beim Handel in einem längeren Intervall, einschließlich Trainingsdaten

    Saldenkurve beim Handel in einem längeren Intervall, einschließlich Trainingsdaten

    Wie erwartet, hat das Netz die Besonderheiten eines bestimmten Intervalls gelernt, aber bald nach dessen dessen Ende ist es nicht mehr rentabel.

    Wiederholen wir das Netztraining zweimal: mit einem Dropout von 25% und 50% (der Parameter DropOutPercentage sollte nacheinander auf 25 und dann auf 50 gesetzt werden). Um das Training neuer Netze zu starten, löschen wir den Parameter NetBinFileName und setzen das Startdatum des Tests auf 2022.01.01.

    Bei einem Dropout von 25% ergibt sich ein deutlich größerer Fehler als im ersten Fall. Dies ist ein erwartetes Ergebnis, da wir versuchen, die Anwendbarkeit des Modells auf Daten außerhalb der Stichprobe zu erweitern, indem wir das Modell vergröbern.

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 125 of 1000, loss 0.46777 ma(0.48644)
      Epoch 251 of 1000, loss 0.36113 ma(0.36982)
      Epoch 381 of 1000, loss 0.30045 ma(0.30557)
      Epoch 503 of 1000, loss 0.27245 ma(0.27566)
      Epoch 624 of 1000, loss 0.24399 ma(0.24698)
      Epoch 744 of 1000, loss 0.22291 ma(0.22590)
      Epoch 840 of 1000, loss 0.19507 ma(0.20062)
      Epoch 930 of 1000, loss 0.18931 ma(0.19018)
      Done by epoch limit 1000 with accuracy 0.182581
      Training result: 0.18258059873803228
    
    

    Bei einem Dropout von 50% steigt der Fehler noch weiter an.

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 118 of 1000, loss 0.54929 ma(0.55782)
      Epoch 242 of 1000, loss 0.43541 ma(0.45008)
      Epoch 367 of 1000, loss 0.38081 ma(0.38477)
      Epoch 491 of 1000, loss 0.34920 ma(0.35316)
      Epoch 611 of 1000, loss 0.30940 ma(0.31467)
      Epoch 729 of 1000, loss 0.29559 ma(0.29751)
      Epoch 842 of 1000, loss 0.27465 ma(0.27760)
      Epoch 956 of 1000, loss 0.25901 ma(0.26199)
      Done by epoch limit 1000 with accuracy 0.251914
      Training result: 0.25191436104184456
    
    

    Die folgende Abbildung zeigt Trainingsdiagramme in drei Varianten.

    Lerndynamik bei unterschiedlichen Dropoutwerten

    Lerndynamik bei unterschiedlichen Dropoutwerten

    Hier sehen Sie die Saldenkurve (die Trainingsdaten sind gelb hervorgehoben).

    Saldenkurve nach Vorhersagen von Netzwerken mit unterschiedlichen Dropouts

    Saldenkurve nach Vorhersagen von Netzwerken mit unterschiedlichen Dropouts

    Aufgrund der zufälligen Unterbrechung der Gewichte während des Dropouts wird die Gleichgewichtslinie in der Trainingsperiode nicht so glatt wie bei einem vollständigen Netz, und der Gesamtgewinn nimmt natürlich ab.

    In diesem Experiment verlieren alle Optionen ziemlich schnell (innerhalb von ein oder zwei Monaten) den Kontakt zum Markt, aber der Kern des Experiments war es, die erstellten neuronalen Netzwerk-Tools zu testen und nicht, ein komplettes System zu entwickeln.

    Im Allgemeinen scheint der durchschnittliche Dropout-Wert von 25% optimaler zu sein, da ein geringerer Grad der Regulierung zu einer Überanpassung führt und ein höherer Grad die Rechenkapazität des Netzes zerstört. Die wichtigste Schlussfolgerung, die wir vorläufig ziehen können, ist jedoch, dass der Ansatz der neuronalen Netze kein Allheilmittel ist, das jedes Handelssystem „retten“ kann. Die Fehler können durch falsche Annahmen über das Vorhandensein bestimmter Abhängigkeiten oder durch falsche Parameter verschiedener Algorithmusmodule oder falsch aufbereitete Daten verursacht werden.

    Bevor Sie die Entscheidung treffen, dieses (oder ein anderes) Handelssystem nicht zu verwenden, sollten Sie verschiedene Möglichkeiten ausprobieren, um die besten Netzwerkeinstellungen zu finden, wie es normalerweise bei EAs ohne KI der Fall ist. Wir müssen mehr Statistiken sammeln, um fundierte Schlussfolgerungen ziehen zu können.

    Insbesondere können wir nach anderen Symbolclustern oder Zeitrahmen suchen, die Optimierung mit den derzeit verfügbaren öffentlichen Variablen durchführen oder ihre Liste erweitern (z. B. durch Hinzufügen von Aktivierungsfunktionen, Vektorgenerierungsmethoden, Filterung nach Wochentagen usw.).

    Der Einsatz von NN entbindet den Händler keineswegs von der Notwendigkeit, Hypothesen aufzustellen, Ideen zu testen und wichtige Faktoren zu berücksichtigen. Der einzige Unterschied besteht darin, dass die Optimierung der Einstellungen des Handelssystems durch die Metaparameter des NN ergänzt wird.

    Versuchsweise führen wir Optimierungen der Vektorgröße, der Anzahl der Vektoren, des Größenfaktors der versteckten Schicht und des Dropouts durch. Darüber hinaus werden wir den Parameter Randomizer in die Optimierung einbeziehen. Auf diese Weise lassen sich für jede Kombination anderer Einstellungen mehrere Instanzen von Netzen erzeugen.

    • Vektorgröße (Tiefe) — von 1 bis 5
    • Trainingsdaten (Reserve) — von 50 bis 400 in Schritten von 50
    • Faktor der ausgeblendeten Schicht (Hidden Layer Factor) — von 1 bis 5
    • DropOut) — 0, 25%, 50%
    • Zufallsgenerator) — von 0 bis 9

    Die .set-Datei mit den Einstellungen ist unten angehängt. Das Datumsintervall reicht von 2022.01.01 bis 2023.02.15.

    Als Optimierungskriterium wird beispielsweise der Gewinnfaktor verwendet. Angesichts der geringen Anzahl von Kombinationen (6000) und ihrer vollständigen Iterationen (im Gegensatz zur genetischen Optimierung) ist dies jedoch nicht von Bedeutung.

    Um die Optimierungsergebnisse zu analysieren, können wir die Daten in eine XML-Datei exportieren oder direkt die .opt-Datei verwenden, wie im OLAP-Programm aus dem Artikel Quantitative und visuelle Analyse von Testerberichten beschrieben, oder ein anderes Skript verwenden (opt ist ein offenes Format).

    Statistische Analyse des Optimierungsberichts

    Statistische Analyse des Optimierungsberichts

    Für diesen Screenshot wurden die Variablen in den geforderten Unterteilungen (Reserve nach X (horizontale Achse) im Verhältnis zu HiddenLayerFactor nach Y (farblich markiert) mit DropOutPercentage 25% nach Z) unter Verwendung einer spezifischen Gewinnfaktorberechnung (nach Zellen in den X/Y/Z-Achsen) aus dem Wiedergewinnungsfaktor (aus jedem Durchlauf des Testers während der Optimierung) aggregiert. Ein solches künstliches Qualitätsmaß ist zwar nicht ideal, aber es ist bereits integriert.

    Ähnliche oder bekanntere Statistiken können in Excel berechnet werden.

    Eine statistisch bessere Leistung wurde mit einem Faktor von 1 für die verborgenen Schichten (anstelle von 2, wie standardmäßig eingestellt) und einer Vektorgröße von 4 (anstelle von 5) erzielt. Empfohlen wird ein Wert von 25% oder 50%, aber nicht 0%.

    Außerdem ist erwartungsgemäß eine tiefere Vorgeschichte vorzuziehen (350 oder 400 ‚counts‘ und wahrscheinlich ist eine weitere Erhöhung gerechtfertigt).

    Lassen Sie uns die gefundenen Arbeitseinstellungen zusammenfassen:

    • Vektorgröße = 4
    • Trainingsdaten = 400
    • Ausgeblendete Schicht Faktor = 1

    Da bei der Optimierung der Parameter Randomizer verwendet wurde, haben wir 30 Netzinstanzen in dieser Konfiguration trainiert: 10 Netze für jede Dropoutrate (0%, 25%, 50%). Wir brauchen 25% und 50%. Wenn wir den Optimierungsbericht in XML hochladen, können wir die erforderlichen Datensätze filtern und erhalten eine Tabelle (sortiert nach Rentabilität mit einem Filter größer als 1):

    Pass    Result  Profit  Expected Profit  Recovery Sharpe Custom  Equity Trades Depth  Reserve Hidden  DropOut Randomizer
    			Payoff	 Factor	 Factor	 Ratio	 	 DD %			      LayerF	Perc
    3838    1.35    336.02  2.41741  1.34991 1.98582 1.20187 1       1.61    139     4       400     1       25      6
    838     1.23    234.40  1.68633  1.23117 0.81474 0.86474 1       2.77    139     4       400     1       25      1
    3438    1.20    209.34  1.50604  1.20481 0.81329 0.78140 1       2.47    139     4       400     1       50      5
    5838    1.17    173.88  1.25094  1.16758 0.61594 0.62326 1       2.76    139     4       400     1       50      9
    5038    1.16    167.98  1.20849  1.16070 0.51542 0.60483 1       3.18    139     4       400     1       25      8
    3238    1.13    141.35  1.01691  1.13314 0.46758 0.48160 1       2.95    139     4       400     1       25      5
    2038    1.11    118.49  0.85245  1.11088 0.38826 0.41380 1       2.96    139     4       400     1       25      3
    4038    1.10    107.46  0.77309  1.09951 0.49377 0.38716 1       2.12    139     4       400     1       50      6
    1438    1.10    104.52  0.75194  1.09700 0.51681 0.37404 1       1.99    139     4       400     1       25      2
    238     1.07    73.33   0.52755  1.06721 0.19040 0.26499 1       3.69    139     4       400     1       25      0
    2838    1.03    34.62   0.24907  1.03111 0.10290 0.13053 1       3.29    139     4       400     1       50      4
    2238    1.02    21.62   0.15554  1.01927 0.05130 0.07578 1       4.12    139     4       400     1       50      3
    
    

    Nehmen wir die beste, die erste Zeile.

    Während der Optimierung werden alle trainierten Netze im Ordner MQL5/Files/<Expertenname>/<Optimierungsdatum> gespeichert. Eigentlich kann dies weggelassen werden, da ein ähnliches Netz durch den Randomizer-Wert neu trainiert werden kann, aber nur, wenn die Eingabedaten vollständig übereinstimmen. Wenn sich der Kursverlauf ändert (z. B. wenn Sie einen anderen Makler nutzen), ist es nicht möglich, das Netzwerk mit genau diesen Merkmalen zu reproduzieren.

    Die Dateien im angegebenen Ordner haben Namen, die aus den Namen und Werten der optimierten Parameter bestehen. Sie können also einfach das Dateisystem durchsuchen:

    Tiefe=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6

    Nehmen wir an, die Datei hat folgenden Namen:

    Depth=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6-3838(0.428079).bpn

    wobei die Zahl in Klammern der Netzwerkfehler ist, die Zahl vor den Klammern ist die Durchgangszahl.

    Werfen wir einen Blick in die Datei: Obwohl es sich um eine Binärdatei handelt, werden unsere Trainings-Metadaten als Text am Ende der Datei gespeichert. Das Trainingsintervall war also 2021.01.12 00:00-2022.07.28 00:00 (400 Balken D1 ).

    Wir kopieren die Datei unter einem kürzeren Namen, z. B. test3838.bpn, in den gemeinsamen Terminalordner.

    Geben Sie den Namen test3838.bpn im Parameter NetBinFileName an und setzen Sie die „Vektorgröße“ (Depth) auf 4 (alle anderen Parameter sind nicht von Bedeutung, wenn wir nur im Prognosemodus arbeiten).

    Überprüfen wir den EA-Handel über einen noch längeren Zeitraum: Da 2022-2023 als Validierungsvorwärtstest verwendet wurden, erfassen wir 2020 als unbekannten Zeitraum.

    Ein Beispiel für einen fehlgeschlagenen Prognosetest außerhalb des Trainingsdaten

    Ein Beispiel für einen fehlgeschlagenen Prognosetest außerhalb der Trainingsdaten

    Das Wunder ist nicht geschehen: Das System ist auch bei neuen Daten unrentabel. Dieses Bild würde auch für andere Einstellungen zutreffen.

    Wir haben also zwei Nachrichten: eine gute und eine schlechte.

    Die schlechte Nachricht ist, dass die vorgeschlagene Idee nicht funktioniert — entweder funktioniert sie überhaupt nicht oder es liegt an den Beschränkungen des untersuchten Faktorraums in unserer Demo (da wir keine Super-Mega-Optimierung für Milliarden von Kombinationen und Hunderte von Symbolen durchgeführt haben).

    Die gute Nachricht ist, dass das vorgeschlagene Toolkit für neuronale Netze zur Bewertung von Ideen verwendet werden kann und die (aus technischer Sicht) erwarteten Ergebnisse liefert.


    Schlussfolgerung

    In diesem Artikel werden Klassen von neuronalen Netzen mit Backpropagation vorgestellt, die MQL5-Matrizen verwenden. Die Implementierung hängt nicht von externen Programmen wie Python ab und erfordert keine spezielle Firmware (Grafikbeschleuniger mit OpenCL-Unterstützung). Zusätzlich zu den normalen Trainings- und Betriebsmodi für neuronale Netze bieten die Klassen Möglichkeiten zur Visualisierung des Prozesses sowie zum Speichern und Wiederherstellen von Netzen in Dateien.

    Mit diesen Klassen kann die Verwendung neuronaler Netze ganz einfach in jedes Programm integriert werden. Bitte beachten Sie jedoch, dass das Netz nur ein Werkzeug ist, das auf ein bestimmtes Material (in unserem Fall: Finanzdaten) angewendet wird. Wenn das Material nicht genügend Informationen enthält, sehr verrauscht oder irrelevant ist, wird kein neuronales Netz in der Lage sein, den heiligen Gral darin zu finden.

    Der Backpropagation-Algorithmus ist eine der gebräuchlichsten grundlegenden Lernmethoden, die als Basis für die Konstruktion komplexerer neuronaler Netztechnologien wie rekurrente Netze, Faltungsnetze und Verstärkungslernen verwendet werden kann.

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

    Beigefügte Dateien |
    MQL5bpnm.zip (22.73 KB)
    Ein Beispiel für die Zusammenstellung von ONNX-Modellen in MQL5 Ein Beispiel für die Zusammenstellung von ONNX-Modellen in MQL5
    ONNX (Open Neural Network eXchange) ist ein offenes Format zur Darstellung neuronaler Netze. In diesem Artikel zeigen wir Ihnen, wie Sie zwei ONNX-Modelle gleichzeitig in einem Expert Advisor verwenden können.
    Wie man ONNX-Modelle in MQL5 verwendet Wie man ONNX-Modelle in MQL5 verwendet
    ONNX (Open Neural Network Exchange) ist ein offenes Format, das zur Darstellung von Modellen des maschinellen Lernens entwickelt wurde. In diesem Artikel wird untersucht, wie ein CNN-LSTM-Modell zur Vorhersage von Finanzzeitreihen erstellt werden kann. Wir werden auch zeigen, wie man das erstellte ONNX-Modell in einem MQL5 Expert Advisor verwendet.
    Algorithmen zur Optimierung mit Populationen: Harmonie-Suche (HS) Algorithmen zur Optimierung mit Populationen: Harmonie-Suche (HS)
    In diesem Artikel werde ich den leistungsstärksten Optimierungsalgorithmus untersuchen und testen - die Harmonie-Suche (HS), inspiriert durch den Prozess der Suche nach der perfekten Klangharmonie. Welcher Algorithmus ist nun der führende in unserer Bewertung?
    Erstellung einer umfassenden Owl-Strategie des Handels Erstellung einer umfassenden Owl-Strategie des Handels
    Meine Strategie basiert auf den klassischen Handelsgrundlagen und der Verfeinerung von Indikatoren, die in allen Arten von Märkten weit verbreitet sind. Es handelt sich um ein fertiges Instrument, mit dem die neue profitable Handelsstrategie voll ausgeschöpft werden kann.