From 81b55e1c46ab9fab8b03aad2a30265d25020e8c6 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Sun, 23 Jul 2023 21:09:45 -0400 Subject: [PATCH 01/20] updated agent syntax --- .github/workflows/testing.yml | 3 +- bloptools/bayesian/__init__.py | 1022 +++++++++-------- bloptools/devices.py | 50 +- bloptools/test_functions.py | 4 +- bloptools/tests/conftest.py | 49 +- bloptools/tests/test_agent.py | 13 +- bloptools/tests/test_bayesian_shadow.py | 24 +- bloptools/tests/test_bayesian_test_funcs.py | 50 - bloptools/tests/test_passive_dofs.py | 33 + bloptools/tests/test_plots.py | 10 + bloptools/utils.py | 11 +- docs/source/tutorials.rst | 2 +- .../tutorials/constrained-himmelblau.ipynb | 18 +- .../source/tutorials/custom-acquisition.ipynb | 141 +++ docs/source/tutorials/introduction.ipynb | 92 +- docs/source/tutorials/multi-task-sirepo.ipynb | 2 +- docs/source/tutorials/passive-dofs.ipynb | 149 +++ output.png | Bin 0 -> 39521 bytes 18 files changed, 1030 insertions(+), 643 deletions(-) delete mode 100644 bloptools/tests/test_bayesian_test_funcs.py create mode 100644 bloptools/tests/test_passive_dofs.py create mode 100644 bloptools/tests/test_plots.py create mode 100644 docs/source/tutorials/custom-acquisition.ipynb create mode 100644 docs/source/tutorials/passive-dofs.ipynb create mode 100644 output.png diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 183bfad..e15983c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -12,8 +12,9 @@ jobs: strategy: matrix: host-os: ["ubuntu-latest"] + python-version: ["3.9"] # host-os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10"] + # python-version: ["3.8", "3.9", "3.10"] fail-fast: false defaults: diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index c15e1d0..945687a 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -17,7 +17,7 @@ from matplotlib.patches import Patch from .. import utils -from . import acquisition, models +from . import models warnings.filterwarnings("ignore", category=botorch.exceptions.warnings.InputDataWarning) @@ -45,28 +45,82 @@ def default_digestion_plan(db, uid): MAX_TEST_INPUTS = 2**11 -AVAILABLE_ACQFS = { +ACQF_CONFIG = { "expected_mean": { "identifiers": ["em", "expected_mean"], + "pretty_name": "Expected mean", + "description": "The expected value at each input.", }, "expected_improvement": { "identifiers": ["ei", "expected_improvement"], + "pretty_name": "Expected improvement", + "description": r"The expected value of max(f(x) - \nu, 0), where \nu is the current maximum.", }, "probability_of_improvement": { "identifiers": ["pi", "probability_of_improvement"], + "pretty_name": "Probability of improvement", + "description": "The probability that this input improves on the current maximum.", }, "upper_confidence_bound": { "identifiers": ["ucb", "upper_confidence_bound"], - "default_args": {"beta": 4}, + "default_args": {"z": 2}, + "pretty_name": "Upper confidence bound", + "description": r"The expected value, plus some multiple of the uncertainty (typically \mu + 2\sigma).", }, } +TASK_TRANSFORMS = {"log": lambda x: np.log(x)} + + +def _validate_and_prepare_dofs(dofs): + for dof in dofs: + if type(dof) is not dict: + raise ValueError("Supplied dofs must be a list of dicts!") + if "device" not in dof.keys(): + raise ValueError("Each DOF must have a device!") + + dof["device"].kind = "hinted" + + if "limits" not in dof.keys(): + dof["limits"] = (-np.inf, np.inf) + dof["limits"] = tuple(np.array(dof["limits"]).astype(float)) + + # read-only DOFs (without a set method) are passive by default + dof["kind"] = dof.get("kind", "active" if hasattr(dof["device"], "set") else "passive") + if dof["kind"] not in ["active", "passive"]: + raise ValueError('DOF kinds must be one of "active" or "passive"') + + dof["mode"] = dof.get("mode", "on" if dof["kind"] == "active" else "off") + if dof["mode"] not in ["on", "off"]: + raise ValueError('DOF modes must be one of "on" or "off"') + + dof_names = [dof["device"].name for dof in dofs] + if not len(set(dof_names)) == len(dof_names): + raise ValueError("Names of DOFs must be unique!") + + return list(dofs) + + +def _validate_and_prepare_tasks(tasks): + for task in tasks: + if type(task) is not dict: + raise ValueError("Supplied tasks must be a list of dicts!") + if task["kind"] not in ["minimize", "maximize"]: + raise ValueError('"mode" must be specified as either "minimize" or "maximize"') + if "weight" not in task.keys(): + task["weight"] = 1 + + task_keys = [task["key"] for task in tasks] + if not len(set(task_keys)) == len(task_keys): + raise ValueError("Keys of tasks must be unique!") + + return list(tasks) + class Agent: def __init__( self, - active_dofs, - active_dof_bounds, + dofs, tasks, db, **kwargs, @@ -89,160 +143,56 @@ def __init__( db : A databroker instance. """ - self.active_dofs = list(np.atleast_1d(active_dofs)) - self.passive_dofs = list(np.atleast_1d(kwargs.get("passive_dofs", []))) - - for dof in self.dofs: - dof.kind = "hinted" - - self.active_dof_bounds = np.atleast_2d(active_dof_bounds).astype(float) - self.tasks = np.atleast_1d(tasks) + # DOFs are parametrized by kind ("active" or "passive") and mode ("on" or "off") + # + # below are the behaviors of DOFs of each kind and mode: + # + # "read": the agent will read the input on every acquisition (all dofs are always read) + # "move": the agent will try to set and optimize over these (there must be at least one of these) + # "input" means that the agent will use the value to make its posterior + # + # + # active passive + # +---------------------+---------------+ + # on | read, input, move | read, input | + # +---------------------+---------------+ + # off | read | read | + # +---------------------+---------------+ + # + # + + self.dofs = _validate_and_prepare_dofs(np.atleast_1d(dofs)) + self.tasks = _validate_and_prepare_tasks(np.atleast_1d(tasks)) self.db = db self.verbose = kwargs.get("verbose", False) self.ignore_acquisition_errors = kwargs.get("ignore_acquisition_errors", False) - self.initialization = kwargs.get("initialization", None) self.acquisition_plan = kwargs.get("acquisition_plan", default_acquisition_plan) self.digestion = kwargs.get("digestion", default_digestion_plan) + self.dets = list(np.atleast_1d(kwargs.get("dets", []))) - self.decoherence = kwargs.get("decoherence", False) - - self.tolerate_acquisition_errors = kwargs.get("tolerate_acquisition_errors", True) - - self.acquisition = acquisition.Acquisition() - - self.dets = np.atleast_1d(kwargs.get("detectors", [])) - - for i, task in enumerate(self.tasks): - task.index = i - - self.n_tasks = len(self.tasks) - - self.training_iter = kwargs.get("training_iter", 256) - - # make some test points for sampling - - self.normalized_test_active_inputs = utils.normalized_sobol_sampler(n=MAX_TEST_INPUTS, d=self.n_active_dofs) - - n_per_active_dim = int(np.power(MAX_TEST_INPUTS, 1 / self.n_active_dofs)) - - self.normalized_test_active_inputs_grid = np.swapaxes( - np.r_[np.meshgrid(*self.n_active_dofs * [np.linspace(0, 1, n_per_active_dim)])], 0, -1 - ) + self.acqf_config = kwargs.get("acqf_config", ACQF_CONFIG) self.table = pd.DataFrame() self._initialized = False self._train_models = True - self.a_priori_hypers = None - def normalize_active_inputs(self, x): - return (x - self.active_dof_bounds.min(axis=1)) / self.active_dof_bounds.ptp(axis=1) - - def unnormalize_active_inputs(self, x): - return x * self.active_dof_bounds.ptp(axis=1) + self.active_dof_bounds.min(axis=1) + # A note on how we transform inputs: + # + # + # Inputs can be _active_ or _passive_. We apply + # + # For passive inputs, this is more complicated. There are two ways to do this def active_inputs_sampler(self, n=MAX_TEST_INPUTS): """ Returns $n$ quasi-randomly sampled inputs in the bounded parameter space """ - return self.unnormalize_active_inputs(utils.normalized_sobol_sampler(n, self.n_active_dofs)) - - @property - def dofs(self): - return np.append(self.active_dofs, self.passive_dofs) - - @property - def n_active_dofs(self): - return len(self.active_dofs) - - @property - def n_passive_dofs(self): - return len(self.passive_dofs) - - @property - def n_dofs(self): - return self.n_active_dofs + self.n_passive_dofs - - @property - def test_active_inputs(self): - """ - A static, quasi-randomly sampled set of test active inputs. - """ - return self.unnormalize_active_inputs(self.normalized_test_active_inputs) - - @property - def test_active_inputs_grid(self): - """ - A static, gridded set of test active inputs. - """ - return self.unnormalize_active_inputs(self.normalized_test_active_inputs_grid) - - # @property - # def input_transform(self): - # return botorch.models.transforms.input.Normalize(d=self.n_dofs) - - @property - def input_transform(self): - coefficient = torch.tensor(self.dof_bounds.ptp(axis=1)).unsqueeze(0) - offset = torch.tensor(self.dof_bounds.min(axis=1)).unsqueeze(0) - return botorch.models.transforms.input.AffineInputTransform(d=self.n_dofs, coefficient=coefficient, offset=offset) - - def save_data(self, filepath="./self_data.h5"): - """ - Save the sampled inputs and targets of the self to a file, which can be used - to initialize a future self. - """ - - self.table.to_hdf(filepath, key="table") - - def forget(self, index): - self.tell(new_table=self.table.drop(index=index), append=False) - - def sampler(self, n): - """ - Returns $n$ quasi-randomly sampled points on the [0,1] ^ n_active_dof hypercube using Sobol sampling. - """ - min_power_of_two = 2 ** int(np.ceil(np.log(n) / np.log(2))) - subset = np.random.choice(min_power_of_two, size=n, replace=False) - return sp.stats.qmc.Sobol(d=self.n_active_dofs, scramble=True).random(n=min_power_of_two)[subset] - - def _set_hypers(self, hypers): - for task in self.tasks: - task.regressor.load_state_dict(hypers[task.name]) - self.classifier.load_state_dict(hypers["classifier"]) - - @property - def hypers(self): - hypers = {"classifier": {}} - for key, value in self.classifier.state_dict().items(): - hypers["classifier"][key] = value - for task in self.tasks: - hypers[task.name] = {} - for key, value in task.regressor.state_dict().items(): - hypers[task.name][key] = value - - return hypers - - def save_hypers(self, filepath): - hypers = self.hypers - with h5py.File(filepath, "w") as f: - for model_key in hypers.keys(): - f.create_group(model_key) - for param_key, param_value in hypers[model_key].items(): - f[model_key].create_dataset(param_key, data=param_value) - - @staticmethod - def load_hypers(filepath): - hypers = {} - with h5py.File(filepath, "r") as f: - for model_key in f.keys(): - hypers[model_key] = OrderedDict() - for param_key, param_value in f[model_key].items(): - hypers[model_key][param_key] = torch.tensor(np.atleast_1d(param_value[()])) - return hypers + transform = self._subset_input_transform(kind="active", mode="on") + return transform.untransform(utils.normalized_sobol_sampler(n, self._n_subset_dofs(kind="active", mode="on"))) def initialize( self, @@ -288,60 +238,58 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): """ new_table = pd.DataFrame() if new_table is None else new_table - self.table = pd.concat([self.table, new_table]) if append else new_table - - self.table.loc[:, "total_fitness"] = self.table.loc[:, self.task_names].fillna(-np.inf).sum(axis=1) self.table.index = np.arange(len(self.table)) - skew_dims = [tuple(np.arange(self.n_active_dofs))] + # self.table.loc[:, "total_fitness"] = self.table.loc[:, self.task_names].fillna(-np.inf).sum(axis=1) + + skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] if not train: hypers = self.hypers - for task in self.tasks: - task.targets = self.targets.loc[:, task.name] - - task.feasibility = self.feasible_for_all_tasks + fitnesses = self.task_fitnesses + feasibility = ~fitnesses.isna().any(axis=1) - if not task.feasibility.sum() >= 2: - raise ValueError("There must be at least two feasible data points per task!") + if not feasibility.sum() >= 2: + raise ValueError("There must be at least two feasible data points per task!") - train_inputs = torch.tensor(self.inputs.loc[task.feasibility].values).double().unsqueeze(0) - train_targets = torch.tensor(task.targets.loc[task.feasibility].values).double().unsqueeze(0).unsqueeze(-1) + inputs = self.inputs.loc[feasibility, self._subset_dof_names(mode="on")].values + train_inputs = torch.tensor(inputs).double().unsqueeze(0) - if train_inputs.ndim == 1: - train_inputs = train_inputs.unsqueeze(-1) - if train_targets.ndim == 1: - train_targets = train_targets.unsqueeze(-1) + for task in self.tasks: + targets = fitnesses.loc[feasibility, task["key"]].values + train_targets = torch.tensor(targets).double().unsqueeze(0).unsqueeze(-1) likelihood = gpytorch.likelihoods.GaussianLikelihood( noise_constraint=gpytorch.constraints.Interval( - torch.tensor(task.MIN_NOISE_LEVEL).square(), - torch.tensor(task.MAX_NOISE_LEVEL).square(), + torch.tensor(1e-6).square(), + torch.tensor(1e-2).square(), ), ).double() - task.regressor = models.LatentGP( + outcome_transform = botorch.models.transforms.outcome.Standardize(m=1, batch_shape=torch.Size((1,))) + + task["model"] = models.LatentGP( train_inputs=train_inputs, train_targets=train_targets, likelihood=likelihood, skew_dims=skew_dims, - input_transform=self.input_transform, - outcome_transform=botorch.models.transforms.outcome.Standardize(m=1, batch_shape=torch.Size((1,))), + input_transform=self._subset_input_transform(mode="on"), + outcome_transform=outcome_transform, ).double() - task.regressor_mll = gpytorch.mlls.ExactMarginalLogLikelihood(task.regressor.likelihood, task.regressor) - - log_feas_prob_weight = np.sqrt(np.sum(np.nanvar(self.targets.values, axis=0) * np.square(self.task_weights))) + log_feas_prob_weight = (self.fitness_variance * self.task_weights.square()).sum().sqrt() self.task_scalarization = botorch.acquisition.objective.ScalarizedPosteriorTransform( - weights=torch.tensor([*[task.weight for task in self.tasks], log_feas_prob_weight]).double(), + weights=torch.tensor([*self.task_weights, log_feas_prob_weight]).double(), offset=0, ) + train_classes = torch.tensor(feasibility).long() # .unsqueeze(0)#.unsqueeze(-1) + dirichlet_likelihood = gpytorch.likelihoods.DirichletClassificationLikelihood( - torch.as_tensor(self.feasible_for_all_tasks.values).long(), learn_additional_noise=True + train_classes, learn_additional_noise=True ).double() self.classifier = models.LatentDirichletClassifier( @@ -349,15 +297,9 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): train_targets=dirichlet_likelihood.transformed_targets.transpose(-1, -2).double(), skew_dims=skew_dims, likelihood=dirichlet_likelihood, - input_transform=self.input_transform, + input_transform=self._subset_input_transform(mode="on"), ).double() - self.classifier_mll = gpytorch.mlls.ExactMarginalLogLikelihood(self.classifier.likelihood, self.classifier) - - self.feas_model = botorch.models.deterministic.GenericDeterministicModel( - f=lambda X: -self.classifier.log_prob(X).square() - ) - if self.a_priori_hypers is not None: self._set_hypers(self.a_priori_hypers) elif not train: @@ -365,51 +307,235 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): else: self.train_models() - self.task_model = botorch.models.model.ModelList(*[task.regressor for task in self.tasks], self.feas_model) + feasibility_fitness_model = botorch.models.deterministic.GenericDeterministicModel( + f=lambda X: -self.classifier.log_prob(X).square() + ) + + self.model_list = botorch.models.model.ModelList(*[task["model"] for task in self.tasks], feasibility_fitness_model) + + @property + def task_fitnesses(self): + df = pd.DataFrame(index=self.table.index) + for task in self.tasks: + df.loc[:, task["key"]] = self.table.loc[:, task["key"]] + valid = (df.loc[:, task["key"]] > -np.inf) & (df.loc[:, task["key"]] < np.inf) + if "transform" in task.keys(): + if task["transform"] == "log": + valid &= df.loc[:, task["key"]] > 0 + df.loc[valid, task["key"]] = np.log(df.loc[valid, task["key"]]) + df.loc[~valid, task["key"]] = np.nan + if task["kind"] == "minimize": + df.loc[valid, task["key"]] *= -1 + return df + + def _dof_kind_mask(self, kind=None): + return [dof["kind"] == kind if kind is not None else True for dof in self.dofs] + + def _dof_mode_mask(self, mode=None): + return [dof["mode"] == mode if mode is not None else True for dof in self.dofs] + + def _dof_mask(self, kind=None, mode=None): + return [(k and m) for k, m in zip(self._dof_kind_mask(kind), self._dof_mode_mask(mode))] + + def _subset_dofs(self, kind=None, mode=None): + return [dof for dof, m in zip(self.dofs, self._dof_mask(kind, mode)) if m] + + def _n_subset_dofs(self, kind=None, mode=None): + return len(self._subset_dofs(kind, mode)) + + def _subset_devices(self, kind=None, mode=None): + return [dof["device"] for dof in self._subset_dofs(kind, mode)] + + def _read_subset_devices(self, kind=None, mode=None): + return [device.read()[device.name]["value"] for device in self._subset_devices(kind, mode)] + + def _subset_dof_names(self, kind=None, mode=None): + return [device.name for device in self._subset_devices(kind, mode)] + + def _subset_dof_limits(self, kind=None, mode=None): + dofs_subset = self._subset_dofs(kind, mode) + if len(dofs_subset) > 0: + return torch.tensor([dof["limits"] for dof in dofs_subset], dtype=torch.float64).T + return torch.empty((2, 0)) + + def test_inputs(self, n=MAX_TEST_INPUTS): + return utils.sobol_sampler(self._acqf_bounds, n=n) + + @property + def test_inputs_grid(self): + n_side = int(MAX_TEST_INPUTS ** (1 / self._n_subset_dofs(kind="active", mode="on"))) + return torch.tensor( + np.r_[ + np.meshgrid( + *[ + np.linspace(*dof["limits"], n_side) + if dof["kind"] == "active" + else dof["device"].read()[dof["device"].name]["value"] + for dof in self._subset_dofs(mode="on") + ] + ) + ] + ).swapaxes(0, -1) + + @property + def _acqf_bounds(self): + return torch.tensor( + [ + dof["limits"] if dof["kind"] == "active" else tuple(2 * [dof["device"].read()[dof["device"].name]["value"]]) + for dof in self.dofs + if dof["mode"] == "on" + ] + ).T + + @property + def n_tasks(self): + return len(self.tasks) + + @property + def det_names(self): + return [det.name for det in self.dets] + + @property + def task_keys(self): + return [task["key"] for task in self.tasks] + + @property + def task_models(self): + return [task["model"] for task in self.tasks] + + @property + def task_weights(self): + return torch.tensor([task["weight"] for task in self.tasks], dtype=torch.float64) + + @property + def task_signs(self): + return torch.tensor([(1 if task["kind"] == "maximize" else -1) for task in self.tasks], dtype=torch.long) + + def _subset_input_transform(self, kind=None, mode=None): + limits = self._subset_dof_limits(kind, mode) + offset = limits.min(dim=0).values + coefficient = limits.max(dim=0).values - offset + return botorch.models.transforms.input.AffineInputTransform( + d=limits.shape[-1], coefficient=coefficient, offset=offset + ) + + def save_data(self, filepath="./self_data.h5"): + """ + Save the sampled inputs and targets of the self to a file, which can be used + to initialize a future self. + """ + + self.table.to_hdf(filepath, key="table") + + def forget(self, index): + self.tell(new_table=self.table.drop(index=index), append=False) + + def sampler(self, n): + """ + Returns $n$ quasi-randomly sampled points on the [0,1] ^ n_active_dof hypercube using Sobol sampling. + """ + min_power_of_two = 2 ** int(np.ceil(np.log(n) / np.log(2))) + subset = np.random.choice(min_power_of_two, size=n, replace=False) + return sp.stats.qmc.Sobol(d=self._n_subset_dofs(kind="active", mode="on"), scramble=True).random(n=min_power_of_two)[ + subset + ] + + def _set_hypers(self, hypers): + for task in self.tasks: + task["model"].load_state_dict(hypers[task["key"]]) + self.classifier.load_state_dict(hypers["classifier"]) + + @property + def hypers(self): + hypers = {"classifier": {}} + for key, value in self.classifier.state_dict().items(): + hypers["classifier"][key] = value + for task in self.tasks: + hypers[task["key"]] = {} + for key, value in task["model"].state_dict().items(): + hypers[task["key"]][key] = value + + return hypers + + def save_hypers(self, filepath): + hypers = self.hypers + with h5py.File(filepath, "w") as f: + for model_key in hypers.keys(): + f.create_group(model_key) + for param_key, param_value in hypers[model_key].items(): + f[model_key].create_dataset(param_key, data=param_value) + + @staticmethod + def load_hypers(filepath): + hypers = {} + with h5py.File(filepath, "r") as f: + for model_key in f.keys(): + hypers[model_key] = OrderedDict() + for param_key, param_value in f[model_key].items(): + hypers[model_key][param_key] = torch.tensor(np.atleast_1d(param_value[()])) + return hypers + + @property + def all_task_fitnesseses_feasible(self): + return ~self.task_fitnesses.isna().any(axis=1) def train_models(self, **kwargs): t0 = ttime.monotonic() for task in self.tasks: - botorch.fit.fit_gpytorch_mll(task.regressor_mll, **kwargs) - botorch.fit.fit_gpytorch_mll(self.classifier_mll, **kwargs) + model = task["model"] + botorch.fit.fit_gpytorch_mll(gpytorch.mlls.ExactMarginalLogLikelihood(model.likelihood, model), **kwargs) + botorch.fit.fit_gpytorch_mll( + gpytorch.mlls.ExactMarginalLogLikelihood(self.classifier.likelihood, self.classifier), **kwargs + ) if self.verbose: print(f"trained models in {ttime.monotonic() - t0:.02f} seconds") + @property + def acqf_info(self): + entries = [] + for k, d in self.acqf_config.items(): + ret = "" + ret += f'{d["pretty_name"].upper()} (identifiers: {d["identifiers"]})\n' + ret += f'-> {d["description"]}' + entries.append(ret) + + print("\n\n".join(entries)) + def get_acquisition_function(self, acqf_identifier="ei", return_metadata=False, acqf_args={}, **kwargs): if not self._initialized: raise RuntimeError(f'Can\'t construct acquisition function "{acqf_identifier}" (the self is not initialized!)') - if acqf_identifier.lower() in AVAILABLE_ACQFS["expected_improvement"]["identifiers"]: + if acqf_identifier.lower() in ACQF_CONFIG["expected_improvement"]["identifiers"]: acqf = botorch.acquisition.analytic.LogExpectedImprovement( - self.task_model, - best_f=self.best_sum_of_tasks, + self.model_list, + best_f=self.scalarized_fitness.max(), posterior_transform=self.task_scalarization, **kwargs, ) acqf_meta = {"name": "expected improvement", "args": {}} - elif acqf_identifier.lower() in AVAILABLE_ACQFS["probability_of_improvement"]["identifiers"]: + elif acqf_identifier.lower() in ACQF_CONFIG["probability_of_improvement"]["identifiers"]: acqf = botorch.acquisition.analytic.LogProbabilityOfImprovement( - self.task_model, - best_f=self.best_sum_of_tasks, + self.model_list, + best_f=self.scalarized_fitness.max(), posterior_transform=self.task_scalarization, **kwargs, ) acqf_meta = {"name": "probability of improvement", "args": {}} - elif acqf_identifier.lower() in AVAILABLE_ACQFS["expected_mean"]["identifiers"]: + elif acqf_identifier.lower() in ACQF_CONFIG["expected_mean"]["identifiers"]: acqf = botorch.acquisition.analytic.UpperConfidenceBound( - self.task_model, + self.model_list, beta=0, posterior_transform=self.task_scalarization, **kwargs, ) acqf_meta = {"name": "expected mean"} - elif acqf_identifier.lower() in AVAILABLE_ACQFS["upper_confidence_bound"]["identifiers"]: - beta = AVAILABLE_ACQFS["upper_confidence_bound"]["default_args"]["beta"] + elif acqf_identifier.lower() in ACQF_CONFIG["upper_confidence_bound"]["identifiers"]: + beta = ACQF_CONFIG["upper_confidence_bound"]["default_args"]["z"] ** 2 acqf = botorch.acquisition.analytic.UpperConfidenceBound( - self.task_model, + self.model_list, beta=beta, posterior_transform=self.task_scalarization, **kwargs, @@ -423,33 +549,38 @@ def get_acquisition_function(self, acqf_identifier="ei", return_metadata=False, def ask(self, acqf_identifier="ei", n=1, route=True, return_metadata=False): if acqf_identifier.lower() == "qr": - x = self.active_inputs_sampler(n=n) + active_X = self.active_inputs_sampler(n=n).squeeze(1).numpy() acqf_meta = {"name": "quasi-random", "args": {}} elif n == 1: - x, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) - return (x, acqf_meta) if return_metadata else x + active_X, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) elif n > 1: + active_x_list = [] for i in range(n): - x, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) + active_x, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) + active_x_list.append(active_x) if i < (n - 1): - task_samples = [task.regressor.posterior(torch.tensor(x)).sample().item() for task in self.tasks] + x = np.c_[active_x, acqf_meta["passive_values"]] + task_samples = [task["model"].posterior(torch.tensor(x)).sample().item() for task in self.tasks] fantasy_table = pd.DataFrame( - np.append(x, task_samples)[None], columns=[*self.dof_names, *self.task_names] + np.c_[active_x, acqf_meta["passive_values"], np.atleast_2d(task_samples)], + columns=[ + *self._subset_dof_names(kind="active", mode="on"), + *self._subset_dof_names(kind="passive", mode="on"), + *self.task_keys, + ], ) - self.tell(fantasy_table, train=False) - - x = self.active_inputs.iloc[-n:].values + self.tell(fantasy_table, train=True) - if n > 1: - self.forget(self.table.index[-(n - 1) :]) + active_X = np.concatenate(active_x_list, axis=0) + self.forget(self.table.index[-(n - 1) :]) - if route: - x = x[utils.route(self.read_active_dofs, x)] + if route: + active_X = active_X[utils.route(self._read_subset_devices(kind="active", mode="on"), active_X)] - return (x, acqf_meta) if return_metadata else x + return (active_X, acqf_meta) if return_metadata else active_X def ask_single( self, @@ -457,7 +588,7 @@ def ask_single( return_metadata=False, ): """ - The next $n$ points to sample, recommended by the self. + The next $n$ points to sample, recommended by the self. Returns """ t0 = ttime.monotonic() @@ -470,18 +601,23 @@ def ask_single( candidates, _ = botorch.optim.optimize_acqf( acq_function=acqf, - bounds=torch.tensor(self.dof_bounds).T, + bounds=self._acqf_bounds, q=BATCH_SIZE, num_restarts=NUM_RESTARTS, raw_samples=RAW_SAMPLES, # used for intialization heuristic ) - x = candidates.detach().numpy()[..., self.dof_is_active_mask] + x = candidates.numpy().astype(float) + + active_x = x[..., [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")]] + passive_x = x[..., [dof["kind"] != "active" for dof in self._subset_dofs(mode="on")]] + + acqf_meta["passive_values"] = passive_x if self.verbose: print(f"found point {x} in {ttime.monotonic() - t0:.02f} seconds") - return (x, acqf_meta) if return_metadata else x + return (active_x, acqf_meta) if return_metadata else active_x def acquire(self, active_inputs): """ @@ -490,22 +626,27 @@ def acquire(self, active_inputs): This should yield a table of sampled tasks with the same length as the sampled inputs. """ try: - uid = yield from self.acquisition_plan(self.dofs, active_inputs, [*self.dets, *self.dofs, *self.passive_dofs]) + active_devices = [dof["device"] for dof in self.dofs if (dof["kind"], dof["mode"]) == ("active", "on")] + passive_devices = [dof["device"] for dof in self.dofs if (dof["kind"], dof["mode"]) != ("active", "on")] + + uid = yield from self.acquisition_plan( + active_devices, active_inputs.astype(float), [*self.dets, *passive_devices] + ) products = self.digestion(self.db, uid) # compute the fitness for each task - for index, entry in products.iterrows(): - for task in self.tasks: - products.loc[index, task.name] = task.get_fitness(entry) + # for index, entry in products.iterrows(): + # for task in self.tasks: + # products.loc[index, task["key"]] = getattr(entry, task["key"]) except Exception as error: - if not self.tolerate_acquisition_errors: + if not self.ignore_acquisition_errors: raise error logging.warning(f"Error in acquisition/digestion: {repr(error)}") products = pd.DataFrame(active_inputs, columns=self.active_dof_names) for task in self.tasks: - products.loc[:, task.name] = np.nan + products.loc[:, task["key"]] = np.nan if not len(active_inputs) == len(products): raise ValueError("The table returned by the digestion must be the same length as the sampled inputs!") @@ -537,239 +678,37 @@ def learn( self.tell(new_table=new_table, reuse_hypers=reuse_hypers) - def normalize_inputs(self, inputs): - return (inputs - self.input_bounds.min(axis=1)) / self.input_bounds.ptp(axis=1) - - def unnormalize_inputs(self, X): - return X * self.input_bounds.ptp(axis=1) + self.input_bounds.min(axis=1) - - def normalize_targets(self, targets): - return (targets - self.targets_mean) / (1e-20 + self.targets_scale) - - def unnormalize_targets(self, targets): - return targets * self.targets_scale + self.targets_mean - - @property - def batch_dimension(self): - return self.dof_names.index("training_batch") if "training_batch" in self.dof_names else None - - @property - def test_inputs(self): - test_passive_inputs = self.read_passive_dofs[None] * np.ones(len(self.test_active_inputs))[..., None] - return np.concatenate([self.test_active_inputs, test_passive_inputs], axis=-1) - - @property - def test_inputs_grid(self): - test_passive_inputs_grid = self.read_passive_dofs * np.ones( - (*self.test_active_inputs_grid.shape[:-1], self.n_passive_dofs) - ) - return np.concatenate([self.test_active_inputs_grid, test_passive_inputs_grid], axis=-1) - @property def inputs(self): - return self.table.loc[:, self.dof_names].astype(float) + return self.table.loc[:, self._subset_dof_names(mode="on")].astype(float) @property - def active_inputs(self): - return self.inputs.loc[:, self.active_dof_names] + def fitness_variance(self): + return torch.tensor(np.nanvar(self.task_fitnesses.values, axis=0)) @property - def passive_inputs(self): - return self.inputs.loc[:, self.passive_dof_names] - - @property - def targets(self): - return self.table.loc[:, self.task_names].astype(float) + def scalarized_fitness(self): + return (self.task_fitnesses * self.task_weights).sum(axis=1) # @property - # def feasible(self): - # with pd.option_context("mode.use_inf_as_null", True): - # feasible = ~self.targets.isna() - # return feasible - - @property - def feasible_for_all_tasks(self): - # TODO: make this more robust - # with pd.option_context("mode.use_inf_as_null", True): - feasible = ~self.targets.isna().any(axis=1) - for task in self.tasks: - if task.min is not None: - feasible &= self.targets.loc[:, task.name].values > task.transform(task.min) - return feasible - - # @property - # def input_bounds(self): - # lower_bound = np.r_[ - # self.active_dof_bounds[:, 0], np.nanmin(self.passive_inputs.astype(float).values, axis=0) - # ] - # upper_bound = np.r_[ - # self.active_dof_bounds[:, 1], np.nanmax(self.passive_inputs.astype(float).values, axis=0) - # ] - # return np.c_[lower_bound, upper_bound] - - @property - def targets_mean(self): - return np.nanmean(self.targets, axis=0) - - @property - def targets_scale(self): - return np.nanstd(self.targets, axis=0) - - @property - def normalized_targets(self): - return self.normalize_targets(self.targets) - - @property - def latest_passive_dof_values(self): - passive_inputs = self.passive_inputs - return [passive_inputs.loc[passive_inputs.last_valid_index(), col] for col in passive_inputs.columns] - - @property - def passive_dof_bounds(self): - # food for thought: should this be the current values, or the latest recorded values? - # the former leads to weird extrapolation (especially for time), and the latter to some latency. - # let's go with the second way for now - return np.outer(self.read_passive_dofs, [1.0, 1.0]) - - @property - def dof_is_active_mask(self): - return np.r_[np.ones(self.n_active_dofs), np.zeros(self.n_passive_dofs)].astype(bool) - - @property - def dof_bounds(self): - return np.r_[self.active_dof_bounds, self.passive_dof_bounds] - - @property - def read_active_dofs(self): - return np.array([dof.read()[dof.name]["value"] for dof in self.active_dofs]) - - @property - def read_passive_dofs(self): - return np.array([dof.read()[dof.name]["value"] for dof in self.passive_dofs]) - - @property - def read_dofs(self): - return np.r_[self.read_active_dofs, self.read_passive_dofs] - - @property - def active_dof_names(self): - return [dof.name for dof in self.active_dofs] - - @property - def passive_dof_names(self): - return [dof.name for dof in self.passive_dofs] - - @property - def dof_names(self): - return [dof.name for dof in self.dofs] - - @property - def det_names(self): - return [det.name for det in self.dets] - - @property - def target_names(self): - return [task.name for task in self.tasks] - - @property - def task_names(self): - return [task.name for task in self.tasks] - - @property - def task_weights(self): - return np.array([task.weight for task in self.tasks]) - - @property - def best_sum_of_tasks(self): - return self.targets.fillna(-np.inf).sum(axis=1).max() - - @property - def best_sum_of_tasks_inputs(self): - return self.inputs[np.nanargmax(self.targets.sum(axis=1))] + # def best_sum_of_tasks_inputs(self): + # return self.inputs[np.nanargmax(self.task_fitnesses.sum(axis=1))] @property def go_to(self, inputs): - yield from bps.mv(*[_ for items in zip(self.dofs, np.atleast_1d(inputs).T) for _ in items]) + yield from bps.mv(*[_ for items in zip(self._subset_dofs(kind="active"), np.atleast_1d(inputs).T) for _ in items]) - @property - def go_to_best_sum_of_tasks(self): - yield from self.go_to(self.best_sum_of_tasks_inputs) + # @property + # def go_to_best_sum_of_tasks(self): + # yield from self.go_to(self.best_sum_of_tasks_inputs) def plot_tasks(self, **kwargs): - if self.n_active_dofs == 1: + if self._n_subset_dofs(kind="active", mode="on") == 1: self._plot_tasks_one_dof(**kwargs) - else: self._plot_tasks_many_dofs(**kwargs) - def plot_feasibility(self, **kwargs): - if self.n_active_dofs == 1: - self._plot_feas_one_dof(**kwargs) - - else: - self._plot_feas_many_dofs(**kwargs) - - def plot_acquisition(self, **kwargs): - if self.n_active_dofs == 1: - self._plot_acq_one_dof(**kwargs) - - else: - self._plot_acq_many_dofs(**kwargs) - - def _plot_feas_one_dof(self, size=32): - self.class_fig, self.class_ax = plt.subplots(1, 1, figsize=(4, 4), sharex=True, constrained_layout=True) - - self.class_ax.scatter(self.inputs.values, self.feasible_for_all_tasks.astype(int), s=size) - - x = torch.tensor(self.test_inputs_grid.reshape(-1, self.n_dofs)).double() - log_prob = self.classifier.log_prob(x).detach().numpy().reshape(self.test_inputs_grid.shape[:-1]) - - self.class_ax.plot(self.test_inputs_grid.ravel(), np.exp(log_prob)) - - self.class_ax.set_xlim(*self.active_dof_bounds[0]) - - def _plot_feas_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, size=32, gridded=None): - if gridded is None: - gridded = self.n_dofs == 2 - - self.class_fig, self.class_axes = plt.subplots( - 1, 2, figsize=(8, 4), sharex=True, sharey=True, constrained_layout=True - ) - - for ax in self.class_axes.ravel(): - ax.set_xlabel(self.dofs[axes[0]].name) - ax.set_ylabel(self.dofs[axes[1]].name) - - data_ax = self.class_axes[0].scatter( - *self.inputs.values.T[:2], s=size, c=self.feasible_for_all_tasks.astype(int), vmin=0, vmax=1, cmap=cmap - ) - - if gridded: - x = torch.tensor(self.test_inputs_grid.reshape(-1, self.n_dofs)).double() - log_prob = self.classifier.log_prob(x).detach().numpy().reshape(self.test_inputs_grid.shape[:-1]) - - self.class_axes[1].pcolormesh( - *np.swapaxes(self.test_inputs_grid, 0, -1), - np.exp(log_prob).T, - shading=shading, - cmap=cmap, - vmin=0, - vmax=1, - ) - - else: - x = torch.tensor(self.test_inputs).double() - log_prob = self.classifier.log_prob(x).detach().numpy() - - self.class_axes[1].scatter(*x.detach().numpy().T[axes], s=size, c=np.exp(log_prob), vmin=0, vmax=1, cmap=cmap) - - self.class_fig.colorbar(data_ax, ax=self.class_axes[:2], location="bottom", aspect=32, shrink=0.8) - - for ax in self.class_axes.ravel(): - ax.set_xlim(*self.active_dof_bounds[axes[0]]) - ax.set_ylim(*self.active_dof_bounds[axes[1]]) - - def _plot_tasks_one_dof(self, size=32, lw=1e0): + def _plot_tasks_one_dof(self, size=16, lw=1e0): self.task_fig, self.task_axes = plt.subplots( self.n_tasks, 1, @@ -783,30 +722,35 @@ def _plot_tasks_one_dof(self, size=32, lw=1e0): for itask, task in enumerate(self.tasks): color = DEFAULT_COLOR_LIST[itask] - self.task_axes[itask].set_ylabel(task.name) + self.task_axes[itask].set_ylabel(task["key"]) - task_posterior = task.regressor.posterior(torch.tensor(self.test_inputs_grid).double()) - task_mean = task_posterior.mean.detach().numpy().ravel() - task_sigma = task_posterior.variance.sqrt().detach().numpy().ravel() + x = self.test_inputs_grid + task_posterior = task["model"].posterior(x) + task_mean = task_posterior.mean.detach().numpy() + task_sigma = task_posterior.variance.sqrt().detach().numpy() - self.task_axes[itask].scatter(self.inputs.values, task.targets, s=size, color=color) - self.task_axes[itask].plot(self.test_active_inputs_grid.ravel(), task_mean, lw=lw, color=color) + self.task_axes[itask].scatter( + self.inputs.loc[:, self._subset_dof_names(kind="active", mode="on")], + self.task_fitnesses.loc[:, task["key"]], + s=size, + color=color, + ) - for z in [1, 2]: + for z in [0, 1, 2]: self.task_axes[itask].fill_between( - self.test_inputs_grid.ravel(), - (task_mean - z * task_sigma).ravel(), - (task_mean + z * task_sigma).ravel(), + x[..., self._dof_mask(kind="active", mode="on")].squeeze(), + (task_mean - z * task_sigma).squeeze(), + (task_mean + z * task_sigma).squeeze(), lw=lw, color=color, alpha=0.5**z, ) - self.task_axes[itask].set_xlim(*self.active_dof_bounds[0]) + self.task_axes[itask].set_xlim(self._subset_dofs(kind="active", mode="on")[0]["limits"]) - def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=32): + def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16): if gridded is None: - gridded = self.n_dofs == 2 + gridded = self._n_subset_dofs(kind="active", mode="on") == 2 self.task_fig, self.task_axes = plt.subplots( self.n_tasks, @@ -818,116 +762,144 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL ) self.task_axes = np.atleast_2d(self.task_axes) - self.task_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") + # self.task_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") + + fitnesses = self.task_fitnesses for itask, task in enumerate(self.tasks): - task_norm = mpl.colors.Normalize(*np.nanpercentile(task.targets, q=[1, 99])) + task_vmin, task_vmax = np.nanpercentile(fitnesses.loc[:, task["key"]], q=[1, 99]) + task_norm = mpl.colors.Normalize(task_vmin, task_vmax) + + # if task["transform"] == "log": + # task_norm = mpl.colors.LogNorm(task_vmin, task_vmax) + # else: - self.task_axes[itask, 0].set_ylabel(task.name) + self.task_axes[itask, 0].set_ylabel(task["key"]) self.task_axes[itask, 0].set_title("samples") self.task_axes[itask, 1].set_title("posterior mean") self.task_axes[itask, 2].set_title("posterior std. dev.") data_ax = self.task_axes[itask, 0].scatter( - *self.inputs.values.T[axes], s=size, c=task.targets, norm=task_norm, cmap=cmap + *self.inputs.values.T[axes], s=size, c=fitnesses.loc[:, task["key"]], norm=task_norm, cmap=cmap ) - x = torch.tensor(self.test_inputs_grid).double() if gridded else torch.tensor(self.test_inputs).double() + x = self.test_inputs_grid.squeeze() if gridded else self.test_inputs(n=MAX_TEST_INPUTS) - task_posterior = task.regressor.posterior(x) - task_mean = task_posterior.mean.detach().numpy() # * task.targets_scale + task.targets_mean - task_sigma = task_posterior.variance.sqrt().detach().numpy() # * task.targets_scale + task_posterior = task["model"].posterior(x) + task_mean = task_posterior.mean + task_sigma = task_posterior.variance.sqrt() if gridded: + if not x.ndim == 3: + raise ValueError() self.task_axes[itask, 1].pcolormesh( - *np.swapaxes(self.test_inputs_grid, 0, -1), - task_mean.reshape(self.test_active_inputs_grid.shape[:-1]).T, + x[..., 0], + x[..., 1], + task_mean[..., 0].detach().numpy(), shading=shading, cmap=cmap, norm=task_norm, ) sigma_ax = self.task_axes[itask, 2].pcolormesh( - *np.swapaxes(self.test_inputs_grid, 0, -1), - task_sigma.reshape(self.test_inputs_grid.shape[:-1]).T, + x[..., 0], + x[..., 1], + task_sigma[..., 0].detach().numpy(), shading=shading, cmap=cmap, ) else: - self.task_axes[itask, 1].scatter(*x.detach().numpy().T[axes], s=size, c=task_mean, norm=task_norm, cmap=cmap) - sigma_ax = self.task_axes[itask, 2].scatter(*x.detach().numpy().T[axes], s=size, c=task_sigma, cmap=cmap) + self.task_axes[itask, 1].scatter( + x.detach().numpy()[..., axes[0]], + x.detach().numpy()[..., axes[1]], + s=size, + c=task_mean, + norm=task_norm, + cmap=cmap, + ) + sigma_ax = self.task_axes[itask, 2].scatter( + x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], s=size, c=task_sigma, cmap=cmap + ) self.task_fig.colorbar(data_ax, ax=self.task_axes[itask, :2], location="bottom", aspect=32, shrink=0.8) self.task_fig.colorbar(sigma_ax, ax=self.task_axes[itask, 2], location="bottom", aspect=32, shrink=0.8) for ax in self.task_axes.ravel(): - ax.set_xlim(*self.active_dof_bounds[axes[0]]) - ax.set_ylim(*self.active_dof_bounds[axes[1]]) + ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[axes[0]]["limits"]) + ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) - def _plot_acq_one_dof(self, size=32, lw=1e0, **kwargs): - acqf_names = np.atleast_1d(kwargs.get("acqf", "ei")) + def plot_acquisition(self, acqfs=["ei"], **kwargs): + if self._n_subset_dofs(kind="active", mode="on") == 1: + self._plot_acq_one_dof(acqfs=acqfs, **kwargs) + else: + self._plot_acq_many_dofs(acqfs=acqfs, **kwargs) + + def _plot_acq_one_dof(self, acqfs, lw=1e0, **kwargs): self.acq_fig, self.acq_axes = plt.subplots( 1, - len(acqf_names), - figsize=(6 * len(acqf_names), 6), + len(acqfs), + figsize=(4 * len(acqfs), 4), sharex=True, constrained_layout=True, ) self.acq_axes = np.atleast_1d(self.acq_axes) - for iacqf, acqf_name in enumerate(acqf_names): - color = DEFAULT_COLOR_LIST[0] + for iacqf, acqf_identifier in enumerate(acqfs): + color = DEFAULT_COLOR_LIST[iacqf] - acqf, acqf_meta = self.get_acquisition_function(acqf_name, return_metadata=True) + acqf, acqf_meta = self.get_acquisition_function(acqf_identifier, return_metadata=True) - *grid_shape, dim = self.test_inputs_grid.shape - x = torch.tensor(self.test_inputs_grid.reshape(-1, 1, dim)).double() - obj = acqf.forward(x) + x = self.test_inputs_grid + *input_shape, input_dim = x.shape + obj = acqf.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) - if acqf_name in ["ei", "pi"]: + if acqf_identifier in ["ei", "pi"]: obj = obj.exp() self.acq_axes[iacqf].set_title(acqf_meta["name"]) - self.acq_axes[iacqf].plot(self.test_active_inputs_grid.ravel(), obj.detach().numpy().ravel(), lw=lw, color=color) - - self.acq_axes[iacqf].set_xlim(*self.active_dof_bounds[0]) + self.acq_axes[iacqf].plot( + x[..., self._dof_mask(kind="active", mode="on")].squeeze(), obj.detach().numpy(), lw=lw, color=color + ) - def _plot_acq_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=32, **kwargs): - acqf_names = np.atleast_1d(kwargs.get("acqf", "ei")) + self.acq_axes[iacqf].set_xlim(self._subset_dofs(kind="active", mode="on")[0]["limits"]) + def _plot_acq_many_dofs( + self, acqfs, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16, **kwargs + ): self.acq_fig, self.acq_axes = plt.subplots( 1, - len(acqf_names), - figsize=(4 * len(acqf_names), 5), + len(acqfs), + figsize=(4 * len(acqfs), 4), sharex=True, sharey=True, constrained_layout=True, ) if gridded is None: - gridded = self.n_active_dofs == 2 + gridded = self._n_subset_dofs(kind="active", mode="on") == 2 self.acq_axes = np.atleast_1d(self.acq_axes) - self.acq_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") + # self.acq_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") - for iacqf, acqf_name in enumerate(acqf_names): - acqf, acqf_meta = self.get_acquisition_function(acqf_name, return_metadata=True) + x = self.test_inputs_grid.squeeze() if gridded else self.test_inputs(n=MAX_TEST_INPUTS) + *input_shape, input_dim = x.shape - if gridded: - *grid_shape, dim = self.test_inputs_grid.shape - x = torch.tensor(self.test_inputs_grid.reshape(-1, 1, dim)).double() - obj = acqf.forward(x) + for iacqf, acqf_identifier in enumerate(acqfs): + acqf, acqf_meta = self.get_acquisition_function(acqf_identifier, return_metadata=True) - if acqf_name in ["ei", "pi"]: - obj = obj.exp() + obj = acqf.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) + if acqf_identifier in ["ei", "pi"]: + obj = obj.exp() + if gridded: self.acq_axes[iacqf].set_title(acqf_meta["name"]) obj_ax = self.acq_axes[iacqf].pcolormesh( - *np.swapaxes(self.test_inputs_grid, 0, -1)[axes], - obj.detach().numpy().reshape(grid_shape).T, + x[..., 0], + x[..., 1], + obj.detach().numpy(), shading=shading, cmap=cmap, ) @@ -935,25 +907,83 @@ def _plot_acq_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLOR self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacqf], location="bottom", aspect=32, shrink=0.8) else: - *inputs_shape, dim = self.test_inputs.shape - x = torch.tensor(self.test_inputs.reshape(-1, 1, dim)).double() - obj = acqf.forward(x) - - if acqf_name in ["ei", "pi"]: - obj = obj.exp() - self.acq_axes[iacqf].set_title(acqf_meta["name"]) obj_ax = self.acq_axes[iacqf].scatter( x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], - c=obj.detach().numpy().reshape(inputs_shape), + c=obj.detach().numpy(), ) self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacqf], location="bottom", aspect=32, shrink=0.8) for ax in self.acq_axes.ravel(): - ax.set_xlim(*self.active_dof_bounds[axes[0]]) - ax.set_ylim(*self.active_dof_bounds[axes[1]]) + ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[axes[0]]["limits"]) + ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) + + def plot_feasibility(self, **kwargs): + if self._n_subset_dofs(kind="active", mode="on") == 1: + self._plot_feas_one_dof(**kwargs) + + else: + self._plot_feas_many_dofs(**kwargs) + + def _plot_feas_one_dof(self, size=16, lw=1e0): + self.feas_fig, self.feas_ax = plt.subplots(1, 1, figsize=(4, 4), sharex=True, constrained_layout=True) + + x = self.test_inputs_grid + *input_shape, input_dim = x.shape + log_prob = self.classifier.log_prob(x.reshape(-1, 1, input_dim)).reshape(input_shape) + + self.feas_ax.scatter(self.inputs.values, self.task_fitnesses.isna().any(axis=1).astype(int), s=size) + self.feas_ax.plot(x[..., self._dof_mask(kind="active", mode="on")].squeeze(), log_prob.exp().detach().numpy(), lw=lw) + + self.feas_ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[0]["limits"]) + + def _plot_feas_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, size=16, gridded=None): + self.feas_fig, self.feas_axes = plt.subplots(1, 2, figsize=(8, 4), sharex=True, sharey=True, constrained_layout=True) + + if gridded is None: + gridded = self._n_subset_dofs(kind="active", mode="on") == 2 + + data_ax = self.feas_axes[0].scatter( + *self.inputs.values.T[:2], + s=size, + c=self.task_fitnesses.isna().any(axis=1).astype(int), + vmin=0, + vmax=1, + cmap=cmap, + ) + + x = self.test_inputs_grid.squeeze() if gridded else self.test_inputs(n=MAX_TEST_INPUTS) + *input_shape, input_dim = x.shape + log_prob = self.classifier.log_prob(x.reshape(-1, 1, input_dim)).reshape(input_shape) + + if gridded: + self.feas_axes[1].pcolormesh( + x[..., 0], + x[..., 1], + log_prob.exp().detach().numpy(), + shading=shading, + cmap=cmap, + vmin=0, + vmax=1, + ) + + # self.acq_fig.colorbar(obj_ax, ax=self.feas_axes[iacqf], location="bottom", aspect=32, shrink=0.8) + + else: + # self.feas_axes.set_title(acqf_meta["name"]) + self.feas_axes[1].scatter( + x.detach().numpy()[..., axes[0]], + x.detach().numpy()[..., axes[1]], + c=log_prob.exp().detach().numpy(), + ) + + self.feas_fig.colorbar(data_ax, ax=self.feas_axes[:2], location="bottom", aspect=32, shrink=0.8) + + for ax in self.feas_axes.ravel(): + ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[axes[0]]["limits"]) + ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) def inspect_beam(self, index, border=None): im = self.images[index] @@ -993,10 +1023,10 @@ def plot_history(self, x_key="index", show_all_tasks=False): if show_all_tasks: for itask, task in enumerate(self.tasks): - y = task.targets.values + y = self.task_fitnesses.loc[:, task["key"]].values hist_axes[itask].scatter(x, y, c=sample_colors) hist_axes[itask].plot(x, y, lw=5e-1, c="k") - hist_axes[itask].set_ylabel(task.name) + hist_axes[itask].set_ylabel(task["key"]) y = self.table.total_fitness diff --git a/bloptools/devices.py b/bloptools/devices.py index 5840fbd..5440113 100644 --- a/bloptools/devices.py +++ b/bloptools/devices.py @@ -1,28 +1,49 @@ import time as ttime -from ophyd import Component as Cpt -from ophyd import Device, Signal, SignalRO +import numpy as np +from ophyd import Signal, SignalRO +DEFAULT_BOUNDS = (-5.0, +5.0) -def dummy_dof(name): - return Signal(name=name, value=0.0) +class DOF(Signal): + """ + Degree of freedom + """ -def dummy_dofs(n=2): - return [dummy_dof(name=f"x{i+1}") for i in range(n)] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) -def get_dummy_device(name="dofs", n=2): - components = {} +class RODOF(DOF): + """ + Read-only degree of freedom + """ - for i in range(n): - components[f"x{i+1}"] = Cpt(Signal, value=i + 1) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - cls = type("DOF", (Device,), components) - device = cls(name=name) +class BrownianMotion(RODOF): + """ + Read-only degree of freedom simulating brownian motion + """ + + def __init__(self, theta=0.95, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.theta = theta + self.old_t = ttime.monotonic() + self.old_y = 0.0 - return [getattr(device, attr) for attr in device.read_attrs] + def get(self): + new_t = ttime.monotonic() + alpha = self.theta ** (new_t - self.old_t) + new_y = alpha * self.old_y + np.sqrt(1 - alpha**2) * np.random.standard_normal() + + self.old_t = new_t + self.old_y = new_y + return new_y class TimeReadback(SignalRO): @@ -33,9 +54,6 @@ class TimeReadback(SignalRO): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def get(self): - return ttime.time() - class ConstantReadback(SignalRO): """ diff --git a/bloptools/test_functions.py b/bloptools/test_functions.py index 9b1983e..d935eea 100644 --- a/bloptools/test_functions.py +++ b/bloptools/test_functions.py @@ -14,9 +14,7 @@ def himmelblau(x1, x2): def constrained_himmelblau(x1, x2): - if x1**2 + x2**2 > 50: - return np.nan - return himmelblau(x1, x2) + return np.where(x1**2 + x2**2 < 50, himmelblau(x1, x2), np.nan) def skewed_himmelblau(x1, x2): diff --git a/bloptools/tests/conftest.py b/bloptools/tests/conftest.py index 9e23e8c..a02f9ca 100644 --- a/bloptools/tests/conftest.py +++ b/bloptools/tests/conftest.py @@ -13,7 +13,6 @@ from sirepo_bluesky.srw_handler import SRWFileHandler from bloptools.bayesian import Agent -from bloptools.tasks import Task from .. import devices, test_functions @@ -57,27 +56,57 @@ def RE(db): @pytest.fixture(scope="function") def agent(db): """ - A simple agent maximizing two Styblinski-Tang functions + A simple agent minimizing Himmelblau's function """ - dofs = devices.dummy_dofs(n=2) - bounds = [(-5, 5), (-5, 5)] + dofs = [ + {"device": devices.DOF(name="x1"), "limits": (-5, 5), "kind": "active"}, + {"device": devices.DOF(name="x2"), "limits": (-5, 5), "kind": "active"}, + ] + + tasks = [ + {"key": "himmelblau", "kind": "minimize"}, + ] + + agent = Agent( + dofs=dofs, + tasks=tasks, + digestion=test_functions.himmelblau_digestion, + db=db, + verbose=True, + tolerate_acquisition_errors=False, + ) + + return agent + + +@pytest.fixture(scope="function") +def multitask_agent(db): + """ + A simple agent minimizing two Styblinski-Tang functions + """ def digestion(db, uid): products = db[uid].table() for index, entry in products.iterrows(): - products.loc[index, "loss1"] = test_functions.styblinski_tang(entry.x1, entry.x2) - products.loc[index, "loss2"] = test_functions.styblinski_tang(entry.x1, -entry.x2) + products.loc[index, "ST1"] = test_functions.styblinski_tang(entry.x1, entry.x2) + products.loc[index, "ST2"] = test_functions.styblinski_tang(entry.x1, -entry.x2) return products - tasks = [Task(key="loss1", kind="min"), Task(key="loss2", kind="min")] + dofs = [ + {"device": devices.DOF(name="x1"), "limits": (-5, 5), "kind": "active"}, + {"device": devices.DOF(name="x2"), "limits": (-5, 5), "kind": "active"}, + ] + + tasks = [ + {"key": "ST1", "kind": "minimize"}, + {"key": "ST2", "kind": "minimize"}, + ] agent = Agent( - active_dofs=dofs, - passive_dofs=[], - active_dof_bounds=bounds, + dofs=dofs, tasks=tasks, digestion=digestion, db=db, diff --git a/bloptools/tests/test_agent.py b/bloptools/tests/test_agent.py index 2d660b3..66657ce 100644 --- a/bloptools/tests/test_agent.py +++ b/bloptools/tests/test_agent.py @@ -3,7 +3,7 @@ import pytest -@pytest.mark.agent +@pytest.mark.test_func def test_writing_hypers(RE, agent): RE(agent.initialize("qr", n_init=32)) @@ -12,3 +12,14 @@ def test_writing_hypers(RE, agent): RE(agent.initialize("qr", n_init=8, hypers="hypers.h5")) os.remove("hypers.h5") + + +@pytest.mark.test_func +def test_writing_hypers_multitask(RE, multitask_agent): + RE(multitask_agent.initialize("qr", n_init=32)) + + multitask_agent.save_hypers("hypers.h5") + + RE(multitask_agent.initialize("qr", n_init=8, hypers="hypers.h5")) + + os.remove("hypers.h5") diff --git a/bloptools/tests/test_bayesian_shadow.py b/bloptools/tests/test_bayesian_shadow.py index d281582..9e7f899 100644 --- a/bloptools/tests/test_bayesian_shadow.py +++ b/bloptools/tests/test_bayesian_shadow.py @@ -1,10 +1,8 @@ -import numpy as np import pytest from sirepo_bluesky.sirepo_ophyd import create_classes import bloptools from bloptools.experiments.sirepo.tes import w9_digestion -from bloptools.tasks import Task @pytest.mark.shadow @@ -16,19 +14,21 @@ def test_bayesian_agent_tes_shadow(RE, db, shadow_tes_simulation): data["models"]["simulation"]["npoint"] = 100000 data["models"]["watchpointReport12"]["histogramBins"] = 32 - kb_dofs = [kbv.x_rot, kbv.offz] - kb_bounds = np.array([[-0.10, +0.10], [-0.50, +0.50]]) + dofs = [ + {"device": kbv.x_rot, "limits": (-0.1, 0.1), "kind": "active"}, + {"device": kbv.offz, "limits": (-0.5, 0.5), "kind": "active"}, + ] - beam_flux_task = Task(key="flux", kind="max", transform=lambda x: np.log(x)) - beam_width_task = Task(key="x_width", kind="min", transform=lambda x: np.log(x)) - beam_height_task = Task(key="y_width", kind="min", transform=lambda x: np.log(x)) + tasks = [ + {"key": "flux", "kind": "maximize"}, + {"key": "w9_fwhm_x", "kind": "minimize"}, + {"key": "w9_fwhm_y", "kind": "minimize"}, + ] agent = bloptools.bayesian.Agent( - active_dofs=kb_dofs, - passive_dofs=[], - detectors=[w9], - active_dof_bounds=kb_bounds, - tasks=[beam_flux_task, beam_width_task, beam_height_task], + dofs=dofs, + tasks=tasks, + dets=[w9], digestion=w9_digestion, db=db, ) diff --git a/bloptools/tests/test_bayesian_test_funcs.py b/bloptools/tests/test_bayesian_test_funcs.py deleted file mode 100644 index 04eeb55..0000000 --- a/bloptools/tests/test_bayesian_test_funcs.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest - -import bloptools -from bloptools.tasks import Task -from bloptools.test_functions import himmelblau_digestion, mock_kbs_digestion - - -@pytest.mark.test_func -def test_bayesian_agent_himmelblau(RE, db): - dofs = bloptools.devices.dummy_dofs(n=2) # get a list of two DOFs - bounds = [(-5.0, +5.0), (-5.0, +5.0)] - task = Task(key="himmelblau", kind="min") - - agent = bloptools.bayesian.Agent( - active_dofs=dofs, - passive_dofs=[], - active_dof_bounds=bounds, - tasks=[task], - digestion=himmelblau_digestion, - db=db, - ) - - RE(agent.initialize("qr", n_init=16)) - - RE(agent.learn("ei", n_iter=2)) - - agent.plot_tasks() - - -@pytest.mark.test_func -def test_bayesian_agent_mock_kbs(RE, db): - dofs = bloptools.devices.dummy_dofs(n=4) # get a list of two DOFs - bounds = [(-4.0, +4.0), (-4.0, +4.0), (-4.0, +4.0), (-4.0, +4.0)] - - tasks = [Task(key="x_width", kind="min"), Task(key="y_width", kind="min")] - - agent = bloptools.bayesian.Agent( - active_dofs=dofs, - passive_dofs=[], - active_dof_bounds=bounds, - tasks=tasks, - digestion=mock_kbs_digestion, - db=db, - ) - - RE(agent.initialize("qr", n_init=16)) - - RE(agent.learn("ei", n_iter=4)) - - agent.plot_tasks() diff --git a/bloptools/tests/test_passive_dofs.py b/bloptools/tests/test_passive_dofs.py new file mode 100644 index 0000000..f170a79 --- /dev/null +++ b/bloptools/tests/test_passive_dofs.py @@ -0,0 +1,33 @@ +import pytest + +from bloptools import devices, test_functions +from bloptools.bayesian import Agent + + +@pytest.mark.test_func +def test_passive_dofs(RE, db): + dofs = [ + {"device": devices.DOF(name="x1"), "limits": (-5, 5), "kind": "active"}, + {"device": devices.DOF(name="x2"), "limits": (-5, 5), "kind": "active"}, + {"device": devices.BrownianMotion(name="brownian1"), "limits": (-2, 2), "kind": "passive"}, + {"device": devices.BrownianMotion(name="brownian2"), "limits": (-2, 2), "kind": "passive"}, + ] + + tasks = [ + {"key": "himmelblau", "kind": "minimize"}, + ] + + agent = Agent( + dofs=dofs, + tasks=tasks, + digestion=test_functions.himmelblau_digestion, + db=db, + verbose=True, + tolerate_acquisition_errors=False, + ) + + RE(agent.initialize("qr", n_init=32)) + + agent.plot_tasks() + agent.plot_acquisition() + agent.plot_feasibility() diff --git a/bloptools/tests/test_plots.py b/bloptools/tests/test_plots.py new file mode 100644 index 0000000..4675b01 --- /dev/null +++ b/bloptools/tests/test_plots.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.mark.test_func +def test_plots(RE, agent): + RE(agent.initialize("qr", n_init=32)) + + agent.plot_tasks() + agent.plot_acquisition() + agent.plot_feasibility() diff --git a/bloptools/utils.py b/bloptools/utils.py index 492ef5c..de1b90c 100644 --- a/bloptools/utils.py +++ b/bloptools/utils.py @@ -5,12 +5,19 @@ from ortools.constraint_solver import pywrapcp, routing_enums_pb2 +def sobol_sampler(bounds, n, q=1): + """ + Returns $n$ quasi-randomly sampled points within the bounds. + """ + return botorch.utils.sampling.draw_sobol_samples(bounds, n=n, q=q) + + def normalized_sobol_sampler(n, d): """ Returns $n$ quasi-randomly sampled points in the [0,1]^d hypercube """ - x = botorch.utils.sampling.draw_sobol_samples(torch.outer(torch.tensor([0, 1]), torch.ones(d)), n=n, q=1) - return x.squeeze(1).detach().numpy() + normalized_bounds = torch.outer(torch.tensor([0, 1]), torch.ones(d)) + return sobol_sampler(normalized_bounds, n=n, q=1) def estimate_root_indices(x): diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 48218c9..f93b95d 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -5,7 +5,7 @@ Tutorials :maxdepth: 2 tutorials/introduction.ipynb - tutorials/hyperparameters.ipynb tutorials/constrained-himmelblau.ipynb + tutorials/hyperparameters.ipynb tutorials/latent-toroid-dimensions.ipynb tutorials/multi-task-sirepo.ipynb diff --git a/docs/source/tutorials/constrained-himmelblau.ipynb b/docs/source/tutorials/constrained-himmelblau.ipynb index 0a36267..9d7b2f4 100644 --- a/docs/source/tutorials/constrained-himmelblau.ipynb +++ b/docs/source/tutorials/constrained-himmelblau.ipynb @@ -36,8 +36,7 @@ "from bloptools.tasks import Task\n", "\n", "task = Task(key=\"himmelblau\", kind=\"min\")\n", - "F = test_functions.himmelblau(X1, X2)\n", - "F[X1**2 + X2**2 > 50] = np.nan\n", + "F = test_functions.constrained_himmelblau(X1, X2)\n", "\n", "plt.pcolormesh(x1, x2, F, norm=mpl.colors.LogNorm(), shading=\"auto\")\n", "plt.colorbar()\n", @@ -65,7 +64,7 @@ " products = db[uid].table()\n", "\n", " for index, entry in products.iterrows():\n", - " products.loc[index, \"himmelblau\"] = test_functions.constrained_himmelblau(entry.x1, entry.x2)\n", + " products.loc[index, \"loss\"] = test_functions.constrained_himmelblau(entry.x1, entry.x2)\n", "\n", " return products" ] @@ -93,21 +92,18 @@ "import bloptools\n", "from bloptools.tasks import Task\n", "\n", - "dofs = bloptools.devices.dummy_dofs(n=2)\n", - "bounds = [(-10, 10), (-10, 10)]\n", + "dofs = bloptools.devices.dummy_dofs(n=2, bounds=(-10, 10))\n", "\n", - "task = Task(key=\"himmelblau\", kind=\"min\")\n", + "task = {\"key\": \"loss\", \"kind\": \"minimize\"}\n", "\n", "agent = bloptools.bayesian.Agent(\n", - " active_dofs=dofs,\n", - " passive_dofs=[],\n", - " active_dof_bounds=bounds,\n", - " tasks=[task],\n", + " dofs=dofs,\n", + " tasks=task,\n", " digestion=digestion,\n", " db=db,\n", ")\n", "\n", - "RE(agent.initialize(\"qr\", n_init=32))\n", + "RE(agent.initialize(\"qr\", n_init=64))\n", "\n", "agent.plot_tasks()" ] diff --git a/docs/source/tutorials/custom-acquisition.ipynb b/docs/source/tutorials/custom-acquisition.ipynb new file mode 100644 index 0000000..1d318be --- /dev/null +++ b/docs/source/tutorials/custom-acquisition.ipynb @@ -0,0 +1,141 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "e7b5e13a-c059-441d-8d4f-fff080d52054", + "metadata": {}, + "source": [ + "# Multi-task optimization of KB mirrors\n", + "\n", + "Often, we want to optimize multiple aspects of a system; in this real-world example aligning the Kirkpatrick-Baez mirrors at the TES beamline's endstation, we care about the horizontal and vertical beam size, as well as the flux. \n", + "\n", + "We could try to model these as a single task by combining them into a single number (i.e., optimization the beam density as flux divided by area), but our model then loses all information about how different inputs affect different outputs. We instead give the optimizer multiple \"tasks\", and then direct it based on its prediction of those tasks. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa8a6989", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%run -i ../../../examples/prepare_bluesky.py\n", + "%run -i ../../../examples/prepare_tes_shadow.py\n", + "\n", + "kb_dofs = [kbv.x_rot, kbh.x_rot]\n", + "kb_bounds = np.array([[-0.10, +0.10], [-0.10, +0.10]])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "071a829f-a390-40dc-9d5b-ae75702e119e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from bloptools.bayesian import Agent\n", + "from bloptools.experiments.sirepo.tes import w9_digestion\n", + "from bloptools.tasks import Task\n", + "\n", + "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", + "beam_width_task = Task(key=\"x_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "beam_height_task = Task(key=\"y_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "\n", + "agent = Agent(\n", + " active_dofs=kb_dofs,\n", + " active_dof_bounds=kb_bounds,\n", + " detectors=[w9],\n", + " tasks=[beam_flux_task, beam_width_task, beam_height_task],\n", + " digestion=w9_digestion,\n", + " db=db,\n", + ")\n", + "\n", + "RE(agent.initialize(\"qr\", n_init=16))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "a6259a4f", + "metadata": {}, + "source": [ + "For each task, we plot the sampled data and the model's posterior with respect to two inputs to the KB mirrors. We can see that each tasks responds very differently to different motors, which is very useful to the optimizer. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "996c3c01-f91d-4a25-9b8d-eba5fa964504", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "agent.plot_tasks()\n", + "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "296d9fd2", + "metadata": {}, + "source": [ + "We should find our optimum (or something close to it) on the very next iteration:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6b39b54", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "RE(agent.learn(\"ei\", n_iter=2))\n", + "agent.plot_tasks()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "e23e920c", + "metadata": {}, + "source": [ + "The agent has learned that certain dimensions affect different tasks differently!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.16" + }, + "vscode": { + "interpreter": { + "hash": "9aced674e98d511b4f654e147532c84d38dc986fe042b1e92785fb9d8df41f75" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/tutorials/introduction.ipynb b/docs/source/tutorials/introduction.ipynb index c960697..90e786e 100644 --- a/docs/source/tutorials/introduction.ipynb +++ b/docs/source/tutorials/introduction.ipynb @@ -35,7 +35,8 @@ "\n", "x = np.linspace(-5, 5, 256)\n", "\n", - "plt.plot(x, test_functions.ackley(x), c=\"b\")" + "plt.plot(x, test_functions.styblinski_tang(x), c=\"b\")\n", + "plt.xlim(-5, 5)" ] }, { @@ -54,66 +55,67 @@ "metadata": {}, "outputs": [], "source": [ - "import bloptools\n", + "from bloptools import devices\n", "\n", - "dofs = bloptools.devices.dummy_dofs(n=1) # an ophyd device that we can read/set\n", - "\n", - "bounds = [(-4.0, 4.0)] # one set of bounds per dof" + "dofs = devices.dummy_dofs(n=1, bounds=(-5, 5))\n", + "dofs" ] }, { - "attachments": {}, - "cell_type": "markdown", - "id": "7a88c7bd", + "cell_type": "code", + "execution_count": null, + "id": "c8556bc9", "metadata": {}, + "outputs": [], "source": [ - "This degree of freedom will move around a variable called `x1`. The agent automatically samples at different inputs, but we often need some post-processing after data collection. In this case, we need to give the agent a way to compute the Rastrigin function. We accomplish this with a digestion function, which always takes `(db, uid)` as an input. For each entry, we compute the function:\n" + "tuple(np.array((2, 2)).astype(float))" ] }, { "cell_type": "code", "execution_count": null, - "id": "e6bfcf73", + "id": "a1543320", "metadata": {}, "outputs": [], "source": [ - "def digestion(db, uid):\n", - " products = db[uid].table()\n", - "\n", - " for index, entry in products.iterrows():\n", - " products.loc[index, \"ackley\"] = test_functions.ackley(entry.x1)\n", - "\n", - " return products" + "tasks = [\n", + " {\"name\": \"loss\", \"kind\": \"minimize\", \"transform\": \"log\"},\n", + "]" ] }, { "attachments": {}, "cell_type": "markdown", - "id": "dad64303", + "id": "7a88c7bd", "metadata": {}, "source": [ - "The next ingredient is a task, which gives the agent something to do. We want it to minimize the Rastrigin function, so we make a task that will try to minimize the output of the digestion function called \"rastrigin\"." + "\n", + "This degree of freedom will move around a variable called `x1`. The agent automatically samples at different inputs, but we often need some post-processing after data collection. In this case, we need to give the agent a way to compute the Rastrigin function. We accomplish this with a digestion function, which always takes `(db, uid)` as an input. For each entry, we compute the function:\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "4c14d162", + "id": "e6bfcf73", "metadata": {}, "outputs": [], "source": [ - "from bloptools.tasks import Task\n", + "def digestion(db, uid):\n", + " products = db[uid].table()\n", "\n", - "task = Task(key=\"ackley\", kind=\"min\")" + " for index, entry in products.iterrows():\n", + " products.loc[index, \"loss\"] = test_functions.styblinski_tang(entry.x1)\n", + "\n", + " return products" ] }, { "attachments": {}, "cell_type": "markdown", - "id": "0d3d91c3", + "id": "dad64303", "metadata": {}, "source": [ - "Combining all of these with a databroker instance, we can make an agent:" + "The next ingredient is a task, which gives the agent something to do. We want it to minimize the Rastrigin function, so we make a task that will try to minimize the output of the digestion function called \"rastrigin\"." ] }, { @@ -127,11 +129,11 @@ "source": [ "%run -i ../../../examples/prepare_bluesky.py # prepare the bluesky environment\n", "\n", - "agent = bloptools.bayesian.Agent(\n", - " active_dofs=dofs,\n", - " passive_dofs=[],\n", - " active_dof_bounds=bounds,\n", - " tasks=[task],\n", + "from bloptools.bayesian import Agent\n", + "\n", + "agent = Agent(\n", + " dofs=dofs,\n", + " tasks=tasks,\n", " digestion=digestion,\n", " db=db,\n", ")\n", @@ -175,6 +177,26 @@ "We can see what the agent is thinking by asking it to plot a few different acquisition functions in its current state." ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "589a263b", + "metadata": {}, + "outputs": [], + "source": [ + "agent.acqf_info" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4efee6aa", + "metadata": {}, + "outputs": [], + "source": [ + "agent._acqf_bounds" + ] + }, { "cell_type": "code", "execution_count": null, @@ -186,7 +208,7 @@ "source": [ "# helper function to list acquisition functions\n", "\n", - "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acquisition(acqfs=[\"ei\", \"pi\", \"ucb\"])" ] }, { @@ -205,17 +227,9 @@ "metadata": {}, "outputs": [], "source": [ - "RE(agent.learn(\"ei\", n_per_iter=4))\n", + "RE(agent.learn(\"ei\"))\n", "agent.plot_tasks()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "87908041", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/source/tutorials/multi-task-sirepo.ipynb b/docs/source/tutorials/multi-task-sirepo.ipynb index 1d318be..e740543 100644 --- a/docs/source/tutorials/multi-task-sirepo.ipynb +++ b/docs/source/tutorials/multi-task-sirepo.ipynb @@ -128,7 +128,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.16" + "version": "3.9.16 (main, Mar 8 2023, 14:00:05) \n[GCC 11.2.0]" }, "vscode": { "interpreter": { diff --git a/docs/source/tutorials/passive-dofs.ipynb b/docs/source/tutorials/passive-dofs.ipynb new file mode 100644 index 0000000..17c6216 --- /dev/null +++ b/docs/source/tutorials/passive-dofs.ipynb @@ -0,0 +1,149 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "e7b5e13a-c059-441d-8d4f-fff080d52054", + "metadata": {}, + "source": [ + "# Multi-task optimization of KB mirrors\n", + "\n", + "Often, we want to optimize multiple aspects of a system; in this real-world example aligning the Kirkpatrick-Baez mirrors at the TES beamline's endstation, we care about the horizontal and vertical beam size, as well as the flux. \n", + "\n", + "We could try to model these as a single task by combining them into a single number (i.e., optimization the beam density as flux divided by area), but our model then loses all information about how different inputs affect different outputs. We instead give the optimizer multiple \"tasks\", and then direct it based on its prediction of those tasks. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa8a6989", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%run -i ../../../examples/prepare_bluesky.py\n", + "%run -i ../../../examples/prepare_tes_shadow.py\n", + "\n", + "kb_dofs = [kbv.x_rot, kbh.x_rot]\n", + "kb_bounds = np.array([[-0.10, +0.10], [-0.10, +0.10]])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "716969ac", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "071a829f-a390-40dc-9d5b-ae75702e119e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from bloptools.bayesian import Agent\n", + "from bloptools.experiments.sirepo.tes import w9_digestion\n", + "from bloptools.tasks import Task\n", + "\n", + "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", + "beam_width_task = Task(key=\"x_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "beam_height_task = Task(key=\"y_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "\n", + "agent = Agent(\n", + " active_dofs=kb_dofs,\n", + " active_dof_bounds=kb_bounds,\n", + " detectors=[w9],\n", + " tasks=[beam_flux_task, beam_width_task, beam_height_task],\n", + " digestion=w9_digestion,\n", + " db=db,\n", + ")\n", + "\n", + "RE(agent.initialize(\"qr\", n_init=16))" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "a6259a4f", + "metadata": {}, + "source": [ + "For each task, we plot the sampled data and the model's posterior with respect to two inputs to the KB mirrors. We can see that each tasks responds very differently to different motors, which is very useful to the optimizer. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "996c3c01-f91d-4a25-9b8d-eba5fa964504", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "agent.plot_tasks()\n", + "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "296d9fd2", + "metadata": {}, + "source": [ + "We should find our optimum (or something close to it) on the very next iteration:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6b39b54", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "RE(agent.learn(\"ei\", n_iter=2))\n", + "agent.plot_tasks()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "e23e920c", + "metadata": {}, + "source": [ + "The agent has learned that certain dimensions affect different tasks differently!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.16" + }, + "vscode": { + "interpreter": { + "hash": "9aced674e98d511b4f654e147532c84d38dc986fe042b1e92785fb9d8df41f75" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/output.png b/output.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba8c8645cf31797b38b8e858222d916b42cea31 GIT binary patch literal 39521 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

