The Mathematics of Large Language Models

Lecture 3 — Backpropagation and Stochastic Optimization

Project connection. Project Step 3 first asks you to build a scalar reverse-mode automatic-differentiation engine. You will validate it on a graph with fan-out, against finite differences, and on the hand-wired diamond network from Step 2. You will then use PyTorch’s tensor-valued version of the same algorithm to train the neural nn-gram model introduced in Lecture 2.

Chapter overview. The cross-entropy objective of a neural network is a scalar function of many parameters. We will represent its evaluation as a computational graph and use the chain rule in two orders. Forward mode carries one input perturbation toward the output. Reverse mode carries one output sensitivity toward every input, which is exactly the shape needed for neural-network training. We then turn from differentiation to optimization: a minibatch supplies a cheap random estimate of the full-corpus gradient, and stochastic gradient descent uses that estimate to update the parameters. These are distinct algorithms. Backpropagation computes a gradient; stochastic gradient descent decides which loss to differentiate and how to use the result.

0. The computational task at hand

We begin with a review of our setting. Let

θ=(θ1,,θP)RP\theta=(\theta_1,\ldots,\theta_P)^\top\in\mathbb R^P

collect all PP trainable parameters of a model. A parameter is a number whose value is chosen during training, such as an entry of a weight matrix, bias vector, or embedding matrix. Suppose the training set contains NN examples and the loss of example ii is i(θ)\ell_i(\theta). As in Lecture 2, the empirical loss is the average

L(θ)=1Ni=1Ni(θ).\mathcal L(\theta) = \frac1N\sum_{i=1}^N\ell_i(\theta).

The word empirical means that the average is taken over observed data. The function being minimized is also called the objective function, or simply the objective.

The gradient is the column vector of first partial derivatives

θL(θ)=(L/θ1L/θP).\nabla_\theta\mathcal L(\theta) = \begin{pmatrix} \partial\mathcal L/\partial\theta_1\\ \vdots\\ \partial\mathcal L/\partial\theta_P \end{pmatrix}.

Lecture 2 defined gradient descent. Written with a possibly changing learning rate ηt>0\eta_t>0, one update is

θ(t+1)=θ(t)ηtL(θ(t)).\theta^{(t+1)} = \theta^{(t)}-\eta_t\nabla\mathcal L(\theta^{(t)}).

The superscript (t)(t) is an index, not an exponent. One execution of the update is an iteration, and ηt\eta_t is the learning rate, or step size. If the gradient uses all NN examples, the method is full-batch gradient descent.

There are now two computational problems.

  1. Differentiation: given a particular loss computation and a particular value of θ\theta, compute its gradient.
  2. Optimization: choose which examples to use, choose a learning rate, and update θ\theta so that the objective becomes small.

Reverse-mode automatic differentiation solves the first problem. Full-batch gradient descent or stochastic gradient descent solves the second.

Think of differentiation as drawing an accurate local arrow on a map: the gradient says which infinitesimal direction raises the loss fastest. Optimization is the travel policy that uses those arrows. It decides how far to move, when to draw a new arrow, and whether to estimate the arrow from the entire dataset or from a small random sample.

Why not differentiate every formula by hand, as we did for logistic and softmax regression in Lecture 2? A neural network may contain millions or billions of parameters, but the more important difficulty is structural: loss along many paths, and the program changes as the architecture changes. We want an algorithm that differentiates the program itself.


0. Our goal

Our goal in this section is to create a very efficient way to do the necessary computations for gradient descent. The loss function for a neural network is much more complicated than that for a simple logistic regression, so we don’t want to deal with its formula, which is a function of all the weights and biases. Instead, we break it apart into its constituent pieces, and the gradient computation becomes many applications of chain rule combining these small pieces. To model this, we need to introduce the notion of a computational graph. This graph is not the neural network graph.

1. Computational graphs

A directed graph consists of nodes and directed edges. A directed path is a sequence of directed edges, followed in their arrow directions. A directed cycle is a nonempty directed path that returns to its starting node. A directed graph is acyclic if it has no directed cycle; a finite directed acyclic graph is abbreviated DAG.

A node with no incoming edges is a source. A node with no outgoing edges is a sink. The parents of a node are the nodes with edges into it, and its children are the nodes with edges out of it. A topological ordering of a finite DAG is an ordered list of its nodes so that every parent occurs before its child. Every finite DAG has at least one topological ordering, though it need not have only one.

v₁ v₂ v₃ v₄ v₅ v₆ source source sink parents of v₃ children of v₃ a DAG (nodes numbered in a topological ordering) a directed cycle: not allowed in a DAG
Left: a DAG. Nodes v₁ and v₂ have no incoming edges (sources); v₆ has no outgoing edges (a sink). The parents of v₃ are v₁ and v₂ (green edges in); its children are v₄ and v₅ (amber edges out). The numbering v₁, …, v₆ is a topological ordering: every parent comes before its children. Right: a directed cycle — the one thing a DAG may not contain.

An edge uvu\to v says that the value at node vv directly depends on the value at node uu.

Definition 1.1 (scalar computational graph). A scalar computational graph is a finite DAG with topologically ordered nodes v1,,vMv_1,\ldots,v_M. Each source node is labelled by a variable. Every non-source node is labelled by a differentiable function of its parents:

vi=φi(vj:jpa(i)),pa(i){1,,i1}.v_i = \varphi_i\bigl(v_j:j\in\operatorname{pa}(i)\bigr), \qquad \operatorname{pa}(i)\subseteq\{1,\ldots,i-1\}.

Here pa(i)\operatorname{pa}(i) is the set of parent indices of node ii, and φi\varphi_i is called the node’s primitive operation, or primitive. The last sink vMv_M is designated as the output.

In practice, the functions at each node should be ‘simple’ ones, in the sense that calculus students will be ok with differentiating them. A computational graph is designed to model the process of computing a complicated function, namely the output as a function on the source variables. Assigning values (typically scalars) to the source variables first and then evaluating the primitives in topological order to determine the output is called the forward pass (or forward sweep). The values created between the inputs and output are intermediate values.

The loss function for a neural network will be modelled by a computational graph. The source nodes hold the inputs and the parameters: the training example’s features and target, together with every entry of each weight matrix, bias vector, and embedding matrix.

Example 1.2 (a running example). We will use one expression throughout the lecture as a running example.

