Aprenda Pytorch: entrene sus primeros modelos de aprendizaje profundo paso a paso

Aquí está mi historia: recientemente di una clase de tutoría universitaria a estudiantes de maestría sobre aprendizaje profundo. En concreto, se trataba de entrenar su primer perceptrón multicapa (MLP) en Pytorch. Literalmente me sorprendieron sus preguntas como principiantes en el campo. Al mismo tiempo, resoné con sus luchas y reflexioné sobre ser un principiante. De eso se trata esta entrada de blog.

Si está acostumbrado a numpy, tensorflow o si desea profundizar su comprensión en el aprendizaje profundo, con un tutorial práctico de codificación, súbase.

Entrenaremos nuestro primer modelo llamado Multi-Layer Perceptron (MLP) en pytorch mientras explicamos las opciones de diseño. El código está disponible en github.

¿Deberíamos empezar?

Importaciones

import torch

import torch.nn as nn

import torch.nn.functional as F

import torch.optim as optim

import torchvision

import torchvision.transforms as transforms

import numpy as np

import matplotlib.pyplot as plt

Él torch.nn El paquete contiene todas las capas necesarias para entrenar nuestra red neuronal. Primero se deben crear instancias de las capas y luego llamarlas usando sus instancias. Durante la inicialización especificamos todos nuestros componentes entrenables. Los pesos normalmente viven en una clase que hereda el torch.nn.Module clase. Las alternativas incluyen la torch.nn.Sequential o el torch.nn.ModuleList clase, que también heredan la torch.nn.Module clase. Las clases de capas generalmente comienzan con una letra mayúscula, incluso si no tienen ningún parámetro entrenable, así que siéntase como si las declarara así:

Él torch.nn.functional contiene todas las funciones que se pueden llamar directamente sin inicialización previa. Mayoríatorch.nn Los módulos tienen su mapeo correspondiente en un módulo funcional como:

Un ejemplo muy útil de una función que uso a menudo es la función de normalización:

Dispositivo: GPU

Los estudiantes desprecian usar la GPU. No ven ninguna razón para hacerlo, ya que solo están usando pequeños conjuntos de datos de juguetes. Les aconsejo que piensen en términos de ampliar los modelos y los datos, pero puedo ver que no es tan obvio al principio. Mi solución fue asignarlos para entrenar un resnet18 en un conjunto de datos de imágenes de 100K en Google Colab.

device = 'cuda:0' if torch.cuda.is_available() else 'cpu'

print('device:', device)

Hay una y solo una razón por la que usamos la GPU: la velocidad. El mismo modelo se puede entrenar mucho más rápido en una GPU de gama alta.

No obstante, queremos tener la opción de cambiar a la ejecución de la CPU de nuestro cuaderno/secuencia de comandos, declarando una variable de «dispositivo» en la parte superior.

¿Por qué? Bien para depuración!

Es bastante común tener errores relacionados con la GPU, que en realidad son errores lógicos simples, pero debido a que el código se ejecuta en la GPU, pytorch no puede rastrear el error correctamente. Los ejemplos pueden incluir errores de corte, como asignar un tensor de forma incorrecta a un corte de otro tensor.

La solución es ejecutar el código en la CPU. Probablemente obtendrá un mensaje de error más preciso.

Ejemplo de mensaje de GPU:

RuntimeError: CUDA error: device-side assert triggered

Los errores del kernel de CUDA pueden informarse de forma asíncrona en alguna otra llamada de API, por lo que el seguimiento de la pila a continuación puede ser incorrecto.

Para la depuración considere pasar CUDA_LAUNCH_BLOCKING=1.

Ejemplo de mensaje de la CPU:

Index 256 is out of bounds

Transformaciones de imagen

Usaremos un conjunto de datos de imagen llamado CIFAR10, por lo que necesitaremos especificar cómo se alimentarán los datos en la red.

transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5))])

