En la entrada anterior vimos un ejemplo práctico del algoritmo BPE. Entender esto es importante porque es la forma estándar en que las máquinas descomponen un texto en una representación que pueden manipular. Puesto que ahora sabemos hacerlo, el siguiente paso lógico es emplear esos fragmentos (o tokens) de alguna manera, para así generar texto elocuente.
En este sentido, la estrategia más popular —enfatizo: popular, no necesariamente mejor— para generar texto “nuevo” a partir de un contexto consiste en calcular la probabilidad de la siguiente palabra o token, condicional al contexto; es decir, P(siguiente palabra o token | contexto). Si nos restringimos a esta estrategia, una manera práctica de obtener esa probabilidad es usando n-gramas. No obstante, también se pueden usar redes neuronales —que es lo que usa ChatGPT—, entre otras alternativas.
En este punto, el lector podría estar preguntándose: “¿Por qué aprender n-gramas si no es lo que utiliza ChatGPT?”. La respuesta es simple: es un marco conceptual muy ilustrativo, que nos permitirá aprender conceptos clave y, así, avanzar hacia el uso de redes neuronales. Como es usual, emplearemos como referencia el capítulo 3 de Jurafsky y Martin, 2025. Dicho esto, empecemos!
Language models
Un modelo de lenguaje (Language Model) es un modelo probabilístico que, dado un contexto, predice la siguiente palabra o token. Esta predicción se realiza construyendo una distribución de probabilidad sobre las posibles siguientes palabras/tokens. En términos concretos, supongamos que tenemos el siguiente corpus:
Aquí llegó, donde otro no ha llegado,
don Alonso de Ercilla, que el primero
en un pequeño barco deslastrado,
con solos diez pasó el desaguadero
el año de cincuenta y ocho entrado
sobre mil y quinientos, por hebrero,
a las dos de la tarde, el postrer día,
volviendo a la dejada compañía.La Araucana, Canto XXXVI. Alonso de Ercilla.
y queremos predecir la palabra que viene después de la secuencia {a las dos de la}. Para eso, podemos calcular la distribución de probabilidad sobre las siguientes palabras condicional a venir de la secuencia {a las dos de la}. Si, por ejemplo, estamos interesados en saber la probabilidad de que la palabra siguiente sea {tarde}, deberíamos calcular P({tarde} | {a las dos de la}).
En este punto, un lector ávido podría preguntarse: ¿por qué elegimos “a las dos de la” y no una secuencia más larga? El problema es que, en un corpus finito, cuanto más largo es el contexto, más raro se vuelve observar exactamente ese mismo contexto otra vez. En el extremo, si el contexto es demasiado largo, la estimación se vuelve muy frágil: muchos contextos no aparecen nunca, y los que aparecen pocas veces producen distribuciones casi deterministas (una única continuación con probabilidad cercana a 1), lo que puede socavar la variedad del texto generado.
Por el contrario, si elegimos un contexto muy corto, perdemos coherencia: por ejemplo, incluso con un corpus grande, después de “y” puede venir casi cualquier palabra. En fin, en términos empíricos, el largo del contexto afecta de forma decisiva al resultado. Al escogerlo, el programador está imponiendo una estructura sobre cómo “depende” el siguiente token del pasado: en los modelos n-gram, esta idea se formaliza en la Markov assumption, según la cual la siguiente palabra depende solo de las últimas palabras del contexto (si quieres saber más sobre cadenas de Markov, puedes ver este video).
Específicamente, cuando fijamos cuántos tokens usaremos como contexto, estamos eligiendo implícitamente en un modelo de n-gramas (ya que el contexto tiene tamaño ). Por ejemplo, un modelo de bigramas asume que la probabilidad de la siguiente palabra depende únicamente de la palabra anterior (si tomamos cada token como una palabra).
,
por otra parte, un trigrama asume que la probabilidad de la siguiente palabra depende de las dos palabras que preceden a aquella,
Dicho esto, ahora estamos en condiciones de estimar estas probabilidades.
Estimando las probabilidades…
El método estándar para estimar estas probabilidades es usar Máxima Verosimilitud. Por ejemplo, para el caso de un bigrama, para calcular la probabilidad de una palabra , dada una palabra , llevamos a cabo el siguiente cálculo,
El siguiente código en Python calcula dicha frecuencia relativa empleando el Quijote de la Mancha de Miguel de Cervantes:
import re
from collections import Counter, defaultdict
from typing import Dict, List, Tuple
from pathlib import Path
WORD_RE = re.compile(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", re.UNICODE)
def tokenize_words(text: str, lowercase: bool = True) -> List[str]:
if lowercase:
text = text.lower()
return WORD_RE.findall(text)
def bigrams(tokens: List[str], add_start_end: bool = True) -> List[Tuple[str, str]]:
if add_start_end:
tokens = ["<s>"] + tokens + ["</s>"]
return list(zip(tokens[:-1], tokens[1:]))
def bigram_mle_words(text: str, lowercase: bool = True, add_start_end: bool = True):
toks = tokenize_words(text, lowercase=lowercase)
bi = bigrams(toks, add_start_end=add_start_end)
context_counts = Counter([w1 for (w1, w2) in bi]) # C(w_{i-1})
bigram_counts = Counter(bi) # C(w_{i-1}, w_i)
probs: Dict[str, Dict[str, float]] = defaultdict(dict)
for (w1, w2), c12 in bigram_counts.items():
probs[w1][w2] = c12 / context_counts[w1] # MLE
return {"tokens": toks, "context_counts": context_counts, "bigram_counts": bigram_counts, "probs": probs}
def top_next(probs: Dict[str, Dict[str, float]], prev: str, k: int = 10):
if prev not in probs:
return []
return sorted(probs[prev].items(), key=lambda x: x[1], reverse=True)[:k]
if __name__ == "__main__":
path = Path("H:\\quijote.txt")
text = path.read_text(encoding="utf-8")
model = bigram_mle_words(text)
print("Tokens:", model["tokens"])
print("Top después de 'tropezando':", top_next(model["probs"], "tropezando", 5))
Al ejecutar el código, podemos ver que la palabra «tropezando», aparece en cuatro oportunidades:
dificultoso, tropezando aquí, cayendo allí, levantándose acullá,
y
resucitar la ya muerta andante caballería, y ha muchos días que, tropezando aquí,
y
y don Quijote, a gran priesa, tropezando aquí y cayendo
y, finalmente en
Quijote, van ya tropezando, y han de caer del todo, sin duda alguna. Vale.
Entonces, , y Luego, la probabilidad de que «aquí» sea la próxima palabra después de «tropezando» es e «y», , tal cual como la ejecución del código predice:

También, con fines didácticos podemos empezar con una palabra, y elegir las que exhiben la probabilidad más alta de ser las siguientes. A modo de ejemplo, si partimos con la palabra «Fazaña» (gracias Aradia), la secuencia que generamos es:
Fazaña fue el cual se le dijo don quijote
Donde las probabilidades son:
| Palabra | Probabilidad |
| Fazaña | – |
| fue | 1 |
| el | 0.0998 |
| cual | 0.0343 |
| se | 0.0639 |
| le | 0.0677 |
| dijo | 0.0662 |
| don | 0.1648 |
| quijote | 0.8182 |
También, podemos calcular la probabilidad de esta frase como
cuyo valor viene dado por:
| 0.00000013218703 |
Como podemos ver, dicho valor es pequeño, al igual que el de las probabilidades condicionales. Cuando n, o nuestro modelo crece, las probabilidades pueden ser aún más pequeñas, provocando un problema conocido como numerical underflow. Para evitarlo, usualmente se guardan las probabilidades en log, y luego, si queremos recuperar el número original, sólo debemos tomar el exponente. Este truco será utilizado más adelante.
Evaluando modelos de lenguaje
La mejor manera de evaluar modelos es mediante la evaluación extrínseca. En términos concretos, si por ejemplo, queremos evaluar que tan bueno es un autocomplete que estamos desarrollando, una evaluación extrínseca sería determinar el porcentaje de sugerencias que fueron aceptadas por el escritor. Debido a que esta evaluación es costosa, usualmente se lleva a cabo otro método llamado evaluación intrínseca. Este tipo de evaluación, mide un modelo por una métrica interna que refleja qué tan bien cumple su objetivo directo, es decir, mide la calidad del modelo independiente de su aplicación. Dentro de este tipo de evaluaciones, una popular es llamada perplexity. En la siguiente sección la veremos en detalle.
Evaluación intrínseca: Perplexity.
Volvamos al ejemplo del autocomplete. Supongamos que entrenamos un modelo de bigramas con el texto del Quijote de la Mancha. Posteriormente, como es usual, evaluamos su eficacia en un texto de prueba. Si nuestro modelo predice mejor las siguientes palabras (o tokens) en ese conjunto, entonces estará “menos sorprendido” cuando aparezca la palabra correcta, en comparación con otro modelo. En el caso ideal, un autocomplete muy bueno asignaría una probabilidad muy alta a la palabra correcta y muy baja a las alternativas incorrectas.
Dicho esto, es intuitivo pensar que la probabilidad que el modelo asigna al texto de prueba debe ser un ingrediente relevante para una métrica. Sin embargo, no se suele usar la probabilidad total del texto, porque depende fuertemente de su longitud: al ser un producto de muchas probabilidades condicionales, disminuye a medida que el texto se hace más largo. Por eso, una métrica adecuada debe normalizar por la cantidad de palabras (o tokens), y medir el desempeño promedio por token; de ahí surge la perplexity . En particular, su fórmula viene dada por:
Al observar la fórmula, podemos ver que depende inversamente de la probabilidad conjunta. Por este motivo, una menor «perplejidad», implica un mejor modelo. Teniendo esto en mente, a continuación, presentamos el código que calcula la perplejidad para el modelo de bigramas entrenado en El Quijote de la Mancha, y que usa Fuente Ovejuna de Lope de Vega como texto de prueba (lo puedes encontrar aquí):
import re
import math
from collections import Counter
from typing import List, Tuple
from pathlib import Path
WORD_RE = re.compile(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", re.UNICODE)
def tokenize_words(text: str, lowercase: bool = True) -> List[str]:
if lowercase:
text = text.lower()
return WORD_RE.findall(text)
def bigrams(tokens: List[str], add_start_end: bool = True) -> List[Tuple[str, str]]:
if add_start_end:
tokens = ["<s>"] + tokens + ["</s>"]
return list(zip(tokens[:-1], tokens[1:]))
def train_bigram_counts(train_text: str, lowercase: bool = True):
toks = tokenize_words(train_text, lowercase=lowercase)
bi = bigrams(toks, add_start_end=True)
context_counts = Counter([w1 for (w1, _) in bi]) # C(w_{i-1})
bigram_counts = Counter(bi) # C(w_{i-1}, w_i)
# vocab del train (incluye tokens especiales)
vocab = set(toks)
vocab.update(["<s>", "</s>", "<unk>"])
return context_counts, bigram_counts, vocab
def perplexity_bigram_addk(
test_text: str,
context_counts: Counter,
bigram_counts: Counter,
vocab: set,
k: float = 1.0, # k=1.0 => Laplace; k=0.1 => add-0.1; k=0 => MLE puro (puede dar inf)
lowercase: bool = True
) -> float:
# Tokeniza test y mapea OOV a <unk>
toks = tokenize_words(test_text, lowercase=lowercase)
toks = [t if t in vocab else "<unk>" for t in toks]
bi = bigrams(toks, add_start_end=True)
V = len(vocab)
# log-prob promedio
log_sum = 0.0
N = 0
for w1, w2 in bi:
c12 = bigram_counts.get((w1, w2), 0)
c1 = context_counts.get(w1, 0)
if k == 0.0:
# MLE puro: si c12=0 => prob=0 => perplexity infinita
if c12 == 0 or c1 == 0:
return float("inf")
p = c12 / c1
else:
# add-k smoothing:
# P(w2|w1) = (C(w1,w2)+k) / (C(w1)+k*V)
p = (c12 + k) / (c1 + k * V)
log_sum += math.log(p)
N += 1
# perplexity = exp(- (1/N) * sum log p)
return math.exp(-log_sum / N)
if __name__ == "__main__":
quijote_path = Path(r"H:\quijote.txt")
fuente_path = quijote_path.with_name("fuenteovejuna.txt")
train_text = quijote_path.read_text(encoding="utf-8")
test_text = fuente_path.read_text(encoding="utf-8")
context_counts, bigram_counts, vocab = train_bigram_counts(train_text)
ppl = perplexity_bigram_addk(
test_text,
context_counts,
bigram_counts,
vocab,
k=1.0 # prueba también k=0.1 o k=0.01
)
print(f"Vocab size (train): {len(vocab)}")
print(f"Perplexity (Fuenteovejuna | trained on Quijote) with add-k={1.0}: {ppl:.4f}")
En términos generales, este código empieza ignorando puntuación y números, pero permitiendo apóstrofes y guiones. Luego, la función tokenize_words() pasa todo a minúsculas y devuelve una lista de tokens (que en este caso son palabras completas). bigrams() construye las secuencias de dos palabras, para luego crear pares consecutivos, es decir, si en la variable tokens entra una lista similar a esta:
tokens = ["don", "quijote", "vive"]
el código devuelve pares consecutivos con el siguiente aspecto:
[("don", "quijote"), ("quijote", "vive")]
train_bigram_counts() lleva a cabo el conteo de esos pares para que sea posible calcular
Luego, al vocabulario visto en el texto de entrenamiento se le añade <unk> para posteriormente representar palabras desconocidas en el texto de prueba.
Finalmente, para calcular la perplejidad, primero, detectamos las palabras desconocidas, es decir, palabras que están en el texto de prueba y que no se encuentran en el vocabulario generado por el texto de entrenamiento:
toks = tokenize_words(test_text)
toks = [t if t in vocab else "<unk>" for t in toks]
para posteriormente, hacer el conteo:
c12 = bigram_counts.get((w1, w2), 0)
c1 = context_counts.get(w1, 0)
que específicamente, calcula y del texto de entrenamiento, para y del texto de prueba.
En este punto, a pesar de tener el conteo del numerador y denominador, aún no podemos realizar la división. Pese a que la fracción está blindada de la división por cero (puesto que al incluir <unk>, nunca será cero), la fracción
puede ser efectivamente cero, si es cero, es decir, si no se encontró un bigrama similar en el texto de prueba. Esto es un problema por dos motivos. El primero, es que la perplejidad no se puede computar, puesto que es el inverso de la probabilidad, y debido a que es cero, estaríamos ante una división por cero. El segundo, es que, que no aparezca el bigrama en el texto de entrenamiento, implica directamente que tiene probabilidad 0, lo que lo convierte en un evento imposible, pero realmente no es imposible, muy probablemente es sólo una manifestación de un corpus de entrenamiento limitado, lo que en la practica, puede socavar muchas aplicaciones. Principalmente por estos motivos, es necesario llevar a cabo un smoothing. Existen múltiples tipos, pero nosotros sólo veremos el add-k, cuyo principio es añadir k unidades al numerador, y kV unidades al denominador, para evitar la división por cero:
Por simplicidad, asumiremos k=1 (conocido como Laplace smoothing), y posteriormente calcularemos la perplejidad en logs:
log_sum += math.log(p)
N += 1
...
...
return math.exp(-log_sum / N)
cuyo número viene dado por
Perplexity (Fuenteovejuna | trained on Quijote) with add-k=1.0: 7196.6119
Notas finales, y próxima entrada…
En esta entrada vimos una estrategia llamada n-grams para predecir la siguiente palabra (o token) en un texto, condicionada a un contexto. Ese contexto puede ser la palabra inmediatamente anterior o, en general, las últimas palabras. Dicha predicción se lleva a cabo con probabilidades condicionales que pueden ser estimadas mediante Máxima Verosimilitud (MLE) a partir de un corpus de entrenamiento. Con fines didácticos, fijamos , entrenamos un modelo de bigramas usando El Quijote de la Mancha y, finalmente, calculamos la perplejidad, un estadístico que permite realizar una evaluación intrínseca del modelo.
Esta estrategia es una base importante para el aprendizaje, porque captura uno de los principios centrales detrás del funcionamiento de los LLMs: modelar el lenguaje como una distribución de probabilidad sobre el siguiente token.
En la próxima entrega veremos cómo clasificar texto, un tema clave en procesamiento del lenguaje natural. Stay tuned!
José Miguel Muñoz Urra – jmunozu@pulki.es