Jim Wright

Discussing all things around software engineering.

Mutating the parameters

Posted on
Reading time 6 minutes


The four mutations that only change numbers, and the one that would have frozen every weight's sign for the whole run

Mutation comes in two halves, and they are kept apart deliberately.

func (b *Breeder) mutateGenome(genome Genome) Genome {
	return b.mutateStructure(b.mutateParameters(genome))
}

This post is the first half: the mutations that change existing genes without adding or removing any.

Why the split

// mutateParameters applies the mutations that only change existing genes.
//
// Nothing here allocates a historical marking, so these can run for many
// genomes at once. They deliberately come before any structural mutation: a
// node added this generation should start from the weights its split gave it,
// not be perturbed again on the way out.
func (b *Breeder) mutateParameters(genome Genome) Genome {
	genome = b.mutateNodeBiases(genome)
	genome = b.mutateNodeActivations(genome)
	genome = b.mutateConnectionWeights(genome)
	genome = b.mutateToggleEnabled(genome)
	return genome
}

Two reasons, and the first is the one that matters for the shape of the code.

Nothing here touches the innovation registry, so these can run on as many goroutines as there are cores. The structural half cannot, because the marking a genome gets has to be the same on every run and racing for those would make the gene numbering depend on which goroutine won.

The second is about the algorithm. A node added by a split starts with carefully chosen weights - that is the next post - and perturbing them on the way out of the same mutation pass would undo the care.

Weights

func (b *Breeder) mutateConnectionWeights(genome Genome) Genome {
	cfg, rng := b.cfg, b.rng
	for i, connection := range genome.Connections {
		if !cfg.Chance(rng, cfg.WeightMutationRate) {
			continue
		}
		if cfg.Chance(rng, cfg.WeightReplaceRate) {
			genome.Connections[i].Weight = cfg.RandWeight(rng)
			continue
		}
		// An additive gaussian step. A multiplicative one can never cross zero
		// and can never move a weight that is already zero, which quietly
		// freezes the sign of every weight for the whole run.
		genome.Connections[i].Weight = cfg.PerturbWeight(rng, connection.Weight)
	}
	return genome
}

Every connection, every offspring, with an 80% chance each. Nine times in ten that is a small step; one time in ten the weight is thrown away and drawn afresh.

The comment in the middle is a bug I actually shipped. Scaling a weight by a random factor looks like a perfectly reasonable perturbation, and it is a trap: w * 1.1 and w * 0.9 are both the same sign as w, so a weight that starts negative is negative forever, and a weight that is exactly zero stays zero no matter how many times it is mutated. Half the search space is unreachable and nothing about the run looks wrong - it just never quite works.

The step itself is gaussian and clamped:

// RandWeight draws a fresh connection weight.
func (c Config) RandWeight(rng *Rand) float64 {
	return util.Clamp(util.Gaussian(rng)*c.WeightInitStdDev, c.MinWeight, c.MaxWeight)
}

// PerturbWeight nudges an existing connection weight.
func (c Config) PerturbWeight(rng *Rand, weight float64) float64 {
	return util.Clamp(weight+util.Gaussian(rng)*c.WeightMutationPower, c.MinWeight, c.MaxWeight)
}

Two separate scales, and they are not the same number for a reason. WeightInitStdDev is how wide a fresh weight is drawn; WeightMutationPower is how far an existing one moves. A perturbation as wide as the initial draw is not a perturbation, it is a replacement with extra steps.

replace (1 in 10)         perturb (9 in 10)

     .-'''-.                    w
   .'       '.                  |
  /           \              .-'|'-.
 -8     0     8             -----+-----
                               small step

Biases

The same shape, on nodes instead of connections:

func (b *Breeder) mutateNodeBiases(genome Genome) Genome {
	cfg, rng := b.cfg, b.rng
	for i, layer := range genome.Layers {
		for j, node := range layer {
			// Input and bias nodes have no learnable bias.
			if !mutableNode(node) {
				continue
			}
			if !cfg.Chance(rng, cfg.BiasMutationRate) {
				continue
			}
			if cfg.Chance(rng, cfg.BiasReplaceRate) {
				genome.Layers[i][j].Bias = cfg.RandBias(rng)
				continue
			}
			genome.Layers[i][j].Bias = cfg.PerturbBias(rng, node.Bias)
		}
	}
	return genome
}

mutableNode is the guard from the genome post. An input node is a sensor, and a sensor with a bias is a sensor that lies.

Activation functions

func (b *Breeder) mutateNodeActivations(genome Genome) Genome {
	cfg, rng := b.cfg, b.rng
	for i, layer := range genome.Layers {
		for j, node := range layer {
			// Only hidden nodes. Input and bias nodes are plain signal
			// sources, and the output activation is fixed by the config
			// because it defines the range the caller reads results in.
			if node.Type != network.Hidden {
				continue
			}
			if !cfg.Chance(rng, cfg.ActivationMutationRate) {
				continue
			}
			genome.Layers[i][j].ActivationFn = network.RandomActivationFunction(rng, cfg.HiddenActivationFns...)
		}
	}
	return genome
}

Two decisions here, and both are about not surprising the caller.

The output activation never mutates. It is fixed by the config, because it decides the range results come back in. If your evaluator reads an output as a probability and evolution swaps the sigmoid for cube, nothing errors - the numbers just stop meaning what you built the evaluator around.

ActivationMutationRate defaults to zero. Mutating activations is a real technique and it is off unless you ask for it. It multiplies the search space by the size of the function set, and on most problems a run does better spending those generations on weights.

Even when it is on, the choices are narrow by default:

// A small, well behaved default. Handing hidden nodes the whole
// registry (exp, inv, log, cube, ...) makes the search wander through
// wildly scaled functions and is rarely what you want.
HiddenActivationFns: []network.ActivationFunctionName{
	network.Sigmoid,
	network.Tanh,
	network.Relu,
},

Switching a connection off

func (b *Breeder) mutateToggleEnabled(genome Genome) Genome {
	cfg, rng := b.cfg, b.rng
	for i, connection := range genome.Connections {
		if !cfg.Chance(rng, cfg.EnabledMutationRate) {
			continue
		}
		genome.Connections[i].Enabled = !connection.Enabled
	}
	return genome
}

One per cent, and it goes both ways. A disabled gene keeps its marking and keeps lining up under crossover, so this is not deletion - it is a switch that a lineage can flip back.

It is deliberately the rarest thing in this post. At a high rate it is not exploration, it is vandalism: connections wink in and out under a fitness function that has no way to reward the change, because the same genome scored differently last generation for reasons of its own.

The rates, together

BiasMutationRate:       .7,
BiasMutationPower:      .5,
BiasReplaceRate:        .1,
ActivationMutationRate: 0,

WeightMutationRate:     .8,
WeightMutationPower:    .5,
WeightReplaceRate:      .1,
EnabledMutationRate:    .01,

Read as a group they say something: nearly every gene moves a little, nearly every generation. That is the opposite of the structural mutations, which are rare on purpose, and it is the division of labour NEAT is built on. Parameters are searched constantly; structure is added slowly and given time to prove itself.

Next: the half that adds and removes genes.

If you found this interesting...

You might like to read the rest of Writing NEAT in GO