Source code for obsidian.campaign.campaign

"""Campaign class definition"""

from obsidian.parameters import ParamSpace, Target
from obsidian.optimizer import Optimizer, BayesianOptimizer
from obsidian.experiment import ExpDesigner, designer_class_dict
from obsidian.objectives import Objective, Objective_Sequence, obj_class_dict
from obsidian.constraints import Output_Constraint, const_class_dict
from obsidian.exceptions import IncompatibleObjectiveError
from obsidian.utils import tensordict_to_dict, TaskType
from obsidian.rng import RNGManager
import obsidian

import pandas as pd
import torch
import warnings
import traceback


[docs] class Campaign(): """ Base class for tracking optimization progress and other metrics over multiple iterations. Args: X_space (ParamSpace): The parameter space for the campaign. target (Target | list[Target]): The target(s) for optimization. task (TaskType | str | None, optional): The task type: 'optimization' or 'characterization'. If ``None``, the campaign will issue a warning and default to ``TaskType.OPTIMIZATION``. Pass ``task='characterization'`` explicitly for characterization campaigns. Defaults to ``None``. constraints (Output_Constraint | list[Output_Constraint] | None, optional): Output constraints for the campaign. Defaults to ``None``. optimizer (Optimizer | None, optional): The optimizer to use. If ``None``, a :class:`BayesianOptimizer` will be created automatically with default settings. To control ``fix_random_state``, create your own optimizer instance and pass it here: .. code-block:: python # For stochastic variation opt = BayesianOptimizer(X_space, seed=123, fix_random_state=False) campaign = Campaign(X_space, target, optimizer=opt) Defaults to ``None``. designer (ExpDesigner | None, optional): The experimental designer for generating initial designs. If ``None``, an :class:`ExpDesigner` will be created automatically. Defaults to ``None``. objective (Objective | None, optional): The objective function for optimization. If ``None``, will be created automatically based on targets. Defaults to ``None``. seed (int | None, optional): Random seed for reproducibility. If ``None`` and no ``rng`` is provided, a time-based seed will be generated. This seed is used to initialize the RNG for the campaign, optimizer, and designer. Defaults to ``None``. rng (RNGManager | None, optional): An existing :class:`RNGManager` instance to share across components. If provided, the campaign will use this shared RNG instead of creating its own. If both ``rng`` and ``seed`` are provided, ``seed`` is ignored. Defaults to ``None``. Attributes: X_space (ParamSpace): The parameter space for the campaign. data (pd.DataFrame): The data collected during the campaign. optimizer (Optimizer): The optimizer used for optimization. designer (ExpDesigner): The experimental designer used for experiment design. iter (int): The current iteration number. seed (int): The seed for random number generation. rng (RNGManager): The random number generator manager for the campaign. Properties: m_exp (int): The number of observations in campaign.data y (pd.Series): The response data in campaign.data y_names (list): The names of the response data columns f (pd.Series): The transformed response data o (pd.Series): The objective function evaluated on f o_names (list): The names of the objective function columns X (pd.DataFrame): The input features of campaign.data response_max (float | pd.Series): The maximum for each response target (Target | list[Target]): The target(s) for optimization. objective (Objective, optional): The objective of the optimization campaign Note: By default, campaigns use deterministic behavior (``fix_random_state=True`` in the optimizer). To enable stochastic variation, create a custom optimizer with ``fix_random_state=False`` and pass it to the campaign. """
[docs] def __init__(self, X_space: ParamSpace, target: Target | list[Target], task: TaskType | str | None = None, constraints: Output_Constraint | list[Output_Constraint] | None = None, optimizer: Optimizer | None = None, designer: ExpDesigner | None = None, objective: Objective | None = None, seed: int | None = None, rng: RNGManager | None = None ): self.set_X_space(X_space) self.data = pd.DataFrame() if task is None: warnings.warn( "task not specified. Defaulting to 'optimization'. " "Pass task='characterization' explicitly for characterization campaigns.", UserWarning, ) task = TaskType.OPTIMIZATION self.task = TaskType.from_value(task) if obsidian.USE_OLD_RNG_CONTROL: optimizer_seed = seed designer_seed = seed warnings.warn("Using old RNG control. This is deprecated.", UserWarning) else: if rng is None: self.rng = obsidian.create_rng_manager(seed) self._owns_rng = True else: # User provided explicit RNG to share self.rng = rng self._owns_rng = False print( "Campaign is using a shared RNGManager instance. Reproducibility will depend how other objects consume from this RNG." ) if seed is not None: warnings.warn( "Both `rng` and `seed` were provided. The seed parameter will be ignored " "in favor of the seed from `rng`.", UserWarning ) seed = self.rng.seed optimizer_seed = seed designer_seed = seed if not optimizer: optimizer = BayesianOptimizer( X_space, task=self.task, rng=self.rng if not obsidian.USE_OLD_RNG_CONTROL else None, seed=optimizer_seed ) self._owns_optimizer = True else: self._owns_optimizer = False self.set_optimizer(optimizer) # Sync the optimizer's task to the campaign's. A user-provided optimizer keeps # its constructor default (TaskType.OPTIMIZATION) otherwise, which silently # selects optimization-task acquisition defaults instead of characterization. if self._optimizer.task != self.task: if not self._owns_optimizer: warnings.warn( f"Provided optimizer has task={self._optimizer.task.value!r}, " f"overriding to match campaign task={self.task.value!r}.", UserWarning, ) self._optimizer.task = self.task if not designer: designer = ExpDesigner( X_space, seed=designer_seed ) self.set_designer(designer) self.set_target(target) self.set_objective(objective) self.output_constraints = None self.constrain_outputs(constraints) # Non-object attributes self.iter = 0 self.seed = seed self.version = obsidian.__version__ # Number of rows in ``self.data`` at the time of the most recent ``fit()``. # Used to gate characterization analysis to freshly-fit state, so add_data() # and set_objective() don't run expensive Sobol+posterior metrics against a # stale optimizer. self._last_fit_n_rows: int | None = None
[docs] def add_data(self, df: pd.DataFrame): """ Adds data to the campaign. Args: Z_i (pd.DataFrame): The data to be added to the campaign. Raises: KeyError: If all X_names are not in the dataset KeyError: If all y_names are not in the dataset """ if not all(name in df.columns for name in self.X_space.X_names): raise KeyError('Input dataset does not contain all of the required parameter names') if not all(name in df.columns for name in self.y_names): raise KeyError('Input dataset does not contain all of the required response target names') new_data = df.copy(deep=True) if 'Iteration' not in new_data.columns: new_data['Iteration'] = self.iter else: self.iter = int(new_data['Iteration'].max()) self.iter += 1 self.data = pd.concat([self.data, new_data], axis=0, ignore_index=True) self.data.index.name = 'Observation ID' self.data.index = self.data.index.astype('int') if self.optimizer.is_fit: self._analyze()
[docs] def clear_data(self): """Clears campaign data""" self.data = pd.DataFrame() self.iter = 0 self._last_fit_n_rows = None
@property def X_space(self) -> ParamSpace: """Campaign ParamSpace""" return self._X_space
[docs] def set_X_space(self, X_space: ParamSpace): """Sets the campaign ParamSpace""" self._X_space = X_space
@property def optimizer(self) -> Optimizer: """Campaign Optimizer""" return self._optimizer
[docs] def set_optimizer(self, optimizer: Optimizer): """Sets the campaign optimizer""" self._optimizer = optimizer
@property def designer(self) -> ExpDesigner: """Campaign Experimental Designer""" return self._designer
[docs] def set_designer(self, designer: ExpDesigner): """Sets the campaign experiment designer""" self._designer = designer
@property def objective(self) -> Objective | None: """Campaign Objective function""" return self._objective def _eval_objective(self): """Evaluates objective and appends it to campaign data""" df_o = self.o for col in df_o.columns: self.data[col] = df_o[col].values self.o_names = [col for col in self.data.columns if 'Objective' in col]
[docs] def set_objective(self, objective: Objective | None): """(Re)sets the campaign objective function""" self._objective = objective if not self.data.empty: # Remove previous objective evaluations self.data = self.data.drop( columns=[col for col in self.data.columns if 'Objective' in col] ) if self.optimizer.is_fit: self._analyze()
[docs] def clear_objective(self): """Clears the campaign objective function""" self._objective = None
@property def target(self): """Campaign experimental target(s)""" return self._target
[docs] def set_target(self, target: Target | list[Target]): """ Sets the experimental target context for the campaign. Args: target (Target | list[Target] | None): The target or list of targets to set. """ if isinstance(target, Target): self._target = [target] else: self._target = target if all(t.tracking_only for t in self._target): warnings.warn( "All targets are tracking-only. Campaign will not optimize towards any target by default. " "Only use this campaign for analyzing data or informational purposes. " "Pass tracking-only targets explicitly to `suggest` to optimize towards them if this is truly intended.", UserWarning, stacklevel=2, ) self.y_names = [t.name for t in self._target] self.n_response = len(self.y_names)
@property def _is_mo(self) -> bool: """ Boolean flag for multi-output """ if self.objective: return self.objective._is_mo else: return self.n_response > 1 @property def _is_characterization(self) -> bool: """ Boolean flag for characterization task """ return self.task == TaskType.CHARACTERIZATION @property def m_exp(self) -> int: """ Number of observations in training data """ return self.data.shape[0] @property def y(self) -> pd.Series | pd.DataFrame: """ Experimental response data """ if not self.data.empty: return self.data[self.y_names] else: return None @property def response_max(self) -> float | pd.Series: """ Maximum response data in training set """ return self.y.max() @property def f(self) -> pd.Series | pd.DataFrame: """ Experimental response data, in transformed space """ f = pd.concat([t.transform_f(self.y[t.name]) for t in self.target], axis=1) return f @property def o(self) -> pd.Series | pd.DataFrame: """ Objective function evaluated on f """ if self.objective: try: x = self.X_space.encode(self.X).values o = self.objective(torch.tensor(self.f.values).unsqueeze(0), X=torch.tensor(x)).squeeze(0) if o.ndim < 2: o = o.unsqueeze(1) # Rearrange into m x o return pd.DataFrame(o.detach().cpu().numpy(), columns=[f'Objective {o_i+1}' for o_i in range(o.shape[1])]) except Exception: raise IncompatibleObjectiveError('Objective(s) did not successfully execute on sample') else: return None @property def out(self) -> pd.Series | pd.DataFrame: """ Returns the objective function as appropriate, else the response data """ if self.objective and self.optimizer.is_fit: return self.o else: return self.y @property def X_best(self) -> pd.DataFrame: """ Best performing X values """ best_idx = self.out.idxmax().values X_best = self.X.iloc[best_idx, :] if isinstance(X_best, pd.Series): X_best = X_best .to_frame().T return X_best @property def X(self) -> pd.DataFrame: """ Feature columns of the training data """ return self.data[list(self.X_space.X_names)] def __repr__(self): """String representation of object""" return f"obsidian Campaign for {getattr(self,'y_names', None)}; {getattr(self,'m_exp', 0)} observations"
[docs] def initialize(self, **design_kwargs): """ Maps ExpDesigner.initialize method """ return self.designer.initialize(**design_kwargs)
[docs] def fit(self, fit_options: dict | None = None): """ Maps Optimizer.fit method Raises: ValueError: If no data has been registered to the campaign """ fit_options = fit_options or {} if self.m_exp <= 0: raise ValueError('Must register data before fitting') self.optimizer.fit(self.data, target=self.target, fit_options=fit_options) self._last_fit_n_rows = len(self.data) self._analyze()
[docs] def suggest(self, **optim_kwargs): """ Maps Optimizer.suggest method """ if self.optimizer.is_fit: try: # In case X_space has changed, re-set the optimizer X_space self.optimizer.set_X_space(self.X_space) # Use campaign attributes as defaults; caller-supplied values take precedence if "objective" not in optim_kwargs: optim_kwargs["objective"] = self.objective if "out_constraints" not in optim_kwargs: optim_kwargs["out_constraints"] = self.output_constraints X, eval = self.optimizer.suggest(**optim_kwargs) return (X, eval) except Exception as e: warnings.warn(f'Optimization failed: {e}') # print full traceback for debugging verbosity if self.optimizer.verbose > 2: print("Stack trace:", traceback.format_exc()) return None else: warnings.warn('Optimizer is not fit to data. Suggesting initial experiments.', UserWarning) X0 = self.initialize() return X0
[docs] def evaluate(self, X_suggest: pd.DataFrame): """ Maps Optimizer.evaluate method """ return self.optimizer.evaluate(X_suggest, objective=self.objective)
[docs] def evaluate_characterization(self, X: pd.DataFrame | int | None = None, PI_range: float = 0.7) -> dict: """ Evaluate characterization metrics on specified points. Args: X (pd.DataFrame | int | None): Points to evaluate (pd.DataFrame), number of Sobol samples (int), or None to use training data PI_range (float): Prediction interval coverage (0.7 or 0.95) Returns: dict: Per-target and joint classification fractions Raises: ValueError: If campaign has no thresholds set """ from obsidian.campaign.characterization import CharacterizationEvaluator if not self._has_thresholds(): raise ValueError("Campaign must have at least one target with a threshold set") if X is None: X = self.X evaluator = CharacterizationEvaluator(self) return evaluator.classify_points(X, PI_range=PI_range)
[docs] def score_against_ground_truth(self, X: pd.DataFrame, y_true, PI_range: float = 0.7) -> dict: """ Score campaign predictions against ground truth (for benchmarking). Args: X (pd.DataFrame): Points to evaluate y_true (np.ndarray): Ground truth values, shape (n_points, n_targets) PI_range (float): Prediction interval coverage Returns: dict: Jaccard scores and confusion matrices Raises: ValueError: If campaign has no thresholds set """ from obsidian.campaign.characterization import CharacterizationEvaluator import numpy as np if not self._has_thresholds(): raise ValueError("Campaign must have at least one target with a threshold set") # Convert y_true to numpy if needed if not isinstance(y_true, np.ndarray): y_true = np.array(y_true) evaluator = CharacterizationEvaluator(self) return evaluator.evaluate_with_ground_truth(X, y_true, PI_range=PI_range)
def _profile_hv(self): """ Calculate and assign the hypervolume values to each iteration in the data. Returns: None """ iters = self.data['Iteration'].unique() hv = {} for i in iters: iter_index = self.data.query(f'Iteration <= {i}').index out_iter = self.out.loc[iter_index, :] out_iter = torch.tensor(out_iter.values) hv[i] = self.optimizer.hypervolume(out_iter) self.data['Hypervolume (iter)'] = self.data.apply(lambda x: hv[x['Iteration']], axis=1) self.data['Pareto Front'] = self.optimizer.pareto(torch.tensor(self.out.values)) def _profile_max(self): """ Calculate the maximum values achieved for targets at each iteration Returns: None """ # Remove previous max-profiling self.data = self.data.drop( columns=[col for col in self.data.columns if '(max) (iter)' in col] ) for out in self.out.columns: self.data[out+' (max) (iter)'] = self.data.apply( lambda x: self.data.query(f'Iteration<={x["Iteration"]}')[out].max(), axis=1 ) return def _has_thresholds(self) -> bool: """ Check if any active target has a threshold set (indicates characterization campaign). Tracking-only targets are excluded because characterization evaluation requires at least one non-tracking target with a threshold; including tracking-only targets here would incorrectly trigger characterization analysis for campaigns that have no actionable thresholds. Returns: bool: True if at least one non-tracking target has a threshold """ return any( (not t.tracking_only) and (t.threshold is not None) for t in self.target ) def _analyze_characterization(self): """ Compute and add characterization classification metrics to campaign.data. Adds percentage metrics (pass %, fail %, classified %) at 70% and 95% CI for each target with a threshold, and joint metrics if multiple targets. Returns: None """ from obsidian.campaign.characterization import CharacterizationEvaluator try: evaluator = CharacterizationEvaluator(self, seed=self.seed) N = evaluator.plan_sample_size(pilot_ratio=0.1, epsilon=0.01, z=1.96, max_samples=20000) summary = evaluator.summarize_confidence(N) current_iter = self.iter - 1 idx = self.data.index[self.data["Iteration"] == current_iter] def _set(col, value): if col not in self.data.columns: self.data[col] = float("nan") self.data.loc[idx, col] = value for name, row in summary.items(): _set(f"Characterization {name} Pass % (mean)", row["pass_mean"] * 100) for ci_label, suffix in (("70% CI", "70"), ("95% CI", "95")): for key in ["pass", "fail", "classified"]: _set(f"Characterization {name} {key.capitalize()} % ({ci_label})", row[f"{key}_{suffix}"] * 100) except Exception as e: warnings.warn(f"Characterization analysis failed: {e}", UserWarning) def _analyze(self): """ Analyzes the campaign data for practical optimization performance metrics Returns: None """ if self.objective: self._eval_objective() # Skip response max profiling for characterization campaigns (not meaningful for characterization) # Explicitly determined by the task type # Optimization campaigns always get max-profiling # Characterization campaigns always skip it regardless of threshold configuration. if not self._is_characterization: self._profile_max() if self._is_mo: self._profile_hv() else: # Remove previous HV-profiling self.data = self.data.drop( columns=[col for col in self.data.columns if 'Hypervolume' in col or 'Pareto' in col] ) # Characterization analysis # Only meaningful for characterization campaigns that also have at least one active threshold. # Additionally, only run when the optimizer was fit against the current dataset — # otherwise add_data()/set_objective() would populate metrics from a stale model. if self._is_characterization: if self._has_thresholds(): if self._last_fit_n_rows == len(self.data): self._analyze_characterization() # else: silently skip; the next fit() will repopulate metrics. else: warnings.warn( "Campaign is set to characterization task, but no thresholds defined in `campaign.target`. " "Set thresholds for `targets` rather than passing threshold values to `suggest`. " "Skipping characterization analysis. ", UserWarning, stacklevel=2, )
[docs] def constrain_outputs(self, constraints: Output_Constraint | list[Output_Constraint] | None) -> None: """ Sets optional output constraints for the campaign. """ if constraints is not None: if isinstance(constraints, Output_Constraint): constraints = [constraints] self.output_constraints = constraints
[docs] def clear_output_constraints(self): """Clears output constraints""" self.output_constraints = None
[docs] def save_state(self) -> dict: """ Saves the state of the Campaign object as a dictionary. Returns: dict: A dictionary containing the saved state of the Campaign object. """ obj_dict = {} obj_dict['X_space'] = self.X_space.save_state() obj_dict['optimizer'] = self.optimizer.save_state() obj_dict['data'] = self.data.to_dict() obj_dict['target'] = [t.save_state() for t in self.target] if self.objective: obj_dict['objective'] = self.objective.save_state() obj_dict['seed'] = self.seed obj_dict['task'] = self.task.value # Preserve designer state (seed for basic designer and advanced configurations for advanced designer) obj_dict['designer'] = self.designer.save_state() # Save RNG state for reproducibility if hasattr(self, 'rng'): obj_dict['rng_state'] = self.rng.save_state() obj_dict['owns_rng'] = getattr(self, '_owns_rng', False) obj_dict['owns_optimizer'] = getattr(self, '_owns_optimizer', False) else: obj_dict['rng_state'] = None obj_dict['owns_rng'] = False obj_dict['owns_optimizer'] = False if getattr(self, 'output_constraints', None): obj_dict['output_constraints'] = [{'class': const.__class__.__name__, 'state': tensordict_to_dict(const.state_dict())} for const in self.output_constraints] return obj_dict
[docs] @classmethod def load_state(cls, obj_dict: dict): """ Loads the state of the campaign from a dictionary. Args: cls (Campaign): The class object. obj_dict (dict): A dictionary containing the campaign state. Returns: Campaign: A new campaign object with the loaded state. """ if 'objective' in obj_dict: if obj_dict['objective']['name'] == 'Objective_Sequence': new_objective = Objective_Sequence.load_state(obj_dict['objective']) else: obj_class = obj_class_dict[obj_dict['objective']['name']] new_objective = obj_class.load_state(obj_dict['objective']) else: new_objective = None # Restore RNG state if saved rng = None seed=obj_dict['seed'] if 'rng_state' in obj_dict and obj_dict['rng_state'] is not None: rng = RNGManager.load_state(obj_dict['rng_state']) else: msg = "Loading a legacy campaign save.\nA new RNG manager object will be created to control randomness.\n" if seed is None: msg += "A random seed will be assigned due to seed is none." else: msg += f"Seed {seed} will be used to initialize the new RNG manager." msg += "\nNote that due to the differences in random states by design, campaign results will be different.\nTo fully recover legacy behavior, set `obsidian.USE_OLD_RNG_CONTROL = True` before loading." warnings.warn(msg, UserWarning) X_space = ParamSpace.load_state(obj_dict['X_space']) # Reconstruct the designer if one was saved. # Old state dicts without a 'designer' key fall back to the default ExpDesigner designer = None designer_state = obj_dict.get('designer') if designer_state is not None: designer_cls = designer_class_dict.get(designer_state.get('name')) if designer_cls is None: warnings.warn( f"Unknown designer class '{designer_state.get('name')}' in saved " "state; falling back to default ExpDesigner.", UserWarning, ) else: # Reuse the already-reconstructed X_space and the campaign's # designer-specific seed to avoid re-parsing the parameter space. designer = designer_cls.load_state(designer_state, X_space=X_space, seed=designer_state.get('seed')) new_campaign = cls(X_space=X_space, target=[Target.load_state(t_dict) for t_dict in obj_dict['target']], # Default legacy saves to optimization explicitly task=obj_dict.get('task', TaskType.OPTIMIZATION.value), optimizer=BayesianOptimizer.load_state(obj_dict['optimizer']), designer=designer, objective=new_objective, seed=seed, rng=rng) new_campaign.data = pd.DataFrame(obj_dict['data']) new_campaign.data.index = new_campaign.data.index.astype('int') # Handle empty data if len(new_campaign.data) > 0 and 'Iteration' in new_campaign.data.columns: new_campaign.iter = new_campaign.data['Iteration'].astype('int').max() else: new_campaign.iter = 0 # Restore owns_rng flag (gets overwritten during __init__ when rng is passed) if 'owns_rng' in obj_dict: new_campaign._owns_rng = obj_dict['owns_rng'] # Restore owns_optimizer flag and sync RNG if Campaign owns the optimizer if 'owns_optimizer' in obj_dict: new_campaign._owns_optimizer = obj_dict['owns_optimizer'] # If Campaign created the optimizer originally, restore RNG sharing if new_campaign._owns_optimizer and hasattr(new_campaign, 'rng') and hasattr(new_campaign.optimizer, 'rng'): new_campaign.optimizer.rng = new_campaign.rng if 'output_constraints' in obj_dict: all_constraints = [ const_class_dict[const_dict['class']](new_campaign.target, **const_dict['state']) for const_dict in obj_dict['output_constraints'] ] new_campaign.constrain_outputs(all_constraints) return new_campaign
[docs] def copy(self): """ Creates a deep copy of the Campaign object. A shortcut for saving and then loading the state. The presence of the torch objects prevents a direct deepcopy. Returns: Campaign: A deep copy of the Campaign object. """ return self.__class__.load_state(self.save_state())