Source code for process_improve.experiments.across_blocks

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

r"""Models fitted across blocks: fit, choose the form, and predict the next block.

An experiment run in blocks (days, batches of raw material, sensory panel sessions) sees every response
shifted a little in each block. The unit of data is then one run's result in one block, and each model here
carries a block effect coded to sum to zero, so its intercept is the average block. Runs tested in more than one
block tie the blocks together.

:func:`fit_across_blocks` fits each candidate form (a model formula's right-hand side) to every response, chooses
the form with the lowest leave-one-run-out error (each run predicted by a fit without it, in every block it was
tested), and returns the chosen fit. Its :meth:`BlockedFit.predict` predicts new runs with prediction intervals in a
known block, in the average block, or in a coming block whose shift is read from runs tested again in it.
"""

from __future__ import annotations

from collections.abc import Hashable, Mapping, Sequence
from dataclasses import dataclass, field

import numpy as np
import pandas as pd
import patsy
from scipy import stats

from process_improve.experiments._lm import validate_formula_is_safe
from process_improve.univariate.metrics import _check_probability


@dataclass(frozen=True)
class _ResponseFit:
    """One response's least-squares fit over [terms, block columns]: its blocks, (X'X)^-1, residual SD and df."""

    beta: np.ndarray
    xtx_inv: np.ndarray
    residual_sd: float
    df: int
    levels: list[Hashable]  # the blocks the response was measured in, in order of first appearance


def _block_columns(blocks: Sequence[Hashable], levels: list[Hashable]) -> np.ndarray:
    """Return the block effect coded to sum to zero: one column per block but the last, which is -1 in each."""
    columns = np.zeros((len(blocks), max(len(levels) - 1, 0)))
    for row, block in enumerate(blocks):
        index = levels.index(block)
        if index == len(levels) - 1:
            columns[row] = -1.0
        else:
            columns[row, index] = 1.0
    return columns


def _fit_rows(terms: np.ndarray, blocks: np.ndarray, y: np.ndarray, rows: np.ndarray) -> _ResponseFit | None:
    """Fit the rows given, with a block effect over the blocks they hold; None when the columns cannot be told apart
    or no degree of freedom is left.
    """
    levels = list(pd.unique(blocks[rows]))
    x = np.column_stack([terms[rows], _block_columns(list(blocks[rows]), levels)])
    n, p = x.shape
    if n <= p or np.linalg.matrix_rank(x) < p:
        return None
    xtx_inv = np.linalg.inv(x.T @ x)
    beta = xtx_inv @ x.T @ y[rows]
    residuals = y[rows] - x @ beta
    return _ResponseFit(beta, xtx_inv, float(np.sqrt(residuals @ residuals / (n - p))), n - p, levels)


def _leave_one_run_out(terms: np.ndarray, blocks: np.ndarray, runs: np.ndarray, y: np.ndarray) -> tuple[float, list]:
    """
    Predict every run from a fit without it, in every block it was tested: the root mean squared error.

    Returns
    -------
    (float, list)
        The RMSE (NaN when no run could be predicted), and the runs that could not be: without them a term or a
        block has no data, so they alone carry it.
    """
    measured = np.isfinite(y)
    errors: list[float] = []
    alone = []
    for run in pd.unique(runs[measured]):
        held = measured & (runs == run)
        train = measured & (runs != run)
        fit = _fit_rows(terms, blocks, y, train)
        if fit is None or not set(blocks[held]) <= set(fit.levels):
            alone.append(run)
            continue
        x = np.column_stack([terms[held], _block_columns(list(blocks[held]), fit.levels)])
        errors += list(y[held] - x @ fit.beta)
    rmse = float(np.sqrt(np.mean(np.square(errors)))) if errors else float("nan")
    return rmse, alone