L=(ab+a)tanhb.\mathcal L=(ab+a)\tanh b.

Introduce the intermediate values

u=ab,v=u+a,w=tanhb,L=vw.u=ab, \qquad v=u+a, \qquad w=\tanh b, \qquad \mathcal L=vw.

The following picture is the corresponding computational graph. The formula is nested, but the graph makes every direct dependence explicit.

a b u = ab v = u + a w = tanh b L = vw sources intermediate values scalar sink
Example computational graph.

A node has fan-out if it has more than one child. Fan-out means that one value influences the output along more than one directed path. In the figure, both aa and bb have fan-out.

The local derivative of a primitive is its derivative with respect to one parent while its other parents are held fixed.
The local derivative of the primitive vv with respect to parent uu is naturally associated to the edge uvu \rightarrow v, so we can think of this as a labelling of the directed edges.

Example 1.3 (local derivatives of the running example). The local derivatives in our running example graph are

ua=b,ub=a,\frac{\partial u}{\partial a}=b, \qquad \frac{\partial u}{\partial b}=a, vu=1,va=1,wb=1w2,\frac{\partial v}{\partial u}=1, \qquad \frac{\partial v}{\partial a}=1, \qquad \frac{\partial w}{\partial b}=1-w^2,

and

Lv=w,Lw=v.\frac{\partial\mathcal L}{\partial v}=w, \qquad \frac{\partial\mathcal L}{\partial w}=v.

Here is the graph with the local derivatives labelling the edges:

a b u = ab v = u + a w = tanh b L = vw ∂u/∂a = b ∂u/∂b = a ∂v/∂u = 1 ∂v/∂a = 1 ∂w/∂b = 1 − w² ∂L/∂v = w ∂L/∂w = v
The same graph, with each edge uv labelled by the local derivative ∂v/∂u of the child with respect to that parent.

2. The chain rule and forward-mode automatic differentiation

The multivariable chain rule says that if a scalar rr influences a scalar qq through intermediate variables s1,,sms_1,\ldots,s_m, then

dqdr=j=1mqsjsjr.\frac{dq}{dr} = \sum_{j=1}^m \frac{\partial q}{\partial s_j} \frac{\partial s_j}{\partial r}.

Automatic differentiation (AD) is the algorithmic evaluation of derivatives by applying the chain rule. It is termed automatic because a program constructs and traverses the derivative computation.

Our principal application is to compute the gradient of L\mathcal{L} with respect to the parameters θ=(θ1,,θP)\theta = (\theta_1, \ldots, \theta_P) for gradient descent.

Fix one parameter coordinate θs\theta_s. For every graph node compute

v˙i:=viθs\dot v_i := \frac{\partial v_i}{\partial\theta_s}

and define the tangent to be v˙i\dot v_i evaluated at θ\theta.

We initialize the input tangents by

θ˙r=1{r=s},\dot\theta_r=\mathbf1_{\{r=s\}},

where the indicator 1E\mathbf1_E equals 11 when statement EE is true and 00 otherwise. More concretely, this sets θ˙r\dot\theta_r to 11 when r=sr=s and to 00 otherwise.

This initialization is called a seed. It selects the input variable (in this case θs\theta_s) or combination of variables whose influence we want to follow.

Algorithm 2.1 (forward-mode automatic differentiation). Visit the nodes of the graph in topological order. At non-source node viv_i, compute the non-source value

vi=φi(vj:jpa(i)),v_i=\varphi_i(v_j:j\in\operatorname{pa}(i)),

and also compute the tangent

v˙i=jpa(i)φivjθv˙j,\dot v_i = \sum_{j\in\operatorname{pa}(i)} \left.\frac{\partial\varphi_i}{\partial v_j}\right|_{\theta}\dot v_j,

where each local derivative φi/vj\partial\varphi_i/\partial v_j is evaluated at the node values the forward pass has already computed at θ\theta.

The process is called forward mode because values and their tangents move together in the forward topological direction.

By design, at the output node,

v˙M=Lθs.\dot v_M = \frac{\partial\mathcal L}{\partial\theta_s}.

The rule in the definition, of course, is just the chain rule at each node. Its computational efficiency comes from storing the tangent of every intermediate, so each shared subcomputation is performed exactly once per seed.

Example 2.2 (two forward passes). For L=(ab+a)tanhb\mathcal L=(ab+a)\tanh b, we perform the full computation needed for the gradient of L\mathcal{L} at point (2,1)(2,-1). This gradient has two entries, namely L/a\partial \mathcal{L} / \partial a and L/b\partial \mathcal{L} / \partial b. To compute the first of these, we use seed a˙=1\dot a = 1, b˙=0\dot b = 0. For the second, we use seed a˙=0\dot a = 0, b˙=1\dot b = 1.

Here is the full computation of L/a\partial \mathcal{L} / \partial a, shown on the computational graph. Each node is labelled with its corresponding value and tangent.

a = 2 ȧ = 1 b = −1 ḃ = 0 u = ab = −2 u̇ = bȧ + aḃ = −1 v = u + a = 0 v̇ = u̇ + ȧ = 0 w = tanh b = −0.7616 ẇ = (1 − w²)ḃ = 0 L = vw = 0 L̇ = wv̇ + vẇ = 0 seed ȧ = 1 seed ḃ = 0 L̇ = ∂L/∂a
One forward pass with seed ȧ = 1, ḃ = 0 at (a,b) = (2,−1). Each node carries its value (top line) and its tangent (bottom line), computed together in topological order. The output tangent is ∂L/∂a = 0.

The following table shows all the computations for both seeds. The third column corresponds to the computation with seed a˙=1\dot a = 1, b˙=0\dot b = 0 and the fourth column for the other seed.

nodevalue at (2,1)node˙=(node)/anode˙=(node)/ba210b101u=ab2b=1a=2v=u+a01+1=02w=tanhb0.76159401w2L=vw0w(0)+v(0)=0w(2)+v(1w2)=1.523188\begin{array}{c|c|c|c} \text{node} & \text{value at }(2,-1) & \dot{\text{node}} = \partial(\text{node})/\partial a & \dot{\text{node}} = \partial(\text{node})/\partial b\\ \hline a & 2 & 1 & 0\\ b & -1 & 0 & 1\\ u=ab & -2 & b=-1 & a=2\\ v=u+a & 0 & -1+1=0 & 2\\ w=\tanh b & -0.761594 & 0 & 1-w^2\\ \mathcal L=vw & 0 & w(0)+v(0)=0 & w(2)+v(1-w^2)=-1.523188 \end{array}

