Skip to content

BayesianOpt

sgptools.methods.BayesianOpt

Bases: Method

Informative sensor placement / path optimization using Bayesian Optimization over a continuous search space.

A Bayesian optimization loop is run over a flattened vector containing all sensing locations for all robots. At each iteration, the candidate locations are reshaped, optionally transformed (for IPP / FoV modeling), evaluated under a GP-based objective (e.g. mutual information), and penalized by any constraints provided by the Transform.

References

  • Vivaldini et al., 2019. UAV route planning for active disease classification.
  • Francis et al., 2019. Occupancy map building through Bayesian exploration.

Attributes

objective: Objective object encapsulating the GP-based information measure to maximize. transform: Optional transform applied to candidate sensing locations before evaluating the objective. pbounds: Dictionary mapping parameter names 'x0', 'x1', ... to their search bounds (lower, upper), as required by bayes_opt.BayesianOptimization.

Source code in sgptools/methods.py
class BayesianOpt(Method):
    """
    Informative sensor placement / path optimization using Bayesian
    Optimization over a continuous search space.

    A Bayesian optimization loop is run over a flattened vector containing all
    sensing locations for all robots. At each iteration, the candidate
    locations are reshaped, optionally transformed (for IPP / FoV modeling),
    evaluated under a GP-based objective (e.g. mutual information), and
    penalized by any constraints provided by the `Transform`.

    References
    ----------
    - Vivaldini et al., 2019. *UAV route planning for active disease
      classification.*
    - Francis et al., 2019. *Occupancy map building through Bayesian
      exploration.*

    Attributes
    ----------
    objective:
        Objective object encapsulating the GP-based information measure to
        maximize.
    transform:
        Optional transform applied to candidate sensing locations before
        evaluating the objective.
    pbounds:
        Dictionary mapping parameter names `'x0', 'x1', ...` to their search
        bounds `(lower, upper)`, as required by `bayes_opt.BayesianOptimization`.
    """

    def __init__(self,
                 num_sensing: int,
                 X_objective: np.ndarray,
                 kernel: gpflow.kernels.Kernel,
                 noise_variance: float,
                 transform: Optional[Transform] = None,
                 num_robots: int = 1,
                 X_candidates: Optional[np.ndarray] = None,
                 num_dim: Optional[int] = None,
                 objective: Union[str, Objective] = 'SLogMI',
                 **kwargs: Any):
        """
        Initialize a Bayesian optimization-based method.

        Parameters
        ----------
        num_sensing:
            Number of sensing locations per robot to optimize.
        X_objective:
            Array of shape `(n, d)` used to define the underlying objective.
            The bounds of this set are used to define the BO search space.
        kernel:
            GPflow kernel used inside the objective.
        noise_variance:
            Observation noise variance used inside the objective.
        transform:
            Optional transform applied to the candidate solution before
            evaluating the objective (and constraints). For example, an
            `IPPTransform`.
        num_robots:
            Number of robots / agents. Defaults to 1.
        X_candidates:
            Optional discrete candidate set of locations with shape `(c, d)`.
            If provided, the final continuous solution is snapped to the
            nearest candidate locations.
        num_dim:
            Dimensionality of the sensing locations. If `None`, defaults to
            `X_objective.shape[-1]`.
        objective:
            Objective specification. Either a string key understood by
            `get_objective` (e.g. `'SLogMI'`, `'MI'`) or an already-instantiated
            `Objective` object.
        **kwargs:
            Additional keyword arguments forwarded to the objective constructor
            when `objective` is a string.
        """
        super().__init__(num_sensing, X_objective, kernel, noise_variance,
                         transform, num_robots, X_candidates, num_dim)
        self.transform = transform

        if isinstance(objective, str):
            self.objective = get_objective(objective)(X_objective, kernel,
                                                      noise_variance, **kwargs)
        else:
            self.objective = objective

        # Use the coordinate-wise min/max of X_objective as BO bounds
        pbounds_dims: List[Tuple[float, float]] = []
        for i in range(self.num_dim):
            pbounds_dims.append(
                (np.min(X_objective[:, i]), np.max(X_objective[:, i])))
        self.pbounds: Dict[str, Tuple[float, float]] = {}
        for i in range(self.num_dim * self.num_sensing * self.num_robots):
            self.pbounds[f'x{i}'] = pbounds_dims[i % self.num_dim]

    def update(self, kernel: gpflow.kernels.Kernel,
               noise_variance: float) -> None:
        """
        Update the kernel and noise variance used by the underlying objective.

        Parameters
        ----------
        kernel:
            New GPflow kernel instance.
        noise_variance:
            New observation noise variance.
        """
        self.objective.update(kernel, noise_variance)

    def get_hyperparameters(self) -> Tuple[gpflow.kernels.Kernel, float]:
        """
        Return the current kernel and noise variance used by the objective.

        Returns
        -------
        (gpflow.kernels.Kernel, float)
            A deep copy of the kernel and the current noise variance.
        """
        return deepcopy(self.objective.kernel), \
               self.objective.noise_variance

    def optimize(self,
                 max_steps: int = 50,
                 init_points: int = 10,
                 verbose: bool = False,
                 seed: Optional[int] = None,
                 **kwargs: Any) -> np.ndarray:
        """
        Run Bayesian optimization to obtain informative sensing locations.

        Parameters
        ----------
        max_steps:
            Number of Bayesian optimization iterations after the initial random
            exploration. Defaults to 50.
        init_points:
            Number of purely random evaluations before BO starts. Defaults to 10.
        verbose:
            If `True`, print progress messages from `BayesianOptimization`.
        seed:
            Optional random seed to make BO reproducible.
        **kwargs:
            Extra keyword arguments forwarded to `BayesianOptimization`
            (currently unused in this wrapper, but accepted for flexibility).

        Returns
        -------
        np.ndarray
            Array of shape `(num_robots, num_sensing, num_dim)` containing the
            optimized sensing locations in the original coordinate space.
        """
        verbose = 1 if verbose else 0
        optimizer = BayesianOptimization(f=self._objective,
                                         pbounds=self.pbounds,
                                         verbose=verbose,
                                         random_state=seed,
                                         allow_duplicate_points=True)
        optimizer.maximize(init_points=init_points, n_iter=max_steps)

        sol: List[float] = []
        for i in range(self.num_dim * self.num_sensing * self.num_robots):
            sol.append(optimizer.max['params'][f'x{i}'])

        # Reshape BO solution to (total_points, num_dim)
        sol_np = np.array(sol).reshape(-1, self.num_dim)
        if self.transform is not None:
            # Use the transform for constraints and internal path logic,
            # but disable sensor model expansion (e.g., FoV) when returning
            # waypoint locations.
            sol_np = self.transform.expand(sol_np,
                                           expand_sensor_model=False)

            if not isinstance(sol_np, np.ndarray):
                sol_np = sol_np.numpy()

        # Optionally snap to candidate set
        if self.X_candidates is not None:
            sol_np = cont2disc(sol_np, self.X_candidates)

        sol_np = sol_np.reshape(self.num_robots, -1, self.num_dim)
        return sol_np

    def _objective(self, **kwargs: float) -> float:
        """
        Objective function passed to `BayesianOptimization`.

        Parameters are expected as a flattened dictionary `{ 'x0': ..., 'x1': ... }`,
        which is reshaped into `(num_sensing * num_robots, num_dim)` to form
        continuous sensing locations. The method:

        1. Reshapes the flattened vector into locations.
        2. Optionally applies the `transform` (including constraints).
        3. Evaluates the GP-based objective.
        4. Adds the constraint penalty returned by the transform.
        5. Returns the scalar objective as a Python float.

        The underlying objective is *maximized*. Transform constraints are
        expected to return non-positive values, so larger violations produce
        more negative penalties.

        Parameters
        ----------
        **kwargs:
            Flattened coordinates keyed by `'x0', 'x1', ...`.

        Returns
        -------
        float
            Objective value to be maximized by `BayesianOptimization`.
        """
        X_list: List[float] = []
        for i in range(len(kwargs)):
            X_list.append(kwargs[f'x{i}'])
        X = np.array(X_list).reshape(-1, self.num_dim)

        constraint_penality: float = 0.0
        if self.transform is not None:
            X_expanded = self.transform.expand(X)
            constraint_penality = self.transform.constraints(X)
            reward = self.objective(X_expanded)  # maximize
        else:
            reward = self.objective(X)  # maximize

        # Transform constraints are typically <= 0; adding them penalizes violations.
        reward += constraint_penality
        return reward.numpy()

