5
\$\begingroup\$

I have wrote my own simple version of NEAT and want to improve the code for mainly performance (training and runtime of a generation). This simple version of NEAT aims to perform somewhere near as good as NEAT-python. When I tested it against NEAT-python this was the result with similar configuration (population 200 otherwise default settings, using my fitness function):

  • NEAT-python: 130 generations (one test)
  • simple version of NEAT: anywhere between 100 and 300 (many tests)

My simple version of NEAT performs on average 50% worse than NEAT-python.

An observation I have is that my algorithm likes to make jumps in fitness and get stuck on that fitness for a while (mainly 0.5 and 0.75 for xor example).

Update - I have tried to train it to play paper scissors rock and each generation takes a couple of seconds when the network gets complicated(using @tdy's suggestions).

Update2 - I trained it for 1000 generations, the final network had over 40 hidden neurons! It does pretty good against the paper scissors rock bots and not bad against a human until they find its weakness.

Code:

activation_functions.py

from numpy import exp
def sigmoid(x: float) -> float:
 return 1 / (1 + exp(-(x)))
activation_functions = {"sigmoid":sigmoid}

FeedForwardNetwork.py

import json
import random
import copy
import activation_functions
class FeedForwardNetwork:
 """
 FeedForwardNetwork
 - num_inputs: the number of inputs, int
 - num_outputs: the number of outputs, int
 - activation_function: the activation function of the network
 - neuron_data: the neuron data, list of lists|None
 The first num_outputs of neuron_data are output neurons
 Each item in neuron_data is data about the neuron
 - list[0]: the bias of the neuron, float
 - list[1]: the connections of the neuron, list of lists
 Each item in connections is data about the connection
 - list[0]: the neuron the connection is to
 - list[1]: the weight of the connection
 """
 def __init__(
 self,
 num_inputs: int,
 num_outputs: int,
 activation_function: str,
 neuron_data: list,
 ):
 if num_outputs > len(neuron_data):
 raise RuntimeError("outputs < len(neuron_data)")
 self.num_inputs = num_inputs
 self.num_outputs = num_outputs
 self.neuron_data = neuron_data
 self.neuron_values = [None] * (len(neuron_data))
 self.inputs = [None] * num_inputs
 self.activation_function_str = activation_function
 self.activation_function = activation_functions.activation_functions[
 activation_function
 ]
 self.change_functions = (
 self.add_neuron,
 self.add_connection,
 self.change_weight,
 self.change_bias,
 )
 self.fitness = 0
 def activate(self, inputs):
 """
 Activates the FeedForwardNetwork with inputs.
 Args:
 inputs (list): A list of numerical values representing the input to the network.
 Returns:
 tuple: A tuple containing the output values of the FeedForwardNetwork.
 Raises:
 RuntimeError: If the number of inputs doesn't match the expected number of inputs.
 """
 if self.num_inputs != len(inputs):
 raise RuntimeError("self.num_inputs != len(inputs)")
 self.neuron_values = [None] * len(self.neuron_data)
 for i in range(len(inputs)):
 self.inputs[i] = inputs[i]
 return tuple([self.calculate_neuron(i) for i in range(self.num_outputs)])
 def calculate_neuron(self, neuron: int):
 """Calculates the output value of a specific neuron in the FeedForwardNetwork.
 Args:
 neuron (int): The index of the neuron whose value to calculate. A negative index accesses input neurons.
 Returns:
 float: The calculated value of the neuron.
 """
 if neuron < 0:
 return self.inputs[neuron]
 neuron_value = self.neuron_values[neuron]
 if neuron_value is not None:
 return neuron_value
 current_neuron_data = self.neuron_data[neuron]
 self.neuron_values[neuron] = 0 # avoid RecursionError
 value = self.activation_function(
 sum(
 [
 self.calculate_neuron(conn) * weight
 for conn, weight in current_neuron_data[1]
 ]
 )
 * current_neuron_data[0]
 )
 self.neuron_values[neuron] = value
 return value
 def mutate_neurons(self):
 """Makes a random change to the structure of a FeedForwardNetwork
 Returns:
 FeedForwardNetwork: the resulting network
 """
 mutation_type = random.randint(0, 1)
 output_neurons = range(self.num_outputs)
 in_neuron = random.choice(
 [
 neuron
 for neuron in range(-self.num_inputs, len(self.neuron_data))
 if neuron not in output_neurons
 ]
 )
 out_neuron = random.randrange(len(self.neuron_data))
 return self.mutate([(mutation_type, (in_neuron, out_neuron))])
 def mutate_weights(self, change_rate: float):
 """- change_rate: how much the weights and biases are changed, float
 Returns:
 FeedForwardNetwork: the resulting network
 """
 num_neurons = len(self.neuron_data)
 connections = [
 (neuron, connection)
 for neuron, data in enumerate(self.neuron_data)
 for connection in range(len(data[1]))
 ]
 num_connections = len(connections)
 changes = []
 for neuron, data in random.sample(
 list(enumerate(self.neuron_data)),
 k=round(num_neurons * random.uniform(0.5, 1.0)),
 ):
 changes.append(
 (3, (neuron, data[0] + (random.uniform(-1.0, 1.0) * change_rate)))
 )
 for neuron, connection in random.sample(
 connections, k=round(num_connections * random.uniform(0.5, 1.0))
 ):
 changes.append(
 (
 2,
 (
 neuron,
 connection,
 self.neuron_data[neuron][1][connection][1]
 + (random.uniform(-1.0, 1.0) * change_rate),
 ),
 )
 )
 return self.mutate(changes)
 def mutate(self, changes: list):
 """- changes: a list of changes
 [(change; 0 to 3, (args)), ...]
 see the four change static methods for args
 - 0 = add_neuron
 - 1 = add_connection
 - 2 = change_weight
 - 3 = change_bias"""
 new_ffn = copy.deepcopy(self)
 for change_function, args in changes:
 self.change_functions[change_function](*args, new_ffn.neuron_data)
 return new_ffn
 def crossover(self, other):
 """Crosses over two FeedForwardNetworks weights and biases assuming they are the same structure
 Args:
 other: the other network to crossover with
 Returns:
 FeedForwardNetwork: the resulting network
 """
 new_ffn = copy.deepcopy(self)
 other_neuron_data = other.neuron_data
 for neuron, data in enumerate(other_neuron_data):
 new_ffn.neuron_data[neuron][0] = (
 data[0] + new_ffn.neuron_data[neuron][0]
 ) / 2.0
 for connection, c_data in enumerate(data[1]):
 new_ffn.neuron_data[neuron][1][connection][1] = (
 c_data[1] + new_ffn.neuron_data[neuron][1][connection][1]
 ) / 2.0
 new_ffn
 return new_ffn
 @staticmethod
 def create_bare_minimum(
 num_inputs: int, num_outputs: int, activation_function="sigmoid"
 ):
 return FeedForwardNetwork(
 num_inputs,
 num_outputs,
 activation_function,
 [copy.deepcopy([0.0, []]) for i in range(num_outputs)],
 )
 @staticmethod
 def add_neuron(neuron_in: int, neuron_out: int, neuron_data: list) -> list:
 new_neuron_data = neuron_data
 new_neuron_data.append([0.0, [[neuron_in, 0.0]]])
 new_neuron_data[neuron_out][1].append([len(new_neuron_data) - 1, 0.0])
 @staticmethod
 def add_connection(neuron_in: int, neuron_out: int, neuron_data: list) -> list:
 new_neuron_data = neuron_data
 new_neuron_data[neuron_out][1].append([neuron_in, 0.0])
 @staticmethod
 def change_weight(
 neuron: int, connection: int, weight: float, neuron_data: list
 ) -> list:
 new_neuron_data = neuron_data
 new_neuron_data[neuron][1][connection][1] = weight
 @staticmethod
 def change_bias(neuron: int, bias: float, neuron_data: list) -> list:
 new_neuron_data = neuron_data
 new_neuron_data[neuron][0] = bias
 @staticmethod
 def network_to_json(feed_forward_network):
 return json.dumps(
 [
 feed_forward_network.num_inputs,
 feed_forward_network.num_outputs,
 feed_forward_network.activation_function_str,
 feed_forward_network.neuron_data,
 ]
 )
 @staticmethod
 def json_to_network(json_str: str):
 data = json.loads(json_str)
 return FeedForwardNetwork(*data)
 def __str__(self) -> str:
 simplified_neuron_data = [
 [-(self.num_inputs - i) for i in range(self.num_inputs)],
 [
 i + self.num_outputs
 for i in range(len(self.neuron_data) - self.num_outputs)
 ],
 [i for i in range(self.num_outputs)],
 ]
 simplified_connections = [
 (connection[0], neuron)
 for neuron, data in enumerate(self.neuron_data)
 for connection in data[1]
 ]
 return str(simplified_neuron_data) + "\n" + str(simplified_connections) + "\n"
 def __repr__(self) -> str:
 return str(self.neuron_data)
if __name__ == "__main__":
 ffn = FeedForwardNetwork.create_bare_minimum(3, 2)
 print(ffn.activate([1, 1, 1]))
 new_ffn = ffn.mutate_neurons()
 print(new_ffn.activate([1, 1, 1]))
 new_new_ffn = new_ffn.mutate_weights(5.0)
 print(new_new_ffn.activate([1, 1, 1]))
 new_new_new_ffn = new_new_ffn.crossover(new_ffn)
 print(new_new_new_ffn.activate([1, 1, 1]))
 print(ffn)
 print(new_ffn)
 print(new_new_ffn)
 print(new_new_new_ffn)

Species.py

import math
from FeedForwardNetwork import FeedForwardNetwork
class Species:
 """Species
 - population: the size of the population, int
 - best_to_keep: how many networks to include in the fittest, int
 - change_rate: how much to change the networks weights when mutating, float
 - parent: the parent of the species, FeedForwardNetwork"""
 def __init__(
 self,
 population: int,
 best_to_keep: int,
 change_rate: float,
 parent: FeedForwardNetwork,
 ) -> None:
 self.best_to_keep = best_to_keep
 self.change_rate = change_rate
 self.parent = parent
 self.generations_alive = 0
 self.population_count = population
 self.population = []
 self.fittest = []
 self.repopulate([self.parent])
 def repopulate(self, parents=None):
 """Repopulates the species population with parents.
 If parents == None: parents = self.fittest
 """
 if parents == None:
 parents = self.fittest
 best = parents[0]
 self.population = parents + [best.crossover(parent) for parent in parents[1:]]
 mutations_needed = self.population_count - len(self.population)
 for index, parent in enumerate(parents):
 if index == len(parents) - 1:
 mutations_for_parent = mutations_needed
 else:
 mutations_for_parent = math.ceil(mutations_needed / 2)
 for i in range(mutations_for_parent):
 self.population.append(
 parent.mutate_weights(self.change_rate)
 )
 mutations_needed -= mutations_for_parent
 def get_fittest(self):
 """sets self.fittest to the self.best_to_keep fittest of the species population"""
 self.fittest = sorted(self.population, key=lambda x: x.fitness, reverse=True)[
 : self.best_to_keep
 ]
if __name__ == "__main__":
 def xor(a, b):
 return bool(a) ^ bool(b)
 bare = FeedForwardNetwork.create_bare_minimum(2, 1)
 # best:
 # [[-34.71928105009088, [[1, -18.799558602148053], [2, 21.97129781931126]]], [-3.7697600919986067, [[-1, -5.2222081577237365], [-2, -8.675206506483445]]], [0.11407851623931986, [[-2, 10.820838527759477], [-1, 12.20712625251363]]]]
 # [1.2235905164272823e-24, 1.0, 1.0, 3.245832864766183e-26]
 # [0, 1, 1, 0]
 # 1.0
 # 500
 xor_parent = bare.mutate([(0, (-1, 0)), (0, (-2, 0)), (1, (-2, 1)), (1, (-1, 2))])
 xor_species = Species(20, 5, 5, xor_parent)
 try:
 gen = 0
 while True:
 gen += 1
 for ffn in xor_species.population:
 expected_output = [
 int(xor(0, 0)),
 int(xor(0, 1)),
 int(xor(1, 0)),
 int(xor(1, 1)),
 ]
 network_output = [
 *ffn.activate([0, 0]),
 *ffn.activate([0, 1]),
 *ffn.activate([1, 0]),
 *ffn.activate([1, 1]),
 ]
 difference = [
 abs(expected - network)
 for expected, network in zip(expected_output, network_output)
 ]
 ffn.fitness = 1 - (sum(difference) / len(difference))
 xor_species.get_fittest()
 xor_species.repopulate()
 best = xor_species.fittest[0]
 print(repr(best), sep="\n")
 print(
 [
 *best.activate([0, 0]),
 *best.activate([0, 1]),
 *best.activate([1, 0]),
 *best.activate([1, 1]),
 ]
 )
 print([int(xor(0, 0)), int(xor(0, 1)), int(xor(1, 0)), int(xor(1, 1))])
 print(repr(best.fitness), sep="\n")
 print(gen, "\n", sep="")
 except KeyboardInterrupt:
 pass

Population.py

from FeedForwardNetwork import FeedForwardNetwork
from Species import Species
class Population:
 """Population
 - num_inputs: the number of inputs the network will have, int
 - num_outputs: the number of outputs the network will have, int
 - num_species: the number of species in the population, int
 - population_per_species: the size of the species populations, int
 - weight_change_rate: how much to change the networks weights when mutating, float
 - species_best_to_keep: how many networks to include in each species fittest, int
 - species_min_gens_alive: the minimum generations a species can run for, int
 - species_dont_remove: cutoff point for trying to remove bad species, 0 - 1, float
 - run_networks_func: function for running species and setting fitnesses, function
 - activation_function: the activation function of the network, default "sigmoid", str
 """
 def __init__(
 self,
 num_inputs: int,
 num_outputs: int,
 num_species: int,
 population_per_species: int,
 weight_change_rate: float,
 species_best_to_keep: int,
 species_min_gens_alive: int,
 species_dont_remove: float,
 run_networks_func,
 activation_function="sigmoid",
 ) -> None:
 self.bare_ffn = FeedForwardNetwork.create_bare_minimum(num_inputs, num_outputs)
 self.species_best_to_keep = species_best_to_keep
 self.weight_change_rate = weight_change_rate
 self.population_per_species = population_per_species
 self.run_networks_func = run_networks_func
 self.species_min_gens_alive = species_min_gens_alive
 self.species_dont_remove = species_dont_remove
 self.population_of_species = []
 self.num_species = num_species
 self.populate_with_species(self.bare_ffn)
 self.generation = 0
 def populate_with_species(self, parent):
 """populates self.population_of_species with parent as the parent of the species"""
 self.population_of_species += [
 Species(
 self.population_per_species,
 self.species_best_to_keep,
 self.weight_change_rate,
 parent.mutate_neurons(),
 )
 for i in range(self.num_species - len(self.population_of_species))
 ]
 def run_generation(self):
 """runs a generation using the run_networks_func
 then does structure mutations, weight mutations and crossovers"""
 self.run_networks_func(
 [
 network
 for species in self.population_of_species
 for network in species.population
 ]
 )
 for species in self.population_of_species:
 species.get_fittest()
 species.repopulate()
 species.generations_alive += 1
 self.generation += 1
 self.remove_worst_species()
 best_network = self.sort_species_by_fitness(reverse=True)[0].fittest[0]
 self.populate_with_species(best_network)
 def remove_worst_species(self):
 """removes the worst species"""
 for index, species in enumerate(self.sort_species_by_fitness()):
 if (species.generations_alive > self.species_min_gens_alive) and index < (
 self.num_species * self.species_dont_remove
 ):
 self.population_of_species.remove(species)
 break
 def get_fitness_of_species(self, species):
 """gets the fitness of a certain species"""
 if not species.fittest:
 species.get_fittest()
 return species.fittest[0].fitness
 def sort_species_by_fitness(self, reverse=False):
 """sorts species by fitness"""
 return sorted(
 self.population_of_species, key=self.get_fitness_of_species, reverse=reverse
 )
if __name__ == "__main__":
 def xor(a, b):
 return bool(a) ^ bool(b)
 xor_expected = [
 int(xor(0, 0)),
 int(xor(0, 1)),
 int(xor(1, 0)),
 int(xor(1, 1)),
 ]
 def run_network(network):
 return [
 *network.activate([0, 0]),
 *network.activate([0, 1]),
 *network.activate([1, 0]),
 *network.activate([1, 1]),
 ]
 def xor_run(networks):
 for ffn in networks:
 network_output = run_network(ffn)
 difference = [
 abs(expected - network)
 for expected, network in zip(xor_expected, network_output)
 ]
 ffn.fitness = 1 - (sum(difference) / len(difference))
 xor_population = Population(2, 1, 5, 20, 5.0, 4, 20, 0.75, xor_run)
 try:
 while True:
 xor_population.run_generation()
 best_species = xor_population.sort_species_by_fitness()[0]
 best_network = best_species.fittest[0]
 print(best_network)
 print(run_network(best_network))
 print(xor_expected)
 print(best_network.fitness)
 print(xor_population.generation, "\n", sep="")
 except KeyboardInterrupt:
 print(repr(best_network))
 with open("best", "w") as file:
 file.write(FeedForwardNetwork.network_to_json(best_network))
 with open("best", "r") as file:
 loaded_best = FeedForwardNetwork.json_to_network(file.read())
 print(loaded_best)
 print(run_network(loaded_best))
 print(xor_expected, "\n")
 print(repr(loaded_best))

SimpleNeat.py

import importlib
from FeedForwardNetwork import FeedForwardNetwork
from Population import Population
class SimpleNeatTrainer:
 """SimpleNeatTrainer
 
 - config_module: the imported config module, module
 - run_networks_func: the function that is called to run the networks, function"""
 def __init__(self, config_module, run_networks_func) -> None:
 self.run_networks_func = run_networks_func
 self.population = Population(
 config_module.num_inputs,
 config_module.num_outputs,
 config_module.num_species,
 config_module.population_per_species,
 config_module.weight_change_rate,
 config_module.species_best_to_keep,
 config_module.species_min_gens_alive,
 config_module.species_dont_remove,
 self.run_networks_func,
 config_module.activation_function,
 )
 self.best_networks = []
 self.generation = self.population.generation
 def run_generation(self):
 """runs generation and calls the run_networks_func"""
 self.population.run_generation()
 self.generation = self.population.generation
 best_species = self.population.sort_species_by_fitness()[0]
 self.best_networks.append(best_species.fittest[0])
 def save_network(self, save_file, network):
 """saves network to save_file"""
 with open(save_file, "w") as file:
 file.write(FeedForwardNetwork.network_to_json(network))
 def load_from_file(self, save_file):
 """loads a network from save_file and returns network"""
 with open(save_file, "r") as file:
 return FeedForwardNetwork.json_to_network(file.read())
 def get_best(self, first_to_fitness=None):
 """gets the best network
 
 if first_to_fitness != None the first network to reach first_to_fitness is returned"""
 if first_to_fitness == None:
 return self.best_networks[-1]
 for network in self.best_networks:
 if network.fitness > first_to_fitness:
 return network
 return self.best_networks[-1]
 

Example code:

example_config.py

"""network
 - num_inputs: the number of inputs the network will have, int
 - num_outputs: the number of outputs the network will have, int
 - activation_function: the activation function of the network, default "sigmoid", str"""
num_inputs = 2
num_outputs = 1
activation_function = "sigmoid"
"""species
 - population_per_species: the size of the species populations, int
 - weight_change_rate: how much to change the networks weights when mutating, float"""
population_per_species = 20
weight_change_rate = 5.0
""" population
 - num_species: the number of species in the population, int
 - species_min_gens_alive: the minimum generations a species can run for, int
 - species_dont_remove: cutoff point for trying to remove bad species, 0 - 1, float
 - species_best_to_keep: how many networks to include in each species fittest, int"""
num_species = 5
species_min_gens_alive = 20
species_dont_remove = 0.75
species_best_to_keep = 4

example.py

from doctest import Example
from time import sleep
from SimpleNeat import SimpleNeatTrainer
import example_config
def xor(a, b):
 return bool(a) ^ bool(b)
xor_expected = [
 int(xor(0, 0)),
 int(xor(0, 1)),
 int(xor(1, 0)),
 int(xor(1, 1)),
]
def run_network(network):
 return [
 *network.activate([0, 0]),
 *network.activate([0, 1]),
 *network.activate([1, 0]),
 *network.activate([1, 1]),
 ]
def xor_run(networks):
 for network in networks:
 network_output = run_network(network)
 difference = [
 abs(expected - network)
 for expected, network in zip(xor_expected, network_output)
 ]
 network.fitness = 1 - (sum(difference) / len(difference))
def print_network_info(network):
 print(network)
 print(run_network(network))
 print(xor_expected)
 print(network.fitness)
xor_trainer = SimpleNeatTrainer(example_config, xor_run)
try:
 while True:
 xor_trainer.run_generation()
 best_network = xor_trainer.get_best()
 print_network_info(best_network)
 print(xor_trainer.generation, "\n", sep="")
 sleep(0)
except KeyboardInterrupt:
 print(best_network)
 print([network.fitness for network in xor_trainer.best_networks])
 xor_trainer.save_network("best.txt", xor_trainer.get_best(0.999))
loaded_best = xor_trainer.load_from_file("best.txt")
xor_run([loaded_best])
print_network_info(loaded_best)

profile output of example.py (relevant bit only)

 ncalls tottime percall cumtime percall filename:lineno(function)
 1 0.000 0.000 0.344 0.344 FeedForwardNetwork.py:1(<module>)
404184/88076 1.184 0.000 4.910 0.000 FeedForwardNetwork.py:102(<listcomp>)
 55 0.001 0.000 0.026 0.000 FeedForwardNetwork.py:113(mutate_neurons)
 55 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:122(<listcomp>)
 15171 0.443 0.000 9.023 0.001 FeedForwardNetwork.py:131(mutate_weights)
 15171 0.104 0.000 0.117 0.000 FeedForwardNetwork.py:138(<listcomp>)
 15226 0.232 0.000 7.356 0.000 FeedForwardNetwork.py:170(mutate)
 3261 0.069 0.000 1.588 0.000 FeedForwardNetwork.py:186(crossover)
 1 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:208(create_bare_minimum)
 1 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:216(<listcomp>)
 24 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:219(add_neuron)
 31 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:225(add_connection)
 128649 0.053 0.000 0.053 0.000 FeedForwardNetwork.py:230(change_weight)
 52947 0.022 0.000 0.022 0.000 FeedForwardNetwork.py:237(change_bias)
 1 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:242(network_to_json)
 1 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:253(json_to_network)
 219 0.007 0.000 0.009 0.000 FeedForwardNetwork.py:258(__str__)
 219 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:260(<listcomp>)
 219 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:261(<listcomp>)
 219 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:265(<listcomp>)
 219 0.001 0.000 0.001 0.000 FeedForwardNetwork.py:267(<listcomp>)
 2 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:29(__init__)
 88076 0.533 0.000 6.650 0.000 FeedForwardNetwork.py:56(activate)
 1 0.000 0.000 0.000 0.000 FeedForwardNetwork.py:7(FeedForwardNetwork)
 88076 0.110 0.000 6.065 0.000 FeedForwardNetwork.py:77(<listcomp>)
1080496/88076 1.744 0.000 5.955 0.000 FeedForwardNetwork.py:79(calculate_neuron)
 1 0.000 0.000 0.001 0.001 Population.py:1(<module>)
 1 0.000 0.000 0.024 0.024 Population.py:20(__init__)
 218 0.001 0.000 0.659 0.003 Population.py:48(populate_with_species)
 1 0.000 0.000 0.000 0.000 Population.py:5(Population)
 218 0.001 0.000 0.658 0.003 Population.py:50(<listcomp>)
 218 0.013 0.000 17.812 0.082 Population.py:60(run_generation)
 218 0.007 0.000 0.007 0.000 Population.py:64(<listcomp>)
 217 0.001 0.000 0.004 0.000 Population.py:82(remove_worst_species)
 3205 0.002 0.000 0.003 0.000 Population.py:91(get_fitness_of_species)
 651 0.001 0.000 0.007 0.000 Population.py:97(sort_species_by_fitness)
 1 0.000 0.000 0.348 0.348 SimpleNeat.py:1(<module>)
 1 0.000 0.000 0.024 0.024 SimpleNeat.py:11(__init__)
 218 0.002 0.000 17.817 0.082 SimpleNeat.py:28(run_generation)
 1 0.001 0.001 0.002 0.002 SimpleNeat.py:36(save_network)
 1 0.000 0.000 0.001 0.001 SimpleNeat.py:41(load_from_file)
 218 0.000 0.000 0.000 0.000 SimpleNeat.py:46(get_best)
 1 0.000 0.000 0.000 0.000 SimpleNeat.py:6(SimpleNeatTrainer)
 1 0.000 0.000 0.000 0.000 Species.py:1(<module>)
 55 0.000 0.000 0.632 0.011 Species.py:13(__init__)
 1142 0.153 0.000 10.785 0.009 Species.py:31(repopulate)
 1142 0.006 0.000 1.593 0.001 Species.py:39(<listcomp>)
 1 0.000 0.000 0.000 0.000 Species.py:5(Species)
 1137 0.005 0.000 0.033 0.000 Species.py:55(get_fittest)
 22740 0.010 0.000 0.010 0.000 Species.py:57(<lambda>)

edited - finished project

asked Mar 21, 2024 at 4:09
\$\endgroup\$
2
  • \$\begingroup\$ mutate() describes what indices {0,1,2,3} mean. Consider using a @dataclass instead, which would allow the use of names. \$\endgroup\$ Commented Mar 26, 2024 at 18:08
  • 1
    \$\begingroup\$ @J_H good idea, I just decided to turn self.change_functions into a dictionary with strings as keys. \$\endgroup\$ Commented Mar 26, 2024 at 21:31

2 Answers 2

3
+50
\$\begingroup\$

General thoughts

The code is scattered all around the place, which makes reasoning about a bit tedious. Consider placing several classes into a single module, I would suggest one for base network-related stuff and one for higher-level usage such as species, populations and training. This is also true for example code found at the end of your files; this should either be a job for doctest or your example.py since randomness is trickier to test right.

The code starts well laid out, documented and with reasonable type hints, but it quickly fades away, becomes inconsistent or downright missing. Please consider extending your efforts to the rest of the code.

Naming is generally fine, except for file names which are un-Pythonic and make imports look weird, consider using snake_case here as well.

You also seem to over-use @staticmethod when some places would more logically make use of a @classmethod to get the class as first parameter instead of hard-coding FeedForwardNetwork, for instance.

Finally, even with your comments, the structure of your neuron_data takes a while to understand and the various [0] and [1] to access it do not help either. Consider using dataclasses to a) document the shape of your datastructure and b) help the reader understand how you access it by naming fields instead of using indexes. Exposing this structure also help to realize that it is not deeply nested and that, maybe, copy.deepcopy can be overkill compared to a simple iteration.