Therefore

(a,b)L(2,1)=(01.523188).\nabla_{(a,b)}\mathcal L(2,-1) = \begin{pmatrix} 0\\ -1.523188\ldots \end{pmatrix}.

We now want to consider the runtime of forward-mode gradient computation. We’ll use some standard runtime terminology from computer science, called big-Oh notation. For positive functions A(s)A(s) and B(s)B(s) we write A(s)=O(B(s))A(s)=O(B(s)) when A(s)/B(s)A(s)/B(s) is bounded above for all sufficiently large ss, and A(s)=Θ(B(s))A(s)=\Theta(B(s)) when it is bounded both above and below by positive constants for all sufficiently large ss. A mathematician is more likely to write this as A(s)<cB(s)A(s) < c B(s) for some constant cc. These notations ignore fixed multiplicative constants and describe growth as the problem size increases. The constants are called hidden constants.

We measure cost in units of one evaluation: the work of computing L(θ)\mathcal L(\theta) itself by a single forward pass, evaluating each primitive in the graph once.

Proposition 2.3 (cost of a full forward-mode gradient). Suppose that L\mathcal L is a function of PP scalar coordinates. Then computing θL\nabla_\theta\mathcal L by coordinatewise forward mode costs Θ(P)\Theta(P) evaluations — Θ(P)\Theta(P) times the cost of computing L(θ)\mathcal L(\theta) once. The hidden constants are determined by the primitive operations.

Proof

One forward pass with seed ese_s, the ssth standard basis vector, produces L/θs\partial\mathcal L/\partial\theta_s. Repeating for s=1,,Ps=1,\ldots,P produces all PP gradient coordinates. Each pass performs a bounded amount of derivative work for each node beside the work of the forward pass itself, so its cost is a constant multiple of one evaluation. \square

How does this compare with more naive approaches? A finite-difference gradient approximates each coordinate by (L(θ+hes)L(θ))/h\bigl(\mathcal L(\theta+h e_s)-\mathcal L(\theta)\bigr)/h, which also costs one evaluation per coordinate — the same Θ(P)\Theta(P) — but each coordinate is only approximate, with the truncation-versus-roundoff tension you met in Project Step 2’s float64 warning. Forward mode has the same cost and is exact (up to floating point), because it applies exact derivative rules. A third option, symbolically differentiating a formula for L\mathcal L, is exact but explosive: the expression for each L/θs\partial\mathcal L/\partial\theta_s can grow far larger than the program that computes L\mathcal L, and the PP expressions share no work. So Θ(P)\Theta(P) evaluations looks hard to beat — and the surprise of the next section is that it can be beaten completely: reverse mode gets the whole gradient for Θ(1)\Theta(1) evaluations.

Forward mode is excellent when there are few inputs and many outputs. The proposition above treats a single scalar output, but notice that one forward pass computes the tangent v˙i\dot v_i of every node in the graph, not only of vMv_M. If a computation has many outputs — say QQ sink nodes rather than one — then a single pass with seed ese_s delivers the derivatives of all QQ outputs with respect to θs\theta_s simultaneously. The cost of forward mode therefore scales with the number of inputs that must be seeded, and is independent of the number of outputs.

Neural-network training has the opposite shape: perhaps billions of input parameters and one scalar loss. This mismatch motivates reverse mode, which we will cover shortly.


3. Reverse mode and backpropagation

Fix input values (source values) in a computational graph. Suppose the output is L\mathcal{L}. Suppose also that we have already done a forward pass, so the values at all nodes are computed as a function of the fixed source values.

As we saw, forward mode computes the value of vi/a\partial v_i / \partial a at each node viv_i in terms of each source aa. By contrast, reverse mode computes the value of L/vi\partial \mathcal{L} / \partial v_i at each node viv_i. Both of these compute the values iteratively from previously computed values. In the case of reverse mode, or backpropagation, we work from right to left, computing the values of leftward derivatives (closer to source) from those already computed to the right (closer to output).

The end goal is to compute L/a\partial \mathcal{L} / \partial a for each source aa. In the case of the loss as a function of source parameters θ\theta, these partial derivatives, taken together, form the gradient θL\nabla_\theta \mathcal{L} needed for gradient descent.

Consider a node PP in the graph. Its value is already known from the forward pass. Its children c1,,cnc_1,\dots,c_n are the nodes that depend on PP directly, and because we are working backward from the output, each child’s adjoint L/c\partial\mathcal L/\partial c has already been computed.

P c₁ c₂ cₙ L sources parent children sink (loss) ∂c/∂P: compute now ∂L/∂c: known from previous steps begin with ∂L/∂L = 1
Backpropagation from the point of view of one node. Values flow left to right in the forward pass; adjoints flow right to left, starting from ∂L/∂L = 1 at the sink. When the reverse pass reaches P, the adjoints ∂L/∂c of its children (green) are already known, and only the local derivatives ∂c/∂P along the amber edges are new.

The chain rule says

LP=children c of PLcby previous stepscPcompute now\frac{\partial\mathcal L}{\partial P} = \sum_{\text{children } c \text{ of } P} \underbrace{\frac{\partial\mathcal L}{\partial c}}_{\text{by previous steps}} \cdot \underbrace{\frac{\partial c}{\partial P}}_{\text{compute now}}

Thus, to compute LP\frac{\partial\mathcal L}{\partial P}, we can use the values Lc\frac{\partial\mathcal L}{\partial c} computed previously (further to the right), and compute the derivative cP\frac{\partial c}{\partial P} at that node as needed.

The backpropagation algorithm performs this computation at each node, from right to left. To initialize, we begin at the sink with L/L=1\partial\mathcal L/\partial\mathcal L=1. All derivatives are evaluated at the node values (computed during the forward pass), so we are storing and computing numbers, not formulas.

We now formalize this with the typical machine-learning language. For the fixed scalar output L\mathcal L, define the adjoint of node viv_i by

vˉi:=Lvi.\bar v_i := \frac{\partial\mathcal L}{\partial v_i}.

Some software calls the same quantity the node’s gradient.

