Tier 3 · Deep Learning & Neural Networks

Neural Networks
From Scratch

Before you use a framework, you build it yourself. This module constructs neural networks from first principles — from the single perceptron to a multi-layer net in raw NumPy — so the abstractions you reach for later are never magic.

📚 4 Lessons 🧮 Math + Code 🔧 NumPy Lab ⏱ ~3 hours
Lesson 1.1

The Perceptron — The Original Neural Network

Every neural network you'll ever build — GPT, ResNet, DALL-E — is a descendant of a remarkably simple idea proposed by Frank Rosenblatt in 1957: the perceptron. It's worth understanding it completely, not as a historical footnote, but as the conceptual atom that everything else compounds from.

💡
Why start here? Most courses introduce PyTorch on day one and treat the mechanics as a black box. That works until something breaks — and in deep learning, things break in confusing ways. Understanding the perceptron means you understand what every node in every layer is actually doing.
What the perceptron does

A perceptron takes a vector of inputs, multiplies each by a learned weight, sums everything up, adds a bias, and passes the result through a threshold function. That's the complete description.

output = f( w₁x₁ + w₂x₂ + ... + wₙxₙ + b )
= f( w · x + b )
where f is a step function: output 1 if > 0, else 0

The weights w are what the network "learns." The bias b shifts the decision boundary. The dot product w · x is a weighted vote from every input feature. The step function makes a hard binary decision.

📥

Inputs (x)

Feature values from your data — pixel intensities, sensor readings, encoded categories. Each input connects to the perceptron with its own wire.

⚖️

Weights (w)

The strength and sign of each connection. A large positive weight means "this input strongly votes yes." A negative weight votes no.

↔️

Bias (b)

A trainable offset that shifts where the decision boundary sits, independent of the inputs. Without it, the boundary is forced through the origin.

🔀

Activation (f)

The function applied to the weighted sum. In the original perceptron, a step function. In modern nets, something differentiable (ReLU, sigmoid — next lesson).

The perceptron learning algorithm

The perceptron doesn't just classify — it learns. The update rule is simple: if the perceptron gets an example wrong, nudge the weights in the direction that would have produced the right answer.

wᵢ ← wᵢ + η · (y - ŷ) · xᵢ
η = learning rate · y = true label · ŷ = predicted label

If the prediction is correct (y = ŷ), the update is zero — nothing changes. If wrong, every weight shifts by an amount proportional to the corresponding input. This is the seed of backpropagation, which you'll see in Module 2.

Python perceptron from scratch
import numpy as np

class Perceptron:
    def __init__(self, n_features, lr=0.01):
        # Small random weights, one per feature plus a bias
        self.weights = np.random.randn(n_features) * 0.01
        self.bias = 0.0
        self.lr = lr

    def predict(self, X):
        # Weighted sum + bias, then step function
        linear = np.dot(X, self.weights) + self.bias
        return (1 if linear >= 0 else 0)

    def train(self, X, y, epochs=100):
        for _ in range(epochs):
            for xi, yi in zip(X, y):
                pred = self.predict(xi)
                error = yi - pred         # 0 if correct, ±1 if wrong
                # Update: push weights toward the right answer
                self.weights += self.lr * error * xi
                self.bias    += self.lr * error

# Simple AND gate — the classic perceptron demo
X = np.array([[0,0],[0,1],[1,0],[1,1]])
y = np.array([0, 0, 0, 1])   # AND: only 1 when both inputs are 1

p = Perceptron(n_features=2)
p.train(X, y, epochs=200)

for xi in X:
    print(f"{xi} → {p.predict(xi)}")
# [0 0] → 0  [0 1] → 0  [1 0] → 0  [1 1] → 1  ✓
⚠️
The XOR Problem A single perceptron can only learn linearly separable patterns. XOR (output 1 when inputs differ) is not linearly separable — no single straight line can divide the data correctly. This was proven by Minsky and Papert in 1969, and it nearly killed AI research for a decade. The solution: stack perceptrons into layers — the multi-layer perceptron.
From perceptron to layer

A single perceptron draws one decision boundary. Stack several perceptrons in parallel (a layer), and each one draws its own boundary. Stack layers, and the network can learn arbitrarily complex boundaries — it approximates any continuous function given enough neurons. This is the Universal Approximation Theorem, and it's why deep networks work.

