In our earlier article on how neural networks learn, we walked through the basic training loop: the model makes a prediction, we measure how wrong it is using a loss function, we tweak the weights, and then we repeat. To adjust each weight correctly, we need to know whether making it bigger or smaller will reduce the loss. One simple way to figure this out is to nudge a weight slightly up or down, recalculate the loss, and see if it improved. The problem is that real models can have millions or billions of these weights. Testing every single one individually would be incredibly slow.

This is where derivatives come in. A derivative describes how quickly a function’s output changes as we change an input, at a particular input value. In this article, we’ll mostly use the loss as our function and examine how it changes with the model’s parameters, keeping the training data fixed. The same idea applies to other functions, including the individual operations inside the model.

For each weight, the derivative tells us how sensitive the loss is to a small change in that weight, assuming all the other weights stay the same. This gives us a direction without testing each possible change: a positive derivative tells us to decrease the weight to reduce the loss locally; a negative derivative tells us to increase it.

Here’s how the ideas fit together:

  • Finite differences: estimate a derivative using two function evaluations at nearby inputs.
  • Limit definition: mathematically define the exact derivative as the interval shrinks toward zero.
  • Symbolic differentiation: use analytical derivative rules to derive a formula for the derivative.
  • Backpropagation (reverse-mode autodiff): combine numerical local derivatives backward through a computation graph to calculate gradients.

We’ll build a small Python engine for reverse-mode automatic differentiation, check its gradients with finite differences, and use it to train a model.

A familiar rate of change

Consider the relationship y=3xy=3x. If xx increases by 11, yy increases by 33. If xx increases by 0.10.1, yy increases by 0.30.3. Whatever the starting value, the change in yy is three times the change in xx:

Δy=3 Δx\Delta y=3\,\Delta x

The symbol Δ\Delta means a change between two values. You can recognize this rate of change without knowing calculus. The derivative gives it a name and notation:

dydx=3\frac{dy}{dx}=3

We read dy/dxdy/dx as the derivative of yy with respect to xx. The denominator tells us which variable we change: xx. The numerator tells us whose response we measure: yy. So dy/dx=3dy/dx=3 means that changing xx by a small amount changes yy by three times that amount.

The function y=3xy=3x tells us the output value for a given input. Its derivative tells us how quickly that output changes as the input changes. Here, the rate is always 33, so Δy=3 Δx\Delta y=3\,\Delta x is exact, even for large changes. The graph is a straight line with the same slope everywhere.

For y=x2y=x^2, the derivative is 2x2x, which we’ll derive shortly. At x=1x=1, the rate is 22; at x=5x=5, it is 1010. Because the rate changes as we move, using the slope at the starting point to predict an output change generally gives only a local approximation. Let’s explore this distinction using a loss function whose slope changes with its input.

Finding a direction by testing nearby values

Suppose our loss depends on one parameter aa:

L(a)=a2+3.L(a)=a^2+3.

At a=5a=5, the loss is 2828. Should we increase or decrease aa to reduce it?

We could try both directions:

Parameter aaLoss a2+3a^2+3
4.94.927.0127.01
5.05.028.0028.00
5.15.129.0129.01

Decreasing aa by 0.10.1, from 55 to 4.94.9, reduced the loss from 2828 to 27.0127.01. Increasing aa by 0.10.1, from 55 to 5.15.1, raised the loss from 2828 to 29.0129.01. These evaluations suggest that a small decrease from a=5a=5 will reduce the loss.

The direction that reduces the loss depends on the current value of aa. If we started at a=−5a=-5, the loss would also be 2828, but increasing aa to −4.9-4.9 would lower it to 27.0127.01. Decreasing aa to −5.1-5.1 would raise it to 29.0129.01. For this function, moving toward zero reduces the loss: from a positive value we should decrease aa, and from a negative value we should increase it.

Same loss, opposite directions
Loss L(a) = a² + 3 against Parameter aThe loss curve is U-shaped with a minimum at a = 0, where the loss is 3. At a = −5 and a = 5, the loss is 28. Arrows follow the curve downward toward zero: increase a from −5, but decrease a from 5.-60-310020330640Loss L(a) = a² + 3Parameter a(−5, 28)(5, 28)Increase a →← Decrease aMinimum (0, 3)
The green arrows show moves that lower the loss. From either starting point, moving toward zero helps; the direction depends on which side of zero you start.

This is why we describe the direction as local: it tells us which way to move from the current parameter value for a small change. The direction can change as we move, and the size of the change matters too.

Let’s now use the same values to estimate how quickly the loss changes as we change the parameter. We can do this using a method called finite differences. A finite-difference estimate is a numerical approximation of a function’s derivative, or rate of change, obtained by dividing the difference between its values at two nearby points by the distance between those points.

There are three common types. The difference is which points we choose relative to the point where we want to estimate the derivative:

  1. Forward difference: compare the current point with a point ahead of it.
  2. Backward difference: compare the current point with a point behind it.
  3. Central difference: compare two points equally spaced on opposite sides of the current point.

For example, we could estimate a car’s current speed by comparing its distance reading now with the reading a moment ago and dividing by the elapsed time. This is a backward difference: we use a past reading because a future one isn’t available yet.

We’ll use forward differences for our main walkthrough. Let’s start by estimating the slope of our loss at a=5a=5, then compare the result with backward and central differences.

Writing the parameter change as hh, the forward-difference formula is:

L(a+h)−L(a)h.\frac{L(a+h)-L(a)}{h}.

With h=0.1h=0.1, we compare the loss at the current value a=5a=5 with the loss at a+h=5.1a+h=5.1:

L(5.1)−L(5)5.1−5=29.01−280.1=10.1.\frac{L(5.1)-L(5)}{5.1-5} =\frac{29.01-28}{0.1} =10.1.

Our forward-difference estimate of 10.110.1 approximates the slope at a=5a=5. It uses the table’s two loss values, L(5)=28L(5)=28 and L(5.1)=29.01L(5.1)=29.01. Because the estimate is positive, it indicates that decreasing aa should reduce the loss.

This ratio is the average change in loss per unit change in the parameter over that interval: we divide the total loss change by the total parameter change. The calculation uses only the two endpoint values. Those values alone don’t tell us how the function behaves between them: the loss could change more slowly in one part of the interval and more quickly in another. So 10.110.1 describes the rate across the whole interval, rather than the exact rate at every point within it.

Here we know the formula, so we can plot the curve between the endpoints and see that distinction. The graph below uses a wider interval, from a=1a=1 to a=5a=5, to make the difference visible. The dashed line connecting the endpoints has the average slope; the green curve shows how the loss actually changes. The shaded triangle shows the total parameter change Δa=4\Delta a=4 and loss change ΔL=24\Delta L=24, so the average slope is 24/4=624/4=6.

The average slope uses only the endpoints
Average slope as change in loss divided by change in parameterA dashed blue secant passes through (1, 4) and (5, 28). A shaded right triangle shows the parameter change Δa = 4 and loss change ΔL = 24, giving an average slope of 6. The midpoint is (3, 12). An orange secant joins (1, 4) to (3, 12), with average slope 4. A purple secant joins (3, 12) to (5, 28), with average slope 8. The green curve is L(a) = a² + 3.Lossa(3, 12)3Δa = 4ΔL = 24(1, 4)1(5, 28)5
Whole intervalFirst halfSecond halfLoss curve

We can compare the average rate over the whole interval with the rates over its two halves. Splitting at a=3a=3, where the loss is 1212, gives:

IntervalParameter changeLoss changeAverage rate
Whole interval1→51 \to 54→284 \to 28(28−4)/(5−1)=6(28-4)/(5-1)=6
First half1→31 \to 34→124 \to 12(12−4)/(3−1)=4(12-4)/(3-1)=4
Second half3→53 \to 512→2812 \to 28(28−12)/(5−3)=8(28-12)/(5-3)=8

The overall average of 66 summarizes the total change; it doesn’t mean the loss rises at a constant rate throughout the interval.

Now let’s see what backward and central differences produce for the same loss L(a)=a2+3L(a)=a^2+3, at the same point a=5a=5, using the same step size h=0.1h=0.1. Only the choice of sample points changes.

