Objetivo

Entender profundamente o mecanismo de atenção, derivar a self-attention matemática, implementar um Transformer do zero com PyTorch e compreender por que essa arquitetura revolucionou toda a IA moderna — de NLP a visão computacional e além.


1. A Limitação das RNNs que os Transformers Resolvem

As RNNs têm três problemas fundamentais:

Processamento sequencial: cada passo depende do anterior — impossível paralelizar durante o treino. Para uma sequência de 1000 tokens, são 1000 passos obrigatoriamente sequenciais.

Gargalo de informação: toda a informação da sequência precisa caber no estado oculto final de tamanho fixo. Em tradução de "O rato comeu o queijo que o gato que o cachorro perseguia assustou", o encoder precisa comprimir tudo em um único vetor.

Dependências longas: mesmo com LSTM, capturar que "assustou" se refere ao "gato" que está 8 tokens atrás é difícil — a informação se dilui ao longo dos passos.

A solução: atenção direta entre todos os pares de tokens, sem percorrer a sequência passo a passo.


2. Intuição do Mecanismo de Atenção

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import math
import warnings
warnings.filterwarnings("ignore")

torch.manual_seed(42)
np.random.seed(42)
sns.set_theme(style="whitegrid")

device = torch.device("cuda" if torch.cuda.is_available()
                       else "cpu")
print(f"Dispositivo: {device}")

# ── Intuição: atenção como busca em banco de dados ───────
print("Intuição do Mecanismo de Atenção:")
print("=" * 50)
print("""
Atenção é como uma busca em banco de dados suave (soft lookup):

  Query (Q):  o que estou procurando?
  Key   (K):  o que cada posição oferece?
  Value (V):  o conteúdo de cada posição

  1. Calcular compatibilidade: score(Q, K) = Q · K
  2. Normalizar scores: softmax → pesos de atenção
  3. Agregar valores: saída = soma ponderada dos Values

Em linguagem natural:
  Q = "o pronome 'ela' se refere a quem?"
  K = cada substantivo na frase
  V = representação de cada substantivo

  A atenção encontra qual substantivo (K) é mais
  compatível com a query, e retorna sua representação (V).
""")

def atencao_simples(query: torch.Tensor,
                     keys: torch.Tensor,
                     values: torch.Tensor,
                     mask: torch.Tensor = None) -> tuple:
    """
    Atenção básica com produto escalar.

    query:  (batch, q_len, d_k)
    keys:   (batch, k_len, d_k)
    values: (batch, k_len, d_v)
    mask:   (batch, q_len, k_len) — opcional

    Retorna:
        saida:  (batch, q_len, d_v)
        pesos:  (batch, q_len, k_len)
    """
    d_k = query.shape[-1]

    # Scores de atenção: Q · Kᵀ
    scores = torch.bmm(query, keys.transpose(-2, -1))
    # Escalar pela raiz de d_k (estabilidade numérica)
    scores = scores / math.sqrt(d_k)

    # Máscara opcional (para decoder ou padding)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)

    # Pesos de atenção via softmax
    pesos = F.softmax(scores, dim=-1)

    # Saída: soma ponderada dos valores
    saida = torch.bmm(pesos, values)

    return saida, pesos

# Exemplo visual: atenção em uma frase
print("Exemplo: Atenção em 'O gato sentou no tapete'")
tokens = ["O", "gato", "sentou", "no", "tapete"]
n_tokens = len(tokens)
d_model  = 8

# Vetores aleatórios simulando embeddings
torch.manual_seed(42)
Q_ex = torch.randn(1, n_tokens, d_model)
K_ex = torch.randn(1, n_tokens, d_model)
V_ex = torch.randn(1, n_tokens, d_model)

saida_ex, pesos_ex = atencao_simples(Q_ex, K_ex, V_ex)

fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(
    pesos_ex[0].detach().numpy(),
    xticklabels=tokens,
    yticklabels=tokens,
    annot=True, fmt=".2f",
    cmap="YlOrRd",
    ax=ax, linewidths=0.5
)
ax.set_title("Pesos de Atenção\n"
             "(linha = query, coluna = key)\n"
             "[valores aleatórios para ilustração]")
ax.set_xlabel("Keys")
ax.set_ylabel("Queries")
plt.tight_layout()
plt.show()

3. Scaled Dot-Product Attention

class ScaledDotProductAttention(nn.Module):
    """
    Atenção por produto escalar escalado.
    Implementação de referência do paper 'Attention Is All You Need'.

    Fórmula:
      Attention(Q, K, V) = softmax(QKᵀ / √d_k) × V
    """

    def __init__(self, dropout: float = 0.0):
        super().__init__()
        self.dropout = nn.Dropout(dropout)

    def forward(self,
                Q: torch.Tensor,
                K: torch.Tensor,
                V: torch.Tensor,
                mask: torch.Tensor = None) -> tuple:
        """
        Q: (batch, heads, seq_q, d_k)
        K: (batch, heads, seq_k, d_k)
        V: (batch, heads, seq_v, d_v)  [seq_v == seq_k]
        """
        d_k = Q.shape[-1]

        # QKᵀ escalado: (batch, heads, seq_q, seq_k)
        scores = torch.matmul(Q, K.transpose(-2, -1))
        scores = scores / math.sqrt(d_k)

        # Máscara (padding ou causal)
        if mask is not None:
            scores = scores.masked_fill(
                mask == 0, float("-inf")
            )

        # Pesos de atenção
        pesos = F.softmax(scores, dim=-1)
        pesos = self.dropout(pesos)

        # Saída ponderada
        saida = torch.matmul(pesos, V)

        return saida, pesos