__init__(num_sensing, X_objective, kernel, noise_variance, transform=None, num_robots=1, X_candidates=None, num_dim=None, objective='SLogMI', **kwargs)

Initialize a Bayesian optimization-based method.

Parameters

num_sensing: Number of sensing locations per robot to optimize. X_objective: Array of shape (n, d) used to define the underlying objective. The bounds of this set are used to define the BO search space. kernel: GPflow kernel used inside the objective. noise_variance: Observation noise variance used inside the objective. transform: Optional transform applied to the candidate solution before evaluating the objective (and constraints). For example, an IPPTransform. num_robots: Number of robots / agents. Defaults to 1. X_candidates: Optional discrete candidate set of locations with shape (c, d). If provided, the final continuous solution is snapped to the nearest candidate locations. num_dim: Dimensionality of the sensing locations. If None, defaults to X_objective.shape[-1]. objective: Objective specification. Either a string key understood by get_objective (e.g. 'SLogMI', 'MI') or an already-instantiated Objective object. **kwargs: Additional keyword arguments forwarded to the objective constructor when objective is a string.

Source code in sgptools/methods.py
def __init__(self,
             num_sensing: int,
             X_objective: np.ndarray,
             kernel: gpflow.kernels.Kernel,
             noise_variance: float,
             transform: Optional[Transform] = None,
             num_robots: int = 1,
             X_candidates: Optional[np.ndarray] = None,
             num_dim: Optional[int] = None,
             objective: Union[str, Objective] = 'SLogMI',
             **kwargs: Any):
    """
    Initialize a Bayesian optimization-based method.

    Parameters
    ----------
    num_sensing:
        Number of sensing locations per robot to optimize.
    X_objective:
        Array of shape `(n, d)` used to define the underlying objective.
        The bounds of this set are used to define the BO search space.
    kernel:
        GPflow kernel used inside the objective.
    noise_variance:
        Observation noise variance used inside the objective.
    transform:
        Optional transform applied to the candidate solution before
        evaluating the objective (and constraints). For example, an
        `IPPTransform`.
    num_robots:
        Number of robots / agents. Defaults to 1.
    X_candidates:
        Optional discrete candidate set of locations with shape `(c, d)`.
        If provided, the final continuous solution is snapped to the
        nearest candidate locations.
    num_dim:
        Dimensionality of the sensing locations. If `None`, defaults to
        `X_objective.shape[-1]`.
    objective:
        Objective specification. Either a string key understood by
        `get_objective` (e.g. `'SLogMI'`, `'MI'`) or an already-instantiated
        `Objective` object.
    **kwargs:
        Additional keyword arguments forwarded to the objective constructor
        when `objective` is a string.
    """
    super().__init__(num_sensing, X_objective, kernel, noise_variance,
                     transform, num_robots, X_candidates, num_dim)
    self.transform = transform

    if isinstance(objective, str):
        self.objective = get_objective(objective)(X_objective, kernel,
                                                  noise_variance, **kwargs)
    else:
        self.objective = objective

    # Use the coordinate-wise min/max of X_objective as BO bounds
    pbounds_dims: List[Tuple[float, float]] = []
    for i in range(self.num_dim):
        pbounds_dims.append(
            (np.min(X_objective[:, i]), np.max(X_objective[:, i])))
    self.pbounds: Dict[str, Tuple[float, float]] = {}
    for i in range(self.num_dim * self.num_sensing * self.num_robots):
        self.pbounds[f'x{i}'] = pbounds_dims[i % self.num_dim]

