<jupyter_start><jupyter_text>Optimización de hiperparámetrosEn esta notebook continuaremos utilizando el mismo conjunto de datos que en la notebook anterior, y trataremos de construir la mejor red neuronal para resolver esa tarea de clasificación<jupyter_code>%matplotlib inline
import matplotlib
import matplotlib.pyplot as plt
import numpy
import pandas
import seaborn
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms

from sklearn import metrics
from tqdm.notebook import tqdm
torch.__version__<jupyter_output><empty_output><jupyter_text>Cargando los datos del CIFAR10- El conjunto de datos a utilizar es el **[CIFAR-10](https://www.cs.toronto.edu/~kriz/cifar.html)**.- Es un conjunto estándar para hacer *reconocimiento de imágenes*.- Buscamos entrenar un clasificador que reconozca que está siendo mostrado en la imagen de un conjunto fijo de categorías posibles.- El CIFAR-10 está compuesto por imágenes a color de 32x32 píxeles representadas como tensores de 32x32x3, donde la tercera dimensión representa el *channel* (i.e. el color en RGB). Los valores representan la intensidad de cada color en dicho pixel.- La salida son 10 clases: avión, auto, pájaro, gato, siervo, perro, sapo, caballo, bote, camión.- La librería `torchvision` nos facilita obtener el conjunto de datos.<jupyter_code>CIFAR_CLASSES = ('plane', 'car', 'bird', 'cat', 'deer', 
                 'dog', 'frog', 'horse', 'ship', 'truck')
BATCH_SIZE = 128  # For mini-batch gradient descent
EPOCHS = 2

# This is to normalize from PILImage to Torch Tensors in range [-1, 1]
transform = transforms.Compose(
    [transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])

trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=BATCH_SIZE,
                                          shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False,
                                       download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=BATCH_SIZE,
                                         shuffle=False, num_workers=2)<jupyter_output>Files already downloaded and verified
Files already downloaded and verified<jupyter_text>Definiremos las funciones de entrenamiento y evaluación por separado para ayudar a la legibilidad de los experimentos<jupyter_code>def train(model, optimizer, loss_function, trainloader, epochs, use_tqdm=True):
    model.train()  # Tell the model to set itself to "train" mode.
    for epoch in range(epochs):  # loop over the dataset multiple times
        running_loss = 0.0
        pbar = tqdm(trainloader) if use_tqdm else trainloader
        for step, data in enumerate(pbar, 1):
            # get the inputs; data is a list of [inputs, labels]
            inputs, labels = data

            # zero the parameter gradients
            optimizer.zero_grad()

            # forward + backward + optimize
            outputs = model(inputs.view(inputs.shape[0], -1))
            loss = loss_function(outputs, labels)
            loss.backward()
            optimizer.step()

            # print statistics
            running_loss += loss.item()
            if use_tqdm and step > 0 and step % 50 == 0:    # print every 50 mini-batches
                pbar.set_description(f"[{epoch + 1}, {step}] loss: {running_loss / step:.4g}")<jupyter_output><empty_output><jupyter_text>A la hora de evaluar un modelo sistemáticamente, debemos elegir una métrica principal y, opcionalmente, varias secundarias. En el caso del dataset cifar, en donde las clases están balanceadas adecuadamente, utilizaremos el accuracy como métrica principal y el f1-score como métrica secundaria.Además de ello, será útil poder aplicar la función de evaluación sobre distintos subconjuntos del dataset.<jupyter_code>def eval(model, dataloader, prefix='test_', use_tqdm=True):
    model.eval();  # Activate evaluation mode
    y_true = []
    y_pred = []
    with torch.no_grad():
        pbar = tqdm(dataloader) if use_tqdm else dataloader
        for data in pbar:
            inputs, labels = data
            outputs = model(inputs.view(inputs.shape[0], -1))
            _, predicted = torch.max(outputs.data, 1)
            y_true.extend(labels.numpy())
            y_pred.extend(predicted.numpy())

    return {
        prefix + 'accuracy': metrics.accuracy_score(y_true, y_pred),
        prefix + 'f1': metrics.f1_score(y_true, y_pred, average='macro'),
    }<jupyter_output><empty_output><jupyter_text>Modelo Básico<jupyter_code>import torch.nn.functional as F

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden_layer1 = nn.Linear(32 * 32 * 3, 512)
        self.hidden_layer2 = nn.Linear(512, 256)
        self.output_layer = nn.Linear(256, 10)
    
    def forward(self, x: torch.Tensor):
        x = self.hidden_layer1(x)  # Go through hidden layer
        x = F.relu(x)  # Activation Function
        x = self.hidden_layer2(x)
        x = F.relu(x)
        x = self.output_layer(x)  # Output Layer
        return x

