Skip to content

GreedySGP

sgptools.methods.GreedySGP

Bases: Method

Greedy sensing / placement using a Sparse GP (SGP) ELBO objective.

At each greedy step, candidate inducing points are selected and used to update the inducing variables of an AugmentedSGPR model, and the ELBO is evaluated. Only single-robot settings are currently supported.

Reference

  • Jakkala & Akella, 2025. Fully differentiable sensor placement and informative path planning.

Attributes

sgpr: AugmentedSGPR model whose ELBO is used as greedy objective.

Source code in sgptools/methods.py
class GreedySGP(Method):
    """
    Greedy sensing / placement using a Sparse GP (SGP) ELBO objective.

    At each greedy step, candidate inducing points are selected and used to
    update the inducing variables of an `AugmentedSGPR` model, and the ELBO
    is evaluated. Only single-robot settings are currently supported.

    Reference
    ---------
    - Jakkala & Akella, 2025. *Fully differentiable sensor placement and 
      informative path planning.*

    Attributes
    ----------
    sgpr:
        `AugmentedSGPR` model whose ELBO is used as greedy objective.
    """

    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,
                 **kwargs: Any):
        """
        Initialize a greedy SGP-based method.

        Parameters
        ----------
        num_sensing:
            Number of inducing points to select.
        X_objective:
            Array of shape `(n, d)` used as training inputs for the SGP model.
        kernel:
            GPflow kernel for the SGP model.
        noise_variance:
            Observation noise variance for the SGP model.
        transform:
            Optional `Transform` applied to inducing points inside the SGP
            model (e.g., IPP transforms).
        num_robots:
            Number of robots / agents. `GreedySGP` currently supports only
            `num_robots = 1` and will assert otherwise.
        X_candidates:
            Discrete candidate set `(c, d)`. If `None`, defaults to
            `X_objective`.
        num_dim:
            Dimensionality of sensing locations. If `None`, defaults to
            `X_objective.shape[-1]`.
        **kwargs:
            Additional keyword arguments accepted for forward compatibility
            (unused here).
        """
        super().__init__(num_sensing, X_objective, kernel, noise_variance,
                         transform, num_robots, X_candidates, num_dim)
        self.X_objective = X_objective
        if X_candidates is None:
            self.X_candidates = X_objective  # Default candidates to objective points

        if transform is not None:
            try:
                num_robots_transform = transform.num_robots
            except AttributeError:
                num_robots_transform = 1  # Assume single robot if num_robots not defined in transform
            error = f"num_robots is not equal in transform: {num_robots_transform} and GreedySGP: {self.num_robots}"
            assert self.num_robots == num_robots_transform, error

        error = f"num_robots={self.num_robots}; GreedySGP only supports num_robots=1"
        assert self.num_robots == 1, error

        # Initialize the SGP
        dtype = X_objective.dtype
        train_set: Tuple[tf.Tensor, tf.Tensor] = (tf.constant(X_objective,
                                                              dtype=dtype),
                                                  tf.zeros(
                                                      (len(X_objective), 1),
                                                      dtype=dtype))

        X_init = get_inducing_pts(X_objective, num_sensing)
        self.sgpr = AugmentedSGPR(train_set,
                                  noise_variance=noise_variance,
                                  kernel=kernel,
                                  inducing_variable=X_init,
                                  transform=transform)

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

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

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

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

    def optimize(self,
                 optimizer: str = 'naive',
                 verbose: bool = False,
                 **kwargs: Any) -> np.ndarray:
        """
        Run greedy selection using the SGP's ELBO as objective.

        Parameters
        ----------
        optimizer:
            Greedy strategy identifier passed to `apricot.CustomSelection`
            (e.g., `'naive'`, `'lazy'`).
        verbose:
            If `True`, print progress information from apricot.
        **kwargs:
            Additional keyword arguments forwarded to `CustomSelection`
            (currently unused here but accepted for flexibility).

        Returns
        -------
        np.ndarray
            Array of shape `(num_robots, num_sensing, num_dim)` containing the
            selected sensing locations.
        """
        model = CustomSelection(self.num_sensing,
                                self._objective,
                                optimizer=optimizer,
                                verbose=verbose)

        # apricot's CustomSelection expects indices, so pass a dummy array of indices
        sol_indices = model.fit_transform(
            np.arange(len(self.X_candidates)).reshape(-1, 1))
        sol_indices = np.array(sol_indices).reshape(-1).astype(int)
        sol_locations = self.X_candidates[sol_indices]

        sol_locations = np.array(sol_locations).reshape(-1, self.num_dim)
        sol_expanded = self.transform.expand(sol_locations,
                                             expand_sensor_model=False)
        if not isinstance(sol_expanded, np.ndarray):
            sol_np = sol_expanded.numpy()
        else:
            sol_np = sol_expanded

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

    def _objective(self, X_indices: np.ndarray) -> float:
        """
        Objective callback used by `apricot.CustomSelection` for greedy SGP.

        Given a (possibly partial) set of indices, this method:
        1. Maps indices to candidate locations.
        2. Pads the selection to `num_sensing` points (so the SGP remains well-defined).
        3. Updates the SGP's inducing variables.
        4. Returns the SGP ELBO for the resulting inducing set.

        Parameters
        ----------
        X_indices:
            Array of shape `(n, 1)` containing indices into `self.X_objective`
            / `self.X_candidates`.

        Returns
        -------
        float
            ELBO value of the SGP model for this inducing set, as a Python
            float. Larger values correspond to better selections.
        """
        # Map solution location indices to locations
        # Since SGP requires num_sensing points,
        # pad the current greedy solution with the
        # first location in the solution (or zeros if no points selected yet)
        X_indices_flat = np.array(X_indices).reshape(-1).astype(int)
        num_pad = self.num_sensing - len(X_indices_flat)

        # Ensure that if X_indices_flat is empty, we still create a valid padding array
        if len(X_indices_flat) == 0 and num_pad > 0:
            X_pad = np.zeros(num_pad, dtype=int)
        elif len(X_indices_flat) > 0 and num_pad > 0:
            X_pad = np.full(num_pad, X_indices_flat[0], dtype=int)
        else:  # num_pad is 0 or negative
            X_pad = np.array([], dtype=int)

        X_combined_indices = np.concatenate([X_indices_flat, X_pad])
        X_locations = self.X_objective[X_combined_indices].reshape(
            -1, self.num_dim)

        # Update the SGP inducing points
        self.sgpr.inducing_variable.Z.assign(X_locations)
        return self.sgpr.elbo().numpy()

    @property
    def transform(self) -> Transform:
        """
        Transform associated with the underlying SGP model.

        Returns
        -------
        Transform
            The `Transform` instance used by `AugmentedSGPR`.
        """
        return self.sgpr.transform

