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:
- Primero realiza un forward hasta llegar a los nodos finales: losses, metricas, etc. Una vez propagado todo, conoce cuanto se equivoca actualmente la red.
- 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:
1 2 3 4 5 6 7 8 |
def backpropagation(self): # Si antes no se ha realizado un forward, debe hacerse. if self.status == Network.STATUS.COMPILED: self.forward() # Recorremos los nodos que SON LOSSES, es decir: Son la salida de datos a la red que determinan el error for n in self.losses: n.backpropagation(is_loss=True) self.status = Network.STATUS.COMPILED |
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á:
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
def clearBackwardDependences(self): self.temp_backward_dependences = 0 def incrementBackwardDependences(self): self.temp_backward_dependences += 1 def checkBackwardDependences(self): return self.temp_backward_dependences == self.number_backward_sum_nexts_nodes #sum([n.compute_backward for n in self.nexts]) def clearBackwardResults(self): self.temp_backward_result = None def computeBackwardAndCorrect(self, has_any_backward_to_compute): # Obtenemos las derivadas de las proximas salidas doutput = self.temp_backward_result if has_any_backward_to_compute: backward = self.layer.backward(doutput) else: backward = None # Transferimos las contribuciones dentro de la capa para corregir los pesos internos # Es importante hacerlo despues del backward, porque este paso variara el valor de los pesos internos if self.layer.is_trainable: self.layer.correctWeights(doutput) return backward def backpropagation(self, is_loss = False): # Si es una loss, debemos indicarlo en la capa posterior, podriamos hacerlo usando isinstance pero es mas rapido usar un booleano. # La diferencia entre un capa normal y una loss es que la loss debe añadirse al final de todo el proceso de la red # Eso es debido a que tiene una naturala distinta al contener los datos en un funcional. has_any_backward_to_compute = self.number_backward_any_prevs_nodes backward = self.computeBackwardAndCorrect(has_any_backward_to_compute) # si no tiene backward o no tiene nodos a los que enviar nada, el proceso se termina if not has_any_backward_to_compute or backward is None: return # si es una loss multiplicamos el peso asociado if is_loss: backward *= self.network.weights_losses[self.layer] # propagamos hacia atras for i, n in enumerate(self.prevs): if n.compute_backward: # si se devuelve una lista, querrá decir que la derivada es distinta por cada entrada if isinstance(backward, list): b = backward[i] else: # la derivada es comun para todas las entradas b = backward # Si no se ha calculado aun el backward se inicializa con la primera contribucion # Esto sucede con nodos que su salida se conecta a distintos nodos, la derivada al final es la suma de todas las contribuciones. # dy/dx = dy/dx(salida1) + dy/dx(salida2) + ... if n.temp_backward_dependences == 0: n.temp_backward_result = b else: n.temp_backward_result += b # Solo se podra ejecutar si todas las dependencias han terminado de calcularse. n.incrementBackwardDependences() if n.checkBackwardDependences(): n.backpropagation() # una vez realizado el backward se vuelven a reinicializar las estructuras # en el backward debemos volver a poner a 0 tambien n.clearBackwardDependences() def computeNumberOfBackwardNodes(self): # para no tener que calcularlo en cada iteracion, lo calculamos en la compilacion self.number_backward_sum_nexts_nodes = sum([n.compute_backward for n in self.nexts]) self.number_backward_any_prevs_nodes = any([n.compute_backward for n in self.prevs]) |
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 ;-)) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
class Layer(object): def __init__(self, node, params={}): self.node = node # pesos de la capa self.weights = Layer.Weights() # valores intermedios de la capa self.values = Layer.Values() # parametros de la capa self.params = params # regularizacion self.regularization = lambda weight: 0 self.is_trainable = True def computeSize(self): pass def compile(self): pass def firstForward(self, inputs): pass def forward(self, inputs): pass def backward(self, doutput=None): raise NotImplemented """ WEIGHTS: """ class Weights(object): def copy(self): c = self.__class__ copy_weights_instance = c.__new__(c) for w in self.__attrs__: setattr(copy_weights_instance, w, copy.copy(getattr(self, w))) return copy_weights_instance class Values(object): def copy(self): c = self.__class__ copy_values_instance = c.__new__(c) for v in self.__attrs__: setattr(copy_values_instance, v, copy.copy(getattr(self, v))) return copy_values_instance def getRegularization(self, name, weight): if isinstance(self.regularization, dict): return self.regularization[name](weight) else: return self.regularization(weight) """ Para actualizar un peso se necesitan diversos parametros: Loss: donde se esta contribuyendo, desconocemos el funcional que se ha usado para unir los batches (Normalmente es la media). Pero es importante saber que cada loss es acumulada de forma independiente. weights_losses: Indica el peso de cada loss, por defecto este es 1/num_losses name: indica el nombre del parametro a actualizar """ def correctWeight(self, name, dweight): # primero obtenemos el peso a corregir w = getattr(self.weights, name) # añadimos la regularizacion # actualizamos la derivada dweight = dweight + self.getRegularization(name, w) # aplicamos el funcional respecto al batch dweight /= self.node.network.batch_size # realizamos la correccion con respecto al optimizador setattr(self.weights, name, w + self.node.network.optimizer.step(dweight)) def copy(self, node): ... class FC(Layer): def __init__(self, node, neurons, params={}): super(FC, self).__init__(node) self.neurons = neurons def computeSize(self): super(FC, self).computeSize() return tuple([self.neurons]) def compile(self): super(FC, self).compile() self.weights.weights = np.random.rand(sum(self.in_size_flatten), self.neurons) self.weights.bias = np.random.rand(1, self.neurons) def forward(self, inputs): super(FC, self).forward(inputs) input = np.reshape(np.concatenate([i.flatten() for i in inputs], axis=-1), [-1, sum(self.in_size_flatten)]) self.values.input = input out = np.dot(input, self.weights.weights) + self.weights.bias return np.reshape(out, [-1] + list(self.out_size)) def backward(self, doutput): # como la capa envia distintas derivadas a cada entrada, esta debe separar los pesos # Calculamos la backward con todos los pesos: (batch)x(neurons) [X] (neurons)x(I1 + I2 + ... In) = (batch)x(I1 + I2 + ... In) partial = np.transpose(self.weights.weights) global_backward = np.dot(doutput, partial) # las ponemos en formato flatten # se separa en cada input # [(batch)x(I1), (batch)xI2, ..., (batch)x(In)] backwards = np.split(global_backward, np.cumsum(self.in_size_flatten[:-1]), axis=-1) return backwards def correctWeights(self, doutput): # para corregir los pesos estos se derivan con respecto los pesos partial_respect_w = np.transpose(self.values.input) # el resultado es una matriz de (input_size)x(output_size) w = np.dot(partial_respect_w, doutput) # aplicamos las correciones a los pesos self.correctWeight('weights', w) self.correctWeight('bias', np.mean(doutput, axis=0)) |
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:
1 2 3 4 5 6 7 8 |
def correctWeights(self, doutput): # para corregir los pesos estos se derivan con respecto los pesos partial_respect_w = np.transpose(self.values.input) # el resultado es una matriz de (input_size)x(output_size) w = np.dot(partial_respect_w, doutput) # aplicamos las correciones a los pesos self.correctWeight('weights', w) self.correctWeight('bias', np.mean(doutput, axis=0)) |
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}.
1 2 3 4 5 6 7 8 9 10 11 |
def correctWeight(self, label, name, dweight): # primero obtenemos el peso a corregir w = getattr(self.weights, name) # aplicamos el funcional respecto al batch dweight /= self.node.network.batch_size # añadimos la regularizacion # actualizamos la derivada dweight = dweight + self.getRegularization(name, w) # realizamos la correccion con respecto al optimizador iweight = self.node.network.optimizer.step(label, name, dweight) setattr(self.weights, name, w + iweight) |
En el caso concreto de la figura anterior el self.node.network.optimizer.step es:
1 2 |
def step(dw): return - lr*dw |
¡Y eso es todo! Ahora nuestra red va a ser capaz de aprender.
¡Espero que os haya gustado!