Presentamos el concepto de convolución, kernel y su aplicación a las redes neuronales convolucionales con un ejemplo de clasificación de imágenes.
import matplotlib.pyplot as plt
from sklearn.datasets import load_sample_images
from sklearn.manifold import TSNE
from scipy.ndimage import rotate
import numpy as np
Definimos una función para poder visualizar imagenes:
def plot_images(images, num=2):
"""Visualización de imagenes"""
_, axes = plt.subplots(nrows=1, ncols=num, figsize=(10, 3))
for ax, image in zip(axes, images):
#ax.set_axis_off()
ax.imshow(image, cmap=plt.cm.gray_r, interpolation="nearest")
Las convoluciones se aplican sobre imágene para poder preservar características que sean invariantes ante traslaciones. Empezaremos con un ejemplo simple, una imagen con valores binarios -1 y 1 que forman una X.
#Definimos imágen de X
img_x = np.array([[-1,-1,-1,-1,-1,-1,-1,-1, -1],[-1, 1,-1,-1,-1,-1, -1, 1, -1],[-1,-1, 1,-1,-1, -1, 1, -1, -1],
[-1,-1,-1, 1, -1, 1,-1,-1, -1],[-1,-1, -1,-1, 1, -1,-1,-1, -1],[-1, -1,-1, 1,-1, 1, -1,-1, -1],
[-1,-1, 1,-1,-1,-1, 1,-1, -1],[-1, 1,-1,-1,-1,-1,-1, 1, -1], [-1, -1,-1,-1,-1,-1,-1, -1, -1]])
plt.imshow(img_x, cmap='Greys')
plt.show()
Una traslación desplaza una imagen en una dirección determinada por un vector $t = (t_1, t_2)$. En una imagen, lo que esto hace es desplazar cada pixel de ésta en esta dirección de tal forma que la imagen resultante se haya movido, trasladado, hacia la dirección indicada por el vector $t$.
Definimos una función de traslación que nos permitirá mover la imagen en una dirección indicada, alterando la forma en que la red neuronal percibe esta imagen.
def translate(image, t=(1,1)):
"""Función de translación de la imagen"""
tx,ty = t
N,M = image.shape
image_translated = -np.ones_like(image)
image_translated[max(tx,0):M+min(tx,0), max(ty,0):N+min(ty,0)]=image[-min(tx,0):M-max(tx,0),-min(ty,0):N-max(ty,0)]
return image_translated
#Aplicamos la traslación
tr_img = translate(img_x, t=(1, 1))
plot_images([img_x, tr_img])
plt.show()
Los filtros o kernels nos permitirán capturar ciertas invarianzas en las imágenes. Aquí definimos tres kernels que se relacionan con la estructura de la figura X. Veremos que al convolucionar con la imagen se preserva la información de estas estructuras.
#Información de la diagonal
diag1 = np.array([[1,-1,-1],[-1,1,-1],[-1,-1,1]])
#Información del centro de la imagen
cent = np.array([[1,-1,1],[-1,1,-1],[1,-1,1]])
#Inofrmación de la diagonal contraria
diag2 = np.array([[-1,-1,1],[-1,1,-1],[1,-1,-1]])
plot_images([diag1, cent, diag2], num=3)
plt.show()
Dada la imagen y un kernel podemos aplicar una convolución (más precisamente una correlación cruzada) entre ambas para obtener de la imagen la información que proporciona el kernel. Para "medir" la imagen en relación al kernel.
La función de convolución de las redes convolucionales se definirá como:
$$\big(W*x\big)_{i,j} = \sum_{m=0}^{k-1} \sum_{n=0}^{k'-1} W_{i,j} x_{i+m,j+n} + b_j$$Donde el kernel $W$ es una matriz de $k\times k'$ y la imagen $x$ tiene altura $H$ y anchura $W$. Además, podremos hablar del número de canales $C$, que en este caso es de 1, pero en general es de 3 o 4, pues se cuenta con RGB o RGB-$\alpha$.
def calculate_target_size(img_size, kernel_size):
"""
Función que calcula las dimensiones de
la imagen resultante de la convolución
"""
num_pixels = 0
for i in range(img_size):
#i + tamaño de kernel
added = i + kernel_size
#Debe ser menor al tamaño de la imagen
if added <= img_size:
#Incrementa en 1
num_pixels += 1
return num_pixels
def convolve(img, kernel):
"""Función de convolución (cross correlation)"""
#Obtieene el tamaño del target, la imagen que resultará de la convolución
tgt_size = calculate_target_size(img.shape[0], kernel.shape[0])
#Tamaño del kernel
k = kernel.shape[0]
#Inicializa la convolución con 0s
convolved_img = np.zeros(shape=(tgt_size, tgt_size))
for i in range(tgt_size):
for j in range(tgt_size):
#Vecinos de un pixel con respecto al tamaño del kernel
neigs = img[i:i+k, j:j+k]
# Aplica la convolución
convolved_img[i, j] = np.sum(neigs*kernel)
return convolved_img
El resultado de aplicar cada uno de los filtros sobre la imagen original es la siguiente. Debe notarse que es común aplicar una capa ReLU a la convolución, aunque puede aplicarse cualquier otra función de activación:
#Función relu
ReLU = lambda x: np.array([np.max([0,c]) for c in x.reshape(x.shape[0]*x.shape[1])]).reshape(x.shape[0],x.shape[1])
diag1_conv_x = ReLU( convolve(img_x, diag1) )
diag2_conv_x = ReLU( convolve(img_x, diag2) )
cent_conv_x = ReLU( convolve(img_x, cent) )
plot_images([diag1_conv_x, cent_conv_x, diag2_conv_x], num=3)
Si aplicamos las convoluciones con los kernels en la imagen trasladada obtendrémos como resultado imágenes en las que esta traslación se preserva; es decir, es equivariante.
diag1_conv_Tx = ReLU( convolve(tr_img, diag1) )
diag2_conv_Tx = ReLU( convolve(tr_img, diag2) )
cent_conv_Tx = ReLU( convolve(tr_img, cent) )
plot_images([diag1_conv_Tx, cent_conv_Tx, diag2_conv_Tx], num=3)
La función de Pooling toma el resultado de la convolución y regresa una representación $h$ de los datos que buscar ser invariante. Se suelen utilizar la función de averag pooling y max pooling. Aquí usamos max pooling que se define como:
$$h = \arg\max \big\{(W*x)_{i+c,j+c}\big\}$$Donde $c$ es el tamaño de la ventana del pooling. El pooling se puede visualzde la siguiente manera:
Es decir, se toman los valores máximos en una ventana cuadrada por cada sección de la imagen. La matriz resultante generalmente es aplanada (convertida en un vector) para que pase por una red Feedforward.
def max_pooling(img, size=(2,2)):
"""Max pooling"""
M, N = img.shape
K, L = size
MK = M // K
NL = N // L
return img[:MK*K, :NL*L].reshape(MK, K, NL, L).max(axis=(1, 3))
Aquí vemos el max poolin para los diferentes kernels en la imagen original y la imagen trasladada:
#Max pooling sobre imagen original
pool_x_d1 = max_pooling(diag1_conv_x)
pool_x_d2 = max_pooling(diag2_conv_x)
pool_x_c = max_pooling(cent_conv_x)
#Max pooling sobre imagen trasladada
pool_Tx_d1 = max_pooling(diag1_conv_Tx)
pool_Tx_d2 = max_pooling(diag2_conv_Tx)
pool_Tx_c = max_pooling(cent_conv_Tx)
plot_images([pool_x_d1, pool_x_c, pool_x_d2], num=3)
plot_images([pool_Tx_d1, pool_Tx_c, pool_Tx_d2], num=3)
Podemos tomar imágenes más complejas en donde el número de canales sea RGB. Ante esto deberemos también tomar kernels más complejos:
#Imágene muestra
images = load_sample_images()
plot_images(images.images)
Existen diferentes formar de tratar un kernel. Aquí definimos tres kernels, como se ve, en la mayoría de estos el pixel central está determinado en el centro (en donde es mayor el peso), los otros se ponderán con relación a este pixel en diferentes relaciones de vecinos.
#Filtro sharpen
sharpen = np.array([
[0, -1, 0],
[-1, 5, -1],
[0, -1, 0]
])
#Filtro Blur
blur = np.array([
[0.0625, 0.125, 0.0625],
[0.125, 0.25, 0.125],
[0.0625, 0.125, 0.0625]
])
#Filtro outline
outline = np.array([
[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]
])
#Filtro convolución
conv = np.array([
[1, 0, -1],
[2, 0, -2],
[1, 0, -1]
])
plot_images([sharpen, blur, outline, conv], num=4)
Podemos ver qué es lo que hace uno de estos kernels con respecto a la imagen:
#Convolución con kernel
img_conv = convolve(images.images[0], outline)
#Aplicando ReLU
x = ReLU(img_conv)
#Aplicando max pool
h = max_pooling(x, size=(2,2))
plot_images([img_conv, x, h], num=3)
Entendido lo que hacen los kernels y las convoluciones, podemos aplicarlos para aprender redes neuronales que puedan manejar diferentes transformaciones en las imágenes. Trabajaremos con el dataset MNIST para clasificar digitos.
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
from tqdm import tqdm
Cargamos el dataset y obtenemos el conjunto de entrenamiento y evaluación:
#Cargar dataset
x = load_digits().images
y = load_digits().target
#Núm de clases:
n = len(set(y))
#Separar datasets
x_train, x_test, y_train, y_test = train_test_split(x,y, test_size=0.3)
#Dimensiones de dataset
N, H, W = x_train.shape
Las redes Feedforward se ven limitadas al tratar con imágenes, pues no son sensibles a las traslaciones. Para comparar el desempeño de las redes convolucionales, podemos ver en primer lugar cómo maneja estos datos una red Feedforward, esperando que las redes convolucionales mejoren su desempeño.
#Definimos la red feedforward
ffw = nn.Sequential(nn.Linear(H*W, 128), nn.ReLU(), nn.Linear(128, 254), nn.Tanh(),
nn.Linear(254, n), nn.Softmax(1))
#De matriz de imagen a vector
x_simple = torch.Tensor(x_train.reshape(N, H*W))
#Entrenamiento
optimizer = torch.optim.Adam(ffw.parameters(), lr=0.1)
criterion = torch.nn.CrossEntropyLoss()
epochs = 1000
for t in tqdm(range(epochs)):
prob = ffw(x_simple)
loss = criterion(input=prob, target=torch.tensor(y_train))
optimizer.zero_grad()
loss.backward()
optimizer.step()
100%|██████████| 1000/1000 [00:03<00:00, 253.48it/s]
Como se ve, los resultados con la red Feedforwar son pobres:
y_pred = ffw(torch.Tensor(x_test.reshape(x_test.shape[0],H*W))).argmax(axis=1)
print(classification_report(y_test, y_pred))
precision recall f1-score support 0 0.00 0.00 0.00 49 1 0.00 0.00 0.00 54 2 0.00 0.00 0.00 55 3 0.00 0.00 0.00 51 4 0.00 0.00 0.00 60 5 0.00 0.00 0.00 55 6 0.00 0.00 0.00 51 7 0.00 0.00 0.00 55 8 0.00 0.00 0.00 45 9 0.12 1.00 0.21 65 accuracy 0.12 540 macro avg 0.01 0.10 0.02 540 weighted avg 0.01 0.12 0.03 540
/home/mijangos/.local/lib/python3.8/site-packages/sklearn/metrics/_classification.py:1221: UndefinedMetricWarning: Precision and F-score are ill-defined and being set to 0.0 in labels with no predicted samples. Use `zero_division` parameter to control this behavior. _warn_prf(average, modifier, msg_start, len(result))
La red convolucional contara con las siguientes capas:
class ConvNet(nn.Module):
def __init__(self, in_channels=1, out_channels=1, kernel=2, classes=2):
super().__init__()
#Capa convolucional
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=kernel, stride=1, padding=1)
#Max-pooling
self.maxpool = nn.MaxPool2d(kernel_size=2, stride=2)
#Feedforward de salida
self.hidden = nn.Sequential(nn.Linear(4*4*out_channels, 128), nn.Tanh())
self.ffw = nn.Sequential(nn.Linear(128, classes), nn.Softmax(1))
#Guarda las representaciones
self.convolved = None
self.h = None
def forward(self,x):
#Aplica convolución
self.convolved = self.conv(x)
#Aplica ReLU
h = self.convolved.relu()
#Max-pooling
h = self.maxpool(self.convolved)
#Reshape
h = h.view(h.size(0), -1)
#Capa oculta feedforward
self.h = self.hidden(h)
#Salida
out = self.ffw(self.h)
return out
Una vez hecho esto, definimos el modelo y preparamos los datos
#Reshapea los datos para que los tome la red
x_input = torch.Tensor(x_train.reshape(N,1,H,W))
#Modelo
model = ConvNet(classes=n)
#Resultados del modelo
conv_x = model(x_input)
conv_x.shape
torch.Size([1257, 10])
Entrenamos la red utilizando una función de entropía cruzada y un optimizador Adam.
optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
criterion = torch.nn.CrossEntropyLoss()
epochs = 1000
for t in tqdm(range(epochs)):
prob = model(x_input)
loss = criterion(input=prob, target=torch.tensor(y_train))
optimizer.zero_grad()
loss.backward()
optimizer.step()
100%|██████████| 1000/1000 [00:03<00:00, 292.97it/s]
Como se puede osbervar, los resultados con las redes convolucionales mejoran mucho el de la red Feedforward.
y_pred = model(torch.Tensor(x_test.reshape(x_test.shape[0],1,8,8))).argmax(axis=1)
print(classification_report(y_test, y_pred))
precision recall f1-score support 0 0.90 0.94 0.92 49 1 0.75 0.70 0.72 54 2 0.77 0.87 0.82 55 3 0.80 0.80 0.80 51 4 0.90 0.87 0.88 60 5 0.89 0.85 0.87 55 6 0.96 0.96 0.96 51 7 0.92 0.84 0.88 55 8 0.66 0.73 0.69 45 9 0.84 0.82 0.83 65 accuracy 0.84 540 macro avg 0.84 0.84 0.84 540 weighted avg 0.84 0.84 0.84 540
También podemos observar el tipo de resultados que arroja la red. En particular el tipo de convoluciones que hace sobre las imágenes:
conv_learn = model.conv.weight.detach().numpy()[0][0]
plot_images([convolve(x_test[0],conv_learn),convolve(x_test[1],conv_learn),convolve(x_test[2],conv_learn)], num=3)
También podemos ver qué tipo de representaciones aprende la red después de pasar por las convoluciones.
#Aplicación del modelo
model(x_input)
#Reducción de dimensionalidad
h = TSNE(3).fit_transform(model.h.detach().numpy())
#Ploteo
fig = plt.figure(figsize=(7,7))
ax = fig.add_subplot(projection='3d')
ax.scatter(h[:,0], h[:,1], h[:,2], c=y_train)
plt.title('Representaciones de la red convolucional')
plt.show()