Autor: Luis Fernando Apáez Álvarez
El objetivo del entrenamiento de una red neuronal consiste en hallar el valor óptimo para el vector de pesos de la red, donde el error cometido por la red respecto a los valores reales debe ser mínimo. Con base en lo anterior, el siguiente paso sería evaluar el modelo obtenido con el conjunto de datos de prueba.
Es preciso mencionar que en este procedimiento de entrenamiento, al intentar minimizar de más el error, no obtendremos necesariamente un mejor modelo, más bien corremos el riesgo de un sobreajuste, es decir, especializar demasiado la red, la cual no permitirá generalizar el modelo para datos que no sean con los cuales ya trabajó previamente. En conclusión, se intentará minimizar el error pero se mantendrá cierto margen del mismo para evitar el sobreajuste.
Asimismo, mencionamos algunas condiciones de parada que nos especificaran cúando debemos de parar el entrenamiento de la red:
Los valores de las neuronas de una capa inferior son propagados hacía las neuronas de la capa superior por medio de las conexiones, donde para cada capa se efectúan una serie de operaciones matemáticas. Básicamente, la propagación hacía adelante es un proceso de izquierda a derecha, partiendo de la capa de entrada de la red hasta la capa de salida.
Considerando la arquitectura de red neuronal
Una vez que se realiza el proceso de propagación hacia adelante obtendremos una salida como resultado, luego, obtendremos también un valor para la función de error (función que, de manera conjunta, contabiliza los errores comparando los valores de predicción con los valores reales). Luego, el proceso volverá a repetirse modificando los vectores de pesos de modo que se busca minimizar la función de error. Al implementarse el algoritmo de propagación hacia adelante, la actualización de los pesos para obtener un mejor modelo se hace respecto a los pesos de cada posible camino, lo cual es computacionalmente costoso, en especial cuando se tienen muchas neuronas, capas y conexiones neuronales. De tal manera, será necesario hallar otro algoritmo que mejore lapropagación hacía adelante.
La idea básica detrás del algoritmo de backpropagation es ir calculando los errores hacía atrás. Sucede solo durante el entrenamiento de la red neuronal. La propagación hacia atrás es lo que hace que la red neuronal aprenda, es decir, sepa cada vez hacer mejores predicciones. Esta propagación se realiza con cualquier algoritmo de optimización numérica, por ejemplo el descenso de gradiente estocástico (SGD), ADAM, etc. Después de que se haya realizado la propagación hacia adelante sucede lo siguiente:
El proceso de propagar el error hacia detrás matemáticamente es el siguiente:
Una vez que calculamos el error, digamos que dicho valor es $\delta$. Lo propagamos hacia atrás, a todas las neuronas de cada capa. Para ello calculamos el error $\delta_1$ de cada neurona. Este se calcula, multiplicando el peso $w$ de dicha neurona, por el error $\delta_2$ de la capa siguiente por la derivada de la función de perdidas $J$ que a su vez es multiplicado por el input que le llega a la capa de salida ($a11$). Replicando esta operación en cada neurona tendremos el error $\delta$ de todas las neuronas. La fórmula matemática es:
$$ \delta_1^{(1)}=w_{1}\cdot \delta_1^{(2)}\cdot J'(a_{11}) $$Este proceso es más conocido como la regla de la cadena. Una vez tenemos todos los deltas de cada neurona, para actualizar su peso $w$, lo que hacemos es, a cada peso $w$ de cada neurona le restamos el resultado de la multiplicación del $\delta$ (error) por el ratio de aprendizaje $\alpha$ por el input que le llego a esa unidad en la propagación hacia adelante $\phi$. La fórmula matemática es:
$w=w-\alpha\cdot \delta\cdot \phi$
En resumen, podemos decir:
La idea de Backporpagation es comparar el valor de salida de la Red con el valor deseado del conjunto de entrenamiento e ir reduciendo dichos errores modificando los pesos de cada conexión. Este proceso se realiza capa por capa, de modo que solo se modifican los pesos de una capa y no todos los posibles como se realizaba con el algoritmo de propagación hacia adelante.
El algoritmo consiste en calcular las derivadas parciales de la función de pérdida o coste$^{(1)}$ de los parámetros, es decir, en función de los pesos asignados a la información recibida de cada neurona de la capa anterior y el sesgo (o bias). Con esto, se podrá conocer la influencia de cada una de las neuronas en el resultado final a través del vector gradiente, que posteriormente, utilizará el método del Descenso del Gradiente, para el aprendizaje de la Red. El método del descenso del gradiente es un método muy utilizado en general en Machine Learning, mientras que el Backpropagation es el que se aplicará de forma más concreta en una Red Neuronal. Más adelante se hablará de él con mayor profundidad.
$(1)$ La función de coste representa la suma del error, la diferencia entre el valor predicho y el valor real (etiquetado). Un ejemplo de función de coste el es error cuadrático medio:
Backpropagation trata, básicamente, de entender cómo el cambio de los pesos y de los sesgos impacta en la función de coste, lo cual no llevará indudablemente al cálculo de derivadas.
El problema es que, en muchos casos, el cálculo de los parámetros que minimizan una cierta función de coste puede resultar computacionalmente inabordable. En una red neuronal, por ejemplo, podemos tener cientos de miles de pesos y de sesgos. Es por ello que se suele recurrir a algoritmos como el descenso de gradiente para el cálculo de estos mínimos. Pero, por supuesto, aplicar el algoritmo de descenso de gradiente implica el cálculo del gradiente de la función de coste en un punto concreto de su dominio (en un punto determinado por unos parámetros concretos), lo que a su vez implica el cálculo de la derivada parcial de la función de coste con respecto a todos y cada uno de los parámetros que van a determinar el funcionamiento de la red neuronal. Dicho cálculo de derivadas parciales suele ser costoso, pues en el pasado, dichos cálculos se efectuaban mediante la fuerza bruta.
Y es aquí donde el algoritmo de backpropagation llega para resolver el problema:
Entonces $\frac{\partial C}{\partial w}$ nos dice qué tan rápido cambia el coste cuando cambiamos el los pesos y los sesgos. Luego, denotemos por $w^{l}_{jk}$ como el peso asociado a la conexión neuronal entre la neurona $k$ y la neurona $j$, referente a la capa $l$. De forma análoga tendremos que $b^{l}_{j}$ representa el sesgo asociado a la neurona $j$ en la capa $l$ y $a^{l}_{j}$ corresponde a la activación de la neurona $j$ en la capa $l$.
Con base en lo anterior, podemos establecer que la activación de la neurona $j$ en la capa $l$ está relacionada con las activaciones en la capa $l-1$ mediante la ecuación:
donde estamos actuando sobre las neuronas $k$ de la capa $l-1$ y donde $f$ es una función de activación. Podemos considerar todos los pesos asociadosa las conexxiones como un vector, así como las activaciones y los bias, de donde la ecuación anterior se puede representar matricialmente como
$$ a^{l}=f\left(w^{l}a^{l-1}+b^{l}\right) $$Continuando, diremos que $\delta_{j}^{l}$ representa el error en la neurona $j$ de la capa $l$, de donde el algoritmo de backpropagation nos dará un procedimiento para hallar los valores de $\delta_{j}^{l}$ relacionados con las derivadas parciales $\frac{\partial C}{\partial w^{l}_{jk}}$ y $\frac{\partial C}{\partial w^{b}_{j}}$. Podemos entender dichas cantidades $\delta_{j}^{l}$ como sigue:
Una vez que obtenemos el error general de la red neuronal en una iteración, dicho error será propagado hacia atrás. Consideremos que una neurona en particular recibe de la capa anterior una ponderación de entrada de $z^{l}_{j}$, entonces, dicha neurona en vez de generar el valor de $f(z^{l}_{j})$, generará el valor de $f(z^{l}_{j}+\Delta z^{l}_{j})$, es decir, se ha agregado un pequeño cambio en la salida. De nuevo, dicho cambio será propagado hacia atrás, lo cual, finalmente, hará que la función de coste cambie en una cantidad $\frac{\partial C}{\partial z^{l}_{j}}\Delta z^{l}_{j}$. En el proceso, el algoritmo trata de hallar un valor del error $\delta_{j}^{l}$ cada vez más pequeño, minimizando así la función de coste. Con base en todo lo anterior establecemos que
$$ \delta_{j}^{l}=\frac{\partial C}{\partial z^{l}_{j}} $$será nuestra medida del error. A continuación presentaremos 4 ecuaciones fundamentales detrás del algoritmo de backpropagation.
El descenso del gradiente es un método de optimización en el cual se toman las primeras derivadas de la función de coste. Eso nos da información puntual sobre la pendiente de la función, pero no sobre su curvatura. Podríamos calcular las segundas derivadas, de forma que pudiéramos conocer también cómo varía el gradiente, pero eso supondría un elevado coste computacional. Existen algunas técnicas de aproximación donde se estiman esas segundas derivadas con un uso de memoria limitado, pero lo más utilizado son optimizaciones sobre el descenso del gradiente estocástico.
Consideremos por ejemplo el siguiente gráfico
Podemos entender intuitivamente el algoritmo del descenso del gradiente, para la función cuya curva es de color azul, como el proceso en el cual partiremos de un punto aleatorio sobre la curva y mediante iteraciones dicho punto se "moverá" intentando buscar el mínimo de la función. Si consideramos un punto en la curva en el tiempo $t$, digamos $x_{t}$. En la siguiente iteración tendremos que $x_{t+1}=x_{t}-\alpha f'(x_{t})$, esto es, al punto $x_{t}$ le restamos la pendiente, pues ésta nos dará la dirección a la cual nos moveremos. Por ejemplo, si $f'(x_{t})>0$, entonces $x_{t+1}$ será mayor a $x_{t}$, lo cual nos dice que $x_{t}$ debe moverse a la derecha en busca del punto mínimo. Al valor $\alpha$ se le conoce como tasa de aprendizaje.
Con base en lo anterior podemos establecer un primer algoritmo para el descenso del gradiente:
# Librerias necesarias
import sympy as sm
import random as rd
# Definimos a x como un simbolo
x = sm.symbols('x')
# Funcion en cuestion
y = 'x ** 2'
# Tasa de aprendizaja
a = 0.01
# Funcion definida por el usuario para evaluar la derivada en un
# punto dado x0
def derivada(x0):
return eval(str(sm.diff(y,x,1)), {'x':x0})
# Inicializamos un primer valor de x0 de manera aleatoria
x0 = rd.random()
# Evaluamos la funcion en x0
y0 = eval(y, {'x': x0})
# Iteramos 1000 veces
for i in range(1000):
# Hacemos la actualizacion
x0 -= a * derivada(x0)
y0 = eval(y, {'x': x0})
# Veamos los valores hallado
print(x0, y0)
6.341698748332135e-10 4.0217143014597363e-19
Obtenemos que $x0$ es extremadamente cercano a cero, así como $f(x0)$. Lo cual representa una muy buena aproximación pues sabemos que el mínimo de $f(x)=x^{2}$ se alcanza en $x=0$.
Para que la clase no sea tan pesada, continuaremos abordando el algoritmo del descenso del gradiente en la siguiente clase.