Dataclasses could also be worth considering for the rest of the code as most of your __init__ methods do is store parameters as attributes. And it also gives you a "free" __repr__ as well.

feed_forward_network.py

There are two main points of contention when trying to understand the FeedForwardNetwork class: how you build and apply the mutations and the crossover method.

mutate

When I saw the self.change_functions list and the call to mutate with indexes, my first thought was that it was way too cryptic of a process to "just" defer the call to the right function for when you'll have the proper neuron_data to call them with.

Instead, and since you have almost all the parameters necessary when you call mutate, you can make use of functools.partial to pass in functions that only require one argument: the neuron_data to work on.

This approach, however, makes "manual" usage of mutate (such as the one at the end of Species.py) a bit awkward. A dedicated class holding mutation functions that takes parameters in and return the function to work on neuron_data might be preferred for a more descriptive API.

crossover

Instead of iterating a first time over our neurons and their connections to create a copy and then, doing it all again over others neurons and connections to update them, you should instead zip the data structures together and iterate at once over both. This will simplify your code and make the function more robust in case other do not, in fact, have the same structure.

As said previously, using dataclasses to represent your neurons can go a long way into making this function more readable and self descriptive.

calculate_neuron

The special case of negative indexes for input neurons in this function got me by surprise at first. Even though it works, because of negative indexes, I really thought that you might skip accesses to the first element and may access past the last.

