Saltar a contenido

Redes neuronales con PyTorch

En documentos anteriores hemos estado viendo distintos tipos de redes neuronales que se pueden construir con la librería Keras, a través del backend TensorFlow de Google. Como hemos podido comprobar, una de las principales características de Keras es su interfaz de alto nivel que permite construir redes neuronales con muy pocas líneas de código, sin entrar en detalles técnicos internos de las mismas.

En este documento vamos a dar unas pinceladas de otra de las librerías relevantes en el desarrollo de redes neuronales, que es PyTorch, creada en 2016 y gestionada por la empresa Meta (Facebook). A diferencia de Keras, PyTorch está más orientada al control de la eficiencia en el proceso de entrenamiento, ofreciendo una API a bajo nivel que permite detallar por separado las distintas etapas del proceso.

1. Primeros pasos con PyTorch

Veremos a continuación cómo instalar y hacer las primeras pruebas de PyTorch en nuestro sistema. De forma adicional, PyTorch también se incluye por defecto en el entorno Google Colab, por lo que puedes utilizarlo sin problemas también en la nube de Google.

1.1. Instalación

Lo primero que tendremos que hacer para trabajar con PyTorch es instalarlo. Es conveniente instalar, además, algunas librerías adicionales orientadas a tareas habituales, como el procesamiento de imágenes, texto o audio. En concreto la instrucción de instalación habitual es la siguiente:

pip install torch torchvision torchaudio torchtext

Nota

Recuerda instalar las librerías en un entorno de ejecución adecuado, que tenga también disponibles otras dependencias que puedas necesitar para ciertas tareas puntuales, como pandas o scikit-learn. Aquí se explica cómo crear el entorno de ejecución adecuado, sobre el que instalar estas otras librerías.

1.2. Nuestra primera red neuronal con PyTorch

Para establecer similitudes y diferencias con la librería Keras que ya conocemos, vamos a partir de un mismo ejemplo que utilizamos cuando dimos nuestros primeros pasos con Keras, y veremos cómo se define dicho modelo con PyTorch.

En concreto, hablamos de este ejemplo en el que intentamos predecir la presión arterial de una persona en base a su edad. No es un modelo que alcance buenos resultados, ya que conecta dos variables en principio poco correlacionadas, pero nos servirá como punto de partida para comprender el funcionamiento de PyTorch.

1.2.1. Librerías necesarias

Utilizamos entonces el mismo dataset, y reemplazamos para empezar la carga de librerías de Keras por las de PyTorch:

import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.model_selection import train_test_split
  • El módulo nn nos permitirá acceder a la clase Module, clase padre a partir de la cual se definen las redes neuronales en PyTorch.
  • El módulo optim nos permite obtener los optimizadores de PyTorch, similares a los de Keras.

1.2.2. Pre-procesamiento de datos

En cuanto al pre-procesamiento de datos, esta etapa es ajena a Keras o PyTorch, y no cambia con respecto a lo que hicimos en el ejemplo original:

datos = pd.read_csv('datos_salud.csv')

X_train, X_test, y_train, y_test = train_test_split(
    datos['age'].values.reshape(-1, 1),
    datos['ap_hi'].values.reshape(-1, 1),
    test_size=0.15,
    random_state=0
)

A continuación preparamos los datos para que sean compatibles con PyTorch: convertimos los arrays de NumPy en tensores de PyTorch, convirtiendo los datos numéricos a reales para mayor compatibilidad:

X_train = torch.tensor(X_train, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.float32)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_test = torch.tensor(y_test, dtype=torch.float32)

1.2.3. Definición del modelo

Definimos ahora el modelo en Pytorch, como una subclase de nn.Module.

class Modelo(nn.Module):
    def __init__(self):
        super(Modelo, self).__init__()
        self.fc1 = nn.Linear(1, 1)  # Capa densa con 1 entrada y 1 salida

    def forward(self, x):
        return self.fc1(x)  
  • En el constructor se definen todas las capas que va a tener nuestro modelo, como atributos de la clase. En nuestro caso hemos definido una sola capa densa con activación lineal, con una sola neurona y una conexión de entrada (introducimos la edad, obtenemos la presión sanguínea).
  • El método forward se invoca automáticamente al entrenar el modelo (o utilizarlo) para propagar los valores de entrada por todas las capas de la red hasta la salida. En nuestro caso, al haber sólo una capa, recibe la entrada (edad) y devuelve la salida (presión sanguínea).

1.2.4. Entrenamiento

Llega el momento de preparar el modelo para su entrenamiento. Lo que haremos será crear una instancia de nuestra clase, definir su función de coste y optimizador y definir el bucle de entrenamiento con las epochs que consideremos.

modelo = Modelo()
# Función 'mae'
func_coste = nn.L1Loss()  
# Optimizador SGD con learning rate 0.01
optimizador = optim.SGD(modelo.parameters(), lr=0.01)

# Entrenamiento del modelo
epochs = 100
for epoch in range(epochs):
    modelo.train()

    # Forward pass
    y_pred = modelo(X_train)

    # Calculamos la pérdida
    coste = func_coste(y_pred, y_train)

    # Backward pass y optimización
    optimizador.zero_grad()
    coste.backward()
    optimizador.step()

    if (epoch + 1) % 10 == 0:
        print(f"Epoch [{epoch + 1}/{epochs}], Loss: {coste.item():.4f}")

Algunos aspectos a considerar del código anterior:

  • La función nn.L1Loss define la función de coste MAE (error absoluto medio). Podemos consultar las funciones de coste disponibles en PyTorch en su documentación oficial.
  • El optimizador optim.SGD, como podemos intuir, activa este optimizador para el entrenamiento de la red. Nuevamente, en la documentación oficial podemos encontrar los optimizadores disponibles, aunque tenemos los habituales también en Keras (SGD, Adam, RMSprop...).
    • Por su parte, la instrucción modelo.parameters obtiene todos los parámetros entrenables de la red (pesos y biases) y se los pasa al optimizador.
  • La instrucción modelo.train pone al modelo en modo entrenamiento. Esto es especialmente importante si utilizamos capas como dropout, ya que en este caso PyTorch debe desactivar conexiones entre neuronas. El caso opuesto sería modelo.eval, que usaremos más adelante para el modo evaluación, donde PyTorch tendría en cuenta este nuevo estado para no hacer nada con estas capas.
  • La instrucción optimizador.zero_grad reinicia los gradientes calculados. Por defecto, en PyTorch los gradientes se acumulan de una iteración a la siguiente. Esta característica tiene sentido si hacemos pequeños "mini-batches" de entrenamiento y queremos ir acumulando el gradiente calculado en todos allos antes de pasar a la siguiente etapa.
  • La instrucción coste.backward calcula los gradientes nuevos aplicando la función de pérdida
  • La instrucción optimizador.step aplica el optimizador a los gradientes calculados para actualizar los pesos de las conexiones.

