Las cadenas de Markov son un concepto bastante viejo (de principios del siglo XX) que se ha usado como una forma simple de generar texto o predecir secuencias, y son la base conceptual de algunas cosas que después evolucionaron hacia lo que hoy llamamos IA generativa.

La idea central

Una cadena de Markov es un modelo matemático que dice: “el siguiente estado depende solo del estado actual, no de todo el historial pasado”. Esto se llama la “propiedad de Markov” o “falta de memoria”.

Aplicado a texto, funciona así:

  1. Analizás un montón de texto (por ejemplo, todos los libros de un autor)
  2. Contás qué palabra (o letra) tiende a seguir a cada palabra
  3. Construís una tabla de probabilidades: “después de ’el’, hay 40% de probabilidad de que venga ‘perro’, 20% ‘gato’, etc.”
  4. Para generar texto nuevo, empezás con una palabra y vas eligiendo la siguiente palabra al azar, pero respetando esas probabilidades

Cadenas de Markov para generar texto

Tu resumen está muy bien. Vamos a la implementación práctica en ambos lenguajes. La idea técnica clave es usar un modelo de orden N (no solo la palabra actual, sino las últimas N palabras) porque con orden 1 el texto generado suele ser bastante caótico. Con orden 2 o 3 ya se ve mucho más coherente.

Python (la forma más simple y flexible)

import random
import re
from collections import defaultdict

def construir_modelo(texto, orden=2):
    palabras = texto.split()
    modelo = defaultdict(list)

    for i in range(len(palabras) - orden):
        clave = tuple(palabras[i:i+orden])       # ej: ("el", "perro")
        siguiente = palabras[i+orden]
        modelo[clave].append(siguiente)

    return modelo

def generar_texto(modelo, orden=2, num_palabras=50):
    # arrancamos con una clave al azar del modelo
    clave = random.choice(list(modelo.keys()))
    resultado = list(clave)

    for _ in range(num_palabras - orden):
        siguientes = modelo.get(clave)
        if not siguientes:
            clave = random.choice(list(modelo.keys()))
            siguientes = modelo[clave]

        palabra = random.choice(siguientes)
        resultado.append(palabra)
        clave = tuple(resultado[-orden:])   # desplazamos la ventana

    return " ".join(resultado)

# --- uso ---
texto = """
El perro corre en el parque. El gato duerme en la casa.
El perro ladra fuerte y el gato lo ignora. En el parque
el perro juega con otro perro mientras el gato observa desde la ventana.
"""

modelo = construir_modelo(texto, orden=2)
print(generar_texto(modelo, orden=2, num_palabras=30))

Puntos clave del código:

  • defaultdict(list) guarda, para cada tupla de N palabras, la lista de palabras que las siguieron (con repeticiones, así se conserva la probabilidad de forma implícita — si “perro” aparece 3 veces como siguiente, tiene 3x más chances de salir sorteado).
  • El “estado” es una tupla de orden palabras, no una sola. Eso es lo que hace que el orden 2 o 3 dé resultados más razonables que el orden 1.
  • Con textos de entrenamiento chicos, vas a caer rápido en callejones sin salida (una secuencia que nunca se repitió), por eso el if not siguientes: reiniciamos.

Para un modelo de letras en vez de palabras (más “creativo” / lovecraftiano) el mismo código sirve, solo cambiás texto.split() por list(texto).

C (más manual, pero instructivo para ver qué pasa “por debajo”)

En C no hay diccionarios nativos, así que la parte interesante es implementar la tabla hash a mano (o usar una estructura simple si el vocabulario es chico). Acá una versión simplificada con orden 1 (palabra → lista de palabras siguientes), usando una tabla hash básica:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#define TABLE_SIZE 1000
#define MAX_WORD 64
#define MAX_SIGUIENTES 200

typedef struct Nodo {
    char palabra[MAX_WORD];
    char siguientes[MAX_SIGUIENTES][MAX_WORD];
    int num_siguientes;
    struct Nodo *next; // encadenamiento para colisiones
} Nodo;

Nodo *tabla[TABLE_SIZE];

unsigned int hash(const char *str) {
    unsigned int h = 5381;
    while (*str) h = ((h << 5) + h) + (unsigned char)(*str++);
    return h % TABLE_SIZE;
}

Nodo* buscar_o_crear(const char *palabra) {
    unsigned int idx = hash(palabra);
    Nodo *actual = tabla[idx];
    while (actual) {
        if (strcmp(actual->palabra, palabra) == 0) return actual;
        actual = actual->next;
    }
    Nodo *nuevo = calloc(1, sizeof(Nodo));
    strcpy(nuevo->palabra, palabra);
    nuevo->next = tabla[idx];
    tabla[idx] = nuevo;
    return nuevo;
}

