Redes Neurais (e mais!) 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 em 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

gemini chatgpt claude

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)
print("grad_fn de d:", d.grad_fn)
d: tensor(20., grad_fn=<AddBackward0>)
grad_fn de d: <AddBackward0 object at 0x7fd0553c9360>

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 2: 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.9843462]
 [-3.5014386]
 [ 1.0013729]]

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)
bias_opt = torch.zeros(1, requires_grad=True)
optimizer = torch.optim.SGD([beta_opt, bias_opt], lr=0.1)

n_epochs = 10000
for epoch in range(n_epochs):
    optimizer.zero_grad()
    logits = X_tensor @ beta_opt + bias_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(), bias_opt.detach().numpy())
# make_dot(loss, params={"loss": loss, "beta_opt": beta_opt, "bias_opt": bias_opt})
Solução otimizada de beta via autograd:
[[-1.9755276]
 [ 1.5226374]
 [-0.9161148]] [-0.06484568]

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

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())
# make_dot(loss, params=dict(model_logistic.named_parameters()))
Solução otimizada de beta via torch.nn para regressão logística:
[[-1.9755334   1.5226942  -0.91611767]] [-0.06487186]

A 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 3: Redes Neurais com PyTorch

Revisão: Redes Neurais

  • Um neurônio recebe entradas \(\mathbf{x} \in \mathbb{R}^d\), calcula uma combinação linear paramétrica e aplica uma transformação não-linear \(\phi\): \[ y = \phi(\mathbf{w}^T\mathbf{x} + b) \]
  • A função \(\phi\) (e.g., ELU, ReLU, \(\tanh\)) é essencial. Sem ela, uma composição de neurônios colapsaria em uma única função afim: \[ (W_2 (W_1 \mathbf{x} + b_1) + b_2) = (W_2 W_1)\mathbf{x} + (W_2 b_1 + b_2) = W'\mathbf{x} + b' \]

Nota

A regressão logística é essencialmente um único neurônio com \(\phi = \sigma\) (função sigmóide), onde: \[ P(y=1|\mathbf{x}) = \sigma(\beta^T\mathbf{x}) \]

Revisão: Redes Neurais

  • Teorema da aproximação universal: Uma rede neural do tipo feedforward com pelo menos uma camada oculta e uma função de ativação não-linear é capaz de aproximar qualquer função contínua \(\mathbb{R}^n \to \mathbb{R}^m\), desde que haja neurônios o suficiente.
  • Redes profundas: Na prática, múltiplas camadas tendem a ser uma representação mais eficiente, e com melhor generalização.

Multi-Layer Perceptron

Um MLP com duas camadas ocultas mapeia os dados \(\mathbf{X}\) através das operações:

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

\[ \hat{\mathbf{y}} = \phi_2 \left( \phi_1 \left( \phi_1 \left( \mathbf{X}\mathbf{W}_1 + \mathbf{b}_1 \right) \mathbf{W}_2 + \mathbf{b}_2 \right) \mathbf{W}_3 + \mathbf{b}_3 \right) \]

NeuralNetwork cluster_input Entrada (X) cluster_h1 Camada h 1 cluster_h2 Camada h 2 cluster_output Saída (ŷ) x1 x 1 h1_1 h 1,1 x1->h1_1 h1_2 h 1,2 x1->h1_2 h1_3 h 1,3 x1->h1_3 x2 x 2 x2->h1_1 x2->h1_2 x2->h1_3 h2_1 h 2,1 h1_1->h2_1 h2_2 h 2,2 h1_1->h2_2 h2_3 h 2,3 h1_1->h2_3 h1_2->h2_1 h1_2->h2_2 h1_2->h2_3 h1_3->h2_1 h1_3->h2_2 h1_3->h2_3 y_hat ŷ h2_1->y_hat h2_2->y_hat h2_3->y_hat

Backpropagation

Como otimizar os parâmetros \(\mathbf{W}_1, \mathbf{W}_2, \mathbf{W}_3\) de uma rede neural? Precisamos dos gradientes \(\nabla_{\mathbf{W}_k} \ell\) para cada camada.

Considere a perda quadrática como exemplo:

\[ \ell(\hat{\mathbf{y}}, \mathbf{y}) = \|\hat{\mathbf{y}} - \mathbf{y}\|^2 \]

Substituindo \(\hat{\mathbf{y}}\) pela expressão do MLP, a perda se torna uma composição de funções:

\[ \ell(\hat{\mathbf{y}}, \mathbf{y}) = \left\| \phi_2 \left( \underbrace{\phi_1 \left( \underbrace{\phi_1 \left( \mathbf{X}\mathbf{W}_1 + \mathbf{b}_1 \right)}_{\mathbf{h}_1} \mathbf{W}_2 + \mathbf{b}_2 \right)}_{\mathbf{h}_2} \mathbf{W}_3 + \mathbf{b}_3 \right) - \mathbf{y} \right\|^2 \]

Backpropagation

Pela regra da cadeia, os gradientes se propagam camada a camada, de trás para frente:

