Genetic Programming

Genetic programming (GP) is evolutionary search over programs. Ordinary machine learning fits numbers inside a model whose shape you choose. Genetic algorithms evolve fixed-length parameter vectors. GP goes one level up: it evolves the structure itself. The individual is a tree-shaped program, the training signal is a set of fitness cases (input/output examples of the behavior you want), and the operators of evolution build new programs out of parts of old ones.

John Koza established the field in the early 1990s with the observation that, given a function set, a terminal set, and a behavioral score, evolution discovers not only the coefficients of a formula but the formula. Three decades of later work refined the machinery (typed trees, linear representation, grammar-guided GP, geometric semantic crossover), but the core loop is small enough to implement in a few hundred lines of Python, which is exactly what you will do in this chapter.

We build everything from first principles, with no NumPy and no GP framework, and apply the same engine to four different problems:

Example Evolved object Fitness cases
1. Symbolic regression math expression 40 noisy (x, y) samples
2. Boolean logic synthesis gate netlist 32-row truth table
3. Regex synthesis pattern program 8 positive + 11 negative strings
4. L-system plant growth rule cell grid of a target plant

Run time for each demo is under three seconds on a laptop, and every run is seeded and reproducible.

This chapter assumes no prior exposure to genetic programming. We start with the ideas and the words, then build the engine, then run four complete problems with it. If you already know the field, skip ahead to “A tiny engine: gp_core.py”; everything before that section is background.

Where GP fits

Machine learning usually answers two questions in order:

  1. What shape should the model have? A line, a polynomial, a decision tree, a neural network.
  2. What numbers should that shape use? Coefficients, weights, thresholds.

Ordinary fitting answers only the second question. You choose the shape by hand, then a solver finds the numbers. It works spectacularly well when you already know the shape.

Genetic programming attacks the first question. The model is the program, and evolution finds both its structure and whatever constants it contains. Nothing is differentiated. The training signal is a score you compute by running each candidate on examples, so any behavior you can simulate can be optimized, whether or not it is smooth or continuous.

That matters most when the shape is the hard part:

  • You have data and no idea what function produced it.
  • You can simulate what you want to build, but you cannot write a differentiable loss for it.
  • The answer has to be readable by a person, not a matrix of weights.
  • The output is naturally discrete: a rule, a circuit, a regular expression, a schedule, a controller.

The price is that GP is a search, and the space it searches is astronomically large. Consider the simplest possible setup: four binary operators and two terminals. A perfect binary tree four levels deep has seven internal nodes and eight leaves, so there are 4^7 * 2^8, about 4.2 million, distinct programs of exactly that shape. At six levels the same count passes 10^28. GP also has to consider every irregular shape up to its depth limit, and there is no list to iterate through: it samples, scores, and breeds.

The three ingredients

Every evolutionary algorithm, GP included, needs three things:

  • Variation. Something must create new candidates (random initialization, crossover, mutation).
  • Heredity. Offspring must resemble their parents, or progress cannot accumulate (a tree is copied, then edited).
  • Selection. Something must favor better candidates, or the population wanders (fitness, tournaments, elitism).

Remove any one of the three and the loop stops being evolution. Random search has variation but no heredity. Hill climbing has selection and heredity but almost no variation. GP has all three, applied to programs.

How GP differs from its neighbors

Method What is optimized Uses gradients Typical output
Least squares / logistic regression coefficients yes numbers
Neural network training weights yes (backpropagation) numbers
Genetic algorithm a fixed-length vector no numbers
Genetic programming program structure no a readable program
Bayesian optimization a few continuous settings no numbers
Random or grid search whatever you define no a sampled configuration
Large language model text, from pretraining no (pretraining only) code or prose

The last row deserves a sentence, because readers today will ask. A language model can propose a program from a description, but it has no loop that measures that program against your objective and improves it. GP has exactly that loop and no idea what your problem means. The two compose well: use a model to suggest a function set or seed the population, and let GP optimize against measurements. We return to that in “What GP is good at, and when not to reach for it”.

The vocabulary of evolution

GP borrows its words from biology. The mapping is loose but useful, and every GP paper and library uses these terms.

Word Meaning in GP In this chapter
individual, program one candidate solution one Tree
population the individuals alive at one time a list[Tree]
generation one round of selection and reproduction one pass of the evolve() loop
genotype the representation that is copied and varied the tree itself
phenotype the behavior the genotype produces the formula, circuit, regex, or plant
gene, node one part of the genotype one Tree node
fitness a number saying how well an individual solves the problem the float that evolve() minimizes
fitness function the procedure that computes fitness the callable passed to evolve()
fitness case one test input, with its expected output a data point, truth-table row, or example string
selection choosing which individuals reproduce tournament()
parent, offspring who is copied, and the new individuals made from them arguments and return values of crossover()
crossover, recombination mixing parts of two parents crossover()
mutation a random change to one individual mutate_subtree, mutate_point, mutate_hoist
elitism copying the best individuals forward unchanged the elitism= argument
diversity how different the individuals are not measured here; it is what selection pressure trades away
convergence the population becoming similar visible as a flat history
premature convergence settling on a mediocre answer example 1’s 37 stagnant generations
bloat programs growing without getting better fought with PARSIMONY, depth caps, and hoist mutation

The one distinction to internalize is genotype versus phenotype. In example 4 the genotype is a rule tree of a few dozen nodes and the phenotype is a plant drawn on a 61 by 31 grid. Two different genotypes can produce the same phenotype, and the evolved rule and the hidden rule in that chapter draw exactly the same plant. Selection only ever sees the phenotype.

A note on signs: here “fitness” is a score we minimize, so lower is better. Many papers and libraries maximize it, so always check the convention before comparing numbers.

The shortest history of evolving programs

None of this is new. People have been trying to make computers evolve code since the 1950s.

Before the name

  • In the 1950s, Nils Aall Barricelli ran some of the first computer simulations of evolution, letting numbers reproduce and compete on early machines.
  • In 1957, A. S. Fraser simulated genetic systems on a digital computer, and in 1958 Richard Friedberg tried to evolve small programs by random mutation and selection. Both were ahead of their time and were largely forgotten for decades.
  • In 1962, Hans-Joachim Bremermann wrote about optimization through evolution and recombination, the idea that a population of candidate solutions can search better than a single point.
  • Through the 1960s a parallel thread appeared in Germany: evolution strategies, from Ingo Rechenberg and Hans-Paul Schwefel, tuned real-valued engineering parameters by mutation and selection.

Three classical branches

By the 1970s evolutionary computation had three schools that developed largely independently:

  • Genetic algorithms, from John Holland’s 1975 book Adaptation in Natural and Artificial Systems. Binary, fixed-length strings; crossover was the star; the theory was built around schemata and building blocks. GP grew out of this branch.
  • Evolution strategies, from Rechenberg and Schwefel: real vectors, self-adapting mutation step sizes, strong selection.
  • Evolutionary programming, from Lawrence Fogel, Alvin Owens and Michael Walsh (1966): finite-state machines evolved by mutation, with no crossover at first.

The three differed in representation, in which operator they treated as primary, and in how much theory they carried, but they were running the same loop. Today “evolutionary computation” is the umbrella term for all of it, and researchers borrow freely across the branches.

From programs to genetic programming

In 1985, N. Michael Cramer published a representation for evolving simple sequential programs as trees, which is recognizably modern GP. The idea did not spread widely at the time.

In 1992, John Koza published Genetic Programming: On the Programming of Computers by Means of Natural Selection, and the field took off. Koza’s contribution was not one algorithm but a whole program of work:

  • a clear tree representation with full, grow, and ramped half-and-half initialization;
  • the operator mix of subtree crossover and mutation that the field still uses;
  • a catalogue of benchmark problems, from symbolic regression (recovering the quartic polynomial x^4 + x^3 + x^2 + x) and Boolean multiplexers to the “artificial ant” that follows the Santa Fe trail;
  • and, in later books (Genetic Programming II in 1994, III in 1999, and IV in 2003), the argument that GP routinely produces human-competitive results, including designs that won patents.

The infrastructure followed. The first European Workshop on Genetic Programming (EuroGP) was held in Paris in 1998, and GECCO, the main evolutionary computation conference, dates from the same period. Textbooks by Banzhaf, Nordin, Keller and Francone (1998) and Langdon and Poli (2002) turned the subject into a curriculum, and the free Field Guide to Genetic Programming (Poli, Langdon and McPhee, 2008) became the standard short introduction.

Where the field is now

Three decades of work added representations (linear programs, grammars, graphs, stacks), theory (why bloat happens, when GP can be expected to converge), and engineering (strongly typed nodes, semantic operators, multi-objective selection, GPU evaluation). Symbolic regression, GP’s oldest application, had a public moment in 2009, when Michael Schmidt and Hod Lipson used it to rediscover physical laws from measurements. Today the standard tools are libraries such as DEAP and gplearn, and a growing line of work uses large language models to propose programs or seed populations for GP to optimize. The loop itself has not changed.

Year Milestone
1950s Early evolutionary simulations (Barricelli)
1957-1958 First attempts to evolve programs (Fraser, Friedberg)
1962 Evolution and recombination as optimization (Bremermann)
1960s Evolution strategies (Rechenberg, Schwefel) and evolutionary programming (Fogel, Owens, Walsh)
1975 Genetic algorithms (Holland)
1985 Tree-shaped programs evolved (Cramer)
1992 Genetic programming named and popularized (Koza)
1994 Genetic Programming II (reusable subroutines); linear GP
1998 First EuroGP; grammatical evolution
1999-2003 Genetic Programming III and IV; human-competitive results
2000s Bloat theory; Cartesian, Push, and gene expression GP
2009 Symbolic regression rediscovers physical laws (Schmidt and Lipson)
2010s Semantic GP, multi-objective GP, mature Python libraries
2020s GP combined with machine learning and language models

Why trees?

A GP individual is an ordered rooted tree. Internal nodes are functions (with a fixed arity), leaves are terminals (variables and constants). This single choice of representation drives almost everything else in the field, so it is worth understanding in detail.

A tree is a program

We write trees in prefix notation: a function name followed by its arguments. The tree

1             add
2           /     \
3         mul      sin
4        /   \       \
5       x    2.0      x

is written (add (mul x 2.0) (sin x)), which means x * 2.0 + sin(x). Prefix form removes two endless sources of bugs: there are no precedence rules to remember, and no parentheses to balance when you splice subtrees. The arity of each function decides the shape, so a well-formed tree is unambiguous by construction, and a subtree is always a complete expression on its own.

Two numbers describe a tree’s size. Size is the number of nodes; the tree above has size 7. Depth (also called height) is the longest root-to-leaf path counted in nodes; the tree above has depth 3, from add down to mul and then x. Every practical GP run limits at least one of these, or programs grow without bound.

The properties that make trees work

  1. Every subtree is a program. You can cut a subtree from one tree and graft it into another at any node, and the offspring is guaranteed to be a syntactically valid program of the same species. A crossover of two floating-point vectors has no such guarantee; a crossover of two expression trees does. This is why GP can reuse genetic algorithms’ operators unchanged.
  2. Modularity. Subtrees compute reusable chunks (sin(x + x) appears inside many better solutions). Selection can spread a good subtree through the population the way a good schema spreads in a genetic algorithm.

Closure and sufficiency

Two properties of the function and terminal sets decide whether GP can solve a problem at all:

  • Closure. Every function must accept, as arguments, every value that any terminal or function can produce. If div raises on zero, or sqrt raises on a negative, one random tree can crash an entire generation. The standard fix is a protected operator: division that returns a safe value instead of raising, as in example 1. A function set that is not closed forces you to write error handling into every fitness function, and error handling inside a search loop is where subtle bugs live.
  • Sufficiency. The sets must be rich enough to express a solution. No amount of evolution will find exp in a function set that does not contain it. Example 1 proves the point by omitting exp and settling for a structural approximation of the damped oscillator.

Those two words come up in every serious GP design review: “is the primitive set closed, and is it sufficient?” If the answer to either is no, fix the sets before you tune anything else.

A third property is a matter of taste rather than correctness. GP does not care whether the functions are arithmetic, Boolean, string operations, or simulation calls. Mixing types (numbers and Booleans in the same tree) does need a typed representation, which is why strongly typed GP exists; see “The wider family of GP methods”.

There is a price for all of this, discussed later: trees can grow. Useless code costs nothing to keep, so GP populations tend toward bloat unless you apply pressure against size.

The GP loop

Generational GP looks like this:

  1. Create a random population of N trees over the function set F and terminal set T.
  2. Evaluate every tree on the fitness cases and compute a scalar fitness (we will minimize throughout).
  3. Repeat for the generation budget: a. Select parents by tournament: draw k individuals uniformly at random, keep the best. b. With high probability apply subtree crossover: pick one random node in each parent, swap the subtrees rooted at those nodes. Otherwise clone a parent. c. With small probability apply a mutation: replace a random subtree with a fresh random tree (subtree mutation), swap a node for another node of the same type class (point mutation), or replace a node by one of its own descendants (hoist mutation). d. Optionally copy the best few individuals into the next generation unchanged (elitism). e. Evaluate the children.

Two design choices deserve names because you will meet them in every GP paper:

  • Initialization, full vs grow. How the first random trees are shaped. There are four standard methods, described next.
  • Fitness cases, not objectives. GP does not optimize a model’s parameters against a loss function you differentiated. It executes each candidate program on example inputs and scores the behavior. That is why GP applies to problems where no differentiable model exists at all: robot controllers, trading heuristics, regular expressions, growth rules.

Initialization: building the first generation

GP starts from random programs, so the initialization method shapes the first generation and the diversity available to selection.

  • Full. Grow every branch to exactly the depth limit D, then place terminals. The result is a perfect tree: bushy, uniform, and often full of equivalent structures.
  • Grow. At each node, choose a function or a terminal. Branches end at their own depths, so the trees are irregular.
  • Half-and-half. Flip a coin per tree between full and grow. This is Koza’s default, and it is what gp_core.py does when you pass method="half".
  • Ramped half-and-half. Koza’s refinement: choose a different depth limit for each tree, spread across the range from 2 to D. The ramp gives the population both tiny and large programs, which matters because a good solution may be shallow.

Population size is a budget decision. This chapter uses 200 to 400 individuals; production runs commonly use thousands, and larger populations buy diversity at the cost of one fitness evaluation per individual per generation. If a run converges too fast, a bigger population is often more effective than a smaller tournament.

The reproduction cycle

A generation is not one operation but a small pipeline:

  1. Rank the population by fitness.
  2. Copy the elite individuals forward unchanged.
  3. Fill the remaining slots by selecting two parents and applying crossover with high probability (typically 0.8 to 0.95), or cloning one parent otherwise.
  4. Apply mutation to the child with low probability (typically 0.01 to 0.2).
  5. Evaluate the children and repeat.

Crossover is the primary operator because it combines working parts. Mutation is the backup: it supplies material that crossover cannot invent, and in later generations it is often the only operator still making progress once the population has converged. If a run stalls and the best fitness has not changed for many generations, raising the mutation rate or lowering the tournament size (both add variation) is usually the first thing to try.

One generation by hand

A generation is easier to understand with a tiny example. Suppose the target is y = 2x, scored on two fitness cases: x = 1 (want 2) and x = 2 (want 4). Fitness is the total absolute error, so 0 is perfect. The function set is {add, mul} and the terminals are {x, 1}. Start with four individuals:

