Skip to content
8 changes: 8 additions & 0 deletions bofire/data_models/constraints/nchoosek.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ def validate_inputs(self, inputs: Inputs):
assert isinstance(
feature_, ContinuousInput
), f"Feature {f} is not a ContinuousInput."
if not (
feature_.bounds[0] == 0
or (feature_.bounds[0] > 0 and feature_.allow_zero)
):
raise ValueError(
f"Feature {f} must have a lower bound of 0 or `allow_zero=True`, "
f"but has bounds[0]={feature_.bounds[0]} and allow_zero={feature_.allow_zero}",
)

@model_validator(mode="after")
def validate_counts(self):
Expand Down
105 changes: 0 additions & 105 deletions bofire/data_models/domain/domain.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import collections.abc
import itertools
import warnings
from collections.abc import Sequence
from typing import Any, Dict, Literal, Optional, Tuple, Union
Expand All @@ -14,7 +13,6 @@
Constraint,
ConstraintNotFulfilledError,
InterpointConstraint,
NChooseKConstraint,
)
from bofire.data_models.domain.constraints import Constraints
from bofire.data_models.domain.features import Inputs, Outputs
Expand Down Expand Up @@ -133,109 +131,6 @@ def validate_constraints(self):
c.validate_inputs(self.inputs)
return self

# TODO: tidy this up
def get_nchoosek_combinations(self, exhaustive: bool = False):
"""Get all possible NChooseK combinations

Args:
exhaustive (bool, optional): if True all combinations are returned. Defaults to False.

Returns:
Tuple(used_features_list, unused_features_list): used_features_list is a list of lists containing features used in each NChooseK combination.
unused_features_list is a list of lists containing features unused in each NChooseK combination.

"""
if len(self.constraints.get(NChooseKConstraint)) == 0:
used_continuous_features = self.inputs.get_keys(ContinuousInput)
return used_continuous_features, []

used_features_list_all = []

# loops through each NChooseK constraint
for con in self.constraints.get(NChooseKConstraint):
assert isinstance(con, NChooseKConstraint)
used_features_list = []

if exhaustive:
for n in range(con.min_count, con.max_count + 1):
used_features_list.extend(itertools.combinations(con.features, n))

if con.none_also_valid:
used_features_list.append(())
else:
used_features_list.extend(
itertools.combinations(con.features, con.max_count),
)

used_features_list_all.append(used_features_list)

used_features_list_all = list(
itertools.product(*used_features_list_all),
) # product between NChooseK constraints

# format into a list of used features
used_features_list_formatted = []
for used_features_list in used_features_list_all:
used_features_list_flattened = [
item for sublist in used_features_list for item in sublist
]
used_features_list_formatted.append(list(set(used_features_list_flattened)))

# sort lists
used_features_list_sorted = []
for used_features in used_features_list_formatted:
used_features_list_sorted.append(sorted(used_features))

# drop duplicates
used_features_list_no_dup = []
for used_features in used_features_list_sorted:
if used_features not in used_features_list_no_dup:
used_features_list_no_dup.append(used_features)

# remove combinations not fulfilling constraints
used_features_list_final = []
for combo in used_features_list_no_dup:
fulfil_constraints = [] # list of bools tracking if constraints are fulfilled
for con in self.constraints.get(NChooseKConstraint):
assert isinstance(con, NChooseKConstraint)
count = 0 # count of features in combo that are in con.features
for f in combo:
if f in con.features:
count += 1
if (
count >= con.min_count
and count <= con.max_count
or count == 0
and con.none_also_valid
):
fulfil_constraints.append(True)
else:
fulfil_constraints.append(False)
if np.all(fulfil_constraints):
used_features_list_final.append(combo)

