Some checks failed
		
		
	
	Build Simulation and Test / Run All Tests (push) Failing after 2m35s
				
			
		
			
				
	
	
		
			467 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			467 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| 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 |