# (c) Kevin Dunn, 2010-2026. MIT License.
r"""Optimal designs on experimental units with known covariates, each unit used once.
Some experiments run on units that carry a measured property the experimenter cannot set: a
sample's composition, a field's slope, or the time at which a run is made. The units come from a
list, each can be used once, and the factors are set for each run on top. The model has the
covariates' main effects beside the factors' terms, and a design is judged on :math:`M = X^T X` of
the whole model (Goos and Jones, *Optimal Design of Experiments: A Case Study Approach*, chapter 9).
The units used and the factors' settings are found together by coordinate exchange: each run's
unit is swapped for the unused unit, and each run's setting of each factor for the level, that
improves the criterion most, until a pass changes nothing; the best of several random starts is
kept. A change replaces one row of the model matrix, so it changes :math:`M` by two outer products.
A time trend is the case where every unit is used: the units are the run positions, with the time
of each as its covariate, and the search places the factors' settings in time.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
import pandas as pd
from process_improve._random import check_random_state
from process_improve.experiments.designs_split_plot import (
_IMPROVEMENT,
_MAX_PASSES,
_RIDGE,
CRITERIA,
_build_model,
_criterion_value,
_Model,
_scores,
_settings,
)
from process_improve.experiments.designs_utils import categorical_effect_columns, sorted_level_ranks
if TYPE_CHECKING:
from process_improve.experiments.factor import Factor
[docs]
@dataclass(frozen=True)
class CovariateOptions:
"""How a design on units with covariates is searched for.
Attributes
----------
criterion : str
One of ``"d_optimal"``, ``"a_optimal"`` and ``"i_optimal"``.
model_type : str
The factors' terms: ``"main_effects"``, ``"interactions"`` or ``"quadratic"``. The
covariates enter by their main effects.
n_starts : int
Random starts; the best design of them is kept.
"""
criterion: str = "d_optimal"
model_type: str = "main_effects"
n_starts: int = 10
@dataclass(frozen=True)
class _Problem:
"""The factors' model, each unit's covariate columns, and the criterion with its moments."""
model: _Model
covariates: np.ndarray # (n_units, c)
moments: np.ndarray # (p, p), the factor box and the units' covariates together
criterion: str
@dataclass
class _State:
"""A design during the search: each run's unit and levels, the model rows, M and its criterion."""
units: np.ndarray
levels: np.ndarray
rows: np.ndarray
information: np.ndarray
score: float
[docs]
def covariate_design(
units: pd.DataFrame,
factors: list[Factor],
n_runs: int,
options: CovariateOptions,
random_state: int | np.random.Generator | None = None,
) -> tuple[pd.DataFrame, dict]:
"""Choose units, each at most once, and set the factors for each, optimally for a model with the covariates.
Parameters
----------
units : pandas.DataFrame
The candidate units, one per row, with a column per covariate: numeric covariates enter
the model as they are given (code them to -1 and +1 for a criterion on the coded scale),
and the others as categorical, effect-coded. The index names the units.
factors : list[Factor]
The factors set for each run: continuous (coded from -1 to +1) or categorical.
n_runs : int
The number of runs, at most the number of units. With every unit used, the search only
sets the factors: a time trend, the units being the run positions.
options : CovariateOptions
The criterion, the factors' model and the number of starts.
random_state : int, numpy.random.Generator or None
Seeds the random starts.
Returns
-------
tuple[pandas.DataFrame, dict]
The design, one run per row: the unit's covariates (indexed by the unit, in the order
of ``units``) and the factors' settings (continuous coded, categorical as labels); and
metadata: ``optimality_criterion``, ``model_type``, ``backend`` (``"covariate_exchange"``),
``units`` (the units used), ``n_starts``, and ``log_det_information`` for D or
``trace_criterion`` for A and I. The I criterion averages over the factor box and over
the candidate units.
Raises
------
ValueError
If the criterion is not one of :data:`CRITERIA`, there are no units, ``n_runs`` exceeds
their number or is below the model's number of terms, a factor shares a covariate's name,
or no choice of units estimates the model.
Examples
--------
A linear trend in time over eight runs: the 2^3 placed so that every main effect is
orthogonal to the trend.
>>> import numpy as np
>>> import pandas as pd
>>> from process_improve.experiments import Factor
>>> times = pd.DataFrame({"time": np.linspace(-1, 1, 8)})
>>> factors = [Factor(name=n, low=-1, high=1) for n in ("x1", "x2", "x3")]
>>> design, meta = covariate_design(times, factors, 8, CovariateOptions(), random_state=1)
>>> bool(np.allclose(design[["x1", "x2", "x3"]].T @ design["time"], 0.0))
True
"""
_check(units, factors, n_runs, options)
model = _build_model(factors, options.model_type)
covariates = _covariate_columns(units)
problem = _Problem(model, covariates, _moments(model, covariates), options.criterion)
if n_runs < len(model.parts) + covariates.shape[1]:
msg = f"{n_runs} runs cannot estimate the {len(model.parts) + covariates.shape[1]} terms of the model."
raise ValueError(msg)
rng = check_random_state(random_state)
best: _State | None = None
for _ in range(options.n_starts):
state = _climb(problem, _start(problem, len(units), n_runs, rng), rng)
if best is None or state.score < best.score:
best = state
assert best is not None # n_starts >= 1, checked above
information = best.rows.T @ best.rows
if np.linalg.matrix_rank(information) < information.shape[0]:
msg = f"No choice of {n_runs} of the {len(units)} units estimates the model."
raise ValueError(msg)
settings = pd.DataFrame(_settings(model, best.levels, factors), columns=[f.name for f in factors])
order = np.argsort(best.units, kind="stable")
design = units.iloc[best.units[order]].copy()
for name in settings.columns:
design[name] = settings[name].to_numpy()[order]
meta = {
"optimality_criterion": options.criterion,
"model_type": options.model_type,
"backend": "covariate_exchange",
"units": design.index.tolist(),
"n_starts": options.n_starts,
**_criterion_value(information, problem.moments, options.criterion),
}
return design, meta
def _check(units: pd.DataFrame, factors: list[Factor], n_runs: int, options: CovariateOptions) -> None:
"""Refuse a criterion, a unit list or a run count the search cannot work with."""
if options.criterion not in CRITERIA:
msg = f"A design on units with covariates is searched for on {list(CRITERIA)}; got {options.criterion!r}."
raise ValueError(msg)
if units.empty or not 1 <= n_runs <= len(units) or options.n_starts < 1:
msg = f"Choose from 1 to {len(units)} of the units, with at least one start; got n_runs={n_runs}."
raise ValueError(msg)
shared = sorted({f.name for f in factors} & set(units.columns))
if shared:
msg = f"The factors {shared} are also covariates of the units; a column is one or the other."
raise ValueError(msg)
def _covariate_columns(units: pd.DataFrame) -> np.ndarray:
"""Return the covariates' model columns: numeric ones as given, the others effect-coded."""
columns = []
for name in units.columns:
values = units[name]
if pd.api.types.is_numeric_dtype(values):
columns.append(values.to_numpy(dtype=float)[:, None])
else:
levels = sorted(pd.unique(values).tolist(), key=str)
ranks = sorted_level_ranks(levels)
codes = ranks[[levels.index(v) for v in values]]
columns.append(categorical_effect_columns(codes, len(levels)))
return np.hstack(columns) if columns else np.empty((len(units), 0))
def _moments(model: _Model, covariates: np.ndarray) -> np.ndarray:
"""Return the averages of the products of the model columns: over the factor box, and over the units."""
first = model.moments[:, 0] # the averages of the factors' columns, the first column being 1
mean = covariates.mean(axis=0)
second = covariates.T @ covariates / len(covariates)
return np.block([[model.moments, np.outer(first, mean)], [np.outer(mean, first), second]])
def _start(problem: _Problem, n_units: int, n_runs: int, rng: np.random.Generator) -> _State:
"""Return a random design: distinct units and random levels."""
units = rng.choice(n_units, size=n_runs, replace=False)
levels = np.column_stack([rng.integers(len(t), size=n_runs) for t in problem.model.tables])
rows = np.hstack([problem.model.rows(levels), problem.covariates[units]])
information = rows.T @ rows
return _State(units, levels, rows, information, float(_score(problem, information, n_runs)))
def _score(problem: _Problem, information: np.ndarray, n_runs: int) -> np.ndarray:
return _scores(information, problem.criterion, problem.moments, _RIDGE * n_runs)
def _try(state: _State, problem: _Problem, run: int, rows: np.ndarray) -> int | None:
"""Return which of the candidate rows for ``run`` improves the criterion most, if one does."""
old = state.rows[run]
candidates = state.information - np.outer(old, old) + np.einsum("li,lj->lij", rows, rows)
found = _score(problem, candidates, len(state.units))
best = int(np.argmin(found))
if found[best] < state.score - _IMPROVEMENT * max(1.0, abs(state.score)):
state.rows[run], state.information, state.score = rows[best], candidates[best], float(found[best])
return best
return None
def _swap(state: _State, problem: _Problem, run: int) -> bool:
"""Exchange ``run``'s unit with another run's, if one exchange improves the criterion; return whether it did.
For a time trend this moves a run to another time. It changes two rows of the model matrix,
which a change of one coordinate cannot do without losing the balance of a factor first.
"""
others = np.delete(np.arange(len(state.units)), run)
width = len(problem.model.parts)
mine = np.hstack(
[np.repeat(state.rows[run, :width][None], len(others), axis=0), problem.covariates[state.units[others]]]
)
theirs = np.hstack(
[state.rows[others, :width], np.repeat(problem.covariates[state.units[run]][None], len(others), 0)]
)
old_pairs = np.einsum("li,lj->lij", state.rows[others], state.rows[others]) + np.outer(
state.rows[run], state.rows[run]
)
new_pairs = np.einsum("li,lj->lij", mine, mine) + np.einsum("li,lj->lij", theirs, theirs)
candidates = state.information - old_pairs + new_pairs
found = _score(problem, candidates, len(state.units))
best = int(np.argmin(found))
if found[best] >= state.score - _IMPROVEMENT * max(1.0, abs(state.score)):
return False
other = others[best]
state.units[run], state.units[other] = state.units[other], state.units[run]
state.rows[run], state.rows[other] = mine[best], theirs[best]
state.information, state.score = candidates[best], float(found[best])
return True
def _improve_run(state: _State, problem: _Problem, run: int) -> bool:
"""Try every change to one run: an unused unit, another run's unit, each factor's levels."""
improved = False
unused = np.setdiff1d(np.arange(len(problem.covariates)), state.units)
if len(unused):
factor_part = np.repeat(state.rows[run, : len(problem.model.parts)][None], len(unused), axis=0)
choice = _try(state, problem, run, np.hstack([factor_part, problem.covariates[unused]]))
if choice is not None:
state.units[run], improved = unused[choice], True
improved = _swap(state, problem, run) or improved
for j, table in enumerate(problem.model.tables):
trial = np.repeat(state.levels[run][None], len(table), axis=0)
trial[:, j] = np.arange(len(table))
own = np.repeat(problem.covariates[state.units[run]][None], len(table), axis=0)
choice = _try(state, problem, run, np.hstack([problem.model.rows(trial), own]))
if choice is not None:
state.levels[run, j], improved = choice, True
return improved
def _climb(problem: _Problem, state: _State, rng: np.random.Generator) -> _State:
"""Improve the runs one at a time, in a random order, until a pass improves nothing."""
for _ in range(_MAX_PASSES):
improved = False
for run in rng.permutation(len(state.units)):
improved = _improve_run(state, problem, run) or improved
if not improved:
break
return state