💻 Tutorial paso a paso

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.

⏱️ ~40 min 📊 Nivel: principiante‑intermedio 🔥 Python · NLTK · spaCy · HuggingFace Tokenizers · PyTorch

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)
1

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:

Texto raw "Hola mundo!" Normalizar lower, acentos stopwords… Tokenizar word / subword / char Numericalizar vocab → IDs + padding Vectorizar one-hot / BoW / embeddings Modelo LSTM / GRU / Transformer Paso 1 Paso 2 Pasos 3‑4 Pasos 5‑6 Pasos 7‑9 Destino

Instalamos las dependencias

Terminal
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')"
Python imports y texto de ejemplo
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")
💡 Idea clave: Cada paso del pipeline tiene múltiples alternativas. No hay una receta única — la mejor opción depende de tu idioma, dominio, tamaño de datos y modelo destino. En cada paso veremos las opciones más importantes.
2

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

Python 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.
⚠️ ¿Siempre lowercase? No. En tareas como Named Entity Recognition (NER), las mayúsculas contienen información ("Apple" empresa vs "apple" fruta). En sentiment analysis suele ser seguro hacer lowercase.

2.2 Eliminar acentos y diacríticos

Python eliminar acentos (Unicode NFD)
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!
L3NFKD descompone "é" en "e" + acento combinante. Luego filtramos los combinantes.
⚠️ Cuidado: Eliminar acentos destruye información en idiomas como español, francés o alemán. "año" ≠ "ano", "más" ≠ "mas". Solo hazlo si tu vocabulario es muy pequeño o trabajas en inglés.

2.3 Limpieza con expresiones regulares

Python limpieza regex
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:

Python stopwords
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

Python stemming vs 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']
stemStemming: corta sufijos con reglas heurísticas. Rápido pero tosco ("buenísimo" → "buen").
lemmaLematización: busca la forma base en un diccionario. Más lento pero correcto ("durmieron" → "dormir").

2.6 Función de normalización completa

Python normalize()
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()
Salida Original: Los gatos de la Sra. García NO duermen nunca por las noches. Normalizado: gato sra. garcía dormir noche . Original: ¡El café está buenísimo! Tomaré 3 tazas más hoy. Normalizado: ¡ café estar buenísimo ! tomar 3 taza hoy .
TécnicaCuándo usarlaCuándo NO
LowercaseCasi siempreNER, case-sensitive tasks
Remove accentsInglés, vocab pequeñoEspañol, francés, alemán
Remove stopwordsBoW, TF-IDFLSTM, Transformers
StemmingBúsqueda rápidaCuando necesitas calidad
LematizaciónBoW/TF-IDF de calidadModelos con embeddings
3

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

Python split simple
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

Python 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

Python NLTK
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', '.']
L4NLTK separa contracciones: "don't" → "do" + "n't". Útil para modelos que necesitan analizar la negación por separado.
L9En español, NLTK mantiene "Sra." como un solo token (reconoce abreviaturas).

3.4 spaCy tokenizer

Python spaCy
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', '.']
Salida Machine pos=PROPN lemma=Machine stop=False Learning pos=PROPN lemma=Learning stop=False y pos=CONJ lemma=y stop=True Deep pos=PROPN lemma=Deep stop=False Learning pos=PROPN lemma=Learning stop=False son pos=AUX lemma=ser stop=True sub pos=NOUN lemma=sub stop=False - pos=PUNCT lemma=- stop=False áreas pos=NOUN lemma=área stop=False de pos=ADP lemma=de stop=True la pos=DET lemma=el stop=True IA pos=PROPN lemma=IA stop=False . pos=PUNCT lemma=. stop=False
🔑 Ventaja de spaCy: Además de tokenizar, te da POS tags, lemas, detección de stopwords, entidades nombradas y dependencias sintácticas. Todo eso con un solo nlp(text).

🧪 Prueba los tokenizadores en vivo