Algorithm 3.1 (reverse accumulation). Input: a computational graph with nodes v1,,vMv_1,\dots,v_M in topological order, output vM=Lv_M=\mathcal L, and every node value from a forward pass. Output: the adjoints vˉi=L/vi\bar v_i=\partial\mathcal L/\partial v_i for all ii.

vˉM:=1;vˉi:=0  for i<Mfor i=M,M1,,2:for each jpa(i):vˉj:=vˉj+vˉiφivjreturn vˉ1,,vˉM\begin{array}{l} \bar v_M := 1;\qquad \bar v_i := 0 \ \text{ for } i<M\\[2pt] \textbf{for } i = M, M-1, \dots, 2\textbf{:}\\ \qquad \textbf{for each } j\in\operatorname{pa}(i)\textbf{:}\\ \qquad\qquad \bar v_j := \bar v_j + \bar v_i\,\dfrac{\partial\varphi_i}{\partial v_j}\\[4pt] \textbf{return } \bar v_1,\dots,\bar v_M \end{array}

Here :=:= denotes assignment, and each local derivative φi/vj\partial\varphi_i/\partial v_j is evaluated at the forward-pass values. This traversal is the reverse pass. When it finishes, vˉj=L/vj\bar v_j=\partial\mathcal L/\partial v_j for every node jj; in particular, the adjoints of the parameter nodes are the complete gradient θL\nabla_\theta\mathcal L.

The algorithm is called reverse accumulation because derivative contributions accumulate while moving opposite the forward edges. It is called reverse-mode automatic differentiation because it applies AD in that direction. Backpropagation is the customary neural-network name for reverse-mode AD applied to a loss.

Imagine that the loss sends a one-unit “responsibility message” backward. At an operation, the incoming message is multiplied by each local derivative before being sent to the corresponding parent. If a value was used in several places, several messages return to it and must be added.

The local derivative answers how strongly the child reacts to its parent. The child’s adjoint answers how strongly the loss reacts to the child. Their product answers how strongly the loss reacts along that one edge.

Algorithm 3.1 is this equation with the sum spread out: each edge from PP to a child cc contributes one term, and the accumulation step adds that term into Pˉ\bar P when the edge is processed.

Let us revisit our running example, the graph from Section 1. At (a,b)=(2,1)(a,b)=(2,-1), the values are

u=2,v=0,w=0.761594,L=0.u=-2, \qquad v=0, \qquad w=-0.761594\ldots, \qquad \mathcal L=0.

(This is computed by one simple forward pass.)

Seed Lˉ=1\bar{\mathcal L}=1. We think of each node sending a message back along each of its incoming edges: the message from child ii to parent jj is the child’s adjoint times the local derivative on that edge, and a node’s adjoint is the sum of all the messages it receives. From the primitive L=vw\mathcal L=vw, the messages are

vˉ=Lˉw=0.761594,wˉ=Lˉv=0.\bar v = \bar{\mathcal L}\,w =-0.761594\ldots, \qquad \bar w = \bar{\mathcal L}\,v =0.

(Each of vv and ww has only one child, so a single message is the whole adjoint.) The tanh\tanh node sends bb the message

wˉ(1w2)=0.\bar w(1-w^2)=0.

The addition v=u+av=u+a has local derivative 11 toward each parent, so it sends its adjoint unchanged to both: the message vˉ=0.761594\bar v=-0.761594\ldots goes to uu, and the same message goes to aa. Since vv is the only child of uu,

uˉ=vˉ=0.761594.\bar u =\bar v=-0.761594\ldots.

Finally, u=abu=ab sends aa the message

uˉb=+0.761594,\bar u\, b =+0.761594\ldots,

and sends bb the message

uˉa=1.523188.\bar u\, a =-1.523188\ldots.

Both inputs received two messages, and each adjoint is the sum of the messages received. The two contributions to aˉ\bar a cancel:

aˉ=0.761594+0.761594=0,\bar a = -0.761594\ldots+0.761594\ldots =0,

while

bˉ=0+(1.523188)=1.523188.\bar b = 0+(-1.523188\ldots) =-1.523188\ldots.

Thus reverse mode reproduces the two-pass forward-mode answer

(a,b)L(2,1)=(0,1.523188),\nabla_{(a,b)}\mathcal L(2,-1) = (0,-1.523188\ldots)^\top,

but it obtains both coordinates in one reverse pass.

a b u = ab v = u + a w = tanh b L = vw ū·b = +0.7616 ū·a = −1.5232 v̄·1 = −0.7616 v̄·1 = −0.7616 w̄·(1 − w²) = 0 L̄·w = −0.7616 L̄·v = 0 ā = −0.7616 + 0.7616 = 0 b̄ = 0 − 1.5232 ū = −0.7616 v̄ = −0.7616 w̄ = 0 L̄ = 1 (seed)
The reverse pass at (a,b) = (2,−1). Each green arrow runs backward along a graph edge and carries the message (child adjoint) × (local derivative); a node's adjoint is the sum of the messages it receives. The two messages arriving at a cancel, giving ā = 0, while b receives 0 and −1.5232.

Real neural-network primitives act on tensors rather than scalars. A tensor is just a finite multidimensional array of numbers: a vector is a 11-tensor, and a matrix is a 22-tensor. The shape of the tensor isn’t as important as the number of scalar entries, so let us consider nodes to be labelled with vectors. Let xyx \mapsto y be one directed edge, where xx and yy are the vector labels of the nodes. We write

y=φ(x),xRm,yRq,y=\varphi(x), \qquad x\in\mathbb R^m, \quad y\in\mathbb R^q,

with φ\varphi differentiable. The Jacobian of φ\varphi at xx is the q×mq\times m matrix of partial derivatives

Jφ(x)=[φixj]1iq,1jm.J_\varphi(x) = \left[ \frac{\partial\varphi_i}{\partial x_j} \right]_{1\leq i\leq q,\,1\leq j\leq m}.

In forward mode, we compute y˙\dot y from x˙\dot x. If the input tangent x˙Rm\dot x\in\mathbb R^m is known, the output tangent is

y˙=Jφ(x)x˙,\dot y = J_\varphi(x)\,\dot x,

called a Jacobian—vector product (JVP). This is exactly the directional derivative of φ\varphi at xx in direction x˙\dot x (Section 2).

