"""Discrete subspaces."""
from __future__ import annotations
import gc
import random
import warnings
from collections.abc import Collection, Iterator, Sequence
from itertools import islice
from math import prod
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
import numpy.typing as npt
import pandas as pd
from attrs import define, field
from cattrs import IterableValidationError
from typing_extensions import override
from baybe.constraints import DISCRETE_CONSTRAINTS_FILTERING_ORDER, validate_constraints
from baybe.constraints.base import DiscreteConstraint
from baybe.constraints.discrete import DiscreteBatchConstraint
from baybe.exceptions import DeprecationError
from baybe.parameters import (
CategoricalEncoding,
CategoricalParameter,
NumericalDiscreteParameter,
)
from baybe.parameters.base import DiscreteParameter
from baybe.parameters.utils import get_parameters_from_dataframe, sort_parameters
from baybe.searchspace.utils import build_constrained_product, select_via_flat_index
from baybe.searchspace.validation import validate_parameter_names, validate_parameters
from baybe.serialization import SerialMixin, converter, select_constructor_hook
from baybe.settings import active_settings
from baybe.utils.basic import to_tuple
from baybe.utils.boolean import eq_dataframe
from baybe.utils.conversion import to_string
from baybe.utils.dataframe import (
get_transform_objects,
normalize_input_dtypes,
pretty_print_df,
)
from baybe.utils.memory import bytes_to_human_readable
if TYPE_CHECKING:
from baybe.searchspace.core import SearchSpace
[docs]
@define(kw_only=True)
class MemorySize:
"""Estimated memory size of a :class:`SubspaceDiscrete`."""
exp_rep_bytes: float
"""The memory size of the experimental representation dataframe in bytes."""
exp_rep_shape: tuple[int, int]
"""The shape of the experimental representation dataframe."""
comp_rep_bytes: float
"""The memory size of the computational representation dataframe in bytes."""
comp_rep_shape: tuple[int, int]
"""The shape of the computational representation dataframe."""
@property
def exp_rep_human_readable(self) -> tuple[float, str]:
"""Human-readable memory size of the experimental representation dataframe.
Consists of a tuple containing memory size and unit.
"""
return bytes_to_human_readable(self.exp_rep_bytes)
@property
def comp_rep_human_readable(self) -> tuple[float, str]:
"""Human-readable memory size of the computational representation dataframe.
Consists of a tuple containing memory size and unit.
"""
return bytes_to_human_readable(self.comp_rep_bytes)
[docs]
@define
class SubspaceDiscrete(SerialMixin):
"""Class for managing discrete subspaces.
Builds the subspace from parameter definitions and optional constraints, keeps
track of search metadata, and provides access to candidate sets and different
parameter views.
"""
parameters: tuple[DiscreteParameter, ...] = field(
converter=sort_parameters,
validator=lambda _, __, x: validate_parameter_names(x),
)
"""The list of parameters of the subspace."""
exp_rep: pd.DataFrame = field(eq=eq_dataframe)
"""The experimental representation of the subspace."""
empty_encoding: bool = field(default=False)
"""Flag encoding whether an empty encoding is used."""
constraints: tuple[DiscreteConstraint, ...] = field(
converter=lambda x: to_tuple(
sorted(
x,
key=lambda c: DISCRETE_CONSTRAINTS_FILTERING_ORDER.index(c.__class__),
)
),
factory=tuple,
)
"""A list of constraints for restricting the space."""
comp_rep: pd.DataFrame = field(eq=eq_dataframe)
"""The computational representation of the space. Technically not required but added
as an optional initializer argument to allow ingestion from e.g. serialized objects
and thereby speed up construction. If not provided, the default hook will derive it
from ``exp_rep``."""
@override
def __str__(self) -> str:
if self.is_empty:
return ""
# Convert the lists to dataFrames to be able to use pretty_printing
param_list = [param.summary() for param in self.parameters]
constraints_list = [constr.summary() for constr in self.constraints]
param_df = pd.DataFrame(param_list)
constraints_df = pd.DataFrame(constraints_list)
fields = [
to_string(
"Discrete Parameters",
pretty_print_df(param_df, max_colwidth=None),
),
to_string("Experimental Representation", pretty_print_df(self.exp_rep)),
to_string("Constraints", pretty_print_df(constraints_df)),
to_string("Computational Representation", pretty_print_df(self.comp_rep)),
]
return to_string(self.__class__.__name__, *fields)
@exp_rep.validator
def _validate_exp_rep( # noqa: DOC101, DOC103
self, _: Any, exp_rep: pd.DataFrame
) -> None:
"""Validate the experimental representation.
Raises:
ValueError: If the index of the provided dataframe contains duplicates.
"""
if exp_rep.index.has_duplicates:
raise ValueError(
"The index of this search space contains duplicates. "
"This is not allowed, as it can lead to hard-to-detect bugs."
)
@comp_rep.default
def _default_comp_rep(self) -> pd.DataFrame:
"""Create the default computational representation."""
return self.transform(self.exp_rep)
[docs]
def to_searchspace(self) -> SearchSpace:
"""Turn the subspace into a search space with no continuous part."""
from baybe.searchspace.core import SearchSpace
return SearchSpace(discrete=self)
[docs]
@classmethod
def empty(cls) -> SubspaceDiscrete:
"""Create an empty discrete subspace."""
return SubspaceDiscrete(parameters=[], exp_rep=pd.DataFrame())
[docs]
@classmethod
def from_parameter(cls, parameter: DiscreteParameter) -> SubspaceDiscrete:
"""Create a subspace from a single parameter.
Args:
parameter: The parameter to span the subspace.
Returns:
The created subspace.
"""
return cls.from_product([parameter])
[docs]
@classmethod
def from_product(
cls,
parameters: Sequence[DiscreteParameter],
constraints: Sequence[DiscreteConstraint] | None = None,
empty_encoding: bool = False,
) -> SubspaceDiscrete:
"""See :class:`baybe.searchspace.core.SearchSpace`."""
constraints = constraints or []
if constraints:
validate_constraints(constraints, parameters)
df = build_constrained_product(parameters, constraints)
return SubspaceDiscrete(
parameters=parameters,
constraints=constraints,
exp_rep=df,
empty_encoding=empty_encoding,
)
[docs]
@classmethod
def from_dataframe(
cls,
df: pd.DataFrame,
parameters: Sequence[DiscreteParameter] | None = None,
empty_encoding: bool = False,
) -> SubspaceDiscrete:
"""Create a discrete subspace with a specified set of configurations.
Args:
df: The experimental representation of the search space to be created.
parameters: Optional parameter objects corresponding to the columns in the
given dataframe that can be provided to explicitly control parameter
attributes. If a match between column name and parameter name is found,
the corresponding parameter object is used. If a column has no match in
the parameter list, a
:class:`baybe.parameters.numerical.NumericalDiscreteParameter` is
created if possible, or a
:class:`baybe.parameters.categorical.CategoricalParameter` is used as
fallback. For both types, default values are used for their optional
arguments. For more details, see
:func:`baybe.parameters.utils.get_parameters_from_dataframe`.
empty_encoding: See :func:`baybe.searchspace.core.SearchSpace.from_product`.
Returns:
The created discrete subspace.
"""
def discrete_parameter_factory(
name: str, values: Collection[Any]
) -> DiscreteParameter:
"""Try to create a numerical parameter or use a categorical fallback."""
try:
if pd.api.types.is_bool_dtype(np.asarray(values)):
# Due to the difference between bool and np.bool and pandas'
# auto-casting into the latter, the usage of is_bool_dtype and map
# is required here.
return CategoricalParameter(
name=name,
values=map(bool, values),
encoding=CategoricalEncoding.INT,
)
return NumericalDiscreteParameter(name=name, values=values)
except IterableValidationError:
return CategoricalParameter(name=name, values=values)
# Catch edge case
if df.shape[1] == 0:
return cls.empty()
# Get the full list of both explicitly and implicitly defined parameter
parameters = get_parameters_from_dataframe(
df, discrete_parameter_factory, parameters
)
# Ensure dtype consistency
df = normalize_input_dtypes(df, parameters)
return cls(parameters=parameters, exp_rep=df, empty_encoding=empty_encoding)
[docs]
@classmethod
def from_simplex(
cls,
max_sum: float,
simplex_parameters: Sequence[NumericalDiscreteParameter],
*,
simplex_coefficients: Sequence[float] | None = None,
product_parameters: Sequence[DiscreteParameter] | None = None,
constraints: Sequence[DiscreteConstraint] | None = None,
min_nonzero: int = 0,
max_nonzero: int | None = None,
boundary_only: bool = False,
tolerance: float = 1e-6,
) -> SubspaceDiscrete:
"""Efficiently create discrete simplex subspaces.
The same result can be achieved using
:meth:`baybe.searchspace.discrete.SubspaceDiscrete.from_product` in combination
with appropriate constraints. However, such an approach is inefficient
because the Cartesian product involved creates an exponentially large set of
candidates, most of which do not satisfy the simplex constraints and must be
subsequently be filtered out by the method.
By contrast, this method uses a shortcut that removes invalid candidates
already during the creation of parameter combinations, resulting in a
significantly faster construction.
Args:
max_sum: The maximum (weighted) sum of the parameter values defining the
simplex size.
simplex_parameters: The parameters to be used for the simplex construction.
simplex_coefficients: Optional coefficients for the weighted sum, one per
entry in ``simplex_parameters``. Defaults to all-ones, i.e. an
unweighted sum.
product_parameters: Optional parameters that enter in form of a Cartesian
product.
constraints: See :class:`baybe.searchspace.core.SearchSpace`.
min_nonzero: Optional restriction on the minimum number of nonzero
parameter values in the simplex construction.
max_nonzero: Optional restriction on the maximum number of nonzero
parameter values in the simplex construction.
boundary_only: Flag determining whether to keep only parameter
configurations on the simplex boundary.
tolerance: Numerical tolerance used to validate the simplex constraint.
Raises:
ValueError: If the length of ``simplex_coefficients`` does not match the
number of ``simplex_parameters``.
ValueError: If ``simplex_coefficients`` contains any zeros.
ValueError: If the passed product parameters are not discrete.
ValueError: If the passed simplex parameters and product parameters are
not disjoint.
Returns:
The created simplex subspace.
Note:
The achieved efficiency gains can vary depending on the particular order in
which the parameters are passed to this method, as the configuration space
is built up incrementally from the parameter sequence.
"""
# Resolve defaults
if product_parameters is None:
product_parameters = []
if constraints is None:
constraints = []
if max_nonzero is None:
max_nonzero = len(simplex_parameters)
if simplex_coefficients is None:
simplex_coefficients = [1.0] * len(simplex_parameters)
# Validate constraints
validate_constraints(constraints, [*simplex_parameters, *product_parameters])
# Validate parameter types
if not (
all(isinstance(p, NumericalDiscreteParameter) for p in simplex_parameters)
):
raise ValueError(
f"All parameters passed via 'simplex_parameters' "
f"must be of type '{NumericalDiscreteParameter.__name__}'."
)
if not all(p.is_discrete for p in product_parameters):
raise ValueError(
f"All parameters passed via 'product_parameters' "
f"must be of subclasses of '{DiscreteParameter.__name__}'."
)
# Validate coefficients length
if len(simplex_coefficients) != len(simplex_parameters):
raise ValueError(
f"'simplex_coefficients' must have one entry per 'simplex_parameters' "
f"entry, but got {len(simplex_coefficients)} coefficient(s) for "
f"{len(simplex_parameters)} parameter(s)."
)
# Validate no zero coefficients
if any(c == 0.0 for c in simplex_coefficients):
raise ValueError("All entries in 'simplex_coefficients' must be non-zero.")
# Validate no overlap between simplex parameters and product parameters
simplex_parameters_names = {p.name for p in simplex_parameters}
product_parameters_names = {p.name for p in product_parameters}
if overlap := simplex_parameters_names.intersection(product_parameters_names):
raise ValueError(
f"Parameter sets passed via 'simplex_parameters' and "
f"'product_parameters' must be disjoint but share the following "
f"parameters: {overlap}."
)
# Handle degenerate simplex cases
if len(simplex_parameters) < 2:
warnings.warn(
f"'{cls.from_simplex.__name__}' was called with less than 2 "
f"simplex parameters, so smart simplex construction has no effect."
f"Consider using '{cls.from_product.__name__}' instead.",
UserWarning,
)
if len(simplex_parameters) < 1:
return cls.from_product(product_parameters, constraints)
# Compute per-parameter minimum weighted contributions.
# For a positive coefficient c the minimum contribution is c*min_raw; for a
# negative coefficient the ordering flips and it becomes c*max_raw. Taking
# min of both products handles any real coefficient correctly.
min_raw = [min(p.values) for p in simplex_parameters]
max_raw = [max(p.values) for p in simplex_parameters]
coeffs = np.asarray(simplex_coefficients, dtype=active_settings.DTypeFloatNumpy)
if not np.isfinite(coeffs).all():
raise ValueError(
f"All simplex_coefficients passed to '{cls.from_simplex.__name__}' "
f"must be finite numbers."
)
min_weighted = np.array(
[min(c * lo, c * hi) for c, lo, hi in zip(coeffs, min_raw, max_raw)]
)
# Get the minimum weighted sum contributions to come in the upcoming joins (the
# first item is the minimum possible weighted sum of all parameters starting
# from the second parameter, the second item is the minimum possible weighted
# sum starting from the third parameter, and so on ...)
min_sum_upcoming = np.cumsum(min_weighted[:0:-1])[::-1]
# Get the min/max number of nonzero values to come in the upcoming joins.
# Nonzero counting is based on raw parameter values, not weighted values,
# because the cardinality constraint counts zero/nonzero entries regardless
# of the coefficient signs.
min_nonzero_upcoming = np.cumsum((np.asarray(min_raw) > 0.0)[:0:-1])[::-1]
max_nonzero_upcoming = np.cumsum((np.asarray(max_raw) > 0.0)[:0:-1])[::-1]
# Incrementally build up the space as a numpy array, dropping invalid
# configurations along the way. Working with raw numpy avoids pandas overhead
# (index management, BlockManager, merge machinery) in the hot loop.
#
# After having cross-joined a new parameter, there must be enough "room" left
# for the remaining parameters to fit. That is, configurations of the current
# parameter subset that exceed the desired total value minus the minimum
# contribution to come from the yet-to-be-added parameters can be already
# discarded, because it is already clear that the total sum will be exceeded
# once all joins are completed. Analogously, nonzero cardinality bounds are
# checked at each step.
#
# Instead of materializing the full cross-product before filtering, we use
# broadcasting to compute the validity mask in 2D (n_old, n_new) and only
# materialize the surviving combinations. This avoids allocating large
# intermediate arrays that are mostly discarded.
arr = np.empty((1, 0), dtype=active_settings.DTypeFloatNumpy)
partial_sums = np.zeros(1, dtype=active_settings.DTypeFloatNumpy)
nz_counts = np.zeros(1, dtype=np.intp)
for coeff, param, min_sum_to_go, min_nonzero_to_go, max_nonzero_to_go in zip(
coeffs,
simplex_parameters,
np.append(min_sum_upcoming, 0.0),
np.append(min_nonzero_upcoming, 0),
np.append(max_nonzero_upcoming, 0),
):
values = np.asarray(param.values, dtype=active_settings.DTypeFloatNumpy)
threshold = (max_sum - min_sum_to_go) + tolerance
effective_min = min_nonzero - max_nonzero_to_go
effective_max = max_nonzero - min_nonzero_to_go
# Compute weighted sums via broadcasting: (n_old, n_new)
new_contributions = values * coeff
total_sums = partial_sums[:, None] + new_contributions[None, :]
# Build 2D validity mask from sum constraint
mask_2d = total_sums <= threshold
# Cardinality check via broadcasting
new_nz = (values != 0.0).astype(np.intp)
total_nz = nz_counts[:, None] + new_nz[None, :]
if effective_min > 0:
mask_2d &= total_nz >= effective_min
if effective_max < len(simplex_parameters):
mask_2d &= total_nz <= effective_max
# Extract surviving indices and materialize only those rows
old_idx, new_idx = np.where(mask_2d)
arr = np.column_stack([arr[old_idx], values[new_idx]])
partial_sums = total_sums[old_idx, new_idx]
nz_counts = total_nz[old_idx, new_idx]
# If requested, keep only the boundary values
if boundary_only:
mask = np.abs(partial_sums - max_sum) <= tolerance
arr = arr[mask]
# Wrap in DataFrame
exp_rep = pd.DataFrame(arr, columns=[p.name for p in simplex_parameters])
# Merge product parameters and apply constraints incrementally
exp_rep = build_constrained_product(
product_parameters, constraints, initial_df=exp_rep
)
return cls(
parameters=[*simplex_parameters, *product_parameters],
exp_rep=exp_rep,
constraints=constraints,
)
@property
def metadata(self) -> pd.DataFrame:
"""Deprecated!"""
from baybe.campaign import Campaign
raise DeprecationError(
f"Search spaces no longer carry any metadata to avoid stateful behavior. "
f"Metadata is now exclusively tracked by the `{Campaign.__name__}` class. "
f"To dynamically exclude discrete candidates from the search space, "
f"use its `{Campaign.toggle_discrete_candidates.__name__}` method."
)
@property
def is_empty(self) -> bool:
"""Return whether this subspace is empty."""
return len(self.parameters) == 0
@property
def is_constrained(self) -> bool:
"""Boolean indicating if the subspace has any constraints."""
return len(self.constraints) > 0
@property
def parameter_names(self) -> tuple[str, ...]:
"""Return tuple of parameter names."""
return tuple(p.name for p in self.parameters)
@property
def comp_rep_columns(self) -> tuple[str, ...]:
"""The columns spanning the computational representation."""
# We go via `comp_rep` here instead of using the columns of the individual
# parameters because the search space potentially uses only a subset of the
# columns due to decorrelation
return tuple(self.comp_rep.columns)
@property
def comp_rep_bounds(self) -> pd.DataFrame:
"""The minimum and maximum values of the computational representation."""
return pd.DataFrame({"min": self.comp_rep.min(), "max": self.comp_rep.max()}).T
@property
def scaling_bounds(self) -> pd.DataFrame:
"""The bounds used for scaling the surrogate model input."""
return (
pd.concat([p.comp_df.agg(["min", "max"]) for p in self.parameters], axis=1)
if self.parameters
else pd.DataFrame(index=["min", "max"])
)
[docs]
@staticmethod
def estimate_product_space_size(
parameters: Sequence[DiscreteParameter],
) -> MemorySize:
"""Estimate an upper bound for the memory size of a product space.
Args:
parameters: The parameters spanning the product space.
Returns:
The estimated memory size.
"""
# Compute the dataframe shapes
n_cols_exp = len(parameters)
n_cols_comp = sum(p.comp_df.shape[1] for p in parameters)
n_rows = prod(len(p.active_values) for p in parameters)
# Comp rep space is estimated as the size of float times the number of matrix
# elements in the comp rep. The latter is the total number of parameter
# configurations (= number of rows) times the total number of columns.
comp_rep_bytes = (
np.array([0.0], dtype=active_settings.DTypeFloatNumpy).itemsize
* n_rows
* n_cols_comp
)
# Exp rep space is estimated as the size of the per-parameter exp rep dataframe
# times the number of times it will appear in the entire search space. The
# latter is the total number of parameter configurations (= number of rows)
# divided by the number of values for the respective parameter. Contributions of
# all parameters are summed up.
exp_rep_bytes = sum(
pd.DataFrame(p.active_values).memory_usage(index=False, deep=True).sum()
* n_rows
/ len(p.active_values)
for p in parameters
)
return MemorySize(
exp_rep_bytes=exp_rep_bytes,
exp_rep_shape=(n_rows, n_cols_exp),
comp_rep_bytes=comp_rep_bytes,
comp_rep_shape=(n_rows, n_cols_comp),
)
@property
def constraints_batch(
self,
) -> tuple[DiscreteBatchConstraint, ...]:
"""The batch constraints of the subspace."""
return tuple(
c for c in self.constraints if isinstance(c, DiscreteBatchConstraint)
)
@property
def n_subsets(self) -> int:
"""The number of possible subset configurations.
Returns 0 if no subset-generating constraints exist, indicating that
no decomposition is needed.
"""
if not self.constraints_batch:
return 0
return prod(
len(self.get_parameters_by_name([c.parameters[0]])[0].active_values)
for c in self.constraints_batch
)
[docs]
def subset_masks(
self,
candidates_exp: pd.DataFrame,
min_candidates: int | None = None,
mode: Literal["sequential", "shuffled", "replace"] = "shuffled",
) -> Iterator[npt.NDArray[np.bool_]]:
"""Get an iterator over all possible subset masks.
Collect masks from each subset-generating constraint, iterates the
Cartesian product, AND-reduces each combination, and yields feasible
combined masks.
Args:
candidates_exp: The experimental representation of candidate points.
min_candidates: If provided, combined masks selecting fewer rows
are silently skipped.
mode: The iteration strategy.
* ``"sequential"`` iterates all combinations in deterministic order.
* ``"shuffled"`` iterates all combinations exactly once in random order.
* ``"replace"`` samples with replacement, producing an infinite iterator
where each draw is independent.
Raises:
ValueError: If an invalid mode is provided.
Yields:
A Boolean mask selecting the subset's rows.
"""
if mode not in (allowed := {"sequential", "shuffled", "replace"}):
raise ValueError(f"Invalid {mode=}. Must be one of {allowed}.")
per_constraint: list[list[npt.NDArray[np.bool_]]]
if not (constraints := self.constraints_batch):
per_constraint = [[np.ones(len(candidates_exp), dtype=bool)]]
else:
per_constraint = [c.subset_masks(candidates_exp) for c in constraints]
total = prod(len(masks) for masks in per_constraint)
if mode == "replace":
candidates = list(range(total))
while candidates:
idx_pos = random.randint(0, len(candidates) - 1)
flat_idx = candidates[idx_pos]
combined = np.logical_and.reduce(
select_via_flat_index(flat_idx, per_constraint)
)
if min_candidates is not None and combined.sum() < min_candidates:
candidates[idx_pos] = candidates[-1]
candidates.pop()
continue
yield combined
else:
order = list(range(total))
if mode == "shuffled":
random.shuffle(order)
for flat_idx in order:
combined = np.logical_and.reduce(
select_via_flat_index(flat_idx, per_constraint)
)
if min_candidates is not None and combined.sum() < min_candidates:
continue
yield combined
[docs]
def sample_subset_masks(
self,
candidates_exp: pd.DataFrame,
n: int,
min_candidates: int | None = None,
) -> list[npt.NDArray[np.bool_]]:
"""Sample subset masks (without replacement).
Args:
candidates_exp: The experimental representation of candidate points.
n: Number of masks to sample.
min_candidates: If provided, Subsets with fewer matching
candidates are skipped.
Returns:
A list of boolean masks.
"""
return list(
islice(
self.subset_masks(candidates_exp, min_candidates),
n,
)
)
[docs]
def get_candidates(self) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Return the set of candidate parameter settings that can be tested.
Returns:
The candidate parameter settings both in experimental and computational
representation.
"""
return self.exp_rep, self.comp_rep
[docs]
def get_parameters_by_name(
self, names: Sequence[str]
) -> tuple[DiscreteParameter, ...]:
"""Return parameters with the specified names.
Args:
names: Sequence of parameter names.
Returns:
The named parameters.
"""
return tuple(p for p in self.parameters if p.name in names)
[docs]
def validate_simplex_subspace_from_config(specs: dict, _) -> None:
"""Validate the discrete space while skipping costly creation steps."""
# Validate product inputs without constructing it
if specs.get("constructor", None) == "from_product":
parameters = converter.structure(specs["parameters"], list[DiscreteParameter])
validate_parameters(parameters)
constraints = specs.get("constraints", [])
if constraints:
constraints = converter.structure(
specs["constraints"], list[DiscreteConstraint]
)
validate_constraints(constraints, parameters)
# Validate simplex inputs without constructing it
elif specs.get("constructor", None) == "from_simplex":
simplex_parameters = converter.structure(
specs["simplex_parameters"], list[NumericalDiscreteParameter]
)
simplex_coefficients = specs.get("simplex_coefficients", None)
if simplex_coefficients is not None:
try:
simplex_coefficients = converter.structure(
simplex_coefficients, list[float]
)
except (IterableValidationError, TypeError, ValueError) as exc:
raise ValueError(
"'simplex_coefficients' must be a list of numeric values."
) from exc
if len(simplex_coefficients) != len(simplex_parameters):
raise ValueError(
f"'simplex_coefficients' must have one entry per "
f"'simplex_parameters' entry, but got "
f"{len(simplex_coefficients)} coefficient(s) for "
f"{len(simplex_parameters)} parameter(s)."
)
if any(c == 0.0 for c in simplex_coefficients):
raise ValueError(
"All entries in 'simplex_coefficients' must be non-zero."
)
product_parameters = specs.get("product_parameters", [])
if product_parameters:
product_parameters = converter.structure(
specs["product_parameters"], list[DiscreteParameter]
)
validate_parameters(simplex_parameters + product_parameters)
constraints = specs.get("constraints", [])
if constraints:
constraints = converter.structure(
specs["constraints"], list[DiscreteConstraint]
)
validate_constraints(constraints, simplex_parameters + product_parameters)
# For all other types, validate by construction
else:
converter.structure(specs, SubspaceDiscrete)
# Register deserialization hook
converter.register_structure_hook(SubspaceDiscrete, select_constructor_hook)
# Collect leftover original slotted classes processed by `attrs.define`
gc.collect()