Comparativa de tokenizadores word-level

TokenizadorVelocidadCalidadIdiomasIdeal para
str.split()⚡⚡⚡BajaTodosPrototipado rápido
re.findall()⚡⚡⚡MediaTodosControl total
NLTK⚡⚡Alta~20Análisis lingüístico
spaCy⚡⚡⚡Muy alta~70Producción NLP

La tokenización por palabras tiene problemas fundamentales:

  1. Vocabulario abierto: cualquier palabra nueva (typo, neologismo, nombre propio) es un <UNK> desconocido.
  2. Vocabulario enorme: el inglés tiene ~170K formas; el español, mucho más (por la conjugación verbal). Vocab grande = embedding layer enorme.
  3. Morfología ignorada: "jugar", "jugando", "jugué" se tratan como 3 tokens independientes, sin compartir información.
  4. 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).

4

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).

💡 Intuición: La palabra "desafortunadamente" podría tokenizarse como ["des", "##afortuna", "##da", "##mente"]. Cada fragmento se reutiliza en "deshacer", "afortunado", "rápidamente", etc. Se comparten representaciones.

4.1 BPE paso a paso

Python BPE manual simplificado
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]})")
Salida Merge 1: e + s → es (freq=9) Merge 2: es + t → est (freq=9) Merge 3: est + ◁ → est◁ (freq=9) Merge 4: l + o → lo (freq=7) Merge 5: lo + w → low (freq=7) Merge 6: n + e → ne (freq=6) Merge 7: ne + w → new (freq=6) Merge 8: new + est◁ → newest◁ (freq=6) Merge 9: w + i → wi (freq=3) Merge 10: wi + d → wid (freq=3)
L1-8get_stats recorre el vocabulario contando cuántas veces aparece cada par consecutivo.
L25Las palabras comienzan como caracteres separados. BPE va fusionando hasta tener un vocabulario del tamaño deseado.
L30En cada iteración se fusiona el par más frecuente. Después de 10 merges, "newest" ya es un solo token.

4.2 BPE con HuggingFace tokenizers

Python Entrenar un tokenizador BPE
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}")
Salida (ejemplo) Tamaño del vocabulario: 349 Tokens : ['Las', 'redes', 'neuronales', 'pro', 'fun', 'das', 'gen', 'er', 'an', 'em', 'bed', 'di', 'ng', 's', 'den', 'sos', '.'] IDs : [5, 12, 18, 67, 89, 42, 55, 31, 28, 44, 102, 39, 71, 8, 156, 203, 4]
💡 Observación: Con un corpus tan pequeño, la palabra "profundas" se divide en ["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)

Python WordPiece con transformers de HuggingFace
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}")
Salida Tokens : ['[CLS]', 'El', 'aprendizaje', 'profundo', 'procesa', 'representaciones', 'vector', '##iales', '.', '[SEP]'] IDs : [101, 2422, 65843, 63839, 82399, 100286, 53942, 12674, 119, 102] Attn mask: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] Decoded : El aprendizaje profundo procesa representaciones vectoriales.
L6BERT añade [CLS] al inicio y [SEP] al final automáticamente.
L8"vectoriales" se divide en ["vector", "##iales"]. El prefijo ## indica que es continuación.

4.4 SentencePiece (Unigram)

