From a649ed6f0eff19f97a26b59d0fc86f3c15738662 Mon Sep 17 00:00:00 2001 From: Vasilis Gkolemis Date: Wed, 16 Feb 2022 15:26:39 +0200 Subject: [PATCH 1/4] Romc fixes (#393) * important fixes on the hessian approximation and added some tests * added test for the construction of the proposal region * small change on line search algorithm * added tests, fixed visualizations * added numdifftools to requirements * added tests * (a) moved NDimBoundingBox from utils.py to romc.py (b) added a test for RomcPosterior (c) visualize_region() from RomcPosterior to ROMC class * minor changes to NDimBoundingBox and added 3 tests * reordered RegionConstructor * made visualize_region external functions * added tests * minor changes to pass lint tests * fixed review comments --- elfi/methods/inference/romc.py | 905 +++++++++++++++++++++-------- elfi/methods/posteriors.py | 163 +----- elfi/methods/utils.py | 175 +----- requirements.txt | 1 + tests/functional/test_inference.py | 466 ++++++++++++++- 5 files changed, 1135 insertions(+), 575 deletions(-) diff --git a/elfi/methods/inference/romc.py b/elfi/methods/inference/romc.py index 8e278437..eb10ee90 100644 --- a/elfi/methods/inference/romc.py +++ b/elfi/methods/inference/romc.py @@ -3,11 +3,14 @@ __all__ = ['ROMC'] import logging +import math import timeit +import typing from functools import partial from multiprocessing import Pool import matplotlib.pyplot as plt +import numdifftools as nd import numpy as np import scipy.optimize as optim import scipy.spatial as spatial @@ -24,8 +27,9 @@ from elfi.methods.inference.parameter_inference import ParameterInference from elfi.methods.posteriors import RomcPosterior from elfi.methods.results import OptimizationResult, RomcSample -from elfi.methods.utils import (NDimBoundingBox, arr2d_to_batch, batch_to_arr2d, - ceil_to_batch_size, compute_ess, flat_array_to_dict) +from elfi.methods.utils import (arr2d_to_batch, batch_to_arr2d, ceil_to_batch_size, + compute_ess, flat_array_to_dict) +from elfi.model.elfi_model import ElfiModel, NodeReference from elfi.model.extensions import ModelPrior from elfi.visualization.visualization import ProgressBar @@ -55,8 +59,7 @@ def __init__(self, exploration_rate=10, batch_size=1, async_acq=False, - seed=None, - **kwargs): + seed=None): """Initialize Bayesian optimization. Parameters @@ -298,11 +301,13 @@ def fit(self): if inp is None: inp = self.prior.rvs(size=1) + if inp.ndim == 1: - inp = np.expand_dims(inp, -1) + inp = np.expand_dims(inp, 0) next_batch = arr2d_to_batch(inp, self.parameter_names) y = np.array([self.det_func(np.squeeze(inp, 0))]) + next_batch[self.target_name] = y self.update(next_batch, ii) @@ -424,8 +429,14 @@ class ROMC(ParameterInference): """ - def __init__(self, model, bounds=None, discrepancy_name=None, output_names=None, - custom_optim_class=None, parallelize=False, **kwargs): + def __init__(self, + model: typing.Union[ElfiModel, NodeReference], + bounds: typing.Union[typing.List, None] = None, + discrepancy_name: typing.Union[str, None] = None, + output_names: typing.Union[typing.List[str]] = None, + custom_optim_class=None, + parallelize: bool = False, + **kwargs): """Class constructor. Parameters @@ -438,14 +449,19 @@ def __init__(self, model, bounds=None, discrepancy_name=None, output_names=None, the name of the output node (obligatory, only if Model is passed as model) output_names: List[string] which node values to store during inference + custom_optim_class: class + Custom OptimizationProblem class provided by the user, to extend the algorithm + parallelize: bool + whether to parallelize all parts of the algorithm kwargs: Dict other named parameters """ - # define model, output names asked by the romc method + # define model model, discrepancy_name = self._resolve_model(model, discrepancy_name) - output_names = [discrepancy_name] + \ - model.parameter_names + (output_names or []) + + # set the output names + output_names = [discrepancy_name] + model.parameter_names + (output_names or []) # setter self.discrepancy_name = discrepancy_name @@ -454,9 +470,9 @@ def __init__(self, model, bounds=None, discrepancy_name=None, output_names=None, self.dim = self.model_prior.dim self.bounds = bounds self.left_lim = np.array([bound[0] for bound in bounds], - dtype=np.float) if bounds is not None else None + dtype=float) if bounds is not None else None self.right_lim = np.array([bound[1] for bound in bounds], - dtype=np.float) if bounds is not None else None + dtype=float) if bounds is not None else None # holds the state of the inference process self.inference_state = {"_has_gen_nuisance": False, @@ -473,72 +489,56 @@ def __init__(self, model, bounds=None, discrepancy_name=None, output_names=None, "accepted": None, "computed_BB": None} - # inputs passed during inference are passed here + # inputs passed during inference will be stored here self.inference_args = {"parallelize": parallelize} - # user-defined OptimisationClass self.custom_optim_class = custom_optim_class - # objects stored during inference; they are all lists of the same dimension (n1) - self.nuisance = None # List of integers - self.optim_problems = None # List of OptimisationProblem objects + # List of OptimisationProblem objects + self.optim_problems = None - # output objects - self.posterior = None # RomcPosterior object - # np.ndarray: (#accepted,n2,D), Samples drawn from RomcPosterior + # RomcPosterior object + self.posterior = None + # Samples drawn from RomcPosterior, np.ndarray (#accepted,n2,D), self.samples = None - # np.ndarray: (#accepted,n2): weights of the samples + # weights of the samples, np.ndarray (#accepted,n2): self.weights = None - # np.ndarray: (#accepted,n2): distances of the samples + # distances of the samples, np.ndarray (#accepted,n2): self.distances = None - self.result = None # RomcSample object + # RomcSample object + self.result = None self.progress_bar = ProgressBar(prefix='Progress', suffix='Complete', decimals=1, length=50, fill='=') super(ROMC, self).__init__(model, output_names, **kwargs) - def _sample_nuisance(self, n1, seed=None): - """Draw n1 nuisance variables (i.e. seeds). - - Parameters - ---------- - n1: int - nof nuisance samples - seed: int (optional) - the seed used for sampling the nuisance variables - - """ + def _define_objectives(self, n1, seed=None): + """Define n1 deterministic optimisation problems, by freezing the seed of the generator.""" + # getters assert isinstance(n1, int) + dim = self.dim + param_names = self.parameter_names + bounds = self.bounds + model_prior = self.model_prior + target_name = self.discrepancy_name # main part # It can sample at most 4x1E09 unique numbers # TODO fix to work with subseeds to remove the limit of 4x1E09 numbers - up_lim = 2**32 - 1 + up_lim = 2 ** 32 - 1 nuisance = ss.randint(low=1, high=up_lim).rvs( size=n1, random_state=seed) # update state self.inference_state["_has_gen_nuisance"] = True - self.nuisance = nuisance self.inference_args["N1"] = n1 self.inference_args["initial_seed"] = seed - def _define_objectives(self): - """Define n1 deterministic optimisation problems, by freezing the seed of the generator.""" - # getters - nuisance = self.nuisance - dim = self.dim - param_names = self.parameter_names - bounds = self.bounds - model_prior = self.model_prior - n1 = self.inference_args["N1"] - target_name = self.discrepancy_name - - # main optim_problems = [] for ind, nuisance in enumerate(nuisance): objective = self._freeze_seed(nuisance) + # f = self._freeze_seed_f(nuisance) if self.custom_optim_class is None: optim_prob = OptimisationProblem(ind, nuisance, param_names, target_name, objective, dim, model_prior, n1, bounds) @@ -589,12 +589,14 @@ def _freeze_seed(self, seed): """ return partial(self._det_generator, seed=seed) - def _worker_solve_gradients(self, args): + @staticmethod + def _worker_solve_gradients(args): optim_prob, kwargs = args is_solved = optim_prob.solve_gradients(**kwargs) return optim_prob, is_solved - def _worker_build_region(self, args): + @staticmethod + def _worker_build_region(args): optim_prob, accepted, kwargs = args if accepted: is_built = optim_prob.build_region(**kwargs) @@ -602,7 +604,8 @@ def _worker_build_region(self, args): is_built = False return optim_prob, is_built - def _worker_fit_model(self, args): + @staticmethod + def _worker_fit_model(args): optim_prob, accepted, kwargs = args if accepted: optim_prob.fit_local_surrogate(**kwargs) @@ -833,7 +836,7 @@ def _fit_models(self, **kwargs): self.inference_state["_has_fitted_local_models"] = True def _define_posterior(self, eps_cutoff): - """Collect all computed regions and define the RomcPosterior. + """Define the posterior distribution. Returns ------- @@ -852,35 +855,43 @@ def _define_posterior(self, eps_cutoff): # collect all constructed regions regions = [] - funcs = [] - funcs_unique = [] + objectives = [] + objectives_actual = [] + objectives_surrogate = None if use_surrogate is False else [] + objectives_local = None if use_local is False else [] nuisance = [] + any_surrogate_used = (use_local or use_surrogate) for i, prob in enumerate(problems): if prob.state["region"]: - for jj in range(len(prob.regions)): + for jj, region in enumerate(prob.regions): + # add nuisance variable nuisance.append(prob.nuisance) - regions.append(prob.regions[jj]) - if not use_local: - if use_surrogate: - assert prob.surrogate is not None - funcs.append(prob.surrogate) - else: - funcs.append(prob.objective) - else: - assert prob.local_surrogate is not None - funcs.append(prob.local_surrogate[jj]) - - if not use_local: - if use_surrogate: - funcs_unique.append(prob.surrogate) - else: - funcs_unique.append(prob.objective) - else: - funcs_unique.append(prob.local_surrogate[0]) - self.posterior = RomcPosterior(regions, funcs, nuisance, funcs_unique, prior, - left_lim, right_lim, eps_filter, eps_region, - eps_cutoff, parallelize) + # add region + regions.append(region) + + # add actual objective + objectives_actual.append(prob.objective) + + # add local and surrogate objectives if they have been computed + objectives_surrogate.append(prob.surrogate) if \ + objectives_surrogate is not None else None + objectives_local.append(prob.local_surrogates[jj]) if \ + objectives_local is not None else None + + # add the objective that will be used by the posterior + # the policy is (a) local (b) surrogate (c) actual + if use_local: + objectives.append(prob.local_surrogates[jj]) + if not use_local and use_surrogate: + objectives.append(prob.surrogate) + if not use_local and not use_surrogate: + objectives.append(prob.objective) + + self.posterior = RomcPosterior(regions, objectives, objectives_actual, + objectives_surrogate, objectives_local, nuisance, + any_surrogate_used, prior, left_lim, right_lim, eps_filter, + eps_region, eps_cutoff, parallelize) self.inference_state["_has_defined_posterior"] = True # Training routines @@ -905,6 +916,8 @@ def fit_posterior(self, n1, eps_filter, use_bo=False, quantile=None, optimizer_a keyword-arguments that will be passed to the regionConstructor seed: Union[None, int] seed definition for making the training process reproducible + eps_region: + threshold for region construction """ assert isinstance(n1, int) @@ -963,8 +976,7 @@ def solve_problems(self, n1, use_bo=False, optimizer_args=None, seed=None): if "seed" not in optimizer_args: optimizer_args["seed"] = seed - self._sample_nuisance(n1=n1, seed=seed) - self._define_objectives() + self._define_objectives(n1=n1, seed=seed) if not use_bo: logger.info("### Solving problems using a gradient-based method ###") @@ -979,8 +991,8 @@ def solve_problems(self, n1, use_bo=False, optimizer_args=None, seed=None): toc = timeit.default_timer() logger.info("Time: %.3f sec" % (toc - tic)) - def estimate_regions(self, eps_filter, use_surrogate=None, region_args=None, - fit_models=False, fit_models_args=None, + def estimate_regions(self, eps_filter, use_surrogate=False, region_args=None, + fit_models=True, fit_models_args=None, eps_region=None, eps_cutoff=None): """Filter solutions and build the N-Dimensional bounding box around the optimal point. @@ -1256,7 +1268,7 @@ def extract_result(self): weights=weights) # Inspection Routines - def visualize_region(self, i, savefig=False): + def visualize_region(self, i, force_objective=False, savefig=False): """Plot the acceptance area of the i-th optimisation problem. Parameters @@ -1267,10 +1279,18 @@ def visualize_region(self, i, savefig=False): None or path """ - assert self.inference_state["_has_estimated_regions"] - self.posterior.visualize_region(i, - samples=self.samples, - savefig=savefig) + def _i_to_solved_i(ii, probs): + k = 0 + for j in range(ii): + if probs[j].state["region"]: + k += 1 + return k + + if self.samples is not None: + samples = self.samples[_i_to_solved_i(i, self.optim_problems)] + else: + samples = None + self.optim_problems[i].visualize_region(force_objective, samples, savefig) def distance_hist(self, savefig=False, **kwargs): """Plot a histogram of the distances at the optimal point. @@ -1306,8 +1326,8 @@ def distance_hist(self, savefig=False, **kwargs): class OptimisationProblem: """Base class for a deterministic optimisation problem.""" - def __init__(self, ind, nuisance, parameter_names, target_name, objective, dim, prior, - n1, bounds): + def __init__(self, ind, nuisance, parameter_names, target_name, + objective, dim, prior, n1, bounds): """Class constructor. Parameters @@ -1332,52 +1352,77 @@ def __init__(self, ind, nuisance, parameter_names, target_name, objective, dim, bounds of the optimisation problem """ - self.ind = ind - self.nuisance = nuisance - self.objective = objective - self.dim = dim - self.bounds = bounds - self.parameter_names = parameter_names - self.target_name = target_name - self.prior = prior - self.n1 = n1 - - # state of the optimization problems + # index of the optimisation problem + self.ind: int = ind + # nuisance variable that created the objective function + self.nuisance: int = nuisance + # the objective function + self.objective: typing.Callable = objective + # dimensionality of the problem + self.dim: int = dim + # bounds of the prior, important for the BO case + self.bounds: np.ndarray = bounds + # names of the parameters, important for the BO case + self.parameter_names: typing.List[str] = parameter_names + # name of the distance variable, needed at the BO case + self.target_name: str = target_name + # The prior distribution + self.prior: ModelPrior = prior + # total number of optimisation problems that have been defined + self.n1: int = n1 + + # state of the optimization problem self.state = {"attempted": False, "solved": False, "has_fit_surrogate": False, "has_fit_local_surrogates": False, + "has_built_region_with_surrogate": False, "region": False} - # store as None as values - self.surrogate = None - self.local_surrogate = None - self.result = None - self.regions = None - self.eps_region = None - self.initial_point = None + # Bayesian Optimisation process + self.bo_process = None + # surrogate model fit at Bayesian Optimisation + self.surrogate: typing.Union[typing.Callable, None] = None + # list with local surrogate models + self.local_surrogates: typing.Union[typing.List[typing.Callable], None] = None + # optimisation result + self.result: typing.Union[RomcOptimisationResult, None] = None + # list with acceptance regions + self.regions: typing.Union[typing.List[NDimBoundingBox], None] = None + # threshold for building the region + self.eps_region: typing.Union[float, None] = None + # initial point of the optimization process + self.initial_point: typing.Union[np.ndarray, None] = None def solve_gradients(self, **kwargs): - """Solve the optimisation problem using the scipy.optimise. + """Solve the optimisation problem using the scipy.optimise package. Parameters ---------- - **kwargs: all input arguments to the optimiser. In the current - implementation the arguments used if defined are: ["seed", "x0", "method", "jac"]. - All the rest will be ignored. + **kwargs: + seed (int): the seed of the optimisation process + x0 (np.ndarray): the initial point of the optimisation process, if not + given explicitly, a random point will be drawn from the prior + method (str): the name of the method to be used, should be one from + https://docs.scipy.org/doc/scipy/reference/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize + Default value is "Nelder-Mead" + jac (np.ndarray): the jacobian matrix for computing the gradients analytically Returns ------- - Boolean, whether the optimisation reached an end point + Boolean, + whether the optimisation reached successfully an end point """ + DEFAULT_ALGORITHM = "Nelder-Mead" + # prepare inputs seed = kwargs["seed"] if "seed" in kwargs else None if "x0" not in kwargs: x0 = self.prior.rvs(size=self.n1, random_state=seed)[self.ind] else: x0 = kwargs["x0"] - method = "L-BFGS-B" if "method" not in kwargs else kwargs["method"] + method = DEFAULT_ALGORITHM if "method" not in kwargs else kwargs["method"] jac = kwargs["jac"] if "jac" in kwargs else None fun = self.objective @@ -1387,10 +1432,8 @@ def solve_gradients(self, **kwargs): if res.success: self.state["solved"] = True - jac = res.jac if hasattr(res, "jac") else None - hess_inv = res.hess_inv.todense() if hasattr(res, "hess_inv") else None - self.result = RomcOpimisationResult( - res.x, res.fun, jac, hess_inv) + hess_appr = nd.Hessian(self.objective)(res.x) + self.result = RomcOptimisationResult(res.x, res.fun, hess_appr) self.initial_point = x0 return True else: @@ -1405,9 +1448,9 @@ def solve_bo(self, **kwargs): Parameters ---------- - **kwargs: all input arguments to the optimiser. In the current - implementation the arguments used if defined are: ["n_evidence", "acq_noise_var"]. - All the rest will be ignored. + **kwargs: + n_evidence (int): number of initial points. Default: 20 + acq_noise_var (float): added noise to point acquisition. Default: 0.1 Returns ------- @@ -1427,7 +1470,6 @@ def solve_bo(self, **kwargs): def create_surrogate_objective(trainer): def surrogate_objective(theta): return trainer.target_model.predict_mean(np.atleast_2d(theta)).item() - return surrogate_objective target_model = GPyRegression(parameter_names=self.parameter_names, @@ -1442,46 +1484,54 @@ def surrogate_objective(theta): target_model=target_model, acq_noise_var=acq_noise_var) trainer.fit() - # self.gp = trainer self.surrogate = create_surrogate_objective(trainer) + self.bo_process = trainer param_names = self.parameter_names x = batch_to_arr2d(trainer.result.x_min, param_names) x = np.squeeze(x, 0) x_min = x - self.result = RomcOpimisationResult( - x_min, self.surrogate(x_min)) + hess_appr = nd.Hessian(self.objective)(x_min) + self.result = RomcOptimisationResult(x_min, self.objective(x_min), hess_appr) self.state["attempted"] = True self.state["solved"] = True self.state["has_fit_surrogate"] = True return True - def build_region(self, **kwargs): + def build_region(self, **kwargs) -> bool: """Compute the n-dimensional Bounding Box. Parameters ---------- - kwargs: all input arguments to the regionConstructor. - - + kwargs: + **eps_region (float): threshold for building the region, this kwargument is mandatory + **use_surrogate (bool): whether to use the surrogate objective (if it is avalable) + for building the proposal region + **eta (float) (default = 1.), step along the search direction + **K (int) (default = 10), nof refinements + **rep_lim (int) (default = 300), nof maximum repetitions Returns ------- - boolean, + bool, whether the region construction was successful """ assert self.state["solved"] + + # set parameters for the region construction if "use_surrogate" in kwargs: use_surrogate = kwargs["use_surrogate"] else: - use_surrogate = True if self.state["_has_fit_surrogate"] else False + use_surrogate = True if self.state["has_fit_surrogate"] else False if use_surrogate: assert self.surrogate is not None, \ "You have to first fit a surrogate model, in order to use it." func = self.surrogate if use_surrogate else self.objective - step = 0.05 if "step" not in kwargs else kwargs["step"] - lim = 100 if "lim" not in kwargs else kwargs["lim"] + self.state["has_built_region_with_surrogate"] = True if use_surrogate else False + eta = 1. if "eta" not in kwargs else kwargs["eta"] + K = 10 if "K" not in kwargs else kwargs["K"] + rep_lim = 300 if "rep_lim" not in kwargs else kwargs["rep_lim"] assert "eps_region" in kwargs, \ "In the current build region implementation, kwargs must contain eps_region" eps_region = kwargs["eps_region"] @@ -1489,74 +1539,101 @@ def build_region(self, **kwargs): # construct region constructor = RegionConstructor( - self.result, func, self.dim, eps_region=eps_region, lim=lim, step=step) + self.result, func, self.dim, eps_region=eps_region, + K=K, eta=eta, rep_lim=rep_lim) self.regions = constructor.build() # update the state self.state["region"] = True return True - def _local_surrogate(self, theta, model_scikit): - assert theta.ndim == 1 - theta = np.expand_dims(theta, 0) - return float(model_scikit.predict(theta)) - - def _create_local_surrogate(self, model): - return partial(self._local_surrogate, model_scikit=model) - - def fit_local_surrogate(self, **kwargs): - """Fit a local quadratic model around the optimal distance. + def fit_local_surrogate(self, **kwargs) -> None: + """Fit a local quadratic model for each acceptance region. Parameters ---------- - kwargs: all keyword arguments - use_surrogate: bool - whether to use the surrogate model fitted with Bayesian Optimisation - nof_samples: int - number of sampled points to be used for fitting the model + kwargs: + **nof_samples (int): how many samples to generate for fitting the local approximator + **use_surrogate (bool): whether to use the surrogate model (if it is avalable) for + labeling the generated samples Returns ------- - Callable, - The fitted model + None """ + def local_surrogate(theta, model_scikit): + assert theta.ndim == 1 + theta = np.expand_dims(theta, 0) + return float(model_scikit.predict(theta)) + + def create_local_surrogate(model): + return partial(local_surrogate, model_scikit=model) + nof_samples = 20 if "nof_samples" not in kwargs else kwargs["nof_samples"] if "use_surrogate" not in kwargs: - objective = self.surrogate if self.state["has_fit_surrogate"] else self.objective + objective = self.objective else: objective = self.surrogate if kwargs["use_surrogate"] else self.objective - # def create_local_surrogate(model): - # def local_surrogate(theta): - # assert theta.ndim == 1 - # - # theta = np.expand_dims(theta, 0) - # return float(model.predict(theta)) - # return local_surrogate - + # fit the surrogate model local_surrogates = [] for i in range(len(self.regions)): # prepare dataset x = self.regions[i].sample(nof_samples) y = np.array([objective(ii) for ii in x]) + # fit model model = Pipeline([('poly', PolynomialFeatures(degree=2)), ('linear', LinearRegression(fit_intercept=False))]) - model = model.fit(x, y) - # local_surrogates.append(create_local_surrogate(model)) - local_surrogates.append(self._create_local_surrogate(model)) + local_surrogates.append(create_local_surrogate(model)) - self.local_surrogate = local_surrogates + # update the state + self.local_surrogates = local_surrogates self.state["local_surrogates"] = True + def visualize_region(self, + force_objective: bool = False, + samples: typing.Union[np.ndarray, None] = None, + savefig: typing.Union[str, None] = None) -> None: + """Plot the i-th n-dimensional bounding box region. + + Parameters + ---------- + force_objective: if enabled, enforces the use of the objective function (not the surrogate) + samples: the samples drawn from this region + savefig: the path for saving the plot + + Returns + ------- + None + + """ + if not self.state["region"]: + print("The specific optimisation problem has not been solved! Please, choose another!") + return + dim = self.dim + + # if has_fit_surrogate use it, except if force_objective is on + use_objective = (not self.state["has_built_region_with_surrogate"] or force_objective) + func = self.objective if use_objective else self.surrogate + + # choose the first region (so far, we support only one region per objective function + region = self.regions[0] -class RomcOpimisationResult: + if dim == 1: + vis_region_1D(func, region, self.nuisance, self.eps_region, samples, use_objective, + savefig) + else: + vis_region_2D(func, region, self.nuisance, samples, use_objective, savefig) + + +class RomcOptimisationResult: """Base class for the optimisation result of the ROMC method.""" - def __init__(self, x_min, f_min, jac=None, hess=None, hess_inv=None): + def __init__(self, x_min, f_min, hess_appr, jac=None, hess=None, hess_inv=None): """Class constructor. Parameters @@ -1569,128 +1646,466 @@ def __init__(self, x_min, f_min, jac=None, hess=None, hess_inv=None): """ self.x_min = np.atleast_1d(x_min) self.f_min = f_min + self.hess_appr = hess_appr self.jac = jac self.hess = hess self.hess_inv = hess_inv +class NDimBoundingBox: + """Class for the n-dimensional bounding box built around the optimal point.""" + + def __init__(self, rotation: np.ndarray, center: np.ndarray, limits: np.ndarray) -> None: + """Class initialiser. + + Parameters + ---------- + rotation: shape (D,D) rotation matrix, defines the rotation of the bounding box + center: shape (D,) center of the bounding box + limits: shape (D,2), limits of the bounding box around the center + i.e. limits[:,0] (left limits) are negative translations and limits[:,1] (right limits) + are positive translations + The structure is + [[d1_left_shift, d1_right_shift], + [d2_left_shift, d2_right_shift], + ... , + [dD_left_shift, dD_right_shift]] + + """ + # shape assertions + assert rotation.ndim == 2 + assert center.ndim == 1 + assert limits.ndim == 2 + assert limits.shape[1] == 2 + assert center.shape[0] == rotation.shape[0] == rotation.shape[1] + + # assert rotation matrix is full-rank i.e. invertible + assert np.linalg.matrix_rank(rotation) == rotation.shape[0] + + # setters + self.dim = rotation.shape[0] + self.rotation = rotation + self.center = center + self.limits = self._secure_limits(limits) + + # compute rotation matrix and volume of the region + self.rotation_inv = np.linalg.inv(self.rotation) + self.volume = self._compute_volume() + + def _secure_limits(self, limits: np.ndarray) -> np.ndarray: + limits = limits.astype(float) + eps = .001 + for i in range(limits.shape[0]): + # assert left limits are negative translations + assert limits[i, 0] <= 0. + # assert right limits are positive translations + assert limits[i, 1] >= 0. + + # if in any dimension, limits too close, move them + if math.isclose(limits[i, 0], limits[i, 1], abs_tol=eps): + logger.warning("The limits of the " + str(i) + "-th dimension of a bounding " + + "box are too narrow (<= " + str(eps) + ")") + limits[i, 0] -= eps / 2 + limits[i, 1] += eps / 2 + return limits + + def _compute_volume(self): + v = np.prod(- self.limits[:, 0] + self.limits[:, 1]) + assert v >= 0 + return v + + def contains(self, point): + """Check if point is inside the bounding box. + + Parameters + ---------- + point: (D, ) + + Returns + ------- + True/False + + """ + assert point.ndim == 1 + assert point.shape[0] == self.dim + + # transform point to the bb's coordinate system + point = np.dot(self.rotation_inv, point) + np.dot(self.rotation_inv, -self.center) + + # Check if point is inside bounding box + inside = True + for i in range(point.shape[0]): + if (point[i] < self.limits[i][0]) or (point[i] > self.limits[i][1]): + inside = False + break + return inside + + def sample(self, n2, seed=None): + """Sample n2 points from the posterior. + + Parameters + ---------- + n2: int + seed: seed of the sampling procedure + + Returns + ------- + np.ndarray, shape: (n2,D) + + """ + center = self.center + limits = self.limits + rot = self.rotation + + loc = limits[:, 0] + scale = limits[:, 1] - limits[:, 0] + + # draw n2 samples + theta = [] + for i in range(loc.shape[0]): + rv = ss.uniform(loc=loc[i], scale=scale[i]) + theta.append(rv.rvs(size=(n2, 1), random_state=seed)) + + theta = np.concatenate(theta, -1) + # translate and rotate + theta_new = np.dot(rot, theta.T).T + center + + return theta_new + + def pdf(self, theta: np.ndarray): + """Evalute the pdf defined by the bounding box. + + Parameters + ---------- + theta: np.ndarray (D,) + + Returns + ------- + float + + """ + return self.contains(theta) / self.volume + + def plot(self, samples): + """Plot the bounding box (works only if dim=1 or dim=2). + + Parameters + ---------- + samples: np.ndarray, shape: (N, D) + + Returns + ------- + None + + """ + R = self.rotation + T = self.center + lim = self.limits + + if self.dim == 1: + plt.figure() + plt.title("Bounding Box region") + + # plot eigenectors + end_point = T + R[0, 0] * lim[0][0] + plt.plot([T[0], end_point[0]], [T[1], end_point[1]], "r-o") + end_point = T + R[0, 0] * lim[0][1] + plt.plot([T[0], end_point[0]], [T[1], end_point[1]], "r-o") + + plt.plot(samples, np.zeros_like(samples), "bo") + plt.legend() + plt.show(block=False) + else: + plt.figure() + plt.title("Bounding Box region") + + # plot sampled points + plt.plot(samples[:, 0], samples[:, 1], "bo", label="samples") + + # plot eigenectors + x = T + x1 = T + R[:, 0] * lim[0][0] + plt.plot([T[0], x1[0]], [T[1], x1[1]], "y-o", label="-v1") + x3 = T + R[:, 0] * lim[0][1] + plt.plot([T[0], x3[0]], [T[1], x3[1]], "g-o", label="v1") + + x2 = T + R[:, 1] * lim[1][0] + plt.plot([T[0], x2[0]], [T[1], x2[1]], "k-o", label="-v2") + x4 = T + R[:, 1] * lim[1][1] + plt.plot([T[0], x4[0]], [T[1], x4[1]], "c-o", label="v2") + + # plot boundaries + def plot_side(x, x1, x2): + tmp = x + (x1 - x) + (x2 - x) + plt.plot([x1[0], tmp[0], x2[0]], [x1[1], tmp[1], x2[1]], "r-o") + + plot_side(x, x1, x2) + plot_side(x, x2, x3) + plot_side(x, x3, x4) + plot_side(x, x4, x1) + + plt.legend() + plt.show(block=False) + + class RegionConstructor: """Class for constructing an n-dim bounding box region.""" - def __init__(self, result: RomcOpimisationResult, - func, dim, eps_region, lim, step): + def __init__(self, + result: RomcOptimisationResult, + func: typing.Callable, + dim: int, + eps_region: float, + K: int = 10, + eta: float = 1., + rep_lim: int = 300): """Class constructor. Parameters ---------- - result: object of RomcOptimisationResult - func: Callable(np.ndarray) -> float - dim: int - eps_region: threshold - lim: float, largets translation along the search direction - step: float, step along the search direction + result: RomcOptimisationResult, output of the optimization process + func: Callable(np.ndarray) -> float, the objective function + dim: int, dimensionality of the problem + eps_region: float, threshold for building the region + K: int (default = 10), nof refinements + eta: float (default = 1.), step along the search direction + rep_lim: int (default = 300), nof maximum repetitions eta """ self.res = result self.func = func self.dim = dim self.eps_region = eps_region - self.lim = lim - self.step = step + self.K = K + self.eta = eta + self.rep_lim = rep_lim - def build(self): - """Build the bounding box. + def _find_rotation_vector(self, hess_appr: np.ndarray) -> np.ndarray: + """Return the rotation vector from the hessian approximation. + + Parameters + ---------- + hess_appr: np.ndarray (D,D) Returns ------- - List[NDimBoundingBox] + rotation matrix, np.ndarray(D, D) """ - res = self.res - func = self.func - dim = self.dim - eps = self.eps_region - lim = self.lim - step = self.step - - theta_0 = np.array(res.x_min, dtype=np.float) + dim = hess_appr.shape[0] - if res.hess is not None: - hess_appr = res.hess - elif res.hess_inv is not None: - # TODO add check for inverse - if np.linalg.matrix_rank(res.hess_inv) != dim: - hess_appr = np.eye(dim) - else: - hess_appr = np.linalg.inv(res.hess_inv) - else: - h = 1e-5 - grad_vec = optim.approx_fprime(theta_0, func, h) - grad_vec = np.expand_dims(grad_vec, -1) - hess_appr = np.dot(grad_vec, grad_vec.T) - if np.isnan(np.sum(hess_appr)) or np.isinf(np.sum(hess_appr)): - hess_appr = np.eye(dim) - - assert hess_appr.shape[0] == dim - assert hess_appr.shape[1] == dim - - if np.isnan(np.sum(hess_appr)) or np.isinf(np.sum(hess_appr)): - logger.info("Eye matrix return as rotation.") + # find search lines from the hessian approximation + if np.linalg.matrix_rank(hess_appr) != dim: hess_appr = np.eye(dim) - eig_val, eig_vec = np.linalg.eig(hess_appr) # if extreme values appear, return the I matrix - if np.isnan(np.sum(eig_vec)) or np.isinf(np.sum(eig_vec)) or (eig_vec.dtype == np.complex): + if np.isnan(np.sum(eig_vec)) or np.isinf(np.sum(eig_vec)) or (eig_vec.dtype == complex): logger.info("Eye matrix return as rotation.") eig_vec = np.eye(dim) if np.linalg.matrix_rank(eig_vec) < dim: eig_vec = np.eye(dim) + return eig_vec - rotation = eig_vec + def build(self) -> typing.List[NDimBoundingBox]: + """Build the bounding box. - # compute limits - nof_points = int(lim / step) + Returns + ------- + List[NDimBoundingBox] - bounding_box = [] - for j in range(dim): - bounding_box.append([]) - vect = eig_vec[:, j] - - # right side - point = theta_0.copy() - v_right = 0 - for i in range(1, nof_points + 1): - point += step * vect - if func(point) > eps: - v_right = i * step - step / 2 - break - if i == nof_points: - v_right = (i - 1) * step - - # left side - point = theta_0.copy() - v_left = 0 - for i in range(1, nof_points + 1): - point -= step * vect - if func(point) > eps: - v_left = -i * step + step / 2 - break - if i == nof_points: - v_left = - (i - 1) * step - - if v_left == 0: - v_left = -step / 2 - if v_right == 0: - v_right = step / 2 - - bounding_box[j].append(v_left) - bounding_box[j].append(v_right) + """ + res = self.res + func = self.func + dim = self.dim + eps = self.eps_region + K = self.K + eta = self.eta + rep_lim = self.rep_lim + theta_0 = np.array(res.x_min, dtype=float) + rotation = self._find_rotation_vector(res.hess_appr) + + # compute bounding box + bounding_box = [] + for d in range(dim): + vd = rotation[:, d] + # negative side + v1 = - line_search(func, theta_0.copy(), -vd, eps, K, eta, rep_lim) + # positive side + v2 = line_search(func, theta_0.copy(), vd, eps, K, eta, rep_lim) + bounding_box.append([v1, v2]) bounding_box = np.array(bounding_box) + + # shape assertions for the bounding box assert bounding_box.ndim == 2 assert bounding_box.shape[0] == dim assert bounding_box.shape[1] == 2 - bb = [NDimBoundingBox(rotation, theta_0, bounding_box, eps)] + # return list of bounding boxes with one element only + # because in the future we will support more cases + bb = [NDimBoundingBox(rotation, theta_0, bounding_box)] return bb + + +def comp_j(f, th_star): + # find output dimensionality + dim = f(th_star).shape[0] + + # + def create_f_i(f, i): + def f_i(th_star): + return f(th_star)[i] + + return f_i + + jacobian = [] + for i in range(dim): + f_i = create_f_i(f, i=i) + jacobian.append(optim.approx_fprime(th_star, f_i, 1e-7)) + + jacobian = np.array(jacobian) + return jacobian + + +def line_search(f, th_star, vd, eps, K=10, eta=1., rep_lim=300): + """Line search algorithm. + + Parameters + ---------- + f: Callable(np.ndarray) -> float, the distance function + th_star: np.array (D,), starting point + vd: np.array (D,) search direction + eps: threshold + K: int (default = 10), nof refinements + eta: float (default = 1.), step along the search direction + rep_lim: int (default = 300), nof maximum repetitions + + Returns + ------- + float, offset where f(th_star + offset*vd) > eps for the first time + + """ + th = th_star.copy() + offset = 0 + for i in range(K): + + # find limit + rep = 0 + while f(th) < eps and rep <= rep_lim: + th += eta * vd + offset += eta + + rep += 1 + + th -= eta * vd + offset -= eta + + # if repetition limit has been reached, stop + if rep > rep_lim: + break + + # divide eta in half + eta = eta / 2 + + # if too small region, put the maximum resolution eta as boundary + if offset <= 0: + offset = eta + + return offset + + +def vis_region_1D(func, region, nuisance, eps_region, samples, is_objective, savefig): + plt.figure() + if is_objective: + plt.title("Seed = %d, f = model's objective" % nuisance) + else: + plt.title("Seed = %d, f = BO surrogate" % nuisance) + + # plot sampled points + if samples is not None: + x = samples[:, 0] + plt.plot(x, np.zeros_like(x), "bo", label="samples") + + x = np.linspace(region.center + + region.limits[0, 0] - + 0.2, region.center + + region.limits[0, 1] + + 0.2, 30) + y = [func(np.atleast_1d(theta)) for theta in x] + plt.plot(x, y, 'r--', label="distance") + plt.plot(region.center, 0, 'ro', label="center") + plt.xlabel("theta") + plt.ylabel("distance") + plt.axvspan(region.center + + region.limits[0, 0], region.center + + region.limits[0, 1], label="acceptance region") + plt.axhline(eps_region, color="g", label="eps") + plt.legend() + if savefig: + plt.savefig(savefig, bbox_inches='tight') + plt.show(block=False) + + +def vis_region_2D(func, region, nuisance, samples, is_objective, savefig): + plt.figure() + if is_objective: + plt.title("Seed = %d, f = model's objective" % nuisance) + else: + plt.title("Seed = %d, f = BO surrogate" % nuisance) + + max_offset = np.sqrt( + 2 * (np.max(np.abs(region.limits)) ** 2)) + 0.2 + x = np.linspace( + region.center[0] - max_offset, region.center[0] + max_offset, 30) + y = np.linspace( + region.center[1] - max_offset, region.center[1] + max_offset, 30) + X, Y = np.meshgrid(x, y) + + Z = [] + for k, ii in enumerate(x): + Z.append([]) + for kk, jj in enumerate(y): + Z[k].append(func(np.array([X[k, kk], Y[k, kk]]))) + Z = np.array(Z) + plt.contourf(X, Y, Z, 100, cmap="RdGy") + plt.plot(region.center[0], region.center[1], "ro") + + # plot sampled points + if samples is not None: + plt.plot(samples[:, 0], samples[:, 1], "bo", label="samples") + + # plot eigenectors + x = region.center + x1 = region.center + region.rotation[:, 0] * region.limits[0][0] + plt.plot([x[0], x1[0]], [x[1], x1[1]], "y-o", + label="-v1, f(-v1)=%.2f" % (func(x1))) + x3 = region.center + region.rotation[:, 0] * region.limits[0][1] + plt.plot([x[0], x3[0]], [x[1], x3[1]], "g-o", + label="v1, f(v1)=%.2f" % (func(x3))) + + x2 = region.center + region.rotation[:, 1] * region.limits[1][0] + plt.plot([x[0], x2[0]], [x[1], x2[1]], "k-o", + label="-v2, f(-v2)=%.2f" % (func(x2))) + x4 = region.center + region.rotation[:, 1] * region.limits[1][1] + plt.plot([x[0], x4[0]], [x[1], x4[1]], "c-o", + label="v2, f(v2)=%.2f" % (func(x3))) + + # plot boundaries + def plot_side(x, x1, x2): + tmp = x + (x1 - x) + (x2 - x) + plt.plot([x1[0], tmp[0], x2[0]], [x1[1], tmp[1], x2[1]], "r-o") + + plot_side(x, x1, x2) + plot_side(x, x2, x3) + plot_side(x, x3, x4) + plot_side(x, x4, x1) + + plt.xlabel("theta 1") + plt.ylabel("theta 2") + + plt.legend() + plt.colorbar() + if savefig: + plt.savefig(savefig, bbox_inches='tight') + plt.show(block=False) diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index 2e49a349..acccdaf9 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -9,7 +9,6 @@ import scipy.stats as ss from elfi.methods.bo.utils import minimize -from elfi.methods.utils import NDimBoundingBox from elfi.model.extensions import ModelPrior from elfi.visualization.visualization import ProgressBar @@ -261,7 +260,7 @@ def plot(self, logpdf=False): class RomcPosterior: r""" - Approximation of the posterior distribution as defined by the ROMC method. + The posterior distribution as defined by the ROMC method. References ---------- @@ -271,10 +270,13 @@ class RomcPosterior: """ def __init__(self, - regions: List[NDimBoundingBox], + regions: List, objectives: List[Callable], + objectives_actual: List[Callable], + objectives_surrogate: List[Callable], + objectives_local: List[Callable], nuisance: List[int], - objectives_unique: List[Callable], + surrogate_used, prior: ModelPrior, left_lim, right_lim, @@ -286,30 +288,42 @@ def __init__(self, Parameters ---------- - regions: List[NDimBoundingBox] - List of the n-dimensional regions + regions: List[elfi.methods.inference.romc.NDimBoundingBox] + List with all n-dimensional BB regions created at romc inference objectives: List[Callable] all the objective functions, equal len with regions. if an objective function produces more than one region, this list repeats the same objective as many times as need in symmetry with the regions list. + objectives_actual: List[Callable] + objectives_surrogate: List[Callable] + objectives_local: List[Callable] nuisance: List[int] the seeds used for defining the objectives - objectives_unique: List[Callable] - all unique objective functions + surrogate_used: bool + whether surrogate function has been used prior: ModelPrior the prior distribution left_lim: np.ndarray left limit right_lim: np.ndarray right limit + eps_filter: float + the threshold for filtering out solutions eps_region: float the threshold defining the acceptance region + eps_cutoff: float + the threshold for the surrogate function + parallelize: bool + whether to parallelize the sampling process """ self.regions = regions self.funcs = objectives + self.objectives_actual = objectives_actual + self.objectives_surrogate = objectives_surrogate + self.objectives_local = objectives_local self.nuisance = nuisance - self.funcs_unique = objectives_unique + self.surrogate_used = surrogate_used self.prior = prior self.eps_filter = eps_filter self.eps_region = eps_region @@ -341,14 +355,16 @@ def _pdf_unnorm_single_point(self, theta: np.ndarray) -> float: prior = self.prior pr = float(prior.pdf(np.expand_dims(theta, 0))) - indicator_sum = self._sum_over_indicators(theta) - # indicator_sum = self._sum_over_regions_indicators(theta) + if self.surrogate_used: + indicator_sum = self._sum_over_regions_indicators(theta) + else: + indicator_sum = self._sum_over_indicators(theta) val = pr * indicator_sum return val def _sum_over_indicators(self, theta: np.ndarray) -> int: - """Evaluate g_i(theta) for all i and count how many obey g_i(theta) <= eps. + """Evaluate d_i(theta) (or a surrogate) for all i. Parameters ---------- @@ -356,11 +372,12 @@ def _sum_over_indicators(self, theta: np.ndarray) -> int: The input point to be evaluated """ - funcs = self.funcs_unique + funcs = self.funcs eps = self.eps_cutoff nof_inside = 0 for i in range(len(funcs)): func = funcs[i] + if func(theta) <= eps: nof_inside += 1 return nof_inside @@ -541,13 +558,6 @@ def pdf(self, theta): self.partition = partition return self.pdf_unnorm_batched(theta) / partition - # pdf_eval = [] - # for i in range(theta.shape[0]): - # pdf_eval.append(self.pdf_unnorm_batched( - # theta[i:i + 1]) / partition) - # return self.pdf_unnorm_batched(theta[i:i + 1]) / partition - # - # return np.array(pdf_eval) def sample(self, n2: int, seed=None) -> (np.ndarray, np.ndarray): """Sample n2 points from each region of the posterior. @@ -575,7 +585,7 @@ def sample(self, n2: int, seed=None) -> (np.ndarray, np.ndarray): if self.parallelize is False: theta = [] for i in range(nof_regions): - theta.append(regions[i].sample(n2, seed)) + theta.append(regions[i].sample(n2, seed=seed)) theta = np.array(theta) else: pool = Pool() @@ -603,9 +613,6 @@ def sample(self, n2: int, seed=None) -> (np.ndarray, np.ndarray): pr = float(prior.pdf(np.expand_dims(cur_theta, 0))) # (iii) indicator - # # ind = indicator_region(cur_theta) - # # if not ind: - # # logger.warning("Negative indicator") dist = funcs[i](cur_theta) distances.append(dist) ind = dist < eps @@ -653,111 +660,3 @@ def compute_expectation(self, h, theta, w): numer = np.sum(h_theta * w) denom = np.sum(w) return numer / denom - - def visualize_region(self, i, samples, savefig): - """Plot the i-th n-dimensional bounding box region. - - Parameters - ---------- - i: int - the index of the region - samples: np.ndarray - the samples drawn from this region - savefig: Union[str, None] - the path for saving the plot or None - - """ - assert i < len(self.funcs) - dim = self.dim - func = self.funcs[i] - region = self.regions[i] - - if dim == 1: - plt.figure() - plt.title("Optimisation problem %d (seed = %d)." % - (i, self.nuisance[i])) - - # plot sampled points - if samples is not None: - x = samples[i, :, 0] - plt.plot(x, np.zeros_like(x), "bo", label="samples") - - x = np.linspace(region.center + - region.limits[0, 0] - - 0.2, region.center + - region.limits[0, 1] + - 0.2, 30) - y = [func(np.atleast_1d(theta)) for theta in x] - plt.plot(x, y, 'r--', label="distance") - plt.plot(region.center, 0, 'ro', label="center") - plt.xlabel("theta") - plt.ylabel("distance") - plt.axvspan(region.center + - region.limits[0, 0], region.center + - region.limits[0, 1], label="acceptance region") - plt.axhline(region.eps_region, color="g", label="eps") - plt.legend() - if savefig: - plt.savefig(savefig, bbox_inches='tight') - plt.show(block=False) - else: - plt.figure() - plt.title("Optimisation problem %d (seed = %d)." % - (i, self.nuisance[i])) - - max_offset = np.sqrt( - 2 * (np.max(np.abs(region.limits)) ** 2)) + 0.2 - x = np.linspace( - region.center[0] - max_offset, region.center[0] + max_offset, 30) - y = np.linspace( - region.center[1] - max_offset, region.center[1] + max_offset, 30) - X, Y = np.meshgrid(x, y) - - Z = [] - for k, ii in enumerate(x): - Z.append([]) - for kk, jj in enumerate(y): - Z[k].append(func(np.array([X[k, kk], Y[k, kk]]))) - Z = np.array(Z) - plt.contourf(X, Y, Z, 100, cmap="RdGy") - plt.plot(region.center[0], region.center[1], "ro") - - # plot sampled points - if samples is not None: - plt.plot(samples[i, :, 0], samples[i, :, 1], - "bo", label="samples") - - # plot eigenectors - x = region.center - x1 = region.center + region.rotation[:, 0] * region.limits[0][0] - plt.plot([x[0], x1[0]], [x[1], x1[1]], "y-o", - label="-v1, f(-v1)=%.2f" % (func(x1))) - x3 = region.center + region.rotation[:, 0] * region.limits[0][1] - plt.plot([x[0], x3[0]], [x[1], x3[1]], "g-o", - label="v1, f(v1)=%.2f" % (func(x3))) - - x2 = region.center + region.rotation[:, 1] * region.limits[1][0] - plt.plot([x[0], x2[0]], [x[1], x2[1]], "k-o", - label="-v2, f(-v2)=%.2f" % (func(x2))) - x4 = region.center + region.rotation[:, 1] * region.limits[1][1] - plt.plot([x[0], x4[0]], [x[1], x4[1]], "c-o", - label="v2, f(v2)=%.2f" % (func(x3))) - - # plot boundaries - def plot_side(x, x1, x2): - tmp = x + (x1 - x) + (x2 - x) - plt.plot([x1[0], tmp[0], x2[0]], [x1[1], tmp[1], x2[1]], "r-o") - - plot_side(x, x1, x2) - plot_side(x, x2, x3) - plot_side(x, x3, x4) - plot_side(x, x4, x1) - - plt.xlabel("theta 1") - plt.ylabel("theta 2") - - plt.legend() - plt.colorbar() - if savefig: - plt.savefig(savefig, bbox_inches='tight') - plt.show(block=False) diff --git a/elfi/methods/utils.py b/elfi/methods/utils.py index 7bb823b4..5d733c67 100644 --- a/elfi/methods/utils.py +++ b/elfi/methods/utils.py @@ -4,7 +4,6 @@ from math import ceil from typing import Union -import matplotlib.pyplot as plt import numpy as np import scipy.stats as ss @@ -295,7 +294,7 @@ def numgrad(fn, x, h=None, replace_neg_inf=True): h = 0.00001 if h is None else h h = np.asanyarray(h).reshape(-1) - x = np.asanyarray(x, dtype=np.float).reshape(-1) + x = np.asanyarray(x, dtype=float).reshape(-1) dim = len(x) X = np.zeros((dim * 3, dim)) @@ -412,178 +411,6 @@ def weighted_sample_quantile(x, alpha, weights=None): return alpha_q -class NDimBoundingBox: - """Class for the n-dimensional bounding box built around the optimal point.""" - - def __init__(self, rotation, center, limits, eps_region): - """Class initialiser. - - Parameters - ---------- - rotation: (D,D) rotation matrix for the Bounding Box - center: (D,) center of the Bounding Box - limits: np.ndarray, shape: (D,2) - The limits of the bounding box. - - """ - assert rotation.ndim == 2 - assert center.ndim == 1 - assert limits.ndim == 2 - assert limits.shape[1] == 2 - assert center.shape[0] == rotation.shape[0] == rotation.shape[1] - - self.rotation = rotation - self.center = center - self.limits = limits - self.dim = rotation.shape[0] - self.eps_region = eps_region - - self.rotation_inv = np.linalg.inv(self.rotation) - - self.volume = self._compute_volume() - - def _compute_volume(self): - v = np.prod(- self.limits[:, 0] + self.limits[:, 1]) - - if v == 0: - logger.warning("zero volume area") - v = 0.05 - return v - - def contains(self, point): - """Check if point is inside the bounding box. - - Parameters - ---------- - point: (D, ) - - Returns - ------- - True/False - - """ - assert point.ndim == 1 - assert point.shape[0] == self.dim - - # transform to bb coordinate system - point1 = np.dot(self.rotation_inv, point) + np.dot(self.rotation_inv, -self.center) - - # Check if point is inside bounding box - inside = True - for i in range(point1.shape[0]): - if (point1[i] < self.limits[i][0]) or (point1[i] > self.limits[i][1]): - inside = False - break - return inside - - def sample(self, n2, seed=None): - """Sample n2 points from the posterior. - - Parameters - ---------- - n2: int - seed: seed of the sampling procedure - - Returns - ------- - np.ndarray, shape: (n2,D) - - """ - center = self.center - limits = self.limits - rot = self.rotation - - loc = limits[:, 0] - scale = limits[:, 1] - limits[:, 0] - - # draw n2 samples - theta = [] - for i in range(loc.shape[0]): - rv = ss.uniform(loc=loc[i], scale=scale[i]) - theta.append(rv.rvs(size=(n2, 1), random_state=seed)) - - theta = np.concatenate(theta, -1) - # translate and rotate - theta_new = np.dot(rot, theta.T).T + center - - return theta_new - - def pdf(self, theta: np.ndarray): - """Evalute the pdf. - - Parameters - ---------- - theta: np.ndarray (D,) - - Returns - ------- - float - - """ - return self.contains(theta) / self.volume - - def plot(self, samples): - """Plot the bounding box (works only if dim=1 or dim=2). - - Parameters - ---------- - samples: np.ndarray, shape: (N, D) - - Returns - ------- - None - - """ - R = self.rotation - T = self.center - lim = self.limits - - if self.dim == 1: - plt.figure() - plt.title("Bounding Box region") - - # plot eigenectors - end_point = T + R[0, 0] * lim[0][0] - plt.plot([T[0], end_point[0]], [T[1], end_point[1]], "r-o") - end_point = T + R[0, 0] * lim[0][1] - plt.plot([T[0], end_point[0]], [T[1], end_point[1]], "r-o") - - plt.plot(samples, np.zeros_like(samples), "bo") - plt.legend() - plt.show(block=False) - else: - plt.figure() - plt.title("Bounding Box region") - - # plot sampled points - plt.plot(samples[:, 0], samples[:, 1], "bo", label="samples") - - # plot eigenectors - x = T - x1 = T + R[:, 0] * lim[0][0] - plt.plot([T[0], x1[0]], [T[1], x1[1]], "y-o", label="-v1") - x3 = T + R[:, 0] * lim[0][1] - plt.plot([T[0], x3[0]], [T[1], x3[1]], "g-o", label="v1") - - x2 = T + R[:, 1] * lim[1][0] - plt.plot([T[0], x2[0]], [T[1], x2[1]], "k-o", label="-v2") - x4 = T + R[:, 1] * lim[1][1] - plt.plot([T[0], x4[0]], [T[1], x4[1]], "c-o", label="v2") - - # plot boundaries - def plot_side(x, x1, x2): - tmp = x + (x1 - x) + (x2 - x) - plt.plot([x1[0], tmp[0], x2[0]], [x1[1], tmp[1], x2[1]], "r-o") - - plot_side(x, x1, x2) - plot_side(x, x2, x3) - plot_side(x, x3, x4) - plot_side(x, x4, x1) - - plt.legend() - plt.show(block=False) - - def flat_array_to_dict(names, arr): """Map flat array to a dictionary with parameter names. diff --git a/requirements.txt b/requirements.txt index 129b32bf..7c914476 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ networkX>=2.0 ipyparallel>=6 toolz>=0.8 scikit-learn>=0.18.1 +numdifftools>=0.9 diff --git a/tests/functional/test_inference.py b/tests/functional/test_inference.py index dfa0263d..c89c4a1b 100644 --- a/tests/functional/test_inference.py +++ b/tests/functional/test_inference.py @@ -6,9 +6,13 @@ import pytest import elfi +import matplotlib.pyplot as plt + from elfi.examples import ma2 from elfi.methods.bo.utils import minimize, stochastic_optimization from elfi.model.elfi_model import NodeReference +from elfi.methods.inference.romc import RegionConstructor, RomcOptimisationResult, OptimisationProblem, NDimBoundingBox +from elfi.methods.posteriors import RomcPosterior """ This file tests inference methods point estimates with an informative data from the @@ -215,11 +219,423 @@ def test_BOLFI(): assert np.isclose(true_logpdf_prior, post.prior.logpdf(x[0, :])) -@pytest.mark.slowtest -def test_romc1(): - """Test ROMC at the simple 1D example introduced in http://proceedings.mlr.press/v108/ikonomov20a.html +@pytest.mark.romc +def test_ndim_bounding_box1(): + """Test 2-dimensional bounding box around (5,-5) rotated by 45 degrees """ + theta = np.radians(45) + c, s = np.cos(theta), np.sin(theta) + rotation = np.array(((c, -s), (s, c))) + center = np.array([5, -5.]) + limits = np.array([[-1, 1], [-2, 2]]) + bb = NDimBoundingBox(rotation, center, limits) + assert np.allclose(bb.volume, 8.) + assert np.allclose(bb.pdf(np.array([5, -5.])), 1 / 8) + assert np.allclose(bb.pdf(np.array([3, -3.])), 0.) + + +@pytest.mark.romc +def test_ndim_bounding_box2(): + """Test 4-dimensional bounding box rotated so that the 1-st dimension becomes the 4-th, + the 2-nd becomes the 3-rh etc. + """ + rotation = np.array([[0.,0,0,1],[0,0,1,0],[0,1,0,0],[1,0,0,0]]) + center = np.array([0.,0,0,0]) + limits = np.array([[-1., 1.], + [-2., 2.], + [-3., 3.], + [-4., 4.]]) + bb = NDimBoundingBox(rotation, center, limits) + assert np.equal(bb.volume, 2*4*6*8) + assert np.allclose(bb.pdf(np.array([4, 3, 2, 1])), 1/bb.volume) + assert np.allclose(bb.pdf(np.array([4.1, 3, 2, 1])), 0) + + +@pytest.mark.romc +def test_ndim_bounding_box3(): + """Test 2-dimensional bounding box with very zero limits + """ + rotation = np.eye(2) + center = np.array([0, 0]) + limits = np.array([[0, 0], [0, 0]]) + bb = NDimBoundingBox(rotation, center, limits) + assert bb.volume > 0 + + +@pytest.mark.romc +def test_region_constructor1(): + """Test for squeezed Gaussian.""" + # Create Gaussian with rotation + mean = np.array([0., 0.]) + hess = np.array([[1.0, .7], [.7, 1.]]) + + def f(x): + rv = ss.multivariate_normal(mean, hess) + return - rv.pdf(x) + + opt_res = RomcOptimisationResult(x_min=mean, f_min=f(mean), hess_appr=hess) + lim = 20 + step = .1 + K = 10 + eta = 1 + eps_region = -.025 + constr = RegionConstructor(opt_res, f, dim=2, + eps_region=eps_region, + K=K, eta=eta) + + prep_region = constr.build() + limits_pred = prep_region[0].limits + limits_gt = np.array([[-2.7265, 2.7265], [-1.1445, 1.1445]]) + + plt.figure() + x, y = np.mgrid[-4:4:.01, -4:4:.01] + pos = np.dstack((x, y)) + plt.contourf(x, y, f(pos)) + plt.colorbar() + x = prep_region[0].sample(300) + plt.plot(x[:,0], x[:,1], 'ro') + plt.show(block=False) + + assert np.allclose(limits_pred, limits_gt, atol=1e-2) + + +@pytest.mark.romc +def test_region_constructor2(): + """Test for square proposal region""" + + # boundaries + x1_neg = -.1 + x1_pos = 1 + x2_neg = -.2 + x2_pos = 2 + + center = np.array([0, 0]) + + hess = np.eye(2) + def f(x): + if (x1_neg <= x[0] <= x1_pos) and (x2_neg <= x[1] <= x2_pos): + y = -1 + else: + y = 1 + return y + + # compute proposal region + opt_res = RomcOptimisationResult(x_min=center, + f_min=f(center), + hess_appr=hess) + lim = 20 + step = .1 + K = 10 + eta = 1 + eps_region = 0. + constr = RegionConstructor(opt_res, f, dim=2, + eps_region=eps_region, + K=K, eta=eta) + proposal_region = constr.build()[0] + # compare limits + limits_pred = proposal_region.limits + limits_gt = np.array([[x1_neg, x1_pos], [x2_neg, x2_pos]]) + assert np.allclose(limits_pred, limits_gt, atol=eta/2**(K-1)) + + # compare volume + assert np.allclose((x1_pos - x1_neg)*(x2_pos - x2_neg), + proposal_region.volume, atol=.1) + + +@pytest.mark.romc +def test_region_constructor3(): + """Test for very tight edge case""" + + # boundaries + x1_neg = -.0001 + x1_pos = .0001 + x2_neg = -.0001 + x2_pos = .0001 + + center = np.array([0, 0]) + + hess = np.eye(2) + def f(x): + if (x1_neg <= x[0] <= x1_pos) and (x2_neg <= x[1] <= x2_pos): + y = -1 + else: + y = 1 + return y + + # compute proposal region + opt_res = RomcOptimisationResult(x_min=center, + f_min=f(center), + hess_appr=hess) + lim = 20 + step = .1 + K = 5 + eta = 1 + eps_region = 0. + constr = RegionConstructor(opt_res, f, dim=2, + eps_region=eps_region, + K=K, eta=eta) + proposal_region = constr.build()[0] + + # compare limits + limits_pred = proposal_region.limits + limits_gt = np.array([[x1_neg, x1_pos], [x2_neg, x2_pos]]) + assert np.allclose(limits_pred, limits_gt, atol=eta/2**(K-1)) + + # compare volume + assert np.allclose((x1_pos - x1_neg)*(x2_pos - x2_neg), + proposal_region.volume, atol=.1) + + +@pytest.mark.romc +def test_region_constructor4(): + """Test when boundary is limitless""" + + # boundaries + rep_lim = 300 + eta = 1 + x1_neg = -rep_lim*eta + x1_pos = rep_lim*eta + x2_neg = -rep_lim*eta + x2_pos = rep_lim*eta + + center = np.array([0, 0]) + + hess = np.eye(2) + def f(x): + return 0 + + # compute proposal region + opt_res = RomcOptimisationResult(x_min=center, + f_min=f(center), + hess_appr=hess) + lim = 20 + K = 5 + eps_region = 0.1 + constr = RegionConstructor(opt_res, f, dim=2, eps_region=eps_region, + K=K, eta=eta, rep_lim=rep_lim) + proposal_region = constr.build()[0] + + # compare limits + limits_pred = proposal_region.limits + limits_gt = np.array([[x1_neg, x1_pos], [x2_neg, x2_pos]]) + assert np.allclose(limits_pred, limits_gt, atol=.1) + + # compare volume + assert np.allclose((x1_pos - x1_neg)*(x2_pos - x2_neg), + proposal_region.volume, atol=.1) + + +@pytest.mark.romc +def test_optimisation_problem1(): + ind = 1 + nuisance = 1 + parameter_names = ["x1", "x2"] + target_name = "y" + dim = 2 + n1 = 20 + bounds = [(-10, 10), (-10, 10)] + + def f(x): + y = np.array([x[0], x[1]**2]) + return y + + def objective(x): + y1 = f(x) + return np.sqrt((y1[0] - 1)**2 + (y1[1] - 4)**2) + + # Create Gaussian with rotation + mean = np.array([0., 0.]) + hess = np.array([[1.0, .7], [.7, 1.]]) + prior = ss.multivariate_normal(mean, hess) + + opt_prob = OptimisationProblem(ind, nuisance, parameter_names, target_name, + objective, dim, prior, n1, bounds) + + x0 = np.array([-10, -10]) + solved = opt_prob.solve_gradients(x0=x0) + + assert solved + assert (np.allclose(opt_prob.result.x_min, np.array([1, 2]), atol=.1) or np.allclose(opt_prob.result.x_min, np.array([1, -2]), atol=.1)) + + opt_prob.build_region(eps_region=0.2) + opt_prob.visualize_region() + + +@pytest.mark.romc +def test_optimisation_problem2(): + ind = 1 + nuisance = 1 + parameter_names = ["x1", "x2"] + target_name = "y" + dim = 2 + n1 = 20 + bounds = [(-10, 10), (-10, 10)] + + def f(x): + y = np.array([x[0], x[1]]) + return y + + def objective(x): + y1 = f(x) + return np.sqrt((y1[0] - 1)**2 + (y1[1] - 4)**2) + + # Create Gaussian with rotation + mean = np.array([0., 0.]) + hess = np.array([[1.0, .7], [.7, 1.]]) + prior = ss.multivariate_normal(mean, hess) + + opt_prob = OptimisationProblem(ind, nuisance, parameter_names, target_name, + objective, dim, prior, n1, bounds) + + x0 = np.array([-10, -10]) + solved = opt_prob.solve_gradients(x0=x0) + + assert solved + assert np.allclose(opt_prob.result.x_min, np.array([1, 4]), atol=.1) + + opt_prob.build_region(eps_region=0.2) + opt_prob.visualize_region() + + +@pytest.mark.romc +def test_optimisation_problem3(): + ind = 1 + nuisance = 1 + parameter_names = ["x1", "x2"] + target_name = "y" + dim = 2 + n1 = 20 + bounds = [(-10, 10), (-10, 10)] + + def f(x): + y = np.array([x[0], x[1]]) + return y + + # Create Gaussian with rotation + mean = np.array([0., 0.]) + hess = np.array([[1.0, .7], [.7, 1.]]) + prior = ss.multivariate_normal(mean, hess) + + def objective(x): + return - prior.pdf(x) + + opt_prob = OptimisationProblem(ind, nuisance, parameter_names, target_name, + objective, dim, prior, n1, bounds) + + x0 = np.array([-10, -10]) + solved = opt_prob.solve_gradients(x0=x0) + assert solved + assert np.allclose(opt_prob.result.x_min, np.array([0., 0.]), atol=.1) + opt_prob.build_region(eps_region=-0.1) + opt_prob.visualize_region() + + solved = opt_prob.solve_bo(x0=x0) + assert solved + assert np.allclose(opt_prob.result.x_min, np.array([0., 0.]), atol=1.) + opt_prob.build_region(eps_region=-0.1, use_surrogate=False) + opt_prob.visualize_region(force_objective=False) + opt_prob.visualize_region(force_objective=True) + + +@pytest.mark.romc +def test_optimisation_problem4(): + ind = 1 + nuisance = 1 + parameter_names = ["x1", "x2"] + target_name = "y" + dim = 2 + n1 = 20 + bounds = [(-10, 10), (-10, 10)] + + def f(x): + y = np.array([x[0], x[1]]) + return y + + # Create Gaussian with rotation + mean = np.array([0., 0.]) + hess = np.array([[1.0, .7], [.7, 1.]]) + prior = ss.multivariate_normal(mean, hess) + + def objective(x): + return - prior.pdf(x) + + opt_prob = OptimisationProblem(ind, nuisance, parameter_names, target_name, + objective, dim, prior, n1, bounds) + + solved = opt_prob.solve_bo() + + assert solved + assert np.allclose(opt_prob.result.x_min, np.array([0., 0.]), atol=1) + + opt_prob.build_region(eps_region=-0.1) + + opt_prob.visualize_region(force_objective=False) + opt_prob.visualize_region(force_objective=True) + + +@pytest.mark.romc +def test_romc_posterior1(): + """Test for ROMC posterior.""" + # f(x) is -1 inside the box 2x4, and 1 outside + x1_neg = -1 + x1_pos = 1. + x2_neg = -2 + x2_pos = 2. + center = np.array([0, 0]) + hess = np.eye(2) + + # define the deterministic simulator d + def f(x): + if (x1_neg <= x[0] <= x1_pos) and (x2_neg <= x[1] <= x2_pos): + y = -1 + else: + y = 1 + return y + + # define the prior class + class Prior: + def __init__(self, ): + self.dim = 2 + return + + @staticmethod + def pdf(x): + if (-1 <= x[0, 0] <= 1) and (-1 <= x[0, 1] <= 1): + return np.array([1.]) + else: + return np.array([0.]) + + # obtain optimisation result + opt_res = RomcOptimisationResult(x_min=center, + f_min=f(center), + hess_appr=hess) + + # construct bounding box region + K = 10 + eta = 1 + eps_region = 0. + constr = RegionConstructor(opt_res, f, dim=2, + eps_region=eps_region, + K=K, eta=eta) + proposal_region = constr.build()[0] + + post = RomcPosterior(proposal_region, [f], [f], [f], [f], [0], + False, + Prior(), + np.array([-1., -1.]), + np.array([1., 1.]), + eps_filter=eps_region, + eps_region=eps_region, + eps_cutoff=eps_region) + + assert np.array_equal(np.array([1.]), post.pdf_unnorm_batched(np.array([[.1, .2]]))) + assert np.array_equal(np.array([0.25]), post.pdf(np.array([[.1, .2]]))) + +@pytest.mark.slowtest +@pytest.mark.romc +def test_romc1(): + """Test ROMC at the simple 1D example.""" # the prior distribution class Prior: def rvs(self, size=None, random_state=None): @@ -236,8 +652,7 @@ def logpdf(self, theta): # function for sampling from the likelihood def likelihood_sample(theta, seed=None): - """Vectorized sampling from likelihood. - """ + """Vectorized sampling from likelihood.""" assert isinstance(theta, np.ndarray) theta = theta.astype(np.float) samples = np.empty_like(theta) @@ -270,7 +685,8 @@ def simulator(theta, dim, batch_size=10000, random_state=None): # Define ELFI model elfi.new_model("1D_example") elfi_prior = elfi.Prior(Prior(), name="theta") - elfi_simulator = elfi.Simulator(simulator, elfi_prior, dim, observed=np.expand_dims(data, 0), name="simulator") + elfi_simulator = elfi.Simulator(simulator, elfi_prior, dim, observed=np.expand_dims(data, 0), + name="simulator") dist = elfi.Distance('euclidean', elfi_simulator, name="dist") # Define ROMC inference method @@ -278,7 +694,7 @@ def simulator(theta, dim, batch_size=10000, random_state=None): romc = elfi.ROMC(dist, bounds) # Gradients-Based solution - n1 = 100 # 500 + n1 = 100 seed = 21 optimizer_args = {} use_bo = False @@ -288,11 +704,12 @@ def simulator(theta, dim, batch_size=10000, random_state=None): eps_filter = .75 fit_models = True fit_models_args = {"nof_points": 30} - romc.estimate_regions(eps_filter=eps_filter, fit_models=fit_models, fit_models_args=fit_models_args) + romc.estimate_regions(eps_filter=eps_filter, fit_models=fit_models, + fit_models_args=fit_models_args) # Sample from the approximate posterior - n2 = 30 # 200 - tmp = romc.sample(n2=n2) + n2 = 30 + romc.sample(n2=n2) # assert summary statistics of samples match the ground truth assert np.allclose(romc.compute_expectation(h=lambda x: np.squeeze(x)), 0, atol=.4) @@ -300,10 +717,9 @@ def simulator(theta, dim, batch_size=10000, random_state=None): @pytest.mark.slowtest +@pytest.mark.romc def test_romc2(): - """Test ROMC at the simple 1D example introduced in http://proceedings.mlr.press/v108/ikonomov20a.html - """ - + """Test ROMC at the simple 1D example.""" # the prior distribution class Prior: def rvs(self, size=None, random_state=None): @@ -320,8 +736,7 @@ def logpdf(self, theta): # function for sampling from the likelihood def likelihood_sample(theta, seed=None): - """Vectorized sampling from likelihood. - """ + """Vectorized sampling from likelihood.""" assert isinstance(theta, np.ndarray) theta = theta.astype(np.float) samples = np.empty_like(theta) @@ -354,7 +769,8 @@ def simulator(theta, dim, batch_size=10000, random_state=None): # Define ELFI model elfi.new_model("1D_example") elfi_prior = elfi.Prior(Prior(), name="theta") - elfi_simulator = elfi.Simulator(simulator, elfi_prior, dim, observed=np.expand_dims(data, 0), name="simulator") + elfi_simulator = elfi.Simulator(simulator, elfi_prior, dim, observed=np.expand_dims(data, 0), + name="simulator") dist = elfi.Distance('euclidean', elfi_simulator, name="dist") # Define ROMC inference method @@ -362,7 +778,7 @@ def simulator(theta, dim, batch_size=10000, random_state=None): romc = elfi.ROMC(dist, bounds) # Bayesian Optimisation solution part - n1 = 50 # 100 + n1 = 50 seed = 21 optimizer_args = {} use_bo = True @@ -370,11 +786,13 @@ def simulator(theta, dim, batch_size=10000, random_state=None): eps_filter = .75 fit_models = True + use_surrogate = True fit_models_args = {"nof_points": 30} - romc.estimate_regions(eps_filter=eps_filter, fit_models=fit_models, fit_models_args=fit_models_args) + romc.estimate_regions(eps_filter=eps_filter, use_surrogate=use_surrogate, + fit_models=fit_models, fit_models_args=fit_models_args) - n2 = 100 # 300 - tmp = romc.sample(n2=n2) + n2 = 100 + romc.sample(n2=n2) # assert summary statistics of samples match the ground truth assert np.allclose(romc.compute_expectation(h=lambda x: np.squeeze(x)), 0, atol=.4) @@ -382,9 +800,9 @@ def simulator(theta, dim, batch_size=10000, random_state=None): @pytest.mark.slowtest +@pytest.mark.romc def test_romc3(): - """Test that ROMC provides sensible samples at the MA2 example. - """ + """Test that ROMC provides sensible samples at the MA2 example.""" # load built-in model seed = 1 np.random.seed(seed) @@ -395,7 +813,7 @@ def test_romc3(): romc = elfi.ROMC(model, bounds=bounds, discrepancy_name="d") # solve problems - n1 = 300 + n1 = 100 seed = 21 romc.solve_problems(n1=n1, seed=seed) @@ -405,7 +823,7 @@ def test_romc3(): # sample from posterior n2 = 50 - tmp = romc.sample(n2=n2) + romc.sample(n2=n2) romc_mean = romc.result.sample_means_array romc_cov = romc.result.samples_cov() From c92e71d5989469c3d7f36e331e7003992d9e3791 Mon Sep 17 00:00:00 2001 From: Jan Kokko Date: Wed, 16 Feb 2022 15:32:37 +0200 Subject: [PATCH 2/4] Feature/bolfire (#348) * Fix flake8 F523 * Abstract base class for ratio estimation classifiers * Posterior LCBSC for BOLFIRE * Logistic regression classifier * Create arch.py * Skeleton for BOLFIRE method * Simplify generate marginal function * Add BOLFIREPosterior implementation * Replace dot products with inner products * Fix docstring * Allow MCMC sampling * Allow to model posterior directly with LCBSC * Fix format * Update init documentation * Add class_min and fix typos in docs * Remove white noise nodes * Refactored bolfire as its own file * Add bolfire to init. * Fix path to ModelPrior in bolfire * Fix gelman_rubin-renaming in bolfire-sampling. * Add a distance node to the arch model * Fix update the inference state * Add a simple test for ARCH * Minimal tests for BOLFIRE Co-authored-by: Jan Kokko Co-authored-by: hpesonen --- elfi/__init__.py | 1 + elfi/classifiers/__init__.py | 1 + elfi/classifiers/classifier.py | 119 ++++++++ elfi/examples/arch.py | 193 +++++++++++++ elfi/methods/bo/acquisition.py | 44 ++- elfi/methods/inference/bolfire.py | 449 ++++++++++++++++++++++++++++++ elfi/methods/mcmc.py | 20 +- elfi/methods/posteriors.py | 144 ++++++++++ elfi/methods/results.py | 34 +++ setup.cfg | 1 + tests/functional/test_bolfire.py | 102 +++++++ tests/unit/test_bolfire_unit.py | 63 +++++ tests/unit/test_examples.py | 11 +- 13 files changed, 1158 insertions(+), 24 deletions(-) create mode 100644 elfi/classifiers/__init__.py create mode 100644 elfi/classifiers/classifier.py create mode 100644 elfi/examples/arch.py create mode 100644 elfi/methods/inference/bolfire.py create mode 100644 tests/functional/test_bolfire.py create mode 100644 tests/unit/test_bolfire_unit.py diff --git a/elfi/__init__.py b/elfi/__init__.py index e3cb0039..8e6bb4de 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -14,6 +14,7 @@ from elfi.methods.diagnostics import TwoStageSelection from elfi.methods.model_selection import * from elfi.methods.inference.bolfi import * +from elfi.methods.inference.bolfire import * from elfi.methods.inference.romc import * from elfi.methods.inference.samplers import * from elfi.methods.post_processing import adjust_posterior diff --git a/elfi/classifiers/__init__.py b/elfi/classifiers/__init__.py new file mode 100644 index 00000000..6e031999 --- /dev/null +++ b/elfi/classifiers/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/elfi/classifiers/classifier.py b/elfi/classifiers/classifier.py new file mode 100644 index 00000000..d1093494 --- /dev/null +++ b/elfi/classifiers/classifier.py @@ -0,0 +1,119 @@ +"""Implementations for ratio estimation classifiers.""" + +import abc + +import numpy as np +from sklearn.linear_model import LogisticRegression as LogReg +from sklearn.preprocessing import StandardScaler + + +class Classifier(abc.ABC): + """An abstract base class for a ratio estimation classifier.""" + + @abc.abstractmethod + def __init__(self): + """Initialize a classifier.""" + raise NotImplementedError + + @abc.abstractmethod + def fit(self, X, y): + """Fit a classifier. + + Parameters + ---------- + X: np.ndarray (n_samples, n_features) + Feature vectors of data. + y: np.ndarray (n_samples, ) + Target values, must be binary. + + """ + raise NotImplementedError + + @abc.abstractmethod + def predict_log_likelihood_ratio(self, X): + """Predict a log-likelihood ratio. + + Parameters + ---------- + X: np.ndarray (n_samples, n_features) + Feature vectors of data. + + Returns + ------- + np.ndarray + + """ + raise NotImplementedError + + def predict_likelihood_ratio(self, X): + """Predict a likelihood ratio. + + Parameters + ---------- + X: np.ndarray (n_samples, n_features) + Feature vectors of data. + + Returns + ------- + np.ndarray + + """ + return np.exp(self.predict_log_likelihood_ratio(X)) + + @property + @abc.abstractmethod + def attributes(self): + """Return attributes dictionary.""" + raise NotImplementedError + + +class LogisticRegression(Classifier): + """A logistic regression classifier for ratio estimation.""" + + def __init__(self, config=None, class_min=0): + """Initialize a logistic regression classifier.""" + self.config = self._resolve_config(config) + self.class_min = self._resolve_class_min(class_min) + self.model = LogReg(**self.config) + self.scaler = StandardScaler() + + def fit(self, X, y): + """Fit a logistic regression classifier.""" + Xs = self.scaler.fit_transform(X) + self.model.fit(Xs, y) + + def predict_log_likelihood_ratio(self, X): + """Predict a log-likelihood ratio.""" + Xs = self.scaler.transform(X) + class_probs = np.maximum(self.model.predict_proba(Xs)[:, 1], self.class_min) + return np.log(class_probs / (1 - class_probs)) + + @property + def attributes(self): + """Return an attributes dictionary.""" + return { + 'parameters': { + 'coef_': self.model.coef_.tolist(), + 'intercept_': self.model.intercept_.tolist(), + 'n_iter': self.model.n_iter_.tolist() + } + } + + def _default_config(self): + """Return a default config.""" + return { + 'penalty': 'l1', + 'solver': 'liblinear' + } + + def _resolve_config(self, config): + """Resolve a config for logistic regression classifier.""" + if not isinstance(config, dict): + config = self._default_config() + return config + + def _resolve_class_min(self, class_min): + """Resolve a class min parameter that prevents negative inf values.""" + if isinstance(class_min, int) or isinstance(class_min, float): + return class_min + raise TypeError('class_min has to be either non-negative int or float') diff --git a/elfi/examples/arch.py b/elfi/examples/arch.py new file mode 100644 index 00000000..ce86cbc0 --- /dev/null +++ b/elfi/examples/arch.py @@ -0,0 +1,193 @@ +"""Example implementation of the ARCH(1) model.""" + +import logging +from itertools import combinations + +import numpy as np + +import elfi + +logger = logging.getLogger(__name__) + + +def get_model(n_obs=100, true_params=None, seed_obs=None, n_lags=5): + """Return a complete ARCH(1) model. + + Parameters + ---------- + n_obs: int + Observation length of the ARCH(1) process. + true_params: list, optinal + Parameters with which the observed data are generated. + seed_obs: int, optional + Seed for the observed data generation. + n_lags: int, optional + Number of lags in summary statistics. + + Returns + ------- + elfi.ElfiModel + + """ + if true_params is None: + true_params = [0.3, 0.7] + logger.info(f'true_params were not given. Now using [t1, t2] = {true_params}.') + + # elfi model + m = elfi.ElfiModel() + + # priors + t1 = elfi.Prior('uniform', -1, 2, model=m) + t2 = elfi.Prior('uniform', 0, 1, model=m) + priors = [t1, t2] + + # observations + y_obs = arch(*true_params, n_obs=n_obs, random_state=np.random.RandomState(seed_obs)) + + # simulator + Y = elfi.Simulator(arch, *priors, observed=y_obs) + + # summary statistics + ss = [] + ss.append(elfi.Summary(sample_mean, Y, name='MU', model=m)) + ss.append(elfi.Summary(sample_variance, Y, name='VAR', model=m)) + for i in range(1, n_lags + 1): + ss.append(elfi.Summary(autocorr, Y, i, name=f'AC_{i}', model=m)) + for i, j in combinations(range(1, n_lags + 1), 2): + ss.append(elfi.Summary(pairwise_autocorr, Y, i, j, name=f'PW_{i}_{j}', model=m)) + + # distance + elfi.Distance('euclidean', *ss, name='d', model=m) + + return m + + +def arch(t1, t2, n_obs=100, batch_size=1, random_state=None): + """Generate a sequence of samples from the ARCH(1) model. + + Parameters + ---------- + t1: float + Mean process parameter in the ARCH(1) process. + t2: float + Variance process parameter in the ARCH(1) process. + n_obs: int, optional + Observation length of the ARCH(1) process. + batch_size: int, optional + Number of simulations. + random_state: np.random.RandomState, optional + + Returns + ------- + np.ndarray + + """ + random_state = random_state or np.random + y = np.zeros((batch_size, n_obs + 1)) + e = E(t2, n_obs, batch_size, random_state) + for i in range(1, n_obs + 1): + y[:, i] = t1 * y[:, i - 1] + e[:, i] + return y[:, 1:] + + +def E(t2, n_obs=100, batch_size=1, random_state=None): + """Variance process function in the ARCH(1) model. + + Parameters + ---------- + t2: float + Variance process parameter in the ARCH(1) process. + n_obs: int, optional + Observation length of the ARCH(1) process. + batch_size: int, optional + Number of simulations. + random_state: np.random.RandomState + + Returns + ------- + np.ndarray + + """ + random_state = random_state or np.random + xi = random_state.normal(size=(batch_size, n_obs + 1)) + e = np.zeros((batch_size, n_obs + 1)) + e[:, 0] = random_state.normal(size=batch_size) + for i in range(1, n_obs + 1): + e[:, i] = xi[:, i] * np.sqrt(0.2 + t2 * np.power(e[:, i - 1], 2)) + return e + + +def sample_mean(x): + """Calculate the sample mean. + + Parameters + ---------- + x: np.ndarray + Simulated/observed data. + + Returns + ------- + np.ndarray + + """ + return np.mean(x, axis=1) + + +def sample_variance(x): + """Calculate the sample variance. + + Parameters + ---------- + x: np.ndarray + Simulated/observed data. + + Returns + ------- + np.ndarray + + """ + return np.var(x, axis=1, ddof=1) + + +def autocorr(x, lag=1): + """Calculate the autocorrelation. + + Parameters + ---------- + x: np.ndarray + Simulated/observed data. + lag: int, optional + Lag in autocorrelation. + + Returns + ------- + np.ndarray + + """ + n = x.shape[1] + x_mu = np.mean(x, axis=1) + x_std = np.std(x, axis=1, ddof=1) + sc_x = ((x.T - x_mu) / x_std).T + C = np.sum(sc_x[:, lag:] * sc_x[:, :-lag], axis=1) / (n - lag) + return C + + +def pairwise_autocorr(x, lag_i=1, lag_j=1): + """Calculate the pairwise autocorrelation. + + Parameters + x: np.ndarray + Simulated/observed data. + lag_i: int, optional + Lag in autocorrelation. + lag_j: int, optinal + Lag in autocorrelation. + + Returns + ------- + np.ndarray + + """ + ac_i = autocorr(x, lag_i) + ac_j = autocorr(x, lag_j) + return ac_i * ac_j diff --git a/elfi/methods/bo/acquisition.py b/elfi/methods/bo/acquisition.py index 86daf6dc..a5c9d06c 100644 --- a/elfi/methods/bo/acquisition.py +++ b/elfi/methods/bo/acquisition.py @@ -192,16 +192,16 @@ class LCBSC(AcquisitionBase): """ - def __init__(self, *args, delta=None, **kwargs): + def __init__(self, *args, delta=None, include_prior=False, **kwargs): """Initialize LCBSC. Parameters ---------- - delta : float, optional + delta: float, optional In between (0, 1). Default is 1/exploration_rate. If given, overrides the exploration_rate. - args - kwargs + include_prior: bool, optional + If true, add negative log prior to model evaluations. """ if delta is not None: @@ -212,6 +212,7 @@ def __init__(self, *args, delta=None, **kwargs): super(LCBSC, self).__init__(*args, **kwargs) self.name = 'lcbsc' self.label_fn = 'Confidence Bound' + self.include_prior = include_prior @property def delta(self): @@ -225,34 +226,49 @@ def _beta(self, t): return 2 * np.log(t**(2 * d + 2) * np.pi**2 / (3 * self.delta)) def evaluate(self, x, t=None): - r"""Evaluate the Lower confidence bound selection criterion. - - mean - sqrt(\beta_t) * std + """Evaluate the Lower confidence bound selection criterion. Parameters ---------- - x : numpy.array - t : int + x: np.ndarray + t: int, optional Current iteration (starting from 0). + Returns + ------- + np.ndarray + """ mean, var = self.model.predict(x, noiseless=True) - return mean - np.sqrt(self._beta(t) * var) + value = mean - np.sqrt(self._beta(t) * var) + if self.include_prior: + # we use negative prior, since we minimize + negative_log_prior = -1 * self.prior.logpdf(x).reshape(-1, 1) + value += negative_log_prior + return value def evaluate_gradient(self, x, t=None): """Evaluate the gradient of the lower confidence bound selection criterion. Parameters ---------- - x : numpy.array - t : int + x: np.ndarray + t: int, optional Current iteration (starting from 0). + Returns + ------- + np.ndarray + """ mean, var = self.model.predict(x, noiseless=True) grad_mean, grad_var = self.model.predictive_gradients(x) - - return grad_mean - 0.5 * grad_var * np.sqrt(self._beta(t) / var) + value = grad_mean - 0.5 * grad_var * np.sqrt(self._beta(t) / var) + if self.include_prior: + # we use negative prior, since we minimize + grad_negative_log_prior = -1 * self.prior.gradient_logpdf(x).reshape(1, -1) + value += grad_negative_log_prior + return value class MaxVar(AcquisitionBase): diff --git a/elfi/methods/inference/bolfire.py b/elfi/methods/inference/bolfire.py new file mode 100644 index 00000000..9f41d286 --- /dev/null +++ b/elfi/methods/inference/bolfire.py @@ -0,0 +1,449 @@ +"""This module contains implementation of bolfire.""" + +__all__ = ['BOLFIRE'] + +import logging + +import numpy as np + +import elfi.methods.mcmc as mcmc +from elfi.classifiers.classifier import Classifier, LogisticRegression +from elfi.loader import get_sub_seed +from elfi.methods.bo.acquisition import LCBSC, AcquisitionBase +from elfi.methods.bo.gpy_regression import GPyRegression +from elfi.methods.inference.parameter_inference import ParameterInference +from elfi.methods.posteriors import BOLFIREPosterior +from elfi.methods.results import BOLFIRESample +from elfi.methods.utils import arr2d_to_batch, batch_to_arr2d +from elfi.model.elfi_model import ElfiModel, Summary +from elfi.model.extensions import ModelPrior + +logger = logging.getLogger(__name__) + + +class BOLFIRE(ParameterInference): + """Bayesian Optimization and Classification in Likelihood-Free Inference (BOLFIRE).""" + + def __init__(self, + model, + n_training_data=10, + marginal=None, + seed_marginal=None, + classifier=None, + bounds=None, + n_initial_evidence=0, + acq_noise_var=0, + exploration_rate=10, + update_interval=1, + target_model=None, + acquisition_method=None, + *args, **kwargs): + """Initialize the BOLFIRE method. + + Parameters + ---------- + model: ElfiModel + Elfi graph used by the algorithm. + n_training_data: int, optional + Size of training data. + marginal: np.ndnarray, optional + Marginal data. + seed_marginal: int, optional + Seed for marginal data generation. + classifier: str, optional + Classifier to be used. Default LogisticRegression. + bounds: dict, optional + The region where to estimate the posterior for each parameter in + model.parameters: dict('parameter_name': (lower, upper), ... ). Not used if + custom target_model is given. + n_initial_evidence: int, optional + Number of initial evidence. + acq_noise_var: float or np.ndarray, optional + Variance(s) of the noise added in the default LCBSC acquisition method. + If an array, should be 1d specifying the variance for each dimension. + exploration_rate: float, optional + Exploration rate of the acquisition method. + update_interval : int, optional + How often to update the GP hyperparameters of the target_model. + target_model: GPyRegression, optional + A surrogate model to be used. + acquisition_method: Acquisition, optional + Method of acquiring evidence points. Default LCBSC. + + """ + # Resolve model and initialize + model = self._resolve_model(model) + super(BOLFIRE, self).__init__(model, output_names=None, *args, **kwargs) + + # Initialize attributes + self.n_training_data = self._resolve_n_training_data(n_training_data) + self.summary_names = self._get_summary_names(self.model) + self.marginal = self._resolve_marginal(marginal, seed_marginal) + self.classifier = self._resolve_classifier(classifier) + self.observed = self._get_observed_summary_values(self.model, self.summary_names) + self.prior = ModelPrior(self.model) + + # TODO: write resolvers for the attributes below + self.bounds = bounds + self.acq_noise_var = acq_noise_var + self.exploration_rate = exploration_rate + self.update_interval = update_interval + + # Initialize GP regression + self.target_model = self._resolve_target_model(target_model) + + # Initialize BO + self.n_initial_evidence = self._resolve_n_initial_evidence(n_initial_evidence) + self.acquisition_method = self._resolve_acquisition_method(acquisition_method) + + # Initialize state dictionary + self.state['n_evidence'] = 0 + self.state['last_GP_update'] = self.n_initial_evidence + + # Initialize classifier attributes list + self.classifier_attributes = [] + + @property + def n_evidence(self): + """Return the number of acquired evidence points.""" + return self.state['n_evidence'] + + def set_objective(self, n_evidence): + """Set an objective for inference. You can continue BO by giving a larger n_evidence. + + Parameters + ---------- + n_evidence: int + Number of total evidence for the GP fitting. This includes any initial evidence. + + """ + if n_evidence < self.n_evidence: + logger.warning('Requesting less evidence than there already exists.') + self.objective['n_sim'] = n_evidence + + def extract_result(self): + """Extract the results from the current state.""" + return BOLFIREPosterior(self.parameter_names, + self.target_model, + self.prior, + self.classifier_attributes) + + def update(self, batch, batch_index): + """Update the GP regression model of the target node with a new batch. + + Parameters + ---------- + batch : dict + dict with `self.outputs` as keys and the corresponding outputs for the batch + as values + batch_index : int + Index of batch. + + """ + # Update the inference state + self.state['n_batches'] += 1 + self.state['n_sim'] += self.batch_size * self.n_training_data + + # Predict log-ratio + likelihood = self._generate_likelihood(self._get_parameter_values(batch)) + X, y = self._generate_training_data(likelihood, self.marginal) + negative_log_ratio_value = -1 * self.predict_log_ratio(X, y, self.observed) + + # Update classifier attributes list + self.classifier_attributes += [self.classifier.attributes] + + # BO part + self.state['n_evidence'] += self.batch_size + parameter_values = batch_to_arr2d(batch, self.parameter_names) + optimize = self._should_optimize() + self.target_model.update(parameter_values, negative_log_ratio_value, optimize) + if optimize: + self.state['last_GP_update'] = self.target_model.n_evidence + + def prepare_new_batch(self, batch_index): + """Prepare values for a new batch. + + Parameters + ---------- + batch_index: int + + Returns + ------- + batch: dict + + """ + t = batch_index - self.n_initial_evidence + if t < 0: # Sample parameter values from the model priors + return + + # Acquire parameter values from the acquisition function + acquisition = self.acquisition_method.acquire(self.batch_size, t) + return arr2d_to_batch(acquisition, self.parameter_names) + + def predict_log_ratio(self, X, y, X_obs): + """Predict the log-ratio, i.e, logarithm of likelihood / marginal. + + Parameters + ---------- + X: np.ndarray + Training data features. + y: np.ndarray + Training data labels. + X_obs: np.ndarray + Observed data. + + Returns + ------- + np.ndarray + + """ + self.classifier.fit(X, y) + return self.classifier.predict_log_likelihood_ratio(X_obs) + + def fit(self, n_evidence, bar=True): + """Fit the surrogate model. + + That is, generate a regression model for the negative posterior value given the parameters. + Currently only GP regression are supported as surrogate models. + + Parameters + ---------- + n_evidence: int + Number of evidence for fitting. + bar: bool, optional + Flag to show or hide the progress bar during fit. + + Returns + ------- + BOLFIREPosterior + + """ + logger.info('BOLFIRE: Fitting the surrogate model...') + if isinstance(n_evidence, int) and n_evidence > 0: + return self.infer(n_evidence, bar=bar) + raise TypeError('n_evidence must be a positive integer.') + + def sample(self, + n_samples, + warmup=None, + n_chains=4, + initials=None, + algorithm='nuts', + sigma_proposals=None, + n_evidence=None, + *args, **kwargs): + """Sample from the posterior distribution of BOLFIRE. + + Sampling is performed with an MCMC sampler. + + Parameters + ---------- + n_samples: int + Number of requested samples from the posterior for each chain. This includes warmup, + and note that the effective sample size is usually considerably smaller. + warmup: int, optional + Length of warmup sequence in MCMC sampling. + n_chains: int, optional + Number of independent chains. + initials: np.ndarray (n_chains, n_params), optional + Initial values for the sampled parameters for each chain. + algorithm: str, optional + Sampling algorithm to use. + sigma_proposals: np.ndarray + Standard deviations for Gaussian proposals of each parameter for Metropolis-Hastings. + n_evidence: int, optional + If the surrogate model is not fitted yet, specify the amount of evidence. + + Returns + ------- + BOLFIRESample + + """ + # Fit posterior in case not done + if self.state['n_batches'] == 0: + self.fit(n_evidence) + + # Check algorithm + if algorithm not in ['nuts', 'metropolis']: + raise ValueError('The given algorithm is not supported.') + + # Check standard deviations of Gaussian proposals when using Metropolis-Hastings + if algorithm == 'metropolis': + if sigma_proposals is None: + raise ValueError('Gaussian proposal standard deviations have ' + 'to be provided for Metropolis-sampling.') + elif sigma_proposals.shape[0] != self.target_model.input_dim: + raise ValueError('The length of Gaussian proposal standard ' + 'deviations must be n_params.') + + posterior = self.extract_result() + warmup = warmup or n_samples // 2 + + # Unless given, select the evidence points with best likelihood ratio + if initials is not None: + if np.asarray(initials).shape != (n_chains, self.target_model.input_dim): + raise ValueError('The shape of initials must be (n_chains, n_params).') + else: + inds = np.argsort(self.target_model.Y[:, 0]) + initials = np.asarray(self.target_model.X[inds]) + + # Enable caching for default RBF kernel + self.target_model.is_sampling = True + + tasks_ids = [] + ii_initial = 0 + for ii in range(n_chains): + seed = get_sub_seed(self.seed, ii) + # Discard bad initialization points + while np.isinf(posterior.logpdf(initials[ii_initial])): + ii_initial += 1 + if ii_initial == len(inds): + raise ValueError('BOLFIRE.sample: Cannot find enough acceptable ' + 'initialization points!') + + if algorithm == 'nuts': + tasks_ids.append( + self.client.apply(mcmc.nuts, + n_samples, + initials[ii_initial], + posterior.logpdf, + posterior.gradient_logpdf, + n_adapt=warmup, + seed=seed, + **kwargs)) + + elif algorithm == 'metropolis': + tasks_ids.append( + self.client.apply(mcmc.metropolis, + n_samples, + initials[ii_initial], + posterior.logpdf, + sigma_proposals, + warmup, + seed=seed, + **kwargs)) + + ii_initial += 1 + + # Get results from completed tasks or run sampling (client-specific) + chains = [] + for id in tasks_ids: + chains.append(self.client.get_result(id)) + + chains = np.asarray(chains) + + logger.info(f'{n_chains} chains of {n_samples} iterations acquired. ' + 'Effective sample size and Rhat for each parameter:') + for ii, node in enumerate(self.parameter_names): + logger.info(f'{node} {mcmc.eff_sample_size(chains[:, :, ii])} ' + f'{mcmc.gelman_rubin_statistic(chains[:, :, ii])}') + + self.target_model.is_sampling = False + + return BOLFIRESample(method_name='BOLFIRE', + chains=chains, + parameter_names=self.parameter_names, + warmup=warmup, + n_sim=self.state['n_sim'], + seed=self.seed, + *args, **kwargs) + + def _resolve_model(self, model): + """Resolve a given elfi model.""" + if not isinstance(model, ElfiModel): + raise ValueError('model must be an ElfiModel.') + if len(self._get_summary_names(model)) == 0: + raise NotImplementedError('model must have at least one Summary node.') + return model + + def _resolve_n_training_data(self, n_training_data): + """Resolve the size of training data to be used.""" + if isinstance(n_training_data, int) and n_training_data > 0: + return n_training_data + raise TypeError('n_training_data must be a positive int.') + + def _get_summary_names(self, model): + """Return the names of summary statistics.""" + return [node for node in model.nodes if isinstance(model[node], Summary) + and not node.startswith('_')] + + def _resolve_marginal(self, marginal, seed_marginal=None): + """Resolve marginal data.""" + if marginal is None: + marginal = self._generate_marginal(seed_marginal) + x, y = marginal.shape + logger.info(f'New marginal data ({x} x {y}) are generated.') + return marginal + if isinstance(marginal, np.ndarray) and len(marginal.shape) == 2: + return marginal + raise TypeError('marginal must be 2d numpy array.') + + def _generate_marginal(self, seed_marginal=None): + """Generate marginal data.""" + batch = self.model.generate(self.n_training_data, + outputs=self.summary_names, + seed=seed_marginal) + return np.column_stack([batch[summary_name] for summary_name in self.summary_names]) + + def _generate_likelihood(self, parameter_values): + """Generate likelihood data.""" + batch = self.model.generate(self.n_training_data, + outputs=self.summary_names, + with_values=parameter_values) + return np.column_stack([batch[summary_name] for summary_name in self.summary_names]) + + def _generate_training_data(self, likelihood, marginal): + """Generate training data.""" + X = np.vstack((likelihood, marginal)) + y = np.concatenate((np.ones(likelihood.shape[0]), -1 * np.ones(marginal.shape[0]))) + return X, y + + def _resolve_classifier(self, classifier): + """Resolve classifier.""" + if classifier is None: + return LogisticRegression() + if isinstance(classifier, Classifier): + return classifier + raise ValueError('classifier must be an instance of Classifier.') + + def _get_observed_summary_values(self, model, summary_names): + """Return observed values for summary statistics.""" + return np.column_stack([model[summary_name].observed for summary_name in summary_names]) + + def _get_parameter_values(self, batch): + """Return parameter values from a given batch.""" + return {parameter_name: float(batch[parameter_name]) for parameter_name + in self.model.parameter_names} + + def _resolve_n_initial_evidence(self, n_initial_evidence): + """Resolve number of initial evidence.""" + if isinstance(n_initial_evidence, int) and n_initial_evidence >= 0: + return n_initial_evidence + raise ValueError('n_initial_evidence must be a non-negative integer.') + + def _resolve_target_model(self, target_model): + """Resolve target model.""" + if target_model is None: + return GPyRegression(self.model.parameter_names, self.bounds) + if isinstance(target_model, GPyRegression): + return target_model + raise TypeError('target_model must be an instance of GPyRegression.') + + def _resolve_acquisition_method(self, acquisition_method): + """Resolve acquisition method.""" + if acquisition_method is None: + return LCBSC(model=self.target_model, + prior=self.prior, + noise_var=self.acq_noise_var, + exploration_rate=self.exploration_rate, + seed=self.seed, + include_prior=True) + if isinstance(acquisition_method, AcquisitionBase): + return acquisition_method + raise TypeError('acquisition_method must be an instance of AcquisitionBase.') + + def _should_optimize(self): + """Check whether GP hyperparameters should be optimized.""" + current = self.target_model.n_evidence + self.batch_size + next_update = self.state['last_GP_update'] + self.update_interval + return current >= self.n_initial_evidence and current >= next_update diff --git a/elfi/methods/mcmc.py b/elfi/methods/mcmc.py index 60faf10b..f6912c24 100644 --- a/elfi/methods/mcmc.py +++ b/elfi/methods/mcmc.py @@ -187,8 +187,8 @@ def nuts(n_iter, params1 = params0 + stepsize * momentum1 momentum1 += 0.5 * stepsize * grad_target(params1) - joint0 = target0 - 0.5 * momentum0.dot(momentum0) - joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) + joint0 = target0 - 0.5 * np.inner(momentum0, momentum0) + joint1 = target(params1) - 0.5 * np.inner(momentum1, momentum1) if np.isfinite(joint1): break @@ -215,7 +215,7 @@ def nuts(n_iter, params1 = params0 + stepsize * momentum1 momentum1 += 0.5 * stepsize * grad_target(params1) - joint1 = target(params1) - 0.5 * momentum1.dot(momentum1) + joint1 = target(params1) - 0.5 * np.inner(momentum1, momentum1) logger.debug("NUTS: Set initial stepsize {}.".format(stepsize)) @@ -239,7 +239,7 @@ def nuts(n_iter, for ii in range(1, n_iter + 1): momentum0 = random_state.randn(*params0.shape) samples_prev = samples[ii - 1, :] - log_joint0 = target(samples_prev) - 0.5 * momentum0.dot(momentum0) + log_joint0 = target(samples_prev) - 0.5 * np.inner(momentum0, momentum0) log_slicevar = log_joint0 - random_state.exponential() samples[ii, :] = samples_prev params_left = samples_prev @@ -271,8 +271,9 @@ def nuts(n_iter, n_diverged += is_div n_outside += is_out n_total += n_steps - all_ok = sub_ok and ((params_right - params_left).dot(momentum_left) >= 0) \ - and ((params_right - params_left).dot(momentum_right) >= 0) + all_ok = sub_ok and \ + (np.inner(params_right - params_left, momentum_left) >= 0) and \ + (np.inner(params_right - params_left, momentum_right) >= 0) depth += 1 if depth > max_depth: logger.debug("NUTS: Maximum recursion depth {} exceeded.".format(max_depth)) @@ -324,7 +325,7 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, ta params1 = params + step * momentum1 momentum1 = momentum1 + 0.5 * step * grad_target(params1) - log_joint = target(params1) - 0.5 * momentum1.dot(momentum1) + log_joint = target(params1) - 0.5 * np.inner(momentum1, momentum1) n_ok = float(log_slicevar <= log_joint) sub_ok = log_slicevar < (1000. + log_joint) # check for diverging error is_out = False @@ -366,8 +367,9 @@ def _build_tree_nuts(params, momentum, log_slicevar, step, depth, log_joint0, ta params1 = params2 # accept move mh_ratio += mh_ratio2 n_steps += n_steps2 - sub_ok = sub_ok and ((params_right - params_left).dot(momentum_left) >= 0) \ - and ((params_right - params_left).dot(momentum_right) >= 0) + sub_ok = sub_ok and \ + (np.inner(params_right - params_left, momentum_left) >= 0) and \ + (np.inner(params_right - params_left, momentum_right) >= 0) n_sub += n_sub2 return params_left, momentum_left, params_right, momentum_right, params1, n_sub, sub_ok, \ diff --git a/elfi/methods/posteriors.py b/elfi/methods/posteriors.py index acccdaf9..adb76dea 100644 --- a/elfi/methods/posteriors.py +++ b/elfi/methods/posteriors.py @@ -1,6 +1,7 @@ """The module contains implementations of approximate posteriors.""" import logging +from collections import OrderedDict from multiprocessing import Pool from typing import Callable, List @@ -258,6 +259,149 @@ def plot(self, logpdf=False): raise NotImplementedError("Currently unsupported for dim > 2") +class BOLFIREPosterior: + """Results from BOLFIRE inference method.""" + + def __init__(self, + parameter_names, + model, + prior, + classifier_attributes, + n_opt_inits=10, + max_opt_iters=1000, + *args, **kwargs): + """Initialize BOLFIREPosterior. + + Parameters + ---------- + parameter_names: list + Names of the parameter nodes. + model: elfi.bo.gpy_regression.GPyRegression + Instance of the surrogate model + prior: elfi.methods.utils.ModelPrior + Joint prior of the elfi model. + classifier_attributes: list + Classifier's attributes on each inference round. + n_opt_inits: int, optional + Number of initialization points in internal optimization. + max_opt_iters: int, optional + Maximum number of iterations performed in internal optimization. + + """ + self._parameter_names = parameter_names + self._model = model + self._prior = prior + self._n_opt_inits = n_opt_inits + self._max_opt_iters = max_opt_iters + self._classifier_attributes = classifier_attributes + + # compute map estimates + self._map_estimates = self._compute_map_estimates() + + @property + def map_estimates(self): + """Return the maximum a posterior estimate for each parameter. + + Returns + ------- + OrderedDict + + """ + return OrderedDict([(parameter_name, self._map_estimates[i]) for i, parameter_name + in enumerate(self._parameter_names)]) + + @property + def classifier_attributes(self): + """Return the classifier's attributes.""" + return self._classifier_attributes + + @property + def surrogate_model_attributes(self): + """Return the surrogate model's (GP regression) attributes.""" + return { + 'parameters': self._model._gp.param_array.tolist(), + 'X': self._model.X.tolist(), + 'Y': self._model.Y.tolist() + } + + def pdf(self, x): + """Return the unnormalized posterior at x. + + Parameters + ---------- + x: np.ndarray + + Returns + ------- + np.ndarray + + """ + return np.exp(self.logpdf(x)) + + def _negative_pdf(self, x): + """Return the negative unnormalized posterior at x.""" + return -1 * self.pdf(x) + + def logpdf(self, x): + """Return the unnormalized log-posterior at x. + + Parameters + ---------- + x: np.ndarray + + Returns + ------- + np.ndarray + + """ + return self._prior.logpdf(x).reshape(-1, 1) - self._model.predict_mean(x) + + def gradient_pdf(self, x): + """Return the gradient of the unnormalized posterior pdf at x. + + Parameters + ---------- + x: np.ndarray + + Returns + ------- + np.ndarray + + """ + return np.exp(self.logpdf(x)) * self.gradient_logpdf(x) + + def _negative_gradient_pdf(self, x): + """Return the negative gradient of the unnormalized posterior pdf at x.""" + return -1 * self.gradient_pdf(x) + + def gradient_logpdf(self, x): + """Return the gradient of unnormalized log-posterior pdf at x. + + Parameters + ---------- + x: np.ndarray + + Returns + ------- + np.ndarray + + """ + return self._prior.gradient_logpdf(x).reshape(1, -1) \ + - self._model.predictive_gradient_mean(x) + + def _compute_map_estimates(self): + """Return the maximum a posterior estimate for each parameter.""" + minimum_location, _ = minimize( + fun=self._negative_pdf, + bounds=self._model.bounds, + grad=self._negative_gradient_pdf, + prior=self._prior, + n_start_points=self._n_opt_inits, + maxiter=self._max_opt_iters + ) + return minimum_location + + class RomcPosterior: r""" The posterior distribution as defined by the ROMC method. diff --git a/elfi/methods/results.py b/elfi/methods/results.py index 8ce3b792..a4806157 100644 --- a/elfi/methods/results.py +++ b/elfi/methods/results.py @@ -485,6 +485,40 @@ def plot_traces(self, selector=None, axes=None, **kwargs): return vis.plot_traces(self, selector, axes, **kwargs) +class BOLFIRESample(Sample): + """Container for results from BOLFIRE.""" + + def __init__(self, method_name, chains, parameter_names, warmup, *args, **kwargs): + """Initialize BOLFIRE result. + + Parameters + ---------- + method_name: str + Name of the inference method. + chains: np.ndarray (n_chains, n_samples, n_parameters) + Chains from sampling, warmup included. + parameter_names: list + List of names in the outputs dict that refer to model parameters. + warmup: int + Number of warmup iterations in chains. + + """ + n_chains = chains.shape[0] + warmed_up = chains[:, warmup:, :] + concatenated = warmed_up.reshape((-1,) + chains.shape[2:]) + outputs = dict(zip(parameter_names, concatenated.T)) + + super(BOLFIRESample, self).__init__( + method_name=method_name, + outputs=outputs, + parameter_names=parameter_names, + chains=chains, + n_chains=n_chains, + warmup=warmed_up, + *args, **kwargs + ) + + class RomcSample(Sample): """Container for results from ROMC.""" diff --git a/setup.cfg b/setup.cfg index 233e0b04..ff68e858 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ known_third_party=matplotlib [tool:pytest] addopts = --doctest-modules testpaths = elfi tests +markers = slowtest [zest.releaser] python-file-with-version = elfi/__init__.py diff --git a/tests/functional/test_bolfire.py b/tests/functional/test_bolfire.py new file mode 100644 index 00000000..e59871ee --- /dev/null +++ b/tests/functional/test_bolfire.py @@ -0,0 +1,102 @@ +import pytest + +import numpy as np + +import elfi +from elfi.classifiers.classifier import LogisticRegression +from elfi.methods.bo.acquisition import LCBSC +from elfi.model.extensions import ModelPrior + + +def simple_gaussian_model(true_param, seed, n_summaries=10): + """The simple gaussian model that has been used as a toy example in the LFIRE paper.""" + + def power(x, y): + return x**y + + m = elfi.ElfiModel() + mu = elfi.Prior('uniform', -5, 10, model=m, name='mu') + y = elfi.Simulator(gauss, *[mu], observed=gauss(true_param, seed=seed), name='y') + for i in range(n_summaries): + elfi.Summary(power, y, i, model=m, name=f'power_{i}') + return m + + +def gauss(mu, sigma=3, n_obs=1, batch_size=1, seed=None, *args, **kwargs): + if isinstance(seed, int): + np.random.seed(seed) + mu = np.asanyarray(mu).reshape((-1, 1)) + sigma = np.asanyarray(sigma).reshape((-1, 1)) + return np.random.normal(mu, sigma, size=(batch_size, n_obs)) + + +@pytest.fixture +def true_param(): + return 2.6 + + +@pytest.fixture +def seed(): + return 4 + + +def test_bolfire_init(true_param, seed): + # define the simple gaussian elfi model + m = simple_gaussian_model(true_param, seed) + + # define the bolfire method + bolfire_method = elfi.BOLFIRE(model=m) + + # check the default size of training data + assert bolfire_method.n_training_data == 10 + # check the size of mariginal data (should be the size of training data x number of summaries) + assert bolfire_method.marginal.shape == (10, 10) + # check the summary names + assert bolfire_method.summary_names == [f'power_{i}' for i in range(10)] + # check the type of a default classifier + assert isinstance(bolfire_method.classifier, LogisticRegression) + # check the lenght of observed summary values + assert len(bolfire_method.observed[0]) == 10 + # check the type of the prior + assert isinstance(bolfire_method.prior, ModelPrior) + # check the acquisition function and GP regression related parameters + assert bolfire_method.bounds is None + assert bolfire_method.acq_noise_var == 0 + assert bolfire_method.exploration_rate == 10 + assert bolfire_method.update_interval == 1 + assert bolfire_method.n_initial_evidence == 0 + assert isinstance(bolfire_method.acquisition_method, LCBSC) + + +@pytest.mark.slowtest +def test_bolfire(true_param, seed): + # define the simple gaussian elfi model + m = simple_gaussian_model(true_param, seed) + + # define the bolfire method + bolfire_method = elfi.BOLFIRE( + model=m, + n_training_data=500, + n_initial_evidence=10, + update_interval=1, + bounds={'mu': (-5, 5)}, + ) + + # run inference + n_evidence = 100 + bolfire_posterior = bolfire_method.fit(n_evidence, bar=False) + + # check the number of evidence + assert bolfire_method.n_evidence == n_evidence + + # check the map estimates + map_estimates = bolfire_posterior.map_estimates + assert np.abs(map_estimates['mu'] - true_param) <= 0.5 + + # run sampling + n_samples = 400 + bolfire_sample = bolfire_method.sample(n_samples) + + # check the sample (posterior) means + sample_means = bolfire_sample.sample_means + assert np.abs(sample_means['mu'] - true_param) <= 1.5 diff --git a/tests/unit/test_bolfire_unit.py b/tests/unit/test_bolfire_unit.py new file mode 100644 index 00000000..854f65b8 --- /dev/null +++ b/tests/unit/test_bolfire_unit.py @@ -0,0 +1,63 @@ +import pytest + +import numpy as np + +import elfi + + +def simple_gaussian_model(true_param, seed, n_summaries=10): + """The simple gaussian model that has been used as a toy example in the LFIRE paper.""" + + def power(x, y): + return x**y + + m = elfi.ElfiModel() + mu = elfi.Prior('uniform', -5, 10, model=m, name='mu') + y = elfi.Simulator(gauss, *[mu], observed=gauss(true_param, seed=seed), name='y') + for i in range(n_summaries): + elfi.Summary(power, y, i, model=m, name=f'power_{i}') + return m + + +def gauss(mu, sigma=3, n_obs=1, batch_size=1, seed=None, *args, **kwargs): + if isinstance(seed, int): + np.random.seed(seed) + mu = np.asanyarray(mu).reshape((-1, 1)) + sigma = np.asanyarray(sigma).reshape((-1, 1)) + return np.random.normal(mu, sigma, size=(batch_size, n_obs)) + + +@pytest.fixture +def true_param(): + return 2.6 + + +@pytest.fixture +def seed(): + return 4 + + +@pytest.fixture +def parameter_values(): + return {'mu': 1.0} + + +@pytest.fixture +def bolfire_method(true_param, seed): + m = simple_gaussian_model(true_param, seed) + return elfi.BOLFIRE(m) + + +def test_generate_marginal(bolfire_method): + assert bolfire_method._generate_marginal().shape == (10, 10) + + +def test_generate_likelihood(bolfire_method, parameter_values): + assert bolfire_method._generate_likelihood(parameter_values).shape == (10, 10) + + +def test_generate_training_data(bolfire_method, parameter_values): + likelihood = bolfire_method._generate_likelihood(parameter_values) + X, y = bolfire_method._generate_training_data(likelihood, bolfire_method.marginal) + assert X.shape == (20, 10) + assert y.shape == (20,) diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index d4e46e96..e8deec9c 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -5,7 +5,7 @@ import pytest import elfi -from elfi.examples import bdm, bignk, gauss, gnk, lotka_volterra, ricker, daycare, lorenz +from elfi.examples import arch, bdm, bignk, gauss, gnk, lotka_volterra, ricker, daycare, lorenz def test_bdm(): @@ -43,11 +43,13 @@ def test_bdm(): if do_cleanup: os.system('rm {}/bdm'.format(cpp_path)) + def test_gauss(): m = gauss.get_model() rej = elfi.Rejection(m, m['d'], batch_size=10) rej.sample(20) + def test_gauss_1d_mean(): params_true = [4] cov_matrix = [1] @@ -95,7 +97,14 @@ def test_Lotka_Volterra(): rej = elfi.Rejection(m, m['d'], batch_size=10) rej.sample(10, quantile=0.5) + def test_daycare(): m = daycare.get_model(time_end=0.05) rej = elfi.Rejection(m['d'], batch_size=10) rej.sample(10, quantile=0.5) + + +def test_arch(): + m = arch.get_model() + rej = elfi.Rejection(m['d'], batch_size=10) + rej.sample(10, quantile=0.5) From 085657f9106c981021d7153a7a8310108eb08057 Mon Sep 17 00:00:00 2001 From: Henri Pesonen Date: Thu, 17 Feb 2022 12:37:24 +0100 Subject: [PATCH 3/4] Update changelog (#394) --- CHANGELOG.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bce3f206..db5efe5e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,10 @@ Changelog ========= - + +- Add a new inference method: BOLFIRE +- Fix the hessian approximation, visualizations and the line search algorithm in ROMC +- Add tests for all ROMC parts + 0.8.2 (2021-10-13) ------------------ - Relax tightly pinned dependency on a version of dask[distributed] From 6d13c7a36d857c7b497bd13dedd9717bf0f03adf Mon Sep 17 00:00:00 2001 From: Henri Pesonen Date: Thu, 17 Feb 2022 14:35:00 +0100 Subject: [PATCH 4/4] Prepare v0.8.3 (#395) * Bump version * Add mock numdifftools --- CHANGELOG.rst | 2 ++ README.md | 5 +++-- docs/conf.py | 2 +- elfi/__init__.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db5efe5e..00075782 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,8 @@ Changelog ========= +0.8.3 (2021-02-17) +------------------ - Add a new inference method: BOLFIRE - Fix the hessian approximation, visualizations and the line search algorithm in ROMC - Add tests for all ROMC parts diff --git a/README.md b/README.md index 0cd0a507..6a3b4f42 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**Version 0.8.2 released!** See the [CHANGELOG](CHANGELOG.rst) and [notebooks](https://github.com/elfi-dev/notebooks). +**Version 0.8.3 released!** See the [CHANGELOG](CHANGELOG.rst) and [notebooks](https://github.com/elfi-dev/notebooks). @@ -24,7 +24,8 @@ Currently implemented LFI methods: - SMC-ABC sampler with [adaptive threshold selection](https://projecteuclid.org/journals/bayesian-analysis/advance-publication/Adaptive-Approximate-Bayesian-Computation-Tolerance-Selection/10.1214/20-BA1211.full) - SMC-ABC sampler with [adaptive distance](https://projecteuclid.org/euclid.ba/1460641065) - [Bayesian Optimization for Likelihood-Free Inference (BOLFI)](http://jmlr.csail.mit.edu/papers/v17/15-017.html) -- [Robust Optimisation Monte Carlo](https://arxiv.org/abs/1904.00670) +- [Robust Optimisation Monte Carlo (ROMC)](https://arxiv.org/abs/1904.00670) +- [Bayesian Optimization for Likelihood-Free Inference by Ratio Estimation (BOLFIRE)](https://helda.helsinki.fi/handle/10138/305039) Other notable included algorithms and methods: - Bayesian Optimization diff --git a/docs/conf.py b/docs/conf.py index fff11d07..3f191046 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,7 @@ def __getattr__(cls, name): 'dask.delayed', 'scipy.linalg', 'scipy.optimize', 'scipy.stats', 'scipy.spatial', 'scipy.sparse', 'scipy.special', 'matplotlib.pyplot', 'numpy.random', 'networkx', 'ipyparallel', 'numpy.lib', 'numpy.lib.format', 'sklearn.linear_model', - 'sklearn.pipeline', 'sklearn.preprocessing' + 'sklearn.pipeline', 'sklearn.preprocessing', 'numdifftools' ] sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) diff --git a/elfi/__init__.py b/elfi/__init__.py index 8e6bb4de..293569e2 100644 --- a/elfi/__init__.py +++ b/elfi/__init__.py @@ -31,4 +31,4 @@ __email__ = 'elfi-support@hiit.fi' # make sure __version_ is on the last non-empty line (read by setup.py) -__version__ = '0.8.2' +__version__ = '0.8.3'