Perplejidad: medir un modelo de lenguaje
30 min de lectura
El muestreo y la caché de claves y valores dejaron resuelto cómo escribe el mini-GPT y cuánto cálculo le cuesta cada token. Falta saber cuánto sabe. La cifra que el bloque viene citando es su pérdida sobre las páginas de Marianela que se apartaron antes de entrenarlo: por token. Ahora piensa en un modelo mucho más tosco. Cuenta cuántas veces sale cada byte en la parte de la novela con la que se entrenó el mini-GPT y, en cada posición, apuesta por los 256 en esas proporciones, sin mirar nada de lo que hay detrás. Sobre las mismas páginas reservadas paga por token.
La misma cifra. ¿Es el mini-GPT, con sus parámetros y sus 2 000 pasos de entrenamiento, tan bueno como contar letras? No, y la razón es el primer asunto de esta lección: los dos pagan por token, y sus tokens no son lo mismo. El del modelo que cuenta bytes es un byte; el del mini-GPT, más de dos. Una pérdida por token mide a la vez al modelo y a su tokenizador, y sólo compara modelos que cortan igual. Esta lección construye la medida que compara cualquier par, le pone al lado dos varas de medir y demuestra lo que la lección sobre el entrenamiento dejó pendiente: por qué la pérdida se mide sobre texto que el modelo no ha visto, y qué pasa cuando deja de serlo.
Hay una manera de leer sin logaritmos. Un modelo que en cada posición dudara exactamente entre candidatas igual de probables, una de ellas siempre la buena, pagaría por token; con , . Leída al revés, la pérdida del mini-GPT dice que ante las páginas reservadas duda como si en cada token tirara un dado de unas 24 caras, elegidas entre las 512 entradas del vocabulario. El modelo que cuenta bytes tira también un dado de 24 caras, pero lo tira en cada byte.
Y ahí está la diferencia. Una tirada del mini-GPT escribe de media 2.14 bytes, y repartida entre ellos equivale a un dado de algo más de cuatro caras por byte. El modelo que cuenta bytes necesita sus 24 caras para cada uno. Contado por byte, el mini-GPT duda más de cinco veces menos.
La perplejidad: la pérdida contada en candidatas
La lección sobre el modelo de lenguaje causal escribió la pérdida de un texto como el logaritmo de su probabilidad, cambiado de signo y repartido entre sus tokens predichos. Ese logaritmo es el natural, y la pérdida sale en nats; en esta lección, a secas es . Deshagamos el logaritmo. La perplejidad del modelo sobre el texto es
la media geométrica de los inversos de las apuestas por lo que de verdad vino. Si todas valen , cada inverso vale y : el dado de arriba, ahora exacto. El modelo uniforme, que da a todo, tiene , 512 con el vocabulario del mini-GPT; uno que lo apostara todo a la verdad, siempre, tiene . Que la media sea geométrica tiene un precio: una apuesta casi nula por algo que ocurrió pesa más que muchas buenas, y una apuesta nula la hace infinita.
La perplejidad no depende de la base del logaritmo. Con , la pérdida sale en bits, y vale ; el mini-GPT paga bits por token. Pero : el mismo número de caras, se cuente en la unidad que se cuente.
Bits por byte: una medida que no depende del tokenizador
La perplejidad hereda el defecto de la pérdida: las dos van por token. Dos tokenizadores parten el mismo texto en números distintos de tokens, y un modelo de tokens más largos reparte la misma dificultad entre menos apuestas, cada una más difícil. Lo que no cambia con el corte es el texto: sus bytes, y la probabilidad que el modelo le da a él entero, . Dividamos lo que cuesta el texto entero, en bits, entre sus bytes:
con los bytes de los tokens predichos y los bytes por token de la lección sobre BPE. Son los bits por byte (bits per byte, bpb). El mini-GPT, con en el texto reservado, paga ; el que cuenta bytes, con , . Y es la perplejidad por byte: el dado de algo más de cuatro caras frente al de 24.5.
(Con rigor, el modelo reparte también algo de probabilidad entre cortes del mismo texto que el tokenizador nunca produce, y esa masa se pierde. Los artículos la ignoran, y esta cuenta también.)
El tokenizador no desaparece de la cuenta: pasa a formar parte de lo que se mide. Por eso el modelo uniforme sobre las 512 entradas del mini-GPT, que no sabe nada, paga bits por byte, menos que el modelo que cuenta bytes. Las fusiones ya son un poco de español.
Dos varas de medir, y dónde se mide
Un número solo no dice si es bueno. El modelo uniforme es la vara más pobre posible. La siguiente sabe una cosa, con qué frecuencia sale cada entrada, y nada del orden. Llamemos a las veces que la entrada aparece en la parte de entrenamiento, como contaba pares en la lección sobre BPE. El modelo de unigramas da en cada posición la misma distribución, sea cual sea el prefijo:
El es el suavizado de Laplace, y no es un adorno. Una entrada con recibiría probabilidad cero, y si aparece en el texto que se mide, la pérdida es infinita. Al mini-GPT no le puede pasar: el softmax nunca da cero. Con tokens de entrenamiento, sumar uno apenas mueve las demás frecuencias.
Sin el uno, este modelo es el mejor que no mira el contexto: sobre el texto donde se contaron las frecuencias, ninguna distribución fija paga menos que ellas. Por eso lo que el mini-GPT paga por debajo del modelo de unigramas es lo que le compra el contexto, y nada más.
Ver por qué ninguna distribución fija paga menos que las frecuencias
Un modelo sin contexto da a la entrada la misma probabilidad en todas las posiciones. Sobre un texto de tokens en el que sale veces, paga por token. Restémosle lo que pagan las frecuencias y usemos que (la recta tangente en queda por encima del logaritmo):
con las sumas sobre las entradas que salen al menos una vez. La diferencia nunca es negativa, y vale cero sólo si en todas, porque únicamente en .
Por qué la pérdida sobre el entrenamiento engaña
Queda dónde medir. Para unos pesos fijados de antemano, la pérdida media sobre ventanas de la parte de entrenamiento, el de la lección sobre el entrenamiento, y la misma media sobre ventanas del texto reservado, , estiman lo mismo si los dos textos salen del mismo sitio: la pérdida media sobre todo el texto de ese origen, que llamaremos . Es el argumento del batch de aquella lección: una media de ventanas sacadas al azar no tiene sesgo.
Pero los pesos del checkpoint no se fijaron de antemano: se eligieron bajando , mirando justo esas ventanas. Llamemos a los que la hacen mínima y a los que hacen mínima , los mejores que esta red puede tener. Con la media tomada sobre el azar que decide qué texto cae en cada parte,
La primera desigualdad es la definición de : sobre su corpus no paga más que nadie. La igualdad siguiente vale porque no depende del corpus; la segunda desigualdad es la definición de ; y la última igualdad vale porque no depende del texto reservado. Leída de punta a punta, la pérdida de entrenamiento queda de media por debajo incluso de la de la mejor red posible, y la del texto reservado estima sin sesgo lo que se paga con texto nuevo.
Dos salvedades. El checkpoint no es , porque 2 000 pasos no llegan al mínimo: la cadena dice hacia dónde, no cuánto. Y el texto reservado no está del todo limpio. Es el final de la novela y no una muestra al azar, las fusiones del tokenizador se contaron sobre la novela entera, y eligió el checkpoint: el paso 1 750 fue el de menor pérdida sobre él entre ocho candidatos. Elegir entre ocho sesga poco; entre miles, como cuando se prueban hiperparámetros contra él, sesgaría mucho. Por eso los artículos apartan además un texto de prueba que sólo se mira al final.
Las varas de medir y el mini-GPT, en el navegador
La primera celda usa sólo el tokenizador; la segunda y la tercera corren sobre el mini-GPT, el checkpoint del bloque. Las tres apartan el mismo 10 % final del corpus como texto reservado. La primera mide el modelo de unigramas con tres tokenizadores (las 0, 64 y 256 primeras fusiones), con el uniforme al lado, y antes dice qué caracteres del texto reservado no salen en la parte de entrenamiento.
import numpy as np
from pyodide.http import open_url
exec(open_url("/courses/llm-agents/bpe.py").read()) # codificar, decodificar, quitar_cabecera
F = [tuple(par) for par in json.load(open_url("/courses/llm-agents/bpe-merges.json"))]
texto = quitar_cabecera(open_url("/courses/llm-agents/corpus.txt").read())
ids = codificar(texto, F)
n_res = len(ids) // 10 # el corte de siempre: el último 10 %
entren, res = decodificar(ids[:-n_res], F), decodificar(ids[-n_res:], F)
n = len(res.encode("utf-8")) # los bytes del texto reservado
print("texto reservado: %d bytes. Caracteres que el entrenamiento no tiene: %s"
% (n, " ".join(sorted(set(res) - set(entren)))))
def unigramas(m):
"""El modelo de unigramas con las m primeras fusiones: frecuencias del entrenamiento, más uno."""
f = np.bincount(codificar(entren, F[:m]), minlength=256 + m)
p_uni = (f + 1) / (f + 1).sum()
x = np.array(codificar(res, F[:m])) # el texto reservado, en tokens
return p_uni, x, int((f[x] == 0).sum())
print(" m |V| T pérdida PPL bpb | uniforme: PPL bpb")
for m in [0, 64, 256]:
p_uni, x, ceros = unigramas(m)
L = -np.log(p_uni[x]).mean() # nats por token
bpb = L * len(x) / n / np.log(2) # bits por byte
print("%4d %4d %5d %6.3f %6.1f %4.2f | %13d %4.2f" % (m, 256 + m, len(x), L, np.exp(L), bpb,
256 + m, np.log2(256 + m) * len(x) / n))
print("tokens del texto reservado con frecuencia cero:", ceros)
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.
Lee la tabla por columnas. La pérdida por token y la perplejidad suben con las fusiones, de a : cada token es un trozo más largo, y sin contexto es más difícil de acertar. Los bits por byte bajan, de a , y ésa es la dirección buena: con más fusiones, el modelo de unigramas sabe más del texto. Por token parecería que empeora. La primera fila es el modelo que cuenta bytes del principio, el de unigramas sin fusiones, con su .
La primera línea explica la última. La parte de entrenamiento no tiene los dígitos 1, 2, 6, 7 ni 8,
ni la Í, que salen en una inscripción de las últimas páginas y en la fecha con la que Galdós
firma la novela: 10 tokens con frecuencia cero. Cambia (f + 1) / (f + 1).sum() por f / f.sum() y
vuelve a ejecutarla: las tres pérdidas salen inf.
La segunda pone al mini-GPT al lado del modelo de unigramas de 256 fusiones, que tiene su mismo
tokenizador, sobre las mismas 32 ventanas de 64 tokens de cada parte. nats es la pérdida de la
lección sobre el modelo causal sin promediar: un número por posición.
exec(open_url("/courses/llm-agents/minigpt.py").read()) # MiniGPT, ventanas, Adam
V = vocabulario(F)
bytes_de = np.array([len(V[i]) for i in range(512)]) # |u|: los bytes de cada entrada
pesos = open_url("/courses/llm-agents/minigpt.json").read()
modelo = MiniGPT.cargar(pesos)
entren, res = np.array(ids[:-n_res]), np.array(ids[-n_res:])
def nats(modelo, X, Y):
"""-log p del token que de verdad vino, en cada posición de cada ventana: forma (B, T)."""
Z = modelo.adelante(X)[0]
logP = Z - Z.max(axis=-1, keepdims=True)
logP -= np.log(np.exp(logP).sum(axis=-1, keepdims=True)) # log-softmax
return -np.take_along_axis(logP, Y[..., None], axis=-1)[..., 0]
print(" pérdida PPL bpb")
for nombre, parte in [("entrenamiento", entren), ("reservado", res)]:
X, Y = ventanas(parte, 32, 64, np.random.default_rng(0)) # 32 ventanas de 64 tokens
print(nombre)
for quien, c in [("unigramas", -np.log(p_uni[Y])), ("mini-GPT", nats(modelo, X, Y))]:
L, bpb = c.mean(), c.sum() / np.log(2) / bytes_de[Y].sum()
print(" %-14s %6.3f %5.1f %4.2f" % (quien, L, np.exp(L), bpb))
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.
Sobre el texto reservado, el mini-GPT paga por token: perplejidad y bits por byte. (Es una muestra de tokens; sobre el texto reservado entero, el guion que produjo el checkpoint mide .) El modelo de unigramas paga , perplejidad . El contexto le compra al mini-GPT nats por token: dividir entre ocho las candidatas.
Y compara las dos partes. El modelo de unigramas paga casi lo mismo en las dos, y : con 511 números libres no puede aprenderse el texto de entrenamiento. El mini-GPT paga sobre el de entrenamiento, menos que sobre el reservado. Es la cadena de arriba, medida. Leyó cada token de esa parte unas veintiocho veces, y parte de lo que sabe de ella lo sabe de memoria.
La tercera responde a lo que quedó pendiente en la lección sobre el entrenamiento. Allí, seguir entrenando sobre las últimas páginas de la novela bajaba la pérdida sobre esas mismas páginas. La celda repite aquel bucle en 100 pasos y mide antes y después sobre esas páginas y sobre el resto del texto reservado, que el bucle no toca.
import time
cola, resto = res[-3000:], res[:-3000] # las últimas páginas, y lo demás
muestras = [ventanas(p, 16, 64, np.random.default_rng(0)) for p in (cola, resto)]
antes = [nats(modelo, X, Y).mean() for X, Y in muestras]
alumno, S = MiniGPT.cargar(pesos), 100 # una copia: el checkpoint no se toca
opt, rng, t0 = Adam(alumno.p), np.random.default_rng(0), time.time()
for s in range(1, S + 1): # el bucle de aquella celda, en corto
X, Y = ventanas(cola, 1, 16, rng)
_, g = alumno.perdida(X, Y)
norma = np.sqrt(sum((v ** 2).sum() for v in g.values()))
if norma > 1:
g = {k: v / norma for k, v in g.items()}
opt.paso(alumno.p, g, 5e-4 * min(1, s / 50) * (0.1 + 0.9 * (1 + np.cos(np.pi * s / S)) / 2))
if time.time() - t0 > 5: # el navegador corta a los 10 s
print("tiempo agotado en el paso", s)
break
print("%d pasos sobre las últimas páginas antes después" % s)
for nombre, (X, Y), a in zip(["esas páginas", "el resto del reservado"], muestras, antes):
print(" %-37s %.3f %.3f" % (nombre, a, nats(alumno, X, Y).mean()))
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.
Sobre las páginas con las que entrena, la pérdida baja de a ; sobre el resto del
texto reservado, sube de a . El mini-GPT no ha aprendido español: se ha aprendido esas
páginas, sus nombres y sus palabras, y lo ha pagado en lo demás. Desde el primer paso, esas páginas
dejaron de ser texto reservado para él, y su pérdida pasó a medir lo que mide la de entrenamiento.
Cambia la semilla del bucle (el default_rng(0) de la línea del Adam): las cifras se mueven y los
signos no.
Comprueba tu intuición
Cuatro preguntas: una perplejidad a mano, dos tokenizadores frente a frente, lo que hace falta para medir sin sesgo y una probabilidad cero.
Sobre un texto del que predice cuatro tokens, un modelo apostó por el que de verdad venía en tres posiciones y en la cuarta. ¿Cuál es su perplejidad sobre ese texto? Redondea a dos decimales.
Se acepta un margen de ±0.05.
Dos modelos se miden sobre el mismo texto. El A usa un vocabulario de 512 entradas, con bytes por token, y tiene perplejidad . El B usa uno de , con , y tiene perplejidad . ¿Cuál le da más probabilidad al texto?
La pérdida sobre el texto reservado estima sin sesgo lo que el modelo pagará sobre texto nuevo del mismo origen. Marca lo que hace falta para que eso sea verdad.
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Sin el , el modelo de unigramas da probabilidad cero a 10 de los tokens del texto reservado. ¿Qué le pasa a su pérdida sobre ese texto?
El mini-GPT tiene ya una cifra que se puede poner al lado de otras: unos 2.2 bits por byte sobre las páginas reservadas de Marianela, tokenizador incluido, frente a los 3.6 del modelo de unigramas y los 4.2 del que no sabe nada. Y la segunda celda dice por dónde flojea. Su pérdida de entrenamiento queda por debajo de la reservada porque ha leído su corpus decenas de veces: tiene demasiados parámetros para tan poco texto, o demasiado poco texto para tantos parámetros, y las dos frases no piden lo mismo.
Cuál de las dos es la buena tiene una respuesta cuantitativa. La pérdida sobre texto no visto baja como una potencia del número de parámetros, del de tokens de entrenamiento y del cálculo gastado, y con un presupuesto fijo hay un reparto mejor que los demás entre modelo y datos. Medir esas potencias y ver dónde cae el mini-GPT respecto a ese reparto es la lección siguiente, sobre las leyes de escala.
Para profundizar3 fuentes · 2 papers, 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.
- Speech and Language Processing, capítulo 3: N-gram Language Models
Esta lección con n-gramas en lugar de un Transformer: la perplejidad como factor de ramificación medio, el suavizado de Laplace sobre unigramas y por qué se separan entrenamiento, desarrollo y prueba.
- The Pile: An 800GB Dataset of Diverse Text for Language Modeling
Un corpus que trae sus pautas de medida: bits por byte y no perplejidad, porque no dependen del tokenizador. La fórmula es la de esta lección; con el tokenizador de GPT-2 salen 3.4 bytes por token.
- Language Modeling Is Compression
La otra cara de los bits por byte: con codificación aritmética, un modelo que paga b bits por byte comprime el texto a b bits por byte. Lo comprueban con modelos grandes sobre texto, imágenes y audio.