MethodFormulaEstimate at a=5a=5, h=0.1h=0.1
ForwardL(a+h)−L(a)h\dfrac{L(a+h)-L(a)}{h}L(5.1)−L(5)0.1=10.1\dfrac{L(5.1)-L(5)}{0.1}=10.1
BackwardL(a)−L(a−h)h\dfrac{L(a)-L(a-h)}{h}L(5)−L(4.9)0.1=9.9\dfrac{L(5)-L(4.9)}{0.1}=9.9
CentralL(a+h)−L(a−h)2h\dfrac{L(a+h)-L(a-h)}{2h}L(5.1)−L(4.9)0.2=10.0\dfrac{L(5.1)-L(4.9)}{0.2}=10.0

Notice how central difference divides by 2h2h, while forward and backward differences divide by hh. The denominator is the distance between the two sample points. For central difference, those points are a−ha-h and a+ha+h, so:

Δa=(a+h)−(a−h)=a+h−a+h=2h.\Delta a=(a+h)-(a-h)=a+h-a+h=2h.

At a=5a=5 and h=0.1h=0.1:

Δa=(5+0.1)−(5−0.1)=5.1−4.9=0.2.\Delta a=(5+0.1)-(5-0.1)=5.1-4.9=0.2.

Each point is 0.10.1 from the current point, but they are 0.20.2 apart. Forward and backward differences use the current point as one endpoint, so their distances are (a+h)−a=h(a+h)-a=h and a−(a−h)=ha-(a-h)=h, respectively.

Central difference gives 1010 in this example. We’ll derive that slope directly next, and return to the accuracy of central differences when we use them to check our engine.

With millions of parameters, estimating a slope this way for each one would require millions of additional loss evaluations. To avoid those evaluations, we need a mechanism that gives us the slope directly.

From finite differences to the derivative

Finite differences measure an average rate of change over an interval. To get the slope at the current parameter value, we consider what that rate approaches as the interval shrinks toward zero. For our loss function, we can work out this limiting value algebraically and obtain a formula for the slope that we can evaluate directly, without testing nearby parameter values.

Let’s first see what shrinking the interval does to our forward-difference estimate. At a=5a=5, we used h=0.1h=0.1 and got 10.110.1. Reducing hh to 0.0010.001 gives:

L(5.001)−L(5)0.001=28.010001−280.001=10.001.\frac{L(5.001)-L(5)}{0.001} =\frac{28.010001-28}{0.001} =10.001.

With h=0.001h=0.001, our estimate is 10.00110.001. As we shrink hh further toward zero, the estimate approaches 1010.

The value we approach is called the derivative. It tells us how quickly a function changes at a particular point. In our case, the function is the loss L(a)L(a), and the point is a=5a=5. We write the derivative as L′(a)L'(a) or dL/dadL/da. Using the forward-difference form we started with, its definition is:

L′(a)=lim⁡h→0L(a+h)−L(a)h.L'(a)=\lim_{h\to0}\frac{L(a+h)-L(a)}{h}.

Using our earlier change notation, Δa=h\Delta a=h and ΔL=L(a+h)−L(a)\Delta L=L(a+h)-L(a). The ratio ΔL/Δa\Delta L/\Delta a is the average rate over an interval; dL/dadL/da, written with the Latin letter dd, is its limit as that interval shrinks toward zero.

For our loss function at a=5a=5, this limit is 1010, so L′(5)=10L'(5)=10. For a differentiable function, all three forms—forward, backward, and central—approach the same derivative value as hh approaches zero.

For L(a)=a2+3L(a)=a^2+3, we can simplify that comparison algebraically. For nonzero hh:

L(a+h)−L(a)h=(a+h)2+3−(a2+3)h=2ah+h2h=2a+h.\begin{aligned} \frac{L(a+h)-L(a)}{h} &=\frac{(a+h)^2+3-(a^2+3)}{h}\\ &=\frac{2ah+h^2}{h}\\ &=2a+h. \end{aligned}

As hh approaches zero, the ratio approaches 2a2a, giving us the derivative rule:

L′(a)=2a,L′(5)=10.L'(a)=2a,\qquad L'(5)=10.

In this way, we build a derivative function: L′(a)=2aL'(a)=2a gives the slope of LL at any value of aa. When we evaluate it at a particular point, such as a=5a=5, we get the slope at that point: L′(5)=10L'(5)=10. We can calculate this directly, without evaluating L(5+h)L(5+h) to discover the slope. Differentiation gives us a rule for calculating the local rate of change.

We now have a formula for the slope. Next, we’ll use it to choose a direction, then compare deriving such a formula with computing the derivative numerically through the function’s operations.

Using the derivative to choose a direction

The derivative tells us how sensitive the loss is to a small parameter change. At a=5a=5, the derivative L′(5)=10L'(5)=10 means that a small change in aa produces a change in loss approximately 1010 times as large:

ΔL≈10 Δa.\Delta L\approx10\,\Delta a.

The derivative gives the instantaneous rate of change at a point. Another way to understand it is as the best constant rate for approximating changes near that point. Here, L′(5)=10L'(5)=10 is exact. The approximation comes from using that same rate throughout a nonzero parameter change, even though the slope changes as we move. Geometrically, this gives the tangent line: the best local linear approximation to the loss curve.

For example, increasing aa by 0.010.01 means Δa=0.01\Delta a=0.01. Substituting this into the formula gives:

ΔL≈10×0.01=0.1.\Delta L\approx10\times0.01=0.1.

The positive result means the loss increases by approximately 0.10.1. Decreasing aa by 0.010.01 means Δa=−0.01\Delta a=-0.01:

ΔL≈10×(−0.01)=−0.1.\Delta L\approx10\times(-0.01)=-0.1.

The negative result means the loss decreases by approximately 0.10.1.

We can choose the local direction from the derivative’s sign without testing both possibilities. At a=−5a=-5, the derivative is −10-10, so a small increase in aa reduces the loss. At a=0a=0, the derivative is zero; for this particular function, that is its minimum. A zero derivative does not establish a minimum for every function.

Visualizing the slope

We can visualize the difference between a finite-difference estimate and a derivative on the loss curve. Draw a straight line through two points on the curve: this is a secant, and its slope gives the average rate of change between them. Keep the first point fixed and move the second toward it. The secant approaches the tangent at the first point, whose slope is the derivative.

In the widget below:

  • The blue curve is the loss function L(a)=a2+3L(a)=a^2+3.
  • The orange secant shows the finite-difference estimate, using two points on the curve.
  • The purple tangent shows the exact derivative at the current value of aa.

At the initial settings a=2a=2 and h=0.8h=0.8, the orange line’s slope is 4.84.8, while the purple line’s slope is 44. Shrinking hh brings the orange slope closer to the purple one.

Set Parameter a to 55 and reduce Small change h to 0.0010.001 below to watch the orange secant approach the purple tangent. At a=5a=5, the slope estimate becomes 10.00110.001, close to the derivative 1010. Move Parameter a from 55 to −5-5 to see the direction of improvement reverse.

The slope gives the local direction
L(a) = a² + 3 against Parameter aAt a=2, the loss is 7 and the tangent slope is 4. With h=0.8, the secant slope is 4.8.-60-310.75021.5332.25643L(a) = a² + 3Parameter a
Tangent: exact derivativeSecant: finite difference
Exact derivative4L′(a) = 2a
Finite difference4.8(L(a + h) − L(a)) / h

The derivative describes a local change. At a=5a=5, decreasing all the way to a=−6a=-6 would increase the loss from 2828 to 3939, despite starting in the suggested direction. The size of the update matters too.

Let’s compare the derivative’s approximation with the exact change in loss. Using the original function L(a)=a2+3L(a)=a^2+3, we can calculate the exact change directly by subtracting the original loss from the new loss:

ΔL=L(a+Δa)−L(a)=(a+Δa)2+3−(a2+3)=a2+2aΔa+(Δa)2+3−a2−3=2aΔa+(Δa)2.\begin{aligned} \Delta L &= L(a+\Delta a)-L(a) \\ &= (a+\Delta a)^2+3-(a^2+3) \\ &= a^2+2a\Delta a+(\Delta a)^2+3-a^2-3 \\ &= 2a\Delta a+(\Delta a)^2. \end{aligned}