1.2.5. Evaluación de resultados

Llega el momento de evaluar la red. Para ello la pondremos en modo evaluación y le pasaremos los datos de entrenamiento y test para recoger y comparar los resultados obtenidos:

modelo.eval()
with torch.no_grad():
    pred_train = modelo(X_train)
    pred_test = modelo(X_test)

    coste_train = func_coste(pred_train, y_train).item()
    coste_test = func_coste(pred_test, y_test).item()

print(f"Coste train: {coste_train}")
print(f"Coste test: {coste_test}")

# Predicciones
y_pred = pred_test.numpy().flatten()
df_resultado = pd.DataFrame({
    'edades': X_test.flatten(),
    'reales': y_test.flatten(),
    'predichos': y_pred
})
print(df_resultado)

La instrucción torch.no_grad anula el cálculo de gradientes que se hace en la etapa de entrenamiento, para que no se modifiquen o recalculen durante la etapa de evaluación.

2. Optimizando el código

El ejemplo anterior es más una forma de ver cómo "traducir" un modelo Keras a uno PyTorch que un modelo PyTorch pleno, ya que algunas de las cosas que hemos definido en el código anterior normalmente se refactorizan y desarrollan de un modo algo diferente en PyTorch. Así, en este apartado vamos a construir un modelo algo más complejo siguiendo las pautas habituales con esta librería.

Continuaremos con el mismo dataset, pero en este caso vamos a hacer una versión de este ejemplo que hicimos en Keras para predecir si una persona tendrá o no problemas cardíacos en base a sus demás parámetros de salud (edad, altura, peso, etc).

La primera parte de nuestro código no cambia respecto a la versión Keras: cargamos las librerías necesarias y pre-procesamos con Pandas los datos de entrada al modelo:

import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

datos = pd.read_csv('datos_salud.csv')

# Codificación de variables categóricas
datos['gender'] = datos['gender'].map({'M':0, 'F':1})
datos['smoke'] = datos['smoke'].map({'No':0, 'Yes':1})
datos['alco'] = datos['alco'].map({'No':0, 'Yes':1})
datos['active'] = datos['active'].map({'No':0, 'Yes':1})
datos['cholesterol'] = datos['cholesterol'].map({'Normal':0, 'Above Normal':1, \
    'Well Above Normal':2})
datos['gluc'] = datos['gluc'].map({'Normal':0, 'Above Normal':1, \
    'Well Above Normal':2})    
datos['cardio'] = datos['cardio'].map({'No':0, 'Yes':1})

# Escalado de variables numéricas
columnas_a_escalar = ['age', 'height', 'weight', 'ap_hi', 'ap_lo']
scaler = StandardScaler()
datos[columnas_a_escalar] = scaler.fit_transform(datos[columnas_a_escalar])

# Identificación de columna objetivo y partición de entrenamiento/test
X = datos.iloc[:, 1:12]
y = datos['cardio']
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, test_size=0.2, random_state=0)

2.1. Configurar el entorno de ejecución

Una de las ventajas que ofrece PyTorch sobre Keras/TensorFlow es que su configuración con entornos de ejecución avanzados (GPUs) es más directa. Sin embargo, tanto PyTorch como Keras sólo son compatibles con determinados tipos de GPU (modelos de NVidia, en general), por lo que conviene determinar el entorno en que se ejecutarán los modelos. Este código establece en la variable dispositivo qué dispositivo se utilizará para ejecutar el modelo, dependiendo de si tenemos disponible alguna GPU compatible o no:

dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Usando dispositivo: {dispositivo}")

Nota

CUDA son las siglas de Compute Unified Device Architecture, una plataforma de computación paralela y un modelo de programación desarrollado por NVIDIA para computación general en unidades de procesamiento gráfico (GPU).

2.2. Uso de la clase Dataset para carga de datos

La clase torch.utils.data.Dataset permite encapsular los datos de entrada de forma que el dispositivo seleccionado como entorno de ejecución los pueda procesar de forma más eficiente. Podemos crear una subclase de ella que reciba como parámetros los datos de entrada al modelo

from torch.utils.data import Dataset

...

class SaludDataset(Dataset):
    def __init__(self, X, Y):
        self.X = torch.tensor(X.values, dtype=torch.float32)
        self.y = torch.tensor(Y.values.reshape(-1, 1), dtype=torch.float32)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

El método __getitem__ se encargará de ir proporcionando cada dato de la secuencia, según el índice idx que indique PyTorch.

Ahora pasamos los conjuntos de entrenamiento y test por separado a esta clase:

train_dataset = SaludDataset(X_train, y_train)
test_dataset = SaludDataset(X_test, y_test)

2.3. Definición del modelo

En este caso vamos a definir un modelo con varias capas. Crearemos, como en el caso anterior, una clase que herede de nn.Module y en el constructor definiremos las capas necesarias: en este caso, una capa de entrada de 11 neuronas, una intermedia de 6 neuronas con activación ReLU, y una de salida de 1 neurona para un clasificador binario (sí/no tendrá problemas cardíacos).

class Modelo(nn.Module):
    def __init__(self):
        super(Modelo, self).__init__()
        self.red = nn.Sequential(
            nn.Linear(11, 6),       # capa densa: 11 entradas, 6 neuronas
            nn.ReLU(),              # activación ReLU
            nn.Linear(6, 1),        # capa densa: 6 entradas, 1 salida
            nn.Sigmoid()            # activación sigmoide
        )
        # Inicialización uniforme
        for capa in self.red:
            if isinstance(capa, nn.Linear):
                # Función de inicialización habitual en PyTorch
                nn.init.xavier_uniform_(capa.weight)
                nn.init.zeros_(capa.bias)

    def forward(self, x):
        return self.red(x)