# Por que escalar por √d_k?
print("Por que escalar por √d_k?")
print("=" * 50)

for d_k in [4, 16, 64, 256]:
    q = torch.randn(100, d_k)
    k = torch.randn(100, d_k)
    scores = (q * k).sum(dim=1)

    scores_escalados = scores / math.sqrt(d_k)
    softmax_normal   = F.softmax(scores, dim=0)
    softmax_escalado = F.softmax(scores_escalados, dim=0)

    entropia_normal   = -(softmax_normal *
                          torch.log(softmax_normal + 1e-8)).sum()
    entropia_escalada = -(softmax_escalado *
                          torch.log(softmax_escalado + 1e-8)).sum()

    print(f"  d_k={d_k:3d}: "
          f"std_scores={scores.std():.2f}, "
          f"entropia_sem_escala={entropia_normal:.2f}, "
          f"entropia_com_escala={entropia_escalada:.2f}")

print("""
Com d_k grande, os produtos escalares ficam muito grandes → softmax
fica muito "peaky" (um token recebe quase todo o peso) → gradientes
muito pequenos. Dividir por √d_k mantém a variância dos scores ≈ 1.
""")

4. Multi-Head Attention

class MultiHeadAttention(nn.Module):
    """
    Multi-Head Attention: múltiplas 'cabeças' de atenção em paralelo.

    Intuição: cada cabeça aprende um tipo diferente de relação:
    - Cabeça 1: relações sintáticas (sujeito-verbo)
    - Cabeça 2: relações semânticas (coreference)
    - Cabeça 3: relações posicionais
    - etc.

    Fórmula:
      MultiHead(Q,K,V) = Concat(head₁,...,headₕ) × Wᴼ
      headᵢ = Attention(Q×Wᵢᴼ, K×Wᵢᴷ, V×Wᵢᵛ)
    """

    def __init__(self,
                  d_model: int,
                  n_heads: int,
                  dropout: float = 0.1):
        super().__init__()
        assert d_model % n_heads == 0, \
            "d_model deve ser divisível por n_heads"

        self.d_model  = d_model
        self.n_heads  = n_heads
        self.d_k      = d_model // n_heads

        # Projeções lineares para Q, K, V
        self.W_Q = nn.Linear(d_model, d_model, bias=False)
        self.W_K = nn.Linear(d_model, d_model, bias=False)
        self.W_V = nn.Linear(d_model, d_model, bias=False)

        # Projeção de saída
        self.W_O = nn.Linear(d_model, d_model)

        self.atencao = ScaledDotProductAttention(dropout)
        self.dropout = nn.Dropout(dropout)

    def _separar_cabecas(self,
                          x: torch.Tensor) -> torch.Tensor:
        """
        Separa o tensor em h cabeças.
        (batch, seq, d_model) → (batch, h, seq, d_k)
        """
        batch, seq, _ = x.shape
        x = x.view(batch, seq, self.n_heads, self.d_k)
        return x.transpose(1, 2)

    def _unir_cabecas(self,
                       x: torch.Tensor) -> torch.Tensor:
        """
        Une as h cabeças.
        (batch, h, seq, d_k) → (batch, seq, d_model)
        """
        batch, _, seq, _ = x.shape
        x = x.transpose(1, 2).contiguous()
        return x.view(batch, seq, self.d_model)

    def forward(self,
                Q: torch.Tensor,
                K: torch.Tensor,
                V: torch.Tensor,
                mask: torch.Tensor = None) -> tuple:
        """
        Q, K, V: (batch, seq, d_model)
        """
        # Projeções lineares + separar em cabeças
        Q = self._separar_cabecas(self.W_Q(Q))
        K = self._separar_cabecas(self.W_K(K))
        V = self._separar_cabecas(self.W_V(V))

        # Atenção em paralelo para todas as cabeças
        if mask is not None:
            mask = mask.unsqueeze(1)   # (batch, 1, seq, seq)

        x, pesos = self.atencao(Q, K, V, mask)

        # Unir cabeças e projeção final
        x = self._unir_cabecas(x)
        x = self.W_O(x)

        return x, pesos

# Teste
mha = MultiHeadAttention(d_model=64, n_heads=8)
x_mha = torch.randn(2, 10, 64)
saida_mha, pesos_mha = mha(x_mha, x_mha, x_mha)

print("Multi-Head Attention:")
print(f"  Entrada: {x_mha.shape}")
print(f"  Saída:   {saida_mha.shape}")
print(f"  Pesos:   {pesos_mha.shape} (batch, heads, q_len, k_len)")
print(f"  Parâmetros: "
      f"{sum(p.numel() for p in mha.parameters()):,}")

# Visualizar pesos por cabeça
fig, axes = plt.subplots(2, 4, figsize=(16, 8))
tokens_vis = [f"t{i}" for i in range(10)]

