From 3b64ef62e15877b3dff517f876b0698e9d62c867 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 18 Jun 2025 17:21:11 -0500 Subject: [PATCH] Refactor neural network implementation into separate neural.py file --- world/base/brain.py | 469 +------------------------------------------ world/base/neural.py | 467 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 468 insertions(+), 468 deletions(-) diff --git a/world/base/brain.py b/world/base/brain.py index 686bc5c..d81d978 100644 --- a/world/base/brain.py +++ b/world/base/brain.py @@ -1,475 +1,8 @@ -import numpy as np -import random -from copy import deepcopy - +from world.base.neural import FlexibleNeuralNetwork from config.constants import MAX_VELOCITY, MAX_ROTATIONAL_VELOCITY from world.behavioral import BehavioralModel -class FlexibleNeuralNetwork: - """ - A flexible neural network that can mutate its structure and weights. - Supports variable topology with cross-layer connections. - """ - - def __init__(self, input_size=2, output_size=2, empty_start=True): - self.input_size = input_size - self.output_size = output_size - - # Network structure: list of layers, each layer is a list of neurons - # Each neuron is represented by its connections and bias - self.layers = [] - - # Initialize network based on empty_start parameter - if empty_start: - self._initialize_empty_network() - else: - self._initialize_basic_network() - - self.network_cost = self.calculate_network_cost() - - def _initialize_basic_network(self): - """Initialize a basic network with input->output connections only.""" - # Input layer (no actual neurons, just placeholders) - input_layer = [{'type': 'input', 'id': i} for i in range(self.input_size)] - - # Output layer with connections to all inputs - output_layer = [] - for i in range(self.output_size): - neuron = { - 'type': 'output', - 'id': f'out_{i}', - 'bias': random.uniform(-1, 1), - 'connections': [] # List of (source_layer, source_neuron, weight) - } - - # Connect to all input neurons - for j in range(self.input_size): - neuron['connections'].append((0, j, random.uniform(-2, 2))) - - output_layer.append(neuron) - - self.layers = [input_layer, output_layer] - - def _initialize_empty_network(self): - """Initialize an empty network with no connections or biases.""" - # Input layer (no actual neurons, just placeholders) - input_layer = [{'type': 'input', 'id': i} for i in range(self.input_size)] - - # Output layer with no connections and zero bias - output_layer = [] - for i in range(self.output_size): - neuron = { - 'type': 'output', - 'id': f'out_{i}', - 'bias': 0.0, - 'connections': [] # Empty connections list - } - output_layer.append(neuron) - - self.layers = [input_layer, output_layer] - - def _remove_duplicate_connections(self): - """Remove duplicate connections and keep only the last weight for each unique connection.""" - for layer in self.layers[1:]: # Skip input layer - for neuron in layer: - if 'connections' not in neuron: - continue - - # Use a dictionary to track unique connections by (source_layer, source_neuron) - unique_connections = {} - - for source_layer, source_neuron, weight in neuron['connections']: - connection_key = (source_layer, source_neuron) - # Keep the last weight encountered for this connection - unique_connections[connection_key] = weight - - # Rebuild connections list without duplicates - neuron['connections'] = [ - (source_layer, source_neuron, weight) - for (source_layer, source_neuron), weight in unique_connections.items() - ] - - def _connection_exists(self, target_neuron, source_layer_idx, source_neuron_idx): - """Check if a connection already exists between two neurons.""" - if 'connections' not in target_neuron: - return False - - for source_layer, source_neuron, weight in target_neuron['connections']: - if source_layer == source_layer_idx and source_neuron == source_neuron_idx: - return True - return False - - def forward(self, inputs): - """ - Forward pass through the network. - - :param inputs: List or array of input values - :return: List of output values - """ - if len(inputs) != self.input_size: - raise ValueError(f"Expected {self.input_size} inputs, got {len(inputs)}") - - # Store activations for each layer - activations = [inputs] # Input layer activations - - # Process each subsequent layer - for layer_idx in range(1, len(self.layers)): - layer_activations = [] - - for neuron in self.layers[layer_idx]: - if neuron['type'] == 'input': - continue # Skip input neurons in hidden layers - - # Calculate weighted sum of inputs - weighted_sum = 0.0 # Start with 0 instead of bias - - # Only add bias if neuron has connections - if 'connections' in neuron and len(neuron['connections']) > 0: - weighted_sum = neuron['bias'] - - for source_layer, source_neuron, weight in neuron['connections']: - if source_layer < len(activations): - if source_neuron < len(activations[source_layer]): - weighted_sum += activations[source_layer][source_neuron] * weight - - # Apply activation function (tanh for bounded output) - # If no connections and no bias applied, this will be tanh(0) = 0 - activation = np.tanh(weighted_sum) - layer_activations.append(activation) - - activations.append(layer_activations) - - return activations[-1] # Return output layer activations - - def mutate(self, mutation_rate=0.1): - """ - Create a mutated copy of this network. - - :param mutation_rate: Base probability multiplied by specific mutation weights - :return: New mutated FlexibleNeuralNetwork instance - """ - mutated = deepcopy(self) - - # Weighted mutations (probability = mutation_rate * weight) - # Higher weights = more likely to occur - mutations = [ - (mutated._mutate_weights, 5.0), # Most common - fine-tune existing - (mutated._mutate_biases, 3.0), # Common - adjust neuron thresholds - (mutated._add_connection, 1.5), # Moderate - grow connectivity - (mutated._remove_connection, 0.8), # Less common - reduce connectivity - (mutated._add_neuron, 0.3), # Rare - structural growth - (mutated._remove_neuron, 0.1), # Very rare - structural reduction - (mutated._add_layer, 0.05), # New: create a new layer (very rare) - ] - - # Apply weighted random mutations - for mutation_func, weight in mutations: - if random.random() < (mutation_rate * weight): - mutation_func() - - # Clean up any duplicate connections that might have been created - mutated._remove_duplicate_connections() - - # Ensure the network maintains basic connectivity - mutated._ensure_network_connectivity() - - mutated.network_cost = mutated.calculate_network_cost() - - return mutated - - def _mutate_weights(self): - """Slightly modify existing connection weights.""" - for layer in self.layers[1:]: # Skip input layer - for neuron in layer: - if 'connections' in neuron: - for i in range(len(neuron['connections'])): - if random.random() < 0.3: # 30% chance to mutate each weight - source_layer, source_neuron, weight = neuron['connections'][i] - # Add small random change - new_weight = weight + random.uniform(-0.5, 0.5) - neuron['connections'][i] = (source_layer, source_neuron, new_weight) - - def _mutate_biases(self): - """Slightly modify neuron biases.""" - for layer in self.layers[1:]: # Skip input layer - for neuron in layer: - if 'bias' in neuron and random.random() < 0.3: - neuron['bias'] += random.uniform(-0.5, 0.5) - - def _add_connection(self): - """Add a new random connection.""" - if len(self.layers) < 2: - return - - # Find layers with neurons - valid_target_layers = [] - for i in range(1, len(self.layers)): - if len(self.layers[i]) > 0: - valid_target_layers.append(i) - - if not valid_target_layers: - return - - # Pick a random target neuron (not in input layer) - target_layer_idx = random.choice(valid_target_layers) - target_neuron_idx = random.randint(0, len(self.layers[target_layer_idx]) - 1) - target_neuron = self.layers[target_layer_idx][target_neuron_idx] - - if 'connections' not in target_neuron: - return - - # Find valid source layers (must have neurons and be before target) - valid_source_layers = [] - for i in range(target_layer_idx): - if len(self.layers[i]) > 0: - valid_source_layers.append(i) - - if not valid_source_layers: - return - - # Pick a random source (from any previous layer with neurons) - source_layer_idx = random.choice(valid_source_layers) - source_neuron_idx = random.randint(0, len(self.layers[source_layer_idx]) - 1) - - # Check if connection already exists using the helper method - if self._connection_exists(target_neuron, source_layer_idx, source_neuron_idx): - return # Connection already exists, don't add duplicate - - # Add new connection - new_weight = random.uniform(-2, 2) - target_neuron['connections'].append((source_layer_idx, source_neuron_idx, new_weight)) - - def _remove_connection(self): - """Remove a random connection.""" - for layer in self.layers[1:]: - for neuron in layer: - if 'connections' in neuron and len(neuron['connections']) > 1: - if random.random() < 0.1: # 10% chance to remove a connection - neuron['connections'].pop(random.randint(0, len(neuron['connections']) - 1)) - - def _add_neuron(self): - """Add a new neuron to a random hidden layer or create a new hidden layer.""" - if len(self.layers) == 2: # Only input and output layers - # Create a new hidden layer - hidden_neuron = { - 'type': 'hidden', - 'id': f'hidden_{random.randint(1000, 9999)}', - 'bias': random.uniform(-1, 1), - 'connections': [] - } - - # Connect to some input neurons (avoid duplicates) - for i in range(self.input_size): - if random.random() < 0.7: # 70% chance to connect to each input - if not self._connection_exists(hidden_neuron, 0, i): - hidden_neuron['connections'].append((0, i, random.uniform(-2, 2))) - - # Insert hidden layer - self.layers.insert(1, [hidden_neuron]) - - # Update output layer connections to potentially use new hidden neuron - for neuron in self.layers[-1]: # Output layer (now at index 2) - if random.random() < 0.5: # 50% chance to connect to new hidden neuron - if not self._connection_exists(neuron, 1, 0): - neuron['connections'].append((1, 0, random.uniform(-2, 2))) - - else: - # Add neuron to existing hidden layer - # Find hidden layers that exist - hidden_layer_indices = [] - for i in range(1, len(self.layers) - 1): - if i < len(self.layers): # Safety check - hidden_layer_indices.append(i) - - if not hidden_layer_indices: - return - - hidden_layer_idx = random.choice(hidden_layer_indices) - new_neuron = { - 'type': 'hidden', - 'id': f'hidden_{random.randint(1000, 9999)}', - 'bias': random.uniform(-1, 1), - 'connections': [] - } - - # Connect to some neurons from previous layers (avoid duplicates) - for layer_idx in range(hidden_layer_idx): - if len(self.layers[layer_idx]) > 0: # Only if layer has neurons - for neuron_idx in range(len(self.layers[layer_idx])): - if random.random() < 0.3: # 30% chance to connect - if not self._connection_exists(new_neuron, layer_idx, neuron_idx): - new_neuron['connections'].append((layer_idx, neuron_idx, random.uniform(-2, 2))) - - self.layers[hidden_layer_idx].append(new_neuron) - - # Update connections from later layers to potentially connect to this new neuron - new_neuron_idx = len(self.layers[hidden_layer_idx]) - 1 - for later_layer_idx in range(hidden_layer_idx + 1, len(self.layers)): - if len(self.layers[later_layer_idx]) > 0: # Only if layer has neurons - for neuron in self.layers[later_layer_idx]: - if random.random() < 0.2: # 20% chance to connect to new neuron - if not self._connection_exists(neuron, hidden_layer_idx, new_neuron_idx): - neuron['connections'].append((hidden_layer_idx, new_neuron_idx, random.uniform(-2, 2))) - - def _remove_neuron(self): - """Remove a random neuron from hidden layers.""" - if len(self.layers) <= 2: # No hidden layers - return - - # Find hidden layers that have neurons - valid_hidden_layers = [] - for layer_idx in range(1, len(self.layers) - 1): # Only hidden layers - if len(self.layers[layer_idx]) > 0: - valid_hidden_layers.append(layer_idx) - - if not valid_hidden_layers: - return - - # Pick a random hidden layer with neurons - layer_idx = random.choice(valid_hidden_layers) - neuron_idx = random.randint(0, len(self.layers[layer_idx]) - 1) - - # Remove the neuron - self.layers[layer_idx].pop(neuron_idx) - - # Remove connections to this neuron from later layers - for later_layer_idx in range(layer_idx + 1, len(self.layers)): - for neuron in self.layers[later_layer_idx]: - if 'connections' in neuron: - neuron['connections'] = [ - (src_layer, src_neuron, weight) - for src_layer, src_neuron, weight in neuron['connections'] - if not (src_layer == layer_idx and src_neuron == neuron_idx) - ] - - # Adjust neuron indices for remaining neurons in the same layer - for later_layer_idx in range(layer_idx + 1, len(self.layers)): - for neuron in self.layers[later_layer_idx]: - if 'connections' in neuron: - adjusted_connections = [] - for src_layer, src_neuron, weight in neuron['connections']: - if src_layer == layer_idx and src_neuron > neuron_idx: - # Adjust index down by 1 since we removed a neuron - adjusted_connections.append((src_layer, src_neuron - 1, weight)) - else: - adjusted_connections.append((src_layer, src_neuron, weight)) - neuron['connections'] = adjusted_connections - - # Remove empty hidden layers to keep network clean - if len(self.layers[layer_idx]) == 0: - self.layers.pop(layer_idx) - - # Adjust all layer indices in connections that reference layers after the removed one - for layer in self.layers: - for neuron in layer: - if 'connections' in neuron: - adjusted_connections = [] - for src_layer, src_neuron, weight in neuron['connections']: - if src_layer > layer_idx: - adjusted_connections.append((src_layer - 1, src_neuron, weight)) - else: - adjusted_connections.append((src_layer, src_neuron, weight)) - neuron['connections'] = adjusted_connections - - def _add_layer(self): - """Add a new hidden layer at a random position with at least one neuron.""" - if len(self.layers) < 2: - return # Need at least input and output layers - - # Choose a position between input and output layers - insert_idx = random.randint(1, len(self.layers) - 1) - # Create a new hidden neuron - new_neuron = { - 'type': 'hidden', - 'id': f'hidden_{random.randint(1000, 9999)}', - 'bias': random.uniform(-1, 1), - 'connections': [] - } - # Connect to all neurons in the previous layer - for prev_idx in range(len(self.layers[insert_idx - 1])): - if random.random() < 0.5: - new_neuron['connections'].append((insert_idx - 1, prev_idx, random.uniform(-2, 2))) - # Insert the new layer - self.layers.insert(insert_idx, [new_neuron]) - # Connect neurons in the next layer to the new neuron - if insert_idx + 1 < len(self.layers): - for neuron in self.layers[insert_idx + 1]: - if 'connections' in neuron and random.random() < 0.5: - neuron['connections'].append((insert_idx, 0, random.uniform(-2, 2))) - - def _ensure_network_connectivity(self): - """Ensure the network maintains basic connectivity from inputs to outputs.""" - # Check if output neurons have any connections - output_layer = self.layers[-1] - - for i, output_neuron in enumerate(output_layer): - if 'connections' not in output_neuron or len(output_neuron['connections']) == 0: - # Output neuron has no connections - reconnect to input layer - for j in range(self.input_size): - if not self._connection_exists(output_neuron, 0, j): - output_neuron['connections'].append((0, j, random.uniform(-2, 2))) - break # Add at least one connection - - # Ensure at least one path exists from input to output - if len(self.layers) > 2: # Has hidden layers - # Check if any hidden neurons are connected to inputs - has_input_connection = False - for layer_idx in range(1, len(self.layers) - 1): # Hidden layers - for neuron in self.layers[layer_idx]: - if 'connections' in neuron: - for src_layer, src_neuron, weight in neuron['connections']: - if src_layer == 0: # Connected to input - has_input_connection = True - break - if has_input_connection: - break - if has_input_connection: - break - - # If no hidden neuron connects to input, create one - if not has_input_connection and len(self.layers) > 2: - first_hidden_layer = self.layers[1] - if len(first_hidden_layer) > 0: - first_neuron = first_hidden_layer[0] - if 'connections' in first_neuron: - # Add connection to first input - if not self._connection_exists(first_neuron, 0, 0): - first_neuron['connections'].append((0, 0, random.uniform(-2, 2))) - - def get_structure_info(self): - """Return information about the network structure.""" - info = { - 'total_layers': len(self.layers), - 'layer_sizes': [len(layer) for layer in self.layers], - 'total_connections': 0, - 'total_neurons': sum(len(layer) for layer in self.layers), - 'network_cost': self.network_cost - } - - for layer in self.layers[1:]: - for neuron in layer: - if 'connections' in neuron: - info['total_connections'] += len(neuron['connections']) - - return info - - def calculate_network_cost(self): - """ - Estimate the computational cost of the network. - Cost is defined as the total number of connections plus the number of neurons - (i.e., total multiply-accumulate operations and activations per forward pass). - """ - total_connections = 0 - total_neurons = 0 - for layer in self.layers[1:]: # Skip input layer (no computation) - for neuron in layer: - total_neurons += 1 - if 'connections' in neuron: - total_connections += len(neuron['connections']) - return total_connections + total_neurons - - class CellBrain(BehavioralModel): """ Enhanced CellBrain using a flexible neural network with input normalization. diff --git a/world/base/neural.py b/world/base/neural.py index e69de29..5de03cc 100644 --- a/world/base/neural.py +++ b/world/base/neural.py @@ -0,0 +1,467 @@ +import numpy as np +import random +from copy import deepcopy + + +class FlexibleNeuralNetwork: + """ + A flexible neural network that can mutate its structure and weights. + Supports variable topology with cross-layer connections. + """ + + def __init__(self, input_size=2, output_size=2, empty_start=True): + self.input_size = input_size + self.output_size = output_size + + # Network structure: list of layers, each layer is a list of neurons + # Each neuron is represented by its connections and bias + self.layers = [] + + # Initialize network based on empty_start parameter + if empty_start: + self._initialize_empty_network() + else: + self._initialize_basic_network() + + self.network_cost = self.calculate_network_cost() + + def _initialize_basic_network(self): + """Initialize a basic network with input->output connections only.""" + # Input layer (no actual neurons, just placeholders) + input_layer = [{'type': 'input', 'id': i} for i in range(self.input_size)] + + # Output layer with connections to all inputs + output_layer = [] + for i in range(self.output_size): + neuron = { + 'type': 'output', + 'id': f'out_{i}', + 'bias': random.uniform(-1, 1), + 'connections': [] # List of (source_layer, source_neuron, weight) + } + + # Connect to all input neurons + for j in range(self.input_size): + neuron['connections'].append((0, j, random.uniform(-2, 2))) + + output_layer.append(neuron) + + self.layers = [input_layer, output_layer] + + def _initialize_empty_network(self): + """Initialize an empty network with no connections or biases.""" + # Input layer (no actual neurons, just placeholders) + input_layer = [{'type': 'input', 'id': i} for i in range(self.input_size)] + + # Output layer with no connections and zero bias + output_layer = [] + for i in range(self.output_size): + neuron = { + 'type': 'output', + 'id': f'out_{i}', + 'bias': 0.0, + 'connections': [] # Empty connections list + } + output_layer.append(neuron) + + self.layers = [input_layer, output_layer] + + def _remove_duplicate_connections(self): + """Remove duplicate connections and keep only the last weight for each unique connection.""" + for layer in self.layers[1:]: # Skip input layer + for neuron in layer: + if 'connections' not in neuron: + continue + + # Use a dictionary to track unique connections by (source_layer, source_neuron) + unique_connections = {} + + for source_layer, source_neuron, weight in neuron['connections']: + connection_key = (source_layer, source_neuron) + # Keep the last weight encountered for this connection + unique_connections[connection_key] = weight + + # Rebuild connections list without duplicates + neuron['connections'] = [ + (source_layer, source_neuron, weight) + for (source_layer, source_neuron), weight in unique_connections.items() + ] + + def _connection_exists(self, target_neuron, source_layer_idx, source_neuron_idx): + """Check if a connection already exists between two neurons.""" + if 'connections' not in target_neuron: + return False + + for source_layer, source_neuron, weight in target_neuron['connections']: + if source_layer == source_layer_idx and source_neuron == source_neuron_idx: + return True + return False + + def forward(self, inputs): + """ + Forward pass through the network. + + :param inputs: List or array of input values + :return: List of output values + """ + if len(inputs) != self.input_size: + raise ValueError(f"Expected {self.input_size} inputs, got {len(inputs)}") + + # Store activations for each layer + activations = [inputs] # Input layer activations + + # Process each subsequent layer + for layer_idx in range(1, len(self.layers)): + layer_activations = [] + + for neuron in self.layers[layer_idx]: + if neuron['type'] == 'input': + continue # Skip input neurons in hidden layers + + # Calculate weighted sum of inputs + weighted_sum = 0.0 # Start with 0 instead of bias + + # Only add bias if neuron has connections + if 'connections' in neuron and len(neuron['connections']) > 0: + weighted_sum = neuron['bias'] + + for source_layer, source_neuron, weight in neuron['connections']: + if source_layer < len(activations): + if source_neuron < len(activations[source_layer]): + weighted_sum += activations[source_layer][source_neuron] * weight + + # Apply activation function (tanh for bounded output) + # If no connections and no bias applied, this will be tanh(0) = 0 + activation = np.tanh(weighted_sum) + layer_activations.append(activation) + + activations.append(layer_activations) + + return activations[-1] # Return output layer activations + + def mutate(self, mutation_rate=0.1): + """ + Create a mutated copy of this network. + + :param mutation_rate: Base probability multiplied by specific mutation weights + :return: New mutated FlexibleNeuralNetwork instance + """ + mutated = deepcopy(self) + + # Weighted mutations (probability = mutation_rate * weight) + # Higher weights = more likely to occur + mutations = [ + (mutated._mutate_weights, 5.0), # Most common - fine-tune existing + (mutated._mutate_biases, 3.0), # Common - adjust neuron thresholds + (mutated._add_connection, 1.5), # Moderate - grow connectivity + (mutated._remove_connection, 0.8), # Less common - reduce connectivity + (mutated._add_neuron, 0.3), # Rare - structural growth + (mutated._remove_neuron, 0.1), # Very rare - structural reduction + (mutated._add_layer, 0.05), # New: create a new layer (very rare) + ] + + # Apply weighted random mutations + for mutation_func, weight in mutations: + if random.random() < (mutation_rate * weight): + mutation_func() + + # Clean up any duplicate connections that might have been created + mutated._remove_duplicate_connections() + + # Ensure the network maintains basic connectivity + mutated._ensure_network_connectivity() + + mutated.network_cost = mutated.calculate_network_cost() + + return mutated + + def _mutate_weights(self): + """Slightly modify existing connection weights.""" + for layer in self.layers[1:]: # Skip input layer + for neuron in layer: + if 'connections' in neuron: + for i in range(len(neuron['connections'])): + if random.random() < 0.3: # 30% chance to mutate each weight + source_layer, source_neuron, weight = neuron['connections'][i] + # Add small random change + new_weight = weight + random.uniform(-0.5, 0.5) + neuron['connections'][i] = (source_layer, source_neuron, new_weight) + + def _mutate_biases(self): + """Slightly modify neuron biases.""" + for layer in self.layers[1:]: # Skip input layer + for neuron in layer: + if 'bias' in neuron and random.random() < 0.3: + neuron['bias'] += random.uniform(-0.5, 0.5) + + def _add_connection(self): + """Add a new random connection.""" + if len(self.layers) < 2: + return + + # Find layers with neurons + valid_target_layers = [] + for i in range(1, len(self.layers)): + if len(self.layers[i]) > 0: + valid_target_layers.append(i) + + if not valid_target_layers: + return + + # Pick a random target neuron (not in input layer) + target_layer_idx = random.choice(valid_target_layers) + target_neuron_idx = random.randint(0, len(self.layers[target_layer_idx]) - 1) + target_neuron = self.layers[target_layer_idx][target_neuron_idx] + + if 'connections' not in target_neuron: + return + + # Find valid source layers (must have neurons and be before target) + valid_source_layers = [] + for i in range(target_layer_idx): + if len(self.layers[i]) > 0: + valid_source_layers.append(i) + + if not valid_source_layers: + return + + # Pick a random source (from any previous layer with neurons) + source_layer_idx = random.choice(valid_source_layers) + source_neuron_idx = random.randint(0, len(self.layers[source_layer_idx]) - 1) + + # Check if connection already exists using the helper method + if self._connection_exists(target_neuron, source_layer_idx, source_neuron_idx): + return # Connection already exists, don't add duplicate + + # Add new connection + new_weight = random.uniform(-2, 2) + target_neuron['connections'].append((source_layer_idx, source_neuron_idx, new_weight)) + + def _remove_connection(self): + """Remove a random connection.""" + for layer in self.layers[1:]: + for neuron in layer: + if 'connections' in neuron and len(neuron['connections']) > 1: + if random.random() < 0.1: # 10% chance to remove a connection + neuron['connections'].pop(random.randint(0, len(neuron['connections']) - 1)) + + def _add_neuron(self): + """Add a new neuron to a random hidden layer or create a new hidden layer.""" + if len(self.layers) == 2: # Only input and output layers + # Create a new hidden layer + hidden_neuron = { + 'type': 'hidden', + 'id': f'hidden_{random.randint(1000, 9999)}', + 'bias': random.uniform(-1, 1), + 'connections': [] + } + + # Connect to some input neurons (avoid duplicates) + for i in range(self.input_size): + if random.random() < 0.7: # 70% chance to connect to each input + if not self._connection_exists(hidden_neuron, 0, i): + hidden_neuron['connections'].append((0, i, random.uniform(-2, 2))) + + # Insert hidden layer + self.layers.insert(1, [hidden_neuron]) + + # Update output layer connections to potentially use new hidden neuron + for neuron in self.layers[-1]: # Output layer (now at index 2) + if random.random() < 0.5: # 50% chance to connect to new hidden neuron + if not self._connection_exists(neuron, 1, 0): + neuron['connections'].append((1, 0, random.uniform(-2, 2))) + + else: + # Add neuron to existing hidden layer + # Find hidden layers that exist + hidden_layer_indices = [] + for i in range(1, len(self.layers) - 1): + if i < len(self.layers): # Safety check + hidden_layer_indices.append(i) + + if not hidden_layer_indices: + return + + hidden_layer_idx = random.choice(hidden_layer_indices) + new_neuron = { + 'type': 'hidden', + 'id': f'hidden_{random.randint(1000, 9999)}', + 'bias': random.uniform(-1, 1), + 'connections': [] + } + + # Connect to some neurons from previous layers (avoid duplicates) + for layer_idx in range(hidden_layer_idx): + if len(self.layers[layer_idx]) > 0: # Only if layer has neurons + for neuron_idx in range(len(self.layers[layer_idx])): + if random.random() < 0.3: # 30% chance to connect + if not self._connection_exists(new_neuron, layer_idx, neuron_idx): + new_neuron['connections'].append((layer_idx, neuron_idx, random.uniform(-2, 2))) + + self.layers[hidden_layer_idx].append(new_neuron) + + # Update connections from later layers to potentially connect to this new neuron + new_neuron_idx = len(self.layers[hidden_layer_idx]) - 1 + for later_layer_idx in range(hidden_layer_idx + 1, len(self.layers)): + if len(self.layers[later_layer_idx]) > 0: # Only if layer has neurons + for neuron in self.layers[later_layer_idx]: + if random.random() < 0.2: # 20% chance to connect to new neuron + if not self._connection_exists(neuron, hidden_layer_idx, new_neuron_idx): + neuron['connections'].append((hidden_layer_idx, new_neuron_idx, random.uniform(-2, 2))) + + def _remove_neuron(self): + """Remove a random neuron from hidden layers.""" + if len(self.layers) <= 2: # No hidden layers + return + + # Find hidden layers that have neurons + valid_hidden_layers = [] + for layer_idx in range(1, len(self.layers) - 1): # Only hidden layers + if len(self.layers[layer_idx]) > 0: + valid_hidden_layers.append(layer_idx) + + if not valid_hidden_layers: + return + + # Pick a random hidden layer with neurons + layer_idx = random.choice(valid_hidden_layers) + neuron_idx = random.randint(0, len(self.layers[layer_idx]) - 1) + + # Remove the neuron + self.layers[layer_idx].pop(neuron_idx) + + # Remove connections to this neuron from later layers + for later_layer_idx in range(layer_idx + 1, len(self.layers)): + for neuron in self.layers[later_layer_idx]: + if 'connections' in neuron: + neuron['connections'] = [ + (src_layer, src_neuron, weight) + for src_layer, src_neuron, weight in neuron['connections'] + if not (src_layer == layer_idx and src_neuron == neuron_idx) + ] + + # Adjust neuron indices for remaining neurons in the same layer + for later_layer_idx in range(layer_idx + 1, len(self.layers)): + for neuron in self.layers[later_layer_idx]: + if 'connections' in neuron: + adjusted_connections = [] + for src_layer, src_neuron, weight in neuron['connections']: + if src_layer == layer_idx and src_neuron > neuron_idx: + # Adjust index down by 1 since we removed a neuron + adjusted_connections.append((src_layer, src_neuron - 1, weight)) + else: + adjusted_connections.append((src_layer, src_neuron, weight)) + neuron['connections'] = adjusted_connections + + # Remove empty hidden layers to keep network clean + if len(self.layers[layer_idx]) == 0: + self.layers.pop(layer_idx) + + # Adjust all layer indices in connections that reference layers after the removed one + for layer in self.layers: + for neuron in layer: + if 'connections' in neuron: + adjusted_connections = [] + for src_layer, src_neuron, weight in neuron['connections']: + if src_layer > layer_idx: + adjusted_connections.append((src_layer - 1, src_neuron, weight)) + else: + adjusted_connections.append((src_layer, src_neuron, weight)) + neuron['connections'] = adjusted_connections + + def _add_layer(self): + """Add a new hidden layer at a random position with at least one neuron.""" + if len(self.layers) < 2: + return # Need at least input and output layers + + # Choose a position between input and output layers + insert_idx = random.randint(1, len(self.layers) - 1) + # Create a new hidden neuron + new_neuron = { + 'type': 'hidden', + 'id': f'hidden_{random.randint(1000, 9999)}', + 'bias': random.uniform(-1, 1), + 'connections': [] + } + # Connect to all neurons in the previous layer + for prev_idx in range(len(self.layers[insert_idx - 1])): + if random.random() < 0.5: + new_neuron['connections'].append((insert_idx - 1, prev_idx, random.uniform(-2, 2))) + # Insert the new layer + self.layers.insert(insert_idx, [new_neuron]) + # Connect neurons in the next layer to the new neuron + if insert_idx + 1 < len(self.layers): + for neuron in self.layers[insert_idx + 1]: + if 'connections' in neuron and random.random() < 0.5: + neuron['connections'].append((insert_idx, 0, random.uniform(-2, 2))) + + def _ensure_network_connectivity(self): + """Ensure the network maintains basic connectivity from inputs to outputs.""" + # Check if output neurons have any connections + output_layer = self.layers[-1] + + for i, output_neuron in enumerate(output_layer): + if 'connections' not in output_neuron or len(output_neuron['connections']) == 0: + # Output neuron has no connections - reconnect to input layer + for j in range(self.input_size): + if not self._connection_exists(output_neuron, 0, j): + output_neuron['connections'].append((0, j, random.uniform(-2, 2))) + break # Add at least one connection + + # Ensure at least one path exists from input to output + if len(self.layers) > 2: # Has hidden layers + # Check if any hidden neurons are connected to inputs + has_input_connection = False + for layer_idx in range(1, len(self.layers) - 1): # Hidden layers + for neuron in self.layers[layer_idx]: + if 'connections' in neuron: + for src_layer, src_neuron, weight in neuron['connections']: + if src_layer == 0: # Connected to input + has_input_connection = True + break + if has_input_connection: + break + if has_input_connection: + break + + # If no hidden neuron connects to input, create one + if not has_input_connection and len(self.layers) > 2: + first_hidden_layer = self.layers[1] + if len(first_hidden_layer) > 0: + first_neuron = first_hidden_layer[0] + if 'connections' in first_neuron: + # Add connection to first input + if not self._connection_exists(first_neuron, 0, 0): + first_neuron['connections'].append((0, 0, random.uniform(-2, 2))) + + def get_structure_info(self): + """Return information about the network structure.""" + info = { + 'total_layers': len(self.layers), + 'layer_sizes': [len(layer) for layer in self.layers], + 'total_connections': 0, + 'total_neurons': sum(len(layer) for layer in self.layers), + 'network_cost': self.network_cost + } + + for layer in self.layers[1:]: + for neuron in layer: + if 'connections' in neuron: + info['total_connections'] += len(neuron['connections']) + + return info + + def calculate_network_cost(self): + """ + Estimate the computational cost of the network. + Cost is defined as the total number of connections plus the number of neurons + (i.e., total multiply-accumulate operations and activations per forward pass). + """ + total_connections = 0 + total_neurons = 0 + for layer in self.layers[1:]: # Skip input layer (no computation) + for neuron in layer: + total_neurons += 1 + if 'connections' in neuron: + total_connections += len(neuron['connections']) + return total_connections + total_neurons \ No newline at end of file