Redes Neuronales Convolucionales para NLP

S1: Convoluciones 1D para Texto

Prof. Francisco Suárez

Universidad Católica Boliviana

2026-04-08

Agenda de Hoy

  1. 🔄 De secuencias a patrones locales
  2. 📐 Convoluciones 1D: la operación fundamental
  3. 🔤 CNNs sobre texto: embeddings como “imágenes”
  4. 🏗️ Filtros y detectores de n-gramas
  5. 🧪 Experimento: CNN para clasificación de texto

Objetivo

Entender cómo las convoluciones 1D pueden capturar patrones locales (n-gramas) en texto, como una alternativa eficiente y paralelizable a las RNNs.

Prerequisitos: Embeddings (Semana 4), RNNs (Semana 6)

¿Por Qué CNNs para Texto?

Limitaciones de las RNNs

RNNs: poderosas pero lentas

  • Procesan tokens secuencialmente (\(t=1, 2, \ldots, T\))
  • No se puede paralelizar dentro de una secuencia
  • Las dependencias largas requieren LSTM/GRU
  • Para clasificación, a veces solo importan patrones locales

CNNs: una alternativa

  • Procesan toda la secuencia en paralelo
  • Capturan patrones locales (como n-gramas)
  • Muy eficientes en GPU
  • Desde 2014: resultados competitivos en clasificación de texto

Paper Fundacional

Kim (2014): Convolutional Neural Networks for Sentence Classification — un modelo simple con una sola capa convolucional logra resultados state-of-the-art.

De Imágenes a Texto

Diferencia clave

En imágenes, el filtro se mueve en 2D (alto × ancho). En texto, el filtro se mueve solo en 1D (a lo largo de los tokens), cubriendo todas las dimensiones del embedding.

Convolución 1D: La Operación

Definición Formal

Dada una secuencia de embeddings \(\mathbf{x}_1, \mathbf{x}_2, \ldots, \mathbf{x}_T \in \mathbb{R}^d\) y un filtro \(\mathbf{W} \in \mathbb{R}^{k \times d}\) de ancho \(k\):

\[h_i = f\left(\mathbf{W} \cdot \mathbf{x}_{i:i+k-1} + b\right)\]

donde:

  • \(\mathbf{x}_{i:i+k-1}\) es la concatenación (o stack) de \(k\) embeddings consecutivos
  • \(f\) es una función de activación (típicamente ReLU)
  • \(b\) es el sesgo (bias)
  • El resultado \(h_i\) es un escalar

Parámetros

  • Filtro de tamaño \(k\): captura n-gramas de tamaño \(k\)
  • Un filtro tiene \(k \times d + 1\) parámetros
  • Se aplica a todas las posiciones: weight sharing

Salida

  • Entrada: \(T\) tokens → Salida: \(T - k + 1\) valores
  • Cada valor indica “¿cuánto se activa este patrón aquí?”
  • Como un detector de n-gramas aprendido

Ejemplo Paso a Paso

Code
import torch
import torch.nn as nn

# Secuencia de 5 tokens, cada uno con embedding de dimensión 4
torch.manual_seed(42)
T, d = 5, 4
x = torch.randn(1, d, T)  # Conv1d espera (batch, channels, length)

# Filtro de ancho k=3
conv = nn.Conv1d(in_channels=d, out_channels=1, kernel_size=3, bias=True)

# Aplicar convolución
h = conv(x)  # (1, 1, T-k+1) = (1, 1, 3)

print(f"Entrada:           shape {x.shape}{T} tokens × {d} dims")
print(f"Filtro:            shape {conv.weight.shape}  → k={3}, d={d}")
print(f"Parámetros filtro: {conv.weight.numel() + conv.bias.numel()} ({3}×{d} + 1)")
print(f"Salida:            shape {h.shape}{T}-{3}+1 = {T-3+1} posiciones")
print(f"\nActivaciones:      {h.detach().squeeze().tolist()}")
Entrada:           shape torch.Size([1, 4, 5])  → 5 tokens × 4 dims
Filtro:            shape torch.Size([1, 4, 3])  → k=3, d=4
Parámetros filtro: 13 (3×4 + 1)
Salida:            shape torch.Size([1, 1, 3])  → 5-3+1 = 3 posiciones

Activaciones:      [0.22406956553459167, 0.3779914975166321, -0.3071877360343933]

Observación

Conv1d en PyTorch espera dimensiones (batch, channels, length). Para texto, channels = d (dimensión del embedding) y length = T (número de tokens).

Múltiples Filtros = Múltiples Detectores

En la práctica, usamos muchos filtros para detectar diferentes patrones:

Code
# 64 filtros de ancho k=3
n_filters = 64
conv_multi = nn.Conv1d(in_channels=d, out_channels=n_filters, kernel_size=3)

