Capítulo 9 de 20 8 secciones 19 min

PyTorch hace en cuatro líneas lo que escribiste a mano

La misma red de los capítulos anteriores, ahora con la librería que se usa en el trabajo. Y la comprobación de que sale lo mismo.

PyTorch es la librería con la que se entrenan redes de verdad, y lo único que hace es lo que ya escribiste tú: la pasada hacia adelante, los gradientes y el paso. Yo lo comprobé de la forma más tonta que se me ocurrió, poniéndole los mismos pesos, y saca los mismos números hasta el cuarto decimal 🔦

Llevas siete capítulos escribiendo redes con numpy. Ahora te voy a enseñar la librería con la que se hacen en el trabajo, y quiero que veas que no te van a enseñar nada nuevo: te van a ahorrar teclas 🧵

Ese orden importa y por eso el capítulo está aquí y no al principio. Quien empieza por la librería aprende a llamar funciones. Quien empieza por la cuenta aprende qué hacen esas funciones, y cuando algo se rompe sabe dónde mirar.

Antes de correr nada, quédate con esta pregunta: ¿qué parte de lo que escribiste a mano crees que PyTorch hace distinto? Al final del capítulo la contestamos con números 🧠

Los mismos datos y el mismo listón

Esto es el arranque del capítulo 7, sin tocar una coma, más las tres líneas que convierten las matrices en tensores.

import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

# Con datos de este tamano los cuatro hilos de torch se estorban entre ellos y
# tarda mas que con uno. Con datos de verdad esta linea se quita.
torch.set_num_threads(1)

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
        v[col] = f
    return v

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)
logistica = Pipeline([
    ('pre', ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])),
    ('mod', LogisticRegression(max_iter=1000, random_state=42)),
]).fit(X_tr, y_tr)

T_tr = logistica.named_steps['pre'].transform(X_tr)
T_te = logistica.named_steps['pre'].transform(X_te)
objetivo = y_tr.values.astype(float).reshape(-1, 1)

# Las tres lineas nuevas: una matriz de numpy se vuelve tensor y ya esta.
E_tr = torch.tensor(T_tr)
E_te = torch.tensor(T_te)
O_tr = torch.tensor(objetivo)

print('entreno con', tuple(E_tr.shape), 'y pruebo con', tuple(E_te.shape))
print('el liston a batir:', round(roc_auc_score(
    y_te, logistica.predict_proba(X_te)[:, 1]), 4))
entreno con (2250, 28) y pruebo con (750, 28)
el liston a batir: 0.7214

Un tensor es una matriz de numpy que además se acuerda de las operaciones que le hicieron. Eso es todo lo que es, y es lo que hace posible lo que viene ahora 📦

Los gradientes que escribiste son los que saca autograd

En el capítulo 6 repartiste la culpa hacia atrás a mano, con la regla de la cadena, y te salieron cuatro matrices de derivadas. Aquí voy a calcular esas mismas cuatro de las dos maneras, con los mismos pesos de partida, y a compararlas.

def sigmoide(z):
    return 1 / (1 + np.exp(-z))

r = np.random.default_rng(0)
W1 = r.normal(0, 0.1, (T_tr.shape[1], 8)); b1 = np.zeros(8)
W2 = r.normal(0, 0.1, (8, 1)); b2 = np.zeros(1)

# 1) A mano, como en el capitulo de retropropagacion.
h = np.tanh(T_tr @ W1 + b1)
p = sigmoide(h @ W2 + b2)
d2 = (p - objetivo) / len(objetivo)
d1 = (d2 @ W2.T) * (1 - h ** 2)
mios = {'W1': T_tr.T @ d1, 'b1': d1.sum(axis=0),
        'W2': h.T @ d2, 'b2': d2.sum(axis=0)}

# 2) Con autograd: los mismos numeros, marcados con requires_grad.
sueltos = {n: torch.tensor(v, requires_grad=True)
           for n, v in [('W1', W1), ('b1', b1), ('W2', W2), ('b2', b2)]}
z = (torch.tanh(E_tr @ sueltos['W1'] + sueltos['b1'])
     @ sueltos['W2'] + sueltos['b2'])
nn.functional.binary_cross_entropy_with_logits(z, O_tr).backward()