[docs] @dataclass(frozen=True) class BlockedFit: """ A model fitted across blocks by :func:`fit_across_blocks`: the chosen form, its fit, and how it was chosen. Attributes ---------- chosen_form : str or None The name of the chosen form; None when no candidate could be fitted. formula : str The chosen form's right-hand side. selection : pandas.DataFrame One row per candidate form, best first: ``estimable`` (every response can be fitted), ``n_parameters`` (terms and block columns), ``loo_rmse`` (the mean over the responses of each one's leave-one-run-out RMSE), one column per response with its own leave-one-run-out RMSE, and ``runs_alone`` (runs that alone carry a term or a block, so cannot be left out). coefficients : pandas.DataFrame The chosen form's coefficients, a row per term (``Intercept`` is the average block), a column per response. standard_errors : pandas.DataFrame Their standard errors, shaped as ``coefficients``. block_shift : pandas.DataFrame Each block's shift from the average block, a row per block, a column per response; the shifts sum to 0. block_shift_se : pandas.DataFrame Their standard errors, shaped as ``block_shift``. residual_sd : pandas.Series Each response's residual standard deviation. df : pandas.Series Each response's residual degrees of freedom. """ chosen_form: str | None formula: str selection: pd.DataFrame coefficients: pd.DataFrame standard_errors: pd.DataFrame block_shift: pd.DataFrame block_shift_se: pd.DataFrame residual_sd: pd.Series df: pd.Series _design: patsy.DesignInfo | None = field(default=None, repr=False) _fits: Mapping[str, _ResponseFit] = field(default_factory=dict, repr=False) def _terms(self, frame: pd.DataFrame) -> np.ndarray: if self._design is None: msg = "No form could be fitted, so there is nothing to predict with." raise ValueError(msg) return np.asarray(patsy.build_design_matrices([self._design], frame, NA_action="raise")[0])
[docs] def predict( self, new: pd.DataFrame, *, block: Hashable | None = None, anchors: pd.DataFrame | None = None, conf_level: float = 0.95, ) -> pd.DataFrame: r""" Predict new runs, with prediction intervals, in a known block, the average block or a coming block. Parameters ---------- new : pandas.DataFrame The runs to predict: a column per factor the formula names. block : hashable or None A block of the fit: the prediction includes its shift. anchors : pandas.DataFrame or None Runs tested again in a coming block, with their factors and the responses measured there. That block's shift is read from them: the prediction is :math:`(x_0 - \bar{x}_a)\hat\beta + \bar{y}_a`, whose error variance is :math:`\sigma^2 (1 + 1/n_a + (x_0 - \bar{x}_a)^T (X^TX)^{-1} (x_0 - \bar{x}_a))`. conf_level : float The coverage of the intervals. Returns ------- pandas.DataFrame Indexed as ``new``, with columns (response, ``prediction`` | ``lower`` | ``upper``). Notes ----- With neither ``block`` nor ``anchors``, the prediction is for the average block, and the interval adds the spread of the fitted block shifts: what a block not yet tested may do. """ _check_probability("conf_level", conf_level) if block is not None and anchors is not None: msg = "Give a block of the fit or the anchors of a coming block, not both." raise ValueError(msg) if block is not None and block not in self.block_shift.index: msg = f"{block!r} is not a block of the fit; its blocks are {list(self.block_shift.index)}." raise ValueError(msg) t0 = self._terms(new) columns: dict[tuple[str, str], np.ndarray] = {} for response, fit in self._fits.items(): n_blocks = len(fit.levels) - 1 x0 = np.column_stack([t0, np.zeros((len(t0), n_blocks))]) extra = 0.0 if block is not None and block not in fit.levels: # the response was not measured in that block nan = np.full(len(t0), np.nan) columns |= {(response, "prediction"): nan, (response, "lower"): nan, (response, "upper"): nan} continue if anchors is not None and response in anchors and np.isfinite(anchors[response].to_numpy(float)).any(): measured = np.isfinite(anchors[response].to_numpy(dtype=float)) ta = self._terms(anchors.loc[measured]) centre = np.concatenate([ta.mean(axis=0), np.zeros(n_blocks)]) shift = float(anchors[response].to_numpy(dtype=float)[measured].mean() - centre @ fit.beta) x_var = x0 - centre extra = fit.residual_sd**2 / measured.sum() elif block is not None: x0[:, t0.shape[1] :] = _block_columns([block] * len(t0), fit.levels) shift, x_var = 0.0, x0 else: # shortcut: the spread of the estimated shifts includes their estimation error, so it overstates the # block-to-block variance; a random-effects fit would separate the two, worth it once blocks are many. spread = self.block_shift[response].var(ddof=1) if n_blocks else fit.residual_sd**2 shift, x_var, extra = 0.0, x0, float(spread) mean = x0 @ fit.beta + shift variance = fit.residual_sd**2 * (1 + np.einsum("ij,jk,ik->i", x_var, fit.xtx_inv, x_var)) + extra half = stats.t.ppf(0.5 + conf_level / 2, fit.df) * np.sqrt(variance) columns |= { (response, "prediction"): mean, (response, "lower"): mean - half, (response, "upper"): mean + half, } return pd.DataFrame(columns, index=new.index)
def _check(data: pd.DataFrame, responses: Sequence[str], forms: Mapping[str, str], block: str) -> None: problems = [f"{r!r} is not a column" for r in [*responses, block] if r not in data.columns] if not forms: problems.append("give at least one candidate form") if not responses: problems.append("give at least one response") factors = [c for c in data.columns if c not in {*responses, block}] for name, form in forms.items(): if "~" in form: problems.append(f"form {name!r}: give the right-hand side only, without '~'") continue try: validate_formula_is_safe(form, factors, allow_transforms=True, allow_numpy=True) except ValueError as err: problems.append(f"form {name!r}: {err}") if problems: msg = "The data and forms cannot be fitted across blocks: " + "; ".join(problems) + "." raise ValueError(msg) def _block_effects(fit: _ResponseFit, p_terms: int) -> tuple[pd.Series, pd.Series]: """Each block's shift from the average block, and its standard error.""" contrasts = np.zeros((len(fit.levels), p_terms + len(fit.levels) - 1)) contrasts[:, p_terms:] = _block_columns(fit.levels, fit.levels) se = fit.residual_sd * np.sqrt(np.einsum("ij,jk,ik->i", contrasts, fit.xtx_inv, contrasts)) return pd.Series(contrasts @ fit.beta, index=fit.levels), pd.Series(se, index=fit.levels)
[docs] def fit_across_blocks( data: pd.DataFrame, responses: Sequence[str], forms: Mapping[str, str], *, block: str, ) -> BlockedFit: r""" Fit candidate model forms across blocks, choose one by leave-one-run-out error, and return its fit. Each form is fitted to each response by least squares, with a block effect coded to sum to zero. The rule, fixed before the data: the form that can be fitted to every response with the lowest mean (over the responses) leave-one-run-out RMSE. Leaving out a run removes it from every block it was tested in; the error is that of predicting it there from the other runs, so a form is judged on runs it has not seen. Parameters ---------- data : pandas.DataFrame One row per run per block. The index names the run, so a run tested in several blocks has several rows with one index value. The columns are the factors (numeric, or categorical as text), the block, and the responses; a response not measured in a row is NaN. A factor's value is the one the run was tested at. responses : sequence of str The response columns. forms : mapping of str to str Candidate forms, by name: each a model formula's right-hand side over the factors, such as ``"A + B + A:B + I(A**2)"``. A categorical factor can be written ``C(F, Sum)`` for effects coding; a square root as ``np.sqrt(A)``. Code the factors first when a form's meaning depends on their centre and scale. block : str The block column. Returns ------- BlockedFit The chosen form, its coefficients and block shifts, the table of every candidate, and ``predict``. Raises ------ ValueError If a column is missing, a form is not a safe right-hand side over the factors, or a factor is missing in a row (patsy's ``NA_action="raise"``). Examples -------- >>> fit = fit_across_blocks(data, ["y"], {"linear": "A + B", "quadratic": "A + B + A:B + I(A**2)"}, block="day") >>> fit.chosen_form, fit.selection >>> fit.predict(new_runs, anchors=repeats_tested_today) """ _check(data, responses, forms, block) runs = np.asarray(data.index) blocks = data[block].to_numpy() levels = list(pd.unique(blocks)) rows, candidates = [], {} for name, form in forms.items(): terms = patsy.dmatrix(form, data, NA_action="raise", return_type="dataframe") t = terms.to_numpy() row: dict = {"form": name, "n_parameters": t.shape[1] + len(levels) - 1} fits, alone = {}, set() for response in responses: y = data[response].to_numpy(dtype=float) fits[response] = _fit_rows(t, blocks, y, np.isfinite(y)) row[response], runs_alone = _leave_one_run_out(t, blocks, runs, y) if fits[response] else (np.nan, []) alone |= set(runs_alone) row |= {"estimable": all(f is not None for f in fits.values()), "runs_alone": sorted(alone, key=str)} row["loo_rmse"] = float(np.nanmean([row[r] for r in responses])) if row["estimable"] else np.nan rows.append(row) candidates[name] = (terms.design_info, fits) selection = pd.DataFrame(rows).set_index("form") selection = selection[["estimable", "n_parameters", "loo_rmse", *responses, "runs_alone"]] usable = selection[selection["estimable"] & selection["loo_rmse"].notna()] chosen = str(usable["loo_rmse"].idxmin()) if len(usable) else None selection = selection.sort_values("loo_rmse", na_position="last", kind="stable") return _blocked_fit(chosen, forms[chosen] if chosen else "", selection, candidates, levels)
def _blocked_fit( chosen: str | None, formula: str, selection: pd.DataFrame, candidates: Mapping[str, tuple[patsy.DesignInfo, Mapping[str, _ResponseFit | None]]], levels: list[Hashable], ) -> BlockedFit: """Gather the chosen form's fit (``candidates`` holds each form's design and fit per response) into tables.""" responses = list(next(iter(candidates.values()))[1]) empty = pd.DataFrame(columns=responses, dtype=float) if chosen is None: nan = pd.Series(np.nan, index=responses) return BlockedFit(None, "", selection, empty, empty, empty, empty, nan, nan) design, fitted = candidates[chosen] fits = {r: f for r, f in fitted.items() if f is not None} p_terms = len(design.column_names) shifts = {r: _block_effects(f, p_terms) for r, f in fits.items()} blocks = pd.Index(levels, name="block") return BlockedFit( chosen_form=chosen, formula=formula, selection=selection, coefficients=pd.DataFrame({r: f.beta[:p_terms] for r, f in fits.items()}, index=design.column_names), standard_errors=pd.DataFrame( {r: f.residual_sd * np.sqrt(np.diag(f.xtx_inv)[:p_terms]) for r, f in fits.items()}, index=design.column_names, ), block_shift=pd.DataFrame({r: s[0] for r, s in shifts.items()}).reindex(blocks), block_shift_se=pd.DataFrame({r: s[1] for r, s in shifts.items()}).reindex(blocks), residual_sd=pd.Series({r: f.residual_sd for r, f in fits.items()}), df=pd.Series({r: f.df for r, f in fits.items()}), _design=design, _fits=fits, )