Phase 05 - Lesson 08

CNNs y RNNs para Texto

Convoluciones aprenden n-gramas. Recurrencias recuerdan. Ambas fueron superadas por atención. Ambas siguen importando en hardware con limitaciones.

Tipo: Construir Idiomas: Python Requisitos previos: Fase 3 · 11 (PyTorch Intro), Fase 5 · 03 (Word Embeddings), Fase 4 · 02 (Convolutions from Scratch) Tiempo: ~75 minutos

El Problema

TF-IDF y Word2Vec producían vectores planos que ignoraban el orden de las palabras. Un clasificador construido sobre ellos no podría diferenciar dog bites man de man bites dog. El orden de las palabras a veces transporta la señal.

Dos familias de arquitecturas llenaron ese vacío antes de la llegada de los transformers.

Redes convolucionales para texto (TextCNN). Aplican convoluciones 1D sobre secuencias de embeddings de palabras. Un filtro de ancho 3 es un detector de trigramas aprendible: abarca tres palabras y genera una puntuación. Apila diferentes anchos (2, 3, 4, 5) para detectar patrones a múltiples escalas. Realiza max-pool para obtener una representación de tamaño fijo. Plano, paralelo, rápido.

Redes recurrentes (RNN, LSTM, GRU). Procesan tokens uno a la vez, manteniendo un estado oculto (hidden state) que transporta la información hacia adelante. Secuenciales, con memoria, longitudes de entrada flexibles. Dominaron el modelado de secuencias de 2014 a 2017, luego llegó la atención.

Esta lección construye ambas y luego identifica la falla que motivó la atención.

El Concepto

TextCNN filters vs. RNN hidden state unrolling

TextCNN (Kim, 2014). Los tokens se incrustan (embed). Una convolución 1D de ancho k desliza un filtro sobre k-gramas consecutivos de embeddings, produciendo un mapa de características (feature map). El max-pooling global sobre ese mapa selecciona la activación más fuerte. Se concatenan las salidas de max-pooling de varios anchos de filtro y se alimentan a un cabezal clasificador (classifier head).

Por qué funciona. Un filtro es un n-grama aprendible. El max-pooling es invariante a la posición, por lo que "not good" activa la misma característica al principio o a la mitad de una reseña. Tres anchos de filtro con 100 filtros cada uno te dan 300 detectores de n-gramas aprendidos. El entrenamiento es paralelo; sin dependencia secuencial.

RNN. En cada paso de tiempo t, el estado oculto h_t = f(W * x_t + U * h_{t-1} + b). Comparte W, U, b a lo largo del tiempo. El estado oculto en el tiempo T es un resumen de todo el prefijo. Para la clasificación, se realiza un pooling a lo largo de h_1 ... h_T (max, mean o last).

Las RNN simples sufren de desvanecimiento de gradiente (vanishing gradients). La LSTM añade compuertas (gates) que deciden qué olvidar, qué almacenar y qué producir, estabilizando los gradientes a lo largo de secuencias largas. La GRU simplifica la LSTM a dos compuertas; funciona de manera similar con menos parámetros.

RNNs bidireccionales ejecutan una RNN hacia adelante y otra hacia atrás, concatenando los estados ocultos. La representación de cada token ve tanto el contexto izquierdo como el derecho. Esencial para tareas de etiquetado (tagging).

Constrúyelo

Paso 1: TextCNN en PyTorch

import torch
import torch.nn as nn
import torch.nn.functional as F


class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, n_classes, filter_widths=(2, 3, 4), n_filters=64, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.convs = nn.ModuleList([
            nn.Conv1d(embed_dim, n_filters, kernel_size=k)
            for k in filter_widths
        ])
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)

    def forward(self, token_ids):
        x = self.embed(token_ids).transpose(1, 2)
        pooled = []
        for conv in self.convs:
            c = F.relu(conv(x))
            p = F.max_pool1d(c, c.size(2)).squeeze(2)
            pooled.append(p)
        h = torch.cat(pooled, dim=1)
        return self.fc(self.dropout(h))

