De texto crudo a embeddings para Deep Learning
Aprenderás el pipeline completo para transformar texto sin procesar en representaciones numéricas (embeddings) que una LSTM, GRU o Transformer pueda consumir. Normalización, tokenización, vectorización y embeddings preentrenados — todo con código ejecutable.
Requisitos previos
- Python 3.9+ instalado
- Conocimientos básicos de Python (strings, listas, diccionarios)
- Haber leído la teoría de Fundamentos de NLP
- Nociones básicas de PyTorch (consulta el tutorial de PyTorch)
- Opcional: GPU con CUDA (solo necesario en el paso final)
El pipeline de texto: visión general
Un modelo de deep learning no sabe leer. Solo entiende tensores de números. El objetivo de este tutorial es convertir texto legible por humanos en matrices numéricas que una LSTM, GRU o Transformer pueda procesar. El camino pasa por varias etapas encadenadas:
Instalamos las dependencias
pip install nltk spacy gensim tokenizers torch torchtext scikit-learn
python -m spacy download es_core_news_sm # modelo en español
python -m spacy download en_core_web_sm # modelo en inglés
python -c "import nltk; nltk.download('punkt_tab'); nltk.download('stopwords'); nltk.download('wordnet')"
import re, unicodedata, collections
import nltk, spacy, gensim
import numpy as np
import torch
import torch.nn as nn
# Texto de ejemplo que usaremos en todo el tutorial
raw_texts = [
"Los gatos de la Sra. García NO duermen nunca por las noches.",
"¡El café está buenísimo! Tomaré 3 tazas más hoy.",
"Machine Learning y Deep Learning son sub-áreas de la IA.",
"The cat sat on the mat. The dog sat on the log.",
]
print(f"Tenemos {len(raw_texts)} oraciones de ejemplo")
Normalización del texto
El texto crudo es ruidoso: mayúsculas arbitrarias, acentos, emojis, espacios duplicados, HTML, URLs… La normalización reduce esta variabilidad para que el modelo no trate "Gato", "GATO" y "gato" como tres palabras distintas.
2.1 Lowercasing
text = "Los Gatos de la Sra. García NO duermen nunca."
# Simplísimo: .lower()
print(text.lower())
# → los gatos de la sra. garcía no duermen nunca.
# Cuidado: en turco, lower('I') != 'i', es 'ı'. Para multi-idioma:
print(text.casefold()) # más agresivo, ideal para comparaciones
# → los gatos de la sra. garcía no duermen nunca.
2.2 Eliminar acentos y diacríticos
def remove_accents(text):
"""Convierte 'García' → 'Garcia' usando descomposición Unicode."""
nfkd = unicodedata.normalize('NFKD', text)
return ''.join(c for c in nfkd if not unicodedata.combining(c))
print(remove_accents("¡El café está buenísimo!"))
# → ¡El cafe esta buenisimo!
NFKD descompone "é" en "e" + acento combinante. Luego filtramos los combinantes.2.3 Limpieza con expresiones regulares
def clean_text(text):
"""Limpia texto: quita URLs, HTML, caracteres raros, espacios extra."""
text = re.sub(r'https?://\S+|www\.\S+', ' ', text) # URLs
text = re.sub(r'<[^>]+>', ' ', text) # HTML tags
text = re.sub(r'[^\w\sáéíóúüñ¿¡.,!?;:\-]', '', text) # emojis / símbolos raros
text = re.sub(r'\s+', ' ', text).strip() # espacios múltiples
return text
text = "Visita https://example.com y <b>mira</b> esto!! 🎉🔥"
print(clean_text(text))
# → Visita y mira esto!!
2.4 Stopwords
Las stopwords son palabras muy frecuentes que aportan poca información semántica (el, la, de, en, y…). Eliminarlas reduce el vocabulario, pero puede eliminar matices. Veamos cómo hacerlo con NLTK y spaCy:
from nltk.corpus import stopwords
# NLTK
stop_es = set(stopwords.words('spanish'))
stop_en = set(stopwords.words('english'))
print(f"Stopwords español: {len(stop_es)} → {list(stop_es)[:10]}")
# spaCy
nlp_es = spacy.load('es_core_news_sm')
doc = nlp_es("Los gatos de la señora García no duermen nunca por las noches")
tokens_sin_stop = [t.text for t in doc if not t.is_stop]
print(f"Original: {[t.text for t in doc]}")
print(f"Sin stops: {tokens_sin_stop}")
# → ['gatos', 'señora', 'García', 'duermen', 'noches']
Elimina stopwords si:
- Usas BoW / TF-IDF (modelos sparse clásicos)
- El vocabulario es demasiado grande y necesitas reducirlo
- Tareas topic modeling, búsqueda de texto
NO elimines stopwords si:
- Usas embeddings o modelos secuenciales (LSTM, Transformer)
- La tarea depende del orden y estructura gramatical (traducción, QA)
- "I am not happy" — eliminar "not" cambia el sentido
- Usas tokenización subword (el modelo aprende a ignorar las innecesarias)
Regla moderna: con LSTMs y Transformers, generalmente no eliminas stopwords. El modelo aprende qué ignorar.
2.5 Stemming y lematización
from nltk.stem import SnowballStemmer, WordNetLemmatizer
stemmer = SnowballStemmer('spanish')
words = ['gatos', 'durmiendo', 'corrieron', 'buenísimo', 'aprendizaje']
print("Stemming: ", [stemmer.stem(w) for w in words])
# → ['gat', 'durm', 'corr', 'buen', 'aprend'] (corta prefijos/sufijos)
# Lematización (solo inglés con WordNet, en español usar spaCy)
lemmatizer = WordNetLemmatizer()
words_en = ['running', 'better', 'cats', 'geese', 'was']
print("Lematiza: ", [lemmatizer.lemmatize(w, pos='v') for w in words_en])
# → ['run', 'better', 'cat', 'goose', 'be']
# Lematización en español con spaCy
doc = nlp_es("Los gatos durmieron en las noches frías")
print("spaCy lem: ", [t.lemma_ for t in doc])
# → ['el', 'gato', 'dormir', 'en', 'el', 'noche', 'frío']
2.6 Función de normalización completa
def normalize(text, lowercase=True, remove_accents_flag=False,
remove_stops=False, lemmatize=False, lang='es'):
"""Pipeline de normalización configurable."""
if lowercase:
text = text.lower()
if remove_accents_flag:
text = remove_accents(text)
# Limpiar URLs, HTML, espacios
text = re.sub(r'https?://\S+', ' ', text)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
if lemmatize or remove_stops:
nlp = spacy.load('es_core_news_sm' if lang == 'es' else 'en_core_web_sm')
doc = nlp(text)
tokens = []
for t in doc:
if remove_stops and t.is_stop:
continue
tokens.append(t.lemma_ if lemmatize else t.text)
text = ' '.join(tokens)
return text
# Ejemplo
for t in raw_texts[:2]:
print(f"Original: {t}")
print(f"Normalizado: {normalize(t, lemmatize=True, remove_stops=True)}")
print()
| Técnica | Cuándo usarla | Cuándo NO |
|---|---|---|
| Lowercase | Casi siempre | NER, case-sensitive tasks |
| Remove accents | Inglés, vocab pequeño | Español, francés, alemán |
| Remove stopwords | BoW, TF-IDF | LSTM, Transformers |
| Stemming | Búsqueda rápida | Cuando necesitas calidad |
| Lematización | BoW/TF-IDF de calidad | Modelos con embeddings |
Tokenización clásica (word‑level)
Tokenizar es dividir el texto en unidades (tokens). La forma más intuitiva es dividir por palabras. Pero definir "palabra" no es trivial: ¿es "sub-área" un token o dos? ¿Y "don't"? Veamos las opciones:
3.1 Split por espacios
text = "los gatos de la sra. garcía no duermen nunca."
# Opción más básica
tokens = text.split()
print(tokens)
# → ['los', 'gatos', 'de', 'la', 'sra.', 'garcía', 'no', 'duermen', 'nunca.']
# Problema: 'nunca.' incluye el punto como parte del token
3.2 Regex tokenizer
# Separa palabras y puntuación
tokens = re.findall(r"\w+|[^\w\s]", text)
print(tokens)
# → ['los', 'gatos', 'de', 'la', 'sra', '.', 'garcía', 'no', 'duermen', 'nunca', '.']
# Más sofisticado: mantener contracciones, abreviaturas
# Patrón inspirado en el tokenizer de GPT-2
gpt2_pattern = r"""'s|'t|'re|'ve|'m|'ll|'d| ?\w+| ?[^\s\w]+|\s+"""
text_en = "I don't think we'll arrive on-time, but I'm trying."
tokens_en = re.findall(gpt2_pattern, text_en)
print([t.strip() for t in tokens_en if t.strip()])
# → ['I', "don't", 'think', "we'll", 'arrive', 'on', '-', 'time', ',', 'but', "I'm", 'trying', '.']
3.3 NLTK word_tokenize
from nltk.tokenize import word_tokenize, TreebankWordTokenizer
# word_tokenize: basado en Punkt + reglas de Penn Treebank
tokens = word_tokenize("I don't think we'll arrive.", language='english')
print(tokens)
# → ['I', 'do', "n't", 'think', 'we', "'ll", 'arrive', '.']
# En español:
tokens_es = word_tokenize("Los gatos de la Sra. García no duermen.", language='spanish')
print(tokens_es)
# → ['Los', 'gatos', 'de', 'la', 'Sra.', 'García', 'no', 'duermen', '.']
3.4 spaCy tokenizer
nlp_es = spacy.load('es_core_news_sm')
doc = nlp_es("Machine Learning y Deep Learning son sub-áreas de la IA.")
# Tokens con metadata rica
for token in doc:
print(f" {token.text:15s} pos={token.pos_:6s} lemma={token.lemma_:15s} stop={token.is_stop}")
# Solo los textos
tokens = [t.text for t in doc]
print(tokens)
# → ['Machine', 'Learning', 'y', 'Deep', 'Learning', 'son', 'sub', '-', 'áreas', 'de', 'la', 'IA', '.']
nlp(text).
Comparativa de tokenizadores word-level
| Tokenizador | Velocidad | Calidad | Idiomas | Ideal para |
|---|---|---|---|---|
str.split() | ⚡⚡⚡ | Baja | Todos | Prototipado rápido |
re.findall() | ⚡⚡⚡ | Media | Todos | Control total |
| NLTK | ⚡⚡ | Alta | ~20 | Análisis lingüístico |
| spaCy | ⚡⚡⚡ | Muy alta | ~70 | Producción NLP |
La tokenización por palabras tiene problemas fundamentales:
- Vocabulario abierto: cualquier palabra nueva (typo, neologismo, nombre propio)
es un
<UNK>desconocido. - Vocabulario enorme: el inglés tiene ~170K formas; el español, mucho más (por la conjugación verbal). Vocab grande = embedding layer enorme.
- Morfología ignorada: "jugar", "jugando", "jugué" se tratan como 3 tokens independientes, sin compartir información.
- Idiomas aglutinantes: en turco, finés o alemán una "palabra" puede ser una frase entera.
La solución moderna: tokenización subword (siguiente paso).
Tokenización subword
La tokenización subword encuentra un punto medio entre caracteres y palabras completas. La idea: las palabras más frecuentes se mantienen enteras, mientras que las raras se descomponen en fragmentos reutilizables. Así, el vocabulario es manejable y se pueden representar todas las palabras (incluso nunca vistas).
["des", "##afortuna", "##da", "##mente"]. Cada fragmento se reutiliza
en "deshacer", "afortunado", "rápidamente", etc. Se comparten representaciones.
BPE
Byte-Pair Encoding
Algoritmo: merge iterativo del par de tokens más frecuente.
Usado en: GPT-2, GPT-3, RoBERTa, LLaMA.
Paper: Sennrich et al. (2016)
WordPiece
Likelihood-based
Algoritmo: merge maximizando la verosimilitud del corpus.
Usado en: BERT, DistilBERT, Electra.
Prefijo: ## para fragmentos que no inician palabra.
Unigram LM
SentencePiece
Algoritmo: empieza con vocab grande, poda tokens menos útiles.
Usado en: T5, ALBERT, mBART, XLNet.
Paper: Kudo (2018)
4.1 BPE paso a paso
from collections import Counter, defaultdict
def get_stats(vocab):
"""Cuenta frecuencia de cada par consecutivo de símbolos."""
pairs = Counter()
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i+1])] += freq
return pairs
def merge_vocab(pair, vocab):
"""Fusiona un par de símbolos en todo el vocabulario."""
out = {}
bigram = ' '.join(pair)
replacement = ''.join(pair)
for word, freq in vocab.items():
new_word = word.replace(bigram, replacement)
out[new_word] = freq
return out
# Corpus (cada palabra se representa como chars separados + símbolo de fin ◁)
corpus = {"l o w ◁": 5, "l o w e r ◁": 2, "n e w e s t ◁": 6, "w i d e s t ◁": 3}
num_merges = 10
for i in range(num_merges):
pairs = get_stats(corpus)
if not pairs:
break
best = max(pairs, key=pairs.get)
corpus = merge_vocab(best, corpus)
print(f"Merge {i+1}: {best[0]} + {best[1]} → {''.join(best)} (freq={pairs[best]})")
get_stats recorre el vocabulario contando cuántas veces aparece cada par consecutivo.4.2 BPE con HuggingFace tokenizers
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# 1. Configurar tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
# 2. Configurar trainer
trainer = BpeTrainer(
vocab_size=8000,
min_frequency=2,
special_tokens=["[PAD]", "[UNK]", "[BOS]", "[EOS]"]
)
# 3. Entrenar en archivos de texto (o lista de strings)
# tokenizer.train(["mi_corpus.txt"], trainer)
# Alternativa: entrenar desde un iterador
corpus_iter = [
"El aprendizaje profundo ha revolucionado el procesamiento del lenguaje natural.",
"Las redes neuronales recurrentes procesan secuencias de tokens.",
"Los transformers utilizan mecanismos de atención para capturar dependencias.",
"El preprocesamiento del texto es crucial para obtener buenos embeddings.",
] * 100 # Repetimos para tener suficientes datos
tokenizer.train_from_iterator(corpus_iter, trainer)
print(f"Tamaño del vocabulario: {tokenizer.get_vocab_size()}")
# 4. Codificar texto
output = tokenizer.encode("Las redes neuronales profundas generan embeddings densos.")
print(f"Tokens : {output.tokens}")
print(f"IDs : {output.ids}")
["pro", "fun", "das"]. Con millones de frases de entrenamiento
y vocab_size=32K, palabras frecuentes como "profundas" serían un solo token.
4.3 WordPiece (BERT)
from transformers import BertTokenizer
tok = BertTokenizer.from_pretrained('bert-base-multilingual-cased')
text = "El aprendizaje profundo procesa representaciones vectoriales."
encoding = tok(text, return_tensors='pt')
tokens = tok.convert_ids_to_tokens(encoding['input_ids'][0])
print(f"Tokens : {tokens}")
print(f"IDs : {encoding['input_ids'][0].tolist()}")
print(f"Attn mask: {encoding['attention_mask'][0].tolist()}")
# Decodificar de vuelta a texto
decoded = tok.decode(encoding['input_ids'][0], skip_special_tokens=True)
print(f"Decoded : {decoded}")
[CLS] al inicio y [SEP] al final automáticamente.["vector", "##iales"]. El prefijo ## indica que es continuación.4.4 SentencePiece (Unigram)
import sentencepiece as spm
# Entrenar un modelo SentencePiece
# spm.SentencePieceTrainer.Train(
# input='mi_corpus.txt',
# model_prefix='sp_model',
# vocab_size=8000,
# model_type='unigram' # o 'bpe'
# )
# Cargar modelo preentrenado (ejemplo: modelo T5 de HuggingFace)
from transformers import T5Tokenizer
tok_t5 = T5Tokenizer.from_pretrained('t5-small')
text = "Neural networks learn distributed representations of words."
tokens = tok_t5.tokenize(text)
print(f"Tokens: {tokens}")
# → ['▁Neural', '▁networks', '▁learn', '▁distribute', 'd', '▁representations', '▁of', '▁words', '.']
ids = tok_t5.encode(text)
print(f"IDs: {ids}")
▁ (U+2581) para marcar el inicio de una palabra.
A diferencia de WordPiece que marca los fragmentos interiores con ##,
SentencePiece marca el inicio. Así puede reconstruir el texto original
incluyendo espacios, sin necesidad de un pre-tokenizador.
Comparativa de métodos subword
| Método | Dirección | Marca especial | Vocab típico | Modelos conocidos |
|---|---|---|---|---|
| BPE | Bottom-up (merge) | Varía (Ġ en GPT-2) | 30K–50K | GPT-2/3, RoBERTa, LLaMA |
| WordPiece | Bottom-up (merge) | ## para continuación |
30K | BERT, Electra |
| Unigram | Top-down (prune) | ▁ para inicio |
32K | T5, ALBERT, XLNet |
| Byte-level BPE | Bottom-up (bytes) | Ninguna (byte is token) | 50K–100K | GPT-4, LLaMA 2/3 |
- Si entrenas tu propio modelo desde cero: BPE con la librería
tokenizersde HuggingFace es la opción más flexible y rápida. También puedes usar SentencePiece (Unigram) si quieres independencia del idioma. - Si usas un modelo preentrenado: siempre usa el tokenizador que viene con el modelo. Un BERT espera WordPiece; un GPT-2 espera BPE. Mezclar tokenizadores rompe las representaciones.
- Si entrenas un modelo LSTM/GRU personalizado: BPE con vocab 8K–16K es un buen punto de partida. Puedes comparar con word-level + GloVe.
- Si tienes muchos idiomas: SentencePiece (Unigram) funciona muy bien sin necesidad de reglas específicas por idioma.
Construcción de vocabulario
Antes de convertir tokens en números necesitamos un vocabulario: un
diccionario que asigna un ID único a cada token. El vocabulario define qué tokens
conoce el modelo y cuáles serán <UNK>.
5.1 Tokens especiales
| Token | ID típico | Propósito |
|---|---|---|
<PAD> | 0 | Relleno para igualar longitudes en un batch. |
<UNK> | 1 | Palabra desconocida (fuera de vocabulario, OOV). |
<BOS> | 2 | Inicio de secuencia (Beginning Of Sequence). |
<EOS> | 3 | Fin de secuencia (End Of Sequence). |
5.2 Vocabulario manual con Counter
from collections import Counter
# Supongamos que ya tenemos todos los tokens de nuestro corpus
all_tokens = [
"el", "gato", "se", "sentó", "en", "la", "alfombra",
"el", "perro", "se", "sentó", "en", "el", "sofá",
"la", "gata", "durmió", "en", "la", "alfombra",
]
# Contar frecuencias
freq = Counter(all_tokens)
print("Frecuencias:", freq.most_common())
# Definir tokens especiales
SPECIALS = ["", "", "", ""]
# Filtrar por frecuencia mínima
min_freq = 1
filtered = [tok for tok, count in freq.most_common() if count >= min_freq]
# Construir mapeos
token2id = {tok: i for i, tok in enumerate(SPECIALS + filtered)}
id2token = {i: tok for tok, i in token2id.items()}
print(f"\nVocabulario ({len(token2id)} tokens):")
for tok, idx in token2id.items():
print(f" {idx:3d} → '{tok}'" + (f" (freq={freq[tok]})" if tok in freq else ""))
5.3 Clase Vocabulary reutilizable
class Vocabulary:
"""Vocabulario con soporte para tokens especiales y OOV."""
def __init__(self, min_freq=1, specials=None):
self.min_freq = min_freq
self.specials = specials or ["", "", "", ""]
self.token2id = {}
self.id2token = {}
self.freq = Counter()
self._built = False
def build(self, token_lists):
"""Construye el vocabulario a partir de listas de tokens."""
for tokens in token_lists:
self.freq.update(tokens)
# Asignar IDs a tokens especiales primero
for i, tok in enumerate(self.specials):
self.token2id[tok] = i
# Añadir tokens que superen min_freq
idx = len(self.specials)
for tok, count in self.freq.most_common():
if count >= self.min_freq and tok not in self.token2id:
self.token2id[tok] = idx
idx += 1
self.id2token = {i: t for t, i in self.token2id.items()}
self._built = True
return self
def __len__(self):
return len(self.token2id)
def encode(self, tokens):
"""Convierte lista de tokens a lista de IDs."""
unk_id = self.token2id[""]
return [self.token2id.get(t, unk_id) for t in tokens]
def decode(self, ids):
"""Convierte lista de IDs a lista de tokens."""
return [self.id2token.get(i, "") for i in ids]
# Uso
corpus_tokenized = [
["el", "gato", "se", "sentó", "en", "la", "alfombra"],
["el", "perro", "se", "sentó", "en", "el", "sofá"],
["la", "gata", "durmió", "en", "la", "alfombra"],
]
vocab = Vocabulary(min_freq=2).build(corpus_tokenized)
print(f"Vocab size: {len(vocab)}")
# → Vocab size: 10 (4 specials + 6 tokens con freq >= 2)
ids = vocab.encode(["el", "gato", "durmió", "en", "una", "caja"])
print(f"IDs: {ids}")
# → IDs: [4, 1, 1, 7, 1, 1]
# "gato", "durmió", "una", "caja" → (ID=1) porque freq < 2 o no visto
print(f"Decoded: {vocab.decode(ids)}")
# → Decoded: ['el', '', '', 'en', '', '']
min_freq controla cuántas veces debe aparecer un token para entrar al vocabulario. Es el control principal del tamaño.encode mapea tokens a IDs, usando el ID de <UNK> para tokens desconocidos.5.4 Vocabulario con torchtext
from torchtext.vocab import build_vocab_from_iterator
def yield_tokens(data):
"""Generador que produce listas de tokens."""
for sentence in data:
yield sentence # ya está tokenizado
corpus_tokenized = [
["el", "gato", "se", "sentó", "en", "la", "alfombra"],
["el", "perro", "se", "sentó", "en", "el", "sofá"],
["la", "gata", "durmió", "en", "la", "alfombra"],
]
vocab_tt = build_vocab_from_iterator(
yield_tokens(corpus_tokenized),
min_freq=1,
specials=["", "", "", ""]
)
vocab_tt.set_default_index(vocab_tt[""]) # OOV →
print(f"Vocab size: {len(vocab_tt)}")
# → Vocab size: 15
# Codificar una frase
ids = vocab_tt(["el", "gato", "duerme", "en", "la", "cama"])
print(f"IDs: {ids}")
# → IDs: [5, 10, 1, 4, 6, 1] ("duerme" y "cama" → =1)
- min_freq demasiado alto: Muchas palabras →
<UNK>. El modelo pierde información. - min_freq demasiado bajo: Vocabulario enorme. La capa de embedding consume mucha memoria y las palabras raras tienen embeddings poco entrenados.
- Regla práctica: Para word-level, vocab 20K–50K funciona bien. Para subword, 8K–32K.
La ley de Zipf establece que la frecuencia de una palabra es inversamente proporcional a su ranking. En la práctica, esto significa que:
- Un puñado de palabras ("el", "de", "la", "en") aparecen millones de veces.
- La inmensa mayoría de palabras aparecen 1–2 veces (hapax legomena).
- ~50% del vocabulario único aparece solo 1 vez en un corpus típico.
Esto justifica usar min_freq ≥ 2: las palabras que solo aparecen una vez
nunca tendrán embeddings bien entrenados y es mejor mapearlas a <UNK>
o usar tokenización subword.
Numericalización y padding
Ya tenemos tokens y vocabulario. Ahora convertimos cada token en su ID numérico (numericalización) y ajustamos todas las secuencias a la misma longitud (padding) para poder agruparlas en tensores.
6.1 Numericalización básica
import torch
# Vocabulario del paso anterior
# vocab.encode(tokens) → [ID, ID, ...]
corpus_tokenized = [
["el", "gato", "se", "sentó", "en", "la", "alfombra"],
["el", "perro", "se", "sentó", "en", "el", "sofá"],
["la", "gata", "durmió", "en", "la", "alfombra"],
]
# Numericalizar
sequences = [torch.tensor(vocab.encode(tokens), dtype=torch.long)
for tokens in corpus_tokenized]
for i, seq in enumerate(sequences):
print(f"Seq {i}: {seq.tolist()} (len={len(seq)})")
# Seq 0: [4, 10, 7, 8, 5, 6, 9] (len=7)
# Seq 1: [4, 11, 7, 8, 5, 4, 12] (len=7)
# Seq 2: [6, 13, 14, 5, 6, 9] (len=6) ← más corta!
6.2 Padding con PyTorch
from torch.nn.utils.rnn import pad_sequence
# pad_sequence espera una lista de tensores 1D
# batch_first=True → shape (batch, seq_len)
# padding_value=0 → usa el ID de
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
print(f"Shape: {padded.shape}") # → torch.Size([3, 7])
print(padded)
# tensor([[ 4, 10, 7, 8, 5, 6, 9],
# [ 4, 11, 7, 8, 5, 4, 12],
# [ 6, 13, 14, 5, 6, 9, 0]]) ← último con PAD
# Máscara de atención: 1 donde hay token real, 0 donde hay PAD
attention_mask = (padded != 0).long()
print(f"Attention mask:\n{attention_mask}")
# tensor([[1, 1, 1, 1, 1, 1, 1],
# [1, 1, 1, 1, 1, 1, 1],
# [1, 1, 1, 1, 1, 1, 0]])
pad_sequence rellena las secuencias cortas hasta la longitud de la más larga del batch.attention_mask es crucial: le dice al modelo qué posiciones ignorar al calcular la loss y atención.6.3 Truncamiento
MAX_LEN = 128 # Longitud máxima permitida
def truncate(ids, max_len, strategy='tail'):
"""Trunca una secuencia de IDs.
Estrategias:
'tail' → conserva los primeros max_len tokens (más común)
'head' → conserva los últimos max_len tokens
'both' → conserva inicio y final, descarta el medio
"""
if len(ids) <= max_len:
return ids
if strategy == 'tail':
return ids[:max_len]
elif strategy == 'head':
return ids[-max_len:]
elif strategy == 'both':
half = max_len // 2
return ids[:half] + ids[-(max_len - half):]
else:
raise ValueError(f"Estrategia desconocida: {strategy}")
# Ejemplo
long_seq = list(range(200)) # Secuencia de 200 tokens
print(f"tail: len={len(truncate(long_seq, 10, 'tail'))}, tokens={truncate(long_seq, 10, 'tail')}")
print(f"head: len={len(truncate(long_seq, 10, 'head'))}, tokens={truncate(long_seq, 10, 'head')}")
print(f"both: len={len(truncate(long_seq, 10, 'both'))}, tokens={truncate(long_seq, 10, 'both')}")
6.4 Collate function completa para DataLoader
from torch.utils.data import Dataset, DataLoader
class TextDataset(Dataset):
def __init__(self, texts, labels, vocab, tokenize_fn, max_len=128):
self.texts = texts
self.labels = labels
self.vocab = vocab
self.tokenize_fn = tokenize_fn
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
tokens = self.tokenize_fn(self.texts[idx])
ids = self.vocab.encode(tokens)
ids = ids[:self.max_len] # truncar
return torch.tensor(ids, dtype=torch.long), self.labels[idx]
def collate_fn(batch):
"""Agrupa secuencias de diferente longitud en un batch."""
sequences, labels = zip(*batch)
# Padding
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
# Máscara
mask = (padded != 0).long()
# Labels a tensor
labels = torch.tensor(labels, dtype=torch.long)
# Longitudes originales (útil para pack_padded_sequence)
lengths = torch.tensor([len(s) for s in sequences], dtype=torch.long)
return padded, mask, labels, lengths
# Ejemplo de uso
texts = ["el gato duerme", "la gata corre por la casa", "el perro ladra"]
labels = [0, 1, 0]
dataset = TextDataset(texts, labels, vocab, tokenize_fn=str.split, max_len=128)
loader = DataLoader(dataset, batch_size=2, shuffle=True, collate_fn=collate_fn)
for padded, mask, labs, lengths in loader:
print(f"Batch shape: {padded.shape}, Lengths: {lengths.tolist()}")
print(f"Padded:\n{padded}")
print(f"Mask:\n{mask}")
print(f"Labels: {labs.tolist()}")
break
torch.nn.utils.rnn.pack_padded_sequence. Empaqueta el tensor padded
de forma que la RNN no procese los tokens de padding. Veremos esto en el paso 9
al integrar todo en un modelo.
Si las secuencias tienen longitudes muy variadas (5 tokens vs 500 tokens), el padding desperdicia mucha memoria y cómputo. Soluciones:
- Bucket Sampler: Agrupa secuencias de longitudes similares en el
mismo batch. Reduce el padding promedio en ~60%.
torchtext.data.BucketIteratorhace exactamente esto. - Dynamic batching: En vez de un batch_size fijo, define un presupuesto máximo de tokens por batch (e.g., 4096 tokens). Batches con secuencias cortas tendrán más ejemplos; batches con secuencias largas, menos.
- SortedSampler: Ordena el dataset por longitud y toma bloques consecutivos como batches. Más simple que bucket sampler.
Representaciones sparse
Antes de los embeddings densos, los textos se representaban como vectores sparse (la mayoría de componentes son 0). Aunque en deep learning preferimos embeddings densos, entender BoW y TF-IDF es fundamental: siguen siendo útiles como baselines y como features auxiliares.
7.1 One-Hot Encoding
import torch
import torch.nn.functional as F
# Si nuestro vocabulario tiene V tokens, cada token es un vector de dimensión V
# con un 1 en su posición y 0 en el resto.
vocab_size = len(vocab) # e.g., 15
ids = torch.tensor([4, 10, 7]) # "el gato se"
one_hot = F.one_hot(ids, num_classes=vocab_size)
print(f"Shape: {one_hot.shape}") # → (3, 15)
print(one_hot)
# tensor([[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ← "el" (ID=4)
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], ← "gato" (ID=10)
# [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]]) ← "se" (ID=7)
- Dimensionalidad: Con vocab de 50K, cada token es un vector de 50K. Imposible para secuencias largas.
- Sin semántica:
cos(one_hot("gato"), one_hot("perro")) = 0. No captura similitud. - Sin orden: No preserva información posicional.
7.2 Bag of Words (BoW)
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
"el gato se sentó en la alfombra",
"el perro se sentó en el sofá",
"la gata durmió en la alfombra",
]
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(corpus)
print("Vocabulario:", vectorizer.get_feature_names_out())
print(f"Shape: {X.shape}") # → (3, 10) — 3 documentos, 10 palabras únicas
print(f"Sparse matrix:\n{X.toarray()}")
# [[1 0 1 1 0 1 1 1 0 1] ← doc 0
# [0 0 2 0 0 1 1 0 1 1] ← doc 1 ("el" aparece 2 veces)
# [1 1 0 1 1 1 0 0 0 0]] ← doc 2
7.3 TF-IDF
TF-IDF (Term Frequency × Inverse Document Frequency) pondera cada palabra por lo informativa que es: palabras muy comunes en todos los documentos (como "el", "de") reciben un peso bajo, mientras que palabras distintivas reciben un peso alto.
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
"el gato se sentó en la alfombra",
"el perro se sentó en el sofá",
"la gata durmió en la alfombra",
]
tfidf = TfidfVectorizer()
X_tfidf = tfidf.fit_transform(corpus)
print("Features:", tfidf.get_feature_names_out())
print(f"\nTF-IDF matrix (redondeado):")
import numpy as np
for i, doc in enumerate(corpus):
vec = X_tfidf[i].toarray()[0]
nonzero = [(tfidf.get_feature_names_out()[j], round(vec[j], 3))
for j in vec.nonzero()[0]]
print(f" Doc {i}: {nonzero}")
TF(t, d) = count(t in d) / |d|IDF(t) = log(N / df(t)) donde N = nº documentos, df(t) = nº docs que contienen tTF-IDF(t, d) = TF(t, d) × IDF(t)
7.4 N-gramas
# Incluir unigramas y bigramas → captura algo de orden local
tfidf_ng = TfidfVectorizer(ngram_range=(1, 2), max_features=20)
X_ng = tfidf_ng.fit_transform(corpus)
print("Features con bigramas:")
print(tfidf_ng.get_feature_names_out())
# → ['alfombra' 'durmió' 'durmió en' 'el gato' 'el perro' 'el sofá'
# 'en el' 'en la' 'gata' 'gata durmió' 'gato' 'gato se' 'la alfombra'
# 'la gata' 'perro' 'perro se' 'se sentó' 'sentó en' 'sofá']
Comparativa: sparse vs dense
| Aspecto | One-Hot / BoW / TF-IDF | Embeddings densos |
|---|---|---|
| Dimensión | = |V| (miles-millones) | 50–1024 (fija) |
| Sparsity | ~99.9% ceros | Densos (sin ceros) |
| Semántica | No captura similitud | Palabras similares → vectores cercanos |
| Entrenamiento | No requiere (determinístico) | Requiere datos o modelo preentrenado |
| Memoria | Eficiente (sparse format) | Eficiente (dimensión baja) |
| Uso en DL | Baselines, features extra | Input principal del modelo |
Siempre. TF-IDF + Logistic Regression es un baseline sorprendentemente fuerte:
- En clasificación de textos con datos limitados (< 5K ejemplos), TF-IDF + SVM/LR a menudo supera a LSTMs y a veces incluso a BERT fine-tuned.
- Es inmediato de entrenar (segundos vs horas).
- Es interpretable: puedes ver qué palabras contribuyen a cada clase.
- Sirve como referencia: si tu modelo DL no supera a TF-IDF + LR, algo va mal.
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
baseline = Pipeline([
('tfidf', TfidfVectorizer(ngram_range=(1, 2), max_features=50000)),
('clf', LogisticRegression(max_iter=1000))
])
baseline.fit(X_train_texts, y_train)
accuracy = baseline.score(X_test_texts, y_test)
print(f"Baseline TF-IDF + LR: {accuracy:.3f}")
Embeddings preentrenados
Los embeddings densos representan cada token como un vector de baja dimensionalidad (normalmente 50–300 dimensiones) donde las relaciones semánticas se codifican como distancias y direcciones. La revolución vino cuando se demostró que estos vectores se pueden preentrenar en corpus masivos y reutilizar.
8.1 Word2Vec con Gensim
from gensim.models import Word2Vec
# Corpus tokenizado (lista de listas de tokens)
corpus = [
["el", "gato", "se", "sentó", "en", "la", "alfombra"],
["el", "perro", "se", "sentó", "en", "el", "sofá"],
["el", "gato", "y", "el", "perro", "juegan", "juntos"],
["la", "gata", "durmió", "en", "la", "alfombra"],
# ... en producción necesitarás millones de frases
]
model = Word2Vec(
sentences=corpus,
vector_size=100, # dimensión del embedding
window=5, # contexto: 5 palabras a cada lado
min_count=1, # incluir todas las palabras
workers=4, # threads
sg=1, # 1=Skip-gram, 0=CBOW
epochs=100, # iteraciones sobre el corpus
)
# Obtener vector de una palabra
vec = model.wv['gato']
print(f"Vector de 'gato': shape={vec.shape}") # → (100,)
# Palabras más similares
print(model.wv.most_similar('gato', topn=3))
# → [('perro', 0.92), ('gata', 0.87), ...]
# Analogías: rey - hombre + mujer = ?
# (con suficientes datos)
# model.wv.most_similar(positive=['rey', 'mujer'], negative=['hombre'])
vector_size=100: cada palabra será un vector ℝ¹⁰⁰. Valores típicos: 50, 100, 200, 300.min_count: palabras con frecuencia menor se ignoran. Con corpus grandes, usar 5-10.8.2 Cargar GloVe preentrenado
import numpy as np
def load_glove(path, dim=300):
"""Carga embeddings GloVe desde archivo de texto.
Descargar de: https://nlp.stanford.edu/projects/glove/
Archivos comunes:
- glove.6B.zip (Wikipedia 2014, 6B tokens, 50/100/200/300d)
- glove.42B.300d.zip (Common Crawl, 42B tokens)
- glove.840B.300d.zip (Common Crawl, 840B tokens)
"""
embeddings = {}
with open(path, 'r', encoding='utf-8') as f:
for line in f:
parts = line.strip().split()
word = parts[0]
vec = np.array(parts[1:], dtype=np.float32)
if len(vec) == dim:
embeddings[word] = vec
print(f"Cargados {len(embeddings)} vectores de dimensión {dim}")
return embeddings
# Uso
glove = load_glove('glove.6B.300d.txt', dim=300)
# → Cargados 400000 vectores de dimensión 300
# Ejemplo de similitud
from numpy.linalg import norm
def cosine_sim(a, b):
return np.dot(a, b) / (norm(a) * norm(b))
print(f"cos(cat, dog) = {cosine_sim(glove['cat'], glove['dog']):.3f}") # ~0.80
print(f"cos(cat, car) = {cosine_sim(glove['cat'], glove['car']):.3f}") # ~0.30
print(f"cos(king, queen) = {cosine_sim(glove['king'], glove['queen']):.3f}") # ~0.75
8.3 FastText (subword embeddings)
from gensim.models import FastText
# FastText = Word2Vec + n-gramas de caracteres
# "gato" → {"<ga", "gat", "ato", "to>"} + "gato"
# Ventaja: puede generar embeddings para palabras OOV
model_ft = FastText(
sentences=corpus,
vector_size=100,
window=5,
min_count=1,
sg=1,
epochs=100,
min_n=3, # longitud mínima de n-grama de chars
max_n=6, # longitud máxima de n-grama de chars
)
# Funciona con palabras nunca vistas (!)
vec_gatito = model_ft.wv['gatito'] # No está en el corpus, pero funciona
print(f"OOV 'gatito': shape={vec_gatito.shape}") # → (100,)
# Cargar FastText preentrenado (157 idiomas)
# from gensim.models.fasttext import load_facebook_vectors
# ft_es = load_facebook_vectors('cc.es.300.bin') # Español, 300d, ~8GB
cc.es.300.bin) tiene 2M de palabras en 300 dimensiones.
8.4 Construir la matriz de embeddings para PyTorch
Para usar embeddings preentrenados en una nn.Embedding de PyTorch,
necesitamos construir una matriz (vocab_size, embed_dim) donde cada
fila es el vector preentrenado del token correspondiente.
import torch
import numpy as np
def build_embedding_matrix(vocab, pretrained_vectors, embed_dim=300):
"""Crea una matriz de embeddings alineada con el vocabulario.
Args:
vocab: dict token→id o Vocabulary con token2id
pretrained_vectors: dict word→np.array (e.g., GloVe)
embed_dim: dimensión de los embeddings
Returns:
torch.FloatTensor de shape (vocab_size, embed_dim)
"""
token2id = vocab.token2id if hasattr(vocab, 'token2id') else vocab
vocab_size = len(token2id)
# Inicializar con distribución normal (para tokens sin vector preentrenado)
matrix = np.random.normal(scale=0.6, size=(vocab_size, embed_dim))
# El token siempre debe ser ceros
matrix[0] = np.zeros(embed_dim)
found = 0
for token, idx in token2id.items():
vec = pretrained_vectors.get(token)
if vec is not None and len(vec) == embed_dim:
matrix[idx] = vec
found += 1
coverage = found / vocab_size * 100
print(f"Cobertura: {found}/{vocab_size} ({coverage:.1f}%) tokens con embeddings preentrenados")
return torch.FloatTensor(matrix)
# Uso
embedding_matrix = build_embedding_matrix(vocab, glove, embed_dim=300)
print(f"Embedding matrix shape: {embedding_matrix.shape}")
# → Embedding matrix shape: torch.Size([15, 300])
# → Cobertura: 11/15 (73.3%) tokens con embeddings preentrenados
<UNK>, <BOS>, <EOS> y palabras OOV.<PAD> (ID=0) debe ser todo ceros para que el padding no contribuya al cálculo.Comparativa de embeddings preentrenados
| Embedding | Algoritmo | OOV | Dimensiones | Entrenado en |
|---|---|---|---|---|
| Word2Vec | Skip-gram / CBOW | ❌ No | 100–300 | Google News (100B) |
| GloVe | Co-ocurrencia global | ❌ No | 50–300 | Wikipedia + Common Crawl |
| FastText | Skip-gram + char n-grams | ✅ Sí | 300 | Common Crawl (157 idiomas) |
| ELMo | BiLSTM contextual | ✅ (char-based) | 1024 | 1B Word Benchmark |
Word2Vec, GloVe y FastText generan embeddings estáticos: cada palabra tiene un único vector sin importar el contexto. La palabra "banco" tiene el mismo vector en "me senté en el banco" y "fui al banco a depositar".
Los modelos contextuales (ELMo, BERT, GPT) generan un vector diferente para cada ocurrencia, dependiendo del contexto. Esto los hace mucho más potentes para desambiguación, pero requieren pasar el texto por el modelo completo.
En este tutorial nos centramos en embeddings estáticos + nn.Embedding
porque son la base para modelos LSTM/GRU personalizados. Si usas un transformer
preentrenado, el tokenizador y los embeddings vienen integrados.
- No normalizar al mismo case: GloVe tiene "cat" pero no "Cat". Si tu vocab incluye "Cat", no encontrará el vector. Normaliza a minúsculas antes.
- Dimensión inconsistente: Mezclar GloVe 100d con tu modelo de 300d. Verifica que coincidan.
- Baja cobertura: Si < 50% de tu vocab tiene vector preentrenado, los embeddings aleatorios dominarán y el modelo se comportará como si no hubiera preentrenamiento.
Embeddings entrenables con nn.Embedding
torch.nn.Embedding es una tabla de lookup diferenciable: mapea IDs enteros
a vectores densos y se entrena junto con el resto del modelo via backpropagation.
Podemos inicializarla desde cero o con vectores preentrenados.
9.1 Embedding desde cero
import torch
import torch.nn as nn
vocab_size = 15 # tokens en nuestro vocabulario
embed_dim = 64 # dimensión del vector embedding
pad_idx = 0 # ID del token
# Crear capa de embedding
embedding = nn.Embedding(
num_embeddings=vocab_size,
embedding_dim=embed_dim,
padding_idx=pad_idx # ← el vector de pad_idx siempre es ceros
)
# Input: tensor de IDs (batch_size=2, seq_len=5)
input_ids = torch.tensor([
[4, 10, 7, 8, 5], # "el gato se sentó en"
[6, 13, 14, 5, 0], # "la gata durmió en "
])
# Forward: busca el vector de cada ID
embedded = embedding(input_ids)
print(f"Input shape: {input_ids.shape}") # → torch.Size([2, 5])
print(f"Output shape: {embedded.shape}") # → torch.Size([2, 5, 64])
# Verificar que PAD es todo ceros
print(f"PAD vector: all zeros = {(embedded[1, 4] == 0).all().item()}") # → True
padding_idx=0 garantiza que el vector de <PAD> sea siempre ceros y no se actualice durante el entrenamiento.(batch, seq_len, embed_dim). Esto es exactamente lo que espera una LSTM.9.2 Inicializar con embeddings preentrenados
# Usando la embedding_matrix del paso 8
# embedding_matrix: torch.FloatTensor de shape (vocab_size, 300)
embed_dim = 300
embedding = nn.Embedding(
num_embeddings=vocab_size,
embedding_dim=embed_dim,
padding_idx=0
)
# Cargar pesos preentrenados
embedding.weight = nn.Parameter(embedding_matrix)
# Opción A: Congelar (no se actualizan durante el entrenamiento)
embedding.weight.requires_grad = False
# Opción B: Fine-tune (se ajustan ligeramente con tus datos)
# embedding.weight.requires_grad = True
print(f"Embedding weight shape: {embedding.weight.shape}")
# → torch.Size([15, 300])
# El PAD sigue siendo ceros (lo forzamos en build_embedding_matrix)
print(f"PAD is zero: {(embedding.weight[0] == 0).all().item()}")
- Congelar cuando tienes pocos datos de entrenamiento (< 10K). Evita overfitting.
- Fine-tune cuando tienes suficientes datos. Los embeddings se adaptan a tu dominio (e.g., lenguaje médico, legal, etc.).
- Truco: Congela durante las primeras 5 épocas, luego descongela con un learning rate bajo.
9.3 Modelo LSTM completo con embeddings
class TextClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes,
pretrained_matrix=None, freeze_embeddings=False,
dropout=0.3, pad_idx=0):
super().__init__()
# 1. Capa de embedding
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
if pretrained_matrix is not None:
self.embedding.weight = nn.Parameter(pretrained_matrix)
if freeze_embeddings:
self.embedding.weight.requires_grad = False
# 2. LSTM bidireccional
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=2,
batch_first=True,
bidirectional=True,
dropout=dropout
)
# 3. Clasificador
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * 2, num_classes) # *2 por bidireccional
def forward(self, input_ids, lengths=None):
# input_ids: (batch, seq_len)
# Embedding lookup
embedded = self.embedding(input_ids) # → (batch, seq_len, embed_dim)
embedded = self.dropout(embedded)
# Pack para que LSTM ignore el padding
if lengths is not None:
embedded = nn.utils.rnn.pack_padded_sequence(
embedded, lengths.cpu(), batch_first=True, enforce_sorted=False
)
# LSTM
lstm_out, (hidden, cell) = self.lstm(embedded)
# hidden: (num_layers*2, batch, hidden_dim)
# Concatenar últimos hidden states de ambas direcciones
hidden_fwd = hidden[-2] # última capa, forward
hidden_bwd = hidden[-1] # última capa, backward
hidden_cat = torch.cat([hidden_fwd, hidden_bwd], dim=1) # (batch, hidden_dim*2)
# Clasificar
output = self.dropout(hidden_cat)
output = self.fc(output) # → (batch, num_classes)
return output
# Instantiar modelo
model = TextClassifier(
vocab_size=vocab_size,
embed_dim=300,
hidden_dim=128,
num_classes=2,
pretrained_matrix=embedding_matrix,
freeze_embeddings=True,
dropout=0.3,
)
# Contar parámetros
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Parámetros totales: {total:,}")
print(f"Parámetros entrenables: {trainable:,}")
print(f"Parámetros congelados (embeddings): {total - trainable:,}")
9.4 Entrenamiento
from torch.optim import Adam
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# Training loop (simplificado)
model.train()
for epoch in range(10):
epoch_loss = 0
for padded, mask, labels, lengths in loader:
padded = padded.to(device)
labels = labels.to(device)
optimizer.zero_grad()
logits = model(padded, lengths)
loss = criterion(logits, labels)
loss.backward()
# Gradient clipping (importante para LSTMs)
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
epoch_loss += loss.item()
print(f"Epoch {epoch+1}: loss = {epoch_loss / len(loader):.4f}")
filter(lambda p: p.requires_grad, ...) excluye los embeddings congelados del optimizador.clip_grad_norm_ previene la explosión de gradientes, un problema frecuente en LSTMs con secuencias largas.- Embedding Dropout: En vez de dropout estándar sobre el vector,
se puede poner a cero palabras completas (como si fueran
<UNK>). Esto fuerza al modelo a no depender de palabras individuales. - Positional embeddings: Sumar un vector posicional al embedding para que el modelo sepa la posición de cada token. Es lo que hacen los Transformers.
- Tied embeddings: Compartir la misma matriz de embeddings entre el codificador (input) y el decodificador (output). Reduce parámetros y mejora generalización. Usado en GPT-2 y muchos language models.
- Multi-sense embeddings: Modelos como BERT generan un embedding diferente según el contexto, capturando los distintos significados de una palabra.
nn.Embedding convierte (batch, seq_len) de IDs enteros en
(batch, seq_len, embed_dim) de vectores densos. Es la puerta de entrada
de cualquier red neuronal que procese texto.
Pipeline completo y referencias
Ha llegado el momento de juntar todo: desde el texto crudo hasta los embeddings listos para una LSTM. Este script unifica los pasos 2–9 en una pipeline reutilizable.
10.1 Pipeline unificado
"""
Pipeline completo: texto crudo → embeddings listos para un modelo LSTM.
Combina normalización, tokenización, vocabulario, numericalización y embedding.
"""
import re
import unicodedata
from collections import Counter
import numpy as np
import torch
import torch.nn as nn
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import Dataset, DataLoader
# ─── 1. Normalización ───────────────────────────────────────
def normalize(text, lower=True, remove_accents=False):
"""Normaliza el texto: minúsculas, limpieza de caracteres especiales."""
if lower:
text = text.lower()
if remove_accents:
nfkd = unicodedata.normalize('NFKD', text)
text = ''.join(c for c in nfkd if not unicodedata.combining(c))
# Eliminar URLs
text = re.sub(r'https?://\S+', ' ', text)
# Eliminar mentions y hashtags
text = re.sub(r'[@#]\w+', ' ', text)
# Normalizar espacios
text = re.sub(r'\s+', ' ', text).strip()
return text
# ─── 2. Tokenización ────────────────────────────────────────
def tokenize(text, method='word-punct'):
"""Tokeniza el texto con el método especificado."""
if method == 'whitespace':
return text.split()
elif method == 'word-punct':
return re.findall(r"\w+|[^\w\s]", text)
elif method == 'spacy':
import spacy
nlp = spacy.load('es_core_news_sm')
return [t.text for t in nlp(text)]
else:
raise ValueError(f"Método desconocido: {method}")
# ─── 3. Vocabulario ─────────────────────────────────────────
class Vocabulary:
SPECIALS = ["", "", "", ""]
def __init__(self, min_freq=2):
self.min_freq = min_freq
self.token2id = {}
self.id2token = {}
self.freq = Counter()
def build(self, token_lists):
for tokens in token_lists:
self.freq.update(tokens)
for i, tok in enumerate(self.SPECIALS):
self.token2id[tok] = i
idx = len(self.SPECIALS)
for tok, count in self.freq.most_common():
if count >= self.min_freq:
self.token2id[tok] = idx
idx += 1
self.id2token = {i: t for t, i in self.token2id.items()}
return self
def __len__(self):
return len(self.token2id)
def encode(self, tokens, add_special=True):
unk = self.token2id[""]
ids = [self.token2id.get(t, unk) for t in tokens]
if add_special:
bos = self.token2id[""]
eos = self.token2id[""]
ids = [bos] + ids + [eos]
return ids
def decode(self, ids, remove_special=True):
tokens = [self.id2token.get(i, "") for i in ids]
if remove_special:
tokens = [t for t in tokens if t not in self.SPECIALS]
return tokens
# ─── 4. Embedding matrix ────────────────────────────────────
def load_glove(path, dim=300):
embeddings = {}
with open(path, 'r', encoding='utf-8') as f:
for line in f:
parts = line.strip().split()
word = parts[0]
vec = np.array(parts[1:], dtype=np.float32)
if len(vec) == dim:
embeddings[word] = vec
return embeddings
def build_embedding_matrix(vocab, pretrained, embed_dim):
matrix = np.random.normal(scale=0.6, size=(len(vocab), embed_dim))
matrix[0] = np.zeros(embed_dim) # PAD = zeros
found = 0
for token, idx in vocab.token2id.items():
vec = pretrained.get(token)
if vec is not None and len(vec) == embed_dim:
matrix[idx] = vec
found += 1
print(f"Embedding coverage: {found}/{len(vocab)} ({found/len(vocab)*100:.1f}%)")
return torch.FloatTensor(matrix)
# ─── 5. Dataset y DataLoader ────────────────────────────────
class TextDataset(Dataset):
def __init__(self, texts, labels, vocab, max_len=256):
self.texts = texts
self.labels = labels
self.vocab = vocab
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
tokens = tokenize(normalize(self.texts[idx]))
ids = self.vocab.encode(tokens)[:self.max_len]
return torch.tensor(ids, dtype=torch.long), self.labels[idx]
def collate_fn(batch):
seqs, labels = zip(*batch)
padded = pad_sequence(seqs, batch_first=True, padding_value=0)
mask = (padded != 0).long()
lengths = torch.tensor([len(s) for s in seqs])
labels = torch.tensor(labels, dtype=torch.long)
return padded, mask, labels, lengths
# ─── 6. Modelo ──────────────────────────────────────────────
class TextClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes,
pretrained_matrix=None, freeze_emb=True, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
if pretrained_matrix is not None:
self.embedding.weight = nn.Parameter(pretrained_matrix)
if freeze_emb:
self.embedding.weight.requires_grad = False
self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=2,
batch_first=True, bidirectional=True, dropout=dropout)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, x, lengths=None):
emb = self.dropout(self.embedding(x))
if lengths is not None:
emb = nn.utils.rnn.pack_padded_sequence(
emb, lengths.cpu(), batch_first=True, enforce_sorted=False)
_, (h, _) = self.lstm(emb)
h = torch.cat([h[-2], h[-1]], dim=1)
return self.fc(self.dropout(h))
# ─── 7. Main ────────────────────────────────────────────────
if __name__ == "__main__":
# -- Datos de ejemplo --
texts = [
"Esta película es maravillosa, me encantó cada momento.",
"Terrible actuación, la peor película del año.",
"Los efectos especiales son increíbles y la historia atrapa.",
"Aburrida, lenta y sin sentido. No la recomiendo.",
"Una obra maestra del cine contemporáneo.",
"Pésimo guion y dirección horrible.",
]
labels = [1, 0, 1, 0, 1, 0] # 1=positivo, 0=negativo
# -- Pipeline --
# 1. Tokenizar todo el corpus
corpus_tokens = [tokenize(normalize(t)) for t in texts]
print("Tokens ejemplo:", corpus_tokens[0])
# 2. Construir vocabulario
vocab = Vocabulary(min_freq=1).build(corpus_tokens)
print(f"Vocabulary size: {len(vocab)}")
# 3. (Opcional) Cargar embeddings preentrenados
# glove = load_glove('glove.6B.300d.txt', dim=300)
# emb_matrix = build_embedding_matrix(vocab, glove, 300)
# 4. Crear DataLoader
dataset = TextDataset(texts, labels, vocab, max_len=128)
loader = DataLoader(dataset, batch_size=2, shuffle=True, collate_fn=collate_fn)
# 5. Crear modelo
model = TextClassifier(
vocab_size=len(vocab),
embed_dim=64, # 300 si usas GloVe
hidden_dim=64,
num_classes=2,
# pretrained_matrix=emb_matrix,
freeze_emb=False,
)
# 6. Entrenar
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = torch.optim.Adam(
filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3
)
criterion = nn.CrossEntropyLoss()
for epoch in range(20):
model.train()
total_loss = 0
for padded, mask, labs, lengths in loader:
padded, labs = padded.to(device), labs.to(device)
optimizer.zero_grad()
logits = model(padded, lengths)
loss = criterion(logits, labs)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 5 == 0:
print(f"Epoch {epoch+1:3d}: loss = {total_loss/len(loader):.4f}")
# 7. Inferencia
model.eval()
with torch.no_grad():
test = "Una película fantástica, muy recomendable."
toks = tokenize(normalize(test))
ids = torch.tensor([vocab.encode(toks)], dtype=torch.long).to(device)
lens = torch.tensor([ids.shape[1]])
pred = model(ids, lens).argmax(dim=1).item()
print(f"\nTexto: '{test}'")
print(f"Predicción: {'Positivo ✅' if pred == 1 else 'Negativo ❌'}")
10.2 Referencias y recursos
📄 Papers fundamentales
| Paper | Año | Contribución |
|---|---|---|
| Mikolov et al. — Word2Vec | 2013 | Skip-gram y CBOW para word embeddings eficientes. |
| Pennington et al. — GloVe | 2014 | Embeddings basados en co-ocurrencia global de palabras. |
| Bojanowski et al. — FastText | 2017 | Embeddings con n-gramas de caracteres para OOV. |
| Sennrich et al. — BPE | 2016 | Byte Pair Encoding para tokenización subword en NMT. |
| Kudo — SentencePiece | 2018 | Tokenizador subword agnóstico al idioma. |
| Peters et al. — ELMo | 2018 | Primeros embeddings contextuales con BiLSTM. |
| Devlin et al. — BERT | 2019 | Pre-entrenamiento bidireccional con Transformers. |
📚 Documentación de librerías
- NLTK — Natural Language Toolkit (tokenización, stemming, corpora).
- spaCy — NLP industrial (tokenización, NER, POS, dependencias).
- Gensim — Topic modeling y word embeddings (Word2Vec, FastText, Doc2Vec).
- HuggingFace Tokenizers — Tokenizadores BPE/WordPiece/Unigram ultrarrápidos.
- HuggingFace Transformers — Modelos preentrenados con tokenizadores integrados.
- TorchText — Utilidades de texto para PyTorch.
- scikit-learn — CountVectorizer, TfidfVectorizer.
- GloVe — Vectores preentrenados (6B, 42B, 840B tokens).
- FastText — Vectores preentrenados para 157 idiomas.
🗂️ Datasets recomendados para practicar
- IMDB — Clasificación de sentimiento (50K reviews en inglés).
- AG News — Clasificación de noticias (120K textos, 4 clases).
- TweetEval — Benchmark de NLP en tweets (sentimiento, emociones, hate speech).
- Amazon Reviews — Reviews multilingüe (incluye español).
nn.Embedding.
Ahora tienes las herramientas y el conocimiento para preparar texto para cualquier
arquitectura de Deep Learning.