Source code for process_improve.univariate.reference_distribution

# (c) Kevin Dunn, 2010-2026. MIT License.
r"""A reference distribution from a record of past results, to judge a new difference of averages.

A plant trial compares the average of :math:`n` runs by one method with the average of the next
:math:`n` by another. Whether the difference is unusual can be judged against the same process's
own history, without assuming that the observations are independent or normally distributed
(Box, Hunter and Hunter, chapter 2):

- **All differences.** Every difference of two adjacent averages of :math:`n` consecutive past
  observations (the later less the earlier) forms the reference set. The significance level of
  an observed difference is the share of the set at least as extreme. A member equal to it counts
  half, as Box, Hunter and Hunter count a tie.
- **Nearly independent differences.** Adjacent members of that set share observations, so they
  are correlated. Taking only the differences of groups of :math:`2n` a gap of one observation
  apart gives :math:`m` nearly independent differences. Their root mean square about zero,
  :math:`s = \sqrt{\sum d^2/m}`, scales the observed difference as a t statistic on :math:`m`
  degrees of freedom.

Examples
--------
Box, Hunter and Hunter's 210 past batch yields, against the plant trial's difference of 1.30
between averages of 10:

>>> import numpy as np
>>> from process_improve.experiments.reference_catalogue import reference_experiments
>>> [record] = [e for e in reference_experiments(source="box-hunter-hunter-1978")
...             if e["name"] == "bhh-production-record-210"]
>>> history = [row[1] for row in record["runs"]["rows"]]
>>> found = reference_distribution(history, 10, observed=1.30)
>>> found["n_differences"], found["n_beyond"], round(found["significance"], 3)
(191, 9, 0.047)
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

import numpy as np
from scipy import stats

#: How the observed difference is judged: ``"greater"`` when the later average is expected to be
#: larger, ``"less"`` when it is expected to be smaller.
ALTERNATIVES = ("greater", "less")

#: Differences closer than this share of the record's spread to the observed one are ties.
TIE_TOLERANCE = 1e-9


[docs] def reference_distribution( history: Sequence[float], window: int, observed: float | None = None, alternative: str = "greater", gap: int = 1, ) -> dict[str, Any]: r"""Build the reference distribution of differences of adjacent averages, and judge a difference by it. Parameters ---------- history : Sequence[float] Past observations of the process, in time order. window : int :math:`n`, the number of observations in each average: as many as each method had in the trial. observed : float or None The trial's difference, the later average less the earlier; None gives the reference distribution alone. alternative : str ``"greater"`` (the later method is expected to give larger values) or ``"less"``. gap : int The number of past observations skipped between the groups of :math:`2n` that give the nearly independent differences. Returns ------- dict[str, Any] - ``differences``: every difference of adjacent averages, in time order, and ``n_differences``, their number; - ``nearly_independent``: the differences of groups of :math:`2n` a ``gap`` apart, ``s`` their root mean square about zero, and ``df`` their number; - with ``observed``: ``n_beyond`` and ``n_tied``, the members beyond it and equal to it, ``significance`` (``n_beyond`` plus half ``n_tied``, over ``n_differences``), ``t`` (the observed difference over ``s``) and ``t_significance``, the share of a t distribution on ``df`` degrees of freedom beyond ``t``. Raises ------ ValueError If the history is not finite numbers, ``window`` or ``gap`` is not a whole number of at least 1 (0 or more for ``gap``), the history is too short for one difference, or ``alternative`` is not one of :data:`ALTERNATIVES`. """ record = np.asarray(history, dtype=float) _check(record, window, alternative, gap) averages = np.lib.stride_tricks.sliding_window_view(record, window).mean(axis=1) differences = averages[window:] - averages[:-window] starts = range(0, len(record) - 2 * window + 1, 2 * window + gap) independent = np.array([differences[start] for start in starts]) s = float(np.sqrt(independent @ independent / len(independent))) found: dict[str, Any] = { "differences": differences.tolist(), "n_differences": len(differences), "nearly_independent": independent.tolist(), "s": s, "df": len(independent), } if observed is None: return found sign = 1.0 if alternative == "greater" else -1.0 tolerance = TIE_TOLERANCE * max(float(np.ptp(record)), abs(observed), 1.0) tied = np.abs(differences - observed) <= tolerance beyond = (sign * (differences - observed) > 0) & ~tied t_statistic = observed / s if s > 0 else np.copysign(np.inf, observed) return found | { "n_beyond": int(beyond.sum()), "n_tied": int(tied.sum()), "significance": (int(beyond.sum()) + int(tied.sum()) / 2) / len(differences), "t": float(t_statistic), "t_significance": float(stats.t.sf(sign * t_statistic, len(independent))), }
def _check(record: np.ndarray, window: int, alternative: str, gap: int) -> None: """Refuse a history, window, alternative or gap the reference distribution cannot use.""" if record.ndim != 1 or not np.all(np.isfinite(record)): msg = "history must be a sequence of finite numbers, in time order." raise ValueError(msg) if isinstance(window, bool) or not isinstance(window, (int, np.integer)) or window < 1: msg = f"window must be a whole number of at least 1; got {window!r}." raise ValueError(msg) if isinstance(gap, bool) or not isinstance(gap, (int, np.integer)) or gap < 0: msg = f"gap must be a whole number, 0 or more; got {gap!r}." raise ValueError(msg) if len(record) < 2 * window: msg = f"A history of {len(record)} observations is too short for one difference of averages of {window}." raise ValueError(msg) if alternative not in ALTERNATIVES: msg = f"alternative must be one of {ALTERNATIVES}; got {alternative!r}." raise ValueError(msg)