Monocurl Essays
Series Download

What a neural network computes

A neuron is a linear score followed by a bend. Sums of bends build bumps, bumps build any curve, and two neurons solve XOR. All with explicit weights.

21 min·Early college·

AssumesClassification: drawing a line between yes and noWhat slope means, and why y = mx + b is a line

A network of ReLU neurons approximating $\sin x$ on $[0, 2\pi]$, with weights given by an explicit formula. Each neuron contributes one bend, at a dot. With 4, 8, 16 and 32 neurons the largest error falls from 0.21 to 0.005.
Monocurl source
let fh = Frame(0, TAU, -1.3, 1.3, [-0.75, 0.1, 0], 5.0, 3.0)
let Fit = |n| {
    let nw = sin_net(n)
    let ts = sin_knots(n)
    return [
        Path(map(ts, |t| at(fh, t, net(nw[0], nw[1], t))), EMBER, 3.5),
        map(ts, |t| Pt(at(fh, t, sin(t)), EMBER, [0.075, 0.05][n > 16]))
    ]
}
let worst = |n| {
    let nw = sin_net(n)
    return max_of(map(sample(0, TAU, 400), |x| abs(net(nw[0], nw[1], x) - sin(x))))
}
let Read = |n| [
    center{[2.85, 0.75, 0]} Math(to_string(n), 1.2, EMBER),
    center{[2.85, -0.75, 0]} Math(to_string(worst(n), 3), 1.2, ROSE)
]
mesh plot = PlotFrame(fh, [1, 2, 3, 4, 5, 6], [-1, 0, 1], "x", "output")
mesh zero = Seg(at(fh, 0, 0), at(fh, TAU, 0), SLATE, 2)
mesh target = Curve(fh, sin, AZURE, 5)
mesh names = [
    center{[2.85, 1.25, 0]} Note("neurons", 0.76),
    center{[2.85, -0.25, 0]} Note("largest error", 0.76)
]
mesh fit = Fit(4)
mesh read = Read(4)

slide "more"
    play Wait(1.2)
    for (n in [8, 16, 32]) {
        read = []
        play Fade(0.25, [&read])
        fit = Fit(n)
        read = Read(n)
        play Fade(0.6)
        play Wait(1.1)
    }
    play Wait(1)

Uses the shared definitions for this lesson's figures.

The last lesson ended with a defeat. A classifier that computes one linear score, $z = w_1x_1 + w_2x_2 + b$, and says “yes” when $z > 0$ has a straight decision boundary, and no straight boundary separates the four XOR points, where the answer is yes exactly when one of the two inputs is 1 and the other is 0.

One linear score is not enough, so use several: compute a few scores from the inputs, then compute a final score from those. Try it with one input. Let the first score be $z = 2x + 1$ and feed it into a second, $u = 3z - 4$. Then $u = 3(2x + 1) - 4 = 6x - 1$. Two layers of arithmetic have produced one linear function of $x$, and this always happens: a linear function of linear functions is linear, so a stack of linear scores, however tall, has exactly the power of one.

Something has to break the linearity between the layers, and it takes very little: one bend.

A neuron is a score followed by a bend

The simplest function with a bend in it is the rectified linear unit, written $\operatorname{ReLU}$:

$$ \operatorname{ReLU}(z) = \max(0, z). $$

It passes positive numbers through unchanged and replaces negative numbers by 0. Apply it to a linear score and you have the basic unit of a neural network.

The part inside the parentheses is the linear score of the earlier lessons; mathematicians call a linear function plus a constant an affine map. The activation is the only new ingredient. Logistic regression is itself a single neuron, with the sigmoid as its activation.

With one input, a ReLU neuron is $h(x) = \operatorname{ReLU}(wx + b)$. Take $w = 2$ and $b = -2$. At $x = 0, 1, 2, 3$ the score $2x - 2$ is $-2, 0, 2, 4$, so the neuron outputs $0, 0, 2, 4$. Its graph is flat at 0 until the score turns positive, at $x = 1$, and from there it is a ramp of slope 2.

Three ReLU neurons with one input. Each graph is flat on one side of a kink and a straight ramp on the other. The kink is where the score wx + b is zero, at x = -b/w, and the ramp has slope w. A negative weight makes the ramp face left.
Three ReLU neurons with one input. Each graph is flat on one side of a kink and a straight ramp on the other. The kink is where the score $wx + b$ is zero, at $x = -b/w$, and the ramp has slope $w$. A negative weight makes the ramp face left.
Monocurl source
let fn = Frame(-3, 4, 0, 4.6, [0, 0.1, 0], 6.4, 3.0)
mesh plot = PlotFrame(fn, [-3, -2, -1, 0, 1, 2, 3, 4], [1, 2, 3, 4], "x", "neuron output h", 0.9)
mesh graphs = [
    Curve(fn, |x| relu(x), AZURE, 4.5),
    Curve(fn, |x| relu(2 * x - 2), EMBER, 4.5),
    Curve(fn, |x| relu(1 - x), PLUM, 4.5)
]
mesh kinks = [Pt(at(fn, 0, 0), AZURE, 0.09), Pt(at(fn, 1, 0), INK, 0.09)]
mesh labels = [
    center{at(fn, 3.1, 2.2)} Math("\mathrm{ReLU}(x)", 0.85, AZURE),
    center{at(fn, 1.75, 3.9)} Math("\mathrm{ReLU}(2x - 2)", 0.85, EMBER),
    center{at(fn, -1.6, 3.6)} Math("\mathrm{ReLU}(1 - x)", 0.85, PLUM)
]

Uses the shared definitions for this lesson's figures.

One neuron cannot do much: its graph has a single kink. The interest is in what sums of them can do.

From ramps to a bump

Add the outputs of several neurons, each multiplied by a number of our choosing. Start with two, and subtract a ramp that starts at $x = 1$ from a ramp that starts at $x = 0$:

$$ s(x) = \operatorname{ReLU}(x) - \operatorname{ReLU}(x - 1). $$

Pause and try

Compute $s(x)$ at $x = -1,\ 0.5,\ 1,\ 3$ and describe its graph.

Show answerHide answer

