Building a neural network for MNIST digits from scratch using only NumPy requires pulling back the abstractions of modern frameworks. Below is the end-to-end mathematical breakdown—from raw grayscale pixels to calculus-driven gradient descent—before writing a single line of code.
Core Conceptual Pillars
Classifying digits ( through ) with a feedforward architecture relies on three primary pillars:
-
Architecture & Dimensions: Flattening a grayscale image into vectors and defining explicit matrix dimensions for weights () and biases ().
-
Forward Propagation & Activations: Executing linear transformations () and non-linearities ( for hidden representations, for multi-class probabilities).
-
Loss & Backpropagation: Quantifying prediction divergence using Categorical Cross-Entropy, applying the multivariable chain rule, and updating parameters via Gradient Descent.
1. Network Architecture & Dimensions
A standard two-layer architecture (one hidden layer, one output layer) handles the MNIST classification task:
| Layer | Input Dimension | Output Dimension | Weight Matrix (W) | Bias Vector (b) | Activation |
|---|---|---|---|---|---|
| Input () | — | — | — | Flatten | |
| Hidden Layer () | |||||
| Output Layer () |
The Input Layer
Each MNIST sample is a single-channel grayscale image of pixels. Fully connected networks require a 1D column vector:
The Hidden Layer
With hidden units, the pre-activation equation is:
To satisfy matrix dimension constraints :
Rather than viewing as abstract arithmetic, interpret it as 128 parallel template matchers:
-
Row-Wise Decomposition: Each row of contains weights corresponding to neuron :
-
Neuron as a Spatial Filter: Reshaping that row back into a grid reveals a spatial blueprint. Computing the dot product acts as a cross-correlation:
-
Bright pixel Positive weight: High positive activation.
-
Bright pixel Negative weight: Penalty/inhibition.
-
Zero/Background pixel: No contribution.
-
-
Feature Projection (): The network shifts from raw sensory questions (“Is pixel (14, 12) lit?”) to semantic building blocks (“Does the image contain an upper loop or a vertical stem?“).
2. Forward Propagation & Activations
Hidden Activation: ReLU
The hidden layer maps linear combinations through a Rectified Linear Unit () element-wise:
Output Transformation & Softmax
The output layer maps the features to class logits:
To turn raw real-valued scores into a normalized probability distribution where , apply the Softmax function:
3. Target Encoding & Loss Function
One-Hot Encoding
Targets are converted to 10-dimensional unit vectors. For target digit :
Categorical Cross-Entropy
Loss for a single sample measures the divergence between target and prediction :
Because for all classes except target , the expression collapses to the negative log-likelihood of the correct class:
4. Backpropagation & The Output Layer Gradients
Gradients trace how changes to intermediate variables affect total loss using the multivariable chain rule.
Output Error ()
The derivative of the loss with respect to raw output logits is defined as:
Because changing a single logit alters the normalization denominator , every predicted probability shifts. By the multivariable chain rule:
Evaluating this summation resolves with clean algebraic cancellation:
The gradient at the output is simply the error vector—predicted probabilities minus ground truth.
Output Parameters ()
Applying the chain rule through the linear transformation :
-
Weight Gradient ():
-
Bias Gradient ():
Examining a single weight scalar connecting hidden unit to output unit :
To compute all pairwise products between error vector and activations simultaneously, matrix multiplication requires transposing :
Summary of Parameter Derivatives
| Parameter | Scalar Form | Matrix Form | Physical Intuition |
|---|---|---|---|
| Weight () | Scales the downstream error by how actively the input unit was firing. | ||
| Bias () | Adds directly to pre-activation (), absorbing downstream error directly. |
5. Backpropagation: The Hidden Layer Gradients
With the output layer gradients (, ) resolved, we must propagate the error backward across the hidden layer to update and .
Step 1: Pulling Error Through the Weights ()
We need to determine how the total loss changes with respect to the post-activation hidden states:
Recall the forward relationship: . Because hidden unit connects to every output neuron , any change in propagates downstream along 10 distinct pathways.
Applying the multivariable chain rule:
In matrix notation, this summation corresponds to multiplying the incoming error vector by the transpose of the weight matrix :
- Matrix Dimensions: .
Intuition: The Transpose as a Reverse Router
During forward propagation, routes signals forward from hidden units to output units:
During backward propagation, we run the network in reverse. Transposing the matrix to inverts the mapping:
Each hidden unit receives a fraction of the error from each output neuron , directly proportional to the strength of the connection that linked them during the forward pass.
Step 2: Passing Through the ReLU Gate ()
Before we can calculate parameter updates, the error must pass backward through the activation function:
Because ReLU is an element-wise operation, each neuron’s pre-activation affects only its own post-activation :
The derivative of ReLU is a simple binary indicator:
Using the Hadamard (element-wise) product :
- Matrix Dimensions: .
- The “Dead Gate” Mechanism: If a neuron had a negative pre-activation during forward propagation (), its derivative is . It completely zeros out the gradient, stopping backpropagation dead in its tracks for that neuron.
Step 3: Gradients for Layer 1 Parameters ()
Finally, we project the filtered error back to the network’s input features :
Using the identical outer-product relationship established for Layer 2:
-
Weight Gradient ():
-
Bias Gradient ():
Complete Gradient Computational Graph
Tracing backwards from loss to raw pixels:
Loss L
│
▼
dZ[2] = ŷ - y (10 × 1)
├──► dW[2] = dZ[2] · (A[1])ᵀ (10 × 128)
├──► db[2] = dZ[2] (10 × 1)
│
▼
dA[1] = (W[2])ᵀ · dZ[2] (128 × 1)
│
▼
dZ[1] = dA[1] ⊙ (Z[1] > 0) (128 × 1)
├──► dW[1] = dZ[1] · Xᵀ (128 × 784)
└──► db[1] = dZ[1] (128 × 1)
6. Bridging Math to Code: Vectorization, Initialization, and Numerical Stability
Before translating our derivations into NumPy, we must address three practical engineering realities: processing multiple samples in parallel, initializing weights to preserve gradient scale, and preventing floating-point overflow.
1. Batch Vectorization ( Samples)
Training one image at a time via pure Python loops is computationally prohibitive. Instead, we stack training examples side-by-side into a single batch matrix:
Because linear algebra distributes over columns, the forward propagation equations remain identical in matrix notation:
Broadcasting Note: The bias vector is automatically copied across all columns by NumPy during addition.
When computing gradients over a batch, the loss is defined as the mean error across all examples. Thus, parameter gradients must be averaged by dividing by :
| Gradient | Vectorized Formula | NumPy Equivalent |
|---|---|---|
| Output Error | dZ2 = A2 - Y | |
| Layer 2 Weights | dW2 = (1 / m) * np.dot(dZ2, A1.T) | |
| Layer 2 Bias | db2 = (1 / m) * np.sum(dZ2, axis=1, keepdims=True) | |
| Hidden Error | dZ1 = np.dot(W2.T, dZ2) * (Z1 > 0) | |
| Layer 1 Weights | dW1 = (1 / m) * np.dot(dZ1, X.T) | |
| Layer 1 Bias | db1 = (1 / m) * np.sum(dZ1, axis=1, keepdims=True) |
keepdims=True ensures the bias shape stays rather than collapsing into a 1D rank-1 array , which prevents subtle broadcasting bugs.
2. Parameter Initialization (He / Kaiming Normal)
How weights are initialized dictates whether signals survive propagation across deep layers.
- The Zero Trap (): If weights are initialized to zeros, every neuron in hidden layer 1 computes , outputs identical activations, and receives identical gradients. The neurons remain symmetric and cannot learn distinct features.
- Large Random Values: Initializing with unscaled random normals (e.g., standard normal ) causes the variance of layer outputs to grow with the number of input connections (), triggering exploding activations.
Because Layer 1 uses ReLU, which zeroes out negative inputs and halves output variance, we use He (Kaiming) Initialization. Weights are sampled from a zero-mean normal distribution scaled by :
# Layer 1: n_in = 784, n_out = 128
W1 = np.random.randn(128, 784) * np.sqrt(2.0 / 784)
b1 = np.zeros((128, 1))
# Layer 2: n_in = 128, n_out = 10
W2 = np.random.randn(10, 128) * np.sqrt(2.0 / 128)
b2 = np.zeros((10, 1))
Biases contribute no symmetry issues and are safely initialized to zeros.
3. Numerically Stable Softmax
The standard Softmax formula involves computing exponentials directly:
In 64-bit floating point arithmetic (float64), overflows to inf when . In 32-bit (float32), this occurs around . Once an exponential overflows, division produces NaN.
To resolve this, apply the identity:
By choosing (the maximum logit for each column sample):
-
The largest exponent becomes .
-
All other terms are strictly , bounding exponents to the interval and eliminating overflow risks entirely.
Python
def softmax(Z):
# Shift Z along class axis (axis=0) by the maximum logit of each sample
shifted_Z = Z - np.max(Z, axis=0, keepdims=True)
exp_Z = np.exp(shifted_Z)
return exp_Z / np.sum(exp_Z, axis=0, keepdims=True)
4. Parameter Updates (SGD)
With the gradients averaged across the batch, update parameters using learning rate :
<FollowUp label="Want to assemble the complete, runnable NumPy training script?" query="Write the complete, runnable Python/NumPy training script for MNIST based on these notes."/>
import pandas as pd
import kagglehub
# Download the Kaggle dataset
path = kagglehub.dataset_download("oddrationale/mnist-in-csv")
train_path = f"{path}/mnist_train.csv"
test_path = f"{path}/mnist_test.csv"
train_df = pd.read_csv(train_path)
test_df = pd.read_csv(test_path)
x_train = train_df.drop('label', axis=1).values / 255.0
y_train = train_df['label'].values
x_test = test_df.drop('label', axis=1).values / 255.0
y_test = test_df['label'].values
np.random.seed(7882)
W1 = np.random.rand(128, 784) * 0.01 # Scale from [0,1) to [0, 0.01)
b1 = np.random.rand(128, 1) * 0.01
W2 = np.random.rand(10, 128) * 0.01
b2 = np.random.rand(10, 1) * 0.01
# W1 = np.random.randn(128, 784) * np.sqrt(2.0 / 784)
# b1 = np.zeros((128, 1))
# W2 = np.random.randn(10, 128) * np.sqrt(2.0 / 128)
# b2 = np.zeros((10, 1))
def relu(x):
return np.maximum(0, x)
def softmax(logits):
# res = np.zeros(10)
# for i, logit in enumerate(logits.ravel()):
# res[i] = np.exp(logit) / np.sum(np.exp(logits))
# return res
# Shift logits by max to avoid overflow
shifted = logits - np.max(logits)
exp_scores = np.exp(shifted)
return exp_scores / np.sum(exp_scores)
def forward(x):
# Ensure x is a column vector
# in our dataloader, x.shape is (784, ) which's 1d numpy array
if x.ndim == 1:
x = x.reshape(-1, 1)
Z1 = W1 @ x + b1
A1 = relu(Z1)
Z2 = W2 @ A1 + b2
A2 = softmax(Z2)
return A2, A1, Z1
def backward(x, y):
if x.ndim == 1:
x = x.reshape(-1, 1)
pred, A1, Z1 = forward(x)
y_one_hot = np.zeros_like(pred)
y_one_hot[y] = 1.0
loss = -np.log(pred[y])
dZ2 = pred - y_one_hot # dim(dZ2) = (10,)
dZ2 = dZ2.reshape(-1, 1)
dW2 = dZ2 @ A1.T # (10, 128)
db2 = dZ2
# Hidden Layer
dA1 = W2.T @ dZ2 # (128,1)
dZ1 = dA1 * (Z1 > 0) # (128,1) element-wise
dW1 = dZ1 @ x.T # (128,784)
db1 = dZ1 # (128,1)
return dW1, db1, dW2, db2, loss
def gdescent(lr=0.01):
global W1, b1, W2, b2
for i in range(5):
total_loss = 0
for sample_i in range(len(x_train)):
x = x_train[sample_i]
y = y_train[sample_i]
dW1, db1, dW2, db2, loss = backward(x, y)
W1 = W1 - dW1 * lr
b1 = b1 - db1 * lr
W2 = W2 - dW2 * lr
b2 = b2 - db2 * lr
total_loss += loss
print(f"Epoch {i} loss: {total_loss / len(x_train)}")
import time
# Start timer
start_time = time.time()
gdescent()
# End timer
end_time = time.time()
training_time = end_time - start_time
print(f"Total training time: {training_time:.2f} seconds")
correct = 0
for i in range(x_test.shape[0]):
probs, _, _ = forward(x_test[i])
pred_label = np.argmax(probs)
ground_truth = y_test[i]
if pred_label == ground_truth:
correct += 1
print(f"Acc: {correct}/{x_test.shape[0]} -> {correct / x_test.shape[0]}")