7 - Tokens y embeddings: cómo el texto se convierte en números¶
En el capítulo 7, el equipo de Minermont quiere entender los grandes modelos de lenguaje que ya usa a diario. La primera lección en AIA trata de la materia prima: una red neuronal solo trabaja con números, así que el texto hay que cortarlo en tokens, cada token recibe un identificador numérico y cada identificador se convierte en un vector, su embedding. En este cuaderno construimos cada pieza a mano:
- Importar las bibliotecas.
- Tres formas de cortar una frase en tokens.
- Tokenización en subpalabras con BPE, desde cero.
- De los tokens a los identificadores y los vectores de embedding.
- Aprender embeddings a partir del contexto: word2vec.
- Medir la similitud: coseno o distancia euclídea (las notas de Marta, sección 7.9).
Paso 1: Importar las bibliotecas¶
import collections
import numpy as np
import matplotlib.pyplot as plt
np.set_printoptions(precision=3, suppress=True)
Paso 2: Tres formas de cortar una frase¶
El libro compara tres estrategias de tokenización con la frase "La paciente tiene fiebre alta". Separar por palabras es sencillo, pero necesita un vocabulario enorme y no sabe qué hacer con las palabras que nunca ha visto. Separar por caracteres sirve para cualquier palabra, pero produce secuencias muy largas y pierde la noción de palabra. Los modelos actuales usan una solución intermedia, las subpalabras.
sentence = "La paciente tiene fiebre alta"
word_tokens = sentence.split()
char_tokens = list(sentence)
print(f"Tokens por palabras ({len(word_tokens)}): {word_tokens}")
print(f"Tokens por caracteres ({len(char_tokens)}): {char_tokens}")
Tokens por palabras (5): ['La', 'paciente', 'tiene', 'fiebre', 'alta'] Tokens por caracteres (29): ['L', 'a', ' ', 'p', 'a', 'c', 'i', 'e', 'n', 't', 'e', ' ', 't', 'i', 'e', 'n', 'e', ' ', 'f', 'i', 'e', 'b', 'r', 'e', ' ', 'a', 'l', 't', 'a']
Paso 3: Tokenización en subpalabras con BPE¶
Byte Pair Encoding (BPE) aprende sus subpalabras a partir de un corpus. Empieza con cada palabra separada en caracteres (más una marca de final de palabra, _), y después repite un solo paso: buscar la pareja de símbolos contiguos que aparece más veces en el corpus y fusionarla en un símbolo nuevo. Las palabras frecuentes terminan siendo un solo token; las raras se quedan partidas en trozos frecuentes.
Nuestro corpus es una lista de palabras médicas con las veces que aparece cada una:
corpus_counts = {
'hipertensión': 30, 'hipotensión': 18, 'hiperglucemia': 22, 'hipoglucemia': 20, 'glucemia': 8, 'hipertermia': 10,
'hipotermia': 12, 'taquicardia': 15, 'bradicardia': 12, 'cardiología': 20, 'cardiólogo': 14, 'neurología': 16,
'neurólogo': 12, 'dermatología': 14, 'dermatólogo': 10, 'fibrilación': 16, 'desfibrilador': 8, 'desfibrilación': 6,
'paciente': 60, 'pacientes': 40, 'tratamiento': 30, 'el': 120, 'de': 60, 'y': 60, 'con': 40, 'dolor': 30, 'pecho': 20,
'fiebre': 25, 'dosis': 20, 'insulina': 15, 'prueba': 25, 'pruebas': 15, 'monitor': 20, 'ventilador': 12,
'doctor': 40, 'indicador': 8, 'deshidratación': 12, 'descompensación': 6, 'detección': 10,
}
test_words = ['hiperglucemia', 'desfibrilador', 'hipertenso', 'miocardiopatía', 'pacientes']
def merge_pair(symbols, pair):
out, i = [], 0
while i < len(symbols):
if i < len(symbols) - 1 and (symbols[i], symbols[i + 1]) == pair:
out.append(symbols[i] + symbols[i + 1])
i += 2
else:
out.append(symbols[i])
i += 1
return tuple(out)
def train_bpe(counts, n_merges):
words = {tuple(w) + ('_',): c for w, c in counts.items()} # cada palabra como una lista de caracteres
merges = []
for _ in range(n_merges):
pairs = collections.Counter()
for symbols, c in words.items():
for pair in zip(symbols, symbols[1:]):
pairs[pair] += c # cuántas veces aparece cada pareja contigua
best = max(pairs, key=pairs.get)
merges.append(best)
words = {merge_pair(symbols, best): c for symbols, c in words.items()}
return merges
def tokenize(word, merges):
symbols = tuple(word) + ('_',)
for pair in merges: # aplicar las fusiones en el orden en que se aprendieron
symbols = merge_pair(symbols, pair)
return list(symbols)
merges = train_bpe(corpus_counts, n_merges=80)
print("Primeras 10 fusiones:", [a + b for a, b in merges[:10]])
subword_vocab = sorted({s for w in corpus_counts for s in tokenize(w, merges)})
print(f"Vocabulario de subpalabras: {len(subword_vocab)} tokens (el corpus tiene {len(corpus_counts)} palabras distintas)\n")
for w in test_words:
status = 'en el corpus' if w in corpus_counts else 'NUNCA VISTA'
print(f"{w:<16} ({status:<12}) -> {tokenize(w, merges)}")
Primeras 10 fusiones: ['en', 'a_', 'ci', 'e_', 'ent', 'aci', 'n_', 'hi', 'el', 'el_'] Vocabulario de subpalabras: 66 tokens (el corpus tiene 39 palabras distintas) hiperglucemia (en el corpus) -> ['hiper', 'glucemia_'] desfibrilador (en el corpus) -> ['des', 'fibril', 'a', 'do', 'r_'] hipertenso (NUNCA VISTA ) -> ['hiper', 'ten', 's', 'o_'] miocardiopatía (NUNCA VISTA ) -> ['mi', 'o', 'cardi', 'o', 'p', 'at', 'í', 'a_'] pacientes (en el corpus) -> ['pacientes_']
Las primeras fusiones son parejas frecuentes como "en", "a_" o "ci". Con 80 fusiones, BPE parte hiperglucemia en [hiper, glucemia], justo el ejemplo del libro, y desfibrilador en [des, fibril, a, do, r]. Los casos interesantes son las palabras que nunca ha visto. Hipertenso se convierte en [hiper, ten, s, o], reutilizando el prefijo hiper-, y hasta miocardiopatía se puede escribir con trozos conocidos (cardi, at) más caracteres sueltos. Ninguna palabra es "desconocida", mientras que una palabra frecuente como pacientes es un solo token.
Paso 4: De los tokens a los identificadores y los vectores¶
Cada token del vocabulario recibe un número, su identificador, y cada identificador selecciona una fila de la matriz de embeddings $E$, de tamaño $|V| \times d$ (tamaño del vocabulario por dimensión del embedding). Como explica Ana en el libro, esta matriz es simplemente una capa más de la red: sus valores empiezan al azar y se ajustan con el descenso del gradiente junto con todos los demás pesos. Aquí usamos el ejemplo del libro con $d = 7$ y una matriz todavía sin entrenar, aleatoria:
vocabulary = sorted(set(sentence.lower().split()))
token_to_id = {tok: i for i, tok in enumerate(vocabulary)}
ids = [token_to_id[t] for t in sentence.lower().split()]
rng = np.random.default_rng(0)
E = rng.normal(0, 0.5, size=(len(vocabulary), 7)) # matriz de embeddings |V| x d
vectors = E[ids] # buscar filas: eso es todo lo que hace una capa de embeddings
print(f"Identificadores: {dict(zip(sentence.lower().split(), ids))}")
print(f"Forma de la frase convertida en vectores: {vectors.shape} ({vectors.shape[0]} tokens x {vectors.shape[1]} dimensiones)")
print(vectors)
Identificadores: {'la': 2, 'paciente': 3, 'tiene': 4, 'fiebre': 1, 'alta': 0}
Forma de la frase convertida en vectores: (5, 7) (5 tokens x 7 dimensiones)
[[-0.623 -0.366 -0.272 -0.158 0.206 0.521 -0.064]
[ 0.683 -0.333 0.176 0.452 0.047 -0.372 -0.461]
[-0.229 0.11 -0.505 -0.105 -0.08 0.27 0.107]
[ 0.474 -0.352 -0.633 -0.312 0.021 -1.163 -0.109]
[ 0.063 -0.066 0.32 0.052 -0.268 0.181 0.652]]
Paso 5: Aprender embeddings a partir del contexto, word2vec¶
¿Cómo llegan los vectores a significar algo? Luis lo resume en el libro: durante el entrenamiento, los tokens que aparecen en contextos parecidos terminan cerca unos de otros. Word2vec (skip-gram con muestreo negativo) es la forma clásica de aprender embeddings solo con esa idea. Para cada palabra de un texto, mira las palabras que la rodean (su contexto) y ajusta los vectores para que:
- una palabra y un vecino real tengan un producto escalar alto, $\sigma(u_o \cdot v_c) \to 1$;
- una palabra y unas cuantas palabras al azar (muestras negativas) tengan uno bajo, $\sigma(u_k \cdot v_c) \to 0$.
Nadie le dice al modelo qué significa cada palabra. Lo entrenamos con 4000 frases sintéticas sobre pacientes, personal, lugares, fechas, síntomas y transporte:
groups = {
'staff': ['el doctor', 'el médico', 'la enfermera', 'el cirujano', 'la pediatra'],
'place': ['el hospital', 'la clínica', 'la planta', 'la farmacia', 'el laboratorio'],
'month': ['enero', 'marzo', 'mayo', 'julio', 'octubre', 'diciembre'],
'number': ['dos', 'tres', 'cuatro', 'cinco', 'siete', 'diez'],
'symptom': ['fiebre', 'tos', 'cefalea', 'náuseas', 'mareo'],
'vehicle': ['coche', 'autobús', 'camión', 'tren', 'taxi'],
'doc': ['el doctor', 'el médico'],
}
templates = [
'{staff} examinó al paciente en {place}', '{staff} trabaja en {place} desde {month}',
'el paciente consultó con {staff} por {symptom}', 'el paciente tuvo {symptom} durante {number} días',
'la cita es en {month} en {place}', 'ella fue hasta {place} en {vehicle} con {symptom}',
'él esperó {number} horas hasta que llegó {staff}', 'el {vehicle} fue hacia {place} en {number} minutos',
'en {month} {staff} atendió a {number} pacientes con {symptom}', '{staff} tomó el {vehicle} hacia {place}',
'{doc} recetó antibióticos por {symptom}', '{doc} firmó el informe de alta en {month}',
]
rng = np.random.default_rng(0)
def make_sentence():
template = templates[rng.integers(len(templates))]
return template.format(**{k: v[rng.integers(len(v))] for k, v in groups.items()}).split()
corpus = [make_sentence() for _ in range(4000)]
words = sorted({w for s in corpus for w in s})
word_id = {w: i for i, w in enumerate(words)}
print(f"Ejemplo: {' '.join(corpus[0])}")
print(f"Vocabulario: {len(words)} palabras, corpus: {sum(map(len, corpus))} tokens")
# parejas (centro, contexto) con una ventana de 2 palabras a cada lado
pairs = np.array([(word_id[s[i]], word_id[s[j]]) for s in corpus for i in range(len(s))
for j in range(max(0, i - 2), min(len(s), i + 3)) if j != i])
counts = np.bincount([word_id[w] for s in corpus for w in s], minlength=len(words))
noise = counts ** 0.75 / np.sum(counts ** 0.75) # la distribución de word2vec para las muestras negativas
print(f"Parejas de entrenamiento: {len(pairs)}")
Ejemplo: el doctor recetó antibióticos por fiebre Vocabulario: 68 palabras, corpus: 32912 tokens Parejas de entrenamiento: 107648
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def train_word2vec(pairs, n_words, dim=16, negatives=5, learning_rate=0.01, epochs=8, batch=64, seed=0):
rng = np.random.default_rng(seed)
W = rng.normal(0, 0.1, (n_words, dim)) # los embeddings que queremos (una fila por palabra)
C = np.zeros((n_words, dim)) # vectores auxiliares de "contexto"
losses = []
for _ in range(epochs):
order, total = rng.permutation(len(pairs)), 0.0
for start in range(0, len(pairs), batch):
centre, context = pairs[order[start:start + batch]].T
negative = rng.choice(n_words, (len(centre), negatives), p=noise)
v, u_pos, u_neg = W[centre], C[context], C[negative]
s_pos = sigmoid(np.sum(v * u_pos, axis=1)) # debería ir a 1
s_neg = sigmoid(np.einsum('bd,bkd->bk', v, u_neg)) # debería ir a 0
total -= np.sum(np.log(s_pos)) + np.sum(np.log(1 - s_neg))
g_pos, g_neg = (s_pos - 1)[:, None], s_neg[:, :, None] # derivadas de la pérdida
np.add.at(C, context, -learning_rate * g_pos * v)
np.add.at(C, negative.ravel(), -learning_rate * (g_neg * v[:, None, :]).reshape(-1, dim))
np.add.at(W, centre, -learning_rate * (g_pos * u_pos + np.sum(g_neg * u_neg, axis=1)))
losses.append(total / len(pairs))
return W, losses
W, losses = train_word2vec(pairs, len(words))
print("Pérdida media por época:", np.round(losses, 3))
Pérdida media por época: [2.267 1.689 1.646 1.633 1.631 1.628 1.626 1.627]
Ahora preguntamos a cada palabra por sus vecinas más cercanas, medidas con la similitud coseno de sus vectores (el paso 6 explica por qué coseno), y proyectamos los vectores de las palabras de los grupos en 2 dimensiones para verlos:
unit = W / np.linalg.norm(W, axis=1, keepdims=True)
def cosine(a, b):
return float(unit[word_id[a]] @ unit[word_id[b]])
for w in ['doctor', 'marzo', 'tres', 'fiebre', 'coche', 'hospital']:
sims = unit @ unit[word_id[w]]
nearest = [words[i] for i in np.argsort(-sims)[1:5]]
print(f"{w:<9} vecinas: {nearest}")
print()
for a, b in [('doctor', 'médico'), ('doctor', 'enfermera'), ('doctor', 'coche'), ('marzo', 'julio'), ('fiebre', 'coche')]:
print(f"coseno({a}, {b}) = {cosine(a, b):.3f}")
group_words = {g: [w.split()[-1] for w in ws] for g, ws in groups.items() if g != 'doc'}
X = np.array([unit[word_id[w]] for ws in group_words.values() for w in ws])
Xc = X - X.mean(axis=0)
coords = Xc @ np.linalg.svd(Xc, full_matrices=False)[2][:2].T # proyección en las 2 direcciones principales (PCA)
names = {'staff': 'personal', 'place': 'lugar', 'month': 'mes', 'number': 'número', 'symptom': 'síntoma', 'vehicle': 'vehículo'}
plt.figure(figsize=(8, 6))
k = 0
for g, ws in group_words.items():
pts = coords[k:k + len(ws)]
plt.scatter(*pts.T, s=40, label=names[g])
for (x, y), w in zip(pts, ws):
plt.annotate(w, (x, y), fontsize=8, xytext=(3, 3), textcoords='offset points')
k += len(ws)
plt.legend()
plt.title('Embeddings de word2vec proyectados en 2 dimensiones')
plt.show()
doctor vecinas: ['médico', 'cirujano', 'pediatra', 'enfermera'] marzo vecinas: ['octubre', 'enero', 'julio', 'mayo'] tres vecinas: ['cinco', 'diez', 'cuatro', 'dos'] fiebre vecinas: ['náuseas', 'mareo', 'cefalea', 'tos'] coche vecinas: ['tren', 'autobús', 'camión', 'taxi'] hospital vecinas: ['laboratorio', 'farmacia', 'planta', 'clínica'] coseno(doctor, médico) = 0.996 coseno(doctor, enfermera) = 0.696 coseno(doctor, coche) = 0.436 coseno(marzo, julio) = 0.990 coseno(fiebre, coche) = 0.068
Sin que nadie le diga nada de medicina ni del calendario, word2vec ha agrupado las palabras según cómo se usan: las vecinas más cercanas de doctor son el resto del personal, las de marzo son meses, las de tres son números, y así sucesivamente. Las similitudes son graduales. Doctor y médico, que aparecen exactamente en los mismos contextos, tienen un coseno de 0,996; doctor y enfermera, que comparten muchos contextos pero no todos, 0,696; doctor y coche, 0,436. En la proyección los seis grupos forman nubes separadas, como los meses y los números en la figura del libro. Los modelos reales aprenden el mismo tipo de estructura a partir de miles de millones de frases y con cientos de dimensiones.
Paso 6: Medir la similitud¶
Las notas de Marta comparan dos formas de medir lo parecidos que son dos vectores. La distancia euclídea mide la distancia en línea recta entre los puntos; la similitud coseno mide el ángulo entre los vectores, sea cual sea su longitud:
$$d(\vec{v}_1, \vec{v}_2) = \sqrt{\sum_i (v_{1i} - v_{2i})^2} \qquad\qquad \cos(\vec{v}_1, \vec{v}_2) = \frac{\vec{v}_1 \cdot \vec{v}_2}{|\vec{v}_1|\,|\vec{v}_2|}$$
Su ejemplo usa tres vectores en 2D: "doctor" $(0.8, 0.6)$, "médico" $(0.7, 0.5)$ y "coche" $(0.2, 0.9)$.
def euclidean(a, b):
return np.sqrt(np.sum((a - b) ** 2))
def cosine_similarity(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
doctor, medico, coche = np.array([0.8, 0.6]), np.array([0.7, 0.5]), np.array([0.2, 0.9])
print(f"doctor-médico: euclídea {euclidean(doctor, medico):.3f}, coseno {cosine_similarity(doctor, medico):.3f}")
print(f"doctor-coche: euclídea {euclidean(doctor, coche):.3f}, coseno {cosine_similarity(doctor, coche):.3f}")
long_medico = 3 * medico # la misma dirección, tres veces más largo
print(f"\ndoctor-(3 x médico): euclídea {euclidean(doctor, long_medico):.3f}, "
f"coseno {cosine_similarity(doctor, long_medico):.3f}")
doctor-médico: euclídea 0.141, coseno 1.000 doctor-coche: euclídea 0.671, coseno 0.759 doctor-(3 x médico): euclídea 1.581, coseno 1.000
Son los números de las notas de Marta: con las dos medidas, doctor está más cerca de médico que de coche (distancia euclídea de 0,141 frente a 0,671; coseno de 1,000 frente a 0,759). Pero si hacemos el vector de médico tres veces más largo, la distancia euclídea salta de 0,141 a 1,581, como si las dos palabras no tuvieran nada en común, mientras que el coseno sigue en 1,000: la dirección, que es lo que lleva el significado, no ha cambiado.
Marta añade un segundo argumento: en espacios de muchas dimensiones las distancias se "concentran", y la mayoría de los puntos acaban casi equidistantes. Podemos comprobarlo con vectores aleatorios: para cada dimensión medimos cuánto más lejos está el punto más lejano que el más cercano, vistos desde un punto de referencia.
rng = np.random.default_rng(1)
for dim in [2, 10, 100, 1000]:
points = rng.normal(size=(1000, dim))
d = np.linalg.norm(points[1:] - points[0], axis=1)
print(f"{dim:>5} dimensiones: distancia al más lejano / al más cercano = {d.max() / d.min():8.2f}")
2 dimensiones: distancia al más lejano / al más cercano = 91.05 10 dimensiones: distancia al más lejano / al más cercano = 3.78 100 dimensiones: distancia al más lejano / al más cercano = 1.50 1000 dimensiones: distancia al más lejano / al más cercano = 1.15
En 2 dimensiones el punto más lejano está 91 veces más lejos que el más cercano; en 1000 dimensiones, solo 1,15 veces. Cuando casi todos los puntos están a una distancia parecida, la distancia euclídea apenas distingue lo cercano de lo lejano, y ese es un motivo más para que los modelos de lenguaje comparen direcciones.
Conclusión¶
- La tokenización convierte el texto en una secuencia de identificadores. Los algoritmos de subpalabras como BPE mantienen un vocabulario manejable y pueden representar cualquier palabra, incluso una nunca vista, partiéndola en trozos conocidos.
- Una capa de embeddings es una matriz: cada identificador de token selecciona una fila, un vector de números reales que la red ajusta durante el entrenamiento.
- El significado surge del contexto: word2vec, que solo aprende a distinguir vecinos reales de palabras al azar, coloca cerca las palabras que se usan de forma parecida.
- Para comparar embeddings, la similitud coseno suele ser mejor que la distancia euclídea: mira direcciones, no longitudes, y se comporta mejor en los espacios de muchas dimensiones de los modelos de lenguaje.
- El siguiente cuaderno usa estos vectores como entrada del mecanismo que hizo posibles los grandes modelos de lenguaje: la atención.