void agregar_transicion(const char *actual, const char *siguiente) {
    Nodo *nodo = buscar_o_crear(actual);
    if (nodo->num_siguientes < MAX_SIGUIENTES) {
        strcpy(nodo->siguientes[nodo->num_siguientes], siguiente);
        nodo->num_siguientes++;
    }
}

Nodo* obtener_nodo(const char *palabra) {
    unsigned int idx = hash(palabra);
    Nodo *actual = tabla[idx];
    while (actual) {
        if (strcmp(actual->palabra, palabra) == 0) return actual;
        actual = actual->next;
    }
    return NULL;
}

int main() {
    srand(time(NULL));

    // texto de entrenamiento de ejemplo
    char texto[] = "el perro corre en el parque el gato duerme en la casa "
                    "el perro ladra fuerte y el gato lo ignora en el parque "
                    "el perro juega con otro perro mientras el gato observa";

    char copia[1024];
    strcpy(copia, texto);

    char *palabras[200];
    int n = 0;
    char *tok = strtok(copia, " ");
    while (tok) {
        palabras[n++] = tok;
        tok = strtok(NULL, " ");
    }

    // construir el modelo (orden 1)
    for (int i = 0; i < n - 1; i++) {
        agregar_transicion(palabras[i], palabras[i+1]);
    }

    // generar texto
    const char *palabra_actual = palabras[0];
    printf("%s", palabra_actual);

    for (int i = 0; i < 30; i++) {
        Nodo *nodo = obtener_nodo(palabra_actual);
        if (!nodo || nodo->num_siguientes == 0) break;

        int idx_rand = rand() % nodo->num_siguientes;
        palabra_actual = nodo->siguientes[idx_rand];
        printf(" %s", palabra_actual);
    }

    printf("\n");
    return 0;
}

Compilás con gcc markov.c -o markov y corrés ./markov.

Diferencias clave frente al Python:

  • Acá implementé una tabla hash con encadenamiento (Nodo *next) porque C no trae diccionarios.
  • Es orden 1 para no complicar demasiado la estructura de datos (la clave del hash sería una palabra concatenada tipo "el_perro" si quisieras orden 2 — totalmente posible, solo hay que armar el string compuesto antes de hashear).
  • No hay manejo de memoria dinámica prolijo (no libero los calloc), en un programa real conviene agregar una función liberar_tabla().

Algo a tener en cuenta

Con textos de entrenamiento chicos como los de mis ejemplos, vas a ver que el generador cae en loops o frases idénticas al original rápido — necesita un corpus bastante grande (miles o decenas de miles de palabras) para que las probabilidades den variedad real y no termine repitiendo el texto fuente casi literal.

La teoría detrás de las cadenas de Markov aplicadas a texto

Vamos a construir el concepto de a poco, desde la probabilidad pura hasta cómo se traduce en estructuras de datos.

1. Estados y transiciones

Un proceso de Markov se define sobre un espacio de estados $S = {s_1, s_2, …, s_n}$. En cada paso, el sistema está en algún estado, y pasa al siguiente según una probabilidad que depende solo del estado actual:

$$P(X_{t+1} = s_j \mid X_t = s_i)$$

Esto se llama propiedad de Markov: el futuro depende del presente, no del pasado completo. Formalmente:

$$P(X_{t+1} \mid X_t, X_{t-1}, …, X_0) = P(X_{t+1} \mid X_t)$$

Aplicado a texto, cada estado es una palabra (o una secuencia corta de palabras, ya volvemos a esto). La “transición” es simplemente: qué palabra viene después.

2. La matriz de transición

Si tenés $n$ palabras distintas en tu vocabulario, en teoría existe una matriz $n \times n$ donde la celda $(i,j)$ guarda:

$$P(\text{palabra}_j \mid \text{palabra}_i)$$

En la práctica nunca construís esta matriz explícitamente, porque el vocabulario puede tener miles de palabras y la matriz sería enorme y casi toda ceros (la mayoría de los pares de palabras nunca aparecen juntos). Por eso se usa una estructura dispersa: un diccionario/mapa donde la clave es el estado actual y el valor es la lista (o distribución) de estados posibles siguientes.

Esto es clave para tu implementación: no necesitás álgebra de matrices, necesitás un mapa de “estado → lista de siguientes posibles”.

3. Cómo se estiman las probabilidades (conteo de frecuencias)

