2.5. Diferenciação automática¶ Open the notebook in SageMaker Studio Lab
Como já explicado em Section 2.4, a diferenciação é uma etapa crucial em quase todos os algoritmos de otimização de Deep Learning. Embora os cálculos para obter esses derivados sejam diretos, exigindo apenas alguns cálculos básicos, para modelos complexos, trabalhando as atualizações manualmente pode ser uma tarefa difícil (e muitas vezes sujeita a erros). Frameworks de Deep learning aceleram este trabalho calculando automaticamente as derivadas, ou seja, diferenciação automática. Na prática, com base em nosso modelo projetado o sistema constrói um grafo computacional, rastreando quais dados combinados por meio de quais operações produzem a saída. A diferenciação automática permite que o sistema propague gradientes posteriormente. Aqui, propagar(do Inglês backpropagate) significa simplesmente traçar o gráfico computacional, preencher as derivadas parciais em relação a cada parâmetro.
2.5.1. Um exemplo simples¶
Como exemplo, digamos que estamos interessados em derivar a função
\(y = 2\mathbf{x}^{\top}\mathbf{x}\) com respeito ao vetor coluna
\(\mathbf{x}\). Inicialmente criamos a variável x
e atribuimos a
ela um valor inicial.
from mxnet import autograd, np, npx
npx.set_np()
x = np.arange(4.0)
x
array([0., 1., 2., 3.])
import torch
x = torch.arange(4.0)
x
tensor([0., 1., 2., 3.])
import tensorflow as tf
x = tf.range(4, dtype=tf.float32)
x
<tf.Tensor: shape=(4,), dtype=float32, numpy=array([0., 1., 2., 3.], dtype=float32)>
Antes de calcularmos o gradiente de\(y\) em relação a \(\mathbf{x}\), precisamos armazena-lo. É importante que não aloquemos nova memória cada vez que tomamos uma derivada em relação a um parâmetro porque costumamos atualizar os mesmos parâmetros milhares ou milhões de vezes e podemos rapidamente ficar sem memória. Observe que um gradiente de uma função com valor escalar com respeito a um vetor \(\mathbf{x}\) tem valor vetorial e tem a mesma forma de \(\mathbf{x}\).
# Alocamos memória do gradiente do vetor invocando `attach_grad`
x.attach_grad()
# Após calcularmos o gradiente como respeito a `x`, será possível
# acessa-lo vai atributo `grad`, cujo valor inicializará com 0s
x.grad
array([0., 0., 0., 0.])
x.requires_grad_(True) # Same as `x = torch.arange(4.0, requires_grad=True)`
x.grad # O valor padrão é None
x = tf.Variable(x)
Então calcularemos \(y\).
# Colocaremos o código dentro do escopo `autograd.record` para contruir
# o grafo computacional
with autograd.record():
y = 2 * np.dot(x, x)
y
array(28.)
y = 2 * torch.dot(x, x)
y
tensor(28., grad_fn=<MulBackward0>)
# Gravando todos os calculos em um *tape*
with tf.GradientTape() as t:
y = 2 * tf.tensordot(x, x, axes=1)
y
<tf.Tensor: shape=(), dtype=float32, numpy=28.0>
Uma vez que x
é um vetor de comprimento 4, um produto interno de
x
ex
é realizado, produzindo a saída escalar que atribuímos a
y
. Em seguida, podemos calcular automaticamente o gradiente de y
com relação a cada componente de x
chamando a função de
retropropagação e imprimindo o gradiente.
y.backward()
x.grad
[03:58:07] src/base.cc:49: GPU context requested, but no GPUs found.
array([ 0., 4., 8., 12.])
y.backward()
x.grad
tensor([ 0., 4., 8., 12.])
x_grad = t.gradient(y, x)
x_grad
<tf.Tensor: shape=(4,), dtype=float32, numpy=array([ 0., 4., 8., 12.], dtype=float32)>
O gradiente da função \(y = 2\mathbf{x}^{\top}\mathbf{x}\) em relação a \(\mathbf{x}\) should be \(4\mathbf{x}\). Vamos verificar rapidamente se nosso gradiente desejado foi calculado corretamente.
x.grad == 4 * x
array([ True, True, True, True])
x.grad == 4 * x
tensor([True, True, True, True])
x_grad == 4 * x
<tf.Tensor: shape=(4,), dtype=bool, numpy=array([ True, True, True, True])>
Agora calculamos outra função de x
.
with autograd.record():
y = x.sum()
y.backward()
x.grad # Sobrescrito pelo novo gradiente calculado
array([1., 1., 1., 1.])
# O PyTorch acumula os gradientes por padrão, precisamos
# apagar os valores anteriores
x.grad.zero_()
y = x.sum()
y.backward()
x.grad
tensor([1., 1., 1., 1.])
with tf.GradientTape() as t:
y = tf.reduce_sum(x)
t.gradient(y, x) # Overwritten by the newly calculated gradient
<tf.Tensor: shape=(4,), dtype=float32, numpy=array([1., 1., 1., 1.], dtype=float32)>
2.5.2. Retroceder para variáveis não escalares¶
Tecnicamente, quando y
não é um escalar, a interpretação mais
natural da diferenciação de um vetor y
em relação a um vetor, x
é uma matriz. Para y
ex
de ordem superior e dimensão superior,
o resultado da diferenciação pode ser um tensor de ordem alta.
No entanto, embora esses objetos mais exóticos apareçam em aprendizado de máquina avançado (incluindo em Deep Learning), com mais frequência quando estamos retrocedendo um vetor, estamos tentando calcular as derivadas das funções de perda para cada constituinte de um lote de exemplos de treinamento. Aqui, nossa intenção é não calcular a matriz de diferenciação mas sim a soma das derivadas parciais calculado individualmente para cada exemplo no lote.
# Quando invocamos `backward` em uma variável de vetor valorado `y` (em função de `x`),
# uma nova variável escalar é criada somando os elementos em `y`. Então o
# gradiente daquela variável escalar em respeito a `x` é computada
with autograd.record():
y = x * x # `y` is a vector
y.backward()
x.grad # Igual a y = sum(x * x)
array([0., 2., 4., 6.])
# Invocar `backward` em um não escalar requer passar um argumento `gradient`
# que especifica o gradiente da função diferenciada w.r.t `self`.
# Em nosso caso, simplesmente queremos somar as derivadas parciais, assim passando
# em um gradiente de uns é apropriado
x.grad.zero_()
y = x * x
# y.backward(torch.ones(len(x))) equivalente a:
y.sum().backward()
x.grad
tensor([0., 2., 4., 6.])
with tf.GradientTape() as t:
y = x * x
t.gradient(y, x) # Same as `y = tf.reduce_sum(x * x)`
<tf.Tensor: shape=(4,), dtype=float32, numpy=array([0., 2., 4., 6.], dtype=float32)>
2.5.3. Computação Detaching¶
Às vezes, desejamos mover alguns cálculos fora do gráfico computacional
registrado. Por exemplo, digamos que y
foi calculado como uma função
dex
, e que subsequentemente z
foi calculado como uma função
dey
e x
. Agora, imagine que quiséssemos calcular o gradiente
de z
em relação ax
, mas queria, por algum motivo, tratar y
como uma constante, e apenas leve em consideração a função que x
jogou apósy
foi calculado. Aqui, podemos desanexar y
para
retornar uma nova variável u
que tem o mesmo valor que y
, mas
descarta qualquer informação sobre como y
foi calculado no grafo
computacional. Em outras palavras, o gradiente não fluirá de volta de
u
para x
. Assim, a seguinte função de retropropagação calcula a
derivada parcial de z = u * x
com respeito ax
enquanto trata
u
como uma constante, em vez da derivada parcial de
z = x * x * x
em relação ax
.
with autograd.record():
y = x * x
u = y.detach()
z = u * x
z.backward()
x.grad == u
array([ True, True, True, True])
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
x.grad == u
tensor([True, True, True, True])
# Defina `persistent=True` para executar `t.gradient` mais de uma vez
with tf.GradientTape(persistent=True) as t:
y = x * x
u = tf.stop_gradient(y)
z = u * x
x_grad = t.gradient(z, x)
x_grad == u
<tf.Tensor: shape=(4,), dtype=bool, numpy=array([ True, True, True, True])>
Uma vez que o cálculo de y
foi registrado, podemos subsequentemente
invocar a retropropagação em y
para obter a derivada
dey = x * x
com respeito a x
, que é2 * x
.
y.backward()
x.grad == 2 * x
array([ True, True, True, True])
x.grad.zero_()
y.sum().backward()
x.grad == 2 * x
tensor([True, True, True, True])
t.gradient(y, x) == 2 * x
<tf.Tensor: shape=(4,), dtype=bool, numpy=array([ True, True, True, True])>
2.5.4. Computando o Gradiente do Python Control Flow¶
Uma vantagem de usar a diferenciação automática é que mesmo se construir
o gráfo computacional de uma função requer muito trabalho com o uso do
Python Control Flow (por exemplo, condicionais, loops e chamadas de
função arbitrárias), ainda podemos calcular o gradiente da variável
resultante. No trecho a seguir, observe que o número de iterações do
loop while
e a avaliação da instrução if
ambos dependem do valor
da entrada a
.
def f(a):
b = a * 2
while np.linalg.norm(b) < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
def f(a):
b = a * 2
while b.norm() < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
def f(a):
b = a * 2
while tf.norm(b) < 1000:
b = b * 2
if tf.reduce_sum(b) > 0:
c = b
else:
c = 100 * b
return c
Vamos computar o gradiente:
a = np.random.normal()
a.attach_grad()
with autograd.record():
d = f(a)
d.backward()
a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()
a = tf.Variable(tf.random.normal(shape=()))
with tf.GradientTape() as t:
d = f(a)
d_grad = t.gradient(d, a)
d_grad
<tf.Tensor: shape=(), dtype=float32, numpy=1024.0>
Agora podemos analisar a função f
definida acima. Observe que é
linear por partes em sua entrada a
. Em outras palavras, para
qualquer a
existe algum escalar constantek
tal que
f (a) = k * a
, onde o valor dek
depende da entrada a
.
Consequentemente, d / a
nos permite verificar se o gradiente está
correto.
a.grad == d / a
array(True)
a.grad == d / a
tensor(True)
d_grad == d / a
<tf.Tensor: shape=(), dtype=bool, numpy=True>
2.5.5. Sumário¶
Frameworks de Deep learning podem automatizar o cálculo de derivadas. Para usá-lo, primeiro anexamos gradientes às variáveis em relação às quais desejamos as derivadas parciais. Em seguida, registramos o cálculo de nosso valor alvo, executamos sua função para retropropagação e acessamos o gradiente resultante.
2.5.6. Exercícios¶
Por que a segunda derivada é muito mais computacionalmente cara de se calcular do que a primeira derivada?
Depois de executar a função de retropropagação, execute-a imediatamente novamente e veja o que acontece.
No exemplo de fluxo de controle onde calculamos a derivada de
d
com respeito aa
, o que aconteceria se mudássemos a variávela
para um vetor ou matriz aleatória. Neste ponto, o resultado do cálculof (a)
não é mais um escalar. O que acontece com o resultado? Como analisamos isso?Redesenhe um exemplo para encontrar o gradiente do Control Flow. Execute e analise o resultado.
Seja \(f (x) = \ sin (x)\). Plote \(f (x)\) e \(\ frac {df (x)} {dx}\), onde o último é calculado sem explorar que \(f '(x) = \ cos (x)\).