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.
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 formasx = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32)print(f"Tensor a partir de lista:\n{x}\n")# Atributosprint(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] =99print(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 elementoprint("Soma:", x + y)print("Produto (Hadamard):", x * y)# Multiplicação de matrizesprint("Produto Matricial (@):", x @ y)print("Produto Matricial (matmul):", torch.matmul(x, y))# Reshapingprint("Original shape:", x.shape)print("Reshaped (view):", x.view(4, 1).shape)
Mover tensores para a GPU é trivial e pode resultar em ganhos de velocidade de ordens de magnitude.
# Verifica se a GPU está disponíveldevice ="cuda"if torch.cuda.is_available() else"cpu"print(f"Usando o dispositivo: {device}")# Cria tensores no dispositivo escolhidox_gpu = torch.randn(1000, 1000, device=device)y_gpu = torch.randn(1000, 1000, device=device)# A operação é executada no dispositivoz_gpu = x_gpu @ y_gpu# Para usar com bibliotecas como Matplotlib ou NumPy, mova de volta para CPUz_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 gradientex = torch.tensor(2.0, requires_grad=True)# Define a funçãoy = x**2* torch.sin(x)# Calcula o gradientey.backward()# O gradiente fica armazenado em x.gradprint(f"Gradiente de y em relação a x em x=2: {x.grad.item():.4f}")# Valor analítico para comparaçãoanalytic_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 observadoy_hat = w * x_obsy_hat.backward() # Veremos mais sobre isso depoisprint("gradiente em w:", w.grad)with torch.no_grad(): pred = w *10print("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 + aprint("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.
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 =10000for epoch inrange(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
nn.Module: container padrão para parâmetros e lógica forward.
nn.Linear: módulo pré-construído com pesos e intercepto.
nn.MSELoss / nn.BCELoss: losses prontas.
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:
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.
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().