for nombre in ['W1', 'b1', 'W2', 'b2']:
    suyo = sueltos[nombre].grad.numpy()
    print('%-3s de %-8s  el mio y el de torch son iguales: %s'
          % (nombre, str(np.shape(mios[nombre])), np.allclose(mios[nombre], suyo)))
W1  de (28, 8)   el mio y el de torch son iguales: True
b1  de (8,)      el mio y el de torch son iguales: True
W2  de (8, 1)    el mio y el de torch son iguales: True
b2  de (1,)      el mio y el de torch son iguales: True

Cuatro veces True 🎉

Esa línea de backward() es el capítulo entero de retropropagación. PyTorch fue apuntando cada operación que hiciste con los tensores, y al llamar a backward() recorrió esa lista al revés aplicando la regla de la cadena, que es exactamente lo que hiciste tú con lápiz. No es otra cuenta: es la misma cuenta, hecha por un programa.

Y fíjate en el detalle que hace que salga igual: binary_cross_entropy_with_logits. Tu d2 era (p - objetivo) / n, y esa resta tan limpia es la derivada de la entropía cruzada con sigmoide. Si le hubiera puesto otra pérdida, las cuatro habrían dado False, y con razón.

El bucle de entrenamiento de PyTorch como un ciclo cerrado de cinco pasos: borrar los gradientes, la pasada hacia adelante, calcular la pérdida, backward y step, y vuelta a empezar.
Las cinco cajas son las mismas cinco cuentas que escribiste a mano en los capítulos anteriores, con otro nombre. La flecha que cierra el ciclo es la que se olvida: sin volver a zero_grad, los gradientes de esta vuelta se suman a los de la anterior.

La red entera, en cuatro líneas

Ahora la red completa. Le copio los mismos pesos de partida para que la comparación sea justa, y le pido las mismas cinco fotos del capítulo 7.

red = nn.Sequential(nn.Linear(28, 8), nn.Tanh(), nn.Linear(8, 1)).double()
with torch.no_grad():
    red[0].weight.copy_(torch.tensor(W1.T)); red[0].bias.copy_(torch.tensor(b1))
    red[2].weight.copy_(torch.tensor(W2.T)); red[2].bias.copy_(torch.tensor(b2))

optimizador = torch.optim.SGD(red.parameters(), lr=0.5)
perdida = nn.BCEWithLogitsLoss()

def mide(modelo):
    modelo.eval()
    with torch.no_grad():
        return (roc_auc_score(y_tr, modelo(E_tr).numpy().ravel()),
                roc_auc_score(y_te, modelo(E_te).numpy().ravel()))

marcas = (0, 500, 2000, 5000, 10000)
for vuelta in range(max(marcas) + 1):
    if vuelta in marcas:
        entrena, prueba = mide(red)
        print('  vuelta %6d  entrena %.4f  prueba %.4f  brecha %+.4f'
              % (vuelta, entrena, prueba, entrena - prueba))
    red.train()
    optimizador.zero_grad()
    perdida(red(E_tr), O_tr).backward()
    optimizador.step()
  vuelta      0  entrena 0.4003  prueba 0.4044  brecha -0.0041
  vuelta    500  entrena 0.7343  prueba 0.7150  brecha +0.0194
  vuelta   2000  entrena 0.7948  prueba 0.6651  brecha +0.1296
  vuelta   5000  entrena 0.8245  prueba 0.6290  brecha +0.1955
  vuelta  10000  entrena 0.8328  prueba 0.6174  brecha +0.2154

Ve a buscar la tabla del capítulo 7 y ponla al lado 😄

Es la misma, cifra por cifra, en las quince casillas. Y no es que se parezca: es que es la misma cuenta con los mismos pesos, así que tenía que salir igual. Cuando alguien te venda una librería como si fuera inteligencia, acuérdate de esta tabla.

Las tres líneas del medio son el bucle entero:

  • optimizador.zero_grad() borra los gradientes de la vuelta anterior, porque PyTorch los suma en vez de reemplazarlos.
  • perdida(...).backward() es tu retropropagación.
  • optimizador.step() es tu W -= paso * gradiente, que en el capítulo 5 escribiste a mano.

Y hay dos que no hacen nada aquí y sí harán falta enseguida: red.train() y red.eval(). Le dicen a la red si está entrenando o contestando, y las capas que se comportan distinto en cada caso las miran. Ahora mismo esta red no tiene ninguna de esas capas, así que las dos dan lo mismo. En cuanto le metamos dropout, dejan de darlo.