Por lo general, las imágenes se leen desde imágenes de almohadas de memoria o como matrices numpy. Por lo tanto, necesitamos convertirlos en tensores. No entraré en detalles sobre qué tensores pytorch aquí. Lo importante es saber que podemos rastrear gradientes de un tensor y moverlos en la GPU. Las imágenes Numpy y Pillow no brindan compatibilidad con GPU.

La normalización de entrada trae los valores alrededor de cero. Se proporciona un valor para los medios y el estándar para cada canal. Si proporciona solo un valor para la media o estándar, pytorch es lo suficientemente inteligente como para copiar el valor para todos los canales (transforms.Normalize(mean=0.5, std=0.5) ).

Xnorteormetro=Xmσ x_ = frac

Las imágenes están en el rango de

[0,1][0,1]

. Después de restar 0.5 y dividir por 0.5 el nuevo rango será

[1,1][-1, 1]

.

Suponiendo que los pesos también se inicialicen alrededor de cero, eso es bastante beneficioso. En la práctica, hace que el entrenamiento sea mucho más fácil de optimizar. En el aprendizaje profundo, nos encanta tener nuestros valores alrededor de cero porque los gradientes son mucho más estables (predecibles) en este rango.

Por qué necesitamos la normalización de entrada

Si las imágenes estuvieran en el

[0,255][0, 255]

rango que interrumpiría el entrenamiento mucho más severamente. ¿Por qué? Suponiendo que los pesos se inicializaron alrededor de 0, la salida de la capa estaría dominada principalmente por valores grandes, de ahí las intensidades de imagen grandes. Eso significa que los pesos solo se verán influenciados por los valores de entrada grandes.

Para convencerte, escribí un pequeño guión para eso:

x = torch.tensor([1., 1., 255.])

w = torch.tensor([0.1, 0.1, 0.1], requires_grad=True)

target = torch.tensor(10.0)

for i in range(100):

with torch.no_grad():

w.grad = torch.zeros(3)

l = target - (x*w).sum()

l.backward()

w = w - 0.01 * w.grad

print(f"Final weights ")

Qué salidas:

Final weights [ 0.11 0.11 2.65]

En esencia, solo cambia el peso que corresponde al valor de entrada grande.

La clase de conjunto de datos de imagen CIFAR10

trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)

valset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)

Pytorch proporciona un par de conjuntos de datos de juguetes para la experimentación. Específicamente, CIFAR10 tiene imágenes RGB de entrenamiento de 50K de tamaño 32×32 y muestras de prueba de 10K. Al especificar la variable booleana de tren, obtenemos el tren y la prueba, respectivamente. Los datos se descargarán en la ruta raíz. Las transformaciones especificadas se aplicarán al obtener los datos. Por ahora solo estamos ajustando la escala de las intensidades de la imagen para

[1,1][-1, 1]

.

Las 3 divisiones de datos en el aprendizaje automático

Por lo general, tenemos 3 divisiones de datos: el tren, la validación y el conjunto de prueba. La principal diferencia entre el conjunto de validación y el de prueba es que el conjunto de prueba se verá solo una vez. Las métricas de rendimiento de la validación serían fiables para realizar un seguimiento del rendimiento durante el entrenamiento, aunque los parámetros del modelo no se optimizarán directamente a partir de los datos de validación. Aún así, usamos los datos de validación para elegir hiperparámetros como la tasa de aprendizaje, el tamaño del lote y la disminución del peso (también conocida como regularización L2).

¿Cómo acceder a estos datos?

Visualice imágenes y comprenda las representaciones de las etiquetas

def imshow(img, i, mean, std):

unnormalize = transforms.Normalize((-mean / std), (1.0 / std))

plt.subplot(1, 10 ,i+1)

npimg = unnormalize(img).numpy()

plt.imshow(np.transpose(npimg, (1, 2, 0)))

img, label = trainset[0]

print(f"Images have a shape of ")

print(f"There are with labels: ")

plt.figure(figsize = (40,20))

