Skip to article content

Neural Networks

Back to Article
Automatic differentiation
Download Notebook

Automatic differentiation

Chain rule

Scalar single-variable chain rule

Recall the limit definition of derivative of a function,

g(x)=limh0g(x+h)g(x)h.g'(x) = \lim_{h \to 0} \frac{g(x+h) - g(x)}{h}.

From the limit definition you can find the value of g(x+h)g(x+h) as

limh0g(x+h)=limh0g(x)+g(x)h.\lim_{h \to 0} g(x+h) = \lim_{h \to 0} g(x) + g'(x)h.

You can use this rule to find the chain rule of finding the chaining of two functions together,

f(g(x))x=limh0f(g(x+h))f(g(x))h=limh0f(g(x)+g(x)h)f(g(x))h=limh0f(g(x))+f(g(x))g(x)hf(g(x))h=f(g(x))g(x)\begin{align} \frac{\p f(g(x))}{\p x} &= \lim_{h \to 0} \frac{f(g(x + h)) - f(g(x))}{h}\\ &=\lim_{h \to 0} \frac{f(g(x) + g'(x) h) - f(g(x))}{h}\\ &=\lim_{h \to 0} \frac{f(g(x)) + f'(g(x))g'(x)h - f(g(x))}{h}\\ &=f'(g(x))g'(x) \end{align}

Scalar two-variable chain rule

Consider a function of two variables f(u(x),v(x))f(u(x),v(x)). Find its derivative,

f(u(x),v(x))x=limh0f(u(x+h),v(x+h))f(u(x),v(x))h=limh0f(u(x)+u(x)h,v(x)+v(x)h)f(u(x),v(x))h\begin{align} \frac{\p f(u(x), v(x))}{\p x} &= \lim_{h \to 0} \frac{f(u(x + h), v(x+h)) - f(u(x), v(x))}{h}\\ &=\lim_{h \to 0} \frac{f(u(x) + u'(x) h, v(x) + v'(x)h) - f(u(x), v(x))}{h} \end{align}

Now f(u+δu,v+δv)f(u + \delta u, v + \delta v) should not be expanded in one step but in two steps. First keep v+δvv + \delta v as it is, and expand with respect to u+δuu + \delta u

limδv,δu0f(u+δu,v+δv)=limδv,δu0f(u,v+δv)+fu(u,v+δv)δu,\lim_{\delta v, \delta u \to 0} f(u+\delta u, v+\delta v) = \lim_{\delta v, \delta u \to 0} f(u, v+\delta v) + f'_u(u, v+\delta v) \delta u,

and then do the same with v+δvv+\delta v,

limδv,δu0f(u+δu,v+δv)=limδv,δu0f(u,v)+fv(u,v)δv+fu(u,v+δv)δu,\lim_{\delta v, \delta u \to 0} f(u+\delta u, v+\delta v) = \lim_{\delta v, \delta u \to 0} f(u, v) + f'_v(u, v) \delta v + f'_u(u, v+\delta v) \delta u,

We use limδv,δu0fu(u,v+δv)δu=limδv,δu0fu(u,v)δu+fuv(u,v)(δv)(δu)=limδu0fu(u,v)δu\lim_{\delta v,\delta u \to 0} f'_u(u, v+\delta v)\delta u = \lim_{\delta v,\delta u \to 0} f'_u(u, v)\delta u + f''_{uv}(u, v)(\delta v)(\delta u) = \lim_{\delta u \to 0} f'_u(u, v)\delta u to get,

limδv,δu0f(u+δu,v+δv)=limδv,δu0f(u,v)+fv(u,v)δv+fu(u,v)δu.\lim_{\delta v, \delta u \to 0} f(u+\delta u, v+\delta v) = \lim_{\delta v, \delta u \to 0} f(u, v) + f'_v(u, v) \delta v + f'_u(u, v) \delta u.

Going back to the chain rule,

f(u(x),v(x))x=limh0f(u(x)+u(x)h,v(x)+v(x)h)f(u(x),v(x))h=limh0f(u(x),v(x))+fv(u(x),v(x))v(x)h+fu(u(x),v(x))u(x)hf(u(x),v(x))h=limh0fv(u(x),v(x))v(x)h+fu(u(x),v(x))u(x)hh=fv(u(x),v(x))v(x)+fu(u(x),v(x))u(x)\begin{align} \frac{\p f(u(x), v(x))}{\p x} &= \lim_{h \to 0} \frac{f(u(x) + u'(x) h, v(x) + v'(x)h) - f(u(x), v(x))}{h} \\ &= \lim_{h \to 0} \frac{f(u(x), v(x)) + f'_v(u(x), v(x)) v'(x)h + f'_u(u(x), v(x)) u'(x) h - f(u(x), v(x))}{h} \\ &= \lim_{h \to 0} \frac{f'_v(u(x), v(x)) v'(x)h + f'_u(u(x), v(x)) u'(x) h }{h} \\ &= f'_v(u(x), v(x)) v'(x) + f'_u(u(x), v(x)) u'(x) \\ \end{align}

Scalar valued vector function chain rule

Consider two functions f(g):RmRf(\bfg): \bbR^m \to \bbR, g(x):RRm\bfg(x): \bbR \to \bbR^m that can be composed together f(g(x))f(\bfg(x)). We want to find the derivative of composition fgf \circ g by chain rule.

Recall that the derivative (Jacobian) of f(g)f(\bfg) is a row vector, $$\frac{\p f(\bfg)}{\p \bfg} =

[fg1fg2fgm]\begin{bmatrix} \frac{\p f}{\p g_1} & \frac{\p f}{\p g_2} & \dots & \frac{\p f}{\p g_m} \end{bmatrix}

$$

And the derivative (Jacobian) of g(x)\bfg(x) is a column vector, $$\frac{\p \bfg(x)}{\p x} =

[g1xg2xgmx]\begin{bmatrix} \frac{\p g_1}{\p x} \\ \frac{\p g_2}{\p x}\\ \vdots\\ \frac{\p g_m}{\p x} \end{bmatrix}

$$

Note that a vector function is a multi-variate scalar function

f(g(x))=f(g1(x),g2(x),,gm(x)).f(\bfg(x)) = f(g_1(x), g_2(x), \dots, g_m(x)).

We can apply the multi-variate scalar function chain rule,

xf(g(x))=fg1(g1(x),,gm(x))g1(x)++fgm(g1(x),,gm(x))gm(x)=fg1(g(x))g1(x)++fgm(g(x))gm(x).\begin{align} \frac{\p}{\p x} f(\bfg(x)) = f'_{g_1}(g_1(x), \dots, g_m(x)) g'_1(x) + \dots + f'_{g_m}(g_1(x), \dots, g_m(x)) g'_m(x)\\ = f'_{g_1}(\bfg(x)) g'_1(x) + \dots + f'_{g_m}(\bfg(x)) g'_m(x) .\end{align}

The derivatives of g\bfg can be separated from derivatives of ff as vector multiplication, $$ \frac{\p}{\p x} f(\bfg(x)) =

[fg1(g(x))fgm(g(x))]\begin{bmatrix} f'_{g_1}(\bfg(x)) & \dots & f'_{g_m}(\bfg(x)) \end{bmatrix}
[g1(x)gm(x)]\begin{bmatrix} g'_1(x) \\ \vdots \\ g'_m(x) \end{bmatrix}

Hence the chain rule for vector derivatives works out for our definition of vector derivatives,

xf(g(x))=f(g(x))gg(x)x.\frac{\p }{\p \bfx} f(\bfg(x))= \frac{\p f(\bfg(x))}{\p \bfg}\frac{\p \bfg(x)}{\p x}.

Note that the order of multiplication matters, specifically

xf(g(x))g(x)xf(g(x))g.\frac{\p }{\p \bfx} f(\bfg(x))\ne \frac{\p \bfg(x)}{\p x} \frac{\p f(\bfg(x))}{\p \bfg}.

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 f(g):RmRn\bff(\bfg): \bbR^m \to \bbR^n and g(x):RpRm\bfg(\bfx): \bbR^p \to \bbR^m, then the derivative (Jacobian) of their composition fg\bff \circ \bfg is

xf(g(x))=f(g(x))gg(x)x\frac{\p }{\p \bfx} \bff(\bfg(\bfx)) = \frac{\p \bff(\bfg(\bfx))}{\p \bfg}\frac{\p \bfg(\bfx)}{\p \bfx}

Computational complexity of Forward vs Reverse mode differentiation

Consider three functions, h(x):RmRn\bfh(\bfx): \bbR^m \to \bbR^n, g(h):RnRp\bfg(\bfh): \bbR^n \to \bbR^p and f(g):RpRq\bff(\bfg): \bbR^p \to \bbR^q chained together for composition f(g(h(x))):RmRq\bff(\bfg(\bfh(\bfx))) : \bbR^m \to \bbR^q. To find the derivative (Jacobian) the composite function, we use chain rule:

xf(g(h(x)))=fgghhx\frac{\p }{\p \bfx} \bff(\bfg(\bfh(\bfx))) = \frac{\p \bff}{\p \bfg} \frac{\p \bfg}{\p \bfh} \frac{\p \bfh}{\p \bfx}
Computational complexity of matrix multiplication

Let’s say you multiply two matrices ARm×nA \in \bbR^{m \times n} and BRn×pB \in \bbR^{n \times p}, total number of additions and multiplications (floating point operations) can be calculated by

C=AB=[a1a2am][b1b2bp],\begin{align} C = A B = \begin{bmatrix} \bfa_1^\top\\ \bfa_2^\top\\ \vdots\\ \bfa_m^\top \end{bmatrix} \begin{bmatrix} \bfb_1 & \bfb_2 & \dots & \bfb_p \end{bmatrix}, \end{align}

where ai\bfa_i^\top are the row-vectors of matrix AA and bi\bfb_i are the column vectors of matrix BB. Then matrix CC is written as

C=[a1b1a1b2a1bpa2b1a2b2a2bpamb1amb2ambp]\begin{align} C = \begin{bmatrix} \bfa_1^\top \bfb_1 & \bfa_1^\top \bfb_2 & \dots & \bfa_1^\top \bfb_p \\ \bfa_2^\top \bfb_1 & \bfa_2^\top \bfb_2 & \dots & \bfa_2^\top \bfb_p \\ \vdots & \vdots & \ddots & \vdots \\ \bfa_m^\top \bfb_1 & \bfa_m^\top \bfb_2 & \dots & \bfa_m^\top \bfb_p \\ \end{bmatrix} \end{align}

We note that CC matrix has pmpm elments and each element requires computing dot product of size nn vectors,

aibj=ai1bj1+ai2bj2++ainbin.\bfa_i^\top \bfb_j = a_{i1} b_{j1} + a_{i2} b_{j2} + \dots + a_{in} b_{in}.

Each dot product requires nn multiplications and n1n-1 additions. Hence matrix multiplication which has pmpm dot products requires pm(n+n1)pm(n + n -1) (floating point) operations.

Matrix multiplication has a computation complexity of O(pmn)O(pmn) for matrices of size m×nm \times n and n×pn \times p.

Computational complexity of forward-mode differentiation

In forward diff, we compute computaional complexity from input side to the output side.

xf(g(h(x)))=(fg(ghhx))\frac{\p }{\p \bfx} \bff(\bfg(\bfh(\bfx))) = \left(\frac{\p \bff}{\p \bfg} \left( \frac{\p \bfg}{\p \bfh} \frac{\p \bfh}{\p \bfx} \right)\right)

The first two matrix multiplications Xp×n=(ghhx)X_{p \times n} = \left( \frac{\p \bfg}{\p \bfh} \frac{\p \bfh}{\p \bfx} \right) are of the size p×mp \times m and m×nm \times n, resulting in O(pmn)O(pmn) complexity.

The second two matrix multiplications (fgXp×n)\left(\frac{\p \bff}{\p \bfg} X_{p \times n} \right) are of the size q×pq \times p and p×np \times n, resulting in O(qpn)O(qpn) complexity.

The total computational complexity of forward differentiation is O(qpn+pmn)=O((qp+pm)n)O(qpn + pmn) = O((qp+pm)n).

For a longer chain of functions of Jacobians of shape qi×piq_i \times p_i with (pi=qi1p_i = q_{i-1}).

xfn(f2(f1(x)))=fnfn1qn×pnf2f1q1×p1f1xq0×p0\frac{\p }{\p \bfx} \bff_n(\dots \bff_2(\bff_1(\bfx))) = \frac{\p \bff_n}{\p \bff_{n-1}}_{q_n \times p_{n}} \dots \frac{\p \bff_2}{\p \bff_1}_{q_1 \times p_1} \frac{\p \bff_1}{\p \bfx}_{q_0 \times p_0}

We get a computational complexity that looks like O((i=1nqipi)p0)O( (\sum_{i=1}^n q_i p_i) p_0). Note that the size of input p0p_0 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.

xf(g(h(x)))=((fggh)hx)\frac{\p }{\p \bfx} \bff(\bfg(\bfh(\bfx))) = \left(\left(\frac{\p \bff}{\p \bfg} \frac{\p \bfg}{\p \bfh}\right) \frac{\p \bfh}{\p \bfx} \right)

The first two matrix multiplications Xq×p=(fggh)X_{q \times p} = \left( \frac{\p \bff}{\p \bfg} \frac{\p \bfg}{\p \bfh} \right) are of the size q×pq \times p and p×mp \times m, resulting in O(qpm)O(qpm) complexity.

The second two matrix multiplications (Xq×phx)\left(X_{q \times p}\frac{\p \bfh}{\p \bfx} \right) are of the size q×pq \times p and p×np \times n, resulting in O(qpn)O(qpn) complexity.

The total computational complexity of forward differentiation is O(qpm+qmn)=O(q(pm+mn))O(qpm + qmn) = O(q(pm+mn)).

For a longer chain of functions of Jacobians of shape qi×piq_i \times p_i with (pi=qi1p_i = q_{i-1}).

xfn(f2(f1(x)))=fnfn1qn×pnf2f1q1×p1f1xq0×p0\frac{\p }{\p \bfx} \bff_n(\dots \bff_2(\bff_1(\bfx))) = \frac{\p \bff_n}{\p \bff_{n-1}}_{q_n \times p_{n}} \dots \frac{\p \bff_2}{\p \bff_1}_{q_1 \times p_1} \frac{\p \bff_1}{\p \bfx}_{q_0 \times p_0}

We get a computational complexity that looks like O(qn(i=0n1qipi))O( q_n (\sum_{i=0}^{n-1} q_i p_i)). Note that the size of output qnq_n 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

f(x1,x2)=x1x2+sin(x1)f(x_1, x_2) = x_1 x_2 + sin(x_1)
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.

xl(f(g(x))=lffggx,\frac{\p }{\p \bfx} l(\bff(\bfg(\bfx)) = \frac{\p l}{\p \bff}\frac{\p \bff}{\p \bfg}\frac{\p \bfg}{\p \bfx},

g(x):RnRp\bfg(\bfx): \bbR^n \to \bbR^p, f(g):RpRq\bff(\bfg): \bbR^p \to \bbR^q and l(f):RqRl(\bff): \bbR^q \to \bbR.

When you are writing a programmatic derivative function for reverse mode differentation, the function does two things:

  1. Compute the local Jacobian of the function for example fg\frac{\p \bff}{\p \bfg}.

  2. Left multiply the Jacobian with a row-vector of accumulated derivative so far. For example, lffg\frac{\p l}{\p \bff} \frac{\p \bff}{\p \bfg}.

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_arg2

If you are given a function g(x)\bfg(\bfx), and you want to implement vjp function for it. It is often easier to imagine a sclar loss function l(g(x))l(\bfg(\bfx)) whose accumulated gradient lg\frac{\p l}{\p \bfg} is given as an input argument. The function vjp returns the derivative of the loss function with respect to the inputs,

xl(g(x))=lggx,\frac{\p}{\p \bfx} l(\bfg(\bfx)) = \frac{\p l}{\p \bfg} \frac{\p \bfg}{\p \bfx},

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 tt.

Say h(x):RmRn\bfh(\bfx): \bbR^m \to \bbR^n, g(h):RnRp\bfg(\bfh): \bbR^n \to \bbR^p and f(g):RpRq\bff(\bfg): \bbR^p \to \bbR^q

xf(g(h(x)))=fgghhx\frac{\p }{\p \bfx} \bff(\bfg(\bfh(\bfx))) = \frac{\p \bff}{\p \bfg} \frac{\p \bfg}{\p \bfh} \frac{\p \bfh}{\p \bfx}

You can assume x\bfx to be function of scalar tRt \in \bbR, x(t)\bfx(t). Then the chain rule is

tf(g(h(x(t))))=fgghhxxt\frac{\p }{\p t} \bff(\bfg(\bfh(\bfx(t)))) = \frac{\p \bff}{\p \bfg} \frac{\p \bfg}{\p \bfh} \frac{\p \bfh}{\p \bfx} \frac{\p \bfx}{\p t}

You can compute the derivative with respect to one element of x\bfx 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 fx2\frac{\p \bff}{\p x_2} then set

xt=[0100]\frac{\p \bfx}{\p t} = \begin{bmatrix} 0\\ 1\\ 0\\ \vdots\\ 0 \end{bmatrix}

.

For forward pass you typically implement a function called jvp which stands for Jacobian vector product:

  1. The Jacobian is the local derivative. For example hx\frac{\p \bfh}{\p \bfx}

  2. Multiplication of the jacobian with an incoming accumulated gradient which is a column-vector. For example, xt\frac{\p \bfx}{\p t}.

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_dt

If you are given a function g(x)\bfg(\bfx), and you want to implement jvp function for it. It is often easier to imagine a sclar input variable g(x(t)))\bfg(\bfx(t))) whose accumulated gradient xt\frac{\p \bfx}{\p t} are given as an input argument. The function jvp returns the derivative of the output with respect to the scalar input tt,

tg(x(t))=gxxt,\frac{\p}{\p t} \bfg(\bfx(t)) = \frac{\p \bfg}{\p \bfx}\frac{\p \bfx}{\p t},

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

f(a,b)=a+b\bff(\bfa, \bfb) = \bfa + \bfb

where a,b,fRn\bfa, \bfb, \bff \in \bbR^n

Let l(f(a,b))Rl(\bff(\bfa, \bfb)) \in \bbR be the eventual scalar output. We find la\frac{\p l}{\p \bfa} and lb\frac{\p l}{\p \bfb} for Vector Jacobian product.

al(f(a,b))=lfa(a+b)=lf(In×n+0n×n)=lf\frac{\p }{\p \bfa} l(\bff(\bfa, \bfb)) = \frac{\p l}{\p \bff}\frac{\p }{\p \bfa}(\bfa + \bfb) = \frac{\p l}{\p \bff} (\bfI_{n \times n} + 0_{n \times n}) = \frac{\p l}{\p \bff}

Similarly,

bl(f(a,b))=lf\frac{\p }{\p \bfb} l(\bff(\bfa, \bfb)) = \frac{\p l}{\p \bff}
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

f(α,β)=αβf(\alpha, \beta) = \alpha \beta

where α,β,fR\alpha, \beta, f \in \bbR

Let l(f(α,β))Rl(f(\alpha, \beta)) \in \bbR be the eventual scalar output. We find lα\frac{\p l}{\p \alpha} and lβ\frac{\p l}{\p \beta} for Vector Jacobian product.

αl(f(α,β))=lfα(αβ)=lfβ\frac{\p }{\p \alpha} l(f(\alpha, \beta)) = \frac{\p l}{\p f}\frac{\p }{\p \alpha}(\alpha \beta) = \frac{\p l}{\p f} \beta
βl(f(α,β))=lfβ(αβ)=lfα\frac{\p }{\p \beta} l(f(\alpha, \beta)) = \frac{\p l}{\p f}\frac{\p }{\p \beta}(\alpha \beta) = \frac{\p l}{\p f} \alpha
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

f(a,b)=abf(\bfa, \bfb) = \bfa^\top \bfb

where fRf \in \bbR, and b,aRn\bfb, \bfa \in \bbR^n

Let l(f(a,b))Rl(f(\bfa, \bfb)) \in \bbR be the eventual scalar output. We find la\frac{\p l}{\p \bfa} and lb\frac{\p l}{\p \bfb} for Vector Jacobian product.

al(f(a,b))=lfa(ab)=lfb\frac{\p }{\p \bfa} l(f(\bfa, \bfb)) = \frac{\p l}{\p f}\frac{\p }{\p \bfa}(\bfa^\top \bfb) = \frac{\p l}{\p f} \bfb^\top

Similarly,

bl(f(a,b))=lfa\frac{\p }{\p \bfb} l(f(\bfa, \bfb)) = \frac{\p l}{\p f}\bfa^\top

Case 2: VJP for matrix-vector multiplication

Let

f(A,b)=Ab\bff(\bfA, \bfb) = \bfA \bfb

where fRm\bff \in \bbR^m, bRn\bfb \in \bbR^n, and ARm×n\bfA \in \bbR^{m \times n}

Let l(f(A,b))Rl(\bff(\bfA, \bfb)) \in \bbR be the eventual scalar output. We want to findfind lA\frac{\p l}{\p \bfA} and lb\frac{\p l}{\p \bfb} for Vector Jacobian product.

Let

A=[a11a12a1na21a22a2nam1am2amn]=[a1a2am]\begin{align} \bfA = \begin{bmatrix} a_{11} & a_{12} & \dots & a_{1n}\\ a_{21} & a_{22} & \dots & a_{2n}\\ \vdots & \vdots & \ddots & \vdots \\ a_{m1} & a_{m2} & \dots & a_{mn} \end{bmatrix} = \begin{bmatrix} \bfa_1^\top\\ \bfa_2^\top\\ \vdots\\ \bfa_m^\top \end{bmatrix}\end{align}

Define matrix derivative of scalar to be:

lA=[la11la12la1nla21la22la2nlam1lam2lamn]=[la1la2lam]\begin{align} \frac{\p l}{\p \bfA} = \begin{bmatrix} \frac{\p l}{\p a_{11}} & \frac{\p l}{\p a_{12}} & \dots & \frac{\p l}{\p a_{1n}}\\ \frac{\p l}{\p a_{21}} & \frac{\p l}{\p a_{22}} & \dots & \frac{\p l}{\p a_{2n}}\\ \vdots & \vdots & \ddots & \vdots \\ \frac{\p l}{\p a_{m1}} & \frac{\p l}{\p a_{m2}} & \dots & \frac{\p l}{\p a_{mn}} \end{bmatrix} = \begin{bmatrix} \frac{\p l}{\p \bfa_1}\\ \frac{\p l}{\p \bfa_2}\\ \vdots\\ \frac{\p l}{\p \bfa_m} \end{bmatrix} \end{align}
Al(f(a,b))=lfA(Ab)\begin{align} \frac{\p }{\p \bfA} l(\bff(\bfa, \bfb)) = \frac{\p l}{\p \bff} \frac{\p }{\p \bfA} (\bfA\bfb) \end{align}

Note that

Ab=[a1a2am]b=[a1ba2bamb]\begin{align} \bfA\bfb = \begin{bmatrix} \bfa_1^\top\\ \bfa_2^\top\\ \vdots\\ \bfa_m^\top \end{bmatrix}\bfb = \begin{bmatrix} \bfa_1^\top\bfb\\ \bfa_2^\top\bfb\\ \vdots\\ \bfa_m^\top\bfb \end{bmatrix} \end{align}

Since aib\bfa_i^\top \bfb is a scalar, it is easier to find its derivative with respect to the matrix A\bfA.

Aaib=[aiba1aiba2aibaiaibam]=[0n0nb0n]Rm×n\begin{align}\frac{\p }{\p \bfA}\bfa_i^\top \bfb = \begin{bmatrix} \frac{\p \bfa_i^\top \bfb }{\p \bfa_1}\\ \frac{\p \bfa_i^\top \bfb }{\p \bfa_2}\\ \vdots\\ \frac{\p \bfa_i^\top \bfb }{\p \bfa_i}\\ \vdots\\ \frac{\p \bfa_i^\top \bfb }{\p \bfa_m} \end{bmatrix} = \begin{bmatrix} \bfzero^\top_{n}\\ \bfzero^\top_{n}\\ \vdots\\ \bfb^\top\\ \vdots\\ \bfzero^\top_{n} \end{bmatrix} \in \bbR^{m \times n} \end{align}

Let

lf=[lf1lf2lfm]\begin{align}\frac{\p l}{\p \bff} = \begin{bmatrix} \frac{\p l}{\p f_1} & \frac{\p l}{\p f_2} & \dots & \frac{\p l}{\p f_m} \end{bmatrix}\end{align}

Then \begin{align}\frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfa_i^\top \bfb =

[lf1lf2lfm]\begin{bmatrix} \frac{\p l}{\p f_1} & \frac{\p l}{\p f_2} & \dots & \frac{\p l}{\p f_m} \end{bmatrix}
[0n0nb0n]\begin{bmatrix} \bfzero^\top_{n}\\ \bfzero^\top_{n}\\ \vdots\\ \bfb^\top\\ \vdots\\ \bfzero^\top_{n} \end{bmatrix}

= \frac{\p l}{\p f_i}\bfb^\top \in \bbR^{1 \times n} \end{align}

Returning to our original quest for

Al(f(A,b))=lfAAb=lfA[a1ba2bamb]=[lfAa1blfAa2blfAamb]=[lf1blf2blfmb]\begin{align} \frac{\p }{\p \bfA} l(\bff(\bfA, \bfb)) = \frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfA \bfb = \frac{\p l}{\p \bff}\frac{\p }{\p \bfA} \begin{bmatrix} \bfa_1^\top\bfb\\ \bfa_2^\top\bfb\\ \vdots\\ \bfa_m^\top\bfb \end{bmatrix} = \begin{bmatrix} \frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfa_1^\top\bfb\\ \frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfa_2^\top\bfb\\ \vdots\\ \frac{\p l}{\p \bff}\frac{\p }{\p \bfA}\bfa_m^\top\bfb \end{bmatrix} = \begin{bmatrix} \frac{\p l}{\p f_1}\bfb^\top\\ \frac{\p l}{\p f_2}\bfb^\top\\ \vdots\\ \frac{\p l}{\p f_m}\bfb^\top \end{bmatrix} \end{align}

Note that

[lf1blf2blfmb]=[lf1lf2lfm]b=(lf)b\begin{align} \begin{bmatrix} \frac{\p l}{\p f_1}\bfb^\top\\ \frac{\p l}{\p f_2}\bfb^\top\\ \vdots\\ \frac{\p l}{\p f_m}\bfb^\top \end{bmatrix} = \begin{bmatrix} \frac{\p l}{\p f_1} \\ \frac{\p l}{\p f_2} \\ \dots \\ \frac{\p l}{\p f_m} \end{bmatrix} \bfb^\top = \left(\frac{\p l}{\p \bff}\right)^\top \bfb^\top \end{align}

We can group the terms inside a single transpose.

Which results in

Al(f(A,b))=(blf)\begin{align} \frac{\p }{\p \bfA} l(\bff(\bfA, \bfb)) = \left(\bfb\frac{\p l}{\p \bff}\right)^\top\end{align}

The derivative with respect to b\bfb is simpler:

bl(f(A,b))=lfb(Ab)=lfA\begin{align} \frac{\p }{\p \bfb} l(\bff(\bfA, \bfb)) = \frac{\p l}{\p \bff}\frac{\p }{\p \bfb}(\bfA\bfb) = \frac{\p l}{\p \bff} \bfA\end{align}

Case 3: VJP for matrix-matrix multiplication

Let

F(A,B)=AB\bfF(\bfA, \bfB) = \bfA \bfB

where FRm×p\bfF \in \bbR^{m \times p}, BRn×p\bfB \in \bbR^{n \times p}, and ARm×n\bfA \in \bbR^{m \times n}

Let l(F(A,B))Rl(\bfF(\bfA, \bfB)) \in \bbR be the eventual scalar output. We want to find lA\frac{\p l}{\p \bfA} and lB\frac{\p l}{\p \bfB} for Vector Jacobian product.

Note that a matrix-matrix multiplication can be written in terms horizontal stacking of matrix-vector multiplications. Specifically, write F\bfF and B\bfB in terms of their column vectors:

B=[b1b2bp]\bfB = \begin{bmatrix} \bfb_1 & \bfb_2 & \dots & \bfb_p \end{bmatrix}

F=[f1f2fp].\bfF = \begin{bmatrix} \bff_1 & \bff_2 & \dots & \bff_p \end{bmatrix}.

Then for all ii

fi=Abi\bff_i = \bfA\bfb_i

From the VJP of matrix-vector multiplication, we can write

lfiAfi=lfiA(Abi)=(bilfi)Rm×n\begin{align} \frac{\p l}{\p \bff_i}\frac{\p }{\p \bfA}\bff_i = \frac{\p l}{\p \bff_i}\frac{\p }{\p \bfA}(\bfA \bfb_i) = \left(\bfb_i\frac{\p l}{\p \bff_i}\right)^\top \in \bbR^{ m \times n } \end{align}

and for all iji \ne j

lfjA(Abi)=0m×n\frac{\p l}{\p \bff_j}\frac{\p }{\p \bfA}(\bfA \bfb_i) = \bfzero_{m \times n}

Instead of writing l(F)l(\bfF), we can also write l(f1,f2,,fp)l(\bff_1, \bff_2, \dots, \bff_p), then by chain rule of functions with multiple arguments, we have,

Al(F(A,B))=Al(f1,f2,,fp)=lf1f1A+lf2f2A++lfpfpA\begin{align} \frac{\p }{\p \bfA}l(\bfF(\bfA, \bfB)) = \frac{\p }{\p \bfA} l(\bff_1, \bff_2, \dots, \bff_p) = \frac{\p l}{\p \bff_1} \frac{\p \bff_1}{\p \bfA} + \frac{\p l}{\p \bff_2} \frac{\p \bff_2}{\p \bfA} + \dots + \frac{\p l}{\p \bff_p} \frac{\p \bff_p}{\p \bfA} \end{align}
Al(F(A,B))=(b1lf1)+(b2lf2)++(bplfp)=(b1lf1+b2lf2++bplfp)\begin{align} \frac{\p }{\p \bfA}l(\bfF(\bfA, \bfB)) = \left(\bfb_1\frac{\p l}{\p \bff_1}\right)^\top + \left(\bfb_2\frac{\p l}{\p \bff_2}\right)^\top + \dots + \left(\bfb_p\frac{\p l}{\p \bff_p}\right)^\top = \left(\bfb_1\frac{\p l}{\p \bff_1} + \bfb_2\frac{\p l}{\p \bff_2} + \dots + \bfb_p\frac{\p l}{\p \bff_p}\right)^\top \end{align}

It turns out that some of outer products can be compactly written as matrix-matrix multiplication:

b1lf1+b2lf2++bplfp=[b1b2bp][lf1lf2lfp]=B(lF)\begin{align} \bfb_1\frac{\p l}{\p \bff_1} + \bfb_2\frac{\p l}{\p \bff_2} + \dots + \bfb_p\frac{\p l}{\p \bff_p} = \begin{bmatrix} \bfb_1 & \bfb_2 & \dots & \bfb_p \end{bmatrix} \begin{bmatrix} \frac{\p l}{\p \bff_1}\\ \frac{\p l}{\p \bff_2}\\ \vdots\\ \frac{\p l}{\p \bff_p}\end{bmatrix} = \bfB \left(\frac{\p l}{\p \bfF}\right)^\top \end{align}

Hence,

Al(F(A,B))=lFB\frac{\p }{\p \bfA}l(\bfF(\bfA, \bfB)) = \frac{\p l}{\p \bfF}\bfB^\top

The vector Jacobian product for B\bfB can be found by applying the above rule to F2(A,C)=F(A,B)=BA=CA\bfF_2(\bfA, \bfC) = \bfF^\top(\bfA, \bfB) = \bfB^\top \bfA^\top = \bfC \bfA^\top where C=B\bfC = \bfB^\top and F2=F\bfF_2 = \bfF^\top.

Cl(F2(A,C))=lF2A\frac{\p }{\p \bfC}l(\bfF_2(\bfA, \bfC)) = \frac{\p l}{\p \bfF_2}\bfA

Take transpose of both sides

Cl(F2(A,C))=AlF2\frac{\p }{\p \bfC^\top}l(\bfF_2^\top(\bfA, \bfC)) = \bfA^\top\frac{\p l}{\p \bfF_2^\top}

Put back, C=B\bfC = \bfB^\top and F2=F\bfF_2 = \bfF^\top,

Bl(F(A,B))=AlF\frac{\p }{\p \bfB}l(\bfF(\bfA, \bfB)) = \bfA^\top\frac{\p l}{\p \bfF}
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.grad
xT = 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

σ(x)=11+exp(x)\sigma(x) = \frac{1}{1+\exp(-x)}
xσ(x)=1(1+exp(x))2(exp(x))\frac{\p }{\p x} \sigma(x) = -\frac{1}{(1+\exp(-x))^2} (-\exp(-x))

The above derivative is computed by chain rule. However, there is much simpler expression that can avoid unnecessary computations,

xσ(x)=11+exp(x)exp(x)1+exp(x)\frac{\p }{\p x} \sigma(x) = \frac{1}{1+\exp(-x)} \frac{\exp(-x)}{1+\exp(-x)}

xσ(x)=11+exp(x)(111+exp(x))\frac{\p }{\p x} \sigma(x) = \frac{1}{1+\exp(-x)} \left(1-\frac{1}{1+\exp(-x)}\right)

xσ(x)=σ(x)(1σ(x))\frac{\p }{\p x} \sigma(x) = \sigma(x) (1-\sigma(x))
# 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.grad

Whats 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:

f(y1,y2)=y1y2f(y₁ , y₂) = y₁ y₂

What if the y1y₁ and y2y₂ were copies of the same variable?

y1(x)=xy₁(x) = x

y2(x)=xy₂(x) = x

We can use the chain rule to find out the result

xf(y1(x),y2(x))=f/y1xy1+f/y2xy2∂ₓ f(y₁(x), y₂(x)) = ∂f/∂y₁ ∂ₓy₁ + ∂f/∂y₂ ∂ₓy₂

If we know that y1y₁ and y2y₂ are just copies of xx, then xy1=1∂ₓy₁ = 1 and xy2=1∂ₓy₂ = 1 .

xf(y1,y2)=f/y1+f/y2∂ₓ f(y₁, y₂) = ∂f/∂y₁ + ∂f/∂y₂

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