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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- Attention Is All You Need (Vaswani et al., 2017)
- BERT paper (Devlin et al., 2018)
- GPT-3 paper (Brown et al., 2020)
- Jay Alammar — The Illustrated Transformer
- Jay Alammar — The Illustrated BERT
- Andrej Karpathy — Let's build GPT from scratch
- Harvard NLP — The Annotated Transformer
- Scaling Laws for Neural Language Models (Kaplan et al., 2020)
- Flash Attention (Dao et al., 2022)
- PyTorch — nn.Transformer