Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 8

Solving XOR: Why Networks Need a Hidden Layer

A staircase light with two switches is too much for a single neuron: no straight line separates on from off. Add one hidden layer and the problem bends into shape, then watch gradient descent find that layer by itself, most of the time.

38 min Prerequisites: Chapters 2, 6 and 7 (the Tensor class saved as tensor.py)
Inspect Architecture: Multi-layer perceptron

1. The idea

Many houses have a staircase light with a switch at the bottom and another at the top. Flip either one and the light changes. With both switches down the light is off; with exactly one up it's on; with both up it's off again. As a table of inputs (bottom, top) and output (light), that's (0, 0) → 0, (0, 1) → 1, (1, 0) → 1, (1, 1) → 0. Logicians call it XOR, 'exclusive or', and in 1969 it became famous as the problem a single neuron cannot learn.

The reason is geometric. Chapter 2 showed that a neuron splits its input space with a straight line (in general a flat plane): everything on one side scores high, everything on the other low. Draw the four switch settings as the corners of a square and the 'on' corners sit on one diagonal, the 'off' corners on the other. No straight line can put both diagonals on different sides. Train one neuron anyway and it settles on predicting 0.5 for every setting, a loss of exactly ln 2 = 0.693, the same as a coin flip.

The fix is a hidden layer. Two ReLU neurons, h₁ = ReLU(x₁ + x₂) and h₂ = ReLU(x₁ + x₂ − 1), count how many switches are up, and whether both are. In the new coordinates (h₁, h₂) the four settings land on just three points, and a single line separates on from off. The output neuron draws that line: light = h₁ − 2h₂ gives exactly 0, 1, 1, 0. The ReLU's bend is essential. Without it, as Chapter 2 showed, two layers collapse into one, and we're back to a single straight line.

Then we hand the problem to gradient descent: random starting weights, Chapter 7's loop, 2,000 steps. With 4 hidden units it finds a solution with loss 0.002. But not every time. Over 20 random starts, 2 hidden units solve XOR only twice, 4 units 15 times and 8 units 18 times. The failures are worth studying, because they're the same ones that trouble much bigger networks: hidden units that go silent, and units that start out identical and stay that way.

Two charts. Left, input space: the four switch settings at the corners of a square. (0, 1) and (1, 0) are filled, meaning the light is on; (0, 0) and (1, 1) are open, meaning off. The on points sit on one diagonal and the off points on the other, so no straight line separates them. Right, after the hidden layer h₁ = ReLU(x₁ + x₂), h₂ = ReLU(x₁ + x₂ − 1): (0, 0) stays at (0, 0), both on settings land on (1, 0), and (1, 1) moves to (2, 1). A dashed line, h₁ − 2h₂ = 0.5, now has the on point on one side and both off points on the other.Two charts. Left, input space: the four switch settings at the corners of a square. (0, 1) and (1, 0) are filled, meaning the light is on; (0, 0) and (1, 1) are open, meaning off. The on points sit on one diagonal and the off points on the other, so no straight line separates them. Right, after the hidden layer h₁ = ReLU(x₁ + x₂), h₂ = ReLU(x₁ + x₂ − 1): (0, 0) stays at (0, 0), both on settings land on (1, 0), and (1, 1) moves to (2, 1). A dashed line, h₁ − 2h₂ = 0.5, now has the on point on one side and both off points on the other.
What a hidden layer is for. It moves the points into a new space where a single straight line can do the job, and the output neuron draws that line.

2. The math

