Rows of Neurons, Run in Order
The layer and the network
One neuron settles one number. A creature needs six comparable answers for the six entries in the action table, all made from the same twenty-four readings.
A layer is a row of neurons reading one row of inputs, every neuron keeping its own weights and bias; a network is layers run in a fixed order, with each layer's answers becoming the next layer's inputs. Nothing else is in it.
The controller size is fixed by the two ends already built: twenty-four inputs from the sensor row and six outputs for the table. The hidden row has twelve neurons, and the whole arrangement lives in one flat slice of 378 weights.
The chapter then takes that arrangement apart. Two rows of plain weighted sums, one feeding the other, do the same work as one row unless something bends between them. The bench prints the agreement to fifteen decimal places, which makes the failure useful before tanh enters.
The checked four-by-three layer
Four inputs and three neurons is small enough to do with a pencil, so do it with a pencil first. The inputs are 0.50, −0.20, 1.00 and 0.00. Neuron 0 carries the weights 0.10, 0.20, −0.30 and 0.40 and a bias of 0.05. Neuron 1 carries −0.50, 1.00, 0.25 and −0.10 with no bias at all. Neuron 2 carries 2.00, 0.00, 0.50 and 1.00 with a bias of −1.00.
Neuron 0: 0.10 × 0.50 is 0.05, 0.20 × −0.20 is −0.04, −0.30 × 1.00 is −0.30, and 0.40 × 0.00 is nothing. Add the four and the bias: 0.05 − 0.04 − 0.30 + 0.05 = −0.24. Neuron 1 gets −0.25, −0.20, 0.25 and nothing, adding to −0.20. Neuron 2 gets 1.00, nothing, 0.50 and nothing, less a bias of 1.00, leaving 0.50. Three neurons, twelve multiplications, three additions of a bias, and the layer's answer is (−0.24, −0.20, 0.50).
Notice what the three neurons did not do. None of them looked at another's answer. None of them ran before or after another in any way that mattered, because each one reads the inputs and writes its own slot. The order of the loop is a convenience for the machine and nothing more, and that fact is what makes the next step legal: put all twelve weights in one slice, end to end, and let the index arithmetic say which neuron each run belongs to.
// internal/mind/net.go
package mind
// Layer runs one row of neurons across one row of inputs. w holds the
// weight runs end to end, b holds one bias per neuron, and out takes one
// number per neuron. The outer loop picks the neuron; the inner loop is
// that neuron's own arithmetic and nothing else.
func Layer(in, w, b, out []float64) {
for j := range out {
row := w[j*len(in) : (j+1)*len(in)]
sum := b[j]
for i, x := range in {
sum += row[i] * x
}
out[j] = sum
}
}
// cmd/layers/main.go — the bench: no valley, no creature, one layer
// hand is one layer small enough to check on paper: four inputs, three
// neurons, fifteen numbers in the slice.
func hand() {
in := []float64{0.5, -0.2, 1, 0}
w := []float64{
0.1, 0.2, -0.3, 0.4,
-0.5, 1.0, 0.25, -0.1,
2.0, 0.0, 0.5, 1.0,
}
b := []float64{0.05, 0, -1}
out := make([]float64, len(b))
fmt.Printf("one layer: %d inputs, %d neurons, %d numbers in the slice\n",
len(in), len(b), len(w)+len(b))
print1("inputs", in)
fmt.Println()
for j := range out {
fmt.Printf(" neuron %d reads W[%2d..%2d], bias W[%2d]\n",
j, j*len(in), (j+1)*len(in)-1, len(w)+j)
fmt.Printf(" ")
sum := b[j]
for i, x := range in {
fmt.Printf("%+.2f*%+.2f ", w[j*len(in)+i], x)
sum += w[j*len(in)+i] * x
}
fmt.Printf("%+.2f = %+.4f\n", b[j], sum)
}
fmt.Println()
mind.Layer(in, w, b, out)
print1("Layer says", out)
// the same three neurons again, each built as its own value over a
// run of the flat slice instead of read out of it by the loop
one := make([]float64, len(b))
for j := range one {
one[j] = mind.Neuron{W: w[j*len(in) : (j+1)*len(in)], B: b[j]}.Sum(in)
}
print1("one by one", one)
}
$ go run ./cmd/layers -mode hand
one layer: 4 inputs, 3 neurons, 15 numbers in the slice
inputs 0.5000 -0.2000 1.0000 0.0000
neuron 0 reads W[ 0.. 3], bias W[12]
+0.10*+0.50 +0.20*-0.20 -0.30*+1.00 +0.40*+0.00 +0.05 = -0.2400
neuron 1 reads W[ 4.. 7], bias W[13]
-0.50*+0.50 +1.00*-0.20 +0.25*+1.00 -0.10*+0.00 +0.00 = -0.2000
neuron 2 reads W[ 8..11], bias W[14]
+2.00*+0.50 +0.00*-0.20 +0.50*+1.00 +1.00*+0.00 -1.00 = +0.5000
Layer says -0.2400 -0.2000 0.5000
one by one -0.2400 -0.2000 0.5000
The two bottom lines are the pencil answer, and the three above them are the pencil working. The bench prints the products because the products are the only thing here anybody can get wrong, and printing them turns a disagreement between you and the program into a hunt for one term instead of an argument about the whole layer.
The last line is the point of the exercise. A mind.Neuron holds its own
weights and its own bias and answers with Sum; the layer holds nothing and
reads a run of weights straight out of a slice somebody else owns. Build the three
neurons one at a time, each over the run of four numbers the loop would have used, and
they answer −0.2400, −0.2000 and 0.5000: the same three numbers to the last
bit. The arithmetic did not change and was never going to. What changed is who owns the
weights, and that turns out to decide everything about what a controller can be handed
around as.
The inner loop of Layer is the neuron and nothing but: multiply, add,
multiply, add, having started the running total at the bias. One line above it carries
the whole design:
row := w[j*len(in) : (j+1)*len(in)]. Neuron j's weights are the
run of len(in) numbers starting at j*len(in), so neuron 0 owns
W[0..3], neuron 1 owns W[4..7], neuron 2 owns W[8..11], and the slice knows nothing about
any of that. The bookkeeping is one multiplication and one addition, and it is the same
bookkeeping the frame buffer does when it turns a row and a column into one offset into
a flat array of pixels.
The middle row of a network is called a hidden layer everywhere outside this book, and the word means one dull thing: nothing outside the network reads it. The senses are handed in, the six scores are handed back, and the twelve numbers in the middle are seen by the code and by nobody else. They are not secret and they are not deep. This book says "the middle row" when it is talking about position and "hidden" when it is naming a block of the weight slice, because that is what the field's own name for it will be in every reference you go and read.
The 378-number weight row
A controller needs four things written down: the twelve middle neurons' weights on the twenty-four inputs, the twelve middle biases, the six output neurons' weights on the twelve middle answers, and the six output biases. That is 24 × 12 = 288, plus 12, plus 12 × 6 = 72, plus 6, for a total of 378 numbers.
Those 378 could live in four separate slices, or in a struct of structs, or in a slice of neurons each holding its own little slice. They go in one flat slice instead, for a reason that has nothing to do with speed today: a controller in this world has to be one thing you can copy, save to a file, hand to another creature and compare against a second controller. A single run of numbers is all of those already, with no code at all. Anything built out of pointers needs a page of code for each.
// internal/mind/net.go
// Net is one controller: the size of its three rows, and every weight it
// owns in one flat slice.
//
// W holds four blocks, in this order: the hidden row's weights, one run
// of In per neuron; the hidden row's biases, one per neuron; the output
// row's weights, one run of Hid per output; the output row's biases.
// A neuron's weights sit end to end the way a row of pixels sits in the
// frame buffer, so hidden neuron h's weight on input i is W[h*In+i].
type Net struct {
In int // numbers the network is handed
Hid int // neurons in the middle row
Out int // numbers it hands back
W []float64
}
// Weights is how many numbers a network of these three sizes owns.
func Weights(in, hid, out int) int {
return in*hid + hid + hid*out + out
}
// New is a network of these sizes with every weight left at zero.
func New(in, hid, out int) Net {
return Net{In: in, Hid: hid, Out: out,
W: make([]float64, Weights(in, hid, out))}
}
// HidW, HidB, OutW and OutB are where each of the four blocks starts in
// W. Nothing else anywhere works out an offset into the slice.
func (n Net) HidW() int { return 0 }
func (n Net) HidB() int { return n.In * n.Hid }
func (n Net) OutW() int { return n.HidB() + n.Hid }
func (n Net) OutB() int { return n.OutW() + n.Hid*n.Out }
The bench reads every boundary back out of those four methods rather than restating them, so an offset written down wrongly in the package would come out as a wrong column on the page.
// cmd/layers/main.go
// slice prints where each of the four blocks starts and ends, and what
// one pass through the network costs in multiplies.
func slice(i, h, o int) {
n := mind.New(i, h, o)
fmt.Printf("a %d-%d-%d network owns %d numbers\n", i, h, o, len(n.W))
fmt.Printf(" %-16s %6s %6s %8s %s\n", "block", "from", "to", "numbers", "what one entry is")
fmt.Printf(" %-16s %6d %6d %8d %s\n", "hidden weights", n.HidW(), n.HidB()-1,
n.HidB()-n.HidW(), fmt.Sprintf("%d runs of %d", h, i))
fmt.Printf(" %-16s %6d %6d %8d %s\n", "hidden biases", n.HidB(), n.OutW()-1,
n.OutW()-n.HidB(), "one per hidden neuron")
fmt.Printf(" %-16s %6d %6d %8d %s\n", "output weights", n.OutW(), n.OutB()-1,
n.OutB()-n.OutW(), fmt.Sprintf("%d runs of %d", o, h))
fmt.Printf(" %-16s %6d %6d %8d %s\n", "output biases", n.OutB(), len(n.W)-1,
len(n.W)-n.OutB(), "one per output")
fmt.Printf("\n hidden neuron h's weight on input i is W[h*%d+i]\n", i)
for _, p := range [][2]int{{0, 0}, {0, i - 1}, {1, 0}, {h - 1, i - 1}} {
fmt.Printf(" neuron %2d, input %2d -> W[%d]\n", p[0], p[1], p[0]*i+p[1])
}
fmt.Printf("\n one pass multiplies %d times: %d*%d into the middle row, %d*%d out of it\n",
i*h+h*o, i, h, h, o)
}
$ go run ./cmd/layers -mode slice
a 24-12-6 network owns 378 numbers
block from to numbers what one entry is
hidden weights 0 287 288 12 runs of 24
hidden biases 288 299 12 one per hidden neuron
output weights 300 371 72 6 runs of 12
output biases 372 377 6 one per output
hidden neuron h's weight on input i is W[h*24+i]
neuron 0, input 0 -> W[0]
neuron 0, input 23 -> W[23]
neuron 1, input 0 -> W[24]
neuron 11, input 23 -> W[287]
one pass multiplies 360 times: 24*12 into the middle row, 12*6 out of it
Four methods and not one of them takes an argument, because each is a sum of sizes the
network already knows. Writing them down once is the point. A layout like this is
usually re-derived at every call site, and every re-derivation is a chance to be off by
twelve; here there is exactly one place that says the hidden biases start at
In*Hid, and everything that reads them goes through it.
The last line of the run is the cost of one thought: 360 multiplications and 360 additions, for one creature, on one tick. Multiply by however many creatures the valley is carrying and the total is still not frightening, which is a pleasant thing to establish early and a claim this chapter is not yet in a position to prove.
Twenty-four and six were decided by the world: that is how many senses a creature has and how many things it can do with a tick. Twelve was decided by a person, and it is the one number here nobody can derive. What can be said about it is what it costs and what it bounds. Every middle neuron costs 24 multiplications going in, 6 coming out, and 31 numbers to store: 24 weights, a bias, and one weight for each output that will read it. Twelve of them come to 360 multiplications and 372 of the slice's 378 numbers. Six would come to 180 and 192. Fifty would come to 1,500 and 1,556.
The bound is more interesting than the bill. Everything the six outputs are allowed to
notice about the twenty-four senses has to pass through the middle row first, so the width
of that row is a ceiling on how many different things the outputs can be reacting to at
once. Twelve is half the senses and twice the actions, which is a defensible place to
stand and not a discovered optimum. It is one field on the network, Hid, it
is an argument to New, and every figure in this chapter comes off a bench
that takes it as a flag.
Running the network is now Layer twice, with the four offsets picking out
which numbers each call gets. The one decision left is where the answers go.
// internal/mind/net.go
// 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.
func (n Net) Forward(in, hid, out []float64) {
Layer(in, n.W[n.HidW():n.HidB()], n.W[n.HidB():n.OutW()], hid)
Layer(hid, n.W[n.OutW():n.OutB()], n.W[n.OutB():], out)
}
// Buffers is the scratch one run of this network needs: the middle row
// and the row it answers with.
func (n Net) Buffers() (hid, out []float64) {
return make([]float64, n.Hid), make([]float64, n.Out)
}
Two things about those two lines. The first is that they run in order and have to: the
second Layer call reads hid, so the first call must have
finished writing all twelve of them before it starts. Layers within a row are
independent and layers between rows are not, and that is the only ordering rule in the
whole package. The second is that in and hid must be different
slices. Hand the same one twice and the first call would overwrite the inputs it is
still reading, halfway through, and Go would not say a word about it: they are both
[]float64 and the compiler has no opinion on whether they point at the same
array.
Forward takes the buffers instead of making them, and that is a deliberate
choice with a cost attached. It is uglier at the call site: three slices go in where one
would do. What it buys is that a creature makes twelve floats and six floats once, when
it is born, and then thinks for the rest of its life without asking the runtime for
memory. A version that returned a fresh slice would be one line shorter and would hand
the garbage collector two small allocations per creature per tick, forever.
Now it needs weights, and weights have to come from somewhere. This page takes them off a ruler: a repeating rule, stated once, that anybody can check by counting on their fingers. Where a controller's numbers ought to come from is a real question and a separate one, and a rule like this answers none of it. It only makes every figure below reproducible without a random stream anywhere in the chapter. What runs them is three lines of setup and one of reading: size the network off the ruler, take its two buffers, run the pass, then say which of the six came out highest.
// cmd/layers/main.go
// ruler fills a slice of n weights off a repeating rule instead of a
// random stream, so every number this bench prints can be checked by
// hand: weight k is k modulo 13, minus 6, over ten.
func ruler(n int) []float64 {
w := make([]float64, n)
for k := range w {
w[k] = (float64(k%13) - 6) / 10
}
return w
}
// row is a stand-in for a sensor row: input i is i modulo 5, minus 2,
// over two, so the values run -1.0, -0.5, 0.0, 0.5, 1.0 and repeat.
func row(n int) []float64 {
x := make([]float64, n)
for i := range x {
x[i] = (float64(i%5) - 2) / 2
}
return x
}
// run puts a row of numbers through the network and prints both rows it
// produces.
func run(i, h, o int) {
n := mind.Net{In: i, Hid: h, Out: o, W: ruler(mind.Weights(i, h, o))}
x := row(i)
hid, out := n.Buffers()
n.Forward(x, hid, out)
fmt.Printf("a %d-%d-%d network on %d weights off the ruler\n", i, h, o, len(n.W))
for k := 0; k < i; k += 12 {
print1(fmt.Sprintf("in[%d..]", k), x[k:min(k+12, i)])
}
for k := 0; k < h; k += 12 {
print1(fmt.Sprintf("hid[%d..]", k), hid[k:min(k+12, h)])
}
print1("out", out)
best := 0
for j, v := range out {
if v > out[best] {
best = j
}
}
fmt.Printf(" the largest of the %d is out[%d] at %+.4f\n", o, best, out[best])
}
$ go run ./cmd/layers -mode run
a 24-12-6 network on 378 weights off the ruler
in[0..] -1.0000 -0.5000 0.0000 0.5000 1.0000 -1.0000 -0.5000 0.0000 0.5000 1.0000 -1.0000 -0.5000
in[12..] 0.0000 0.5000 1.0000 -1.0000 -0.5000 0.0000 0.5000 1.0000 -1.0000 -0.5000 0.0000 0.5000
hid[0..] -0.3500 -0.0500 -1.0500 -0.1000 -0.4500 1.1500 0.1500 -0.2000 -0.5500 1.0500 1.3500 -0.9500
out 1.1100 1.2100 0.8550 0.8900 -0.3750 -1.7050
the largest of the 6 is out[1] at +1.2100
Check the first middle number by hand if you want the layout confirmed rather than
believed. Neuron 0's weights are W[0..23], which the ruler makes
−0.6, −0.5, and so on up to 0.6 at W[12] before starting over. Multiply each
by the matching input, add the twenty-four products, and you get 0.05. Neuron 0's bias
is W[288], and 288 divided by 13 leaves a remainder of 2, so the bias is
(2 − 6) ÷ 10 = −0.40. 0.05 − 0.40 is −0.35, which is what
the hid row prints in its first column.
The out row is six numbers on no particular scale, and the last line picks
the largest. That is the whole interface a controller offers whatever ends up reading it:
here are six figures, compare them yourself. None of them is a probability and none is
bounded, so out[5] at −1.7050 reads as a low bid and never as a refusal.
There are two ways to write the inner loop and only one is right, and the wrong one has
the special property of never crashing. Instead of stepping along neuron
j's own run, step down the slice taking every len(out)-th
number:
// cmd/layers/main.go
// sidewaysLayer is the mistake: an inner loop that steps neurons instead
// of inputs. It stays inside the slice and answers with numbers.
func sidewaysLayer(in, w, b, out []float64) {
for j := range out {
sum := b[j]
for i, x := range in {
sum += w[i*len(out)+j] * x
}
out[j] = sum
}
}
Both loops read exactly len(in) × len(out) numbers and both stay
inside the slice, because the two index rules are the same set of numbers walked in a
different order. Nothing panics. Nothing is out of range. The layer answers, in the
right count, on a believable scale.
So the bench runs the two loops side by side over the same twelve weights, and then asks them the one question whose answer is known before it is asked.
// cmd/layers/main.go
// sideways runs the layer whose inner loop walks down the slice instead
// of along it, against the one that walks along, and probes both with a
// row that is 1 in one place and 0 everywhere else.
func sideways() {
in, neurons := 4, 3
w := ruler(in * neurons)
b := []float64{0, 0, 0}
x := []float64{0.5, -0.2, 1, 0}
right, wrong := make([]float64, neurons), make([]float64, neurons)
fmt.Printf("%d inputs, %d neurons, %d weights off the ruler\n", in, neurons, len(w))
print1("W", w)
print1("inputs", x)
mind.Layer(x, w, b, right)
sidewaysLayer(x, w, b, wrong)
print1("along", right)
print1("down", wrong)
fmt.Println("\n the same two layers on a row that is 1 in one place")
fmt.Printf(" %-8s %26s %26s\n", "input", "along", "down")
for k := 0; k < in; k++ {
one := make([]float64, in)
one[k] = 1
mind.Layer(one, w, b, right)
sidewaysLayer(one, w, b, wrong)
fmt.Printf(" %-8d", k)
for _, v := range right {
fmt.Printf(" %8.4f", v)
}
fmt.Printf(" ")
for _, v := range wrong {
fmt.Printf(" %8.4f", v)
}
fmt.Println()
}
fmt.Printf("\n along: neuron j reads W[j*%d+i], so input %d picks out W[%d], W[%d], W[%d]\n",
in, in-1, in-1, 2*in-1, 3*in-1)
fmt.Printf(" down: neuron j reads W[i*%d+j], so input %d picks out W[%d], W[%d], W[%d]\n",
neurons, in-1, (in-1)*neurons, (in-1)*neurons+1, (in-1)*neurons+2)
}
$ go run ./cmd/layers -mode sideways
4 inputs, 3 neurons, 12 weights off the ruler
W -0.6000 -0.5000 -0.4000 -0.3000 -0.2000 -0.1000 0.0000 0.1000 0.2000 0.3000 0.4000 0.5000
inputs 0.5000 -0.2000 1.0000 0.0000
along -0.6000 -0.0800 0.4400
down -0.2400 -0.1100 0.0200
the same two layers on a row that is 1 in one place
input along down
0 -0.6000 -0.2000 0.2000 -0.6000 -0.5000 -0.4000
1 -0.5000 -0.1000 0.3000 -0.3000 -0.2000 -0.1000
2 -0.4000 0.0000 0.4000 0.0000 0.1000 0.2000
3 -0.3000 0.1000 0.5000 0.3000 0.4000 0.5000
along: neuron j reads W[j*4+i], so input 3 picks out W[3], W[7], W[11]
down: neuron j reads W[i*3+j], so input 3 picks out W[9], W[10], W[11]
The first four lines are useless for telling them apart. −0.6000, −0.0800, 0.4400 against −0.2400, −0.1100, 0.0200: three plausible numbers against three plausible numbers, and no way to say which set a correct layer would have produced without doing the arithmetic yourself.
The bottom half is how you tell. Hand the layer a row that is 1.0 in one place and 0.0 everywhere else. Every product except one is zero, so each neuron answers with its own weight on that single input, plus its own bias, and the biases here are all zero. So the three numbers on each line are weights, read straight out of the slice, and the only question is which three. The correct layer picks out one number from each neuron's run, spaced four apart. The sideways layer picks out three numbers sitting next to each other. The last two lines of the run name the indices: W[3], W[7], W[11] against W[9], W[10], W[11].
Look at the first row of the probe and at the last entry of the last row, though. Input 0 gives both layers −0.6000 for neuron 0, and input 3 gives both of them 0.5000 for neuron 2. The two index rules agree at the corners and disagree everywhere in between, and that is exactly the property that lets this bug survive a casual check by somebody who tried one number and moved on.
The probe is cheap enough to keep, so it belongs in a test file rather than in a person's memory.
// internal/mind/net_test.go
package mind
import "testing"
// A row that is 1 in one place and 0 everywhere else makes a layer hand
// back one column of its weights: neuron j answers with its own weight
// on that input, plus its own bias. It is the cheapest question that can
// tell a layer reading along its rows from one reading down them.
func TestLayerReadsAlongItsRows(t *testing.T) {
w := []float64{
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
}
b := []float64{100, 200, 300}
out := make([]float64, len(b))
for i := range 4 {
one := make([]float64, 4)
one[i] = 1
Layer(one, w, b, out)
for j := range out {
if want := w[j*4+i] + b[j]; out[j] != want {
t.Errorf("input %d, neuron %d: got %v, want %v", i, j, out[j], want)
}
}
}
}
Whole numbers in the weights and hundreds in the biases mean every expected answer is
readable at a glance, and floating point cannot round any of it. Put the sideways loop
into Layer and this test reports ten of its twelve cells wrong, printing the
got and the want for each one; the two it lets through are the corners the run above
already pointed at. Leave the loop correct and it says nothing at all, which is the
behaviour you want from a check that is going to be run a thousand times and read twice.
The rule underneath is one to keep for every flat array in this world, the frame buffer and the terrain grid included. An index rule that stays in bounds is not thereby correct. Two rules over the same block of memory can both be legal, and legality is what the compiler checks; correctness is what you check by asking the data a question whose answer you already know.
Two rows collapse to one
Now the awkward part. Take the smallest network that has a middle row at all: two inputs, two middle neurons, one output. Run it forwards on paper, then run it again a different way, and watch the middle row disappear.
The inputs are 0.50 and −0.20. Middle neuron 0 carries the weights 1.00 and 2.00 with a bias of 0.10; middle neuron 1 carries −0.50 and 0.40 with a bias of −0.20. The single output carries 3.00 and 1.00 on the two middle answers, with a bias of 0.05.
Forwards. Middle neuron 0 is 1.00 × 0.50 + 2.00 × −0.20 + 0.10, which is 0.50 − 0.40 + 0.10 = 0.20. Middle neuron 1 is −0.25 − 0.08 − 0.20 = −0.53. The output is 3.00 × 0.20 + 1.00 × −0.53 + 0.05 = 0.60 − 0.53 + 0.05 = 0.12.
Now do it in the other order. The output is 3.00 of the first middle answer plus 1.00 of the second. The first middle answer is 1.00 of input 0 plus 2.00 of input 1 plus 0.10. So the output collects 3.00 × 1.00 = 3.00 of input 0 through that route, and the second middle neuron adds 1.00 × −0.50 = −0.50 more of input 0. Input 0's total share of the output is 3.00 − 0.50 = 2.50. Input 1's is 3.00 × 2.00 + 1.00 × 0.40 = 6.40. The three biases collect the same way: 3.00 × 0.10 + 1.00 × −0.20 + 0.05 = 0.15.
output = 2.50 × input0 + 6.40 × input1 + 0.15
Check it: 2.50 × 0.50 = 1.25, 6.40 × −0.20 = −1.28, and 1.25 − 1.28 + 0.15 = 0.12. The same answer, from three numbers instead of nine, and with no middle row anywhere in the calculation.
In letters, with x the inputs, u the middle row's weights and p its biases, v the output
row's weights and q its biases, the general version is two lines. The middle row is
h = u·x + p. The output row is y = v·h + q.
Substitute the first into the second, which just means writing out what h stands for,
and you get y = v·(u·x + p) + q, which multiplies out to
y = (v·u)·x + (v·p + q). The bracket
(v·u) is one weight per input per output, worked out once, and
(v·p + q) is one number per output. That is a layer.
Doing that multiplication in code is a triple loop, and it belongs in the bench rather
than in the package, because its only job is to disagree with Forward and
fail to. The mode around it does the small example first, at two inputs and one output,
and then the same thing to the full controller.
// cmd/layers/main.go
// flatten multiplies a network's two rows of weights into the single row
// that does the same job: m[o*In+i] is what output o ends up doing with
// input i, and c[o] is what it adds whatever the inputs are.
func flatten(n mind.Net) (m, c []float64) {
m = make([]float64, n.Out*n.In)
c = make([]float64, n.Out)
hw, hb := n.W[n.HidW():n.HidB()], n.W[n.HidB():n.OutW()]
ow, ob := n.W[n.OutW():n.OutB()], n.W[n.OutB():]
for o := 0; o < n.Out; o++ {
c[o] = ob[o]
for h := 0; h < n.Hid; h++ {
v := ow[o*n.Hid+h]
c[o] += v * hb[h]
for i := 0; i < n.In; i++ {
m[o*n.In+i] += v * hw[h*n.In+i]
}
}
}
return m, c
}
// collapse multiplies the two rows of weights together into the one row
// that does the same job, and checks it against the network.
func collapse(i, h, o, show int) {
fmt.Println("two neurons in the middle, one output, worked on paper")
small := mind.Net{In: 2, Hid: 2, Out: 1, W: []float64{
1.0, 2.0,
-0.5, 0.4,
0.1, -0.2,
3.0, 1.0,
0.05,
}}
sx := []float64{0.5, -0.2}
shid, sout := small.Buffers()
small.Forward(sx, shid, sout)
print1("inputs", sx)
print1("middle", shid)
print1("output", sout)
sm, sc := flatten(small)
print1("one layer", sm)
print1("constant", sc)
fmt.Printf(" and %+.2f*%+.2f %+.2f*%+.2f %+.2f = %+.4f\n",
sm[0], sx[0], sm[1], sx[1], sc[0], sm[0]*sx[0]+sm[1]*sx[1]+sc[0])
n := mind.Net{In: i, Hid: h, Out: o, W: ruler(mind.Weights(i, h, o))}
x := row(i)
hid, out := n.Buffers()
n.Forward(x, hid, out)
m, c := flatten(n)
flat := make([]float64, o)
for j := range flat {
flat[j] = c[j]
for k := range x {
flat[j] += m[j*i+k] * x[k]
}
}
fmt.Printf("\nthe same thing done to the %d-%d-%d network\n", i, h, o)
fmt.Printf(" %d weights in two rows against %d in one\n", len(n.W), len(m)+len(c))
print1("two rows", out)
print1("one row", flat)
worst := 0.0
for j := range out {
if d := math.Abs(out[j] - flat[j]); d > worst {
worst = d
}
}
fmt.Printf(" largest difference between them: %.3g\n", worst)
fmt.Printf("\n the collapsed row, first %d of its %d columns\n", show, i)
for j := 0; j < o; j++ {
print1(fmt.Sprintf("out %d", j), append(append([]float64{}, m[j*i:j*i+show]...), c[j]))
}
fmt.Printf(" (the last column is the constant, not a weight)\n")
}
$ go run ./cmd/layers -mode collapse
two neurons in the middle, one output, worked on paper
inputs 0.5000 -0.2000
middle 0.2000 -0.5300
output 0.1200
one layer 2.5000 6.4000
constant 0.1500
and +2.50*+0.50 +6.40*-0.20 +0.15 = +0.1200
the same thing done to the 24-12-6 network
378 weights in two rows against 150 in one
two rows 1.1100 1.2100 0.8550 0.8900 -0.3750 -1.7050
one row 1.1100 1.2100 0.8550 0.8900 -0.3750 -1.7050
largest difference between them: 7.22e-16
the collapsed row, first 6 of its 24 columns
out 0 0.1500 -0.0500 0.5300 0.2000 0.6500 0.1900 0.9400
out 1 0.1100 -0.0800 0.5100 0.1900 0.6500 0.2000 0.9900
out 2 -0.7100 -0.7600 -0.0300 -0.2100 0.3900 0.0800 0.5200
out 3 -0.1000 -0.0100 -0.8300 -0.8700 -0.1300 -0.3000 0.1800
out 4 0.2500 0.4800 -0.2000 -0.1000 -0.9100 -0.9400 -0.0300
out 5 0.3400 0.7100 0.1700 0.4100 -0.2600 -0.1500 -1.4100
(the last column is the constant, not a weight)
The top block is the paper working, printed: 0.2000 and −0.5300 in the middle, 0.1200 out, and the same 0.1200 from 2.5000, 6.4000 and a constant of 0.1500. The bottom block is the same argument at full size. 378 weights in two rows produce 1.1100, 1.2100, 0.8550, 0.8900, −0.3750, −1.7050; 150 weights in one row produce the same six numbers, and the largest disagreement anywhere among them is 7.22e-16.
That last figure is a fact about float64 and not a softening of the argument. Two arithmetically identical routes to the same answer round differently at the last bit, because the two-row route sums 24 products and then 12, while the one-row route sums 24 products once. 7.22e-16 is about three ulps at this magnitude, which is the noise floor of doing the same sum in a different order, and it is the reason a replayed simulation has to fix the order of its arithmetic and not only its inputs.
The obvious objection is that twelve middle neurons is too few, and a wider middle row would do something a single layer could not. One more mode widens it, collapses each width in turn, and reports the two sizes against how far apart the two routes landed.
// cmd/layers/main.go
// wide widens the middle row and reports what it buys.
func wide(i, o int) {
x := row(i)
fmt.Printf("%d inputs, %d outputs, the middle row widened\n", i, o)
fmt.Printf(" %8s %10s %12s %14s\n", "hidden", "weights", "collapsed", "difference")
for _, h := range []int{2, 6, 12, 64, 256} {
n := mind.Net{In: i, Hid: h, Out: o, W: ruler(mind.Weights(i, h, o))}
hid, out := n.Buffers()
n.Forward(x, hid, out)
m, c := flatten(n)
worst := 0.0
for j := range out {
v := c[j]
for k := range x {
v += m[j*i+k] * x[k]
}
if d := math.Abs(out[j] - v); d > worst {
worst = d
}
}
fmt.Printf(" %8d %10d %12d %14.3g\n", h, len(n.W), len(m)+len(c), worst)
}
}
$ go run ./cmd/layers -mode wide
24 inputs, 6 outputs, the middle row widened
hidden weights collapsed difference
2 68 150 1.11e-16
6 192 150 3.33e-16
12 378 150 7.22e-16
64 1990 150 6.22e-15
256 7942 150 3.55e-14
Two hundred and fifty-six neurons in the middle, 7,942 weights, and the collapsed column still reads 150. There is no width at which a second row of plain weighted sums starts being able to say something one row cannot. The collapsed layer is the same size every time because its size is fixed by the ends: 24 inputs times 6 outputs plus 6 constants, whatever happens in the middle.
So ask what a middle row is supposed to be for, given that this one is not doing it. The honest answer is that a middle neuron is meant to be a quantity computed once and reusing: some combination of the senses that several of the six actions all care about, worked out in one place instead of six. That is a real saving and a real idea. The trouble is that a plain weighted sum of the inputs adds no new quantity at all: it is more of the same material, and any output that wanted it could have built it out of the inputs directly, at no extra cost, using weights it already has. Reuse pays only when the thing being reused could not have been had for free.
The traffic only runs one way, though, and the top row of that table is where it shows. Two middle neurons also collapse to 150 numbers, but they cannot produce every possible set of 150 numbers: everything the outputs see has to squeeze through two figures first, so all six collapsed rows end up being built out of the same two ingredients. A narrow middle row is a restriction. A wide one is not an ability. Both of them are, in the end, one layer.
Why the arrangement is the design
Strip out the creature and what is left is a way of building anything that maps a fixed row of numbers to a shorter fixed row. Decide the sizes. Lay every parameter end to end in one array with a stated block order. Write one function that runs a row of neurons, and call it once per layer. Everything else is arithmetic that has already been checked at a size a person can hold in their head.
Three habits in that outlast this chapter. The first is that the layout gets written down once, in the four offset methods, and every read goes through them. Flat arrays with implicit structure are the standard way to make software fast and the standard way to make it silently wrong, and the difference between the two is whether the structure is stated in one place or re-derived at each call site. The second is that a probe with a known answer beats an inspection of plausible output every time. One-hot rows tell you which way a layer reads. A single input at zero tells you a bias is being applied. Constant inputs tell you the sums are being reset between calls. None of those questions can be answered by looking at six believable numbers.
The last habit is the one about buffers. Forward writes into slices its caller
owns, and the caller keeps them for the life of the thing that thinks with them. That
arrangement scales to a valley in a way returning fresh slices does not, and it also makes
the ownership visible: two creatures cannot accidentally share a middle row, because each
one is holding its own. When something eventually runs several of these at once, the
question it has to answer is exactly which memory two runs might both touch, and a
package with no allocation in it and no state on the network answers that question
already.
The collapse is the more important lesson, because it is about design and not about code. Adding parts to a model does not add power; adding parts that do something the existing parts cannot does. Twelve middle neurons of plain weighted sums cost 228 numbers over the 150 the same job needs, buy nothing whatever, and the arithmetic proving it took four lines. That test is available every time a component is added to a system: write out what the system computes with the component and what it computes without, then see whether the two expressions can be made equal. If they can, the component is decoration with a maintenance cost.
- Given four inputs and three neurons' weights and biases, produce the layer's
three answers with a pencil and match them against
-mode handto four decimal places. - Say where in a 378-number slice hidden neuron 7's weight on input 3 lives, and
derive the answer from
In,HidandOutrather than from a table. - Handed a layer and told nothing about it, decide whether it reads along its rows or down its columns by feeding it a row that is 1 in one place, and say which two entries of a 4-by-3 slice will agree either way.
- Collapse a two-row network of plain weighted sums into one row on paper, and explain why the collapsed row has 150 numbers whether the middle holds 2 neurons or 256.
- Shown two arithmetically identical routes that disagree by 7.22e-16, say which property of float64 causes it and why fixing the order of operations matters to a simulation that has to replay.
- Explain why
Forwardis handed its scratch buffers instead of making them, in terms of what a creature does every tick of its life.
Exercise 1 — squeeze the middle to one neuron. Run
go run ./cmd/layers -mode collapse -hidden 1 and look hard at the six
collapsed rows. Something is true of all of them that was not true at twelve.
Every row is the same twenty-four numbers times a different scale. Out 0 starts −0.3600, −0.3000, −0.2400; out 1 is exactly those with the sign flipped; out 2 is them times −0.8333, and so on down the table. With one neuron in the middle, everything the six outputs are allowed to notice about the inputs has to arrive through a single number, so the outputs cannot disagree about anything except how strongly to react to it and where to sit.
$ go run ./cmd/layers -mode collapse -hidden 1 | tail -8
the collapsed row, first 6 of its 24 columns
out 0 -0.3600 -0.3000 -0.2400 -0.1800 -0.1200 -0.0600 0.2000
out 1 0.3600 0.3000 0.2400 0.1800 0.1200 0.0600 -0.3000
out 2 0.3000 0.2500 0.2000 0.1500 0.1000 0.0500 -0.1500
out 3 0.2400 0.2000 0.1600 0.1200 0.0800 0.0400 0.0000
out 4 0.1800 0.1500 0.1200 0.0900 0.0600 0.0300 0.1500
out 5 0.1200 0.1000 0.0800 0.0600 0.0400 0.0200 0.3000
(the last column is the constant, not a weight)
Note the constants in the last column do vary freely: 0.2000, −0.3000, −0.1500, 0.0000, 0.1500, 0.3000. A bottleneck restricts what the outputs can learn from the world. It does not stop them having different opinions by default.
Exercise 2 — size a smaller controller on paper. A creature
with 9 senses, 4 middle neurons and 3 actions: work out how many numbers it owns and
where all four blocks start and end, then check with
go run ./cmd/layers -mode slice -in 9 -hidden 4 -out 3.
9 × 4 = 36 hidden weights, 4 hidden biases, 4 × 3 = 12 output weights, 3 output biases: 55 numbers. So the blocks run 0 to 35, 36 to 39, 40 to 51 and 52 to 54.
$ go run ./cmd/layers -mode slice -in 9 -hidden 4 -out 3
a 9-4-3 network owns 55 numbers
block from to numbers what one entry is
hidden weights 0 35 36 4 runs of 9
hidden biases 36 39 4 one per hidden neuron
output weights 40 51 12 3 runs of 4
output biases 52 54 3 one per output
hidden neuron h's weight on input i is W[h*9+i]
neuron 0, input 0 -> W[0]
neuron 0, input 8 -> W[8]
neuron 1, input 0 -> W[9]
neuron 3, input 8 -> W[35]
one pass multiplies 48 times: 9*4 into the middle row, 4*3 out of it
48 multiplications against the valley controller's 360, for a creature with a third of the senses and half the actions. Cost climbs faster than either end does, because the first block is a product of two sizes and not a sum.
Exercise 3 — make the rounding gap bigger on purpose. The
collapse disagreed by 7.22e-16 at 24 inputs. Predict what happens to that column with
ten times as many inputs, then run
go run ./cmd/layers -mode wide -in 240.
More terms in each sum means more roundings, and the errors accumulate roughly with the square root of the count when they are independent, so the gap should grow by a factor of a few and not by a factor of ten. It goes from 7.22e-16 to 7.11e-15 at twelve middle neurons: about ten times, because the sums also got ten times longer in magnitude, not only in count.
$ go run ./cmd/layers -mode wide -in 240
240 inputs, 6 outputs, the middle row widened
hidden weights collapsed difference
2 500 1446 1.92e-15
6 1488 1446 1.78e-15
12 2970 1446 7.11e-15
64 15814 1446 2.22e-14
256 63238 1446 9.24e-14
Two useful things in that table. The collapsed column is 240 × 6 + 6 = 1446 and never moves, which is the same argument as before at a different size. And at two middle neurons the network is now smaller than its own collapse, 500 numbers against 1446: proof, if any were needed, that collapsing a network is a statement about what it can compute and not a way to make it cheaper.
Everything above turns on one absence. Between the middle row's sums and the output row's weights, nothing happens: the twelve numbers are handed straight on. Put anything there that bends, clips or squashes those twelve numbers before the next row multiplies them and the substitution in the interlude stops going through, because you can no longer write the output as a plain sum of the inputs. The middle row starts earning its keep the moment that gap is filled, and what to fill it with is a decision with more than one defensible answer and a cost attached to each.