Módulo 8 · Lenguaje y audio
Modelos de lenguaje
En esta páginaEl problema
El problema
Un teclado predictivo aprende de estas seis frases:
| # | frase |
|---|---|
| 1 | el gato come pescado |
| 2 | el perro come carne |
| 3 | el gato duerme en la cama |
| 4 | el perro duerme en el patio |
| 5 | la niña come pescado |
| 6 | la niña duerme en la cama |
Después de escribir el, el teclado debe sugerir la siguiente palabra. También debe decidir cuál de dos frases que no aparecen en la tabla es más probable: «el perro come pescado» o «pescado come el perro». Por último, necesitas un número que diga qué tan bien predice el teclado frases nuevas, como «la niña come en la cama».
Modelo de bigramas
Un modelo de lenguaje asigna una probabilidad a cada posible token siguiente, dado el texto anterior. Aquí cada palabra es un token. Con la regla de la cadena, la probabilidad de una frase es un producto:
P(w₁, w₂, …, wₙ) = P(w₁) · P(w₂ | w₁) · … · P(wₙ | w₁, …, wₙ₋₁)
Un modelo de bigramas supone que cada token depende solo del token anterior. Cada probabilidad sale de contar pares consecutivos en el corpus:
P(siguiente | previo) = conteo(previo, siguiente) / conteo(previo)
Cada frase empieza con el token <s> y termina con </s>. Así el modelo también aprende qué palabra abre una frase y cuándo termina. La función tokens parte la frase en palabras con split() y agrega esas dos marcas.
corpus = [
"el gato come pescado",
"el perro come carne",
"el gato duerme en la cama",
"el perro duerme en el patio",
"la niña come pescado",
"la niña duerme en la cama",
]
def tokens(frase):
return ["<s>"] + frase.split() + ["</s>"]
print(tokens(corpus[0]))
La salida esperada está oculta.
contar_bigramas guarda un Counter por cada token previo. defaultdict(Counter) crea ese contador vacío la primera vez que aparece un token previo. prob_conteo divide el conteo del par entre el total de pares que empiezan con ese token previo.
Pregunta. ¿Qué palabras siguen a el en el corpus y cuántas veces? ¿Cuánto vale P(gato | el)?
from collections import Counter, defaultdict
def contar_bigramas(frases):
conteos = defaultdict(Counter)
for frase in frases:
t = tokens(frase)
for previo, siguiente in zip(t[:-1], t[1:]): # pares consecutivos
conteos[previo][siguiente] += 1
return conteos
def prob_conteo(conteos, previo, siguiente):
return conteos[previo][siguiente] / sum(conteos[previo].values())
conteos = contar_bigramas(corpus)
print(dict(conteos["el"]))
print(round(prob_conteo(conteos, "el", "gato"), 3))
print(dict(conteos["come"]))
La salida esperada está oculta.
prob_frase multiplica las probabilidades de todos los pares de una frase. Recibe el modelo como una función prob(previo, siguiente), así que sirve igual para cualquier modelo de esta lección. modelo_conteo es esa función para los conteos.
Pregunta. Ninguna de las tres frases de la celda está completa en el corpus. ¿Cuál recibe probabilidad 0 y en qué par falla?
def prob_frase(frase, prob):
t = tokens(frase)
p = 1.0
for previo, siguiente in zip(t[:-1], t[1:]):
p *= prob(previo, siguiente)
return p
def modelo_conteo(previo, siguiente):
return prob_conteo(conteos, previo, siguiente)
for frase in ["el perro come pescado", "la niña come carne", "pescado come el perro"]:
print(f"{frase:24} {prob_frase(frase, modelo_conteo):.4f}")
La salida esperada está oculta.
Perplejidad
Para medir un modelo sobre frases de prueba se usa la probabilidad que asigna a cada token real. La log-verosimilitud negativa media promedia −ln P(wᵢ | wᵢ₋₁) sobre los N tokens que el modelo predice. En cada frase, esos tokens son las palabras y </s>; <s> no se predice. La perplejidad es la exponencial de ese promedio:
PP = exp(−(1/N) · Σ ln P(wᵢ | wᵢ₋₁))
Una perplejidad menor indica que el modelo esperaba mejor ese texto. La función perplejidad junta ln P de todos los pares de todas las frases y aplica la fórmula.
Pregunta. ¿El modelo de conteos tiene menor perplejidad en el corpus de entrenamiento o en «el gato come carne»? ¿Qué pasa con «la niña come en la cama», que contiene el par come en?
import math
def perplejidad(frases, prob):
logs = []
for frase in frases:
t = tokens(frase)
for previo, siguiente in zip(t[:-1], t[1:]):
p = prob(previo, siguiente)
logs.append(math.log(p) if p > 0 else -math.inf) # ln 0 = −∞
return math.exp(-sum(logs) / len(logs))
prueba = ["el gato come carne", "la niña come en la cama"]
print(round(perplejidad(corpus, modelo_conteo), 3))
print(round(perplejidad(prueba[:1], modelo_conteo), 3))
print(perplejidad(prueba[1:], modelo_conteo))
La salida esperada está oculta.
El suavizado de Laplace suma k = 1 a todos los conteos antes de dividir. V es el número de tokens que el modelo puede predecir: las palabras del corpus más </s>.
P(siguiente | previo) = (conteo(previo, siguiente) + k) / (conteo(previo) + k · V)
Como referencia, la celda también evalúa un modelo uniforme, que da la misma probabilidad 1/V a cada token. prob_suavizada aplica la fórmula con k y V como argumentos, y modelo_laplace la fija en k = 1.
Pregunta. Con suavizado, ¿la perplejidad en el corpus de entrenamiento sube o baja? ¿Qué perplejidad tiene el modelo uniforme?
vocabulario = sorted({w for frase in corpus for w in frase.split()} | {"</s>"}) # sin <s>
V = len(vocabulario)
def prob_suavizada(conteos, previo, siguiente, k, V):
total = sum(conteos[previo].values())
return (conteos[previo][siguiente] + k) / (total + k * V)
def modelo_laplace(previo, siguiente):
return prob_suavizada(conteos, previo, siguiente, k=1, V=V)
def modelo_uniforme(previo, siguiente):
return 1 / V
print(V)
print(round(perplejidad(corpus, modelo_laplace), 3))
for frase in prueba:
print(f"{frase:24} {perplejidad([frase], modelo_laplace):.3f}")
print(round(perplejidad(prueba, modelo_uniforme), 3))
La salida esperada está oculta.
Modelo de caracteres
Un modelo neuronal reemplaza la tabla de conteos por una red. La red recibe los tokens anteriores y produce un logit por cada token del vocabulario. softmax convierte esos logits en probabilidades del siguiente token.
Aquí los tokens son caracteres. El corpus combina 4 sujetos, 3 verbos y 4 objetos en 48 frases. Cada frase termina en punto. Ocho frases al azar quedan para validación. La celda plegada genera las frases y las une en dos textos, texto_ent y texto_val.
Mostrar códigoOcultar código
import numpy as np
sujetos = ["el gato", "el perro", "la niña", "el niño"]
verbos = ["mira", "busca", "quiere"]
objetos = ["el pan", "la pelota", "la luna", "el agua"]
frases = [f"{s} {v} {o}." for s in sujetos for v in verbos for o in objetos]
rng = np.random.default_rng(0)
orden = rng.permutation(len(frases))
texto_val = "".join(frases[i] for i in orden[:8])
texto_ent = "".join(frases[i] for i in orden[8:])
print(len(frases), frases[0])
print(repr(texto_ent[:60]))
La salida esperada está oculta.
Cada texto es una sola cadena: las frases van pegadas, sin espacio después del punto. texto_ent reúne 40 frases y texto_val las otras 8.
Cada ejemplo de entrenamiento es una ventana de CONTEXTO = 4 caracteres y el carácter que la sigue. El vocabulario caracteres son los caracteres distintos de texto_ent, e indice asigna a cada uno un entero. ejemplos convierte el texto en enteros y torch.stack apila todas las ventanas en una matriz.
Pregunta. Si el texto de entrenamiento tiene L caracteres, ¿cuántos ejemplos salen y qué forma tienen X_ent e y_ent?
import torch
caracteres = sorted(set(texto_ent))
indice = {c: i for i, c in enumerate(caracteres)}
V_car = len(caracteres)
CONTEXTO = 4
def ejemplos(texto):
ids = torch.tensor([indice[c] for c in texto])
X = torch.stack([ids[i : i + CONTEXTO] for i in range(len(ids) - CONTEXTO)])
y = ids[CONTEXTO:] # el carácter que sigue a cada ventana
return X, y
X_ent, y_ent = ejemplos(texto_ent)
X_val, y_val = ejemplos(texto_val)
print(V_car, repr("".join(caracteres)))
print(len(texto_ent), X_ent.shape, y_ent.shape)
print(repr(texto_ent[:4]), "->", repr(texto_ent[4]))
La salida esperada está oculta.
El modelo convierte cada carácter en un embedding de 8 números con nn.Embedding, concatena los 4 embeddings y aplica una capa oculta con tanh y una capa de salida con 19 logits.
from torch import nn
class ModeloCaracteres(nn.Module):
def __init__(self, V, contexto, dim=8, oculto=64):
super().__init__()
self.embedding = nn.Embedding(V, dim)
self.oculta = nn.Linear(contexto * dim, oculto)
self.salida = nn.Linear(oculto, V)
def forward(self, x):
e = self.embedding(x).flatten(1) # (n, 4, 8) -> (n, 32)
return self.salida(torch.tanh(self.oculta(e)))
F.cross_entropy recibe los logits y los índices correctos y calcula la media de −ln P(carácter correcto), la misma cantidad que la perplejidad eleva con exp. torch.manual_seed(0) fija los pesos iniciales, y dentro de torch.no_grad() la red se evalúa sin registrar gradientes.
Pregunta. Antes de entrenar, ¿qué pérdida esperas si la red reparte la probabilidad casi por igual entre 19 caracteres? Después de entrenar, ordena de menor a mayor la perplejidad de validación del modelo neuronal, del bigrama de caracteres y del modelo uniforme.
import torch.nn.functional as F
torch.manual_seed(0)
modelo = ModeloCaracteres(V_car, CONTEXTO)
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
with torch.no_grad():
inicial = F.cross_entropy(modelo(X_ent), y_ent).item()
print(f"pérdida inicial {inicial:.3f} ln(19) = {math.log(V_car):.3f}")
La salida esperada está oculta.
Cada paso de entrenamiento calcula la pérdida con todos los ejemplos, borra los gradientes anteriores con zero_grad, calcula los nuevos con backward y actualiza los pesos con step. En los pasos 100 y 500, la celda mide la pérdida de validación y su perplejidad.
for paso in range(1, 501):
perdida = F.cross_entropy(modelo(X_ent), y_ent)
optimizador.zero_grad()
perdida.backward()
optimizador.step()
if paso in (100, 500):
with torch.no_grad():
perdida_val = F.cross_entropy(modelo(X_val), y_val).item()
print(f"paso {paso}: val {perdida_val:.3f} perplejidad {math.exp(perdida_val):.3f}")
La salida esperada está oculta.
Como comparación sirve un bigrama de caracteres con Laplace (k = 1): cuenta en texto_ent cuántas veces cada carácter sigue a otro y predice cada objetivo de validación solo con el último carácter de su ventana.
Ver resultado
La frase da la perplejidad del bigrama de caracteres sobre los mismos objetivos de validación que mide la celda de entrenamiento, así que se compara directamente con la perplejidad de la red en el paso 500. Se lee como un número de opciones: por ejemplo, una perplejidad de 2 equivaldría a dudar en promedio entre dos caracteres equiprobables.
El bigrama de caracteres tiene perplejidad de validación 4.271.
ln 19 = 2.944: una red sin entrenar da probabilidades cercanas a 1/19, es decir, perplejidad cercana a 19. Tras 500 pasos, la perplejidad de validación es 1.234, contra 4.271 del bigrama de caracteres y 19 del modelo uniforme. Las frases de validación no aparecen en el entrenamiento, pero sus sujetos, verbos y objetos sí. Cuatro caracteres de contexto bastan para casi determinar el siguiente carácter dentro de una palabra; uno solo, no.Temperatura y top-k
Para generar texto, el modelo produce una distribución del siguiente token, se toma una muestra, se agrega al texto y se repite. Dos parámetros cambian la distribución antes de muestrear:
- La temperatura
Tdivide los logits antes desoftmax:softmax(z / T). - top-k conserva los
klogits más altos, pone-infen los demás y aplicasoftmaxa los logits restantes.
La celda plegada define mostrar, que devuelve los n caracteres más probables con su probabilidad.
Mostrar códigoOcultar código
def mostrar(p, n=4):
valores, posiciones = torch.topk(p, n)
opciones = zip(valores.tolist(), posiciones.tolist())
return " ".join(f"{caracteres[i]!r}:{v:.3f}" for v, i in opciones)
siguiente toma los últimos 4 caracteres del texto y devuelve la distribución del siguiente. torch.topk(logits, k) devuelve los k logits más altos en orden, y masked_fill pone -inf en los logits menores que el último de ellos.
Pregunta. Después de '.el ', el modelo elige entre el comienzo de niño, perro y gato. Respecto a T = 1, ¿la probabilidad de n sube o baja con T = 0.5? ¿Y con T = 2? ¿Qué queda con k = 2?
def siguiente(texto, temperatura=1.0, top_k=None):
x = torch.tensor([[indice[c] for c in texto[-CONTEXTO:]]]) # lote de un ejemplo
with torch.no_grad():
logits = modelo(x)[0] / temperatura
if top_k is not None:
umbral = torch.topk(logits, top_k).values[-1]
logits = logits.masked_fill(logits < umbral, float("-inf"))
return torch.softmax(logits, dim=-1)
for temperatura in [0.5, 1.0, 2.0]:
print(f"T={temperatura} {mostrar(siguiente('.el ', temperatura))}")
print(f"k=2 {mostrar(siguiente('.el ', 1.0, top_k=2))}")
La salida esperada está oculta.
La celda siguiente genera 60 caracteres a partir de '.el ' con la misma semilla en cada configuración. torch.multinomial toma la muestra, y torch.Generator().manual_seed(semilla) le da a cada configuración la misma secuencia de números aleatorios.
Pregunta. ¿Qué configuración produce más palabras que no existen en el corpus? ¿Qué cambia al agregar top_k=3 a T = 3?
def generar(inicio, n, temperatura=1.0, top_k=None, semilla=0):
generador = torch.Generator().manual_seed(semilla)
texto = inicio
for _ in range(n):
p = siguiente(texto, temperatura, top_k)
texto += caracteres[torch.multinomial(p, 1, generator=generador).item()]
return texto
for temperatura, top_k in [(0.5, None), (1.0, None), (3.0, None), (3.0, 3)]:
print(f"T={temperatura} k={top_k}: {generar('.el ', 60, temperatura, top_k)[4:]}") # [4:] quita '.el '
La salida esperada está oculta.
Muestreo con temperatura y top-k
Mueve la temperatura y el top-k y observa cómo cambian las probabilidades y la entropía.
Qué probar
Muestrea varias veces con T baja, reinicia la semilla y repite con T alta para comparar las dos filas del historial.
Contexto En el desayuno, Sofía toma un vaso de
Con la semilla reiniciada, los números aleatorios se repiten desde la muestra 1.
Sin muestras todavía.
Arquitecturas transformer
Los modelos de lenguaje grandes reemplazan la ventana fija por bloques transformer, que atienden a todos los tokens de la entrada, hasta un largo máximo propio de cada modelo. Se agrupan en tres familias según qué posiciones ve cada token y qué aprenden a predecir:
- Encoder, como BERT. Cada token atiende a los tokens de ambos lados. Se preentrena como modelo de lenguaje enmascarado: se reemplazan algunos tokens por
[MASK]y el modelo los predice. Produce una representación de cada token que usa todo el texto; se usa para clasificar o etiquetar texto, no para generarlo token por token. - Decoder, como GPT-2. Usa la máscara causal de Atención y transformers: cada token atiende solo a sí mismo y a los anteriores. Se entrena para predecir el siguiente token, igual que el bigrama y el modelo de caracteres de esta lección. Genera texto con el ciclo de muestreo de la sección anterior.
- Encoder-decoder, como T5. Un encoder lee todo el texto de entrada. Un decoder causal genera la salida y, además, atiende a todas las posiciones del encoder con atención cruzada. T5 se preentrena reemplazando tramos del texto por tokens centinela, como
<extra_id_0>, y generando los tramos que faltan.
Para GPT-2, la máscara causal sale de torch.tril, que pone en 0 lo que queda sobre la diagonal de una matriz de unos. Para una frase de 4 palabras:
palabras = ["el", "gato", "come", "pescado"]
causal = torch.tril(torch.ones(4, 4, dtype=torch.int64))
visibles_fila_1 = [palabras[j] for j in range(4) if causal[1, j]] # posiciones con 1 en la fila 1
Pregunta. Para la frase «el gato come pescado», escribe qué entrada recibe y qué debe producir cada familia. Cuando BERT predice el token [MASK], ¿qué palabras puede ver? ¿Y GPT-2 cuando predice come? ¿Qué fila de la máscara causal produce la predicción de pescado?
Ver resultado
La primera tabla da, para BERT y T5, la entrada que recibe el modelo y lo que debe producir. Por ejemplo, la fila de BERT dice que el modelo recibe la frase con [MASK] en lugar de la tercera palabra y debe producir come. La segunda tabla recorre las filas de la máscara causal de GPT-2: «máscara» es esa fila de causal, «ve» son las palabras con 1 en esa fila y «objetivo» es el token que predice la salida de esa fila. Por ejemplo, la fila 1, con máscara [1, 1, 0, 0], ve el gato y predice come.
| familia | entrada | objetivo |
|---|---|---|
| BERT | el gato [MASK] pescado | come |
| T5 | el gato <extra_id_0> pescado | <extra_id_0> come <extra_id_1> |
| fila de GPT-2 | máscara | ve | objetivo |
|---|---|---|---|
| 0 | [1, 0, 0, 0] | el | gato |
| 1 | [1, 1, 0, 0] | el gato | come |
| 2 | [1, 1, 1, 0] | el gato come | pescado |
| 3 | [1, 1, 1, 1] | el gato come pescado | ninguno: la frase termina |
come con el, gato y también pescado, que está a la derecha. En GPT-2, la fila i de la máscara causal marca con 1 las posiciones 0 a i, y la salida de esa fila predice el token i + 1. Por eso come se predice en la fila 1, solo con el gato, y pescado en la fila 2. Una frase de 4 palabras da 3 objetivos en una sola pasada. T5 recibe el texto con un hueco y genera el contenido del hueco entre centinelas. Solo el decoder y el encoder-decoder generan texto con temperatura y top-k.Práctica
Traza guiada
Con este corpus, escribe antes de procesar: los conteos de lo que sigue a no, P(sí | no), la probabilidad de «no sí» y su perplejidad con el modelo de conteos.
corpus_traza = ["sí no sí", "no no sí"]
Comprueba con contar_bigramas, prob_conteo, prob_frase y perplejidad. La frase «no sí» tiene tres tokens predichos: no, sí y </s>. Si tu perplejidad no coincide, revisa si contaste <s> como token predicho.
Suavizado con k
Repite la celda de suavizado con k = 0.1 y k = 0.01. Antes de procesar, predice si la perplejidad de «el gato come carne» sube o baja y qué pasa con la de «la niña come en la cama» cuando k se acerca a 0. Comprueba con assert y math.isclose que, para previo = "come", las 13 probabilidades suman 1 con cada k.
Modelo de trigramas
Escribe un modelo de trigramas: cada palabra depende de las dos anteriores y cada frase empieza con dos tokens <s>.
- Cuenta tríos consecutivos con un
defaultdict(Counter)cuya clave sea la pareja previa. - Aplica suavizado con
k = 0.1y el mismoV = 13. - Calcula su perplejidad en
corpusy enprueba. - Compara con el bigrama con
k = 0.1y explica cuál gana en entrenamiento y cuál en prueba.
Reto opcional
Entrena ModeloCaracteres con CONTEXTO = 8. Compara la perplejidad de validación con la de CONTEXTO = 4 y genera 60 caracteres con T = 1. Explica si todavía aparecen frases como «el gato mira el perro busca» y relaciona el resultado con la ventana de contexto.
Errores que revisar
- Si la perplejidad sale
inf, algún par de prueba tiene conteo 0. Localízalo imprimiendo la probabilidad de cada par y aplica suavizado. - Si la perplejidad sale menor que 1, revisa el signo: debe promediarse
−ln P, noln P. - Si la pérdida de un modelo de siguiente token baja casi a 0 en pocos pasos, revisa el desplazamiento: el objetivo de cada ventana debe ser el token que sigue, no uno que ya está en la entrada.
- Si comparas la perplejidad de un modelo de palabras con la de uno de caracteres, los números no son comparables: cada uno promedia sobre tokens distintos.
- Si
torch.multinomiallanza un error porque la distribución contienenanoinf, revisa la temperatura: conT = 0la división produce infinitos. Para elegir siempre el más probable usaargmaxen lugar deT = 0.
Cierre
Explica por qué el modelo de conteos da perplejidad infinita a «la niña come en la cama» aunque todas sus palabras están en el corpus, y qué cuesta el suavizado de Laplace en el corpus de entrenamiento.
Lista de comprobación
- ¿Cómo se obtiene
P(siguiente | previo)a partir de conteos y por qué suman 1 las probabilidades que siguen a un mismo token? - ¿Qué perplejidad tiene un modelo uniforme sobre
Vtokens y por qué? - ¿Por qué
expde la entropía cruzada media es la perplejidad? - ¿Qué efecto tienen
T < 1,T > 1y top-k sobre la distribución del siguiente token? - ¿Qué posiciones ve un token en un encoder, en un decoder y en un encoder-decoder?
Documentación oficial
- Perplejidad en Hugging Face Transformers: definición y cálculo de la perplejidad de un modelo de lenguaje causal.
- torch.nn.functional.cross_entropy: pérdida sobre logits y promedio de
−ln Pdel objetivo. - BERT en Hugging Face Transformers: encoder bidireccional y preentrenamiento con tokens enmascarados.
- GPT-2 en Hugging Face Transformers: decoder causal entrenado para predecir el siguiente token.
- T5 en Hugging Face Transformers: encoder-decoder, formato texto a texto y tokens centinela.