Evolve Rules for Desired Behaviour
Cellular Automata From First Principles 25: Evolve Rules for Desired Behaviour
Once we can represent a rule, mutate it and measure its behavior, we can evolve cellular automata.
The key idea is simple:
population of rules
↓
simulate
↓
measure
↓
select
↓
mutate
↓
repeat
This chapter builds that loop without hiding it behind an optimization framework.
Represent a rule as bits
For an elementary CA, eight output bits completely define the rule:
import numpy as np
def rule_bits(rule_number):
return np.array(
[int(bit) for bit in f"{rule_number:08b}"],
dtype=np.uint8,
)
For larger rules, the same idea still works: the genome is simply larger.
Define a target behavior
Suppose we want sustained activity without saturation.
A simple fitness function might reward:
- density near 0.5,
- persistent change,
- and non-trivial entropy.
def fitness(metrics):
density_term = 1.0 - abs(metrics["mean_density"] - 0.5)
activity_term = metrics["tail_activity"]
entropy_term = metrics["mean_entropy"]
return (
0.35 * density_term
+ 0.35 * activity_term
+ 0.30 * entropy_term
)
This objective is not “the definition of complexity.”
It is merely a design goal.
Mutation
def mutate(genome, rng, rate=0.05):
child = genome.copy()
mask = rng.random(len(child)) < rate
child[mask] ^= 1
return child
Mutation creates nearby rules.
Selection
Keep the strongest candidates:
def select(evaluated, survivors):
evaluated = sorted(
evaluated,
key=lambda item: item[0],
reverse=True,
)
return [genome for score, genome in evaluated[:survivors]]
Then refill the population with mutated offspring.
A complete evolutionary loop
def evolve(initial_population, evaluate_genome, generations=50, survivors=8, seed=42):
rng = np.random.default_rng(seed)
population = [g.copy() for g in initial_population]
for generation in range(generations):
evaluated = []
for genome in population:
metrics = evaluate_genome(genome)
evaluated.append((fitness(metrics), genome))
parents = select(evaluated, survivors)
best_score = max(score for score, _ in evaluated)
print(generation, best_score)
next_population = [p.copy() for p in parents]
while len(next_population) < len(population):
parent = parents[rng.integers(len(parents))]
next_population.append(mutate(parent, rng))
population = next_population
return population
The algorithm is small because most of the intellectual work happened earlier:
representation
measurement
fitness
Evaluate across several worlds
Never optimize against one initial condition if you want general behavior.
def robust_fitness(genome, seeds):
scores = []
for seed in seeds:
history = run_genome(genome, seed=seed)
scores.append(fitness(fingerprint(history)))
return float(np.mean(scores))
You can also penalize variance:
return np.mean(scores) - 0.25 * np.std(scores)
Now rules are rewarded for working consistently rather than getting lucky once.
Watch for objective hacking
Optimization finds loopholes.
If we reward activity alone, a rule that flips every bit every step may score extremely well.
If we reward entropy alone, noise-like behavior may dominate.
This is a useful lesson that reaches far beyond cellular automata:
An optimizer will satisfy the measurement you gave it, not the intention you had in mind.
Use several measurements, inspect winners and test them out of distribution.
Preserve diversity
Selecting only the highest score can collapse the population around one family of similar rules.
One simple improvement is to combine fitness and novelty:
combined = 0.8 * normalized_fitness + 0.2 * normalized_novelty
Or maintain several behavior niches separately.
This lets evolution explore instead of only climbing one local hill.
Store lineage
Keep parent-child relationships:
record = {
"generation": generation,
"genome": genome.tolist(),
"parent": parent_id,
"fitness": score,
"metrics": metrics,
}
Then an evolved rule is not a mysterious final artifact.
We can reconstruct how it emerged.
From evolved rules to learned rules
Evolution changes the rule between simulations.
Later, neural cellular automata will use gradient descent to learn parameters of the local rule.
The optimization mechanism changes, but the surrounding experimental architecture remains recognizable:
parameterized local rule
↓
simulation
↓
objective
↓
update rule parameters
Before we get there, we need one more conceptual piece.
Cellular automata are not only simulations. Some of them can perform computation.
The next chapter looks at information processing, universality and how to think about a cellular automaton as a computer.