Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/python_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ jobs:
pip install ".[test]"
- name: Run the tests (Linux/macOS)
if: runner.os != 'Windows'
shell: bash
run: |
echo -e '## Pytest Results\n\n' >> "$GITHUB_STEP_SUMMARY"
echo -e '```' >> "$GITHUB_STEP_SUMMARY"
pytest -v --cov=beamfit/ tests 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"
pytest -v --cov=paretobench tests 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"
echo -e '```' >> "$GITHUB_STEP_SUMMARY"
- name: Run the tests (Windows)
if: runner.os == 'Windows'
Expand Down
277 changes: 194 additions & 83 deletions src/paretobench/containers.py

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion src/paretobench/ext/xopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@
# Handle xopt 2.x and 3.x constraint/objective accessor styles
try:
from xopt.vocs import GreaterThanConstraint
from gest_api.vocs import LessThanConstraint, MaximizeObjective, MinimizeObjective
from gest_api.vocs import (
ContinuousVariable,
DiscreteVariable,
LessThanConstraint,
MaximizeObjective,
MinimizeObjective,
)

def _constraint_value(c):
return c.value

def _variable_bounds(var):
if isinstance(var, ContinuousVariable):
return var.domain[0], var.domain[1]
elif isinstance(var, DiscreteVariable):
raise ValueError("DiscreteVariable is currently not supported by ParetoBench")
raise ValueError(f"Unrecognized variable type: {type(var)}")

def _constraint_direction(c):
if isinstance(c, GreaterThanConstraint):
return ">"
Expand All @@ -39,6 +52,9 @@ def _objective_direction(obj):
def _constraint_value(c):
return c[1]

def _variable_bounds(var):
return var[0], var[1]

