Source code for endgame.models.neuroevolution.neat

from __future__ import annotations

"""
NEAT (NeuroEvolution of Augmenting Topologies) sklearn-compatible classifiers and regressors.

Uses neat-python as the backend. Follows the same sklearn estimator pattern as
endgame's SymbolicClassifier/SymbolicRegressor.
"""

import tempfile
import textwrap

import numpy as np
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin


[docs] class NEATClassifier(BaseEstimator, ClassifierMixin): """ NEAT classifier using neat-python. Evolves neural network topology and weights using the NEAT algorithm. Parameters ---------- population_size : int Number of individuals per generation. n_generations : int Number of evolutionary generations. n_hidden : int Initial number of hidden nodes (0 = minimal topology). activation_default : str Default activation function for new nodes. random_state : int or None Random seed for reproducibility. verbose : int Verbosity level (0 = silent). """ def __init__(self, population_size=150, n_generations=100, n_hidden=0, activation_default='sigmoid', random_state=None, verbose=0): import neat as _neat # noqa: F401 self.population_size = population_size self.n_generations = n_generations self.n_hidden = n_hidden self.activation_default = activation_default self.random_state = random_state self.verbose = verbose def _create_neat_config(self, n_inputs, n_outputs): """Generate a neat-python INI config string.""" config_text = textwrap.dedent(f"""\ [NEAT] fitness_criterion = max fitness_threshold = 1.0 pop_size = {self.population_size} reset_on_extinction = True no_fitness_termination = True [DefaultGenome] # node activation options activation_default = {self.activation_default} activation_mutate_rate = 0.1 activation_options = sigmoid tanh relu # node aggregation options aggregation_default = sum aggregation_mutate_rate = 0.0 aggregation_options = sum # node bias options bias_init_mean = 0.0 bias_init_stdev = 1.0 bias_init_type = gaussian bias_max_value = 30.0 bias_min_value = -30.0 bias_mutate_power = 0.5 bias_mutate_rate = 0.7 bias_replace_rate = 0.1 # genome compatibility options compatibility_disjoint_coefficient = 1.0 compatibility_weight_coefficient = 0.5 # connection add/remove rates conn_add_prob = 0.5 conn_delete_prob = 0.2 # connection enable options enabled_default = True enabled_mutate_rate = 0.01 enabled_rate_to_true_add = 0.0 enabled_rate_to_false_add = 0.0 feed_forward = True initial_connection = full_direct single_structural_mutation = False structural_mutation_surer = default # node add/remove rates node_add_prob = 0.3 node_delete_prob = 0.1 # network parameters num_hidden = {self.n_hidden} num_inputs = {n_inputs} num_outputs = {n_outputs} # node response options response_init_mean = 1.0 response_init_stdev = 0.0 response_init_type = gaussian response_max_value = 30.0 response_min_value = -30.0 response_mutate_power = 0.0 response_mutate_rate = 0.0 response_replace_rate = 0.0 # connection weight options weight_init_mean = 0.0 weight_init_stdev = 1.0 weight_init_type = gaussian weight_max_value = 30 weight_min_value = -30 weight_mutate_power = 0.5 weight_mutate_rate = 0.8 weight_replace_rate = 0.1 [DefaultSpeciesSet] compatibility_threshold = 3.0 [DefaultStagnation] species_fitness_func = max max_stagnation = 20 species_elitism = 2 [DefaultReproduction] elitism = 2 survival_threshold = 0.2 min_species_size = 1 """) return config_text
[docs] def fit(self, X, y): """Fit the NEAT classifier by evolving neural network topology.""" import neat X = np.asarray(X, dtype=np.float64) y = np.asarray(y) self.classes_ = np.unique(y) self.n_classes_ = len(self.classes_) n_inputs = X.shape[1] n_outputs = self.n_classes_ # Write config to temp file config_text = self._create_neat_config(n_inputs, n_outputs) with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as f: f.write(config_text) config_path = f.name try: config = neat.Config( neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, config_path, ) finally: import os os.unlink(config_path) # Set random seed if self.random_state is not None: import random random.seed(self.random_state) np.random.seed(self.random_state) # Store training data for eval callback self._X_train = X self._y_train = y def eval_genomes(genomes, config): for genome_id, genome in genomes: net = neat.nn.FeedForwardNetwork.create(genome, config) correct = 0 for xi, yi in zip(self._X_train, self._y_train): output = net.activate(xi.tolist()) pred = np.argmax(output) if pred == yi: correct += 1 genome.fitness = correct / len(self._y_train) pop = neat.Population(config) if self.verbose > 0: pop.add_reporter(neat.StdOutReporter(True)) pop.add_reporter(neat.StatisticsReporter()) winner = pop.run(eval_genomes, self.n_generations) self.best_genome_ = winner self.config_ = config self.best_net_ = neat.nn.FeedForwardNetwork.create(winner, config) # Cleanup del self._X_train del self._y_train return self
[docs] def predict_proba(self, X): """Predict class probabilities using the best evolved network.""" from scipy.special import softmax X = np.asarray(X, dtype=np.float64) raw_outputs = np.array([self.best_net_.activate(xi.tolist()) for xi in X]) return softmax(raw_outputs, axis=1)
[docs] def predict(self, X): """Predict class labels.""" proba = self.predict_proba(X) return self.classes_[np.argmax(proba, axis=1)]
[docs] class NEATRegressor(BaseEstimator, RegressorMixin): """ NEAT regressor using neat-python. Evolves neural network topology and weights using the NEAT algorithm, optimizing for mean squared error. Targets are normalized internally so that network outputs (near [-1, 1]) can match the target scale. Parameters ---------- population_size : int Number of individuals per generation. n_generations : int Number of evolutionary generations. n_hidden : int Initial number of hidden nodes (0 = minimal topology). activation_default : str Default activation function for new nodes. random_state : int or None Random seed for reproducibility. verbose : int Verbosity level (0 = silent). """ def __init__(self, population_size=150, n_generations=100, n_hidden=0, activation_default='tanh', random_state=None, verbose=0): import neat as _neat # noqa: F401 self.population_size = population_size self.n_generations = n_generations self.n_hidden = n_hidden self.activation_default = activation_default self.random_state = random_state self.verbose = verbose def _create_neat_config(self, n_inputs): """Generate a neat-python INI config string for regression (1 output).""" config_text = textwrap.dedent(f"""\ [NEAT] fitness_criterion = max fitness_threshold = 1e10 pop_size = {self.population_size} reset_on_extinction = True no_fitness_termination = True [DefaultGenome] activation_default = {self.activation_default} activation_mutate_rate = 0.1 activation_options = sigmoid tanh relu aggregation_default = sum aggregation_mutate_rate = 0.0 aggregation_options = sum bias_init_mean = 0.0 bias_init_stdev = 1.0 bias_init_type = gaussian bias_max_value = 30.0 bias_min_value = -30.0 bias_mutate_power = 0.5 bias_mutate_rate = 0.7 bias_replace_rate = 0.1 compatibility_disjoint_coefficient = 1.0 compatibility_weight_coefficient = 0.5 conn_add_prob = 0.5 conn_delete_prob = 0.2 enabled_default = True enabled_mutate_rate = 0.01 enabled_rate_to_true_add = 0.0 enabled_rate_to_false_add = 0.0 feed_forward = True initial_connection = full_direct single_structural_mutation = False structural_mutation_surer = default node_add_prob = 0.3 node_delete_prob = 0.1 num_hidden = {self.n_hidden} num_inputs = {n_inputs} num_outputs = 1 response_init_mean = 1.0 response_init_stdev = 0.0 response_init_type = gaussian response_max_value = 30.0 response_min_value = -30.0 response_mutate_power = 0.0 response_mutate_rate = 0.0 response_replace_rate = 0.0 weight_init_mean = 0.0 weight_init_stdev = 1.0 weight_init_type = gaussian weight_max_value = 30 weight_min_value = -30 weight_mutate_power = 0.5 weight_mutate_rate = 0.8 weight_replace_rate = 0.1 [DefaultSpeciesSet] compatibility_threshold = 3.0 [DefaultStagnation] species_fitness_func = max max_stagnation = 20 species_elitism = 2 [DefaultReproduction] elitism = 2 survival_threshold = 0.2 min_species_size = 1 """) return config_text
[docs] def fit(self, X, y): """Fit the NEAT regressor by evolving neural network topology.""" import neat X = np.asarray(X, dtype=np.float64) y = np.asarray(y, dtype=np.float64) # Normalize targets so network outputs (near [-1, 1]) can match self._y_mean = float(y.mean()) self._y_std = float(y.std()) or 1.0 y_norm = (y - self._y_mean) / self._y_std n_inputs = X.shape[1] self._X_train = X self._y_train = y_norm config_text = self._create_neat_config(n_inputs) with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as f: f.write(config_text) config_path = f.name try: config = neat.Config( neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, config_path, ) finally: import os os.unlink(config_path) if self.random_state is not None: import random random.seed(self.random_state) np.random.seed(self.random_state) def eval_genomes(genomes, config): for genome_id, genome in genomes: net = neat.nn.FeedForwardNetwork.create(genome, config) mse = 0.0 for xi, yi in zip(self._X_train, self._y_train): output = net.activate(xi.tolist()) mse += (output[0] - yi) ** 2 mse /= len(self._y_train) genome.fitness = -mse pop = neat.Population(config) if self.verbose > 0: pop.add_reporter(neat.StdOutReporter(True)) pop.add_reporter(neat.StatisticsReporter()) winner = pop.run(eval_genomes, self.n_generations) self.best_genome_ = winner self.config_ = config self.best_net_ = neat.nn.FeedForwardNetwork.create(winner, config) del self._X_train del self._y_train return self
[docs] def predict(self, X): """Predict continuous values using the best evolved network.""" X = np.asarray(X, dtype=np.float64) raw = np.array([self.best_net_.activate(xi.tolist())[0] for xi in X]) return raw * self._y_std + self._y_mean