Skip to main content

From scalars to tensors

Rebuild the scalar Var class of M05.8 as a Tensor class around a NumPy array: data, grad, parents and a backward closure recorded as each operation runs, with a vector-Jacobian product for every elementwise operation.
DL01.6.1L2 Intermediate25 min
In review (what this means)

The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.

Before this lesson: M05.8 Computational graphs and automatic differentiation M05.9 Backpropagation as reverse-mode differentiation DL01.5 Backpropagation by hand

What you will be able to do
  • Describe a Tensor as a NumPy array together with a gradient of the same shape, the tensors it was made from and a backward closure recorded when the operation ran.
  • Derive the vector-Jacobian product of an elementwise operation and explain why it never needs the Jacobian matrix.
  • Implement add, subtract, multiply, negate, power, exp, log, sin, tanh and ReLU with their backward closures, and a backward method that sorts the graph topologically.
  • Check the engine on the running program of M05.8 run elementwise on arrays, against the hand formulas and against central differences.

The question

The Var class of M05.8.3 differentiated \({\ln x_1 + x_1 x_2 - \sin x_2}\) at one point with a few lines of Python. A hidden layer of 100 units with 100 inputs, on a batch of 64 examples, makes 6,400 numbers at once, and its matrix product needs 640,000 multiplications. Wrapped one number at a time, every one of those would be a Python object on the tape. What must change so that the same few lines of backward logic work on whole arrays at once?

Intuition

Keep the graph and change what sits at each node. In M05.8 a node held one number and its adjoint. Here a node holds a whole NumPy array, the data, and a second array of the same shape, the grad, which will hold the derivative of the output with respect to every entry.

The computational graph of ln x1 plus x1 x2 minus sine x2, with seven rounded boxes joined by grey arrows. Each box holds two rows of three cells. The blue input boxes x1 and x2 hold the values 2, 1, 0.5 and 5, 0, 2 in their top rows. The vermillion boxes v1 equals ln x1, v2 equals x1 x2, v3 equals sine x2, v4 equals v1 plus v2 and y equals v4 minus v3 hold their computed values in the top row, for example 11.65, 0, minus 0.60 for y. Every bottom row, in green, holds the gradient: 1, 1, 1 at y, minus 1, minus 1, minus 1 at v3, and 5.5, 1, 4 at x1 and 1.72, 0, 0.92 at x2. A vermillion arrow across the top reads forward: each operation runs on whole arrays and records its parents. A green arrow across the bottom, pointing left, reads backward: each node's closure sends whole arrays back.
DL01.6.1-F1

The running program of M05.8, now run on arrays of three numbers. Every node keeps its values (top row) and its gradient (bottom row, green). The first column is the point \({(2, 5)}\) of M05.8, and its gradients 5.5 and 1.72 are the ones found there.

Look at the bottom-left corner. The first column repeats M05.8: \({(5.5,\ 1.716)}\). The other two columns are two more points of the same program, differentiated in the same sweep. The graph still has seven nodes. With arrays of a million entries it would still have seven, and each node’s work would be one NumPy call on a whole array. That is the whole idea of a tensor engine: the graph is as small as the program, not as large as the data.

Build it up

Step 1. A tensor is a record of four things. The data, the grad (zeros to begin with), the parents it was computed from, and a backward closure: a small function, written at the moment the operation ran, that knows how to pass this tensor’s grad back to its parents.

A large vermillion card titled the Tensor v2 equals x1 x2, after backward, with four labelled rows. data: three cells 10, 0, 1, labelled the values. grad: three green cells 1, 1, 1, labelled same shape, starts at 0. parents: the text (x1, x2), labelled made from these, with two grey arrows leaving the card to two blue cards on the left for x1 and x2, each showing its data and its gradient. backward_fn: two lines of green code, x1.grad plus-equals x2.data times v2.grad and x2.grad plus-equals x1.data times v2.grad, with the note a closure, written when the product ran.
DL01.6.1-F2

