TICS-579-Deep Learning

Clase 4: Introducción a Pytorch

Alfonso Tobar-Arancibia

¿Qué es Pytorch?

Pytorch

Es una librería de manipulación de Tensores especializada en Deep Learning. Provee principalmente, manipulación de tensores (igual que Numpy, pero en GPU), además de Autograd (calcula derivadas de manera automática).

Para poder comenzar a utilizarlo se requieren normalmente 3 imports:

import torch
import torch.nn as nn
import torch.nn.functional as F
  • torch es donde se encuentran la mayoría de funciones básicas para manipular tensores.
  • torch.nn es donde se encuentran los módulos necesarios para poder crear redes neuronales (neural networks). Cada módulo es una clase en Python.
  • torch.nn.functional es donde se encontrarán las versiones funcionales de elementos de torch.nn.

GPU

  • Su principal ventaja es que puede ejecutarse en GPU, lo cual entrega una ventaja comparativa enorme (Muchos más núcleos).
  • Las GPUs están programadas en CUDA, una variante de C++ que es muy complicado de entender. Por lo que los mensajes de error son sumamente crípticos. Se recomienda desarrollar en CPU, y cambiar a GPU sólo cuando sea necesario ejecutar libre de errores.
## Permite automáticamente reconocer si es que existe GPU en el sistema y de existir lo asigna.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  • El código de arriba es particularmente útil para Google Colab o plataformas que permitan activar o desactivar GPUs.
  • También es posible definirlo de manera manual en caso de querer debuggear algo en particular.

Mapeando lo aprendido con Pytorch

  • Supongamos el caso particular en el cual queremos resolver un problema de clasificación binaria. ¿Cuánto valdría \(k\) y cuál sería la Loss Function a utilizar?
  • Supongamos que queremos transformar una Matriz \(X\) de 1000 registros y 10 variables. Además tenemos un vector \(y\) el cuál queremos predecir.
  • Supongamos que queremos llevar a 32 variables, luego a 64 para luego generar nuestra predicción.
  • Supongamos además que queremos usar como función de activación la función ReLU en ambas capas de transformación.

¿Cómo definimos los 3 elementos principales de una red?

(Hipótesis, Loss Function y Optimizador)

nn.Module

En Pytorch, cada parte de una red es una clase.

  • Una clase tiene la ventaja de que es un objeto mutable que puede almacenar estados en su interior. En el caso particular de una red neuronal, ¿qué estado será importante que guarde?

Una vez que un módulo es instanciado, acepta tensores de entrada y devuelve tensores de salida.

nn.Linear()
Corresponde a la Red más básica de Pytorch y permite realizar Transformaciones Affine.
fc = nn.Linear(in_features, out_features, bias=True)
  • in_features es la dimensión inicial (\(n_i\)).
  • out_features la dimensión a la que se quiere llevar (\(n_{i+1}\)).

De manera análoga, nn.ReLU() será el módulo que representará una función de activación ReLU.

Pero, ¿Cómo combinamos distintos módulos para crear una sóla arquitectura que represente nuestra Hipótesis?

Hipótesis

Para poder crear una Hipótesis en Pytorch podemos combinar cada Módulo entra clase que herede desde nn.Module.

class MyNeuralNetwork(nn.Module):
    def __init__(self,):
        pass
    def forward(self,x):
        pass


  • La red neuronal siempre debe heredar nn.Module. Esto permitirá que transformar la clase en Módulos que pueden combinarse para crear Arquitecturas cada vez más complejas.
  • __init__() corresponde al constructor. Acá se deben definir todos los parámetros de entrada (similar a una función), con la que se instanciará la clase.
  • forward() corresponde a la definición del *forward pass de la red en cuestión.

Hipótesis: __init__()

class MyNeuralNetwork(nn.Module):
    def __init__(self,*):
        super().__init__()
        self.w1 = nn.Linear(10,32)
        self.w2 = nn.Linear(32,64)
        self.w3 = nn.Linear(64,1)
        self.relu_1= nn.ReLU()
        self.relu_2= nn.ReLU()


  • Siempre el primer elemento de una red neuronal la inicialización del nn.Module mediante el super().__init__().
  • Es importante notar que todos los elementos dentro de la clase deben tener el prefijo self. Esto permite que estos elementos puedan estar disponibles en cualquier método de la clase.
  • Es posible inicializar elementos mediante parámetros (representado por *) para que la red sea flexible y reutilizable. La convención es que todos los métodos tienen que tener como primer parámetro la palabra self y luego pueden tener otros parámetros.

Hipótesis: forward()

class MyNeuralNetwork(nn.Module):
    def __init__(self,*):
        super().__init__()
        self.w1 = nn.Linear(10,32)
        self.w2 = nn.Linear(32,64)
        self.w3 = nn.Linear(64,1)
        self.relu_1= nn.ReLU()
        self.relu_2= nn.ReLU()
    def forward(self,x):
        x = self.w1(x)
        x = self.relu_1(x)
        x = self.w2(x)
        x = self.relu_2(x)
        x = self.w3(x)
        return x
  • La método forward representa el *forward pass de la red e indica cómo están conectadas las distintas etapas de la red.
  • En este caso \(x\) representa una instancia/registro que va pasando por la red.

Loss Function y Optimizer