Individual Formula x=1 x=2 Fitness
P1 (add x x) 2 4 0
P2 (mul x x) 1 4 1
P3 (add x 1) 2 3 1
P4 x 1 2 3

Now run one generation:

  1. Elitism. Copy P1, the best, into the next generation unchanged. The best fitness found so far can never be lost.
  2. Select. Hold a tournament for each remaining slot: draw two individuals at random, keep the fitter. Suppose the first tournament draws P1 and P3. P1 wins, because 0 beats 1.
  3. Crossover. Take P1 and P3 and swap one randomly chosen subtree. If the second argument of each is chosen, the x from P1 trades places with the 1 from P3. The children are (add x 1), fitness 1, and (add x x), fitness 0. Notice what happened: the perfect frame (add x _) from P3 and the variable x from P1 recombined into the perfect program. Crossover moved a building block; it did not invent one.
  4. Mutate. With low probability, change one node in a child. Point-mutating P4’s leaf x into 1 gives the constant program 1, fitness |1 - 2| + |1 - 4| = 4, which selection discards. Most mutations are neutral or harmful; occasionally one adds something new.

After a few generations the population fills with programs that compute 2x in several different ways. That redundancy is useful: it is the raw material for the next improvement. When the population collapses to copies of one program, the run can only drift. The engine in the next section automates every step above.

Selection: choosing who reproduces

Selection converts fitness into reproductive opportunity, and its strength is the main dial on the whole search. Too little and the population wanders; too much and it converges on the first decent answer and stops exploring. A few standard schemes:

  • Fitness-proportionate (roulette wheel). Each individual gets a slice of the wheel proportional to its fitness. It is simple, but sensitive to scale, it needs nonnegative fitness, and it loses its grip when all fitnesses are nearly equal.
  • Rank selection. Sort by fitness and assign probabilities by rank, not by raw value. This removes the scale problem.
  • Tournament selection. Draw k individuals at random and keep the best. This is the default in most modern GP systems, including gp_core.py, because it needs no scaling, tolerates negative fitness, and is trivial to implement. The tournament size k is a direct selection-pressure dial: k = 2 is mild, k = 7 is strong, and k equal to the population size is nearly greedy.
  • Truncation. Keep only the top fraction. Very strong pressure, useful for quick experiments and dangerous for diversity.
  • Lexicase and epsilon-lexicase. Modern methods for problems with many test cases: each parent is chosen by filtering candidates on randomly ordered cases. They are excellent when no single scalar fitness captures the problem, as in program synthesis with dozens of unit tests.

A useful intuition: with tournament size k, the best individual in a random group of k wins, so larger groups strongly favor the top of the population. In this chapter tournament sizes range from 5 to 7. That is high pressure, which is why example 1 converges by generation 2 and then coasts.

Elitism is a separate, gentler kind of pressure: copy the best e individuals forward unchanged. It guarantees that the reported best-so-far never gets worse, which makes logs and stopping rules simpler. The cost is that it can slow the removal of bad building blocks. One or two elites in a population of a few hundred is typical.

Selection pressure interacts with population size. A large population under strong pressure still holds diversity for a while; a small population under strong pressure collapses in a few generations. If you cannot afford a large population, lower the pressure.

Fitness: what GP is actually optimizing

Fitness is the specification. The function set, the operators, and the parameters are all secondary; if the fitness function is wrong, the run will find a way to exploit it, and the exploit is what you will see at the end.

Good fitness functions share four properties:

  • Defined for every candidate. Closure and protected operators keep the evaluator from raising. A fitness function that crashes throws away a whole generation’s work.
  • Fast. Fitness is evaluated population size * generations times, often millions of times. A slow fitness function is the most common reason a GP run is unusable.
  • Graded. Prefer a ramp to a cliff. “Three rows wrong” is more informative than “not perfect”, and example 3’s prefix credit exists precisely to turn a cliff into a ramp.
  • Faithful. It must score the thing you actually want. If the fitness can be gamed, it will be. A regex that accepts every string is a perfect example of a fitness loophole.

A short checklist for the fitness function itself:

  1. What is a perfect score, and what is the score of doing nothing?
  2. Can a degenerate answer score well? (Examples: a wildcard regex, an always-true circuit, a plant that paints nothing.)
  3. Is partial progress visible, or is the landscape a needle in a haystack? Parity is the classic hard case, discussed with example 2.
  4. Is the evaluation deterministic? If the simulator is stochastic, average several runs.
  5. Is it cheap enough to run millions of times?

Two design patterns recur in this chapter:

  • Error plus a size term. Fitness = error + lambda * size. The error says “solve the problem”; the size term says “do not grow”. Examples 1 and 3 both use it.
  • Weighted example errors. Give hard cases, or false accepts, more weight than easy ones. Example 3 charges full price for accepting a negative string and only partial credit for rejecting a positive one, because the degenerate pattern that accepts everything must stay expensive.

Fitness is not the goal

The most important limitation is also the simplest: GP optimizes the fitness cases you give it, and stops there. It has no notion of the underlying problem. Example 3 evolves a pattern that satisfies all 19 example strings and then rejects 22:00, a perfectly valid time. That is not a bug in the search; it is a bug in the specification. The cures are more cases, held-out validation cases, and a test suite the search never sees.

Split your examples. Train on one part, validate on another, and only then report the third. If you cannot afford three splits, at least hold out a random fifth of the cases and check the winner against them. A GP result that improves on training data while getting worse on validation is overfitting, exactly as in any other model.

Bloat: why programs grow

Left alone, GP populations get bigger. Programs keep working while accumulating subtrees that change nothing. This is called bloat, and it is the most reliable phenomenon in the field.

Why does it happen? Several effects push the same way:

  • Neutral drift. A useless subtree does not change fitness, so selection cannot remove it. It rides along and accumulates.
  • Hitchhiking. A growing subtree attached to a useful one is copied along with it.
  • Removal bias. Deleting a random subtree is more likely to break a working program than adding one is, so the survivors tend to be larger.
  • Recombination bias. Crossover of two valid trees tends to produce trees at least as large as the smaller parent.

Bloat is not free. Larger programs evaluate more slowly, are harder to read, and overfit noise more easily, because a big enough tree can memorize the fitness cases. A run that reports “solved” with a 5,000-node tree has usually found a lookup table, not an insight.

The standard cures, in roughly increasing complexity:

  • Hard limits. A maximum depth or node count, enforced at initialization, at crossover, and at mutation. Crude, always available, and enough for the demos here.
  • Parsimony pressure. Add a penalty per node to fitness. Examples 1 and 3 do this. The weight matters: too small and it does nothing, too large and it fights accuracy.
  • Multi-objective selection. Treat size as a second objective and keep a Pareto front of accuracy-versus-size trade-offs. This is more principled than picking a penalty weight by hand, and it is what NSGA-II-based GP systems do.
  • Hoist mutation. Replace a node by one of its own descendants, which can only shrink the tree. It is a cheap local anti-bloat operator, used in examples 2, 3, and 4.
  • Simplification and modules. Algebraically simplify the winner, or let GP evolve reusable functions (automatically defined functions) so that repeated subtrees are named once.

You do not need all of these. You do need at least one. A GP system with no size control will, sooner or later, hand you a program that is technically correct and practically useless.

A tiny engine: gp_core.py

The engine below implements exactly that loop. The only design rule is separation of concerns: gp_core manipulates tree structure and never looks at what the nodes mean. Each example supplies three things the engine calls:

  • a function-arity map {name: arity},
  • a terminal sampler, a zero-argument callable that returns one terminal symbol. Making terminals a callable instead of a list lets an example draw Ephemeral Random Constants (ERCs): a fresh random number every time a constant leaf is needed. Two trees never see the same constants, so constants are re-invented each generation and selection tunes them statistically.
  • a fitness function tree -> float that the loop minimizes.

Study all_nodes(): it returns every node of a tree by reference together with its parent and position. One helper serves all the operators, because every operator reduces to “pick a random node, then splice or overwrite its subtree.”

  1 # Genetic Programming Core: A Tiny, Dependency-Free Tree-GP Engine
  2 #
  3 # Provides the machinery every tree-based GP example in this chapter needs:
  4 # random tree creation ("full" and "grow" methods), subtree crossover,
  5 # subtree and point mutation, tournament selection, and a generational
  6 # evolve() loop with elitism. The engine knows nothing about what the trees
  7 # *mean* -- each example supplies its own function set, terminal sampler, and
  8 # fitness function, so the same core drives symbolic regression, Boolean
  9 # logic synthesis, program evolution, and L-system rule evolution.
 10 #
 11 # References:
 12 #   GP overview:    https://en.wikipedia.org/wiki/Genetic_programming
 13 #   Koza, J. (1992) "Genetic Programming: On the Programming of Computers
 14 #                   by Means of Natural Selection", MIT Press (full/grow
 15 #                   initialization and the standard benchmark problems)
 16 #   Bloat:          https://en.wikipedia.org/wiki/Genetic_programming#Bloat
 17 
 18 
 19 from __future__ import annotations
 20 
 21 import random
 22 from collections.abc import Callable, Mapping, Sequence
 23 from dataclasses import dataclass, field
 24 
 25 # A terminal sampler is a zero-argument callable that returns one terminal
 26 # symbol (variable name, literal constant, character-class tag, ...).
 27 # Examples use it to draw Ephemeral Random Constants (ERCs) on demand.
 28 TerminalSampler = Callable[[], str]
 29 
 30 
 31 @dataclass
 32 class Tree:
 33     """A GP expression tree: a name plus an ordered list of child subtrees.
 34 
 35     Leaves have no children; their `name` carries the terminal symbol
 36     (for example "x", "0.42", "digit"). Internal nodes name a function
 37     whose arity must agree with len(args).
 38     """
 39 
 40     name: str
 41     args: list[Tree] = field(default_factory=list)
 42 
 43     def copy(self) -> Tree:
 44         """Return a deep copy of this subtree."""
 45         return Tree(self.name, [child.copy() for child in self.args])
 46 
 47     @property
 48     def size(self) -> int:
 49         """Total number of nodes (functions + terminals)."""
 50         return 1 + sum(child.size for child in self.args)
 51 
 52     @property
 53     def depth(self) -> int:
 54         """Longest root-to-leaf path, counted in nodes."""
 55         return 1 + max((child.depth for child in self.args), default=0)
 56 
 57     @property
 58     def is_leaf(self) -> bool:
 59         return not self.args
 60 
 61     def __str__(self) -> str:
 62         """Prefix notation, e.g. (add x (mul 0.5 y))."""
 63         if self.is_leaf:
 64             return self.name
 65         inner = " ".join(str(arg) for arg in self.args)
 66         return f"({self.name} {inner})"
 67 
 68 
 69 # A mutator takes a tree and returns a mutated copy of it.
 70 Mutator = Callable[[Tree], Tree]
 71 
 72 # A fitness function maps a tree to a real number; evolve() MINIMIZES it.
 73 FitnessFunction = Callable[[Tree], float]
 74 
 75 
 76 def sequence_sampler(rng: random.Random, symbols: Sequence[str]) -> TerminalSampler:
 77     """Wrap a fixed terminal list into a TerminalSampler (uniform choice)."""
 78     pool = list(symbols)
 79 
 80     def sample() -> str:
 81         return rng.choice(pool)
 82 
 83     return sample
 84 
 85 
 86 def all_nodes(tree: Tree) -> list[tuple[Tree, Tree | None, int]]:
 87     """Listing of (node, parent, index-in-parent) triples, root first.
 88 
 89     Nodes are returned *by reference* so callers can splice subtrees in
 90     place. The root's parent is None and its index is -1. Every operator
 91     needs only each node together with its parent and index, so the exact
 92     visit order (depth-first, siblings right-to-left) is a nicety, not a
 93     contract.
 94     """
 95     out: list[tuple[Tree, Tree | None, int]] = [(tree, None, -1)]
 96     stack: list[Tree] = [tree]
 97     while stack:
 98         node = stack.pop()
 99         # Appending while iterating backwards lists each parent's children