En este caso hemos definido un atributo self.red con las características completas de la red, incluyendo todas las capas. El método forward recibe una entrada X y devuelve la salida producida por la red para esa entrada.

2.4. El proceso de entrenamiento y evaluación

Para facilitar los datos al modelo, se suele emplear otra clase de PyTorch llamada DataLoader, del paquete torch.utils.data. Esta clase se apoya en la clase Dataset anterior para ir tomando los datos y proporcionándoselos a la red. Así lo preparamos:

from torch.utils.data import Dataset, DataLoader

...

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=16)

Nota

El parámetro batch_size indica el tamaño de los lotes que se pasarán en el entrenamiento. El parámetro shuffle indica si se mezclarán aleatoriamente los datos de entrada antes de hacer los lotes (sí para el entrenamiento, no para el test, normalmente).

El proceso de entrenamiento podemos definirlo en una función que se invoque para tal fin:

def entrenar(modelo, loader, func_coste, optimizador, dispositivo):
    modelo.train()
    coste_total = 0
    for X, y in loader:
        X, y = X.to(dispositivo), y.to(dispositivo)

        optimizador.zero_grad()
        salida = modelo(X)
        coste = func_coste(salida, y)
        coste.backward()
        optimizador.step()

        coste_total += coste.item()

    return coste_total / len(loader)

La función devuelve la media de costes de los datos pasados en el lote en cuestión.

Para la evaluación podemos definir otra función que agrupe todo el proceso:

def evaluar(modelo, loader, func_coste, dispositivo):
    modelo.eval()
    coste_total = 0
    with torch.no_grad():
        for X, y in loader:
            X, y = X.to(dispositivo), y.to(dispositivo)
            salida = modelo(X)
            coste = func_coste(salida, y)
            coste_total += coste.item()
    return coste_total / len(loader)

Como vemos, el comportamiento es el mismo pero sin alterar los cálculos de gradiente.

Ahora podemos lanzar el proceso de entrenamiento que vaya, por lotes, llamando a estas dos funciones para reajustar los pesos y comprobar los costes:

modelo = Modelo().to(dispositivo)
func_coste = nn.BCELoss()  # Entropía cruzada binaria
optimizador = optim.SGD(modelo.parameters(), lr=0.01)

for epoch in range(1, 101):
    coste_train = entrenar(modelo, train_loader, func_coste, optimizador, dispositivo)
    coste_test = evaluar(modelo, test_loader, func_coste, dispositivo)
    if epoch % 10 == 0 or epoch == 1:
        print(f"Época {epoch:3d} - Pérdida entrenamiento: {coste_train:.4f}" + \
            f" - Test: {coste_test:.4f}")

En este enlace tienes todo el código completo de este ejemplo.

Nota

Observa en el ejemplo que hemos escalado los datos numéricos. En el mismo ejemplo resuelto en Keras en documentos anteriores no fue necesario hacerlo pero, en PyTorch, si no lo hacemos, los resultados se estancan y el modelo no aprende. Esto se debe a que Keras hace cierto procesamiento más avanzado de los datos por nosotros, inicializa los pesos de una forma distinta y nos evita tener que preocuparnos de ciertas cosas aunque los datos no estén escalados (aunque sea preferible escalarlos igualmente). El efecto de no escalar los datos en PyTorch se agudiza más, ya que la librería está pensada para que el programador se preocupe más de cosas a bajo nivel.

Ejercicio 1

Sobre el ejemplo anterior, añade el código necesario para calcular la precisión (accuracy) del modelo. En el caso de un clasificador binario como este, la precisión se calcula viendo cuántos casos coinciden con los esperados:

# Convertimos las predicciones en valores 0. o 1., como las salidas
predicciones = (salida >= 0.5).float()
correctas = (y >= 0.5).float()
# Vemos cuántos casos coinciden de los totales
accuracy = (predicciones == correctas).float().mean().item()

En este caso, al estar haciendo procesamientos por lotes, deberemos acumular (sum) los aciertos de cada lote y luego calcular la media sobre el total.

Ejercicio 2

Construye ahora una red similar a la anterior para predecir tumores de mama benignos o malignos según este dataset. Puedes basarte en el Ejercicio 3 de este documento.

2.5. Guardando nuestro modelo PyTorch

Para guardar y recuperar el modelo PyTorch tenemos dos opciones:

  • Guardar los pesos y biases nada más y luego cargarlo en un modelo de características similares. Para ello necesitamos tener disponible la clase (class) en la que hemos definido nuestro modelo.
  • Guardar el modelo completo y recuperarlo sin más

Para la primera opción podemos usar un código como este:

# Guardado
torch.save(modelo.state_dict(), 'modelo_entrenado.pth')

# Carga posterior
modelo = Modelo() # Necesitamos tener accesible nuestra clase Modelo
modelo.load_state_dict(torch.load('modelo_entrenado.pth'))
modelo.eval()  # Importante para habilitar el modo evaluación

Para guardar y cargar el modelo entero podemos hacer esto otro:

# Guardado
torch.save(modelo, 'modelo_completo.pth')

# Carga posterior
modelo = torch.load('modelo_completo.pth', weights_only=False)
modelo.eval()

Advertencia

En ambos casos, es IMPORTANTE tener disponible la clase donde se ha definido el modelo.

3. Otros modelos de redes

Vamos a analizar ahora cómo hacer con PyTorch otros modelos de redes que hemos visto en Keras.

3.1. Clasificadores no binarios

La forma de desarrollar clasificadores no binarios en PyTorch tiene algunas particularidades con respecto a los que hemos hecho en Keras, por ejemplo, en este ejemplo anterior.

¿Qué diferencias encontramos respecto a Keras, y respecto a los modelos de clasificación que hemos hecho en ejercicios anteriores de PyTorch?

  • La última capa del modelo tendrá tantas neuronas como categorías, pero no se aplica función softmax, como sí hacíamos en Keras. En su lugar se aplica una función lineal.
  • La función de coste será una entropía cruzada categórica, definida en la clase nn.CrossEntropyLoss. Esta clase, internamente, se encarga de aplicar una función softmax sobre los valores que recibe. Por este motivo no tenemos que aplicarla nosotros directamente en la salida.

Veamos cómo quedaría entonces el procesamiento del dataset y definición del modelo con PyTorch.

