Automatic differentiation
Refs:¶
Chain rule¶
Scalar single-variable chain rule¶
Recall the limit definition of derivative of a function,
From the limit definition you can find the value of as
You can use this rule to find the chain rule of finding the chaining of two functions together,
Scalar two-variable chain rule¶
Consider a function of two variables . Find its derivative,
Now should not be expanded in one step but in two steps. First keep as it is, and expand with respect to
and then do the same with ,
We use to get,
Going back to the chain rule,
Scalar valued vector function chain rule¶
Consider two functions , that can be composed together . We want to find the derivative of composition by chain rule.
Recall that the derivative (Jacobian) of is a row vector, $$\frac{\p f(\bfg)}{\p \bfg} =
$$
And the derivative (Jacobian) of is a column vector, $$\frac{\p \bfg(x)}{\p x} =
$$
Note that a vector function is a multi-variate scalar function
We can apply the multi-variate scalar function chain rule,
The derivatives of can be separated from derivatives of as vector multiplication, $$ \frac{\p}{\p x} f(\bfg(x)) =
Hence the chain rule for vector derivatives works out for our definition of vector derivatives,
Note that the order of multiplication matters, specifically
This is a consequence of row-vector convention. If we chose a column-vector convention the result will be completely different.
General chain rule¶
Let the function be and , then the derivative (Jacobian) of their composition is
Computational complexity of Forward vs Reverse mode differentiation¶
Consider three functions, , and chained together for composition . To find the derivative (Jacobian) the composite function, we use chain rule:
Computational complexity of matrix multiplication¶
Let’s say you multiply two matrices and , total number of additions and multiplications (floating point operations) can be calculated by
where are the row-vectors of matrix and are the column vectors of matrix . Then matrix is written as
We note that matrix has elments and each element requires computing dot product of size vectors,
Each dot product requires multiplications and additions. Hence matrix multiplication which has dot products requires (floating point) operations.
Matrix multiplication has a computation complexity of for matrices of size and .
Computational complexity of forward-mode differentiation¶
In forward diff, we compute computaional complexity from input side to the output side.
The first two matrix multiplications are of the size and , resulting in complexity.
The second two matrix multiplications are of the size and , resulting in complexity.
The total computational complexity of forward differentiation is .
For a longer chain of functions of Jacobians of shape with ().
We get a computational complexity that looks like . Note that the size of input is the only common factor for the entire chain.
Computational complexity of reverse-mode diff¶
In reverse-mode diff, we compute computaional complexity from input side to the output side.
The first two matrix multiplications are of the size and , resulting in complexity.
The second two matrix multiplications are of the size and , resulting in complexity.
The total computational complexity of forward differentiation is .
For a longer chain of functions of Jacobians of shape with ().
We get a computational complexity that looks like . Note that the size of output is the only common factor for the entire chain.
Reverse-mode differentiation is called backpropagation¶
Reverse-mode differentiation is called backpropagation in neural networks. It is more popular because most of the times you compute the derivatives of the loss function which is a scalar function with output dimension as only 1. This makes reverse-mode differentiation clearly superior for loss function gradient.
Implementation of forward/reverse mode differentiation in Pytorch¶
Reverse mode¶
Let’s compute the derivatives of
import torch as t
x1 = t.Tensor([2]) # Initialize a tensor
x1.requires_grad_(True) # enable gradient tracking
x2 = t.Tensor([7])
x2.requires_grad_(True)
f = x1 * x2 + x1.sin() # Create computation graph
print("Before backward:", x1.grad, x2.grad) # print df/dx1 and df/dx2
f.backward(t.Tensor([1])) # Intialize backward computation with dg/df = 1
print("After backward:", x1.grad, x2.grad) # print df/dx1 and df/dx2---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[1], line 1
----> 1 import torch as t
4 x1 = t.Tensor([2]) # Initialize a tensor
5 x1.requires_grad_(True) # enable gradient tracking
ModuleNotFoundError: No module named 'torch'Forward mode¶
import torch as t
import torch.autograd.forward_ad as fwAD
x1 = t.Tensor([2]) # Initialize a tensor
x2 = t.Tensor([7]) # Initialize a tensor
with fwAD.dual_level():
x1_pd = fwAD.make_dual(x1, t.Tensor([1])) # Intialize dx1/dz = 1
x2_pd = fwAD.make_dual(x2, t.Tensor([0])) # Intialize dx2/dz = 0
f = x1_pd * x2_pd + x1_pd.sin() # compute the function
dfdx1 = fwAD.unpack_dual(f).tangent
print(dfdx1)
x1_pd = fwAD.make_dual(x1, t.Tensor([0])) # Intialize dx1/dz = 0
x2_pd = fwAD.make_dual(x2, t.Tensor([1])) # Intialize dx2/dz = 1
f = x1_pd * x2_pd + x1_pd.sin() # compute the function
dfdx2 = fwAD.unpack_dual(f).tangent
print(dfdx2)Vector Jacobian product (vjp) for reverse-mode differentiation¶
Typical output of a neural network is a loss function. Loss function is always a scalar. Most neural network libraries implement reverse-mode differentiation only for a scalar output.
Hence, the first Jacobian on the output side of chain rule is a row-vector.
, and .
When you are writing a programmatic derivative function for reverse mode differentation, the function does two things:
Compute the local Jacobian of the function for example .
Left multiply the Jacobian with a row-vector of accumulated derivative so far. For example, .
The template of the function is like this:
def g(arg1, arg2):
# Compute g
return g
def g_vjp(arg1, arg2, dl_dg):
# Compute vector Jacobian product with respect to each oargument
return dl_arg1, dl_arg2If you are given a function , and you want to implement vjp function for it. It is often easier to imagine a sclar loss function whose accumulated gradient is given as an input argument. The function vjp returns the derivative of the loss function with respect to the inputs,
which looks like a vector Jacobian product, but you are free to not compute the Jacobian separately. Sometimes it is computationally harder to compute the jacobian separately then multiply it by the vector.
Jacobian vector product (jvp) for forward-mode differentiation¶
It is also common to implment foward mode differentiation with only a scalar input assumption, say .
Say , and
You can assume to be function of scalar , . Then the chain rule is
You can compute the derivative with respect to one element of at a time by setting that element’s derivative to be 1 and the rest to be zero. For example, if you want to compute the then set
.
For forward pass you typically implement a function called jvp which stands for Jacobian vector product:
The Jacobian is the local derivative. For example
Multiplication of the jacobian with an incoming accumulated gradient which is a column-vector. For example, .
The template of the function is like this:
def g(arg1, arg2):
# Compute g
return g
def g_jvp(arg1, arg2, darg1_dt, darg2_dt):
# Compute Jacobian vector product with respect to t
return dg_dtIf you are given a function , and you want to implement jvp function for it. It is often easier to imagine a sclar input variable whose accumulated gradient are given as an input argument. The function jvp returns the derivative of the output with respect to the scalar input ,
which looks like a Jacobian vector product, but you are free to not compute the Jacobian separately. Sometimes it is computationally harder to compute the jacobian separately then multiply it by the vector.
Implementing numpy backpropagation for various operations¶
# Refs:
# 1. https://github.com/karpathy/micrograd/tree/master/micrograd
# 2. https://github.com/mattjj/autodidact
# 3. https://github.com/mattjj/autodidact/blob/master/autograd/numpy/numpy_vjps.py
from collections import namedtuple
import numpy as np# The unbroadcast function is explained in the end because expaining it now
# does not make narrative sense
def unbroadcast(target, g, axis=0):
"""Remove broadcasted dimensions by summing along them.
When computing gradients of a broadcasted value, this is the right thing to
do when computing the total derivative and accounting for cloning.
"""
while np.ndim(g) > np.ndim(target):
g = g.sum(axis=axis)
for axis, size in enumerate(target.shape):
if size == 1:
g = g.sum(axis=axis, keepdims=True)
if np.iscomplexobj(g) and not np.iscomplex(target):
g = g.real()
return g# namedtuple are just like dictionaries but are faster
Op = namedtuple('Op', ['apply',
'vjp',
'name',
'nargs'])Vector Jacobian Product for addition¶
where
Let be the eventual scalar output. We find and for Vector Jacobian product.
Similarly,
def add_vjp(dldf, a, b):
dlda = unbroadcast(a, dldf)
dldb = unbroadcast(b, dldf)
return dlda, dldb
add = Op(
apply=np.add,
vjp=add_vjp,
name='+',
nargs=2)VJP for element-wise multiplication¶
where
Let be the eventual scalar output. We find and for Vector Jacobian product.
def mul_vjp(dldf, a, b):
dlda = unbroadcast(a, dldf * b)
dldb = unbroadcast(b, dldf * a)
return dlda, dldb
mul = Op(
apply=np.multiply,
vjp=mul_vjp,
name='*',
nargs=2)VJP for matrix-matrix, matrix-vector and vector-vector multiplication¶
Case 1: VJP for vector-vector multiplication¶
where , and
Let be the eventual scalar output. We find and for Vector Jacobian product.
Similarly,
Case 2: VJP for matrix-vector multiplication¶
Let
where , , and
Let be the eventual scalar output. We want to findfind and for Vector Jacobian product.
Let
Define matrix derivative of scalar to be:
Note that
Since is a scalar, it is easier to find its derivative with respect to the matrix .
Let
Then \begin{align}\frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfa_i^\top \bfb =
= \frac{\p l}{\p f_i}\bfb^\top \in \bbR^{1 \times n} \end{align}
Returning to our original quest for
Note that
We can group the terms inside a single transpose.
Which results in
The derivative with respect to is simpler:
Case 3: VJP for matrix-matrix multiplication¶
Let
where , , and
Let be the eventual scalar output. We want to find and for Vector Jacobian product.
Note that a matrix-matrix multiplication can be written in terms horizontal stacking of matrix-vector multiplications. Specifically, write and in terms of their column vectors:
Then for all
From the VJP of matrix-vector multiplication, we can write
and for all
Instead of writing , we can also write , then by chain rule of functions with multiple arguments, we have,
It turns out that some of outer products can be compactly written as matrix-matrix multiplication:
Hence,
The vector Jacobian product for can be found by applying the above rule to where and .
Take transpose of both sides
Put back, and ,
def matmul_vjp(dldF, A, B):
G = dldF
if G.ndim == 0:
# Case 1: vector-vector multiplication
assert A.ndim == 1 and B.ndim == 1
dldA = G*B
dldB = G*A
return (unbroadcast(A, dldA),
unbroadcast(B, dldB))
assert not (A.ndim == 1 and B.ndim == 1)
# 1. If both arguments are 2-D they are multiplied like conventional matrices.
# 2. If either argument is N-D, N > 2, it is treated as a stack of matrices
# residing in the last two indexes and broadcast accordingly.
if A.ndim >= 2 and B.ndim >= 2:
dldA = G @ B.swapaxes(-2, -1)
dldB = A.swapaxes(-2, -1) @ G
if A.ndim == 1:
# 3. If the first argument is 1-D, it is promoted to a matrix by prepending a
# 1 to its dimensions. After matrix multiplication the prepended 1 is removed.
A_ = A[np.newaxis, :]
G_ = G[np.newaxis, :]
dldA = G @ B.swapaxes(-2, -1)
dldB = A_.swapaxes(-2, -1) @ G_ # outer product
elif B.ndim == 1:
# 4. If the second argument is 1-D, it is promoted to a matrix by appending
# a 1 to its dimensions. After matrix multiplication the appended 1 is removed.
B_ = B[:, np.newaxis]
G_ = G[:, np.newaxis]
dldA = G_ @ B_.swapaxes(-2, -1) # outer product
dldB = A.swapaxes(-2, -1) @ G
return (unbroadcast(A, dldA),
unbroadcast(B, dldB))
matmul = Op(
apply=np.matmul,
vjp=matmul_vjp,
name='@',
nargs=2)def exp_vjp(dldf, x):
dldx = dldf * np.exp(x)
return (unbroadcast(x, dldx),)
exp = Op(
apply=np.exp,
vjp=exp_vjp,
name='exp',
nargs=1)def log_vjp(dldf, x):
dldx = dldf / x
return (unbroadcast(x, dldx),)
log = Op(
apply=np.log,
vjp=log_vjp,
name='log',
nargs=1)def sum_vjp(dldf, x, axis=None, **kwargs):
if axis is not None:
dldx = np.expand_dims(dldf, axis=axis) * np.ones_like(x)
else:
dldx = dldf * np.ones_like(x)
return (unbroadcast(x, dldx),)
sum_ = Op(
apply=np.sum,
vjp=sum_vjp,
name='sum',
nargs=1)def maximum_vjp(dldf, a, b):
dlda = dldf * np.where(a > b, 1, 0)
dldb = dldf * np.where(a > b, 0, 1)
return unbroadcast(a, dlda), unbroadcast(b, dldb)
maximum = Op(
apply=np.maximum,
vjp=maximum_vjp,
name='maximum',
nargs=2)NoOp = Op(apply=None, name='', vjp=None, nargs=0)
class Tensor:
__array_priority__ = 100
def __init__(self, value, grad=None, parents=(), op=NoOp, kwargs={}, requires_grad=True):
self.value = np.asarray(value)
self.grad = grad
self.parents = parents
self.op = op
self.kwargs = kwargs
self.requires_grad = requires_grad
shape = property(lambda self: self.value.shape)
ndim = property(lambda self: self.value.ndim)
size = property(lambda self: self.value.size)
dtype = property(lambda self: self.value.dtype)
def __add__(self, other):
cls = type(self)
other = other if isinstance(other, cls) else cls(other)
return cls(add.apply(self.value, other.value),
parents=(self, other),
op=add)
__radd__ = __add__
def __mul__(self, other):
cls = type(self)
other = other if isinstance(other, cls) else cls(other)
return cls(mul.apply(self.value, other.value),
parents=(self, other),
op=mul)
__rmul__ = __mul__
def __matmul__(self, other):
cls = type(self)
other = other if isinstance(other, cls) else cls(other)
return cls(matmul.apply(self.value, other.value),
parents=(self, other),
op=matmul)
def exp(self):
cls = type(self)
return cls(exp.apply(self.value),
parents=(self,),
op=exp)
def log(self):
cls = type(self)
return cls(log.apply(self.value),
parents=(self, ),
op=log)
def __pow__(self, other):
cls = type(self)
other = other if isinstance(other, cls) else cls(other)
return (self.log() * other).exp()
def __div__(self, other):
return self * (other**(-1))
def __sub__(self, other):
return self + (other * (-1))
def __neg__(self):
return self*(-1)
def sum(self, axis=None):
cls = type(self)
return cls(sum_.apply(self.value, axis=axis),
parents=(self,),
op=sum_,
kwargs=dict(axis=axis))
def maximum(self, other):
cls = type(self)
other = other if isinstance(other, cls) else cls(other)
return cls(maximum.apply(self.value, other.value),
parents=(self, other),
op=maximum)
def __repr__(self):
cls = type(self)
return f"{cls.__name__}(value={self.value}, op={self.op.name})" if self.parents else f"{cls.__name__}(value={self.value})"
#return f"{cls.__name__}(value={self.value}, parents={self.parents}, op={self.op}"
def backward(self, grad):
self.grad = grad if self.grad is None else (self.grad+grad)
if self.requires_grad and self.parents:
p_vals = [p.value for p in self.parents]
assert len(p_vals) == self.op.nargs
p_grads = self.op.vjp(grad, *p_vals, **self.kwargs)
for p, g in zip(self.parents, p_grads):
p.backward(g)Tensor([1, 2]).sum()
try:
from graphviz import Digraph
except ImportError as e:
import subprocess
subprocess.call("pip install --user graphviz".split())
def trace(root):
nodes, edges = set(), set()
def build(v):
if v not in nodes:
nodes.add(v)
for p in v.parents:
edges.add((p, v))
build(p)
build(root)
return nodes, edges
def draw_dot(root, format='svg', rankdir='LR'):
"""
format: png | svg | ...
rankdir: TB (top to bottom graph) | LR (left to right)
"""
assert rankdir in ['LR', 'TB']
nodes, edges = trace(root)
dot = Digraph(format=format, graph_attr={'rankdir': rankdir}) #, node_attr={'rankdir': 'TB'})
for n in nodes:
vstr = np.array2string(np.asarray(n.value), precision=4)
gradstr= np.array2string(np.asarray(n.grad), precision=4)
dot.node(name=str(id(n)), label = f"{{v={vstr} | g={gradstr}}}", shape='record')
if n.parents:
dot.node(name=str(id(n)) + n.op.name, label=n.op.name)
dot.edge(str(id(n)) + n.op.name, str(id(n)))
for n1, n2 in edges:
dot.edge(str(id(n1)), str(id(n2)) + n2.op.name)
return dot# a very simple example
x = Tensor([[1.0, 2.0],
[2.0, -1.0]])
y = (x * 2 - 1).maximum(0).sum(axis=-1)
draw_dot(y)y.backward(np.ones_like(y))
draw_dot(y)def f_np(x):
b = [1, 0]
return (x @ b)*np.exp((-x*x).sum(axis=-1))
def f_T(x):
b = [1, 0]
return (x @ b)*(-x*x).sum(axis=-1).exp()
def grad_f(x):
xT = Tensor(x)
y = f_T(xT)
y.backward(np.ones_like(y.value))
return xT.gradxT = Tensor([1, 2])
out = f_T(xT)
out.backward(1)
print(xT.grad)
draw_dot(out)def numerical_jacobian(f, x, h=1e-10):
n = x.shape[-1]
eye = np.eye(n)
x_plus_dx = x + h * eye # n x n
num_jac = (f(x_plus_dx) - f(x)) / h # limit definition of the formula # n x m
if num_jac.ndim >= 2:
num_jac = num_jac.swapaxes(-1, -2) # m x n
return num_jac
# Compare our grad_f with numerical gradient
def check_numerical_jacobian(f, jac_f, nD=2, **kwargs):
x = np.random.rand(nD)
print(x)
num_jac = numerical_jacobian(f, x, **kwargs)
print(num_jac)
print(jac_f(x))
return np.allclose(num_jac, jac_f(x), atol=1e-06, rtol=1e-4) # m x n
## Throw error if grad_f is wrong
assert check_numerical_jacobian(f_np, grad_f)Customizing backward step (vector-Jacobian product) in PyTorch¶
Consider the derivative of Sigmoid activation function
The above derivative is computed by chain rule. However, there is much simpler expression that can avoid unnecessary computations,
# https://pytorch.org/tutorials/beginner/basics/autogradqs_tutorial.html
# https://pytorch.org/docs/stable/notes/autograd.html
import torch as t
class SigmoidCustom(t.autograd.Function):
@staticmethod
def forward(ctx, x):
# Because we are saving one of the inputs use `save_for_backward`
# Save non-tensors and non-inputs/non-outputs directly on ctx
sigmoid_x = 1/(1+(-x).exp())
ctx.save_for_backward(x, sigmoid_x)
return sigmoid_x
@staticmethod
def backward(ctx, grad_out):
# A function support double backward automatically if autograd
# is able to record the computations performed in backward
x, sigmoid_x = ctx.saved_tensors
jacobian = sigmoid_x * (1-sigmoid_x)
return grad_out * jacobian # vector jacobian product
def sigmoid_c(x):
return SigmoidCustom.apply(x)
%%timeit
x = t.tensor([100.], requires_grad=True)
def s(x):
return 1/(1+(-x).exp())
out = s(s(s(x)))
out.backward(t.tensor([1.]))
x.grad%%timeit
x = t.tensor([100.], requires_grad=True)
out = sigmoid_c(sigmoid_c(sigmoid_c(x)))
out.backward(t.tensor([1.]))
x.gradWhats the deal with the unbroadcast function¶
To understand unbroadcast function, first it is important to understand numpy broadcasting. Once you understand that then we can proceed to understand unbroadcast function
Once you understand broadcasting, we can appreciate the unbroadcast function. Suppose you have A.shape = (100, 1) and B.shape = (1, 100) and you add them together
F = A + B
Then F.shape = (100, 100). Note that as if we created copies of A and B along the relevant dimensions.
Let’s think about backpropagation now. The incoming grad will have a dimension of dl__dF.shape = (100, 100) but the returned dl__dA must have shape dl__dA.shape = (100, 1) and dl__dB.shape = (1, 100).
From the chain rule of addition we only know that dF_{ij}/dA_{ij} = 1 and dF_{ij}/dB_{ij} = 1. What happens when you create copies of a variable and they contribute to a function? Let’s consider a very simple function, of two variables:
What if the and were copies of the same variable?
We can use the chain rule to find out the result
If we know that and are just copies of , then and .
In other words, if input variables are copied multiple times and then the function is computed using the copies, then the derivative is the sum of the derivatives with respect to the copies.
That’s what the unbroadcast function does. Because broadcasting creates the copies of the inputs along the mismatching axis, unbroadcasting sums the derivatives along the mismatching axis.
Coming back to the F = A + B example. We get dl__dF.shape = (100, 100). A.shape = (100, 1) was copied 100 times along the axis=1 (2nd dimension). So dl__dA = dl__dF.sum(axis=1) . Same for the B as well. dl__dB = dl__dF.sum(axis=0).
That’s what unbroadcast function does but in a more generalizable way