Source code for process_improve.experiments.true_process

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

"""A known process, for simulating the results of a designed experiment.

Before running a design on the plant, it can be run on a process whose true model is
known: simulate the responses, analyse them as if they were real, and compare what
the analysis finds with the truth. That shows what a design can and cannot recover,
such as an interaction aliased with a main effect, or a quadratic too small to detect
at the noise level, before any experiment is spent.

Also see
--------
process_improve.experiments.compare.compare_designs : the same question asked of the design alone.
"""

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

from process_improve._random import check_random_state
from process_improve.experiments.designs_utils import RESERVED_COLUMN_NAMES
from process_improve.experiments.factor import DesignResult
from process_improve.experiments.optimization import _extract_b_and_B

if TYPE_CHECKING:
    from collections.abc import Mapping

#: A square, written ``I(A ** 2)`` as the analysis names it, or ``A**2`` or ``A^2``.
_SQUARE = re.compile(r"I\(\s*(\w+)\s*\*\*\s*2\s*\)|(\w+)\s*(?:\*\*|\^)\s*2")

_TERM_HELP = (
    "a term is 'Intercept', a factor such as 'A', a product of two factors such as 'A:B' or 'A*B', "
    "or a square such as 'I(A ** 2)', 'A**2' or 'A^2'"
)


def _factor_name(name: str, term: str) -> str:
    """Return a factor name used in a term, refusing anything that is not an identifier."""
    if not name.isidentifier() or name == "Intercept":
        raise ValueError(f"Cannot read the term {term!r}: {name!r} is not a factor name; {_TERM_HELP}.")
    return name


def _canonical(term: str) -> tuple[str, tuple[str, ...]]:
    """Write a term as ``analyze_experiment`` names it, with the factors it multiplies.

    Raises
    ------
    ValueError
        If the term is not the intercept, a factor, a product of two factors or a square.
    """
    text = str(term).strip()
    if text == "Intercept":
        return text, ()
    square = _SQUARE.fullmatch(text)
    if square:
        name = _factor_name(square.group(1) or square.group(2), text)
        return f"I({name} ** 2)", (name, name)
    parts = [part.strip() for part in re.split(r"[:*]", text)]
    if len(parts) == 1:
        return _factor_name(parts[0], text), (parts[0],)
    if len(parts) == 2:
        first, second = (_factor_name(part, text) for part in parts)
        if first == second:
            return f"I({first} ** 2)", (first, first)
        return f"{first}:{second}", (first, second)
    raise ValueError(f"Cannot use the term {text!r}: only second-order models are simulated; {_TERM_HELP}.")


def _canonical_coefficients(coefficients: Mapping[str, float]) -> dict[str, float]:
    """Normalise the terms' spellings, refusing a term given twice or a value that is not a number."""
    canonical: dict[str, float] = {}
    seen: dict[tuple[str, ...], str] = {}
    for term, value in coefficients.items():
        name, factors = _canonical(term)
        key = tuple(sorted(factors))
        if key in seen:
            raise ValueError(f"The terms {seen[key]!r} and {term!r} are the same term; give it once.")
        number = float(value)
        if not math.isfinite(number):
            raise ValueError(f"The coefficient of {term!r} must be a finite number, not {value!r}.")
        seen[key] = str(term)
        canonical[name] = number
    return canonical


def _coded_frame(design: DesignResult | pd.DataFrame) -> pd.DataFrame:
    """Return a design's factor columns in coded units."""
    if isinstance(design, DesignResult):
        return design.design[list(design.factor_names)]
    if isinstance(design, pd.DataFrame):
        return design[[c for c in design.columns if c not in RESERVED_COLUMN_NAMES]]
    raise TypeError(
        "Pass the design as a DesignResult or a DataFrame of coded factor columns, so the terms can find "
        "their factors by name."
    )


