Source code for process_improve.experiments.incomplete_blocks

# (c) Kevin Dunn, 2010-2026. MIT License.
r"""Balanced incomplete block designs: the intrablock, interblock and combined estimates of the treatments.

In a balanced incomplete block design (BIBD) each of :math:`b` blocks holds :math:`k` of the
:math:`a` treatments, each treatment appears :math:`r` times and each pair of treatments shares
:math:`\lambda` blocks. A treatment total then carries the effects of the blocks it fell in, so the
treatments are compared within blocks (Montgomery, section 4.4; Yates 1940):

- the adjusted treatment total :math:`Q_i = y_{i.} - \tfrac{1}{k}\sum_j n_{ij}\,y_{.j}` removes them,
  with :math:`n_{ij}` 1 when treatment :math:`i` is in block :math:`j`;
- the intrablock estimate of the treatment effect is :math:`\hat\tau_i = k Q_i/(\lambda a)`, and the
  adjusted treatment sum of squares :math:`k\sum_i Q_i^2/(\lambda a)`.

When the blocks are a random sample, the block totals also carry information about the
treatments, Yates's *recovery of interblock information*:

- the interblock estimate :math:`\tilde\tau_i = [\sum_j n_{ij}\,y_{.j} - k r \bar y_{..}]/(r - \lambda)`;
- the combined estimate weights the two by their variances,

  .. math::

      \tau_i^* = \frac{k Q_i (\sigma^2 + k\sigma_\beta^2) + \left(\sum_j n_{ij}\,y_{.j}
                 - k r \bar y_{..}\right)\sigma^2}{(r - \lambda)\sigma^2 + \lambda a (\sigma^2 + k\sigma_\beta^2)},

  with :math:`\sigma^2` the intrablock mean square for error and
  :math:`\hat\sigma_\beta^2 = [MS_\text{blocks (adjusted)} - MS_E](b - 1)/[a(r - 1)]`, taken as 0
  when it comes out negative.

Examples
--------
Montgomery's Example 4.5, four catalysts in four batches of three:

>>> import pandas as pd
>>> runs = pd.DataFrame({
...     "catalyst": [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4],
...     "batch": [1, 2, 4, 2, 3, 4, 1, 2, 3, 1, 3, 4],
...     "time": [73, 74, 71, 75, 67, 72, 73, 75, 68, 75, 72, 75],
... })
>>> found = bibd_analysis(runs, "catalyst", "batch", "time")
>>> found["concurrences"], {t: round(v, 2) for t, v in found["interblock_effects"].items()}
(2, {'1': 10.5, '2': -3.5, '3': -0.5, '4': -6.5})
"""

from __future__ import annotations

import itertools
from typing import Any

import numpy as np
import pandas as pd


