Phase 03 - Lesson 11

Introducao ao PyTorch

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Voce construiu o motor a partir de pistoes e virabrequins. Agora aprenda aquele que todo mundo de fato dirige.

Type: Build Languages: Python Prerequisites: Licao 03.10 (Construa Seu Proprio Mini Framework) Time: ~75 minutos

Objetivos de Aprendizagem

  • Construir e treinar redes neurais usando nn.Module, nn.Sequential e o autograd do PyTorch
  • Usar tensores do PyTorch, aceleracao por GPU e o laco de treinamento padrao (zero_grad, forward, loss, backward, step)
  • Converter os componentes do seu mini framework feito do zero para seus equivalentes em PyTorch
  • Medir e comparar a velocidade de treinamento entre seu framework em Python puro e o PyTorch na mesma tarefa

O Problema

Voce tem um mini framework funcional. Camadas lineares, ReLU, dropout, batch norm, Adam, um DataLoader, um laco de treinamento. Ele treina uma rede de 4 camadas em um problema de classificacao de circulos, em Python puro.

Ele tambem e 500x mais lento que o PyTorch no mesmo problema.

Seu mini framework processa uma amostra por vez com lacos aninhados em Python. O PyTorch despacha as mesmas operacoes para kernels otimizados em C++/CUDA que rodam na GPU. Em uma unica NVIDIA A100, o PyTorch treina uma ResNet-50 (25,6M de parametros) na ImageNet (1,28M de imagens) em cerca de 6 horas. Seu framework levaria aproximadamente 3.000 horas na mesma tarefa -- se nao ficasse sem memoria antes.

Velocidade nao e a unica lacuna. Seu framework nao tem suporte a GPU. Nao tem diferenciacao automatica -- voce escreveu backward() a mao para cada modulo. Nao tem serializacao. Nao tem treinamento distribuido. Nao tem precisao mista. Nao tem como depurar o fluxo de gradientes sem instrucoes print.

O PyTorch preenche cada uma dessas lacunas. E faz isso mantendo exatamente o mesmo modelo mental que voce ja construiu: Module, forward(), parameters(), backward(), optimizer.step(). Os conceitos se transferem um a um. A sintaxe e quase identica. A diferenca e que o PyTorch envolve uma decada de engenharia de sistemas por tras da mesma interface que voce projetou do zero.

O Conceito

Por Que o PyTorch Venceu

Em 2015, o TensorFlow exigia que voce definisse um grafo de computacao estatico antes de rodar qualquer coisa. Voce construia o grafo, compilava e entao alimentava dados atraves dele. Depurar significava encarar visualizacoes de grafos. Mudar a arquitetura significava reconstruir o grafo do zero.

O PyTorch foi lancado em 2017 com uma filosofia diferente: execucao eager. Voce escreve Python. Ele roda imediatamente. y = model(x) de fato computa y agora, e nao "adiciona um no a um grafo que computara y depois". Isso fez com que as ferramentas padrao de depuracao do Python funcionassem. print() funcionava. pdb funcionava. if/else no seu forward pass funcionava.

Em 2020, o mercado ja havia se manifestado. A participacao do PyTorch em artigos de pesquisa de ML passou de 7% (2017) para mais de 75% (2022). Meta, Google DeepMind, OpenAI, Anthropic e Hugging Face usam o PyTorch como framework principal. O TensorFlow 2.x adotou execucao eager em resposta -- admissao tacita de que o design do PyTorch estava correto.

A licao: a experiencia do desenvolvedor se acumula. Um framework que e 10% mais lento mas 50% mais rapido de depurar vence sempre.

Tensores

Um tensor e um array multidimensional com tres propriedades criticas: shape, dtype e device.

import torch

x = torch.zeros(3, 4)           # shape: (3, 4), dtype: float32, device: cpu
x = torch.randn(2, 3, 224, 224) # batch of 2 RGB images, 224x224
x = torch.tensor([1, 2, 3])     # from a Python list

Shape e a dimensionalidade. Um escalar tem shape (), um vetor e (n,), uma matriz e (m, n), um batch de imagens e (batch, channels, height, width).

Dtype controla precisao e memoria.

dtype Bits Faixa Caso de uso
float32 32 ~7 digitos decimais Treinamento padrao
float16 16 ~3,3 digitos decimais Precisao mista
bfloat16 16 Mesma faixa que float32, menos precisao Treinamento de LLM
int8 8 -128 a 127 Inferencia quantizada

