Tabla de Contenidos

7.b Descenso de gradiente

El Descenso de gradiente es el algoritmo que usamos para entrenar la red neuronal.

¿Que era entrenar la red neuronal? Pues simplemente pasarle muchos $x$ e $y$ para que obtenga o aprenda los mejores parámetros posibles de pesos (weight) y sesgos (bias) de forma que la función de coste sea mínima.

Sabemos que: $$y= Salida \: verdadera. \: La \: que \: debería \: haber \: generado \: la \: red \: neuronal. Son \: datos \: que \: tenemos \: reales \: de \: salida$$ $$\hat{y}= Salida \: predicha. \: La \: que \: ha \: generado \: la \: red \: neuronal$$

$$loss=\frac{1}{N} \sum_{i=1}^{N} |y_i-\hat{y}_i|$$

Seguimos con la red neuronal que usamos de ejemplo:

Dedujimos que corresponde a la siguiente fórmula:

$$ \large \hat{y}=\frac{1}{1 + e^{-( w_{5,2}\frac{1}{1 + e^{-( w_{2}x+b_{2} )}}+w_{5,3}\frac{1}{1 + e^{-( w_{3}x+b_{3} )}}+w_{5,4}\frac{1}{1 + e^{-( w_{4}x+b_{4} )}}+b_5 )}} $$

Ya hemos visto que la siguiente función matemática nos dice los buena que es nuestra red neuronal, al resta el valor real de $y$ la salida de la red neuronal $\hat{y}$ es decir $y-\hat{y}$

$$ \large loss(x,y,parametros)=\frac{1}{N} \sum_{i=1}^{N}|y_i-\frac{1}{1 + e^{-( w_{5,2}\frac{1}{1 + e^{-( w_{2}x_i+b_{2} )}}+w_{5,3}\frac{1}{1 + e^{-( w_{3}x_i+b_{3} )}}+w_{5,4}\frac{1}{1 + e^{-( w_{4}x_i+b_{4} )}}+b_5 )}}| $$

Por lo que los parámetros a entrenar son los siguientes: $w_{2} , w_{3} , w_{4} , w_{5,2} , w_{5,3} , w_{5,4} , b_{4} , b_{2} , b_{3} , b_5$. Es decir que realmente entrenar consiste en averiguar los valores de los parámetros ($w_2,w_3,b_2$, etc) que hacen que el valor de loss tenga un valor mínimo.

Lo siguiente es saber el algoritmo para averiguar los parámetros. De forma didáctica expongo 3 métodos aunque solo se gasta el último.

Durante este tema la función a minimizar va a ser loss y siguiendo con nuestro ejemplo el código es el siguiente:

def sigmoid(z):
    return 1/(1 + np.exp(-z))


