Objetivo

Entender a arquitetura interna de GPT e BERT, como o pré-treinamento os torna tão poderosos, as diferenças fundamentais entre modelos encoder-only e decoder-only, e como usar esses modelos via Hugging Face para tarefas práticas sem treinar do zero.


1. O que são LLMs e por que são revolucionários?

Large Language Models (LLMs) são modelos Transformer treinados em quantidades massivas de texto com o objetivo de aprender a representação estatística da linguagem. O que os torna revolucionários não é a arquitetura em si (é o Transformer da aula anterior), mas a escala e a forma de treinamento:

  • GPT-3: 175 bilhões de parâmetros, treinado em ~300 bilhões de tokens
  • BERT-large: 340 milhões de parâmetros, treinado em toda a Wikipedia + BookCorpus
  • LLaMA-3: 70 bilhões de parâmetros, treinado em 15 trilhões de tokens

A propriedade mais surpreendente: capacidades emergentes — comportamentos complexos que surgem espontaneamente em modelos grandes sem serem explicitamente programados, como raciocínio em poucos passos, tradução zero-shot e geração de código.


2. BERT — Bidirectional Encoder Representations from Transformers

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}")

# ── Arquitetura BERT ──────────────────────────────────────
print("BERT — Arquitetura e Pré-treinamento:")
print("=" * 55)
print("""
Arquitetura: Transformer Encoder (bidirecional)
  BERT-base:  12 camadas, 768 dimensões, 12 cabeças → 110M params
  BERT-large: 24 camadas, 1024 dimensões, 16 cabeças → 340M params

Tokens especiais:
  [CLS] → começo de toda sequência (usado para classificação)
  [SEP] → separador entre sentenças (pares de texto)
  [MASK] → token mascarado para MLM
  [PAD] → preenchimento para lotes

Formato de entrada:
  [CLS] token₁ token₂ ... [SEP] token₁ token₂ ... [SEP]
  ↑                                ↑
  Sentença A                       Sentença B

Pré-treinamento com 2 tarefas simultâneas:
  1. MLM (Masked Language Model): prever tokens mascarados
  2. NSP (Next Sentence Prediction): prever se B segue A
""")

class BERTMiniatura(nn.Module):
    """
    BERT em miniatura para fins didáticos.
    Implementa MLM e NSP como no paper original.
    """

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

        self.d_model = d_model

        # Três tipos de embedding somados
        self.token_embedding   = nn.Embedding(
            vocab_size, d_model, padding_idx=0
        )
        self.posicao_embedding = nn.Embedding(
            max_len, d_model
        )
        self.segmento_embedding = nn.Embedding(
            n_segmentos + 1, d_model
        )

        self.norm_emb  = nn.LayerNorm(d_model)
        self.dropout   = nn.Dropout(dropout)

        # Camadas Transformer Encoder
        encoder_layer  = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=n_heads,
            dim_feedforward=d_ff,
            dropout=dropout,
            batch_first=True,
            norm_first=True    # Pre-LN
        )
        self.encoder   = nn.TransformerEncoder(
            encoder_layer, num_layers=n_camadas,
            norm=nn.LayerNorm(d_model)
        )

        # Cabeça de MLM (Masked Language Modeling)
        self.mlm_head  = nn.Sequential(
            nn.Linear(d_model, d_model),
            nn.GELU(),
            nn.LayerNorm(d_model),
            nn.Linear(d_model, vocab_size)
        )

        # Cabeça de NSP (Next Sentence Prediction)
        self.pooler    = nn.Sequential(
            nn.Linear(d_model, d_model),
            nn.Tanh()
        )
        self.nsp_head  = nn.Linear(d_model, 2)

        self._inicializar()

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

    def forward(self,
                tokens: torch.Tensor,
                segmentos: torch.Tensor = None,
                mask_padding: torch.Tensor = None) -> dict:
        """
        tokens:     (batch, seq_len) — índices
        segmentos:  (batch, seq_len) — 0 para A, 1 para B
        mask_padding: (batch, seq_len) — True onde é padding
        """
        batch, seq_len = tokens.shape

        # Posições
        posicoes = torch.arange(seq_len, device=tokens.device)
        posicoes = posicoes.unsqueeze(0).expand(batch, -1)

        # Segmentos (padrão: tudo segmento 0)
        if segmentos is None:
            segmentos = torch.zeros_like(tokens)

        # Embedding total = token + posição + segmento
        x = (self.token_embedding(tokens)
              + self.posicao_embedding(posicoes)
              + self.segmento_embedding(segmentos))
        x = self.dropout(self.norm_emb(x))

        # Máscara de padding para o Transformer
        if mask_padding is not None:
            src_key_padding_mask = mask_padding
        else:
            src_key_padding_mask = (tokens == 0)

        # Encoder
        hidden = self.encoder(
            x,
            src_key_padding_mask=src_key_padding_mask
        )

        # Saídas para as duas tarefas
        # MLM: predição para todos os tokens
        logits_mlm = self.mlm_head(hidden)

        # NSP: usar representação do [CLS] (posição 0)
        cls_rep    = self.pooler(hidden[:, 0, :])
        logits_nsp = self.nsp_head(cls_rep)

        return {
            "hidden":     hidden,
            "logits_mlm": logits_mlm,
            "logits_nsp": logits_nsp,
            "cls_rep":    cls_rep,
        }

# Instanciar BERT miniatura
bert = BERTMiniatura(
    vocab_size=1000,
    d_model=128,
    n_heads=4,
    n_camadas=4,
    d_ff=256,
    max_len=64
).to(device)

n_params_bert = sum(p.numel() for p in bert.parameters())
print(f"\nBERT Miniatura:")
print(f"  Parâmetros: {n_params_bert:,}")
print(f"  (BERT-base real: ~110 milhões)")

# Teste forward
tokens_demo = torch.randint(1, 1000, (2, 20)).to(device)
seg_demo    = torch.zeros_like(tokens_demo)
seg_demo[:, 10:] = 1   # segunda sentença

saidas_bert = bert(tokens_demo, seg_demo)
print(f"\nForward pass BERT:")
print(f"  Input:       {tokens_demo.shape}")
print(f"  Hidden:      {saidas_bert['hidden'].shape}")
print(f"  MLM logits:  {saidas_bert['logits_mlm'].shape}")
print(f"  NSP logits:  {saidas_bert['logits_nsp'].shape}")
print(f"  CLS rep:     {saidas_bert['cls_rep'].shape}")

3. Pré-treinamento BERT: MLM e NSP

def criar_dados_mlm(tokens: torch.Tensor,
                     vocab_size: int,
                     mask_prob: float = 0.15,
                     mask_token_id: int = 103) -> tuple:
    """
    Implementa o mascaramento do BERT para MLM.

    Estratégia:
      80% → substitui por [MASK]
      10% → substitui por token aleatório
      10% → mantém o token original
    """
    labels    = tokens.clone()
    mascara   = torch.rand(tokens.shape) < mask_prob

    # Não mascarar tokens especiais (CLS, SEP, PAD)
    mascara  &= (tokens > 3)

    # 80% → [MASK]
    proporcao_mask = torch.rand(tokens.shape) < 0.8
    tokens_mod     = tokens.clone()
    tokens_mod[mascara & proporcao_mask] = mask_token_id

    # 10% → token aleatório (dos 80% restantes, metade)
    proporcao_random = (~proporcao_mask) & (torch.rand(tokens.shape) < 0.5)
    tokens_random    = torch.randint(4, vocab_size, tokens.shape)
    tokens_mod[mascara & proporcao_random] = tokens_random[mascara & proporcao_random]

    # Onde não foi mascarado, labels = -100 (ignorado pelo CrossEntropy)
    labels[~mascara] = -100

    return tokens_mod, labels, mascara

