🎯 What You'll Learn
- Understand the biological analogy for artificial neurons and exactly where the analogy breaks down
- Implement a single artificial neuron from scratch in NumPy: weights, bias, activation function
- Understand the historical Perceptron: what it can solve (AND, OR) and why it fails (XOR)
- Grasp why hidden layers are necessary and what they do conceptually (feature transformation)
- Build a complete Multi-Layer Perceptron (MLP) using only NumPy matrix operations
- Understand the four core activation functions — Sigmoid, Tanh, ReLU, Softmax — when to use each and why
- Understand the dying ReLU problem and its solutions (Leaky ReLU, GELU)
- Understand weight initialization: why zero init fails, and why Xavier/Kaiming init works
Your brain has 86 billion neurons. Each neuron receives electrical signals from other neurons, does a simple computation (should I fire or not?), and either passes a signal forward or stays silent. Deep learning models are a very rough computational analogy of this. Understanding one artificial neuron — truly understanding it — unlocks everything else: multi-layer networks, activation functions, backpropagation, modern architectures. Everything builds on this foundation.
1 The Biological Analogy
Biological neurons are electrochemical signal processors. A typical neuron has three main parts:
- Dendrites: The input channels. They receive electrical signals from other neurons. A neuron might have thousands of dendrites, each connected to a different upstream neuron.
- Cell body (soma): The processing unit. It sums all incoming signals. If the total exceeds a threshold, the neuron "fires."
- Axon: The output channel. When the neuron fires, it transmits a signal down the axon to the dendrites of downstream neurons.
The artificial neuron (or "unit") mimics this structure in a mathematically simplified way:
- Inputs (x₁, x₂, ..., xₙ): Analogous to dendrites. These are numbers — pixel values, word embeddings, feature measurements.
- Weights (w₁, w₂, ..., wₙ): The "strength" of each connection. A weight of 2.0 means this input has twice the influence; a weight of -1.0 means this input inhibits the neuron. Weights are learned during training.
- Bias (b): A constant added to the weighted sum. It shifts the activation threshold, allowing the neuron to fire even when all inputs are zero.
- Activation function f: Determines whether and how strongly the neuron fires based on the total weighted input. This introduces the non-linearity that makes neural networks powerful.
The full computation of one artificial neuron:
import numpy as np
# One neuron with 3 inputs
# -----------------------
# Inputs: a 3-dimensional feature vector
x = np.array([0.5, -1.2, 0.8]) # e.g., [height, weight, age] (normalized)
# Weights: one per input — these are LEARNED during training
w = np.array([0.4, -0.3, 0.7]) # current weights
# Bias: a single learned scalar
b = 0.1
# Step 1: Weighted sum (the "summation" in the cell body)
z = np.dot(w, x) + b
# Equivalently: z = w[0]*x[0] + w[1]*x[1] + w[2]*x[2] + b
print(f"Weighted sum z = w·x + b = {z:.4f}")
# z = 0.4*0.5 + (-0.3)*(-1.2) + 0.7*0.8 + 0.1
# z = 0.2 + 0.36 + 0.56 + 0.1 = 1.22
# Step 2: Activation function (the "threshold" decision)
def sigmoid(z):
return 1 / (1 + np.exp(-z))
output = sigmoid(z)
print(f"Neuron output = sigmoid({z:.4f}) = {output:.4f}")
# A single number between 0 and 1 — the neuron's "firing strength"
Artificial neurons are far simpler than biological neurons. Real neurons: communicate with complex spike trains, not just scalar values; have thousands of distinct neurotransmitters; are embedded in 3D space with physical constraints; have wildly varying shapes and functions (pyramidal cells, Purkinje cells, etc.); process information in milliseconds with chemical gradients. Artificial neurons are just a weighted sum + a nonlinearity. The analogy is useful for building intuition, not for accurate neuroscience. Don't over-read it.
2 A Single Neuron: The Maths in Full
Let's be precise about what a neuron computes. If the neuron receives n inputs, it computes:
output = f(w₁x₁ + w₂x₂ + ... + wₙxₙ + b) = f(W·x + b)
Where W is the weight vector, x is the input vector, b is the bias scalar, and f is the activation function. In vector form, the dot product W·x computes the weighted sum in one operation.
The most fundamental diagram in deep learning: each input xᵢ is multiplied by its weight wᵢ, all products (plus the bias b) are summed into z, and the activation function f squashes z into the neuron's final output. Every neural network, no matter how large, is built from this one unit repeated millions of times.
This looks very much like logistic regression — and it is! A single neuron with a sigmoid activation is logistic regression. The difference is that stacking many such neurons in layers — a neural network — can represent arbitrarily complex functions.
What do the weights and bias mean intuitively?
- A large positive weight on input xᵢ means "when xᵢ is large, this neuron fires more strongly."
- A large negative weight on xᵢ means "when xᵢ is large, this neuron is inhibited."
- A weight near zero means "this input barely matters to this neuron."
- The bias shifts the firing threshold. With b = +5, the neuron is "pre-activated" and fires even when all inputs are near zero. With b = -5, the neuron requires strong positive inputs to activate.
import numpy as np
# Implement a single neuron as a function
def neuron(x, w, b, activation='sigmoid'):
"""
x: input vector, shape (n,)
w: weight vector, shape (n,)
b: bias scalar
"""
z = np.dot(w, x) + b # weighted sum
if activation == 'sigmoid':
return 1 / (1 + np.exp(-z))
elif activation == 'relu':
return np.maximum(0, z)
elif activation == 'tanh':
return np.tanh(z)
else:
return z # linear (no activation)
# Example 1: Detecting "is income high and debt is low?"
# Input: [income_normalised, debt_normalised]
# High income = positive feature; high debt = negative feature
w = np.array([2.0, -2.0]) # income matters positively, debt negatively
b = -0.5 # threshold: need reasonable net signal to fire
x_rich_low_debt = np.array([1.0, 0.2]) # high income, low debt
x_poor_high_debt = np.array([0.3, 0.9]) # low income, high debt
x_medium = np.array([0.6, 0.5]) # medium both
print("Is this person a good credit risk?")
print(f" Rich, low debt: {neuron(x_rich_low_debt, w, b):.4f} (close to 1 = good)")
print(f" Poor, high debt: {neuron(x_poor_high_debt, w, b):.4f} (close to 0 = bad)")
print(f" Medium: {neuron(x_medium, w, b):.4f} (uncertain)")
# Example 2: Role of bias
print("\nEffect of bias on activation threshold:")
w_simple = np.array([1.0])
x_half = np.array([0.0]) # zero input
for b_val in [-3, 0, 3]:
out = neuron(x_half, w_simple, b_val)
print(f" bias={b_val:+.0f}, input=0: output = {out:.4f}")
3 The Perceptron: The First Neural Network (1958)
Frank Rosenblatt invented the Perceptron in 1958 at Cornell University. It was not just a theoretical idea — it was a physical machine built in hardware. The perceptron is a single neuron with a step activation function: if the weighted sum exceeds zero, output 1; otherwise output 0. This binary threshold makes it suitable for binary classification.
What the Perceptron Can Solve
The perceptron can learn any problem that is linearly separable — where a single straight line (or hyperplane in higher dimensions) can separate the two classes.
import numpy as np
def step(z):
"""Step activation: 1 if z > 0, else 0"""
return 1 if z > 0 else 0
def perceptron_train(X, y, lr=0.1, epochs=100):
"""Manually train a perceptron using the perceptron learning rule."""
n_features = X.shape[1]
w = np.zeros(n_features)
b = 0.0
for epoch in range(epochs):
errors = 0
for xi, yi in zip(X, y):
z = np.dot(w, xi) + b
y_pred = step(z)
error = yi - y_pred # 0, +1, or -1
w += lr * error * xi # adjust weights
b += lr * error # adjust bias
errors += abs(error)
if errors == 0:
print(f"Converged at epoch {epoch+1}")
break
return w, b
# ── AND gate ──
# Output is 1 only when BOTH inputs are 1
X_and = np.array([[0,0], [0,1], [1,0], [1,1]])
y_and = np.array([0, 0, 0, 1])
w, b = perceptron_train(X_and, y_and)
print(f"\nAND gate learned weights: w={w}, b={b:.2f}")
print("Predictions:")
for xi, yi in zip(X_and, y_and):
pred = step(np.dot(w, xi) + b)
print(f" {xi} → {pred} (correct: {yi})")
# ── OR gate ──
# Output is 1 when AT LEAST ONE input is 1
X_or = np.array([[0,0], [0,1], [1,0], [1,1]])
y_or = np.array([0, 1, 1, 1])
w_or, b_or = perceptron_train(X_or, y_or)
print(f"\nOR gate learned weights: w={w_or}, b={b_or:.2f}")
for xi, yi in zip(X_or, y_or):
pred = step(np.dot(w_or, xi) + b_or)
print(f" {xi} → {pred} (correct: {yi})")
The XOR Problem: Why the Perceptron Fails
The XOR gate outputs 1 when exactly one of the two inputs is 1, and 0 otherwise. Try to draw a single straight line that separates the "output=1" points from the "output=0" points on a grid — it's impossible. The classes are not linearly separable. Minsky and Papert proved this mathematically in their 1969 book "Perceptrons," which nearly killed neural network research for a decade.
# ── XOR gate — the Perceptron FAILS ──
X_xor = np.array([[0,0], [0,1], [1,0], [1,1]])
y_xor = np.array([0, 1, 1, 0]) # XOR: 1 only when inputs differ
# The perceptron will never converge on XOR
# Let's show this with a limited epoch count
w_xor, b_xor = perceptron_train(X_xor, y_xor, lr=0.1, epochs=1000)
print("\nXOR — predictions after 1000 epochs:")
errors = 0
for xi, yi in zip(X_xor, y_xor):
pred = step(np.dot(w_xor, xi) + b_xor)
correct = "✓" if pred == yi else "✗"
print(f" {xi} → {pred} (correct: {yi}) {correct}")
if pred != yi: errors += 1
print(f"Total errors: {errors}/4 — the perceptron cannot solve XOR!")
XOR is the simplest example of a non-linearly-separable problem, but the same structure appears everywhere in real data. "Is this a fraudulent transaction?" often depends on a complex combination of features that no single decision boundary can capture. "Is this image a cat?" requires distinguishing at multiple scales and rotations. The inability to handle non-linear patterns is a fundamental limitation of single-layer networks — and solving it with hidden layers is the foundation of deep learning.
4 Why We Need Hidden Layers: Solving XOR
The key insight that rescued neural networks from the XOR problem: add a hidden layer. A hidden layer is a layer of neurons between the inputs and the output — "hidden" because its outputs are not directly visible (they're intermediate representations).
The Geometric Intuition
Imagine the four XOR data points on a 2D grid: (0,0)→0, (0,1)→1, (1,0)→1, (1,1)→0. They form an X pattern — the 1s are at the off-diagonals, the 0s are at the main diagonal corners. You cannot draw a single line to separate them. But here's the trick: if you transform the data into a new coordinate system (a new feature space), the problem might become linearly separable.
A hidden layer does exactly this — it learns a transformation of the input space. After the transformation, the data is in a new space where a linear decision boundary works perfectly. The hidden neurons learn "new features" that make the problem easy.
import numpy as np
# Manual 2-layer network solving XOR
# Architecture: 2 inputs → 2 hidden neurons (sigmoid) → 1 output (sigmoid)
# We'll set the weights manually to show how it works
# These weights were chosen to implement XOR:
# Hidden neuron 1: fires when (x1=1 OR x2=1) but NOT (x1=1 AND x2=1)
# Hidden neuron 2: fires when (x1=1 AND x2=1)
# Output: fires when h1 fires AND h2 doesn't fire
def sigmoid(z):
return 1 / (1 + np.exp(-z))
# Weights from input layer to hidden layer (2x2 matrix)
# W1[i,j] = weight from input j to hidden neuron i
W1 = np.array([[20, 20], # Hidden neuron 1: large positive weights → OR-like
[20, 20]]) # Hidden neuron 2: large positive weights → AND-like
b1 = np.array([-10, -30]) # Bias 1: fires with >= 1 input; Bias 2: fires only with both
# Weights from hidden layer to output (1x2)
W2 = np.array([[20, -20]]) # Output: fires if h1 fires, inhibited if h2 fires
b2 = np.array([-10]) # Threshold for output
def forward_xor(x):
# Hidden layer
z1 = W1 @ x + b1 # shape (2,)
h1 = sigmoid(z1) # hidden activations
# Output layer
z2 = W2 @ h1 + b2 # shape (1,)
output = sigmoid(z2) # final output
return h1, output[0]
X_xor = np.array([[0,0], [0,1], [1,0], [1,1]])
y_xor = np.array([0, 1, 1, 0])
print("XOR solved with a 2-layer network:")
print(f"{'Input':>10} | {'Hidden h1, h2':>20} | {'Output':>8} | {'Target':>6}")
print("-" * 60)
for x, y in zip(X_xor, y_xor):
h, out = forward_xor(x)
print(f" {x} | {h[0]:.3f}, {h[1]:.3f} | {out:.4f} | {y}")
Notice what the hidden layer does: it transforms [0,0], [0,1], [1,0], [1,1] into new representations. The inputs (0,1) and (1,0) both become [1, 0] in hidden space — meaning both map to the SAME hidden representation. In this new space, the problem is linearly separable. This is the essence of representation learning: each layer learns a new, better representation of the data.
Each layer in a neural network learns a new representation of the data — a new "view" of the problem that makes the final decision easier. In image recognition: Layer 1 learns edges → Layer 2 learns shapes → Layer 3 learns parts → Layer 4 learns objects. In language models: Layer 1 learns syntax → Layer 2 learns grammar → Layer 3 learns semantics → Layer 4 learns world knowledge. The depth of the network allows it to build hierarchical representations from simple to complex. This is why deep learning is "deep."
5 Multi-Layer Perceptron (MLP): Building One from Scratch
An MLP (Multi-Layer Perceptron) is the simplest full neural network: an input layer, one or more hidden layers, and an output layer. Every neuron in each layer is connected to every neuron in the next layer — these are called "fully connected" or "dense" layers.
The Universal Approximation Theorem
In 1989, George Cybenko proved a remarkable theorem: a neural network with just one hidden layer containing enough neurons can approximate any continuous function to arbitrary accuracy. This is the mathematical foundation for why neural networks work — in theory, any pattern in data can be learned by some MLP. In practice, deeper (more layers) is usually better than wider (more neurons per layer) for complex functions, which is why we use "deep" networks.
A "fully connected" (dense) MLP: every node in the input layer connects to every node in the hidden layer, and every hidden node connects to every output node. Each connection carries its own learned weight — this 3→4→2 network already has (3×4 + 4) + (4×2 + 2) = 26 learnable parameters.
import numpy as np
# ── Build a complete 2-layer MLP from scratch in NumPy ──
# Architecture: 2 inputs → 4 hidden neurons (ReLU) → 1 output (sigmoid)
# Task: binary classification
class MLP:
def __init__(self, input_dim, hidden_dim, output_dim, seed=42):
np.random.seed(seed)
# Layer 1 weights: shape (hidden_dim, input_dim)
# Xavier initialization (more on this in Section 8)
scale1 = np.sqrt(2.0 / input_dim)
self.W1 = np.random.randn(hidden_dim, input_dim) * scale1
self.b1 = np.zeros(hidden_dim)
# Layer 2 weights: shape (output_dim, hidden_dim)
scale2 = np.sqrt(2.0 / hidden_dim)
self.W2 = np.random.randn(output_dim, hidden_dim) * scale2
self.b2 = np.zeros(output_dim)
def relu(self, z):
return np.maximum(0, z)
def sigmoid(self, z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def forward(self, X):
"""
X: shape (batch_size, input_dim)
Returns: output probabilities, shape (batch_size, output_dim)
"""
# Layer 1: linear + ReLU
# X.shape: (batch, input_dim)
# W1.T shape: (input_dim, hidden_dim)
self.a1 = X @ self.W1.T + self.b1 # (batch, hidden_dim)
self.h1 = self.relu(self.a1) # (batch, hidden_dim)
# Layer 2: linear + sigmoid
self.a2 = self.h1 @ self.W2.T + self.b2 # (batch, output_dim)
self.output = self.sigmoid(self.a2) # (batch, output_dim)
return self.output
def predict(self, X, threshold=0.5):
probs = self.forward(X)
return (probs >= threshold).astype(int)
# Test the MLP
np.random.seed(0)
model = MLP(input_dim=2, hidden_dim=4, output_dim=1)
# Simple circular dataset: class 1 = inside circle, class 0 = outside
n = 200
angles = np.random.uniform(0, 2*np.pi, n)
radii = np.random.uniform(0, 2, n)
X = np.column_stack([radii * np.cos(angles), radii * np.sin(angles)])
y = (radii < 1.2).astype(int).reshape(-1, 1) # inside radius 1.2 = class 1
# Forward pass
probs = model.forward(X)
predictions = model.predict(X)
# Accuracy before training (random weights)
accuracy = (predictions == y).mean()
print(f"MLP architecture: 2 → 4 (ReLU) → 1 (Sigmoid)")
print(f"Parameters: W1={model.W1.shape}, b1={model.b1.shape}, W2={model.W2.shape}, b2={model.b2.shape}")
print(f"Total parameters: {model.W1.size + model.b1.size + model.W2.size + model.b2.size}")
print(f"Accuracy with random weights (before training): {accuracy:.4f}")
print(f"Output range: [{probs.min():.4f}, {probs.max():.4f}]")
The MLP is not yet trained — we need backpropagation for that (Lesson 40). But this forward pass already demonstrates the key structure: the input flows through two layers of weighted sums and activations to produce an output.
6 Activation Functions: Why They Matter
Here is a critical insight that's worth pausing on: without activation functions, a stack of linear layers is just one linear layer. Why? Because composing linear transformations produces another linear transformation. If Layer 1 computes W₁x + b₁ and Layer 2 computes W₂(W₁x + b₁) + b₂, that simplifies to (W₂W₁)x + (W₂b₁ + b₂) — still just a linear map with different weights. You could have 100 layers and it would be mathematically equivalent to one linear transformation.
Activation functions introduce non-linearity between layers. This breaks the collapse — now Layer 2 is applying a linear transformation to a non-linearly transformed version of the input, which cannot be simplified to a single linear map. This is what gives neural networks the ability to represent complex, curved decision boundaries.
Toggle each curve below to compare shape, output range, and — most importantly — behavior near x = 0 and for large |x|. Notice how Sigmoid and Tanh flatten out ("saturate") for |x| > 3, while ReLU and Leaky ReLU keep growing linearly forever on the positive side:
Sigmoid ∈ (0,1), Tanh ∈ (-1,1) — both saturate for |x| > 3. ReLU ∈ [0,∞) is unbounded above and flat (zero gradient) below x = 0.
import numpy as np
# ── The four core activation functions ──
def sigmoid(x):
"""Squashes input to (0, 1). Good for output layer in binary classification."""
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
def sigmoid_derivative(x):
s = sigmoid(x)
return s * (1 - s) # Maximum value: 0.25 at x=0
def tanh(x):
"""Squashes input to (-1, 1). Zero-centered — better than sigmoid for hidden layers."""
return np.tanh(x)
def tanh_derivative(x):
return 1 - np.tanh(x)**2 # Maximum value: 1.0 at x=0
def relu(x):
"""Most popular hidden activation. Fast, solves vanishing gradient for + inputs."""
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float) # 1 if x > 0, 0 if x <= 0
def softmax(x):
"""Converts a vector of scores to probabilities. Used in multi-class output."""
e_x = np.exp(x - np.max(x)) # subtract max for numerical stability
return e_x / e_x.sum()
# ── Compare on a range of inputs ──
x_vals = np.array([-3, -1, 0, 1, 3])
print(f"{'x':>6} | {'sigmoid':>8} | {'tanh':>8} | {'ReLU':>8}")
print("-" * 42)
for x in x_vals:
print(f"{x:>6.1f} | {sigmoid(x):>8.4f} | {tanh(x):>8.4f} | {relu(x):>8.4f}")
# ── Softmax example for multi-class output ──
print("\nSoftmax example (3-class classification):")
scores = np.array([2.5, 1.0, -0.5]) # Raw output scores (logits)
probs = softmax(scores)
print(f"Logits: {scores}")
print(f"Probabilities: {probs.round(4)}")
print(f"Sum of probabilities: {probs.sum():.4f}") # Always 1.0
print(f"Predicted class: {np.argmax(probs)}")
When to Use Each Activation Function
| Activation | Output Range | Use Case | Problem |
|---|---|---|---|
| Sigmoid | (0, 1) | Binary classification output, gates in LSTMs | Saturates for |x| > 3 → vanishing gradients in hidden layers |
| Tanh | (-1, 1) | RNN hidden states, when zero-centered output needed | Still saturates → still has vanishing gradient problem |
| ReLU | [0, ∞) | ⭐ Default for hidden layers in most networks | Dying ReLU (neurons can get stuck at 0) |
| Softmax | (0, 1), sums to 1 | Multi-class classification output only | Never use in hidden layers — collapses representational power |
7 ReLU Deep Dive: Why It Enabled Modern Deep Learning
ReLU (Rectified Linear Unit) looks almost insultingly simple: max(0, x). Yet this function is arguably the single most important architectural choice in the history of deep learning. Here's why it matters so much.
The Problem with Sigmoid/Tanh: Vanishing Gradients
Sigmoid's maximum derivative is 0.25 (at x=0). For x > 2 or x < -2, the derivative is nearly zero. Now consider a 10-layer network with sigmoid activations. During backpropagation, gradients flow backward through each layer and are multiplied by each layer's derivative. If every derivative is at most 0.25, the gradient after 10 layers has been multiplied by at most 0.25^10 = 0.0000009. The gradient for the first layer is essentially zero — it cannot learn. This is the vanishing gradient problem.
Why ReLU Helps
ReLU's derivative is exactly 1 for any positive input, and 0 for negative inputs. For positive activations, the gradient passes through unchanged — no shrinkage. This means a network with 50 layers of ReLU can still propagate gradients to the first layer without them vanishing. This is what enabled training "deep" networks in the first place, and why the deep learning revolution only happened in the 2010s when people started using ReLU instead of sigmoid.
The Dying ReLU Problem
ReLU has one pathological failure mode: if a neuron's weighted input is always negative (which can happen if the weights or biases are initialized poorly, or the learning rate is too large and a gradient update pushes the weights negative), the neuron will always output 0 and always have gradient 0. It is "dead" — it can never recover through gradient descent because there's no gradient to update it. This is called the dying ReLU problem.
import numpy as np
# ── Leaky ReLU: a fix for dying neurons ──
def leaky_relu(x, alpha=0.01):
"""
For negative inputs: output is alpha * x (not zero).
This ensures a small gradient always flows, preventing dead neurons.
"""
return np.where(x > 0, x, alpha * x)
def leaky_relu_derivative(x, alpha=0.01):
return np.where(x > 0, 1.0, alpha)
# ── GELU: used in BERT, GPT, modern Transformers ──
def gelu(x):
"""
Gaussian Error Linear Unit.
Smooth approximation of ReLU.
Outperforms ReLU in large language models.
"""
return 0.5 * x * (1 + np.tanh(np.sqrt(2/np.pi) * (x + 0.044715 * x**3)))
# ── ELU: Exponential Linear Unit ──
def elu(x, alpha=1.0):
"""For negative inputs: alpha*(exp(x)-1) — smooth, negative mean → addresses bias shift."""
return np.where(x > 0, x, alpha * (np.exp(x) - 1))
# Compare at key values
x_vals = np.array([-2, -1, -0.5, 0, 0.5, 1, 2])
print(f"{'x':>6} | {'ReLU':>6} | {'LeakyReLU':>10} | {'ELU':>7} | {'GELU':>7}")
print("-" * 50)
for x in x_vals:
print(f"{x:>6.1f} | {max(0,x):>6.3f} | {leaky_relu(x):>10.4f} | {elu(x):>7.4f} | {gelu(x):>7.4f}")
# Key property: for x > 0, all are identical or near-identical to ReLU
# For x < 0, they differ — Leaky/ELU/GELU allow gradient to flow
For most tasks: start with ReLU. It's fast, well-understood, and usually works. If you see dying neurons (many neurons stuck at exactly 0), try Leaky ReLU (nn.LeakyReLU(0.01)). If you're working with Transformer-based architectures (BERT, GPT), use GELU — it's the standard there. If you see erratic training: try ELU or SELU, which have better properties around zero. Sigmoid: only for binary classification output and LSTM gates. Softmax: only for multi-class output.
8 Weight Initialization: Why Random Matters (And How Random)
Before training starts, you need to give the network some initial weights. This seemingly minor choice has a massive impact on whether training succeeds at all.
The Symmetry Problem: Why Zero Initialization Fails
If you initialize all weights to zero (or any constant), all neurons in a layer compute exactly the same thing (zero input → zero output). Their gradients are identical. The weight update is identical. After training, they're still all identical — the network has learned nothing useful. This is the symmetry problem: you need random initialization to break the symmetry so different neurons can specialize.
Too Large: Exploding Activations
If weights are too large, the pre-activation values (W @ x + b) will be very large, pushing sigmoid/tanh into their saturation regions (where derivatives are near zero) → vanishing gradients. With ReLU, large weights cause the outputs to blow up numerically → exploding values.
Xavier/Glorot Initialization (for Tanh/Sigmoid)
Xavier Glorot showed in 2010 that the optimal weight variance is 2/(fan_in + fan_out), where fan_in is the number of inputs to the layer and fan_out is the number of outputs. This keeps the variance of activations roughly constant across layers — no vanishing or exploding.
Kaiming/He Initialization (for ReLU)
ReLU kills half the activations (the negative half), effectively halving the variance. Kaiming He adjusted the formula to account for this: variance = 2/fan_in. This is the PyTorch default for Linear and Conv layers and is almost always the right choice when using ReLU.
import numpy as np
def show_activation_statistics(W, name):
"""
Forward pass through a deep network with given init.
Track activation mean and std at each layer.
"""
x = np.random.randn(1000, 100) # 1000 samples, 100 features
print(f"\n{'='*50}")
print(f"Init: {name}")
print(f"{'Layer':>6} | {'Mean':>8} | {'Std':>8} | {'Dead %':>8}")
print("-" * 40)
for layer in range(10):
z = x @ W[layer].T
x = np.maximum(0, z) # ReLU
dead_frac = (x == 0).mean() * 100
print(f"{layer+1:>6} | {x.mean():>8.4f} | {x.std():>8.4f} | {dead_frac:>7.1f}%")
np.random.seed(42)
n_layers, fan_in, fan_out = 10, 100, 100
# Bad init 1: Too small (std = 0.01)
W_small = [np.random.randn(fan_out, fan_in) * 0.01 for _ in range(n_layers)]
show_activation_statistics(W_small, "Too small (std=0.01)")
# Bad init 2: Too large (std = 1.0)
W_large = [np.random.randn(fan_out, fan_in) * 1.0 for _ in range(n_layers)]
show_activation_statistics(W_large, "Too large (std=1.0)")
# Good init: Kaiming/He (std = sqrt(2/fan_in))
kaiming_std = np.sqrt(2.0 / fan_in)
W_kaiming = [np.random.randn(fan_out, fan_in) * kaiming_std for _ in range(n_layers)]
show_activation_statistics(W_kaiming, "Kaiming/He (std=sqrt(2/fan_in))")
Kaiming initialization keeps activation variance roughly constant across all 10 layers. The other two initialisations collapse (too small) or explode (too large) within a few layers. In PyTorch, Kaiming init is applied automatically for nn.Linear layers.
Real-World Spotlight: How a Network "Sees" a Handwritten Digit
MNIST — 70,000 handwritten digit images — is the "Hello World" of deep learning. Understanding how a neural network processes these images makes the abstract concepts concrete.
Each image is 28×28 pixels = 784 input neurons. Each pixel value (0–255, normalized to 0–1) is one input. For a fully-connected MLP:
- Layer 1 (edges): 128 neurons, each learning to detect a local pattern — a horizontal edge, a vertical edge, a diagonal. If you visualize the 784 weights of each neuron, they look like hand-drawn strokes.
- Layer 2 (shapes): 64 neurons, each combining the edge detectors from Layer 1. They respond to curves, corners, and junctions — the building blocks of digit structure.
- Output layer: 10 neurons, one per digit (0–9). The softmax activation converts their raw scores to probabilities. The network predicts whichever digit has the highest probability.
import numpy as np
# Simulate MNIST-like processing (conceptual)
# In a real implementation, you'd load the actual dataset
np.random.seed(42)
# Simulate one 28x28 digit image
digit_image = np.random.randint(0, 256, (28, 28)).astype(np.float32) / 255.0
# Step 1: Flatten to 1D vector (784,)
x = digit_image.flatten()
print(f"Original image shape: {digit_image.shape}") # (28, 28)
print(f"Flattened input: {x.shape}") # (784,)
# Define a simple MLP (random weights for illustration)
np.random.seed(42)
W1 = np.random.randn(128, 784) * np.sqrt(2/784) # Kaiming init
b1 = np.zeros(128)
W2 = np.random.randn(64, 128) * np.sqrt(2/128)
b2 = np.zeros(64)
W3 = np.random.randn(10, 64) * np.sqrt(2/64)
b3 = np.zeros(10)
def relu(z): return np.maximum(0, z)
def softmax(z):
e = np.exp(z - z.max())
return e / e.sum()
# Forward pass
h1 = relu(W1 @ x + b1) # Layer 1: 784 → 128 (edge detectors)
h2 = relu(W2 @ h1 + b2) # Layer 2: 128 → 64 (shape detectors)
logits = W3 @ h2 + b3 # Output: 64 → 10 (digit scores)
probs = softmax(logits) # Convert to probabilities
print(f"\nLayer 1 output: {h1.shape} (128 edge-detecting neurons)")
print(f"Layer 2 output: {h2.shape} (64 shape-detecting neurons)")
print(f"Output logits: {logits.shape} (10 digit scores)")
print(f"\nPrediction probabilities:")
for digit, p in enumerate(probs):
bar = "█" * int(p * 40)
print(f" Digit {digit}: {p:.4f} {bar}")
print(f"\nPredicted digit: {np.argmax(probs)}")
total_params = W1.size + b1.size + W2.size + b2.size + W3.size + b3.size
print(f"\nTotal parameters: {total_params:,}")
This hierarchical feature learning — primitives → parts → wholes — is the universal pattern in deep learning. In image networks: pixels → edges → shapes → objects → scenes. In language models: characters → words → phrases → sentences → meaning. In audio: samples → phonemes → words → sentences. Each layer builds on the previous one's abstractions. This is why depth matters: you cannot jump directly from pixels to "cat" without learning the intermediate representations. And this is why deep learning is deep.
✍️ Practice Exercises
- Build a neuron: Implement a single neuron function that takes inputs
x, weightsw, biasb, and an activation choice ('sigmoid', 'relu', 'tanh'). Test it with the input vector [1.5, -0.5, 2.0] and weights [0.3, 0.8, -0.2], bias 0.5. - Perceptron on NAND gate: The NAND gate outputs 0 only when both inputs are 1 (it is the opposite of AND). Train a perceptron on it. Does it converge? Why or why not?
- Visualize activations: Plot the four activation functions (Sigmoid, Tanh, ReLU, Leaky ReLU) on a single figure over the range x ∈ [-5, 5]. Label each curve. Notice how ReLU has a "kink" at 0 and grows without bound, while Sigmoid/Tanh saturate.
- Initialization experiment: Create a 5-layer network with 100 neurons each. Run a forward pass with 1000 input samples under three initialization strategies: zeros, std=0.01, and Kaiming. Print the activation mean and std at each layer and observe the collapse vs. stability.
- XOR with gradient descent: Using the MLP class from Section 5, attempt to train it on XOR. (Hint: you'll need to implement a simple gradient descent loop. The weights will move, but without proper backprop they won't converge optimally — this is preview motivation for Lesson 40.)
▶ Show Hints
import numpy as np
# Exercise 1: Single neuron
def neuron(x, w, b, activation='sigmoid'):
z = np.dot(w, x) + b
if activation == 'sigmoid': return 1 / (1 + np.exp(-z))
if activation == 'relu': return np.maximum(0, z)
if activation == 'tanh': return np.tanh(z)
return z
x_test = np.array([1.5, -0.5, 2.0])
w_test = np.array([0.3, 0.8, -0.2])
b_test = 0.5
for act in ['sigmoid', 'relu', 'tanh']:
print(f"{act}: {neuron(x_test, w_test, b_test, act):.4f}")
# Exercise 2: NAND
X_nand = np.array([[0,0], [0,1], [1,0], [1,1]])
y_nand = np.array([1, 1, 1, 0]) # NAND = NOT AND
# NAND is linearly separable! A perceptron CAN solve it.
# The boundary just needs to exclude (1,1).
📚 Primary Sources for This Lesson
Neural Networks and Deep Learning — Chapter 1 (Michael Nielsen) — the clearest written explanation of perceptrons and artificial neurons anywhere on the internet. Free online. Highly recommended for any concept that is still unclear after this lesson.
Stanford CS231n Lecture 4: Neural Networks — visualisations of activation functions, weight initialization experiments, and MLP architecture are particularly excellent.