Source code for process_improve.experiments.omars_trade_off

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

r"""The foldover OMARS trade-off: which model does a given run budget buy.

The two-level trade-off table in :mod:`process_improve.experiments.trade_off`
answers "I can afford N runs and want k factors, what do I give up?" with a
resolution and an alias structure. That currency does not transfer to OMARS
designs, because an OMARS design *always* has its main effects orthogonal to
each other and to every second-order term. Resolution is constant, so it cannot
be what the table reports.

What varies instead is **which model is estimable at all**, and that is set by
the foldover structure. A foldover is ``[H; -H; 0]``, and every second-order
term is an *even* function, so the quadratic and interaction columns of ``H``
and ``-H`` are identical. The even block therefore has at most ``h + 1``
distinct rows, against ``1 + k(k+1)/2`` columns for the full second-order model.
The main effects live in the odd block and contribute at most ``k`` more, so for
every foldover

.. math::

   \mathrm{rank}(X) \le k + \min\!\left(h + 1,\; 1 + \tfrac{k(k+1)}{2}\right)

with equality for half-designs in general position. The bound counts one centre
run. A foldover can carry none, or several: ``N = 2h + c`` for ``c`` centre
runs, and the even block sees ``h`` distinct rows plus one more when ``c >= 1``.
For a given ``N`` the most distinct rows come from one centre run when ``N`` is
odd, ``(N + 1) / 2`` of them, and from none or two when ``N`` is even, ``N / 2``.
An even budget therefore buys the model of the odd budget one run below it, with
one more error degree of freedom. Three capability classes follow, and they are
the OMARS analogue of resolution:

``Full``
    ``N >= k^2 + k + 1``, or ``k^2 + k + 2`` for an even ``N``. Main effects,
    pure quadratics and every two-factor interaction are jointly estimable, so
    a response surface can be fitted without a follow-up design.
``Quad``
    ``N >= 2k + 2``. Main effects and the pure quadratics, with degrees of
    freedom left to test them, so curvature can be judged factor by factor. The
    two-factor interactions are present in the design but not in the model.
    The smallest, ``2k + 2`` runs, is a definitive screening design with a
    second centre run.
``Satd``
    ``N = 2k + 1``. The definitive screening design at its minimal size:
    estimable but exactly saturated, so there are point estimates and no
    inference.

Alphabetically ``Full < Quad < Satd``, which is also decreasing capability, so
the ordering is easy to keep straight.

Every bound here is for **foldover** OMARS designs, the class that
:func:`~process_improve.experiments.generate_omars` builds. OMARS designs in
general (Nunez Ares and Goos, 2020) need not be foldovers, and a non-foldover
member can do better: a 19-run, four-factor OMARS design from which the full
second-order model is estimable exists, against the 21 runs a foldover needs.
The table therefore gives the smallest foldover size for each class, not the
smallest OMARS size.

``exists`` says that a foldover OMARS design of that size exists, not that
:func:`~process_improve.experiments.generate_omars` builds it. That generator
needs error degrees of freedom, so it refuses the saturated ``Satd`` size (for an
even ``k``, ``generate_design(design_type="dsd")`` gives that design); it needs
at least one centre run, so it builds an even size with two; and it repeats
half-runs only for three and four factors, up to a cap. Larger sizes in the
table, such as 39 runs for three factors, exist but are not built.

Every number here is closed-form, so the table is instant and exact: no integer
program, no solver, and no dependence on a search budget. The quality of a
*particular* design at a given size (its D-efficiency, its second-order
correlations) does need the ILP, and lives on
:func:`~process_improve.experiments.generate_omars` instead.

Also see
--------
process_improve.experiments.trade_off : the two-level counterpart.
process_improve.experiments.designs_omars_ilp.generate_omars : build the design.
process_improve.experiments.omars.analyze_omars : the staged analysis it feeds.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import pandas as pd

from process_improve.experiments.designs_omars_ilp import (
    _full_second_order_params,
    _min_runs,
)
from process_improve.experiments.strategy.budget import _BBD_RUNS

if TYPE_CHECKING:
    from collections.abc import Sequence

#: Capability classes, best first. The four-character tags line up in a table,
#: and sort alphabetically in decreasing order of capability.
CAPABILITIES: tuple[str, ...] = ("full", "quad", "satd")

_TAGS = {"full": "Full", "quad": "Quad", "satd": "Satd"}

_MEANINGS = {
    "full": "main effects, quadratics and all two-factor interactions jointly estimable",
    "quad": "main effects and pure quadratics, with error degrees of freedom to test them",
    "satd": "saturated: estimable but no error degrees of freedom, so no inference",
}

# OMARS designs need at least three factors (see ``generate_omars``). The upper
# bound is generous because nothing here enumerates runs; it is arithmetic.
_MIN_FACTORS = 3
_MAX_FACTORS = 25

#: Default rows: odd run counts spanning the useful band for three to seven
#: factors. The last one is the smallest budget at which every one of those
#: factor counts reaches ``Full``.
DEFAULT_RUNS: tuple[int, ...] = (9, 13, 17, 21, 25, 31, 37, 43, 57)

#: Default columns.
DEFAULT_FACTORS: tuple[int, ...] = (3, 4, 5, 6, 7)

#: Named standard designs the table can mark, smallest first. The definitive
#: screening design sits at the small end of the OMARS family and the
#: Box-Behnken design is among the largest, so marking both shows the span a
#: column covers rather than only its middle.
REFERENCE_DESIGNS: tuple[str, ...] = ("dsd", "bbd")

#: Orders up to 26 (the largest needed for 25 factors) at which no conference matrix
#: exists, by Belevitch's condition that ``m - 1`` be a sum of two squares.
_NO_CONFERENCE_MATRIX = frozenset({22})

#: Centre runs each Box-Behnken design conventionally carries. The design runs
#: themselves come from ``_BBD_RUNS``; these are the counts the published tables
#: quote alongside them, giving the familiar totals of 15, 27, 46, 54 and 62.
_BBD_CENTRE_RUNS: dict[int, int] = {3: 3, 4: 3, 5: 6, 6: 6, 7: 6}


[docs] @dataclass class OmarsTradeOffTableEntry: """What one run budget buys for one factor count. Attributes ---------- n_runs : int The run budget. n_factors : int Number of factors. exists : bool Whether any foldover OMARS design has this run count for this many factors. ``False`` for a run count below ``2k + 1``. capability : str ``"full"``, ``"quad"``, ``"satd"``, or ``"none"`` when *exists* is ``False``. tag : str The four-character table tag: ``"Full"``, ``"Quad"``, ``"Satd"``, or the empty string. model : str or None The largest model this run count supports: ``"full_second_order"``, ``"main_quadratic"``, or ``None``. model_params : int Parameter count of that model; zero when there is no design. error_df : int Degrees of freedom left for error after fitting it. Zero for ``Satd``. label : str Self-contained cell text, e.g. ``"Full df=11"``. min_runs_full : int Smallest run count reaching ``Full`` for this many factors, ``k^2 + k + 1``. min_runs_quad : int Smallest run count reaching ``Quad``, ``2k + 2``. min_runs_satd : int Smallest run count that is a design at all, ``2k + 1``. reason : str Why a cell is empty; empty string when *exists* is ``True``. """ n_runs: int n_factors: int exists: bool capability: str tag: str model: str | None model_params: int error_df: int label: str min_runs_full: int min_runs_quad: int min_runs_satd: int reason: str = ""
def _check_factors(n_factors: int) -> int: """Validate and return the factor count.""" if int(n_factors) != n_factors: raise ValueError('The "n_factors" input must be an integer.') k = int(n_factors) if not (_MIN_FACTORS <= k <= _MAX_FACTORS): raise ValueError(f"OMARS designs need {_MIN_FACTORS} to {_MAX_FACTORS} factors; got {k}.") return k
[docs] def omars_minimum_runs(n_factors: int, capability: str = "full") -> int: """Return the smallest foldover OMARS run count reaching *capability* for *n_factors*. The bound holds for foldover designs ``[H; -H; 0]``; a non-foldover OMARS design can reach a class with fewer runs (see the module docstring). Parameters ---------- n_factors : int Number of factors, ``k``, between 3 and 25. capability : {"full", "quad", "satd"}, default "full" Which class to reach. See the module docstring. Returns ------- int The run count: ``k^2 + k + 1`` for ``"full"``, ``2k + 2`` for ``"quad"``, ``2k + 1`` for ``"satd"``. The ``"quad"`` size is even: a foldover with two centre runs, one more than the saturated design. Raises ------ ValueError If *n_factors* is out of range or *capability* is not a known class. Examples -------- >>> omars_minimum_runs(5) 31 >>> omars_minimum_runs(5, "quad") 12 >>> [omars_minimum_runs(k) for k in (3, 4, 5, 6, 7)] [13, 21, 31, 43, 57] """ k = _check_factors(n_factors) if capability not in CAPABILITIES: raise ValueError(f"capability must be one of {CAPABILITIES}, got {capability!r}.") if capability == "full": return _min_runs(k) if capability == "quad": return 2 * k + 2 return 2 * k + 1
[docs] def definitive_screening_runs(n_factors: int) -> int: """Return the run count of the definitive screening design for *n_factors*. Parameters ---------- n_factors : int Number of factors, ``k``, between 3 and 25. Returns ------- int ``2m + 1`` for a conference matrix of order ``m``: ``2k + 1`` for an even *n_factors* and ``2k + 3`` for an odd one, except where no conference matrix of that order exists. Notes ----- The construction folds a conference matrix of order ``k``, available for an even ``k``. For an odd ``k`` it uses one of order ``k + 1`` and drops the last column, so the design arrives with two runs to spare and lands one capability class above saturated. No conference matrix of order 22 exists (Belevitch's condition: ``m - 1`` must be a sum of two squares), so 21 and 22 factors use order 24 and 49 runs. Examples -------- >>> [definitive_screening_runs(k) for k in (3, 4, 5, 6, 7)] [9, 9, 13, 13, 17] """ k = _check_factors(n_factors) order = k if k % 2 == 0 else k + 1 if order in _NO_CONFERENCE_MATRIX: order += 2 return 2 * order + 1
[docs] def box_behnken_runs(n_factors: int) -> int | None: """Return the run count of the published Box-Behnken design for *n_factors*. Parameters ---------- n_factors : int Number of factors, ``k``, between 3 and 25. Returns ------- int or None Design runs plus the centre runs the published tables quote, or ``None`` when Box and Behnken did not publish a design for that factor count. Examples -------- >>> [box_behnken_runs(k) for k in (3, 4, 5, 6, 7)] [15, 27, 46, 54, 62] >>> box_behnken_runs(8) is None True """ k = _check_factors(n_factors) design_runs = _BBD_RUNS.get(k) return None if design_runs is None else design_runs + _BBD_CENTRE_RUNS[k]
[docs] def omars_anchor_entry(design: str, n_factors: int) -> OmarsTradeOffTableEntry | None: """Report what a named standard design buys at *n_factors*. The anchor counterpart of :func:`get_omars_trade_off_table_entry`, for the two designs that bracket the OMARS family rather than for a run budget. Parameters ---------- design : {"dsd", "bbd"} Which standard design: the definitive screening design, at the small end of the family, or the Box-Behnken design, among the largest. n_factors : int Number of factors, ``k``, between 3 and 25. Returns ------- OmarsTradeOffTableEntry or None ``None`` where no such design was published for that factor count, which is every Box-Behnken design above seven factors. Raises ------ ValueError If *design* is not a known name, or *n_factors* is out of range. Notes ----- :func:`get_omars_trade_off_table_entry` reports a budget at its best centre-run arrangement, one centre run for an odd count and none or two for an even one. A named design carries whatever centre replication its published form specifies instead: a Box-Behnken design has three or six centre runs, so its total is even from five factors upwards. Its capability is that of its distinct half-rows, which extra centre runs do not alter. Examples -------- >>> omars_anchor_entry("bbd", 5).n_runs, omars_anchor_entry("bbd", 5).label (46, 'Full df=25') >>> omars_anchor_entry("dsd", 4).label 'Satd df=0' >>> omars_anchor_entry("bbd", 9) is None True Also see -------- omars_trade_off_table : puts these on the table as anchor rows. """ if design not in REFERENCE_DESIGNS: raise ValueError(f"design must be one of {REFERENCE_DESIGNS}, got {design!r}.") k = _check_factors(n_factors) runs = definitive_screening_runs(k) if design == "dsd" else box_behnken_runs(k) if runs is None: return None satd, quad, full = (omars_minimum_runs(k, c) for c in ("satd", "quad", "full")) capability = "full" if runs >= full else "quad" if runs >= quad else "satd" if capability == "full": model, params = "full_second_order", _full_second_order_params(k) else: model, params = "main_quadratic", 1 + 2 * k tag = _TAGS[capability] return OmarsTradeOffTableEntry( n_runs=runs, n_factors=k, exists=True, capability=capability, tag=tag, model=model, model_params=params, error_df=runs - params, label=f"{tag} df={runs - params}", min_runs_full=full, min_runs_quad=quad, min_runs_satd=satd, )
[docs] def get_omars_trade_off_table_entry(n_runs: int, n_factors: int, display: bool = True) -> OmarsTradeOffTableEntry: """Report which model a run budget buys, for a foldover OMARS design. The capability is that of the foldover class; a non-foldover OMARS design of the same size can support a larger model (see the module docstring). *exists* does not mean that :func:`~process_improve.experiments.generate_omars` builds the size; see the module docstring for the sizes it refuses. The OMARS counterpart of :func:`~process_improve.experiments.trade_off.get_trade_off_table_entry`. Because OMARS main effects are always clear of the second-order terms, the answer is not a resolution: it is which model is estimable, and how much is left over to test it with. Parameters ---------- n_runs : int Run budget. An odd budget is a foldover with one centre run, ``2h + 1`` runs; an even one is a foldover with no centre run or with two, and buys the model of the odd budget one run below it, with one more error degree of freedom. n_factors : int Number of factors, between 3 and 25. display : bool, default True Print a short report as well as returning it. Returns ------- OmarsTradeOffTableEntry The capability class, the model it supports, and the error degrees of freedom. See the class for the full field list. Raises ------ ValueError If *n_factors* is out of range, or *n_runs* is not a positive integer. Examples -------- >>> get_omars_trade_off_table_entry(21, 4, display=False).label 'Full df=6' >>> get_omars_trade_off_table_entry(17, 4, display=False).label 'Quad df=8' >>> get_omars_trade_off_table_entry(9, 4, display=False).label 'Satd df=0' >>> get_omars_trade_off_table_entry(10, 4, display=False).label 'Quad df=1' >>> get_omars_trade_off_table_entry(22, 4, display=False).label 'Full df=7' Also see -------- omars_trade_off_table : the same answer across a grid of budgets. """ k = _check_factors(n_factors) if int(n_runs) != n_runs: raise ValueError('The "n_runs" input must be an integer.') runs = int(n_runs) if runs < 1: raise ValueError(f"The number of runs must be positive; got {runs}.") satd, quad, full = (omars_minimum_runs(k, c) for c in ("satd", "quad", "full")) # The second-order columns of a foldover see one distinct row per half-run, plus one for # the centre runs if there are any. One centre run gives an odd budget (N + 1) / 2 such # rows; none or two give an even budget N / 2. Those rows decide which model is estimable. distinct_even_rows = (runs + 1) // 2 capability, reason = "none", "" if runs < satd: reason = f"{runs} runs is below the smallest OMARS design for {k} factors ({satd} runs)." elif distinct_even_rows >= _full_second_order_params(k) - k: capability = "full" elif runs > 1 + 2 * k: capability = "quad" else: capability = "satd" if capability == "none": model, params, error_df = None, 0, 0 elif capability == "full": model, params = "full_second_order", _full_second_order_params(k) error_df = runs - params else: model, params = "main_quadratic", 1 + 2 * k # Satd is exactly saturated by construction, so runs - params is zero. error_df = runs - params tag = _TAGS.get(capability, "") result = OmarsTradeOffTableEntry( n_runs=runs, n_factors=k, exists=capability != "none", capability=capability, tag=tag, model=model, model_params=params, error_df=error_df, label=f"{tag} df={error_df}" if tag else "", min_runs_full=full, min_runs_quad=quad, min_runs_satd=satd, reason=reason, ) if display: print(_format_result(result)) # noqa: T201 return result
def _format_result(result: OmarsTradeOffTableEntry) -> str: """Render an :class:`OmarsTradeOffTableEntry` as the printed report.""" lines = [f"OMARS: {result.n_runs} runs, {result.n_factors} factors"] if not result.exists: lines.append(f" No design: {result.reason}") lines.append(f" The smallest design for {result.n_factors} factors has {result.min_runs_satd} runs.") return "\n".join(lines) + "\n" lines.append(f" {result.tag}: {_MEANINGS[result.capability]}") lines.append(f" Model: {result.model} ({result.model_params} parameters), {result.error_df} error df") lines.append( f" Thresholds for {result.n_factors} factors: " f"Satd {result.min_runs_satd}, Quad {result.min_runs_quad}, Full {result.min_runs_full} runs." ) if result.capability != "full": short = result.min_runs_full - result.n_runs noun = "run" if short == 1 else "runs" lines.append(f" {short} more {noun} would reach Full (all two-factor interactions estimable).") return "\n".join(lines) + "\n"
[docs] def omars_trade_off_table( runs: Sequence[int] = DEFAULT_RUNS, factors: Sequence[int] = DEFAULT_FACTORS, display: bool = True, anchors: bool = False, ) -> pd.DataFrame: """Return the run-budget against factor-count table for foldover OMARS designs. Each cell says which model that budget supports and how much error is left to test it with, for example ``"Full df=11"``. Blank cells are budgets below the smallest foldover design. The bounds are for foldover designs, and not every listed size is one that :func:`~process_improve.experiments.generate_omars` builds; see the module docstring for both points. Parameters ---------- runs : sequence of int, default :data:`DEFAULT_RUNS` Run budgets, one per row. An even budget is read as a foldover with no centre run or with two. factors : sequence of int, default :data:`DEFAULT_FACTORS` Factor counts, one per column. display : bool, default True Print the table left-aligned, with the ``df=`` label written once per column rather than in every cell. anchors : bool, default False Add a ``DSD`` row above the budgets and a ``BBD`` row below them, giving the two ends of the family a fixed place on the table. Their run counts change from column to column, so their cells lead with the run count, for example ``"46 Full df=25"``. Turning this on makes the index mix strings with the integer budgets. Returns ------- pd.DataFrame Rows indexed by run count, columns by factor count. Cells are self-contained labels; the once-per-column compression applies only to the printed view. Examples -------- >>> table = omars_trade_off_table(display=False) >>> table.loc[21, 4] 'Full df=6' >>> table.loc[9, 3] 'Quad df=2' >>> anchored = omars_trade_off_table(display=False, anchors=True) >>> anchored.loc[9, 4] 'Satd df=0 | DSD' >>> anchored.loc[46, 5] 'Full df=25 | BBD' >>> anchored.loc[54, 5] '' Also see -------- get_omars_trade_off_table_entry : the detail behind one cell. definitive_screening_runs, box_behnken_runs : the anchor run counts. omars_anchor_entry : the detail behind one anchor cell. """ checked = [_check_factors(k) for k in factors] index: list[int] = sorted(runs) marks: dict[int, dict[str, int | None]] = {} if anchors: marks = {k: {"dsd": definitive_screening_runs(k), "bbd": box_behnken_runs(k)} for k in checked} # A named design sits on the row of its own run count, so the table has to carry that # row even when it is not one of the budgets asked for. index = sorted(set(index) | {n for m in marks.values() for n in m.values() if n is not None}) cells: dict[int, dict[int, str]] = {} for k in checked: dsd, bbd = (marks[k]["dsd"], marks[k]["bbd"]) if anchors else (None, None) column: dict[int, str] = {} for n in index: if bbd is not None and n > bbd: # Past the Box-Behnken design the column has nothing left to say: every row # below it is Full as well, on more runs. column[n] = "" continue entry = omars_anchor_entry("bbd", k) if n == bbd else get_omars_trade_off_table_entry(n, k, display=False) label = "" if entry is None else entry.label if label and n == bbd: label += " | BBD" elif label and n == dsd: label += " | DSD" column[n] = label cells[k] = column table = pd.DataFrame(cells, index=index) table.index.name = "runs" table.columns.name = "factors" if display: print(_format_table(table)) # noqa: T201 return table
def _format_table(table: pd.DataFrame) -> str: """Render the table left-aligned, with ``df=`` written once per column. Repeating ``df=`` in every cell buries the staircase where the capability class changes, which is the thing the table exists to show. The label is kept on the first live cell of each column, where a reader meets it first. """ # Work off a plain list-of-columns rather than the frame, so the cells stay # ordinary strings all the way through the formatting. columns: list[list[str]] = [] for column in table.columns: labelled = False cells: list[str] = [] for label in (str(value) for value in table[column]): if not label: cells.append("") elif labelled: tag, _, value = label.partition(" df=") cells.append(f"{tag} {value}") else: cells.append(label) labelled = True columns.append(cells) width = max((len(cell) for column_cells in columns for cell in column_cells), default=0) + 2 index_width = max(len("runs"), *(len(str(i)) for i in table.index)) + 2 lines = ["runs".ljust(index_width) + "".join(f"k={c}".ljust(width) for c in table.columns)] lines.append("-" * (index_width + width * len(table.columns))) lines.extend( str(index).ljust(index_width) + "".join(column_cells[row].ljust(width) for column_cells in columns) for row, index in enumerate(table.index) ) lines.append("") lines.append(" Full: " + _MEANINGS["full"]) lines.append(" Quad: " + _MEANINGS["quad"]) lines.append(" Satd: " + _MEANINGS["satd"]) return "\n".join(lines) + "\n"