La arquitectura encoder-decoder
26 min de lectura
Este bloque empieza con la arquitectura ya montada. La lección anterior, sobre modelos de secuencia a secuencia, encadenó dos redes —una que lee la entrada y la resume en un vector, otra que escribe la salida a partir de ese resumen— y las entrenó de punta a punta sobre una tarea de juguete. Lo que este bloque va a mirar con lupa es el punto donde las dos se tocan. Pero antes hay que cambiar de tarea, porque aquel juguete le perdonaba tres cosas a la arquitectura y traducir no le perdona ninguna.
Mira una frase y su traducción: ayer leí un libro muy bueno se convierte en yesterday i read a very good book. Entran seis tokens y salen siete, y el que sobra es i, un pronombre que en la entrada no aparece por ningún lado —el español lo lleva dentro de la conjugación del verbo y el inglés tiene que escribirlo aparte—. muy bueno, que en español va detrás del sustantivo, en inglés se le adelanta. Y esta no es «la» traducción, sólo una: yesterday i read a really good book sirve igual. Longitud distinta, orden distinto y respuesta no única. Son tres cosas.
Las tres tienen la misma forma: la tarea de juguete respondía por el modelo preguntas que en una tarea real tiene que contestar él.
La primera es el vocabulario. Cuando la salida se escribe con los mismos símbolos que la entrada hay un solo vocabulario, y cada una de sus entradas sirve igual para leer que para escribir. Traducir necesita dos, de idiomas distintos y de tamaños distintos: el libro que entra y el book que sale no son la misma entrada de la misma tabla, y el decoder no tiene que producir jamás una palabra española.
La segunda es la longitud, y es la que más cuesta. Un bucle que escribe tantos tokens como tenía la entrada funciona mientras la tarea garantice que entrada y salida miden lo mismo; en cuanto no lo garantiza, la longitud de la salida deja de ser un dato y pasa a ser una incógnita. Tiene que salir de algún sitio, y el único sitio disponible es el propio modelo: el vocabulario de salida gana un token que significa «he terminado», y escribirlo es lo que detiene la generación.
La tercera cambia lo que hay que leer en la salida del decoder. Si una frase admite varias traducciones aceptables, señalar un token no es la respuesta honesta; repartir probabilidad entre todos sí lo es. Y el decoder ya hacía eso —de cada estado cuelga una distribución, no un token—, así que lo que cambia no es la red sino qué significa lo que produce: en cada paso, una columna de probabilidades sobre el vocabulario de salida, y la frase escrita es un camino a través de esas columnas.
Dos vocabularios y una salida que se detiene sola
Fijemos los dos vocabularios: para la entrada y para la salida. La entrada es una secuencia de tokens , cada una entrada de ; la salida es otra, , sobre , y las dos longitudes son independientes. El encoder no cambia nada respecto de la lección anterior: lee la entrada de principio a fin con la recurrencia de siempre y entrega su último estado,
donde es el one-hot del token -ésimo y tiene una columna por cada entrada de . El decoder tampoco cambia de recurrencia, y arranca donde el encoder terminó, . Lo que cambia es su salida, que ahora vive en el otro idioma:
con : una fila por cada entrada de , y es la única forma del modelo que sabe cuántas entradas tiene el idioma de llegada. Nada obliga a que y se parezcan.
Dónde vive el final de la frase
Ahora el token que faltaba. A —y sólo a — le añadimos una entrada más, <EOS>, el símbolo de fin de secuencia, y toda traducción del entrenamiento termina en ella: si la salida correcta tiene tokens, la secuencia de entrenamiento tiene y el último es . La pérdida es la de la lección sobre modelos de secuencia a secuencia, sin tocar una línea,
y ese detalle de contabilidad es todo el mecanismo: como la última posición está dentro del sumatorio, aporta su propio término, y el descenso de gradiente empuja hacia arriba la probabilidad de <EOS> exactamente ahí —y, por el mismo camino, hacia abajo en las posiciones anteriores, donde el token correcto es otro—. Aprender a parar es aprender a predecir un token más.
La consecuencia es la que hacía falta: ya no es un dato que reciba el bucle, sino algo que
sale del modelo. Al generar, el modelo escribe token a token y para cuando el elegido es
<EOS>. En el código habrá además un tope, max_len, para que un modelo mal entrenado no escriba
indefinidamente; es una red de seguridad de la implementación, no parte del modelo, y conviene no
confundir las dos cosas.
El decoder define una distribución sobre traducciones
Podemos escribir cualquier distribución sobre secuencias, sin suponer nada de nada, como un producto de distribuciones sobre un token cada una. Es la regla de la cadena de la probabilidad, aplicada posición a posición:
donde es el prefijo , lo ya escrito antes del paso . Hasta aquí no hay arquitectura ninguna: la igualdad vale para cualquier modo de asignar probabilidades a secuencias. La arquitectura entra al decir qué mira cada factor, y lo que dice el encoder-decoder es esto:
Léela despacio, porque el bloque entero vive de ella. A la izquierda, el paso podría mirar la entrada completa; a la derecha, mira un vector. Cada uno de los dos condicionantes de la derecha está en un sitio concreto del modelo: es el estado inicial , y el prefijo entra realimentado token a token, de modo que ambos llegan al paso resumidos en . Y las probabilidades del factor son las coordenadas que ya calculamos: .
Generar es recorrer el producto
Escribir una traducción es elegir un camino por esas columnas. El camino más barato de todos es tomar en cada paso la coordenada mayor,
realimentar el token elegido y seguir hasta <EOS>. Es la decodificación voraz, la misma de la lección sobre el modelo de lenguaje a nivel de carácter, y cuesta un paso por token de salida.
La concesión es que ese camino no es, en general, el más probable. Nada garantiza que el producto de los máximos sea el máximo de los productos: un token que gana su columna por poco puede dejar al decoder en un estado desde el que todo lo que sigue es malo, y la decodificación voraz no vuelve nunca sobre esa decisión. Buscar de verdad el mejor camino obligaría a puntuar frases, y eso no lo hace nadie; entre lo voraz y lo exhaustivo hay búsquedas intermedias —la habitual es el beam search— que este curso no cubre.
Ver un caso donde lo voraz pierde
Dos tokens posibles en el primer paso, con probabilidades y —los números me los invento yo, sólo hacen falta cuatro—. La decodificación voraz se queda con el . Supón que desde esa rama la mejor continuación vale , y que desde la otra vale . Entonces los dos caminos completos valen
y el mejor es el que la voraz descartó en el primer paso, cuando todavía no podía saber lo que venía después. Con más pasos el efecto se acumula, y por eso los sistemas de traducción reales no decodifican así.
Un traductor de juguete en NumPy
Vamos a entrenar un traductor diminuto: un idioma de juguete con seis palabras en español y otro con siete en inglés. De juguete a propósito —la traducción automática de verdad no cabe en el navegador—, pero con las tres cosas que esta lección ha puesto sobre la mesa: dos vocabularios distintos, una salida cuya longitud decide el modelo, y una reordenación que obliga a esperar. Las frases españolas son verbo + sustantivo + adjetivo opcional, y su traducción mete un i al principio y adelanta el adjetivo al sustantivo. Ejecuta la celda y mira las parejas.
# Idioma de juguete. Dos vocabularios distintos: uno para leer, otro para escribir.
VX = ["leo", "vendo", "libros", "coches", "rojos", "nuevos"] # V_x, la entrada
VY = ["i", "read", "sell", "red", "new", "books", "cars", "<EOS>"] # V_y, la salida
TRAD = {"leo": "read", "vendo": "sell", "libros": "books",
"coches": "cars", "rojos": "red", "nuevos": "new"}
EOS = VY.index("<EOS>") # el fin de secuencia es una entrada mas de V_y
GO = len(VY) # arranque: solo entra al decoder, nunca se predice
nx, ny = len(VX), len(VY)
datos = np.random.default_rng(0) # semilla fija: veras estas frases
def frase(): # una frase espanola y su traduccion, en indices
es = [VX[datos.integers(0, 2)], VX[datos.integers(2, 4)]] # verbo + sustantivo
if datos.random() < 0.67:
es.append(VX[datos.integers(4, 6)]) # ... + adjetivo, a veces
en = ["i", TRAD[es[0]]] + [TRAD[w] for w in es[2:]] + [TRAD[es[1]]] # el adjetivo se adelanta
return [VX.index(w) for w in es], [VY.index(w) for w in en] + [EOS]
print("frase traduccion + <EOS> T_x T_y")
for _ in range(5):
x, y = frase()
print(" %-20s %-30s %3d %4d" % (
" ".join(VX[i] for i in x), " ".join(VY[i] for i in y),
len(x), len(y)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Compara las dos columnas de la derecha. La traducción lleva una palabra más que la frase —ese i que el español no escribe— y la secuencia que el decoder tiene que producir lleva una más todavía, porque <EOS> ocupa una posición como cualquier otro token y también hay que predecirlo: . Doce frases posibles en total, así que este modelo no va a aprender español —va a aprender esas doce—; lo que está a prueba es el mecanismo, no el traductor.
La celda siguiente monta las dos redes y entrena. La función paso es la de la lección anterior
línea por línea: encoder hacia delante, decoder hacia delante con teacher forcing, vuelta del
decoder y, colgada del gradiente en , vuelta del encoder. Lo único que cambia son los dos
vocabularios —nx columnas a la entrada, ny filas a la salida— y el <EOS> que frase ya
dejó puesto al final de cada objetivo.
# Necesita la celda anterior: VX, VY, EOS, GO, nx, ny, frase.
d_h, eta, theta, iters = 64, 0.30, 5.0, 1200
ini = np.random.default_rng(1); sc = 0.1 # generador propio: no depende de frase
Wxh_e = ini.normal(size=(d_h, nx)) * sc; Whh_e = ini.normal(size=(d_h, d_h)) * sc; bh_e = np.zeros(d_h)
Wxh_d = ini.normal(size=(d_h, ny + 1)) * sc; Whh_d = ini.normal(size=(d_h, d_h)) * sc; bh_d = np.zeros(d_h)
Why = ini.normal(size=(ny, d_h)) * sc; by = np.zeros(ny) # ny filas: una por entrada de V_y
pesos = [Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by]
def paso(x, y): # x sobre V_x; y sobre V_y, con <EOS> al final
Lx, Ly = len(x), len(y)
He = np.zeros((d_h, Lx)); h = np.zeros(d_h) # --- encoder: lee y resume ---
for j in range(Lx):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e); He[:, j] = h
c = h # vector de contexto: el ultimo estado
ent = np.concatenate(([GO], y[:-1])) # teacher forcing: GO, y1, ..., y_{Ty-1}
S = np.zeros((d_h, Ly)); P = np.zeros((ny, Ly)); s = c.copy(); loss = 0.0
for i in range(Ly): # --- decoder: escribe desde c ---
s = np.tanh(Wxh_d[:, ent[i]] + Whh_d @ s + bh_d); S[:, i] = s
o = Why @ s + by; o -= o.max(); p = np.exp(o); p /= p.sum(); P[:, i] = p
loss -= np.log(p[y[i]] + 1e-12)
gWhy = np.zeros_like(Why); gby = np.zeros_like(by) # --- vuelta del decoder ---
gWxh_d = np.zeros_like(Wxh_d); gWhh_d = np.zeros_like(Whh_d); gbh_d = np.zeros_like(bh_d)
ds = np.zeros(d_h)
for i in reversed(range(Ly)):
dO = P[:, i].copy(); dO[y[i]] -= 1.0
gWhy += np.outer(dO, S[:, i]); gby += dO
dp = (1 - S[:, i] ** 2) * (Why.T @ dO + ds)
s_ant = S[:, i - 1] if i > 0 else c
gWhh_d += np.outer(dp, s_ant); gWxh_d[:, ent[i]] += dp; gbh_d += dp
ds = Whh_d.T @ dp
gWxh_e = np.zeros_like(Wxh_e); gWhh_e = np.zeros_like(Whh_e); gbh_e = np.zeros_like(bh_e)
dh = ds # unico enlace: el gradiente en c
for j in reversed(range(Lx)): # --- vuelta del encoder ---
dp = (1 - He[:, j] ** 2) * dh
h_ant = He[:, j - 1] if j > 0 else np.zeros(d_h)
gWhh_e += np.outer(dp, h_ant); gWxh_e[:, x[j]] += dp; gbh_e += dp
dh = Whh_e.T @ dp
return loss / Ly, [g / Ly for g in (gWxh_e, gWhh_e, gbh_e, gWxh_d, gWhh_d, gbh_d, gWhy, gby)]
suave = None
for it in range(iters):
x, y = frase()
loss, grads = paso(x, y)
for g in grads:
nrm = np.linalg.norm(g)
if nrm > theta: g *= theta / nrm # recorte del gradiente, bloque anterior
for Pm, g in zip(pesos, grads):
Pm -= eta * g
suave = loss if suave is None else 0.99 * suave + 0.01 * loss
if it % 300 == 0 or it == iters - 1:
print("iter %4d perdida/token %.3f" % (it, suave))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
La pérdida arranca cerca de —repartir la probabilidad por igual entre las ocho
entradas de , contando <EOS>— y baja hasta rozar el cero. Ahora la parte que importa:
generar. El bucle de abajo no recibe la longitud de la salida por ningún sitio; escribe, mira si lo
escrito es <EOS> y, si lo es, para. max_len está sólo por seguridad.
# Necesita las celdas anteriores: los pesos entrenados, VX, VY, TRAD, EOS, GO.
def traduce(x, max_len=8): # nadie le dice cuantos tokens escribir
h = np.zeros(d_h)
for j in range(len(x)): # el encoder lee la frase entera
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e)
s = h; ix = GO; out = []
for _ in range(max_len):
s = np.tanh(Wxh_d[:, ix] + Whh_d @ s + bh_d)
ix = int((Why @ s + by).argmax()) # el mas probable: decodificacion voraz
if ix == EOS: # el modelo dice que ha terminado
break
out.append(ix)
return out
ejemplos = [["leo", "libros"], ["vendo", "coches"], ["leo", "coches", "rojos"],
["vendo", "libros", "nuevos"], ["vendo", "libros"], ["leo", "coches", "nuevos"]]
print("frase traduccion entra sale")
for es in ejemplos:
got = traduce([VX.index(w) for w in es])
quiero = ["i", TRAD[es[0]]] + [TRAD[w] for w in es[2:]] + [TRAD[es[1]]]
print(" %-20s %-24s %3d %5d %s" % (
" ".join(es), " ".join(VY[i] for i in got), len(es), len(got),
"OK" if [VY[i] for i in got] == quiero else "X"))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Dos cosas en esa tabla. La columna de longitudes: entran dos o tres tokens y salen tres o cuatro,
uno más siempre, y ese número lo ha decidido el modelo al colocar <EOS> —no hay ningún
range(len(x)) en el bucle—. Y el orden: cuando la frase lleva adjetivo, el modelo lo escribe
antes que el sustantivo, aunque lo leyó después. Para hacerlo tuvo que retener el sustantivo
mientras escribía el adjetivo, y todo lo que tenía para retenerlo era .
Comprueba tu intuición
Cuatro preguntas: por qué hace falta un token de fin, qué distribución define exactamente el decoder, si la decodificación voraz es óptima, y de dónde sale la habilidad de parar en el sitio.
El modelo de la lección anterior generaba con un bucle de longitud fija: escribía tantos tokens como tenía la entrada. ¿Por qué ese bucle no sirve para traducir?
¿Cuál de estas expresiones es la que define el decoder?
Tomar en cada paso el token más probable produce siempre la secuencia más probable.
El decoder acaba escribiendo <EOS> en la posición correcta, ni antes ni después. ¿De dónde sale esa habilidad?
La arquitectura ya está dicha con precisión, y esa precisión deja a la vista una línea incómoda: . La igualdad no es un teorema, es una decisión de diseño, y afirma algo fuerte —que números bastan para todo lo que la entrada tenga que aportar a cualquier posición de la salida—. En un juguete de tres tokens nadie lo notaría. La pregunta es qué le pasa a esa afirmación cuando la entrada es una frase de verdad.
Esa pregunta tiene una respuesta que se puede medir, y es la lección siguiente, sobre el cuello de botella del vector de contexto. Allí la entrada se va alargando mientras se mira qué le ocurre a la salida, hasta ver dónde deja de aguantar la igualdad de arriba y por qué el sitio donde se rompe no depende de cuánto se entrene.
Para profundizar2 fuentes · 1 paper, 1 libro
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.
- Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation
Su §2 escribe la factorización que sostiene el bloque: P(y|x) como producto de P(y_i | lo ya escrito, c), con c el último estado del encoder y el símbolo de fin. Su decoder realimenta c en cada paso; el tuyo solo arranca con ella.
- Speech and Language Processing, cap. 13: Machine Translation
Su §13.4 es la decodificación voraz y el beam search que esta lección nombra y no desarrolla; también trae el teacher forcing y la entropía cruzada. Aviso: el capítulo ya lo cuenta sobre un transformer, no sobre una RNN.