13. Entrenamiento de redes neuronales con PyTorch#

13.1. Funciones de costo y optimizadores#

Para entrenar una red neuronal debemos definir

  1. Una función de costo: Aquello que vamos a minimizar

  2. Un algoritmo de optimización: De que forma vamos a minimizar

Las funciones de costo están implementadas torch.nn, a continuación revisaremos las dos más comunes. La clase MSELoss representa el error cuadrático medio. Se utiliza en problemas de regresión (etiqueta con valores reales) y se define como

\[ \mathcal{L}(y, \hat y) = \sum_{d=1}^D ( y_d - \hat y_d)^2 \]

donde \(y\) es la etiqueta (vector con D dimensiones) e \(\hat y\) es la predicción del modelo

Nota

Cuando entrenamos con esta función de costo debemos asegurarnos de que el modelo tenga tantas unidades de salida como dimensiones tenga la etiqueta

La clase CrossEntropyLoss representa la entropía cruzada. Esta función de costo se utiliza en problemas de clasificación de \(C\) clases y se define como

\[ \mathcal{L}(y, \hat y) = - \sum_{c=1}^C y_c \log (\hat y_c ) \]

donde \(y_c \in \{0,1\}\), \(\hat y_c \in [0,1]\) y \(\sum_{c=1}^C \hat y_c = 1\)

Nota

Cuando entrenamos con esta función de costo debemos asegurarnos de que el modelo tenga tantas unidades de salida como clases tenga el problema.

Advertencia

La implementación de entropía cruzada de torch espera que las etiquetas \(y\) estén en formato categórico, es decir como un entero con valor \(0, 1, 2, \ldots, C-1\). Adicionalmente espera las predicciones \(\hat y\) estén en formato logits (números reales sin activación)

Luego de crear una instancia de estas clases las podemos llamar con una función. Por ejemplo si queremos calcular el error medio cuadrático entre dos tensores:

criterion = torch.nn.MSELoss(reduction='mean')
loss = criterion(output, target)

Nota

También podemos crear nuestra propia función de costo. Por ejemplo podríamos lograr lo mismo que MSELoss habiendo definido criterion = lambda ypred, y : (y-ypred).pow(2).mean()

Los algoritmos de optimización están implementados en el módulo torch.optim. Hoy en día los más utilizados son

Optimizador

Descripción

SGD

Gradiente descedente estocástico con momentum

Adam

Gradiente descedente con tasa de aprendizaje adaptiva

Para crear un objeto optimizar debemos entregar como argumento los parámetros del modelo y las argumetnos específicos del optimizar, por ejemplo para SGD:

optimizer = torch.optim.SGD(model.parameters(), lr=1e-3, momentum=0, weight_decay=0)

donde:

  • lr Es la tasa de aprendizaje. Debe ser un valor pequeño para no desestabilizar el entrenamiento, pero no tan pequeño para enlentencerlo demasiado

  • momentum es la tasa de momentum. Podemos utilizar un valor mayor que cero para evitar estancamiento en mínimos locales

  • weight_decay controla la regularización (norma L2) de los parámetros. Podemos utilizar un valor mayor que cero para evitar sobreajuste

Una vez creado podemos llamar su función principal step() la cual realiza una actualización de parámetros de acuerdo a las derivadas calculadas con loss.backward()

13.2. Ajustando un modelo paso a paso#

Consideremos el siguiente dataset \(X\) con etiqueta \(Y\). El dataset tiene cuatro ejemplos separados en dos clases. Los ejemplos tienen cada uno dos atributos.

Utilizaremos CrossEntropyLoss para entrenar por lo tanto utilizamos dos unidades de salida

import torch
import torch.nn as nn

X = torch.tensor([[-1.0, -1.0],
                  [-1.0, 1.0],
                  [1.0, -1.0],
                  [1.0, 1.0]])
Y = torch.tensor([0, 0, 0, 1])