Lesson 1.2

Activation Functions: Sigmoid, Tanh, ReLU, and Why ReLU Mostly Won

The activation function is what makes a neural network non-linear — and non-linearity is what makes it capable of learning anything beyond a linear relationship. Without activations, stacking layers achieves nothing: a linear function composed with a linear function is still a linear function.

💡
The core requirement An activation function needs to be (1) non-linear, so layers can learn complex patterns, and (2) differentiable (almost everywhere), so gradients can flow backward during training. The history of which function to use is a history of discovering which ones let deep networks actually train.
The three you need to know
FunctionFormulaRangeGradientUse Today
Sigmoid σ(x) 1 / (1 + e⁻ˣ) (0, 1) σ(x)(1 - σ(x)) — small near extremes Output layer only
Tanh (eˣ - e⁻ˣ) / (eˣ + e⁻ˣ) (-1, 1) 1 - tanh²(x) — same vanishing problem RNNs, some gates
ReLU max(0, x) [0, ∞) 1 if x>0, else 0 — sparse and fast Default for hidden layers
📉

Sigmoid: historically first

Maps any input to (0,1), great for probability outputs. Fatal flaw: at large positive or negative inputs, the gradient is nearly zero — gradients "vanish" as they flow backward through layers. Deep networks with sigmoid can't train.

〰️

Tanh: sigmoid's improvement

Zero-centered (outputs range -1 to 1), which helps gradient flow compared to sigmoid. Still suffers from saturation at extremes. Preferred over sigmoid for hidden layers when it was all we had, still used in LSTMs.

📐

ReLU: the modern default

Embarrassingly simple: zero below zero, identity above. The gradient is always either 0 or 1 — no saturation for positive inputs. Training deep networks became practical once ReLU replaced sigmoid.

🔧

ReLU variants

Leaky ReLU (small slope for x<0 to fix "dead neurons"), GELU (used in transformers — smooth approximation of ReLU), Swish (GELU alternative). ReLU is still the default unless you have a specific reason.

Why ReLU won

Three concrete reasons ReLU enabled the deep learning revolution:

  • 1No vanishing gradient for active neurons. When x > 0, the gradient is exactly 1. It doesn't shrink as it flows through layers. Sigmoid's gradient maxes out at 0.25 — compound that across 20 layers and you get ~10⁻⁶. No signal reaches early layers.
  • 2Sparse activation. On average, ~50% of ReLU neurons output zero. This sparsity means networks are computationally efficient and representations are compact — different inputs activate different subsets of the network.
  • 3Cheap to compute. max(0, x) is one comparison instruction. No exponentials, no divisions. When you're running billions of these operations, that matters enormously.
Python activation functions in NumPy
import numpy as np

# Sigmoid and its gradient
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def sigmoid_grad(x):
    s = sigmoid(x)
    return s * (1 - s)   # Max 0.25 at x=0

# Tanh and its gradient
def tanh(x):
    return np.tanh(x)

def tanh_grad(x):
    return 1 - np.tanh(x)**2   # Max 1.0 at x=0

# ReLU and its gradient
def relu(x):
    return np.maximum(0, x)

def relu_grad(x):
    return (x > 0).astype(float)  # 1 where active, 0 where not

# Leaky ReLU — fixes "dying ReLU" by allowing small gradient at x<0
def leaky_relu(x, alpha=0.01):
    return np.where(x > 0, x, alpha * x)

# Quick comparison at x = [-2, -1, 0, 1, 2]
x = np.array([-2., -1., 0., 1., 2.])
print("ReLU:   ", relu(x))       # [0.  0.  0.  1.  2.]
print("Sigmoid:", sigmoid(x).round(2))  # [0.12 0.27 0.5  0.73 0.88]
print("Tanh:   ", np.tanh(x).round(2)) # [-0.96 -0.76  0.   0.76  0.96]
Practical rule of thumb Start with ReLU for all hidden layers. Use sigmoid for binary classification output. Use softmax for multi-class output. If you see dead neurons (many outputs stuck at zero), switch to Leaky ReLU or GELU.
Lesson 1.3

Forward Propagation, By Hand, On Paper First