$s(-1) = 0 - 0 = 0$. $s(0.5) = 0.5 - 0 = 0.5$. $s(1) = 1 - 0 = 1$. $s(3) = 3 - 2 = 1$. The graph is 0 up to $x = 0$, climbs with slope 1 until $x = 1$, and is flat at 1 from then on: once both neurons are active, their slopes $1$ and $-1$ cancel. It is a step with a sloped riser.

Two neurons raise the output and hold it. To make a bump, a function that is zero far to the left, rises, and returns to zero for good, the output has to be brought down again, which takes more downward slope. Subtract the second ramp twice, so that the total slope after $x = 1$ is $1 - 2 = -1$, and then add a third ramp at $x = 2$ to cancel the fall once it reaches zero:

$$ T(x) = \operatorname{ReLU}(x) - 2\operatorname{ReLU}(x - 1) + \operatorname{ReLU}(x - 2). $$

Check it: $T(0) = 0$, $T(1) = 1$, $T(1.5) = 1.5 - 1 = 0.5$, $T(2) = 2 - 2 = 0$, and $T(3) = 3 - 4 + 1 = 0$. It is a tent of height 1 standing on the interval $[0, 2]$, and zero everywhere else.

  1. One neuron: zero up to its kink at x = 0, then a ramp of slope 1.
  2. Subtract a neuron with its kink at x = 1. From there on the two slopes cancel, and the sum stays at 1.
  3. Give that neuron the weight -2 instead. After x = 1 the sum now falls with slope -1 and keeps falling.
  4. A third neuron with its kink at x = 2 cancels the fall. The sum is a bump: a tent of height 1 on the interval from 0 to 2.
Building a bump from ReLU neurons. The thin lines are the weighted outputs of the individual neurons and the thick green line is their sum. Each new neuron changes the slope of the sum from its kink onward and leaves everything to the left of the kink alone.
Monocurl source
let fb = Frame(-1.5, 4.5, -2.4, 3.2, [0, -0.15, 0], 6.4, 2.9)
let PARTS = [[1, 0, AZURE], [-1, 1, EMBER], [-2, 1, EMBER], [1, 2, PLUM]]
let Piece = |i| Curve(fb, |x| PARTS[i][0] * relu(x - PARTS[i][1]), PARTS[i][2], 2)
let Total = |ids| Curve(fb, |x| sum(map(ids, |i| PARTS[i][0] * relu(x - PARTS[i][1]))), LEAF, 5)
let Title = |tex| center{[0, 1.95, 0]} Math(tex, 0.9, INK)
mesh plot = PlotFrame(fb, [-1, 0, 1, 2, 3, 4], [-2, -1, 0, 1, 2, 3], "x", "output", 0.3)
mesh zero = Seg(at(fb, -1.5, 0), at(fb, 4.5, 0), SLATE, 2)
mesh pieces = []
mesh total = []
mesh title = []

slide "One neuron: zero up to its kink at x = 0, then a ramp of slope 1."
    total = Total([0])
    title = Title("\mathrm{ReLU}(x)")
    play Fade(0.6)

slide "Subtract a neuron with its kink at x = 1. From there on the two slopes cancel, and the sum stays at 1."
    title = []
    play Fade(0.3, [&title])
    pieces = [Piece(0), Piece(1)]
    total = Total([0, 1])
    title = Title("\mathrm{ReLU}(x) - \mathrm{ReLU}(x - 1)")
    play Fade(0.7)

slide "Give that neuron the weight -2 instead. After x = 1 the sum now falls with slope -1 and keeps falling."
    title = []
    play Fade(0.3, [&title])
    pieces = [Piece(0), Piece(2)]
    total = Total([0, 2])
    title = Title("\mathrm{ReLU}(x) - 2\,\mathrm{ReLU}(x - 1)")
    play Fade(0.7)

slide "A third neuron with its kink at x = 2 cancels the fall. The sum is a bump: a tent of height 1 on the interval from 0 to 2."
    title = []
    play Fade(0.3, [&title])
    pieces = [Piece(0), Piece(2), Piece(3)]
    total = Total([0, 2, 3])
    title = Title("\mathrm{ReLU}(x) - 2\,\mathrm{ReLU}(x - 1) + \mathrm{ReLU}(x - 2)")
    play Fade(0.7)

Uses the shared definitions for this lesson's figures.

Two neurons could not have made a bump, and the proof uses the rule that the figure illustrates. A weighted sum of ReLU neurons is continuous and made of straight pieces, and its slope can change only at a neuron’s kink. With two neurons there are at most two kinks, so at most three pieces. For a bump, the first and last pieces are the zero function, so the function is 0 at both kinks, and the middle piece is a straight line through two zeros, which is zero too. A bump needs three kinks: up, over the top, and back to flat.

Any curve from bumps, and the weights to do it

A tent can be moved, by shifting the kinks, widened, by spreading them, and scaled, by multiplying the three output weights. So we can plant a tent of any height at any place. That is enough to build a function through any list of points. Suppose we want a curve through

$$ (0, 0), \quad (1, 2), \quad (2, 1), \quad (3, 3). $$

Plant a tent of height 2 centered at $x = 1$, one of height 1 centered at $x = 2$, and half a tent of height 3 rising toward $x = 3$. At $x = 1$ only the first tent is nonzero, so the sum is 2; at $x = 2$ only the second, so the sum is 1; and in between the sum of two straight pieces is straight. The sum joins the dots with straight segments.

Counting neurons, we can be more economical. Each tent uses three neurons, but neighboring tents have kinks at the same places, and two neurons with the same kink can be merged into one by adding their output weights. That leaves one neuron per kink, and the weight of each is determined by a simple rule: a neuron’s output weight is the change of slope at its kink. The segments above have slopes $2, -1, 2$. So the slope must change by $+2$ at $x = 0$ (from flat), by $-3$ at $x = 1$ and by $+3$ at $x = 2$:

$$ g(x) = 2\operatorname{ReLU}(x) - 3\operatorname{ReLU}(x - 1) + 3\operatorname{ReLU}(x - 2). $$

Pause and try

Check that $g$ passes through all four points.

Show answerHide answer