The staircase light is XOR
x₁, x₂ ∈ {0, 1} (bottom, top switch, 1 = up) y = 1 exactly when x₁ ≠ x₂ (0, 0) → 0 (0, 1) → 1 (1, 0) → 1 (1, 1) → 0 as a formula: y = x₁ + x₂ − 2·x₁·x₂
The formula has a product x₁·x₂ in it. A neuron can only add weighted inputs, which is a hint that one neuron won't be enough.
One neuron draws one straight line
light on ⇔ w₁·x₁ + w₂·x₂ + b > 0 boundary: w₁·x₁ + w₂·x₂ + b = 0, a straight line XOR needs: b ≤ 0, w₂ + b > 0, w₁ + b > 0, w₁ + w₂ + b ≤ 0 → impossible
The four inequalities say 'off at (0, 0), on at (0, 1) and (1, 0), off at (1, 1)'. The first derivation shows they contradict each other.
The best one neuron can do is a coin flip
at w = 0, b = 0: P − Y = [0.5, −0.5, −0.5, 0.5] (P − Y)ᵀ X / 4 = [0, 0], mean(P − Y) = 0 ⇒ the gradient is zero one sigmoid neuron's loss is a bowl ⇒ L_min = ln 2 = 0.6931
Cross-entropy for a single sigmoid neuron is convex in (w, b): it has one valley and no other dips. Training from a random start lands there, at p = 0.5 for every setting.
A hand-built hidden layer
h = ReLU(X W₁ᵀ + b₁) W₁ = [ 1 1 ; 1 1 ] b₁ = [0, −1] h₁ = ReLU(x₁ + x₂) counts the switches that are up h₂ = ReLU(x₁ + x₂ − 1) is 1 only when both are
Same layout as Chapter 2: one neuron per row of W₁. Both neurons see the same sum, but h₂'s bias of −1 keeps it silent until both switches are up.
The hidden layer moves the points
(0, 0) → (0, 0) (0, 1) → (1, 0) (1, 0) → (1, 0) (1, 1) → (2, 1) output: z = h₁ − 2·h₂ → 0, 1, 1, 0
Both 'on' settings land on the same hidden point, and (1, 1) is lifted out of line. In (h₁, h₂) space the line h₁ − 2h₂ = 0.5 separates on from off: that's the figure's right-hand panel.
As probabilities
p = σ(10·(h₁ − 2h₂) − 5) → σ(−5) = 0.0067, σ(5) = 0.9933 cross-entropy: −ln 0.9933 = 0.0067 on every setting, so L = 0.0067
Scaling by 10 and shifting by 5 turns the exact 0/1 answers into confident probabilities. Bigger scales push the loss closer to 0, which is why a trained network's weights keep growing slowly.
Why the bend matters
without ReLU: W₂(W₁x + b₁) + b₂ = (W₂W₁)x + (W₂b₁ + b₂) → one neuron again ReLU(u) = max(0, u) has a kink at u = 0, and the kinks are what bend the boundary
Chapter 2 checked this numerically. However many linear layers you stack, the result draws a single straight line. Each ReLU unit adds one fold, and the output combines the folds.
Counting knobs
2 inputs → H hidden → 1 output: W₁ (H × 2) + b₁ (H) + W₂ (1 × H) + b₂ (1) = 4H + 1 H = 2: 9 H = 3: 13 H = 4: 17 H = 8: 33
Two hidden units are enough in principle (the hand-built network uses exactly 9 knobs). Extra units don't make XOR any easier to represent, but they make it much easier to find.
Gradient descent finds a hidden layer, usually
random normal start, η = 0.5, 2,000 full-batch steps, 20 random starts each H = 2: solved 2/20 H = 3: 10/20 H = 4: 15/20 H = 8: 18/20 H = 4, seed 0: L = 0.0020, p = [0.006, 0.999, 0.999, 0.001]
'Solved' means all four settings end up on the right side of 0.5. The successful runs reach losses around 0.002. The failures stop at a handful of specific values, explained in the next box.
How it fails
a dead unit: ReLU input < 0 on every example ⇒ output 0, slope 0, its weights never change H = 2, one dead and one always on ⇒ linear again ⇒ L = ln 2 = 0.693 H = 2, one unit firing on one setting only ⇒ p = [1/3, 1/3, 1, 1/3] L = (2 ln 1.5 + ln 3) / 4 = 0.477 all weights equal at the start ⇒ every hidden unit stays identical ⇒ L = ln 2
A dead unit gets zero gradient, so nothing can revive it. A unit that fires on every input is just linear. The symmetric start is the second derivation. More hidden units give more chances that enough of them start somewhere useful.