Loss Function

La nomenclatura utilizada en Pytorch para referirse a la definición de la función de Pérdida es el criterion. Es decir, el criterio con el que se mide la pérdida. Más Loss Functions se pueden encontrar acá.

Optimizador

La nomenclatura utilizada en Pytorch para referirse al optimizador a utilizar es optimizer. Éste se importa desde torch.optim y debe recibir como argumentos model.parameters() y al menos el learning_rate. Todos los optimizers pueden encontrarse acá.

model = MyNeuralNetwork()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr = 3e-4)

Training Loop

Definiremos como Training Loop al proceso en el cual entrenaremos el modelo.

for e in range(EPOCHS):
    ## Fijar el modelo en Modo Entrenamiento
    model.train()

    ## Fijar Gradientes en 0
    optimizer.zero_grad()

    ## Forward Pass
    preds = model(X)

    ## Cálculo del Loss (Ojo, primero va la predicción y luego el target). Ver Docs.
    loss = criterion(preds, y)

    ## Cálculo de Gradientes
    loss.backward()

    ## Update Rule
    optimizer.step()
  • .zero_grad() fijan los gradientes a cero, ya que Pytorch acumula gradientes siempre. Es importante que en cada epoch todos los gradientes acumulados vuelvan a cero para una siguiente optimización.
  • En el caso de querer dejar en zero los gradientes de un tensor, y no del optimizador, se puede usar .zero_().

Inferencia

Para generar predicciones basta con generar un Forward Pass con el modelo ya entrenado. Dependiendo del modelo, es posible que sea necesario aplicar un post-procesamiento.


## Fijar el Modelo en Evaluación.
model.eval()

## Evita que Pytorch calcule Gradientes ya que no es necesario.
with torch.no_grad():
    ## Cálculo de la salida del modelo (h)
    h = model(X)

## Cálculo de Probabilidades (si es que fuera necesario)
y_proba = torch.sigmoid(h)

## Clasificación propiamente tal
y_preds = torch.where(y_proba>=0.5, 1,0)

Mini-Batching

  • Rara vez los datos vienen en formato de Tensor de Pytorch. Por lo tanto, el dataset (tablas, imágenes, videos, texto, audio, etc) debe ser llevado a formato Tensor, lo cual puede ser un proceso bastante costoso y que consume muchos recursos.
  • Además, la cantidad de datos necesaria para poder entrenar un modelo de Deep Learning normalmente es alta. Lo cual limita el cierto Hardware al no contar con la capacidad necesaria.
Mini-Batching
Se refiere a aplicar un proceso de Optimización Estocástica, con sólo una muestra de los datos. Se basa en que el gradiente de la suma de las muestras es equivalente al gradiente total.

Para ello Pytorch introduce los conceptos de Dataset y DataLoader para implementar conversión y carga de datos on-the-fly.


from torch.utils.data import Dataset, DataLoader

Mini-Batching: Dataset

Pytorch necesita crear una clase que herede de Dataset y que permita tomar elementos uno a uno y transformarlos en Tensores. Este clase debe tener al menos 3 métodos: __init__, __len__ y __getitem__.

Supongamos que nuestros datos iniciales estaban en Numpy.

class MyDataSet(Dataset):
    def __init__(self, X,y):
        self.X = X
        self.y = y
    def __len__(self):
        return len(self.X)
    def __getitem__(self,idx):
        features = torch.from_numpy(self.X[idx])
        target = torch.from_numpy(self.y[idx])
        return features, target

Model Registry

Cada vez que nosotros llamamos un objeto modelo (que herede de nn.Module) este modelo mostrará el model registry. El registry permitirá ver todos los elementos que son parte del modelo. Para que un elemento sea parte del registro, debe haber sido definido como self.----.

  • Si es que se define un elemento como self.--- debe definirse como un nn.Module y no como un F.---
  • Además se puede acceder a cualquier elemento/atributo mediante el comando model.atributo.

Model Registry

  • Es posible acceder a los datos de Parámetros y Bias de una capa linear utilizando:
model.w1.weights.data
model.w1.bias.data

Idea:

  • Podría utilizarse esto para poder definir valores iniciales de capas de parámetros y de bias.
class MyNeuralNetwork(nn.Module):
    def __init__(self, *):
        self.w1 = ...
        self.relu = ...
        self.model.w1.weights.data = tensor([...])
        self.model.w1.bias.data = tensor([...])
    
    def forward(self,x):
        ...

Mini-Batching: Dataloader

El Dataloader permitirá ir cargando los datos en memoria en un cierto batch_size. La idea es no generar cuellos de botella por falta de memoria disponible.

data = MyDataset(X,y)
train_loader = DataLoader(data, batch_size=32, pin_memory=True,num_workers=12, shuffle=True)

Esto implica que nuestro Training Loop deberá sufrir ciertas modificaciones para ir actualizandose por Batch y no sólo por Epoch.

for e in range(EPOCHS):
    train_loss = []

    model.train()
    for batch in train_loader:
        X, y = batch
        optimizer.zero_grad()
        preds = model(X)
        loss = criterion(preds, y)
        loss.backward()
        optimizer.step()
        train_loss.append(loss.item())
    print(f"Loss para Epoch {e}: {np.mean(train_loss)}")

Continuará