def predict_formula(x,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    part1=w_52*sigmoid(w_2*x+b_2)
    part2=w_53*sigmoid(w_3*x+b_3)
    part3=w_54*sigmoid(w_4*x+b_4)
    part4=b_5
    z=part1+part2+part3+part4

    return sigmoid(z)


def loss_mae(y_true,y_pred):
    error=np.abs(np.subtract(y_true,y_pred))
    mean_error=np.sum(error)/len(y_true)

    return mean_error



def loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    y_pred=predict_formula(x,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)
 
    return loss_mae(y_true,y_pred)

Lo siguiente que vamos a hacer es una gráfica para ver como variaría el valor de loss , es decir la pérdida, en caso de ir variando el valor del peso w_2.

iris=load_iris()
x=iris.data[0:99,2]
y_true=iris.target[0:99]
 

model=get_model("mae")
w_2_original,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original=get_parameters_from_model(model)
perdida=loss(x,y_true,w_2_original,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original)

rango_w_2=np.linspace(-5,5,400)
perdidas_w_2=[]
for w_2 in rango_w_2:
    perdidas_w_2.append( loss(x,y_true,w_2,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original))


figure=plt.figure(figsize=(5.5,4))
axes = figure.add_subplot()
axes.plot(rango_w_2,perdidas_w_2)
axes.scatter(w_2_original,perdida,color="red",s=40)
axes.set_xlabel("w_2")
axes.set_ylabel('Perdida')

En la gráfica , el punto rojo es el valor actual de w_2 y la pérdida de la red, es decir el resultado de la función loss. La curva nos permite ver como evoluciona la pérdida, si vamos variando el valor de w_2. En nuestro caso, viendo la gráfica podemos encontrar rápidamente el valor de w_2 que haría que fuera mínimo y podríamos darle ese valor pero debemos tener en cuenta que si variamos mucho w_2 cambiaría totalmente la pérdida para el resto de pesos por lo que hacemos es ir variando poco a poco el valor de cada peso. En nuestro ejemplo, para hacer que la pérdida sea la mínima, ¿que es mejor que w_2 se haga un poquito más grande o un poquito más pequeño? La respuesta es que lo mejor es que se haga un poquito más grande.

En la siguiente gráfica podemos ver como varia la pérdida para todos los pesos de nuestro ejemplo.

Es decir que como nuestra función de loss tiene muchos parámetros, no podemos encontrar el mínimo de cada parámetro por separado sino que hay que buscar poco a poco el mínimo de cada parámetro.

Veamos ahora un ejemplo de una función de loss con dos parámetros:

Las dos imágenes son la misma función pero la de la derecha está representada en 2D, donde al "altura" se representa con colores.

Como vemos no podríamos encontrar el mínimo para w_0 y luego para w_1.Así que los haremos poco a poco usando el algoritmo de descenso de gradiente.

Otro ejemplo más sencillo sería:

Matemáticas en el descenso de gradiente

Ahora que vemos la necesidad de encontrar el mínimo de una función pasemos a ver como se encuentra el mínimo. Pues es tan sencillo como imaginarse que estamos en una montaña y para encontrar la parte mas baja solo hay que ir descendiendo por la montaña. Es decir que hay que ver si la función (la montaña) crece y en ese caso ir hacia el lado contrario.

La siguiente imagen ilustra la idea.

Y podemos jugar con un simulador de descenso de gradiente en Loss Landscape Explorer

Y ver un video de como funciona en LR COASTER | learning rate in machine learning | Loss Landscape visualization | Deep Learning

¿Como hacemos eso matemáticamente? Pues lo primero es pensar que cuando se inicializa la red neuronal, a los parámetros ($w_0$ y $w_1$) se les da un valor aleatorio, por lo que estamos en un punto cualquiera de esa montaña. ¿Como sabemos en que dirección debemos movernos? Pues muy fácil, incrementamos un poquito el valor del parámetro (a ese incremento lo llamaremos $h$ o $\Delta x$) y vemos como se incrementa con respecto del valor original, (a ese incremento de la función lo llamamos $\Delta f$) . Es decir los restamos (aunque también lo dividimos entre el valor del incremento).

$$ gradiente \: de \: f(x) = derivada \: de \: f(x)=\frac{\Delta f}{\Delta x}=\lim_{h \to 0} \frac {f(x+h)-f(x)}{h}=\frac{\partial \: f(x)}{\partial \: x} $$

Ese valor es lo que se llama la derivada de una función y si:

Una explicación mas detallada de la fórmula se puede ver en el siguiente video: DERIVADAS - Clase Completa: Explicación Desde Cero | El Traductor
En el contexto del descenso de gradiente no se suele hablar que el resultado de la fórmula es la derivada sino el gradiente. Ya que para eso estamos en el descenso de gradente

La siguiente fórmula del gradiente queda así con respecto $w_0$ en el punto $(w_0,w_1)$. La segunda fórmula, es simplemente lo mismo pero expresado de forma mas compacta visto como derivadas.

$$ gradiente \: de \: w_0 = \lim_{h \to 0} \frac {loss(w_0+h,w_1)-loss(w_0,w_1)}{h}=\frac{\partial \: loss(w_0,w_1)}{\partial \: w_0} $$

La siguiente fórmula es lo mismo pero para $w_1$

$$ gradiente \: de \: w_1 = \lim_{h \to 0} \frac {loss(w_0,w_1+h)-loss(w_0,w_1)}{h}=\frac{\partial \: loss(w_0,w_1)}{\partial \: w_1} $$

Ya sabemos si crece pues en ese caso soy hay que moverse justo para el lado contrario de ahí el "menos" de la siguiente fórmula.

$$ w_0=w_0-\alpha \cdot gradiente \: de \: w_0=w_0-\alpha \cdot \frac{\partial \: loss(w_0,w_1)}{\partial \: w_0} $$

y

$$ w_1=w_1-\alpha \cdot gradiente \: de \: w_1=w_1-\alpha \cdot \frac{\partial \: loss(w_0,w_1)}{\partial \: w_1} $$

Si ésto lo repetimos muchas veces es justamente el algoritmo del descenso de gradiente.

La formula general de lo que es el descenso de gradiente se podría expresar así:

$$ \huge w_i^{t+1}=w_i^{t}-\alpha \cdot \frac{\partial \: loss(w_i^{t})}{\partial \: w_i^{t}} $$

Expliquemos un poco la fórmula:

Descenso de gradiente en Python

Pasemos ahora a ver como se programa todo ésto en Python.Siguiendo con nuestro ejemplo de:

Ya teníamos el siguiente código:

import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from sklearn.datasets import load_iris
 

def get_model(loss,learning_rate):
    np.random.seed(5)
    tf.random.set_seed(5)
    random.seed(5)  
     
    model=Sequential()
    model.add(Dense(3, input_dim=1,activation="sigmoid"))
    model.add(Dense(1,activation="sigmoid"))
    model.compile(loss=loss,optimizer=tf.keras.optimizers.SGD(learning_rate=learning_rate))
     
    return model


def get_w(model,layer,neuron,index):
    layer=model.layers[layer]
    return layer.get_weights()[0][index,neuron]
   
   
   
def get_b(model,layer,neuron):
    layer=model.layers[layer]
    return layer.get_weights()[1][neuron]
  
 
def get_parameters_from_model(model):
    w_2 =get_w(model,0,0,0)
    w_3 =get_w(model,0,1,0)
    w_4 =get_w(model,0,2,0)
    w_52=get_w(model,1,0,0)
    w_53=get_w(model,1,0,1)
    w_54=get_w(model,1,0,2)
    b_2 =get_b(model,0,0)
    b_3 =get_b(model,0,1)
    b_4 =get_b(model,0,2)
    b_5 =get_b(model,1,0)
 
    return w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5

def print_params(cabecera,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    decimals=8

    print(cabecera)
    print("w_2=",round(w_2,decimals),"   ","b_2=",round(b_2,decimals))
    print("w_3=",round(w_3,decimals),"   ","b_3=",round(b_3,decimals))   
    print("w_4=",round(w_4,decimals),"   ","b_4=",round(b_4,decimals)) 
    print("w_52=",round(w_52,decimals),"   ","w_53=",round(w_53,decimals),"w_54=",round(w_54,decimals),"   ","b_5=",round(b_5,decimals)) 


def sigmoid(x):
    return 1/(1 + np.exp(-x))
  
def predict_formula(x,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    part1=w_52*sigmoid(w_2*x+b_2)
    part2=w_53*sigmoid(w_3*x+b_3)
    part3=w_54*sigmoid(w_4*x+b_4)
    part4=b_5
    z=part1+part2+part3+part4
  
    return sigmoid(z)

def loss_mae(y_true,y_pred):
    error=np.abs(np.subtract(y_true,y_pred))
    mean_error=np.sum(error)/len(y_true)
 
    return mean_error

def loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    y_pred=predict_formula(x,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)

    return loss_mae(y_true,y_pred)

y ahora añadimos el código de los métodos descenso_gradiente y fit.



def descenso_gradiente(x,y_true,learning_rate,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    h=0.000003

    gradiente_w_2 =(loss(x,y_true,w_2+h,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_w_3 =(loss(x,y_true,w_2,w_3+h,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_w_4 =(loss(x,y_true,w_2,w_3,w_4+h,w_52,w_53,w_54,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_w_52=(loss(x,y_true,w_2,w_3,w_4,w_52+h,w_53,w_54,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_w_53=(loss(x,y_true,w_2,w_3,w_4,w_52,w_53+h,w_54,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_w_54=(loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54+h,b_2,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_b_2 =(loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2+h,b_3,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_b_3 =(loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3+h,b_4,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_b_4 =(loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4+h,b_5)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h
    gradiente_b_5 =(loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5+h)-loss(x,y_true,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5))/h

    w_2 =w_2 -learning_rate*gradiente_w_2 
    w_3 =w_3 -learning_rate*gradiente_w_3 
    w_4 =w_4 -learning_rate*gradiente_w_4 
    w_52=w_52-learning_rate*gradiente_w_52
    w_53=w_53-learning_rate*gradiente_w_53
    w_54=w_54-learning_rate*gradiente_w_54
    b_2 =b_2 -learning_rate*gradiente_b_2 
    b_3 =b_3 -learning_rate*gradiente_b_3 
    b_4 =b_4 -learning_rate*gradiente_b_4 
    b_5 =b_5 -learning_rate*gradiente_b_5 

    return w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5



def fit(x, y_true,epochs,learning_rate,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5):
    
    for epoch in range(epochs):
        w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5=descenso_gradiente(x,y_true,learning_rate,w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)   

    return w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5

Por último usamos todo el código para ver si entrena igual con nuestro código que en Keras.

learning_rate=0.001
epochs=50

iris=load_iris()
x=iris.data[0:99,2]
y_true=iris.target[0:99]

model=get_model('mae',learning_rate)

w_2_original,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original=get_parameters_from_model(model)
print_params("Valor de los pesos antes del entrenamiento:",w_2_original,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original)

history=model.fit(x, y_true,epochs=epochs,verbose=False,batch_size=len(x),shuffle=False)
w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5=get_parameters_from_model(model)
print_params("Valor de los pesos tras entrenamiento usando Keras:",w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)

w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5=fit(x,y_true,epochs,learning_rate,w_2_original,w_3_original,w_4_original,w_52_original,w_53_original,w_54_original,b_2_original,b_3_original,b_4_original,b_5_original)
print_params("Valor de los pesos tras entrenamiento Manualmente:",w_2,w_3,w_4,w_52,w_53,w_54,b_2,b_3,b_4,b_5)

Valor de los pesos antes del entrenamiento:
w_2= 0.30959857     b_2= 0.0
w_3= 0.07310068     b_3= 0.0
w_4= 0.63308823     b_4= 0.0
w_52= 0.49408045     w_53= -1.1185157 w_54= 0.14808941     b_5= 0.0
Valor de los pesos tras entrenamiento usando Keras:
w_2= 0.31066534     b_2= -0.00022871
w_3= 0.06847349     b_3= 6.122e-05
w_4= 0.6330435     b_4= -0.00013335
w_52= 0.49511436     w_53= -1.1182635 w_54= 0.14937088     b_5= -9.583e-05
Valor de los pesos tras entrenamiento Manualmente:
w_2= 0.31066538     b_2= -0.00022871
w_3= 0.06847351     b_3= 6.122e-05
w_4= 0.63304377     b_4= -0.00013335
w_52= 0.49511434     w_53= -1.11826369 w_54= 0.14937088     b_5= -9.583e-05

Mas información:

Entrenamiento en Keras

Ya hemos visto como el método fit de Keras realiza el entrenamiento pero veamos un poco más en detalle lo que hay que hacer.

Para el entrenamiento debemos establecer parámetros tanto en fit como en compile. Veamos un ejemplo.

model.compile(loss=loss,optimizer=tf.keras.optimizers.SGD(learning_rate=learning_rate))
model.fit(x, y_true,epochs=epochs,verbose=False,batch_size=len(x),shuffle=False)

Cuando entrenamos por defecto fit nos muestra el resultado del entrenamiento de cada época, indicando el valor de loss en cada una de ellas.

Epoch 1/50
1/1 [==============================] - 0s 282ms/step - loss: 0.4918
Epoch 2/50
1/1 [==============================] - 0s 7ms/step - loss: 0.4917
Epoch 3/50
1/1 [==============================] - 0s 5ms/step - loss: 0.4917
...
...
...
Epoch 48/50
1/1 [==============================] - 0s 3ms/step - loss: 0.4913
Epoch 49/50
1/1 [==============================] - 0s 4ms/step - loss: 0.4913
Epoch 50/50
1/1 [==============================] - 0s 4ms/step - loss: 0.4913

Tanto el método fit como compile tienen muchos más parámetros pero aquí hemos visto únicamente los básicos.

Mas información:

Profundizando en el descenso de gradiente

Vamos ahora a ver de una forma más gráfica como funciona el descenso de gradiente y algunos problemas que tiene.

En este apartado a modo de ejemplo, vamos a usar como función de coste:

$$loss(w_0,w_1)=3(1-w_0)^2e^{-w_0^2-(w_1+1)^2}-10(\frac{w_0}{5}-w_0^3-w_1^5)e^{-w_0^2-w_1^2}-\frac{1}{3}e^{-(w_0+1)^2-w_1^2}$$

y en Python:

def loss_function(w_0,w_1):
    return  3*(1 - w_0)**2 * np.exp(-w_0**2 - (w_1 + 1)**2)  - 10*(w_0/5 - w_0**3 - w_1**5)*np.exp(-w_0**2 - w_1**2) - 1./3*np.exp(-(w_0 + 1)**2 - w_1**2) 

Cuya gráfica es:

En los apéndices de este tema está el código que vamos a usar que se usa así:

figure=plt.figure(figsize=(16,15))
axes = figure.add_subplot()
plot_loss_function(axes)

epochs=5
learning_rate=0.03
w_0_original=-0.35
w_1_original=-0.67
plot_descenso_gradiente(axes,get_puntos_descenso_gradiente(epochs,learning_rate,w_0_original,w_1_original))

La función get_puntos_descenso_gradiente tiene los siguientes parámetros:

Que genera la siguiente gráfica

  • La estrella roja es desde donde empieza el algoritmo.Es decir, el valor inicial de los parámetros $w_0,w_1$
  • La estrella azul es donde acaba el algoritmo.Es decir, el valor tras el entrenamiento de los parámetros $w_0,w_1$
  • Los puntos amarillo son los pasos por lo que se va moviendo el algoritmo. Cada uno de los valores intermedios de los parámetros durante el entrenamiento.

Optimizadores de Keras

Podemos mejorar nuestro código en Python haciendo que podamos usar directamente los Optimizers de Keras y de esa forma ver como funciona cada uno de ellos. Para poder usarlos directamente vamos a creas las nuevas funciones loss_tf y get_puntos_descenso_gradiente_optimizer adecuadas a TensorFlow y Keras.

def loss_tf(w_0,w_1):
    return  3*(1 - w_0)**2 * tf.exp(-w_0**2 - (w_1 + 1)**2)  - 10*(w_0/5 - w_0**3 - w_1**5)*tf.exp(-w_0**2 - w_1**2) - 1./3*tf.exp(-(w_0 + 1)**2 - w_1**2) 

def get_puntos_descenso_gradiente_optimizer(epochs,optimizer_function,w_0_init,w_1_init):

    puntos_descenso_gradiente=np.array([[w_0_init,w_1_init]])

    w_0=w_0_init
    w_1=w_1_init
    for epoch in range(epochs): 
        var_w_0=tf.Variable(w_0)
        var_w_1=tf.Variable(w_1)

        optimizer_function.minimize(lambda: loss_tf(var_w_0,w_1),  var_list=[var_w_0])
        optimizer_function.minimize(lambda: loss_tf(w_0,var_w_1),  var_list=[var_w_1])

        w_0=var_w_0.numpy()
        w_1=var_w_1.numpy()      

        puntos_descenso_gradiente=np.append(puntos_descenso_gradiente,[[w_0,w_1]], axis=0)           

    return puntos_descenso_gradiente

Lo que ha cambiado principalmente es la función get_puntos_descenso_gradiente_optimizer no hace cálculo del gradiente (derivada) ni actualiza los parámetros, sino que llama a la función de Keras de optimización.

Si usamos get_puntos_descenso_gradiente_optimizer ahora con tf.keras.optimizers.SGD

get_puntos_descenso_gradiente_optimizer(5,tf.keras.optimizers.SGD(learning_rate=0.03),-0.35,-0.67)
array([[-0.35      , -0.67      ],
       [-0.269319  , -0.72470832],
       [-0.13292952, -0.83883744],
       [ 0.06544246, -1.06462073],
       [ 0.24086528, -1.40752137],
       [ 0.26578248, -1.59573841]])
Vamos que el resultado es prácticamente el mismo que cuando lo hicimos manualmente.

Y podemos generar de la misma forma el gráfico:

figure=plt.figure(figsize=(16,15))
axes = figure.add_subplot()
plot_loss_function(axes)

plot_descenso_gradiente(axes,get_puntos_descenso_gradiente_optimizer(5,tf.keras.optimizers.SGD(learning_rate=0.03),-0.35,-0.67))

Y obviamente el resultado es el mismo

Learning rate y epochs

Veamos ahora como influye el learning_rate en el funcionamiento del algoritmo de gradiente.

Comparemos la gráfica anterior con un learning_rate de 0.03 con este otro ejemplo con learning_rate de 0.07

plot_descenso_gradiente(get_puntos_descenso_gradiente(5,0.07,-0.35,-0.67))

Podemos ver que ahora que damos pasos demasiado largos y no llegamos hasta el mínimo de la función.

Sigamos con otro ejemplo:

plot_descenso_gradiente(get_puntos_descenso_gradiente(11,0.1,-0.35,-0.67))

El valor de learning_rate es mayor aun , siendo de 0.1 por lo que damos pasos tan grandes que nos salimos de mínimo de la función y eso que hemos pasado de 5 épocas a 11 épocas. Es decir, si el learning_rate es elevado, no llegará al mínimo aunque pongamos muchas épocas.

Sigamos con otro ejemplo en el que el learning_rate es pequeño, concretamente de 0.006

plot_descenso_gradiente(get_puntos_descenso_gradiente(11,0.006,-0.35,-0.67))

Vemos que al dar pasos tan pequeños nos quedamos a mitad de camino y eso que también hemos puesto 11 épocas. En este caso para solucionarlo solo habría que aumentar el número de épocas.

Así que en el siguiente ejemplo, vamos a aumentar el número de épocas de 11 a 30.

plot_descenso_gradiente(get_puntos_descenso_gradiente(30,0.006,-0.35,-0.67))

Y ahora ya ha llegado hasta el mínimo.

En general nos interesa valores bajos de learning_rate y altos de epochs.

Mínimos locales y mínimos globales

Ahora vamos a simular distintos valores iniciales de $w_0$ y $w_1$ es decir, suponiendo que el generador de números aleatorios genera unos valores iniciales distintos. Y vamos a comparar que ocurre con los distintos valores iniciales.

plot_descenso_gradiente(axes,get_puntos_descenso_gradiente(10, 0.03,  0.3 ,   1.3))
plot_descenso_gradiente(axes,get_puntos_descenso_gradiente(10, 0.03, -0.35, -0.67))
plot_descenso_gradiente(axes,get_puntos_descenso_gradiente(10, 0.03, -0.4 , -0.5 ))
plot_descenso_gradiente(axes,get_puntos_descenso_gradiente(10, 0.03, -0.3 , -0.5 ))

Esta última gráfica es muy interesante. No hay que perder de vista que las estrellas rojas es el valor inicial de los pesos que tenemos en nuestra red neuronal. Es decir , son esos pesos aleatorios con los que de inicializa la red neuronal. Pues lo interesante es que según los valores iniciales aleatorios que elijamos, puede que no lleguemos al mínimo de la función (mínimo global), sino a lo que llamamos un mínimo local.

Se puede ver mejor en esta gráfica:

Es decir que el algoritmo del descenso de gradiente no nos garantiza que encontremos el mínimo global de la función sino solo un mínimo local. Lo que conlleva que quizás no encontramos los mejores valores de los parámetros (weight y bias) para nuestra red neuronal.

Para intentar solventar los problemas del algoritmo del descenso de gradiente existen diversas variaciones del mismo que vamos a ver en el siguiente apartado.

Usando optimizadores en Keras

Ya hemos visto como funciona el algoritmo del descenso de gradiente pero resulta que existe muchas variaciones sobre el algoritmo básico. Es lo que en Keras se llaman Optimizers.

Lo que pretenden estos optimizadores es hacer que se llegue lo más rápido posible al mínimo además de evitar mínimos locales, etc. El descenso de gradiente que hemos visto es lo que en Keras se llama Stochastic gradient descent (SGD).

Al igual que pasaba otras veces en Keras, los optimizadores se pueden usar de 2 formas distintas y se usan en el método compile con el argumento optimizer:

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.SGD(learning_rate=0.03))

model.compile(loss="binary_crossentropy",optimizer="sgd")

Notar que el optimizador se define en el método compile pero donde se usa es con el método fit.

Tipos de optimizadores en Keras

Veamos ahora los distintos tipos de optimizadores que hay en Keras.

Mas información:

Stochastic gradient descent o SGD

Es el que hemos usado hasta ahora. Se llama "Stochastic gradient descent".

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.SGD(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="sgd")

Mas información:

Adagrad

Es como SGD pero intenta aprender el learning_rate

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.Adagrad(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="adagrad")

Más información:

RMSprop

Es como AdraGrad pero aprende el learning_rate.

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.RMSprop(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="rmsprop")

Más información:

Adadelta

Es como AdraGrad pero learning_rate la aprende sola.

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.Adadelta(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="adadelta")

Más información:

Adam

RMSprop + Momentum

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.Adam(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="adam")

Más información:

Adamax

Como Adam pero mas estable a ruido del gradiente

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.Adamax(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="adamax")

Más información:

Nadam

Como Adam pero se utiliza el algoritmo de Nesterov para el momentum

model.compile(loss="binary_crossentropy",optimizer=tf.keras.optimizers.Nadam(learning_rate=0.03))
model.compile(loss="binary_crossentropy",optimizer="nadam")

Más información:

En nuestros proyectos, solemos necesitar mostrar por pantalla el optimizador que estamos usando y normalmente pasa ésto:

print(tf.keras.optimizers.Adam(learning_rate=0.001))
print(tf.keras.optimizers.Adamax(learning_rate=0.00000001))

<keras.optimizer_v2.adam.Adam object at 0x7fc4c0d16b60>
<keras.optimizer_v2.adamax.Adamax object at 0x7fc4c1db1810>

Pero hay un truco para que se muestre de forma más amigable, que es sobre escribir la función str de la clase Optimizer:

tf.keras.optimizers.Optimizer.__str__=lambda self: f'{self._name} lr=' + f'{self.learning_rate.numpy():.10f}'.rstrip('0')

Y si volvemos a ejecutar de nuevo el código:

print(tf.keras.optimizers.Adam(learning_rate=0.001))
print(tf.keras.optimizers.Adamax(learning_rate=0.00000001))

Adam lr=0.001
Adamax lr=0.00000001

Elección del optimizador.

Al igual que pasaba con las funciones de activación, según el problema, puede ser mejor uno u otro.

En el siguiente esquema se puede ver la relación entre unos y otros y así ver cual es más moderno.

Usando las funciones que ya tenemos de get_puntos_descenso_gradiente, plot_loss_function, etc. he creado a crear un video para que se vea de la velocidad a la que se mueve cada optimizador.

El objetivo del vídeo no es decir que optimizador es mejor sino hacer ver que hay mejores y peores. En otro problema y con otros parámetros el resultado puede ser distinto.

Veamos ahora un ejemplo con código de como funcionan

figure=plt.figure(figsize=(16,15))

learning_rate=0.1
epochs=10
optimizers=[
    [tf.keras.optimizers.SGD(learning_rate=learning_rate),"SGD"],
    [tf.keras.optimizers.Adagrad(learning_rate=learning_rate),"Adagrad"],
    [tf.keras.optimizers.RMSprop(learning_rate=learning_rate),"RMSprop"],
    [tf.keras.optimizers.Adadelta(learning_rate=1),"Adadelta"],
    [tf.keras.optimizers.Adam(learning_rate=learning_rate),"Adam"],
    [tf.keras.optimizers.Adam(learning_rate=learning_rate,amsgrad=True),"AMSGrad"],
    [tf.keras.optimizers.Adamax(learning_rate=learning_rate),"Adamax"],
    [tf.keras.optimizers.Nadam(learning_rate=learning_rate),"Nadam"] 
]

for index,(optimizer,title) in enumerate(optimizers):
    axes = figure.add_subplot(3,3,index+1)
    plot_loss_function(axes,15,title)
    plot_descenso_gradiente(axes,get_puntos_descenso_gradiente_optimizer(epochs,optimizer,-0.35,-0.67))

Mas información:

Backpropagation

El Backpropagation es el algoritmo que optimiza el entrenamiento de la red. Calcular el gradiente (o derivada) de toda la red es muy costoso. Se basa en la idea de que los parámetros de una capa no dependen de la capa anterior.

Si volvemos a ver nuestra red neuronal de ejemplo, podemos calcular los pesos de la neurona 5 sin que influya en como van a ser los pesos de las neuronas 2, 3 y 4. Es decir que empezamos con las neuronas de las capas más hacía la salida y una vez calculados sus pesos , calculamos los parámetros de las capa anterior (más hacia la entrada) , y eso significa ir hacia atrás o backpropagation.

Con backpropagation acabamos de ver el orden en el que se calculan los parámetros de cada neurona y a continuación vamos a ver con el descenso de gradiente como calculamos los parámetros de una neurona.

Junto con el backpropagation aparece otro concepto llamado regla de la cadena o chain rule que se usa para junto al backpropagation para hacer menos cálculos. Está relacionado con el cálculo de derivadas.

En los siguientes videos está explicado perfectamente el backpropagation y la chain rule:

Hardware entrenamiento

Mas información

Ejercicios

Ejercicio 1

Dado las gráficas de las funciones de coste de una red neuronal:

Indica para cada parámetro: Si la función crece o decrece, el valor de su derivada (positiva o negativa) y por lo tanto si se debe incrementar el valor del parámetro o decremenarlo.

Ejercicio 2

Dado la siguiente función matemática:

$$f(x) = \frac{x}{1 + e^{-x}}$$

Ejercicio 3

Copia el siguiente código python:

#Código base de varios ejercicios.
 
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris
from matplotlib.ticker import MaxNLocator
from matplotlib.ticker import MultipleLocator
 
def sigmoid(z):
    return 1/(1 + np.exp(-z))
  
def predict_formula(x,w,b):
    return sigmoid(w*x+b)
 
 
def loss_mae(y_true,y_pred):
    error=np.abs(np.subtract(y_true,y_pred))
    mean_error=np.sum(error)/len(y_true)
  
    return mean_error
  
  
  
def loss(x,y_true,w,b):
    y_pred=predict_formula(x,w,b)
   
    return loss_mae(y_true,y_pred)
 
 
def plot_loss(x,y_true,perdida_original,valor_parametro_inicial,rango,perdidas,xlabel,axes):
    axes.set_ylim(ymin=0.0,ymax=0.7)
    axes.plot(rango,perdidas)
    axes.scatter(valor_parametro_inicial,perdida_original,color="#ff0000",s=40)
    axes.set_xlabel(xlabel)
    axes.set_ylabel('loss')
 
    axes.vlines(x = valor_parametro_inicial, ymin = perdida_original, ymax = 0.7,colors = '#ff0000',linestyle="dashed")
    axes.text(valor_parametro_inicial+0.1,0.72,f'{xlabel}={valor_parametro_inicial:0.2f}',c="#ff0000")
 
 
    min_x=rango[np.argmin(perdidas)]
    min_y=np.min(perdidas)
    axes.vlines(x = min_x, ymin = 0, ymax = min_y,colors = '#00ff00',linestyle="dashed")
    axes.text(min_x+0.1,0.015,f'{min_x:0.2f}',c="#00ff00")
 
     
def plot_simple_metrics(axes,history,title):
 
    axes.plot(history,linestyle="dotted",label=f"loss :{history[-1]:.2f}",c="#003B80")  
 
    axes.set_xlabel('Nº Épocas', fontsize=13,color="#003B80") 
    axes.xaxis.set_major_locator(MaxNLocator(integer=True))
 
    axes.set_ylabel('Métricas', fontsize=13,color="#003B80")
    axes.set_ylim(ymin=0,ymax=1.1)
    axes.yaxis.set_major_locator(MultipleLocator(0.1))
 
    axes.set_title(title)
    axes.set_facecolor("#F0F7FF")
    axes.grid(visible=True, which='major', axis='both',color="#FFFFFF",linewidth=2)
    axes.legend()
 
 
def plot_losses(x,y_true,w_inicial,b_inicial,subfigure):
    perdida_original=loss(x,y_true,w_inicial,b_inicial)
 
    kk=subfigure
    subfigure.suptitle(f'loss={perdida_original:0.2f}',c="#ff0000")

    axes_w=subfigure.add_subplot(1,2,1)
    axes_b=subfigure.add_subplot(1,2,2)   
 
    rango=np.linspace(-5,5,400)
    perdidas_w=[]
    perdidas_b=[]
 
    for parametro in rango:
        perdidas_w.append(loss(x,y_true,parametro,b_inicial))
        perdidas_b.append(loss(x,y_true,w_inicial,parametro))
 
 
    plot_loss(x,y_true,perdida_original,w_inicial,rango,perdidas_w,"w",axes_w)
    plot_loss(x,y_true,perdida_original,b_inicial,rango,perdidas_b,"b",axes_b)
 
    return perdida_original
 
def plot_evolucion_parametros(axes,ws,bs):
    axes.plot(ws,linestyle="solid",label="w",c="#6ABF40")  
    axes.plot(bs,linestyle="solid",label="b",c="#BF9140")     
 
    axes.set_xlabel('Nº Épocas', fontsize=13,color="#003B80") 
    axes.xaxis.set_major_locator(MaxNLocator(integer=True))
 
    axes.set_ylabel('Valor de los parámetros', fontsize=13,color="#003B80")
    axes.set_ylim(ymin=-5,ymax=5)
    axes.yaxis.set_major_locator(MultipleLocator(1))
 
    axes.set_title("Evolución de los parámetros en cada época")
    axes.set_facecolor("#F0F7FF")
    axes.grid(visible=True, which='major', axis='both',color="#FFFFFF",linewidth=2)
    axes.legend()
 


def plot_parametros(x,y_true,parametros):
    figure=plt.figure(figsize=(8,3.5*(len(parametros)+1)),layout='constrained')
    figure.suptitle("$y=\\frac{1}{1 + e^{-( w \\cdot x+b  )}}$")
    subfigures = figure.subfigures(nrows=len(parametros)+1, ncols=1)

    ws=[]
    bs=[]
    history=[]
    for index,(w,b) in enumerate(parametros):
        subfigure=subfigures[index]
 
        loss=plot_losses(x,y_true,w,b,subfigure)
        ws.append(w)
        bs.append(b)
        history.append(loss)
 
    axes=subfigures[-1].add_subplot(1,2,1)
    plot_simple_metrics(axes,history,"loss")
    axes=subfigures[-1].add_subplot(1,2,2) 
    plot_evolucion_parametros(axes,ws,bs)
 
def descenso_gradiente(x,y_true,learning_rate,w,b):
    h=0.000003
  
    gradiente_w =(loss(x,y_true,w+h,b)-loss(x,y_true,w,b))/h
    gradiente_b =(loss(x,y_true,w,b+h)-loss(x,y_true,w,b))/h
 
    w=w-learning_rate*gradiente_w
    b=b-learning_rate*gradiente_b
    return w,b
 
 
def plot_descenso_gradiente(x,y_true,w_inicial,b_inicial,learning_rate,epochs):
    figure=plt.figure(figsize=(8,3.5*epochs),layout='constrained')
    figure.suptitle("$y=\\frac{1}{1 + e^{-( w \\cdot x+b  )}}$")
 
    subfigures = figure.subfigures(nrows=epochs+1, ncols=1)
    w=w_inicial
    b=b_inicial
 
    ws=[]
    bs=[]
    history=[]
    for epoch in range(epochs):
        if (epochs>1):
            subfigure=subfigures[epoch]
        else:
            subfigure=subfigures
 
        loss=plot_losses(x,y_true,w,b,subfigure)
        ws.append(w)
        bs.append(b)
        history.append(loss)
        w,b=descenso_gradiente(x,y_true,learning_rate,w,b)
 
    axes=subfigures[-1].add_subplot(1,2,1)
    plot_simple_metrics(axes,history,"loss")
    axes=subfigures[-1].add_subplot(1,2,2)    
    plot_evolucion_parametros(axes,ws,bs)

Vamos a trabajar con una red neuronal correspondiente a una única neurona y función de activación sigmoide, que corresponde a la siguiente fórmula:

$$ \large y=\frac{1}{1 + e^{-( w \cdot x+b )}} $$

Lo que debe hacer es entrenar tu manualmente la red , es decir, encontrar los mejores valores de w y b.

Para ello ejecuta el siguiente código:

iris=load_iris()
x=iris.data[0:99,2]
y_true=iris.target[0:99]

parametros=[(-0.3,0.1)]
plot_parametros(x,y_true,parametros)

Que muestra la siguiente gráfica:

Esta gráfica muestra en rojo que con los valores de w=-0.3 y b=0.1 el valor de la pérdida de la red es 0.59. loss=0.59. Mientras que en verde se muestra los valores que harían mínima cada ua de las funciones w=0.55 y b=-4.

Modifica el array parametros añadiendo mas pares de valores de w y b para ver si consigues obtener un valor de loss lo más cercano a 0 que sea posible. Deberás ir modificando poco a poco los valores de w y b.

Ejercicio 4

Entrena ahora la red neuronal usando el descenso de gradiente. Para ello ejecuta el siguiente código:

iris=load_iris()
x=iris.data[0:99,2]
y_true=iris.target[0:99]

w_inicial=-0.3
b_inicial=0.1
learning_rate=0.3
epochs=5
plot_descenso_gradiente(x,y_true,w_inicial,b_inicial,learning_rate,epochs)

Modifica los valores de learning_rate y epochs para que la red se entrene sola para obtener un valor de loss lo más cercano a 0 que sea posible.

Ejercicio 5.A

Crea una red neuronal para entrenar las flores. La red tendrá las siguientes características:

Y con todas las combinaciones de:

Muestra las gráficas de la pérdida en función de las épocas de forma que por filas estén los optimizadores y por columnas las tasas de aprendizaje.

Responde las siguientes cuestiones:

Ejercicio 5.B

Repite el ejercicio anterior pero ahora solo con 20 épocas.

¿Que tasas de aprendizaje y optimizadores se ve que van a generar redes inestables?

Ejercicio 6.A

Haz una red neuronal que averigüe un dígito que se ha escrito a mano. Para ello obtén los datos con el siguiente código:

from sklearn.datasets import load_digits

def get_datos():
  datos=load_digits()
  x=datos.data
  y=datos.target
  label_binarizer = LabelBinarizer()
  label_binarizer.fit(range(max(y)+1))
  y = label_binarizer.transform(y)

  x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42,stratify=y)

  return x_train, x_test, y_train, y_test

La red debería ser realmente una red convolucional pero en este ejemplo usaremos una red neuronal complemente conectada

La red tendrá las siguientes características:

Prueba únicamente con 5 épocas con todas las combinaciones de lo siguiente:

Muestra las gráficas de la pérdida en función de las épocas de forma que por filas estén los optimizadores y por columnas las tasas de aprendizaje.

Indica para cada tasa de aprendizaje y para cada optimizador si la entrenarías o no y el motivo.

Ejercicio 6.B

Siguiendo con el ejercicio anterior, entrena ahora la red con 300 épocas pero ahora solo con los optimizadores/tasas de aprendizaje que seleccionaste en el ejercicio anterior

Ejercicio 6.C

Repite el ejercicio anterior pero ahora con TODAS las combinaciones de optimizadores y tasas de aprendizaje.

¿Fue adecuada la selección que hiciste en el ejercicio anterior?