junio 28, 2020

~ 75 MIN

El Perceptrón - Optimización

< Blog RSS

Open In Colab

El Perceptrón - Optimización

En el post anterior aprendimos a implementar nuestro primer algoritmo de Machine Learning: el Perceptrón.

Este modelo está inspirado en el funcionamiento de las neuronas biológicas que forman las redes neuronales de nuestros cerebros, recibiendo una serie de señales de entrada y devolviendo un resultado a la salida, calculando una suma ponderada de todos los inputs y aplicando una función de activación.

\hat{y} = f(\mathbf{w} \cdot \mathbf{x}) = f(w_0 + w_1 x_1 + ... + w_m x_m)

También vimos cómo aplicar este sencillo algoritmo para la tarea de regresión lineal, en la que el objetivo es dar un valor numérico real a partir de una serie de características de entrada. Aprendimos que podemos encontrar los pesos \mathbf{w} de nuestro modelo utilizando el algoritmo de optimización conocido como descenso por gradiente en el que minimizamos una función de pérdida. En este post vamos a ver el efecto que tienen sobre el proceso de entrenamiento (y por lo tanto en el resultado obtenido) los diferentes parámetros envueltos.

El algoritmo de Descenso por Gradiente

Como vimos en el post anterior, el algoritmo de descenso por gradiente para el Perceptrón usando como función de pérdida el error medio cuadrático (MSE), se implementa de la forma siguiente:

  1. Calcular la salida del modelo, \hat{y} .
  2. Calcular la derivada de la función de pérdida con respecto a los parámetros del modelo, \frac{\partial MSE}{\partial w} = \frac{2}{N} \frac{\partial \hat{y}}{\partial w} (\hat{y} - y) dónde \frac{\partial \hat{y}}{\partial w} = x .
  3. Actualizar los parámetros, w \leftarrow w - \eta \frac{\partial MSE}{\partial w} , dónde \eta es el learning rate.
  4. Repetir hasta converger.

Inicializaremos nuestro modelo con unos valores aleatorios para los pesos, e iremos actualizando sus valores de manera iterativa en la dirección de pendiente negativa de la función de pérdida. Como puedes ver hay varios hyperparámetros en el proceso de optimización que van a afectar el proceso de optimización. Éstos son:

  • Inicialización de los pesos
  • Valor escogido del learning rate
  • Datos utilizados para el cálculo del gradiente

Vamos a ver cómo afecta la elección de diferentes valores para estos parámetros.

💡 Llamamos hyperparámetros a todos aquellos parámetros que influyen en el resultado de la optimización pero que no son resultados de la misma (como los valores iniciales, learning rate, etc), mientras que los parámetros serían los resultados de la optimización (los pesos del modelo).

Efectos de la inicialización

Vamos a ver cómo afecta al proceso de optimización la elección del valor inicial de los pesos. Para ello vamos a utilizar el mismo dataset sintético y funciones utilizadas en el post anterior.

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

x = np.random.rand(20)
y = 2*x + (np.random.rand(20)-0.5)*0.5

plt.plot(x, y, "b.")
plt.xlabel("$x_1$", fontsize=14)
plt.ylabel("$y$", rotation=0, fontsize=14)
plt.grid(True)
plt.show()

png

def gradient(w, x, y): 
    # calculamos la derivada de la función de pérdida
    # con respecto a los parámteros `w`
    dldw = x*w - y
    dydw = x
    dldw = dldw*dydw
    return np.mean(2*dldw)

def cost(y, y_hat): 
    # calculamos la función de pérdida
    return ((y_hat - y)**2).mean()

def solve(epochs = 29, w = 1.2, lr = 0.2):
    # iteramos un número determinado de `epochs`
    # por cada epoch, calculamos gradientes y 
    # actualizamos los pesos
    weights = [(w, gradient(w, x, y), cost(x*w, y))]
    for i in range(1,epochs+1):
        dw = gradient(w, x, y)
        w = w - lr*dw
        weights.append((w, dw, cost(x*w, y)))
    return weights
