From a slope to backpropagation: build a tiny autograd engine

In our article on how neural networks learn, we followed a model through its training loop: make a prediction, measure its error with a loss function, calculate gradients, and update the weights. To adjust the weights, we used gradients—derivatives that tell us how the loss changes with respect to each weight. Gradient descent uses these derivatives to choose the direction of an update.

In this article, we want to focus on how those gradients are calculated. If a model’s loss is 99, how do we work out that increasing one weight would reduce it? Which function are we differentiating, and where do its derivative rules come from?

To answer that, we need to follow the computation that produced the loss. The prediction and loss together form a function of the model’s parameters. We’ll build up from f(x)=x2f(x)=x^2, calculate derivatives through individual operations, and implement a small automatic differentiation engine in Python. Then we’ll use it to train a model.

Our engine uses reverse-mode automatic differentiation, or autodiff: it records the operations in a forward evaluation at the current parameter values, then applies derivative rules backward. We will also use finite differences for two separate purposes: visualizing what a slope means and independently checking the engine’s answers. Those nearby-point estimates belong to the explanation and verification; the training loop gets its gradients from autodiff.

You only need basic Python and algebra. We will introduce the calculus as we use it. The implementation follows the scalar-computation approach taught in Andrej Karpathy’s micrograd lecture, using an explicit table of local derivatives on graph edges. It requires no machine-learning libraries.

Start with a function and its derivative

Consider:

f(x)=x2.f(x)=x^2.

At x=5x=5, the output is 2525. The power rule gives its derivative, written f′(x)f'(x) or df/dxdf/dx:

f′(x)=2x,f′(5)=10.f'(x)=2x,\qquad f'(5)=10.

The derivative is the slope of the tangent at that point. It tells us how sensitive the output is to a small input change:

f(5+Δx)≈25+10Δx.f(5+\Delta x)\approx25+10\Delta x.

The derivative is the quantity we want. Several methods can calculate or approximate that quantity.

Three ways to obtain the same derivative

Keep the function f(x)=x2f(x)=x^2 and evaluation point x=5x=5 fixed:

MethodWhat it usesCalculation at x=5x=5Result
Symbolic differentiationRules applied to an expressionDerive 2x2x, then substitute 551010
Finite differencesOutputs at nearby inputs(f(5.001)−f(5))/0.001(f(5.001)-f(5))/0.001Approximately 10.00110.001
Automatic differentiationThe executed operations and their local derivative rulesFor x * x, add the contributions 5+55+51010

Symbolic differentiation produces a derivative expression that we can evaluate later. Autodiff evaluates derivative rules using the values recorded during the computation. For this smooth function, both calculate the derivative at the chosen point, subject to floating-point arithmetic in code. Finite differences estimate it by comparing outputs at different points.

Neural-network backpropagation uses the third approach. We supply derivative rules for basic operations, and the engine combines them through the chain rule. It can compute the gradient at the current parameter values without generating one large symbolic expression for the whole network.

Autodiff through one multiplication

Write the same function as a program:

def f(x):
    return x * x

We already know the derivative rule for multiplication. If z=abz=ab, its local derivatives are:

∂z∂a=b,∂z∂b=a.\frac{\partial z}{\partial a}=b, \qquad \frac{\partial z}{\partial b}=a.

For x * x, both operands refer to the same input. At x=5x=5, each operand is 55, so each local derivative is also 55. A change in xx affects the result through both operand paths. Their contributions add:

d(x⋅x)dx=5+5=10at x=5.\frac{d(x\cdot x)}{dx}=5+5=10 \quad\text{at }x=5.

Select Evaluate x × x, then Seed output gradient. Follow each operand in turn to see the accumulated gradient reach 1010.

Experiment 1 · automatic differentiationOne input, two operand paths
Differentiate x times x through its two operand pathsBoth paths connect the same input x=5 to one multiplication. Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.Left operand: xRight operand: xShared input xvalue = 5grad so far = —q = x × xvalue = —∂q/∂q = —
Forward: values →Backward: gradient contributions ←
Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.
Input used for this trace: x = 5.

The trace uses the multiplication edges from the article’s scalar engine. Changing x starts a new trace at that value.

The widget starts the backward pass with the output’s derivative with respect to itself: dq/dq=1dq/dq=1. Each path multiplies that incoming gradient by its local derivative. In ordinary Python, the arithmetic is:

x = 5.0
left, right = x, x
q = left * right             # forward: 25.0

output_grad = 1.0            # dq/dq
x_grad = 0.0
x_grad += output_grad * right  # left operand contributes 5.0
x_grad += output_grad * left   # right operand contributes 5.0
print(x_grad)                 # 10.0

Every value needed for this derivative came from the computation at x=5x=5. We used the multiplication rule and the saved operands. Evaluating f(5.001)f(5.001) belongs to the finite-difference method in the table; the autodiff trace above is complete with the single evaluation point.

The automation comes from recording these operand paths and applying their derivative rules for us. Later, our Value class will make the same calculation happen when we write q = x * x and call q.backward().

Knowing the operations and understanding the shape

Suppose we had only a table:

xxObserved output
11
24
39
…\ldots…\ldots
10100

Those finite observations do not uniquely determine the function between the listed points, or its exact derivative at a listed point. Multiple functions can agree with every row and have different slopes. Computing a finite-difference estimate from available nearby samples is one option.

With the program return x * x, we have more information: we know the operations that generate the output, so we can apply their derivative rules. A neural network likewise has known computations for its predictions and loss. The training examples may arrive as a table, but the model and loss are executable mathematical functions.

Knowing those operations also does not automatically give us an understanding of the entire curve. Consider:

g(x)=sin⁡(x2)+0.2x2,g′(x)=2xcos⁡(x2)+0.4x.g(x)=\sin(x^2)+0.2x^2, \qquad g'(x)=2x\cos(x^2)+0.4x.

We can evaluate the value and derivative at any chosen point using these formulas. Can you picture every rise, dip, and turning point just from reading them? Move Known evaluation point x below, then select Reveal full curve. The local value and slope are available before the full plot is visible.

Experiment 2 · local slope and global shapeCompute a slope before revealing the curve
g(x) = sin(x²) + 0.2x²
g(x) against xAt x=1, g(x)=1.0415 and its derivative is 1.4806. Only the selected point and its tangent are visible.-4-1-20.50223.545g(x)x
Known operations: x² = 1 → sin(x²) = 0.8415 → add 0.2x² = 0.2.
g(x) = 1.0415 · g′(x) = 2x cos(x²) + 0.4x = 1.4806

The value and derivative are computed from known rules at the selected point. Revealing the curve additionally samples the function across the displayed interval.

The formula specifies the function completely. Understanding its global geometry requires additional evaluation or analysis. A plot gives us more of that information over a chosen interval, while a derivative describes local sensitivity at one point.

For a neural network, the loss may depend on millions of weights and biases. We know the program that computes L(w1,w2,…)L(w_1,w_2,\ldots) at a chosen parameter setting. Its gradient gives local sensitivities there. Those computations do not locate every minimum elsewhere in that high-dimensional space. Training uses the local information to choose an update, then calculates a new gradient at the updated parameters.

Use nearby points to visualize a slope

Now return to f(x)=x2f(x)=x^2 for the first of our two uses of finite differences: building slope intuition. We have already calculated its derivative through autodiff. This experiment illustrates what that derivative means geometrically.

Compare f(5)=25f(5)=25 and f(5.001)=25.010001f(5.001)=25.010001. Their average slope is:

f(5.001)−f(5)0.001=10.001.\frac{f(5.001)-f(5)}{0.001}=10.001.

For a general nonzero input change hh:

f(x+h)−f(x)h=(x+h)2−x2h=2x+h.\frac{f(x+h)-f(x)}{h} =\frac{(x+h)^2-x^2}{h} =2x+h.

As hh approaches zero, this ratio approaches the derivative 2x2x. At x=5x=5, the finite-difference estimate approaches 1010.

Reduce Small change h to 0.0010.001 and watch the orange secant approach the purple tangent. Then move Position x to a negative value: a negative slope means that a small increase in the input decreases the output.

Experiment 3 · finite-difference intuitionNearby values illustrate the slope
f(x) = x² against xAt x=5, the tangent slope is 10. With h=0.8, the secant slope is 10.8.-60-310020330640f(x) = x²x
CurveTangent: exact derivativeSecant: finite difference
(f(x + h) − f(x)) / h = 10.8
f′(x) = 2x = 10

A small increase in x increases f(x). The finite-difference error is h = 0.8.

Purpose: visualize the meaning of a derivative. The training experiments compute gradients with automatic differentiation.

This nearby-point calculation is a finite difference. Later, we will use another finite difference to independently verify the engine. In the training experiments, gradients come from recorded operations and their local derivative rules.

For x2x^2, the exact output change is 2xΔx+(Δx)22x\Delta x+(\Delta x)^2. The derivative captures the first-order part; it does not predict arbitrary finite changes exactly.

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 x2x^2:

StepFamiliar functionModel’s loss with fixed training data
Write the functionf(t)=t2f(t)=t^2L(w)=(2w−5)2L(w)=(2w-5)^2
Differentiatef′(t)=2tf'(t)=2tL′(w)=8w−20L'(w)=8w-20
Insert the current valuet=5t=5w=1w=1
Obtain the derivativef′(5)=10f'(5)=10L′(1)=−12L'(1)=-12

The letter naming the variable has changed from tt to ww. 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.

This model is small enough to expand into a polynomial. For a larger network, autodiff records the forward operations at the current parameter setting and evaluates their local derivative rules backward. It computes the numerical values of the parameter derivatives without first constructing one expanded symbolic formula for the network’s gradient.

Which derivative do we take?

For the weight, we want:

∂L∂w.\frac{\partial L}{\partial w}.

Read this as: how quickly would the loss change if we changed the weight, holding the other independent quantities fixed? The symbol ∂\partial is used because LL depends on several variables. For this calculation, the input, target, and bias stay fixed.

The model and loss form a composition. First compute the prediction, then subtract the target, then square the error:

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. The exact new loss is (2.02−5)2=8.8804(2.02-5)^2=8.8804, a change of −0.1196-0.1196. The agreement improves as the step gets smaller.

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

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.

Experiment 4The 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.

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.

Experiment 5Follow 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.

An important distinction appears when a value has multiple paths to the loss. Contributions from those paths add. For example:

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.

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.

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.

Experiment 6Use 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.

A neuron multiplies inputs by weights, adds a bias, and optionally applies tanh. An MLP chains layers of neurons. 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.

What the loss contributed

We started with a derivative of x2x^2, which needed no notion of a model or training. Then we chose a prediction function and attached a loss. Substitution made the loss a function of the parameters, giving us a precise question to ask: how would this objective change if one parameter changed?

The loss defines the objective and its sensitivity to the prediction. The model supplies the paths from parameters to that prediction. Backpropagation combines local derivatives along those paths, adding contributions where paths meet. Gradient descent uses the resulting parameter gradients to choose an update.

For a model built from operations with usable derivative rules, a scalar loss gives our differentiation engine an output to start from, and an objective for the optimizer to reduce.

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.