Objetivo del módulo

Aprender a convertir palabras en características numéricas que una red neuronal pueda procesar, desde la codificación más simple hasta los embeddings. Al terminar, el lector sabrá:

Clasificar correos

Una aplicación de procesamiento del lenguaje natural (PLN) debe clasificar los correos de una empresa: trabajo, personal o publicidad no deseada. Para decidir puede fijarse en tres cosas: la presencia de ciertas palabras («factura», «oferta»), su frecuencia y el significado en contexto. Pero una red neuronal no entiende letras: solo opera con números. El primer paso de cualquier modelo de texto, desde un clasificador sencillo hasta un modelo de lenguaje grande (LLM), es convertir las palabras en vectores de características.

1. Textoel correo o la frase2. Tokenspiezas de texto3. Índicesposición en el vocabulario4. Vectoreslo que procesa la red«I like cats»Ilikecats0720,10,20,30,20,30,40,30,40,5los modelos solo operan con números: cada palabra debe convertirse en un vector de características
Fig. 1 — De la frase al vector: tokenizar, asignar un índice a cada token y convertir cada índice en un vector.

Una característica (feature) es un número que describe algún aspecto de la entrada; un vector de características es la lista de esos números. En una imagen, las características son los valores de los píxeles; en un texto hay que inventarlas, y la forma de hacerlo decide qué podrá aprender el modelo. Este módulo recorre tres respuestas, de la más simple a la que usan los modelos actuales: one-hot, bolsa de palabras y embeddings.

Corpus y vocabulario

Para verlo con un ejemplo mínimo se usan tres frases, y la tarea es identificar de qué animal habla cada una:

Un conjunto de frases, documentos o secuencias se llama corpus (o conjunto de datos). Tras tokenizar, cada token distinto recibe un índice; la lista de tokens con sus índices es el vocabulario. Aquí tiene 9 tokens, porque el tokenizador separa «I'm» en «I» y «'m». El siguiente código construye el vocabulario como lo haría torchtext: primero los tokens más frecuentes y, a igualdad, en orden alfabético.

import re from collections import Counter import torch dataset = ["I like cats", "I hate dogs", "I'm impartial to hippos"] def tokenizar(texto): # separa «I'm» en «I» y «'m», como spaCy return re.findall(r"'\w+|\w+", texto) frecuencias = Counter(t for frase in dataset for t in tokenizar(frase)) orden = sorted(frecuencias, key=lambda t: (-frecuencias[t], t)) # frecuencia, luego alfabético vocab = {token: i for i, token in enumerate(orden)} print(vocab) # {'I': 0, "'m": 1, 'cats': 2, 'dogs': 3, 'hate': 4, 'hippos': 5, 'impartial': 6, 'like': 7, 'to': 8} index = [torch.tensor([vocab[t] for t in tokenizar(f)]) for f in dataset] print(index) # [tensor([0, 7, 2]), tensor([0, 4, 3]), tensor([0, 1, 6, 8, 5])]

Qué hace el código

Cada parte tiene una función concreta:

En PyTorch los índices empiezan en 0. «I» aparece tres veces y recibe el índice 0; el resto aparece una vez y se ordena alfabéticamente (el apóstrofo va antes que las letras). Por eso «I like cats» se convierte en [0, 7, 2].

Codificación one-hot

La codificación one-hot convierte datos categóricos, como los tokens, en vectores que una red puede procesar. Cada vector tiene tantas posiciones como palabras hay en el vocabulario; todas valen 0 excepto la que corresponde al índice del token, que vale 1.

Vocabulario (9 tokens)índicetoken0I1'm2cats3dogs4hate5hippos6impartial7like8toVector one-hot de «like»tantas posiciones como palabras; un 1 en la posición 7000102030405061708Las tres frases en one-hotI100000000like000000010cats001000000«I like cats» = 3 vectores de 9 posiciones; con 50 000 palabras serían 3 × 50 000
Fig. 2 — Codificación one-hot: cada token es un vector con tantas posiciones como palabras tiene el vocabulario y un único 1.