\[ \frac{\partial \ell}{\partial \mathbf{W}_3} = \frac{\partial \ell}{\partial \hat{\mathbf{y}}} \cdot \frac{\partial \hat{\mathbf{y}}}{\partial \mathbf{W}_3}, \qquad \frac{\partial \ell}{\partial \mathbf{W}_2} = \frac{\partial \ell}{\partial \hat{\mathbf{y}}} \cdot \frac{\partial \hat{\mathbf{y}}}{\partial \mathbf{h}_2} \cdot \frac{\partial \mathbf{h}_2}{\partial \mathbf{W}_2}, \qquad \frac{\partial \ell}{\partial \mathbf{W}_1} = \frac{\partial \ell}{\partial \hat{\mathbf{y}}} \cdot \frac{\partial \hat{\mathbf{y}}}{\partial \mathbf{h}_2} \cdot \frac{\partial \mathbf{h}_2}{\partial \mathbf{h}_1} \cdot \frac{\partial \mathbf{h}_1}{\partial \mathbf{W}_1} \]

Dica

Esse é exatamente o cálculo que o autograd realiza ao chamarmos loss.backward(). Não importa quantas camadas a rede tenha, a regra da cadeia se aplica recursivamente.

Otimizadores

Com os gradientes \(\nabla_\theta \ell\) em mãos, precisamos de uma regra de atualização para os parâmetros.

  • SGD (Stochastic Gradient Descent): a atualização mais simples: \[ \theta \leftarrow \theta - \eta \, \nabla_\theta \ell \] onde \(\eta\) é a taxa de aprendizado (learning rate).
  • Adam: combina médias móveis do gradiente (\(1^{\circ}\) momento) e do gradiente ao quadrado (\(2^{\circ}\) momento), adaptando a taxa de aprendizado para cada parâmetro individualmente. É o otimizador padrão na maioria das aplicações modernas.
  • Na prática, basta trocar optim.SGD(...) por optim.Adam(...), a API é idêntica.

Importante

Não é necessário implementar backpropagation nem os otimizadores manualmente: o PyTorch cuida de tudo. Basta definir a rede, a perda, e chamar loss.backward() + optimizer.step().

MLP para Classificação Binária

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.layer2 = nn.Linear(hidden_dim, hidden_dim)
        self.output = nn.Linear(hidden_dim, output_dim)
        self.activation = nn.ReLU()
        self.output_activation = nn.Sigmoid()

    def forward(self, x):
        h1 = self.activation(self.layer1(x))
        h2 = self.activation(self.layer2(h1))
        y_pred = self.output_activation(self.output(h2))
        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))
# make_dot(y_pred, params=dict(model.named_parameters()))

MLP para Classificação Binária

Relembrando…

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

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()

Exercícios: Otimização com PyTorch

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

  • Gerar dados sintéticos com torch.poisson.
  • Implementar o modelo linear em PyTorch.
  • Implementar poisson_nll_loss.
  • Treinar com gradiente descendente e visualizar convergência.

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

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

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

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

4: CNN (MNIST)

Objetivo: construir e treinar uma CNN para classificar dígitos.

Dada uma imagem \(\mathbf{X} \in \mathbb{R}^{28 \times 28}\), classifique-a (0 a 9) minimizando a entropia cruzada (Cross-Entropy Loss): \[ L = -\frac{1}{n}\sum_{i,c} y_{i,c} \log(\hat{y}_{i,c}) \]

Tarefa

  • Carregar o MNIST via torchvision.datasets.
  • Criar modelo com nn.Conv2d, nn.MaxPool2d e nn.Linear.
  • Treinar em mini-batches (DataLoader) com nn.CrossEntropyLoss().
  • Avaliar a acurácia no teste.

5: FM para Recomendação de filmes

Objetivo: aplicar fatorização matricial para sistemas de recomendação.

Aproximamos a avaliação \(r_{u,i}\) (usuário \(u\), item \(i\)) por: \[ \hat{r}_{u,i} = \mu + b_u + b_i + \mathbf{p}_u^\top \mathbf{q}_i \]

Minimizando o erro quadrático médio com penalização (\(L_2\)): \[ L = \sum_{(u,i)} (r_{u,i} - \hat{r}_{u,i})^2 + \lambda \left(||\mathbf{p}_u||^2 + ||\mathbf{q}_i||^2 + b_u^2 + b_i^2\right) \]

Tarefa

  • Usar nn.Embedding para \(p_u, q_i, b_u, b_i\).
  • Calcular \(\hat{r}_{u,i}\) no forward e otimizar a perda com MSE.
  • Treinar no conjunto movielens-100k.
  • Indicar top-K recomendações para um usuário.

6: RNN para Séries Temporais

Objetivo: prever valores em uma série temporal usando redes recorrentes.

Dada uma sequência \((x_{t-k}, \dots, x_{t})\), preveja \(x_{t+1}\) atualizando o estado oculto \(\mathbf{h}_t\): \[ \mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1}), \quad \hat{x}_{t+1} = \mathbf{w}^\top \mathbf{h}_t + b \]

Tarefa

  • Utilizar os dados de Manchas Solares (statsmodels.api.datasets.sunspots).
  • Formatar dados usando janelas do histórico.
  • Implementar modelo com nn.RNN ou nn.LSTM e nn.Linear.
  • Treinar minimizando MSE e plotar previsões contra dados originais.