Skip to content

Commit 8f1a1d0

Browse files
committed
feature/ga-optimization
1 parent e9e7c96 commit 8f1a1d0

File tree

1 file changed

+238
-0
lines changed

1 file changed

+238
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import random
2+
from collections.abc import Callable, Sequence
3+
from concurrent.futures import ThreadPoolExecutor
4+
5+
import numpy as np
6+
7+
# Parameters
8+
N_POPULATION = 100 # Population size
9+
N_GENERATIONS = 500 # Maximum number of generations
10+
N_SELECTED = 50 # Number of parents selected for the next generation
11+
MUTATION_PROBABILITY = 0.1 # Mutation probability
12+
CROSSOVER_RATE = 0.8 # Probability of crossover
13+
SEARCH_SPACE = (-10, 10) # Search space for the variables
14+
15+
16+
# Random number generator
17+
rng = np.random.default_rng()
18+
19+
20+
class GeneticAlgorithm:
21+
def __init__(
22+
self,
23+
function: Callable[[float, float], float],
24+
bounds: Sequence[tuple[int | float, int | float]],
25+
population_size: int,
26+
generations: int,
27+
mutation_prob: float,
28+
crossover_rate: float,
29+
maximize: bool = True,
30+
) -> None:
31+
self.function = function # Target function to optimize
32+
self.bounds = bounds # Search space bounds (for each variable)
33+
self.population_size = population_size
34+
self.generations = generations
35+
self.mutation_prob = mutation_prob
36+
self.crossover_rate = crossover_rate
37+
self.maximize = maximize
38+
self.dim = len(bounds) # Dimensionality of the function (number of variables)
39+
40+
# Initialize population
41+
self.population = self.initialize_population()
42+
43+
def initialize_population(self) -> list[np.ndarray]:
44+
"""
45+
Initialize the population with random individuals within the search space.
46+
47+
Returns:
48+
list[np.ndarray]: A list of individuals represented as numpy arrays.
49+
"""
50+
return [
51+
rng.uniform(
52+
low=[self.bounds[j][0] for j in range(self.dim)],
53+
high=[self.bounds[j][1] for j in range(self.dim)],
54+
)
55+
for _ in range(self.population_size)
56+
]
57+
58+
def fitness(self, individual: np.ndarray) -> float:
59+
"""
60+
Calculate the fitness value (function value) for an individual.
61+
"""
62+
value = float(self.function(*individual)) # Ensure fitness is a float
63+
return value if self.maximize else -value # If minimizing, invert the fitness
64+
65+
def select_parents(
66+
self, population_score: list[tuple[np.ndarray, float]]
67+
) -> list[np.ndarray]:
68+
"""
69+
Select top N_SELECTED parents based on fitness.
70+
"""
71+
population_score.sort(key=lambda score_tuple: score_tuple[1], reverse=True)
72+
selected_count = min(N_SELECTED, len(population_score))
73+
return [ind for ind, _ in population_score[:selected_count]]
74+
75+
def crossover(
76+
self, parent1: np.ndarray, parent2: np.ndarray
77+
) -> tuple[np.ndarray, np.ndarray]:
78+
"""
79+
Perform uniform crossover between two parents to generate offspring.
80+
81+
Args:
82+
parent1 (np.ndarray): The first parent.
83+
parent2 (np.ndarray): The second parent.
84+
85+
Returns:
86+
tuple[np.ndarray, np.ndarray]: The two offspring generated by crossover.
87+
88+
Example:
89+
>>> ga = GeneticAlgorithm(
90+
lambda x, y: -(x**2 + y**2),
91+
[(-10, 10), (-10, 10)],
92+
10, 100, 0.1, 0.8, True
93+
)
94+
>>> parent1, parent2 = np.array([1, 2]), np.array([3, 4])
95+
>>> len(ga.crossover(parent1, parent2)) == 2
96+
True
97+
"""
98+
if random.random() < self.crossover_rate:
99+
cross_point = random.randint(1, self.dim - 1)
100+
child1 = np.concatenate((parent1[:cross_point], parent2[cross_point:]))
101+
child2 = np.concatenate((parent2[:cross_point], parent1[cross_point:]))
102+
return child1, child2
103+
return parent1, parent2
104+
105+
def mutate(self, individual: np.ndarray) -> np.ndarray:
106+
"""
107+
Apply mutation to an individual.
108+
109+
Args:
110+
individual (np.ndarray): The individual to mutate.
111+
112+
Returns:
113+
np.ndarray: The mutated individual.
114+
115+
Example:
116+
>>> ga = GeneticAlgorithm(
117+
lambda x, y: -(x**2 + y**2),
118+
[(-10, 10), (-10, 10)],
119+
10, 100, 0.1, 0.8, True
120+
)
121+
>>> ind = np.array([1.0, 2.0])
122+
>>> mutated = ga.mutate(ind)
123+
>>> len(mutated) == 2 # Ensure it still has the correct number of dimensions
124+
True
125+
"""
126+
for i in range(self.dim):
127+
if random.random() < self.mutation_prob:
128+
individual[i] = rng.uniform(self.bounds[i][0], self.bounds[i][1])
129+
return individual
130+
131+
def evaluate_population(self) -> list[tuple[np.ndarray, float]]:
132+
"""
133+
Evaluate the fitness of the entire population in parallel.
134+
135+
Returns:
136+
list[tuple[np.ndarray, float]]: # Trailing whitespace here
137+
The population with their respective fitness values.
138+
139+
Example:
140+
>>> ga = GeneticAlgorithm(
141+
lambda x, y: -(x**2 + y**2),
142+
[(-10, 10), (-10, 10)],
143+
10, 100, 0.1, 0.8, True
144+
)
145+
>>> eval_population = ga.evaluate_population()
146+
>>> len(eval_population) == ga.population_size # Ensure population size
147+
True
148+
>>> all(
149+
isinstance(ind, tuple) and isinstance(ind[1], float)
150+
for ind in eval_population
151+
)
152+
True
153+
"""
154+
with ThreadPoolExecutor() as executor:
155+
return list(
156+
executor.map(
157+
lambda individual: (individual, self.fitness(individual)),
158+
self.population,
159+
)
160+
)
161+
162+
def evolve(self, verbose=True) -> np.ndarray:
163+
"""
164+
Evolve the population over the generations to find the best solution.
165+
166+
Returns:
167+
np.ndarray: The best individual found during the evolution process.
168+
"""
169+
for generation in range(self.generations):
170+
# Evaluate population fitness (multithreaded)
171+
population_score = self.evaluate_population()
172+
173+
# Check the best individual
174+
best_individual = max(
175+
population_score, key=lambda score_tuple: score_tuple[1]
176+
)[0]
177+
best_fitness = self.fitness(best_individual)
178+
179+
# Select parents for next generation
180+
parents = self.select_parents(population_score)
181+
next_generation = []
182+
183+
# Generate offspring using crossover and mutation
184+
for i in range(0, len(parents), 2):
185+
parent1, parent2 = parents[i], parents[(i + 1) % len(parents)]
186+
child1, child2 = self.crossover(parent1, parent2)
187+
next_generation.append(self.mutate(child1))
188+
next_generation.append(self.mutate(child2))
189+
190+
# Ensure population size remains the same
191+
self.population = next_generation[: self.population_size]
192+
193+
if verbose and generation % 10 == 0:
194+
print(f"Generation {generation}: Best Fitness = {best_fitness}")
195+
196+
return best_individual
197+
198+
199+
# Example target function for optimization
200+
def target_function(var_x: float, var_y: float) -> float:
201+
"""
202+
Example target function (parabola) for optimization.
203+
204+
Args:
205+
var_x (float): The x-coordinate.
206+
var_y (float): The y-coordinate.
207+
208+
Returns:
209+
float: The value of the function at (var_x, var_y).
210+
211+
Example:
212+
>>> target_function(0, 0)
213+
0
214+
>>> target_function(1, 1)
215+
2
216+
"""
217+
return var_x**2 + var_y**2 # Simple parabolic surface (minimization)
218+
219+
220+
# Set bounds for the variables (var_x, var_y)
221+
bounds = [(-10, 10), (-10, 10)] # Both var_x and var_y range from -10 to 10
222+
223+
224+
# Instantiate and run the genetic algorithm
225+
ga = GeneticAlgorithm(
226+
function=target_function,
227+
bounds=bounds,
228+
population_size=N_POPULATION,
229+
generations=N_GENERATIONS,
230+
mutation_prob=MUTATION_PROBABILITY,
231+
crossover_rate=CROSSOVER_RATE,
232+
maximize=False, # Minimize the function
233+
)
234+
235+
236+
best_solution = ga.evolve()
237+
print(f"Best solution found: {best_solution}")
238+
print(f"Best fitness (minimum value of function): {target_function(*best_solution)}")

0 commit comments

Comments
 (0)