Model Building with BoFire

This notebooks shows how to setup and analyze models trained with BoFire.

Imports

import numpy as np
import pandas as pd
import shap

import bofire.surrogates.api as surrogates
from bofire.data_models.domain.api import Inputs, Outputs
from bofire.data_models.enum import RegressionMetricsEnum
from bofire.data_models.features.api import ContinuousInput, ContinuousOutput
from bofire.data_models.kernels.api import LinearKernel
from bofire.data_models.surrogates.api import (
    SelectionSurrogate,
    SingleTaskGPSurrogate,
)
from bofire.plot.cv_folds import plot_cv_folds_plotly
from bofire.plot.feature_importance import plot_feature_importance_by_feature_plotly
from bofire.plot.gp_slice import plot_gp_slice_plotly
from bofire.surrogates.feature_importance import (
    combine_lengthscale_importances,
    combine_permutation_importances,
    lengthscale_importance_hook,
    permutation_importance_hook,
    shap_importance,
    shap_importance_hook,
)
/opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/site-packages/torch/jit/_script.py:1491: FutureWarning: `torch.jit.script` is deprecated. Please switch to `torch.compile` or `torch.export`.
  warnings.warn(

Problem Setup

For didactic purposes, we sample data from a Himmelblau benchmark function and use them to train a SingleTaskGP.

input_features = Inputs(
    features=[ContinuousInput(key=f"x_{i+1}", bounds=(-4, 4)) for i in range(3)],
)
output_features = Outputs(features=[ContinuousOutput(key="y")])
experiments = input_features.sample(n=50)
experiments.eval("y=((x_1**2 + x_2 - 11)**2+(x_1 + x_2**2 -7)**2)", inplace=True, engine = "python")
experiments["valid_y"] = 1

experiments["labcode"] = "exp_" + experiments.index.astype(str)
/tmp/ipykernel_6711/3100225016.py:6: Pandas4Warning: The inplace keyword in DataFrame.eval is deprecated and will be removed in a future version (PDEP-8).
See https://pandas.pydata.org/docs/dev/whatsnew/v3.1.0.html#deprecation-inplace for more details.
  experiments.eval("y=((x_1**2 + x_2 - 11)**2+(x_1 + x_2**2 -7)**2)", inplace=True, engine = "python")

Cross Validation

Run the cross validation

data_model = SingleTaskGPSurrogate(
    inputs=input_features,
    outputs=output_features,
)

model = surrogates.map(data_model=data_model)
train_cv, test_cv, pi = model.cross_validate(
    experiments,
    folds=5,
    include_X = True,
    include_labcodes = True,
    hooks={
        "permutation_importance": permutation_importance_hook,
        "lengthscale_importance": lengthscale_importance_hook,
        "shap_importance": shap_importance_hook,
    },
)
/opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/site-packages/bofire/utils/torch_tools.py:51: UserWarning: The given NumPy array is not writable, and PyTorch does not support non-writable tensors. This means writing to this tensor will result in undefined behavior. You may want to copy the array to protect its data or make it writable before converting it to a tensor. This type of warning will be suppressed for the rest of this program. (Triggered internally at /__w/pytorch/pytorch/torch/csrc/utils/tensor_numpy.cpp:213.)
  return torch.from_numpy(np.ascontiguousarray(df.to_numpy())).to(**tkwargs)

Plot feature importance

shap_folds = pi["shap_importance"]  # list[dict[str, shap.Explanation]]
shap_mean_abs_per_fold = pd.DataFrame(
    [
        pd.Series(
            np.mean(np.abs(fold["y"].values), axis=0),
            index=fold["y"].feature_names,
        )
        for fold in shap_folds
    ]
)

combined_importances = {
    m.name: combine_permutation_importances(pi["permutation_importance"], m).describe()
    for m in RegressionMetricsEnum
}
combined_importances["lengthscale"] = combine_lengthscale_importances(
    pi["lengthscale_importance"],
).describe()
combined_importances["shap"] = shap_mean_abs_per_fold.describe()

plot_feature_importance_by_feature_plotly(
    combined_importances,
    relative=False,
    caption="Feature Importances",
    show_std=True,
    importance_measure="Importance",
)

Analyze the cross validation

# Performance on test sets
test_cv.get_metrics(combine_folds=False)
MAE MSD R2 MAPE PEARSON SPEARMAN FISHER
0 6.556564 72.193006 0.969621 0.107982 0.992059 0.963636 0.103175
1 5.847264 91.512382 0.967011 1.094589 0.983974 0.915152 0.103175
2 6.952437 138.076027 0.977103 0.147360 0.995237 1.000000 0.003968
3 5.372986 37.439627 0.986144 0.240850 0.994484 0.987879 0.003968
4 9.822739 149.877112 0.977759 0.491490 0.995123 0.987879 0.003968
display(test_cv.get_metrics(combine_folds=False))
display(test_cv.get_metrics(combine_folds=False).describe())
MAE MSD R2 MAPE PEARSON SPEARMAN FISHER
0 6.556564 72.193006 0.969621 0.107982 0.992059 0.963636 0.103175
1 5.847264 91.512382 0.967011 1.094589 0.983974 0.915152 0.103175
2 6.952437 138.076027 0.977103 0.147360 0.995237 1.000000 0.003968
3 5.372986 37.439627 0.986144 0.240850 0.994484 0.987879 0.003968
4 9.822739 149.877112 0.977759 0.491490 0.995123 0.987879 0.003968
MAE MSD R2 MAPE PEARSON SPEARMAN FISHER
count 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000
mean 6.910398 97.819631 0.975528 0.416454 0.992175 0.970909 0.043651
std 1.739438 46.563959 0.007543 0.407401 0.004761 0.033853 0.054338
min 5.372986 37.439627 0.967011 0.107982 0.983974 0.915152 0.003968
25% 5.847264 72.193006 0.969621 0.147360 0.992059 0.963636 0.003968
50% 6.556564 91.512382 0.977103 0.240850 0.994484 0.987879 0.003968
75% 6.952437 138.076027 0.977759 0.491490 0.995123 0.987879 0.103175
max 9.822739 149.877112 0.986144 1.094589 0.995237 1.000000 0.103175

Select a surrogate by cross-validation

The cross-validation results above describe one surrogate configuration. We can also use cross-validation to choose between several complete surrogate configurations. SelectionSurrogate evaluates every option with the requested metric and fits the best option on all experiments. Here, we compare the default single-task GP to a GP with a linear kernel.

selection_data_model = SelectionSurrogate(
    inputs=input_features,
    outputs=output_features,
    options=[
        data_model,
        SingleTaskGPSurrogate(
            inputs=input_features,
            outputs=output_features,
            kernel=LinearKernel(),
        ),
    ],
    metric=RegressionMetricsEnum.MAE,
    folds=5,
    random_state=42,
)

selection_model = surrogates.map(selection_data_model)
selection_model.fit(experiments)

display(selection_model.scores)
selection_data_model.options[selection_model.selected]
MAE MSD R2 MAPE PEARSON SPEARMAN FISHER
0 6.736808 94.409667 0.979571 0.400986 0.989823 0.979160 7.910729e-15
1 44.548453 3109.376854 0.327161 5.538874 0.573174 0.536327 1.135371e-02
SingleTaskGPSurrogate(optimizer=LBFGSB(type='LBFGSB', maxiter=None, ftol=None, gtol=None, max_attempts=50), type='SingleTaskGPSurrogate', inputs=Inputs(type='Inputs', features=[ContinuousInput(type='ContinuousInput', key='x_1', context=None, unit=None, descriptors=None, bounds=(-4.0, 4.0), local_relative_bounds=None, stepsize=None, allow_zero=False), ContinuousInput(type='ContinuousInput', key='x_2', context=None, unit=None, descriptors=None, bounds=(-4.0, 4.0), local_relative_bounds=None, stepsize=None, allow_zero=False), ContinuousInput(type='ContinuousInput', key='x_3', context=None, unit=None, descriptors=None, bounds=(-4.0, 4.0), local_relative_bounds=None, stepsize=None, allow_zero=False)]), outputs=Outputs(type='Outputs', features=[ContinuousOutput(type='ContinuousOutput', key='y', context=None, unit=None, objective=MaximizeObjective(type='MaximizeObjective', w=1.0, bounds=[0, 1]))]), dump=None, categorical_encodings={}, engineered_features=EngineeredFeatures(type='EngineeredFeatures', features=[]), scaler=Normalize(type='Normalize', features=[]), output_scaler=<ScalerEnum.STANDARDIZE: 'STANDARDIZE'>, kernel=RBFKernel(type='RBFKernel', features=None, lengthscale_prior=DimensionalityScaledLogNormalPrior(type='DimensionalityScaledLogNormalPrior', loc=1.4142135623730951, loc_scaling=0.5, scale=1.7320508075688772, scale_scaling=0.0), lengthscale_constraint=GreaterThan(type='GreaterThan', initial_value=None, lower_bound=0.025), ard=True), mean=ConstantMean(type='ConstantMean', prior=None, constraint=None), likelihood=GaussianLikelihood(type='GaussianLikelihood', noise_prior=LogNormalPrior(type='LogNormalPrior', loc=-4.0, scale=1.0), noise_constraint=GreaterThan(type='GreaterThan', initial_value=0.006737946999085467, lower_bound=0.0001)))

The rows of scores correspond to the options in their original order, and selected is the position of the winning configuration. For a broader search, SingleTaskGPSurrogate.options(input_features, output_features) constructs 36 GP configurations comprising different kernels, priors, scale-kernel settings, and ARD settings. Using all of them with five folds requires 180 cross-validation fits, followed by a fit of the winner on all experiments. A smaller, problem-informed set of options is therefore often more practical.

Plot Folds

plot_cv_folds_plotly(test_cv, plot_X=True, plot_labcodes=True, plot_uncertainties=True)

Plot a slice of the GP

In this slice plot, we vary the inputs x_1 and x_2 across their full bounds to create a grid, while keeping x_3 fixed at a selected value.

The outputs are two Plotly figures: one showing the GP’s predicted mean (fig_mean) and the other showing the predicted standard deviation or uncertainty (fig_sd) over the slice.

If observed_data=experiments is provided, the mean plot will also display the measured data points, with those matching the fixed value of x_3 highlighted separately.

fixed_input_features = [model.inputs.get_by_key("x_3")]
fixed_values = [0.0]
varied_input_features = [
    model.inputs.get_by_key("x_1"),
    model.inputs.get_by_key("x_2"),
]
output_feature = model.outputs.get_by_key("y")

fixed_values = [experiments.iloc[0]["x_3"]]
fig_mean, fig_sd = plot_gp_slice_plotly(
    surrogate=model,
    fixed_input_features=fixed_input_features,
    fixed_values=fixed_values,
    varied_input_features=varied_input_features,
    output_feature=output_feature,
    resolution=80,
    observed_data=experiments,
)
display(fig_mean)
display(fig_sd)

Shapley Plots

shap_explanations = shap_importance(
    predictor=model,
    experiments=experiments,
    bg_experiments=experiments,   # background for KernelSHAP
    bg_sample_size=50,
    seed=42,
)

exp = shap_explanations['y']
shap.summary_plot(exp.values, exp.data, feature_names=exp.feature_names)