El cuello de botella del vector de contexto

El cuello de botella del vector de contexto

27 min de lectura

El decoder de la lección anterior, sobre la arquitectura encoder-decoder, no lee nunca la frase de entrada: lee un vector. c\mathbf{c} tiene dhd_h coordenadas, ese número se fija al construir la red —antes de ver ninguna frase— y es el mismo para cinco tokens que para quinientos. Aquella lección dejó eso anotado como lo que es: una apuesta sobre lo que cabe en dhd_h números, no algo que se demuestre.

Una apuesta se puede perder de dos maneras muy distintas, y separarlas es todo el trabajo de hoy. Un modelo puede fallar porque está mal ajustado, y entonces entrenarlo más lo arregla. O puede fallar porque lo que se le pide no cabe donde se le pide que quepa, y entonces no hay entrenamiento que valga: la pared no se mueve. Esta lección hace las dos cosas por separado. Primero cuenta, con lápiz, cuántos mensajes distintos caben en c\mathbf{c} y cuántas frases distintas hay que meter en él. Después mide qué le pasa a un modelo de verdad cuando la entrada se alarga, con dos anchos de estado y varios presupuestos de entrenamiento.

Las dos cantidades de la cuenta crecen de formas incompatibles, y el explorable las pone una sobre otra. La recta verde no se mueve con la longitud: dice cuántos mensajes distintos puede llegar a codificar un vector de dhd_h coordenadas, y ese número no sabe nada de la frase que está resumiendo. La ámbar sube un escalón entero por cada token que añades a la entrada. Ensancha el estado todo lo que quieras y busca después el punto donde se cruzan.

Ensancha el estado cuanto quieras: la recta verde sube, pero sigue siendo horizontal. La ambar tiene pendiente, y una recta con pendiente termina cruzando a cualquier horizontal.

Cuántos mensajes caben y cuántas frases hay

El estado del encoder sale de un tanh\tanh, así que cada coordenada de c\mathbf{c} vive en el intervalo (1,1)(-1, 1). Sobre el papel eso es un continuo y cabe en él cualquier cosa; en un modelo que se entrena con gradientes y se lee con otra red, no. Llamemos qq al número de niveles que de una coordenada se distinguen de verdad —dos valores más próximos que eso llegan al decoder como el mismo número—. El valor de qq me lo doy yo: ocho niveles, dieciséis, treinta y dos, según lo optimista que uno quiera ser. Lo que viene ahora no depende de cuál elijas.

Con esa convención, el encoder tiene un número finito de mensajes distintos que enviar, y las frases que tiene que distinguir son todas las de longitud TxT_x sobre el vocabulario de entrada:

{coˊdigos}qdh,{fuentes de longitud Tx}=VxTx.\lvert\{\text{códigos}\}\rvert \le q^{d_h}, \qquad \lvert\{\text{fuentes de longitud } T_x\}\rvert = \lvert V_x \rvert^{T_x}.

Fíjate en dónde está cada letra. En el primer recuento, TxT_x no aparece: el vector de contexto es igual de grande para una frase corta que para una larga, que es exactamente la decisión de diseño de la lección anterior. En el segundo, TxT_x está en el exponente.

Ahora el paso que convierte esa asimetría en una imposibilidad. Una vez fijado c\mathbf{c}, todo lo que el decoder escribe está determinado: arranca en s0=c\mathbf{s}_0 = \mathbf{c}, toma en cada paso la coordenada mayor y se realimenta con lo que acaba de escribir. La salida completa es, por tanto, una función de c\mathbf{c}, y una función no puede dar dos respuestas distintas al mismo argumento. Si hay menos códigos que frases, dos frases distintas comparten código y el decoder devuelve la misma salida para las dos, de modo que al menos una de ellas la reconstruye mal. Contando las que puede acertar:

{fuentes reconstruidas exactamente}VxTxmin(1,qdhVxTx).\frac{\lvert\{\text{fuentes reconstruidas exactamente}\}\rvert}{\lvert V_x \rvert^{T_x}} \le \min\left(1,\, \frac{q^{d_h}}{\lvert V_x \rvert^{T_x}}\right).