for h in range(8):
    ax = axes[h // 4, h % 4]
    sns.heatmap(
        pesos_mha[0, h].detach().numpy(),
        cmap="Blues",
        ax=ax,
        cbar=False,
        xticklabels=tokens_vis,
        yticklabels=tokens_vis
    )
    ax.set_title(f"Cabeça {h+1}", fontsize=9)
    ax.tick_params(labelsize=7)

plt.suptitle("Pesos de Atenção por Cabeça (8 cabeças)\n"
             "Cada cabeça aprende padrões diferentes",
             fontsize=12)
plt.tight_layout()
plt.show()

5. Positional Encoding

class PositionalEncoding(nn.Module):
    """
    Codificação posicional sinusoidal.

    O Transformer não tem recorrência — não sabe a ordem dos tokens.
    O Positional Encoding injeta informação de posição.

    Fórmula:
      PE(pos, 2i)   = sin(pos / 10000^(2i/d_model))
      PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
    """

    def __init__(self, d_model: int,
                  max_len: int = 5000,
                  dropout: float = 0.1):
        super().__init__()
        self.dropout = nn.Dropout(dropout)

        # Calcular encodings uma vez
        pe  = torch.zeros(max_len, d_model)
        pos = torch.arange(0, max_len).unsqueeze(1).float()

        # Denominadores: 10000^(2i/d_model)
        div = torch.exp(
            torch.arange(0, d_model, 2).float() *
            -(math.log(10000.0) / d_model)
        )

        pe[:, 0::2] = torch.sin(pos * div)   # dimensões pares
        pe[:, 1::2] = torch.cos(pos * div)   # dimensões ímpares

        # Registrar como buffer (não é parâmetro treinável)
        pe = pe.unsqueeze(0)   # (1, max_len, d_model)
        self.register_buffer("pe", pe)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """x: (batch, seq_len, d_model)"""
        x = x + self.pe[:, :x.shape[1], :]
        return self.dropout(x)

# Visualizar positional encoding
pe_vis = PositionalEncoding(d_model=64, dropout=0.0)
pe_mat = pe_vis.pe[0].numpy()   # (max_len, d_model)

fig, axes = plt.subplots(1, 2, figsize=(16, 5))

# Heatmap das primeiras posições
im = axes[0].imshow(
    pe_mat[:50, :32],
    cmap="RdBu", aspect="auto",
    vmin=-1, vmax=1
)
axes[0].set_xlabel("Dimensão do Embedding")
axes[0].set_ylabel("Posição")
axes[0].set_title("Positional Encoding (primeiras 50 posições)")
plt.colorbar(im, ax=axes[0])

# Sinusoides para algumas dimensões
posicoes = range(100)
for dim in [0, 1, 4, 5, 20, 21]:
    tipo = "sin" if dim % 2 == 0 else "cos"
    axes[1].plot(posicoes,
                  pe_mat[:100, dim],
                  label=f"dim {dim} ({tipo})",
                  linewidth=1.5)
axes[1].set_xlabel("Posição")
axes[1].set_ylabel("Valor")
axes[1].set_title("Sinusoides do Positional Encoding")
axes[1].legend(fontsize=8, ncol=2)

plt.tight_layout()
plt.show()

print("Por que funções sinusoidais?")
print("  1. Cada posição tem um padrão único")
print("  2. O modelo pode aprender atenção relativa:")
print("     PE(pos+k) pode ser expresso como função linear de PE(pos)")
print("  3. Funciona para sequências maiores que as vistas no treino")

6. Feed-Forward Network e Camada Transformer

class FeedForward(nn.Module):
    """
    Rede feed-forward aplicada posição a posição.
    Fórmula: FFN(x) = max(0, xW₁ + b₁)W₂ + b₂

    A expansão interna (d_ff >> d_model) cria capacidade
    de memorização e transformação não-linear.
    """

    def __init__(self,
                  d_model: int,
                  d_ff: int,
                  dropout: float = 0.1):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),           # mais suave que ReLU
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.rede(x)

class CamadaEncoder(nn.Module):
    """
    Uma camada do Encoder Transformer.

    Arquitetura (com Pre-LN para estabilidade):
      x → LayerNorm → MHA(x,x,x) → +x → LayerNorm → FFN → +x

    A versão original usa Post-LN:
      x → MHA(x,x,x) → LayerNorm(+x) → FFN → LayerNorm(+x)

    Pre-LN treina mais estável e não precisa de warm-up.
    """

    def __init__(self,
                  d_model: int,
                  n_heads: int,
                  d_ff: int,
                  dropout: float = 0.1):
        super().__init__()

        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.mha   = MultiHeadAttention(d_model, n_heads,
                                          dropout)
        self.ffn   = FeedForward(d_model, d_ff, dropout)
        self.drop  = nn.Dropout(dropout)

    def forward(self,
                x: torch.Tensor,
                mask: torch.Tensor = None) -> tuple:
        """
        x: (batch, seq, d_model)
        Retorna: (saida, pesos_atencao)
        """
        # Pre-LN + Self-Attention + Residual
        x_norm = self.norm1(x)
        attn_out, pesos = self.mha(x_norm, x_norm, x_norm,
                                    mask)
        x = x + self.drop(attn_out)

        # Pre-LN + FFN + Residual
        x = x + self.drop(self.ffn(self.norm2(x)))

        return x, pesos

class CamadaDecoder(nn.Module):
    """
    Uma camada do Decoder Transformer.

    Três sub-camadas:
    1. Masked Self-Attention (causal: não vê o futuro)
    2. Cross-Attention (atende ao encoder)
    3. Feed-Forward
    """

    def __init__(self,
                  d_model: int,
                  n_heads: int,
                  d_ff: int,
                  dropout: float = 0.1):
        super().__init__()

        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.norm3 = nn.LayerNorm(d_model)

        self.self_attn  = MultiHeadAttention(d_model, n_heads,
                                               dropout)
        self.cross_attn = MultiHeadAttention(d_model, n_heads,
                                               dropout)
        self.ffn        = FeedForward(d_model, d_ff, dropout)
        self.drop       = nn.Dropout(dropout)

    def forward(self,
                x: torch.Tensor,
                memoria: torch.Tensor,
                mask_tgt: torch.Tensor = None,
                mask_src: torch.Tensor = None) -> tuple:
        """
        x:       (batch, tgt_len, d_model)
        memoria: (batch, src_len, d_model) — saída do encoder
        """
        # 1. Masked Self-Attention
        x_norm = self.norm1(x)
        sa_out, pesos_sa = self.self_attn(
            x_norm, x_norm, x_norm, mask_tgt
        )
        x = x + self.drop(sa_out)

        # 2. Cross-Attention (decoder atende ao encoder)
        x_norm = self.norm2(x)
        ca_out, pesos_ca = self.cross_attn(
            x_norm, memoria, memoria, mask_src
        )
        x = x + self.drop(ca_out)

        # 3. Feed-Forward
        x = x + self.drop(self.ffn(self.norm3(x)))

        return x, pesos_sa, pesos_ca