h_multi = conv_multi(x)
print(f"Entrada:  {x.shape}")            # (1, 4, 5)
print(f"Salida:   {h_multi.shape}")      # (1, 64, 3)
print(f"\nCada filtro produce {T-3+1} activaciones")
print(f"Total: {n_filters} filtros × {T-3+1} posiciones = {n_filters*(T-3+1)} valores")
Entrada:  torch.Size([1, 4, 5])
Salida:   torch.Size([1, 64, 3])

Cada filtro produce 3 activaciones
Total: 64 filtros × 3 posiciones = 192 valores

Intuición

  • Filtro A podría aprender a detectar “not good” (negación + adjetivo)
  • Filtro B podría detectar “very nice” (intensificador + positivo)
  • Filtro C podría detectar “the movie” (artículo + sustantivo)
  • Cada filtro es un detector de n-gramas aprendido

CNNs sobre Texto 🔤

El Pipeline Completo

1. Embedding Layer

  • Cada token → vector de \(d\) dimensiones
  • Oración de \(T\) tokens → matriz \(T \times d\)
  • Puede usar embeddings pre-entrenados (Word2Vec, GloVe)

2. Capa Convolucional

  • Múltiples filtros de diferentes anchos (\(k = 2, 3, 4, 5\))
  • Cada filtro detecta patrones de diferente tamaño

3. Max Pooling (global)

  • De cada mapa de activaciones → tomar el máximo
  • Resultado: un escalar por filtro
  • Captura “¿aparece este patrón en algún lugar?”

4. Clasificador

  • Concatenar todos los valores max-pooled
  • Capa Linearsoftmax → probabilidades

Diferentes Anchos de Filtro

Multi-Size Filters

Usando filtros de diferentes anchos simultáneamente (\(k=2,3,4,5\)), el modelo puede capturar bigramas, trigramas, y patrones más largos al mismo tiempo.

Implementación en PyTorch

Code
class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, n_filters, filter_sizes, n_classes,
                 dropout=0.5, pad_idx=0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
        
        # Una capa Conv1d por cada tamaño de filtro
        self.convs = nn.ModuleList([
            nn.Conv1d(embed_dim, n_filters, kernel_size=k)
            for k in filter_sizes
        ])
        
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(n_filters * len(filter_sizes), n_classes)
    
    def forward(self, x):
        """x: (batch, seq_len) — índices de tokens"""
        emb = self.embedding(x)                     # (batch, seq_len, embed_dim)
        emb = emb.permute(0, 2, 1)                  # (batch, embed_dim, seq_len)
        
        # Aplicar cada filtro + ReLU + max pooling global
        conv_outs = []
        for conv in self.convs:
            h = torch.relu(conv(emb))               # (batch, n_filters, seq_len - k + 1)
            h = h.max(dim=2).values                  # (batch, n_filters) ← max pooling
            conv_outs.append(h)
        
        # Concatenar todos los filtros
        cat = torch.cat(conv_outs, dim=1)            # (batch, n_filters * len(filter_sizes))
        cat = self.dropout(cat)
        return self.fc(cat)                          # (batch, n_classes)

# Ejemplo
model_cnn = TextCNN(vocab_size=5000, embed_dim=100, n_filters=64,
                     filter_sizes=[2, 3, 4, 5], n_classes=2)
x_demo = torch.randint(0, 5000, (8, 30))  # batch=8, longitud=30
out = model_cnn(x_demo)
print(f"Entrada: {x_demo.shape}")     # (8, 30)
print(f"Salida:  {out.shape}")        # (8, 2)
print(f"\nParámetros por componente:")
total = 0
for name, param in model_cnn.named_parameters():
    total += param.numel()
    print(f"  {name:30s} {str(list(param.shape)):20s}{param.numel():>8,}")
print(f"  {'TOTAL':30s} {'':20s}{total:>8,}")
Entrada: torch.Size([8, 30])
Salida:  torch.Size([8, 2])

Parámetros por componente:
  embedding.weight               [5000, 100]          →  500,000
  convs.0.weight                 [64, 100, 2]         →   12,800
  convs.0.bias                   [64]                 →       64
  convs.1.weight                 [64, 100, 3]         →   19,200
  convs.1.bias                   [64]                 →       64
  convs.2.weight                 [64, 100, 4]         →   25,600
  convs.2.bias                   [64]                 →       64
  convs.3.weight                 [64, 100, 5]         →   32,000
  convs.3.bias                   [64]                 →       64
  fc.weight                      [2, 256]             →      512
  fc.bias                        [2]                  →        2
  TOTAL                                               →  590,370

Experimento: Clasificación de Sentimiento 🧪

Dataset: Reseñas de Películas

