agosto 15, 2020

~ 13 MIN

Pytorch - Introducción

< Blog RSS

Open In Colab

Pytorch - Introducción

Hast ahora hemos implementado nuestros propios modelos de Machine Learning utilizando Python y Numpy. Este ejercicio nos ha servido para aprender mejor a utilizar estas herramientas de análisis de datos así como a conocer en gran detalle algunos de los elementos fundamentales de las redes neuronales: el perceptrón, el algoritmo de descenso por gradiente, el perceptrón multicapa, etc. Sin embargo, de ahora en adelante, utilizaremos frameworks desarrollados por terceros. En el post anterior hablamos en detalle de los motivos y presentamos algunos ejemplos. En este post, empezamos a aprender a trabajar con uno de los frameworks de redes neuronales más utilizados hoy en día: Pytorch.

⚡ Si trabajas en Google Colab ya tendrás Pytorch instalado. Si quieres trabajar en local, simplemente sigue las instrucciones en https://pytorch.org/. Te recomiendo la instalación con conda, sobre todo si quieres soporte para GPU.

import torch

¿ Qué es Pytorch ?

Pytorch es un framework de redes neuronales, un conjunto de librerías y herramientas que nos hacen la vida más fácil a la hora de diseñar, entrenar y poner en producción nuestros modelos de Deep Learning. Una forma sencilla de entender qué es Pytorch es la siguiente:

Pytorch = Numpy + Autograd + GPU

Vamos a ver qué significa cada uno de estos términos.

NumPy

Quizás la característica más relevante de Pytorch es su facilidad de uso. Esto es debido a que sigue una interfaz muy similar a la de NumPy, y como nosotros ya sabemos trabajar con esta librería no deberíamos tener muchos problemas para aprender a trabajar con Pytorch 😁.

🧠 Si no estás familiarizado con la librería NumPy te recomiendo que le eches un vistazo a nuestros posts en los que aprendemos a trabajar con esta librería.

De la misma manera que en NumPy el objeto principal es el ndarray, en Pytorch el objeto principal es el tensor. Podemos definir un tensor de manera similar a como definimos un array, incluso podemos inicializar tensores a partir de arrays.

# matriz de ceros, 5 filas y 3 columnas

x = torch.zeros(5, 3)
x
tensor([[0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.],
        [0., 0., 0.]])
# tensor con valores aleatorios

x = torch.randn(5, 3, 2)
x
tensor([[[ 0.6419,  0.4804],
         [-0.2469,  2.0063],
         [-0.3315,  0.2666]],

        [[-0.4203,  0.0810],
         [-0.3352,  1.0289],
         [ 2.1357, -1.0319]],

        [[-1.1740, -1.7788],
         [ 0.7060, -0.4316],
         [-2.6329, -0.0426]],

        [[-0.1593,  1.0271],
         [ 0.5173,  0.8870],
         [ 0.0660,  4.5830]],

        [[-0.3571, -1.0476],
         [ 2.0990, -0.5810],
         [ 0.2271, -1.5096]]])
# tensor a partir de lista 

x = torch.tensor([[1, 2, 3],[4, 5, 6]])
x
tensor([[1, 2, 3],
        [4, 5, 6]])
import numpy as np

# tensor a partir de array

a = np.array([[1, 2, 3],[4, 5, 6]])
x = torch.from_numpy(a)
x
tensor([[1, 2, 3],
        [4, 5, 6]], dtype=torch.int32)

Y como puedes esperar, prácticamente todos los conceptos que ya conocemos para trabajar con NumPy pueden aplicarse en Pytorch. Esto incluye operaciones aritméticas, indexado y troceado, iteración, vectorización y broadcasting.

# operaciones

x = torch.randn(3, 3)
y = torch.randn(3, 3)

x, y
(tensor([[ 0.5209,  0.4702,  0.5770],
         [ 1.2662,  1.6964,  0.5227],
         [-0.1698, -1.0931, -0.4474]]),
 tensor([[ 0.2721, -0.6498,  0.8164],
         [ 0.1165,  0.4336,  1.0783],
         [-0.2287,  1.2283,  0.2684]]))
x + y
tensor([[ 0.7930, -0.1796,  1.3934],
        [ 1.3827,  2.1300,  1.6010],
        [-0.3985,  0.1352, -0.1790]])
x - y
tensor([[ 0.2488,  1.1200, -0.2394],
        [ 1.1496,  1.2628, -0.5556],
        [ 0.0589, -2.3214, -0.7158]])
# indexado

# primera fila

x[0]
tensor([0.5209, 0.4702, 0.5770])
# primera fila, primera columna

x[0, 0]
tensor(0.5209)
# primera columna