model = MLP()
print(model)<jupyter_output>MLP(
  (hidden_layer1): Linear(in_features=3072, out_features=512, bias=True)
  (hidden_layer2): Linear(in_features=512, out_features=256, bias=True)
  (output_layer): Linear(in_features=256, out_features=10, bias=True)
)<jupyter_text>Búsqueda de hiperparámterosAntes de comenzar a experimentar con distintos tipos de redes, debemos ser organizados a la hora de guardar nuestros resultados. Existen frameworks que ayudan con este proceso, pero para concentrarnos mejor en la teoría, utilizaremos un método muy simple guardando hiperparámetros y resultados en un diccionario.<jupyter_code>experiments = []
EPOCHS = 5
def run_experiment(model, epochs, trainloader, testloader):
    loss_function = nn.CrossEntropyLoss()
    lr = 0.001
    optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
    train(model, optimizer, loss_function, trainloader, epochs)
    test_results = eval(model, testloader)

    experiment = {
        'arquitecture': str(model), 'loss': str(loss_function),
        'lr': lr, 'epochs': epochs,
    }
    experiment.update(test_results)
    return experiment
model = MLP()  # It's important to re-define the model to avoid keep training an earlier version
experiment = run_experiment(model, EPOCHS, trainloader, testloader)
experiments.append(experiment)
print('Accuracy:', experiment['test_accuracy'], 'F1:',  experiment['test_f1'])<jupyter_output>Accuracy: 0.4337 F1: 0.4266821720574705<jupyter_text>Tamaño y número de capas- Las capas de una red nos determinan su poder de representación.- Mientras más neuronas tenga una red, tendrá potencialmente la capacidad de representar problemas con más causas latentes, como por ejemplo el lenguaje. Sin embargo, significa que necesitaremos más datos para poder entrenarla.- Mientras más profunda sea la red, tendrá potencialmente la capacidad de representar problemas donde las relaciones entre las causas latentes son más complicadas, como en el caso de las imágenes. Sin embargo, será más difícil de entrenar y propagar los gradientes adecuadamente.  Funciones de Activación- Una red neuronal con activación lineal no tiene más poder de representación que un algoritmo lineal.- Para expresar no linearidad en la red neuronal se necesitan funciones no lineales de activación.- Las funciones de activación más comunes (para utilizar en las capas intermedias) son la *sigmoide* (o logística), *tanh* (tangente hiperbólica) y *ReLU*. Hoy en día, por sus propiedades, ReLU suele ser la más utilizada [1].- La función de activación *Softmax* suele utilizarse al final de una red de multiples clases, tiene como objetivo transformar un vector de *scores* en un vector probabilístico.    - Si bien solía ser muy común en las primeras versiones de muchos frameworks de deep learning, con el tiempo se dejó de utilizar y se incluye directamente en la función de costo de manera transparente al usuario.- En PyTorch la mayoría de estas funciones se definen en el módulo [`torch.nn.functional`](https://pytorch.org/docs/stable/nn.functional.html). Fuente: https://ujjwalkarn.me/2016/08/09/quick-intro-neural-networks/ Veamos qué resultado nos da con un modelo con menos capas que el original.<jupyter_code>class SimpleMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden_layer1 = nn.Linear(32 * 32 * 3, 256)
        self.output_layer = nn.Linear(256, 10)
    
    def forward(self, x: torch.Tensor):
        x = self.hidden_layer1(x)  # Go through hidden layer
        x = torch.tanh(x)  # Activation Function
        x = self.output_layer(x)  # Output Layer
        return x

experiment = run_experiment(SimpleMLP(), EPOCHS, trainloader, testloader)
experiments.append(experiment)
print('Accuracy:', experiment['test_accuracy'], 'F1:',  experiment['test_f1'])
class BigMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden_layer1 = nn.Linear(32 * 32 * 3, 256)
        self.hidden_layer2 = nn.Linear(256, 1024)
        self.hidden_layer3 = nn.Linear(1024, 2048)
        self.hidden_layer4 = nn.Linear(2048, 512)
        self.output_layer = nn.Linear(512, 10)
    
    def forward(self, x: torch.Tensor):
        x = torch.tanh(self.hidden_layer1(x))
        x = torch.tanh(self.hidden_layer2(x))
        x = torch.tanh(self.hidden_layer3(x))
        x = torch.tanh(self.hidden_layer4(x))
        x = self.output_layer(x)  # Output Layer
        return x

experiment = run_experiment(BigMLP(), EPOCHS, trainloader, testloader)
experiments.append(experiment)
print('Accuracy:', experiment['test_accuracy'], 'F1:',  experiment['test_f1'])<jupyter_output><empty_output><jupyter_text>Podemos saber la cantidad de parámetros entrenables que tiene cada modelo:<jupyter_code>print("Trainable parameters in SimpleMLP", sum(p.numel() for p in SimpleMLP().parameters() if p.requires_grad))
print("Trainable parameters in MLP", sum(p.numel() for p in MLP().parameters() if p.requires_grad))
print("Trainable parameters in BigMLP", sum(p.numel() for p in BigMLP().parameters() if p.requires_grad))<jupyter_output>Trainable parameters in SimpleMLP 789258
Trainable parameters in MLP 1707274
Trainable parameters in BigMLP 4203274<jupyter_text>Batch sizeUno de los hiperparámetros más importantes de una red es el tamaño del batch. Determina:* La cantidad de memoria que requiere el modelo para entrenarse.    - Si ocurre un error OOM (out of memory), se puede reducir el tamaño del batch.* El tiempo de entrenamiento.    - Si se utiliza un GPU, todos los elementos del batch son procesados en paralelo. Adicionalmente, el paso de cómputo del CPU al GPU consume recursos. Por lo tanto, si el batch size es grande, hay menos batches por época, mayor paralelismo y menos cambios de dispositivo.* La "suavidad" de las actualizaciones de los pesos.    - La dirección de los gradientes utilizada en cada actualización se calcula en base al promedio de los errores del batch.    - Si el batch es muy pequeño y el conjunto de datos no es suave, los gradientes pueden ser muy erráticos.<jupyter_code># Ejercicio: explorar distintos tamaños de batch para cada modelo.<jupyter_output><empty_output><jupyter_text>Learning Rate y optimizer- El *learning rate* determina con qué "velocidad" aprende una red.    - Si el lr es muy bajo, la red aprende muy lento, ya que los pesos se optimizan con deltas muy pequeños.    - Si el lr es muy alto, la red puede provocar que la red diverja.    - Los mejores resultados se consiguen cambiando el mismo del lr durante el entrenamiento.- El algoritmo de optimización determina cómo se actualizan los pesos de la red y el learning rate.    - Si se utiliza el optimizador *SGD* (stochastic gradient descent) es recomendable asignar valores al `mometum`, de lo contrario, utilizará el mismo lr durante todo el entrenamiento y suele ser muy lento.    - Los optimizadores más genéricos y que mejor funcionan son *Adam* y *RMSProp*. Para poder evaluar qué combinación de optimizador/lr es la mejor, tendremos que monitorear el valor de la función de loss en cada época.<jupyter_code>def run_experiment2(model, epochs, trainloader, testloader,
                   optimizer_class=optim.SGD, lr=0.001, use_tqdm=False):
    loss_function = nn.CrossEntropyLoss()
    optimizer = optimizer_class(model.parameters(), lr=lr)
    train(model, optimizer, loss_function, trainloader, epochs,
          use_tqdm=use_tqdm)
    test_results = eval(model, testloader, use_tqdm=use_tqdm)

    experiment = {
        'arquitecture': str(model), 'loss': str(loss_function),
        'epochs': epochs, 'lr': lr, 'optimizer': str(optimizer_class)
    }
    experiment.update(test_results)
    return experiment