You can also leverage that behavior of negative indexes to avoid a special case for them: append the inputs to neuron_values and you're done; positive indexes will access output and inner neurons as usual and negative indexes will access input values without a specific test.

Lastly, this function is an utility function meant to be used by activate, consider advertising that by prepending an underscore to its name (which, by convention, indicate an internal function).

JSON

Consider using helper packages to leverage your JSON (de)serialization needs. When using dataclasses, I find dataclasses_json simple to use and easily extensible. Also, since you’re only converting networks to JSON, even in SimpleNEAT.py, consider putting all your JSON needs into this class. dataclasses_json will provide a to_dict/to_json and from_dict/from_json for you so you’ll just need to add a dump (save_to_file) and load (load_from_file) to interact with the filesystem.

construction and configuration

create_bare_minimum feels clunky to me. It's just an alternate constructor with fewer parameters. You can incorporate this behavior directly into the constructor by having a default value of None for neuron_data and test for that to create a default neuron for each output.

Also the way to handle the activation function is a bit rigid, as it forces users to define them into your activation_functions module. You’d be better off directly accepting any callable of one argument, with a sensible default of activation_functions.sigmoid. This way, users of your package won't have to perform monkey-patching wizardry to circumvent your limitations.

Speaking of sigmoid, though, I didn't quite get why you'd use numpy's exp instead of math's until exp(709.7827) ish got me an OverflowError. numpy behaviour is nicer as it only outputs a warning and returns inf, but we can approximate that quite easily while dropping that big dependency by simply catching the OverflowError and returning 0.0 instead, which is as good as it gets at such high numbers anyways.

