Nos centraremos principalmente en la base matemática que hay detrás de la propagación (en futuros artículos lo programaremos).
Antes de empezar vamos a realizar un pequeño paréntesis para explicar algunas herramientas que nos van a ser útiles para detectar problemas en la arquitectura de la red.
Herramientas
Detección de ciclos
Nuestra red es un grafo aciclico dirigido (DAG), esto quiere decir que no podemos encontrarnos ciclos en la red. La existencia de bucles en la red simplemente harían que nunca acabase la propagación.
El algoritmo para detectar bucles lo único que hará es:
- Trazar un camino desde un nodo de entrada cualquiera de la red.
- Ir avanzando luego se comprobará a cada paso si el nodo que sé esta explorando ya forma parte del camino.
- De ser así quiere decir que hemos pasado múltiples veces por el mismo nodo y por lo tanto tiene que haber un ciclo.
Veamos como implementamos este concepto:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def testIsAcyclicGraph(self): # definimos una estructura para saber el camino recorrido path_nodes = [] # función recursiva que nos permitirá recorrer nodos def exploreNode(node): # Comprobamos que no este en el camino if node in path_nodes: raise Network.IsCyclicGraphException("¡El grafo es ciclico!") # Lo añadimos al camino path_nodes.append(node) # Avanzamos al siguiente nodo for n in node.nexts: exploreNode(n) # Volvemos hacia atrás y quitamos el nodo del camino path_nodes.pop() # Empezamos por todos los nodos que solo tienen salidas for n in self.nodes_with_only_outputs.values(): # empezamos a recorrer exploreNode(n) |
Nodos de entrada, salida y no conectados
Es interesante indicar si existen en la red nodos de entrada, de salida o si nodos no conectados.
Aunque esta comprobación es más adicional y no influye directamente en el comportamiento del sistema. Pero nos va a permitir alertar al diseñador de la red de que algo no es correcto (o sobra):
1 2 3 4 5 6 7 8 9 |
def testHasInputs(self): if len(self.nodes_with_only_outputs) == 0: raise Network.NotInputException("¡El grafo no tiene entrada!"+str([n for n in self.nodes_with_only_outputs])) def testHasOutputs(self): if len(self.nodes_with_only_inputs) == 0: raise Network.NotOutputException("¡El grafo no tiene salida!"+str([n for n in self.nodes_with_only_inputs])) def testHasNotConnecteds(self): if len(self.nodes_not_connected) > 0: raise Network.NotConnectedException("¡Existen nodos sin conectar! "+str([n for n in self.nodes_not_connected])) |
Podríamos decir que el proceso de compilación será realizar estos tests y limpiar/inicializar todas las estructuras necesarias para poder aplicar luego tanto el forward como el backward:
1 2 3 4 5 6 7 8 9 10 11 |
def compile(self): for n in self.nodes.values(): # limpiamos las dependencias n.clearForwardResults() n.clearBackwardResults() # determinamos el tipo de nodo n.determineNode() # realizamos los tests self.testHasInputs() self.testHasOutputs() self.testIsAcyclicGraph() |
Profundizando en el forward:
En el primer articulo vimos como funciona el algoritmo de forward/backward en el ámbito de los nodos (como estos se propagan), pero… En las redes neuronales sabemos que los nodos realmente son operaciones. ¿Como se propaga cuando tratamos cada nodo como una función?
El proceso de forward se realiza básicamente pasando nodo a nodo los resultados, es decir la salida de un nodo será la entrada de su siguiente.
Matemáticamente hablando lo que hacemos se conoce como una composición de funciones.
Si usamos la red de la anterior figura, tendríamos lo siguiente en el forward (f = forward):
- f(A) = input(X)
- f(B) = compute_{B}(f(A))
- f(C) = compute_{C}(f(A))
- f(D) = compute_{C}(f(B),f(C))
- f(E) = compute_{C}(f(B))
- f(F) = compute_{C}(f(C))
- F(X) = f(G) = compute_{C}(f(E),f(D),f(F))
En esencia una red neuronal es la composición de capas que crean una función MUY pero que MUY compleja con una condición muy apetecible… Es diferenciable por lo tanto corregible si puede cuantificar el error.
Al final todo se basa en eso. Tener más parámetros equivale a grados de libertad para generar una solución.
Profundizando en el backward:
En el forward todo era bastante sencillo pero en el backward se complica un poco más. Recordemos que para corregir los pesos de cada capa, necesitamos la derivada del error respecto esos pesos.
Si la propagación hacia adelante era una composición de funciones la propagación hacia atrás es un producto de derivadas.
Fijémonos en la regla de la cadena:
Estas serian las derivadas respecto cada variable:
\frac{\partial z(x(w), y(w))}{\partial x} =\frac{\partial z}{\partial x}
\frac{\partial z(x(w), y(w))}{\partial y} =\frac{\partial z}{\partial y}
\frac{\partial z(x(w), y(w))}{\partial w} =\frac{\partial z}{\partial x} \cdot\frac{\partial x}{\partial w} +\frac{\partial z}{\partial y} \cdot\frac{\partial y}{\partial w}
La derivada que llega al nodo corresponde al producto de las derivadas (regla de la cadena) de cada nodo.
Y en caso de tener más de un nodo de salida estas son la suma de todas esas contribuciones.
- \frac{\partial L}{\partial f(G)} = \partial out
- \frac{\partial L}{\partial f(E)} =\frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(E)}
- \frac{\partial L}{\partial f(F)} =\frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(F)}
- \frac{\partial L}{\partial f(D)} =\frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(D)}
- \frac{\partial L}{\partial f(B)} =\frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(E)} \cdot\frac{\partial f(E)}{\partial f(B)} + \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(D)} \cdot\frac{\partial f(D)}{\partial f(B)}
- \frac{\partial L}{\partial f(C)} =\frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(F)} \cdot\frac{\partial f(F)}{\partial f(C)} + \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(D)} \cdot\frac{\partial f(D)}{\partial f(C)}
- \frac{\partial L}{\partial f(A)} =\left ( \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(E)} \cdot\frac{\partial f(E)}{\partial f(B)} + \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(D)} \cdot\frac{\partial f(D)}{\partial f(B)} \right ) \cdot \frac{\partial f(B)}{\partial f(A)} + \\\left ( \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(F)} \cdot\frac{\partial f(F)}{\partial f(C)} + \frac{\partial L}{\partial f(G)} \cdot\frac{\partial f(G)}{\partial f(D)} \cdot\frac{\partial f(D)}{\partial f(C)}\right ) \cdot \frac{\partial f(C)}{\partial f(A)}
Como podemos observar, se crean unos carros de derivadas que asustan. ¡Imaginad cuando hablamos de una red neuronal de verdad! Pero no debéis preocuparos demasiado porque todos estos cálculos los hará nuestro ordenador.
Al final gracias a la regla de la cadena, solo debemos definir:
- La operación de un nodo.
- La derivada de la salida respecto la entrada de ese nodo (para propagar hacia atrás).
- La derivada de la salida respecto sus pesos (para que estos puedan ser corregidos).
En el próximo articulo empezaremos a estructurar nuestro código para que todo esto sea posible.
Referencias:
- https://www.geeksforgeeks.org/detect-cycle-undirected-graph/
- https://es.khanacademy.org/math/multivariable-calculus/multivariable-derivatives/differentiating-vector-valued-functions/a/multivariable-chain-rule-simple-version
- https://en.wikipedia.org/wiki/Function_composition
- https://en.wikipedia.org/wiki/Backpropagation