-
Notifications
You must be signed in to change notification settings - Fork 15
feat: Add Genetic Algorithm solver with unit tests #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| # job_shop_lib/genetic_algorithm.py | ||
|
|
||
| import random | ||
|
|
||
| class GeneticAlgorithm: | ||
| """ | ||
| A class to encapsulate the logic for a Genetic Algorithm solver. | ||
| This class is unchanged from the previous version. | ||
| """ | ||
| def __init__(self, fitness_func, population_size, num_genes, | ||
| crossover_rate=0.8, mutation_rate=0.01, tournament_size=5): | ||
| self.fitness_func = fitness_func | ||
| self.population_size = population_size | ||
| self.num_genes = num_genes | ||
| self.crossover_rate = crossover_rate | ||
| self.mutation_rate = mutation_rate | ||
| self.tournament_size = tournament_size | ||
| self.population = self._initialize_population() | ||
|
|
||
| def _initialize_population(self): | ||
| population = [] | ||
| for _ in range(self.population_size): | ||
| individual = [random.randint(0, 1) for _ in range(self.num_genes)] | ||
| population.append(individual) | ||
| return population | ||
|
|
||
| def _selection(self): | ||
| tournament = random.sample(self.population, self.tournament_size) | ||
| best_individual = max(tournament, key=self.fitness_func) | ||
| return best_individual | ||
|
|
||
| def _crossover(self, parent1, parent2): | ||
| if random.random() < self.crossover_rate: | ||
| point = random.randint(1, self.num_genes - 1) | ||
| child1 = parent1[:point] + parent2[point:] | ||
| child2 = parent2[:point] + parent1[point:] | ||
| return child1, child2 | ||
| return parent1, parent2 | ||
|
|
||
| def _mutation(self, individual): | ||
| mutated_individual = [] | ||
| for gene in individual: | ||
| if random.random() < self.mutation_rate: | ||
| mutated_individual.append(1 - gene) | ||
| else: | ||
| mutated_individual.append(gene) | ||
| return mutated_individual | ||
|
|
||
| def solve(self, num_generations): | ||
| best_overall_individual = None | ||
| best_overall_fitness = -float('inf') | ||
|
|
||
| print("\n🧬 Starting Genetic Algorithm...") | ||
| for generation in range(num_generations): | ||
| fitness_scores = [self.fitness_func(ind) for ind in self.population] | ||
| best_fitness_in_gen = max(fitness_scores) | ||
|
|
||
| if best_fitness_in_gen > best_overall_fitness: | ||
| best_overall_fitness = best_fitness_in_gen | ||
| best_index = fitness_scores.index(best_fitness_in_gen) | ||
| best_overall_individual = self.population[best_index] | ||
|
|
||
| # Use \r to overwrite the line for a cleaner progress display | ||
| print(f"Generation {generation + 1}/{num_generations} | Best Fitness: {best_overall_fitness}", end='\r') | ||
|
|
||
| print("\n✅ Genetic Algorithm finished.") | ||
| return best_overall_individual, best_overall_fitness | ||
|
Comment on lines
+49
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The You need to implement the generational loop where a new population is created based on the fitness of the current one. This involves selecting parents, performing crossover and mutation, and replacing the old population with the new one. def solve(self, num_generations):
best_overall_individual = max(self.population, key=self.fitness_func)
best_overall_fitness = self.fitness_func(best_overall_individual)
print("\n🧬 Starting Genetic Algorithm...")
for generation in range(num_generations):
# --- Create new generation ---
new_population = []
# Elitism: Carry over the best individual from the previous generation
new_population.append(best_overall_individual)
while len(new_population) < self.population_size:
parent1 = self._selection()
parent2 = self._selection()
child1, child2 = self._crossover(parent1, parent2)
new_population.append(self._mutation(child1))
if len(new_population) < self.population_size:
new_population.append(self._mutation(child2))
self.population = new_population
# --- Evaluation of new population ---
current_best_individual = max(self.population, key=self.fitness_func)
current_best_fitness = self.fitness_func(current_best_individual)
if current_best_fitness > best_overall_fitness:
best_overall_fitness = current_best_fitness
best_overall_individual = current_best_individual
# Use \r to overwrite the line for a cleaner progress display
print(f"Generation {generation + 1}/{num_generations} | Best Fitness: {best_overall_fitness}", end='\r')
print("\n✅ Genetic Algorithm finished.")
return best_overall_individual, best_overall_fitness |
||
|
|
||
|
|
||
| # --- Helper function to get validated user input --- | ||
| def get_validated_input(prompt, target_type, min_val=None, max_val=None): | ||
| """A robust function to get and validate user input.""" | ||
| while True: | ||
| user_input = input(prompt) | ||
| try: | ||
| value = target_type(user_input) | ||
| if min_val is not None and value < min_val: | ||
| print(f"Error: Value must be at least {min_val}.") | ||
| elif max_val is not None and value > max_val: | ||
| print(f"Error: Value must be no more than {max_val}.") | ||
| else: | ||
| return value | ||
| except ValueError: | ||
| print(f"Error: Invalid input. Please enter a valid {target_type.__name__}.") | ||
|
|
||
|
|
||
| # --- Dynamic Example Usage --- | ||
| if __name__ == '__main__': | ||
| # Define a simple fitness function (One-Max problem) | ||
| def one_max_fitness(individual): | ||
| return sum(individual) | ||
|
|
||
| print("--- ⚙️ Configure Genetic Algorithm Parameters ---") | ||
|
|
||
| # Get user input for GA parameters | ||
| pop_size = get_validated_input("Enter the population size (e.g., 100): ", int, min_val=10) | ||
| genes = get_validated_input("Enter the number of genes per individual (e.g., 50): ", int, min_val=1) | ||
| generations = get_validated_input("Enter the number of generations to run (e.g., 100): ", int, min_val=1) | ||
| cross_rate = get_validated_input("Enter the crossover rate (e.g., 0.8): ", float, min_val=0.0, max_val=1.0) | ||
| mut_rate = get_validated_input("Enter the mutation rate (e.g., 0.01): ", float, min_val=0.0, max_val=1.0) | ||
|
|
||
| # Create and run the solver with user-defined parameters | ||
| ga_solver = GeneticAlgorithm( | ||
| fitness_func=one_max_fitness, | ||
| population_size=pop_size, | ||
| num_genes=genes, | ||
| crossover_rate=cross_rate, | ||
| mutation_rate=mut_rate | ||
| ) | ||
|
|
||
| best_solution, best_score = ga_solver.solve(generations) | ||
|
|
||
| print("\n--- 📊 Results ---") | ||
| print(f"Best solution found: {best_solution}") | ||
| print(f"Fitness score: {best_score} (out of a maximum possible {genes})") | ||
|
Comment on lines
+70
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The helper function Your PR description mentions adding an example to |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # tests/test_genetic_algorithm.py | ||
|
|
||
| import unittest | ||
| # Make sure to import your GeneticAlgorithm class | ||
| from job_shop_lib.genetic_algorithm import GeneticAlgorithm | ||
|
|
||
| # A simple fitness function for testing purposes | ||
| def dummy_fitness(individual): | ||
| return sum(individual) | ||
|
|
||
| class TestGeneticAlgorithm(unittest.TestCase): | ||
|
|
||
| def setUp(self): | ||
| """Set up a GA instance before each test.""" | ||
| self.ga_solver = GeneticAlgorithm( | ||
| fitness_func=dummy_fitness, | ||
| population_size=10, | ||
| num_genes=8 | ||
| ) | ||
|
|
||
| def test_initialization(self): | ||
| """Test if the population is initialized correctly.""" | ||
| self.assertEqual(len(self.ga_solver.population), 10) | ||
| self.assertEqual(len(self.ga_solver.population[0]), 8) | ||
|
|
||
| def test_solve_returns_valid_output(self): | ||
| """Test if the solve method runs and returns the correct format.""" | ||
| num_generations = 5 | ||
| best_solution, best_score = self.ga_solver.solve(num_generations) | ||
|
|
||
| # Check if the output has the correct length and the score is a number | ||
| self.assertEqual(len(best_solution), 8) | ||
| self.assertIsInstance(best_score, (int, float)) | ||
|
Comment on lines
+26
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current A more robust test would check if the fitness actually improves over generations. This would have caught the bug. Consider replacing the current test with one that asserts fitness improvement. You'll need to seed the random number generator for a deterministic result. Here's an example of a more robust test. Note that this test will only pass after the def test_solve_improves_fitness_and_returns_valid_output(self):
"""Test if solve improves fitness and returns the correct format."""
import random
random.seed(42)
# Use a new GA instance with parameters that are more likely to show improvement
ga_solver = GeneticAlgorithm(
fitness_func=dummy_fitness,
population_size=20,
num_genes=20,
mutation_rate=0.1,
crossover_rate=0.8,
tournament_size=3
)
initial_best_fitness = max(dummy_fitness(ind) for ind in ga_solver.population)
# Run the solver (this will only work after the `solve` method is fixed)
best_solution, final_best_fitness = ga_solver.solve(num_generations=20)
# Check output format
self.assertEqual(len(best_solution), 20)
self.assertIsInstance(final_best_fitness, (int, float))
# Check for improvement
self.assertGreater(final_best_fitness, initial_best_fitness,
"Final fitness should be greater than initial best fitness for this seed.") |
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current
GeneticAlgorithmimplementation is tightly coupled with a binary representation for individuals (e.g.,random.randint(0, 1)in_initialize_populationand1 - genein_mutation). This significantly limits its applicability. For a library namedjob_shop_lib, one would expect solvers that can handle permutation-based or other complex encodings typical for job shop scheduling problems.To make the solver more generic and reusable, consider adopting a more flexible design. For example, you could allow the user to provide their own functions for initialization, crossover, and mutation as arguments to
__init__. This would decouple the core GA logic from the specific problem representation.