transform property

Transform associated with the underlying SGP model.

Returns

Transform The Transform instance used by AugmentedSGPR.

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

Initialize a greedy SGP-based method.

Parameters

num_sensing: Number of inducing points to select. X_objective: Array of shape (n, d) used as training inputs for the SGP model. kernel: GPflow kernel for the SGP model. noise_variance: Observation noise variance for the SGP model. transform: Optional Transform applied to inducing points inside the SGP model (e.g., IPP transforms). num_robots: Number of robots / agents. GreedySGP currently supports only num_robots = 1 and will assert otherwise. X_candidates: Discrete candidate set (c, d). If None, defaults to X_objective. num_dim: Dimensionality of sensing locations. If None, defaults to X_objective.shape[-1]. **kwargs: Additional keyword arguments accepted for forward compatibility (unused here).

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,
             **kwargs: Any):
    """
    Initialize a greedy SGP-based method.

    Parameters
    ----------
    num_sensing:
        Number of inducing points to select.
    X_objective:
        Array of shape `(n, d)` used as training inputs for the SGP model.
    kernel:
        GPflow kernel for the SGP model.
    noise_variance:
        Observation noise variance for the SGP model.
    transform:
        Optional `Transform` applied to inducing points inside the SGP
        model (e.g., IPP transforms).
    num_robots:
        Number of robots / agents. `GreedySGP` currently supports only
        `num_robots = 1` and will assert otherwise.
    X_candidates:
        Discrete candidate set `(c, d)`. If `None`, defaults to
        `X_objective`.
    num_dim:
        Dimensionality of sensing locations. If `None`, defaults to
        `X_objective.shape[-1]`.
    **kwargs:
        Additional keyword arguments accepted for forward compatibility
        (unused here).
    """
    super().__init__(num_sensing, X_objective, kernel, noise_variance,
                     transform, num_robots, X_candidates, num_dim)
    self.X_objective = X_objective
    if X_candidates is None:
        self.X_candidates = X_objective  # Default candidates to objective points

    if transform is not None:
        try:
            num_robots_transform = transform.num_robots
        except AttributeError:
            num_robots_transform = 1  # Assume single robot if num_robots not defined in transform
        error = f"num_robots is not equal in transform: {num_robots_transform} and GreedySGP: {self.num_robots}"
        assert self.num_robots == num_robots_transform, error

    error = f"num_robots={self.num_robots}; GreedySGP only supports num_robots=1"
    assert self.num_robots == 1, error

    # Initialize the SGP
    dtype = X_objective.dtype
    train_set: Tuple[tf.Tensor, tf.Tensor] = (tf.constant(X_objective,
                                                          dtype=dtype),
                                              tf.zeros(
                                                  (len(X_objective), 1),
                                                  dtype=dtype))

    X_init = get_inducing_pts(X_objective, num_sensing)
    self.sgpr = AugmentedSGPR(train_set,
                              noise_variance=noise_variance,
                              kernel=kernel,
                              inducing_variable=X_init,
                              transform=transform)