El transpose(1, 2) cambia la forma de [batch, seq_len, embed_dim] a [batch, embed_dim, seq_len] porque nn.Conv1d trata el eje intermedio como canales. La salida del pooling es de tamaño fijo independientemente de la longitud de la entrada.

Paso 2: Clasificador LSTM

class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, bidirectional=True, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=bidirectional)
        factor = 2 if bidirectional else 1
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim * factor, n_classes)

    def forward(self, token_ids):
        x = self.embed(token_ids)
        out, _ = self.lstm(x)
        pooled = out.max(dim=1).values
        return self.fc(self.dropout(pooled))

Realiza max-pool sobre la secuencia, no pooling de último estado (last-state). Para la clasificación, el max-pooling suele superar al último estado oculto porque la información al final de una secuencia larga tiende a dominar el último estado.

Paso 3: Demostración del desvanecimiento de gradiente (intuición)

Una RNN simple sin compuertas no puede aprender dependencias de largo alcance. Considera una tarea ficticia: predecir si el token A apareció en algún lugar de una secuencia. Si A está en la posición 1 y la secuencia tiene 100 tokens de largo, el gradiente de la pérdida tiene que fluir de regreso a través de 99 multiplicaciones del peso recurrente. Si el peso es menor que 1, el gradiente se desvanece (vanishes). Si es mayor que 1, explota (explodes).

def vanishing_gradient_sim(seq_len, recurrent_weight=0.9):
    import math
    return math.pow(recurrent_weight, seq_len)


# At weight=0.9 over 100 steps:
#   0.9 ^ 100 ≈ 2.7e-5
# The gradient from step 100 to step 1 is effectively zero.

Las LSTMs solucionan esto con un estado de celda (cell state) que recorre la red solo con interacciones aditivas (la compuerta de olvido lo escala multiplicativamente, pero los gradientes siguen fluyendo por la "autopista"). Las GRUs hacen algo similar con menos parámetros. Ambas ofrecen un entrenamiento estable a lo largo de secuencias de más de 100 pasos.

Paso 4: Por qué esto aún no era suficiente

Tres problemas persistieron incluso con las LSTMs.

  1. Cuello de botella secuencial. Entrenar una RNN en una secuencia de longitud 1000 requiere 1000 pasos seriales de avance/retroceso (forward/backward). No se puede paralelizar a lo largo del tiempo.
  2. Vector de contexto de tamaño fijo en configuraciones codificador-decodificador (encoder-decoder). El decodificador solo ve el estado oculto final del codificador, comprimido sobre toda la entrada. Las entradas largas pierden detalles. La Lección 09 cubre esto directamente.
  3. Techo de precisión en dependencias lejanas. Las LSTMs superan a las RNN simples, pero aún tienen dificultades para propagar información específica a lo largo de más de 200 pasos.

El mecanismo de atención resolvió los tres. Los transformers eliminaron la recurrencia por completo. La Lección 10 es el punto de inflexión.

Cómo usarlo

nn.LSTM, nn.GRU y nn.Conv1d de PyTorch están listos para producción. El código de entrenamiento es estándar.

Hugging Face distribuye embeddings preentrenados que se conectan como la capa de entrada:

from transformers import AutoModel

encoder = AutoModel.from_pretrained("bert-base-uncased")
for param in encoder.parameters():
    param.requires_grad = False


class BertCNN(nn.Module):
    def __init__(self, n_classes, filter_widths=(2, 3, 4), n_filters=64):
        super().__init__()
        self.encoder = encoder
        self.convs = nn.ModuleList([nn.Conv1d(768, n_filters, kernel_size=k) for k in filter_widths])
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)

    def forward(self, input_ids, attention_mask):
        with torch.no_grad():
            out = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
        x = out.transpose(1, 2)
        pooled = [F.max_pool1d(F.relu(conv(x)), kernel_size=conv(x).size(2)).squeeze(2) for conv in self.convs]
        return self.fc(torch.cat(pooled, dim=1))

