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 ellos 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.

Nota

Una definición alternativa de la clase podría consistir en pasarle al constructor el número de neuronas de la capa de entrada, para no tener que ponerlo a mano en el código.

class Modelo(nn.Module):
    def __init__(self, entrada):
        super(Modelo, self).__init__()
        self.red = nn.Sequential(
            nn.Linear(entrada, 6),  # capa densa: entradas conectadas a 6 neuronas
    ...

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
    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() * X.size(0)
            total += y.size(0)
    return coste_total / total

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)
# Si tenemos un constructor parametrizado, usaríamos este otro constructor
# modelo = Modelo(X.shape[1]).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 exactitud (accuracy) del modelo. En el caso de un clasificador binario como este, la exactitud 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().sum().item()

En este caso, al estar haciendo procesamientos por lotes, deberemos acumular (sum) los aciertos de cada lote y luego calcular el porcentaje 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. Vamos a analizarlas, por ejemplo, con en este ejemplo.

¿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 exactitud (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
    aciertos = 0
    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() * X.size(0)
            total += y.size(0)
            predicciones = torch.argmax(salida, dim=1)
            aciertos += (predicciones == y).float().sum().item()

    # Devolvemos coste y accuracy
    return coste_total / total, 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
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 o canales de entrada (tres, en el caso de RGB, para la capa inicial), el número de canales de salida (tantos como filtros de convolución queramos aplicar), 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")
print(f"Usando dispositivo: {dispositivo}")

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
    total = 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()
        total += etiquetas.size(0)

    print(f"Epoch {epoch+1}, Coste: {coste_total/total:.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"Exactitud 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 unsqueeze 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. Aquí tienes algunas imágenes de prueba.

Ejercicio 5

Traslada ahora el Ejercicio 3 de este documento a un modelo PyTorch que identifique los tres tipos de comida a analizar (donuts, guacamole y nachos). Aquí puedes descargar las imágenes para entrenamiento y aquí las de prueba del modelo

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
import nltk
from nltk.corpus import stopwords
from nltk.stem import SnowballStemmer

nltk.download('stopwords')

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 = 64        # 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):

# Stop words: podemos generarlas con NLTK o crear una lista propia offline

stop_words = stopwords.words('english')

'''
stop_words=['a', 'about', 'an', 'and', 'as', 'at', 'be', 'because', 'been', 
    'being', 'by', 'during', 'each', 'for', 'from', 'he', 'he\'d', 'he\'ll',
    'he\'s', 'her', 'him', 'his', 'how', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve',
    'if', 'in', 'into', 'it', 'it\'s', 'its', 'let', 'let\'s', 'll', 'of', 'or', 
    'our', 'out', 'over', 're', 'same', 'she', 'she\'d', 'she\'ll', 'she\'s', 
    'so', 'some', 'such', 'than', 'that', 'that\'s', 'the', 'them', 'then', 
    'there', 'these', 'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 
    'this', 'those', 'through', 'to', 'until', 've', 'very', 'we', 'we\'d', 
    'we\'ll', 'we\'re', 'we\'ve', 'what', 'what\'s', 'when', 'when\'s', 'where', 
    'where\'s', 'which', 'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 
    'with', 'would', 'wouldn', 'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 
    'you\'ve', 'your', 'yours']
'''

# Lematizador

stemmer = SnowballStemmer('english')

# Funciones auxiliares

# Función que tokeniza y aplica stemming
def stem_tokenizer(texto):
    # Tokenizamos (separamos las palabras del texto)
    tokens = re.findall(r'\b\w+\b', texto.lower())
    # Eliminamos los stop words
    tokens = list(filter(lambda x: x not in stop_words, tokens))
    # Aplicamos stemming
    return [stemmer.stem(t) for t in tokens]

# Genera el diccionario de raíces más habituales
def generar_diccionario(textos, N):
    cv = CountVectorizer(tokenizer=stem_tokenizer, token_pattern=None, stop_words=None, max_features = N)
    cv.fit_transform(textos)
    diccionario = cv.vocabulary_
    diccionario = dict([(palabra, i+2) for i, palabra in enumerate(diccionario)])
    diccionario['DESC'] = 0
    diccionario['PAD'] = 1 
    return diccionario

# Función auxiliar para, dada una cadena, convertirla en una secuencia de 
# códigos según el diccionario aportado, hasta un tamaño máximo de T códigos
def procesar_cadena(texto, diccionario, stop_words, T):
    # Identificar palabras en el texto
    palabras = re.findall(r'\b\w+\b', texto.lower())
    # Eliminar stop words
    palabras = list(filter(lambda x: x not in stop_words, palabras))
    # Lematizar (stemming) el resto de palabras
    palabras = [stemmer.stem(p) for p in 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

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, stop_words, 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
    total = 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()
        total += batch_etiquetas.size(0)

    accuracy = correctos / total
    coste_total /= total
    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, stop_words, 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 6

Une las piezas anteriores para construir el modelo que aprenda a predecir la valoración de las reseñas de TripAdvisor.

Ejercicio 7

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

dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
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(dispositivo)

Después, en la etapa de entrenamiento, también debemos tener la precaución de repartir los batches entre los dispositivos disponibles:

for batch_textos, batch_etiquetas in dataloader:
    # IMPORTANTE: Mover los datos al mismo dispositivo que el modelo
    batch_textos, batch_etiquetas = batch_textos.to(dispositivo), batch_etiquetas.to(dispositivo)
    ...

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.

5. Transformers

Aunque las redes LSTM mejoraron la memoria a largo plazo, siguen teniendo un problema fundamental: el procesamiento secuencial. Las palabras se procesan una a una, lo que impide la paralelización masiva y dificulta captar relaciones complejas en frases muy largas. Es ahí donde la arquitectura transformer hace valer su ventaja.

5.1. Características generales

Antes de entrar en detalles sobre implementación y opciones quen os ofrece Transformers, vamos a analizar algunos aspectos relevantes de esta arquitectura.

5.1.1. El mecanismo de atención (self-attention)

A diferencia de las RNN, que mantienen un "estado oculto" que se actualiza paso a paso, los Transformers utilizan el mecanismo de auto-atención. Esto permite que la red mire todas las palabras de una frase al mismo tiempo y decida cuáles son más relevantes para entender el significado de la palabra actual.

Por ejemplo: en la frase "El banco estaba cerrado porque era festivo", el mecanismo de atención ayuda a la red a asociar la palabra banco con cerrado para saber que se refiere a una entidad financiera y no a un mueble de parque.

5.1.2. Ventajas sobre LSTM

Las principales ventajas que ofrecen los transformers sobre las redes LSTM son:

  • Paralelización: al no procesar palabra por palabra, los transformers pueden entrenarse mucho más rápido en GPUs modernas.
  • Codificación posicional (positional encoding): como los transformers procesan todo en paralelo, pierden la noción de orden que sí tienen las RNN de forma natural. Para solucionar esto, añaden un vector especial a cada palabra que indica su posición en la frase.
  • Memoria infinita (teórica): no sufren del desvanecimiento del gradiente de la misma forma que las RNN, lo que les permite conectar ideas separadas por miles de palabras.

5.2. Implementación

Hoy en día no solemos programar un transformer desde cero para cada tarea. En su lugar utilizamos modelos pre-entrenados como BERT o GPT a través de la librería Hugging Face Transformers, que se integra perfectamente con PyTorch. Deberemos instalarla en nuestro sistema con pip install transformers.

5.2.1. Modelos disponibles

El ecosistema Transformers pone a nuestra disposición distintos tipos de modelos de procesamiento del lenguaje, cada uno adaptado a distintas características. Algunos de los más relevantes son:

  • BERT (Bidirectional Encoder Representations from Transformers): es el estándar original, y típicamente se usa para premiar la precisión sobre la velocidad, ya que es un modelo complejo, que también requiere de GPUs decentes para su funcionamiento. Dispone de algunas variantes, como por ejemplo para procesamiento de texto en inglés ignorando mayúsculas/minúsculas (identificador bert-uncased) o una versión para español, conocido como BETO (identificador dccuchile/bert-base-spanish-wwm-cased).
  • RoBERTa (Robustly optimized BERT approach): versión optimizada para Meta (Facebook) entrenada por más tiempo y con más datos. Su identificador es roberta-base, y podemos emplearlo si pensamos que podemos llegar a un poco más de precisión que con BERT.
  • ALBERT (A Lite BERT): versión diseñada para ocupar poca memoria y consumir pocos recursos. Apta para ordenadores limitados o móviles. Su identificador es albert-base-v2.
  • mBERT (Multilingual BERT): entrenado en más de 100 idiomas simultáneamente. Útil para analizar textos en varios idiomas. Su identificador es bert-base-multilingual-cased.
  • DistilBERT: versión de BERT más ligera con menores requerimientos de cómputo, similar a ALBERT. Su identificador es distilbert-base-uncased.

Podemos consultar otros muchos modelos de lenguaje en la web de Hugging Face.

5.2.2. Primeros pasos. Entendiendo el pipeline

Para entender mejor cómo funciona la librería transformers, conviene analizar el funcionamiento de sus módulos. Un pipeline es un objeto de alto nivel que encapsula todo el flujo de trabajo necesario para procesar una tarea de Procesamiento de Lenguaje Natural (NLP). El flujo interno es el siguiente:

  • Pre-procesamiento (Tokenizer): el texto crudo se convierte en números que el modelo entiende.
  • Inferencia (Modelo): los números pasan por la red neuronal (el Transformer que elijamos) y salen logits (puntuaciones numéricas brutas).
  • Post-procesamiento: Esos números brutos se convierten en algo legible, como una etiqueta ("Positivo"/"Negativo") o un porcentaje de probabilidad.

Veamos un ejemplo sencillo de análisis de sentimiento:

from transformers import pipeline

# Creamos el pipeline especificando la tarea
clasificador = pipeline("sentiment-analysis")

# Lo usamos directamente con un texto
resultado = clasificador("This AI course is absolutely amazing.")
print(resultado)
# Salida esperada: [{'label': 'POSITIVE', 'score': 0.99}]

Aquí tenemos otro ejemplo con un pipeline que permite clasificar un texto en categorías que el modelo no ha visto anteriormente:

from transformers import pipeline

clasificador_libre = pipeline("zero-shot-classification")

texto = "The new particle accelerator has detected an outlier."
etiquetas_candidatas = ["cooking", "physics", "political", "sports"]
resultado = clasificador_libre(texto, candidate_labels=etiquetas_candidatas)
print(resultado['labels'][0], resultado['scores'][0])
# Salida: physics 0.98...

Nota

Como podrás comprobar, la mayoría de modelos están optimizados u orientados a procesar el idioma inglés. En el caso de que queramos procesar otro idioma con un pipeline, debemos indicar el modelo en cuestión que queremos emplear. Por ejemplo, podemos emplear un modelo multilingüe, aunque puede perderse algo de precisión:

clasificador_multi = pipeline(
   "sentiment-analysis", 
   model="nlptown/bert-base-multilingual-uncased-sentiment"
)

Ejercicio 8

Vamos a usar el pipeline de análisis de sentimiento para analizar las opiniones de esta web. Deberás hacer web scraping o bien copiar y pegar las reseñas en un fichero de texto para luego analizarlas y sacar después qué porcentaje de reseñas positivas tiene el hotel.

5.2.3. Componentes principales

A pesar de que usar los modelos de Hugging Face de por sí ya aporta mucha funcionalidad, en ocasiones nos va a tocar ajustarlos un poco más a nuestras necesidades específicas. Por ello es importante entender el proceso que todo modelo sigue internamente, y que se compone de distintos elementos.

1. El tokenizador

Dado que los modelos de IA trabajan con elementos numéricos, los tokenizadores se encargan de:

  • Dividir el texto, aunque no siempre tiene que ser por palabras. En ocasiones nos puede interesar un tokenizador que divida en subpalabras para entender mejor el significado de ciertos términos. Por ejemplo, camin-ando indica una acción de continuada de caminar (gerundio).
  • Mapear a códigos: cada fragmento tiene asignado un código numérico identificativo en el diccionario
  • Tensores de atención: la máscara de atención (attention mask) le dice al modelo qué códigos son de palabras reales y cuáles son de relleno o padding

2. El modelo

Deberemos definir la red neuronal o modelo encargado de procesar los datos de entrada para generar la salida. Como entrada recibirá los tensores del tokenizer previo, y como salida mostrará los logits (números lineales brutos), que luego se pueden adaptar a una respuesta más concreta.

Ejemplo

Vamos a descomponer el pipeline anterior de análisis de sentimiento en distintas etapas:

from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

model_name = "pysentimiento/robertuito-sentiment-analysis"

# 1. Cargamos el tokenizador y el modelo
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)

texto = "Esta clase es una maravilla."

# 2. El Tokenizer convierte texto en Tensores de PyTorch
inputs = tokenizer(texto, return_tensors="pt")
print("IDs de los tokens:", inputs["input_ids"])

# 3. El Modelo procesa los tensores
with torch.no_grad():
    outputs = model(**inputs)

# 4. Los 'Logits' son la salida bruta
logits = outputs.logits
print("Logits (salida bruta):", logits)

# 5. Post-procesamiento
prediccion = torch.softmax(logits, dim=-1)
print("Probabilidades reales:", prediccion)
# Estas son las etiquetas de cada posición
print(model.config.id2label)

Vamos a comentar algunos aspectos del código anterior:

  • En este caso usamos como base el modelo pysentimiento/robertuito-sentiment-analysis, un modelo en español para análisis de sentimiento.
  • Las auto classes (AutoModel... y AutoTokenizer) permiten adaptar el tokenizador y el modelo a la arquitectura que elijamos (en este caso, robertuito). Cada modelo tiene su propio tokenizador, por lo que si intentamos utilizar otro ajeno (por ejemplo, un tokenizador de BERT con un modelo de GPT) no tendremos buenos resultados.

3. Los datasets

En los ejemplos anteriores hemos probado con textos sueltos, pero para entrenar modelos necesitaremos grandes baterías de textos de entrada. Es aquí donde los datasets de Hugging Face entran en acción. A diferencia de un simple CSV o un DataFrame de Pandas, un Dataset de Hugging Face está optimizado para:

  • Memoria mapeada: se pueden cargar archivos muy grandes sin agotar la RAM del ordenador.
  • Integración total: están diseñados para hablar el mismo idioma que los tokenizadores.

Nota

Si no la tienes instalada, deberás instalar la librería datasets con pip install datasets.

Para comunicar los textos de entrada con los tokenizadores podemos emplear la función map del propio dataset. Aquí vemos un ejemplo donde cargamos una serie de reseñas de películas de IMDB, predefinido en el hub de Hugging Face:

from datasets import load_dataset
from transformers import AutoTokenizer

# 1. Cargamos un dataset de ejemplo (reseñas de películas)
dataset = load_dataset("imdb", split="train[:1000]") # Solo 1000 para probar

# 2. Cargamos el Tokenizer basado en BERT multilingual
tokenizer = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")

# 3. Función de procesamiento
# padding="max_length" iguala todas las frases al tamaño máximo del modelo (típicamente 512)
# truncation=True trunca las frases más largas de ese tamaño
def tokenize_function(ejemplos):
    return tokenizer(ejemplos["text"], padding="max_length", truncation=True)

# 4. Aplicamos la función a TODO el dataset (Mapping)
tokenized_datasets = dataset.map(tokenize_function, batched=True)

print(tokenized_datasets[0].keys())
# Cada ejemplo tiene: 'text', 'label', 'input_ids', 'attention_mask'

5.2.4. Un ejemplo concreto

Para entender mejor el funcionamiento vamos a basarnos en este ejemplo anterior de reseñas de TripAdvisor.

1. Preparando los datos

Primero, cargamos el archivo local y ajustamos las etiquetas para que sean compatibles con la red neuronal.

from datasets import load_dataset
from transformers import AutoTokenizer, AutoConfig, AutoModelForSequenceClassification

# Cargar el CSV local
dataset = load_dataset('csv', data_files='tripadvisor_hotel_reviews.csv')

# Dividir en entrenamiento y prueba (80/20)
dataset = dataset['train'].train_test_split(test_size=0.2)

# Función para ajustar etiquetas: de (1 a 5) a (0 a 4)
def ajustar_etiquetas(ejemplo):
    ejemplo['Rating'] = ejemplo['Rating'] - 1
    return ejemplo

dataset = dataset.map(ajustar_etiquetas)

2. Configurando el tokenizador y el modelo

Vamos a configurar ahora el tokenizador y el modelo para esta tarea. Usaremos el modelo multilingüe de BERT, aunque podemos probar con cualquier otro en inglés también.

model_name = "bert-base-multilingual-cased"

# 1. Configuración: Definimos que hay 5 clases (estrellas 1 a 5)
# El parámetro "id2label" es común a otros modelos, y define las etiquetas de salida
config = AutoConfig.from_pretrained(
    model_name, 
    num_labels=5,
    id2label={0: "1 estrella", 1: "2 estrellas", 2: "3 estrellas", 3: "4 estrellas", 4: "5 estrellas"}
)

# 2. Tokenizador
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 3. Modelo cargado con la configuración anterior
model = AutoModelForSequenceClassification.from_pretrained(model_name, config=config)

3. Procesando los datos de entrada

Aplicamos un padding de relleno y un truncado a todo el dataset de entrada.

def tokenizar(examples):
    return tokenizer(examples['Review'], truncation=True, padding='max_length', max_length=128)

tokenized_dataset = dataset.map(tokenizar, batched=True)

# Eliminamos las columnas de texto originales y renombramos 'Rating' a 'labels'
# (Hugging Face busca la columna 'labels' por defecto durante el entrenamiento)
tokenized_dataset = tokenized_dataset.remove_columns(['Review'])
tokenized_dataset = tokenized_dataset.rename_column("Rating", "labels")
tokenized_dataset.set_format("torch")

4. El bucle de entrenamiento

Aunque podríamos definir un bucle de entrenamiento típico de PyTorch, podemos valernos de las clases TrainingArguments y Trainer de transformers para facilitar el proceso. La primera sirve para definir los parámetros del entrenamiento (learning rate, número de epochs, etc), y la segunda para configurar el entrenamiento con los conjuntos de entrenamiento y test.

from transformers import TrainingArguments, Trainer

# Definimos los parámetros del entrenamiento
training_args = TrainingArguments(
    output_dir="./resultados",     # Carpeta donde se guardará el modelo
    eval_strategy="epoch",         # Evaluar cada vez que termine una vuelta a los datos
    learning_rate=2e-5,            # Un lr muy bajo es apropiado para "fine tuning"
    per_device_train_batch_size=8, # Tamaño del batch para cada dispositivo vinculado
    num_train_epochs=3,            # Número de epochs
    weight_decay=0.01              # Intenta evitar overfitting haciendo que los pesos no se vuelvan muy grandes
)

# Creamos el entrenador
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    eval_dataset=tokenized_dataset["test"],
)

trainer.train()

Es posible que necesitemos instalar algunas librerías adicionales, como accelerate>=1.1.0 para compatibilizar la clase Trainer con PyTorch, o evaluate si queremos calcular algunas métricas adicionales, como el accuracy.

Vamos a definir el cálculo del accuracy:

import numpy as np
import evaluate

# Cargamos la métrica de exactitud (accuracy)
metrica = evaluate.load("accuracy")

# Función para calcular la métrica
# Le pasamos los *logits* predichos y las etiquetas reales
# Devuelve el cálculo entre las predicciones y las etiquetas reales
def calcular_metrica(eval_pred):
    logits, etiquetas = eval_pred
    # Obtenemos el índice de la categoría con mayor puntuación
    predicciones = np.argmax(logits, axis=-1)
    # Comparamos predicciones vs etiquetas reales
    return metrica.compute(predictions=predicciones, references=etiquetas)

Debemos pasarle al Trainer este cálculo de métricas también:

trainer = Trainer(
    ... # mismos parámetros que antes
    compute_metrics=calcular_metrica
)

5. Evaluación

Con el modelo entrenado podemos probar su funcionamiento con nuevos datos. Para ello podemos construir una función como esta:

import torch

def predecir_estrellas(texto):
    # 1. Preparar el texto (Tokenización)
    # Importante: Usar los mismos parámetros que en el entrenamiento
    inputs = tokenizer(texto, return_tensors="pt", truncation=True, padding="max_length", max_length=128)

    # Mover los tensores a la misma unidad que el modelo (CPU o GPU)
    inputs = {k: v.to(model.device) for k, v in inputs.items()}

    # 2. Inferencia (Pasar por el modelo)
    model.eval() # Ponemos el modelo en modo evaluación
    with torch.no_grad():
        outputs = model(**inputs)

    # 3. Post-procesamiento
    logits = outputs.logits
    # Sacamos el índice con el valor más alto (0 a 4)
    prediccion_idx = torch.argmax(logits, dim=-1).item()

    # Convertimos el índice al nombre de la etiqueta que definimos en el Config
    etiqueta = model.config.id2label[prediccion_idx]

    return etiqueta

# Ejemplos de prueba
ejemplos = [
    "The room was dirty and the staff was very rude, I will never come back.",
    "Un lugar aceptable, aunque el desayuno podría mejorar bastante.",
    "Amazing experience! The view from the balcony was breathtaking and the bed was so comfy."
]

for ex in ejemplos:
    resultado = predecir_estrellas(ex)
    print(f"Reseña: {ex[:50]}... -> Predicción: {resultado}")

Ejercicio 9

Une las piezas de código anteriores para crear un modelo Transformer que aprenda a evaluar las reseñas de TripAdvisor.

Advertencia

El modelo puede tardar mucho en entrenar. Para hacer una prueba simple quizá puedas usar una versión reducida del dataset original, con unos 100-200 registros nada más. O bien puedes ejecutarlo con una GPU en Google Colab.

Cuando lancemos el entrenamiento, veremos un mensaje que muestra una tabla con parámetros del modelo, con este formato aproximado:

Key                                        | Status     |
-------------------------------------------+------------+-
cls.seq_relationship.bias                  | UNEXPECTED |
cls.predictions.transform.dense.bias       | UNEXPECTED |
cls.seq_relationship.weight                | UNEXPECTED |
cls.predictions.transform.dense.weight     | UNEXPECTED |
cls.predictions.transform.LayerNorm.weight | UNEXPECTED |
cls.predictions.bias                       | UNEXPECTED |
cls.predictions.transform.LayerNorm.bias   | UNEXPECTED |
classifier.weight                          | MISSING    |
classifier.bias                            | MISSING    |

Los pesos "MISSING" (Faltantes)

Cuando cargamos AutoModelForSequenceClassification basado en el modelo bert-base-multilingual-cased estamos descargando el cuerpo de un modelo que fue entrenado para "rellenar huecos" en frases y queremos que ahora clasifique reseñas en 5 categorías (estrellas). El modelo original no tiene una "cabeza" de clasificación de 5 neuronas, así que PyTorch crea la capa classifier totalmente nueva y vacía (con valores aleatorios). El mensaje avisa de esto para que sepamos que debemos entrenar el modelo antes de usarlo, porque esa parte final aún no sabe nada de tu tarea.

Los pesos "UNEXPECTED" (Inesperados)

El archivo que descargamos de Internet (bert-base-multilingual-cased) incluye las capas que se usaron originalmente para predecir palabras. Como ahora vamos a clasificar sentimientos y no a predecir la siguiente palabra, la arquitectura actual no necesita esas capas. PyTorch las descarta y avisa de que en el modelo vienen estas cosas pero no las ha usado, por lo que pueden ser ignoradas.

Por tanto, el código ahora mismo hará lo siguiente:

  • Cuerpo del modelo: Mantiene el conocimiento del lenguaje inglés (gramática, vocabulario, sarcasmo, etc.) que ya venía en BERT multilenguaje.
  • Cabeza del modelo (Las capas MISSING): Está empezando a aprender desde cero cómo conectar ese conocimiento del inglés con las etiquetas de 1 a 5 estrellas de TripAdvisor.

5.2.5. Otros usos de transformers

En los ejemplos previos nos hemos centrado en pipelines o modelos de clasificación, bien para analizar el sentimiento de un texto (positivo / neutro / negativo) o para identificar la valoración numérica de una reseña.

Además de estas tareas, podemos encontrar en el ecosistema transformers otros pipelines y modelos orientados a distintas tareas:

  • Generación de texto, o sistemas de conversación, basados en modelos como GPT o Mistral.
  • Resumen de texto
  • Traductores entre distintos idiomas
  • Sistemas de pregunta-respuesta

A la hora de utilizar alguno de estos modelos, existen ciertas limitaciones desde distintos puntos de vista:

  • Para ejecución local no existen límites legales de uso, pero sí en cuanto a recursos hardware. Por ejemplo, los modelos 7B (7 billones de parámetros) muy probablemente darán un error Out of memory al intentar ponerlos en marcha o entrenarlos. Además, con una CPU normal pueden tardar incluso minutos en dar una respuesta, mientras que con GPUs relativamente potentes el tiempo se reduce a unos pocos segundos.
  • Por otra parte, tenemos el hub de Hugging Face, mediante el que podemos consultar de forma remota (y gratuita) a los modelos. veremos algún ejemplo de esto en otras secciones (acceso a modelos pre-entrenados), pero también tiene sus limitaciones en cuanto a disponibilidad y tiempo de respuesta.
  • Finalmente, si queremos utilizar ciertos modelos propietarios como los de OpenAI o Anthropic, no están disponibles a través de transformers, ya que ésta está destinada a modelos open source.

Vamos a probar a continuación con un modelo de resumen de textos.

from transformers import AutoTokenizer, AutoModelForSeq2SeqLM

nombre_modelo = "google/flan-t5-small"   # ligera y razonable para local

tokenizer = AutoTokenizer.from_pretrained(nombre_modelo)
modelo = AutoModelForSeq2SeqLM.from_pretrained(nombre_modelo)

texto = """
Machine learning is a field of artificial intelligence that focuses on
building systems that learn from data. It is widely used in applications
such as recommendation systems, speech recognition, and fraud detection.
"""

prompt = "Summarize this text in English:\n" + texto

entradas = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512)

