Choosing the Squash
The function between layers
The controller has twenty-four inputs, twelve hidden sums and six output sums. Without a function between the rows, the hidden row costs 228 extra numbers and buys the same answers one row could have produced.
One function of one number goes between the hidden row and the output row, and it is judged on two facts: whether it bends, and where its floor and ceiling sit. A straight middle collapses. A bounded middle changes how the row above must be priced.
The far end has a different fault. A step decision can say 1 or 0, so a creature turning on that value asks for the largest turn every time it asks for any turn. The mannerisms work made turning take time; a hard yes throws that away.
Four candidates go through the same test: step, sigmoid, hyperbolic tangent and rectifier. The plots come from their own arithmetic, the two-row gate proves why a bend matters, and the controller keeps tanh in the hidden row while leaving the six output scores bare.
Four candidates, eleven sums
Take one hidden neuron and two ticks before taking anything else. A sensor row holds twenty-four numbers; pick two of them and a neuron that reads them with weights of 1.50 and −1.00 against a bias of −0.20. On a tick when the first sense reads 0.80 and the second −0.50, the sum is 0.80 × 1.50 = 1.20, plus −0.50 × −1.00 = 0.50, less the bias of 0.20, which comes to 1.50. On a tick when the same two senses read −0.40 and 0.60, the sum is −0.60 − 0.60 − 0.20 = −1.40. Two ticks, and 2.90 between them.
That gap is the thing the layer above needs to know about, and each candidate passes a
different amount of it upward. The step reports 1 on the first tick and 0 on the second,
so all 2.90 of the difference arrives as 1.00 and the same 1.00 would have arrived if the
sums had been 0.01 and −0.01. The rectifier reports 1.50 and 0.00: the whole of the
first tick's sum survives intact and the whole of the second is erased, which is 1.50 of
the 2.90. The two built on e need more than a calculator at this stage, and
the program has them.
Each of the four takes a number and returns a number, so all four fit the same one-line signature and none of them needs to know anything about layers, weights or creatures. That signature gets a name, because a network is about to hold one as a field.
// internal/mind/act.go
package mind
import "math"
// Act is what goes between one row of neurons and the next: one number
// in, one number out, run over every middle total before the row above
// multiplies it. Step already fits this signature. The three below are
// the ones that can answer with something other than yes or no.
type Act func(float64) float64
// Bare hands the total back untouched. It is the pass as it ran before
// this file existed, named so that a table can print it beside the
// others and so the collapse can be run instead of asserted.
func Bare(z float64) float64 { return z }
// Sigmoid squeezes the whole number line into the range 0 to 1,
// crossing 0.5 at zero and never quite reaching either end.
func Sigmoid(z float64) float64 { return 1 / (1 + math.Exp(-z)) }
// Tanh is that same curve stretched to run from -1 to 1 and centred on
// zero, so a neuron with nothing to say says nothing, and one that
// disagrees can push the other way instead of only going quiet.
func Tanh(z float64) float64 { return math.Tanh(z) }
// ReLU passes a positive total through unchanged and flattens
// everything below zero. It has a floor and no ceiling.
func ReLU(z float64) float64 {
if z < 0 {
return 0
}
return z
}
// cmd/squash/main.go
// squash is one candidate, named for the tables.
type squash struct {
Name string
F mind.Act
}
// The five settings every table below runs. Bare is first because it is
// what the forward pass already had.
var squashes = []squash{
{"bare", mind.Bare},
{"step", mind.Step},
{"sigmoid", mind.Sigmoid},
{"tanh", mind.Tanh},
{"relu", mind.ReLU},
}
// four is the four candidates without the do-nothing pass.
var four = squashes[1:]
// head prints one row of column names over the four candidates.
func head(first string) {
fmt.Printf(" %9s", first)
for _, s := range four {
fmt.Printf(" %11s", s.Name)
}
fmt.Println()
}
// hand is the two ticks the chapter works with a calculator before any
// of this becomes a table: one hidden neuron with two weights and a
// bias, on a tick with a plant ahead of the creature and a tick with
// the plant behind it.
func hand() {
w := [2]float64{1.5, -1.0}
bias := -0.20
ticks := [2][2]float64{{0.80, -0.50}, {-0.40, 0.60}}
fmt.Printf("squash: one hidden neuron, weights %.2f and %.2f, bias %.2f\n", w[0], w[1], bias)
fmt.Printf(" %8s %8s %9s", "sense a", "sense b", "sum")
for _, s := range four {
fmt.Printf(" %11s", s.Name)
}
fmt.Println()
var got [2][]float64
var sum [2]float64
for t, in := range ticks {
sum[t] = in[0]*w[0] + in[1]*w[1] + bias
fmt.Printf(" %8.2f %8.2f %9.4f", in[0], in[1], sum[t])
for _, s := range four {
v := s.F(sum[t])
got[t] = append(got[t], v)
fmt.Printf(" %11.6f", v)
}
fmt.Println()
}
fmt.Printf("\n the two sums differ by %.4f; what each one passes upward:\n", sum[0]-sum[1])
for i, s := range four {
fmt.Printf(" %9s %11.6f\n", s.Name, got[0][i]-got[1][i])
}
}
$ go run ./cmd/squash -mode hand
squash: one hidden neuron, weights 1.50 and -1.00, bias -0.20
sense a sense b sum step sigmoid tanh relu
0.80 -0.50 1.5000 1.000000 0.817574 0.905148 1.500000
-0.40 0.60 -1.4000 0.000000 0.197816 -0.885352 0.000000
the two sums differ by 2.9000; what each one passes upward:
step 1.000000
sigmoid 0.619758
tanh 1.790500
relu 1.500000
The two hand sums come back as 1.5000 and −1.4000, and the last block is the part to stare at. Of the 2.9000 that separated the two ticks, the step passes 1.000000, the rectifier 1.500000, the sigmoid 0.619758 and tanh 1.790500. Tanh passes the most because it uses both sides of zero, and the sigmoid passes the least because it spends its whole range between 0 and 1 and both ticks land well inside it. None of the four passes the difference on unchanged, and none of them is meant to: a function that did would be the straight middle the last section ruled out. Widen the sums past a couple of units and the picture changes again.
One mode walks eleven sums, then two far enough out that the bending is over, and finishes by handing each function an infinity, which is the cheapest way there is to ask one where it is allowed to go.
// cmd/squash/main.go
// curve prints what each candidate answers at eleven sums, then at two
// sums far enough out that three of the four have stopped moving.
func curve() {
fmt.Println("squash: one number in, one number out")
head("sum")
for _, x := range []float64{-4, -2, -1, -0.5, -0.1, 0, 0.1, 0.5, 1, 2, 4} {
fmt.Printf(" %9.2f", x)
for _, s := range four {
fmt.Printf(" %11.6f", s.F(x))
}
fmt.Println()
}
fmt.Println()
head("far out")
for _, x := range []float64{-20, 20} {
fmt.Printf(" %9.2f", x)
for _, s := range four {
fmt.Printf(" %11.6f", s.F(x))
}
fmt.Println()
}
fmt.Printf(" the floor and the ceiling each one is walled in by:\n")
for _, s := range four {
lo, hi := s.F(math.Inf(-1)), s.F(math.Inf(1))
fmt.Printf(" %9s %11.4f %11.4f\n", s.Name, lo, hi)
}
}
$ go run ./cmd/squash -mode curve
squash: one number in, one number out
sum step sigmoid tanh relu
-4.00 0.000000 0.017986 -0.999329 0.000000
-2.00 0.000000 0.119203 -0.964028 0.000000
-1.00 0.000000 0.268941 -0.761594 0.000000
-0.50 0.000000 0.377541 -0.462117 0.000000
-0.10 0.000000 0.475021 -0.099668 0.000000
0.00 0.000000 0.500000 0.000000 0.000000
0.10 1.000000 0.524979 0.099668 0.100000
0.50 1.000000 0.622459 0.462117 0.500000
1.00 1.000000 0.731059 0.761594 1.000000
2.00 1.000000 0.880797 0.964028 2.000000
4.00 1.000000 0.982014 0.999329 4.000000
far out step sigmoid tanh relu
-20.00 0.000000 0.000000 -1.000000 0.000000
20.00 1.000000 1.000000 1.000000 20.000000
the floor and the ceiling each one is walled in by:
step 0.0000 1.0000
sigmoid 0.0000 1.0000
tanh -1.0000 1.0000
relu 0.0000 +Inf
Read down the columns and the differences are already plain. The step column holds two values and no others, and it takes the jump between them just above zero, so the 0.00 row reads 0.000000 and the 0.10 row reads 1.000000. The sigmoid column moves on every row and never leaves the range 0 to 1, but it is centred on 0.5, so a neuron with a sum of zero still hands 0.5 up to the layer above. Tanh moves on every row too and answers 0.000000 at a sum of zero. The rectifier is two straight pieces hinged at the origin, and its column is the only one whose bottom row and top row differ by more than 1.
The last block is the important one. Handing each function an infinity asks it where it
can go, and three of the four answer with a pair of ordinary numbers. The rectifier
answers +Inf. Every other line in this chapter follows from that table.
Everything above was arithmetic on eleven sums. Here is the same arithmetic written the way it appears everywhere else, which is useful only because you will meet these four written this way for the rest of your life. Call the number arriving from the weighted sum x.
step(x) = 1 if x > 0, else 0
Zero is not above zero, so step(0) is 0. That is the 0.00 row of the table above, and the 0.00 bearing of the table further down.
One new symbol does the rest of the work. e is a fixed number,
2.718281828..., and e−x means that number multiplied by
itself −x times, which for a negative exponent means divided instead. At x = 0 it
is 1, because anything to the power 0 is 1. At x = 1 it is 1 ÷ 2.71828 = 0.36788.
At x = −1 it is 2.71828 itself. It falls toward 0 as x grows and climbs without
limit as x goes negative, and in Go it is math.Exp(-x).
σ(x) = 1 ÷ (1 + e−x)
Check it at zero: 1 ÷ (1 + 1) = 0.5, the middle row of the table. Check it at 2: e−2 = 0.13534, and 1 ÷ 1.13534 = 0.880797, which is what the sigmoid column printed. The denominator is always larger than 1 and always finite, so the answer is always between 0 and 1 and never either.
tanh(x) = (ex − e−x) ÷ (ex +
e−x)
The top and the bottom differ by one sign. At x = 0 they are 1 − 1 = 0 over 1 + 1 = 2, so tanh(0) = 0. As x grows, e−x shrinks toward nothing and the fraction approaches ex ÷ ex = 1. Going the other way the same argument gives −1. It is the sigmoid doubled and slid down: tanh(x) = 2σ(2x) − 1, which you can check at x = 1, where 2 × 0.880797 − 1 = 0.761594.
relu(x) = max(0, x)
max(a, b) is whichever of the two is larger, and it is one
if in code. The name is short for rectified linear unit, which describes a
straight line with the negative half cut off.
math.Exp(-x)These four are called activation functions, from the days when a neuron was described as firing or not firing; the older word for the same slot is the transfer function. There are plenty more. A leaky rectifier returns 0.01x below the origin instead of 0, so a neuron that has gone negative can still be moved. Smoother relatives of the rectifier turn up in very large models, where the gentler bend matters to the training. And softmax is different in kind: it takes a whole row at once and returns numbers between 0 and 1 that add up to exactly 1, for when a row is meant to be read as a set of probabilities. Nothing in this book reads a row that way, so softmax has no work here. The four on the list are the ones whose behaviour can be predicted from their arithmetic before a network is built, and prediction is the whole exercise: no creature in this world is ever trained, so every property has to be right by construction.
Eleven rows of a table is a thin way to meet a curve. The renderer already draws straight lines between whole pixels and hashes what it drew, so the four can be plotted from the same functions the table called.
// cmd/squash/plot.go
// The picture: four square panels in a row, each one plotting a
// candidate across the same sums and against the same zero line, with
// its name under it. Every measurement is odd or even so that zero
// lands on a whole pixel instead of between two.
const (
Panel = 65 // one panel, square, with a middle column and a middle row
Half = Panel / 2
Gap = 4 // the margin round the strip and the gutter between panels
Lab = 8 // the row of glyphs under each panel
PerX = 8 // pixels across per unit of sum, so the panel runs -4 to 4
PerY = 25 // pixels down per unit of answer, so the panel runs -1.28 to 1.28
)
// The four palette entries the picture is drawn in.
const (
ground render.Color = 0xFF181A29 // the panel a curve is drawn on
rule render.Color = 0xFF323847 // the two axes
ink render.Color = 0xFFF0BE35 // the curve itself
tag render.Color = 0xFF8A979E // the name under the panel
)
// col is the pixel column a sum lands in, and row the pixel row an
// answer lands in. Both are measured from the panel's own corner.
func col(x float64) int { return Half + int(math.Round(x*PerX)) }
func row(y float64) int { return Half - int(math.Round(y*PerY)) }
// panel draws one candidate: the ground, the two axes, then a line
// from every column to the next. The clip is the panel, so a curve
// that leaves the top is cut at the edge instead of scribbling on its
// neighbour.
func panel(b *render.Buffer, ox, oy int, s squash) (int, int) {
b.SetClip(render.Rect{X0: ox, Y0: oy, X1: ox + Panel, Y1: oy + Panel})
b.FillRect(render.Rect{X0: ox, Y0: oy, X1: ox + Panel, Y1: oy + Panel}, ground)
b.Line(ox, oy+row(0), ox+Panel-1, oy+row(0), rule)
b.Line(ox+col(0), oy, ox+col(0), oy+Panel-1, rule)
inside, off := 0, 0
px, py := 0, 0
for c := range Panel {
x := float64(c-Half) / PerX
r := row(s.F(x))
if r >= 0 && r < Panel {
inside++
} else {
off++
}
if c > 0 {
b.Line(ox+px, oy+py, ox+c, oy+r, ink)
}
px, py = c, r
}
b.SetClip(b.Bounds())
return inside, off
}
$ go run ./cmd/squash -mode plot -shot assets/frames/squash-four.png
squash: four panels of 65 by 65, 280 by 83 altogether
each panel runs -4.00 to 4.00 across at 8 pixels a unit,
and -1.28 to 1.28 down at 25 pixels a unit
plotted off top its own panel
step 65 0 f610be2be6be9bff
sigmoid 65 0 815dbad914ebb81d
tanh 65 0 0feb7ea8172f9bbf
relu 43 22 8a02dee3c856f042
the strip ea19ca6ca2d15f964dd9ad3af99683c708c3c520d27e5b272bd14eb463780d90
wrote assets/frames/squash-four.png at 3 times, 840 by 249
A panel is 65 pixels square so that there is a middle column and a middle row for the two axes to sit on: 32 pixels either side of zero, 8 pixels for every unit across and 25 for every unit down. Sixty-five columns are sampled, one per pixel, and each is joined to the last with the same line routine the plants are drawn with. The count beside each name is how many of those sixty-five landed inside the panel. Three of the four scored 65. The rectifier scored 43, with 22 of its columns off the top, and it did not have to be told to stop: the clip was set to the panel, so the twenty-two columns whose answer landed above the frame were cut by the same check that keeps a sprite off its neighbour.
assets/frames/squash-four.png: the
four candidates over the same sums, from −4 on the left to 4 on the right, each on
axes crossing at the origin. Sigmoid never dips below its panel's centre line, and
tanh crosses that line where the two axes meet: the 0.500000 and 0.000000 entries of
the table's middle row, drawn. The rectifier's diagonal running off the top is the picture of
+Inf.
The amount passed upward
The forward pass has to be told which of these to use, and there is exactly one place in it where the question arises. The pass gains a field and one call.
// internal/mind/net.go — the two edits this chapter makes to the pass
type Net struct {
In int // numbers the network is handed
Hid int // neurons in the middle row
Out int // numbers it hands back
Squash Act // run over every middle total before the output row reads it
W []float64
}
// Forward runs in through both rows and writes the answer into out. hid
// is the caller's scratch for the middle row. Nothing here allocates:
// whoever owns the network makes its two buffers once and keeps them.
//
// Between the two rows sits Squash, run over every middle total in
// place. A network never handed one runs the way this pass ran before
// there was anything to hand it: the middle totals go straight on.
func (n Net) Forward(in, hid, out []float64) {
Layer(in, n.W[n.HidW():n.HidB()], n.W[n.HidB():n.OutW()], hid)
if n.Squash != nil {
for j, z := range hid {
hid[j] = n.Squash(z)
}
}
Layer(hid, n.W[n.OutW():n.OutB()], n.W[n.OutB():], out)
}
One field and one loop, and nothing else in the package moved. Layer is
untouched, so the arithmetic that was checked against a pencil is the same arithmetic.
The four offset methods are untouched, so the weight layout is the same layout and
Weights(24, 12, 6) is still 378. The second Layer call is
untouched, with nothing at all between its totals and out. And the nil
check earns its two lines: every network built before this chapter still runs, and it
still produces the numbers it produced, because a Net with no
Squash skips the loop entirely. A network handed
mind.Bare takes the loop and comes out at exactly the same numbers, which
is what lets the collapse be run beside the alternatives instead of argued about.
Now put one neuron in front of a real question. A creature's rays report where the nearest plant sits off its heading, scaled to −1 for hard left and 1 for hard right the way every entry in the sensor row is scaled. One neuron reads that number with a weight of 4 and no bias, and its answer is read as how much of a turn the creature commits to. Walk the bearing across its whole range and watch what each candidate is capable of saying.
// cmd/squash/main.go
// bearing is one neuron reading one number: where the nearest plant
// sits off the creature's heading, scaled to -1 hard left and 1 hard
// right, weighted by gain and squashed. The answer is read as how much
// of a turn the creature commits to.
func bearing(gain float64, steps int) {
fmt.Printf("squash: one neuron, weight %.1f, bias 0, reading a bearing from -1 to 1\n", gain)
head("bearing")
for i := -4; i <= 4; i++ {
b := float64(i) / 4
fmt.Printf(" %9.2f", b)
for _, s := range four {
fmt.Printf(" %11.6f", s.F(gain*b))
}
fmt.Println()
}
fmt.Printf("\n different answers across %d bearings evenly spaced from -1 to 1:\n", steps+1)
for _, s := range four {
seen := map[float64]bool{}
for i := 0; i <= steps; i++ {
seen[s.F(gain*(-1+2*float64(i)/float64(steps)))] = true
}
fmt.Printf(" %9s %6d\n", s.Name, len(seen))
}
}
$ go run ./cmd/squash -mode bearing
squash: one neuron, weight 4.0, bias 0, reading a bearing from -1 to 1
bearing step sigmoid tanh relu
-1.00 0.000000 0.017986 -0.999329 0.000000
-0.75 0.000000 0.047426 -0.995055 0.000000
-0.50 0.000000 0.119203 -0.964028 0.000000
-0.25 0.000000 0.268941 -0.761594 0.000000
0.00 0.000000 0.500000 0.000000 0.000000
0.25 1.000000 0.731059 0.761594 1.000000
0.50 1.000000 0.880797 0.964028 2.000000
0.75 1.000000 0.952574 0.995055 3.000000
1.00 1.000000 0.982014 0.999329 4.000000
different answers across 201 bearings evenly spaced from -1 to 1:
step 2
sigmoid 201
tanh 201
relu 101
Two hundred and one bearings, and the step neuron produces two answers. Every plant anywhere to the left gets the same 0.000000 and every plant anywhere to the right gets the same 1.000000, so a creature wired this way turns as hard for a plant one degree off its nose as for one at the edge of its vision, and then turns exactly as hard again on the next tick. Sigmoid and tanh give a different answer for every one of the two hundred and one. The rectifier gives 101, because it answers 0.000000 for every bearing on the left and a different number for each on the right: half a creature's world flattened into one reply.
Tanh's column is the one to look at closely. It reads 0.000000 at a bearing of zero, ±0.761594 a quarter of the way out, and ±0.999329 at the edges. A plant straight ahead buys no turn at all, a plant slightly off buys a small one, a plant far round the side buys nearly the whole turn on offer. That is a controller saying how much. Sigmoid says how much too, but it says 0.500000 for the plant straight ahead, so a creature reading that column as a turn spends half of every tick's turning on a plant it is already pointed at.
anything hold weighted sums, which can come
to any size at all, and the box between them is what puts a range back.
The two-input gate
Bending the middle was supposed to make the second row earn the 228 numbers it costs. Testing that needs a question one row of weights provably cannot answer, and the smallest one there is takes two numbers, each either 0 or 1, and asks whether exactly one of them is on. Two inputs, four cases, one answer.
Wire it by hand. One hidden neuron adds the two inputs, multiplies by a gain of 10 and subtracts 5, so its sum is above zero when at least one input is on. The other does the same with 15 subtracted, so its sum is above zero only when both are. The output neuron wants the first without the second, and it is priced twice, for a reason the run is about to make obvious.
// cmd/squash/main.go
// The question: is exactly one of these two numbers on. The four cases,
// and what the answer should be.
var (
cases = [4][2]float64{{0, 0}, {0, 1}, {1, 0}, {1, 1}}
want = [4]bool{false, true, true, false}
)
// hiddenRow is the row both weight sets share: one neuron whose sum is
// above zero when at least one input is on, one whose sum is above zero
// only when both are. The gain g is how hard both of them are driven,
// and the two biases are half of it and one and a half of it, so the
// question stays the same question at any gain.
func hiddenRow(g float64) []float64 {
return []float64{g, g, g, g, -g / 2, -1.5 * g}
}
// The output row is priced twice, because the hidden row's off state is
// not the same number in both. Under step, sigmoid and relu an idle
// neuron hands up 0 and the price of the second one is -3. Under tanh
// an idle neuron hands up -1, and -3 of that is +3.
func offZero(g float64) []float64 { return append(hiddenRow(g), 1, -3, -0.5) }
func offMinus(g float64) []float64 { return append(hiddenRow(g), 1, -1, -1) }
// gate runs the two-layer net over the four cases under one candidate
// and reports how many of the four it got right.
func gate(s squash, w []float64) int {
n := mind.Net{In: 2, Hid: 2, Out: 1, Squash: s.F, W: w}
hid, out := n.Buffers()
fmt.Printf(" %-8s %9s %9s %9s %9s %9s %s\n",
s.Name, "in x", "in y", "hidden 1", "hidden 2", "out", "says")
right := 0
for i, c := range cases {
in := []float64{c[0], c[1]}
n.Forward(in, hid, out)
yes, said, mark := out[0] > 0, "no", "wrong"
if yes {
said = "yes"
}
if yes == want[i] {
right++
mark = "right"
}
fmt.Printf(" %-8s %9.2f %9.2f %9.4f %9.4f %9.4f %-3s %s\n",
"", c[0], c[1], hid[0], hid[1], out[0], said, mark)
}
fmt.Printf(" %-8s %d of 4\n\n", "", right)
return right
}
$ go run ./cmd/squash -mode gate
squash: is exactly one of the two on? a 2-2-1 net, 9 weights
hidden weights [10 10 10 10], hidden biases [-5 -15]
output row priced for an off state of 0: weights [1 -3], bias [-0.5]
bare in x in y hidden 1 hidden 2 out says
0.00 0.00 -5.0000 -15.0000 39.5000 yes wrong
0.00 1.00 5.0000 -5.0000 19.5000 yes right
1.00 0.00 5.0000 -5.0000 19.5000 yes right
1.00 1.00 15.0000 5.0000 -0.5000 no right
3 of 4
step in x in y hidden 1 hidden 2 out says
0.00 0.00 0.0000 0.0000 -0.5000 no right
0.00 1.00 1.0000 0.0000 0.5000 yes right
1.00 0.00 1.0000 0.0000 0.5000 yes right
1.00 1.00 1.0000 1.0000 -2.5000 no right
4 of 4
sigmoid in x in y hidden 1 hidden 2 out says
0.00 0.00 0.0067 0.0000 -0.4933 no right
0.00 1.00 0.9933 0.0067 0.4732 yes right
1.00 0.00 0.9933 0.0067 0.4732 yes right
1.00 1.00 1.0000 0.9933 -2.4799 no right
4 of 4
relu in x in y hidden 1 hidden 2 out says
0.00 0.00 0.0000 0.0000 -0.5000 no right
0.00 1.00 5.0000 0.0000 4.5000 yes right
1.00 0.00 5.0000 0.0000 4.5000 yes right
1.00 1.00 15.0000 5.0000 -0.5000 no right
4 of 4
the same row under tanh, whose off state is -1 instead of 0
tanh in x in y hidden 1 hidden 2 out says
0.00 0.00 -0.9999 -1.0000 1.5001 yes wrong
0.00 1.00 0.9999 -0.9999 3.4996 yes right
1.00 0.00 0.9999 -0.9999 3.4996 yes right
1.00 1.00 1.0000 0.9999 -2.4997 no right
3 of 4
output row re-priced for an off state of -1: weights [1 -1], bias [-1]
tanh in x in y hidden 1 hidden 2 out says
0.00 0.00 -0.9999 -1.0000 -0.9999 no right
0.00 1.00 0.9999 -0.9999 0.9998 yes right
1.00 0.00 0.9999 -0.9999 0.9998 yes right
1.00 1.00 1.0000 0.9999 -0.9999 no right
4 of 4
Start with the bare block, because it fails in a way that generalizes. Its
out column reads 39.5000, 19.5000, 19.5000, −0.5000. Work the
algebra and there is no mystery: hidden 1 is 10(x+y) − 5, hidden 2 is
10(x+y) − 15, and the output is hidden 1 minus three times hidden 2 minus a half,
which comes to −20(x+y) + 39.5. The whole network is a straight line in one
quantity, x+y, and a straight line crosses zero once. It can answer "at least one" or
"both". Asking it to say yes at x+y = 1 and no on either side of that is asking a line
to bend, and no choice of the nine weights will do it: three of four is the ceiling, and it stays the ceiling if you
add nine more layers of the same kind.
Step, sigmoid and the rectifier all get four of four out of the identical weights. The hidden pair now answers on the two sides of its own threshold instead of running away with the magnitude, so the output row sees 0 and 0, then 1 and 0, then 1 and 1, and a straight line through those three points is easy. The three of them differ in how they get there. Step hands up exact zeros and ones. Sigmoid hands up 0.0067 and 0.9933, close enough that the same output weights work with the margin barely moved: 0.4732 where step gave 0.5000. The rectifier hands up 5.0000 and 15.0000, the raw magnitudes, and the output row happens to be priced to cancel them.
Then tanh, on the same nine weights, drops back to three of four, and its wrong row is the first one: two inputs off, both hidden neurons idle, and the answer comes out 1.5001. The reason is in the hidden columns. Under the other three an idle neuron hands up 0 and contributes nothing at all to the sum above it. Under tanh an idle neuron hands up −1, which is as loud as an active one and points the other way, so the output weight of −3 that was subtracting an active neuron is now adding an idle one: −3 × −1 = +3. Re-price that row for the off state it actually has, weights 1 and −1 against a bias of −1, and tanh answers four of four with a comfortable margin either side.
That is the second half of the rule, in numbers. A squash decides not only whether the layer above it can express something, but what the layer above it costs to set: the same nine weights mean different things under two functions whose graphs look almost the same.
The idle hidden row
One question is left. If a function in the middle is this useful, why not put one over the output row too? The obvious first wiring uses one candidate everywhere, which is fewer decisions and one constant to change. Six output sums come off a creature's controller on an ordinary tick, and the highest of the six is the action it takes.
// cmd/squash/main.go
// The six numbers one creature's output row came out with on one tick.
// Every one is negative, which is an ordinary tick: nothing in reach is
// worth much, and the least bad thing to do is walk.
var actions = []string{"rest", "walk", "sprint", "turn left", "turn right", "bite"}
// best is the action a creature takes: the highest score, and the
// earliest in table order when two of them are equal.
func best(s []float64) int {
at := 0
for i, v := range s {
if v > s[at] {
at = i
}
}
return at
}
// pick prints the six scores as each candidate would leave them, and
// the action each choice ends in.
func pick(scores []float64) {
fmt.Println("squash: six scores off a bare output row, and the action each squash picks")
fmt.Printf(" %-11s", "action")
for _, s := range squashes {
fmt.Printf(" %11s", s.Name)
}
fmt.Println()
cols := make([][]float64, len(squashes))
for j, s := range squashes {
cols[j] = make([]float64, len(scores))
for i, v := range scores {
cols[j][i] = s.F(v)
}
}
for i, name := range actions {
fmt.Printf(" %-11s", name)
for j := range squashes {
fmt.Printf(" %11.6f", cols[j][i])
}
fmt.Println()
}
fmt.Printf(" %-11s", "it does")
for j := range squashes {
fmt.Printf(" %11s", actions[best(cols[j])])
}
fmt.Println()
}
$ go run ./cmd/squash -mode pick
squash: six scores off a bare output row, and the action each squash picks
action bare step sigmoid tanh relu
rest -1.420000 0.000000 0.194662 -0.889599 0.000000
walk -0.350000 0.000000 0.413382 -0.336376 0.000000
sprint -2.100000 0.000000 0.109097 -0.970452 0.000000
turn left -0.880000 0.000000 0.293178 -0.706419 0.000000
turn right -0.610000 0.000000 0.352059 -0.544127 0.000000
bite -3.050000 0.000000 0.045217 -0.995524 0.000000
it does walk rest walk walk rest
The symptom in the running valley was that creatures with the rectifier over both rows did nothing. Not sometimes: every creature, every tick, resting until its store ran out and it fell over. The scores were printed to find out which action was scoring so high, and every score was 0.000000, which is a strange thing for twelve weighted sums to agree on.
The bare column says why. All six sums are negative, which is an ordinary tick rather
than a broken one: nothing in reach is much use, and the least bad of six poor options
is to walk. The rectifier flattens every negative number to exactly zero, so the six
sums that were ranked −0.35, −0.61, −0.88, −1.42, −2.10,
−3.05 all become 0.000000 and the ranking is gone. best is left with
six equal numbers and returns the first, which is rest. Step does the same
thing for the same reason, from the other side: it flattens everything negative to 0 as
well.
Sigmoid and tanh keep the ordering and pick walk, the same action the bare
row picks, and that is the argument against them as well. Both are increasing functions,
so applying either to all six leaves them in the same order they were already in, and
the answer cannot change. Six calls to math.Exp a creature a tick, bought
to produce a result already sitting in the sums. There is a way for them to change the
answer, and it is worse than doing nothing: tanh returns exactly 1.0 for every input
above about 19, so two large sums that were ranked can come back tied, and the tie goes
to whichever action the table lists first.
The rule underneath is small. A monotonic function applied to every candidate in a comparison either changes nothing or destroys information, and never adds any. The six numbers are only ever compared with each other, never added to something, never handed to another layer, never read as a probability. So the output row is left bare, and the only function in the whole pass is the one on the hidden row.
Why the bend belongs in the middle
Strip the creature out and two independent properties have been under test all chapter. One is whether the function bends. Any bend at all, anywhere, is enough to stop two layers collapsing into one, and that is the entire job of the middle of a network: the step, the two S-curves and the hinge all did it, using four quite different bends. The other is where the function's floor and ceiling sit. That decides what the weights above it have to be set to, whether an idle neuron is silent or shouting, and whether one loud neuron can drown out the others.
The pattern reaches past networks. Chain any number of stages that each scale their input by a constant, and the chain has one gain overall: adding stages buys nothing, and the only cure is a stage that does something other than scale. Put a limiter in the chain and two things happen at once. The stages downstream can now do work no arrangement of the upstream ones could, and the limiter's floor and ceiling become facts every downstream stage has to be designed around. That second half is the one people forget, and it is the same lesson the plants taught with their ceiling: a bound nobody typed still ends up in every number computed after it.
Those two are why the valley's controller runs tanh through its hidden row. It bends. Its floor and ceiling are −1 and 1, so twelve hidden neurons all contribute on the same scale and none of them can dominate the eleven others whatever its sum came to. It is centred on zero, so a neuron with nothing to say contributes nothing, and a neuron can disagree by going negative instead of only by going quiet. And it answers a different number for every input, so a controller reading it can say a little left.
The rectifier is the interesting one to reject, because in most of this field it is the default. Its case is speed and its case is good: a comparison instead of an exponential. What it lacks is the ceiling, and this book never trains a network, so nothing will ever come along and scale a runaway weight back down. Ask what one hidden neuron can hand up in the worst case and the arithmetic is short, and the mode that prints it totals the pass on the way past, with a wrapper that tallies the calls as they happen.
// cmd/squash/main.go
// counted wraps a candidate in a tally, so what a pass costs is read
// off a run instead of counted on paper and hoped for.
func counted(f mind.Act, n *int) mind.Act {
return func(x float64) float64 {
*n++
return f(x)
}
}
// cost prints what one forward pass of the valley's controller comes
// to, and what a valley of them comes to.
func cost(creatures int) {
calls := 0
n := mind.Net{In: 24, Hid: 12, Out: 6, Squash: counted(mind.Tanh, &calls)}
size := mind.Weights(n.In, n.Hid, n.Out)
n.W = make([]float64, size)
in := make([]float64, n.In)
hid, out := n.Buffers()
n.Forward(in, hid, out)
muls := n.In*n.Hid + n.Hid*n.Out
adds := muls + n.Hid + n.Out
fmt.Printf("squash: one pass of a %d-%d-%d net, and %d of them\n", n.In, n.Hid, n.Out, creatures)
fmt.Printf(" %-22s %10d %14d\n", "weights", size, size*creatures)
fmt.Printf(" %-22s %10d %14d\n", "multiplies", muls, muls*creatures)
fmt.Printf(" %-22s %10d %14d\n", "additions", adds, adds*creatures)
fmt.Printf(" %-22s %10d %14d\n", "squash calls", calls, calls*creatures)
fmt.Printf(" one squash call for every %.1f multiplies\n", float64(muls)/float64(calls))
worst := float64(n.In) + 1
fmt.Printf("\n every input at 1 and every weight at 1 puts %.1f into one hidden neuron:\n", worst)
for _, s := range four {
fmt.Printf(" %9s %11.4f\n", s.Name, s.F(worst))
}
}
$ go run ./cmd/squash -mode cost
squash: one pass of a 24-12-6 net, and 3000 of them
weights 378 1134000
multiplies 360 1080000
additions 378 1134000
squash calls 12 36000
one squash call for every 30.0 multiplies
every input at 1 and every weight at 1 puts 25.0 into one hidden neuron:
step 1.0000
sigmoid 1.0000
tanh 1.0000
relu 25.0000
Twenty-four inputs at their maximum of 1, twenty-four weights at 1, plus a bias of 1, comes to 25.0 arriving at one hidden neuron. Three of the four hand up 1.0000 and the rectifier hands up 25.0000, twenty-five times what a neuron of ordinary opinion contributes, on weights nobody chose deliberately. The output row would then be reading one number and eleven rounding errors.
The cost of the choice is on the lines above it. A pass of the valley's controller is 360 multiplies, 378 additions and 12 calls to tanh: one call for every thirty multiplies. Three thousand creatures put that at 1,080,000 multiplies and 36,000 calls in a tick. Half a tick at ten ticks a second is fifty milliseconds, and those are the numbers the second half of that budget has to be argued from.
- Given any sum, produce all four answers to six decimal places with a
calculator, including the two that need
eto a power, and check them against the-mode curvetable. - Say which of the four have a ceiling and which have a floor, and get the program to tell me by handing each one an infinity.
- Shown that a bare hidden row reduces this gate to −20(x+y) + 39.5, explain why no output row over it reaches four of four, and why adding layers of the same kind does not help.
- Handed an output row priced for a hidden row that idles at 0, re-price it for one that idles at −1, and predict the new numbers before running it.
- Given six output sums, say which squashes over them would change the chosen action and which cannot, without running anything.
- For a net of any three sizes, state its weight count, its multiplies, its additions and its squash calls per pass.
Exercise 1 — turn the neuron's weight down. Run
go run ./cmd/squash -mode bearing -gain 0.5. Predict what happens to the
four counts at the bottom, and to the largest turn tanh ever asks for.
The counts do not move: 2, 201, 201, 101. A weight only stretches the input across the curve, and stretching cannot merge two answers that were different or separate two that were the same. What changes is how much of the curve gets used. Tanh's extremes fall from ±0.999329 to ±0.462117, so a creature reading that column as a turn never commits more than 46% of a turn even for a plant right at the edge of its vision, and a plant at a quarter bearing buys 0.124353 where it used to buy 0.761594. The weight on a sense is the gain on a control loop, and setting it too low leaves the creature unable to react hard to anything.
Exercise 2 — drive the gate's hidden pair ten times more gently.
Run go run ./cmd/squash -mode gate -hidden 1, which uses hidden weights
of 1 and biases of −0.5 and −1.5. Predict which candidates still answer
four of four.
Only step does. Its answers depend on the sign of the sum and nothing else, and the
signs did not move. Sigmoid drops to two of four: its hidden pair now hands up
0.6225 and 0.3775 for one input on, and the output row priced for a separation of
nearly a whole unit sees 0.6225 − 3 × 0.3775 − 0.5 = −1.0102
and says no. The rectifier also drops to two of four, and its failing rows are worth
a look: they read exactly 0.0000 in the out column, where 5.0000
− 0.5 used to sit, so the verdict turns on the strict comparison in
out[0] > 0. Tanh re-priced drops to two of four as well. Only the
function that ignores magnitude survives having the magnitude taken away, which is
the same fact as its two answers in the bearing table.
Exercise 3 — give the creature something to do. Run
go run ./cmd/squash -mode pick -scores -1.42,-0.35,-2.10,-0.88,-0.61,0.40,
which is the same tick with the bite sum lifted from −3.05 to 0.40. Predict
which columns change their pick.
All five agree on bite. The rectifier still flattens the five negatives
to zero, but 0.40 survives as 0.400000 and beats them; step turns the same six into
five zeros and a one. The failure needed every score to be negative, which makes it
the kind of bug a short demo run never finds and a valley left alone overnight finds
immediately: it strikes exactly on the ticks when there is nothing much to do, and
then the creature never does anything again, because resting does not change
anything the senses read.
The network is now the right size, wired in the right order, with the right function in the middle and nothing over the end, and it still cannot do a thing. Every weight quoted in this chapter was typed by hand: nine for the gate, twenty-five for the worst case, twelve columns of a table. A creature needs 378, and no hand is going to type 378 numbers for every creature that is ever born in The Hollow.