[docs] def bibd_analysis(data: pd.DataFrame, treatment: str, block: str, response: str) -> dict[str, Any]: r"""Analyse a balanced incomplete block design: intrablock, interblock and combined treatment estimates. Parameters ---------- data : pandas.DataFrame One row per run. treatment : str The column of the treatment labels. block : str The column of the block labels. response : str The column of the response. Returns ------- dict[str, Any] The design's ``n_treatments`` (a in Montgomery's notation), ``n_blocks`` (b), ``block_size`` (k), ``replicates`` (r, the runs of each treatment), ``concurrences`` (lambda, the blocks each pair of treatments shares) and ``n_runs`` (N); by treatment label (as a string): ``treatment_totals`` :math:`y_{i.}`, ``adjusted_treatment_totals`` :math:`Q_i`, ``intrablock_effects`` :math:`\hat\tau_i`, ``totals_of_blocks_with`` :math:`\sum_j n_{ij} y_{.j}`, ``interblock_effects`` :math:`\tilde\tau_i` and ``combined_effects`` :math:`\tau_i^*`; by block label: ``block_totals`` :math:`y_{.j}` and ``adjusted_block_totals`` :math:`Q'_j = y_{.j} - \tfrac{1}{r}\sum_i n_{ij}\,y_{i.}`; ``sums_of_squares`` (``total``, ``treatments_unadjusted``, ``treatments_adjusted``, ``blocks_unadjusted``, ``blocks_adjusted``, ``error``), ``df_error``, ``ms_error`` and ``sigma2_blocks`` (:math:`\hat\sigma_\beta^2`). Raises ------ ValueError If a column is missing, a response is not a finite number, a treatment appears twice in a block, or the design is not a balanced incomplete block design: blocks of different sizes, a block holding every treatment, treatments run a different number of times, or pairs of treatments sharing different numbers of blocks. """ y, incidence, treatments, blocks = _layout(data, treatment, block, response) a, b = incidence.shape k, r, lam = _balance(incidence, treatments) n = int(incidence.sum()) treatment_totals, block_totals = y.sum(axis=1), y.sum(axis=0) grand = float(block_totals.sum()) with_blocks = incidence @ block_totals # sum over j of n_ij y_.j adjusted = treatment_totals - with_blocks / k adjusted_blocks = block_totals - incidence.T @ treatment_totals / r observed = y[incidence == 1] total = float(observed @ observed - grand**2 / n) treatments_adjusted = float(k * adjusted @ adjusted / (lam * a)) treatments_unadjusted = float(treatment_totals @ treatment_totals / r - grand**2 / n) blocks_unadjusted = float(block_totals @ block_totals / k - grand**2 / n) error = total - treatments_adjusted - blocks_unadjusted blocks_adjusted = treatments_adjusted + blocks_unadjusted - treatments_unadjusted df_error = n - a - b + 1 ms_error = error / df_error sigma2_blocks = max(0.0, (blocks_adjusted / (b - 1) - ms_error) * (b - 1) / (a * (r - 1))) between = with_blocks - k * r * grand / n within_weight = ms_error + k * sigma2_blocks combined = (k * adjusted * within_weight + between * ms_error) / ((r - lam) * ms_error + lam * a * within_weight) def by_treatment(values: np.ndarray) -> dict[str, float]: return dict(zip(treatments, values.tolist(), strict=True)) return { "n_treatments": a, "n_blocks": b, "block_size": k, "replicates": r, "concurrences": lam, "n_runs": n, "treatment_totals": by_treatment(treatment_totals), "adjusted_treatment_totals": by_treatment(adjusted), "intrablock_effects": by_treatment(k * adjusted / (lam * a)), "totals_of_blocks_with": by_treatment(with_blocks), "interblock_effects": by_treatment(between / (r - lam)), "combined_effects": by_treatment(combined), "block_totals": dict(zip(blocks, block_totals.tolist(), strict=True)), "adjusted_block_totals": dict(zip(blocks, adjusted_blocks.tolist(), strict=True)), "sums_of_squares": { "total": total, "treatments_unadjusted": treatments_unadjusted, "treatments_adjusted": treatments_adjusted, "blocks_unadjusted": blocks_unadjusted, "blocks_adjusted": blocks_adjusted, "error": error, }, "df_error": df_error, "ms_error": ms_error, "sigma2_blocks": sigma2_blocks, }
def _levels(column: pd.Series) -> list[str]: """Return a column's labels as strings, in natural order: by length, then by value.""" return sorted(column.astype(str).unique(), key=lambda label: (len(label), label)) def _layout( data: pd.DataFrame, treatment: str, block: str, response: str ) -> tuple[np.ndarray, np.ndarray, list[str], list[str]]: """Return the responses as a treatment-by-block table (0 where not run), the incidence, and the labels.""" missing = [name for name in (treatment, block, response) if name not in data.columns] if missing: msg = f"data has no column {missing}; its columns are {list(data.columns)}." raise ValueError(msg) values = pd.to_numeric(data[response], errors="coerce").to_numpy(dtype=float) if not np.all(np.isfinite(values)): msg = f"Each value of {response!r} must be a finite number." raise ValueError(msg) treatments, blocks = _levels(data[treatment]), _levels(data[block]) rows = data[treatment].astype(str).map({label: i for i, label in enumerate(treatments)}).to_numpy() columns = data[block].astype(str).map({label: j for j, label in enumerate(blocks)}).to_numpy() incidence = np.zeros((len(treatments), len(blocks))) np.add.at(incidence, (rows, columns), 1) if incidence.max() > 1: msg = "A treatment appears more than once in a block; a balanced incomplete block design runs it once." raise ValueError(msg) y = np.zeros_like(incidence) y[rows, columns] = values return y, incidence, treatments, blocks def _balance(incidence: np.ndarray, treatments: list[str]) -> tuple[int, int, int]: """Return k, r and lambda, refusing a layout that is not a balanced incomplete block design.""" a = incidence.shape[0] sizes, replicates = incidence.sum(axis=0), incidence.sum(axis=1) if len(set(sizes)) != 1 or sizes[0] >= a or len(set(replicates)) != 1: msg = ( "This is not a balanced incomplete block design: the blocks must hold the same number of " f"treatments, fewer than all {a}, and each treatment must be run equally often; the block " f"sizes are {sorted(set(sizes.astype(int).tolist()))} and the replicates " f"{sorted(set(replicates.astype(int).tolist()))}." ) raise ValueError(msg) concurrences = incidence @ incidence.T pairs = {(treatments[i], treatments[j]): int(concurrences[i, j]) for i, j in itertools.combinations(range(a), 2)} if len(set(pairs.values())) != 1: msg = ( "This is not a balanced incomplete block design: pairs of treatments share different numbers of " f"blocks, {pairs}." ) raise ValueError(msg) return int(sizes[0]), int(replicates[0]), next(iter(pairs.values()))