Y ahora lo que a mano no ibas a escribir

Hasta aquí PyTorch solo ha empatado. Lo que gana es lo que viene: los tres frenos del capítulo 8 ya están escritos, los lotes también, y hay optimizadores que a mano no te apetece programar.

torch.manual_seed(0)
red2 = nn.Sequential(nn.Linear(28, 16), nn.ReLU(), nn.Dropout(0.2),
                     nn.Linear(16, 1)).double()
carga = DataLoader(TensorDataset(E_tr, O_tr), batch_size=64, shuffle=True,
                   generator=torch.Generator().manual_seed(0))
opt = torch.optim.Adam(red2.parameters(), lr=0.01, weight_decay=1e-3)

for epoca in range(1, 31):
    red2.train()
    for filas, etiquetas in carga:
        opt.zero_grad()
        perdida(red2(filas), etiquetas).backward()
        opt.step()
    if epoca in (1, 2, 3, 5, 10, 20, 30):
        entrena, prueba = mide(red2)
        print('  epoca %3d  entrena %.4f  prueba %.4f  brecha %+.4f'
              % (epoca, entrena, prueba, entrena - prueba))
  epoca   1  entrena 0.7134  prueba 0.7233  brecha -0.0098
  epoca   2  entrena 0.7223  prueba 0.7214  brecha +0.0009
  epoca   3  entrena 0.7278  prueba 0.7199  brecha +0.0079
  epoca   5  entrena 0.7344  prueba 0.7161  brecha +0.0184
  epoca  10  entrena 0.7506  prueba 0.7076  brecha +0.0430
  epoca  20  entrena 0.7688  prueba 0.6852  brecha +0.0836
  epoca  30  entrena 0.7799  prueba 0.6876  brecha +0.0923

El mejor resultado del capítulo está en la primera línea 😅

La época 1 da 0,7233 en prueba, que es el mejor número que ha sacado ninguna red en este libro sobre estos datos, y le pasa por delante al listón de la regresión logística, que era 0,7214. A partir de ahí solo empeora. Treinta épocas de entrenamiento para que la buena fuera la primera.

Y antes de que lo cuentes por ahí: esa ventaja es de 19 diezmilésimas, con una semilla y una partición. El capítulo 5 ya dejó escrito que una corrida no es un resultado, y esto es exactamente eso. La conclusión del libro no cambia: en tabla, la red empata con la logística en el mejor de los casos y cuesta cien veces más. Lo que sí es nuevo aquí es que empatar le costó una época en vez de quinientas vueltas 🐢

Eso no es un fallo de PyTorch ni de Adam. Es la misma lección del capítulo 7 con otra ropa: estos datos dan para lo que dan, y una red con 481 números que ajustar se los aprende de memoria en cuanto la dejas. Lo que cambió es la velocidad a la que llega: donde el descenso a mano necesitaba 500 vueltas por todas las filas, Adam con lotes de 64 llegó más alto en una sola pasada.

Los nombres nuevos, uno por uno, y todos son cosas que ya conoces:

Lo que poneQué es, en lo que ya sabes
nn.Dropout(0.2)El freno 2 del capítulo 8, apagar neuronas al azar
weight_decay=1e-3El freno 1, encoger los pesos grandes
DataLoaderPartir las filas en lotes y barajarlas en cada época
AdamDescenso de gradiente con un paso distinto para cada peso, ajustado sobre la marcha
épocaUna pasada por todas las filas, o sea 36 lotes de 64

Y ahora sí, red2.train() y red2.eval() hacen algo: el dropout apaga neuronas mientras entrena y no las apaga al medir. Si se te olvida el eval(), tus números de prueba salen peores de lo que son y encima cambian en cada corrida.

Los dos errores que salen la primera vez

Estos dos me los sé de memoria porque los he visto en todos los cursos que he dado. El primero sale al mezclar precisiones.

capa = nn.Linear(28, 8)     # nace en float32
capa(E_tr)                  # y mis datos son float64
RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float

Double es float64 y Float es float32, que es un vocabulario de C asomando por debajo. PyTorch crea las capas en float32 porque es la mitad de memoria y en GPU va al doble de rápido, y numpy trabaja en float64 por costumbre. Se arregla eligiendo un lado: .double() a la red, que es lo que hice arriba para poder comparar con numpy, o dtype=torch.float32 a los tensores, que es lo que harías con datos de verdad.