get_hyperparameters()

Return the current kernel and noise variance used by the objective.

Returns

(gpflow.kernels.Kernel, float) A deep copy of the kernel and the current noise variance.

Source code in sgptools/methods.py
def get_hyperparameters(self) -> Tuple[gpflow.kernels.Kernel, float]:
    """
    Return the current kernel and noise variance used by the objective.

    Returns
    -------
    (gpflow.kernels.Kernel, float)
        A deep copy of the kernel and the current noise variance.
    """
    return deepcopy(self.objective.kernel), \
           self.objective.noise_variance

optimize(max_steps=50, init_points=10, verbose=False, seed=None, **kwargs)

Run Bayesian optimization to obtain informative sensing locations.

Parameters

max_steps: Number of Bayesian optimization iterations after the initial random exploration. Defaults to 50. init_points: Number of purely random evaluations before BO starts. Defaults to 10. verbose: If True, print progress messages from BayesianOptimization. seed: Optional random seed to make BO reproducible. **kwargs: Extra keyword arguments forwarded to BayesianOptimization (currently unused in this wrapper, but accepted for flexibility).

Returns

np.ndarray Array of shape (num_robots, num_sensing, num_dim) containing the optimized sensing locations in the original coordinate space.

Source code in sgptools/methods.py
def optimize(self,
             max_steps: int = 50,
             init_points: int = 10,
             verbose: bool = False,
             seed: Optional[int] = None,
             **kwargs: Any) -> np.ndarray:
    """
    Run Bayesian optimization to obtain informative sensing locations.

    Parameters
    ----------
    max_steps:
        Number of Bayesian optimization iterations after the initial random
        exploration. Defaults to 50.
    init_points:
        Number of purely random evaluations before BO starts. Defaults to 10.
    verbose:
        If `True`, print progress messages from `BayesianOptimization`.
    seed:
        Optional random seed to make BO reproducible.
    **kwargs:
        Extra keyword arguments forwarded to `BayesianOptimization`
        (currently unused in this wrapper, but accepted for flexibility).

    Returns
    -------
    np.ndarray
        Array of shape `(num_robots, num_sensing, num_dim)` containing the
        optimized sensing locations in the original coordinate space.
    """
    verbose = 1 if verbose else 0
    optimizer = BayesianOptimization(f=self._objective,
                                     pbounds=self.pbounds,
                                     verbose=verbose,
                                     random_state=seed,
                                     allow_duplicate_points=True)
    optimizer.maximize(init_points=init_points, n_iter=max_steps)

    sol: List[float] = []
    for i in range(self.num_dim * self.num_sensing * self.num_robots):
        sol.append(optimizer.max['params'][f'x{i}'])

    # Reshape BO solution to (total_points, num_dim)
    sol_np = np.array(sol).reshape(-1, self.num_dim)
    if self.transform is not None:
        # Use the transform for constraints and internal path logic,
        # but disable sensor model expansion (e.g., FoV) when returning
        # waypoint locations.
        sol_np = self.transform.expand(sol_np,
                                       expand_sensor_model=False)

        if not isinstance(sol_np, np.ndarray):
            sol_np = sol_np.numpy()

    # Optionally snap to candidate set
    if self.X_candidates is not None:
        sol_np = cont2disc(sol_np, self.X_candidates)

    sol_np = sol_np.reshape(self.num_robots, -1, self.num_dim)
    return sol_np

update(kernel, noise_variance)

Update the kernel and noise variance used by the underlying objective.

Parameters

kernel: New GPflow kernel instance. noise_variance: New observation noise variance.

Source code in sgptools/methods.py
def update(self, kernel: gpflow.kernels.Kernel,
           noise_variance: float) -> None:
    """
    Update the kernel and noise variance used by the underlying objective.

    Parameters
    ----------
    kernel:
        New GPflow kernel instance.
    noise_variance:
        New observation noise variance.
    """
    self.objective.update(kernel, noise_variance)