Python SentencePiece
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}")
📝 Nota sobre el carácter ▁ : SentencePiece usa (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étodoDirecciónMarca especialVocab típicoModelos 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 tokenizers de 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.
⚠️ Error frecuente: Entrenar un tokenizador en un corpus diferente al que usarás para entrenar el modelo. El tokenizador debe reflejar la distribución de tu corpus final. Si entrenas BPE en Wikipedia pero lo usas en tweets, muchas palabras informales serán fragmentadas en exceso.
5

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

TokenID típicoPropósito
<PAD>0Relleno para igualar longitudes en un batch.
<UNK>1Palabra desconocida (fuera de vocabulario, OOV).
<BOS>2Inicio de secuencia (Beginning Of Sequence).
<EOS>3Fin de secuencia (End Of Sequence).

5.2 Vocabulario manual con Counter

Python Vocabulario desde cero
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 ""))
Salida Frecuencias: [('en', 3), ('el', 3), ('la', 3), ('se', 2), ('sentó', 2), ('alfombra', 2), ('gato', 1), ('perro', 1), ('sofá', 1), ('gata', 1), ('durmió', 1)] Vocabulario (15 tokens): 0 → '<PAD>' 1 → '<UNK>' 2 → '<BOS>' 3 → '<EOS>' 4 → 'en' (freq=3) 5 → 'el' (freq=3) 6 → 'la' (freq=3) 7 → 'se' (freq=2) 8 → 'sentó' (freq=2) 9 → 'alfombra' (freq=2) 10 → 'gato' (freq=1) 11 → 'perro' (freq=1) 12 → 'sofá' (freq=1) 13 → 'gata' (freq=1) 14 → 'durmió' (freq=1)

5.3 Clase Vocabulary reutilizable

Python Clase Vocabulary
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', '', '']
L4-5min_freq controla cuántas veces debe aparecer un token para entrar al vocabulario. Es el control principal del tamaño.
L37-39encode mapea tokens a IDs, usando el ID de <UNK> para tokens desconocidos.

5.4 Vocabulario con torchtext

Python torchtext.vocab
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)
⚠️ Decisiones críticas al construir el vocabulario:
  • 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.

6

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.

Seq 1: 4 10 7 0 0 ← PAD Seq 2: 5 11 7 8 12 Seq 3: 6 13 14 4 0 ← PAD Batch tensor shape: (3, 5)

6.1 Numericalización básica

Python Token → ID
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

Python pad_sequence
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]])
L6pad_sequence rellena las secuencias cortas hasta la longitud de la más larga del batch.
L14-15La attention_mask es crucial: le dice al modelo qué posiciones ignorar al calcular la loss y atención.

6.3 Truncamiento

Python Estrategias de 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')}")
Salida tail: len=10, tokens=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] head: len=10, tokens=[190, 191, 192, 193, 194, 195, 196, 197, 198, 199] both: len=10, tokens=[0, 1, 2, 3, 4, 195, 196, 197, 198, 199]

6.4 Collate function completa para DataLoader

Python collate_fn para PyTorch 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
🔑 pack_padded_sequence: Para las LSTM/GRU, PyTorch ofrece 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.BucketIterator hace 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.
7

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

Python One-hot
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)
⚠️ Problemas del one-hot:
  • 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)

Python Bag of Words
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
L11BoW es la suma de vectores one-hot de todos los tokens. Cada componente cuenta cuántas veces aparece cada palabra.
L14Perdemos completamente el orden: "el gato persiguió al perro" y "el perro persiguió al gato" tienen la misma representación.

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.

Python TF-IDF
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}")
Salida Features: ['alfombra' 'durmió' 'el' 'en' 'gata' 'gato' 'la' 'perro' 'se' 'sentó' 'sofá'] TF-IDF matrix (redondeado): Doc 0: [('alfombra', 0.356), ('el', 0.271), ('en', 0.271), ('gato', 0.468), ('la', 0.271), ('se', 0.356), ('sentó', 0.356)] Doc 1: [('el', 0.422), ('en', 0.211), ('perro', 0.365), ('se', 0.277), ('sentó', 0.277), ('sofá', 0.365)] ← "el" sube porque aparece 2 veces Doc 2: [('alfombra', 0.356), ('durmió', 0.468), ('en', 0.271), ('gata', 0.468), ('la', 0.542)]
💡 Fórmulas:
TF(t, d) = count(t in d) / |d|
IDF(t) = log(N / df(t)) donde N = nº documentos, df(t) = nº docs que contienen t
TF-IDF(t, d) = TF(t, d) × IDF(t)