• Suppose a neuron with weights w₁, w₂ and bias b turns the light on exactly when w₁·x₁ + w₂·x₂ + b > 0. Write down what XOR demands at each corner.

• (0, 0) off: b ≤ 0. (0, 1) on: w₂ + b > 0. (1, 0) on: w₁ + b > 0. (1, 1) off: w₁ + w₂ + b ≤ 0.

• Add the two 'on' conditions: w₁ + w₂ + 2b > 0, so w₁ + w₂ + b > −b.

• From the first condition, b ≤ 0, so −b ≥ 0. That gives w₁ + w₂ + b > 0, which contradicts the 'off' condition at (1, 1). So no choice of w₁, w₂, b works. ∎

• The same argument works for a sigmoid neuron with any threshold, because σ is increasing: 'p > 0.5' is the same as 'z > 0'. Geometrically, the midpoint of the 'on' diagonal and the midpoint of the 'off' diagonal are the same point, (0.5, 0.5), and a straight line can't put one point on both sides.

• Suppose hidden units j and k start with identical incoming weights (rows j and k of W₁ are equal, and b₁ⱼ = b₁ₖ) and identical outgoing weights (W₂[0, j] = W₂[0, k]).

• Forward: they receive the same input with the same weights, so hⱼ = hₖ on every example.

• Backward: the output sees two identical units with identical weights, so the slope arriving at each is the same, dhⱼ = dhₖ. Their local slopes (ReLU′) are the same too, so their incoming weight gradients are equal, and so are their outgoing weight gradients, which are dZ times hⱼ or hₖ.

• Equal weights plus equal gradients give equal weights after the step. By induction they stay equal at every step, forever. ∎ So H identical units behave like a single unit, which can't solve XOR: in the experiment every row of W₁ ends at 0.3476 and the loss at ln 2.

• The cure is randomness at the start, which is why every framework initializes weights randomly. How large that randomness should be is the subject of Chapter 12.

3. How it works

1
Check whether one line can do it
Plot the classes, or try a single neuron. If its loss gets stuck well above zero, the classes probably aren't separable by a straight line, and you need a hidden layer.
2
Add a hidden layer with a bend
H = ReLU(X W₁ᵀ + b₁), then the output neuron reads H. Without the ReLU the extra layer adds nothing.
3
Start from random weights
Draw W₁ and W₂ from a normal distribution. Zero or equal weights leave every hidden unit identical, and they stay that way.
4
Train with the loop
Cross-entropy loss, Chapter 7's loop, full batch for four examples. Watch the loss: around 0.002 means solved; stuck at 0.693 or 0.477 means a failure mode.
5
If it fails, restart or widen
Try another random seed, or more hidden units. Wider layers fail less often because more units start somewhere useful. Look at which units fire on which inputs to see what went wrong.

4. The code (python)

core_ch8.py
# Chapter 8: XOR, the problem one neuron can't solve, and the hidden layer that fixes it.
# Uses the Tensor class from Chapter 6, saved as tensor.py (see Chapter 7).
import numpy as np
from tensor import Tensor

# The staircase light: a switch at the bottom and one at the top. The light is on
# when exactly one of them is up. That's XOR.
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])   # (bottom, top), 1 = up
Y = np.array([[0.0], [1.0], [1.0], [0.0]])                         # 1 = light on


def cross_entropy(P):
    return -(Y * P.log() + (1 - Y) * (1 - P).log()).mean()