get_hyperparameters()

Return the current kernel and noise variance of the SGP model.

Returns

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

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

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

optimize(optimizer='naive', verbose=False, **kwargs)

Run greedy selection using the SGP's ELBO as objective.

Parameters

optimizer: Greedy strategy identifier passed to apricot.CustomSelection (e.g., 'naive', 'lazy'). verbose: If True, print progress information from apricot. **kwargs: Additional keyword arguments forwarded to CustomSelection (currently unused here but accepted for flexibility).

Returns

np.ndarray Array of shape (num_robots, num_sensing, num_dim) containing the selected sensing locations.

Source code in sgptools/methods.py
def optimize(self,
             optimizer: str = 'naive',
             verbose: bool = False,
             **kwargs: Any) -> np.ndarray:
    """
    Run greedy selection using the SGP's ELBO as objective.

    Parameters
    ----------
    optimizer:
        Greedy strategy identifier passed to `apricot.CustomSelection`
        (e.g., `'naive'`, `'lazy'`).
    verbose:
        If `True`, print progress information from apricot.
    **kwargs:
        Additional keyword arguments forwarded to `CustomSelection`
        (currently unused here but accepted for flexibility).

    Returns
    -------
    np.ndarray
        Array of shape `(num_robots, num_sensing, num_dim)` containing the
        selected sensing locations.
    """
    model = CustomSelection(self.num_sensing,
                            self._objective,
                            optimizer=optimizer,
                            verbose=verbose)

    # apricot's CustomSelection expects indices, so pass a dummy array of indices
    sol_indices = model.fit_transform(
        np.arange(len(self.X_candidates)).reshape(-1, 1))
    sol_indices = np.array(sol_indices).reshape(-1).astype(int)
    sol_locations = self.X_candidates[sol_indices]

    sol_locations = np.array(sol_locations).reshape(-1, self.num_dim)
    sol_expanded = self.transform.expand(sol_locations,
                                         expand_sensor_model=False)
    if not isinstance(sol_expanded, np.ndarray):
        sol_np = sol_expanded.numpy()
    else:
        sol_np = sol_expanded

    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 SGP model.

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 SGP model.

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