x[0, :]
tensor([0.5209, 0.4702, 0.5770])
# troceado

x[:-1, 1:]
tensor([[0.4702, 0.5770],
        [1.6964, 0.5227]])

Una funcionalidad importante del objeto tensor que utilizaremos muy a menudo es cambiar su forma. Esto lo conseguimos con la función view.

x.shape
torch.Size([3, 3])
# añadimos una dimensión extra

x.view(1, 3, 3).shape
torch.Size([1, 3, 3])
# estiramos en una sola dimensión

x.view(9).shape
torch.Size([9])
# usamos -1 para asignar todos los valores restantes a una dimensión

x.view(-1).shape
torch.Size([9])

Podemos transformar un tensor en un array con la función numpy.

x.numpy()
array([[ 0.5208606 ,  0.4702225 ,  0.57700956],
       [ 1.2661644 ,  1.6964071 ,  0.5227089 ],
       [-0.1698234 , -1.0931063 , -0.4474047 ]], dtype=float32)

Como puedes ver, un tensor de Pytorch es muy similar a un array de NumPy. Aquí hemos visto alguna de la funcionalidad más útil, puedes aprender más aquí.

Autograd

Ya hemos visto que Pytorch es muy similar a NumPy, sin embargo su funcionalidad va más allá de una estructura de datos eficiente con la que podemos llevar a cabo operaciones (para eso ya nos basta con NumPy). La funcionalidad más importante que Pytorch añade es la conocidad como autograd, la cual nos proporciona la posibilidad de calcular derivadas de manera automática con respecto a cualquier tensor. Esto le da a Pytorch un gran potencial para diseñar redes neuronales complejas y entrenarlas utilizando algoritmos de gradientes sin tener que calcular todas estas derivadas manualmente (como hemos hecho en los posts anteriores). Para poder llevar a cabo estas operaciones, Pytorch va construyendo de manera dinámica un grafo computacional. Cada vez que aplicamos una operación sobre uno o varios tensores, éstos se añaden al grafo computacional junto a la operación en concreto. De esta manera, si queremos calcular la derivada de cualquier valor con respecto a cualquier tensor, simplemente tenemos que aplicar el algoritmo de backpropagation (que no es más que la regla de la cadena de la derivada) en el grafo. Vamos a ilustrarlo con un ejemplo.

x = torch.tensor(1., requires_grad=True)
y = torch.tensor(2., requires_grad=True)
p = x + y

z = torch.tensor(3., requires_grad=True)
g = p * z

En la celda anterior hemos definido tres tensores: x , y y z . En primer lugar, para poder calcular derivadas con respecto a estos tensores necesitamos ponder su propiedad requiers_grad a True. Ahora, calculamos el tensor intermedio p como p = x+ y y luego usamos este valor para calcular el resultado final g como g = p*z . Cada vez que aplicamos una operación sobre un tensor que tiene su propiedad requires_grad a True, Pytorch irá construyendo el grafo computacional. Para este ejemplo, el grafo tendría la siguiente forma

Si ahora queremos calcular las derivadas de g con respecto a x , y y z , es tan fácil como llamar a la función backward.

g.backward()

En este punto, Pytorch ha aplicado el algoritmo de backpropagation encima del grafo computacional, calculando todas las derivadas.

\frac{dg}{dz} = p
z.grad
tensor(3.)
\frac{dg}{dx} = \frac{dg}{dp} \frac{dp}{dx} = z
x.grad
tensor(3.)
\frac{dg}{dy} = \frac{dg}{dp} \frac{dp}{dy} = z
y.grad
tensor(3.)

Como puedes ver, el grafo computacional es una herramienta extraordinaria para diseñar redes neuronales de complejidad arbitraria. Con una simple función, gracias al algoritmo de backpropagation, podemos calcular todas las derivadas de manera sencilla (cada nodo que representa una operación solo necesita calcular su propia derivada de manera local) y optimizar el modelo con nuestro algoritmo de gradiente preferido.

💡 Sabiendo que el perceptrón leva a cabo la operación \hat{y} = f(\mathbf{w} \cdot \mathbf{x} + b) , ¿te ves capaz de dibujar su grafo computacional?

Añadiendo autograd encima de NumPy, Pytorch nos ofrece todo lo que necesitamos para diseñar y entrenar redes neuronales. Puedes aprender más sobre autograd aquí. Sin embargo, si queremos entrenar redes muy grandes o utilizar datasets muy grandes (o ambas), el proceso de entrenamiento será muy lento. Es aquí donde entra en juego el último elemento que hace de Pytorch lo que es.

GPU

