Where the Weights Come From
The flat brain row
The controller runs, and every score it produces is zero. With every weight and bias set to zero, tanh receives zero twelve times and the output row returns a six-way tie forever.
A brain here is one flat row of 378 numbers with no internal structure a copier has to respect: drawn from a stream of its own, written to a file exactly as it stands, and judged only from the outside. The creature lives with that row; the valley judges the whole result.
Gradient training needs a target answer before it can move one weight. A creature in this valley has no correct action printed beside a tick. It has a life, a death and a line in the books, so selection can compare whole brains without opening them.
The 378 positions already have meaning from the forward pass: hidden weights, hidden biases, output weights, output biases. The page reads those offsets, draws numbers at a scale set by each neuron's fan-in, writes the row, and reads it back byte for byte.
The 378 positions
Count them before writing anything. Each of the twelve middle neurons reads all twenty-four inputs, so it owns twenty-four weights: 24 × 12 = 288. Each of those twelve also owns one bias, a number added to its sum whatever the inputs are doing: 12 more. Each of the six output neurons reads all twelve middle values, so it owns twelve weights: 12 × 6 = 72. Each of the six owns a bias: 6 more. Add them. 288 + 12 + 72 + 6 = 378. That is the whole brain, and every one of those numbers has to come from somewhere.
They go in one slice. Not a struct holding four slices, and not twelve neuron values each holding a little slice of their own, because of what actually happens to a brain in this world. It gets allocated once. It gets copied. It gets compared with another brain. It gets written to a file and read back. It gets altered in ways that have no interest in which position is a weight and which is a bias. Every one of those operations is a single loop over one contiguous block of numbers, or a single call, and every one of them turns into four cases the moment the brain is four things instead of one. At three thousand creatures, four allocations apiece is twelve thousand allocations where there could be three thousand.
What the flat row costs is that a position on its own means nothing. W[295]
is a bias only because 295 falls between 288 and 300, and it falls between them only
because the network is 24 inputs wide with 12 in the middle. The offsets have to be
worked out from the three sizes somewhere, and that somewhere already exists: the
forward pass cuts W into its four blocks on every single run, and it gets
the four cut points from four methods that take no arguments at all. This page adds
none of its own. Everything below that needs to know where the biases start asks the same
four methods the forward pass asks, because a second function computing the same offsets
is a second thing that has to stay true, and it will not.
internal/mind already holds the count, the four offsets, and the
allocation that follows from both. They are used constantly from here on, so here they
are again before the new file starts.
// internal/mind/net.go
// 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 }
// Command brains is chapter 54's bench: where a controller's 378
// numbers come from, how big each one is allowed to be, what it costs
// to write them down, and why nothing here computes a gradient. Every
// mode is arithmetic on drawn numbers; no valley is read and no gram
// moves.
package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"math"
"os"
"theworld/internal/mind"
)
// layout prints where each of the four blocks starts, which run of
// numbers each neuron owns, and what one thought costs.
func layout(in, hid, out, creatures int) {
n := mind.New(in, hid, out)
fmt.Printf("brains: one %d-%d-%d controller as a flat row of numbers\n\n", in, hid, out)
fmt.Printf(" %-16s %7s %8s %8s %s\n", "part", "count", "from", "to", "arithmetic")
fmt.Printf(" %-16s %7d %8d %8d %d inputs x %d hidden\n",
"hidden weights", n.HidB()-n.HidW(), n.HidW(), n.HidB(), in, hid)
fmt.Printf(" %-16s %7d %8d %8d one per hidden neuron\n",
"hidden biases", n.OutW()-n.HidB(), n.HidB(), n.OutW())
fmt.Printf(" %-16s %7d %8d %8d %d hidden x %d out\n",
"output weights", n.OutB()-n.OutW(), n.OutW(), n.OutB(), hid, out)
fmt.Printf(" %-16s %7d %8d %8d one per output neuron\n",
"output biases", len(n.W)-n.OutB(), n.OutB(), len(n.W))
fmt.Printf(" %-16s %7d\n", "the whole brain", len(n.W))
fmt.Printf("\n hidden neuron j reads W[%d + j*%d : %d + j*%d + %d]\n",
n.HidW(), in, n.HidW(), in, in)
fmt.Printf(" output neuron k reads W[%d + k*%d : %d + k*%d + %d]\n",
n.OutW(), hid, n.OutW(), hid, hid)
for _, j := range []int{0, 1, hid - 1} {
fmt.Printf(" hidden %2d: W[%3d:%3d] bias W[%d]\n",
j, n.HidW()+j*in, n.HidW()+(j+1)*in, n.HidB()+j)
}
for _, k := range []int{0, out - 1} {
fmt.Printf(" output %2d: W[%3d:%3d] bias W[%d]\n",
k, n.OutW()+k*hid, n.OutW()+(k+1)*hid, n.OutB()+k)
}
muls := in*hid + hid*out
fmt.Printf("\n one tick of one brain: %d multiplications, %d additions, %d tanh\n",
muls, muls+hid+out, hid)
fmt.Printf(" %d creatures, all thinking once: %d multiplications a tick\n",
creatures, muls*creatures)
}
$ go run ./cmd/brains -mode row
brains: one 24-12-6 controller as a flat row of numbers
part count from to arithmetic
hidden weights 288 0 288 24 inputs x 12 hidden
hidden biases 12 288 300 one per hidden neuron
output weights 72 300 372 12 hidden x 6 out
output biases 6 372 378 one per output neuron
the whole brain 378
hidden neuron j reads W[0 + j*24 : 0 + j*24 + 24]
output neuron k reads W[300 + k*12 : 300 + k*12 + 12]
hidden 0: W[ 0: 24] bias W[288]
hidden 1: W[ 24: 48] bias W[289]
hidden 11: W[264:288] bias W[299]
output 0: W[300:312] bias W[372]
output 5: W[360:372] bias W[377]
one tick of one brain: 360 multiplications, 378 additions, 12 tanh
3000 creatures, all thinking once: 1080000 multiplications a tick
Read the printing code against the table it printed. Not one from and not
one to in that output is arithmetic this file did: they are
n.HidW(), n.HidB(), n.OutW() and
n.OutB(), asked and printed. The counts are subtractions between two of
them. Change the middle layer from twelve neurons to twenty and every number on the
page moves: the first block grows to 480, the biases start at 480, the whole brain
becomes 626. Nothing here has to be edited for that to come out right, and nothing here
can drift away from what the forward pass believes, because it is asking the forward
pass what it believes.
Note the last two lines as well. The multiplications are the entire arithmetic cost of a brain thinking once, and three thousand of them come to a little over a million a tick. That is a number to hold on to.
W[168:192] and its bias is
W[295], and both of those addresses are 24, 12 and 6 multiplied and added.
The fan-in scale
A weight has to be some number, and the range it is drawn from is a decision. Between −1 and 1 looks harmless. Every sense arrives already between −1 and 1, so weights on the same scale seem to match. Work out what the middle layer then has to add up and the trouble appears.
One middle neuron adds twenty-four products and a bias. Suppose every input is at the top of its range and every weight came out at 1: the sum is 24, and tanh of 24 is 1 to fifteen decimal places. Suppose instead the weights come out half positive and half negative and cancel exactly: the sum is 0. Neither of those is what happens. What happens is a walk of twenty-five steps, some forward and some back, and the useful question is how far from the start such a walk usually finishes.
Start with the size of one drawn number. A weight taken evenly from between −1
and 1 is sometimes 0.9 and sometimes 0.03, so no single value describes it. The measure
that does is the one Len already applies to the two numbers of a vector:
square them, average the squares, take the square root back off. Do that to a great
many numbers drawn evenly between −1 and 1 and the answer is
0.5774, which is the square root of one third. Call that the usual size and write it
s.
Now add n of them together, each one independent of the others. Their usual sizes do not add. What adds is the squares, so the sum of n numbers of usual size s has usual size s multiplied by the square root of n. Twenty-four inputs and one bias make twenty-five numbers going into a middle neuron, so in plain figures:
usual sum = 0.5774 × √25 = 0.5774 × 5 = 2.8868
That is the same as √(25 ÷ 3), and it is the number the run
below predicts before it measures anything. What does tanh do with a sum of 2.8868? It
returns 0.9938. The squash is flat there. A middle neuron sitting at 0.9938 answers
almost the same 0.99-something whatever its inputs do, and a neuron that always says
the same thing has quietly become a nineteenth bias.
The repair is division. Draw each weight evenly between −1 and 1 as before, then divide it by the square root of how many numbers its neuron adds up. Call that count the fan, f. Every weight shrinks by √f, so the usual sum shrinks by √f too:
usual sum = √((f + 1) ÷ 3) ÷ √f
At a fan of 24 that is 2.8868 ÷ 4.899 = 0.5893, and tanh of 0.5893 is 0.5292, which is on the sloped part of the curve where a change in the inputs still changes the answer. The better property is what happens as the fan grows. At a fan of 384 the division gives 0.5781, and at a fan of a million it would give 0.5774. The rule is a rule instead of a number: it holds at any layer size, so a network whose sizes change never needs its weights re-tuned by hand.
// internal/mind/weights.go
package mind
import (
"encoding/binary"
"math"
"math/rand/v2"
)
// Fan is how many numbers a neuron in each row adds up: a hidden neuron
// reads all In of the inputs, an output neuron reads all Hid of the
// middle values. It is the only thing the size of a drawn weight
// depends on, and it is read off the sizes rather than stated anywhere.
func (n Net) Fan() (hid, out int) { return n.In, n.Hid }
// StreamBrain is where a brain's numbers come from: stream 14 on the
// world's seed, continuing the registry the terrarium opened. Nothing
// else in the world draws from it, so a valley that gains a creature
// does not move a plant's numbers.
const StreamBrain = 14
// Draw fills a brain's whole row from one stream, in flat-slice order,
// one number per draw. Every number lands between -1 and 1 and is then
// divided by the square root of the fan its neuron adds up, so a
// hidden neuron reading twenty-four inputs gets smaller weights than an
// output neuron reading twelve. The order is the contract: a brain is a
// fixed number of steps down the stream, and two runs of one seed hand
// the same creature the same brain.
func (n Net) Draw(r *rand.Rand) {
hid, out := n.Fan()
small := [2]float64{1 / math.Sqrt(float64(hid)), 1 / math.Sqrt(float64(out))}
split := n.OutW()
for i := range n.W {
layer := 0
if i >= split {
layer = 1
}
n.W[i] = (r.Float64()*2 - 1) * small[layer]
}
}
// Brains opens the one generator a valley draws its brains from.
func Brains(seed uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed, StreamBrain))
}
// Bits is the row as raw bytes, every number in flat-slice order,
// eight bytes each, most significant first. It is what a digest is
// taken over: two brains agree here or they are two different brains.
func (n Net) Bits() []byte {
out := make([]byte, 8*len(n.W))
for i, w := range n.W {
binary.BigEndian.PutUint64(out[8*i:], math.Float64bits(w))
}
return out
}
The single comparison i >= split is what splits the row into the two
fans, and split is n.OutW(), the same offset the forward pass
slices at. Everything before it belongs to a middle neuron reading twenty-four inputs;
everything from it onward belongs to an output neuron reading twelve, biases included,
since a bias is added into the same sum as the weights beside it and has to arrive on
the same scale. Note the loop draws once for every position, all 378 of them, biases
and all. That is not an accident of writing: a brain has to be a fixed number of steps
down the stream, because otherwise the tenth creature born in a run gets different
numbers depending on what the ninth one turned out to be.
The bench checks the interlude's prediction against a measurement, and it does it on a layer of four thousand neurons instead of twelve. Twelve is a small enough sample that one lucky draw moves the answer by a lot, and the claim being tested is about the arithmetic, not about one brain. Every neuron is fed the noisiest row its inputs allow: every entry at the far end of its range, alternating sign so nothing cancels for a tidy reason. Both halves of the table are the same draw off the same stream, so the only difference between them is the division.
// loud is the noisiest row a layer's inputs allow: every entry at the
// far end of its range, alternating in sign so nothing cancels for a
// tidy reason.
func loud(n int) []float64 {
x := make([]float64, n)
for i := range x {
x[i] = 1
if i%2 == 1 {
x[i] = -1
}
}
return x
}
// spread is what a whole layer of sums came to: the usual size of one
// of them, measured the way the interlude defines it, the average size
// of what tanh made of it, and the share of the layer tanh answered
// past the pin with.
func spread(sums []float64) (usual, squashed, pinned float64) {
for _, z := range sums {
usual += z * z
t := math.Abs(mind.Tanh(z))
squashed += t
if t > 0.99 {
pinned++
}
}
n := float64(len(sums))
return math.Sqrt(usual / n), squashed / n, pinned / n
}
$ go run ./cmd/brains -mode fan
brains: a layer of 4000 neurons fed the loudest row its inputs allow,
drawn off stream 14 and used twice: as drawn, then divided by the root of the fan
inputs root predict usual |tanh| pinned predict usual |tanh| pinned
4 2.000 1.291 1.306 0.6372 4% 0.645 0.653 0.4300 0%
12 3.464 2.082 2.098 0.7525 21% 0.601 0.606 0.4050 0%
24 4.899 2.887 2.877 0.8156 36% 0.589 0.587 0.3957 0%
96 9.798 5.686 5.743 0.9046 64% 0.580 0.586 0.3967 0%
384 19.596 11.328 11.422 0.9520 82% 0.578 0.583 0.3937 0%
left half: every number between -1 and 1. right half: the same numbers over the root of the fan.
predict is sqrt((inputs+1)/3) times the size the numbers were drawn at.
usual is the measured one: every sum squared, averaged, and the root taken back off.
pinned is the share of the layer whose tanh came back past 0.99.
Read the two predict columns against the two usual columns
first, because that is the interlude being checked: 2.887 predicted and 2.877 measured
at twenty-four inputs, 11.328 predicted and 11.422 measured at three hundred and
eighty-four. The arithmetic was done on paper and the run agrees with it to about a
percent, which is what four thousand samples buys.
Now read the pinned columns, which say what it costs. Left, with the
division left out, more than a third of the layer is past 0.99 at twenty-four inputs
and four fifths of it is past 0.99 at three hundred and eighty-four. Every pinned
neuron has stopped reporting: it will say 0.995 to this sensor row and 0.996 to a
completely different one, and its contribution to the six scores is very nearly a
constant. Right, with the division in, the pinned column reads 0% at every size on the
table and the average middle value sits near 0.40 whatever the fan. This valley's own
controller is the 24 line, so it is the mild case, and the rule is what keeps it mild.
One brain is 378 of those draws taken in a row, and the second brain starts where the first one stopped. What that buys is the whole reason a world made of drawn numbers can be replayed, so it gets measured here instead of assumed.
// digest is the sixteen hex digits this bench compares brains by,
// folded the way the valley folds its own state: sha256 over the raw
// bytes of every weight in order, cut to the first sixteen. Two brains
// agree here only if every one of their numbers is the same number
// down to the last bit.
func digest(n mind.Net) string {
sum := sha256.Sum256(n.Bits())
return hex.EncodeToString(sum[:])[:16]
}
// brainAt is the k'th brain a valley draws: open stream 14 on the world
// seed, throw away the brains born before this one, and take the next
// row of numbers off it.
func brainAt(seed uint64, k, in, hid, out int) mind.Net {
r := mind.Brains(seed)
n := mind.New(in, hid, out)
n.Squash = mind.Tanh
for range k {
n.Draw(r)
}
n.Draw(r)
return n
}
$ go run ./cmd/brains -mode draw
brains: 4 of them drawn off stream 14 on world seed 5
brain W[0] W[1] W[377] digest
0 0.11373768 0.15953022 0.06711743 a978c376398aa14e
1 0.12787853 -0.08717750 -0.15217619 a6ce5d024e4641ad
2 0.13539839 0.03748572 0.24593342 c90a2c7c021463d2
3 -0.00090827 0.11102333 -0.08975907 36797ad6205e4801
the same seed again, from a generator that has never been used:
0 0.11373768 0.15953022 0.06711743 a978c376398aa14e
1 0.12787853 -0.08717750 -0.15217619 a6ce5d024e4641ad
2 0.13539839 0.03748572 0.24593342 c90a2c7c021463d2
3 -0.00090827 0.11102333 -0.08975907 36797ad6205e4801
4 of 4 brains came back identical, bit for bit
brain 3 skipped to directly: 36797ad6205e4801
Four brains, four digests, no two alike, and the whole set repeats exactly when a fresh
generator is opened on the same seed. The last line is the property that matters most
and is easiest to lose. It was produced by brainAt: open the stream, throw
away three brains' worth of draws without keeping a single number, then draw the
fourth. The digest is 36797ad6205e4801, the same as the fourth brain in
the table above. Because every brain costs exactly 378 draws, position in the stream is
arithmetic, and a creature born fourth gets the fourth brain whatever happened to the
three before it.
The saved brain file
Everything so far lives in memory and dies with the process. That is fine while a run is a few seconds of bench, and useless the moment a valley is stopped and started again, or a brain has to be looked at by something that is not this program. The row has to become a file.
One line of JSON per brain, appended, never rewritten. It is the same discipline the
world's event log has kept since it was first opened: a file made of lines can be
appended to safely, read one item at a time without holding the whole thing in memory,
counted with wc -l, and diffed. A single JSON document wrapping every brain
in one array has none of those properties and buys nothing in exchange.
What goes on the line is the row and the three sizes, and nothing else. Not the seed the brain was drawn from, because a seed is a recipe for a row only for as long as nothing has since touched the numbers, and a file that stores a recipe instead of the thing quietly stops being true the first time it is wrong. Not a version stamp, because the three sizes already say everything a reader has to check.
// internal/mind/file.go
package mind
import (
"bufio"
"encoding/json"
"fmt"
"io"
)
// Brain is one controller written down: the name it answers to, the
// three sizes it was built at, and its whole row of numbers. Nothing
// else is stored, because nothing else is the brain.
type Brain struct {
ID int `json:"id"`
In int `json:"in"`
Hid int `json:"hid"`
Out int `json:"out"`
W []float64 `json:"w"`
}
// Save writes brains one JSON object to a line, in the order they are
// handed over. A row whose length disagrees with the sizes beside it is
// refused here, where the caller still knows which brain it was.
func Save(w io.Writer, brains []Brain) error {
enc := json.NewEncoder(w)
for _, b := range brains {
if want := Weights(b.In, b.Hid, b.Out); len(b.W) != want {
return fmt.Errorf("save brain %d: %d numbers for a %d-%d-%d net, which needs %d",
b.ID, len(b.W), b.In, b.Hid, b.Out, want)
}
if err := enc.Encode(b); err != nil {
return fmt.Errorf("save brain %d: %w", b.ID, err)
}
}
return nil
}
// Load reads back what Save wrote, one line at a time, and checks every
// row against the sizes written beside it. The scanner is given a
// megabyte because one line here is thousands of numbers long and the
// default would stop reading in the middle of a brain.
func Load(r io.Reader) ([]Brain, error) {
var out []Brain
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for line := 1; sc.Scan(); line++ {
var b Brain
if err := json.Unmarshal(sc.Bytes(), &b); err != nil {
return nil, fmt.Errorf("brain file line %d: %w", line, err)
}
if want := Weights(b.In, b.Hid, b.Out); len(b.W) != want {
return nil, fmt.Errorf("brain file line %d: %d numbers for a %d-%d-%d net, which needs %d",
line, len(b.W), b.In, b.Hid, b.Out, want)
}
out = append(out, b)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("brain file: %w", err)
}
return out, nil
}
$ go run ./cmd/brains -mode file
brains: 4 of them written to brains.jsonl, 31040 bytes
the first line, cut off at 96 characters:
{"id":0,"in":24,"hid":12,"out":6,"w":[0.11373767798729006,0.15953021871777115,0.1650562778221637...
brain drawn read back same
0 a978c376398aa14e a978c376398aa14e true
1 a6ce5d024e4641ad a6ce5d024e4641ad true
2 c90a2c7c021463d2 c90a2c7c021463d2 true
3 36797ad6205e4801 36797ad6205e4801 true
4 of 4 rows survived the trip to disk with every bit intact
W[0] drawn 0.11373767798729006
W[0] read back 0.11373767798729006
The digests are the claim. They are taken over the raw eight bytes of every number in
order, not over the printed text, so two rows agreeing here have agreed bit for bit.
All four survived, and the reason is the seventeen digits in the last two lines:
encoding/json writes the shortest run of decimal digits that reads back as
exactly the same float64, and 0.11373767798729006 is that run for this number. It is
not a rounded version of the weight. It is the weight.
The size is honest about what the format costs. Four brains take 31,040 bytes, so one brain is 7,760, against the 3,024 bytes its 378 numbers occupy in memory. Text is about two and a half times the price of the numbers, and it buys a file a person can open, search, and compare line by line without a tool. For thousands of brains a run that trade stays reasonable; for millions it would not, and the digest is what would tell you when to change it.
There is an obvious improvement available here, and it is the one to run rather than argue about. Seventeen digits a number is unreadable. Four decimal places is what every table in this book prints, it lines up in columns, and a weight of 0.0660 is surely the same weight as 0.06595034566088936 for any purpose a creature has.
// cmd/brains/main.go — inside tidy: one brain written twice, the
// second time with every number put through -places decimals
n := brainAt(seed, which, in, hid, out)
round := mind.New(n.In, n.Hid, n.Out)
round.Squash = n.Squash
for i, w := range n.W {
s := fmt.Sprintf("%.*f", places, w)
var v float64
fmt.Sscanf(s, "%g", &v)
round.W[i] = v
}
$ go run ./cmd/brains -mode tidy -brain 11 -steps 200
brains: brain 11 written twice, once whole and once to 4 places
W[0] digest
as drawn 0.06595034566088936 0a9391b5cbf1a897
as written 0.066 8156971184afbceb
378 of the 378 numbers came back a different number
the largest move is W[82], -0.04145000774514353 to -0.0415, a gap of 5.00e-05
the 9 rays swept together in 201 steps, and the two brains asked at each:
rays as drawn reloaded margin reloaded
0.8650 walk walk 0.001817 0.001858
0.8700 walk walk 0.000905 0.000945
0.8750 right walk 0.000004 0.000036
0.8800 right right 0.000909 0.000869
0.8850 right right 0.001810 0.001771
the two brains disagreed on 1 of the 201 steps
only the steps where a winner's margin fell under 0.002 are printed
The symptom is the second digest. 0a9391b5cbf1a897 went in and
8156971184afbceb came out, so the brain that was loaded is not the brain
that was saved, and every run built on the loaded one has left the run that saved it.
The middle lines say how far: all 378 numbers moved, and the largest single move is
five hundred-thousandths, at position 82.
Five hundred-thousandths sounds like nothing, and the objection deserves an answer rather than a shrug. The answer is in the last block. Sweeping the nine ray distances in two hundred steps instead of ten finds the place where this brain changes its mind, at a ray reading of 0.8750, and there the winning score is ahead of the runner-up by four millionths. The rounding is more than ten times that gap. One of the two hundred and one steps comes out differently: the whole brain turns right, the reloaded brain walks, and the reloaded one is not even hesitant about it, holding walk ahead by nine times the margin the whole brain gave right. Every other step agrees, which is the trap. A fault that shows up in one case in two hundred is a fault that gets committed.
The general rule is short. A stored number is only as good as the smallest difference the program will act on. These six scores are compared with each other and nothing else, so there is no size below which a difference stops mattering; two scores can be as close as float64 allows and the action still turns on which is larger. Any rounding at all in the file puts a floor under the precision of the world and leaves you guessing where it will bite. Write the numbers whole, and let the file be ugly.
The missing target answer
Now the question this chapter has been circling. The weights are drawn and never improved. Why is there no training loop anywhere in these creatures, when training is the entire reason networks are interesting? The run below is the argument. It takes one drawn brain and a sensor row typed out by hand, holds every entry of that row still except the nine ray distances, and walks those from a plant pressed against the eye out to a valley with nothing in range.
// Where each reading sits in the sensor row, in the order the senses
// were fixed in: nine rays, two numbers to a ray, then the two slopes,
// then the store and the speed. This bench never reads a valley, so
// these are the only positions it needs.
const (
rays = 9 // vision rays in the fan
senses = 2*rays + 4 + 2 // and the whole row they open
wetX = 2 * rays // the moisture slope, going east
wetY = wetX + 1 // and going south
foodX = wetY + 1 // the standing-tissue slope, going east
foodY = foodX + 1 // and going south
storeAt = foodY + 1 // the store, as a share of full
speedAt = storeAt + 1 // the speed, as a share of top
)
// eye is where ray i's two numbers sit: how far it went at eye(i), and
// what it met at eye(i)+1.
func eye(i int) int { return 2 * i }
// The action table, in the order the six output scores are read in.
// The two turns are shortened here to keep a column narrow.
var actions = [6]string{"rest", "walk", "sprint", "left", "right", "bite"}
// The row every swept question below is asked with: twenty-four numbers
// typed here rather than read off a valley. A creature standing still
// with a quarter of a store, on ground that gets wetter and greener to
// the east, with nine rays that have met nothing at all.
func row() []float64 {
in := make([]float64, senses)
for i := range rays {
in[eye(i)] = 0.5
in[eye(i)+1] = 0.0
}
in[wetX], in[wetY] = 0.10, -0.05
in[foodX], in[foodY] = 0.30, 0.15
in[storeAt] = 0.24
in[speedAt] = 0.00
return in
}
// scores runs one sensor row through a brain and hands back the six
// numbers, the action that wins, and how far ahead of the runner-up it
// finished.
func scores(n mind.Net, in, hid, out []float64) (best int, margin float64) {
n.Forward(in, hid, out)
best, second := 0, 1
if out[1] > out[0] {
best, second = 1, 0
}
for i := 2; i < len(out); i++ {
switch {
case out[i] > out[best]:
best, second = i, best
case out[i] > out[second]:
second = i
}
}
return best, out[best] - out[second]
}
$ go run ./cmd/brains -mode sweep -brain 11 -what rays
brains: brain 11 off stream 14, digest 0a9391b5cbf1a897
one sensor row held still but for "rays", swept from 0.00 to 1.00
rays rest walk sprint left right bite picks margin
0.00 -0.1599 0.3069 -0.0597 -0.0350 0.1138 -0.1192 walk 0.1931
0.10 -0.1164 0.3108 -0.0695 -0.0617 0.1401 -0.1396 walk 0.1706
0.20 -0.0732 0.3139 -0.0783 -0.0887 0.1664 -0.1599 walk 0.1475
0.30 -0.0304 0.3164 -0.0862 -0.1158 0.1924 -0.1799 walk 0.1240
0.40 0.0116 0.3182 -0.0932 -0.1427 0.2177 -0.1995 walk 0.1006
0.50 0.0527 0.3195 -0.0994 -0.1692 0.2420 -0.2184 walk 0.0775
0.60 0.0926 0.3204 -0.1048 -0.1948 0.2651 -0.2365 walk 0.0552
0.70 0.1311 0.3208 -0.1095 -0.2195 0.2868 -0.2537 walk 0.0340
0.80 0.1681 0.3209 -0.1136 -0.2430 0.3069 -0.2698 walk 0.0140
0.90 0.2035 0.3207 -0.1172 -0.2652 0.3252 -0.2848 right 0.0045
1.00 0.2373 0.3204 -0.1203 -0.2859 0.3418 -0.2987 right 0.0214
2 of the 6 actions won a step, and the winner changed hands once
Every one of the six columns moves smoothly. Not one of them jumps, and the column that
wins most of the table barely moves at all: walk travels from 0.3069 to
0.3209 and turns back down again, a span of fourteen thousandths across the whole
sweep. The picks column does something else. It holds at walk
for nine rows, and on the tenth it becomes right and stays. Look at the
margin beside it. At 0.80 the winner is ahead by 0.0140; one row later the winner is a
different action, ahead by 0.0045. The behaviour did not bend. It switched.
That switch is the first of three things standing between this world and a gradient. A gradient is a statement about slope: change this weight by a very small amount and the thing you care about changes by so much, in this direction. The thing cared about here is which action the creature takes, and the last piece of the pipeline that produces it keeps the largest of the six scores and throws the rest away. Keeping a largest has no slope. Nudge a weight and either the same action wins by a slightly different margin, in which case nothing changed, or a different action wins, in which case everything did. There is no small change in behaviour to divide by a small change in weight.
The second obstacle is worse and simpler. To measure how wrong an output was you must
know what the right output was. Nobody knows. There is no table saying that on tick 4,102
a browser standing on wet soil with a stand two cells to its left and a store at 0.62
should have scored walk at 0.71. Such a table would be a hand-written policy
copied into six numbers, and a network trained to imitate a hand-written policy is a slow
way to run the hand-written policy. The interesting behaviour is exactly the behaviour
nobody can write down.
The third is that the only honest measure of a creature is separated from its weights by
the entire world. Whether a row of 378 numbers was any good is answered by whether the
creature carrying it was still standing at the end of the year, and between the weights
and that answer sit thousands of ticks, a valley of plants growing and being eaten, a
calendar, other creatures, and a lot of luck. There is no chain of multiplications
running from the answer back to W[82].
Selection asks for none of that. It needs an ordering: some way to say this brain did
better than that one, which the store of energy in a living creature supplies for free.
And it needs a copy: some way to make a new brain out of an old one, which is a single
copy of 378 float64s. It never asks which weight was responsible, never
needs a target, and never has to differentiate anything. The price is that it learns far
more slowly than a gradient would, and it pays that price in a currency this world has in
quantity: ticks nobody is watching.
One number gives that trade its scale. A brain thinking once costs 360 multiplications, and three thousand of them cost 1,080,000 a tick. Computing a gradient means walking the network backwards afterwards, touching all 378 weights again for every creature, on top of a forward pass the tick is already paying for. Selection adds nothing per tick at all. Its cost lands somewhere else entirely, and nowhere inside the 50 milliseconds a tick has to spend on creatures.
Why selection treats the row whole
Strip the network out and a pattern is left that turns up whenever the thing judging a design sits outside the design. If every judgement is made on the whole candidate, then the parts never have to be addressed by name, and the representation should be as featureless as it can be: a fixed-length block of numbers, laid out by arithmetic, with no pointers, no nesting, and no invariants that a copier has to know about. Copying is one call. Comparing is one loop. Writing it down is one line. Altering it is picking a position and putting a different number there.
Compare that with a representation carrying structure: a tree of neurons, a graph of connections held together by pointers. Every one of those operations becomes a piece of code that has to understand the structure, and every one is a place to get it wrong. This valley already made the same choice twice without calling attention to it. The picture on screen is one flat block of pixels addressed by row times width plus column. The soil under the valley is two numbers a cell in one flat block, which is what lets a whole terrarium be folded into a digest in a few lines. A brain is the third.
Laid-out-by-arithmetic carries an obligation, and this chapter is the place it shows.
The arithmetic has to live in exactly one function, and every reader of the block has to
go through that function, however tempting it is to write In*Hid again in
the file you happen to be in. Two copies of a layout agree perfectly until the day one
of them is edited.
There is a standard in the file half of it as well, and it is not about neural networks at all. When you save state that a program will act on, the question to ask is not "how many digits look sensible" but "what is the smallest difference this program can behave differently on". For a score compared only against other scores, the answer is: any difference at all. So the file keeps everything, and the check that it kept everything is a digest over the bits, not an eyeball over the text.
- Given a network of 24 inputs, 12 middle neurons and 6 outputs, produce 378 from the four multiplications, and say which positions hold the biases without looking anything up.
- Handed a middle layer of 20 neurons instead of 12, state the new offsets — 480, 500, 620 — and the new total of 626, and name the four methods the bench asked to print them.
- Say why a weight is divided by the square root of its neuron's fan, predict a usual sum of 2.8868 for twenty-four inputs drawn without that division, and name what tanh does to a number that size.
- Explain why
Drawtakes exactly 378 numbers off the stream even though the biases could be started at zero, in terms of what the tenth creature born in a run receives. - Shown a saved-and-reloaded brain whose digest changed, check the printed precision of the file before suspecting the loader.
- Give the three reasons no gradient is computed here, and say what selection needs instead of a target and a slope.
Exercise 1 — move the middle layer. Run
go run ./cmd/brains -mode row -hidden 20. Predict all four offsets and
the total before you look, then predict what happens to the digests in
-mode draw -hidden 20.
Hidden weights become 24 × 20 = 480 and start at 0. Hidden biases are 20 and
start at 480. Output weights become 20 × 6 = 120 and start at 500. Output
biases are 6 and start at 620, and the brain is 626 numbers long. Not a line of the
bench had to be edited to print any of that, because it prints what the four
methods answer. The digests in -mode draw all change, and not only
because the row is longer: a brain now costs 626 draws instead of 378, so brain 1
starts at a different place in stream 14 and is a different brain even in the
positions the two layouts share.
Exercise 2 — find the precision that is safe. Run
-mode tidy -brain 11 -steps 200 at -places 5, then 6, then
8, then 10, and find the first setting at which the digest still changes but the two
brains never disagree. Then argue about whether that setting is safe.
Five places is already enough: the largest move drops from 5.00e-05 to 4.97e-06 and the two brains agree on all 201 steps, while the digest keeps changing at six, at eight, and at ten. It is easy to find a number of places those 201 steps agree at, because 201 steps sample a very small part of what a creature will actually meet. The argument is the point. Somewhere in a thousand years of ticks a decision will be made on a margin smaller than whatever floor you install, and the run where it happens will not announce itself. The digest changing is the honest signal, and it changes at every setting short of the whole number. Nothing below "keep all of it" can be defended, only got away with.
Exercise 3 — make a deaf layer on purpose. Using the fan
table's left half, work out what fan would put the usual sum at 10 for weights drawn
between −1 and 1, then run -mode fan and read the pinned column at
that size. Then say what a creature with such a middle layer would do.
A usual sum of 10 needs √((f+1) ÷ 3) = 10, so
f + 1 = 300 and the fan is 299. The table brackets it: at 96 inputs the
predicted sum is 5.686 and 64% of the layer is pinned, and at 384 it is 11.328 and
82% is pinned. A layer that far gone reports almost the same twelve numbers for
every sensor row it is ever shown, so the six output scores are almost the same six
numbers too, and the creature picks the same action in every situation it will ever
be in. It is the zero-weight brain from the first page of this chapter with extra
weights: it has senses, it reads them, and its behaviour does not depend on them.
A brain now exists, it is reproducible from a seed, and it can be put on disk and brought back exactly. What it has never been given is a real sensor row. The twenty-four numbers the bench swept were typed into a Go file by hand, and the six scores it printed were read by a table of labels rather than by anything that can charge a store or take a gram off a stand. Both ends are still loose: the senses that fill the row belong to a creature standing on a cell in The Hollow, and the six numbers have to become one legal, paid-for action.