mbCrypto's picture
Added the update JSON heuristics
e831cc5
raw
history blame
21.7 kB
import copy
import random
from functools import partial
from typing import Callable, Optional, Tuple, List
def initialize_population(services: dict, users: dict, population_size: int) -> list:
"""
Initialize the population of assignment solutions for the genetic algorithm.
Args:
services (dict): A dictionary containing service constraints.
users (dict): A dictionary containing user preferences and constraints.
population_size (int): The number of assignment solutions to generate.
Returns:
list: A list of generated assignment solutions.
"""
population = []
# Generate population_size number of assignment solutions
for _ in range(population_size):
assignment_solution = {}
for service in services.keys():
# Randomly assign users to each service, while considering user preferences and constraints
assigned_users = []
for user, user_info in users.items():
# Check if user cannot be assigned to this service
if service not in user_info["cannot_assign"]:
# Assign user to service based on their preference
if service in user_info["preferences"]:
assigned_users.append(user)
# Assign user to service with a small probability if not in their preferences
elif random.random() < 0.1:
assigned_users.append(user)
# Shuffle the list of assigned users to create random assignments
random.shuffle(assigned_users)
assignment_solution[service] = assigned_users
# Add the generated assignment solution to the population
population.append(assignment_solution)
return population
def calculate_fitness(population: list, services: dict, users: dict, fitness_fn: Optional[Callable] = None) -> list:
"""
Calculate the fitness of each assignment solution in the population.
Args:
population (list): A list of assignment solutions.
services (dict): A dictionary containing service constraints.
users (dict): A dictionary containing user preferences and constraints.
fitness_fn (Optional[Callable]): An optional custom fitness function.
Returns:
list: A list of fitness scores for each assignment solution in the population.
"""
if not fitness_fn:
fitness_fn = default_fitness_function
fitness_scores = []
# Calculate the fitness score for each assignment solution in the population
for assignment_solution in population:
fitness_score = fitness_fn(assignment_solution, services, users)
fitness_scores.append(fitness_score)
return fitness_scores
def default_fitness_function(assignment_solution: dict, services: dict, users: dict) -> float:
"""
Calculate the fitness of an assignment solution based on the criteria described in the problem statement,
including user preferences and cannot_assign constraints.
Args:
assignment_solution (dict): An assignment solution to evaluate.
services (dict): A dictionary containing service constraints.
users (dict): A dictionary containing user preferences and constraints.
Returns:
float: The fitness score of the given assignment solution.
"""
fitness = -100
for service, assigned_users in assignment_solution.items():
service_info = services[service]
num_assigned_users = len(assigned_users)
# Prefers for solutions that assign users near the recommended value
# (positive or negative value is punishment, score 0 for the best fit)
if service_info["min"] <= num_assigned_users <= service_info["max"]:
fitness += abs(num_assigned_users - service_info["rec"]) * service_info["priority"]
# Punish solutions that assign users below the minimum value
# (positive value is punishment)
elif num_assigned_users < service_info["min"]:
fitness += (service_info["min"] - num_assigned_users) * service_info["priority"]
# Punish solutions that assign users above the maximum value
# (positive value is punishment)
else: # num_assigned_users > service_info["max"]:
fitness += (num_assigned_users - service_info["max"]) * service_info["priority"]
# Punish solutions that assign users to their cannot_assign services
# (positive value is punishment)
for user in assigned_users:
if service in users[user]["cannot_assign"]:
fitness += 100 * service_info["priority"]
# Bonus solutions that assign users to their preferred services
# (negative value is bonus)
for user, user_info in users.items():
if service in user_info["preferences"] and user in assigned_users:
fitness -= 20
return fitness
def least_changed_fitness_function(prev_solution: dict, solution: dict, services: dict, users: dict) -> float:
"""
A fitness function that favors solutions with the least changes from a previous solution.
Args:
solution (dict): The assignment solution to evaluate.
services (dict): The input services dictionary.
users (dict): The input users dictionary.
prev_solution (dict): The previous assignment solution to compare against.
Returns:
float: A fitness score for the assignment solution.
"""
# Add the default fitness function score
fitness = default_fitness_function(solution, services, users)
# Bonus for users assigned to the same services as in the previous solution
same_user_assignments = 0
for service, assigned_users in solution.items():
if service in prev_solution:
prev_assigned_users = prev_solution[service]
same_users = set(assigned_users).intersection(set(prev_assigned_users))
same_user_assignments += len(same_users)
user_bonus = same_user_assignments * 100 # / sum(len(user_data["preferences"]) for user_data in users.values())
# (positive value is punishment)
fitness -= user_bonus
# Bonus for services having the same users assigned as in the previous solution
same_service_assignments = 0
for service, assigned_users in solution.items():
if service in prev_solution:
prev_assigned_users = prev_solution[service]
same_users = set(assigned_users).intersection(set(prev_assigned_users))
same_service_assignments += len(same_users)
service_bonus = same_service_assignments * 100
# (positive value is punishment)
fitness -= service_bonus
# Malus for user and service changes
user_changes = 0
service_changes = 0
for service, service_data in services.items():
if service in prev_solution:
prev_service_data = prev_solution[service]
if service_data != prev_service_data:
service_changes += 1
for user, user_data in users.items():
if user in prev_service_data and user not in service_data:
user_changes += 1
user_malus = user_changes
service_malus = service_changes
# (positive value is punishment)
fitness += (user_malus + service_malus)
return fitness
def selection(fitness_scores: list) -> Tuple[int, int]:
"""
Select two parent solutions from the population based on their fitness scores.
Args:
fitness_scores (list): A list of fitness scores for each assignment solution in the population.
Returns:
Tuple[int, int]: The indices of the two selected parent solutions in the population.
"""
# Calculate the total fitness of the population
total_fitness = sum(fitness_scores)
# Calculate the relative fitness of each solution
relative_fitness = [f / total_fitness for f in fitness_scores]
# Select the first parent using roulette wheel selection
parent1_index = -1
r = random.random()
accumulator = 0
for i, rf in enumerate(relative_fitness):
accumulator += rf
if accumulator >= r:
parent1_index = i
break
# Select the second parent using roulette wheel selection, ensuring it's different from the first parent
parent2_index = -1
while parent2_index == -1 or parent2_index == parent1_index:
r = random.random()
accumulator = 0
for i, rf in enumerate(relative_fitness):
accumulator += rf
if accumulator >= r:
parent2_index = i
break
return parent1_index, parent2_index
def crossover(parent1: dict, parent2: dict) -> dict:
"""
Combine two parent assignment solutions to create a child solution.
Args:
parent1 (dict): The first parent assignment solution.
parent2 (dict): The second parent assignment solution.
Returns:
dict: The child assignment solution created by combining the parents.
"""
child_solution = {}
# Iterate over the services in the parents
for service in parent1.keys():
# Create two sets of users assigned to the current service in parent1 and parent2
assigned_users_parent1 = set(parent1[service])
assigned_users_parent2 = set(parent2[service])
# Perform set union to combine users assigned in both parents
combined_assigned_users = assigned_users_parent1 | assigned_users_parent2
# Randomly assign each user from the combined set to the child solution
child_assigned_users = []
for user in combined_assigned_users:
if random.random() < 0.5:
child_assigned_users.append(user)
child_solution[service] = child_assigned_users
return child_solution
def mutation(solution: dict, users: dict, mutation_rate: float = 0.01) -> dict:
"""
Mutate an assignment solution by randomly reassigning users to services.
Args:
solution (dict): The assignment solution to mutate.
users (dict): A dictionary containing user preferences and constraints.
mutation_rate (float): The probability of mutation for each user in the solution (default: 0.01).
Returns:
dict: The mutated assignment solution.
"""
mutated_solution = copy.deepcopy(solution)
# Iterate over the services in the solution
for service, assigned_users in mutated_solution.items():
for user in assigned_users:
# Check if the user should be mutated based on the mutation rate
if random.random() < mutation_rate:
# Remove the user from the current service
assigned_users.remove(user)
# Find a new service for the user while considering their cannot_assign constraints
new_service = service
while new_service == service or new_service in users[user]["cannot_assign"]:
new_service = random.choice(list(mutated_solution.keys()))
# Assign the user to the new service
mutated_solution[new_service].append(user)
return mutated_solution
def report_generation(generation: int, fitness_scores: list, best_solution: dict, services: dict, users: dict) -> None:
"""
Print a report of the genetic algorithm's progress for the current generation.
Args:
generation (int): The current generation number.
fitness_scores (list): The fitness scores for the current population.
best_solution (dict): The best assignment solution found so far.
services (dict): The input services dictionary.
users (dict): The input users dictionary.
"""
best_fitness = min(fitness_scores)
worst_fitness = max(fitness_scores)
avg_fitness = sum(fitness_scores) / len(fitness_scores)
generation_errors = polish_errors(calculate_errors(best_solution, services, users))
print(f"Generation {generation}:")
print(f" Best fitness: {best_fitness}")
print(f" Worst fitness: {worst_fitness}")
print(f" Average fitness: {avg_fitness}")
print(f" Best solution so far: {best_solution}")
print(f" Errors so far: {generation_errors}")
def calculate_errors(solution: dict, services: dict, users: dict) -> dict:
"""
Calculate the errors in the assignment solution based on the user and service constraints.
Args:
solution (dict): The assignment solution to analyze.
services (dict): The input services dictionary.
users (dict): The input users dictionary.
Returns:
dict: A dictionary containing the errors for each user and service in the assignment solution.
"""
errors = {"users": {}, "services": {}}
# Analyze user errors
for user, user_data in users.items():
errors["users"][user] = {"unmet_max_assignments": False, "unmet_preference": [], "unmet_cannot_assign": []}
user_assignments = [service for service, assigned_users in solution.items() if user in assigned_users]
if len(user_assignments) > user_data["max_assignments"]:
errors["users"][user]["unmet_max_assignments"] = True
errors["users"][user]["effective_assignments"] = len(user_assignments)
for preferred_service in user_data["preferences"]:
if preferred_service not in user_assignments:
errors["users"][user]["unmet_preference"].append(preferred_service)
for cannot_assign_service in user_data["cannot_assign"]:
if cannot_assign_service in user_assignments:
errors["users"][user]["unmet_cannot_assign"].append(cannot_assign_service)
# Analyze service errors
for service, service_data in services.items():
errors["services"][service] = {"unmet_constraint": None, "extra_users": []}
assigned_users = solution[service]
num_assigned_users = len(assigned_users)
if num_assigned_users < service_data["min"]:
errors["services"][service]["unmet_constraint"] = "min"
elif num_assigned_users > service_data["rec"]:
errors["services"][service]["unmet_constraint"] = "rec"
elif num_assigned_users > service_data["max"]:
errors["services"][service]["unmet_constraint"] = "max"
extra_users = assigned_users[service_data["max"]:]
errors["services"][service]["extra_users"] = extra_users
return errors
def polish_errors(errors: dict) -> dict:
"""
Remove users and services without unmet constraints from the errors object.
Args:
errors (dict): The errors object to polish.
Returns:
dict: A polished errors object without users and services with no unmet constraints.
"""
polished_errors = {"users": {}, "services": {}}
for user, user_errors in errors["users"].items():
polished_user_errors = {}
if user_errors["unmet_max_assignments"]:
polished_user_errors["unmet_max_assignments"] = True
for key, value in user_errors.items():
if key not in ["unmet_max_assignments"] and value:
polished_user_errors[key] = value
if polished_user_errors:
polished_errors["users"][user] = polished_user_errors
for service, service_errors in errors["services"].items():
polished_service_errors = {}
for key, value in service_errors.items():
if value:
polished_service_errors[key] = value
if polished_service_errors:
polished_errors["services"][service] = polished_service_errors
return polished_errors
def calculate_diff(solution1: dict, solution2: dict) -> dict:
"""
Calculate the differences between two solution JSON objects and return the differences categorized into
"added" and "removed" attributes for each service.
Args:
solution1 (dict): The first solution JSON object.
solution2 (dict): The second solution JSON object.
Returns:
dict: A dictionary with the differences between the two solutions, categorized into "added" and
"removed" attributes for each service.
"""
diff = {}
all_services = set(solution1.keys()).union(set(solution2.keys()))
for service in all_services:
service_diff = {
"added": [],
"removed": []
}
if service not in solution1:
service_diff["added"] = solution2[service]
elif service not in solution2:
service_diff["removed"] = solution1[service]
else:
added_users = set(solution2[service]) - set(solution1[service])
removed_users = set(solution1[service]) - set(solution2[service])
if added_users:
service_diff["added"] = list(added_users)
if removed_users:
service_diff["removed"] = list(removed_users)
if service_diff["added"] or service_diff["removed"]:
diff[service] = service_diff
return diff
def genetic_algorithm(services: dict, users: dict, population_size: int = 100, num_generations: int = 100,
mutation_rate: float = 0.01, fitness_fn: Optional[Callable] = None,
initial_population: Optional[List[dict]] = None) -> dict:
"""
Run the genetic algorithm to find an optimal assignment solution based on user preferences and constraints.
Args:
services (dict): The input services dictionary.
users (dict): The input users dictionary.
population_size (int): The size of the population for each generation (default: 100).
num_generations (int): The number of generations for the genetic algorithm to run (default: 100).
mutation_rate (float): The probability of mutation for each individual in the population (default: 0.01).
fitness_fn (Callable, optional): An optional custom fitness function.
initial_population: An optional previous solution to be used as starting point
Returns:
dict: The best assignment solution found by the genetic algorithm.
"""
# Initialize the population
population = initial_population or initialize_population(services, users, population_size)
# If no custom fitness function is provided, use the default fitness function
if fitness_fn is None:
fitness_fn = default_fitness_function
# Calculate the initial fitness scores for the population
fitness_scores = calculate_fitness(population, services, users, fitness_fn)
best_solution = None
best_fitness = float('inf')
# Main loop of the genetic algorithm
for generation in range(num_generations):
# Select two parent solutions based on their fitness scores
parent1_index, parent2_index = selection(fitness_scores)
# Create a child solution by combining the parents using crossover
child_solution = crossover(population[parent1_index], population[parent2_index])
# Mutate the child solution
mutated_child_solution = mutation(child_solution, users, mutation_rate)
# Calculate the fitness of the child solution
child_fitness = fitness_fn(mutated_child_solution, services, users)
# Replace the least-fit solution in the population with the child solution
worst_fitness_index = fitness_scores.index(max(fitness_scores))
population[worst_fitness_index] = mutated_child_solution
fitness_scores[worst_fitness_index] = child_fitness
# Update the best solution found so far
if child_fitness < best_fitness:
best_solution = mutated_child_solution
best_fitness = child_fitness
# Print the progress of the algorithm
report_generation(generation, fitness_scores, best_solution, services, users)
return best_solution
def update_genetic_algorithm(prev_solution: dict, updated_services: dict, updated_users: dict,
population_size: int = 100, num_generations: int = 100, mutation_rate: float = 0.01,
fitness_fn: Optional[Callable] = None) -> dict:
"""
Update the previous assignment solution with updated services and users using a genetic algorithm.
Args:
prev_solution (dict): The previous assignment solution.
updated_services (dict): The updated services dictionary.
updated_users (dict): The updated users dictionary.
population_size (int): The size of the population for each generation in the genetic algorithm (default: 100).
num_generations (int): The number of generations for the genetic algorithm to run (default: 100).
mutation_rate (float): The probability of mutation for each individual in the population (default: 0.01).
fitness_fn (Optional[Callable]): An optional fitness function to use in the genetic algorithm.
Returns:
dict: The updated assignment solution.
"""
# Create the initial population with the given previous solution
population = initialize_population(updated_services, updated_users, population_size - 1)
population.append(prev_solution)
if fitness_fn is None:
fitness_fn = partial(least_changed_fitness_function, prev_solution)
# Run the genetic algorithm using the initial population
updated_solution = genetic_algorithm(updated_services, updated_users, population_size, num_generations,
mutation_rate,
fitness_fn,
population)
return updated_solution