diff --git a/package/samplers/moead/LICENSE b/package/samplers/moead/LICENSE new file mode 100644 index 00000000..31726532 --- /dev/null +++ b/package/samplers/moead/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package/samplers/moead/README.md b/package/samplers/moead/README.md new file mode 100644 index 00000000..d0de99db --- /dev/null +++ b/package/samplers/moead/README.md @@ -0,0 +1,74 @@ +--- +author: Hiroaki Natsume +title: MOEA/D sampler +description: Sampler using MOEA/D algorithm. MOEA/D stands for "Multi-Objective Evolutionary Algorithm based on Decomposition. +tags: [sampler, multiobjective] +optuna_versions: [4.0.0] +license: MIT License +--- + +## Abstract + +Sampler using MOEA/D algorithm. MOEA/D stands for "Multi-Objective Evolutionary Algorithm based on Decomposition. + +This sampler is specialized for multiobjective optimization. The objective function is internally decomposed into multiple single-objective subproblems to perform optimization. + +## Class or Function Names + +- MOEADSampler + +## Installation + +``` +pip install scipy +``` + +## Example + +```python +import optuna +import optunahub + +def objective(trial: optuna.Trial) -> tuple[float, float]: + x = trial.suggest_float("x", 0, 5) + y = trial.suggest_float("y", 0, 3) + + v0 = 4 * x**2 + 4 * y**2 + v1 = (x - 5) ** 2 + (y - 5) ** 2 + return v0, v1 + + +if __name__ == "__main__": + population_size = 100 + n_trials = 1000 + + mod = optunahub.load_module("samplers/moead") + sampler = mod.MOEADSampler( + population_size=population_size, + scalar_aggregation_func="tchebycheff", + n_neighbors=population_size // 10, + ) + study = optuna.create_study(sampler=sampler) + study.optimize(objective, n_trials=n_trials) +``` + +## Others + +Comparison between Random, NSGAII and MOEA/D with ZDT1 as the objective function. +See `compare_2objective.py` in moead directory for details. + +### Pareto Front Plot + +| MOEA/D | NSGAII | Random | +| --------------------------- | ---------------------------- | ---------------------------- | +| ![MOEA/D](images/moead.png) | ![NSGAII](images/nsgaii.png) | ![Random](images/random.png) | + +### Compare + +![Compare](images/compare_pareto_front.png) + +### Reference + +Q. Zhang and H. Li, +"MOEA/D: A Multiobjective Evolutionary Algorithm Based on Decomposition," in IEEE Transactions on Evolutionary Computation, vol. 11, no. 6, pp. 712-731, Dec. 2007, +doi: 10.1109/TEVC.2007.892759. diff --git a/package/samplers/moead/__init__.py b/package/samplers/moead/__init__.py new file mode 100644 index 00000000..e16bbff9 --- /dev/null +++ b/package/samplers/moead/__init__.py @@ -0,0 +1,4 @@ +from .moead import MOEADSampler + + +__all__ = ["MOEADSampler"] diff --git a/package/samplers/moead/_child_generation_strategy.py b/package/samplers/moead/_child_generation_strategy.py new file mode 100644 index 00000000..98ed6147 --- /dev/null +++ b/package/samplers/moead/_child_generation_strategy.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence + +import numpy as np +from optuna import Study +from optuna._transform import _SearchSpaceTransform +from optuna.distributions import BaseDistribution +from optuna.distributions import FloatDistribution +from optuna.distributions import IntDistribution +from optuna.samplers._lazy_random_state import LazyRandomState +from optuna.samplers.nsgaii._crossover import _is_contained +from optuna.samplers.nsgaii._crossover import _try_crossover +from optuna.samplers.nsgaii._crossovers._base import BaseCrossover +from optuna.trial import FrozenTrial + + +_NUMERICAL_DISTRIBUTIONS = ( + FloatDistribution, + IntDistribution, +) + + +class MOEAdChildGenerationStrategy: + """Generate a child parameter from the given parent population by MOEA/D algorithm. + Args: + study: + Target study object. + search_space: + A dictionary containing the parameter names and parameter's distributions. + parent_population: + A list of trials that are selected as parent population. + Returns: + A dictionary containing the parameter names and parameter's values. + """ + + def __init__( + self, + *, + mutation_prob: float | None = None, + crossover: BaseCrossover, + crossover_prob: float, + swapping_prob: float, + rng: LazyRandomState, + ) -> None: + if not (mutation_prob is None or 0.0 <= mutation_prob <= 1.0): + raise ValueError( + "`mutation_prob` must be None or a float value within the range [0.0, 1.0]." + ) + + if not (0.0 <= crossover_prob <= 1.0): + raise ValueError("`crossover_prob` must be a float value within the range [0.0, 1.0].") + + if not (0.0 <= swapping_prob <= 1.0): + raise ValueError("`swapping_prob` must be a float value within the range [0.0, 1.0].") + + if not isinstance(crossover, BaseCrossover): + raise ValueError( + f"'{crossover}' is not a valid crossover." + " For valid crossovers see" + " https://optuna.readthedocs.io/en/stable/reference/samplers.html." + ) + + self._crossover_prob = crossover_prob + self._mutation_prob = mutation_prob + self._swapping_prob = swapping_prob + self._crossover = crossover + self._rng = rng + self._subproblem_id = 0 + + def __call__( + self, + study: Study, + search_space: dict[str, BaseDistribution], + parent_population: list[FrozenTrial], + neighbors: dict[int, list[int]], + ) -> dict[str, Any]: + """Generate a child parameter from the given parent population by NSGA-II algorithm. + Args: + study: + Target study object. + search_space: + A dictionary containing the parameter names and parameter's distributions. + parent_population: + A list of trials that are selected as parent population. + neighbors: + A dictionary containing the subproblem id and its neighboring subproblems. + Returns: + A dictionary containing the parameter names and parameter's values. + """ + subproblem_parent_population = [ + parent_population[i] for i in neighbors[self._subproblem_id] + ] + + # We choose a child based on the specified crossover method. + if self._rng.rng.rand() < self._crossover_prob: + child_params = self._perform_crossover( + self._crossover, + study, + subproblem_parent_population, + search_space, + self._rng.rng, + self._swapping_prob, + ) + else: + parent_population_size = len(parent_population) + parent_params = parent_population[self._rng.rng.choice(parent_population_size)].params + child_params = {name: parent_params[name] for name in search_space.keys()} + + n_params = len(child_params) + if self._mutation_prob is None: + mutation_prob = 1.0 / max(1.0, n_params) + else: + mutation_prob = self._mutation_prob + + params = {} + for param_name in child_params.keys(): + if self._rng.rng.rand() >= mutation_prob: + params[param_name] = child_params[param_name] + + self._subproblem_id += 1 + if self._subproblem_id >= len(neighbors): + self._subproblem_id = 0 + return params + + def _perform_crossover( + self, + crossover: BaseCrossover, + study: Study, + parent_population: Sequence[FrozenTrial], + search_space: Dict[str, BaseDistribution], + rng: np.random.RandomState, + swapping_prob: float, + ) -> Dict[str, Any]: + numerical_search_space: Dict[str, BaseDistribution] = {} + categorical_search_space: Dict[str, BaseDistribution] = {} + for key, value in search_space.items(): + if isinstance(value, _NUMERICAL_DISTRIBUTIONS): + numerical_search_space[key] = value + else: + categorical_search_space[key] = value + + numerical_transform: Optional[_SearchSpaceTransform] = None + if len(numerical_search_space) != 0: + numerical_transform = _SearchSpaceTransform(numerical_search_space) + + while True: # Repeat while parameters lie outside search space boundaries. + parents = self._select_parents(crossover, parent_population, rng) + child_params = _try_crossover( + parents, + crossover, + study, + rng, + swapping_prob, + categorical_search_space, + numerical_search_space, + numerical_transform, + ) + + if _is_contained(child_params, search_space): + break + + return child_params + + def _select_parents( + self, + crossover: BaseCrossover, + parent_population: Sequence[FrozenTrial], + rng: np.random.RandomState, + ) -> List[FrozenTrial]: + parents: List[FrozenTrial] = rng.choice( + np.array(parent_population), crossover.n_parents, replace=False + ).tolist() + return parents diff --git a/package/samplers/moead/_elite_population_selection_strategy.py b/package/samplers/moead/_elite_population_selection_strategy.py new file mode 100644 index 00000000..0e608a9e --- /dev/null +++ b/package/samplers/moead/_elite_population_selection_strategy.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import numpy as np +from optuna import Study +from optuna.study import StudyDirection +from optuna.trial import FrozenTrial +from scipy.spatial import cKDTree +from scipy.stats import qmc + +from ._scalar_aggregation_func import tchebycheff +from ._scalar_aggregation_func import weighted_sum + + +class MOEAdElitePopulationSelectionStrategy: + def __init__( + self, + seed: int | None, + population_size: int, + n_neighbors: int, + scalar_aggregation_func: str, + ) -> None: + self._seed = seed + self._population_size = population_size + self._n_neighbors = n_neighbors + self._weight_vectors = None + + if scalar_aggregation_func == "tchebycheff": + self._scalar_aggregation_func = tchebycheff + elif scalar_aggregation_func == "weighted_sum": + self._scalar_aggregation_func = weighted_sum + else: + raise ValueError( + "`scalar_aggregation_func` must be one of 'weighted_sum', 'tchebycheff'." + ) + + def __call__(self, study: Study, population: list[FrozenTrial]) -> list[FrozenTrial]: + if self._weight_vectors is None: + weight_vectors = self._generate_weight_vectors( + self._population_size, len(study.directions) + ) + self._compute_neighborhoods(weight_vectors) + + if len(population) == self._population_size: + return population + + self._update_reference_point(study.directions, population) + + if self._weight_vectors is not None: + return self._update_neighboring_solutions(population, self._weight_vectors) + else: + raise ValueError("Weight vectors are not generated.") + + def _update_neighboring_solutions( + self, population: list[FrozenTrial], weight_vectors: np.ndarray + ) -> list[FrozenTrial]: + elite_population: list[FrozenTrial] = [] + offset = len(population) // 2 + + for id, neighbor_ids in self._neighbors.items(): + id_old = offset + id + elite = population[id_old] + lambda_ = weight_vectors[id] + + g_old = self._scalar_aggregation_func( + lambda_, population[id_old], self._reference_point, self._nadir_point + ) + for n_id in neighbor_ids: + # check new population + target_population = population[n_id] + g_new = self._scalar_aggregation_func( + lambda_, target_population, self._reference_point, self._nadir_point + ) + if g_new < g_old: + elite = target_population + g_old = g_new + + # check old population + target_population = population[n_id + offset] + g_new = self._scalar_aggregation_func( + lambda_, target_population, self._reference_point, self._nadir_point + ) + if g_new < g_old: + elite = target_population + g_old = g_new + + elite_population.append(elite) + + return elite_population + + def _update_reference_point( + self, directions: list[StudyDirection], population: list[FrozenTrial] + ) -> None: + self._reference_point = [] + self._nadir_point = [] # using for normalize of subproblem objective values + + for i, direction in enumerate(directions): + target_values = np.array([trial.values[i] for trial in population]) + if direction == StudyDirection.MINIMIZE: + self._reference_point.append(np.min(target_values, axis=0)) + self._nadir_point.append(np.max(target_values, axis=0)) + else: + self._reference_point.append(np.max(target_values, axis=0)) + self._nadir_point.append(np.min(target_values, axis=0)) + + # More uniform sequences generation method is better. + def _generate_weight_vectors(self, n_vector: int, n_objective: int) -> np.ndarray: + if n_objective == 2: + x = np.linspace(0, 1, n_vector) + y = 1 - x + self._weight_vectors = np.column_stack((x, y)) + else: + sampler = qmc.Sobol(d=n_objective, scramble=True, optimization="lloyd") + sample = sampler.random_base2(m=int(np.ceil(np.log2(n_vector)))) + vectors = sample[:n_vector] + self._weight_vectors = vectors / np.sum(vectors, axis=1, keepdims=True) + + return self._weight_vectors + + def _compute_neighborhoods(self, weight_vectors: np.ndarray) -> None: + self._neighbors: dict[int, list[int]] = {} + + tree = cKDTree(weight_vectors) + for i, weight_vector in enumerate(weight_vectors): + _, idx = tree.query(weight_vector, k=self._n_neighbors + 1) + + # include itself, first element is itself + self._neighbors[i] = idx diff --git a/package/samplers/moead/_scalar_aggregation_func.py b/package/samplers/moead/_scalar_aggregation_func.py new file mode 100644 index 00000000..6c13dab6 --- /dev/null +++ b/package/samplers/moead/_scalar_aggregation_func.py @@ -0,0 +1,28 @@ +import numpy as np +from optuna.trial import FrozenTrial + + +def weighted_sum( + weight_vector: list[float], + trial: FrozenTrial, + reference_point: list[float], + nadir_point: list[float], +) -> float: + lambda_ = np.array(weight_vector) + value = np.array(trial.values) + ref = np.array(reference_point) + nadir = np.array(nadir_point) + return float(np.sum(lambda_ * (value - ref) / (nadir - ref))) + + +def tchebycheff( + weight_vector: list[float], + trial: FrozenTrial, + reference_point: list[float], + nadir_point: list[float], +) -> float: + lambda_ = np.array(weight_vector) + value = np.array(trial.values) + ref = np.array(reference_point) + nadir = np.array(nadir_point) + return float(np.max(lambda_ * np.abs((value - ref) / (nadir - ref)))) diff --git a/package/samplers/moead/compare_2objectives.py b/package/samplers/moead/compare_2objectives.py new file mode 100644 index 00000000..86add4aa --- /dev/null +++ b/package/samplers/moead/compare_2objectives.py @@ -0,0 +1,54 @@ +import numpy as np +import optuna +import optunahub + + +def objective(trial: optuna.Trial) -> tuple[float, float]: + # ZDT1 + n_variables = 30 + + x = np.array([trial.suggest_float(f"x{i}", 0, 1) for i in range(n_variables)]) + g = 1 + 9 * np.sum(x[1:]) / (n_variables - 1) + f1 = x[0] + f2 = g * (1 - (f1 / g) ** 0.5) + + return f1, f2 + + +if __name__ == "__main__": + mod = optunahub.load_module("samplers/moead") + + seed = 42 + population_size = 100 + n_trials = 10000 + crossover = optuna.samplers.nsgaii.BLXAlphaCrossover() + samplers = [ + optuna.samplers.RandomSampler(seed=seed), + optuna.samplers.NSGAIISampler( + seed=seed, + population_size=population_size, + crossover=crossover, + ), + mod.MOEADSampler( + seed=seed, + population_size=population_size, + n_neighbors=population_size // 5, + scalar_aggregation_func="tchebycheff", + crossover=crossover, + ), + ] + studies = [] + for sampler in samplers: + study = optuna.create_study( + sampler=sampler, + study_name=f"{sampler.__class__.__name__}", + directions=["minimize", "minimize"], + ) + study.optimize(objective, n_trials=n_trials) + studies.append(study) + + optuna.visualization.plot_pareto_front(study).show() + + m = optunahub.load_module("visualization/plot_pareto_front_multi") + fig = m.plot_pareto_front(studies) + fig.show() diff --git a/package/samplers/moead/example.py b/package/samplers/moead/example.py new file mode 100644 index 00000000..c5944541 --- /dev/null +++ b/package/samplers/moead/example.py @@ -0,0 +1,33 @@ +import optuna +import optunahub + + +def objective(trial: optuna.Trial) -> tuple[float, float]: + x = trial.suggest_float("x", 0, 5) + y = trial.suggest_float("y", 0, 3) + + v0 = 4 * x**2 + 4 * y**2 + v1 = (x - 5) ** 2 + (y - 5) ** 2 + return v0, v1 + + +if __name__ == "__main__": + mod = optunahub.load_module("samplers/moead") + + population_size = 100 + n_trials = 1000 + crossover = optuna.samplers.nsgaii.BLXAlphaCrossover() + sampler = mod.MOEADSampler( + population_size=population_size, + scalar_aggregation_func="tchebycheff", + n_neighbors=population_size // 10, + crossover=crossover, + ) + study = optuna.create_study( + sampler=sampler, + study_name=f"{sampler.__class__.__name__}", + directions=["minimize", "minimize"], + ) + study.optimize(objective, n_trials=n_trials) + + optuna.visualization.plot_pareto_front(study).show() diff --git a/package/samplers/moead/images/compare_pareto_front.png b/package/samplers/moead/images/compare_pareto_front.png new file mode 100644 index 00000000..b2b49205 Binary files /dev/null and b/package/samplers/moead/images/compare_pareto_front.png differ diff --git a/package/samplers/moead/images/moead.png b/package/samplers/moead/images/moead.png new file mode 100644 index 00000000..6371511c Binary files /dev/null and b/package/samplers/moead/images/moead.png differ diff --git a/package/samplers/moead/images/nsgaii.png b/package/samplers/moead/images/nsgaii.png new file mode 100644 index 00000000..d104b2f6 Binary files /dev/null and b/package/samplers/moead/images/nsgaii.png differ diff --git a/package/samplers/moead/images/random.png b/package/samplers/moead/images/random.png new file mode 100644 index 00000000..4734721b Binary files /dev/null and b/package/samplers/moead/images/random.png differ diff --git a/package/samplers/moead/moead.py b/package/samplers/moead/moead.py new file mode 100644 index 00000000..5925bb09 --- /dev/null +++ b/package/samplers/moead/moead.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from collections import defaultdict +import hashlib +from typing import Any +from typing import Dict +from typing import TYPE_CHECKING + +import optuna +from optuna.distributions import BaseDistribution +from optuna.samplers import BaseSampler +from optuna.samplers import RandomSampler +from optuna.samplers._lazy_random_state import LazyRandomState +from optuna.samplers.nsgaii._crossovers._base import BaseCrossover +from optuna.samplers.nsgaii._crossovers._uniform import UniformCrossover +from optuna.search_space import IntersectionSearchSpace +from optuna.trial import FrozenTrial + +from ._child_generation_strategy import MOEAdChildGenerationStrategy +from ._elite_population_selection_strategy import MOEAdElitePopulationSelectionStrategy + + +if TYPE_CHECKING: + from optuna.study import Study + +# Define key names of `Trial.system_attrs`. +_GENERATION_KEY = "moead:generation" +_POPULATION_CACHE_KEY_PREFIX = "moead:population" + + +class MOEADSampler(BaseSampler): + def __init__( + self, + *, + population_size: int = 100, + n_neighbors: int | None = None, + scalar_aggregation_func: str = "tchebycheff", + mutation_prob: float | None = None, + crossover: BaseCrossover | None = None, + crossover_prob: float = 0.9, + swapping_prob: float = 0.5, + seed: int | None = None, + ) -> None: + """Multi-objective sampler using the MOEA/D algorithm. + + MOEA/D stands for "Multi-Objective Evolutionary Algorithm based on Decomposition. + + For more information about MOEA/D, please refer to the following paper: + + - `MOEA/D: A Multiobjective Evolutionary Algorithm Based on Decomposition`__ + + Args: + seed: + Seed for random number generator. + + n_neighbors: + The number of the weight vectors in the neighborhood of each weight vector. + The larger this value, the more weight is applied to the exploration. + + scalar_aggregation_function: + The scalar aggregation function to use. The default is "tchebycheff". Other options is "weight_sum". + + population_size: + Number of individuals (trials) in a generation. + ``population_size`` must be greater than or equal to ``crossover.n_parents``. + For :class:`~optuna.samplers.nsgaii.UNDXCrossover` and + :class:`~optuna.samplers.nsgaii.SPXCrossover`, ``n_parents=3``, and for the other + algorithms, ``n_parents=2``. + + mutation_prob: + Probability of mutating each parameter when creating a new individual. + If :obj:`None` is specified, the value ``1.0 / len(parent_trial.params)`` is used + where ``parent_trial`` is the parent trial of the target individual. + + crossover: + Crossover to be applied when creating child individuals. + For more information on each of the crossover method, please refer to + optuna crossover documentation. + + crossover_prob: + Probability that a crossover (parameters swapping between parents) will occur + when creating a new individual. + + swapping_prob: + Probability of swapping each parameter of the parents during crossover. + """ + if population_size < 2: + raise ValueError("`population_size` must be greater than or equal to 2.") + + if n_neighbors is None: + n_neighbors = population_size // 10 + elif n_neighbors >= population_size: + raise ValueError("`n_neighbors` must be less than `population_size`.") + + if scalar_aggregation_func not in ["weighted_sum", "tchebycheff", "PBI"]: + raise ValueError( + "`scalar_aggregation_function` must be one of 'weighted_sum', 'tchebycheff', 'PBI'." + ) + + if crossover is None: + crossover = UniformCrossover(swapping_prob) + self._population_size = population_size + self._random_sampler = RandomSampler(seed=seed) + self._rng = LazyRandomState(seed) + self._search_space = IntersectionSearchSpace() + self._seed = seed + self._weight_vectors = None + + self._elite_population_selection_strategy = MOEAdElitePopulationSelectionStrategy( + seed=seed, + population_size=population_size, + n_neighbors=n_neighbors, + scalar_aggregation_func=scalar_aggregation_func, + ) + self._child_generation_strategy = MOEAdChildGenerationStrategy( + crossover_prob=crossover_prob, + mutation_prob=mutation_prob, + swapping_prob=swapping_prob, + crossover=crossover, + rng=self._rng, + ) + + def sample_relative( + self, + study: Study, + trial: FrozenTrial, + search_space: Dict[str, BaseDistribution], + ) -> Dict[str, Any]: + parent_generation, parent_population = self._collect_parent_population(study) + + generation = parent_generation + 1 + study._storage.set_trial_system_attr(trial._trial_id, _GENERATION_KEY, generation) + + if parent_generation < 0: + return {} + + neighbors = self._elite_population_selection_strategy._neighbors + return self._child_generation_strategy(study, search_space, parent_population, neighbors) + + def sample_independent( + self, + study: Study, + trial: FrozenTrial, + param_name: str, + param_distribution: BaseDistribution, + ) -> Any: + # Following parameters are randomly sampled here. + # 1. A parameter in the initial population/first generation. + # 2. A parameter to mutate. + # 3. A parameter excluded from the intersection search space. + + return self._random_sampler.sample_independent( + study, trial, param_name, param_distribution + ) + + def reseed_rng(self) -> None: + return self._random_sampler.reseed_rng() + + def infer_relative_search_space( + self, study: Study, trial: FrozenTrial + ) -> dict[str, BaseDistribution]: + search_space: dict[str, BaseDistribution] = {} + for name, distribution in self._search_space.calculate(study).items(): + if distribution.single(): + # The `untransform` method of `optuna._transform._SearchSpaceTransform` + # does not assume a single value, + # so single value objects are not sampled with the `sample_relative` method, + # but with the `sample_independent` method. + continue + search_space[name] = distribution + return search_space + + def _collect_parent_population(self, study: Study) -> tuple[int, list[FrozenTrial]]: + trials = study._get_trials(deepcopy=False, use_cache=True) + + generation_to_runnings = defaultdict(list) + generation_to_population = defaultdict(list) + for trial in trials: + if _GENERATION_KEY not in trial.system_attrs: + continue + + generation = trial.system_attrs[_GENERATION_KEY] + if trial.state != optuna.trial.TrialState.COMPLETE: + if trial.state == optuna.trial.TrialState.RUNNING: + generation_to_runnings[generation].append(trial) + continue + + generation_to_population[generation].append(trial) + + hasher = hashlib.sha256() + parent_population: list[FrozenTrial] = [] + parent_generation = -1 + while True: + generation = parent_generation + 1 + population = generation_to_population[generation] + + if len(population) < self._population_size: + break + + for trial in generation_to_runnings[generation]: + hasher.update(bytes(str(trial.number), "utf-8")) + + cache_key = "{}:{}".format(_POPULATION_CACHE_KEY_PREFIX, hasher.hexdigest()) + study_system_attrs = study._storage.get_study_system_attrs(study._study_id) + cached_generation, cached_population_numbers = study_system_attrs.get( + cache_key, (-1, []) + ) + if cached_generation >= generation: + generation = cached_generation + population = [trials[n] for n in cached_population_numbers] + else: + population.extend(parent_population) + population = self._elite_population_selection_strategy(study, population) + + if len(generation_to_runnings[generation]) == 0: + population_numbers = [t.number for t in population] + study._storage.set_study_system_attr( + study._study_id, cache_key, (generation, population_numbers) + ) + + parent_generation = generation + parent_population = population + + return parent_generation, parent_population