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.
Inspect Architecture: Multi-layer perceptron1. 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.
2. The math
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₂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 → impossibleat 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.6931h = 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(0, 0) → (0, 0) (0, 1) → (1, 0) (1, 0) → (1, 0) (1, 1) → (2, 1)
output: z = h₁ − 2·h₂ → 0, 1, 1, 0p = σ(10·(h₁ − 2h₂) − 5) → σ(−5) = 0.0067, σ(5) = 0.9933
cross-entropy: −ln 0.9933 = 0.0067 on every setting, so L = 0.0067without 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 boundary2 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: 33random 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]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• 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
4. The code (python)
# 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.
6. Go further
- 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?
- 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.
- 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
In the catalog
Finished this chapter?