XOR Example: Detailed Walkthrough
This is a complete walkthrough of the XOR example (examples/xor/evolve-feedforward.py), which evolves a neural network to compute the XOR (exclusive-or) logic function. This is often considered the “Hello, World!” of evolutionary neural networks.
Expected completion time: Usually converges in 50-150 generations, taking 1-3 seconds on modern hardware.
The XOR Problem
XOR (exclusive-or) is a classic test problem for neural networks:
Input 1 |
Input 2 |
Output |
|---|---|---|
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
1 |
1 |
1 |
0 |
Fitness function
The key thing you need to figure out for a given problem is how to measure the fitness of the genomes that are produced
by NEAT. Fitness is expected to be a Python float value. If genome A solves your problem more successfully than genome B,
then the fitness value of A should be greater than the value of B. The absolute magnitude and signs of these fitnesses
are not important, only their relative values.
- In this example, we create a feed-forward neural network based on the genome, and then for each case in the
table above, we provide that network with the inputs, and compute the network’s output. The fitness for each genome
is computed as \(4.0 - \\sum_i (e_i - a_i)^2\), where \(e_i\) and \(a_i\) are the expected and actual outputs for each of the four XOR test cases. If the network produces exactly the expected output on all cases, its fitness is 4.0; otherwise it is a value less than 4.0, with the fitness value decreasing the more incorrect the network responses are.
This fitness computation is implemented in the eval_genomes function. This function takes two arguments: a list
of genomes (the current population) and the active configuration. neat-python expects the fitness function to calculate
a fitness for each genome and assign this value to the genome’s fitness member.
Note
What’s Happening in the Fitness Function?
Create Network: Convert the genome (genetic encoding) into an actual neural network
Initialize Fitness: Start with perfect fitness (4.0) since there are 4 test cases
Test All Cases: Feed each XOR input to the network and get the output
Calculate Error: Subtract the squared error from fitness
Final Fitness: Higher fitness = better network. Fitness of 4.0 would be perfect.
NEAT uses these fitness values to determine which genomes should reproduce. Better genomes are more likely to have offspring.
Sample Output
When you run the XOR example, you’ll see generation-by-generation progress like this:
****** Running generation 0 ******
Population's average fitness: 2.21888 stdev: 0.34917
Best fitness: 2.98110 - size: (1, 2) - species 1 - id 143
Average adjusted fitness: 0.561
Mean genetic distance 1.739, standard deviation 0.497
Population of 150 members in 2 species (after reproduction):
ID age size fitness adj fit stag
==== === ==== ========= ======= ====
1 0 123 2.981 0.599 0
2 0 27 2.919 0.522 0
Total extinctions: 0
Generation time: 0.003 sec
What this output means:
Best fitness: 2.98110 - The best genome scored 2.98 out of 4.0 possible (pretty good for generation 0!)
size: (1, 2) - This genome has 1 hidden node and 2 connections
species 1 - id 143 - This is genome #143 in species #1
2 species - Population has split into 2 groups of similar genomes
Generation time: 0.003 sec - Very fast! Fitness evaluation is simple for XOR
After some generations (typically 50-150), you’ll see:
****** Running generation 73 ******
Best individual in generation 73 meets fitness threshold - complexity: (0, 2)
Best genome:
Key: 2891
Fitness: 3.9623
Nodes:
0 DefaultNodeGene(key=0, bias=-3.127, response=1.0, activation=sigmoid, aggregation=sum)
Connections:
DefaultConnectionGene(key=(-1, 0), weight=4.723, enabled=True, innovation=1)
DefaultConnectionGene(key=(-2, 0), weight=-4.856, enabled=True, innovation=2)
Success! The network reached the fitness threshold (3.9). Notice:
complexity: (0, 2) - No hidden nodes needed! Direct connections from inputs to output were sufficient
2 connections - From input -1 (first input) and input -2 (second input) to output 0
High weights (±4.7) - Strong connections that saturate the sigmoid function
Negative bias (-3.127) - Shifts the sigmoid to implement XOR logic
Common Mistakes
1. Forgetting to Set genome.fitness
# WRONG - fitness never assigned
def eval_genomes(genomes, config):
for genome_id, genome in genomes:
net = neat.nn.FeedForwardNetwork.create(genome, config)
output = net.activate([1.0, 0.0])
# Oops! Forgot to set genome.fitness
# RIGHT - fitness properly assigned
def eval_genomes(genomes, config):
for genome_id, genome in genomes:
net = neat.nn.FeedForwardNetwork.create(genome, config)
genome.fitness = calculate_fitness(net) # ✓
Symptom: All genomes have None as fitness, evolution doesn’t work
2. Configuration File Path Issues
# WRONG - relative path may not work depending on where you run from
config = neat.Config(..., 'config-feedforward')
# RIGHT - use absolute path
import os
local_dir = os.path.dirname(__file__)
config_path = os.path.join(local_dir, 'config-feedforward')
config = neat.Config(..., config_path)
Symptom: FileNotFoundError: config-feedforward
3. Wrong Activation Function Range
# If your fitness function expects outputs in [-1, 1]
# but you're using sigmoid (outputs in [0, 1])
activation_default = sigmoid # Wrong for [-1, 1] range
# Use tanh instead
activation_default = tanh # Correct for [-1, 1] range
Symptom: Networks never reach good fitness
4. Not Returning Network Output
# WRONG - forgot to use network output
def eval_genomes(genomes, config):
for genome_id, genome in genomes:
net = neat.nn.FeedForwardNetwork.create(genome, config)
net.activate(xor_inputs[0]) # Output discarded!
genome.fitness = 1.0 # Fixed value - no feedback
# RIGHT - use output to calculate fitness
def eval_genomes(genomes, config):
for genome_id, genome in genomes:
net = neat.nn.FeedForwardNetwork.create(genome, config)
output = net.activate(xor_inputs[0]) # ✓
genome.fitness = 4.0 - (output[0] - expected) ** 2 # ✓
Symptom: All genomes get same fitness, no evolution
Running NEAT
Once you have implemented a fitness function, you mostly just need some additional boilerplate code that carries out the following steps:
Create a
neat.config.Configobject from the configuration file (described in the Configuration file description).
Note
What’s Happening: Configuration Loading
The config file controls everything about evolution:
Population size: How many genomes per generation
Network structure: Number of inputs/outputs, allowed activation functions
Mutation rates: How fast networks complexify
Speciation: How genetic diversity is protected
Termination: When to stop evolving
See Configuration Essentials for a beginner-friendly guide to configuration.
Create a
neat.population.Populationobject using theConfigobject created above.
Note
What’s Happening: Population Initialization
NEAT creates the initial population of random genomes (neural networks). Each genome starts simple - typically just inputs connected directly to outputs with random weights. Complexity evolves over time as needed.
Call the
runmethod on thePopulationobject, giving it your fitness function and (optionally) the maximum number of generations you want NEAT to run.
Note
What’s Happening: The Evolution Loop
Each generation, NEAT:
Evaluate: Calls your fitness function for all genomes
Speciate: Groups similar genomes into species (protects innovation)
Select: Identifies the fittest genomes in each species
Reproduce: Creates offspring through crossover and mutation
Mutate: Randomly modifies offspring (weights, add/remove nodes/connections)
Repeat: Until fitness threshold reached or max generations
This implements the NEAT algorithm’s core principle: start simple and complexify as needed.
After these three things are completed, NEAT will run until either you reach the specified number of generations, or at least one genome achieves the fitness_threshold value you specified in your config file.
Getting the results
Once the call to the population object’s run method has returned, you can query the statistics member of the
population (a neat.statistics.StatisticsReporter object) to get the best genome(s) seen during the run.
In this example, we take the ‘winner’ genome to be that returned by pop.statistics.best_genome().
Other information available from the default statistics object includes per-generation mean fitness, per-generation standard deviation of fitness, and the best N genomes (with or without duplicates).
Visualizations
Functions are available in the visualize module to plot the best and average fitness vs. generation, plot the change in species vs. generation, and to show the structure of a network described by a genome.
Example Source
NOTE: This page shows the source and configuration file for the current version of neat-python available on GitHub. If you are using the version 0.92 installed from PyPI, make sure you get the script and config file from the archived source for that release.
Here’s the entire example:
"""
2-input XOR example -- this is most likely the simplest possible example.
"""
import os
import neat
import visualize
# 2-input XOR inputs and expected outputs.
xor_inputs = [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)]
xor_outputs = [(0.0,), (1.0,), (1.0,), (0.0,)]
def eval_genomes(genomes, config):
for genome_id, genome in genomes:
genome.fitness = 4.0
net = neat.nn.FeedForwardNetwork.create(genome, config)
for xi, xo in zip(xor_inputs, xor_outputs):
output = net.activate(xi)
genome.fitness -= (output[0] - xo[0]) ** 2
def run(config_file):
# Load configuration.
config = neat.Config(neat.DefaultGenome, neat.DefaultReproduction,
neat.DefaultSpeciesSet, neat.DefaultStagnation,
config_file)
# Create the population, which is the top-level object for a NEAT run.
p = neat.Population(config)
# Add a stdout reporter to show progress in the terminal.
p.add_reporter(neat.StdOutReporter(True))
stats = neat.StatisticsReporter()
p.add_reporter(stats)
p.add_reporter(neat.Checkpointer(5))
# Run for up to 300 generations.
winner = p.run(eval_genomes, 300)
# Display the winning genome.
print(f'\nBest genome:\n{winner!s}')
# Show output of the most fit genome against training data.
print('\nOutput:')
winner_net = neat.nn.FeedForwardNetwork.create(winner, config)
for xi, xo in zip(xor_inputs, xor_outputs):
output = winner_net.activate(xi)
print(f"input {xi!r}, expected output {xo!r}, got {output!r}")
node_names = {-1: 'A', -2: 'B', 0: 'A XOR B'}
visualize.draw_net(config, winner, True, node_names=node_names)
visualize.draw_net(config, winner, True, node_names=node_names, prune_unused=True)
visualize.plot_stats(stats, ylog=False, view=True)
visualize.plot_species(stats, view=True)
p = neat.Checkpointer.restore_checkpoint('neat-checkpoint-4')
p.run(eval_genomes, 10)
if __name__ == '__main__':
# Determine path to configuration file. This path manipulation is
# here so that the script will run successfully regardless of the
# current working directory.
local_dir = os.path.dirname(__file__)
config_path = os.path.join(local_dir, 'config-feedforward')
run(config_path)
and here is the associated config file:
[NEAT]
fitness_criterion = max
fitness_threshold = 3.9
pop_size = 150
reset_on_extinction = False
no_fitness_termination = False
# Reproducibility: Uncomment to enable deterministic evolution
# Useful for debugging, comparing algorithm variants, or scientific reproducibility
# seed = 42
[DefaultGenome]
# node activation options
activation_default = sigmoid
activation_mutate_rate = 0.0
activation_options = sigmoid
# node aggregation options
aggregation_default = sum
aggregation_mutate_rate = 0.0
aggregation_options = sum
# node bias options
bias_init_mean = 0.0
bias_init_stdev = 1.0
bias_max_value = 30.0
bias_min_value = -30.0
bias_mutate_power = 0.5
bias_mutate_rate = 0.7
bias_replace_rate = 0.1
# genome compatibility options
compatibility_disjoint_coefficient = 1.0
compatibility_weight_coefficient = 0.5
# connection add/remove rates
conn_add_prob = 0.5
conn_delete_prob = 0.5
# connection enable options
enabled_default = True
enabled_mutate_rate = 0.01
feed_forward = True
# Start with all inputs connected to all outputs.
initial_connection = full_direct
# node add/remove rates
node_add_prob = 0.2
node_delete_prob = 0.2
# network parameters
num_hidden = 0
num_inputs = 2
num_outputs = 1
# node response options
response_init_mean = 1.0
response_init_stdev = 0.0
response_max_value = 30.0
response_min_value = -30.0
response_mutate_power = 0.0
response_mutate_rate = 0.0
response_replace_rate = 0.0
# connection weight options
weight_init_mean = 0.0
weight_init_stdev = 1.0
weight_max_value = 30
weight_min_value = -30
weight_mutate_power = 0.5
weight_mutate_rate = 0.8
weight_replace_rate = 0.1
single_structural_mutation = false
structural_mutation_surer = default
bias_init_type = gaussian
response_init_type = gaussian
weight_init_type = gaussian
enabled_rate_to_true_add = 0.0
enabled_rate_to_false_add = 0.0
[DefaultSpeciesSet]
compatibility_threshold = 3.0
[DefaultStagnation]
species_fitness_func = max
max_stagnation = 20
species_elitism = 2
[DefaultReproduction]
elitism = 2
survival_threshold = 0.2
min_species_size = 2