n_features = X.shape[1]
n_classes = len(Y.unique())
n_hidden = 3
model = nn.Sequential(nn.Linear(n_features, n_hidden),
                      nn.Sigmoid(),
                      nn.Linear(n_hidden, n_classes))

criterion = torch.nn.CrossEntropyLoss(reduction='sum')

model
Sequential(
  (0): Linear(in_features=2, out_features=3, bias=True)
  (1): Sigmoid()
  (2): Linear(in_features=3, out_features=2, bias=True)
)

Como optimizador utilizaremos el Adam

optimizer = torch.optim.Adam(model.parameters(), lr=1e-2)

Primero realizamos una predicción (inferencia) con:

hatY = model(X)
hatY
tensor([[-0.5435, -0.2738],
        [-0.6110, -0.3163],
        [-0.4007, -0.0868],
        [-0.4353, -0.0844]], grad_fn=<AddmmBackward0>)

Luego calculamos la función de pérdida:

loss = criterion(hatY, Y)
loss
tensor(3.0837, grad_fn=<NllLossBackward0>)

En seguida calculamos el gradiente de la función de pérdida:

loss.backward()

Los gradientes se guardan en el atributo grad de todos los pesos y sesgos (parámetros) de la red:

model[0].weight.grad, model[0].bias.grad
(tensor([[-0.0361, -0.0448],
         [ 0.0359,  0.0351],
         [ 0.0051,  0.0038]]),
 tensor([ 0.0542, -0.0474, -0.0047]))
model[2].weight.grad, model[2].bias.grad
(tensor([[-0.2650, -0.6544, -0.5589],
         [ 0.2650,  0.6544,  0.5589]]),
 tensor([-1.3048,  1.3048]))

Finalmente actualizamos los parámetros usando la función step de nuestro optimizador

Los parámetros actuales de la primera capa son:

model[0].weight, model[0].bias
(Parameter containing:
 tensor([[ 0.6084,  0.3615],
         [ 0.1907, -0.0184],
         [-0.5428,  0.5596]], requires_grad=True),
 Parameter containing:
 tensor([-0.5945,  0.1355, -0.3496], requires_grad=True))
optimizer.step()

Los parámetros luego de hacer la actualización son:

model[0].weight, model[0].bias
(Parameter containing:
 tensor([[ 0.6184,  0.3715],
         [ 0.1807, -0.0284],
         [-0.5528,  0.5496]], requires_grad=True),
 Parameter containing:
 tensor([-0.6045,  0.1455, -0.3396], requires_grad=True))

Truco

Para interpretar la salida de la red como probabilidades podemos aplicar una activación nn.Softmax(dim=1) como se muestra a continuación.

nn.Softmax(dim=1)(hatY).detach()
tensor([[0.4330, 0.5670],
        [0.4269, 0.5731],
        [0.4222, 0.5778],
        [0.4132, 0.5868]])

Cada columna representa a una clase y cada fila a un ejemplo. Los valores están siempre entre cero y uno, y además las filas suman uno

Más en general el proceso de ajuste se realiza iterativamente durante un cierto número de pasos o “épocas” de entrenamiento como se muestra a continuación:

for nepoch in range(1000):
    hatY = model.forward(X)
    optimizer.zero_grad()
    loss = criterion(hatY, Y)
    loss.backward()
    optimizer.step()
    if nepoch % 100 == 0:
        print(f"{nepoch} {loss.item():0.2f} {torch.nn.Softmax(dim=1)(hatY)[:, 1].detach()}")
0 3.03 tensor([0.5562, 0.5608, 0.5669, 0.5745])
100 0.79 tensor([0.0398, 0.1102, 0.1771, 0.6459])
200 0.19 tensor([0.0078, 0.0315, 0.0410, 0.9014])
300 0.08 tensor([0.0037, 0.0145, 0.0179, 0.9549])
400 0.05 tensor([0.0022, 0.0086, 0.0103, 0.9733])
500 0.03 tensor([0.0015, 0.0057, 0.0069, 0.9820])
600 0.02 tensor([0.0011, 0.0041, 0.0049, 0.9869])
700 0.02 tensor([8.9189e-04, 3.1499e-03, 3.7359e-03, 9.8999e-01])
800 0.01 tensor([7.1810e-04, 2.4861e-03, 2.9403e-03, 9.9207e-01])
900 0.01 tensor([5.9280e-04, 2.0164e-03, 2.3796e-03, 9.9354e-01])

