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

Modelo n-grama: contar, suavizar, generar

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:

  1. Laplace (add-one). Suma 1 a cada conteo. Simple, pésimo en eventos raros.
  2. Good-Turing. Reasigna masa de probabilidad de eventos de mayor frecuencia hacia los nunca vistos según la frecuencia de las frecuencias.
  3. Interpolación. Combina estimaciones de n-grama, (n-1)-grama, etc., con pesos ajustables.
  4. Backoff. Si el n-grama tiene conteo cero, retrocede al (n-1)-grama. El backoff de Katz normaliza esto.
  5. Descuento absoluto. Resta un descuento fijo D de todos los conteos, redistribuye hacia los nunca vistos.
  6. 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

  1. 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.
  2. 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%.
  3. 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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).