From 33d6dff2c5fdc4b52ad238d512612e8e79965df5 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 22 Jan 2025 22:16:07 -0800 Subject: [PATCH 01/29] first implementation of storing more general optimization data --- environment.yml | 3 ++ src/paretobench/containers.py | 71 +++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/environment.yml b/environment.yml index 6e50cfa..5610313 100644 --- a/environment.yml +++ b/environment.yml @@ -1,3 +1,6 @@ name: paretobench dependencies: - pytest + - pip + - pip: + - pre-commit \ No newline at end of file diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 6c779c1..89a9f9f 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -34,6 +34,11 @@ class Population(BaseModel): names_f: Optional[List[str]] = None names_g: Optional[List[str]] = None + maximize_f: np.ndarray + + less_than_g: np.ndarray + boundary_g: np.ndarray + @model_validator(mode="before") @classmethod def set_default_vals(cls, values): @@ -58,6 +63,14 @@ def set_default_vals(cls, values): if values.get("g") is None: values["g"] = np.empty((batch_size, 0), dtype=np.float64) + # Handle objectives / constraints settings (default to canonical problem) + if values.get("maximize_f") is None: + values["maximize_f"] = np.zeros(values["f"].shape[1], dtype=bool) + if values.get("less_than_g") is None: + values["less_than_g"] = np.zeros(values["g"].shape[1], dtype=bool) + if values.get("boundary_g") is None: + values["boundary_g"] = np.zeros(values["g"].shape[1], dtype=float) + # Set fevals to number of individuals if not included if values.get("fevals") is None: values["fevals"] = batch_size @@ -88,6 +101,19 @@ def validate_names(self): raise ValueError("Length of names_g must match the number of constraints in g.") return self + @model_validator(mode="after") + def validate_obj_constraint_settings(self): + """ + Checks that the settings for objectives and constraints match with the dimensions of each. + """ + if len(self.maximize_f) != self.f.shape[1]: + raise ValueError("Length of maximize_f must match number of objectives in f.") + if len(self.less_than_g) != self.g.shape[1]: + raise ValueError("Length of less_than_g must match number of constraints in g.") + if len(self.boundary_g) != self.g.shape[1]: + raise ValueError("Length of boundary_g must match number of constraints in g.") + return self + @field_validator("x", "f", "g") @classmethod def validate_numpy_arrays(cls, value: np.ndarray, info) -> np.ndarray: @@ -121,6 +147,20 @@ def __eq__(self, other): and self.names_g == other.names_g ) + @property + def f_canonical(self): + """ + Return the objectives transformed so that we are a minimization problem. + """ + return np.where(self.maximize_f, -1, 1)[None, :] * self.f + + @property + def g_canonical(self): + """ + Return constraints transformed such that g[...] >= 0 are the feasible solutions. + """ + return np.where(self.less_than_g, -1, 1)[None, :] * self.g - self.boundary_g[None, :] + def __add__(self, other: "Population") -> "Population": """ The sum of two populations is defined here as the population containing all unique individuals from both (set union). @@ -129,13 +169,19 @@ def __add__(self, other: "Population") -> "Population": if not isinstance(other, Population): raise TypeError("Operands must be instances of Population") - # Check that the names are consistent + # Check that the names/settings are consistent if self.names_x != other.names_x: raise ValueError("names_x are inconsistent between populations") if self.names_f != other.names_f: raise ValueError("names_f are inconsistent between populations") if self.names_g != other.names_g: raise ValueError("names_g are inconsistent between populations") + if not np.array_equal(self.maximize_f, other.maximize_f): + raise ValueError("maximize_f are inconsistent between populations") + if not np.array_equal(self.less_than_g, other.less_than_g): + raise ValueError("less_than_g are inconsistent between populations") + if not np.array_equal(self.boundary_g, other.boundary_g): + raise ValueError("boundary_g are inconsistent between populations") # Concatenate the arrays along the batch dimension (axis=0) new_x = np.concatenate((self.x, other.x), axis=0) @@ -160,6 +206,9 @@ def __add__(self, other: "Population") -> "Population": names_x=self.names_x, names_f=self.names_f, names_g=self.names_g, + maximize_f=self.maximize_f, + less_than_g=self.less_than_g, + boundary_g=self.boundary_g, ) def __getitem__(self, idx: Union[slice, np.ndarray, List[int]]) -> "Population": @@ -184,18 +233,21 @@ def __getitem__(self, idx: Union[slice, np.ndarray, List[int]]) -> "Population": names_x=self.names_x, names_f=self.names_f, names_g=self.names_g, + maximize_f=self.maximize_f, + less_than_g=self.less_than_g, + boundary_g=self.boundary_g, ) def get_nondominated_indices(self): """ Returns a boolean array of whether or not an individual is non-dominated. """ - return np.sum(get_domination(self.f, self.g), axis=0) == 0 + return np.sum(get_domination(self.f_canonical, self.g_canonical), axis=0) == 0 def get_feasible_indices(self): if self.g.shape[1] == 0: return np.ones((len(self)), dtype=bool) - return np.all(self.g >= 0.0, axis=1) + return np.all(self.g_canonical >= 0.0, axis=1) def get_nondominated_set(self): return self[self.get_nondominated_indices()] @@ -320,7 +372,7 @@ class History(BaseModel): Assumptions: - All reports must have a consistent number of objectives, decision variables, and constraints. - - Names, if used, must be consistent across populations + - Objective/constraint settings and ames, if used, must be consistent across populations """ reports: List[Population] @@ -356,6 +408,17 @@ def validate_consistent_populations(self): if names_g and len(set(names_g)) != 1: raise ValueError(f"Inconsistent names for constraints in reports: {names_g}") + # Check settings for objectives and constraints + maximize_f = [tuple(x.maximize_f) for x in self.reports] + less_than_g = [tuple(x.less_than_g) for x in self.reports] + boundary_g = [tuple(x.boundary_g) for x in self.reports] + if maximize_f and len(set(maximize_f)) != 1: + raise ValueError(f"Inconsistent maximize_f in reports: {maximize_f}") + if less_than_g and len(set(less_than_g)) != 1: + raise ValueError(f"Inconsistent less_than_g in reports: {less_than_g}") + if boundary_g and len(set(boundary_g)) != 1: + raise ValueError(f"Inconsistent boundary_g in reports: {boundary_g}") + return self def __eq__(self, other): From 7a95d6c1079c07ad27b7ae07595869c0388607c0 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 22 Jan 2025 22:47:47 -0800 Subject: [PATCH 02/29] add start of checks for old file format loading --- tests/test_containers.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_containers.py b/tests/test_containers.py index 63d6c6a..2850cd3 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -3,10 +3,38 @@ import os import pytest import tempfile +from pathlib import Path +from paretobench.analyze_metrics import normalize_problem_name from paretobench.containers import Experiment, Population, History +def get_test_files(): + test_dir = Path(__file__).parent / "legacy_file_formats" + return [f for f in test_dir.glob("*.h5")] + + +@pytest.mark.parametrize("test_file", get_test_files()) +def test_load_legacy_files(test_file): + """ + Test loading different versions of saved experiment files for backwards compatibility. + """ + exp = Experiment.load(test_file) + + # Some basic checks + assert len(exp.runs) == 6 + for run in exp.runs: + assert len(run) == 8 + assert len(run.reports[0]) == 50 + assert run.reports[0].m == 2 + assert run.reports[0].n == 5 + assert run.reports[0].g.shape[1] == 0 + + # Check problems are right + probs = set(normalize_problem_name(x.problem) for x in exp.runs) + assert probs == {"ZDT2 (n=5)", "ZDT4 (n=5)", "ZDT6 (n=5)"} + + @pytest.mark.parametrize("generate_names", [False, True]) def test_experiment_save_load(generate_names): """ From b32279eda519e092a0a9dd549cdd311fe09c749a Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 22 Jan 2025 22:48:31 -0800 Subject: [PATCH 03/29] add data --- .../paretobench_file_format_v1.0.0.h5 | Bin 0 -> 159064 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/legacy_file_formats/paretobench_file_format_v1.0.0.h5 diff --git a/tests/legacy_file_formats/paretobench_file_format_v1.0.0.h5 b/tests/legacy_file_formats/paretobench_file_format_v1.0.0.h5 new file mode 100644 index 0000000000000000000000000000000000000000..04fd3ff80fc002d88ac0423a17ba586824cce6bd GIT binary patch literal 159064 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 C(1_&# literal 0 HcmV?d00001 From 2563fffb27ffe931da6ccdba3f373c0c6faaaa70 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Wed, 22 Jan 2025 23:13:44 -0800 Subject: [PATCH 04/29] add ability to save new data --- dev_notebooks/save_new_legacy_file.ipynb | 59 ++++++++++++++++++ src/paretobench/containers.py | 16 ++++- .../paretobench_file_format_v1.1.0.h5 | Bin 0 -> 159064 bytes 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 dev_notebooks/save_new_legacy_file.ipynb create mode 100644 tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5 diff --git a/dev_notebooks/save_new_legacy_file.ipynb b/dev_notebooks/save_new_legacy_file.ipynb new file mode 100644 index 0000000..8e849a9 --- /dev/null +++ b/dev_notebooks/save_new_legacy_file.ipynb @@ -0,0 +1,59 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Prepare a New \"Legacy Test File\"\n", + "This notebook loads the data from the first legacy test file and saves it using a new version of the library in the latest save file format. This will ensure a record of the file format exists for the purpose of regression tests." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from paretobench import Experiment" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "exp = Experiment.load(\"../tests/legacy_file_formats/paretobench_file_format_v1.0.0.h5\")\n", + "exp.save(\"../tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "paretobench", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.1" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 89a9f9f..ea49d1d 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -526,6 +526,12 @@ def _to_h5py_group(self, g: h5py.Group): if self.reports and self.reports[0].names_g is not None: g["g"].attrs["names"] = self.reports[0].names_g + # Save the configuration data + if self.reports: + g["f"].attrs["maximize"] = self.reports[0].maximize_f + g["g"].attrs["less_than"] = self.reports[0].less_than_g + g["g"].attrs["boundary"] = self.reports[0].boundary_g + @classmethod def _from_h5py_group(cls, grp: h5py.Group): """ @@ -547,6 +553,11 @@ def _from_h5py_group(cls, grp: h5py.Group): g = grp["g"][()] # Get the names + maximize_f = grp["f"].attrs.get("maximize", None) + less_than_g = grp["g"].attrs.get("less_than", None) + boundary_g = grp["g"].attrs.get("boundary", None) + + # Get the objective / constraint settings names_x = grp["x"].attrs.get("names", None) names_f = grp["f"].attrs.get("names", None) names_g = grp["g"].attrs.get("names", None) @@ -564,6 +575,9 @@ def _from_h5py_group(cls, grp: h5py.Group): names_x=names_x, names_f=names_f, names_g=names_g, + maximize_f=maximize_f, + less_than_g=less_than_g, + boundary_g=boundary_g, ) ) start_idx += pop_size @@ -631,7 +645,7 @@ class Experiment(BaseModel): software_version: str = "" comment: str = "" creation_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - file_version: str = "1.0.0" + file_version: str = "1.1.0" def __eq__(self, other): if not isinstance(other, Experiment): diff --git a/tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5 b/tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5 new file mode 100644 index 0000000000000000000000000000000000000000..7508fea86faae3880be29b510fbace59b994b1d0 GIT binary patch literal 159064 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

