4
\$\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

\$\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\$
    – J_H
    Mar 26 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\$
    – coder
    Mar 26 at 21:31

2 Answers 2

2
+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()
\$\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\$
    – coder
    Apr 4 at 0:42
5
\$\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, ...), ...])
    
\$\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\$
    – coder
    Mar 26 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
    Mar 29 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
    Mar 29 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
    Mar 30 at 6:58

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.