# Verificar shapes
d_model = 64
n_heads = 8
d_ff    = 256

camada_enc = CamadaEncoder(d_model, n_heads, d_ff)
x_test     = torch.randn(2, 15, d_model)
saida_enc, pesos_enc = camada_enc(x_test)

print("CamadaEncoder:")
print(f"  Entrada: {x_test.shape}")
print(f"  Saída:   {saida_enc.shape}")
print(f"  Pesos de atenção: {pesos_enc.shape}")
n_enc_params = sum(p.numel() for p in camada_enc.parameters())
print(f"  Parâmetros: {n_enc_params:,}")

7. Transformer Completo

class TransformerEncoder(nn.Module):
    """
    Encoder do Transformer: pilha de N camadas de atenção.
    Usado em: BERT, classificação de texto, encoder de seq2seq.
    """

    def __init__(self,
                  vocab_size: int,
                  d_model: int = 128,
                  n_heads: int = 8,
                  n_camadas: int = 4,
                  d_ff: int = 512,
                  max_len: int = 512,
                  dropout: float = 0.1):
        super().__init__()

        self.d_model   = d_model
        self.embedding = nn.Embedding(vocab_size, d_model,
                                        padding_idx=0)
        self.pos_enc   = PositionalEncoding(d_model, max_len,
                                              dropout)
        self.camadas   = nn.ModuleList([
            CamadaEncoder(d_model, n_heads, d_ff, dropout)
            for _ in range(n_camadas)
        ])
        self.norm_final = nn.LayerNorm(d_model)

        # Inicialização
        self._inicializar_pesos()

    def _inicializar_pesos(self):
        for p in self.parameters():
            if p.dim() > 1:
                nn.init.xavier_uniform_(p)

    def criar_mascara_padding(
        self, x: torch.Tensor
    ) -> torch.Tensor:
        """
        Máscara para ignorar tokens de padding (índice 0).
        Retorna tensor booleano: True onde há conteúdo real.
        """
        return (x != 0).unsqueeze(1).unsqueeze(2)

    def forward(self,
                x: torch.Tensor) -> tuple:
        """
        x: (batch, seq_len) — índices dos tokens
        """
        # Máscara de padding
        mask = self.criar_mascara_padding(x).to(x.device)

        # Embedding + Positional Encoding
        # Escalar embeddings por √d_model (paper original)
        x = self.embedding(x) * math.sqrt(self.d_model)
        x = self.pos_enc(x)

        # Passar por todas as camadas
        todos_pesos = []
        for camada in self.camadas:
            x, pesos = camada(x, mask)
            todos_pesos.append(pesos)

        x = self.norm_final(x)
        return x, todos_pesos

