Out of the box, Ax offers many options for candidate generation, most of which utilize Bayesian optimization algorithms built using BoTorch. For users that want to leverage Ax for experiment orchestration (via AxClient
or Scheduler
) and other features (e.g., early stopping), while relying on other methods for candidate generation, we introduced ExternalGenerationNode
.
A GenerationNode
is a building block of a GenerationStrategy
. They can be combined together utilize different methods for generating candidates at different stages of an experiment. ExternalGenerationNode
exposes a lightweight interface to allow the users to easily integrate their methods into Ax, and use them as standalone or with other GenerationNode
s in a GenerationStrategy
.
In this tutorial, we will implement a simple generation node using RandomForestRegressor
from sklearn, and combine it with Sobol (for initialization) to optimize the Hartmann6 problem.
NOTE: This is for illustration purposes only. We do not recommend using this strategy as it typically does not perform well compared to Ax's default algorithms due to it's overly greedy behavior.
import time
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from ax.core.base_trial import TrialStatus
from ax.core.data import Data
from ax.core.experiment import Experiment
from ax.core.parameter import RangeParameter
from ax.core.types import TParameterization
from ax.modelbridge.external_generation_node import ExternalGenerationNode
from ax.modelbridge.generation_node import GenerationNode
from ax.modelbridge.generation_strategy import GenerationStrategy
from ax.modelbridge.model_spec import ModelSpec
from ax.modelbridge.registry import Models
from ax.modelbridge.transition_criterion import MaxTrials
from ax.plot.trace import plot_objective_value_vs_trial_index
from ax.service.ax_client import AxClient, ObjectiveProperties
from ax.service.utils.report_utils import exp_to_df
from ax.utils.common.typeutils import checked_cast
from ax.utils.measurement.synthetic_functions import hartmann6
from sklearn.ensemble import RandomForestRegressor
class RandomForestGenerationNode(ExternalGenerationNode):
"""A generation node that uses the RandomForestRegressor
from sklearn to predict candidate performance and picks the
next point as the random sample that has the best prediction.
To leverage external methods for candidate generation, the user must
create a subclass that implements ``update_generator_state`` and
``get_next_candidate`` methods. This can then be provided
as a node into a ``GenerationStrategy``, either as standalone or as
part of a larger generation strategy with other generation nodes,
e.g., with a Sobol node for initialization.
"""
def __init__(self, num_samples: int, regressor_options: Dict[str, Any]) -> None:
"""Initialize the generation node.
Args:
regressor_options: Options to pass to the random forest regressor.
num_samples: Number of random samples from the search space
used during candidate generation. The sample with the best
prediction is recommended as the next candidate.
"""
t_init_start = time.monotonic()
super().__init__(node_name="RandomForest")
self.num_samples: int = num_samples
self.regressor: RandomForestRegressor = RandomForestRegressor(
**regressor_options
)
# We will set these later when updating the state.
# Alternatively, we could have required experiment as an input
# and extracted them here.
self.parameters: Optional[List[RangeParameter]] = None
self.minimize: Optional[bool] = None
# Recording time spent in initializing the generator. This is
# used to compute the time spent in candidate generation.
self.fit_time_since_gen: float = time.monotonic() - t_init_start
def update_generator_state(self, experiment: Experiment, data: Data) -> None:
"""A method used to update the state of the generator. This includes any
models, predictors or any other custom state used by the generation node.
This method will be called with the up-to-date experiment and data before
``get_next_candidate`` is called to generate the next trial(s). Note
that ``get_next_candidate`` may be called multiple times (to generate
multiple candidates) after a call to ``update_generator_state``.
For this example, we will train the regressor using the latest data from
the experiment.
Args:
experiment: The ``Experiment`` object representing the current state of the
experiment. The key properties includes ``trials``, ``search_space``,
and ``optimization_config``. The data is provided as a separate arg.
data: The data / metrics collected on the experiment so far.
"""
search_space = experiment.search_space
parameter_names = list(search_space.parameters.keys())
metric_names = list(experiment.optimization_config.metrics.keys())
if any(
not isinstance(p, RangeParameter) for p in search_space.parameters.values()
):
raise NotImplementedError(
"This example only supports RangeParameters in the search space."
)
if search_space.parameter_constraints:
raise NotImplementedError(
"This example does not support parameter constraints."
)
if len(metric_names) != 1:
raise NotImplementedError(
"This example only supports single-objective optimization."
)
# Get the data for the completed trials.
num_completed_trials = len(experiment.trials_by_status[TrialStatus.COMPLETED])
x = np.zeros([num_completed_trials, len(parameter_names)])
y = np.zeros([num_completed_trials, 1])
for t_idx, trial in experiment.trials.items():
if trial.status == "COMPLETED":
trial_parameters = trial.arm.parameters
x[t_idx, :] = np.array([trial_parameters[p] for p in parameter_names])
trial_df = data.df[data.df["trial_index"] == t_idx]
y[t_idx, 0] = trial_df[trial_df["metric_name"] == metric_names[0]][
"mean"
].item()
# Train the regressor.
self.regressor.fit(x, y)
# Update the attributes not set in __init__.
self.parameters = search_space.parameters
self.minimize = experiment.optimization_config.objective.minimize
def get_next_candidate(
self, pending_parameters: List[TParameterization]
) -> TParameterization:
"""Get the parameters for the next candidate configuration to evaluate.
We will draw ``self.num_samples`` random samples from the search space
and predict the objective value for each sample. We will then return
the sample with the best predicted value.
Args:
pending_parameters: A list of parameters of the candidates pending
evaluation. This is often used to avoid generating duplicate candidates.
We ignore this here for simplicity.
Returns:
A dictionary mapping parameter names to parameter values for the next
candidate suggested by the method.
"""
bounds = np.array([[p.lower, p.upper] for p in self.parameters.values()])
unit_samples = np.random.random_sample([self.num_samples, len(bounds)])
samples = bounds[:, 0] + (bounds[:, 1] - bounds[:, 0]) * unit_samples
# Predict the objective value for each sample.
y_pred = self.regressor.predict(samples)
# Find the best sample.
best_idx = np.argmin(y_pred) if self.minimize else np.argmax(y_pred)
best_sample = samples[best_idx, :]
# Convert the sample to a parameterization.
candidate = {
p_name: best_sample[i].item()
for i, p_name in enumerate(self.parameters.keys())
}
return candidate
We will use Sobol for the first 5 trials and defer to random forest for the rest.
generation_strategy = GenerationStrategy(
name="Sobol+RandomForest",
nodes=[
GenerationNode(
node_name="Sobol",
model_specs=[ModelSpec(Models.SOBOL)],
transition_criteria=[
MaxTrials(
# This specifies the maximum number of trials to generate from this node,
# and the next node in the strategy.
threshold=5,
block_transition_if_unmet=True,
transition_to="RandomForest"
)
],
),
RandomForestGenerationNode(num_samples=128, regressor_options={}),
],
)
ax_client = AxClient(generation_strategy=generation_strategy)
ax_client.create_experiment(
name="hartmann_test_experiment",
parameters=[
{
"name": f"x{i}",
"type": "range",
"bounds": [0.0, 1.0],
"value_type": "float", # Optional, defaults to inference from type of "bounds".
}
for i in range(1, 7)
],
objectives={"hartmann6": ObjectiveProperties(minimize=True)},
)
def evaluate(parameterization: TParameterization) -> Dict[str, Tuple[float, float]]:
x = np.array([parameterization.get(f"x{i+1}") for i in range(6)])
return {"hartmann6": (checked_cast(float, hartmann6(x)), 0.0)}
[INFO 12-26 07:20:00] ax.service.ax_client: Starting optimization with verbose logging. To disable logging, set the `verbose_logging` argument to `False`. Note that float values in the logs are rounded to 6 decimal points.
[INFO 12-26 07:20:00] ax.service.utils.instantiation: Created search space: SearchSpace(parameters=[RangeParameter(name='x1', parameter_type=FLOAT, range=[0.0, 1.0]), RangeParameter(name='x2', parameter_type=FLOAT, range=[0.0, 1.0]), RangeParameter(name='x3', parameter_type=FLOAT, range=[0.0, 1.0]), RangeParameter(name='x4', parameter_type=FLOAT, range=[0.0, 1.0]), RangeParameter(name='x5', parameter_type=FLOAT, range=[0.0, 1.0]), RangeParameter(name='x6', parameter_type=FLOAT, range=[0.0, 1.0])], parameter_constraints=[]).
for i in range(15):
parameterization, trial_index = ax_client.get_next_trial()
ax_client.complete_trial(
trial_index=trial_index, raw_data=evaluate(parameterization)
)
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/modelbridge/cross_validation.py:439: UserWarning: Encountered exception in computing model fit quality: RandomModelBridge does not support prediction. warn("Encountered exception in computing model fit quality: " + str(e)) [INFO 12-26 07:20:00] ax.service.ax_client: Generated new trial 0 with parameters {'x1': 0.209293, 'x2': 0.114449, 'x3': 0.958776, 'x4': 0.048401, 'x5': 0.020042, 'x6': 0.197162} using model Sobol.
[INFO 12-26 07:20:00] ax.service.ax_client: Completed trial 0 with data: {'hartmann6': (-0.045776, 0.0)}.
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/modelbridge/cross_validation.py:439: UserWarning: Encountered exception in computing model fit quality: RandomModelBridge does not support prediction. warn("Encountered exception in computing model fit quality: " + str(e)) [INFO 12-26 07:20:00] ax.service.ax_client: Generated new trial 1 with parameters {'x1': 0.663598, 'x2': 0.944394, 'x3': 0.217332, 'x4': 0.756483, 'x5': 0.841761, 'x6': 0.587242} using model Sobol.
[INFO 12-26 07:20:00] ax.service.ax_client: Completed trial 1 with data: {'hartmann6': (-0.010039, 0.0)}.
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/modelbridge/cross_validation.py:439: UserWarning: Encountered exception in computing model fit quality: RandomModelBridge does not support prediction. warn("Encountered exception in computing model fit quality: " + str(e)) [INFO 12-26 07:20:00] ax.service.ax_client: Generated new trial 2 with parameters {'x1': 0.787173, 'x2': 0.425548, 'x3': 0.596326, 'x4': 0.323213, 'x5': 0.526865, 'x6': 0.85789} using model Sobol.
[INFO 12-26 07:20:00] ax.service.ax_client: Completed trial 2 with data: {'hartmann6': (-0.346825, 0.0)}.
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/modelbridge/cross_validation.py:439: UserWarning: Encountered exception in computing model fit quality: RandomModelBridge does not support prediction. warn("Encountered exception in computing model fit quality: " + str(e)) [INFO 12-26 07:20:00] ax.service.ax_client: Generated new trial 3 with parameters {'x1': 0.335717, 'x2': 0.501952, 'x3': 0.353417, 'x4': 0.622882, 'x5': 0.330998, 'x6': 0.498812} using model Sobol.
[INFO 12-26 07:20:00] ax.service.ax_client: Completed trial 3 with data: {'hartmann6': (-0.566929, 0.0)}.
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/modelbridge/cross_validation.py:439: UserWarning: Encountered exception in computing model fit quality: RandomModelBridge does not support prediction. warn("Encountered exception in computing model fit quality: " + str(e)) [INFO 12-26 07:20:00] ax.service.ax_client: Generated new trial 4 with parameters {'x1': 0.428099, 'x2': 0.252098, 'x3': 0.018834, 'x4': 0.726867, 'x5': 0.430466, 'x6': 0.098478} using model Sobol.
[INFO 12-26 07:20:00] ax.service.ax_client: Completed trial 4 with data: {'hartmann6': (-0.112836, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs) [INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 5 with parameters {'x1': 0.814701, 'x2': 0.372932, 'x3': 0.728201, 'x4': 0.787881, 'x5': 0.476341, 'x6': 0.148376} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 5 with data: {'hartmann6': (-0.016445, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 6 with parameters {'x1': 0.306594, 'x2': 0.407379, 'x3': 0.249692, 'x4': 0.602978, 'x5': 0.079739, 'x6': 0.707817} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 6 with data: {'hartmann6': (-0.367856, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 7 with parameters {'x1': 0.478228, 'x2': 0.858051, 'x3': 0.012524, 'x4': 0.979766, 'x5': 0.234911, 'x6': 0.787842} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 7 with data: {'hartmann6': (-0.002598, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 8 with parameters {'x1': 0.358302, 'x2': 0.58597, 'x3': 0.800062, 'x4': 0.029467, 'x5': 0.923949, 'x6': 0.382428} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 8 with data: {'hartmann6': (-0.105298, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 9 with parameters {'x1': 0.508803, 'x2': 0.110283, 'x3': 0.976728, 'x4': 0.3054, 'x5': 0.533491, 'x6': 0.969078} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 9 with data: {'hartmann6': (-0.315568, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 10 with parameters {'x1': 0.48842, 'x2': 0.645454, 'x3': 0.458618, 'x4': 0.616122, 'x5': 0.481335, 'x6': 0.527301} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 10 with data: {'hartmann6': (-0.262786, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 11 with parameters {'x1': 0.080256, 'x2': 0.129313, 'x3': 0.949979, 'x4': 0.531663, 'x5': 0.783572, 'x6': 0.39853} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 11 with data: {'hartmann6': (-0.030958, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 12 with parameters {'x1': 0.421669, 'x2': 0.168933, 'x3': 0.883741, 'x4': 0.283625, 'x5': 0.880907, 'x6': 0.591184} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 12 with data: {'hartmann6': (-0.060244, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:01] ax.service.ax_client: Generated new trial 13 with parameters {'x1': 0.989043, 'x2': 0.138226, 'x3': 0.121293, 'x4': 0.997229, 'x5': 0.882753, 'x6': 0.399592} using model RandomForest.
[INFO 12-26 07:20:01] ax.service.ax_client: Completed trial 13 with data: {'hartmann6': (-1e-05, 0.0)}.
/opt/hostedtoolcache/Python/3.10.15/x64/lib/python3.10/site-packages/sklearn/base.py:1389: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel(). return fit_method(estimator, *args, **kwargs)
[INFO 12-26 07:20:02] ax.service.ax_client: Generated new trial 14 with parameters {'x1': 0.365732, 'x2': 0.616887, 'x3': 0.734415, 'x4': 0.152389, 'x5': 0.450701, 'x6': 0.708879} using model RandomForest.
[INFO 12-26 07:20:02] ax.service.ax_client: Completed trial 14 with data: {'hartmann6': (-0.780275, 0.0)}.
exp_df = exp_to_df(ax_client.experiment)
exp_df
trial_index | arm_name | trial_status | generation_method | hartmann6 | x1 | x2 | x3 | x4 | x5 | x6 | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0_0 | COMPLETED | Sobol | -0.045776 | 0.209293 | 0.114449 | 0.958776 | 0.048401 | 0.020042 | 0.197162 |
1 | 1 | 1_0 | COMPLETED | Sobol | -0.010039 | 0.663598 | 0.944394 | 0.217332 | 0.756483 | 0.841761 | 0.587242 |
2 | 2 | 2_0 | COMPLETED | Sobol | -0.346825 | 0.787173 | 0.425548 | 0.596326 | 0.323213 | 0.526865 | 0.857890 |
3 | 3 | 3_0 | COMPLETED | Sobol | -0.566929 | 0.335717 | 0.501952 | 0.353417 | 0.622882 | 0.330998 | 0.498812 |
4 | 4 | 4_0 | COMPLETED | Sobol | -0.112836 | 0.428099 | 0.252098 | 0.018834 | 0.726867 | 0.430466 | 0.098478 |
5 | 5 | 5_0 | COMPLETED | RandomForest | -0.016445 | 0.814701 | 0.372932 | 0.728201 | 0.787881 | 0.476341 | 0.148376 |
6 | 6 | 6_0 | COMPLETED | RandomForest | -0.367856 | 0.306594 | 0.407379 | 0.249692 | 0.602978 | 0.079739 | 0.707817 |
7 | 7 | 7_0 | COMPLETED | RandomForest | -0.002598 | 0.478228 | 0.858051 | 0.012524 | 0.979766 | 0.234911 | 0.787842 |
8 | 8 | 8_0 | COMPLETED | RandomForest | -0.105298 | 0.358302 | 0.585970 | 0.800062 | 0.029467 | 0.923949 | 0.382428 |
9 | 9 | 9_0 | COMPLETED | RandomForest | -0.315568 | 0.508803 | 0.110283 | 0.976728 | 0.305400 | 0.533491 | 0.969078 |
10 | 10 | 10_0 | COMPLETED | RandomForest | -0.262786 | 0.488420 | 0.645454 | 0.458618 | 0.616122 | 0.481335 | 0.527301 |
11 | 11 | 11_0 | COMPLETED | RandomForest | -0.030958 | 0.080256 | 0.129313 | 0.949979 | 0.531663 | 0.783572 | 0.398530 |
12 | 12 | 12_0 | COMPLETED | RandomForest | -0.060244 | 0.421669 | 0.168933 | 0.883741 | 0.283625 | 0.880907 | 0.591184 |
13 | 13 | 13_0 | COMPLETED | RandomForest | -0.000010 | 0.989043 | 0.138226 | 0.121293 | 0.997229 | 0.882753 | 0.399592 |
14 | 14 | 14_0 | COMPLETED | RandomForest | -0.780275 | 0.365732 | 0.616887 | 0.734415 | 0.152389 | 0.450701 | 0.708879 |
plot_objective_value_vs_trial_index(
exp_df=exp_df,
metric_colname="hartmann6",
minimize=True,
title="Hartmann6 Objective Value vs. Trial Index",
)
/tmp/tmp.2WLYdnTLMd/Ax-main/ax/plot/trace.py:870: FutureWarning: DataFrame.fillna with 'method' is deprecated and will raise in a future version. Use obj.ffill() or obj.bfill() instead.
Total runtime of script: 7.1 seconds.