diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e6ebed..59b5990 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,28 +2,28 @@ default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v5.0.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/ambv/black - rev: 23.1.0 + rev: 24.10.0 hooks: - id: black language_version: python3 - id: black-jupyter language_version: python3 - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort args: ["--profile", "black"] - repo: https://github.com/kynan/nbstripout - rev: 0.6.1 + rev: 0.8.1 hooks: - id: nbstripout diff --git a/src/blop/agent.py b/src/blop/agent.py index f595968..4e95546 100644 --- a/src/blop/agent.py +++ b/src/blop/agent.py @@ -17,8 +17,6 @@ import pandas as pd import scipy as sp import torch - -# from botorch.utils.transforms import normalize from botorch.acquisition.objective import ScalarizedPosteriorTransform from botorch.models.deterministic import GenericDeterministicModel from botorch.models.model_list_gp_regression import ModelListGP @@ -184,7 +182,7 @@ def refresh(self): def redigest(self): self._table = self.digestion(self._table, **self.digestion_kwargs) - def sample(self, n: int = DEFAULT_MAX_SAMPLES, method: str = "quasi-random") -> torch.Tensor: + def sample(self, n: int = DEFAULT_MAX_SAMPLES, normalize: bool = False, method: str = "quasi-random") -> torch.Tensor: """ Returns a (..., 1, n_active_dofs) tensor of points sampled within the parameter space. @@ -214,7 +212,7 @@ def sample(self, n: int = DEFAULT_MAX_SAMPLES, method: str = "quasi-random") -> else: raise ValueError("'method' argument must be one of ['quasi-random', 'random', 'grid'].") - return self.dofs(active=True).untransform(X).double() + return X.double() if normalize else self.dofs(active=True).untransform(X).double() def ask(self, acqf="qei", n=1, route=True, sequential=True, upsample=1, **acqf_kwargs): """Ask the agent for the best point to sample, given an acquisition function. @@ -382,7 +380,7 @@ def tell( t0 = ttime.monotonic() train_model(obj.model) if self.verbose: - print(f"trained model '{obj.name}' in {1e3*(ttime.monotonic() - t0):.00f} ms") + print(f"trained model '{obj.name}' in {1e3 * (ttime.monotonic() - t0):.00f} ms") else: train_model(obj.model, hypers=cached_hypers) @@ -585,31 +583,54 @@ def posterior(self, x): @property def fitness_model(self): - active_fitness_models = self.objectives(active=True, kind="fitness") - if len(active_fitness_models) == 0: - return GenericDeterministicModel(f=lambda x: torch.ones(x.shape[:-1]).unsqueeze(-1)) - if len(active_fitness_models) == 1: - return active_fitness_models[0].model - return ModelListGP(*[obj.model for obj in active_fitness_models]) + active_fitness_objectives = self.objectives(active=True, fitness=True) + if len(active_fitness_objectives) == 0: + # A dummy model that outputs noise, for when there are only constraints. + dummy_X = self.sample(n=256, normalize=True).squeeze(-2) + dummy_Y = torch.rand(size=(*dummy_X.shape[:-1], 1), dtype=torch.double) + return construct_single_task_model(X=dummy_X, y=dummy_Y, min_noise=1e2, max_noise=2e2) + if len(active_fitness_objectives) == 1: + return active_fitness_objectives[0].model + return ModelListGP(*[obj.model for obj in active_fitness_objectives]) + + # @property + # def pseudofitness_model(self): + # """ + # In the case that we have all constraints, there is no fitness model. In that case, + # we replace the fitness model with a + # """ + # active_fitness_objectives = self.objectives(active=True, fitness=True) + # if len(active_fitness_objectives) == 0: + # # A dummy model that outputs all ones, for when there are only constraints. + # dummy_X = self.sample(n=256, normalize=True).squeeze(-2) + # dummy_Y = torch.ones(size=(*dummy_X.shape[:-1], 1), dtype=torch.double) + # return construct_single_task_model(X=dummy_X, y=dummy_Y) + # if len(active_fitness_objectives) == 1: + # return active_fitness_objectives[0].model + # return ModelListGP(*[obj.model for obj in active_fitness_objectives]) @property def evaluated_constraints(self): - constraint_objectives = self.objectives(kind="constraint") + constraint_objectives = self.objectives(constraint=True) raw_targets_dict = self.raw_targets() if len(constraint_objectives): - return torch.cat([obj.constrain(raw_targets_dict[obj.name]) for obj in constraint_objectives], dim=-1) + return torch.cat( + [obj.constrain(raw_targets_dict[obj.name]).unsqueeze(-1) for obj in constraint_objectives], dim=-1 + ) else: return torch.ones(size=(len(self._table), 0), dtype=torch.bool) def fitness_scalarization(self, weights="default"): - fitness_objectives = self.objectives(active=True, kind="fitness") + active_fitness_objectives = self.objectives(active=True, fitness=True) + if len(active_fitness_objectives) == 0: + return ScalarizedPosteriorTransform(weights=torch.tensor([1.0], dtype=torch.double)) if weights == "default": - weights = torch.tensor([obj.weight for obj in fitness_objectives], dtype=torch.double) + weights = torch.tensor([obj.weight for obj in active_fitness_objectives], dtype=torch.double) elif weights == "equal": - weights = torch.ones(len(fitness_objectives), dtype=torch.double) + weights = torch.ones(len(active_fitness_objectives), dtype=torch.double) elif weights == "random": - weights = torch.rand(len(fitness_objectives), dtype=torch.double) - weights *= len(fitness_objectives) / weights.sum() + weights = torch.rand(len(active_fitness_objectives), dtype=torch.double) + weights *= len(active_fitness_objectives) / weights.sum() elif not isinstance(weights, torch.Tensor): raise ValueError(f"'weights' must be a Tensor or one of ['default', 'equal', 'random'], and not {weights}.") return ScalarizedPosteriorTransform(weights=weights) @@ -620,10 +641,10 @@ def scalarized_fitnesses(self, weights="default", constrained=True): If constrained=True, the points that satisfy the most constraints are automatically better than the others. """ - fitness_objs = self.objectives(kind="fitness") + fitness_objs = self.objectives(fitness=True) if len(fitness_objs) >= 1: f = self.fitness_scalarization(weights=weights).evaluate( - self.train_targets(active=True, kind="fitness", concatenate=True) + self.train_targets(active=True, fitness=True, concatenate=True) ) f = torch.where(f.isnan(), -np.inf, f) # remove all nans else: @@ -646,7 +667,7 @@ def pareto_mask(self): Returns a mask of all points that satisfy all constraints and are Pareto efficient. A point is Pareto efficient if it is there is no other point that is better at every objective. """ - Y = self.train_targets(active=True, kind="fitness", concatenate=True) + Y = self.train_targets(active=True, fitness=True, concatenate=True) # nuke the bad points Y[~self.evaluated_constraints.all(axis=-1)] = -np.inf @@ -669,7 +690,7 @@ def min_ref_point(self): @property def random_ref_point(self): - return self.train_targets(active=True, kind="fitness", concatenate=True)[self.argmax_best_f(weights="random")] + return self.train_targets(active=True, fitness=True, concatenate=True)[self.argmax_best_f(weights="random")] @property def all_objectives_valid(self): @@ -747,9 +768,7 @@ def _get_acquisition_function(self, identifier, return_metadata=False): found in `agent.all_acqfs`. """ - acquisition._construct_acqf(self, identifier=identifier, return_metadata=return_metadata) - - return + return acquisition._construct_acqf(self, identifier=identifier, return_metadata=return_metadata) def _latent_dim_tuples(self, obj_index=None): """ @@ -779,7 +798,7 @@ def sample_domain(self): Read-only DOFs are set to exactly their last known value. Discrete DOFs are relaxed to some continuous domain. """ - return self.dofs(active=True).transform(self.dofs(active=True).search_domain.T) + return self.dofs(active=True).transform(self.dofs(active=True).search_domain.T).clone() @property def input_normalization(self): @@ -842,9 +861,9 @@ def _set_hypers(self, hypers): def constraint(self, x): p = torch.ones(x.shape[:-1]) for obj in self.objectives(active=True): - # if the targeting constraint is non-trivial - # if obj.kind == "constraint": - # p *= obj.targeting_constraint(x) + # if the constraint is non-trivial + if obj.constraint is not None: + p *= obj.constraint_probability(x) # if the validity constaint is non-trivial if obj.validity_conjugate_model is not None: p *= obj.validity_constraint(x) @@ -1009,9 +1028,9 @@ def plot_objectives(self, axes: Tuple = (0, 1), **kwargs): """ if len(self.dofs(active=True, read_only=False)) == 1: - if len(self.objectives(active=True, kind="fitness")) > 0: + if len(self.objectives(active=True, fitness=True)) > 0: plotting._plot_fitness_objs_one_dof(self, **kwargs) - if len(self.objectives(active=True, kind="constraint")) > 0: + if len(self.objectives(active=True, constraint=True)) > 0: plotting._plot_constraint_objs_one_dof(self, **kwargs) else: plotting._plot_objs_many_dofs(self, axes=axes, **kwargs) diff --git a/src/blop/bayesian/models.py b/src/blop/bayesian/models.py index 7ee3faf..c125a1d 100644 --- a/src/blop/bayesian/models.py +++ b/src/blop/bayesian/models.py @@ -20,6 +20,8 @@ def construct_single_task_model(X, y, skew_dims=None, min_noise=1e-6, max_noise= Construct an untrained model for an objective. """ + skew_dims = skew_dims if skew_dims is not None else [(i,) for i in range(X.shape[-1])] + likelihood = gpytorch.likelihoods.GaussianLikelihood( noise_constraint=gpytorch.constraints.Interval( torch.tensor(min_noise), diff --git a/src/blop/objectives.py b/src/blop/objectives.py index 54c8bd1..f8a3dd6 100644 --- a/src/blop/objectives.py +++ b/src/blop/objectives.py @@ -1,6 +1,4 @@ from collections.abc import Iterable, Sequence -from dataclasses import dataclass, field -from typing import List, Tuple, Union import numpy as np import pandas as pd @@ -12,21 +10,21 @@ DEFAULT_MAX_NOISE_LEVEL = 1e0 OBJ_FIELD_TYPES = { - "name": "str", - "description": "object", - "type": "str", - "kind": "str", - "target": "object", - "transform": "str", - "domain": "str", - "trust_domain": "object", - "weight": "float", - "units": "object", - "noise_bounds": "object", - "noise": "float", - "n_valid": "int", - "latent_groups": "object", - "active": "bool", + "name": str, + "description": object, + "active": bool, + "type": str, + "units": object, + "target": object, + "constraint": object, + "transform": str, + "domain": str, + "trust_domain": object, + "weight": float, + "noise_bounds": object, + "noise": float, + "n_valid": int, + "latent_groups": object, } SUPPORTED_OBJ_TYPES = ["continuous", "binary", "ordinal", "categorical"] @@ -34,16 +32,13 @@ class DuplicateNameError(ValueError): - ... + pass domains = {"log"} def _validate_obj_transform(transform): - if transform is None: - return (-np.inf, np.inf) - if transform not in TRANSFORM_DOMAINS: raise ValueError(f"'transform' must be a callable with one argument, or one of {TRANSFORM_DOMAINS}") @@ -61,7 +56,6 @@ def _validate_continuous_domains(trust_domain, domain): raise ValueError(f"The trust domain {trust_domain} is outside the transform domain {domain}.") -@dataclass class Objective: """An objective to be used by an agent. @@ -93,34 +87,55 @@ class Objective: DOFs will be modeled independently. """ - name: str - description: str = "" - type: str = "continuous" - target: Union[Tuple[float, float], float, str] = "max" - transform: str = None - weight: float = 1.0 - active: bool = True - trust_domain: Tuple[float, float] or None = None - min_noise: float = DEFAULT_MIN_NOISE_LEVEL - max_noise: float = DEFAULT_MAX_NOISE_LEVEL - units: str = None - latent_groups: List[Tuple[str, ...]] = field(default_factory=list) - - def __post_init__(self): - if self.transform is not None: - _validate_obj_transform(self.transform) + def __init__( + self, + name: str, + description: str = "", + type: str = "continuous", + target: float | str | None = None, + constraint: tuple[float, float] | set | None = None, + transform: str = None, + weight: float = 1.0, + active: bool = True, + trust_domain: tuple[float, float] | None = None, + min_noise: float = DEFAULT_MIN_NOISE_LEVEL, + max_noise: float = DEFAULT_MAX_NOISE_LEVEL, + units: str = None, + latent_groups: list[tuple[str, ...]] = {}, + ): + self.name = name + self.units = units + self.description = description + self.type = type + self.active = active + + if (target is None) and (constraint is None): + raise ValueError("You must supply either a 'target' or a 'constraint'.") + + self.target = target + self.constraint = constraint + + if transform is not None: + _validate_obj_transform(transform) + + self.transform = transform + + if self.type == "continuous": + _validate_continuous_domains(trust_domain, self.domain) + else: + raise NotImplementedError() + + self.trust_domain = trust_domain + self.weight = weight if target is not None else None + self.min_noise = min_noise + self.max_noise = max_noise + self.latent_groups = latent_groups if isinstance(self.target, str): # eventually we will be able to target other strings, as outputs of a discrete objective if self.target not in ["min", "max"]: raise ValueError("'target' must be either 'min', 'max', a number, or a tuple of numbers.") - self.use_as_constraint = True if isinstance(self.target, tuple) else False - - @property - def kind(self): - return "fitness" if self.target in ["min", "max"] else "constraint" - @property def domain(self): """ @@ -132,12 +147,12 @@ def domain(self): return TRANSFORM_DOMAINS[self.transform] def constrain(self, y): - """ - The total domain of the objective. - """ - if self.kind != "constraint": + if self.constraint is None: raise RuntimeError("Cannot call 'constrain' with a non-constraint objective.") - return (y > self.target[0]) & (y < self.target[1]) + elif isinstance(self.constraint, tuple): + return (y > self.constraint[0]) & (y < self.constraint[1]) + else: + return np.array([value in self.constraint for value in np.atleast_1d(y)]) @property def _trust_domain(self): @@ -189,7 +204,7 @@ def noise_bounds(self) -> tuple: @property def summary(self) -> pd.Series: - series = pd.Series(index=list(OBJ_FIELD_TYPES.keys()), dtype="object") + series = pd.Series(index=list(OBJ_FIELD_TYPES.keys()), dtype=object) for attr in series.index: value = getattr(self, attr) @@ -217,42 +232,34 @@ def snr(self) -> float: def n_valid(self) -> int: return int((~self.model.train_targets.isnan()).sum()) if hasattr(self, "model") else 0 - def targeting_constraint(self, x: torch.Tensor) -> torch.Tensor: - if not isinstance(self.target, tuple): - return None + def constraint_probability(self, x: torch.Tensor) -> torch.Tensor: + if self.constraint is None: + raise RuntimeError("Cannot call 'constrain' with a non-constraint objective.") - a, b = self.target + a, b = self.constraint p = self.model.posterior(x) m = p.mean s = p.variance.sqrt() sish = s + 0.1 * m.std() # for numerical stability - return ( + p = ( 0.5 * (approximate_erf((b - m) / (np.sqrt(2) * sish)) - approximate_erf((a - m) / (np.sqrt(2) * sish)))[..., -1] - ) + ) # noqa - @property - def is_fitness(self): - return self.target in ["min", "max"] - - def value_prediction(self, X): - p = self.model.posterior(X) - - if self.is_fitness: - return self.fitness_inverse(p.mean) + return p.detach() - if isinstance(self.target, tuple): - return p.mean - - def fitness_prediction(self, X): - p = self.model.posterior(X) + def pseudofitness(self, x: torch.tensor) -> torch.tensor: + """ + When the optimization problem consists only of constraints, the + """ + p = self.model.posterior(x) if self.is_fitness: return self.fitness_inverse(p.mean) if isinstance(self.target, tuple): - return self.targeting_constraint(X).log().clamp(min=-16) + return self.constraint_probability(x).log().clamp(min=-16) @property def model(self): @@ -273,7 +280,7 @@ def names(self): def __getattr__(self, attr): # This is called if we can't find the attribute in the normal way. if all([hasattr(obj, attr) for obj in self.objectives]): - if OBJ_FIELD_TYPES.get(attr) in ["float", "int", "bool"]: + if OBJ_FIELD_TYPES.get(attr) in [float, "int", "bool"]: return np.array([getattr(obj, attr) for obj in self.objectives]) return [getattr(obj, attr) for obj in self.objectives] if attr in self.names: @@ -300,38 +307,41 @@ def __len__(self): @property def summary(self) -> pd.DataFrame: - table = pd.DataFrame(columns=list(OBJ_FIELD_TYPES.keys()), index=np.arange(len(self))) + # table = pd.DataFrame(columns=list(OBJ_FIELD_TYPES.keys()), index=np.arange(len(self))) - for index, obj in enumerate(self.objectives): - for attr, value in obj.summary.items(): - table.at[index, attr] = value + # for index, obj in enumerate(self.objectives): + # for attr, value in obj.summary.items(): + # table.at[index, attr] = value - for attr, dtype in OBJ_FIELD_TYPES.items(): - table[attr] = table[attr].astype(dtype) + # for attr, dtype in OBJ_FIELD_TYPES.items(): + # table[attr] = table[attr].astype(dtype) - return table + return pd.concat([objective.summary for objective in self.objectives], axis=1) def __repr__(self): - return self.summary.T.__repr__() + return self.summary.__repr__() def _repr_html_(self): - return self.summary.T._repr_html_() + return self.summary._repr_html_() def add(self, objective): self.objectives.append(objective) @staticmethod - def _test_obj(obj, active=None, kind=None): + def _test_obj(obj, active=None, fitness=None, constraint=None): if active is not None: if obj.active != active: return False - if kind is not None: - if obj.kind != kind: + if fitness is not None: + if obj.target is None: + return False + if constraint is not None: + if obj.constraint is None: return False return True - def subset(self, active=None, kind=None): - return ObjectiveList([obj for obj in self.objectives if self._test_obj(obj, active=active, kind=kind)]) + def subset(self, **kwargs): + return ObjectiveList([obj for obj in self.objectives if self._test_obj(obj, **kwargs)]) def transform(self, Y): """ diff --git a/src/blop/plotting.py b/src/blop/plotting.py index 91b7ca7..39fb5bd 100644 --- a/src/blop/plotting.py +++ b/src/blop/plotting.py @@ -14,7 +14,7 @@ def _plot_fitness_objs_one_dof(agent, size=16, lw=1e0): - fitness_objs = agent.objectives(kind="fitness") + fitness_objs = agent.objectives(fitness=True) agent.obj_fig, agent.obj_axes = plt.subplots( len(fitness_objs), @@ -97,7 +97,7 @@ def _plot_constraint_objs_one_dof(agent, size=16, lw=1e0): val_ax.scatter(x_values, obj_values, s=size, color=color) - con_ax.plot(test_x, obj.targeting_constraint(test_model_inputs).detach()) + con_ax.plot(test_x, obj.constraint_probability(test_model_inputs).detach()) for z in [0, 1, 2]: val_ax.fill_between( @@ -180,8 +180,8 @@ def _plot_objs_many_dofs(agent, axes=(0, 1), shading="nearest", cmap=DEFAULT_COL # test_values = obj.fitness_inverse(test_mean) if obj.kind == "fitness" else test_mean test_constraint = None - if not obj.kind == "fitness": - test_constraint = obj.targeting_constraint(model_inputs).detach().squeeze().numpy() + if obj.constraint is not None: + test_constraint = obj.constraint_probability(model_inputs).detach().squeeze().numpy() if gridded: # _ = agent.obj_axes[obj_index, 1].pcolormesh( @@ -192,7 +192,7 @@ def _plot_objs_many_dofs(agent, axes=(0, 1), shading="nearest", cmap=DEFAULT_COL # cmap=cmap, # norm=val_norm, # ) - if obj.kind == "fitness": + if obj.constraint is not None: fitness_ax = agent.obj_axes[obj_index, 1].pcolormesh( test_x, test_y, @@ -229,7 +229,7 @@ def _plot_objs_many_dofs(agent, axes=(0, 1), shading="nearest", cmap=DEFAULT_COL # norm=val_norm, # cmap=cmap, # ) - if obj.kind == "fitness": + if obj.constraint is not None: fitness_ax = agent.obj_axes[obj_index, 1].scatter( test_x, test_y, @@ -260,7 +260,7 @@ def _plot_objs_many_dofs(agent, axes=(0, 1), shading="nearest", cmap=DEFAULT_COL val_cbar = agent.obj_fig.colorbar(val_ax, ax=agent.obj_axes[obj_index, 0], location="bottom", aspect=32, shrink=0.8) val_cbar.set_label(f"{obj.units or ''}") - if obj.kind == "fitness": + if obj.constraint is not None: _ = agent.obj_fig.colorbar(fitness_ax, ax=agent.obj_axes[obj_index, 1], location="bottom", aspect=32, shrink=0.8) _ = agent.obj_fig.colorbar(fit_err_ax, ax=agent.obj_axes[obj_index, 2], location="bottom", aspect=32, shrink=0.8) @@ -539,7 +539,7 @@ def inspect_beam(agent, index, border=None): def _plot_pareto_front(agent, obj_indices=(0, 1)): - f_objs = agent.objectives(kind="fitness") + f_objs = agent.objectives(fitness=True) (i, j) = obj_indices if len(f_objs) < 2: @@ -547,7 +547,7 @@ def _plot_pareto_front(agent, obj_indices=(0, 1)): fig, ax = plt.subplots(1, 1, figsize=(6, 6)) - y = agent.train_targets(kind="fitness", concatenate=True) + y = agent.train_targets(fitness=True, concatenate=True) pareto_mask = agent.pareto_mask constraint = agent.evaluated_constraints.all(axis=-1) diff --git a/src/blop/tests/conftest.py b/src/blop/tests/conftest.py index 84acb41..8c3c9a2 100644 --- a/src/blop/tests/conftest.py +++ b/src/blop/tests/conftest.py @@ -76,7 +76,7 @@ def get_agent(param): elif param == "1d_1c": return Agent( dofs=DOF(description="The first DOF", name="x1", search_domain=(-5.0, 5.0)), - objectives=Objective(description="Himmelblau’s function", name="himmelblau", target=(95, 105)), + objectives=Objective(description="Himmelblau’s function", name="himmelblau", constraint=(95, 105)), digestion=sketchy_himmelblau_digestion, ) @@ -90,6 +90,19 @@ def get_agent(param): digestion=sketchy_himmelblau_digestion, ) + elif param == "2d_2c": + return Agent( + dofs=[ + DOF(description="The first DOF", name="x1", search_domain=(-5.0, 5.0)), + DOF(description="The first DOF", name="x2", search_domain=(-5.0, 5.0)), + ], + objectives=[ + Objective(description="Himmelblau’s function", name="himmelblau", target="min"), + Objective(description="Himmelblau’s function", name="himmelblau", constraint=(95, 105)), + ], + digestion=sketchy_himmelblau_digestion, + ) + elif param == "2d_1f_1c": return Agent( dofs=[ @@ -98,7 +111,7 @@ def get_agent(param): ], objectives=[ Objective(description="Himmelblau’s function", name="himmelblau", target="min"), - Objective(description="Himmelblau’s function", name="himmelblau", target=(95, 105)), + Objective(description="Himmelblau’s function", name="himmelblau", constraint=(95, 105)), ], digestion=sketchy_himmelblau_digestion, ) @@ -112,8 +125,8 @@ def get_agent(param): objectives=[ Objective(description="f1", name="f1", target="min"), Objective(description="f2", name="f2", target="min"), - Objective(description="c1", name="c1", target=(-np.inf, 225)), - Objective(description="c2", name="c2", target=(-np.inf, 0)), + Objective(description="c1", name="c1", constraint=(-np.inf, 225)), + Objective(description="c2", name="c2", constraint=(-np.inf, 0)), ], digestion=chankong_and_haimes_digestion, ) @@ -130,7 +143,7 @@ def get_agent(param): objectives=[ Objective(name="himmelblau", target="min"), Objective(name="himmelblau_transpose", target="min"), - Objective(description="Himmelblau’s function", name="himmelblau", target=(95, 105)), + Objective(description="Himmelblau’s function", name="himmelblau", constraint=(95, 105)), ], digestion=sketchy_himmelblau_digestion, )