En primer lugar cargamos las librerías necesarias

import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split

Ahora definimos la clase para procesar el dataset:

class MiDataset(Dataset):
    def __init__(self, X, Y):
        self.X = torch.tensor(X.values, dtype=torch.float32)
        # Tipo long para clasificación categórica con CrossEntropy
        # No hay que hacer reshape de Y en este caso porque CrossEntropy
        # espera un vector unidimensional de valores (etiquetas de salida)
        self.y = torch.tensor(Y.values, dtype=torch.long)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

Como se indica en el comentario, a diferencia de otros ejemplos anteriores ahora no pasamos un array vertical de resultados, sino un vector unidimensional, que es lo que espera recibir la función de entropía cruzada.

Definimos ahora nuestro modelo con 2 capas ocultas de 30 neuronas y una capa de salida de tantas neuronas como clases haya:

class Modelo(nn.Module):
    def __init__(self, entrada, clases):
        super(Modelo, self).__init__()
        self.red = nn.Sequential(
            nn.Linear(entrada, 30), # capa densa de 30 neuronas conectada con la entrada
            nn.ReLU(),              # activación ReLU
            nn.Linear(30, 30),      # capa densa de 30 neuronas conectada con la anterior
            nn.ReLU(),              # activación ReLU
            nn.Linear(30, clases),  # capa densa: 30 entradas, tantas salidas como clases
        )
        # Inicialización uniforme
        for capa in self.red:
            if isinstance(capa, nn.Linear):
                nn.init.xavier_uniform_(capa.weight)
                nn.init.zeros_(capa.bias)

    def forward(self, x):
        return self.red(x)

Como podemos observar, la salida de la red es una función lineal, no aplicamos softmax. Esto lo conectaremos después con la función de coste adecuada.

La función de entrenamiento no cambia respecto a otros ejercicios previos:

def entrenar(modelo, loader, func_coste, optimizador, dispositivo):
    modelo.train()
    for X, y in loader:
        X, y = X.to(dispositivo), y.to(dispositivo)

        optimizador.zero_grad()
        salida = modelo(X)
        coste = func_coste(salida, y)
        coste.backward()
        optimizador.step()

La función de evaluación sí se modifica ligeramente, para calcular la precisión (accuracy) en el caso de un clasificador categórico no binario. Lo que hacemos es obtener las n salidas de la red para la entrada, quedarnos con la posición de mayor valor (usando argmax) y cotejarlo con la categoría correcta de salida (valores numéricos de 0 a n-1, siendo n el total de categorías identificables).

def evaluar(modelo, loader, func_coste, dispositivo):
    modelo.eval()
    coste_total = 0
    # Variables adicionales para calcular la precisión
    aciertos = 0
    total = 0
    with torch.no_grad():
        for X, y in loader:
            X, y = X.to(dispositivo), y.to(dispositivo)
            salida = modelo(X)
            # Código para calcular la precisión acumulada de cada batch
            predicciones = torch.argmax(salida, dim=1)
            aciertos += (predicciones == y).float().sum().item()
            total += len(y)

            coste = func_coste(salida, y)
            coste_total += coste.item()

    # Devolvemos coste y accuracy
    return coste_total / len(loader), aciertos / total

Ahora vamos a cargar los datos de entrada y procesarlos:

datos = pd.read_csv('datos_coches.csv')
datos['price'] = datos['price'].map({'vhigh': 3, 'high': 2, 'med': 1, 'low': 0})
datos['maint'] = datos['maint'].map({'vhigh': 3, 'high': 2, 'med': 1, 'low': 0})
datos['doors'] = datos['doors'].map({'2': 2, '3': 3, '4': 4, '5more': 5})
datos['capacity'] = datos['capacity'].map({'2': 2, '4': 4, 'more': 5})
datos['luggage'] = datos['luggage'].map({'big': 2, 'med': 1, 'small': 0})
datos['safety'] = datos['safety'].map({'high': 2, 'med': 1, 'low': 0})
# La columna objetivo la codificamos con valores numéricos de 0 a n-1
datos['result'] = datos['result'].map({'unacc': 0, 'acc': 1, 'good': 2, 'vgood': 3})

X = datos.iloc[:, :-1]
y = datos.iloc[:, -1]
num_clases = len(y.unique())

# Separación de entrenamiento y test
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, test_size=0.2, random_state=0)

# Adaptación de datos a PyTorch
train_dataset = MiDataset(X_train, y_train)
test_dataset = MiDataset(X_test, y_test)

Cargamos ahora los datos en el modelo e iniciamos el entrenamiento:

# Configuración de entorno de ejecución
dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Usando dispositivo: {dispositivo}")

# Preparamos los datos en lotes de tamaño indicado
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=32)

# Lanzamos el entrenamiento
# Modelo con X.shape[1] parámetros de entrada y num_clases neuronas de salida
modelo = Modelo(X.shape[1], num_clases).to(dispositivo)
func_coste = nn.CrossEntropyLoss()  # Entropía cruzada (no binaria)
optimizador = optim.Adam(modelo.parameters(), lr=0.01)

for epoch in range(1, 101):
    entrenar(modelo, train_loader, func_coste, optimizador, dispositivo)
    coste_train, precision_train = evaluar(modelo, train_loader, func_coste, dispositivo)
    coste_test, precision_test = evaluar(modelo, test_loader, func_coste, dispositivo)
    if epoch % 10 == 0 or epoch == 1:
        print(f"Época {epoch:3d} - loss_train: {coste_train:.4f}, accuracy_train: {precision_train:.4f}" + \
            f", loss_test: {coste_test:.4f}, accuracy_test: {precision_test:.4f}")

Ejercicio 3

Copia el código anterior en un fichero valoracion_coches_train.py que entrene el modelo, y añade al final el código para guardar el modelo generado. Define ahora otro fichero valoracion_coches_test.py que cargue el modelo, le pida al usuario los datos de entrada y se los pase para obtener la predicción.

3.2. Redes convolucionales

Las redes convolucionales en PyTorch guardan muchas similitudes con las de Keras a la hora de definir las clases a utilizar, ya que PyTorch también dispone de clases como nn.Conv2d para hacer las convoluciones, nn.MaxPool2d para hacer el pooling posterior o nn.Flatten para el aplanado previo a las capas fully connected del final. Si todo esto te suena a chino, recuerda que en este documento se explica en detalle el funcionamiento de las redes convolucionales en general, y usando Keras/TensorFlow en particular.