# Demonstrar MLM
print("\nDemonstração do Mascaramento MLM:")
tokens_ex  = torch.tensor([[5, 23, 67, 12, 89, 34, 56, 4]])
tokens_mod, labels_mlm, mascara = criar_dados_mlm(
    tokens_ex, vocab_size=1000
)
print(f"  Original:  {tokens_ex[0].tolist()}")
print(f"  Mascarado: {tokens_mod[0].tolist()}")
print(f"  Labels:    {labels_mlm[0].tolist()}")
print(f"  Máscara:   {mascara[0].tolist()}")
print(f"  (Labels -100 = não mascarado, ignorado na loss)")

# Loop de pré-treinamento simplificado
def pretreinamento_bert_epoch(modelo: BERTMiniatura,
                                dataloader,
                                optimizer,
                                device: torch.device,
                                vocab_size: int) -> dict:
    """Executa uma época de pré-treinamento BERT."""
    modelo.train()
    crit_mlm = nn.CrossEntropyLoss(ignore_index=-100)
    crit_nsp = nn.CrossEntropyLoss()

    losses_mlm, losses_nsp = [], []

    for batch in dataloader:
        tokens, segmentos, labels_nsp = batch
        tokens     = tokens.to(device)
        segmentos  = segmentos.to(device)
        labels_nsp = labels_nsp.to(device)

        # Mascaramento para MLM
        tokens_masked, labels_mlm, _ = criar_dados_mlm(
            tokens, vocab_size
        )
        labels_mlm = labels_mlm.to(device)

        optimizer.zero_grad()
        saidas = modelo(tokens_masked, segmentos)

        # Loss MLM
        loss_mlm = crit_mlm(
            saidas["logits_mlm"].view(-1, vocab_size),
            labels_mlm.view(-1)
        )

        # Loss NSP
        loss_nsp = crit_nsp(saidas["logits_nsp"], labels_nsp)

        # Loss total
        loss = loss_mlm + loss_nsp
        loss.backward()
        nn.utils.clip_grad_norm_(modelo.parameters(), 1.0)
        optimizer.step()

        losses_mlm.append(loss_mlm.item())
        losses_nsp.append(loss_nsp.item())

    return {
        "loss_mlm": np.mean(losses_mlm),
        "loss_nsp": np.mean(losses_nsp),
    }

print("\nPré-treinamento BERT (conceitual — dados sintéticos):")
print("  Tarefa 1 — MLM: prever tokens [MASK]")
print("  Tarefa 2 — NSP: prever se sentença B segue sentença A")
print("  Na prática: treina em Wikipedia + BookCorpus por dias/semanas em GPUs")

4. GPT — Generative Pre-trained Transformer

print("\n" + "=" * 55)
print("GPT — Arquitetura e Geração Autoregressiva")
print("=" * 55)
print("""
Arquitetura: Transformer Decoder (apenas)
  GPT-1: 12 camadas, 768 dim, 12 cabeças → 117M params
  GPT-2: 48 camadas, 1600 dim, 25 cabeças → 1.5B params
  GPT-3: 96 camadas, 12288 dim, 96 cabeças → 175B params
  GPT-4: estimado >1T params (arquitetura não revelada)

Diferença fundamental do BERT:
  - Unidirecional (causal): cada token só vê tokens anteriores
  - Treinado para prever o PRÓXIMO token
  - Não usa [MASK] — aprende de todo o texto disponível

Pré-treinamento: Language Modeling
  Dado: "O gato sentou no ___"
  Prever: "tapete"

Geração autoregressiva:
  "O" → prever "gato"
  "O gato" → prever "sentou"
  "O gato sentou" → prever "no"
  ...
""")

class GPTMiniatura(nn.Module):
    """
    GPT em miniatura: Transformer Decoder-only.
    """

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

        self.d_model   = d_model
        self.max_len   = max_len
        self.vocab_size = vocab_size

        self.embedding = nn.Embedding(vocab_size, d_model,
                                        padding_idx=0)
        self.pos_emb   = nn.Embedding(max_len, d_model)
        self.drop      = nn.Dropout(dropout)

        # Decoder-only: cada camada tem APENAS masked self-attention
        decoder_layer = nn.TransformerDecoderLayer(
            d_model=d_model,
            nhead=n_heads,
            dim_feedforward=d_ff,
            dropout=dropout,
            batch_first=True,
            norm_first=True
        )
        self.decoder   = nn.TransformerDecoder(
            decoder_layer, num_layers=n_camadas,
            norm=nn.LayerNorm(d_model)
        )

        # Cabeça de linguagem: projeta para vocab
        self.lm_head   = nn.Linear(d_model, vocab_size,
                                    bias=False)

        # Weight tying: compartilhar pesos de embedding e LM head
        self.lm_head.weight = self.embedding.weight

        self._inicializar()

    def _inicializar(self):
        for p in self.parameters():
            if p.dim() > 1:
                nn.init.normal_(p, std=0.02)

    def _criar_mascara_causal(self,
                               seq_len: int) -> torch.Tensor:
        """Cria máscara triangular inferior (causal)."""
        mask = torch.triu(
            torch.ones(seq_len, seq_len), diagonal=1
        ).bool()
        return mask

    def forward(self,
                x: torch.Tensor) -> torch.Tensor:
        """
        x: (batch, seq_len)
        Retorna logits: (batch, seq_len, vocab_size)
        """
        batch, seq_len = x.shape

        # Posições
        pos = torch.arange(seq_len, device=x.device)
        pos = pos.unsqueeze(0).expand(batch, -1)

        # Embedding
        emb = self.drop(
            self.embedding(x) * math.sqrt(self.d_model)
            + self.pos_emb(pos)
        )

        # Máscara causal
        causal_mask = self._criar_mascara_causal(
            seq_len
        ).to(x.device)

        # Em PyTorch TransformerDecoder sem encoder:
        # usamos o tensor como "memory" também (hack)
        out = self.decoder(
            emb, emb,
            tgt_mask=causal_mask,
            memory_mask=causal_mask
        )

        # Logits sobre o vocabulário
        return self.lm_head(out)

    @torch.no_grad()
    def gerar(self,
               prompt: torch.Tensor,
               max_novos_tokens: int = 50,
               temperatura: float = 1.0,
               top_k: int = 50,
               top_p: float = 0.9) -> torch.Tensor:
        """
        Gera texto autoregressivamente.

        temperatura: > 1 = mais criativo, < 1 = mais conservador
        top_k:       manter apenas os k tokens mais prováveis
        top_p:       nucleus sampling — manter tokens até acumular p
        """
        self.eval()
        sequencia = prompt.clone()

        for _ in range(max_novos_tokens):
            # Truncar se exceder max_len
            seq_in  = sequencia[:, -self.max_len:]

            # Forward
            logits  = self(seq_in)
            # Pegar logits do ÚLTIMO token
            logits  = logits[:, -1, :] / temperatura

            # Top-k filtering
            if top_k > 0:
                valores, _ = torch.topk(logits, top_k)
                limiar_k   = valores[:, -1].unsqueeze(-1)
                logits      = logits.masked_fill(
                    logits < limiar_k, float("-inf")
                )

            # Top-p (nucleus) filtering
            if top_p < 1.0:
                probs_sorted, indices_sorted = torch.sort(
                    F.softmax(logits, dim=-1),
                    descending=True
                )
                cumsum = torch.cumsum(probs_sorted, dim=-1)
                remover = (cumsum - probs_sorted) > top_p
                probs_sorted[remover] = 0.0
                probs_sorted = probs_sorted / probs_sorted.sum()
                idx_sample   = torch.multinomial(
                    probs_sorted, 1
                )
                proximo = indices_sorted.gather(-1, idx_sample)
            else:
                probs   = F.softmax(logits, dim=-1)
                proximo = torch.multinomial(probs, 1)

            sequencia = torch.cat([sequencia, proximo], dim=1)

        return sequencia

