[mathjax] Este articulo hablaremos sobre el santo grial de las redes neuronales.

Si recordáis en la segunda parte hablamos sobre el backward desde el punto de vista matemático. El algoritmo de backpropagation (algoritmo que permite a la red aprender). Este usa exactamente la combinación de tanto el forward como el backward.
Aunque aquí somos ingenieros y necesitamos plasmar esas maravillosas formulas en hechos, necesitamos algoritmos que nos lleven a algún lado.
En este articulo os propongo como lo implementé yo el backpropagation, eso no quiere decir que existan de mejores soluciones ni que sea la solución sea la mas optimizada.
Sino que es como yo entiendo mejor el concepto. Como siempre comentaros que es muy interesante si lo veis de otra forma o si encontráis una forma mejor, la compartáis conmigo.

¿Recordáis que os decía que una red neuronal no era más que un grandioso funcional? Pues bien cuando hablamos de funcionales matemáticamente tenemos que tener la idea de que estaremos tratando con una integral que se encarga compactar los valores. Cuando llevamos ese concepto a un problema discreto está se convierte en un simple sumatorio.
En nuestro problema de la red neuronal esto sucede justo después de aplicar la Loss. Momento en el que nos encargaremos de mezclar los errores de cada muestra para tener un único error. Si eso se realizara por todas las muestras del aprendizaje hablaríamos de gradient descent habitual, si es por todo el batch hablaríamos de batch gradient descent (podéis repasar los términos en el articulo 1 de esta serie).

min_{W}\sum_{i \in batch} \frac{1}{N} Loss \left ( F_{W}(X_i), Y_i \right )

El hecho que sea un sumatorio nos aporta una ventajas fantásticas:

  • Podemos combinar distintas losses, donde cada una calcula un error distinto y/o en un punto distinto de la red. Como la suma es asociativa, podemos decir que: \sum_i L1_i \alpha  + \sum_i L2_i \alpha =\sum_i (L1_i + L2_i) \alpha
  • En muchos casos resolverlo como si fueran matrices u operar por elementos (por ejemplo usar el operador hadamard), de modo que podemos usar librerías que computan en la GPU.

El algoritmo estrella, el Backpropagation:

En sí el proceso de backward se encuentra dentro del backpropagation. El algoritmo de backpropagation es bastante simple:

  1. Primero realiza un forward hasta llegar a los nodos finales: losses, metricas, etc. Una vez propagado todo, conoce cuanto se equivoca actualmente la red.
  2. Después aplica un backward y calcula para cada peso como deben ser corregidos para disminuir el error. Ademas usa los valores intermedios del forward para no tener que volver a calcularlos.

ES… ¡BRILLANTE!

Como el forward ya vimos como funciona, unicamente nos centraremos en la implementación del backpropagation y backward…
Todo el proceso empieza por los nodos que son de tipo Loss ya que són los que conocen el error:

Asi que los losses son el punto de partida para corregir los pesos.
Para simplificar la explicación usaremos una red que solamente tiene: una entrada, una capa de Fully connected (matriz todos contra todos) y una L2-loss. Esto se expresa de la siguiente forma:

min_{W, \beta}\frac{1}{2}\sum_i || WX_i + \beta - Y_i ||^2

donde(W) son los pesos y(\beta) el bias. Si lo por capas (composición de funciones) obtenemos que:

  • Loss(a, Y) = \frac{1}{2}|| fc(a) - Y_i || ^2.
  • Fc(a) = Wa + \beta.

Cuando queremos propagar la corrección hacoa atrás deberemos derivar respecto la entrada de la capa y cuando queremos corregir los pesos deberemos derivar respecto cada uno de los pesos de la capa.
Eso quiere decir que capas con pesos deben calcular tantas derivadas como pesos tiene más las entradas. Y capas sin pesos solo las entradas.
Veamos el anterior ejemplo:

  • Loss(a, b) derivará respecto su entrada \frac{\partial Loss(a, Y)}{\partial fc(a)} =\frac{2}{2}(fc(a) - Y_i) y propagará hacia atrás.
  • Fc(a) derivará:
    • respecto los pesos:\frac{\partial Fc}{\partial W} = a,\frac{\partial Fc}{\partial \beta} = I y corregirá.
    • respecto la entrada:\frac{\partial Fc}{\partial a} = W y propagará hacia atrás (aunque en este caso es algo inútil ya que input es el ultimo nodo y no tiene pesos).