Así, cada token de un documento se representa con su propio vector, el que la red recibe como característica x de esa palabra. Una forma de verlo: el vector one-hot es una fila de interruptores, uno por palabra del vocabulario, en la que solo se enciende el de la palabra actual. No dice nada sobre la palabra salvo cuál es.

Límites del one-hot

One-hot: todas las palabras a la misma distanciacatsdogshatelikeproducto escalar entre dos palabras distintas = 0Embeddings: la cercanía refleja el significadoanimalescatsdogshipposlikehateverbos de opinión
Fig. 3 — Los vectores one-hot no expresan parecidos; los embeddings aprendidos colocan cerca las palabras con usos similares (ilustración en 2 dimensiones).
Mini-quiz · Codificación one-hot

1. Con un vocabulario de 9 tokens, ¿cuántas posiciones tiene el vector one-hot de «like»?

2. Si «like» tiene el índice 7, ¿dónde está el 1 de su vector one-hot?

3. ¿Por qué los vectores one-hot no capturan que «cats» y «dogs» se parecen?

Ejercicio 1.1 · Codificar a mano

Con el vocabulario del ejemplo (I 0, 'm 1, cats 2, dogs 3, hate 4, hippos 5, impartial 6, like 7, to 8), escribe los vectores one-hot de «I hate dogs».

Solución

I → (1, 0, 0, 0, 0, 0, 0, 0, 0); hate → (0, 0, 0, 0, 1, 0, 0, 0, 0); dogs → (0, 0, 0, 1, 0, 0, 0, 0, 0). Cada vector tiene un único 1, en la posición del índice del token.

Ejercicio 1.2 · El coste del one-hot

Un corpus de correos tiene un vocabulario de 20 000 palabras. ¿Cuántos valores ocupa en one-hot un correo de 100 palabras y cuántos de ellos son unos?

Solución

Casi toda la memoria se gasta en ceros. Por eso en la práctica no se construyen estos vectores, sino que se trabaja directamente con los índices.

Bolsa de palabras

Con un vector por token, ¿cómo se representa un documento entero? La bolsa de palabras (bag of words) lo representa como la suma (o la media) de los vectores one-hot de sus palabras. Para «I like cats» se suman los vectores de «I», «like» y «cats»: el resultado tiene un 1 en las posiciones 0, 7 y 2.

I100000000+like000000010+cats001000000bolsa de palabras101000010=0 I1 'm2 cats3 dogs4 hate5 hippos6 impartial7 like8 toun solo vectorpor documentola suma cuentacuántas vecesaparece cadapalabrael orden se pierde: «cats like I» produciría exactamente el mismo vector
Fig. 4 — Bolsa de palabras: el documento se representa como la suma (o la media) de los vectores one-hot de sus palabras.

Con la suma, cada posición cuenta cuántas veces aparece esa palabra; con la media, el vector no depende de la longitud del documento. En ambos casos se pierde el orden: «el perro muerde al hombre» y «el hombre muerde al perro» producen la misma bolsa.

# continúa el código anterior (vocab, index) import torch.nn.functional as F uno = F.one_hot(index[0], num_classes=len(vocab)) # «I like cats»: una fila por palabra print(uno) # tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0], # [0, 0, 0, 0, 0, 0, 0, 1, 0], # [0, 0, 1, 0, 0, 0, 0, 0, 0]]) bolsa = uno.sum(dim=0) # bolsa de palabras print(bolsa) # tensor([1, 0, 1, 0, 0, 0, 0, 1, 0])

Qué hace el código

F.one_hot(indices, num_classes) convierte cada índice en un vector one-hot de num_classes posiciones; con tres índices devuelve una matriz de 3 × 9, una fila por palabra. sum(dim=0) suma esas filas (recorre la dimensión 0, la de las palabras) y deja un único vector de 9 posiciones: la bolsa de palabras. Para la media bastaría con uno.float().mean(dim=0).

La bolsa en una red

El vector de la bolsa de palabras puede ser la entrada de una red neuronal: la primera capa lineal lo transforma, las siguientes calculan una puntuación por clase y se elige la de mayor valor. Para «I like cats», la salida más alta corresponde a «gatos».

Fíjate en la primera capa: recibe 9 números, casi todos ceros. Los ceros no aportan nada a la multiplicación, así que todo el trabajo lo hacen las pocas posiciones encendidas. Esta observación es la que lleva a los embeddings.

«I like cats»1I0'm1cats0dogs0hate0hippos0impartial1like0toentrada (9)capa ocultasalida (3)−0,1perros0,1hipopótamos0,9gatosse elige la clasecon el valor más alto
Fig. 5 — Una red neuronal recibe el vector de la bolsa de palabras y asigna la frase a la clase con la puntuación más alta.
Mini-quiz · Bolsa de palabras

1. ¿Cómo se obtiene la bolsa de palabras de un documento?

2. ¿Qué información pierde la bolsa de palabras?

3. ¿Qué ventaja tiene usar la media en lugar de la suma?

Ejercicio 2.1 · Contar con la bolsa

Con el mismo vocabulario, calcula la bolsa de palabras (suma) de «I like I like cats». ¿Qué frase distinta tendría exactamente la misma bolsa?

Solución

(2, 0, 1, 0, 0, 0, 0, 2, 0): «I» y «like» aparecen dos veces y «cats» una. Cualquier reordenación, por ejemplo «cats like I like I», da el mismo vector, porque la bolsa solo cuenta.

Ejercicio 2.2 · Suma frente a media

Calcula la bolsa de palabras de «I like cats» usando la media en lugar de la suma.

Solución

Se divide la suma entre 3 palabras: (1/3, 0, 1/3, 0, 0, 0, 0, 1/3, 0) ≈ (0,33; 0; 0,33; 0; 0; 0; 0; 0,33; 0). Las posiciones activas son las mismas; solo cambia la escala.

De one-hot a embedding

Al introducir en la primera capa lineal el vector one-hot de «cats», que solo tiene un 1 en la posición 2, todos los pesos se multiplican por 0 excepto los de esa posición. La salida es, simplemente, la fila de pesos de «cats». Construir el vector one-hot y multiplicar es un rodeo: basta con el índice para leer esa fila.

one-hot de «cats» (1 × 9)001000000×matriz de embeddings W (9 × 3)I0,10,20,3'm0,60,70,8cats0,30,40,5dogs0,50,60,7hate0,40,50,6hippos0,91,01,1impartial0,70,80,9like0,20,30,4to0,80,91,0=embeddingde «cats»0,30,40,5multiplicar por un one-hot solo «selecciona» una fila: basta con el índice (2) para leerla directamente
Fig. 6 — Una capa de embedding equivale a multiplicar el vector one-hot por la matriz de pesos: el resultado es la fila de esa palabra.

La capa que recibe el índice y devuelve esa fila se llama capa de embedding, y su salida es el vector de embedding (o simplemente embedding) de la palabra. En la práctica funciona como una tabla de consulta: el índice es el número de fila y la capa devuelve lo que hay escrito en ella. La diferencia con un diccionario normal es que el contenido de la tabla no se escribe a mano, sino que se aprende: durante el entrenamiento, la retropropagación ajusta las filas de las palabras que aparecen en cada lote.

La matriz de embeddings

Los pesos de la capa forman la matriz de embeddings: tiene una fila por palabra del vocabulario y tantas columnas como la dimensión del embedding, d. Sus valores se aprenden durante el entrenamiento, como cualquier otro peso de la red.

Vocabulario de 50 000 palabras: tamaño del vector de cada palabraone-hot50 000 posiciones, 49 999 cerosembedding300 valores reales, todos informativosdensos y de baja dimensión: menos cálculo y la posibilidad de aprender similitudes
Fig. 7 — Un embedding es mucho más pequeño que un vector one-hot y todos sus valores aportan información.

nn.Embedding en PyTorch

nn.Embedding(n_embedding, embedding_dim) crea la capa a partir del número de tokens del vocabulario y la dimensión deseada. Recibe un tensor de índices y devuelve un vector por índice: para «I like cats», una matriz de 3 filas.

# continúa el código anterior import torch.nn as nn torch.manual_seed(0) embedding_dim = 3 # tamaño de cada vector n_embedding = len(vocab) # 9 palabras distintas embeds = nn.Embedding(n_embedding, embedding_dim) print(embeds) # Embedding(9, 3) i_like_cats = embeds(index[0]) # una fila por palabra print(i_like_cats.shape) # torch.Size([3, 3]) impartial_to_hippos = embeds(index[-1]) # último documento print(impartial_to_hippos.shape) # torch.Size([5, 3]) # Comprobación: one-hot × matriz de pesos = fila de la palabra uno_cats = F.one_hot(torch.tensor(vocab["cats"]), n_embedding).float() print(torch.allclose(uno_cats @ embeds.weight, embeds.weight[2])) # True

Qué hace el código

Línea a línea:

Los valores concretos de los embeddings son aleatorios hasta que se entrena la red: por eso no se muestran aquí. Lo importante son las formas y la equivalencia.

Mini-quiz · Embeddings

1. ¿Qué devuelve una capa de embedding cuando recibe el índice 7?

2. ¿Qué forma tiene la matriz de nn.Embedding(9, 3)?

3. ¿Qué ventaja tienen los embeddings frente a los vectores one-hot?

Ejercicio 3.1 · Contar parámetros

Un modelo usa un vocabulario de 50 000 palabras y embeddings de dimensión 300. ¿Cuántos parámetros tiene la capa de embedding? ¿Cuántos valores ocuparía la matriz que forman los 50 000 vectores one-hot?

Solución

La capa de embedding necesita 15 millones de parámetros; los vectores one-hot de todo el vocabulario formarían una matriz de 2500 millones de valores casi todos nulos.

Ejercicio 3.2 · Leer la matriz

Con la matriz de la ilustración (I = (0,1; 0,2; 0,3), hate = (0,4; 0,5; 0,6), dogs = (0,5; 0,6; 0,7)), ¿qué devuelve la capa de embedding para «I hate dogs»?

Solución

Una matriz de 3 × 3 con una fila por palabra: [[0,1; 0,2; 0,3], [0,4; 0,5; 0,6], [0,5; 0,6; 0,7]]. La capa no combina las filas: devuelve un vector por token.

Bolsa de embeddings

Si a la primera capa lineal se le da la bolsa de palabras en lugar de un solo one-hot, la salida es la suma de los embeddings de las palabras del documento. Una vez más, no hace falta construir la bolsa: basta con los índices.

Esto es lo que hace una bolsa de embeddings (embedding bag): recibe los índices de los tokens de un documento y devuelve un único vector que combina sus embeddings. Como no guarda los vectores intermedios, es más rápida y ahorra memoria que aplicar nn.Embedding y sumar después.

nn.Embeddingun vector por tokennn.EmbeddingBagun vector por documentoI → 00,10,20,3I → 0like → 70,20,30,4like → 7cats → 20,30,40,5cats → 2salida: forma (3, 3)tres palabras × 3 dimensionessuma omedia0,60,91,2salida con mode="sum": (0,6; 0,9; 1,2)con mode="mean" (por defecto): (0,2; 0,3; 0,4)EmbeddingBag no guarda los vectores intermedios: es más rápido y ahorra memoria
Fig. 8 — Embedding devuelve un vector por token; EmbeddingBag los combina en un único vector por documento.

nn.EmbeddingBag

Se crea igual que nn.Embedding, y el parámetro mode decide cómo se combinan los vectores: "mean" (media, el valor por defecto), "sum" (suma) o "max" (máximo por componente). Además del tensor de índices recibe offsets, la posición donde empieza cada documento; con un solo documento siempre es [0].

# continúa el código anterior bolsa_emb = nn.EmbeddingBag(n_embedding, embedding_dim, mode="sum") bolsa_emb.weight = embeds.weight # comparte la matriz para poder comparar suma = bolsa_emb(index[0], offsets=torch.tensor([0])) # un solo documento print(suma.shape) # torch.Size([1, 3]) print(torch.allclose(suma[0], embeds(index[0]).sum(dim=0))) # True: suma de filas print(torch.allclose(bolsa.float() @ embeds.weight, suma[0])) # True: bolsa × matriz

Qué hace el código

Línea a línea:

Error común

Suponer que nn.EmbeddingBag suma los embeddings. Por defecto calcula la media; para obtener la suma, que es lo que equivale a multiplicar la bolsa de palabras por la matriz, hay que indicar mode="sum". Las dos opciones son válidas, pero no dan los mismos números.

Mini-quiz · Bolsa de embeddings

1. Al pasar a una capa lineal el vector de la bolsa de palabras, ¿qué se obtiene?

2. ¿Qué modo usa nn.EmbeddingBag si no se indica nada?

3. ¿Qué forma tiene la salida de EmbeddingBag para un documento de 5 tokens con dimensión 3?

Ejercicio 4.1 · Suma y media a mano

Con la matriz de la ilustración, calcula lo que devuelve una bolsa de embeddings para «I hate dogs» con mode="sum" y con mode="mean".

Solución

La media es la suma dividida entre el número de tokens del documento.

Ejercicio 4.2 · Elegir la capa

Para cada caso, ¿conviene nn.Embedding o nn.EmbeddingBag? (a) Clasificar reseñas por sentimiento con un modelo sencillo. (b) Una red recurrente que procesa la frase palabra a palabra.

Solución

(a) EmbeddingBag: el clasificador solo necesita un vector por reseña y la bolsa es más eficiente. (b) Embedding: la red recurrente necesita un vector por palabra y en orden, justo lo que la bolsa descarta.

Varios documentos: offsets

En PLN es habitual juntar todos los documentos de un lote en un único tensor de una dimensión, sin relleno. Para que la bolsa sepa dónde termina cada documento, se le pasan los offsets: la posición inicial de cada uno.

index_flat = torch.cat(index): los tres documentos en un solo tensor00712203443506176889510posiciónoffset 0offset 3offset 6«I like cats» · 3 tokens«I hate dogs» · 3 tokens«I'm impartial to hippos» · 5 tokenslongitudes [3, 3, 5] → suma acumulada [0, 3, 6, 11] → quitar el último → offsets [0, 3, 6]cada offset marca dónde empieza un documento
Fig. 9 — Los offsets indican la posición inicial de cada documento dentro del tensor plano.
  1. Concatenar los tensores de índices con torch.cat: se obtiene index_flat.
  2. Contar los tokens de cada documento: [3, 3, 5].
  3. Calcular la suma acumulada de las longitudes, empezando en 0, con torch.cumsum, y quitar el último valor: [0, 3, 6].
# continúa el código anterior index_flat = torch.cat(index) # todos los documentos seguidos print(index_flat) # tensor([0, 7, 2, 0, 4, 3, 0, 1, 6, 8, 5]) longitudes = [len(muestra) for muestra in index] # [3, 3, 5] offset = torch.cumsum(torch.tensor([0] + longitudes), dim=0)[:-1] print(offset) # tensor([0, 3, 6]) media = nn.EmbeddingBag(n_embedding, embedding_dim) # mode="mean" por defecto media.weight = embeds.weight my_embeddings = media(index_flat, offsets=offset) print(my_embeddings.shape) # torch.Size([3, 3]): un vector por documento

Qué hace el código

Línea a línea:

Comparado con rellenar las frases hasta igualarlas, los offsets no gastan memoria en tokens de relleno. Es como guardar varias canciones seguidas en una cinta y apuntar en qué segundo empieza cada una.

Error común

Construir los offsets con la suma acumulada sin quitar el último valor, o sin empezar en 0. Si el primer offset no es 0, PyTorch da un error; si falta un offset, no hay error, pero dos documentos se mezclan en la misma bolsa y el modelo aprende de datos equivocados.

Mini-quiz · Offsets

1. ¿Qué indica cada valor de offsets?

2. Si los documentos tienen 3, 3 y 5 tokens, ¿cuáles son los offsets?

3. ¿Qué hace torch.cat(index)?

Ejercicio 5.1 · Calcular offsets

Un lote tiene cuatro documentos de 2, 5, 1 y 4 tokens. Calcula los offsets y la longitud de index_flat.

Solución

Suma acumulada empezando en 0: [0, 2, 7, 8, 12]; sin el último valor, los offsets son [0, 2, 7, 8]. index_flat tiene 12 posiciones.

import torch longitudes = torch.tensor([2, 5, 1, 4]) offsets = torch.cumsum(torch.cat([torch.tensor([0]), longitudes]), dim=0)[:-1] print(offsets) # tensor([0, 2, 7, 8]) print(longitudes.sum()) # tensor(12): longitud de index_flat
Ejercicio 5.2 · Encontrar el fallo

Con los tres documentos del ejemplo, alguien pasa offsets=torch.tensor([0, 3]). El código no da error y devuelve dos vectores. ¿Qué ha pasado?

Solución

Falta el offset 6. La primera bolsa toma las posiciones 0–2 («I like cats») y la segunda, de la 3 a la 10, mezcla «I hate dogs» con «I'm impartial to hippos». La salida tiene forma (2, 3) en lugar de (3, 3). Comprobar que haya un offset por documento evita el problema.

Proyecto: clasificar correos

Este apartado reúne todo lo aprendido en un programa completo que se puede ejecutar de principio a fin: un clasificador que asigna cada correo a trabajo, personal o spam (publicidad no deseada). Usa un corpus pequeño escrito para el ejemplo; con datos reales el código es el mismo y solo cambia la lista de correos.

correotextotokenizare indexar[0, 7, 2]EmbeddingBagun vector por correoLinearuna puntuación por claseclasetrabajo · personal · spamse entrena de extremo a extremo: los embeddings se ajustan para separar bien las clases
Fig. 10 — Un clasificador de texto mínimo: los índices de cada correo pasan por EmbeddingBag y una capa lineal.

El programa sigue los pasos del módulo en orden: tokenizar, construir el vocabulario, convertir en índices, agrupar en lotes con offsets, combinar los embeddings con una bolsa y clasificar con una capa lineal. Solo necesita PyTorch (pip install torch).

# ===== 1. Datos: correos etiquetados ===== import re from collections import Counter import torch import torch.nn as nn from torch.utils.data import DataLoader torch.manual_seed(42) CLASES = ["trabajo", "personal", "spam"] entrenamiento = [ ("adjunto el informe trimestral para la reunión del lunes", 0), ("la reunión con el cliente se mueve al jueves", 0), ("revisa el presupuesto del proyecto antes del viernes", 0), ("necesito tu informe de ventas para la presentación", 0), ("el cliente aprobó el presupuesto del proyecto", 0), ("convocatoria de reunión del equipo de proyecto", 0), ("envía la factura del proveedor a contabilidad", 0), ("la presentación para el cliente está lista", 0), ("cena el sábado en casa de mamá", 1), ("feliz cumpleaños te queremos mucho", 1), ("vamos al cine el domingo con los niños", 1), ("las fotos de las vacaciones en la playa", 1), ("mamá pregunta si vienes a comer el domingo", 1), ("te llamo esta noche para hablar de las vacaciones", 1), ("los niños quieren ir a la playa el sábado", 1), ("gracias por la cena fue un cumpleaños precioso", 1), ("has ganado un premio gratis haz clic aquí", 2), ("oferta exclusiva descuento del 90 por ciento solo hoy", 2), ("gana dinero rápido desde casa sin esfuerzo", 2), ("tu cuenta será bloqueada haz clic para verificar", 2), ("premio garantizado reclama tu regalo gratis", 2), ("descuento increíble compra ahora oferta limitada", 2), ("felicidades eres el ganador de un premio", 2), ("dinero fácil oferta gratis solo hoy", 2), ] prueba = [ ("el informe del cliente para la reunión", 0), ("presupuesto de la presentación del viernes", 0), ("comemos el domingo en casa de mamá", 1), ("fotos del cumpleaños de los niños", 1), ("haz clic y gana un regalo gratis", 2), ("oferta de dinero rápido solo hoy", 2), ] # ===== 2. Tokenizar y construir el vocabulario ===== def tokenizar(texto): return re.findall(r"\w+", texto.lower()) frecuencias = Counter(t for texto, _ in entrenamiento for t in tokenizar(texto)) itos = ["<unk>"] + sorted(frecuencias, key=lambda t: (-frecuencias[t], t)) stoi = {t: i for i, t in enumerate(itos)} def a_indices(texto): return [stoi.get(t, stoi["<unk>"]) for t in tokenizar(texto)] # ===== 3. Lotes con índices planos y offsets ===== def collate_fn(lote): etiquetas, indices, offsets = [], [], [0] for texto, etiqueta in lote: ids = torch.tensor(a_indices(texto), dtype=torch.long) etiquetas.append(etiqueta) indices.append(ids) offsets.append(ids.size(0)) offsets = torch.tensor(offsets[:-1]).cumsum(dim=0) return torch.tensor(etiquetas), torch.cat(indices), offsets cargador = DataLoader(entrenamiento, batch_size=4, shuffle=True, collate_fn=collate_fn) # ===== 4. Modelo: EmbeddingBag + capa lineal ===== class ClasificadorCorreos(nn.Module): def __init__(self, n_vocab, dim, n_clases): super().__init__() self.embedding = nn.EmbeddingBag(n_vocab, dim, mode="mean") self.salida = nn.Linear(dim, n_clases) def forward(self, indices, offsets): return self.salida(self.embedding(indices, offsets)) modelo = ClasificadorCorreos(len(itos), dim=16, n_clases=len(CLASES)) criterio = nn.CrossEntropyLoss() optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01) # ===== 5. Entrenamiento ===== for epoca in range(1, 31): total = 0.0 for etiquetas, indices, offsets in cargador: optimizador.zero_grad() perdida = criterio(modelo(indices, offsets), etiquetas) perdida.backward() optimizador.step() total += perdida.item() if epoca % 10 == 0: print(f"época {epoca}: pérdida media {total / len(cargador):.3f}") # ===== 6. Evaluación ===== modelo.eval() with torch.no_grad(): etiquetas, indices, offsets = collate_fn(prueba) predicciones = modelo(indices, offsets).argmax(dim=1) aciertos = (predicciones == etiquetas).sum().item() print(f"exactitud en prueba: {aciertos}/{len(prueba)}") # ===== 7. Clasificar correos nuevos ===== def clasificar(texto): with torch.no_grad(): ids = torch.tensor(a_indices(texto), dtype=torch.long) probs = modelo(ids, torch.tensor([0])).softmax(dim=1)[0] return CLASES[probs.argmax().item()], round(probs.max().item(), 2) print(clasificar("reclama tu premio gratis ahora")) print(clasificar("mañana enviamos el informe al cliente")) print(clasificar("las vacaciones con los niños en la playa")) # ===== 8. Qué han aprendido los embeddings ===== def parecidas(palabra, k=3): W = modelo.embedding.weight.detach() sim = torch.cosine_similarity(W[stoi[palabra]].unsqueeze(0), W) sim[stoi[palabra]] = -1 # excluir la propia palabra return [itos[i] for i in sim.topk(k).indices.tolist()] print("gratis →", parecidas("gratis")) print("reunión →", parecidas("reunión"))

Cómo funciona el proyecto

  1. Datos. Cada correo es una pareja (texto, etiqueta), con la etiqueta como índice de CLASES. Hay 24 correos de entrenamiento y 6 de prueba que el modelo no ve al entrenar.
  2. Vocabulario. Se construye solo con los datos de entrenamiento, para no «filtrar» información de la prueba. El token <unk> ocupa el índice 0, y a_indices envía a él cualquier palabra desconocida (stoi.get(t, 0)), en lugar de dar un error.
  3. Lotes. collate_fn recibe 4 correos, convierte cada uno en índices, los concatena en un único tensor y calcula los offsets con la suma acumulada, exactamente como en el apartado de offsets. Devuelve etiquetas, índices y offsets.
  4. Modelo. EmbeddingBag (media, dimensión 16) produce un vector por correo y nn.Linear lo convierte en 3 puntuaciones, una por clase.
  5. Entrenamiento. En cada lote: se ponen a cero los gradientes (zero_grad), se calcula la pérdida de entropía cruzada entre puntuaciones y etiquetas, se retropropaga (backward) y el optimizador Adam actualiza los pesos, incluidos los embeddings. Una época es una pasada completa por los datos.
  6. Evaluación. modelo.eval() y torch.no_grad() desactivan el cálculo de gradientes; argmax elige la clase con mayor puntuación y se cuentan los aciertos.
  7. Uso. clasificar procesa un correo nuevo con un único offset, [0], y softmax convierte las puntuaciones en probabilidades.
  8. Inspección. parecidas compara el embedding de una palabra con todos los demás mediante la similitud del coseno y devuelve los más cercanos.

Resultados

Esta es la salida del programa:

época 10: pérdida media 0.301 época 20: pérdida media 0.039 época 30: pérdida media 0.014 exactitud en prueba: 6/6 ('spam', 1.0) ('trabajo', 0.61) ('personal', 0.99) gratis → ['esfuerzo', 'felicidades', 'tu'] reunión → ['necesito', 'ventas', 'jueves']

Con un corpus tan pequeño, acertar 6 de 6 no garantiza que el modelo funcione con correos reales: sirve para comprobar que todas las piezas encajan. Para un uso real se necesitan miles de ejemplos y una evaluación más amplia.

Recomendaciones

Términos clave

TérminoSignificado
CorpusConjunto de frases, documentos o secuencias con el que se trabaja.
VocabularioLista de tokens distintos, cada uno con un índice entero.
Codificación one-hotVector del tamaño del vocabulario con un 1 en la posición del token y 0 en el resto.
Bolsa de palabrasRepresentación de un documento como suma o media de los one-hot de sus palabras; pierde el orden.
EmbeddingVector denso y de baja dimensión que representa una palabra; se aprende al entrenar.
Matriz de embeddingsPesos de la capa de embedding: una fila por palabra y una columna por dimensión.
Bolsa de embeddingsCapa que combina (media, suma o máximo) los embeddings de un documento en un solo vector.
OffsetsPosiciones de inicio de cada documento dentro del tensor plano de índices.
ÉpocaUna pasada completa por todos los datos de entrenamiento.
Similitud del cosenoMedida de cuánto apuntan dos vectores en la misma dirección; se usa para comparar embeddings.
Suma acumuladaSecuencia de sumas parciales (torch.cumsum); con las longitudes, da los offsets.

Síntesis del módulo

Autoevaluación

1. ¿Por qué hay que convertir las palabras en vectores antes de usar una red neuronal?

2. Con un vocabulario de 30 000 palabras, ¿cuántos unos tiene el vector one-hot de una palabra?

3. «El gato persigue al ratón» y «el ratón persigue al gato», ¿tienen la misma bolsa de palabras?

4. ¿Qué operación sustituye la capa de embedding?

5. ¿Cuántos parámetros tiene nn.Embedding(10000, 64)?

6. ¿De dónde salen los valores de un embedding?

7. Para «I like cats», ¿qué devuelven nn.Embedding y nn.EmbeddingBag con dimensión 3?

8. ¿Cómo se obtiene la suma de embeddings con nn.EmbeddingBag?

9. Documentos de 4, 2 y 6 tokens: ¿cuáles son los offsets?

10. ¿Qué ventaja tienen los offsets frente a rellenar los documentos hasta la misma longitud?