# Instanciar GPT miniatura
gpt = GPTMiniatura(
    vocab_size=1000,
    d_model=128,
    n_heads=4,
    n_camadas=4,
    d_ff=256,
    max_len=128
).to(device)

n_params_gpt = sum(p.numel() for p in gpt.parameters())
print(f"\nGPT Miniatura:")
print(f"  Parâmetros: {n_params_gpt:,}")

# Teste de geração
prompt_ex  = torch.randint(1, 100, (1, 5)).to(device)
gerado_ex  = gpt.gerar(prompt_ex, max_novos_tokens=10,
                         temperatura=1.0, top_k=20)
print(f"\nGeração autoregressiva:")
print(f"  Prompt:  {prompt_ex[0].tolist()}")
print(f"  Gerado:  {gerado_ex[0].tolist()}")
print(f"  (índices de tokens — sem vocabulário real aqui)")

5. Pré-treinamento GPT: Language Modeling

# Dataset sintético de sequências de tokens
class LMDataset(torch.utils.data.Dataset):
    """
    Dataset para Language Modeling.
    Cada exemplo é uma sequência; label é a mesma sequência
    deslocada em 1 posição (próximo token).
    """

    def __init__(self, n: int = 5000,
                  seq_len: int = 32,
                  vocab_size: int = 200):
        np.random.seed(42)
        self.seqs = []

        # Simular padrões: sequências com estrutura
        padroes = [
            list(range(1, 20)),        # sequência crescente
            list(range(20, 1, -1)),    # sequência decrescente
            [i % 10 + 1 for i in range(20)],  # repetição cíclica
        ]

        for _ in range(n):
            padrao = padroes[np.random.randint(len(padroes))]
            inicio = np.random.randint(0, len(padrao))
            seq    = []
            for i in range(seq_len + 1):
                idx = (inicio + i) % len(padrao)
                seq.append(padrao[idx])
            # Adicionar ruído
            seq = [max(1, min(vocab_size-1,
                               s + np.random.randint(-1, 2)))
                    for s in seq]
            self.seqs.append(seq)

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

    def __getitem__(self, idx):
        seq = torch.tensor(self.seqs[idx], dtype=torch.long)
        return seq[:-1], seq[1:]   # input, target

ds_lm  = LMDataset(n=3000, seq_len=32, vocab_size=200)
dl_lm  = torch.utils.data.DataLoader(
    ds_lm, batch_size=64, shuffle=True
)

# Pré-treinar GPT
gpt_lm  = GPTMiniatura(
    vocab_size=201, d_model=64, n_heads=4,
    n_camadas=3, d_ff=256, max_len=64
).to(device)

opt_lm  = torch.optim.AdamW(
    gpt_lm.parameters(), lr=3e-4, weight_decay=0.1
)
crit_lm = nn.CrossEntropyLoss()

print("Pré-treinando GPT por Language Modeling...")
hist_lm = []

for epoca in range(20):
    gpt_lm.train()
    losses_ep = []
    for x_b, y_b in dl_lm:
        x_b = x_b.to(device)
        y_b = y_b.to(device)

        opt_lm.zero_grad()
        logits = gpt_lm(x_b)
        loss   = crit_lm(
            logits.view(-1, 201),
            y_b.view(-1)
        )
        loss.backward()
        nn.utils.clip_grad_norm_(gpt_lm.parameters(), 1.0)
        opt_lm.step()
        losses_ep.append(loss.item())

    loss_ep = np.mean(losses_ep)
    hist_lm.append(loss_ep)

    if (epoca + 1) % 5 == 0:
        # Calcular perplexidade
        perplexidade = math.exp(loss_ep)
        print(f"  Época {epoca+1:2d}/20 | "
              f"Loss: {loss_ep:.4f} | "
              f"Perplexidade: {perplexidade:.2f}")

# Testar geração após pré-treinamento
gpt_lm.eval()
prompt_test = torch.tensor([[1, 2, 3]]).to(device)
gerado_test = gpt_lm.gerar(
    prompt_test,
    max_novos_tokens=15,
    temperatura=0.7,
    top_k=10
)
print(f"\nGeração pós-treinamento:")
print(f"  Prompt: {prompt_test[0].tolist()}")
print(f"  Gerado: {gerado_test[0].tolist()}")
print(f"  (modelo aprende padrões sequenciais)")

6. Comparação BERT vs GPT

print("\n" + "=" * 60)
print("BERT vs GPT: COMPARAÇÃO DETALHADA")
print("=" * 60)

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

# Diagrama BERT
axes[0].set_xlim(0, 10)
axes[0].set_ylim(0, 10)
axes[0].axis("off")
axes[0].set_title("BERT (Encoder-only)\nBidirecional",
                   fontsize=13, fontweight="bold")

# Tokens
tokens_bert = ["[CLS]", "o", "gato", "[MASK]", "no", "[SEP]"]
for i, tok in enumerate(tokens_bert):
    cor = "#FFB347" if tok in ["[CLS]", "[SEP]"] else \
          "#FF6B6B" if tok == "[MASK]" else "#87CEEB"
    rect = plt.Rectangle((i * 1.5 + 0.2, 1), 1.2, 0.6,
                           color=cor, ec="gray", lw=1.5)
    axes[0].add_patch(rect)
    axes[0].text(i * 1.5 + 0.8, 1.35, tok,
                  ha="center", va="center", fontsize=8,
                  fontweight="bold")

# Setas bidirecionais entre tokens
for i in range(len(tokens_bert)):
    for j in range(len(tokens_bert)):
        if i != j:
            axes[0].annotate("",
                xy=(j * 1.5 + 0.8, 2.5),
                xytext=(i * 1.5 + 0.8, 2.5),
                arrowprops=dict(
                    arrowstyle="->", color="steelblue",
                    alpha=0.15, lw=0.8
                )
            )