In reverse mode, we compute xˉ\bar x from yˉ\bar y. If yˉ=yLRq\bar y=\nabla_y\mathcal L\in\mathbb R^q is known, the chain rule gives

xˉ=Jφ(x)yˉ.\bar x = J_\varphi(x)^\top\bar y.

Equivalently, in row-vector notation, yˉJφ(x)\bar y^\top J_\varphi(x) is a vector—Jacobian product (VJP). Reverse-mode libraries implement a VJP rule for each primitive.

An arithmetic circuit is a computational graph whose primitives are arithmetic operations such as addition, multiplication, and division where the denominator is nonzero. Its size is its number of operation nodes. We first state the classical result for such circuits; standard AD systems extend the same idea with derivative rules for primitives such as exponential, logarithm, and tanh\tanh.

Theorem 3.2 (cheap-gradient principle; Baur—Strassen). Suppose a scalar function is evaluated by an arithmetic circuit of size ss. Its full gradient with respect to every input can be evaluated by a circuit of size O(s)O(s). Consequently, reverse-mode differentiation costs a constant multiple of the forward evaluation, independent of the number PP of inputs.

Proof sketch

Store the value produced by every node during the forward evaluation. In reverse topological order, visit each edge once. Each visit multiplies a child adjoint by one local derivative and adds the result to a parent adjoint. An arithmetic primitive has a fixed number of parents, and computing each local derivative takes a fixed amount of work. The total reverse work is therefore at most a constant times the number of forward operations. The Baur—Strassen theorem makes this construction precise for rational arithmetic circuits, circuits whose operation nodes use addition, subtraction, multiplication, and division. \square


4. Backpropagation through softmax and a neural nn-gram

We now write every step of backpropagation for a concrete model: the Bengio-style neural nn-gram language model of Lecture 2, Section 4.4, which is also the model trained in Project Step 3. The model predicts the next token from the previous kk tokens. It looks up a learned embedding vector for each context token, concatenates the kk embeddings into one vector x\mathbf x, passes x\mathbf x through a single tanh\tanh hidden layer, and applies one more affine map followed by softmax to produce a probability for each of the VV tokens in the vocabulary. The loss is the negative log of the probability assigned to the token that actually came next. Schematically,

context tokens    x  affine  u  tanh  hctx  affine  z  softmax  p    =logpy.\text{context tokens} \;\longmapsto\; \mathbf x \;\overset{\text{affine}}{\longmapsto}\; \mathbf u \;\overset{\tanh}{\longmapsto}\; \mathbf h_{\mathrm{ctx}} \;\overset{\text{affine}}{\longmapsto}\; \mathbf z \;\overset{\text{softmax}}{\longmapsto}\; \mathbf p \;\longmapsto\; \ell=-\log p_y.
tokens rows of C x hidden layer hctx h tanh units (5 drawn) logits z V units (5 drawn) a₁ a₂ a₃ p = softmax(z) ℓ = −log pᵧ look up in C concatenate tanh(W₁x + b₁) W₂hctx + b softmax backward: adjoints flow right to left zℓ = pe ctx = W₂ᵀ∇z W₂ℓ, ∇b uℓ = ctx ⊙ (1hctxhctx) = W₁ᵀ∇uℓ; ∇W₁ℓ, ∇b split into 3 blocks of length d scatter-add each block into a row of C every adjoint is computed from the one to its right and the values stored in the forward pass
The neural n-gram network of Lecture 2, Example 4.7, with the backward pass drawn underneath. Values flow left to right along the network; adjoints flow right to left through the same stages, each computed from the adjoint to its right and the stored forward values.

Section 4.1 recalls the reverse rule for the softmax cross-entropy loss, a short interlude records the local rules for affine maps and coordinatewise tanh\tanh, and Section 4.2 states the architecture precisely and assembles the rules into the complete backward pass.

4.1 Backpropagation: loss with respect to logit

Let z=(z1,,zV)RV\mathbf z=(z_1,\ldots,z_V)^\top\in\mathbb R^V be the logit vector: one unconstrained score for each of VV possible target tokens. Our goal in this subsection is to compute z\nabla_{\mathbf z} \ell.

Lecture 2 defined

pj=softmax(z)j=ezjc=1Vezc.p_j = \operatorname{softmax}(\mathbf z)_j = \frac{e^{z_j}}{\sum_{c=1}^V e^{z_c}}.

Let y{1,,V}y\in\{1,\ldots,V\} be the target-token index. Its one-hot vector eyRV\mathbf e_y\in\mathbb R^V has a 11 in coordinate yy and zeros elsewhere. Define the log-sum-exp function

lse(z)=logc=1Vezc.\operatorname{lse}(\mathbf z) = \log\sum_{c=1}^V e^{z_c}.

The single-example categorical cross-entropy loss (Lecture 2, Definition 3.2) is

(z,y)=logpy=zy+lse(z).\ell(\mathbf z,y) = -\log p_y = -z_y+\operatorname{lse}(\mathbf z).

We already computed the gradient of this loss with respect to the logits in Lecture 2, Proposition 3.4, for softmax regression. There the logits were an affine function of the features, but the computation used only the dependence of \ell on z\mathbf z, so it applies verbatim here: with p=softmax(z)\mathbf p=\operatorname{softmax}(\mathbf z),

z=pey.\boxed{ \nabla_{\mathbf z}\ell=\mathbf p-\mathbf e_y. }

Recall that the coordinates of this gradient sum to zero, because a common shift of every logit leaves the loss unchanged.

The vector pey\mathbf p-\mathbf e_y is often called the output error signal, meaning the adjoint at the logit vector from which the rest of backpropagation starts. It is not the difference between a predicted token and a token number; token indices have no numerical distance. It is a difference between two probability vectors.

Lecture 2 worked this out for the logits z=(0,log2,log3)\mathbf z=(0,\log2,\log3)^\top of Example 3.3: with the second token as target, the gradient (16,23,12)(\tfrac16,-\tfrac23,\tfrac12)^\top lowers each wrong logit by its current probability and raises the target logit by 1p21-p_2. The update is largest when the model assigns little probability to the target.

Backpropagation: derivatives of affine maps and tanh

We need only affine maps and coordinatewise tanh\tanh to propagate the output signal through the Step 3 network. Here are some useful equations for relevant derivatives.

First suppose

q=Wr+b,\mathbf q=W\mathbf r+\mathbf b,