Nota

Paso a paso, el error (función de costo) disminuye y la predicción del modelo se acerca a la etiqueta

Y
tensor([0, 0, 0, 1])

13.3. Entrenamiento por minibatches#

Si el dataset del problema es de gran tamaño no es conveniente utilizar el conjunto completo para calcular la función de costo y derivar.

En general actualizamos el modelo presentandole subconjuntos (minibatches) del dataset

Nota

Esto es lo que se conoce como la estimación estocástica del gradiente descedente (SGD)

A continuación veremos algunas clases de PyTorch que facilitan la interacción entre el modelo y el conjunto de entrenamiento. A modo de ejemplo crearemos un conjunto de datos sintético con dos atributos, dos clases y que no es linealmente separable:

%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import sklearn.datasets

np.random.seed(0)
X, y = sklearn.datasets.make_circles(n_samples=1000, noise=0.2, factor=0.25)

fig, ax = plt.subplots(figsize=(6, 5))
for k, marker in enumerate(['x', 'o']):
    mask = y == k
    ax.scatter(X[mask, 0], X[mask, 1], s=20, marker=marker, alpha=0.75)
../../_images/a1d3d70331efa0deed36d160743622bf3add7499789cbce891f7f12e8fa126ea.png

El súbmodulo torch.utils.data tiene los objetos que facilitan la interacción con datos

Sus clases principales son

  • Dataset: Clase abstracta que representa a un conjunto de datos

  • Subset: Clase que representa una partición de un conjunto de datos, por ejemplo la partición de entrenamiento o validación

  • DataLoader: Generador que recibe un conjunto de datos y retorna subconjuntos (minibatches) iterativamente

El primer paso es crear una clase que represente nuestro conjunto de datos en particular, esta clase debe heredar de Dataset y debe implementar las funciones __len__ y __getitem__

import torch.utils.data as data

class CircleDataset(data.Dataset):
    
    def __init__(self, data: np.ndarray, labels: np.ndarray):
        self.data = torch.from_numpy(data.astype('float32'))
        self.labels = torch.from_numpy(labels.astype('int'))
        
    def __getitem__(self, idx: int):
        return (self.data[idx], self.labels[idx])
    
    def __len__(self):
        return len(self.labels)
    
    
dataset = CircleDataset(X, y)

dataset[0], len(dataset)
((tensor([0.3359, 0.0898]), tensor(1)), 1000)

donde

  • El constructor recibe los datos y los convierte en formato tensor

  • __getitem__, recibe un índice y retorna una tupla (dato, etiqueta)

  • __len__ retorna la cantidad de ejemplos del conjunto de datos

Para crear particiones de este dataset podemos utilizar la función random_split del módulo data. Esta función recibe el dataset, los tamaños de cada partición y una semilla aleatoria

train_set, valid_set, test_set = data.random_split(dataset, [600, 200, 200], 
                                                   generator=torch.Generator().manual_seed(1234))

Finalmente creamos objetos DataLoader para obtener minibatches de entrenamiento, validación y prueba con:

train_loader = data.DataLoader(train_set, shuffle=True, batch_size=32)
valid_loader = data.DataLoader(valid_set, shuffle=False, batch_size=128)
test_loader = data.DataLoader(test_set, shuffle=False, batch_size=128)

Los argumentos principales de esta clase son:

  • Un objeto Dataset o Subset

  • shuffle: Booleano que índica si los ejemplos se muestran de forma ordenada o desordenada

  • batch_size: Entero que indica la cantidad de ejemplos por minibatch

