La RNN vanilla
28 min de lectura
La lección anterior, sobre por qué el perceptrón multicapa (multilayer perceptron, MLP) falla con secuencias, dejó dos defectos medidos: lo que hay que recortar de un texto largo, y lo que la red averigua en una posición y no le sirve en otra. Los dos salen de la misma causa, y esa causa no está en el tamaño de la red ni en cómo se la entrena. Está en la entrada: aquella red no leía una secuencia, recibía un vector. Lo que sigue es la corrección, y llega con una propiedad que de una corrección no se espera: la red que la aplica es más pequeña que la que falla.
Se llama red neuronal recurrente (recurrent neural network, RNN), y en su forma original (la que todo el mundo llama vanilla) cabe en dos matrices y un bucle. Lo difícil de ella no es cómo funciona, que son dos líneas, sino por qué algo tan corto consigue lo que la red grande no conseguía. La respuesta está entera en una renuncia: esta red no mira la frase, la recorre.
Recórrela antes de seguir. En el explorable de abajo, cada avance aplica el mismo paso a una posición más: las dos matrices son siempre las mismas y sólo cambia el estado que entra por la izquierda. Así es, además, como la vas a encontrar dibujada en cualquier libro que la trate.
Hay una lectura que esa secuencia invita a hacer y que es falsa: las cajas no son capas distintas. Son la
misma función dibujada una vez por posición, como se dibujarían las vueltas de un bucle for, y por
eso los rótulos se repiten en lugar de numerarse. Una red con una capa por posición tendría un juego
de pesos por cada una; ésta tiene uno solo, valga la secuencia lo que valga.
Un estado que se reescribe en cada posición
Cada posición aporta el vector de su entrada del vocabulario, , la fila de que le corresponde, igual que en la lección anterior. Lo nuevo es el otro vector, el que la red se pasa a sí misma de un paso al siguiente: el estado oculto , donde se elige al construir la red y es lo único que decide cuánto cabe en esa memoria. Fijemos además , porque antes de leer el primer token no hay nada que recordar.
Con eso, un paso es una capa corriente a la que entran dos cosas en lugar de una:
con leyendo el estado anterior, leyendo el token de turno y . La tangente hiperbólica ocupa el hueco de la lección sobre funciones de activación, y en ese hueco caben las tres que se vieron allí: la fórmula se escribe igual con cualquiera. Lo que las separa aquí es algo que ninguna capa del bloque anterior hacía, y es que la salida vuelve a entrar. es la entrada del paso , que a su vez alimenta el , así que lo que la activación haga con el estado se repite una vez por token.
Eso descarta primero a ReLU, porque a la derecha no tiene techo. Míralo en una sola coordenada, con un peso recurrente de y entradas que no lo contrarresten: ReLU deja pasar el estado entero, cada vuelta lo duplica, y veinte vueltas lo multiplican por más de un millón. Y eso ocurre en la pasada hacia adelante, sin haber calculado todavía un solo gradiente. La tangente hiperbólica deja cada coordenada en tanto en como en , porque su techo no depende de lo que le entre. Hay RNN con ReLU, pero exigen inicializar con cuidado para que al principio no estire nada; la no pide ese cuidado.
La sigmoide también está acotada, y pierde frente a la por las dos diferencias que aquella lección señaló. La primera es el centro. Con la sigmoide, cada coordenada del estado sería positiva y podría decir cuánto, pero nunca hacia qué lado; y el con el que arranca la lectura, que para la es el centro de su recorrido, para la sigmoide sería un borde al que no llega nunca. La segunda es la pendiente, que en el origen vale en la y en la sigmoide. Cuando haya que entrenar esta red, el error viajará de la última posición a la primera y cruzará esa pendiente una vez por paso: con la sigmoide, ese factor lo dividiría como mínimo por cuatro en cada uno. Por eso la escribo con su nombre en vez de dejar : es la que la recurrencia lleva en todo el bloque.
El paso parece pedir una regla aparte, y no la pide. Con el primer término desaparece solo:
Ver la secuencia entera dentro del último estado
Sustituye por su propia definición, empezando por la de arriba:
y una vez más, que ya deja el patrón a la vista:
Cada nueva posición envuelve todo lo anterior en una capa más de y de , sin que se caiga nada por el camino: sigue ahí dentro cuando vale , y seguiría con .
Ese anidamiento contesta la pregunta de dónde queda guardado el orden. No está en los pesos (son los mismos en las tres posiciones) sino en cuántas veces ha atravesado cada token la matriz antes de llegar al final: en , el vector la ha cruzado dos veces, una y ninguna. Cambia dos tokens de sitio y cambias esa cuenta.
Queda sacar una respuesta, y hay estados de donde sacarla. Para un veredicto por documento se usa el último, , porque es el único que ha leído el texto entero: el anidamiento de arriba mete todos los tokens dentro de él, mientras que cualquier anterior no ha visto lo que viene después de . La respuesta sale de una capa más, puesta encima de él:
con de forma (una fila por salida, y aquí la salida es una) y de una coordenada. La activación es otra, y no por descuido: es la sigmoide logística y no la del bucle, porque lo que la red entrega es una probabilidad y la devuelve números entre y . Es la capa de salida del clasificador de reseñas del bloque anterior sin un solo cambio; lo distinto es de dónde viene lo que entra en ella. Por eso el dibujo de arriba no la lleva y la cuenta de más abajo tampoco: la recurrencia son las dos matrices del bucle, y esta tercera es una capa prestada que ya sabes montar.
Repasa ahora las formas de esas dos matrices y comprueba qué no aparece en ninguna: . mide , mide , y ninguna de las dos sabe cuántas veces se la va a usar. Eso deja sin objeto las dos operaciones que la longitud fija imponía: no hay un por el que truncar, y no hay posiciones sobrantes que rellenar con el vector nulo (el padding de la lección anterior), porque no hay posiciones de más. Hay exactamente las que trae el documento.
Debajo de eso hay un cambio de unidad que conviene ver. En el MLP concatenado, una entrada de la red era un documento: la primera capa tenía tantas columnas como ocupaba el documento entero. Aquí una entrada de la red es un token, porque tiene columnas y ninguna más, y en cada paso la red no ve nada aparte del vector de esa posición y del estado que trae. La longitud del texto no ha desaparecido del sistema: se ha mudado de las formas al número de vueltas del bucle. Y esa mudanza es la que lo cambia todo, porque una forma queda fija al construir la red y ya no se toca (eso es lo que obligaba a truncar), mientras que el número de vueltas lo decide cada documento al llegar.
Una sola matriz para todas las posiciones
Suma lo que ocupan las tres piezas de la recurrencia:
Ponla al lado de la de la lección anterior, . Lo que hay que mirar no es cuál sale más pequeña (eso es una consecuencia) sino qué letras aparecen en cada una: allí estaba y aquí no está. Leer documentos diez veces más largos no añade un peso.
Y el ahorro es lo de menos. Lo que cambia es a qué se dedica cada peso. En la concatenación, el trozo de la primera capa que leía la posición 2 y el que leía la posición 8 eran dos matrices distintas, ajustadas con ejemplos distintos, y la segunda se quedaba como salió de la inicialización si el corpus nunca traía nada interesante tan al final. En la recurrencia hay una , y la atraviesan los vectores de todas las posiciones de todos los documentos. Lo que aprenda a hacer con el vector de guion lo aplica esté guion donde esté, porque no tiene forma de saber dónde está.
Compartir los pesos es lo que hace que una RNN sea una RNN. Quítalo (una matriz distinta por posición) y lo que queda es el MLP concatenado con otro nombre.
El reverso viene con el gradiente, y conviene enunciarlo aquí aunque esta lección no lo resuelva. no interviene una vez en el cálculo de : interviene veces, como enseña el anidamiento de arriba. Así que la pérdida depende de esa matriz por caminos distintos, y su gradiente tendrá que recogerlos todos.
Ejecutando la recurrencia sobre dos frases de distinta longitud
Doce entradas de vocabulario (esta celda usa ocho; las cuatro últimas son para la siguiente),
embeddings de y un estado de coordenadas. Los pesos me los da un generador con semilla fija y no están entrenados: lo que hay que
mirar en esta celda no es qué valores salen, sino cuántas veces se usa cada matriz. paso es la
recurrencia transcrita línea por línea, y lee la aplica a una frase imprimiendo el estado en cada
posición.
V = ["la", "el", "actriz", "película", "guion", "salva", "hunde", "y",
"no", "es", "aburrida", "divertida"]
d_model, d_h = 4, 3
rng = np.random.default_rng(0)
E = rng.normal(size=(len(V), d_model)) * 0.5 # (12, 4): una fila por entrada
Wxh = rng.normal(size=(d_h, d_model)) * 0.5 # (3, 4): lee el token de turno
Whh = rng.normal(size=(d_h, d_h)) * 0.5 # (3, 3): lee el estado anterior
bh = np.zeros(d_h)
def paso(h, x):
return np.tanh(Whh @ h + Wxh @ x + bh) # los mismos pesos en cada posición
def lee(frase):
h, estados = np.zeros(d_h), [] # h_0 = 0, antes de leer nada
print("«%s»" % frase)
for t, palabra in enumerate(frase.split(), 1):
h = paso(h, E[V.index(palabra)])
estados.append(h)
print(" h_%-2d %s" % (t, np.round(h, 3)))
return estados
corta = lee("el guion hunde la película")
larga = lee("la actriz salva la película y el guion la hunde")
print("\nformas: Whh %s Wxh %s bh %s (ninguna menciona T)" % (
Whh.shape, Wxh.shape, bh.shape))
print("«el guion» leído en las posiciones 1-2 y en las 7-8:")
print(" %s %s" % (np.round(corta[0], 3), np.round(corta[1], 3)))
print(" %s %s" % (np.round(larga[6], 3), np.round(larga[7], 3)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Cuenta las filas: cinco para la primera frase, diez para la segunda, y las mismas Whh y Wxh en
las quince. No hay una versión de la red para textos cortos y otra para largos, y no ha hecho falta
recortar ni rellenar nada: el bucle da las vueltas que le pida la frase.
Las dos últimas líneas son la lección anterior contestada. el guion ocupa las posiciones 1 y 2 de la primera frase y las 7 y 8 de la segunda, y los estados que produce son casi los mismos: frente a . Casi, no exactamente, y las dos mitades cuentan. Se parecen porque los pesos que leen esos dos tokens son los mismos pesos, estén donde estén; en el MLP concatenado no había ninguna razón para que se parecieran, y la lección anterior midió que no se parecían en absoluto. Y difieren porque el estado que traían de antes no era el mismo, que es justo la información de orden que la bolsa de palabras no tenía dónde guardar.
Esa diferencia se puede aislar, y el bloque anterior dejó preparado el caso para hacerlo: las dos frases con las que cerró el proyecto de sentimiento. Tienen los mismos tokens, con las mismas repeticiones, y dicen lo contrario.
V = ["la", "el", "actriz", "película", "guion", "salva", "hunde", "y",
"no", "es", "aburrida", "divertida"]
d_model, d_h = 4, 3
rng = np.random.default_rng(0) # la misma red de la celda anterior
E = rng.normal(size=(len(V), d_model)) * 0.5
Wxh = rng.normal(size=(d_h, d_model)) * 0.5
Whh = rng.normal(size=(d_h, d_h)) * 0.5
bh = np.zeros(d_h)
def tokens(frase):
return frase.replace(",", " ").split() # fuera la coma, como en aquel proyecto
def estados(frase):
h, hs = np.zeros(d_h), []
for palabra in tokens(frase):
h = np.tanh(Whh @ h + Wxh @ E[V.index(palabra)] + bh)
hs.append(h)
return hs
def bolsa(frase):
v = np.zeros(len(V), dtype=int)
for palabra in tokens(frase):
v[V.index(palabra)] += 1
return v
a = "la película no es aburrida, es divertida"
b = "la película no es divertida, es aburrida"
print("a «%s»\nb «%s»" % (a, b))
print("\nbolsa de a %s\nbolsa de b %s\n" % (bolsa(a), bolsa(b)))
for t, (pa, ha, pb, hb) in enumerate(zip(tokens(a), estados(a), tokens(b), estados(b)), 1):
print("h_%d %-10s %-24s %-10s %s" % (t, pa, np.round(ha, 3), pb, np.round(hb, 3)))
print("\n T_max=30 T_max=300")
concat = [128 * t * 100 for t in (30, 300)] # d1 · T_max · d_model
recurr = [128 * 128 + 128 * 100 + 128] * 2 # d_h² + d_h · d_model + d_h
print("concat. %-13d %d" % tuple(concat))
print("recurr. %-13d %d" % tuple(recurr))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
La bolsa de palabras devuelve el mismo vector para las dos frases, casilla por casilla, porque suma y la suma no tiene orden. Es la entrada que recibía el clasificador de aquel proyecto, y por eso les dio a las dos.
Los estados cuentan la lectura paso a paso, y la tabla se parte en dos. Las cuatro primeras filas coinciden cifra por cifra, porque hasta ahí las dos frases dicen lo mismo, la película no es, y la red todavía no tiene nada que distinguir. En la quinta se separan: es la posición en la que no es cae sobre aburrida en una frase y sobre divertida en la otra. Aquel proyecto concluyó que la coordenada de no decía cuántas veces salía, pero no sobre qué caía. Aquí el no va dentro del estado que llega a la posición 5, y el adjetivo se lee con él delante.
Los estados finales quedan en y . Con los pesos sin entrenar, cuánto se separen no significa nada todavía, y ninguno de los dos quiere decir aún «positiva». Lo que sí asegura es que la capa de salida recibirá dos vectores distintos, que es la condición sin la cual ningún entrenamiento podría darles probabilidades distintas. Con la bolsa esa condición ya fallaba antes de empezar. La recurrencia se ha quedado con lo bueno de las dos representaciones que el bloque lleva comparando: distingue el orden, como la concatenación, y comparte los pesos, como la bolsa.
Las dos últimas líneas ponen el precio en números, con las dimensiones de la lección anterior: y una capa (o un estado) de coordenadas. Pasar de leer frases a leer párrafos multiplica por diez la primera capa del MLP concatenado y deja la recurrencia exactamente donde estaba, en pesos. Los que el MLP añade empiezan todos en su inicialización, y hay que ajustarlos con el mismo corpus de antes.
Comprueba tu intuición
Tres preguntas: cuántos pesos pide la recurrencia, por qué una posición que el entrenamiento nunca vio deja de ser un problema, qué dice y qué no dice la fórmula.
Una RNN con un estado de coordenadas sobre embeddings de . ¿Cuántos pesos suman entre , y ?
Se acepta un margen de ±0.
En el conjunto de entrenamiento, guion no aparece nunca más allá de la cuarta posición. Llega un documento de prueba que lo trae en la novena, y la RNN lo trata con criterio en vez de ignorarlo. ¿Por qué?
Marca todo lo que sea cierto de la recurrencia con .
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Y una recurrencia entera escrita por ti, que es la que vas a derivar en la lección siguiente.
Escribe la pasada hacia adelante de esta lección, para una secuencia de longitud cualquiera.
paso(h, x, Whh, Wxh, bh)devuelve el estado siguiente a partir del anterior y del vector del token de turno.forward(X, Whh, Wxh, bh)recibeXde forma , con una fila por posición, y devuelve los estados apilados, de forma , partiendo de .
Ninguna de las dos puede escribir en los arrays que recibe.
La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.
La red ya sabe leer y todavía no sabe aprender. Todo lo de arriba es la pasada hacia adelante: pesos puestos por un generador, ni un gradiente, ni un paso de descenso. Y el gradiente es donde compartir los pesos deja de salir gratis. En una red del bloque anterior cada matriz aparecía una vez en el camino de la entrada a la pérdida, y eso es lo que hacía que su gradiente fuera un solo producto; aparece veces.
Qué hacer con esos caminos (sumarlos, y recorrer la secuencia entera hacia atrás para obtenerlos) es la siguiente lección, sobre backpropagation a través del tiempo (backpropagation through time, BPTT). Es la regla de la cadena del bloque anterior sin ninguna variación, aplicada a un grafo que en vez de tener capas de altura tiene pasos de largo, y de ahí sale la cuenta completa de , y .
Para profundizar1 fuente · 1 paper
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Finding Structure in Time
Introdujo la red recurrente simple que montas aquí: un estado que se realimenta como contexto y esta misma recurrencia. Su parte inicial plantea el problema de la lección anterior; luego construye esta red. De 1990 y todavía se lee bien.