from __future__ import annotations from dataclasses import dataclass from enum import Enum from random import choice import numpy as np from graphs import creates_cycle rng = np.random.default_rng() class NodeType(Enum): INPUT = 1 HIDDEN = 2 OUTPUT = 3 class MutationType(Enum): ADD_CONNECTION = 1 ADD_NODE = 2 @dataclass(frozen=True) class NodeGene: id: int type: NodeType @dataclass() class ConnectionGene: nodes: tuple[int, int] weight: float innovation_no: int disabled: bool = False _CONNECTION_GENES: dict[tuple[int, int], ConnectionGene] = dict() class Genome: def __init__(self): # Initialize nodes self.nodes: dict[int, NodeGene] = dict() # Initialize connections self.connections: dict[tuple[int, int], ConnectionGene] = dict() def add_node(self, node_type: NodeType = NodeType.HIDDEN) -> int: """ Adds a node of the given type to the genome and returns the identification key. """ key = len(self.nodes) self.nodes[key] = NodeGene(key, node_type) return key def add_connection(self, from_node: int, to_node: int, weight: float) -> tuple[int, int]: """ Adds a connection of weight between two given nodes to the genome and returns the identification key. """ key = (from_node, to_node) connection = ConnectionGene(key, weight, -1) if key in _CONNECTION_GENES: connection.innovation_no = _CONNECTION_GENES[key].innovation_no else: connection.innovation_no = len(_CONNECTION_GENES) _CONNECTION_GENES[key] = connection self.connections[key] = connection return key @staticmethod def new(inputs: int, outputs: int) -> Genome: genome = Genome() # Add input nodes for _ in range(inputs): genome.add_node(node_type=NodeType.INPUT) # Add output nodes for _ in range(outputs): genome.add_node(node_type=NodeType.OUTPUT) # Fully connect for i in range(inputs): for o in range(inputs, inputs + outputs): genome.add_connection(i, o, weight=1) return genome def mutate(genome: Genome) -> None: mutation = choice([MutationType.ADD_NODE, MutationType.ADD_CONNECTION]) print(mutation) if mutation is MutationType.ADD_CONNECTION: _mutate_add_connection(genome) elif mutation is MutationType.ADD_NODE: _mutate_add_node(genome) def _mutate_add_connection(genome: Genome) -> None: """ In the add_connection mutation, a single new connection gene with a random weight is added connecting two previously unconnected nodes. """ from_node = choice([node for node in genome.nodes.values() if not node.type != NodeType.OUTPUT]) to_node = choice([node for node in genome.nodes.values() if node.type != NodeType.INPUT]) # Checking if connection already exists if (from_node, to_node) in genome.connections: return # Checking for cycles if creates_cycle(genome.connections.keys(), (from_node.id, to_node.id)): return genome.add_connection(from_node, to_node, weight=rng.uniform(0,1)) def _mutate_add_node(genome: Genome) -> None: """ In the add_node mutation, an existing connection is split and the new node placed where the old connection used to be. The old connection is disabled and two new conections are added to the genome. The new connection leading into the new node receives a weight of 1, and the new connection leading out receives the same weight as the old connection. """ # Find connection to split try: connection = choice([node for node in genome.connections.values() if not node.disabled]) except IndexError: return connection.disabled = True # Create new node new_node = genome.add_node() from_node, to_node = connection.nodes # Connect previous from_node to new_node genome.add_connection(from_node, new_node, weight=1) # Connection new_node to previous to_node genome.add_connection(new_node, to_node, weight=connection.weight)