Using Ax for Human-in-the-loop Experimentation¶
While Ax can be used in as a fully automated service, generating and deploying candidates Ax can be also used in a trial-by-trial fashion, allowing for human oversight.
Typically, human intervention in Ax is necessary when there are clear tradeoffs between multiple metrics of interest. Condensing multiple outcomes of interest into a single scalar quantity can be really challenging. Instead, it can be useful to specify an objective and constraints, and tweak these based on the information from the experiment.
To facilitate this, Ax provides the following key features:
- Constrained optimization
- Interfaces for easily modifying optimization goals
- Utilities for visualizing and deploying new trials composed of multiple optimizations.
In this tutorial, we'll demonstrate how Ax enables users to explore these tradeoffs. With an understanding of the tradeoffs present in our data, we'll then make use of the constrained optimization utilities to generate candidates from multiple different optimization objectives, and create a conglomerate batch, with all of these candidates in together in one trial.
Experiment Setup
For this tutorial, we will assume our experiment has already been created.
import sys
in_colab = 'google.colab' in sys.modules
if in_colab:
%pip install ax-platform
import os
from ax import (
Data,
Metric,
OptimizationConfig,
Objective,
OutcomeConstraint,
ComparisonOp,
json_load,
)
from ax.modelbridge.cross_validation import cross_validate
from ax.modelbridge.registry import Models
from ax.plot.diagnostic import tile_cross_validation
from ax.plot.scatter import plot_multiple_metrics, tile_fitted
from ax.utils.notebook.plotting import render, init_notebook_plotting
import pandas as pd
import plotly.io as pio
init_notebook_plotting()
if in_colab:
pio.renderers.default = "colab"
[INFO 02-03 20:33:40] ax.utils.notebook.plotting: Injecting Plotly library into cell. Do not overwrite or delete cell.
[INFO 02-03 20:33:40] ax.utils.notebook.plotting: Please see
(https://ax.dev/tutorials/visualizations.html#Fix-for-plots-that-are-not-rendering)
if visualizations are not rendering.
NOTE: The path below assumes the tutorial is being run either from the root directory of
the Ax package or from the human_in_the_loop
directory that this tutorial lives in.
This is needed since the jupyter notebooks may change active directory during runtime,
making it tricky to find the file in a consistent way.
curr_dir = os.getcwd()
if "human_in_the_loop" not in curr_dir:
curr_dir = os.path.join(curr_dir, "tutorials", "human_in_the_loop")
experiment = json_load.load_experiment(os.path.join(curr_dir, "hitl_exp.json"))
Initial Sobol Trial
Bayesian Optimization experiments almost always begin with a set of random points. In
this experiment, these points were chosen via a Sobol sequence, accessible via the
ModelBridge
factory.
A collection of points run and analyzed together form a BatchTrial
. A Trial
object
provides metadata pertaining to the deployment of these points, including details such
as when they were deployed, and the current status of their experiment.
Here, we see an initial experiment has finished running (COMPLETED status).
experiment.trials[0]
BatchTrial(experiment_name='human_in_the_loop_tutorial', index=0, status=TrialStatus.COMPLETED)
experiment.trials[0].time_created
datetime.datetime(2019, 3, 29, 18, 10, 6)
# Number of arms in first experiment, including status_quo
len(experiment.trials[0].arms)
65
# Sample arm configuration
experiment.trials[0].arms[0]
Arm(name='0_0', parameters={'x_excellent': 0.9715802669525146, 'x_good': 0.8615524768829346, 'x_moderate': 0.7668091654777527, 'x_poor': 0.34871453046798706, 'x_unknown': 0.7675797343254089, 'y_excellent': 2.900710028409958, 'y_good': 1.5137152910232545, 'y_moderate': 0.6775947093963622, 'y_poor': 0.4974367544054985, 'y_unknown': 1.0852564811706542, 'z_excellent': 517803.49761247635, 'z_good': 607874.5171427727, 'z_moderate': 1151881.2023103237, 'z_poor': 2927449.2621421814, 'z_unknown': 2068407.6935052872})
Experiment Analysis
Optimization Config
An important construct for analyzing an experiment is an OptimizationConfig. An OptimizationConfig contains an objective, and outcome constraints. Experiment's can have a default OptimizationConfig, but models can also take an OptimizationConfig as input independent of the default.
Objective: A metric to optimize, along with a direction to optimize (default: maximize)
Outcome Constraint: A metric to constrain, along with a constraint direction (<= or >=), as well as a bound.
Let's start with a simple OptimizationConfig. By default, our objective metric will be
maximized, but can be minimized by setting the minimize
flag. Our outcome constraint
will, by default, be evaluated as a relative percentage change. This percentage change
is computed relative to the experiment's status quo arm.
experiment.status_quo
Arm(name='status_quo', parameters={'x_excellent': 0, 'x_good': 0, 'x_moderate': 0, 'x_poor': 0, 'x_unknown': 0, 'y_excellent': 1, 'y_good': 1, 'y_moderate': 1, 'y_poor': 1, 'y_unknown': 1, 'z_excellent': 1000000, 'z_good': 1000000, 'z_moderate': 1000000, 'z_poor': 1000000, 'z_unknown': 1000000})
objective_metric = Metric(name="metric_1")
constraint_metric = Metric(name="metric_2")
experiment.optimization_config = OptimizationConfig(
objective=Objective(objective_metric, minimize=False),
outcome_constraints=[
OutcomeConstraint(metric=constraint_metric, op=ComparisonOp.LEQ, bound=5),
],
)
Data
Another critical piece of analysis is data itself! Ax data follows a standard format, shown below. This format is imposed upon the underlying data structure, which is a Pandas DataFrame.
A key set of fields are required for all data, for use with Ax models.
It's a good idea to double check our data before fitting models -- let's make sure all of our expected metrics and arms are present.
data = Data(pd.read_json(os.path.join(curr_dir, "hitl_data.json")))
data.df.head()
arm_name | metric_name | mean | sem | trial_index | start_time | end_time | n | |
---|---|---|---|---|---|---|---|---|
0 | 0_1 | metric_1 | 495.763 | 2.62164 | 0 | 2019-03-30 | 2019-04-03 | 1599994 |
1 | 0_23 | metric_1 | 524.368 | 2.73165 | 0 | 2019-03-30 | 2019-04-03 | 1596356 |
2 | 0_14 | metric_2 | 21.4602 | 0.069457 | 0 | 2019-03-30 | 2019-04-03 | 1600182 |
3 | 0_53 | metric_2 | 21.4374 | 0.069941 | 0 | 2019-03-30 | 2019-04-03 | 1601081 |
4 | 0_53 | metric_1 | 548.388 | 2.89349 | 0 | 2019-03-30 | 2019-04-03 | 1601081 |
data.df["arm_name"].unique()
array(['0_1', '0_23', '0_14', '0_53', '0_0', '0_54', '0_55', '0_56',
'0_27', '0_57', '0_58', '0_13', '0_59', '0_6', '0_60', '0_61',
'0_62', '0_63', '0_7', '0_28', '0_15', '0_16', '0_17', '0_18',
'0_19', '0_29', '0_2', '0_20', '0_21', '0_22', '0_3', '0_30',
'0_8', '0_10', '0_31', '0_24', '0_32', '0_33', '0_34', '0_35',
'0_36', '0_37', '0_38', '0_9', '0_39', '0_4', '0_25', '0_11',
'0_40', '0_41', '0_42', '0_43', '0_44', '0_45', 'status_quo',
'0_46', '0_47', '0_48', '0_26', '0_49', '0_12', '0_5', '0_50',
'0_51', '0_52'], dtype=object)
data.df["metric_name"].unique()
array(['metric_1', 'metric_2'], dtype=object)
Search Space
The final component necessary for human-in-the-loop optimization is a SearchSpace. A SearchSpace defines the feasible region for our parameters, as well as their types.
Here, we have both parameters and a set of constraints on those parameters.
Without a SearchSpace, our models are unable to generate new candidates. By default, the models will read the search space off of the experiment, when they are told to generate candidates. SearchSpaces can also be specified by the user at this time. Sometimes, the first round of an experiment is too restrictive--perhaps the experimenter was too cautious when defining their initial ranges for exploration! In this case, it can be useful to generate candidates from new, expanded search spaces, beyond that specified in the experiment.
experiment.search_space.parameters
{'x_excellent': RangeParameter(name='x_excellent', parameter_type=FLOAT, range=[0.0, 1.0]),
'x_good': RangeParameter(name='x_good', parameter_type=FLOAT, range=[0.0, 1.0]),
'x_moderate': RangeParameter(name='x_moderate', parameter_type=FLOAT, range=[0.0, 1.0]),
'x_poor': RangeParameter(name='x_poor', parameter_type=FLOAT, range=[0.0, 1.0]),
'x_unknown': RangeParameter(name='x_unknown', parameter_type=FLOAT, range=[0.0, 1.0]),
'y_excellent': RangeParameter(name='y_excellent', parameter_type=FLOAT, range=[0.1, 3.0]),
'y_good': RangeParameter(name='y_good', parameter_type=FLOAT, range=[0.1, 3.0]),
'y_moderate': RangeParameter(name='y_moderate', parameter_type=FLOAT, range=[0.1, 3.0]),
'y_poor': RangeParameter(name='y_poor', parameter_type=FLOAT, range=[0.1, 3.0]),
'y_unknown': RangeParameter(name='y_unknown', parameter_type=FLOAT, range=[0.1, 3.0]),
'z_excellent': RangeParameter(name='z_excellent', parameter_type=FLOAT, range=[50000.0, 5000000.0]),
'z_good': RangeParameter(name='z_good', parameter_type=FLOAT, range=[50000.0, 5000000.0]),
'z_moderate': RangeParameter(name='z_moderate', parameter_type=FLOAT, range=[50000.0, 5000000.0]),
'z_poor': RangeParameter(name='z_poor', parameter_type=FLOAT, range=[50000.0, 5000000.0]),
'z_unknown': RangeParameter(name='z_unknown', parameter_type=FLOAT, range=[50000.0, 5000000.0])}
experiment.search_space.parameter_constraints
[OrderConstraint(x_poor <= x_moderate),
OrderConstraint(x_moderate <= x_good),
OrderConstraint(x_good <= x_excellent),
OrderConstraint(y_poor <= y_moderate),
OrderConstraint(y_moderate <= y_good),
OrderConstraint(y_good <= y_excellent)]