
Entrenamos un perceptrón multicapa usando el algoritmo de Levenberg-Marquardt
Introducción
El objetivo de este artículo será ofrecer a los tráders en activo un algoritmo eficaz para entrenar redes neuronales, una variante del método de optimización newtoniano conocido como algoritmo de Levenberg-Marquardt. Se trata de uno de los algoritmos más rápidos para entrenar redes neuronales de propagación directa, al que solo puede plantar cara el algoritmo Broyden-Fletcher-Goldfarb-Shanno (L-BFGS).
Los métodos de optimización estocástica, como el descenso de gradiente estocástico (SGD) y Adam, resultan muy adecuados para el entrenamiento offline cuando la red neuronal se reentrena durante largos periodos de tiempo. Si un tráder que utiliza redes neuronales quiere que el modelo se adapte rápidamente a unas condiciones comerciales en cambio constante, necesitará reentrenar la red online en cada nueva barra, o tras un breve periodo de tiempo. En tal caso, los algoritmos que, además de información sobre el gradiente de la función de pérdida, también usan información adicional sobre las segundas derivadas parciales, lo cual permitirá encontrar el mínimo local de la función de pérdida en unas pocas épocas de entrenamiento.
De momento, que yo sepa, no existe ninguna implementación de acceso abierto del algoritmo Levenberg-Marquardt en MQL5. Así que ha llegado la hora de llenar este vacío, y también de repasar brevemente los algoritmos de optimización más conocidos y sencillos, como el descenso de gradiente, el descenso de gradiente con impulso y el descenso de gradiente estocástico. Al final del artículo, realizaremos algunas pruebas de rendimiento del algoritmo Levenberg-Marquardt y de algoritmos de la biblioteca de aprendizaje automático scikit-learn.
El conjunto de datos
En todos los ejemplos siguientes se usarán datos sintéticos para simplificar la presentación. Como única variable predictiva se utilizará el tiempo, mientras que, en calidad de variable objetivo que queremos predecir utilizando la red neuronal, se usará la función:
1 + sin(pi/4*time) + NormDistr(0,sigma)
Esta función constará de una parte determinista, representada por un componente periódico en forma de seno, y una parte estocástica, representada por ruido blanco gaussiano. En total, tendremos 81 puntos de datos. A continuación le mostramos un gráfico de esta función y su aproximación mediante un perceptrón de tres capas.
Fig.(1) La función objetivo y su aproximación usando un perceptrón de tres capas
Descenso de gradiente
Comenzaremos aplicando el descenso de gradiente ordinario como método más sencillo para entrenar redes neuronales. Para ello, utilizaremos como plantilla un ejemplo muy bueno del libro de referencia MQL5 (Métodos de matrices y vectores/Aprendizaje automático). Lo hemos modificado ligeramente añadiendo la posibilidad de seleccionar la función de activación para la última capa de la red y haciendo universal la implementación del descenso de gradiente, capaz de aprender no solo con la función de pérdida cuadrática, como se supone implícitamente en el ejemplo del libro de referencia, sino con todas las funciones de pérdida disponibles en MQL5. La función de pérdida resulta fundamental para el entrenamiento de redes neuronales, y a veces merecerá la pena experimentar con distintas funciones más allá de la pérdida cuadrática. Aquí tenemos la fórmula general para calcular el error de la capa de salida (delta):
aquí,
- delta_k — error de la capa de salida,
- E — función de pérdida,
- g'(a_k) — derivada de la función de activación,
- a_k — preactivación de la última capa,
- y_k — valor predicho de la red.
//--- Производная функции потерь по предсказанному значению matrix DerivLoss_wrt_y = result_.LossGradient(target,loss_func); matrix deriv_act; if(!result_.Derivative(deriv_act, ac_func_last)) return false; matrix loss = deriv_act*DerivLoss_wrt_y; // loss = delta_k
Las derivadas parciales de la función de pérdida sobre el valor predicho de la red se calcularán mediante la función LossGradient, mientras que la derivada de la función de activación se calculará mediante la función Derivative. En el ejemplo de la referencia, la diferencia entre el valor objetivo y el valor predicho de la red multiplicado por 2 se utilizará como error de la capa de salida.
matrix loss = (target - result_)*2;
En la literatura sobre aprendizaje automático, el error de cada capa de la red suele denominarse delta(D2,D1, etc.) en lugar de pérdida (véase, por ejemplo, Bishop(1995)). En el futuro, utilizaremos esta denominación concreta en el código.
Bien, ¿cómo hemos llegado a este resultado? Aquí asumiremos implícitamente que la función de pérdida es la suma de cuadrados de las diferencias entre el valor objetivo y el predicho, en lugar del error cuadrático medio (ECM), que además se normalizará por el tamaño de la muestra de entrenamiento. La derivada de esta función de pérdida será exactamente igual a (target - result)*2. Y como la última capa de la red utiliza una función de activación idéntica cuya derivada es igual a uno, llegaremos a este resultado. Por lo tanto, aquellos que deseen usar funciones de pérdida arbitrarias y funciones de activación de la capa de salida para entrenar la red deberán utilizar la fórmula general anterior.
Vamos a entrenar ahora nuestra red con la función de pérdida RMS. Aquí, para mayor claridad, hemos representado el gráfico en escala logarítmica.
Fig.2 Función de pérdida MSE, descenso de gradiente
Por término medio, el algoritmo de descenso de gradiente necesita 1.500-2.000 épocas (es decir, pasadas por todo el conjunto de datos de entrenamiento) para alcanzar el umbral mínimo de la función de pérdida. Para ello, hemos utilizado dos capas ocultas de 5 neuronas cada una.
La línea roja del gráfico indica el umbral mínimo de la función de pérdida. Se define como la varianza del ruido blanco gaussiano. Aquí hemos utilizado ruido con una varianza igual a 0,01 (0,1 sigma* 0,1 sigma).
¿Qué ocurrirá si dejamos que el modelo de red neuronal se entrene por debajo de este umbral mínimo? Entonces nos encontraremos con un fenómeno indeseable como el sobreentrenamiento de la red. No tiene sentido intentar que el error de la función de pérdida con el conjunto de datos de entrenamiento sea inferior a un umbral mínimo, ya que esto afectará al poder predictivo del modelo con el conjunto de datos de prueba. Y aquí nos enfrentaremos al hecho de que es imposible predecir una serie con más exactitud de la que permite la dispersión estadística de dicha serie. Si dejamos de aprender por encima del umbral mínimo, nos enfrentaremos a otro problema: la red estará poco entrenada. Es decir, tendremos una red que no habrá comprendido plenamente el componente predictivo de la serie.
Como podemos ver, el descenso de gradiente necesita pasar por bastantes iteraciones para alcanzar el conjunto óptimo de parámetros. Y eso en el caso de un conjunto de datos sencillo como el nuestro. Para los problemas prácticos reales, el tiempo de entrenamiento del descenso de gradiente resulta inaceptable. Una de las formas más sencillas de mejorar la convergencia y la velocidad del descenso de gradiente es el método de impulso.
Descenso de gradiente con impulso
La idea que subyace al descenso de gradiente con impulso consiste en suavizar la trayectoria de los parámetros de la red durante el entrenamiento, promediando los parámetros mediante un tipo de media exponencial ordinaria. Del mismo modo que suavizamos la serie temporal de los precios de los instrumentos financieros con el promedio para resaltar la dirección principal, del mismo modo tendremos derecho a suavizar la trayectoria del vector paramétrico que se desplaza hacia el punto de mínimo local de nuestra función de pérdida. Para visualizarlo mejor, echemos un vistazo a este gráfico, que muestra cómo han cambiado los valores de los dos parámetros, desde el inicio del entrenamiento hasta el punto de mínimo de la función de pérdida. La figura 3 muestra la trayectoria sin utilizar el impulso.
Fig.3 Descenso de gradiente sin impulso
Podemos ver que, a medida que nos acercamos al mínimo, el vector de parámetros empieza a oscilar caóticamente, lo cual nos impedirá alcanzar el punto óptimo. Para eliminar este fenómeno, deberemos reducir el coeficiente de aprendizaje. Entonces, obviamente, el algoritmo empezará a converger, pero el tiempo empleado en la búsqueda podría aumentar considerablemente.
La Fig. 4 muestra la trayectoria del vector de parámetros usando el impulso (con valor=0,9). Esta vez la trayectoria resulta más suave y llegaremos tranquilamente al punto óptimo. Y ahora podremos incluso aumentar el factor de velocidad de aprendizaje. En realidad, esta es la idea principal del descenso de gradiente con impulso: acelerar el proceso de convergencia.
Fig.(4) Descenso de gradiente, momentum (0,9)
El script Momentum_SD implementa el algoritmo de descenso de gradiente con impulso. En él, hemos decidido deshacernos de una capa oculta y separar los pesos y los desplazamientos de la red, para mayor claridad de percepción. Ahora solo tendremos una capa oculta con 20 neuronas en lugar de dos capas ocultas de 5 neuronas cada una como en el ejemplo anterior.
//+------------------------------------------------------------------+ //| Momentum_SD.mq5 | //| Eugene | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Eugene" #property link "https://www.mql5.com" #property version "1.00" #property script_show_inputs #include <Graphics\Graphic.mqh> #include <Math\Stat\Math.mqh> #include <Math\Stat\Normal.mqh> enum Plots { LossFunction_plot, target_netpredict_plot }; matrix weights1, weights2,bias1,bias2; // network parameter matrices matrix dW1,db1,dW2,db2; // weight increment matrices matrix n1,n2,act1,act2; // neural layer output matrices input int layer1 = 20; // neurons Layer 1 input int Epochs = 1000; // Epochs input double lr = 0.1; // learning rate coefficient input double sigma_ = 0.1; // standard deviation synthetic data input double gamma_ = 0.9; // momentum input Plots plot_ = LossFunction_plot; // display graph input bool plot_log = false; // Plot Log graph input ENUM_ACTIVATION_FUNCTION ac_func = AF_TANH; // Activation Layer1 input ENUM_ACTIVATION_FUNCTION ac_func_last = AF_LINEAR; // Activation Layer2 input ENUM_LOSS_FUNCTION loss_func = LOSS_MSE; // Loss function double LossPlot[],target_Plot[],NetOutput[]; matrix ones_; int Sample_,Features; //+------------------------------------------------------------------+ //| Функция запуска скрипта | //+------------------------------------------------------------------+ void OnStart() { //--- генерируем обучающую выборку matrix data, target; Func(data,target); StandartScaler(data); Sample_= (int)data.Rows(); Features = (int)data.Cols(); ArrayResize(target_Plot,Sample_); for(int i=0; i< (int)target.Rows(); i++) { target_Plot[i] =target[i,0]; } ones_ = matrix::Ones(1,Sample_); ulong start=GetMicrosecondCount(); //--- обучаем модель if(!Train(data, target, Epochs)) return; ulong end = (GetMicrosecondCount()-start)/1000; Print("Learning time = " + (string)end + " msc"); //--- генерируем тестовую выборку Func(data,target); StandartScaler(data); //--- тестирование модели Test(data, target); //--- отображаем графики PlotGraphic(15,plot_log); } //+------------------------------------------------------------------+ //| Метод обучения модели | //+------------------------------------------------------------------+ bool Train(matrix &data, matrix &target, const int epochs) { //--- создаем модель if(!CreateNet()) return false; ArrayResize(LossPlot,Epochs); //--- обучаем модель for(int ep = 0; ep < epochs; ep++) { //--- прямой проход if(!FeedForward(data)) return false; PrintFormat("Epoch %d, loss %.5f", ep, act2.Loss(target, loss_func)); LossPlot[ep] = act2.Loss(target, loss_func); //--- обратный проход и обновление матриц весов if(!Backprop(data, target)) return false; } //--- double rmse=act2.RegressionMetric(target.Transpose(),REGRESSION_RMSE); PrintFormat("rmse %.3f / sigma %.2f ",rmse,sigma_); ArrayResize(NetOutput,Sample_); for(int i=0; i< (int)act2.Cols(); i++) { NetOutput[i] =act2.Transpose()[i,0]; } //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод создания модели | //+------------------------------------------------------------------+ bool CreateNet() { //--- инициализируем матрицы весов if(!weights1.Init(layer1,Features) || !weights2.Init(1,layer1)) return false; //--- инициализируем матрицы смещений if(!bias1.Init(layer1,1) || !bias2.Init(1,1)) return false; //--- инициализируем матрицы приращений параметров dW1.Init(layer1,Features); dW2.Init(1, layer1); db1.Init(layer1,1); db2.Init(1,1); dW1.Fill(0); dW2.Fill(0); db1.Fill(0); db2.Fill(0); //--- заполняем матрицы параметров случайными значениями weights1.Random(-0.1, 0.1); weights2.Random(-0.1, 0.1); bias1.Random(-0.1,0.1); bias2.Random(-0.1,0.1); //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод прямого прохода | //+------------------------------------------------------------------+ bool FeedForward(matrix &data) { //--- вычисляем первый нейронный слой //--- n1 предактивация первого слоя n1 = weights1.MatMul(data.Transpose()) + bias1.MatMul(ones_); //--- вычисляем функцию активации первого слоя act1 n1.Activation(act1, ac_func); //--- вычисляем второй нейронный слой //--- n2 предактивация второго слоя n2 = weights2.MatMul(act1) + bias2.MatMul(ones_); //--- вычисляем функцию активации второго слоя act2 n2.Activation(act2, ac_func_last); //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод обратного прохода | //+------------------------------------------------------------------+ bool Backprop(matrix &data, matrix &target) { //--- Производная функции потерь по предсказанному значению matrix DerivLoss_wrt_y = act2.LossGradient(target.Transpose(),loss_func); matrix deriv_act2; n2.Derivative(deriv_act2, ac_func_last); //--- D2 matrix D2 = deriv_act2*DerivLoss_wrt_y; // ошибка(delta) выходного слоя сети //--- D1 matrix deriv_act1; n1.Derivative(deriv_act1, ac_func); matrix D1 = weights2.Transpose().MatMul(D2); D1 = D1*deriv_act1; // ошибка (delta) первого слоя сети //--- обновляем параметры сети matrix ones = matrix::Ones(data.Rows(),1); dW1 = gamma_*dW1 + (1-gamma_)*(D1.MatMul(data)) * lr; db1 = gamma_*db1 + (1-gamma_)*(D1.MatMul(ones)) * lr; dW2 = gamma_*dW2 + (1-gamma_)*(D2.MatMul(act1.Transpose())) * lr; db2 = gamma_*db2 + (1-gamma_)*(D2.MatMul(ones)) * lr; weights1 = weights1 - dW1; weights2 = weights2 - dW2; bias1 = bias1 - db1; bias2 = bias2 - db2; //--- возвращаем результат return true; }
Gracias al impulso, hemos podido aumentar el ritmo de aprendizaje de 0,1 a 0,5. El algoritmo converge ahora en 150-200 iteraciones en lugar de las 500 del descenso de gradiente convencional.
Fig.(5) Función de pérdida MSE, MLP(1-20-1) SD_Momentum
Descenso de gradiente estocástico
Momentum es bueno, pero cuando en el conjunto de datos no hay 81 puntos de datos, como en nuestro ejemplo, sino decenas de miles de instancias de datos, tiene sentido hablar de un algoritmo bien establecido (y sencillo) como el SGD. ¿Qué supone dicho algoritmo? Es lo mismo que el descenso de gradiente, pero el gradiente no se calcula sobre todo el conjunto de entrenamiento, sino solo sobre una parte muy pequeña de este conjunto (minilotes), o sobre un único punto de datos. A continuación, se actualizan los pesos de la red, se elige aleatoriamente un nuevo punto de datos y se repite el proceso hasta que el algoritmo converja. Por eso el algoritmo se llama estocástico, en el descenso de gradiente convencional actualizamos los pesos de la red solo después de haber calculado el gradiente en todo el conjunto de datos, es el llamado método por lotes.
Implementaremos una variante de SGD en la que solo se utilizará un punto de datos como minipaquete.
Fig.(6) Función de pérdida en escala logarítmica, SGD
El algoritmo SGD (batch_size = 1) converge al límite mínimo en 4-6 mil iteraciones, pero recordemos que solo usaremos un ejemplo de entrenamiento de 81 para actualizar el vector de parámetros. Por ello, el algoritmo convergirá en este conjunto de datos en aproximadamente 50-75 épocas. No es una mala mejora respecto al algoritmo anterior, ¿cierto? Aquí también hemos utilizado el impulso, pero como se usa un único punto de datos, no tendrá demasiado efecto en la velocidad de convergencia.
Algoritmo de Levenberg-Marquardt
Por alguna razón este viejo algoritmo está completamente olvidado hoy en día, aunque si nuestra red tiene hasta un par de cientos de parámetros, no encontrará nada igual a él junto con el L-BFGS.
Pero hay un punto importante a considerar. El algoritmo LM está diseñado para minimizar funciones que sean sumas de cuadrados de otras funciones no lineales. Por consiguiente, para este método, nos limitaremos únicamente a la función de pérdida cuadrática o RMS. En igualdad de condiciones, esta función de pérdida hace un gran trabajo y no existirá mayor problema, pero debemos saber que no podremos entrenar la red utilizando este algoritmo en otras funciones.
Vamos ahora a explicar cómo surgió este algoritmo. Empecemos por el método de Newton:
aquí,
A – matriz de Hesse inversa de la función de pérdida F(x),
g – gradiente de la función de pérdida F(x),
x – vector de parámetros
Ahora echaremos un vistazo a nuestra función de pérdida cuadrática:
aquí, v será el error de la red (valor predicho menos objetivo), mientras que x será un vector de parámetros de red que incluirá todos los pesos y desplazamientos de cada capa.
Busquemos ahora el gradiente de esta función de pérdida:
En forma de matriz, podemos escribirlo de la siguiente manera:
El punto clave será la matriz de Jacobi:
La matriz de Jacobi, en cada fila, contendrá todas las derivadas parciales del error de red sobre todos los parámetros. Cada fila se corresponderá con un ejemplo del conjunto de entrenamiento.
Veamos ahora la matriz de Hesse. Es la matriz de las segundas derivadas parciales de la función de pérdida. El cálculo del hessiano es una tarea difícil y costosa, por lo que utilizaremos la aproximación del hessiano usando la matriz de Jacobi:
Si sustituimos las fórmulas del hessiano y del gradiente en la fórmula del método de Newton, obtendremos el método de Gauss-Newton:
Pero el problema del método Gauss-Newton es que la matriz [J'J] puede no ser reversible. Entonces, para resolver este problema, añadiremos a esta matriz una matriz unitaria multiplicada por un escalar positivo mu*I. En este caso, obtendremos exactamente el algoritmo de Levenberg-Marquardt:
La peculiaridad de este algoritmo es que cuando el parámetro mu toma valores positivos grandes, el algoritmo se reducirá al descenso de gradiente habitual que hemos considerado al principio del artículo. Si el parámetro mu tiende a cero, volveremos al método de Gauss-Newton.
Normalmente, el entrenamiento comienza con un valor pequeño de mu; si el valor de la función de pérdida no se ha reducido, entonces se aumentará el parámetro mu (por ejemplo, se multiplicará por 10). Como esto nos acerca al método de descenso de gradiente, tarde o temprano lograremos una reducción de la función de pérdida. Si la función de pérdida ha disminuido, reduciremos el valor del parámetro mu usando el método Gauss-Newton para una convergencia más rápida al punto de mínimo. Esta es la idea básica del método Levenberg-Marquardt: alternar constantemente entre el método de descenso de gradiente y el método de Gauss-Newton.
La implementación del método de propagación inversa para el algoritmo de Levenberg-Marquardt tiene sus propias peculiaridades. Como los elementos de la matriz de Jacobi son derivadas parciales de los errores de la red, no los cuadrados de estos errores, la fórmula para calcular el delta de la última capa de la red que dimos al principio del artículo, se simplificará. Ahora delta será simplemente igual a la derivada de la función de activación de la última capa. Este resultado se obtendrá si hallamos la derivada del error de red (y - objetivo) en y, que obviamente será igual a uno.
De hecho, aquí tenemos el propio código de la red neuronal con comentarios detallados.
//+------------------------------------------------------------------+ //| LM.mq5 | //| Eugene | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Eugene" #property link "https://www.mql5.com" #property version "1.00" #property script_show_inputs #include <Graphics\Graphic.mqh> #include <Math\Stat\Math.mqh> #include <Math\Stat\Normal.mqh> enum Plots { LossFunction_plot, mu_plot, gradient_plot, target_netpredict_plot }; matrix weights1,weights2,bias1,bias2; // network parameter matrices matrix n1,n2,act1,act2,new_n1,new_n2,new_act1,new_act2; // neural layer output matrices input int layer1 = 20; // neurons Layer 1 input int Epochs = 10; // Epochs input double Initial_mu = 0.001; // mu input double Incr_Rate = 10; // increase mu input double Decr_Rate = 0.1; // decrease mu input double Min_grad = 0.000001; // min gradient norm input double Loss_goal = 0.001; // Loss goal input double sigma_ = 0.1; // standard deviation synthetic data input Plots plot_ = LossFunction_plot; // display graph input bool plot_log = false; // logarithmic function graph input ENUM_ACTIVATION_FUNCTION ac_func = AF_TANH; // first layer activation function input ENUM_ACTIVATION_FUNCTION ac_func_last = AF_LINEAR; // last layer activation function input ENUM_LOSS_FUNCTION loss_func = LOSS_MSE; // Loss function double LossPlot[],NetOutput[],mu_Plot[],gradient_Plot[],target_Plot[]; matrix ones_; double old_error,gradient_NormP2; double mu_ = Initial_mu; bool break_forloop = false; int Sample_,Features; //+------------------------------------------------------------------+ //| Функция запуска скрипта | //+------------------------------------------------------------------+ void OnStart() { //--- генерируем обучающую выборку matrix data, target; Func(data,target); StandartScaler(data); Sample_= (int)data.Rows(); Features = (int)data.Cols(); ArrayResize(target_Plot,Sample_); for(int i=0; i< (int)target.Rows(); i++) { target_Plot[i] =target[i,0]; } ones_ = matrix::Ones(1,Sample_); //--- обучаем модель ulong start=GetMicrosecondCount(); Train(data, target, Epochs); ulong end = (GetMicrosecondCount()-start)/1000 ; Print("Learning time = " + (string)end + " msc"); int NumberParameters = layer1*(Features+1) + 1*(layer1+1); Print("Number Parameters of NN = ",NumberParameters); //--- генерируем тестовую выборку Func(data,target); StandartScaler(data); //--- тестирование модели Test(data,target); //--- отображаем графики PlotGraphic(15,plot_log); } //+------------------------------------------------------------------+ //| Метод обучения модели | //+------------------------------------------------------------------+ bool Train(matrix &data, matrix &target, const int epochs) { //--- создаем модель if(!CreateNet()) return false; //--- обучаем модель for(int ep = 0; ep < epochs; ep++) { //--- прямой проход if(!FeedForward(data)) return false; PrintFormat("Epoch %d, loss %.5f", ep, act2.Loss(target, loss_func)); //--- массивы для графиков ArrayResize(LossPlot,ep+1,10000); ArrayResize(mu_Plot,ep+1,10000); ArrayResize(gradient_Plot,ep+1,10000); LossPlot[ep] = act2.Loss(target, loss_func); mu_Plot [ep] = mu_; gradient_Plot[ep] = gradient_NormP2; //--- Прекращаем обучение если достигнуто целевое значение функции потерь if(break_forloop == true){break;} //--- обратный проход и обновление матриц весов if(!Backprop(data, target)) return false; } //--- Евклидова норма градиента, параметр mu, метрика RMSE Print("gradient_normP2 = ", gradient_NormP2); Print(" mu_ = ", mu_); double rmse=act2.RegressionMetric(target.Transpose(),REGRESSION_RMSE); PrintFormat("rmse %.3f / sigma %.2f ",rmse,sigma_); //--- массив выхода сети для графика ArrayResize(NetOutput,Sample_); for(int i=0; i< (int)act2.Transpose().Rows(); i++) { NetOutput[i] = act2.Transpose()[i,0]; } //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод создания модели | //+------------------------------------------------------------------+ bool CreateNet() { //--- инициализируем матрицы весов if(!weights1.Init(layer1,Features) || !weights2.Init(1,layer1)) return false; //--- инициализируем матрицы смещений if(!bias1.Init(layer1,1) || !bias2.Init(1,1)) return false; //--- заполняем матрицы весов случайными значениями weights1.Random(-0.1, 0.1); weights2.Random(-0.1, 0.1); bias1.Random(-0.1, 0.1); bias2.Random(-0.1, 0.1); //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод прямого прохода | //+------------------------------------------------------------------+ bool FeedForward(matrix &data) { //--- вычисляем первый нейронный слой //--- n1 предактивация первого слоя n1 = weights1.MatMul(data.Transpose()) + bias1.MatMul(ones_); //--- вычисляем функцию активации первого слоя act1 n1.Activation(act1, ac_func); //--- вычисляем второй нейронный слой //--- n2 предактивация второго слоя n2 = weights2.MatMul(act1) + bias2.MatMul(ones_); //--- вычисляем функцию активации второго слоя act2 n2.Activation(act2, ac_func_last); //--- возвращаем результат return true; } //+------------------------------------------------------------------+ //| Метод обратного прохода | //+------------------------------------------------------------------+ bool Backprop(matrix &data, matrix &target) { //--- текущее значение функции потерь old_error = act2.Loss(target, loss_func); //--- ошибка сети (квадратичная функция потерь) matrix loss = act2.Transpose() - target ; //--- производная функции активации последнего слоя matrix D2; n2.Derivative(D2, ac_func_last); //--- производная функции активации первого слоя matrix deriv_act1; n1.Derivative(deriv_act1, ac_func); //--- ошибка первого слоя сети matrix D1 = weights2.Transpose().MatMul(D2); D1 = deriv_act1 * D1; //--- первые частные производные ошибок сети по весам первого слоя matrix jac1; partjacobian(data.Transpose(),D1,jac1); //--- первые частные производные ошибок сети по весам второго слоя matrix jac2; partjacobian(act1,D2,jac2); //--- Якобиан matrix j1_D1 = Matrixconcatenate(jac1,D1.Transpose(),1); matrix j2_D2 = Matrixconcatenate(jac2,D2.Transpose(),1); matrix jac = Matrixconcatenate(j1_D1,j2_D2,1); // --- Градиент функции потерь matrix je = (jac.Transpose().MatMul(loss)); //--- Евклидова норма градиента нормируемая на обьем выборки gradient_NormP2 = je.Norm(MATRIX_NORM_FROBENIUS)/Sample_; if(gradient_NormP2 < Min_grad) { Print("Локальный минимум.Градиент меньше заданного значения."); break_forloop = true; // прекращаем обучение return true; } //--- Гессиан matrix Hessian = (jac.Transpose().MatMul(jac)); matrix I=matrix::Eye(Hessian.Rows(), Hessian.Rows()); //--- break_forloop = true; while(mu_ <= 1e10 && mu_ > 1e-20) { matrix H_I = (Hessian + mu_*I); //--- решение через Solve vector v_je = je.Col(0); vector Updatelinsolve = -1* H_I.Solve(v_je); matrix Update = matrix::Zeros(Hessian.Rows(),1); Update.Col(Updatelinsolve,0); // приращение вектора параметров //--- неэффективное вычисление обратной матрицы // matrix Update = H_I.Inv(); // Update = -1*Update.MatMul(je); //--- //--- cохраняем текущие параметры matrix Prev_weights1 = weights1; matrix Prev_bias1 = bias1; matrix Prev_weights2 = weights2; matrix Prev_bias2 = bias2; //--- //--- обновляем параметры //--- первый слой matrix updWeight1 = matrix::Zeros(layer1,Features); int count =0; for(int j=0; j <Features; j++) { for(int i=0 ; i <layer1; i++) { updWeight1[i,j] = Update[count,0]; count = count+1; } } matrix updbias1 = matrix::Zeros(layer1,1); for(int i =0 ; i <layer1; i++) { updbias1[i,0] = Update[count,0]; count = count +1; } weights1 = weights1 + updWeight1; bias1 = bias1 + updbias1; //--- второй слой matrix updWeight2 = matrix::Zeros(1,layer1); for(int i =0 ; i <layer1; i++) { updWeight2[0,i] = Update[count,0]; count = count +1; } matrix updbias2 = matrix::Zeros(1,1); updbias2[0,0] = Update[count,0]; weights2 = weights2 + updWeight2; bias2 = bias2 + updbias2; //--- вычисляем функцию потерь для новых параметров new_n1 = weights1.MatMul(data.Transpose()) + bias1.MatMul(ones_); new_n1.Activation(new_act1, ac_func); new_n2 = weights2.MatMul(new_act1) + bias2.MatMul(ones_); new_n2.Activation(new_act2, ac_func_last); //--- функция потерь с учетом новых параметров double new_error = new_act2.Loss(target, loss_func); //--- если функция потерь меньше заданного порога завершаем обучение if(new_error < Loss_goal) { break_forloop = true; Print("Обучение завершено. Достигнуто желаемое значение функции потерь"); return true; } break_forloop = false; //---корректируем параметр mu if(new_error >= old_error) { weights1 = Prev_weights1; bias1 = Prev_bias1; weights2 = Prev_weights2; bias2 = Prev_bias2; mu_ = mu_*Incr_Rate; } else { mu_ = mu_*Decr_Rate; break; } } //--- возвращаем результат return true; }
El algoritmo convergirá si la norma de gradiente es inferior a un número predeterminado, o si se alcanza el nivel deseado de la función de pérdida. El algoritmo se detendrá si el parámetro mu es superior o inferior a un número predeterminado, o después de completar un número predeterminado de épocas.
Fig.(7) Parámetros del script LM
Veamos el resultado de todos estos malabarismos matemáticos:
Fig.(8) Función de pérdida en escala logarítmica, LM
Ahora resulta bastante distinto, el algoritmo ha alcanzado el límite mínimo en 6 iteraciones. ¿Y si entrenamos la red con mil épocas? Obtendríamos el típico sobreajuste, la imagen de abajo lo demuestra bien. La red empieza a memorizar el ruido gaussiano.
Fig.(9) Sobreajuste típico, LM, 1000 épocas
Veamos las métricas del conjunto de entrenamiento y del conjunto de pruebas.
Fig.(10) estadísticas de rendimiento, LM, 1000 épocas
Podemos ver un RMSE de 0,168 con un límite inferior de 0,20 y, a continuación, una recuperación inmediata por sobreajuste en una prueba de 0,267.
Pruebas con big data y comparación con la biblioteca Python sklearn
Es hora de probar nuestro algoritmo con un ejemplo más realista. Ahora hemos tomado dos características de 1000 puntos de datos cada una. Podrá descargar estos datos junto con el script LM_BigData al final del artículo. Con LM competirán los algoritmos de la biblioteca Python: SGD, Adam y L-BFGS.
Aquí tenemos un script de prueba en Python
# Eugene # https://www.mql5.com import numpy as np import time import matplotlib.pyplot as plt import pandas as pd from sklearn.neural_network import MLPRegressor # здесь ваш путь к данным df = pd.read_csv(r'C:\Users\Evgeniy\AppData\Local\Programs\Python\Python39\Data.csv',delimiter=';') X = df.to_numpy() df1 = pd.read_csv(r'C:\Users\Evgeniy\AppData\Local\Programs\Python\Python39\Target.csv') y = df1.to_numpy() y = y.reshape(-1) start = time.time() ''' clf = MLPRegressor(solver='sgd', alpha=0.0, hidden_layer_sizes=(20), activation='tanh', max_iter=700,batch_size=10, learning_rate_init=0.01,momentum=0.9, shuffle = False,n_iter_no_change = 2000, tol = 0.000001) ''' ''' clf = MLPRegressor(solver='adam', alpha=0.0, hidden_layer_sizes=(20), activation='tanh', max_iter=3000,batch_size=100, learning_rate_init=0.01, n_iter_no_change = 2000, tol = 0.000001) ''' #''' clf = MLPRegressor(solver='lbfgs', alpha=0.0, hidden_layer_sizes=(100), activation='tanh',max_iter=300, tol = 0.000001) #''' clf.fit(X, y) end = time.time() - start # время обучения print("learning time =",end*1000) print("solver = ",clf.solver); print("loss = ",clf.loss_*2) print("iter = ",clf.n_iter_) #print("n_layers_ = ",clf.n_layers_) #print("n_outputs_ = ",clf.n_outputs_) #print("out_activation_ = ",clf.out_activation_) coef = clf.coefs_ #print("coefs_ = ",coef) inter = clf.intercepts_ #print("intercepts_ = ",inter) plt.plot(np.log(pd.DataFrame(clf.loss_curve_))) plt.title(clf.solver) plt.xlabel('Epochs') plt.ylabel('Loss') plt.show()
Para comparar correctamente los algoritmos, hemos multiplicado la función de pérdida en Python por 2, ya que en esta biblioteca se calcula así:
return ((y_true - y_pred) ** 2).mean() / 2
Es decir, los desarrolladores han dividido adicionalmente el MSE por 2. A continuación le mostramos algunos resultados típicos del optimizador. Hemos intentado elegir los mejores ajustes de hiperprámetros para estos algoritmos. Desafortunadamente, esta biblioteca carece de la capacidad de inicializar los valores de los parámetros de inicio para garantizar que todos los algoritmos partan del mismo punto en el espacio de parámetros. Tampoco hay forma de establecer un umbral objetivo para la función de pérdida. Para el LM el objetivo de la función de pérdida se ha fijado en 0,01, para los algoritmos Python he intentado fijar el número de iteraciones en las que se alcanza aproximadamente el mismo nivel.
Resultados de la prueba MLP con una capa oculta , 20 neuronas:
1) Descenso de gradiente estocástico
- loss mse – 0,00278
- tiempo de entrenamiento - 11459 msc
Fig.(11) SGD, 20 neuronas, pérdida = 0,00278
2) Adam
- loss mse – 0,03363
- tiempo de entrenamiento - 8581 msc
Fig.(12) Adam, 20 neuronas, pérdida = 0,03363
3)L-BFGS
- loss mse – 0,02770
- tiempo de entrenamiento - 277 msc
Desgraciadamente, para el L-BFGS no resulta posible mostrar el gráfico de la función de pérdida.
4) LM MQL5
- loss – 0,00846
- tiempo de entrenamiento - 117 msc
Fig.(13) LM, 20 neuronas, pérdida = 0,00846
Bueno, en cuanto a nuestro caso, ha resultado bastante positivo, el algoritmo puede competir fácilmente con el L-BFGS y, como podemos ver, también puede ofrecer una cierta ventaja. Pero nada es perfecto, a medida que aumenta el número de parámetros, el método Levenberg-Marquardt empieza a perder frente al L-BFGS.
100 neuronas L-BFGS:
- loss mse – 0,00847
- tiempo de entrenamiento - 671 msc
- loss mse – 0,00206
- tiempo de entrenamiento - 1253 msc
100 neuronas corresponden a 401 parámetros de red. A usted, querido lector, le corresponde decidir si es mucho o poco, en mi humilde opinión hablamos de potencia sobrante. Y hasta 100 neuronas supone una ventaja para el LM.
Conclusión
En este artículo hemos analizado y aplicado algunos de los algoritmos más básicos y sencillos para entrenar redes neuronales:
- descenso gradiente
- descenso de gradiente con impulso
- descenso de gradiente estocástico
Junto a esto, hemos abordado brevemente la convergencia y el reentrenamiento de las redes neuronales.
Pero lo más importante es que hemos creado un algoritmo de Levenberg-Marquardt muy rápido, ideal para el entrenamiento online de redes pequeñas.
Asimismo, hemos comparado el rendimiento de los algoritmos de entrenamiento de redes neuronales utilizados en la biblioteca de aprendizaje automático scikit-learn, y nuestro algoritmo ha sido el más rápido cuando el número de parámetros de la red neuronal no supera los 400 o 100 neuronas de la capa oculta. Además, a medida que aumenta el número de neuronas, el L-BFGS empieza a dominar.
Además hemos creado scripts independientes para cada algoritmo, con comentarios detallados:
# | Nombre | Tipo | Descripción |
---|---|---|---|
1 | SD.mq5 | Script | Descenso de gradiente |
2 | Momentum_SD.mq5 | Script | Descenso de gradiente con impulso |
3 | SGD.mq5 | Script | Descenso de gradiente estocástico |
4 | LM.mq5 | Script | Algoritmo de Levenberg-Marquardt |
5 | LM_BigData.mq5 | Script | Algoritmo LM, prueba de características bidimensionales |
6 | SklearnMLP.py | Script | Script de prueba de algoritmos Python |
7 | ArchivoCSV.mqh | Include | Lectura de archivos de texto con datos |
8 | Data.csv, Target.csv | Csv | Características y objetivo para el script de Python |
9 | X1.txt, X2.txt, Target.txt | Txt | Características y objetivo para el script de LM_BigData.mq5 |
Traducción del ruso hecha por MetaQuotes Ltd.
Artículo original: https://www.mql5.com/ru/articles/16296





- Aplicaciones de trading gratuitas
- 8 000+ señales para copiar
- Noticias económicas para analizar los mercados financieros
Usted acepta la política del sitio web y las condiciones de uso