$g(0) = 0$. $g(1) = 2 - 0 + 0 = 2$. $g(2) = 4 - 3 + 0 = 1$. $g(3) = 6 - 6 + 3 = 3$.

  1. The first neuron has its kink at x = 0 and weight 2, the slope needed to reach the second point.
  2. At x = 1 the slope must change from 2 to -1, so the second neuron has weight -3.
  3. At x = 2 the slope must change from -1 to 2, so the third neuron has weight 3. The sum passes through all four points.
A network of three ReLU neurons through four given points. Each neuron’s kink is at a data point and its output weight is the change of slope needed there: $+2$, $-3$, $+3$.
Monocurl source
let fp = Frame(-0.6, 3.6, -0.5, 3.6, [-0.2, -0.15, 0], 5.6, 2.9)
let TS = [0, 1, 2, 3]
let YS = [0, 2, 1, 3]
let CS = fit_weights(TS, YS)
let Upto = |k| Curve(fp, |x| net(take(CS, k), take(TS, k), x), LEAF, 5)
let Title = |tex| center{[-0.2, 1.95, 0]} Math(tex, 0.9, INK)
mesh plot = PlotFrame(fp, [0, 1, 2, 3], [0, 1, 2, 3], "x", "output", 0.3)
mesh sumline = []
mesh pts = map(range(0, 4), |i| Pt(at(fp, TS[i], YS[i]), INK, 0.1))
mesh title = []

slide "The first neuron has its kink at x = 0 and weight 2, the slope needed to reach the second point."
    sumline = Upto(1)
    title = Title("2\,\mathrm{ReLU}(x)")
    play Fade(0.6)

slide "At x = 1 the slope must change from 2 to -1, so the second neuron has weight -3."
    title = []
    play Fade(0.3, [&title])
    sumline = Upto(2)
    title = Title("2\,\mathrm{ReLU}(x) - 3\,\mathrm{ReLU}(x - 1)")
    play Fade(0.7)

slide "At x = 2 the slope must change from -1 to 2, so the third neuron has weight 3. The sum passes through all four points."
    title = []
    play Fade(0.3, [&title])
    sumline = Upto(3)
    title = Title("2\,\mathrm{ReLU}(x) - 3\,\mathrm{ReLU}(x - 1) + 3\,\mathrm{ReLU}(x - 2)")
    play Fade(0.7)

Uses the shared definitions for this lesson's figures.

The arrangement we have been using has a name and a standard picture. The inputs feed a row of neurons, called a hidden layer because its outputs are neither inputs nor the final answer, and the output is a weighted sum of the hidden neurons plus a bias.

The rule for the weights is a theorem, and it holds for any list of points.

This is a network with one hidden layer of $n$ ReLU neurons, with $w_k = 1$, $b_k = -t_k$, $c_k = m_{k+1} - m_k$ and $c_0 = g(t_0)$. The figure at the top of the page applies the theorem to the broken line through points on the sine curve at $n$ equally spaced inputs. With 4 neurons the largest gap between the network and $\sin x$ is $0.21$; with 8 it is $0.070$; with 16, $0.019$; with 32, $0.0048$. Each doubling divides the error by between three and four, and the ratio approaches four.

The universal approximation theorem, and its fine print

How good the approximation gets depends on how smooth the target is. For a twice-differentiable function $f$ whose second derivative is at most $M$ in size, the broken line through points spaced $h$ apart is never farther than $Mh^2/8$ from $f$. This is the classical error bound for linear interpolation, proved in numerical analysis from Rolle’s theorem; for $\sin x$, $M = 1$, and with 32 pieces it gives $0.0048$. For an arbitrary continuous $f$ on a closed bounded interval there is no such formula, but the error still tends to 0 as the spacing shrinks, because a continuous function on a closed bounded interval is uniformly continuous (the Heine–Cantor theorem). Together with our theorem, that proves that one hidden layer of ReLU neurons can approximate any continuous function of one variable on a closed bounded interval, as closely as desired.

The same is true with any number of inputs and almost any activation. We state this without proof.

ReLU and the sigmoid are continuous and are not polynomials, so both qualify. The statement is often summarized as “a neural network can learn anything”, which claims far more than the theorem does. Read what it says.

It says suitable parameters exist. It does not say how many neurons are needed, and for functions of many variables $N$ can be astronomically large. It does not say that gradient descent will find those parameters: the loss surface of a network is not a bowl, and the guarantees of the lesson on gradient descent for non-convex losses are weak. It concerns a function $f$ that is known everywhere on $K$, whereas training has only finitely many examples, so it is silent on how a network behaves between them, which is the subject of the lesson on overfitting. And it promises nothing outside $K$.

The 8-neuron approximation to \sin x, drawn beyond the interval [0, 2\pi] on which it was built (shaded). Inside, it follows the sine curve. To the left every neuron is off and the output is stuck at 0; to the right every neuron is on and the output is a straight line that climbs forever.
The 8-neuron approximation to $\sin x$, drawn beyond the interval $[0, 2\pi]$ on which it was built (shaded). Inside, it follows the sine curve. To the left every neuron is off and the output is stuck at 0; to the right every neuron is on and the output is a straight line that climbs forever.
Monocurl source
let fx2 = Frame(-3.5, 10, -2.2, 3.4, [0, 0.05, 0], 6.6, 3.0)
let NW = sin_net(8)
mesh band = Region([at(fx2, 0, -2.2), at(fx2, TAU, -2.2), at(fx2, TAU, 3.4), at(fx2, 0, 3.4)], tint(LEAF, 0.12))
mesh plot = PlotFrame(fx2, [-2, 0, 2, 4, 6, 8, 10], [-2, -1, 0, 1, 2, 3], "x", "output", 0.3)
mesh zero = Seg(at(fx2, -3.5, 0), at(fx2, 10, 0), SLATE, 2)
mesh target = Curve(fx2, sin, AZURE, 3.5)
mesh approx = Curve(fx2, |x| net(NW[0], NW[1], x), EMBER, 4.5, 400)
mesh labels = [
    center{at(fx2, 3.1, 2.7)} Cap("built to match here", 0.74, LEAF),
    center{at(fx2, 6.9, 2.3)} Cap("network", 0.74, EMBER),
    center{at(fx2, 8.9, -1.35)} Math("\sin x", 0.85, AZURE)
]

