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?

  1. Create Network: Convert the genome (genetic encoding) into an actual neural network

  2. Initialize Fitness: Start with perfect fitness (4.0) since there are 4 test cases

  3. Test All Cases: Feed each XOR input to the network and get the output

  4. Calculate Error: Subtract the squared error from fitness

  5. 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:

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.

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 run method on the Population object, 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:

  1. Evaluate: Calls your fitness function for all genomes

  2. Speciate: Groups similar genomes into species (protects innovation)

  3. Select: Identifies the fittest genomes in each species

  4. Reproduce: Creates offspring through crossover and mutation

  5. Mutate: Randomly modifies offspring (weights, add/remove nodes/connections)

  6. 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