op_experiments = []
# This is going to take some time!
for optimizer_class in [optim.RMSprop, optim.Adagrad, optim.Adam]:
    for lr in [0.0001, 0.001, 0.005, 0.01]:
        print("Exploring ", optimizer_class, lr)
        experiment = run_experiment2(MLP(), EPOCHS, trainloader, testloader,
                                     optimizer_class=optimizer_class, lr=lr)
        op_experiments.append(experiment)
for experiment in op_experiments:
  print(experiment['optimizer'], experiment['lr'], experiment['test_accuracy'], experiment['test_f1'])
data = pandas.DataFrame(data=op_experiments)
data['optimizer'] = data['optimizer'].apply(lambda x: x.split(".")[-1].strip("'>"))
seaborn.pointplot(
    data=data, x='lr', y='test_accuracy', hue='optimizer',)<jupyter_output><empty_output><jupyter_text>Overfitting y underfittingLos modelos de aprendizaje automático tienen dos problemas comunes: puede suceder que sobreajusten a los datos de entrenamiento (overfitting) y no generalicen a datos no vistos o, por el contrario, que no tengan la capacidad de aprender el conjunto de entrenamiento(underfitting).* En caso de overfitting:    * Reducir el número de parámetros de la red    * Recolectar más datos o datos más diversos    * Regularizar la red* En caso de underfitting:    * Decrementar la regularización de la red.    * Cambiar la arquitectura de la red, aumentando el número de parámetros o buscando nuevas combinaciones de capas (recurrencias, convoluciones, etc.)Para diagnosticar estos problemas es necesario examinar las curvas de aprendizaje del modelo en el conjunto de entrenamiento y en un conjunto de datos no visto (validación).<jupyter_code>def train_and_eval(model, optimizer, loss_function, trainloader, epochs,
                   valloader, use_tqdm=True):
    history = {'train_loss': [], 'val_loss': [], 'val_accuracy': []}
    for epoch in range(epochs):  # loop over the dataset multiple times
        model.train()
        running_loss = 0.0
        pbar = tqdm(trainloader) if use_tqdm else trainloader
        for step, data in enumerate(pbar, 1):
            # get the inputs; data is a list of [inputs, labels]
            inputs, labels = data
            # zero the parameter gradients
            optimizer.zero_grad()
            # forward + backward + optimize
            outputs = model(inputs.view(inputs.shape[0], -1))
            loss = loss_function(outputs, labels)
            loss.backward()
            optimizer.step()

            # print statistics
            running_loss += loss.item()
            if use_tqdm and step > 0 and step % 50 == 0:
                pbar.set_description(
                    f"[{epoch + 1}, {step}] loss: {running_loss / step:.4g}")
        history['train_loss'].append((epoch, running_loss / step))

        # At the end of the epoch, evaluate model on validation
        model.eval();  # Activate evaluation mode
        running_loss = 0.0
        y_true = []
        y_pred = []
        with torch.no_grad():
            pbar = tqdm(valloader) if use_tqdm else valloader
            for step, data in enumerate(pbar, 1):
                inputs, labels = data
                outputs = model(inputs.view(inputs.shape[0], -1))
                running_loss += loss_function(outputs, labels).item()
                _, predicted = torch.max(outputs.data, 1)
                y_true.extend(labels.numpy())
                y_pred.extend(predicted.numpy())
        history['val_accuracy'].append(metrics.accuracy_score(y_true, y_pred))
        history['val_loss'].append((epoch, running_loss / step))
    
    return history
