Módulo 01 · Fundamentos

Del Perceptrón al Transformer:
El sustrato de los LLM modernos

Este capítulo explica «cómo funciona realmente la IA» con el mínimo de matemáticas avanzadas. Tanto si eres de producto, ingeniería o ventas, después de trabajar este capítulo podrás seguir cualquier discusión posterior sobre modelos y frameworks.

1. Redes neuronales artificiales: de la neurona biológica a la función matemática

1.1 La unidad mínima de una red neuronal

Una red neuronal artificial se inspira en cómo funcionan las neuronas biológicas. Una sola neurona es, en esencia, una función matemática:

output = activation(w · x + b)

Aquí x es el vector de entrada, w es el peso, b es el sesgo, y activation es una función no lineal (comúnmente Sigmoid, ReLU o GELU). Apila miles de estas «neuronas» en capas y conéctalas y tendrás la base del deep learning moderno.

1.2 Los tres elementos del entrenamiento

💡 Intuición clave: entrenar una red neuronal es esencialmente «buscar en un espacio de parámetros de miles de millones de dimensiones una configuración de pesos que minimice la pérdida». Por eso los LLM necesitan enormes cantidades de datos y cómputo — el espacio de parámetros es enorme.

2. Transformer: la piedra angular de los LLM modernos

En 2017, Google publicó Attention Is All You Need, introduciendo la arquitectura Transformer y cambiando la trayectoria de la IA. Su innovación central: eliminar por completo la recurrencia y depender puramente del mecanismo de atención para procesar secuencias.

2.1 Por qué el Transformer arrasó en NLP

PropiedadRNN / LSTMTransformer
Dependencias de largo alcanceTiende a olvidar el contexto tempranoConexión directa entre dos posiciones cualesquiera
Cómputo en paraleloEstrictamente secuencialProcesa todas las posiciones a la vez
Velocidad de entrenamientoLentaRápida (uso completo de GPU)
EscalabilidadDifícil escalar más allá de 10B parámetrosSoporte nativo para 100B+ parámetros

2.2 Self-attention

La idea central: para cada palabra de una secuencia, «mira» a cada otra palabra y decide cuánto importa cada una para ella.

Toma esta frase de ejemplo — «Xiaoming fue ayer de viaje de negocios a Pekín, y él volvió hoy»:

  1. Cada palabra produce tres vectores: Query (Q), Key (K), Value (V).
  2. Para «él», calcula el producto punto de su Q con el K de cada otra palabra para obtener una puntuación de relevancia.
  3. Aplica Softmax para obtener los pesos de atención (p. ej., 0,8 para «Xiaoming», 0,1 para «Pekín»).
  4. Usa esos pesos para tomar una suma ponderada del V de cada palabra, produciendo una representación nueva, rica en contexto, de «él».

En forma matemática:

Attention(Q, K, V) = softmax(Q · K^T / √d_k) · V

donde d_k es la dimensionalidad del vector Key; dividir por su raíz cuadrada evita que el producto punto haga explotar el gradiente del Softmax.

2.3 Atención multi-cabezal (Multi-head attention)

La atención multi-cabezal es self-attention con esteroides. Ejecuta múltiples operaciones de self-attention en paralelo a través de diferentes «subespacios de representación» y concatena los resultados. Esto permite al modelo capturar diferentes tipos de relaciones a la vez:

2.4 Codificación posicional (Positional encoding)

El Transformer procesa cada palabra en paralelo, por lo que pierde por defecto la información de «orden». «El perro muerde al hombre» y «El hombre muerde al perro» tienen tokens idénticos — solo difiere el orden. La codificación posicional añade un «vector de posición» único al embedding de cada palabra para que el modelo sepa dónde se sitúa en la secuencia.

El Transformer original usó codificaciones posicionales sinusoidales/coseno fijas. Modelos posteriores (LLaMA, Qwen) usan RoPE (Rotary Position Embeddings), que maneja secuencias mucho más largas.

3. La arquitectura completa del Transformer

Un bloque Transformer estándar tiene dos mitades:

input → [LayerNorm → Multi-Head Attention → Residual]
     → [LayerNorm → Feed-Forward Network (FFN) → Residual]
     → output

La familia GPT (ChatGPT, DeepSeek, Qwen) son simplemente decenas o cientos de tales bloques apilados en un Transformer Decoder-only.

4. Pre-entrenamiento, SFT, RLHF: la receta de entrenamiento en tres pasos

4.1 Pre-entrenamiento

Entrena en un vasto corpus de texto de internet (billones de tokens) para que el modelo aprenda a «predecir el siguiente token». Este es el paso más largo y costoso — DeepSeek-V3 usó 14,8T tokens de datos de entrenamiento y costó aproximadamente 5,57M USD (alquiler de GPU).

4.2 Ajuste fino supervisado (SFT)

Entrena en un pequeño conjunto de pares «instrucción-respuesta» curados por humanos y de alta calidad (decenas de miles) para que el modelo aprenda a «entender la solicitud y responder en consecuencia». El volumen es muy inferior al del pre-entrenamiento, pero los requisitos de calidad son extremos.