Entre las principales diferencias con respecto a lo que se hace en Keras, podemos hablar de la forma de procesar las imágenes de entrada, ya que, obviamente, en este caso utilizaremos clases y métodos propios de PyTorch para procesar las imágenes antes de pasárselas a la red.

Para ilustrar el proceso nos basaremos en el Ejercicio 1 de este ejemplo que también hicimos en Keras, sobre identificación de personas que llevan o no llevan mascarilla. Aquí tienes el conjunto de imágenes que utilizaremos para el entrenamiento.

3.2.1. Librerías necesarias

En este caso, además de las librerías habituales para trabajar con PyTorch haremos uso de la librería torchvision, que incorpora clases y funcionalidades para análisis y procesamiento de imágenes. También emplearemos la librería os, nativa de Python, para examinar las subcarpetas donde tengamos las imágenes.

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader
import os

3.2.2. Procesamiento de las imágenes de entrada

Para procesar las imágenes de entrada utilizaremos el método Compose del módulo transforms de torchvision. Este método recibe una secuencia de operaciones a realizar, y las aplicará en ese orden sobre las imágenes que le facilitaremos.

En primer lugar configuramos la secuencia de pasos a realizar. Por ejemplo, redimensionar las imágenes a un tamaño de entrada, convertirlas en tensores y normalizar sus valores:

transform = transforms.Compose([
    transforms.Resize((128, 128)),     
    transforms.ToTensor(),             
    # Normalizado por canales para imágenes RGB
    transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) 
])

En el caso de querer aplicar algo de data augmentation (rotaciones, volteos, etc), tenemos disponibles también esas operaciones en transforms, pero en este caso necesitaríamos definir un Compose para entrenamiento con esas operaciones y otro para test sin ellas:

train_transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.RandomHorizontalFlip(),      # Volteo horizontal aleatorio
    transforms.RandomVerticalFlip(),        # Volteo vertical aleatorio
    transforms.RandomRotation(15),          # Rotación aleatoria ±15 grados
    transforms.ToTensor(),
    transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])
])

test_transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor(),
    transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])
])

Aclaración

En el normalizado, como has podido observar, se facilitan dos vectores o listas a la instrucción Normalize. La primera contiene las medias de los canales afectados (tres en este caso, suponiendo imágenes a color) y la segunda las desviaciones típicas (también tres). Lo que hace este normalizado es escalar los valores en torno a 0 respecto a la desviación típica.

La conversión en tensor hecha por la instrucción ToTensor convierte los valores originales de la imagen (típicamente en el rango 0-255 por canal) en valores en el rango 0-1. Sobre estos valores se aplica la normalización, restando a cada valor la media indicada (0.5) y dividiendo entre la desviación típica indicada (0.5), y con esto se dejan los valores en el rango [-1, 1].

Ahora que hemos definido el procesado de las imágenes de entrada, debemos definir un mecanismo que las cargue de las distintas carpetas y las haga pasar por este proceso. Supondremos, para nuestro ejemplo, que el dataset de imágenes facilitado lo tenemos en una subcarpeta llamada imagenes, de forma que en la subcarpeta train tenemos las imágenes para entrenamiento y en la subcarpeta test las de test. Dentro de cada una de estas subcarpetas hay una carpeta mascarilla con muestras de gente con mascarilla, y otra no_mascarilla con muestras de gente sin ella. Quedaría algo así:

- imagenes
    - train
        - mascarilla
        - no_mascarilla
    - test
        - mascarilla
        - no_mascarilla

Bajo este supuesto, vamos a construir los datasets de entrenamiento y test y se los pasamos a un DataLoader que los facilitará a nuestro modelo:

data_dir = "imagenes"

train_dataset = datasets.ImageFolder(os.path.join(data_dir, "train"), 
    transform = train_transform)
test_dataset  = datasets.ImageFolder(os.path.join(data_dir, "test"), 
    transform = test_transform)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader  = DataLoader(test_dataset, batch_size=32, shuffle=False)

# Verificar las clases
print(train_dataset.classes)  # ['mascarilla', 'no_mascarilla']

3.2.3. Definición del modelo

Vamos a definir ahora nuestro modelo de red convolucional. Dividiremos el diseño en dos partes: un módulo convolucional y otro fully connected al final.

class CNNMascarilla(nn.Module):
    def __init__(self):
        super(CNNMascarilla, self).__init__()
        self.conv_layer = nn.Sequential(
            nn.Conv2d(3, 16, kernel_size=3, padding=1),  # Entrada con 3 canales (RGB)
            nn.ReLU(),
            nn.MaxPool2d(2, 2),

            nn.Conv2d(16, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),

            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),
        )

        self.fc_layer = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 16 * 16, 128),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(128, 2)  # 2 clases: mascarilla y no_mascarilla
        )

    def forward(self, x):
        x = self.conv_layer(x)
        x = self.fc_layer(x)
        return x

Algunas aclaraciones sobre el código anterior:

  • La clase Conv2d recibe como parámetros el número de conexiones de entrada, el de salida, el tamaño del kernel con el que hacer la convolución (en nuestro caso matrices de 3x3) y el parámetro padding indica con cuántos píxeles (negros) rellenar el borde de las imágenes para compensar la pérdida sufrida por la convolución, y poder así explorar también los bordes de las imágenes.
  • En la red fully connected (objeto self.fc_layer), se tiene primero una capa Flatten que aplana el resultado del módulo de convolución anterior. En nuestro caso, el módulo de convolución realiza el proceso siguiente:
    • Las imágenes de entrada las pasa por 16 filtros de convolución, sin pérdida de resolución porque aplicamos un padding de 1. Por lo tanto, tras la primera capa de convolución tenemos una salida de 16 x 128 x 128.
    • Tras el primer pooling las imágenes se reducen a la mitad, obteniendo una salida de 16 x 64 x 64
    • Con la segunda convolución se aplican 32 filtros sobre las imágenes anteriores, obteniendo una salida de 32 x 64 x 64.
    • Tras el segundo pooling nuevamente se divide el tamaño a la mitad, generando una salida de 32 x 32 x 32
    • La tercera convolución aplica 64 filtros, generando una salida de 64 x 32 x 32 elementos
    • El tercer pooling divide de nuevo el tamaño a la mitad, generando para el módulo fully connected una matriz de 64 x 16 x 16, que se aplana con el Flatten
    • La capa siguiente al Flatten necesita recibir tantas entradas como elementos se han aplanado, de ahí la operación que se ve de 64 * 16 * 16. En lugar de hacer este cálculo a mano, se puede utilizar alternativamente la clase LazyLinear, en lugar de Linear (aunque puede no estar disponible en versiones antiguas de PyTorch). En este caso quedaría así el módulo fully connected:
