From derivatives to backpropagation: build a tiny autograd engine
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 . If increases by , increases by . If increases by , increases by . Whatever the starting value, the change in is three times the change in :
The symbol means a change between two values. You can recognize this rate of change without knowing calculus. The derivative gives it a name and notation:
We read as the derivative of with respect to . The denominator tells us which variable we change: . The numerator tells us whose response we measure: . So means that changing by a small amount changes by three times that amount.
The function 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 , so is exact, even for large changes. The graph is a straight line with the same slope everywhere.
For , the derivative is , which we’ll derive shortly. At , the rate is ; at , it is . 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 :
At , the loss is . Should we increase or decrease to reduce it?
We could try both directions:
| Parameter | Loss |
|---|---|
Decreasing by , from to , reduced the loss from to . Increasing by , from to , raised the loss from to . These evaluations suggest that a small decrease from will reduce the loss.
The direction that reduces the loss depends on the current value of . If we started at , the loss would also be , but increasing to would lower it to . Decreasing to would raise it to . For this function, moving toward zero reduces the loss: from a positive value we should decrease , and from a negative value we should increase it.
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:
- Forward difference: compare the current point with a point ahead of it.
- Backward difference: compare the current point with a point behind it.
- 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 , then compare the result with backward and central differences.
Writing the parameter change as , the forward-difference formula is:
With , we compare the loss at the current value with the loss at :
Our forward-difference estimate of approximates the slope at . It uses the table’s two loss values, and . Because the estimate is positive, it indicates that decreasing 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 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 to , 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 and loss change , so the average slope is .
We can compare the average rate over the whole interval with the rates over its two halves. Splitting at , where the loss is , gives:
| Interval | Parameter change | Loss change | Average rate |
|---|---|---|---|
| Whole interval | |||
| First half | |||
| Second half |
The overall average of 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 , at the same point , using the same step size . Only the choice of sample points changes.
| Method | Formula | Estimate at , |
|---|---|---|
| Forward | ||
| Backward | ||
| Central |
Notice how central difference divides by , while forward and backward differences divide by . The denominator is the distance between the two sample points. For central difference, those points are and , so:
At and :
Each point is from the current point, but they are apart. Forward and backward differences use the current point as one endpoint, so their distances are and , respectively.
Central difference gives 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 , we used and got . Reducing to gives:
With , our estimate is . As we shrink further toward zero, the estimate approaches .
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 , and the point is . We write the derivative as or . Using the forward-difference form we started with, its definition is:
Using our earlier change notation, and . The ratio is the average rate over an interval; , written with the Latin letter , is its limit as that interval shrinks toward zero.
For our loss function at , this limit is , so . For a differentiable function, all three forms—forward, backward, and central—approach the same derivative value as approaches zero.
For , we can simplify that comparison algebraically. For nonzero :
As approaches zero, the ratio approaches , giving us the derivative rule:
In this way, we build a derivative function: gives the slope of at any value of . When we evaluate it at a particular point, such as , we get the slope at that point: . We can calculate this directly, without evaluating 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 , the derivative means that a small change in produces a change in loss approximately times as large:
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, 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 by means . Substituting this into the formula gives:
The positive result means the loss increases by approximately . Decreasing by means :
The negative result means the loss decreases by approximately .
We can choose the local direction from the derivative’s sign without testing both possibilities. At , the derivative is , so a small increase in reduces the loss. At , 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 .
- 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 .
At the initial settings and , the orange line’s slope is , while the purple line’s slope is . Shrinking brings the orange slope closer to the purple one.
Set to and reduce to below to watch the orange secant approach the purple tangent. At , the slope estimate becomes , close to the derivative . Move from to to see the direction of improvement reverse.
The derivative describes a local change. At , decreasing all the way to would increase the loss from to , 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 , we can calculate the exact change directly by subtracting the original loss from the new loss:
The derivative approximation, , keeps only the first term. The exact change also includes , which becomes small relative to the first term for sufficiently small changes when . For the move from to , we have : the derivative predicts , but the exact change is . That is why the loss rises from to 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, , at :
| Method | What it uses | Calculation at | Result |
|---|---|---|---|
| Finite differences | Outputs at nearby inputs | Approximately | |
| Symbolic differentiation | Rules applied to an expression | Derive , then substitute | |
| Automatic differentiation | The executed operations and their local derivative rules | Combine the rules for multiplication and addition in a * a + 3, using |
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:
Here, the represent all the trainable parameters, including weights and biases. Our example 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 to describe how changes when we change . Now our operations have two independent inputs, and , and an output . To measure how changes when we vary while holding fixed, we use a partial derivative, written . We read this as 'the partial derivative of with respect to '. The order means the same thing as before; the symbol indicates that we’re varying one input while holding the others fixed.
It doesn’t mean we ignore the value of ; it means we hold that value fixed while changing . For example, if , the derivative with respect to is , because is a constant multiplier of . Its value still determines how strongly a change in affects .
Let’s see how changing affects addition and multiplication at and , with held fixed. The table shows each operation’s derivative rule, its value at these inputs, and a finite-difference check that increases only by :
| Operation | Derivative with respect to | At | Finite-difference check |
|---|---|---|---|
Now we reverse the roles: vary while keeping fixed. This gives the partial derivative with respect to , written . The checks now increase only by :
| Operation | Derivative with respect to | At | Finite-difference check |
|---|---|---|---|
The tables above demonstrate that for addition, changing either input changes the output by the same amount, so both partial derivatives are . For multiplication, the change is scaled by the other input: and .
The finite-difference checks recalculate after changing each input separately. The derivative rules need no such extra evaluations: using the existing inputs and , we obtain both multiplication derivatives directly, and .
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 and be differentiable functions of :
| Rule | How the functions combine | Derivative with respect to |
|---|---|---|
| Sum rule | ||
| Product rule | ||
| Chain rule |
The sum rule adds the rates of change of the two terms. It gives another way to differentiate : the derivative of is , and the constant 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 , 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 to get an intermediate value , then multiply by to get :
We want to know how changing affects the final output . One approach is to substitute into , writing the output directly in terms of the input:
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:
Multiplying these rates along the path gives:
At , the two local slopes are and , giving a combined slope of . A small change in therefore changes by approximately , which changes by approximately .
Our autodiff engine will record these operations and combine their numerical local derivatives: at , it will multiply to obtain .
Following two paths
Now let’s use in two places: first add it to to produce , then multiply by :
At , the forward computation gives and . Changing now affects in two ways: it changes the intermediate value , and it changes the that multiplies . Our derivative must account for both effects while keeping fixed. For this small expression, we can find the answer by expanding it and differentiating directly:
Using the sum rule to differentiate each term with respect to , we get from and from . At , this gives . The product rule gives the same result without expanding: differentiating the two factors in gives . This tells us that, starting at and keeping fixed, increasing by a tiny amount increases by approximately .
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, : changing affects through the intermediate value . Increasing by increases by . Holding the multiplication’s other input at , that contributes to the output change. The local derivatives along this path multiply to .
- Direct effect, : the multiplication uses itself. Holding its other input at , increasing by contributes to the output change. The local derivative along this path is .
Changing affects both paths, so we add their contributions:
We hold one multiplication input fixed only to isolate each contribution. In the complete calculation, both inputs change, giving the combined first-order change . We can see how this relates to the exact change by increasing from to , while keeping :
The original output was , so the exact change is . The derivative captures the part. The extra comes from both multiplication inputs changing together; it explains why increasing by a full changes by , even though the derivative at the starting point is .
The widget below shows both paths from to . Use and to highlight them. Move to see their contributions change, then increase to to compare the predicted change of with the actual change of at . Shrink to see the approximation improve.
The buttons highlight a path; both paths still contribute when x changes. y stays fixed at 4.
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:
We can also calculate the sensitivity to using the same saved values. There is one path, , giving . 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 at , the same function and point used in our comparison table. Symbolic differentiation gave us , then . Now we’ll calculate that by following the executed operations:
def loss(a):
q = a * a
return q + 3The forward calculation gives and . The addition has local derivative with respect to . The multiplication has two operand paths, both referring to the same ; each local derivative is the other operand’s value, .
Start the backward pass with . Passing through the addition gives . Each multiplication path then contributes to the gradient of , so:
Select , then . Use , followed by and , to watch the gradient reach .
Scroll the graph sideways to follow the multiplication and addition.
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.0Every value needed came from the computation at . We never evaluated the loss at and never constructed the expression 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 and should predict the target . Give it one weight and one bias :
The hat in marks a prediction. At and , the prediction is , which is 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:
The current loss is . Zero would mean the prediction matches the target for this example.
Substitute the model’s computation into the loss:
This is the connection we need. The loss is now a function of the parameters we can change. For the fixed training example :
The number alone does not tell us which way to move. With , both and produce loss , 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 , target , and bias fixed. The loss becomes an ordinary function of one variable:
We can differentiate this small expression by hand and substitute the current weight, exactly as we did for :
| Step | Familiar function | Model’s loss with fixed training data |
|---|---|---|
| Write the function | ||
| Differentiate | ||
| Insert the current value | ||
| Obtain the derivative |
The variable is now the weight , rather than our earlier parameter . The training input and target are fixed numbers inside ; 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 through the prediction and error to the loss:
Each operation has a local derivative:
| Operation | What changes? | Local derivative |
|---|---|---|
| Change , hold fixed | ||
| Change , hold fixed | ||
| Change |
The chain rule multiplies these sensitivities along the path:
At our starting values, and :
A weight increase of should therefore change the loss by approximately .
For the bias, the last factor changes: . So:
The derivatives with respect to all trainable parameters together form the gradient. Here that vector is , in the order . 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:
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:
Select , then until you reach and . Every gradient displayed is a derivative of the final loss, even when the box represents an intermediate result.
Scroll the graph sideways to follow all the operations.
| Value | Forward value | Gradient |
|---|---|---|
| w | 1 | — |
| x | 2 | — |
| p = w × x | 2 | — |
| b | 0 | — |
| ŷ = p + b | 2 | — |
| 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 and . 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:
At , that derivative is . A backward pass must keep all three contributions, including both uses of 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 , both edge derivatives are . For , they are and .
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 . 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_derivativeThere 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 contributeThe 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 . 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-6This 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 , while central difference has error proportional to . Halving 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 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:
| Loss | Derivative with respect to prediction | Derivative with respect to weight |
|---|---|---|
| Squared error: | ||
| Absolute error: | , away from zero | , away from zero |
At our starting point, the prediction gradient is for squared error and for absolute error. Multiplying by the same model derivative, , gives weight gradients and .
Switch between and below. Move while watching both the prediction gradient and the weight gradient. The input stays fixed, so only the first factor changes.
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:
The learning rate sets the step size. With squared error, , and :
Holding the bias at zero, the new prediction is and the new loss is . Use and 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 , giving prediction 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 and let its input come from a multiplication: . Keep fixed and treat as the weight we adjust:
The multiplication gives , and the loss gives . Combining them:
At , we get and , so the gradient is . If an update moves to , then becomes and the gradient becomes :
| Weight | Intermediate value | Loss derivative | Weight gradient |
|---|---|---|---|
The rules stay the same, but their numerical values can change after an update. The multiplication’s derivative stays because is fixed; the loss derivative changes with . Reusing the old gradient of at would describe the slope at the previous weight, not the current one.
Move below to see these values change. From the initial weight of , select to move to and recalculate the gradient. This demonstration uses learning rate , so each update subtracts one sixteenth of the current gradient from the weight.
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 pairs satisfy . Add more examples:
| Input | Target |
|---|---|
| 0 | 1 |
| 1 | 3 |
| 2 | 5 |
| 3 | 7 |
These points lie on , so we can check whether training finds and .
Define the loss as the mean of the four squared errors:
The shared weight appears in all four predictions. During backward, each path contributes to the same w.grad. Multiplication by scales all contributions to match the mean loss. Summing instead of averaging would multiply both gradients by four.
Use to inspect one update, then . Try learning rate from the reset state to see loss grow even though the gradients are calculated correctly.
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_lossBoth 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:
Here, is one input, is its learned weight, is the learned bias, and is the activation function. The output is a single scalar; we keep for the target. In this section, indexes the inputs to one neuron, rather than the training examples in the previous section.
The dot product multiplies corresponding entries and sums them. Adding the bias makes an affine transformation from to . Applying a nonlinear activation such as ReLU, sigmoid, or tanh lets layers compose nonlinear functions. Our earlier model is the one-input case with the identity activation .
For a concrete example, take three inputs:
First compute the weighted sum plus bias:
With ReLU, the output would be . Our engine uses tanh, so its nonlinear neuron instead returns:
The inputs, weights, and bias determine ; 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:
The local derivative of the weighted sum with respect to is . The activation contributes . Multiplying these by the loss sensitivity arriving at the output gives:
For the bias, , so:
For tanh, . If this single neuron predicts a target and we use squared error, , then:
Return to the three-input example and choose target . At , the activation slope is approximately . For the first weight:
A small increase in 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 , recovering the earlier formula.
For a hidden neuron, arrives from the later layers. To continue backward, this neuron contributes to the gradient of input . 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 neurons has one weight vector and one bias per neuron. Stack the weight vectors as the rows of and the biases into :
Here, collects the layer’s outputs, and acts on each entry separately. An MLP feeds those outputs into the next layer.
The code below implements the same two stages: total is , 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 and different inputs as . 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_lossThe 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.pyWith the supplied seed, 1,000 steps, and learning rate , 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:
For positive , the activation’s derivative is . For negative , it is . At , ReLU is not differentiable; implementations commonly choose a derivative value of zero. When is negative, the weight gradient becomes:
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 :
| Local factors along the path | Product | Effect |
|---|---|---|
| Ten factors of | The gradient contribution shrinks | |
| Ten factors of | The 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 . When its output is close to or , 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 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.