def run_experiment3(model, epochs, trainloader, testloader,
                    optimizer_class=optim.SGD, lr=0.001, weight_decay=0.0,
                    use_tqdm=False):
    loss_function = nn.CrossEntropyLoss()
    optimizer = optimizer_class(model.parameters(), lr=lr,
                                weight_decay=weight_decay)
    history = train_and_eval(
        model, optimizer, loss_function, trainloader, epochs,
        valloader=testloader, use_tqdm=use_tqdm)
    test_results = eval(model, testloader, use_tqdm=use_tqdm)

    experiment = {
        'arquitecture': str(model), 'loss': str(loss_function),
        'epochs': epochs, 'lr': lr, 'optimizer': str(optimizer_class),
        'weight_decay': weight_decay
    }
    experiment.update(history)
    experiment.update(test_results)
    return experiment
model = MLP()  # It's important to re-define the model to avoid keep training an earlier version
experiment = run_experiment3(model, 15, trainloader, testloader,
                             optimizer_class=optim.Adagrad, lr=0.01)
data = []
for epoch, (train_loss, val_loss) in enumerate(zip(
        experiment['train_loss'], experiment['val_loss'])):
    data.append((epoch, 'train', train_loss[1]))
    data.append((epoch, 'val', val_loss[1]))
data = pandas.DataFrame(data=data, columns=['epoch', 'dataset', 'loss'])
seaborn.pointplot(
    data=data, x='epoch', y='loss', hue='dataset'
)
eval(model, testloader)<jupyter_output><empty_output><jupyter_text>Regularización de la red Regularización de los pesos- La red puede regularizarse penalizando la magnitud de los parámetros.- Los pesos se regularizan mediante alguna norma:    - L1 es la suma del valor absoluto: $ \lambda \sum_{i=1}^{k} |w_i| $    - L2 es la suma del valor cuadrado, es la más común: ${\displaystyle \lambda \sum_{i=1}^{k} w_i^2}$    - Elastic net es una combinación de ambas: ${\displaystyle \lambda_1 \sum_{i=1}^{k} |w_i| + \lambda_2 \sum_{i=1}^{k} w_i^2}$- Para un análisis detallado de la diferencia entre L1 y L2 revisar [\[2\]](http://www.chioka.in/differences-between-l1-and-l2-as-loss-function-and-regularization/) Varios (sino todos) de los algoritmos de optimización implementados en `torch.optim` vienen con una implementación de la norma L2 para regularización (que suele ser la norma por defecto), simplemente se debe seleccionar el valor de $\lambda$ que viene representado por el parámetro `weight_decay`.```pythonmodel = MLP()loss_function = nn.CrossEntropyLoss()optimizer = optim.Adam(model.parameters(),                        lr=0.001,                       weight_decay=0.001)``` Si queremos mayor control (e.g. implementar una versión de L1 o ElasticNet), debemos hacerlo como parte del *training loop*:```pythonl1_lambda = 0.001model = model.to(device)model.train()for inputs, labels in tqdm(trainloader):    inputs = inputs.to(device)    labels = labels.to(device)    optimizer.zero_grad()    outputs = model(inputs.view(inputs.shape[0], -1))    loss = loss_function(outputs, labels)    l1_reg = torch.tensor(0.).to(device)    for param in model.parameters():        l1_reg += torch.norm(param, p=1)    loss += l1_reg * l1_lambda    loss.backward()    optimizer.step()``` El caso anterior regulariza todos los pesos, i.e. los pesos de las capas y los bias. Si sólo queremos regularizar los pesos, se puede utilizar [`nn.Module.named_parameters`](https://pytorch.org/docs/stable/generated/torch.nn.Module.htmltorch.nn.Module.named_parameters) y filtrar aquellos parámetros que tengan `bias` en el nombre.```pythonl1_reg = torch.tensor(0.)for param_name, param_weight in model.named_parameters():    if 'bias' not in param_name:        l1_reg += torch.norm(param, p=1)```<jupyter_code>wd_experiments = []
for weight_decay in [0, 0.001, 0.01, 0.05, 0.1]:
  print("Exploring weight_decay: ", weight_decay)
  model = MLP()  # It's important to re-define the model to avoid keep training an earlier version
  wd_experiments.append(run_experiment3(
      model, 10, trainloader, testloader,
      optimizer_class=optim.Adagrad, lr=0.01, weight_decay=weight_decay,
      use_tqdm=True))
wd_experiments
data = []
for experiment in wd_experiments:
    wd = experiment['weight_decay']
    for epoch, train_loss in experiment['train_loss']:
        data.append((epoch, wd, train_loss, 'train'))
    for epoch, val_loss in experiment['val_loss']:
        data.append((epoch, wd, val_loss, 'val'))
data = pandas.DataFrame(data=data, columns=['epoch', 'weight_decay', 'loss', 'dataset'])
data[:3]
seaborn.catplot(
    data=data, x='epoch', y='loss', hue='dataset',
    col='weight_decay', col_wrap=3,
    kind='point'
);<jupyter_output><empty_output><jupyter_text>Dropout- Otra forma muy usada a la hora de regularizar es el **dropout** [3].- Es extremadamente efectivo y simple.- Es complementario a L1/L2/ElasticNet.- Durante el entrenamiento se implementa apagando un neurón con alguna probabilidad **_p_** (un hiperparámetro). Fuente: Trabajo de Srivastava et al. [3] En Pytorch, el dropout se implementa agregando capas al modelo. Se utiliza la clase [`torch.nn.Dropout`](https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html) y se agrega después de la capa que se quiere regularizar.```pythonmodel = nn.Sequential(    nn.Linear(32 * 32 * 3, 512),    nn.ReLU(),    nn.Dropout(p=0.3),    nn.Linear(512, 10))```<jupyter_code><jupyter_output><empty_output><jupyter_text>Batch Normalization- En general, para acelerar la convergencia de la red, se normalizan los features de entrada, de manera que todos estén en un rango similar.- Esta idea también puede llevarse a las capas ocultas de la red.- La idea de la "Normalización por Lotes" (*Batch Normalization*) [4] es reducir el rango en el que se mueven los valores de las neuronas ocultas.- La manera en que se hace esto es restarle, a cada salida de cada capa oculta, la media del lote (batch) de datos de entrenamiento y dividirlo por la desviación estándar (a grandes razgos).- Como resultado, la red converge más rápido e incluso se genera un efecto de regularización.  Batch Normalization en PyTorch- Se aplica agregando capas al modelo.- Se llaman capas [`BatchNorm*`](https://pytorch.org/docs/stable/nn.htmlnormalization-layers), donde `*` se reemplaza por las dimensiones de entrada (`1d`, `2d`, `3d`) y se agrega a cada capa que se quiere normalizar.- El `momentum` es un parámetro que decide cuánta información de los lotes anteriores se tiene en cuenta a la hora de normalizar el lote actual (en el trabajo original de *Batch Normalization*, este es de `0`).<jupyter_code>model = nn.Sequential(
    nn.Linear(32 * 32 * 3, 512),
    nn.BatchNorm1d(512, momentum=0.1),
    nn.ReLU(),
    nn.Dropout(p=0.3),
    nn.Linear(512, 10)
)<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09764584586304666, 'fraction_numerical': 0.016754016668098635, 'mean_word_length': 3.7605316973415133, 'pattern_counts': {'":': 0, '<': 53, '<?xml version=': 0, '>': 56, 'https://': 6, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 11, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '14', 'dir': 'jupyter-structured-clean-dedup', 'id': '522274', 'license': 'MIT', 'n_tokens_mistral': 7638, 'n_tokens_neox': 7323, 'n_words': 2326, 'path': '1_1_hyperparameters.ipynb', 'repo_name': 'DiploDatos-Grupo2/AprendizajeProfundo', 'size': '23278'}