for i in range(10):

imshow(trainset[i][0], i, mean=0.5, std=0.5)

print(f"Label which corresponds to will be converted to one-hot encoding by F.one_hot(torch.tensor(label),10)) as: ", F.one_hot(torch.tensor(label),10))

Aquí está la salida:

Images have a shape of torch.Size([3, 32, 32])

There are 10 with labels: ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']


Cifar10-imágenes


Imágenes de ejemplo del conjunto de datos Cifar10

A cada etiqueta de imagen se le asignará una identificación de clase:

id=0 → airplane

id=1 → automobile

id=2 → bird

. . .

Los índices de clase se convertirán en codificaciones one-hot. Puede hacer esto manualmente una vez para estar 100% seguro de lo que significa llamando:

Label 6 which corresponds to frog will be converted to one-hot encoding by F.one_hot(torch.tensor(label),10)) as: tensor([0, 0, 0, 0, 0, 0, 1, 0, 0, 0])

La clase del cargador de datos

train_loader = torch.utils.data.DataLoader(trainset, batch_size=256, shuffle=True)

val_loader = torch.utils.data.DataLoader(valset, batch_size=256, shuffle=False)

La práctica estándar es utilizar solo un lote de imágenes en lugar de todo el conjunto de datos en cada paso. Es por eso que la clase del cargador de datos apila muchas imágenes con sus etiquetas correspondientes en un lote en cada paso.

Es fundamental saber que los datos de entrenamiento deben mezclarse aleatoriamente.

De esta forma, los índices de datos se barajan aleatoriamente en cada época. Por lo tanto, cada lote de imágenes es representativo de la distribución de datos de todo el conjunto de datos. El aprendizaje automático se basa en gran medida en la suposición iid, lo que significa datos de muestra independientes e idénticamente distribuidos. Esto implica que el conjunto de validación y prueba debe muestrearse de la misma distribución que el conjunto de trenes.

Resumamos la parte del conjunto de datos/cargador de datos:

print("List of label names are:", trainset.classes)

print("Total training images:", len(trainset))

img, label = trainset[0]

print(f"Example image with shape , label , which is a ")

print(f'The dataloader contains batches of batch size and images')

imgs_batch , labels_batch = next(iter(train_loader))

print(f"A batch of images has shape , labels ")

La salida del código anterior es:

List of label names are: ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']

Total training images: 50000

Example image with shape torch.Size([3, 32, 32]), label 6, which is a frog

The dataloader contains 196 batches of batch size 256 and 50000 images

A batch of images has shape torch.Size([256, 3, 32, 32]), labels torch.Size([256])

Construyendo un MLP de tamaño variable

class MLP(nn.Module):

def __init__(self, in_channels, num_classes, hidden_sizes=[64]):

super(MLP, self).__init__()

assert len(hidden_sizes) >= 1 , "specify at least one hidden layer"

layers = nn.ModuleList()

layer_sizes = [in_channels] + hidden_sizes

for dim_in, dim_out in zip(layer_sizes[:-1], layer_sizes[1:]):

layers.append(nn.Linear(dim_in, dim_out))

layers.append(nn.ReLU())

self.layers = nn.Sequential(*layers)

self.out_layer = nn.Linear(hidden_sizes[-1], num_classes)

def forward(self, x):

out = x.view(x.shape[0], -1)

out = self.layers(out)

out = self.out_layer(out)

return out

Ya que heredamos el torch.nn.Module clase, tenemos que definir la init y forward función. init tiene todas las capas añadidas en el nn.ModuleList(). La lista de módulos es solo una lista vacía que es consciente de que todos los elementos de la lista son módulos del torch.nn paquete. Luego ponemos todos los elementos de la lista para torch.nn.Sequential. el asterisco

torch.nn.Sequential( nn.Linear(1,2), nn.ReLU(), nn.Linear(2,5), ... )

