Source code for process_improve.experiments.designs_mixture

# (c) Kevin Dunn, 2010-2026. MIT License.

"""Mixture designs: simplex-lattice and simplex-centroid points sized to the Scheffé model.

For mixture experiments the factor levels represent proportions that must
sum to 1.  These designs operate directly in actual proportions rather
than coded -1/+1 units.
"""

from __future__ import annotations

import itertools
import math
from typing import TYPE_CHECKING

import numpy as np

from process_improve.experiments.designs_mixture_constrained import SCHEFFE_DEGREE, scheffe_model
from process_improve.experiments.factor import FactorType

if TYPE_CHECKING:
    from process_improve.experiments.factor import Constraint, Factor


#: Default design names, by largest blend; the full simplex centroid is named as such.
_DEFAULT_METHOD = {
    1: "simplex_vertices_plus_centroid",
    2: "simplex_lattice_degree_2_plus_centroid",
    3: "simplex_centroid_ternary_plus_centroid",
}


[docs] def dispatch_mixture( factors: list[Factor], budget: int | None = None, constraints: list[Constraint] | None = None, model_type: str = "interactions", random_state: int | np.random.Generator | None = None, ) -> tuple[np.ndarray, dict]: """Generate a mixture design. On the full simplex (every component bounded by 0 and 1, no constraints) the design is sized to the Scheffé model: the pure components, the binary 50:50 blends for a quadratic model, and the ternary 1/3 blends for a special cubic one, each plus the overall centroid (Cornell 2002). These are the simplex-centroid points that blend at most one, two or three components; with three components the quadratic and special cubic designs are the full 7-run simplex centroid. A full cubic model is sized by the {q, 3} simplex lattice, every blend in thirds, plus the overall centroid when it is not one of them (four or more components). A *budget* smaller than that design gives a D-optimal subset of the candidate blends instead (see below), so the design never exceeds the budget. A budget below the number of model terms raises a ``ValueError``. When any component has a bound inside (0, 1), or *constraints* are given, the region is a polytope inside the simplex and the design comes from :func:`~process_improve.experiments.designs_mixture_constrained.constrained_mixture_design`: vertices, edge midpoints and centroid without a budget, a D-optimal subset of the candidate blends with one. Parameters ---------- factors : list[Factor] Mixture factors (proportions summing to 1); ``low``/``high`` are component bounds. budget : int or None Maximum number of runs. constraints : list[Constraint] or None Linear inequalities in the proportions, e.g. ``"x1 + x2 <= 0.7"``. model_type : str Scheffé model the design is built for: ``"scheffe_linear"`` (``"main_effects"`` maps here), ``"scheffe_quadratic"`` (``"interactions"`` and ``"quadratic"`` map here), ``"scheffe_special_cubic"`` or ``"scheffe_cubic"`` (``"cubic"`` maps here). random_state : int, numpy.random.Generator or None Seed for the exchange of a D-optimal design. Returns ------- tuple[np.ndarray, dict] Design matrix (in proportions, not coded) and metadata. """ k = len(factors) if k < 2: raise ValueError("Mixture designs require at least 2 components.") if any(f.type != FactorType.mixture for f in factors): raise ValueError("A mixture design needs every factor to be a mixture component (type='mixture').") model = scheffe_model(model_type) bounded = any((f.low or 0.0) > 0.0 or (f.high if f.high is not None else 1.0) < 1.0 for f in factors) blend_size = SCHEFFE_DEGREE[model] if model == "scheffe_cubic": n_default = math.comb(k + 2, 3) + (k > 3) else: n_default = sum(math.comb(k, r) for r in range(1, min(blend_size, k) + 1)) + (blend_size < k) if bounded or constraints or (budget is not None and budget < n_default): from process_improve.experiments.designs_constrained import ConstrainedOptions # noqa: PLC0415 from process_improve.experiments.designs_mixture_constrained import ( # noqa: PLC0415 constrained_mixture_design, ) return constrained_mixture_design(factors, budget, constraints, ConstrainedOptions(model), random_state) if model == "scheffe_cubic": lattice = _simplex_lattice(k, 3) if k > 3: # the centroid, 1/k of each, is not a blend in thirds return np.vstack([lattice, np.full((1, k), 1.0 / k)]), { "method": "simplex_lattice_degree_3_plus_centroid", "model_type": model, } return lattice, {"method": "simplex_lattice_degree_3", "model_type": model} matrix = _simplex_centroid(k, max_components=blend_size) method = "simplex_centroid" if blend_size >= k - 1 else _DEFAULT_METHOD[blend_size] return matrix, {"method": method, "model_type": model}
def _simplex_lattice(k: int, degree: int = 2) -> np.ndarray: """Generate a {k, degree} simplex-lattice design. The lattice consists of all points where each component takes values from {0, 1/degree, 2/degree, ..., 1} and all components sum to 1. They are listed directly, as the ways of sharing ``degree`` equal parts among the ``k`` components, so the work grows with the ``C(k + degree - 1, degree)`` points, not with ``(degree + 1) ** k``. Parameters ---------- k : int Number of mixture components. degree : int Degree of the lattice (typically 2 or 3). Returns ------- np.ndarray Design matrix of shape (n_points, k) with rows summing to 1. Raises ------ ValueError If ``k`` exceeds ``settings.max_factors_combinatorial``, or the lattice has more than 1,000,000 points (SEC-19 #268). """ from process_improve.config import settings # noqa: PLC0415 if k > settings.max_factors_combinatorial: raise ValueError( f"_simplex_lattice: k={k} exceeds the SEC-19 cap of " f"{settings.max_factors_combinatorial}. " "Increase settings.max_factors_combinatorial if intentional." ) n_points = math.comb(k + degree - 1, degree) if n_points > 1_000_000: raise ValueError( f"_simplex_lattice: the {{{k}, {degree}}} lattice has {n_points} points, " "more than the 1M cap. Reduce k or degree." ) points = np.zeros((n_points, k)) for row, parts in enumerate(itertools.combinations_with_replacement(range(k), degree)): np.add.at(points[row], list(parts), 1.0 / degree) return points def _simplex_centroid(k: int, max_components: int | None = None) -> np.ndarray: """Generate a simplex-centroid design for *k* components. Includes: - k vertices (pure components) - k*(k-1)/2 binary midpoints - k*(k-1)*(k-2)/6 ternary centroids - ... up to the overall centroid (1/k, ..., 1/k) Parameters ---------- k : int Number of mixture components. max_components : int or None Keep only the blends of at most this many components, plus the overall centroid. ``None`` keeps every blend: the full design. Returns ------- np.ndarray Design matrix of shape (2^k - 1, k) for the full design. Raises ------ ValueError If the full design is asked for with ``k`` above ``settings.max_factors_combinatorial`` (SEC-19 #268): it has ``2**k - 1`` rows, and ``k=40`` would allocate ~1 TiB. A partial design is refused above 1,000,000 rows instead. """ from process_improve.config import settings # noqa: PLC0415 sizes = list(range(1, k + 1)) if max_components is not None and max_components < k - 1: sizes = [*range(1, max_components + 1), k] n_rows = sum(math.comb(k, r) for r in sizes) if n_rows > 1_000_000: raise ValueError(f"_simplex_centroid: {n_rows} rows exceed the 1M cap. Reduce k or max_components.") elif k > settings.max_factors_combinatorial: raise ValueError( f"_simplex_centroid: k={k} exceeds the SEC-19 cap of " f"{settings.max_factors_combinatorial}. A k={k} centroid design " f"has 2**{k} - 1 rows. Increase settings.max_factors_combinatorial " "if intentional." ) points: list[list[float]] = [] # Generate the non-empty subsets of {0, 1, ..., k-1} of the sizes kept for r in sizes: for subset in itertools.combinations(range(k), r): point = [0.0] * k proportion = 1.0 / r for idx in subset: point[idx] = proportion points.append(point) return np.array(points)