Uses the shared definitions for this lesson's figures.

Two inputs: solving XOR

Now back to the problem that started this. With two inputs, a neuron $\operatorname{ReLU}(w_1x_1 + w_2x_2 + b)$ is zero on one side of the line $w_1x_1 + w_2x_2 + b = 0$ and rises like a tilted plane on the other. Its kink is a crease along that line.

The XOR points are $(0,0)$ and $(1,1)$, labeled 0, and $(1,0)$ and $(0,1)$, labeled 1. Look at the sum $s = x_1 + x_2$ at each: it is $0$ for the first point, $2$ for the second, and $1$ for both points labeled 1. So in terms of $s$ we need an output that is low at $s = 0$, high at $s = 1$ and low again at $s = 2$. That is the tent $T$ we already built, minus one neuron we do not need:

$$ h_1 = \operatorname{ReLU}(x_1 + x_2), \qquad h_2 = \operatorname{ReLU}(x_1 + x_2 - 1), \qquad \text{output} = h_1 - 2h_2. $$

Pause and try

Compute $h_1$, $h_2$ and the output at each of the four XOR points.

Show answerHide answer

At $(0, 0)$: $h_1 = 0$, $h_2 = \operatorname{ReLU}(-1) = 0$, output 0. At $(1, 0)$ and at $(0, 1)$: $h_1 = 1$, $h_2 = \operatorname{ReLU}(0) = 0$, output 1. At $(1, 1)$: $h_1 = 2$, $h_2 = 1$, output $2 - 2 = 0$. The output equals the XOR label at every point.

The network that computes XOR. Each hidden neuron adds the two inputs, with weights 1, adds its bias, and applies ReLU. The output is h_1 - 2h_2. The table lists what flows through the network for each of the four inputs.
The network that computes XOR. Each hidden neuron adds the two inputs, with weights 1, adds its bias, and applies ReLU. The output is $h_1 - 2h_2$. The table lists what flows through the network for each of the four inputs.
Monocurl source
let IN = [[-3.1, 0.95, 0], [-3.1, -0.75, 0]]
let HD = [[-1.0, 0.95, 0], [-1.0, -0.75, 0]]
let OUT = [0.9, 0.1, 0]
let Node = |pos, tex, col| [Disc(pos, 0.4, col, 0.16, 3.5), center{pos} Math(tex, 0.9, INK)]
let edge = |a, b| Tip(a + 0.42 * normalize(b - a), b - 0.46 * normalize(b - a), SLATE, 2.5, 0.14)
mesh edges = [
    map(IN, |a| map(HD, |b| edge(a, b))),
    map(HD, |a| edge(a, OUT))
]
mesh nodes = [
    Node(IN[0], "x_1", SLATE), Node(IN[1], "x_2", SLATE),
    Node(HD[0], "h_1", AZURE), Node(HD[1], "h_2", AZURE),
    Node(OUT, "y", EMBER)
]
mesh weights = [
    center{[-2.05, 1.2, 0]} Math("1", 0.78, INK),
    center{[-2.05, -1.0, 0]} Math("1", 0.78, INK),
    center{[-2.4, 0.67, 0]} Math("1", 0.78, INK),
    center{[-2.4, -0.47, 0]} Math("1", 0.78, INK),
    center{[0.1, 0.82, 0]} Math("1", 0.78, INK),
    center{[0.1, -0.62, 0]} Math("-2", 0.78, INK),
    center{[-1.0, 1.62, 0]} Math("\text{bias } 0", 0.74, AZURE),
    center{[-1.0, -1.42, 0]} Math("\text{bias } -1", 0.74, AZURE)
]
mesh heads = [
    center{[-3.1, 1.95, 0]} Note("inputs", 0.74),
    center{[-1.0, 2.0, 0]} Note("hidden (ReLU)", 0.74),
    center{[0.9, 0.85, 0]} Note("output", 0.74)
]
mesh rows = [
    center{[2.7, 1.3, 0]} Math("x_1\ \ x_2\ \ \ h_1\ \ h_2\ \ \ y", 0.74, SLATE),
    map(range(0, 4), |i| center{[2.7, 0.75 - 0.5 * i, 0]} Math(str_join([
        to_string(XOR[i][0]), "\ \ \ ", to_string(XOR[i][1]), "\ \ \ \ \ ",
        to_string(hid(XOR[i])[0]), "\ \ \ ", to_string(hid(XOR[i])[1]), "\ \ \ \ \ ",
        to_string(xor_out(XOR[i]))
    ]), 0.8, [AZURE, EMBER][XOR[i][2]]))
]

Uses the shared definitions for this lesson's figures.

To turn the output into a yes or no, say yes when it exceeds $\frac12$. The output equals $T(x_1 + x_2)$ wherever $x_1 + x_2 \le 2$ and is negative beyond that, where the tent’s third neuron is missing, so that happens when $\frac12 < x_1 + x_2 < \frac32$: in a diagonal band, bounded by two parallel lines. No single line could produce this region. Two creases can.

The output of the XOR network over the plane of inputs, orange where it exceeds \frac12 and blue where it does not. The output is a ridge of height 1 along the dashed line x_1 + x_2 = 1. The "yes" region is the band between the two solid lines, which contains both points labeled 1 and neither point labeled 0.
The output of the XOR network over the plane of inputs, orange where it exceeds $\frac12$ and blue where it does not. The output is a ridge of height 1 along the dashed line $x_1 + x_2 = 1$. The “yes” region is the band between the two solid lines, which contains both points labeled 1 and neither point labeled 0.
Monocurl source
let fo = Frame(-0.6, 1.6, -0.6, 1.6, [-1.3, 0.2, 0], 3.1, 3.1)
let oshade = |v| [lerp(tint(AZURE, 0.5), PAPER, clamp(2 * v, 0, 1)), lerp(PAPER, tint(EMBER, 0.55), clamp(2 * v - 1, 0, 1))][v > 0.5]
mesh field = stroke{alpha{0} PAPER, 0} point_map{|p| at(fo, p[0], p[1])}
    ColorGrid(|p, idx| oshade(xor_out(p)), [-0.6, 1.6, 67], [-0.6, 1.6, 67], 1)