# Representações contextuais
for i, tok in enumerate(tokens_bert):
    cor = "#FFB347" if tok in ["[CLS]", "[SEP]"] else \
          "#FF6B6B" if tok == "[MASK]" else "#90EE90"
    rect2 = plt.Rectangle((i * 1.5 + 0.2, 3.5), 1.2, 0.6,
                            color=cor, ec="gray", lw=1.5)
    axes[0].add_patch(rect2)

axes[0].text(4.5, 4.2, "Prediz token mascarado", ha="center",
              fontsize=9, color="red", fontweight="bold")
axes[0].text(4.5, 4.8, "[CLS] → classificação",
              ha="center", fontsize=9, color="darkorange",
              fontweight="bold")
axes[0].text(4.5, 2.0, "← → atenção bidirecional ← →",
              ha="center", fontsize=9, color="steelblue")
axes[0].text(4.5, 0.5,
              "Cada token vê TODOS os outros tokens",
              ha="center", fontsize=10, color="gray")

# Diagrama GPT
axes[1].set_xlim(0, 10)
axes[1].set_ylim(0, 10)
axes[1].axis("off")
axes[1].set_title("GPT (Decoder-only)\nCausal (esquerda→direita)",
                   fontsize=13, fontweight="bold")

tokens_gpt = ["o", "gato", "sentou", "no", "tapete"]
for i, tok in enumerate(tokens_gpt):
    cor = "#87CEEB"
    rect = plt.Rectangle((i * 1.8 + 0.1, 1), 1.5, 0.6,
                           color=cor, ec="gray", lw=1.5)
    axes[1].add_patch(rect)
    axes[1].text(i * 1.8 + 0.85, 1.35, tok,
                  ha="center", va="center", fontsize=9)

# Setas causais (apenas para frente)
for i in range(len(tokens_gpt)):
    for j in range(i + 1, len(tokens_gpt)):
        axes[1].annotate("",
            xy=(j * 1.8 + 0.85, 2.4),
            xytext=(i * 1.8 + 0.85, 2.4),
            arrowprops=dict(
                arrowstyle="->", color="coral",
                alpha=0.4, lw=1.0
            )
        )

axes[1].text(4.5, 2.0, "→ atenção apenas para passado →",
              ha="center", fontsize=9, color="coral")

# Predições
for i, tok in enumerate(tokens_gpt):
    if i < len(tokens_gpt) - 1:
        axes[1].text(i * 1.8 + 0.85, 3.8,
                      f"→ '{tokens_gpt[i+1]}'",
                      ha="center", fontsize=8,
                      color="darkgreen")

axes[1].text(4.5, 4.8,
              "Prediz PRÓXIMO token a cada passo",
              ha="center", fontsize=10,
              color="darkgreen", fontweight="bold")
axes[1].text(4.5, 0.5,
              "Cada token vê apenas tokens ANTERIORES",
              ha="center", fontsize=10, color="gray")

plt.suptitle("Diferença Fundamental: BERT vs GPT",
             fontsize=14, y=0.98)
plt.tight_layout()
plt.show()

# Tabela comparativa
print("\nComparação estruturada:")
print(f"{'Aspecto':<28} {'BERT':>20} {'GPT':>20}")
print("-" * 70)
comparacoes = [
    ("Arquitetura",        "Encoder-only",      "Decoder-only"),
    ("Atenção",            "Bidirecional",       "Causal (unidirec.)"),
    ("Pré-treinamento",    "MLM + NSP",          "Language Modeling"),
    ("Tokens especiais",   "[CLS] [SEP] [MASK]", "Nenhum especial"),
    ("Força principal",    "Compreensão",        "Geração"),
    ("Classificação",      "Excelente",          "Boa (com prompt)"),
    ("Geração de texto",   "Não adequado",       "Excelente"),
    ("Zero-shot",          "Limitado",           "Muito bom"),
    ("Few-shot",           "Requer fine-tuning", "In-context learning"),
    ("Exemplo base",       "BERT, RoBERTa",      "GPT-2, GPT-3, LLaMA"),
]
for asp, bert_v, gpt_v in comparacoes:
    print(f"{asp:<28} {bert_v:>20} {gpt_v:>20}")

7. Fine-tuning de LLMs

print("\n" + "=" * 60)
print("FINE-TUNING DE LLMs")
print("=" * 60)
print("""
Fine-tuning adapta um LLM pré-treinado para uma tarefa específica.

Estratégias:
┌────────────────────────────────────────────────────────────┐
│ Full Fine-tuning                                            │
│ Treina TODOS os parâmetros no dataset da tarefa            │
│ ✓ Melhor performance  ✗ Muito caro (≈ pré-treino)         │
├────────────────────────────────────────────────────────────┤
│ Feature Extraction (Linear Probing)                        │
│ Congela o LLM, treina apenas uma cabeça de classificação   │
│ ✓ Rápido  ✗ Performance limitada                          │
├────────────────────────────────────────────────────────────┤
│ LoRA (Low-Rank Adaptation)                                  │
│ Adiciona matrizes de baixo rank treináveis                 │
│ ✓ Eficiente  ✓ Boa performance  ✓ PADRÃO atual           │
├────────────────────────────────────────────────────────────┤
│ Prompt Tuning / Prefix Tuning                              │
│ Aprende soft prompts sem modificar os pesos do modelo      │
│ ✓ Muito eficiente  ✗ Performance variável                 │
└────────────────────────────────────────────────────────────┘
""")

# Simulação de fine-tuning com Linear Probing
class BERTParaClassificacao(nn.Module):
    """
    Fine-tuning do BERT para classificação.
    Usa a representação [CLS] + cabeça linear.
    """

    def __init__(self,
                  bert_modelo: BERTMiniatura,
                  n_classes: int,
                  congelar_bert: bool = True,
                  dropout: float = 0.1):
        super().__init__()
        self.bert     = bert_modelo
        self.dropout  = nn.Dropout(dropout)
        self.cabeca   = nn.Linear(
            bert_modelo.d_model, n_classes
        )

        if congelar_bert:
            for param in self.bert.parameters():
                param.requires_grad = False
            print("BERT congelado (Linear Probing)")
        else:
            print("BERT descongelado (Full Fine-tuning)")

        n_total   = sum(p.numel() for p in self.parameters())
        n_treino  = sum(p.numel() for p in self.parameters()
                        if p.requires_grad)
        print(f"  Parâmetros: {n_total:,} total, "
              f"{n_treino:,} treináveis ({n_treino/n_total:.1%})")

    def forward(self, tokens, segmentos=None):
        saidas  = self.bert(tokens, segmentos)
        cls_rep = self.dropout(saidas["cls_rep"])
        return self.cabeca(cls_rep)