The derivative approximation, ΔL≈L′(a)Δa=2aΔa\Delta L\approx L'(a)\Delta a=2a\Delta a, keeps only the first term. The exact change also includes (Δa)2(\Delta a)^2, which becomes small relative to the first term for sufficiently small changes when a≠0a\ne0. For the move from a=5a=5 to a=−6a=-6, we have Δa=−11\Delta a=-11: the derivative predicts 10×(−11)=−11010\times(-11)=-110, but the exact change is −110+121=11-110+121=11. That is why the loss rises from 2828 to 3939 despite the initial direction pointing downhill.

Evaluating a derivative at one point tells us the local sensitivity; it does not locate every minimum elsewhere on the curve. For a neural network, the gradient likewise describes the loss near the current parameter values, while finding useful parameter values requires further updates and evaluations.

Three ways to calculate or estimate a derivative

So far, we’ve used two approaches to find the slope of our loss function: estimating it from nearby values and deriving a formula we can evaluate directly. These are called finite differences and symbolic differentiation.

Neural-network engines use a third approach: automatic differentiation, or autodiff. This is the approach we’ll implement in our Python engine.

Symbolic differentiation produces a derivative expression that we can then evaluate. It can use the chain rule without expanding the original function, and software can perform the symbolic work for us. Our autodiff engine instead evaluates local derivative rules using recorded values and accumulates numerical gradients. The distinction is what we compute—an expression or numerical sensitivities—not whether we use calculus rules, expand brackets, or work by hand. Finite differences provide a separate numerical approximation that we can use to check the engine.

A derivative is the quantity we want; these are three ways to estimate or calculate it. All three methods can find the derivative of the same function at the same point.

Let’s compare these three approaches using the same loss function, L(a)=a2+3L(a)=a^2+3, at a=5a=5:

MethodWhat it usesCalculation at a=5a=5Result
Finite differencesOutputs at nearby inputs(L(5.001)−L(5))/0.001(L(5.001)-L(5))/0.001Approximately 10.00110.001
Symbolic differentiationRules applied to an expressionDerive L′(a)=2aL'(a)=2a, then substitute 551010
Automatic differentiationThe executed operations and their local derivative rulesCombine the rules for multiplication and addition in a * a + 3, using a=5a=51010

Autodiff relies on knowing the operations that produced the output. A finite table of input–output observations alone does not uniquely determine the function between those points or its exact derivatives. A model, however, is a program: its operations are known even when its training data arrive as a table.

All three methods also work for functions with many inputs. To see why neural-network engines use autodiff, consider how much larger their calculations are than our example.

A neural network is a large function built from many repeated, connected mathematical operations: multiplication, addition, and activation functions. Its parameters are the weights and biases used in those operations. Results pass through successive layers, creating a nested calculation that eventually produces a prediction. The loss function then compares that prediction with the target.

For a fixed training batch, we can treat this entire prediction-and-loss calculation as one function of the parameters:

L(w1,w2,…,wn).L(w_1,w_2,\ldots,w_n).

Here, the wiw_i represent all the trainable parameters, including weights and biases. Our example L(a)=a2+3L(a)=a^2+3 has one parameter and a few operations; a neural network’s loss can depend on millions or billions of parameters.

The current point is then the current combination of all those parameter values. We want to know how sensitive the loss is to each parameter while the others stay fixed. Each of these rates is a partial derivative; together, they form the gradient.

Estimating this gradient with finite differences would require additional loss evaluations for each parameter. Reverse-mode autodiff computes all these partial derivatives efficiently for a loss that produces a single number: it uses one forward computation followed by a backward pass. The engine combines the individual derivative rules through the chain rule, without generating one large symbolic expression for the whole network. This is the approach used in backpropagation.

To understand how that backward pass works, we’ll first look at the derivative rules for individual operations, then see how the chain rule connects them.

From local derivatives to the chain rule

We can associate mathematical operations with derivative rules that describe how their outputs respond to small changes in their inputs. These rules are the building blocks for calculating how each parameter affects the final loss.

During training, we need each parameter’s individual effect to decide how to update it. Holding the other parameters fixed isolates that effect; changing several at once would mix their effects together. This is how we calculate each derivative, but it doesn’t require us to update only one parameter at a time. We can use the individual derivatives to update all the parameters together.

Earlier, we wrote dy/dxdy/dx to describe how yy changes when we change xx. Now our operations have two independent inputs, xx and yy, and an output zz. To measure how zz changes when we vary xx while holding yy fixed, we use a partial derivative, written ∂z/∂x\partial z/\partial x. We read this as 'the partial derivative of zz with respect to xx'. The order means the same thing as before; the symbol ∂\partial indicates that we’re varying one input while holding the others fixed.

It doesn’t mean we ignore the value of yy; it means we hold that value fixed while changing xx. For example, if z=xyz=xy, the derivative with respect to xx is yy, because yy is a constant multiplier of xx. Its value still determines how strongly a change in xx affects zz.

Let’s see how changing xx affects addition and multiplication at x=3x=3 and y=4y=4, with yy held fixed. The table shows each operation’s derivative rule, its value at these inputs, and a finite-difference check that increases only xx by 0.0010.001:

OperationDerivative
with respect to xx
At x=3,y=4x=3,y=4Finite-difference check
z=x+yz=x+y∂z/∂x=1\partial z/\partial x=111((3.001+4)−(3+4))/0.001=1((3.001+4)-(3+4))/0.001=1
z=xyz=xy∂z/∂x=y\partial z/\partial x=y44((3.001×4)−(3×4))/0.001=4((3.001\times4)-(3\times4))/0.001=4

Now we reverse the roles: vary yy while keeping xx fixed. This gives the partial derivative with respect to yy, written ∂z/∂y\partial z/\partial y. The checks now increase only yy by 0.0010.001:

OperationDerivative
with respect to yy
At x=3,y=4x=3,y=4Finite-difference check
z=x+yz=x+y∂z/∂y=1\partial z/\partial y=111((3+4.001)−(3+4))/0.001=1((3+4.001)-(3+4))/0.001=1
z=xyz=xy∂z/∂y=x\partial z/\partial y=x33((3×4.001)−(3×4))/0.001=3((3\times4.001)-(3\times4))/0.001=3

The tables above demonstrate that for addition, changing either input changes the output by the same amount, so both partial derivatives are 11. For multiplication, the change is scaled by the other input: ∂z/∂x=y\partial z/\partial x=y and ∂z/∂y=x\partial z/\partial y=x.

The finite-difference checks recalculate zz after changing each input separately. The derivative rules need no such extra evaluations: using the existing inputs x=3x=3 and y=4y=4, we obtain both multiplication derivatives directly, ∂z/∂x=4\partial z/\partial x=4 and ∂z/∂y=3\partial z/\partial y=3.

The derivative rules for addition and multiplication shown in the tables are called local because each describes how one operation’s output changes when one of its inputs changes. To follow an input’s effect through a longer calculation, we need to account for how those operations are connected.

Rules for differentiating combined functions

We’ve examined addition and multiplication with independent inputs. But an operation’s inputs can themselves be functions of the same variable. There are three differentiation rules we’ll use to handle these combinations. Let ff and gg be differentiable functions of xx:

RuleHow the functions combineDerivative with respect to xx
Sum rulef(x)+g(x)f(x)+g(x)f′(x)+g′(x)f'(x)+g'(x)
Product rulef(x)g(x)f(x)g(x)f′(x)g(x)+f(x)g′(x)f'(x)g(x)+f(x)g'(x)
Chain rulef(g(x))f(g(x))f′(g(x))g′(x)f'(g(x))g'(x)

The sum rule adds the rates of change of the two terms. It gives another way to differentiate x2+3x^2+3: the derivative of x2x^2 is 2x2x, and the constant 33 contributes zero.

The product rule accounts for changes in both factors. One contribution comes from the first factor changing, scaled by the current value of the second; the other comes from the second factor changing, scaled by the current value of the first. Multiplying two functions does not mean multiplying their derivatives.