El segundo sale al querer sacar un número de un tensor que todavía está enganchado al grafo.

suelto = torch.tensor([2.0], requires_grad=True)
(suelto * 3).numpy()
RuntimeError: Can't call numpy() on Tensor that requires grad. Use tensor.detach().numpy() instead.

Este error es un favor. Ese tensor lleva colgando el historial entero de operaciones que hacen falta para el backward(), y si lo pasaras a numpy sin avisar, ese historial se quedaría vivo en memoria pegado a algo que ya no lo usa. Por eso te obliga a decirlo en voz alta con detach(), o a envolverlo en with torch.no_grad(): como hice en mide.

Lo de la GPU, que es una línea y no es magia

Todo lo de este capítulo corrió en el procesador de tu computadora y tardó segundos. Cuando los datos crecen, se mueve a la tarjeta gráfica así:

aparato = 'cuda' if torch.cuda.is_available() else 'cpu'
red2.to(aparato)
E_tr = E_tr.to(aparato)

Y ya. Lo importante es lo que no hace: una GPU no mejora tu modelo ni un decimal. Hace la misma cuenta más rápido, porque puede multiplicar matrices grandes en paralelo. Con 2.250 filas y 28 columnas no vas a notar nada, y muy probablemente vaya más lento, porque mover los datos a la tarjeta cuesta más que la cuenta que ahorras.

La regla que uso: GPU cuando el modelo no entra o el entrenamiento no cabe en una tarde. Antes de eso es un gasto y una fuente de errores nuevos.

La trampa

Una analista entrena su primera red en PyTorch, copia el bucle de un tutorial y lo adapta. La pérdida empieza en 0,69, baja un poco y después se dispara hasta dar nan. Prueba con menos paso, prueba con menos neuronas y sigue igual.

for epoca in range(30):
    for filas, etiquetas in carga:
        salida = red(filas)
        error = perdida(salida, etiquetas)
        error.backward()
        opt.step()
Qué está mal

Falta opt.zero_grad() al principio del lote. PyTorch suma los gradientes en vez de reemplazarlos, así que en el lote 10 el gradiente que se aplica es la suma de los diez anteriores, en el lote 100 la de los cien, y el paso efectivo crece sin parar hasta que los pesos se van a nan. La pista está en que empeora con el tiempo en vez de empeorar desde el principio. Y que sume no es un descuido de PyTorch: es lo que permite juntar varios lotes chicos en uno grande cuando no te cabe en memoria. Lo raro es útil, pero hay que saberlo.

Ejercicios

Seis. El 4 es el que hago yo antes de creerme cualquier resultado 💛

1. Cuatro optimizadores con el mismo paso

Entrena la misma red diez épocas con SGD, SGD con momento, RMSprop y Adam, todos con paso 0,01, y compara.

def arma(ancho=16, semilla=0):
    torch.manual_seed(semilla)
    return nn.Sequential(nn.Linear(28, ancho), nn.ReLU(), nn.Dropout(0.2),
                         nn.Linear(ancho, 1)).double()

def entrena_lotes(modelo, opt, epocas=10, lote=64):
    datos = DataLoader(TensorDataset(E_tr, O_tr), batch_size=lote, shuffle=True,
                       generator=torch.Generator().manual_seed(0))
    for _ in range(epocas):
        modelo.train()
        for filas, etiquetas in datos:
            opt.zero_grad()
            perdida(modelo(filas), etiquetas).backward()
            opt.step()
    return mide(modelo)

for nombre, hace in [
        ('SGD', lambda p: torch.optim.SGD(p, lr=0.01)),
        ('SGD momento', lambda p: torch.optim.SGD(p, lr=0.01, momentum=0.9)),
        ('RMSprop', lambda p: torch.optim.RMSprop(p, lr=0.01)),
        ('Adam', lambda p: torch.optim.Adam(p, lr=0.01))]:
    m = arma()
    a, b = entrena_lotes(m, hace(m.parameters()))
    print('%-13s entrena %.4f  prueba %.4f' % (nombre, a, b))
SGD           entrena 0.6042  prueba 0.6136
SGD momento   entrena 0.7159  prueba 0.7167
RMSprop       entrena 0.7571  prueba 0.6941
Adam          entrena 0.7587  prueba 0.7011

Guarda esta tabla, que desmonta dos cosas a la vez 🧨

