Por ello usaremos un sencillo diagrama de clases que nos puede aproximar como acabará siendo el proyecto:
Podemos observar que la red contiene nodos y a su vez esos nodos tienen una capa asociada.
Para algunas de las capas más representativas de una NN, podríamos ver el siguiente diagrama:
Vamos a definir una clase capa, que tendrá funciones que usaremos (aunque en el futuro probablemente debamos añadir algunas más funcionalidades).
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 |
class Layer(object): def __init__(self, node, func_repr_weights=lambda x: x, params=None): Layer.LAYER_COUNTER += 1 self.LAYER_COUNTER = Layer.LAYER_COUNTER if params is None: params = {} self.node = node # pesos de la capa self.weights = Layer.Weights(func_repr_weights) if 'weights_names' in params: self.weights_names = params['weights_names'] del params['weights_names'] else: self.weights_names = None # valores intermedios de la capa self.values = Layer.Values() # parametros de la capa self.params = params # regularizacion self.regularizator = None self.is_trainable = True self.compute_backward = True def computeSize(self): ... def compile(self): # inicializaciones ... def forward(self, inputs): pass def derivatives(self, doutput): raise NotImplemented def copy(self, node): return cls(self, node) |
En la clase genérica Layer encontramos distintos métodos interesantes:
- El constructor crea una nueva capa define:
- Al que nodo pertenece.
- Inicializa el puntero de los pesos y los valores intermedios, pero no carga nada.
- Carga los parámetros.
- La copia simplemente copia una capa a otro nodo.
- El método computeSize se encarga de calcular el tamaño de salida.
- El método compile se encarga de generar las estructuras de datos que necesita la capa, normalmente son pesos.
- El método forward usa la capa y envía el resultado.
- El método derivatives, calcula la derivada respecto a la entrada y opcionalmente si tiene pesos la derivada por cada peso que tiene la capa. La propagación de esto, se encargará el propio nodo.
Compilación:
El proceso de compilación empieza en la clase red (Network) y se encarga de realizar distintos pasos tanto para asegurarse que la red se podrá ejecutar como preparar las estructuras necesarias (lo comentamos en el anterior post).
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 |
# Network.py def __compile(self): for n in self.nodes.values(): # limpiamos las dependencias n.clearForwardDependences() n.clearForwardResults() n.clearBackwardDependences() n.clearBackwardResults() n.clearSizesResults() # determinamos el tipo de nodo n.determineNode() # Realizamos los tests self.__testHasInputs() self.__testHasOutputs() self.__testHasNotConnecteds() self.__testIsAcyclicGraph() # Habilitamos los tests en tiempo de ejecuccion self.predict_flag = False # Calculamos los tamaños, realmente aplicamos un "forward" # Las unicas capas que no tienen size de entrada son los inputs for n in self.nodes_with_only_outputs.values(): n.computeSize() # Compilamos las capas for n in self.nodes.values(): n.compile() for l in self.losses: l.hasToComputeBackward() for n in self.nodes.values(): # se dene ejecutar una vez compilados n.computeNumberOfBackwardNodes() # cambiamos estado self.status = self.STATUS.COMPILED def compile(self, inputs, outputs, losses, metrics = [], optimizer=SGD()): self.__testFreezeModel() if isinstance(metrics, Node): metrics = [metrics] self.metrics = metrics # Calculamos los pesos de las losses # si la entrada ha sido una lista los pesos de cada loss son equiprobables. if isinstance(losses, Node): losses = [losses] if isinstance(losses, list): v = 1./len(losses) for l in losses: l.layer.weight = v self.losses = losses # si en cambio se ha introducido un diccionario del tipo {Loss1: 0.5, Loss2: 0.3, Loss3: 0.2} elif isinstance(losses, dict): for k, v in losses.items(): k.layer.weight = v self.losses = list(losses.keys()) self.optimizer = optimizer # Compilamos la parte interna self.__compile() # Entradas y salidas de la red if isinstance(inputs, Node): inputs = [inputs] self.inputs = inputs if isinstance(outputs, Node): outputs = [outputs] self.outputs = outputs # Empezamos self.__start() |
La compilación recibe como parámetros, las losses que se encargan de minimizar el error, las métricas adicionales y finalmente el optimizador (hablaremos más adelante, por ahora podríamos decir que es el encargado de actualizar el peso, en el caso de un descenso de gradiente puro es solamente el learning rate $\mathbf{t}$ en $W = W – \mathbf{t} \frac{\partial L} {\partial W}$ ).
Internamente la compilación realiza:
- Limpia los valores temporales de los nodos.
- Determina el tipo de nodo que es (Entrada, salida o intermedio en la red).
- Realiza unos tests que puede ejecutarse antes de la ejecución como por ejemplo evitar bucles en el grafo.
- Calcula constantes que se usaran a lo largo de todo el aprendizaje.
- Calcula todos los tamaños.
- Compila las capas (Guarda espacio para los pesos, estructuras temporales, etc).
- Se introducen los pesos de cada loss o se ponen pesos por defecto iguales para todas.
Uno de los pasos es calcular los tamaños que entran y salen en cada capa. El funcionamiento para calcularlos es similar al forward y se inicia en Network. Con esto queremos evitar inconsistencias entre los tamaños, futuros problemas en los cálculos y alertar de que hay un problema en la red al usuario.
1 2 3 4 5 6 7 |
# Network.py def computeSizes(self): for n in self.nodes.values(): n.clearSizesResults() # Calculamos por cada nodo su tamaño for n in self.nodes_with_only_outputs.values(): n.computeSize() |
Por cada nodo borramos sus tamaños y procedemos a calcularlos. En el Node el proceso de calculo de tamaños es exactamente igual al forward (que explicamos en el primer articulo) pero esta vez en vez de realizar la función del nodo, calculamos los tamaños que entrarán y saldrán de esa capa en concreto.
En cada nodo realizaremos las siguientes operaciones:
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 |
# Node.py def clearSizesResults(self): self.layer.in_size = [] self.layer.in_size_flatten = [] self.layer.out_size = None self.layer.out_size_flatten = None def checkComputeSizeDependences(self): check = True for n in self.prevs: check &= (n.layer.out_size is not None) return check def computeSize(self): self.layer.in_size = [n.layer.out_size for n in self.prevs] self.layer.in_size_flatten = [int(np.prod(s)) for s in self.layer.in_size] self.layer.out_size = [int(n) for n in self.layer.computeSize()] self.layer.out_size_flatten = int(np.prod(self.layer.out_size)) for n in self.nexts: # Solo se podra ejecutar si todas las dependencias han terminado de calcularse. if n.checkComputeSizeDependences(): n.computeSize() def hasToComputeBackward(self): self.compute_backward = True for n in self.prevs: if not n.compute_backward and n.layer.compute_backward: n.hasToComputeBackward() |
La función computeSize es la encargada de calcular tanto los tamaños de entrada (que pueden ser varios) y la salida (que solo puede ser una única salida que puede ir a distintos nodos). Y luego propagar el calculo hacia los posteriores nodos.
La función checkComputeSizeDependences impide realizar el calculo sin antes no haber calculado los tamaños de los nodos del que ese nodo depende. En la siguiente figura C, debe esperar a A y B.
Una vez hemos calculado los tamaños podemos realizar comprobaciones interesantes dependiendo de la capa en la que estemos trabajando. Por ejemplo podemos forzar a que todos los tamaños de entrada sean iguales y si no devolver un error.
1 2 3 4 |
# Dentro de layers/input.py if self.in_size[:1] == self.in_size[:-1]: raise Network.DifferentInputShape("El tipo de datos en la capa " \ + self.node.name + " debe ser igual en todos los casos. "+str([i.shape for i in inputs])) |
En el próximo articulo realizaremos el forward de los nodos.