mesh lines = [
    Seg(at(fo, -0.6, 1.1), at(fo, 1.1, -0.6), INK, 3.5),
    Seg(at(fo, -0.1, 1.6), at(fo, 1.6, -0.1), INK, 3.5),
    stroke{SLATE, 2.5} dashed{[0.1, 0.07]} Line(at(fo, -0.6, 1.6), at(fo, 1.6, -0.6))
]
mesh frame = [
    Outline([at(fo, -0.6, -0.6), at(fo, 1.6, -0.6), at(fo, 1.6, 1.6), at(fo, -0.6, 1.6)], SLATE, 2.5),
    map([0, 1], |v| [
        center{[fx(fo, v), fy(fo, -0.6) - 0.3, 0]} Math(to_string(v), 0.72, SLATE),
        center{[fx(fo, -0.6) - 0.3, fy(fo, v), 0]} Math(to_string(v), 0.72, SLATE)
    ]),
    center{[fx(fo, 0.5), fy(fo, -0.6) - 0.32, 0]} Math("x_1", 0.82, SLATE),
    center{[fx(fo, -0.6) - 0.32, fy(fo, 0.5), 0]} Math("x_2", 0.82, SLATE)
]
mesh pts = Dots(fo, XOR, 0.11)
mesh side = [
    center{[2.4, 1.45, 0]} Note("network output", 0.74),
    map(range(0, 4), |i| center{[2.4, 0.9 - 0.52 * i, 0]} Math(str_join(["(", to_string(XOR[i][0]), ",\ ", to_string(XOR[i][1]), ")\ \to\ ", to_string(xor_out(XOR[i]))]), 0.88, [AZURE, EMBER][XOR[i][2]])),
    center{[2.4, -1.35, 0]} Cap("yes if output > 0.5", 0.72, EMBER)
]

Uses the shared definitions for this lesson's figures.

There is a second way to see why this works, and it is the one that generalizes. The output neuron is a linear function of $h_1$ and $h_2$, so in the plane of $(h_1, h_2)$ its decision boundary $h_1 - 2h_2 = \frac12$ is a straight line. The last layer is still a linear classifier. What has changed is where the points are. The hidden layer sends each input $(x_1, x_2)$ to the point $(h_1, h_2)$:

$$ (0, 0) \mapsto (0, 0), \qquad (1, 0) \mapsto (1, 0), \qquad (0, 1) \mapsto (1, 0), \qquad (1, 1) \mapsto (2, 1). $$

The two points labeled 1 land on the same spot, and the two labeled 0 land on either side of it, no longer in each other’s way.

What the hidden layer does to the plane. Every input $(x_1, x_2)$ slides to its hidden values $(h_1, h_2)$, listed on the right for the four XOR points. The plane is flattened onto a single path and that path is creased at the image of the line $x_1 + x_2 = 1$. After the move, the green line $h_1 - 2h_2 = \frac12$ separates the points labeled 1 from the points labeled 0.
Monocurl source
let fv = Frame(-0.5, 2.5, -0.7, 1.7, [-1.2, 0.05, 0], 3.9, 3.12)
let Row = |i| center{[2.4, 0.65 - 0.5 * i, 0]} Math(str_join([
    "(", to_string(XOR[i][0]), ",\ ", to_string(XOR[i][1]), ")\ \mapsto\ (",
    to_string(hid(XOR[i])[0]), ",\ ", to_string(hid(XOR[i])[1]), ")"
]), 0.85, [AZURE, EMBER][XOR[i][2]])
let move = |p, u| [p[0] + u * (hid(p)[0] - p[0]), p[1] + u * (hid(p)[1] - p[1])]
let GRID = [-0.2, 0, 0.2, 0.4, 0.6, 0.8, 1, 1.2]
let Sheet = |u| [
    map(GRID, |c| Clipped(fv, map(sample(-0.2, 1.2, 15), |t| move([c, t], u)), MIST, 2)),
    map(GRID, |c| Clipped(fv, map(sample(-0.2, 1.2, 15), |t| move([t, c], u)), MIST, 2)),
    Clipped(fv, map(sample(-0.2, 1.2, 15), |t| move([t, 1 - t], u)), SLATE, 2.5),
    map(XOR, |e| [
        Ring([fx(fv, move(e, u)[0]), fy(fv, move(e, u)[1]) + 0.1 * u * (e[1] - e[0]) * e[2], 0], AZURE, 0.11, 3.5),
        Pt([fx(fv, move(e, u)[0]), fy(fv, move(e, u)[1]) + 0.1 * u * (e[1] - e[0]) * e[2], 0], EMBER, 0.11)
    ][e[2]])
]
mesh frame = [
    Outline([at(fv, -0.5, -0.7), at(fv, 2.5, -0.7), at(fv, 2.5, 1.7), at(fv, -0.5, 1.7)], SLATE, 2.5),
    map([0, 1, 2], |v| center{[fx(fv, v), fy(fv, -0.7) - 0.3, 0]} Math(to_string(v), 0.72, SLATE)),
    map([0, 1], |v| center{[fx(fv, -0.5) - 0.3, fy(fv, v), 0]} Math(to_string(v), 0.72, SLATE))
]
mesh sheet = Sheet(u: 0)
mesh table = [
    center{[2.4, 1.25, 0]} Math("(x_1,\ x_2)\ \mapsto\ (h_1,\ h_2)", 0.8, SLATE),
    map(range(0, 4), Row)
]
mesh names = [
    center{[fx(fv, 2.3), fy(fv, -0.7) - 0.3, 0]} Math("x_1", 0.82, SLATE),
    center{[fx(fv, -0.5) - 0.3, fy(fv, 1.5), 0]} Math("x_2", 0.82, SLATE),
    center{[fx(fv, 1), fy(fv, 1.7) + 0.3, 0]} Cap("plane of inputs", 0.8, INK)
]
mesh cut = []

