Ya tenemos todas las piezas. Aquí las juntamos y respondemos la pregunta con la que mucha gente llega a este libro: qué está pasando exactamente cuando le escribes a ChatGPT o a Claude 🤖
Primero: el texto se parte en trozos
En el capítulo 10 quedó un error abierto: pedir el vector de "huancayo" reventaba porque esa palabra no estaba en el vocabulario. Así se arregla:
import numpy as np
CONOCIDOS = {'bo', 'de', 'ga', 'hu', 'an', 'ca', 'yo', 'li', 'ma', 'min',
'i', 'mar', 'ket', 'a', 'o', 'e', 'u', 'n', 'r', 's', 't'}
def trozos(palabra, conocidos):
salida, i = [], 0
while i < len(palabra):
for j in range(len(palabra), i, -1): # el trozo más largo que quepa
if palabra[i:j] in conocidos:
salida.append(palabra[i:j])
i = j
break
else:
salida.append('?')
i += 1
return salida
for w in ['bodega', 'huancayo', 'minimarket', 'lima']:
print(f'{w:12} -> {trozos(w, CONOCIDOS)}')
bodega -> ['bo', 'de', 'ga'] huancayo -> ['hu', 'an', 'ca', 'yo'] minimarket -> ['min', 'i', 'mar', 'ket'] lima -> ['li', 'ma']
Ahí está: "huancayo" se parte en hu-an-ca-yo. No hace falta que la palabra esté en el vocabulario, basta con que estén sus trozos 🧩
Eso es la tokenización por subpalabras, y es por lo que un modelo puede leer un nombre propio peruano que nunca vio, o una palabra inventada, o un error de tipeo.
También explica dos cosas que se notan usándolos. Que el español gaste más tokens que el inglés (los vocabularios se arman mirando sobre todo texto en inglés, así que nuestras palabras se parten en más trozos), y que a los modelos les cueste contar letras: ellos no ven letras, ven trozos.
Segundo: el bloque completo
recentra y reescala las activaciones de cada ejemplo, y ese epsilon de abajo es lo que evita dividir entre cero cuando la varianza es minúscula
La atención del capítulo 11 no va sola. Un bloque de transformer le añade tres cosas, y las tres son necesarias:
def softmax(z):
z = z - z.max(axis=-1, keepdims=True)
e = np.exp(z)
return e / e.sum(axis=-1, keepdims=True)
def norma(X, eps=1e-5):
return (X - X.mean(-1, keepdims=True)) / np.sqrt(X.var(-1, keepdims=True) + eps)
rng = np.random.default_rng(7)
n, d = 6, 8
X = rng.normal(0, 1, (n, d))
Wq, Wk, Wv = [rng.normal(0, 0.5, (d, d)) for _ in range(3)]
W1 = rng.normal(0, 0.5, (d, 4 * d))
W2 = rng.normal(0, 0.5, (4 * d, d))
def bloque(X):
A = softmax((X @ Wq) @ (X @ Wk).T / np.sqrt(d))
X = norma(X + A @ (X @ Wv)) # atención + residual + norma
X = norma(X + np.maximum(0, X @ W1) @ W2) # feedforward + residual + norma
return X
Y = bloque(X)
print('entra', X.shape, 'y sale', Y.shape)
print('media de cada fila al salir :', np.round(Y.mean(axis=1), 3))
print('desviación de cada fila al salir:', np.round(Y.std(axis=1), 3))
entra (6, 8) y sale (6, 8) media de cada fila al salir : [ 0. 0. 0. -0. -0. 0.] desviación de cada fila al salir: [1. 1. 1. 1. 1. 1.]
Tres añadidos y cada uno resuelve un problema concreto 🔧
- ➕ El residual, ese
X +, deja pasar la entrada sin tocar al lado de lo que calculó la capa. Así el gradiente del capítulo 6 tiene un camino directo hasta abajo sin multiplicarse por nada, y por eso se pueden apilar cien capas. - 📏 La norma por capa centra cada fila en media 0 y desviación 1. Mira la salida: todas las medias son 0 y todas las desviaciones 1, exacto.
- 🧠 El feedforward es la red del capítulo 4 aplicada a cada posición por separado, y suele ser cuatro veces más ancha. Ahí está la mayoría de los pesos de un modelo de lenguaje, aunque la fama se la lleve la atención.
Por qué el residual no es opcional
la capa aprende lo que hay que sumarle a la entrada en vez de la salida entera, y por eso el gradiente encuentra siempre un camino corto de vuelta
def bloque_sin_residual(X):
A = softmax((X @ Wq) @ (X @ Wk).T / np.sqrt(d))
X = norma(A @ (X @ Wv))
X = norma(np.maximum(0, X @ W1) @ W2)
return X
def parecido(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
Z = X.copy()
for _ in range(6):
Z = bloque_sin_residual(Z)
print('sin residual, tras 6 bloques, parecido entre dos posiciones:',
round(parecido(Z[0], Z[1]), 4))
Z = X.copy()
for capa in range(6):
Z = bloque(Z)
print(f'con residual, tras {capa + 1} bloques: magnitud media {np.abs(Z).mean():.4f}')
sin residual, tras 6 bloques, parecido entre dos posiciones: 1.0 con residual, tras 1 bloques: magnitud media 0.8264 con residual, tras 2 bloques: magnitud media 0.8187 con residual, tras 3 bloques: magnitud media 0.8034 con residual, tras 4 bloques: magnitud media 0.8156 con residual, tras 5 bloques: magnitud media 0.8210 con residual, tras 6 bloques: magnitud media 0.8247
Sin residual, después de seis bloques las dos posiciones tienen un parecido de 1,0: son el mismo vector. Todas las palabras se volvieron idénticas y el modelo perdió la frase entera.
Con residual, la magnitud se queda entre 0,80 y 0,83 bloque tras bloque, sin crecer ni apagarse.
Una nota honesta: aquí los pesos son aleatorios, así que ese colapso es más brutal de lo que sería con un modelo entrenado. Lo que la medición enseña de verdad es que el residual y la norma son lo que mantiene estable una pila de bloques, y sin ellos las redes profundas de texto no se pueden entrenar 🏗️
Tercero: el bucle que escribe
Y ahora la parte que sorprende a casi todo el mundo. Un modelo de lenguaje hace una sola cosa: dada una secuencia, poner una probabilidad a cada token posible como siguiente.
Escribir es repetir eso: eliges uno, lo pegas al final, y vuelves a preguntar.
COMENTARIOS = [
'el pedido llego completo y a tiempo', 'el pedido llego incompleto y con retraso',
'la bodega pidio arroz y azucar', 'el minimarket pidio aceite y azucar',
'el mayorista pidio arroz en cantidad', 'el pedido de la bodega llego tarde',
'el pedido del minimarket llego tarde', 'la bodega reclamo por el retraso del pedido',
'el minimarket reclamo por el aceite roto', 'el mayorista reclamo por la factura',
'llego todo completo sin reclamo', 'el arroz llego en buen estado',
'el aceite llego en buen estado', 'el azucar llego roto y con retraso',
'la bodega pago la factura a tiempo', 'el minimarket pago la factura con retraso',
'el mayorista pago la factura a tiempo', 'el vendedor visito la bodega el lunes',
'el vendedor visito el minimarket el martes', 'el vendedor visito al mayorista el lunes',
'la bodega compro arroz el lunes', 'el minimarket compro aceite el martes',
'el mayorista compro arroz y aceite', 'nadie visito la bodega esta semana',
'el pedido de arroz llego completo', 'el pedido de aceite llego incompleto',
]
vocabulario = sorted({p for f in COMENTARIOS for p in f.split()} | {'<fin>'})
lugar = {p: i for i, p in enumerate(vocabulario)}
V = len(vocabulario)
siguiente = np.zeros((V, V))
for f in COMENTARIOS:
ps = f.split() + ['<fin>']
for a, b in zip(ps, ps[1:]):
siguiente[lugar[a], lugar[b]] += 1
def escribe(inicio, temperatura=1.0, semilla=0, maximo=10):
r = np.random.default_rng(semilla)
salida = [inicio]
for _ in range(maximo):
fila = siguiente[lugar[salida[-1]]]
if fila.sum() == 0:
break
puntajes = np.where(fila > 0, np.log(fila + 1e-9), -1e9) / temperatura
p = np.exp(puntajes - puntajes.max())
p = p / p.sum()
elegida = vocabulario[r.choice(V, p=p)]
if elegida == '<fin>':
break
salida.append(elegida)
return ' '.join(salida)
for s in range(4):
print(escribe('el', semilla=s))
el minimarket el aceite el minimarket reclamo el martes el arroz en cantidad
Eso es un modelo de lenguaje. Uno malísimo, porque solo mira la palabra anterior en vez de toda la frase con atención, y porque aprendió de 26 comentarios en vez de billones de palabras.
Pero el bucle es exactamente el mismo: mirar, poner probabilidades, elegir, pegar, repetir. Cuando ves a Claude escribir palabra a palabra, es esto, con un transformer en el medio en vez de una tabla 🔁
Y de aquí sale algo que conviene entender: el modelo no tiene un plan de lo que va a decir. Cada token se elige con lo que hay escrito hasta ese momento. Que las respuestas salgan coherentes es consecuencia de que el contexto incluye todo lo que ya escribió.
La temperatura, que es lo único que ajustas tú
palabras = ['completo', 'tarde', 'roto', 'nunca', 'ya']
puntajes = np.array([3.2, 2.1, 0.8, -0.5, -1.4])
def con_temperatura(z, T):
z = z / T
e = np.exp(z - z.max())
return e / e.sum()
print(f"{'palabra':12}" + ''.join(f'{f"T={t}":>10}' for t in [0.2, 0.5, 1.0, 1.5, 3.0]))
for i, p in enumerate(palabras):
print(f'{p:12}' + ''.join(f'{con_temperatura(puntajes, t)[i]:10.4f}'
for t in [0.2, 0.5, 1.0, 1.5, 3.0]))
palabra T=0.2 T=0.5 T=1.0 T=1.5 T=3.0 completo 0.9959 0.8931 0.6857 0.5514 0.3774 tarde 0.0041 0.0990 0.2282 0.2648 0.2616 roto 0.0000 0.0073 0.0622 0.1113 0.1696 nunca 0.0000 0.0005 0.0170 0.0468 0.1100 ya 0.0000 0.0001 0.0069 0.0257 0.0815
Ahí está lo que hace, y es una sola división 🌡️
Con T=0,2 la favorita se lleva 0,9959 y las demás quedan en nada: el modelo va a decir siempre lo mismo. Con T=3,0 la favorita baja a 0,3774 y hasta "ya", que era la última, se lleva 0,0815.
Y fíjate en lo que la temperatura no hace: no cambia el orden ni cambia lo que el modelo sabe. Completo sigue siendo la más probable en las cinco columnas. Solo cambia cuánto se arriesga al elegir.
Por eso "bájale la temperatura para que no alucine" funciona a medias: si el modelo tiene mal el dato, con T=0 lo va a decir mal con toda seguridad 😬
Los otros dos filtros
p = con_temperatura(puntajes, 1.0)
def top_k(p, k):
q = p.copy()
corte = np.sort(q)[::-1][k - 1]
q[q < corte] = 0
return q / q.sum()
def top_p(p, umbral):
orden = np.argsort(p)[::-1]
acumulado = np.cumsum(p[orden])
cuantas = int(np.searchsorted(acumulado, umbral)) + 1
q = np.zeros_like(p)
q[orden[:cuantas]] = p[orden[:cuantas]]
return q / q.sum()
print('sin filtro:', np.round(p, 4))
print('top-k, k=2:', np.round(top_k(p, 2), 4))
print('top-p, 0.9:', np.round(top_p(p, 0.9), 4))
sin filtro: [0.6857 0.2282 0.0622 0.017 0.0069] top-k, k=2: [0.7503 0.2497 0. 0. 0. ] top-p, 0.9: [0.7503 0.2497 0. 0. 0. ]
El top-k se queda con las k mejores y tira el resto. El top-p se queda con las que hagan falta para juntar ese porcentaje de probabilidad, así que el corte se mueve solo: si el modelo está segurísimo se queda con una, y si duda se queda con muchas.
Aquí los dos dan lo mismo porque con estos cinco números las dos primeras ya suman el 91%. Con un vocabulario de 100.000 tokens la diferencia se nota muchísimo, y por eso el top-p es el que se usa hoy 🎚️
Lo que este capítulo NO explica
Y esto es importante para que no salgas de aquí creyendo que ya sabes cómo se hace un ChatGPT.
- 📚 El entrenamiento. Todo lo de este libro, pero sobre billones de palabras y durante meses, en miles de tarjetas gráficas. La retropropagación es la misma; la escala no.
- 🎓 El ajuste con personas. Un modelo entrenado solo a predecir la palabra siguiente no contesta preguntas: completa texto. Que sea útil y educado sale de una fase posterior con evaluaciones humanas.
- 🧯 Las barreras. Lo que decide qué no responde no está en la arquitectura.
Lo que sí te llevas es la maquinaria: tokens, atención, bloques apilados y un bucle que elige. Y con eso ya puedes leer un paper y entender de qué habla 💛
Ejercicios
1. Cuántos tokens gasta el español
Parte varias palabras y cuenta los trozos.
for w in ['lima', 'bodega', 'minimarket', 'huancayo']:
t = trozos(w, CONOCIDOS)
print(f'{w:12} {len(w):2d} letras -> {len(t)} trozos {t}')
lima 4 letras -> 2 trozos ['li', 'ma'] bodega 6 letras -> 3 trozos ['bo', 'de', 'ga'] minimarket 10 letras -> 4 trozos ['min', 'i', 'mar', 'ket'] huancayo 8 letras -> 4 trozos ['hu', 'an', 'ca', 'yo']
Cuatro letras dan dos trozos y diez letras dan cuatro. Con un vocabulario de verdad la proporción es mejor, pero la idea es esa.
Y tiene consecuencia de plata: los modelos se cobran por token. Si tu texto está en español y el vocabulario se armó mirando inglés, pagas más por decir lo mismo 💸
2. La palabra que no se puede partir
Prueba con una palabra cuyos trozos no estén todos.
print(trozos('zapallo', CONOCIDOS))
print(trozos('xyz', CONOCIDOS))
['?', 'a', '?', 'a', '?', '?', 'o'] ['?', '?', '?']
Aparecen interrogantes donde no encontró nada. En un tokenizador de verdad eso no pasa nunca, porque el vocabulario incluye todas las letras sueltas: siempre hay una salida, aunque sea letra a letra.
Ese es el diseño que hace que nunca falle, a diferencia de los embeddings del capítulo 10, que reventaban con una palabra nueva 🧩
3. La temperatura sobre el generador
Escribe con temperaturas distintas y compara.
for T in [0.2, 1.0, 3.0]:
print(f'T={T}:')
for s in range(3):
print(' ', escribe('el', temperatura=T, semilla=s))
T=0.2:
el pedido de aceite
el pedido llego completo y azucar
el minimarket el pedido de arroz y azucar
T=1.0:
el minimarket el aceite
el minimarket reclamo
el martes
T=3.0:
el minimarket el aceite
el mayorista reclamo
el lunes
Las frases cambian, sí, pero no como esperabas: con T=0,2 salen las más largas, y con T=3,0 las más cortas y sosas. Justo al revés de la intuición de "temperatura alta, más creativo".
Y tiene explicación. Con una tabla de 26 comentarios, la mayoría de las palabras tienen una o dos continuaciones posibles, así que la temperatura no cambia cuánto se arriesga: cambia por qué rama se va. Y unas ramas llevan a frases largas y otras a un punto final.
La temperatura hace lo que dice el apartado anterior cuando hay muchas continuaciones plausibles, que es lo que pasa con un vocabulario de 100.000 tokens. Con cinco opciones, lo que se ve es otra cosa 🌡️
4. La misma semilla siempre da lo mismo
Comprueba que no hay nada mágico ni impredecible.
print(escribe('la', semilla=3))
print(escribe('la', semilla=3))
print('¿idénticas?', escribe('la', semilla=3) == escribe('la', semilla=3))
la bodega el pedido del minimarket llego incompleto la bodega el pedido del minimarket llego incompleto ¿idénticas? True
Idénticas, siempre. La misma entrada y la misma semilla dan la misma salida.
Que un modelo te conteste distinto cada vez no es porque "piense diferente": es porque el azar de la elección cambia. Con temperatura 0 y sin muestreo, un modelo de lenguaje es una función determinista 🎲
5. Cuántos pesos hay en cada sitio
Reparte los parámetros de un bloque entre atención y feedforward.
for dim in [8, 768, 4096]:
atencion = 4 * dim * dim # Wq, Wk, Wv y la de salida
feed = 8 * dim * dim # una de d a 4d y otra de 4d a d
print(f'd={dim:5d}: atención {atencion:12,} feedforward {feed:12,} '
f'({feed / (atencion + feed):.0%} en el feedforward)')
d= 8: atención 256 feedforward 512 (67% en el feedforward) d= 768: atención 2,359,296 feedforward 4,718,592 (67% en el feedforward) d= 4096: atención 67,108,864 feedforward 134,217,728 (67% en el feedforward)
Dos tercios de los pesos de un bloque están en el feedforward, no en la atención.
La atención se lleva toda la atención (perdón) porque es la idea nueva, y el grueso de la memoria del modelo está en esas dos matrices anchas que procesan cada posición por separado 🧠
6. Qué tan grande es la norma por capa
Comprueba que deja cada fila en media 0 y desviación 1.
raro = np.array([[1000., 1001, 999, 1002],
[0.001, 0.002, 0.0015, 0.0011]])
print('antes, medias :', np.round(raro.mean(axis=1), 4))
print('antes, desviaciones:', np.round(raro.std(axis=1), 4))
print('después, medias :', np.round(norma(raro).mean(axis=1), 4))
print('después, desviaciones:', np.round(norma(raro).std(axis=1), 4))
antes, medias : [1.0005e+03 1.4000e-03] antes, desviaciones: [1.118e+00 4.000e-04] después, medias : [ 0. -0.] después, desviaciones: [1. 0.1235]
Las dos filas salen en media 0, y ahí funcionó. Pero mira las desviaciones: la primera sale 1 y la segunda sale 0,1235.
El culpable es el eps de la fórmula. La segunda fila tiene una
varianza de 1,6e-07, o sea muchísimo más chica que el 1e-05 que le sumamos para
no dividir entre cero, así que ese epsilon se come el cálculo y la fila sale
aplastada.
No es un fallo: es el precio de protegerse de la división entre cero, y solo se nota cuando los números son ridículamente pequeños. Me pareció que valía la pena enseñarlo porque estas protecciones nunca son gratis, y esta en concreto está en todas las redes que existen 🧯
Por lo demás, la norma por capa es el StandardScaler del capítulo
5 aplicado dentro de la red y a cada fila en vez de a las
columnas antes de empezar. Y por la misma razón: para que el gradiente no tenga
que lidiar con escalas distintas ⚖️
7. El error del token que no existe
Pídele al generador que arranque con una palabra que no está en el corpus.
escribe('hola')
KeyError: 'hola'
Mismo error que cerraba el capítulo 10, y por eso lo repito aquí: el generador trabaja con una tabla de palabras conocidas y "hola" no está.
La diferencia es que ahora ya sabes la solución. Si en vez de palabras enteras usara los trozos del principio del capítulo, "hola" se partiría en pedacitos conocidos y no habría error.
La tokenización por subpalabras no es un detalle de implementación: es lo que hace que el sistema no se rompa con el mundo real 🧩
Comprueba que lo tienes
¿Por qué las conexiones residuales no son opcionales en un transformer profundo?
- Porque le dan al gradiente un camino corto de vuelta a las primeras capas
- Porque hacen el modelo más rápido
- Porque reducen el número de parámetros
- Porque evitan el sobreajuste
Lo que te llevas
- 🧩 El texto se parte en trozos, así que "huancayo" se lee como hu-an-ca-yo sin estar en el vocabulario.
- ➕ Un bloque es atención más residual más norma más feedforward. Sin residual, seis bloques dejan todas las posiciones con parecido 1,0.
- 🧠 Dos tercios de los pesos están en el feedforward, no en la atención.
- 🔁 Escribir es un bucle: poner probabilidades, elegir, pegar, repetir. No hay plan previo.
- 🌡️ La temperatura es una división: con 0,2 la favorita se lleva 0,9959 y con 3,0 baja a 0,3774. No cambia el orden ni lo que el modelo sabe.
- 🎚️ Top-k corta por cantidad y top-p por probabilidad acumulada, así que el corte del top-p se mueve según lo seguro que esté.
- 🎲 Misma entrada y misma semilla dan la misma salida. Nada de esto es impredecible.
- 📚 Y lo que aquí no está: el entrenamiento a escala, el ajuste con personas y las barreras.
En el capítulo 13 pasamos al lado práctico: cuándo conviene un prompt, cuándo RAG y cuándo ajustar un modelo, que es la pregunta que llega a las empresas.
Que tengas lindo día! 🌸