Code
import random
import torch.optim as optim

torch.manual_seed(42)
random.seed(42)
np.random.seed(42)

# --- Vocabulario y datos sintéticos ---
positive_templates = [
    "the movie was great and entertaining",
    "a wonderful film with amazing acting",
    "really enjoyed this excellent movie",
    "fantastic story beautifully told",
    "brilliant performances all around",
    "loved every moment of this film",
    "an outstanding and memorable experience",
    "the best movie I have seen",
    "absolutely incredible and moving story",
    "superb direction and great screenplay",
]

negative_templates = [
    "the movie was terrible and boring",
    "a horrible film with bad acting",
    "really hated this awful movie",
    "worst story ever made honestly",
    "dreadful performances all around sadly",
    "wasted every moment watching this film",
    "an embarrassing and forgettable disaster",
    "the worst movie in recent years",
    "absolutely terrible and painful experience",
    "poor direction and weak screenplay overall",
]

# Construir vocabulario
all_text = " ".join(positive_templates + negative_templates)
words = sorted(set(all_text.split()))
word2idx = {"<PAD>": 0, "<UNK>": 1}
for w in words:
    word2idx[w] = len(word2idx)
VOCAB_SIZE = len(word2idx)

def encode_sentence(sent, max_len=12):
    tokens = [word2idx.get(w, 1) for w in sent.split()]
    tokens = tokens[:max_len]
    tokens += [0] * (max_len - len(tokens))
    return tokens

# Generar datos con variaciones
def augment(templates, n_per_template=80):
    data = []
    for t in templates:
        words_t = t.split()
        for _ in range(n_per_template):
            # Pequeñas variaciones: eliminar 0-1 palabras, permutar adyacentes
            w = words_t.copy()
            if len(w) > 3 and random.random() < 0.3:
                w.pop(random.randint(1, len(w)-2))
            if len(w) > 2 and random.random() < 0.3:
                i = random.randint(0, len(w)-2)
                w[i], w[i+1] = w[i+1], w[i]
            data.append(" ".join(w))
    return data

pos_data = augment(positive_templates)
neg_data = augment(negative_templates)

X_all = torch.tensor([encode_sentence(s) for s in pos_data + neg_data])
y_all = torch.cat([torch.ones(len(pos_data)), torch.zeros(len(neg_data))]).long()

# Shuffle y split
perm = torch.randperm(len(X_all))
X_all, y_all = X_all[perm], y_all[perm]
split = int(0.8 * len(X_all))
X_train, y_train = X_all[:split], y_all[:split]
X_test, y_test = X_all[split:], y_all[split:]

print(f"Vocabulario: {VOCAB_SIZE} palabras")
print(f"Entrenamiento: {len(X_train)} | Test: {len(X_test)}")
print(f"Ejemplo positivo: '{pos_data[0]}'")
print(f"Ejemplo negativo: '{neg_data[0]}'")
Vocabulario: 68 palabras
Entrenamiento: 1280 | Test: 320
Ejemplo positivo: 'the movie great was and entertaining'
Ejemplo negativo: 'the movie was terrible and boring'

Entrenamiento CNN vs. RNN

Code
# --- Modelo RNN para comparación ---
class TextRNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, pad_idx=0):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
        self.rnn = nn.GRU(embed_dim, hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, n_classes)
    
    def forward(self, x):
        emb = self.embedding(x)
        _, h = self.rnn(emb)
        return self.fc(h.squeeze(0))