Before writing code, do it on paper. Forward propagation is a mechanical process: transform inputs through layer after layer until you get an output. Running it by hand once — with real numbers — means you'll never be confused about what "a layer" actually does.

💡
Why bother with manual calculation? When you use PyTorch later, model(x) runs forward propagation invisibly. If your output is wrong, you need to be able to trace the computation to find the bug. People who skipped the manual step can't do this effectively.
A worked example: 2→3→1 network

We have a network with 2 inputs, 3 hidden neurons, and 1 output. Let's trace a single forward pass with specific numbers.

Input: x = [0.5, 0.8]

Layer 1 weights W¹ = [[0.1, 0.4], [0.3, -0.2], [-0.1, 0.5]]
Layer 1 biases b¹ = [0, 0, 0]

Layer 2 weights W² = [[0.6, -0.3, 0.8]]
Layer 2 bias b² = [0]
Dimensions: W¹ is (3×2), W² is (1×3)
  • 1Linear combination, Layer 1: z¹ = W¹x + b¹
    z¹₁ = 0.1(0.5) + 0.4(0.8) = 0.05 + 0.32 = 0.37
    z¹₂ = 0.3(0.5) + (-0.2)(0.8) = 0.15 − 0.16 = −0.01
    z¹₃ = −0.1(0.5) + 0.5(0.8) = −0.05 + 0.40 = 0.35
  • 2Apply ReLU, Layer 1: a¹ = ReLU(z¹)
    a¹ = ReLU([0.37, −0.01, 0.35]) = [0.37, 0.00, 0.35]
    The second neuron is "off" — its weighted sum was negative.
  • 3Linear combination, Layer 2: z² = W²a¹ + b²
    z² = 0.6(0.37) + (−0.3)(0.00) + 0.8(0.35) = 0.222 + 0 + 0.280 = 0.502
  • 4Apply sigmoid, output layer: ŷ = σ(0.502) = 1/(1+e⁻⁰·⁵⁰²) ≈ 0.623
    Interpretation: the network predicts a 62.3% probability of class 1.
Python verifying our hand calculation
import numpy as np

# Same values as our worked example
x = np.array([0.5, 0.8])

W1 = np.array([[0.1, 0.4],
               [0.3, -0.2],
               [-0.1, 0.5]])
b1 = np.zeros(3)

W2 = np.array([[0.6, -0.3, 0.8]])
b2 = np.zeros(1)

# Layer 1
z1 = W1 @ x + b1              # Matrix multiply + bias
a1 = np.maximum(0, z1)       # ReLU

print("z1:", z1.round(4))    # [ 0.37  -0.01   0.35]
print("a1:", a1.round(4))    # [ 0.37   0.     0.35]

# Layer 2
z2 = W2 @ a1 + b2
print("z2:", z2.round(4))    # [0.502]

# Output with sigmoid
y_hat = 1 / (1 + np.exp(-z2))
print("ŷ:  ", y_hat.round(4))  # [0.623]  ← matches hand calc ✓
Notation that will follow you everywhere
z

Pre-activation (z)

The raw weighted sum before the activation function. Also called the "logit." The activation function transforms z into the neuron's output.

a

Activation (a)

The output of a neuron after applying the activation function. a = f(z). The activations from one layer become the inputs to the next.

W

Weight matrix (W)

A matrix of shape (output_neurons × input_neurons). The matrix multiply W@x computes all neurons in a layer simultaneously — this is why vectorization matters.

L

Layer index (ℓ)

Superscripts denote layers: W^[1] is layer 1's weights, a^[2] is layer 2's activations. The input is a^[0] = x. The output is a^[L] = ŷ.

Lesson 1.4

Building a Tiny Neural Net in Raw NumPy

This is the lesson that matters most in this module, even though it's the last. You're going to build a working neural network — training included — using only NumPy. No PyTorch, no Keras, no scikit-learn. Just math and arrays.

When you do this, the things that feel like magic in frameworks become engineering decisions you understand completely: how weights are initialized, how gradients flow, how the training loop works. You'll also make mistakes — divide by the wrong dimension, forget the chain rule on a particular layer — and that confusion is the most valuable debugging training you can get before the abstractions hide it.

🎯
What we're building A 2-layer network that learns XOR — the problem that killed the perceptron. This requires a hidden layer, which means backpropagation, which means you'll implement the full learning loop from first principles.
Python two-layer neural network in pure NumPy — XOR
import numpy as np