4.3 Aprendizaje por refuerzo a partir de feedback humano (RLHF)

Los humanos puntúan las diferentes respuestas del modelo; se entrena un modelo de recompensa con esas clasificaciones, y luego se usa RL (típicamente PPO o DPO) para empujar al modelo hacia respuestas mejor clasificadas.

5. ¿Por qué necesitamos clusters de GPU?

Entrenar un modelo de 100B parámetros — un solo pase de forward + backward requiere:

Nada de esto cabe en una sola GPU (80GB), por lo que necesitamos múltiples estrategias paralelas:

ParalelismoIdeaFramework representativo
Datos paraleloDivide los datos entre GPUs; cada una ejecuta el modelo completoPyTorch DDP
Modelo paraleloUbica diferentes capas en diferentes GPUsMegatron-LM
Pipeline paraleloDivide el modelo en etapas; cada GPU procesa un micro-batch diferenteGPipe, PipeDream
Tensor paraleloDivide una sola operación matricial entre GPUsMegatron-LM

6. De los LLM a los Agents: el salto en capacidad

Una vez que los LLM se volvieron lo suficientemente fuertes, la gente empezó a añadir «extras»: el modelo no solo puede «hablar» sino también «hacer». Este es el punto de partida del AI Agent:

7. Resumen del capítulo

📚 Próximo capítulo: Evolución del Agent — Del LLM al Agentic Agent

8. Práctica: construye un Mini Transformer desde cero

Ahora que la teoría está cubierta, esta sección te guía en la construcción de un Transformer Decoder-only de 6 capas en PyTorch desde cero, entrenado en un pequeño corpus de texto. El código completo tiene alrededor de 250 líneas y se ejecuta en CPU o GPU — el objetivo es hacer que cada engranaje del Transformer sea visible para ti.

8.1 Objetivos y entorno

Configuración del entorno (2 líneas):

pip install torch numpy
python -c "import torch; print('torch', torch.__version__)"

8.2 Preparación de datos

Usaremos un pequeño fragmento de datos pseudo estilo poesía Tang (puedes reemplazarlo con tu propio input.txt):

cat > data/input.txt << 'EOF'
Quiet night thoughts
Before my bed a pool of light,
Can it be frost upon the ground?
Eyes up, I find the moon so bright,
Head down, I think of home I've found.
Spring dawn
Spring slumber knows no dawn so soon,
The birds' song fills the air with tune.
Last night the wind and rain did blow,
How many flowers have let go?
EOF
wc -c data/input.txt

Un modelo a nivel de carácter simplemente lee el archivo completo como una cadena larga — no se necesita tokenizador.

8.3 Código completo: train.py

"""Mini Transformer: 6 capas Decoder-only, modelo de lenguaje a nivel de carácter.

Uso:
    python train.py
"""
import math
import time
from pathlib import Path

import torch
import torch.nn as nn
import torch.nn.functional as F


# ---------- 1. Data loader ----------
class CharDataset:
    """Corta un fichero de texto en muestras de entrenamiento de longitud (block_size)."""

    def __init__(self, path: str, block_size: int = 64):
        self.text = Path(path).read_text(encoding="utf-8")
        self.chars = sorted(set(self.text))
        self.vocab_size = len(self.chars)
        self.stoi = {c: i for i, c in enumerate(self.chars)}
        self.itos = {i: c for i, c in enumerate(self.chars)}
        self.block_size = block_size
        self.data = torch.tensor([self.stoi[c] for c in self.text], dtype=torch.long)
        print(f"[data] chars={self.vocab_size} len={len(self.data)}")

    def get_batch(self, batch_size: int = 32):
        ix = torch.randint(0, len(self.data) - self.block_size - 1, (batch_size,))
        x = torch.stack([self.data[i : i + self.block_size] for i in ix])
        y = torch.stack([self.data[i + 1 : i + 1 + self.block_size] for i in ix])
        return x, y


# ---------- 2. Componentes del modelo ----------
class PositionalEncoding(nn.Module):
    """Codificación posicional sinusoidal, congelada."""

    def __init__(self, d_model: int, max_len: int = 512):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len).unsqueeze(1).float()
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer("pe", pe.unsqueeze(0))  # (1, max_len, d_model)

    def forward(self, x):
        return x + self.pe[:, : x.size(1)]


class CausalSelfAttention(nn.Module):
    """Atención multi-cabezal causal (usada en el Decoder)."""

    def __init__(self, d_model: int, n_heads: int, max_len: int = 512, dropout: float = 0.1):
        super().__init__()
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model)
        self.proj = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        # máscara causal: triángulo superior (excluyendo diagonal) es -inf
        mask = torch.triu(torch.ones(max_len, max_len), diagonal=1).bool()
        self.register_buffer("mask", mask)

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim).permute(2, 0, 3, 1, 4)
        q, k, v = qkv[0], qkv[1], qkv[2]  # (B, h, T, head_dim)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        att = att.masked_fill(self.mask[:T, :T], float("-inf"))
        att = F.softmax(att, dim=-1)
        att = self.dropout(att)
        out = (att @ v).transpose(1, 2).reshape(B, T, C)
        return self.proj(out)


