Otimização e Modelagem Estatística com PyTorch

Minicurso - XI WPSM

Victor Coscrato

Introdução: O que é PyTorch?

O que é PyTorch?

  • Uma biblioteca de computação numérica de alto desempenho para Python.
  • Origens: Criado pelo laboratório de pesquisa em IA do Facebook (FAIR) para superar as limitações de flexibilidade de frameworks da época.
  • Open Source: É uma biblioteca de código aberto desde o seu nascimento em 2016, o que permitiu uma rápida adoção e contribuição da comunidade global.
  • Governança: Embora sempre tenha sido aberto, a governança mudou em 2022 da Meta para a PyTorch Foundation (sob a Linux Foundation), garantindo uma gestão neutra e colaborativa entre diversas empresas.
  • Diferencial: Introduziu o conceito de grafos de computação dinâmicos.

Por que PyTorch?

  • Diferenciação automática (autograd): pode calcular automaticamente o gradiente (a derivada) de qualquer função que você definir. Isso elimina a necessidade de derivar manualmente funções de perda complexas, um processo tedioso e extremamente propenso a erros.
  • Aceleração por hardware (GPU): permite a execução de operações matemáticas em tensores (arrays multidimensionais, como os do NumPy) de forma paralela em GPUs, o que é essencial para treinar modelos de redes neurais profundas em um tempo razoável.
  • Flexibilidade: É difícil pensar um modelo paramétrico que não possa ser implementado em PyTorch.

Dica

Vale pensar no PyTorch como um NumPy com “superpoderes”.

Por que PyTorch?

  • Maturidade: madura e estável, com uma comunidade grande e ativa.
  • Popularidade: domina a pesquisa acadêmica, e.g. 80% dos artigos do NeurIPS 2023 usam PyTorch.
  • Momentum e feedback loop: a maturidade e popularidade criam um ciclo virtuoso de desenvolvimento e adoção.

Exemplos - Reinforcement Learning

Fonte: thumbnail de GothamChess (YouTube).

Exemplos - Computer Vision

Fonte: vídeo de demonstração da Ultralytics.

Exemplos - IA Generativa

Fluxo simplificado de IA generativa: prompt, modelo e saída.

Fonte: elaboração própria.

Tá, mas e eu com isso?

Dica

Mesmo que você não pretenda treinar redes neurais profundas, o PyTorch pode te ajudar, e muito.

Importante

Esse minicurso abordará não apenas redes neurais, mas também aplicações probabilísticas e estatísticas cotidianas.

Parte 1: Conceitos Básicos de PyTorch

Tensores

  • A unidade de dados no PyTorch. Análogo aos ndarrays do NumPy, mas com “superpoderes”.
  • Um tensor possui atributos essenciais:
    • shape: a dimensionalidade do tensor (e.g., escalar, vetor, matriz).
    • dtype: o tipo de dados (torch.float32, torch.long, etc.).
    • device: onde o tensor está alocado na memória (cpu ou cuda).

Tensores

# Criando tensores de várias formas
x = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32)
print(f"Tensor a partir de lista:\n{x}\n")

# Atributos
print(f"Shape: {x.shape}")
print(f"Data type: {x.dtype}")
print(f"Device: {x.device}\n")

# Interoperabilidade com NumPy (compartilham a mesma memória na CPU)
a_np = np.array([5, 6, 7])
a_pt = torch.from_numpy(a_np)
print(f"Tensor a partir de NumPy: {a_pt}")
a_pt[0] = 99
print(f"NumPy original foi modificado: {a_np}")
Tensor a partir de lista:
tensor([[1., 2.],
        [3., 4.]])

Shape: torch.Size([2, 2])
Data type: torch.float32
Device: cpu

Tensor a partir de NumPy: tensor([5, 6, 7])
NumPy original foi modificado: [99  6  7]

Operações com Tensores

A sintaxe é familiar para quem usa NumPy.

x = torch.tensor([[1., 2.], [3., 4.]])
y = torch.tensor([[5., 6.], [7., 8.]])

# Operações elemento a elemento
print("Soma:", x + y)
print("Produto (Hadamard):", x * y)

# Multiplicação de matrizes
print("Produto Matricial (@):", x @ y)
print("Produto Matricial (matmul):", torch.matmul(x, y))