Importante

Es conveniente presentar el conjunto de entrenamiento en distinto orden en cada época. Utiliza shuffle=True para este conjunto.

Una vez creado el objeto DataLoader se puede ocupar de forma equivalente a otros iteradores de Python:

for batchx, batchy in train_loader:
    break
    
batchx.T, batchy
(tensor([[ 5.8060e-03,  3.9618e-01, -2.1615e-01, -1.4567e+00,  8.9777e-02,
          -8.4204e-01, -1.3546e-02, -5.3465e-01,  1.1073e-01,  8.1661e-01,
           1.1322e-01,  8.5403e-01, -1.7986e-01, -7.6658e-01,  7.8203e-01,
          -6.7067e-01, -2.4749e-01,  7.7392e-01, -2.6569e-01, -1.0139e+00,
           1.6972e-01, -4.8297e-01, -4.6460e-01,  1.2651e-03, -3.6006e-01,
           4.0705e-01, -6.5095e-01, -5.1035e-02, -1.6224e-01,  1.2680e+00,
           1.1209e+00,  5.4708e-01],
         [ 2.7475e-02, -2.7057e-01,  4.6566e-01,  3.0285e-01,  1.4572e-01,
           6.5266e-02, -1.1583e+00, -3.4511e-01,  4.3440e-01, -5.9045e-01,
           3.1949e-01,  4.0839e-01, -6.9010e-02,  3.3542e-01, -2.9861e-01,
           5.9069e-01, -1.9815e-01, -5.1124e-01, -3.1567e-01,  3.3081e-01,
          -3.0929e-01,  4.3608e-01, -3.5730e-01, -1.9970e-01,  1.2396e+00,
          -8.5842e-02, -5.7960e-01,  2.3860e-01, -7.7749e-01, -6.7565e-01,
           5.6453e-01,  3.7583e-01]]),
 tensor([1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1,
         0, 1, 0, 1, 0, 0, 0, 0]))

13.4. Esquema general de entrenamiento en PyTorch#

Habiendo definido el modelo, el criterio, el optimizador y los datos, el esquema general de entrenamiento de un modelo en PyTorch sería:

for epoch in range(num_epochs): # Durante un cierto número de épocas
    for minibatch in data: # Para cada minibatch de datos
        optimizer.zero_grad() # Limpiamos los gradientes
        x, y = minibatch # Desempaquetamos
        yhat = model.forward(x) # Predecimos
        loss = criterion(yhat, y) # Evaluamos
        loss.backward() # Calculamos los gradientes
        optimizer.step() # Actualizamos los parámetros

donde

  • Una época es una presentación completa del conjunto de entrenamiento

  • Un minibatch es un subconjunto del conjunto de entrenamiento

Adicionalmente, debemos considerar un bucle de validación donde sólo realizamos predicción y evaluación de la loss con el objetivo de detectar sobre-ajuste

Early Stopping

Detención del entrenamiento cuando la loss de validación no haya disminuido durante una cierta cantidad de épocas (paciencia). Se utiliza para evitar el sobreajuste

Importante

En cada época, verifica que si el error de validación representa un nuevo mínimo. Si es así puedes respaldar el modelo utilizado torch.save

A continuaciones se ponen en práctica estos criterios utilizando PyTorch. Por conveniencia se implementan las siguientes funciones

  • update_step: Recibe un minibatch y actualiza los parámetros

  • evaluation_step: Recibe un minibtach y evalúa el criterio de optimización

  • train_one_epoch: Realiza una época de entrenamiento. Se encarga de guardar el modelo si es el mejor encontrado hasta ese punto

def update_step(data, label):
    prediction = model(data)
    optimizer.zero_grad()
    loss = criterion(prediction, label)
    loss.backward()
    optimizer.step()
    return loss.item()

def evaluate_step(data, label):
    prediction = model(data)
    loss = criterion(prediction, label)
    return loss.item()

