Módulo 8 · Lenguaje y audio

Lección 2 de 5

Modelos de lenguaje

En esta páginaEl problema

El problema

Un teclado predictivo aprende de estas seis frases:

#frase
1el gato come pescado
2el perro come carne
3el gato duerme en la cama
4el perro duerme en el patio
5la niña come pescado
6la 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.

Python 3 · ejemplo reproducible
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]))
Salida esperada

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)?

Python 3 · ejemplo reproducible
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"]))
Salida esperada

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?

Python 3 · ejemplo reproducible
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}")
Salida esperada

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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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ódigoPython
Python 3 · ejemplo reproducible
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:])
Python 3 · ejemplo reproducible
print(len(frases), frases[0])
print(repr(texto_ent[:60]))
Salida esperada

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?

Python 3 · ejemplo reproducible
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]))
Salida esperada

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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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}")
Salida esperada

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.

Python 3 · ejemplo reproducible
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}")
Salida esperada

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.

Lectura. La pérdida inicial, 2.957, casi coincide con 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 T divide los logits antes de softmax: softmax(z / T).
  • top-k conserva los k logits más altos, pone -inf en los demás y aplica softmax a los logits restantes.

La celda plegada define mostrar, que devuelve los n caracteres más probables con su probabilidad.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
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?

Python 3 · ejemplo reproducible
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))}")
Salida esperada

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?

Python 3 · ejemplo reproducible
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 '
Salida esperada

La salida esperada está oculta.

Explorador

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

leche0.43agua0.24jugo0.16chocolate0.08atole0.04café0.03horchata0.02limonada0.01arena< 0.01tinta< 0.01probabilidad acumuladalecheaguajugo01
Parámetros
Semilla 7 · próxima muestra 1

Con la semilla reiniciada, los números aleatorios se repiten desde la muestra 1.

Historial de muestras

Sin muestras todavía.

Temperatura 1.00, top-k 10: los 10 tokens reciben probabilidad y «leche» lleva 0.43. Entropía 1.57 nats, unas 4.8 opciones equiprobables.

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:

Python 3 · ejemplo reproducible
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.

familiaentradaobjetivo
BERTel gato [MASK] pescadocome
T5el gato <extra_id_0> pescado<extra_id_0> come <extra_id_1>
fila de GPT-2máscaraveobjetivo
0[1, 0, 0, 0]elgato
1[1, 1, 0, 0]el gatocome
2[1, 1, 1, 0]el gato comepescado
3[1, 1, 1, 1]el gato come pescadoninguno: la frase termina
Lectura. BERT predice 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.

Python 3 · ejemplo reproducible
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>.

  1. Cuenta tríos consecutivos con un defaultdict(Counter) cuya clave sea la pareja previa.
  2. Aplica suavizado con k = 0.1 y el mismo V = 13.
  3. Calcula su perplejidad en corpus y en prueba.
  4. Compara con el bigrama con k = 0.1 y 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, no ln 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.multinomial lanza un error porque la distribución contiene nan o inf, revisa la temperatura: con T = 0 la división produce infinitos. Para elegir siempre el más probable usa argmax en lugar de T = 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 V tokens y por qué?
  • ¿Por qué exp de la entropía cruzada media es la perplejidad?
  • ¿Qué efecto tienen T < 1, T > 1 y 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

Cargando progreso…