where WRm×nW\in\mathbb R^{m\times n}, rRn\mathbf r\in\mathbb R^n, and b,qRm\mathbf b,\mathbf q\in\mathbb R^m. If the incoming adjoint is qˉ=q\bar{\mathbf q}=\nabla_{\mathbf q}\ell, then

rˉ=Wqˉ,W=qˉr,b=qˉ.\boxed{ \bar{\mathbf r}=W^\top\bar{\mathbf q}, \qquad \nabla_W\ell=\bar{\mathbf q}\,\mathbf r^\top, \qquad \nabla_{\mathbf b}\ell=\bar{\mathbf q}. }

The matrix qˉr\bar{\mathbf q}\,\mathbf r^\top is an outer product: the product of a column vector and a row vector. Its (i,j)(i,j) entry is qˉirj\bar q_i r_j, as required because qi=jWijrj+biq_i=\sum_jW_{ij}r_j+b_i.

Next suppose h=tanhu\mathbf h=\tanh\mathbf u coordinatewise. Since d(tanhs)/ds=1tanh2sd(\tanh s)/ds=1-\tanh^2s,

uˉ=hˉ(1hh).\boxed{ \bar{\mathbf u} = \bar{\mathbf h}\odot(\mathbf 1-\mathbf h\odot\mathbf h). }

The symbol \odot denotes the Hadamard product, or elementwise product: (rs)i=risi(\mathbf r\odot\mathbf s)_i=r_is_i. The 1\mathbf 1 in the formula denotes the vector of ones of the appropriate length.

These rules illustrate a general principle: the forward shapes determine the backward shapes. If WW is m×nm\times n, then its gradient must also be m×nm\times n; if r\mathbf r has length nn, then WqˉW^\top\bar{\mathbf q} has length nn. Checking shapes catches many backpropagation mistakes before checking numbers.

4.2 One complete backward pass

Let us recall the notation of the neural nn-gram model of Lecture 2 (Example 4.7). Let CRV×dC\in\mathbb R^{V\times d} be an embedding matrix. An embedding is a learned vector representation, and row CaRdC_a\in\mathbb R^d represents token aa. For a context (a1,,ak)(a_1,\ldots,a_k), an embedding lookup reads those rows and concatenates them into one column vector:

x=concat(Ca1,,Cak)Rkd.\mathbf x = \operatorname{concat}(C_{a_1},\ldots,C_{a_k})^\top \in\mathbb R^{kd}.

Concatenation joins vectors end to end. The network then computes

u=W1x+b1,hctx=tanhu,\mathbf u=W_1\mathbf x+\mathbf b_1, \qquad \mathbf h_{\mathrm{ctx}}=\tanh \mathbf u, z=W2hctx+b2,p=softmax(z),=logpy.\mathbf z=W_2\mathbf h_{\mathrm{ctx}}+\mathbf b_2, \qquad \mathbf p=\operatorname{softmax}(\mathbf z), \qquad \ell=-\log p_y.

Here

W1Rh×kd,b1Rh,W2RV×h,b2RV.W_1\in\mathbb R^{h\times kd}, \quad \mathbf b_1\in\mathbb R^h, \quad W_2\in\mathbb R^{V\times h}, \quad \mathbf b_2\in\mathbb R^V.

The vector u\mathbf u is the hidden preactivation, the value before the nonlinear tanh\tanh is applied. The vector hctx\mathbf h_{\mathrm{ctx}} is the hidden representation of this context.

lookup C[aᵢ] and concatenate x ∈ Rᵏᵈ context vector u = W₁x + b₁ hctx = tanh(u) z = W₂hctx + b₂ V logits p = softmax(z) ℓ = −log pᵧ forward: values and activations zℓ = pe u x scatter-add backward: adjoints, parameter gradients, and embedding-row updates shared matrix hidden width h one scalar loss
The forward pass follows the upper arrows. The reverse pass (backpropagation) is shown by the lower arrows.

The logit gradient, also called the output error signal, is

z=pey.\nabla_{\mathbf z}\ell =\mathbf p-\mathbf e_y.

Applying the tanh and affine rules above gives

W2=(z)hctx,b2=z,\nabla_{W_2}\ell = (\nabla_{\mathbf z}\ell)\mathbf h_{\mathrm{ctx}}^\top, \qquad \nabla_{\mathbf b_2}\ell = \nabla_{\mathbf z}\ell, hˉctx=W2z,\bar{\mathbf h}_{\mathrm{ctx}} = W_2^\top\nabla_{\mathbf z}\ell, u=hˉctx(1hctxhctx),\nabla_{\mathbf u}\ell = \bar{\mathbf h}_{\mathrm{ctx}} \odot (\mathbf 1-\mathbf h_{\mathrm{ctx}}\odot\mathbf h_{\mathrm{ctx}}), W1=(u)x,b1=u,\nabla_{W_1}\ell = (\nabla_{\mathbf u}\ell)\mathbf x^\top, \qquad \nabla_{\mathbf b_1}\ell = \nabla_{\mathbf u}\ell,

and

xˉ=W1u.\bar{\mathbf x} = W_1^\top\nabla_{\mathbf u}\ell.

Finally, split xˉRkd\bar{\mathbf x}\in\mathbb R^{kd} into kk consecutive blocks of length dd. The block for position rr is added to the gradient of row CarC_{a_r}. If the same token occurs in two context positions, both blocks are added to the same row. This operation is often called a scatter-add: values associated with indices are added back into the corresponding locations of a larger array.

The embedding matrix exhibits parameter sharing. Parameter sharing means that the same parameter participates in several parts of the computation. Every occurrence of token aa reads the same row CaC_a, so the row has fan-out across positions and examples. Its gradient is the sum of all returning contributions.

For a minibatch, each example acquires a leading batch coordinate. The same equations then use batched matrix multiplication, and parameter gradients sum or average over the batch coordinate.

Backpropagation is modular. Softmax cross-entropy knows only how to turn a target and logits into z\nabla_{\mathbf z}\ell. The second affine layer knows only how to turn z\nabla_{\mathbf z}\ell into gradients for W2,b2W_2,\mathbf b_2, and its input. Tanh knows only its local derivative. The first affine layer and embedding lookup do the same. Each module receives the sensitivity of its output and returns the sensitivity of its inputs.