def train_one_epoch(epoch):    
    train_loss, valid_loss = 0.0, 0.0    
    for batchx, batchy in train_loader:
        train_loss += update_step(batchx, batchy)    
    for batchx, batchy in valid_loader:
        valid_loss += evaluate_step(batchx, batchy)
        
    # Guardar modelo si es el mejor hasta ahora
    global best_valid_loss
    if epoch % 10 == 0:
        if valid_loss < best_valid_loss:
            best_valid_loss = valid_loss
            torch.save({'epoca': epoch,
                        'model_state_dict': model.state_dict(),
                        'optimizer_state_dict': optimizer.state_dict(),
                        'loss': valid_loss}, 
                       'best_model.pt')
    
    return train_loss/len(train_loader.dataset), valid_loss/len(valid_loader.dataset)

Nota

Para entrenar en GPU sería necesario

  • que el modelo esté en memoría de GPU

  • enviar los datos y etiquetas a GPU antes de realizar los cálculos.

En este caso el modelo se entrena por 500 épocas

%%time

def circle_model(n_hidden: int):
    return nn.Sequential(nn.Linear(2, n_hidden), nn.Sigmoid(), nn.Linear(n_hidden, 2))

model = circle_model(10)
criterion = torch.nn.CrossEntropyLoss(reduction='sum')
optimizer = torch.optim.Adam(model.parameters(), lr=1e-2)

max_epochs, best_valid_loss = 500, np.inf
running_loss = np.zeros(shape=(max_epochs, 2))

for epoch in range(max_epochs):
    running_loss[epoch] = train_one_epoch(epoch)
    
fig, ax = plt.subplots(figsize=(7, 4), tight_layout=True)
ax.plot(running_loss[:, 0], label='Entrenamiento')
ax.plot(running_loss[:, 1], label='Validación')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.legend();
CPU times: user 2min 6s, sys: 86.8 ms, total: 2min 6s
Wall time: 32.1 s
<matplotlib.legend.Legend at 0x7fbef9e85eb0>
../../_images/bd17a4ebd097f133b18485ebe29f9d37ba95ba2fc5e3f942edcbf11446517065.png

Cargamos la información serializada del modelo utilizado torch.load

saved_model = torch.load('best_model.pt')

El mejor modelo se obtuvo en la época

saved_model['epoca']
70

Podemos asignar los mejores parámetros al modelo anterior con:

model = circle_model(10)
model.load_state_dict(saved_model['model_state_dict'])
<All keys matched successfully>

Podemos evaluar el mejor modelo en el conjunto de test para analizar su capacidad de generalización

from sklearn.metrics import classification_report

ytrue, ypred = [], []
for x, y_ in test_loader:
    ypred.append(nn.Softmax(dim=1)(model(x)).detach().argmax(dim=1))
    ytrue.append(y_)
ytrue, ypred = np.concatenate(ytrue), np.concatenate(ypred)

print(classification_report(ytrue, ypred))
              precision    recall  f1-score   support

           0       0.97      0.98      0.98       104
           1       0.98      0.97      0.97        96

    accuracy                           0.97       200
   macro avg       0.98      0.97      0.97       200
weighted avg       0.98      0.97      0.97       200

En este caso también podemos visualizar el resultado de predicción en el espacio de características:

x_test = np.arange(-2, 2, 0.05, dtype=np.float32)
x_test1, x_test2 = np.meshgrid(x_test, x_test)
x_test = torch.from_numpy(np.stack((x_test1.ravel(), x_test2.ravel())).T)

prob_test = nn.Softmax(dim=1)(model(x_test)).detach()[:, 1].reshape(x_test1.shape)

fig, ax = plt.subplots(figsize=(6, 5))
ax.pcolormesh(x_test1, x_test2, prob_test, cmap=plt.cm.RdBu_r)
for k, marker in enumerate(['x', 'o']):
    mask = y == k
    ax.scatter(X[mask, 0], X[mask, 1], c='k', s=20, marker=marker, alpha=0.5)
