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
2 Answers 2
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()
-
\$\begingroup\$ Thanks for putting lots of effort into giving an answer that covers everything. Your level is definitely a little above mine :). \$\endgroup\$coder– coder2024年04月04日 00:42:25 +00:00Commented Apr 4, 2024 at 0:42
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, ...), ...])
-
\$\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\$coder– coder2024年03月26日 21:30:12 +00:00Commented 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\$tdy– tdy2024年03月29日 19:22:05 +00:00Commented 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\$tdy– tdy2024年03月29日 19:22:46 +00:00Commented 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\$coder– coder2024年03月30日 06:58:31 +00:00Commented Mar 30, 2024 at 6:58
Explore related questions
See similar questions with these tags.
mutate()
describes what indices {0,1,2,3} mean. Consider using a @dataclass instead, which would allow the use of names. \$\endgroup\$