# Reshaping
print("Original shape:", x.shape)
print("Reshaped (view):", x.view(4, 1).shape)
Soma: tensor([[ 6.,  8.],
        [10., 12.]])
Produto (Hadamard): tensor([[ 5., 12.],
        [21., 32.]])
Produto Matricial (@): tensor([[19., 22.],
        [43., 50.]])
Produto Matricial (matmul): tensor([[19., 22.],
        [43., 50.]])
Original shape: torch.Size([2, 2])
Reshaped (view): torch.Size([4, 1])

Aceleração com GPU

Mover tensores para a GPU é trivial e pode resultar em ganhos de velocidade de ordens de magnitude.

# Verifica se a GPU está disponível
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Usando o dispositivo: {device}")

# Cria tensores no dispositivo escolhido
x_gpu = torch.randn(1000, 1000, device=device)
y_gpu = torch.randn(1000, 1000, device=device)

# A operação é executada no dispositivo
z_gpu = x_gpu @ y_gpu

# Para usar com bibliotecas como Matplotlib ou NumPy, mova de volta para CPU
z_cpu = z_gpu.to("cpu")
print(z_cpu.shape)
Usando o dispositivo: cuda
torch.Size([1000, 1000])

O “superpoder” dos tensores

Importante

autograd é o sistema que rastreia todas as operações em tensores para calcular gradientes automaticamente. É a base para a otimização de modelos.

  • Grafo computacional dinâmico: PyTorch constrói um grafo “on-the-fly”.
  • Rastreamento: para um tensor x, se x.requires_grad=True, PyTorch guarda sua “história” de operações.
  • Cálculo de gradientes: ao chamar .backward() em um escalar (e.g., função de perda), PyTorch aplica a regra da cadeia.

autograd na Prática

Vamos calcular a derivada de \(y = x^2 \sin(x)\) em \(x = 2\).

Dica

A derivada analítica é \(2x \sin(x) + x^2 \cos(x)\).

# Define o tensor e habilita rastreamento de gradiente
x = torch.tensor(2.0, requires_grad=True)

# Define a função
y = x**2 * torch.sin(x)

# Calcula o gradiente
y.backward()

# O gradiente fica armazenado em x.grad
print(f"Gradiente de y em relação a x em x=2: {x.grad.item():.4f}")

# Valor analítico para comparação
analytic_grad = 2 * 2 * np.sin(2) + 2**2 * np.cos(2)
print(f"Valor analítico: {analytic_grad:.4f}")
Gradiente de y em relação a x em x=2: 1.9726
Valor analítico: 1.9726

Boas Práticas com Tensores

  • requires_grad=True apenas para parâmetros: dados de entrada geralmente não precisam de gradiente.
  • Use with torch.no_grad() em inferência para economizar memória e tempo.
w = torch.tensor(1.0, requires_grad=True)
x_obs = torch.tensor(2.0)  # dado observado

y_hat = w * x_obs
y_hat.backward() # Veremos mais sobre isso depois
print("gradiente em w:", w.grad)

with torch.no_grad():
    pred = w * 10
print("predição sem rastrear gradiente:", pred)
gradiente em w: tensor(2.)
predição sem rastrear gradiente: tensor(10.)

O Grafo Dinâmico e o grad_fn

No PyTorch, resultados de operações sobre tensores com gradiente carregam um ponteiro para a operação que os criou.

a = torch.tensor(5.0, requires_grad=True)
b = torch.tensor(3.0, requires_grad=True)
d = a * b + a

print("d:", d.item())
print("grad_fn de d:", d.grad_fn)
d: 20.0
grad_fn de d: <AddBackward0 object at 0x7fc71be61390>

Visualizando o Grafo Computacional

make_dot(d, params={'d': d, 'a': a, 'b': b})

  • Os retângulos azuis são as folhas (nossos parâmetros)
  • Os retângulos cinzas são as operações intermediárias
  • A seta indica o fluxo de informação (forward pass)

Calculando as derivadas

make_dot(d, params={'d': d, 'a': a, 'b': b})

Podemos calcular gradientes automaticamente através do backward pass: o PyTorch percorre o grafo de trás para frente e aplica a regra da cadeia para calcular as derivadas parciais.