El techo vale 11 —no dice nada— mientras las frases quepan, y a partir de ahí se divide entre Vx\lvert V_x \rvert con cada token nuevo. El punto donde cambia se despeja tomando logaritmos en VxTxqdh\lvert V_x \rvert^{T_x} \le q^{d_h}:

T=dhlogqlogVx.T^{\star} = \left\lfloor \frac{d_h \log q}{\log \lvert V_x \rvert} \right\rfloor.

Pon números de los que se usan de verdad. Un estado de dh=256d_h = 256 coordenadas, ocho niveles por coordenada y un vocabulario de subpalabras de Vx=32768\lvert V_x \rvert = 32\,768 entradas dan T=51T^{\star} = 51 tokens; diez tokens más allá, el techo de arriba ha caído a 104410^{-44}. Y hay una palabra que no aparece en ninguna de las tres fórmulas: entrenamiento. Ni el número de iteraciones, ni la tasa de aprendizaje, ni los datos entran en el argumento, porque lo que se ha contado es cuántas respuestas distintas puede llegar a dar un mecanismo, no cómo de bien las elige.

Un techo muy por encima de donde se rompe

Ese recuento demuestra que la pared existe. No dice dónde está, y conviene ser explícito con la diferencia, porque el mejor modelo de la celda siguiente tiene dh=32d_h = 32, q=8q = 8 niveles y Vx=4\lvert V_x \rvert = 4 —cuatro letras—, lo que le da un techo de T=48T^{\star} = 48 tokens. En tiras de cinco letras acertará una de cada tres.

La distancia entre esas dos cifras tiene una explicación, y es material del bloque anterior mirado desde un sitio nuevo. El gradiente que enseña al encoder a conservar el token jj de la entrada sale del término de la pérdida en la posición ii de la salida, retrocede por los pasos del decoder hasta s0=c\mathbf{s}_0 = \mathbf{c}, y desde ahí sigue por los del encoder hasta la posición jj. No hay otra ruta —c\mathbf{c} es el único punto de contacto entre las dos redes—, y su longitud es

i+(Txj)multiplicaciones,i + (T_x - j) \quad \text{multiplicaciones},

máxima cuando j=1j = 1, es decir para el primer token de la entrada. La lección sobre el gradiente desvanecido dice qué le ocurre a una señal que atraviesa esa ruta: cada paso la multiplica por Whh\mathbf{W}_{hh}^{\top} y por la máscara del tanh\tanh, y con un radio espectral menor que uno la magnitud cae de forma exponencial con la distancia. Al primer token de la frase le llega la señal más débil de todas.

De modo que el encoder no reparte c\mathbf{c} entre las posiciones como lo haría un código óptimo: lo llena sobre todo con lo último que ha leído, porque es lo único que consigue aprender a conservar. Los 4848 tokens de la cuenta suponen un codificador perfecto; el puñado de letras del experimento es lo que se consigue entrenando una recurrencia con descenso de gradiente. Las dos cosas son ciertas y dicen cosas distintas: contar demuestra que la pared existe; entrenar enseña dónde está.

Dos anchos de estado y tres presupuestos de entrenamiento

La tarea es la de la lección sobre modelos de secuencia a secuencia: leer una tira de letras y escribirla al revés. Es deliberadamente pobre —no hay gramática, ni significado, ni nada que generalizar— y esa pobreza es la que hace limpio el experimento: para invertir una tira no hace falta más que recordarla, así que todo lo que falle es memoria.

La primera celda monta un modelo del ancho que le pidas y define su ida y vuelta, línea por línea las de la lección anterior. Lo único que hace falta mirar es que c\mathbf{c} sigue siendo el único enlace entre las dos redes: una sola variable, marcada en el código.

import numpy as np

# La tarea de secuencia a secuencia del bloque anterior: leer una tira de letras y escribirla al reves.
V = list("abcd") # cuatro letras: |V_x| = 4
n_sim, GO = 4, 4 # GO solo entra al decoder, nunca se predice
L_MIN, L_MAX = 2, 7
eta, theta = 0.10, 5.0