7.4 N-gramas

Python TF-IDF con bigramas
# 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

AspectoOne-Hot / BoW / TF-IDFEmbeddings densos
Dimensión= |V| (miles-millones)50–1024 (fija)
Sparsity~99.9% cerosDensos (sin ceros)
SemánticaNo captura similitudPalabras similares → vectores cercanos
EntrenamientoNo requiere (determinístico)Requiere datos o modelo preentrenado
MemoriaEficiente (sparse format)Eficiente (dimensión baja)
Uso en DLBaselines, features extraInput 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.
Python Baseline TF-IDF + LogReg
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}")
8

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.

gato perro caballo rey reina príncipe manzana naranja plátano rey − hombre + mujer ≈ reina

8.1 Word2Vec con Gensim

Python Entrenar Word2Vec
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'])
L14vector_size=100: cada palabra será un vector ℝ¹⁰⁰. Valores típicos: 50, 100, 200, 300.
L16min_count: palabras con frecuencia menor se ignoran. Con corpus grandes, usar 5-10.
L18Skip-gram (sg=1) funciona mejor con corpus pequeños/medianos. CBOW (sg=0) es más rápido con corpus grandes.

8.2 Cargar GloVe preentrenado

Python Cargar vectores GloVe
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)

Python FastText con Gensim
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
💡 FastText para español: FastText tiene modelos preentrenados para 157 idiomas, incluyendo español. Descarga desde fasttext.cc. El modelo de español (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.

Python Construir embedding matrix
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
L19Tokens sin vector preentrenado se inicializan aleatoriamente. Esto incluye <UNK>, <BOS>, <EOS> y palabras OOV.
L22Crucial: el vector de <PAD> (ID=0) debe ser todo ceros para que el padding no contribuya al cálculo.

Comparativa de embeddings preentrenados

EmbeddingAlgoritmoOOVDimensionesEntrenado 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.

⚠️ Errores comunes con embeddings preentrenados:
  • 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.
9

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

Python nn.Embedding básico
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
L12padding_idx=0 garantiza que el vector de <PAD> sea siempre ceros y no se actualice durante el entrenamiento.
L23La salida tiene una dimensión extra: (batch, seq_len, embed_dim). Esto es exactamente lo que espera una LSTM.

9.2 Inicializar con embeddings preentrenados

Python Cargar GloVe en nn.Embedding
# 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 o fine-tune?
  • 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

Python Clasificador LSTM 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:,}")
Salida (ejemplo) Parámetros totales: 862,722 Parámetros entrenables: 858,222 Parámetros congelados (embeddings): 4,500

9.4 Entrenamiento

Python Loop de 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}")
L6filter(lambda p: p.requires_grad, ...) excluye los embeddings congelados del optimizador.
L22-23clip_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.
🎯 Resumen de la capa de embedding: 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.
10

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

Python text_pipeline.py — Pipeline de texto a embeddings
"""
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 ❌'}")
Salida (ejemplo) Tokens ejemplo: ['esta', 'película', 'es', 'maravillosa', ',', 'me', 'encantó', 'cada', 'momento', '.'] Vocabulary size: 40 Epoch 5: loss = 0.5432 Epoch 10: loss = 0.1287 Epoch 15: loss = 0.0234 Epoch 20: loss = 0.0058 Texto: 'Una película fantástica, muy recomendable.' Predicción: Positivo ✅

10.2 Referencias y recursos

📄 Papers fundamentales

PaperAñoContribució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).
🎉 ¡Tutorial completado! Has recorrido todo el pipeline de preprocesamiento de texto para Deep Learning: desde la normalización del texto crudo, pasando por tokenización (clásica y subword), construcción de vocabulario, numericalización, representaciones sparse, embeddings preentrenados, hasta integrar todo en un modelo LSTM con nn.Embedding. Ahora tienes las herramientas y el conocimiento para preparar texto para cualquier arquitectura de Deep Learning.