d.backward()
print("dd/da:", a.grad.item())
print("dd/db:", b.grad.item())
dd/da: 4.0
dd/db: 5.0

Dica

Lembrando que:

  • \(a = 5\) e \(b = 3\)
  • \(d = a \cdot b + a\)

Segue que:

  • \(\frac{\partial d}{\partial a} = b + 1 = 4\)
  • \(\frac{\partial d}{\partial b} = a = 5\).

Resumo dos Conceitos Básicos

  • Tensores são a estrutura central do PyTorch (shape, dtype, device).
  • requires_grad=True ativa o rastreamento para parâmetros que serão otimizados.
  • O autograd constrói o grafo dinâmico e calcula gradientes com .backward().

Importante

Com isso, já temos o básico para definir modelos e treinar parâmetros por otimização numérica.

Parte 3: Modelos Estatísticos com PyTorch

Exemplo: Regressão Linear

Vamos recordar a solução analítica de mínimos quadrados do modelo de regressão linear:

\[ y = X \beta + \epsilon \]

A perda quadrática é: \[ L(\beta) = \sum_{i=1}^{n} (y_i - X_i^T\beta)^2 \]

Derivando e igualando a zero, obtemos a solução fechada:

\[ \hat{\beta} = (X^T X)^{-1}X^Ty \]

Vamos comparar essa solução com a otimização via autograd.

Solução analítica

# Gerar dados sintéticos
np.random.seed(0)
n_samples = 10000
n_features = 3
X = np.random.rand(n_samples, n_features)
true_beta = np.array([2.0, -3.5, 1.0])
y = X @ true_beta + np.random.randn(n_samples) * 0.5

# Converter para tensores do PyTorch
X_tensor = torch.tensor(X, dtype=torch.float32)
y_tensor = torch.tensor(y, dtype=torch.float32).view(-1, 1)

# Solução analítica
XTX_inv = torch.inverse(X_tensor.t() @ X_tensor)
beta_analytical = XTX_inv @ X_tensor.t() @ y_tensor
print("Solução analítica de beta:")
print(beta_analytical.numpy())
Solução analítica de beta:
[[ 1.9843934]
 [-3.5015233]
 [ 1.0014043]]

Solução Numérica com autograd

beta_opt = torch.randn(n_features, 1, requires_grad=True)
optimizer = torch.optim.SGD([beta_opt], lr=0.01)

n_epochs = 10000
for epoch in range(n_epochs):
    optimizer.zero_grad()
    y_pred = X_tensor @ beta_opt
    loss = torch.mean((y_tensor - y_pred) ** 2)
    loss.backward()
    optimizer.step()

print("Solução otimizada de beta via autograd:")
print(beta_opt.detach().numpy())
# make_dot(loss, params={"loss": loss, "beta_opt": beta_opt})
Solução otimizada de beta via autograd:
[[ 1.9843446]
 [-3.5014403]
 [ 1.0013767]]

Interpretação e Ganho Prático

  • No exemplo, o autograd recupera uma solução muito próxima da analítica.
  • O ganho real não é encontrar a solução de mínimos quadrados, mas ter um otimizador universal para modelos sem solução fechada.
  • O mesmo padrão de código se estende para regressão logística, redes neurais e modelos customizados.

Exemplo: Regressão Logística

Para regressão logística:

\[ P(y=1|X) = \sigma(X^T\beta), \quad \sigma(z)=\frac{1}{1+e^{-z}} \]

A função de perda é a log-verossimilhança negativa:

\[ L(\beta) = -\sum_{i=1}^{n} \left[ y_i \log(\sigma(X_i^T\beta)) + (1-y_i)\log(1-\sigma(X_i^T\beta)) \right] \]

Solução Numérica com autograd

# Gerar dados sintéticos
np.random.seed(0)
n_samples = 10000
n_features = 3
X = np.random.rand(n_samples, n_features)
true_beta = np.array([-2.0, 1.5, -1.0])
logits = X @ true_beta
probabilities = 1 / (1 + np.exp(-logits))
y = np.random.binomial(1, probabilities)

