Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ff3579e
Replace SingleTaskGP+IndexKernel with MultiTaskGP for transfer learning
Hrovatin Jun 4, 2025
175b342
Expand tests with different types of available data
Hrovatin Jun 4, 2025
678f11a
Improve the approach used to get the single task parameter
Hrovatin Jun 6, 2025
09d25c4
Correct anti-pattern in test
Hrovatin Jun 6, 2025
68de45d
Implement review comments
Hrovatin Jun 6, 2025
2bb9fc0
Clarify why single active value is required
Hrovatin Jun 6, 2025
221250b
Improve test description
Hrovatin Jun 13, 2025
80213a8
Change TaskParameter computational representation to int
Hrovatin Jun 13, 2025
1208995
Remove int conversion for new int-based comp_df of task parameter
Hrovatin Jun 13, 2025
3a80d0d
Add test for transfer learning with multiple active task parameter va…
Hrovatin Sep 25, 2025
93f422d
Remove constraint to use single active task parameter value
Hrovatin Sep 25, 2025
74833a9
Update tests and assert that multiple active values are recommended
Hrovatin Sep 25, 2025
f83f8e1
Remove mypy errors
Hrovatin Sep 25, 2025
18b081f
Remove check that both tasks were recommended as this may not always …
Hrovatin Sep 26, 2025
f6c947b
Update baybe/surrogates/gaussian_process/core.py
Hrovatin Oct 2, 2025
cb57237
Update tests/test_transfer_learning.py
Hrovatin Oct 2, 2025
8e8cbb9
Remove unnecessary comments
Hrovatin Oct 2, 2025
4b059e3
Clarify tests
Hrovatin Oct 2, 2025
c1d8946
Reuse parent method for integer casting
AdrianSosic Oct 6, 2025
57c690f
Add temporary _task_parameter property to SearchSpace class
AdrianSosic Oct 6, 2025
f8578e4
Refactor GP fitting method
AdrianSosic Oct 6, 2025
4292ddd
Refactor transfer learning tests using parametrization/fixtures
AdrianSosic Oct 6, 2025
cbb6da9
Update CHANGELOG.md
AdrianSosic Oct 6, 2025
149b4d0
Use parametrization instead of request
AdrianSosic Oct 9, 2025
5190477
Directly specify active_dims in kernel
Hrovatin Oct 15, 2025
8db6a0a
Drop unnecessary arguments
AdrianSosic Nov 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Dataframe-to-tensor conversion now yields contiguous tensors, improving
reproducibility of downstream operations
- Transfer learning now uses BoTorch's `MultiTaskGP` instead of a custom construction

### Fixed
- Random seed not entering simulation when explicitly passed to `simulate_scenarios`

## [0.14.1] - 2025-10-01
### Added
- `to_json` and `from_json` methods now also natively support (de)serialization to/from
Expand Down
7 changes: 7 additions & 0 deletions baybe/parameters/categorical.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ class TaskParameter(CategoricalParameter):
encoding: CategoricalEncoding = field(default=CategoricalEncoding.INT, init=False)
# See base class.

@override
@cached_property
def comp_df(self) -> pd.DataFrame:
# Task parameters do not enter the regular kernel computation (which operates
# on floats) but are used for indexing purposes and are thus treated as integers
return super().comp_df.astype(int)


# Collect leftover original slotted classes processed by `attrs.define`
gc.collect()
31 changes: 17 additions & 14 deletions baybe/searchspace/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,23 @@ def parameter_names(self) -> tuple[str, ...]:
"""Return tuple of parameter names."""
return self.discrete.parameter_names + self.continuous.parameter_names

@property
def _task_parameter(self) -> TaskParameter | None:
"""The (single) task parameter of the space, if it exists."""
# Currently private since only a temporary solution (--> extension to multiple
# task parameters needed)
params = [p for p in self.parameters if isinstance(p, TaskParameter)]

if not params:
return None

