Introduction
Topologies (NEAT) is a robust algorithm launched in 2002 by Kenneth O. Stanley and Risto Miikkulainen from the College of Texas at Austin in this paper. NEAT algorithm launched a brand new concept to the usual neuro-evolution strategies that developed fixed-topology networks, by dynamically growing the complexity of the networks over the generations.
On this article, I’ll stroll via the NEAT algorithm and its implementation from scratch in Python, specializing in the algorithm’s design choices and the intricacies that make NEAT each elegant and difficult to breed. This text is meant for a technical viewers, acquainted with neural networks and the fundamentals of evolutionary computation.
Evolutionary Algorithms: A Basic Overview
Earlier than leaping into NEAT, let’s evaluate the evolutionary algorithm’s fundaments. Impressed by genetics and pure choice, evolutionary algorithms are a kind of optimization algorithms that resolve complicated issues by iteratively enhancing a inhabitants of candidate options.
The core concept is to mimic the method of organic evolution:
- Initialization: The method begins by producing an preliminary inhabitants of candidate options. This preliminary options are generated randomly, and every answer is usually represented as a “genome” or “chromosome”, which encodes its options and traits.
- Analysis: Every particular person within the inhabitants is evaluated in accordance with how nicely it solves the given downside. That is accomplished utilizing a health perform which assigns a numerical rating to every answer. The upper the health, the higher the answer.
- Choice: Based mostly on their health, a subset of the inhabitants is chosen to develop into the “mother and father” of the subsequent technology. People with increased health scores have a better chance of being chosen.
- Copy: The chosen people then reproduce to create “offspring” for the subsequent technology utilizing genetic operators. There are two major processes on this section:
- Crossover: Two or extra mother or father genomes are mixed to provide new offspring genomes, merging advantageous traits.
- Mutation: Small adjustments are randomly launched into the offspring genomes. This introduces novelty and helps discover the search area, stopping the algorithm from getting caught in a neighborhood optima.
- Alternative: The newly generated offspring replaces the present inhabitants (both solely or partially), forming the brand new technology.
- Termination: Steps 2–5 are repeated for a set variety of generations, till a sure health threshold is reached, or till a passable answer to the issue is discovered.
What Makes NEAT Particular?
NEAT stands out as a result of it goes additional than common evolutionary algorithms, which solely evolve the weights of the community. NEAT additionally evolves the topology of the networks, making them more and more complicated.
Earlier than NEAT, there have been two major challenges that didn’t permit for normal evolutionary algorithms for use to dinamically modify the structure of the networks. NEAT solves these two challenges:
- The way to carry out crossover between topologically numerous networks: In conventional genetic algorithms, combining two genomes with vastly completely different buildings typically results in non-functional or malformed offspring. Think about making an attempt to mix two completely different neural networks, the place one has three hidden layers and one other has just one, merely averaging weights or randomly merging connections will probably break the community’s performance.
- The way to keep variety in a inhabitants with vastly completely different topologies: When networks can develop in complexity (including new nodes and connections), these structural mutations typically result in a brief lower of their health. For instance, a brand new connection could intrude with the prevailing community’s performance earlier than its parameters are correctly tuned. In consequence, lately mutated networks could be prematurely outcompeted by easier, extra optimized ones, even when the newer variant have the potential to evolve right into a superior answer given extra time. This untimely convergence to native optima is a typical downside.
How NEAT Solves These Challenges
NEAT tackles these two basic issues via two ingenious mechanisms: historic markings (additionally known as innovation numbers) and speciation.
Historic Markings
To deal with the problem of performing crossover between topologically numerous networks, NEAT introduces the idea of innovation numbers. When a brand new connection or node is added to a community throughout mutation, it’s assigned a globally distinctive innovation quantity. This quantity acts as a historic marking, indicating the historic origin of that individual genetic characteristic.
Let’s have a look at the instance within the picture above, the place now we have two networks, “Dad or mum 1” and “Dad or mum 2” present process crossover. We are able to see that each networks have a connection from node 2 to node 5, with innovation quantity 4. This tells us that this connection will need to have been inherited by each networks from a typical ancestor sooner or later. Nevertheless, we will additionally see that “Dad or mum 1” has a connection (from node 1 to node 5) with the innovation quantity 8, however “Dad or mum 2” doesn’t have this connection. This exhibits that “Dad or mum 1” developed this particular connection independently.
Throughout crossover, NEAT aligns the genes (nodes and connections) of the 2 mother and father based mostly on their innovation numbers.
- Matching genes (these with the identical innovation quantity) are inherited randomly from both mother or father.
- Disjoint genes (current in a single mother or father however not the opposite, and with innovation numbers inside the vary of the opposite mother or father’s genes) are sometimes inherited from the fitter mother or father.
- Extra genes (current in a single mother or father however not the opposite, and with innovation numbers outdoors the vary of the opposite mother or father’s genes, that means they appeared later in evolutionary historical past) are additionally sometimes inherited from the fitter mother or father.
This complete course of ensures that functionally comparable elements of the networks are mixed appropriately, even when their total buildings differ considerably.
Speciation
To take care of variety in a inhabitants with vastly completely different topologies, and stop untimely convergence, NEAT employs a way known as speciation. By means of speciation the inhabitants is split into completely different “species” based mostly on topological similarity. Networks inside the similar species usually tend to share widespread ancestral traits and thus, extra comparable buildings.
The similarity between two networks (genomes) is calculated utilizing a compatibility distance perform. This perform considers three elements:
- The variety of disjoint genes (genes current in a single genome however not the opposite, however inside the shared historic vary).
- The variety of extra genes (genes current in a single genome however not the opposite, and out of doors the shared historic vary).
- The common weight distinction of matching genes.
If the compatibility distance between two networks falls under a sure threshold, they’re thought-about to belong to the identical species.
By speciating the inhabitants, NEAT ensures that:
- Competitors happens solely inside species: This protects novel or much less complicated buildings from being instantly outcompeted by extremely complicated however maybe presently sub-optimal designs.
- Every species has an opportunity to innovate and enhance: The very best people from every species are allowed to breed (elitism), selling the evolution of distinctive options inside completely different topological niches.
- Species can die out if they don’t seem to be profitable: If a species constantly performs poorly, it’s going to shrink and finally disappear, making room for extra promising genetic strains.
Core Elements of NEAT
There are 4 core elements within the NEAT algorithm:
Node Genes
Every node gene represents a neuron of the neural community. Every node has:
- An ID (distinctive identifier)
- A layer: it could possibly be enter, hidden, or output
- An activation fuction
- A bias worth
class NodeGene:
def __init__(self, id, layer, activation, bias):
self.id = id
self.layer = layer # The layer to which the node belongs
self.activation = activation # Activation perform
self.bias = bias
Connection Genes
The connection genes (synapses) signify the connections between the neurons of the community. Every connection gene has:
- Enter node ID
- Ouput node ID
- Weight
- Enabled flag (signifies whether or not the connection is enabled)
- Innovation quantity (distinctive identifier assigned when the connection is first created)
class ConnectionGene:
def __init__(self, in_node_id: int, out_node_id: int, weight: float, innov: int, enabled: bool = True):
self.in_node_id = in_node_id
self.out_node_id = out_node_id
self.weight = weight
self.enabled = enabled # Whether or not the connection is enabled or not
self.innov = innov # Innovation quantity described within the paper
Genomes
The genome is the “genetic blueprint” of a single neural community inside the NEAT algorithm. It’s basically a set of all of the node and connection genes that outline the community’s construction and parameters. With the Genome we will later assemble the precise runnable community (which we’ll name Phenotype). Every genome represents one particular person within the inhabitants.
class Genome:
def __init__(self, nodes, connections):
self.nodes = {node.id: node for node in nodes if node shouldn't be None}
self.connections = [c.copy() for c in connections]
self.health = 0
Innovation Tracker
A crucial part in any NEAT implementation is an Innovation Tracker. That is my customized implementation of this mechanism that’s answerable for assigning and maintaining monitor of distinctive innovation numbers for each new connection and node created all through the method. This ensures that historic markers are constant throughout the whole inhabitants, which is prime for appropriately aligning genes throughout crossover.
class InnovationTracker:
def __init__(self):
self.current_innovation = 0
self.connection_innovations = {} # (in_node_id, out_node_id) -> innovation_number
self.node_innovations = {} # connection_innovation -> (node_innovation, conn1_innovation, conn2_innovation)
self.node_id_counter = 0
def get_connection_innovation(self, in_node_id, out_node_id):
"""Get innovation quantity for a connection, creating new if wanted"""
key = (in_node_id, out_node_id)
if key not in self.connection_innovations:
self.connection_innovations[key] = self.current_innovation
self.current_innovation += 1
return self.connection_innovations[key]
def get_node_innovation(self, connection_innovation):
"""Get innovation numbers for node insertion, creating new if wanted"""
if connection_innovation not in self.node_innovations:
# Create new node and two connections
node_id = self.node_id_counter
self.node_id_counter += 1
conn1_innovation = self.current_innovation
self.current_innovation += 1
conn2_innovation = self.current_innovation
self.current_innovation += 1
self.node_innovations[connection_innovation] = (node_id, conn1_innovation, conn2_innovation)
return self.node_innovations[connection_innovation]
NEAT Algorithm Movement
With the core elements understood, now we will piece them collectively to grasp how NEAT works. Within the instance proven, the algorithm is making an attempt to unravel the XOR downside.
1. Initialization
The very first technology of genomes is at all times created with a quite simple and stuck construction. This method aligns with NEAT’s philosophy of “beginning easy and rising complexity”, making certain it explores the best options first, step by step growing complexity.
On this code instance, we initialize the networks with the minimal construction: a fully-connected community with no hidden layers.
def create_initial_genome(num_inputs, num_outputs, innov: InnovationTracker):
input_nodes = []
output_nodes = []
connections = []
# Create enter nodes
for i in vary(num_inputs):
node = NodeGene(i, "enter", lambda x: x, 0)
input_nodes.append(node)
# Create output nodes
for i in vary(num_outputs):
node_id = num_inputs + i # We begin the ids the place we left within the earlier loop
node = NodeGene(node_id, "output", sigmoid, random.uniform(-1, 1))
output_nodes.append(node)
# Replace the innov tracker's node id
innov.node_id_counter = num_inputs + num_outputs
# Create connections
for i in vary(num_inputs):
for j in vary(num_outputs):
in_node_id = i
out_node_id = j + num_inputs
innov_num = innov.get_connection_innovation(in_node_id, out_node_id)
weight = random.uniform(-1, 1)
conn = ConnectionGene(in_node_id, out_node_id, weight, innov_num, True)
connections.append(conn)
all_nodes = input_nodes + output_nodes
return Genome(all_nodes, connections)
def create_initial_population(dimension, num_inputs, num_outputs, innov):
inhabitants = []
for _ in vary(dimension):
genome = create_initial_genome(num_inputs, num_outputs, innov)
inhabitants.append(genome)
return inhabitants
2. Consider Inhabitants Health
After the preliminary inhabitants is about up, the NEAT algorithm enters the principle evolutionary loop. This loop repeats for a pre-defined variety of generations, or till one in all its options reaches a health threshold. Every technology undergoes a sequence of crucial steps: analysis, speciation, health adjustment, and copy. Step one is to judge the health of every particular person within the inhabitants. To do that first now we have to undergo the next steps:
- Phenotype Expression: For every Genome we first have to precise it into its phenotype (a runnable neural community). This includes setting up the precise neural community from the nodes and connections lists inside the Genome.
- Ahead Cross: As soon as the community is constructed, we carry out the ahead move with the given inputs to provide the outputs.
- Health Calculation: Given the community’s enter and output we will now calculate it’s health utilizing the health perform. The health perform is problem-specific and is designed to return a numerical rating indicating how nicely the community achieved its objective.
def topological_sort(edges):
""" Helper perform to type the community's nodes """
visited = set()
order = []
def go to(n):
if n in visited:
return
visited.add(n)
for m in edges[n]:
go to(m)
order.append(n)
for node in edges:
go to(node)
return order[::-1]
class Genome:
... # The remainder of the strategies would go right here
def consider(self, input_values: checklist[float]):
"""
Technique of the Genome class.
Performs the phenotype expression and ahead move
"""
node_values = {}
node_inputs = {n: [] for n in self.nodes}
input_nodes = [n for n in self.nodes.values() if n.layer == "input"]
output_nodes = [n for n in self.nodes.values() if n.layer == "output"]
# Confirm that the variety of enter values matches the variety of enter nodes
if len(input_nodes) != len(input_values):
elevate ValueError(f"Variety of inputs would not match variety of enter nodes. Enter={len(input_nodes)}, num_in_val={len(input_values)}")
# Assign enter values
for node, val in zip(input_nodes, input_values):
node_values[node.id] = val
edges = {}
for n in self.nodes.values():
edges[n] = []
for conn in self.connections: # Solely assemble enabled connections
if conn.enabled:
in_node = self.get_node(conn.in_node_id)
out_node = self.get_node(conn.out_node_id)
edges[in_node].append(out_node)
node_inputs[conn.out_node_id].append(conn)
sorted_nodes = topological_sort(edges)
for node in sorted_nodes:
if node.id in node_values:
proceed
incoming = node_inputs[node.id]
total_input = sum(
node_values[c.in_node_id] * c.weight for c in incoming
) + node.bias
node_values[node.id] = node.activation(total_input)
return [node_values.get(n.id, 0) for n in output_nodes]
def fitness_xor(genome):
"""Calculate health for XOR downside"""
# XOR Drawback knowledge
X = [[0, 0], [0, 1], [1, 0], [1, 1]]
y = [0, 1, 1, 0]
total_error = 0
for i in vary(len(X)):
attempt:
output = genome.consider(X[i])
# print(f"Output: {output}")
if output:
error = abs(output[0] - y[i])
total_error += error
else:
error = y[i]
total_error += error
besides Exception as e:
print(f"Error: {e}")
return 0 # Return 0 health if analysis fails
health = 4 - total_error
return max(0, health)
3. Speciation
As a substitute of letting all people compete globally, NEAT divides the inhabitants into species, placing collectively these genomes which can be topologically comparable. This method prevents new topological improvements from being immediatly outcompeted by bigger, extra mature species, and permits them to mature.
The method of speciation in every technology includes:
- Measuring Compatibility: We use a compatibility distance perform to measure how completely different two Genomes are. The shorter the space between them, the extra comparable two genomes are. The next code implementation makes use of the method proposed within the unique paper, with the proposed parameters.
def distance(genome1: Genome, genome2: Genome, c1=1.0, c2=1.0, c3=0.4):
genes1 = {g.innov: g for g in genome1.connections}
genes2 = {g.innov: g for g in genome2.connections}
innovations1 = set(genes1.keys())
innovations2 = set(genes2.keys())
matching = innovations1 & innovations2
disjoint = (innovations1 ^ innovations2)
extra = set()
max_innov1 = max(innovations1) if innovations1 else 0
max_innov2 = max(innovations2) if innovations2 else 0
max_innov = min(max_innov1, max_innov2)
# Separate extra from disjoint
for innov in disjoint.copy():
if innov > max_innov:
extra.add(innov)
disjoint.take away(innov)
# Weight distinction of matching genes
if matching:
weight_diff = sum(
abs(genes1[i].weight - genes2[i].weight) for i in matching
)
avg_weight_diff = weight_diff / len(matching)
else:
avg_weight_diff = 0
# Normalize by N
N = max(len(genome1.connections), len(genome2.connections))
if N < 20: # NEAT sometimes makes use of 1 if N < 20
N = 1
delta = (c1 * len(extra)) / N + (c2 * len(disjoint)) / N + c3 * avg_weight_diff
return delta
2. Grouping into Species: On the beggining of every technology, the Speciator is answerable for categorizing all genomes into present or new species. Every species has one consultant genome, that serves because the benchmark towards which each and every particular person of the inhabitants is in comparison with decide if belongs to that species.
class Species:
def __init__(self, consultant: Genome):
self.consultant = consultant
self.members = [representative]
self.adjusted_fitness = 0
self.best_fitness = -float('inf')
self.stagnant_generations = 0
def add_member(self, genome: Genome):
self.members.append(genome)
def clear_members(self):
self.members = []
def update_fitness_stats(self):
if not self.members:
self.adjusted_fitness = 0
return
current_best_fitness = max(member.health for member in self.members)
# Test for enchancment and replace stagnation
if current_best_fitness > self.best_fitness:
self.best_fitness = current_best_fitness
self.stagnant_generations = 0
else:
self.stagnant_generations += 1
self.adjusted_fitness = sum(member.health for member in self.members) / len(self.members)
class Speciator:
def __init__(self, compatibility_threshold=3.0):
self.species = []
self.compatibility_threshold = compatibility_threshold
def speciate(self, inhabitants: checklist[Genome]):
""" Group genomes into species based mostly on distance """
# Clear all species for the brand new technology
for s in self.species:
s.clear_members()
for genome in inhabitants:
found_species = False
for species in self.species:
if distance(genome, species.consultant) < self.compatibility_threshold:
species.add_member(genome)
found_species = True
break
if not found_species:
new_species = Species(consultant=genome)
self.species.append(new_species)
# Take away empty species
self.species = [s for s in self.species if s.members]
# Recompute adjusted health
for species in self.species:
species.update_fitness_stats()
# Replace consultant to be the most effective member
species.consultant = max(species.members, key=lambda g: g.health)
def get_species(self):
return self.species
4. Adjusting Health
Even when genomes are grouped into species, a uncooked health worth isn’t sufficient to permit for honest copy. Bigger species would naturally produce extra offspring, probably overwhelming smaller species which may maintain promising, however nonetheless nascent, improvements. To counter this, NEAT employs the adjusted health, and adjusts the health based mostly on the species efficiency.
To regulate the health of a person, its health is split between the variety of people in its species. This mechanism is carried out within the update_fitness_stats methodology contained in the Species class.
5. Copy
After speciating and adjusting the fitnesses, the algorithm strikes to the copy section, the place the subsequent technology of genomes is created via a mixture of choice, crossover, and mutation.
- Choice: On this implementation the choice is completed via elitism in the principle evolutionary loop.
2. Crossover: Some key facets of this implementation are:
- Node inheritance: Enter and output nodes are explicitly ensured to be handed right down to the offspring. That is accomplished to make sure the performance of the community doesn’t break.
- Matching genes: When each mother and father have a gene with the identical innovation quantity, one is chosen randomly. If the gene was disabled in both mother or father, there’s a 75% probability of the gene being disabled within the offspring.
- Extra genes: Extra genes from the much less match mother or father are usually not inherited.
def crossover(parent1: Genome, parent2: Genome) -> Genome:
""" Crossover assuming parent1 is the fittest mother or father """
offspring_connections = []
offspring_nodes = set()
all_nodes = {} # Gather all nodes from each mother and father
for node in parent1.nodes.values():
all_nodes[node.id] = node.copy()
if node.layer in ("enter", "output"):
offspring_nodes.add(all_nodes[node.id]) # Make sure the enter and output nodes are included
for node in parent2.nodes.values():
if node.id not in all_nodes:
all_nodes[node.id] = node.copy()
# Construct maps of genes keyed by innovation quantity
genes1 = {g.innov: g for g in parent1.connections}
genes2 = {g.innov: g for g in parent2.connections}
# Mix all innovation numbers
all_innovs = set(genes1.keys()) | set(genes2.keys())
for innov in sorted(all_innovs):
gene1 = genes1.get(innov)
gene2 = genes2.get(innov)
if gene1 and gene2: # Matching genes
chosen = random.selection([gene1, gene2])
gene_copy = chosen.copy()
if not gene1.enabled or not gene2.enabled: # 75% probability of the offsprign gene being disabled
if random.random() < 0.75:
gene_copy.enabled = False
elif gene1 and never gene2: # Disjoint gene (from the fittest mother or father)
gene_copy = gene1.copy()
else: # Not taking disjoint genes from much less match mother or father
proceed
# get nodes
in_node = all_nodes.get(gene_copy.in_node_id)
out_node = all_nodes.get(gene_copy.out_node_id)
if in_node and out_node:
offspring_connections.append(gene_copy)
offspring_nodes.add(in_node)
offspring_nodes.add(out_node)
offspring_nodes = checklist(offspring_nodes) # Take away the duplicates
return Genome(offspring_nodes, offspring_connections)
3. Mutation: After crossover, mutation is utilized to the offspring. A key facet of this implementation is that we keep away from forming cycles when including connections.
class Genome:
... # The remainder of the strategies would go right here
def _path_exists(self, start_node_id, end_node_id, checked_nodes=None):
""" Recursive perform to test whether or not a apth between two nodes exists."""
if checked_nodes is None:
checked_nodes = set()
if start_node_id == end_node_id:
return True
checked_nodes.add(start_node_id)
for conn in self.connections:
if conn.enabled and conn.in_node_id == start_node_id:
if conn.out_node_id not in checked_nodes:
if self._path_exists(conn.out_node_id, end_node_id, checked_nodes):
return True
return False
def get_node(self, node_id):
return self.nodes.get(node_id, None)
def mutate_add_connection(self, innov: InnovationTracker):
node_list = checklist(self.nodes.values())
# Strive max 10 occasions
max_tries = 10
found_appropiate_nodes = False
for _ in vary(max_tries):
node1, node2 = random.pattern(node_list, 2)
if (node1.layer == "output" or (node1.layer == "hid" and node2.layer == "enter")):
node1, node2 = node2, node1 # Swap them
# Test if it is making a loop to the identical node
if node1 == node2:
proceed
# Test if it is making a connection between two nodes on the identical layer
if node1.layer == node2.layer:
proceed
if node1.layer == "output" or node2.layer == "enter":
proceed
# Test whether or not the connection already exists
conn_exists=False
for c in self.connections:
if (c.in_node_id == node1.id and c.out_node_id == node2.id) or
(c.in_node_id == node2.id and c.out_node_id == node1.id):
conn_exists = True
break
if conn_exists:
proceed
# If there's a path from node2 to node1, then including a connection from node1 to node2 creates a cycle
if self._path_exists(node2.id, node1.id):
proceed
innov_num = innov.get_connection_innovation(node1.id, node2.id)
new_conn = ConnectionGene(node1.id, node2.id, random.uniform(-1, 1), innov_num, True)
self.connections.append(new_conn)
return
def mutate_add_node(self, innov: InnovationTracker):
enabled_conn = [c for c in self.connections if c.enabled]
if not enabled_conn:
return
connection = random.selection(enabled_conn) # select a random enabled connectin
connection.enabled = False # Disable the connection
node_id, conn1_innov, conn2_innov = innov.get_node_innovation(connection.innov)
# Create node and connections
new_node = NodeGene(node_id, "hid", ReLU, random.uniform(-1,1))
conn1 = ConnectionGene(connection.in_node_id, node_id, 1, conn1_innov, True)
conn2 = ConnectionGene(node_id, connection.out_node_id, connection.weight, conn2_innov, True)
self.nodes[node_id] = new_node
self.connections.prolong([conn1, conn2])
def mutate_weights(self, price=0.8, energy=0.5):
for conn in self.connections:
if random.random() < price:
if random.random() < 0.1:
conn.weight = random.uniform(-1, 1)
else:
conn.weight += random.gauss(0, energy)
conn.weight = max(-5, min(5, conn.weight)) # Clamp weights
def mutate_bias(self, price=0.7, energy=0.5):
for node in self.nodes.values():
if node.layer != "enter" and random.random() < price:
if random.random() < 0.1:
node.bias = random.uniform(-1, 1)
else:
node.bias += random.gauss(0, energy)
node.bias = max(-5, min(5, node.bias))
def mutate(self, innov, conn_mutation_rate=0.05, node_mutation_rate=0.03, weight_mutation_rate=0.8, bias_mutation_rate=0.7):
self.mutate_weights(weight_mutation_rate)
self.mutate_bias(bias_mutation_rate)
if random.random() < conn_mutation_rate:
self.mutate_add_connection(innov)
if random.random() < node_mutation_rate:
self.mutate_add_node(innov)
6. Repeating the Course of Inside the Important Evolutionary Loop
As soon as all of the offspring has been generated and the brand new inhabitants is fashioned, the present technology ends, and the brand new inhabitants turns into the place to begin for the subsequent evolutionary cycle. That is dealt with by a major evolutionary loop, which orchestrates the entire algorithm.
def evolution(inhabitants, fitness_scores, speciator: Speciator, innov: InnovationTracker, stagnation_limit: int = 15):
new_population = []
# Assign health to genomes
for genome, health in zip(inhabitants, fitness_scores):
genome.health = health
# Speciate inhabitants
speciator.speciate(inhabitants)
species_list = speciator.get_species()
species_list.type(key=lambda s: s.best_fitness, reverse=True) # Type species by best_fitness
print(f"Species created: {len(species_list)}")
# Take away stagnant species
surviving_species = []
if species_list:
surviving_species.append(species_list[0]) # Hold the most effective one no matter stagnation
for s in species_list[1:]:
if s.stagnant_generations < stagnation_limit:
surviving_species.append(s)
species_list = surviving_species
print(f"Species that survived: {len(species_list)}")
total_adjusted_fitness = sum(s.adjusted_fitness for s in species_list)
print(f"Whole adjusted health: {total_adjusted_fitness}")
# elitism
for species in species_list:
if species.members:
best_genome = max(species.members, key=lambda g: g.health)
new_population.append(best_genome)
remaining_offspring = len(inhabitants) - len(new_population)
# Allocate the remaining offspring
for species in species_list:
if total_adjusted_fitness > 0:
offspring_count = int((species.adjusted_fitness / total_adjusted_fitness) * remaining_offspring) # The fitter species can have extra offspring
else:
offspring_count = remaining_offspring // len(species_list) # If all of the species carried out poorly, assign offspring evenly between them
if offspring_count > 0:
offspring = reproduce_species(species, offspring_count, innov)
new_population.prolong(offspring)
# Guarantee there are sufficient people (we may have much less due to the rounding error)
whereas len(new_population) < len(inhabitants):
best_species = max(species_list, key=lambda s: s.adjusted_fitness)
offspring = reproduce_species(best_species, 1, innov)
new_population.prolong(offspring)
return new_population
7. Working the Algorithm
def run_neat_xor(save_best=False, generations=50, pop_size=50, target_fitness=3.9, speciator_threshold=2.0):
NUM_INPUTS = 2
NUM_OUTPUTS = 1
# Initialize Innovation Quantity and Speciator
innov = InnovationTracker()
speciator = Speciator(speciator_threshold)
# Create preliminary inhabitants
inhabitants = create_initial_population(pop_size, NUM_INPUTS, NUM_OUTPUTS, innov)
# Stats
best_fitness_history = []
avg_fitness_history = []
species_count_history = []
# major loop
for gen in vary(generations):
fitness_scores = [fitness_xor(g) for g in population]
print(f"health: {fitness_scores}")
# get the stats
best_fitness = max(fitness_scores)
avg_fitness = sum(fitness_scores) / len(fitness_scores)
best_fitness_history.append(best_fitness)
avg_fitness_history.append(avg_fitness)
print(f"Era {gen}: Finest={best_fitness}, Avg={avg_fitness}")
# Test if we achieved the goal health
if best_fitness >= target_fitness:
print(f"Drawback was solved in {gen} generations")
print(f"Finest health achieved: {max(best_fitness_history)}")
best_genome = inhabitants[fitness_scores.index(best_fitness)]
if save_best:
with open("best_genome.pkl", "wb") as f:
pickle.dump(best_genome, f)
return best_genome, best_fitness_history, avg_fitness_history, species_count_history
inhabitants = evolution(inhabitants, fitness_scores, speciator, innov)
print(f"Could not resolve the XOR downside in {generations} generations")
print(f"Finest health achieved: {max(best_fitness_history)}")
return None, best_fitness_history, avg_fitness_history, species_count_history