def monta(d_h): # misma semilla: los modelos parten igual
r = np.random.default_rng(1); sc = 0.1
return [r.normal(size=(d_h, 5)) * sc, r.normal(size=(d_h, d_h)) * sc, np.zeros(d_h),
r.normal(size=(d_h, 5)) * sc, r.normal(size=(d_h, d_h)) * sc, np.zeros(d_h),
r.normal(size=(n_sim, d_h)) * sc, np.zeros(n_sim)]


def paso(pesos, x, y): # ida y vuelta, las de la leccion anterior
Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by = pesos
d_h = len(bh_e); L = len(x)
He = np.zeros((d_h, L)); h = np.zeros(d_h)
for j in range(L):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e); He[:, j] = h
c = h # el cuello: por aqui pasa todo
ent = np.concatenate(([GO], y[:-1])) # teacher forcing
S = np.zeros((d_h, L)); P = np.zeros((n_sim, L)); s = c.copy()
for i in range(L):
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[:, i] = p / p.sum()
gWxh_e = np.zeros_like(Wxh_e); gWhh_e = np.zeros_like(Whh_e); gbh_e = np.zeros_like(bh_e)
gWxh_d = np.zeros_like(Wxh_d); gWhh_d = np.zeros_like(Whh_d); gbh_d = np.zeros_like(bh_d)
gWhy = np.zeros_like(Why); gby = np.zeros_like(by); ds = np.zeros(d_h)
for i in reversed(range(L)): # --- vuelta del decoder ---
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)
gWhh_d += np.outer(dp, S[:, i - 1] if i > 0 else c); gWxh_d[:, ent[i]] += dp; gbh_d += dp
ds = Whh_d.T @ dp
dh = ds # unico enlace entre las dos redes
for j in reversed(range(L)): # --- vuelta del encoder ---
dp = (1 - He[:, j] ** 2) * dh
gWhh_e += np.outer(dp, He[:, j - 1] if j > 0 else np.zeros(d_h))
gWxh_e[:, x[j]] += dp; gbh_e += dp
dh = Whh_e.T @ dp
return [g / L for g in (gWxh_e, gWhh_e, gbh_e, gWxh_d, gWhh_d, gbh_d, gWhy, gby)]
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.

La segunda entrena y mide: por cada longitud, el porcentaje de tiras que salen exactas —ciento veinte tiras nuevas por casilla, generadas sin teacher forcing—. Salen cuatro filas. Las tres primeras son el mismo estado estrecho, dh=8d_h = 8, en tres momentos de su entrenamiento; la cuarta tiene el estado cuatro veces más ancho y entrena la mitad que la tercera. Las tiras de prueba son las mismas en las cuatro filas y las redes parten de la misma semilla, de modo que la única diferencia entre filas es lo que dice su etiqueta.

import numpy as np

# Necesita la celda anterior: monta, paso, n_sim, GO, L_MIN, L_MAX, eta, theta.
def escribe(pesos, x): # generacion voraz, sin teacher forcing
Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by = pesos
h = np.zeros(len(bh_e))
for j in range(len(x)):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e)
s = h; ix = GO; out = []
for _ in range(len(x)):
s = np.tanh(Wxh_d[:, ix] + Whh_d @ s + bh_d)
ix = int((Why @ s + by).argmax()); out.append(ix)
return out


def acierto(pesos): # % de tiras EXACTAS, por longitud
ac = np.random.default_rng(123) # mismas tiras de prueba en todas las filas
fila = []
for L in range(L_MIN, L_MAX + 1):
bien = 0
for _ in range(120):
x = ac.integers(0, n_sim, size=L)
bien += int(escribe(pesos, x) == list(x[::-1]))
fila.append(100 * bien / 120)
return fila


