# (c) Kevin Dunn, 2010-2026. MIT License.
r"""Multistratum analysis of variance: split-plot, strip-plot and other designs with several sizes of unit.
A design that restricts its randomisation (a hard-to-change factor set once for several runs, a
batch that holds four strips, a furnace load that crosses an implanter batch) has runs that fall
into *strata*: the whole plots, the subplots within them, and so on. Each stratum is a set of
contrasts among the runs with its own error, and each treatment term is estimated in the stratum
its contrasts fall in. This is R's ``aov(y ~ treatments + Error(blocks))``:
- the strata are given as error terms, ``"rep/plot"`` (replicates, then whole plots within them)
or ``"row + column"`` (a strip plot); a last stratum, ``"Within"``, holds the contrasts left;
- each stratum's basis is the part of its error term's indicator columns that the strata before it
do not span, so the strata split the n - 1 contrasts among the runs between them;
- in each stratum the treatment terms are fitted in order, each one's degrees of freedom the rank
it adds there, and what is left is the stratum's residual.
Without a response it gives the skeleton analysis of variance: the sources and degrees of freedom a
design will have, before it is run. With one, each term's sum of squares, and its F test on the
residual of its stratum where that residual has degrees of freedom.
Examples
--------
>>> import itertools
>>> import pandas as pd
>>> from process_improve.experiments.multistratum import multistratum_anova
>>> runs = pd.DataFrame(list(itertools.product([0, 1], [0, 1], [0, 1], [0, 1])), columns=["S", "T", "rep", "A"])
>>> layout = multistratum_anova(runs, "S * T * A", "S:T:rep")
>>> layout["df"]["S:T:rep"]
{'S': 1, 'T': 1, 'S:T': 1, 'Residual': 4}
>>> layout["df"]["Within"]
{'A': 1, 'S:A': 1, 'T:A': 1, 'S:T:A': 1, 'Residual': 4}
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from patsy import dmatrix
from scipy import stats
from process_improve.experiments.models import validate_formula_is_safe, validate_identifier_is_safe
_RANK_TOLERANCE = 1e-9
[docs]
def error_terms(strata: str | list[str]) -> list[str]:
"""Expand a block structure into its error terms, in order: ``"a/b"`` is ``a`` and ``a:b``, ``"a + b"`` is both.
Parameters
----------
strata : str or list[str]
The block structure, ``"rep/plot"``, ``"row + column"``, or a list of error terms.
Returns
-------
list[str]
The error terms, each a factor or an interaction of factors such as ``"rep:plot"``.
"""
parts = strata if isinstance(strata, list) else strata.split("+")
terms: list[str] = []
for part in parts:
nested = [name.strip() for name in str(part).split("/")]
terms += [":".join(nested[: i + 1]) for i in range(len(nested))]
return list(dict.fromkeys(terms))
def _basis(columns: np.ndarray) -> np.ndarray:
"""Return an orthonormal basis of the space the columns span."""
if columns.size == 0: # no columns, or a stratum with no units left: it spans nothing
return columns[:, :0]
u, s, _ = np.linalg.svd(columns, full_matrices=False)
return u[:, s > _RANK_TOLERANCE * max(1.0, float(s[0]))]
def _strata(table: pd.DataFrame, terms: list[str]) -> list[tuple[str, np.ndarray]]:
"""Return each stratum's name and orthonormal basis: one per error term, then ``"Within"``."""
n = len(table)
spanned = np.ones((n, 1)) / np.sqrt(n) # the grand mean is a stratum of its own, and is not reported
strata = []
for term in terms:
groups = table[term.split(":")].astype(str).agg(":".join, axis=1)
indicators = pd.get_dummies(groups).to_numpy(dtype=float)
basis = _basis(indicators - spanned @ (spanned.T @ indicators))
strata.append((term, basis))
spanned = np.hstack([spanned, basis])
strata.append(("Within", _basis(np.eye(n) - spanned @ spanned.T)))
return strata
def _stratum_rows(
basis: np.ndarray, terms: list[tuple[str, np.ndarray]], y: np.ndarray | None
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Fit the treatment terms in order within one stratum; return their rows and the stratum's residual."""
projected_y = None if y is None else basis.T @ y
fitted = np.empty((basis.shape[1], 0))
rows: list[dict[str, Any]] = []
for term, columns in terms:
projected = basis.T @ columns
added = _basis(projected - fitted @ (fitted.T @ projected))
if added.shape[1] == 0:
continue # the term has no contrast in this stratum
row: dict[str, Any] = {"term": term, "df": int(added.shape[1])}
if projected_y is not None:
row["ss"] = float(np.sum((added.T @ projected_y) ** 2))
row["ms"] = row["ss"] / row["df"]
rows.append(row)
fitted = np.hstack([fitted, added])
residual: dict[str, Any] = {"df": int(basis.shape[1] - fitted.shape[1])}
if projected_y is not None:
residual["ss"] = float(np.sum(projected_y**2) - np.sum((fitted.T @ projected_y) ** 2))
residual["ms"] = residual["ss"] / residual["df"] if residual["df"] else float("nan")
for row in rows:
row["f"] = row["ms"] / residual["ms"] if residual["df"] else float("nan")
row["p_value"] = float(stats.f.sf(row["f"], row["df"], residual["df"])) if residual["df"] else float("nan")
return rows, residual
[docs]
def multistratum_anova(
design_matrix: pd.DataFrame,
model: str,
strata: str | list[str],
response: str | None = None,
) -> dict[str, Any]:
"""Return the analysis of variance of a design with several strata of units, or its skeleton without a response.
Parameters
----------
design_matrix : DataFrame
The runs: the treatment factors, the columns that name the units (replicate, whole plot,
batch), and the response if there is one. Every factor is taken as levels.
model : str
The treatment terms, a plain formula over the columns: ``"S * T * A"``.
strata : str or list[str]
The block structure as error terms (see :func:`error_terms`): ``"rep/plot"`` for whole
plots within replicates, ``"S:T"`` for a whole plot at each combination of S and T,
``"row + column"`` for a strip plot.
response : str or None
The response column; without one, the result holds degrees of freedom only.
Returns
-------
dict[str, Any]
``strata``, one entry per stratum (each error term, then ``"Within"``) with its ``df``,
its ``units`` (the number of groups its error term makes, the runs for ``"Within"``), the
``terms`` estimated in it, each with its ``df`` (and with a response ``ss``, ``ms``,
``f`` and ``p_value``), and its ``residual``; ``df``, each stratum's terms and
``"Residual"`` mapped to their degrees of freedom; and ``table``, every row as a
DataFrame. A term whose contrasts fall in two strata, as in a design that is not
orthogonal, has a row in each.
Raises
------
ValueError
If the model or an error term names a column the runs do not have.
"""
table = design_matrix.reset_index(drop=True)
terms = error_terms(strata)
for name in {factor for term in terms for factor in term.split(":")} | ({response} - {None}):
validate_identifier_is_safe(name)
if name not in table.columns:
raise ValueError(f"The runs have no column {name!r}.")
validate_formula_is_safe(f"{response or '_'} ~ {model}", [*table.columns, "_"])
factors = [c for c in table.columns if c != response]
x = dmatrix(model, table[factors].astype(str), return_type="dataframe")
treatments = [
(name, x.to_numpy()[:, columns])
for name, columns in x.design_info.term_name_slices.items()
if name != "Intercept"
]
y = None if response is None else table[response].to_numpy(dtype=float)
found: list[dict[str, Any]] = []
for name, basis in _strata(table, terms):
rows, residual = _stratum_rows(basis, treatments, y)
units = len(table) if name == "Within" else int(table[name.split(":")].drop_duplicates().shape[0])
found.append({"stratum": name, "df": int(basis.shape[1]), "units": units, "terms": rows, "residual": residual})
flat = [
{"stratum": s["stratum"], **row}
for s in found
for row in [*s["terms"], {"term": "Residual", **s["residual"]}]
if row["df"] or row["term"] != "Residual"
]
return {
"strata": found,
"df": {
s["stratum"]: {row["term"]: row["df"] for row in s["terms"]} | {"Residual": s["residual"]["df"]}
for s in found
},
"table": pd.DataFrame(flat),
}