# ─── Data ───────────────────────────────────────────────────────────────
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)  # (4, 2)
y = np.array([[0],[1],[1],[0]], dtype=float)           # (4, 1)

# ─── Architecture ───────────────────────────────────────────────────────
np.random.seed(42)
n_input, n_hidden, n_output = 2, 4, 1

# Xavier / Glorot init: keeps variance stable through layers
W1 = np.random.randn(n_input, n_hidden) * np.sqrt(2 / n_input)
b1 = np.zeros((1, n_hidden))
W2 = np.random.randn(n_hidden, n_output) * np.sqrt(2 / n_hidden)
b2 = np.zeros((1, n_output))

lr = 0.1

# ─── Training loop ──────────────────────────────────────────────────────
for epoch in range(10000):

    # ── Forward pass ──────────────────────────────────────────────────
    z1 = X @ W1 + b1          # (4, 4)  linear combination
    a1 = np.maximum(0, z1)    # (4, 4)  ReLU activation

    z2 = a1 @ W2 + b2         # (4, 1)  output layer linear
    a2 = 1 / (1 + np.exp(-z2))  # (4, 1)  sigmoid → prediction

    # ── Loss: binary cross-entropy ─────────────────────────────────────
    eps = 1e-8  # prevent log(0)
    loss = -np.mean(y * np.log(a2 + eps) + (1-y) * np.log(1-a2 + eps))

    # ── Backward pass (backpropagation) ────────────────────────────────
    # Output layer gradients
    dL_da2 = -(y/(a2+eps) - (1-y)/(1-a2+eps))  # dLoss/da2
    da2_dz2 = a2 * (1 - a2)                      # sigmoid derivative
    dz2 = dL_da2 * da2_dz2                        # (4, 1)

    dW2 = a1.T @ dz2 / len(X)    # (4, 1)
    db2 = np.mean(dz2, axis=0)   # (1, 1)

    # Hidden layer gradients — chain rule through W2 and ReLU
    da1 = dz2 @ W2.T              # (4, 4)  gradient flows back through W2
    dz1 = da1 * (z1 > 0)         # (4, 4)  ReLU gradient (0 where inactive)

    dW1 = X.T @ dz1 / len(X)    # (2, 4)
    db1 = np.mean(dz1, axis=0)  # (1, 4)

    # ── Gradient descent update ────────────────────────────────────────
    W2 -= lr * dW2;  b2 -= lr * db2
    W1 -= lr * dW1;  b1 -= lr * db1

    if epoch % 2000 == 0:
        print(f"Epoch {epoch:5d}  loss: {loss:.4f}")

# ─── Verify ─────────────────────────────────────────────────────────────
preds = (1 / (1 + np.exp(-(a1 @ W2 + b2)))) > 0.5
print("\nXOR predictions:")
for xi, yi, pi in zip(X, y, preds):
    print(f"  {xi} → true:{int(yi[0])}  pred:{int(pi[0])}")
# All four XOR cases should now be correct — what the perceptron couldn't do
⚠️
Read the backward pass carefully. The backpropagation code above is the chain rule applied mechanically, layer by layer. If this feels opaque, that's the signal to slow down — Module 2 derives it completely. The pattern dz2 = dL_da2 * da2_dz2 is the chain rule: derivative of loss w.r.t. z2 = (dL/da2) × (da2/dz2).
What you just built

Xavier initialization

Weights initialized with variance 2/n_inputs keeps signal magnitude stable through layers. Bad initialization → vanishing or exploding gradients before training even starts.

Binary cross-entropy loss

The right loss for binary classification. It penalizes confident wrong answers heavily and doesn't saturate the way MSE does with sigmoid outputs.

Vectorized operations

All four training examples computed simultaneously with matrix operations — no Python for-loop over examples. This is why GPUs are fast: they run thousands of these in parallel.

Gradient descent update

Weights shift opposite the gradient direction. The learning rate lr controls the step size. This is the same algorithm that trains GPT, just at 10⁹× the scale.

🚀
Next module preview You wrote the backward pass above, but where exactly do those gradient formulas come from? Module 2 derives each one from scratch using the chain rule. After that derivation, you'll be able to write the backward pass for any architecture without looking anything up.