print(" modelo " + "".join("L=%d " % L for L in range(L_MIN, L_MAX + 1)))
for d_h, cortes in [(8, (250, 1000, 2000)), (32, (1000,))]:
pesos = monta(d_h); rng = np.random.default_rng(2)
for it in range(max(cortes)):
L = int(rng.integers(L_MIN, L_MAX + 1))
x = rng.integers(0, n_sim, size=L); y = x[::-1].copy()
grads = paso(pesos, 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
if it + 1 in cortes:
print(" d_h=%2d, %4d iter " % (d_h, it + 1)
+ "".join("%3.0f%% " % v for v in acierto(pesos)))
numpy

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.

Lee la tabla por parejas de filas. De la primera a la segunda, el entrenamiento hace exactamente lo que se espera de él: en 750 iteraciones más, las tiras de dos y tres letras pasan de no salir nunca a salir la mayoría de las veces. De la segunda a la tercera, al doblar el presupuesto, ese progreso continúa —y sólo en las columnas cortas—: las tiras de dos letras llegan al pleno y las de tres y cuatro suben otro tramo, mientras que las de cinco, seis y siete se quedan a ras de suelo, donde ya estaban. Ahí está el hallazgo, y conviene decirlo despacio: entrenar compra acierto en lo que cabe, y no compra nada en lo que no cabe.

Y ahora la cuarta fila, que entrena la mitad que la tercera con un estado cuatro veces más ancho: sube todo lo que tenía margen para subir, y las tres columnas largas pasan de anecdóticas a reales —una de cada tres tiras de cinco letras, contra una de cada doce—. Esa es la respuesta a la pregunta del principio, medida y no intuida. El ancho de c\mathbf{c} y el presupuesto de entrenamiento no compran lo mismo: el segundo afina el uso del vector hasta donde el vector llega, y sólo el primero mueve la pared.

Lo más revelador, sin embargo, es cómo se equivoca, que es la sección anterior vista desde el lado de la salida. La lección sobre modelos de secuencia a secuencia ya lo dejaba ver en esta misma tarea: en una tira larga, el principio de lo que el decoder escribe —que es el final de lo que el encoder leyó, lo más reciente en c\mathbf{c}— suele estar bien, y lo que se desordena es el final, que le pediría recordar el primer token de la entrada. El token peor conservado es el que está al final de la ruta más larga.

Comprueba tu intuición

Cuatro preguntas: qué arregla entrenar más, cuánto mide el techo del recuento, qué se compra al ensanchar el estado, y qué token de la entrada es el que peor sobrevive.

El modelo estrecho de la tabla, con dh=8d_h = 8, acierta buena parte de las tiras de tres letras y casi ninguna de las de seis. Lo entrenas diez veces más. ¿Qué esperas ver?

Un encoder tiene dh=64d_h = 64 coordenadas y de cada una se distinguen q=8q = 8 niveles. Lee frases escritas sobre un vocabulario de Vx=4096\lvert V_x \rvert = 4096 entradas. ¿Cuántos tokens mide la frase más larga que la cuenta de esta lección no descarta?

tokens

Se acepta un margen de ±0.

Según la cuenta de esta lección, doblar dhd_h dobla TT^{\star} —la longitud máxima que el recuento no descarta—, salvo el redondeo a entero.

En una entrada de TxT_x tokens, ¿cuál es el que peor sobrevive dentro de c\mathbf{c}?


Las dos mitades de la lección apuntan al mismo sitio, y no es al tamaño de dhd_h. El recuento compara los mensajes que caben con las frases que hay porque hemos aceptado que haya un solo mensaje; la ruta se hace larga porque toda la información pasa por un solo punto. Ensanchar c\mathbf{c} mueve el techo unos tokens y deja las dos frases anteriores intactas. Lo que hay debajo no es un vector pequeño: es la idea de resumir.

Y hay algo que llama la atención en cuanto se dice así. El encoder calculó hˉ1,,hˉTx\bar{\mathbf{h}}_1, \dots, \bar{\mathbf{h}}_{T_x}, un estado por token de la entrada, y la arquitectura tira todos menos el último. Los otros existieron, se calcularon y se descartaron. Recuperarlos —dejar que el decoder mire hacia atrás, a la entrada entera, en lugar de conformarse con el resumen— es la lección siguiente, sobre la idea de atención. Ponerle fórmulas a esa mirada ocupa el resto del bloque.

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.

  • On the Properties of Neural Machine Translation: Encoder-Decoder Approaches
    paperCho, van Merriënboer, Bahdanau y Bengio, 2014arXiv:1409.1259EN

    El experimento de esta lección con traducción real: su figura 4a es la calidad cayendo en picado según crece la frase, y su §5.1 lo achaca al vector fijo. Mide la pared; no cuenta por qué está ahí, que es la otra mitad de la lección.