# Converter para tensores do PyTorch
X_tensor = torch.tensor(X, dtype=torch.float32)
y_tensor = torch.tensor(y.reshape(-1, 1), dtype=torch.float32)

Solução Numérica com autograd

beta_opt = torch.randn(n_features, 1, requires_grad=True)
optimizer = torch.optim.SGD([beta_opt], lr=0.1)

n_epochs = 10000
for epoch in range(n_epochs):
    optimizer.zero_grad()
    logits = X_tensor @ beta_opt
    y_pred = torch.sigmoid(logits)
    loss = -torch.mean(y_tensor * torch.log(y_pred) +
        (1 - y_tensor) * torch.log(1 - y_pred))
    loss.backward()
    optimizer.step()

print("Solução otimizada de beta via autograd:")
print(beta_opt.detach().numpy())
# make_dot(loss, params={"loss": loss, "beta_opt": beta_opt})
Solução otimizada de beta via autograd:
[[-2.013724 ]
 [ 1.4827118]
 [-0.9567747]]

Transição para torch.nn

Até aqui, trabalhamos com tensores e autograd de forma direta. A API torch.nn organiza isso em módulos reutilizáveis. Vamos repetir a regressão logística usando nn.Module.

class LogisticRegressionModel(nn.Module):
    def __init__(self, n_features):
        super(LogisticRegressionModel, self).__init__()
        self.linear = nn.Linear(n_features, 1)

    def forward(self, x):
        return torch.sigmoid(self.linear(x))

Regressão Logística com torch.nn

— AQUI —

model_logistic = LogisticRegressionModel(n_features)
criterion = nn.BCELoss()
optimizer = optim.SGD(model_logistic.parameters(), lr=0.1)

n_epochs = 10000
for epoch in range(n_epochs):
    optimizer.zero_grad()
    y_pred = model_logistic(X_tensor)
    loss = criterion(y_pred, y_tensor)
    loss.backward()
    optimizer.step()

print("Solução otimizada de beta via torch.nn para regressão logística:")
print(model_logistic.linear.weight.detach().numpy(), model_logistic.linear.bias.detach().numpy())

if make_dot is not None:
    make_dot(y_pred, params=dict(model_logistic.named_parameters()))
else:
    print("Instale torchviz para visualizar o grafo com make_dot.")
Solução otimizada de beta via torch.nn para regressão logística:
[[-1.9755329  1.5226946 -0.9161172]] [-0.06487267]

Componentes-Chave da API torch.nn

  1. nn.Module: container padrão para parâmetros e lógica forward.
  2. nn.Linear: módulo pré-construído com pesos e intercepto.
  3. nn.MSELoss / nn.BCELoss: losses prontas.
  4. torch.optim: atualização dos parâmetros + zero_grad().

Parte 4: Redes Neurais com PyTorch

Redes Neurais

Uma MLP com uma camada oculta pode ser escrita como:

\[ \mathbf{h} = \phi_1(\mathbf{X}\mathbf{W}_1 + \mathbf{b}_1), \quad \hat{\mathbf{y}} = \phi_2(\mathbf{h}\mathbf{W}_2 + \mathbf{b}_2) \]

Definir e treinar essa rede no PyTorch segue o mesmo ciclo de otimização.

Definindo uma MLP Simples

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

    def forward(self, x):
        h = self.activation1(self.layer1(x))
        y_pred = self.activation2(self.layer2(h))
        return y_pred


model = SimpleMLP(input_dim=3, hidden_dim=5, output_dim=1)
y_pred = model(torch.tensor([0, 0, 0], dtype=torch.float32))

if make_dot is not None:
    make_dot(y_pred, params=dict(model.named_parameters()))
else:
    print("Instale torchviz para visualizar o grafo com make_dot.")

Treinamento da MLP em Classificação Binária

O autograd diferencia automaticamente através de camadas e ativações. Basta definir a perda e chamar loss.backward().

class SimpleMLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super(SimpleMLP, self).__init__()
        self.layer1 = nn.Linear(input_dim, hidden_dim)
        self.activation1 = nn.ReLU()
        self.layer2 = nn.Linear(hidden_dim, output_dim)
        self.activation2 = nn.Sigmoid()

    def forward(self, x):
        h = self.activation1(self.layer1(x))
        y_pred = self.activation2(self.layer2(h))
        return y_pred