indica que las capas se pasarán ya que cada capa es una entrada de la función como: torch.nn.Sequential Cuando no hay conexiones de salto dentro de un bloque de capas y solo hay una entrada y una salida, podemos simplemente pasar todo en el

clase. Como resultado, no tendremos que especificar repetidamente que la salida de la capa anterior es la entrada de la siguiente.

y = self.layers(x)

Durante el reenvío, solo lo llamaremos una vez:

Eso hace que el código sea mucho más compacto y fácil de leer. Incluso si el modelo incluye caminos de avance alternativos formados por conexiones de salto, la parte secuencial se puede empaquetar muy bien así.

def validate(model, val_loader, device):

model.eval()

criterion = nn.CrossEntropyLoss()

correct = 0

loss_step = []

with torch.no_grad():

for inp_data, labels in val_loader:

labels = labels.view(labels.shape[0]).to(device)

inp_data = inp_data.to(device)

outputs = model(inp_data)

val_loss = criterion(outputs, labels)

predicted = torch.argmax(outputs, dim=1)

correct += (predicted == labels).sum()

loss_step.append(val_loss.item())

val_acc = (100 * correct / len(val_loader.dataset)).cpu().numpy()

val_loss_epoch = torch.tensor(loss_step).mean().numpy()

return val_acc , val_loss_epoch

Escribiendo el ciclo de validación

Suponiendo que tenemos una tarea de clasificación, nuestra pérdida será la entropía cruzada categórica. Si desea profundizar en por qué usamos esta función de pérdida, eche un vistazo a la estimación de máxima verosimilitud.

Durante el tiempo de validación/prueba, debemos asegurarnos de 2 cosas. En primer lugar, no se debe realizar un seguimiento de los gradientes, ya que no estamos actualizando los parámetros en esta etapa. En segundo lugar, el modelo se comporta como lo haría durante el tiempo de prueba. La deserción es un gran ejemplo: durante el entrenamiento hacemos ceropag

pag

por ciento de las activaciones, mientras que en el momento de la prueba se comporta como una función identidad (y=Xy =x

).

  • with torch.no_grad(): se puede usar para asegurarnos de que no estamos rastreando gradientes.

  • model.eval() cambia automáticamente el comportamiento de nuestras capas al comportamiento de prueba. Necesitamos llamar a model.train() para deshacer su efecto.

A continuación, necesitamos mover los datos a la GPU. Seguimos usando el dispositivo variable para poder cambiar entre la ejecución de GPU y CPU.

  • outputs = model(inputs) llama a la función de reenvío y calcula el no normalizado predicción de salida. La gente generalmente se refiere a las predicciones no normalizadas del modelo como logits. Asegúrese de no perderse en la jungla de jerga.

Los logits se normalizarán con softmax y se calculará la pérdida. Durante la misma llamada (criterion(outputs, labels)) las etiquetas de destino se convierten en una codificación activa.

Aquí hay algo que confunde a muchos estudiantes: cómo calcular la precisión del modelo. Solo hemos visto cómo calcular la pérdida de entropía cruzada. Bueno, la respuesta es bastante simple: toma el argmax de los logits. Esto nos da la predicción. Luego, comparamos cuántas de las predicciones son iguales a los objetivos.

El modelo aprenderá a asignar mayores probabilidades a la clase objetivo. Pero para calcular la precisión necesitamos ver cuántas de las probabilidades máximas son las correctas. Para eso se puede usar predicted = torch.max(outputs, dim=1)[1] o predicted = torch.argmax(outputs, dim=1). torch.max() devuelve una tupla de los valores e índices máximos y solo nos interesan los últimos.

Otra cosa interesante es la value.item() llamada. Este método solo se puede usar para valores escalares como las funciones de pérdida. Para tensores solemos hacer algo como t.detach().cpu().numpy(). Separar se asegura de que no se realice un seguimiento de los gradientes. Luego lo volvemos a mover a la CPU y lo convertimos en una matriz numpy.