class TransformerClassificador(nn.Module):
    """
    Transformer para classificação de sequências.
    Encoder + cabeça de classificação sobre o [CLS] token.
    """

    def __init__(self,
                  vocab_size: int,
                  n_classes: int,
                  d_model: int = 128,
                  n_heads: int = 8,
                  n_camadas: int = 4,
                  d_ff: int = 512,
                  max_len: int = 128,
                  dropout: float = 0.1):
        super().__init__()

        self.encoder = TransformerEncoder(
            vocab_size, d_model, n_heads,
            n_camadas, d_ff, max_len, dropout
        )

        # Cabeça de classificação
        self.classificador = nn.Sequential(
            nn.Linear(d_model, d_model // 2),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_model // 2, n_classes)
        )

    def forward(self, x: torch.Tensor) -> tuple:
        """
        x: (batch, seq_len)
        Estratégia: usar média de todos os tokens (mean pooling)
        """
        encoded, pesos = self.encoder(x)

        # Mean pooling sobre tokens não-padding
        mask_float = (x != 0).float().unsqueeze(-1)
        soma       = (encoded * mask_float).sum(dim=1)
        n_tokens   = mask_float.sum(dim=1).clamp(min=1)
        pooled     = soma / n_tokens

        logits = self.classificador(pooled)
        return logits, pesos

# Instanciar e inspecionar
vocab_size_t = 1000
transformer  = TransformerClassificador(
    vocab_size=vocab_size_t,
    n_classes=2,
    d_model=64,
    n_heads=4,
    n_camadas=2,
    d_ff=256,
    max_len=64,
    dropout=0.1
).to(device)

n_params_t = sum(p.numel() for p in transformer.parameters())
print(f"\nTransformer Classificador:")
print(f"  Parâmetros: {n_params_t:,}")

# Verificar shapes
x_t = torch.randint(0, vocab_size_t, (4, 30)).to(device)
logits_t, pesos_t = transformer(x_t)
print(f"  Input:   {x_t.shape}")
print(f"  Output:  {logits_t.shape}")
print(f"  Camadas de pesos: {len(pesos_t)}")
print(f"  Shape pesos[0]:   {pesos_t[0].shape}")

8. Máscaras no Transformer

def criar_mascara_causal(seq_len: int,
                           device: torch.device) -> torch.Tensor:
    """
    Máscara causal para o decoder.
    Impede que o token na posição t veja os tokens t+1, t+2, ...
    Isso garante autoregression: cada token só depende do passado.
    """
    # Triangular inferior: True nas posições válidas
    mask = torch.tril(torch.ones(seq_len, seq_len,
                                   device=device)).bool()
    return mask.unsqueeze(0).unsqueeze(0)

# Visualizar máscaras
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
seq_len_vis = 8

# Máscara causal
mask_causal = criar_mascara_causal(seq_len_vis, "cpu")
axes[0].imshow(mask_causal[0, 0].numpy(),
                cmap="Blues", vmin=0, vmax=1)
for i in range(seq_len_vis):
    for j in range(seq_len_vis):
        axes[0].text(j, i,
                      "✓" if mask_causal[0,0,i,j] else "✗",
                      ha="center", va="center",
                      fontsize=10,
                      color="white" if mask_causal[0,0,i,j]
                      else "gray")
axes[0].set_title("Máscara Causal (Decoder)\n"
                   "✓ = pode ver, ✗ = não pode ver")
axes[0].set_xlabel("Key Position")
axes[0].set_ylabel("Query Position")

# Máscara de padding
seq_com_pad = [1, 2, 3, 4, 0, 0, 0, 0]  # 4 tokens + 4 pads
mask_pad    = torch.tensor(seq_com_pad) != 0
mask_pad_2d = mask_pad.unsqueeze(0) & mask_pad.unsqueeze(1)

axes[1].imshow(mask_pad_2d.numpy().astype(float),
                cmap="Blues", vmin=0, vmax=1)
for i in range(len(seq_com_pad)):
    for j in range(len(seq_com_pad)):
        axes[1].text(j, i,
                      "✓" if mask_pad_2d[i,j] else "✗",
                      ha="center", va="center", fontsize=10,
                      color="white" if mask_pad_2d[i,j]
                      else "gray")
axes[1].set_title("Máscara de Padding\n"
                   "✓ = token real, ✗ = padding ignorado")
axes[1].set_xlabel("Key Position")
axes[1].set_ylabel("Query Position")
plt.tight_layout()
plt.show()

9. Treinando o Transformer

from torch.utils.data import Dataset, DataLoader
from sklearn.metrics import accuracy_score

# Dataset sintético de classificação de texto
class TextoTokenizadoDataset(Dataset):
    """Dataset de textos tokenizados para classificação."""

    def __init__(self, n: int = 3000,
                  vocab_size: int = 500,
                  max_len: int = 40,
                  n_classes: int = 3):
        np.random.seed(42)
        self.sequencias = []
        self.labels     = []

        for _ in range(n):
            label   = np.random.randint(0, n_classes)
            seq_len = np.random.randint(10, max_len)

            # Tokens de diferentes faixas por classe
            faixa_inicio = label * (vocab_size // n_classes) + 5
            faixa_fim    = faixa_inicio + (vocab_size // n_classes) - 5

            # Mistura de tokens da classe e tokens neutros
            n_classe = int(seq_len * 0.6)
            n_neutro = seq_len - n_classe

            tokens_classe = np.random.randint(
                faixa_inicio, faixa_fim, n_classe
            )
            tokens_neutros = np.random.randint(1, 10, n_neutro)

            seq = np.concatenate([tokens_classe, tokens_neutros])
            np.random.shuffle(seq)

            # Padding até max_len
            seq = seq[:max_len].tolist()
            seq += [0] * (max_len - len(seq))

            self.sequencias.append(seq)
            self.labels.append(label)

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, idx):
        return (
            torch.tensor(self.sequencias[idx],
                          dtype=torch.long),
            torch.tensor(self.labels[idx],
                          dtype=torch.long)
        )

# Criar dataset
ds_trans = TextoTokenizadoDataset(
    n=3000, vocab_size=500, max_len=40, n_classes=3
)

from torch.utils.data import random_split
n_tr_t = int(0.7 * len(ds_trans))
n_va_t = int(0.15 * len(ds_trans))
n_te_t = len(ds_trans) - n_tr_t - n_va_t

ds_tr_t, ds_va_t, ds_te_t = random_split(
    ds_trans, [n_tr_t, n_va_t, n_te_t],
    generator=torch.Generator().manual_seed(42)
)

dl_tr_t = DataLoader(ds_tr_t, batch_size=64, shuffle=True)
dl_va_t = DataLoader(ds_va_t, batch_size=64)
dl_te_t = DataLoader(ds_te_t, batch_size=64)

print(f"Dataset: Treino={len(ds_tr_t)}, "
      f"Val={len(ds_va_t)}, Teste={len(ds_te_t)}")

# Transformer para 3 classes
transformer_clf = TransformerClassificador(
    vocab_size=501,
    n_classes=3,
    d_model=64,
    n_heads=4,
    n_camadas=3,
    d_ff=256,
    max_len=50,
    dropout=0.1
).to(device)

n_clf = sum(p.numel() for p in transformer_clf.parameters())
print(f"Parâmetros: {n_clf:,}")

# Otimizador com warm-up (crucial para Transformers)
class WarmupScheduler:
    """
    Learning rate warm-up seguido de decaimento.
    O paper original usa: lr = d_model^(-0.5) × min(step^(-0.5), step×warmup^(-1.5))
    """

    def __init__(self, optimizer, d_model: int,
                  warmup_steps: int = 400):
        self.optimizer     = optimizer
        self.d_model       = d_model
        self.warmup_steps  = warmup_steps
        self.step_num      = 0

    def step(self):
        self.step_num += 1
        lr = (self.d_model ** (-0.5) *
               min(self.step_num ** (-0.5),
                   self.step_num *
                   self.warmup_steps ** (-1.5)))
        for pg in self.optimizer.param_groups:
            pg["lr"] = lr
        return lr

optimizer_t = torch.optim.Adam(
    transformer_clf.parameters(),
    lr=0, betas=(0.9, 0.98), eps=1e-9
)
scheduler_t  = WarmupScheduler(
    optimizer_t, d_model=64, warmup_steps=400
)
criterion_t  = nn.CrossEntropyLoss()

n_epocas_t  = 30
hist_t      = {"loss_tr": [], "acc_tr": [],
                "loss_va": [], "acc_va": []}
melhor_val_t = float("inf")
lrs_registrados = []

print(f"\nTreinando Transformer por {n_epocas_t} épocas...")

for epoca in range(n_epocas_t):
    # ── Treino ────────────────────────────────────────
    transformer_clf.train()
    losses_tr_t, n_c_tr_t, n_tot_tr_t = [], 0, 0

    for X_b, y_b in dl_tr_t:
        X_b = X_b.to(device)
        y_b = y_b.to(device)

        optimizer_t.zero_grad()
        logits_b, _ = transformer_clf(X_b)
        loss = criterion_t(logits_b, y_b)
        loss.backward()
        # Gradient clipping — importante para Transformers
        nn.utils.clip_grad_norm_(
            transformer_clf.parameters(), 1.0
        )
        scheduler_t.step()
        optimizer_t.step()

        losses_tr_t.append(loss.item())
        preds = logits_b.argmax(dim=1)
        n_c_tr_t   += (preds == y_b).sum().item()
        n_tot_tr_t += len(y_b)

    # Registrar LR desta época
    lrs_registrados.append(
        optimizer_t.param_groups[0]["lr"]
    )

    # ── Validação ─────────────────────────────────────
    transformer_clf.eval()
    losses_va_t, n_c_va_t, n_tot_va_t = [], 0, 0

    with torch.no_grad():
        for X_b, y_b in dl_va_t:
            X_b    = X_b.to(device)
            y_b    = y_b.to(device)
            logits_b, _ = transformer_clf(X_b)
            loss   = criterion_t(logits_b, y_b)
            losses_va_t.append(loss.item())
            preds  = logits_b.argmax(dim=1)
            n_c_va_t   += (preds == y_b).sum().item()
            n_tot_va_t += len(y_b)

    loss_tr_t = np.mean(losses_tr_t)
    acc_tr_t  = n_c_tr_t / n_tot_tr_t
    loss_va_t = np.mean(losses_va_t)
    acc_va_t  = n_c_va_t / n_tot_va_t

    hist_t["loss_tr"].append(loss_tr_t)
    hist_t["acc_tr"].append(acc_tr_t)
    hist_t["loss_va"].append(loss_va_t)
    hist_t["acc_va"].append(acc_va_t)

    if loss_va_t < melhor_val_t:
        melhor_val_t = loss_va_t
        torch.save(transformer_clf.state_dict(),
                    "transformer_melhor.pt")

    if (epoca + 1) % 10 == 0:
        lr_atual = optimizer_t.param_groups[0]["lr"]
        print(f"  Época {epoca+1:2d}/{n_epocas_t} | "
              f"Loss: {loss_tr_t:.4f}/{loss_va_t:.4f} | "
              f"Acc: {acc_tr_t:.4f}/{acc_va_t:.4f} | "
              f"LR: {lr_atual:.6f}")

# Avaliar
transformer_clf.load_state_dict(
    torch.load("transformer_melhor.pt", weights_only=True)
)
transformer_clf.eval()

preds_te_t, labels_te_t = [], []
with torch.no_grad():
    for X_b, y_b in dl_te_t:
        X_b = X_b.to(device)
        logits_b, _ = transformer_clf(X_b)
        p = logits_b.argmax(dim=1).cpu().numpy()
        preds_te_t.extend(p)
        labels_te_t.extend(y_b.numpy())

acc_transformer = accuracy_score(labels_te_t, preds_te_t)
print(f"\nAcurácia no teste: {acc_transformer:.4f}")

# Visualizações
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

ep_t = range(1, n_epocas_t + 1)
axes[0].plot(ep_t, hist_t["loss_tr"],
              label="Treino", color="steelblue",
              linewidth=2)
axes[0].plot(ep_t, hist_t["loss_va"],
              label="Validação", color="coral",
              linewidth=2, linestyle="--")
axes[0].set_xlabel("Época")
axes[0].set_ylabel("Loss")
axes[0].set_title("Loss de Treinamento")
axes[0].legend()

axes[1].plot(ep_t, hist_t["acc_tr"],
              label="Treino", color="steelblue",
              linewidth=2)
axes[1].plot(ep_t, hist_t["acc_va"],
              label="Validação", color="coral",
              linewidth=2, linestyle="--")
axes[1].set_xlabel("Época")
axes[1].set_ylabel("Acurácia")
axes[1].set_title("Acurácia")
axes[1].legend()

axes[2].plot(range(len(lrs_registrados)),
              lrs_registrados,
              color="mediumseagreen", linewidth=2)
axes[2].set_xlabel("Época")
axes[2].set_ylabel("Learning Rate")
axes[2].set_title("Warmup LR Schedule")

plt.suptitle("Treinamento do Transformer", fontsize=13)
plt.tight_layout()
plt.show()

10. Arquiteturas Baseadas em Transformer

print("\n" + "=" * 60)
print("ARQUITETURAS BASEADAS EM TRANSFORMER")
print("=" * 60)

print("""
Do paper original 'Attention Is All You Need' (2017)
surgiram três famílias de modelos:

┌─────────────────────────────────────────────────────────────┐
│                    ENCODER                                  │
│  Bidirecional: vê o contexto completo de cada token        │
│                                                              │
│  Modelos: BERT, RoBERTa, ALBERT, DeBERTa                   │
│  Tarefas:  Classificação, NER, QA extrativa                │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    DECODER                                  │
│  Causal: cada token só vê o que veio antes (left-to-right) │
│                                                              │
│  Modelos: GPT-2, GPT-3, GPT-4, LLaMA, Mistral             │
│  Tarefas:  Geração de texto, completamento, code            │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                 ENCODER-DECODER                             │
│  Encoder processa input, Decoder gera output               │
│                                                              │
│  Modelos: T5, BART, mT5                                     │
│  Tarefas:  Tradução, sumarização, geração condicionada     │
└─────────────────────────────────────────────────────────────┘
""")

# Comparação de complexidade computacional
print("Complexidade computacional por operação:")
print("-" * 55)
print(f"{'Operação':<30} {'Complexidade':>20}")
print("-" * 55)
operacoes = [
    ("Self-Attention",          "O(n² × d)"),
    ("RNN",                     "O(n × d²)"),
    ("CNN (kernel k)",          "O(k × n × d²)"),
    ("Posição para posição",    ""),
    ("  Self-Attention",         "O(1)"),
    ("  RNN",                    "O(n)"),
    ("  CNN (kernel k)",         "O(log n)"),
]
for op, comp in operacoes:
    print(f"{op:<30} {comp:>20}")

print("""
n = comprimento da sequência, d = dimensão do modelo

Trade-off da Self-Attention:
  ✓ O(1) passos para conectar quaisquer dois tokens
  ✗ O(n²) em memória e tempo — quadrático na sequência

Para sequências longas (n > 1024), surgem variantes eficientes:
  - Sparse Attention (Longformer, BigBird): O(n)
  - Linear Attention (Performer): O(n)
  - Flash Attention: O(n²) mas com IO otimizado
""")

# Scaling Laws — por que modelos maiores funcionam melhor
print("Scaling Laws (Kaplan et al., 2020):")
print("-" * 45)
tamanhos = [1e6, 1e7, 1e8, 1e9, 1e10]
for n_params in tamanhos:
    # Loss ≈ C × N^(-α) — relação empírica
    loss_approx = 10 * (n_params ** (-0.076))
    print(f"  {n_params:.0e} parâmetros → "
          f"loss ≈ {loss_approx:.3f}")

print("""
  A loss melhora previsivelmente com:
    - Mais parâmetros (N)
    - Mais dados de treino (D)
    - Mais compute (C = N × D × 6)
""")

Resumo da Aula

  • Atenção calcula compatibilidade entre Query, Key e Value: Attention = softmax(QKᵀ/√d_k) × V
  • Escalar por √d_k evita saturação do softmax para dimensões grandes
  • Multi-Head Attention aplica atenção em h subsubespaços em paralelo — cada cabeça aprende um tipo de relação
  • Positional Encoding injeta informação de ordem usando sinusoides — sem ele, o Transformer não sabe a posição dos tokens
  • O bloco Transformer combina: Multi-Head Self-Attention + Feed-Forward + LayerNorm + Residual
  • O Decoder usa duas atenções: masked self-attention (causal) e cross-attention (sobre o encoder)
  • Máscara causal impede que o decoder veja tokens futuros durante o treino
  • Warm-up do learning rate é crucial para Transformers: começa pequeno, cresce, depois decai
  • Três famílias: Encoder-only (BERT), Decoder-only (GPT), Encoder-Decoder (T5)
  • A complexidade O(n²) da self-attention é o principal gargalo para sequências longas
  • Scaling Laws mostram que performance melhora previsivelmente com mais parâmetros e dados

Exercícios

  1. Explique o mecanismo de Query-Key-Value da atenção usando uma analogia concreta (não use exemplos de programação). Por que esse mecanismo é mais poderoso que simplesmente calcular a similaridade direta entre tokens?

    ✓ Resposta:

    Analogia com uma biblioteca: você chega com uma lista de necessidades (Query — o que você quer aprender). Cada livro tem uma etiqueta no dorso descrevendo seu conteúdo (Key — o que cada fonte oferece) e o conteúdo real das páginas (Value — a informação que você vai absorver). Você compara sua necessidade com cada etiqueta, dá mais peso às fontes mais relevantes, e absorve uma mistura ponderada de todas as fontes, com as mais relevantes contribuindo mais.

    Essa abordagem é mais poderosa que similaridade direta por três razões. Primeiro, as projeções lineares (W_Q, W_K, W_V) permitem que o modelo aprenda qual aspecto de cada token é relevante para a busca (Keys), qual é relevante para ser buscado (Queries) e qual informação extrair (Values) — são três papéis diferentes para o mesmo token. Segundo, múltiplas cabeças permitem buscar diferentes tipos de relação simultaneamente. Terceiro, as projeções criam um espaço de atenção onde relações semânticas, sintáticas e posicionais podem ser aprendidas independentemente.

  2. Por que o Transformer precisa de Positional Encoding? O que aconteceria se você removesse completamente o PE? Explique por que funções sinusoidais foram escolhidas em vez de embeddings de posição aprendíveis.

    ✓ Resposta:

    O Transformer processa todos os tokens em paralelo, sem recorrência ou convoluções. Sem PE, "o gato comeu o rato" e "o rato comeu o gato" seriam representações idênticas — mesmos tokens, mesmos pesos de atenção. O modelo seria invariante à permutação, o que é catastrófico para linguagem onde ordem é fundamental.

    Sem PE, o modelo ainda aprenderia algo útil em tarefas onde ordem não importa (como classificação por Bag-of-Words), mas falharia completamente em tradução, geração e compreensão de relações gramaticais.

    Funções sinusoidais foram escolhidas sobre embeddings aprendíveis por três razões: generalização para comprimentos maiores que os vistos no treino (os senos e cossenos extrapolam matematicamente, embeddings aprendíveis não têm parâmetros para posições novas), a relação PE(pos+k) pode ser expressa como transformação linear de PE(pos), permitindo que o modelo aprenda atenção relativa, e sem parâmetros extras o modelo tem a mesma performance com menos complexidade.

  3. Explique a diferença entre self-attention, cross-attention e masked self-attention. Em qual parte da arquitetura Transformer cada uma é usada e por quê?

    ✓ Resposta:

    Self-attention: Q, K e V vêm todos da mesma sequência. Cada token atende a todos os outros tokens da mesma sequência, incluindo a si mesmo. Usada no Encoder (bidirecional — vê todos os tokens) para construir representações contextuais.

    Masked self-attention: Q, K e V vêm da mesma sequência, mas a máscara causal impede que a posição t veja posições t+1, t+2, etc. Usada na primeira sub-camada do Decoder durante o treino para simular geração autoregressiva — cada token só pode "ver" os tokens anteriores, como na geração real.

    Cross-attention: Q vem do Decoder (o que estou gerando), mas K e V vêm do Encoder (a sequência de entrada). Usada na segunda sub-camada do Decoder para que cada token gerado possa "olhar" para toda a sequência de entrada e decidir de onde extrair informação relevante.

  4. Por que o Multi-Head Attention é melhor que usar uma única cabeça de atenção grande? O que cada cabeça aprende na prática? Quais são as implicações em termos de parâmetros?

    ✓ Resposta:

    Uma única cabeça grande de atenção com d_model dimensões aprende um único tipo de relação — a projeção linear faz tudo convergir para um único padrão de atenção.

    Com h cabeças menores (cada uma com d_model/h dimensões), cada cabeça projeta Q, K, V em subespaços diferentes e aprende a focar em diferentes tipos de relações. Em modelos BERT treinados, análises empíricas identificaram cabeças especializadas em: atenção a tokens adjacentes, relações de coreferência (pronome → antecedente), verbos e seus sujeitos, tokens de pontuação, e relações semânticas.

    Em termos de parâmetros: h cabeças de dimensão d_k = d_model/h têm exatamente o mesmo número de parâmetros que uma cabeça de dimensão d_model (as projeções W_Q, W_K, W_V de tamanho d_model × d_model somadas). A diversidade vem da inicialização diferente e do gradiente que cada cabeça recebe, não de mais parâmetros.

  5. Explique o warm-up do learning rate. Por que os Transformers precisam dessa estratégia enquanto muitos outros modelos não precisam? O que acontece se você não usar warm-up ao treinar um Transformer do zero?

    ✓ Resposta:

    No início do treino, os pesos do Transformer são aleatórios e os gradientes são instáveis. O Adam com learning rate grande pode fazer updates muito grandes que destroem as representações nos primeiros passos — a rede diverge ou converge para soluções ruins.

    O warm-up começa com LR muito pequeno (quase zero), aumenta linearmente por warmup_steps (tipicamente 1000-4000 steps), depois decai seguindo um schedule. Isso permite que: - Os primeiros gradientes instáveis façam apenas pequenas correções - O modelo construa gradualmente representações coerentes antes de dar passos grandes - A normalização dos momentos do Adam se estabilize antes de usar LR grande

    MLPs e CNNs simples são menos sensíveis porque têm menos camadas residuais e a escala dos gradientes é mais previsível. Os Transformers têm múltiplas camadas de atenção interconectadas onde instabilidade em uma camada amplifica instabilidade em todas as outras.

    Sem warm-up: a loss geralmente diverge nos primeiros epochs, os gradientes explodem ou o modelo fica preso em um mínimo ruim, e a acurácia final é significativamente pior. Em experimentos práticos, treinar BERT sem warm-up pode resultar em 5-10% menos de acurácia em tarefas downstream.

  6. Compare as três arquiteturas Transformer (Encoder-only, Decoder-only, Encoder-Decoder) em termos de: mecanismo de atenção usado, tarefas adequadas e exemplos de modelos. Por que GPT (decoder-only) se tornou o paradigma dominante para LLMs?

    ✓ Resposta:

    Encoder-only (BERT, RoBERTa): usa self-attention bidirecional — cada token vê todos os outros, incluindo os futuros. Isso é ótimo para compreensão porque o contexto completo melhora a representação de cada token. Não pode gerar texto (precisa de toda a sequência). Adequado para: classificação de texto, NER, extração de relações, QA extrativa (encontrar span na passagem).

    Decoder-only (GPT, LLaMA): usa masked self-attention causal — cada token só vê tokens anteriores. Treinado para prever o próximo token dado o contexto. Pode gerar texto de forma autoregressive. Adequado para: geração de texto, completamento, code generation, e com prompting qualquer tarefa.

    Encoder-Decoder (T5, BART): encoder bidirecional processa a entrada, decoder causal com cross-attention gera a saída. Mais parâmetros para a mesma capacidade, mas arquitetura naturalmente adequada para seq2seq. Adequado para: tradução, sumarização, geração condicionada a um input estruturado.

    GPT se tornou dominante por vários motivos convergentes: a escala funciona melhor em decoders (os scaling laws são mais favoráveis), o pré-treinamento por predição do próximo token usa naturalmente todo o texto disponível sem necessidade de labels, o in-context learning emergiu espontaneamente em modelos grandes (GPT-3 mostrou que com exemplos no prompt o modelo resolve tarefas sem fine-tuning), e a interface de input/output unificada (tudo é texto) simplifica o deployment.

Referências