[docs] @dataclass(frozen=True, eq=False) class TrueProcess: r"""A process with a known second-order model, in coded units, and normal noise. The response at coded settings :math:`\mathbf{x}` is .. math:: y = b_0 + \sum_i b_i x_i + \sum_{i<j} b_{ij} x_i x_j + \sum_i b_{ii} x_i^2 + \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, \sigma^2), with the coefficients named as :func:`analyze_experiment` reports them, so the analysis of simulated runs can be set beside the truth term by term. A factor of the design that no term names is inert: it has no effect on the response. Parameters ---------- coefficients : mapping of str to float The terms and their coefficients. A term is ``"Intercept"``, a factor (``"A"``), a product of two factors (``"A:B"`` or ``"A*B"``) or a square (``"I(A ** 2)"``, ``"A**2"`` or ``"A^2"``); each is stored under the name ``analyze_experiment`` uses. Terms of third order and above are refused, as is a term given twice under two spellings. sigma : float The standard deviation of the run-to-run noise, in the response's units. Attributes ---------- coefficients : Mapping[str, float] The terms under their canonical names, read-only. sigma : float The noise standard deviation. Raises ------ ValueError If a term cannot be read, a coefficient is not a finite number, or ``sigma`` is not a positive finite number. Examples -------- >>> import pandas as pd >>> process = TrueProcess({"Intercept": 62, "A": 4, "B*A": 1.5, "C^2": -2.25}, sigma=0.5) >>> dict(process.coefficients) {'Intercept': 62.0, 'A': 4.0, 'B:A': 1.5, 'I(C ** 2)': -2.25} >>> runs = pd.DataFrame({"A": [-1, 1, 0], "B": [1, 1, 0], "C": [0, 1, -1]}) >>> process.mean(runs).tolist() [56.5, 65.25, 59.75] """ coefficients: Mapping[str, float] sigma: float def __post_init__(self) -> None: """Normalise the terms and freeze them; check the noise level.""" object.__setattr__(self, "coefficients", MappingProxyType(_canonical_coefficients(self.coefficients))) sigma = float(self.sigma) if not (math.isfinite(sigma) and sigma > 0): raise ValueError(f"sigma must be a positive number, not {self.sigma!r}.") object.__setattr__(self, "sigma", sigma) @property def factors(self) -> tuple[str, ...]: """Return the factors the terms use, in the order they first appear.""" names: dict[str, None] = {} for term in self.coefficients: for name in _canonical(term)[1]: names[name] = None return tuple(names)
[docs] def mean(self, design: DesignResult | pd.DataFrame) -> np.ndarray: """Return the noise-free response at each run of a design. Parameters ---------- design : DesignResult or pandas.DataFrame The runs in coded units: a :class:`DesignResult` (its coded ``design``) or a DataFrame with a column per factor; ``RunOrder`` and ``Block`` are ignored. Returns ------- numpy.ndarray One value per run. Raises ------ ValueError If a term uses a factor the design does not have. TypeError If the design is not a DesignResult or a DataFrame. """ frame = _coded_frame(design) names = [str(c) for c in frame.columns] missing = [f for f in self.factors if f not in names] if missing: raise ValueError(f"The process uses the factor(s) {missing}, which the design does not have: {names}.") terms = [{"term": term, "coefficient": value} for term, value in self.coefficients.items()] intercept, linear, quadratic = _extract_b_and_B(terms, names) x = frame.to_numpy(dtype=float) return intercept + x @ linear + np.einsum("ij,jk,ik->i", x, quadratic, x)
[docs] def simulate( self, design: DesignResult | pd.DataFrame, *, random_state: int | np.random.Generator | None = None, ) -> pd.Series: """Return simulated responses: the true mean at each run plus normal noise. Parameters ---------- design : DesignResult or pandas.DataFrame The runs in coded units, as for :meth:`mean`. random_state : int, numpy.random.Generator or None Seed or generator for the noise; the same integer gives the same responses. ``None`` draws fresh noise on every call. Returns ------- pandas.Series One response per run, named ``"y"``, on the design's index. """ frame = _coded_frame(design) rng = check_random_state(random_state) values = self.mean(design) + rng.normal(0.0, self.sigma, len(frame)) return pd.Series(values, index=frame.index, name="y")