def fit(params, forward, lr=0.5, steps=2000):
    """Chapter 7's loop, full batch: forward, zero the grads, backward, step."""
    for _ in range(steps):
        loss = cross_entropy(forward())
        for p in params:
            p.grad = np.zeros_like(p.data)
        loss.backward()
        for p in params:
            p.data = p.data - lr * p.grad
    P = forward()
    return float(cross_entropy(P).data), P.data.ravel()


# === 1. One neuron does its best, and its best is a coin flip =====================
rng = np.random.default_rng(0)
w, b = Tensor(rng.normal(size=(1, 2))), Tensor(np.zeros(1))
loss, P = fit([w, b], lambda: (Tensor(X) @ w.T + b).sigmoid(), steps=5000)
print(f"one neuron:  loss {loss:.4f} (ln 2 = {np.log(2):.4f}), predictions {P.round(3)}")

# === 2. A two-layer network, built by hand ========================================
W1 = np.array([[1.0, 1.0],      # h1 counts the switches that are up
               [1.0, 1.0]])     # h2 ... minus 1: it only fires when both are up
b1 = np.array([0.0, -1.0])
w2 = np.array([1.0, -2.0])      # light = h1 − 2·h2
H = np.maximum(0, X @ W1.T + b1)
print("\nhand-built:  input   hidden (h1, h2)   h1 − 2·h2")
for x, h in zip(X, H):
    print(f"            {x.astype(int)}     {h}           {h @ w2:+.0f}")
p = 1 / (1 + np.exp(-(10 * (H @ w2) - 5)))       # scaled into probabilities
print("as probabilities:", p.round(4), " loss", round(float(-np.mean(Y.ravel() * np.log(p) + (1 - Y.ravel()) * np.log(1 - p))), 4))

# === 3. Let gradient descent find its own hidden layer ==============================
def mlp(hidden, seed, init="random"):
    rng = np.random.default_rng(seed)
    if init == "random":
        W1 = Tensor(rng.normal(size=(hidden, 2)))
        W2 = Tensor(rng.normal(size=(1, hidden)))
    else:                                           # every weight the same: a symmetric start
        W1 = Tensor(np.full((hidden, 2), 0.5))
        W2 = Tensor(np.full((1, hidden), 0.5))
    b1, b2 = Tensor(np.zeros(hidden)), Tensor(np.zeros(1))
    params = [W1, b1, W2, b2]

    def forward():
        H = (Tensor(X) @ W1.T + b1).relu()
        return (H @ W2.T + b2).sigmoid()
    loss, P = fit(params, forward)
    active = (np.maximum(0, X @ W1.data.T + b1.data) > 0).sum(axis=0)   # inputs each hidden unit fires on
    return loss, P, active, W1.data

loss, P, active, _ = mlp(4, seed=0)
print(f"\ntrained 2-4-1 net (seed 0): loss {loss:.4f}, predictions {P.round(3)}")

# === 4. It doesn't always work: 20 random starts per width ==========================
print("\nhidden units   solved   final losses of the first 10 starts")
for hidden in [2, 3, 4, 8]:
    runs = [mlp(hidden, seed) for seed in range(20)]
    solved = sum(np.all((P > 0.5) == (Y.ravel() == 1)) for _, P, _, _ in runs)
    print(f"{hidden:12}    {solved:3}/20   {[round(l, 3) for l, _, _, _ in runs[:10]]}")

# What the two kinds of failure look like, for 2 hidden units
for seed in [0, 2]:
    loss, P, active, _ = mlp(2, seed)
    print(f"seed {seed}: loss {loss:.4f}, predictions {P.round(3)}, inputs each unit fires on: {active}")

# === 5. A symmetric start never breaks the tie ====================================
loss, P, _, W1 = mlp(4, seed=0, init="same")
print(f"\nall weights 0.5: loss {loss:.4f}, predictions {P.round(3)}")
print("first-layer weights after training (every row identical):\n", W1.round(4))