The chain rule applies to function composition: one function’s output becomes another function’s input. It multiplies the outer function’s derivative, evaluated at the intermediate value g(x)g(x), by the inner function’s derivative.

Addition, multiplication, and composition are different ways to combine functions. A larger calculation can use all three. Let’s first follow a change through one path, then see what happens when the same input affects the output through two paths.

Following one path

Suppose we square xx to get an intermediate value aa, then multiply aa by 33 to get zz:

x ⟶ a=x2 ⟶ z=3ax\ \longrightarrow\ a=x^2\ \longrightarrow\ z=3a

We want to know how changing xx affects the final output zz. One approach is to substitute a=x2a=x^2 into z=3az=3a, writing the output directly in terms of the input:

z=3x2⟹dzdx=6xz=3x^2\qquad\Longrightarrow\qquad\frac{dz}{dx}=6x

The chain rule gives us the same answer using the two relationships we already know. The first function’s derivative tells us how the intermediate value changes; the second function’s derivative tells us how that intermediate change affects the final output:

dadx=2x,dzda=3\frac{da}{dx}=2x,\qquad\frac{dz}{da}=3

Multiplying these rates along the path gives:

dzdx=dzdadadx=3⋅2x=6x\frac{dz}{dx}=\frac{dz}{da}\frac{da}{dx}=3\cdot2x=6x

At x=3x=3, the two local slopes are 66 and 33, giving a combined slope of 1818. A small change hh in xx therefore changes aa by approximately 6h6h, which changes zz by approximately 3×6h=18h3\times6h=18h.

Our autodiff engine will record these operations and combine their numerical local derivatives: at x=3x=3, it will multiply 3×63\times6 to obtain 1818.

Following two paths

Now let’s use xx in two places: first add it to yy to produce aa, then multiply aa by xx:

a=x+y,z=ax.a=x+y,\qquad z=ax.

At x=3,y=4x=3,y=4, the forward computation gives a=7a=7 and z=21z=21. Changing xx now affects zz in two ways: it changes the intermediate value aa, and it changes the xx that multiplies aa. Our derivative must account for both effects while keeping yy fixed. For this small expression, we can find the answer by expanding it and differentiating directly:

z=(x+y)x=x2+xy⟹∂z∂x=2x+y=10.z=(x+y)x=x^2+xy \quad\Longrightarrow\quad \frac{\partial z}{\partial x}=2x+y=10.

Using the sum rule to differentiate each term with respect to xx, we get 2x2x from x2x^2 and yy from xyxy. At x=3,y=4x=3,y=4, this gives 2(3)+4=102(3)+4=10. The product rule gives the same result without expanding: differentiating the two factors in (x+y)x(x+y)x gives 1⋅x+(x+y)⋅1=3+7=101\cdot x+(x+y)\cdot1=3+7=10. This tells us that, starting at x=3x=3 and keeping y=4y=4 fixed, increasing xx by a tiny amount hh increases zz by approximately 10h10h.

The chain rule lets us obtain the same derivative by combining the local derivatives along each path from an input to the final output. We multiply the derivatives along each path, as in the previous example, and then add the two contributions:

  • Indirect effect, x→a→zx\to a\to z: changing xx affects zz through the intermediate value aa. Increasing xx by hh increases a=x+ya=x+y by hh. Holding the multiplication’s other input at x=3x=3, that contributes 3h3h to the output change. The local derivatives along this path multiply to 3×1=33\times1=3.
  • Direct effect, x→zx\to z: the multiplication uses xx itself. Holding its other input at a=7a=7, increasing xx by hh contributes 7h7h to the output change. The local derivative along this path is a=7a=7.

Changing xx affects both paths, so we add their contributions:

∂z∂x=3×1⏟through a+7⏟direct path=10\frac{\partial z}{\partial x} =\underbrace{3\times1}_{\text{through }a} +\underbrace{7}_{\text{direct path}} =10

We hold one multiplication input fixed only to isolate each contribution. In the complete calculation, both inputs change, giving the combined first-order change Δz≈3h+7h=10h\Delta z\approx3h+7h=10h. We can see how this relates to the exact change by increasing xx from 33 to 3+h3+h, while keeping y=4y=4:

znew=(7+h)(3+h)=21+10h+h2z_{\text{new}}=(7+h)(3+h)=21+10h+h^2

The original output was 2121, so the exact change is Δz=10h+h2\Delta z=10h+h^2. The derivative captures the 10h10h part. The extra h2h^2 comes from both multiplication inputs changing together; it explains why increasing xx by a full 11 changes zz by 1111, even though the derivative at the starting point is 1010.

The widget below shows both paths from xx to zz. Use Indirect via a and Direct to z to highlight them. Move Input x to see their contributions change, then increase Change h to 11 to compare the predicted change of 1010 with the actual change of 1111 at x=3x=3. Shrink hh to see the approximation improve.

One input, two paths to the output
Two paths through a = x + y and z = a × xAt x = 3 and fixed y = 4, a = 7 and z = 21. The indirect path goes from x through a to z. The direct path goes from x straight to the multiplication producing z.Indirect via aDirect to zy (fixed)4x3a = x + y7z = a × x21

The buttons highlight a path; both paths still contribute when x changes. y stays fixed at 4.

Indirect via aSlope: x × 1 = 3Change: xh = 0.3
Direct to zSlope: a = 7Change: ah = 0.7
Combined derivative: ∂z/∂x = (3) + (7) = 10
Predicted Δz = (2x + y)h1
Actual Δz = (2x + y)h + h²1.01

x: 3 → 3.1 · a: 7 → 7.1 · z: 21 → 22.01

The extra h² = 0.01 comes from both multiplication inputs changing together.

For comparison, a finite difference evaluates the full function at a nearby input:

z(3.001,4)−z(3,4)0.001=21.010001−210.001=10.001\frac{z(3.001,4)-z(3,4)}{0.001} =\frac{21.010001-21}{0.001} =10.001

We can also calculate the sensitivity to yy using the same saved values. There is one path, y→a→zy\to a\to z, giving ∂z/∂y=3×1=3\partial z/\partial y=3\times1=3. Neither derivative required another evaluation at a perturbed input.

The pattern is the same for a loss: multiply local derivatives along a path, and add contributions from different paths. Let’s now turn that arithmetic into a numerical trace of our original loss. The engine will record its operations as a graph and work backward through them.

Trace the same loss with autodiff

Return to L(a)=a2+3L(a)=a^2+3 at a=5a=5, the same function and point used in our comparison table. Symbolic differentiation gave us L′(a)=2aL'(a)=2a, then L′(5)=10L'(5)=10. Now we’ll calculate that 1010 by following the executed operations:

def loss(a):
    q = a * a
    return q + 3

The forward calculation gives q=25q=25 and L=28L=28. The addition has local derivative 11 with respect to qq. The multiplication has two operand paths, both referring to the same aa; each local derivative is the other operand’s value, 55.

Start the backward pass with dL/dL=1dL/dL=1. Passing through the addition gives dL/dq=1×1=1dL/dq=1\times1=1. Each multiplication path then contributes 1×51\times5 to the gradient of aa, so:

dLda=1×5⏟left operand+1×5⏟right operand=10\frac{dL}{da}=\underbrace{1\times5}_{\text{left operand}}+\underbrace{1\times5}_{\text{right operand}}=10

Select Evaluate a × a + 3, then Seed loss gradient. Use Backpropagate addition, followed by Follow left operand and Follow right operand, to watch the gradient reach 1010.

The same loss, differentiated through its operations
Differentiate a times a plus threeTwo paths connect a=5 to q=a×a, then addition connects q and constant 3 to L. Stage 0 of 5. Choose the evaluation point a = 5. We will compute L = a × a + 3.Left operandRight operandAddInput avalue 5grad —q = a × avalue —grad —L = q + 3value —grad —Constant 3value 3grad —

Scroll the graph sideways to follow the multiplication and addition.

Forward: values →Backward: gradient contributions ←
Stage 0 of 5. Choose the evaluation point a = 5. We will compute L = a × a + 3.

