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.surrogates.api import 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,
)

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)
experiments["valid_y"] = 1

experiments["labcode"] = "exp_" + experiments.index.astype(str)

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.12/x64/lib/python3.12/site-packages/bofire/surrogates/botorch.py:181: 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 /pytorch/torch/csrc/utils/tensor_numpy.cpp:213.)

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 8.592110 161.354824 0.954383 0.061934 0.979556 0.987879 0.003968
1 11.512511 411.573889 0.858897 8.067431 0.929099 0.781818 0.003968
2 10.165839 285.099009 0.925470 0.135754 0.962811 0.890909 0.103175
3 4.950543 72.280032 0.984845 0.234848 0.994711 0.987879 0.103175
4 10.777914 302.496059 0.830300 0.145771 0.952155 0.939394 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 8.592110 161.354824 0.954383 0.061934 0.979556 0.987879 0.003968
1 11.512511 411.573889 0.858897 8.067431 0.929099 0.781818 0.003968
2 10.165839 285.099009 0.925470 0.135754 0.962811 0.890909 0.103175
3 4.950543 72.280032 0.984845 0.234848 0.994711 0.987879 0.103175
4 10.777914 302.496059 0.830300 0.145771 0.952155 0.939394 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 9.199783 246.560763 0.910779 1.729148 0.963666 0.917576 0.043651
std 2.607575 131.792555 0.064752 3.543739 0.025221 0.085881 0.054338
min 4.950543 72.280032 0.830300 0.061934 0.929099 0.781818 0.003968
25% 8.592110 161.354824 0.858897 0.135754 0.952155 0.890909 0.003968
50% 10.165839 285.099009 0.925470 0.145771 0.962811 0.939394 0.003968
75% 10.777914 302.496059 0.954383 0.234848 0.979556 0.987879 0.103175
max 11.512511 411.573889 0.984845 8.067431 0.994711 0.987879 0.103175

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)