ids_salidas = modelo.generate(
    **entradas,
    max_new_tokens=60,
    do_sample=False  # Quita aleatoriedad a la respuesta
)

resumen = tokenizer.decode(ids_salidas[0], skip_special_tokens=True)
print(resumen)

Ejercicio 10

Recupera las reseñas de la web del Ejercicio 8. Recopílalas en dos listas separadas (positivas y negativas) y usa el modelo de resumen que hemos visto para que haga un resumen general de cada bloque. Como la longitud será muy grande, tendrás que resumir cada reseña por separado, y luego hacer un resumen de los resúmenes (agrupados en lotes), hasta obtener un resumen único de cada categoría.

En el caso de querer ajustar uno de estos modelos a nuestras necesidades, podemos hacer uso de los pasos anteriores. Por ejemplo, imaginemos que queremos hacer un modelo que nos ayude a completar un texto (una reseña de TripAdvisor). Podemos emplear un modelo como gpt2 o distilgpt2 (más ligero), y seguir unas etapas similares a las anteriores:

from transformers import pipeline, AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset

model_name = "gpt2"  # O "distilgpt2" si queremos más velocidad

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
# IMPORTANTE: GPT-2 no tiene un token de relleno (padding) por defecto.
# Usamos el token de fin de frase (eos_token) para rellenar los huecos.
tokenizer.pad_token = tokenizer.eos_token