population.py

I would put in a single file every other classes that build upon FeedForwardNetwork since they serve similar purposes and are only abstraction layers on top of each other. I also feel that the SimpleNeatTrainer is not necessary as its behavior can easily be embeded in a single @classmethod of Population. The biggest thing that kept feeling not quite right with the interactions between all these classes was how the "parents" were managing the state of the "children" such as how Species managed the fitness of FeedForwardNetwork and how Population managed the generations_alive of the Species. In the end, I feel it's better to tie these states closer to where they are used and to let Species manage both its generations_alive and associate a fitness to each network it owns (through the use of yet another dataclass, since we’re at it).

Other than that, not much changes are needed besides adapting to the changes in lower data structures. Only dropping a note here that using dataclasses can simplify sorting/scanning for fittest as you can easily define which fields participate in ordering comparison, so you don't have to extract them afterwards using the key parameters of sorted or max.

Proposed improvements

activation_functions.py

from math import exp
def sigmoid(x: float) -> float:
 try:
 return 1 / (1 + exp(-x))
 except OverflowError:
 return 0.0

feed_forward_network.py

import json
import random
import itertools
from dataclasses import dataclass, field
from typing import Callable, Self, TypeAlias
from dataclasses_json import dataclass_json, config
from marshmallow import fields
import activation_functions
def retrieve_activation_function(name):
 if callable(name):
 return name
 try:
 return getattr(activation_functions, name)
 except AttributeError:
 import warnings
 warnings.warn(f"activation function {name} not found, falling back to default")
 return activation_functions.sigmoid