Each grad is the derivative of the final loss with respect to that node. The trace uses the actual addition and multiplication edges from the scalar engine. Changing a starts a new trace.

In ordinary Python, the same arithmetic is:

a = 5.0
q = a * a                   # forward: 25.0
loss = q + 3.0              # forward: 28.0

loss_grad = 1.0             # dL/dL
q_grad = loss_grad * 1.0    # derivative of q + 3 with respect to q
a_grad = 0.0
a_grad += q_grad * a        # left operand contributes 5.0
a_grad += q_grad * a        # right operand contributes 5.0
print(a_grad)               # 10.0

Every value needed came from the computation at a=5a=5. We never evaluated the loss at 5+h5+h and never constructed the expression 2a2a as the output of a symbolic differentiation procedure. We applied the local rules to numbers and accumulated the result.

The automation comes from recording the operations and applying those rules for us. Our Value class will do this when we write loss = a * a + 3 and call loss.backward(). First, let’s connect the same process to a loss built from a model’s predictions and targets.

A prediction gives us something to evaluate

Now suppose a model receives the input x=2x=2 and should predict the target y=5y=5. Give it one weight ww and one bias bb:

y^=wx+b.\hat y=wx+b.

The hat in y^\hat y marks a prediction. At w=1w=1 and b=0b=0, the prediction is 22, which is 33 below the target.

What should training minimize? Minimizing the prediction itself would drive it downward, even though we need it to increase. We need a function that measures the discrepancy between prediction and target.

Choose squared error:

L=(y^−y)2.L=(\hat y-y)^2.

The current loss is (2−5)2=9(2-5)^2=9. Zero would mean the prediction matches the target for this example.

Substitute the model’s computation into the loss:

L(w,b)=(wx+b−y)2.\boxed{L(w,b)=(wx+b-y)^2.}

This is the connection we need. The loss is now a function of the parameters we can change. For the fixed training example (x,y)=(2,5)(x,y)=(2,5):

L(w,b)=(2w+b−5)2.L(w,b)=(2w+b-5)^2.

The number 99 alone does not tell us which way to move. With b=0b=0, both w=1w=1 and w=4w=4 produce loss 99, but they need to move in opposite directions. We differentiate the function that produced the loss, evaluated at the current parameters.

The same calculus, with a weight as the variable

Hold the training input x=2x=2, target y=5y=5, and bias b=0b=0 fixed. The loss becomes an ordinary function of one variable:

L(w)=(2w−5)2=4w2−20w+25.L(w)=(2w-5)^2=4w^2-20w+25.

We can differentiate this small expression by hand and substitute the current weight, exactly as we did for a2+3a^2+3:

StepFamiliar functionModel’s loss with fixed training data
Write the functionL(a)=a2+3L(a)=a^2+3L(w)=(2w−5)2L(w)=(2w-5)^2
DifferentiateL′(a)=2aL'(a)=2aL′(w)=8w−20L'(w)=8w-20
Insert the current valuea=5a=5w=1w=1
Obtain the derivativeL′(5)=10L'(5)=10L′(1)=−12L'(1)=-12

The variable is now the weight ww, rather than our earlier parameter aa. The training input and target are fixed numbers inside L(w)L(w); we are asking how the loss responds to the weight. The same distinction applies when we use a whole batch: the batch’s inputs and targets stay fixed while we differentiate with respect to the parameters.

Next, we’ll calculate the same weight derivative by following the model’s operations, as our engine will do.

Which derivative do we take?

For the weight, we follow the path from ww through the prediction and error to the loss:

w ⟶ y^=wx+b ⟶ e=y^−y ⟶ L=e2.w\ \longrightarrow\ \hat y=wx+b \ \longrightarrow\ e=\hat y-y \ \longrightarrow\ L=e^2.

Each operation has a local derivative:

OperationWhat changes?Local derivative
y^=wx+b\hat y=wx+bChange ww, hold x,bx,b fixed∂y^/∂w=x\partial\hat y/\partial w=x
e=y^−ye=\hat y-yChange y^\hat y, hold yy fixed∂e/∂y^=1\partial e/\partial\hat y=1
L=e2L=e^2Change eedL/de=2edL/de=2e

The chain rule multiplies these sensitivities along the path:

∂L∂w=dLde∂e∂y^∂y^∂w=2e⋅1⋅x=2(y^−y)x.\frac{\partial L}{\partial w} =\frac{dL}{de} \frac{\partial e}{\partial\hat y} \frac{\partial\hat y}{\partial w} =2e\cdot1\cdot x =2(\hat y-y)x.

At our starting values, e=−3e=-3 and x=2x=2:

∂L∂w=2(−3)(2)=−12.\frac{\partial L}{\partial w}=2(-3)(2)=-12.

A weight increase of 0.010.01 should therefore change the loss by approximately −12×0.01=−0.12-12\times0.01=-0.12.

For the bias, the last factor changes: ∂y^/∂b=1\partial\hat y/\partial b=1. So:

∂L∂b=2(y^−y)=−6.\frac{\partial L}{\partial b}=2(\hat y-y)=-6.

The derivatives with respect to all trainable parameters together form the gradient. Here that vector is ∇L=(−12,−6)\nabla L=(-12,-6), in the order (w,b)(w,b). We combined the local derivatives by hand; now let’s watch a backward pass do the same calculation on this exact model and loss.

Backpropagation is the chain rule through a graph

We can record the computation as a graph. Each box holds a number, and each operation connects its result to the values used to produce it.

Start at the rightmost box. The derivative of the loss with respect to itself is one:

∂L∂L=1.\frac{\partial L}{\partial L}=1.

Then work backward. A box receives a gradient telling us how the final loss responds to that box’s value. To pass a contribution to one of its inputs, multiply by the operation’s local derivative:

input gradient contribution=output gradient×local derivative.\boxed{\text{input gradient contribution} =\text{output gradient}\times\text{local derivative}.}

Select Seed gradient, then Backpropagate one operation until you reach ww and xx. Every gradient displayed is a derivative of the final loss, even when the box represents an intermediate result.

Follow the loss backward
wvalue 1grad —xvalue 2grad —p = w × xvalue 2grad —bvalue 0grad —ŷ = p + bvalue 2grad —y (target)value 5grad —e = ŷ − yvalue -3grad —L = e²value 9grad —Values flow → · apply local derivative rules ←

Scroll the graph sideways to follow all the operations.

Stage 0 of 5. Forward pass: evaluate the operations from left to right. Each box stores its value. Gradients have not been seeded yet.
Gradients revealed so far. “grad” means ∂L / ∂(box value).
ValueForward valueGradient
w1—
x2—
p = w × x2—
b0—
ŷ = p + b2—
y (target)5—
e = ŷ − y-3—
L = e²9—

The graph also gives derivatives with respect to the input and target. That does not make them trainable. We choose which values the optimizer updates: here, only ww and bb. In a deeper network, a layer’s “input” may be a previous layer’s output, so its gradient becomes the route back to earlier parameters.

As in our two-path example, contributions add when a value has multiple paths to the output. We’ll use this compact case to check that behavior in the engine:

q=a⋅a+a⟹dqda=a+a+1=2a+1.q=a\cdot a+a \quad\Longrightarrow\quad \frac{dq}{da}=a+a+1=2a+1.

At a=3a=3, that derivative is 77. A backward pass must keep all three contributions, including both uses of aa in the multiplication. This is why our engine will use += when propagating gradients.

Build a number that remembers its computation

An ordinary Python float stores a value. Our Value will also store:

  • grad: the derivative of the chosen final output with respect to this value.
  • edges: a sequence of (parent, local_derivative) pairs recording how this value was computed.

Addition and multiplication become graph-building operations. For z=a+bz=a+b, both edge derivatives are 11. For z=abz=ab, they are bb and aa.

Here is the first part of the engine, including integer powers and the activation we will use later:

import math