def tokenize_function(examples):
    # Tokenizamos el texto
    outputs = tokenizer(examples["Review"], truncation=True, padding="max_length", max_length=128)
    # Para modelos generativos, las etiquetas son los mismos IDs de entrada
    outputs["labels"] = outputs["input_ids"].copy()
    return outputs

# Aplicamos el proceso al CSV
dataset = load_dataset('csv', data_files='tripadvisor_hotel_reviews.csv')
tokenized_dataset = dataset.map(tokenize_function, batched=True, remove_columns=dataset["train"].column_names)

# Entrenamiento

training_args = TrainingArguments(
    output_dir="./gpt2-tripadvisor",
    per_device_train_batch_size=4, # Bajamos batch porque los modelos generativos pesan más
    num_train_epochs=1,            # Con una época suele bastar para ver cambio de estilo
    learning_rate=5e-5,            # Un poco más alto que en clasificación
    logging_steps=10,
    save_steps=100,
    weight_decay=0.01,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
)

trainer.train()

# Evaluación

# Creamos un generador con nuestro modelo recién entrenado
generador_tripadvisor = pipeline("text-generation", model=model, tokenizer=tokenizer)

prompt = "The hotel room was"

# Pedimos 3 finales distintos
resultados = generador_tripadvisor(prompt, max_length=50, num_return_sequences=3)

for i, res in enumerate(resultados):
    print(f"Propuesta {i+1}: {res['generated_text']}")

5.2.6. Más información

Podemos encontrar más información sobre cómo usar otros modelos en la propia web de Hugging Face. Podemos buscar modelos por funcionalidad (generación de texto, análisis de imágenes, etc) y luego yendo a la página del modelo y eligiendo la opción Transformers en el desplegable de Use this model.