# features unused
features_in_cc = []
for con in self.constraints.get(NChooseKConstraint):
assert isinstance(con, NChooseKConstraint)
features_in_cc.extend(con.features)
features_in_cc = list(set(features_in_cc))
features_in_cc.sort()
unused_features_list = []
for used_features in used_features_list_final:
unused_features_list.append(
[f_key for f_key in features_in_cc if f_key not in used_features],
)

# postprocess
# used_features_list_final2 = []
# unused_features_list2 = []
# for used, unused in zip(used_features_list_final,unused_features_list):
# if len(used) == 3:
# used_features_list_final2.append(used), unused_features_list2.append(unused)

return used_features_list_final, unused_features_list

def coerce_invalids(self, experiments: pd.DataFrame) -> pd.DataFrame:
"""Coerces all invalid output measurements to np.nan

Expand Down
49 changes: 49 additions & 0 deletions bofire/data_models/strategies/random.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,61 @@


class RandomStrategy(Strategy):
"""Strategy for drawing random samples from a domain subject to its constraints.

Sampling proceeds in four regimes, picked automatically based on the
constraints present in the domain:

1. **Unconstrained / categorical-only domains.** Samples are drawn directly
from each input feature using ``fallback_sampling_method`` (uniform,
Sobol, or LHS).
2. **Linear/interpoint-constrained domains.** A hit-and-run polytope sampler
(``botorch.sample_q_batches_from_polytope``) draws candidates that
satisfy linear equality, linear inequality, and interpoint equality
constraints. Categorical and discrete inputs are sampled independently
with the fallback method.
3. **NChooseK and/or ``allow_zero`` features.** The strategy draws up to
``max_combinations`` distinct active-feature subsets uniformly from all
valid subsets (one group per ``NChooseKConstraint``, plus one singleton
group per ``ContinuousInput`` with ``allow_zero=True`` outside any
NChooseK). For each drawn subset the unselected zeroable features are
pinned to ``0`` and the remaining features are sampled via the polytope
sampler. Final candidates are concatenated and uniformly subsampled.
4. **Nonlinear or product constraints.** When constraints are present that
cannot be enforced directly by the polytope sampler, regimes 1-3 are
used as a proposal distribution and rejection sampling filters
candidates until ``candidate_count`` valid samples are found.

Attributes:
fallback_sampling_method: Sampling method for unconstrained / fixed
inputs and for categorical and discrete features.
n_burnin: Burn-in length for the hit-and-run polytope sampler.
n_thinning: Thinning factor for the hit-and-run polytope sampler.
num_base_samples: Batch size used when drawing proposals during
rejection sampling. If ``None``, the requested ``candidate_count``
is used.
max_iters: Maximum number of rejection-sampling iterations before the
strategy gives up. Each iteration draws ``num_base_samples``
candidates.
max_combinations: Maximum number of distinct active-feature subsets to
draw per ``ask`` when NChooseK or ``allow_zero`` features are
present. Larger values give a better mix of subsets at the cost of
more polytope-sampler invocations.
nchoosek_max_iters: Maximum number of rejection-sampling attempts when
drawing a single valid active-feature subset under overlapping
``NChooseKConstraint``s. Independent from ``max_iters``.
sampler_kwargs: Extra keyword arguments forwarded to the fallback
sampler (e.g. ``{"scramble": True}`` for Sobol).
"""

type: Literal["RandomStrategy"] = "RandomStrategy"
fallback_sampling_method: SamplingMethodEnum = SamplingMethodEnum.UNIFORM
n_burnin: Annotated[int, Field(ge=1)] = 1000
n_thinning: Annotated[int, Field(ge=1)] = 32
num_base_samples: Optional[Annotated[int, Field(gt=0)]] = None
max_iters: Annotated[int, Field(gt=0)] = 1000
max_combinations: Annotated[int, Field(gt=0)] = 64
nchoosek_max_iters: Annotated[int, Field(gt=0)] = 1000
sampler_kwargs: Optional[dict] = None

def is_constraint_implemented(self, my_type: Type[Constraint]) -> bool:
Expand Down
Loading
Loading