class NeatError(Exception):
 pass
@dataclass_json
@dataclass(slots=True, eq=False, match_args=False)
class Connection:
 source: int
 weight: float
@dataclass_json
@dataclass(slots=True, eq=False, match_args=False)
class Neuron:
 bias: float
 connections: list[Connection]
 @classmethod
 def create_default(cls):
 return cls(0.0, [])
Neurons: TypeAlias = list[Neuron]
MutationCallback: TypeAlias = Callable[[Neurons], None]
ActivationFunction: TypeAlias = Callable[[float], float]
class Mutation:
 """Catalog of mutation functions.
 Each method is meant to be called with parameters of a change to be
 applied on a network. It then return a callback that accept a list
 of Neuron as parameter and apply the configured change onto it, in
 place.
 """
 @staticmethod
 def add_neuron(neuron_in: int, neuron_out: int) -> MutationCallback:
 def mutate(neuron_data: Neurons):
 extra_neuron_index = len(neuron_data)
 neuron_data.append(Neuron(0.0, [Connection(neuron_in, 0.0)]))
 neuron_data[neuron_out].connections.append(Connection(extra_neuron_index, 0.0))
 return mutate
 @staticmethod
 def add_connection(neuron_in: int, neuron_out: int) -> MutationCallback:
 def mutate(neuron_data: Neurons):
 neuron_data[neuron_out].connections.append(Connection(neuron_in, 0.0))
 return mutate
 @staticmethod
 def change_weight(neuron: int, connection: int, weight: float) -> MutationCallback:
 def mutate(neuron_data: Neurons):
 neuron_data[neuron].connections[connection].weight = weight
 return mutate
 @staticmethod
 def change_bias(neuron: int, bias: float) -> MutationCallback:
 def mutate(neuron_data: Neurons):
 neuron_data[neuron].bias = bias
 return mutate
