From 1ccc6408d7faba9f54326a9605fb5c259eb99303 Mon Sep 17 00:00:00 2001 From: Timoschak Egor <64361102+Timotshak@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:34:24 +0300 Subject: [PATCH] Adapt Genetic Algorithm to multi-objective tasks (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NSGA2 selection in genetic algorithm to perform multi-objective optimization - Change return type of fitness functions to tuple - Add multiobjective fitness function TimeAndResourcesFitness - Add is_multiobjective and fitness_weights parameters in Genetic Scheduler - Add multiobjective scheduling methods to Genetic Scheduler - Add Individual and IndividialFitness classes to change them dynamically what can't be done through the deap.creator - Сhange the logic of genetic operators. Now they work with chromosomes, and individuals are then created in a genetic algorithm - Replace HallOfFame by ParetoFront in genetic algorithm to perform multi-objective optimization. In the case of single- objective optimization, the behavior will not change - Add make_offspring function to reduce code - Add test of multi-objective genetic scheduling --- sampo/scheduler/genetic/__init__.py | 3 + sampo/scheduler/genetic/base.py | 134 +++++++--- sampo/scheduler/genetic/operators.py | 181 ++++++++----- sampo/scheduler/genetic/schedule_builder.py | 248 +++++++++--------- sampo/utilities/resource_usage.py | 4 +- tests/scheduler/genetic/full_scheduling.py | 6 +- .../genetic/multiobjective_scheduling.py | 20 ++ tests/scheduler/genetic/operators_test.py | 4 +- .../resources_in_time/basic_res_test.py | 7 +- 9 files changed, 373 insertions(+), 234 deletions(-) create mode 100644 tests/scheduler/genetic/multiobjective_scheduling.py diff --git a/sampo/scheduler/genetic/__init__.py b/sampo/scheduler/genetic/__init__.py index 0b71feb6..0b416136 100644 --- a/sampo/scheduler/genetic/__init__.py +++ b/sampo/scheduler/genetic/__init__.py @@ -1,2 +1,5 @@ from sampo.scheduler.genetic.base import GeneticScheduler from sampo.scheduler.genetic.converter import ScheduleGenerationScheme +from sampo.scheduler.genetic.operators import (TimeFitness, SumOfResourcesPeaksFitness, SumOfResourcesFitness, + TimeWithResourcesFitness, DeadlineResourcesFitness, DeadlineCostFitness, + TimeAndResourcesFitness) diff --git a/sampo/scheduler/genetic/base.py b/sampo/scheduler/genetic/base.py index 368cd6ff..422b503e 100644 --- a/sampo/scheduler/genetic/base.py +++ b/sampo/scheduler/genetic/base.py @@ -3,7 +3,7 @@ from sampo.scheduler.base import Scheduler, SchedulerType from sampo.scheduler.genetic.operators import FitnessFunction, TimeFitness -from sampo.scheduler.genetic.schedule_builder import build_schedule +from sampo.scheduler.genetic.schedule_builder import build_schedules from sampo.scheduler.genetic.converter import ChromosomeType, ScheduleGenerationScheme from sampo.scheduler.heft.base import HEFTScheduler, HEFTBetweenScheduler from sampo.scheduler.lft.base import LFTScheduler @@ -40,8 +40,10 @@ def __init__(self, rand: Optional[random.Random] = None, seed: Optional[float or None] = None, n_cpu: int = 1, - weights: list[int] = None, - fitness_constructor: Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction] = TimeFitness, + weights: Optional[list[int] or None] = None, + fitness_constructor: Callable[ + [Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction] = TimeFitness, + fitness_weights: tuple[int | float, ...] = (-1,), scheduler_type: SchedulerType = SchedulerType.Genetic, resource_optimizer: ResourceOptimizer = IdentityResourceOptimizer(), work_estimator: WorkTimeEstimator = DefaultWorkEstimator(), @@ -59,6 +61,7 @@ def __init__(self, self.size_of_population = size_of_population self.rand = rand or random.Random(seed) self.fitness_constructor = fitness_constructor + self.fitness_weights = fitness_weights self.work_estimator = work_estimator self.sgs_type = sgs_type @@ -93,7 +96,7 @@ def get_params(self, works_count: int) -> tuple[float, float, float, int]: mutate_resources = self.mutate_resources if mutate_resources is None: - mutate_resources = 0.005 + mutate_resources = 0.05 mutate_zones = self.mutate_zones if mutate_zones is None: @@ -232,6 +235,68 @@ def schedule_with_cache(self, :param timeline: :return: """ + schedule, schedule_start_time, timeline, order_nodes = self._build_schedules(wg, contractors, landscape, spec, + assigned_parent_time, timeline, + is_multiobjective=False)[0] + + if validate: + validate_schedule(schedule, wg, contractors) + + return schedule, schedule_start_time, timeline, order_nodes + + def schedule_multiobjective(self, + wg: WorkGraph, + contractors: list[Contractor], + spec: ScheduleSpec = ScheduleSpec(), + validate: bool = False, + start_time: Time = Time(0), + timeline: Timeline | None = None, + landscape: LandscapeConfiguration = LandscapeConfiguration()) \ + -> list[Schedule]: + """ + Implementation of a multiobjective scheduling process + + :return: list of pareto-efficient Schedules + """ + if wg is None or len(wg.nodes) == 0: + raise ValueError('None or empty WorkGraph') + if contractors is None or len(contractors) == 0: + raise ValueError('None or empty contractor list') + schedules = self.schedule_multiobjective_with_cache(wg, contractors, landscape, spec, validate, start_time, + timeline) + schedules = [schedule for schedule, _, _, _ in schedules] + return schedules + + def schedule_multiobjective_with_cache(self, + wg: WorkGraph, + contractors: list[Contractor], + landscape: LandscapeConfiguration = LandscapeConfiguration(), + spec: ScheduleSpec = ScheduleSpec(), + validate: bool = False, + assigned_parent_time: Time = Time(0), + timeline: Timeline | None = None) \ + -> list[tuple[Schedule, Time, Timeline, list[GraphNode]]]: + """ + Build pareto-efficient schedules for received graph of workers and return their current states + """ + schedules = self._build_schedules(wg, contractors, landscape, spec, assigned_parent_time, timeline, + is_multiobjective=True) + + if validate: + for schedule, _, _, _ in schedules: + validate_schedule(schedule, wg, contractors) + + return schedules + + def _build_schedules(self, + wg: WorkGraph, + contractors: list[Contractor], + landscape: LandscapeConfiguration = LandscapeConfiguration(), + spec: ScheduleSpec = ScheduleSpec(), + assigned_parent_time: Time = Time(0), + timeline: Timeline | None = None, + is_multiobjective: bool = False) \ + -> list[tuple[Schedule, Time, Timeline, list[GraphNode]]]: init_schedules = GeneticScheduler.generate_first_population(wg, contractors, landscape, spec, self.work_estimator, self._deadline, self._weights) @@ -239,33 +304,34 @@ def schedule_with_cache(self, worker_pool = get_worker_contractor_pool(contractors) deadline = None if self._optimize_resources else self._deadline - scheduled_works, schedule_start_time, timeline, order_nodes = build_schedule(wg, - contractors, - worker_pool, - size_of_population, - self.number_of_generation, - mutate_order, - mutate_resources, - mutate_zones, - init_schedules, - self.rand, - spec, - landscape, - self.fitness_constructor, - self.work_estimator, - self.sgs_type, - self._n_cpu, - assigned_parent_time, - timeline, - self._time_border, - self._max_plateau_steps, - self._optimize_resources, - deadline, - self._only_lft_initialization, - self._verbose) - schedule = Schedule.from_scheduled_works(scheduled_works.values(), wg) - - if validate: - validate_schedule(schedule, wg, contractors) - - return schedule, schedule_start_time, timeline, order_nodes + schedules = build_schedules(wg, + contractors, + worker_pool, + size_of_population, + self.number_of_generation, + mutate_order, + mutate_resources, + mutate_zones, + init_schedules, + self.rand, + spec, + landscape, + self.fitness_constructor, + self.fitness_weights, + self.work_estimator, + self.sgs_type, + self._n_cpu, + assigned_parent_time, + timeline, + self._time_border, + self._max_plateau_steps, + self._optimize_resources, + deadline, + self._only_lft_initialization, + is_multiobjective, + self._verbose) + schedules = [ + (Schedule.from_scheduled_works(scheduled_works.values(), wg), schedule_start_time, timeline, order_nodes) + for scheduled_works, schedule_start_time, timeline, order_nodes in schedules] + + return schedules diff --git a/sampo/scheduler/genetic/operators.py b/sampo/scheduler/genetic/operators.py index cf02aa0a..e8b72a76 100644 --- a/sampo/scheduler/genetic/operators.py +++ b/sampo/scheduler/genetic/operators.py @@ -7,7 +7,7 @@ from typing import Callable, Iterable import numpy as np -from deap import creator, base +from deap import base, tools from sampo.scheduler.genetic.converter import (convert_chromosome_to_schedule, convert_schedule_to_chromosome, ChromosomeType, ScheduleGenerationScheme) @@ -37,7 +37,7 @@ def __init__(self, evaluator: Callable[[list[ChromosomeType]], list[Schedule]]): self._evaluator = evaluator @abstractmethod - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int | float, ...]]: """ Calculate the value of fitness function of the all chromosomes. It is better when value is less. @@ -50,8 +50,8 @@ class TimeFitness(FitnessFunction): Fitness function that relies on finish time. """ - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: - return [schedule.execution_time.value for schedule in self._evaluator(chromosomes)] + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int]]: + return [(schedule.execution_time.value,) for schedule in self._evaluator(chromosomes)] class SumOfResourcesPeaksFitness(FitnessFunction): @@ -65,15 +65,16 @@ def __init__(self, evaluator: Callable[[list[ChromosomeType]], list[Schedule]], self._resources_names = list(resources_names) if resources_names is not None else None @staticmethod - def prepare(resources_names: Iterable[str]) -> Callable[[list[ChromosomeType]], list[Schedule]]: + def prepare(resources_names: Iterable[str]) \ + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: """ - Returns the constructor of that fitness function prepared to use in Genetic + Returns the constructor of that fitness function prepared to use in Genetic algorithm """ return partial(SumOfResourcesPeaksFitness, resources_names=resources_names) - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int]]: evaluated = self._evaluator(chromosomes) - return [resources_peaks_sum(schedule, self._resources_names) for schedule in evaluated] + return [(resources_peaks_sum(schedule, self._resources_names),) for schedule in evaluated] class SumOfResourcesFitness(FitnessFunction): @@ -87,15 +88,16 @@ def __init__(self, evaluator: Callable[[list[ChromosomeType]], list[Schedule]], self._resources_names = list(resources_names) if resources_names is not None else None @staticmethod - def prepare(resources_names: Iterable[str]) -> Callable[[list[ChromosomeType]], list[Schedule]]: + def prepare(resources_names: Iterable[str]) \ + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: """ - Returns the constructor of that fitness function prepared to use in Genetic + Returns the constructor of that fitness function prepared to use in Genetic algorithm """ return partial(SumOfResourcesFitness, resources_names=resources_names) - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int]]: evaluated = self._evaluator(chromosomes) - return [resources_sum(schedule, self._resources_names) for schedule in evaluated] + return [(resources_sum(schedule, self._resources_names),) for schedule in evaluated] class TimeWithResourcesFitness(FitnessFunction): @@ -109,15 +111,16 @@ def __init__(self, evaluator: Callable[[list[ChromosomeType]], list[Schedule]], self._resources_names = list(resources_names) if resources_names is not None else None @staticmethod - def prepare(resources_names: Iterable[str]) -> Callable[[list[ChromosomeType]], list[Schedule]]: + def prepare(resources_names: Iterable[str]) \ + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: """ - Returns the constructor of that fitness function prepared to use in Genetic + Returns the constructor of that fitness function prepared to use in Genetic algorithm """ return partial(TimeWithResourcesFitness, resources_names=resources_names) - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int]]: evaluated = self._evaluator(chromosomes) - return [schedule.execution_time.value + resources_peaks_sum(schedule, self._resources_names) + return [(schedule.execution_time.value + resources_peaks_sum(schedule, self._resources_names),) for schedule in evaluated] @@ -134,16 +137,16 @@ def __init__(self, deadline: Time, evaluator: Callable[[list[ChromosomeType]], l @staticmethod def prepare(deadline: Time, resources_names: Iterable[str] | None = None) \ - -> Callable[[list[ChromosomeType]], list[Schedule]]: + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: """ - Returns the constructor of that fitness function prepared to use in Genetic + Returns the constructor of that fitness function prepared to use in Genetic algorithm """ return partial(DeadlineResourcesFitness, deadline, resources_names=resources_names) - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[float]]: evaluated = self._evaluator(chromosomes) - return [int(resources_peaks_sum(schedule, self._resources_names) - * max(1.0, schedule.execution_time.value / self._deadline.value)) + return [(resources_peaks_sum(schedule, self._resources_names) + * max(1.0, schedule.execution_time.value / self._deadline.value),) for schedule in evaluated] @@ -160,24 +163,55 @@ def __init__(self, deadline: Time, evaluator: Callable[[list[ChromosomeType]], l @staticmethod def prepare(deadline: Time, resources_names: Iterable[str] | None = None) \ - -> Callable[[list[ChromosomeType]], list[Schedule]]: + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: """ - Returns the constructor of that fitness function prepared to use in Genetic + Returns the constructor of that fitness function prepared to use in Genetic algorithm """ return partial(DeadlineCostFitness, deadline, resources_names=resources_names) - def evaluate(self, chromosomes: list[ChromosomeType]) -> list[int]: + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[float]]: evaluated = self._evaluator(chromosomes) # TODO Integrate cost calculation to native module - return [int(resources_costs_sum(schedule, self._resources_names) * max(1.0, schedule.execution_time.value / self._deadline.value)) + return [(resources_costs_sum(schedule, self._resources_names) + * max(1.0, schedule.execution_time.value / self._deadline.value),) for schedule in evaluated] -# create class FitnessMin, the weights = -1 means that fitness - is function for minimum +class TimeAndResourcesFitness(FitnessFunction): + """ + Bi-objective fitness function of finish time and sum of resources peaks. + """ + + def __init__(self, evaluator: Callable[[list[ChromosomeType]], list[Schedule]], + resources_names: Iterable[str] | None = None): + super().__init__(evaluator) + self._resources_names = list(resources_names) if resources_names is not None else None + + @staticmethod + def prepare(resources_names: Iterable[str]) \ + -> Callable[[Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction]: + """ + Returns the constructor of that fitness function prepared to use in Genetic algorithm + """ + return partial(TimeAndResourcesFitness, resources_names=resources_names) + + def evaluate(self, chromosomes: list[ChromosomeType]) -> list[tuple[int, int]]: + evaluated = self._evaluator(chromosomes) + return [(schedule.execution_time.value, resources_peaks_sum(schedule, self._resources_names)) + for schedule in evaluated] -creator.create('FitnessMin', base.Fitness, weights=(-1.0,)) -creator.create('Individual', list, fitness=creator.FitnessMin) -Individual = creator.Individual + +class Individual(list): + def __init__(self, individual_fitness_constructor: Callable[[], base.Fitness], chromosome: ChromosomeType): + super().__init__(chromosome) + self.fitness = individual_fitness_constructor() + + @staticmethod + def prepare(individual_fitness_constructor: Callable[[], base.Fitness]) -> Callable[[ChromosomeType], list]: + """ + Returns the constructor of Individual prepared to use in Genetic algorithm + """ + return partial(Individual, individual_fitness_constructor) def init_toolbox(wg: WorkGraph, @@ -205,9 +239,11 @@ def init_toolbox(wg: WorkGraph, children: dict[int, set[int]], resources_border: np.ndarray, assigned_parent_time: Time = Time(0), + fitness_weights: tuple[int | float, ...] = (-1,), work_estimator: WorkTimeEstimator = DefaultWorkEstimator(), sgs_type: ScheduleGenerationScheme = ScheduleGenerationScheme.Parallel, - only_lft_initialization: bool = False) -> base.Toolbox: + only_lft_initialization: bool = False, + is_multiobjective: bool = False) -> base.Toolbox: """ Object, that include set of functions (tools) for genetic model and other functions related to it. list of parameters that received this function is sufficient and complete to manipulate with genetic algorithm @@ -215,6 +251,8 @@ def init_toolbox(wg: WorkGraph, :return: Object, included tools for genetic algorithm """ toolbox = base.Toolbox() + toolbox.register('register_individual_constructor', register_individual_constructor, toolbox=toolbox) + toolbox.register_individual_constructor(fitness_weights) # generate chromosome toolbox.register('generate_chromosome', generate_chromosome, wg=wg, contractors=contractors, work_id2index=work_id2index, worker_name2index=worker_name2index, @@ -223,13 +261,14 @@ def init_toolbox(wg: WorkGraph, # create population # toolbox.register('population', tools.initRepeat, list, lambda: toolbox.generate_chromosome()) - toolbox.register('population', generate_population, wg=wg, contractors=contractors, + toolbox.register('population_chromosomes', generate_chromosomes, wg=wg, contractors=contractors, work_id2index=work_id2index, worker_name2index=worker_name2index, contractor2index=contractor2index, contractor_borders=contractor_borders, spec=spec, init_chromosomes=init_chromosomes, rand=rand, work_estimator=work_estimator, landscape=landscape, only_lft_initialization=only_lft_initialization) # selection - toolbox.register('select', select_new_population, pop_size=population_size) + selection = tools.selNSGA2 if is_multiobjective else select_new_population + toolbox.register('select', selection, k=population_size) # combined crossover toolbox.register('mate', mate, rand=rand) # combined mutation @@ -259,37 +298,43 @@ def init_toolbox(wg: WorkGraph, work_id2index=work_id2index, worker_name2index=worker_name2index, contractor2index=contractor2index, contractor_borders=contractor_borders, spec=spec, landscape=landscape) - toolbox.register("chromosome_to_schedule", convert_chromosome_to_schedule, worker_pool=worker_pool, + toolbox.register('chromosome_to_schedule', convert_chromosome_to_schedule, worker_pool=worker_pool, index2node=index2node, index2contractor=index2contractor_obj, worker_pool_indices=worker_pool_indices, assigned_parent_time=assigned_parent_time, work_estimator=work_estimator, worker_name2index=worker_name2index, contractor2index=contractor2index, index2zone=index2zone, landscape=landscape, sgs_type=sgs_type) - toolbox.register('copy_individual', lambda ind: Individual(copy_chromosome(ind))) + toolbox.register('copy_individual', lambda ind: toolbox.Individual(copy_chromosome(ind))) return toolbox -def copy_chromosome(chromosome: ChromosomeType) -> ChromosomeType: - return chromosome[0].copy(), chromosome[1].copy(), chromosome[2].copy(), \ - deepcopy(chromosome[3]), chromosome[4].copy() +def register_individual_constructor(fitness_weights: tuple[int | float, ...], toolbox: base.Toolbox): + class IndividualFitness(base.Fitness): + weights = fitness_weights + toolbox.register('Individual', Individual.prepare(IndividualFitness)) -def generate_population(n: int, - wg: WorkGraph, - contractors: list[Contractor], - spec: ScheduleSpec, - work_id2index: dict[str, int], - worker_name2index: dict[str, int], - contractor2index: dict[str, int], - contractor_borders: np.ndarray, - init_chromosomes: dict[str, tuple[ChromosomeType, float, ScheduleSpec]], - rand: random.Random, - work_estimator: WorkTimeEstimator = None, - landscape: LandscapeConfiguration = LandscapeConfiguration(), - only_lft_initialization: bool = False) -> list[Individual]: + +def copy_chromosome(ind: ChromosomeType) -> ChromosomeType: + return ind[0].copy(), ind[1].copy(), ind[2].copy(), deepcopy(ind[3]), ind[4].copy() + + +def generate_chromosomes(n: int, + wg: WorkGraph, + contractors: list[Contractor], + spec: ScheduleSpec, + work_id2index: dict[str, int], + worker_name2index: dict[str, int], + contractor2index: dict[str, int], + contractor_borders: np.ndarray, + init_chromosomes: dict[str, tuple[ChromosomeType, float, ScheduleSpec]], + rand: random.Random, + work_estimator: WorkTimeEstimator = None, + landscape: LandscapeConfiguration = LandscapeConfiguration(), + only_lft_initialization: bool = False) -> list[ChromosomeType]: """ - Generates population. + Generates n chromosomes. Do not use `generate_chromosome` function. """ @@ -304,8 +349,8 @@ def randomized_init(is_topological: bool = False) -> ChromosomeType: contractor2index, contractor_borders, schedule, spec, landscape) if only_lft_initialization: - chromosomes = [Individual(randomized_init(is_topological=False)) for _ in range(n - 1)] - chromosomes.append(Individual(init_chromosomes['lft'][0])) + chromosomes = [randomized_init(is_topological=False) for _ in range(n - 1)] + chromosomes.append(init_chromosomes['lft'][0]) return chromosomes count_for_specified_types = (n // 3) // len(init_chromosomes) @@ -332,11 +377,11 @@ def randomized_init(is_topological: bool = False) -> ChromosomeType: for generated_type in chromosome_types: match generated_type: case 'topological': - ind = Individual(randomized_init(is_topological=True)) + ind = randomized_init(is_topological=True) case 'rand_lft': - ind = Individual(randomized_init(is_topological=False)) + ind = randomized_init(is_topological=False) case _: - ind = Individual(init_chromosomes[generated_type][0]) + ind = init_chromosomes[generated_type][0] chromosomes.append(ind) @@ -353,7 +398,7 @@ def generate_chromosome(wg: WorkGraph, spec: ScheduleSpec, rand: random.Random, work_estimator: WorkTimeEstimator = DefaultWorkEstimator(), - landscape: LandscapeConfiguration = LandscapeConfiguration()) -> Individual: + landscape: LandscapeConfiguration = LandscapeConfiguration()) -> ChromosomeType: """ It is necessary to generate valid scheduling, which are satisfied to current dependencies That's why will be used the approved order of works (HEFT order and Topological sorting) @@ -386,16 +431,16 @@ def randomized_init() -> ChromosomeType: else: chromosome = randomized_init() - return Individual(chromosome) + return chromosome -def select_new_population(population: list[ChromosomeType], pop_size: int) -> list[ChromosomeType]: +def select_new_population(population: list[Individual], k: int) -> list[Individual]: """ Selection operator for genetic algorithm. - Select top n individuals in population. + Select top k individuals in population. """ population = sorted(population, key=attrgetter('fitness'), reverse=True) - return population[:pop_size] + return population[:k] def is_chromosome_correct(chromosome: ChromosomeType, node_indices: list[int], parents: dict[int, set[int]], @@ -452,8 +497,8 @@ def get_order_part(order: np.ndarray, other_order: np.ndarray) -> np.ndarray: return np.array([node for node in other_order if node not in order]) -def mate_scheduling_order(ind1: ChromosomeType, ind2: ChromosomeType, rand: random.Random, copy: bool = True) \ - -> tuple[ChromosomeType, ChromosomeType]: +def mate_scheduling_order(ind1: ChromosomeType, ind2: ChromosomeType, rand: random.Random, + copy: bool = True) -> tuple[ChromosomeType, ChromosomeType]: """ Two-Point crossover for order. @@ -464,7 +509,7 @@ def mate_scheduling_order(ind1: ChromosomeType, ind2: ChromosomeType, rand: rand :return: two mated individuals """ - child1, child2 = (Individual(copy_chromosome(ind1)), Individual(copy_chromosome(ind2))) if copy else (ind1, ind2) + child1, child2 = (copy_chromosome(ind1), copy_chromosome(ind2)) if copy else (ind1, ind2) order1, order2 = child1[0], child2[0] parent1 = ind1[0].copy() @@ -576,7 +621,7 @@ def mate_resources(ind1: ChromosomeType, ind2: ChromosomeType, rand: random.Rand :return: two mated individuals """ - child1, child2 = (Individual(copy_chromosome(ind1)), Individual(copy_chromosome(ind2))) if copy else (ind1, ind2) + child1, child2 = (copy_chromosome(ind1), copy_chromosome(ind2)) if copy else (ind1, ind2) res1, res2 = child1[1], child2[1] num_works = len(res1) @@ -778,8 +823,8 @@ def mutate_values(chromosome_part: np.ndarray, row_indexes: np.ndarray, col_inde cur_row[col_index] = rand.choices(choices, weights=weights)[0] -def mate_for_zones(ind1: ChromosomeType, ind2: ChromosomeType, - rand: random.Random, copy: bool = True) -> tuple[ChromosomeType, ChromosomeType]: +def mate_for_zones(ind1: ChromosomeType, ind2: ChromosomeType, rand: random.Random, + copy: bool = True) -> tuple[ChromosomeType, ChromosomeType]: """ CxOnePoint for zones @@ -790,7 +835,7 @@ def mate_for_zones(ind1: ChromosomeType, ind2: ChromosomeType, :return: two mated individuals """ - child1, child2 = (Individual(copy_chromosome(ind1)), Individual(copy_chromosome(ind2))) if copy else (ind1, ind2) + child1, child2 = (copy_chromosome(ind1), copy_chromosome(ind2)) if copy else (ind1, ind2) zones1 = child1[4] zones2 = child2[4] diff --git a/sampo/scheduler/genetic/schedule_builder.py b/sampo/scheduler/genetic/schedule_builder.py index 8b8f9380..b31d3034 100644 --- a/sampo/scheduler/genetic/schedule_builder.py +++ b/sampo/scheduler/genetic/schedule_builder.py @@ -32,11 +32,13 @@ def create_toolbox_and_mapping_objects(wg: WorkGraph, str, tuple[Schedule, list[GraphNode] | None, ScheduleSpec, float]], rand: random.Random, spec: ScheduleSpec = ScheduleSpec(), + fitness_weights: tuple[int | float, ...] = (-1,), work_estimator: WorkTimeEstimator = None, sgs_type: ScheduleGenerationScheme = ScheduleGenerationScheme.Parallel, assigned_parent_time: Time = Time(0), landscape: LandscapeConfiguration = LandscapeConfiguration(), only_lft_initialization: bool = False, + is_multiobjective: bool = False, verbose: bool = True) \ -> tuple[Toolbox, dict[str, int], dict[int, dict[int, Worker]], dict[int, set[int]]]: start = time.time() @@ -89,7 +91,7 @@ def create_toolbox_and_mapping_objects(wg: WorkGraph, contractor2index, contractor_borders, schedule, chromosome_spec, landscape, order), importance, chromosome_spec) - if schedule is not None else None + if schedule is not None else None for name, (schedule, order, chromosome_spec, importance) in init_schedules.items()} if verbose: @@ -120,37 +122,41 @@ def create_toolbox_and_mapping_objects(wg: WorkGraph, children, resources_border, assigned_parent_time, + fitness_weights, work_estimator, sgs_type, - only_lft_initialization), worker_name2index, worker_pool_indices, parents - - -def build_schedule(wg: WorkGraph, - contractors: list[Contractor], - worker_pool: WorkerContractorPool, - population_size: int, - generation_number: int, - mutpb_order: float, - mutpb_res: float, - mutpb_zones: float, - init_schedules: dict[str, tuple[Schedule, list[GraphNode] | None, ScheduleSpec, float]], - rand: random.Random, - spec: ScheduleSpec, - landscape: LandscapeConfiguration = LandscapeConfiguration(), - fitness_constructor: Callable[ - [Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction] = TimeFitness, - work_estimator: WorkTimeEstimator = DefaultWorkEstimator(), - sgs_type: ScheduleGenerationScheme = ScheduleGenerationScheme.Parallel, - n_cpu: int = 1, - assigned_parent_time: Time = Time(0), - timeline: Timeline | None = None, - time_border: int | None = None, - max_plateau_steps: int | None = None, - optimize_resources: bool = False, - deadline: Time | None = None, - only_lft_initialization: bool = False, - verbose: bool = True) \ - -> tuple[ScheduleWorkDict, Time, Timeline, list[GraphNode]]: + only_lft_initialization, + is_multiobjective), worker_name2index, worker_pool_indices, parents + + +def build_schedules(wg: WorkGraph, + contractors: list[Contractor], + worker_pool: WorkerContractorPool, + population_size: int, + generation_number: int, + mutpb_order: float, + mutpb_res: float, + mutpb_zones: float, + init_schedules: dict[str, tuple[Schedule, list[GraphNode] | None, ScheduleSpec, float]], + rand: random.Random, + spec: ScheduleSpec, + landscape: LandscapeConfiguration = LandscapeConfiguration(), + fitness_constructor: Callable[ + [Callable[[list[ChromosomeType]], list[Schedule]]], FitnessFunction] = TimeFitness, + fitness_weights: tuple[int | float, ...] = (-1,), + work_estimator: WorkTimeEstimator = DefaultWorkEstimator(), + sgs_type: ScheduleGenerationScheme = ScheduleGenerationScheme.Parallel, + n_cpu: int = 1, + assigned_parent_time: Time = Time(0), + timeline: Timeline | None = None, + time_border: int | None = None, + max_plateau_steps: int | None = None, + optimize_resources: bool = False, + deadline: Time | None = None, + only_lft_initialization: bool = False, + is_multiobjective: bool = False, + verbose: bool = True) \ + -> list[tuple[ScheduleWorkDict, Time, Timeline, list[GraphNode]]]: """ Genetic algorithm. Structure of chromosome: @@ -170,46 +176,49 @@ def build_schedule(wg: WorkGraph, toolbox, *mapping_objects = create_toolbox_and_mapping_objects(wg, contractors, worker_pool, population_size, mutpb_order, mutpb_res, mutpb_zones, init_schedules, - rand, spec, work_estimator, sgs_type, - assigned_parent_time, landscape, - only_lft_initialization, verbose) + rand, spec, fitness_weights, work_estimator, + sgs_type, assigned_parent_time, landscape, + only_lft_initialization, is_multiobjective, + verbose) worker_name2index, worker_pool_indices, parents = mapping_objects native = NativeWrapper(toolbox, wg, contractors, worker_name2index, worker_pool_indices, parents, work_estimator) # create population of a given size - pop = toolbox.population(n=population_size) + chromosomes = toolbox.population_chromosomes(n=population_size) if verbose: print(f'Toolbox initialization & first population took {(time.time() - start) * 1000} ms') if native.native: native_start = time.time() - best_chromosome = native.run_genetic(pop, mutpb_order, mutpb_order, mutpb_res, mutpb_res, mutpb_res, mutpb_res, - population_size) + best_chromosomes = [native.run_genetic(chromosomes, mutpb_order, mutpb_order, mutpb_res, mutpb_res, mutpb_res, + mutpb_res, population_size)] if verbose: print(f'Native evaluated in {(time.time() - native_start) * 1000} ms') else: have_deadline = deadline is not None # save best individuals - hof = tools.HallOfFame(1, similar=compare_individuals) + hof = tools.ParetoFront(similar=compare_individuals) fitness_f = fitness_constructor(native.evaluate) if not have_deadline else TimeFitness(native.evaluate) + if have_deadline: + toolbox.register_individual_constructor((-1,)) + pop = [toolbox.Individual(chromosome) for chromosome in chromosomes if toolbox.validate(chromosome)] evaluation_start = time.time() # map to each individual fitness function - pop = [ind for ind in pop if toolbox.validate(ind)] fitness = fitness_f.evaluate(pop) evaluation_time = time.time() - evaluation_start for ind, fit in zip(pop, fitness): - ind.fitness.values = [fit] + ind.fitness.values = fit hof.update(pop) - best_fitness = hof[0].fitness.values[0] + best_fitness = hof[0].fitness.values if verbose: print(f'First population evaluation took {evaluation_time * 1000} ms') @@ -219,50 +228,37 @@ def build_schedule(wg: WorkGraph, generation = 1 plateau_steps = 0 new_generation_number = generation_number if not have_deadline else generation_number // 2 - max_plateau_steps = max_plateau_steps if max_plateau_steps is not None else new_generation_number + new_max_plateau_steps = max_plateau_steps if max_plateau_steps is not None else new_generation_number - while generation <= new_generation_number and plateau_steps < max_plateau_steps \ + while generation <= new_generation_number and plateau_steps < new_max_plateau_steps \ and (time_border is None or time.time() - global_start < time_border): if verbose: print(f'-- Generation {generation}, population={len(pop)}, best fitness={best_fitness} --') rand.shuffle(pop) - offspring = [] - - for ind1, ind2 in zip(pop[::2], pop[1::2]): - # mate - offspring.extend(toolbox.mate(ind1, ind2, optimize_resources)) - - for mutant in offspring: - # mutation - if optimize_resources: - # resource borders mutation - toolbox.mutate_resource_borders(mutant) - toolbox.mutate(mutant) + offspring_chromosomes = make_offspring(toolbox, pop, optimize_resources) + offspring = [toolbox.Individual(chromosome) for chromosome in offspring_chromosomes] evaluation_start = time.time() offspring_fitness = fitness_f.evaluate(offspring) for ind, fit in zip(offspring, offspring_fitness): - ind.fitness.values = [fit] + ind.fitness.values = fit evaluation_time += time.time() - evaluation_start # renewing population pop += offspring pop = toolbox.select(pop) - hof.update([pop[0]]) + hof.update(pop) prev_best_fitness = best_fitness - best_fitness = hof[0].fitness.values[0] - if best_fitness == prev_best_fitness: - plateau_steps += 1 - else: - plateau_steps = 0 + best_fitness = hof[0].fitness.values + plateau_steps = plateau_steps + 1 if best_fitness == prev_best_fitness else 0 - if have_deadline and best_fitness <= deadline: + if have_deadline and best_fitness[0] <= deadline: if all([ind.fitness.values[0] <= deadline for ind in pop]): break @@ -272,119 +268,129 @@ def build_schedule(wg: WorkGraph, if have_deadline: - fitness_resource = fitness_constructor(native.evaluate) + fitness_resource_f = fitness_constructor(native.evaluate) + toolbox.register_individual_constructor(fitness_weights) + # clear best individuals + hof.clear() - if best_fitness > deadline: - print(f'Deadline not reached !!! Deadline {deadline} < best time {best_fitness}') - # save best individuals - hof = tools.HallOfFame(1, similar=compare_individuals) - pop = [ind for ind in pop if ind.fitness.values[0] == best_fitness] + for ind in pop: + ind.time = ind.fitness.values[0] - evaluation_start = time.time() - - fitness = fitness_resource.evaluate(pop) - for ind, res_peak in zip(pop, fitness): - ind.time = ind.fitness.values[0] - ind.fitness.values = [res_peak] - - evaluation_time += time.time() - evaluation_start - - hof.update(pop) + if best_fitness[0] > deadline: + print(f'Deadline not reached !!! Deadline {deadline} < best time {best_fitness[0]}') + pop = [ind for ind in pop if ind.time == best_fitness[0]] else: optimize_resources = True - # save best individuals - hof = tools.HallOfFame(1, similar=compare_individuals) + pop = [ind for ind in pop if ind.time <= deadline] - pop = [ind for ind in pop if ind.fitness.values[0] <= deadline] + new_pop = [] + for ind in pop: + ind_time = ind.time + new_ind = toolbox.copy_individual(ind) + new_ind.time = ind_time + new_pop.append(new_ind) + del pop + pop = new_pop - evaluation_start = time.time() + evaluation_start = time.time() - fitness = fitness_resource.evaluate(pop) - for ind, res_peak in zip(pop, fitness): - ind.time = ind.fitness.values[0] - ind.fitness.values = [res_peak] + fitness = fitness_resource_f.evaluate(pop) + for ind, res_fit in zip(pop, fitness): + ind.fitness.values = res_fit - evaluation_time += time.time() - evaluation_start + evaluation_time += time.time() - evaluation_start - hof.update(pop) + hof.update(pop) + if best_fitness[0] <= deadline: + # Optimizing resources plateau_steps = 0 new_generation_number = generation_number - generation + 1 - max_plateau_steps = max_plateau_steps if max_plateau_steps is not None else new_generation_number - best_fitness = hof[0].fitness.values[0] + new_max_plateau_steps = max_plateau_steps if max_plateau_steps is not None else new_generation_number + best_fitness = hof[0].fitness.values if len(pop) < population_size: individuals_to_copy = rand.choices(pop, k=population_size - len(pop)) copied_individuals = [toolbox.copy_individual(ind) for ind in individuals_to_copy] for copied_ind, ind in zip(copied_individuals, individuals_to_copy): - copied_ind.fitness.values = [ind.fitness.values[0]] + copied_ind.fitness.values = ind.fitness.values copied_ind.time = ind.time pop += copied_individuals - while generation <= generation_number and plateau_steps < max_plateau_steps \ + while generation <= generation_number and plateau_steps < new_max_plateau_steps \ and (time_border is None or time.time() - global_start < time_border): if verbose: print(f'-- Generation {generation}, population={len(pop)}, best peak={best_fitness} --') - rand.shuffle(pop) - - offspring = [] - for ind1, ind2 in zip(pop[::2], pop[1::2]): - # mate - offspring.extend(toolbox.mate(ind1, ind2, optimize_resources)) + rand.shuffle(pop) - for mutant in offspring: - # resource borders mutation - toolbox.mutate_resource_borders(mutant) - # other mutation - toolbox.mutate(mutant) + offspring_chromosomes = make_offspring(toolbox, pop, optimize_resources) + offspring = [toolbox.Individual(chromosome) for chromosome in offspring_chromosomes] evaluation_start = time.time() fitness = fitness_f.evaluate(offspring) for ind, t in zip(offspring, fitness): - ind.time = t + ind.time = t[0] offspring = [ind for ind in offspring if ind.time <= deadline] - fitness_res = fitness_resource.evaluate(offspring) + fitness_res = fitness_resource_f.evaluate(offspring) - for ind, res_peak in zip(offspring, fitness_res): - ind.fitness.values = [res_peak] + for ind, res_fit in zip(offspring, fitness_res): + ind.fitness.values = res_fit evaluation_time += time.time() - evaluation_start # renewing population pop += offspring pop = toolbox.select(pop) - hof.update([pop[0]]) + hof.update(pop) prev_best_fitness = best_fitness - best_fitness = hof[0].fitness.values[0] - if best_fitness == prev_best_fitness: - plateau_steps += 1 - else: - plateau_steps = 0 + best_fitness = hof[0].fitness.values + plateau_steps = plateau_steps + 1 if best_fitness == prev_best_fitness else 0 generation += 1 native.close() if verbose: - print(f'Final time: {best_fitness}') + print(f'Final fitness: {best_fitness}') print(f'Generations processing took {(time.time() - start) * 1000} ms') print(f'Full genetic processing took {(time.time() - global_start) * 1000} ms') print(f'Evaluation time: {evaluation_time * 1000}') - best_chromosome = hof[0] + best_chromosomes = [chromosome for chromosome in hof] + + best_schedules = [toolbox.chromosome_to_schedule(best_chromosome, landscape=landscape, timeline=timeline) + for best_chromosome in best_chromosomes] + best_schedules = [({node.id: work for node, work in scheduled_works.items()}, + schedule_start_time, timeline, order_nodes) + for scheduled_works, schedule_start_time, timeline, order_nodes in best_schedules] + + return best_schedules + + +def compare_individuals(first: ChromosomeType, second: ChromosomeType) -> bool: + return ((first[0] == second[0]).all() and (first[1] == second[1]).all() and (first[2] == second[2]).all() + or first.fitness == second.fitness) + - scheduled_works, schedule_start_time, timeline, order_nodes = toolbox.chromosome_to_schedule(best_chromosome, - landscape=landscape, - timeline=timeline) +def make_offspring(toolbox: Toolbox, population: list[ChromosomeType], optimize_resources: bool) \ + -> list[ChromosomeType]: + offspring = [] - return {node.id: work for node, work in scheduled_works.items()}, schedule_start_time, timeline, order_nodes + for ind1, ind2 in zip(population[::2], population[1::2]): + # mate + offspring.extend(toolbox.mate(ind1, ind2, optimize_resources)) + for mutant in offspring: + if optimize_resources: + # resource borders mutation + toolbox.mutate_resource_borders(mutant) + # other mutation + toolbox.mutate(mutant) -def compare_individuals(first: tuple[ChromosomeType], second: tuple[ChromosomeType]) -> bool: - return (first[0] == second[0]).all() and (first[1] == second[1]).all() and (first[2] == second[2]).all() + return offspring diff --git a/sampo/utilities/resource_usage.py b/sampo/utilities/resource_usage.py index 2399d817..05c13e4d 100644 --- a/sampo/utilities/resource_usage.py +++ b/sampo/utilities/resource_usage.py @@ -9,7 +9,7 @@ def get_total_resources_usage(schedule: Schedule, resources_names: Iterable[str] | None = None) -> dict[str, np.ndarray]: df = schedule.full_schedule_df - points = df[['start', 'finish']].to_numpy().copy() + points = df[['start', 'finish']].to_numpy() points = SortedList(set(points.flatten())) usage = defaultdict(lambda: np.zeros_like(points)) @@ -20,7 +20,7 @@ def get_total_resources_usage(schedule: Schedule, resources_names: Iterable[str] start = points.bisect_left(swork.start_time) finish = points.bisect_left(swork.finish_time) for worker in swork.workers: - if worker.name in resources_names or is_none: + if is_none or worker.name in resources_names: usage[worker.name][start: finish] += worker.count return usage diff --git a/tests/scheduler/genetic/full_scheduling.py b/tests/scheduler/genetic/full_scheduling.py index ba6e1980..8f633b8a 100644 --- a/tests/scheduler/genetic/full_scheduling.py +++ b/tests/scheduler/genetic/full_scheduling.py @@ -1,12 +1,12 @@ from sampo.scheduler import GeneticScheduler -def test_multiprocessing(setup_scheduler_parameters): +def test_genetic_scheduling(setup_scheduler_parameters): setup_wg, setup_contractors, setup_landscape = setup_scheduler_parameters genetic = GeneticScheduler(number_of_generation=10, mutate_order=0.05, - mutate_resources=0.005, + mutate_resources=0.05, size_of_population=50) - genetic.schedule(setup_wg, setup_contractors, landscape=setup_landscape) + genetic.schedule(setup_wg, setup_contractors, validate=True, landscape=setup_landscape) diff --git a/tests/scheduler/genetic/multiobjective_scheduling.py b/tests/scheduler/genetic/multiobjective_scheduling.py new file mode 100644 index 00000000..3ea3a943 --- /dev/null +++ b/tests/scheduler/genetic/multiobjective_scheduling.py @@ -0,0 +1,20 @@ +from sampo.scheduler.genetic import GeneticScheduler, TimeAndResourcesFitness, ScheduleGenerationScheme +from sampo.utilities.resource_usage import resources_peaks_sum + + +def test_multiobjective_genetic_scheduling(setup_scheduler_parameters): + setup_wg, setup_contractors, setup_landscape = setup_scheduler_parameters + + genetic = GeneticScheduler(number_of_generation=100, + mutate_order=0.05, + mutate_resources=0.05, + size_of_population=50, + fitness_constructor=TimeAndResourcesFitness, + fitness_weights=(-1, -1), + optimize_resources=True, + sgs_type=ScheduleGenerationScheme.Serial) + + schedules = genetic.schedule_multiobjective(setup_wg, setup_contractors, validate=True, landscape=setup_landscape) + assert isinstance(schedules, list) and len(schedules) + fitnesses = [(schedule.execution_time.value, resources_peaks_sum(schedule)) for schedule in schedules] + print('\nPareto-efficient fitnesses:\n', fitnesses) diff --git a/tests/scheduler/genetic/operators_test.py b/tests/scheduler/genetic/operators_test.py index 31e041cc..516d6cba 100644 --- a/tests/scheduler/genetic/operators_test.py +++ b/tests/scheduler/genetic/operators_test.py @@ -51,7 +51,7 @@ def test_mate_order(setup_toolbox, setup_wg): tb, _, _, _, _, _ = setup_toolbox _, _, _, population_size = get_params(setup_wg.vertex_count) - population = tb.population(n=population_size) + population = tb.population_chromosomes(n=population_size) for i in range(TEST_ITERATIONS): individual1, individual2 = population[:2] @@ -71,7 +71,7 @@ def test_mate_resources(setup_toolbox, setup_wg): tb, resources_border, _, _, _, _ = setup_toolbox _, _, _, population_size = get_params(setup_wg.vertex_count) - population = tb.population(n=population_size) + population = tb.population_chromosomes(n=population_size) for i in range(TEST_ITERATIONS): individual1, individual2 = random.sample(population, 2) diff --git a/tests/scheduler/resources_in_time/basic_res_test.py b/tests/scheduler/resources_in_time/basic_res_test.py index c8ead55a..d9cf35e5 100644 --- a/tests/scheduler/resources_in_time/basic_res_test.py +++ b/tests/scheduler/resources_in_time/basic_res_test.py @@ -1,8 +1,7 @@ import pytest import math -from sampo.scheduler.genetic.base import GeneticScheduler -from sampo.scheduler.genetic.operators import DeadlineResourcesFitness, SumOfResourcesPeaksFitness +from sampo.scheduler.genetic import GeneticScheduler, DeadlineResourcesFitness, SumOfResourcesPeaksFitness from sampo.scheduler.heft.base import HEFTScheduler from sampo.scheduler.resources_in_time.average_binary_search import AverageBinarySearchResourceOptimizingScheduler from sampo.utilities.resource_usage import resources_costs_sum, resources_peaks_sum @@ -89,7 +88,7 @@ def test_lexicographic_genetic_deadline_planning(setup_scheduler_parameters): scheduler_combined = GeneticScheduler(number_of_generation=5, mutate_order=0.05, - mutate_resources=0.005, + mutate_resources=0.05, size_of_population=50, fitness_constructor=DeadlineResourcesFitness.prepare(deadline), optimize_resources=True, @@ -99,7 +98,7 @@ def test_lexicographic_genetic_deadline_planning(setup_scheduler_parameters): scheduler_lexicographic = GeneticScheduler(number_of_generation=5, mutate_order=0.05, - mutate_resources=0.005, + mutate_resources=0.05, size_of_population=50, fitness_constructor=SumOfResourcesPeaksFitness, verbose=False)