Si has prestado atención durante nuestro viaje a través de las diferentes implementaciones que hemos llevado a cabo en los posts anteriores, te habrás dado cuenta que, en su mayoría, nuestros modelos llevan a cabo una operación simple: el producto de matrices. Esta operación puede ser muy lenta si estas matrices son muy grandes. Sin embargo, existe hardware especializado en acelerar precisamente este tipo de operaciones: las unidades de procesado gráfico, o GPUs.

Si eres gamer hay poco sobre este tipo de hardware que no sepas. Para el resto, este chip (que puedes entender como un mini-ordenador dentro de tu ordenador) fue diseñado con el objetivo de acelerar los cálculos necesarios para renderizar una escena tridimensional en la pantalla de tu ordenador. Estas escenas se representan mediante triángulos con una posición determinada en el mundo virtual que se desea representar, y en cada fotograma se tiene que calcular su posición relativa a una cámara virtual, el punto de vista de la cual es renderizado en tu pantalla. Si estas escenas tienen muchos triángulos, hacer estos cálculos en la CPU (la unidad de procesado central de tu ordenador) pueden llevar mucho tiempo, destruyendo la experiencia en tiempo real que los videojuegos requieren. Es por este motivo que utilizamos GPUs, hardware especializado en llevar a cabo estas operaciones de manera rápida permitiendo las experiencias fluidas a las que estamos acostrumbrados hoy en día. Da la casualidad que el tipo de operaciones necesarias para calcular la posición de estos triángulos es la misma que necesitamos para entrenar nuestras redes neuronales: el producto de (grandes) matrices. El uso de GPUs para acelerar el entrenamiento de modelos de Deep Learning ha supuesto una gran revolución en la última década, y es uno de los motivos principales de la explosión que estamos viviendo en el aumento de aplicaciones.

Si trabajas en Google Colab, puedes utilizar una GPU de manera gratuita simplemente cambiando el tipo de runtime. Si quieres utilizar una GPU de manera local, tendrás que comprar una e instalarla en tu PC.

Pytorch nos permite acelerar las operaciones entre tensores de manera muy sencilla. Simplemente tenemos que asegurarnos que nuestros tensores viven en una GPU, Pytorch se encargará del resto.

# comprobar que podemos usar GPU

torch.cuda.is_available()
True
x = torch.randn(10000,10000)
y = torch.randn(10000,10000)

%time z = x*y
Wall time: 37.9 ms
x = torch.randn(10000,10000).cuda()
y = torch.randn(10000,10000).cuda()

%time z = x*y
Wall time: 14.4 ms

Como puedes observar, llevar a cabo operaciones con grandes tensores en una GPU en vez de la CPU puede resultar en una considerable reducción del tiempo de cálculo. Todas las siguientes maneras son válidas para copiar un tensor en una GPU

device = torch.device("cuda")

x = torch.randn((10000,10000), device="cuda")
x = x.cuda()
x = x.to("cuda")
x = x.to(device)

Y para volver a copiar un tensor de vuelta en la CPU

device = torch.device("cpu")

x = x.cpu()
x = x.to("cpu")
x = x.to(device)

Reimplementando nuestro MLP

Para terminar, vamos a poner todos los conceptos que hemos visto juntos en la reimplementación de nuestro modelo de MLP con una sola capa oculta que ya conocemos de posts anteriores. Para ello, llevaremos a cabo la tarea de clasificación de imágenes con el dataset MNIST.

from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', version=1)
X, Y = mnist["data"], mnist["target"]

X.shape, Y.shape
((70000, 784), (70000,))
%matplotlib inline

import matplotlib as mpl
import matplotlib.pyplot as plt
import random 

r, c = 3, 5
fig = plt.figure(figsize=(2*c, 2*r))
for _r in range(r):
    for _c in range(c):
        plt.subplot(r, c, _r*c + _c + 1)
        ix = random.randint(0, len(X)-1)
        img = X[ix]
        plt.imshow(img.reshape(28,28), cmap='gray')
        plt.axis("off")
        plt.title(Y[ix])
plt.tight_layout()
plt.show()

png

# normalizamos los datos

X_train, X_test, y_train, y_test = X[:60000] / 255., X[60000:] / 255., Y[:60000].astype(np.int), Y[60000:].astype(np.int)
# función de pérdida y derivada

def softmax(x):
    return torch.exp(x) / torch.exp(x).sum(axis=-1,keepdims=True)

def cross_entropy(output, target):
    logits = output[torch.arange(len(output)), target]
    loss = - logits + torch.log(torch.sum(torch.exp(output), axis=-1))
    loss = loss.mean()
    return loss
D_in, H, D_out = 784, 100, 10