One tensor of the running program, opened up. The closure refers to the arrays of the parents and to the tensor’s own grad, so it can run long after the product was computed.

Step 2. An operation computes and records. Running \({v_2 = x_1 x_2}\) does two things: NumPy computes the product, and the result is given its parents and its closure. So the graph is built by the program as it runs, one node per operation. This is called define-by-run, and it is how PyTorch and Karpathy’s micrograd work.

Step 3. The backward rule of an elementwise operation is an elementwise product. Entry \(i\) of \({\sin x_2}\) depends on entry \(i\) of \(x_2\) only. So the Jacobian is diagonal, and multiplying by it is the same as multiplying entry by entry.

Two panels. Left, titled Build the Jacobian, then multiply: a 3 by 3 grid with green diagonal cells 0.28, 1, minus 0.42 and grey zero cells elsewhere, labelled J transpose equals diag of cos x2, times a green column minus 1, minus 1, minus 1 labelled delta v3, equals the green column minus 0.28, minus 1, 0.42, labelled to x2; the note reads 9 numbers, 6 of them zero. Right, titled Multiply elementwise: the green column cos x2, 0.28, 1, minus 0.42, circle-dot the column delta v3, equals the same result column; the note reads 3 multiplications: the same answer.
DL01.6.1-F3

The backward step of \({v_3 = \sin x_2}\) in the running program. Writing out the Jacobian wastes \(n^2\) numbers, almost all zero. The elementwise product gives the same column with \(n\) multiplications.

Step 4. Backward sorts, seeds and sweeps. The output’s grad is set to ones. The graph is put in topological order, every node after its parents, and the closures are called from the end of that list to the start, each adding into its parents’ grads. A node is reached only after all of its children have reported, the rule M05.8.3 needed for a value used twice.

The formal statement

NoteThe vector-Jacobian product of an operation

Let an operation compute \({\vect{v} = f(\vect{u})}\) with \({\vect{u} \in \R^n}\) and \({\vect{v} \in \R^m}\), and let \({\cgrad{\vect{\delta}_{\vect{v}}}}\) be the gradient of the output with respect to \(\vect{v}\). Its backward closure adds

\[ \cgrad{\vect{\delta}_{\vect{u}}} \mathrel{+}= \mat{J}_f(\vect{u})\T\,\cgrad{\vect{\delta}_{\vect{v}}} \]

into the grad of \(\vect{u}\). If \({v_i = g(u_i)}\) for a function \(g\) of one variable, this is