This modularity is why changing one layer does not require re-deriving the entire network by hand. The AD library needs one correct local rule for the new primitive.


5. Autograd in practice

Autograd is the common name for a software system that records differentiable operations and automatically computes their derivatives. It is an implementation of automatic differentiation, not a different calculus algorithm.

5.1 What a scalar Value object stores

Project Step 3 wraps every scalar in a Value object. Conceptually, each object stores four things:

  1. data: the node’s forward value;
  2. grad: its adjoint, initialized to zero;
  3. _parents: the nodes on which it directly depends; and
  4. _backward: the local VJP rule that adds this node’s contribution to its parents.

A leaf node is a source node created directly by the user, such as a parameter or input. A root for a backward pass is the output node from which reverse traversal begins—normally the scalar loss. A topological sort is an algorithm that constructs a topological ordering. A depth-first search follows one unvisited dependency path as far as it can before returning to try another. The small engine performs such a search from the loss, appends a node after visiting its parents, and reverses the resulting list for backpropagation.

For multiplication

q=rs,q=rs,

the closure stored on qq implements

r.grad+=q.grads.data,s.grad+=q.gradr.data.r.\mathrm{grad}\mathrel{+}=q.\mathrm{grad}\,s.\mathrm{data}, \qquad s.\mathrm{grad}\mathrel{+}=q.\mathrm{grad}\,r.\mathrm{data}.

A closure is a function that retains access to values from the scope in which it was created. Here the local backward closure remembers rr, ss, and qq even after the forward multiplication has returned.

Calling loss.backward() then performs exactly Algorithm 3.1:

  1. topologically order the reachable graph;
  2. set the loss gradient to 11;
  3. traverse the order backward; and
  4. call each node’s local backward rule.

5.2 Why gradients accumulate and must be cleared

AD systems use addition for two different kinds of accumulation.

  • Within one graph, fan-out creates several paths to the same node.
  • Across several backward calls, a user may intentionally sum gradients from several losses or microbatches. A microbatch is one small part of a larger logical batch processed separately to save memory.

For this reason, PyTorch normally adds into .grad rather than replacing it. A training loop that wants a fresh minibatch gradient must clear old parameter gradients before the next backward pass. Forgetting this step silently changes the update into a sum over the current and previous minibatches.

The parameter update itself should not become part of the differentiated model computation. PyTorch’s torch.no_grad() context temporarily disables graph recording. A tensor is detached when it shares numerical data with a computation but is treated as having no derivative connection to that computation. Detaching in the middle of a model accidentally cuts the backward path; detaching a diagnostic value or disabling recording during the parameter update is intentional.

Algorithm 5.1 (the autograd training cycle). Repeat:

  1. Forward: evaluate a scalar minibatch loss while recording the graph.
  2. Clear: set parameter gradients from the previous iteration to zero or to “not yet allocated.”
  3. Backward: seed the loss adjoint with 11 and reverse the graph.
  4. Update: with graph recording disabled, replace each parameter by parameter minus learning rate times gradient.

The next forward pass builds a new graph from the updated parameter values.

5.3 Three independent checks

A derivative implementation is most convincing when independent methods agree.

  1. Hand calculation: derive a small graph with the chain rule.
  2. Finite differences: compare selected coordinates with nearby loss evaluations.
  3. Independent systems: compare your scalar engine, finite differences, and PyTorch on one input to the Step 2 diamond network.

For scalars gg and g^\widehat g, the absolute error is gg^|g-\widehat g|. A scale-aware comparison often uses the relative error

gg^max(1,g,g^).\frac{|g-\widehat g|} {\max(1,|g|,|\widehat g|)}.

The denominator avoids declaring two tiny, harmless numbers wildly different. No single numerical error threshold fits every data type and graph, but Step 3’s small float64 checks should agree much more closely than a large low-precision training run.

Common failure patterns are diagnostic:

  • correct chain graphs but incorrect fan-out graphs suggest assignment instead of addition;
  • gradients missing from an early node suggest a wrong traversal order or an accidental detach;
  • gradients exactly multiplied by the number of iterations suggest that old gradient values were not cleared;
  • plausible AD gradients that disagree with finite differences only at extreme step sizes suggest numerical, rather than chain-rule, error.

6. Connection to Project Step 3

6.1 From the scalar engine to the neural model

Step 3 first validates Algorithm 3.1 on L=(ab+a)tanhb\mathcal L=(ab+a)\tanh b. It then rebuilds one forward pass of the Step 2 diamond classifier from scalar Value\texttt{Value} objects and differentiates its loss with respect to the input. The closed formula, finite differences, your engine, and PyTorch all produce the same gradient. This closes the loop between a network assembled by hand and an algorithm that differentiates its computational graph.

The project next trains the neural nn-gram model of Section 4 with minibatch SGD. Its parameter count is

Vd+(kd)h+h+hV+V.Vd+(kd)h+h+hV+V.

The five terms count the embedding matrix CC, first-layer weights W1W_1, first-layer bias b1b_1, second-layer weights W2W_2, and second-layer bias b2b_2. The count grows linearly with context length kk, whereas an unrestricted next-token matrix indexed by every length-kk context has Vk+1V^{k+1} entries.

The training set is the set used to update parameters. A disjoint validation set is held out from parameter updates and used to estimate performance on unseen examples during model development. Training loss and validation loss are the same loss formula averaged over these two sets.

6.2 Reading the context-length sweep

The reference run in Project Step 3 reports:

context length kkparameterstraining lossvalidation lossbits/character
111,6012.47062.48053.579
315,6971.91711.98212.860
519,7931.90542.00182.888
825,9371.93722.03292.933

A nat is the information unit obtained when cross-entropy uses natural logarithms. A bit is the corresponding unit for base-22 logarithms; dividing loss in nats by log2\log2 gives bits per predicted character.

When k=1k=1, the model sees only the immediately preceding character, just as a bigram model does. The hidden network is wide enough to assign an independent logit vector to each of the finitely many input characters, so its model family contains the bigram behavior. Its validation loss should therefore be near the earlier bigram result when optimization succeeds. A model family, or hypothesis class, is the set of functions obtainable as its parameters vary.

Moving from k=1k=1 to k=3k=3 supplies useful context and sharply improves validation loss. Moving to k=5k=5 or k=8k=8 does not help at the fixed hidden width and training budget. Model capacity is the range and complexity of functions a model family can represent. Flattening the longer vector spreads the fixed hidden capacity across more inputs and also makes the optimization problem harder.

