From derivatives to backpropagation: the theory behind Micrograd
The process of training a neural network involves searching for weights and biases that make its predictions more accurate. We compare those predictions with the expected answers using a loss function—a formula that turns their mismatch into a number, with a lower value indicating a better match. To choose an update, we need to know how changing each parameter would affect that loss. We could randomly nudge a parameter, calculate the loss again, and compare the results. But checking millions of parameters one at a time this way would require millions of additional evaluations.
To guide this search, we use the gradient, which collects the partial derivatives of the loss with respect to the parameters. These derivatives let us estimate how small changes in the parameters will affect the loss without recalculating it. For each parameter, the derivative describes how sensitive the loss is to that parameter while the others stay fixed.
Mathematically, this is an optimization problem, specifically minimization: we look for model parameter values that make the loss as small as possible. Although zeroth-order techniques such as MeZO can guide updates by randomly perturbing many parameters at once and evaluating the loss, the standard approach is to use derivative information to guide the search. Neural networks today are predominantly trained using gradients computed by the algorithm known as backpropagation—the approach we’ll develop here.
To understand backpropagation, we’ll build on several ideas: local derivatives, the chain rule, and computation graphs. Along the way, we’ll explore three methods for calculating derivatives: numerical differentiation, symbolic differentiation, and automatic differentiation. We’ll begin with functions small enough to differentiate by hand and compare their exact derivatives with finite-difference estimates, then move to functions with several inputs. We’ll use partial derivatives to isolate each input’s effect while holding the others fixed and collect these rates into a gradient.
Next, we’ll follow changes through connected operations, showing how the chain rule multiplies derivatives along a path and adds contributions when an input affects the output through several paths. The central idea is that knowing how to differentiate a small set of primitive operations lets us differentiate functions assembled from them. Even the derivative of a square can follow from multiplication alone. We’ll connect this idea to symbolic differentiation, then trace a forward and backward pass to see how reverse-mode autodiff reuses intermediate results to calculate gradients for many inputs—the approach used by backpropagation.
This article builds the theoretical foundations for understanding Andrej Karpathy’s Micrograd, a small automatic differentiation library with a PyTorch-like API. In his video walkthrough, he builds the library and uses it to train a neural network. The same principles underlie PyTorch’s torch.autograd.backward, which computes gradients using the chain rule.
From rate of change to derivatives
Let’s start with a simple linear relationship and find its derivative by examining the slope of its graph. A derivative tells us how quickly a function’s output changes as we change its input, at a particular input value. Here, any increase in produces an increase in three times as large. Using to represent a change, we can write:
When we plot horizontally and vertically, this constant rate of change gives a straight line. For a straight line, the derivative equals its slope, so we can find it by calculating the ratio of output change to input change, often called ‘rise over run’:
We can see on the graph below how the blue line rises by for a run of , giving a slope of .
The dotted gray line and dashed purple line rise by only and for the same run of . With the same axis scales, a larger absolute slope means a steeper line. All three slopes are positive, so the lines rise as we move right; a negative slope would make a line fall.
We write this derivative as , read as ‘the derivative of with respect to ’. This notation describes how quickly the output changes as the input changes. For our blue line:
The symbols indicate a derivative, whereas describes the average rate of change over a finite interval. For , the rate of change is constant and doesn’t depend on , so the derivative is wherever we evaluate it.
We’ve used the line’s slope to find the derivative. We can also calculate it using differentiation rules. Since , we can replace with its expression and write:
Here, the differential operator means ‘differentiate the expression that follows with respect to ’. In , that expression is , treated as a function of . This notation is especially useful when we treat individual terms in a larger expression as functions and differentiate them separately.
The constant-multiple rule captures the behavior we saw on the graph: multiplying an input by multiplies its changes by . To differentiate , we keep the multiplier and differentiate . The derivative of with respect to itself is :
Differentiation rules give us a systematic way to calculate rates that are harder to see in more complicated expressions. We’ll introduce more of these rules when we connect several operations.
A derivative that changes with the input
Let’s now look at a function whose rate of change depends on its input. This makes the derivative’s local nature clearer: it describes how the output changes near the particular input value where we evaluate it. To demonstrate that, we’ll use . We can also write to make its dependence on explicit:
We can write this derivative as , using the same notation we introduced above, called Leibniz notation. Another way to write it is , read as “z prime of x,” which is called Lagrange notation, or prime notation. Both mean the derivative of with respect to .
To find the derivative of , we’ll differentiate its two terms separately, then add the results. This uses three rules:
| Rule | How we use it |
|---|---|
| Power rule | Differentiate |
| Constant rule | Differentiate the constant term |
| Sum rule | Add the derivatives of the two terms |
Let’s first apply the power rule to : multiply by the exponent, then reduce the exponent by one. In general,
For , this gives . Then we apply the constant rule to . Its derivative with respect to is zero because it does not change as changes. And finally, we use the sum rule to add these derivatives. So for our function, the derivative is:
To see what this formula tells us, let’s calculate the function’s output and its derivative at three values of :
| Input | Output | Derivative |
|---|---|---|
Unlike the constant rate of in , this rate depends on the current value of . The function’s output is the same at and , but the derivatives have opposite signs. At , the positive derivative means the output rises as increases slightly. At , the negative derivative means the output falls as increases slightly.
To reduce the function’s output, the derivative’s sign tells us which way to move from each starting point: increase at and decrease at . The green arrows below show these directions, which we can determine without trying both an increase and a decrease in and recalculating the output. Both arrows point toward , where this function reaches its minimum and the derivative is zero. A zero derivative, however, does not always indicate a minimum.
A common way to visualize the derivative is as the slope of the tangent to the graph at a selected point. At , the derivative is , so the tangent has slope . Change below to see how the purple tangent’s slope changes along the blue curve.
The tangent also helps us estimate how much the function’s output changes nearby. At , where the output is and the derivative is , a small change in produces a change in output approximately times as large:
We use in the expression above because we are estimating the change in output over a small step. At , the derivative is exactly , but the slope changes as we move along the curve. Setting to in the widget gives a slope of ; a smaller move to would give . The smaller the step, the less the slope changes, so treating it as constant at gives a closer estimate of the change in output.
Estimating the derivative with finite differences
We just used a known derivative to estimate a small change in output. We can also work in the other direction: evaluate the function at two nearby inputs and use the output change to estimate the derivative. This returns to the rise-over-run calculation we used for . For a curved graph, however, the slope varies, so the ratio generally gives an approximation. This approach is called finite differences, a method of numerical differentiation. It requires function evaluations, but no formula for the derivative.
The forward-difference estimate divides the output change by a small, nonzero input change :
Let’s calculate the finite-difference estimate using . Starting at , a step of takes us to :
This is the average rate of change between the two inputs. Geometrically, it is the slope of the straight line joining their points on the curve, called a secant. The derivative gives the tangent’s slope at the starting point.
In the widget below, set to , where the purple tangent has slope . With at , the orange secant has slope . This is the finite-difference estimate. As we shrink , the orange slope approaches , the exact derivative.
We refer to this as an average because we divide the total change in output by the total change in input across the interval. The finite-difference approximation uses only the two endpoint values, so it doesn’t tell us how the function behaves between them. Its output could change more slowly in one part of the interval and more quickly in another.
Since we know the formula, 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 output actually changes. The shaded triangle shows the total input change and output 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 output is , gives:
| Interval | Input change | Output change | Average rate |
|---|---|---|---|
| Whole interval | |||
| First half | |||
| Second half |
The limit definition makes precise the idea of a rate of change at a single point: it is the value that the average rate of change approaches as the interval shrinks toward zero:
We let approach zero rather than substitute , which would give . Taking this limit lets us derive the rule . For our function, first simplify the ratio while keeping nonzero:
This makes the distinction explicit: a finite-difference calculation at a chosen step gives . Taking the limit as approaches zero removes the extra , giving us the derivative rule:
This explains the rule we applied symbolically earlier. Once we have the formula , we can evaluate it directly at any input without evaluating the function at a nearby input.
Forward difference compares with . Sampling behind gives a backward-difference estimate, while sampling equally on both sides gives a central-difference estimate. These are other types of finite-difference approximation. For the same function , we’ll now use the larger step :
| Method | Formula | Estimate at , |
|---|---|---|
| Forward | ||
| Backward | ||
| Central |
In exact arithmetic, as we make the step smaller, the forward, backward, and central difference estimates converge to the exact derivative—for our function at , that value is . Central difference is exact for this quadratic in exact arithmetic, but generally remains an approximation.
Software can estimate a derivative using finite differences: evaluate the function at two nearby inputs, subtract the outputs, and divide by the input difference. Choosing that difference requires care. A finite step generally introduces truncation error because we use an interval to estimate the rate at a point. Shrinking the step reduces this error, but an extremely small step creates another problem: computers store numbers with limited precision. Subtracting nearly equal function values can leave a difference dominated by rounding error, which division by a tiny amplifies.
These methods are useful for checking derivatives, but their additional function evaluations make them expensive when applied separately to millions of parameters.
Multiple inputs and partial derivatives
So far, we’ve used functions and with only one parameter . Let’s now look at and , with two parameters and that we can vary separately. To understand each parameter’s effect on , we change one while holding the other fixed. Changing both at once would mix their effects together.
Earlier, we wrote to describe how the output changes with the single input . For our two-input functions, we need to specify which input we vary. 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.
Geometrically, the two inputs determine a point on the surface . A partial derivative measures the slope at that point in one input’s direction, while the other input stays fixed. We therefore need both input values to specify where we measure the slope, even though we examine changes in only one direction.
When calculating a partial derivative, we apply differentiation rules while treating the other inputs as constants.
Let’s start with addition, . Holding fixed, increasing by increases by , so . Reversing the roles gives .
For multiplication, , the rate depends on the input we hold fixed. The following table shows how changes as we increase while holding at the constant value :
We can see that every time increases by , increases by . With held at , the rate of change with respect to is therefore :
As you can see, the rate of change is equal to the constant value of . This uses the same constant-multiple rule we applied to . The multiplier in was . In , the multiplier is , which we hold constant while differentiating . So when we keep one variable constant, it still participates in the calculation and influences how the output responds to changes in the other variable.
Differentiating with respect to works the same way: we hold constant and apply the constant-multiple rule, giving . The two inputs play symmetric roles, but their effects depend on the current values of and . At and , the output is , and the partial derivatives are and , respectively.
We can now summarize the local derivative rules for both operations:
| Operation | ||
|---|---|---|
We can derive the formula without knowing the numerical value of . To evaluate that derivative, however, we need its value: when , the derivative is . Holding fixed means it does not change as we vary ; its value still determines the rate of change.
Why does the derivative with respect to differ between these operations? In addition, shifts the output without changing how strongly affects it: increasing by increases by , whatever fixed value has. In multiplication, scales that effect: increasing by changes by . With , this is exactly the relationship we started with.
Holding fixed makes this scaling exact: . Holding fixed instead gives . Each operand is therefore the scaling factor for changes in the other operand. More generally, a local derivative is a scaling factor for small input changes; for a nonlinear function, that relationship is usually an approximation near the current input.
The derivative describes how an operation’s output responds to an input; it isn’t a property of alone. These derivatives are called local because they describe that relationship for a single operation. Later, we’ll connect them to follow changes through several operations.
The partial derivatives and describe sensitivity, not portions of the output . For equal small changes, varying alone has times the effect of varying alone. To estimate the effect of changing both inputs, we need both derivatives and the proposed input changes.
To do this, we collect the partial derivatives into a vector called the gradient. We denote the gradient with the symbol , so for our function at and , we write . This vector also has a geometric meaning in the input plane: among moves of the same small Euclidean length, it points toward the greatest first-order increase in the output. Its negative points toward steepest local decrease.
The graph below demonstrates these directions for . You can see how the purple arrow leads uphill from , where the output is , while the green arrow leads downhill. They trace surface paths as we move the inputs in the gradient direction or its opposite. The graph scales height separately; the gradient specifies a direction in the input plane.
Once we know the partial derivatives, we can use a first-order Taylor approximation, also called a linear approximation. It allows us to estimate the combined effect of small input changes on the output: we multiply each partial derivative by the corresponding input change and add the results.
This is the dot product of the gradient and the input-change vector. For our function at , it gives:
For example, increasing both inputs by gives:
Increasing by contributes to the predicted change, while increasing by contributes . Each contribution is a partial derivative multiplied by the corresponding input change. The derivatives describe sensitivity; the chosen changes determine how much of that sensitivity enters the prediction.
Evaluating the function at the new inputs gives an actual change of . The remaining is the interaction term , which the linear approximation leaves out. The terms and therefore describe contributions to the predicted change, not shares of the original output . The same distinction applies to backpropagation: it calculates the loss’s sensitivity to each weight. Multiplying those derivatives by proposed weight changes estimates their contributions to the loss change.
Combining differentiation rules for a more complex function
In the previous section, we calculated partial derivatives for addition and multiplication. Now we’ll combine differentiation rules in an expression with several terms and powers:
We’ll compare two equivalent approaches to calculating partial derivatives at a chosen point: differentiate first and substitute the values afterward, or substitute the other input’s value first, differentiate the resulting one-input function, and then evaluate its derivative. Both approaches produce derivative formulas. Plotting the one-input functions as slices through a surface will show why each partial derivative is also an ordinary tangent slope.
Differentiate first, then evaluate
We keep and as symbols, finding a partial derivative for each input while treating the other as constant. Then we’ll evaluate both derivatives at .
First, we differentiate with respect to , holding fixed so that acts as a constant multiplier of the derivative of . To differentiate , we use the power rule:
Then we differentiate with respect to , reversing the roles and holding fixed. Now is a constant multiplier of . The term does not depend on , so differentiating it with respect to gives zero:
This gives us symbolic formulas for the partial derivatives. We can evaluate them at a particular point by substituting its input values—for example, :
| Input we change | Symbolic formula | Partial derivative at | Predicted output change |
|---|---|---|---|
Each partial derivative tells us how quickly changes as we vary one input while holding the other fixed. As before, we collect the partial derivatives into a gradient vector. At , the gradient is . To estimate how small changes in both inputs affect the output, we multiply each change by its corresponding partial derivative and add the results. This is the dot product of the gradient and the input-change vector:
At , both coordinates specify the point where we measure the slopes. Moving in the -direction with fixed at gives a tangent slope of ; moving in the -direction with fixed at gives a slope of . These are two directions through the same point on the surface. Each rate is a partial derivative of the original two-input function and an ordinary derivative of its corresponding one-input slice. The gradient combines these rates into the direction of steepest local increase in the input plane.
Fix one input first, then differentiate the slice
Let’s now look at the second approach, where we fix one input first, then differentiate the slice. We substitute a value for one input in , leaving an ordinary function of the remaining input.
Each row below starts from the original function and fixes one input:
| Input held fixed | Remaining variable | Expression we will differentiate |
|---|---|---|
These expressions are slices of the original function. We have only substituted input values; we have not differentiated yet.
Let’s now differentiate the slice obtained by substituting into . Just as before, acts as a constant multiplier of , contributing to the derivative when . With only remaining as a variable, we can now take the ordinary derivative with respect to :
Then, for the slice obtained by substituting into , we differentiate with respect to :
Both slices pass through the same selected point, where .
The two approaches differ in the order of the steps: we can differentiate first and then substitute the fixed input’s value, or substitute that value first and then differentiate. Differentiating first gives the general formulas and . Substituting first and then differentiating gives for the slice with , and for the slice with —the same formulas we get by substituting those fixed values into the general derivatives. Evaluating the derivatives of these slices at gives the same values as our earlier calculation. Both produce:
We can now use this slicing technique to visualize how the function changes as we vary each input separately. At the top of the widget below, you can see the surface : its height represents the output for each input pair. The two purple curves trace slices through the selected point—one varies while holding fixed, and the other varies while holding fixed.
Below the surface, the two plots show these slices separately: on the left and on the right at the initial point . Each green tangent shows the slope at the selected point, giving the corresponding partial derivative. The orange arrow on the surface follows the gradient direction, combining both partial derivatives into a direction of steepest local increase in the input plane.
∂f/∂x = 31
∂f/∂y = 12
Starting from , moving the slider affects the two plots differently. On the left, is the varying input, so the selected point moves along the same curve while stays fixed. On the right, is the fixed input that defines the slice, so changing it from to changes the formula from to . In other words, moving moves the point along the left slice and changes which slice is shown on the right. Moving does the reverse.
Following derivatives through connected operations
Earlier, we used the sum rule to differentiate : we differentiated each term with respect to the chosen input, holding the other input fixed, and added the results. More generally, we can view each intermediate calculation as a function—for example, and , so our original expression is . Here, we combine the functions by adding their outputs. We can also multiply their outputs or use one function’s output as another’s input. Each way of combining functions has a corresponding differentiation rule.
The following table shows the rules for addition, multiplication, and composition (nesting). For simplicity, we’ll write these rules for two differentiable functions of one input, and :
| Rule | How the functions combine | Derivative with respect to |
|---|---|---|
| Sum rule | ||
| Product rule | ||
| Chain rule |
These rules also cover cases where one function stays constant as changes. Its derivative is then zero: in the sum rule, its contribution disappears; in the product rule, its value remains as a multiplier. If that function is a multiplier , differentiating gives . The same mechanism works with : if is constant with respect to , then , so reduces to , with acting as the constant multiplier. In our earlier term , the fixed value played the role of this constant multiplier when we differentiated with respect to .
The chain rule is especially important when we build complex functions by connecting simpler operations, with one operation’s output becoming another’s input. Knowing each operation’s local derivative lets us describe how it responds to its immediate inputs; the chain rule connects those derivatives to determine how the final output responds to an input earlier in the calculation.
These rules offer different ways to differentiate the same expression. We can also view the product as a composition: first calculate , then multiply its output by . The chain rule gives the same derivative, , by connecting those two operations’ local derivatives.
Let’s now look in detail at how the chain rule connects local derivatives in our function . We’ll start by splitting the calculation into individual operations, using braces to label their results within the original expression:
Using these names, we can write each operation separately and show which intermediate results it uses:
We can also draw the calculation as a graph, with arrows showing how inputs and intermediate results flow into subsequent operations:
For example, squaring maps to , and multiplication maps and to . The square’s output becomes an input to multiplication. When one function’s output becomes another’s input, the functions compose—a relationship called function composition, shown as in the table above. An operation has its own immediate inputs; connecting operations makes its result depend on earlier inputs as well.
The split follows the expression’s dependencies: must be available before , and both and must be available before . Since does not depend on or , we can calculate it before or after that branch. The split is not unique: we could express squaring and cubing as multiplications. Here, we treat them as individual operations and use the power rule for their local derivatives.
We’ll now use the chain rule to calculate derivatives through individual operations, multiplying local derivatives along each path and adding contributions from different paths. We’ll first follow one path, then examine an input that reaches the output through two paths.
The chain rule: following one path
We’ll start by differentiating with respect to , holding fixed. The split shows why is a useful starting point: changing it affects , then , while stays constant. Afterward, we’ll examine , which affects both and . For now, there is just one path from to the final output:
To see the difference between a local derivative and the chain rule, focus on just two operation nodes: the square and the multiplication , connected by .
Viewed on its own, the multiplication takes and as inputs. Its local partial derivatives are:
To derive each local derivative, we only need the operation’s expression and the input we differentiate with respect to. For , treating as constant gives , regardless of how was calculated or where is used. We do not need the rest of the graph for this step. The square separately supplies .
Notice how the chain rule includes the derivative of node , , separately, even though node already uses ’s value. The local derivative only tells us how responds to a change in . It does not include how changes with . That rate is , so we need both factors:
With fixed, multiplication is the outer function and squaring is the inner function. The outer derivative alone cannot tell us how the inner function changes: multiplication accepts ’s value without knowing whether it came from , , or another calculation. At , both and produce , but their derivatives are and , respectively. The same value therefore does not imply the same response to a change in . This distinction applies whenever we differentiate a composition.
If we substitute into , the complete expression contains that inner dependency. Differentiating it directly gives , accounting for both rates. The graph makes those two factors explicit.
The table below follows only the effect of changing , with held fixed. It focuses on the two operations and , stopping at to calculate :
| Step | Symbolic form |
|---|---|
| Define the operations | , then |
| Derive the local derivative formulas | , |
| Combine the formulas using the chain rule |
The connections matter when we combine local derivatives: the path tells us to multiply by to find . The operation supplies the local rule; the graph tells us how to connect it to the other rules.
Now continue to the final addition. The original function takes and , but each operation has its own inputs. For the final addition , those inputs are and . Its local derivative asks how the addition’s output changes when changes while stays fixed. This equals , because adding a small amount to adds the same amount to . The chain rule then connects these local responses back to the original input .
The following table lists the local derivatives along the full path:
| Operation | Local derivative |
|---|---|
| Square to obtain | |
| Multiply by the fixed value to obtain | |
| Add the fixed value to obtain |
To obtain the derivative of the final output with respect to , we can now apply the chain rule, multiplying the local derivatives along :
Each brace names the operation whose local derivative appears above it: addition contributes , multiplication by the fixed contributes , and squaring contributes .
The final addition also has a local derivative . Including both inputs to that addition gives:
The second contribution is zero because does not change with when is held fixed: . Although the graph has two branches into the final addition, only one carries a change from to . When we differentiate with respect to , both branches contribute, as we’ll see next.
This matches the formula we obtained by differentiating directly. Here, equals because the addition passes on the change in while stays fixed.
The widget below renders a graph of these connected calculations. It starts with and , the inputs the function depends on. The expressions inside the nodes, such as and , define the operations. Solid arrows point from an operation’s result to the operation that uses it. Thus means that uses the result of , making their composition visible. Multiplication uses , not its derivative ; the derivative labels and dashed arrows to the chain-rule box show how we differentiate the calculation. Follow the orange path and multiply the local derivatives to obtain .
Select or to compare which paths contribute, or to see the two partial derivatives together. Each partial derivative holds the other input fixed. The graph starts with the derivative for , holding fixed. We’ll derive the two-path result for in the next section.
Scroll the graph sideways to see the chain-rule box.
When we now take the derivative formula and substitute and , we obtain , matching the partial derivative we calculated earlier. We can evaluate this formula at any input pair without calculating the intermediate function values first.
At and , a small change in produces approximately change in . Multiplication scales that change by , giving approximately change in . Adding the unchanged leaves that change intact.
Multiple paths: adding derivative contributions
We’ve followed how affects the output through one path. Let’s now find the partial derivative of with respect to , holding fixed. We use the same intermediate results:
The value stays constant, but both and change with . This gives us two paths from to the final output:
We already calculated by differentiating with respect to , holding constant. The chain rule gives the same answer using the local derivatives along both paths:
| Operation | Local derivative |
|---|---|
| Multiply by the fixed value to obtain | |
| Cube to obtain | |
| Add and to obtain , considering the effect of | |
| Add and to obtain , considering the effect of |
Each local derivative of the addition holds its other input fixed. Both inputs change when changes, so we need both contributions.
We now apply the multivariable chain rule: multiply the local derivatives along each path, then add the two contributions to obtain the derivative of the final output with respect to :
The diagram below shows the two paths from to the final output: and . Both track the effect of changing , with held constant. The nodes and are gray because that branch stays fixed; it still supplies the value used by multiplication. The orange path contributes , and the purple path contributes . The active operation boxes show their local derivatives. The box above multiplies the derivatives along each path, then adds the contributions to obtain .
The diagram starts with selected. Switch to to compare the active paths and local derivatives while keeping the graph in place, or select to show them together.
Scroll the graph sideways to follow the paths.
When we now take the derivative formula and substitute and , we obtain . This matches the partial derivative we calculated earlier, without calculating the intermediate function values first.
Calculating derivatives with rules: symbolic differentiation and autodiff
The examples above combine local derivative rules to differentiate a larger function. We can take that decomposition further: even squaring and cubing can be built from multiplication, so their derivatives can follow from the multiplication rule.
A square’s derivative from multiplication alone
To differentiate a square using multiplication and the chain rule, write as . For a multiplication , the local derivatives are:
Here, both input positions refer to the same variable: and . Changing affects the result through both positions, so we add their contributions:
The derivative of the square follows from multiplication and the chain rule; we never needed a separate square rule. At , the two input positions contribute . Even though there is only one variable , both uses of it must contribute.
The same idea works for a cube. Write , then . Combining the derivatives gives:
We can therefore differentiate positive integer powers expressed as repeated multiplications without using the power rule. Other functions require additional basic operations: for example, multiplication alone cannot express or arbitrary fractional powers. The general principle is to combine known local derivative rules to differentiate functions built from those operations.
The same rules can produce expressions or numbers
In the square example, we kept as a symbol, constructed , and simplified it to . Producing derivative expressions this way is symbolic differentiation. Software can automate that expression construction using the same local rules and dependencies.
Micrograd applies those rules to numerical values. At , the original multiplication produces , and its backward rule accumulates into ‘s gradient. This is automatic differentiation: the calculation is mathematically equivalent to evaluating at , without constructing that symbolic formula. Backpropagation uses reverse-mode automatic differentiation to calculate the loss gradients needed for training.
Both methods compose the same calculus rules. In this comparison, what changes is whether we construct derivative expressions or execute the derivative calculations on numbers:
| Method | What it works with | What it produces |
|---|---|---|
| Symbolic differentiation | Symbolic expressions, including named intermediate expressions | Derivative formulas that we can evaluate at chosen inputs |
| Automatic differentiation | A program’s operations and their values at chosen inputs | Numerical derivatives obtained by combining local derivatives |
From derivative formulas to executable rules
In the earlier one-path symbolic graph, we wrote beside the operation . That formula also tells us how to calculate the derivative using arithmetic: take the input value and multiply it by . At , the result is . This is a numerical local derivative: the local derivative evaluated at specific input values.
Autodiff does not infer primitive derivative rules from the values or discover them symbolically during execution. The programmer supplies a rule for each primitive operation, translating its known derivative formula into code. If we choose squaring as a primitive, becomes 2 * input_value. The formula explains the rule mathematically; the code executes that same rule on numbers. At runtime, supplying the input value 2 gives 4. If we instead implement squaring as y * y, the two multiplication contributions give the same result, 2 + 2 = 4.
To differentiate combinations of those operations, we use their implemented derivative rules together with the graph and the chain rule. Each local rule is implemented once and reused whenever that operation appears:
| Program operation | Known local derivative rule | Numerical calculation |
|---|---|---|
u = y ** 2 | 2 * y.data | |
v = x * u | , | u.data, x.data |
w = x ** 3 | 3 * x.data ** 2 | |
f = v + w | , | 1, 1 |
Here, .data holds a node’s numerical value. Multiplication’s derivative with respect to is obtained by reading the other operand’s value, . Applying this local rule requires only that value, regardless of how it was calculated.
An autodiff implementation also records which inputs produced each result, preserving the dependencies shown in our graph. Ordinary arithmetic calculates values; autodiff adds the dependency tracking and derivative rules. It can also follow operations executed inside loops and conditional branches, without the need to rewrite the entire calculation as a single formula.
For the graph used in our walkthrough, we choose a dedicated power primitive, keeping each square and cube as one operation. Squaring and cubing can then be implemented as y.pow(2) and x.pow(3). This is an implementation choice; repeated multiplication would also work for these powers. A pow(exponent) method applies the general power rule:
For the positive integer exponents used here, its calculation is:
pow(exponent) {
return new Value(
this.data ** exponent, // function value
[[this, exponent * this.data ** (exponent - 1)]] // input and local derivative
);
}Here, this is the input node. The operation calculates its output value and records the connection to that input together with its numerical local derivative. The backward pass will use that saved derivative to propagate a gradient contribution to the input.
Calling y.pow(2) supplies the exponent , so the local derivative becomes . Calling x.pow(3) gives . The exponent comes from the operation’s argument; the programmer supplies the general derivative rule. The code evaluates that rule using the supplied exponent and input value. A specialized square() method can use the same calculation with the exponent fixed at .
The graph supplies the connections; the implemented rules supply the local derivative values. The chain rule tells us how to combine them.
We can now revisit the table from the symbolic example, again following only with held fixed to calculate . Autodiff executes the same operations at chosen inputs, so it also calculates their numerical output values. It still needs local derivatives, but it evaluates the rules already implemented for each operation rather than deriving new formulas during execution:
| Step | At , , with held fixed |
|---|---|
| Use the same operations | , then |
| Calculate the operation values | , then |
| Evaluate the local derivative rules | , |
| Combine the numerical derivatives using the chain rule |
Continuing through the final addition gives . This table isolates one input’s effect for explanation. Our symbolic calculation combined into the formula . Autodiff evaluates the individual rules at the current inputs and combines instead. It uses known derivative rules as numerical instructions, without constructing a symbolic derivative formula for the whole function.
The widget below uses the same graph as the one-path example. Switch between and to compare combining derivative expressions with combining their numerical values at and .
The derivative toggle also lets you compare the one path from with the two paths from in either mode. Select to see both formulas or both numerical derivatives together.
Scroll the graph sideways to see the chain-rule box.
In symbolic mode, the solid arrows show how the functions compose: each intermediate result becomes an input to another operation. In automatic differentiation mode, the same arrows also describe the forward flow of numerical values: squaring produces , which multiplication uses with to produce . The dependencies stay the same; we now evaluate the operations at specific inputs. This comparison widget does not animate that flow. The step-by-step walkthrough below will show these forward values and the backward propagation of derivative contributions.
Calculating operation values is distinct from calculating local derivatives. Each derivative rule uses whichever values it needs: along this path, the square’s rule needs , and the multiplication’s derivative with respect to needs . The rows in the numerical calculation table describe different tasks, not separate passes: an implementation can evaluate local derivative rules during its forward or backward pass. In this walkthrough, we evaluate and save them during the forward pass.
The comparison widget displays the evaluated derivatives; the later walkthrough will also display the forward values. The square’s output and its local derivative both happen to equal at , but they are different quantities: at , they would be and .
Evaluating the combined derivative formula gives the same result as evaluating the local derivatives first and then combining them using the chain rule. At , evaluating gives , as does multiplying . The operations and their dependencies stay the same; what changes is whether we combine derivative expressions or numerical values.
Avoiding an expanded formula matters because repeatedly substituting intermediate expressions can make a derivative formula much larger than the original expression—a problem called expression swell. The expanded formula may contain many copies of the same subexpression, taking more space and repeating work if each copy is evaluated separately. Keeping named intermediate results lets us reuse those calculations. Symbolic differentiation can preserve and share these expressions too, so expression swell is not an unavoidable limitation of the method.
Forward and reverse modes
The widget shows which local derivatives we combine, but not the order in which we propagate them. Its dashed connections collect the factors into a product. Forward-mode and reverse-mode autodiff can use the same computation graph, and both evaluate the function from inputs to output. They differ in how derivatives propagate: forward mode carries them from inputs toward the outputs, while reverse mode carries derivative contributions from an output back toward the inputs.
Starting with one chosen input, forward mode can calculate how all the outputs respond to that input in one derivative pass. Starting with one chosen output, reverse mode can calculate how that output responds to all the inputs in one backward pass. We’ll focus on reverse mode, the approach used by backpropagation. The complete backward pass will compute both and , accumulating contributions from both paths to .
A forward-mode derivative pass propagates derivatives alongside the function’s values. The forward pass of our reverse-mode calculation instead evaluates the function and records what the later backward pass needs; the derivatives of the final output are accumulated during that backward pass. We’ll follow this calculation in two passes:
- The forward pass evaluates the function. In this walkthrough, each operation calculates its value and saves its input connections and numerical local derivatives.
- The backward pass starts with the derivative of the output with respect to itself, , and uses the saved local derivatives to propagate numerical contributions backward using the chain rule. Contributions to the same input accumulate.
The table below shows what our forward pass calculates and stores at and . An operation’s type determines which derivative rule runs; the stored edges pair its inputs with the evaluated local derivatives:
| Calculation | Output value at | Stored input connections and local derivatives |
|---|---|---|
| Input , with | ||
| Input , with ; input , with | ||
| Input , with | ||
| Input , with ; input , with |
The output values and the local derivatives have different roles. For example, the multiplication uses to calculate its output , and that same operand value supplies its local derivative with respect to . Backward combines the stored local derivatives with accumulated sensitivities to the final output.
A model is a program, so autodiff has access to the operations that produced its output; a finite table of input–output observations alone cannot provide that information or determine exact derivatives. For a fixed training batch, the model’s prediction-and-loss calculation has many parameters and one loss value. Reverse mode reuses intermediate results to compute all the parameter derivatives in one backward pass, rather than repeating the whole calculation for each parameter.
We’ve established what the forward pass records and how the backward pass uses local derivative rules. Before tracing the full calculation, let’s look at how one multiplication uses its values during backward.
One value, two mathematical roles
In Micrograd, each Value object stores a forward value in .data and accumulates the derivative of the chosen final output in .grad. Start with two independent inputs and multiply them:
from micrograd.engine import Value
x = Value(2.0)
y = Value(3.0)
z = x * y
z.backward()The multiplication creates a third Value connected to both inputs. After z.backward(), the objects contain:
Here, is the final output, so backward starts with z.grad = 1: . The multiplication rule then gives:
| Input receiving a contribution | Local scaling factor | Backward update |
|---|---|---|
x | x.grad += y.data * z.grad → | |
y | y.grad += x.data * z.grad → |
The same y.data = 3 has two mathematical roles: it is an operand used to calculate , and it equals the scaling factor that tells us how responds to changes in . The operation’s derivative rule explains why a forward value can supply a local derivative. .data continues to store the forward value throughout backward.
This is why Micrograd’s multiplication backward rule can look deceptively simple:
self.grad += other.data * out.gradThree ideas meet in this line: other.data supplies a saved forward value; the multiplication rule identifies that value as the local derivative with respect to self; multiplying it by out.grad propagates a contribution using the chain rule. Micrograd evaluates that rule numerically without storing a separate symbolic derivative expression.
The same distinction applies to a neuron computing . Its local derivative with respect to the weight is , so for a loss , the loss gradient is:
Even when we only want gradients for the weights, we still need the input values to evaluate them. This local step uses and the upstream gradient; the upstream gradient can itself depend on through the forward calculation.
There are two mathematical roles for multiplication to distinguish here: multiplication as the operation being differentiated, and multiplication of derivatives to apply the chain rule. For , differentiating with respect to gives , and differentiating with respect to gives . Obtaining either local derivative only requires reading the other operand’s value. The * in other.data * out.grad then multiplies that local derivative by the upstream gradient. This chain-rule multiplication is needed even when the forward operation is addition, whose local derivative is ; its backward code can omit the multiplication by .
The same * syntax hides different mathematical roles
When we translate these derivative calculations into code, two mathematically distinct steps can look exactly alike. Backward code uses the same * operator both to evaluate local derivative formulas and to apply the chain rule. Both are ordinary numerical multiplication, so the code’s syntax does not distinguish their purposes. This makes compact backward expressions easy to misread.
For a square primitive , both roles appear in x.grad += (2 * x.data) * z.grad. Separating that expression makes the distinction visible:
local_derivative = 2 * x.data # evaluate dz/dx = 2x
contribution = local_derivative * z.grad # apply the chain rule
x.grad += contribution # accumulate this contributionThe first * evaluates the local derivative formula; the second combines that derivative with the upstream gradient. To understand a * in backward code, ask what its factors represent. In 2 * x.data, they belong to the square’s derivative formula. In local_derivative * z.grad, they are two sensitivities combined by the chain rule.
Returning to at , , suppose we build a fresh graph, extend it with , and call L.backward(). Here, is an intermediate value and names the final output whose derivatives we want. The multiplication receives z.grad = 2 * z.data = 12. It then contributes to x.grad and to y.grad. Its local derivatives are still and ; the upstream gradient now includes the effect of the later square.
The multiplication by out.grad always applies the chain rule, including when that gradient is the seed . As a graph grows, the incoming gradient incorporates downstream contributions one operation at a time. A square uses the same propagation mechanism, but its local factor is 2 * input.data; using the other operand’s value directly is specific to multiplication.
Reverse-mode autodiff step by step
Let’s now follow a complete reverse-mode autodiff calculation using the same function at . We’ll evaluate the four operations from the table above, then follow their derivatives backward to obtain the gradient :
x = Value(3)
y = Value(2)
u = y ** 2
v = x * u
w = x ** 3
f = v + w
f.backward()The forward pass gives , , , and the final output . Each result keeps its connections to the inputs that produced it, along with the information needed for its local derivative rule. The backward pass calculates how the final output responds to each node; it does not change these forward values.
The forward values are also called primals. The derivatives accumulated during the backward pass are called adjoints: each measures how the final output changes with respect to a particular input or intermediate value. In our widget, these are labeled value and grad, respectively. An adjoint is a derivative of the final output, distinct from an operation’s local derivative.
Before the backward pass, every .grad starts at zero. We seed f.grad = 1, because .
Each scalar node’s .grad stores the derivative of the final output with respect to that node. Together, x.grad and y.grad form the gradient vector . The walkthrough stores numerical local derivatives on the connections and uses them during backward: parent.grad += node.grad * local_derivative.
The earlier comparison widget followed only the path from to calculate . The widget below processes the whole graph in one backward pass, calculating the derivatives with respect to both and . These steps happen sequentially within the same pass. Each box shows its output value, numerical local derivatives, and accumulated gradient separately.
The inputs are fixed at and , equivalent to creating x = Value(3) and y = Value(2). Select to see the four operations produce the output. Then select and follow the backward steps through addition, cubing, multiplication, and squaring. Each backward step highlights the operation and its inputs, then connects the symbolic chain-rule expression to its numerical substitution and the corresponding .grad update below the graph. Watch how the two contributions accumulate at .
The following table summarizes the backward steps you can follow in the widget. The forward values stay unchanged throughout; only the accumulated gradients change.
| Backward step | Gradient update |
|---|---|
| Seed | |
| Addition | , |
| Cube | |
| Multiplication, contribution to | Add , giving |
| Multiplication, contribution to | |
| Square |
The two paths from contribute separately, while receives its contribution through and :
This order processes the cube branch before the multiplication branch. Other valid backward orders give the same result, provided a node receives all downstream contributions before it passes its accumulated derivative to its inputs.
The widget shows the full backward pass. Let’s now connect its multiplication and square steps to the code. The multiplication’s backward step multiplies two different derivatives:
The first factor tells us how the final output responds to . The second tells us how responds to , holding fixed. Multiplying them is the chain rule: it gives this path’s contribution to the derivative with respect to .
The walkthrough saves numerical local derivatives during the forward pass. Another implementation can save the operand values and evaluate the same local rules during backward. In that form, the multiplication method __mul__(self, other) creates a result node called out, and its backward rule can be written as:
self.grad += out.grad * other.data # contribution to the left operand
other.grad += out.grad * self.data # contribution to the right operandThese two lines handle the two input positions of one multiplication. Every executed operation has its own backward step, so a larger computation introduces more operations, each reusing the same small rules. The incoming out.grad already accounts for how this operation’s result affects the final output. The multiplication’s backward rule only needs that accumulated derivative and the operand values.
For v = x * u, out is , self is , and other is . Written with those names, the multiplication updates are followed by the square’s update:
# Multiplication: v = x * u
x.grad += v.grad * u.data # add 1 × 4 to the existing 27 → 31
u.grad += v.grad * x.data # add 1 × 3 → 3
# Square: u = y ** 2
y.grad += u.grad * (2 * y.data) # add 3 × 4 → 12The forward pass supplied , , and . Multiplication uses the other operand’s value for each local derivative. The square uses and multiplies it by the accumulated u.grad = 3.
Earlier, symbolic differentiation gave . The cube’s and multiplication’s x.grad += ... updates perform that addition numerically: the cube contributes , and the multiplication contributes , giving . The += in backward code implements the addition of derivative contributions in calculus. Using = would overwrite the cube’s contribution with the multiplication’s contribution, leaving instead of .
The same accumulation explains our earlier square example, s = x * x. Here, self and other reference the same Value object, so both backward lines update the same x.grad. Starting with x.grad = 0 and s.grad = 1, at they accumulate . If we instead multiply two separately created Value(3) objects, they represent distinct input nodes, each receiving a gradient of . Contributions accumulate at the node they describe; equal numerical values do not make two nodes the same input.
| Code | Meaning |
|---|---|
out.grad | Derivative of the final output with respect to this operation’s result |
other.data | Other operand’s value, which equals the local derivative for multiplication |
+= | Add this path’s contribution to the input’s accumulated derivative |
other.data stores a value, not a derivative. The multiplication rule tells us that this value equals the local derivative we need. For addition, the local derivative is , so the corresponding update is self.grad += out.grad * 1. For a power, we evaluate the power rule at the input: the cube’s local derivative is , and the square’s is .
In the symbolic widget, we combined the local derivative expressions along the path from :
The code follows the same connections but combines numbers. The addition gives v.grad = 1; multiplication gives u.grad = 1 × 3 = 3; the square gives y.grad = 3 × 4 = 12. Thus u.grad already contains the first two factors of the symbolic expression. Autodiff obtains the same derivative without constructing the combined formula .
These steps calculate derivatives at ; they do not update the inputs to minimize the function. The Value class automates the backward steps in a single call to f.backward().
From the calculation to an engine
A derivative measures local sensitivity. Partial derivatives isolate each input’s effect, and the gradient collects those measurements into a vector. Backpropagation calculates them by working backward through recorded operations: multiply derivatives along each path and add contributions where paths meet.
A neuron computes a weighted sum followed by an activation. The engine needs derivative rules for the primitive operations used in that calculation and in the loss, but no special derivative rule for a neuron or layer. Multiplication and addition cover the weighted sum; activations and loss functions must also be built from supported operations or supplied with their own derivative rules. The same operation rules work wherever those operations appear, so the engine can differentiate an entire network by following its computation graph.
To see these ideas implemented in Python, follow Andrej Karpathy’s Micrograd walkthrough. The local derivative rules, gradient accumulation, and backward traversal we explored here provide the foundations for understanding his implementation.
For a deeper mathematical view, explore this talk on automatic differentiation and linear maps. It connects calculus with linear algebra by showing how derivatives act as linear maps, how Jacobian matrices represent them, and how the chain rule combines them—the mathematical foundations of automatic differentiation.