self.fc_layer = nn.Sequential(
    nn.Flatten(),
    nn.LazyLinear(128), # Entrada auto-calculada, salida de 128
    nn.ReLU(),
    nn.Dropout(0.3),
    nn.Linear(128, 2)   # 2 clases: mascarilla y no_mascarilla
)

3.2.4. Entrenamiento del modelo

Llega la hora de entrenar el modelo. Primero detectaremos en qué dispositivo podemos lanzarlo, y lo pondremos en marcha:

dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
modelo = CNNMascarilla().to(dispositivo)

func_coste = nn.CrossEntropyLoss()
optimizador = optim.Adam(modelo.parameters(), lr=0.001)

# Entrenamiento
num_epochs = 10
for epoch in range(num_epochs):
    modelo.train()
    coste_total = 0.0
    for imagenes, etiquetas in train_loader:
        imagenes, etiquetas = imagenes.to(dispositivo), etiquetas.to(dispositivo)

        optimizador.zero_grad()
        salidas = modelo(imagenes)
        coste = func_coste(salidas, etiquetas)
        coste.backward()
        optimizador.step()

        coste_total += coste.item()

    print(f"Epoch {epoch+1}, Coste: {coste_total/len(train_loader):.4f}")

3.2.5. Evaluación

Vamos ahora a evaluar el modelo entrenado con el conjunto de test.

modelo.eval()
correctos = 0
total = 0

with torch.no_grad():
    for imagenes, etiquetas in test_loader:
        imagenes, etiquetas = imagenes.to(dispositivo), etiquetas.to(dispositivo)
        salidas = modelo(imagenes)
        # Valor máximo y posición del valor, por columna (dim=1), de cada imagen
        valor_maximo, posicion = torch.max(salidas, dim=1)
        # Acumulamos número de imágenes procesadas
        total += etiquetas.size(0)
        # Determinamos cuántas han sido correctas
        correctos += (posicion == etiquetas).sum().item()

print(f"Precisión en test: {100 * correctos / total:.2f}%")

3.2.6. Prueba con nuevos datos de entrada

Una vez tengamos el modelo entrenado y guardado, podemos pasarle nuevas imágenes de entrada y que las clasifique. Para ello, necesitamos aplicar las mismas transformaciones sobre esas imágenes que las que hicimos sobre el conjunto de test:

transform = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor(),
    transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])
])

Suponiendo que la imagen a facilitar es prueba.png, nos aseguramos de que esté en formato RGB (por si viene con algún canal adicional), y la pasamos por el transformador anterior:

from PIL import Image

...

imagen = Image.open("prueba.png").convert("RGB")
tensor_entrada = transform(imagen).unsqueeze(0)

Nota

La instrucción "squeeze" transforma una imagen de 3 canales x 128 de alto x 128 de ancho en un array de 1 imagen x 3 canales x 128H x 128W, como entrada al modelo.

Ahora pasamos la imagen al modelo y obtenemos la predicción:

# Deben coincidir con el orden del modelo original
clases = ['mascarilla', 'no mascarilla']

with torch.no_grad():
    salida = modelo(tensor_entrada)
    valor_maximo, posicion = torch.max(salida, 1)
    print(f"Predicción: {clases[posicion.item()]}")

Ejercicio 4

Traslada el ejemplo anterior a un fichero mascarilla_pytorch_train.py y guarda el modelo entrenado en fichero. Crea luego un fichero mascarilla_pytorch_test.py que tome una imagen de entrada y la pase al modelo para obtener la predicción.

3.3. Redes recurrentes

Las redes recurrentes en PyTorch guardan bastantes similitudes con las que hemos definido en Keras en documentos anteriores. En general, en PyTorch podemos crear capas recurrentes utilizando tres clases distintas:

  • nn.RNN: puede emplearse para redes simples, ya que su principal desventaja es que no admite memoria a largo plazo.
  • nn.LSTM: para redes complejas que requieran memoria a largo plazo, y no sólo recordar el estado anterior de cada neurona.
  • nn.GRU: Gated Recurrent Unit, es una versión simplificada de las neuronas LSTM que fusiona las puertas de entrada y olvido. Es algo más eficiente, aunque puede no resultar tan precisa.

Nos basaremos en este ejemplo de una sesión anterior, en el que catalogamos reseñas en TripAdvisor en valoraciones de 1 a 5. Aquí tenemos disponible el dataset en cuestión. Utilizaremos una capa de embedding adicional que nos vectorice los textos de entrada antes de pasarlos a la red.

3.3.1. Carga de librerías y parámetros de configuración generales

Cargamos las librerías habituales, incluyendo alguna otra que ya utilizamos en el ejemplo en Keras para tokenizar los textos y generar el diccionario de palabras clave:

import re
import pandas as pd
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
from sklearn.feature_extraction.text import CountVectorizer, ENGLISH_STOP_WORDS

Los parámetros generales de configuración de la red los podemos definir en una serie de constantes al inicio del programa. Como puedes ver si cotejas este ejemplo con el de la versión en Keras, compartimos varios de ellos:

# Constantes de configuración

N = 20000     # Número de palabras del diccionario
T = 100       # Longitud prefijada de la reseña
D = 8         # Longitud prefijada del vector de embedding
CLASES = 5    # Número de categorías distintas a identificar
EPOCHS = 5
NEURONAS_CAPA = 128
BATCH_SIZE = 32
ID_DESC = 0   # Código para palabras no registradas en el diccionario
ID_PAD = 1    # Código para palabras de relleno (padding)

También podemos compartir las funciones que utilizamos en aquel ejemplo para generar el diccionario y codificar un texto en un vector de T códigos numéricos (incluyendo símbolos de relleno, si procede):

