02. Introducción a Redes Neuronales con PyTorch¶
Introducción¶
En este capítulo, se presentarán los fundamentos de las redes neuronales y cómo se implementan en PyTorch. Se presentarán los conceptos de redes neuronales, funciones de activación, funciones de pérdida, optimizadores y cómo se implementan en PyTorch.
Desarrollando la primera red neuronal en PyTorch¶
Una red neuronal se compone de un elemento fundamental llamado neurona. Cada neurona de una red neuronal realiza tres operaciones básicas:
- Multiplica cada entrada que recibe por un peso.
- Suma todas las entradas ponderadas añadiendo un sesgo (constante).
- Aplica una función no lineal a la salida. Esta función se denomina función de activación.
Los pesos y el sesgo son parámetros de la neurona que se aprenden durante el entrenamiento. La función de activación es una función no lineal que se aplica a la salida de la neurona. Gracias a la función de activación no lineal, la red neuronal puede aprender relaciones no lienales entre las entradas y las salidas.
Las operaciones de una neurona se puede describir en forma matemática de la siguiente manera:
y = f(w*x + b)
Siendo, x es un vector de entrada de tamaño n, w es un vector de pesos de tamaño n, b es el sesgo (un solo número) y f es la función de activación.
Si no se utiliza función de activación, la salida y es simplemente una suma ponderada de las entradas (más el sesgo), es decir, una regresión lineal:
y = w1x1 + w2x2 + ... + wnxn + b
Para entender cómo se implementa una arquitectura de deep learning en PyTorch, se comenzará desarrollando un modelo de regresión lineal. Este modelo es el más simple de todos los modelos de redes neuronales, pero es un buen punto de partida para entender cómo se implementan las redes neuronales en PyTorch.
Creando un conjunto de datos sintético: predicción de producción de manzanas y naranjas¶
El problema que se va a resolver es el siguiente: se tiene información sobre el clima en ciertas localidades y se desea predecir la producción de manzanas y naranjas en esas localidades en base a los datos climáticos.
Es interesante recalcar que se dispone de dos columnas que se quieren predecir, por lo que este ejemplo consiste en de dos modelos de regresión lineal. Cada modelo de regresión lineal predice una columna distinta.
| Region | Temperatura | Lluvia | Humedad | Manzanas (target 1) | Naranjas (target 2) |
|---|---|---|---|---|---|
| España | 73 | 67 | 43 | 56 | 70 |
| Italia | 91 | 88 | 64 | 81 | 101 |
| Alemania | 87 | 134 | 58 | 119 | 133 |
| Portugal | 102 | 43 | 37 | 22 | 37 |
| Francia | 69 | 96 | 70 | 103 | 119 |
En un modelo de regresión lineal, cada variable objetivo se estima como una suma ponderada (también llamados weights) de las variables de entrada, sumando una constante o bias:
produccion_manzana = w11 * temperatura + w12 * lluvia + w13 * humedad + b1
produccion_naranja = w21 * temperatura + w22 * lluvia + w23 * humedad + b2
Ahora implementaremos un modelo de regresión lineal para predecir con PyTorch.
import numpy as np
import torch
Datos de entrenamiento¶
Podemos representar los datos de entrenamiento usando dos matrices: entradas y objetivos, cada una con una fila por observación y una columna por variable.
# Entrada (tempertura, precipitación, humedad)
inputs = np.array([[73, 67, 43],
[91, 88, 64],
[87, 134, 58],
[102, 43, 37],
[69, 96, 70]], dtype='float32')
# Salida (manzanas, naranjas)
targets = np.array([[56, 70],
[81, 101],
[119, 133],
[22, 37],
[103, 119]], dtype='float32')
NOTA:
Se convierten las matrices en tensores PyTorch. Si quieres saber más sobre tensores y operaciones con ellos, puedes consultar el post de Introducción a PyTorch.
# Se transforman las matrices a tensores
inputs = torch.from_numpy(inputs)
targets = torch.from_numpy(targets)
print(inputs)
print(targets)
tensor([[ 73., 67., 43.],
[ 91., 88., 64.],
[ 87., 134., 58.],
[102., 43., 37.],
[ 69., 96., 70.]])
tensor([[ 56., 70.],
[ 81., 101.],
[119., 133.],
[ 22., 37.],
[103., 119.]])
Modelo de regresión lineal¶
Los pesos y sesgos (w11, w12,... w23, b1 y b2) también se pueden representar como matrices, inicializadas como valores aleatorios. La primera fila de w y el primer elemento de b se utilizan para predecir la primera variable objetivo, es decir, el rendimiento de las manzanas y, de manera similar, el segundo para las naranjas.
# Pesos y sesgos
w = torch.randn(2, 3, requires_grad=True)
b = torch.randn(2, requires_grad=True)
print(w)
print(b)
tensor([[-1.7679, 1.1114, -0.2267],
[ 0.0614, -1.4329, 2.0267]], requires_grad=True)
tensor([ 1.7997, -2.2624], requires_grad=True)
torch.randn crea un tensor con la forma dada, con elementos elegidos aleatoriamente de una distribución normal con media 0 y desviación estándar 1.
El modelo que se va a crear es simplemente una función que realiza una multiplicación matricial de las entradas y los pesos w (transpuestos) y agrega el sesgo b (replicado para cada observación).