../../_images/1dc9ac3d0de4c57946321f4a4fe4990b11d095ad338d1fd59d77662e8a390345.png

El resultado en la capa de salida tiene forma circular. Sin embargo, si inspeccionemos la salida de la capa oculta, que en este caso tiene 10 neuronas:

fig, ax = plt.subplots(2, 5, figsize=(10, 4), tight_layout=True)

for ax_, layer, w in zip(ax.ravel(), model[0](x_test).T, model[2].weight.detach()[0]):
    ax_.pcolormesh(x_test1, x_test2, 
                   nn.Sigmoid()(layer).reshape(x_test1.shape).detach(), 
                   cmap=plt.cm.RdBu_r)
    ax_.set_title(f"{w.item():0.4f}")
../../_images/d191fcf83a9f23d08274c25f97ddbf1c1679806ee025758d950474a1ce85a38b.png

Nota

La capa de salida combina los hiperplanos de la capa oculta para formar un circulo. Sobre cada neurona oculta se muestra su peso en la capa de salida. Mientras más cercano a cero, menos relevante es su aporte.

13.5. Diagnósticos a partir de curvas de aprendizaje#

Podemos diagnosticar el entrenamiento observando la evolución de la función de costo

Importante

Siempre visualiza la loss en ambos conjuntos: entrenamiento y validación

Veamos algunos casos

Ambas curvas en descenso

Si las curvas se ven así:

epochs = np.arange(1, 500)
loss_train = (epochs)**(-1/10) + 0.01*np.random.randn(len(epochs))
loss_valid = (epochs)**(-1/10) + 0.01*np.random.randn(len(epochs)) + 0.1
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(epochs, loss_train, lw=2, label='entrenamiento')
ax.plot(epochs, loss_valid, lw=2, label='validación')
ax.set_ylim([0.5, 1.05])
plt.legend();
../../_images/481f4d3f976c2c8016aa9adf19f8087056e663956847d53d7a7186d5305c06f3.png

En este caso deberías continuar con el entrenamiento, pues no hay señal de convergencia

Sobreajuste temprano

Si las curvas se ven así:

epochs = np.arange(1, 500)
loss_train = (epochs)**(-1/10) + 0.01*np.random.randn(len(epochs))
loss_valid = (epochs)**(-1/10) + 0.00001*(epochs)**2 +0.01*np.random.randn(len(epochs)) + 0.01
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(epochs, loss_train, lw=2, label='entrenamiento')
ax.plot(epochs, loss_valid, lw=2, label='validación')
ax.set_ylim([0.5, 1.05])
plt.legend();
../../_images/7cc859bca1ff278a142fee78490493d4b08562f3d61f34d5dfcfaad2af403f59.png

Significa que tu modelo se ha sobreajustado, por ende:

  • Implementa un modelo más sencillo

  • Consigue más datos

  • Considera utilizar regularización

Error en el código o mal punto de partida

Si tus curvas se ven así:

epochs = np.arange(1, 500)
loss_train = 1.0 + 0.01*np.random.randn(len(epochs))
loss_valid = 1.0 + 0.01*np.random.randn(len(epochs)) + 0.01
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(epochs, loss_train, lw=2, label='entrenamiento')
ax.plot(epochs, loss_valid, lw=2, label='validación')
#ax.set_ylim([0.5, 1.05])
plt.legend();
../../_images/4ac45648950a114fbccbd0727c324b51d71b5ee4e627ddfc7dfa673bc1466d0f.png

Revisa que tu código no tenga bugs, causas comunes de error:

  • el modelo no esté recibiendo adecuadamente los datos y/o la etiqueta

  • los datos tiene un rango muy grande y los gradientes explotan: normaliza los datos

  • la función de costo no es la adecuada para el problema

  • la taza de aprendizaje es demasiado alta

También puede deberse a una muy mala inicialización aleatoria, reinicia el entrenamiento para comprobar si es el caso