Acá está el corazón del algoritmo, y es puro conteo, nada sofisticado:

  1. Recorrés el texto de entrenamiento palabra por palabra.
  2. Por cada palabra en la posición $i$, mirás cuál es la palabra en la posición $i+1$.
  3. Vas anotando: “después de X, vi Y una vez más”.

Si “el” fue seguido de “perro” 3 veces y de “gato” 1 vez en todo el corpus, entonces:

$$P(\text{perro} \mid \text{el}) = 3/4, \quad P(\text{gato} \mid \text{el}) = 1/4$$

Truco de implementación importante: no hace falta calcular estas probabilidades explícitamente como fracciones. Si simplemente guardás una lista con repeticiones (["perro", "perro", "perro", "gato"]) y después elegís un elemento al azar de esa lista con distribución uniforme, estás logrando exactamente el mismo efecto que si hubieras calculado las probabilidades reales. Es la manera más simple de “samplear” de una distribución categórica sin hacer matemática explícita.

4. El problema del orden 1 (por qué necesitás memoria de más de una palabra)

Con orden 1 (el estado es una sola palabra), el modelo pierde demasiado contexto. Por ejemplo, “banco” podría seguir a “el” tanto en “el banco de plaza” como en “el banco central” — el modelo mezcla todo y pierde coherencia rápido.

La solución es usar cadenas de orden N: el estado no es una palabra, sino una tupla de N palabras consecutivas. Con orden 2, el estado sería ("el", "banco") y ahí las transiciones posibles ya son mucho más específicas y coherentes.

Esto sigue siendo matemáticamente una cadena de Markov de orden 1, solo que redefiniste qué es un “estado”: en vez de que el estado sea una palabra, es una ventana deslizante de N palabras. Es un truco muy común: subir el orden de contexto convirtiendo secuencias en estados atómicos.

Trade-off que tenés que entender:

  • Orden bajo (1-2): más variedad y creatividad, pero menos coherencia gramatical, texto más “loco”.
  • Orden alto (4-5+): el texto generado se vuelve casi una copia literal del original, porque secuencias largas de N palabras casi no se repiten en el corpus, entonces el modelo tiene muy pocas opciones (a veces solo una) para cada estado.

Este es el trade-off fundamental que vas a tener que ajustar empíricamente según el tamaño de tu corpus.

5. El algoritmo de generación (sampling)

Una vez que tenés el mapa “estado → lista de posibles siguientes”, generar texto es:

  1. Elegís un estado inicial (al azar, o el primero del corpus, o uno que empiece con mayúscula si querés simular inicio de oración).
  2. Buscás la lista de palabras que pueden seguir a ese estado.
  3. Elegís una al azar de esa lista (respetando las frecuencias, como vimos).
  4. Armás el nuevo estado descartando la palabra más vieja y agregando la nueva (ventana deslizante).
  5. Repetís hasta llegar a la longitud deseada, o hasta toparte con un estado sin salidas registradas (ahí reiniciás o cortás).

6. El problema de los “callejones sin salida” (dead ends)

Si tu estado actual nunca fue visto seguido de nada en el corpus (por ejemplo, es la última palabra del texto de entrenamiento), no hay transición posible. Tenés que decidir una estrategia:

  • Reiniciar con un estado al azar del modelo.
  • Terminar la generación ahí (tratarlo como fin de oración).
  • Usar un modelo de “back-off”: si no hay datos para el estado de orden N, probás con orden N-1 (esto ya es más avanzado, similar a lo que hacen los modelos de lenguaje n-gram clásicos con smoothing).

7. Conexión con la IA generativa moderna

Vale la pena que veas el paralelismo, porque es justo lo que mencionaste al principio:

Cadena de Markov LLM moderno (GPT, Claude, etc.)
Estado = últimas N palabras Contexto = últimos miles de tokens
Probabilidades = conteo de frecuencias Probabilidades = red neuronal entrenada
Transición fija por corpus Transición aprendida y generalizable a secuencias nunca vistas
Sampling categórico simple Sampling con temperatura, top-k, top-p, etc.

La diferencia enorme es que un LLM no memoriza conteos exactos de secuencias vistas: aprende una función continua que generaliza a secuencias nunca vistas antes, y puede mantener coherencia en contextos de miles de palabras, no solo 2 o 3. Pero la lógica de fondo — “dado el contexto, sampleo la palabra siguiente según una distribución de probabilidad” — es conceptualmente la misma línea evolutiva.


Con esto ya tenés todos los ladrillos teóricos: estados, transiciones, conteo de frecuencias, orden N, sampling, y manejo de callejones sin salida. ¿Querés que profundice en alguna parte en particular, como el tema del back-off o cómo pensar la estructura de datos antes de programarla vos mismo?