Lista de verificación para "usar cuando se ajuste a la restricción":

  • Inferencia en el borde (edge) / en el dispositivo. TextCNN con embeddings GloVe es entre 10 y 100 veces más pequeño que un transformer. Si tu objetivo de despliegue (deploy) es un teléfono, esta es la pila (stack).
  • Clasificación por streaming / en línea. RNN procesa un token a la vez; los transformers necesitan la secuencia completa. Para texto entrante en tiempo real, las LSTM siguen ganando.
  • Modelos diminutos para líneas base (baselines). Iteración rápida en una nueva tarea. Entrena una TextCNN en 5 minutos en una CPU.
  • Etiquetado de secuencias con datos limitados. BiLSTM-CRF (lección 06) sigue siendo una arquitectura NER de grado de producción para 1k-10k oraciones etiquetadas.

Todo lo demás va a un transformer.

Envíalo

Guarda como outputs/prompt-text-encoder-picker.md:

---
name: text-encoder-picker
description: Pick a text encoder architecture for a given constraint set.
phase: 5
lesson: 08
---

Given constraints (task, data volume, latency budget, deploy target, compute budget), output:

1. Encoder architecture: TextCNN, BiLSTM, BiLSTM-CRF, transformer fine-tune, or "use a pretrained transformer as a frozen encoder + small head".
2. Embedding input: random init, GloVe / fastText frozen, or contextualized transformer embeddings.
3. Training recipe in 5 lines: optimizer, learning rate, batch size, epochs, regularization.
4. One monitoring signal. For RNN/CNN models: attention mechanism absence means they miss long-range deps; check per-length accuracy. For transformers: fine-tuning collapse if LR too high; check train loss.

Refuse to recommend fine-tuning a transformer when data is under ~500 labeled examples without showing that a TextCNN / BiLSTM baseline has plateaued. Flag edge deployment as needing architecture-before-everything.

Ejercicios

  1. Fácil. Entrena una TextCNN en un conjunto de datos ficticio de 3 clases (tú inventas los datos). Verifica que los anchos de filtro (2, 3, 4) superen a un solo ancho (3) en el F1 promedio.
  2. Medio. Implementa max-pool, mean-pool y pooling de último estado (last-state) para el clasificador LSTM. Compara en un conjunto de datos pequeño; documenta qué pooling gana e hipotetiza por qué.
  3. Difícil. Construye un etiquetador NER BiLSTM-CRF (combina la lección 06 y esta). Entrena en CoNLL-2003. Compara con la línea base de solo CRF de la lección 06 y con un ajuste fino (fine-tune) de BERT. Informa el tiempo de entrenamiento, la memoria y el F1.

Términos Clave

Término Lo que la gente dice Lo que realmente significa
TextCNN CNN para texto Pila de convoluciones 1D sobre embeddings de palabras con max-pool global. Kim (2014).
RNN Red recurrente Estado oculto actualizado en cada paso de tiempo: h_t = f(W x_t + U h_{t-1}).
LSTM RNN con compuertas Agrega compuertas de entrada / olvido / salida + un estado de celda. Se entrena de manera estable a lo largo de secuencias largas.
GRU LSTM más simple Dos compuertas en lugar de tres. Precisión similar, menos parámetros.
Bidirecional Ambas direcciones RNN hacia adelante + hacia atrás concatenadas. Cada token ve ambos lados de su contexto.
Desvanecimiento de gradiente La señal de entrenamiento muere La multiplicación repetida por pesos <1 en RNN simples hace que los gradientes de los primeros pasos sean efectivamente cero.

Lecturas Recomendadas

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).