assert len(params) == 1 # currently ensured by parameter validation step
return params[0]

@property
def task_idx(self) -> int | None:
"""The column index of the task parameter in computational representation."""
try:
# TODO [16932]: Redesign metadata handling
task_param = next(
p for p in self.parameters if isinstance(p, TaskParameter)
)
except StopIteration:
if (task_param := self._task_parameter) is None:
return None
# TODO[11611]: The current approach has three limitations:
# 1. It matches by column name and thus assumes that the parameter name
Expand All @@ -266,15 +274,10 @@ def n_tasks(self) -> int:
# multiple task parameters, we need to align what the output should even
# represent (e.g. number of combinatorial task combinations, number of
# tasks per task parameter, etc).
try:
task_param = next(
p for p in self.parameters if isinstance(p, TaskParameter)
)
return len(task_param.values)

# When there are no task parameters, we effectively have a single task
except StopIteration:
if (task_param := self._task_parameter) is None:
# When there are no task parameters, we effectively have a single task
return 1
return len(task_param.values)

def get_comp_rep_parameter_indices(self, name: str, /) -> tuple[int, ...]:
"""Find a parameter's column indices in the computational representation.
Expand Down
79 changes: 39 additions & 40 deletions baybe/surrogates/gaussian_process/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,14 @@ def parameter_bounds(self) -> Tensor:

return torch.from_numpy(self.searchspace.scaling_bounds.values)

def get_numerical_indices(self, n_inputs: int) -> tuple[int, ...]:
"""Get the indices of the regular numerical model inputs."""
return tuple(i for i in range(n_inputs) if i != self.task_idx)
@property
def numerical_indices(self) -> tuple[int, ...]:
"""The indices of the regular numerical model inputs."""
return tuple(
i
for i in range(len(self.searchspace.comp_rep_columns))
if i != self.task_idx
)


@define
Expand All @@ -83,7 +88,7 @@ class GaussianProcessSurrogate(Surrogate):
# Note [Scaling Workaround]
# -------------------------
# For GPs, we deactivate the base class scaling and instead let the botorch
# model internally handle input/output scaling. The reasons is that we need to
# model internally handle input/output scaling. The reason is that we need to
# make `to_botorch` expose the actual botorch GP object, instead of going
# via the `AdapterModel`, because certain acquisition functions (like qNIPV)
# require the capability to `fantasize`, which the `AdapterModel` does not support.
Expand Down Expand Up @@ -146,64 +151,58 @@ def _fit(self, train_x: Tensor, train_y: Tensor) -> None:
import gpytorch
import torch

# FIXME[typing]: It seems there is currently no better way to inform the type
# checker that the attribute is available at the time of the function call
assert self._searchspace is not None

assert self._searchspace is not None # provided by base class
context = _ModelContext(self._searchspace)

numerical_idxs = context.get_numerical_indices(train_x.shape[-1])

# For GPs, we let botorch handle the scaling. See [Scaling Workaround] above.
# Input/output scaling
# NOTE: For GPs, we let BoTorch handle scaling (see [Scaling Workaround] above)
input_transform = botorch.models.transforms.Normalize(
train_x.shape[-1],
bounds=context.parameter_bounds,
indices=list(numerical_idxs),
indices=list(context.numerical_indices),
)
outcome_transform = botorch.models.transforms.Standardize(train_y.shape[-1])

# extract the batch shape of the training data
batch_shape = train_x.shape[:-2]
# Mean function
mean_module = gpytorch.means.ConstantMean()

# create GP mean
mean_module = gpytorch.means.ConstantMean(batch_shape=batch_shape)
# Covariance function
kernel = self.kernel_factory(context.searchspace, train_x, train_y)
kernel_num_dims = train_x.shape[-1] - context.n_task_dimensions
covar_module = kernel.to_gpytorch(ard_num_dims=kernel_num_dims)

# define the covariance module for the numeric dimensions
base_covar_module = self.kernel_factory(
context.searchspace, train_x, train_y
).to_gpytorch(
ard_num_dims=train_x.shape[-1] - context.n_task_dimensions,
active_dims=numerical_idxs,
batch_shape=batch_shape,
)

# create GP covariance
if not context.is_multitask:
covar_module = base_covar_module
else:
task_covar_module = gpytorch.kernels.IndexKernel(
num_tasks=context.n_tasks,
active_dims=context.task_idx,
rank=context.n_tasks, # TODO: make controllable
)
covar_module = base_covar_module * task_covar_module

# create GP likelihood
# Likelihood model
noise_prior = _default_noise_factory(context.searchspace, train_x, train_y)
likelihood = gpytorch.likelihoods.GaussianLikelihood(
noise_prior=noise_prior[0].to_gpytorch(), batch_shape=batch_shape
noise_prior=noise_prior[0].to_gpytorch()
)
likelihood.noise = torch.tensor([noise_prior[1]])

# construct and fit the Gaussian process
self._model = botorch.models.SingleTaskGP(
# Model selection
model_cls: type[botorch.models.SingleTaskGP] | type[botorch.models.MultiTaskGP]
if (task_param := context.searchspace._task_parameter) is None:
model_cls = botorch.models.SingleTaskGP
model_kwargs = {}
else:
model_cls = botorch.models.MultiTaskGP
task_comp_rep = task_param.comp_df.iloc[:, 0]
model_kwargs = {
"task_feature": context.task_idx,
"output_tasks": task_comp_rep[list(task_param.active_values)], # type: ignore[index]
"rank": context.n_tasks,
"all_tasks": task_comp_rep.to_list(),
}

# Model construction and fitting
self._model = model_cls(
train_x,
train_y,
input_transform=input_transform,
outcome_transform=outcome_transform,
mean_module=mean_module,
covar_module=covar_module,
likelihood=likelihood,
**model_kwargs, # type: ignore[arg-type]
)

# TODO: This is still a temporary workaround to avoid overfitting seen in
Expand Down
68 changes: 68 additions & 0 deletions tests/test_transfer_learning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for transfer learning."""