def generar_diccionario(textos, N):
    cv = CountVectorizer(stop_words='english', max_features = N)
    cv.fit_transform(textos)
    diccionario = cv.vocabulary_
    diccionario = dict([(palabra, i+2) for i, palabra in enumerate(diccionario)])
    diccionario['DESC'] = ID_DESC
    diccionario['PAD'] = ID_PAD
    return diccionario

def procesar_cadena(texto, diccionario, T):
    # Identificar palabras en el texto
    palabras = re.findall(r'\b\w+\b', texto.lower())
    palabras = list(filter(lambda x: x not in ENGLISH_STOP_WORDS, palabras))
    resultado = [] 
    for i in range(0, T):
        if i < len(palabras): 
            if palabras[i] in diccionario:
                resultado.append(diccionario[palabras[i]])
            else:
                resultado.append(diccionario['DESC'])
        else:
            resultado.append(diccionario['PAD'])
    return np.array(resultado)

Nota

En este caso, en lugar de crear a mano una lista de stop words hemos empleado una que ya viene predefinida en SKLearn, para facilitar la tarea.

Nota

Existen algunas clases y métodos disponibles en la librería torchtext que también nos pueden ayudar a generar el diccionario y las secuencias de código de tamaño fijo. El problema es que puede haber incompatibilidades entre la versión que hayamos instalado de PyTorch y la que tengamos de torchtext, invalidando entonces estos métodos. Por eso en este ejemplo hemos optado por hacer nosotros ese proceso.

3.3.2. Carga de datos

Vamos ahora a leer el dataset y pasarlo por nuestra instancia de la clase Dataset de PyTorch:

datos = pd.read_csv("tripadvisor_hotel_reviews.csv")
y = datos['Rating']
textos = datos['Review']
X = []
diccionario = generar_diccionario(textos, N)
for texto in textos:
    X.append(procesar_cadena(texto, diccionario, T))
# X contendrá las reseñas codificadas y rellenas con la misma longitud
X = np.array(X)

# Dataset para almacenar la información
class ReviewDataset(Dataset):
    def __init__(self, X, y):
        self.reviews = X
        self.etiquetas = [int(rating) - 1 for rating in y]  # clases 0-4

    def __len__(self):
        return len(self.reviews)

    def __getitem__(self, idx):
        return torch.tensor(self.reviews[idx]), torch.tensor(self.etiquetas[idx])

Como puedes ver, procesamos cada texto de la columna Review y lo convertimos en una secuencia de T códigos. Todas estas secuencias, junto con los resultados de 1 a 5, se los pasamos a la clase para que los almacene. Internamente, la clase convierte las secuencias 1-5 en secuencias de 0-4 para basar los resultados en 0 y adaptarlos a la función de entropía cruzada que los usará.

3.3.3. Construcción del modelo

Vamos a construir ahora nuestro modelo. Tendrá una capa de embedding inicial que recibirá las secuencias numéricas y transformará cada número en un vector de D componentes. Esta capa conectará con un conjunto de capas LSTM, que finalmente enlazarán con una capa lineal de salida.

Una primera versión del modelo podría ser ésta:

class ClasificadorReviews(nn.Module):
    def __init__(self, tam_vocabulario, tam_embedding, clases, id_padding):
        super().__init__()
        self.embedding = nn.Embedding(tam_vocabulario, tam_embedding, padding_idx=id_padding)
        # 3 capas LSTM
        self.lstm = nn.LSTM(tam_embedding, NEURONAS_CAPA, num_layers = 3, 
            batch_first=True, dropout=0.2)
        self.fc = nn.Linear(NEURONAS_CAPA, clases)

    def forward(self, x):
        vectores = self.embedding(x)
        _, (hidden, _) = self.lstm(vectores)
        capa_final = hidden[-1]
        return self.fc(capa_final)

Al modelo le pasamos como parámetros:

  • El tamaño del vocabulario o diccionario generado (constante N)
  • El tamaño que queremos para los vectores de embedding (constante D)
  • El número de clases diferentes en la salida (constante CLASES)
  • El código que tiene el símbolo de padding en nuestro diccionario (constante ID_PAD), y que necesita la capa de embedding para tratar de forma especial a este código, por ser más irrelevante que el resto.

Tras la capa de embedding construimos las capas LSTM a través del constructor nn.LSTM, que recibe varios parámetros:

  • El tamaño del vector de embedding (constante D), para calcular con él el tamaño de la entrada (serán frases de T códigos, cada uno codificado con D números).
  • El número de neuronas de cada capa
  • El número de capas LSTM que queremos
  • El parámetro batch_first=True le dice a las capas que la primera dimensión de los datos de entrada es el tamaño del batch. Si, por ejemplo, es 32, indicará que tendremos 32 secuencias de T símbolos codificados con vectores de D números. Es una forma más natural de presentar la información de entrada.
  • El parámetro dropout permite establecer un porcentaje de dropout entre cada capa LSTM, si se quiere

La capa de salida Linear recibe las NEURONAS_CAPA salidas de la última capa LSTM, y produce tantas salidas como CLASES tenga nuestro problema.

Por otra parte, la función forward recibe como entrada cada secuencia de T códigos numéricos y:

  • Los pasa por la capa de embedding para transformar cada número en un vector de D componentes
  • La salida la pasa por las capas LSTM, y obtiene como resultado:
  • Toda la secuencia de salidas de las capas LSTM (normalmente poco útil, por eso la asignamos a una variable irrelevante _)
  • Último vector de salida de las capas, que sí nos interesa para pasarlo a la capa final, y que tenemos en la variable hidden. De hecho, nos quedamos con la salida de la última de las capas (hidden[-1]).
  • El estado final de cada celda de memoria a largo plazo de cada capa (también suele ser poco útil, por eso lo almacenamos en otra variable irrelevante _).
  • La capa de salida recibe las salidas de la última capa LSTM (hidden[-1]) y con ellas produce el resultado con la categoría estimada (de 0 a 4, en nuestro ejemplo).

Añadiendo bidireccionalidad