100         # right-to-left, and pushes the leftmost child last so the walk
101         # itself continues left-to-right.
102         for index in range(len(node.args) - 1, -1, -1):
103             child = node.args[index]
104             out.append((child, node, index))
105             stack.append(child)
106     return out
107 
108 
109 def random_tree(
110     rng: random.Random,
111     functions: Mapping[str, int],
112     terminals: TerminalSampler,
113     max_depth: int,
114     min_depth: int = 1,
115     method: str = "half",
116 ) -> Tree:
117     """Create one random valid tree.
118 
119     Parameters:
120         rng        -- seeded random.Random for reproducibility
121         functions  -- map of function name -> arity (nonterminals)
122         terminals  -- sampler producing terminal symbols
123         max_depth  -- hard cap on root-to-leaf path length (in nodes)
124         min_depth  -- trees must reach at least this depth
125         method     -- "full" (perfect trees), "grow" (mixed shapes) or
126                       "half" (coin flip between the two, Koza's default)
127 
128     The "grow" method stops a branch with probability 0.4 once the minimum
129     depth is reached, so branches end at different depths and the tree comes
130     out irregular -- the shape GP is known for.
131     """
132     if method not in ("full", "grow", "half"):
133         raise ValueError(f"unknown initialization method {method!r}")
134     if max_depth < 1:
135         raise ValueError(f"max_depth must be at least 1, got {max_depth}")
136     if min_depth > max_depth:
137         raise ValueError(f"min_depth {min_depth} exceeds max_depth {max_depth}")
138     if method == "half":
139         method = "full" if rng.random() < 0.5 else "grow"
140 
141     names = list(functions)
142 
143     def build(depth_left: int, depth: int) -> Tree:
144         can_branch = depth_left > 1 and bool(names)
145         if not can_branch:
146             return Tree(terminals())
147         must_branch = depth < min_depth
148         if not must_branch and method == "grow" and rng.random() < 0.4:
149             return Tree(terminals())
150         name = rng.choice(names)
151         arity = functions[name]
152         return Tree(name, [build(depth_left - 1, depth + 1) for _ in range(arity)])
153 
154     return build(max_depth, 1)
155 
156 
157 def crossover(
158     rng: random.Random,
159     parent_a: Tree,
160     parent_b: Tree,
161     max_depth: int | None = None,
162 ) -> tuple[Tree, Tree]:
163     """Subtree crossover: swap one random subtree between two clones.
164 
165     Both offspring are returned; the parents are never modified. If
166     `max_depth` is given, a swap that would make either offspring deeper
167     than the cap is skipped (the clones come back unchanged), so bloat
168     stays bounded without disabling crossover.
169     """
170     child_a, child_b = parent_a.copy(), parent_b.copy()
171 
172     spots_a = all_nodes(child_a)
173     spots_b = all_nodes(child_b)
174     node_a, parent_na, index_a = rng.choice(spots_a)
175     node_b, parent_nb, index_b = rng.choice(spots_b)
176 
177     if max_depth is not None:
178         # Refuse the swap if either side would exceed the depth cap.
179         depth_a_rest = parent_a.depth - node_a.depth  # context above node_a
180         depth_b_rest = parent_b.depth - node_b.depth
181         new_depth_a = depth_a_rest + node_b.depth
182         new_depth_b = depth_b_rest + node_a.depth
183         if max(new_depth_a, new_depth_b) > max_depth:
184             return child_a, child_b
185 
186     # Splicing at the root means the whole other subtree becomes the child.
187     if parent_na is None:
188         child_a = node_b.copy()
189     else:
190         parent_na.args[index_a] = node_b.copy()
191     if parent_nb is None:
192         child_b = node_a.copy()
193     else:
194         parent_nb.args[index_b] = node_a.copy()
195     return child_a, child_b
196 
197 
198 def mutate_subtree(
199     rng: random.Random,
200     tree: Tree,
201     functions: Mapping[str, int],
202     terminals: TerminalSampler,
203     max_depth: int = 4,
204 ) -> Tree:
205     """Subtree mutation: replace one random node's subtree with fresh randoms.
206 
207     The replacement subtree respects `max_depth`, but the whole tree can
208     still end up deeper than the original where the graft happened.
209     """
210     child = tree.copy()
211     node, _parent, _index = rng.choice(all_nodes(child))
212     new = random_tree(rng, functions, terminals, max_depth=max_depth, method="grow")
213     # Overwrite in place: reusing the node object keeps the tree connected.
214     node.name, node.args = new.name, new.args
215     return child
216 
217 
218 def mutate_point(
219     rng: random.Random,
220     tree: Tree,
221     functions: Mapping[str, int],
222     terminals: TerminalSampler,
223 ) -> Tree:
224     """Point mutation: swap a function for another of the SAME arity, or a
225     terminal for another terminal. Structure (shape) is preserved."""
226     child = tree.copy()
227     node, _parent, _index = rng.choice(all_nodes(child))
228     if node.is_leaf:
229         node.name = terminals()
230     else:
231         same_arity = [n for n, a in functions.items() if a == len(node.args)]
232         if same_arity:
233             node.name = rng.choice(same_arity)
234     return child
235 
236 
237 def mutate_hoist(rng: random.Random, tree: Tree) -> Tree:
238     """Hoist mutation: replace a node by one of its own descendants, pruning
239     the surrounding context. A cheap anti-bloat operator."""
240     child = tree.copy()
241     node, _parent, _index = rng.choice(all_nodes(child))
242     pick, _p, _i = rng.choice(all_nodes(node))
243     node.name = pick.name
244     node.args = [arg.copy() for arg in pick.args]
245     return child
246 
247 
248 def tournament(
249     rng: random.Random,
250     fitnesses: Sequence[float],
251     population: Sequence[Tree],
252     k: int = 3,
253 ) -> Tree:
254     """Tournament selection (minimization). Draw k DISTINCT individuals at
255     random, return the one with the LOWEST fitness. Indices refer into the
256     parallel `fitnesses` and `population` sequences."""
257     n = len(population)
258     if n == 0:
259         raise ValueError("cannot select from an empty population")
260     if k < 1:
261         raise ValueError(f"tournament size must be at least 1, got {k}")
262     contenders = rng.sample(range(n), min(k, n))
263     return population[min(contenders, key=fitnesses.__getitem__)]
264 
265 
266 @dataclass
267 class EvolutionResult:
268     """Outcome of one evolve() run.
269 
270     `history[i]` is the best-so-far fitness reported at the start of
271     generation `i`, and the final entry repeats `best_fitness`.
272     """
273 
274     best: Tree
275     best_fitness: float
276     generations: int
277     solved: bool
278     history: list[float] = field(default_factory=list)
279 
280 
281 def evolve(
282     rng: random.Random,
283     population: list[Tree],
284     fitness: FitnessFunction,
285     mutate: Mutator,
286     *,
287     generations: int,
288     crossover_rate: float = 0.8,
289     mutation_rate: float = 0.15,
290     tournament_size: int = 7,
291     elitism: int = 1,
292     max_depth: int = 17,
293     target: float | None = None,
294     reporter: Callable[[int, float, Tree], None] | None = None,
295 ) -> EvolutionResult:
296     """Generational GP loop (minimizes `fitness`).
297 
298     Each generation: rank the population, optionally stop at `target`,
299     carry the best `elitism` trees unchanged, then fill the rest of the
300     next generation by tournament-selected crossover, mutating each child
301     with probability `mutation_rate`.
302 
303     Parameters:
304         rng             -- seeded random source
305         population      -- initial list of Trees (mutated in no way)
306         fitness         -- objective to minimize
307         mutate          -- the mutator chosen by the example
308         generations     -- budget of generations
309         crossover_rate  -- probability of crossover per child (else clone)
310         mutation_rate   -- probability of mutating each crossed/cloned child
311         tournament_size -- k for selection tournaments
312         elitism         -- number of best trees copied unchanged each gen;
313                            an elitism at or above the population size leaves
314                            no room for children, so nothing evolves
315         max_depth       -- depth cap enforced on crossover; mutators apply
316                            their own (relative) limits, so a mutated child
317                            can still exceed this value
318         target          -- stop early once best fitness <= target
319         reporter        -- called as reporter(generation, best_fitness, best_tree)
320 
321     Returns:
322         EvolutionResult with the best tree ever seen. `generations` counts
323         the reproduction rounds actually completed, so an early stop at
324         generation `g` reports `g`, not `g + 1`.
325     """
326     if not population:
327         raise ValueError("population must not be empty")
328     if generations < 0:
329         raise ValueError("generations must not be negative")
330     if tournament_size < 1:
331         raise ValueError("tournament_size must be at least 1")
332     if elitism < 0:
333         raise ValueError("elitism must not be negative")
334     n = len(population)
335     fitnesses = [fitness(tree) for tree in population]
336     best_index = min(range(n), key=fitnesses.__getitem__)
337     best_overall = (fitnesses[best_index], population[best_index].copy())
338     result = EvolutionResult(
339         best=best_overall[1],
340         best_fitness=best_overall[0],
341         generations=0,
342         solved=False,
343     )
344 
345     completed = 0
346     for generation in range(generations):
347         result.history.append(best_overall[0])
348         if reporter is not None:
349             reporter(generation, best_overall[0], best_overall[1])
350         if target is not None and best_overall[0] <= target:
351             result.solved = True
352             break
353 
354         order = sorted(range(n), key=fitnesses.__getitem__)
355         children: list[Tree] = [population[i].copy() for i in order[:elitism]]
356         while len(children) < n:
357             parent_a = tournament(rng, fitnesses, population, tournament_size)
358             parent_b = tournament(rng, fitnesses, population, tournament_size)
359             if rng.random() < crossover_rate:
360                 child, _sibling = crossover(rng, parent_a, parent_b, max_depth=max_depth)
361             else:
362                 child = parent_a.copy()
363             if rng.random() < mutation_rate:
364                 child = mutate(child)
365             children.append(child)
366 
367         population = children
368         fitnesses = [fitness(tree) for tree in population]
369         gen_best_index = min(range(n), key=fitnesses.__getitem__)
370         if fitnesses[gen_best_index] < best_overall[0]:
371             best_overall = (
372                 fitnesses[gen_best_index],
373                 population[gen_best_index].copy(),
374             )
375         completed = generation + 1
376 
377     result.generations = completed
378     result.best = best_overall[1]
379     result.best_fitness = best_overall[0]
380     result.history.append(result.best_fitness)
381     return result

A few notes on the listing.

random_tree() enforces both a depth cap and a depth floor. The floor matters: a population of depth-1 trees (just "x") evaluates instantly and selects instantly, then evolution has nothing to select between once the cap-only version floods with junk.

crossover() returns two children but evolve() keeps only the first. That is the common GP convention (it halves the evaluation cost and behaves nearly identically). The depth check refuses swaps that would exceed the depth cap rather than retrying forever, and the root case is handled explicitly, because a root node has no parent to splice into.

tournament() draws k distinct competitors with rng.sample(). Drawing with replacement would let a tournament of size k = N miss the best individual entirely, which would break the invariant test in tests/test_gp_core.py.

evolve() keeps the best tree ever seen (best_overall), not merely the final generation’s best. GP runs are noisy; the best individual typically appears well before the last generation.

Example 1: Symbolic regression

Symbolic regression fits data with programs. You give GP (x, y) examples and an operator alphabet, and it evolves the formula. Unlike interpolation or least-squares fitting, nothing fixes the model class: GP decides which operators appear and how they nest.

Our hidden target is a damped oscillator:

math

sampled at 40 points on [0, 5] with Gaussian noise, standard deviation 0.05. The data the search actually sees looks like this (first four rows, as make_dataset produces them):

1   x=0.000  y=-0.012794
2   x=0.128  y=+0.511572
3   x=0.256  y=+0.889584
4   x=0.385  y=+1.208017

Two deliberate handicaps teach the classic GP lessons:

  • The function set contains no exp. GP cannot copy the textbook answer; it must find an equally cheap structure that fits the noisy samples.
  • Parsimony pressure. Fitness is Code Test with Code Test the node count and Code Test per node. Adding a subtree must buy at least half a thousandth of squared error to be worth keeping. This is the cheapest known cure for bloat.

Division is a protected operator: when the denominator is closer to zero than Code Test it returns Code Test instead of raising. Every random tree must evaluate on every fitness case, or the search stalls on exceptions. (The same protection applies to sqrt and log in fielded systems.)

  1 # Example 1 -- Symbolic Regression: Recovering a Damped Oscillator
  2 #
  3 # GP evolves computer programs (expression trees) that fit noisy numeric
  4 # data. Unlike curve fitting with a FIXED model shape, GP searches over
  5 # model STRUCTURES: the tree decides which operators appear, where, and
  6 # with which constants. The result is a human-readable formula.
  7 #
  8 # This demo hides the true generating function -- a damped sine wave
  9 #     y(x) = 2 * exp(-x/3) * sin(2x) + noise
 10 # sampled at 40 points on [0, 5] -- and asks GP to approximate it. The
 11 # function set below deliberately has NO `exp` primitive, so the evolved
 12 # formula cannot be an exact copy of the truth; GP must find an equally
 13 # good structural approximation. That is typical: symbolic regression
 14 # rewards "right shape", not "textbook answer".
 15 #
 16 # Two classic GP knobs are on display:
 17 #   * Parsimony pressure -- fitness = error + lambda * tree size, which
 18 #     discourages bloat (the runaway growth of useless subtrees).
 19 #   * Protected operators -- division returns 1.0 when the denominator is
 20 #     near zero, so every random tree evaluates to a finite number.
 21 #
 22 # References:
 23 #   Symbolic regression: https://en.wikipedia.org/wiki/Symbolic_regression
 24 #   Bloat control:       Luke & Panait (2006), "A Comparison of Bloat
 25 #                        Control Methods for Genetic Programming",
 26 #                        https://doi.org/10.1162/evco.2006.14.3.309
 27 #   GP field guide:      https://www.gp-field-guide.org.uk/
 28 
 29 from __future__ import annotations
 30 
 31 import math
 32 import random
 33 from collections.abc import Callable, Sequence
 34 from typing import Final
 35 
 36 from gp_core import (
 37     TerminalSampler,
 38     Tree,
 39     evolve,
 40     mutate_point,
 41     mutate_subtree,
 42     random_tree,
 43 )
 44 
 45 SEED: Final = 7  # chosen so this run converges quickly and repeatably
 46 GENERATIONS: Final = 40
 47 
 48 # ---------------------------------------------------------------------------
 49 # The hidden ground truth and the data we are allowed to see.
 50 # ---------------------------------------------------------------------------
 51 
 52 
 53 def target(x: float) -> float:
 54     """The secret: a damped oscillation GP must approximate."""
 55     return 2.0 * math.exp(-x / 3.0) * math.sin(2.0 * x)
 56 
 57 
 58 NOISE_SIGMA: Final = 0.05
 59 XS: Final[list[float]] = [5.0 * i / 39 for i in range(40)]
 60 
 61 
 62 def make_dataset(rng: random.Random) -> list[tuple[float, float]]:
 63     """Sample the target at XS with Gaussian measurement noise."""
 64     return [(x, target(x) + rng.gauss(0.0, NOISE_SIGMA)) for x in XS]
 65 
 66 
 67 # ---------------------------------------------------------------------------
 68 # Function set: name -> (arity, evaluation). The raw operators are partial:
 69 # sin overflows the domain at huge inputs, mul can overflow, div can divide
 70 # by zero. Division is protected here and evaluate() maps every non-finite
 71 # result to 0.0, so every random tree still yields a finite float.
 72 # ---------------------------------------------------------------------------
 73 
 74 ArityAndEval = tuple[int, Callable[[Sequence[float]], float]]
 75 
 76 FUNCTIONS: Final[dict[str, ArityAndEval]] = {
 77     "add": (2, lambda a: a[0] + a[1]),
 78     "sub": (2, lambda a: a[0] - a[1]),
 79     "mul": (2, lambda a: a[0] * a[1]),
 80     "div": (  # protected: |denominator| < 1e-10 -> 1.0
 81         2,
 82         lambda a: a[0] / a[1] if abs(a[1]) > 1e-10 else 1.0,
 83     ),
 84     "sin": (1, lambda a: math.sin(a[0])),
 85     "cos": (1, lambda a: math.cos(a[0])),
 86     "neg": (1, lambda a: -a[0]),
 87     "abs": (1, lambda a: abs(a[0])),
 88 }
 89 
 90 FUNCTION_ARITIES: Final[dict[str, int]] = {
 91     name: arity for name, (arity, _eval) in FUNCTIONS.items()
 92 }
 93 
 94 
 95 def terminal_sampler(rng: random.Random) -> TerminalSampler:
 96     """Terminals = the input variable x plus Ephemeral Random Constants.
 97 
 98     An ERC is re-drawn every time it is needed, so no two trees ever share
 99     the same constant set: GP re-invents its own numeric literals every
100     generation and selection tunes them statistically.
101     """
102 
103     def sample() -> str:
104         if rng.random() < 0.25:
105             return "x"
106         return f"{rng.uniform(-3.0, 3.0):.4f}"
107 
108     return sample
109 
110 
111 # ---------------------------------------------------------------------------
112 # Evaluating a tree on one input, and the parsimony-pressured fitness.
113 # ---------------------------------------------------------------------------
114 
115 
116 def evaluate(tree: Tree, x: float) -> float:
117     """Recursive post-order evaluation. Leaves are 'x' or a float literal."""
118     if tree.is_leaf:
119         return x if tree.name == "x" else float(tree.name)
120     arity, op = FUNCTIONS[tree.name]
121     if len(tree.args) != arity:  # defensive: corrupted tree
122         return 0.0
123     values = [evaluate(child, x) for child in tree.args]
124     result = op(values)
125     return result if math.isfinite(result) else 0.0
126 
127 
128 PARSIMONY: Final = 5e-4  # fitness penalty per tree node
129 
130 
131 class RegressionFitness:
132     """Mean squared error plus a size penalty (parsimony pressure)."""
133 
134     def __init__(self, data: list[tuple[float, float]]) -> None:
135         self.data = data
136 
137     def __call__(self, tree: Tree) -> float:
138         squared = sum((evaluate(tree, x) - y) ** 2 for x, y in self.data)
139         return squared / len(self.data) + PARSIMONY * tree.size
140 
141 
142 # ---------------------------------------------------------------------------
143 # Pretty printing: prefix tree -> readable infix formula.
144 # ---------------------------------------------------------------------------
145 
146 
147 def render(tree: Tree) -> str:
148     """Infix for binaries, call-style for unaries, plain for leaves."""
149     if tree.is_leaf:
150         return tree.name
151     (arity, _op) = FUNCTIONS[tree.name]
152     kids = [render(child) for child in tree.args]
153     if arity == 2:
154         symbol = {"add": " + ", "sub": " - ", "mul": " * ", "div": " / "}.get(tree.name)
155         if symbol is not None:
156             return f"({kids[0]}{symbol}{kids[1]})"
157         return f"{tree.name}({kids[0]}, {kids[1]})"
158     return f"{tree.name}({kids[0]})"
159 
160 
161 # ---------------------------------------------------------------------------
162 # The run.
163 # ---------------------------------------------------------------------------
164 
165 
166 def build_population(rng: random.Random, size: int, max_depth: int) -> list[Tree]:
167     terminals = terminal_sampler(rng)
168     return [
169         random_tree(
170             rng,
171             FUNCTION_ARITIES,
172             terminals,
173             max_depth=max_depth,
174             min_depth=2,
175             method="half",
176         )
177         for _ in range(size)
178     ]
179 
180 
181 def main() -> None:
182     rng = random.Random(SEED)
183     data = make_dataset(rng)
184     scoring = RegressionFitness(data)
185 
186     population = build_population(rng, size=400, max_depth=8)
187     terminals = terminal_sampler(rng)
188 
189     def mutate(tree: Tree) -> Tree:
190         roll = rng.random()
191         if roll < 0.55:
192             return mutate_subtree(rng, tree, FUNCTION_ARITIES, terminals, max_depth=4)
193         return mutate_point(rng, tree, FUNCTION_ARITIES, terminals)
194 
195     def report(generation: int, penalty_fitness: float, best: Tree) -> None:
196         # The run plateaus early, so print checkpoints only.
197         if generation % 5 and generation != GENERATIONS - 1:
198             return
199         mse = sum((evaluate(best, x) - y) ** 2 for x, y in data) / len(data)
200         print(f"gen {generation:3d}  best MSE {mse:8.5f}  size {best.size:3d}  {render(best)[:60]}")
201 
202     print(f"# Symbolic regression: fit {len(data)} noisy samples of a damped oscillator")
203     print(f"# hidden truth: y = 2*exp(-x/3)*sin(2x), noise sd = {NOISE_SIGMA}\n")
204 
205     result = evolve(
206         rng,
207         population,
208         scoring,
209         mutate,
210         generations=GENERATIONS,
211         crossover_rate=0.8,
212         mutation_rate=0.15,
213         tournament_size=7,
214         elitism=2,
215         max_depth=12,
216         reporter=report,
217     )
218 
219     mse = sum((evaluate(result.best, x) - y) ** 2 for x, y in data) / len(data)
220     signal_power = sum(y * y for _, y in data) / len(data)
221     print(f"\nBest after {result.generations} generations:")
222     print(f"  formula   y = {render(result.best)}")
223     print(f"  nodes     {result.best.size}, depth {result.best.depth}")
224     print(
225         f"  MSE {mse:.5f} vs signal power {signal_power:.5f}  "
226         f"(explains {100 * (1 - mse / signal_power):.1f}% of variance)"
227     )
228 
229     print("\n     x      y*      y_hat   error")
230     for x in XS[::4]:
231         y_hat = evaluate(result.best, x)
232         print(f"  {x:5.2f}  {target(x):6.3f}  {y_hat:6.3f}  {y_hat - target(x):+6.3f}")
233 
234 
235 if __name__ == "__main__":
236     main()

