# (c) Kevin Dunn, 2010-2026. MIT License.
"""Optimal designs: D-optimal, I-optimal, A-optimal, E-optimal.
Two engines, chosen per request by :func:`_dispatch_optimal`:
- ``pyoptex`` (coordinate exchange), when installed and no constraints are given.
It also builds split-plot designs for ``hard_to_change`` factors. ``pyoptex`` is
not a process-improve extra because it pins ``plotly~=5.24`` (< 6), which
conflicts with this project's ``plotly>=6.5.2``; install it separately
(``pip install pyoptex``) in its own environment.
- The built-in candidate exchange in ``designs_constrained.py`` otherwise: a
Fedorov exchange over a grid of candidate points for D-, I-, A- or E-optimality,
with constraints, categorical factors and fixed runs, but no split-plot structure.
pyoptex's continuous factors take the levels ``{-1, 0, 1}``, or five levels from -1 to
1 under a quadratic model.
Mixture factors always use ``designs_mixture_constrained.py``, and mixture components
together with process factors ``designs_mixture_process.py``.
"""
from __future__ import annotations
import contextlib
import logging
import warnings
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
import numpy as np
import pandas as pd
from process_improve._random import check_random_state
from process_improve._warnings import user_stacklevel
if TYPE_CHECKING:
from collections.abc import Iterator
from process_improve.experiments.factor import Constraint, Factor
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# pyoptex availability check
# ---------------------------------------------------------------------------
_PYOPTEX_AVAILABLE = False
try:
from pyoptex.doe.fixed_structure import (
Factor as PyoptexFactor,
)
from pyoptex.doe.fixed_structure import (
RandomEffect,
create_fixed_structure_design,
create_parameters,
default_fn,
)
from pyoptex.doe.fixed_structure.metric import Aopt, Dopt, Iopt
from pyoptex.utils.model import model2Y2X, partial_rsm_names
_PYOPTEX_AVAILABLE = True
except ImportError: # pragma: no cover - exercised via env-without-pyoptex
pass
#: Remediation hint shown when pyoptex is required but not installed. pyoptex is
#: deliberately not a process-improve extra: its latest release pins
#: ``plotly~=5.24`` (< 6), which conflicts with this project's ``plotly>=6.5.2``
#: floor, so the two cannot share an environment. Install it separately.
_PYOPTEX_INSTALL_HINT = (
"Install it separately with `pip install pyoptex` (note: pyoptex pins "
"plotly<6, which conflicts with this project's plotly>=6.5.2, so it cannot "
"be co-installed with the 'plotting'/'all' extras; use a separate "
"environment)."
)
# ---------------------------------------------------------------------------
# pyoptex adapter layer
# ---------------------------------------------------------------------------
#: Map our model-type strings to pyoptex's partial_rsm_names keywords.
_PYOPTEX_MODEL_MAP = {
"main_effects": "lin",
"interactions": "tfi",
"quadratic": "quad",
}
def _n_model_parameters(factors: list[Factor], model_type: str) -> int:
"""Return the number of coefficients in the model an optimal design is asked for.
A design with fewer runs than this cannot estimate the model at all: the
model matrix is rank deficient no matter which points are chosen. The count
is the estimability floor every backend is held to.
Each factor contributes one encoded column, except a categorical factor with
``L`` levels, which contributes ``L - 1``. The model then has an intercept,
the main effects, the two-factor interactions (for ``"interactions"`` and
``"quadratic"``), and one pure-quadratic term per non-categorical factor
(for ``"quadratic"`` only; a categorical factor has no square, which is why
``_run_pyoptex`` drops it to a ``"tfi"`` model per factor).
Parameters
----------
factors : list[Factor]
Factor specifications.
model_type : str
``"main_effects"``, ``"interactions"`` or ``"quadratic"``, as validated by
``_dispatch_optimal``.
Returns
-------
int
The number of model coefficients.
"""
from process_improve.experiments.factor import FactorType # noqa: PLC0415
widths = [len(f.levels) - 1 if f.type == FactorType.categorical and f.levels else 1 for f in factors]
n_parameters = 1 + sum(widths) # intercept + main effects
rsm_key = _PYOPTEX_MODEL_MAP.get(model_type, "tfi")
if rsm_key in {"tfi", "quad"}:
n_parameters += sum(widths[i] * widths[j] for i in range(len(widths)) for j in range(i + 1, len(widths)))
if rsm_key == "quad":
n_parameters += sum(1 for f in factors if f.type != FactorType.categorical)
return n_parameters
def _floor_budget_at_model_size(factors: list[Factor], budget: int, model_type: str) -> int:
"""Raise the default ``budget`` to the model's parameter count, warning when it does.
Only the default budget reaches this floor: a budget the caller gave is refused by
:func:`~process_improve.experiments.designs_utils.refuse_budget_below_model` when it is
too small. Both backends need this floor and neither imposed it consistently. pyoptex
failed with an upstream message about "rank collinearity" between model
components, which names neither the budget nor the model; the point-exchange
fallback clamped to one run per factor, one short of the intercept, and even
the default ``budget = 2 * n_factors + 1`` falls below the parameter count
of an interactions model from five factors up.
"""
n_parameters = _n_model_parameters(factors, model_type)
if budget < n_parameters:
logger.warning(
"A budget of %d run(s) cannot estimate a '%s' model over %d factor(s), which has %d "
"coefficients; raising the budget to %d. Reduce the model or the number of factors to "
"run fewer experiments.",
budget,
model_type,
len(factors),
n_parameters,
n_parameters,
)
return n_parameters
return budget
def _check_hard_to_change(req: _OptimalRequest) -> None:
"""Refuse ``hard_to_change`` names that are not factors of the design."""
unknown = sorted(set(req.hard_to_change or []) - {f.name for f in req.factors})
if unknown:
raise ValueError(
f"hard_to_change names {unknown} are not factors of the design; use names from "
f"{[f.name for f in req.factors]}."
)
def _settled_budget(req: _OptimalRequest) -> int | None:
"""Return the budget to build with: the one asked for, or a default covering blocks and fixed runs.
A budget asked for is refused below the model size; with blocks or fixed runs, the engine refuses
it later when the block effects or the rank the fixed runs lack need more runs. Without a budget,
blocks or fixed runs this is ``None`` (the default ``2 * k + 1`` applies). Without a budget, the
default leaves room for the block effects and for the coefficients the fixed runs do not estimate,
so it never falls at or below the number of fixed runs.
"""
from process_improve.experiments.designs_utils import refuse_budget_below_model # noqa: PLC0415
n_parameters = _n_model_parameters(req.factors, req.model_type)
if req.budget is not None:
refuse_budget_below_model(req.budget, n_parameters, repr(req.model_type))
return req.budget
k = len(req.factors)
# The blocks' effects are coefficients too; counting them keeps the default above what the engine needs.
n_parameters += req.n_blocks - 1 if req.n_blocks and req.n_blocks > 1 else 0
if req.fixed_runs is None or not len(req.fixed_runs):
return max(2 * k + 1, n_parameters) if req.n_blocks and req.n_blocks > 1 else None
with contextlib.suppress(ValueError, KeyError, TypeError): # invalid fixed runs are reported later, in full
prior = _prepare_prior_runs(req.fixed_runs, req.factors, len(req.fixed_runs) + n_parameters)
rank = int(np.linalg.matrix_rank(_coded_rows(req.factors, prior, req.model_type)))
return max(2 * k + 1, n_parameters, len(prior) + max(n_parameters - rank, 1))
return max(2 * k + 1, n_parameters, len(req.fixed_runs) + 1)
#: Map our optimality-criterion strings to pyoptex metric constructors.
_PYOPTEX_METRIC_MAP: dict = {}
if _PYOPTEX_AVAILABLE: # pragma: no branch - false only in env-without-pyoptex
_PYOPTEX_METRIC_MAP = {
"d_optimal": Dopt,
"i_optimal": Iopt,
"a_optimal": Aopt,
}
#: Levels pyoptex may give a continuous factor under a quadratic model (A- and I-optimal designs need the
#: interior ones). Without squared terms pyoptex's default, ``{-1, 0, 1}``, is kept.
_QUADRATIC_LEVELS = np.linspace(-1.0, 1.0, 5)
#: Ratio of the whole-plot to the run-to-run variance assumed for a split-plot design.
_WHOLE_PLOT_VARIANCE_RATIO = 0.5
def _whole_plot_count(factors: list[Factor], hard_to_change: list[str], n_runs: int, model_type: str) -> int:
"""Default number of whole plots: ``max(4, n_runs // 3)``, raised to one more than the whole-plot terms.
The whole-plot terms are the coefficients of the model in the hard-to-change factors
alone (intercept, their main effects, interactions and squares). With fewer whole
plots than those, they cannot be estimated; one more leaves a degree of freedom for
the whole-plot variance. Never more than ``n_runs``.
"""
htc = [f for f in factors if f.name in set(hard_to_change)]
n_terms = _n_model_parameters(htc, model_type)
return min(n_runs, max(4, n_runs // 3, n_terms + 1))
def _convert_factors_to_pyoptex(
factors: list[Factor],
hard_to_change: list[str] | None = None,
n_runs: int | None = None,
n_whole_plots: int | None = None,
model_type: str = "interactions",
) -> list:
"""Translate our ``Factor`` objects into ``pyoptex.doe.fixed_structure.Factor`` objects.
Parameters
----------
factors : list[Factor]
Our Factor specifications.
hard_to_change : list[str] or None
Names of hard-to-change factors. When provided a ``RandomEffect``
is created that groups consecutive runs into whole plots.
n_runs : int or None
Total number of runs (needed when building the split-plot structure).
n_whole_plots : int or None
Number of whole plots. Defaults to ``max(4, n_runs // 3)``, raised to one more
than the number of model terms in the hard-to-change factors alone (see
:func:`_whole_plot_count`), when *hard_to_change* is given.
model_type : str
The model: it sets the default number of whole plots, and for ``"quadratic"``
the continuous factors may take the five levels ``_QUADRATIC_LEVELS`` instead of
pyoptex's ``{-1, 0, 1}``, since A- and I-optimal designs need interior levels.
Returns
-------
list[pyoptex Factor]
"""
from process_improve.experiments.factor import FactorType # noqa: PLC0415
random_effect = None
if hard_to_change and n_runs:
if n_whole_plots is None:
n_whole_plots = _whole_plot_count(factors, hard_to_change, n_runs, model_type)
# Build a balanced whole-plot assignment: e.g. 12 runs, 4 plots → [0,0,0, 1,1,1, 2,2,2, 3,3,3]
runs_per_plot = max(1, n_runs // n_whole_plots)
z_array = np.repeat(np.arange(n_whole_plots), runs_per_plot)
# Pad if n_runs doesn't divide evenly
if len(z_array) < n_runs:
z_array = np.concatenate([z_array, np.full(n_runs - len(z_array), n_whole_plots - 1)])
random_effect = RandomEffect(z_array[:n_runs], ratio=_WHOLE_PLOT_VARIANCE_RATIO)
from process_improve.experiments.designs_utils import sorted_level_ranks # noqa: PLC0415
htc_set = set(hard_to_change) if hard_to_change else set()
continuous_levels = _QUADRATIC_LEVELS if model_type == "quadratic" else None
pyoptex_factors = []
for f in factors:
if f.type == FactorType.categorical:
# pyoptex effect-codes a categorical factor against its last level. Giving it the
# levels in sorted order makes that the level evaluate_design (patsy) and the
# candidate exchange use, so all three report the same A-criterion.
levels = list(f.levels or [])
pf = PyoptexFactor(
f.name,
random_effect if f.name in htc_set else None,
type="categorical",
levels=[levels[i] for i in np.argsort(sorted_level_ranks(levels))],
)
elif continuous_levels is not None:
pf = PyoptexFactor(
f.name,
random_effect if f.name in htc_set else None,
type="continuous",
levels=continuous_levels,
)
else:
pf = PyoptexFactor(
f.name,
random_effect if f.name in htc_set else None,
type="continuous",
)
pyoptex_factors.append(pf)
return pyoptex_factors
@dataclass
class _PyoptexOptions:
"""Optional knobs for :func:`_run_pyoptex`.
Pulled into a dataclass so the function signature stays at four
parameters (ENG-25 / #307: removes one ``noqa: PLR0913``).
"""
model_type: str = "interactions"
hard_to_change: list[str] | None = None
n_tries: int = 10
fixed_runs: pd.DataFrame | None = None
random_state: int | np.random.Generator | None = None
@contextlib.contextmanager
def _global_numpy_seed(rng: np.random.Generator) -> Iterator[None]:
"""Seed numpy's global RNG from ``rng`` for the duration of the block, then restore the caller's state.
pyoptex draws its coordinate-exchange restarts from the global RNG, which a
caller's ``random_state`` cannot otherwise reach. Restoring the state afterwards
means the design neither depends on nor disturbs any global seeding the caller
has done.
"""
saved = np.random.get_state() # noqa: NPY002
np.random.seed(int(rng.integers(2**32))) # noqa: NPY002 - pyoptex reads the legacy global RNG
try:
yield
finally:
np.random.set_state(saved) # noqa: NPY002
def _prepare_prior_runs(fixed_runs: pd.DataFrame, factors: list[Factor], budget: int) -> pd.DataFrame:
"""Validate and format fixed runs for pyoptex's ``prior=`` augmentation.
``fixed_runs`` holds runs to keep fixed while the coordinate exchange fills the remaining
``budget - len(fixed_runs)`` runs. It must be in the same coding as the returned design:
continuous factors in coded ``[-1, 1]`` units, categorical factors as level labels. Columns
other than the factor names are ignored, and the input is not mutated.
Parameters
----------
fixed_runs : pandas.DataFrame
One row per fixed run, one column per factor.
factors : list[Factor]
The design factors.
budget : int
Total number of runs (fixed plus optimized).
Returns
-------
pandas.DataFrame
A copy holding only the factor columns, in factor order, ready to pass to pyoptex.
"""
from process_improve.experiments.factor import FactorType # noqa: PLC0415
if not isinstance(fixed_runs, pd.DataFrame):
raise TypeError("fixed_runs must be a pandas DataFrame with one column per factor.")
names = [f.name for f in factors]
missing = [n for n in names if n not in fixed_runs.columns]
if missing:
raise ValueError(f"fixed_runs is missing columns for factors: {missing}.")
n_fixed = len(fixed_runs)
if n_fixed == 0:
raise ValueError("fixed_runs is empty; omit it instead of passing an empty frame.")
if n_fixed >= budget:
raise ValueError(
f"fixed_runs has {n_fixed} runs but budget is {budget}; budget must exceed the number "
f"of fixed runs so at least one run is optimized."
)
prior = fixed_runs.loc[:, names].reset_index(drop=True).copy()
for f in factors:
col = prior[f.name]
if f.type == FactorType.categorical:
levels = list(f.levels or [])
unknown = sorted(set(col.astype(str)) - {str(lv) for lv in levels})
if unknown:
raise ValueError(
f"fixed_runs has unknown levels for categorical factor {f.name!r}: {unknown}. "
f"Valid levels: {levels}."
)
else:
vals = pd.to_numeric(col, errors="coerce")
if vals.isna().any():
raise ValueError(f"fixed_runs has non-numeric values for continuous factor {f.name!r}.")
if (vals.abs() > 1.0 + 1e-9).any():
raise ValueError(
f"fixed_runs values for continuous factor {f.name!r} must be in coded [-1, 1], "
f"the same coding as the returned design."
)
prior[f.name] = vals.astype(float)
return prior
def _run_pyoptex(
factors: list[Factor],
criterion: str,
budget: int,
options: _PyoptexOptions | None = None,
) -> tuple[np.ndarray, dict]:
"""Run pyoptex's coordinate-exchange optimizer.
Parameters
----------
factors : list[Factor]
Our Factor specifications.
criterion : str
One of ``"d_optimal"``, ``"i_optimal"``, ``"a_optimal"``.
budget : int
Number of runs in the design.
options : _PyoptexOptions or None
Optional knobs (model_type, hard_to_change, n_tries). Defaults
are interactions / no hard-to-change / 10 restarts.
Returns
-------
tuple[np.ndarray, dict]
Coded design matrix (levels in ``[-1, 1]`` for continuous factors, labels for
categorical ones) and metadata. ``metric_value`` is pyoptex's own criterion
value; without split-plot structure, ``log_det_information`` or
``trace_criterion`` give it on the candidate exchange's scale. A split-plot
design records each run's whole plot (``whole_plot``), ``n_whole_plots`` and
``whole_plot_variance_ratio``.
"""
opts = options if options is not None else _PyoptexOptions()
model_type = opts.model_type
hard_to_change = opts.hard_to_change
n_tries = opts.n_tries
prior = None
if opts.fixed_runs is not None:
prior = _prepare_prior_runs(opts.fixed_runs, factors, budget)
_refuse_budget_short_of_fixed_rank(factors, prior, model_type, budget)
pyoptex_factors = _convert_factors_to_pyoptex(
factors,
hard_to_change=hard_to_change,
n_runs=budget,
model_type=model_type,
)
# Build the model per factor. A categorical factor has no pure-quadratic
# term (its square is undefined and, once indicator-coded, idempotent), so a
# "quadratic" request becomes a partial response-surface model: quadratics on
# the continuous factors, main-effect-plus-interactions on the categorical
# ones. This is the standard second-order-with-categorical model and avoids
# the rank collinearity a uniform x**2 would create.
from process_improve.experiments.factor import FactorType # noqa: PLC0415
rsm_key = _PYOPTEX_MODEL_MAP.get(model_type, "tfi")
def _factor_rsm_key(factor: Factor) -> str:
if rsm_key == "quad" and factor.type == FactorType.categorical:
return "tfi"
return rsm_key
model_spec = partial_rsm_names({f.name: _factor_rsm_key(f) for f in factors})
y2x = model2Y2X(model_spec, pyoptex_factors)
# Select metric
metric_cls = _PYOPTEX_METRIC_MAP[criterion]
metric = metric_cls()
fn = default_fn(pyoptex_factors, metric, y2x)
params = create_parameters(pyoptex_factors, fn, nruns=budget, prior=prior)
with _global_numpy_seed(check_random_state(opts.random_state)):
design_df, state = create_fixed_structure_design(params, n_tries=n_tries)
meta = {
"optimality_criterion": criterion,
"metric_value": float(state.metric),
"model_type": model_type,
"backend": "pyoptex",
}
if hard_to_change:
z_array = next(pf.re.Z for pf in pyoptex_factors if pf.re is not None)
meta["hard_to_change"] = hard_to_change
whole_plot = [int(z) for z in z_array]
meta["whole_plot"] = whole_plot
meta["n_whole_plots"] = len(set(whole_plot))
meta["whole_plot_variance_ratio"] = _WHOLE_PLOT_VARIANCE_RATIO
else:
meta.update(_exchange_scale_metadata(factors, design_df, criterion, model_type))
if prior is not None:
meta["n_fixed_runs"] = len(prior)
return design_df.values, meta
def _coded_rows(factors: list[Factor], design: pd.DataFrame, model_type: str) -> np.ndarray:
"""Model rows (the candidate exchange's coding) of a design: coded continuous values, categorical labels."""
from process_improve.experiments.designs_constrained import _Region, model_matrix # noqa: PLC0415
from process_improve.experiments.factor import FactorType # noqa: PLC0415
continuous = [f for f in factors if f.type != FactorType.categorical]
categorical = [f for f in factors if f.type == FactorType.categorical]
region = _Region(continuous, categorical, [])
coded = design[[f.name for f in continuous]].to_numpy(dtype=float).reshape(len(design), len(continuous))
labels = {f.name: [str(lv) for lv in f.levels or []] for f in categorical}
cats = np.array([[labels[f.name].index(str(v)) for v in design[f.name]] for f in categorical], dtype=int).T.reshape(
len(design), len(categorical)
)
return model_matrix(region, coded, cats, model_type)
def _refuse_budget_short_of_fixed_rank(
factors: list[Factor], prior: pd.DataFrame, model_type: str, budget: int
) -> None:
"""Raise when the free runs cannot supply the rank the fixed runs lack (as the candidate exchange does)."""
from process_improve.experiments.designs_constrained import refuse_budget_short_of_fixed_runs # noqa: PLC0415
model = model_type if model_type in _PYOPTEX_MODEL_MAP else "interactions"
f_fixed = _coded_rows(factors, prior, model)
refuse_budget_short_of_fixed_runs(f_fixed, f_fixed.shape[1], budget)
def _exchange_scale_metadata(factors: list[Factor], design: pd.DataFrame, criterion: str, model_type: str) -> dict:
"""Return the criterion value under the candidate exchange's key and scale, so the backends can be compared.
pyoptex reports ``metric_value`` on its own scale: ``det(X'X)^(1/p)`` for D and
``-trace`` for A and I. This adds ``log_det_information`` or ``trace_criterion``,
computed in the candidate exchange's coding (I-optimality averaged over a fixed
uniform sample of the factor box). Both effect-code a categorical factor against the
same level, so for A-optimality ``trace_criterion`` equals ``-metric_value``.
"""
from process_improve.experiments.designs_constrained import ( # noqa: PLC0415
_Region,
_uniform_rows,
criterion_metadata,
make_criterion,
)
from process_improve.experiments.factor import FactorType # noqa: PLC0415
model = model_type if model_type in _PYOPTEX_MODEL_MAP else "interactions"
rows = _coded_rows(factors, design, model)
continuous = [f for f in factors if f.type != FactorType.categorical]
region = _Region(continuous, [f for f in factors if f.type == FactorType.categorical], [])
rng = np.random.default_rng(0) # a fixed sample, so the reported value does not vary between calls
chosen = make_criterion(
criterion, rows.shape[1], lambda: _uniform_rows(region, model, rng, np.zeros((1, len(continuous))))
)
return criterion_metadata(chosen, chosen.value(rows.T @ rows))
# ---------------------------------------------------------------------------
# Routing: pyoptex, or the built-in candidate exchange
# ---------------------------------------------------------------------------
@dataclass
class _OptimalRequest:
"""Everything an optimal-design request carries, whichever criterion it is for."""
factors: list[Factor]
budget: int | None
hard_to_change: list[str] | None
constraints: list[Constraint] | None
model_type: str
fixed_runs: pd.DataFrame | None
random_state: int | np.random.Generator | None
candidates: pd.DataFrame | None = None
backend: str = "auto"
n_blocks: int | None = None
def _dispatch_mixture_optimal(criterion: str, req: _OptimalRequest) -> tuple[np.ndarray, dict]:
"""Optimal mixture design over the (possibly constrained) simplex, for a Scheffé model."""
from process_improve.experiments.designs_constrained import ConstrainedOptions # noqa: PLC0415
from process_improve.experiments.designs_mixture_constrained import ( # noqa: PLC0415
constrained_mixture_design,
scheffe_matrix,
)
if req.fixed_runs is not None or req.hard_to_change:
raise ValueError("fixed_runs and hard_to_change are not supported for mixture designs.")
n_terms = scheffe_matrix(np.ones((1, len(req.factors))), req.model_type).shape[1]
options = ConstrainedOptions(model_type=req.model_type, criterion=criterion, candidates=req.candidates)
return constrained_mixture_design(
req.factors, req.budget or n_terms + 3, req.constraints, options, req.random_state
)
def _dispatch_mixture_process_optimal(criterion: str, req: _OptimalRequest) -> tuple[np.ndarray, dict]:
"""Optimal design for mixture components and process factors, over the product of their regions."""
from process_improve.experiments.designs_constrained import ConstrainedOptions # noqa: PLC0415
from process_improve.experiments.designs_mixture_process import mixture_process_design # noqa: PLC0415
asked = {
"fixed_runs": req.fixed_runs is not None,
"hard_to_change": bool(req.hard_to_change),
"candidates": req.candidates is not None,
"n_blocks": bool(req.n_blocks and req.n_blocks > 1),
"backend='pyoptex'": req.backend == "pyoptex",
}
given = [name for name, used in asked.items() if used]
if given:
raise ValueError(f"Mixture-process designs do not take {', '.join(given)} yet.")
options = ConstrainedOptions(model_type=req.model_type, criterion=criterion)
return mixture_process_design(req.factors, req.budget, req.constraints, options, req.random_state)
def _split_plot_refusal(criterion: str, req: _OptimalRequest) -> str | None:
"""Why the built-in split-plot search cannot build this request, or ``None`` when it can."""
from process_improve.experiments.designs_split_plot import CRITERIA # noqa: PLC0415
asked = {
"constraints are given": bool(req.constraints),
"a candidate set is given": req.candidates is not None,
"fixed runs are given (backend='pyoptex' keeps them in a split-plot design)": req.fixed_runs is not None,
"blocks are asked for": bool(req.n_blocks and req.n_blocks > 1),
f"the split-plot search has no {criterion[0].upper()}-optimality criterion": criterion not in CRITERIA,
}
reasons = [reason for reason, given in asked.items() if given]
return "; ".join(reasons) or None
def _native_split_plot(criterion: str, req: _OptimalRequest, budget: int) -> tuple[np.ndarray, dict]:
"""Build a split-plot design by the built-in coordinate exchange on the GLS information."""
from process_improve.experiments.designs_split_plot import SplitPlotOptions, split_plot_design # noqa: PLC0415
hard_to_change = list(req.hard_to_change or [])
options = SplitPlotOptions(
criterion=criterion,
model_type=req.model_type,
n_whole_plots=_whole_plot_count(req.factors, hard_to_change, budget, req.model_type),
variance_ratio=_WHOLE_PLOT_VARIANCE_RATIO,
)
return split_plot_design(req.factors, hard_to_change, budget, options, req.random_state)
def _candidate_exchange(
criterion: str, req: _OptimalRequest, budget: int, refusal: str | None
) -> tuple[np.ndarray, dict]:
"""Build the design by the built-in candidate exchange; ``hard_to_change`` is ignored, with a warning."""
from process_improve.experiments.designs_constrained import ( # noqa: PLC0415
ConstrainedOptions,
constrained_optimal_design,
)
prior = _prepare_prior_runs(req.fixed_runs, req.factors, budget) if req.fixed_runs is not None else None
options = ConstrainedOptions(
model_type=req.model_type,
criterion=criterion,
fixed_runs=prior,
candidates=req.candidates,
n_blocks=req.n_blocks,
)
matrix, meta = constrained_optimal_design(req.factors, budget, req.constraints or [], options, req.random_state)
if req.hard_to_change:
warnings.warn(
f"hard_to_change={list(req.hard_to_change)} is ignored, so this is an ordinary (not a split-plot) "
f"design: {refusal}. metadata['hard_to_change_ignored'] records it.",
category=UserWarning,
stacklevel=user_stacklevel(),
)
meta["hard_to_change_ignored"] = list(req.hard_to_change)
return matrix, meta
def _pyoptex_blocker(criterion: str, req: _OptimalRequest) -> str | None:
"""Why pyoptex cannot build this request, or ``None`` when it can."""
if req.constraints:
return "constraints are given (pyoptex does not enforce them)"
if req.candidates is not None:
return "a candidate set is given"
if criterion in ("e_optimal", "g_optimal", "k_optimal"):
return f"pyoptex has no {criterion[0].upper()}-optimality criterion"
if not _PYOPTEX_AVAILABLE:
return f"pyoptex is not installed. {_PYOPTEX_INSTALL_HINT}"
return None
def _dispatch_optimal(criterion: str, req: _OptimalRequest) -> tuple[np.ndarray, dict]:
"""Route a D-, I-, A- or E-optimal request to the backend that can honour it.
- Mixture factors go to the constrained-simplex engine with a Scheffé model, and
mixture components beside process factors to the mixture-process engine.
- By default the built-in engines run, so the design does not depend on whether pyoptex
is installed: for a split-plot design (``hard_to_change``) the coordinate exchange of
:func:`~process_improve.experiments.designs_split_plot.split_plot_design`, otherwise the
candidate exchange
(:func:`~process_improve.experiments.designs_constrained.constrained_optimal_design`),
which handles every criterion, constraints, candidate sets, categorical factors and
fixed runs. When the split-plot search cannot honour the request (see
:func:`_split_plot_refusal`), ``hard_to_change`` is ignored and recorded.
- pyoptex's coordinate exchange runs for ``backend="pyoptex"``.
"""
from process_improve.experiments.factor import FactorType # noqa: PLC0415
if req.factors and all(f.type == FactorType.mixture for f in req.factors):
return _dispatch_mixture_optimal(criterion, req)
if any(f.type == FactorType.mixture for f in req.factors):
return _dispatch_mixture_process_optimal(criterion, req)
if req.model_type not in _PYOPTEX_MODEL_MAP:
raise ValueError(
f"model_type={req.model_type!r} is not supported for {criterion}; choose from "
f"{', '.join(_PYOPTEX_MODEL_MAP)} (Scheffé models apply to mixture components only)."
)
_check_hard_to_change(req)
settled = _settled_budget(req)
if settled != req.budget: # no budget was given: build with the default the blocks and fixed runs need
return _dispatch_optimal(criterion, replace(req, budget=settled))
budget = req.budget if req.budget is not None else 2 * len(req.factors) + 1
budget = _floor_budget_at_model_size(req.factors, budget, req.model_type)
if req.backend not in ("auto", "exchange", "pyoptex"):
raise ValueError(f"backend must be 'auto', 'exchange' or 'pyoptex'; got {req.backend!r}.")
if req.backend != "pyoptex":
# The built-in engines are the default, so a design does not depend on which optional packages
# are installed: the coordinate exchange for split-plot structure, the candidate exchange otherwise.
refusal = _split_plot_refusal(criterion, req) if req.hard_to_change else None
if req.hard_to_change and refusal is None:
return _native_split_plot(criterion, req, budget)
return _candidate_exchange(criterion, req, budget, refusal)
blocker = _pyoptex_blocker(criterion, req)
if blocker is not None:
raise ValueError(f"backend='pyoptex' cannot be used here: {blocker}.")
return _run_pyoptex(
req.factors,
criterion=criterion,
budget=budget,
options=_PyoptexOptions(
model_type=req.model_type,
hard_to_change=req.hard_to_change,
fixed_runs=req.fixed_runs,
random_state=req.random_state,
),
)
# ---------------------------------------------------------------------------
# Public dispatch functions
# ---------------------------------------------------------------------------
_DISPATCH_PARAMETERS = """
Parameters
----------
factors : list[Factor]
Factor specifications. All-mixture factors get a Scheffé-model design over
the (possibly constrained) simplex.
budget : int or None
Number of runs. Defaults to ``2 * n_factors + 1``, or with ``fixed_runs`` to
enough runs beyond them to estimate the model. A budget below the model's number
of coefficients raises a ``ValueError``, and so does one that leaves too few runs
for the block effects (``n_blocks``) or for the rank ``fixed_runs`` lack.
hard_to_change : list[str] or None
Names of hard-to-change factors: a split-plot design, found by the built-in
coordinate exchange on the GLS information (see
:func:`~process_improve.experiments.designs_split_plot.split_plot_design`), or by
pyoptex with ``backend="pyoptex"``. A name that is not a factor raises
``ValueError``. Ignored, with a warning, and recorded as ``hard_to_change_ignored``,
when the design is built by the candidate exchange: with constraints, a candidate
set, fixed runs (unless ``backend="pyoptex"``), blocks, or the E-, G- or K-criterion.
constraints : list[Constraint] or None
Inequalities in actual units over the continuous factors, e.g.
``"3*T + 5*D <= 600"``. Enforced: every run the optimizer places satisfies
them. Fixed runs outside the region are kept as given, with a warning, and
counted in ``metadata["n_fixed_runs_outside_region"]``.
model_type : str
Model assumption: ``"main_effects"``, ``"interactions"``, or ``"quadratic"``.
fixed_runs : pd.DataFrame or None
Runs to hold fixed while the optimizer fills the rest (design
augmentation). Occupies the first rows of the returned design and counts
towards ``budget``.
random_state : int, numpy.random.Generator or None
Seed for the candidate exchange's random starts.
candidates : pd.DataFrame or None
Settings to choose the runs from, in actual units (proportions for a
mixture), one column per factor. Replaces the generated grid; rows breaking
a constraint are dropped. ``metadata["selected_candidates"]`` counts how often
each row (by index label) was picked.
backend : {"auto", "exchange", "pyoptex"}
``"auto"`` and ``"exchange"`` use the built-in engines: the coordinate exchange for a
split-plot design, the candidate exchange otherwise. ``"pyoptex"`` asks for pyoptex.
n_blocks : int or None
Blocks the design is run in. With the built-in exchange, the blocks are fixed
effects in the model the runs are chosen for, and ``metadata["block_labels"]``
gives each run's block (the fixed runs a block of their own). Otherwise the
blocks are assigned afterwards, by :func:`~process_improve.experiments.generate_design`.
Returns
-------
tuple[np.ndarray, dict]
The coded design and its metadata; ``metadata["backend"]`` names the engine
(``"split_plot_exchange"``, ``"candidate_exchange"`` or ``"pyoptex"``).
"""
[docs]
def dispatch_d_optimal( # noqa: PLR0913
factors: list[Factor],
budget: int | None = None,
hard_to_change: list[str] | None = None,
constraints: list[Constraint] | None = None,
model_type: str = "interactions",
fixed_runs: pd.DataFrame | None = None,
random_state: int | np.random.Generator | None = None,
candidates: pd.DataFrame | None = None,
backend: str = "auto",
n_blocks: int | None = None,
) -> tuple[np.ndarray, dict]:
"""Generate a D-optimal design (maximises ``det(X'X)``, the precision of the coefficients jointly)."""
req = _OptimalRequest(
factors,
budget,
hard_to_change,
constraints,
model_type,
fixed_runs,
random_state,
candidates,
backend,
n_blocks,
)
return _dispatch_optimal("d_optimal", req)
[docs]
def dispatch_i_optimal( # noqa: PLR0913
factors: list[Factor],
budget: int | None = None,
hard_to_change: list[str] | None = None,
constraints: list[Constraint] | None = None,
model_type: str = "interactions",
fixed_runs: pd.DataFrame | None = None,
random_state: int | np.random.Generator | None = None,
candidates: pd.DataFrame | None = None,
backend: str = "auto",
n_blocks: int | None = None,
) -> tuple[np.ndarray, dict]:
"""Generate an I-optimal design (minimises the average prediction variance over the region)."""
req = _OptimalRequest(
factors,
budget,
hard_to_change,
constraints,
model_type,
fixed_runs,
random_state,
candidates,
backend,
n_blocks,
)
return _dispatch_optimal("i_optimal", req)
[docs]
def dispatch_a_optimal( # noqa: PLR0913
factors: list[Factor],
budget: int | None = None,
hard_to_change: list[str] | None = None,
constraints: list[Constraint] | None = None,
model_type: str = "interactions",
fixed_runs: pd.DataFrame | None = None,
random_state: int | np.random.Generator | None = None,
candidates: pd.DataFrame | None = None,
backend: str = "auto",
n_blocks: int | None = None,
) -> tuple[np.ndarray, dict]:
"""Generate an A-optimal design (minimises the summed variance of the coefficients)."""
req = _OptimalRequest(
factors,
budget,
hard_to_change,
constraints,
model_type,
fixed_runs,
random_state,
candidates,
backend,
n_blocks,
)
return _dispatch_optimal("a_optimal", req)
for _fn in (dispatch_d_optimal, dispatch_i_optimal, dispatch_a_optimal):
_fn.__doc__ = (_fn.__doc__ or "") + "\n" + _DISPATCH_PARAMETERS