Ya que estamos definiendo una red de análisis de textos, puede resultar interesante que la comunicación en las capas recurrentes sea bidireccional, para que la información de estados posteriores afecte a la de estados anteriores. Para ello, en primer lugar debemos añadir un parámetro bidirectional=True en la definición de las capas recurrentes. En segundo lugar, la salida que produce el módulo LSTM ahora es algo más compleja, y lo que se suele hacer es combinar la salida forward y backward de la última capa para pasársela a la capa de salida (duplicando así el número de neuronas necesarias en esta capa). La red quedaría de este modo:

class ClasificadorReviews(nn.Module):
    def __init__(self, tam_vocabulario, tam_embedding, clases, id_padding):
        super().__init__()
        self.embedding = nn.Embedding(tam_vocabulario, tam_embedding, padding_idx=id_padding)
        # 3 capas LSTM
        self.lstm = nn.LSTM(tam_embedding, NEURONAS_CAPA, num_layers = 3, 
            batch_first=True, dropout=0.2, bidirectional=True)
        # La capa final tiene el doble de neuronas, para absorber el
        # forward y backward de la última capa LSTM
        self.fc = nn.Linear(NEURONAS_CAPA * 2, clases)

    def forward(self, x):
        vectores = self.embedding(x)
        _, (hidden, _) = self.lstm(vectores)
        forward = hidden[-2]
        backward = hidden[-1]
        capa_final = torch.cat((forward, backward), dim=1)
        return self.fc(capa_final)

3.3.4. Entrenamiento y evaluación

Pasamos ahora a constuir la red y entrenarla con los parámetros que hemos definido previamente:

# Dataset y DataLoader
dataset = ReviewDataset(X, y)
dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)

# Modelo, optimizador y loss
# El tamaño del vocabulario es N + 2 para incluir DESC y PAD
modelo = ClasificadorReviews(N+2, D, CLASES, ID_PAD)
optimizador = optim.Adam(modelo.parameters(), lr=0.001)
func_coste = nn.CrossEntropyLoss()

# Bucle
for epoch in range(EPOCHS):
    modelo.train()
    coste_total = 0
    correctos = 0

    for batch_textos, batch_etiquetas in dataloader:
        optimizador.zero_grad()
        salidas = modelo(batch_textos)
        coste = func_coste(salidas, batch_etiquetas)
        coste.backward()
        optimizador.step()

        coste_total += coste.item()
        correctos += (salidas.argmax(1) == batch_etiquetas).sum().item()

    accuracy = correctos / len(dataset)
    print(f"Epoch {epoch+1}: Loss = {coste_total:.4f}, Accuracy = {accuracy:.2f}")

En cuanto a la evaluación posterior del modelo, podemos hacer algo así:

def predecir(texto):
    modelo.eval()
    tokens = procesar_cadena(texto, diccionario, T)
    tensor_entrada = torch.tensor(tokens).unsqueeze(0)
    with torch.no_grad():
        salida = modelo(tensor_entrada)
        pred_class = salida.argmax(1).item() + 1  # de 1 a 5
    return pred_class

# Ejemplo
print(predecir("Amazing experience and delicious food"))

Nota

Observa que, en la función de predicción, volvemos a sumar 1 a la salida de la red (que internamente trabajaba con valores de 0 a 4 para calcular la entropía cruzada), y así volver a los códigos reales del problema.

Ejercicio 5

Basándote en el ejemplo anterior, construye ahora otro modelo de red recurrente que utilice este dataset para predecir si un tuit trata sobre un desastre natural o no.

4. Otros aspectos adicionales

Veremos a continuación algunos aspectos adicionales que conviene tener presentes de PyTorch y que no hemos abarcado en los apartados anteriores.

4.1. Uso de capas Dropout

Al igual que ocurre en Keras, en el caso de que nuestra red tenga overfitting podemos tratar de paliarlo añadiendo capas de Dropout en nuestro modelo. Para ello tenemos disponible la clase nn.Dropout, a la que le pasamos en su parámetro p el porcentaje de desconexión.

Estas capas típicamente se suelen colocar detrás de las capas densas (Linear) o detrás de la activación ReLU que puedan tener, si es el caso. Aquí vemos un ejemplo:

class Modelo(nn.Module):
    def __init__(self, entrada, clases):
        super(Modelo, self).__init__()
        self.red = nn.Sequential(
            nn.Linear(entrada, 30), # capa densa de 30 neuronas conectada con la entrada
            nn.ReLU(),              # activación ReLU
            nn.Dropout(p = 0.2)     # Dropout
            nn.Linear(30, 30),      # capa densa de 30 neuronas conectada con la anterior
            nn.ReLU(),              # activación ReLU
            nn.Dropout(p = 0.2)     # Dropout
            nn.Linear(30, clases),  # capa densa: 30 entradas, tantas salidas como clases
        )
        # Inicialización uniforme
        for capa in self.red:
            if isinstance(capa, nn.Linear):
                nn.init.xavier_uniform_(capa.weight)
                nn.init.zeros_(capa.bias)

    def forward(self, x):
        return self.red(x)

4.2. Optimización de uso de las GPUs

Si tenemos CUDA disponible en nuestro entorno, podemos detectar cuántos dispositivos (es decir, GPUs) tenemos disponibles y paralelizar la ejecución de nuestro modelo entre todas ellas. El proceso podemos dividirlo en etapas.

En primer lugar, como ya hemos visto, es importante detectar si tenemos CUDA disponible para trabajar sobre una GPU. Después, podemos obtener cuántos dispositivos diferentes tenemos, y cuál es el actualmente usado

if torch.cuda.is_available():
    num_gpus = torch.cuda.device_count()
    num_actual = torch.cuda.current_device()
    nombre_actual = torch.cuda.get_device_name(num_actual)

Una vez nos hemos asegurado de tener disponibles varias GPUs, podemos emplear la clase nn.DataParallel de PyTorch para paralelizar el funcionamiento de nuestro modelo. Suponiendo que tenemos el modelo cargado en una variable modelo, es tan sencillo como hacer esto:

if torch.cuda.device_count() > 1:
    modelo = nn.DataParallel(modelo)

modelo = modelo.to('cuda')

Esto reparte automáticamente los datos entre GPUs disponibles y recolecta los resultados en la GPU principal. Lo que hace DataParallel es dividir el batch entre las distintas GPUs, ejecutar el forward en cada una, combinar los resultados y calcular la pérdida y retropropagación en la GPU principal. Este funcionamiento será más efectivo si los batch son grandes, con lo que merecerá más la pena paralelizarlos.