La primera: el SGD pelado con paso 0,01 casi no aprende, 0,6136. No es que sea mal optimizador, es que ese paso le queda corto. Arriba, con paso 0,5, el mismo SGD llegó a 0,7150.

La segunda es la buena: el que gana en prueba es el SGD con momento, no Adam. Adam memoriza más (0,7587 contra 0,7159 en entrenamiento) y generaliza peor. Adam es el que casi todo el mundo pone por defecto, y en problemas grandes suele merecerlo, pero aquí no. Medir cuesta cuatro líneas y te ahorra creerte una costumbre.

2. El ancho de la capa oculta

Prueba 4, 16, 64 y 256 neuronas ocultas y saca también cuántos números tiene que ajustar cada red.

for ancho in [4, 16, 64, 256]:
    m = arma(ancho)
    a, b = entrena_lotes(m, torch.optim.Adam(m.parameters(), lr=0.01))
    cuantos = sum(t.numel() for t in m.parameters())
    print('%4d neuronas  %7d parametros  entrena %.4f  prueba %.4f'
          % (ancho, cuantos, a, b))
   4 neuronas      121 parametros  entrena 0.7288  prueba 0.7147
  16 neuronas      481 parametros  entrena 0.7587  prueba 0.7011
  64 neuronas     1921 parametros  entrena 0.8018  prueba 0.6724
 256 neuronas     7681 parametros  entrena 0.8502  prueba 0.6357

Las cuatro filas van en la misma dirección: cuanto más grande, mejor entrenamiento y peor prueba, sin una sola excepción 📉

La red de 121 parámetros le gana en prueba a la de 7.681 por casi ocho centésimas. Con 2.250 filas, 7.681 números que ajustar son demasiados, y el capítulo 8 explica por qué. La costumbre que te recomiendo: empieza por la red más chica que se te ocurra y crece solo si la prueba mejora.

3. El tamaño del lote

Entrena con lotes de 16, 64, 256 y con las 2.250 filas de golpe.

for lote in [16, 64, 256, 2250]:
    m = arma()
    a, b = entrena_lotes(m, torch.optim.Adam(m.parameters(), lr=0.01), lote=lote)
    print('lote %5d  entrena %.4f  prueba %.4f' % (lote, a, b))
lote    16  entrena 0.7708  prueba 0.6939
lote    64  entrena 0.7587  prueba 0.7011
lote   256  entrena 0.7399  prueba 0.7165
lote  2250  entrena 0.6906  prueba 0.7099

Al revés de lo que suele contarse 🙃

Se dice que los lotes chicos regularizan porque meten ruido, y aquí el lote más chico es el que peor generaliza. Lo que pasa es que con lotes de 16 hay 141 pasos por época y con lotes de 256 hay 9, así que en las mismas diez épocas la red del lote chico da quince veces más pasos y le da tiempo a memorizar mucho más. Se ve en la columna de entrenamiento: 0,7708 contra 0,7399.

El tamaño del lote no es solo el ruido: es también cuántos pasos das. Cuando compares tamaños de lote, mira si estás comparando épocas o pasos, porque no es lo mismo.

4. Guarda la red y vuelve a cargarla

Entrena tres épocas, guarda los pesos, cárgalos en una red recién hecha y comprueba que contesta exactamente igual.

import io

m = arma(16)
entrena_lotes(m, torch.optim.Adam(m.parameters(), lr=0.01), epocas=3)
m.eval()
with torch.no_grad():
    antes = m(E_te).numpy().ravel()

archivo = io.BytesIO()          # en tu maquina, la ruta de un .pt
torch.save(m.state_dict(), archivo)
archivo.seek(0)

otra = nn.Sequential(nn.Linear(28, 16), nn.ReLU(), nn.Dropout(0.2),
                     nn.Linear(16, 1)).double()
otra.load_state_dict(torch.load(archivo, weights_only=True))
otra.eval()
with torch.no_grad():
    despues = otra(E_te).numpy().ravel()

print('salen identicas:', np.array_equal(antes, despues))
print('primeras tres antes  :', np.round(antes[:3], 6))
print('primeras tres despues:', np.round(despues[:3], 6))
salen identicas: True
primeras tres antes  : [0.823251 0.833399 0.897478]
primeras tres despues: [0.823251 0.833399 0.897478]

Idénticas, no parecidas 🔐

