Source code for physbo.search.range._policy

# SPDX-License-Identifier: MPL-2.0
# Copyright (C) 2025- The University of Tokyo
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

import numpy as np
import copy
import pickle as pickle
import time

from ._history import History
from .. import utility
from .. import score as search_score
from ..optimize.random import Optimizer as RandomOptimizer
from ... import gp
from ...gp import Predictor as gp_predictor
from ...blm import Predictor as blm_predictor
from ...misc import SetConfig
from ..._variable import Variable, normalize_t


[docs] class Policy: """Single objective Bayesian optimization with continuous search space""" def __init__( self, *, min_X=None, max_X=None, config=None, initial_data=None, comm=None ): """ Parameters ---------- min_X: numpy.ndarray The minimum value of each dimension of the search space. max_X: numpy.ndarray The maximum value of each dimension of the search space. config: SetConfig object (physbo.misc.SetConfig) initial_data: tuple[np.ndarray, np.ndarray] The initial training datasets. The first elements is the array of inputs and the second is the array of values of objective functions comm: MPI.Comm, optional MPI Communicator """ self.predictor = None self.training = Variable() self.new_data = None if min_X is None or max_X is None: raise ValueError("min_X and max_X must be specified") self.min_X = np.array(min_X) self.max_X = np.array(max_X) self.dim = self.min_X.shape[0] assert self.dim == self.max_X.shape[0], ( "The dimension of min_X and max_X must be the same" ) assert np.all(self.min_X < self.max_X), ( "min_X must be less than max_X for each dimension" ) self.L_X = self.max_X - self.min_X self.history = History(dim=self.dim) if config is None: self.config = SetConfig() else: self.config = config self.ard = False if initial_data is not None: if len(initial_data) != 2: msg = "ERROR: initial_data should be 2-elements tuple or list (X and objectives)" raise RuntimeError(msg) init_X, fs = initial_data fs_normalized = normalize_t(fs, k=1) assert init_X.shape[0] == fs_normalized.shape[0], ( "The number of initial data must be the same" ) assert init_X.shape[1] == self.dim, ( "The dimension of initial_data[0] must be the same as the dimension of min_X and max_X" ) self.write(init_X, fs_normalized) if comm is None: self.mpicomm = None self.mpisize = 1 self.mpirank = 0 else: self.mpicomm = comm self.mpisize = comm.size self.mpirank = comm.rank self.config.learning.is_disp = ( self.config.learning.is_disp and self.mpirank == 0 )
[docs] def set_seed(self, seed): """ Setting a seed parameter for np.random. Parameters ---------- seed: int seed number ------- """ self.seed = seed np.random.seed(self.seed)
[docs] def write( self, X, t, time_total=None, time_update_predictor=None, time_get_action=None, time_run_simulator=None, ): """ Writing history (update history, not output to a file). Parameters ---------- X: numpy.ndarray N x d dimensional matrix. Each row of X denotes the d-dimensional feature vector of each search candidate. t: numpy.ndarray N dimensional array (1D) or N x 1 dimensional array (2D). The negative energy of each search candidate (value of the objective function to be optimized). Will be normalized to (N, 1) shape internally. All the values must be finite. Failed evaluations are not supported in continuous search spaces. time_total: numpy.ndarray N dimenstional array. The total elapsed time in each step. If None (default), filled by 0.0. time_update_predictor: numpy.ndarray N dimenstional array. The elapsed time for updating predictor (e.g., learning hyperparemters) in each step. If None (default), filled by 0.0. time_get_action: numpy.ndarray N dimenstional array. The elapsed time for getting next action in each step. If None (default), filled by 0.0. time_run_simulator: numpy.ndarray N dimenstional array. The elapsed time for running the simulator in each step. If None (default), filled by 0.0. Returns ------- Raises ------ ValueError If t contains a non-finite value (NaN or +-Inf). Nothing is written to the history in this case. """ # Normalize t to (N, 1) shape t_normalized = normalize_t(t, k=1) utility.require_finite(X, t_normalized) self.history.write( t.flatten(), X, time_total=time_total, time_update_predictor=time_update_predictor, time_get_action=time_get_action, time_run_simulator=time_run_simulator, ) # Get basis and convert to (1, N, n) format for single-objective Z_basis = self.predictor.get_basis(X) if self.predictor is not None else None if Z_basis is not None: Z = Z_basis[np.newaxis, :, :] # (N, n) -> (1, N, n) else: Z = None self.training.add(X=X, t=t_normalized, Z=Z) if self.new_data is None: self.new_data = Variable(X=X, t=t_normalized, Z=Z) else: self.new_data.add(X=X, t=t_normalized, Z=Z)
@staticmethod def _warn_no_predictor(method_name): print("Warning: Since policy.predictor is not yet set,") print(" a GP predictor (num_rand_basis=0) is used for predicting") print(" If you want to use a BLM predictor (num_rand_basis>0),") print(" call bayes_search(max_num_probes=0, num_rand_basis=nrb)") print(" before calling {}.".format(method_name))
[docs] def get_post_fmean(self, xs): """ Calculate mean value of predictor (post distribution) Parameters ---------- xs: physbo.Variable or np.ndarray input parameters to calculate mean value shape is (num_points, num_parameters) Returns ------- fmean: numpy.ndarray Mean value of the post distribution. Returned shape is (num_points). """ X = self._make_variable_X(xs) if self.predictor is None: self._warn_no_predictor("get_post_fmean()") predictor = self._make_gp_predictor() predictor.fit(self.training, 0, comm=self.mpicomm, objective_index=0) predictor.prepare(self.training, objective_index=0) return predictor.get_post_fmean(self.training, X, objective_index=0) else: self._update_predictor() return self.predictor.get_post_fmean(self.training, X, objective_index=0)
[docs] def get_post_fcov(self, xs, diag=True): """ Calculate covariance of predictor (post distribution) Parameters ---------- xs: physbo.Variable or np.ndarray input parameters to calculate covariance shape is (num_points, num_parameters) diag: bool If true, only variances (diagonal elements) are returned. Returns ------- fcov: numpy.ndarray Covariance matrix of the post distribution. Returned shape is (num_points) if diag=true, (num_points, num_points) if diag=false. """ X = self._make_variable_X(xs) if self.predictor is None: self._warn_no_predictor("get_post_fcov()") predictor = self._make_gp_predictor() predictor.fit(self.training, 0, comm=self.mpicomm, objective_index=0) predictor.prepare(self.training, objective_index=0) return predictor.get_post_fcov(self.training, X, diag, objective_index=0) else: self._update_predictor() return self.predictor.get_post_fcov( self.training, X, diag, objective_index=0 )
[docs] def get_kernel_length_scale(self): """ Return the Gaussian kernel length scale(s) (width) of the predictor. With ARD, returns one length scale per input dimension; otherwise a single value. Returns ------- numpy.ndarray or None Length scale(s). Shape (num_dim,) when ARD is used, (1,) otherwise. None if the predictor is not set or not a GP with Gaussian kernel. """ if self.predictor is None: return None self._update_predictor() try: cov = self.predictor.model.prior.cov except AttributeError: return None if not hasattr(cov, "width"): return None return np.atleast_1d(np.asarray(cov.width).flatten())
[docs] def get_num_dim(self): """ Return the input dimension (number of features) of the search space. Returns ------- int The number of dimensions (from min_X / max_X). """ return self.dim
[docs] def get_score( self, mode, *, xs=None, predictor=None, training=None, parallel=True, alpha=1 ): """ Calcualte score (acquisition function) Parameters ---------- mode: str The type of aquisition funciton. TS, EI and PI are available. These functions are defined in score.py. xs: physbo.Variable or np.ndarray input parameters to calculate score predictor: predictor object predictor used to calculate score. If not given, self.predictor will be used. training:physbo.Variable Training dataset. If not given, self.training will be used. parallel: bool Calculate scores in parallel by MPI (default: True) alpha: float Tuning parameter which is used if mode = TS. In TS, multi variation is tuned as np.random.multivariate_normal(mean, cov*alpha**2, size). Returns ------- f: float or list of float Score defined in each mode. Raises ------ RuntimeError If both *actions* and *xs* are given Notes ----- When neither *actions* nor *xs* are given, scores for actions not yet searched will be calculated. When *parallel* is True, it is assumed that the function receives the same input (*actions* or *xs*) for all the ranks. If you want to split the input array itself, set *parallel* be False and merge results by yourself. """ if training is None: training = self.training if training.X is None or training.X.shape[0] == 0: msg = "ERROR: No training data is registered." raise RuntimeError(msg) if predictor is None: if self.predictor is None: self._warn_no_predictor("get_score()") predictor = self._make_gp_predictor() predictor.fit(training, 0, comm=self.mpicomm, objective_index=0) predictor.prepare(training, objective_index=0) else: self._update_predictor() predictor = self.predictor if xs is not None: test = self._make_variable_X(xs) if parallel and self.mpisize > 1: actions = np.array_split(np.arange(test.X.shape[0]), self.mpisize) test = test.get_subset(actions[self.mpirank]) else: raise RuntimeError("ERROR: xs is not given") f = search_score.score( mode, predictor=predictor, training=training, test=test, alpha=alpha ) if parallel and self.mpisize > 1: fs = self.mpicomm.allgather(f) f = np.hstack(fs) return f
def _argmax_score(self, mode, predictor, training, extra_trainings, optimizer): K = len(extra_trainings) if K == 0: predictor.prepare(training, objective_index=0) def fn(x): return self.get_score( mode, xs=x.reshape(1, -1), predictor=predictor, parallel=False )[0] else: # marginal score trains = [copy.deepcopy(training) for _ in range(K)] predictors = [copy.deepcopy(predictor) for _ in range(K)] for k in range(K): extra_train = extra_trainings[k] # Ensure t is normalized if extra_train.t is not None: extra_train.t = normalize_t(extra_train.t, k=1) trains[k].add(X=extra_train.X, t=extra_train.t) predictors[k].update(trains[k], extra_train, objective_index=0) def fn(x): f = np.zeros(K) for k in range(K): f[k] = self.get_score( mode, xs=x.reshape(1, -1), predictor=predictors[k], training=trains[k], parallel=False, )[0] return np.mean(f) X = optimizer(fn, mpicomm=self.mpicomm) return X def _get_actions(self, mode, N, K, alpha, optimizer, num_rand_basis=0): """ Getting next candidates Parameters ---------- mode: str The type of aquisition funciton. TS (Thompson Sampling), EI (Expected Improvement) and PI (Probability of Improvement) are available. These functions are defined in score.py. N: int The total number of actions to return. K: int The total number of samples to evaluate marginal score alpha: float Tuning parameter which is used if mode = TS. In TS, multi variation is tuned as np.random.multivariate_normal(mean, cov*alpha**2, size). Returns ------- chosen_actions: numpy.ndarray An N-dimensional array of actions selected in each search process. """ X = np.zeros((N, self.dim)) self._update_predictor() predictor = copy.deepcopy(self.predictor) predictor.config.is_disp = False X[0, :] = self._argmax_score( mode, predictor, self.training, [], optimizer=optimizer ) for n in range(1, N): extra_training = Variable(X=X[0:n, :]) t = self.predictor.get_predict_samples( self.training, extra_training, K, objective_index=0 ) extra_trainings = [copy.deepcopy(extra_training) for _ in range(K)] for k in range(K): # Normalize t to (N, 1) shape t_normalized = normalize_t(t[k, :], k=1) extra_trainings[k].t = t_normalized X[n, :] = self._argmax_score( mode, predictor, self.training, extra_trainings, optimizer=optimizer ) return X def _get_random_action(self, N): """ Getting indexes of actions randomly. Parameters ---------- N: int Total number of search candidates. Returns ------- action: numpy.ndarray Indexes of actions selected randomly from search candidates. """ action = np.random.rand(N, self.dim) * self.L_X.reshape( 1, -1 ) + self.min_X.reshape(1, -1) if self.mpisize > 1: self.mpicomm.Bcast(action, root=0) return action
[docs] def save(self, file_history, file_training=None, file_predictor=None): """ Saving history, training and predictor into the corresponding files. Parameters ---------- file_history: str The name of the file that stores the information of the history. file_training: str The name of the file that stores the training dataset. file_predictor: str The name of the file that stores the predictor dataset. Returns ------- """ if self.mpirank == 0: self.history.save(file_history) if file_training is not None: self.training.save(file_training) if file_predictor is not None: with open(file_predictor, "wb") as f: pickle.dump(self.predictor, f)
[docs] def load(self, file_history, file_training=None, file_predictor=None): """ Loading files about history, training and predictor. Parameters ---------- file_history: str The name of the file that stores the information of the history. file_training: str The name of the file that stores the training dataset. file_predictor: str The name of the file that stores the predictor dataset. Returns ------- """ self.history.load(file_history) if file_training is None: # rebuild the training data from the valid observations only X, t = self.history.export_valid() # Normalize t to (N, 1) shape t_normalized = normalize_t(t, k=1) self.training = Variable(X=X, t=t_normalized) else: self.training = Variable() self.training.load(file_training) # Ensure t is normalized to (N, 1) shape after loading if self.training.t is not None: self.training.t = normalize_t(self.training.t, k=1) if file_predictor is not None: with open(file_predictor, "rb") as f: self.predictor = pickle.load(f)
[docs] def export_predictor(self): """ Returning the predictor dataset Returns ------- """ return self.predictor
[docs] def export_training(self): """ Returning the training dataset Returns ------- """ return self.training
[docs] def export_history(self): """ Returning the information of the history. Returns ------- """ return self.history
def _init_predictor(self, is_rand_expans): """ Initialize predictor. Parameters ---------- is_rand_expans: bool If true, physbo.blm.predictor is selected. If false, physbo.gp.Predictor is selected. """ if is_rand_expans: self.predictor = self._make_blm_predictor() else: self.predictor = self._make_gp_predictor() def _make_gp_predictor(self): """Create a GP predictor, with ARD if self.ard is True.""" ard = self.ard num_dim = self.get_num_dim() model = gp.core.Model.create_default(ard=ard, num_dim=num_dim) return gp_predictor(self.config, model=model) def _make_blm_predictor(self): """Create a BLM predictor, with ARD if self.ard is True.""" ard = self.ard num_dim = self.get_num_dim() model = gp.core.Model.create_default(ard=ard, num_dim=num_dim) return blm_predictor(self.config, model=model) def _learn_hyperparameter(self, num_rand_basis): self.predictor.fit( self.training, num_rand_basis, comm=self.mpicomm, objective_index=0 ) # Get basis and convert to (1, N, n) format for single-objective training_Z_basis = self.predictor.get_basis(self.training.X) if training_Z_basis is not None: self.training.Z = training_Z_basis[np.newaxis, :, :] # (N, n) -> (1, N, n) self.predictor.prepare(self.training, objective_index=0) self.new_data = None def _update_predictor(self): if self.new_data is not None: self.predictor.update(self.training, self.new_data, objective_index=0) self.new_data = None def _make_variable_X(self, test_X): """ Make a new *Variable* with X=test_X Parameters ---------- test_X: numpy.ndarray or physbo.Variable The set of candidates. Each row vector represents the feature vector of each search candidate. Returns ------- test_X: numpy.ndarray or physbo.Variable The set of candidates. Each row vector represents the feature vector of each search candidate. """ if isinstance(test_X, np.ndarray): test = Variable(X=test_X) elif isinstance(test_X, Variable): test = test_X else: raise TypeError("The type of test_X must be ndarray or physbo.Variable") return test
def _run_simulator(simulator, action, comm=None): """ Run simulator and normalize return value to (N, 1) shape. Parameters ---------- simulator: callable Function that takes action and returns t value(s) action: numpy.ndarray Array of actions comm: MPI.Comm, optional MPI communicator Returns ------- numpy.ndarray Normalized array with shape (N, 1) """ if comm is None: t = simulator(action) else: if comm.rank == 0: t = simulator(action) else: t = 0.0 t = comm.bcast(t, root=0) return normalize_t(t, k=1)