The World Vol 5 · Bodies and Brains
ch 52 / 105
Chapter 52

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.

▣ Build · stage 1 — a row of neurons, and the loop that is one of them
// 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.

◆ Note — the word "hidden", which sounds more interesting than it is

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.

▣ Build · stage 2 — one slice, four blocks, four offsets
// 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.

A 24-12-6 network's 378 weights, as four blocks of one slice A horizontal bar drawn to scale representing the 378 numbers of one controller, divided into four blocks. The first and by far the largest, marked 288, is the hidden weights. Three narrow blocks follow: hidden biases with 12 numbers, output weights with 72, and output biases with 6, each labelled by a line leading down and left. An arrow leads down from the first block to a second bar of twelve equal cells numbered 0 to 11, one cell per hidden neuron, with cell 1 highlighted. Two lines beneath say that one cell is one hidden neuron's 24 weights end to end, and that cell 1 is W bracket 24 to 47 while neuron 1's bias is W bracket 289. ONE CONTROLLER'S 378 NUMBERS, DRAWN TO SCALE 288 12 72 6 hidden weights hidden biases, 12 output weights, 72 output biases, 6 THE FIRST BLOCK, OPENED OUT 0 1 2 3 4 5 6 7 8 9 10 11 one cell is one hidden neuron: 24 weights, end to end cell 1 is W[24..47], and neuron 1's bias is W[289]
Figure 52.1 — the bar is to scale, and the scale is the useful part: 288 of the 378 numbers are weights on the senses, and every other block put together is a tenth of the slice. Widen the sensor row and the first block grows with it; add an action and the two right-hand slivers grow by twelve and by one.

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.

▣ Build · stage 3 — both rows, run in order
// 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.

⚠ Worked failure — the layer that read its weights sideways

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.

∑ Math Interlude — where the middle row goes

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.

xthe row of inputs; 0.50 and −0.20 here
uthe middle row's weights, one run per middle neuron
pthe middle row's biases, one per middle neuron
hwhat the middle row answers: one number per middle neuron
vthe output row's weights, one run per output
qthe output row's biases, one per output
ywhat the network answers
a·bevery weight in a times the matching number in b, summed for each neuron
v·uthe collapsed weights: what each output ends up doing with each input

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.

▣ Build · stage 4 — the two rows multiplied into one
// 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.

✓ Checkpoint — a controller you can index, run and take apart
  • Given four inputs and three neurons' weights and biases, produce the layer's three answers with a pencil and match them against -mode hand to 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, Hid and Out rather 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 Forward is handed its scratch buffers instead of making them, in terms of what a creature does every tick of its life.
⚡ Exercises — try first, then reveal
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.