\[ \cgrad{\vect{\delta}_{\vect{u}}} \mathrel{+}= g'(\vect{u}) \odot \cgrad{\vect{\delta}_{\vect{v}}}, \]

where \({g'(\vect{u})}\) applies \(g'\) to every entry and \(\odot\) multiplies entry by entry.

Derivation. The first line is the adjoint recurrence of M05.8.3 written for a whole array at once: the adjoint of entry \(u_j\) collects \({\delta_{v_i}\,\partial v_i / \partial u_j}\) from every entry \(v_i\) that uses it, and

\[ \sum_i \pd{v_i}{u_j}\,\delta_{v_i} = \big(\mat{J}_f\T\vect{\delta}_{\vect{v}}\big)_j , \]

since \({\partial v_i / \partial u_j}\) is entry \((i, j)\) of the Jacobian. For an elementwise operation, \(v_i\) depends on \(u_i\) alone, so \({\partial v_i/\partial u_j} = {g'(u_i)}\) when \({i = j}\) and 0 otherwise. The sum then has one term, \({g'(u_j)\,\delta_{v_j}}\), which is the second line.

An operation of two arguments has one rule for each. For \({\vect{v}} = {\vect{a} \odot \vect{b}}\), entry \(v_i = a_i b_i\) has slope \(b_i\) in \(a_i\) and \(a_i\) in \(b_i\), so it sends \({\vect{b} \odot \vect{\delta}_{\vect{v}}}\) to \(\vect{a}\) and \({\vect{a} \odot \vect{\delta}_{\vect{v}}}\) to \(\vect{b}\). Applied to each operation in turn:

Operation Sent back to the input
\({a + b}\), \({a - b}\) \(\delta\) to \(a\); \(\delta\) or \(-\delta\) to \(b\)
\({a \odot b}\) \({b \odot \delta}\) to \(a\), \({a \odot \delta}\) to \(b\)
\({-a}\), \({a^k}\) \(-\delta\); \({k\,a^{k-1} \odot \delta}\)
\({e^{a}}\), \({\ln a}\) \({e^{a} \odot \delta}\); \({\delta / a}\)
\({\sin a}\), \({\tanh a}\) \({\cos a \odot \delta}\); \({(1 - \tanh^2 a) \odot \delta}\)
\({\operatorname{relu} a}\) \(\delta\) where \({a > 0}\), else 0

Two of these reuse the forward result instead of recomputing it: \({e^a}\) is the output itself, and so is \(\tanh a\). At \({a = 0}\) ReLU has no derivative; PyTorch’s autograd notes ask for the subgradient of smallest size there, which is 0, and the engine follows it.

Seeding with ones. If the output \(\vect{y}\) is an array, the seed \({\vect{\delta}_{\vect{y}} = \vect{1}}\) makes the sweep return \({\mat{J}\T\vect{1}}\): the gradient of \({\sum_i y_i}\). For an elementwise program each \(y_i\) depends only on entry \(i\) of the inputs, so this is every entry’s own derivative. A training loss is a single number, and there the seed is the usual 1.

Go deeper: why the depth-first sort puts every node after its parents

The sort pops a node, marks it seen, pushes a “finished” marker for it, and then pushes its unseen parents above the marker. The stack is last in, first out, so every parent, and everything above it, is finished before the marker is popped, and a node is appended only when its marker is popped. Could a parent already be seen but not finished? Then the node would lie above that parent’s marker, so it would have been reached from the parent by following parents, making it an ancestor of its own parent: a cycle. A program that only ever computes new values from old ones has no cycles, so every node lands in the list after all its parents, and reversing the list puts every node before its parents.

Worked example

The running program on three points. Take \({x_1 = (2, 1, 0.5)}\) and \({x_2 = (5, 0, 2)}\). The forward pass gives \({v_1 = \ln x_1}\), \({v_2 = x_1 \odot x_2 = (10, 0, 1)}\), \({v_3 = \sin x_2}\), \({v_4 = v_1 + v_2}\) and \({y} = {v_4} - {v_3} \approx {(11.652,\ 0,\ -0.602)}\). Now the closures run in reverse topological order.

A table of nine rows with three columns: closure, what it adds, and that grad, now, the last as three green cells. Seed: delta y equals ones, 1, 1, 1. Closure of y: delta v4 plus-equals delta y, 1, 1, 1; delta v3 plus-equals minus delta y, minus 1, minus 1, minus 1. Closure of v4: delta v1 and delta v2 each plus-equals delta v4, 1, 1, 1. Closure of v1: delta x1 plus-equals delta v1 over x1, 0.5, 1, 2. Closure of v2, on a yellow row: delta x1 plus-equals x2 times delta v2, now 5.5, 1, 4; then delta x2 plus-equals x1 times delta v2, 2, 1, 0.5. Closure of v3, on a yellow row: delta x2 plus-equals cos x2 times delta v3, now 1.72, 0, 0.92. A note reads: yellow rows, the gradients are complete, 1 over x1 plus x2 and x1 minus cos x2.
DL01.6.1-F4

The backward sweep in the order the engine runs it. Each input collects two contributions, one per use. The yellow rows finish the gradient of each input.

  • Seed: \({\delta_y = (1, 1, 1)}\).
  • The closure of \({y = v_4 - v_3}\) adds \(\delta_y\) to \(v_4\) and \({-\delta_y}\) to \(v_3\).
  • The closure of \({v_4 = v_1 + v_2}\) copies \({(1, 1, 1)}\) to both.
  • The closure of \({v_1 = \ln x_1}\) adds \({\delta_{v_1} / x_1} = {(0.5,\ 1,\ 2)}\) to \(x_1\).
  • The closure of \(v_2\) adds \({x_2 \odot \delta_{v_2}} = {(5,\ 0,\ 2)}\) to \(x_1\), which now holds \({(5.5,\ 1,\ 4)}\), and \({x_1 \odot \delta_{v_2}} = {(2,\ 1,\ 0.5)}\) to \(x_2\).
  • The closure of \({v_3 = \sin x_2}\) adds \({\cos x_2 \odot (-1, -1, -1)}\), about \({(-0.284,\ -1,\ 0.416)}\), to \(x_2\), which now holds about \({(1.716,\ 0,\ 0.916)}\).

The hand formulas agree: \({\partial y / \partial x_1} = {1/x_1} + {x_2}\) gives \({(5.5, 1, 4)}\), and \({\partial y / \partial x_2} = {x_1} - {\cos x_2}\) gives \({(1.716, 0, 0.916)}\) to three decimals.

Code

The class, from scratch. Every operation computes its data with NumPy, then calls record to attach its parents and its closure. For now both arguments of an operation must have the same shape; DL01.6.2 lifts that restriction.

import numpy as np

class Tensor:
    """A NumPy array that remembers how it was made, so that gradients can flow back through it."""
    def __init__(self, data):
        self.data = np.asarray(data, dtype=float)
        self.grad = np.zeros_like(self.data)     # d(output)/d(entry), same shape as data
        self.parents = ()                        # the tensors this one was computed from
        self.backward_fn = None                  # passes self.grad back to the parents

    def record(self, parents, backward_fn):
        self.parents, self.backward_fn = parents, backward_fn
        return self

    @property
    def shape(self):
        return self.data.shape

    def __add__(self, other):
        assert self.shape == other.shape, "same shapes only, until DL01.6.2"
        out = Tensor(self.data + other.data)
        def backward_fn():
            self.grad += out.grad
            other.grad += out.grad
        return out.record((self, other), backward_fn)

    def __sub__(self, other):
        assert self.shape == other.shape, "same shapes only, until DL01.6.2"
        out = Tensor(self.data - other.data)
        def backward_fn():
            self.grad += out.grad
            other.grad += -out.grad
        return out.record((self, other), backward_fn)

    def __mul__(self, other):
        assert self.shape == other.shape, "same shapes only, until DL01.6.2"
        out = Tensor(self.data * other.data)
        def backward_fn():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        return out.record((self, other), backward_fn)

    def __neg__(self):
        out = Tensor(-self.data)
        def backward_fn():
            self.grad += -out.grad
        return out.record((self,), backward_fn)

    def __pow__(self, k):                        # k is a plain number
        out = Tensor(self.data ** k)
        def backward_fn():
            self.grad += k * self.data ** (k - 1) * out.grad
        return out.record((self,), backward_fn)

    def exp(self):
        out = Tensor(np.exp(self.data))
        def backward_fn():
            self.grad += out.data * out.grad     # the slope of exp is exp: reuse it
        return out.record((self,), backward_fn)

    def log(self):
        out = Tensor(np.log(self.data))
        def backward_fn():
            self.grad += out.grad / self.data
        return out.record((self,), backward_fn)

    def sin(self):
        out = Tensor(np.sin(self.data))
        def backward_fn():
            self.grad += np.cos(self.data) * out.grad
        return out.record((self,), backward_fn)

    def tanh(self):
        out = Tensor(np.tanh(self.data))
        def backward_fn():
            self.grad += (1 - out.data ** 2) * out.grad
        return out.record((self,), backward_fn)

    def relu(self):
        out = Tensor(np.maximum(self.data, 0))
        def backward_fn():
            self.grad += (self.data > 0) * out.grad   # slope 0 at exactly 0
        return out.record((self,), backward_fn)

    def topo_order(self):
        """Every tensor this one depends on, each listed after its parents."""
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, finished = stack.pop()
            if finished:
                order.append(node)
            elif id(node) not in seen:
                seen.add(id(node))
                stack.append((node, True))
                stack.extend((p, False) for p in node.parents if id(p) not in seen)
        return order

    def backward(self):
        order = self.topo_order()
        for node in order:
            if node.parents:                     # results start from zero; leaves keep their grad
                node.grad = np.zeros_like(node.data)
        self.grad = np.ones_like(self.data)      # the seed
        for node in reversed(order):             # children before parents
            if node.backward_fn:
                node.backward_fn()

The running program, run once on three points:

x1 = Tensor([2.0, 1.0, 0.5])
x2 = Tensor([5.0, 0.0, 2.0])
y = x1.log() + x1 * x2 - x2.sin()
y.backward()
print("y       =", np.round(y.data, 3))
print("x1.grad =", np.round(x1.grad, 3))
print("x2.grad =", np.round(x2.grad, 3))
print("nodes in the graph:", len(y.topo_order()))
y       = [11.652  0.    -0.602]
x1.grad = [5.5 1.  4. ]
x2.grad = [1.716 0.    0.916]
nodes in the graph: 7

The first entries repeat M05.8.3. Now a sterner test: a program that uses every operation, checked against central differences entry by entry. Because each output entry depends only on the same entry of the inputs, the seed of ones gives every entry’s derivative in one sweep.

rng = np.random.default_rng(0)
a0, b0 = rng.normal(size=4), rng.normal(size=4)

def program(a, b):
    return (a.tanh() * b).exp() + (a ** 3 - b).relu() + (b * b).log() - (-a).sin()

a, b = Tensor(a0), Tensor(b0)
program(a, b).backward()

def plain(a_values, b_values):
    return program(Tensor(a_values), Tensor(b_values)).data

h = 1e-6
num_a = (plain(a0 + h, b0) - plain(a0 - h, b0)) / (2 * h)   # every entry at once: they do not interact
num_b = (plain(a0, b0 + h) - plain(a0, b0 - h)) / (2 * h)
print("largest gap, a:", f"{np.abs(a.grad - num_a).max():.1e}", "  b:", f"{np.abs(b.grad - num_b).max():.1e}")
largest gap, a: 1.6e-10   b: 3.0e-10

The library check: SciPy’s scipy.optimize.check_grad compares a gradient function with a forward-difference estimate and returns the length of the gap. Here the function is the sum of the running program’s outputs, and the gradient is one call to backward.

from scipy.optimize import check_grad

def total(x):
    return float((Tensor(x[:3]).log() + Tensor(x[:3]) * Tensor(x[3:]) - Tensor(x[3:]).sin()).data.sum())

def gradient(x):
    t1, t2 = Tensor(x[:3]), Tensor(x[3:])
    (t1.log() + t1 * t2 - t2.sin()).backward()
    return np.concatenate([t1.grad, t2.grad])

x0 = np.array([2.0, 1.0, 0.5, 5.0, 0.0, 2.0])
print("check_grad:", f"{check_grad(total, gradient, x0):.1e}")

big1, big2 = Tensor(rng.uniform(0.5, 2.0, size=1_000_000)), Tensor(rng.normal(size=1_000_000))
big = big1.log() + big1 * big2 - big2.sin()
print("nodes for a million entries:", len(big.topo_order()))
check_grad: 1.5e-07
nodes for a million entries: 7

The gap of check_grad is the size that a forward difference makes; the engine’s gradient is exact up to rounding. A million entries still make a graph of seven nodes.

Explore

The module’s interactive, Broadcast and backward, is in DL01.6.2, where operations start to change the shapes of arrays.

Pitfalls

Two panels, each the graph x equals 1, a equals x squared, b equals e to the a, y equals a plus b, with arrows from x to a, a to b, b to y and a straight to y, and yellow numbered badges for the order in which closures run. Top, marked with a cross and titled Fire each closure as soon as it is reached: order y, a, b; the arrow from a to b is dashed; a has delta 1 and x has delta 2, with notes b reports to a, but a has fired, and delta x equals 2: wrong. Bottom, marked with a tick and titled Topological order: a waits for b: order y, b, a; a has delta 3.72 and x has delta 7.44, with the note delta x equals 2 times (1 plus e) equals 7.44.
DL01.6.1-F5

Wrong beside right. The node \(a\) feeds both \(b\) and \(y\). If its closure runs before \(b\) has reported, it passes back only part of its gradient, and \(x\) gets 2 instead of \({2(1 + e)}\).

“Walk back from the output and run each closure when you first reach it.”

A node can be reached before all its children have reported. In the figure the walk reaches \(a\) straight from \(y\), runs its closure with \({\delta_a = 1}\), and only later does \(b\) add \(e\) to \(\delta_a\), which by then goes nowhere. The topological sort exists to stop this: a node’s closure runs only after every node that uses it.

“Changing x.data after the forward pass is harmless.”

The closures read the parents’ arrays when backward runs, not when the forward pass ran. If x.data is changed in place between the two, the gradient is computed at the new values and belongs to no forward pass at all. PyTorch guards against in-place changes of this kind: every tensor carries a version counter, and backward raises an error if a tensor it saved has changed since. This small engine trusts you.

“Calling backward on an array output gives the Jacobian.”

It gives \({\mat{J}\T\vect{1}}\), the gradient of the sum of the outputs. For an elementwise program that happens to list every entry’s derivative, but for a matrix product or a sum it mixes entries. PyTorch refuses to guess: backward() on a tensor with more than one element needs the seed passed in as its gradient argument.

Check yourself

Recall

1. What four things does a Tensor hold?

Show answer

Its data (a NumPy array), its grad (an array of the same shape, zero at first), its parents, and a backward closure that passes its grad back to the parents.

2. What does the backward closure of an elementwise operation \({v = g(u)}\) add to u.grad?

Show answer

\({g'(u) \odot \delta_v}\), the vector-Jacobian product with a diagonal Jacobian, computed with \(n\) multiplications.

3. Why are the closures called in reverse topological order?

Show answer

So that a node’s closure runs only after every node that uses it has added its share to the node’s grad; then the grad it passes back is complete.

4. What does backward compute when the output is an array and the seed is all ones?

Show answer

\({\mat{J}\T\vect{1}}\), the gradient of the sum of the output’s entries.

5. How many nodes does the running program’s graph have when the inputs have a million entries?

Show answer

Seven, as for one entry: one node per input and one per operation. The size of the arrays does not change the size of the graph.

Exercises

1. ★ Add a sigmoid operation, \({\sigma(a) = 1/(1 + e^{-a})}\), to the class, with a backward closure that reuses the forward result. What does it add to a.grad?

Show solution

The derivative is \({\sigma'(a)} = {\sigma(a)\,(1 - \sigma(a))}\), so the closure adds \({\sigma(a)(1 - \sigma(a)) \odot \delta}\), all from the output’s own data:

def sigmoid(self):
    out = Tensor(1 / (1 + np.exp(-self.data)))
    def backward_fn():
        self.grad += out.data * (1 - out.data) * out.grad
    return out.record((self,), backward_fn)

2. ★★ Let \({x = (1, -2, 0.5)}\) and \({y = x^3}\). What is x.grad after one call of y.backward(), and after a second call on the same graph? Why?

Show solution

After one call, \({3x^2 \odot \vect{1}} = {(3,\ 12,\ 0.75)}\). The second call zeroes the grad of \(y\) (a result) and reseeds it, but \(x\) is a leaf and keeps what it holds, so the closure adds \({(3, 12, 0.75)}\) again: \({(6,\ 24,\ 1.5)}\). Leaves accumulate across calls, as in PyTorch, which is why DL01.6.3 needs a zero_grad.

3. ★★★ A program is built only from the elementwise operations of this lesson, on inputs of the same shape. Prove that its Jacobian with respect to each input is diagonal, and conclude that the seed of ones gives every entry’s own derivative.

Show solution

Use induction over the operations in the order they run. Each input is a leaf, and its Jacobian with respect to itself is the identity, which is diagonal; with respect to the other input it is zero, also diagonal. Suppose every tensor computed so far has a diagonal Jacobian with respect to each input. A one-argument operation \({v = g(u)}\) has, by the chain rule, Jacobian \({\operatorname{diag}(g'(u))\,\mat{J}_u}\), a product of diagonal matrices, which is diagonal. A two-argument one, \({a + b}\) or \({a \odot b}\), has \({\mat{J}_a + \mat{J}_b}\) or \({\operatorname{diag}(b)\mat{J}_a} + {\operatorname{diag}(a)\mat{J}_b}\): sums of products of diagonal matrices, again diagonal. So the output’s Jacobian \(\mat{J}\) is diagonal, and entry \(j\) of \({\mat{J}\T\vect{1}}\) is the sum of column \(j\) of \(\mat{J}\), which is its one diagonal entry \({\partial y_j / \partial x_j}\).

Summary card

A tensor engine is the scalar engine with an array at every node.

The anchor figure again: the seven-node graph of ln x1 plus x1 x2 minus sine x2, every node holding a row of three values and a green row of three gradients; the inputs end with gradients 5.5, 1, 4 and 1.72, 0, 0.92.
DL01.6.1-F1

Same graph, whole arrays.

  • A Tensor holds data, grad, parents and a backward closure.
  • Backward rule: \({\vect{\delta}_{\vect{u}} \mathrel{+}= \mat{J}\T\vect{\delta}_{\vect{v}}}\).
  • Elementwise: \({\vect{\delta}_{\vect{u}} \mathrel{+}= g'(\vect{u}) \odot \vect{\delta}_{\vect{v}}}\).
  • Sort topologically, seed ones, call closures in reverse, add.

Sources and next steps

Sources. Each was opened and checked against this lesson on 15 November 2026.

  • A. G. Baydin, B. A. Pearlmutter, A. A. Radul and J. M. Siskind, Automatic Differentiation in Machine Learning: a Survey, JMLR 18(153), 2018. Section 3.2 computes the transposed Jacobian-vector product by starting the reverse phase with the seed, and notes the storage cost; section 4.2 explains the terms define-by-run and dynamic computational graph.
  • I. Goodfellow, Y. Bengio and A. Courville, Deep Learning, chapter 6. Section 6.5.2 writes the chain rule as a Jacobian-gradient product (equations 6.46 and 6.47, the second for tensors); section 6.5.6 gives every operation its own bprop method (equation 6.54).
  • A. Karpathy, micrograd (MIT licence): a scalar reverse-mode engine of about 100 lines over a dynamically built graph. This module follows its closure design but puts arrays at the nodes.
  • PyTorch 2.15, Autograd mechanics: the graph’s leaves are the inputs and its roots the outputs, it is recreated at every iteration, a non-differentiable point such as ReLU at 0 takes the subgradient of minimum norm, and a version counter catches in-place changes. torch.Tensor.backward: a non-scalar output needs a gradient argument, and gradients accumulate in the leaves.

Next. DL01.6.2 Matrix products, reductions and broadcasting adds the operations that change shapes, and with them everything a network needs.

This lesson unlocks

DL01.6.2 Matrix products, reductions and broadcasting

Your progress is saved in this browser only. No account, no tracking.
Back to top