model = SimpleMLP(input_dim=X.shape[1], hidden_dim=5, output_dim=1)
criterion = nn.BCELoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)

n_epochs = 10000
for epoch in range(n_epochs):
    optimizer.zero_grad()
    y_pred = model(X_tensor)
    loss = criterion(y_pred, y_tensor)
    loss.backward()
    optimizer.step()

# Conjunto de teste
X_test = np.random.rand(100000, n_features)
X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
logits_test = X_test @ true_beta
probabilities_test = 1 / (1 + np.exp(-logits_test))
y_test = np.random.binomial(1, probabilities_test)
y_test_tensor = torch.tensor(y_test.reshape(-1, 1), dtype=torch.float32)

print("Acurácia no conjunto de teste:")
with torch.no_grad():
    y_pred_test = model(X_test_tensor)
    y_pred_labels = (y_pred_test >= 0.5).float()
    accuracy = (y_pred_labels.eq(y_test_tensor).sum().item()) / y_test_tensor.size(0)
    print(f"Acurácia: {accuracy * 100:.2f}%")

print("Acurácia do modelo de regressão logística via torch.nn no conjunto de teste:")
with torch.no_grad():
    y_pred_test = model_logistic(X_test_tensor)
    y_pred_labels = (y_pred_test >= 0.5).float()
    accuracy = (y_pred_labels.eq(y_test_tensor).sum().item()) / y_test_tensor.size(0)
    print(f"Acurácia: {accuracy * 100:.2f}%")
Acurácia no conjunto de teste:
Acurácia: 69.41%
Acurácia do modelo de regressão logística via torch.nn no conjunto de teste:
Acurácia: 69.39%

Exercícios — Otimização com PyTorch

Exercício 1 — Regressão de Poisson (GLM)

Objetivo: implementar e otimizar uma regressão de Poisson com função de perda customizada.

Queremos modelar:

\[ y_i \sim \text{Poisson}(\lambda_i), \quad \log(\lambda_i) = \mathbf{x}_i^\top \mathbf{w} + b \]

A NLL correspondente é:

\[ L = \sum_i \left(e^{\mathbf{x}_i^\top \mathbf{w} + b} - y_i(\mathbf{x}_i^\top \mathbf{w} + b)\right) \]

Tarefa: 1. Gerar dados sintéticos com torch.poisson. 2. Implementar o modelo linear em PyTorch. 3. Implementar poisson_nll_loss. 4. Treinar com gradiente descendente e visualizar convergência.

Exercício 2 — Gaussian Mixture Model (GMM)

Objetivo: ajustar os parâmetros de um GMM 1D com duas componentes diretamente via autograd.

\[ p(y_i) = \pi_1 \mathcal{N}(y_i | \mu_1, \sigma_1^2) + \pi_2 \mathcal{N}(y_i | \mu_2, \sigma_2^2) \]

Para impor restrições: - softmax para pesos da mistura. - exp nos log-desvios para garantir \(\sigma_k > 0\).

Tarefa: 1. Gerar dados de um GMM 1D com duas gaussianas. 2. Definir parâmetros como torch.nn.Parameter. 3. Implementar gmm_nll_loss com torch.logsumexp. 4. Otimizar parâmetros e comparar densidade aprendida com dados reais.

Exercício 3 — Regressão Linear Heterocedástica

Objetivo: modelar média e variância condicional simultaneamente.

\[ y_i \sim \mathcal{N}(\mu_i, \sigma_i^2), \quad \mu_i = f_\mu(\mathbf{x}_i), \quad \log(\sigma_i^2) = f_\sigma(\mathbf{x}_i) \]

A NLL pode ser escrita como:

\[ L = \frac{1}{n} \sum_i \left[\frac{1}{2}s_i + \frac{(y_i - \mu_i)^2}{2e^{s_i}}\right], \quad s_i = \log(\sigma_i^2) \]

Tarefa: 1. Criar um nn.Module com duas saídas (mu_head e logvar_head). 2. Implementar heteroscedastic_gaussian_nll. 3. Treinar em dados simulados com variância crescente. 4. Plotar média prevista e bandas de incerteza (\(\pm 2\sigma\)).