Device determina onde a computacao acontece.

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(3, 4, device=device)
x = x.to("cuda")
x = x.cpu()

Toda operacao exige que todos os tensores estejam no mesmo device. Este e o erro #1 do PyTorch que iniciantes enfrentam: RuntimeError: Expected all tensors to be on the same device. Corrija movendo tudo para o mesmo device antes da computacao.

Reshape tem tempo constante -- ele muda os metadados, nao os dados.

x = torch.randn(2, 3, 4)
x.view(2, 12)      # reshape to (2, 12) -- must be contiguous
x.reshape(6, 4)    # reshape to (6, 4) -- works always
x.permute(2, 0, 1) # reorder dimensions
x.unsqueeze(0)     # add dimension: (1, 2, 3, 4)
x.squeeze()        # remove size-1 dimensions

Autograd

Seu mini framework exigia que voce implementasse backward() para cada modulo. O PyTorch nao. Ele registra cada operacao sobre tensores em um grafo aciclico direcionado (o grafo computacional) e entao percorre esse grafo em sentido reverso para computar gradientes automaticamente.

graph LR
    x["x (leaf)"] --> mul["*"]
    w["w (leaf, requires_grad)"] --> mul
    mul --> add["+"]
    b["b (leaf, requires_grad)"] --> add
    add --> loss["loss"]
    loss --> |".backward()"| add
    add --> |"grad"| b
    add --> |"grad"| mul
    mul --> |"grad"| w

A diferenca fundamental em relacao ao seu framework: o PyTorch usa autodiferenciacao baseada em fita (tape). Cada operacao e anexada a uma "fita" durante o forward pass. Chamar .backward() reproduz a fita em sentido reverso.

x = torch.randn(3, requires_grad=True)
y = x ** 2 + 3 * x
z = y.sum()
z.backward()
print(x.grad)  # dz/dx = 2x + 3

Tres regras do autograd:

  1. Apenas tensores folha (leaf) com requires_grad=True acumulam gradientes
  2. Gradientes acumulam por padrao -- chame optimizer.zero_grad() antes de cada backward pass
  3. torch.no_grad() desativa o rastreamento de gradientes (use durante a avaliacao)

nn.Module

nn.Module e a classe base para todo componente de rede neural no PyTorch. Voce ja construiu essa abstracao na Licao 10. A versao do PyTorch adiciona registro automatico de parametros, descoberta recursiva de modulos, gerenciamento de device e serializacao via state dict.

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.layer1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        x = self.layer1(x)
        x = self.relu(x)
        x = self.layer2(x)
        return x

Quando voce atribui um nn.Module ou nn.Parameter como atributo em __init__, o PyTorch o registra automaticamente. model.parameters() coleta recursivamente cada parametro registrado. E por isso que voce nunca precisa reunir pesos manualmente como fazia no mini framework.

Blocos de construcao fundamentais:

Modulo O que faz Parametros
nn.Linear(in, out) Wx + b in*out + out
nn.Conv2d(in_ch, out_ch, k) Convolucao 2D in_chout_chk*k + out_ch
nn.BatchNorm1d(features) Normaliza ativacoes 2 * features
nn.Dropout(p) Zeramento aleatorio 0
nn.ReLU() max(0, x) 0
nn.GELU() Linear de erro gaussiano 0
nn.Embedding(vocab, dim) Tabela de consulta vocab * dim
nn.LayerNorm(dim) Normalizacao por amostra 2 * dim

Funcoes de Perda e Otimizadores

O PyTorch traz versoes prontas para producao de tudo que voce construiu.

Funcoes de perda (de torch.nn):

Perda Tarefa Entrada
nn.MSELoss() Regressao Qualquer shape
nn.CrossEntropyLoss() Classificacao multiclasse Logits (nao softmax)
nn.BCEWithLogitsLoss() Classificacao binaria Logits (nao sigmoid)
nn.L1Loss() Regressao (robusta) Qualquer shape
nn.CTCLoss() Alinhamento de sequencias Log-probabilidades

Observacao: CrossEntropyLoss combina LogSoftmax + NLLLoss internamente. Passe logits brutos, nao saidas de softmax. Este e um erro comum que produz gradientes errados silenciosamente.