slide "fold"
    play Wait(1)
    sheet.u = 0.985
    play Lerp(4, [&sheet])
    names = []
    play Fade(0.3, [&names])
    names = [
        center{[fx(fv, 2.3), fy(fv, -0.7) - 0.3, 0]} Math("h_1", 0.82, SLATE),
        center{[fx(fv, -0.5) - 0.3, fy(fv, 1.5), 0]} Math("h_2", 0.82, SLATE),
        center{[fx(fv, 1), fy(fv, 1.7) + 0.3, 0]} Cap("plane of hidden values", 0.8, INK),
        center{[2.4, -1.45, 0]} Math("h_1 - 2h_2 = \frac{1}{2}", 0.9, LEAF)
    ]
    cut = Seg(at(fv, 0.5, 0), at(fv, 2.5, 1), LEAF, 4)
    play Fade(0.7, [&names, &cut])
    play Wait(2)

Uses the shared definitions for this lesson's figures.

This is the working picture of a neural network. The hidden layer re-draws the input space, folding it along the creases of its neurons, and the final layer draws one straight line through the result. In the previous lesson we rescued XOR by inventing a feature by hand; here the features $h_1$ and $h_2$ are computed by neurons, and their weights are parameters like any others. Training a network means choosing the fold and the line together.

Worked examples

Stepping back

The difficulty at the start was that combining linear functions produces nothing new. The whole subject turns on the smallest possible departure from linearity, a function that is linear on each side of one point. A neuron then contributes a kink, a sum of neurons contributes a broken line with weights that can be written down, and broken lines can be made to follow any continuous curve. In two dimensions the kinks become creases, and a layer of creases can rearrange points so that a line separates what no line could separate before.

We chose every weight here by hand, which was possible because the examples were tiny. A network with thousands of weights has to find them by gradient descent, and for that it needs the derivative of the loss with respect to each one. The next lesson, on backpropagation, shows how to get all of them at once.

Check yourself

The function $f(x) = \operatorname{ReLU}(x - 1) - \operatorname{ReLU}(x - 3)$ is a network with two hidden neurons. What is $f(5)$, and what does the graph look like?

Problems

The first three repeat the lesson’s computations, the next two combine them, and the last two extend them.

Problem 1

For the neuron $h(x) = \operatorname{ReLU}(2x - 3)$, find $h(0)$, $h(1)$, $h(2)$, $h(3)$, the location of the kink, and the slope of the ramp.

Show solutionHide solution

The scores are $-3, -1, 1, 3$, so the outputs are $0, 0, 1, 3$. The kink is where $2x - 3 = 0$, at $x = 1.5$, and the ramp has slope 2, the weight.

Problem 2

Evaluate the XOR network of the lesson at $(0.5, 0.5)$, $(0.25, 0.25)$ and $(2, 2)$. Which of these would it label “yes” with the threshold $\frac12$?

Show solutionHide solution

At $(0.5, 0.5)$: $s = 1$, $h_1 = 1$, $h_2 = 0$, output 1: yes. At $(0.25, 0.25)$: $s = 0.5$, $h_1 = 0.5$, $h_2 = 0$, output $0.5$, which does not exceed $\frac12$: no (it is exactly on the boundary). At $(2, 2)$: $s = 4$, $h_1 = 4$, $h_2 = 3$, output $4 - 6 = -2$: no. Beyond $s = 2$ the output keeps falling, because the third neuron of the tent was left out.

Problem 3

Find a ReLU network equal to the broken line through $(0, 2), (2, 0), (3, 0), (5, 4)$ on $[0, 5]$, and check it at $x = 5$.

Show solutionHide solution

The slopes are $-1$, $0$ and $2$. The output bias is $g(0) = 2$. The slope changes are $-1$ at $x = 0$, $+1$ at $x = 2$ and $+2$ at $x = 3$. So $g(x) = 2 - \operatorname{ReLU}(x) + \operatorname{ReLU}(x - 2) + 2\operatorname{ReLU}(x - 3)$. Check: $g(5) = 2 - 5 + 3 + 4 = 4$. Also $g(2) = 2 - 2 = 0$ and $g(3) = 2 - 3 + 1 = 0$.

Problem 4

Show that (a) $x = \operatorname{ReLU}(x) - \operatorname{ReLU}(-x)$, (b) $|x| = \operatorname{ReLU}(x) + \operatorname{ReLU}(-x)$, and (c) $\max(a, b) = a + \operatorname{ReLU}(b - a)$, for all real numbers. What does (a) say about what a ReLU layer can pass along?

Show solutionHide solution

If $x \ge 0$ then $\operatorname{ReLU}(x) = x$ and $\operatorname{ReLU}(-x) = 0$; if $x < 0$ then $\operatorname{ReLU}(x) = 0$ and $\operatorname{ReLU}(-x) = -x$. (a) The difference is $x - 0 = x$ in the first case and $0 - (-x) = x$ in the second. (b) The sum is $x$ in the first case and $-x$ in the second, which is $|x|$ in both. (c) If $b \ge a$ then $a + (b - a) = b = \max(a, b)$; if $b < a$ then $a + 0 = a = \max(a, b)$. Part (a) says that two ReLU neurons can carry a number through a layer unchanged, so a layer loses no information unless its weights are chosen to discard it.

Problem 5

A one-hidden-layer network with $N$ ReLU neurons and one input is used on the whole real line. (a) Explain why its graph has at most $N$ kinks. (b) What does the graph look like far to the left and far to the right? (c) Deduce that no such network equals $\sin x$ on the whole line, and say why this does not contradict the universal approximation theorem.

Show solutionHide solution

(a) Each neuron $c_k\operatorname{ReLU}(w_kx + b_k)$ is linear on each side of its kink at $x = -b_k/w_k$ (and is constant if $w_k = 0$). A sum of such functions is linear on any interval containing no kink, so its slope can change only at the at most $N$ kinks. (b) To the left of the leftmost kink and to the right of the rightmost one, the network is a single linear function: a straight ray on each side. (c) $\sin x$ oscillates forever, and a straight ray does not: a ray is either constant or unbounded, while $\sin x$ is bounded and not constant on any ray. So the two functions differ somewhere, for any $N$. The theorem concerns approximation on a closed bounded set $K$, with $N$ allowed to depend on $K$ and on the accuracy. It makes no claim about the whole line.