# Criar datasets de classificação
class ClassificacaoDataset(torch.utils.data.Dataset):
    def __init__(self, n=2000, seq_len=20, vocab_size=1000,
                  n_classes=3):
        np.random.seed(42)
        self.seqs   = []
        self.labels = []
        for _ in range(n):
            label = np.random.randint(0, n_classes)
            # Padrão por classe
            base  = label * (vocab_size // n_classes) + 5
            fim   = base + vocab_size // n_classes - 5
            seq   = [1]   # [CLS]
            for _ in range(seq_len - 2):
                if np.random.random() < 0.6:
                    seq.append(int(np.random.randint(base, fim)))
                else:
                    seq.append(int(np.random.randint(5, 100)))
            seq.append(2)   # [SEP]
            self.seqs.append(seq)
            self.labels.append(label)

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

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

ds_clf = ClassificacaoDataset(n=2000, seq_len=20,
                                vocab_size=1000, n_classes=3)
n_tr_c = int(0.7 * len(ds_clf))
n_va_c = int(0.15 * len(ds_clf))
n_te_c = len(ds_clf) - n_tr_c - n_va_c

from torch.utils.data import random_split
ds_tr_c, ds_va_c, ds_te_c = random_split(
    ds_clf, [n_tr_c, n_va_c, n_te_c],
    generator=torch.Generator().manual_seed(42)
)
dl_tr_c = torch.utils.data.DataLoader(
    ds_tr_c, batch_size=64, shuffle=True
)
dl_va_c = torch.utils.data.DataLoader(ds_va_c, batch_size=64)
dl_te_c = torch.utils.data.DataLoader(ds_te_c, batch_size=64)

# Comparar estratégias de fine-tuning
estrategias_ft = {
    "Linear Probing": True,
    "Full Fine-tuning": False,
}
resultados_ft = {}

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

for (nome, congelar), cor in zip(
    estrategias_ft.items(),
    ["steelblue", "coral"]
):
    print(f"\n{'='*40}")
    bert_ft = BERTMiniatura(vocab_size=1000, d_model=128,
                              n_heads=4, n_camadas=4)
    modelo_ft = BERTParaClassificacao(
        bert_ft, n_classes=3, congelar_bert=congelar
    ).to(device)

    opt_ft = torch.optim.AdamW(
        filter(lambda p: p.requires_grad,
               modelo_ft.parameters()),
        lr=2e-4 if congelar else 2e-5
    )
    crit_ft = nn.CrossEntropyLoss()
    hist_ft = {"acc_tr": [], "acc_va": []}

    for epoca in range(20):
        modelo_ft.train()
        n_c, n_t = 0, 0
        for x_b, y_b in dl_tr_c:
            x_b = x_b.to(device)
            y_b = y_b.to(device)
            opt_ft.zero_grad()
            logits = modelo_ft(x_b)
            loss   = crit_ft(logits, y_b)
            loss.backward()
            opt_ft.step()
            n_c += (logits.argmax(1) == y_b).sum().item()
            n_t += len(y_b)
        hist_ft["acc_tr"].append(n_c / n_t)

        modelo_ft.eval()
        n_c, n_t = 0, 0
        with torch.no_grad():
            for x_b, y_b in dl_va_c:
                x_b = x_b.to(device)
                y_b = y_b.to(device)
                logits = modelo_ft(x_b)
                n_c += (logits.argmax(1) == y_b).sum().item()
                n_t += len(y_b)
        hist_ft["acc_va"].append(n_c / n_t)

    # Teste
    modelo_ft.eval()
    n_c, n_t = 0, 0
    with torch.no_grad():
        for x_b, y_b in dl_te_c:
            x_b = x_b.to(device)
            y_b = y_b.to(device)
            logits = modelo_ft(x_b)
            n_c += (logits.argmax(1) == y_b).sum().item()
            n_t += len(y_b)
    acc_te_ft = n_c / n_t

    resultados_ft[nome] = acc_te_ft
    print(f"\n  {nome} — Acurácia no Teste: {acc_te_ft:.4f}")

    ep = range(1, 21)
    axes[0].plot(ep, hist_ft["acc_tr"],
                  label=f"{nome} (tr)", color=cor,
                  linewidth=2, alpha=0.6)
    axes[1].plot(ep, hist_ft["acc_va"],
                  label=f"{nome} acc={acc_te_ft:.3f}",
                  color=cor, linewidth=2)

for ax, titulo in zip(axes,
                       ["Acurácia Treino",
                        "Acurácia Validação"]):
    ax.set_xlabel("Época")
    ax.set_ylabel("Acurácia")
    ax.set_title(titulo)
    ax.legend(fontsize=9)
    ax.set_ylim(0, 1)

plt.suptitle("Fine-tuning: Linear Probing vs Full Fine-tuning",
             fontsize=13)
plt.tight_layout()
plt.show()

8. LoRA — Low-Rank Adaptation

class LoRALinear(nn.Module):
    """
    Implementação do LoRA para uma camada Linear.

    Ideia: em vez de atualizar W (d × d), aprende duas matrizes
    de baixo rank: A (d × r) e B (r × d), onde r << d.

    ΔW = BA  →  W_efetivo = W₀ + (α/r) × BA

    Parâmetros adicionados: d×r + r×d = 2dr
    Para d=768, r=8: 2×768×8 = 12.288 vs 768² = 589.824
    Redução: ~98% menos parâmetros!
    """

    def __init__(self,
                  linear_original: nn.Linear,
                  rank: int = 4,
                  alpha: float = 1.0,
                  dropout: float = 0.0):
        super().__init__()

        self.linear   = linear_original
        self.rank     = rank
        self.alpha    = alpha
        self.escala   = alpha / rank

        in_features  = linear_original.in_features
        out_features = linear_original.out_features

        # Matrizes de baixo rank (treináveis)
        self.lora_A  = nn.Linear(in_features, rank,
                                   bias=False)
        self.lora_B  = nn.Linear(rank, out_features,
                                   bias=False)
        self.dropout = nn.Dropout(dropout)

        # Inicialização: A com Gaussian, B com zeros
        # Garante que ΔW = BA = 0 no início
        nn.init.kaiming_uniform_(self.lora_A.weight,
                                   a=math.sqrt(5))
        nn.init.zeros_(self.lora_B.weight)

        # Congelar pesos originais
        for param in self.linear.parameters():
            param.requires_grad = False

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Saída original (pesos congelados)
        saida_original = self.linear(x)

        # Adaptação de baixo rank (pesos treináveis)
        adaptacao = self.lora_B(self.lora_A(
            self.dropout(x)
        )) * self.escala

        return saida_original + adaptacao

    @property
    def n_params_treinaveis(self) -> int:
        return (sum(p.numel() for p in self.lora_A.parameters())
                + sum(p.numel() for p in self.lora_B.parameters()))

def aplicar_lora(modelo: nn.Module,
                  rank: int = 4,
                  alpha: float = 1.0,
                  modulos_alvo: list = None) -> nn.Module:
    """
    Aplica LoRA a todas as camadas Linear do modelo.
    """
    if modulos_alvo is None:
        modulos_alvo = ["W_Q", "W_V"]   # típico do LoRA

    for nome, modulo in list(modelo.named_modules()):
        if isinstance(modulo, nn.Linear):
            # Verificar se é um módulo alvo
            nome_curto = nome.split(".")[-1]
            if modulos_alvo is None or \
               any(alvo in nome for alvo in modulos_alvo):
                # Substituir pela versão LoRA
                parent = modelo
                partes = nome.split(".")
                for parte in partes[:-1]:
                    parent = getattr(parent, parte)
                setattr(parent, partes[-1],
                         LoRALinear(modulo, rank, alpha))

    return modelo

# Demonstração do LoRA
print("\nLoRA — Low-Rank Adaptation:")
print("=" * 50)

# Criar modelo base
bert_base = BERTMiniatura(
    vocab_size=1000, d_model=128, n_heads=4, n_camadas=2
)
n_params_base = sum(
    p.numel() for p in bert_base.parameters()
)

# Congelar todo o modelo base
for param in bert_base.parameters():
    param.requires_grad = False

# Aplicar LoRA
bert_lora = aplicar_lora(
    bert_base, rank=4, alpha=8.0,
    modulos_alvo=["W_Q", "W_K", "W_V"]
)

# Adicionar cabeça de classificação
cabeca_lora = nn.Linear(128, 3)
bert_lora_completo = nn.Sequential(
    bert_lora, cabeca_lora
)

n_params_lora = sum(
    p.numel() for p in bert_lora.parameters()
    if p.requires_grad
)
n_params_cabeca = sum(
    p.numel() for p in cabeca_lora.parameters()
)
n_treinaveis_total = n_params_lora + n_params_cabeca

print(f"  Parâmetros totais do modelo base: {n_params_base:,}")
print(f"  Parâmetros treináveis (LoRA + cabeça): "
      f"{n_treinaveis_total:,}")
print(f"  Redução: {1 - n_treinaveis_total/n_params_base:.1%} "
      f"menos parâmetros treináveis")
print(f"\n  Matrizes LoRA adicionadas:")
for nome, modulo in bert_lora.named_modules():
    if isinstance(modulo, LoRALinear):
        print(f"    {nome}: {modulo.n_params_treinaveis:,} params "
              f"(rank={modulo.rank})")

9. Perplexidade e Métricas de LLMs

print("\n" + "=" * 60)
print("MÉTRICAS DE AVALIAÇÃO DE LLMs")
print("=" * 60)
print("""
Métricas principais para modelos de linguagem:

Perplexidade (PPL):
  PPL = exp(média das log-probs negativas)
  Interpretação: "quantas palavras o modelo considera igualmente
  prováveis a cada passo"
  PPL = 1   → modelo perfeito (prever sempre a palavra certa)
  PPL = 10  → como se escolhesse entre 10 palavras igualmente
  PPL = 100 → muito incerto

BPC/BPB (bits per character/byte):
  Medida de compressão — modelo bom = comprime mais

BLEU (para tradução/geração):
  Mede sobreposição de n-gramas com referências humanas

ROUGE (para sumarização):
  Recall-oriented de n-gramas

Benchmarks de capabilities:
  MMLU: 57 tarefas acadêmicas (medicina, direito, etc)
  HellaSwag: completamento de sentenças
  HumanEval: geração de código Python
  GSM8K: problemas matemáticos
  TruthfulQA: respostas factuais precisas
""")

def calcular_perplexidade(modelo: GPTMiniatura,
                            dataloader,
                            device: torch.device) -> float:
    """Calcula a perplexidade no dataset."""
    modelo.eval()
    total_loss = 0.0
    total_tokens = 0
    crit = nn.CrossEntropyLoss(reduction="sum")

    with torch.no_grad():
        for x_b, y_b in dataloader:
            x_b = x_b.to(device)
            y_b = y_b.to(device)

            logits = modelo(x_b)
            loss   = crit(
                logits.view(-1, modelo.vocab_size),
                y_b.view(-1)
            )
            total_loss   += loss.item()
            total_tokens += y_b.numel()

    avg_loss     = total_loss / total_tokens
    perplexidade = math.exp(avg_loss)
    return perplexidade

# Calcular perplexidade do GPT treinado
dl_lm_test = torch.utils.data.DataLoader(
    LMDataset(n=500, seq_len=32, vocab_size=200),
    batch_size=64
)
ppl = calcular_perplexidade(gpt_lm, dl_lm_test, device)
print(f"\nPerplexidade do GPT miniatura no teste: {ppl:.2f}")
print(f"(Menor = melhor; random baseline ≈ {200:.0f})")

# Visualizar evolução da perplexidade durante treino
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ep_lm = range(1, len(hist_lm) + 1)
axes[0].plot(ep_lm, hist_lm, color="steelblue",
              linewidth=2)
axes[0].set_xlabel("Época")
axes[0].set_ylabel("Cross-Entropy Loss")
axes[0].set_title("Treinamento do GPT por LM")

ppls = [math.exp(l) for l in hist_lm]
axes[1].plot(ep_lm, ppls, color="coral", linewidth=2)
axes[1].set_xlabel("Época")
axes[1].set_ylabel("Perplexidade")
axes[1].set_title("Perplexidade ao Longo do Treino\n"
                   "(Menor = modelo mais certo)")

plt.tight_layout()
plt.show()

10. Exemplo Completo: Usando LLMs na Prática

print("\n" + "=" * 60)
print("USANDO LLMs NA PRÁTICA — SIMULAÇÃO COMPLETA")
print("=" * 60)

# Pipeline completo: pré-treino → fine-tuning → inferência
print("""
Pipeline completo para uma tarefa de NLP:

  1. Escolher o modelo base:
     Encoder-only → classificação, NER, QA extrativa
     Decoder-only → geração, completamento
     Encoder-Decoder → tradução, sumarização

  2. Pré-processamento:
     Tokenizar com o tokenizer do modelo
     Truncar/paddar para comprimento máximo
     Adicionar tokens especiais

  3. Fine-tuning:
     Full: melhor perf., mais recurso
     LoRA: padrão para modelos grandes
     Linear Probing: baseline rápido

  4. Avaliação:
     Métricas da tarefa (F1, BLEU, etc.)
     Perplexidade para geração
     Testes humanos para qualidade

  5. Inferência:
     Batch inference para throughput
     Quantização para reduzir memória
""")

# Simulação de classificação com BERT fine-tuned
class PipelineClassificacaoBERT:
    """
    Pipeline completo de classificação com BERT.
    Simula o que você faria com Hugging Face na prática.
    """

    def __init__(self,
                  vocab_size: int = 1000,
                  n_classes: int = 3,
                  nomes_classes: list = None):
        self.vocab_size  = vocab_size
        self.n_classes   = n_classes
        self.nomes_classes = nomes_classes or \
                             [f"Classe {i}" for i in range(n_classes)]

        # Modelo
        bert_interno = BERTMiniatura(
            vocab_size=vocab_size,
            d_model=128, n_heads=4, n_camadas=4
        )
        self.modelo = BERTParaClassificacao(
            bert_interno, n_classes=n_classes,
            congelar_bert=False
        ).to(device)

        self.optimizer = torch.optim.AdamW(
            self.modelo.parameters(), lr=2e-5
        )
        self.criterion = nn.CrossEntropyLoss()
        self.historico = {"loss": [], "acc": []}

    def treinar(self, dataloader,
                 dataloader_val, n_epocas: int = 10):
        melhor = 0.0
        for epoca in range(n_epocas):
            self.modelo.train()
            n_c, n_t = 0, 0
            for x_b, y_b in dataloader:
                x_b = x_b.to(device)
                y_b = y_b.to(device)
                self.optimizer.zero_grad()
                logits = self.modelo(x_b)
                loss   = self.criterion(logits, y_b)
                loss.backward()
                nn.utils.clip_grad_norm_(
                    self.modelo.parameters(), 1.0
                )
                self.optimizer.step()
                n_c += (logits.argmax(1) == y_b).sum().item()
                n_t += len(y_b)

            self.modelo.eval()
            n_cv, n_tv = 0, 0
            with torch.no_grad():
                for x_b, y_b in dataloader_val:
                    x_b = x_b.to(device)
                    y_b = y_b.to(device)
                    logits = self.modelo(x_b)
                    n_cv += (logits.argmax(1) == y_b).sum().item()
                    n_tv += len(y_b)

            acc_va = n_cv / n_tv
            self.historico["loss"].append(
                self.criterion(logits, y_b).item()
            )
            self.historico["acc"].append(acc_va)

            if acc_va > melhor:
                melhor = acc_va
                torch.save(self.modelo.state_dict(),
                            "bert_pipeline.pt")

        self.modelo.load_state_dict(
            torch.load("bert_pipeline.pt", weights_only=True)
        )
        return self

    @torch.no_grad()
    def predizer(self, tokens: torch.Tensor) -> dict:
        """Retorna predição e probabilidades."""
        self.modelo.eval()
        tokens = tokens.to(device)
        logits = self.modelo(tokens)
        probs  = F.softmax(logits, dim=-1)
        pred   = probs.argmax(dim=-1)

        return {
            "classe":        [self.nomes_classes[p.item()]
                               for p in pred],
            "probabilidades": probs.cpu().tolist(),
            "confianca":     probs.max(dim=-1).values.cpu().tolist()
        }

# Instanciar e treinar pipeline
pipeline = PipelineClassificacaoBERT(
    vocab_size=1000, n_classes=3,
    nomes_classes=["Negativo", "Neutro", "Positivo"]
)

print(f"\nTreinando pipeline BERT...")
pipeline.treinar(dl_tr_c, dl_va_c, n_epocas=15)

# Teste final
pipeline.modelo.eval()
n_c, n_t = 0, 0
todos_preds, todos_labels = [], []
with torch.no_grad():
    for x_b, y_b in dl_te_c:
        resultado = pipeline.predizer(x_b)
        preds_idx = [
            pipeline.nomes_classes.index(c)
            for c in resultado["classe"]
        ]
        n_c += sum(p == y for p, y in zip(
            preds_idx, y_b.tolist()
        ))
        n_t += len(y_b)
        todos_preds.extend(preds_idx)
        todos_labels.extend(y_b.tolist())

from sklearn.metrics import accuracy_score
acc_pipeline = accuracy_score(todos_labels, todos_preds)
print(f"Acurácia final do pipeline: {acc_pipeline:.4f}")

# Visualização final
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# Curva de acurácia
ep = range(1, len(pipeline.historico["acc"]) + 1)
axes[0].plot(ep, pipeline.historico["acc"],
              color="steelblue", linewidth=2)
axes[0].axhline(y=acc_pipeline, color="coral",
                 linestyle="--", label=f"Teste={acc_pipeline:.3f}")
axes[0].set_xlabel("Época")
axes[0].set_ylabel("Acurácia Validação")
axes[0].set_title("Fine-tuning BERT")
axes[0].legend()

# Comparação de modelos
modelos_cmp = {
    "Linear Probing": resultados_ft.get("Linear Probing", 0),
    "Full FT":        resultados_ft.get("Full Fine-tuning", 0),
    "Pipeline BERT":  acc_pipeline,
}
cores_cmp = ["coral", "steelblue", "mediumseagreen"]
barras = axes[1].bar(
    modelos_cmp.keys(),
    modelos_cmp.values(),
    color=cores_cmp, alpha=0.85
)
axes[1].set_ylim(0, 1.1)
axes[1].set_title("Comparação de Estratégias")
axes[1].set_ylabel("Acurácia no Teste")
for b, v in zip(barras, modelos_cmp.values()):
    axes[1].text(b.get_x() + b.get_width()/2,
                  v + 0.02, f"{v:.4f}",
                  ha="center", fontsize=10)

# Resumo do LoRA
lora_dados = {
    "Modelo Base":    n_params_base,
    "LoRA Treináveis": n_treinaveis_total,
}
cores_lora = ["coral", "steelblue"]
barras_lora = axes[2].bar(
    lora_dados.keys(),
    lora_dados.values(),
    color=cores_lora, alpha=0.85
)
axes[2].set_title("Eficiência do LoRA\n"
                   "Parâmetros treináveis")
axes[2].set_ylabel("Número de parâmetros")
for b, v in zip(barras_lora, lora_dados.values()):
    axes[2].text(b.get_x() + b.get_width()/2,
                  v * 1.02, f"{v:,}",
                  ha="center", fontsize=9)

plt.suptitle("LLMs na Prática: Fine-tuning e Eficiência",
             fontsize=13)
plt.tight_layout()
plt.show()

print("\n" + "=" * 60)
print("RESUMO: DO PRÉ-TREINAMENTO AO DEPLOY")
print("=" * 60)
print("""
Fluxo completo de um projeto com LLMs:

  1. Escolher modelo base (BERT, GPT, T5, LLaMA...)
  2. Carregar via Hugging Face (próxima aula!)
  3. Tokenizar dados com o tokenizer do modelo
  4. Fine-tuning (LoRA para eficiência)
  5. Avaliar com métricas da tarefa
  6. Servir via API (FastAPI, Gradio, etc.)

Considerações práticas:
  - Modelos > 7B parâmetros precisam de GPU com ≥ 16GB VRAM
  - Quantização (4-bit, 8-bit) reduz uso de memória em 4-8x
  - LoRA é o padrão atual para fine-tuning eficiente
  - Modelos fechados (GPT-4) via API, modelos abertos via HF
""")

Resumo da Aula

  • BERT é encoder-only, bidirecional, pré-treinado com MLM (prever tokens mascarados) e NSP
  • GPT é decoder-only, causal, pré-treinado com Language Modeling (prever próximo token)
  • BERT é superior para compreensão; GPT é superior para geração
  • O pré-treinamento em bilhões de tokens cria representações ricas transferíveis para qualquer tarefa
  • Fine-tuning adapta o LLM: Linear Probing (apenas cabeça), Full Fine-tuning (tudo), LoRA (eficiente)
  • LoRA reduz parâmetros treináveis em >95% mantendo performance próxima do Full Fine-tuning
  • Perplexidade mede a incerteza do modelo: PPL = exp(loss), menor é melhor
  • RLHF (Reinforcement Learning from Human Feedback) alinha modelos como ChatGPT a preferências humanas
  • A próxima aula usará Hugging Face para carregar e usar modelos reais como BERT e GPT-2

Exercícios

  1. Explique o objetivo da tarefa MLM (Masked Language Modeling) no BERT. Por que 15% dos tokens são mascarados e não 50% ou 5%? Por que usar a estratégia 80/10/10 (mask/random/original)?

    ✓ Resposta:

    MLM transforma o problema de representação de linguagem em uma tarefa supervisionada: dado um texto com alguns tokens ocultos, reconstrua-os usando o contexto bidirecional completo. Isso força o modelo a aprender representações que capturam semântica e sintaxe.

    15% é um equilíbrio empírico. Com 5%, muito poucos tokens são preditos por época — o aprendizado é lento e ineficiente. Com 50%, o texto fica tão distorcido que o contexto disponível é insuficiente para fazer predições coerentes — a tarefa fica muito difícil e o modelo aprende representações ruins.

    A estratégia 80/10/10 resolve um problema de incompatibilidade entre pré-treinamento e fine-tuning. Se todos os tokens mascarados fossem substituídos por [MASK], o modelo nunca veria tokens reais durante o pré-treinamento nessas posições. No fine-tuning, não há [MASK] — o modelo veria apenas tokens reais. Essa discrepância prejudicaria a performance. Os 10% com token aleatório forçam o modelo a contextualizar todo token e não apenas os [MASK]. Os 10% mantidos originais ensinam que a resposta pode ser o próprio token da posição.

  2. O que é geração autoregressiva no GPT? Explique o processo token-por-token. Por que não podemos gerar todos os tokens em paralelo como fazemos no encoder?

    ✓ Resposta:

    Geração autoregressiva significa que o GPT gera um token por vez, usando todos os tokens gerados anteriormente como contexto para o próximo. O processo é:

    1. Receber o prompt: "O gato"
    2. Calcular probabilidades para cada token do vocabulário dado o contexto
    3. Amostrar um token (ex: "sentou") de acordo com essas probabilidades
    4. Adicionar ao contexto: "O gato sentou"
    5. Repetir com o novo contexto até atingir o comprimento máximo ou um token de fim

    Não podemos gerar todos os tokens em paralelo porque cada token depende dos anteriores. Na aula 25, vimos que o decoder usa máscara causal — durante o treino, o token na posição t não pode ver t+1. Portanto, durante a inferência, o token t+1 genuinamente não existe ainda quando t está sendo calculado. O BERT pode processar em paralelo porque ele nunca gera — ele apenas processa sequências existentes.

  3. O que é temperatura na geração de texto? Explique o efeito de temperatura = 0.1, 1.0 e 2.0 em termos de criatividade e coerência. Quando você usaria cada valor?

    ✓ Resposta:

    A temperatura escala os logits antes do softmax: logits_escalados = logits / temperatura. Isso controla a "nitidez" da distribuição de probabilidades.

    Temperatura = 0.1: distribui quase toda a probabilidade no token mais provável. O modelo é muito determinístico e conservador — sempre escolhe a opção mais segura. Útil para: code generation, geração de fatos, tarefas onde a precisão importa mais que a criatividade.

    Temperatura = 1.0: usa a distribuição original do modelo, sem modificação. Equilíbrio entre criatividade e coerência. É o padrão e funciona bem para a maioria das tarefas.

    Temperatura = 2.0: "achata" a distribuição — tokens improváveis recebem probabilidade relativamente maior. O modelo é muito criativo/aleatório, podendo gerar textos incoerentes ou surpreendentes. Útil para: brainstorming, geração de poesia experimental, diversidade na geração. Raramente usada em produção.

  4. Explique o LoRA matematicamente. Por que funciona? Quais são os parâmetros rank e alpha e como eles afetam o tradeoff entre eficiência e performance?

    ✓ Resposta:

    O LoRA parte da observação empírica de que os updates de peso durante fine-tuning têm baixa dimensionalidade intrínseca. Em vez de aprender ΔW (d × d = d² parâmetros), aprende duas matrizes: A (d × r) e B (r × d), onde r << d. O update efetivo é ΔW = BA.

    Isso funciona porque modelos pré-treinados já aprenderam representações ricas. O fine-tuning precisa fazer apenas pequenos ajustes nessas representações, e esses ajustes têm estrutura de baixo rank — podem ser capturados por poucas direções principais no espaço de parâmetros.

    Parâmetro rank (r): controla a "capacidade" do adaptador. r=4 é conservador, r=16 ou r=32 é mais expressivo. Rank baixo: menos parâmetros, pode não capturar todas as nuances do fine-tuning. Rank alto: mais expressivo, mais parâmetros, maior risco de overfitting.

    Parâmetro alpha (α): escala o update pelo fator α/r. Separa o learning rate do rank — permite usar o mesmo learning rate independente do rank escolhido. Convenção comum: alpha = 2 × rank.

  5. O que é perplexidade em modelos de linguagem? Se um modelo tem PPL=10 em inglês e PPL=50 em swahili, o que isso indica? Por que a perplexidade é uma métrica mais informativa que a acurácia para LLMs?

    ✓ Resposta:

    Perplexidade = exp(perda de entropia cruzada média por token). Intuitivamente, é "quantas palavras o modelo considera igualmente prováveis a cada passo". PPL=10 significa que o modelo tem incerteza equivalente a escolher uniformemente entre 10 palavras.

    PPL=10 em inglês e PPL=50 em swahili indica que o modelo foi treinado predominantemente em inglês. Para inglês, o modelo aprendeu bem a estrutura e pode fazer predições confiantes. Para swahili, viu poucos dados e tem maior incerteza — considera mais palavras igualmente prováveis a cada passo. Isso não significa que o modelo "erra mais" em swahili, mas que é menos confiante nas suas predições.

    A perplexidade é mais informativa que acurácia para LLMs porque modelos de linguagem geram distribuições sobre o vocabulário inteiro, não classificam em classes fixas. A acurácia exigiria definir o que é "correto" — mas muitos tokens são semanticamente equivalentes em um dado contexto. A perplexidade captura quão bem o modelo comprime/prediz o texto completo, o que é uma medida mais natural da qualidade de representação da linguagem.

  6. Compare os paradigmas de uso de LLMs: fine-tuning vs prompting (in-context learning). Quais são as vantagens e desvantagens de cada abordagem? Em qual situação você escolheria cada uma?

    ✓ Resposta:

    Fine-tuning adapta os pesos do modelo para uma tarefa específica usando exemplos rotulados. Vantagens: performance máxima na tarefa alvo, comportamento previsível e controlado, não depende da qualidade do prompt. Desvantagens: requer exemplos rotulados (que podem ser caros), requer compute para treino, um modelo fine-tuned por tarefa.

    In-context learning (prompting) usa exemplos no próprio prompt sem atualizar os pesos. Vantagens: zero compute de treino, um único modelo para múltiplas tarefas, fácil de iterar rapidamente, funciona para tarefas sem dados de treino. Desvantagens: exemplos ocupam espaço do contexto (limitando input real), performance geralmente abaixo do fine-tuning, sensível à formulação do prompt.

    Escolha fine-tuning quando: a tarefa é crítica e requer performance máxima, você tem ≥ 1000 exemplos rotulados de qualidade, a tarefa tem formato específico diferente do pré-treinamento, vai fazer muitas inferências (amortiza o custo do treino).

    Escolha prompting quando: poucos ou nenhum exemplo rotulado disponível, precisa de prototipagem rápida, a tarefa muda frequentemente, está usando APIs de modelos fechados (GPT-4) onde não pode fazer fine-tuning facilmente.

Referências