Neural Networks from Scratch
Forward pass, backpropagation, and gradient descent built in NumPy before touching PyTorch. The foundation every deep learning framework is built on.
Every algorithm in Section 5 required you to hand-craft features. A neural network learns its own features directly from raw data. That is the entire revolution.
When DoorDash wants to predict delivery time, you manually build features: distance, traffic score, restaurant prep time, time of day. You encode your domain knowledge into numbers. The model learns relationships between those numbers and the target. The quality of your model is bounded by the quality of your features.
Now imagine DoorDash wants to detect damaged packaging from a photo. What features do you hand-craft from an image? Pixel brightness? Edge patterns? Colour distributions? You do not know which pixel combinations indicate damage. A neural network does not need you to know. It learns the relevant features — edges, shapes, textures — directly from thousands of labelled photos. The layers of a network are a hierarchy of learned feature detectors, going from raw pixels to abstract concepts without any human guidance.
This module builds a neural network from scratch in NumPy — no PyTorch, no TensorFlow. Every operation is explicit. You will understand exactly what a forward pass does, what backpropagation computes, and why gradient descent works. After this module, PyTorch becomes obvious — it automates exactly what you will code by hand here.
Imagine teaching a child to recognise cats. You could write down rules: "furry, four legs, pointed ears, whiskers." That is classical ML — hand-crafted features. Or you could show the child 10,000 photos of cats and non-cats and let them figure out the pattern themselves. They learn features you never named — the specific curve of an ear, the texture of fur, the shape of eyes. That is a neural network.
The child's brain adjusts internal connections after each photo — strengthening what was right, weakening what was wrong. A neural network does exactly this: adjust weights after each prediction based on how wrong it was. That adjustment process is backpropagation. The rule for how much to adjust is gradient descent.
One neuron — weighted sum plus activation
A single neuron does two things. First it computes a weighted sum of its inputs — each input multiplied by a weight, all added together, plus a bias term. Then it applies an activation function to that sum — a non-linear transformation that lets the network learn non-linear patterns. Without activation functions, stacking many neurons would still only produce a linear model.
Activation functions — why they matter and which to use
Output zero for negative z, z for positive. Simple, fast, does not saturate for positive values. Default choice for hidden layers.
Squashes output to (0, 1). Interpretable as probability. Saturates at both ends — gradients vanish for large |z|.
Converts a vector of scores to probabilities summing to 1. Each output is the probability of that class.
Squashes to (−1, 1). Zero-centred — better gradient flow than sigmoid. Still saturates for large |z|.
The forward pass — data flows through layers, one matrix multiply at a time
A neural network is multiple neurons stacked into layers. Every neuron in one layer connects to every neuron in the next — a fully connected (dense) layer. The forward pass computes a prediction by passing data from the input layer through each hidden layer to the output layer. Each layer is one matrix multiplication plus an activation.
Backpropagation — the chain rule applied backwards through the network
The forward pass produces a prediction. The prediction is wrong. We compute the loss — how wrong it is. Now we need to know: how should each weight change to make the prediction less wrong? The answer is the gradient of the loss with respect to each weight — ∂Loss/∂W.
Backpropagation computes these gradients efficiently using the chain rule from calculus. The loss depends on the output, which depends on layer 3, which depends on layer 2, which depends on layer 1, which depends on the weights. Backprop unrolls this chain from output back to input — hence "backward" propagation.
MSE loss for regression: L = mean((y_pred − y_true)²). The gradient of MSE with respect to the prediction is: ∂L/∂A3 = 2 × (y_pred − y_true) / n
∂L/∂W3 = A2ᵀ @ ∂L/∂A3 — how much does the output layer weight contribute to the loss? ∂L/∂A2 = ∂L/∂A3 @ W3ᵀ — how much does the signal from layer 2 contribute?
ReLU kills gradients for negative pre-activations. ∂L/∂Z2 = ∂L/∂A2 × relu_derivative(Z2) — element-wise multiply. Zero where Z2 was negative, pass-through where Z2 was positive.
Apply the same pattern for layer 1. Each layer produces two gradients: one for its weights (∂L/∂W) and one to pass backward (∂L/∂A_prev).
Gradient descent — update weights, repeat until convergence
Backpropagation computes the direction of steepest increase in the loss. Gradient descent moves weights in the opposite direction — subtracting a fraction of the gradient called the learning rate. One forward pass + one backward pass + one weight update = one training step. Repeat over the entire dataset many times (epochs) until the loss converges.
Three variants of gradient descent
Compute gradient on the entire dataset per step. Exact gradient. Slow on large datasets. Never used in deep learning.
Compute gradient on one sample per step. Very noisy — gradient direction jumps randomly. Can escape local minima. Very fast per step.
Compute gradient on a batch of 32–256 samples. Best of both — stable enough to converge, fast enough for large datasets. What every deep learning framework uses by default.
The same network in PyTorch — autograd handles backpropagation for you
Everything you just coded by hand — forward pass, loss computation, backward pass, weight updates — PyTorch automates with one call to loss.backward(). Its autograd engine traces all operations in the forward pass and automatically computes gradients for every parameter. The code becomes dramatically shorter without changing what happens.
Every common neural network mistake — explained and fixed
Why 'implement backprop from scratch' is still a real interview question
No production ML team writes a neural network in raw NumPy. Every real training job runs through PyTorch, JAX, or TensorFlow, and nobody hand-codes a backward pass for a model going into production. So it surprises a lot of candidates that "build a two-layer network and backpropagation from scratch" is still a live interview question at companies with mature ML platforms. The reason is simple: calling loss.backward() correctly and understanding what it computes are two different skills, and only one of them is testable by watching someone use a framework fluently.
A candidate who has only ever called framework methods can usually get a model training. What the from-scratch exercise actually probes is whether they can reason about the system underneath those calls: can they predict the shape of a gradient without running the code, do they understand that a gradient is a sensitivity, not just "the number the optimiser subtracts," and can they spot a subtly wrong result with no error message attached to it. Framework bugs almost never raise exceptions — a transposed weight matrix, a wrong reduction, a mismatched activation derivative all train "successfully" while quietly computing the wrong thing. That is exactly the failure mode this module's numerical gradient check exists to catch, and it is exactly what interviewers are checking a candidate can reason about by hand.
Can you say, before running anything, what shape dW2 has to be given A1 and dZ2 — without printing .shape and guessing from the error.
Do you understand ∂Loss/∂W as "how much this weight is responsible for the current error," not just as an opaque number the optimiser consumes.
Given a network that trains but underperforms, can you narrow down whether the bug is in the forward pass, the loss, or the backward pass by reasoning about intermediate values.
The same skill shows up directly in production debugging, not just interviews. Any team that ships a custom loss function, a custom autograd operation, or a hand-written CUDA kernel for performance reasons runs into exactly the from-scratch problem this module teaches: PyTorch's autograd only computes the correct gradient automatically for operations it already knows about. The moment you write a custom backward method, you are back to being responsible for the chain rule yourself — and a wrong custom gradient will often still let the model train, just worse, with no error to point at the cause.
Five things people get wrong about neural networks built from scratch
The weighted-sum-plus-activation computation was loosely inspired by neuroscience in the 1940s and 1950s, but modern neural networks are not modelling how real neurons work — real neurons spike in time, involve thousands of distinct neurotransmitter mechanisms, and do not compute a single differentiable scalar. Treating the biological analogy as literal leads people to expect properties (adaptability, energy efficiency, one-shot learning) that artificial neurons simply do not have. The useful mental model is the one this module actually uses: a neuron is z = Σwᵢxᵢ + b followed by a non-linearity — nothing more, nothing biological required to reason about it correctly.
"Learns its own features" means the network does not need you to hand-craft what a feature looks like — it does not mean every design decision is automatic. You still choose the number of layers, the width of each layer, which activation to use where, how to standardise inputs, how to initialise weights, and the batch size. Get any of these wrong — as the errors section of this module shows with NaN losses and dead networks — and the network learns nothing at all, no matter how good the raw data is. Feature learning replaces manual feature engineering; it does not replace architecture and preprocessing decisions.
Without a bias term, every neuron's pre-activation z = Σwᵢxᵢ is forced to equal zero whenever every input is zero — geometrically, the decision boundary of every layer is forced to pass through the origin. For real data, where the useful separation between classes is rarely centred exactly at the origin, this is a serious restriction, not a cosmetic one. That is why this module initialises biases to zero but weights to a scaled random distribution — the bias needs to be free to shift away from zero during training, it just does not need a random starting point to break symmetry the way weights do.
More capacity only helps if the dataset has enough signal to constrain it. This module's own errors section shows the failure mode directly: a network with far more parameters than training examples will memorise the training set and score much worse on unseen data — the loss keeps dropping on training data while test performance gets worse. Model size has to be matched to dataset size and problem complexity; the fix for "not learning enough" is not always "add more layers," and the fix for overfitting is usually to shrink the network, add regularisation, or get more data — not the reverse.
There is no closed-form solution being solved here. Gradient descent takes small, iterative steps downhill on a loss surface that is almost never convex for a real network — different random initialisations, different mini-batch orderings, and different learning rates will all converge to different sets of weights, often with similar but never identical loss. That is precisely why this module trains with mini-batches over many epochs rather than solving a system of equations once: there is no single correct answer to converge to, only a "good enough" region of weight space reached through repeated, approximate steps.
Neural networks from scratch — 5 questions interviewers actually ask
Starting from input X of shape (batch, n_features): layer 1 computes Z1 = X @ W1 + b1, producing a (batch, n_hidden1) pre-activation, then applies an activation function element-wise to get A1 — the actual output that becomes the input to the next layer. Layer 2 repeats this: Z2 = A1 @ W2 + b2, A2 = activation(Z2). The output layer does one more linear step, Z3 = A2 @ W3 + b3, and either leaves it linear (regression) or applies sigmoid/softmax (classification) depending on the task. Every intermediate value — X, Z1, A1, Z2, A2 — has to be cached during this pass, because backpropagation needs all of them to compute gradients afterward.
Without one, stacking layers is mathematically pointless: a linear function of a linear function is still just a linear function. If every layer only computed Z = A_prev @ W + b with no activation in between, the whole network — regardless of depth — could be collapsed algebraically into a single equivalent linear layer, no more expressive than plain linear regression. The non-linearity inserted after each linear step (ReLU, sigmoid, tanh) is what lets each additional layer actually add representational power, letting the network approximate curved decision boundaries and complex functions instead of only straight lines and hyperplanes.
Batch gradient descent computes the gradient over the entire dataset before taking one step — exact, but far too slow to use on large datasets, and the whole dataset often does not fit in memory. Stochastic gradient descent computes the gradient from a single example per step — very fast per step but extremely noisy, with the gradient direction jumping around from sample to sample. Mini-batch gradient descent, using batches of roughly 32 to 256 samples, is the practical compromise: it is stable enough to converge reliably, small enough to fit in memory and run efficiently on a GPU, and it is what every production deep learning framework defaults to.
If every weight in a layer starts at exactly zero, every neuron in that layer computes the exact same z, applies the exact same activation, and — critically — receives the exact same gradient during backpropagation. Every neuron in the layer updates identically forever, so a layer with 100 neurons behaves like a layer with 1 neuron repeated 100 times; the network never breaks this symmetry on its own. Random initialisation (this module uses He initialisation, scaling by √(2/n_inputs) for ReLU networks) breaks that symmetry so different neurons learn different things, while also keeping the initial activations in a numerically well-behaved range instead of vanishing or exploding.
The NumPy version in this module works, but every time the architecture changes — a new layer, a skip connection, a different activation — the backward() function has to be rewritten by hand, and a single wrong transpose silently produces incorrect gradients with no error thrown. Autograd builds a computational graph automatically during the forward pass and derives the correct backward pass for that exact graph every time, for arbitrary architectures, without anyone re-deriving the chain rule by hand. It also plugs into GPU execution, mixed-precision training, and distributed training — infrastructure that would take far more than "faster NumPy" to reimplement correctly from scratch.
You built a neural network from scratch. Now: make it train faster and better.
The network you just built works — but plain SGD is the slowest, least reliable optimizer available. Module 41 covers the training techniques that make modern deep learning practical: Adam optimizer (adaptive learning rates per parameter), batch normalisation (stabilise activations between layers), dropout (prevent overfitting), and learning rate schedules (reduce lr as training progresses). These four techniques take a network from "trains but slowly" to "trains fast and generalises well."
The four techniques that separate a network that trains from one that trains well. Used in every production deep learning system.
🎯 Key Takeaways
- ✓A neural network learns its own features from raw data — stacked layers of weighted sums followed by non-linear activations. No manual feature engineering needed. Each layer learns increasingly abstract representations.
- ✓One neuron: z = Σ(wᵢxᵢ) + b, a = activation(z). One layer: Z = X @ W + b, A = activation(Z). Matrix multiplication makes the computation efficient for batches of samples simultaneously.
- ✓Use ReLU (max(0, z)) as the default activation for hidden layers. It does not saturate for positive values, is fast to compute, and produces sparse activations. Use sigmoid only at the output for binary classification, softmax for multi-class, linear for regression.
- ✓Backpropagation applies the chain rule backwards through the network to compute ∂Loss/∂W for every weight. Each layer produces two gradients: one to update its own weights and one to pass backward to the previous layer. Gradient check (compare analytical vs numerical gradients) verifies correctness.
- ✓Mini-batch gradient descent — process batches of 32–256 samples per update — is the correct trade-off between noisy single-sample updates and slow full-dataset updates. Shuffle data each epoch to prevent the model from memorising the order.
- ✓PyTorch automates backpropagation via autograd. loss.backward() computes all gradients, optimizer.step() applies them. The from-scratch implementation is identical in logic — PyTorch just removes the manual gradient code so you can focus on architecture design.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.