Proyecto: un modelo de lenguaje a nivel de carácter
30 min read
La lección anterior, sobre la GRU, dejó el bloque con toda su maquinaria montada y ninguna pieza en marcha: una red que lee textos de cualquier longitud, un gradiente que la recorre por el tiempo, y dos celdas que le dan una memoria capaz de sobrevivir a la distancia. Lo que falta no es otra pieza, es un trabajo que las use. Este es ese trabajo, y consiste en construir un modelo que, leído un texto, sepa seguir escribiéndolo.
Ese modelo tiene nombre: un modelo de lenguaje (language model) es una red que, dado un trozo de texto, predice qué viene después. A nivel de carácter —la tokenización por caracteres de la lección sobre tokenización— eso es el próximo carácter, una letra, un espacio o un punto, y en esa pregunta minúscula cabe toda la tarea. Se entrena sobre un corpus pequeño, unos miles de caracteres, y después se le pide que escriba, carácter a carácter, realimentándose lo que va produciendo. El texto que sale es la red hablándose a sí misma. Saldrá malo —lo diré con todas las letras cuando salga—, y será la primera vez en el curso que algo escribe.
La red que entrenamos en la lección sobre BPTT leía una reseña entera y daba un solo veredicto, una salida y una pérdida, colgadas del último estado. Un modelo de lenguaje cambia una cosa y sólo una: saca una predicción en cada posición. En el paso , con el estado que resume lo leído hasta ahí, predice el carácter ; de modo que un texto de caracteres son predicciones en una sola pasada, y su pérdida suma las . Ahí está lo que convierte un texto en un conjunto de entrenamiento sin que nadie lo etiquete: la respuesta correcta de cada posición es el carácter de la siguiente, ya escrito en el corpus.
Lo honesto es decir de entrada qué aprende y qué no. Con unos miles de caracteres y un estado de unas decenas de coordenadas, la red aprende la estadística de los caracteres: dónde caen los espacios, que tras la q viene casi siempre una u, que la, de y el están por todas partes. No aprende nada del significado ni de la gramática. Así que lo que genera se parece al español visto desde la otra punta de una sala ruidosa: palabras cortas de verdad, palabras largas verosímiles que no existen, y ni una frase que quiera decir algo. Eso no es un defecto que esta lección venga a arreglar: es lo que un modelo de caracteres de este tamaño aprende, y nombrarlo sin rodeos es media lección.
Una salida en cada posición
La recurrencia es la de la lección sobre la RNN vanilla, sin tocar una letra: , con . Lo nuevo es : como cada token es un carácter, la entrada de la posición es el one-hot de ese carácter, , donde es el conjunto de caracteres distintos del corpus. El vocabulario ya no son palabras: son caracteres, así que y .
Ese one-hot trae el atajo de la lección sobre one-hot: multiplicar una matriz por un one-hot escoge una de sus columnas, columna de . Así que las columnas de son los vectores de entrada de cada carácter, que la red aprende sobre la marcha.
Lo nuevo es la salida. La lección sobre la RNN vanilla leía un veredicto del último estado; el modelo de lenguaje lee uno de cada estado, y es una distribución sobre el próximo carácter:
con , una fila por cada carácter que podría venir. La etiqueta es la que el corpus puso de verdad: el one-hot del carácter siguiente, . La pérdida de esa posición es la entropía cruzada de la lección sobre funciones de pérdida, y la de la secuencia entera suma una por posición, promediada por carácter:
donde es la probabilidad que la red le dio al carácter que de verdad venía. El es la media por posición, el análogo del del bloque anterior repartiendo entre el batch —sólo que aquí el «lote» son las posiciones de una misma secuencia—.
La vuelta: cada paso recibe dos errores
La lección sobre BPTT ya hizo casi toda esta cuenta. Allí sólo el último estado tocaba la pérdida, así que sólo arrancaba desde una salida y los pasos interiores recibían su error transportado del siguiente. El modelo de lenguaje cambia exactamente eso: cada posición tiene su propia salida, de modo que cada paso arranca un error propio y además pasa hacia atrás el que viene de adelante.
Sigue siendo , el error de la preactivación. Lo que cambia es que el estado llega ahora a la pérdida por dos caminos —su salida en y el paso —, y la regla de la cadena del bloque anterior los suma:
donde el primer término es la salida de la lección sobre funciones de pérdida —softmax con entropía cruzada entrega — repartida entre las coordenadas del estado por , y el segundo es el transporte de la lección sobre BPTT, intacto. Enmascara con la pendiente del y tienes el error del paso:
con , porque después del último paso no hay ninguno. Ponla al lado de las dos líneas de la lección sobre BPTT y verás que son sus dos mitades juntas: el arranque desde la salida, que allí era sólo de , y el transporte, que allí era sólo de los interiores.
Con el error de cada paso en la mano, los gradientes son los de la lección sobre BPTT más el de la capa de salida, todos sumados sobre las posiciones:
y los sesgos igual, sumados. Todo lo que va bajo la suma es una pieza ya derivada; lo único nuevo es que también se comparte entre las posiciones —una salida por paso, no una al final—, así que su gradiente se une a la suma sobre el tiempo en lugar de ser un producto suelto.
Ver por qué la estadística cercana se aprende y la lejana no
El transporte sigue multiplicando por una vez por paso, así que el desvanecimiento de la lección sobre el gradiente que se desvanece sigue en pie: un error que deba volver muchos pasos atrás llega apagado. La diferencia con el clasificador es que ahora cada posición inyecta un error fresco desde su propia salida, sin haber cruzado todavía ninguna . Por eso el modelo aprende bien lo cercano y lo lejano se le escapa: la señal que ata un carácter con su vecino no viaja, y la que lo ataría con algo de veinte posiciones atrás se apaga por el camino. Es la frontera que motivaba las celdas con compuerta, vista desde el texto que producen.
Entrenar y generar en NumPy
Tres celdas: el corpus, el entrenamiento y la escritura. Ejecútalas en orden.
La primera trae el corpus —unos párrafos sobre un pueblo de mar, escritos para el curso—, lo pasa a minúsculas, le quita todo lo que no sea letra, espacio, punto o coma, y lo deja como una tira de índices. Mira el vocabulario que queda, y la última línea, que enseña el modelo entero en dos palabras: lo que entra es el texto, y lo que sale es el mismo texto corrido una posición.
import numpy as np
from pyodide.http import open_url
raw = open_url("/courses/dl-nlp/corpus-mar.txt").read()
texto = raw.lower()
texto = re.sub(r"[^a-záéíóúñ .,]", " ", texto) # deja letras, ñ, tildes, espacio, . y ,
texto = re.sub(r"\s+", " ", texto).strip() # un solo espacio entre palabras
V = sorted(set(texto)) # el vocabulario: los caracteres distintos
a_ix = {c: i for i, c in enumerate(V)}
datos = np.array([a_ix[c] for c in texto]) # el corpus entero, como indices
n_v = len(V)
print("corpus: %d caracteres" % len(texto))
print("vocabulario: %d caracteres -> «%s»" % (n_v, "".join(V)))
print("entra:", repr("".join(V[i] for i in datos[:12])))
print("sale: ", repr("".join(V[i] for i in datos[1:13])), " (el mismo, corrido una posicion)")
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Salen 2 632 caracteres y 32 caracteres distintos —las letras, la ñ, las cinco vocales con tilde, el espacio y dos signos—, así que . Las dos últimas líneas son el conjunto de entrenamiento entero: el pueblo es como entrada y l pueblo est como etiqueta, la misma tira desplazada un lugar. De ahí salen los casi 2 632 ejemplos, uno por posición, sin etiquetar nada.
La segunda es el modelo completo: la ida de la lección sobre la RNN vanilla, que en cada posición saca una puntuación por carácter y el softmax vuelve probabilidades; la vuelta de la lección sobre BPTT con el error de la salida sumado en cada paso; el recorte del gradiente de la lección sobre el gradiente que se desvanece; y un descenso que actualiza los cinco grupos de pesos. Entrena 1 200 pasos sobre trozos de 25 caracteres, y cada trozo es una secuencia independiente que arranca en , como en la lección sobre BPTT. Mira las dos cosas que imprime cada pocos cientos de pasos: la pérdida por carácter, y una muestra de lo que la red escribiría entonces.
# Necesita la celda anterior: datos, V, a_ix, n_v.
d_h, seq = 64, 25 # anchura del estado y longitud de cada trozo
eta, theta, iters = 0.3, 5.0, 1200
rng = np.random.default_rng(0) # semilla fija: veras estos numeros
Wxh = rng.normal(size=(d_h, n_v)) * 0.01 # (d_h, n_v): columna c = vector del caracter c
Whh = rng.normal(size=(d_h, d_h)) * 0.01 # (d_h, d_h): la recurrencia de la RNN vanilla
Why = rng.normal(size=(n_v, d_h)) * 0.01 # (n_v, d_h): del estado a una puntuacion por caracter
bh, by = np.zeros(d_h), np.zeros(n_v)
def adelante(inp, tgt):
H, h = np.zeros((d_h, len(inp))), np.zeros(d_h)
for t in range(len(inp)):
h = np.tanh(Wxh[:, inp[t]] + Whh @ h + bh) # la recurrencia paso a paso (Wxh @ one-hot = columna)
H[:, t] = h
O = Why @ H + by[:, None] # (n_v, seq): una columna de puntuaciones por posicion
O -= O.max(0); P = np.exp(O); P /= P.sum(0) # softmax en cada columna
loss = -np.log(P[tgt, np.arange(len(inp))]).mean() # entropia cruzada, media por caracter
return H, P, loss
def atras(inp, tgt, H, P):
T = len(inp)
dO = P.copy(); dO[tgt, np.arange(T)] -= 1.0 # (y_hat - y) en cada posicion
gWhy = dO @ H.T; gby = dO.sum(1); dHy = Why.T @ dO # capa de salida, sumada sobre el tiempo
gWxh, gWhh, gbh = np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(bh)
dh_sig = np.zeros(d_h)
for t in reversed(range(T)):
dp = (1 - H[:, t] ** 2) * (dHy[:, t] + dh_sig) # delta_t: el error de la salida de t y el del paso t+1
h_ant = H[:, t - 1] if t > 0 else np.zeros(d_h)
gWhh += np.outer(dp, h_ant); gWxh[:, inp[t]] += dp; gbh += dp
dh_sig = Whh.T @ dp # transporta el error a t-1, como en la BPTT
return [g / T for g in (gWxh, gWhh, gWhy, gbh, gby)]
def genera(n, semilla):
g = np.random.default_rng(semilla); h = np.zeros(d_h); ix = a_ix[" "]; salida = []
for _ in range(n):
h = np.tanh(Wxh[:, ix] + Whh @ h + bh)
o = Why @ h + by; o -= o.max(); p = np.exp(o); p /= p.sum()
ix = g.choice(n_v, p=p); salida.append(ix) # muestrea el proximo y lo realimenta
return "".join(V[i] for i in salida)
j, suave = 0, None
for it in range(iters):
if j + seq + 1 >= len(datos): j = 0
inp, tgt = datos[j:j + seq], datos[j + 1:j + seq + 1]; j += seq
H, P, loss = adelante(inp, tgt)
grads = atras(inp, tgt, H, P)
for g in grads:
nrm = np.linalg.norm(g)
if nrm > theta: g *= theta / nrm # recorte del gradiente
for Pm, g in zip([Wxh, Whh, Why, bh, by], grads):
Pm -= eta * g
suave = loss if suave is None else 0.999 * suave + 0.001 * loss
if it % 300 == 0 or it == iters - 1:
print("iter %4d perdida/car %.3f «%s»" % (it, suave, genera(48, 0)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
La pérdida arranca en , que no es un número cualquiera: es , la pérdida de una red que reparte por igual entre los 32 caracteres —el eco del del clasificador del bloque anterior, con 32 caras en lugar de 2—. De ahí baja sin sobresaltos hasta rondar , y seguiría bajando: el bucle se corta pronto para caber en los diez segundos que el navegador da a una celda, porque mediocre y a tiempo vale más que bueno y nunca. La muestra de cada línea cuenta la misma historia por otro lado: la primera es ruido, caracteres al azar y tildes sueltas; hacia la mitad ya hay espacios en su sitio y palabras cortas; al final es español mal hecho: palabras cortas reales —la, el, de, y— enhebradas entre palabras largas que suenan a español sin serlo.
El corte en trozos tiene un segundo precio: como cada trozo empieza de cero, la red nunca ve una dependencia más larga que 25 caracteres. Para un modelo que sólo persigue la estadística local se paga a gusto; y esa dependencia larga a la que se renuncia es justo la que las celdas con compuerta de la LSTM y la GRU sabían conservar.
La tercera celda usa los pesos ya entrenados para escribir. Generar invierte el entrenamiento: en vez del carácter real que venía, la red recibe el que ella misma propone. Arranca en con un carácter cualquiera —un espacio, para empezar palabra—, calcula , muestrea de esa distribución el siguiente (sampling: extraer uno al azar con la probabilidad que le da), lo realimenta como entrada, y repite. La celda genera dos veces muestreando y una tercera tomando siempre el carácter más probable, para ver la diferencia.
# Necesita las dos celdas anteriores: los pesos entrenados, genera, V, a_ix, d_h.
print("MUESTREANDO (varia con la semilla):")
for s in [0, 1]:
print(" «%s»" % genera(200, s))
def genera_voraz(n): # siempre el mas probable: determinista
h = np.zeros(d_h); ix = a_ix[" "]; salida = []
for _ in range(n):
h = np.tanh(Wxh[:, ix] + Whh @ h + bh)
o = Why @ h + by; o -= o.max(); p = np.exp(o); p /= p.sum()
ix = int(p.argmax()); salida.append(ix) # el mas probable, sin azar
return "".join(V[i] for i in salida)
print("\nSIEMPRE EL MAS PROBABLE (determinista):")
print(" «%s»" % genera_voraz(200))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Las dos primeras líneas salen distintas entre sí —el azar del muestreo— y las dos son español mal hecho. La última no: tomar siempre el carácter más probable es determinista, y en cuanto entra en una racha de alta probabilidad se queda atrapado repitiendo un mismo fragmento una y otra vez. Por eso se muestrea en lugar de ir a lo seguro: el azar es lo que impide que la red se muerda la cola. Y con todo, ninguna de las dos escribe nada que signifique algo. No puede: nunca vio una palabra como unidad, sólo caracteres, y olvidó cuanto quedaba más atrás de 25. Es exactamente lo que se prometió —malo—, y verlo salir es la lección.
Comprueba tu intuición
Tres preguntas: cuántos ejemplos esconde un texto para este modelo, de dónde le llega el error a cada paso de la vuelta, y qué arreglaría —y qué no— entrenar mucho más.
El corpus de esta lección tiene 2 632 caracteres. ¿Cuántos ejemplos de entrenamiento contiene para este modelo, y por qué?
En la lección sobre BPTT, un paso interior recibía su error de un solo sitio. En este modelo de lenguaje, ¿de dónde le llega el error a ? Marca todo lo que sea cierto.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
El texto que genera la red es reconociblemente español y a la vez un disparate sin sentido. Si la entrenaras diez veces más pasos, ¿qué cabría esperar?
Por primera vez en el curso, una red ha escrito. Es poco y es malo, pero es suyo: ningún carácter de esas líneas estaba en el corpus en ese orden, y aun así se parecen al español porque la red aprendió, letra a letra, en qué consiste parecerse al español. Toda la maquinaria del bloque —leer cualquier longitud, entrenar por el tiempo, arreglar la vía por la que el gradiente vuelve— estaba para poder llegar hasta aquí y encender algo.
Pero este modelo hace una sola cosa: continuar una tira de texto en el mismo idioma en que la leyó. No sabe tomar una secuencia y devolver otra distinta —una pregunta y su respuesta, una frase en español y la misma en inglés— porque sólo maneja una secuencia, no dos. Leer una entera con una red y escribir otra con otra, atándolas por lo que la primera entendió, es la siguiente lección, sobre modelos de secuencia a secuencia: el encoder que resume lo leído y el decoder que escribe a partir de ese resumen, el último paso antes de salir del bloque.
Further reading2 sources · 1 paper, 1 article
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- The Unreasonable Effectiveness of Recurrent Neural Networks
Su gist min-char-rnn es casi tu celda: una RNN vanilla que predice el siguiente carácter, con BPTT y recorte del gradiente. Sus muestras salen mejores porque usa LSTM de varias capas y megabytes de texto, no unos miles de caracteres.
- Generating Sequences With Recurrent Neural Networks
La versión seria de lo que haces: un modelo a nivel de carácter, entrenado sobre Wikipedia con LSTM, que genera muestreando un carácter cada vez. Consigue texto con estructura de párrafo; además hace escritura a mano, que no te toca.