Acordaros que derivar sobre una capa no es suficiente y siempre debemos usar la regla de la cadena.  De lo contrario no estaríamos corrigiendo respecto la loss/losses y la corrección del error no se estaría propagando:


¿Que sucede a nivel de código? Este proceso se implementa en gran parte a la clase nodo. Que gestiona toda la propagación de derivadas y regla de la cadena.

Cada nodo realiza principalmente dos operaciones importantes:

  • Derivada respecto la entrada (se encuentra en computeBackwardAndCorrect): El nodo llama a la capa a que derive respecto su entrada, aplica la regla de la cadena y se lo pasa al anterior nodo. Esta sirve para que anteriores nodos que tengan capas con pesos sean capaces de corregirlos. Existen nodos que no tienen pesos propios pero que solo calculan esta derivada para que anteriores nodos puedan calcular la corrección de sus pesos.
  • Llama a que derive la capa sobre sus pesos (es correctWeights): El nodo le pasa a la capa la derivada acumulada hasta ahora y ella se encarga de corregir los pesos.

En el caso del Fully-Connected seria el siguiente (lo detallaremos más en profundidad en los siguientes artículos, pero así os hacéis una idea ;-)) :

Como veis la cosa se complica un poco… Pero si os fijáis en el método correctWeights de la clase FC, hace exactamente lo que hemos explicado antes. Acumula la derivada y corrige los pesos (\Sigma) y el bias (\beta).

Casos cabrones:

Existen dos casos que son los que complican un poco el proceso del backward.

Múltiples salidas:

¿Recordais en el caso del forward que teníamos un contador de dependencias? Eso nos permitia saber si hemos propagado a todos los nodos.
En este caso usaremos el mismo concepto para saber cuales nodos ya pueden calcularse. Aunque debido a la naturaleza del backward este se complica un poco más…
En el forward solo teníamos una salida por capa, pero en el backward cuando tenemos múltiples salidas de una capa, la derivada es la suma de todas ellas.



Esto lo podemos encontrar dentro del método backpropagation (lineas 42-65 del código del articulo).

Múltiples entradas:

Como comentamos cuando tenemos múltiples entradas en un nodo, debemos derivar por cada distinta entrada. ¿Como se traduce esto en el código? Es la clase Layer será la que se encargue.
Cuando una capa admite distintas entradas, el método backward de la clase Layer debe devolver una lista donde cada elemento es la derivada por cada entrada respectivamente.

Del backward a la corrección de los pesos

Ahora hemos propagado las derivadas a lo largo de la red, pero ¿Como las usamos para corregir los pesos de una capa? Pues esa función es la que se encarga el Optimizador. Para este articulo usaremos el más cutre pero existen otros que benefician el aprendizaje y aportan ademas estabilidad numérica (Adam, RMSProp, SGD, … Keras por ejemplo tiene todos estos: https://keras.io/optimizers).
En la clase de la capa especifica que estamos usando, existe un método llamado correctWeights, este se encarga de derivar respecto los pesos y corregirlos, veamos en el caso de la capa Fully-Connected:

El optimizador recibe \frac{\partial A}{\partial W}\frac{\partial Loss}{\partial A}, el es el encargado de actualizar los pesos. El optimizador usado en la figura anterior es un simple Gradient Descent. A nivel de código, lo encontramos en la clase Layer el método correctWeight. Este método recibe el nombre del peso que debe actualizarse y \frac{\partial A}{\partial W}\frac{\partial Loss}{\partial A}.

En el caso concreto de la figura anterior el self.node.network.optimizer.step es:

¡Y eso es todo! Ahora nuestra red va a ser capaz de aprender.
¡Espero que os haya gustado!

Leave a Reply