Phase 05 - Lesson 16
Generación de Texto Antes de los Transformers — Modelos de Lenguaje N-grama
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Si una palabra es sorprendente, el modelo es malo. La perplejidad convierte la sorpresa en un número. El suavizado la mantiene finita.
Tipo: Build Lenguajes: Python Prerrequisitos: Fase 5 · 01 (Procesamiento de Texto), Fase 2 · 14 (Naive Bayes) Tiempo: ~45 minutos
El Problema
Antes de los transformers, antes de las RNN, antes de los word embeddings, un modelo de lenguaje predecía la siguiente palabra contando con qué frecuencia seguía a las n-1 palabras anteriores. Cuenta "the cat" → "sat" 47 veces, "the cat" → "jumped" 12 veces, "the cat" → "refrigerator" 0 veces. Normaliza para obtener una distribución de probabilidad.
Eso es un modelo de lenguaje n-grama. Hizo funcionar a todo reconocedor de voz, todo corrector ortográfico y todo sistema de traducción automática basado en frases desde 1980 hasta 2015. Aún funciona cuando necesitas modelado de lenguaje económico directamente en el dispositivo.
El problema interesante es qué hacer con los n-gramas nunca vistos. Un modelo basado en conteos en bruto asigna probabilidad cero a cualquier cosa que no haya visto, lo cual es catastrófico porque las oraciones son largas y casi toda oración larga contiene al menos una secuencia nunca vista. Cincuenta años de investigación en suavizado solucionaron eso. El suavizado de Kneser-Ney es el resultado, y el deep learning moderno heredó su tradición empírica.
El Concepto
Probabilidad n-grama: P(w_i | w_{i-n+1}, ..., w_{i-1}). Fija n (típicamente 3 para trigramas, 4 para 4-gramas). Calcula a partir de los conteos:
P(w | context) = count(context, w) / count(context)
El problema del conteo cero. Cualquier n-grama no visto en el entrenamiento obtiene probabilidad cero. Un estudio de 2007 sobre el corpus Brown encontró que incluso un modelo de 4-gramas tenía el 30% de los 4-gramas de validación (held-out) nunca vistos en el entrenamiento. No puedes evaluar ningún texto real sin suavizado.
Enfoques de suavizado, en orden de sofisticación:
- Laplace (add-one). Suma 1 a cada conteo. Simple, pésimo en eventos raros.
- Good-Turing. Reasigna masa de probabilidad de eventos de mayor frecuencia hacia los nunca vistos según la frecuencia de las frecuencias.
- Interpolación. Combina estimaciones de n-grama, (n-1)-grama, etc., con pesos ajustables.
- Backoff. Si el n-grama tiene conteo cero, retrocede al (n-1)-grama. El backoff de Katz normaliza esto.
- Descuento absoluto. Resta un descuento fijo
Dde todos los conteos, redistribuye hacia los nunca vistos. - Kneser-Ney. Descuento absoluto más una elección inteligente para el modelo de orden inferior: usar la probabilidad de continuación (en cuántos contextos aparece una palabra) en lugar de la frecuencia en bruto.
La intuición de Kneser-Ney es profunda. "San Francisco" es un bigrama común. El unigrama "Francisco" aparece casi siempre después de "San". El descuento absoluto ingenuo le da a "Francisco" una alta probabilidad unigrama (porque el conteo es alto). Kneser-Ney nota que "Francisco" aparece en un solo contexto y reduce su probabilidad de continuación en consecuencia. Resultado: un bigrama novedoso que termina en "Francisco" obtiene la baja probabilidad apropiada.
Evaluación: perplejidad. El exponente del promedio del log-verosimilitud negativo por palabra sobre un conjunto de prueba de validación (held-out). Menor es mejor. Una perplejidad de 100 significa que el modelo está tan confundido como lo estaría eligiendo uniformemente entre 100 palabras.
perplexity = exp(- (1/N) * Σ log P(w_i | context_i))
Constrúyelo
Paso 1: conteos de trigramas
from collections import Counter, defaultdict
def train_ngram(corpus_tokens, n=3):
ngrams = Counter()
contexts = Counter()
for sentence in corpus_tokens:
padded = ["<s>"] * (n - 1) + sentence + ["</s>"]
for i in range(len(padded) - n + 1):
ctx = tuple(padded[i:i + n - 1])
word = padded[i + n - 1]
ngrams[ctx + (word,)] += 1
contexts[ctx] += 1
return ngrams, contexts
def raw_probability(ngrams, contexts, context, word):
ctx = tuple(context)
if contexts.get(ctx, 0) == 0:
return 0.0
return ngrams.get(ctx + (word,), 0) / contexts[ctx]
La entrada es una lista de oraciones tokenizadas. La salida son conteos de n-gramas y conteos de contexto. <s> y </s> son fronteras de oración.
Paso 2: suavizado de Laplace
def laplace_probability(ngrams, contexts, vocab_size, context, word):
ctx = tuple(context)
numerator = ngrams.get(ctx + (word,), 0) + 1
denominator = contexts.get(ctx, 0) + vocab_size
return numerator / denominator
Suma 1 a cada conteo. Suaviza, pero asigna demasiada masa a los eventos nunca vistos, perjudicando también a los eventos raros conocidos.
Paso 3: Kneser-Ney (bigrama, interpolado)
def kneser_ney_bigram_model(corpus_tokens, discount=0.75):
unigrams = Counter()
bigrams = Counter()
unigram_contexts = defaultdict(set)
for sentence in corpus_tokens:
padded = ["<s>"] + sentence + ["</s>"]
for i, w in enumerate(padded):
unigrams[w] += 1
if i > 0:
prev = padded[i - 1]
bigrams[(prev, w)] += 1
unigram_contexts[w].add(prev)
total_unique_bigrams = sum(len(ctx_set) for ctx_set in unigram_contexts.values())
continuation_prob = {
w: len(ctx_set) / total_unique_bigrams for w, ctx_set in unigram_contexts.items()
}
context_totals = Counter()
for (prev, w), count in bigrams.items():
context_totals[prev] += count
unique_follow = defaultdict(set)
for (prev, w) in bigrams:
unique_follow[prev].add(w)
def prob(prev, w):
count = bigrams.get((prev, w), 0)
denom = context_totals.get(prev, 0)
if denom == 0:
return continuation_prob.get(w, 1e-9)
first_term = max(count - discount, 0) / denom
lambda_prev = discount * len(unique_follow[prev]) / denom
return first_term + lambda_prev * continuation_prob.get(w, 1e-9)
return prob
Tres piezas móviles. continuation_prob captura "¿en cuántos contextos diferentes aparece esta palabra?" (la innovación de Kneser-Ney). lambda_prev es la masa liberada por el descuento, usada para ponderar el backoff. La probabilidad final es el término principal descontado más el término de continuación ponderado.
Paso 4: generando texto con muestreo
import random
def generate(prob_fn, vocab, prefix, max_len=30, seed=0):
rng = random.Random(seed)
tokens = list(prefix)
for _ in range(max_len):
candidates = [(w, prob_fn(tokens[-1], w)) for w in vocab]
total = sum(p for _, p in candidates)
r = rng.random() * total
acc = 0.0
for w, p in candidates:
acc += p
if r <= acc:
tokens.append(w)
break
if tokens[-1] == "</s>":
break
return tokens
Muestreo proporcional a la probabilidad. Siempre da una salida diferente por seed. Para una salida al estilo de búsqueda en haz (beam search), elige el argmax en cada paso (greedy) y agrega una pequeña perilla de aleatoriedad (temperatura).
Paso 5: perplejidad
import math
def perplexity(prob_fn, sentences):
total_log_prob = 0.0
total_tokens = 0
for sentence in sentences:
padded = ["<s>"] + sentence + ["</s>"]
for i in range(1, len(padded)):
p = prob_fn(padded[i - 1], padded[i])
total_log_prob += math.log(max(p, 1e-12))
total_tokens += 1
return math.exp(-total_log_prob / total_tokens)
Menor es mejor. Para el corpus Brown, un modelo KN de 4-gramas bien ajustado alcanza una perplejidad cercana a 140. Un LM transformer alcanza 15-30 en el mismo conjunto de prueba. La brecha es de aproximadamente 10x. Esa brecha es la razón por la que el campo siguió adelante.
Úsalo
- Enseñanza de NLP clásico. La exposición más clara a suavizado, MLE y perplejidad que puedes obtener.
- KenLM. Biblioteca n-grama de producción. Usada como rescorer en sistemas de voz y MT donde importa la baja latencia.
- Autocompletado en el dispositivo. Modelos de trigramas en teclados. Todavía.
- Baselines. Siempre calcula la perplejidad de un LM n-grama antes de declarar que tu LM neuronal es bueno. Si tu transformer no le gana al KN por un amplio margen, algo está mal.
Entrégalo
Guarda como outputs/prompt-lm-baseline.md:
---
name: lm-baseline
description: Build a reproducible n-gram language model baseline before training a neural LM.
phase: 5
lesson: 16
---
Given a corpus and target use (next-word prediction, rescoring, perplexity baseline), output:
1. N-gram order. Trigram for general English, 4-gram if corpus is large, 5-gram for speech rescoring.
2. Smoothing. Modified Kneser-Ney is the default; Laplace only for teaching.
3. Library. `kenlm` for production, `nltk.lm` for teaching, roll your own only to learn.
4. Evaluation. Held-out perplexity with consistent tokenization between train and test sets.
Refuse to report perplexity computed with different tokenization between systems being compared — perplexity numbers are comparable only under identical tokenization. Flag OOV rate in test set; KN handles OOV poorly unless you reserve a special <UNK> token during training.
Ejercicios
- Fácil. Entrena un LM de trigramas sobre un corpus de 1.000 oraciones de Shakespeare. Genera 20 oraciones. Serán localmente plausibles, pero globalmente incoherentes. Esta es la demostración canónica.
- Medio. Implementa la perplejidad para tu modelo KN sobre una partición de validación (held-out) de Shakespeare. Compara contra Laplace. Deberías ver que el KN reduce la perplejidad en un 30-50%.
- Difícil. Construye un corrector ortográfico de trigramas: dada una palabra mal escrita y su contexto, genera correcciones y ordénalas por la probabilidad de contexto bajo el LM. Evalúa sobre el corpus de ortografía Birkbeck (público).
Términos clave
| Término | Lo que la gente dice | Lo que realmente significa |
|---|---|---|
| N-grama | Secuencia de palabras | Secuencia de n tokens consecutivos. |
| Suavizado | Evitar ceros | Reasignar masa de probabilidad para que los eventos nunca vistos obtengan probabilidad no nula. |
| Perplejidad | Métrica de calidad de LM | exp(-log-prob promedio) sobre datos de validación. Menor es mejor. |
| Backoff | Retroceder a un contexto más corto | Si el conteo del trigrama es cero, usa el bigrama. El backoff de Katz lo formaliza. |
| Kneser-Ney | Mejor suavizado para n-gramas | Descuento absoluto + probabilidad de continuación para el modelo de orden inferior. |
| Probabilidad de continuación | Específico de KN | P(w) ponderado por el número de contextos en los que aparece w, no por el conteo en bruto. |
Lectura adicional
- Jurafsky and Martin — Speech and Language Processing, Chapter 3 (2026 draft) — el tratamiento canónico de los LM n-grama y el suavizado.
- Chen and Goodman (1998). An Empirical Study of Smoothing Techniques for Language Modeling — el artículo que estableció a Kneser-Ney como el mejor suavizador de n-gramas.
- Kneser and Ney (1995). Improved Backing-off for M-gram Language Modeling — el artículo original de KN.
- KenLM — LM n-grama de producción rápido, aún usado en 2026 para aplicaciones sensibles a la latencia.