def __init__( self, weights: Tensor, outcomes: Optional[List[int]] = None, num_outcomes: Optional[int] = None, ) -> None: r"""Initialize Objective. Args: weights: `m'`-dim tensor of outcome weights. outcomes: A list of the `m'` indices that the weights should be applied to. num_outcomes: the total number of outcomes `m` """ super().__init__(outcomes=outcomes, num_outcomes=num_outcomes) if weights.ndim != 1: raise BotorchTensorDimensionError( f"weights must be an 1-D tensor, but got {weights.shape}." ) elif outcomes is not None and weights.shape[0] != len(outcomes): raise BotorchTensorDimensionError( "weights must contain the same number of elements as outcomes, " f"but got {weights.numel()} weights and {len(outcomes)} outcomes." ) self.register_buffer("weights", weights)
def compute(self, pareto_Y: Tensor) -> float: r"""Compute hypervolume. Args: pareto_Y: A `n x m`-dim tensor of pareto optimal outcomes Returns: The hypervolume. """ if pareto_Y.shape[-1] != self._ref_point.shape[0]: raise BotorchTensorDimensionError( "pareto_Y must have the same number of objectives as ref_point. " f"Got {pareto_Y.shape[-1]}, expected {self._ref_point.shape[0]}." ) elif pareto_Y.ndim != 2: raise BotorchTensorDimensionError( f"pareto_Y must have exactly two dimensions, got {pareto_Y.ndim}." ) # This assumes maximization, but internally flips the sign of the pareto front # and the reference point and computes hypervolume for the minimization problem. pareto_Y = -pareto_Y better_than_ref = (pareto_Y <= self._ref_point).all(dim=-1) pareto_Y = pareto_Y[better_than_ref] # shift the pareto front so that reference point is all zeros pareto_Y = pareto_Y - self._ref_point self._initialize_multilist(pareto_Y) bounds = torch.full_like(self._ref_point, float("-inf")) return self._hv_recursive(i=self._ref_point.shape[0] - 1, n_pareto=pareto_Y.shape[0], bounds=bounds)
def __init__(self, Y_mean: Tensor, Y_std: Tensor, outcomes: Optional[List[int]] = None) -> None: r"""Initialize objective. Args: Y_mean: `m`-dim tensor of outcome means. Y_std: `m`-dim tensor of outcome standard deviations. outcomes: A list of `m' <= m` indices that specifies which of the `m` model outputs should be considered as the outcomes for MOO. If omitted, use all model outcomes. Typically used for constrained optimization. """ if Y_mean.ndim > 1 or Y_std.ndim > 1: raise BotorchTensorDimensionError( "Y_mean and Y_std must both be 1-dimensional, but got " f"{Y_mean.ndim} and {Y_std.ndim}") elif outcomes is not None and len(outcomes) > Y_mean.shape[-1]: raise BotorchTensorDimensionError( f"Cannot specify more ({len(outcomes)}) outcomes than are present in " f"the normalization inputs ({Y_mean.shape[-1]}).") super().__init__(outcomes=outcomes, num_outcomes=Y_mean.shape[-1]) if outcomes is not None: Y_mean = Y_mean.index_select(-1, self.outcomes.to(Y_mean.device)) Y_std = Y_std.index_select(-1, self.outcomes.to(Y_mean.device)) self.register_buffer("Y_mean", Y_mean) self.register_buffer("Y_std", Y_std)
def __init__(self, Y_mean: Tensor, Y_std: Tensor, outcomes: Optional[List[int]] = None) -> None: r"""Initialize objective. Args: Y_mean: `m`-dim tensor of outcome means Y_std: `m`-dim tensor of outcome standard deviations outcomes: A list of `m' <= m` indices that specifies which of the `m` model outputs should be considered as the outcomes for MOO. If omitted, use all model outcomes. Typically used for constrained optimization. """ if Y_mean.ndim > 1 or Y_std.ndim > 1: raise BotorchTensorDimensionError( "Y_mean and Y_std must both be 1-dimensional, but got " f"{Y_mean.ndim} and {Y_std.ndim}") if outcomes is not None: if len(outcomes) < 2: raise BotorchTensorDimensionError( "Must specify at least two outcomes for MOO.") elif len(outcomes) > Y_mean.shape[-1]: raise BotorchTensorDimensionError( f"Cannot specify more ({len(outcomes)}) outcomes that present in " f"the normalization inputs ({Y_mean.shape[-1]}).") super().__init__() self.outcome_transform = Standardize(m=Y_mean.shape[0], outputs=outcomes).to(Y_mean) Y_std_unsqueezed = Y_std.unsqueeze(0) self.outcome_transform.means = Y_mean.unsqueeze(0) self.outcome_transform.stdvs = Y_std_unsqueezed self.outcome_transform._stdvs_sq = Y_std_unsqueezed.pow(2) self.outcome_transform.eval()
def _validate_tensor_args( X: Tensor, Y: Tensor, Yvar: Optional[Tensor] = None, strict: bool = True ) -> None: r"""Checks that `Y` and `Yvar` have an explicit output dimension if strict. This also checks that `Yvar` has the same trailing dimensions as `Y`. Note we only infer that an explicit output dimension exists when `X` and `Y` have the same `batch_shape`. Args: X: A `batch_shape x n x d`-dim Tensor, where `d` is the dimension of the feature space, `n` is the number of points per batch, and `batch_shape` is the batch shape (potentially empty). Y: A `batch_shape' x n x m`-dim Tensor, where `m` is the number of model outputs, `n'` is the number of points per batch, and `batch_shape'` is the batch shape of the observations. Yvar: A `batch_shape' x n x m` tensor of observed measurement noise. Note: this will be None when using a model that infers the noise level (e.g. a `SingleTaskGP`). strict: A boolean indicating whether to check that `Y` and `Yvar` have an explicit output dimension. """ if strict: if X.dim() != Y.dim(): if (X.dim() - Y.dim() == 1) and (X.shape[:-1] == Y.shape): message = ( "An explicit output dimension is required for targets." f" Expected Y with dimension: {Y.dim()} (got {X.dim()})." ) else: message = ( "Expected X and Y to have the same number of dimensions" f" (got X with dimension {X.dim()} and Y with dimension" f" {Y.dim()}." ) raise BotorchTensorDimensionError(message) else: warnings.warn( "Non-strict enforcement of botorch tensor conventions. Ensure that " f"target tensors Y{' and Yvar have' if Yvar is not None else ' has an'}" f" explicit output dimension{'s' if Yvar is not None else ''}.", BotorchTensorDimensionWarning, ) # Yvar may not have the same batch dimensions, but the trailing dimensions # of Yvar should be the same as the trailing dimensions of Y. if Yvar is not None and Y.shape[-(Yvar.dim()) :] != Yvar.shape: raise BotorchTensorDimensionError( "An explicit output dimension is required for observation noise." f" Expected Yvar with shape: {Y.shape[-Yvar.dim() :]} (got" f" {Yvar.shape})." )
def _untransform(self, X: Tensor) -> Tensor: r"""Warp the inputs through the Kumaraswamy inverse CDF. Args: X: A `input_batch_shape x batch_shape x n x d`-dim tensor of inputs. Returns: A `input_batch_shape x batch_shape x n x d`-dim tensor of transformed inputs. """ if len(self.batch_shape) > 0: if self.batch_shape != X.shape[-2 - len(self.batch_shape) : -2]: raise BotorchTensorDimensionError( "The right most batch dims of X must match self.batch_shape: " f"({self.batch_shape})." ) X_tf = X.clone() k = Kumaraswamy( concentration1=self.concentration1, concentration0=self.concentration0 ) # unnormalize from [eps, 1-eps] to [0,1] X_tf[..., self.indices] = ( (k.icdf(X_tf[..., self.indices]) - self._X_min) / self._X_range ).clamp(0.0, 1.0) return X_tf
def partition_space_2d(self) -> None: r"""Partition the non-dominated space into disjoint hypercells. This direct method works for `m=2` outcomes. """ if self.num_outcomes != 2: raise BotorchTensorDimensionError( "partition_non_dominated_space_2d requires 2 outputs, " f"but num_outcomes={self.num_outcomes}") pf_ext_idx = self._get_augmented_pareto_front_indices() n_pf_plus_1 = self._neg_pareto_Y.shape[-2] + 1 view_shape = torch.Size([1] * len(self.batch_shape) + [n_pf_plus_1]) expand_shape = self.batch_shape + torch.Size([n_pf_plus_1]) range_pf_plus1 = torch.arange(n_pf_plus_1, dtype=torch.long, device=self._neg_pareto_Y.device) range_pf_plus1_expanded = range_pf_plus1.view(view_shape).expand( expand_shape) lower = torch.stack([ range_pf_plus1_expanded, torch.zeros_like(range_pf_plus1_expanded) ], dim=-1) upper = torch.stack( [ 1 + range_pf_plus1_expanded, pf_ext_idx[..., -range_pf_plus1 - 1, -1] ], dim=-1, ) # 2 x batch_shape x n_cells x 2 self.register_buffer("hypercells", torch.stack([lower, upper], dim=0))
def _transform(self, X: Tensor) -> Tensor: r"""Normalize the inputs. If no explicit bounds are provided, this is stateful: In train mode, calling `forward` updates the module state (i.e. the normalizing bounds). In eval mode, calling `forward` simply applies the normalization using the current module state. Args: X: A `batch_shape x n x d`-dim tensor of inputs. Returns: A `batch_shape x n x d`-dim tensor of inputs normalized to the module's bounds. """ if self.learn_bounds and self.training: if X.size(-1) != self.mins.size(-1): raise BotorchTensorDimensionError( f"Wrong input dimension. Received {X.size(-1)}, " f"expected {self.mins.size(-1)}.") self.mins = X.min(dim=-2, keepdim=True)[0] ranges = X.max(dim=-2, keepdim=True)[0] - self.mins ranges[torch.where(ranges <= self.min_range)] = self.min_range self.ranges = ranges if hasattr(self, "indices"): X_new = X.clone() X_new[..., self.indices] = ( X_new[..., self.indices] - self.mins[..., self.indices]) / self.ranges[..., self.indices] return X_new return (X - self.mins) / self.ranges
def _transform(self, X: Tensor) -> Tensor: r"""Standardize the inputs. In train mode, calling `forward` updates the module state (i.e. the mean/std normalizing constants). If in eval mode, calling `forward` simply applies the standardization using the current module state. Args: X: A `batch_shape x n x d`-dim tensor of inputs. Returns: A `batch_shape x n x d`-dim tensor of inputs normalized to the module's bounds. """ if self.training and self.learn_bounds: if X.size(-1) != self.means.size(-1): raise BotorchTensorDimensionError( f"Wrong input. dimension. Received {X.size(-1)}, " f"expected {self.means.size(-1)}") self.means = X.mean(dim=-2, keepdim=True) self.stds = X.std(dim=-2, keepdim=True) self.stds = torch.clamp(self.stds, min=self.min_std) if hasattr(self, "indices"): X_new = X.clone() X_new[..., self.indices] = ( X_new[..., self.indices] - self.means[..., self.indices]) / self.stds[..., self.indices] return X_new return (X - self.means) / self.stds
def __init__( self, d: int, bounds: Optional[Tensor] = None, batch_shape: torch.Size = torch.Size(), # noqa: B008 ) -> None: r"""Normalize the inputs to the unit cube. Args: d: The dimension of the input space. bounds: If provided, use these bounds to normalize the inputs. If omitted, learn the bounds in train mode. batch_shape: The batch shape of the inputs (asssuming input tensors of shape `batch_shape x n x d`). If provided, perform individual normalization per batch, otherwise uses a single normalization. """ super().__init__() if bounds is not None: if bounds.size(-1) != d: raise BotorchTensorDimensionError( "Incompatible dimensions of provided bounds") mins = bounds[..., 0:1, :] ranges = bounds[..., 1:2, :] - mins self.learn_bounds = False else: mins = torch.zeros(*batch_shape, 1, d) ranges = torch.zeros(*batch_shape, 1, d) self.learn_bounds = True self.register_buffer("mins", mins) self.register_buffer("ranges", ranges) self._d = d
def _expand_ref_point(self, ref_point: Tensor) -> Tensor: r"""Expand reference point to the proper batch_shape.""" if ref_point.shape[:-1] != self.batch_shape: if ref_point.ndim > 1: raise BotorchTensorDimensionError( "Expected ref_point to be a `batch_shape x m` or `m`-dim tensor, " f"but got {ref_point.shape}." ) ref_point = ref_point.view( *(1 for _ in self.batch_shape), ref_point.shape[-1] ).expand(self.batch_shape + ref_point.shape[-1:]) return ref_point
def __init__( self, d: int, bounds: Optional[Tensor] = None, batch_shape: torch.Size = torch.Size(), # noqa: B008 transform_on_train: bool = True, transform_on_eval: bool = True, transform_on_set_train_data: bool = False, reverse: bool = False, ) -> None: r"""Normalize the inputs to the unit cube. Args: d: The dimension of the input space. bounds: If provided, use these bounds to normalize the inputs. If omitted, learn the bounds in train mode. batch_shape: The batch shape of the inputs (asssuming input tensors of shape `batch_shape x n x d`). If provided, perform individual normalization per batch, otherwise uses a single normalization. transform_on_train: A boolean indicating whether to apply the transforms in train() mode. Default: True transform_on_eval: A boolean indicating whether to apply the transform in eval() mode. Default: True transform_on_set_train_data: A boolean indicating whether to apply the transform when setting training inputs on the mode. Default: False reverse: A boolean indicating whether the forward pass should untransform the inputs. """ super().__init__() if bounds is not None: if bounds.size(-1) != d: raise BotorchTensorDimensionError( "Incompatible dimensions of provided bounds" ) mins = bounds[..., 0:1, :] ranges = bounds[..., 1:2, :] - mins self.learn_bounds = False else: mins = torch.zeros(*batch_shape, 1, d) ranges = torch.zeros(*batch_shape, 1, d) self.learn_bounds = True self.register_buffer("mins", mins) self.register_buffer("ranges", ranges) self._d = d self.transform_on_train = transform_on_train self.transform_on_eval = transform_on_eval self.transform_on_set_train_data = transform_on_set_train_data self.reverse = reverse self.batch_shape = batch_shape
def get_chebyshev_scalarization( weights: Tensor, Y: Tensor, alpha: float = 0.05) -> Callable[[Tensor, Optional[Tensor]], Tensor]: r"""Construct an augmented Chebyshev scalarization. Outcomes are first normalized to [0,1] and then an augmented Chebyshev scalarization is applied. Augmented Chebyshev scalarization: objective(y) = min(w * y) + alpha * sum(w * y) Note: this assumes maximization. See [Knowles2005]_ for details. This scalarization can be used with qExpectedImprovement to implement q-ParEGO as proposed in [Daulton2020qehvi]_. Args: weights: A `m`-dim tensor of weights. Y: A `n x m`-dim tensor of observed outcomes, which are used for scaling the outcomes to [0,1]. alpha: Parameter governing the influence of the weighted sum term. The default value comes from [Knowles2005]_. Returns: Transform function using the objective weights. Example: >>> weights = torch.tensor([0.75, 0.25]) >>> transform = get_aug_chebyshev_scalarization(weights, Y) """ if weights.shape != Y.shape[-1:]: raise BotorchTensorDimensionError( "weights must be an `m`-dim tensor where Y is `... x m`." f"Got shapes {weights.shape} and {Y.shape}.") elif Y.ndim > 2: raise NotImplementedError("Batched Y is not currently supported.") Y_bounds = torch.stack([Y.min(dim=-2).values, Y.max(dim=-2).values]) def obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor: # scale to [0,1] Y_normalized = normalize(Y, bounds=Y_bounds) product = weights * Y_normalized return product.min(dim=-1).values + alpha * product.sum(dim=-1) return obj
def update(self, Y: Union[List[Tensor], Tensor]) -> None: r"""Update the partitioning. Args: Y: A `n_box_decompositions x n x num_outcomes`-dim tensor or a list where the ith element contains the new points for box_decomposition `i`. """ if (torch.is_tensor(Y) and Y.ndim != 3 and Y.shape[0] != len(self.box_decompositions)) or (isinstance( Y, List) and len(Y) != len(self.box_decompositions)): raise BotorchTensorDimensionError( "BoxDecompositionList.update requires either a batched tensor Y, " "with one batch per box decomposition or a list of tensors with " "one element per box decomposition.") for i, p in enumerate(self.box_decompositions): p.update(Y[i])
def __init__(self, Y_mean: Tensor, Y_std: Tensor) -> None: r"""Initialize objective. Args: Y_mean: `m`-dim tensor of outcome means Y_std: `m`-dim tensor of outcome standard deviations """ if Y_mean.ndim > 1 or Y_std.ndim > 1: raise BotorchTensorDimensionError( "Y_mean and Y_std must both be 1-dimensional, but got " f"{Y_mean.ndim} and {Y_std.ndim}") super().__init__() self.outcome_transform = Standardize(m=Y_mean.shape[0]).to(Y_mean) Y_std_unsqueezed = Y_std.unsqueeze(0) self.outcome_transform.means = Y_mean.unsqueeze(0) self.outcome_transform.stdvs = Y_std_unsqueezed self.outcome_transform._stdvs_sq = Y_std_unsqueezed.pow(2) self.outcome_transform.eval()
def condition_on_observations( self, X: Tensor, Y: Tensor, **kwargs: Any ) -> ModelListGP: r"""Condition the model on new observations. Args: X: A `batch_shape x n' x d`-dim Tensor, where `d` is the dimension of the feature space, `n'` is the number of points per batch, and `batch_shape` is the batch shape (must be compatible with the batch shape of the model). Y: A `batch_shape' x n' x m`-dim Tensor, where `m` is the number of model outputs, `n'` is the number of points per batch, and `batch_shape'` is the batch shape of the observations. `batch_shape'` must be broadcastable to `batch_shape` using standard broadcasting semantics. If `Y` has fewer batch dimensions than `X`, its is assumed that the missing batch dimensions are the same for all `Y`. Returns: A `ModelListGPyTorchModel` representing the original model conditioned on the new observations `(X, Y)` (and possibly noise observations passed in via kwargs). Here the `i`-th model has `n_i + n'` training examples, where the `n'` training examples have been added and all test-time caches have been updated. """ self._validate_tensor_args( X=X, Y=Y, Yvar=kwargs.get("noise", None), strict=False ) inputs = [X] * self.num_outputs if Y.shape[-1] != self.num_outputs: raise BotorchTensorDimensionError( "Incorrect number of outputs for observations. Received " f"{Y.shape[-1]} observation outputs, but model has " f"{self.num_outputs} outputs." ) targets = [Y[..., i] for i in range(Y.shape[-1])] if "noise" in kwargs: noise = kwargs.pop("noise") # Note: dimension checks were performed in _validate_tensor_args kwargs_ = {**kwargs, "noise": [noise[..., i] for i in range(Y.shape[-1])]} else: kwargs_ = kwargs return super().get_fantasy_model(inputs, targets, **kwargs_)
def partition_non_dominated_space_2d(self) -> None: r"""Partition the non-dominated space into disjoint hypercells. This direct method works for `m=2` outcomes. """ if self.num_outcomes != 2: raise BotorchTensorDimensionError( "partition_non_dominated_space_2d requires 2 outputs, " f"but num_outcomes={self.num_outcomes}" ) pf_ext_idx = self._get_augmented_pareto_front_indices() range_pf_plus1 = torch.arange( self._pareto_Y.shape[0] + 1, dtype=torch.long, device=self._pareto_Y.device ) lower = torch.stack([range_pf_plus1, torch.zeros_like(range_pf_plus1)], dim=-1) upper = torch.stack( [range_pf_plus1 + 1, pf_ext_idx[-range_pf_plus1 - 1, -1]], dim=-1 ) self.register_buffer("hypercells", torch.stack([lower, upper], dim=0))
def _expand_ref_point(ref_point: Tensor, batch_shape: Size) -> Tensor: r"""Expand reference point to the proper batch_shape. Args: ref_point: A `(batch_shape) x m`-dim tensor containing the reference point. batch_shape: The batch shape. Returns: A `batch_shape x m`-dim tensor containing the expanded reference point """ if ref_point.shape[:-1] != batch_shape: if ref_point.ndim > 1: raise BotorchTensorDimensionError( "Expected ref_point to be a `batch_shape x m` or `m`-dim tensor, " f"but got {ref_point.shape}.") ref_point = ref_point.view( *(1 for _ in batch_shape), ref_point.shape[-1]).expand(batch_shape + ref_point.shape[-1:]) return ref_point
def _transform(self, X: Tensor) -> Tensor: r"""Normalize the inputs. If no explicit bounds are provided, this is stateful: In train mode, calling `forward` updates the module state (i.e. the normalizing bounds). In eval mode, calling `forward` simply applies the normalization using the current module state. Args: X: A `batch_shape x n x d`-dim tensor of inputs. Returns: A `batch_shape x n x d`-dim tensor of inputs normalized to the module's bounds. """ if self.learn_bounds and self.training: if X.size(-1) != self.mins.size(-1): raise BotorchTensorDimensionError( f"Wrong input. dimension. Received {X.size(-1)}, " f"expected {self.mins.size(-1)}") self.mins = X.min(dim=-2, keepdim=True)[0] self.ranges = X.max(dim=-2, keepdim=True)[0] - self.mins return (X - self.mins) / self.ranges
def __init__(self, outcomes: Optional[List[int]] = None, num_outcomes: Optional[int] = None) -> None: r"""Initialize Objective. Args: weights: `m'`-dim tensor of outcome weights. outcomes: A list of the `m'` indices that the weights should be applied to. num_outcomes: The total number of outcomes `m` """ super().__init__() if outcomes is not None: if len(outcomes) < 2: raise BotorchTensorDimensionError( "Must specify at least two outcomes for MOO.") if any(i < 0 for i in outcomes): if num_outcomes is None: raise BotorchError( "num_outcomes is required if any outcomes are less than 0." ) outcomes = normalize_indices(outcomes, num_outcomes) self.register_buffer("outcomes", torch.tensor(outcomes, dtype=torch.long))
def scalarize_posterior( posterior: GPyTorchPosterior, weights: Tensor, offset: float = 0.0 ) -> GPyTorchPosterior: r"""Affine transformation of a multi-output posterior. Args: posterior: The posterior over `m` outcomes to be scalarized. Supports `t`-batching. weights: A tensor of weights of size `m`. offset: The offset of the affine transformation. Returns: The transformed (single-output) posterior. If the input posterior has mean `mu` and covariance matrix `Sigma`, this posterior has mean `weights^T * mu` and variance `weights^T Sigma w`. Example: Example for a model with two outcomes: >>> X = torch.rand(1, 2) >>> posterior = model.posterior(X) >>> weights = torch.tensor([0.5, 0.25]) >>> new_posterior = scalarize_posterior(posterior, weights=weights) """ if weights.ndim > 1: raise BotorchTensorDimensionError("`weights` must be one-dimensional") mean = posterior.mean q, m = mean.shape[-2:] batch_shape = mean.shape[:-2] if m != weights.size(0): raise RuntimeError("Output shape not equal to that of weights") mvn = posterior.mvn cov = mvn.lazy_covariance_matrix if mvn.islazy else mvn.covariance_matrix if m == 1: # just scaling, no scalarization necessary new_mean = offset + (weights[0] * mean).view(*batch_shape, q) new_cov = weights[0] ** 2 * cov new_mvn = MultivariateNormal(new_mean, new_cov) return GPyTorchPosterior(new_mvn) new_mean = offset + (mean @ weights).view(*batch_shape, q) if q == 1: new_cov = weights.unsqueeze(-2) @ (cov @ weights.unsqueeze(-1)) else: # we need to handle potentially different representations of the multi-task mvn if mvn._interleaved: w_cov = weights.repeat(q).unsqueeze(0) sum_shape = batch_shape + torch.Size([q, m, q, m]) sum_dims = (-1, -2) else: # special-case the independent setting if isinstance(cov, BlockDiagLazyTensor): new_cov = SumLazyTensor( *[ cov.base_lazy_tensor[..., i, :, :] * weights[i].pow(2) for i in range(cov.base_lazy_tensor.size(-3)) ] ) new_mvn = MultivariateNormal(new_mean, new_cov) return GPyTorchPosterior(new_mvn) w_cov = torch.repeat_interleave(weights, q).unsqueeze(0) sum_shape = batch_shape + torch.Size([m, q, m, q]) sum_dims = (-2, -3) cov_scaled = w_cov * cov * w_cov.transpose(-1, -2) # TODO: Do not instantiate full covariance for lazy tensors (ideally we simplify # this in GPyTorch: https://github.com/cornellius-gp/gpytorch/issues/1055) if isinstance(cov_scaled, LazyTensor): cov_scaled = cov_scaled.evaluate() new_cov = cov_scaled.view(sum_shape).sum(dim=sum_dims[0]).sum(dim=sum_dims[1]) new_mvn = MultivariateNormal(new_mean, new_cov) return GPyTorchPosterior(new_mvn)
def get_chebyshev_scalarization( weights: Tensor, Y: Tensor, alpha: float = 0.05) -> Callable[[Tensor, Optional[Tensor]], Tensor]: r"""Construct an augmented Chebyshev scalarization. Augmented Chebyshev scalarization: objective(y) = min(w * y) + alpha * sum(w * y) Outcomes are first normalized to [0,1] for maximization (or [-1,0] for minimization) and then an augmented Chebyshev scalarization is applied. Note: this assumes maximization of the augmented Chebyshev scalarization. Minimizing/Maximizing an objective is supported by passing a negative/positive weight for that objective. To make all w * y's have positive sign such that they are comparable when computing min(w * y), outcomes of minimization objectives are shifted from [0,1] to [-1,0]. See [Knowles2005]_ for details. This scalarization can be used with qExpectedImprovement to implement q-ParEGO as proposed in [Daulton2020qehvi]_. Args: weights: A `m`-dim tensor of weights. Positive for maximization and negative for minimization. Y: A `n x m`-dim tensor of observed outcomes, which are used for scaling the outcomes to [0,1] or [-1,0]. alpha: Parameter governing the influence of the weighted sum term. The default value comes from [Knowles2005]_. Returns: Transform function using the objective weights. Example: >>> weights = torch.tensor([0.75, -0.25]) >>> transform = get_aug_chebyshev_scalarization(weights, Y) """ if weights.shape != Y.shape[-1:]: raise BotorchTensorDimensionError( "weights must be an `m`-dim tensor where Y is `... x m`." f"Got shapes {weights.shape} and {Y.shape}.") elif Y.ndim > 2: raise NotImplementedError("Batched Y is not currently supported.") def chebyshev_obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor: product = weights * Y return product.min(dim=-1).values + alpha * product.sum(dim=-1) if Y.shape[-2] == 0: # If there are no observations, we do not need to normalize the objectives return chebyshev_obj if Y.shape[-2] == 1: # If there is only one observation, set the bounds to be # [min(Y_m), min(Y_m) + 1] for each objective m. This ensures we do not # divide by zero Y_bounds = torch.cat([Y, Y + 1], dim=0) else: # Set the bounds to be [min(Y_m), max(Y_m)], for each objective m Y_bounds = torch.stack([Y.min(dim=-2).values, Y.max(dim=-2).values]) # A boolean mask indicating if minimizing an objective minimize = weights < 0 def obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor: # scale to [0,1] Y_normalized = normalize(Y, bounds=Y_bounds) # If minimizing an objective, convert Y_normalized values to [-1,0], # such that min(w*y) makes sense, we want all w*y's to be positive Y_normalized[..., minimize] = Y_normalized[..., minimize] - 1 return chebyshev_obj(Y=Y_normalized) return obj
def __init__( self, d: int, indices: Optional[List[int]] = None, bounds: Optional[Tensor] = None, batch_shape: torch.Size = torch.Size(), # noqa: B008 transform_on_train: bool = True, transform_on_eval: bool = True, transform_on_fantasize: bool = True, reverse: bool = False, min_range: float = 1e-8, ) -> None: r"""Normalize the inputs to the unit cube. Args: d: The dimension of the input space. indices: The indices of the inputs to normalize. If omitted, take all dimensions of the inputs into account. bounds: If provided, use these bounds to normalize the inputs. If omitted, learn the bounds in train mode. batch_shape: The batch shape of the inputs (asssuming input tensors of shape `batch_shape x n x d`). If provided, perform individual normalization per batch, otherwise uses a single normalization. transform_on_train: A boolean indicating whether to apply the transforms in train() mode. Default: True. transform_on_eval: A boolean indicating whether to apply the transform in eval() mode. Default: True. transform_on_fantasize: A boolean indicating whether to apply the transform when called from within a `fantasize` call. Default: True. reverse: A boolean indicating whether the forward pass should untransform the inputs. min_range: Amount of noise to add to the range to ensure no division by zero errors. """ super().__init__() if (indices is not None) and (len(indices) == 0): raise ValueError("`indices` list is empty!") if (indices is not None) and (len(indices) > 0): indices = torch.tensor(indices, dtype=torch.long) if len(indices) > d: raise ValueError("Can provide at most `d` indices!") if (indices > d - 1).any(): raise ValueError( "Elements of `indices` have to be smaller than `d`!") if len(indices.unique()) != len(indices): raise ValueError( "Elements of `indices` tensor must be unique!") self.indices = indices if bounds is not None: if bounds.size(-1) != d: raise BotorchTensorDimensionError( "Dimensions of provided `bounds` are incompatible with `d`!" ) mins = bounds[..., 0:1, :] ranges = bounds[..., 1:2, :] - mins self.learn_bounds = False else: mins = torch.zeros(*batch_shape, 1, d) ranges = torch.zeros(*batch_shape, 1, d) self.learn_bounds = True self.register_buffer("mins", mins) self.register_buffer("ranges", ranges) self._d = d self.transform_on_train = transform_on_train self.transform_on_eval = transform_on_eval self.transform_on_fantasize = transform_on_fantasize self.reverse = reverse self.batch_shape = batch_shape self.min_range = min_range
def sample_perturbed_subset_dims( X: Tensor, bounds: Tensor, n_discrete_points: int, sigma: float = 1e-1, qmc: bool = True, prob_perturb: Optional[float] = None, ) -> Tensor: r"""Sample around `X` by perturbing a subset of the dimensions. By default, dimensions are perturbed with probability equal to `min(20 / d, 1)`. As shown in [Regis]_, perturbing a small number of dimensions can be beneificial. The perturbations are sampled from N(0, sigma^2 I) and truncated to be within [0,1]^d. Args: X: A `n x d`-dim tensor starting points. `X` must be normalized to be within `[0, 1]^d`. bounds: The bounds to sample perturbed values from n_discrete_points: The number of points to sample. sigma: The standard deviation of the additive gaussian noise for perturbing the points. qmc: A boolean indicating whether to use qmc. prob_perturb: The probability of perturbing each dimension. If omitted, defaults to `min(20 / d, 1)`. Returns: A `n_discrete_points x d`-dim tensor containing the sampled points. """ if bounds.ndim != 2: raise BotorchTensorDimensionError("bounds must be a `2 x d`-dim tensor.") elif X.ndim != 2: raise BotorchTensorDimensionError("X must be a `n x d`-dim tensor.") d = bounds.shape[-1] if prob_perturb is None: # Only perturb a subset of the features prob_perturb = min(20.0 / d, 1.0) if X.shape[0] == 1: X_cand = X.repeat(n_discrete_points, 1) else: rand_indices = torch.randint(X.shape[0], (n_discrete_points,), device=X.device) X_cand = X[rand_indices] pert = sample_truncated_normal_perturbations( X=X_cand, n_discrete_points=n_discrete_points, sigma=sigma, bounds=bounds, qmc=qmc, ) # find cases where we are not perturbing any dimensions mask = ( torch.rand( n_discrete_points, d, dtype=bounds.dtype, device=bounds.device, ) <= prob_perturb ) ind = (~mask).all(dim=-1).nonzero() # perturb `n_perturb` of the dimensions n_perturb = ceil(d * prob_perturb) perturb_mask = torch.zeros(d, dtype=mask.dtype, device=mask.device) perturb_mask[:n_perturb].fill_(1) # TODO: use batched `torch.randperm` when available: # https://github.com/pytorch/pytorch/issues/42502 for idx in ind: mask[idx] = perturb_mask[torch.randperm(d, device=bounds.device)] # Create candidate points X_cand[mask] = pert[mask] return X_cand
def test_botorch_exception_hierarchy(self): self.assertIsInstance(BotorchError(), Exception) self.assertIsInstance(CandidateGenerationError(), BotorchError) self.assertIsInstance(InputDataError(), BotorchError) self.assertIsInstance(UnsupportedError(), BotorchError) self.assertIsInstance(BotorchTensorDimensionError(), BotorchError)