Skip to content
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `df_apply_permutation_augmentation` has a different interface and now expects
permutation groups instead of column groups
- `ParameterSelectorProtocol.__call__` now declares its input as positional-only
- `DiscreteDependenciesConstraint` now rejects causing parameters that are among their
own affected parameters

### Changed
- Transfer-learning convergence benchmarks now build the naive baseline from a
Expand All @@ -29,8 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
center, which caused `OptimizationGradientError` during recommendation
- `ThresholdCondition` with the `!=` operator no longer fails when Polars is used for
constraint filtering
- `get_parameters_by_name` now rejects a single string instead of matching parameter
names by substring

### Added
- `symmetries` attribute of `GaussianProcessSurrogate` (also accepted by
`GaussianProcessSurrogate.from_preset`) for enforcing symmetries via invariant
kernels, making the model predictions exactly invariant
- `RGPESurrogate`, a rank-weighted Gaussian process ensemble for transfer learning,
selectable directly or by setting `override_transfer_learning_mode="RGPE"` on a
`TaskParameter`
Expand Down
8 changes: 8 additions & 0 deletions baybe/constraints/discrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,13 +704,21 @@ def _validate_affected_parameters( # noqa: DOC101, DOC103
Raises:
ValueError: If one set of affected parameters does not have exactly one
condition.
ValueError: If a causing parameter is also one of its affected
parameters.
"""
if len(self.conditions) != len(value):
raise ValueError(
f"For the {self.__class__.__name__}, for each item in the "
f"affected_parameters list you must provide exactly one condition in "
f"the conditions list."
)
for causing, affected in zip(self.parameters, value):
if causing in affected:
raise ValueError(
f"For the {self.__class__.__name__}, the causing parameter "
f"'{causing}' cannot also be one of its affected parameters."
)

@property
@override
Expand Down
6 changes: 5 additions & 1 deletion baybe/recommenders/pure/bayesian/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ class BayesianRecommender(PureRecommender, ABC):
validator=deep_iterable(member_validator=instance_of(Symmetry)),
kw_only=True,
)
"""Symmetries triggering data augmentation during model fitting."""
"""Symmetries triggering data augmentation during model fitting.

For Gaussian process surrogates, symmetries can alternatively be enforced via
:attr:`baybe.surrogates.gaussian_process.core.GaussianProcessSurrogate.symmetries`.
"""

# TODO: The objective is currently only required for validating the recommendation
# context. Once multi-target support is complete, we might want to refactor
Expand Down
12 changes: 10 additions & 2 deletions baybe/searchspace/continuous.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,16 +713,24 @@ def full_factorial(self) -> pd.DataFrame:
return pd.DataFrame(index=index).reset_index()

def get_parameters_by_name(
self, names: Sequence[str]
self, names: Collection[str]
) -> tuple[NumericalContinuousParameter, ...]:
"""Return parameters with the specified names.

Args:
names: Sequence of parameter names.
names: Collection of parameter names.

Raises:
ValueError: If a single string is passed instead of a collection of names.

Returns:
The named parameters.
"""
if isinstance(names, str):
raise ValueError(
f"'names' must be a collection of parameter names, not a string. "
f"Use ('{names}',) to select a single parameter."
)
return tuple(p for p in self.parameters if p.name in names)


Expand Down
4 changes: 2 additions & 2 deletions baybe/searchspace/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,11 +519,11 @@ def transform(

return comp_rep

def get_parameters_by_name(self, names: Sequence[str]) -> tuple[Parameter, ...]:
def get_parameters_by_name(self, names: Collection[str]) -> tuple[Parameter, ...]:
"""Return parameters with the specified names.

Args:
names: Sequence of parameter names.
names: Collection of parameter names.

Returns:
The named parameters.
Expand Down
12 changes: 10 additions & 2 deletions baybe/searchspace/discrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,16 +743,24 @@ def transform(
return comp_rep

def get_parameters_by_name(
self, names: Sequence[str]
self, names: Collection[str]
) -> tuple[DiscreteParameter, ...]:
"""Return parameters with the specified names.

Args:
names: Sequence of parameter names.
names: Collection of parameter names.

Raises:
ValueError: If a single string is passed instead of a collection of names.

Returns:
The named parameters.
"""
if isinstance(names, str):
raise ValueError(
f"'names' must be a collection of parameter names, not a string. "
f"Use ('{names}',) to select a single parameter."
)
return tuple(p for p in self.parameters if p.name in names)


Expand Down
2 changes: 2 additions & 0 deletions baybe/surrogates/gaussian_process/_override/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from baybe.surrogates.gaussian_process._override.kernel import (
extract_parameter_kernel_overrides,
get_active_dimensions,
iter_gpytorch_kernel_tree,
raise_incompatible_override,
reduce_kernel_spec,
)

__all__ = [
"extract_parameter_kernel_overrides",
"get_active_dimensions",
"iter_gpytorch_kernel_tree",
"raise_incompatible_override",
"reduce_kernel_spec",
]
74 changes: 60 additions & 14 deletions baybe/surrogates/gaussian_process/_override/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Iterator
from copy import deepcopy
from typing import TYPE_CHECKING, NoReturn

Expand Down Expand Up @@ -94,8 +95,57 @@ def bind_gpytorch_override(
return result


def iter_gpytorch_kernel_tree(
kernel: GPyTorchKernel, n_columns: int, /
) -> Iterator[tuple[GPyTorchKernel, tuple[int, ...]]]:
"""Iterate over all kernels of a GPyTorch kernel tree with their input columns.

In contrast to :meth:`gpytorch.kernels.Kernel.named_sub_kernels`, each kernel is
yielded together with the columns of the full model input it actually acts on.
These are not simply the kernel's ``active_dims``, since:

* ``active_dims`` are relative to the (already sliced) input of the parent kernel.
* A :class:`~gpytorch.kernels.ScaleKernel` passes its sliced input directly to the
``forward`` method of its base kernel, bypassing the base kernel's own slicing.

Example:
For ``ProductKernel(RBFKernel(active_dims=[0]), ScaleKernel(MaternKernel(
active_dims=[1, 2])))`` on three columns, the yielded pairs are the product
kernel with ``(0, 1, 2)``, the RBF kernel with ``(0,)``, and both the scale
kernel (which adopts the ``active_dims`` of its base kernel) and the Matérn
kernel with ``(1, 2)``.

Args:
kernel: The root of the kernel tree.
n_columns: The number of columns of the full model input.

Yields:
Each kernel of the tree (in depth-first order, starting with the root) together
with the indices of the model input columns it acts on.
"""
from gpytorch.kernels import Kernel, ScaleKernel
from torch.nn import ModuleList

def _iterate(
kernel: GPyTorchKernel, columns: tuple[int, ...], sliced: bool
) -> Iterator[tuple[GPyTorchKernel, tuple[int, ...]]]:
if sliced and (active_dims := kernel.active_dims) is not None:
# TODO[typing]: GPyTorch annotates `active_dims` with the constructor's
# tuple type, but `register_buffer` stores a tensor at runtime.
columns = tuple(columns[i] for i in active_dims.tolist()) # pyrefly: ignore[missing-attribute]
yield kernel, columns
for child in kernel.children():
for sub in child if isinstance(child, ModuleList) else (child,):
if isinstance(sub, Kernel):
yield from _iterate(
sub, columns, not isinstance(kernel, ScaleKernel)
)

yield from _iterate(kernel, tuple(range(n_columns)), True)


def get_active_dimensions(kernel: GPyTorchKernel, searchspace: SearchSpace) -> set[int]:
"""Get the input columns used by a kernel, including standard composites.
"""Get the input columns used by a kernel, i.e. those of its leaf kernels.

Args:
kernel: The resolved kernel to inspect.
Expand All @@ -104,20 +154,16 @@ def get_active_dimensions(kernel: GPyTorchKernel, searchspace: SearchSpace) -> s
Returns:
The active input column indices.
"""
from gpytorch.kernels import AdditiveKernel, ProductKernel, ScaleKernel

if kernel.active_dims is not None:
# TODO[typing]: GPyTorch annotates `active_dims` with the constructor's tuple
# type, but `register_buffer` stores a tensor at runtime.
return set(kernel.active_dims.tolist()) # pyrefly: ignore[missing-attribute]
if isinstance(kernel, (AdditiveKernel, ProductKernel)):
return set().union(
# TODO[typing]: Iterating a `ModuleList` yields the `Module` base type.
*(get_active_dimensions(k, searchspace) for k in kernel.kernels) # pyrefly: ignore[bad-argument-type]
from gpytorch.kernels import Kernel

return {
column
for k, columns in iter_gpytorch_kernel_tree(
kernel, len(searchspace.comp_rep_columns)
)
if isinstance(kernel, ScaleKernel):
return get_active_dimensions(kernel.base_kernel, searchspace)
return set(range(len(searchspace.comp_rep_columns)))
if not any(isinstance(m, Kernel) for m in k.modules() if m is not k)
for column in columns
}


def reduce_kernel_spec(
Expand Down
Loading
Loading