Neural Networks & Backpropagation Calculus
Derive matrix calculus backpropagation step by step using the generalized chain rule, explore Adam moment bias correction proofs, and inspect zero-dependency neural network implementations in Python and Rust.
Neural networks extend linear models by interweaving linear matrix transformations with non-linear activation functions. The Universal Approximation Theorem guarantees that a feedforward network with a single hidden layer and non-linear activations can approximate any continuous function on compact subsets of to arbitrary precision.
To optimize millions of parameters simultaneously, deep learning relies on Backpropagation, an elegant application of the multivariate chain rule across directed computation graphs.
The outer product of the incoming error vector and outgoing activation yields the exact weight Jacobian.
1. Activation Functions and Exact Derivatives#
Non-linear activations prevent deep networks from collapsing into a single trivial linear transformation .
Analytical Derivatives#
- Sigmoid:
- Hyperbolic Tangent (Tanh):
- Rectified Linear Unit (ReLU):
- Leaky ReLU ():
2. Step-by-Step Matrix Calculus Derivation of Backpropagation#
Consider an -layer network. For layer :
- : Weight matrix
- : Bias vector
- : Linear pre-activation vector
- : Non-linear activation vector (with )
Let denote the scalar loss function.
The Error Vector Definition#
We define the error vector of layer as the partial derivative of scalar loss with respect to the linear pre-activation :
Step 1: Base Case (Output Layer Error $\delta^{(L)}$)
By the chain rule:
Because element-wise activations depend only on their own coordinate ():
Therefore, in vector notation:
Where represents the Hadamard element-wise vector product.
Step 2: Inductive Step (Hidden Layer Error Propagation)
Now, relate the error at hidden layer to the error at the subsequent layer :
By definition, . Next, evaluate :
Differentiating with respect to :
Substitute this back into the summation:
In matrix-vector notation, this is the canonical Backpropagation Equation:
Step 3: Compute Gradients for Weights and Biases
Now that is known, we compute the gradient with respect to weight matrix :
In outer product matrix notation:
For the bias vector:
3. Adam Optimization: Bias-Correction Derivation#
The Adam (Adaptive Moment Estimation) optimizer maintains exponentially decaying running averages of past gradients () and past squared gradients ():
Mathematical Proof of Moment Bias Correction#
Because moments are initialized to zero (), the running average is heavily biased toward zero in initial iterations.
Expanding recursively:
Taking expectations on both sides, assuming gradients come from a stationary distribution with true mean :
The summation is a finite geometric series:
Substituting this sum back:
To obtain an unbiased estimator such that , we divide by :
The final parameter update is:
4. From-Scratch Implementation: Zero Dependencies#
The following implementations build a 2-layer Multi-Layer Perceptron (MLP) trained on the classic non-linear XOR problem from scratch in pure Python and pure Rust.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384import math import random from typing import List class MultiLayerPerceptronFromScratch: def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, lr: float = 0.5): self.lr = lr self.hidden_dim = hidden_dim self.output_dim = output_dim # Xavier/He uniform weight initialization random.seed(42) scale1 = 1.0 / math.sqrt(input_dim) self.W1 = [[random.uniform(-scale1, scale1) for _ in range(input_dim)] for _ in range(hidden_dim)] self.b1 = [0.0] * hidden_dim scale2 = 1.0 / math.sqrt(hidden_dim) self.W2 = [[random.uniform(-scale2, scale2) for _ in range(hidden_dim)] for _ in range(output_dim)] self.b2 = [0.0] * output_dim @staticmethod def _sigmoid(z: float) -> float: if z < -700.0: return 0.0 if z > 700.0: return 1.0 return 1.0 / (1.0 + math.exp(-z)) @staticmethod def _sigmoid_derivative(a: float) -> float: return a * (1.0 - a) def forward(self, x: List[float]): # Hidden layer: z1 = W1 * x + b1, a1 = sigmoid(z1) z1 = [sum(self.W1[j][k] * x[k] for k in range(len(x))) + self.b1[j] for j in range(self.hidden_dim)] a1 = [self._sigmoid(val) for val in z1] # Output layer: z2 = W2 * a1 + b2, a2 = sigmoid(z2) z2 = [sum(self.W2[j][k] * a1[k] for k in range(self.hidden_dim)) + self.b2[j] for j in range(self.output_dim)] a2 = [self._sigmoid(val) for val in z2] return a1, a2 def train_step(self, x: List[float], y: List[float]): # 1. Forward propagation a1, a2 = self.forward(x) # 2. Output layer error: delta2 = (a2 - y) * sigma'(z2) delta2 = [(a2[j] - y[j]) * self._sigmoid_derivative(a2[j]) for j in range(self.output_dim)] # 3. Hidden layer error: delta1 = (W2^T * delta2) * sigma'(z1) delta1 = [0.0] * self.hidden_dim for k in range(self.hidden_dim): backprop_sum = sum(self.W2[j][k] * delta2[j] for j in range(self.output_dim)) delta1[k] = backprop_sum * self._sigmoid_derivative(a1[k]) # 4. Update W2 and b2: dW2 = delta2 * a1^T for j in range(self.output_dim): for k in range(self.hidden_dim): self.W2[j][k] -= self.lr * delta2[j] * a1[k] self.b2[j] -= self.lr * delta2[j] # 5. Update W1 and b1: dW1 = delta1 * x^T for j in range(self.hidden_dim): for k in range(len(x)): self.W1[j][k] -= self.lr * delta1[j] * x[k] self.b1[j] -= self.lr * delta1[j] def predict(self, x: List[float]) -> List[float]: _, a2 = self.forward(x) return a2 if __name__ == "__main__": # Non-linear XOR problem X = [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]] Y = [[0.0], [1.0], [1.0], [0.0]] mlp = MultiLayerPerceptronFromScratch(input_dim=2, hidden_dim=4, output_dim=1, lr=0.8) for epoch in range(10000): for sample_x, sample_y in zip(X, Y): mlp.train_step(sample_x, sample_y) print("XOR Predictions after 10k epochs:") for sample_x in X: pred = mlp.predict(sample_x)[0] print(f"Input: {sample_x} -> Prediction: {pred:.4f} (Class {round(pred)})")
5. Active Knowledge Checks#
In the matrix calculus derivation of backpropagation, why does the error vector delta^(l) transform by the transpose of the next layer's weight matrix (W^(l+1))^T?
Why does the Adam optimizer require bias-correction terms (dividing m_t by 1 - beta_1^t and v_t by 1 - beta_2^t) during initial training steps?