Problem 6

(A different solution to XOR.) Let $h_1 = \operatorname{ReLU}(x_1 - x_2)$, $h_2 = \operatorname{ReLU}(x_2 - x_1)$ and $\text{output} = h_1 + h_2$. (a) Show that this network also computes XOR on the four points. (b) Describe its “yes” region for the threshold $\frac12$. (c) The two networks agree on all four training points. Do they agree at $(0.5, 0.5)$? What does that say about what the data determine?

Show solutionHide solution

(a) By part (b) of problem 4, $h_1 + h_2 = |x_1 - x_2|$. At $(0, 0)$ and $(1, 1)$ this is 0; at $(1, 0)$ and $(0, 1)$ it is 1. (b) The output exceeds $\frac12$ when $|x_1 - x_2| > \frac12$: the two regions on either side of the band around the diagonal $x_1 = x_2$, that is, $x_2 < x_1 - \frac12$ or $x_2 > x_1 + \frac12$. (c) At $(0.5, 0.5)$ this network outputs $0$ (no), while the lesson’s network outputs $1$ (yes). Both fit the four data points perfectly and they disagree in between. The data fix the function only at the data; what a network does elsewhere depends on which of the many fitting networks training happens to find. This is the question of generalization, which the universal approximation theorem does not address and the lesson on overfitting takes up.

Problem 7

(Kinks and creases.) (a) Using the kink-counting argument of the lesson, show that the tent $T$ cannot be written with fewer than three ReLU neurons, even if an output bias is allowed. (b) In the XOR network both hidden neurons use the same weights $(1, 1)$, so their creases are parallel. Give the equations of the two creases and say which of the four XOR points lie on a crease.

Show solutionHide solution

(a) $T$ has slope $0$ for $x < 0$, $1$ on $(0, 1)$, $-1$ on $(1, 2)$ and $0$ for $x > 2$, so its slope changes at three places: $0$, $1$ and $2$. A network $c_0 + \sum c_k\operatorname{ReLU}(w_kx + b_k)$ can change slope only at the kinks of its neurons, and each neuron has at most one kink. The bias $c_0$ shifts the graph up or down and creates no kink. So at least three neurons are needed. (b) The crease of $h_1$ is where its score is zero: $x_1 + x_2 = 0$, which passes through $(0, 0)$. The crease of $h_2$ is $x_1 + x_2 = 1$, which passes through $(1, 0)$ and $(0, 1)$. The point $(1, 1)$ lies beyond both creases, where both neurons are on.

Shared definitions used by this lesson's figures
# house kit: prepended to every essay figure after the std imports

# palette. AZURE and EMBER are the two actors, LEAF marks an answer, ROSE a problem
let PAPER = [1, 1, 1, 1]
let INK = [0.08, 0.09, 0.11, 1]
let SLATE = [0.40, 0.44, 0.50, 1]
let MIST = [0.85, 0.86, 0.88, 1]
let AZURE = [0.13, 0.42, 0.68, 1]
let EMBER = [0.89, 0.42, 0.12, 1]
let LEAF = [0.13, 0.55, 0.36, 1]
let ROSE = [0.78, 0.22, 0.24, 1]
let PLUM = [0.47, 0.30, 0.66, 1]
let GOLD = [0.85, 0.62, 0.08, 1]

background = PAPER

# an opaque pastel of col; use for fills so overlaps do not darken
let tint = |col, amount = 0.18| [
    1 - (1 - col[0]) * amount,
    1 - (1 - col[1]) * amount,
    1 - (1 - col[2]) * amount,
    1
]

# text: Cap for labels, Note for quiet labels, Math for typeset maths
let Cap = |txt, size = 0.75, col = INK| color{col} Text(txt, size, "Charter")
let Note = |txt, size = 0.65| color{SLATE} Text(txt, size, "Charter")
let Math = |tex, size = 0.9, col = INK| color{col} Tex(tex, size)

# a paper rectangle to sit behind text that crosses lines
let Plate = |pos, size| center{pos} fill{PAPER} stroke{alpha{0} PAPER, 0} Rect(size)

# shapes with fill and stroke both set, so no stray black appears
let Pt = |pos, col = INK, r = 0.075| center{pos} fill{col} stroke{col, 1} Circle(r, 48)
let Ring = |pos, col = INK, r = 0.1, w = 3| center{pos} fill{PAPER} stroke{col, w} Circle(r, 48)
let Seg = |a, b, col = INK, w = 3| stroke{col, w} Line(a, b)
let Path = |pts, col = INK, w = 3| stroke{col, w} Polyline(pts)
let Region = |pts, col| fill{col} stroke{alpha{0} col, 0} Polygon(pts)
let Outline = |pts, col = INK, w = 3| fill{alpha{0} PAPER} stroke{col, w} Polygon(pts)
let Shape = |pts, col, amount = 0.18, w = 3| fill{tint(col, amount)} stroke{col, w} Polygon(pts)
let Disc = |pos, r, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Circle(r, 96)
let Box = |pos, size, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Rect(size)
# a line from a to b ending in a small solid arrowhead
let Tip = |a, b, col = INK, w = 3, head = 0.17| {
    let d = normalize([b[0] - a[0], b[1] - a[1], 0])
    let n = [-d[1], d[0], 0]
    let base = [b[0] - d[0] * head, b[1] - d[1] * head, 0]
    return [
        stroke{col, w} Line(a, base),
        fill{col} stroke{col, 1} Polygon([
            b,
            [base[0] + n[0] * head * 0.42, base[1] + n[1] * head * 0.42, 0],
            [base[0] - n[0] * head * 0.42, base[1] - n[1] * head * 0.42, 0]
        ])
    ]
}

# plots. a frame is [x0, x1, y0, y1, centre, width, height]: a data window and the scene box it fills
let Frame = |x0, x1, y0, y1, c = [0, 0, 0], w = 6.4, h = 3.6| [x0, x1, y0, y1, c, w, h]
let fx = |fr, x| fr[4][0] + (x - (fr[0] + fr[1]) / 2) * fr[5] / (fr[1] - fr[0])
let fy = |fr, y| fr[4][1] + (y - (fr[2] + fr[3]) / 2) * fr[6] / (fr[3] - fr[2])
let at = |fr, x, y| [fx(fr, x), fy(fr, y), 0]