Running it

1 uv sync
2 make regression    # or: uv run python example_1_symbolic_regression.py

Output (seed 7):

 1 # Symbolic regression: fit 40 noisy samples of a damped oscillator
 2 # hidden truth: y = 2*exp(-x/3)*sin(2x), noise sd = 0.05
 3 
 4 gen   0  best MSE  0.14600  size   7  neg((sin((x + x)) * -0.6569))
 5 gen   5  best MSE  0.08975  size   4  sin((x + x))
 6 gen  10  best MSE  0.08975  size   4  sin((x + x))
 7 gen  15  best MSE  0.08975  size   4  sin((x + x))
 8 gen  20  best MSE  0.08975  size   4  sin((x + x))
 9 gen  25  best MSE  0.08975  size   4  sin((x + x))
10 gen  30  best MSE  0.08975  size   4  sin((x + x))
11 gen  35  best MSE  0.08975  size   4  sin((x + x))
12 gen  39  best MSE  0.08975  size   4  sin((x + x))
13 
14 Best after 40 generations:
15   formula   y = sin((x + x))
16   nodes     4, depth 3
17   MSE 0.08975 vs signal power 0.56206  (explains 84.0% of variance)
18 
19      x      y*      y_hat   error
20    0.00   0.000   0.000  +0.000
21    0.51   1.441   0.855  -0.586
22    1.03   1.260   0.887  -0.373
23    1.54   0.077   0.065  -0.013
24    2.05  -0.827  -0.820  +0.008
25    2.56  -0.778  -0.915  -0.136
26    3.08  -0.092  -0.129  -0.036
27    3.59   0.472   0.781  +0.309
28    4.10   0.478   0.939  +0.461
29    4.62   0.083   0.193  +0.110

Reading the results

The best MSE is the mean of squared deviations over the 40 noisy samples,

math

and the last header line compares it to the signal power Code Test: “explains 84.0% of variance” means Code Test is 0.84.

Three things happened, and each is typical GP behavior:

  1. GP found the frequency of the oscillator. sin(x + x) is Code Test, and notice where the 2 lives: in an addition Code Test, not a constant multiply. Both spellings cost the same four nodes, but x + x needs no lucky constant, so the search reaches it long before a constant that happens to land near Code Test does. It never recovered the damping, because without exp the damping factor costs a large tree, and every large tree that might approximate e^{-x/3} lost the parsimony auction to the small one.
  2. Improvement stopped at generation 2 and the population coasted for 37 more generations. Early convergence on a plateau is the normal case, not a bug: tournament selection with Code Test plus elitism consumes diversity quickly. Raise the mutation rate, lower the tournament size, or use the target argument of evolve() (like examples 2, 3, and 4 do) to stop the waste.
  3. There is no bloat in the final answer (4 nodes!). The parsimony term is small enough not to distort the error comparison (0.0005 per node versus an MSE near 0.09) but large enough that bloated mutants never reproduce.

If you add "exp": (1, lambda a: math.exp(a[0])) to FUNCTIONS, watch the run discover the damping factor. (That is practice problem 1.)

Example 2: Boolean logic synthesis

The canonical GP problem class: evolve a program whose input/output behavior matches a specification. Here the specification is a machine safety interlock. Enable the motor only when both operator keys are turned on, there is no fault signal, and at least one of the two redundant sensors agrees:

math

The search sees the 32-row truth table and nothing else. Terminals are the five input names plus ephemeral Boolean constants TRUE/FALSE (20% of terminal draws); functions are the three gates AND, OR, NOT. Fitness is the fraction of truth-table rows computed incorrectly, a plain count of wrong behaviors.

Two notes before the listing. First, the constant leaves matter: parity-like and multiplexer-style targets are famously hard for GP without ephemeral constants, because the population spends generations discovering trivial constants. Second, note how little this problem is about syntax: the interlock above has many equivalent netlists, and GP finds one of them, not “the” one.

  1 # Example 2 -- Boolean Logic Synthesis with Tree GP
  2 #
  3 # Genetic programming on the canonical GP problem class: evolve a PROGRAM
  4 # (here, a combinational logic circuit) whose INPUT/OUTPUT behavior matches
  5 # a specification given as a truth table. Fitness counts wrong rows, so the
  6 # landscape is a staircase of partially-correct circuits -- exactly the kind
  7 # of "behavior, not structure" scoring that distinguishes GP from ordinary
  8 # parameter optimization.
  9 #
 10 # The specification is a machine-safety interlock:
 11 #
 12 #   ENABLE = (both operator keys pressed) AND (no fault)
 13 #            AND (at least one of the two redundant sensors agrees)
 14 #
 15 # Inputs: K1, K2 = operator keys; F = fault flag (active high);
 16 #         S1, S2 = redundant sensors. The interlock needs only ONE of the
 17 #         redundant sensors to agree, so the pair is free to disagree on
 18 #         any row and ENABLE still fires.
 19 # Hand-writing the gate netlist for this is error-prone; GP rediscovers a
 20 # correct netlist from 32 example rows in seconds.
 21 #
 22 # This is also a clean illustration of two GP subtleties:
 23 #   * Ephemeral Boolean constants (TRUE/FALSE leaves) -- without them, GP
 24 #     struggles badly on parity-like targets.
 25 #   * Behavior-only scoring: the evolved netlist in our run is NOT the
 26 #     textbook formula. It is a structurally different circuit that
 27 #     computes the same function, and the demo verifies it row by row.
 28 #     GP optimizes what a program DOES, never how it LOOKS.
 29 #
 30 # References:
 31 #   Koza, J. (1992) "Genetic Programming: On the Programming of Computers
 32 #                   by Means of Natural Selection", MIT Press (Boolean
 33 #                   problems)
 34 #   Parity difficulty in GP: Langdon & Poli (1998), "Why Building Blocks
 35 #                   Don't Work on Parity Problems", CSRP-98-17:
 36 #                   http://web4.cs.ucl.ac.uk/staff/W.Langdon/csrp-98-17/eq.html
 37 #   Logic synthesis:           https://en.wikipedia.org/wiki/Logic_synthesis
 38 
 39 from __future__ import annotations
 40 
 41 import itertools
 42 import random
 43 from collections.abc import Callable, Mapping, Sequence
 44 from typing import Final
 45 
 46 from gp_core import (
 47     TerminalSampler,
 48     Tree,
 49     evolve,
 50     mutate_hoist,
 51     mutate_point,
 52     mutate_subtree,
 53     random_tree,
 54 )
 55 
 56 SEED: Final = 12
 57 
 58 INPUTS: Final[tuple[str, ...]] = ("K1", "K2", "F", "S1", "S2")
 59 
 60 
 61 def specification(row: Sequence[bool]) -> bool:
 62     """The interlock we want GP to rediscover (row order = INPUTS)."""
 63     k1, k2, fault, s1, s2 = row
 64     return (k1 and k2) and not fault and (s1 or s2)
 65 
 66 
 67 TRUTH_TABLE: Final[list[tuple[tuple[bool, ...], bool]]] = [
 68     (row, specification(row)) for row in itertools.product((False, True), repeat=len(INPUTS))
 69 ]
 70 
 71 # The same cases keyed by input name, built once so fitness() does not
 72 # rebuild a dict for every candidate. Evaluating a tree is the hot path.
 73 CASES: Final[list[tuple[Mapping[str, bool], bool]]] = [
 74     (dict(zip(INPUTS, row, strict=True)), expected) for row, expected in TRUTH_TABLE
 75 ]
 76 
 77 # Gate-level function set. NOT is unary; AND/OR binary. (NAND-only synthesis
 78 # is possible too -- try it as an exercise!)
 79 GATES: Final[dict[str, tuple[int, Callable[[Sequence[bool]], bool]]]] = {
 80     "AND": (2, lambda a: a[0] and a[1]),
 81     "OR": (2, lambda a: a[0] or a[1]),
 82     "NOT": (1, lambda a: not a[0]),
 83 }
 84 
 85 FUNCTION_ARITIES: Final[dict[str, int]] = {name: arity for name, (arity, _eval) in GATES.items()}
 86 
 87 
 88 def terminals(rng: random.Random) -> TerminalSampler:
 89     """Inputs plus 20% chance of an Ephemeral Random Boolean constant."""
 90     pool = list(INPUTS)
 91 
 92     def sample() -> str:
 93         if rng.random() < 0.2:
 94             return rng.choice(("TRUE", "FALSE"))
 95         return rng.choice(pool)
 96 
 97     return sample
 98 
 99 
100 def evaluate(tree: Tree, row: Mapping[str, bool]) -> bool:
101     """Interpret the tree as a gate netlist over one input row."""
102     if tree.is_leaf:
103         if tree.name == "TRUE":
104             return True
105         if tree.name == "FALSE":
106             return False
107         return row[tree.name]
108     arity, op = GATES[tree.name]
109     if len(tree.args) != arity:
110         return False
111     return op([evaluate(child, row) for child in tree.args])
112 
113 
114 def fitness(tree: Tree) -> float:
115     """Fraction of truth-table rows the circuit gets WRONG (0.0 = perfect)."""
116     wrong = 0
117     for row, expected in CASES:
118         if evaluate(tree, row) != expected:
119             wrong += 1
120     return float(wrong) / len(CASES)
121 
122 
123 def render(tree: Tree) -> str:
124     """Human-readable gate expression, e.g. (NOT (K1 AND F))."""
125     if tree.is_leaf:
126         return tree.name
127     arity, _op = GATES[tree.name]
128     kids = [render(child) for child in tree.args]
129     if arity == 2:
130         return f"({kids[0]} {tree.name} {kids[1]})"
131     return f"({tree.name} {kids[0]})"
132 
133 
134 def build_population(rng: random.Random, size: int, max_depth: int) -> list[Tree]:
135     sampler = terminals(rng)
136     return [
137         random_tree(
138             rng,
139             FUNCTION_ARITIES,
140             sampler,
141             max_depth=max_depth,
142             min_depth=2,
143             method="grow",
144         )
145         for _ in range(size)
146     ]
147 
148 
149 def main() -> None:
150     rng = random.Random(SEED)
151     population = build_population(rng, size=250, max_depth=8)
152     sampler = terminals(rng)
153 
154     def mutate(tree: Tree) -> Tree:
155         roll = rng.random()
156         if roll < 0.45:
157             return mutate_subtree(rng, tree, FUNCTION_ARITIES, sampler, max_depth=4)
158         if roll < 0.9:
159             return mutate_point(rng, tree, FUNCTION_ARITIES, sampler)
160         return mutate_hoist(rng, tree)
161 
162     def report(generation: int, best_fitness: float, best: Tree) -> None:
163         print(
164             f"gen {generation:3d}  wrong rows {round(best_fitness * len(CASES)):2d}/32  "
165             f"size {best.size:3d}  {render(best)[:58]}"
166         )
167 
168     print("# Evolving a 5-input safety interlock from its 32-row truth table")
169     print("# inputs: K1, K2 (keys), F (fault), S1, S2 (sensors)\n")
170 
171     result = evolve(
172         rng,
173         population,
174         fitness,
175         mutate,
176         generations=60,
177         crossover_rate=0.85,
178         mutation_rate=0.12,
179         tournament_size=5,
180         elitism=1,
181         max_depth=12,
182         target=0.0,
183         reporter=report,
184     )
185 
186     print(
187         f"\nPerfect circuit found: {result.solved} "
188         f"(generation {result.generations}, {result.best.size} gates, depth {result.best.depth})"
189     )
190     print(f"  ENABLE = {render(result.best)}")
191 
192     # Double-check: re-evaluate EVERY row against the discovered netlist.
193     failures = [row for row, expected in CASES if evaluate(result.best, row) != expected]
194     print(f"  verification: {len(CASES) - len(failures)}/{len(CASES)} rows correct")
195 
196     print("\n  truth table (1 = ENABLE):")
197     header = "  " + " ".join(f"{name:>2}" for name in INPUTS) + " | spec | evolved"
198     print(header)
199     print("  " + "-" * (len(header) - 2))
200     for row, expected in CASES:
201         got = evaluate(result.best, row)
202         mark = "  " if got == expected else " <-- MISMATCH"
203         bits = " ".join(f"{int(row[name]):>2}" for name in INPUTS)
204         print(f"  {bits} |  {int(expected)}  |   {int(got)}{mark}")
205 
206 
207 if __name__ == "__main__":
208     main()