class FeedForward(nn.Module):
    """FFN del Transformer: GELU + Linear + Dropout."""

    def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    """Bloque Decoder Pre-LN: Attention -> FFN, cada uno con residual."""

    def __init__(self, d_model: int, n_heads: int, d_ff: int, max_len: int, dropout: float):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)
        self.attn = CausalSelfAttention(d_model, n_heads, max_len, dropout)
        self.ln2 = nn.LayerNorm(d_model)
        self.ffn = FeedForward(d_model, d_ff, dropout)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ffn(self.ln2(x))
        return x


class MiniTransformer(nn.Module):
    """Transformer Decoder-only de 6 capas, modelo de lenguaje a nivel de carácter."""

    def __init__(
        self,
        vocab_size: int,
        d_model: int = 128,
        n_heads: int = 4,
        d_ff: int = 512,
        n_layers: int = 6,
        max_len: int = 256,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d_model)
        self.pos_enc = PositionalEncoding(d_model, max_len)
        self.blocks = nn.ModuleList(
            [Block(d_model, n_heads, d_ff, max_len, dropout) for _ in range(n_layers)]
        )
        self.ln_f = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size, bias=False)
        # Weight tying: el embedding de entrada comparte pesos con la cabeza de salida
        self.head.weight = self.tok_emb.weight
        self.apply(self._init_weights)

    def _init_weights(self, m):
        if isinstance(m, nn.Linear):
            nn.init.normal_(m.weight, std=0.02)
            if m.bias is not None:
                nn.init.zeros_(m.bias)
        elif isinstance(m, nn.Embedding):
            nn.init.normal_(m.weight, std=0.02)

    def forward(self, idx, targets=None):
        x = self.tok_emb(idx)
        x = self.pos_enc(x)
        for blk in self.blocks:
            x = blk(x)
        x = self.ln_f(x)
        logits = self.head(x)
        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
        return logits, loss

    @torch.no_grad()
    def generate(self, idx, max_new_tokens: int = 100, temperature: float = 1.0, top_k: int = 20):
        """Generación autorregresiva con temperatura + muestreo top-k."""
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -self.pos_enc.pe.size(1) :]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / temperature
            if top_k:
                v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
                logits[logits < v[:, [-1]]] = float("-inf")
            probs = F.softmax(logits, dim=-1)
            nxt = torch.multinomial(probs, num_samples=1)
            idx = torch.cat([idx, nxt], dim=1)
        return idx


# ---------- 3. Bucle de entrenamiento ----------
def main():
    torch.manual_seed(0)
    ds = CharDataset("data/input.txt", block_size=64)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model = MiniTransformer(vocab_size=ds.vocab_size).to(device)
    print(f"[model] params={sum(p.numel() for p in model.parameters()) / 1e6:.2f}M device={device}")

    opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
    steps = 1000
    batch_size = 32
    t0 = time.time()
    for step in range(steps):
        x, y = ds.get_batch(batch_size)
        x, y = x.to(device), y.to(device)
        _, loss = model(x, y)
        opt.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        if step % 100 == 0 or step == steps - 1:
            dt = time.time() - t0
            print(f"step {step:4d} loss={loss.item():.4f} elapsed={dt:.1f}s")
    torch.save({"model": model.state_dict(), "stoi": ds.stoi, "itos": ds.itos}, "ckpt.pt")
    print("[done] saved ckpt.pt")


if __name__ == "__main__":
    main()

8.4 Script de inferencia: sample.py

"""Genera texto a partir de un ckpt.pt entrenado."""
import torch
from train import MiniTransformer

ckpt = torch.load("ckpt.pt", map_location="cpu")
stoi, itos = ckpt["stoi"], ckpt["itos"]
model = MiniTransformer(vocab_size=len(itos))
model.load_state_dict(ckpt["model"])
model.eval()

prompt = "Quiet night thoughts\n"
idx = torch.tensor([[stoi[c] for c in prompt]], dtype=torch.long)
out = model.generate(idx, max_new_tokens=120, temperature=0.8, top_k=10)[0].tolist()
print("=== generated ===")
print("".join(itos[i] for i in out))

8.5 Ejecútalo y salida esperada

mkdir -p data
# (escribe el input.txt de la sección 8.2)
python train.py

Salida esperada (CPU ~30-60s, MPS/GPU unos pocos segundos):

[data] chars=28 len=87
[model] params=1.21M device=cpu
step    0 loss=3.3321 elapsed=0.0s
step  100 loss=2.7102 elapsed=2.1s
step  200 loss=2.3504 elapsed=4.0s
...
step  900 loss=1.8421 elapsed=32.5s
step  999 loss=1.7903 elapsed=36.0s
[done] saved ckpt.pt

python sample.py
=== generated ===
Quiet night thoughts
Before my bed a pool of light,
Can it be frost upon the ground?
Eyes up, I find the moon so bright,
Head down, I think of home I've found.

8.6 Consejos de ajuste

8.7 Siguientes pasos

📚 Anterior: 7. Resumen del capítulo · Próximo capítulo: Evolución del Agent — Del LLM al Agentic Agent