# pesos del MLP (copiamos en gpu)
w1 = torch.tensor(np.random.normal(loc=0.0, 
          scale = np.sqrt(2/(D_in+H)), 
          size = (D_in, H)), requires_grad=True, device="cuda", dtype=torch.float)
b1 = torch.zeros(H, requires_grad=True, device="cuda", dtype=torch.float)

w2 = torch.tensor(np.random.normal(loc=0.0, 
          scale = np.sqrt(2/(D_out+H)), 
          size = (H, D_out)), requires_grad=True, device="cuda", dtype=torch.float)
b2 = torch.zeros(D_out, requires_grad=True, device="cuda", dtype=torch.float)

# convertimos datos a tensores y copiamos en gpu
X_t = torch.from_numpy(X_train).float().cuda()
Y_t = torch.from_numpy(y_train).long().cuda()

epochs = 100
lr = 0.8
log_each = 10
l = []
for e in range(1, epochs+1): 
    
    # forward
    h = X_t.mm(w1) + b1
    h_relu = h.clamp(min=0) # relu
    y_pred = h_relu.mm(w2) + b2

    # loss
    loss = cross_entropy(y_pred, Y_t)
    l.append(loss.item())

    # Backprop (calculamos todos los gradientes automáticamente)
    loss.backward()

    with torch.no_grad():
        # update pesos
        w1 -= lr * w1.grad
        b1 -= lr * b1.grad
        w2 -= lr * w2.grad  
        b2 -= lr * b2.grad
        
        # ponemos a cero los gradientes para la siguiente iteración
        # (sino acumularíamos gradientes)
        w1.grad.zero_()
        w2.grad.zero_()
        b1.grad.zero_()
        b2.grad.zero_()
    
    if not e % log_each:
        print(f"Epoch {e}/{epochs} Loss {np.mean(l):.5f}")
Epoch 10/100 Loss 1.59729
Epoch 20/100 Loss 1.29243
Epoch 30/100 Loss 1.05548
Epoch 40/100 Loss 0.89808
Epoch 50/100 Loss 0.78841
Epoch 60/100 Loss 0.70760
Epoch 70/100 Loss 0.65183
Epoch 80/100 Loss 0.60465
Epoch 90/100 Loss 0.56557
Epoch 100/100 Loss 0.53316
def evaluate(x):
    h = x.mm(w1) + b1
    h_relu = h.clamp(min=0)
    y_pred = h_relu.mm(w2) + b2
    y_probas = softmax(y_pred)
    return torch.argmax(y_probas, axis=1)
from sklearn.metrics import accuracy_score

y_pred = evaluate(torch.from_numpy(X_test).float().cuda())
accuracy_score(y_test, y_pred.cpu().numpy())
0.9364
r, c = 3, 5
fig = plt.figure(figsize=(2*c, 2*r))
test_imgs, test_labs = [], []
for _r in range(r):
    for _c in range(c):
        plt.subplot(r, c, _r*c + _c + 1)
        ix = random.randint(0, len(X_test)-1)
        img = X_test[ix]
        y_pred = evaluate(torch.tensor([img]).float().cuda())[0]
        plt.imshow(img.reshape(28,28), cmap='gray')
        plt.axis("off")
        plt.title(f"{y_test[ix]}/{y_pred}", color="green" if y_test[ix] == y_pred else "red")
plt.tight_layout()
plt.show()

png

Como puedes observar, simplemente definiendo los tensores para los pesos y los datos y copiándolos a la GPU podemos definir el grafo computacional de manera dinámica aplicando operaciones sobre los tensores (multiplicamos por los pesos y sumamos el bias). Una vez tenemos la salida del MLP calculamos la función de pérdida y llamando a la función backward Pytorch se encarga de calcular todas las derivadas de manera automática. Una vez tenemos los gradientes con respecto a los pesos, podemos actualizarlos.

Resumen

En este post hemos visto una introducción a Pytorch, un framework de redes neuronales muy utilizado a día de hoy. Hemos visto que Pytorch es muy similar a NumPy y comparten gran parte de su sintaxis, lo cual es una ventaja si ya sabemos trabajar con NumPy. Además, añade autograd, la capacidad de construir de manera dinámica un grafo computacional de manera que en cualquier momento podemos calcular derivadas con respecto a cualquier tensor de manera automática. Por último, hemos visto como podemos ejecutar todas estas operaciones en una GPU para acelerar el proceso de entrenamiento de nuestros modelos de Deep Learning. Este es el núcleo de Pytorch, sin embargo esta librería nos ofrece más funcionalidad, de la cual hablaremos más adelante, que nos será muy útil para diseñar, entrenar y poner a trabajar nuestras redes neuronales.

< Blog RSS