
Selbstoptimierender Expert Advisor mit MQL5 und Python (Teil VI): Die Vorteile des tiefen doppelten Abstiegs nutzen
Die Überanpassung beim maschinellen Lernen kann viele verschiedene Formen annehmen. Am häufigsten geschieht dies, wenn ein KI-Modell zu viel vom Rauschen in den Daten lernt und keine nützlichen Verallgemeinerungen machen kann. Dies führt zu einer miserablen Leistung, wenn wir das Modell anhand von Daten bewerten, die es noch nie gesehen hat. Es gibt viele Techniken, die entwickelt wurden, um die Überanpassung zu verringern, aber solche Methoden können sich oft als schwierig zu implementieren erweisen, vor allem, wenn Sie gerade erst mit der Arbeit beginnen. Ein kürzlich von einer Gruppe fleißiger Harvard-Absolventen veröffentlichtes Papier deutet jedoch darauf hin, dass die Überanpassung bei bestimmten Aufgaben ein Problem der Vergangenheit sein könnte. Dieser Artikel führt Sie durch das Forschungspapier und zeigt Ihnen, wie Sie KI-Modelle von Weltklasse im Einklang mit der weltweit führenden Forschung erstellen können.
Überblick über die Methodik
Es gibt viele Techniken, um bei der Entwicklung von KI-Modellen eine Überanpassung festzustellen. Die zuverlässigste Methode ist die Untersuchung der Diagramme für den Test- und Trainingsfehler des Modells. Anfänglich können die beiden Teile zusammen fallen, was ein gutes Zeichen ist. Wenn wir mit dem Training unseres Modells fortfahren, erreichen wir ein optimales Fehlerniveau, und sobald wir dieses überschreiten, sinkt unser Trainingsfehler weiter, aber unser Testfehler wird nur noch schlechter. Es wurden viele Techniken entwickelt, um dieses Problem zu lösen, z. B. das frühzeitige Anhalten. Vorzeitiges Beenden: Beendet das Trainingsverfahren, wenn sich der Validierungsfehler des Modells nicht signifikant ändert oder kontinuierlich verschlechtert. Danach werden die besten Gewichte wiederhergestellt, und es wird davon ausgegangen, dass das beste Modell gefunden wurde (siehe Abb. 1).
Abb. 1: Eine verallgemeinerte Darstellung, die die Überanpassung in der Praxis zeigt
Diese Vorstellungen wurden durch ein Forschungspapier aus dem Jahr 2019 mit dem Titel „Deep Double Descent“ (tiefer doppelter Abstieg) in ihren Grundfesten erschüttert; der Link ist hier zu finden. Das Papier versucht nicht, das dargestellte Phänomen zu erklären, sondern beschreibt lediglich die Merkmale des Phänomens, die zum Zeitpunkt der Abfassung beobachtet wurden. Im Wesentlichen zeigt das Papier, dass bei bestimmten Problemen der Testfehler des Modells zunächst sinkt, bevor er zu steigen beginnt und dann ein zweites Mal dramatisch fällt, bis er neue Tiefststände erreicht, bevor das Modell schließlich konvergiert, wie in Abb. 2 unten dargestellt.
Abb. 2: Visualisierung des Phänomens des tiefen doppelten Abstiegs
In dem Papier wird aufgezeigt, dass dieses Phänomen als eine Funktion folgender Faktoren betrachtet werden kann
- Die Parameter des Modells.
- Die maximale Anzahl von Trainingsiterationen.
Das heißt, wenn Sie immer größere Modelle auf demselben Datensatz trainieren, werden wir beobachten, dass unser Testfehler zunächst sinkt, bevor er wieder ansteigt, und wenn wir weiterhin größere Modelle trainieren, werden wir beobachten, dass unser Testfehler ein zweites Mal sinkt, und zwar auf einen neuen Tiefpunkt, wodurch ein Fehlerdiagramm ähnlich wie in Abb. 2 oben entsteht. Das schrittweise Trainieren immer größerer Modelle ist jedoch aufgrund des Rechenaufwands nicht immer machbar. Für unsere Diskussion werden wir das Phänomen des tiefen doppelten Abstiegs in Abhängigkeit von der maximalen Anzahl der zulässigen Iterationen untersuchen.
Die Idee dahinter ist, dass der Validierungsfehler des Modells mit zunehmender Anzahl von Trainingsiterationen immer weiter ansteigt, bevor er einen neuen Tiefpunkt erreicht. Die Zeit, die das Modell benötigt, um seine Fehlerhöchstwerte zu erreichen und zu sinken, hängt von verschiedenen Faktoren ab, z. B. von der Menge des Rauschens im Datensatz und der Art des zu trainierenden Modells.
Es gibt keine allgemein akzeptierten Erklärungen für dieses Phänomen, aber bisher ist es am einfachsten zu verstehen, wenn man sich den doppelten Abstieg als Funktion der Parameter des Modells vorstellt.
Nehmen wir an, wir beginnen mit einem einfachen neuronalen Netz, dann wird das Modell höchstwahrscheinlich unsere Daten nicht ausreichend berücksichtigen. Das bedeutet, dass die Leistung durch eine höhere Komplexität des Modells verbessert werden könnte. Wenn wir die Komplexität unseres neuronalen Netzes erhöhen, nähern wir uns langsam einem Punkt, an dem unser Modell genau zu unseren Daten passt. Beim traditionellen maschinellen Lernen wird uns beigebracht, dass der Trainingsfehler des Modells immer sinkt, wenn wir unser Modell komplexer machen. Das ist richtig. Dies ist jedoch nicht die ganze Wahrheit.
Sobald unser Modell so komplex ist, dass es perfekt zu unseren Daten passt, liegt der Trainingsfehler in der Regel nahe bei 0 und sinkt nicht mehr, wenn wir unser Modell komplexer machen. Dies ist der erste Schlag gegen die traditionellen Ideologien des maschinellen Lernens. Dieser Punkt wird im Allgemeinen als Interpolationsschwelle bezeichnet. Wenn wir die Komplexität des Modells über diesen Schwellenwert hinaus erhöhen, werden wir einen bemerkenswerten Rückgang der Testgenauigkeit feststellen. Und in den meisten Fällen werden die Fehlerquoten des Modells auf neue Tiefstwerte fallen und sich dort stabilisieren.
Algorithmen, die eine Überanpassung verhindern sollen, wie z. B. das frühzeitige Abbrechen, scheinen uns ungewollt zu behindern. Diese Algorithmen beenden das Trainingsverfahren immer, bevor wir den zweiten Abstieg beobachten können. Wir wollen das Phänomen des doppelten Abstiegs nachstellen, um es selbst zu beobachten.
Die ersten Schritte
Zunächst müssen wir unsere Daten von unserer MetaTrader 5-Plattform mit Hilfe eines Skripts extrahieren, das wir in MQL5 erstellt haben.
//+------------------------------------------------------------------+ //| ProjectName | //| Copyright 2020, CompanyName | //| http://www.companyname.net | //+------------------------------------------------------------------+ #property copyright "Gamuchirai Zororo Ndawana" #property link "https://www.mql5.com/en/users/gamuchiraindawa" #property version "1.00" #property script_show_inputs //+------------------------------------------------------------------+ //| Script Inputs | //+------------------------------------------------------------------+ input int size = 100000; //How much data should we fetch? //+------------------------------------------------------------------+ //| Global variables | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| On start function | //+------------------------------------------------------------------+ void OnStart() { //--- File name string file_name = "Market Data " + Symbol()+ ".csv"; //--- Write to file int file_handle=FileOpen(file_name,FILE_WRITE|FILE_ANSI|FILE_CSV,","); for(int i= size;i>=0;i--) { if(i == size) { FileWrite(file_handle,"Time","Open","High","Low","Close"); } else { FileWrite(file_handle,iTime(Symbol(),PERIOD_CURRENT,i), iOpen(Symbol(),PERIOD_CURRENT,i), iHigh(Symbol(),PERIOD_CURRENT,i), iLow(Symbol(),PERIOD_CURRENT,i), iClose(Symbol(),PERIOD_CURRENT,i) ); } } //--- Close the file FileClose(file_handle); } //+------------------------------------------------------------------+
Für den Anfang importieren wir zunächst die benötigten Bibliotheken.
#Standard libraries import pandas as pd import numpy as np import seaborn as sns import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D from sklearn.linear_model import LinearRegression from sklearn.neural_network import MLPRegressor from sklearn.metrics import mean_squared_error from sklearn.model_selection import cross_val_score,TimeSeriesSplit
Lesen wir nun die Daten ein
#Read in the data data = pd.read_csv('GBPUSD_Daily_20160103_20240131.csv',sep='\t')
und bereinigen unsere Daten.
#Clean up the data data.rename(columns={'<OPEN>':'Open','<HIGH>':'High','<LOW>':'Low','<CLOSE>':'Close'},inplace=True)
Jetzt streichen wir die unnötigen Spalten
#Drop columns we don't need data = data.drop(['<DATE>','<VOL>','<SPREAD>','<TICKVOL>'],axis=1) data
und visualisieren die Daten.
#Plot the close price plt.plot(data["Close"]) plt.xlabel("Time") plt.ylabel("Close Price") plt.title("GBPUSD Daily Close")
Abb. 3: Wir arbeiten mit den Tagesdaten von GBPUSD OHLC
Wir möchten ein Modell trainieren, das die täglichen Erträge des GBPUSD vorhersagt. Es gibt jedoch 2 Variablen, die wir auswählen müssen:
- Mit welcher Häufigkeit sollten wir die Erträge berechnen?
- Wie weit sollten wir in die Zukunft schauen?
In der Regel prognostizieren wir einen Schritt in die Zukunft und berechnen die Ergenisse als Differenz zwischen zwei aufeinanderfolgenden Tagen. Aber ist das wirklich optimal? Ist das das Beste, was wir zu jeder Zeit tun können? Wir werden diese Frage nicht beantworten, die Daten selbst werden diese Frage für uns beantworten.
Lassen Sie uns eine Rastersuche nach den Parametern für unsere Ergebnisse und unseren Prognosehorizont durchführen. Zunächst müssen wir für beide Parameter eine einheitliche Achse festlegen.
#Define the input range x_min , x_max = 2,100 #Look ahead y_min , y_max = 2,100 #Period
Definieren wir nun die x- und y-Achse.
#Sample input range uniformly x_axis = np.arange(x_min,x_max,4) #Look ahead y_axis = np.arange(y_min,y_max,4) #Period
Wir müssen ein Mesh-Gitter erstellen. Das Maschengitter besteht aus zwei individuellen, zweidimensionalen Feldern, die zusammen verwendet werden können, um alle möglichen Eingabekombinationen abzubilden, die wir bewerten wollen.
#Create a meshgrid
x , y = np.meshgrid(x_axis,y_axis)
Diese Funktion wird verwendet, um den Datensatz zu bereinigen, bevor wir die Genauigkeit unseres Modells mit den neuen Einstellungen testen, die wir bewerten möchten.
#This function will create and return a clean dataframe according to our specifications def clean_data(look_ahead,period): #Create a copy of the data temp = pd.read_csv('GBPUSD_Daily_20160103_20240131.csv',sep='\t') #Clean up the data temp.rename(columns={'<OPEN>':'Open','<HIGH>':'High','<LOW>':'Low','<CLOSE>':'Close'},inplace=True) temp = temp.drop(['<DATE>','<VOL>','<SPREAD>','<TICKVOL>'],axis=1) #Define our target temp["Target"] = temp["Close"].shift(-look_ahead) #Apply the differencing temp["Close"] = temp["Close"].diff(period) temp["Open"] = temp["Open"].diff(period) temp["High"] = temp["High"].diff(period) temp["Low"] = temp["Low"].diff(period) temp = temp.dropna() temp = temp.reset_index(drop=True) return(temp)
Unsere nächste Funktion führt eine Kreuzvalidierung unseres Modells unter den von uns übergebenen Einstellungen durch und gibt den Kreuzvalidierungsfehler zurück.
#Evaluate the objective function def evaluate(look_ahead,period): #Define the model model = LinearRegression() #Define our time series split tscv = TimeSeriesSplit(n_splits=5,gap=look_ahead) temp = clean_data(look_ahead,period) score = np.mean(cross_val_score(model,temp.loc[:,["Open","High","Low","Close"]],temp["Target"],cv=tscv)) return(score)
Schließlich brauchen wir eine Funktion, die unsere Ergebnisse in einem Array aufzeichnet, das die gleiche Form hat wie eines unserer Mesh-Gitter.
#Define the objective def objective(x,y): #Define the output matrix results = np.zeros([x.shape[0],y.shape[0]]) #Fill in the output matrix for i in np.arange(0,x.shape[0]): #Select the rows look_ahead = x[i] period = y[i] for j in np.arange(0,y.shape[0]): results[i,j] = evaluate(look_ahead[j],period[j]) return(results)
Bisher haben wir die Funktionen implementiert, die wir benötigen, um zu sehen, wie sich die Fehlerniveaus unseres Modells ändern, wenn wir das Intervall ändern, mit dem wir unsere Ergebnisse berechnen, und wie weit in die Zukunft wir prognostizieren wollen. Beobachten wir zunächst, wie sich ein einfaches Modell verhält, wenn wir diese Parameter ändern, bevor wir uns mit komplexeren, tiefen neuronalen Netzen befassen.
linear_reg_res = objective(x,y) linear_reg_res = np.abs(linear_reg_res)
Ein Höhenlinienplan wird in der Geografie häufig verwendet, um Höhenveränderungen in einem Gelände darzustellen. Anhand dieser Oberflächendiagramme können wir herausfinden, welches Parameterpaar die niedrigsten Fehlerwerte unseres einfachen linearen Regressionsmodells ergibt. Die blauen Bereiche sind Kombinationen, die einen geringen Fehler aufweisen, während die roten Bereiche nicht zufriedenstellende Kombinationen darstellen. Der weiße Punkt im dunkelsten, blauen Bereich unseres Konturdiagramms stellt die besten Vorhersageeinstellungen für unser lineares Regressionsmodell dar.
Wie aus der nachstehenden Grafik hervorgeht, hätte unser einfaches lineares KI-Modell jeden Händler auf dem Markt, der die klassische Ergebnisperiode von 1 verwendet und einen Schritt in die Zukunft prognostiziert, leicht übertroffen.
plt.contourf(x,y,linear_reg_res,100,cmap="jet") plt.plot(x_axis[linear_reg_res.min(axis=0).argmin()],y_axis[linear_reg_res.min(axis=1).argmin()],'.',color='white') plt.ylabel("Differencing Period") plt.xlabel("Forecast Horizon") plt.title("Linear Regression Accuracy Forecasting GBPUSD Daily Close")
Abb. 4: Unser Konturdiagramm der Genauigkeit unserer linearen Regression bei der Prognose des GBPUSD Daily
Die Visualisierung der Ergebnisse in 3D erzeugt eine Oberfläche, die es uns ermöglicht, die Beziehung zwischen unserem Modell und dem GBPUSD-Markt zu visualisieren. Die Grafik zeigt uns, dass unsere Fehlerquoten mit zunehmender Dauer der Vorhersage auf ein optimales Niveau sinken und mit zunehmender Dauer der Vorhersage ansteigen. Die wichtigste Erkenntnis ist jedoch, dass unser lineares Modell, wie Abbildung 5 unten zeigt, sowohl für den Prognosehorizont als auch für die Ergebnisperiode die besten Modellinputs im Bereich von 20 bis 40 aufweist.
#Create a surface plot fig , ax = plt.subplots(subplot_kw={"projection":"3d"}) fig.set_size_inches(8,8) ax.plot_surface(x,y,linear_reg_res,cmap="jet")
Abb. 5: Die Visualisierung des Fehlers unseres linearen Modells bei der Prognose der Ergebnisse mit Tages-GBPUSD
Da wir nun mit Kontur- und Oberflächendiagrammen vertraut sind, wollen wir sehen, wie unser tiefes neuronales Netz abschneidet, wenn wir es für die Suche im gleichen Parameterraum verwenden.
res = objective(x,y) res = np.abs(res)
Die Darstellung der Oberfläche unserer neuronalen Netze ist exponentiell komplexer. Die blauen Zonen sind wünschenswert, weil sie Kombinationen darstellen, die zu geringen Fehlern führen. Es ist jedoch zu beobachten, dass in der Mitte der optimalen Kombinationen plötzlich rote Bereiche auftauchen. Das ist sehr interessant, nicht wahr?
Wie können 2 Kombinationen so nahe beieinander liegen und dennoch sehr unterschiedliche Fehlerwerte aufweisen? Dies ist zum Teil auf die Art der Optimierungsalgorithmen zurückzuführen, die zum Trainieren neuronaler Netze verwendet werden. Wenn wir dieses Modell ein zweites Mal trainieren würden, würden wir ein völlig anderes Diagramm erhalten, mit einem anderen optimalen Punkt.
plt.contourf(x,y,res,100,cmap="jet") plt.plot(x_axis[res.min(axis=0).argmin()],y_axis[res.min(axis=1).argmin()],'.',color='white') plt.ylabel("Differencing Period") plt.xlabel("Forecast Horizon") plt.title("Neural Network Accuracy Forecasting GBPUSD Daily Close")
Abb. 6: Neuronale Netze sind sehr empfindlich gegenüber den Eingaben, die wir haben
Wenn wir die Leistung des Modells in 3D visualisieren, können wir sehen, wie instabil neuronale Netze sein können. Können wir mit Sicherheit sagen, dass das neuronale Netz tatsächlich nützliche Beziehungen gelernt hat? Welches Modell schneidet bisher besser ab? Wenn wir das Problem von der traditionellen Denkschule aus angehen, werden wir das einfache lineare Modell wählen, weil es glattere Fehlerkurven erzeugt, was ein Zeichen für mehr Kompetenz sein könnte, und die schwankenden Fehlerraten des neuronalen Netzes könnten als Zeichen für eine Überanpassung der Daten angesehen werden.
Dies ist jedoch ein klassischer Ansatz des maschinellen Lernens. In der modernen Denkschule sehen wir die Fehlerdiagramme des neuronalen Netzes als Hinweis darauf, dass das Modell noch nicht wirklich konvergiert hat, und nicht als Hinweis auf eine Überanpassung. Mit anderen Worten: Nach dem Artikel über den tiefen, doppelten Abstieg ist es für uns noch zu früh, das neuronale Netz zu vergleichen. Wir sollten versuchen, uns selbst davon zu überzeugen, anstatt Forschungspapieren aufgrund der Akkreditierung der Autoren blind zu vertrauen.
#Create a surface plot fig , ax = plt.subplots(subplot_kw={"projection":"3d"}) fig.set_size_inches(8,8) ax.plot_surface(x,y,res,cmap="jet")
Abb. 7: Unsere neuronalen Netzwerke prognostizieren die täglichen Ergebnisse mit GBPUSD
Test des doppelten Abstiegs
Wir werden zunächst die besten Parameter anwenden, die wir für die Berechnung der Ergebnisse gefunden haben und wie weit in die Zukunft wir voraussagen sollten.
#The best settings we have found so far look_ahead = x_axis[res.min(axis=0).argmin()] difference_period = y_axis[res.min(axis=1).argmin()] data["Target"] = data["Close"].shift(-look_ahead) #Apply the differencing data["Close"] = data["Close"].diff(difference_period) data["Open"] = data["Open"].diff(difference_period) data["High"] = data["High"].diff(difference_period) data["Low"] = data["Low"].diff(difference_period) data.dropna(inplace=True) data.reset_index(drop=True,inplace=True) data
Abb. 8: Unsere Daten in ihrer derzeitigen Form
Importieren der benötigten Bibliotheken.
from sklearn.model_selection import train_test_split,TimeSeriesSplit from sklearn.metrics import mean_squared_error
Dann legen wir die maximale Anzahl der Epochen fest. Erinnern Sie sich, dass der doppelte Abstieg eine Funktion der Modellkomplexität oder der maximalen Anzahl von Trainingsiterationen ist. Wir werden dies mit einem einfachen neuronalen Netz testen und die maximale Anzahl der Iterationen variieren. Unsere maximale Anzahl von Trainingsiterationen ist eine progressive Potenz von 2.
max_epoch = 50
Wir erstellen einen Datenrahmens zum Speichern unserer Fehlerstufen
err_rates = pd.DataFrame(columns = np.arange(0,max_epoch),index=["Train","Validation","Test"])
Wir müssen unser geteiltes Zeitreihenobjekt festlegen.
tscv = TimeSeriesSplit(n_splits=5,gap=look_ahead)
Nun führen wir eine Aufteilung der Trainingsdaten durch.
train , test = train_test_split(data,shuffle=False,test_size=0.5)
Die Kreuzvalidierung unseres Modells, wenn wir die maximale Anzahl der Iterationen als einheitliche Potenzen von 2 erhöhen.
for j in np.arange(0,max_epoch): #Define our model and measure its error current_train_err = [] current_val_err = [] model = MLPRegressor(hidden_layer_sizes=(6,5),max_iter=(2 ** j)) for i,(train_index,test_index) in enumerate(tscv.split(train)): #Assess the model model.fit(train.loc[train_index,["Open","High","Low","Close"]],train.loc[train_index,'Target']) current_train_err.append(mean_squared_error(train.loc[train_index,'Target'],model.predict(train.loc[train_index,["Open","High","Low","Close"]]))) current_val_err.append(mean_squared_error(train.loc[test_index,'Target'],model.predict(train.loc[test_index,["Open","High","Low","Close"]]))) #Record our observations err_rates.loc["Train",j] = np.mean(current_train_err) err_rates.loc["Validation",j] = np.mean(current_val_err) err_rates.loc["Test",j] = mean_squared_error(test['Target'],model.predict(test.loc[:,["Open","High","Low","Close"]]))
Die ersten 6 Iterationen zeigen, wie sich die Fehlerraten unseres Modells im Verlauf von 1 bis 32 Trainingsiterationen verändert haben. Wie aus der nachstehenden Grafik hervorgeht, begann unser Testfehler zunächst zu sinken, stieg dann an und erreichte schließlich einen höheren Tiefpunkt. Unsere Fehlerquoten beim Training und bei der Validierung stiegen zunächst an, bevor sie auf einen etwas höheren Tiefstand fielen und dann wieder anstiegen. Die 32 Iterationen stellen jedoch nur einen kleinen Teil des Trainingsverfahrens dar; beobachten wir, wie sich der Rest des Trainingsverfahrens entwickelt.
plt.plot(err_rates.iloc[0,0:5]) plt.plot(err_rates.iloc[1,0:5]) plt.plot(err_rates.iloc[2,0:5]) plt.legend(["Train Error","Validation Error","Test Error"]) plt.ylabel("RMSE") plt.xlabel("Epochs: Our Epochs Are Indices of 2") plt.title("Neural Network Accuracy Forecasting GBPUSD Daily Close")
Abb. 9: Unsere Validierungsgenauigkeit beim Übergang von 1 auf 32 Iterationen
Im weiteren Verlauf sehen wir nun, wie sich die Fehlerquoten unseres Modells über das Intervall von 64 bis 256 entwickeln. Es hat den Anschein, dass sich unsere Fehlerquoten nach einer gewissen Divergenz schließlich auf ein Minimum zubewegen. Dem Papier zufolge haben wir jedoch noch einen langen Weg vor uns.
Der Leser sollte wissen, dass scikit-learn standardmäßig neuronale Netze instanziiert, die nur 200 Iterationen durchführen. Dies ist eine Zahl, die etwas kleiner als 2 hoch 8 ist. Und mit Algorithmen wie dem frühzeitigen Stoppen wären wir in trügerischen lokalen Optima gefangen gewesen, irgendwo in den Hügeln und Tälern der unebenen Oberfläche, die wir in Abb. 7 oben beobachtet haben
plt.plot(err_rates.iloc[0,0:9]) plt.plot(err_rates.iloc[1,0:9]) plt.plot(err_rates.iloc[2,0:9]) plt.legend(["Train Error","Validation Error","Test Error"]) plt.ylabel("RMSE") plt.xlabel("Epochs: Our Epochs Are Indices of 2") plt.title("Neural Network Accuracy Forecasting GBPUSD Daily Close")
Abb. 10: Die Fehlerquoten unseres Modells beginnen zu konvergieren
Unsere optimalen Fehlerquoten wurden erreicht, wenn unser Modell mehr als 1 Milliarde Iterationen durchführen konnte! Die genaue Zahl ist 2 hoch 30. Dieser Punkt ist durch die rote vertikale Linie in Abb. 11 unten markiert. Normalerweise führen wir nur einen Bruchteil der optimalen Anzahl von Iterationen durch, da wir befürchten, die Daten zu stark anzupassen, was dazu führt, dass wir in den suboptimalen Fehlerbereichen links der roten Linie gefangen sind.
plt.plot(err_rates.iloc[0,:]) plt.plot(err_rates.iloc[1,:]) plt.plot(err_rates.iloc[2,:]) plt.axvline(err_rates.loc["Test",:].argmin(),color='red') plt.legend(["Train Error","Validation Error","Test Error","Double Descent Error"]) plt.ylabel("RMSE") plt.xlabel("Epochs: Our Epochs Are Indices of 2") plt.title("Neural Network Accuracy Forecasting GBPUSD Daily Close")
Abb. 11: Die Fehlerniveaus unseres tiefen doppelten Abstiegs sind durch die rote vertikale Linie markiert, und links sehen wir den klassischen Bereich des traditionellen maschinellen Lernens
Optimierung unseres neuronalen Netzes
Das Papier hat zweifellos einige Vorteile. Unter normalen Umständen würden wir nicht einmal im Entferntesten erwägen, zahlreiche Iterationen zuzulassen, aus Angst vor einer Überanpassung. Wir können unser Modell nun getrost optimieren, ohne eine Überanpassung an die Trainingsdaten befürchten zu müssen.
from sklearn.model_selection import RandomizedSearchCV
Initialisieren wir das Modell,
#Reinitialize the model model = MLPRegressor(max_iter=(err_rates.loc["Test",:].argmin()))
Definieren wir nun die Parameter, nach denen wir suchen wollen.
#Define the tuner tuner = RandomizedSearchCV( model, { "activation" : ["relu","logistic","tanh","identity"], "solver":["adam","sgd","lbfgs"], "alpha":[0.1,0.01,0.001,0.0001,0.00001,0.00001,0.0000001], "tol":[0.1,0.01,0.001,0.0001,0.00001,0.000001,0.0000001], "learning_rate":['constant','adaptive','invscaling'], "learning_rate_init":[0.1,0.01,0.001,0.0001,0.00001,0.000001,0.0000001], "hidden_layer_sizes":[(1,4),(5,8,10),(5,10,20),(10,50,10),(20,5),(1,5),(20,10)], "early_stopping":[True,False], "warm_start":[True,False], "shuffle": [True,False] }, n_iter=2**9, cv=5, n_jobs=-1, scoring="neg_mean_squared_error" )
Schließlich passen wir das Tuner-Objekt an.
tuner.fit(train.loc[:,["Open","High","Low","Close"]],train.loc[:,"Target"])
Dies sind besten Parameter, die wir gefunden haben.
tuner.best_params_
'tol': 0.1,
'solver': 'lbfgs',
'shuffle': False,
'learning_rate_init': 1e-06,
'learning_rate': 'adaptive',
'hidden_layer_sizes': (5, 8, 10),
'early_stopping': False,
'alpha': 1e-05,
'activation': 'relu'}
Umstellung auf ONNX
Nachdem wir nun unser Modell erstellt haben, können wir es in das ONNX-Format konvertieren. ONNX steht für Open Neural Network Exchange und ist ein Open-Source-Protokoll, das es uns ermöglicht, KI-Modelle in jeder Programmiersprache zu erstellen und einzusetzen, die die ONNX-API-Spezifikation unterstützt. Mit MQL5 können wir unsere KI-Modelle importieren und direkt in unseren Terminals einsetzen. Zunächst werden wir die benötigten Bibliotheken importieren.
import onnx from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType
Dann passen wir unser Modell an alle Daten an, die wir haben.
model = tuner.best_estimator_.fit(train.loc[:,["Open","High","Low","Close"]],train.loc[:,"Target"])
Wir geben die Eingabeform unseres Modells an
#Define the input shape of 1,4 initial_type = [('float_input', FloatTensorType([1, 4]))] #Specify the input shape onnx_model = convert_sklearn(model, initial_types=initial_type)
und speichern das ONNX-Modell.
#Save the onnx model onnx.save(onnx_model,"GBPUSD DAILY.onnx")
Abb. 12: Die Eingabe- und Ausgabeparameter unseres ONNX-Modells
Implementation in MQL5
Wir können nun mit der Implementierung unserer Handelsstrategie in MQL5 beginnen. Unsere Strategie wird sich auf den täglichen Zeitrahmen stützen. Wir verwenden eine Kombination aus Bollinger Bändern und gleitenden Durchschnitten, um den vorherrschenden Markttrend zu bestimmen.
Die Bollinger-Bänder werden üblicherweise verwendet, um überkaufte oder überverkaufte Wertpapiere zu identifizieren. Wenn die Kurse das obere Band erreichen, gilt das beobachtete Wertpapier in der Regel als überkauft. Wenn die Kurse überkauft sind, erwarten Händler in der Regel, dass die Kurse in Zukunft fallen und zum durchschnittlichen Kursniveau zurückkehren werden. Wir werden stattdessen das Bollinger Band in einer trendfolgenden Weise verwenden.
Wenn die Kurse die Mittellinie der Bänder überschreiten, betrachten wir dies als ein starkes Aufwärtssignal, und das Gegenteil ist der Fall, wenn die Kurse unter das mittlere Band fallen, betrachten wir dies als ein starkes Verkaufssignal. Solche einfachen Handelsregeln erzeugen zwangsläufig zu viele Signale, die nicht immer ideal sind. Stattdessen werden wir Kursschwankungen filtern, indem wir gleitende Durchschnittswerte anstelle der Kursbewegung selbst berücksichtigen.
Wir werden 2 gleitende Durchschnitte anwenden, einen auf den Höchstkurs und den anderen auf den Tiefstkurs, um einen gleitenden Durchschnittskanal zu erstellen. Unsere Einstiegssignale werden generiert, wenn beide gleitenden Durchschnitte die Mittellinie des Bollinger Bandes kreuzen und unser KI-Signal vorhersagt, dass sich der Preis tatsächlich in diese Richtung bewegen wird.
Schließlich werden unsere Positionen geschlossen, wenn die gleitenden Durchschnittskanäle die Mittellinie der Bollinger Bänder überschreiten oder wenn der gleitende Durchschnittskanal nach dem Ausbruch aus den Bändern wieder in die Bollinger Bänder zurückfällt, je nachdem, was zuerst eintritt.
Beginnen wir damit, dass wir zunächst unser ONNX-Modell laden.
//+------------------------------------------------------------------+ //| GBPUSD AI.mq5 | //| Gamuchirai Zororo Ndawana | //| https://www.mql5.com/en/gamuchiraindawa | //+------------------------------------------------------------------+ #property copyright "Gamuchirai Zororo Ndawana" #property link "https://www.mql5.com/en/gamuchiraindawa" #property version "1.00" //+------------------------------------------------------------------+ //| Load our ONNX file | //+------------------------------------------------------------------+ #resource "\\Files\\GBPUSD DAILY.onnx" as const uchar onnx_buffer[];
Als Nächstes müssen wir die Handelsbibliothek laden, die uns bei der Verwaltung unserer Positionen hilft.
//+------------------------------------------------------------------+ //| Libraries | //+------------------------------------------------------------------+ #include <Trade\Trade.mqh> CTrade Trade;
Wir brauchen auch einige globale Variablen für Daten, die wir in verschiedenen Teilen unserer Anwendung gemeinsam nutzen werden.
//+------------------------------------------------------------------+ //| Global variables | //+------------------------------------------------------------------+ bool patience = true; long onnx_model; int bb_handler,ma_h_handler,ma_l_handler; double ma_h_buffer[],ma_l_buffer[]; double bb_h_buffer[],bb_m_buffer[],bb_l_buffer[]; int state; double bid,ask; vectorf model_forecast = vectorf::Zeros(1);
Unsere technischen Indikatoren haben Periodenparameter, die der Endnutzer an veränderte Marktbedingungen anpassen können soll.
//+------------------------------------------------------------------+ //| User Inputs | //+------------------------------------------------------------------+ input group "Technical Indicators" input int bb_period = 60; input int ma_period = 14;
Wenn unsere Anwendung das erste Mal geladen wird, laden wir zuerst unsere technischen Indikatoren, bevor wir unser ONNX-Modell laden. Wir werden den ONNX-Puffer verwenden, den wir zu Beginn unseres Programms definiert haben, um ein ONNX-Modell aus diesem Puffer zu erstellen. Von dort aus werden wir überprüfen, ob unser ONNX-Modell solide ist und ob unsere Eingabe- und Ausgabeparameter mit unseren Spezifikationen übereinstimmen.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Setup technical indicators bb_handler = iBands(Symbol(),PERIOD_D1,bb_period,0,1,PRICE_CLOSE); ma_h_handler = iMA(Symbol(),PERIOD_D1,ma_period,0,MODE_SMA,PRICE_HIGH); ma_l_handler = iMA(Symbol(),PERIOD_D1,ma_period,0,MODE_SMA,PRICE_LOW); //--- Define our ONNX model ulong input_shape [] = {1,4}; ulong output_shape [] = {1,1}; //--- Create the model onnx_model = OnnxCreateFromBuffer(onnx_buffer,ONNX_DEFAULT); if(onnx_model == INVALID_HANDLE) { Comment("[ERROR] Failed to load AI module correctly"); return(INIT_FAILED); } //--- Validate I/O if(!OnnxSetInputShape(onnx_model,0,input_shape)) { Comment("[ERROR] Failed to set input shape correctly: ",GetLastError()," Actual shape: ",OnnxGetInputCount(onnx_model)); return(INIT_FAILED); } if(!OnnxSetOutputShape(onnx_model,0,output_shape)) { Comment("[ERROR] Failed to load AI module correctly: ",GetLastError()," Actual shape: ",OnnxGetOutputCount(onnx_model)); return(INIT_FAILED); } //--- Everything was okay return(INIT_SUCCEEDED); }
Wenn unsere Handelsanwendung nicht mehr in Gebrauch ist, geben wir die nicht mehr benötigten Ressourcen frei.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- OnnxRelease(onnx_model); IndicatorRelease(bb_handler); IndicatorRelease(ma_h_handler); IndicatorRelease(ma_l_handler); }
Schließlich werden wir unsere globalen Variablen immer dann aktualisieren, wenn wir neue Preisnotierungen erhalten. Der nächste Schritt hängt von der Anzahl der offenen Stellen ab, die wir zu besetzen haben. Wenn nicht, suchen wir nach einem Einstiegssignal. Andernfalls werden wir auf Ausstiegssignale achten.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- Update technical data update(); if(PositionsTotal() == 0) { patience = true; check_setup(); } if(PositionsTotal() > 0) { string direction = model_forecast[0] > iClose(Symbol(),PERIOD_D1,0) ? "UP" : "DOWN"; Comment("Model Forecast: ",model_forecast[0]," ",direction); close_setup(); } }
Die folgende Funktion liefert eine Prognose aus unserem Modell.
//+------------------------------------------------------------------+ //| Get a prediction from our model | //+------------------------------------------------------------------+ void model_predict(void) { double o,h,l,c; vector op,hi,lo,cl; op.CopyRates(Symbol(),PERIOD_D1,COPY_RATES_OPEN,0,3); hi.CopyRates(Symbol(),PERIOD_D1,COPY_RATES_HIGH,0,3); lo.CopyRates(Symbol(),PERIOD_D1,COPY_RATES_LOW,0,3); cl.CopyRates(Symbol(),PERIOD_D1,COPY_RATES_CLOSE,0,3); o = op[2] - op[0]; h = hi[2] - hi[0]; l = lo[2] - lo[0]; c = cl[2] - cl[0]; vectorf model_inputs = vectorf::Zeros(4); model_inputs[0] = o; model_inputs[1] = h; model_inputs[2] = l; model_inputs[3] = c; OnnxRun(onnx_model,ONNX_DEFAULT,model_inputs,model_forecast); }
Nun werden wir festlegen, wie unsere Anwendung ihre Positionen schließen soll. Die boolesche Variable „patience“ (Geduld) wird verwendet, um zu steuern, wann die Anwendung unsere Positionen schließen soll. Wenn der Kanal des gleitenden Durchschnitts nicht aus den Bollinger Bändern ausgebrochen ist, als unsere Positionen ursprünglich eröffnet wurden, wird die „patience“ auf true gesetzt. Der Wert bleibt so lange gültig, bis der Kanal der gleitende Durchschnitte aus den Bändern ausbricht. Zu diesem Zeitpunkt wird „patience“ auf false gesetzt, und wenn der Kanal wieder in die Bänder fällt, werden unsere Positionen geschlossen.
//+------------------------------------------------------------------+ //| Close our open positions | //+------------------------------------------------------------------+ void close_setup(void) { if(patience) { if(state == 1) { if(ma_l_buffer[0] > bb_h_buffer[0]) { patience = false; } if((ma_h_buffer[0] < bb_m_buffer[0]) && (ma_l_buffer[0] < bb_m_buffer[0])) { Trade.PositionClose(Symbol()); } } else if(state == -1) { if(ma_h_buffer[0] < bb_l_buffer[0]) { patience = false; } if((ma_h_buffer[0] > bb_m_buffer[0]) && (ma_l_buffer[0] > bb_m_buffer[0])) { Trade.PositionClose(Symbol()); } } } else { if((state == -1) && (ma_l_buffer[0] > bb_l_buffer[0])) { Trade.PositionClose(Symbol()); } if((state == 1) && (ma_h_buffer[0] < bb_h_buffer[0])) { Trade.PositionClose(Symbol()); } } }
Damit wir das Setup als gültig betrachten können, muss der Kanal des gleitenden Durchschnitts vollständig auf einer Seite des mittleren Bandes liegen und unsere KI-Prognose mit der Preisbewegung übereinstimmen. Andernfalls werden wir einfach abwarten, anstatt flüchtigen Preisschwankungen nachzujagen.
//+------------------------------------------------------------------+ //| Check for valid trade setups | //+------------------------------------------------------------------+ void check_setup(void) { if((ma_h_buffer[0] < bb_m_buffer[0]) && (ma_l_buffer[0] < bb_m_buffer[0])) { model_predict(); if((model_forecast[0] < iClose(Symbol(),PERIOD_CURRENT,0))) { if(ma_h_buffer[0] < bb_l_buffer[0]) patience = false; Trade.Sell(0.3,Symbol(),bid,0,0,"GBPUSD AI"); state = -1; } } if((ma_h_buffer[0] > bb_m_buffer[0]) && (ma_l_buffer[0] > bb_m_buffer[0])) { model_predict(); if(model_forecast[0] > iClose(Symbol(),PERIOD_CURRENT,0)) { if(ma_l_buffer[0] > bb_h_buffer[0]) patience = false; Trade.Buy(0.3,Symbol(),ask,0,0,"GBPUSD AI"); state = 1; } } }
Schließlich brauchen wir eine Funktion, die für die Aktualisierung unserer globalen Variablen verantwortlich ist.
//+------------------------------------------------------------------+ //| Update our market data | //+------------------------------------------------------------------+ void update(void) { CopyBuffer(bb_handler,0,0,1,bb_m_buffer); CopyBuffer(bb_handler,1,0,1,bb_h_buffer); CopyBuffer(bb_handler,2,0,1,bb_l_buffer); CopyBuffer(ma_h_handler,0,0,1,ma_h_buffer); CopyBuffer(ma_l_handler,0,0,1,ma_l_buffer); } //+------------------------------------------------------------------+
Wir können nun unsere Handelsstrategie einem Backtest unterziehen. Wir haben den Strategietester verwendet, um unsere Anwendung anhand der Tagesdaten von GBPUSD über etwa 3 Jahre zu bewerten. Beachten Sie, dass wir bei der Erstellung unseres KI-Modells die Tagesdaten von 2016 bis 2024 verwendet haben. Daher testet der unten gezeigte Backtest unsere KI-Strategie effektiv mit Daten, die das Modell bereits gesehen hat. Obwohl unser Modell mit den Daten in Berührung gekommen ist und gut trainiert wurde, war unser Kontostand im Laufe der Zeit sehr volatil.
Dies zeigt, dass sich KI-Modelle, obwohl wir unser Modell gut trainiert haben, nicht an das „Gelernte“ erinnern, wie es ein Mensch tut. Es wird versucht, eine Formel zu erstellen, die sich gut auf die Daten anwenden lässt. Das bedeutet, dass es bei Daten, auf die es bereits trainiert wurde, immer noch Fehler machen kann.
Abb. 13: Test unserer Anwendung anhand der Tagesdaten von GBPUSD über etwa 3 Jahre
Abb. 14: Details zur Handelsleistung unseres Modells
Schlussfolgerung
Zusammenfassend haben wir gezeigt, dass der Anschein einer „Überanpassung“ unter bestimmten Umständen nur eine Aufforderung zu größeren Anstrengungen sein kann. Die klassische Ideologie der Überanpassung von KI-Modellen hat uns bis zu einem gewissen Grad in suboptimalen Fehlerniveaus gefangen gehalten. Wir sind jedoch zuversichtlich, dass Sie nach der Lektüre dieses Artikels in der Lage sein werden, Ihre Modelle besser zu nutzen. Der Leser wird sich daran erinnern, dass wir auch die Möglichkeit hatten, einfach die Anzahl der versteckten Schichten im Modell zu erhöhen oder einfach ein Modell mit einer Schicht zu trainieren und die Breite der Schicht des Modells zu erhöhen. Das Training solch umfangreicher Modelle erfordert jedoch einen völlig anderen Ansatz, der Fähigkeiten im parallelen Rechnen voraussetzt.
In diesem Artikel wurde ein rechnerisch kostengünstiger Ansatz vorgestellt, bei dem stattdessen ein Basismodell mit fester Größe trainiert wird und aufgrund der geringen Anzahl von Zeilen, die in einem solchen Zeitrahmen verarbeitet werden müssen, tägliche Daten verwendet werden. Damit unsere Ergebnisse jedoch schlüssig und robust sind, müssen wir unsere Trainingsmenge möglicherweise auf die Hälfte reduzieren, sodass unser Modell von 2016 bis 2020 trainiert wird und alle Daten von 2020 bis 2024 während des Trainings nicht auf unser Modell treffen.
Übersetzt aus dem Englischen von MetaQuotes Ltd.
Originalartikel: https://www.mql5.com/en/articles/15971





- Freie Handelsapplikationen
- Über 8.000 Signale zum Kopieren
- Wirtschaftsnachrichten für die Lage an den Finanzmärkte
Sie stimmen der Website-Richtlinie und den Nutzungsbedingungen zu.