Source code for process_improve.experiments.optimization

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

"""Response optimization for designed experiments (Tool 4).

Find optimal factor settings for one or multiple responses after fitting
a model with :func:`analyze_experiment` (Tool 3).

Implemented methods
-------------------
- **desirability** - Derringer-Suich desirability functions (single and
  multi-response) with ``scipy.optimize.minimize`` (SLSQP).
- **steepest_ascent** / **steepest_descent** - Move along the gradient
  of a first-order model from the design centre.
- **stationary_point** - Locate the stationary point of a second-order
  model via ``numpy.linalg.solve``.
- **canonical_analysis** - Eigenvalue decomposition of the *B* matrix
  to classify the stationary point (max / min / saddle).
- **ridge_analysis** - Trace the constrained optimum along spheres of
  increasing radius, by solving Draper's secular equation for the
  Lagrange multiplier.
- **pareto_front** - The non-dominated set over several responses, via
  augmented Chebyshev scalarisation on a Das-Dennis weight lattice.
"""

from __future__ import annotations

import contextlib
import functools
import itertools
import logging
import math
import numbers
import re
import warnings
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import numpy as np
import pandas as pd
from patsy import PatsyError
from scipy import optimize

from process_improve._random import check_random_state
from process_improve._warnings import user_stacklevel
from process_improve.experiments._desirability import (
    SCORED_GOALS,
    check_goal,
    check_importances,
    individual_desirability,
    overall_desirability,
)
from process_improve.experiments._uniform_sampling import UniformSampler

if TYPE_CHECKING:
    from process_improve.experiments.region import DesignRegion

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_METHODS = {
    "desirability",
    "steepest_ascent",
    "steepest_descent",
    "stationary_point",
    "canonical_analysis",
    "ridge_analysis",
    "pareto_front",
}

# ---------------------------------------------------------------------------
# Model evaluation layer
# ---------------------------------------------------------------------------


_POWER = re.compile(r"I\((\w+)\s*\*\*\s*(\d+)\)|(?:np\.)?power\((\w+)\s*,\s*(\d+)\)")


def _parse_term(term: str) -> tuple[str, ...]:
    """Classify a coefficient term name into its components.

    Returns
    -------
    tuple[str, ...]
        Empty tuple for ``"Intercept"``, single-element for linear,
        ``("A", "B")`` for interaction ``"A:B"``, ``("A", "A")`` for
        quadratic ``"I(A ** 2)"``, and each factor of a product of powers
        repeated as often as the power multiplies it: ``("A", "A", "A")`` for
        ``"I(A ** 3)"``, ``("A", "B", "B")`` for ``"A:I(B ** 2)"``.
    """
    if term == "Intercept":
        return ()

    # A power is ``I(A ** 2)`` (older statsmodels) or ``np.power(A, 2)`` /
    # ``power(A, 2)`` (newer). Both spellings appear in the wild depending on
    # the installed statsmodels / patsy version. SEC-27 (#276): if either is
    # missed, the term silently falls through to the linear branch and the
    # downstream surface / optimisation produces wrong results.
    components: list[str] = []
    for part in term.split(":"):
        m = _POWER.fullmatch(part.strip())
        if m:
            components += [m.group(1) or m.group(3)] * int(m.group(2) or m.group(4))
        else:
            components.append(part)
    return tuple(components)


def _parsed_terms(
    coefficients: list[dict[str, Any]],
    factor_names: list[str],
    *,
    max_order: int | None = None,
) -> list[tuple[tuple[str, ...], float]]:
    """Parse every coefficient's term, refusing terms the model evaluation cannot represent.

    Parameters
    ----------
    coefficients : list[dict]
        Each with ``"term"`` and ``"coefficient"``.
    factor_names : list[str]
        The factors a term may be built from.
    max_order : int or None
        The largest number of factors a term may multiply together; 2 for the
        second-order analyses, which work with ``b`` and ``B`` only. ``None``
        allows any product.

    Returns
    -------
    list[tuple[tuple[str, ...], float]]
        The parsed components of each term, with its coefficient.

    Raises
    ------
    ValueError
        If a term names something other than a product of whole powers of the
        factors (a categorical ``C(A)[T.1]``, a misspelt factor), or has more than
        *max_order* components (``A:B:C`` or ``I(A ** 3)`` in a second-order
        analysis, which would otherwise be dropped and the analysis run on a
        truncated model).
    """
    parsed = [(_parse_term(entry["term"]), float(entry["coefficient"])) for entry in coefficients]
    known = set(factor_names)
    unknown = [e["term"] for e, (parts, _) in zip(coefficients, parsed, strict=True) if not set(parts) <= known]
    if unknown:
        msg = (
            f"Cannot evaluate the model term(s) {unknown}: a term must be the intercept, a factor, or a product of "
            "factors and their whole powers such as 'A:B', 'I(A ** 2)' or 'A:I(B ** 2)', over the factors "
            f"{factor_names}."
        )
        raise ValueError(msg)
    if max_order is not None:
        too_high = [e["term"] for e, (parts, _) in zip(coefficients, parsed, strict=True) if len(parts) > max_order]
        if too_high:
            msg = (
                f"This analysis needs a second-order model, with linear, two-factor interaction and squared "
                f"terms only; the model also has {too_high}. Use method='desirability' or 'pareto_front', "
                "which evaluate any polynomial, or refit without those terms."
            )
            raise ValueError(msg)
    return parsed


def _build_model_evaluator(
    coefficients: list[dict[str, Any]],
    factor_names: list[str],
) -> Callable[[np.ndarray], float]:
    """Return a function ``f(point) -> float`` that evaluates the model.

    Parameters
    ----------
    coefficients : list[dict]
        Each dict has ``"term"`` and ``"coefficient"`` keys, as returned
        by ``analyze_experiment(..., analysis_type="coefficients")``.
    factor_names : list[str]
        Ordered factor names (e.g. ``["A", "B"]``).

    Returns
    -------
    callable
        ``f(x)`` where *x* is a 1-D array of coded factor values in the
        same order as *factor_names*.
    """
    name_to_idx = {n: i for i, n in enumerate(factor_names)}
    parsed = _parsed_terms(coefficients, factor_names)

    def _eval(x: np.ndarray) -> float:
        y = 0.0
        for components, coef in parsed:
            if len(components) == 0:
                # Intercept
                y += coef
            elif len(components) == 1:
                # Linear
                y += coef * x[name_to_idx[components[0]]]
            elif len(components) == 2:
                # Interaction or quadratic
                y += coef * x[name_to_idx[components[0]]] * x[name_to_idx[components[1]]]
            else:
                # Higher-order (unusual but handle gracefully)
                val = 1.0
                for c in components:
                    val *= x[name_to_idx[c]]
                y += coef * val
        return y

    return _eval