5. Practice

Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.

P1 In the hand-built network, what is h₁ = ReLU(x₁ + x₂) for the setting (1, 1)?
Both switches are up.
Solution. ReLU(1 + 1) = 2. And h₂ = ReLU(1 + 1 − 1) = 1.
P2 Still at (1, 1): what is the output z = h₁ − 2·h₂?
Use h₁ = 2 and h₂ = 1.
Solution. 2 − 2·1 = 0: the light is off. h₂'s job is to cancel h₁ exactly when both switches are up.
P3 How many knobs does a 2-4-1 network have (2 inputs, 4 hidden units, 1 output, with biases)?
4H + 1 with H = 4.
Solution. W₁ has 8, b₁ has 4, W₂ has 4 and b₂ has 1: 17.
P4 A model predicts p = 0.5 for all four settings. What is its mean cross-entropy? (3 decimal places.)
Every example costs −ln 0.5, whatever its label.
Solution. −ln 0.5 = ln 2 ≈ 0.693 on every example, so the mean is 0.693. That's the best one neuron can do on XOR.
P5 A trained hidden unit has weights (2.35, −2.53) and bias −0.15. On how many of the four settings does its ReLU fire (input > 0)?
Compute 2.35·x₁ − 2.53·x₂ − 0.15 at each corner.
Solution. (0, 0): −0.15. (0, 1): −2.68. (1, 0): 2.20. (1, 1): −0.33. Only (1, 0) is positive. This is the unit from the experiment's seed 2, stuck at loss 0.477.
P6 A ReLU unit's input is negative on all four examples. What gradient do its incoming weights receive?
ReLU's slope is 0 wherever its input is negative.
Solution. 0 for every weight and the bias: the unit outputs 0, its slope is 0, and nothing flows back through it. A dead unit stays dead.
P7 In the scaled hand-built network, p = σ(10·(h₁ − 2h₂) − 5). What is p for the setting (0, 1)? (4 decimal places.)
h₁ − 2h₂ = 1 there, so the input to σ is 5.
Solution. σ(5) = 1 / (1 + e⁻⁵) ≈ 0.9933.
P8 A stuck network predicts p = [1/3, 1/3, 1, 1/3] for labels [0, 1, 1, 0]. What is its mean cross-entropy? (3 decimal places.)
The two 'off' settings cost −ln(2/3) each, (0, 1) costs −ln(1/3), and (1, 0) costs nothing.
Solution. (2·ln 1.5 + ln 3 + 0) / 4 = (0.811 + 1.099) / 4 ≈ 0.477, the value the failed runs stop at.

6. Go further

  1. Replace ReLU with a leaky ReLU, which has slope 0.01 for negative inputs instead of 0, so no unit can die completely. Add it to tensor.py with its own _backward, rerun the 20-seed table for 2 hidden units, and compare with the 2/20 above. Which failure mode disappears?
  2. Add a third switch on a landing halfway up the stairs: the light is on when an odd number of switches are up. Write the 8-row truth table and hand-build a network with ReLU units of the form ReLU(x₁ + x₂ + x₃ − k). How many hidden units do you need, and what output weights? Then check whether gradient descent finds a solution with that many units, and with twice as many.
  3. Draw what the trained 2-4-1 network (seed 0) does between the corners: evaluate it on a 21 × 21 grid over x₁, x₂ ∈ [−0.5, 1.5] and print '#' where p > 0.5 and '.' elsewhere. What shape is the 'on' region? Compare with the hand-built network.

7. Check yourself

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 Why can't a single neuron learn XOR?
Q2/5 What does the hidden layer do in the hand-built network?
Q3/5 What happens if you remove the ReLU from the hidden layer?
Q4/5 Why does starting every weight at the same value (say 0.5) fail?
Q5/5 Two hidden units are enough to represent XOR. Why do 8 units solve it far more often (18/20 against 2/20)?

Finished this chapter?