@dataclass_json
@dataclass(slots=True, eq=False, match_args=False)
class FeedForwardNetwork:
 """Simple network representation that stores data about neurons and
 their connections.
 This data is stored into a list of Neuron so that:
 - input neurons are implicit and only have an associated value
 during activation (see self.activate)
 - output neurons are the first num_output neurons in the list
 - other values in the list are inner neurons used to connect
 inputs and outputs through various paths
 Args:
 num_inputs: the number of input neurons
 num_outputs: the number of output neurons
 activation_function: the activation function of the network
 neuron_data: the neuron data, base topology of the network
 """
 num_inputs: int
 num_outputs: int
 activation_function: ActivationFunction = field(
 default=activation_functions.sigmoid,
 metadata=config(
 encoder=lambda f: f.__name__, # necessary for to_dict/to_json
 decoder=retrieve_activation_function, # necessary for from_dict/from_json
 mm_field=fields.Function( # necessary for .schema()
 serialize=lambda ffn: ffn.activation_function.__name__,
 deserialize=retrieve_activation_function,
 ),
 ),
 )
 neuron_data: Neurons = None
 def __post_init__(self):
 output = self.num_outputs
 if self.neuron_data is None:
 self.neuron_data = [Neuron.create_default() for _ in range(output)]
 if output > len(self.neuron_data):
 raise NeatError("Providing less neurons than expected outputs")
 def activate(self, inputs: list[float]) -> tuple[float, ..., float]:
 """
 Activates the network with inputs.
 Args:
 inputs: A list of numerical values representing the input to the network.
 Returns:
 The output values of the network.
 Raises:
 NeatError: If the number of inputs doesn't match the expected number of inputs.
 """
 if self.num_inputs != len(inputs):
 raise NeatError(f"Providing {len(inputs)} input instead of {self.num_inputs}")
 neuron_values = [None] * len(self.neuron_data)
 neuron_values.extend(inputs)
 return tuple(self._calculate_neuron(i, neuron_values) for i in range(self.num_outputs))
 def _calculate_neuron(self, neuron: int, neuron_values: list[float | None]) -> float:
 """Calculate the output value of a specific neuron in the network
 Args:
 neuron: The index of the neuron whose value to calculate
 neuron_values: The pre-computed values for each neuron.
 Input values are present at the end so
 negative indexes can access them.
 Returns:
 The calculated output of the neuron
 """
 neuron_value = neuron_values[neuron]
 if neuron_value is not None:
 return neuron_value
 neuron_data = self.neuron_data[neuron]
 neuron_values[neuron] = 0 # avoid RecursionError
 value = self.activation_function(
 neuron_data.bias * sum(
 self._calculate_neuron(c.source, neuron_values) * c.weight
 for c in neuron_data.connections
 )
 )
 neuron_values[neuron] = value
 return value
 def mutate_neurons(self) -> Self:
 """Make a random change to the structure of the network
 Returns:
 The resulting network
 """
 mutation_type = random.choice([Mutation.add_neuron, Mutation.add_connection])
 in_neuron = random.choice(list(itertools.chain(
 range(-self.num_inputs, 0), # Source neuron can be either an input
 range(self.num_outputs, len(self.neuron_data)), # or an inner neuron
 )))
 # Destination neuron is either an output or an inner neuron
 out_neuron = random.randrange(len(self.neuron_data))
 return self.mutate([mutation_type(in_neuron, out_neuron)])
 def mutate_weights(self, change_rate: float) -> Self:
 """Make random changes to the values of the network
 Args:
 change_rate: how much the weights and biases are changed
 Returns:
 The resulting network
 """
 neuron_data = list(enumerate(self.neuron_data))
 num_neurons = len(neuron_data)
 num_changes = round(num_neurons * random.uniform(0.5, 1.0))
 neurons_changed = random.sample(neuron_data, k=num_changes)
 changes = [
 Mutation.change_bias(
 neuron_index,
 neuron.bias + random.uniform(-1.0, 1.0) * change_rate,
 )
 for neuron_index, neuron in neurons_changed
 ]
 connections = [
 (neuron_index, connection_index, connection.weight)
 for neuron_index, neuron in neuron_data
 for connection_index, connection in enumerate(neuron.connections)
 ]
 num_changes = round(len(connections) * random.uniform(0.5, 1.0))
 connections_changed = random.sample(connections, k=num_changes)
 changes += [
 Mutation.change_weight(
 neuron,
 connection,
 weight + random.uniform(-1.0, 1.0) * change_rate,
 )
 for neuron, connection, weight in connections_changed
 ]
 return self.mutate(changes)
 def mutate(self, changes: list[MutationCallback]) -> Self:
 """Apply the given changes to the network.
 Each change will be called with a copy of the current neurons.
 
 Args:
 changes: a list of changes functions to apply
 Returns:
 The resulting network
 """
 new_neurons = self.clone_neurons()
 for change_function in changes:
 change_function(new_neurons)
 return self.duplicate(new_neurons)
 def crossover(self, other: Self) -> Self:
 """Crosses over two networks weights and biases assuming they
 are the same structure.
 Args:
 other: the other network to crossover with
 Returns:
 The resulting network
 """
 new_neurons = [
 Neuron((n1.bias + n2.bias) / 2.0, [
 Connection(c1.source, (c1.weight + c2.weight) / 2.0)
 for c1, c2 in zip(n1.connections, n2.connections)
 ])
 for n1, n2 in zip(self.neuron_data, other.neuron_data)
 ]
 return self.duplicate(new_neurons)
 def clone_neurons(self) -> Neurons:
 """Copy the neurons of the network.
 Mostly used as base neurons in another network without sharing
 state between both.
 Returns:
 A deep copy of our neurons
 """
 return [
 Neuron(n.bias, [Connection(c.source, c.weight) for c in n.connections])
 for n in self.neuron_data
 ]
 def duplicate(self, new_neurons: Neurons=None) -> Self:
 """Create a new network out of the provided neurons.
 Clone ours if neurons are not provided.
 Args:
 new_neurons (optional): the neurons used to construct the
 new network
 Returns:
 A network with similar properties than ourselves but new
 neurons
 """
 if new_neurons is None:
 new_neurons = self.clone_neurons()
 return self.__class__(self.num_inputs, self.num_outputs, self.activation_function, new_neurons)
 def dump(self, filename: str) -> None:
 """Dump the network JSON representation into a file"""
 with open(filename, 'w') as json_file:
 json.dump(self.__class__.schema().dump(self), json_file)
 @classmethod
 def load(cls, filename: str) -> Self:
 """Load a JSON representation out of a file to build a network from"""
 with open(filename, 'r') as json_file:
 return cls.schema().load(json.load(json_file))
 def __str__(self) -> str:
 neuron_range = iter(range(-self.num_inputs, len(self.neuron_data)))
 input_range = list(itertools.islice(neuron_range, self.num_inputs))
 output_range = list(itertools.islice(neuron_range, self.num_outputs))
 extra_range = list(neuron_range)
 simplified_neuron_data = [input_range, extra_range, output_range]
 simplified_connections = [
 (connection.source, neuron_index)
 for neuron_index, neuron in enumerate(self.neuron_data)
 for connection in neuron.connections
 ]
 return str(simplified_neuron_data) + "\n" + str(simplified_connections) + "\n"