Podemos definir el modelo de la siguiente manera:
def model(x):
return x @ w.t() + b
@ representa la multiplicación de matrices en PyTorch, y el método .t devuelve la transposición de un tensor.
La matriz obtenida al pasar los datos de entrada al modelo es un conjunto de predicciones para las variables objetivo.
# Generar predicciones
preds = model(inputs)
print(preds)
tensor([[ -62.5404, -6.6384],
[ -75.7836, 6.9362],
[ -16.2256, -71.3851],
[-139.1242, 17.3723],
[ -29.3582, 6.2823]], grad_fn=<AddBackward0>)
Comparemos las predicciones de nuestro modelo con los objetivos reales.
# Comparar con los targets
print(targets)
tensor([[ 56., 70.],
[ 81., 101.],
[119., 133.],
[ 22., 37.],
[103., 119.]])
Como se podía esperar, existe una gran diferencia entre las predicciones de nuestro modelo y los valores reales de las variables a predecir. Como todavía no hemos entrenado el modelo, los pesos y sesgos son números aleatorios.
Función de pérdida¶
El entrenamiento de una red neuronal consiste en determinar los pesos y sesgos que hacen que la predicción sea lo más parecida al conjunto de valores reales (observaciones). Este proceso es en realidad un problema de optimización, en concreto de minización. La minimización de una función $y=f(x)$ consiste en determinar los parámetros $x$ que minimizan el valor de la función $f(x)$.
En el caso de la función $y=x^2$ el valor que minimiza la función es $x=0$. Calcular la derivada de una función, y despejar el valor de la $x$ para determinar el valor mínimo es muy costoso computacionalmente, y en ocasiones no es posible. Por ello, la optimización de los parámetros se realiza con un proceso numérico denominado back-propagation. En la siguiente referencia se puede encontrar más información al respecto [REFERENCE999].
Es necesario por tanto, definir cuál es la función que se quiere minimizar en el entrenamiento de nuestra red neuronal. Esta función se denomina función de pérdidad.
Las funciones de pérdida más utilizadas son:
- Problemas de regresión: Error cuadrático medio (MSE) y error cuadrático absoluto (MAE).
- Problemas de clasificación: Entropía cruzada.
Calcula la diferencia entre las dos matrices (preds y targets). Cuadre todos los elementos de la matriz de diferencias para eliminar los valores negativos.
*Calcular el promedio de los elementos de la matriz resultante.
En el problema planteado, se utilizará como función de pérdida el error cuadrático medio (MSE).
# MSE loss
def mse(t1, t2):
diff = t1 - t2
return torch.sum(diff * diff) / diff.numel()
torch.sum devuelve la suma de todos los elementos en un tensor. El método .numel de un tensor devuelve el número de elementos en un tensor. Calculemos el error cuadrático medio de las predicciones actuales de nuestro modelo.
# Computar loss
loss = mse(preds, targets)
print(loss)
Así es como podemos interpretar el resultado: En promedio, cada elemento en la predicción difiere del objetivo real por la raíz cuadrada de la pérdida. Y eso es bastante malo, considerando que los números que estamos tratando de predecir están en el rango de 50 a 200. El resultado se llama pérdida porque indica qué tan malo es el modelo para predecir las variables de destino. Representa la pérdida de información en el modelo: cuanto menor es la pérdida, mejor es el modelo.
Calcular gradientes¶
Con PyTorch, podemos calcular automáticamente el gradiente o la derivada de la pérdida w.r.t. a los pesos y sesgos porque tienen requires_grad establecido en True. Veremos cómo esto es útil en un momento.
# Computar gradients
loss.backward()
Los gradientes se almacenan en la propiedad .grad de los respectivos tensores. Tenga en cuenta que la derivada de la pérdida w.r.t. la matriz de pesos es en sí misma una matriz con las mismas dimensiones.
Ajuste pesos y sesgos para reducir la pérdida¶
La pérdida es una función cuadrática de nuestros pesos y sesgos, y nuestro objetivo es encontrar el conjunto de pesos donde la pérdida es la más baja. Si trazamos un gráfico de la pérdida con cualquier elemento de peso o sesgo individual, se verá como la figura que se muestra a continuación. Una idea importante del cálculo es que el gradiente indica la tasa de cambio de la pérdida, es decir, la [pendiente] (https://en.wikipedia.org/wiki/Slope) de la función de pérdida w.r.t. los pesos y sesgos.
Si un elemento degradado es positivo:
- aumentar el valor del elemento de peso ligeramente aumentará la pérdida
- disminuir el valor del elemento de peso ligeramente disminuirá la pérdida

Si un elemento degradado es negativo:
- aumentar el valor del elemento de peso ligeramente disminuirá la pérdida
- disminuir el valor del elemento de peso ligeramente aumentará la pérdida

El aumento o disminución de la pérdida al cambiar un elemento de peso es proporcional al gradiente de la pérdida w.r.t. ese elemento Esta observación forma la base del_descenso de gradiente_algoritmo de optimización que usaremos para mejorar nuestro modelo (descendiendo_a lo largo del_gradiente).
Podemos restar de cada elemento de peso una pequeña cantidad proporcional a la derivada de la pérdida w.r.t. ese elemento para reducir ligeramente la pérdida.
w
w.grad
with torch.no_grad():
w -= w.grad * 1e-5
b -= b.grad * 1e-5
Multiplicamos los gradientes con un número muy pequeño (10^-5 en este caso) para asegurarnos de no modificar los pesos en una cantidad muy grande. Queremos dar un pequeño paso en la dirección cuesta abajo de la pendiente, no un salto gigante. Este número se denomina tasa de aprendizaje del algoritmo.
Usamos torch.no_grad para indicarle a PyTorch que no debemos rastrear, calcular o modificar gradientes mientras actualizamos los pesos y sesgos.
# Let's verify that the loss is actually lower
loss = mse(preds, targets)
print(loss)
Antes de continuar, restablecemos los gradientes a cero invocando el método .zero_(). Necesitamos hacer esto porque PyTorch acumula gradientes. De lo contrario, la próxima vez que invocamos .backward en la pérdida, los nuevos valores de gradiente se agregan a los gradientes existentes, lo que puede generar resultados inesperados.
w.grad.zero_()
b.grad.zero_()
print(w.grad)
print(b.grad)
Entrena el modelo usando descenso de gradiente¶
Como se vio anteriormente, reducimos la pérdida y mejoramos nuestro modelo utilizando el algoritmo de optimización de descenso de gradiente. Por lo tanto, podemos entrenar el modelo usando los siguientes pasos:
Genera predicciones
Calcular la pérdida
Calcular gradientes con los pesos y sesgos
Ajuste los pesos restando una pequeña cantidad proporcional al gradiente
Restablecer los gradientes a cero
Implementemos lo anterior paso a paso.
p = model(inputs)
loss = mse(p, targets)
loss.backward()
with torch.no_grad():
w -= w.grad * 1e-4
b -= b.grad * 1e-4
w.grad.zero_()
b.grad.zero_()
print(loss)
# Generate predictions
preds = model(inputs)
print(preds)
# Calculate the loss
loss = mse(preds, targets)
print(loss)
# Compute gradients
loss.backward()
print(w.grad)
print(b.grad)
Actualicemos los pesos y sesgos usando los gradientes calculados arriba.
# Adjust weights & reset gradients
with torch.no_grad():
w -= w.grad * 1e-4
b -= b.grad * 1e-4
w.grad.zero_()
b.grad.zero_()
Echemos un vistazo a los nuevos pesos y sesgos.
print(w)
print(b)
Con los nuevos pesos y sesgos, el modelo debería tener una pérdida menor.
# Calculate loss
preds = model(inputs)
loss = mse(preds, targets)
print(loss)
with torch.no_grad():
w -= w.grad * 1e-4
b -= b.grad * 1e-4
w.grad.zero_()
b.grad.zero_()
# Calculate loss
preds = model(inputs)
loss = mse(preds, targets)
print(loss)
loss.backward()
print(targets)
print(preds)
Ya hemos logrado una reducción significativa en la pérdida simplemente ajustando los pesos y sesgos ligeramente mediante el descenso de gradiente.
Tren para múltiples épocas¶
Para reducir aún más la pérdida, podemos repetir el proceso de ajustar los pesos y sesgos utilizando los gradientes varias veces. Cada iteración se denomina época. Entrenemos el modelo para 100 épocas.
# Train for 100 epochs
for i in range(100):
preds = model(inputs)
loss = mse(preds, targets)
loss.backward()
print(loss)
with torch.no_grad():
w -= w.grad * 1e-5
b -= b.grad * 1e-5
w.grad.zero_()
b.grad.zero_()
Una vez más, comprobemos que la pérdida ahora es menor:
# Calculate loss
preds = model(inputs)
loss = mse(preds, targets)
print(loss)
La pérdida es ahora mucho menor que su valor inicial. Veamos las predicciones del modelo y comparémoslas con los objetivos.
# Predictions
preds
# Targets
targets
Las predicciones ahora están bastante cerca de las variables objetivo. Podemos obtener resultados aún mejores si entrenamos durante algunas épocas más.
Regresión lineal usando las funciones incorporadas de PyTorch¶
Hemos implementado un modelo de regresión lineal y descenso de gradiente utilizando algunas operaciones básicas de tensor. Sin embargo, dado que este es un patrón común en el aprendizaje profundo, PyTorch proporciona varias funciones y clases integradas para facilitar la creación y el entrenamiento de modelos con solo unas pocas líneas de código.
Comencemos importando el paquete torch.nn de PyTorch, que contiene clases de utilidad para construir redes neuronales.
import torch.nn as nn
Como antes, representamos las entradas, los objetivos y las matrices.
# Input (temp, rainfall, humidity)
inputs = np.array([[73, 67, 43],
[91, 88, 64],
[87, 134, 58],
[102, 43, 37],
[69, 96, 70],
[74, 66, 43],
[91, 87, 65],
[88, 134, 59],
[101, 44, 37],
[68, 96, 71],
[73, 66, 44],
[92, 87, 64],
[87, 135, 57],
[103, 43, 36],
[68, 97, 70]],
dtype='float32')
# Targets (apples, oranges)
targets = np.array([[56, 70],
[81, 101],
[119, 133],
[22, 37],
[103, 119],
[57, 69],
[80, 102],
[118, 132],
[21, 38],
[104, 118],
[57, 69],
[82, 100],
[118, 134],
[20, 38],
[102, 120]],
dtype='float32')
inputs = torch.from_numpy(inputs)
targets = torch.from_numpy(targets)
inputs
Usamos 15 ejemplos de capacitación para ilustrar cómo trabajar con grandes conjuntos de datos en lotes pequeños.
Conjunto de datos y cargador de datos¶
Crearemos un TensorDataset, que permite el acceso a filas desde inputs y targets como tuplas, y proporciona API estándar para trabajar con muchos tipos diferentes de conjuntos de datos en PyTorch.
from torch.utils.data import TensorDataset
TensorDataset?
# Define dataset
train_ds = TensorDataset(inputs, targets)
train_ds[:3]
train_ds[0:3]
El TensorDataset nos permite acceder a una pequeña sección de los datos de entrenamiento utilizando la notación de indexación de matriz ([0:3] en el código anterior). Devuelve una tupla con dos elementos. El primer elemento contiene las variables de entrada para las filas seleccionadas y el segundo contiene los objetivos.
También crearemos un DataLoader, que puede dividir los datos en lotes de un tamaño predefinido durante el entrenamiento. También proporciona otras utilidades como la reproducción aleatoria y el muestreo aleatorio de los datos.
from torch.utils.data import DataLoader
DataLoader?
# Define data loader
batch_size = 5
train_dl = DataLoader(train_ds, batch_size, shuffle=True)
print(train_dl)
Podemos usar el cargador de datos en un bucle for. Veamos un ejemplo.
for xb, yb in train_dl:
print(xb)
print(yb)
# break
En cada iteración, el cargador de datos devuelve un lote de datos con el tamaño de lote dado. Si shuffle se establece en True, mezcla los datos de entrenamiento antes de crear lotes. El barajado ayuda a aleatorizar la entrada al algoritmo de optimización, lo que conduce a una reducción más rápida de la pérdida.
nn.Lineal¶
En lugar de inicializar manualmente los pesos y sesgos, podemos definir el modelo usando la clase nn.Linear de PyTorch, que lo hace automáticamente.
mod = nn.Linear(10, 20)
x = torch.randn(120, 10)
mod(x).shape
mod.weight.shape
# Define model
model = nn.Linear(3, 2)
print(model.weight)
print(model.bias)
Los modelos de PyTorch también tienen un útil método .parameters, que devuelve una lista que contiene todas las matrices de ponderación y sesgo presentes en el modelo. Para nuestro modelo de regresión lineal, tenemos una matriz de ponderación y una matriz de sesgo.
# Parameters
list(model.parameters())
print(model)
Podemos usar el modelo para generar predicciones de la misma manera que antes.
# Generate predictions
preds = model(inputs)
preds
Función de pérdida¶
En lugar de definir una función de pérdida manualmente, podemos usar la función de pérdida integrada mse_loss.
# Import nn.functional
import torch.nn.functional as F
El paquete nn.function contiene muchas funciones de pérdida útiles y varias otras utilidades.
# Define loss function
loss_fn = F.mse_loss
Calculemos la pérdida para las predicciones actuales de nuestro modelo.
loss = loss_fn(model(inputs), targets)
print(loss)
Optimizador¶
En lugar de manipular manualmente los pesos y sesgos del modelo usando gradientes, podemos usar el optimizador optim.SGD. SGD es la abreviatura de "descenso de gradiente estocástico". El término estocástico indica que las muestras se seleccionan en lotes aleatorios en lugar de como un solo grupo.
torch.optim.SGD?
# Define optimizer
opt = torch.optim.SGD(model.parameters(), lr=1e-5)
Tenga en cuenta que model.parameters() se pasa como argumento a optim.SGD para que el optimizador sepa qué matrices deben modificarse durante el paso de actualización. Además, podemos especificar una tasa de aprendizaje que controle la cantidad en la que se modifican los parámetros.
Entrenar al modelo¶
Ahora estamos listos para entrenar el modelo. Seguiremos el mismo proceso para implementar el descenso de gradiente:
Genera predicciones
Calcular la pérdida
Calcular gradientes con los pesos y sesgos
Ajuste los pesos restando una pequeña cantidad proporcional al gradiente
Restablecer los gradientes a cero
El único cambio es que trabajaremos con lotes de datos en lugar de procesar todos los datos de entrenamiento en cada iteración. Definamos una función de utilidad fit que entrene el modelo para un número determinado de épocas.
# Utility function to train the model
def fit(num_epochs, model, loss_fn, opt, train_dl):
# Repeat for given number of epochs
for epoch in range(num_epochs):
# Train with batches of data
for xb,yb in train_dl:
# 1. Generate predictions
pred = model(xb)
# 2. Calculate loss
loss = loss_fn(pred, yb)
# 3. Compute gradients
loss.backward()
# 4. Update parameters using gradients
opt.step()
# 5. Reset the gradients to zero
opt.zero_grad()
# Print the progress
if (epoch+1) % 10 == 0:
print('Epoch [{}/{}], Loss: {:.4f}'.format(epoch+1, num_epochs, loss.item()))
Algunas cosas a tener en cuenta arriba:
Usamos el cargador de datos definido anteriormente para obtener lotes de datos para cada iteración. En lugar de actualizar los parámetros (pesos y sesgos) manualmente, usamos opt.step para realizar la actualización y opt.zero_grad para restablecer los gradientes a cero.
- También agregamos una declaración de registro que imprime la pérdida del último lote de datos para cada décima época para realizar un seguimiento del progreso del entrenamiento.
loss.itemdevuelve el valor real almacenado en el tensor de pérdida.
Entrenemos el modelo para 100 épocas.
fit(100, model, loss_fn, opt, train_dl)
Generemos predicciones usando nuestro modelo y verifiquemos que estén cerca de nuestros objetivos.
# Generate predictions
preds = model(inputs)
preds
# Compare with targets
targets
De hecho, las predicciones están bastante cerca de nuestros objetivos. Hemos entrenado un modelo razonablemente bueno para predecir el rendimiento de los cultivos de manzanas y naranjas al observar la temperatura promedio, las precipitaciones y la humedad en una región. Podemos usarlo para hacer predicciones de rendimiento de cultivos para nuevas regiones pasando un lote que contiene una sola fila de entrada.
model(torch.tensor([[75, 63, 44.]]))
El rendimiento previsto de manzanas es de 54,3 toneladas por hectárea y el de naranjas de 68,3 toneladas por hectárea.
F.mse_loss(model(inputs),targets)**0.5