{Y&65#vaz*btt;P79 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 H6!1R)sQdn> literal 0 HcmV?d00001 From e39e48e82bf6095db5520555a126260dadc44f6e Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Sun, 23 Jul 2023 21:54:56 -0400 Subject: [PATCH 02/20] updated notebooks --- bloptools/bayesian/__init__.py | 21 +- docs/source/tutorials.rst | 1 + .../tutorials/constrained-himmelblau.ipynb | 19 +- .../source/tutorials/custom-acquisition.ipynb | 141 -------- docs/source/tutorials/hyperparameters.ipynb | 47 ++- docs/source/tutorials/introduction.ipynb | 33 +- .../tutorials/latent-toroid-dimensions.ipynb | 34 +- docs/source/tutorials/multi-task-sirepo.ipynb | 38 +- docs/source/tutorials/passive-dofs.ipynb | 125 ++----- docs/wip/passive-dofs.ipynb | 329 ------------------ output.png | Bin 39521 -> 0 bytes 11 files changed, 140 insertions(+), 648 deletions(-) delete mode 100644 docs/source/tutorials/custom-acquisition.ipynb delete mode 100644 docs/wip/passive-dofs.ipynb delete mode 100644 output.png diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index 945687a..1ccfa69 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -736,9 +736,11 @@ def _plot_tasks_one_dof(self, size=16, lw=1e0): color=color, ) + on_dofs_are_active_mask = [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")] + for z in [0, 1, 2]: self.task_axes[itask].fill_between( - x[..., self._dof_mask(kind="active", mode="on")].squeeze(), + x[..., on_dofs_are_active_mask].squeeze(), (task_mean - z * task_sigma).squeeze(), (task_mean + z * task_sigma).squeeze(), lw=lw, @@ -774,7 +776,7 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL # task_norm = mpl.colors.LogNorm(task_vmin, task_vmax) # else: - self.task_axes[itask, 0].set_ylabel(task["key"]) + self.task_axes[itask, 0].set_ylabel(f'{task["key"]}_fitness') self.task_axes[itask, 0].set_title("samples") self.task_axes[itask, 1].set_title("posterior mean") @@ -860,9 +862,9 @@ def _plot_acq_one_dof(self, acqfs, lw=1e0, **kwargs): obj = obj.exp() self.acq_axes[iacqf].set_title(acqf_meta["name"]) - self.acq_axes[iacqf].plot( - x[..., self._dof_mask(kind="active", mode="on")].squeeze(), obj.detach().numpy(), lw=lw, color=color - ) + + on_dofs_are_active_mask = [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")] + self.acq_axes[iacqf].plot(x[..., on_dofs_are_active_mask].squeeze(), obj.detach().numpy(), lw=lw, color=color) self.acq_axes[iacqf].set_xlim(self._subset_dofs(kind="active", mode="on")[0]["limits"]) @@ -934,8 +936,11 @@ def _plot_feas_one_dof(self, size=16, lw=1e0): *input_shape, input_dim = x.shape log_prob = self.classifier.log_prob(x.reshape(-1, 1, input_dim)).reshape(input_shape) - self.feas_ax.scatter(self.inputs.values, self.task_fitnesses.isna().any(axis=1).astype(int), s=size) - self.feas_ax.plot(x[..., self._dof_mask(kind="active", mode="on")].squeeze(), log_prob.exp().detach().numpy(), lw=lw) + self.feas_ax.scatter(self.inputs.values, ~self.task_fitnesses.isna().any(axis=1), s=size) + + on_dofs_are_active_mask = [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")] + + self.feas_ax.plot(x[..., on_dofs_are_active_mask].squeeze(), log_prob.exp().detach().numpy(), lw=lw) self.feas_ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[0]["limits"]) @@ -947,8 +952,8 @@ def _plot_feas_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLO data_ax = self.feas_axes[0].scatter( *self.inputs.values.T[:2], + c=~self.task_fitnesses.isna().any(axis=1), s=size, - c=self.task_fitnesses.isna().any(axis=1).astype(int), vmin=0, vmax=1, cmap=cmap, diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index f93b95d..3a57df0 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -7,5 +7,6 @@ Tutorials tutorials/introduction.ipynb tutorials/constrained-himmelblau.ipynb tutorials/hyperparameters.ipynb + tutorials/passive-dofs.ipynb tutorials/latent-toroid-dimensions.ipynb tutorials/multi-task-sirepo.ipynb diff --git a/docs/source/tutorials/constrained-himmelblau.ipynb b/docs/source/tutorials/constrained-himmelblau.ipynb index 9d7b2f4..bc887e1 100644 --- a/docs/source/tutorials/constrained-himmelblau.ipynb +++ b/docs/source/tutorials/constrained-himmelblau.ipynb @@ -64,7 +64,7 @@ " products = db[uid].table()\n", "\n", " for index, entry in products.iterrows():\n", - " products.loc[index, \"loss\"] = test_functions.constrained_himmelblau(entry.x1, entry.x2)\n", + " products.loc[index, \"himmelblau\"] = test_functions.constrained_himmelblau(entry.x1, entry.x2)\n", "\n", " return products" ] @@ -89,16 +89,21 @@ "source": [ "%run -i ../../../examples/prepare_bluesky.py # prepare the bluesky environment\n", "\n", - "import bloptools\n", - "from bloptools.tasks import Task\n", + "from bloptools import devices\n", + "from bloptools.bayesian import Agent\n", "\n", - "dofs = bloptools.devices.dummy_dofs(n=2, bounds=(-10, 10))\n", + "dofs = [\n", + " {\"device\": devices.DOF(name=\"x1\"), \"limits\": (-8, 8), \"kind\": \"active\"},\n", + " {\"device\": devices.DOF(name=\"x2\"), \"limits\": (-8, 8), \"kind\": \"active\"},\n", + "]\n", "\n", - "task = {\"key\": \"loss\", \"kind\": \"minimize\"}\n", + "tasks = [\n", + " {\"key\": \"himmelblau\", \"kind\": \"minimize\"},\n", + "]\n", "\n", - "agent = bloptools.bayesian.Agent(\n", + "agent = Agent(\n", " dofs=dofs,\n", - " tasks=task,\n", + " tasks=tasks,\n", " digestion=digestion,\n", " db=db,\n", ")\n", diff --git a/docs/source/tutorials/custom-acquisition.ipynb b/docs/source/tutorials/custom-acquisition.ipynb deleted file mode 100644 index 1d318be..0000000 --- a/docs/source/tutorials/custom-acquisition.ipynb +++ /dev/null @@ -1,141 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "e7b5e13a-c059-441d-8d4f-fff080d52054", - "metadata": {}, - "source": [ - "# Multi-task optimization of KB mirrors\n", - "\n", - "Often, we want to optimize multiple aspects of a system; in this real-world example aligning the Kirkpatrick-Baez mirrors at the TES beamline's endstation, we care about the horizontal and vertical beam size, as well as the flux. \n", - "\n", - "We could try to model these as a single task by combining them into a single number (i.e., optimization the beam density as flux divided by area), but our model then loses all information about how different inputs affect different outputs. We instead give the optimizer multiple \"tasks\", and then direct it based on its prediction of those tasks. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fa8a6989", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "%run -i ../../../examples/prepare_bluesky.py\n", - "%run -i ../../../examples/prepare_tes_shadow.py\n", - "\n", - "kb_dofs = [kbv.x_rot, kbh.x_rot]\n", - "kb_bounds = np.array([[-0.10, +0.10], [-0.10, +0.10]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "071a829f-a390-40dc-9d5b-ae75702e119e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from bloptools.bayesian import Agent\n", - "from bloptools.experiments.sirepo.tes import w9_digestion\n", - "from bloptools.tasks import Task\n", - "\n", - "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", - "beam_width_task = Task(key=\"x_width\", kind=\"min\", transform=lambda x: np.log(x))\n", - "beam_height_task = Task(key=\"y_width\", kind=\"min\", transform=lambda x: np.log(x))\n", - "\n", - "agent = Agent(\n", - " active_dofs=kb_dofs,\n", - " active_dof_bounds=kb_bounds,\n", - " detectors=[w9],\n", - " tasks=[beam_flux_task, beam_width_task, beam_height_task],\n", - " digestion=w9_digestion,\n", - " db=db,\n", - ")\n", - "\n", - "RE(agent.initialize(\"qr\", n_init=16))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "a6259a4f", - "metadata": {}, - "source": [ - "For each task, we plot the sampled data and the model's posterior with respect to two inputs to the KB mirrors. We can see that each tasks responds very differently to different motors, which is very useful to the optimizer. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "996c3c01-f91d-4a25-9b8d-eba5fa964504", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "agent.plot_tasks()\n", - "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "296d9fd2", - "metadata": {}, - "source": [ - "We should find our optimum (or something close to it) on the very next iteration:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d6b39b54", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "RE(agent.learn(\"ei\", n_iter=2))\n", - "agent.plot_tasks()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "e23e920c", - "metadata": {}, - "source": [ - "The agent has learned that certain dimensions affect different tasks differently!" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.16" - }, - "vscode": { - "interpreter": { - "hash": "9aced674e98d511b4f654e147532c84d38dc986fe042b1e92785fb9d8df41f75" - } - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/source/tutorials/hyperparameters.ipynb b/docs/source/tutorials/hyperparameters.ipynb index 124295d..b32a115 100644 --- a/docs/source/tutorials/hyperparameters.ipynb +++ b/docs/source/tutorials/hyperparameters.ipynb @@ -43,6 +43,22 @@ "The optimization goes faster if our model understands how the function changes as we change the inputs in different ways. The way it picks up on this is by starting from a general model that could describe a lot of functions, and making it specific to this one by choosing the right hyperparameters. Our Bayesian agent is very good at this, and only needs a few samples to figure out what the function looks like:" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e9c949e", + "metadata": {}, + "outputs": [], + "source": [ + "def digestion(db, uid):\n", + " products = db[uid].table()\n", + "\n", + " for index, entry in products.iterrows():\n", + " products.loc[index, \"booth\"] = test_functions.booth(entry.x1, entry.x2)\n", + "\n", + " return products" + ] + }, { "cell_type": "code", "execution_count": null, @@ -54,29 +70,22 @@ "source": [ "%run -i ../../../examples/prepare_bluesky.py # prepare the bluesky environment\n", "\n", - "import bloptools\n", - "from bloptools.tasks import Task\n", - "\n", - "dofs = bloptools.devices.dummy_dofs(n=2)\n", - "bounds = [(-8, 8), (-8, 8)]\n", - "\n", - "task = Task(key=\"booth\", kind=\"min\")\n", - "\n", + "from bloptools import devices\n", + "from bloptools.bayesian import Agent\n", "\n", - "def digestion(db, uid):\n", - " products = db[uid].table()\n", - "\n", - " for index, entry in products.iterrows():\n", - " products.loc[index, \"booth\"] = test_functions.booth(entry.x1, entry.x2)\n", "\n", - " return products\n", + "dofs = [\n", + " {\"device\": devices.DOF(name=\"x1\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", + " {\"device\": devices.DOF(name=\"x2\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", + "]\n", "\n", + "tasks = [\n", + " {\"key\": \"booth\", \"kind\": \"minimize\"},\n", + "]\n", "\n", - "agent = bloptools.bayesian.Agent(\n", - " active_dofs=dofs,\n", - " passive_dofs=[],\n", - " active_dof_bounds=bounds,\n", - " tasks=[task],\n", + "agent = Agent(\n", + " dofs=dofs,\n", + " tasks=tasks,\n", " digestion=digestion,\n", " db=db,\n", ")\n", diff --git a/docs/source/tutorials/introduction.ipynb b/docs/source/tutorials/introduction.ipynb index 90e786e..c47e2f0 100644 --- a/docs/source/tutorials/introduction.ipynb +++ b/docs/source/tutorials/introduction.ipynb @@ -57,8 +57,9 @@ "source": [ "from bloptools import devices\n", "\n", - "dofs = devices.dummy_dofs(n=1, bounds=(-5, 5))\n", - "dofs" + "dofs = [\n", + " {\"device\": devices.DOF(name=\"x\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", + "]" ] }, { @@ -67,19 +68,9 @@ "id": "c8556bc9", "metadata": {}, "outputs": [], - "source": [ - "tuple(np.array((2, 2)).astype(float))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a1543320", - "metadata": {}, - "outputs": [], "source": [ "tasks = [\n", - " {\"name\": \"loss\", \"kind\": \"minimize\", \"transform\": \"log\"},\n", + " {\"key\": \"styblinski-tang\", \"kind\": \"minimize\"},\n", "]" ] }, @@ -104,7 +95,7 @@ " products = db[uid].table()\n", "\n", " for index, entry in products.iterrows():\n", - " products.loc[index, \"loss\"] = test_functions.styblinski_tang(entry.x1)\n", + " products.loc[index, \"styblinski-tang\"] = test_functions.styblinski_tang(entry.x)\n", "\n", " return products" ] @@ -187,16 +178,6 @@ "agent.acqf_info" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "4efee6aa", - "metadata": {}, - "outputs": [], - "source": [ - "agent._acqf_bounds" - ] - }, { "cell_type": "code", "execution_count": null, @@ -206,8 +187,6 @@ }, "outputs": [], "source": [ - "# helper function to list acquisition functions\n", - "\n", "agent.plot_acquisition(acqfs=[\"ei\", \"pi\", \"ucb\"])" ] }, @@ -227,7 +206,7 @@ "metadata": {}, "outputs": [], "source": [ - "RE(agent.learn(\"ei\"))\n", + "RE(agent.learn(\"ei\", n_iter=4))\n", "agent.plot_tasks()" ] } diff --git a/docs/source/tutorials/latent-toroid-dimensions.ipynb b/docs/source/tutorials/latent-toroid-dimensions.ipynb index 0bd3aa4..5ad5fbe 100644 --- a/docs/source/tutorials/latent-toroid-dimensions.ipynb +++ b/docs/source/tutorials/latent-toroid-dimensions.ipynb @@ -21,10 +21,7 @@ "outputs": [], "source": [ "%run -i ../../../examples/prepare_bluesky.py\n", - "%run -i ../../../examples/prepare_tes_shadow.py\n", - "\n", - "toroid_dofs = np.array([toroid.x_rot, toroid.offz])\n", - "toroid_bounds = np.array([[-1e-3, 1e-3], [-4e-1, +4e-1]])" + "%run -i ../../../examples/prepare_tes_shadow.py" ] }, { @@ -36,17 +33,20 @@ }, "outputs": [], "source": [ - "from bloptools.bayesian import Agent\n", + "import bloptools\n", "from bloptools.experiments.sirepo.tes import w8_digestion\n", - "from bloptools.tasks import Task\n", "\n", - "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", + "dofs = [\n", + " {\"device\": toroid.x_rot, \"limits\": (-0.001, 0.001), \"kind\": \"active\"},\n", + " {\"device\": toroid.offz, \"limits\": (-0.5, 0.5), \"kind\": \"active\"},\n", + "]\n", + "\n", + "tasks = [{\"key\": \"flux\", \"kind\": \"maximize\", \"transform\": \"log\"}]\n", "\n", - "agent = Agent(\n", - " active_dofs=toroid_dofs,\n", - " active_dof_bounds=toroid_bounds,\n", - " detectors=[w8],\n", - " tasks=[beam_flux_task],\n", + "agent = bloptools.bayesian.Agent(\n", + " dofs=dofs,\n", + " tasks=tasks,\n", + " dets=[w8],\n", " digestion=w8_digestion,\n", " db=db,\n", ")\n", @@ -70,7 +70,7 @@ "metadata": {}, "outputs": [], "source": [ - "agent.tasks[0].regressor.covar_module.latent_transform" + "agent.tasks[0][\"model\"].covar_module.latent_transform" ] }, { @@ -86,6 +86,14 @@ "agent.plot_feasibility()\n", "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "efd5ab4f", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/docs/source/tutorials/multi-task-sirepo.ipynb b/docs/source/tutorials/multi-task-sirepo.ipynb index e740543..b965327 100644 --- a/docs/source/tutorials/multi-task-sirepo.ipynb +++ b/docs/source/tutorials/multi-task-sirepo.ipynb @@ -23,10 +23,17 @@ "outputs": [], "source": [ "%run -i ../../../examples/prepare_bluesky.py\n", - "%run -i ../../../examples/prepare_tes_shadow.py\n", - "\n", - "kb_dofs = [kbv.x_rot, kbh.x_rot]\n", - "kb_bounds = np.array([[-0.10, +0.10], [-0.10, +0.10]])" + "%run -i ../../../examples/prepare_tes_shadow.py" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8672bf9e", + "metadata": {}, + "outputs": [], + "source": [ + "toroid.offz" ] }, { @@ -40,17 +47,22 @@ "source": [ "from bloptools.bayesian import Agent\n", "from bloptools.experiments.sirepo.tes import w9_digestion\n", - "from bloptools.tasks import Task\n", "\n", - "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", - "beam_width_task = Task(key=\"x_width\", kind=\"min\", transform=lambda x: np.log(x))\n", - "beam_height_task = Task(key=\"y_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "dofs = [\n", + " {\"device\": kbv.x_rot, \"limits\": (-0.1, 0.1), \"kind\": \"active\"},\n", + " {\"device\": kbh.x_rot, \"limits\": (-0.1, 0.1), \"kind\": \"active\"},\n", + "]\n", + "\n", + "tasks = [\n", + " {\"key\": \"flux\", \"kind\": \"maximize\", \"transform\": \"log\"},\n", + " {\"key\": \"w9_fwhm_x\", \"kind\": \"minimize\", \"transform\": \"log\"},\n", + " {\"key\": \"w9_fwhm_y\", \"kind\": \"minimize\", \"transform\": \"log\"},\n", + "]\n", "\n", "agent = Agent(\n", - " active_dofs=kb_dofs,\n", - " active_dof_bounds=kb_bounds,\n", - " detectors=[w9],\n", - " tasks=[beam_flux_task, beam_width_task, beam_height_task],\n", + " dofs=dofs,\n", + " tasks=tasks,\n", + " dets=[w9],\n", " digestion=w9_digestion,\n", " db=db,\n", ")\n", @@ -128,7 +140,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.16 (main, Mar 8 2023, 14:00:05) \n[GCC 11.2.0]" + "version": "3.9.16" }, "vscode": { "interpreter": { diff --git a/docs/source/tutorials/passive-dofs.ipynb b/docs/source/tutorials/passive-dofs.ipynb index 17c6216..6f5adea 100644 --- a/docs/source/tutorials/passive-dofs.ipynb +++ b/docs/source/tutorials/passive-dofs.ipynb @@ -6,118 +6,61 @@ "id": "e7b5e13a-c059-441d-8d4f-fff080d52054", "metadata": {}, "source": [ - "# Multi-task optimization of KB mirrors\n", - "\n", - "Often, we want to optimize multiple aspects of a system; in this real-world example aligning the Kirkpatrick-Baez mirrors at the TES beamline's endstation, we care about the horizontal and vertical beam size, as well as the flux. \n", - "\n", - "We could try to model these as a single task by combining them into a single number (i.e., optimization the beam density as flux divided by area), but our model then loses all information about how different inputs affect different outputs. We instead give the optimizer multiple \"tasks\", and then direct it based on its prediction of those tasks. " + "# Passive degrees of freedom\n", + "\n" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "fa8a6989", - "metadata": { - "tags": [] - }, - "outputs": [], + "attachments": {}, + "cell_type": "markdown", + "id": "c18ef717", + "metadata": {}, "source": [ - "%run -i ../../../examples/prepare_bluesky.py\n", - "%run -i ../../../examples/prepare_tes_shadow.py\n", - "\n", - "kb_dofs = [kbv.x_rot, kbh.x_rot]\n", - "kb_bounds = np.array([[-0.10, +0.10], [-0.10, +0.10]])" + "Passive dofs!" ] }, { "cell_type": "code", "execution_count": null, - "id": "716969ac", + "id": "e6bfcf73", "metadata": {}, "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "071a829f-a390-40dc-9d5b-ae75702e119e", - "metadata": { - "tags": [] - }, - "outputs": [], "source": [ + "%run -i ../../../examples/prepare_bluesky.py # prepare the bluesky environment\n", + "\n", + "from bloptools import devices, test_functions\n", "from bloptools.bayesian import Agent\n", - "from bloptools.experiments.sirepo.tes import w9_digestion\n", - "from bloptools.tasks import Task\n", "\n", - "beam_flux_task = Task(key=\"flux\", kind=\"max\", transform=lambda x: np.log(x))\n", - "beam_width_task = Task(key=\"x_width\", kind=\"min\", transform=lambda x: np.log(x))\n", - "beam_height_task = Task(key=\"y_width\", kind=\"min\", transform=lambda x: np.log(x))\n", + "\n", + "def digestion(db, uid):\n", + " products = db[uid].table()\n", + "\n", + " for index, entry in products.iterrows():\n", + " products.loc[index, \"styblinksi-tang\"] = test_functions.styblinski_tang(entry.x - 1e-1 * entry.brownian)\n", + "\n", + " return products\n", + "\n", + "\n", + "dofs = [\n", + " {\"device\": devices.DOF(name=\"x\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", + " {\"device\": devices.BrownianMotion(name=\"brownian\"), \"limits\": (-2, 2), \"kind\": \"passive\"},\n", + "]\n", + "\n", + "tasks = [\n", + " {\"key\": \"styblinksi-tang\", \"kind\": \"minimize\"},\n", + "]\n", "\n", "agent = Agent(\n", - " active_dofs=kb_dofs,\n", - " active_dof_bounds=kb_bounds,\n", - " detectors=[w9],\n", - " tasks=[beam_flux_task, beam_width_task, beam_height_task],\n", - " digestion=w9_digestion,\n", + " dofs=dofs,\n", + " tasks=tasks,\n", + " digestion=digestion,\n", " db=db,\n", ")\n", "\n", - "RE(agent.initialize(\"qr\", n_init=16))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "a6259a4f", - "metadata": {}, - "source": [ - "For each task, we plot the sampled data and the model's posterior with respect to two inputs to the KB mirrors. We can see that each tasks responds very differently to different motors, which is very useful to the optimizer. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "996c3c01-f91d-4a25-9b8d-eba5fa964504", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "agent.plot_tasks()\n", - "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "296d9fd2", - "metadata": {}, - "source": [ - "We should find our optimum (or something close to it) on the very next iteration:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d6b39b54", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "RE(agent.learn(\"ei\", n_iter=2))\n", + "RE(agent.initialize(\"qr\", n_init=32))\n", + "\n", "agent.plot_tasks()" ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "e23e920c", - "metadata": {}, - "source": [ - "The agent has learned that certain dimensions affect different tasks differently!" - ] } ], "metadata": { diff --git a/docs/wip/passive-dofs.ipynb b/docs/wip/passive-dofs.ipynb deleted file mode 100644 index 89d601e..0000000 --- a/docs/wip/passive-dofs.ipynb +++ /dev/null @@ -1,329 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "id": "e7b5e13a-c059-441d-8d4f-fff080d52054", - "metadata": {}, - "source": [ - "## Introduction\n", - "\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "c18ef717", - "metadata": {}, - "source": [ - "This tutorial is an introduction to the syntax used by the optimizer, as well as the principles of Bayesian optimization in general.\n", - "\n", - "We'll start by minimizing Himmelblau's function, which looks like this:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "22438de8", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import matplotlib as mpl\n", - "from matplotlib import pyplot as plt\n", - "\n", - "import bloptools\n", - "\n", - "x1 = x2 = np.linspace(-5.0, 5.0, 256)\n", - "X1, X2 = np.meshgrid(x1, x2)\n", - "\n", - "plt.pcolormesh(x1, x2, bloptools.experiments.tests.himmelblau(X1, X2), norm=mpl.colors.LogNorm(), shading=\"auto\")\n", - "\n", - "plt.xlabel(\"x1\")\n", - "plt.ylabel(\"x2\")\n", - "plt.colorbar()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f46924af", - "metadata": {}, - "outputs": [], - "source": [ - "from bloptools.objects import TimeReadback\n", - "\n", - "tr = TimeReadback(name=\"timestamp\")\n", - "\n", - "tr.read()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "ecef8da5", - "metadata": {}, - "source": [ - "There are several things that our agent will need. The first ingredient is some degrees of freedom (these are always `ophyd` devices) which the agent will move around to different inputs within each DOF's bounds (the second ingredient). We define these here:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4c870567", - "metadata": {}, - "outputs": [], - "source": [ - "import bloptools\n", - "\n", - "dofs = bloptools.objects.get_dummy_dofs(n=2) # get a list of two DOFs\n", - "bounds = [(-5.0, +5.0), (-5.0, +5.0)]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "7a88c7bd", - "metadata": {}, - "source": [ - "The agent automatically samples at different inputs, but we often need some post-processing after data collection. In this case, we need to give the agent a way to compute Himmelblau's function. We accomplish this with a digestion function:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6bfcf73", - "metadata": {}, - "outputs": [], - "source": [ - "import bloptools\n", - "\n", - "dofs = bloptools.objects.get_dummy_dofs(n=2) # get a list of two DOFs\n", - "bounds = [(-5.0, +5.0), (-5.0, +5.0)]\n", - "\n", - "from bloptools.objects import TimeReadback\n", - "\n", - "tr = TimeReadback(name=\"timestamp\")\n", - "\n", - "tr.read()\n", - "\n", - "\n", - "def digestion(db, uid):\n", - " table = db[uid].table()\n", - " products = pd.DataFrame()\n", - "\n", - " for index, entry in table.iterrows():\n", - " products.loc[index, \"himmelblau\"] = bloptools.test_functions.himmelblau(entry.x1, entry.x2)\n", - "\n", - " return products\n", - "\n", - "\n", - "from bloptools.tasks import Task\n", - "\n", - "task = Task(key=\"himmelblau\", kind=\"min\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "4532b087", - "metadata": {}, - "source": [ - "The next ingredient is a task, which gives the agent something to do. We want it to minimize Himmelblau's function, so we make a task that will try to minimize the output of the digestion function called \"himmelblau\". We also include a transform function, which will make it easier to regress over the outputs of the function." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4c14d162", - "metadata": {}, - "outputs": [], - "source": [ - "from bloptools.tasks import Task\n", - "\n", - "task = Task(key=\"himmelblau\", kind=\"min\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "0d3d91c3", - "metadata": {}, - "source": [ - "Combining all of these with a databroker instance, we can make an agent:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "071a829f-a390-40dc-9d5b-ae75702e119e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "%run -i ../../../examples/prepare_bluesky.py # prepare the bluesky environment\n", - "\n", - "boa = bloptools.bayesian.Agent(\n", - " dofs=dofs,\n", - " bounds=bounds,\n", - " passive_dims=[tr],\n", - " tasks=task,\n", - " digestion=digestion,\n", - " db=db,\n", - ")\n", - "\n", - "RE(boa.initialize(init_scheme=\"quasi-random\", n_init=32))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "d8f2da43", - "metadata": {}, - "source": [ - "We initialized the GP with the \"quasi-random\" strategy, as it doesn't require any prior data. We can view the state of the optimizer's posterior of the tasks over the input parameters:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5818143a", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e93c75a1", - "metadata": {}, - "outputs": [], - "source": [ - "np.atleast_1d([]).size" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "996c3c01-f91d-4a25-9b8d-eba5fa964504", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "boa.plot_tasks()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "d2eb855c", - "metadata": {}, - "source": [ - "We want to learn a bit more, so we can ask the agent where to sample based off of some strategy. Here we use the \"esti\" strategy, which maximizes the expected sum of tasks improvement." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f0e74651", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "RE(boa.learn(strategy=\"esti\", n_iter=4))\n", - "boa.plot_tasks()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "aeab7a9b", - "metadata": {}, - "source": [ - "The agent has updated its model of the tasks, including refitting the hyperparameters. Continuing:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d6b39b54", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "RE(boa.learn(strategy=\"esti\", n_iter=16))\n", - "boa.plot_tasks()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "id": "e955233f", - "metadata": {}, - "source": [ - "Eventually, we reach a point of saturation where no more improvement takes place:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d73e4fd5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "RE(boa.learn(strategy=\"esti\", n_iter=32))\n", - "boa.plot_tasks()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5ad4b1e7", - "metadata": {}, - "outputs": [], - "source": [ - "boa.tasks[0].regressor.covar_module.base_kernel.trans_matrix" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "96e9abac", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.16" - }, - "vscode": { - "interpreter": { - "hash": "9aced674e98d511b4f654e147532c84d38dc986fe042b1e92785fb9d8df41f75" - } - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/output.png b/output.png deleted file mode 100644 index 2ba8c8645cf31797b38b8e858222d916b42cea31..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39521 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

{Y&65#vaz*btt;P79 z##I1xuf9pC5Sj>WUt1mp(`rP%0h2r@gO;a1Uvvg!sF zTynDeKk{5?F!jJCEN3f96|f*hriiHH0JxMObp((qk=39F(fku&Y($5HW2GP1u$uyd z6b_BANyovnjR1ozUs1jW69`1$jL>MJ9=tz5d;3IJKQwy5SkT$(Zf`Z0I~TS{uR#KT zaZ4O>8I0D^_-v=#?cE|dxw%G|4KdbfabkKkqGwHwH~3~0RnDi39&&VmeDr))oD=58Z|5$qSXG#=dm~+vw zWGNo`g#0|5rJ+O2U;l*(lpLDfP9zKXpHQ%uBr9{Di^W>O(BqqmB4?j-Ryzq$tTF5p zL5gx&WS>A{dO~eg*&ZxJJ}W6sj}rT$X^yIv)%mFb<$bEF5AL8-&vcM)x^#j1&C`1q%eq?`RBHFOhAp^3%Mr%`$?ihR7}@yoI##>3OgR(Das3 zONGz{*9oXkZQ)(JdD}&A^{%e!ow>VC^V(%QgbL!%PF2o~0VjivcOf;5***ppyE%gc z5C#Ft*|)dkoVIL3W0R^o&oQ(~1#0i`DW+wLP&p+%h|)fV@3$(I|I7UewL|*kMVKsv z1lbCXN`1S(elwX(RQbm@GR2E`Acn~_-4&cTqDjpZY`m3)4VHVoBaGL01J||ZRX%zz za7j#mqfAw(!zf+Tt7I9!Rbh>hT3bI|Fi{n$@`sAK@b*!pRUz{Gu1NtU^INyxJYJX{2+%fv(Odhj|K+Qq(}3_#OWU;8 zw_<)Y;AO`dDz*DI6QHT3r~bBo`}n)vghDW+7NR5O8v6WnqaZ z=(+PY;gQ^DOA)F!c3CF-(ROBg^iQP^xj+qfi8al-X6?__f4|I+i=ckR5m8p6+oLG` zCXt*n#L5&l{(GF=EkJz&D^e;@lT5D28J=&>CI_Z0u#bYrqM20lj$KDk_Hw6GV6cVo z*QHvrIaXuCZOe}|V)Z!&N>tUKWumywVg3ukRC_Fo`xOhk-ljj_9nsUym85O{?l`;{ zw~cy!QN0mUm&6}NHmr}F#??K)%)4FiI`z$PpSbScgdX3Y+b4vmk|zA(3j7yV`*RJp z-*Un0^OuQ#nu>@Z@?yaBhb+UH|FsU>c1MHD_G(`uubOf4K?|b7C_iC6p@Ah5 z-PQw(riBIC$Fc2t>C@DTV(dZn?4RnC9DT&*%eEPQa=!Zg4%TI)sN%Hr~PwzYKDazor^lUwzcMo{H`C!>-bH>IlD zGX@+_*l8tylXQIDPe}3{*t0=YrQIGMkKx+d#g9<7|38G^3n@zwte$6Kk$Y4Pjkf^7 zD3_t01c@{N`&=>q7WPk$uPtPZL)F)QMCwnwXOpx{W?rkpxcE9O^zG$W24xz6?Jv=;W%Q{j%-+5a#1|K2? z-IdI&EPTN0Uj2dMHW}5oa2SJsF)Mk($?VgU_|@o53$sCxxt8#ZQJaFif1SK?hNcEM zKW@^-1iDqnrnBeqW+QLe$?6}hJKklOPdUKhp~ugNx6A5&U$gt!&i;Mbr}Z=@XbSC( z{9g9<&ctIL_M|mPj)Dy-wDLRalWWSRKfU_iMBI(TU7r!C%Q<3V=nS`A&458TSZQ% zG8h(??o|U-DTI;s)4J@&QBir(w|3#Vv_YT^=eHu#K3qK=RHH)giNOjB2tKIIoDuMw zp}!L9=u~j8Qq$(of2X|+-nvBlii>}~Jpr(Iwa0z98jBW@ga}F#LFAY2k)TcEOUZOd`#Q9GU z35b~I`=y?AJWvwARzc_l2raT60jK#Qv-TLPQ6QHiThS%O9W9;Z>5pjcP*73%skR{0 zHH1m#WuA0~q-=3aO*PX{@7TsJn>#%K4jI@Ox2?6IkLquv8{MZu!^RO}@v~NXzFB7* zh7SbQPSO(wXYKg=d1Qas1jCJH&=x!fpK{cZ*jab5uMgQO0mAq(r7z*ECs+NbKRSk# zmAH=*wx8EvuiW+$c45xXjol}=S99~Ay6{<|ijLg&I+UaMDreew0WD|>teUA)shjb- zx{qfwqvd`AoR30mC-Qqn@mwk}dzy-^MlSdVRUb=BRwC3&`h#Z$mlryZKhL1|1X7`iXKu$-S=Ris zxV{|UQSi&8HfK7d$|hUCKRXg89<8nWfdBcy(m|m~W6%lIN&q@r;VCI8K^npglS*#gP^Dg{hQ=b~WI7bhg6n7c)|Xk+UdH!o&eq(7S?}gz|IRSm2KFEp#U7#%`Eo zR%-Uk(lynQos2CH4&q!eo+US?lE0}pG<^dz>0?c*mi{};DT-Fc!dj`{wca>CvYj*T z3T6cD^BLIhJXG)20V@sUQslokHc%H1@Ns@%3qtMwGL`Gs6%g)!1exI_U>rg7QwCrR z$wBT7twO_!iZ1DY60r8?tq^@I7|l$B9Qa zKtU&SW@*yMI4sl{Z!XfbNKx3EQd}T%CezfqxTrtt59x+PV*KMSZPJ;oI625q_0&1H zo(1Z4DMwkid?CgjOJvssET~b{k6bc5js&;&nbayYr$~ z2>84cwnCH^zx&o_vL$yOav?OW?p#kV{j)uV!CQ^{4JPrlmb3VGIW9g1G@D$K5@k-z zeF?n{#T9M>6p%@@F}9r3=c#(EVBynJ-^vJeOL=&Og|Mtn5-kM57)9Oms%dorp2qah zR+!tBhv&E{J3Q2KeBmS|CnlI&`;0yBumVl;=qPWcZDuhZ&9M^W{y|oR(`1+mpM$|{Ba2fGr2>56Y}h z)6Y#sZEHSR`1!e`(q)c1!z%KqZ_A>l+|;7?*c9! zZKxX*I`#U>U$BfaXR9ZuNa8~PKEDT5+!<+X6r{|qTP@VUV%z0*zg2uTp31m)(XhAL zm=a3d?TnumoHmK%fB@~~{*xMtQ(w8&`8rx)RRQ3wzB^Lc{!+siU;_c1ZqPt$bepon zxSs6vWy|5?i3S}a_$$*=jM4ufE-+F5##xz36Li5sME1h%m#4NX#%_jxQ(n6>^qteX^~DXRQQB8IQKL66|*&(tQ-*FL2VujG^hf&YN||A(-MyR4#C$*xn!dW$Ju*s_|vNWMTXLUwO{FkI`^5J@g?{RcU zf&oZcgy$tG(W+yqNq3(?4Bp%ICU&fN6GXxSGfN-{^LrtIjRtSt;=s^@>J|2EC+@y{ zPUi7|^{q-Cb8*+fXPln)>F4m!iILr0($WNIu-$u)(~)uS*FWqu)AKxOV^mq&)iyN; z2aI9?`zu&u7qiWWD>~Ro8~7vO&$-HTjMer1d{sGj$Q7T+y@djP&YR}!2-#bOVMsWm#%})X0?-hV+9?{D zo@8d{k2$o3KxF**K;N$nSLS|7Qym0>+V{r&>972AxK?R^;1#%WhN*w>F68n<)T1Ao zd-9R`fuHh+bDOp_7hDk=m0l!$bAee|}Cb86$EWBPxzbw=G@`q?f zk|%Iea~(fv*M}!4OF>f#EBAUAa=j)_ekLjZ#mYhmbP42 zALFTHH1ijG#F+Ix7Fs+8zl2+=`sOOf4r`F#shz)R?!e*xZa5P0L)K){o$pm-xTxAyLv zFw$yP&SIsc{ZCuHQO!6V*So*vw-2jEtmG5tcj@l`i0HVX*6*!DL2^W0AO654`0Zzd z)x~0uSNa$~`psVc#4)Az0y{5(fJn-rzLcFTJOCZ zm@Tp%ZLjfIYW}mIm@j+toAUUWb=q&+{g3;t+ge^`;YqylKbIrKoTtL2a>WZisMqqT zsD@pOQ|P<$$336vZw_-l`Qrn6BU{1h#@ikNX64GCer7o=Dr)0l2rZ)}v+Veyd?d6z zw%jeZW!p_IS6#$l)IM1i=Ptx~3cBuGJgl8gd!6-+XyGW4k!QqfQ~z+k-}S3eU7GKL zh?;<7?Ym-cJ1r-9vS&-*hQBQJB&a)2s#SJ>5L~Qnpb!)-_Vv4{RFOV>=5+>3b{W2F zDKR{IoUro#hcy*zAGWP8QL8@n|DJtp_Tlwqk$cu16W@Z4n$A#=pNTp=diyiPK%zw9 z4)vGCRew`w-|ZE6v~7<=gPXrz_4o#{ZJwR_bP-3yR^x1IiAV1;E@rpq<%vGYzZY%v z`BsQl6Mx`hPIWNV?&XP%j-`F~s_I<(WA&gi=j>T8{jlwttj!co59h|2{nd5fRn1Id zLc#83BGbT_tiv}E1-KZai-w1Zw-|~vfRla{Yd`)yLJSJH0%sG5}b8VW==R0!+O)RhZLX z2Fph6v7NR385ittd|^JXcJrQDnQG+2tU`9K6eHr)R2Iyagk8gHFEE zH|8#lcPhS>6TIQ;Q_$FeuX(&ORK;2Fz^ z^{d%3M!hh0c+XkGDpVvf_3GSIJ0JLOr2eQ)`c)aMd8m__2ZJZbO%=vOzfE#S|6TuM z^&@+^(cIG1!2`)W=iTMYJe{7(t|Xs#ZpO+?qTh8nTJx{l;@z6wSeTJEjoMkCQe$(~ zuSs8w={S1mq=3P^R5X{(eNB>*GuYw!b9V2Qtxe{>N%GSPy|?|;-MMCCEOfCkGcyobPW%pco|iA)iwBEtABl2j_}8;8CSlhLi!7^zS!5uw*VgTcJM#PXw4HB_Zf;E8a@ zqN9uD_^U8y52u}rLRWOlth}P7zH>FH-@)~lE9Lrpe#~?tc<)2rL|7r zlXewciXYb#(`CO>_3hv|Ce8DmmSx9aHoLY;XifHyI=)!$=!@!f`m`9#`IT=k&hPK1 zjh4NBn0S%Sy0H2nfh}=8WSQH*ep z_u0ZHb$o}1Ih##u%71IW9x`AsPMxl{N4XwKE!K;vA{|pxj;)WnNM6-hl+)?GDl=JT zAX!K_U4IehZzdYO9T#=!DNRL;OqvdN--2(H&u^|pf7!+7&$8akSF9~=9sO12f$5Bo zIhWO}plJQ8&9^tbRH^*Pu5Qgfq4Rsy1MA8(>Q>2&@ZCh*dlx2seXL!ex2h_TZJvgP z-UNLuBri)2d(dJ`4oU-e)YQVEX-EN7S@-e;@0rR3P%<`nS7T_NjS-qkW@M?jsz`h5 z>~G_?FTVJ8bZ>uEWW1kjB9>Q}PX7YN_lh>x$_QIcMe~9x=2nH!{Sfn6FP*}J@4kzZ z3BQF42S(|)hEg<>t)&X7TcsK$tBiDgEM0)$tpXiEbxX`b!DavsX(i~EDKjuIz{vX- z3>O!{%wrHr6*E$uf^L%drv2m3+GgK8;NoZGfQ@{VGI@b zbDt8e{l4+lPP%KpY+cpb`!}P@rsDfjq?OsFrbZe3HD(=EEON)LFMogMoZ7Xw`yp#k zYu_ebU-BjH%ZATiuJ^XhHhApUTfZB(s*x3UtKDs1zLj-DR+%i8HDh(Oykuv^V7E8DF4 zQ#$+*O01gR-Zfz7?7c&Ff+EaS_3vTm=7ZWlid!yE%<2Sf9PaJ!y;ADT9=ylPd9m>@ zB=Y0@p2h3e;V9yG@NowXkpfo+Bhhc_H|)2T%Tv0?n-R4 z7(Fpt>$SOfs@rs-3rr2{T&uEPYrQR2sOn+Xf9N1BZ5MCFr)hs0A9BW-6h(zfyKuXR zC}w8XumQ<(1CQ0~wr-5PBBy1UCcIK6UMAOmO#@5DP^=r;#C$?-?s_Y*qTkg^<#__eyJMA4jatE=| zE9lw<%9>cP>j0c>0h;3VfS~1Ft+!-quk4L;Uk8MTH`h}QG#<*om>=`{@a;uSHmrOD znw^lxtgmVHnR<98e1w(uJwCZq>qC`(5R@>5pJ7{gCE;EIb-M8>F_1TC0dSe<4lvya zG!}l+2~?%L%Dtbx_Y%8YW`ds-+xcD{Idon8v{prb<0hlv+YIKhh@1uMd7as#XZhn( zwe8*;+2(g2Ee*5|&AI1$jj|Y7QK?8~W~E4G&5Ckf(E6D=IbP-8MiRPAXeqMwoNZ#! zN4YO}qi^wf-y((P{ovc$Tmz}G7b8{PKho@1Xz#Up(2n5^jf|b9$a>+U>G9fW$&FL; zTE{`si?7uyB|f|Nj#~J9(gDtc+Ph9n7BnuaIouxgfVPI>5-^ulRMj$4I(?O&)zxVc z?;NTwI&kVCZi#Fh5L2c8y;s%I$o^)z!X}MG! z03T}$Y?c4KHsYxMu-Sd`?`qScqsT|yF`r#<@H_Lcz;&H4zQ1ZnM9>Ib~FyecnusnEjK29szL zPi2SbRW@xW`9C(J4`W1r+$CTZVJ&I8Jw2VhoH+ihyX-v}*lBJMwA;ofk?z#gCy}}m zR`Om?CjCAzx0Rs4>+Tir{Dbno`jXpN%9|~T)uN?88(Z52)hTZVs*0~0+O1VBIX{9v zoPQR-t&L3kq_(zH^Qj9fYm>dU*T6Rymy(=3JvTR3cq1=2_bZs5yt%dwK0((y0q^1x6B`jBz{8w86EYUnMvC#X zV4%doY^?8nxU8t4Uf}@gd&#H-29w_Yql;GsyoSGlB&r;mnWEmSuLLY2VJ|8iqj7}305%25sUmhM z@9n-9fWi^IZ;zjE-)uGFkGa@+f_3J`A zd+P1`_p`wWpd2)WT!Wpdq7JfJO9N7)yT4}2!FA|x^0;&lGz&`ycNLrA5(O}6*-&!F z_??rp#b6H7D=O|nx3?Z>ZWbFK{{|Lv*_D^Lm`m(oopW2-#|d~~sI#(I2L=ZpRb9s5 zhZ_BTNV>g`IXAYu^vE4d?7?DcI5v4|P%HeIMY#G88u~8*mRAKBw|Twng@JqQ2XeK~z>>)$W(YfjMr_uVCONX@Wcmq`BK zzQg%CHues5iaOj>-n&yz6yr@RpoqT+bN9BERvdWP$0jG|L-Xb~+Yj{muZ%(AnGJ64 zEzQjwprwU!xlmPB8hH9^M$if2n<+hDVIP4%%3`OAb z`mtfS5%Q6?6Kl~Fvq2(lq1&=o*hNdZFtFbVdTu|=H>@*&(^N5@;_J_)r2%M33u7c& zJzfCa0j$B~O?zG^U+X&bi-coWxTs|EwxJ=Pit*luz67wu9Csenw>f%zs;Vub;JkPn zsa+J~N;G47SkeNWV?fgSu`9g>96#iJxOEGRqO75LjTrRg?Y%&d|NFON zEHpm^ZM9az{}H)$7-71f+$8?!Yh{yK^Qf>BbvyC@&nft-cPslt5KdT9Lzy zBBQ81(%=ceSYs-Ch0Kl*Pmz>l{(JlSayOzVCeE>s5N{<-elD)phK2Xk|BdKg{D(!tS38`{YW=Qsuhz_T$*dEJGsy@FLQ((GZ`gXmHn z*nw5xlNZi=oGFSvC7N-ay(E*Tghe!(A|;w)o!v2mM_@LnWzMlOoR^srZNu=yO!2f5 zZbe|gFLk);TKBxgq>}&plsyNS6Oc))`vC{>$C@Ty_h>QDcVbw9`=tu~QTl+xxb-uy zu)~U^j_KXFtu~mugB0-H+goo85~M*V4Pmz>R6APFP|L$kTIRf@RRhMW9UUF~XmmjL z>AhlY2>oE1W8F!g$WetUXyx7M>guvF2y|#8`O+3yz#{%p&ZSJr1)trqQNS@{38qIB z#Y)0?_6II|!yuM6D;+L4@PZBS3?=0QpuBW^`J(ONQRP2CTbPj{3y!|kz?aG%H1OnE z_tos$-0_{+a6ta-68ZiWXpEU~-eg`QqJ!O7 z47wvHx|f=F^#P}+9C``dXd=Bj+qxCas2$Ci%pxwPdef{1r@LyG(Ia27lRI*F{lUYTxWdV>(}!g)==^hDB?Gzq^7opz@JXi zqYPG48FUU25*D_CC`>WlyaHab%}~)*Fe11kD;sc;i|Yb7+o(K^h!Cm}GliYMj5raN z`b8*Av&s^#M58xu^WW|a4IGbS#-myJAD^Qr1gni+!g-$qja=vE=2il(FsO?rL23b= z+c!bpkXursSy@>LieArZdez67KzF(p!*>1ESMwuK7-xXD&av8an8)YEN0`xrW2Qor z9>6kUxMFO3ua)rMuG>#oSG|dY{l32Jfy;k=C;0#0cSmR6n|}AFN~q~Y7gbTXAzvVC H6!1R)sQdn> From c9a0064eae981626909207427ba5c4a61ea2126d Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Sun, 23 Jul 2023 22:05:53 -0400 Subject: [PATCH 03/20] cleaned notebooks --- docs/source/tutorials/hyperparameters.ipynb | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/source/tutorials/hyperparameters.ipynb b/docs/source/tutorials/hyperparameters.ipynb index b32a115..03830f1 100644 --- a/docs/source/tutorials/hyperparameters.ipynb +++ b/docs/source/tutorials/hyperparameters.ipynb @@ -73,7 +73,6 @@ "from bloptools import devices\n", "from bloptools.bayesian import Agent\n", "\n", - "\n", "dofs = [\n", " {\"device\": devices.DOF(name=\"x1\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", " {\"device\": devices.DOF(name=\"x2\"), \"limits\": (-5, 5), \"kind\": \"active\"},\n", @@ -95,16 +94,6 @@ "agent.plot_tasks()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "8fba0f0c", - "metadata": {}, - "outputs": [], - "source": [ - "agent.tasks[0].regressor.covar_module.latent_transform" - ] - }, { "attachments": {}, "cell_type": "markdown", From 1fb8a49ce110a00af7db876995b3b1c84e496e3f Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 24 Jul 2023 11:49:46 -0400 Subject: [PATCH 04/20] added fitnesses to table --- bloptools/bayesian/__init__.py | 78 +++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index 1ccfa69..954d939 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -41,7 +41,7 @@ def default_digestion_plan(db, uid): # dofs: degrees of freedom are things that can change # inputs: these are the values of the dofs, which may be transformed/normalized # targets: these are what our model tries to predict from the inputs -# tasks: these are quantities that our self will try to optimize over +# tasks: these are quantities that our agent will try to optimize over MAX_TEST_INPUTS = 2**11 @@ -109,6 +109,8 @@ def _validate_and_prepare_tasks(tasks): raise ValueError('"mode" must be specified as either "minimize" or "maximize"') if "weight" not in task.keys(): task["weight"] = 1 + if "limits" not in task.keys(): + task["limits"] = (-np.inf, np.inf) task_keys = [task["key"] for task in tasks] if not len(set(task_keys)) == len(task_keys): @@ -131,11 +133,11 @@ def __init__( Parameters ---------- dofs : iterable of ophyd objects - The degrees of freedom that the self can control, which determine the output of the model. + The degrees of freedom that the agent can control, which determine the output of the model. bounds : iterable of lower and upper bounds The bounds on each degree of freedom. This should be an array of shape (n_dofs, 2). tasks : iterable of tasks - The tasks which the self will try to optimize. + The tasks which the agent will try to optimize. acquisition : Bluesky plan generator that takes arguments (dofs, inputs, dets) A plan that samples the beamline for some given inputs. digestion : function that takes arguments (db, uid) @@ -203,7 +205,7 @@ def initialize( ): """ An initialization plan for the self. - This must be run before the self can learn. + This must be run before the agent can learn. It should be passed to a Bluesky RunEngine. """ @@ -234,21 +236,24 @@ def initialize( def tell(self, new_table=None, append=True, train=True, **kwargs): """ - Inform the self about new inputs and targets for the model. + Inform the agent about new inputs and targets for the model. """ new_table = pd.DataFrame() if new_table is None else new_table self.table = pd.concat([self.table, new_table]) if append else new_table self.table.index = np.arange(len(self.table)) - # self.table.loc[:, "total_fitness"] = self.table.loc[:, self.task_names].fillna(-np.inf).sum(axis=1) + fitnesses = self.task_fitnesses # computes from self.table + + # update fitness estimates + self.table.loc[:, fitnesses.columns] = fitnesses.values + self.table.loc[:, "total_fitness"] = fitnesses.values.sum(axis=1) skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] if not train: hypers = self.hypers - fitnesses = self.task_fitnesses feasibility = ~fitnesses.isna().any(axis=1) if not feasibility.sum() >= 2: @@ -258,7 +263,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): train_inputs = torch.tensor(inputs).double().unsqueeze(0) for task in self.tasks: - targets = fitnesses.loc[feasibility, task["key"]].values + targets = self.table.loc[feasibility, f'{task["key"]}_fitness'].values train_targets = torch.tensor(targets).double().unsqueeze(0).unsqueeze(-1) likelihood = gpytorch.likelihoods.GaussianLikelihood( @@ -279,17 +284,14 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): outcome_transform=outcome_transform, ).double() - log_feas_prob_weight = (self.fitness_variance * self.task_weights.square()).sum().sqrt() - + # this ensures that we have equal weight between task fitness and feasibility fitness self.task_scalarization = botorch.acquisition.objective.ScalarizedPosteriorTransform( - weights=torch.tensor([*self.task_weights, log_feas_prob_weight]).double(), + weights=torch.tensor([*torch.ones(self.n_tasks), self.fitness_variance.sum().sqrt()]).double(), offset=0, ) - train_classes = torch.tensor(feasibility).long() # .unsqueeze(0)#.unsqueeze(-1) - dirichlet_likelihood = gpytorch.likelihoods.DirichletClassificationLikelihood( - train_classes, learn_additional_noise=True + torch.tensor(feasibility).long(), learn_additional_noise=True ).double() self.classifier = models.LatentDirichletClassifier( @@ -317,15 +319,22 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): def task_fitnesses(self): df = pd.DataFrame(index=self.table.index) for task in self.tasks: - df.loc[:, task["key"]] = self.table.loc[:, task["key"]] - valid = (df.loc[:, task["key"]] > -np.inf) & (df.loc[:, task["key"]] < np.inf) + name = f'{task["key"]}_fitness' + + df.loc[:, name] = task["weight"] * self.table.loc[:, task["key"]] + + # check that task values are inside acceptable values + valid = (df.loc[:, name] > task["limits"][0]) & (df.loc[:, name] < task["limits"][1]) + + # transform if needed if "transform" in task.keys(): if task["transform"] == "log": - valid &= df.loc[:, task["key"]] > 0 - df.loc[valid, task["key"]] = np.log(df.loc[valid, task["key"]]) - df.loc[~valid, task["key"]] = np.nan + valid &= df.loc[:, name] > 0 + df.loc[valid, name] = np.log(df.loc[valid, name]) + df.loc[~valid, name] = np.nan + if task["kind"] == "minimize": - df.loc[valid, task["key"]] *= -1 + df.loc[valid, name] *= -1 return df def _dof_kind_mask(self, kind=None): @@ -421,7 +430,7 @@ def _subset_input_transform(self, kind=None, mode=None): def save_data(self, filepath="./self_data.h5"): """ - Save the sampled inputs and targets of the self to a file, which can be used + Save the sampled inputs and targets of the agent to a file, which can be used to initialize a future self. """ @@ -475,10 +484,6 @@ def load_hypers(filepath): hypers[model_key][param_key] = torch.tensor(np.atleast_1d(param_value[()])) return hypers - @property - def all_task_fitnesseses_feasible(self): - return ~self.task_fitnesses.isna().any(axis=1) - def train_models(self, **kwargs): t0 = ttime.monotonic() for task in self.tasks: @@ -503,7 +508,7 @@ def acqf_info(self): def get_acquisition_function(self, acqf_identifier="ei", return_metadata=False, acqf_args={}, **kwargs): if not self._initialized: - raise RuntimeError(f'Can\'t construct acquisition function "{acqf_identifier}" (the self is not initialized!)') + raise RuntimeError(f'Can\'t construct acquisition function "{acqf_identifier}" (the agent is not initialized!)') if acqf_identifier.lower() in ACQF_CONFIG["expected_improvement"]["identifiers"]: acqf = botorch.acquisition.analytic.LogExpectedImprovement( @@ -688,7 +693,7 @@ def fitness_variance(self): @property def scalarized_fitness(self): - return (self.task_fitnesses * self.task_weights).sum(axis=1) + return self.task_fitnesses.sum(axis=1) # @property # def best_sum_of_tasks_inputs(self): @@ -731,7 +736,7 @@ def _plot_tasks_one_dof(self, size=16, lw=1e0): self.task_axes[itask].scatter( self.inputs.loc[:, self._subset_dof_names(kind="active", mode="on")], - self.task_fitnesses.loc[:, task["key"]], + self.table.loc[:, f'{task["key"]}_fitness'], s=size, color=color, ) @@ -766,10 +771,9 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL self.task_axes = np.atleast_2d(self.task_axes) # self.task_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") - fitnesses = self.task_fitnesses - for itask, task in enumerate(self.tasks): - task_vmin, task_vmax = np.nanpercentile(fitnesses.loc[:, task["key"]], q=[1, 99]) + sampled_fitness = self.table.loc[:, f'{task["key"]}_fitness'].values + task_vmin, task_vmax = np.nanpercentile(sampled_fitness, q=[1, 99]) task_norm = mpl.colors.Normalize(task_vmin, task_vmax) # if task["transform"] == "log": @@ -783,7 +787,7 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL self.task_axes[itask, 2].set_title("posterior std. dev.") data_ax = self.task_axes[itask, 0].scatter( - *self.inputs.values.T[axes], s=size, c=fitnesses.loc[:, task["key"]], norm=task_norm, cmap=cmap + *self.inputs.values.T[axes], s=size, c=sampled_fitness, norm=task_norm, cmap=cmap ) x = self.test_inputs_grid.squeeze() if gridded else self.test_inputs(n=MAX_TEST_INPUTS) @@ -815,13 +819,17 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL self.task_axes[itask, 1].scatter( x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], + c=task_mean[..., 0].detach().numpy(), s=size, - c=task_mean, norm=task_norm, cmap=cmap, ) sigma_ax = self.task_axes[itask, 2].scatter( - x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], s=size, c=task_sigma, cmap=cmap + x.detach().numpy()[..., axes[0]], + x.detach().numpy()[..., axes[1]], + c=task_sigma[..., 0].detach().numpy(), + s=size, + cmap=cmap, ) self.task_fig.colorbar(data_ax, ax=self.task_axes[itask, :2], location="bottom", aspect=32, shrink=0.8) @@ -1028,7 +1036,7 @@ def plot_history(self, x_key="index", show_all_tasks=False): if show_all_tasks: for itask, task in enumerate(self.tasks): - y = self.task_fitnesses.loc[:, task["key"]].values + y = self.table.loc[:, f'{task["key"]}_fitness'].values hist_axes[itask].scatter(x, y, c=sample_colors) hist_axes[itask].plot(x, y, lw=5e-1, c="k") hist_axes[itask].set_ylabel(task["key"]) From b9fc3826b7e823e6d824bfd0866ec40f3eb81029 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 7 Aug 2023 12:43:28 -0400 Subject: [PATCH 05/20] clean up --- .github/workflows/testing.yml | 4 +-- bloptools/bayesian/__init__.py | 65 +++++++++++++++++----------------- bloptools/bayesian/kernels.py | 15 +++++--- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e15983c..26af390 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -12,9 +12,9 @@ jobs: strategy: matrix: host-os: ["ubuntu-latest"] - python-version: ["3.9"] + # python-version: ["3.9"] # host-os: ["ubuntu-latest", "macos-latest", "windows-latest"] - # python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10"] fail-fast: false defaults: diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index 954d939..db7f25a 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -33,19 +33,20 @@ def default_acquisition_plan(dofs, inputs, dets): def default_digestion_plan(db, uid): - return db[uid].table() + return db[uid].table(fill=True) -# let's be specific about our terminology. -# -# dofs: degrees of freedom are things that can change -# inputs: these are the values of the dofs, which may be transformed/normalized -# targets: these are what our model tries to predict from the inputs -# tasks: these are quantities that our agent will try to optimize over - MAX_TEST_INPUTS = 2**11 + +TASK_CONFIG = {} + ACQF_CONFIG = { + "quasi-random": { + "identifiers": ["qr", "quasi-random"], + "pretty_name": "Quasi-random", + "description": "Sobol-sampled quasi-random points.", + }, "expected_mean": { "identifiers": ["em", "expected_mean"], "pretty_name": "Expected mean", @@ -168,7 +169,7 @@ def __init__( self.db = db self.verbose = kwargs.get("verbose", False) - self.ignore_acquisition_errors = kwargs.get("ignore_acquisition_errors", False) + self.allow_acquisition_errors = kwargs.get("allow_acquisition_errors", True) self.initialization = kwargs.get("initialization", None) self.acquisition_plan = kwargs.get("acquisition_plan", default_acquisition_plan) self.digestion = kwargs.get("digestion", default_digestion_plan) @@ -182,19 +183,12 @@ def __init__( self._train_models = True self.a_priori_hypers = None - # A note on how we transform inputs: - # - # - # Inputs can be _active_ or _passive_. We apply - # - # For passive inputs, this is more complicated. There are two ways to do this - - def active_inputs_sampler(self, n=MAX_TEST_INPUTS): + def _subset_inputs_sampler(self, kind=None, mode=None, n=MAX_TEST_INPUTS): """ Returns $n$ quasi-randomly sampled inputs in the bounded parameter space """ - transform = self._subset_input_transform(kind="active", mode="on") - return transform.untransform(utils.normalized_sobol_sampler(n, self._n_subset_dofs(kind="active", mode="on"))) + transform = self._subset_input_transform(kind=kind, mode=mode) + return transform.untransform(utils.normalized_sobol_sampler(n, d=self._n_subset_dofs(kind=kind, mode=mode))) def initialize( self, @@ -252,7 +246,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] if not train: - hypers = self.hypers + cached_hypers = self.hypers feasibility = ~fitnesses.isna().any(axis=1) @@ -260,11 +254,11 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): raise ValueError("There must be at least two feasible data points per task!") inputs = self.inputs.loc[feasibility, self._subset_dof_names(mode="on")].values - train_inputs = torch.tensor(inputs).double().unsqueeze(0) + train_inputs = torch.tensor(inputs).double() # .unsqueeze(0) for task in self.tasks: targets = self.table.loc[feasibility, f'{task["key"]}_fitness'].values - train_targets = torch.tensor(targets).double().unsqueeze(0).unsqueeze(-1) + train_targets = torch.tensor(targets).double().unsqueeze(-1) # .unsqueeze(0) likelihood = gpytorch.likelihoods.GaussianLikelihood( noise_constraint=gpytorch.constraints.Interval( @@ -273,7 +267,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): ), ).double() - outcome_transform = botorch.models.transforms.outcome.Standardize(m=1, batch_shape=torch.Size((1,))) + outcome_transform = botorch.models.transforms.outcome.Standardize(m=1) # , batch_shape=torch.Size((1,))) task["model"] = models.LatentGP( train_inputs=train_inputs, @@ -305,9 +299,12 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): if self.a_priori_hypers is not None: self._set_hypers(self.a_priori_hypers) elif not train: - self._set_hypers(hypers) + self._set_hypers(cached_hypers) else: - self.train_models() + try: + self.train_models() + except botorch.exceptions.errors.ModelFittingError: + self._set_hypers(cached_hypers) feasibility_fitness_model = botorch.models.deterministic.GenericDeterministicModel( f=lambda X: -self.classifier.log_prob(X).square() @@ -437,7 +434,7 @@ def save_data(self, filepath="./self_data.h5"): self.table.to_hdf(filepath, key="table") def forget(self, index): - self.tell(new_table=self.table.drop(index=index), append=False) + self.tell(new_table=self.table.drop(index=index), append=False, train=False) def sampler(self, n): """ @@ -554,7 +551,7 @@ def get_acquisition_function(self, acqf_identifier="ei", return_metadata=False, def ask(self, acqf_identifier="ei", n=1, route=True, return_metadata=False): if acqf_identifier.lower() == "qr": - active_X = self.active_inputs_sampler(n=n).squeeze(1).numpy() + active_X = self._subset_inputs_sampler(n=n, kind="active", mode="on").squeeze(1).numpy() acqf_meta = {"name": "quasi-random", "args": {}} elif n == 1: @@ -577,7 +574,7 @@ def ask(self, acqf_identifier="ei", n=1, route=True, return_metadata=False): *self.task_keys, ], ) - self.tell(fantasy_table, train=True) + self.tell(fantasy_table, train=False) active_X = np.concatenate(active_x_list, axis=0) self.forget(self.table.index[-(n - 1) :]) @@ -631,8 +628,8 @@ def acquire(self, active_inputs): This should yield a table of sampled tasks with the same length as the sampled inputs. """ try: - active_devices = [dof["device"] for dof in self.dofs if (dof["kind"], dof["mode"]) == ("active", "on")] - passive_devices = [dof["device"] for dof in self.dofs if (dof["kind"], dof["mode"]) != ("active", "on")] + active_devices = self._subset_devices(kind="active", mode="on") + passive_devices = [*self._subset_devices(kind="passive"), *self._subset_devices(kind="active", mode="off")] uid = yield from self.acquisition_plan( active_devices, active_inputs.astype(float), [*self.dets, *passive_devices] @@ -646,10 +643,10 @@ def acquire(self, active_inputs): # products.loc[index, task["key"]] = getattr(entry, task["key"]) except Exception as error: - if not self.ignore_acquisition_errors: + if not self.allow_acquisition_errors: raise error logging.warning(f"Error in acquisition/digestion: {repr(error)}") - products = pd.DataFrame(active_inputs, columns=self.active_dof_names) + products = pd.DataFrame(active_inputs, columns=self._subset_dof_names(kind="active", mode="on")) for task in self.tasks: products.loc[:, task["key"]] = np.nan @@ -771,8 +768,10 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL self.task_axes = np.atleast_2d(self.task_axes) # self.task_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") + feasible = ~self.task_fitnesses.isna().any(axis=1) + for itask, task in enumerate(self.tasks): - sampled_fitness = self.table.loc[:, f'{task["key"]}_fitness'].values + sampled_fitness = np.where(feasible, self.table.loc[:, f'{task["key"]}_fitness'].values, np.nan) task_vmin, task_vmax = np.nanpercentile(sampled_fitness, q=[1, 99]) task_norm = mpl.colors.Normalize(task_vmin, task_vmax) diff --git a/bloptools/bayesian/kernels.py b/bloptools/bayesian/kernels.py index db2d99f..66066d2 100644 --- a/bloptools/bayesian/kernels.py +++ b/bloptools/bayesian/kernels.py @@ -21,8 +21,7 @@ def __init__( self.num_inputs = num_inputs self.scale_output = scale_output - self.nu = kwargs.get("nu", 1.5) - self.batch_dimension = kwargs.get("batch_dimension", None) + self.nu = kwargs.get("nu", 2.5) if type(skew_dims) is bool: if skew_dims: @@ -170,6 +169,14 @@ def forward(self, x1, x2, diag=False, **params): trans_x1 = torch.matmul(self.latent_transform.unsqueeze(1), (x1 - mean).unsqueeze(-1)).squeeze(-1) trans_x2 = torch.matmul(self.latent_transform.unsqueeze(1), (x2 - mean).unsqueeze(-1)).squeeze(-1) - distance = self.covar_dist(trans_x1, trans_x2, diag=diag, **params) + d = self.covar_dist(trans_x1, trans_x2, diag=diag, **params) - return (self.outputscale if self.scale_output else 1.0) * (1 + distance) * torch.exp(-distance) + if self.num_outputs == 1: + d = d.squeeze(0) + + if self.nu == 0.5: + return (self.outputscale if self.scale_output else 1.0) * torch.exp(-d) + if self.nu == 1.5: + return (self.outputscale if self.scale_output else 1.0) * (1 + d) * torch.exp(-d) + if self.nu == 2.5: + return (self.outputscale if self.scale_output else 1.0) * (1 + d + d**2 / 3) * torch.exp(-d) From a3248dd07d65c7d1237c8e92ad6deb03e4eab62d Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 7 Aug 2023 13:01:18 -0400 Subject: [PATCH 06/20] update sirepo-bluesky syntax --- examples/prepare_chx_shadow.py | 6 +----- examples/prepare_tes_shadow.py | 2 +- examples/prepare_tes_srw.py | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/prepare_chx_shadow.py b/examples/prepare_chx_shadow.py index 252476f..faacf5f 100644 --- a/examples/prepare_chx_shadow.py +++ b/examples/prepare_chx_shadow.py @@ -10,13 +10,9 @@ connection = SirepoBluesky("http://localhost:8000") data, schema = connection.auth("shadow", "I1Flcbdw") -classes, objects = create_classes(connection.data, connection=connection) +classes, objects = create_classes(connection=connection) globals().update(**objects) -# data["models"]["simulation"]["npoint"] = 100000 -# data["models"]["watchpointReport12"]["histogramBins"] = 32 -# w9.duration.kind = "hinted" - bec.disable_baseline() bec.disable_heading() bec.disable_table() diff --git a/examples/prepare_tes_shadow.py b/examples/prepare_tes_shadow.py index e590741..27b13e0 100644 --- a/examples/prepare_tes_shadow.py +++ b/examples/prepare_tes_shadow.py @@ -16,7 +16,7 @@ connection = SirepoBluesky("http://localhost:8000") data, schema = connection.auth("shadow", "00000002") -classes, objects = create_classes(connection.data, connection=connection) +classes, objects = create_classes(connection=connection) globals().update(**objects) data["models"]["simulation"]["npoint"] = 100000 diff --git a/examples/prepare_tes_srw.py b/examples/prepare_tes_srw.py index a8de346..8c25c1f 100644 --- a/examples/prepare_tes_srw.py +++ b/examples/prepare_tes_srw.py @@ -9,7 +9,7 @@ connection = SirepoBluesky("http://localhost:8000") data, schema = connection.auth("srw", "00000002") -classes, objects = create_classes(connection.data, connection=connection) +classes, objects = create_classes(connection=connection) globals().update(**objects) # w9.duration.kind = "hinted" From e30705b6e687c4c1efe93f6314b5a3ae6b633cb2 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 7 Aug 2023 13:14:27 -0400 Subject: [PATCH 07/20] forgot about the tests! --- bloptools/bayesian/__init__.py | 3 +-- bloptools/tests/test_bayesian_shadow.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index db7f25a..4b5f394 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -245,8 +245,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] - if not train: - cached_hypers = self.hypers + cached_hypers = self.hypers feasibility = ~fitnesses.isna().any(axis=1) diff --git a/bloptools/tests/test_bayesian_shadow.py b/bloptools/tests/test_bayesian_shadow.py index 9e7f899..001f6e8 100644 --- a/bloptools/tests/test_bayesian_shadow.py +++ b/bloptools/tests/test_bayesian_shadow.py @@ -8,7 +8,7 @@ @pytest.mark.shadow def test_bayesian_agent_tes_shadow(RE, db, shadow_tes_simulation): data, schema = shadow_tes_simulation.auth("shadow", "00000002") - classes, objects = create_classes(shadow_tes_simulation.data, connection=shadow_tes_simulation) + classes, objects = create_classes(connection=shadow_tes_simulation) globals().update(**objects) data["models"]["simulation"]["npoint"] = 100000 From 18036ceea0d5ec580ea6cdea8384cf69570df82e Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 7 Aug 2023 14:30:12 -0400 Subject: [PATCH 08/20] only cache hypers after initialization --- bloptools/bayesian/__init__.py | 8 ++++++-- docs/source/usage.rst | 26 +++++++++++++++++++++++++- examples/prepare_tes_shadow.py | 6 ------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index 4b5f394..fb8db35 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -245,7 +245,8 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] - cached_hypers = self.hypers + if self._initialized: + cached_hypers = self.hypers feasibility = ~fitnesses.isna().any(axis=1) @@ -303,7 +304,10 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): try: self.train_models() except botorch.exceptions.errors.ModelFittingError: - self._set_hypers(cached_hypers) + if self._initialized: + self._set_hypers(cached_hypers) + else: + raise RuntimeError("Could not fit model on initialization!") feasibility_fitness_model = botorch.models.deterministic.GenericDeterministicModel( f=lambda X: -self.classifier.log_prob(X).square() diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 0958322..98e64c1 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -2,8 +2,32 @@ Usage ===== -Start by importing bloptools. +Working in the Bluesky environment, we need to pass four ingredients to the Bayesian agent: + +* ``dofs``: A list of degrees of freedom for the agent. +* ``dets`` (Optional): A list of detectors to be triggered during acquisition. +* ``tasks``: A list of tasks for the agent to maximize. +* ``digestion``: A function that processes the output of the acquisition into the task values. .. code-block:: python import bloptools + + dofs = [ + {"device": some_motor, "limits": (-0.5, 0.5), "kind": "active"}, + {"device": another_motor, "limits": (-0.5, 0.5), "kind": "active"}, + ] + + tasks = [ + {"key": "flux", "kind": "maximize", "transform": "log"} + ] + + agent = bloptools.bayesian.Agent( + dofs=dofs, + tasks=tasks, + dets=[some_detector, another_detector], + digestion=your_digestion_function, + db=db, + ) + + RE(agent.initialize("qr", n_init=24)) diff --git a/examples/prepare_tes_shadow.py b/examples/prepare_tes_shadow.py index 27b13e0..708eaaa 100644 --- a/examples/prepare_tes_shadow.py +++ b/examples/prepare_tes_shadow.py @@ -30,9 +30,3 @@ import warnings warnings.filterwarnings("ignore", module="sirepo_bluesky") - -# This should be done by installing the package with `pip install -e .` or something similar. -# import sys -# sys.path.insert(0, "../") - -kb_dofs = [kbv.x_rot, kbv.offz, kbh.x_rot, kbh.offz] # noqa F821 From 39c19e4a7dad15fba449f9da1648b17556ecbb7f Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Tue, 8 Aug 2023 12:46:17 -0400 Subject: [PATCH 09/20] changed requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 65eb816..0977478 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ numpy ophyd ortools scipy -sirepo-bluesky +sirepo-bluesky>=0.7.0 +tables torch From d84fa74e2b656f853fec5d15be4bb81ac75ac1da Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Mon, 7 Aug 2023 21:23:03 -0400 Subject: [PATCH 10/20] better docs --- bloptools/bayesian/__init__.py | 13 +--- bloptools/bayesian/acquisition.py | 40 ++----------- bloptools/bayesian/digestion.py | 2 + bloptools/test_functions.py | 35 ++++++++++- docs/source/usage.rst | 98 ++++++++++++++++++++++++++----- 5 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 bloptools/bayesian/digestion.py diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index fb8db35..bf94b06 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -13,6 +13,8 @@ import pandas as pd import scipy as sp import torch +from acquisition import default_acquisition_plan +from digestion import default_digestion_function from matplotlib import pyplot as plt from matplotlib.patches import Patch @@ -27,15 +29,6 @@ DEFAULT_COLORMAP = "turbo" -def default_acquisition_plan(dofs, inputs, dets): - uid = yield from bp.list_scan(dets, *[_ for items in zip(dofs, np.atleast_2d(inputs).T) for _ in items]) - return uid - - -def default_digestion_plan(db, uid): - return db[uid].table(fill=True) - - MAX_TEST_INPUTS = 2**11 @@ -172,7 +165,7 @@ def __init__( self.allow_acquisition_errors = kwargs.get("allow_acquisition_errors", True) self.initialization = kwargs.get("initialization", None) self.acquisition_plan = kwargs.get("acquisition_plan", default_acquisition_plan) - self.digestion = kwargs.get("digestion", default_digestion_plan) + self.digestion = kwargs.get("digestion", default_digestion_function) self.dets = list(np.atleast_1d(kwargs.get("dets", []))) self.acqf_config = kwargs.get("acqf_config", ACQF_CONFIG) diff --git a/bloptools/bayesian/acquisition.py b/bloptools/bayesian/acquisition.py index 0a041dc..3354d19 100644 --- a/bloptools/bayesian/acquisition.py +++ b/bloptools/bayesian/acquisition.py @@ -1,37 +1,7 @@ -import torch -from botorch.acquisition.analytic import LogExpectedImprovement, LogProbabilityOfImprovement +import bluesky.plans as bp +import numpy as np -class Acquisition: - def __init__(self, *args, **kwargs): - ... - - @staticmethod - def log_expected_improvement(candidates, agent): - *input_shape, n_dim = candidates.shape - - x = torch.as_tensor(candidates.reshape(-1, 1, n_dim)).double() - - LEI = LogExpectedImprovement( - model=agent.task_model, best_f=agent.best_sum_of_tasks, posterior_transform=agent.scalarization - ) - - lei = LEI.forward(x) - feas_log_prob = agent.feas_model(x) - - return (lei.reshape(input_shape) + feas_log_prob.reshape(input_shape)).detach().numpy() - - @staticmethod - def log_probability_of_improvement(candidates, agent): - *input_shape, n_dim = candidates.shape - - x = torch.as_tensor(candidates.reshape(-1, 1, n_dim)).double() - - LPI = LogProbabilityOfImprovement( - model=agent.task_model, best_f=agent.best_sum_of_tasks, posterior_transform=agent.scalarization - ) - - lpi = LPI.forward(x) - feas_log_prob = agent.feas_model(x) - - return (lpi.reshape(input_shape) + feas_log_prob.reshape(input_shape)).detach().numpy() +def default_acquisition_plan(dofs, inputs, dets): + uid = yield from bp.list_scan(dets, *[_ for items in zip(dofs, np.atleast_2d(inputs).T) for _ in items]) + return uid diff --git a/bloptools/bayesian/digestion.py b/bloptools/bayesian/digestion.py new file mode 100644 index 0000000..147d2d3 --- /dev/null +++ b/bloptools/bayesian/digestion.py @@ -0,0 +1,2 @@ +def default_digestion_function(db, uid): + return db[uid].table(fill=True) diff --git a/bloptools/test_functions.py b/bloptools/test_functions.py index d935eea..d61b7f8 100644 --- a/bloptools/test_functions.py +++ b/bloptools/test_functions.py @@ -2,22 +2,37 @@ def booth(x1, x2): + """ + The Booth function (https://en.wikipedia.org/wiki/Test_functions_for_optimization) + """ return (x1 + 2 * x2 - 7) ** 2 + (2 * x1 + x2 - 5) ** 2 def matyas(x1, x2): + """ + The Matyas function (https://en.wikipedia.org/wiki/Test_functions_for_optimization) + """ return 0.26 * (x1**2 + x2**2) - 0.48 * x1 * x2 def himmelblau(x1, x2): + """ + Himmelblau's function (https://en.wikipedia.org/wiki/Himmelblau%27s_function) + """ return (x1**2 + x2 - 11) ** 2 + (x1 + x2**2 - 7) ** 2 def constrained_himmelblau(x1, x2): + """ + Himmelblau's function, returns NaN outside the constraint + """ return np.where(x1**2 + x2**2 < 50, himmelblau(x1, x2), np.nan) def skewed_himmelblau(x1, x2): + """ + Himmelblau's function, with skewed coordinates + """ _x1 = 2 * x1 + x2 _x2 = 0.5 * (x1 - 2 * x2) @@ -25,20 +40,32 @@ def skewed_himmelblau(x1, x2): def bukin(x1, x2): + """ + Bukin function N.6 (https://en.wikipedia.org/wiki/Test_functions_for_optimization) + """ return 100 * np.sqrt(np.abs(x2 - 1e-2 * x1**2)) + 0.01 * np.abs(x1) def rastrigin(*x): + """ + The Rastrigin function in arbitrary dimensions (https://en.wikipedia.org/wiki/Rastrigin_function) + """ X = np.c_[x] return 10 * X.shape[-1] + (X**2 - 10 * np.cos(2 * np.pi * X)).sum(axis=1) def styblinski_tang(*x): + """ + Styblinski-Tang function in arbitrary dimensions (https://en.wikipedia.org/wiki/Test_functions_for_optimization) + """ X = np.c_[x] return 0.5 * (X**4 - 16 * X**2 + 5 * X).sum(axis=1) def ackley(*x): + """ + The Ackley function in arbitrary dimensions (https://en.wikipedia.org/wiki/Ackley_function) + """ X = np.c_[x] return ( -20 * np.exp(-0.2 * np.sqrt(0.5 * (X**2).sum(axis=1))) @@ -49,10 +76,16 @@ def ackley(*x): def gaussian_beam_waist(x1, x2): + """ + Simulating a misaligned Gaussian beam. The optimum is at (1, 1, 1, 1) + """ return np.sqrt(1 + 0.25 * (x1 - x2) ** 2 + 16 * (x1 + x2 - 2) ** 2) def himmelblau_digestion(db, uid): + """ + Digests Himmelblau's function into the feedback. + """ products = db[uid].table() for index, entry in products.iterrows(): @@ -63,7 +96,7 @@ def himmelblau_digestion(db, uid): def mock_kbs_digestion(db, uid): """ - Simulating a misaligned Gaussian beam. The optimum is at (1, 1, 1, 1) + Digests a beam waist and height into the feedback. """ products = db[uid].table() diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 98e64c1..807c23b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -4,30 +4,98 @@ Usage Working in the Bluesky environment, we need to pass four ingredients to the Bayesian agent: -* ``dofs``: A list of degrees of freedom for the agent. -* ``dets`` (Optional): A list of detectors to be triggered during acquisition. +* ``dofs``: A list of degrees of freedom for the agent to optimize over. +* ``dets``: A list of detectors to be triggered during acquisition. * ``tasks``: A list of tasks for the agent to maximize. * ``digestion``: A function that processes the output of the acquisition into the task values. -.. code-block:: python - import bloptools - dofs = [ - {"device": some_motor, "limits": (-0.5, 0.5), "kind": "active"}, - {"device": another_motor, "limits": (-0.5, 0.5), "kind": "active"}, + + +Degrees of freedom +++++++++++++++++++ + +Degrees of freedom (DOFs) are passed as an iterable of dicts, each containing at least the device and set of limits. + +.. code-block:: python + + my_dofs = [ + {"device": some_motor, "limits": (lower_limit, upper_limit)}, + {"device": another_motor, "limits": (lower_limit, upper_limit)}, ] - tasks = [ - {"key": "flux", "kind": "maximize", "transform": "log"} +Here ``some_motor`` and ``another_motor`` are ``ophyd`` objects. + + +Detectors ++++++++++ + +Detectors are triggered for each input. + +.. code-block:: python + + my_dets = [some_detector, some_other_detector] + + +Tasks ++++++ + +Degrees of freedom (DOFs) are passed as an iterable of dicts, each containing at least the device and set of limits. + +.. code-block:: python + + my_tasks = [ + {"key": "value_to_maximize"} ] - agent = bloptools.bayesian.Agent( - dofs=dofs, - tasks=tasks, - dets=[some_detector, another_detector], - digestion=your_digestion_function, - db=db, + + +Digestion ++++++++++ + +The digestion function is how we go from what is spit out by the acquisition to the actual values of the tasks. + +.. code-block:: python + + def my_digestion_function(db, uid): + + products = db[uid].table(fill=True) # a pandas DataFrame + + # for each entry, do some + for index, entry in products.iterrows(): + + raw_output_1 = entry.raw_output_1 + raw_output_2 = entry.raw_output_2 + + entry.loc[index, "value_to_maximize"] = some_fitness_function(raw_output_1, raw_output_2) + + return products + + + +Building the agent +++++++++++++++++++ + +Combining these with a databroker instance will construct an agent. + +.. code-block:: python + + import bloptools + + my_agent = bloptools.bayesian.Agent( + dofs=my_dofs, + dets=my_dets, + tasks=my_tasks, + digestion=my_digestion_function, + db=db, # a databroker instance ) RE(agent.initialize("qr", n_init=24)) + + +In the example below, the agent will loop over the following steps in each iteration of learning. + +#. Find the most interesting point (or points) to sample, and move the degrees of freedom there. +#. For each point, run an acquisition plan (e.g., trigger and read the detectors). +#. Digest the results of the acquisition to find the value of the task. From 8a02d1477457615f12d11445d3c554198a174e81 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 10:56:44 -0400 Subject: [PATCH 11/20] global rename acqf -> acq_func --- bloptools/bayesian/__init__.py | 166 ++++++++++-------- .../tutorials/constrained-himmelblau.ipynb | 2 +- docs/source/tutorials/hyperparameters.ipynb | 2 +- docs/source/tutorials/introduction.ipynb | 4 +- 4 files changed, 92 insertions(+), 82 deletions(-) diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index bf94b06..f683c0e 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -34,7 +34,7 @@ TASK_CONFIG = {} -ACQF_CONFIG = { +ACQ_FUNC_CONFIG = { "quasi-random": { "identifiers": ["qr", "quasi-random"], "pretty_name": "Quasi-random", @@ -168,7 +168,7 @@ def __init__( self.digestion = kwargs.get("digestion", default_digestion_function) self.dets = list(np.atleast_1d(kwargs.get("dets", []))) - self.acqf_config = kwargs.get("acqf_config", ACQF_CONFIG) + self.acq_func_config = kwargs.get("acq_func_config", ACQ_FUNC_CONFIG) self.table = pd.DataFrame() @@ -185,7 +185,7 @@ def _subset_inputs_sampler(self, kind=None, mode=None, n=MAX_TEST_INPUTS): def initialize( self, - acqf=None, + acq_func=None, n_init=4, data=None, hypers=None, @@ -210,12 +210,12 @@ def initialize( self.tell(new_table=data) # now let's get bayesian - elif acqf in ["qr"]: + elif acq_func in ["qr"]: yield from self.learn("qr", n_iter=1, n_per_iter=n_init, route=True) else: raise Exception( - """Could not initialize model! Either load a table, or specify an acqf from: + """Could not initialize model! Either load a table, or specify an acq_func from: ['qr'].""" ) @@ -361,7 +361,7 @@ def _subset_dof_limits(self, kind=None, mode=None): return torch.empty((2, 0)) def test_inputs(self, n=MAX_TEST_INPUTS): - return utils.sobol_sampler(self._acqf_bounds, n=n) + return utils.sobol_sampler(self._acq_func_bounds, n=n) @property def test_inputs_grid(self): @@ -380,7 +380,7 @@ def test_inputs_grid(self): ).swapaxes(0, -1) @property - def _acqf_bounds(self): + def _acq_func_bounds(self): return torch.tensor( [ dof["limits"] if dof["kind"] == "active" else tuple(2 * [dof["device"].read()[dof["device"].name]["value"]]) @@ -489,9 +489,9 @@ def train_models(self, **kwargs): print(f"trained models in {ttime.monotonic() - t0:.02f} seconds") @property - def acqf_info(self): + def acq_func_info(self): entries = [] - for k, d in self.acqf_config.items(): + for k, d in self.acq_func_config.items(): ret = "" ret += f'{d["pretty_name"].upper()} (identifiers: {d["identifiers"]})\n' ret += f'-> {d["description"]}' @@ -499,71 +499,73 @@ def acqf_info(self): print("\n\n".join(entries)) - def get_acquisition_function(self, acqf_identifier="ei", return_metadata=False, acqf_args={}, **kwargs): + def get_acquisition_function(self, acq_func_identifier="ei", return_metadata=False, acq_func_args={}, **kwargs): if not self._initialized: - raise RuntimeError(f'Can\'t construct acquisition function "{acqf_identifier}" (the agent is not initialized!)') + raise RuntimeError( + f'Can\'t construct acquisition function "{acq_func_identifier}" (the agent is not initialized!)' + ) - if acqf_identifier.lower() in ACQF_CONFIG["expected_improvement"]["identifiers"]: - acqf = botorch.acquisition.analytic.LogExpectedImprovement( + if acq_func_identifier.lower() in ACQ_FUNC_CONFIG["expected_improvement"]["identifiers"]: + acq_func = botorch.acquisition.analytic.LogExpectedImprovement( self.model_list, best_f=self.scalarized_fitness.max(), posterior_transform=self.task_scalarization, **kwargs, ) - acqf_meta = {"name": "expected improvement", "args": {}} + acq_func_meta = {"name": "expected improvement", "args": {}} - elif acqf_identifier.lower() in ACQF_CONFIG["probability_of_improvement"]["identifiers"]: - acqf = botorch.acquisition.analytic.LogProbabilityOfImprovement( + elif acq_func_identifier.lower() in ACQ_FUNC_CONFIG["probability_of_improvement"]["identifiers"]: + acq_func = botorch.acquisition.analytic.LogProbabilityOfImprovement( self.model_list, best_f=self.scalarized_fitness.max(), posterior_transform=self.task_scalarization, **kwargs, ) - acqf_meta = {"name": "probability of improvement", "args": {}} + acq_func_meta = {"name": "probability of improvement", "args": {}} - elif acqf_identifier.lower() in ACQF_CONFIG["expected_mean"]["identifiers"]: - acqf = botorch.acquisition.analytic.UpperConfidenceBound( + elif acq_func_identifier.lower() in ACQ_FUNC_CONFIG["expected_mean"]["identifiers"]: + acq_func = botorch.acquisition.analytic.UpperConfidenceBound( self.model_list, beta=0, posterior_transform=self.task_scalarization, **kwargs, ) - acqf_meta = {"name": "expected mean"} + acq_func_meta = {"name": "expected mean"} - elif acqf_identifier.lower() in ACQF_CONFIG["upper_confidence_bound"]["identifiers"]: - beta = ACQF_CONFIG["upper_confidence_bound"]["default_args"]["z"] ** 2 - acqf = botorch.acquisition.analytic.UpperConfidenceBound( + elif acq_func_identifier.lower() in ACQ_FUNC_CONFIG["upper_confidence_bound"]["identifiers"]: + beta = ACQ_FUNC_CONFIG["upper_confidence_bound"]["default_args"]["z"] ** 2 + acq_func = botorch.acquisition.analytic.UpperConfidenceBound( self.model_list, beta=beta, posterior_transform=self.task_scalarization, **kwargs, ) - acqf_meta = {"name": "upper confidence bound", "args": {"beta": beta}} + acq_func_meta = {"name": "upper confidence bound", "args": {"beta": beta}} else: - raise ValueError(f'Unrecognized acquisition acqf_identifier "{acqf_identifier}".') + raise ValueError(f'Unrecognized acquisition acq_func_identifier "{acq_func_identifier}".') - return (acqf, acqf_meta) if return_metadata else acqf + return (acq_func, acq_func_meta) if return_metadata else acq_func - def ask(self, acqf_identifier="ei", n=1, route=True, return_metadata=False): - if acqf_identifier.lower() == "qr": + def ask(self, acq_func_identifier="ei", n=1, route=True, return_metadata=False): + if acq_func_identifier.lower() == "qr": active_X = self._subset_inputs_sampler(n=n, kind="active", mode="on").squeeze(1).numpy() - acqf_meta = {"name": "quasi-random", "args": {}} + acq_func_meta = {"name": "quasi-random", "args": {}} elif n == 1: - active_X, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) + active_X, acq_func_meta = self.ask_single(acq_func_identifier, return_metadata=True) elif n > 1: active_x_list = [] for i in range(n): - active_x, acqf_meta = self.ask_single(acqf_identifier, return_metadata=True) + active_x, acq_func_meta = self.ask_single(acq_func_identifier, return_metadata=True) active_x_list.append(active_x) if i < (n - 1): - x = np.c_[active_x, acqf_meta["passive_values"]] + x = np.c_[active_x, acq_func_meta["passive_values"]] task_samples = [task["model"].posterior(torch.tensor(x)).sample().item() for task in self.tasks] fantasy_table = pd.DataFrame( - np.c_[active_x, acqf_meta["passive_values"], np.atleast_2d(task_samples)], + np.c_[active_x, acq_func_meta["passive_values"], np.atleast_2d(task_samples)], columns=[ *self._subset_dof_names(kind="active", mode="on"), *self._subset_dof_names(kind="passive", mode="on"), @@ -578,11 +580,11 @@ def ask(self, acqf_identifier="ei", n=1, route=True, return_metadata=False): if route: active_X = active_X[utils.route(self._read_subset_devices(kind="active", mode="on"), active_X)] - return (active_X, acqf_meta) if return_metadata else active_X + return (active_X, acq_func_meta) if return_metadata else active_X def ask_single( self, - acqf_identifier="ei", + acq_func_identifier="ei", return_metadata=False, ): """ @@ -591,15 +593,17 @@ def ask_single( t0 = ttime.monotonic() - acqf, acqf_meta = self.get_acquisition_function(acqf_identifier=acqf_identifier, return_metadata=True) + acq_func, acq_func_meta = self.get_acquisition_function( + acq_func_identifier=acq_func_identifier, return_metadata=True + ) BATCH_SIZE = 1 NUM_RESTARTS = 8 RAW_SAMPLES = 256 - candidates, _ = botorch.optim.optimize_acqf( - acq_function=acqf, - bounds=self._acqf_bounds, + candidates, _ = botorch.optim.optimize_acq_func( + acq_function=acq_func, + bounds=self._acq_func_bounds, q=BATCH_SIZE, num_restarts=NUM_RESTARTS, raw_samples=RAW_SAMPLES, # used for intialization heuristic @@ -610,12 +614,12 @@ def ask_single( active_x = x[..., [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")]] passive_x = x[..., [dof["kind"] != "active" for dof in self._subset_dofs(mode="on")]] - acqf_meta["passive_values"] = passive_x + acq_func_meta["passive_values"] = passive_x if self.verbose: print(f"found point {x} in {ttime.monotonic() - t0:.02f} seconds") - return (active_x, acqf_meta) if return_metadata else active_x + return (active_x, acq_func_meta) if return_metadata else active_x def acquire(self, active_inputs): """ @@ -653,7 +657,7 @@ def acquire(self, active_inputs): def learn( self, - acqf_identifier, + acq_func_identifier, n_iter=1, n_per_iter=1, reuse_hypers=True, @@ -668,11 +672,13 @@ def learn( """ for iteration in range(n_iter): - x, acqf_meta = self.ask(n=n_per_iter, acqf_identifier=acqf_identifier, return_metadata=True, **kwargs) + x, acq_func_meta = self.ask( + n=n_per_iter, acq_func_identifier=acq_func_identifier, return_metadata=True, **kwargs + ) new_table = yield from self.acquire(x) - new_table.loc[:, "acqf"] = acqf_meta["name"] + new_table.loc[:, "acq_func"] = acq_func_meta["name"] self.tell(new_table=new_table, reuse_hypers=reuse_hypers) @@ -834,50 +840,52 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[axes[0]]["limits"]) ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) - def plot_acquisition(self, acqfs=["ei"], **kwargs): + def plot_acquisition(self, acq_funcs=["ei"], **kwargs): if self._n_subset_dofs(kind="active", mode="on") == 1: - self._plot_acq_one_dof(acqfs=acqfs, **kwargs) + self._plot_acq_one_dof(acq_funcs=acq_funcs, **kwargs) else: - self._plot_acq_many_dofs(acqfs=acqfs, **kwargs) + self._plot_acq_many_dofs(acq_funcs=acq_funcs, **kwargs) - def _plot_acq_one_dof(self, acqfs, lw=1e0, **kwargs): + def _plot_acq_one_dof(self, acq_funcs, lw=1e0, **kwargs): self.acq_fig, self.acq_axes = plt.subplots( 1, - len(acqfs), - figsize=(4 * len(acqfs), 4), + len(acq_funcs), + figsize=(4 * len(acq_funcs), 4), sharex=True, constrained_layout=True, ) self.acq_axes = np.atleast_1d(self.acq_axes) - for iacqf, acqf_identifier in enumerate(acqfs): - color = DEFAULT_COLOR_LIST[iacqf] + for iacq_func, acq_func_identifier in enumerate(acq_funcs): + color = DEFAULT_COLOR_LIST[iacq_func] - acqf, acqf_meta = self.get_acquisition_function(acqf_identifier, return_metadata=True) + acq_func, acq_func_meta = self.get_acquisition_function(acq_func_identifier, return_metadata=True) x = self.test_inputs_grid *input_shape, input_dim = x.shape - obj = acqf.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) + obj = acq_func.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) - if acqf_identifier in ["ei", "pi"]: + if acq_func_identifier in ["ei", "pi"]: obj = obj.exp() - self.acq_axes[iacqf].set_title(acqf_meta["name"]) + self.acq_axes[iacq_func].set_title(acq_func_meta["name"]) on_dofs_are_active_mask = [dof["kind"] == "active" for dof in self._subset_dofs(mode="on")] - self.acq_axes[iacqf].plot(x[..., on_dofs_are_active_mask].squeeze(), obj.detach().numpy(), lw=lw, color=color) + self.acq_axes[iacq_func].plot( + x[..., on_dofs_are_active_mask].squeeze(), obj.detach().numpy(), lw=lw, color=color + ) - self.acq_axes[iacqf].set_xlim(self._subset_dofs(kind="active", mode="on")[0]["limits"]) + self.acq_axes[iacq_func].set_xlim(self._subset_dofs(kind="active", mode="on")[0]["limits"]) def _plot_acq_many_dofs( - self, acqfs, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16, **kwargs + self, acq_funcs, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16, **kwargs ): self.acq_fig, self.acq_axes = plt.subplots( 1, - len(acqfs), - figsize=(4 * len(acqfs), 4), + len(acq_funcs), + figsize=(4 * len(acq_funcs), 4), sharex=True, sharey=True, constrained_layout=True, @@ -892,16 +900,16 @@ def _plot_acq_many_dofs( x = self.test_inputs_grid.squeeze() if gridded else self.test_inputs(n=MAX_TEST_INPUTS) *input_shape, input_dim = x.shape - for iacqf, acqf_identifier in enumerate(acqfs): - acqf, acqf_meta = self.get_acquisition_function(acqf_identifier, return_metadata=True) + for iacq_func, acq_func_identifier in enumerate(acq_funcs): + acq_func, acq_func_meta = self.get_acquisition_function(acq_func_identifier, return_metadata=True) - obj = acqf.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) - if acqf_identifier in ["ei", "pi"]: + obj = acq_func.forward(x.reshape(-1, 1, input_dim)).reshape(input_shape) + if acq_func_identifier in ["ei", "pi"]: obj = obj.exp() if gridded: - self.acq_axes[iacqf].set_title(acqf_meta["name"]) - obj_ax = self.acq_axes[iacqf].pcolormesh( + self.acq_axes[iacq_func].set_title(acq_func_meta["name"]) + obj_ax = self.acq_axes[iacq_func].pcolormesh( x[..., 0], x[..., 1], obj.detach().numpy(), @@ -909,17 +917,17 @@ def _plot_acq_many_dofs( cmap=cmap, ) - self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacqf], location="bottom", aspect=32, shrink=0.8) + self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacq_func], location="bottom", aspect=32, shrink=0.8) else: - self.acq_axes[iacqf].set_title(acqf_meta["name"]) - obj_ax = self.acq_axes[iacqf].scatter( + self.acq_axes[iacq_func].set_title(acq_func_meta["name"]) + obj_ax = self.acq_axes[iacq_func].scatter( x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], c=obj.detach().numpy(), ) - self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacqf], location="bottom", aspect=32, shrink=0.8) + self.acq_fig.colorbar(obj_ax, ax=self.acq_axes[iacq_func], location="bottom", aspect=32, shrink=0.8) for ax in self.acq_axes.ravel(): ax.set_xlim(*self._subset_dofs(kind="active", mode="on")[axes[0]]["limits"]) @@ -977,10 +985,10 @@ def _plot_feas_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLO vmax=1, ) - # self.acq_fig.colorbar(obj_ax, ax=self.feas_axes[iacqf], location="bottom", aspect=32, shrink=0.8) + # self.acq_fig.colorbar(obj_ax, ax=self.feas_axes[iacq_func], location="bottom", aspect=32, shrink=0.8) else: - # self.feas_axes.set_title(acqf_meta["name"]) + # self.feas_axes.set_title(acq_func_meta["name"]) self.feas_axes[1].scatter( x.detach().numpy()[..., axes[0]], x.detach().numpy()[..., axes[1]], @@ -1025,9 +1033,11 @@ def plot_history(self, x_key="index", show_all_tasks=False): ) hist_axes = np.atleast_1d(hist_axes) - unique_strategies, acqf_index, acqf_inverse = np.unique(self.table.acqf, return_index=True, return_inverse=True) + unique_strategies, acq_func_index, acq_func_inverse = np.unique( + self.table.acq_func, return_index=True, return_inverse=True + ) - sample_colors = np.array(DEFAULT_COLOR_LIST)[acqf_inverse] + sample_colors = np.array(DEFAULT_COLOR_LIST)[acq_func_inverse] if show_all_tasks: for itask, task in enumerate(self.tasks): @@ -1049,9 +1059,9 @@ def plot_history(self, x_key="index", show_all_tasks=False): hist_axes[-1].set_xlabel(x_key) handles = [] - for i_acqf, acqf in enumerate(unique_strategies): - # i_acqf = np.argsort(acqf_index)[i_handle] - handles.append(Patch(color=DEFAULT_COLOR_LIST[i_acqf], label=acqf)) + for i_acq_func, acq_func in enumerate(unique_strategies): + # i_acq_func = np.argsort(acq_func_index)[i_handle] + handles.append(Patch(color=DEFAULT_COLOR_LIST[i_acq_func], label=acq_func)) legend = hist_axes[0].legend(handles=handles, fontsize=8) legend.set_title("acquisition function") diff --git a/docs/source/tutorials/constrained-himmelblau.ipynb b/docs/source/tutorials/constrained-himmelblau.ipynb index bc887e1..01c06d8 100644 --- a/docs/source/tutorials/constrained-himmelblau.ipynb +++ b/docs/source/tutorials/constrained-himmelblau.ipynb @@ -152,7 +152,7 @@ }, "outputs": [], "source": [ - "agent.plot_acquisition(acqf=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acquisition(acq_func=[\"ei\", \"pi\", \"ucb\"])" ] }, { diff --git a/docs/source/tutorials/hyperparameters.ipynb b/docs/source/tutorials/hyperparameters.ipynb index 03830f1..d37b5c1 100644 --- a/docs/source/tutorials/hyperparameters.ipynb +++ b/docs/source/tutorials/hyperparameters.ipynb @@ -89,7 +89,7 @@ " db=db,\n", ")\n", "\n", - "RE(agent.initialize(acqf=\"qr\", n_init=16))\n", + "RE(agent.initialize(acq_func=\"qr\", n_init=16))\n", "\n", "agent.plot_tasks()" ] diff --git a/docs/source/tutorials/introduction.ipynb b/docs/source/tutorials/introduction.ipynb index c47e2f0..bdefdf0 100644 --- a/docs/source/tutorials/introduction.ipynb +++ b/docs/source/tutorials/introduction.ipynb @@ -175,7 +175,7 @@ "metadata": {}, "outputs": [], "source": [ - "agent.acqf_info" + "agent.acq_func_info" ] }, { @@ -187,7 +187,7 @@ }, "outputs": [], "source": [ - "agent.plot_acquisition(acqfs=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acquisition(acq_funcs=[\"ei\", \"pi\", \"ucb\"])" ] }, { From 5ea4c350a92082d836c3601ff3cc31f06d0696ac Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 11:07:53 -0400 Subject: [PATCH 12/20] I broke botorch --- .github/workflows/testing.yml | 2 -- bloptools/bayesian/__init__.py | 32 ++++++++++++++++---------------- bloptools/bayesian/kernels.py | 13 ++++++++----- bloptools/devices.py | 3 +-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 26af390..83ac879 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -12,8 +12,6 @@ jobs: strategy: matrix: host-os: ["ubuntu-latest"] - # python-version: ["3.9"] - # host-os: ["ubuntu-latest", "macos-latest", "windows-latest"] python-version: ["3.8", "3.9", "3.10"] fail-fast: false diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index f683c0e..1fa7ee1 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -13,13 +13,13 @@ import pandas as pd import scipy as sp import torch -from acquisition import default_acquisition_plan -from digestion import default_digestion_function from matplotlib import pyplot as plt from matplotlib.patches import Patch from .. import utils from . import models +from .acquisition import default_acquisition_plan +from .digestion import default_digestion_function warnings.filterwarnings("ignore", category=botorch.exceptions.warnings.InputDataWarning) @@ -181,7 +181,7 @@ def _subset_inputs_sampler(self, kind=None, mode=None, n=MAX_TEST_INPUTS): Returns $n$ quasi-randomly sampled inputs in the bounded parameter space """ transform = self._subset_input_transform(kind=kind, mode=mode) - return transform.untransform(utils.normalized_sobol_sampler(n, d=self._n_subset_dofs(kind=kind, mode=mode))) + return transform.untransform(utils.normalized_sobol_sampler(n, d=self._len_subset_dofs(kind=kind, mode=mode))) def initialize( self, @@ -236,7 +236,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): self.table.loc[:, fitnesses.columns] = fitnesses.values self.table.loc[:, "total_fitness"] = fitnesses.values.sum(axis=1) - skew_dims = [tuple(np.arange(self._n_subset_dofs(mode="on")))] + skew_dims = [tuple(np.arange(self._len_subset_dofs(mode="on")))] if self._initialized: cached_hypers = self.hypers @@ -342,7 +342,7 @@ def _dof_mask(self, kind=None, mode=None): def _subset_dofs(self, kind=None, mode=None): return [dof for dof, m in zip(self.dofs, self._dof_mask(kind, mode)) if m] - def _n_subset_dofs(self, kind=None, mode=None): + def _len_subset_dofs(self, kind=None, mode=None): return len(self._subset_dofs(kind, mode)) def _subset_devices(self, kind=None, mode=None): @@ -365,7 +365,7 @@ def test_inputs(self, n=MAX_TEST_INPUTS): @property def test_inputs_grid(self): - n_side = int(MAX_TEST_INPUTS ** (1 / self._n_subset_dofs(kind="active", mode="on"))) + n_side = int(MAX_TEST_INPUTS ** (1 / self._len_subset_dofs(kind="active", mode="on"))) return torch.tensor( np.r_[ np.meshgrid( @@ -438,9 +438,9 @@ def sampler(self, n): """ min_power_of_two = 2 ** int(np.ceil(np.log(n) / np.log(2))) subset = np.random.choice(min_power_of_two, size=n, replace=False) - return sp.stats.qmc.Sobol(d=self._n_subset_dofs(kind="active", mode="on"), scramble=True).random(n=min_power_of_two)[ - subset - ] + return sp.stats.qmc.Sobol(d=self._len_subset_dofs(kind="active", mode="on"), scramble=True).random( + n=min_power_of_two + )[subset] def _set_hypers(self, hypers): for task in self.tasks: @@ -601,7 +601,7 @@ def ask_single( NUM_RESTARTS = 8 RAW_SAMPLES = 256 - candidates, _ = botorch.optim.optimize_acq_func( + candidates, _ = botorch.optim.optimize_acqf( acq_function=acq_func, bounds=self._acq_func_bounds, q=BATCH_SIZE, @@ -707,7 +707,7 @@ def go_to(self, inputs): # yield from self.go_to(self.best_sum_of_tasks_inputs) def plot_tasks(self, **kwargs): - if self._n_subset_dofs(kind="active", mode="on") == 1: + if self._len_subset_dofs(kind="active", mode="on") == 1: self._plot_tasks_one_dof(**kwargs) else: self._plot_tasks_many_dofs(**kwargs) @@ -756,7 +756,7 @@ def _plot_tasks_one_dof(self, size=16, lw=1e0): def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16): if gridded is None: - gridded = self._n_subset_dofs(kind="active", mode="on") == 2 + gridded = self._len_subset_dofs(kind="active", mode="on") == 2 self.task_fig, self.task_axes = plt.subplots( self.n_tasks, @@ -841,7 +841,7 @@ def _plot_tasks_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) def plot_acquisition(self, acq_funcs=["ei"], **kwargs): - if self._n_subset_dofs(kind="active", mode="on") == 1: + if self._len_subset_dofs(kind="active", mode="on") == 1: self._plot_acq_one_dof(acq_funcs=acq_funcs, **kwargs) else: @@ -892,7 +892,7 @@ def _plot_acq_many_dofs( ) if gridded is None: - gridded = self._n_subset_dofs(kind="active", mode="on") == 2 + gridded = self._len_subset_dofs(kind="active", mode="on") == 2 self.acq_axes = np.atleast_1d(self.acq_axes) # self.acq_fig.suptitle(f"(x,y)=({self.dofs[axes[0]].name},{self.dofs[axes[1]].name})") @@ -934,7 +934,7 @@ def _plot_acq_many_dofs( ax.set_ylim(*self._subset_dofs(kind="active", mode="on")[axes[1]]["limits"]) def plot_feasibility(self, **kwargs): - if self._n_subset_dofs(kind="active", mode="on") == 1: + if self._len_subset_dofs(kind="active", mode="on") == 1: self._plot_feas_one_dof(**kwargs) else: @@ -959,7 +959,7 @@ def _plot_feas_many_dofs(self, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLO self.feas_fig, self.feas_axes = plt.subplots(1, 2, figsize=(8, 4), sharex=True, sharey=True, constrained_layout=True) if gridded is None: - gridded = self._n_subset_dofs(kind="active", mode="on") == 2 + gridded = self._len_subset_dofs(kind="active", mode="on") == 2 data_ax = self.feas_axes[0].scatter( *self.inputs.values.T[:2], diff --git a/bloptools/bayesian/kernels.py b/bloptools/bayesian/kernels.py index 66066d2..dc46438 100644 --- a/bloptools/bayesian/kernels.py +++ b/bloptools/bayesian/kernels.py @@ -169,14 +169,17 @@ def forward(self, x1, x2, diag=False, **params): trans_x1 = torch.matmul(self.latent_transform.unsqueeze(1), (x1 - mean).unsqueeze(-1)).squeeze(-1) trans_x2 = torch.matmul(self.latent_transform.unsqueeze(1), (x2 - mean).unsqueeze(-1)).squeeze(-1) - d = self.covar_dist(trans_x1, trans_x2, diag=diag, **params) + distance = self.covar_dist(trans_x1, trans_x2, diag=diag, **params) if self.num_outputs == 1: - d = d.squeeze(0) + distance = distance.squeeze(0) + outputscale = self.outputscale if self.scale_output else 1.0 + + # special cases of the Matern function if self.nu == 0.5: - return (self.outputscale if self.scale_output else 1.0) * torch.exp(-d) + return outputscale * torch.exp(-distance) if self.nu == 1.5: - return (self.outputscale if self.scale_output else 1.0) * (1 + d) * torch.exp(-d) + return outputscale * (1 + distance) * torch.exp(-distance) if self.nu == 2.5: - return (self.outputscale if self.scale_output else 1.0) * (1 + d + d**2 / 3) * torch.exp(-d) + return outputscale * (1 + distance + distance**2 / 3) * torch.exp(-distance) diff --git a/bloptools/devices.py b/bloptools/devices.py index 5440113..1b669c2 100644 --- a/bloptools/devices.py +++ b/bloptools/devices.py @@ -20,8 +20,7 @@ class RODOF(DOF): Read-only degree of freedom """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + ... class BrownianMotion(RODOF): From 7463bc1a5eceb8a98ea801d223307e9ecfed5bb5 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 11:23:43 -0400 Subject: [PATCH 13/20] enforce read-only for RODOF --- bloptools/devices.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bloptools/devices.py b/bloptools/devices.py index 1b669c2..a203eaf 100644 --- a/bloptools/devices.py +++ b/bloptools/devices.py @@ -6,6 +6,10 @@ DEFAULT_BOUNDS = (-5.0, +5.0) +class ReadOnlyError(Exception): + ... + + class DOF(Signal): """ Degree of freedom @@ -20,7 +24,11 @@ class RODOF(DOF): Read-only degree of freedom """ - ... + def put(self, value, *, timestamp=None, force=False): + raise ReadOnlyError(f'Cannot put, DOF "{self.name}" is read-only!') + + def set(self, value, *, timestamp=None, force=False): + raise ReadOnlyError(f'Cannot set, DOF "{self.name}" is read-only!') class BrownianMotion(RODOF): From 17c144e67f2a0c0d88b506202f6d053e4282cac4 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 19:32:42 -0400 Subject: [PATCH 14/20] addressing comments --- bloptools/bayesian/__init__.py | 29 ++++++++++++++++++----------- bloptools/utils.py | 3 ++- docs/source/usage.rst | 13 +++++++++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/bloptools/bayesian/__init__.py b/bloptools/bayesian/__init__.py index 1fa7ee1..d1d643a 100644 --- a/bloptools/bayesian/__init__.py +++ b/bloptools/bayesian/__init__.py @@ -2,6 +2,7 @@ import time as ttime import warnings from collections import OrderedDict +from collections.abc import Mapping import bluesky.plan_stubs as bps import bluesky.plans as bp # noqa F401 @@ -68,8 +69,8 @@ def _validate_and_prepare_dofs(dofs): for dof in dofs: - if type(dof) is not dict: - raise ValueError("Supplied dofs must be a list of dicts!") + if not isinstance(dof, Mapping): + raise ValueError("Supplied dofs must be an iterable of mappings (e.g. a dict)!") if "device" not in dof.keys(): raise ValueError("Each DOF must have a device!") @@ -77,10 +78,10 @@ def _validate_and_prepare_dofs(dofs): if "limits" not in dof.keys(): dof["limits"] = (-np.inf, np.inf) - dof["limits"] = tuple(np.array(dof["limits"]).astype(float)) + dof["limits"] = tuple(np.array(dof["limits"], dtype=float)) - # read-only DOFs (without a set method) are passive by default - dof["kind"] = dof.get("kind", "active" if hasattr(dof["device"], "set") else "passive") + # dofs are passive by default + dof["kind"] = dof.get("kind", "passive") if dof["kind"] not in ["active", "passive"]: raise ValueError('DOF kinds must be one of "active" or "passive"') @@ -89,16 +90,20 @@ def _validate_and_prepare_dofs(dofs): raise ValueError('DOF modes must be one of "on" or "off"') dof_names = [dof["device"].name for dof in dofs] - if not len(set(dof_names)) == len(dof_names): - raise ValueError("Names of DOFs must be unique!") + + # check that dof names are unique + unique_dof_names, counts = np.unique(dof_names, return_counts=True) + duplicate_dof_names = unique_dof_names[counts > 1] + if len(duplicate_dof_names) > 0: + raise ValueError(f'Duplicate name(s) in supplied dofs: "{duplicate_dof_names}"') return list(dofs) def _validate_and_prepare_tasks(tasks): for task in tasks: - if type(task) is not dict: - raise ValueError("Supplied tasks must be a list of dicts!") + if not isinstance(task, Mapping): + raise ValueError("Supplied tasks must be an iterable of mappings (e.g. a dict)!") if task["kind"] not in ["minimize", "maximize"]: raise ValueError('"mode" must be specified as either "minimize" or "maximize"') if "weight" not in task.keys(): @@ -107,8 +112,10 @@ def _validate_and_prepare_tasks(tasks): task["limits"] = (-np.inf, np.inf) task_keys = [task["key"] for task in tasks] - if not len(set(task_keys)) == len(task_keys): - raise ValueError("Keys of tasks must be unique!") + unique_task_keys, counts = np.unique(task_keys, return_counts=True) + duplicate_task_keys = unique_task_keys[counts > 1] + if len(duplicate_task_keys) > 0: + raise ValueError(f'Duplicate key(s) in supplied tasks: "{duplicate_task_keys}"') return list(tasks) diff --git a/bloptools/utils.py b/bloptools/utils.py index de1b90c..67e1ec1 100644 --- a/bloptools/utils.py +++ b/bloptools/utils.py @@ -7,7 +7,8 @@ def sobol_sampler(bounds, n, q=1): """ - Returns $n$ quasi-randomly sampled points within the bounds. + Returns $n$ quasi-randomly sampled points within the bounds (a 2 by d tensor) + and batch size $q$ """ return botorch.utils.sampling.draw_sobol_samples(bounds, n=n, q=q) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 807c23b..40ca85d 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,12 +5,10 @@ Usage Working in the Bluesky environment, we need to pass four ingredients to the Bayesian agent: * ``dofs``: A list of degrees of freedom for the agent to optimize over. -* ``dets``: A list of detectors to be triggered during acquisition. * ``tasks``: A list of tasks for the agent to maximize. * ``digestion``: A function that processes the output of the acquisition into the task values. - - - +* ``dets``: (Optional) A list of detectors to be triggered during acquisition. +* ``acquisition``: (Optional) A Bluesky plan to run for each set of inputs. Degrees of freedom @@ -38,6 +36,13 @@ Detectors are triggered for each input. my_dets = [some_detector, some_other_detector] + +Acquisition ++++++++++ + +We run this plan for each set of DOF inputs. By default, this just moves the active DOFs to the desired points and triggers the supplied detectors. + + Tasks +++++ From 50dd161287e676b26acace73de27f8147db9bdb4 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 20:01:25 -0400 Subject: [PATCH 15/20] rst is mean --- docs/source/usage.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 40ca85d..b5b0ba4 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -38,7 +38,7 @@ Detectors are triggered for each input. Acquisition -+++++++++ ++++++++++++ We run this plan for each set of DOF inputs. By default, this just moves the active DOFs to the desired points and triggers the supplied detectors. From 5d947ffa34a2d4c6ca2f892f9143e792844c96c8 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 20:55:04 -0400 Subject: [PATCH 16/20] fixed devices --- bloptools/devices.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bloptools/devices.py b/bloptools/devices.py index a203eaf..0afc20e 100644 --- a/bloptools/devices.py +++ b/bloptools/devices.py @@ -15,11 +15,10 @@ class DOF(Signal): Degree of freedom """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + ... -class RODOF(DOF): +class DOFRO(DOF): """ Read-only degree of freedom """ @@ -31,7 +30,7 @@ def set(self, value, *, timestamp=None, force=False): raise ReadOnlyError(f'Cannot set, DOF "{self.name}" is read-only!') -class BrownianMotion(RODOF): +class BrownianMotion(DOFRO): """ Read-only degree of freedom simulating brownian motion """ @@ -61,6 +60,9 @@ class TimeReadback(SignalRO): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + def get(self): + return ttime.time() + class ConstantReadback(SignalRO): """ From a4e068eb89d8e955671e5a7d26bc34d847205732 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 21:27:35 -0400 Subject: [PATCH 17/20] tweaked docs --- docs/source/conf.py | 14 +------- docs/source/tutorials/introduction.ipynb | 2 +- docs/source/usage.rst | 41 +++++++++++++----------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1575f81..c6993bb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -107,10 +107,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" -import sphinx_rtd_theme - -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "furo" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -125,15 +122,6 @@ # Custom sidebar templates, must be a dictionary that maps document names # to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - "**": [ - "relations.html", # needs 'show_related': True theme option to display - "searchbox.html", - ] -} # -- Options for HTMLHelp output ------------------------------------------ diff --git a/docs/source/tutorials/introduction.ipynb b/docs/source/tutorials/introduction.ipynb index bdefdf0..f33e2ca 100644 --- a/docs/source/tutorials/introduction.ipynb +++ b/docs/source/tutorials/introduction.ipynb @@ -17,7 +17,7 @@ "source": [ "This tutorial is an introduction to the syntax used by the optimizer, as well as the principles of Bayesian optimization in general.\n", "\n", - "We'll start by minimizing the Rastrigin function in one dimension, which looks like this:" + "We'll start by minimizing the Styblinski-Tang function in one dimension, which looks like this:" ] }, { diff --git a/docs/source/usage.rst b/docs/source/usage.rst index b5b0ba4..de2267e 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -26,32 +26,17 @@ Degrees of freedom (DOFs) are passed as an iterable of dicts, each containing at Here ``some_motor`` and ``another_motor`` are ``ophyd`` objects. -Detectors -+++++++++ - -Detectors are triggered for each input. - -.. code-block:: python - - my_dets = [some_detector, some_other_detector] - - - -Acquisition -+++++++++++ - -We run this plan for each set of DOF inputs. By default, this just moves the active DOFs to the desired points and triggers the supplied detectors. - Tasks +++++ -Degrees of freedom (DOFs) are passed as an iterable of dicts, each containing at least the device and set of limits. +Tasks are what we want our agent to try to optimize (either maximize or minimize). We can pass as many as we'd like: .. code-block:: python my_tasks = [ - {"key": "value_to_maximize"} + {"key": "something_to_maximize", "kind": "maximize"} + {"key": "something_to_minimize", "kind": "minimize"} ] @@ -73,11 +58,29 @@ The digestion function is how we go from what is spit out by the acquisition to raw_output_1 = entry.raw_output_1 raw_output_2 = entry.raw_output_2 - entry.loc[index, "value_to_maximize"] = some_fitness_function(raw_output_1, raw_output_2) + entry.loc[index, "thing_to_maximize"] = some_fitness_function(raw_output_1, raw_output_2) return products +Detectors ++++++++++ + +Detectors are triggered for each input. + +.. code-block:: python + + my_dets = [some_detector, some_other_detector] + + + +Acquisition ++++++++++++ + +We run this plan for each set of DOF inputs. By default, this just moves the active DOFs to the desired points and triggers the supplied detectors. + + + Building the agent ++++++++++++++++++ From d1fa15d8eb2038ad6930ffbffcbec33994d5e8b7 Mon Sep 17 00:00:00 2001 From: Thomas Morris Date: Thu, 10 Aug 2023 21:35:20 -0400 Subject: [PATCH 18/20] typos --- docs/source/tutorials/introduction.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/tutorials/introduction.ipynb b/docs/source/tutorials/introduction.ipynb index f33e2ca..1d4eef1 100644 --- a/docs/source/tutorials/introduction.ipynb +++ b/docs/source/tutorials/introduction.ipynb @@ -81,7 +81,7 @@ "metadata": {}, "source": [ "\n", - "This degree of freedom will move around a variable called `x1`. The agent automatically samples at different inputs, but we often need some post-processing after data collection. In this case, we need to give the agent a way to compute the Rastrigin function. We accomplish this with a digestion function, which always takes `(db, uid)` as an input. For each entry, we compute the function:\n" + "This degree of freedom will move around a variable called `x1`. The agent automatically samples at different inputs, but we often need some post-processing after data collection. In this case, we need to give the agent a way to compute the Styblinski-Tang function. We accomplish this with a digestion function, which always takes `(db, uid)` as an input. For each entry, we compute the function:\n" ] }, { @@ -106,7 +106,7 @@ "id": "dad64303", "metadata": {}, "source": [ - "The next ingredient is a task, which gives the agent something to do. We want it to minimize the Rastrigin function, so we make a task that will try to minimize the output of the digestion function called \"rastrigin\"." + "The next ingredient is a task, which gives the agent something to do. We want it to minimize the Styblinski-Tang function, so we make a task that will try to minimize the output of the digestion function called \"styblinski-tang\"." ] }, { From e1c9ce5230af257f1cec9f8dcc78252c6d0038fb Mon Sep 17 00:00:00 2001 From: Thomas Morris <41275226+thomaswmorris@users.noreply.github.com> Date: Thu, 10 Aug 2023 21:45:56 -0400 Subject: [PATCH 19/20] added furo --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4169c3c..6782e7c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,7 @@ black pytest-codecov coverage flake8 +furo isort nbstripout pre-commit From 671dfb0a8cf88a4bdce9da4823b9cb1f65dd14cb Mon Sep 17 00:00:00 2001 From: Thomas Morris <41275226+thomaswmorris@users.noreply.github.com> Date: Thu, 10 Aug 2023 21:47:18 -0400 Subject: [PATCH 20/20] reword usage.rst --- docs/source/usage.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index de2267e..905c0e6 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,7 +5,7 @@ Usage Working in the Bluesky environment, we need to pass four ingredients to the Bayesian agent: * ``dofs``: A list of degrees of freedom for the agent to optimize over. -* ``tasks``: A list of tasks for the agent to maximize. +* ``tasks``: A list of tasks for the agent to optimize. * ``digestion``: A function that processes the output of the acquisition into the task values. * ``dets``: (Optional) A list of detectors to be triggered during acquisition. * ``acquisition``: (Optional) A Bluesky plan to run for each set of inputs.