from typing import Literal

import pandas as pd
import pytest

from baybe import Campaign
from baybe.parameters import NumericalContinuousParameter, TaskParameter
from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender
from baybe.searchspace import SearchSpace
from baybe.targets import NumericalTarget


@pytest.fixture
def campaign(
training_data: Literal["source", "target", "both"],
active_tasks: Literal["target_only", "both"],
) -> Campaign:
"""A transfer-learning campaign with various active tasks and training data."""
assert training_data in ["source", "target", "both"]
assert active_tasks in ["target_only", "both"]

source = "B"
target = "A"
parameters = [
NumericalContinuousParameter("x", (0, 5)),
TaskParameter(
"task",
values=(target, source),
active_values=(
(target,) if active_tasks == "target_only" else (target, source)
),
),
]
searchspace = SearchSpace.from_product(parameters=parameters)
objective = NumericalTarget(name="y").to_objective()
recommender = BotorchRecommender()
lookup = pd.DataFrame(
{
"x": [1.0, 2.0, 3.0, 4.0],
"y": [1.0, 2.0, 3.0, 4.0],
"task": [target] * 2 + [source] * 2,
}
)

if training_data == "source":
lookup = lookup[lookup["task"] == source]
elif training_data == "target":
lookup = lookup[lookup["task"] == target]

campaign = Campaign(
searchspace=searchspace,
objective=objective,
recommender=recommender,
)
campaign.add_measurements(lookup)

return campaign


@pytest.mark.parametrize("active_tasks", ["target_only", "both"])
@pytest.mark.parametrize("training_data", ["source", "target", "both"])
def test_recommendation(campaign: Campaign):
"""Transfer learning recommendation works regardless of which task are
present in the training data and which tasks are active.
""" # noqa: D205
campaign.recommend(1)
Loading