Running it

1 make logic    # or: uv run python example_2_boolean_logic.py

Output (seed 12):

 1 # Evolving a 5-input safety interlock from its 32-row truth table
 2 # inputs: K1, K2 (keys), F (fault), S1, S2 (sensors)
 3 
 4 gen   0  wrong rows  3/32  size   4  (NOT (NOT (NOT TRUE)))
 5 gen   1  wrong rows  2/32  size  28  ((NOT ((F AND S1) OR ((F AND ((NOT K1) OR (S1 OR S2))) OR 
 6 gen   2  wrong rows  2/32  size  28  ((NOT ((F AND S1) OR ((F AND ((NOT K1) OR (S1 OR S2))) OR 
 7 gen   3  wrong rows  1/32  size  13  (K1 AND ((NOT (F OR (NOT (K2 AND (K1 AND K1))))) AND K1))
 8 gen   4  wrong rows  1/32  size  13  (K1 AND ((NOT (F OR (NOT (K2 AND (K1 AND K1))))) AND K1))
 9 gen   5  wrong rows  1/32  size  13  (K1 AND ((NOT (F OR (NOT (K2 AND (K1 AND K1))))) AND K1))
10 gen   6  wrong rows  1/32  size  13  (K1 AND ((NOT (F OR (NOT (K2 AND (K1 AND K1))))) AND K1))
11 gen   7  wrong rows  1/32  size  13  (K1 AND ((NOT (F OR (NOT (K2 AND (K1 AND K1))))) AND K1))
12 gen   8  wrong rows  0/32  size  18  (((NOT K1) OR (S1 OR S2)) AND ((NOT (F OR (NOT (K2 AND (K2
13 
14 Perfect circuit found: True (generation 8, 18 gates, depth 8)
15   ENABLE = (((NOT K1) OR (S1 OR S2)) AND ((NOT (F OR (NOT (K2 AND (K2 AND K1))))) AND K2))
16   verification: 32/32 rows correct
17 
18   truth table (1 = ENABLE):
19   K1 K2  F S1 S2 | spec | evolved
20   -------------------------------
21    0  0  0  0  0 |  0  |   0  
22    0  0  0  0  1 |  0  |   0  
23    0  0  0  1  0 |  0  |   0  
24    0  0  0  1  1 |  0  |   0  
25    0  0  1  0  0 |  0  |   0  
26    0  0  1  0  1 |  0  |   0  
27    0  0  1  1  0 |  0  |   0  
28    0  0  1  1  1 |  0  |   0  
29    0  1  0  0  0 |  0  |   0  
30    0  1  0  0  1 |  0  |   0  
31    0  1  0  1  0 |  0  |   0  
32    0  1  0  1  1 |  0  |   0  
33    0  1  1  0  0 |  0  |   0  
34    0  1  1  0  1 |  0  |   0  
35    0  1  1  1  0 |  0  |   0  
36    0  1  1  1  1 |  0  |   0  
37    1  0  0  0  0 |  0  |   0  
38    1  0  0  0  1 |  0  |   0  
39    1  0  0  1  0 |  0  |   0  
40    1  0  0  1  1 |  0  |   0  
41    1  0  1  0  0 |  0  |   0  
42    1  0  1  0  1 |  0  |   0  
43    1  0  1  1  0 |  0  |   0  
44    1  0  1  1  1 |  0  |   0  
45    1  1  0  0  0 |  0  |   0  
46    1  1  0  0  1 |  1  |   1  
47    1  1  0  1  0 |  1  |   1  
48    1  1  0  1  1 |  1  |   1  
49    1  1  1  0  0 |  0  |   0  
50    1  1  1  0  1 |  0  |   0  
51    1  1  1  1  0 |  0  |   0  
52    1  1  1  1  1 |  0  |   0  

Reading the results

Progress is honest: 3 wrong rows at generation 0 (an always-false constant circuit: Code Test mislabels the 3 rows where the interlock should ENABLE), 2 at generation 1, 1 by generation 3, then a perfect 18-gate circuit at generation 8. The verification line and the printed truth table confirm 32/32 rows.

Compare the evolved netlist with the hand-written formula. They compute the same function, but GP’s answer contains K2 AND (K2 AND K1) where you would write K1 AND K2, and a NOT (F OR (NOT ...)) wrapper that De Morgan’s law collapses into the NOT F you would have written yourself. These are intrinsically redundant subexpressions: code that can never change an output is invisible to selection, so evolution keeps it. If you ship this circuit, you pass it through a Boolean simplifier first. The printed truth table is the point of the whole demo: spec column and evolved column agree on all 32 rows, and GP earned that by behavior, not by deriving anything.

Example 3: Evolving regular expressions

GP is not restricted to numeric programs. The individual here is a pattern program: a tree whose nodes are regex operators (cat, alt, opt, star, plus) and whose leaves are pre-generalized character fragments (D compiles to Code Test, h1 to Code Test, h3 to Code Test, and so on). Compile the tree to a regex string, run Python’s re engine on example strings, score accept/reject.

The task: learn a validator for 24-hour clock times from 8 positive strings and 11 negative strings:

1 positive: 09:15  23:59  00:01  17:42  08:05  12:00  01:30  20:47
2 negative: 24:00  29:00  09:5  9:15  0915  ab:cd  09:60  1:2:3  ''  09:1a  123:45

Nobody tells GP about clock arithmetic. It must discover from misfit pain that hour tens digits live in Code Test, that a leading Code Test restricts its follower to Code Test, and that minute tens digits live in Code Test.

The fitness function has three parts, and all three earned their place the hard way (see NOTES.md):

  • Partial credit on positives. A rejected positive costs Code Test, where Code Test is the largest fraction of the string the pattern can fully match as a prefix. Patterns that get “23:” or “09:1” right are closer to the truth than patterns that cannot start. This ramp is what lets the search escape the “rejects everything” plateau.
  • Full price on false accepts. Accepting any negative costs a flat 1.0, so a pattern that accepts everything stays expensive.
  • A never-matching pattern NEVER_MATCHES$ as a bloat guard. Trees with nested quantifiers or more than Code Test quantifier nodes compile to it. This is not cosmetic: Python’s re engine backtracks exponentially on patterns like (?:(?:(?:\d*)*)+)*, and one such individual can stall the population evaluation for minutes. We measured this: an unguarded version of this demo hung the machine.
  1 # Example 3 -- Evolving Regular Expressions from String Examples
  2 #
  3 # GP where the "program" is a MATCHER instead of a computer: the evolved
  4 # tree is a pattern program that reads a string and returns accept/reject.
  5 # This flips the usual GP setup in an instructive way -- the phenotype is a
  6 # piece of text-processing logic, the fitness cases are example strings,
  7 # and the primitives are regex operators instead of arithmetic.
  8 #
  9 # Task: synthesize a validator for 24-hour clock times ("09:15", "23:59")
 10 # from POSITIVE and NEGATIVE examples only. Nobody tells GP the structure
 11 # of clock arithmetic; it has to discover that the hour's first digit is
 12 # limited to [0-2], that the minute tens digit is limited to [0-5], and so
 13 # on -- purely from the pain of accepting "24:00" or rejecting "09:15".
 14 #
 15 # Fitness is example-weighted error with a smoothing trick: an unmatched
 16 # positive is charged (1 - best_prefix_fraction) instead of a flat 1.0 --
 17 # how much of the string the pattern could match from the left. Accepting
 18 # a negative still costs a full 1.0, so the search cannot cheat by
 19 # accepting every string, while partial credit stops it from getting
 20 # trapped in plateaus like `(?:(?:[0-2]|:))+` (rejects everything, matches
 21 # almost nothing). A small size term breaks ties toward shorter, cleaner
 22 # patterns.
 23 #
 24 # The terminal alphabet is *pre-generalized*: it contains character-class
 25 # fragments like [0-3] and [4-9] alongside single literals, mimicking how
 26 # real GP-regex tools build patterns from library components.
 27 #
 28 # References:
 29 #   Bartoli, De Lorenzo, Medvet & Tarlao, "Playing Regex Golf with Genetic
 30 #   Programming" (GECCO 2014):
 31 #                   https://doi.org/10.1145/2576768.2598333
 32 #   Bartoli, De Lorenzo, Medvet & Tarlao, "On the Automatic Construction
 33 #   of Regular Expressions from Examples (GP vs. Humans 1-0)" (GECCO
 34 #   2016):          https://doi.org/10.1145/2908961.2930946
 35 #   Python re module:           https://docs.python.org/3/library/re.html
 36 
 37 from __future__ import annotations
 38 
 39 import functools
 40 import random
 41 import re
 42 from typing import Final
 43 
 44 from gp_core import (
 45     TerminalSampler,
 46     Tree,
 47     evolve,
 48     mutate_hoist,
 49     mutate_point,
 50     mutate_subtree,
 51     random_tree,
 52 )
 53 
 54 SEED: Final = 1  # chosen so this run converges quickly and repeatably
 55 
 56 POSITIVE: Final[list[str]] = [
 57     "09:15",
 58     "23:59",
 59     "00:01",
 60     "17:42",
 61     "08:05",
 62     "12:00",
 63     "01:30",
 64     "20:47",
 65 ]
 66 NEGATIVE: Final[list[str]] = [
 67     "24:00",  # hour out of range
 68     "29:00",  # hour out of range (also punishes [0-5]-for-hours)
 69     "09:5",  # single-digit minutes
 70     "9:15",  # single-digit hour
 71     "0915",  # missing separator
 72     "ab:cd",  # not digits
 73     "09:60",  # minute out of range
 74     "1:2:3",  # extra separator
 75     "",  # empty input
 76     "09:1a",  # non-digit tail
 77     "123:45",  # too many hour digits
 78 ]
 79 
 80 # (negative string, cost of accepting it). All equal here; the tuple form
 81 # leaves room to weight near-miss cases differently.
 82 NEGATIVE_WEIGHTS: Final[list[tuple[str, float]]] = [(s, 1.0) for s in NEGATIVE]
 83 
 84 # ---------------------------------------------------------------------------
 85 # Pattern-program function set. Every node compiles to a regex fragment;
 86 # unary ops wrap children in (?:...) so precedence is always safe.
 87 # ---------------------------------------------------------------------------
 88 
 89 PATTERN_OPS: Final[dict[str, int]] = {
 90     "cat": 2,  # concatenation:  a b
 91     "alt": 2,  # alternation:    a|b
 92     "opt": 1,  # optional:       a?
 93     "star": 1,  # Kleene star:    a*
 94     "plus": 1,  # one-or-more:    a+
 95 }
 96 
 97 # The quantifier operators, used by the anti-backtracking guard.
 98 QUANTIFIER_OPS: Final = frozenset({"opt", "star", "plus"})
 99 
100 # Terminal alphabet -> regex fragment. Pre-generalized classes do most of
101 # the "cleverness"; GP discovers WHERE they must appear. Deliberately NO
102 # "." wildcard: with prefix credit below, `.` would grant every positive
103 # full credit and flatten the whole fitness landscape into plateaus.
104 TERMINAL_FRAGMENTS: Final[dict[str, str]] = {
105     "D": r"\d",  # any digit
106     "h1": r"[0-1]",  # hour tens digit 0 or 1
107     "h2": r"[0-2]",  # ... alternative tens constraint
108     "h3": r"[0-3]",  # hour units digit when the tens digit is 2
109     "h9": r"[3-9]",  # hour units digit paired with a 0/1 tens digit
110     "m1": r"[0-5]",  # minute tens constraint
111     "m9": r"[6-9]",  # minute tens digit that makes the time invalid
112     "c": r":",  # the separator, as a literal
113     "d0": r"0",
114     "d1": r"1",
115     "d2": r"2",
116     "d5": r"5",
117     "d9": r"9",
118 }
119 
120 TERMINAL_POOL: Final[list[str]] = list(TERMINAL_FRAGMENTS)
121 
122 FUNCTION_ARITIES: Final[dict[str, int]] = dict(PATTERN_OPS)
123 
124 SIZE_PRESSURE: Final = 0.005  # fitness = example error + 0.5% per tree node
125 MAX_QUANTIFIERS: Final = 4  # anti-backtracking guard (see compile_tree)
126 MAX_SOURCE_LEN: Final = 300
127 
128 NEVER_MATCHES: Final = r"(?!a)a"  # impossible pattern: fails instantly
129 
130 
131 def terminals(rng: random.Random) -> TerminalSampler:
132     """Bias leaf sampling toward the meaningful classes, not bare literals."""
133     weights = {"D": 4, "h1": 4, "h2": 3, "h3": 4, "h9": 2, "m1": 4, "m9": 2, "c": 4}
134     pool: list[str] = []
135     for name in TERMINAL_POOL:
136         pool.extend([name] * weights.get(name, 1))
137 
138     def sample() -> str:
139         return rng.choice(pool)
140 
141     return sample
142 
143 
144 # ---------------------------------------------------------------------------
145 # Compile a pattern tree into an anchored regex string (and evaluate it).
146 # ---------------------------------------------------------------------------
147 
148 
149 def quantifier_stats(tree: Tree) -> tuple[int, bool]:
150     """Return (number of quantifier nodes, whether any quantifier nests).
151 
152     `nested` is True when a quantifier node has another quantifier anywhere
153     below it, the shape that makes the backtracking `re` engine exponential
154     even on 6-character strings: `(?:(?:(?:[0-5]*)*)+)*` can hang a CPU
155     forever. One post-order pass answers both questions.
156     """
157     total = 1 if tree.name in QUANTIFIER_OPS else 0
158     nested = False
159     for child in tree.args:
160         child_total, child_nested = quantifier_stats(child)
161         total += child_total
162         if child_nested or (tree.name in QUANTIFIER_OPS and child_total):
163             nested = True
164     return total, nested
165 
166 
167 def compile_tree(tree: Tree) -> str:
168     r"""Tree -> regex source string.
169 
170     Bloat guard: trees with more than `MAX_QUANTIFIERS` quantifier nodes,
171     NESTED quantifiers, or an absurdly long source compile to a
172     never-matching pattern. Without this, degenerate candidates like the
173     greedy `(?:\d|:)+` or `(?:(?:[0-5]*)*)+` make fitness evaluation take
174     exponential time on a six-character string -- a dramatic demonstration
175     that fitness evaluation must stay computable. The exact solution here
176     uses no quantifiers at all, so the guard only removes junk.
177 
178     The guard is a heuristic, not a proof: a single quantifier over an
179     ambiguous alternation (for example one digit class followed by another)
180     can still backtrack. It is enough because these fitness cases are at
181     most six characters.
182     """
183     count, nested = quantifier_stats(tree)
184     if count > MAX_QUANTIFIERS or nested:
185         return NEVER_MATCHES
186     source = _compile_node(tree)
187     return source if len(source) <= MAX_SOURCE_LEN else NEVER_MATCHES
188 
189 
190 def _compile_node(tree: Tree) -> str:
191     if tree.is_leaf:
192         return TERMINAL_FRAGMENTS.get(tree.name, re.escape(tree.name))
193     arity = PATTERN_OPS.get(tree.name)
194     if arity is None:  # defensive: unknown function
195         return NEVER_MATCHES
196     kids = [_compile_node(child) for child in tree.args]
197     if len(kids) != arity:  # defensive: malformed tree
198         return NEVER_MATCHES
199     match tree.name:
200         case "cat":
201             return kids[0] + kids[1]
202         case "alt":
203             return f"(?:{kids[0]}|{kids[1]})"
204         case "opt":
205             return f"(?:{kids[0]})?"
206         case "star":
207             return f"(?:{kids[0]})*"
208         case "plus":
209             return f"(?:{kids[0]})+"
210         case _:
211             raise AssertionError(f"unknown pattern op {tree.name!r}")
212 
213 
214 @functools.lru_cache(maxsize=8192)
215 def compile_cached(source: str) -> re.Pattern[str] | None:
216     """Compile anchored regex text once per distinct string; None if invalid."""
217     try:
218         return re.compile(f"^(?:{source})$")
219     except re.error:
220         return None
221 
222 
223 def prefix_credit(tree: Tree, text: str) -> float:
224     """Fraction of `text` the pattern can consume from the left (1.0 = match).
225 
226     Turns the staircase fitness into a ramp: a pattern that gets half of
227     "23:59" right is closer to the truth than one that cannot even start.
228     Note we must ask "can the WHOLE prefix k be matched?" (fullmatch on
229     text[:k]) -- a plain .match() succeeds as long as the pattern consumes
230     even one character, which would silently grant full credit.
231     """
232     if not text:
233         return 0.0
234     anchored = compile_cached(compile_tree(tree))
235     if anchored is None:
236         return 0.0
237     for k in range(len(text), 0, -1):
238         if anchored.fullmatch(text[:k]):
239             return k / len(text)
240     return 0.0
241 
242 
243 def matches(tree: Tree, text: str) -> bool:
244     pattern = compile_cached(compile_tree(tree))
245     return pattern is not None and pattern.fullmatch(text) is not None
246 
247 
248 def fitness(tree: Tree) -> float:
249     r"""Weighted example error + tiny pressure toward short patterns.
250 
251     Rejecting a positive costs (1 - prefix_credit); accepting a negative
252     costs its full weight -- see module docstring for why this mix keeps
253     both degenerate strategies (the greedy `(?:\d|:)+` and the
254     never-matching pattern) bad while still giving the search somewhere to
255     climb.
256     """
257     error = 0.0
258     for text in POSITIVE:
259         error += 1.0 - prefix_credit(tree, text)
260     for text, weight in NEGATIVE_WEIGHTS:
261         if matches(tree, text):
262             error += weight
263     return error + SIZE_PRESSURE * tree.size
264 
265 
266 def render(tree: Tree) -> str:
267     """Pattern source, kept short for progress lines."""
268     text = compile_tree(tree)
269     return text if len(text) <= 58 else text[:55] + "..."
270 
271 
272 def build_population(rng: random.Random, size: int, max_depth: int) -> list[Tree]:
273     sampler = terminals(rng)
274     return [
275         random_tree(
276             rng,
277             FUNCTION_ARITIES,
278             sampler,
279             max_depth=max_depth,
280             min_depth=3,
281             method="grow",
282         )
283         for _ in range(size)
284     ]
285 
286 
287 def main() -> None:
288     rng = random.Random(SEED)
289     population = build_population(rng, size=200, max_depth=9)
290     sampler = terminals(rng)
291 
292     def mutate(tree: Tree) -> Tree:
293         roll = rng.random()
294         if roll < 0.5:
295             return mutate_subtree(rng, tree, FUNCTION_ARITIES, sampler, max_depth=4)
296         if roll < 0.85:
297             return mutate_point(rng, tree, FUNCTION_ARITIES, sampler)
298         return mutate_hoist(rng, tree)
299 
300     def report(generation: int, best_fitness: float, best: Tree) -> None:
301         accepted = sum(1 for s in POSITIVE if matches(best, s))
302         rejected = sum(1 for s, _w in NEGATIVE_WEIGHTS if not matches(best, s))
303         print(
304             f"gen {generation:3d}  fit {best_fitness:6.3f}  "
305             f"pos {accepted}/{len(POSITIVE)}  neg {rejected}/{len(NEGATIVE)}  {render(best)[:40]}"
306         )
307 
308     print("# Evolving a regular expression for 24-hour clock times")
309     print(f"# {len(POSITIVE)} positive, {len(NEGATIVE)} negative example strings\n")
310 
311     result = evolve(
312         rng,
313         population,
314         fitness,
315         mutate,
316         generations=100,
317         crossover_rate=0.85,
318         mutation_rate=0.12,
319         tournament_size=5,
320         elitism=2,
321         max_depth=12,
322         target=SIZE_PRESSURE * 20,  # zero example error with <= 20 nodes
323         reporter=report,
324     )
325 
326     best_source = compile_tree(result.best)
327     print(f"\nEvolved pattern ({result.best.size} nodes, depth {result.best.depth}):")
328     print(f"  ^{best_source}$")
329     print(f"  all examples satisfied: {result.solved}")
330 
331     print("\n  input       expected  evolved")
332     for text in POSITIVE:
333         print(f"  {text:<10}  MATCH     {'MATCH' if matches(result.best, text) else 'no match'}")
334     for text in NEGATIVE:
335         shown = text if text else "<empty>"
336         verdict = "REJECTED" if not matches(result.best, text) else "accepted (!)"
337         print(f"  {shown:<10}  reject    {verdict}")
338 
339     # Show generalization: strings the GP system never saw.
340     print("\n  unseen probes:")
341     for text in ["05:59", "23:61", "22:00", "07:7", "11:11", "2:03"]:
342         verdict = "MATCH" if matches(result.best, text) else "reject"
343         truth = "valid" if re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", text) else "invalid"
344         print(f"  {text:<8} -> {verdict:<7} ({truth})")
345 
346 
347 if __name__ == "__main__":
348     main()

Running it

1 make regex    # or: uv run python example_3_regex_synthesis.py

Output (seed 1):

 1 # Evolving a regular expression for 24-hour clock times
 2 # 8 positive, 11 negative example strings
 3 
 4 gen   0  fit  5.430  pos 0/8  neg 11/11  (?:9[3-9]|(?:[0-3])+)
 5 gen   1  fit  5.410  pos 0/8  neg 11/11  (?:[0-3])+
 6 gen   2  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
 7 gen   3  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
 8 gen   4  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
 9 gen   5  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
10 gen   6  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
11 gen   7  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
12 gen   8  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
13 gen   9  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
14 gen  10  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
15 gen  11  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
16 gen  12  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
17 gen  13  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
18 gen  14  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
19 gen  15  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
20 gen  16  fit  4.830  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:[0-3])+)
21 gen  17  fit  4.070  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:(?:5:)+|[0-1]\d:))
22 gen  18  fit  3.680  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:(?:[0-3][3-9]|(?:[0-3])
23 gen  19  fit  3.270  pos 0/8  neg 11/11  (?:[0-3][3-9]|(?:(?:5:)+|[0-5]\d:))
24 gen  20  fit  3.260  pos 0/8  neg 11/11  (?:(?:[0-3][3-9]|(?:[0-3])+)(?::)+|(?:[0
25 gen  21  fit  2.730  pos 0/8  neg 11/11  (?:[0-3](?:[0-3](?::)+|:)[3-9]|(?:(?:5:)
26 gen  22  fit  2.730  pos 0/8  neg 11/11  (?:[0-3](?:[0-3](?::)+|:)[3-9]|(?:(?:5:)
27 gen  23  fit  2.720  pos 0/8  neg 11/11  (?:[0-3](?:[0-3](?::)+|:)[3-9]|(?:(?:[0-
28 gen  24  fit  2.350  pos 0/8  neg 11/11  (?:(?:[0-3](?:[0-3](?::)+|[0-3])|(?:[0-3
29 gen  25  fit  1.755  pos 3/8  neg 11/11  (?:(?:[0-3](?:[0-3](?::)+|[0-3])|(?:[0-3
30 gen  26  fit  1.755  pos 3/8  neg 11/11  (?:(?:[0-3](?:[0-3](?::)+|[0-3])|(?:[0-3
31 gen  27  fit  1.755  pos 3/8  neg 11/11  (?:(?:[0-3](?:[0-3](?::)+|[0-3])|(?:[0-3
32 gen  28  fit  1.190  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:(?:[0-3](?:[
33 gen  29  fit  1.170  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:(?:[0-3](?:[
34 gen  30  fit  1.170  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:(?:[0-3](?:[
35 gen  31  fit  1.170  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:(?:[0-3](?:[
36 gen  32  fit  1.165  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:(?:[0-3](?:[
37 gen  33  fit  1.135  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:[0-3](?:[0-3
38 gen  34  fit  1.135  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:[0-3](?:[0-3
39 gen  35  fit  1.125  pos 8/8  neg 10/11  (?:[0-1]\d:(?:[0-5])+|(?:(?:[0-3](?:[0-3
40 gen  36  fit  0.140  pos 8/8  neg 11/11  (?:[0-1]\d:(?:[0-5])+(?:[0-5])+|(?:(?:[0
41 gen  37  fit  0.140  pos 8/8  neg 11/11  (?:[0-1]\d:(?:[0-5])+(?:[0-5])+|(?:(?:[0
42 gen  38  fit  0.130  pos 8/8  neg 11/11  (?:[0-1]\d:(?:[0-5])+(?:[0-5])+|(?:[0-3]
43 gen  39  fit  0.130  pos 8/8  neg 11/11  (?:[0-1]\d:(?:[0-5])+(?:[0-5])+|(?:[0-3]
44 gen  40  fit  0.125  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5](?:[0-5])+|(?:[0-3](?:[0
45 gen  41  fit  0.120  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5](?:[0-5])+|(?:(?:[0-3]\d
46 gen  42  fit  0.120  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5](?:[0-5])+|(?:(?:[0-3]\d
47 gen  43  fit  0.120  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5](?:[0-5])+|(?:(?:[0-3]\d
48 gen  44  fit  0.105  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5][0-5]|(?:[0-3]\d:|[0-5])
49 gen  45  fit  0.105  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5][0-5]|(?:[0-3]\d:|[0-5])
50 gen  46  fit  0.105  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5][0-5]|(?:[0-3]\d:|[0-5])
51 gen  47  fit  0.105  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5][0-5]|(?:[0-3]\d:|[0-5])
52 gen  48  fit  0.095  pos 8/8  neg 11/11  (?:[0-1]\d:[0-5][0-5]|[0-3]\d:\d[3-9])
53 
54 Evolved pattern (19 nodes, depth 6):
55   ^(?:[0-1]\d:[0-5][0-5]|[0-3]\d:\d[3-9])$
56   all examples satisfied: True
57 
58   input       expected  evolved
59   09:15       MATCH     MATCH
60   23:59       MATCH     MATCH
61   00:01       MATCH     MATCH
62   17:42       MATCH     MATCH
63   08:05       MATCH     MATCH
64   12:00       MATCH     MATCH
65   01:30       MATCH     MATCH
66   20:47       MATCH     MATCH
67   24:00       reject    REJECTED
68   29:00       reject    REJECTED
69   09:5        reject    REJECTED
70   9:15        reject    REJECTED
71   0915        reject    REJECTED
72   ab:cd       reject    REJECTED
73   09:60       reject    REJECTED
74   1:2:3       reject    REJECTED
75   <empty>     reject    REJECTED
76   09:1a       reject    REJECTED
77   123:45      reject    REJECTED
78 
79   unseen probes:
80   05:59    -> MATCH   (valid)
81   23:61    -> reject  (invalid)
82   22:00    -> reject  (valid)
83   07:7     -> reject  (invalid)
84   11:11    -> MATCH   (valid)
85   2:03     -> reject  (invalid)

Reading the results

Read the progress log as a story of discovery. For twenty generations the population only builds hour-fraction fragments (Code Test, Code Test) because partial credit pays for prefixes. At generation 25 the best pattern already accepts 3 of 8 positives. At generation 36 it accepts all 8 positives and rejects all 11 negatives, and the rest of the run is pure size pressure deleting wrapper nodes (Code Test simplifies toward Code Test).

The final answer deserves a close look:

1 ^(?:[0-1]\d:[0-5][0-5]|[0-3]\d:\d[3-9])$

It is not the textbook regex Code Test. The first branch is textbook-like (Code Test hours, Code Test minutes). The second branch handles the 2-o’clock hours in a way a human would not write: Code Test for the hours, followed by Code Test for the minutes. Why is that correct on all examples? It never has to be “the minutes rule”. It only has to reject Code Test and Code Test while accepting Code Test. Pairing “hours in [0-3]\d” with “minute units in [3-9]$” does exactly that on this example set, and the two branches together cover all eight positives. It satisfies the specification-as-examples. It is also wrong about clock times in general, and the unseen-probes section prints the confession: Code Test is a valid time that this pattern rejects.

That is the overfitting lesson of this chapter, delivered automatically: GP searched until the fitness cases were exhausted, and the fitness cases were all it ever saw. More negative examples (Code Test as a positive would have done it) or a held-out validation set are the standard cures.

Example 4: Evolving plant growth rules

The last example evolves the most “program-like” individuals yet: L-system growth rules, judged by the organisms they grow. An L-system rewrites a string iteratively; here the rule is F -> (something), applied four times starting from the axiom F, and the final string is read by a turtle:

1 F  move forward, drawing a segment
2 f  move forward WITHOUT drawing
3 +  turn left 25 degrees     -  turn right 25 degrees
4 [  push position and heading    ]  pop them back  (a branch)

A GP tree, say seq(F, branch(seq(+, F))), renders to the rule text F[+F]. Apply it, draw it, and compare the painted cells against a target plant grown from a hidden rule. Fitness is the Jaccard distance between the two cell sets:

math

with 0.0 meaning pixel-perfect and larger values meaning less overlap. Every candidate paints the shared start cell, so the distance never quite reaches 1.0.

This demo shows the genotype-phenotype distinction more sharply than the other three. The genotype is a small rule tree (the winner below uses 25 nodes); the phenotype is a plant built from a string of thousands of characters grown by iterating one rule four times. Selection never touches the string; it only sees the cells.

One tuning lesson, recorded in NOTES.md: with five rewrite iterations every candidate plant saturates the grid into a dense blob, the Jaccard score stops discriminating, and runs plateau around 0.34 forever. Four iterations keep the target sparse and the landscape informative. When a GP run “cannot improve,” check whether the fitness function still distinguishes good from better.

  1 # Example 4 -- Evolving L-System Growth Rules to Match a Target Plant
  2 #
  3 # GP where the evolved object is a BIOLOGICAL DEVELOPMENT PROGRAM: an
  4 # L-system rule whose repeated application grows a plant, and whose fitness
  5 # is the plant's shape. This is the most "developmental" of the four demos --
  6 # a tiny genotype (the rule tree) unfolds into a large phenotype (the
  7 # rendered plant) through iterative rewriting, exactly as genes unfold
  8 # into organisms.
  9 #
 10 # Task: we show GP a target plant -- grown by a known rule we keep secret --
 11 # and evolve replacement rules F -> ... until the evolved plant's occupied
 12 # cells overlap the target's cells. The fitness is the JACCARD DISTANCE of
 13 # the two cell sets: 0.0 means "pixel-perfect" and higher is worse. Every
 14 # candidate paints the shared start cell, so the distance stays below 1.0.
 15 # Because every candidate is rendered to the same grid, the landscape is
 16 # surprisingly smooth: rules that grow "taller, then bushier, then leaning
 17 # left" climb the fitness staircase gradually.
 18 #
 19 # Turtle semantics (classic L-system drawing):
 20 #   F  move forward, drawing a segment
 21 #   f  move forward WITHOUT drawing (a stem you cannot see)
 22 #   +  turn left 25 degrees        -  turn right 25 degrees
 23 #   [ push position+heading         ]  pop them back (a branch!)
 24 #
 25 # References:
 26 #   L-systems:       https://en.wikipedia.org/wiki/L-system
 27 #   Prusinkiewicz & Lindenmayer, "The Algorithmic Beauty of Plants"
 28 #   Jacob, C. (1994) "Genetic L-System Programming", PPSN III:
 29 #                    https://doi.org/10.1007/3-540-58484-6_277
 30 
 31 from __future__ import annotations
 32 
 33 import math
 34 import random
 35 from typing import Final
 36 
 37 from gp_core import (
 38     TerminalSampler,
 39     Tree,
 40     evolve,
 41     mutate_hoist,
 42     mutate_point,
 43     mutate_subtree,
 44     random_tree,
 45 )
 46 
 47 SEED: Final = 42
 48 GRID_W: Final = 61
 49 GRID_H: Final = 31
 50 ITERATIONS: Final = 4
 51 MAX_EXPANSION: Final = 12000  # rewrite length cap (anti-explosion guard)
 52 TURN_DEG: Final = 25.0
 53 
 54 # Rule-grammar function set: seq glues tokens, branch wraps in [ ].
 55 FUNCTION_ARITIES: Final[dict[str, int]] = {"seq": 2, "branch": 1}
 56 
 57 # Turtle alphabet as terminals. 'branch'/'seq' are the only nonterminals.
 58 TERMINAL_POOL: Final[list[str]] = ["F", "F", "F", "f", "+", "-"]
 59 
 60 
 61 def terminals(rng: random.Random) -> TerminalSampler:
 62     pool = list(TERMINAL_POOL)
 63 
 64     def sample() -> str:
 65         return rng.choice(pool)
 66 
 67     return sample
 68 
 69 
 70 # ---------------------------------------------------------------------------
 71 # Genotype -> phenotype: expand the rule tree, rewrite the axiom, draw.
 72 # ---------------------------------------------------------------------------
 73 
 74 
 75 def rule_text(tree: Tree) -> str:
 76     """Render a rule tree as L-system RHS text, e.g. F[+F][-F]F."""
 77     if tree.is_leaf:
 78         return tree.name
 79     kids = [rule_text(child) for child in tree.args]
 80     if tree.name == "seq":
 81         return kids[0] + kids[1]
 82     return f"[{kids[0]}]"
 83 
 84 
 85 def expand(rule: str, iterations: int = ITERATIONS) -> str:
 86     """Apply `F -> rule` to every F, `iterations` times, from axiom F."""
 87     string = "F"
 88     for _ in range(iterations):
 89         string = string.replace("F", rule)
 90         if len(string) > MAX_EXPANSION:
 91             return string[:MAX_EXPANSION]
 92     return string
 93 
 94 
 95 def render_grid(string: str) -> set[tuple[int, int]]:
 96     """Turtle-walk an L-system string; return the set of painted cells."""
 97     x = GRID_W // 2.0
 98     y = GRID_H - 1.0
 99     heading = 90.0  # pointing up
100     stack: list[tuple[float, float, float]] = []
101     cells: set[tuple[int, int]] = {(int(round(x)), int(round(y)))}
102 
103     for token in string:
104         if token == "F":
105             angle = math.radians(heading)
106             x += math.cos(angle)
107             y -= math.sin(angle)  # image rows grow downward
108             cell = (int(round(x)), int(round(y)))
109             if 0 <= cell[0] < GRID_W and 0 <= cell[1] < GRID_H:
110                 cells.add(cell)
111         elif token == "f":
112             angle = math.radians(heading)
113             x += math.cos(angle)
114             y -= math.sin(angle)
115         elif token == "+":
116             heading += TURN_DEG
117         elif token == "-":
118             heading -= TURN_DEG
119         elif token == "[":
120             stack.append((x, y, heading))
121         elif token == "]" and stack:
122             x, y, heading = stack.pop()
123     return cells
124 
125 
126 # The hidden target plant: grown from a rule GP must rediscover.
127 TARGET_RULE: Final = "F[+F]F[-F]F"
128 TARGET_CELLS: Final[set[tuple[int, int]]] = render_grid(expand(TARGET_RULE))
129 
130 
131 def jaccard_distance(tree: Tree) -> float:
132     """1 - |overlap| / |union| of painted cells against the target plant.
133 
134     The turtle always paints its start cell, so both sets are non-empty and
135     the result is 0.0 for a pixel-perfect plant.
136     """
137     cells = render_grid(expand(rule_text(tree)))
138     union = len(cells | TARGET_CELLS)
139     return 1.0 - len(cells & TARGET_CELLS) / union
140 
141 
142 def print_plant(cells: set[tuple[int, int]], label: str) -> None:
143     """Print one plant as an ASCII grid under a heading."""
144     print(f"\n{label}")
145     for row in range(GRID_H):
146         print("  " + "".join("#" if (col, row) in cells else "." for col in range(GRID_W)))
147 
148 
149 def build_population(rng: random.Random, size: int, max_depth: int) -> list[Tree]:
150     sampler = terminals(rng)
151     return [
152         random_tree(
153             rng,
154             FUNCTION_ARITIES,
155             sampler,
156             max_depth=max_depth,
157             min_depth=2,
158             method="grow",
159         )
160         for _ in range(size)
161     ]
162 
163 
164 def main() -> None:
165     rng = random.Random(SEED)
166     population = build_population(rng, size=300, max_depth=7)
167     sampler = terminals(rng)
168 
169     def mutate(tree: Tree) -> Tree:
170         roll = rng.random()
171         if roll < 0.5:
172             return mutate_subtree(rng, tree, FUNCTION_ARITIES, sampler, max_depth=3)
173         if roll < 0.85:
174             return mutate_point(rng, tree, FUNCTION_ARITIES, sampler)
175         return mutate_hoist(rng, tree)
176 
177     def report(generation: int, best_fitness: float, best: Tree) -> None:
178         print(f"gen {generation:3d}  jaccard {best_fitness:6.3f}  rule F -> {rule_text(best)[:30]}")
179 
180     print("# Evolving an L-system growth rule to reproduce a target plant")
181     print(f"# grid {GRID_W}x{GRID_H}, {ITERATIONS} rewrite iterations, turn {TURN_DEG:.0f} deg\n")
182 
183     result = evolve(
184         rng,
185         population,
186         jaccard_distance,
187         mutate,
188         generations=70,
189         crossover_rate=0.85,
190         mutation_rate=0.12,
191         tournament_size=6,
192         elitism=2,
193         max_depth=10,
194         target=0.02,
195         reporter=report,
196     )
197 
198     print(f"\nsecret target rule was:  F -> {TARGET_RULE}")
199     print(f"evolved rule:            F -> {rule_text(result.best)}")
200     print(
201         f"shape distance {result.best_fitness:.3f} after {result.generations} generations "
202         f"(pixel-perfect: {result.best_fitness == 0.0})"
203     )
204 
205     print_plant(TARGET_CELLS, "TARGET PLANT (the hidden rule's phenotype):")
206     print_plant(render_grid(expand(rule_text(result.best))), "EVOLVED PLANT (GP's rule phenotype):")
207 
208 
209 if __name__ == "__main__":
210     main()

Running it

1 make plants    # or: uv run python example_4_lsystem_plants.py

Output (seed 42), including both ASCII-art plants:

 1 # Evolving an L-system growth rule to reproduce a target plant
 2 # grid 61x31, 4 rewrite iterations, turn 25 deg
 3 
 4 gen   0  jaccard  0.608  rule F -> [FF[f]]fF[F]FFF
 5 gen   1  jaccard  0.538  rule F -> [[[F]]]F[[f]][[F]]FF[f][-FFF]
 6 gen   2  jaccard  0.538  rule F -> [[[F]]]F[[f]][[F]]FF[f][-FFF]
 7 gen   3  jaccard  0.537  rule F -> [[[[F]F]]]F[FfF]FF[-F[F]]
 8 gen   4  jaccard  0.537  rule F -> [[[[F]F]]]F[FfF]FF[-F[F]]
 9 gen   5  jaccard  0.467  rule F -> [[[F]]]F[[f]][F]FF[f][F[[f]][[
10 gen   6  jaccard  0.467  rule F -> [[[F]]]F[[f]][F]FF[f][F[[f]][[
11 gen   7  jaccard  0.458  rule F -> [F+F]F[FfF]FF[-F[F]]
12 gen   8  jaccard  0.458  rule F -> [F+F]F[FfF]FF[-F[F]]
13 gen   9  jaccard  0.432  rule F -> [F+F]F[Ff[+]]FF[-[F]]
14 gen  10  jaccard  0.395  rule F -> [F+F]F[Ff[+]]FF[[[F]]]
15 gen  11  jaccard  0.160  rule F -> [F+F]F[F-F]FF[f[F]]
16 gen  12  jaccard  0.160  rule F -> [F+F]F[F-F]FF[f[F]]
17 gen  13  jaccard  0.000  rule F -> [F+F]F[F-F]FF[f[+]]
18 
19 secret target rule was:  F -> F[+F]F[-F]F
20 evolved rule:            F -> [F+F]F[F-F]FF[f[+]]
21 shape distance 0.000 after 13 generations (pixel-perfect: True)
22 
23 TARGET PLANT (the hidden rule's phenotype):
24   ............................###..............................
25   .............................##..............................
26   ..............................#..............................
27   ..............................##.............................
28   ..............................###.#..........................
29   ..............................#..###.........................
30   .............................#####...........................
31   .............................#####...........................
32   ..............................###............................
33   ..............................##.............................
34   ..............................###............................
35   ..............................#..............................
36   ..............................##.............................
37   ..........................###.###............................
38   ..........................##..#..............................
39   ...........................####..............................
40   ..........................#####..............................
41   ...........................##.#..............................
42   ............................###..............................
43   .............................##..............................
44   ..............................#..............................
45   ..............................##.............................
46   ..............................###............................
47   ..............................#..............................
48   .............................##..............................
49   .............................##..............................
50   ..............................#..............................
51   ..............................#..............................
52   ..............................#..............................
53   ..............................#..............................
54   ..............................#..............................
55 
56 EVOLVED PLANT (GP's rule phenotype):
57   ............................###..............................
58   .............................##..............................
59   ..............................#..............................
60   ..............................##.............................
61   ..............................###.#..........................
62   ..............................#..###.........................
63   .............................#####...........................
64   .............................#####...........................
65   ..............................###............................
66   ..............................##.............................
67   ..............................###............................
68   ..............................#..............................
69   ..............................##.............................
70   ..........................###.###............................
71   ..........................##..#..............................
72   ...........................####..............................
73   ..........................#####..............................
74   ...........................##.#..............................
75   ............................###..............................
76   .............................##..............................
77   ..............................#..............................
78   ..............................##.............................
79   ..............................###............................
80   ..............................#..............................
81   .............................##..............................
82   .............................##..............................
83   ..............................#..............................
84   ..............................#..............................
85   ..............................#..............................
86   ..............................#..............................
87   ..............................#..............................

Reading the results

The run goes 0.608 to 0.537 to 0.467 to 0.395, then 0.160 at generation 11, then 0.000 at generation 13: thirteen generations, a few thousand evaluated plants, one perfect plant. Look at the two rules:

1 secret target rule: F -> F[+F]F[-F]F
2 evolved rule:       F -> [F+F]F[F-F]FF[f[+]]

They are not the same rule. They grow the same set of cells on this grid. The turtle’s world is 61 by 31 integer cells and a plain Code Test step and an Code Test inside a pushed branch can land on identical cells when the turn angles are small; the evolved rule’s trailing Code Test pushes an invisible stub that paints nothing. Both plants therefore have the same phenotype. The final lines print both grids so you can verify the claim yourself.

This is why GP papers insist on behavioral fitness evaluation and on validation examples: scoring the genotype (the rule text) would be a string-edit metric with no relationship to the plants, and scoring behavior on one example plant (as we do) is perfect only up to equivalence classes GP never sees through.

The wider family of GP methods

Tree GP is the original form and still the most common, but it is one member of a family. Most variants exist to fix a specific weakness of plain tree GP, and knowing which one to reach for saves a lot of wasted tuning.

Variant Idea Fixes
Tree GP programs are expression trees (this chapter) the baseline
Strongly typed GP every node has a type, and crossover respects it mixing numbers, Booleans, and other types
Linear GP programs are sequences of register-machine instructions faster evaluation, easier constants
Grammatical evolution a list of integers is mapped through a grammar guarantees syntax, supports any grammar
Gene expression programming a fixed-length chromosome decodes to expression trees keeps a simple genome, gains tree flexibility
Cartesian GP programs are graphs of indexed nodes circuits, neural networks, reusable structure
PushGP programs are stack code in a typed language multiple data types, self-modifying programs
Semantic GP operators act on program behavior, not syntax smoother landscapes for regression
Multi-objective GP optimizes accuracy and size together principled bloat control
Island and age-layered models subpopulations exchange migrants, or ages are layered premature convergence and diversity loss

A few of these deserve one sentence each:

  • Strongly typed GP (Montana, 1995) gives every node a type. Crossover only swaps subtrees of compatible types, so a Boolean can never be dropped into a numeric argument. If you want to evolve a program that mixes numbers, Booleans, and sequences, this is the variant you want.
  • Linear GP (Nordin, 1994) represents a program as a sequence of instructions for a simple register machine. Evaluation is a fast loop over a list, and constants are easier to handle than they are in trees.
  • Grammatical evolution (Ryan, Collins and O’Neill, 1998) keeps a fixed-length list of integers as the genotype and uses it to choose production rules from a grammar. The grammar guarantees that every decoded program is syntactically valid, which is a strong advantage when the target language has a strict syntax.
  • Gene expression programming (Ferreira, 2001) uses a fixed-length chromosome that decodes into expression trees, combining a simple genome with tree-shaped programs.
  • Cartesian GP (Miller and Thomson, 2000) evolves a directed graph of indexed nodes. It is a natural fit for circuits and neural networks, where reuse and fan-out matter more than tree structure.
  • PushGP (Spector and Robinson, 2002) evolves stack programs in the Push language, which supports several data types and even self-modifying code. It has produced strong results on program synthesis benchmarks.
  • Semantic and geometric semantic GP (Moraglio, Krawiec and Johnson, 2012) define operators that act on the vector of program outputs rather than on syntax. For regression problems this turns a rugged landscape into a smoother one and can speed convergence dramatically.
  • Multi-objective GP uses a Pareto-based algorithm such as NSGA-II (Deb et al., 2002) to keep a front of accuracy-versus-size trade-offs, which is a cleaner way to control bloat than a hand-tuned penalty.
  • Island models and age-layered populations (ALPS, Hornby, 2006) split the population into subpopulations that exchange a few migrants, or protect young individuals from competition with older ones. Both slow premature convergence and are standard in long runs.

There are also hybrids. Memetic GP runs a local optimizer on the constants inside each tree. GP can seed a neural network’s architecture, or a neural network can guide GP’s operator choice. And a large language model can now propose an initial population or a function set for GP to optimize, which combines the model’s broad prior knowledge with GP’s exact, measurable search.

What GP is good at, and when not to reach for it

GP is not a general replacement for machine learning. It is a specific tool for a specific situation, and it pays to know which situation you are in.

Where GP shines

  • The structure is unknown. You can write down the inputs, the outputs, and a way to score a candidate, but not the model.
  • You can simulate the objective. A physics engine, a circuit simulator, a parser, or a game gives you a behavioral score with no gradient.
  • The answer must be readable. A short formula, a gate netlist, or a regex can be reviewed by a domain expert in a way that a weight matrix cannot.
  • Data is scarce. GP can work from a small set of examples or a simulator, and it does not need millions of labeled points.
  • You want several good answers. A population gives you a set of diverse solutions, not one point estimate, which is useful when the final choice involves constraints the fitness did not encode.
  • A few million evaluations are affordable. That is the realistic currency of a GP run.

Where GP struggles

  • Gradients are cheap. If backpropagation or least squares applies, use it. GP will be slower and less accurate.
  • Fitness is very expensive or very noisy. Every candidate costs an evaluation, so a fitness function that takes minutes or returns a different answer each time is a serious obstacle. Surrogate models and averaging help, but only so much.
  • The landscape is deceptive. Parity is the standard example: the fraction of correct rows gives almost no signal until the program is nearly complete, so search is closer to guessing. Example 2 discusses this.
  • You need guarantees. GP is a stochastic search with no correctness proof. Safety-critical logic needs formal verification on top of whatever GP proposes.
  • The solution needs long-range coordination. Problems where many parts must be right simultaneously, with no partial credit, are hard for any local search.
  • You cannot afford to repeat the run. A single lucky seed is not evidence. If you cannot afford dozens of runs, you cannot say much about the method’s reliability.

GP and its neighbors, honestly

  • Versus neural networks. Use networks for perception, high-dimensional input, and abundant data. Use GP for structure discovery, small data, and interpretable output. The two are complementary: a network can be a component inside a GP tree, or a fitness predictor.
  • Versus reinforcement learning. Reinforcement learning handles sequential decisions with delayed reward. GP can evolve a policy or a controller too, and it is often simpler when the policy can be written as a program and episodes are cheap.
  • Versus program synthesis. Constraint solvers and sketch-based synthesis are exact and fast when you have a formal specification. GP works from examples and a score, which is weaker but applies when no formal specification exists.
  • Versus a large language model. A language model proposes code from a description; GP optimizes code against measurements. Neither subsumes the other. Use the model to write candidate programs, the function set, or the test cases, and use GP to search the space the model suggests.

A note on stochasticity

GP is a randomized search. One run that finds a good answer proves very little, and one run that fails proves even less. Report the median and the spread over at least a few dozen seeds, and look at the best-so-far curves rather than only the final number. Every demo in this chapter takes a SEED constant for exactly this reason; changing it is the first experiment you should run.

GP in the real world

GP has been applied wherever a program can be scored. A short gallery of the areas where it has earned its keep:

  • Symbolic regression and scientific discovery. The oldest application is fitting a formula to data. Schmidt and Lipson’s 2009 result went further and recovered conservation laws and equations of motion from sensor data with no model supplied in advance. Symbolic regression is now used in physics, chemistry, biology, and engineering to turn measurements into interpretable equations.
  • Circuit design and evolvable hardware. Koza’s group evolved analog circuits that were patented as genuinely new designs, and GP has been used for filters, amplifiers, and digital logic. In the 1990s Adrian Thompson evolved a configuration for a field-programmable gate array that discriminated tones without a clock, exploiting the physical quirks of the chip in ways a human designer would not have tried.
  • Antenna design. Researchers at NASA used genetic programming to design spacecraft antennas with unusual shapes that met their performance specifications and flew on missions. This is a good example of a fitness function that is a physics simulation and a result that is a physical object.
  • Robot controllers and behavior. GP can evolve a control program directly from a simulated robot’s behavior, including walking gaits and navigation strategies. The fitness is the simulation, so no dynamics model needs to be differentiable.
  • Games and game AI. Evolving heuristics and opponent strategies for board games and video games, and generating content such as levels or rules.
  • Scheduling, routing, and logistics. Job-shop scheduling, vehicle routing, and resource allocation, where the evolved program is a dispatch rule.
  • Image and signal processing. Evolving filters, feature extractors, and small classifiers, often as a preprocessing step in front of a conventional learner.
  • Medicine and biology. Classifying medical signals, finding candidate biomarkers, and modeling biological networks. Interpretability is often the reason to prefer a formula over a black box.
  • Finance. Trading rules and risk models, where overfitting is severe and walk-forward validation is mandatory.
  • Program synthesis and testing. Evolving small programs to satisfy input/output examples or a test suite, and generating test inputs that expose bugs.
  • Quantum circuits. Evolving gate sequences to prepare states or approximate operators on noisy hardware.

Two patterns stand out. First, the fitness function is almost always a simulator or a scoring harness rather than a closed-form objective. Second, the useful answers tend to be small: a formula, a rule, a small circuit. GP’s reputation was built on problems where the result is a structure a human can inspect, simplify, and then trust.

A practical checklist for your own runs

The demos in this chapter are small enough to read in one sitting. A real project is bigger, and most failures come from skipping one of these steps.

  1. Write the specification before the code. Enumerate the inputs, the outputs, and what “good” means. If you cannot describe a perfect answer, GP cannot find one.
  2. Pick a representation. Tree GP is the default. Choose a variant only when you can name the weakness it fixes in your problem.
  3. Design the primitive sets for closure and sufficiency. Every function must accept every value; together the sets must be able to express a solution.
  4. Design the fitness function deliberately. Make it fast, graded, faithful, and guarded. Decide the score of doing nothing, and look for degenerate high scorers.
  5. Establish a baseline. Random search, a hand-written formula, or a simple learned model. If GP cannot beat the baseline, say so.
  6. Seed and repeat. Run at least 30 seeds before making a claim. Report the median, the spread, and the best-so-far curve.
  7. Split your cases. Train, validate, and hold out a test set. Check the winner on the test set before you believe it.
  8. Bound the resources. Depth and size caps, a timeout per evaluation, a node budget, and a wall-clock limit. Example 3 exists partly to show what happens without them.
  9. Control bloat. Parsimony pressure, multi-objective size, or hoist mutation. Choose one from the start, not after the trees explode.
  10. Simplify and validate the winner. Algebraically simplify it if you can, then test it on inputs the search never saw.
  11. Inspect the winner for loopholes. Ask whether the program is doing the task or exploiting the test set. Example 3’s regex is the cautionary tale.
  12. Consider a hybrid. Local search for constants, a cached simulator, or a language model for the initial population can all help, and none of them changes the GP loop.

One last piece of advice, easy to ignore: log more than fitness. Record tree sizes, the best program text, the number of evaluations, and the wall-clock time. When a run disappoints, those logs tell you whether the problem was the search, the fitness function, or the specification.

Wrap up

All four demos share one engine (gp_core.py, 381 lines) and differ only in what a tree means:

  • Representation is everything. Trees make crossover “always a valid program.” Examples 1 and 2 exploit it for functions and circuits; example 3 exploits it for pattern programs; example 4 for growth rules. The same crossover() call splices all of them.
  • You must pay for size control. Bloat is the default outcome of any GP setup that does not punish size: useless subtrees are free. We used parsimony terms (Code Test) in examples 1 and 3, a hard depth cap everywhere, hoist mutation in examples 2, 3, and 4, and a length-capped rewrite in example 4.
  • Fitness evaluation must stay computable and discriminative. Protected operators and finite returns (example 1), never-matching guards on exponentially slow regexes (example 3), and a grid resolution coarse enough to keep plants sparse (example 4) are all examples of the same engineering duty.
  • GP optimizes fitness cases, and stops there. The evolved regex rejects a valid time; the evolved plant rule differs from the hidden one. More fitness cases, held-out validation, and behavior-level tests are the difference between “fits the data” and “knows the rule”.

The chapter-level takeaway is that GP is the right tool precisely when you want the structure and cannot write it down beforehand: formulas, controllers, parsers, schedules. When you only need coefficients inside a known model class, use ordinary fitting. GP is slower and its output needs curation (simplify, validate, guard), but its answers are readable programs.

For larger work, use DEAP or gplearn. Their GP modules implement this same loop (typed trees, guarded operators, tournament selection, parsimony or multi-objective size control), and you can port every experiment here by swapping gp_core calls for theirs.

Practice problems

  1. Give GP exp. Add exp (protected: math.exp with the argument clamped to [-50, 50]) to example 1’s function set. Rerun and measure: does the evolved formula now express damping? Does its tree grow? What does PARSIMONY need to become to keep it small?
  2. NAND-only synthesis. Rewrite example 2’s function set to {NAND: 2}. The truth table is unchanged. NAND is functionally complete, so the optimum is reachable, but the search space is deeper. How many generations does the 32-row interlock now need? Then try parity-of-4 (Code Test) as the specification and observe why parity is the classic GP difficulty.
  3. A second regex, blindfolded. Point example 3 at a new task (US five-digit ZIP codes plus four-digit extensions, or dd.mm.yyyy dates): swap POSITIVE/NEGATIVE and re-generalize TERMINAL_FRAGMENTS. Keep prefix_credit and the quantifier guard; watch where the partial credit does and does not help, and design an example set that punishes the overfit you find.
  4. Different angle, different plant. Set example 4’s TURN_DEG to 60, 45, 90 and rerun. At 90 degrees plants degenerate into grid-aligned snakes; at 60 the target rule becomes easier. Report which angle gives the fastest convergence and explain the result in terms of cell collisions (the mechanism behind the “same phenotype, different genotype” observation).
  5. Selection pressure dial. In example 4 replace tournament size 6 with 2, 10, and 30 (with k=30 you get near-greedy selection). Plot generations-to-0 against tournament size. Explain the shape of that curve with one sentence about diversity.
  6. Initialization methods. Example 1 seeds its population with method="half"; examples 2-4 use "grow". Rerun example 1 with Code Test and with "grow" across five seeds each. Compare the generation-0 best MSE and the final size of the winner. Write the result in three sentences; the honest answer is “usually a little, and not reliably.”
  7. Validation split. Modify example 3 to train on a random half of the positives and report fitness on the other half. The textbook regex Code Test should now beat the two-branch overfit. That single change is the difference between a demo and a system.
  8. Paper GP. Using the four-individual example in “One generation by hand”, run two more generations with tournament size 2, a crossover rate of 1.0, and no mutation, keeping one elite. Record the population after each generation and explain why the average fitness falls even though no new program was designed.
  9. Design a fitness function, then break it. Pick a task you know well: valid email addresses, a bowling score, a pizza order. Write down the primitive sets and a fitness function, then find a degenerate program that scores well without solving the task. Every loophole you find is a lesson about specifications.
  10. Watch the bloat. Run example 1 with PARSIMONY = 0.0 and print the winner’s size each generation. How large does it get in 40 generations? Restore the penalty and compare the final size and MSE.
  11. Turn the pressure dial. Run example 1 with tournament sizes 2, 7, and 15 on the same SEED. Record the generation at which the best MSE first drops below 0.10, and the final size. Explain the differences in one paragraph about diversity.
  12. Your own problem. Choose a small task with a clear score: a text-formatting rule, a simple game strategy, a unit-conversion formula. Write down the terminals, the functions, and the fitness cases before you write any code, then reuse gp_core.py unchanged. Report what surprised you.

Glossary

ADF (automatically defined function). A subtree that evolution reuses by name, so repeated structure is encoded once. A step beyond plain tree GP.

Allele. The value stored at a gene. In tree GP, the symbol at a node: a function name or a terminal.

Bloat. Growth in program size that does not improve fitness. See “Bloat: why programs grow”.

Closure. The property that every function accepts every value the primitive set can produce. Protected operators restore closure.

Crossover (recombination). Building an offspring by exchanging parts of two parents; in tree GP, swapping two subtrees.

Elitism. Copying the best individuals into the next generation unchanged.

Ephemeral Random Constant (ERC). A new random constant drawn whenever a terminal leaf is created, so constants are re-invented every generation and tuned by selection.

Fitness. The scalar score used by selection. This chapter minimizes it.

Fitness case. One test input, with its expected output.

Function set. The internal nodes available to a program, each with a fixed arity.

Generation. One cycle of selection and reproduction.

Genotype. The representation that is copied and varied (here, the tree). Compare phenotype.

Hoist mutation. Replacing a node with one of its own descendants, which can only shrink the program.

Individual. One candidate program.

Initialization. How the first generation is built: full, grow, half-and-half, or ramped half-and-half.

Mutation. A random change to one individual.

Parsimony pressure. A fitness penalty proportional to program size, used to control bloat.

Phenotype. The behavior a genotype produces: the formula, circuit, regex, or plant.

Population. The set of individuals alive at one time.

Premature convergence. Losing diversity and settling on a mediocre answer before the search has explored enough.

Protected operator. A function that returns a safe value instead of raising, such as division that returns 1.0 for a near-zero denominator.

Selection pressure. How strongly selection favors the best individuals. Tournament size is the usual dial.

Subtree. A node together with all of its descendants; the unit of crossover and mutation.

Sufficiency. The property that the primitive set can express a solution at all.

Terminal set. The leaves available to a program: variables, constants, and generated constants.

Tournament selection. Drawing k individuals at random and keeping the best.

Tree depth and size. Depth is the longest root-to-leaf path, counted in nodes; size is the total node count.

Further reading

  • John R. Koza, Genetic Programming: On the Programming of Computers by Means of Natural Selection, MIT Press, 1992. The book that defined the field and the source of full/grow initialization and the standard benchmark problems.
  • Wolfgang Banzhaf, Peter Nordin, Robert E. Keller and Frank D. Francone, Genetic Programming: An Introduction, Morgan Kaufmann, 1998. A textbook that covers the representation and the main variants.
  • William B. Langdon and Riccardo Poli, Foundations of Genetic Programming, Springer, 2002. The theory: schemata, bloat, and convergence.
  • Riccardo Poli, William B. Langdon and Nicholas F. McPhee, A Field Guide to Genetic Programming, 2008, free online at https://www.gp-field-guide.org.uk/. The best single next read after this chapter.
  • The genetic programming bibliography, https://gpbib.pmacs.upenn.edu/, maintains the literature going back to the 1950s.
  • Michael Schmidt and Hod Lipson, “Distilling free-form natural laws from experimental data”, Science, 2009. A landmark symbolic regression result that rediscovered physical laws from measurements.
  • For production work, use DEAP (https://deap.readthedocs.io) or gplearn (https://gplearn.readthedocs.io). Both implement this chapter’s loop with typed trees, guarded operators, and multi-objective size control, so you can port any experiment by swapping gp_core calls for theirs.