Otimizadores (de torch.optim):

Otimizador Quando usar LR tipica
SGD(params, lr, momentum) CNNs, pipelines bem ajustados 0,01--0,1
Adam(params, lr) Ponto de partida padrao 1e-3
AdamW(params, lr, weight_decay) Transformers, fine-tuning 1e-4--1e-3
LBFGS(params) Pequena escala, segunda ordem 1.0

O Laco de Treinamento

Todo laco de treinamento do PyTorch segue o mesmo padrao de 5 passos. Voce ja conhece isso da Licao 10.

sequenceDiagram
    participant D as DataLoader
    participant M as Model
    participant L as Loss fn
    participant O as Optimizer

    loop Each Epoch
        D->>M: batch = next(dataloader)
        M->>L: predictions = model(batch)
        L->>L: loss = criterion(predictions, targets)
        L->>M: loss.backward()
        O->>M: optimizer.step()
        O->>O: optimizer.zero_grad()
    end

O padrao canonico:

for epoch in range(num_epochs):
    model.train()
    for inputs, targets in train_loader:
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

Cinco linhas dentro do laco de batch. Cinco linhas que treinaram o GPT-4, o Stable Diffusion e o LLaMA. A arquitetura muda. Os dados mudam. Essas cinco linhas nao.

Dataset e DataLoader

O Dataset do PyTorch e uma classe abstrata com dois metodos: __len__ e __getitem__. O DataLoader o envolve com batching, embaralhamento e carregamento de dados em multiplos processos.

from torch.utils.data import Dataset, DataLoader

class MNISTDataset(Dataset):
    def __init__(self, images, labels):
        self.images = images
        self.labels = labels

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

    def __getitem__(self, idx):
        return self.images[idx], self.labels[idx]

loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)

num_workers=4 cria 4 processos para carregar dados em paralelo enquanto a GPU treina no batch atual. Em cargas limitadas por disco (imagens grandes, audio), isso sozinho pode dobrar a velocidade de treinamento.

Treinamento em GPU

Movendo um modelo para a GPU:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

Isso move recursivamente cada parametro e buffer para a GPU. Depois mova cada batch durante o treinamento:

inputs, targets = inputs.to(device), targets.to(device)

Precisao mista reduz o uso de memoria pela metade e dobra a vazao em GPUs modernas (A100, H100, RTX 4090) ao rodar forward/backward em float16 enquanto mantem os pesos mestres em float32:

from torch.amp import autocast, GradScaler

scaler = GradScaler()
for inputs, targets in loader:
    with autocast(device_type="cuda"):
        outputs = model(inputs)
        loss = criterion(outputs, targets)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
    optimizer.zero_grad()

Comparacao: Mini Framework vs PyTorch vs JAX

Recurso Mini Framework (L10) PyTorch JAX
Autodiff backward() manual Autograd baseado em fita Transformacoes funcionais
Execucao Eager (lacos Python) Eager (kernels C++) Tracado + compilado JIT
Suporte a GPU Nao Sim (CUDA, ROCm, MPS) Sim (CUDA, TPU)
Velocidade (MLP MNIST) ~300s/epoca ~0,5s/epoca ~0,3s/epoca
Sistema de modulos Classe Module customizada nn.Module Funcoes sem estado (Flax/Equinox)
Depuracao print() print(), pdb, breakpoint() Mais dificil (tracing JIT quebra print)
Ecossistema Nenhum Hugging Face, Lightning, timm Flax, Optax, Orbax
Curva de aprendizagem Voce construiu Moderada Ingreme (paradigma funcional)
Uso em producao Problemas de brinquedo Meta, OpenAI, Anthropic, HF Google DeepMind, Midjourney

Construa

Uma MLP de 3 camadas treinada na MNIST usando apenas primitivas do PyTorch. Sem wrappers de alto nivel. Sem torchvision.datasets. Baixamos e parseamos os dados brutos nos mesmos.

Passo 1: Carregar a MNIST a Partir de Arquivos Brutos

A MNIST vem em 4 arquivos comprimidos com gzip: imagens de treino (60.000 x 28 x 28), rotulos de treino, imagens de teste (10.000 x 28 x 28), rotulos de teste. Baixamos e parseamos o formato binario.

import torch
import torch.nn as nn
import struct
import gzip
import urllib.request
import os