# Entrenar ambos modelos
def train_model(model, X_tr, y_tr, n_epochs=30, lr=0.003):
    optimizer = optim.Adam(model.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()
    losses = []
    BATCH = 64
    for epoch in range(n_epochs):
        model.train()
        idx = torch.randperm(len(X_tr))
        epoch_loss = 0
        n_b = 0
        for i in range(0, len(X_tr), BATCH):
            batch_idx = idx[i:i+BATCH]
            out = model(X_tr[batch_idx])
            loss = criterion(out, y_tr[batch_idx])
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item()
            n_b += 1
        losses.append(epoch_loss / n_b)
    return losses

EMBED = 50
HIDDEN = 64

# CNN
cnn = TextCNN(VOCAB_SIZE, EMBED, n_filters=32, filter_sizes=[2, 3, 4], n_classes=2)
cnn_losses = train_model(cnn, X_train, y_train)

# RNN
rnn = TextRNN(VOCAB_SIZE, EMBED, HIDDEN, n_classes=2)
rnn_losses = train_model(rnn, X_train, y_train)

print(f"CNN params: {sum(p.numel() for p in cnn.parameters()):,}")
print(f"RNN params: {sum(p.numel() for p in rnn.parameters()):,}")
CNN params: 18,090
RNN params: 25,802

Curvas de Aprendizaje

Evaluación

Code
def evaluate(model, X, y):
    model.eval()
    with torch.no_grad():
        preds = model(X).argmax(dim=1)
        acc = (preds == y).float().mean().item()
    return acc

cnn_acc = evaluate(cnn, X_test, y_test)
rnn_acc = evaluate(rnn, X_test, y_test)

print(f"{'Modelo':<12} {'Accuracy':>10} {'Parámetros':>12}")
print("=" * 36)
print(f"{'CNN':<12} {cnn_acc:>9.1%} {sum(p.numel() for p in cnn.parameters()):>12,}")
print(f"{'GRU':<12} {rnn_acc:>9.1%} {sum(p.numel() for p in rnn.parameters()):>12,}")
Modelo         Accuracy   Parámetros
====================================
CNN             100.0%       18,090
GRU             100.0%       25,802

Ventajas de las CNNs

Paralelización y Eficiencia

RNN: secuencial

t=1: h₁ = f(x₁, h₀)
t=2: h₂ = f(x₂, h₁)  ← espera h₁
t=3: h₃ = f(x₃, h₂)  ← espera h₂
...
  • Cada paso depende del anterior
  • No paralelizable dentro de una secuencia
  • Tiempo: \(O(T)\) pasos secuenciales

CNN: paralelo

pos 1: h₁ = f(x₁, x₂, x₃)  ┐
pos 2: h₂ = f(x₂, x₃, x₄)  ├ simultáneo
pos 3: h₃ = f(x₃, x₄, x₅)  ┘
  • Todas las posiciones se computan en paralelo
  • Ideal para GPUs
  • Tiempo: \(O(1)\) pasos secuenciales (una capa)

En la práctica

Para clasificación de texto (sentimiento, temas), las CNNs suelen ser más rápidas de entrenar que RNNs con resultados comparables o mejores. Para tareas que requieren dependencias largas (traducción, generación), las RNNs con atención siguen siendo superiores.

Campo Receptivo

Una capa convolucional

  • Filtro de ancho \(k\) → ve \(k\) tokens consecutivos
  • Campo receptivo = \(k\) (limitado)
  • No captura dependencias más allá de \(k\) tokens

Múltiples capas (stacking)

  • 2 capas con \(k=3\): campo receptivo = \(5\)
  • \(L\) capas con \(k\): campo receptivo = \(L(k-1) + 1\)
  • Crecimiento lineal del campo receptivo

Comparación con RNNs

Una RNN tiene campo receptivo infinito (en teoría) desde el primer paso, pero en la práctica sufre de vanishing gradients. Las CNNs tienen un campo receptivo finito pero controlable apilando capas.

Resumen

Lo Que Aprendimos Hoy

Conceptos

  • Las convoluciones 1D detectan patrones locales en texto
  • Un filtro de ancho \(k\) es un detector de \(k\)-gramas aprendido
  • Múltiples filtros de diferentes anchos capturan patrones variados
  • Max pooling global extrae la activación más fuerte
  • Las CNNs son paralelizables (ventaja sobre RNNs)

Práctica

  • nn.Conv1d(embed_dim, n_filters, kernel_size=k) — filtro 1D
  • nn.ModuleList para manejar múltiples tamaños de filtro
  • .max(dim=2) para global max pooling
  • La arquitectura Kim (2014) es simple y efectiva
  • Campo receptivo crece con profundidad: \(L(k-1)+1\)

Ecuaciones Clave

Concepto Ecuación
Conv1D \(h_i = \text{ReLU}(\mathbf{W} \cdot \mathbf{x}_{i:i+k-1} + b)\)
Tamaño salida \(T_{\text{out}} = T - k + 1\) (sin padding)
Max pooling \(\hat{h} = \max_i h_i\)
Parámetros (1 filtro) \(k \times d + 1\)
Campo receptivo \(L(k-1) + 1\) (\(L\) capas)

Para la Próxima Sesión 📚

Semana 8, S2: Max Pooling Global vs. Local

  • Global max pooling: captura “¿aparece el patrón en algún lugar?”
  • Average pooling: captura “¿cuánto aparece en promedio?”
  • K-max pooling: retiene los \(k\) valores más altos
  • Impacto de la estrategia de pooling en el rendimiento

Lectura:

  • Kim (2014): Convolutional Neural Networks for Sentence Classification (secciones 3-4)
  • Kalchbrenner et al. (2014): A Convolutional Neural Network for Modelling Sentences
  • Johnson & Zhang (2015): Effective Use of Word Order for Text Categorization

¿Preguntas? 🙋

¡Gracias!

📧 fsuarez@ucb.edu.bo

🔗 Materiales: github.com/fjsuarez/ucb-nlp