# axes through the data origin (or the nearest edge of the window), with optional names
let Axes = |fr, x_name = "", y_name = "", col = SLATE| {
    let ox = clamp(fr[0], 0, fr[1])
    let oy = clamp(fr[2], 0, fr[3])
    var parts = [
        Tip(at(fr, fr[0], oy), [fx(fr, fr[1]) + 0.25, fy(fr, oy), 0], col, 2.5, 0.15),
        Tip(at(fr, ox, fr[2]), [fx(fr, ox), fy(fr, fr[3]) + 0.25, 0], col, 2.5, 0.15)
    ]
    if (x_name != "") {
        parts .= center{[fx(fr, fr[1]) + 0.5, fy(fr, oy), 0]} Math(x_name, 0.8, col)
    }
    if (y_name != "") {
        parts .= center{[fx(fr, ox), fy(fr, fr[3]) + 0.5, 0]} Math(y_name, 0.8, col)
    }
    return parts
}

# tick marks with numbers at the listed data values
let XTicks = |fr, xs, col = SLATE, places = 0| block {
    let oy = fy(fr, clamp(fr[2], 0, fr[3]))
    for (x in xs) {
        . Seg([fx(fr, x), oy - 0.07, 0], [fx(fr, x), oy + 0.07, 0], col, 2)
        . center{[fx(fr, x), oy - 0.33, 0]} Math(to_string(x, places), 0.72, col)
    }
}
let YTicks = |fr, ys, col = SLATE, places = 0| block {
    let ox = fx(fr, clamp(fr[0], 0, fr[1]))
    for (y in ys) {
        . Seg([ox - 0.07, fy(fr, y), 0], [ox + 0.07, fy(fr, y), 0], col, 2)
        . center{[ox - 0.36, fy(fr, y), 0]} Math(to_string(y, places), 0.72, col)
    }
}

# faint grid lines at the listed data values
let GridLines = |fr, xs, ys, col = MIST| block {
    for (x in xs) {
        . Seg(at(fr, x, fr[2]), at(fr, x, fr[3]), col, 1.5)
    }
    for (y in ys) {
        . Seg(at(fr, fr[0], y), at(fr, fr[1], y), col, 1.5)
    }
}

# the graph of f over [a, b], drawn in the frame. values are clamped to the window
let Graph = |fr, f, a, b, col = AZURE, w = 4, n = 160|
    stroke{col, w} Polyline(map(sample(a, b, n), |x| at(fr, x, clamp(fr[2], f(x), fr[3]))))

# the region between the graph of f and the x axis over [a, b]
let AreaUnder = |fr, f, a, b, col = AZURE, amount = 0.2, n = 80| {
    var pts = [at(fr, a, 0)]
    for (x in sample(a, b, n)) {
        pts .= at(fr, x, clamp(fr[2], f(x), fr[3]))
    }
    pts .= at(fr, b, 0)
    return fill{tint(col, amount)} stroke{alpha{0} col, 0} Polygon(pts)
}

# draw the parts of a list of data points that fall inside the frame
let Clipped = |fr, pts, col, th| block {
    var cur = []
    for (p in pts) {
        if (p[0] >= fr[0] and p[0] <= fr[1] and p[1] >= fr[2] and p[1] <= fr[3]) {
            cur .= at(fr, p[0], p[1])
        } else {
            if (len(cur) >= 2) { . Path(cur, col, th) }
            cur = []
        }
    }
    if (len(cur) >= 2) { . Path(cur, col, th) }
}
let Curve = |fr, f, col = AZURE, th = 4, n = 240| Clipped(fr, map(sample(fr[0], fr[1], n), |x| [x, f(x)]), col, th)
let PlotFrame = |fr, xs, ys, xname, yname, ydx = 0.3| [
    GridLines(fr, xs, ys),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[1], fr[2]), SLATE, 2.5),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[0], fr[3]), SLATE, 2.5),
    map(xs, |v| center{[fx(fr, v), fy(fr, fr[2]) - 0.3, 0]} Math(to_string(v), 0.72, SLATE)),
    map(ys, |v| center{[fx(fr, fr[0]) - 0.34, fy(fr, v), 0]} Math(to_string(v), 0.72, SLATE)),
    center{[fx(fr, fr[1]) + 0.3, fy(fr, fr[2]), 0]} Math(xname, 0.82, SLATE),
    center{[fx(fr, fr[0]) + ydx, fy(fr, fr[3]) + 0.28, 0]} Note(yname, 0.74)
]
# a one-hidden-layer network of ReLU neurons with kinks at ts and output weights cs
let net = |cs, ts, x| sum(map(range(0, len(ts)), |k| cs[k] * relu(x - ts[k])))
# the weights that make the network pass through (ts[k], ys[k]) and the final point (t_end, y_end)
let fit_weights = |ts, ys| {
    var out = []
    var prev = 0
    for (k in range(0, len(ts) - 1)) {
        let m = (ys[k + 1] - ys[k]) / (ts[k + 1] - ts[k])
        let c = m - prev
        out .= c
        prev = m
    }
    return out
}
# n equal pieces for sin on [0, 2 pi]: knots and weights
let sin_knots = |n| map(range(0, n + 1), |k| TAU * k / n)
let sin_net = |n| {
    let ts = sin_knots(n)
    return [fit_weights(ts, map(ts, sin)), take(ts, n)]
}
let XOR = [[0, 0, 0], [1, 0, 1], [0, 1, 1], [1, 1, 0]]
let hid = |p| [relu(p[0] + p[1]), relu(p[0] + p[1] - 1)]
let xor_out = |p| hid(p)[0] - 2 * hid(p)[1]
let Dots = |fr, pts, r = 0.1| map(pts, |e| [Ring(at(fr, e[0], e[1]), AZURE, r, 3.5), Pt(at(fr, e[0], e[1]), EMBER, r)][e[2]])