An optimization bottleneck occurs when the training procedure fails to find a low-loss member of the model family. A representational bottleneck occurs when the family itself cannot express the needed function. Both can produce underfitting, meaning that important patterns remain unfitted even in the training data. Overfitting has a different signature: training loss improves while validation performance worsens because the model has specialized too strongly to the training set.

The k=8k=8 run has worse training loss than the k=3k=3 run, so its poorer validation result cannot be explained by classic overfitting alone. The small k=5k=5 training improvement leaves room for a mixture of effects, but the matrix as a whole points toward representational and optimization bottlenecks rather than a large train—validation gap.

More available context is not automatically more usable context. The Step 3 model flattens all kk embeddings into one fixed vector and gives every context the same pattern of connections. A longer window increases the amount the first layer must disentangle, but does not give it a way to select which position matters for this particular prediction.

Lecture 4 introduces attention: a mechanism that forms content-dependent weighted combinations of context positions. The weights can change from one input to another, allowing the model to retrieve relevant earlier information without treating the entire context as one undifferentiated address.

6.3 What to carry forward

The scalar engine is intentionally small, but it exposes principles that remain true in large systems:

  • computations form a DAG for one forward pass;
  • local VJP rules compose into a global gradient;
  • fan-out requires addition;
  • reverse order is a dependency requirement, not a coding preference;
  • tensor backpropagation avoids materializing giant Jacobians;
  • minibatching changes the gradient estimator, not the chain rule; and
  • clearing gradients and excluding updates from the graph are explicit parts of the training algorithm.

Summary

A differentiable program can be represented as a computational graph. Forward mode propagates a tangent from selected inputs toward all later nodes and naturally computes Jacobian—vector products. Computing every coordinate of a scalar-output gradient this way requires one pass per parameter.

Reverse mode seeds the scalar loss with adjoint 11 and propagates sensitivities in reverse topological order. Each edge contributes a child adjoint times a local derivative, and fan-out contributions are added. This produces every parameter derivative in one reverse pass. The cheap-gradient principle says the arithmetic cost is a constant multiple of the forward computation, while stored or recomputed activations account for the memory cost.

For softmax cross-entropy, the logit adjoint is pey\mathbf p-\mathbf e_y. Affine, tanh\tanh, and embedding-lookup VJPs route this signal through the Bengio neural nn-gram model. The same rules operate on scalars in your Value engine and on tensors in PyTorch.

In practice, training pairs backpropagation with minibatch stochastic gradient descent: a random batch of BB examples supplies a cheap estimate of the full-corpus gradient that is correct on average, and the parameters move against it by a learning rate. Batch size, learning-rate schedule, and hardware throughput are tuned together; Lecture 6 takes up these practical choices.

Exercises (paired with Step 3)

A star marks a Project Step 3 task.

  1. ★ For L=(ab+a)tanhb\mathcal L=(ab+a)\tanh b at (a,b)=(2,1)(a,b)=(2,-1), reproduce Example 2.2 and the reverse pass of Section 3 without looking at the tables. Label every local derivative and every accumulated contribution.
  2. ★ Implement scalar Value primitives for addition, multiplication, tanh\tanh, exponential, logarithm, and powers. Explain which forward values each backward closure must retain.
  3. Construct the smallest computational graph you can find on which replacing addition by assignment during reverse accumulation gives a wrong derivative. Give values for which the wrong answer does not accidentally equal the right one.
  4. Give a DAG with two valid topological orderings. Reverse one valid ordering and verify Algorithm 3.1. Then exhibit an invalid reverse order and identify the contribution it loses.
  5. Compute the directional derivative Lr\nabla\mathcal L^\top r of the running example at (a,b)=(2,1)(a,b)=(2,-1) in direction r=(3,2)r=(3,-2)^\top in two ways: one forward-mode pass seeded with (a˙,b˙)=r(\dot a,\dot b)=r (a seed need not be a standard basis vector!), and the dot product of rr with the gradient you already know.
  6. ★ Re-derive the softmax cross-entropy gradient z=pey\nabla_{\mathbf z}\ell=\mathbf p-\mathbf e_y of Lecture 2, Proposition 3.4. Verify it with your scalar engine and with PyTorch for a randomly chosen five-logit vector.
  7. Derive the affine-layer VJP coordinate by coordinate. Check the shapes of all three outputs rˉ\bar r, W\nabla_W\ell, and b\nabla_b\ell.
  8. For a context (a,b,a)(a,b,a), write explicitly how the three blocks of xˉ\bar{\mathbf x} scatter-add into the embedding-matrix gradient. Which rows are exactly zero for this one example?
  9. Count the activations stored by an LL-layer width-hh MLP on a batch of size BB. Propose a checkpointing scheme and state what it stores and recomputes.
  10. Show that permuting the hh hidden coordinates of a one-hidden-layer network, together with the corresponding rows and columns of its weight matrices, preserves the represented function.
  11. ★ At x=(0.5,0.25)\mathbf{x}=(0.5,0.25)^\top and γ=4\gamma=4, use the Step 2 diamond network with target “inside.” Derive /x1=/x2=2γp(outsidex)\partial\ell/\partial x_1=\partial\ell/\partial x_2 =2\gamma p(\mathrm{outside}\mid\mathbf{x}). Reproduce both derivatives with your scalar engine, central finite differences, and PyTorch autograd, and report the maximum absolute disagreement.
  12. In Algorithm 5.1, deliberately omit gradient clearing for three identical forward/backward passes without updating parameters. Predict the result before running it, then explain the observed gradient values.
  13. The k=8k=8 reference model has worse training and validation loss than the k=3k=3 model. Explain why this evidence points toward underfitting rather than overfitting, and name at least two interventions that would distinguish an optimization bottleneck from a representational bottleneck.

Pointers

Baydin, Pearlmutter, Radul, and Siskind, Automatic Differentiation in Machine Learning: a Survey; Griewank and Walther, Evaluating Derivatives; Baur and Strassen (1983); Robbins and Monro (1951); Bengio, Ducharme, Vincent, and Jauvin, A Neural Probabilistic Language Model (2003); and Olah’s backpropagation essay. See the resources page, Project Step 3, and Lecture 2.