weights = solve(w = 1.2)
anim = compute_anim(weights)
anim

En el primer ejemplo utilizamos el mismo valor inicial que vimos en el post anterior. En cada iteración calculamos la derivada de la función de pérdida con respecto al peso del modelo, la cual nos indicará la dirección en la que tenemos que actualizar su valor para reducir el error. Repetimos el proceso por un número determinado de iteraciones (epochs).

weights = solve(w = 1.6)
anim = compute_anim(weights)
anim

En el siguiente ejemplo vemos el efecto que tiene utilizar un valor de inicialización más cercano al valor óptimo. Cómo podemos ver, en este caso llegamos al valor óptimo más rápido, en menos epochs. Es común inicializar estos pesos de manera aleatoria (quizás siguiendo una distribución de probabilidad determinada). Así pues, si tenemos suerte y los valores iniciales están cercanos al óptimo el proceso de optimización necesitará menos iteraciones y por lo tanto será más rápido (o para el mismo número de iteraciones tendremos un mejor resultado).

weights = solve(w = 5)
anim = compute_anim(weights)
anim

En el último ejemplo utilizamos un valor más alejado del óptimo, cómo podríamos esperar necesitamos más iteraciones para acercarnos al valor óptimo.

Cómo hemos podido ver la inicialización de nuestro modelo tendrá un efecto en el proceso de optimización, principalmente en el número de iteraciones que necesitaremos para llegar al valor óptimo. Sin embargo, debido a que el algoritmo de descenso por gradiente no nos garantiza encontrar el valor óptimo global y puede quedarse "atascado" en un valor óptimo local, es común realizar múltiples optimizaciones con diferentes valores iniciales.

Efectos del learning rate

En esta sección vemos cómo afecta el valor escogido del learning rate al proceso de optimización. Para ello usarmos el mismo valor inicial en todos los casos.

weights = solve(lr = 0.2)
anim = compute_anim(weights)
anim

En el primer caso utilizamos un valor de \eta = 0.2 y podemos ver como llegamos a la solución óptima poco a poco.

weights = solve(lr = 0.01)
anim = compute_anim(weights)
anim

Un valor muy pequeño nos sigue llevando a la solución óptima, pero requiriendo de muchas iteraciones.

weights = solve(lr = 1)
anim = compute_anim(weights)
anim

En el caso anterior utilizamos un valor más grande de \eta = 1 y observamos como llegamos a la solución óptima mucho más rápido, en apenas 5 iteraciones mientras que en el caso anterior necesitábamos 30. Recordemos que los pesos los actualizamos siguiendo la ecuación w \leftarrow w - \eta \frac{\partial MSE}{\partial w} por lo que el learning rate determinará como de grande será el paso que daremos en cada iteración en dirección al valor óptimo.

weights = solve(lr = 3)
anim = compute_anim(weights)
anim

Sin embargo, si utilizamos un valor demasiado grande podemos observar que en vez de acelerar el proceso de optimización se realentiza, necesitando más iteraciones para llegar al valor óptimo. Aún así, vemos que el proceso converge.

weights = solve(w = 1.8, lr = 3.5)
anim = compute_anim(weights)
anim

Cuando utilizamos un valor demasiado grande, el proceso de optimización no puede converger ya que los pasos que damos son muy grandes consiguiendo justo el efecto contrario: en vez de dar pasos hacia el valor óptimo nos alejamos de él. Incluso si nuestra inicialización es buena, un learning rate grande será perjudicial.

Hemos visto el efecto que tiene el learning rate en el entrenamiento de nuesto Perceptrón. Un valor muy pequeño resulta en un proceso de optimización lento, mientras que un valor muy grande hará que el proceso sea divergente. Así pues tendremos que utilizar valores adecuados para tener una optimización rápida y convergente. Lamentablemente un valor "muy pequeño" o "muy grande" o "adecuado" va a depender de la función de pérdida y modelo utilizados, por lo que cada caso requerirá de varias pruebas para encontrar el mejor valor de learning rate.

