El capítulo 10 terminó con tres cosas mal, y una era que el orden se pierde. Este capítulo arregla esa 🔗
Antes de empezar, una pregunta: ¿cómo le explicarías a una máquina la diferencia entre "llegó el pedido pero no la factura" y "llegó la factura pero no el pedido"? Son las mismas ocho palabras, la misma cuenta de cada una, y significan lo contrario 🤔
Y te aviso de una vez cómo termina, porque este libro no le hace publicidad a nada: las redes de este capítulo perdieron. Hoy casi nadie las usa. Están aquí porque sin entender qué no pudieron hacer, la atención del capítulo 12 parece una idea bonita en vez de lo que es, que es la solución a un problema concreto y medido.
Una tarea que la bolsa de palabras no puede
Vamos a fabricar comentarios de clientes de la distribuidora. Dos formas, y en las dos falta algo distinto.
import numpy as np PALABRAS = ['ayer', 'hoy', 'llego', 'el', 'la', 'pedido', 'factura', 'pero', 'no', 'todavia'] IDX = {p: i for i, p in enumerate(PALABRAS)} # Mismas palabras, orden distinto, significado contrario. def comentarios(n, semilla): rng = np.random.default_rng(semilla) X, y = [], [] for _ in range(n): dia = 'ayer' if rng.random() < 0.5 else 'hoy' cola = ['todavia'] if rng.random() < 0.5 else [] if rng.random() < 0.5: f = [dia, 'llego', 'el', 'pedido', 'pero', 'no', 'la', 'factura'] etiqueta = 0 # lo que falta es la factura else: f = [dia, 'llego', 'la', 'factura', 'pero', 'no', 'el', 'pedido'] etiqueta = 1 # lo que falta es el pedido X.append(f + cola) y.append(etiqueta) return X, np.array(y) Xtr, ytr = comentarios(600, 0) Xte, yte = comentarios(200, 1) print(' '.join(Xtr[0]), ' -> falta', ['la factura', 'el pedido'][ytr[0]]) for i, f in enumerate(Xtr): if ytr[i] != ytr[0]: print(' '.join(f), ' -> falta', ['la factura', 'el pedido'][ytr[i]]) break
hoy llego el pedido pero no la factura todavia -> falta la factura ayer llego la factura pero no el pedido -> falta el pedido
Esto no es un juego de palabras: es el correo que le llega a operaciones un lunes. Y de la respuesta depende a quién se le reclama 📦
Ahora contemos las palabras, que es lo que hace una bolsa de palabras, el método más común de todos:
from sklearn.linear_model import LogisticRegression def bolsa(X): M = np.zeros((len(X), len(PALABRAS))) for i, f in enumerate(X): for p in f: M[i, IDX[p]] += 1 return M modelo = LogisticRegression(max_iter=1000).fit(bolsa(Xtr), ytr) print('filas distintas en la bolsa:', len(np.unique(bolsa(Xtr), axis=0)), 'de', len(Xtr)) print('acierto de la bolsa de palabras:', round(modelo.score(bolsa(Xte), yte), 4))
filas distintas en la bolsa: 4 de 600 acierto de la bolsa de palabras: 0.53
Mira la primera línea, que es la que lo explica todo: 600 comentarios y solo 4 filas distintas 😳
Las dos variantes cuentan exactamente las mismas palabras, así que al contar se vuelven idénticas. Las cuatro combinaciones salen de ayer u hoy y de si lleva todavía, que no dicen nada.
Y el acierto es 0,53, que con dos clases equilibradas es tirar una moneda. No es que el modelo esté mal entrenado: es que le estamos dando una entrada donde la respuesta no está. Ningún modelo, por grande que sea, adivina lo que le borraste 🪙
La recurrente, escrita
La idea es de una línea: leer las palabras en orden y arrastrar un estado. Ese estado es lo que la red se acuerda de lo que ya leyó, y en cada palabra se actualiza mezclando lo que venía con lo nuevo.
V, D, H = len(PALABRAS), 8, 12 def sigmoide(z): return 1 / (1 + np.exp(-z)) def arranca(semilla): r = np.random.default_rng(semilla) return dict(E=r.normal(0, .5, (V, D)), Wx=r.normal(0, .5, (D, H)), Wh=r.normal(0, .5, (H, H)) * .5, b=np.zeros(H), Wo=r.normal(0, .5, (H, 1)), bo=np.zeros(1)) def adelante(p, ids): hs = [np.zeros(H)] for t in ids: x = p['E'][t] hs.append(np.tanh(x @ p['Wx'] + hs[-1] @ p['Wh'] + p['b'])) return hs, float(sigmoide(hs[-1] @ p['Wo'] + p['bo'])[0]) p = arranca(1) frase = ['hoy', 'llego', 'el', 'pedido'] hs, _ = adelante(p, [IDX[w] for w in frase]) for w, h in zip(frase, hs[1:]): print(f'{w:8} estado -> {np.round(h[:4], 3)} ...')
hoy estado -> [ 0.385 0.298 0.188 -0.426] ... llego estado -> [-0.445 -0.033 0.128 0.903] ... el estado -> [-0.736 -0.421 -0.374 0.983] ... pedido estado -> [ 0.212 -0.465 0.451 -0.171] ...
Fíjate en la línea del medio, que es toda la novedad del capítulo:
hs.append(np.tanh(x @ p['Wx'] + hs[-1] @ p['Wh'] + p['b']))
Es la misma neurona del capítulo 2 con un
sumando de más: hs[-1] @ Wh, o sea el estado anterior. Eso es una
recurrente entera. No hay más 🔁
Y por eso el vector de pedido es distinto según lo que venga antes:
no depende solo de la palabra, depende del camino. La misma Wh se
usa en todos los pasos, igual que el filtro del capítulo 9 se
usaba en todas las posiciones. Es la misma idea de compartir pesos, aplicada al
tiempo en vez de al espacio 🔑
Entrenarla, hacia atrás y en el tiempo
La retropropagación del capítulo 6 sirve igual, con un detalle: hay que recorrer la frase al revés, acumulando en las mismas matrices porque son las mismas en todos los pasos. Eso tiene nombre propio: retropropagación en el tiempo.
def gradiente(p, ids, y): hs, prob = adelante(p, ids) g = {k: np.zeros_like(v) for k, v in p.items()} d = np.array([prob - y]) g['Wo'] = np.outer(hs[-1], d); g['bo'] = d dh = (p['Wo'] @ d).ravel() for t in range(len(ids) - 1, -1, -1): dz = dh * (1 - hs[t + 1] ** 2) g['Wx'] += np.outer(p['E'][ids[t]], dz) g['Wh'] += np.outer(hs[t], dz) g['b'] += dz g['E'][ids[t]] += p['Wx'] @ dz dh = p['Wh'] @ dz return g def entrena(p, X, y, vueltas=10, paso=0.1): I = [[IDX[w] for w in f] for f in X] for v in range(vueltas): for i in np.random.default_rng(v).permutation(len(I)): g = gradiente(p, I[i], y[i]) for k in p: p[k] -= paso * g[k] return p def acierta(p, X, y): return float(np.mean([(adelante(p, [IDX[w] for w in f])[1] >= .5) == yy for f, yy in zip(X, y)])) p = entrena(arranca(1), Xtr, ytr) print('acierto de la recurrente:', round(acierta(p, Xte, yte), 4))
acierto de la recurrente: 1.0
De 0,53 a 1,0 🎉
Y el modelo no es más grande ni más listo: es que recibe la información que la bolsa de palabras tiraba. Casi siempre que un modelo no puede con algo, la pregunta buena no es qué modelo poner, sino qué le estamos dando de entrada.
El problema, medido paso a paso
Ahora la parte que explica por qué estas redes ya no se usan. Mira otra vez la última línea del bucle de arriba:
dh = p['Wh'] @ dz
Cada paso hacia atrás multiplica por Wh. Otra vez. Y otra. En una
frase de treinta palabras, lo que le llega al primer paso pasó por treinta
multiplicaciones seguidas.
Vamos a medir cuánto queda.
def cuanto_gradiente_llega(p, ids): hs, prob = adelante(p, ids) dh = (p['Wo'] @ np.array([prob - 1])).ravel() normas = [] for t in range(len(ids) - 1, -1, -1): dz = dh * (1 - hs[t + 1] ** 2) normas.append(np.linalg.norm(dz)) dh = p['Wh'] @ dz return normas[::-1] largo = 30 ids = [IDX['pedido']] + list(np.random.default_rng(0).integers(0, V, size=largo - 1)) n = cuanto_gradiente_llega(arranca(1), ids) print(f'{"paso":>5} {"gradiente que llega":>21}') for t in [0, 5, 10, 15, 20, 25, 29]: print(f'{t+1:5} {n[t]:21.3e}') print(f'\ndel paso 30 al paso 1 se divide entre {n[-1]/n[0]:.0f}')
paso gradiente que llega
1 6.848e-07
6 3.134e-06
11 5.348e-05
16 4.828e-04
21 5.134e-03
26 6.448e-02
30 4.606e-01
del paso 30 al paso 1 se divide entre 672620
Ahí está, y es de las cosas que se entienden mejor viéndolas que leyéndolas 📉
La señal que corrige el último paso vale 0,46. La que le llega al primero vale 0,0000007, o sea seiscientas setenta y dos mil veces menos. El primer paso prácticamente no se entera de que hubo un error.
Eso se llama gradiente que se desvanece, y es el mismo fenómeno del capítulo 7 con las capas profundas, con una diferencia que lo empeora: allá el número de capas lo eliges tú, y aquí lo elige la longitud de la frase. Un comentario de cincuenta palabras son cincuenta capas, quieras o no 😰
La consecuencia práctica es directa: la red aprende lo cercano y no aprende lo lejano. Con nuestras frases de ocho palabras funciona perfecto. Con un párrafo, no.
El primer error que te va a salir
Todo lo de arriba funciona porque nuestras frases miden ocho o nueve palabras y las procesamos de una en una. En cuanto quieras hacer varias a la vez, que es lo que se hace de verdad, aparece esto 👇
lote = [[IDX[w] for w in f] for f in Xtr[:3]] for f in lote: print(len(f), f) np.array(lote)
ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (3,) + inhomogeneous part.
inhomogeneous shape. Una matriz de numpy quiere filas del mismo largo, y los comentarios no lo son: unos llevan todavía y otros no.
Esto no es un detalle de numpy, es el problema central de trabajar con secuencias. Todo lo que sabes de matrices asume filas iguales, y el lenguaje no viene así. La solución estándar es rellenar las cortas hasta el largo de la más larga, y esa solución trae su propia trampa, que es la de este capítulo. Léela con calma 👀
La LSTM, o darle un carril propio a la memoria
En 1997, Hochreiter y Schmidhuber publicaron el arreglo, y es de esas ideas que una vez que la ves ya no se te olvida.
Si el problema es que el recuerdo tiene que atravesar una multiplicación en cada paso, entonces hay que darle un camino donde no lo multipliquen. La LSTM añade una segunda línea, la memoria, que solo se suma y se borra, y tres puertas que deciden qué pasa con ella:
- 🗑️ La puerta de olvido decide qué se borra de lo guardado.
- 📥 La de entrada decide qué de lo nuevo se guarda.
- 👁️ La de salida decide qué de lo guardado se deja ver.
Cada puerta es una sigmoide, o sea un número entre 0 y 1 por cada casilla de la memoria: 0 es cerrado y 1 es abierto. Y se aprenden, como todo lo demás.
def arranca_lstm(semilla): r = np.random.default_rng(semilla) p = dict(E=r.normal(0, .5, (V, D)), W=r.normal(0, .3, (D + H, 4 * H)), b=np.zeros(4 * H), Wo=r.normal(0, .5, (H, 1)), bo=np.zeros(1)) p['b'][:H] = 1.0 # la puerta de olvido arranca abierta return p def adelante_lstm(p, ids): h = np.zeros(H); c = np.zeros(H); pasos = [] for t in ids: x = p['E'][t] z = np.concatenate([x, h]) a = z @ p['W'] + p['b'] olvido = sigmoide(a[:H]) # que borro de la memoria entrada = sigmoide(a[H:2*H]) # que dejo entrar salida = sigmoide(a[2*H:3*H]) # que dejo ver nuevo = np.tanh(a[3*H:]) # lo que propongo guardar c_ant = c c = olvido * c_ant + entrada * nuevo tc = np.tanh(c) h = salida * tc pasos.append((t, z, olvido, entrada, salida, nuevo, c_ant, c, tc, h)) return pasos, float(sigmoide(h @ p['Wo'] + p['bo'])[0]) pasos, _ = adelante_lstm(arranca_lstm(1), [IDX[w] for w in frase]) _, _, olvido, entrada, salida, _, _, _, _, _ = pasos[-1] print('en el ultimo paso las puertas valen (primeras 4 casillas):') print(' olvido ', np.round(olvido[:4], 3)) print(' entrada', np.round(entrada[:4], 3)) print(' salida ', np.round(salida[:4], 3))
en el ultimo paso las puertas valen (primeras 4 casillas): olvido [0.732 0.771 0.823 0.741] entrada [0.557 0.467 0.427 0.659] salida [0.496 0.52 0.537 0.488]
Las tres puertas son tres números por casilla y ninguno es 0 ni 1: la red decide cuánto, no sí o no. Y fíjate en la de olvido, que sale por encima de 0,7 en las cuatro: sin entrenar todavía, ya está conservando la mayor parte de lo que guardó, que es exactamente para lo que se puso ese sesgo 🚪
La línea que importa de todo el bloque es esta:
c = olvido * c_ant + entrada * nuevo
La memoria nueva es la vieja, con una parte borrada, más lo que entra. Es una suma, no una transformación. Si la puerta de olvido está abierta (vale 1), la memoria pasa entera al siguiente paso sin que nadie la multiplique por una matriz.
Y por eso p['b'][:H] = 1.0 no es un detalle: arrancar con la
puerta de olvido abierta es lo que hace que al principio del entrenamiento la
memoria se conserve en vez de irse a cero. Es una de esas decisiones que no
aparecen en los diagramas y deciden si la cosa entrena o no 🔧
La misma medición, sobre la LSTM
Repetimos exactamente el experimento de antes, con la misma frase de treinta pasos. La cuenta hacia atrás cambia porque la memoria tiene su propio camino.
def cuanto_gradiente_llega_lstm(p, ids): pasos, prob = adelante_lstm(p, ids) dh = (p['Wo'] @ np.array([prob - 1])).ravel() dc = np.zeros(H); normas = [] for t in range(len(ids) - 1, -1, -1): _, z, olvido, entrada, salida, nuevo, c_ant, c, tc, h = pasos[t] d_salida = dh * tc dc = dc + dh * salida * (1 - tc ** 2) normas.append(np.linalg.norm(dc)) da = np.concatenate([ dc * c_ant * olvido * (1 - olvido), dc * nuevo * entrada * (1 - entrada), d_salida * salida * (1 - salida), dc * entrada * (1 - nuevo ** 2)]) dz = p['W'] @ da dh = dz[D:] dc = dc * olvido return normas[::-1] m = cuanto_gradiente_llega_lstm(arranca_lstm(1), ids) print(f'{"paso":>5} {"recurrente":>13} {"LSTM":>13}') for t in [0, 10, 20, 29]: print(f'{t+1:5} {n[t]:13.3e} {m[t]:13.3e}') print(f'\nla recurrente se divide entre {n[-1]/n[0]:.0f}') print(f'la LSTM se divide entre {m[-1]/m[0]:.0f}')
paso recurrente LSTM
1 6.848e-07 7.148e-03
11 5.348e-05 2.943e-02
21 5.134e-03 1.360e-01
30 4.606e-01 4.519e-01
la recurrente se divide entre 672620
la LSTM se divide entre 63
672.620 contra 63 🎯
Cuatro órdenes de magnitud, y ninguna de las dos redes está entrenada: es puramente la forma de la arquitectura. Mira la última línea del bucle, que es la que lo consigue:
dc = dc * olvido
Hacia atrás, la memoria se multiplica por la puerta de olvido, que es un número entre 0 y 1 y arranca cerca de 1. La recurrente se multiplicaba por una matriz entera. Multiplicar por algo cercano a 1 treinta veces deja casi todo; multiplicar por una matriz treinta veces no deja nada 🚪
Entonces por qué no ganaron
Por una razón que no tiene nada que ver con la memoria, y que se ve en cuatro líneas.
import time X = np.random.default_rng(0).normal(0, 1, (200, H)) W = np.random.default_rng(1).normal(0, 1, (H, H)) inicio = time.time() for _ in range(50): h = np.zeros(H) for fila in X: # en cadena: cada paso espera al anterior h = np.tanh(fila @ W + h @ W) uno_a_uno = time.time() - inicio inicio = time.time() for _ in range(50): _ = np.tanh(X @ W) # de golpe: las 200 a la vez de_golpe = time.time() - inicio print(f'200 pasos en cadena : {uno_a_uno:.4f} s') print(f'las 200 filas de golpe: {de_golpe:.4f} s') print(f'la cadena tarda {uno_a_uno/de_golpe:.0f} veces mas')
200 pasos en cadena : ...
El número exacto cambia en tu máquina; lo que no cambia es de qué lado está. En la mía la cadena tardó decenas de veces más 🐌
Y la culpa no es de numpy: es que el paso 2 necesita el resultado del paso 1. No hay forma de hacerlos a la vez, ni con mil tarjetas gráficas. Una recurrente es secuencial por definición.
Eso, que con frases de ocho palabras da igual, es lo que decidió la historia. Cuando en 2017 apareció una arquitectura que mira todas las palabras a la vez y resuelve las dependencias largas, se acabó la discusión: las recurrentes tardaban semanas donde la otra tardaba días, y encima entendía mejor 🏁
Esa arquitectura es la del capítulo 12, y ahora ya sabes contra qué compite.
Una recurrente recuerda multiplicando, y multiplicar muchas veces borra. Una LSTM recuerda sumando, y por eso aguanta.
Dónde siguen vivas
Que perdieran en lenguaje no quiere decir que no sirvan. Se siguen usando donde la secuencia es corta y el equipo es chico, porque una LSTM entrena en un portátil y un transformer no 💻
| Dónde | Por qué ahí sí |
|---|---|
| Sensores y series cortas | Pocas variables, muchos datos, secuencias de decenas de pasos y no de miles |
| Dispositivos con poca memoria | Una LSTM chica cabe donde un transformer no |
| Equipos sin GPU | Entrenan con lo que tengas, aunque tarden |
| Texto largo o cualquier cosa con lenguaje | Aquí ya no. Es territorio de transformers desde 2017 |
Y si lo que tienes es una serie de tiempo de negocio, antes de una LSTM prueba lo simple: para pronosticar ventas por mes, un modelo de estadística clásica le gana a una red casi siempre, por la misma razón del capítulo 1. Eso está en el libro de estadística desde cero 📊
La trampa
Un equipo entrena una LSTM para clasificar comentarios de clientes. Cada comentario es una lista de palabras de largo distinto, así que para meterlos todos en una matriz los rellenan con ceros hasta el más largo. Esto es lo que corre.
# comentarios: lista de listas de indices, de largos distintos largo_max = max(len(c) for c in comentarios) X = np.zeros((len(comentarios), largo_max), dtype=int) for i, c in enumerate(comentarios): X[i, :len(c)] = c # el resto se queda en 0 h = np.zeros((len(comentarios), H)) for t in range(largo_max): h = paso_lstm(X[:, t], h) # todos los comentarios avanzan a la vez prediccion = clasifica(h) # se usa el estado del ultimo paso
Qué está mal
El estado que se usa al final es el del paso largo_max, y para casi todos los comentarios ese paso es relleno. Un comentario de seis palabras en un lote donde el más largo tiene ochenta pasa setenta y cuatro pasos procesando ceros, y en cada uno de esos pasos la puerta de olvido sigue borrando lo que había guardado. Cuando llega el final, lo que la red "recuerda" del comentario ya se diluyó, y encima el 0 es un índice válido del vocabulario, así que la red está leyendo setenta y cuatro veces la palabra número 0 como si fuera una palabra de verdad. Se arregla de dos maneras y hay que hacer las dos: guardar el largo real de cada comentario y tomar el estado de ESE paso, y reservar el índice 0 para un relleno que no signifique nada. Es el error más común de quien empieza con secuencias, y no da error: da un modelo que acierta el 50%.
Ejercicios
Seis, y el 4 es el que de verdad enseña. Intenta antes de abrir 💛
1. Cambia el largo de la memoria
Prueba H = 2 y H = 40 en la tarea de los comentarios
y compara el acierto.
for tamano in [2, 4, 12, 40]: H = tamano p = entrena(arranca(1), Xtr, ytr) print(f'H={tamano:3} acierto {acierta(p, Xte, yte):.4f}')
Ojo con una cosa al correrlo: H es una variable global que usan
arranca y adelante, así que reasignarla cambia la red
entera. Si te sale un error de formas que no cuadran, es que quedó un
p viejo de otro tamaño dando vueltas.
2. Rómpelo a propósito
Quita el sumando del estado anterior, o sea deja
np.tanh(x @ p['Wx'] + p['b']), y vuelve a entrenar.
Tiene que caerse a la zona del 0,5, porque sin ese sumando ya no es una recurrente: es una neurona normal aplicada a la última palabra. Es la forma más rápida de comprobar que entendiste qué línea hace el trabajo.
3. Alarga la frase
Cambia largo = 30 por 60 y por 100 en la medición del gradiente,
y mira cómo crece la división.
Vas a ver que no crece: se dispara. Cada paso multiplica, así que la caída es geométrica, y por eso el problema no se arregla "entrenando más".
4. La prueba de memoria de verdad
El experimento que cierra el capítulo, y va aparte porque tarda un par de minutos. La tarea: la palabra clave va al principio, después vienen treinta de relleno, y hay que decir cuál era la clave.
def memoria(n, relleno, semilla): rng = np.random.default_rng(semilla) X, y = [], [] for _ in range(n): clave = int(rng.random() < 0.5) X.append([clave] + list(rng.integers(2, 6, size=relleno))) y.append(clave) return X, np.array(y)
Entrena las dos redes sobre eso, con 200 filas, 60 vueltas y paso 0,1, y prueba tres semillas de arranque. Esto es lo que me salió a mí:
recurrente 0.475 0.490 0.500 aciertan 0/3 LSTM 1.000 0.475 1.000 aciertan 2/3
La recurrente no aprende nunca, y lo comprobé subiendo a 150 vueltas: se queda igual. No es que le falte entrenamiento, es que el gradiente no llega. La LSTM lo resuelve perfecto en dos de tres arranques, y en el tercero se queda plantada, que es exactamente lo mismo que nos pasó con el XOR en el capítulo 4: poder resolverlo y llegar a encontrarlo no son la misma cosa.
5. Rellena el lote y mira qué se rompe
Arregla el error de inhomogeneous shape de la forma ingenua, la que salta primero: rellenar con ceros hasta el largo máximo.
largo_max = max(len(f) for f in lote) X = np.zeros((len(lote), largo_max), dtype=int) for i, f in enumerate(lote): X[i, :len(f)] = f print(X)
Ahora mira la matriz que sale y contesta dos cosas antes de seguir: qué palabra del vocabulario es el índice 0, y qué pasa si la red la lee como si fuera una palabra más. Si te cuesta, la respuesta entera está en la trampa de este capítulo.
6. Cierra la puerta de olvido
Cambia p['b'][:H] = 1.0 por -1.0 y repite la
medición del gradiente de la LSTM.
Con el sesgo negativo la puerta arranca casi cerrada, la memoria se borra en
cada paso y la LSTM se parece mucho más a la recurrente. Ese 1.0
es de las líneas que más deciden y menos se explican.
Comprueba que lo tienes
Tienes comentarios de clientes de unas 200 palabras y un servidor sin tarjeta gráfica. ¿Qué haces?
- Empezar por una bolsa de palabras y medirla
- Una LSTM, que aguanta las dependencias largas
- Un transformer, que es lo que se usa hoy
- Una recurrente simple, que es más chica
Lo que hay que llevarse
- 🔁 Una recurrente es la neurona de siempre más un sumando: el estado anterior.
- 📉 Hacia atrás, cada paso multiplica, y multiplicar treinta veces divide entre 672.620. Por eso no aprende lo lejano.
- 🚪 La LSTM le da a la memoria un carril donde solo se suma y se borra, y la misma cuenta baja a 63.
- 🐌 Y aun así perdieron, porque leen de a una palabra y eso no se paraleliza.
- 🎯 Que es justo lo que arregla el capítulo 12.
Si el numpy 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 🐍
Y si quieres el vocabulario suelto, está definido en dos líneas por término en el glosario de IA 📖
Que tengas lindo día! 🌸