class Value:
    def __init__(self, data, edges=()):
        self.data = float(data)
        self.grad = 0.0
        self.edges = tuple(edges)  # (parent, local derivative) pairs

    @staticmethod
    def wrap(value):
        return value if isinstance(value, Value) else Value(value)

    def __add__(self, other):
        other = Value.wrap(other)
        return Value(self.data + other.data, ((self, 1.0), (other, 1.0)))

    def __mul__(self, other):
        other = Value.wrap(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, exponent):
        if not isinstance(exponent, int) or exponent < 1:
            raise ValueError("This engine supports positive integer powers only")
        return Value(self.data ** exponent,
                     ((self, exponent * self.data ** (exponent - 1)),))

    def tanh(self):
        output = math.tanh(self.data)
        return Value(output, ((self, 1.0 - output * output),))

When we multiply two Value objects, __mul__ calculates the output immediately and saves two edges with their local derivatives. These derivative values are evaluated using the operands’ current data. Nothing is learned yet; we are recording a forward computation.

We implement powers directly, so (prediction - target) ** 2 records a square operation. The engine intentionally supports only positive integer powers. This covers our examples without introducing domains such as fractional powers of negative numbers.

The hyperbolic tangent, tanh, is a smooth activation function with derivative 1−tanh⁡2(z)1-\tanh^2(z). It lets a network express nonlinear relationships.

Python’s convenience operators make the engine pleasant to use. Reverse operators allow expressions such as 2 * w and sum(...):

    def __neg__(self):
        return self * -1.0

    def __sub__(self, other):
        return self + -Value.wrap(other)

    def __rsub__(self, other):
        return Value.wrap(other) + -self

    __radd__ = __add__
    __rmul__ = __mul__

These methods all belong inside Value. The complete Python file includes the engine, the models, and runnable examples. You can also download tinygrad.py.

Visit the graph in the right order

To calculate gradients, we need to process each result before its parents, after all of that result’s downstream contributions have arrived.

First, a depth-first traversal visits a node’s parents before appending the node itself. This creates a topological ordering. A seen set ensures a shared node is appended only once. Reversing the order puts the final output first.

Add this method to Value:

    def backward(self):
        order, seen = [], set()

        def visit(node):
            if node in seen:
                return
            seen.add(node)
            for parent, _ in node.edges:
                visit(parent)
            order.append(node)

        visit(self)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, local_derivative in node.edges:
                parent.grad += node.grad * local_derivative

There are three separate steps: clear old gradients in the reachable graph, seed the final output’s gradient with one, and traverse backward to accumulate contributions.

The seen set deduplicates nodes, while edges preserves every operand occurrence. In a * a, two edges point to the same parent. Both execute their += update. Replacing edges with a set of parents without retaining the corresponding contributions would lose that distinction.

Our backward() deliberately clears reachable gradients on each call. This makes repeated calls on the same unchanged graph reproducible. It differs from APIs that accumulate leaf gradients across backward calls and require a separate zeroing step. Within one backward pass, contributions still accumulate from all graph paths.

This implementation saves numeric local derivatives during the forward pass. Change parameter data after backward, then build a fresh graph for the next iteration. Rerunning backward on an old graph after changing data would use stale local derivatives.

The recursive graph traversal keeps this teaching engine short. Long computation chains, such as a loss summed over thousands of examples, can exceed Python’s recursion limit; an iterative traversal avoids that limit.

Check the engine against the arithmetic

Save the complete file as tinygrad.py. In another Python file in the same directory:

from tinygrad import Value

w, b = Value(1), Value(0)
prediction = w * 2 + b
loss = (prediction - 5) ** 2
loss.backward()

print(loss.data)  # 9.0
print(w.grad)     # -12.0
print(b.grad)     # -6.0

a = Value(3)
q = a * a + a
q.backward()
print(a.grad)    # 7.0: all three paths contribute

The engine never received a special formula for this model’s weight gradient. It recorded multiplication, addition, subtraction, and a square, then combined their local derivatives.

Use finite differences to check the result

This is the second purpose announced at the start: independent verification. The engine has already obtained w.grad from the recorded operations at w=1w=1. To check its implementation, evaluate the loss separately on both sides of that weight:

def numeric_loss(weight):
    return (weight * 2 - 5) ** 2

h = 1e-5
finite_difference = (numeric_loss(1 + h) - numeric_loss(1 - h)) / (2 * h)
assert abs(w.grad - finite_difference) < 1e-6

This central difference is a numerical check, not the engine’s differentiation algorithm. Checking every parameter this way needs two evaluations per parameter. Reverse-mode automatic differentiation instead reuses a single recorded graph to compute all parameter gradients for a scalar output, with work proportional to the graph’s size.

We choose a central difference because sampling both sides cancels the leading approximation error. For sufficiently smooth functions, forward and backward differences typically have error proportional to hh, while central difference has error proportional to h2h^2. Halving hh therefore roughly halves the one-sided error but reduces central-difference error to about a quarter. For our quadratic loss, central difference is exact in exact arithmetic; for general functions, it remains an approximation.

Extremely small values of hh eventually introduce floating-point cancellation, so smaller is not always better for this numerical check. The training loops below obtain their gradients by calling backward() on the current graph. With the engine checked, we can examine how the choice of loss affects those gradients and then use them to update parameters.

The choice of loss changes the learning signal

The model tells us how its prediction responds to a weight. The loss tells us how much that prediction change matters to the objective.

For the same prediction, compare two losses:

LossDerivative with respect to predictionDerivative with respect to weight
Squared error: (y^−y)2(\hat y-y)^22(y^−y)2(\hat y-y)2(y^−y)x2(\hat y-y)x
Absolute error: ∣y^−y∣\lvert\hat y-y\rvertsign⁡(y^−y)\operatorname{sign}(\hat y-y), away from zerosign⁡(y^−y)x\operatorname{sign}(\hat y-y)x, away from zero

At our starting point, the prediction gradient is −6-6 for squared error and −1-1 for absolute error. Multiplying by the same model derivative, x=2x=2, gives weight gradients −12-12 and −2-2.

Switch between Squared error and Absolute error below. Move Weight w while watching both the prediction gradient and the weight gradient. The input stays fixed, so only the first factor changes.

The loss changes the slope
Loss L(w) against weight wInput x=2, target y=5, bias b=0. Prediction 2, loss 9, weight gradient -12.-100.7513.752.527.54.2541.25655Loss L(w)weight w
Current weightLocal slopeAfter the proposed step
ŷ = 2 × 1 = 2 · target = 5 · L = 9
∂L/∂ŷ = -6 · ∂ŷ/∂w = 2 · ∂L/∂w = -12
w next = 1 − 0.1 × (-12) = 2.2 · next loss = 0.36

x = 2, y = 5, and b = 0 stay fixed. This experiment updates only w.

Squared error produces a larger prediction gradient when the error’s magnitude is larger. Absolute error has constant gradient magnitude with respect to the prediction whenever the error is nonzero. Its derivative is undefined at zero; the widget selects a subgradient of zero there.

Neither loss supplies the rules for differentiation. Those rules come from calculus. The choice of loss determines the function those rules act on, and therefore the initial signal propagated through the model.

A derivative becomes an update

Gradient descent subtracts a scaled gradient:

wnew=w−η∂L∂w.w_{\text{new}}=w-\eta\frac{\partial L}{\partial w}.

The learning rate η\eta sets the step size. With squared error, w=1w=1, and η=0.1\eta=0.1:

wnew=1−0.1(−12)=2.2.w_{\text{new}}=1-0.1(-12)=2.2.

Holding the bias at zero, the new prediction is 4.44.4 and the new loss is 0.360.36. Use Reset and Take one step in the loss experiment to reproduce this calculation.

Here we updated only the weight. If we also update the bias from the same gradient, it becomes 0.60.6, giving prediction 2.2×2+0.6=52.2\times2+0.6=5 and loss zero for this single example. These are different updates, so they produce different results.

A negative derivative suggests increasing the parameter; a positive derivative suggests decreasing it. That is a local statement. A large step can pass a minimum and increase the loss. Backpropagation calculates the gradient; the optimizer and its learning rate determine what to do with it.

Why we recalculate gradients after an update

The update moves the weight, but the gradient was evaluated at its previous value. The chain rule combines derivative rules, and the numbers those rules produce can change after that move. Let’s reuse our loss L(a)=a2+3L(a)=a^2+3 and let its input come from a multiplication: a=wxa=wx. Keep x=2x=2 fixed and treat ww as the weight we adjust:

w ⟶ a=2w ⟶ L=a2+3w\ \longrightarrow\ a=2w\ \longrightarrow\ L=a^2+3

The multiplication gives da/dw=2da/dw=2, and the loss gives dL/da=2adL/da=2a. Combining them:

dLdw=dLdadadw=2a×2\frac{dL}{dw}=\frac{dL}{da}\frac{da}{dw}=2a\times2

At w=1w=1, we get a=2a=2 and L=7L=7, so the gradient is 4×2=84\times2=8. If an update moves ww to 0.50.5, then aa becomes 11 and the gradient becomes 2×2=42\times2=4:

Weight wwIntermediate value aaLoss derivative dL/da=2adL/da=2aWeight gradient dL/dwdL/dw
11224488
0.50.5112244
−1-1−2-2−4-4−8-8

The rules stay the same, but their numerical values can change after an update. The multiplication’s derivative stays 22 because xx is fixed; the loss derivative changes with aa. Reusing the old gradient of 88 at w=0.5w=0.5 would describe the slope at the previous weight, not the current one.

Move Weight w below to see these values change. From the initial weight of 11, select Apply one update to move to 0.50.5 and recalculate the gradient. This demonstration uses learning rate η=1/16\eta=1/16, so each update subtracts one sixteenth of the current gradient from the weight.

Same rules, new gradient
Weight w1
a = 2w2
L = a² + 37
Multiplication derivativeda/dw = 2Fixed input x = 2
Loss derivativedL/da = 2a = 4Changes with a
Current gradient: dL/dw = (4) × 2 = 8

Learning rate η = 1/16: w ← w − η × (dL/dw). The intermediate value, loss, and gradient are recalculated after each update.

This connects the two senses in which we refer to derivatives and approximations as local. Each operation supplies a local derivative rule. The resulting gradient describes the loss near the current weight and gives a local approximation of its change. After updating the weights, our engine builds a fresh graph and computes a new gradient. We can now repeat that process to train on several examples.

Learn from several examples

One training example cannot determine a unique line: many (w,b)(w,b) pairs satisfy 2w+b=52w+b=5. Add more examples:

Input xxTarget yy
01
13
25
37

These points lie on y=2x+1y=2x+1, so we can check whether training finds w≈2w\approx2 and b≈1b\approx1.

Define the loss as the mean of the four squared errors:

L(w,b)=14∑i=14(wxi+b−yi)2.L(w,b)=\frac14\sum_{i=1}^{4}(wx_i+b-y_i)^2.

The shared weight appears in all four predictions. During backward, each path contributes to the same w.grad. Multiplication by 1/41/4 scales all contributions to match the mean loss. Summing instead of averaging would multiply both gradients by four.

Use Step to inspect one update, then Run 100 steps. Try learning rate 0.30.3 from the reset state to see loss grow even though the gradients are calculated correctly.

Use the gradients to learn a line
prediction ŷ against input xAfter 0 updates, w=-1, b=0, mean squared error=41.5. The four targets lie on y=2x+1.-0.3-40.6-0.751.52.52.45.753.39prediction ŷinput x
Model: ŷ = wx + bTargetsPrediction errors
Step 0 · w = -1 · b = 0 · MSE = 41.5
log₁₀(MSE) against training stepLoss history on a logarithmic scale. A value of −3 means MSE 0.001. Losses below 10⁻¹² are plotted at −12.0-32.5-1.755-0.57.50.75102log₁₀(MSE)training step

Each click builds a fresh graph, averages four squared errors, runs backward(), and updates both w and b. The line plot rescales if the parameters grow; the loss plot uses log₁₀, with a floor of 10⁻¹².

The widget uses a JavaScript counterpart of the scalar engine above. Its gradients are calculated through a new graph on each step. Here is the same training loop in Python:

def train_line(steps=1000, learning_rate=0.05):
    data = [(0, 1), (1, 3), (2, 5), (3, 7)]
    w, b = Value(-1), Value(0)
    for _ in range(steps):
        loss = sum((w * x + b - y) ** 2 for x, y in data) * (1 / len(data))
        loss.backward()  # also clears this graph's old gradients
        for parameter in (w, b):
            parameter.data -= learning_rate * parameter.grad
    final_loss = sum((w.data * x + b.data - y) ** 2 for x, y in data) / len(data)
    return w.data, b.data, final_loss

Both gradients come from the same forward pass before either parameter changes. The next iteration recomputes predictions, loss, and derivatives at the updated parameters. The returned loss is evaluated after the last update, so it describes the returned weights.

Use the same engine for a neural network

So far, the model has been affine. A small neural network needs no new differentiation machinery: its forward pass is a larger composition of the operations we already support.

One neuron: weighted sum, then activation

A neuron first combines its inputs into one number, then applies an activation function:

z=∑i=1nwixi+b=w⊤x+b,y^=f(z)=f ⁣(∑i=1nwixi+b).z=\sum_{i=1}^{n}w_ix_i+b=\mathbf w^\top\mathbf x+b, \qquad \boxed{\hat y=f(z)=f\!\left(\sum_{i=1}^{n}w_ix_i+b\right).}

Here, xix_i is one input, wiw_i is its learned weight, bb is the learned bias, and ff is the activation function. The output y^\hat y is a single scalar; we keep yy for the target. In this section, ii indexes the inputs to one neuron, rather than the training examples in the previous section.

The dot product w⊤x\mathbf w^\top\mathbf x multiplies corresponding entries and sums them. Adding the bias makes zz an affine transformation from Rn\mathbb R^n to R\mathbb R. Applying a nonlinear activation such as ReLU, sigmoid, or tanh lets layers compose nonlinear functions. Our earlier model y^=wx+b\hat y=wx+b is the one-input case with the identity activation f(z)=zf(z)=z.

For a concrete example, take three inputs:

x=[231],w=[0.5−12],b=1.\mathbf x=\begin{bmatrix}2\\3\\1\end{bmatrix}, \qquad \mathbf w=\begin{bmatrix}0.5\\-1\\2\end{bmatrix}, \qquad b=1.

First compute the weighted sum plus bias:

z=(0.5)(2)+(−1)(3)+(2)(1)+1=1.z=(0.5)(2)+(-1)(3)+(2)(1)+1=1.

With ReLU, the output would be max⁡(0,1)=1\max(0,1)=1. Our engine uses tanh, so its nonlinear neuron instead returns:

y^=tanh⁡(1)≈0.7616.\hat y=\tanh(1)\approx0.7616.

The inputs, weights, and bias determine zz; the activation determines how that value is passed forward.

Follow the chain rule through the neuron

Attach a loss to this neuron’s output. A weight affects that loss through the weighted sum and the activation:

wi ⟶ z ⟶ y^=f(z) ⟶ L.w_i\ \longrightarrow\ z\ \longrightarrow\ \hat y=f(z)\ \longrightarrow\ L.

The local derivative of the weighted sum with respect to wiw_i is xix_i. The activation contributes f′(z)f'(z). Multiplying these by the loss sensitivity arriving at the output gives:

