Este capítulo empieza con una red que no aprende nada. Y no le pasa nada raro: le pasa lo que le pasa a casi todas las primeras veces 🫠
En el capítulo 4 vimos que apilar capas es lo que permite resolver lo que una sola no puede. Vamos a llevarlo al extremo: cuatro capas ocultas sobre los dígitos, que debería ir mejor que una, y a mirar qué ocurre.
Antes de correrlo, piénsalo tú un segundo: ¿qué le pasa a un número cuando lo multiplicas cuatro veces seguidas por una matriz? Ahí está toda la respuesta 🧠
Las cuatro capas, y lo que se les va de las manos
import numpy as np from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split d = load_digits() X = d.data / 16.0 y = d.target X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) print('para entrenar:', X_tr.shape) print('para probar :', X_te.shape)
para entrenar: (1347, 64) para probar : (450, 64)
Los mismos dígitos de 8 por 8 del capítulo 10. Ahora los pesos, y aquí viene el truco del capítulo: los voy a poder multiplicar por un número para desajustarlos a propósito.
def pesos_al_azar(escala, capas=4, H=32, semilla=0): # La red del capitulo: cuatro capas ocultas y una de salida, inicializadas # como en el proyecto final pero multiplicadas por `escala` para poder # desajustarlas a proposito. rng = np.random.default_rng(semilla) dims = [64] + [H] * capas + [10] return [rng.normal(0, escala * np.sqrt(2 / dims[i]), (dims[i], dims[i + 1])) for i in range(len(dims) - 1)] for escala in [1.0, 4.0]: A = X_tr anchos = [] for W in pesos_al_azar(escala)[:4]: A = np.maximum(0, A @ W) anchos.append(round(float(A.std()), 3)) print(f'pesos x{escala}, desviacion capa por capa:', anchos)
pesos x1.0, desviacion capa por capa: [0.281, 0.212, 0.252, 0.204] pesos x4.0, desviacion capa por capa: [1.123, 3.384, 16.119, 52.103]
Mira la segunda fila 😳
Con los pesos cuatro veces más grandes, los números que salen de cada capa se van multiplicando: 1,1 después 3,4 después 16,1 y al final 52,1. Cuatro capas y ya son cincuenta veces más anchos de lo que entraron.
La primera fila también dice algo, aunque más callado: 0,281 y va bajando hasta 0,204. Se encoge. En una red de cuarenta capas eso llega a cero y se llama gradiente que se desvanece, que es exactamente el problema del capítulo 12 pero en vertical en vez de en el tiempo.
A esto se le llama desplazamiento interno: cada capa aprende mirando lo que le manda la de abajo, y lo que le manda la de abajo cambia de escala mientras las dos aprenden. Es como aprender a cocinar mientras alguien te cambia la unidad de la balanza cada rato 🍳
La idea, que son tres líneas
Si el problema es que los números se van de escala, la solución es
devolverlos a su sitio antes de pasar a la siguiente capa. Restar la media y
dividir entre la desviación, que es exactamente lo que se le hace a una columna
de monto antes de meterla en un modelo:
Z = X_tr @ pesos_al_azar(4.0)[0] print('sin normalizar: media', round(float(Z.mean()), 3), ' desviacion', round(float(Z.std()), 3)) Zn = (Z - Z.mean(0)) / np.sqrt(Z.var(0) + 1e-5) print('normalizado : media', round(float(Zn.mean()), 3), ' desviacion', round(float(Zn.std()), 3))
sin normalizar: media -0.679 desviacion 2.664 normalizado : media 0.0 desviacion 1.0
Media 0 y desviación 1, que es donde las activaciones del capítulo 3 se portan bien 📏
El + 1e-5 no es decoración: si una neurona sale siempre igual su
varianza es cero, y sin ese numerito estarías dividiendo entre cero. Se llama
épsilon y va en todas las implementaciones de verdad.
Y lo importante, que es lo que le da nombre: la media y la desviación se calculan sobre el lote, o sea sobre las filas que están pasando ahora mismo por la red. Por eso se llama normalización por lotes y no normalización a secas 🔧
Gamma y beta, o cómo deshacerlo si conviene
Aquí hay una objeción buena, y si se te ocurrió a ti, muy bien: si forzamos todas las capas a media 0 y desviación 1, le estamos quitando libertad a la red. ¿Y si a esa capa le venía bien salir con media 3?
Por eso después de normalizar se vuelve a estirar y a correr, con dos números que la red aprende sola:
salida = gamma * normalizado + beta
gamma dice cuánto estirar y beta cuánto correr. Y
son parámetros como los pesos: empiezan en 1 y en 0, y el descenso de gradiente
del capítulo 5 los mueve.
O sea que la red puede deshacer la normalización si le conviene. La diferencia es que ahora lo elige en vez de sufrirlo 💛
La red entera, con y sin
Es larga porque está escrita a mano, como todo en este libro. Las líneas de la normalización son cinco y están marcadas:
def softmax(z): z = z - z.max(axis=1, keepdims=True) e = np.exp(z) return e / e.sum(axis=1, keepdims=True) def entrena(con_norma, escala=1.0, capas=4, H=32, vueltas=300, paso=0.5, semilla=0): W = pesos_al_azar(escala, capas, H, semilla) b = [np.zeros(H) for _ in range(capas)] + [np.zeros(10)] g = [np.ones(H) for _ in range(capas)] # gamma, cuanto estirar be = [np.zeros(H) for _ in range(capas)] # beta, cuanto correr mu_g = [np.zeros(H) for _ in range(capas)] # las medias que se guardan var_g = [np.ones(H) for _ in range(capas)] Y = np.eye(10)[y_tr] n = len(X_tr) for it in range(vueltas): A, guardado = [X_tr], [] for i in range(capas): Z = A[-1] @ W[i] + b[i] if con_norma: mu, var = Z.mean(0), Z.var(0) + 1e-5 mu_g[i] = 0.9 * mu_g[i] + 0.1 * mu # media movil var_g[i] = 0.9 * var_g[i] + 0.1 * var Zn = (Z - mu) / np.sqrt(var) guardado.append((var, Zn)) Z = g[i] * Zn + be[i] else: guardado.append(None) A.append(np.maximum(0, Z)) P = softmax(A[-1] @ W[-1] + b[-1]) if it == 0: primera = -np.mean(np.log(P[np.arange(n), y_tr] + 1e-12)) dZ = (P - Y) / n gW = [None] * len(W) gb = [None] * len(b) gW[-1] = A[-1].T @ dZ gb[-1] = dZ.sum(0) dA = dZ @ W[-1].T for i in range(capas - 1, -1, -1): dZi = dA * (A[i + 1] > 0) if con_norma: var, Zn = guardado[i] g[i] -= paso * (dZi * Zn).sum(0) be[i] -= paso * dZi.sum(0) dZn = dZi * g[i] dZi = (dZn - dZn.mean(0) - Zn * (dZn * Zn).mean(0)) / np.sqrt(var) gW[i] = A[i].T @ dZi gb[i] = dZi.sum(0) dA = dZi @ W[i].T for i in range(len(W)): W[i] -= paso * gW[i] b[i] -= paso * gb[i] ultima = -np.mean(np.log(P[np.arange(n), y_tr] + 1e-12)) return dict(W=W, b=b, g=g, be=be, mu=mu_g, var=var_g, capas=capas, con_norma=con_norma, primera=primera, ultima=ultima) def aplica(m, M, guardadas=True): for i in range(m['capas']): Z = M @ m['W'][i] + m['b'][i] if m['con_norma']: mu = m['mu'][i] if guardadas else Z.mean(0) var = m['var'][i] if guardadas else Z.var(0) + 1e-5 Z = m['g'][i] * (Z - mu) / np.sqrt(var) + m['be'][i] M = np.maximum(0, Z) return softmax(M @ m['W'][-1] + m['b'][-1]) print(f'{"pesos":>8} {"norma":>8} {"perdida":>18} {"acierto":>9}') for escala in [1.0, 4.0]: for con in [False, True]: m = entrena(con, escala) acierto = (aplica(m, X_te).argmax(1) == y_te).mean() print(f'{escala:8.1f} {"si" if con else "no":>8} ' f'{m["primera"]:8.3f} -> {m["ultima"]:6.3f} {acierto:9.4f}')
pesos norma perdida acierto
1.0 no 2.369 -> 0.372 0.8467
1.0 si 2.837 -> 0.003 0.9622
4.0 no 24.471 -> 2.302 0.1022
4.0 si 6.512 -> 0.012 0.9044
Ahí está todo el capítulo en cuatro filas 📊
Con los pesos bien puestos, la norma sube de 0,8467 a 0,9622. Está bien, pero no es el titular.
El titular es la tercera fila: con los pesos desajustados la red no aprende nada. Pérdida 2,302, que es exactamente lo que da contestar al azar entre diez dígitos, y acierto 0,1022, que es uno de cada diez. Trescientas vueltas para quedarse donde empezó.
Y la cuarta fila es la misma red, con los mismos pesos rotos, y las tres líneas puestas: 0,9044. De tirar el modelo a la basura a un modelo que sirve 💪
Eso es lo que hace la normalización por lotes de verdad. No es que mejore un poco: es que hace entrenable lo que no lo era. Por eso está en casi toda red que veas.
Normalizar por lotes no mejora una red que ya entrena. Hace entrenar a una que no podía.
Y ahora el problema que trae debajo
Vuelve a leer la idea: la media y la desviación se calculan sobre el lote.
Eso está muy bien mientras entrenas, porque siempre tienes mil filas pasando a la vez. Pero cuando pongas el modelo a trabajar te va a llegar un pedido, uno solo, y un lote de una fila tiene media él mismo y desviación cero 😰
Por eso en el código de arriba hay dos líneas que todavía no expliqué:
mu_g[i] = 0.9 * mu_g[i] + 0.1 * mu var_g[i] = 0.9 * var_g[i] + 0.1 * var
Son medias móviles. Durante el entrenamiento se va guardando un promedio de las medias y las varianzas de todos los lotes que pasaron, y esos números guardados son los que se usan después, cuando toca predecir.
Mira si da igual o no da igual:
m = entrena(True) print('con las medias guardadas :', round(float((aplica(m, X_te).argmax(1) == y_te).mean()), 4)) print('con las del lote de prueba :', round(float((aplica(m, X_te, guardadas=False).argmax(1) == y_te).mean()), 4))
con las medias guardadas : 0.9622 con las del lote de prueba : 0.96
Casi lo mismo, ¿no? 0,9622 contra 0,96. Aquí es donde uno cierra el archivo y se va tranquilo 🙃
Y aquí es donde te la pegas. Porque en producción no llegan 450 filas juntas: llega una.
print('lo que de verdad son :', y_te[:10].tolist()) print('todas juntas, medias guardadas :', aplica(m, X_te).argmax(1)[:10].tolist()) print('fila por fila, medias guardadas:', [int(aplica(m, X_te[i:i + 1]).argmax(1)[0]) for i in range(10)]) print('fila por fila, medias del lote :', [int(aplica(m, X_te[i:i + 1], guardadas=False).argmax(1)[0]) for i in range(10)])
lo que de verdad son : [1, 0, 9, 1, 5, 6, 7, 9, 1, 5] todas juntas, medias guardadas : [1, 0, 9, 1, 5, 6, 7, 8, 1, 5] fila por fila, medias guardadas: [1, 0, 9, 1, 5, 6, 7, 8, 1, 5] fila por fila, medias del lote : [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
Tres. Diez veces 😵
Con las medias del propio lote, la red contesta 3 a todo. Y tiene sentido: si el lote es una fila sola, normalizarla contra sí misma la deja en ceros, así que las diez filas entran a la red convertidas en la misma cosa y salen con la misma respuesta.
La línea de arriba es la que salva: con las medias guardadas, fila por fila da exactamente lo mismo que en lote. Idéntico, y fíjate que incluye el mismo fallo, ese 8 donde había un 9. Eso es lo que uno quiere de un modelo en producción: que conteste igual esté solo o acompañado, aunque se equivoque 🎯
Y este es el motivo por el que en PyTorch existen model.train() y
model.eval(), y por el que olvidarse del segundo es de los errores
más repetidos del mundo entero. No es un capricho de la librería: es esta
diferencia.
El error que sí revienta
Cada capa tiene su propio gamma y su propio beta, y
son del ancho de esa capa. Si te confundes de capa al guardarlos o al cargarlos,
sale esto:
gamma_de_la_primera = np.ones(32) Z_de_una_capa_de_64 = np.zeros((100, 64)) gamma_de_la_primera * Z_de_una_capa_de_64
ValueError: operands could not be broadcast together with shapes (32,) (100,64)
Este es el error bueno, el que te avisa 🚩
El malo es el de arriba, el de las medias, que no revienta: te devuelve predicciones perfectamente formadas y todas iguales.
Y en tu trabajo, ¿dónde toca esto?
Te lo aterrizo con la distribuidora del libro, que es de donde salen las ventas, el canal y la ciudad de todos los capítulos 💼
| Lo que estás haciendo | ¿Normalizar por lotes? |
|---|---|
| Una red de dos capas sobre tu tabla de ventas | Ni te molestes. Con dos capas no hay nada que se descontrole, y antes revisa el capítulo 1 |
| Leer el monto de una factura escaneada, con una red de verdad | Sí, y ya viene puesta. Toda arquitectura moderna de imágenes la trae |
| Servir ese modelo pedido por pedido desde una API | Aquí es donde importa este capítulo. Modo evaluación siempre, y compruébalo mandando la misma fila sola y acompañada |
| Predecir el stock de la semana con una red recurrente | Ahí se usa la prima hermana, la normalización por capa, que es la del capítulo 14 |
La tercera fila es la que yo pondría en el checklist de todo el mundo: antes de dar por bueno un despliegue, manda una fila sola y compárala con la misma fila dentro de un lote. Si no dan lo mismo, tienes esto 🔍
La prima hermana, en dos líneas
Porque te la vas a encontrar y conviene que no te sorprenda. La normalización por capa hace la misma cuenta pero al revés: en vez de promediar cada neurona a lo largo de las filas del lote, promedia todas las neuronas de una fila.
por lotes: media de cada neurona, a lo largo de las filas del lote
por capa : media de todas las neuronas, dentro de una sola fila
La diferencia práctica es enorme: la de capa no depende del lote, así que no tiene el problema de arriba y no necesita guardar medias. Por eso es la que usan los transformers del capítulo 14, donde cada frase tiene un largo distinto y hacer lotes es un lío 🧩
Si quieres el detalle formal de las dos, el paper original de la de lotes es Batch Normalization (2015) y el de la de capa es Layer Normalization (2016). Los dos se leen bien.
Y una nota honesta sobre el porqué: durante años se explicó que la normalización funciona porque quita el desplazamiento interno, que es como te lo conté al principio. En 2018 salió How Does Batch Normalization Help Optimization?, que mide eso mismo y encuentra que la explicación no se sostiene: lo que la normalización hace es suavizar el terreno por donde baja el gradiente. Funciona igual de bien; lo que cambió es el porqué 📚
La trampa
Un equipo despliega una red con normalización por lotes para leer el monto de las facturas que llegan escaneadas. La prueban con un lote de mil facturas viejas, sale 0,96 y la ponen en producción. A la semana, la mitad de los montos leídos están mal y nadie entiende por qué.
# el servicio, que atiende una factura por peticion def lee_monto(imagen): lote = imagen.reshape(1, -1) # una sola factura return red.predict(lote) # y asi se probo antes de subirlo print(red.score(mil_facturas_viejas, montos_reales)) # 0.96
Qué está mal
Se probó con mil facturas a la vez y se usa de una en una. Con normalización por lotes eso no es lo mismo: si la red sigue calculando la media y la desviación del lote que le llega, un lote de una fila se normaliza contra sí mismo y sale convertido en ceros, así que todas las facturas le parecen la misma. En el capítulo está medido: diez filas distintas y la red contesta 3 a las diez. La corrección es usar las medias móviles guardadas durante el entrenamiento, que en PyTorch es model.eval() y en Keras se hace solo al llamar a predict. Y la prueba que lo hubiera cazado antes de subirlo cabe en dos líneas: manda una fila sola, mándala dentro de un lote, y comprueba que dan lo mismo.
Ejercicios
Seis. El 4 es el que yo haría antes de subir cualquier modelo a producción 💛
1. Sube la escala hasta romperlo del todo
Prueba con escala 8 y 16, con norma y sin.
for escala in [1.0, 4.0, 8.0, 16.0]: for con in [False, True]: m = entrena(con, escala) acierto = (aplica(m, X_te).argmax(1) == y_te).mean() print(f'x{escala:<5} {"con" if con else "sin":>4} norma: {acierto:.4f}')
La pregunta que contesta: ¿hay un punto donde ni la normalización salva la red? Búscalo, que saber dónde se rompe una herramienta vale más que saber que funciona.
2. Quita gamma y beta
Deja g fijo en 1 y be fijo en 0,
o sea normaliza y no dejes que la red lo deshaga.
Se hace borrando las dos líneas que los actualizan. Y ojo con lo que esperas: el acierto baja poco, porque en una red chiquita como esta la libertad que dan importa menos de lo que suena. Vale igual medirlo, que es la única forma de saber cuánto aporta cada pieza.
3. Normaliza solo la primera capa
En vez de las cuatro, normaliza únicamente la capa 0 y mide.
Si el problema fuera solo de la entrada, con esto bastaría. Si el problema es que la escala se va acumulando capa a capa, no va a bastar. El resultado te dice cuál de las dos historias es la verdadera, y ese es el tipo de experimento que convierte una explicación en un hecho 🔬
4. La prueba de las dos formas
La que te salva del error de la trampa. Escríbela como función y déjala puesta.
def coinciden(m, M): juntas = aplica(m, M).argmax(1) solas = np.array([aplica(m, M[i:i + 1]).argmax(1)[0] for i in range(len(M))]) return (juntas == solas).mean() print('con medias guardadas:', coinciden(m, X_te[:50]))
Tiene que dar 1.0 exacto. Si da menos, tu modelo contesta distinto según con quién viaje, y eso en producción es un problema silencioso.
5. Lotes pequeños
Entrena con lotes de 8 filas en vez de con las 1.347 de golpe y compara.
Con lotes chicos la media y la desviación se calculan sobre poquitas filas, así que son ruidosas. Ese ruido tiene dos caras: molesta al entrenar y de paso regulariza, que es lo del capítulo 8. Mide las dos caras antes de opinar.
6. Ordena las cajas al revés
Mueve la normalización a después de la ReLU en vez de antes y mira si cambia algo.
El paper original la pone antes de la activación y es lo que hicimos aquí. Mucha implementación moderna la pone después y funciona igual de bien. Es una de esas discusiones donde la respuesta honesta es medir en tu caso, no citar a nadie 🙂
Comprueba que lo tienes
Tu modelo con normalización por lotes da 0,96 en la prueba y en producción contesta casi siempre lo mismo. ¿Qué miras primero?
- Si al predecir está usando las medias guardadas o las del lote que llega
- Si los datos de producción vienen de otra distribución
- Si la red está sobreajustada
- Si hay que entrenar más vueltas
Lo que te llevas
- 📏 Normalizar por lotes es restar la media y dividir entre la desviación del lote, dentro de la red y en cada capa.
- 💪 Sobre estos dígitos, con los pesos desajustados, la red pasa de 0,1022 a 0,9044. No mejora: hace posible lo que no lo era.
- 🎛️
gammaybetale devuelven a la red la libertad que la normalización le quita, y los aprende sola. - ⚠️ Al predecir hay que usar las medias guardadas. Con las del lote, diez filas distintas devuelven 3 diez veces.
- 🧩 En texto se usa la prima hermana, la normalización por capa, que no depende del lote.
En el capítulo 10 está la otra pieza que trae toda red de imágenes, y el vocabulario suelto está en el glosario de IA 📖
Que tengas lindo día! 🌸