e`oi@RrcW8FBg11aQ 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 literal 0 HcmV?d00001 From a60199b2386de9d919ac50733f95eb9942b28369 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 23 Jan 2025 00:41:28 -0800 Subject: [PATCH 05/29] update plotting to handle general problems --- src/paretobench/plotting/attainment.py | 43 ++++++++++++++++---------- src/paretobench/plotting/history.py | 2 +- src/paretobench/plotting/population.py | 16 +++++----- src/paretobench/plotting/utils.py | 3 +- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/paretobench/plotting/attainment.py b/src/paretobench/plotting/attainment.py index 22ded9a..a333121 100644 --- a/src/paretobench/plotting/attainment.py +++ b/src/paretobench/plotting/attainment.py @@ -20,10 +20,10 @@ def get_reference_point(population: Population, padding=0.1): ndarray Reference point coordinates calculated as max values plus padding. """ - min_vals = np.min(population.f, axis=0) - max_vals = np.max(population.f, axis=0) + min_vals = np.min(population.f_canonical, axis=0) + max_vals = np.max(population.f_canonical, axis=0) ref_point = max_vals + (max_vals - min_vals) * padding - return ref_point + return ref_point * np.where(population.maximize_f, -1, 1) def compute_attainment_surface_2d(population: Population, ref_point=None, padding=0.1): @@ -58,19 +58,21 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin # Handle missing ref-point if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) + ref_point = np.asarray(ref_point) * np.where(population.maximize_f, -1, 1) # Get only nondominated points population = population.get_nondominated_set() - if not np.all(ref_point >= np.max(population.f, axis=0)): + # Check reference point is reasonable + max_canonical = np.max(population.f_canonical, axis=0) + if not np.all(ref_point >= max_canonical): raise ValueError( - f"Reference point coordinates must exceed all points in non-dominated set " - f"(ref_point={ref_point}, max_pf=({np.max(population.f[:, 0])}, {np.max(population.f[:, 1])}))" + f"Reference point must be dominated by all points in non-dominated set " + f"(ref_point={ref_point}, max_non_dominated_canonical=({max_canonical[0]}, {max_canonical[1]}))" ) - # Sort points by x coordinate (first objective) - sorted_points = population.f[np.argsort(population.f[:, 0])] + # Sort points by x coordinate (first one) with canonical objectives + sorted_points = population.f_canonical[np.argsort(population.f_canonical[:, 0])] # Initialize the surface points list with the first point surface = [] @@ -86,7 +88,9 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin # Add the next point surface.append(next_point) surface = np.array(surface) - return np.concatenate( + + # Add "arms" going out to reference point boundary to surface + surface = np.concatenate( ( [[surface[0, 0], ref_point[1]]], surface, @@ -95,6 +99,9 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin axis=0, ) + # Do inverse transformation from canonical objectives to actual objectives + return surface * np.where(population.maximize_f, -1, 1)[None, :] + def save_mesh_to_stl(vertices: np.ndarray, triangles: np.ndarray, filename: str): """ @@ -294,9 +301,9 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin # If no reference point provided, compute one if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) + ref_point = np.asarray(ref_point) * np.where(population.maximize_f, -1, 1) - if not np.all(ref_point >= np.max(population.f, axis=0)): + if not np.all(ref_point >= np.max(population.f_canonical, axis=0)): raise ValueError("Reference point must dominate all points") # Get the nondominated points @@ -307,9 +314,9 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin vertex_dict = {} # Sort points in each dimension - sorted_by_z = population.f[np.argsort(population.f[:, 2])] # For XY plane - sorted_by_x = population.f[np.argsort(population.f[:, 0])] # For YZ plane - sorted_by_y = population.f[np.argsort(population.f[:, 1])] # For XZ plane + sorted_by_z = population.f_canonical[np.argsort(population.f_canonical[:, 2])] # For XY plane + sorted_by_x = population.f_canonical[np.argsort(population.f_canonical[:, 0])] # For YZ plane + sorted_by_y = population.f_canonical[np.argsort(population.f_canonical[:, 1])] # For XZ plane # Process XY plane (sorted by Z) triangles.extend(mesh_plane(sorted_by_z, 2, 0, 1, ref_point, vertex_dict, vertices)[1]) @@ -320,4 +327,8 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin # Process XZ plane (sorted by Y) triangles.extend(mesh_plane(sorted_by_y, 1, 0, 2, ref_point, vertex_dict, vertices)[1]) - return np.array(vertices), np.array(triangles) + # Convert to numpy arrays and correct for canonicalized objectives + vertices = np.array(vertices) * np.where(population.maximize_f, -1, 1)[None, :] + triangles = np.array(triangles) + + return vertices, triangles diff --git a/src/paretobench/plotting/history.py b/src/paretobench/plotting/history.py index 1ca7f47..dd9746e 100644 --- a/src/paretobench/plotting/history.py +++ b/src/paretobench/plotting/history.py @@ -451,7 +451,7 @@ def history_obj_animation( if len(scale.shape) != 1 or scale.shape[0] != history.reports[0].m: raise ValueError( f"Length of scale must match number of objectives. Got scale factors with shape {scale.shape}" - f" and {history.reports[0].f.shape[1]} objectives." + f" and {history.reports[0].m} objectives." ) else: scale = np.ones(history.reports[0].m) diff --git a/src/paretobench/plotting/population.py b/src/paretobench/plotting/population.py index 4668510..fbd8ccd 100644 --- a/src/paretobench/plotting/population.py +++ b/src/paretobench/plotting/population.py @@ -102,7 +102,7 @@ def population_obj_scatter( if len(scale.shape) != 1 or scale.shape[0] != population.m: raise ValueError( f"Length of scale must match number of objectives. Got scale factors with shape {scale.shape}" - f" and {population.f.shape[1]} objectives." + f" and {population.m} objectives." ) else: scale = np.ones(population.m) @@ -119,10 +119,10 @@ def population_obj_scatter( pf = None if problem is not None: problem = get_problem_from_obj_or_str(problem) - if problem.m != population.f.shape[1]: + if problem.m != population.m: raise ValueError( f'Number of objectives in problem must match number in population. Got {problem.m} objectives from problem "{problem}" ' - f"and {population.f.shape[1]} from the population." + f"and {population.m} from the population." ) if isinstance(problem, ProblemWithPF): pf = problem.get_pareto_front(n_pf) @@ -134,10 +134,10 @@ def population_obj_scatter( pf = np.asarray(pf_objectives) if pf.ndim != 2: raise ValueError("pf_objectives must be a 2D array") - if pf.shape[1] != population.f.shape[1]: + if pf.shape[1] != population.m: raise ValueError( f"Number of objectives in pf_objectives must match number in population. Got {pf.shape[1]} in pf_objectives " - f"and {population.f.shape[1]} in population" + f"and {population.m} in population" ) # Get the point settings for this plot @@ -157,7 +157,7 @@ def population_obj_scatter( # For 2D problems add_legend = False base_color = color - if population.f.shape[1] == 2: + if population.m == 2: # Make axis if not supplied if ax is None: ax = fig.add_subplot(111) @@ -225,7 +225,7 @@ def population_obj_scatter( ax.set_ylabel(labels[obj_idx[1]]) # For 3D problems - elif population.f.shape[1] == 3: + elif population.m == 3: # Get an axis if not supplied if ax is None: ax = fig.add_subplot(111, projection="3d") @@ -283,7 +283,7 @@ def population_obj_scatter( # We can't plot in 4D :( else: - raise ValueError(f"Plotting supports only 2D and 3D objectives currently: n_objs={population.f.shape[1]}") + raise ValueError(f"Plotting supports only 2D and 3D objectives currently: n_objs={population.m}") if add_legend: plt.legend(loc=legend_loc) diff --git a/src/paretobench/plotting/utils.py b/src/paretobench/plotting/utils.py index bf5bd93..85902da 100644 --- a/src/paretobench/plotting/utils.py +++ b/src/paretobench/plotting/utils.py @@ -80,7 +80,8 @@ def get_per_point_settings_population( # Get the domination ranks (of only the visible solutions so we don't end up with a plot of all invisible points) ranks = np.zeros(len(population)) filtered_indices = np.where(plot_filt)[0] - for rank, idx in enumerate(fast_dominated_argsort(population.f[plot_filt, :], population.g[plot_filt, :])): + idxs = fast_dominated_argsort(population.f_canonical[plot_filt, :], population.g_canonical[plot_filt, :]) + for rank, idx in enumerate(idxs): ranks[filtered_indices[idx]] = rank # Compute alpha from the ranks From e58eb7a182cd37ce32784d2be6577929fb45b09b Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 23 Jan 2025 01:26:45 -0800 Subject: [PATCH 06/29] fix g_canonical --- src/paretobench/containers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index ea49d1d..e74a5ac 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -159,7 +159,9 @@ def g_canonical(self): """ Return constraints transformed such that g[...] >= 0 are the feasible solutions. """ - return np.where(self.less_than_g, -1, 1)[None, :] * self.g - self.boundary_g[None, :] + gc = np.where(self.less_than_g, -1, 1)[None, :] * self.g + gc += np.where(self.less_than_g, 1, -1)[None, :] * self.boundary_g[None, :] + return gc def __add__(self, other: "Population") -> "Population": """ From 9263581588f853916f584a669544622daf1e03a2 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Thu, 23 Jan 2025 01:47:34 -0800 Subject: [PATCH 07/29] better checks for objective/constraint settings --- src/paretobench/containers.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index e74a5ac..b528001 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -106,12 +106,22 @@ def validate_obj_constraint_settings(self): """ Checks that the settings for objectives and constraints match with the dimensions of each. """ - if len(self.maximize_f) != self.f.shape[1]: - raise ValueError("Length of maximize_f must match number of objectives in f.") - if len(self.less_than_g) != self.g.shape[1]: - raise ValueError("Length of less_than_g must match number of constraints in g.") - if len(self.boundary_g) != self.g.shape[1]: - raise ValueError("Length of boundary_g must match number of constraints in g.") + for attr, arrlen, propname, dtype in [ + ("maximize_f", self.m, "objectives", np.bool), + ("less_than_g", self.g.shape[1], "constraints", np.bool), + ("boundary_g", self.g.shape[1], "constraints", np.float64), + ]: + if not isinstance(getattr(self, attr), np.ndarray): + raise ValueError(f"maximize_f must be a numpy array, got: {type(getattr(self, attr))}") + if len(getattr(self, attr).shape) != 1: + raise ValueError(f"maximize_f must be 1D, shape was: {getattr(self, attr).shape}") + if len(getattr(self, attr)) != arrlen: + raise ValueError( + f"Length of maximize_f must match number of {propname} in f. Got {len(getattr(self, attr))} " + f"elements and {arrlen} {propname} from f" + ) + if getattr(self, attr).dtype != dtype: + raise ValueError(f"maximize_f dtype must be {dtype}. Got {getattr(self, attr).dtype}") return self @field_validator("x", "f", "g") From 4f45334ff8d036d1610136a069616a48e32791f6 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:17:35 -0800 Subject: [PATCH 08/29] add the objective and constraint settings to tests --- src/paretobench/containers.py | 47 ++++++++++++++++++++++++++++++++--- tests/test_containers.py | 1 + 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index b528001..c4af09f 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -155,6 +155,9 @@ def __eq__(self, other): and self.names_x == other.names_x and self.names_f == other.names_f and self.names_g == other.names_g + and np.array_equal(self.maximize_f, other.maximize_f) + and np.array_equal(self.less_than_g, other.less_than_g) + and np.array_equal(self.boundary_g, other.boundary_g) ) @property @@ -273,6 +276,7 @@ def from_random( pop_size: int, fevals: int = 0, generate_names: bool = False, + generate_obj_constraint_settings: bool = False, ) -> "Population": """ Generate a randomized instance of the Population class. @@ -291,6 +295,8 @@ def from_random( The number of evaluations of the objective functions performed to reach this state, by default 0. generate_names : bool, optional Whether to include names for the decision variables, objectives, and constraints, by default False. + generate_obj_constraint_settings : bool, optional + Randomize the objective and constraint settings, default to minimization problem and g >= 0 constraint Returns ------- @@ -315,9 +321,24 @@ def from_random( g = np.random.rand(pop_size, n_constraints) if n_constraints > 0 else np.empty((pop_size, 0)) # Optionally generate names if generate_names is True - names_x = [f"x{i+1}" for i in range(n_decision_vars)] if generate_names else None - names_f = [f"f{i+1}" for i in range(n_objectives)] if generate_names else None - names_g = [f"g{i+1}" for i in range(n_constraints)] if generate_names else None + if generate_names: + names_x = [f"x{i+1}" for i in range(n_decision_vars)] + names_f = [f"f{i+1}" for i in range(n_objectives)] + names_g = [f"g{i+1}" for i in range(n_constraints)] + else: + names_x = None + names_f = None + names_g = None + + # Create randomized settings for objectives/constraints + if generate_obj_constraint_settings: + maximize_f = np.random.randint(0, 1, size=n_objectives, dtype=bool) + less_than_g = np.random.randint(0, 1, size=n_constraints, dtype=bool) + boundary_g = np.random.rand(n_constraints) + else: + maximize_f = None + less_than_g = None + boundary_g = None return cls( x=x, @@ -327,6 +348,9 @@ def from_random( names_x=names_x, names_f=names_f, names_g=names_g, + maximize_f=maximize_f, + less_than_g=less_than_g, + boundary_g=boundary_g, ) def __len__(self): @@ -447,6 +471,7 @@ def from_random( n_constraints: int, pop_size: int, generate_names: bool = False, + generate_obj_constraint_settings: bool = False, ) -> "History": """ Generate a randomized instance of the History class, including random problem name and metadata. @@ -465,6 +490,8 @@ def from_random( The number of individuals in each population. generate_names : bool, optional Whether to include names for the decision variables, objectives, and constraints, by default False. + generate_obj_constraint_settings : bool, optional + Randomize the objective and constraint settings, default to minimization problem and g >= 0 constraint Returns ------- @@ -498,6 +525,16 @@ def from_random( for i in range(n_populations) ] + # Create randomized settings for objectives/constraints (must be consistent between objects) + if generate_obj_constraint_settings: + maximize_f = np.random.randint(0, 1, size=n_objectives, dtype=bool) + less_than_g = np.random.randint(0, 1, size=n_constraints, dtype=bool) + boundary_g = np.random.rand(n_constraints) + for report in reports: + report.maximize_f = maximize_f + report.less_than_g = less_than_g + report.boundary_g = boundary_g + return cls(reports=reports, problem=problem, metadata=metadata) def _to_h5py_group(self, g: h5py.Group): @@ -698,6 +735,7 @@ def from_random( n_constraints: int, pop_size: int, generate_names: bool = False, + generate_obj_constraint_settings: bool = False, ) -> "Experiment": """ Generate a randomized instance of the Experiment class. @@ -718,6 +756,8 @@ def from_random( The number of individuals in each population. generate_names : bool, optional Whether to include names for the decision variables, objectives, and constraints, by default False. + generate_obj_constraint_settings : bool, optional + Randomize the objective and constraint settings, default to minimization problem and g >= 0 constraint Returns ------- @@ -733,6 +773,7 @@ def from_random( n_constraints, pop_size, generate_names=generate_names, + generate_obj_constraint_settings=generate_obj_constraint_settings, ) for _ in range(n_histories) ] diff --git a/tests/test_containers.py b/tests/test_containers.py index 2850cd3..c2ba60d 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -49,6 +49,7 @@ def test_experiment_save_load(generate_names): n_constraints=2, pop_size=50, generate_names=generate_names, + generate_obj_constraint_settings=True, ) # Use a temporary directory to save the file From e10ac0d37970c9f234c43458d16a6d5fecb65923 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:20:25 -0800 Subject: [PATCH 09/29] update docstring --- src/paretobench/containers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index c4af09f..ec1865f 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -16,8 +16,10 @@ class Population(BaseModel): Stores the individuals in a population for one reporting interval in a genetic algorithm. Conventional names are used for the decision variables (x), the objectives (f), and inequality constraints (g). The first dimension of each array is the batch dimension. The number of evaluations of the objective functions performed to reach this state is also recorded. - Objectives are assumed to be part of a minimization problem and constraints are set such that individuals with g_i > 0 are - feasible. + + Whether each objective is being minimized or maximized is set by the boolean array maximize_f. Constraints are configured + by the boolean array less_than_g which sets whether it is a "less than" or "greater than" constraint and boundary_g which + sets the boundary of the constraint. All arrays must have the same size batch dimension even if they are empty. In this case the non-batch dimension will be zero length. Names may be associated with decision variables, objectives, or constraints in the form of lists. @@ -34,8 +36,8 @@ class Population(BaseModel): names_f: Optional[List[str]] = None names_g: Optional[List[str]] = None + # Configuration of objectives/constraints (minimization or maximization problem, direction of and boundary of constraint) maximize_f: np.ndarray - less_than_g: np.ndarray boundary_g: np.ndarray From d763c28e26a15d3ce658c55a24e52a507d319950 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:27:14 -0800 Subject: [PATCH 10/29] add check for feasibility --- tests/test_containers.py | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_containers.py b/tests/test_containers.py index c2ba60d..5c0454d 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -356,3 +356,66 @@ def test_count_unique_individuals(): g=np.array([[3.0], [4.0], [3.0]]), ) assert pop_empty_dims.count_unique_individuals() == 2 + + +def test_get_feasible_indices(): + # Single less-than constraint (g <= 1) + pop1 = Population( + x=np.empty((3, 0)), + f=np.empty((3, 0)), + g=np.array([[0.5], [1.5], [1.0]]), + less_than_g=np.array([True]), + boundary_g=np.array([1.0]), + ) + assert np.array_equal(pop1.get_feasible_indices(), np.array([True, False, True])) + + # Single greater-than constraint (g >= 1) + pop2 = Population( + x=np.empty((3, 0)), + f=np.empty((3, 0)), + g=np.array([[0.5], [1.5], [1.0]]), + less_than_g=np.array([False]), + boundary_g=np.array([1.0]), + ) + assert np.array_equal(pop2.get_feasible_indices(), np.array([False, True, True])) + + # Single less-than constraint (g <= -1) + pop1 = Population( + x=np.empty((3, 0)), + f=np.empty((3, 0)), + g=np.array([[-0.5], [-1.5], [-1.0]]), + less_than_g=np.array([True]), + boundary_g=np.array([-1.0]), + ) + assert np.array_equal(pop1.get_feasible_indices(), np.array([False, True, True])) + + # Single greater-than constraint (g >= -1) + pop2 = Population( + x=np.empty((3, 0)), + f=np.empty((3, 0)), + g=np.array([[-0.5], [-1.5], [-1.0]]), + less_than_g=np.array([False]), + boundary_g=np.array([-1.0]), + ) + assert np.array_equal(pop2.get_feasible_indices(), np.array([True, False, True])) + + # Multiple mixed constraints (g1 <= 0, g2 >= 1) + pop3 = Population( + x=np.empty((4, 0)), + f=np.empty((4, 0)), + g=np.array( + [ + [-0.5, 1.5], + [0.5, 0.5], + [-1.0, 1.0], + [0.1, 2.0], + ] + ), + less_than_g=np.array([True, False]), + boundary_g=np.array([0.0, 1.0]), + ) + assert np.array_equal(pop3.get_feasible_indices(), np.array([True, False, True, False])) + + # No constraints + pop4 = Population(x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.empty((3, 0))) + assert np.array_equal(pop4.get_feasible_indices(), np.array([True, True, True])) From 9108959cc071470dea915a74b013745c444b2145 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:35:13 -0800 Subject: [PATCH 11/29] allow users to pass lists for objective/constraint config --- src/paretobench/containers.py | 5 +++++ tests/test_containers.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index ec1865f..ac8fee1 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -73,6 +73,11 @@ def set_default_vals(cls, values): if values.get("boundary_g") is None: values["boundary_g"] = np.zeros(values["g"].shape[1], dtype=float) + # Support lists being passed to us (cast into numpy array) + for attr, dtype in [("maximize_f", bool), ("less_than_g", bool), ("boundary_g", float)]: + if isinstance(values[attr], list): + values[attr] = np.array(values[attr], dtype=dtype) + # Set fevals to number of individuals if not included if values.get("fevals") is None: values["fevals"] = batch_size diff --git a/tests/test_containers.py b/tests/test_containers.py index 5c0454d..ddbb0af 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -419,3 +419,29 @@ def test_get_feasible_indices(): # No constraints pop4 = Population(x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.empty((3, 0))) assert np.array_equal(pop4.get_feasible_indices(), np.array([True, True, True])) + + +def test_list_settings_conversion(): + pop = Population( + x=np.empty((3, 0)), + f=np.zeros((3, 2)), + g=np.zeros((3, 3)), + maximize_f=[True, False], + less_than_g=[True, False, True], + boundary_g=[1, 2, -1], + ) + + # Check types + assert isinstance(pop.maximize_f, np.ndarray) + assert isinstance(pop.less_than_g, np.ndarray) + assert isinstance(pop.boundary_g, np.ndarray) + + # Check values preserved + assert np.array_equal(pop.maximize_f, np.array([True, False])) + assert np.array_equal(pop.less_than_g, np.array([True, False, True])) + assert np.array_equal(pop.boundary_g, np.array([1.0, 2.0, -1.0])) + + # Check dtypes + assert pop.maximize_f.dtype == bool + assert pop.less_than_g.dtype == bool + assert pop.boundary_g.dtype == np.float64 From 49758d313c5416e907f6e479de102fe3cfc16ea3 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:53:53 -0800 Subject: [PATCH 12/29] new names --- src/paretobench/containers.py | 142 ++++++++++++------------- src/paretobench/plotting/attainment.py | 10 +- tests/test_containers.py | 44 ++++---- 3 files changed, 98 insertions(+), 98 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index ac8fee1..9211592 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -17,9 +17,9 @@ class Population(BaseModel): the decision variables (x), the objectives (f), and inequality constraints (g). The first dimension of each array is the batch dimension. The number of evaluations of the objective functions performed to reach this state is also recorded. - Whether each objective is being minimized or maximized is set by the boolean array maximize_f. Constraints are configured - by the boolean array less_than_g which sets whether it is a "less than" or "greater than" constraint and boundary_g which - sets the boundary of the constraint. + Whether each objective is being minimized or maximized is set by the boolean array obj_directions. Constraints are configured + by the boolean array constraint_directions which sets whether it is a "less than" or "greater than" constraint and + constraint_targets which sets the boundary of the constraint. All arrays must have the same size batch dimension even if they are empty. In this case the non-batch dimension will be zero length. Names may be associated with decision variables, objectives, or constraints in the form of lists. @@ -37,9 +37,9 @@ class Population(BaseModel): names_g: Optional[List[str]] = None # Configuration of objectives/constraints (minimization or maximization problem, direction of and boundary of constraint) - maximize_f: np.ndarray - less_than_g: np.ndarray - boundary_g: np.ndarray + obj_directions: np.ndarray + constraint_directions: np.ndarray + constraint_targets: np.ndarray @model_validator(mode="before") @classmethod @@ -66,15 +66,15 @@ def set_default_vals(cls, values): values["g"] = np.empty((batch_size, 0), dtype=np.float64) # Handle objectives / constraints settings (default to canonical problem) - if values.get("maximize_f") is None: - values["maximize_f"] = np.zeros(values["f"].shape[1], dtype=bool) - if values.get("less_than_g") is None: - values["less_than_g"] = np.zeros(values["g"].shape[1], dtype=bool) - if values.get("boundary_g") is None: - values["boundary_g"] = np.zeros(values["g"].shape[1], dtype=float) + if values.get("obj_directions") is None: + values["obj_directions"] = np.zeros(values["f"].shape[1], dtype=bool) + if values.get("constraint_directions") is None: + values["constraint_directions"] = np.zeros(values["g"].shape[1], dtype=bool) + if values.get("constraint_targets") is None: + values["constraint_targets"] = np.zeros(values["g"].shape[1], dtype=float) # Support lists being passed to us (cast into numpy array) - for attr, dtype in [("maximize_f", bool), ("less_than_g", bool), ("boundary_g", float)]: + for attr, dtype in [("obj_directions", bool), ("constraint_directions", bool), ("constraint_targets", float)]: if isinstance(values[attr], list): values[attr] = np.array(values[attr], dtype=dtype) @@ -114,21 +114,21 @@ def validate_obj_constraint_settings(self): Checks that the settings for objectives and constraints match with the dimensions of each. """ for attr, arrlen, propname, dtype in [ - ("maximize_f", self.m, "objectives", np.bool), - ("less_than_g", self.g.shape[1], "constraints", np.bool), - ("boundary_g", self.g.shape[1], "constraints", np.float64), + ("obj_directions", self.m, "objectives", np.bool), + ("constraint_directions", self.g.shape[1], "constraints", np.bool), + ("constraint_targets", self.g.shape[1], "constraints", np.float64), ]: if not isinstance(getattr(self, attr), np.ndarray): - raise ValueError(f"maximize_f must be a numpy array, got: {type(getattr(self, attr))}") + raise ValueError(f"obj_directions must be a numpy array, got: {type(getattr(self, attr))}") if len(getattr(self, attr).shape) != 1: - raise ValueError(f"maximize_f must be 1D, shape was: {getattr(self, attr).shape}") + raise ValueError(f"obj_directions must be 1D, shape was: {getattr(self, attr).shape}") if len(getattr(self, attr)) != arrlen: raise ValueError( - f"Length of maximize_f must match number of {propname} in f. Got {len(getattr(self, attr))} " + f"Length of obj_directions must match number of {propname} in f. Got {len(getattr(self, attr))} " f"elements and {arrlen} {propname} from f" ) if getattr(self, attr).dtype != dtype: - raise ValueError(f"maximize_f dtype must be {dtype}. Got {getattr(self, attr).dtype}") + raise ValueError(f"obj_directions dtype must be {dtype}. Got {getattr(self, attr).dtype}") return self @field_validator("x", "f", "g") @@ -162,9 +162,9 @@ def __eq__(self, other): and self.names_x == other.names_x and self.names_f == other.names_f and self.names_g == other.names_g - and np.array_equal(self.maximize_f, other.maximize_f) - and np.array_equal(self.less_than_g, other.less_than_g) - and np.array_equal(self.boundary_g, other.boundary_g) + and np.array_equal(self.obj_directions, other.obj_directions) + and np.array_equal(self.constraint_directions, other.constraint_directions) + and np.array_equal(self.constraint_targets, other.constraint_targets) ) @property @@ -172,15 +172,15 @@ def f_canonical(self): """ Return the objectives transformed so that we are a minimization problem. """ - return np.where(self.maximize_f, -1, 1)[None, :] * self.f + return np.where(self.obj_directions, -1, 1)[None, :] * self.f @property def g_canonical(self): """ Return constraints transformed such that g[...] >= 0 are the feasible solutions. """ - gc = np.where(self.less_than_g, -1, 1)[None, :] * self.g - gc += np.where(self.less_than_g, 1, -1)[None, :] * self.boundary_g[None, :] + gc = np.where(self.constraint_directions, -1, 1)[None, :] * self.g + gc += np.where(self.constraint_directions, 1, -1)[None, :] * self.constraint_targets[None, :] return gc def __add__(self, other: "Population") -> "Population": @@ -198,12 +198,12 @@ def __add__(self, other: "Population") -> "Population": raise ValueError("names_f are inconsistent between populations") if self.names_g != other.names_g: raise ValueError("names_g are inconsistent between populations") - if not np.array_equal(self.maximize_f, other.maximize_f): - raise ValueError("maximize_f are inconsistent between populations") - if not np.array_equal(self.less_than_g, other.less_than_g): - raise ValueError("less_than_g are inconsistent between populations") - if not np.array_equal(self.boundary_g, other.boundary_g): - raise ValueError("boundary_g are inconsistent between populations") + if not np.array_equal(self.obj_directions, other.obj_directions): + raise ValueError("obj_directions are inconsistent between populations") + if not np.array_equal(self.constraint_directions, other.constraint_directions): + raise ValueError("constraint_directions are inconsistent between populations") + if not np.array_equal(self.constraint_targets, other.constraint_targets): + raise ValueError("constraint_targets are inconsistent between populations") # Concatenate the arrays along the batch dimension (axis=0) new_x = np.concatenate((self.x, other.x), axis=0) @@ -228,9 +228,9 @@ def __add__(self, other: "Population") -> "Population": names_x=self.names_x, names_f=self.names_f, names_g=self.names_g, - maximize_f=self.maximize_f, - less_than_g=self.less_than_g, - boundary_g=self.boundary_g, + obj_directions=self.obj_directions, + constraint_directions=self.constraint_directions, + constraint_targets=self.constraint_targets, ) def __getitem__(self, idx: Union[slice, np.ndarray, List[int]]) -> "Population": @@ -255,9 +255,9 @@ def __getitem__(self, idx: Union[slice, np.ndarray, List[int]]) -> "Population": names_x=self.names_x, names_f=self.names_f, names_g=self.names_g, - maximize_f=self.maximize_f, - less_than_g=self.less_than_g, - boundary_g=self.boundary_g, + obj_directions=self.obj_directions, + constraint_directions=self.constraint_directions, + constraint_targets=self.constraint_targets, ) def get_nondominated_indices(self): @@ -339,13 +339,13 @@ def from_random( # Create randomized settings for objectives/constraints if generate_obj_constraint_settings: - maximize_f = np.random.randint(0, 1, size=n_objectives, dtype=bool) - less_than_g = np.random.randint(0, 1, size=n_constraints, dtype=bool) - boundary_g = np.random.rand(n_constraints) + obj_directions = np.random.randint(0, 1, size=n_objectives, dtype=bool) + constraint_directions = np.random.randint(0, 1, size=n_constraints, dtype=bool) + constraint_targets = np.random.rand(n_constraints) else: - maximize_f = None - less_than_g = None - boundary_g = None + obj_directions = None + constraint_directions = None + constraint_targets = None return cls( x=x, @@ -355,9 +355,9 @@ def from_random( names_x=names_x, names_f=names_f, names_g=names_g, - maximize_f=maximize_f, - less_than_g=less_than_g, - boundary_g=boundary_g, + obj_directions=obj_directions, + constraint_directions=constraint_directions, + constraint_targets=constraint_targets, ) def __len__(self): @@ -452,15 +452,15 @@ def validate_consistent_populations(self): raise ValueError(f"Inconsistent names for constraints in reports: {names_g}") # Check settings for objectives and constraints - maximize_f = [tuple(x.maximize_f) for x in self.reports] - less_than_g = [tuple(x.less_than_g) for x in self.reports] - boundary_g = [tuple(x.boundary_g) for x in self.reports] - if maximize_f and len(set(maximize_f)) != 1: - raise ValueError(f"Inconsistent maximize_f in reports: {maximize_f}") - if less_than_g and len(set(less_than_g)) != 1: - raise ValueError(f"Inconsistent less_than_g in reports: {less_than_g}") - if boundary_g and len(set(boundary_g)) != 1: - raise ValueError(f"Inconsistent boundary_g in reports: {boundary_g}") + obj_directions = [tuple(x.obj_directions) for x in self.reports] + constraint_directions = [tuple(x.constraint_directions) for x in self.reports] + constraint_targets = [tuple(x.constraint_targets) for x in self.reports] + if obj_directions and len(set(obj_directions)) != 1: + raise ValueError(f"Inconsistent obj_directions in reports: {obj_directions}") + if constraint_directions and len(set(constraint_directions)) != 1: + raise ValueError(f"Inconsistent constraint_directions in reports: {constraint_directions}") + if constraint_targets and len(set(constraint_targets)) != 1: + raise ValueError(f"Inconsistent constraint_targets in reports: {constraint_targets}") return self @@ -534,13 +534,13 @@ def from_random( # Create randomized settings for objectives/constraints (must be consistent between objects) if generate_obj_constraint_settings: - maximize_f = np.random.randint(0, 1, size=n_objectives, dtype=bool) - less_than_g = np.random.randint(0, 1, size=n_constraints, dtype=bool) - boundary_g = np.random.rand(n_constraints) + obj_directions = np.random.randint(0, 1, size=n_objectives, dtype=bool) + constraint_directions = np.random.randint(0, 1, size=n_constraints, dtype=bool) + constraint_targets = np.random.rand(n_constraints) for report in reports: - report.maximize_f = maximize_f - report.less_than_g = less_than_g - report.boundary_g = boundary_g + report.obj_directions = obj_directions + report.constraint_directions = constraint_directions + report.constraint_targets = constraint_targets return cls(reports=reports, problem=problem, metadata=metadata) @@ -584,9 +584,9 @@ def _to_h5py_group(self, g: h5py.Group): # Save the configuration data if self.reports: - g["f"].attrs["maximize"] = self.reports[0].maximize_f - g["g"].attrs["less_than"] = self.reports[0].less_than_g - g["g"].attrs["boundary"] = self.reports[0].boundary_g + g["f"].attrs["directions"] = self.reports[0].obj_directions + g["g"].attrs["directions"] = self.reports[0].constraint_directions + g["g"].attrs["targets"] = self.reports[0].constraint_targets @classmethod def _from_h5py_group(cls, grp: h5py.Group): @@ -609,9 +609,9 @@ def _from_h5py_group(cls, grp: h5py.Group): g = grp["g"][()] # Get the names - maximize_f = grp["f"].attrs.get("maximize", None) - less_than_g = grp["g"].attrs.get("less_than", None) - boundary_g = grp["g"].attrs.get("boundary", None) + obj_directions = grp["f"].attrs.get("directions", None) + constraint_directions = grp["g"].attrs.get("directions", None) + constraint_targets = grp["g"].attrs.get("targets", None) # Get the objective / constraint settings names_x = grp["x"].attrs.get("names", None) @@ -631,9 +631,9 @@ def _from_h5py_group(cls, grp: h5py.Group): names_x=names_x, names_f=names_f, names_g=names_g, - maximize_f=maximize_f, - less_than_g=less_than_g, - boundary_g=boundary_g, + obj_directions=obj_directions, + constraint_directions=constraint_directions, + constraint_targets=constraint_targets, ) ) start_idx += pop_size diff --git a/src/paretobench/plotting/attainment.py b/src/paretobench/plotting/attainment.py index a333121..b4c0621 100644 --- a/src/paretobench/plotting/attainment.py +++ b/src/paretobench/plotting/attainment.py @@ -23,7 +23,7 @@ def get_reference_point(population: Population, padding=0.1): min_vals = np.min(population.f_canonical, axis=0) max_vals = np.max(population.f_canonical, axis=0) ref_point = max_vals + (max_vals - min_vals) * padding - return ref_point * np.where(population.maximize_f, -1, 1) + return ref_point * np.where(population.obj_directions, -1, 1) def compute_attainment_surface_2d(population: Population, ref_point=None, padding=0.1): @@ -58,7 +58,7 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin # Handle missing ref-point if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) * np.where(population.maximize_f, -1, 1) + ref_point = np.asarray(ref_point) * np.where(population.obj_directions, -1, 1) # Get only nondominated points population = population.get_nondominated_set() @@ -100,7 +100,7 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin ) # Do inverse transformation from canonical objectives to actual objectives - return surface * np.where(population.maximize_f, -1, 1)[None, :] + return surface * np.where(population.obj_directions, -1, 1)[None, :] def save_mesh_to_stl(vertices: np.ndarray, triangles: np.ndarray, filename: str): @@ -301,7 +301,7 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin # If no reference point provided, compute one if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) * np.where(population.maximize_f, -1, 1) + ref_point = np.asarray(ref_point) * np.where(population.obj_directions, -1, 1) if not np.all(ref_point >= np.max(population.f_canonical, axis=0)): raise ValueError("Reference point must dominate all points") @@ -328,7 +328,7 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin triangles.extend(mesh_plane(sorted_by_y, 1, 0, 2, ref_point, vertex_dict, vertices)[1]) # Convert to numpy arrays and correct for canonicalized objectives - vertices = np.array(vertices) * np.where(population.maximize_f, -1, 1)[None, :] + vertices = np.array(vertices) * np.where(population.obj_directions, -1, 1)[None, :] triangles = np.array(triangles) return vertices, triangles diff --git a/tests/test_containers.py b/tests/test_containers.py index ddbb0af..888c866 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -364,8 +364,8 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - less_than_g=np.array([True]), - boundary_g=np.array([1.0]), + constraint_directions=np.array([True]), + constraint_targets=np.array([1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([True, False, True])) @@ -374,8 +374,8 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - less_than_g=np.array([False]), - boundary_g=np.array([1.0]), + constraint_directions=np.array([False]), + constraint_targets=np.array([1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([False, True, True])) @@ -384,8 +384,8 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - less_than_g=np.array([True]), - boundary_g=np.array([-1.0]), + constraint_directions=np.array([True]), + constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([False, True, True])) @@ -394,8 +394,8 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - less_than_g=np.array([False]), - boundary_g=np.array([-1.0]), + constraint_directions=np.array([False]), + constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([True, False, True])) @@ -411,8 +411,8 @@ def test_get_feasible_indices(): [0.1, 2.0], ] ), - less_than_g=np.array([True, False]), - boundary_g=np.array([0.0, 1.0]), + constraint_directions=np.array([True, False]), + constraint_targets=np.array([0.0, 1.0]), ) assert np.array_equal(pop3.get_feasible_indices(), np.array([True, False, True, False])) @@ -426,22 +426,22 @@ def test_list_settings_conversion(): x=np.empty((3, 0)), f=np.zeros((3, 2)), g=np.zeros((3, 3)), - maximize_f=[True, False], - less_than_g=[True, False, True], - boundary_g=[1, 2, -1], + obj_directions=[True, False], + constraint_directions=[True, False, True], + constraint_targets=[1, 2, -1], ) # Check types - assert isinstance(pop.maximize_f, np.ndarray) - assert isinstance(pop.less_than_g, np.ndarray) - assert isinstance(pop.boundary_g, np.ndarray) + assert isinstance(pop.obj_directions, np.ndarray) + assert isinstance(pop.constraint_directions, np.ndarray) + assert isinstance(pop.constraint_targets, np.ndarray) # Check values preserved - assert np.array_equal(pop.maximize_f, np.array([True, False])) - assert np.array_equal(pop.less_than_g, np.array([True, False, True])) - assert np.array_equal(pop.boundary_g, np.array([1.0, 2.0, -1.0])) + assert np.array_equal(pop.obj_directions, np.array([True, False])) + assert np.array_equal(pop.constraint_directions, np.array([True, False, True])) + assert np.array_equal(pop.constraint_targets, np.array([1.0, 2.0, -1.0])) # Check dtypes - assert pop.maximize_f.dtype == bool - assert pop.less_than_g.dtype == bool - assert pop.boundary_g.dtype == np.float64 + assert pop.obj_directions.dtype == bool + assert pop.constraint_directions.dtype == bool + assert pop.constraint_targets.dtype == np.float64 From d9991e048938329c85f622e782b6c75820458e1f Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 01:57:19 -0800 Subject: [PATCH 13/29] fix random obj/constraint settings generation --- src/paretobench/containers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 9211592..889593b 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -339,8 +339,8 @@ def from_random( # Create randomized settings for objectives/constraints if generate_obj_constraint_settings: - obj_directions = np.random.randint(0, 1, size=n_objectives, dtype=bool) - constraint_directions = np.random.randint(0, 1, size=n_constraints, dtype=bool) + obj_directions = np.random.randint(0, 2, size=n_objectives, dtype=bool) + constraint_directions = np.random.randint(0, 2, size=n_constraints, dtype=bool) constraint_targets = np.random.rand(n_constraints) else: obj_directions = None @@ -534,8 +534,8 @@ def from_random( # Create randomized settings for objectives/constraints (must be consistent between objects) if generate_obj_constraint_settings: - obj_directions = np.random.randint(0, 1, size=n_objectives, dtype=bool) - constraint_directions = np.random.randint(0, 1, size=n_constraints, dtype=bool) + obj_directions = np.random.randint(0, 2, size=n_objectives, dtype=bool) + constraint_directions = np.random.randint(0, 2, size=n_constraints, dtype=bool) constraint_targets = np.random.rand(n_constraints) for report in reports: report.obj_directions = obj_directions From 6a54215258acd43e448097e6f730fbf6f49376bd Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 02:06:41 -0800 Subject: [PATCH 14/29] update __repr__ --- example_notebooks/container_objects.ipynb | 8 ++++---- src/paretobench/containers.py | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/example_notebooks/container_objects.ipynb b/example_notebooks/container_objects.ipynb index 554f6be..82ce531 100644 --- a/example_notebooks/container_objects.ipynb +++ b/example_notebooks/container_objects.ipynb @@ -36,7 +36,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Population(size=32, vars=10, objs=2, cons=0, fevals=0)\n", + "Population(size=32, vars=10, objs=[-,-], cons=[], fevals=0)\n", "Decision variables: (32, 10)\n", "Objectives: (32, 2)\n", "Constraints: (32, 0)\n", @@ -119,17 +119,17 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Experiment(name='NSGA-II (default params)', created='2024-12-10', author='The author of ParetoBench', software='ParetoBench example notebook 1.0.0', runs=32)\n", + "Experiment(name='NSGA-II (default params)', created='2025-01-24', author='The author of ParetoBench', software='ParetoBench example notebook 1.0.0', runs=32)\n", "Number of histories: 32\n", "Name: NSGA-II (default params)\n", - "Creation time: 2024-12-10 05:21:51.993640+00:00\n" + "Creation time: 2025-01-24 10:05:40.012822+00:00\n" ] } ], diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 889593b..8a70a76 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -363,8 +363,28 @@ def from_random( def __len__(self): return self.x.shape[0] + def _get_obj_direction_str(self) -> str: + # Create a list of +/- symbols + return "[" + ",".join("+" if d else "-" for d in self.obj_directions) + "]" + + def _get_constraint_direction_str(self) -> str: + # Create a list of >/< symbols with their targets + return ( + "[" + + ",".join( + f"{'<=' if d else '>='}{t:.1e}" for d, t in zip(self.constraint_directions, self.constraint_targets) + ) + + "]" + ) + def __repr__(self) -> str: - return f"Population(size={len(self)}, vars={self.x.shape[1]}, objs={self.f.shape[1]}, cons={self.g.shape[1]}, fevals={self.fevals})" + return ( + f"Population(size={len(self)}, " + f"vars={self.x.shape[1]}, " + f"objs={self._get_obj_direction_str()}, " + f"cons={self._get_constraint_direction_str()}, " + f"fevals={self.fevals})" + ) def __str__(self): return self.__repr__() From 03dec4a59beaeb4e367642e0ff8a708fad44e7b1 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 02:11:32 -0800 Subject: [PATCH 15/29] change constraint direction meaning --- src/paretobench/containers.py | 12 ++++++------ tests/test_containers.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 8a70a76..b918adc 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -37,8 +37,8 @@ class Population(BaseModel): names_g: Optional[List[str]] = None # Configuration of objectives/constraints (minimization or maximization problem, direction of and boundary of constraint) - obj_directions: np.ndarray - constraint_directions: np.ndarray + obj_directions: np.ndarray # True is maximize, False is minimize + constraint_directions: np.ndarray # True is greater-than, False is Less-Than constraint_targets: np.ndarray @model_validator(mode="before") @@ -69,7 +69,7 @@ def set_default_vals(cls, values): if values.get("obj_directions") is None: values["obj_directions"] = np.zeros(values["f"].shape[1], dtype=bool) if values.get("constraint_directions") is None: - values["constraint_directions"] = np.zeros(values["g"].shape[1], dtype=bool) + values["constraint_directions"] = np.ones(values["g"].shape[1], dtype=bool) if values.get("constraint_targets") is None: values["constraint_targets"] = np.zeros(values["g"].shape[1], dtype=float) @@ -179,8 +179,8 @@ def g_canonical(self): """ Return constraints transformed such that g[...] >= 0 are the feasible solutions. """ - gc = np.where(self.constraint_directions, -1, 1)[None, :] * self.g - gc += np.where(self.constraint_directions, 1, -1)[None, :] * self.constraint_targets[None, :] + gc = np.where(self.constraint_directions, 1, -1)[None, :] * self.g + gc += np.where(self.constraint_directions, -1, 1)[None, :] * self.constraint_targets[None, :] return gc def __add__(self, other: "Population") -> "Population": @@ -372,7 +372,7 @@ def _get_constraint_direction_str(self) -> str: return ( "[" + ",".join( - f"{'<=' if d else '>='}{t:.1e}" for d, t in zip(self.constraint_directions, self.constraint_targets) + f"{'>=' if d else '<='}{t:.1e}" for d, t in zip(self.constraint_directions, self.constraint_targets) ) + "]" ) diff --git a/tests/test_containers.py b/tests/test_containers.py index 888c866..a2cde51 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -364,7 +364,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - constraint_directions=np.array([True]), + constraint_directions=np.array([False]), constraint_targets=np.array([1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([True, False, True])) @@ -374,7 +374,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - constraint_directions=np.array([False]), + constraint_directions=np.array([True]), constraint_targets=np.array([1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([False, True, True])) @@ -384,7 +384,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - constraint_directions=np.array([True]), + constraint_directions=np.array([False]), constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([False, True, True])) @@ -394,7 +394,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - constraint_directions=np.array([False]), + constraint_directions=np.array([True]), constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([True, False, True])) @@ -411,7 +411,7 @@ def test_get_feasible_indices(): [0.1, 2.0], ] ), - constraint_directions=np.array([True, False]), + constraint_directions=np.array([False, True]), constraint_targets=np.array([0.0, 1.0]), ) assert np.array_equal(pop3.get_feasible_indices(), np.array([True, False, True, False])) From bad8cf34a6dee45c9b3b74e46f01052d40e48b7b Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 02:16:36 -0800 Subject: [PATCH 16/29] some more organization --- src/paretobench/containers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index b918adc..2b28276 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -25,11 +25,13 @@ class Population(BaseModel): zero length. Names may be associated with decision variables, objectives, or constraints in the form of lists. """ + # The decision vars, objectives, and constraints x: np.ndarray f: np.ndarray g: np.ndarray + + # Total number of function evaluations performed during optimization after this population was completed fevals: int - model_config = ConfigDict(arbitrary_types_allowed=True) # Optional lists of names for decision variables, objectives, and constraints names_x: Optional[List[str]] = None @@ -41,6 +43,9 @@ class Population(BaseModel): constraint_directions: np.ndarray # True is greater-than, False is Less-Than constraint_targets: np.ndarray + # Pydantic config + model_config = ConfigDict(arbitrary_types_allowed=True) + @model_validator(mode="before") @classmethod def set_default_vals(cls, values): From 1dad58cf93a22ce4188728b000f22a72f61dc5ee Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Fri, 24 Jan 2025 02:17:20 -0800 Subject: [PATCH 17/29] more documentation --- src/paretobench/containers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 2b28276..c3f1dff 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -19,7 +19,8 @@ class Population(BaseModel): Whether each objective is being minimized or maximized is set by the boolean array obj_directions. Constraints are configured by the boolean array constraint_directions which sets whether it is a "less than" or "greater than" constraint and - constraint_targets which sets the boundary of the constraint. + constraint_targets which sets the boundary of the constraint. True in the boolean arrays means maximization or greater-than + and False means minimization or less-than. All arrays must have the same size batch dimension even if they are empty. In this case the non-batch dimension will be zero length. Names may be associated with decision variables, objectives, or constraints in the form of lists. From 23dbbb83f583ffb063b1e3de9873e10eebc74802 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 16:46:58 -0800 Subject: [PATCH 18/29] change to string directions for better readability --- src/paretobench/containers.py | 125 +++++++++++++------------ src/paretobench/plotting/attainment.py | 12 +-- src/paretobench/utils.py | 9 ++ tests/test_containers.py | 36 +------ 4 files changed, 85 insertions(+), 97 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index c3f1dff..fafc5a5 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -8,7 +8,7 @@ import re import string -from .utils import get_domination +from .utils import get_domination, binary_str_to_numpy class Population(BaseModel): @@ -17,13 +17,13 @@ class Population(BaseModel): the decision variables (x), the objectives (f), and inequality constraints (g). The first dimension of each array is the batch dimension. The number of evaluations of the objective functions performed to reach this state is also recorded. - Whether each objective is being minimized or maximized is set by the boolean array obj_directions. Constraints are configured - by the boolean array constraint_directions which sets whether it is a "less than" or "greater than" constraint and - constraint_targets which sets the boundary of the constraint. True in the boolean arrays means maximization or greater-than - and False means minimization or less-than. - All arrays must have the same size batch dimension even if they are empty. In this case the non-batch dimension will be zero length. Names may be associated with decision variables, objectives, or constraints in the form of lists. + + Whether each objectives is being minimized or maximized is set by the string obj_directions where each character corresponds + to an objectve and '+' means maximize with '-' meaning minimize. The constraints are configured by the string of directions + `constraint_directions` and the numpy array of targets `constraint_targets`. The string should contain either the '<' or '>' + character for the constraint at that index to be satisfied when it is less than or greater than the target respectively. """ # The decision vars, objectives, and constraints @@ -40,8 +40,10 @@ class Population(BaseModel): names_g: Optional[List[str]] = None # Configuration of objectives/constraints (minimization or maximization problem, direction of and boundary of constraint) - obj_directions: np.ndarray # True is maximize, False is minimize - constraint_directions: np.ndarray # True is greater-than, False is Less-Than + obj_directions: str # '+' means maximize, '-' means minimize + constraint_directions: ( + str # '<' means satisfied when less-than target, '>' means satisfied when greater-than target + ) constraint_targets: np.ndarray # Pydantic config @@ -73,17 +75,12 @@ def set_default_vals(cls, values): # Handle objectives / constraints settings (default to canonical problem) if values.get("obj_directions") is None: - values["obj_directions"] = np.zeros(values["f"].shape[1], dtype=bool) + values["obj_directions"] = "-" * values["f"].shape[1] if values.get("constraint_directions") is None: - values["constraint_directions"] = np.ones(values["g"].shape[1], dtype=bool) + values["constraint_directions"] = ">" * values["g"].shape[1] if values.get("constraint_targets") is None: values["constraint_targets"] = np.zeros(values["g"].shape[1], dtype=float) - # Support lists being passed to us (cast into numpy array) - for attr, dtype in [("obj_directions", bool), ("constraint_directions", bool), ("constraint_targets", float)]: - if isinstance(values[attr], list): - values[attr] = np.array(values[attr], dtype=dtype) - # Set fevals to number of individuals if not included if values.get("fevals") is None: values["fevals"] = batch_size @@ -115,26 +112,44 @@ def validate_names(self): return self @model_validator(mode="after") - def validate_obj_constraint_settings(self): - """ - Checks that the settings for objectives and constraints match with the dimensions of each. - """ - for attr, arrlen, propname, dtype in [ - ("obj_directions", self.m, "objectives", np.bool), - ("constraint_directions", self.g.shape[1], "constraints", np.bool), - ("constraint_targets", self.g.shape[1], "constraints", np.float64), - ]: - if not isinstance(getattr(self, attr), np.ndarray): - raise ValueError(f"obj_directions must be a numpy array, got: {type(getattr(self, attr))}") - if len(getattr(self, attr).shape) != 1: - raise ValueError(f"obj_directions must be 1D, shape was: {getattr(self, attr).shape}") - if len(getattr(self, attr)) != arrlen: - raise ValueError( - f"Length of obj_directions must match number of {propname} in f. Got {len(getattr(self, attr))} " - f"elements and {arrlen} {propname} from f" - ) - if getattr(self, attr).dtype != dtype: - raise ValueError(f"obj_directions dtype must be {dtype}. Got {getattr(self, attr).dtype}") + def validate_obj_directions(self): + if len(self.obj_directions) != self.m: + raise ValueError( + "Length of obj_directions must match number of objectives, got" + f" {len(self.obj_directions)} chars but we have {self.m} objectives" + ) + if not all(c in "+-" for c in self.obj_directions): + raise ValueError(f"obj_directions must contain only + or - characters. Got: {self.obj_directions}") + return self + + @model_validator(mode="after") + def validate_constraint_directions(self): + if len(self.constraint_directions) != self.n_constraints: + raise ValueError( + "Length of constraint_directions must match numbe of constraints, got" + f" {len(self.constraint_directions)} chars but we have {self.n_constraints} constraints" + ) + if not all(c in "<>" for c in self.constraint_directions): + raise ValueError( + f"constraint_directions must contain only < or > characters. Got: {self.constraint_directions}" + ) + return self + + @model_validator(mode="after") + def validate_constraint_targets(self): + # Check the targets + attr = "constraint_targets" + if not isinstance(getattr(self, attr), np.ndarray): + raise ValueError(f"{attr} must be a numpy array, got: {type(getattr(self, attr))}") + if len(getattr(self, attr).shape) != 1: + raise ValueError(f"{attr} must be 1D, shape was: {getattr(self, attr).shape}") + if len(getattr(self, attr)) != self.g.shape[1]: + raise ValueError( + f"Length of {attr} must match number of constraints in g. Got {len(getattr(self, attr))} " + f"elements and {self.g.shape[1]} constraints from g" + ) + if getattr(self, attr).dtype != np.float64: + raise ValueError(f"{attr} dtype must be {np.float64}. Got {getattr(self, attr).dtype}") return self @field_validator("x", "f", "g") @@ -168,8 +183,8 @@ def __eq__(self, other): and self.names_x == other.names_x and self.names_f == other.names_f and self.names_g == other.names_g - and np.array_equal(self.obj_directions, other.obj_directions) - and np.array_equal(self.constraint_directions, other.constraint_directions) + and self.obj_directions == other.obj_directions + and self.constraint_directions == other.constraint_directions and np.array_equal(self.constraint_targets, other.constraint_targets) ) @@ -178,15 +193,15 @@ def f_canonical(self): """ Return the objectives transformed so that we are a minimization problem. """ - return np.where(self.obj_directions, -1, 1)[None, :] * self.f + return binary_str_to_numpy(self.obj_directions, "-", "+")[None, :] * self.f @property def g_canonical(self): """ Return constraints transformed such that g[...] >= 0 are the feasible solutions. """ - gc = np.where(self.constraint_directions, 1, -1)[None, :] * self.g - gc += np.where(self.constraint_directions, -1, 1)[None, :] * self.constraint_targets[None, :] + gc = binary_str_to_numpy(self.constraint_directions, ">", "<")[None, :] * self.g + gc += binary_str_to_numpy(self.constraint_directions, "<", ">")[None, :] * self.constraint_targets[None, :] return gc def __add__(self, other: "Population") -> "Population": @@ -204,9 +219,9 @@ def __add__(self, other: "Population") -> "Population": raise ValueError("names_f are inconsistent between populations") if self.names_g != other.names_g: raise ValueError("names_g are inconsistent between populations") - if not np.array_equal(self.obj_directions, other.obj_directions): + if self.obj_directions != other.obj_directions: raise ValueError("obj_directions are inconsistent between populations") - if not np.array_equal(self.constraint_directions, other.constraint_directions): + if self.constraint_directions != other.constraint_directions: raise ValueError("constraint_directions are inconsistent between populations") if not np.array_equal(self.constraint_targets, other.constraint_targets): raise ValueError("constraint_targets are inconsistent between populations") @@ -345,8 +360,8 @@ def from_random( # Create randomized settings for objectives/constraints if generate_obj_constraint_settings: - obj_directions = np.random.randint(0, 2, size=n_objectives, dtype=bool) - constraint_directions = np.random.randint(0, 2, size=n_constraints, dtype=bool) + obj_directions = "".join(np.random.choice(["+", "-"], size=n_objectives)) + constraint_directions = "".join(np.random.choice([">", "<"], size=n_constraints)) constraint_targets = np.random.rand(n_constraints) else: obj_directions = None @@ -369,25 +384,15 @@ def from_random( def __len__(self): return self.x.shape[0] - def _get_obj_direction_str(self) -> str: - # Create a list of +/- symbols - return "[" + ",".join("+" if d else "-" for d in self.obj_directions) + "]" - def _get_constraint_direction_str(self) -> str: # Create a list of >/< symbols with their targets - return ( - "[" - + ",".join( - f"{'>=' if d else '<='}{t:.1e}" for d, t in zip(self.constraint_directions, self.constraint_targets) - ) - + "]" - ) + return "[" + ",".join(f"{d}{t:.1e}" for d, t in zip(self.constraint_directions, self.constraint_targets)) + "]" def __repr__(self) -> str: return ( f"Population(size={len(self)}, " f"vars={self.x.shape[1]}, " - f"objs={self._get_obj_direction_str()}, " + f"objs={self.obj_directions}, " f"cons={self._get_constraint_direction_str()}, " f"fevals={self.fevals})" ) @@ -478,8 +483,8 @@ def validate_consistent_populations(self): raise ValueError(f"Inconsistent names for constraints in reports: {names_g}") # Check settings for objectives and constraints - obj_directions = [tuple(x.obj_directions) for x in self.reports] - constraint_directions = [tuple(x.constraint_directions) for x in self.reports] + obj_directions = [x.obj_directions for x in self.reports] + constraint_directions = [x.constraint_directions for x in self.reports] constraint_targets = [tuple(x.constraint_targets) for x in self.reports] if obj_directions and len(set(obj_directions)) != 1: raise ValueError(f"Inconsistent obj_directions in reports: {obj_directions}") @@ -560,8 +565,8 @@ def from_random( # Create randomized settings for objectives/constraints (must be consistent between objects) if generate_obj_constraint_settings: - obj_directions = np.random.randint(0, 2, size=n_objectives, dtype=bool) - constraint_directions = np.random.randint(0, 2, size=n_constraints, dtype=bool) + obj_directions = "".join(np.random.choice(["+", "-"], size=n_objectives)) + constraint_directions = "".join(np.random.choice([">", "<"], size=n_constraints)) constraint_targets = np.random.rand(n_constraints) for report in reports: report.obj_directions = obj_directions diff --git a/src/paretobench/plotting/attainment.py b/src/paretobench/plotting/attainment.py index b4c0621..68535ba 100644 --- a/src/paretobench/plotting/attainment.py +++ b/src/paretobench/plotting/attainment.py @@ -1,7 +1,7 @@ import numpy as np from ..containers import Population -from ..utils import get_nondominated_inds +from ..utils import get_nondominated_inds, binary_str_to_numpy def get_reference_point(population: Population, padding=0.1): @@ -23,7 +23,7 @@ def get_reference_point(population: Population, padding=0.1): min_vals = np.min(population.f_canonical, axis=0) max_vals = np.max(population.f_canonical, axis=0) ref_point = max_vals + (max_vals - min_vals) * padding - return ref_point * np.where(population.obj_directions, -1, 1) + return ref_point * binary_str_to_numpy(population.obj_directions, "-", "+") def compute_attainment_surface_2d(population: Population, ref_point=None, padding=0.1): @@ -58,7 +58,7 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin # Handle missing ref-point if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) * np.where(population.obj_directions, -1, 1) + ref_point = np.asarray(ref_point) * binary_str_to_numpy(population.obj_directions, "-", "+") # Get only nondominated points population = population.get_nondominated_set() @@ -100,7 +100,7 @@ def compute_attainment_surface_2d(population: Population, ref_point=None, paddin ) # Do inverse transformation from canonical objectives to actual objectives - return surface * np.where(population.obj_directions, -1, 1)[None, :] + return surface * binary_str_to_numpy(population.obj_directions, "-", "+")[None, :] def save_mesh_to_stl(vertices: np.ndarray, triangles: np.ndarray, filename: str): @@ -301,7 +301,7 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin # If no reference point provided, compute one if ref_point is None: ref_point = get_reference_point(population, padding=padding) - ref_point = np.asarray(ref_point) * np.where(population.obj_directions, -1, 1) + ref_point = np.asarray(ref_point) * binary_str_to_numpy(population.obj_directions, "-", "+") if not np.all(ref_point >= np.max(population.f_canonical, axis=0)): raise ValueError("Reference point must dominate all points") @@ -328,7 +328,7 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin triangles.extend(mesh_plane(sorted_by_y, 1, 0, 2, ref_point, vertex_dict, vertices)[1]) # Convert to numpy arrays and correct for canonicalized objectives - vertices = np.array(vertices) * np.where(population.obj_directions, -1, 1)[None, :] + vertices = np.array(vertices) * binary_str_to_numpy(population.obj_directions, "-", "+")[None, :] triangles = np.array(triangles) return vertices, triangles diff --git a/src/paretobench/utils.py b/src/paretobench/utils.py index ce7ca0c..20bec78 100644 --- a/src/paretobench/utils.py +++ b/src/paretobench/utils.py @@ -180,3 +180,12 @@ def get_problem_from_obj_or_str(obj_or_str: Union[str, Problem]) -> Problem: return Problem.from_line_fmt(obj_or_str) else: raise ValueError(f"Unrecognized input type: {type(obj_or_str)}") + + +def binary_str_to_numpy(ss, pos_char, neg_char): + """ + Convert the characters of the string ss into a numpy array with +1 being wherever + the character pos_char shows up and -1 being wherever neg_char shows up. + """ + arr = np.array(list(ss)) + return np.where(arr == pos_char, 1, np.where(arr == neg_char, -1, 0)) diff --git a/tests/test_containers.py b/tests/test_containers.py index a2cde51..43c7b61 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -364,7 +364,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - constraint_directions=np.array([False]), + constraint_directions="<", constraint_targets=np.array([1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([True, False, True])) @@ -374,7 +374,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[0.5], [1.5], [1.0]]), - constraint_directions=np.array([True]), + constraint_directions=">", constraint_targets=np.array([1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([False, True, True])) @@ -384,7 +384,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - constraint_directions=np.array([False]), + constraint_directions="<", constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop1.get_feasible_indices(), np.array([False, True, True])) @@ -394,7 +394,7 @@ def test_get_feasible_indices(): x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.array([[-0.5], [-1.5], [-1.0]]), - constraint_directions=np.array([True]), + constraint_directions=">", constraint_targets=np.array([-1.0]), ) assert np.array_equal(pop2.get_feasible_indices(), np.array([True, False, True])) @@ -411,7 +411,7 @@ def test_get_feasible_indices(): [0.1, 2.0], ] ), - constraint_directions=np.array([False, True]), + constraint_directions="<>", constraint_targets=np.array([0.0, 1.0]), ) assert np.array_equal(pop3.get_feasible_indices(), np.array([True, False, True, False])) @@ -419,29 +419,3 @@ def test_get_feasible_indices(): # No constraints pop4 = Population(x=np.empty((3, 0)), f=np.empty((3, 0)), g=np.empty((3, 0))) assert np.array_equal(pop4.get_feasible_indices(), np.array([True, True, True])) - - -def test_list_settings_conversion(): - pop = Population( - x=np.empty((3, 0)), - f=np.zeros((3, 2)), - g=np.zeros((3, 3)), - obj_directions=[True, False], - constraint_directions=[True, False, True], - constraint_targets=[1, 2, -1], - ) - - # Check types - assert isinstance(pop.obj_directions, np.ndarray) - assert isinstance(pop.constraint_directions, np.ndarray) - assert isinstance(pop.constraint_targets, np.ndarray) - - # Check values preserved - assert np.array_equal(pop.obj_directions, np.array([True, False])) - assert np.array_equal(pop.constraint_directions, np.array([True, False, True])) - assert np.array_equal(pop.constraint_targets, np.array([1.0, 2.0, -1.0])) - - # Check dtypes - assert pop.obj_directions.dtype == bool - assert pop.constraint_directions.dtype == bool - assert pop.constraint_targets.dtype == np.float64 From 42d2a4172de980db07580c8af7b629d058a325bb Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 17:09:33 -0800 Subject: [PATCH 19/29] update save file --- .../paretobench_file_format_v1.1.0.h5 | Bin 159064 -> 159064 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5 b/tests/legacy_file_formats/paretobench_file_format_v1.1.0.h5 index 7508fea86faae3880be29b510fbace59b994b1d0..831337a088144613988c4a68dd8de6a10798133c 100644 GIT binary patch delta 1891 zcmb7_O-vI(6vy|?YTTt3T0Xa12sN5eLK;9Xeg-`l8bTU~8hQ|=DO9)!0S+}DESCcD zLYYIon2?xY06)4exCCMXF(xKn^tRz3HFA(pE8NhF?(WPMY*KBvhuL}af3Nd?^X9F( z!K)j5!o@ogLmrQZj21fA27Hb8oXB1;K3F&2O7J1x)eJ!by*H|d6v2Y z|Kw`xD|4u;vebgU6Ne**T8v3m$ZwTD@jmOcka%DV5Ft?gSvvOzi9w+DaOiKHf0Hgm z$>^ffj&bs0#`A8IdH)U>-Bic48;XmJDRdJ9#1t+%YoijWDQd`J-Rpk z9zBWFXiTb<)sX72H}x7ZGx_`RdSsK&;FssdBveRZRwG>giRlIWkHn@02X2n8LJ5AH zcB1_#UP*_<9rixTEbt5P>tf!t=Tm4w;EKoQV36H?X5g{aa4hp8Lu#3dO;Q=>IbtzMB^NTJ4jO{ewg8EONReKd zKPkP3{*Tl*$somZOLWVUbPVB0kVo3W!(bn)T9JWA(D7J?UJ=>UBFOhsmk^s+1iYH= z#>r|M%h`}#LmDIy;`U-x*Wjgc6VM@dqdHAM`^B2)JH`K6JD4&%y@!W-qP#FR0m>O0 bs)Ith2l-s&4@~?wFgeLTVimOlMY{d~1Xg7h delta 2333 zcmd^AO-vI(6rMLVakmI9D9N@6-NZy{hz7%@iAHDw(nf+H8a6?tD}_LmA3>9<2g^Y! zx0X4Si%Cz_@)IeWS`uPXZYCbQRt^>;CN_`)H{%6|oms$}XPC>IH}k#scE4}lOme)`iy5HIiDAPX&2OH3}N!PXBo_-(p~{0n%Ccq#W0t^7fDuY*}kl;)Z=H724^E= zC72WUrPUJSNqpo<`|`>QE@GQlrp@rC?;Msw(Le8?%H#^6Xhj0 zzRlS*xC?w|TuLEQw*CzyZD;)$keBma);xI^h8P*&=q9m(m8|vji@{AsMofdvdJGxj zE_72KZ31svN~1$lw!)pz7Akdcq#wHrOM)Vf>9;WJKKWO*;WX4PDzp}5C>8*(Q9(e0{Luf zhhlQke^z+Qz=8DFI8b?}qqYPLoBHQ<{;3oYh$a(IT~o_}h75`kXjJ1eD07AkMTf3u z?Ml^aNO#R04;|_pC-v9EcnGb?sDu}NNJx=g2lJPftdr Date: Sat, 25 Jan 2025 17:26:50 -0800 Subject: [PATCH 20/29] some editing --- src/paretobench/containers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index fafc5a5..b73e76d 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -126,7 +126,7 @@ def validate_obj_directions(self): def validate_constraint_directions(self): if len(self.constraint_directions) != self.n_constraints: raise ValueError( - "Length of constraint_directions must match numbe of constraints, got" + "Length of constraint_directions must match number of constraints, got" f" {len(self.constraint_directions)} chars but we have {self.n_constraints} constraints" ) if not all(c in "<>" for c in self.constraint_directions): @@ -156,7 +156,7 @@ def validate_constraint_targets(self): @classmethod def validate_numpy_arrays(cls, value: np.ndarray, info) -> np.ndarray: """ - Double checks that the arrays have the right numbe of dimensions and datatype. + Double checks that the arrays have the right number of dimensions and datatype. """ if value.dtype != np.float64: raise TypeError(f"Expected array of type { np.float64} for field '{info.field_name}', got {value.dtype}") @@ -392,7 +392,7 @@ def __repr__(self) -> str: return ( f"Population(size={len(self)}, " f"vars={self.x.shape[1]}, " - f"objs={self.obj_directions}, " + f"objs=[{self.obj_directions}], " f"cons={self._get_constraint_direction_str()}, " f"fevals={self.fevals})" ) @@ -407,7 +407,7 @@ def count_unique_individuals(self, decimals=13): Parameters ---------- decimals : int, optional - _description_, by default 13 + Number of digits to which we will compare the values, by default 13 Returns ------- @@ -446,7 +446,7 @@ class History(BaseModel): Assumptions: - All reports must have a consistent number of objectives, decision variables, and constraints. - - Objective/constraint settings and ames, if used, must be consistent across populations + - Objective/constraint settings and names, if used, must be consistent across populations """ reports: List[Population] From 464ffae7d4ab3fa7b8ae3478a22c3bc4119afb6b Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 22:15:43 -0800 Subject: [PATCH 21/29] add example of obj directions --- example_notebooks/container_objects.ipynb | 58 ++++++++++++++++++++++- src/paretobench/containers.py | 2 +- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/example_notebooks/container_objects.ipynb b/example_notebooks/container_objects.ipynb index 82ce531..e0f35b3 100644 --- a/example_notebooks/container_objects.ipynb +++ b/example_notebooks/container_objects.ipynb @@ -14,6 +14,7 @@ "metadata": {}, "outputs": [], "source": [ + "import numpy as np\n", "import os\n", "import paretobench as pb\n", "import tempfile" @@ -36,7 +37,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Population(size=32, vars=10, objs=[-,-], cons=[], fevals=0)\n", + "Population(size=32, vars=10, objs=[--], cons=[], fevals=0)\n", "Decision variables: (32, 10)\n", "Objectives: (32, 2)\n", "Constraints: (32, 0)\n", @@ -75,6 +76,59 @@ "pop.names_g = []" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Populations default to representing minimization problems with constraints that are satisfied when each g >= 0. This can be modified as in the following code block. Configuring these settings is important for when using container methods to find feasible individuals and in calculation of domination. We observe the difference on randomly generated data here." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Original: n_nondominated=2, n_feasible=16\n", + "\"Correct\" Settings: n_nondominated=3, n_feasible=4\n" + ] + }, + { + "data": { + "text/plain": [ + "Population(size=100, vars=10, objs=[+-], cons=[<2.0e-01,>4.0e-01,<6.0e-01], fevals=0)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# New population with some constraints\n", + "pop = pb.Population.from_random(n_objectives=2, n_decision_vars=10, n_constraints=3, pop_size=100)\n", + "\n", + "# Get the number of nondominated and feasible solutions\n", + "print(f\"Original: n_nondominated={len(pop.get_nondominated_set())}, n_feasible={np.sum(pop.get_feasible_indices())}\")\n", + "\n", + "# Set the direction of the objectives. First objective is being maximized, second minimized\n", + "pop.obj_directions = \"+-\"\n", + "\n", + "# Set the direction and targets of the constraints.\n", + "pop.constraint_directions = \"<><\"\n", + "pop.constraint_targets = np.array([0.2, 0.4, 0.6])\n", + "\n", + "# Get the number of nondominated and feasible solutions\n", + "print(\n", + " f'\"Correct\" Settings: n_nondominated={len(pop.get_nondominated_set())}, n_feasible={np.sum(pop.get_feasible_indices())}'\n", + ")\n", + "\n", + "pop" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -85,7 +139,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 10, "metadata": {}, "outputs": [ { diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index b73e76d..9a5d930 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -346,7 +346,7 @@ def from_random( """ x = np.random.rand(pop_size, n_decision_vars) f = np.random.rand(pop_size, n_objectives) - g = np.random.rand(pop_size, n_constraints) if n_constraints > 0 else np.empty((pop_size, 0)) + g = np.random.rand(pop_size, n_constraints) - 0.5 if n_constraints > 0 else np.empty((pop_size, 0)) # Optionally generate names if generate_names is True if generate_names: From 989d84314fd4e2ab4cd6bd557346093ff3c39170 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 22:35:08 -0800 Subject: [PATCH 22/29] better container notebook --- example_notebooks/container_objects.ipynb | 103 +++++++++++++--------- 1 file changed, 59 insertions(+), 44 deletions(-) diff --git a/example_notebooks/container_objects.ipynb b/example_notebooks/container_objects.ipynb index e0f35b3..bbd5de5 100644 --- a/example_notebooks/container_objects.ipynb +++ b/example_notebooks/container_objects.ipynb @@ -30,24 +30,29 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 32, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Population(size=32, vars=10, objs=[--], cons=[], fevals=0)\n", + "Population(size=32, vars=10, objs=[--], cons=[], fevals=32)\n", "Decision variables: (32, 10)\n", "Objectives: (32, 2)\n", "Constraints: (32, 0)\n", - "Function evaluations: 0\n" + "Function evaluations: 32\n" ] } ], "source": [ "# Create an example population\n", - "pop = pb.Population.from_random(n_objectives=2, n_decision_vars=10, n_constraints=0, pop_size=32)\n", + "pop = pb.Population(\n", + " x=np.random.random((32, 10)), # 32 individuals worth of 10 decision vars\n", + " f=np.random.random((32, 2)), # 2 objectives\n", + " # Not specifying an array (constraints here) will cause it to populate empty based on other's shape\n", + " fevals=32,\n", + ")\n", "print(pop)\n", "\n", "# Examine some of the parameters\n", @@ -66,14 +71,31 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 29, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "Population(size=32, vars=10, objs=[--], cons=[>0.0e+00], fevals=32)" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "# Set naems of the decision variables, objectives, and constraints\n", - "pop.names_x = [f\"Decision var {idx}\" for idx in range(pop.x.shape[1])]\n", - "pop.names_f = [\"Objective 1\", \"Objective 2\"]\n", - "pop.names_g = []" + "# Create a population with some names\n", + "pop = pb.Population(\n", + " x=np.random.random((32, 10)),\n", + " f=np.random.random((32, 2)),\n", + " g=np.random.random((32, 1)), # Add a single constraint this time\n", + " names_x=[f\"Decision var {idx}\" for idx in range(pop.x.shape[1])],\n", + " names_f=[\"Objective_1\", \"Objective_2\"],\n", + " names_g=[\"Important_Constraint\"],\n", + ")\n", + "pop" ] }, { @@ -85,45 +107,29 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 31, "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Original: n_nondominated=2, n_feasible=16\n", - "\"Correct\" Settings: n_nondominated=3, n_feasible=4\n" - ] - }, { "data": { "text/plain": [ - "Population(size=100, vars=10, objs=[+-], cons=[<2.0e-01,>4.0e-01,<6.0e-01], fevals=0)" + "Population(size=32, vars=10, objs=[+-+], cons=[>3.3e-01,<6.6e-01], fevals=32)" ] }, - "execution_count": 22, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# New population with some constraints\n", - "pop = pb.Population.from_random(n_objectives=2, n_decision_vars=10, n_constraints=3, pop_size=100)\n", - "\n", - "# Get the number of nondominated and feasible solutions\n", - "print(f\"Original: n_nondominated={len(pop.get_nondominated_set())}, n_feasible={np.sum(pop.get_feasible_indices())}\")\n", - "\n", - "# Set the direction of the objectives. First objective is being maximized, second minimized\n", - "pop.obj_directions = \"+-\"\n", - "\n", - "# Set the direction and targets of the constraints.\n", - "pop.constraint_directions = \"<><\"\n", - "pop.constraint_targets = np.array([0.2, 0.4, 0.6])\n", - "\n", - "# Get the number of nondominated and feasible solutions\n", - "print(\n", - " f'\"Correct\" Settings: n_nondominated={len(pop.get_nondominated_set())}, n_feasible={np.sum(pop.get_feasible_indices())}'\n", + "# Create a population and specify the objective / constraint directions and target\n", + "pop = pb.Population(\n", + " x=np.random.random((32, 10)),\n", + " f=np.random.random((32, 3)),\n", + " g=np.random.random((32, 2)),\n", + " obj_directions=\"+-+\", # \"+\" indicates maximization, \"-\" inidicates minimization\n", + " constraint_directions=\"><\", # \"<\" means satisifed when g\" means satsified when g>target\n", + " constraint_targets=np.array([0.33, 0.66]), # >0.33 constraint and <0.66 constraint\n", ")\n", "\n", "pop" @@ -139,23 +145,32 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 36, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "History(problem='WFG1 (n=10)', reports=15, vars=10, objs=2, cons=0)\n", - "Problem: WFG1 (n=10)\n", - "Number of reports: 15\n" + "History(problem='WFG1', reports=10, vars=30, objs=2, cons=0)\n", + "Problem: WFG1\n", + "Number of reports: 10\n" ] } ], "source": [ - "# Create an example history object\n", - "hist = pb.History.from_random(n_populations=15, n_objectives=2, n_decision_vars=10, n_constraints=0, pop_size=32)\n", - "hist.problem = \"WFG1 (n=10)\"\n", + "# Create some population objects which will go into the history. We will use the random generation helper function here.\n", + "n_reports = 10\n", + "reports = [\n", + " pb.Population.from_random(n_objectives=2, n_decision_vars=30, n_constraints=0, pop_size=50)\n", + " for _ in range(n_reports)\n", + "]\n", + "\n", + "# Construct the history object\n", + "hist = pb.History(\n", + " reports=reports,\n", + " problem=\"WFG1\", # Use ParetoBench single line format for maximum compatibility with plotting, etc\n", + ")\n", "print(hist)\n", "\n", "# Print some of the properties\n", From 2358e93ea676fe52864a37807e36df7ac3f38e4d Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 22:39:28 -0800 Subject: [PATCH 23/29] add objectives constraints readout to history --- example_notebooks/container_objects.ipynb | 20 ++++++++++---------- src/paretobench/containers.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/example_notebooks/container_objects.ipynb b/example_notebooks/container_objects.ipynb index bbd5de5..07e0310 100644 --- a/example_notebooks/container_objects.ipynb +++ b/example_notebooks/container_objects.ipynb @@ -30,7 +30,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 2, "metadata": {}, "outputs": [ { @@ -71,7 +71,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -80,7 +80,7 @@ "Population(size=32, vars=10, objs=[--], cons=[>0.0e+00], fevals=32)" ] }, - "execution_count": 29, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -107,7 +107,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -116,7 +116,7 @@ "Population(size=32, vars=10, objs=[+-+], cons=[>3.3e-01,<6.6e-01], fevals=32)" ] }, - "execution_count": 31, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -145,14 +145,14 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "History(problem='WFG1', reports=10, vars=30, objs=2, cons=0)\n", + "History(problem='WFG1', reports=10, vars=30, objs=[--], cons=[])\n", "Problem: WFG1\n", "Number of reports: 10\n" ] @@ -188,17 +188,17 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Experiment(name='NSGA-II (default params)', created='2025-01-24', author='The author of ParetoBench', software='ParetoBench example notebook 1.0.0', runs=32)\n", + "Experiment(name='NSGA-II (default params)', created='2025-01-26', author='The author of ParetoBench', software='ParetoBench example notebook 1.0.0', runs=32)\n", "Number of histories: 32\n", "Name: NSGA-II (default params)\n", - "Creation time: 2025-01-24 10:05:40.012822+00:00\n" + "Creation time: 2025-01-26 06:36:29.299300+00:00\n" ] } ], diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 9a5d930..bedd815 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -704,8 +704,8 @@ def pf_reduce(a, b): def __repr__(self) -> str: dims = ( ( - f"vars={self.reports[0].x.shape[1]}, objs={self.reports[0].f.shape[1]}, " - f"cons={self.reports[0].g.shape[1]}" + f"vars={self.reports[0].x.shape[1]}, objs=[{self.reports[0].obj_directions}], " + f"cons={self.reports[0]._get_constraint_direction_str()}" ) if self.reports else "empty" From ce0218e6aba97aa0caf0a727f7414f07913911b4 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 22:59:19 -0800 Subject: [PATCH 24/29] update random constraints --- src/paretobench/containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index bedd815..02c996b 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -362,7 +362,7 @@ def from_random( if generate_obj_constraint_settings: obj_directions = "".join(np.random.choice(["+", "-"], size=n_objectives)) constraint_directions = "".join(np.random.choice([">", "<"], size=n_constraints)) - constraint_targets = np.random.rand(n_constraints) + constraint_targets = np.random.rand(n_constraints) - 0.5 else: obj_directions = None constraint_directions = None From e036dd75288d4e2369afcaa58c02021b792bf546 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 23:20:02 -0800 Subject: [PATCH 25/29] editing --- example_notebooks/container_objects.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_notebooks/container_objects.ipynb b/example_notebooks/container_objects.ipynb index 07e0310..4f9baa5 100644 --- a/example_notebooks/container_objects.ipynb +++ b/example_notebooks/container_objects.ipynb @@ -102,7 +102,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Populations default to representing minimization problems with constraints that are satisfied when each g >= 0. This can be modified as in the following code block. Configuring these settings is important for when using container methods to find feasible individuals and in calculation of domination. We observe the difference on randomly generated data here." + "Populations default to representing minimization problems with constraints that are satisfied when each g >= 0. This can be modified as in the following code block. Configuring these settings is important when using container methods to find feasible individuals and in calculation of domination." ] }, { From 39dfcbd033fbf3894ce65e0f23e7220a98356901 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 23:21:52 -0800 Subject: [PATCH 26/29] minor edits --- src/paretobench/containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index 02c996b..cfc9a96 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -39,7 +39,7 @@ class Population(BaseModel): names_f: Optional[List[str]] = None names_g: Optional[List[str]] = None - # Configuration of objectives/constraints (minimization or maximization problem, direction of and boundary of constraint) + # Configuration of objectives/constraints (minimization or maximization problem, direction of and target of constraint) obj_directions: str # '+' means maximize, '-' means minimize constraint_directions: ( str # '<' means satisfied when less-than target, '>' means satisfied when greater-than target From d82f8884ff4ce1b6ba61117cc45e386e56282169 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 23:25:10 -0800 Subject: [PATCH 27/29] minor edits --- src/paretobench/containers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/paretobench/containers.py b/src/paretobench/containers.py index cfc9a96..db3b459 100644 --- a/src/paretobench/containers.py +++ b/src/paretobench/containers.py @@ -119,7 +119,7 @@ def validate_obj_directions(self): f" {len(self.obj_directions)} chars but we have {self.m} objectives" ) if not all(c in "+-" for c in self.obj_directions): - raise ValueError(f"obj_directions must contain only + or - characters. Got: {self.obj_directions}") + raise ValueError(f'obj_directions must contain only + or - characters. Got: "{self.obj_directions}"') return self @model_validator(mode="after") @@ -131,7 +131,7 @@ def validate_constraint_directions(self): ) if not all(c in "<>" for c in self.constraint_directions): raise ValueError( - f"constraint_directions must contain only < or > characters. Got: {self.constraint_directions}" + f'constraint_directions must contain only < or > characters. Got: "{self.constraint_directions}"' ) return self From 43a3ef928dc99de73c9d6f1c49f48cdd9194c706 Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 23:40:21 -0800 Subject: [PATCH 28/29] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 26f45ee..ad52c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paretobench" -version = "0.3.0" +version = "0.4.0" authors = [ { name="Christopher M. Pierce", email="contact@chris-pierce.com" }, ] From 01a09ee675f16c816372dbc2e0758a754ed8246b Mon Sep 17 00:00:00 2001 From: "Christopher M. Pierce" Date: Sat, 25 Jan 2025 23:46:14 -0800 Subject: [PATCH 29/29] correct error check --- src/paretobench/plotting/attainment.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/paretobench/plotting/attainment.py b/src/paretobench/plotting/attainment.py index 68535ba..3dac9b6 100644 --- a/src/paretobench/plotting/attainment.py +++ b/src/paretobench/plotting/attainment.py @@ -303,11 +303,10 @@ def compute_attainment_surface_3d(population: Population, ref_point=None, paddin ref_point = get_reference_point(population, padding=padding) ref_point = np.asarray(ref_point) * binary_str_to_numpy(population.obj_directions, "-", "+") - if not np.all(ref_point >= np.max(population.f_canonical, axis=0)): - raise ValueError("Reference point must dominate all points") - # Get the nondominated points population = population.get_nondominated_set() + if not np.all(ref_point >= np.max(population.f_canonical, axis=0)): + raise ValueError("Reference point must be dominated by all non-dominated points in set") vertices = [] triangles = []