diff --git a/.github/workflows/pyblish_pypi.yml b/.github/workflows/pyblish_pypi.yml new file mode 100644 index 000000000..e071a794a --- /dev/null +++ b/.github/workflows/pyblish_pypi.yml @@ -0,0 +1,30 @@ +name: Build and publish package to PyPi + +on: + workflow_dispatch: + +jobs: + build_and_publish: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ 3.9 ] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Build package + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade setuptools wheel + python setup.py sdist bdist_wheel + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.PYPI_PASSWORD }} + repository_url: https://upload.pypi.org/legacy/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 19362d577..186b2fce1 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ celerybeat.pid # Environments .env .venv +.venv* env/ venv/ ENV/ diff --git a/README.rst b/README.rst index 48f08d530..95dc9e19a 100644 --- a/README.rst +++ b/README.rst @@ -5,12 +5,18 @@ .. list-table:: :stub-columns: 1 + * - tests + - | |build| * - docs - |docs| * - license - | |license| * - support - | |tg| + * - gitlab + - | |gitlab| + * - funding + - | |ITMO| |NCCR| .. end-badges @@ -41,8 +47,9 @@ The dynamics of the optimisation can be visualized as (breakwaters optimisation How to use ========== -All details about first steps with GEFEST might be found in the `quick start guide `__ -and in the `tutorial for novices `__ +All details about first steps with GEFEST might be found in the `quick start guide `__. + +Tutorals for more spicific use cases can be found `tutorial section of docs `__. Project Structure ================= @@ -58,10 +65,16 @@ The repository includes the following directories: Cases and examples ================== +**Note**: To run the examples below, the old kernel gefest version, which can be installed on python 3.7 with: + +.. code:: bash + pip install git+https://github.com/aimclub/GEFEST.git@4f9c34c449c0eb65d264476e5145f09b4839cd70 - `Experiments `__ with various real and synthetic cases - `Case `__ devoted to the red blood cell traps design. +Migrated examples can be found in cases folder of the main branch. + Current R&D and future plans ============================ @@ -69,10 +82,8 @@ Currently, we are working on integration of new types of physical objects with c The major ongoing tasks: -* to make the use of GEFEST more accessible and simple for users * to integrate three dimensional physical objects * to implement gradient based approaches for optimization of physical objects -* to improve efficiency of GEFEST's standard sampler Documentation ============= @@ -116,6 +127,17 @@ Citation publisher={Elsevier} } +@inproceedings{solovev2023ai, + title={AI Framework for Generative Design of Computational Experiments with Structures in Physical Environment}, + author={Solovev, Gleb Vitalevich and Kalyuzhnaya, Anna and Hvatov, Alexander and Starodubcev, Nikita and Petrov, Oleg and Nikitin, Nikolay}, + booktitle={NeurIPS 2023 AI for Science Workshop}, + year={2023} +} + +.. |build| image:: https://github.com/aimclub/GEFEST/workflows/unit%20tests/badge.svg?branch=main + :alt: Build Status + :target: https://github.com/aimclub/GEFEST/actions + .. |docs| image:: https://readthedocs.org/projects/gefest/badge/?version=latest :target: https://gefest.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status @@ -126,4 +148,16 @@ Citation .. |tg| image:: https://img.shields.io/badge/Telegram-Group-blue.svg :target: https://t.me/gefest_helpdesk - :alt: Telegram Chat \ No newline at end of file + :alt: Telegram Chat + +.. |ITMO| image:: https://github.com/ITMO-NSS-team/open-source-ops/blob/add_badge/badges/ITMO_badge_rus.svg + :alt: Acknowledgement to ITMO + :target: https://itmo.ru + +.. |NCCR| image:: https://github.com/ITMO-NSS-team/open-source-ops/blob/add_badge/badges/NCCR_badge.svg + :alt: Acknowledgement to NCCR + :target: https://actcognitive.org/ + +.. |gitlab| image:: https://camo.githubusercontent.com/9bd7b8c5b418f1364e72110a83629772729b29e8f3393b6c86bff237a6b784f6/68747470733a2f2f62616467656e2e6e65742f62616467652f6769746c61622f6d6972726f722f6f72616e67653f69636f6e3d6769746c6162 + :alt: GitLab mirror for this repository + :target: https://gitlab.actcognitive.org/itmo-nss-team/GEFEST diff --git a/cases/sound_waves/configuration/config_parallel.py b/cases/sound_waves/configuration/config_parallel.py index b396e6cad..0f112af1e 100644 --- a/cases/sound_waves/configuration/config_parallel.py +++ b/cases/sound_waves/configuration/config_parallel.py @@ -78,10 +78,10 @@ def _evaluate(self, ind: Structure): tuner_cfg = TunerParams( tuner_type='sequential', - n_steps_tune=10, - hyperopt_dist='normal', + n_steps_tune=50, + hyperopt_dist='uniform', verbose=True, - variacne_generator=partial(percent_edge_variance, percent=0.2), + variacne_generator=partial(percent_edge_variance, percent=0.5), timeout_minutes=30, ) @@ -121,7 +121,7 @@ def _evaluate(self, ind: Structure): ], extra=5, estimation_n_jobs=-1, - n_jobs=11, + n_jobs=-1, log_dir='logs/tuners_exp', run_name='roulette_1_obj', golem_keep_histoy=True, diff --git a/cases/synthetic/circle/single_objective.py b/cases/synthetic/circle/single_objective.py index 887e82b42..53ecb5cd6 100644 --- a/cases/synthetic/circle/single_objective.py +++ b/cases/synthetic/circle/single_objective.py @@ -16,20 +16,19 @@ def __init__(self, domain: Domain, estimator: Estimator = None) -> None: def _evaluate(self, ind: Structure) -> float: - num = 3 num_polys = len(ind.polygons) loss = 0 for poly in ind.polygons: area = self.domain.geometry.get_square(poly) length = self.domain.geometry.get_length(poly) if area == 0: - ratio = None + ratio = float('inf') else: ratio = 1 - 4 * np.pi * area / length ** 2 loss += ratio - - loss = loss + 20 * abs(num_polys - num) + if num_polys > 1: + loss += 20 * abs(num_polys - 1) return loss @@ -61,7 +60,7 @@ def _evaluate(self, ind: Structure) -> float: tuner_cfg = TunerParams( tuner_type='optuna', - n_steps_tune=10, + n_steps_tune=25, hyperopt_dist='uniform', verbose=True, timeout_minutes=60, @@ -101,6 +100,7 @@ def _evaluate(self, ind: Structure) -> float: 'not_too_close_points', ], extra=5, + estimation_n_jobs=-1, n_jobs=-1, log_dir='logs', run_name='run_name', diff --git a/docs/source/gefest/installation.rst b/docs/source/gefest/installation.rst deleted file mode 100644 index 5c5885b7b..000000000 --- a/docs/source/gefest/installation.rst +++ /dev/null @@ -1,10 +0,0 @@ -Installation from GitHub -======================== - | git clone https://github.com/ITMO-NSS-team/GEFEST.git - | cd GEFEST - | pip install -r requirements.txt - | pytest -s test/ - -.. Installation from PyPI -.. ====================== -.. | pip install gefest \ No newline at end of file diff --git a/docs/source/gefest/quickstart.rst b/docs/source/gefest/quickstart.rst index 8cee123c2..ad3aa37f1 100644 --- a/docs/source/gefest/quickstart.rst +++ b/docs/source/gefest/quickstart.rst @@ -13,7 +13,7 @@ Tested on python 3.9-3.10 .. code:: - pip install https://github.com/ITMO-NSS-team/GEFEST/archive/master.zip + pip install gefest How to run ---------- @@ -62,7 +62,7 @@ Let's take a step-by-step look at how to do this. from gefest.core.opt.objective.objective import Objective from gefest.tools.estimators.estimator import Estimator -- **Step 1**. Define objectives using loss function and simulator of the physical process if required. +- **Step 1**. Define objectives using fitness function and simulator of the physical process if required. Objective for finding a polygon that seems like circle showed below. @@ -127,7 +127,7 @@ Domain describes geometric constraints for individuals. By default, the standard sampler is used. You can select another sampler or define custom for spicific task. -How to define your own samler described in the tutorials section of the documentation. +How to define your own sampler described in the tutorials section of the documentation. - **Step 4**. Define tuner configuraton. @@ -197,7 +197,7 @@ To know more about configuration options see :ref:`configuration` section of API - **Step 5**. Run generative design and results visualisation. Now you can run the optimization as it was described above in *How to run* section of this tutorial. -Let's look at how it works inside. +Let's take a look at code in `run_experiments.py` script. .. code:: python @@ -221,7 +221,7 @@ Let's look at how it works inside. # Optimized pop visualization logger.info('Collecting plots of optimized structures...') - # GIFMaker object creates mp4 series of optimized structures plots + # GIFMaker object creates mp4 from optimized structures plots gm = GIFMaker(domain=opt_params.domain) for st in tqdm(optimized_pop): gm.create_frame(st, {'Optimized': st.fitness}) diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index accffdfb9..c802ed32d 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -7,3 +7,4 @@ Tutorials optimisation tuning + sa diff --git a/docs/source/tutorials/optimisation.rst b/docs/source/tutorials/optimisation.rst index abfb3e1f1..95b538c1d 100644 --- a/docs/source/tutorials/optimisation.rst +++ b/docs/source/tutorials/optimisation.rst @@ -7,7 +7,7 @@ Optimisation Optimisers summary ------------------ -To solve the optimisation problem, 4 optimisers are available in GEFEST - 1 native and 2 based on GOLEM. +To solve the optimisation problems 4 optimisers are available in GEFEST - 1 native and 2 based on GOLEM. All of them have a single interface and can be imported from ``gefest.tools.optimizers``. .. list-table:: Optimizers comparation @@ -39,13 +39,13 @@ All of them have a single interface and can be imported from ``gefest.tools.opti - :raw-html-m2r:`Yes` -``BaseGA`` implements the base genetic algorithm, that performs generation of the initial population, +``BaseGA`` implements the base genetic algorithm, that performs sampling of the initial population, crossover and mutation operations, fitness estimation and selection. Each of the steps is encapsulated in a separate executor, which allows you to change the logic of individual steps. Thus, BaseGA essentially only implements the sequence of their call. -``StandardOptimizer`` is a wrapper for GOLEM`s ``EvoGraphOptimizer`` optimiser. -It allows to select different evolutionary schemes, adaptive mutation strategies and some other features. +``StandardOptimizer`` is a wrapper for GOLEM`s ``EvoGraphOptimizer``. +It allows to use different evolutionary schemes, adaptive mutation strategies and some other features. To use multiobjective optimisation set `golem_selection_type` in ``OptimizationParams`` config to 'spea2'. ``SurrogateOptimizer`` is the extension of ``StandardOptimizer`` with the ability @@ -64,7 +64,7 @@ How to optimise Easiest way to run optimiser described in :ref:`quickstart`. -If you want to get some more control you can do it in code by import corresponding classes: +If you want to get some more control you can do it in your code: .. code-block:: python diff --git a/docs/source/tutorials/sa.rst b/docs/source/tutorials/sa.rst new file mode 100644 index 000000000..2a18a27a3 --- /dev/null +++ b/docs/source/tutorials/sa.rst @@ -0,0 +1,39 @@ +Sensitivity analysis +==================== + +SA +-- + +Use SA to run local search near an optimized structure. + + +.. code-block:: python + + from gefest.core.configs.optimization_params import OptimizationParams + from gefest.core.geometry.datastructs.structure import Structure + from gefest.tools.tuners.sa import SensitivityAnalysis, report_viz + from matplotlib import pyplot as plt + + structure: list[Structure] | Structure = ... + opt_params: OptimizationParams = ... + + sa = SensitivityAnalysis([optimized_struct], opt_params) + res = sa.analysis() + + # plot analysis history + report_viz(res) + + + # plot initial and best structure + sv = StructVizualizer() + sv.plot_structure(res[1][0]) + sv.plot_structure(res[1][1]) + + plt.show(block=True) + + # animated history of structures during SA + gm = GIFMaker() + for st in tqdm(res[1]): + gm.create_frame(st, {'sa': st.fitness}) + + gm.make_gif('sa individuals', 500) diff --git a/docs/source/tutorials/tuning.rst b/docs/source/tutorials/tuning.rst index eb02cc0b6..a68059623 100644 --- a/docs/source/tutorials/tuning.rst +++ b/docs/source/tutorials/tuning.rst @@ -62,11 +62,12 @@ Here we will take a closer look at several ``TunerParams`` attributes which may * ``hyperopt_dist`` is the type of distribution from which random values will be taken during tuning. Available values are names of `hpyeropt hp module finctions `_. + **Note**: in GOLEM 0.4.0 part of tuners does not converts bounds into mu and sigma. Use 'uniform' to avoid invalid intervals. * ``variance_generator`` is function that generates bounds of intervals from which random values should pe picked for all components of all point in structure. If normal distribution set they will be automatically converted into means and varicances. -``verage_edge_variance`` function setes variance to 50% of average edge length for each polygon. This solution can be much more "greedy" than necessary, which can lead to many invalid intermediate variants during tuning. To improve fitness in fewer tuning steps, it is worth creating variance generation functions for selecting smaller intervals based on the conditions of a specific task. +``percent_edge_variance`` function setes variance to spicific percent of average edge length for each polygon. This solution can be much more "greedy" than necessary, which can lead to many invalid intermediate variants during tuning. To improve fitness in fewer tuning steps, it is worth creating variance generation functions for selecting smaller intervals based on the conditions of a specific task. Now that the ``OptimizationParams`` have been defined and some structures have been created, we can run tuning with couple lines of code: diff --git a/gefest/core/opt/adapters/factories.py b/gefest/core/opt/adapters/factories.py index 6b8d30d07..dfb72adde 100644 --- a/gefest/core/opt/adapters/factories.py +++ b/gefest/core/opt/adapters/factories.py @@ -6,7 +6,7 @@ class StructureFactory(RandomGraphFactory): - """Simple GEFEST sampler wrap for GOLEM RandomGraphFactory compatibility.""" + """GOLEM RandomGraphFactory version of GEFEST sampler.""" def __init__( self, @@ -19,4 +19,4 @@ def __init__( def __call__(self, *args, **kwargs) -> OptGraph: """Generates ranom GOLEM graph.""" samples = self.sampler(1) - return self.adapter(samples[0]) + return self.adapter.adapt(samples[0]) diff --git a/gefest/core/opt/objective/objective_eval.py b/gefest/core/opt/objective/objective_eval.py index ab447b607..a4647aa31 100644 --- a/gefest/core/opt/objective/objective_eval.py +++ b/gefest/core/opt/objective/objective_eval.py @@ -1,3 +1,7 @@ +from typing import Union + +from golem.utilities.data_structures import ensure_wrapped_in_sequence + from gefest.core.geometry.datastructs.structure import Structure from gefest.core.opt.objective.objective import Objective from gefest.core.utils import where @@ -20,10 +24,12 @@ def __init__( def __call__( self, - pop: list[Structure], + pop: Union[list[Structure], Structure], **kwargs, ) -> list[Structure]: """Calls objectives evaluation.""" + pop = ensure_wrapped_in_sequence(pop) + return self.set_pop_objectives(pop=pop) def set_pop_objectives( diff --git a/gefest/core/viz/struct_vizualizer.py b/gefest/core/viz/struct_vizualizer.py index baf70821d..eddd6c125 100644 --- a/gefest/core/viz/struct_vizualizer.py +++ b/gefest/core/viz/struct_vizualizer.py @@ -19,10 +19,10 @@ class StructVizualizer: """ - def __init__(self, domain: Domain): + def __init__(self, domain: Domain = None): self.domain = domain - def plot_structure(self, structs: list[Structure], domain=None, infos=None, linestyles='-'): + def plot_structure(self, structs: list[Structure], infos=None, linestyles='-', legend=False): """The method displays the given list[obj:`Structure`]. Args: @@ -52,8 +52,8 @@ def plot_structure(self, structs: list[Structure], domain=None, infos=None, line x = [pt.x for pt in boundary.points] y = [pt.y for pt in boundary.points] plt.plot(x, y, 'k') - if domain.prohibited_area: - for poly in domain.prohibited_area: + if self.domain.prohibited_area: + for poly in self.domain.prohibited_area: self.plot_poly(poly, '-', color='m') for poly in struct.polygons: @@ -62,7 +62,9 @@ def plot_structure(self, structs: list[Structure], domain=None, infos=None, line lines = [ Line2D([0], [0], color='black', linewidth=3, linestyle=style) for style in linestyles ] - plt.legend(lines, infos, loc=2) + if legend: + plt.legend(lines, infos, loc=2) + return fig def plot_poly(self, poly, linestyle, **kwargs): @@ -88,14 +90,14 @@ def plot_poly(self, poly, linestyle, **kwargs): class GIFMaker(StructVizualizer): """Smple API for saving a series of plots in mp4 with moviepy.""" - def __init__(self, domain) -> None: + def __init__(self, domain=None) -> None: super().__init__(domain=domain) self.frames = [] self.counter = 0 def create_frame(self, structure, infos): """Appends new frame from given structure.""" - fig = self.plot_structure(structure, self.domain, infos) + fig = self.plot_structure(structure, infos) numpy_fig = mplfig_to_npimage(fig) self.frames.append(numpy_fig) plt.close() diff --git a/gefest/tools/samplers/sens_analysis/__init__.py b/gefest/tools/samplers/sens_analysis/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/gefest/tools/samplers/sens_analysis/sens_sampler.py b/gefest/tools/samplers/sens_analysis/sens_sampler.py deleted file mode 100644 index a9cbb8392..000000000 --- a/gefest/tools/samplers/sens_analysis/sens_sampler.py +++ /dev/null @@ -1,59 +0,0 @@ -from copy import deepcopy -from multiprocessing import Pool - -from gefest.core.opt.constraints import check_constraints -from gefest.core.opt.operators.sensitivity_methods import get_structure_for_analysis -from gefest.core.opt.postproc.resolve_errors import postprocess -from gefest.core.structure.domain import Domain -from gefest.core.utils import project_root - -MAX_ITER = 50000 -NUM_PROC = 1 - - -class SensitivitySampler: - def __init__(self, path: str): - self.path = path - pass - - def sample(self, n_samples: int, domain: Domain, initial_state=None): - # Method for initialization of population - population_new = [] - - if initial_state is None: - while len(population_new) < n_samples: - if NUM_PROC > 1: - with Pool(NUM_PROC) as p: - new_items = p.map(self.get_pop_worker, [domain] * n_samples) - else: - new_items = [] - for _ in range(n_samples): - new_items.append(self.get_pop_worker(domain)) - - for structure in new_items: - population_new.append(structure) - if len(population_new) == n_samples: - return population_new - else: - for _ in range(n_samples): - population_new.append(deepcopy(initial_state)) - return population_new - - def get_pop_worker(self, domain: Domain): - # Create a random structure and postprocess it - structure = get_structure_for_analysis(self.path) - structure = postprocess(structure, domain) - constraints = check_constraints(structure=structure, domain=domain) - max_attempts = 3 # Number of postprocessing attempts - while not constraints: - structure = postprocess(structure, domain) - constraints = check_constraints(structure=structure, domain=domain) - max_attempts -= 1 - if max_attempts < 0: - # If the number of attempts is over, - # a new structure is created on which postprocessing is performed - structure = get_structure_for_analysis(self.path) - structure = postprocess(structure, domain) - constraints = check_constraints(structure=structure, domain=domain) - - return structure diff --git a/gefest/tools/tuners/sa.py b/gefest/tools/tuners/sa.py index 538b7733f..297ffd1ef 100644 --- a/gefest/tools/tuners/sa.py +++ b/gefest/tools/tuners/sa.py @@ -4,36 +4,39 @@ import matplotlib.pyplot as plt -from gefest.core.algs.geom.validation import intersection, out_of_bound, too_close -from gefest.core.structure.point import Point -from gefest.core.structure.structure import Structure +from gefest.core.geometry import Point, Structure +from gefest.core.opt.objective.objective_eval import ObjectivesEvaluator +from gefest.core.opt.postproc.validation import validate -class SensitivityAnalysisMethods: - """Base class consists transformation methods for sensitivity-based optimization""" +class SensitivityAnalysis: + """Base class consists transformation methods for sensitivity-based optimization.""" - def __init__(self, optimized_pop, estimator, domain, *args, **kwargs): + def __init__(self, optimized_pop, opt_params): self.optimized_structure = optimized_pop[0] - self.cost = estimator.estimate - self.input_domain = domain + self.opt_params = opt_params + self.cost = ObjectivesEvaluator( + opt_params.objectives, + opt_params.estimation_n_jobs, + ) + self.domain = opt_params.domain self.sa_time_history = [0] self.start_time = time.time() @property def get_time_history(self): - """Returns time history of optimization process""" + """Returns time history of optimization process.""" return self.sa_time_history def moving_position(self): - """Analysis of moving polygons around by different distances""" + """Analysis of moving polygons around by different distances.""" structure = self.optimized_structure print(structure) for poly_num, poly in enumerate(structure.polygons): poly.id = 'poly_' + str(poly_num) - init_fitness = round( - self.cost([structure])[0], - 3, - ) # only high of wave in multicreterial loss + + structure = self.cost(structure)[0] + init_fitness = round(structure.fitness[0], 3) # only high of wave in multicreterial loss fitness_history = [] structure_history = [] @@ -45,10 +48,10 @@ def moving_position(self): for poly_num, poly in enumerate(structure.polygons): step_fitness = 0 - max_attempts = 3 + max_attempts = 10 if poly.id != 'fixed': - moving_step = self.input_domain.geometry.get_length(polygon=poly) * 0.2 + moving_step = self.domain.geometry.get_length(polygon=poly) * 0.2 while step_fitness <= current_fitness and max_attempts > 0: step_structure, step_fitness, worse_res = self._moving_for_one_step( @@ -63,21 +66,18 @@ def moving_position(self): self.sa_time_history.append(end_step_time - self.start_time) if worse_res: - fitness_diff = round( - 100 * ((worse_res - current_fitness) / current_fitness), - 1, + diff = round( + 100 * ((worse_res - current_fitness) / current_fitness), 1, ) polygon_history.append( - f'{str(poly.id)}, step={round(moving_step)},\ - fitness=+{str(fitness_diff)}%', + f'{str(poly.id)}, step={round(moving_step)}, fitness=+{str(diff)}%' ) else: - fitness_diff = round( - 100 * ((step_fitness - current_fitness) / current_fitness), - 1, + diff = round( + 100 * ((step_fitness - current_fitness) / current_fitness), 1, ) polygon_history.append( - f'{str(poly.id)}, step={round(moving_step)}, fitness={str(fitness_diff)}%', + f'{str(poly.id)}, step={round(moving_step)}, fitness={str(diff)}%' ) if step_fitness >= current_fitness: @@ -90,11 +90,7 @@ def moving_position(self): return fitness_history, structure_history, polygon_history def _moving_for_one_step( - self, - structure: Structure, - poly_number: int, - moving_step, - init_fitness, + self, structure: Structure, poly_number: int, moving_step, init_fitness ): moved_init_poly = structure.polygons[poly_number] directions = ['north', 'south', 'east', 'west', 'n-w', 's-w', 'n-e', 's-e'] @@ -107,15 +103,11 @@ def _moving_for_one_step( moved_poly.points[idx] = self._moving_point(direct, point, moving_step) tmp_structure = deepcopy(structure) - tmp_structure.polygons[poly_number] = moved_poly - fitness = round(self.cost([tmp_structure])[0], 3) - non_invalid = not any( - [ - out_of_bound(tmp_structure, self.input_domain), - too_close(tmp_structure, self.input_domain), - intersection(tmp_structure, self.input_domain), - ], - ) + tmp_structure[poly_number] = moved_poly + tmp_structure = self.cost(tmp_structure)[0] + fitness = round(tmp_structure.fitness[0], 3) + non_invalid = validate(tmp_structure, self.opt_params.postprocess_rules, self.domain) + if fitness < init_fitness and non_invalid: results[fitness] = tmp_structure elif fitness >= init_fitness and non_invalid: @@ -145,8 +137,7 @@ def _moving_point(self, direction: str, point: Point, moving_step) -> Point: return directions[direction] def exploring_combinations(self, structure: Structure, init_fitness): - """Analysis of polygons necessity, looking for the best combination of polys""" - + """Analysis of polygons necessity, looking for the best combination of polys.""" best_fitness = [] best_structures = [] best_description = [] @@ -165,11 +156,12 @@ def exploring_combinations(self, structure: Structure, init_fitness): for unique_comb in itertools.combinations(structure.polygons, length): tmp_structure = deepcopy(structure) tmp_structure.polygons = list(unique_comb) - fitness = round(self.cost([tmp_structure])[0], 3) + tmp_structure = self.cost(tmp_structure)[0] + fitness = round(tmp_structure.fitness[0], 3) structure_history.append(tmp_structure) fitness_history.append(init_fitness) - fitness_diff = round(100 * ((fitness - init_fitness) / init_fitness), 1) + diff = round(100 * ((fitness - init_fitness) / init_fitness), 1) if fitness <= init_fitness * 1.01: best_fitness.append(fitness) @@ -178,13 +170,15 @@ def exploring_combinations(self, structure: Structure, init_fitness): ids = [] for polygon in tmp_structure.polygons: ids.append(polygon.id) - polygon_history.append(f'{str(ids)}, fitness={str(fitness_diff)}%') - best_description.append(f'{str(ids)}, fitness={str(fitness_diff)}%') + + polygon_history.append(f'{str(ids)}, fitness={str(diff)}%') + best_description.append(f'{str(ids)}, fitness={str(diff)}%') else: ids = [] for polygon in tmp_structure.polygons: ids.append(polygon.id) - polygon_history.append(f'{str(ids)}, fitness=+{str(fitness_diff)}%') + + polygon_history.append(f'{str(ids)}, fitness=+{str(diff)}%') end_step_time = time.time() self.sa_time_history.append(end_step_time - self.start_time) @@ -206,6 +200,7 @@ def exploring_combinations(self, structure: Structure, init_fitness): return fitness_history, structure_history, polygon_history def removing_points(self, structure: Structure, init_fitness): + """Analysis of the points removal.""" fitness_history = [] structure_history = [] polygon_history = [] @@ -235,12 +230,12 @@ def removing_points(self, structure: Structure, init_fitness): tmp_points.remove(point) tmp_polygon.points = tmp_points - tmp_structure.polygons[poly_number] = tmp_polygon + tmp_structure[poly_number] = tmp_polygon + tmp_structure = self.cost(tmp_structure)[0] + fitness = round(tmp_structure.fitness[0], 3) - fitness = round(self.cost([tmp_structure])[0], 3) - fitness_diff = round( - 100 * ((fitness - current_fitness) / current_fitness), - 1, + diff = round( + 100 * ((fitness - current_fitness) / current_fitness), 1, ) structure_history.append(tmp_structure) @@ -250,18 +245,17 @@ def removing_points(self, structure: Structure, init_fitness): new_polygon = tmp_polygon fitness_history.append(fitness) polygon_history.append( - f'{str(polygon.id)}, del={str(point.coords())},\ - fitness={str(fitness_diff)}%', + f'{str(polygon.id)}, del={str(point.coords)}, fitness={str(diff)}' ) else: fitness_history.append(current_fitness) polygon_history.append( - f'{str(polygon.id)}, del={str(point.coords())},\ - fitness=+{str(fitness_diff)}%', + f'{str(polygon.id)}, del={str(point.coords)}, fitness=+{str(diff)}%' ) end_step_time = time.time() self.sa_time_history.append(end_step_time - self.start_time) + new_structure = tmp_structure else: for point in polygon.points: @@ -271,12 +265,12 @@ def removing_points(self, structure: Structure, init_fitness): tmp_points.remove(point) tmp_polygon.points = tmp_points - tmp_structure.polygons[poly_number] = tmp_polygon + tmp_structure[poly_number] = tmp_polygon - fitness = round(self.cost([tmp_structure])[0], 3) - fitness_diff = round( - 100 * ((fitness - current_fitness) / current_fitness), - 1, + tmp_structure = self.cost(tmp_structure)[0] + fitness = round(tmp_structure.fitness[0], 3) + diff = round( + 100 * ((fitness - current_fitness) / current_fitness), 1, ) structure_history.append(tmp_structure) @@ -286,25 +280,24 @@ def removing_points(self, structure: Structure, init_fitness): new_polygon = tmp_polygon fitness_history.append(fitness) polygon_history.append( - f'{str(polygon.id)}, del={str(point.coords())},\ - fitness={str(fitness_diff)}%', + f'{str(polygon.id)}, del={str(point.coords)}, fitness={str(diff)}%' ) else: fitness_history.append(current_fitness) polygon_history.append( - f'{str(polygon.id)}, del={str(point.coords())},\ - fitness=+{str(fitness_diff)}%', + f'{str(polygon.id)}, del={str(point.coords)}, fitness=+{str(diff)}%' ) end_step_time = time.time() self.sa_time_history.append(end_step_time - self.start_time) + new_structure = tmp_structure return fitness_history, structure_history, polygon_history def rotate_objects(self, structure: Structure, init_fitness: int): - """Analysis of rotating polygons""" - rotate_func = self.input_domain.geometry.rotate_poly + """Analysis of rotating polygons.""" + rotate_func = self.domain.geometry.rotate_poly fitness_history = [] structure_history = [] polygon_history = [] @@ -329,33 +322,32 @@ def rotate_objects(self, structure: Structure, init_fitness: int): rotated_poly = deepcopy(poly) rotated_poly = rotate_func(rotated_poly, angle=angle) - tmp_structure.polygons[poly_num] = rotated_poly - - fitness = round(self.cost([tmp_structure])[0], 3) + tmp_structure[poly_num] = rotated_poly + tmp_structure = self.cost(tmp_structure)[0] + fitness = round(tmp_structure.fitness[0], 3) tmp_fit_history.append([fitness, angle]) tmp_str_history.append(tmp_structure) best_poly_fit = min(tmp_fit_history) idx_best = tmp_fit_history.index(best_poly_fit) - fitness_diff = round( - 100 * ((best_poly_fit[0] - curent_fitness) / curent_fitness), - 1, + diff = round( + 100 * ((best_poly_fit[0] - curent_fitness) / curent_fitness), 1, ) if best_poly_fit[0] < curent_fitness: curent_fitness = best_poly_fit[0] best_tmp_structure = tmp_str_history[idx_best] - structure.polygons[poly_num] = best_tmp_structure.polygons[poly_num] + structure[poly_num] = best_tmp_structure.polygons[poly_num] fitness_history.append(best_poly_fit[0]) structure_history.append(best_tmp_structure) polygon_history.append( - f'{str(poly.id)}, best_angle={best_poly_fit[1]}, fitnesss={fitness_diff}%', + f'{str(poly.id)}, best_angle={best_poly_fit[1]}, fitnesss={diff}%' ) else: fitness_history.append(curent_fitness) structure_history.append(tmp_str_history[idx_best]) polygon_history.append( - f'{str(poly.id)}, best_angle={best_poly_fit[1]}, fitnesss=+{fitness_diff}%', + f'{str(poly.id)}, best_angle={best_poly_fit[1]}, fitnesss=+{diff}%' ) end_step_time = time.time() @@ -374,40 +366,22 @@ def rotate_objects(self, structure: Structure, init_fitness: int): return fitness_history, structure_history, polygon_history - -class SA(SensitivityAnalysisMethods): - """The class for doing sensitivity-based optimization for structures - - Parameters: - optimized_pop: optimized ''Structure'' from generative design process - estimator: physical process simulator (SWAN, COMSOL, etc.) - domain: ''Domain'' class (same with initial ''Domain'' in the previous gen. design process) - - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - def analysis(self): - """Method for sensitivity-based optimization + """Method for sensitivity-based optimization. Returns: List: fitness history, structure history, description for an each step of analysis, time history """ - mov_fitness, mov_structure, mov_poly = self.moving_position() rotated_fitness, rotated_structure, rotated_poly = self.rotate_objects( - mov_structure[-1], - mov_fitness[-1], + mov_structure[-1], mov_fitness[-1] ) del_fitness, del_structure, del_poly = self.exploring_combinations( - rotated_structure[-1], - rotated_fitness[-1], + rotated_structure[-1], rotated_fitness[-1] ) rm_points_fitness, rm_points_structure, rm_points_poly = self.removing_points( - del_structure[-1], - del_fitness[-1], + del_structure[-1], del_fitness[-1] ) fitness_history = mov_fitness + rotated_fitness + del_fitness + rm_points_fitness @@ -420,32 +394,22 @@ def analysis(self): @property def get_improved_structure(self): - """Getter method if needed to recieve only improved structure + """Getter method if needed to recieve only improved structure. Returns: Structure: improved structure by sensitivity-based optimization """ - _, structure, _, _ = self.analysis() return structure[-1] def report_viz(analysis_result): - """Generates a picture-report of sensitivity-based optimization - - Args: - analysis_result (List): results of sensitivity analysis of structure (from ''SA.analysis()'') - """ - + """Plots a picture-report of sensitivity-based optimization.""" fitness_history = analysis_result[0] - structure_history = analysis_result[1] descriptions = analysis_result[2] time_history = analysis_result[3] - initial_strucutre = structure_history[0] - optimized_structure = structure_history[-1] - x = list(range(len(descriptions))) y = fitness_history @@ -453,31 +417,20 @@ def report_viz(analysis_result): start_fit = fitness_history[0] end_fit = fitness_history[-1] - fitness_difference = round(100 * (start_fit - end_fit) / start_fit, 1) - - fig, axd = plt.subplot_mosaic( - [['upper', 'upper'], ['lower left', 'lower right']], - figsize=(15, 8), - height_ratios=[1, 3], - ) + difference = round(100 * (start_fit - end_fit) / start_fit, 1) + fig = plt.figure(figsize=(15, 10)) fig.suptitle( - f'Sensitivity-based optimization report, spend={spend_time}sec,\ - fitness improved on {fitness_difference}%', + f'SA report, spend={spend_time}sec, fitness improved on {difference}%' ) - initial_strucutre.plot(color='r', ax=axd['lower left'], legend=True) - axd['lower left'].set_title(f'Initial structure, fitness={round(fitness_history[0], 3)}') - optimized_structure.plot(ax=axd['lower right'], legend=True) - axd['lower right'].set_title(f'Processed structure, fitness={round(fitness_history[-1], 3)}') - - axd['upper'].plot(fitness_history, c='c') - axd['upper'].scatter(x, y, marker='o', c='c') + plt.plot(fitness_history, c='c') + plt.scatter(x, y, marker='o', c='c') for idx, text in enumerate(descriptions): - axd['upper'].annotate(text, (x[idx] + 0.01, y[idx] + 0.01), rotation=45.0) - axd['upper'].set_xlabel('iteration of senitivity analysis') - axd['upper'].set_ylabel('loss - height of waves') + plt.annotate(text, (x[idx], y[idx]), rotation=75.0) + plt.xlabel('iteration of senitivity analysis') + plt.ylabel('fitness') fig.tight_layout() - plt.legend() + fig.savefig('sensitivity_report.png') diff --git a/gefest/tools/tuners/tuner.py b/gefest/tools/tuners/tuner.py index 36c0a8275..82c527a72 100644 --- a/gefest/tools/tuners/tuner.py +++ b/gefest/tools/tuners/tuner.py @@ -64,8 +64,9 @@ def __init__( self.verbose: bool = opt_params.tuner_cfg.verbose self.timeout = timedelta(minutes=opt_params.tuner_cfg.timeout_minutes) self.generate_variances: VarianceGeneratorType = opt_params.tuner_cfg.variacne_generator + self.tuner = None - def _get_tuner( + def _set_tuner( self, graph: OptGraph, search_space: SearchSpace, @@ -81,7 +82,7 @@ def _get_tuner( if self.tuner_type == 'optuna': kwargs['objectives_number'] = len(self.objective.metrics) - return getattr(TunerType, self.tuner_type).value(**kwargs) + self.tuner = getattr(TunerType, self.tuner_type).value(**kwargs) def _generate_search_space( self, @@ -126,15 +127,15 @@ def tune( graph, self.generate_variances(struct, self.domain), ) - tuner = self._get_tuner(graph, SearchSpace(search_space)) - tuned_structures = tuner.tune(graph) - metrics = tuner.obtained_metric + self._set_tuner(graph, SearchSpace(search_space)) + tuned_structures = self.tuner.tune(graph) + metrics = self.tuner.obtained_metric tuned_structures = ensure_wrapped_in_sequence(tuned_structures) - metrics = ensure_wrapped_in_sequence(tuner.obtained_metric) + metrics = ensure_wrapped_in_sequence(self.tuner.obtained_metric) for idx_, _ in enumerate(tuned_structures): - tuned_structures[idx_].fitness = metrics[idx_] + tuned_structures[idx_].fitness = ensure_wrapped_in_sequence(metrics[idx_]) tuned_objects.extend(tuned_structures) tuned_objects = sorted(tuned_objects, key=lambda x: x.fitness) diff --git a/setup.cfg b/setup.cfg index de4422133..3bec477ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,14 +1,12 @@ [flake8] exclude = .git,.github,docs,__pycache__,env,venv,.venv,.ipynb_checkpoints, - nsga2.py,moead.py,age.py,analytics.py,sa.py,sens_sampler.py, - DL, serialization + analytics.py, DL, serialization max-line-length = 100 max-complexity = 10 docstring-convention = google ignore = D100,D400,C812,VNE001,W503,E203,D107,D104,D105 per-file-ignores = - gefest\tools\optimizers\golem_optimizer\age.py:N803 __init__.py:F401 sound_interface.py:N806 comsol_interface.py:C901 diff --git a/test/test_adapters.py b/test/test_adapters.py index 1527204fc..3ebfa1425 100644 --- a/test/test_adapters.py +++ b/test/test_adapters.py @@ -1,15 +1,18 @@ from pathlib import Path from golem.core.optimisers.genetic.gp_params import GPAlgorithmParameters +from golem.core.optimisers.graph import OptGraph from golem.core.optimisers.optimization_parameters import GraphRequirements from golem.core.optimisers.optimizer import GraphGenerationParams from gefest.core.configs.utils import load_config +from gefest.core.geometry.datastructs.structure import Structure from gefest.core.opt.adapters.configuration_mapping import ( map_into_gpa, map_into_graph_generation_params, map_into_graph_requirements, ) +from gefest.core.opt.adapters.factories import StructureFactory filepath = Path(__file__) test_config = load_config(str(filepath.parent) + '/test_config.py') @@ -31,3 +34,12 @@ def test_mapping_into_gpa(): """Test OptimizationParams translation into GPAlgorithmParameters.""" gpa = map_into_gpa(test_config) assert isinstance(gpa, GPAlgorithmParameters) + + +def test_structure_factory(): + """Test sampler for GOLEM.""" + factory = StructureFactory(test_config.sampler, test_config.golem_adapter) + assert isinstance(factory, StructureFactory) + random_graph = factory() + assert isinstance(random_graph, OptGraph) + assert isinstance(test_config.golem_adapter.restore(random_graph), Structure) diff --git a/test/test_config.py b/test/test_config.py index a12dd0686..53a346584 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -74,7 +74,7 @@ def _evaluate(self, ind: Structure) -> float: opt_params = OptimizationParams( optimizer='gefest_ga', domain=domain_cfg, - tuner_cfg=None, + tuner_cfg=tuner_cfg, n_steps=1, pop_size=50, postprocess_attempts=3, diff --git a/test/test_tuners.py b/test/test_tuners.py new file mode 100644 index 000000000..1986583ee --- /dev/null +++ b/test/test_tuners.py @@ -0,0 +1,62 @@ +from contextlib import nullcontext as no_exception +from pathlib import Path + +import pytest + +from gefest.core.configs.utils import load_config +from gefest.core.geometry import Point, Polygon, Structure +from gefest.tools.tuners.tuner import GolemTuner, TunerType + +filepath = Path(__file__) +test_config = load_config(str(filepath.parent) + '/test_config.py') +ref_tuner_cfg = test_config.tuner_cfg +ref_objectives_list = test_config.objectives + + +struct_for_tune = Structure( + [Polygon([Point(x, y) for x, y in [(15, 15), (15, 2), (2, 2), (2, 15), (15, 15)]])] +) +struct_for_tune.fitness = [test_config.objectives[0](struct_for_tune)] + + +@pytest.mark.parametrize( + ', '.join( + [ + 'tuner_name', + 'expectation', + ] + ), + [ + ('iopt', no_exception()), + ('optuna', no_exception()), + ('sequential', no_exception()), + ('simulataneous', no_exception()), + (None, pytest.raises(ValueError)), + ], +) +def test_golem_tuners_wrap(tuner_name, expectation): + """Check all available tuner types on single objective synthetic.""" + test_config.objectives = [ref_objectives_list[0]] # sinngle objective + if tuner_name: + test_config.tuner_cfg = ref_tuner_cfg + test_config.tuner_cfg.tuner_type = tuner_name + else: + test_config.tuner_cfg = None + + with expectation: + tuner = GolemTuner(test_config) + res = tuner.tune(struct_for_tune)[0] + assert isinstance(res, Structure) + assert res.fitness[0] <= struct_for_tune.fitness[0] + assert isinstance(tuner.tuner, getattr(TunerType, tuner_name).value) + + +def test_multiobj_tuner(): + """Check optuna tuner on multiobjective synthetic.""" + test_config.tuner_cfg = ref_tuner_cfg + test_config.objectives = ref_objectives_list + test_config.tuner_cfg.tuner_type = 'optuna' + tuner = GolemTuner(test_config) + results = tuner.tune(struct_for_tune) + assert all(isinstance(res, Structure) for res in results) + assert all(len(res.fitness) == len(test_config.objectives) for res in results) diff --git a/test/test_validation.py b/test/test_validation.py index 0d65e1418..8e81c3ddc 100644 --- a/test/test_validation.py +++ b/test/test_validation.py @@ -49,19 +49,8 @@ def poly_from_coords(coords): self_intersected_poly_open = [(2, 2), (3, 8), (2, 1), (1, 4), (9, 9)] self_intersected_poly_2 = [(4, 4), (4, 2), (2, 2), (2, 4), (4, 4), (2, 7), (4, 7), (2, 4)] self_intersected_poly_3 = [ - (4, 4), - (4, 2), - (2, 2), - (2, 4), - (4, 4), - (2, 7), - (4, 7), - (2, 4), - (4, 4), - (4, 2), - (2, 2), - (2, 4), - (4, 4), + (4, 4), (4, 2), (2, 2), (2, 4), (4, 4), (2, 7), (4, 7), + (2, 4), (4, 4), (4, 2), (2, 2), (2, 4), (4, 4) ] self_intersected_poly_4 = [(4, 4), (4, 2), (2, 2), (2, 4), (4, 4), (4, 2), (2, 2), (2, 4), (4, 4)] not_self_intersected_poly_closed = [(4, 4), (4, 2), (2, 2), (2, 4), (4, 4)]