Finalmente note la diferencia entre len(val_loader) y len(val_loader.dataset). len(val_loader) devuelve el número total de lotes en los que se dividió el conjunto de datos. len(val_loader.dataset) es el número de muestras de datos.

Escribiendo el ciclo de entrenamiento

def train_one_epoch(model, optimizer, train_loader, device):

model.train()

criterion = nn.CrossEntropyLoss()

loss_step = []

correct, total = 0, 0

for (inp_data, labels) in train_loader:

labels = labels.view(labels.shape[0]).to(device)

inp_data = inp_data.to(device)

outputs = model(inp_data)

loss = criterion(outputs, labels)

optimizer.zero_grad()

loss.backward()

optimizer.step()

with torch.no_grad():

_, predicted = torch.max(outputs, 1)

total += labels.size(0)

correct += (predicted == labels).sum()

loss_step.append(loss.item())

loss_curr_epoch = np.mean(loss_step)

train_acc = (100 * correct / total).cpu()

return loss_curr_epoch, train_acc

def train(model, optimizer, num_epochs, train_loader, val_loader, device):

best_val_loss = 1000

best_val_acc = 0

model = model.to(device)

dict_log =

pbar = tqdm(range(num_epochs))

for epoch in pbar:

loss_curr_epoch, train_acc = train_one_epoch(model, optimizer, train_loader, device)

val_acc, val_loss = validation(model, val_loader, device)

