La arquitectura encoder-decoder

La arquitectura encoder-decoder

26 min read

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.

Cinco columnas, una por paso del decoder, cada una con tres tokens candidatos y una barra proporcional a su probabilidad. En la primera columna gana i con 0.94, por delante de read y sell. En la segunda gana sell con 0.88, por delante de read y new. En la tercera gana new con 0.71, seguida de cars con 0.21 y red con 0.08: es la columna menos decidida de las cinco. En la cuarta gana cars con 0.85, por delante de books y new. En la quinta gana el simbolo de fin, EOS, con 0.96. Debajo de todas, el producto de las cinco probabilidades ganadoras: 0.94 por 0.88 por 0.71 por 0.85 por 0.96 igual a 0.48.
Traduciendo «vendo coches nuevos». La longitud de la salida no la fija el bucle: la fija la columna en la que gana «<EOS>». Las probabilidades las he puesto yo a mano para el dibujo.

Dos vocabularios y una salida que se detiene sola

Fijemos los dos vocabularios: VxV_x para la entrada y VyV_y para la salida. La entrada es una secuencia de tokens x1:Txx_{1:T_x}, cada xjx_j una entrada de VxV_x; la salida es otra, y1:Tyy_{1:T_y}, sobre VyV_y, 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,

hˉj=tanh(Wxhencoxj+Whhenchˉj1+bhenc),c=hˉTxRdh,\bar{\mathbf{h}}_j = \tanh\left(\mathbf{W}^{\text{enc}}_{xh}\mathbf{o}_{x_j} + \mathbf{W}^{\text{enc}}_{hh}\bar{\mathbf{h}}_{j-1} + \mathbf{b}^{\text{enc}}_h\right), \qquad \mathbf{c} = \bar{\mathbf{h}}_{T_x} \in \mathbb{R}^{d_h},

donde oxj{0,1}Vx\mathbf{o}_{x_j} \in \{0,1\}^{\lvert V_x \rvert} es el one-hot del token jj-ésimo y WxhencRdh×Vx\mathbf{W}^{\text{enc}}_{xh} \in \mathbb{R}^{d_h \times \lvert V_x \rvert} tiene una columna por cada entrada de VxV_x. El decoder tampoco cambia de recurrencia, y arranca donde el encoder terminó, s0=c\mathbf{s}_0 = \mathbf{c}. Lo que cambia es su salida, que ahora vive en el otro idioma:

y^i=softmax(Whysi+by)RVy,\hat{\mathbf{y}}_i = \text{softmax}\left(\mathbf{W}_{hy}\mathbf{s}_i + \mathbf{b}_y\right) \in \mathbb{R}^{\lvert V_y \rvert},

con WhyRVy×dh\mathbf{W}_{hy} \in \mathbb{R}^{\lvert V_y \rvert \times d_h}: una fila por cada entrada de VyV_y, y es la única forma del modelo que sabe cuántas entradas tiene el idioma de llegada. Nada obliga a que Vx\lvert V_x \rvert y Vy\lvert V_y \rvert se parezcan.

Dónde vive el final de la frase

Ahora el token que faltaba. A VyV_y —y sólo a VyV_y— 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 nn tokens, la secuencia de entrenamiento tiene Ty=n+1T_y = n + 1 y el último es yTy=<EOS>y_{T_y} = \texttt{<EOS>}. La pérdida es la de la lección sobre modelos de secuencia a secuencia, sin tocar una línea,

=1Tyi=1Tylogy^i,yi,\ell = \frac{1}{T_y}\sum_{i=1}^{T_y} -\log \hat{y}_{i,\,y_i},

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: TyT_y 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:

P(y1:Tyx1:Tx)=i=1TyP(yiy<i,x1:Tx),P\left(y_{1:T_y} \mid x_{1:T_x}\right) = \prod_{i=1}^{T_y} P\left(y_i \mid y_{<i},\, x_{1:T_x}\right),

donde y<iy_{<i} es el prefijo y1,,yi1y_1, \dots, y_{i-1}, lo ya escrito antes del paso ii. 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:

P(yiy<i,x1:Tx)=P(yiy<i,c).P\left(y_i \mid y_{<i},\, x_{1:T_x}\right) = P\left(y_i \mid y_{<i},\, \mathbf{c}\right).

Léela despacio, porque el bloque entero vive de ella. A la izquierda, el paso ii 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: c\mathbf{c} es el estado inicial s0\mathbf{s}_0, y el prefijo y<iy_{<i} entra realimentado token a token, de modo que ambos llegan al paso ii resumidos en si1\mathbf{s}_{i-1}. Y las probabilidades del factor son las coordenadas que ya calculamos: P(yi=ky<i,c)=y^i,kP(y_i = k \mid y_{<i}, \mathbf{c}) = \hat{y}_{i,k}.

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,

yi=argmaxkVyy^i,k,y_i = \arg\max_{k \in V_y} \hat{y}_{i,k},

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 VyTy\lvert V_y \rvert^{T_y} 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 0.60.6 y 0.40.4 —los números me los invento yo, sólo hacen falta cuatro—. La decodificación voraz se queda con el 0.60.6. Supón que desde esa rama la mejor continuación vale 0.50.5, y que desde la otra vale 0.90.9. Entonces los dos caminos completos valen

0.60.5=0.30y0.40.9=0.36,0.6 \cdot 0.5 = 0.30 \qquad\text{y}\qquad 0.4 \cdot 0.9 = 0.36,

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.

import numpy as np

# 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)))
numpy

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: Ty=Tx+2T_y = T_x + 2. 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 c\mathbf{c}, 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.

import numpy as np

# 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))
numpy

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 ln82.08\ln 8 \approx 2.08 —repartir la probabilidad por igual entre las ocho entradas de VyV_y, 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.

import numpy as np

# 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"))
numpy

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 c\mathbf{c}.

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: P(yiy<i,x1:Tx)=P(yiy<i,c)P(y_i \mid y_{<i}, x_{1:T_x}) = P(y_i \mid y_{<i}, \mathbf{c}). La igualdad no es un teorema, es una decisión de diseño, y afirma algo fuerte —que dhd_h 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.

Further reading2 sources · 1 paper, 1 book

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.