def download_mnist(path="./mnist_data"):
    base_url = "https://storage.googleapis.com/cvdf-datasets/mnist/"
    files = [
        "train-images-idx3-ubyte.gz",
        "train-labels-idx1-ubyte.gz",
        "t10k-images-idx3-ubyte.gz",
        "t10k-labels-idx1-ubyte.gz",
    ]
    os.makedirs(path, exist_ok=True)
    for f in files:
        filepath = os.path.join(path, f)
        if not os.path.exists(filepath):
            urllib.request.urlretrieve(base_url + f, filepath)

def load_images(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num, rows, cols = struct.unpack(">IIII", f.read(16))
        data = f.read()
        images = torch.frombuffer(bytearray(data), dtype=torch.uint8)
        images = images.reshape(num, rows * cols).float() / 255.0
    return images

def load_labels(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num = struct.unpack(">II", f.read(8))
        data = f.read()
        labels = torch.frombuffer(bytearray(data), dtype=torch.uint8).long()
    return labels

Passo 2: Definir o Modelo

Uma MLP de 3 camadas: 784 -> 256 -> 128 -> 10. Ativacoes ReLU. Dropout para regularizacao. Sem batch norm, para manter a simplicidade.

class MNISTModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.net(x)

A camada de saida produz 10 logits brutos (um por digito). Sem softmax -- CrossEntropyLoss cuida disso internamente.

Contagem de parametros: 784256 + 256 + 256128 + 128 + 128*10 + 10 = 235.146. Minusculo para os padroes modernos. O GPT-2 small tem 124M. Isso treina em segundos.

Passo 3: Laco de Treinamento

O padrao canonico forward-loss-backward-step.

def train_one_epoch(model, loader, criterion, optimizer, device):
    model.train()
    total_loss = 0
    correct = 0
    total = 0
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += labels.size(0)
    return total_loss / total, correct / total


def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)
            total_loss += loss.item() * images.size(0)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)
    return total_loss / total, correct / total

Note o torch.no_grad() durante a avaliacao. Isso desativa o autograd, reduzindo o uso de memoria e acelerando a inferencia. Sem ele, o PyTorch constroi um grafo computacional que voce nunca usa.

Passo 4: Conectar Tudo

def main():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    download_mnist()
    train_images = load_images("./mnist_data/train-images-idx3-ubyte.gz")
    train_labels = load_labels("./mnist_data/train-labels-idx1-ubyte.gz")
    test_images = load_images("./mnist_data/t10k-images-idx3-ubyte.gz")
    test_labels = load_labels("./mnist_data/t10k-labels-idx1-ubyte.gz")

    train_dataset = torch.utils.data.TensorDataset(train_images, train_labels)
    test_dataset = torch.utils.data.TensorDataset(test_images, test_labels)
    train_loader = torch.utils.data.DataLoader(
        train_dataset, batch_size=64, shuffle=True
    )
    test_loader = torch.utils.data.DataLoader(
        test_dataset, batch_size=256, shuffle=False
    )

    model = MNISTModel().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    num_params = sum(p.numel() for p in model.parameters())
    print(f"Device: {device}")
    print(f"Parameters: {num_params:,}")
    print(f"Train samples: {len(train_dataset):,}")
    print(f"Test samples: {len(test_dataset):,}")
    print()

    for epoch in range(10):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device
        )
        test_loss, test_acc = evaluate(
            model, test_loader, criterion, device
        )
        print(
            f"Epoch {epoch+1:2d} | "
            f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
            f"Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}"
        )

    torch.save(model.state_dict(), "mnist_mlp.pt")
    print(f"\nModel saved to mnist_mlp.pt")
    print(f"Final test accuracy: {test_acc:.4f}")

Saida esperada apos 10 epocas: ~97,8% de acuracia em teste. Tempo de treinamento em CPU: ~30 segundos. Em GPU: ~5 segundos. No seu mini framework com a mesma arquitetura: ~45 minutos.

Use

Comparacao Rapida: Mini Framework vs PyTorch

Mini Framework (Licao 10) PyTorch
model = Sequential(Linear(784, 256), ReLU(), ...) model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), ...)
pred = model.forward(x) pred = model(x)
optimizer.zero_grad() optimizer.zero_grad()
grad = criterion.backward() depois model.backward(grad) loss.backward()
optimizer.step() optimizer.step()
Sem GPU model.to("cuda")
backward manual para cada modulo O autograd cuida de tudo

