PyTorch 101, Noções básicas sobre gráficos, diferenciação automática e autograd
Introdução
PyTorch é uma das principais bibliotecas de aprendizado profundo em Python que existe. É a escolha certa para pesquisas de aprendizagem profunda e, a cada dia que passa, mais e mais empresas e laboratórios de pesquisa estão adotando esta biblioteca.
Nesta série de tutoriais, apresentaremos o PyTorch e como fazer o melhor uso das bibliotecas, bem como do ecossistema de ferramentas construído em torno dele. Abordaremos primeiro os blocos de construção básicos e, em seguida, veremos como você pode prototipar rapidamente arquiteturas personalizadas. Finalmente concluiremos com algumas postagens sobre como dimensionar seu código e como depurá-lo se algo der errado.
Você pode obter todo o código nesta postagem (e também em outras postagens) no repositório do Github aqui.
Pré-requisitos
- Regra da cadeia
- Compreensão básica de aprendizagem profunda
- PyTorch 1.0
Diferenciação Automática
Muitas séries de tutoriais sobre PyTorch começariam com uma discussão rudimentar sobre quais são as estruturas básicas. No entanto, gostaria de começar discutindo primeiro a diferenciação automática.
A diferenciação automática é um alicerce não apenas do PyTorch, mas de todas as bibliotecas DL existentes. Na minha opinião, o mecanismo de diferenciação automática do PyTorch, chamado Autograd, é uma ferramenta brilhante para entender como funciona a diferenciação automática. Isso não apenas ajudará você a entender melhor o PyTorch, mas também outras bibliotecas DL.
As arquiteturas modernas de redes neurais podem ter milhões de parâmetros que podem ser aprendidos. Do ponto de vista computacional, o treinamento de uma rede neural consiste em duas fases:
- Uma passagem direta para calcular o valor da função de perda.
- Uma passagem para trás para calcular os gradientes dos parâmetros que podem ser aprendidos.
O passe para frente é bastante direto. A saída de uma camada é a entrada da próxima e assim por diante.
A passagem para trás é um pouco mais complicada, pois exige que usemos a regra da cadeia para calcular os gradientes de pesos em relação à função de perda.
Um exemplo de brinquedo
Tomemos uma rede neural muito simples que consiste em apenas 5 neurônios. Nossa rede neural se parece com a seguinte.
Uma rede neural muito simples
As equações a seguir descrevem nossa rede neural.
$$b=w_1 * a $$$$c=w_2 * a $$$$d=w_3 * b + w_4 * c $$$$L=10 - d $$
Vamos calcular os gradientes para cada um dos parâmetros que podem ser aprendidos $w$.
$$\frac{\partial{L}}{\partial{w_4}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4 }} $$$$\frac{\partial{L}}{\partial{w_3}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{ \partial{w_3}} $$$$\frac{\partial{L}}{\partial{w_2}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{ d}}{\partial{c}} * \frac{\partial{c}}{\partial{w_2}} $$$$\frac{\partial{L}}{\partial{w_1}}=\frac {\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{b}} * \frac{\partial{b}}{\partial{w_1}} $$
Todos esses gradientes foram calculados aplicando a regra da cadeia. Observe que todos os gradientes individuais no lado direito das equações mencionadas acima podem ser calculados diretamente, uma vez que os numeradores dos gradientes são funções explícitas dos denominadores.
Gráficos de computação
Poderíamos calcular manualmente os gradientes de nossa rede, pois era muito simples. Imagine, e se você tivesse uma rede com 152 camadas. Ou, se a rede tivesse várias filiais.
Quando projetamos software para implementar redes neurais, queremos encontrar uma maneira que nos permita calcular perfeitamente os gradientes, independentemente do tipo de arquitetura, para que o programador não precise calcular manualmente os gradientes quando forem feitas alterações no rede.
Galvanizamos essa ideia na forma de uma estrutura de dados chamada Gráfico de computação. Um gráfico de computação é muito semelhante ao diagrama do gráfico que fizemos na imagem acima. No entanto, os nós em um gráfico de computação são basicamente operadores. Esses operadores são basicamente operadores matemáticos, exceto em um caso, onde precisamos representar a criação de uma variável definida pelo usuário.
Observe que também denotamos as variáveis folha $a, w_1, w_2, w_3, w_4$no gráfico para maior clareza. No entanto, deve-se observar que eles não fazem parte do gráfico de cálculo. O que eles representam em nosso gráfico é o caso especial de variáveis definidas pelo usuário que acabamos de abordar como exceção.
Gráfico de computação para nossa rede neural muito simples
As variáveis b,c e d são criadas como resultado de operações matemáticas, enquanto as variáveis a, w1, w2, w3 e >w4 são inicializados pelo próprio usuário. Como não são criados por nenhum operador matemático, os nós correspondentes à sua criação são representados pelo próprio nome. Isso é verdade para todos os nós folha do gráfico.
Calculando os gradientes
Agora estamos prontos para descrever como calcularemos gradientes usando um gráfico de computação.
Cada nó do gráfico de computação, com exceção dos nós folha, pode ser considerado como uma função que recebe algumas entradas e produz uma saída. Considere o nó do gráfico que produz a variável d de $w_4c$e $w_3b$. Portanto podemos escrever,
$$d=f(w_3b, w_4c) $$
d é a saída da função f(x,y)=x + y
Agora, podemos calcular facilmente o gradiente de $f$em relação às suas entradas, $\frac{\partial{f}}{\partial{w_3b}}$e $\frac{\partial{f}}{\ parcial{w_4c}}$(ambos 1). Agora, rotule as arestas que entram nos nós com seus respectivos gradientes, como na imagem a seguir.
Gradientes Locais
Fazemos isso para todo o gráfico. O gráfico fica assim.
Retropropagação em um gráfico computacional
A seguir descrevemos o algoritmo para calcular a derivada de qualquer nó neste gráfico em relação à perda, $L$. Digamos que queremos calcular a derivada, $\frac{\partial{f}}{\partial{w_4}}$.
- Primeiro rastreamos todos os caminhos possíveis de d até $w_4$.
- Existe apenas um desses caminhos.
- Multiplicamos todas as arestas ao longo deste caminho.
Se você observar, o produto é exatamente a mesma expressão que derivamos usando a regra da cadeia. Se houver mais de um caminho para uma variável de L então, multiplicamos as arestas ao longo de cada caminho e depois as somamos. Por exemplo, $\frac{\partial{L}}{\partial{a}}$é calculado como
$$\frac{\partial{f}}{\partial{w_4}}=\frac{\partial{L}}{\partial{d}}*\frac{\partial{d}}{\partial{b }}*\frac{\partial{b}}{\partial{a}} + \frac{\partial{L}}{\partial{d}}*\frac{\partial{d}}{\partial{ c}}*\frac{\partial{c}}{\partial{a}} $$
PyTorch Autograd
Agora que entendemos o que é um gráfico computacional, vamos voltar ao PyTorch e entender como o acima é implementado no PyTorch.
Tensor
Tensor
é uma estrutura de dados que é um bloco de construção fundamental do PyTorch. Tensores
s são muito parecidos com arrays numpy, exceto que, diferentemente do numpy, os tensores são projetados para aproveitar as vantagens dos recursos de computação paralela de uma GPU. Muita sintaxe do Tensor é semelhante à dos arrays numpy.
In [1]: import torch
In [2]: tsr = torch.Tensor(3,5)
In [3]: tsr
Out[3]:
tensor([[ 0.0000e+00, 0.0000e+00, 8.4452e-29, -1.0842e-19, 1.2413e-35],
[ 1.4013e-45, 1.2416e-35, 1.4013e-45, 2.3331e-35, 1.4013e-45],
[ 1.0108e-36, 1.4013e-45, 8.3641e-37, 1.4013e-45, 1.0040e-36]])
Um deles, Tensor
é como um ndarray
numpy. Uma estrutura de dados que permite executar opções rápidas de álgebra linear. Se você deseja que o PyTorch crie um gráfico correspondente a essas operações, você terá que definir o atributo requires_grad
do Tensor
como True.
A API pode ser um pouco confusa aqui. Existem várias maneiras de inicializar tensores no PyTorch. Embora algumas maneiras permitam que você defina explicitamente que requires_grad
no próprio construtor, outras exigem que você o defina manualmente após a criação do Tensor.
>> t1 = torch.randn((3,3), requires_grad = True)
>> t2 = torch.FloatTensor(3,3) # No way to specify requires_grad while initiating
>> t2.requires_grad = True
requires_grad
é contagioso. Isso significa que quando um Tensor
é criado operando em outros Tensor
s, o requires_grad
do Tensor
resultante seria ser definido como True
dado que pelo menos um dos tensores usados para criação tem seu requires_grad
definido como True
.
Cada Tensor
possui um atributo chamado grad_fn
, que se refere ao operador matemático que cria a variável. Se requires_grad
estiver definido como False, grad_fn
seria Nenhum.
Em nosso exemplo onde, $ d=f(w_3b , w_4c) $, a função grad de d seria o operador de adição, já que f adiciona-os à entrada. Observe que o operador de adição também é o nó em nosso gráfico que produz o d. Se nosso Tensor
for um nó folha (inicializado pelo usuário), então grad_fn
também será Nenhum.
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
L = 10 - d
print("The grad fn for a is", a.grad_fn)
print("The grad fn for d is", d.grad_fn)
Se você executar o código acima, obterá a seguinte saída.
The grad fn for a is None
The grad fn for d is <AddBackward0 object at 0x1033afe48>
Pode-se usar a função membro is_leaf
para determinar se uma variável é um Tensor
folha ou não.
Função
Todas as operações matemáticas no PyTorch são implementadas pela classe torch.nn.Autograd.Function. Esta classe tem duas funções-membro importantes que precisamos examinar.
A primeira é a função forward , que simplesmente calcula a saída usando suas entradas.
A função backward
pega o gradiente de entrada vindo da parte da rede na frente dela. Como você pode ver, o gradiente a ser retropropagado a partir de uma função f é basicamente o gradiente que é retropropagado para f a partir das camadas na frente dela multiplicado pelo gradiente local da saída de f em relação às suas entradas. Isso é exatamente o que a função backward
faz.
Vamos entender novamente com nosso exemplo de $$d=f(w_3b , w_4c) $$
- d é o nosso
Tensor
aqui. Seugrad_fn
é
. Esta é basicamente a operação de adição, já que a função que cria d adiciona entradas. - A função
forward
do it égrad_fn
recebe as entradas $w_3b$e $w_4c$e as adiciona. Este valor é basicamente armazenado no d - A função
backward
de
basicamente toma o gradiente de entrada das outras camadas como entrada. Isso é basicamente $\frac{\partial{L}}{\partial{d}}$vindo ao longo da borda que vai de L a d. Este gradiente também é o gradiente de L w.r.t para d e é armazenado no atributograd
dod
. Ele pode ser acessado chamandod.grad
. - Em seguida, calcula os gradientes locais $\frac{\partial{d}}{\partial{w_4c}}$e $\frac{\partial{d}}{\partial{w_3b}}$.
- Em seguida, a função reversa multiplica o gradiente de entrada pelos gradientes calculados localmente respectivamente e "envia" os gradientes para suas entradas invocando o método retroativo do
grad_fn
de suas entradas. - Por exemplo, a função
backward
de
associada a d invoca a função de retrocesso do grad_fn do $w_4*c$(aqui, $w_4*c$é um tensor intermediário e seu grad_fn é
. No momento da invocação dobackward
, o gradiente $\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4c}} $é passado como entrada. - Agora, para a variável $w_4*c$, $\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4c}} $torna-se o gradiente de entrada , como $\frac{\partial{L}}{\partial{d}} $foi para $d$ na etapa 3 e o processo se repete.
Algoritmicamente, veja como a retropropagação acontece com um gráfico de computação. (Não é a implementação real, apenas representativa)
def backward(self, incoming_gradients):
# Set the gradient for the current tensor
self.Tensor.grad = incoming_gradients
# Loop through the inputs to propagate the gradient
for inp in self.inputs:
if inp.grad_fn is not None:
# Compute new incoming gradients for the input
new_incoming_gradients = incoming_gradients * local_grad(self.Tensor, inp)
# Recursively call backward on the input
inp.grad_fn.backward(new_incoming_gradients)
Aqui, self.Tensor
é basicamente o Tensor
criado por Autograd.Function, que foi d em nosso exemplo.
Gradientes de entrada e gradientes locais foram descritos acima.
Para calcular derivadas em nossa rede neural, geralmente chamamos backward
no Tensor
que representa nossa perda. Em seguida, retrocedemos no gráfico começando no nó que representa o grad_fn
de nossa perda.
Conforme descrito acima, a função backward
é chamada recursivamente através do gráfico à medida que retrocedemos. Uma vez, alcançamos um nó folha, já que grad_fn
é None, mas paramos de retroceder por esse caminho.
Uma coisa a notar aqui é que o PyTorch apresenta um erro se você chamar backward()
no Tensor com valor vetorial. Isso significa que você só pode chamar backward
em um Tensor com valor escalar. Em nosso exemplo, se assumirmos que a
é um Tensor com valor vetorial e chamarmos backward
em L, ocorrerá um erro.
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
L = (10 - d)
L.backward()
A execução do trecho acima resulta no seguinte erro.
RuntimeError: grad can be implicitly created only for scalar outputs
Isso ocorre porque os gradientes podem ser calculados em relação aos valores escalares por definição. Você não pode diferenciar exatamente um vetor em relação a outro vetor. A entidade matemática usada para tais casos é chamada de Jacobiano, cuja discussão está além do escopo deste artigo.
Existem duas maneiras de superar isso.
Se você apenas fizer uma pequena alteração no código acima, configurando L
como a soma de todos os erros, nosso problema será resolvido.
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
Replace L = (10 - d) by
L = (10 -d).sum()
L.backward()
Feito isso, você pode acessar os gradientes chamando o atributo grad
do Tensor
.
A segunda maneira é, por algum motivo, ter que chamar absolutamente backward
em uma função vetorial, você pode passar um torch.ones
do tamanho da forma do tensor que você está tentando chamar para trás com.
# Replace L.backward() with
L.backward(torch.ones(L.shape))
Observe como backward
costumava receber gradientes de entrada como entrada. Fazer o que foi dito acima faz com que o backward
pense que o gradiente de entrada é apenas Tensor do mesmo tamanho que L, e é capaz de retropropagar.
Desta forma, podemos ter gradientes para cada Tensor
, e podemos atualizá-los usando o algoritmo de otimização de nossa escolha.
w1 = w1 - learning_rate * w1.grad
E assim por diante.
Como os gráficos do PyTorch são diferentes dos gráficos do TensorFlow
PyTorch cria algo chamado Gráfico de Computação Dinâmica, o que significa que o gráfico é gerado dinamicamente.
Até que a função forward
de uma variável seja chamada, não existe nenhum nó para o Tensor
(é grad_fn
) no gráfico.
a = torch.randn((3,3), requires_grad = True) #No graph yet, as a is a leaf
w1 = torch.randn((3,3), requires_grad = True) #Same logic as above
b = w1*a #Graph with node `mulBackward` is created.
O gráfico é criado como resultado da função forward
de muitos Tensores sendo invocados. Somente então, os buffers para os nós não-folha alocados para o gráfico e os valores intermediários (usados para calcular gradientes posteriormente). Quando você chama backward
, à medida que os gradientes são calculados, esses buffers (para nós não-folha variáveis) são essencialmente liberadas e o gráfico é destruído (de certa forma, você não pode retropropagar através dele, pois os buffers que contêm valores para calcular os gradientes desapareceram).
Da próxima vez, você chamará forward
no mesmo conjunto de tensores, os buffers dos nós folha da execução anterior serão compartilhados, enquanto os buffers dos nós não-folha serão criados novamente.
Se você chamar backward
mais de uma vez em um gráfico com nós não-folha, você encontrará o seguinte erro.
RuntimeError: Trying to backward through the graph a second time, but the buffers have already been freed. Specify retain_graph=True when calling backward the first time.
Isso ocorre porque os buffers não-folha são destruídos na primeira vez que backward()
é chamado e, portanto, não há caminho para navegar até as folhas quando backward
é invocado pela segunda vez. . Você pode desfazer esse comportamento de destruição de buffer não-folha adicionando o argumento retain_graph=True
à função backward
.
loss.backward(retain_graph = True)
Se você fizer o acima, poderá retropropagar novamente através do mesmo gráfico e os gradientes serão acumulados, ou seja, na próxima retropropagação, os gradientes serão adicionados àqueles já armazenados no back pass anterior.
Isso contrasta com os gráficos de computação estática, usados pelo TensorFlow, onde o gráfico é declarado antes executando o programa. Em seguida, o gráfico é “executado” alimentando valores no gráfico predefinido.
O paradigma do gráfico dinâmico permite que você faça alterações na arquitetura da rede durante o tempo de execução, já que um gráfico é criado somente quando um trecho de código é executado.
Isso significa que um gráfico pode ser redefinido durante a vida útil de um programa, uma vez que não é necessário defini-lo de antemão.
Isto, entretanto, não é possível com gráficos estáticos, onde os gráficos são criados antes da execução do programa e apenas executados mais tarde.
Os gráficos dinâmicos também facilitam a depuração, pois é mais fácil localizar a origem do seu erro.
Alguns truques de comércio
requer_grad
Este é um atributo da classe Tensor
. Por padrão, é falso. É útil quando você precisa congelar algumas camadas e impedir que elas atualizem os parâmetros durante o treinamento. Você pode simplesmente definir requires_grad
como False, e esses Tensors
não participarão do gráfico de cálculo.
Assim, nenhum gradiente seria propagado para elas, ou para as camadas que dependem dessas camadas para o fluxo de gradiente requires_grad
. Quando definido como True, requires_grad
é contagioso, o que significa que mesmo se um operando de uma operação tiver requires_grad
definido como True, o mesmo acontecerá com o resultado.
tocha.no_grad()
Quando estamos computando gradientes, precisamos armazenar em cache os valores de entrada e os recursos intermediários, pois eles podem ser necessários para calcular o gradiente posteriormente.
O gradiente de $b=w_1*a $w.r.t suas entradas $w_1$e $a$são $a$e $w_1$respectivamente. Precisamos armazenar esses valores para cálculo do gradiente durante a passagem para trás. Isso afeta o consumo de memória da rede.
Embora estejamos realizando inferência, não calculamos gradientes e, portanto, não precisamos armazenar esses valores. Na verdade, nenhum gráfico precisa ser criado durante a inferência, pois isso levará ao consumo inútil de memória.
PyTorch oferece um gerenciador de contexto, chamado torch.no_grad
para essa finalidade.
with torch.no_grad:
inference code goes here
Nenhum gráfico é definido para operações executadas neste gerenciador de contexto.
Conclusão
Compreender como o Autograd e os gráficos de computação funcionam pode tornar a vida com o PyTorch muito mais fácil. Com nossas bases sólidas, os próximos posts irão detalhar como criar arquiteturas complexas personalizadas, como criar pipelines de dados personalizados e coisas mais interessantes.
Leitura adicional
- Regra da Cadeia
- Retropropagação