[docs] def evaluate_model( coefficients: list[dict[str, Any]], factor_names: list[str], point: dict[str, float], ) -> float: """Evaluate predicted response at an arbitrary coded point. Parameters ---------- coefficients : list[dict] Coefficient list from ``analyze_experiment``. factor_names : list[str] Ordered factor names. point : dict[str, float] Factor settings in coded units, e.g. ``{"A": 0.5, "B": -1.0}``. Returns ------- float Predicted response value. """ f = _build_model_evaluator(coefficients, factor_names) x = np.array([point[n] for n in factor_names], dtype=float) return float(f(x))
# --------------------------------------------------------------------------- # Extract b vector and B matrix from second-order model # --------------------------------------------------------------------------- def _extract_b_and_B( # noqa: N802 coefficients: list[dict[str, Any]], factor_names: list[str], ) -> tuple[float, np.ndarray, np.ndarray]: """Extract intercept, linear vector *b* and quadratic matrix *B*. For a second-order model ``y = b0 + b'x + x'Bx``, returns ``(b0, b, B)`` where *B* is symmetric with off-diagonal elements equal to half the interaction coefficients. Raises ------ ValueError If a term is not part of a second-order model in *factor_names*; see :func:`_parsed_terms`. """ k = len(factor_names) name_to_idx = {n: i for i, n in enumerate(factor_names)} b0 = 0.0 b = np.zeros(k) B = np.zeros((k, k)) for components, coef in _parsed_terms(coefficients, factor_names, max_order=2): if len(components) == 0: b0 = coef elif len(components) == 1: b[name_to_idx[components[0]]] = coef else: # two components: _parsed_terms(max_order=2) refuses anything longer i = name_to_idx[components[0]] j = name_to_idx[components[1]] if i == j: # Quadratic term: coefficient is the diagonal of B B[i, i] = coef else: # Interaction: split equally across B[i,j] and B[j,i] B[i, j] = coef / 2.0 B[j, i] = coef / 2.0 return b0, b, B # --------------------------------------------------------------------------- # Stationary point # --------------------------------------------------------------------------- #: An eigenvalue of *B* this small, relative to the largest in magnitude, is #: zero: the surface is flat along its eigenvector (a ridge), not curved. The #: default of the ``flat_tolerance`` argument. _FLAT_EIGENVALUE_TOL = 1e-8 #: An eigenvalue below this share of the largest in size, though above the flat #: tolerance, gets a note: the surface is nearly flat along its eigenvector, and #: the data may not tell it from a ridge (#789 B3). _NEARLY_FLAT = 0.05 @dataclass class _SurfaceShape: """The shape of a second-order surface ``b0 + b'x + x'Bx``, read off the eigenvalues of *B*.""" classification: str flat: np.ndarray # True for an eigenvalue that counts as zero drifts: bool # b has a component along a flat direction, so there is no stationary point ridge_of: str | None = None # "maxima" or "minima" for a ridge system, else None def _surface_shape( b: np.ndarray, eigenvalues: np.ndarray, eigenvectors: np.ndarray, flat_tolerance: float = _FLAT_EIGENVALUE_TOL ) -> _SurfaceShape: """Classify the surface, treating eigenvalues at most ``flat_tolerance`` of the largest in size as a ridge. All curved directions bending down gives a maximum, all bending up a minimum, and both a saddle. When some eigenvalue is zero the surface is a ridge system (Myers, Montgomery and Anderson-Cook, *Response Surface Methodology*, sec. 6.4): a *stationary ridge*, a line or plane of equal optima, when *b* has no component along the flat directions, and a *rising ridge*, with no stationary point, when it has one and the response keeps changing along them. The slope along the flat directions is judged as their eigenvalues are: at most ``flat_tolerance`` of the largest eigenvalue in size is none, so a ridge whose estimated slope is small is stationary. """ scale = float(np.abs(eigenvalues).max()) flat = np.abs(eigenvalues) <= flat_tolerance * scale slope = float(np.linalg.norm(eigenvectors[:, flat].T @ b)) drifts = slope > max(_FLAT_EIGENVALUE_TOL * max(1.0, float(np.linalg.norm(b))), flat_tolerance * scale) curved = eigenvalues[~flat] if np.all(curved < 0): kind, ridge_of = "maximum", "maxima" elif np.all(curved > 0): kind, ridge_of = "minimum", "minima" else: return _SurfaceShape("saddle_point", flat, drifts) if not flat.any(): return _SurfaceShape(kind, flat, drifts) return _SurfaceShape("rising_ridge" if drifts else "stationary_ridge", flat, drifts, ridge_of) def _eigen_by_size(B: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Return the eigenvalues of *B*, largest in size first, and their eigenvectors as columns in the same order.""" eigenvalues, eigenvectors = np.linalg.eigh(B) order = np.argsort(-np.abs(eigenvalues), kind="stable") return eigenvalues[order], eigenvectors[:, order] def _refuse_unused_factors(coefficients: list[dict[str, Any]], factor_names: list[str]) -> None: """Refuse a factor name that no model term uses: it would add a zero eigenvalue to *B* (#796 item 4). A row and column of zeros in *B* reads as a direction along which the surface is flat, so a maximum would be called a stationary ridge. Such a name is not a factor of the model: a block column, or a copy of a factor in other units. """ used = {part for parts, _ in _parsed_terms(coefficients, factor_names) for part in parts} unused = [name for name in factor_names if name not in used] if unused: msg = ( f"No term of the model uses {unused}, so the second-order analysis would read each as a direction " "along which the surface is flat, and could call a maximum or minimum a ridge. Leave them out of " "factor_names." ) raise ValueError(msg) def _nearly_flat_note(eigenvalues: np.ndarray, shape: _SurfaceShape) -> str | None: """Say when a curved direction has an eigenvalue below :data:`_NEARLY_FLAT` of the largest in size.""" scale = float(np.abs(eigenvalues).max()) small = [i for i, ev in enumerate(eigenvalues) if not shape.flat[i] and abs(ev) < _NEARLY_FLAT * scale] if not small: return None sizes = ", ".join(f"{eigenvalues[i]:.4g} ({100 * abs(eigenvalues[i]) / scale:.2g}% of the largest)" for i in small) return ( f"The surface is nearly flat along the eigenvector of the eigenvalue {sizes}: the data may not tell this " f"{shape.classification.replace('_', ' ')} from a ridge. flat_tolerance={_NEARLY_FLAT} reads it as flat." ) def _find_stationary_point( coefficients: list[dict[str, Any]], factor_names: list[str], factor_ranges: dict[str, dict[str, float]] | None = None, search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None = None, flat_tolerance: float = _FLAT_EIGENVALUE_TOL, ) -> dict[str, Any]: """Find the stationary point of a second-order response surface model. Solves ``2*B*x_s + b = 0`` for ``x_s``. Parameters ---------- coefficients : list[dict] Model coefficients. factor_names : list[str] Ordered factor names. factor_ranges : dict or None Maps factor name to ``{"low": float, "high": float}`` in actual units. Used to convert coded -> actual. search_bounds : tuple[float, float] or dict[str, tuple[float, float]] or None Coded-unit region used to decide ``inside_design_space``. Pass a single ``(low, high)`` tuple to apply the same bounds to every factor, or a per-factor dict to give each factor its own bounds. Defaults to the factorial cube ``(-1, 1)`` for each factor when ``None``; supply a wider region for e.g. a central composite design's axial distance. flat_tolerance : float An eigenvalue of ``B`` at most this share of the largest in size is read as zero, a direction along which the surface is flat. Returns ------- dict ``stationary_point_coded``, ``predicted_response``, ``classification`` (``"maximum"``, ``"minimum"``, ``"saddle_point"`` or ``"stationary_ridge"``), ``eigenvalues`` (list of floats, spectrum of the pure-quadratic matrix ``B``, largest in size first, as :func:`_canonical_analysis` gives them), and ``inside_design_space`` (bool, whether the stationary point falls inside ``search_bounds``). Also includes ``stationary_point_actual`` when ``factor_ranges`` is provided. For a stationary ridge, where an eigenvalue is zero and every point along its eigenvector is equally good, the point reported is the one nearest the design centre and ``ridge_of`` says whether the ridge is one of ``"maxima"`` or ``"minima"``. ``classification_note`` says when an eigenvalue is small next to the largest though not flat, so the surface may be a ridge. Returns a dict with an ``error`` key instead when the model has no quadratic or interaction terms, or when there is no stationary point (a rising ridge, or a saddle that drifts along a flat direction); the latter also carries ``classification`` and ``eigenvalues``. """ b0, b, B = _extract_b_and_B(coefficients, factor_names) # Check that B has quadratic terms (not purely first-order) if np.allclose(B, 0): return {"error": "Model has no quadratic or interaction terms - cannot find stationary point."} _refuse_unused_factors(coefficients, factor_names) eigenvalues, eigenvectors = _eigen_by_size(B) shape = _surface_shape(b, eigenvalues, eigenvectors, flat_tolerance) if shape.drifts: return { "error": ( "No stationary point: B has a zero eigenvalue and the linear terms keep the response changing along " "its eigenvector (a rising ridge). Use 'ridge_analysis' or steepest ascent/descent to follow it." ), "classification": shape.classification, "eigenvalues": [float(e) for e in eigenvalues], } # Solve 2*B*x_s = -b in the eigenbasis. Along a flat direction b has no # component, so any step there is equally stationary; take none, which gives # the stationary point nearest the centre. curved = ~shape.flat x_s = eigenvectors[:, curved] @ (-0.5 * (eigenvectors[:, curved].T @ b) / eigenvalues[curved]) # Predicted response at stationary point y_s = float(b0 + b @ x_s + x_s @ B @ x_s) # Is the stationary point inside the region the experiment covered? The # default region is the factorial cube; a central composite design reaches # further, so its axial distance can be supplied via search_bounds. region = _resolve_search_bounds(search_bounds, factor_names) inside_design_space = bool(all(low <= value <= high for value, (low, high) in zip(x_s, region, strict=True))) result: dict[str, Any] = { "stationary_point_coded": {n: float(x_s[i]) for i, n in enumerate(factor_names)}, "predicted_response": y_s, "classification": shape.classification, "eigenvalues": [float(e) for e in eigenvalues], "inside_design_space": inside_design_space, } if shape.ridge_of is not None: result["ridge_of"] = shape.ridge_of if note := _nearly_flat_note(eigenvalues, shape): result["classification_note"] = note if factor_ranges: result["stationary_point_actual"] = _coded_to_actual(result["stationary_point_coded"], factor_ranges) return result # --------------------------------------------------------------------------- # Canonical analysis # --------------------------------------------------------------------------- def _canonical_analysis( coefficients: list[dict[str, Any]], factor_names: list[str], flat_tolerance: float = _FLAT_EIGENVALUE_TOL, ) -> dict[str, Any]: """Canonical analysis of a second-order response surface model. Computes eigenvalues and eigenvectors of the *B* matrix to determine the shape and orientation of the response surface, largest eigenvalue in size first. An eigenvalue at most ``flat_tolerance`` of the largest in size is read as zero. Returns ------- dict ``eigenvalues``, ``eigenvectors``, ``classification`` (``"maximum"``, ``"minimum"``, ``"saddle_point"``, ``"stationary_ridge"`` or ``"rising_ridge"``), and ``canonical_form_description``, which labels each canonical axis concave, convex, or flat (a zero eigenvalue). A ridge also carries ``ridge_of``, ``"maxima"`` or ``"minima"``, and ``classification_note`` says when an eigenvalue is small next to the largest though not flat. ``ridge_form`` is the surface in the ridge form of Box and Draper (2007, Eq. 11.1.2), the form a ridge is read in: y = y0 + theta * X_k + sum of lambda_i X_i^2, with X_i = v_i'(x - x0). Its axis ``k`` is that of the smallest eigenvalue in size, the ridge's direction, and its ``origin`` x0 is the point nearest the design centre where the response stops changing along every other axis that is not flat. It holds the ``origin`` (coded), the ``origin_response`` y0 there, the ``linear`` coefficient theta along the ridge, v_k'b, and each axis's ``offset``, the constant c_i of the plane X_i = v_i'x + c_i = 0. The form is exact for any surface: a maximum, minimum or saddle is a ridge whose theta is zero and whose smallest eigenvalue is not. The one exception is a slope along a flat axis other than ``k``, which only a surface with several flat axes can have, and which the form leaves out (#971 K2). It is not Box and Draper's B canonical form, which is centred on the stationary point (below). When the surface has a stationary point x_s, ``canonical_axes`` gives each canonical variable W_i = v_i'(x - x_s) as its ``constant``, -v_i'x_s, and ``coefficients``: Box and Draper's B canonical form is y = y_s + sum of lambda_i W_i^2. Their A canonical form, the axes rotated about the design centre with a slope theta_i = v_i'b along each, y = b0 + sum of theta_i X_i + sum of lambda_i X_i^2, is not returned. """ b0, b, B = _extract_b_and_B(coefficients, factor_names) if np.allclose(B, 0): return {"error": "Model has no quadratic or interaction terms - canonical analysis not applicable."} _refuse_unused_factors(coefficients, factor_names) eigenvalues, eigenvectors = _eigen_by_size(B) shape = _surface_shape(b, eigenvalues, eigenvectors, flat_tolerance) desc_parts = [] for i, (ev, is_flat) in enumerate(zip(eigenvalues, shape.flat, strict=True)): direction = "flat" if is_flat else ("concave" if ev < 0 else "convex") desc_parts.append(f"W{i + 1}: eigenvalue={ev:.4f} ({direction})") result: dict[str, Any] = { "eigenvalues": [float(e) for e in eigenvalues], "eigenvectors": [[float(v) for v in eigenvectors[:, i]] for i in range(len(eigenvalues))], "classification": shape.classification, "canonical_form_description": desc_parts, "factor_names": factor_names, "ridge_form": _ridge_form((b0, b, B), eigenvalues, eigenvectors, factor_names, shape.flat), } if not shape.drifts: # a stationary point exists: the B canonical form's axes are centred on it curved = ~shape.flat x_s = eigenvectors[:, curved] @ (-0.5 * (eigenvectors[:, curved].T @ b) / eigenvalues[curved]) result["canonical_axes"] = [ { "constant": float(-eigenvectors[:, i] @ x_s), "coefficients": dict(zip(factor_names, map(float, eigenvectors[:, i]), strict=True)), } for i in range(len(eigenvalues)) ] if shape.ridge_of is not None: result["ridge_of"] = shape.ridge_of if note := _nearly_flat_note(eigenvalues, shape): result["classification_note"] = note return result def _ridge_form( surface: tuple[float, np.ndarray, np.ndarray], eigenvalues: np.ndarray, eigenvectors: np.ndarray, factor_names: list[str], flat: np.ndarray, ) -> dict[str, Any]: """Return Box and Draper's ridge form (Eq. 11.1.2), taking the last axis (the smallest eigenvalue) as the ridge. The origin x0 = -1/2 sum over the other curved axes of (v_i'b / lambda_i) v_i is the point nearest the centre where the gradient has no component along them, so the form keeps one linear term, along the ridge. An axis whose eigenvalue is *flat* has no such point and is left out, as the last axis is (#965). *surface* is the fitted quadratic's intercept, linear vector b and matrix B. """ b0, b, B = surface others = ~flat others[-1] = False origin = eigenvectors[:, others] @ (-0.5 * (eigenvectors[:, others].T @ b) / eigenvalues[others]) return { "ridge_axis": len(eigenvalues) - 1, "origin": dict(zip(factor_names, map(float, origin), strict=True)), "origin_response": float(b0 + b @ origin + origin @ B @ origin), "linear": float(eigenvectors[:, -1] @ b), "offsets": [float(-eigenvectors[:, i] @ origin) for i in range(len(eigenvalues))], } # --------------------------------------------------------------------------- # Steepest ascent / descent # --------------------------------------------------------------------------- def _steepest_path( coefficients: list[dict[str, Any]], factor_names: list[str], step_size: float = 0.5, n_steps: int = 10, direction: str = "ascent", factor_ranges: dict[str, dict[str, float]] | None = None, ) -> dict[str, Any]: """Return the path of steepest ascent (or descent) from the design centre, for ``optimize_responses``. :func:`~process_improve.experiments.sequential.steepest_path` sets the path out; this steps ``step_size``, a distance in coded units, as ``optimize_responses`` does. Parameters ---------- coefficients : list[dict] Model coefficients. factor_names : list[str] Ordered factor names. step_size : float Euclidean distance, in coded units, between successive points on the path (default 0.5). Each step moves every factor in proportion to its linear coefficient, so a factor moves by ``step_size * b_j / ||b||``. Must be positive. n_steps : int Number of steps to take away from the design centre (default 10). The returned ``steps`` list has ``n_steps + 1`` entries because it also includes step 0 at the centre. direction : str ``"ascent"`` or ``"descent"``. factor_ranges : dict or None For coded → actual conversion. Returns ------- dict The path as :func:`~process_improve.experiments.sequential.steepest_path` returns it, or ``{"error": ...}`` when every linear coefficient is zero. Raises ------ ValueError If *step_size* is not a positive finite number or *n_steps* < 1: a negative step would walk the path the wrong way. """ from process_improve.experiments.sequential import NoSteepestDirectionError, steepest_path # noqa: PLC0415 if not (np.isfinite(step_size) and step_size > 0): msg = f"step_size must be a positive number of coded units; got {step_size}." raise ValueError(msg) if n_steps < 1: msg = f"n_steps must be at least 1; got {n_steps}." raise ValueError(msg) model = {"coefficients": coefficients, "factor_names": factor_names, "factor_coding": factor_ranges or {}} goal = "minimize" if direction == "descent" else "maximize" try: return steepest_path(model, step_size, n_steps, goal=goal) except NoSteepestDirectionError: return {"error": "All linear coefficients are zero - no steepest direction."} def _resolve_search_bounds( search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None, factor_names: list[str], ) -> list[tuple[float, float]]: """Return per-factor coded bounds for the region to search. The default of (-1, 1) is the factorial cube, which is the right region for a two-level design. It is not the right region for a central composite design, whose axial runs sit at plus or minus alpha: restricting the search to the cube there would refuse to consider settings the experiment actually covered. Pass the design's axial distance to search the whole region. Parameters ---------- search_bounds : tuple, dict, or None A single ``(low, high)`` pair applied to every factor, or a mapping from factor name to its own pair. Factors absent from the mapping fall back to (-1, 1). ``None`` means (-1, 1) throughout. factor_names : list[str] Ordered factor names. Returns ------- list[tuple[float, float]] One ``(low, high)`` pair per factor, in *factor_names* order. Raises ------ ValueError If a pair is malformed, non-finite, or has low >= high, or if the mapping names a factor the model does not have. """ default = (-1.0, 1.0) def _check(pair: Sequence[float], where: str) -> tuple[float, float]: try: low, high = (float(pair[0]), float(pair[1])) except (TypeError, ValueError, IndexError, KeyError) as exc: msg = f"search_bounds{where} must be a (low, high) pair of numbers; got {pair!r}." raise ValueError(msg) from exc if not (np.isfinite(low) and np.isfinite(high)): msg = f"search_bounds{where} must be finite; got ({low}, {high})." raise ValueError(msg) if low >= high: msg = f"search_bounds{where} must have low < high; got ({low}, {high})." raise ValueError(msg) return low, high if search_bounds is None: return [default] * len(factor_names) if isinstance(search_bounds, dict): unknown = set(search_bounds) - set(factor_names) if unknown: msg = f"search_bounds names unknown factor(s) {sorted(unknown)}; the model has {factor_names}." raise ValueError(msg) return [ _check(search_bounds[name], f"[{name!r}]") if name in search_bounds else default for name in factor_names ] return [_check(search_bounds, "")] * len(factor_names) def _align_goals_to_models( fitted_models: list[dict[str, Any]], goals: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Return *goals* reordered to match *fitted_models*. When every model names its response and every goal names one, the goals are reordered by name, and names that do not pair up one to one are an error: pairing them by position instead would optimise each response against another response's goal whenever the lists are in different orders. When either side leaves a name out, the goals are taken in list order, with a warning when there is more than one response, since position is then the only reading left. Parameters ---------- fitted_models : list[dict] Each optionally has ``"response_name"``. goals : list[dict] Each optionally has ``"response"``. Returns ------- list[dict] Goals in the same order as *fitted_models*. Raises ------ ValueError If the two lists differ in length, or if both sides name every response but the names do not correspond one to one (a typo, a case difference, or a duplicate). """ if len(goals) != len(fitted_models): msg = f"Got {len(fitted_models)} fitted model(s) but {len(goals)} goal(s); they must correspond one to one." raise ValueError(msg) model_names = [m.get("response_name") for m in fitted_models] goal_names = [g.get("response") for g in goals] if any(n is None for n in model_names) or any(n is None for n in goal_names): if len(goals) > 1: warnings.warn( "Matching goals to fitted models by position: not every model has 'response_name' and not every " "goal has 'response'. Name both to have them matched by name instead.", UserWarning, stacklevel=user_stacklevel(), ) return goals model_keys = [str(n) for n in model_names] by_name = {str(g["response"]): g for g in goals} if len(by_name) != len(goals) or len(set(model_keys)) != len(model_keys) or set(by_name) != set(model_keys): unmatched_goals = sorted(set(by_name) - set(model_keys)) unmatched_models = sorted(set(model_keys) - set(by_name)) msg = ( f"The goals' 'response' names {[str(n) for n in goal_names]} do not correspond one to one with the " f"models' 'response_name' values {model_keys}: goal name(s) {unmatched_goals} match no model and " f"model name(s) {unmatched_models} match no goal, and each name must appear once on each side. " "Fix the names (they are case-sensitive), or leave 'response' out of every goal to pair them by position." ) raise ValueError(msg) return [by_name[key] for key in model_keys] @dataclass class _SearchSpace: """Where SLSQP may look: bounds, scipy constraint dicts, and feasible starting points.""" bounds: list[tuple[float, float]] constraints: list[dict[str, Any]] starts: list[np.ndarray] def _search_space( factor_names: list[str], search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None, region: DesignRegion | None, rng: np.random.Generator, n_starts: int, ) -> _SearchSpace: """Translate a search box, and optionally a :class:`DesignRegion`, into SLSQP inputs. Without a region the search is the coded box, started from its centre and from random points in it. With a box region its constraints become inequality constraints on top of that box. A mixture region replaces the box: its component bounds become the bounds, ``sum(x) = 1`` an equality constraint, and its linear constraints inequalities. A mixture-process region does both: the components' part as a mixture region's, the process factors' as a box region's. The starts are then drawn uniformly from the feasible set itself, since a start outside it can leave SLSQP stranded at an infeasible point. """ bounds = _resolve_search_bounds(search_bounds, factor_names) lows, highs = np.array(bounds).T if region is None: starts = [(lows + highs) / 2.0, *(rng.uniform(lows, highs) for _ in range(n_starts - 1))] return _SearchSpace(bounds, [], starts) if region.rules is not None: example = ", ".join(f"{name}={levels[0]!r}" for name, levels in region.rules.levels.items()) msg = ( f"The region's constraints on the categorical factors {region.rules.factors} give one region of " f"settings per allowed combination of levels; search one of them, as region.given({example})." ) raise ValueError(msg) if set(region.names) != set(factor_names): msg = f"The region's factors {region.names} do not match the model's factors {factor_names}." raise ValueError(msg) idx = [factor_names.index(n) for n in region.names] # region column j is model column idx[j] components = [factor_names.index(n) for n in region.mixture_names] for j in components: bounds[j] = region.bounds[region.names.index(factor_names[j])] constraints: list[dict[str, Any]] = [ {"type": "ineq", "fun": lambda x, g=g: -float(g(x[idx][None, :])[0])} for g in region.inequalities ] if components: constraints.append({"type": "eq", "fun": lambda x: float(x[components].sum() - 1.0)}) if region.kind != "box": samples = region.sample(n_starts, rng) else: # uniform over the search box cut by the constraints, which may reach beyond the coded cube box = (lows[idx], highs[idx]) samples = UniformSampler( region.inequalities, box, lambda m: rng.uniform(*box, size=(m, len(idx))), seeds=region.seed_points, tolerances=region._widen(), # so a start passes region.feasible() ).draw(n_starts, rng) starts = [np.empty(len(factor_names)) for _ in range(n_starts)] for start, sample in zip(starts, samples, strict=True): start[idx] = sample return _SearchSpace(bounds, constraints, starts) def _region_for_method(region: DesignRegion | None, method: str) -> DesignRegion | None: """Return ``region`` for the methods that search inside one, warning and dropping it otherwise.""" if region is not None and method not in {"desirability", "pareto_front"}: warnings.warn( f"region is honoured by 'desirability' and 'pareto_front' only; it is ignored by {method!r}, " "which works over search_bounds instead.", UserWarning, stacklevel=user_stacklevel(), ) return None return region def _multistart_results( objective: Callable[[np.ndarray], float], space: _SearchSpace, accept: Callable[[np.ndarray], bool], options: dict[str, Any] | None = None, ) -> list[optimize.OptimizeResult]: """Run SLSQP from every start in ``space``; return the accepted results, best first (ties in start order).""" results = [] for x0 in space.starts: res = optimize.minimize( objective, x0, method="SLSQP", bounds=space.bounds, constraints=space.constraints, options=options ) if accept(res.x) and np.isfinite(res.fun): results.append(res) return sorted(results, key=lambda res: res.fun) def _multistart_slsqp( objective: Callable[[np.ndarray], float], space: _SearchSpace, accept: Callable[[np.ndarray], bool], options: dict[str, Any] | None = None, ) -> optimize.OptimizeResult | None: """Run SLSQP from every start in ``space``; return the best accepted result, or ``None``.""" results = _multistart_results(objective, space, accept, options) return results[0] if results else None def _within_region(region: DesignRegion | None, factor_names: list[str], x: np.ndarray) -> bool | None: """Whether ``x`` (model column order) lies in ``region``; ``None`` without a region. A box region's own ``[-1, 1]`` cube is not imposed: the search box (``search_bounds``) bounds the search, and the region adds its constraints on top. Nor is it for the process factors of a mixture-process region, whose components must still sum to 1. """ if region is None: return None point = x[[factor_names.index(n) for n in region.names]][None, :] if region.kind == "mixture": return bool(region.feasible(point, tol=1e-6)[0]) inside = all(float(g(point)[0]) <= 1e-6 for g in region.inequalities) components = [region.names.index(n) for n in region.mixture_names] return inside and abs(float(point[0, components].sum()) - 1.0) <= 1e-6 if components else inside def _ramp_values(y_values: Sequence[float], goals: list[dict[str, Any]]) -> np.ndarray: """Return the unclipped linear desirability ramps, all positive exactly where every d is. A maximise goal contributes ``(y - low) / (high - low)``, a minimise goal ``(high - y) / (high - low)``, a target goal both of its sides, and an in-range goal both of its margins, each over the width of the range. Unlike the desirabilities themselves, these keep a slope outside the acceptable limits, so they show which way to move from a setting where some response scores 0. """ pieces: list[float] = [] for y, goal in zip(y_values, goals, strict=True): low, high = float(goal["low"]), float(goal["high"]) if goal["goal"] == "maximize": pieces.append((y - low) / (high - low)) elif goal["goal"] == "minimize": pieces.append((high - y) / (high - low)) elif goal["goal"] == "in_range": pieces.extend(((y - low) / (high - low), (high - y) / (high - low))) else: target = float(goal["target"]) pieces.extend(((y - low) / (target - low), (high - y) / (high - target))) return np.array(pieces) def _closest_to_specification( evaluators: list[Callable[[np.ndarray], float]], goals: list[dict[str, Any]], space: _SearchSpace, accept: Callable[[np.ndarray], bool], ) -> np.ndarray | None: """Return the setting that maximises the smallest unclipped ramp, a start with every d > 0 if one exists. A Derringer-Suich desirability is exactly 0, with zero gradient, wherever its response lies outside the acceptable limits, so a gradient search started there cannot leave. Tight limits can leave the region where the composite is positive small enough for every random start to miss it. Maximising ``t`` subject to every ramp being at least ``t`` (and ``t <= 1``, beyond which every desirability is already 1) is smooth everywhere and reaches that region whenever the search finds a setting with ``t > 0``; when none exists, the result is the setting that comes closest to meeting every limit. """ n = len(space.bounds) def ramps(z: np.ndarray) -> np.ndarray: return _ramp_values([f(z[:n]) for f in evaluators], goals) constraints = [{"type": "ineq", "fun": lambda z: ramps(z) - z[n]}] constraints += [{**c, "fun": lambda z, c=c: c["fun"](z[:n])} for c in space.constraints] best: tuple[float, np.ndarray] | None = None for x0 in space.starts: z0 = np.append(x0, min(1.0, float(ramps(np.append(x0, 0.0)).min()))) res = optimize.minimize( lambda z: -z[n], z0, method="SLSQP", bounds=[*space.bounds, (None, 1.0)], constraints=constraints ) x = res.x[:n] t = float(ramps(res.x).min()) if np.all(np.isfinite(res.x)) and accept(x) and (best is None or t > best[0]): best = (t, x) return None if best is None else best[1] #: How far, as a fraction of its range, an in-range response may sit outside its limits at an accepted point. _LIMIT_TOL = 1e-6 def _response_limits( evaluators: list[Callable[[np.ndarray], float]], goals: list[dict[str, Any]] ) -> list[dict[str, Any]]: """Return SLSQP constraints that hold each ``"in_range"`` response within its limits. Each margin is divided by the width of the range, so the constraints of responses in different units are on one scale. """ constraints: list[dict[str, Any]] = [] for f, goal in zip(evaluators, goals, strict=True): if goal["goal"] != "in_range": continue low, high = float(goal["low"]), float(goal["high"]) width = high - low constraints.append({"type": "ineq", "fun": lambda x, f=f, low=low, width=width: (f(x) - low) / width}) constraints.append({"type": "ineq", "fun": lambda x, f=f, high=high, width=width: (high - f(x)) / width}) return constraints def _check_desirability_goals(goals: list[dict[str, Any]]) -> None: """Check every goal, and that at least one scores a response rather than only limiting it.""" for goal in goals: check_goal(goal) if not any(goal["goal"] in SCORED_GOALS for goal in goals): msg = ( "Every goal is 'in_range', which only limits a response: give at least one response a goal of " "'maximize', 'minimize' or 'target' to optimise." ) raise ValueError(msg) def _no_optimum(region: DesignRegion | None, limits: list[dict[str, Any]]) -> Exception: """Return the error for a search in which no start ended at an accepted point.""" if limits: return ValueError( "No setting in the search region holds every 'in_range' response within its limits; widen " "search_bounds or the limits." ) return RuntimeError("optimization produced no result" if region is None else "no start converged inside the region") #: An alternative's composite desirability is at least this share of the best's. _ALTERNATIVE_SHARE = 0.95 #: An alternative lies at least this far, in coded units, from the best and from every other alternative. _ALTERNATIVE_DISTANCE = 0.5 @dataclass(frozen=True) class _Scored: """What a desirability result reports at a setting: the models and their goals, and how settings are shown.""" factor_names: list[str] names: list[str] evaluators: list[Callable[[np.ndarray], float]] goals: list[dict[str, Any]] importances: list[float] | None factor_ranges: dict[str, dict[str, float]] | None region: DesignRegion | None def at(self, x: np.ndarray) -> dict[str, Any]: """Return the settings, the predicted responses and their desirabilities at coded settings ``x``.""" predictions = {name: float(f(x)) for name, f in zip(self.names, self.evaluators, strict=True)} each = [individual_desirability(predictions[name], g) for name, g in zip(self.names, self.goals, strict=True)] point: dict[str, Any] = { "optimal_coded": {n: float(x[i]) for i, n in enumerate(self.factor_names)}, "predicted_responses": predictions, "individual_desirability": {name: float(d) for name, d in zip(self.names, each, strict=True)}, "composite_desirability": float(overall_desirability(each, self.goals, self.importances)), } if self.region is not None: point["within_region"] = _within_region(self.region, self.factor_names, x) if self.factor_ranges: point["optimal_actual"] = _coded_to_actual(point["optimal_coded"], self.factor_ranges) return point def _alternatives(results: list[optimize.OptimizeResult], n_alternatives: int) -> list[np.ndarray]: """Return up to ``n_alternatives`` other optima of a search: nearly as good as the best, and far from it. The results are taken best first. One is kept when its composite desirability is at least :data:`_ALTERNATIVE_SHARE` of the best's and it lies at least :data:`_ALTERNATIVE_DISTANCE` from the best and from every alternative kept before it, so each is a different choice. """ if n_alternatives == 0 or not results or -results[0].fun <= 0.0: return [] kept = [results[0].x] for res in results[1:]: if -res.fun < _ALTERNATIVE_SHARE * -results[0].fun or len(kept) > n_alternatives: break if min(float(np.linalg.norm(res.x - x)) for x in kept) >= _ALTERNATIVE_DISTANCE: kept.append(res.x) return kept[1:] def _optimize_desirability( # noqa: PLR0913 fitted_models: list[dict[str, Any]], goals: list[dict[str, Any]], factor_names: list[str], factor_ranges: dict[str, dict[str, float]] | None = None, importances: list[float] | None = None, random_state: int | np.random.Generator | None = 42, search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None = None, region: DesignRegion | None = None, *, align_goals: bool = True, n_alternatives: int = 0, ) -> dict[str, Any]: """Optimise composite desirability using scipy SLSQP. Parameters ---------- fitted_models : list[dict] Each has ``"coefficients"`` and ``"response_name"``. goals : list[dict] Per-response goals. Matched to *fitted_models* by response name when both sides supply one, otherwise by position. factor_names : list[str] Ordered factor names. factor_ranges : dict or None Factor bounds in actual units. importances : list[float] or None Relative importance of each response in the composite. This is not the same as a goal's ``weight``, which shapes that response's own ramp. search_bounds : tuple, dict, or None Coded region to search. Defaults to the factorial cube, (-1, 1). region : DesignRegion or None Constraints the optimum must satisfy; see :func:`_search_space`. align_goals : bool False when the caller has already aligned *goals* with :func:`_align_goals_to_models`, so a positional pairing is not warned about twice. n_alternatives : int How many other optima to report (see :func:`_alternatives`). Asking for any searches from ten more starts for each. Returns ------- dict Optimal settings, predicted responses, individual and composite desirability, ``within_region`` when a region is given, and ``alternatives`` when *n_alternatives* is above 0. """ if align_goals: goals = _align_goals_to_models(fitted_models, goals) _check_desirability_goals(goals) evaluators = [_build_model_evaluator(m["coefficients"], factor_names) for m in fitted_models] def neg_composite(x: np.ndarray) -> float: """Return the negated composite desirability at coded settings ``x``, for minimization.""" d_vals = [individual_desirability(f(x), goal) for f, goal in zip(evaluators, goals, strict=True)] return -overall_desirability(d_vals, goals, importances) # Multi-start: try centre + random points. # SEC-33 (#282): the hard-coded ``42`` moved to the public signature # ``random_state=42`` (default preserves the previous deterministic # behaviour). Resolved via the ENG-08 helper. rng = check_random_state(random_state) # Start from the centre of the searched region, then sample across it, so # that widening the bounds actually widens where the search looks. space = _search_space(factor_names, search_bounds, region, rng, n_starts=10 + 10 * n_alternatives) limits = _response_limits(evaluators, goals) space.constraints.extend(limits) def accept(x: np.ndarray) -> bool: within = _within_region(region, factor_names, x) is not False return within and all(c["fun"](x) >= -_LIMIT_TOL for c in limits) # Start first from the setting closest to meeting every limit: from there the # search can climb even when every other start sits where some d is 0, and on a # tie at D = 0 it is the setting reported. if (closest := _closest_to_specification(evaluators, goals, space, accept)) is not None: space.starts.insert(0, closest) results = _multistart_results(neg_composite, space, accept) if not results: raise _no_optimum(region, limits) best_result = results[0] composite_d = float(-best_result.fun) if composite_d <= 0.0: warnings.warn( "No setting in the search region was found where every response has a desirability above 0, so " "the composite desirability is 0. The setting reported is the one that came closest to meeting every " "goal's limits; widen search_bounds or relax the limits.", UserWarning, stacklevel=user_stacklevel(), ) names = [m.get("response_name", "response") for m in fitted_models] scored = _Scored(factor_names, names, evaluators, goals, importances, factor_ranges, region) result = scored.at(best_result.x) | {"optimizer_success": bool(best_result.success)} if n_alternatives: result["alternatives"] = [scored.at(x) for x in _alternatives(results, n_alternatives)] return result # --------------------------------------------------------------------------- # Ridge analysis # --------------------------------------------------------------------------- #: Below this, ``b`` counts as having no component along the leading eigenspace. _RIDGE_HARD_CASE_TOL = 1e-10 #: Initial distance from the spectrum when bracketing the Lagrange multiplier. _RIDGE_BRACKET_START = 1e-6 _RIDGE_BRACKET_MAX = 1e12 def _ridge_point( b: np.ndarray, eigenvalues: np.ndarray, eigenvectors: np.ndarray, radius: float, *, maximise: bool, ) -> tuple[np.ndarray, float]: r"""Return the constrained optimum on a sphere, and the multiplier that gives it. For ``y = b0 + b'x + x'Bx`` restricted to ``||x|| = radius``, the Lagrange condition is ``(B - mu*I) x = -b/2``. Writing ``g = V'b`` for the linear coefficients in the eigenbasis of *B*: .. math:: x(\mu) = -\tfrac{1}{2} (B - \mu I)^{-1} b, \qquad \lVert x(\mu) \rVert^2 = \sum_i \frac{g_i^2}{4 (\lambda_i - \mu)^2}. The Hessian of the Lagrangian is ``2 (B - mu*I)``, so the point is a constrained *maximum* exactly when ``mu > lambda_max``, and a constrained *minimum* when ``mu < lambda_min``. On either of those intervals every ``|lambda_i - mu|`` grows as ``mu`` moves away from the spectrum, so the radius is strictly monotone in ``mu`` and the multiplier for a given radius is unique. Finding it is therefore a one-dimensional root-find, not a search: this is Draper's ridge analysis, and equivalently the trust-region subproblem. The "hard case" is handled as well. When *b* has no component along the leading eigenspace the radius stays bounded as ``mu`` approaches that eigenvalue, so no interior multiplier exists; the optimum for any larger radius is the limiting point plus a step along the leading eigenvector. A model whose stationary point sits at the centre (``b = 0``) is the common instance, and its ridge runs straight along that eigenvector. Parameters ---------- b : np.ndarray Linear coefficients, shape (k,). eigenvalues : np.ndarray Ascending eigenvalues of *B*, shape (k,), as ``numpy.linalg.eigh`` returns them. eigenvectors : np.ndarray The matching eigenvectors in columns, shape (k, k). radius : float Radius of the sphere, in coded units. Must be positive. maximise : bool True traces the ridge of maxima, False the ridge of minima. Returns ------- tuple[np.ndarray, float] The optimal coded point on that sphere, and the multiplier ``mu`` that produced it. ``mu`` is the boundary eigenvalue in the hard case. """ g = eigenvectors.T @ b edge = float(eigenvalues[-1] if maximise else eigenvalues[0]) sign = 1.0 if maximise else -1.0 scale = max(1.0, float(np.abs(eigenvalues).max())) def point_at(mu: float) -> np.ndarray: return eigenvectors @ (-0.5 * g / (eigenvalues - mu)) leading = np.isclose(eigenvalues, edge, rtol=1e-10, atol=1e-12) if np.all(np.abs(g[leading]) <= _RIDGE_HARD_CASE_TOL * max(1.0, float(np.linalg.norm(g)))): limit = np.zeros_like(g) limit[~leading] = -0.5 * g[~leading] / (eigenvalues[~leading] - edge) limit_radius = float(np.linalg.norm(limit)) if limit_radius <= radius: step = np.sqrt(max(radius**2 - limit_radius**2, 0.0)) limit[leading] = step / np.sqrt(float(leading.sum())) return eigenvectors @ limit, edge # Bracket the multiplier strictly outside the spectrum, then solve. Work on # 1 / ||x(mu)||, which is smooth and near-linear in mu; ||x(mu)|| itself # blows up at the inner end of the bracket. def residual(mu: float) -> float: return 1.0 / float(np.linalg.norm(point_at(mu))) - 1.0 / radius offset = _RIDGE_BRACKET_START * scale while residual(edge + sign * offset) > 0.0: offset /= 4.0 inner = edge + sign * offset outer = edge + sign * offset while residual(outer) < 0.0 and abs(outer - edge) < _RIDGE_BRACKET_MAX: outer = edge + (outer - edge) * 4.0 lo, hi = sorted((inner, outer)) mu = float(optimize.brentq(residual, lo, hi, xtol=1e-14, rtol=8.9e-16)) return point_at(mu), mu def _ridge_analysis( coefficients: list[dict[str, Any]], factor_names: list[str], *, direction: str = "maximize", n_radii: int = 10, max_radius: float = 1.0, ) -> dict[str, Any]: """Trace the constrained optimum along spheres of increasing radius. When a second-order model's stationary point falls outside the region the experiment covered, or is a saddle, it is not a usable recommendation. The ridge answers the question actually being asked: *given that I will move no further than r from the centre, where is the best point and what does the model predict there?* Tracing r upwards shows how fast the prediction improves, which factors have to move to get it, and where the returns flatten off. Parameters ---------- coefficients : list[dict] Model coefficients, each with ``"term"`` and ``"coefficient"``. factor_names : list[str] Ordered factor names. direction : {"maximize", "minimize"} Which ridge to trace. n_radii : int How many radii to report, over and above the centre. max_radius : float The largest radius to trace, in coded units. ``optimize_responses`` derives it from ``search_bounds``: the factorial cube's default of (-1, 1) traces out to 1, and a rotatable central composite design's ``(-1.41, 1.41)`` traces out to its axial distance. Returns ------- dict ``direction``, ``eigenvalues``, ``max_radius``, ``stationary_point_radius`` (the distance to the unconstrained stationary point, beyond which the ridge stops moving because the constraint no longer binds; ``None`` when *B* is singular), and ``path``: one entry per radius, each carrying ``radius``, ``mu``, ``coded`` and ``predicted_response``. Returns a dict with a single ``error`` key when the model has no quadratic or interaction terms: there is then no curvature to trace, and steepest ascent is the right tool. Raises ------ ValueError If *direction* is not one of the two accepted values, *n_radii* < 1, or *max_radius* is not positive. References ---------- Draper, N.R. (1963). Ridge analysis of response surfaces. *Technometrics*, 5(4), 469-479. """ if direction not in ("maximize", "minimize"): msg = f"direction must be 'maximize' or 'minimize'; got {direction!r}." raise ValueError(msg) if n_radii < 1: msg = f"n_radii must be at least 1; got {n_radii}." raise ValueError(msg) if not max_radius > 0: msg = f"max_radius must be positive; got {max_radius}." raise ValueError(msg) b0, b, B = _extract_b_and_B(coefficients, factor_names) if np.allclose(B, 0): return { "error": ( "Model has no quadratic or interaction terms - ridge analysis needs curvature to trace. " "Use 'steepest_ascent' or 'steepest_descent' for a first-order model." ) } eigenvalues, eigenvectors = np.linalg.eigh(B) path: list[dict[str, Any]] = [] for radius in np.linspace(0.0, max_radius, n_radii + 1): if radius == 0.0: x, mu = np.zeros(len(factor_names)), float("nan") else: x, mu = _ridge_point(b, eigenvalues, eigenvectors, float(radius), maximise=direction == "maximize") entry: dict[str, Any] = { "radius": float(radius), "mu": mu, "coded": {n: float(x[i]) for i, n in enumerate(factor_names)}, "predicted_response": float(b0 + b @ x + x @ B @ x), } path.append(entry) stationary_radius: float | None = None with contextlib.suppress(np.linalg.LinAlgError): stationary_radius = float(np.linalg.norm(np.linalg.solve(2.0 * B, -b))) return { "direction": direction, "eigenvalues": [float(e) for e in eigenvalues], "max_radius": float(max_radius), "stationary_point_radius": stationary_radius, "path": path, } def _ridge_within_bounds( coefficients: list[dict[str, Any]], factor_names: list[str], search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None, direction: str, n_radii: int, ) -> dict[str, Any]: """Trace the ridge out to the farthest bound, flagging the points that leave the search box. The ridge lives on spheres around the design centre, so it can follow a box only as far as its largest ``|bound|``. Bounds that differ between factors, or that are not symmetric about 0, are therefore not followed: each path entry carries ``inside_search_bounds`` instead, and a warning says so. """ box = _resolve_search_bounds(search_bounds, factor_names) max_radius = max(max(abs(low), abs(high)) for low, high in box) ridge = _ridge_analysis(coefficients, factor_names, direction=direction, n_radii=n_radii, max_radius=max_radius) for entry in ridge.get("path", []): entry["inside_search_bounds"] = all( low - 1e-9 <= entry["coded"][name] <= high + 1e-9 for name, (low, high) in zip(factor_names, box, strict=True) ) if len(set(box)) > 1 or box[0][0] != -box[0][1]: warnings.warn( f"ridge_analysis traces spheres around the design centre out to radius {max_radius}, the " "largest |bound|; it cannot follow search_bounds that differ between factors or are not symmetric " "about 0. Path points outside the bounds have inside_search_bounds=False.", UserWarning, stacklevel=user_stacklevel(), ) return ridge # --------------------------------------------------------------------------- # Pareto front # --------------------------------------------------------------------------- #: Weight on the augmentation term in the Chebyshev scalarisation. Small enough #: not to distort the front, large enough to reject weakly-dominated points. _PARETO_AUGMENT = 1e-4 #: Relative tolerance when deciding that one objective vector dominates another. _PARETO_TOL = 1e-9 #: Below this, an objective is flat across the payoff table and is not rescaled. _SPREAD_FLOOR = 1e-12 #: Tighter than SLSQP's default, since each evaluation is a polynomial. _SLSQP_OPTIONS = {"ftol": 1e-12, "maxiter": 500} def _objective_senses(goals: list[dict[str, Any]]) -> list[tuple[str, float | None]]: """Map each goal onto ``(kind, target)``, where kind drives the scalarisation.""" senses: list[tuple[str, float | None]] = [] for goal in goals: kind = str(goal.get("goal", "maximize")).lower() if kind == "target": if goal.get("target") is None: msg = "A goal with goal='target' must supply 'target'." raise ValueError(msg) senses.append(("target", float(goal["target"]))) elif kind in ("maximize", "minimize"): senses.append((kind, None)) else: msg = f"Unknown goal {kind!r}; expected 'maximize', 'minimize' or 'target'." if kind == "in_range": msg += " To hold a response within limits, use method='desirability' with an 'in_range' goal." raise ValueError(msg) return senses def _as_utilities( values: np.ndarray, senses: list[tuple[str, float | None]], ) -> np.ndarray: """Turn raw predicted responses into quantities where more is always better. A ``"target"`` goal becomes the negated *squared* deviation rather than the negated absolute one: both are maximised at the target and both order points identically, but the square is differentiable there, which matters because SLSQP has to work through this point. """ out = np.empty_like(values) for i, (kind, target) in enumerate(senses): if kind == "maximize": out[i] = values[i] elif kind == "minimize": out[i] = -values[i] else: out[i] = -((values[i] - float(target)) ** 2) # type: ignore[arg-type] return out def _simplex_weights(n_objectives: int, n_points: int) -> np.ndarray: """Return weight vectors spread evenly over the unit simplex (Das-Dennis). For two objectives this is just ``n_points`` evenly spaced pairs. For more, it is the standard stars-and-bars lattice: every way of splitting *divisions* into *n_objectives* non-negative parts, where *divisions* is the smallest number giving at least *n_points* vectors. """ if n_objectives == 1: return np.ones((1, 1)) if n_objectives == 2: share = np.linspace(0.0, 1.0, max(n_points, 2)) return np.column_stack([share, 1.0 - share]) divisions = 1 while math.comb(divisions + n_objectives - 1, n_objectives - 1) < n_points: divisions += 1 rows = [ np.diff((0, *cuts, divisions + n_objectives)) - 1 for cuts in itertools.combinations(range(1, divisions + n_objectives), n_objectives - 1) ] return np.array(rows, dtype=float) / divisions def _non_dominated(utilities: np.ndarray) -> np.ndarray: """Return a boolean mask of the rows no other row dominates (more is better).""" keep = np.ones(len(utilities), dtype=bool) for i, row in enumerate(utilities): # Strictly better somewhere, and no worse anywhere: that dominates row i. scale = np.maximum(np.abs(row), 1.0) better_or_equal = (utilities >= row - _PARETO_TOL * scale).all(axis=1) strictly_better = (utilities > row + _PARETO_TOL * scale).any(axis=1) if (better_or_equal & strictly_better).any(): keep[i] = False return keep def _front_entries( points: np.ndarray, raw: Callable[[np.ndarray], np.ndarray], factor_names: list[str], names: list[str], ) -> list[dict[str, Any]]: """Describe each distinct front point, sorted by the first response. Neighbouring weight vectors often land on the same solution, and duplicates say nothing about the trade-off, so they are dropped here rather than reported as separate options. """ entries: list[dict[str, Any]] = [] seen: list[np.ndarray] = [] for x in points: if any(np.allclose(x, other, atol=1e-6) for other in seen): continue seen.append(x) values = raw(x) entry: dict[str, Any] = { "coded": {n: float(x[i]) for i, n in enumerate(factor_names)}, "responses": {name: float(values[i]) for i, name in enumerate(names)}, } entries.append(entry) entries.sort(key=lambda entry: entry["responses"][names[0]]) return entries def _payoff_table( best_of: Callable[[Callable[[np.ndarray], float]], np.ndarray], raw: Callable[[np.ndarray], np.ndarray], utility: Callable[[np.ndarray], np.ndarray], senses: list[tuple[str, float | None]], n_objectives: int, ) -> tuple[np.ndarray, np.ndarray]: """Optimise each objective alone and record what every response does there. The diagonal of the result is the ideal point; the worst entry in each column estimates the nadir. Together they set the scale each objective is normalised by, so responses in different units carry equal say in the scalarisation. Returns ------- tuple[np.ndarray, np.ndarray] The table in raw response units and in utility units, both (m, m). """ def negated(x: np.ndarray, index: int) -> float: return float(-utility(x)[index]) anchors = [best_of(functools.partial(negated, index=i)) for i in range(n_objectives)] payoff_raw = np.array([raw(x) for x in anchors]) return payoff_raw, np.array([_as_utilities(row, senses) for row in payoff_raw]) def _pareto_front( fitted_models: list[dict[str, Any]], goals: list[dict[str, Any]], factor_names: list[str], *, n_points: int = 21, search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None = None, random_state: int | np.random.Generator | None = 42, region: DesignRegion | None = None, ) -> dict[str, Any]: """Compute the Pareto front of several fitted response-surface models. Where ``"desirability"`` collapses the responses into one number and returns the single point that maximises it, this returns the whole set of non-dominated compromises: every setting where no response can be improved without giving something up elsewhere. Choosing among them is a judgement about trade-offs, and it is better made on the trade-offs themselves than on a weight chosen in advance. Method: augmented weighted Chebyshev scalarisation, solved with SLSQP from several starts, over a Das-Dennis lattice of weights, then filtered to the non-dominated set. Each response is normalised by the range of its own payoff table, so responses in different units carry equal say. NSGA-II would be the usual choice, and is the wrong one here. It is built for expensive black-box objectives, where a population is the only way to make progress. These objectives are low-order polynomials over a box, evaluated in microseconds and differentiable everywhere, so a gradient solver reaches each front point to solver tolerance rather than to whatever a finite population converged to, with no random seed and no generation count to tune. Chebyshev scalarisation is used rather than a weighted sum because a weighted sum can only ever find points on the convex hull of the front, and quadratic models routinely produce non-convex fronts. Parameters ---------- fitted_models : list[dict] Each with ``"coefficients"`` and ``"response_name"``. goals : list[dict] One per response, with ``"goal"`` in ``{"maximize", "minimize", "target"}`` and, for ``"target"``, a ``"target"`` value. Matched to *fitted_models* by response name when both sides name their responses, otherwise by position. The ``"low"`` / ``"high"`` desirability limits are not used: the front is computed on the predicted responses themselves. factor_names : list[str] Ordered factor names. n_points : int Target number of weight vectors. The front returned is usually smaller, because dominated and duplicate solutions are dropped. search_bounds : tuple, dict, or None The coded region to search. Defaults to the factorial cube, (-1, 1). random_state : int, Generator, or None Seed for the extra random starts. The default keeps the result reproducible. region : DesignRegion or None Constraints every front point must satisfy. Returns ------- dict ``objectives`` (response name and goal, in order), ``ideal`` and ``nadir`` (the payoff table's corners, in raw response units), ``n_weights``, and ``front``: the non-dominated points, each with ``coded`` and ``responses``, sorted by the first response. Raises ------ ValueError If fewer than two models are supplied (a front needs a trade-off), or a goal is malformed. """ if len(fitted_models) < 2: msg = ( f"A Pareto front needs at least two responses to trade off; got {len(fitted_models)}. " "Use 'desirability' or 'stationary_point' for a single response." ) raise ValueError(msg) goals = _align_goals_to_models(fitted_models, goals) senses = _objective_senses(goals) evaluators = [_build_model_evaluator(m["coefficients"], factor_names) for m in fitted_models] names = [str(m.get("response_name", f"response_{i + 1}")) for i, m in enumerate(fitted_models)] rng = check_random_state(random_state) if region is None: bounds = _resolve_search_bounds(search_bounds, factor_names) lows, highs = np.array(bounds).T starts = [(lows + highs) / 2.0, lows.copy(), highs.copy(), *(rng.uniform(lows, highs) for _ in range(5))] space = _SearchSpace(bounds, [], starts) else: space = _search_space(factor_names, search_bounds, region, rng, n_starts=8) def raw(x: np.ndarray) -> np.ndarray: return np.array([evaluator(x) for evaluator in evaluators]) def utility(x: np.ndarray) -> np.ndarray: return _as_utilities(raw(x), senses) def best_of(objective: Callable[[np.ndarray], float]) -> np.ndarray | None: # SLSQP's default ftol of 1e-6 leaves front points a visible distance # short of the true frontier. These objectives cost microseconds to # evaluate, so there is nothing to buy by stopping early. res = _multistart_slsqp( objective, space, lambda x: _within_region(region, factor_names, x) is not False, _SLSQP_OPTIONS ) return None if res is None else res.x def anchor(objective: Callable[[np.ndarray], float]) -> np.ndarray: if (x := best_of(objective)) is None: raise RuntimeError("Pareto front: no start converged inside the region for a single objective.") return x payoff_raw, payoff_utility = _payoff_table(anchor, raw, utility, senses, len(names)) ideal_utility = payoff_utility.diagonal().copy() nadir_utility = payoff_utility.min(axis=0) spread = np.where(np.abs(ideal_utility - nadir_utility) > _SPREAD_FLOOR, ideal_utility - nadir_utility, 1.0) def chebyshev(x: np.ndarray, w: np.ndarray) -> float: gap = w * (ideal_utility - utility(x)) / spread return float(gap.max() + _PARETO_AUGMENT * gap.sum()) weights = _simplex_weights(len(names), n_points) found = [best_of(functools.partial(chebyshev, w=w)) for w in weights] points = np.array([x for x in found if x is not None]) # a weight with no feasible optimum adds no point keep = _non_dominated(np.array([utility(x) for x in points])) front = _front_entries(points[keep], raw, factor_names, names) return { "objectives": [{"response": name, "goal": senses[i][0]} for i, name in enumerate(names)], "ideal": {name: float(payoff_raw[j, j]) for j, name in enumerate(names)}, "nadir": {name: float(payoff_raw[int(np.argmin(payoff_utility[:, j])), j]) for j, name in enumerate(names)}, "n_weights": len(weights), "front": front, } # --------------------------------------------------------------------------- # Coded ↔ actual conversion helpers # --------------------------------------------------------------------------- def _add_actual_units( result: dict[str, Any], factor_ranges: dict[str, dict[str, float]] | None, key: str, ) -> dict[str, Any]: """Add an ``"actual"`` reading to every entry under *key*, in place. Coded-to-actual conversion is reporting, not optimisation, so the methods that build these lists do not carry *factor_ranges* through their own signatures just to decorate their output at the end. """ if factor_ranges: for entry in result.get(key, []): entry["actual"] = _coded_to_actual(entry["coded"], factor_ranges) return result def _coded_to_actual(coded: dict[str, float], factor_ranges: dict[str, dict[str, float]]) -> dict[str, float]: """Convert coded factor settings to actual units, as plain Python floats.""" actual = {} for name, coded_val in coded.items(): if name in factor_ranges: lo = float(factor_ranges[name]["low"]) hi = float(factor_ranges[name]["high"]) actual[name] = (lo + hi) / 2.0 + float(coded_val) * (hi - lo) / 2.0 else: actual[name] = float(coded_val) return actual # --------------------------------------------------------------------------- # Public API - dispatcher # --------------------------------------------------------------------------- def _intervals_at_point( fitted_results: list[Any], fitted_models: list[dict[str, Any]], factor_names: list[str], point_coded: dict[str, float], significance_level: float, ) -> dict[str, Any]: """Confidence and prediction intervals for each response at one point. The optimizer works from coefficients alone, which is enough to locate an optimum but not to say how well it is known. The residual variance and the design's leverage at that point are needed for that, and both live on the fitted model object rather than in its coefficients. Parameters ---------- fitted_results : list Statsmodels results objects, aligned with *fitted_models*, fitted on the coded factors. fitted_models : list[dict] Used only for the response names. factor_names : list[str] Ordered factor names, matching the columns the models were fitted on. point_coded : dict[str, float] Coded factor settings at which to report the intervals. significance_level : float Alpha. 0.05 gives 95% intervals. Returns ------- dict Keyed by response name. Each entry has ``predicted``, ``confidence_interval``, ``prediction_interval``, and ``confidence_level``. A response whose model cannot be evaluated carries an ``error`` string instead, so one failure does not discard the intervals for the others. """ from process_improve.experiments._analyses.prediction import _run_prediction # noqa: PLC0415 if len(fitted_results) != len(fitted_models): msg = ( f"Got {len(fitted_models)} fitted model(s) but {len(fitted_results)} fitted result(s); " "they must correspond one to one and be in the same order." ) raise ValueError(msg) new_point = pd.DataFrame([{name: point_coded[name] for name in factor_names}]) intervals: dict[str, Any] = {} for i, (results_obj, model) in enumerate(zip(fitted_results, fitted_models, strict=True)): resp_name = model.get("response_name", f"Response {i + 1}") try: record = _run_prediction(results_obj, new_point, alpha=significance_level)["predictions"][0] except (AttributeError, KeyError, TypeError, ValueError, PatsyError) as exc: logger.warning("Could not compute intervals for response %r: %s", resp_name, exc) intervals[resp_name] = {"error": str(exc)} continue intervals[resp_name] = { "predicted": record["predicted"], "confidence_interval": [record["ci_low"], record["ci_high"]], "prediction_interval": [record["pi_low"], record["pi_high"]], "confidence_level": 1.0 - significance_level, } return intervals #: Methods that analyse the surface of one fitted model. _SINGLE_RESPONSE_METHODS = { "stationary_point", "canonical_analysis", "steepest_ascent", "steepest_descent", "ridge_analysis", } def _span(coding: dict[str, Any]) -> tuple[float, float]: return float(coding["low"]), float(coding["high"]) def _ranges_from_coding( fitted_models: list[dict[str, Any]], factor_ranges: dict[str, dict[str, float]] | None, components: Sequence[str] = (), ) -> dict[str, dict[str, float]] | None: """Check that each model is on the coded scale, and add the factor ranges its coding implies. ``analyze_experiment`` reports the scale of its coefficients as ``coding``, and how each factor maps to -1 and +1 as ``factor_coding``. The optimiser evaluates models at coded settings, so a model whose coefficients are in actual units would be searched over the wrong region (-1 to +1 degC, say). A coded model instead says which actual settings its coded optimum stands for. A model without these keys, such as one written out by hand, is taken to be coded, as before. Mixture ``components`` (those of the region searched) are proportions on either scale, so they are left out. Raises ------ ValueError If a model's coefficients are in actual units, if two models code a factor from different ranges, or if *factor_ranges* disagrees with a model's coding. """ implied: dict[str, dict[str, float]] = {} for i, model in enumerate(fitted_models): coding = {f: how for f, how in (model.get("factor_coding") or {}).items() if f not in components} name = model.get("response_name", f"fitted_models[{i}]") if coding and model.get("coding") == "actual": spans = ", ".join(f"{factor} from {how['low']} to {how['high']}" for factor, how in coding.items()) msg = ( f"The coefficients of {name!r} are in actual units ({spans}), but optimize_responses evaluates " "models at coded settings, -1 to +1. Fit it with analyze_experiment(..., coding='coded'), or with " "coding set to the factor ranges, and pass that result. A split-plot fit is made on the runs as " "given: code its factors to -1 and +1 before fitting it." ) raise ValueError(msg) for factor, how in coding.items(): if not all(isinstance(how[end], numbers.Real) for end in ("low", "high")): continue # a categorical factor's two levels: its setting stays coded known = implied.setdefault(factor, {"low": float(how["low"]), "high": float(how["high"])}) if not np.allclose(_span(known), _span(how)): msg = ( f"The models code {factor} from different ranges ({known['low']} to {known['high']}, and " f"{how['low']} to {how['high']} for {name!r}), so a coded setting does not mean the same in " "each. Fit them all with the same ranges: analyze_experiment(..., coding={...})." ) raise ValueError(msg) for factor, given in (factor_ranges or {}).items(): if factor in implied and not np.allclose(_span(given), _span(implied[factor])): msg = ( f"factor_ranges gives {factor} from {given['low']} to {given['high']}, but the coefficients are coded " f"with {factor} from {implied[factor]['low']} to {implied[factor]['high']}. Leave factor_ranges out, " "or fit with analyze_experiment(..., coding=factor_ranges) so that the two agree." ) raise ValueError(msg) return {**implied, **(factor_ranges or {})} or factor_ranges def _check_fitted_models(fitted_models: list[dict[str, Any]], method: str) -> None: """Check that *fitted_models* carries what *method* needs, naming what is missing. Raises ------ ValueError If the list is empty, a model lacks ``"coefficients"`` or ``"factor_names"``, or a single-response method gets more than one model (it would otherwise analyse the first and ignore the rest). """ if not fitted_models: msg = "At least one fitted model is required." raise ValueError(msg) for i, model in enumerate(fitted_models): if not isinstance(model, dict): msg = f"fitted_models[{i}] must be a dict; got {type(model).__name__}." raise TypeError(msg) if "coefficients" not in model: msg = ( f"fitted_models[{i}] has no 'coefficients'. Run analyze_experiment with 'coefficients' among its " "analysis_type values, and pass its result." ) raise ValueError(msg) if "factor_names" not in model: msg = ( f"fitted_models[{i}] has no 'factor_names', the ordered factors its coefficients refer to " "(analyze_experiment returns them alongside 'coefficients'). Add e.g. 'factor_names': ['A', 'B']." ) raise ValueError(msg) if method in _SINGLE_RESPONSE_METHODS and len(fitted_models) > 1: names = [m.get("response_name", f"model {i}") for i, m in enumerate(fitted_models)] msg = ( f"method={method!r} analyses one response; got {len(fitted_models)} models ({names}). " "Call it once per response, or use 'desirability' or 'pareto_front' to optimise them together." ) raise ValueError(msg) def _check_alternatives(n_alternatives: int) -> int: """Return ``n_alternatives``, refusing anything but a whole number of at least 0.""" if isinstance(n_alternatives, bool) or not isinstance(n_alternatives, int) or n_alternatives < 0: msg = f"n_alternatives must be a whole number of at least 0; got {n_alternatives!r}." raise ValueError(msg) return n_alternatives def _desirability_result( # noqa: PLR0913 *, fitted_models: list[dict[str, Any]], goals: list[dict[str, Any]], factor_names: list[str], factor_ranges: dict[str, dict[str, float]] | None, response_importance: list[float] | None, fitted_results: list[Any] | None, significance_level: float, search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None = None, region: DesignRegion | None = None, random_state: int | np.random.Generator | None = 42, n_alternatives: int = 0, ) -> dict[str, Any]: """Assemble the full desirability result: optimum, intervals, and plot input. Returns ------- dict The optimum from :func:`_optimize_desirability`, plus ``"response_intervals"`` when *fitted_results* is supplied, plus ``"responses"``, which pairs each model's coefficients with its specification limits so the result can be passed straight to the overlay plot. """ aligned_goals = _align_goals_to_models(fitted_models, goals) for goal in aligned_goals: check_goal(goal) importances = response_importance if importances is None: importances = [g.get("importance", 1.0) for g in aligned_goals] check_importances(importances, len(fitted_models)) desirability = _optimize_desirability( fitted_models, aligned_goals, factor_names, factor_ranges, importances, random_state=random_state, search_bounds=search_bounds, region=region, align_goals=False, n_alternatives=n_alternatives, ) if fitted_results is not None: desirability["response_intervals"] = _intervals_at_point( fitted_results, fitted_models, factor_names, desirability["optimal_coded"], significance_level ) carried = ("goal", "low", "high", "target", "weight", "weight_high", "importance") desirability["responses"] = [ { "name": model.get("response_name", f"Response {i + 1}"), "coefficients": model.get("coefficients", []), **{key: goal[key] for key in carried if key in goal}, } for i, (model, goal) in enumerate(zip(fitted_models, aligned_goals, strict=True)) ] return desirability
[docs] def optimize_responses( # noqa: PLR0913 fitted_models: list[dict[str, Any]], goals: list[dict[str, Any]] | None = None, method: str = "desirability", factor_ranges: dict[str, dict[str, float]] | None = None, step_size: float = 0.5, n_steps: int = 10, response_importance: list[float] | None = None, fitted_results: list[Any] | None = None, significance_level: float = 0.05, search_bounds: tuple[float, float] | dict[str, tuple[float, float]] | None = None, ridge_direction: str = "maximize", n_pareto_points: int = 21, region: DesignRegion | None = None, random_state: int | np.random.Generator | None = 42, n_alternatives: int = 0, flat_tolerance: float = _FLAT_EIGENVALUE_TOL, ) -> dict[str, Any]: """Find optimal factor settings for one or multiple responses. Parameters ---------- fitted_models : list[dict] One model for the single-response methods (``"stationary_point"``, ``"canonical_analysis"``, ``"steepest_ascent"``, ``"steepest_descent"``, ``"ridge_analysis"``), one or more for ``"desirability"`` and two or more for ``"pareto_front"``. The models are evaluated at coded settings, so their coefficients must be on the coded scale: pass the result of ``analyze_experiment(..., analysis_type="coefficients", coding="coded")`` as it is. One fitted with ``coding="actual"`` on factors that are not already coded is refused, since its optimum would be searched for between -1 and +1 in actual units. Each dict describes a fitted model with keys: - ``"response_name"`` (str) - name of the response. - ``"coefficients"`` (list[dict]) - coefficient list, each with ``"term"`` and ``"coefficient"`` keys as returned by ``analyze_experiment(..., analysis_type="coefficients")``. - ``"factor_names"`` (list[str]) - ordered factor names. - ``"mse_residual"`` (float, optional) - mean squared error. - ``"r_squared"`` (float, optional) - model R-squared. - ``"coding"`` and ``"factor_coding"`` (optional) - the scale of the coefficients and each factor's coded range, as ``analyze_experiment`` reports them. A model without them is taken to be coded. goals : list[dict] or None Per-response optimisation goals. Each dict has keys: - ``"response"`` (str) - response name. Matched against each model's ``"response_name"``; when both sides name their responses the goals are reordered to match, so the two lists need not be in the same order, and names that do not pair up one to one raise a ``ValueError``. When either side omits a name, goals are taken in list order, with a warning. - ``"goal"`` (str) - ``"maximize"``, ``"minimize"``, ``"target"`` or ``"in_range"``. An ``"in_range"`` goal (Design-Expert's "is in range") holds the predicted response between ``low`` and ``high``: the search keeps to settings where it does, its individual desirability is 1 there, and it is left out of the composite. At least one goal must be of another kind. ``"pareto_front"`` takes the other three only. - ``"target"`` (float, optional) - target value (required when ``goal="target"``). - ``"low"`` (float) - lower acceptable bound. - ``"high"`` (float) - upper acceptable bound. - ``"weight"`` (float, default 1) - the exponent shaping *this* response's desirability ramp between ``low`` and ``high``. Above 1 concentrates desirability near the good end; below 1 flattens it. - ``"weight_high"`` (float, optional) - a separate exponent for the falling side of a ``"target"`` goal. Defaults to ``"weight"``. - ``"importance"`` (float, default 1) - how much this response counts relative to the others when the composite is formed. Unlike ``weight``, it has no effect on this response's own ramp. method : str Optimisation method: ``"desirability"``, ``"steepest_ascent"``, ``"steepest_descent"``, ``"stationary_point"``, ``"canonical_analysis"``, ``"ridge_analysis"``, ``"pareto_front"``. factor_ranges : dict or None Maps factor name to ``{"low": float, "high": float}`` in actual units. Used for coded ↔ actual conversion. A coded model from ``analyze_experiment`` supplies the ranges of its own factors, and a range given here for one of them must agree with it. step_size : float Step length for steepest ascent/descent: the Euclidean distance, in coded units, between successive points on the path. Must be positive. n_steps : int Number of steps along a path: the steepest ascent/descent steps, or the radii reported by ridge analysis over and above the centre. response_importance : list[float] or None Relative importance per response, overriding the per-goal ``"importance"`` values. Aligned with *fitted_models*. fitted_results : list or None Optional statsmodels results objects, one per entry in *fitted_models* and in the same order, as returned by ``lm()`` or by ``analyze_experiment``. When supplied, a confidence interval and a prediction interval for each response are reported at the optimum. The models must have been fitted on the coded factors, since the optimum is located in coded units. significance_level : float Alpha for those intervals. The default of 0.05 gives 95% intervals. search_bounds : tuple, dict, or None The coded region to search, and the region against which a stationary point is judged inside or outside. Defaults to the factorial cube, ``(-1, 1)`` on every factor. ``"ridge_analysis"`` traces spheres out to the largest ``|bound|`` and flags each path point with ``inside_search_bounds``, since a sphere cannot follow bounds that differ between factors or are asymmetric. That default suits a two-level design but understates a central composite design, whose axial runs sit at plus or minus alpha: leaving it at the cube would refuse to consider settings the experiment actually covered. Pass ``(-1.41, 1.41)`` for a two-factor rotatable central composite design, or a mapping such as ``{"T": (-1.41, 1.41)}`` to widen one factor only. Factors left out of a mapping keep the (-1, 1) default. ridge_direction : {"maximize", "minimize"} Which ridge ``method="ridge_analysis"`` traces. n_pareto_points : int Target number of weight vectors for ``method="pareto_front"``. The front returned is usually smaller, since dominated and duplicate solutions are dropped. region : DesignRegion or None The region the optimum must lie in, for ``"desirability"`` and ``"pareto_front"``: the same constraints the design was built under (``DesignRegion.from_dict(result.metadata["region"])``), so the recommended settings are ones the process can run. For a box region the constraints apply on top of *search_bounds*. For a mixture region the factors are the proportions the Scheffé model was fitted on, their bounds replace *search_bounds*, and the optimum sums to 1. The desirability result then carries ``within_region``. Other methods ignore it, with a warning. random_state : int, Generator, or None Seed for the random starting points of the multistart search used by ``"desirability"`` and ``"pareto_front"``; the other methods are deterministic. The default of 42 keeps results reproducible; try another seed to check that an optimum does not depend on where the search began. n_alternatives : int For ``"desirability"``: how many other settings that do almost as well to report beside the best, under ``"alternatives"``. Each is a local optimum of the multistart search whose composite desirability is at least 95% of the best's, at least 0.5 apart in coded units from the best and from every other alternative, best first. Asking for any searches from ten more starts for each. The default of 0 reports none and leaves the search as it was. flat_tolerance : float For ``"stationary_point"`` and ``"canonical_analysis"``: an eigenvalue of the pure-quadratic matrix ``B`` at most this share of the largest in size is read as zero, so the surface is classified as a ridge along its eigenvector, and so is the slope along that eigenvector: a ridge rises only when its slope is above this share of the largest eigenvalue. The default, 1e-8, reads only rounding as zero. An estimated eigenvalue is rarely exactly zero, and the sources read a small one as flat: Box, Hunter and Hunter's stationary ridge has eigenvalues -9.02 and 0.0017 and a slope of 0.005 along it, which ``flat_tolerance=0.05`` reads as a stationary ridge; their rising ridge, with a slope of 2.97, still rises. Below 5% of the largest, the result's ``classification_note`` says so. Returns ------- dict[str, Any] Results keyed by method. Always includes ``"method"`` and ``"factor_names"``. Raises ------ ValueError If *method* is unknown, if *flat_tolerance* is not in [0, 1), if *fitted_models* is empty or a model lacks ``"coefficients"`` or ``"factor_names"``, if ``"stationary_point"`` or ``"canonical_analysis"`` gets a factor name that no model term uses, if a single-response method (``"stationary_point"``, ``"canonical_analysis"``, ``"steepest_ascent"``, ``"steepest_descent"``, ``"ridge_analysis"``) gets more than one model, if a method that needs goals is called without them, or if a model's coefficients are in actual units, two models code a factor from different ranges, or *factor_ranges* disagrees with a model's coding. Examples -------- >>> from process_improve.experiments.optimization import optimize_responses >>> model = { ... "response_name": "yield", ... "coefficients": [ ... {"term": "Intercept", "coefficient": 40.0}, ... {"term": "A", "coefficient": 5.25}, ... {"term": "B", "coefficient": -2.0}, ... {"term": "I(A ** 2)", "coefficient": -3.0}, ... {"term": "I(B ** 2)", "coefficient": -1.5}, ... {"term": "A:B", "coefficient": 1.5}, ... ], ... "factor_names": ["A", "B"], ... } >>> result = optimize_responses( ... fitted_models=[model], ... method="stationary_point", ... ) >>> result["stationary_point"]["classification"] 'maximum' """ logger.debug("optimize_responses: method=%r, %d fitted model(s)", method, len(fitted_models)) if method not in _METHODS: available = sorted(_METHODS) msg = f"Unknown method {method!r}. Available: {available}" raise ValueError(msg) if not 0 <= flat_tolerance < 1: msg = f"flat_tolerance must be at least 0 and below 1, a share of the largest eigenvalue; got {flat_tolerance}." raise ValueError(msg) _check_fitted_models(fitted_models, method) factor_ranges = _ranges_from_coding(fitted_models, factor_ranges, region.mixture_names if region else ()) # Use factor_names from the first model as the canonical ordering factor_names = fitted_models[0]["factor_names"] coefficients = fitted_models[0]["coefficients"] result: dict[str, Any] = {"method": method, "factor_names": factor_names} region = _region_for_method(region, method) if method == "stationary_point": result["stationary_point"] = _find_stationary_point( coefficients, factor_names, factor_ranges, search_bounds, flat_tolerance ) elif method == "canonical_analysis": result["canonical_analysis"] = _canonical_analysis(coefficients, factor_names, flat_tolerance) # Also include the stationary point for context result["stationary_point"] = _find_stationary_point( coefficients, factor_names, factor_ranges, search_bounds, flat_tolerance ) elif method in ("steepest_ascent", "steepest_descent"): direction = "ascent" if method == "steepest_ascent" else "descent" result["steepest_path"] = _steepest_path( coefficients, factor_names, step_size, n_steps, direction, factor_ranges ) elif method == "desirability": if goals is None: msg = "Goals are required for desirability optimization." raise ValueError(msg) result["desirability"] = _desirability_result( fitted_models=fitted_models, goals=goals, factor_names=factor_names, factor_ranges=factor_ranges, response_importance=response_importance, fitted_results=fitted_results, significance_level=significance_level, search_bounds=search_bounds, region=region, random_state=random_state, n_alternatives=_check_alternatives(n_alternatives), ) elif method == "ridge_analysis": result["ridge_analysis"] = _add_actual_units( _ridge_within_bounds(coefficients, factor_names, search_bounds, ridge_direction, n_steps), factor_ranges, "path", ) else: # "pareto_front", the last of _METHODS, which every call was checked against if goals is None: msg = "Goals are required for Pareto front optimization." raise ValueError(msg) result["pareto_front"] = _add_actual_units( _pareto_front( fitted_models, goals, factor_names, n_points=n_pareto_points, search_bounds=search_bounds, random_state=random_state, region=region, ), factor_ranges, "front", ) return result