msg = (f'Ep /: Accuracy: Train: Val:

|| Loss: Train Val ')

pbar.set_description(msg)

dict_log["train_acc_epoch"].append(train_acc)

dict_log["val_acc_epoch"].append(val_acc)

dict_log["loss_epoch"].append(loss_curr_epoch)

dict_log["val_loss"].append(val_loss)

return dict_log

  • model.train() cambia las capas (p. ej. abandono, norma de lote) a su comportamiento de entrenamiento.

La principal diferencia es que la regla de actualización y la propagación hacia atrás entran en juego aquí a través de:

loss = criterion(outputs, labels)

optimizer.zero_grad()

loss.backward()

optimizer.step()

Primero, la pérdida siempre debe ser un escalar. Segundo, cada parámetro entrenable tiene un atributo llamado grad. Este atributo es un tensor de la misma forma que el tensor donde se almacenan los gradientes. Llamando optimizer.zero_grad() revisamos todos los parámetros y reemplazamos los valores de gradiente del tensor a cero. En pseudocódigo:

for param in parameters:

param.grad = 0

¿Por qué? Debido a que los nuevos gradientes deben calcularse durante loss.backward(). Durante una llamada hacia atrás, los gradientes se calculan y agregado a los valores previamente existentes.

for param, new_grad in zip(parameters, new_gradients):

param.grad = param.grad + new_grad

Eso da mucha flexibilidad con respecto a la frecuencia con la que podemos actualizar nuestro modelo. Esto sería beneficioso, por ejemplo, para entrenar con un tamaño de lote más grande de lo que nuestro hardware nos permite, una técnica llamada acumulación de gradiente.

En muchos casos necesitamos actualizar los parámetros en cada paso. Por lo tanto, los gradientes deben guardarse mientras se eliminan los valores del lote anterior.

Calcular los gradientes no es actualizar los parámetros. Tenemos que pasar una vez más por todos los parámetros del modelo y aplicar la regla de actualización con optimizer.step() como:

for param in parameters:

param = param - lr * param.grad

El resto es igual que en la función de validación. Tanto las pérdidas como las precisiones por época se guardan en un diccionario para trazarlas más adelante.

Poniendolo todo junto

in_channels = 3 * 32 * 32

num_classes = 10

hidden_sizes = [128]

epochs = 50

lr = 1e-3

momentum = 0.9

wd = 1e-4

device = "cuda"

model = MLP(in_channels, num_classes, hidden_sizes).to(device)

optimizer = optim.SGD(model.parameters(), lr=lr, momentum=momentum, weight_decay=wd)

dict_log = train(model, optimizer, epochs, train_loader, val_loader, device)

Mejor precisión de validación: 53,52% en CIFAR10 utilizando un MLP de dos capas.


tren-estadísticas-mlp-cifar10


Pérdidas y precisiones durante el entrenamiento.

Opciones de diseño

Entonces, ¿cómo se diseña y entrena una red neuronal MLP?

  • Tamaño del lote: los tamaños de lote muy pequeños, generalmente

  • Independiente e idénticamente distribuido (IID): lo ideal es que los lotes sigan la suposición de IID, así que asegúrese de mezclar siempre sus datos de entrenamiento, a menos que tenga una razón muy específica para no hacerlo.

  • Siempre ir de opciones de diseño simples a complejas al diseñar modelos. En términos de arquitectura y tamaño del modelo, esto se traduce en comenzar con una red pequeña. Vaya a lo grande si cree que el rendimiento se satura. ¿Por qué? Porque un modelo pequeño ya puede funcionar lo suficientemente bien para su caso de uso. Mientras tanto, ahorra mucho tiempo, ya que los modelos más pequeños pueden entrenar más rápido. Imagine que en un escenario de la vida real necesitará entrenar su modelo varias veces para decidir la mejor configuración. O incluso volver a entrenarlo a medida que haya más datos disponibles.

  • Siempre mezcla tus datos de entrenamiento. No mezcle el conjunto de validación y prueba.

  • Diseñar implementaciones de modelos flexibles. A pesar de que comenzamos de a poco y usamos solo una capa oculta, no hay nada que nos impida crecer. La implementación de nuestro modelo nos permite tener tantas capas como queramos. En la práctica, pocas veces he visto un MLP con más de 3 capas y más de 4096 dimensiones.

  • Aumente las dimensiones del modelo en múltiplos de 32. El espacio de optimización es increíblemente grande y toma decisiones acertadas, como tener en cuenta el hardware (GPU).

  • Añadir regularización después identifica sobreajuste y no antes.

  • Si no tiene idea del tamaño del modelo, comience sobreajustando un pequeño subconjunto de datos sin aumentos (consulte antorcha.utils.datos.Subconjunto).

Para convencerte aún más, aquí es un tutorial en línea que alguien usó 3 capas ocultas en CIFAR10 y logró la misma precisión de validación que nosotros (~53%).

Conclusión y adónde ir después

¿Es nuestro clasificador lo suficientemente bueno?

¡Bueno, sí! En comparación con una suposición aleatoria (1/10), podemos obtener la clase correcta en más del 50 %.

¿Nuestro clasificador es bueno en comparación con un humano?

No, el reconocimiento de imágenes a nivel humano en este conjunto de datos sería fácilmente superior al 90 %.

¿Qué le falta a nuestro clasificador?

Lo descubrirás en el siguiente tutorial.

Tenga en cuenta que el código completo es disponible en github. ¡Manténganse al tanto!

En este punto, debe implementar sus propios modelos en nuevos conjuntos de datos. Un ejemplo: intente mejorar su clasificador aún más agregando regularización para evitar el sobreajuste. Publica tus resultados en las redes sociales y etiquétanos.

Finalmente, si siente que necesita un proyecto estructurado para ensuciarse las manos, considere estos recursos adicionales:

O puedes probar nuestro propio curso: Introducción al aprendizaje profundo y las redes neuronales

Libro Aprendizaje Profundo en Producción 📖

Aprenda a crear, entrenar, implementar, escalar y mantener modelos de aprendizaje profundo. Comprenda la infraestructura de ML y MLOps con ejemplos prácticos.

Aprende más

* Divulgación: tenga en cuenta que algunos de los enlaces anteriores pueden ser enlaces de afiliados y, sin costo adicional para usted, ganaremos una comisión si decide realizar una compra después de hacer clic.

Fuente del artículo

Deja un comentario