Esto es lo que hago antes de creerme que un modelo está listo, y no por desconfianza: es que un modelo entrenado que no sabes volver a cargar es un modelo que se muere cuando se apague el cuaderno. Fíjate en el otra.eval(), que aquí no es opcional: sin él, el dropout seguiría apagando neuronas al azar y las dos filas no coincidirían nunca.

5. La matriz que PyTorch guarda al revés

Mira la forma de weight en una capa Linear y reproduce su salida multiplicando tú.

lineal = m[0]
print('lineal.weight.shape:', tuple(lineal.weight.shape))
print('columnas de entrada:', T_tr.shape[1], ' neuronas:', 16)

a_mano = T_tr @ lineal.weight.detach().numpy().T + lineal.bias.detach().numpy()
with torch.no_grad():
    suyo = lineal(E_tr).numpy()
print('mi multiplicacion y la suya coinciden:', np.allclose(a_mano, suyo))
lineal.weight.shape: (16, 28)
columnas de entrada: 28  neuronas: 16
mi multiplicacion y la suya coinciden: True

Tu W1 era de (28, 8) y el de PyTorch es de (16, 28), o sea al revés 🔄

Por eso arriba, al copiar los pesos, escribí W1.T y no W1. Es la fuente de confusión más tonta y más frecuente del principio, y el remedio es el de este ejercicio: no te fíes de la documentación, imprime la forma y reproduce el resultado tú.

6. Cuenta los parámetros y comprueba la cuenta

Lista los pesos con nombre y compara el total contra la fórmula escrita a mano.

m = arma(16)
for nombre, t in m.named_parameters():
    print('%-10s %-12s %6d' % (nombre, str(tuple(t.shape)), t.numel()))
print('%-10s %-12s %6d' % ('total', '', sum(t.numel() for t in m.parameters())))
print('a mano: 28*16 + 16 + 16*1 + 1 =', 28 * 16 + 16 + 16 * 1 + 1)
0.weight   (16, 28)        448
0.bias     (16,)            16
3.weight   (1, 16)          16
3.bias     (1,)              1
total                      481
a mano: 28*16 + 16 + 16*1 + 1 = 481

481 y 481 🎯

Los índices 0 y 3 son las posiciones dentro del Sequential: la 1 es la ReLU y la 2 el Dropout, y ninguna de las dos tiene nada que aprender. Saber contar parámetros a ojo es de las cosas que más rápido te hacen entender un modelo ajeno, y es una multiplicación y una suma por capa.

Comprueba que lo tienes

Entrenas una red en PyTorch, la pérdida baja bien pero al medir en prueba los números salen distintos en cada corrida. ¿Qué miras primero?

  • Si al medir llamaste a red.eval() antes
  • Si el DataLoader está barajando las filas
  • Si te falta fijar la semilla del optimizador
  • Si el paso es demasiado grande

Lo que te llevas

  • 🔦 backward() es el capítulo de retropropagación entero, y con los mismos pesos saca los mismos gradientes que sacaste tú.
  • 📋 La red en PyTorch reprodujo la tabla del capítulo 7 casilla por casilla, las quince.
  • 🔁 El bucle son tres líneas: zero_grad(), backward() y step(). Si te falta la primera, los gradientes se suman y acabas en nan.
  • 🏁 Con Adam y lotes de 64, el mejor resultado fue la época 1 con 0,7233. Más entrenamiento solo empeoró.
  • ⚖️ En los ejercicios, el SGD con momento le gana a Adam en prueba, y la red de 121 parámetros le gana a la de 7.681.
  • 🖥️ La GPU hace lo mismo más rápido. No mejora el modelo ni un decimal.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Una librería no sabe más que tú. Escribe más rápido lo que tú ya entiendes.

Si quieres ver el mismo salto en el otro sentido, en el libro de machine learning desde cero está scikit-learn desde el primer capítulo, y ahí el pipeline hace el mismo papel que aquí hace nn.Sequential 🧰. El vocabulario suelto está definido en el glosario de IA, y si el código de este capítulo te costó más que la idea, eso es de Python y se arregla en el libro de Python desde cero 🐍

En el capítulo 10 viene la pieza que hace posible entrenar redes profundas, y ahora ya sabes qué es ese eval() del que va a hablar.

Que tengas lindo día! 🌸

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab y no hay que instalar nada.

¿Tienes alguna duda o consulta?