population.py

I started to get lazy rewriting docstrings and type hints.

from math import ceil
from typing import Callable, TypeAlias
from dataclasses import dataclass, field, InitVar
from feed_forward_network import FeedForwardNetwork, Mutation, ActivationFunction
FitnessFunction: TypeAlias = Callable[[FeedForwardNetwork], float]
@dataclass(slots=True, order=True, match_args=False)
class Individual:
 network: FeedForwardNetwork = field(compare=False)
 fitness: float = field(default=0.0)
@dataclass(slots=True, eq=False, match_args=False)
class Species:
 """Simple species representation.
 A species is a group of networks evolving via reproduction between
 each other and/or mutations of their individual members. Only
 fittest networks are kept between generations to increase the odds
 of finding the fittest individual.
 Args:
 population_count: the size of the population
 best_to_keep: how many networks to include in the fittest
 change_rate: how much to change the networks weights when mutating
 parent: the first parent of the species
 """
 population_count: int
 best_to_keep: int
 change_rate: float
 parent: InitVar[FeedForwardNetwork]
 generations_alive: int = field(default=0, init=False)
 population: list[Individual] = field(init=False)
 def __post_init__(self, parent):
 self.population = [Individual(parent)]
 def repopulate(self):
 """Repopulate the species population by reproductions and mutations"""
 parents = sorted(self.population, reverse=True)[:self.best_to_keep]
 siblings = iter(parents)
 best = next(siblings)
 # Increase population by reproduction
 self.population = parents + [
 Individual(best.network.crossover(sibling.network))
 for sibling in siblings
 ]
 # Increase population by mutation
 mutations_needed = self.population_count - len(self.population)
 for parent in parents:
 mutations_for_parent = ceil(mutations_needed / 2)
 self.population.extend(
 Individual(parent.network.mutate_weights(self.change_rate))
 for _ in range(mutations_for_parent)
 )
 mutations_needed -= mutations_for_parent
 # Keep applying the last mutations needed on the last parent
 self.population.extend(
 Individual(parents[-1].network.mutate_weights(self.change_rate))
 for _ in range(mutations_needed)
 )
 self.generations_alive += 1
 def run_for_fitness(self, fitness_function: FitnessFunction) -> None:
 """Apply the fitness function to each individuals and score their fitness"""
 for individual in self.population:
 individual.fitness = fitness_function(individual.network)
 @property
 def fittest(self) -> Individual:
 """Find the fittest individual in the population"""
 return max(self.population)
@dataclass(slots=True, eq=False, match_args=False)
class Population:
 """A group of similar Species.
 A population keep track of some species and cull out unfit ones if
 they do not outperform in a given number of generations.
 - num_inputs: the number of inputs the network will have, int
 - num_outputs: the number of outputs the network will have, int
 - num_species: the number of species in the population, int
 - population_per_species: the size of the species populations, int
 - weight_change_rate: how much to change the networks weights when mutating, float
 - species_best_to_keep: how many networks to include in each species fittest, int
 - species_min_gens_alive: the minimum generations a species can run for, int
 - species_dont_remove: cutoff point for trying to remove bad species, 0 - 1, float
 - fitness_function: function for computing fitness of a given network, function
 - activation_function: the activation function of the network, default "sigmoid", str
 """
 num_inputs: InitVar[int]
 num_outputs: InitVar[int]
 num_species: int
 population_per_species: int
 weight_change_rate: float
 species_best_to_keep: int
 species_min_gens_alive: int
 species_dont_remove: float
 fitness_function: FitnessFunction
 activation_function: InitVar[ActivationFunction] = None
 population_of_species: list[Species] = field(init=False)
 generation: int = field(default=0, init=False)
 def __post_init__(self, num_inputs, num_outputs, activation_function):
 self.population_of_species = []
 ffn = FeedForwardNetwork(num_inputs, num_outputs)
 if activation_function is not None:
 ffn.activation_function = activation_function
 self.populate_with_species(ffn)
 def populate_with_species(self, parent: FeedForwardNetwork) -> None:
 """populates self.population_of_species with parent as the parent of the species"""
 self.population_of_species += [
 Species(
 self.population_per_species,
 self.species_best_to_keep,
 self.weight_change_rate,
 parent.mutate_neurons(),
 )
 for i in range(self.num_species - len(self.population_of_species))
 ]
 def run_generation(self) -> None:
 """runs a generation using the fitness_function
 then does structure mutations, weight mutations and crossovers"""
 for species in self.population_of_species:
 species.repopulate()
 species.run_for_fitness(self.fitness_function)
 self.generation += 1
 self.remove_worst_species()
 self.populate_with_species(self.fittest_species.fittest.network)
 def remove_worst_species(self) -> None:
 """removes the worst species"""
 species = sorted(
 (s.fittest.fitness, s.generations_alive, index)
 for index, s in enumerate(self.population_of_species)
 )
 cutoff_point = self.num_species * self.species_dont_remove
 for _, generations, index in species:
 if generations > self.species_min_gens_alive and index < cutoff_point:
 del self.population_of_species[index]
 break
 @property
 def fittest_species(self) -> Species:
 """Find the species with the fittest individual"""
 return max(self.population_of_species, key=lambda s: s.fittest.fitness)
 @classmethod
 def train(cls, config_module, fitness_function: FitnessFunction, target_fitness: float=None) -> Individual:
 self = cls(
 config_module.num_inputs,
 config_module.num_outputs,
 config_module.num_species,
 config_module.population_per_species,
 config_module.weight_change_rate,
 config_module.species_best_to_keep,
 config_module.species_min_gens_alive,
 config_module.species_dont_remove,
 fitness_function,
 config_module.activation_function,
 )
 best_individual = Individual(None)
 try:
 while target_fitness is None or best_individual.fitness < target_fitness:
 self.run_generation()
 best_species = self.fittest_species
 best_individual = best_species.fittest
 except Exception:
 pass
 finally:
 return best_individual

example.py

from itertools import chain
from operator import xor
from population import Population, Species
from feed_forward_network import FeedForwardNetwork
XOR_EXPECTED = {
 inputs: xor(*inputs)
 for inputs in ((0, 0), (0, 1), (1, 0), (1, 1))
}
def expected():
 return list(XOR_EXPECTED.values())
def run_network(network):
 # We expect a single output from each inputs so
 # flatten the resulting list for easier manipulation
 return list(chain.from_iterable(
 network.activate(inputs)
 for inputs in XOR_EXPECTED
 ))
