# (c) Kevin Dunn, 2010-2026. MIT License.
"""Optimal designs (D, I, A and E) over a constrained factor region, by candidate exchange.
A ``Constraint`` such as ``"3*T + 5*D <= 600"`` cuts a corner off the factor box.
Classical designs cannot follow that cut, so the design is chosen from a set of
feasible *candidate* points instead, in three steps:
1. **Candidate set.** A grid over the box, plus the points where each constraint
boundary crosses a grid line (found by bisection) and the vertices where linear
constraints meet each other or the box, minus every infeasible point. The boundary
points matter: a D-optimal design pushes its runs to the edge of the region, and
without them the nearest grid point can sit well inside it.
2. **Model matrix.** Each candidate is expanded into the columns of the model the
design is for (intercept, main effects, two-factor interactions, squares).
3. **Exchange.** A Fedorov exchange swaps design runs for candidates while the
criterion improves (see :class:`Criterion`: the determinant of the information
matrix ``X'X`` for D, a weighted trace of its inverse for A and I, its smallest
eigenvalue for E), with several random starts. For D, A and I each run in turn
takes its best swap, with ``(X'X)^-1`` kept current by rank-one updates; when the
grid was coarse or sampled, each start's design is then polished by moving single
coordinates on a 5-level lattice.
Constraint expressions are parsed into a small arithmetic tree and evaluated with
numpy. Nothing is passed to ``eval``, so an expression from an untrusted caller can
only compute a number.
"""
from __future__ import annotations
import ast
import keyword
import logging
import math
from collections.abc import Callable, Iterable, Iterator, Mapping
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any, cast
import numpy as np
import pandas as pd
from scipy.stats import qmc
from process_improve._random import check_random_state
from process_improve.experiments._expression_text import MAX_EXPRESSION_LENGTH, nfkc
from process_improve.experiments._lm import suggest_identifier
from process_improve.experiments._region_geometry import box_vertices
from process_improve.experiments._rules import Rules, compile_rules
from process_improve.experiments._uniform_sampling import (
UniformSampler,
_affine_form,
_compares_numbers_only,
_spread,
)
from process_improve.experiments.factor import FactorType
if TYPE_CHECKING:
from process_improve.experiments.factor import Constraint, Factor
from process_improve.experiments.region import DesignRegion
logger = logging.getLogger(__name__)
#: Model types the candidate exchange builds a model matrix for.
_MODEL_TYPES = ("main_effects", "interactions", "quadratic")
#: Upper limit on the candidate grid; a larger grid is refused before allocation.
MAX_CANDIDATES = 100_000
#: Points taken from a grid of requested levels too large to list (a power of two, for the Sobol sequence): the
#: exchange's cost grows with this. An automatic grid is sampled down to :func:`_candidate_cap` instead.
_SAMPLED_CANDIDATES = 2**15
#: Slack when testing ``g(x) <= 0``, so points found on a boundary are kept.
_FEASIBILITY_TOL = 1e-9
_BISECTION_STEPS = 50
_MAX_EXCHANGES = 500
#: Rank-one updates of ``M^-1`` between full re-factorisations, which bound the drift of the updates.
_REFACTOR_EVERY = 50
#: ``M^-1`` is also rebuilt when the largest candidate variance falls below this fraction of its value at the last
#: rebuild, as it does while a singular design is filled in: the rounding error scales with the old, larger values.
_COLLAPSE = 1e-3
#: Most passes over the design rows the row-wise exchange makes.
_MAX_PASSES = 100
_N_STARTS = 5
#: Most random starts of the D, A and I exchange, and the work (candidates x coefficients x free runs, summed over
#: the starts) that sets how many a problem gets between ``_N_STARTS`` and this (see :func:`_n_starts`).
_MAX_STARTS = 50
_START_WORK = 400_000_000
#: A greedy start step picks at random among candidates whose variance is within this fraction of the largest.
_GREEDY_SLACK = 0.05
# ---------------------------------------------------------------------------
# 1. Constraint expressions -> vectorised inequality functions g(x) <= 0
# ---------------------------------------------------------------------------
_Env = Mapping[str, np.ndarray]
_Evaluator = Callable[[_Env], Any]
_BINARY_OPS: dict[type[ast.operator], Callable[[Any, Any], Any]] = {
ast.Add: np.add,
ast.Sub: np.subtract,
ast.Mult: np.multiply,
ast.Div: np.divide,
ast.Pow: np.power,
}
_UNARY_OPS: dict[type[ast.unaryop], Callable[[Any], Any]] = {ast.USub: np.negative, ast.UAdd: np.positive}
_FUNCTIONS: dict[str, Callable[[Any], Any]] = {
"abs": np.abs,
"sqrt": np.sqrt,
"exp": np.exp,
"log": np.log,
"log10": np.log10,
}
#: For each allowed comparison, whether ``g`` is ``rhs - lhs`` (True) or ``lhs - rhs``.
_FLIP: dict[type[ast.cmpop], bool] = {ast.Lt: False, ast.LtE: False, ast.Gt: True, ast.GtE: True}
def _shown(name: str) -> str:
"""Return ``name`` quoted, with its code points when it is not ASCII, so two names that print alike read apart."""
return repr(name) if name.isascii() else f"{name!r} ({' '.join(f'U+{ord(c):04X}' for c in name)})"
[docs]
def factor_lookup(names: Iterable[str]) -> dict[str, str]:
"""Map each factor name's NFKC form, the name a parsed limit holds, to the name as given.
Raises
------
ValueError
If two names share an NFKC form, so a limit could not tell which factor it names.
"""
lookup: dict[str, str] = {}
for name in sorted(names): # sorted, so a refusal names the two in one order whatever order they came in
other = lookup.setdefault(nfkc(name), name)
if other != name:
raise ValueError(
f"The factors {_shown(other)} and {_shown(name)} read as the same name, {nfkc(name)!r}, in a "
"limit: Python's parser writes every name in its Unicode NFKC form. Rename one of them."
)
return lookup
def _compile(node: ast.expr, names: Mapping[str, str]) -> _Evaluator:
"""Turn one arithmetic node into a function of the factor values.
Only numbers, factor names, ``+ - * / **``, unary signs and the functions in
``_FUNCTIONS`` are accepted; anything else raises ``ValueError``. ``names`` is
:func:`factor_lookup`'s map, from the parsed spelling of a factor's name to its name.
"""
if isinstance(node, ast.Constant) and isinstance(node.value, int | float) and not isinstance(node.value, bool):
number = float(node.value)
return lambda _env: number
if isinstance(node, ast.Name):
name = names.get(node.id)
if name is None:
known = ", ".join(_shown(n) for n in sorted(names.values()))
raise ValueError(f"Unknown name {_shown(node.id)}; constraints may use the continuous factors [{known}].")
return lambda env: env[name]
if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPS:
fn, lhs, rhs = _BINARY_OPS[type(node.op)], _compile(node.left, names), _compile(node.right, names)
return lambda env: fn(lhs(env), rhs(env))
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
unary, operand = _UNARY_OPS[type(node.op)], _compile(node.operand, names)
return lambda env: unary(operand(env))
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id in _FUNCTIONS
and len(node.args) == 1
and not node.keywords
):
func, argument = _FUNCTIONS[node.func.id], _compile(node.args[0], names)
return lambda env: func(argument(env))
raise ValueError(f"Unsupported syntax in constraint: {ast.unparse(node)!r}.")
def _difference(lhs: _Evaluator, rhs: _Evaluator) -> _Evaluator:
"""Return ``g = lhs - rhs``, which is feasible where ``g <= 0``."""
return lambda env: np.asarray(lhs(env), dtype=float) - np.asarray(rhs(env), dtype=float)
[docs]
def parse_constraint(expression: str, names: set[str]) -> list[_Evaluator]:
"""Parse a constraint into inequality functions, each feasible where ``g(x) <= 0``.
Parameters
----------
expression : str
An inequality in actual units, e.g. ``"3*T + 5*D <= 600"``. A chained
comparison such as ``"400 <= 3*T + 5*D <= 600"`` gives two inequalities.
Strict and non-strict comparisons are treated alike, since the boundary
of a continuous region has no volume.
names : set[str]
Names that may appear in the expression (the continuous factors). A name is
matched through its Unicode NFKC form, as Python's parser writes it, so a factor
named with a decomposed accent or a full-width letter can appear in a limit.
Returns
-------
list[Callable]
One function per comparison. Each takes a mapping from factor name to an
array of actual values and returns an array ``g``.
Raises
------
ValueError
For an expression that is too long, is not a comparison, uses ``==`` or
``!=``, or uses a name or syntax outside the allowed set; or for two names
that share an NFKC form.
"""
if len(expression) > MAX_EXPRESSION_LENGTH:
raise ValueError(f"Constraint expression is longer than {MAX_EXPRESSION_LENGTH} characters.")
try:
tree = ast.parse(expression, mode="eval").body
except SyntaxError as err:
raise ValueError(f"Constraint {expression!r} is not a valid expression: {err.msg}.") from err
compare = tree if isinstance(tree, ast.Compare) else None
if compare is None:
raise ValueError(f"Constraint {expression!r} must be an inequality, e.g. 'A + B <= 10'.")
lookup = factor_lookup(names)
terms = [_compile(compare.left, lookup)] + [_compile(c, lookup) for c in compare.comparators]
sides = [compare.left, *compare.comparators]
inequalities = []
for op, lhs, rhs, left, right in zip(compare.ops, terms[:-1], terms[1:], sides[:-1], sides[1:], strict=True):
flip = _FLIP.get(type(op))
if flip is None:
raise ValueError(
f"Constraint {expression!r}: only <, <=, >, >= are supported. An equality leaves "
"a region with no volume to place runs in; for proportions summing to 1, use "
"mixture factors."
)
constant = _cancelled(right, left, names) if flip else _cancelled(left, right, names)
inequalities.append(constant or (_difference(rhs, lhs) if flip else _difference(lhs, rhs)))
return inequalities
def _cancelled(first: ast.expr, second: ast.expr, names: set[str]) -> _Evaluator | None:
"""Return ``g = first - second`` as its exact value when its factors cancel, as in ``0.1*A + 0.2*A <= 0.3*A``.
Evaluated term by term in floating point, that difference is rounding noise in
``A``, which the tests of the region and the design engines would read as a limit
with a gradient. Read in exact arithmetic it compares numbers only. None unless both
sides are affine by their syntax and every factor's coefficient is exactly zero.
"""
from process_improve.experiments.region import _exact_difference # noqa: PLC0415 region imports this module
exact = _exact_difference(first, second, sorted(names))
if exact is None or exact[0].any():
return None
value = -exact[1]
return lambda _env: np.float64(value)
def _factor_names(tree: ast.AST) -> list[str]:
"""Names in ``tree`` that stand for factors (every ``ast.Name`` except a called function), in order of use."""
functions = {id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)}
seen: dict[str, None] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Name) and id(node) not in functions:
seen.setdefault(node.id, None)
return list(seen)
class _Renamer(ast.NodeTransformer):
"""Rename factor names in one pass, leaving called function names alone."""
def __init__(self, names: Mapping[str, str]) -> None:
self.names = names
def visit_Call(self, node: ast.Call) -> ast.Call:
node.args = [self.visit(arg) for arg in node.args]
return node
def visit_Name(self, node: ast.Name) -> ast.Name:
return ast.copy_location(ast.Name(id=self.names.get(node.id, node.id), ctx=node.ctx), node)
[docs]
def rename_factors(expression: str, names: Mapping[str, str]) -> str:
"""Rename factors in a constraint expression, all at once, and return it in canonical form.
Every name is replaced in a single pass over the parsed expression, so a swap such
as ``{"A": "B", "B": "A"}`` works. Function names (``sqrt``, ``log``, ...) are left
alone even when a factor shares them. The result is ``ast.unparse`` of the renamed
expression, so ``rename_factors(expression, {})`` gives the canonical text of an
expression, and renaming back with the inverse mapping gives that canonical text.
Parameters
----------
expression : str
A constraint, e.g. ``"3*T + 5*D <= 600"``, as :func:`parse_constraint` accepts.
names : Mapping[str, str]
Old name to new name. Names the expression does not use are ignored. An old
name is matched through its Unicode NFKC form, as :func:`parse_constraint` does.
Returns
-------
str
The renamed expression, e.g. ``"3 * Temp + 5 * D <= 600"``.
Raises
------
ValueError
If ``expression`` is not a constraint :func:`parse_constraint` accepts (before
or after renaming, which also bounds the length of the result), a new name is
not a valid identifier, or two different factors of the expression would end
up with the same name.
Examples
--------
>>> rename_factors("3*T + 5*D <= 600", {"T": "D", "D": "T"})
'3 * D + 5 * T <= 600'
>>> rename_factors("sqrt(log) <= 2", {"log": "L"})
'sqrt(L) <= 2'
"""
if len(expression) > MAX_EXPRESSION_LENGTH:
raise ValueError(f"Constraint expression is longer than {MAX_EXPRESSION_LENGTH} characters.")
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as err:
raise ValueError(f"Constraint {expression!r} is not a valid expression: {err.msg}.") from err
used = _factor_names(tree)
parse_constraint(expression, set(used))
for new in names.values():
if not (isinstance(new, str) and new.isidentifier() and not keyword.iskeyword(new)):
raise ValueError(
f"{new!r} cannot be a factor name in a constraint: it is not a valid identifier. "
f"Try {suggest_identifier(new)!r}."
)
parsed = {nfkc(old): new for old, new in names.items()} # the tree holds the NFKC spelling of each name
renamed = [parsed.get(name, name) for name in used]
if len({nfkc(name) for name in renamed}) < len(renamed):
merged = sorted(name for name in used if [nfkc(r) for r in renamed].count(nfkc(parsed.get(name, name))) > 1)
raise ValueError(f"Renaming {expression!r} with {dict(names)} would merge the factors {merged} into one.")
text = ast.unparse(_Renamer(parsed).visit(tree))
parse_constraint(text, set(renamed))
return text
# ---------------------------------------------------------------------------
# 2. Candidate set: grid + boundary crossings, filtered to the feasible region
# ---------------------------------------------------------------------------
@dataclass
class _Region:
"""The factor box, split into continuous and categorical parts.
With ``rules`` (constraints that name categorical factors, see
:mod:`~process_improve.experiments._rules`), some combinations of levels are not
allowed, and the inequalities in ``conditional[j]`` apply only on the runs where
``rules.limits[j]`` says they do. ``inequalities`` apply everywhere. ``source`` is
the same region as a :class:`~process_improve.experiments.region.DesignRegion`,
which weights each allowed combination by its volume in :meth:`draw_levels`.
"""
continuous: list[Factor]
categorical: list[Factor]
inequalities: list[_Evaluator]
rules: Rules | None = None
conditional: list[list[_Evaluator]] = field(default_factory=list)
#: Model columns kept when the rules leave some coefficients inestimable; None keeps every column.
kept_columns: np.ndarray | None = None
source: DesignRegion | None = field(default=None, repr=False, compare=False)
_lengths: np.ndarray | None = field(default=None, init=False, repr=False, compare=False)
def _limits(self) -> list[tuple[_Evaluator, int | None]]:
"""Every inequality, with the clause of ``rules.limits`` it applies under (None where it applies everywhere)."""
return [(g, None) for g in self.inequalities] + [
(g, j) for j, group in enumerate(self.conditional) for g in group
]
def labels(self, cats: np.ndarray) -> dict[str, np.ndarray]:
"""Return the level of each run, as text, for each categorical factor the rules name."""
if self.rules is None:
return {}
return {
f.name: np.asarray(self.rules.levels[f.name], dtype=str)[cats[:, j]]
for j, f in enumerate(self.categorical)
if f.name in self.rules.levels
}
def allowed(self, cats: np.ndarray) -> np.ndarray:
"""Return whether each run's combination of levels is allowed."""
return np.ones(len(cats), dtype=bool) if self.rules is None else self.rules.allowed(self.labels(cats))
def applies(self, cats: np.ndarray) -> np.ndarray:
"""Return ``(runs, clauses)``: whether each clause's inequalities apply to each run."""
if self.rules is None:
return np.zeros((len(cats), 0), dtype=bool)
return self.rules.applies(self.labels(cats))
def given(self, applies: np.ndarray) -> _Region:
"""Return the region, without rules, of the runs on which the clauses ``applies`` marks apply."""
chosen = [g for j, group in enumerate(self.conditional) if applies[j] for g in group]
return _Region(self.continuous, [], self.inequalities + chosen)
def draw_levels(self, n: int, rng: np.random.Generator) -> np.ndarray:
"""Draw ``n`` combinations of levels, each allowed combination in proportion to its volume.
The factors the rules name take one of their allowed combinations, each in
proportion to the volume of the region its limits leave (see
:meth:`~process_improve.experiments.region.DesignRegion.sample_frame`), so every
allowed setting is equally likely; every other categorical factor takes each of
its levels with the same probability.
"""
cats = np.zeros((n, len(self.categorical)), dtype=int)
for j, f in enumerate(self.categorical):
cats[:, j] = rng.integers(len(f.levels or []), size=n)
if self.rules is not None:
table, _ = self.rules.allowed_table()
pick = cast("DesignRegion", self.source)._draw_combinations(n, rng)
for j, f in enumerate(self.categorical):
if f.name in self.rules.levels:
position = {level: i for i, level in enumerate(self.rules.levels[f.name])}
cats[:, j] = [position[level] for level in table[f.name].to_numpy()[pick]]
return cats
def actual(self, coded: np.ndarray) -> dict[str, np.ndarray]:
"""Map coded continuous columns to ``{name: actual values}``."""
return {
f.name: f.center + coded[:, j] * (f.high - f.low) / 2.0 # type: ignore[operator]
for j, f in enumerate(self.continuous)
}
def offsets(self) -> np.ndarray:
"""Return ``centre / half-range`` per continuous factor: where its actual values sit next to its coded ones."""
low = np.array([f.low for f in self.continuous], dtype=float)
high = np.array([f.high for f in self.continuous], dtype=float)
return (low + high) / (high - low)
def slack(self, coded: np.ndarray, cats: np.ndarray | None = None) -> np.ndarray:
"""Return the largest ``g`` per row: the point is feasible where this is ``<= 0``.
With rules, ``cats`` (each run's categorical level indices) is needed: a run whose
combination of levels is not allowed has an infinite slack, and an inequality that
does not apply to a run is left out of its maximum.
A straight constraint has its value divided by the length of its gradient in coded
units, which makes it a distance in coded units. So a point within
``_FEASIBILITY_TOL`` of a straight boundary passes, and one further out fails,
however the constraint is scaled and whatever its units, as in
:meth:`~process_improve.experiments.region.DesignRegion.feasible`. A curved one has
its value divided by the largest size it takes over the box. In the constraint's own
units, ``1e-9`` dropped vertices that lie ``1e-12`` (coded) from a steep boundary
such as ``120*T + 33*D + 450*F <= 17000``, and let every setting pass a gentle one,
such as ``X <= 0.5e-9`` on a factor whose range is ``1e-9``.
"""
env = self.actual(coded)
rows = coded.shape[0]
if self.rules is not None and cats is None:
raise ValueError("The constraints name categorical factors, so the slack needs each run's levels.")
applies = self.applies(cats) if cats is not None else np.zeros((rows, 0), dtype=bool)
g = []
for (ineq, clause), size in zip(self._limits(), self._value_scales(), strict=True):
value = np.broadcast_to(ineq(env), (rows,)) / size
g.append(value if clause is None else np.where(applies[:, clause], value, -np.inf))
slack = np.max(g, axis=0) if g else np.full(rows, -np.inf)
return slack if cats is None else np.where(self.allowed(cats), slack, np.inf)
def _value_scales(self) -> np.ndarray:
"""Return per constraint what :meth:`slack` divides its value by (cached).
The length of a straight constraint's gradient in coded units; the largest size a
curved one takes over the box (see :func:`_spread`); and 1 for one that compares
numbers only, as :meth:`~process_improve.experiments.region.DesignRegion.feasible`
widens its tolerance.
"""
if self._lengths is None:
k = len(self.continuous)
ones = np.ones(k)
lengths = []
for g, _clause in self._limits():
coded = _coded_inequality(self, g)
form = _affine_form(coded, -ones, ones, self.offsets()) if k else None
if form is None:
lengths.append(_spread(coded, -ones, ones) if k else 1.0)
else:
lengths.append(1.0 if _compares_numbers_only(*form) else math.hypot(*form[0]))
self._lengths = np.array(lengths, dtype=float)
return self._lengths
#: The automatic grid holds at most ``max(_MIN_CANDIDATE_CAP, _CANDIDATES_PER_COEFFICIENT * p)`` candidates for a
#: model with ``p`` coefficients (and never more than ``MAX_CANDIDATES``); a larger grid is sampled down to that.
_MIN_CANDIDATE_CAP = 5000
_CANDIDATES_PER_COEFFICIENT = 200
#: Criteria on the prediction variance over the region, which place runs inside it as well as on its edges.
_VARIANCE_CRITERIA = ("i_optimal", "g_optimal")
def _candidate_cap(region: _Region, model_type: str) -> int:
"""Most candidates the automatic grid may hold, which grows with the number of model coefficients."""
n_parameters = model_matrix(
region, np.zeros((1, len(region.continuous))), np.zeros((1, len(region.categorical)), dtype=int), model_type
).shape[1]
return min(MAX_CANDIDATES, max(_MIN_CANDIDATE_CAP, _CANDIDATES_PER_COEFFICIENT * n_parameters))
def _level_choices(model_type: str, criterion: str) -> list[int]:
"""Grid resolutions to try, finest first.
A quadratic model needs 3 levels; for criteria that push runs to the edges of the
region (D, A, E, K) an even level count adds no centre and only displaces the
middle level, so 4 is skipped. Models without squared terms may use 2 levels.
"""
if model_type != "quadratic":
return [5, 4, 3, 2]
return [5, 4, 3] if criterion in _VARIANCE_CRITERIA else [5, 3]
def _grid_levels(
region: _Region, n_levels: int | None, model_type: str, criterion: str = "d_optimal"
) -> tuple[int, bool]:
"""Pick the continuous grid resolution, and whether the grid is too large to list in full.
With ``n_levels`` given, that grid is used, and sampled when it has more than
``MAX_CANDIDATES`` points. Otherwise the finest of :func:`_level_choices` whose grid
stays within :func:`_candidate_cap` is used, and the coarsest is sampled when none
does (see :func:`_lattice_points`). The cap grows with the model, so the exchange's
cost does too, and not with the number of grid levels: 5 levels on 7 factors are
78,125 points, and 3 levels lose nothing for a D-optimal quadratic design on a box.
"""
n_cat = int(np.prod([len(f.levels or []) for f in region.categorical]))
if n_levels is not None:
if n_levels < 2:
raise ValueError("n_levels must be at least 2.")
return n_levels, n_levels ** len(region.continuous) * n_cat > MAX_CANDIDATES
cap = _candidate_cap(region, model_type)
choices = _level_choices(model_type, criterion)
for n in choices:
if n ** len(region.continuous) * n_cat <= cap:
return n, False
return choices[-1], True
def _lattice_points(shape: list[int], k_cont: int, n_sampled: int | None, edges: bool = False) -> np.ndarray:
"""Index vectors of the candidate grid: all of it (``n_sampled`` None), or ``n_sampled`` well-spread points of it.
The sample is the unscrambled Sobol sequence rounded down onto the grid: evenly
spread over the grid, and deterministic without drawing on any random generator.
With ``edges`` (for a 3-level grid), a continuous coordinate takes the middle level
with probability ``1 / k_cont`` rather than 1/3, so a typical point has one factor at
its middle level and the rest at the extremes: those are the points a D-optimal
quadratic design is built from, and a uniform sample of a large grid holds few of
them (2% of the points of the 10-factor grid have no factor at its middle level).
A sampled grid on an odd number of levels also holds the centre and the face
centres (one factor at an extreme, the rest at the middle), the points a quadratic
model leans on and a random sample would rarely contain.
"""
if n_sampled is None:
return np.indices(shape).reshape(len(shape), -1).T
unit = qmc.Sobol(d=len(shape), scramble=False).random_base2(math.ceil(math.log2(n_sampled)))[:n_sampled]
idx = np.minimum((unit * shape).astype(int), np.array(shape) - 1)
if edges and k_cont:
p_middle = min(1.0 / 3.0, 1.0 / k_cont)
cont = unit[:, :k_cont]
idx[:, :k_cont] = np.where(cont < (1.0 - p_middle) / 2, 0, np.where(cont < (1.0 + p_middle) / 2, 1, 2))
levels = shape[0] if k_cont else 0
if levels % 2:
middle = np.full((2 * k_cont + 1, len(shape)), levels // 2)
middle[:, k_cont:] = 0
for axis in range(k_cont):
middle[1 + 2 * axis, axis], middle[2 + 2 * axis, axis] = 0, levels - 1
idx = np.vstack([middle, idx])
_, first = np.unique(idx, axis=0, return_index=True)
return idx[np.sort(first)]
def _boundary_points(region: _Region, grid: np.ndarray, cat_idx: np.ndarray, step: float) -> tuple:
"""Find where each constraint boundary crosses a grid edge, by vectorised bisection.
For every edge between neighbouring grid points along one axis where one end is
feasible for a constraint and the other is not, the crossing point is located on
that edge. The feasible side of the final bracket is returned, so the point
satisfies the constraint it came from; other constraints are checked afterwards.
A limit that applies on some levels only is crossed only on those.
"""
points, cats = [np.empty((0, grid.shape[1]))], [np.empty((0, cat_idx.shape[1]), dtype=int)]
for axis in range(grid.shape[1]):
lower = grid[:, axis] < 1.0 - step / 2
start, cat = grid[lower], cat_idx[lower]
applies = region.applies(cat)
direction = np.zeros(grid.shape[1])
direction[axis] = step
for ineq, clause in region._limits():
g_lo = ineq(region.actual(start)) <= 0
g_hi = ineq(region.actual(start + direction)) <= 0
cross = np.broadcast_to(g_lo != g_hi, (start.shape[0],))
if clause is not None:
cross = cross & applies[:, clause]
if not cross.any():
continue
base, lo_feasible = start[cross], np.broadcast_to(g_lo, cross.shape)[cross]
t_feas, t_infeas = np.where(lo_feasible, 0.0, 1.0), np.where(lo_feasible, 1.0, 0.0)
for _ in range(_BISECTION_STEPS):
t_mid = (t_feas + t_infeas) / 2
ok = ineq(region.actual(base + t_mid[:, None] * direction)) <= 0
t_feas, t_infeas = np.where(ok, t_mid, t_feas), np.where(ok, t_infeas, t_mid)
points.append(base + t_feas[:, None] * direction)
cats.append(cat[cross])
return np.vstack(points), np.vstack(cats)
#: Most vertices of a region the candidate set takes; a region with more gives none. Listing them is quick up to
#: here (0.2 s in 10 factors with 8 limits), while the exchange's cost grows with every candidate.
_MAX_VERTEX_CANDIDATES = 2**13
def _coded_inequality(region: _Region, g: _Evaluator) -> Callable[[np.ndarray], np.ndarray]:
"""``g`` as a function of coded points ``(n, k)``."""
return lambda x: np.broadcast_to(np.asarray(g(region.actual(np.atleast_2d(x))), dtype=float), (len(x),))
def _polytope_vertices(region: _Region) -> np.ndarray:
"""Vertices of the box cut by the region's linear constraints, in coded units.
A vertex is a point where ``k`` of the hyperplanes (box faces and linear constraint
boundaries) meet. These are where D-optimal runs go, and two constraints usually
meet away from every grid line, where neither the grid nor the boundary crossings
reach (Atkinson, Donev and Tobias 2007, ch. 12). They are listed by cutting the box
with one constraint at a time
(:func:`~process_improve.experiments._region_geometry.box_vertices`, which
:class:`~process_improve.experiments.region.DesignRegion` uses too), so the work
grows with the number of vertices rather than with the ways of choosing ``k``
boundaries. Non-linear constraints only filter the result. Returns no points when
there is no linear constraint, or when the region has more than
``_MAX_VERTEX_CANDIDATES`` vertices.
"""
k = len(region.continuous)
ones = np.ones(k)
forms = [_affine_form(_coded_inequality(region, g), -ones, ones, region.offsets()) for g in region.inequalities]
rows = [f for f in forms if f is not None]
if not rows or k == 0:
return np.empty((0, k))
a_rows, b_rows = [], []
for a, b in rows:
# Over the box |a @ x| <= sum |a|, so a row with less than |b| there (a limit on numbers only, whose
# fit leaves a gradient of rounding noise) holds everywhere or nowhere, by the sign of b.
if not a.any() or np.abs(a).sum() < abs(b):
if b < 0: # no point meets it
a_rows.append(np.zeros(k))
b_rows.append(-1.0)
continue
size = math.hypot(*a)
a_rows.append(a / size)
b_rows.append(b / size)
try:
return box_vertices(np.array(a_rows).reshape(len(a_rows), k), np.array(b_rows), _MAX_VERTEX_CANDIDATES)
except ValueError: # more vertices than the candidate set takes
return np.empty((0, k))
def _vertex_candidates(region: _Region, cat_shape: list[int]) -> tuple[np.ndarray, np.ndarray]:
"""Return the vertices of the region at every allowed combination of levels.
Without limits that apply on some levels only, the vertices are the same at every
combination. With them, the combinations are grouped by which limits apply, and each
group's vertices are found once: ``m`` such limits give at most ``2**m`` groups.
"""
if not region.conditional:
return _with_every_level(_polytope_vertices(region), cat_shape)
combos = np.indices(cat_shape).reshape(len(cat_shape), -1).T
k = len(region.continuous)
points, cats = [np.empty((0, k))], [np.empty((0, len(cat_shape)), dtype=int)]
if len(combos) > MAX_CANDIDATES:
return points[0], cats[0]
combos = combos[region.allowed(combos)]
applies = region.applies(combos)
for signature in np.unique(applies, axis=0):
members = combos[np.all(applies == signature, axis=1)]
vertices = _polytope_vertices(region.given(signature))
if len(vertices) * len(members) <= MAX_CANDIDATES:
points.append(np.repeat(vertices, len(members), axis=0))
cats.append(np.tile(members, (len(vertices), 1)))
return np.vstack(points), np.vstack(cats)
def _with_every_level(points: np.ndarray, cat_shape: list[int]) -> tuple[np.ndarray, np.ndarray]:
"""Pair every point with every combination of categorical levels, unless that is more than ``MAX_CANDIDATES``."""
combos = np.indices(cat_shape).reshape(len(cat_shape), -1).T if cat_shape else np.empty((1, 0), dtype=int)
if len(points) * len(combos) > MAX_CANDIDATES:
return np.empty((0, points.shape[1])), np.empty((0, len(cat_shape)), dtype=int)
return np.repeat(points, len(combos), axis=0), np.tile(combos, (len(points), 1))
[docs]
def build_candidates(
region: _Region, n_levels: int | None = None, model_type: str = "quadratic", criterion: str = "d_optimal"
) -> tuple[np.ndarray, np.ndarray, dict]:
"""Return the feasible candidate points in coded units.
The candidates are the grid (see :func:`_grid_levels`), the points where each
constraint boundary crosses a grid edge (:func:`_boundary_points`), and the
vertices of the region cut by its linear constraints (:func:`_polytope_vertices`),
each with every categorical level combination.
Parameters
----------
region : _Region
Factors and constraints.
n_levels : int or None
Grid levels per continuous factor; ``None`` picks them (see :func:`_grid_levels`).
model_type : str
The model the design is for: without squared terms, 2 levels may be used.
criterion : str
The optimality criterion the design is for: I- and G-optimality may use 4 levels
on a quadratic model, the others go from 5 levels to 3 (see :func:`_level_choices`).
Returns
-------
tuple[np.ndarray, np.ndarray, dict]
Coded continuous values ``(n, k_cont)``, categorical level indices
``(n, k_cat)``, and counts for the metadata.
"""
levels, sampled = _grid_levels(region, n_levels, model_type, criterion)
shape = [levels] * len(region.continuous) + [len(f.levels or []) for f in region.categorical]
k_cont = len(region.continuous)
n_sampled = None
if sampled:
n_sampled = _SAMPLED_CANDIDATES if n_levels is not None else _candidate_cap(region, model_type)
edges = n_levels is None and levels == 3 and model_type == "quadratic" and criterion not in _VARIANCE_CRITERIA
idx = _lattice_points(shape, k_cont, n_sampled, edges)
if sampled:
logger.info(
"The %d-level grid on %d factors is too large to list; sampling %d points.", levels, k_cont, len(idx)
)
grid = np.linspace(-1.0, 1.0, levels)[idx[:, :k_cont]]
cat_idx = idx[:, k_cont:]
extra, extra_cat = _boundary_points(region, grid, cat_idx, step=2.0 / (levels - 1))
corners, corner_cat = _vertex_candidates(region, shape[k_cont:])
coded = np.vstack([grid, extra, corners])
cats = np.vstack([cat_idx, extra_cat, corner_cat])
feasible = region.slack(coded, cats) <= _FEASIBILITY_TOL
coded, cats = coded[feasible], cats[feasible]
# Drop duplicates (a boundary point can coincide with a grid point).
_, unique = np.unique(np.hstack([coded.round(9), cats]), axis=0, return_index=True)
unique.sort()
counts = {
"n_levels": levels,
"n_grid_points": grid.shape[0],
"grid_sampled": sampled,
"n_boundary_points": extra.shape[0],
"n_vertex_points": corners.shape[0],
"n_candidates": unique.size,
}
return coded[unique], cats[unique], counts
# ---------------------------------------------------------------------------
# 3. Model matrix and Fedorov exchange
# ---------------------------------------------------------------------------
[docs]
def model_matrix(region: _Region, coded: np.ndarray, cats: np.ndarray, model_type: str) -> np.ndarray:
"""Expand points into model columns: intercept, main effects, interactions, squares.
A categorical factor with ``L`` levels contributes ``L - 1`` effect-coded (sum-to-zero)
columns, the coding ``evaluate_design`` uses (see
:func:`~process_improve.experiments.designs_utils.categorical_effect_columns`): a
two-level factor is -1 and +1, and with more levels the last one, in sorted order, is
-1 in every column. The levels are taken in sorted order, as patsy reads a label
column, so the criterion value reported here is the one ``evaluate_design`` gives
for the returned design.
The D-, I- and G-criteria do not depend on which full-rank coding is used, so they
select the same designs under any coding. The A-, E- and K-criteria do depend on
it, and here optimise the effect-coded model, the usual convention in DoE software.
"""
from process_improve.experiments.designs_utils import ( # noqa: PLC0415
categorical_effect_columns,
sorted_level_ranks,
)
blocks = [coded[:, [j]] for j in range(coded.shape[1])]
for j, f in enumerate(region.categorical):
levels = list(f.levels or [])
blocks.append(categorical_effect_columns(sorted_level_ranks(levels)[cats[:, j]], len(levels)))
columns = [np.ones((coded.shape[0], 1)), *blocks]
if model_type in {"interactions", "quadratic"}:
columns.extend(
np.einsum("ni,nj->nij", blocks[i], blocks[j]).reshape(coded.shape[0], -1)
for i in range(len(blocks))
for j in range(i + 1, len(blocks))
)
if model_type == "quadratic":
columns.append(coded**2)
matrix = np.hstack(columns)
return matrix if region.kept_columns is None else matrix[:, region.kept_columns]
#: Candidate rows handled at a time when a quadratic form is taken over the whole candidate set.
_CHUNK_ROWS = 8192
def _quadratic_forms(rows: np.ndarray, matrix: np.ndarray) -> np.ndarray:
"""``r' matrix r`` for every row ``r`` of ``rows``, a block of rows at a time to bound the temporaries."""
out = np.empty(len(rows))
for start in range(0, len(rows), _CHUNK_ROWS):
block = rows[start : start + _CHUNK_ROWS]
out[start : start + _CHUNK_ROWS] = np.einsum("ij,ij->i", block @ matrix, block)
return out
def _greedy_start(f_cand: np.ndarray, f_fixed: np.ndarray, n_free: int, rng: np.random.Generator) -> np.ndarray:
"""Build a non-singular starting design by adding a high-variance candidate each step.
Each step picks at random among the candidates whose prediction variance is within
``_GREEDY_SLACK`` of the largest. On a grid many candidates tie for the largest
variance; always taking the first of them gave nearly the same start every time,
so the restarts of the exchange explored almost nothing (one or two distinct local
optima from five starts). Adding a run is a rank-one update of ``M^-1`` and of the
variance vector, so a step costs one product of the candidate matrix with a vector.
Both are rebuilt from ``M`` every ``_REFACTOR_EVERY`` steps, and whenever the largest
variance has fallen by ``_COLLAPSE`` since the last rebuild: the ridge makes the first
variances about ``1e6`` times the final ones, and the rounding error they leave behind
would otherwise swamp the final values.
"""
p = f_cand.shape[1]
info = f_fixed.T @ f_fixed + 1e-6 * np.eye(p) # small ridge so the first steps are defined
rows = [int(rng.integers(f_cand.shape[0]))]
info += np.outer(f_cand[rows[0]], f_cand[rows[0]])
m_inv = np.linalg.inv(info)
variance = _quadratic_forms(f_cand, m_inv)
refactored, peak = 0, float(variance.max())
for step in range(1, n_free):
near = np.flatnonzero(variance >= (1.0 - _GREEDY_SLACK) * variance.max())
best = int(rng.choice(near))
rows.append(best)
added = f_cand[best]
info += np.outer(added, added)
if step == n_free - 1:
break
m_inv_added = m_inv @ added
scale = 1.0 / (1.0 + float(added @ m_inv_added))
variance -= scale * (f_cand @ m_inv_added) ** 2
m_inv -= scale * np.outer(m_inv_added, m_inv_added)
if step - refactored >= _REFACTOR_EVERY or variance.max() < _COLLAPSE * peak:
m_inv = np.linalg.inv(info)
variance = _quadratic_forms(f_cand, m_inv)
refactored, peak = step, float(variance.max())
return np.array(rows)
[docs]
@dataclass(frozen=True)
class Criterion:
"""The optimality criterion an exchange maximises.
``"d_optimal"`` maximises ``log det(M)``, with ``M = X'X``. ``"a_optimal"`` and
``"i_optimal"`` minimise ``trace(M^-1 W)``: with ``W = I`` that is the summed
variance of the coefficients (A), and with ``W`` the average of ``f(x) f(x)'``
over the region it is the average prediction variance over the region (I), which
is what ``evaluate_design`` reports as I-efficiency. ``"e_optimal"`` maximises the
smallest eigenvalue of ``M``, which bounds the variance of the worst-estimated
linear combination of the coefficients.
Use :meth:`d`, :meth:`a`, :meth:`i` or :meth:`e` to build one.
"""
name: str
weights: np.ndarray | None = None
region_rows: np.ndarray | None = None
segment: np.ndarray | None = None
[docs]
@classmethod
def d(cls) -> Criterion:
"""D-optimality."""
return cls("d_optimal")
[docs]
@classmethod
def a(cls, n_parameters: int) -> Criterion:
"""A-optimality for a model with ``n_parameters`` coefficients."""
return cls("a_optimal", np.eye(n_parameters))
[docs]
@classmethod
def i(cls, region_rows: np.ndarray) -> Criterion:
"""I-optimality, with the moment matrix estimated from model rows sampled uniformly in the region."""
return cls("i_optimal", region_rows.T @ region_rows / len(region_rows))
[docs]
@classmethod
def e(cls) -> Criterion:
"""E-optimality."""
return cls("e_optimal")
[docs]
@classmethod
def g(cls, region_rows: np.ndarray) -> Criterion:
"""G-optimality: the largest prediction variance over the model rows ``region_rows``."""
return cls("g_optimal", region_rows=region_rows)
[docs]
@classmethod
def k(cls) -> Criterion:
"""K-optimality: the condition number of ``X'X`` (Ye and Zhou 2013)."""
return cls("k_optimal")
[docs]
def blocked(self, n_block_columns: int, segment: np.ndarray) -> Criterion:
"""Return this criterion for a design run in blocks: the factor effects, adjusted for the blocks.
The model rows gain ``n_block_columns`` block columns, and ``segment`` gives the
block of each candidate row; a run is only ever swapped for a candidate of its
own block, so the block sizes stay as they started. With them fixed,
``det(M) = det(Z'Z) det(S)``, where ``S`` is the factor information adjusted for
the blocks (the Schur complement of the block part ``Z'Z`` of ``M``), so D
maximises ``det(S)``. The trace criteria give the block columns no weight, and
``trace(M^-1 W) = trace(S^-1 W)``. Only D, A and I are blocked this way.
"""
weights = None if self.weights is None else np.pad(self.weights, (0, n_block_columns))
return replace(self, weights=weights, segment=segment)
[docs]
def value(self, info: np.ndarray) -> float:
"""Score an information matrix; higher is better.
``log det(M)`` (D), ``-trace(M^-1 W)`` (A, I), ``lambda_min`` (E), minus the largest
prediction variance over the region (G), and ``-log(lambda_max / lambda_min)`` (K).
"""
if self.name in ("e_optimal", "k_optimal", "g_optimal"):
return _spectral_or_minimax_value(self, info)
if self.weights is None:
sign, logdet = np.linalg.slogdet(info)
return float(logdet) if sign > 0 else -np.inf
if np.linalg.matrix_rank(info) < info.shape[0]:
return -np.inf
return -float(np.trace(np.linalg.solve(info, self.weights)))
@property
def n_phases(self) -> int:
"""Exchange phases: E-optimality climbs ``phi_p`` first, then polishes ``lambda_min``."""
return 2 if self.name == "e_optimal" else 1
[docs]
def best_swap(
self, info: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray, phase: int = 0
) -> tuple[int, int, float]:
"""Return ``(i, j, gain)`` for the best single swap: design row ``i`` out, candidate ``j`` in."""
if self.name == "e_optimal":
return _best_e_swap(info, f_design, f_cand, polish=phase > 0)
if self.name == "k_optimal":
return _best_k_swap(info, f_design, f_cand)
gains = self.swap_gains(np.linalg.pinv(info), f_design, f_cand)
i, j = np.unravel_index(np.argmax(gains), gains.shape)
return int(i), int(j), float(gains[i, j])
[docs]
def swap_gains(self, m_inv: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray) -> np.ndarray:
"""Return the gain of every swap (design run ``i`` out, candidate ``j`` in), shape ``(n_design, n_cand)``.
With ``A = M^-1`` and ``d(a, b) = f(a)' A f(b)``, D-optimality multiplies the
determinant by ``1 + d(j) - d(i) - [d(i) d(j) - d(i, j)**2]`` (Fedorov 1972).
For the trace criteria, two Sherman-Morrison updates (add ``j``, then remove
``i``) with ``b(a, b) = f(a)' A W A f(b)`` give the new trace in closed form::
s = 1 + d(j)
trace_new = trace - b(j)/s + [b(i) - 2 d(i,j) b(i,j)/s + d(i,j)**2 b(j)/s**2] / [1 - d(i) + d(i,j)**2/s]
so both kinds are scored for all pairs in a few matrix products.
"""
a_design, a_cand = f_design @ m_inv, f_cand @ m_inv
d_i = np.einsum("ij,ij->i", a_design, f_design)[:, None]
d_j = np.einsum("ij,ij->i", a_cand, f_cand)[None, :]
d_ij = a_design @ f_cand.T
if self.weights is None:
return _d_gain(d_i, d_j, d_ij)
b_design, b_cand = a_design @ self.weights, a_cand @ self.weights
b_i = np.einsum("ij,ij->i", b_design, a_design)[:, None]
b_j = np.einsum("ij,ij->i", b_cand, a_cand)[None, :]
b_ij = b_design @ a_cand.T
return _trace_gain((d_i, d_j, d_ij), (b_i, b_j, b_ij))
def _d_gain(d_i: float | np.ndarray, d_j: np.ndarray, d_ij: np.ndarray) -> np.ndarray:
"""Return the relative change in ``det(M)`` from swapping run ``i`` for candidate ``j``."""
return d_j - d_i - (d_i * d_j - d_ij**2)
def _trace_gain(d: tuple, b: tuple) -> np.ndarray:
"""Return the fall in ``trace(M^-1 W)`` from swapping run ``i`` for candidate ``j`` (:meth:`Criterion.swap_gains`).
``d`` holds ``(d(i), d(j), d(i, j))`` and ``b`` holds ``(b(i), b(j), b(i, j))``, as
scalars or arrays that broadcast against each other.
"""
(d_i, d_j, d_ij), (b_i, b_j, b_ij) = d, b
s = 1.0 + d_j
denominator = 1.0 - d_i + d_ij**2 / s
numerator = b_i - 2.0 * d_ij * b_ij / s + d_ij**2 * b_j / s**2
with np.errstate(divide="ignore", invalid="ignore"):
gain = b_j / s - numerator / denominator
return np.where(denominator > 1e-10, gain, -np.inf)
#: Criteria whose swap gains have a closed form (:meth:`Criterion.swap_gains`), climbed by :func:`_row_exchange`.
_CLOSED_FORM_CRITERIA = ("d_optimal", "a_optimal", "i_optimal")
def _regularised_inverse(info: np.ndarray) -> np.ndarray:
"""``M^-1``, with a small ridge when ``M`` is singular so swaps that fill the missing directions score highest."""
eigenvalues = np.linalg.eigvalsh(info)
largest = max(float(eigenvalues[-1]), 1.0)
ridge = 0.0 if eigenvalues[0] > 1e-10 * largest else 1e-8 * largest
return np.linalg.inv(info + ridge * np.eye(len(info)))
#: Entries of the (design rows x candidates) block of swap-gain terms the row-wise exchange keeps.
_BLOCK_ENTRIES = 2**20
#: Most design rows in such a block: each swap updates the terms of the rows after it.
_BLOCK_ROWS = 32
@dataclass
class _RowTerms:
"""Swap-gain terms of a block of design rows: ``d(i)``, ``d(i, j)``, and ``b(i)``, ``b(i, j)`` for a trace."""
d_i: np.ndarray
d_ij: np.ndarray
b_i: np.ndarray | None = None
b_ij: np.ndarray | None = None
def follow(self, x: np.ndarray, step: tuple, start: int) -> None:
"""Bring rows ``start:`` (model rows ``x``) up to date after one rank-one update ``step`` of ``A`` and ``B``.
With ``A <- A - s u u'``, ``d(i, j)`` falls by ``s (u'x)(F u)`` and ``d(i)`` by
``s (u'x)^2``; ``B`` changes by two cross terms and a square term, and so do
``b(i, j)`` and ``b(i)``. Each costs a few passes over the block, where a fresh
score would multiply the block by the whole candidate matrix again.
"""
u, g, s, vbv, f_u, f_g = step
ux = x @ u
self.d_i[start:] -= s * ux**2
self.d_ij[start:] -= s * np.multiply.outer(ux, f_u)
if self.b_i is not None and self.b_ij is not None:
gx = x @ g
self.b_i[start:] += s * (s * vbv * ux**2 - 2.0 * ux * gx)
self.b_ij[start:] += s * (
np.multiply.outer(s * vbv * ux - gx, f_u) - np.multiply.outer(ux, f_g) # type: ignore[arg-type]
)
class _ExchangeState:
"""``M^-1`` of a design and the candidates' variance terms, kept current through each swap.
For D-optimality the state is ``A = M^-1`` and ``d(j) = f(j)' A f(j)`` for every
candidate; the trace criteria also keep ``B = A W A`` and ``b(j) = f(j)' B f(j)``.
A swap is two rank-one (Sherman-Morrison) updates, adding the candidate and then
removing the run. Each costs one product of the candidate matrix with a vector (two
for a trace) instead of a new inverse and a new pass over every (run, candidate)
pair; the removal reuses the products the swap was scored with. The state is rebuilt
from ``X'X`` every ``_REFACTOR_EVERY`` swaps, and when the largest variance collapses
(see ``_COLLAPSE``), to bound rounding drift.
"""
def __init__(self, f_cand: np.ndarray, f_fixed: np.ndarray, rows: np.ndarray, criterion: Criterion) -> None:
self.f_cand, self.f_fixed, self.rows = f_cand, f_fixed, rows.copy()
self.weights, self.segment = criterion.weights, criterion.segment
self.refactor()
def refactor(self) -> None:
"""Rebuild ``A``, ``d`` (and ``B``, ``b``) from the design's information matrix."""
x = np.vstack([self.f_fixed, self.f_cand[self.rows]])
self.m_inv = _regularised_inverse(x.T @ x)
self.variance = _quadratic_forms(self.f_cand, self.m_inv)
if self.weights is not None:
self.b_mat = self.m_inv @ self.weights @ self.m_inv
self.b_variance = _quadratic_forms(self.f_cand, self.b_mat)
self.n_updates, self.peak = 0, float(self.variance.max())
def terms(self, block: np.ndarray) -> _RowTerms:
"""Return the swap-gain terms of design rows ``block`` against every candidate, one matrix product each."""
x = self.f_cand[self.rows[block]]
x_a = x @ self.m_inv
terms = _RowTerms(np.einsum("ij,ij->i", x_a, x), x_a @ self.f_cand.T)
if self.weights is not None:
x_b = x @ self.b_mat
terms.b_i, terms.b_ij = np.einsum("ij,ij->i", x_b, x), x_b @ self.f_cand.T
return terms
def best_swaps(self, terms: _RowTerms, rows: slice, design_rows: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return the best candidate for block ``rows`` and the gain of each swap (:meth:`Criterion.swap_gains`).
``design_rows`` are those rows' positions in the design. For D the gain
``d(j) (1 - d(i)) + d(i, j)^2 - d(i)`` is ranked without its last term, in one
temporary, where the general form takes several. In a blocked design a run only
competes with the candidates of its own block.
"""
d_i = terms.d_i[rows]
index = np.arange(len(d_i))
if self.weights is None:
score = np.square(terms.d_ij[rows])
score += np.multiply.outer(1.0 - d_i, self.variance)
self._keep_in_block(score, design_rows)
best = np.argmax(score, axis=1)
return best, score[index, best] - d_i
gains = _trace_gain(
(d_i[:, None], self.variance[None, :], terms.d_ij[rows]),
(terms.b_i[rows, None], self.b_variance[None, :], terms.b_ij[rows]), # type: ignore[index]
)
self._keep_in_block(gains, design_rows)
best = np.argmax(gains, axis=1)
return best, gains[index, best]
def _keep_in_block(self, scores: np.ndarray, design_rows: np.ndarray) -> None:
"""Rule out, in place, every swap of a run for a candidate of another block (when the design is blocked)."""
if self.segment is not None:
own_block = self.segment[self.rows[design_rows]]
scores[own_block[:, None] != self.segment[None, :]] = -np.inf
def _rank_one(
self, v: np.ndarray, sign: float, f_u: np.ndarray | None = None, f_g: np.ndarray | None = None
) -> tuple:
"""Add (``sign = 1``) or remove (``sign = -1``) the model row ``v``: ``A <- A - s (A v)(A v)'``.
``f_u = F A v`` and ``f_g = F B v`` are computed unless given. Returns the step
``(A v, B v, s, v' B v, f_u, f_g)``, from which the products of another row with
the updated ``A`` and ``B`` follow without touching ``F`` again (:meth:`_RowTerms.follow`).
"""
u = self.m_inv @ v
s = sign / (1.0 + sign * float(v @ u))
f_u = self.f_cand @ u if f_u is None else f_u
g, vbv = None, 0.0
if self.weights is not None: # B = A W A takes two cross terms and one square term
g = self.b_mat @ v
vbv = float(v @ g)
f_g = self.f_cand @ g if f_g is None else f_g
self.b_variance += s * (s * vbv * f_u**2 - 2.0 * f_u * f_g)
cross = np.outer(u, g)
self.b_mat += s * (s * vbv * np.outer(u, u) - cross - cross.T)
self.variance -= s * f_u**2
self.m_inv -= s * np.outer(u, u)
return u, g, s, vbv, f_u, f_g
def swap(self, i: int, j: int, terms: _RowTerms, r: int) -> list[tuple] | None:
"""Replace design row ``i`` by candidate ``j``; row ``r`` of ``terms`` holds its scored terms.
Returns the two rank-one steps made, or None when the state was rebuilt instead
(and other rows' terms must be scored afresh).
"""
removed = self.f_cand[self.rows[i]]
self.rows[i] = j
self.n_updates += 1
if self.n_updates >= _REFACTOR_EVERY:
self.refactor()
return None
added = self._rank_one(self.f_cand[j], 1.0)
u, g, s, vbv, f_u, f_g = added
# The removed row's products with the updated A and B, from the ones it was scored with.
u_a = float(u @ removed)
f_a, f_ba = terms.d_ij[r] - s * u_a * f_u, None
if terms.b_ij is not None and g is not None:
f_ba = terms.b_ij[r] - s * (f_u * float(g @ removed) + f_g * u_a) + s * s * vbv * u_a * f_u
steps = [added, self._rank_one(removed, -1.0, f_a, f_ba)]
if self.variance.max() < _COLLAPSE * self.peak:
self.refactor()
return None
return steps
def _climb_block(state: _ExchangeState, block: np.ndarray) -> bool:
"""Swap each row of ``block`` in turn for its best candidate, when that helps; return whether any was swapped.
The block's terms come from one matrix product. A swap changes ``A``, and the terms
of the rows after it follow by rank-one updates (:meth:`_RowTerms.follow`), so every
row is scored against the current design without multiplying by the candidates
again. Rows are scored a few at a time, one after a swap and twice as many after each
group with none, so frequent swaps do not rescore rows that a later swap changes.
"""
terms, r, size, swapped = state.terms(block), 0, 1, False
while r < len(block):
best, gain = state.best_swaps(terms, slice(r, r + size), block[r : r + size])
better = np.flatnonzero(gain > 1e-9) # also refuses NaN
if not len(better):
r, size = r + size, 2 * size
continue
r += int(better[0])
steps = state.swap(int(block[r]), int(best[better[0]]), terms, r)
r, size, swapped = r + 1, 1, True
if r == len(block):
break
if steps is None: # the state was rebuilt: score the rest afresh
terms, block, r = state.terms(block[r:]), block[r:], 0
continue
rest = state.f_cand[state.rows[block[r:]]]
for step in steps:
terms.follow(rest, step, r)
return swapped
def _row_exchange(f_cand: np.ndarray, f_fixed: np.ndarray, rows: np.ndarray, criterion: Criterion) -> np.ndarray:
"""Swap each design row for its best candidate in turn: the modified Fedorov exchange (Cook and Nachtsheim 1980).
Each row is scored against every candidate and its best improving swap is made at
once, where the full Fedorov step scored all ``n x N`` (run, candidate) pairs, and
rebuilt ``X'X`` and its inverse, to make one swap. Passes repeat until none improves
the criterion. Rows are scored in blocks of up to ``_BLOCK_ROWS`` rows and
``_BLOCK_ENTRIES`` terms (:func:`_climb_block`).
"""
state = _ExchangeState(f_cand, f_fixed, rows, criterion)
size = int(np.clip(_BLOCK_ENTRIES // max(len(f_cand), 1), 1, _BLOCK_ROWS))
for _ in range(_MAX_PASSES):
improved = False
for start in range(0, len(rows), size):
improved |= _climb_block(state, np.arange(start, min(len(rows), start + size)))
if not improved:
break
return state.rows
#: Exponent of Kiefer's ``phi_p``, the smooth stand-in for ``lambda_min`` that ranks E-optimal swaps.
_E_P = 8
#: Work allowed per E-optimal iteration for exact scoring, in (swaps x coefficients^2): all swaps when they fit.
_E_EXACT_WORK = 4_000_000
def _phi_p(eigenvalues: np.ndarray) -> np.ndarray:
"""Kiefer's ``phi_p = (sum lambda_k^-p)^(-1/p)`` along the last axis of ascending eigenvalues.
It sits just below ``lambda_min`` and rises whenever any small eigenvalue rises,
so it separates designs that tie on ``lambda_min``. Written through the ratios
``lambda_min / lambda_k <= 1`` so the powers cannot overflow.
"""
floor = 1e-12 * max(float(np.max(eigenvalues)), 1.0)
lam = np.maximum(eigenvalues, floor)
ratio = lam[..., :1] / lam
return lam[..., 0] * np.sum(ratio**_E_P, axis=-1) ** (-1.0 / _E_P)
def _exact_e_scores(
info: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray, pairs: tuple[np.ndarray, np.ndarray]
) -> tuple[np.ndarray, np.ndarray]:
"""``lambda_min`` and ``phi_p`` after each swap in ``pairs`` (design rows, candidate rows), in chunks."""
p = info.shape[0]
chunk = max(1, 2_000_000 // p**2) # about 16 MB of matrices at a time
lam_min, phi = [], []
for start in range(0, len(pairs[0]), chunk):
a = f_design[pairs[0][start : start + chunk]]
b = f_cand[pairs[1][start : start + chunk]]
swapped = info[None] - a[:, :, None] * a[:, None, :] + b[:, :, None] * b[:, None, :]
eigenvalues = np.linalg.eigvalsh(swapped)
lam_min.append(eigenvalues[:, 0])
phi.append(_phi_p(eigenvalues))
return np.concatenate(lam_min), np.concatenate(phi)
def _best_e_swap(
info: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray, *, polish: bool = False
) -> tuple[int, int, float]:
"""Best swap for E-optimality: rank every swap by the gradient of ``phi_p``, then score the best exactly.
The exchange runs in two phases. The first climbs ``phi_p`` itself, a smooth
stand-in for ``lambda_min`` that can trade a little of the smallest eigenvalue for
a lot of the next ones, and so escapes designs where no swap raises
``lambda_min`` alone. The second (``polish=True``) then takes only swaps that
raise ``lambda_min``, or hold it and raise ``phi_p``. Each phase climbs one
objective, so neither can cycle. Two things make ``lambda_min`` hard to climb one
swap at a time:
- **Ties.** On a +/-1 grid thousands of swaps share a first-order estimate, so a
short list of "most promising" swaps can miss the one that helps. The list
scored exactly is therefore large (every swap when ``_E_EXACT_WORK`` allows),
and batched eigenvalue solves keep that cheap.
- **Repeated eigenvalues.** If ``lambda_min`` has multiplicity 2 or more, no single
swap can raise it: adding ``b b'`` gives ``lambda_1(M + b b') <= lambda_2(M)``
(Weyl interlacing). A swap that keeps ``lambda_min`` and raises ``phi_p``
splits the repeated eigenvalue, so a later swap can lift it.
Swaps are ranked by the first-order change in ``phi_p``, which weights each
eigen-direction by ``(lambda_min / lambda_k)^(p+1)``: the directions at or near the
minimum count fully, the rest hardly at all.
Returns
-------
tuple[int, int, float]
Design row out, candidate row in, and the gain: in ``phi_p`` in the first phase;
when polishing, in ``lambda_min`` when it rises, otherwise in ``phi_p`` with
``lambda_min`` held. A gain of 0 means no swap helps.
"""
eigenvalues, eigenvectors = np.linalg.eigh(info)
current, current_phi = float(eigenvalues[0]), float(_phi_p(eigenvalues))
weights = (max(current, 1e-12) / np.maximum(eigenvalues, 1e-12)) ** (_E_P + 1)
added = (f_cand @ eigenvectors) ** 2 @ weights
removed = (f_design @ eigenvectors) ** 2 @ weights
estimate = added[None, :] - removed[:, None]
n_exact = max(2000, _E_EXACT_WORK // info.shape[0] ** 2)
order = np.argsort(estimate, axis=None)[::-1][:n_exact]
rows_out, rows_in = np.unravel_index(order, estimate.shape)
pairs = (rows_out, rows_in)
lam_min, phi = _exact_e_scores(info, f_design, f_cand, pairs)
phi_tol = 1e-9 * max(1.0, abs(current_phi))
if not polish:
best = int(np.argmax(phi))
gain = float(phi[best] - current_phi)
return (int(pairs[0][best]), int(pairs[1][best]), gain) if gain > phi_tol else (0, 0, 0.0)
tol = 1e-9 * max(1.0, abs(current))
if (lam_min > current + tol).any():
best = int(np.argmax(np.where(lam_min > current + tol, lam_min + 1e-12 * phi, -np.inf)))
return int(pairs[0][best]), int(pairs[1][best]), float(lam_min[best] - current)
holds = lam_min >= current - tol
if holds.any():
best = int(np.argmax(np.where(holds, phi, -np.inf)))
gain = float(phi[best] - current_phi)
if gain > phi_tol:
return int(pairs[0][best]), int(pairs[1][best]), gain
return 0, 0, 0.0
def _spectral_or_minimax_value(criterion: Criterion, info: np.ndarray) -> float:
"""``lambda_min`` (E), ``-log(lambda_max / lambda_min)`` (K), or minus the largest prediction variance (G)."""
if criterion.name == "g_optimal":
if np.linalg.matrix_rank(info) < info.shape[0]:
return -np.inf
return -float(_prediction_variance(criterion.region_rows, np.linalg.inv(info)).max()) # type: ignore[arg-type]
eigenvalues = np.linalg.eigvalsh(info)
if criterion.name == "e_optimal":
return float(eigenvalues[0])
if eigenvalues[0] <= 1e-12 * max(float(eigenvalues[-1]), 1e-300):
return -np.inf
return -float(np.log(eigenvalues[-1] / eigenvalues[0]))
def _prediction_variance(rows: np.ndarray, m_inv: np.ndarray) -> np.ndarray:
"""``f(x)' M^-1 f(x)`` for every model row ``f(x)`` in ``rows``."""
return np.einsum("ij,jk,ik->i", rows, m_inv, rows)
def _swap_eigenvalues(info: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray, pairs: tuple) -> np.ndarray:
"""Ascending eigenvalues of ``M - a a' + b b'`` for each swap in ``pairs`` (design rows, candidate rows)."""
p = info.shape[0]
chunk = max(1, 2_000_000 // p**2)
out = []
for start in range(0, len(pairs[0]), chunk):
a = f_design[pairs[0][start : start + chunk]]
b = f_cand[pairs[1][start : start + chunk]]
out.append(np.linalg.eigvalsh(info[None] - a[:, :, None] * a[:, None, :] + b[:, :, None] * b[:, None, :]))
return np.concatenate(out)
#: Swaps whose K-criterion is within this fraction of the best swap's tie with it (:func:`_best_k_swap`).
_K_TIE = 1e-9
def _best_k_swap(info: np.ndarray, f_design: np.ndarray, f_cand: np.ndarray) -> tuple[int, int, float]:
"""Best swap for K-optimality (smallest condition number of ``X'X``).
Every swap is ranked by the first-order change in ``log(lambda_min) - log(lambda_max)``,
``((v' b)^2 - (v' a)^2) / lambda`` along the extreme eigenvectors ``v``, and the most
promising ones are scored exactly with batched eigenvalue solves, as for E-optimality.
Of the swaps that tie for the best score, to within ``_K_TIE``, the one with the first design
row, then the first candidate, is made. On a grid many swaps tie exactly, and an extreme
eigenvalue is usually repeated, so its eigenvector is any vector of its eigenspace, chosen by
the BLAS kernel's rounding. Taking the first maximum in the order that vector ranks the swaps
in gave a different search path, and designs of different quality, on different processors
(#1129).
"""
eigenvalues, eigenvectors = np.linalg.eigh(info)
lam_min, lam_max = max(float(eigenvalues[0]), 1e-12), float(eigenvalues[-1])
current = -np.log(lam_max / lam_min)
def pull(rows: np.ndarray) -> np.ndarray:
return (rows @ eigenvectors[:, 0]) ** 2 / lam_min - (rows @ eigenvectors[:, -1]) ** 2 / lam_max
estimate = pull(f_cand)[None, :] - pull(f_design)[:, None]
n_exact = max(2000, _E_EXACT_WORK // info.shape[0] ** 2)
order = np.argsort(estimate, axis=None)[::-1][:n_exact]
pairs = np.unravel_index(order, estimate.shape)
eig = _swap_eigenvalues(info, f_design, f_cand, pairs)
with np.errstate(divide="ignore", invalid="ignore"):
score = np.where(eig[:, 0] > 1e-12 * eig[:, -1], -np.log(eig[:, -1] / eig[:, 0]), -np.inf)
top = float(np.max(score))
tied = np.flatnonzero(score >= top - _K_TIE * max(1.0, abs(top))) # every swap when all are singular
best = int(tied[np.argmin(order[tied])]) # order holds each swap's (design row, candidate) as a flat index
gain = float(score[best] - current)
return (int(pairs[0][best]), int(pairs[1][best]), gain) if gain > 1e-12 else (0, 0, 0.0)
#: Reweighting rounds of the I-lambda search for G-optimality, and the size of its final polish.
_G_ROUNDS = 12
_G_POLISH_CANDIDATES = 400
_G_POLISH_PASSES = 20
def _g_polish(f_cand: np.ndarray, f_fixed: np.ndarray, rows: np.ndarray, region: np.ndarray) -> np.ndarray:
"""Exact exchange on the largest prediction variance over ``region``: take the best single swap while it helps.
With ``A = (M - a a')^-1`` after removing design row ``a``, adding candidate ``b`` gives
``d(r) = r' A r - (r' A b)^2 / (1 + b' A b)`` at every region row ``r`` at once.
"""
rows = rows.copy()
for _ in range(_G_POLISH_PASSES):
x = np.vstack([f_fixed, f_cand[rows]])
m_inv = np.linalg.inv(x.T @ x)
current = float(_prediction_variance(region, m_inv).max())
pool = np.arange(len(f_cand))
if len(pool) > _G_POLISH_CANDIDATES: # the runs that help most sit where the variance is largest
pool = np.argsort(_prediction_variance(f_cand, m_inv))[::-1][:_G_POLISH_CANDIDATES]
best = (current - 1e-9 * current, -1, -1)
for i, row in enumerate(rows):
a = f_cand[row]
denominator = 1.0 - a @ m_inv @ a
if denominator <= 1e-10:
continue
m_inv_a = m_inv @ a
removed = m_inv + np.outer(m_inv_a, m_inv_a) / denominator
region_removed = region @ removed
base = np.einsum("ij,ij->i", region_removed, region)
b = f_cand[pool]
cross = region_removed @ b.T
scale = 1.0 + np.einsum("ij,jk,ik->i", b, removed, b)
worst = (base[:, None] - cross**2 / scale[None, :]).max(axis=0)
j = int(np.argmin(worst))
if worst[j] < best[0]:
best = (float(worst[j]), i, int(pool[j]))
if best[1] < 0:
break
rows[best[1]] = best[2]
return rows
def _g_exchange(
f_cand: np.ndarray, n_free: int, f_fixed: np.ndarray, rng: np.random.Generator, criterion: Criterion
) -> tuple[np.ndarray, float]:
"""G-optimal design by I-lambda optimality (Hernandez and Nachtsheim 2018), then an exact polish.
The largest prediction variance over the region is hard to lower one swap at a time,
so each round runs the closed-form trace exchange for a weighted average of the
variance (I-lambda), and Lawson's rule then moves weight towards the region points
where the variance is largest (``w <- w * d``). The rounds converge on the minimax
design; the best design met is kept and polished by exact swaps on the maximum.
"""
region = criterion.region_rows
if region is None:
raise ValueError("G-optimality needs the region's model rows.")
best_rows, best_value = np.empty(0, dtype=int), -np.inf
for _ in range(_N_STARTS):
rows = _greedy_start(f_cand, f_fixed, n_free, rng)
weights = np.full(len(region), 1.0 / len(region))
start_rows, start_value = rows.copy(), -np.inf
for _ in range(_G_ROUNDS):
rows = _climb(f_cand, f_fixed, rows, Criterion("i_optimal", (region * weights[:, None]).T @ region))
x = np.vstack([f_fixed, f_cand[rows]])
if np.linalg.matrix_rank(x) < x.shape[1]:
break
variance = _prediction_variance(region, np.linalg.inv(x.T @ x))
if -variance.max() > start_value:
start_rows, start_value = rows.copy(), -float(variance.max())
weights = weights * variance
weights /= weights.sum()
if np.isfinite(start_value):
start_rows = _g_polish(f_cand, f_fixed, start_rows, region)
x = np.vstack([f_fixed, f_cand[start_rows]])
value = criterion.value(x.T @ x)
if value > best_value or len(best_rows) == 0:
best_rows, best_value = start_rows, value
return best_rows, best_value
def _n_starts(criterion: Criterion, f_cand: np.ndarray, n_free: int) -> int:
"""Random starts for the exchange: more for a small problem, so long as their total work stays near ``_START_WORK``.
The work of one start grows with candidates x coefficients x free runs. E and K
score every swap at each step and keep ``_N_STARTS``.
"""
if criterion.name not in _CLOSED_FORM_CRITERIA:
return _N_STARTS
work = f_cand.shape[0] * f_cand.shape[1] * max(n_free, 1)
return int(np.clip(_START_WORK // work, _N_STARTS, _MAX_STARTS))
[docs]
def fedorov_exchange(
f_cand: np.ndarray,
n_free: int,
f_fixed: np.ndarray,
rng: np.random.Generator,
criterion: Criterion | None = None,
) -> tuple[np.ndarray, float]:
"""Choose ``n_free`` candidate rows that maximise ``criterion`` (D-optimality by default).
For D, A and I each design run in turn is swapped for the candidate that improves
the criterion most (:func:`_row_exchange`, the modified Fedorov exchange of Cook and
Nachtsheim 1980), with the gains in the closed form of :meth:`Criterion.swap_gains`
(Fedorov 1972). E and K make the best single swap over all pairs at each step. Rows in
``f_fixed`` stay in the design and are never swapped out. Candidates may repeat,
which gives replicated runs where the criterion wants them. The best of several
random starts is kept: ``_N_STARTS`` for E and K, and up to ``_MAX_STARTS`` for a
small D, A or I problem (see :func:`_n_starts`).
Returns
-------
tuple[np.ndarray, float]
Selected candidate indices and the criterion value of the full design (see
:meth:`Criterion.value`). G-optimality runs its own search, :func:`_g_exchange`.
"""
criterion = criterion if criterion is not None else Criterion.d()
if criterion.name == "g_optimal":
return _g_exchange(f_cand, n_free, f_fixed, rng, criterion)
best_rows, best_value = np.empty(0, dtype=int), -np.inf
for rows in _climbed_starts(f_cand, n_free, f_fixed, rng, criterion):
x = np.vstack([f_fixed, f_cand[rows]])
value = criterion.value(x.T @ x)
if value > best_value or len(best_rows) == 0: # keep a design even if every start is singular
best_rows, best_value = rows.copy(), value
return best_rows, best_value
def _climbed_starts(
f_cand: np.ndarray, n_free: int, f_fixed: np.ndarray, rng: np.random.Generator, criterion: Criterion
) -> Iterator[np.ndarray]:
"""Each random start of the exchange (see :func:`_n_starts`), climbed to a local optimum of ``criterion``."""
for _ in range(_n_starts(criterion, f_cand, n_free)):
start = _greedy_start(f_cand, f_fixed, n_free, rng)
if criterion.segment is not None:
start = _spread_over_blocks(start, criterion.segment)
yield _climb(f_cand, f_fixed, start, criterion)
def _spread_over_blocks(rows: np.ndarray, segment: np.ndarray) -> np.ndarray:
"""Deal a start's runs out to the blocks in turn, so the block sizes differ by at most one.
A blocked exchange's candidates are one copy of the points per block, laid end to
end in block order (``segment`` holds each row's block), so a point moves to the
``b``-th block by moving to the same row of the ``b``-th copy.
"""
n_blocks = len(np.unique(segment))
n_points = len(segment) // n_blocks
return rows % n_points + n_points * (np.arange(len(rows)) % n_blocks)
def _climb(f_cand: np.ndarray, f_fixed: np.ndarray, rows: np.ndarray, criterion: Criterion) -> np.ndarray:
"""Improve ``rows`` by swaps while ``criterion`` improves; return the rows.
D, A and I (whose swaps have a closed form) use the row-wise exchange,
:func:`_row_exchange`. E and K make the best single swap at each step, in each of
their phases.
"""
if criterion.name in _CLOSED_FORM_CRITERIA:
return _row_exchange(f_cand, f_fixed, rows, criterion)
rows = rows.copy()
for phase in range(criterion.n_phases):
for _ in range(_MAX_EXCHANGES):
x = np.vstack([f_fixed, f_cand[rows]])
i, j, gain = criterion.best_swap(x.T @ x, f_cand[rows], f_cand, phase)
if not gain > 1e-9: # also stops on NaN
break
rows[i] = j
return rows
[docs]
@dataclass
class PolishLattice:
"""Where :func:`polish_runs` may move a run.
Parameters
----------
n_levels : int
Each continuous coordinate (coded) may move to one of ``n_levels`` evenly spaced
levels from -1 to +1.
model_rows : Callable[[np.ndarray, np.ndarray], np.ndarray]
Model rows of points given as coded continuous values and categorical level indices.
feasible : Callable[[np.ndarray, np.ndarray], np.ndarray] or None
True for each point (coded continuous values, categorical level indices) that
satisfies the constraints; None for the whole box.
"""
n_levels: int
model_rows: Callable[[np.ndarray, np.ndarray], np.ndarray]
feasible: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None
#: Levels per factor of the lattice a design from a coarser or sampled grid is polished on.
_POLISH_LEVELS = 5
[docs]
def polish_levels(counts: dict) -> int | None:
"""Levels of the lattice to polish on (see :func:`polish_runs`), or None when the candidate grid already holds it.
A grid coarser than ``_POLISH_LEVELS`` levels, or a sampled one, is polished on the
``_POLISH_LEVELS``-level lattice: runs may then use settings between the grid levels,
which can raise the criterion even for D-optimality (a few runs at +/-0.5 when some
runs are fixed, for instance).
"""
return _POLISH_LEVELS if counts["grid_sampled"] or counts["n_levels"] < _POLISH_LEVELS else None
def _coordinate_moves(coded: np.ndarray, levels: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Every run with one continuous coordinate moved to another level: the points, and the run each came from."""
n_runs, n_levels = len(coded), len(levels)
owner = np.repeat(np.arange(n_runs), n_levels)
points, owners = [np.empty((0, coded.shape[1]))], [np.empty(0, dtype=int)]
for axis in range(coded.shape[1]):
moved = coded[owner].copy()
moved[:, axis] = np.tile(levels, n_runs)
changed = ~np.isclose(moved[:, axis], coded[owner, axis])
points.append(moved[changed])
owners.append(owner[changed])
return np.vstack(points), np.concatenate(owners)
[docs]
def polish_runs(
coded: np.ndarray, cats: np.ndarray, f_fixed: np.ndarray, criterion: Criterion, lattice: PolishLattice
) -> tuple[np.ndarray, np.ndarray, int]:
"""Coordinate exchange on a finer or complete grid, after the candidate exchange on a coarse or sampled one.
Each run in turn is moved to the best of its neighbours, the points that differ
from it in one continuous coordinate (set to another level of ``lattice``), when
that improves ``criterion``; passes repeat until no move helps. The gains are those
of :meth:`Criterion.swap_gains`, so only D, A and I are polished. This reaches grid
points the candidate set left out, at a cost that grows with the number of runs
and factors and not with the size of the grid.
Parameters
----------
coded : np.ndarray
The free runs, continuous factors in coded units, shape ``(n, k_cont)``.
cats : np.ndarray
Their categorical level indices, shape ``(n, k_cat)``; these are not moved.
f_fixed : np.ndarray
Model rows of the runs that stay in the design.
criterion : Criterion
D, A or I.
lattice : PolishLattice
The levels, constraints and model rows.
Returns
-------
tuple[np.ndarray, np.ndarray, int]
The polished runs (coded, categorical) and the number of moves made.
"""
coded, cats = coded.copy(), cats.copy()
levels = np.linspace(-1.0, 1.0, lattice.n_levels)
x = np.array(lattice.model_rows(coded, cats), dtype=float) # a copy: patsy's rows can be read-only under pandas 3
n_moves = 0
for _ in range(_MAX_PASSES):
points, owner = _coordinate_moves(coded, levels)
if lattice.feasible is not None:
keep = lattice.feasible(points, cats[owner])
points, owner = points[keep], owner[keep]
rows = lattice.model_rows(points, cats[owner])
moved, start, size = False, 0, 1
m_inv = _regularised_inverse(f_fixed.T @ f_fixed + x.T @ x)
while start < len(coded): # runs a few at a time, as in _climb_block: one after a move, doubling after none
group = np.flatnonzero((owner >= start) & (owner < start + size))
gains = _paired_gains(criterion, m_inv, x[owner[group]], rows[group])
improving = gains > 1e-9 # also refuses NaN
if not improving.any():
start, size = start + size, 2 * size
continue
run = int(owner[group[improving]].min()) # the first run with a move that helps takes its best one
best = int(group[np.argmax(np.where(owner[group] == run, gains, -np.inf))])
m_inv = _swapped_inverse(m_inv, x[run], rows[best])
coded[run], x[run] = points[best], rows[best]
moved, start, size, n_moves = True, run + 1, 1, n_moves + 1
if not moved:
break
return coded, cats, n_moves
def _swapped_inverse(m_inv: np.ndarray, removed: np.ndarray, added: np.ndarray) -> np.ndarray:
"""``M^-1`` after the model row ``removed`` is replaced by ``added``: two Sherman-Morrison updates."""
for v, sign in ((added, 1.0), (removed, -1.0)):
u = m_inv @ v
m_inv = m_inv - sign / (1.0 + sign * float(v @ u)) * np.outer(u, u)
return m_inv
def _paired_gains(criterion: Criterion, m_inv: np.ndarray, x_out: np.ndarray, x_in: np.ndarray) -> np.ndarray:
"""Gain of swapping each row of ``x_out`` for the matching row of ``x_in`` (see :meth:`Criterion.swap_gains`)."""
a_out, a_in = x_out @ m_inv, x_in @ m_inv
d = (np.einsum("ij,ij->i", a_out, x_out), np.einsum("ij,ij->i", a_in, x_in), np.einsum("ij,ij->i", a_out, x_in))
if criterion.weights is None:
return _d_gain(*d)
b_out = a_out @ criterion.weights
b = (
np.einsum("ij,ij->i", b_out, a_out),
np.einsum("ij,ij->i", a_in @ criterion.weights, a_in),
np.einsum("ij,ij->i", b_out, a_in),
)
return _trace_gain(d, b)
[docs]
@dataclass
class CandidatePool:
"""Candidate points, their model rows, and the lattice their runs may be polished on.
Parameters
----------
coded : np.ndarray
Continuous factor settings in coded units, shape ``(N, k_cont)``.
cats : np.ndarray
Categorical level indices, shape ``(N, k_cat)``.
rows : np.ndarray
Model rows of the candidates, shape ``(N, p)``.
lattice : PolishLattice or None
Where each start's design is polished (:func:`polish_runs`); None for no polish.
"""
coded: np.ndarray
cats: np.ndarray
rows: np.ndarray
lattice: PolishLattice | None = None
[docs]
def select_runs(
pool: CandidatePool, n_free: int, f_fixed: np.ndarray, rng: np.random.Generator, criterion: Criterion
) -> tuple[np.ndarray | None, np.ndarray, np.ndarray, float]:
"""Choose ``n_free`` runs by :func:`fedorov_exchange`, polishing each start's design when the pool says so.
The polish can move a run off the candidate set, and the start whose design is best
*after* polishing is kept: on the 10-factor augmentation of the 1.97.0 review, the
best design before polishing was not the best one after it in three seeds out of four.
Returns
-------
tuple
The candidate indices chosen (None once polished, as runs may then lie between
candidates), the runs' coded settings and categorical levels, and the criterion
value of the full design.
"""
lattice = pool.lattice
if lattice is None or criterion.name not in _CLOSED_FORM_CRITERIA:
rows, value = fedorov_exchange(pool.rows, n_free, f_fixed, rng, criterion)
return rows, pool.coded[rows], pool.cats[rows], value
best: tuple[np.ndarray | None, np.ndarray, np.ndarray, float] = (None, pool.coded[:0], pool.cats[:0], -np.inf)
for rows in _climbed_starts(pool.rows, n_free, f_fixed, rng, criterion):
coded, cats, _moves = polish_runs(pool.coded[rows], pool.cats[rows], f_fixed, criterion, lattice)
x = np.vstack([f_fixed, lattice.model_rows(coded, cats)])
value = criterion.value(x.T @ x)
if value > best[3] or not len(best[1]): # keep a design even if every start is singular
best = (None, coded, cats, value)
return best
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
[docs]
@dataclass
class ConstrainedOptions:
"""Optional settings for :func:`constrained_optimal_design` and the constrained mixture design.
Parameters
----------
model_type : str
``"main_effects"``, ``"interactions"`` or ``"quadratic"`` (a mixture design maps
these to Scheffé models).
criterion : str
``"d_optimal"`` (default), ``"i_optimal"``, ``"a_optimal"``, ``"e_optimal"``,
``"g_optimal"`` or ``"k_optimal"``.
fixed_runs : pandas.DataFrame or None
Runs kept in the design (continuous in coded units, categorical as labels),
already validated by the caller. They count towards the budget.
n_levels : int or None
Grid levels per continuous factor. ``None`` picks the finest of 5, 4 and 3
(and 2, for a model without squared terms; 4 is skipped for a quadratic model
unless the criterion is I or G) that keeps the grid within
``max(5000, 200 p)`` points for ``p`` model coefficients, and samples the
coarsest grid down to that when none does. A sampled or coarse grid is then
polished (see :func:`polish_runs`).
candidates : pandas.DataFrame or None
Settings the runs must be chosen from, in actual units (proportions for a
mixture), one column per factor, instead of a generated grid. Rows that
break a constraint are dropped, and the I-optimality average is taken over
the remaining rows.
n_blocks : int or None
Blocks the design is run in. For D, A and I the blocks enter the exchange as
fixed effects (see :meth:`Criterion.blocked`), and ``metadata["block_labels"]``
gives each run's block; the fixed runs, already made, are a block of their
own. Other criteria leave the blocks to be assigned afterwards.
"""
model_type: str = "interactions"
criterion: str = "d_optimal"
fixed_runs: pd.DataFrame | None = None
n_levels: int | None = None
candidates: pd.DataFrame | None = None
n_blocks: int | None = None
[docs]
def block_columns(labels: np.ndarray, n_blocks: int) -> np.ndarray:
"""Return sum-to-zero columns for the 0-based block ``labels``: ``n_blocks - 1`` of them, the last block -1 in each.
``analyze_experiment`` codes blocks this way, so a criterion over these columns is
the one the analysis estimates with, and the intercept is the average block.
"""
coding = np.vstack([np.eye(n_blocks - 1), -np.ones((1, n_blocks - 1))])
return coding[np.asarray(labels, dtype=int)]
def _blocks_in_exchange(opts: ConstrainedOptions, criterion: Criterion) -> bool:
"""Whether the blocks enter the exchange: there are two or more, and the criterion is D, A or I."""
return opts.n_blocks is not None and opts.n_blocks > 1 and criterion.name in _CLOSED_FORM_CRITERIA
def _blocked_search(
pool: CandidatePool, f_fixed: np.ndarray, criterion: Criterion, n_blocks: int, budget: int
) -> tuple[CandidatePool, np.ndarray, Criterion, int]:
"""Set the exchange up for a design run in ``n_blocks`` blocks: return its pool, fixed rows, criterion and budget.
The fixed runs, already made, are a block of their own, the first; the free runs
fill the others in near-equal shares. Each block the free runs fill gets a copy of
the candidates, with that block's columns (:func:`block_columns`) and its index as
one more categorical column, which the polish never moves.
Raises
------
ValueError
If the block effects leave the budget too few runs to estimate the model, or the
budget leaves fewer than two new runs for each block they fill.
"""
n_fixed = len(f_fixed)
free_blocks = np.arange(1 if n_fixed else 0, n_blocks)
f_fixed = np.hstack([f_fixed, block_columns(np.zeros(n_fixed, dtype=int), n_blocks)])
n_parameters = pool.rows.shape[1] + n_blocks - 1
needed = n_fixed + n_parameters - (int(np.linalg.matrix_rank(f_fixed)) if n_fixed else 0)
if budget < needed:
raise ValueError(
f"Running the design in {n_blocks} blocks adds {n_blocks - 1} block effect(s) to the model's "
f"{pool.rows.shape[1]} coefficients, so it needs at least {needed} runs, more than budget={budget}. "
f"Use budget >= {needed} or fewer blocks."
)
if budget - n_fixed < 2 * len(free_blocks):
raise ValueError(
f"{n_blocks} blocks need at least two new runs in each of the {len(free_blocks)} blocks the new runs "
f"fill, but the budget leaves {budget - n_fixed}. Use fewer blocks or a larger budget."
)
segment = np.repeat(free_blocks, len(pool.rows))
copies = len(free_blocks)
lattice = pool.lattice
if lattice is not None:
factor_rows = lattice.model_rows
lattice = replace(
lattice,
model_rows=lambda coded, cats: np.hstack(
[factor_rows(coded, cats[:, :-1]), block_columns(cats[:, -1], n_blocks)]
),
)
blocked_pool = CandidatePool(
np.tile(pool.coded, (copies, 1)),
np.column_stack([np.tile(pool.cats, (copies, 1)), segment]),
np.hstack([np.tile(pool.rows, (copies, 1)), block_columns(segment, n_blocks)]),
lattice,
)
return blocked_pool, f_fixed, criterion.blocked(n_blocks - 1, segment), budget
def _adjusted_for_blocks(criterion: Criterion, value: float, labels: np.ndarray, n_blocks: int) -> float:
"""Return ``value`` for the factor effects adjusted for the blocks (see :meth:`Criterion.blocked`).
For D, ``log det(M)`` less ``log det(Z'Z)`` of the block columns ``Z`` is
``log det(S)``; the trace criteria already score ``S``.
"""
if criterion.name != "d_optimal":
return value
z = block_columns(labels, n_blocks)
return value - float(np.linalg.slogdet(z.T @ z)[1])
def _coded_runs(region: _Region, runs: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
"""Return runs given continuous in coded units and categorical as labels, as coded values and level indices."""
coded = runs[[f.name for f in region.continuous]].to_numpy(dtype=float)
labels = {f.name: [str(lv) for lv in f.levels or []] for f in region.categorical}
cats = np.array(
[[labels[f.name].index(str(v)) for v in runs[f.name]] for f in region.categorical], dtype=int
).T.reshape(len(runs), len(region.categorical))
return coded, cats
def _fixed_rows(region: _Region, fixed_runs: pd.DataFrame | None, model_type: str, n_columns: int) -> np.ndarray:
"""Model rows for the fixed runs (none when ``fixed_runs`` is None), warning about any outside the region."""
if fixed_runs is None:
return np.empty((0, n_columns))
fixed_coded, fixed_cats = _coded_runs(region, fixed_runs)
n_outside = int((region.slack(fixed_coded, fixed_cats) > _FEASIBILITY_TOL).sum())
if n_outside:
logger.warning("%d fixed run(s) lie outside the constrained region; they are kept as given.", n_outside)
return model_matrix(region, fixed_coded, fixed_cats, model_type)
def _fixed_run_metadata(region: _Region, fixed_runs: pd.DataFrame) -> dict:
"""Metadata on the fixed runs: how many, and how many lie outside the region."""
meta = {"n_fixed_runs": len(fixed_runs)}
coded, cats = _coded_runs(region, fixed_runs)
n_outside = int((region.slack(coded, cats) > _FEASIBILITY_TOL).sum())
if n_outside:
meta["n_fixed_runs_outside_region"] = n_outside
return meta
def _user_candidates(region: _Region, candidates: pd.DataFrame) -> tuple[np.ndarray, np.ndarray, dict, list]:
"""Code a user's candidate settings, drop the infeasible and repeated ones.
Returns
-------
tuple
Coded continuous values, categorical level indices, counts for the
metadata, and the ``candidates`` index label of each kept row.
"""
names = [f.name for f in region.continuous] + [f.name for f in region.categorical]
missing = [n for n in names if n not in candidates.columns]
if missing:
raise ValueError(f"candidates is missing columns for factors: {missing}.")
actual = candidates[[f.name for f in region.continuous]].apply(pd.to_numeric, errors="coerce")
if actual.isna().any().any():
raise ValueError("candidates has missing or non-numeric values in a continuous factor column.")
low = np.array([f.low for f in region.continuous], dtype=float)
high = np.array([f.high for f in region.continuous], dtype=float)
coded = (actual.to_numpy(dtype=float) - (low + high) / 2.0) / ((high - low) / 2.0)
cats = np.zeros((len(candidates), len(region.categorical)), dtype=int)
for j, f in enumerate(region.categorical):
labels = [str(lv) for lv in f.levels or []]
values = candidates[f.name].astype(str)
unknown = sorted(set(values) - set(labels))
if unknown:
raise ValueError(f"candidates has unknown levels {unknown} for categorical factor {f.name!r}.")
cats[:, j] = [labels.index(v) for v in values]
if (np.abs(coded) > 1.0 + 1e-9).any():
logger.warning("Some candidates lie outside the factors' low/high range; they are kept (coded beyond +/-1).")
feasible = region.slack(coded, cats) <= _FEASIBILITY_TOL
_, first = np.unique(np.hstack([coded.round(9), cats]), axis=0, return_index=True)
keep = np.zeros(len(coded), dtype=bool)
keep[first] = True
keep &= feasible
counts = {
"n_candidates_supplied": len(candidates),
"n_candidates_infeasible": int((~feasible).sum()),
"n_candidates": int(keep.sum()),
}
return coded[keep], cats[keep], counts, list(candidates.index[keep])
def _corners_suffice(region: _Region, opts: ConstrainedOptions) -> bool:
"""Whether some D-optimal design uses only the corners of the box: no squared terms and no constraints."""
return (
opts.criterion == "d_optimal"
and opts.model_type != "quadratic"
and not region.inequalities
and not region.conditional
)
def _polish_lattice(region: _Region, opts: ConstrainedOptions, counts: dict) -> PolishLattice | None:
"""Return the lattice to polish a design from a generated grid on, or None when there is nothing to add.
See :func:`polish_levels`. Supplied candidates and an explicit ``n_levels`` are kept
to, and a design that only needs the box's corners is not polished.
"""
if opts.candidates is not None or opts.n_levels is not None or opts.criterion not in _CLOSED_FORM_CRITERIA:
return None
n_levels = polish_levels(counts)
if n_levels is None or _corners_suffice(region, opts):
return None
return PolishLattice(
n_levels,
lambda coded, cats: model_matrix(region, coded, cats, opts.model_type),
lambda coded, cats: region.slack(coded, cats) <= _FEASIBILITY_TOL,
)
def _candidate_pool(
region: _Region, opts: ConstrainedOptions, rng: np.random.Generator
) -> tuple[np.ndarray, np.ndarray, dict, list | None, Callable[[], np.ndarray]]:
"""Return the candidates (a generated grid, or the user's), and the rows I-optimality averages over.
Raises
------
ValueError
If no candidate point satisfies the constraints.
"""
coded, cats, counts, labels, region_rows = _candidates_and_region(region, opts, rng)
if counts["n_candidates"] == 0:
raise ValueError("No candidate point satisfies all the constraints; check them for conflicts.")
return coded, cats, counts, labels, region_rows
def _candidates_and_region(
region: _Region, opts: ConstrainedOptions, rng: np.random.Generator
) -> tuple[np.ndarray, np.ndarray, dict, list | None, Callable[[], np.ndarray]]:
"""See :func:`_candidate_pool`, which checks that some candidate is feasible."""
if opts.candidates is not None:
coded, cats, counts, labels = _user_candidates(region, opts.candidates)
return coded, cats, counts, labels, lambda: model_matrix(region, coded, cats, opts.model_type)
n_levels = opts.n_levels
if n_levels is None and _corners_suffice(region, opts):
# Without squared terms each model row is affine in every single coordinate, so det(X'X) is a convex
# quadratic in it and is largest at -1 or +1: some exact D-optimal design uses only the box's corners.
n_levels = 2
coded, cats, counts = build_candidates(region, n_levels, opts.model_type, opts.criterion)
return coded, cats, counts, None, lambda: _uniform_rows(region, opts.model_type, rng, coded, cats)
[docs]
def selection_counts(labels: list, rows: np.ndarray) -> dict[str, int]:
"""Count how often each supplied candidate (by index label) was chosen."""
chosen = pd.Series([labels[r] for r in rows]).value_counts(sort=False)
return {str(label): int(n) for label, n in chosen.items()}
def _missing_rank(f_fixed: np.ndarray, n_parameters: int) -> int:
"""Count the model coefficients the fixed runs leave unestimated: the rank the free runs must supply."""
return n_parameters - (int(np.linalg.matrix_rank(f_fixed)) if len(f_fixed) else 0)
[docs]
def refuse_budget_short_of_fixed_runs(f_fixed: np.ndarray, n_parameters: int, budget: int) -> None:
"""Raise when the budget leaves too few new runs to supply the rank the fixed runs lack.
Fixed runs that repeat a point (three centre runs, say) carry less information than
their count, so a budget above the number of coefficients can still be too small.
Raises
------
ValueError
If ``budget`` minus the fixed runs is below the rank they lack.
"""
n_fixed = len(f_fixed)
missing_rank = _missing_rank(f_fixed, n_parameters)
if budget - n_fixed < missing_rank:
raise ValueError(
f"The {n_fixed} fixed run(s) span only {n_parameters - missing_rank} of the {n_parameters} model "
f"coefficients, so at least {missing_rank} more run(s) are needed: budget >= {n_fixed + missing_rank}, "
f"not {budget}."
)
#: Uniform draws used to estimate the region's moment matrix for I-optimality.
_N_MOMENT_SAMPLES = 20_000
def _uniform_rows(
region: _Region,
model_type: str,
rng: np.random.Generator,
seeds: np.ndarray,
seed_cats: np.ndarray | None = None,
) -> np.ndarray:
"""Model rows at points drawn uniformly from the feasible region.
Thin regions are handled by hit-and-run started from the feasible candidates
(``seeds``, whose level indices are ``seed_cats``), so the moment matrix covers the
whole region, not the few points a rejection pass happens to hit.
With rules, each allowed combination of levels carries the weight of its volume (see
:meth:`_Region.draw_levels`), and the continuous settings of a run are drawn
uniformly from the region its levels leave: the box cut by the inequalities that
apply there. Without rules, every combination of levels is allowed and the region
is the same for each.
"""
if region.rules is None:
coded = _uniform_points(region, _N_MOMENT_SAMPLES, rng, seeds)
cats = np.empty((len(coded), 0), dtype=int)
if region.categorical:
cats = np.column_stack([rng.integers(len(f.levels or []), size=len(coded)) for f in region.categorical])
return model_matrix(region, coded, cats, model_type)
cats = region.draw_levels(_N_MOMENT_SAMPLES, rng)
applies = region.applies(cats)
seed_applies = region.applies(seed_cats) if seed_cats is not None else None
coded = np.empty((len(cats), len(region.continuous)))
for signature in np.unique(applies, axis=0):
rows = np.all(applies == signature, axis=1)
mine = seeds if seed_applies is None else seeds[np.all(seed_applies == signature, axis=1)]
coded[rows] = _uniform_points(region.given(signature), int(rows.sum()), rng, mine)
return model_matrix(region, coded, cats, model_type)
def _uniform_points(region: _Region, n: int, rng: np.random.Generator, seeds: np.ndarray) -> np.ndarray:
"""Draw ``n`` coded points uniformly from the box cut by the region's inequalities (its rules are not read)."""
k = len(region.continuous)
def coded_inequality(g: Callable) -> Callable[[np.ndarray], np.ndarray]:
return lambda x: np.broadcast_to(np.asarray(g(region.actual(x)), dtype=float), (len(x),))
sampler = UniformSampler(
[coded_inequality(g) for g in region.inequalities],
(np.full(k, -1.0), np.full(k, 1.0)),
lambda m: rng.uniform(-1.0, 1.0, size=(m, k)),
seeds=lambda: seeds,
tolerances=region._value_scales()[: len(region.inequalities)], # so a drawn point passes slack()
)
return sampler.draw(n, rng)
#: Uniform draws added to the candidates for the G-criterion's maximum (the candidates hold the boundary points).
_N_G_REGION_SAMPLES = 3000
[docs]
def make_criterion(
name: str,
n_parameters: int,
region_rows: Callable[[], np.ndarray],
candidate_rows: np.ndarray | None = None,
) -> Criterion:
"""Build the :class:`Criterion` called ``name``.
``region_rows`` (model rows drawn uniformly in the region) is only evaluated for
I- and G-optimality. G takes its maximum over those rows plus ``candidate_rows``,
which hold the vertices and boundary points where the worst case usually sits.
"""
if name == "d_optimal":
return Criterion.d()
if name == "a_optimal":
return Criterion.a(n_parameters)
if name == "i_optimal":
return Criterion.i(region_rows())
if name == "e_optimal":
return Criterion.e()
if name == "k_optimal":
return Criterion.k()
if name == "g_optimal":
sample = region_rows()[:_N_G_REGION_SAMPLES]
rows = sample if candidate_rows is None else np.vstack([candidate_rows, sample])
return Criterion.g(np.unique(rows.round(12), axis=0))
raise ValueError(
f"Unknown criterion {name!r}; choose 'd_optimal', 'i_optimal', 'a_optimal', 'e_optimal', 'g_optimal' or "
"'k_optimal'."
)
def _check_factors_and_model(factors: list[Factor], model_type: str) -> None:
"""Refuse mixture components and a model type the model matrix does not build."""
if model_type not in _MODEL_TYPES:
raise ValueError(f"model_type={model_type!r} is not supported; choose from {', '.join(_MODEL_TYPES)}.")
if any(f.type == FactorType.mixture for f in factors):
raise ValueError(
"Mixture components need the mixture engine: give only mixture factors, and generate_design routes "
"them there. Mixture-process designs are not supported."
)
[docs]
def constrained_optimal_design(
factors: list[Factor],
budget: int,
constraints: list[Constraint],
options: ConstrainedOptions | None = None,
random_state: int | np.random.Generator | None = None,
) -> tuple[np.ndarray, dict]:
"""Generate a D-, I-, A- or E-optimal design from a candidate set, every chosen run satisfying ``constraints``.
This is also the optimal-design backend without constraints, unless the design is a
split-plot design or pyoptex is asked for. Runs in ``options.fixed_runs`` are kept as given, even
outside the region (with a warning); ``metadata["n_fixed_runs_outside_region"]``
counts them.
Parameters
----------
factors : list[Factor]
Continuous and categorical factors. Mixture factors are not supported here.
budget : int
Total number of runs, including ``fixed_runs``. A budget that leaves too few free
runs for the rank the fixed runs lack, or for the block effects, raises a ``ValueError``.
constraints : list[Constraint]
Inequalities in actual units over the continuous factors, e.g.
``Constraint(expression="3*T + 5*D <= 600")``. May be empty.
options : ConstrainedOptions or None
Model type, criterion, fixed runs and grid resolution; defaults to a
D-optimal design for an interactions model, no fixed runs and an automatic grid.
random_state : int, numpy.random.Generator or None
Seed for the random starts of the exchange.
Returns
-------
tuple[np.ndarray, dict]
The design in coded units (categorical factors as labels) and metadata.
Raises
------
ValueError
If ``model_type`` is unknown, a constraint cannot be parsed, no candidate point
is feasible, or the feasible region cannot support the requested model.
"""
rng = check_random_state(random_state)
opts = options if options is not None else ConstrainedOptions()
model_type, fixed_runs = opts.model_type, opts.fixed_runs
_check_factors_and_model(factors, model_type)
region = region_with_rules(factors, constraints)
coded, cats, counts, labels, region_rows = _candidate_pool(region, opts, rng)
f_cand, inestimable = _candidate_rows(region, coded, cats, model_type)
f_fixed = _fixed_rows(region, fixed_runs, model_type, f_cand.shape[1])
n_fixed = len(f_fixed)
_check_candidates_support_model(counts, f_fixed, f_cand, model_type)
p = f_cand.shape[1]
refuse_budget_short_of_fixed_runs(f_fixed, p, budget)
criterion = make_criterion(opts.criterion, p, region_rows, f_cand)
pool = CandidatePool(coded, cats, f_cand, _polish_lattice(region, opts, counts))
block_labels = None
if _blocks_in_exchange(opts, criterion):
n_blocks = int(opts.n_blocks) # type: ignore[arg-type]
search, search_fixed, criterion, budget = _blocked_search(pool, f_fixed, criterion, n_blocks, budget)
rows, chosen, chosen_cats, value = select_runs(search, budget - n_fixed, search_fixed, rng, criterion)
block_labels = np.concatenate([np.zeros(n_fixed, dtype=int), chosen_cats[:, -1]])
rows, chosen_cats = (None if rows is None else rows % len(f_cand)), chosen_cats[:, :-1]
value = _adjusted_for_blocks(criterion, value, block_labels, n_blocks)
else:
rows, chosen, chosen_cats, value = select_runs(pool, budget - n_fixed, f_fixed, rng, criterion)
chosen_rows = model_matrix(region, chosen, chosen_cats, model_type)
if len(chosen) != budget - n_fixed or np.linalg.matrix_rank(np.vstack([f_fixed, chosen_rows])) < p:
raise ValueError(
f"No design of {budget} runs from these candidates can estimate the '{model_type}' model "
f"({p} coefficients). Use a simpler model or more distinct candidate points."
)
if pool.lattice is not None:
counts["polish_levels"] = pool.lattice.n_levels
meta = {
"backend": "candidate_exchange",
"optimality_criterion": opts.criterion,
"model_type": model_type,
**counts,
}
if constraints:
meta["constraints"] = [c.expression for c in constraints]
meta["constraints_enforced"] = True
if inestimable:
meta["inestimable_columns"] = inestimable
meta.update(criterion_metadata(criterion, value))
if block_labels is not None:
meta["block_labels"] = (block_labels + 1).tolist()
if fixed_runs is not None:
meta.update(_fixed_run_metadata(region, fixed_runs))
if labels is not None and rows is not None:
meta["candidate_source"] = "user"
meta["selected_candidates"] = selection_counts(labels, rows)
return _assemble_design(region, factors, chosen, chosen_cats, fixed_runs), meta
[docs]
def region_with_rules(factors: list[Factor], constraints: list[Constraint]) -> _Region:
"""Return the region of continuous and categorical ``factors`` cut by ``constraints``, their rules included.
A constraint that names a categorical factor, or joins comparisons with ``and``,
``or`` or ``not``, is read by :func:`~process_improve.experiments._rules.compile_rules`;
every other one by :func:`parse_constraint`, as before.
"""
continuous = [f for f in factors if f.type != FactorType.categorical]
categorical = [f for f in factors if f.type == FactorType.categorical]
names = {f.name for f in continuous}
plain, rules = compile_rules(constraints, names, {f.name: list(f.levels or []) for f in categorical})
inequalities = [g for _source, text in plain for g in parse_constraint(text, names)]
if rules is None:
return _Region(continuous, categorical, inequalities)
from process_improve.experiments.region import DesignRegion # noqa: PLC0415 region imports this module
conditional = [parse_constraint(clause.limit or "", names) for clause in rules.limits]
return _Region(continuous, categorical, inequalities, rules, conditional, source=DesignRegion(factors, constraints))
def _candidate_rows(
region: _Region, coded: np.ndarray, cats: np.ndarray, model_type: str
) -> tuple[np.ndarray, dict[str, int]]:
"""Return the candidates' model rows, and per term the columns the rules leave inestimable (none without rules).
With rules, the columns that cannot be estimated are dropped from the model, here and
in every later call of :func:`model_matrix` on ``region``.
"""
f_cand = model_matrix(region, coded, cats, model_type)
if region.rules is None:
return f_cand, {}
_check_every_level_runs(region, cats)
region.kept_columns, inestimable = _estimable_columns(region, f_cand, model_type)
return f_cand[:, region.kept_columns], inestimable
def _check_every_level_runs(region: _Region, cats: np.ndarray) -> None:
"""Refuse candidates that leave a level of a categorical factor with no setting the constraints allow."""
for j, f in enumerate(region.categorical):
levels = list(f.levels or [])
empty = [levels[i] for i in sorted(set(range(len(levels))) - set(cats[:, j].tolist()))]
if empty:
shown = ", ".join(f"{f.name} = {level!r}" for level in empty)
raise ValueError(
f"No candidate the constraints allow has {shown}: the limits that apply there (or the candidates "
"supplied) leave that level without a run. Loosen them, or remove that level from the factor."
)
#: A model column is estimable from the candidates when its part not explained by the columns before it is larger
#: than this share of its length.
_ESTIMABLE_TOL = 1e-8
def _estimable_columns(region: _Region, f_cand: np.ndarray, model_type: str) -> tuple[np.ndarray, dict[str, int]]:
"""Return the model columns the candidates can estimate, and per term the number of columns lost, with a warning.
A combination of levels that is not allowed is a missing cell: with ``catalyst`` at 3
levels and ``solvent`` at 2, one disallowed pair leaves 5 cells for the 6
coefficients of the main effects and their interaction, so one interaction column
cannot be estimated. The columns are kept in model order (intercept, main effects,
interactions, squares) while each adds rank, so the lost columns are the highest-order
ones. Every set of columns kept this way spans the same model space over the
candidates, so the D- and I-optimal designs do not depend on which column of a term
is dropped.
"""
keep = independent_columns(f_cand)
kept = keep.tolist()
terms = _column_terms(region, model_type)
lost: dict[str, int] = {}
for j in sorted(set(range(len(terms))) - set(kept)):
lost[terms[j]] = lost.get(terms[j], 0) + 1
for term, n_lost in lost.items():
width = terms.count(term)
logger.warning(
"The constraints leave the %s term with %d of its %d column(s) estimable%s; the design is built for "
"the columns that can be estimated, and metadata['inestimable_columns'] records the %d lost.",
term,
width - n_lost,
width,
_missing_cells(region, term),
n_lost,
)
return keep, lost
[docs]
def independent_columns(matrix: np.ndarray) -> np.ndarray:
"""Return the columns of ``matrix`` kept, in order, while each adds rank: a basis of its column space.
Worked on the triangular factor of a QR decomposition, which has the column space's
geometry at the size of the number of columns. A column is kept when its part not
explained by the columns kept before it is longer than ``_ESTIMABLE_TOL`` of its own
length.
"""
r = np.linalg.qr(matrix, mode="r")
basis = np.empty((r.shape[0], 0))
kept = []
for j in range(r.shape[1]):
column = r[:, j]
residual = column - basis @ (basis.T @ column)
size = float(np.linalg.norm(residual))
if size > _ESTIMABLE_TOL * max(float(np.linalg.norm(column)), 1.0):
basis = np.column_stack([basis, residual / size])
kept.append(j)
return np.array(kept, dtype=int)
def _column_terms(region: _Region, model_type: str) -> list[str]:
"""Return the term each column of :func:`model_matrix` belongs to, before any column is dropped."""
blocks = [(f.name, 1) for f in region.continuous] + [(f.name, len(f.levels or []) - 1) for f in region.categorical]
terms = ["Intercept"] + [name for name, width in blocks for _ in range(width)]
if model_type in {"interactions", "quadratic"}:
for i, (first, width_i) in enumerate(blocks):
for second, width_j in blocks[i + 1 :]:
terms += [f"{first}:{second}"] * (width_i * width_j)
if model_type == "quadratic":
terms += [f"{f.name}^2" for f in region.continuous]
return terms
def _missing_cells(region: _Region, term: str) -> str:
"""Name the pairs of levels no allowed run has, for an interaction of two factors the rules name."""
if region.rules is None or term.count(":") != 1:
return ""
first, second = term.split(":")
if first not in region.rules.levels or second not in region.rules.levels:
return ""
allowed, _ = region.rules.allowed_table()
seen = set(zip(allowed[first], allowed[second], strict=True))
missing = [
f"{first} = {a!r} with {second} = {b!r}"
for a in region.rules.levels[first]
for b in region.rules.levels[second]
if (a, b) not in seen
]
return f" (no allowed run has {'; '.join(missing)})" if missing else ""
def _check_candidates_support_model(counts: dict, f_fixed: np.ndarray, f_cand: np.ndarray, model_type: str) -> None:
"""Raise unless the candidates, with the fixed runs, can estimate the model."""
if np.linalg.matrix_rank(np.vstack([f_fixed, f_cand])) < f_cand.shape[1]:
raise ValueError(
f"The feasible region ({counts['n_candidates']} candidate points) cannot support a "
f"'{model_type}' model with {f_cand.shape[1]} coefficients. Use a simpler model, supply "
"candidates, or loosen the constraints."
)
def _assemble_design(
region: _Region, factors: list[Factor], chosen: np.ndarray, chosen_cats: np.ndarray, fixed_runs: pd.DataFrame | None
) -> np.ndarray:
"""Return the design: the fixed runs, then the chosen ones, continuous factors coded and categorical as labels."""
design = pd.DataFrame(chosen, columns=[f.name for f in region.continuous])
for j, f in enumerate(region.categorical):
design[f.name] = np.asarray(f.levels, dtype=object)[chosen_cats[:, j]]
if fixed_runs is not None:
design = pd.concat([fixed_runs[design.columns].reset_index(drop=True), design], ignore_index=True)
design = design[[f.name for f in factors]]
return design.to_numpy() if region.categorical else design.to_numpy(dtype=float)