def _constraint_direction(c):
if c[0] == "GREATER_THAN":
return ">"
Expand Down Expand Up @@ -160,6 +176,9 @@ def population_from_dataframe(df: pd.DataFrame, vocs: VOCS, errors_as_constraint
Population
Population object with the loaded data
"""
# Get the decision variable bounds. Note that vocs.bounds is not used here as its shape changed between xopt 2.x and 3.x
var_bounds = [_variable_bounds(vocs.variables[name]) for name in vocs.variable_names]

# Get base constraints if they exist
g = df[vocs.constraint_names].to_numpy() if vocs.constraints else None
names_g = vocs.constraint_names
Expand Down Expand Up @@ -190,6 +209,8 @@ def population_from_dataframe(df: pd.DataFrame, vocs: VOCS, errors_as_constraint
obj_directions="".join([_objective_direction(vocs.objectives[name]) for name in vocs.objective_names]),
constraint_directions="".join(constraint_directions),
constraint_targets=np.array(constraint_targets),
var_lower_bounds=np.array([b[0] for b in var_bounds], dtype=np.float64),
var_upper_bounds=np.array([b[1] for b in var_bounds], dtype=np.float64),
)


Expand Down
2 changes: 1 addition & 1 deletion src/paretobench/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def var_lower_bounds(self):

@property
def var_upper_bounds(self):
return np.array([[1.0, 5.0]])
return np.array([1.0, 5.0])

@property
def reference(self):
Expand Down
3 changes: 3 additions & 0 deletions src/paretobench/plotting/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def history_dvar_pairs(
hist_bins=hist_bins,
show_names=show_names,
scale=scale,
plot_bounds=plot_bounds,
)

if generation_mode == "cumulative":
Expand Down Expand Up @@ -336,12 +337,14 @@ def history_dvar_pairs(

# Only plot bounds on the last iteration if requested
if plot_idx == len(indices) - 1:
plot_settings["plot_bounds"] = plot_bounds
if plot_bounds and user_specified_bounds:
plot_settings["lower_bounds"] = lower_bounds
plot_settings["upper_bounds"] = upper_bounds
elif plot_bounds and history.problem is not None:
plot_settings["problem"] = history.problem
else:
plot_settings["plot_bounds"] = False
plot_settings["problem"] = None
plot_settings["lower_bounds"] = None
plot_settings["upper_bounds"] = None
Expand Down
62 changes: 44 additions & 18 deletions src/paretobench/plotting/population.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

from ..containers import Population
from ..exceptions import EmptyPopulationError, NoDecisionVarsError, NoObjectivesError
from ..problem import Problem, ProblemWithFixedPF, ProblemWithPF
from ..utils import get_problem_from_obj_or_str
from ..problem import Problem, ProblemWithFixedPF, ProblemWithPF, get_problem_from_obj_or_str
from .attainment import compute_attainment_surface_2d, compute_attainment_surface_3d
from .utils import get_per_point_settings_population, alpha_scatter, selection_to_indices

Expand Down Expand Up @@ -291,6 +290,28 @@ def population_obj_scatter(
return fig, ax


def _draw_bound(line_fn, bounds, idx, scale, props):
"""
Draws one decision variable bound onto an axis. Unset and infinite bounds are skipped as they have no location
on the plot and would ruin the axis limits.

Parameters
----------
line_fn : callable
The axis method used to draw the line (`ax.axvline` or `ax.axhline`).
bounds : array-like or None
The lower or upper bounds of all decision variables.
idx : int
Index of the decision variable being drawn.
scale : float
Scale factor applied to this decision variable.
props : dict
Line properties passed through to `line_fn`.
"""
if bounds is not None and np.isfinite(bounds[idx]):
line_fn(scale * bounds[idx], **props)


def population_dvar_pairs(
population: Population,
dvars: Optional[Union[int, slice, List[int], Tuple[int, int]]] = None,
Expand All @@ -305,6 +326,7 @@ def population_dvar_pairs(
upper_bounds: Optional[np.ndarray] = None,
color: Optional[str] = None,
scale: Optional[np.ndarray] = None,
plot_bounds: bool = True,
):
"""
Creates a pairs plot (scatter matrix) showing correlations between decision variables
Expand Down Expand Up @@ -332,14 +354,16 @@ def population_dvar_pairs(
problem : str/Problem, optional
The problem for plotting decision variable bounds
lower_bounds : array-like, optional
Lower bounds for each decision variable
Lower bounds for each decision variable. Defaults to the bounds carried by the population.
upper_bounds : array-like, optional
Upper bounds for each decision variable
Upper bounds for each decision variable. Defaults to the bounds carried by the population.
color : str, optional
What color should we use for the points. Defaults to selecting from matplotlib color cycler
scale : array-like, optional
Scale factors for each variable. Must have the same length as the number of decision vars.
If None, no scaling is applied.
plot_bounds : bool, optional
Whether to plot the decision variable bounds, by default True. Infinite bounds are not plotted.

Returns
-------
Expand Down Expand Up @@ -367,10 +391,6 @@ def population_dvar_pairs(
var_indices = np.array(selection_to_indices(dvars, population.n))
n_vars = len(var_indices)

# Default, don't show bounds
lower_bounds = None
upper_bounds = None

# Handle user specified problem
if problem is not None:
if (lower_bounds is not None) or (upper_bounds is not None):
Expand All @@ -383,6 +403,16 @@ def population_dvar_pairs(
lower_bounds = problem.var_lower_bounds
upper_bounds = problem.var_upper_bounds

# Fall back onto the bounds carried by the population itself
elif plot_bounds and (lower_bounds is None) and (upper_bounds is None):
lower_bounds = population.var_lower_bounds
upper_bounds = population.var_upper_bounds

# The bounds are not plotted when the user asks us not to
if not plot_bounds:
lower_bounds = None
upper_bounds = None

# Validate and convert bounds to numpy arrays if provided
if lower_bounds is not None:
lower_bounds = np.asarray(lower_bounds)
Expand Down Expand Up @@ -484,10 +514,8 @@ def population_dvar_pairs(
base_color = patches[0].get_facecolor()

# Add vertical bound lines to histograms
if lower_bounds is not None:
ax.axvline(scale[var_indices[i]] * lower_bounds[var_indices[i]], **bound_props)
if upper_bounds is not None:
ax.axvline(scale[var_indices[i]] * upper_bounds[var_indices[i]], **bound_props)
_draw_bound(ax.axvline, lower_bounds, var_indices[i], scale[var_indices[i]], bound_props)
_draw_bound(ax.axvline, upper_bounds, var_indices[i], scale[var_indices[i]], bound_props)

# Off-diagonal plots (scatter plots)
else:
Expand All @@ -505,12 +533,10 @@ def population_dvar_pairs(
base_color = scatter.get_facecolor()[0] # Get the color that matplotlib assigned

# Add bound lines to scatter plots
if lower_bounds is not None:
ax.axvline(scale[var_indices[j]] * lower_bounds[var_indices[j]], **bound_props) # x-axis bound
ax.axhline(scale[var_indices[i]] * lower_bounds[var_indices[i]], **bound_props) # y-axis bound
if upper_bounds is not None:
ax.axvline(scale[var_indices[j]] * upper_bounds[var_indices[j]], **bound_props) # x-axis bound
ax.axhline(scale[var_indices[i]] * upper_bounds[var_indices[i]], **bound_props) # y-axis bound
_draw_bound(ax.axvline, lower_bounds, var_indices[j], scale[var_indices[j]], bound_props)
_draw_bound(ax.axhline, lower_bounds, var_indices[i], scale[var_indices[i]], bound_props)
_draw_bound(ax.axvline, upper_bounds, var_indices[j], scale[var_indices[j]], bound_props)
_draw_bound(ax.axhline, upper_bounds, var_indices[i], scale[var_indices[i]], bound_props)
if i == n_vars - 1:
ax.set_xlabel(var_names[j])
if j == 0:
Expand Down
47 changes: 41 additions & 6 deletions src/paretobench/problem.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import numpy as np
from pydantic import BaseModel

from .containers import Population
from .exceptions import DeserializationError, InputError
from .factory import create_problem
from .simple_serialize import dumps, loads
Expand Down Expand Up @@ -45,8 +46,7 @@ def __call__(self, x: np.ndarray, check_bounds=True):
raise InputError(msg)
if check_bounds and ((x > self.var_upper_bounds).all() or (x < self.var_lower_bounds).all()):
raise InputError("Input lies outside of problem bounds.")
pop = self._call(x[None, :])
pop.x = np.reshape(x, (1, -1))
x = np.reshape(x, (1, -1))

# If batched input is used
elif len(x.shape) == 2:
Expand All @@ -57,15 +57,23 @@ def __call__(self, x: np.ndarray, check_bounds=True):
raise InputError(msg)
if check_bounds and ((x > self.var_upper_bounds).all() or (x < self.var_lower_bounds).all()):
raise InputError("Input lies outside of problem bounds.")
pop = self._call(x)
pop.x = x

# If user provided something not usable
else:
raise ValueError(f"Incompatible shape of input array x: {x.shape}")

# Set the decision variables
return pop
# Attach the decision variables and the problem's bounds to the evaluated population. These must be set together
# because the bounds are validated against the number of decision variables, so assigning them one at a time
# leaves the population in a state which does not validate.
pop = self._call(x)
return Population.model_validate(
{
**dict(pop),
"x": x,
"var_lower_bounds": np.asarray(self.var_lower_bounds, dtype=np.float64),
"var_upper_bounds": np.asarray(self.var_upper_bounds, dtype=np.float64),
}
)

def _call(self, x: np.ndarray):
"""
Expand Down Expand Up @@ -193,6 +201,33 @@ def __str__(self):
return self.__repr__()


def get_problem_from_obj_or_str(obj_or_str: "str | Problem") -> "Problem":
"""Convert input to Problem instance.

Parameters
----------
obj_or_str : Problem or str
Input to convert. If already a Problem instance, returns as-is.
If string, creates Problem from line format.

Returns
-------
Problem
The resulting Problem instance.

Raises
------
ValueError
If input is neither Problem nor str type.
"""
if isinstance(obj_or_str, Problem):
return obj_or_str
elif isinstance(obj_or_str, str):
return Problem.from_line_fmt(obj_or_str)
else:
raise ValueError(f"Unrecognized input type: {type(obj_or_str)}")


class ProblemWithPF:
"""
Mixin class for problems with a defined Pareto front where you can request a certain number of points from it.
Expand Down
30 changes: 0 additions & 30 deletions src/paretobench/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
from itertools import combinations, chain, count
from math import comb
from typing import Union
import numpy as np

from .problem import Problem


def get_betas(m, p):
"""
Expand Down Expand Up @@ -171,33 +168,6 @@ def weighted_chunk_sizes(n, weights):
return ns


def get_problem_from_obj_or_str(obj_or_str: Union[str, Problem]) -> Problem:
"""Convert input to Problem instance.

Parameters
----------
obj_or_str : Problem or str
Input to convert. If already a Problem instance, returns as-is.
If string, creates Problem from line format.

Returns
-------
Problem
The resulting Problem instance.

Raises
------
ValueError
If input is neither Problem nor str type.
"""
if isinstance(obj_or_str, Problem):
return obj_or_str
elif isinstance(obj_or_str, str):
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
Expand Down
5 changes: 5 additions & 0 deletions tests/ext/test_xopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ def df_comp(df1, df2):
assert all(tp.constraint_targets == [0.0, 0.0])
assert tp.fevals == (idx + 1) * population_size

# Confirm the decision variable bounds came from the VOCS
vocs_bounds = [_variable_bounds(tnk_vocs.variables[name]) for name in tnk_vocs.variable_names]
np.testing.assert_allclose(tp.var_lower_bounds, [b[0] for b in vocs_bounds])
np.testing.assert_allclose(tp.var_upper_bounds, [b[1] for b in vocs_bounds])

# Confirm data is correct
df_comp(rx, pd.DataFrame(tp.x, columns=tp.names_x))
rf.columns = [x.removeprefix("objective_") for x in rf.columns]
Expand Down
16 changes: 12 additions & 4 deletions tests/generate_file_version_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def make_experiment():
pop_size=25,
generate_names=True,
generate_obj_constraint_settings=True,
generate_bounds=True,
)
run.problem = problem
runs.append(run)
Expand Down Expand Up @@ -84,12 +85,19 @@ def main():
)
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, help="directory to save the file into")
parser.add_argument("--force", action="store_true", help="overwrite an existing file for this version")
parser.add_argument("--manifest", type=Path, help="rewrite the manifest of an existing file and exit")
parser.add_argument(
"--refresh-manifests",
type=Path,
nargs="*",
help="rewrite the manifests of files which already exist, defaulting to every file in the output directory",
)
args = parser.parse_args()

# Refresh the manifest of a file which already exists (used to bootstrap files saved by older versions)
if args.manifest is not None:
print(f"Wrote {write_manifest(args.manifest)}")
# Rewrite the manifests of files which already exist. Needed whenever the contents of a manifest change, such as
# when a new field is added to the containers, and to bootstrap the manifests of files saved by older versions.
if args.refresh_manifests is not None:
for path in args.refresh_manifests or sorted(args.out_dir.glob("*.h5")):
print(f"Wrote {write_manifest(path)}")
return

# Save the data, then name the file after the version which actually ended up in it
Expand Down
Loading
Loading