def xor_fitness(network):
 differences = [
 abs(network.activate(inputs)[0] - expected)
 for inputs, expected in XOR_EXPECTED.items()
 ]
 return 1 - (sum(differences) / len(differences))
class Config:
 # Network
 num_inputs = 2
 num_outputs = 1
 activation_function = None # keep default
 # Species
 population_per_species = 20
 weight_change_rate = 5.0
 # Population
 num_species = 5
 species_best_to_keep = 4
 species_min_gens_alive = 20
 species_dont_remove = 0.75
def simple_demo():
 ffn = FeedForwardNetwork(3, 2)
 print(ffn.activate([1, 1, 1]))
 new_ffn = ffn.mutate_neurons()
 print(new_ffn.activate([1, 1, 1]))
 new_new_ffn = new_ffn.mutate_weights(5.0)
 print(new_new_ffn.activate([1, 1, 1]))
 new_new_new_ffn = new_new_ffn.crossover(new_ffn)
 print(new_new_new_ffn.activate([1, 1, 1]))
 print(ffn)
 print(new_ffn)
 print(new_new_ffn)
 print(new_new_new_ffn)
 new_new_new_ffn.dump('best')
 x = FeedForwardNetwork.load('best')
 print(repr(x))
def species_demo():
 bare = FeedForwardNetwork(2, 1)
 xor_parent = bare.mutate([
 Mutation.add_neuron(-1, 0),
 Mutation.add_neuron(-2, 0),
 Mutation.add_connection(-2, 1),
 Mutation.add_connection(-1, 2),
 ])
 xor_species = Species(20, 5, 5, xor_parent)
 while True:
 xor_species.repopulate()
 xor_species.run_for_fitness(xor_fitness)
 best = xor_species.fittest
 print(repr(best.network))
 print(run_network(best.network))
 print(expected())
 print(best.fitness)
 print(xor_species.generations_alive)
 print('-' * 42)
def population_demo():
 xor_population = Population(2, 1, 5, 20, 5.0, 4, 20, 0.75, xor_fitness)
 try:
 while True:
 xor_population.run_generation()
 best_species = xor_population.fittest_species
 best = best_species.fittest
 print(best.network)
 print(run_network(best.network))
 print(expected())
 print(best.fitness)
 print(xor_population.generation)
 print('-' * 42)
 except KeyboardInterrupt:
 print(repr(best.network))
 best.network.dump('best')
 loaded_best = FeedForwardNetwork.load('best')
 print(loaded_best)
 print(run_network(loaded_best))
 print(expected())
 print(repr(loaded_best))
def training_demo():
 best = Population.train(Config, xor_fitness)
 best.network.dump('best')
 loaded_best = FeedForwardNetwork.load('best')
 print(loaded_best)
 print(run_network(loaded_best))
 print(expected())
 print(repr(loaded_best))
if __name__ == '__main__':
 training_demo()
answered Apr 3, 2024 at 13:10
\$\endgroup\$
1
  • \$\begingroup\$ Thanks for putting lots of effort into giving an answer that covers everything. Your level is definitely a little above mine :). \$\endgroup\$ Commented Apr 4, 2024 at 0:42
6
\$\begingroup\$

Avoid unnecessary loops/comprehensions

  • Replace this loop:

    # for i in range(len(inputs)):
    # self.inputs[i] = inputs[i]
    

    with an O(1) assignment:

    self.inputs = inputs
    

  • Replace these comprehensions:

    # simplified_neuron_data = [
    # [-(self.num_inputs - i) for i in range(self.num_inputs)],
    # [
    # i + self.num_outputs
    # for i in range(len(self.neuron_data) - self.num_outputs)
    # ],
    # [i for i in range(self.num_outputs)],
    # ]
    

    with list(range) constructs:

    simplified_neuron_data = [
     list(range(-self.num_inputs, 0)),
     list(range(self.num_outputs, len(self.neuron_data))),
     list(range(self.num_outputs)),
    ]
    


Avoid cryptic numeric codes

The mutation codes (0, 1, 2, 3) just look like random numbers unless you find the legend (located in a separate function's docstring). That makes them hard to understand/maintain and thus bug-prone.

  • Replace these numeric constants:

    # changes.append((3, ...))
    # changes.append((2, ...))
    # ...
    # new_ffn = ffn.mutate([(0, ...), (0, ...), (1, ...)])
    # new_new_ffn = new_ffn.mutate([(2, ...), (2, ...), (3, ...)])
    # ...
    # parent = bare.mutate([(0, ...), (0, ...), (1, ...)])
    # xor_parent = bare2.mutate([(0, ...), (0, ...), (1, ...), (1, ...)])
    

    with human-readable constants, e.g. as an Enum:

    [Enumerations] are useful for defining an immutable, related set of constant values that may or may not have a semantic meaning.

    from enum import Enum
    class Mutation(Enum):
     ADD_NEURON = 0
     ADD_CONNECTION = 1
     CHANGE_WEIGHT = 2
     CHANGE_BIAS = 3
    changes.append((Mutation.CHANGE_BIAS, ...))
    changes.append((Mutation.CHANGE_WEIGHT, ...))
    ...
    new_ffn = ffn.mutate([(Mutation.ADD_NEURON, ...), ...])
    new_new_ffn = new_ffn.mutate([(Mutation.CHANGE_WEIGHT, ...), ...])
    ...
    parent = bare.mutate([(Mutation.ADD_NEURON, ...), ...])
    xor_parent = bare2.mutate([(Mutation.ADD_NEURON, ...), ...])
    
answered Mar 26, 2024 at 15:58
\$\endgroup\$
4
  • \$\begingroup\$ I implemented these suggestions thanks, I was hoping someone would look at all of my code. (or did you and everything else was perfect??) \$\endgroup\$ Commented Mar 26, 2024 at 21:30
  • 2
    \$\begingroup\$ @coder I don't really know enough about NEAT to offer deeper insights, so that's why my answer is mainly about base python stuff. I suspect it'll be hard for others as well without prior/related expertise. \$\endgroup\$ Commented Mar 29, 2024 at 19:22
  • 1
    \$\begingroup\$ I mainly work with vectorization (numpy/pandas), but I see that your code requires recursion. You might consider extracting smaller parts for review (e.g. making a standalone version of the recursive FeedForwardNetwork.calculate_neuron() method). That would be easier for people to digest, and you might get someone who's good at recursion even if they don't know NEAT. \$\endgroup\$ Commented Mar 29, 2024 at 19:22
  • \$\begingroup\$ ok, I thought it would be best to post everything in one question as it's not much code. I have posted FeedForwardNetwork by itself (a while ago using an account I now can't access) and have had it optimized (not much, it was pretty good already). I am mostly focused on base python stuff for performance stuff but I do want someone to check and see if the algorithm was alright. \$\endgroup\$ Commented Mar 30, 2024 at 6:58

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.