∂L∂wi=∂L∂y^dy^dz∂z∂wi=∂L∂y^⏟loss sensitivityf′(z)⏟activation slopexi⏟input.\boxed{ \frac{\partial L}{\partial w_i} =\frac{\partial L}{\partial\hat y} \frac{d\hat y}{dz} \frac{\partial z}{\partial w_i} =\underbrace{\frac{\partial L}{\partial\hat y}}_{\text{loss sensitivity}} \underbrace{f'(z)}_{\text{activation slope}} \underbrace{x_i}_{\text{input}}. }

For the bias, ∂z/∂b=1\partial z/\partial b=1, so:

∂L∂b=∂L∂y^ f′(z).\frac{\partial L}{\partial b} =\frac{\partial L}{\partial\hat y}\,f'(z).

For tanh, f′(z)=1−tanh⁡2(z)=1−y^2f'(z)=1-\tanh^2(z)=1-\hat y^2. If this single neuron predicts a target yy and we use squared error, L=(y^−y)2L=(\hat y-y)^2, then:

∂L∂wi=2(y^−y)(1−y^2)xi.\frac{\partial L}{\partial w_i} =2(\hat y-y)(1-\hat y^2)x_i.

Return to the three-input example and choose target y=1y=1. At z=1z=1, the activation slope is approximately 0.42000.4200. For the first weight:

∂L∂w1=2(tanh⁡(1)−1)(1−tanh⁡2(1))⋅2≈−0.4005.\frac{\partial L}{\partial w_1} =2(\tanh(1)-1)\bigl(1-\tanh^2(1)\bigr)\cdot2 \approx-0.4005.

A small increase in w1w_1 therefore reduces the loss locally. Compared with our earlier affine model, the chain has one additional factor: the activation’s slope. With the identity activation, that factor is 11, recovering the earlier formula.

For a hidden neuron, ∂L/∂y^\partial L/\partial\hat y arrives from the later layers. To continue backward, this neuron contributes (∂L/∂y^)f′(z)wi(\partial L/\partial\hat y)f'(z)w_i to the gradient of input xix_i. If an input feeds several neurons, their contributions add, just as they did in our earlier graph examples.

From one neuron to a layer

A layer with mm neurons has one weight vector and one bias per neuron. Stack the weight vectors as the rows of W∈Rm×nW\in\mathbb R^{m\times n} and the biases into b∈Rm\mathbf b\in\mathbb R^m:

a=f(Wx+b),a∈Rm.\mathbf a=f(W\mathbf x+\mathbf b), \qquad \mathbf a\in\mathbb R^m.

Here, a\mathbf a collects the layer’s outputs, and ff acts on each entry separately. An MLP feeds those outputs into the next layer.

The code below implements the same two stages: total is zz, and total.tanh() applies the activation. We keep the final layer linear for this regression example:

import random


class Neuron:
    def __init__(self, n_inputs, rng, nonlinear=True):
        self.weights = [Value(rng.uniform(-1, 1)) for _ in range(n_inputs)]
        self.bias = Value(0)
        self.nonlinear = nonlinear

    def __call__(self, inputs):
        total = sum((w * x for w, x in zip(self.weights, inputs)), self.bias)
        return total.tanh() if self.nonlinear else total

    def parameters(self):
        return self.weights + [self.bias]


class MLP:
    def __init__(self, sizes, seed=42):
        rng = random.Random(seed)
        self.layers = [
            [Neuron(n_in, rng, nonlinear=i < len(sizes) - 2)
             for _ in range(n_out)]
            for i, (n_in, n_out) in enumerate(zip(sizes, sizes[1:]))
        ]

    def __call__(self, inputs):
        for layer in self.layers:
            inputs = [neuron(inputs) for neuron in layer]
        return inputs

    def parameters(self):
        return [p for layer in self.layers for neuron in layer
                for p in neuron.parameters()]

MLP([2, 4, 1]) creates two inputs, four hidden neurons with tanh, and one linear output. Its parameters are 8 hidden weights, 4 hidden biases, 4 output weights, and 1 output bias: 17 trainable scalars.

Train it on XOR, encoding equal inputs as −1-1 and different inputs as +1+1. No affine function of two inputs can fit all four targets exactly; the hidden nonlinear activations allow the model to fit them.

def train_xor(steps=1000, learning_rate=0.1):
    # Same inputs twice equal -1; different inputs equal +1.
    examples = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
    model = MLP([2, 4, 1], seed=42)
    parameters = model.parameters()
    for _ in range(steps):
        loss = sum((model(x)[0] - y) ** 2 for x, y in examples) * 0.25
        loss.backward()
        for parameter in parameters:
            parameter.data -= learning_rate * parameter.grad
    predictions = [model(x)[0].data for x, _ in examples]
    final_loss = sum((p - y) ** 2 for p, (_, y) in zip(predictions, examples)) / 4
    return predictions, final_loss

The training structure is unchanged: build predictions and a scalar loss, call backward(), and update parameters. The engine sees operations and edges. It does not need to know which operations belong to a neuron or a layer.

Run the complete file:

python3 tinygrad.py

With the supplied seed, 1,000 steps, and learning rate 0.10.1, the XOR predictions round to [-1.0, 1.0, 1.0, -1.0] at four decimal places. This is a small demonstration with fixed data and initialization, rather than a guarantee for arbitrary networks or learning rates.

When gradients vanish, explode, or become zero

The chain rule also explains why a neuron may learn slowly or stop receiving a learning signal. Each path multiplies local derivatives together, so a zero factor blocks that path, while repeated small or large factors can shrink or amplify its contribution.

Consider a neuron with a ReLU activation:

z=∑iwixi+b,y^=ReLU⁡(z)=max⁡(0,z).z=\sum_i w_i x_i+b,\qquad \hat y=\operatorname{ReLU}(z)=\max(0,z).

For positive zz, the activation’s derivative is 11. For negative zz, it is 00. At z=0z=0, ReLU is not differentiable; implementations commonly choose a derivative value of zero. When zz is negative, the weight gradient becomes:

∂L∂wi=∂L∂y^f′(z)⏟0xi=0\frac{\partial L}{\partial w_i} =\frac{\partial L}{\partial\hat y} \underbrace{f'(z)}_{0} x_i =0

The bias gradient is zero too. This example therefore supplies no gradient update to the neuron’s incoming weights or bias through this activation. If the ReLU stays inactive for every training example, it can become a dying ReLU: it consistently outputs zero and blocks the gradient needed to change its incoming parameters. With fixed inputs and ordinary gradient descent, it cannot recover through these gradients alone. In a hidden layer, changes in earlier layers can still change its inputs. Stanford’s CS231n notes discuss this failure mode and how Leaky ReLU retains a small nonzero slope for negative inputs.

A zero derivative for one example does not mean the neuron is dead—it may activate for another. Nor does a zero loss gradient necessarily indicate a dead neuron: it can occur at a stationary point, including a minimum.

Gradients can also become very small or very large without any factor being zero. Along a simplified path with ten local derivative factors, starting with an incoming gradient of 11:

Local factors along the pathProductEffect
Ten factors of 0.50.50.510≈0.000980.5^{10}\approx0.00098The gradient contribution shrinks
Ten factors of 22210=10242^{10}=1024The gradient contribution grows

Repeated shrinking can produce vanishing gradients, leaving earlier parameters with very small updates. Repeated amplification can produce exploding gradients, making updates unstable or causing numerical overflow. In a network, these factors include weights as well as activation derivatives, and contributions from different paths add together. The table illustrates one path rather than predicting the whole network’s gradient. The Deep Learning textbook explains these training difficulties in more detail.

Our engine uses tanh, whose derivative is 1−y^21-\hat y^2. When its output is close to −1-1 or 11, this derivative is close to zero, so it can shrink gradients even without ReLU’s flat negative region. This is activation saturation, distinct from a dying ReLU. Autodiff calculates these derivatives correctly; it does not guarantee that they will be large enough for useful learning or small enough for stable updates.

From derivative rules to a trainable model

We calculated derivatives in three ways. Finite differences estimated slopes from nearby outputs and later checked the engine independently. Symbolic differentiation gave us expressions such as L′(a)=2aL'(a)=2a that we could evaluate at a chosen point. Our autodiff engine applied the same calculus rules to recorded operations and accumulated numerical gradients at the current parameter values.

The engine needed derivative rules for its basic operations, not a separate derivative formula for each model. A forward pass recorded values and local derivatives; a backward pass combined their contributions, including every path through a reused value. The loss supplied the objective, and gradient descent used its parameter gradients to update the model. Each new update required a fresh computation at the new parameters.

We verified that calculation against both hand-derived answers and finite differences, then used the same engine to train a line and a small neural network. Increasing the model’s complexity changed the computation graph; the differentiation machinery stayed the same.

For the broader neural-network treatment, see How neural networks learn: backpropagation and gradient descent. For a complementary implementation that attaches backward functions to operations, see Karpathy’s micrograd engine and his Neural Networks: Zero to Hero materials.

Image viewer

Pinch or use + / − to zoom. Drag or use arrow keys to move. Esc to close.