¿Que són las Convolutional Neural Network?
Como visteis en anteriores tutoriales las redes MLP tienen muchísimos parámetros (demasiados) por lo que aun ser una red neuronal muy potente, aprender todos esos parámetros supone un coste muy elevado y muchas veces es imposible ponerlo en la practica.
Las redes neuronales convolucionales surgen de esa necesidad, de aportar una alternativa viable a problemas de clasificación o regresión, eliminando esa inmensidad de parámetros pero aplicando una añadiendo una restricción: Buscaremos un patrón que se encuentra en un dominio temporal o espacial de la señal de entrada. ¿Que quiere decir eso? Que estamos buscando un patrón en la entrada que puede estar en diferente/s posiciones de la señal de entrada. Eso nos va a permitir usar una y otra vez los mismos parámetros pero buscando en distintos sitios de la señal (a eso se le conoce como correlación).
Existen distintas capas en las redes CNN, aunque en este caso nos centraremos en las convoluciones.
¿Que es una correlación?
Una correlación es una medida de similaridad que compara una señal A con otra B en distintos puntos de la señal A. La comparación entre ambas se hace con lo que se conoce como producto de señales, donde el valor más alto de la correlación será el punto donde más similares sean (este proceso es el mismo que se usa en la transformada de fourier, solo que él prueba infinitos armónicos).
corr(A, B)(x) = \int_{k =-\infty}^{\infty} A(w + x)\cdot B(k)
Supongamos las siguientes funciones A y B:
- Se desplaza B por A y se compara.
- La comparación es la suma del producto de señales: cmp(A, B) = \sum_k A(k) \cdot B_{desplazada}(k)
- El valor máximo se dará donde las señales sean mas similares, ideal para buscar patrones.
Si estáis familiarizados con las redes CNN sabréis que actualmente se usan muchísimo para problemas que involucran imágenes.
Una imagen no es más que una señal 2D. Los problemas habituales de las imágenes es buscar algún patron/objeto en ellas, estos pueden estar en diferentes tamaños y posiciones de modo que las correlaciones son el elemento perfecto para este trabajo. En este tipo de redes, estas capas se conocen como capas de convolución, en aspectos técnicos el nombre no es del todo correcto ya que se deberían llamar capas de correlación. Pero se debe a que la correlación y la convolución tienen una relación y se puede aplicar una convolución usando una correlación o viceversa.
¿Como funciona cuando tenemos 2 dimensiones? Pues realmente funciona exactamente igual (más adelante lo veremos exactamente), se tiene una señal A que es nuestra imagen de entrada y otra señal B que es el filtro o patrón que estamos buscando (color rojo).
Nuestra red será la encargada de buscar aquellos patrones (señales B) que nos permitirán identificar ciertos patrones en la imagen.
Vale muy bonito pero… ¿Y ya esta?
No, la gracia no es solo buscar un filtro, sino aplicar el concepto anterior a lo GRANDE.
En vez de aplicar un solo filtro a la imagen aplicaremos varios a la vez. Y después volveremos aplicar otros, luego otros y otros, … Aquí es donde reside la potencia de estas redes en la concatenación de capas que detectarán ciertos patrones. Fijaros en la siguiente red CNN (FC son capas como fully-connected las que usábamos en MLP):
Podemos observar como hay distintas capas de convolución, cada nivel de este conjunto de capas será capaz de detectar ciertos patrones.
- Las primeras detectarán patrones muy simples (esquinas, lineas, cambios de contraste, etc).
- Las intermedias detectarán combinaciones de las anteriores siendo capaces de detectar (cuadrados, esferas, triángulos, formas geométricas).
- Las mas profundas serán capaces de detectar los patrones mas complejos (caras, objetos, gatos, más gatos, gatos negros, gatos blancos, gatos lilas)…
Es decir, cada capa de nuestra red convolucional supone un nivel de atracción mas alto. En el siguiente vídeo se puede ver lo que ve exactamente cada capa de nuestra red (muy interesante):
Profundicemos en la «capa de convolucion»
Ahora que ya tenemos algunos conocimientos sobre que es una convolución vamos a entrar más en detalle. Las capas de convolución en las redes CNN para imagenes se desplazan en 2 dimensiones pero trabajan en 3 dimensiones.
o_{x,y} = \sum_{m=-\frac{wk}{2}}^{\frac{wk}{2}} \sum_{n=\frac{-hk}{2}}^{\frac{hk}{2}} \sum_p i_{x+m, y+n, p} \cdot k_{p,m,n} donde i es la entrada y k es el filtro que tiene el 0,0 en el centro del mismo.
Veamos un ejemplo con un solo filtro:
- Tengo una imagen: 10x10x3.
- Aplico un filtro: 3×3
- Obtengo una salida: 8x8x1
Aplicar un solo filtro no es muy potente y probablemente querramos aplicar muchos más a la vez. La capa de convolución se define como una lista de filtros todos del mismo tamaño y ancho, de forma que todos se aplican a la vez.
o_{x,y,d} = \sum_{m=-\frac{wk}{2}}^{\frac{wk}{2}} \sum_{n=\frac{-hk}{2}}^{\frac{hk}{2}} \sum_p i_{x+m, y+n, p} \cdot k_{p,m,n,d}
Si volvemos al anterior ejemplo:
- Tengo una imagen: 10x10x3.
- Aplico una capa de convolucion de 96 filtros de 3×3
- Obtengo una salida: 8x8x96.
Fijaros que aplicar un filtro siempre nos supondrá perder dimensiones en el alto y ancho de la entrada. Eso se debe a que sino el filtro sobresaldría fuera de la imagen (nos iríamos a indices inferiores a 0 o superiores a los de la imagen).
Más detalles sobre los filtros…
En los filtros existen tres parámetros más que aún no hemos comentado:
- Padding.
- Stride.
- Bias.
El padding se encarga de controlar los limites entre la entrada y el filtro, existen dos configuraciones posibles:
- Valido (valid): Solo se calculan los pixeles en los que el filtro y la entrada encajan.
- Igual (same): Se calculan mientras el pixel central del filtro este dentro de la entrada, esto da como resultado una salida igual que la entrada. Los pixeles que no existen se rellenan con 0’s.
Es stride es muy sencillo, va a controlar el incremento en el desplazamiento. Por lo general es 1, pero si un stride por ejemplo es: 2, querrá decir que un pixel se evalua, otro no.
El stride es útil cuando queremos hacer una disminución de la dimensionalidad a lo bruto, suponemos que perder un punto de cada dos no nos supondrá perder información vital.
El bias se añade por cada filtro, dejando la ecuación de la correlación de la siguiente forma:
o_{x,y,d} = \sum_{m=-\frac{wk}{2}}^{\frac{wk}{2}} \sum_{n=\frac{-hk}{2}}^{\frac{hk}{2}} \sum_p i_{x+m, y+n, p} \cdot k_{p,m,n,d} + b_d
Con tantos parámetros… ¿Como calculamos el tamaño de la salida?
Si el padding es valido, solamente debemos aplicar la siguiente ecuación (sx y sy son el stride horizontal y vertical):
wo = \frac{wi - wk}{sx + 1}
ho = \frac{hi - hk}{sy + 1}
Si el padding es same, querrá decir que la debemos forzar que la salida sea igual que la entrada por lo tanto ambas serán iguales.
Programando el forward
Dicho todo esto… ¿Como queda resumido todos estos conceptos en el código?
Primero de todo se añaden el padding necesario si el método es «same».
Después para simplificar el algoritmo, es más fácil recorrer la imagen de salida e ir calculando el producto y la acumulación entre la entrada y los filtros.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def convolution2d(input, stride, kernel, bias, padding='valid'): # bias (NUM_FILTERS) # Kernel (DEEP INPUT, KW, KH, NUM_FILTERS) # Stride (INCX, INCY) if padding = 'valid': pad = (0, 0) elif padding='same': pad = (kernel.shape[0] // 2, kernel.shape[1] // 2) input_pad = np.pad(input, [(0, 0), (pad[0], pad[0]), (pad[1], pad[1]), (0, 0)], mode='constant') out_size = [(input.shape[0] - kernel.shape[1]) / stride[0] + 1, (input.shape[1] - kernel.shape[2]) / stride[1] + 1] out = np.zeros(shape=(out_size[0], out_size[1], kernel.shape[3])) for i in range(out_size[0]): for j in range(out_size[1]): iin = i*stride[0] jin = j*stride[1] for n in range(kernel.shape[3]): for kw in range(kernel.shape[1]): for kh in range(kernel.shape[2]): for m in range(kernel.shape[0]): out[i, j, n] += input[iin + kw, jin + kh, m] * kernel[m, kw, kh, n] + bias[n] |
Programando el backward:
Como siempre las cosas se complican un poco en el backward. En la convolución tendremos que derivar respecto los pesos internos: kernel y bias; y respecto la entrada: input. Los gráficos que se mostrarán a continuación son para un solo filtro y un ejemplo en concreto, perdonar que no sean de mejor calidad.
Primero recordemos como se aplica el filtro:
Derivada respecto los pesos (k):
Derivar respecto K, supone de derivar cada salida o respecto cada filtro k. Realmente estamos computando la matriz jacobiana de la función o_{x, y} con respecto k, en formato matricial el resultado seria el siguiente:
\frac{\partial L}{\partial k} = \frac{\partial L}{\partial o} J_{o_k}
Veamos el ejemplo anterior en detalle:
Como podéis ver \frac{\partial o_{x,y}}{\partial k_{m,n}} tiene múltiples valores, acordaros que la derivada es la suma de todas estas contribuciones.
Si desarrollamos las ecuaciones obtenemos:
\frac{\partial L}{\partial k_{0,0}} =\frac{\partial L}{\partial o_{0,0}} i_{0, 0} + \frac{\partial L}{\partial o_{0,1}} i_{0, 1} + \frac{\partial L}{\partial o_{1,0}} i_{1, 0} + \frac{\partial L}{\partial o_{1,1}} i_{1, 1}
\frac{\partial L}{\partial k_{0,1}} =\frac{\partial L}{\partial o_{0,0}} i_{0, 1} + \frac{\partial L}{\partial o_{0,1}} i_{0, 2} + \frac{\partial L}{\partial o_{1,0}} i_{1, 1} + \frac{\partial L}{\partial o_{1,1}} i_{1, 2}
\frac{\partial L}{\partial k_{1,0}} =\frac{\partial L}{\partial o_{0,0}} i_{1, 0} + \frac{\partial L}{\partial o_{0,1}} i_{1, 1} + \frac{\partial L}{\partial o_{1,0}} i_{2, 0} + \frac{\partial L}{\partial o_{1,1}} i_{2, 1}
\frac{\partial L}{\partial k_{1,1}} =\frac{\partial L}{\partial o_{0,0}} i_{1, 1} + \frac{\partial L}{\partial o_{0,1}} i_{1, 2} + \frac{\partial L}{\partial o_{1,0}} i_{2, 1} + \frac{\partial L}{\partial o_{1,1}} i_{2, 2}
Cuesta un poco ver el patrón, pero simplemente deberemos recorrer como en el forward e ir acumulando los productos entre \frac{\partial L}{\partial o{m,n}} y su entrada i_{x, y} asociada.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def convolution2d_derivative_kernel(input, dout, kernel, stride, padding='valid'): # bias (NUM_FILTERS) # Kernel (DEEP INPUT, KW, KH, NUM_FILTERS) # Stride (INCX, INCY) if padding = 'valid': pad = (0, 0) elif padding='same': pad = (kernel.shape[0] // 2, kernel.shape[1] // 2) input_pad = np.pad(input, [(0, 0), (pad[0], pad[0]), (pad[1], pad[1]), (0, 0)], mode='constant') out_size = dout.shape[:2] dw = np.zeros(shape=kernel.shape) for i in range(out_size[0]): for j in range(out_size[1]): iin = i*stride[0] jin = j*stride[1] for n in range(kernel.shape[3]): for kw in range(kernel.shape[1]): for kh in range(kernel.shape[2]): for m in range(kernel.shape[0]): dw[m, kw, kh, n] += input[iin_kw, jin_kh, m]*dout[i, j, n] |
Derivada respecto la entrada (i):
Este caso, es similar al anterior pero esta vez deberemos derivar respecto la entrada i, la derivada de la salida respecto la entrada corresponderán a la posición del filtro en el que ha participado la entrada i.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def convolution2d_derivative_input(input, dout, kernel, stride, padding='valid'): # bias (NUM_FILTERS) # Kernel (DEEP INPUT, KW, KH, NUM_FILTERS) # Stride (INCX, INCY) if padding = 'valid': pad = (0, 0) elif padding='same': pad = (kernel.shape[0] // 2, kernel.shape[1] // 2) input_pad = np.pad(input, [(0, 0), (pad[0], pad[0]), (pad[1], pad[1]), (0, 0)], mode='constant') out_size = out.shape[:2] dx = np.zeros(shape=input.shape) for i in range(out_size[0]): for j in range(out_size[1]): iin = i*stride[0] jin = j*stride[1] for n in range(kernel.shape[3]): for kw in range(kernel.shape[1]): for kh in range(kernel.shape[2]): for m in range(kernel.shape[0]): dx[iin_kw, jin_kh, m] += kernel[m, kw, kh, n]*dout[i, j, n] |
Derivada respecto el bías (b):
En el caso del bias, más simple no podía ser. El bias es un único valor que se suma a la convolución.
\frac{\partial o_{x,y,d}}{\partial b_k} = \cancel{ \sum_{m=-\frac{wk}{2}}^{\frac{wk}{2}} \sum_{n=\frac{-hk}{2}}^{\frac{hk}{2}} \sum_p i_{x+m, y+n, p} \cdot k_{p,m,n,d} } + 1= 1
\frac{\partial L}{\partial b_k} = \sum_x \sum_y \frac{\partial L}{\partial o_{x,y,d}}\frac{\partial o_{x,y,d}}{\partial b_k} = \sum_x \sum_y \frac{\partial L}{\partial o_{x,y,d}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
def convolution2d_derivative_bias(input, dout, bias, stride, padding='valid'): # bias (NUM_FILTERS) # Kernel (DEEP INPUT, KW, KH, NUM_FILTERS) # Stride (INCX, INCY) if padding = 'valid': pad = (0, 0) elif padding='same': pad = (kernel.shape[0] // 2, kernel.shape[1] // 2) input_pad = np.pad(input, [(0, 0), (pad[0], pad[0]), (pad[1], pad[1]), (0, 0)], mode='constant') out_size = out.shape[:2] db = np.zeros(shape=bias.shape) for i in range(out_size[0]): for j in range(out_size[1]): iin = i*stride[0] jin = j*stride[1] for n in range(kernel.shape[3]): db[n] += dout[i, j, n] |
Juntando las piezas
El resultado final se ha separado en dos codigos distintos ya que python no es el lenguaje que mejor procesa los bucles. Lo separaremos en Python que hará de wrapper y Cython que hará los calculos.
Codigo Python:
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 |
from .layer import Layer from ..backend.initializer import Initializer import numpy as np from .cython import conv2d import threading class Conv2D(Layer): def __init__(self, node, num_filters, kernel_size=(3,3), stride=(1, 1), padding='valid', initializer={'weights': Initializer("lecun"), 'bias': Initializer("normal")}, params=None): if params is None: params = {} self.initializer = initializer self.num_filters = num_filters self.kernel_size = tuple(kernel_size) if isinstance(stride, (list, tuple)): self.stride = tuple(stride) else: self.stride = (stride, stride) self.padding = padding if self.padding == 'valid': self.padding_size = (0, 0) elif self.padding == 'same': self.padding_size = (self.kernel_size[0] // 2, self.kernel_size[1] // 2) if 'weights_names' not in params: params['weights_names'] = ('kernels', 'bias') super(Conv2D, self).__init__(node, func_repr_weights=lambda x: \ np.transpose(x, [0, 3, 1, 2]), params=params) def computeSize(self): super(Conv2D, self).computeSize() return (self.in_size[0][0] - self.kernel_size[0] + 2*self.padding_size[0])//self.stride[0] + 1, \ (self.in_size[0][1] - self.kernel_size[1] + 2*self.padding_size[1])//self.stride[1] + 1, \ self.num_filters def compile(self): super(Conv2D, self).compile() if len(self.in_size[0]) <= 2: self.num_dim = 1 else: self.num_dim = self.in_size[0][2] self.weights.kernels = self.initializer['weights'].get(shape=(self.num_dim, self.kernel_size[0], self.kernel_size[1], self.num_filters)) / (self.kernel_size[0]*self.kernel_size[1]*self.num_filters) self.weights.bias = self.initializer['bias'].get(shape=[self.num_filters]) def forward(self, inputs): super(Conv2D, self).forward(inputs) self.values.input = np.pad(inputs[0], [(0, 0), (self.padding_size[0], self.padding_size[0]), (self.padding_size[1], self.padding_size[1]), (0, 0)], mode='constant') out = conv2d.nb_forward(self.values.input, self.weights.kernels, self.weights.bias, self.stride) return out def derivatives(self, doutput): dx, dw, db = conv2d.nb_derivatives(doutput, self.values.input, self.weights.kernels, self.stride) # devolvemos resultados return dx[:, self.padding_size[0]:(self.in_size[0][0] - self.padding_size[0]), self.padding_size[1]:(self.in_size[0][1] - self.padding_size[1]), :], (dw, db) def save(self, h5_container): layer_json = super(Conv2D, self).save(h5_container) layer_json['attributes']['num_filters'] = self.num_filters layer_json['attributes']['kernel_size'] = self.kernel_size layer_json['attributes']['stride'] = self.stride layer_json['attributes']['padding'] = self.padding layer_json['attributes']['padding_size'] = self.padding_size return layer_json def load(self, data, h5_container): super(Conv2D, self).load(data, h5_container) self.num_filters = data['attributes']['num_filters'] self.kernel_size = tuple(data['attributes']['kernel_size']) self.stride = tuple(data['attributes']['stride']) self.padding = data['attributes']['padding'] self.padding_size = tuple(data['attributes']['padding_size']) |
Codigo Cython:
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 |
import cython cimport cython import numpy as np cimport numpy as np from types cimport FLOAT64 from cython.parallel import prange,parallel cimport openmp from libc.stdlib cimport abort, malloc, free # forward @cython.wraparound(False) @cython.boundscheck(False) def nb_forward(np.ndarray[FLOAT64, ndim=4] inputv, np.ndarray[FLOAT64, ndim=4] kernels, np.ndarray[FLOAT64, ndim=1] bias, tuple stride): cdef unsigned int kernel_size0 = kernels.shape[1], kernel_size1 = kernels.shape[2], \ stride0 = stride[0], stride1 = stride[1], \ num_dim = kernels.shape[0], \ num_filters = kernels.shape[3], \ batch_size = inputv.shape[0] cdef unsigned int out_size0 = (inputv.shape[1] - kernel_size0) / stride0 + 1, \ out_size1 = (inputv.shape[2] - kernel_size1) / stride1 + 1 cdef np.ndarray[FLOAT64, ndim=4] out = np.empty(shape=[batch_size, out_size0, out_size1, num_filters], dtype=np.float64) #np.ndarray[FLOAT64, ndim=4] cdef unsigned int b, i, j, m, kw, kh, n cdef unsigned int iin, jin cdef double acc for b in prange(batch_size, nogil=True): for i in range(out_size0): for j in range(out_size1): iin = i*stride0 jin = j*stride1 for n in range(num_filters): out[b, i, j, n] = bias[n] for kw in range(kernel_size0): for kh in range(kernel_size1): for m in range(num_dim): out[b, i, j, n] += inputv[b, iin + kw, jin + kh, m] * kernels[m, kw, kh, n] return out # derivatives @cython.wraparound(False) @cython.boundscheck(False) def nb_derivatives(np.ndarray[FLOAT64, ndim=4] doutput, np.ndarray[FLOAT64, ndim=4] inputv, np.ndarray[FLOAT64, ndim=4] kernels, tuple stride): cdef unsigned int out_size0 = doutput.shape[1], out_size1 = doutput.shape[2], \ kernel_size0 = kernels.shape[1], kernel_size1 = kernels.shape[2], \ stride0 = stride[0], stride1 = stride[1], \ num_dim = kernels.shape[0], \ num_filters = kernels.shape[3], \ batch_size = inputv.shape[0] cdef np.ndarray[FLOAT64, ndim=4] dx = np.zeros(shape=[batch_size, inputv.shape[1], inputv.shape[2], num_dim]) cdef np.ndarray[FLOAT64, ndim=4] dw = np.zeros(shape=[num_dim, kernel_size0, kernel_size1, num_filters]) cdef np.ndarray[FLOAT64, ndim=1] db = np.zeros(shape=[num_filters]) cdef unsigned int b, i, j, m, kw, kh, n cdef unsigned int iin, jin cdef unsigned int iin_kw, jin_kh cdef double doutput_ptr for b in prange(batch_size, nogil=True): for i in range(out_size0): for j in range(out_size1): iin = i*stride0 jin = j*stride1 for n in range(num_filters): doutput_ptr = doutput[b, i, j, n] #pragma omp critical db[n] += doutput_ptr for kw in range(kernel_size0): for kh in range(kernel_size1): iin_kw = (iin + kw) jin_kh = (jin + kh) for m in range(num_dim): #pragma omp critical dw[m, kw, kh, n] += inputv[b, iin_kw, jin_kh, m]*doutput_ptr dx[b, iin_kw, jin_kh, m] += kernels[m, kw, kh, n]*doutput_ptr return dx, dw, db |
En este articulo hemos visto la punta del iceberg de las redes convolucionales. En los siguientes artículos veremos las otras capas que también nos son útiles en las redes CNN.
Referencias:
- https://en.wikipedia.org/wiki/Convolution
- https://becominghuman.ai/back-propagation-in-convolutional-neural-networks-intuition-and-code-714ef1c38199