A interface e quase identica. A diferenca e tudo o que esta por baixo.

Salvando e Carregando Modelos

torch.save(model.state_dict(), "model.pt")

model = MNISTModel()
model.load_state_dict(torch.load("model.pt", weights_only=True))
model.eval()

Sempre salve o state_dict() (o dicionario de parametros), nao o objeto do modelo. Salvar o objeto do modelo usa pickle, que quebra quando voce refatora o codigo. State dicts sao portateis.

Agendamento de Taxa de Aprendizado

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=10
)
for epoch in range(10):
    train_one_epoch(model, train_loader, criterion, optimizer, device)
    scheduler.step()

O PyTorch traz mais de 15 schedulers: StepLR, ExponentialLR, CosineAnnealingLR, OneCycleLR, ReduceLROnPlateau. Todos se encaixam na mesma interface de otimizador.

Entregue

Esta licao produz dois artefatos:

  • outputs/prompt-pytorch-debugger.md -- um prompt para diagnosticar falhas comuns de treinamento em PyTorch
  • outputs/skill-pytorch-patterns.md -- uma referencia de skill para padroes de treinamento em PyTorch

Exercicios

  1. Adicione batch normalization. Insira nn.BatchNorm1d apos cada camada linear (antes da ativacao). Compare a acuracia de teste e a velocidade de treinamento com a versao apenas com dropout. O batch norm deve alcancar 98%+ em menos epocas.

  2. Implemente um localizador de taxa de aprendizado. Treine por uma epoca com taxa de aprendizado crescente exponencialmente (de 1e-7 ate 1.0). Plote a perda vs LR. A LR otima e logo antes de a perda comecar a subir. Use isso para escolher uma LR melhor para o modelo da MNIST.

  3. Porte para GPU com precisao mista. Adicione torch.amp.autocast e GradScaler ao laco de treinamento. Meca a vazao (amostras/segundo) com e sem precisao mista em GPU. Em uma A100, espere ~2x de aceleracao.

  4. Construa um Dataset customizado. Baixe o Fashion-MNIST (mesmo formato que a MNIST, mas com pecas de roupa). Implemente uma classe FashionMNISTDataset(Dataset) com __getitem__ e __len__. Treine a mesma MLP e compare a acuracia. O Fashion-MNIST e mais dificil -- espere ~88% vs ~98%.

  5. Substitua o Adam por SGD + momentum. Treine com SGD(params, lr=0.01, momentum=0.9). Compare as curvas de convergencia. Depois adicione um scheduler CosineAnnealingLR e veja se o SGD alcanca o Adam na epoca 10.

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Tensor "Um array multidimensional" Um array tipado e ciente de device, com suporte a diferenciacao automatica embutido em cada operacao
Autograd "Backprop automatico" Um sistema baseado em fita que registra operacoes durante o forward pass e depois as reproduz em sentido reverso para computar gradientes exatos
nn.Module "Uma camada" A classe base para qualquer bloco de computacao diferenciavel -- registra parametros, suporta aninhamento, gerencia modos de treino/avaliacao
state_dict "Os pesos do modelo" Um OrderedDict que mapeia nomes de parametros para tensores -- a representacao portatil e serializavel de um modelo treinado
.backward() "Computar gradientes" Percorrer o grafo computacional em sentido reverso, computando e acumulando gradientes para cada tensor folha com requires_grad=True
.to(device) "Mover para a GPU" Transferir recursivamente todos os parametros e buffers para o device especificado (CPU, CUDA, MPS)
DataLoader "O pipeline de dados" Um iterador que faz batching, embaralha e opcionalmente paraleliza o carregamento de dados de um Dataset
Precisao mista "Usar float16" Treinar com forward/backward em float16 para ganhar velocidade enquanto mantem pesos mestres em float32 para estabilidade numerica
Execucao eager "Rodar agora" Operacoes executam imediatamente quando chamadas, e nao adiadas para um passo de compilacao posterior -- a escolha de design central que diferencia o PyTorch do TF 1.x
zero_grad "Resetar gradientes" Zerar todos os gradientes de parametros antes do proximo backward pass, ja que o PyTorch acumula gradientes por padrao

Leitura Complementar

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