#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from types import FunctionType
from typing import List, Optional, Tuple, Union, cast
from ax.core.optimization_config import (
OptimizationConfig,
MultiObjectiveOptimizationConfig,
)
from ax.core.search_space import SearchSpace
from ax.service.utils.instantiation import TParameterRepresentation
from ax.utils.common.base import Base
from ax.utils.common.equality import equality_typechecker
from ax.utils.common.logger import get_logger
from ax.utils.common.typeutils import checked_cast
from ax.utils.measurement.synthetic_functions import SyntheticFunction
logger = get_logger(__name__)
NONE_DOMAIN_ERROR = """
When creating a `BenchmarkProblem` with custom function, one of the `domain` or
`search_space` argumetns is required to be non-null.
"""
NONE_SYNTHETIC_FUNCTION_DOMAIN_ERROR = """
When creating a `BenchmarkProblem` with a `SyntheticFunction`, a non-null `domain`
argument or a non-null `domain` property on the `SyntheticFunction` is required.
"""
ANONYMOUS_FUNCTION_ERROR = """
If using anonymous function, please pass non-null `name` argument.
"""
ADHOC_FUNCTION_NOISE_SET_ERROR = """
Cannot set `noise_sd` setting for problems that use anonymous functions, as their
inherent noise level is unknown. Add a known synthetic function to `ax.utils.
measurement.synthetic_functions` to be able to add noise to the benchmark problem.
"""
[docs]class BenchmarkProblem(Base):
"""Benchmark problem, represented in terms of Ax search space and optimization
config. Useful to represent complex problems that involve constaints, non-
range parameters, etc.
Note: if this problem is computationally intensive, consider setting
`evaluate_suggested` argument to False.
Args:
search_space: Problem domain.
optimization_config: Problem objective and constraints. Note that by
default, an `Objective` in the `OptimizationConfig` has `minimize`
set to False, so by default an `OptimizationConfig` is that of
maximization.
name: Optional name of the problem, will default to the name of the
objective metric (e.g., "Branin" or "Branin_constrainted" if
constraints are present). The name of the problem is reflected in the
names of the benchmarking experiments (e.g. "Sobol_on_Branin").
optimal_value: Optional target objective value for the optimization.
evaluate_suggested: Whether the model-predicted best value should be
evaluated when benchmarking on this problem. Note that in practice,
this means that for every model-generated trial, an extra point will
be evaluated. This extra point is often different from the model-
generated trials, since those trials aim to both explore and exploit,
so the aim is not usually to suggest the current model-predicted
optimum.
"""
name: str
search_space: SearchSpace
optimization_config: OptimizationConfig
optimal_value: Optional[float]
# Whether to evaluate model-predicted best values at each iteration to
# compare to the model predictions. Should only be `False` if the problem
# is expensive to evaluate and therefore no extra evaluations beyond the one
# optimization loop should be performed.
evaluate_suggested: bool
def __init__(
self,
search_space: SearchSpace,
optimization_config: OptimizationConfig,
name: Optional[str] = None,
optimal_value: Optional[float] = None,
evaluate_suggested: bool = True,
) -> None:
self.search_space = search_space
self.optimization_config = optimization_config
suffix = ( # To avoid clashing of two problem names, mark constrained
"_constrained" if len(optimization_config.outcome_constraints) > 0 else ""
)
if name is None:
if isinstance(optimization_config, MultiObjectiveOptimizationConfig):
name = "_".join(m.name for m in optimization_config.objective.metrics)
else:
name = optimization_config.objective.metric.name
name += suffix
self.name = name
self.optimal_value = optimal_value
self.evaluate_suggested = evaluate_suggested
[docs]class SimpleBenchmarkProblem(BenchmarkProblem):
"""Benchmark problem, represented in terms of simplified constructions: a
callable function, a domain that consists or ranges, etc. This problem does
not support parameter or outcome constraints.
Note: if this problem is computationally intensive, consider setting
`evaluate_suggested` argument to False.
Args:
f: Ax `SyntheticFunction` or an ad-hoc callable that evaluates points
represented as nd-arrays. Input to the callable should be an (n x d)
array, where n is the number of points to evaluate, and d is the
dimensionality of the points. Returns a float or an (1 x n) array.
Used as problem objective.
name: Optional name of the problem, will default to the name of the
objective metric (e.g., "Branin" or "Branin_constrainted" if
constraints are present). The name of the problem is reflected in the
names of the benchmarking experiments (e.g. "Sobol_on_Branin").
domain: Problem domain as list of tuples. Parameter names will be derived
from the length of this list, as {"x1", ..., "xN"}, where N is the
length of this list.
optimal_value: Optional target objective value for the optimization.
minimize: Whether this is a minimization problem, defatuls to False.
noise_sd: Measure of the noise that will be added to the observations
during the optimization. During the evaluation phase, true values
will be extracted to measure a method's performance. Only applicable
when using a known `SyntetheticFunction` as the `f` argument.
evaluate_suggested: Whether the model-predicted best value should be
evaluated when benchmarking on this problem. Note that in practice,
this means that for every model-generated trial, an extra point will
be evaluated. This extra point is often different from the model-
generated trials, since those trials aim to both explore and exploit,
so the aim is not usually to suggest the current model-predicted
optimum.
"""
f: Union[SyntheticFunction, FunctionType]
name: str
domain: List[Tuple[float, float]]
optimal_value: Optional[float]
minimize: bool
noise_sd: float
# Whether to evaluate model-predicted best values at each iteration to
# compare to the model predictions. Should only be `False` if the problem
# is expensive to evaluate and therefore no extra evaluations beyond the one
# optimization loop should be performed.
evaluate_suggested: bool # NOTE: not yet implemented
def __init__(
self,
f: Union[SyntheticFunction, FunctionType],
name: Optional[str] = None,
domain: Optional[List[Tuple[float, float]]] = None,
optimal_value: Optional[float] = None,
minimize: bool = False,
noise_sd: float = 0.0,
evaluate_suggested: bool = True,
) -> None:
# Whether we are using Ax `SyntheticFunction` custom ad-hoc function
self.uses_synthetic_function = isinstance(f, SyntheticFunction)
# Validate that domain is available, since it's used to make search space
if domain is None:
if not self.uses_synthetic_function:
# Custom callable was passed as `f`, ensure presence of `domain`
raise ValueError(NONE_DOMAIN_ERROR)
elif checked_cast(SyntheticFunction, f).domain is None:
# If no domain was passed, will use one from the `SyntheticFunction`
raise ValueError(NONE_SYNTHETIC_FUNCTION_DOMAIN_ERROR)
# Validate that if noise setting specified, known synthetic function is used
if noise_sd != 0.0 and not self.uses_synthetic_function:
raise ValueError(ADHOC_FUNCTION_NOISE_SET_ERROR)
self.f = f
self.name = name or (
checked_cast(SyntheticFunction, f).name
if self.uses_synthetic_function
else checked_cast(FunctionType, f).__name__
)
if self.name == "<lambda>":
raise ValueError(ANONYMOUS_FUNCTION_ERROR)
# If domain is `None`, grab it from the `SyntheticFunction`
self.domain = domain or checked_cast(SyntheticFunction, self.f).domain
if optimal_value is None and self.uses_synthetic_function:
# If no optimal_value is passed, try extracting it from synthetic function.
try:
synt_f = checked_cast(SyntheticFunction, self.f)
self.optimal_value = synt_f.fmin if minimize else synt_f.fmax
except NotImplementedError as err: # optimal_value is unknown
logger.warning(err)
else:
self.optimal_value = optimal_value
self.minimize = minimize
self.noise_sd = noise_sd
self.evaluate_suggested = evaluate_suggested
[docs] def domain_as_ax_client_parameters(
self,
) -> List[TParameterRepresentation]:
return [
cast(
TParameterRepresentation,
{
"name": f"x{i}",
"type": "range",
"bounds": list(self.domain[i]),
"value_type": "float",
},
)
for i in range(len(self.domain))
]
@equality_typechecker
def __eq__(self, other: "BenchmarkProblem") -> bool:
for field in self.__dict__.keys():
self_val = getattr(self, field)
other_val = getattr(other, field)
if field == "f" and (
( # For synthetic functions, they are same if same class.
self.uses_synthetic_function
and self_val.__class__ is other_val.__class__
)
or ( # For custom callables, considered same if same name.
not self.uses_synthetic_function
and self_val.__name__ is other_val.__name__
)
):
continue
if self_val != other_val:
return False
return True