diff --git a/bloptools/bayesian/acquisition/__init__.py b/bloptools/bayesian/acquisition/__init__.py index c0d8f39..854e63f 100644 --- a/bloptools/bayesian/acquisition/__init__.py +++ b/bloptools/bayesian/acquisition/__init__.py @@ -18,27 +18,27 @@ config[acq_func_name]["identifiers"].append(acq_func_name) -def parse_acq_func(acq_func_identifier): +def parse_acq_func_identifier(identifier): acq_func_name = None for _acq_func_name in config.keys(): - if acq_func_identifier.lower() in config[_acq_func_name]["identifiers"]: + if identifier.lower() in config[_acq_func_name]["identifiers"]: acq_func_name = _acq_func_name if acq_func_name is None: - raise ValueError(f'Unrecognized acquisition function identifier "{acq_func_identifier}".') + raise ValueError(f'Unrecognized acquisition function identifier "{identifier}".') return acq_func_name -def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=True, verbose=False, **acq_func_kwargs): - """ - Generates an acquisition function from a supplied identifier. +def get_acquisition_function(agent, identifier="qei", return_metadata=True, verbose=False, **acq_func_kwargs): + """Generates an acquisition function from a supplied identifier. A list of acquisition functions and + their identifiers can be found at `agent.all_acq_funcs`. """ - acq_func_name = parse_acq_func(acq_func_identifier) - acq_func_config = agent.acq_func_config["upper_confidence_bound"] + acq_func_name = parse_acq_func_identifier(identifier) + acq_func_config = config["upper_confidence_bound"] - if agent.acq_func_config[acq_func_name]["multitask_only"] and (agent.num_tasks == 1): + if config[acq_func_name]["multitask_only"] and (agent.num_tasks == 1): raise ValueError(f'Acquisition function "{acq_func_name}" is only for multi-task optimization problems!') # there is probably a better way to structure this @@ -46,7 +46,7 @@ def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=T acq_func = analytic.ConstrainedLogExpectedImprovement( constraint=agent.constraint, model=agent.model, - best_f=agent.best_scalarized_objective, + best_f=agent.max_scalarized_objective, posterior_transform=ScalarizedPosteriorTransform(weights=agent.objective_weights_torch, offset=0), ) acq_func_meta = {"name": acq_func_name, "args": {}} @@ -55,7 +55,7 @@ def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=T acq_func = monte_carlo.qConstrainedExpectedImprovement( constraint=agent.constraint, model=agent.model, - best_f=agent.best_scalarized_objective, + best_f=agent.max_scalarized_objective, posterior_transform=ScalarizedPosteriorTransform(weights=agent.objective_weights_torch, offset=0), ) acq_func_meta = {"name": acq_func_name, "args": {}} @@ -64,7 +64,7 @@ def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=T acq_func = analytic.ConstrainedLogProbabilityOfImprovement( constraint=agent.constraint, model=agent.model, - best_f=agent.best_scalarized_objective, + best_f=agent.max_scalarized_objective, posterior_transform=ScalarizedPosteriorTransform(weights=agent.objective_weights_torch, offset=0), ) acq_func_meta = {"name": acq_func_name, "args": {}} @@ -73,7 +73,7 @@ def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=T acq_func = monte_carlo.qConstrainedProbabilityOfImprovement( constraint=agent.constraint, model=agent.model, - best_f=agent.best_scalarized_objective, + best_f=agent.max_scalarized_objective, posterior_transform=ScalarizedPosteriorTransform(weights=agent.objective_weights_torch, offset=0), ) acq_func_meta = {"name": acq_func_name, "args": {}} @@ -119,11 +119,11 @@ def get_acquisition_function(agent, acq_func_identifier="qei", return_metadata=T acq_func_meta = {"name": acq_func_name, "args": {"beta": beta}} elif acq_func_name == "expected_mean": - acq_func = get_acquisition_function(agent, acq_func_identifier="ucb", beta=0, return_metadata=False) + acq_func = get_acquisition_function(agent, identifier="ucb", beta=0, return_metadata=False) acq_func_meta = {"name": acq_func_name, "args": {}} elif acq_func_name == "monte_carlo_expected_mean": - acq_func = get_acquisition_function(agent, acq_func_identifier="qucb", beta=0, return_metadata=False) + acq_func = get_acquisition_function(agent, identifier="qucb", beta=0, return_metadata=False) acq_func_meta = {"name": acq_func_name, "args": {}} return (acq_func, acq_func_meta) if return_metadata else acq_func diff --git a/bloptools/bayesian/acquisition/analytic.py b/bloptools/bayesian/acquisition/analytic.py index 38b10b6..780979e 100644 --- a/bloptools/bayesian/acquisition/analytic.py +++ b/bloptools/bayesian/acquisition/analytic.py @@ -6,8 +6,19 @@ class ConstrainedUpperConfidenceBound(UpperConfidenceBound): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Upper confidence bound, but scaled by some constraint. + NOTE: Because the UCB can be negative, we constrain it by adjusting the Gaussian quantile. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): @@ -26,8 +37,18 @@ def forward(self, x): class ConstrainedLogExpectedImprovement(LogExpectedImprovement): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Log expected improvement, but scaled by some constraint. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): @@ -35,8 +56,18 @@ def forward(self, x): class ConstrainedLogProbabilityOfImprovement(LogProbabilityOfImprovement): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Log probability of improvement acquisition function, but scaled by some constraint. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): diff --git a/bloptools/bayesian/acquisition/config.yml b/bloptools/bayesian/acquisition/config.yml index b62398f..2fac116 100644 --- a/bloptools/bayesian/acquisition/config.yml +++ b/bloptools/bayesian/acquisition/config.yml @@ -65,7 +65,7 @@ monte_carlo_probability_of_improvement: type: monte_carlo random: - description: A uniform random sampel of the parameters. + description: Uniformly-sampled random points. identifiers: - r multitask_only: false diff --git a/bloptools/bayesian/acquisition/monte_carlo.py b/bloptools/bayesian/acquisition/monte_carlo.py index 6a78eee..ea4671a 100644 --- a/bloptools/bayesian/acquisition/monte_carlo.py +++ b/bloptools/bayesian/acquisition/monte_carlo.py @@ -8,8 +8,19 @@ class qConstrainedUpperConfidenceBound(qUpperConfidenceBound): - def __init__(self, constraint, beta=4, *args, **kwargs): - super().__init__(beta=beta, *args, **kwargs) + """Monte Carlo expected improvement, but scaled by some constraint. + NOTE: Because the UCB can be negative, we constrain it by adjusting the Gaussian quantile. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, constraint, beta=4, **kwargs): + super().__init__(beta=beta, **kwargs) self.constraint = constraint self.beta = torch.tensor(beta) @@ -30,8 +41,18 @@ def forward(self, x): class qConstrainedExpectedImprovement(qExpectedImprovement): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Monte Carlo expected improvement, but scaled by some constraint. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): @@ -39,8 +60,18 @@ def forward(self, x): class qConstrainedProbabilityOfImprovement(qProbabilityOfImprovement): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Monte Carlo probability of improvement, but scaled by some constraint. + + Parameters + ---------- + model: + A BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): @@ -48,8 +79,19 @@ def forward(self, x): class qConstrainedNoisyExpectedHypervolumeImprovement(qNoisyExpectedHypervolumeImprovement): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """Monte Carlo noisy expected hypervolume improvement, but scaled by some constraint. + Only works with multi-objective models. + + Parameters + ---------- + model: + A multi-objective BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): @@ -57,8 +99,18 @@ def forward(self, x): class qConstrainedLowerBoundMaxValueEntropy(qLowerBoundMaxValueEntropy): - def __init__(self, constraint, *args, **kwargs): - super().__init__(*args, **kwargs) + """GIBBON (General-purpose Information-Based Bayesian OptimisatioN), but scaled by some constraint. + + Parameters + ---------- + model: + A multi-objective BoTorch model over which to compute the acquisition function. + constraint: + A callable which when evaluated on inputs returns the probability of feasibility. + """ + + def __init__(self, model, constraint, **kwargs): + super().__init__(model=model, **kwargs) self.constraint = constraint def forward(self, x): diff --git a/bloptools/bayesian/agent.py b/bloptools/bayesian/agent.py index 14eaa84..62ff540 100644 --- a/bloptools/bayesian/agent.py +++ b/bloptools/bayesian/agent.py @@ -1,15 +1,15 @@ import logging -import os import time as ttime import warnings from collections import OrderedDict +from collections.abc import Mapping +from typing import Callable, Sequence, Tuple import bluesky.plan_stubs as bps # noqa F401 import bluesky.plans as bp # noqa F401 import botorch import gpytorch import h5py -import IPython as ip import matplotlib as mpl import numpy as np import pandas as pd @@ -17,16 +17,16 @@ import torch from botorch.models.deterministic import GenericDeterministicModel from botorch.models.model_list_gp_regression import ModelListGP +from databroker import Broker +from ophyd import Signal from .. import utils from . import acquisition, models, plotting from .digestion import default_digestion_function -from .dofs import DOFList -from .objectives import ObjectiveList +from .dofs import DOF, DOFList +from .objectives import Objective, ObjectiveList from .plans import default_acquisition_plan -os.environ["KMP_DUPLICATE_LIB_OK"] = "True" - warnings.filterwarnings("ignore", category=botorch.exceptions.warnings.InputDataWarning) mpl.rc("image", cmap="coolwarm") @@ -37,27 +37,42 @@ class Agent: def __init__( self, - dofs, - objectives, - db, - **kwargs, + dofs: Sequence[DOF], + objectives: Sequence[Objective], + db: Broker = None, + dets: Sequence[Signal] = [], + acquistion_plan=default_acquisition_plan, + digestion: Callable = default_digestion_function, + verbose: bool = False, + tolerate_acquisition_errors=False, + sample_center_on_init=False, + trigger_delay: float = 0, ): """ - A Bayesian optimization self. + A Bayesian optimization agent. Parameters ---------- - dofs : iterable of ophyd objects + dofs : iterable of DOF objects 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). - objectives : iterable of objectives + objectives : iterable of Objective objects The objectives which the agent will try to optimize. - acquisition : Bluesky plan generator that takes arguments (dofs, inputs, dets) + dets : iterable of ophyd objects + Detectors to trigger during acquisition. + acquisition_plan : optional A plan that samples the beamline for some given inputs. - digestion : function that takes arguments (db, uid) - A function to digest the output of the acquisition. - db : A databroker instance. + digestion : + A function to digest the output of the acquisition, taking arguments (db, uid). + db : optional + A databroker instance. + verbose : bool + To be verbose or not. + tolerate_acquisition_errors : bool + Whether to allow errors during acquistion. If `True`, errors will be caught as warnings. + sample_center_on_init : bool + Whether to sample the center of the DOF limits when the agent has no data yet. + trigger_delay : float + How many seconds to wait between moving DOFs and triggering detectors. """ # DOFs are parametrized by whether they are active and whether they are read-only @@ -82,50 +97,55 @@ def __init__( self.objectives = ObjectiveList(list(np.atleast_1d(objectives))) self.db = db - self.verbose = kwargs.get("verbose", 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_function) - self.dets = list(np.atleast_1d(kwargs.get("dets", []))) - - self.trigger_delay = kwargs.get("trigger_delay", 0) - self.acq_func_config = kwargs.get("acq_func_config", acquisition.config) - self.sample_center_on_init = kwargs.get("sample_center_on_init", False) - - self.table = pd.DataFrame() + self.dets = dets + self.acquisition_plan = acquistion_plan + self.digestion = digestion - self._train_models = True - self.a_priori_hypers = None + self.verbose = verbose - self.plots = {"objectives": {}} + self.tolerate_acquisition_errors = tolerate_acquisition_errors + self.trigger_delay = trigger_delay + self.sample_center_on_init = sample_center_on_init - @property - def has_models(self): - return all([hasattr(obj, "model") for obj in self.objectives]) + self.table = pd.DataFrame() + self.initialized = False + self.a_priori_hypers = None - def tell(self, new_table=None, append=True, train=True, **kwargs): + def tell(self, x: Mapping, y: Mapping, metadata=None, append=True, train_models=True, hypers=None): """ Inform the agent about new inputs and targets for the model. If run with no arguments, it will just reconstruct all the models. - """ - new_table = pd.DataFrame() if new_table is None else new_table + Parameters + ---------- + x : dict + A dict keyed by the name of each DOF, with a list of values for each DOF. + y : dict + A dict keyed by the name of each objective, with a list of values for each objective. + append: bool + If `True`, will append new data to old data. If `False`, will replace old data with new data. + train_models: bool + Whether to train the models on construction. + hypers: + A dict of hyperparameters for the model to assume a priori. + """ + + new_table = pd.DataFrame({**x, **y, **metadata} if metadata is not None else {**x, **y}) self.table = pd.concat([self.table, new_table]) if append else new_table self.table.index = np.arange(len(self.table)) - if len(self.table) < 2: - return + self._update_models(train=train_models, a_priori_hypers=hypers) - skew_dims = self.latent_dim_tuples + def _update_models(self, train=True, skew_dims=None, a_priori_hypers=None): + skew_dims = skew_dims if skew_dims is not None else self.latent_dim_tuples - if self.has_models: - cached_hypers = self.hypers + # if self.initialized: + # cached_hypers = self.hypers - inputs = self.table.loc[:, self.dofs.subset(active=True).device_names].values.astype(float) + inputs = self.table.loc[:, self.dofs.subset(active=True).names].values.astype(float) for i, obj in enumerate(self.objectives): self.table.loc[:, f"{obj.key}_fitness"] = targets = self._get_objective_targets(i) @@ -148,7 +168,7 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): torch.tensor(1 / obj.min_snr).square(), ), # noise_prior=gpytorch.priors.torch_priors.LogNormalPrior(loc=loc, scale=scale), - ).double() + ) outcome_transform = botorch.models.transforms.outcome.Standardize(m=1) # , batch_shape=torch.Size((1,))) @@ -159,11 +179,11 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): skew_dims=skew_dims, input_transform=self._subset_input_transform(active=True), outcome_transform=outcome_transform, - ).double() + ) dirichlet_likelihood = gpytorch.likelihoods.DirichletClassificationLikelihood( self.all_objectives_valid.long(), learn_additional_noise=True - ).double() + ) self.classifier = models.LatentDirichletClassifier( train_inputs=torch.tensor(inputs).double(), @@ -171,32 +191,39 @@ def tell(self, new_table=None, append=True, train=True, **kwargs): skew_dims=skew_dims, likelihood=dirichlet_likelihood, input_transform=self._subset_input_transform(active=True), - ).double() + ) - if self.a_priori_hypers is not None: - self._set_hypers(self.a_priori_hypers) - elif not train: - self._set_hypers(cached_hypers) + if a_priori_hypers is not None: + self._set_hypers(a_priori_hypers) else: - try: - self.train_models() - except botorch.exceptions.errors.ModelFittingError: - if self.has_models: - self._set_hypers(cached_hypers) - else: - raise RuntimeError("Could not fit model on initialization!") + self._train_models() + # try: - self.constraint = GenericDeterministicModel(f=lambda x: self.classifier.probabilities(x)[..., -1]) + # except botorch.exceptions.errors.ModelFittingError: + # if self.initialized: + # self._set_hypers(cached_hypers) + # else: + # raise RuntimeError("Could not fit model on initialization!") - def ask(self, acq_func_identifier="qei", n=1, route=True, sequential=True, **acq_func_kwargs): - """ - Ask the agent for the best point to sample, given an acquisition function. + self.constraint = GenericDeterministicModel(f=lambda x: self.classifier.probabilities(x)[..., -1]) - acq_func_identifier: which acquisition function to use - n: how many points to get - """ + def ask(self, acq_func_identifier="qei", n=1, route=True, sequential=True): + """Ask the agent for the best point to sample, given an acquisition function. - acq_func_name = acquisition.parse_acq_func(acq_func_identifier) + Parameters + ---------- + acq_func_identifier : + Which acquisition function to use. Supported values can be found in `agent.all_acq_funcs` + n : int + How many points you want + route : bool + Whether to route the supplied points to make a more efficient path. + sequential : bool + Whether to generate points sequentially (as opposed to in parallel). Sequential generation involves + finding one points and constructing a fantasy posterior about its value to generate the next point. + """ + + acq_func_name = acquisition.parse_acq_func_identifier(acq_func_identifier) acq_func_type = acquisition.config[acq_func_name]["type"] start_time = ttime.monotonic() @@ -205,18 +232,16 @@ def ask(self, acq_func_identifier="qei", n=1, route=True, sequential=True, **acq print(f'finding points with acquisition function "{acq_func_name}" ...') if acq_func_type in ["analytic", "monte_carlo"]: - if not self.has_models: + if not all(hasattr(obj, "model") for obj in self.objectives): raise RuntimeError( f'Can\'t construct non-trivial acquisition function "{acq_func_identifier}"' - f" (the agent is not initialized!)" + f" (not all of the objectives have models!)" ) if acq_func_type == "analytic" and n > 1: raise ValueError("Can't generate multiple design points for analytic acquisition functions.") - acq_func, acq_func_meta = self.get_acquisition_function( - acq_func_identifier=acq_func_identifier, return_metadata=True - ) + acq_func, acq_func_meta = self.get_acquisition_function(identifier=acq_func_identifier, return_metadata=True) NUM_RESTARTS = 8 RAW_SAMPLES = 1024 @@ -274,11 +299,17 @@ def ask(self, acq_func_identifier="qei", n=1, route=True, sequential=True, **acq return acq_points, acq_func_meta def acquire(self, acquisition_inputs): - """ - Acquire and digest according to the self's acquisition and digestion plans. + """Acquire and digest according to the self's acquisition and digestion plans. - This should yield a table of sampled objectives with the same length as the sampled inputs. + Parameters + ---------- + acquisition_inputs : + A 2D numpy array comprising inputs for the active and non-read-only DOFs to sample. """ + + if self.db is None: + raise ValueError("Cannot run acquistion without databroker instance!") + try: acquisition_devices = self.dofs.subset(active=True, read_only=False).devices # read_only_devices = self.dofs.subset(active=True, read_only=True).devices @@ -301,14 +332,14 @@ def acquire(self, acquisition_inputs): # for obj in self.objectives: # products.loc[index, objective["key"]] = getattr(entry, objective["key"]) - except KeyboardInterrupt: - raise KeyboardInterrupt() + except KeyboardInterrupt as interrupt: + raise interrupt except Exception as error: - if not self.allow_acquisition_errors: + if not self.tolerate_acquisition_errors: raise error logging.warning(f"Error in acquisition/digestion: {repr(error)}") - products = pd.DataFrame(acquisition_inputs, columns=self.dofs.subset(active=True, read_only=False).device_names) + products = pd.DataFrame(acquisition_inputs, columns=self.dofs.subset(active=True, read_only=False).names) for obj in self.objectives: products.loc[:, obj.key] = np.nan @@ -317,50 +348,74 @@ def acquire(self, acquisition_inputs): return products + def load_data(self, data_file, append=True, train_models=True): + new_table = pd.read_hdf(data_file, key="table") + x = {key: new_table.pop(key).tolist() for key in self.dofs.names} + y = {key: new_table.pop(key).tolist() for key in self.objectives.keys} + metadata = new_table.to_dict(orient="list") + self.tell(x=x, y=y, metadata=metadata, append=append, train_models=train_models) + def learn( self, acq_func=None, n=1, iterations=1, upsample=1, - train=True, - data=None, - **kwargs, + train_models=True, + hypers_file=None, + append=True, ): - """ - This iterates the learning algorithm, looping over ask -> acquire -> tell. - It should be passed to a Bluesky RunEngine. - """ + """This returns a Bluesky plan which iterates the learning algorithm, looping over ask -> acquire -> tell. - if data is not None: - if type(data) == str: - self.tell(new_table=pd.read_hdf(data, key="table")) - else: - self.tell(new_table=data) + For example: + + RE(agent.learn("qr", n=16)) + RE(agent.learn("qei", n=4, iterations=4)) - if self.sample_center_on_init and not self.has_models: - new_table = yield from self.acquire(self.dofs.subset(active=True, read_only=False).limits.mean(axis=1)) + Parameters + ---------- + acq_func : str + A valid identifier for an implemented acquisition function. + n : int + How many points to sample on each iteration. + iterations: int + How many iterations of the learning loop to perform. + train: bool + Whether to train the models upon telling the agent. + append: bool + If `True`, add the new data to the old data. If `False`, replace the old data with the new data. + data_file: str + If supplied, read a saved data file instead of running the acquisition plan. + hypers_file: str + If supplied, read a saved hyperparameter file instead of fitting models. NOTE: The agent will assume these + hyperparameters a priori for the rest of the run, and not try to fit a model. + """ + + if self.sample_center_on_init and not self.initialized: + center_inputs = np.atleast_2d(self.dofs.subset(active=True, read_only=False).limits.mean(axis=1)) + new_table = yield from self.acquire(center_inputs) new_table.loc[:, "acq_func"] = "sample_center_on_init" - self.tell(new_table=new_table, train=False) - - if acq_func is not None: - for i in range(iterations): - print(f"running iteration {i + 1} / {iterations}") - for single_acq_func in np.atleast_1d(acq_func): - x, acq_func_meta = self.ask(n=n, acq_func_identifier=single_acq_func, **kwargs) - new_table = yield from self.acquire(x) - new_table.loc[:, "acq_func"] = acq_func_meta["name"] - self.tell(new_table=new_table, train=train) - - def get_acquisition_function(self, acq_func_identifier, return_metadata=False): - return acquisition.get_acquisition_function( - self, acq_func_identifier=acq_func_identifier, return_metadata=return_metadata - ) - def reset(self): - """ - Reset the agent. + for i in range(iterations): + print(f"running iteration {i + 1} / {iterations}") + for single_acq_func in np.atleast_1d(acq_func): + acq_points, acq_func_meta = self.ask(n=n, acq_func_identifier=single_acq_func) + new_table = yield from self.acquire(acq_points) + new_table.loc[:, "acq_func"] = acq_func_meta["name"] + + x = {key: new_table.pop(key).tolist() for key in self.dofs.names} + y = {key: new_table.pop(key).tolist() for key in self.objectives.keys} + metadata = new_table.to_dict(orient="list") + self.tell(x=x, y=y, metadata=metadata, append=append, train_models=train_models) + + def get_acquisition_function(self, identifier, return_metadata=False): + """Returns a BoTorch acquisition function for a given identifier. Acquisition functions can be + found in `agent.all_acq_funcs`. """ + return acquisition.get_acquisition_function(self, identifier=identifier, return_metadata=return_metadata) + + def reset(self): + """Reset the agent.""" self.table = pd.DataFrame() for obj in self.objectives: del obj.model @@ -368,6 +423,19 @@ def reset(self): def benchmark( self, output_dir="./", runs=16, n_init=64, learning_kwargs_list=[{"acq_func": "qei", "n": 4, "iterations": 16}] ): + """Iterate over having the agent learn from scratch, and save the results to an output directory. + + Parameters + ---------- + output_dir : + Where to save the optimized agents + runs : int + How many benchmarks to run + n_init : int + How many points to sample on reseting the agent. + learning_kwargs_list: + A list of kwargs which the agent will run sequentially for each run. + """ # cache_limits = {dof.name: dof.limits for dof in self.dofs} for run in range(runs): @@ -377,20 +445,14 @@ def benchmark( self.reset() - yield from self.learn("qr", n=n_init) - for kwargs in learning_kwargs_list: yield from self.learn(**kwargs) - self.save_data(output_dir + f"benchmark-{int(ttime.time())}.h5") - - ip.display.clear_output(wait=True) + self.save_data(f"{output_dir}/run-{int(ttime.time())}.h5") @property def model(self): - """ - A model encompassing all the objectives. A single GP in the single-objective case, or a model list. - """ + """A model encompassing all the objectives. A single GP in the single-objective case, or a model list.""" return ModelListGP(*[obj.model for obj in self.objectives]) if len(self.objectives) > 1 else self.objectives[0].model @property @@ -398,9 +460,7 @@ def objective_weights_torch(self): return torch.tensor(self.objectives.weights, dtype=torch.double) def _get_objective_targets(self, i): - """ - Returns the targets (what we fit to) for an objective, given the objective index. - """ + """Returns the targets (what we fit to) for an objective, given the objective index.""" obj = self.objectives[i] targets = self.table.loc[:, obj.key].values.copy() @@ -426,39 +486,39 @@ def _get_objective_targets(self, i): @property def n_objs(self): - """ - Returns a (num_objectives x n_observations) array of objectives - """ + """Returns a (num_objectives x n_observations) array of objectives""" return len(self.objectives) @property def objectives_targets(self): - """ - Returns a (num_objectives x n_obs) array of objectives - """ + """Returns a (num_objectives x n_obs) array of objectives""" return torch.cat([torch.tensor(self._get_objective_targets(i))[..., None] for i in range(self.n_objs)], dim=1) @property def scalarized_objectives(self): + """Returns a (n_obs,) array of scalarized objectives""" return (self.objectives_targets * self.objectives.weights).sum(axis=-1) @property - def best_scalarized_objective(self): + def max_scalarized_objective(self): + """Returns the value of the best scalarized objective seen so far.""" f = self.scalarized_objectives - return np.where(np.isnan(f), -np.inf, f).max() + return np.max(np.where(np.isnan(f), -np.inf, f)) @property - def all_objectives_valid(self): - return ~torch.isnan(self.scalarized_objectives) + def argmax_scalarized_objective(self): + """Returns the index of the best scalarized objective seen so far.""" + f = self.scalarized_objectives + return np.argmax(np.where(np.isnan(f), -np.inf, f)) @property - def target_names(self): - return [f"{obj.key}_fitness" for obj in self.objectives] + def all_objectives_valid(self): + """A mask of whether all objectives are valid for each data point.""" + return ~torch.isnan(self.scalarized_objectives) def test_inputs_grid(self, max_inputs=MAX_TEST_INPUTS): - """ - Returns a (n_side, ..., n_side, 1, n_active_dof) grid of test_inputs. - n_side is 1 if a dof is read-only + """Returns a (`n_side`, ..., `n_side`, 1, `n_active_dofs`) grid of test_inputs; `n_side` is 1 if a dof is read-only. + The value of `n_side` is the largest value such that the entire grid has less than `max_inputs` inputs. """ n_settable_acq_func_dofs = len(self.dofs.subset(active=True, read_only=False)) n_side_settable = int(np.power(max_inputs, n_settable_acq_func_dofs**-1)) @@ -478,16 +538,12 @@ def test_inputs_grid(self, max_inputs=MAX_TEST_INPUTS): ).unsqueeze(-2) def test_inputs(self, n=MAX_TEST_INPUTS): - """ - Returns a (n, 1, n_active_dof) grid of test_inputs - """ + """Returns a (n, 1, n_active_dof) grid of test_inputs""" return utils.sobol_sampler(self.acquisition_function_bounds, n=n) @property def acquisition_function_bounds(self): - """ - Returns a (2, n_active_dof) array of bounds for the acquisition function - """ + """Returns a (2, n_active_dof) array of bounds for the acquisition function""" active_dofs = self.dofs.subset(active=True) acq_func_lower_bounds = [dof.lower_limit if not dof.read_only else dof.readback for dof in active_dofs] @@ -495,30 +551,6 @@ def acquisition_function_bounds(self): return torch.tensor(np.vstack([acq_func_lower_bounds, acq_func_upper_bounds]), dtype=torch.double) - # @property - # def num_objectives(self): - # return len(self.objectives) - - # @property - # def det_names(self): - # return [det.name for det in self.dets] - - # @property - # def objective_keys(self): - # return [obj.key for obj in self.objectives] - - # @property - # def objective_models(self): - # return [obj.model for obj in self.objectives] - - # @property - # def objective_weights(self): - # return torch.tensor([objective["weight"] for obj in self.objectives], dtype=torch.float64) - - # @property - # def objective_signs(self): - # return torch.tensor([(-1 if objective["minimize"] else +1) for obj in self.objectives], dtype=torch.long) - @property def latent_dim_tuples(self): """ @@ -554,8 +586,20 @@ 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, train=False) + def forget(self, index, train=True): + """ + Make the agent forget some index of the data table. + """ + self.table.drop(index=index, inplace=True) + self._update_models(train=train) + + def forget_last_n(self, n, train=True): + """ + Make the agent forget the last `n` data points taken. + """ + if n > len(self.table): + raise ValueError(f"Cannot forget {n} data points (only {len(self.table)} have been taken).") + self.forget(self.table.index.iloc[-n:], train=train) def sampler(self, n, d): """ @@ -572,6 +616,7 @@ def _set_hypers(self, hypers): @property def hypers(self): + """Returns a dict of all the hyperparameters in all the agent's models.""" hypers = {"classifier": {}} for key, value in self.classifier.state_dict().items(): hypers["classifier"][key] = value @@ -583,6 +628,7 @@ def hypers(self): return hypers def save_hypers(self, filepath): + """Save the agent's fitted hyperparameters to a given filepath.""" hypers = self.hypers with h5py.File(filepath, "w") as f: for model_key in hypers.keys(): @@ -592,6 +638,7 @@ def save_hypers(self, filepath): @staticmethod def load_hypers(filepath): + """Load hyperparameters from a file.""" hypers = {} with h5py.File(filepath, "r") as f: for model_key in f.keys(): @@ -600,7 +647,8 @@ def load_hypers(filepath): hypers[model_key][param_key] = torch.tensor(np.atleast_1d(param_value[()])) return hypers - def train_models(self, **kwargs): + def _train_models(self, **kwargs): + """Fit all of the agent's models. All kwargs are passed to `botorch.fit.fit_gpytorch_mll`.""" t0 = ttime.monotonic() for obj in self.objectives: model = obj.model @@ -612,9 +660,10 @@ def train_models(self, **kwargs): print(f"trained models in {ttime.monotonic() - t0:.01f} seconds") @property - def acq_func_info(self): + def all_acq_funcs(self): + """Description and identifiers for all supported acquisition functions.""" entries = [] - for k, d in self.acq_func_config.items(): + for k, d in acquisition.config.items(): ret = "" ret += f'{d["pretty_name"].upper()} (identifiers: {d["identifiers"]})\n' ret += f'-> {d["description"]}' @@ -624,54 +673,105 @@ def acq_func_info(self): @property def inputs(self): - return self.table.loc[:, self.dofs.device_names].astype(float) + """A two-dimensional array of all DOF values.""" + return self.table.loc[:, self.dofs.names].astype(float) @property def active_inputs(self): - return self.table.loc[:, self.dofs.subset(active=True).device_names].astype(float) + """A two-dimensional array of all inputs for model fitting.""" + return self.table.loc[:, self.dofs.subset(active=True).names].astype(float) @property def acquisition_inputs(self): - return self.table.loc[:, self.dofs.subset(active=True, read_only=False).device_names].astype(float) + """A two-dimensional array of all inputs for computing acquisition functions.""" + return self.table.loc[:, self.dofs.subset(active=True, read_only=False).names].astype(float) + + @property + def best(self): + """Returns all data for the best point.""" + return self.table.loc[self.argmax_scalarized_objective] @property def best_inputs(self): + """Returns the value of each DOF at the best point.""" + return self.table.loc[self.argmax_scalarized_objective, self.dofs.names].to_dict() + + def go_to(self, **positions): + """Set all settable DOFs to a given position. DOF/value pairs should be supplied as kwargs, e.g. as + + RE(agent.go_to(some_dof=x1, some_other_dof=x2, ...)) """ - Returns a value for each currently active and non-read-only degree of freedom - """ - return self.table.loc[ - np.nanargmax(self.scalarized_objectives), self.dofs.subset(active=True, read_only=False).device_names - ] + mv_args = [] + for dof_name, dof_value in positions.items(): + if dof_name not in self.dofs.names: + raise ValueError(f"There is no DOF named {dof_name}") + dof = self.dofs[dof_name] + if dof.read_only: + raise ValueError(f"Cannot move DOF {dof_name} as it is read-only.") + mv_args.append(dof.device) + mv_args.append(dof_value) - def go_to(self, positions): - args = [] - for dof, value in zip(self.dofs.subset(active=True, read_only=False), np.atleast_1d(positions)): - args.append(dof.device) - args.append(value) - yield from bps.mv(*args) + yield from bps.mv(*mv_args) def go_to_best(self): - yield from self.go_to(self.best_inputs) + """Go to the position of the best input seen so far.""" + yield from self.go_to(**self.best_inputs) + + def scale_limits(self, factor): + """Shrink or expand the limits by some factor, keeping the same center.""" + for dof in self.dofs: + lower_limit, upper_limit = dof.limits + center = 0.5 * (upper_limit + lower_limit) + radius = 0.5 * (upper_limit - lower_limit) + dof.limits = (center - factor * radius, center + factor * radius) + + def center_on_best(self): + """Reposition the center of the limits on the best output so far.""" + ... + + def plot_objectives(self, axes: Tuple = (0, 1), **kwargs): + """Plot the sampled objectives - def plot_objectives(self, **kwargs): + Parameters + ---------- + axes : + A tuple specifying which DOFs to plot as a function of. Can be either an int or the name of DOFs. + """ if len(self.dofs.subset(active=True, read_only=False)) == 1: plotting._plot_objs_one_dof(self, **kwargs) else: - plotting._plot_objs_many_dofs(self, **kwargs) + plotting._plot_objs_many_dofs(self, axes=axes, **kwargs) - def plot_acquisition(self, acq_funcs=["ei"], **kwargs): + def plot_acquisition(self, acq_func="ei", axes: Tuple = (0, 1), **kwargs): + """Plot an acquisition function over test inputs sampling the limits of the parameter space. + + Parameters + ---------- + acq_func : + Which acquisition function to plot. Can also take a list of acquisition functions. + axes : + A tuple specifying which DOFs to plot as a function of. Can be either an int or the name of DOFs. + """ if len(self.dofs.subset(active=True, read_only=False)) == 1: - plotting._plot_acq_one_dof(self, acq_funcs=acq_funcs, **kwargs) + plotting._plot_acqf_one_dof(self, acq_funcs=np.atleast_1d(acq_func), **kwargs) else: - plotting._plot_acq_many_dofs(self, acq_funcs=acq_funcs, **kwargs) + plotting._plot_acqf_many_dofs(self, acq_funcs=np.atleast_1d(acq_func), axes=axes, **kwargs) + + def plot_constraint(self, axes: Tuple = (0, 1), **kwargs): + """Plot the modeled constraint over test inputs sampling the limits of the parameter space. - def plot_validity(self, **kwargs): + Parameters + ---------- + axes : + A tuple specifying which DOFs to plot as a function of. Can be either an int or the name of DOFs. + """ if len(self.dofs.subset(active=True, read_only=False)) == 1: plotting._plot_valid_one_dof(self, **kwargs) else: - plotting._plot_valid_many_dofs(self, **kwargs) + plotting._plot_valid_many_dofs(self, axes=axes, **kwargs) def plot_history(self, **kwargs): + """Plot the improvement of the agent over time.""" plotting._plot_history(self, **kwargs) diff --git a/bloptools/bayesian/devices.py b/bloptools/bayesian/devices.py index 3b30da5..585a784 100644 --- a/bloptools/bayesian/devices.py +++ b/bloptools/bayesian/devices.py @@ -2,12 +2,19 @@ import uuid import numpy as np -from ophyd import Signal, SignalRO # noqa F401 +from ophyd import SignalRO + +DEFAULT_BOUNDS = (-5.0, +5.0) +DOF_FIELDS = ["description", "readback", "lower_limit", "upper_limit", "units", "active", "read_only", "tags"] class BrownianMotion(SignalRO): - """ - Read-only degree of freedom simulating brownian motion + """Read-only degree of freedom simulating Brownian motion. + + Parameters + ---------- + theta : float + Determines the autocorrelation of the process; smaller values correspond to faster variation. """ def __init__(self, name=None, theta=0.95, *args, **kwargs): diff --git a/bloptools/bayesian/dofs.py b/bloptools/bayesian/dofs.py index 33eda63..533d616 100644 --- a/bloptools/bayesian/dofs.py +++ b/bloptools/bayesian/dofs.py @@ -9,7 +9,7 @@ from ophyd import Signal, SignalRO DEFAULT_BOUNDS = (-5.0, +5.0) -DOF_FIELDS = ["name", "readback", "lower_limit", "upper_limit", "units", "active", "read_only", "tags"] +DOF_FIELDS = ["description", "readback", "limits", "units", "active", "read_only", "tags"] numeric = Union[float, int] @@ -32,14 +32,45 @@ def _validate_dofs(dofs): @dataclass class DOF: + """A degree of freedom (DOF), to be used by an agent. + + Parameters + ---------- + name: str + The name of the DOF. This is used as a key. + description: str + A longer name for the DOF. + device: Signal, optional + An ophyd device. If None, a dummy ophyd device is generated. + limits: tuple, optional + A tuple of the lower and upper limit of the DOF. If the DOF is not read-only, the agent + will not explore outside the limits. If the DOF is read-only, the agent will reject all + sampled data where the DOF is outside the limits. + read_only: bool + If True, the agent will not try to set the DOF. Must be set to True if the supplied ophyd + device is read-only. + active: bool + If True, the agent will try to use the DOF in its optimization. If False, the agent will + still read the DOF but not include it any model or acquisition function. + units: str + The units of the DOF (e.g. mm or deg). This is only for plotting and general housekeeping. + tags: list + A list of tags. These make it easier to subset large groups of dofs. + latent_group: optional + An agent will fit latent dimensions to all DOFs with the same latent_group. If None, the + DOF will be modeled independently. + """ + + description: str = None + name: str = None device: Signal = None limits: Tuple[float, float] = (-10.0, 10.0) - name: str = None + units: str = "" read_only: bool = False active: bool = True tags: list = field(default_factory=list) - latent_group = None + latent_group: str = None def __post_init__(self): self.uuid = str(uuid.uuid4()) @@ -63,10 +94,12 @@ def __post_init__(self): @property def lower_limit(self): + """The lower limit of the DOF.""" return float(self.limits[0]) @property def upper_limit(self): + """The upper limit of the DOF.""" return float(self.limits[1]) @property @@ -75,6 +108,7 @@ def readback(self): @property def summary(self) -> pd.Series: + """A pandas Series representing the current state of the DOF.""" series = pd.Series(index=DOF_FIELDS) for attr in series.index: series[attr] = getattr(self, attr) @@ -82,7 +116,8 @@ def summary(self) -> pd.Series: @property def label(self) -> str: - return f"{self.name}{f' [{self.units}]' if len(self.units) > 0 else ''}" + """A formal label for plotting.""" + return f"{self.name}{f' [{self.units}]' if self.units is not None else ''}" @property def has_model(self): @@ -90,31 +125,36 @@ def has_model(self): class DOFList(Sequence): + """A class for handling a list of DOFs.""" + def __init__(self, dofs: list = []): _validate_dofs(dofs) self.dofs = dofs - def __getitem__(self, i): - return self.dofs[i] + def __getitem__(self, index): + """Get a DOF either by name or its position in the list.""" + if type(index) is str: + return self.dofs[self.names.index(index)] + if type(index) is int: + return self.dofs[index] def __len__(self): + """Number of DOFs in the list.""" return len(self.dofs) def __repr__(self): + """A table showing the state of each DOF.""" return self.summary.__repr__() - # def _repr_html_(self): - # return self.summary._repr_html_() - @property def summary(self) -> pd.DataFrame: table = pd.DataFrame(columns=DOF_FIELDS) - for i, dof in enumerate(self.dofs): + for dof in self.dofs: for attr in table.columns: - table.loc[i, attr] = getattr(dof, attr) + table.loc[dof.name, attr] = getattr(dof, attr) # convert dtypes - for attr in ["readback", "lower_limit", "upper_limit"]: + for attr in ["readback"]: table[attr] = table[attr].astype(float) for attr in ["read_only", "active"]: @@ -130,10 +170,6 @@ def names(self) -> list: def devices(self) -> list: return [dof.device for dof in self.dofs] - @property - def device_names(self) -> list: - return [dof.device.name for dof in self.dofs] - @property def lower_limits(self) -> np.array: return np.array([dof.lower_limit for dof in self.dofs]) @@ -175,26 +211,16 @@ def _dof_mask(self, active=None, read_only=None, tags=[]): def subset(self, active=None, read_only=None, tags=[]): return DOFList([dof for dof, m in zip(self.dofs, self._dof_mask(active, read_only, tags)) if m]) - # def _subset_devices(self, read_only=None, active=None, tags=[]): - # return [dof["device"] for dof in self._subset_dofs(read_only, active, tags)] - - # def _read_subset_devices(self, read_only=None, active=None, tags=[]): - # return [device.read()[device.name]["value"] for device in self._subset_devices(read_only, active, tags)] - - # def _subset_dof_names(self, read_only=None, active=None, tags=[]): - # return [device.name for device in self._subset_devices(read_only, active, tags)] - - # def _subset_dof_limits(self, read_only=None, active=None, tags=[]): - # dofs_subset = self._subset_dofs(read_only, active, tags) - # 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 activate(self, read_only=None, active=None, tags=[]): + """Activate all degrees of freedom with a given tag, active status or read-only status. + + For example, `dofs.activate(tag='kb')` will turn off all dofs which contain the tag 'kb'. + """ for dof in self._subset_dofs(read_only, active, tags): dof.active = True def deactivate(self, read_only=None, active=None, tags=[]): + """The same as .activate(), only in reverse.""" for dof in self._subset_dofs(read_only, active, tags): dof.active = False diff --git a/bloptools/bayesian/objectives.py b/bloptools/bayesian/objectives.py index 14e8d2d..0bd7b05 100644 --- a/bloptools/bayesian/objectives.py +++ b/bloptools/bayesian/objectives.py @@ -4,12 +4,11 @@ import numpy as np import pandas as pd -from ophyd import Signal numeric = Union[float, int] -DEFAULT_MINIMUM_SNR = 1e1 -OBJ_FIELDS = ["name", "key", "limits", "weight", "target", "log"] +DEFAULT_MINIMUM_SNR = 2e1 +OBJ_FIELDS = ["description", "key", "target", "log", "limits", "weight", "n", "snr", "min_snr"] class DuplicateKeyError(ValueError): @@ -26,8 +25,31 @@ def _validate_objectives(objectives): @dataclass class Objective: + """An optimization objective, to be used by an agent. + + Parameters + ---------- + key: str + The key indexing the value of interest. + description: str + A longer description for the objective. + target: str or float + If 'min' or 'max', the agent will respectively minimize or maximize the value. If a number, + the agent will target that number. + limits: tuple, optional + A tuple of a lower and upper limit describing the acceptable values of the objective. + weight: float + The relative importance of the objective. + log: bool + Whether to log-scale the objective. + units: str + The units of the objective. This is only for plotting and general housekeeping. + min_snr: list + The minimum signal-to-noise ratio of the objective model. + """ + key: str - name: str = None + description: str = "" target: Union[float, str] = "max" log: bool = False weight: numeric = 1.0 @@ -36,9 +58,6 @@ class Objective: units: str = None def __post_init__(self): - if self.name is None: - self.name = self.key - if self.limits is None: if self.log: self.limits = (0, np.inf) @@ -49,11 +68,11 @@ def __post_init__(self): if self.target not in ["min", "max"]: raise ValueError("'target' must be either 'min', 'max', or a number.") - self.device = Signal(name=self.name) + # self.device = Signal(name=self.key) @property def label(self): - return f"{'neg ' if self.target == 'min' else ''}{'log ' if self.log else ''}{self.name}" + return f"{'neg ' if self.target == 'min' else ''}{'log ' if self.log else ''}{self.description}" @property def summary(self): @@ -69,6 +88,14 @@ def __repr__(self): def noise(self): return self.model.likelihood.noise.item() if hasattr(self, "model") else None + @property + def snr(self): + return np.round(1 / self.model.likelihood.noise.sqrt().item(), 1) if hasattr(self, "model") else None + + @property + def n(self): + return self.model.train_targets.shape[0] if hasattr(self, "model") else 0 + class ObjectiveList(Sequence): def __init__(self, objectives: list = []): @@ -97,9 +124,16 @@ def summary(self): def __repr__(self): return self.summary.__repr__() + # @property + # def descriptions(self) -> list: + # return [obj.description for obj in self.objectives] + @property - def names(self) -> list: - return [obj.name for obj in self.objectives] + def keys(self) -> list: + """ + Returns an array of the objective weights. + """ + return [obj.key for obj in self.objectives] @property def weights(self) -> np.array: diff --git a/bloptools/bayesian/plotting.py b/bloptools/bayesian/plotting.py index a368c89..7e065fb 100644 --- a/bloptools/bayesian/plotting.py +++ b/bloptools/bayesian/plotting.py @@ -55,7 +55,7 @@ def _plot_objs_one_dof(agent, size=16, lw=1e0): agent.obj_axes[obj_index].set_ylabel(obj.label) -def _plot_objs_many_dofs(agent, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=32, grid_zoom=1): +def _plot_objs_many_dofs(agent, axes=(0, 1), shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=32, grid_zoom=1): """ Axes represents which active, non-read-only axes to plot with """ @@ -183,7 +183,7 @@ def _plot_objs_many_dofs(agent, axes=[0, 1], shading="nearest", cmap=DEFAULT_COL ax.set_ylim(*y_dof.limits) -def _plot_acq_one_dof(agent, acq_funcs, lw=1e0, **kwargs): +def _plot_acqf_one_dof(agent, acq_funcs, lw=1e0, **kwargs): agent.acq_fig, agent.acq_axes = plt.subplots( 1, len(acq_funcs), @@ -210,7 +210,7 @@ def _plot_acq_one_dof(agent, acq_funcs, lw=1e0, **kwargs): agent.acq_axes[iacq_func].set_ylabel(acq_func_meta["name"]) -def _plot_acq_many_dofs( +def _plot_acqf_many_dofs( agent, acq_funcs, axes=[0, 1], shading="nearest", cmap=DEFAULT_COLORMAP, gridded=None, size=16, **kwargs ): agent.acq_fig, agent.acq_axes = plt.subplots( diff --git a/bloptools/tests/test_agent.py b/bloptools/tests/test_agent.py new file mode 100644 index 0000000..0c0e074 --- /dev/null +++ b/bloptools/tests/test_agent.py @@ -0,0 +1,20 @@ +import pytest # noqa F401 + + +def test_agent(agent, RE): + RE(agent.learn("qr", n=16)) + + +def test_agent_save_load_data(agent, RE): + RE(agent.learn("qr", n=16)) + agent.save_data("/tmp/test_save_data.h5") + agent.reset() + agent.learn(data_file="/tmp/test_save_data.h5") + RE(agent.learn("qr", n=16)) + + +def test_agent_save_load_hypers(agent, RE): + RE(agent.learn("qr", n=16)) + agent.save_hypers("/tmp/test_save_hypers.h5") + agent.reset() + RE(agent.learn("qr", n=16, hypers_file="/tmp/test_save_hypers.h5")) diff --git a/bloptools/tests/test_passive_dofs.py b/bloptools/tests/test_passive_dofs.py index 5347238..5b13b87 100644 --- a/bloptools/tests/test_passive_dofs.py +++ b/bloptools/tests/test_passive_dofs.py @@ -31,4 +31,4 @@ def test_passive_dofs(RE, db): agent.plot_objectives() agent.plot_acquisition() - agent.plot_validity() + agent.plot_constraint() diff --git a/bloptools/tests/test_plots.py b/bloptools/tests/test_plots.py index bf81ac2..706e042 100644 --- a/bloptools/tests/test_plots.py +++ b/bloptools/tests/test_plots.py @@ -7,5 +7,5 @@ def test_plots(RE, agent): agent.plot_objectives() agent.plot_acquisition() - agent.plot_validity() + agent.plot_constraint() agent.plot_history() diff --git a/docs/source/tutorials/himmelblau.ipynb b/docs/source/tutorials/himmelblau.ipynb index c9e93f0..1ac5c6b 100644 --- a/docs/source/tutorials/himmelblau.ipynb +++ b/docs/source/tutorials/himmelblau.ipynb @@ -185,7 +185,7 @@ "metadata": {}, "outputs": [], "source": [ - "agent.acq_func_info" + "agent.all_acq_funcs" ] }, { @@ -204,7 +204,7 @@ "metadata": {}, "outputs": [], "source": [ - "agent.plot_acquisition(acq_funcs=[\"qei\", \"pi\", \"qucb\"])" + "agent.plot_acquisition(acq_func=\"qei\")" ] }, { @@ -247,7 +247,7 @@ "outputs": [], "source": [ "X, _ = agent.ask(\"qei\", n=8, route=True)\n", - "agent.plot_acquisition(acq_funcs=[\"qei\"])\n", + "agent.plot_acquisition(acq_func=\"qei\")\n", "plt.scatter(*X.T, marker=\"d\", facecolor=\"w\", edgecolor=\"k\")\n", "plt.plot(\n", " *X.T,\n", @@ -270,7 +270,7 @@ "metadata": {}, "outputs": [], "source": [ - "RE(agent.learn(\"qei\", n=8, iterations=4))" + "RE(agent.learn(\"qei\", n=4, iterations=8))" ] }, { @@ -289,7 +289,7 @@ "outputs": [], "source": [ "agent.plot_objectives()\n", - "print(agent.best_inputs)" + "print(agent.best)" ] } ], @@ -309,7 +309,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.10.0" }, "vscode": { "interpreter": { diff --git a/docs/source/tutorials/hyperparameters.ipynb b/docs/source/tutorials/hyperparameters.ipynb index d972439..02f2a9f 100644 --- a/docs/source/tutorials/hyperparameters.ipynb +++ b/docs/source/tutorials/hyperparameters.ipynb @@ -114,7 +114,7 @@ }, "outputs": [], "source": [ - "agent.plot_acquisition(acq_funcs=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acquisition(acq_func=\"qei\")" ] }, { @@ -145,7 +145,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.5" + "version": "3.10.0" }, "vscode": { "interpreter": { diff --git a/docs/source/tutorials/passive-dofs.ipynb b/docs/source/tutorials/passive-dofs.ipynb index 3c7a7b3..3af9c40 100644 --- a/docs/source/tutorials/passive-dofs.ipynb +++ b/docs/source/tutorials/passive-dofs.ipynb @@ -46,8 +46,8 @@ " DOF(name=\"x1\", limits=(-5.0, 5.0)),\n", " DOF(name=\"x2\", limits=(-5.0, 5.0)),\n", " DOF(name=\"x3\", limits=(-5.0, 5.0), active=False),\n", - " DOF(BrownianMotion(name=\"brownian1\"), read_only=True),\n", - " DOF(BrownianMotion(name=\"brownian2\"), read_only=True, active=False),\n", + " DOF(device=BrownianMotion(name=\"brownian1\"), read_only=True),\n", + " DOF(device=BrownianMotion(name=\"brownian2\"), read_only=True, active=False),\n", "]\n", "\n", "objectives = [\n", @@ -93,7 +93,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.10.0" }, "vscode": { "interpreter": { diff --git a/docs/wip/constrained-himmelblau copy.ipynb b/docs/wip/constrained-himmelblau copy.ipynb index 0c8a7b3..4efff8a 100644 --- a/docs/wip/constrained-himmelblau copy.ipynb +++ b/docs/wip/constrained-himmelblau copy.ipynb @@ -152,7 +152,7 @@ "source": [ "import bloptools\n", "\n", - "bloptools.bayesian.acquisition.parse_acq_func(acq_func_identifier=\"quasi-random\")" + "bloptools.bayesian.acquisition.parse_acq_func_identifier(acq_func_identifier=\"quasi-random\")" ] }, { @@ -208,7 +208,7 @@ }, "outputs": [], "source": [ - "agent.plot_validity(cmap=\"viridis\")" + "agent.plot_constraint(cmap=\"viridis\")" ] }, { diff --git a/docs/wip/introduction.ipynb b/docs/wip/introduction.ipynb index aede30e..07db65e 100644 --- a/docs/wip/introduction.ipynb +++ b/docs/wip/introduction.ipynb @@ -177,7 +177,7 @@ "metadata": {}, "outputs": [], "source": [ - "agent.acq_func_info" + "agent.all_acq_funcs" ] }, { @@ -189,7 +189,7 @@ }, "outputs": [], "source": [ - "agent.plot_acquisition(acq_funcs=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acqfuisition(acq_funcs=[\"ei\", \"pi\", \"ucb\"])" ] }, { diff --git a/docs/wip/latent-toroid-dimensions.ipynb b/docs/wip/latent-toroid-dimensions.ipynb index 14d8426..5148279 100644 --- a/docs/wip/latent-toroid-dimensions.ipynb +++ b/docs/wip/latent-toroid-dimensions.ipynb @@ -85,7 +85,7 @@ "outputs": [], "source": [ "agent.plot_objectives()\n", - "agent.plot_validity()\n", + "agent.plot_constraint()\n", "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" ] } diff --git a/docs/wip/multi-task-sirepo.ipynb b/docs/wip/multi-task-sirepo.ipynb index 85b4e1b..d1bd14c 100644 --- a/docs/wip/multi-task-sirepo.ipynb +++ b/docs/wip/multi-task-sirepo.ipynb @@ -91,7 +91,7 @@ "outputs": [], "source": [ "agent.plot_objectives()\n", - "agent.plot_acquisition(strategy=[\"ei\", \"pi\", \"ucb\"])" + "agent.plot_acqfuisition(strategy=[\"ei\", \"pi\", \"ucb\"])" ] }, { diff --git a/requirements-dev.txt b/requirements-dev.txt index 34cc0da..2ec27e7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,14 +2,11 @@ # the documentation) but not necessarily required for _using_ it. black pytest-codecov -chardet coverage flake8 furo isort -markupsafe nbstripout -numpydoc pre-commit pre-commit-hooks pytest diff --git a/requirements.txt b/requirements.txt index 11fef54..e48f1be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ botorch databroker gpytorch h5py -IPython matplotlib numpy ophyd