Variantes del Descenso por Gradiente

En todos los casos anteriores hemos utilizado todos los elementos del dataset en el cálculo del gradiente. Esto se conoce como Batch Gradient Descent y su principal limitación es aquellos casos en los que el conjunto de datos es tan grande que no cabe en la memoria del ordenador. En estos casos tenemos dos alternativas. En el caso diametralmente opuesto, Stochastic Gradient Descent calcula la derivada por cada elemento del dataset de manera independiente. Aún así, la opción más utilizada es Mini-Batch Gradient Descent, método en el que usaremos un pequeño conjunto de los datos para calcular la derivada de la función de pérdida en cada iteración.

Stochastic Gradient Descent

def solve_sgd(epochs = 5, w = 1.2, lr = 0.2):
    # stochastic gradient descent
    weights = [(w, gradient(w, x, y), cost(x*w, y))]
    for i in range(1,epochs+1):
        # un update por cada elemento del dataset
        for _x, _y in zip(x, y):
            dw = gradient(w, _x, _y)
            w = w - lr*dw
            weights.append((w, dw, cost(_x*w, _y)))
    return weights
weights = solve_sgd()
anim = compute_anim(weights)
anim

Este algoritmo es capaz de converger, pero en esta variante el proceso de optimización es errático, ya que la estimación del gradiente varía mucho de un punto a otro.

Mini-Batch Gradient Descent

def solve_mbgd(epochs = 20, w = 1.2, lr = 0.2, batch_size=10):
    # mini-batch gradient descent
    batches = len(x) // batch_size
    weights = [(w, gradient(w, x, y), cost(x*w, y))]
    for i in range(1,epochs+1):
        # un update por cada `batch`
        for j in range(batches):
            _x = x[j*batch_size:(j+1)*batch_size]
            _y = y[j*batch_size:(j+1)*batch_size]
            dw = gradient(w, _x, _y)
            w = w - lr*dw
            weights.append((w, dw, cost(_x*w, _y)))
    return weights
weights = solve_mbgd()
anim = compute_anim(weights)
anim

En este caso observamos un comportamiento intermedio entre utilizar todos los datos para hacer un paso de optimización o un solo elemento del dataset. El proceso sigue convergiendo, no de manera tan suave como en la variante Batch Gradient Descent, sigue siendo un poco errático, pero no tanto como la variante Stochastic Gradient Descent. Este hecho hace que sea la opción por defecto en la mayoría de procesos de optimización.

⚡ Una implementación en modo mini batch siempre nos permitirá utilizar la variante batch o stochastic simplemente usando un batch size igual al número de elementos del dataset para el primer caso o igual a 1 para el segundo.

Resumen

En este post hemos visto el efecto que tienen los diferentes hyperparámetros en el proceso de entrenamiento del Perceptrón. En lo que respecta a la inicialización del modelo observamos que empezar la optimización cerca del valor óptimo requerirá de pocas iteraciones para converger, mientras que cuanto más alejados empezamos, más iteraciones necesitaremos. También hemos podido comprobar que un learning rate muy pequeño implica un proceso de optimización lento, mientras que un learning rate muy elevado puede hacer que el proceso diverga. Encontrar el mejor valor será clave para conseguir un entrenamiento bueno y rápido. Por último, también hemos visto el efecto que tiene el número de valores que usamos en el cálculo del gradiente. Usar todo el conjunto de datos dará como resultado una optimización estable, pero si nuestro dataset es muy grande no cabrá en memoria y no podremos aplicarlo. En este caso, usar mini-batches (unos pocos datos) soluciona el problema y puede acelerar también la convergencia del proceso de optimización.

< Blog RSS