# (c) Kevin Dunn, 2010-2026. MIT License.
"""Screening designs: fractional factorial, Plackett-Burman, Taguchi.
All functions accept a list of ``Factor`` objects and return a raw coded
numpy array. Post-processing (center points, replication, randomization,
Column/Expt conversion) is handled by ``designs_utils.build_design_result``.
"""
from __future__ import annotations
import contextlib
import itertools
import math
import warnings
from typing import TYPE_CHECKING
import numpy as np
from process_improve._warnings import user_stacklevel
from process_improve.experiments._classical import fracfact, pbdesign
from process_improve.experiments._finite_fields import hadamard_matrix
from process_improve.experiments._orthogonal_arrays import ORTHOGONAL_ARRAY_NAMES, orthogonal_array
from process_improve.experiments.designs_utils import categorical_codes
if TYPE_CHECKING:
from types import ModuleType
from process_improve.experiments.factor import DesignResult, Factor
# Minimum-aberration 2^(k-p) fractional factorials for 5 to 11 factors, keyed by the number of
# factors and then by the run count 2^(k-p). Letters name factors by position, in the textbook
# convention A, B, ..., H, J, K, L (I stands for the identity), and the derived factors are the
# last p. The half fraction, with the last factor equal to the product of all the others, is
# built for any k, so it is not listed. The designs are the standard ones (Montgomery, "Design
# and Analysis of Experiments", Chapter 8); an exhaustive search over every generator set
# confirmed that each has minimum aberration, and the tests pin its word-length pattern.
_MIN_ABERRATION: dict[int, dict[int, tuple[str, ...]]] = {
5: {8: ("D=AB", "E=AC")},
6: {16: ("E=ABC", "F=BCD"), 8: ("D=AB", "E=AC", "F=BC")},
7: {
32: ("F=ABCD", "G=ABDE"),
16: ("E=ABC", "F=BCD", "G=ACD"),
8: ("D=AB", "E=AC", "F=BC", "G=ABC"),
},
8: {
64: ("G=ABCD", "H=ABEF"),
32: ("F=ABC", "G=ABD", "H=BCDE"),
16: ("E=BCD", "F=ACD", "G=ABC", "H=ABD"),
},
9: {
128: ("H=ACDFG", "J=BCEFG"),
64: ("G=ABCD", "H=ACEF", "J=CDEF"),
32: ("F=BCDE", "G=ACDE", "H=ABDE", "J=ABCE"),
16: ("E=ABC", "F=BCD", "G=ACD", "H=ABD", "J=ABCD"),
},
10: {
256: ("J=ABCDEF", "K=ABCDGH"),
128: ("H=ABCG", "J=BCDE", "K=ACDF"),
64: ("G=BCDF", "H=ACDF", "J=ABDE", "K=ABCE"),
32: ("F=ABCD", "G=ABCE", "H=ABDE", "J=ACDE", "K=BCDE"),
16: ("E=ABC", "F=BCD", "G=ACD", "H=ABD", "J=ABCD", "K=AB"),
},
11: {
512: ("K=ABCDEF", "L=ABCGHJ"),
256: ("J=ABCDE", "K=ABCFG", "L=ABDFH"),
128: ("H=ABCG", "J=BCDE", "K=ACDF", "L=ABCDEFG"),
64: ("G=CDE", "H=ABCD", "J=ABF", "K=BDEF", "L=ADEF"),
32: ("F=ABC", "G=BCD", "H=CDE", "J=ACD", "K=ADE", "L=BDE"),
16: ("E=ABC", "F=BCD", "G=ACD", "H=ABD", "J=ABCD", "K=AB", "L=AC"),
},
}
_TABLE_LETTERS = "ABCDEFGHJKL"
_MAX_TABULATED_FACTORS = 11
# (derived factor index, base factor indices): one generator, e.g. D=ABC is (3, [0, 1, 2]).
_Generator = tuple[int, list[int]]
[docs]
def dispatch_fractional_factorial(
factors: list[Factor],
resolution: int | None = None,
generators: list[str] | None = None,
n_runs: int | None = None,
) -> tuple[np.ndarray, dict]:
"""Generate a 2-level fractional factorial design.
Parameters
----------
factors : list[Factor]
Continuous factors (all treated as 2-level).
resolution : int or None
Desired minimum resolution (3 or more). The design is the minimum-aberration
fraction with the fewest runs that reaches it, so its resolution can be higher.
When neither *resolution* nor *generators* is given, the design is the half
fraction 2^(k-1), of resolution k. With *generators*, it is checked instead:
generators that reach a lower resolution raise ``ValueError``.
generators : list[str] or None
Explicit generator strings, e.g. ``["D=ABC", "E=AC"]``. When given,
these are translated into ``fracfact`` generator notation.
n_runs : int or None
Runs of the fraction, a power of two: the minimum-aberration fraction of that size
(#956). With *resolution*, the fraction must reach it. Ignored with *generators*.
Returns
-------
tuple[np.ndarray, dict]
Coded design matrix (-1 / +1) and metadata dict with keys
``"generators_used"``, ``"defining_relation"`` and ``"resolution"``, the
resolution the design achieves. A word of the defining relation that is -1 on
every run (from a negated generator such as ``"D=-AB"``) is written ``"I=-ABD"``.
A defining relation of more than 1023 words (more than ten generators) is not
listed: ``"defining_relation_note"`` says so, and :func:`defining_relation` lists it.
Raises
------
ValueError
If fewer than 3 factors are given without generators, if *resolution* is below 3
or above the number of factors, if *generators* or the *n_runs* fraction fall short
of *resolution*, or if no minimum-aberration fraction of *n_runs* runs is tabulated or
quick to search.
"""
factor_names = [f.name for f in factors]
k = len(factors)
if generators:
derived_idx, rhs_indices = _parse_generators(factor_names, generators)
coded_matrix = _fracfact_from_indices(k, derived_idx, rhs_indices)
parsed = [(lhs, rhs) for lhs, (rhs, _negated) in zip(derived_idx, rhs_indices, strict=True)]
signs = [-1 if negated else 1 for _rhs, negated in rhs_indices]
generators_used = list(generators)
else:
if k < 3:
raise ValueError(f"A fractional factorial needs at least 3 factors, got {k}; use a full factorial.")
if resolution is not None and not 3 <= resolution <= k:
raise ValueError(
f"A fractional factorial in {k} factors has a resolution from 3 to {k} (the half fraction); "
f"got resolution={resolution}. Use a full factorial for more."
)
if n_runs is not None:
parsed = _fraction_of_size(k, n_runs)
elif resolution is None: # the half fraction
parsed = [(k - 1, list(range(k - 1)))]
else:
parsed = _minimum_aberration_generators(k, resolution)
signs = [1] * len(parsed)
coded_matrix = _fracfact_from_indices(k, [lhs for lhs, _ in parsed], [(rhs, False) for _, rhs in parsed])
generators_used = [f"{factor_names[lhs]}={''.join(factor_names[i] for i in rhs)}" for lhs, rhs in parsed]
words = _defining_word_masks(parsed, signs)
achieved = min(mask.bit_count() for mask, _ in words)
if not generators and n_runs is not None and resolution is not None and achieved < resolution:
raise ValueError(
f"The {n_runs}-run fraction of {k} factors reaches resolution {achieved}, below the "
f"resolution={resolution} asked for. Give a larger budget or a lower resolution."
)
if generators and resolution is not None and achieved < resolution:
raise ValueError(
f"The generators {list(generators)} give a resolution-{achieved} design, below the resolution={resolution} "
"asked for. Pass either generators or resolution, or generators that reach the resolution."
)
meta: dict = {"generators_used": generators_used, "resolution": achieved}
if len(words) <= _MAX_LISTED_WORDS:
meta["defining_relation"] = _listed_words(words, factor_names)
else:
meta["defining_relation_note"] = (
f"The defining relation has {len(words)} words, more than the {_MAX_LISTED_WORDS} a design lists; "
"defining_relation(design) lists them all."
)
return coded_matrix, meta
[docs]
def defining_relation(design: DesignResult) -> list[str]:
"""Return every word of a two-level fraction's defining relation, however many generators it has.
A design lists its defining relation only up to ten generators (1023 words), because the
relation doubles with each generator: a saturated 2^(15-11) design has 2047 words. This
function works them out from the design's generators on request, in the design's own form:
shortest first, each as ``"I=ABD"``, or ``"I=-ABD"`` where the word is -1 on every run.
Parameters
----------
design : DesignResult
A two-level fraction, or a design built on one (such as a composite design on a
fractional cube), from :func:`generate_design`.
Returns
-------
list[str]
The words of the defining relation; empty for a full factorial.
Raises
------
ValueError
If the design has no generators and is not a full factorial, so it has no defining
relation (a Plackett-Burman or optimal design, for example).
Examples
--------
>>> from process_improve.experiments import Factor, generate_design
>>> factors = [Factor(name=name, low=-1, high=1) for name in "ABCDEFGHJKLMNOP"]
>>> design = generate_design(factors, design_type="fractional_factorial", resolution=3)
>>> design.n_runs, design.defining_relation
(19, None)
>>> relation = defining_relation(design)
>>> len(relation), relation[0]
(2047, 'I=ABE')
"""
if not design.generators:
if design.design_type == "full_factorial":
return []
msg = f"design_type={design.design_type!r} has no generators, so it has no defining relation to list."
raise ValueError(msg)
names = list(design.factor_names)
derived, terms = _parse_generators(names, design.generators)
parsed = [(lhs, rhs) for lhs, (rhs, _negated) in zip(derived, terms, strict=True)]
signs = [-1 if negated else 1 for _rhs, negated in terms]
return _listed_words(_defining_word_masks(parsed, signs), names)
def _listed_words(words: list[tuple[int, int]], factor_names: list[str]) -> list[str]:
"""Return the words of a defining relation as strings, shortest first and then in factor order."""
k = len(factor_names)
ordered = sorted(words, key=lambda item: (item[0].bit_count(), [i for i in range(k) if item[0] >> i & 1]))
return [_format_word(mask, sign, factor_names) for mask, sign in ordered]
#: Longest defining relation listed in the metadata (ten generators); a longer one is
#: left out, since the generators define it, and :func:`defining_relation` lists it on request.
_MAX_LISTED_WORDS = 1023
def _format_word(mask: int, sign: int, factor_names: list[str]) -> str:
"""Format a defining word as ``"I=ABD"``, or ``"I=-ABD"`` in a fraction where the word is -1."""
return ("I=-" if sign < 0 else "I=") + "".join(name for i, name in enumerate(factor_names) if mask >> i & 1)
def _minimum_aberration_generators(k: int, resolution: int) -> list[_Generator]:
"""Choose the fewest-run fraction of resolution at least *resolution*.
Up to 11 factors the fraction comes from the minimum-aberration table, and the half
fraction is the answer whenever no smaller tabulated fraction reaches *resolution*.
Beyond 11 factors :func:`_search_fraction` finds the fewest runs that reach it, and
:func:`_strongest_at_size` then takes the best fraction of that size.
"""
half_fraction = [(k - 1, list(range(k - 1)))]
if k > _MAX_TABULATED_FACTORS:
return _strongest_at_size(k, _search_fraction(k, resolution))
for _n_runs, entry in sorted(_MIN_ABERRATION.get(k, {}).items()):
candidate = [(_TABLE_LETTERS.index(g[0]), [_TABLE_LETTERS.index(c) for c in g[2:]]) for g in entry]
if min(len(word) for word in _defining_words(candidate)) >= resolution:
return candidate
return half_fraction
def _fraction_of_size(k: int, n_runs: int) -> list[_Generator]:
"""Return the minimum-aberration fraction of *k* factors in *n_runs* runs (a power of two).
The half fraction is the one with a single generator; other sizes come from the
minimum-aberration table up to 11 factors, else from the generator search of
:func:`~process_improve.experiments.trade_off.minimum_aberration_generators`, which raises
a ``ValueError`` naming the size of the search when it is too large.
"""
trade_off = _trade_off()
if n_runs == 2 ** (k - 1):
return [(k - 1, list(range(k - 1)))]
entry = _MIN_ABERRATION.get(k, {}).get(n_runs)
if entry is not None:
return [(_TABLE_LETTERS.index(g[0]), [_TABLE_LETTERS.index(c) for c in g[2:]]) for g in entry]
if k > len(trade_off._FACTOR_NAMES):
raise ValueError(f"No minimum-aberration fraction of {k} factors is searched; supply the generators.")
return _as_indices(trade_off.minimum_aberration_generators(n_runs, k))
def _trade_off() -> ModuleType:
"""Return :mod:`~process_improve.experiments.trade_off`, imported here because it imports this module."""
from process_improve.experiments import trade_off # noqa: PLC0415
return trade_off
def _as_indices(generators: tuple[str, ...]) -> list[_Generator]:
"""Turn generators such as ``("D=AB",)`` from the minimum-aberration search into column indices."""
names = _trade_off()._FACTOR_NAMES
return [(names.index(g[0]), [names.index(c) for c in g[2:]]) for g in generators]
def _strongest_at_size(k: int, found: list[_Generator]) -> list[_Generator]:
"""Return the best fraction with as many runs as *found*.
It is the minimum-aberration fraction where scoring every generator set is quick
(:func:`~process_improve.experiments.trade_off.minimum_aberration_generators`), as it is
for 12 to 15 factors in 16 runs. Otherwise it is the fraction of the highest resolution
the generator search reaches at that size: 16 factors in 32 runs are resolution IV,
though resolution III was asked for.
"""
trade_off = _trade_off()
n_base = k - len(found)
if k <= len(trade_off._FACTOR_NAMES):
with contextlib.suppress(trade_off.SearchLimitError):
return _as_indices(trade_off.minimum_aberration_generators(2**n_base, k))
reached = min(mask.bit_count() for mask, _ in _defining_word_masks(found))
for stronger in range(n_base + 1, reached, -1): # a generator's word has at most n_base + 1 letters
better = _search_at_size(k, n_base, stronger)
if better is not None:
return better
return found
#: Branches the generator search for more than 11 factors may try at one run size
#: before it moves to the next larger size; it keeps the search to about a second.
_SEARCH_NODE_LIMIT = 200_000
def _search_fraction(k: int, resolution: int) -> list[_Generator]:
"""Find a 2^(k-p) fraction of resolution at least *resolution*, trying the fewest runs first.
At each run size ``2^(k-p)`` :func:`_search_at_size` looks for one; the half fraction,
of resolution ``k``, always qualifies.
"""
for n_base in range(max(2, k.bit_length()), k - 1):
found = _search_at_size(k, n_base, resolution)
if found is not None:
return found
return [(k - 1, list(range(k - 1)))] # the half fraction, of resolution k
def _search_at_size(k: int, n_base: int, resolution: int) -> list[_Generator] | None:
"""Find generators for k factors on *n_base* base factors that reach *resolution*, or None.
A depth-first search picks the ``k - n_base`` generator words one at a time, keeping
only choices that leave every word of the defining relation at least *resolution*
letters long. It gives up after :data:`_SEARCH_NODE_LIMIT` branches (or when there are
too many candidate words to start), so None does not prove that no such fraction exists.
"""
# A generator word is the derived factor times at least resolution - 1 base factors.
sizes = range(max(2, resolution - 1), n_base + 1)
if sum(math.comb(n_base, size) for size in sizes) > _SEARCH_NODE_LIMIT:
return None # too many candidate words to search at this size
candidates = [sum(1 << i for i in combo) for size in sizes for combo in itertools.combinations(range(n_base), size)]
found = _search_generator_words(candidates, n_base, k - n_base, resolution)
if found is None:
return None
return [(n_base + j, [i for i in range(n_base) if mask >> i & 1]) for j, mask in enumerate(found)]
def _search_generator_words(candidates: list[int], n_base: int, n_extra: int, resolution: int) -> list[int] | None:
"""Depth-first search for *n_extra* generator words (bit masks of base factors), or None."""
nodes = 0
def extend(start: int, words: list[int], chosen: list[int]) -> list[int] | None:
nonlocal nodes
if len(chosen) == n_extra:
return chosen
for index in range(start, len(candidates)):
nodes += 1
if nodes > _SEARCH_NODE_LIMIT:
return None
generator = candidates[index] | (1 << (n_base + len(chosen)))
new_words = [generator] + [word ^ generator for word in words]
if all(word.bit_count() >= resolution for word in new_words):
found = extend(index + 1, words + new_words, [*chosen, candidates[index]])
if found is not None or nodes > _SEARCH_NODE_LIMIT:
return found
return None
return extend(0, [], [])
def _defining_word_masks(generators: list[_Generator], signs: list[int] | None = None) -> list[tuple[int, int]]:
"""Return every word of the defining relation as ``(bit mask of factors, sign)``.
A generator's word is its derived factor times its base factors; the defining
relation holds every product of those words (symmetric differences, since each
factor squares to the identity), and a product's sign is the product of the signs
of the generators in it. No product is the identity: each generator word holds its
own derived factor.
"""
words: list[tuple[int, int]] = []
for (lhs, rhs), sign in zip(generators, signs or [1] * len(generators), strict=True):
mask = 1 << lhs
for index in rhs:
mask ^= 1 << index # a repeated base factor cancels
words += [(mask, sign)] + [(word ^ mask, word_sign * sign) for word, word_sign in words]
return words
def _defining_words(generators: list[_Generator]) -> list[frozenset[int]]:
"""Return every word of the defining relation as a set of factor indices, shortest first."""
words = [
frozenset(i for i in range(mask.bit_length()) if mask >> i & 1) for mask, _ in _defining_word_masks(generators)
]
return sorted(words, key=lambda word: (len(word), sorted(word)))
def _parse_generator_word(word: str, factor_names: list[str]) -> list[int]:
"""Parse a generator word (e.g. ``"ABC"``) into factor indices by NAME.
Uses the same convention as ``evaluate._parse_word``: character-by-character
when every factor name is a single character, greedy longest-name-first
matching otherwise. Unlike that helper, unparseable content raises instead
of being silently skipped: a generator that does not resolve to real
factors would otherwise produce a design for the wrong fraction.
"""
name_to_idx = {name: i for i, name in enumerate(factor_names)}
indices: list[int] = []
if all(len(n) == 1 for n in factor_names):
for ch in word:
if ch not in name_to_idx:
raise ValueError(f"Generator word {word!r} refers to {ch!r}, which is not a factor name.")
indices.append(name_to_idx[ch])
return indices
remaining = word
sorted_names = sorted(name_to_idx, key=len, reverse=True)
while remaining:
for name in sorted_names:
if remaining.startswith(name):
indices.append(name_to_idx[name])
remaining = remaining[len(name) :]
break
else:
raise ValueError(f"Generator word {word!r} does not resolve to factor names {factor_names}.")
return indices
def _parse_generators(factor_names: list[str], generators: list[str]) -> tuple[list[int], list[tuple[list[int], bool]]]:
"""Parse and validate generator strings into (derived indices, rhs terms)."""
derived_idx: list[int] = []
rhs_indices: list[tuple[list[int], bool]] = []
for g in generators:
if "=" not in g:
raise ValueError(f"Generator {g!r} must have the form 'D=ABC' (or 'D=-ABC').")
lhs_word, rhs_word = (part.strip() for part in g.split("=", 1))
negated = rhs_word.startswith("-")
rhs_word = rhs_word.lstrip("+-").strip()
lhs = _parse_generator_word(lhs_word, factor_names)
if len(lhs) != 1:
raise ValueError(f"Generator {g!r}: the left-hand side must be exactly one factor.")
rhs = _parse_generator_word(rhs_word, factor_names)
if not rhs:
raise ValueError(f"Generator {g!r}: the right-hand side names no factors.")
if lhs[0] in rhs:
raise ValueError(f"Generator {g!r}: the left-hand factor may not appear on the right-hand side.")
if lhs[0] in derived_idx:
raise ValueError(f"Generator {g!r}: factor {factor_names[lhs[0]]!r} is derived more than once.")
derived_idx.append(lhs[0])
rhs_indices.append((rhs, negated))
base_idx = [i for i in range(len(factor_names)) if i not in derived_idx]
for (rhs, _neg), g in zip(rhs_indices, generators, strict=True):
not_base = [i for i in rhs if i not in base_idx]
if not_base:
names = [factor_names[i] for i in not_base]
raise ValueError(f"Generator {g!r}: right-hand factors {names} are themselves derived factors.")
return derived_idx, rhs_indices
def _fracfact_from_indices(k: int, derived_idx: list[int], rhs_indices: list[tuple[list[int], bool]]) -> np.ndarray:
"""Build the coded matrix for parsed generators, with column ``i`` belonging to factor ``i``.
``fracfact`` is handed canonical single letters (so multi-character factor names are
never misread as products) and returns the base factors followed by the derived
ones. The columns are then put back in factor order: returning that order
once swapped columns silently whenever a derived factor was not the last one
(e.g. ``"B=AC"`` with factors A, B, C).
"""
base_idx = [i for i in range(k) if i not in derived_idx]
letters = "abcdefghijklmnopqrstuvwxyz"
if len(base_idx) > len(letters):
raise ValueError(f"At most {len(letters)} base factors are supported; got {len(base_idx)}.")
base_letter = {factor_index: letters[pos] for pos, factor_index in enumerate(base_idx)}
tokens = [base_letter[i] for i in base_idx]
for rhs, negated in rhs_indices:
word = "".join(base_letter[i] for i in rhs)
tokens.append(f"-{word}" if negated else word)
# One non-empty token per factor, so fracfact returns exactly k columns, in the order
# (bases..., derived...); map them back to factor order.
coded = fracfact(" ".join(tokens))
reordered = np.empty_like(coded)
for position, factor_index in enumerate(base_idx + derived_idx):
reordered[:, factor_index] = coded[:, position]
return reordered
#: Plackett and Burman's (1946) generating row for 36 runs, as Montgomery's Table 8.23 prints it: the first
#: column of the design, each next column the one before moved down one run, then a run of minus signs.
#: Orders whose run count less one is a prime 3 mod 4 (12, 20, 24, 44, ...) are Paley's cyclic designs,
#: which the finite-field module builds; 36 is not one, so its row is kept here.
_PB_CYCLIC_ROWS = {36: "-+-+++---+++++-+++--+----+-+-++--+-"}
def _cyclic_design(row: str) -> np.ndarray:
"""Return the design a generating row of signs gives: its cyclic shifts as columns, then a run of minus signs."""
first = np.array([1.0 if sign == "+" else -1.0 for sign in row])
columns = np.column_stack([np.roll(first, shift) for shift in range(len(first))])
return np.vstack([columns, -np.ones((1, len(first)))])
[docs]
def plackett_burman_runs(n_factors: int) -> int:
"""Return the number of runs in the Plackett-Burman design for ``n_factors`` factors.
The smallest multiple of 4 above ``n_factors`` for which a Hadamard matrix is
built here: every order up to 232. The first gaps are 236, 260 and 268, which
step up to the next multiple of 4.
"""
n = 4 * (n_factors // 4 + 1)
while hadamard_matrix(n) is None:
n += 4
return n
[docs]
def dispatch_plackett_burman(factors: list[Factor]) -> tuple[np.ndarray, dict]:
"""Generate a Plackett-Burman screening design.
The ``N``-run design is ``k`` columns of a normalised Hadamard matrix of order
``N``, the smallest multiple of 4 above ``k`` that can be built (see
:func:`plackett_burman_runs`). Where ``N - 1`` is a prime 3 mod 4 (12, 20, 24, 44,
48, ... runs) the design is Paley's cyclic one, which is what Plackett and Burman
(1946) printed for 12, 20, 24 and 44 runs; for 36 runs it is their own generating
row. Powers of 2 are the classical construction (Sylvester's doubling), and the other
orders (28, 40, 52, ..., and the Goethals-Seidel arrays at 92, 116, 156, 172 and 188)
come from the constructions in :mod:`process_improve.experiments._finite_fields`,
checked against ``H'H = N I``. A doubled design is used only where no cyclic one is
built: it aliases some products of three columns completely with the intercept. A
``UserWarning`` names both run counts when ``N`` is above the smallest multiple of 4
above ``k`` because that order is not built here.
Parameters
----------
factors : list[Factor]
Continuous factors.
Returns
-------
tuple[np.ndarray, dict]
Coded design matrix (-1 / +1) and metadata, including the ``construction``.
"""
k = len(factors)
n = plackett_burman_runs(k)
if n > (smallest := 4 * (k // 4 + 1)):
warnings.warn(
f"No {smallest}-run Plackett-Burman design is built here, so the design for {k} factors has {n} runs.",
UserWarning,
stacklevel=user_stacklevel(),
)
if n in _PB_CYCLIC_ROWS:
return _cyclic_design(_PB_CYCLIC_ROWS[n])[:, :k], {
"construction": f"cyclic_row_{n}",
"note": f"Plackett-Burman design for {k} factors in {n} runs",
}
hadamard, construction = hadamard_matrix(n) # type: ignore[misc] # not None: n was chosen so
coded_matrix = None
# Paley's cyclic design (I+C(paley_q=n-1)) is Plackett and Burman's own; a doubled 12- or 20-run core
# is not, and aliases some products of three columns completely with the intercept.
doubled = n & (n - 1) != 0 and n not in (12, 20)
if not (doubled and construction.startswith("I+C(paley_q=")):
with contextlib.suppress(ValueError): # no 1-, 12- or 20-run core for this order
coded_matrix = pbdesign(k)
if coded_matrix is not None and coded_matrix.shape[0] == n:
construction = "classical" # the 1-, 12- or 20-run core, doubled (Plackett and Burman, 1946)
else:
coded_matrix = hadamard[:, 1 : k + 1].astype(float)
return coded_matrix, {
"construction": construction,
"note": f"Plackett-Burman design for {k} factors in {n} runs",
}
def _is_strength_two(array: np.ndarray) -> bool:
"""Whether every pair of columns shows every level combination equally often."""
for i, j in itertools.combinations(range(array.shape[1]), 2):
_, counts = np.unique(array[:, [i, j]], axis=0, return_counts=True)
if len(counts) != (array[:, i].max() + 1) * (array[:, j].max() + 1) or counts.min() != counts.max():
return False
return True
def _taguchi_codes(factor: Factor, n_levels: int) -> np.ndarray:
"""Coded settings for a factor's ``n_levels`` levels: equally spaced, or its own ``levels`` coded to [-1, 1]."""
if factor.type.value != "categorical" and factor.levels:
low, high = float(factor.low), float(factor.high) # type: ignore[arg-type]
values = np.unique(np.asarray(factor.levels, dtype=float))
return (values - (low + high) / 2.0) / ((high - low) / 2.0)
return categorical_codes(n_levels)
def _assign_columns(
free: list[int], needed: list[int], order: list[int], *, share: bool
) -> list[tuple[int, int]] | None:
"""Place each factor in a column of an array with ``free`` levels per column, or return ``None``.
A factor takes a column of exactly its level count, or else one whose level count it
divides (a 6-level column carries a 3-level factor). Factors are placed in ``order``.
With ``share``, a column keeps what its first factor leaves: a 6-level column that
carries a 3-level factor still offers 2 levels, so it carries a 2-level factor too,
as Taguchi writes the L18 as one 2-level and seven 3-level columns. Each factor gets
``(column, place)``: its level is ``(level // place) % n`` for an ``n``-level factor,
a digit of the column's level in mixed radix, so the factors sharing a column take
every combination of their levels equally often.
"""
capacity, place, used = list(free), [1] * len(free), [False] * len(free)
placed: dict[int, tuple[int, int]] = {}
for i in order:
open_columns = [c for c in range(len(free)) if capacity[c] > 1 and (share or not used[c])]
column = next((c for c in open_columns if capacity[c] == needed[i]), None)
if column is None:
column = next((c for c in open_columns if capacity[c] % needed[i] == 0), None)
if column is None:
return None
placed[i] = (column, place[column])
used[column] = True
place[column] *= needed[i]
capacity[column] //= needed[i]
return [placed[i] for i in range(len(needed))]
[docs]
def dispatch_taguchi(factors: list[Factor]) -> tuple[np.ndarray, dict]:
"""Generate a Taguchi orthogonal-array design.
Uses the smallest of the standard arrays that has a column for every factor:
two levels for a continuous factor, the number of its ``levels`` for a categorical
factor or a continuous factor given explicit levels. Each factor takes its own
column (one with its level count, or else one whose levels divide evenly into its
own, as a 6-level column carries a 3-level factor); the other columns are left
unused. When that leaves a factor without a column, a column whose level count is
a product of two factors' counts carries both, as a digit each of its level: the
6-level column of ``L18(6^1 3^6)`` is the 2-level and 3-level columns of Taguchi's
L18 for one 2-level and seven 3-level factors. The chosen columns are checked to be
balanced in every pair (strength 2) before use. No centre points are added.
Parameters
----------
factors : list[Factor]
Continuous factors (two levels, or the values in ``levels``) and categorical factors.
Returns
-------
tuple[np.ndarray, dict]
Coded design matrix (-1 / +1 for two levels, equally spaced codes otherwise) and
metadata with the ``orthogonal_array`` and the array ``columns`` used, a column
named twice when two factors share it.
"""
needed = [len(f.levels) if f.levels else 2 for f in factors]
order = sorted(range(len(factors)), key=lambda i: -needed[i])
for name in ORTHOGONAL_ARRAY_NAMES: # in order of run count
array = orthogonal_array(name)
# Column level counts come from the array itself: some names do not match their
# arrays ("L27(2^1 3^12)" is all three-level, "L36(3^23)" has eleven two-level columns).
free = [int(array[:, j].max()) + 1 for j in range(array.shape[1])]
# A column to each factor; only when that fails, a column shared by two factors.
placed = _assign_columns(free, needed, order, share=False) or _assign_columns(free, needed, order, share=True)
if placed is None:
continue
chosen = [column for column, _ in placed]
levels = np.column_stack([(array[:, c] // place) % needed[i] for i, (c, place) in enumerate(placed)])
# Every stored array is strength 2 (tests check it); the check guards edits to the table.
if _is_strength_two(levels):
coded = np.column_stack([_taguchi_codes(f, needed[i])[levels[:, i]] for i, f in enumerate(factors)]).astype(
float
)
return coded, {"orthogonal_array": name, "columns": chosen}
raise ValueError(
f"No standard orthogonal array has columns for {len(factors)} factors with {sorted(needed)} levels; "
"use 'full_factorial' or an optimal design instead."
)