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.

Refer to the following paper for more details
  • Jakkala and Akella, 2025. Fully differentiable sensor placement and informative path planning.

Attributes:

Name Type Description
sgpr AugmentedSGPR

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.

    Refer to the following paper for more details:
        - Jakkala and Akella, 2025. *Fully differentiable sensor placement and 
        informative path planning.*

    Attributes:
        sgpr (AugmentedSGPR):
            `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.

        Args:
            num_sensing (int):
                Number of inducing points to select.
            X_objective (np.ndarray):
                Array of shape `(n, d)` used as training inputs for the SGP model.
            kernel (gpflow.kernels.Kernel):
                GPflow kernel for the SGP model.
            noise_variance (float):
                Observation noise variance for the SGP model.
            transform (Transform | None):
                Optional `Transform` applied to inducing points inside the SGP
                model (e.g., IPP transforms).
            num_robots (int):
                Number of robots / agents. `GreedySGP` currently supports only
                `num_robots = 1` and will assert otherwise.
            X_candidates (np.ndarray | None):
                Discrete candidate set `(c, d)`. If `None`, defaults to
                `X_objective`.
            num_dim (int | None):
                Dimensionality of sensing locations. If `None`, defaults to
                `X_objective.shape[-1]`.
            **kwargs (Any):
                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.

        Args:
            kernel (gpflow.kernels.Kernel):
                New GPflow kernel instance.
            noise_variance (float):
                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:
            Tuple[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.

        Args:
            optimizer (str):
                Greedy strategy identifier passed to `apricot.CustomSelection`
                (e.g., `'naive'`, `'lazy'`).
            verbose (bool):
                If `True`, print progress information from apricot.
            **kwargs (Any):
                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.

        Args:
            X_indices (np.ndarray):
                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:

Name Type Description
Transform 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:

Name Type Description Default
num_sensing int

Number of inducing points to select.

required
X_objective ndarray

Array of shape (n, d) used as training inputs for the SGP model.

required
kernel Kernel

GPflow kernel for the SGP model.

required
noise_variance float

Observation noise variance for the SGP model.

required
transform Transform | None

Optional Transform applied to inducing points inside the SGP model (e.g., IPP transforms).

None
num_robots int

Number of robots / agents. GreedySGP currently supports only num_robots = 1 and will assert otherwise.

1
X_candidates ndarray | None

Discrete candidate set (c, d). If None, defaults to X_objective.

None
num_dim int | None

Dimensionality of sensing locations. If None, defaults to X_objective.shape[-1].

None
**kwargs Any

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.

    Args:
        num_sensing (int):
            Number of inducing points to select.
        X_objective (np.ndarray):
            Array of shape `(n, d)` used as training inputs for the SGP model.
        kernel (gpflow.kernels.Kernel):
            GPflow kernel for the SGP model.
        noise_variance (float):
            Observation noise variance for the SGP model.
        transform (Transform | None):
            Optional `Transform` applied to inducing points inside the SGP
            model (e.g., IPP transforms).
        num_robots (int):
            Number of robots / agents. `GreedySGP` currently supports only
            `num_robots = 1` and will assert otherwise.
        X_candidates (np.ndarray | None):
            Discrete candidate set `(c, d)`. If `None`, defaults to
            `X_objective`.
        num_dim (int | None):
            Dimensionality of sensing locations. If `None`, defaults to
            `X_objective.shape[-1]`.
        **kwargs (Any):
            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:

Type Description
Tuple[Kernel, float]

Tuple[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:
        Tuple[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:

Name Type Description Default
optimizer str

Greedy strategy identifier passed to apricot.CustomSelection (e.g., 'naive', 'lazy').

'naive'
verbose bool

If True, print progress information from apricot.

False
**kwargs Any

Additional keyword arguments forwarded to CustomSelection (currently unused here but accepted for flexibility).

{}

Returns:

Type Description
ndarray

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.

    Args:
        optimizer (str):
            Greedy strategy identifier passed to `apricot.CustomSelection`
            (e.g., `'naive'`, `'lazy'`).
        verbose (bool):
            If `True`, print progress information from apricot.
        **kwargs (Any):
            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:

Name Type Description Default
kernel Kernel

New GPflow kernel instance.

required
noise_variance float

New observation noise variance.

required
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.

    Args:
        kernel (gpflow.kernels.Kernel):
            New GPflow kernel instance.
        noise_variance (float):
            New observation noise variance.
    """
    self.sgpr.update(kernel, noise_variance)