La Multilayer Perceptron (MLP) probablemente sea la red neuronal más básica (pero no por ello menos potente). El problema que normalmente sucede con esta arquitectura es que usa tantísimos parámetros que cuando el problema es medianamente complejo se disparan, lo que la hace inviable.
La MLP tiene como unidad mínima de inteligencia el Perceptron. Un perceptron nace como un elemento que pretende simular el comportamiento de una neurona, las neuronas tienen un conjunto de entradas y salidas que se comunican a su vez con otras neuronas generando mapas de conexiones sumamente complejos.
Para simular este comportamiento simplemente se disponen de N entradas, estás se multiplican por un peso que indica que importancia o el valor tienen para ese perceptrón en concreto y posteriormente se suman dando como resultado un unico valor. Es decir, estamos aplicando el producto escalar entre entradas y pesos. Posteriormente se aplica una función no-lineal (sin parametros) llamada activación que activa o no la neurona.
a = <X,W> = W^{T}X
y = activacion(a)
¿Que tiene de interesante esa combinación de elementos lineales con elementos no-lineales? Dado que la composición de funciones lineales da como resultado otra función lineal… Si concatenásemos perceptrones conseguiríamos una función que la podría seguir representando un solo perceptron.
Al añadirle una función no-lineal en su salida evitamos justamente eso, a cada concatenación aumentamos la capacidad de nuestra función.
Así que si usamos un único perceptron seremos capaces de representar un pequeño abanico de funciones, esto cambia cuando en vez de tener uno tenemos miles y miles concatenados.
La potencia reside en la cantidad de capas
Ahí es donde a partir de la combinación de funciones simples no-lineales somos capaces de crear un monstruo, capaz de representar «cualquier cosa».
Existen dos formas de añadir perceptrones:
- Paralelo: Comparten la misma entrada pero los pesos de cada neurona son distintos.
y_1 = activacion_1 \left (W_{1}^{T}X \right ) y_2 = activacion_2 \left (W_{2}^{T}X \right )
- Serie: Las salidas de una neurona es la entrada de otra.
y_{1a} = activacion_1a \left (W_{1a}^{T}X \right ) y_{1b} = activacion_1b \left (W_{1b}^{T}X \right ) y_2 = activacion_2 \left (W_{2}^{T} \begin{bmatrix} y_{1a} \\ y_{1b} \end{bmatrix} \right )
Las neuronas en paralelo serán capaces de representar distintos conceptos de una misma entrada y las neuronas en serie a cada nivel extraerán características más complejas.
Veamos como implementamos este concepto (os encontrareis también que habitualmente hablamos de neuronas directamente en vez de perceptrones):
Fully-Connected:
El primer paso es aplicar la parte lineal, las neuronas en paralelo es lo que conocemos como capas fully-connected. ¿Por qué? Porque tenemos todas las neuronas/entradas anteriores conectadas con las neuronas de esta capa. El ejemplo anterior lo podemos escribir de forma más compacta(cada fila es una muestra):
\mathbb{R}^{inputs} \rightarrow \mathbb{R}^{neuronas}
\underbrace{X}_{muestras \times inputs} \cdot \underbrace{\begin{bmatrix}W_{1a} & W_{1b}\end{bmatrix}}_{inputs \times neuronas} = \underbrace{A}_{muestras \times neuronas}
Tenemos una aplicación lineal que convierte una entrada de input dimensiones a una salida de neuronas dimensiones. Usando una matriz somos capaces de representar el primer paso de muchos perceptrones en paralelo. Básicamente es fuerza bruta y conectamos todo con todo y en cada conexión ponemos un peso.
El segundo paso es aplicar una función no-lineal sin parámetros a cada una de las neuronas. Para facilitar la implementación nombraremos Fully-Connected al primer paso y la función no-lineal activación.
Habitualmente en Deep Learning las Fully-Connected ya pueden incluir la capa de activación, estas también se conocen como capa Dense.
Primero de todo debemos definir el constructor la clase FC (Fully-Connected):
1 2 3 4 5 6 7 |
def __init__(self, node, neurons, initializer={'weights': Initializer("normal"), 'bias': Initializer("normal")}, params=None): if params is None: params = {} params['number_of_inputs'] = 1 super(FC, self).__init__(node, weights_names=('weights', 'bias'), params=params) self.initializer = initializer self.neurons = neurons |
Cuando inicializemos una capa FC debemos tener en cuenta solo tres cosas:
- El numero de neuronas de salida, corresponderán al numero de dimensiones de salida.
- El tipo de inicialización de los pesos de la capa (elemento muy importante que explicaremos en futuros artículos).
- Se envían a la clase padre los nombres ordenados de los pesos (tal y como se devuelven en la función derivatives). Esto permite saber cada peso qué derivada tiene asociada.
Los demás argumentos del constructor son añadidos ya que ayudan a definir el comportamiento de esta capa.
En el calculo del tamaño solamente deberemos devolver el numero de neuronas de salida, estas corresponderán a las dimensiones salida
1 2 3 |
def computeSize(self): super(FC, self).computeSize() return tuple([self.neurons]) |
En la compilación lo único que deberemos hacer es inicializar los pesos que vamos a usar (aleatoriamente), en el caso de los pesos (W) se crea una matriz de inputs \times neuronas, en el caso del bias un vector-fila del tamaño de neuronas.
1 2 3 4 |
def compile(self): super(FC, self).compile() self.weights.weights = self.initializer['weights'].get(shape=(sum(self.in_size_flatten), self.neurons)) #np.random.rand(sum(self.in_size_flatten), self.neurons) self.weights.bias = self.initializer['bias'].get(shape=[self.neurons]) #np.random.rand(1, self.neurons) |
El proceso de forward es aquel que hemos repetido varias veces en estos articulos.
\underbrace{X}_{muestras \times inputs} \cdot \underbrace{W}_{inputs \times neuronas} + \underbrace{\begin{bmatrix} b\\ \vdots \\b\end{bmatrix}}_{muestras \times neuronas}
1 2 3 4 5 |
def forward(self, inputs): super(FC, self).forward(inputs) self.values.input = inputs[0] out = self.values.input.dot(self.weights.weights) + self.weights.bias return out.reshape([-1] + self.out_size) |
¿Que sucede en el proceso de backward? Que debemos calcular dos derivadas: respecto la entrada para propagar la derivada, respecto los pesos para corregirlos.
Propagación hacia atrás:
\frac{\partial FC}{\partial X} = W^T
\frac{\partial out}{\partial X} =\frac{\partial out}{\partial FC} \frac{\partial FC}{\partial X} =\frac{\partial out}{\partial FC} W^T
Corrección de pesos:
\frac{\partial FC}{\partial W} = X^T \frac{\partial FC}{\partial b} = I \frac{\partial out}{\partial W} =\frac{\partial FC}{\partial W}\frac{\partial out}{\partial FC} = X^T \frac{\partial out}{\partial FC} \frac{\partial out}{\partial b} =\frac{\partial FC}{\partial b}\frac{\partial out}{\partial FC} = \sum_{i}^{neuronas} \left ( I \cdot \frac{\partial out}{\partial FC} \right )_{i} El código quedaría de la siguiente forma:
1 2 3 4 5 |
def derivatives(self, doutput): dx = doutput.dot(self.weights.weights.T) dw = self.values.input.T.dot(doutput) db = np.sum(doutput, axis=0) return dx, (dw, db) |
La función derivatives no corrige ningún peso solamente aporta los datos necesarios para seguir propagando hacia atrás y para que el optimizador haga su trabajo.
Activación:
La función de activación es la que aplicaremos como función element-wise, es decir: elemento a elemento, la función se aplica por cada elemento de la matriz o vector.
Supongamos que: activacion(x) = \phi(x) una función no-lineal cualquiera.
Su correspondiente backward:
\frac{\partial out}{\partial X} = \frac{\partial out}{\partial \phi} \circ \frac{\partial \phi}{\partial X}
El operador \circ es el operador Hadamard que es simplemente aplicar el producto elemento a elemento (A \circ B equivale a \forall i,j\>A_{i,j}B_{i,j} ).
Algunas activaciones:
Existen funciones que están demostradas que son útiles en redes neuronales. La única condición de una función de activación es que no sea constante y sea diferenciable aunque sea por partes. De las mas comunes: tanh, sigmoide, ReLU, leaky-relu (PReLU).
Tanh
La función tanh debido a su naturaleza de función con dos asintoticas horizontales comprime los datos de entrada en una salida comprendida entre [-1, 1].
Esto puede suponer una desventaja cuando se concatenan capas con activaciones tanh porque puede provocar problemas que se conoce como vanishing gradient. El vanishing gradient es básicamente que el valor del gradiente se vuelve tan pequeño que no hace cambiar prácticamente los pesos en la corrección.
¿Porque sucede eso? Pues básicamente por la compresión que hace en el rango de entrada de los datos, cada tanh coge el rango [-\inf, \inf] a [-1, 1] y como consecuencia la derivada le sucede lo mismo.
La derivada es \frac{\partial tanh(x)}{\partial x} = \frac{1}{cosh^2 (x)} = 1 - tanh^2(x).
Sigmoid
Es una función que tiene un comportamiento bastante similar al de la tanh de hecho tiene el mismo problema de vanishing gradient.
sigmoid = \frac{1}{(1 + e^{-x})}
La derivada es \frac{\partial sigmoid(x)}{\partial x} = sigmoid(x)(1 - sigmoid(x)).
Existen algunas evidencias que la tanh tiene un mejor comportamiento que la sigmoide al estar centrada al 0 (ya que normalmente el algoritmo converge antes y mejor con los datos centrados al 0). Pero la función tanh requiere más computo.
Convergence is usually faster if the average of each input variable over the training set is close to zero. To see this, consider the extreme case where all the inputs are positive. Weights to a particular node in the first weight layer are updated by an amount proportional to δx where δ is the (scalar) error at that node and x is the input vector (see equations (5) and (10)). When all of the components of an input vector are positive, all of the updates of weights that feed into a node will have the same sign (i.e. sign(δ)). As a result, these weights can only all decrease or all increase together for a given input pattern. Thus, if a weight vector must change direction it can only do so by zigzagging which is inefficient and thus very slow. – Yan Lecun (Articulo)
ReLU:
Es la función más utilizada actualmente. Es una función curiosa ya que no es continua sino por partes.
relu = max(0, x) =\begin{cases} x &, x \geq 0\\0 &, x < 0\end{cases}
Básicamente elimina todo valor que no sea superior a 0. ¿Que beneficios aporta una función así? Primero que no es lineal y fácilmente computable y segundo que toda neurona que no debe ser activada derivará en un valor negativo. Esto tiene una ventaja y es que fuerza una solución «sparse» y lo más sencilla posible.
Como consecuencia también conocida como dying ReLU, hay que ir con cuidado con esta capa de activación porque a veces su comportamiento es tan agresivo que perdemos todos los gradientes de las entradas menores que 0 y si estas son la gran mayoría es probable que la red no aprenda.
Unfortunately, ReLU units can be fragile during training and can «die». For example, a large gradient flowing through a ReLU neuron could cause the weights to update in such a way that the neuron will never activate on any datapoint again. If this happens, then the gradient flowing through the unit will forever be zero from that point on. That is, the ReLU units can irreversibly die during training since they can get knocked off the data manifold. For example, you may find that as much as 40% of your network can be «dead» (i.e. neurons that never activate across the entire training dataset) if the learning rate is set too high. With a proper setting of the learning rate this is less frequently an issue. – Articulo
¿Como es la derivada?
\frac{\partial relu}{\partial x} =\begin{cases} 1 &, x \geq 0\\0 &, x < 0\end{cases}
Leaky ReLU:
Trata de solucionar el problema de las ReLU, simplemente añade una pequeña pendiente a los valores negativos.
prelu =\begin{cases} x &, x \geq 0\\ax &, x < 0\end{cases}
Un valor posible de a podría ser 0.3.
La derivada también es similiar a la ReLU:
\frac{\partial prelu}{\partial x} =\begin{cases} 1 &, x \geq 0\\a &, x < 0\end{cases}
Implementación de la activación:
Las implementaciones de las capas de activación son muy sencillas eso se debe a que son funciones element-wise y las dimensiones de salida serán equivalentes a las de entrada. Tampoco tienen pesos así que solo tienen derivada respecto la entrada.
Usamos values para guardar valores que podemos reutilizar en la derivada.
Tanh:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from .layer import Layer import numpy as np class Tanh(Layer): def __init__(self, node, params=None): if params is None: params = {} params['number_of_inputs'] = 1 super(Tanh, self).__init__(node, params=params) def computeSize(self): super(Tanh, self).computeSize() return tuple(self.in_size[0]) def forward(self, inputs): super(Tanh, self).forward(inputs) input = inputs[0] self.values.input = np.tanh(inputs[0]) return self.values.input def derivatives(self, doutput): dx = doutput*(1 - (self.values.input**2)) return dx |
Sigmoid:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from .layer import Layer import numpy as np class Sigmoid(Layer): def __init__(self, node, params=None): if params is None: params = {} params['number_of_inputs'] = 1 super(Sigmoid, self).__init__(node, params=params) def computeSize(self): super(Sigmoid, self).computeSize() return tuple(self.in_size[0]) def forward(self, inputs): super(Sigmoid, self).forward(inputs) input = inputs[0] input = 1./(1. + np.exp(- input)) self.values.input = input return input def derivatives(self, doutput): partial = self.values.input*(1 - self.values.input) return doutput*partial |
ReLU:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from .layer import Layer import numpy as np class ReLU(Layer): def __init__(self, node, params=None): if params is None: params = {} params['number_of_inputs'] = 1 super(ReLU, self).__init__(node, params=params) def computeSize(self): super(ReLU, self).computeSize() return tuple(self.in_size[0]) def forward(self, inputs): super(ReLU, self).forward(inputs) input = inputs[0] self.values.input = inputs[0] return np.maximum(0, self.values.input) def derivatives(self, doutput): dx = np.array(doutput, copy=True) dx[self.values.input <= 0] = 0 return dx |
Leaky ReLU:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
from .layer import Layer import numpy as np class PReLU(Layer): def __init__(self, node, alpha, params=None): if params is None: params = {} self.alpha = alpha params['number_of_inputs'] = 1 super(PReLU, self).__init__(node, params=params) def computeSize(self): super(PReLU, self).computeSize() return tuple(self.in_size[0]) def forward(self, inputs): super(PReLU, self).forward(inputs) input = inputs[0] self.values.input = np.array(input, copy=True) xindex = self.values.input < 0 self.values.input[xindex] = self.alpha*self.values.input[xindex] return self.values.input def derivatives(self, doutput): dx = np.array(doutput, copy=True) dx[self.values.input <= 0] = self.alpha return dx |
Algunos os preguntareis porque no aparece en ningún lado la Softmax y es que es una capa un pelín más compleja. En el próximo articulo presentaremos los conceptos de regresión y clasificación para una red MLP (incluida la softmax).
¡Y eso ha sido todo!