Source code for process_improve.experiments.pseudocomponents

# (c) Kevin Dunn, 2010-2026. MIT License.
r"""L-pseudocomponents: a mixture region with lower bounds, put back on the whole simplex.

When each component :math:`i` of a mixture must be at least :math:`L_i`, the blends that remain form
a smaller simplex of the same shape inside the original (Cornell; Goos and Jones, section 6.3.4).
The L-pseudocomponents

.. math::

    x'_i = \frac{x_i - L_i}{T - \sum_j L_j}, \qquad x_i = L_i + \left(T - \sum_j L_j\right) x'_i,

with :math:`T` the mixture's total (1 for proportions, 100 for percentages), map that smaller
simplex onto the whole one: a component at its lower bound has :math:`x'_i = 0`, and
:math:`x'_i = 1` is the blend with every other component at its lower bound. A design built for
the full simplex, a simplex lattice or centroid, is then made in pseudocomponents and converted
back, and a Scheffe model fitted in pseudocomponents is better conditioned than one fitted in the
proportions of a small region.

Examples
--------
Lower bounds of 0.2, 0.2 and 0: the pure first pseudocomponent is the blend (0.8, 0.2, 0.0).

>>> from_pseudocomponents([1.0, 0.0, 0.0], [0.2, 0.2, 0.0]).tolist()
[0.8, 0.2, 0.0]
>>> to_pseudocomponents([0.5, 0.3, 0.2], [0.2, 0.2, 0.0]).round(4).tolist()
[0.5, 0.1667, 0.3333]
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence

import numpy as np
import pandas as pd

Blends = pd.DataFrame | np.ndarray | Sequence[float] | Sequence[Sequence[float]]


[docs] def to_pseudocomponents(blends: Blends, lower: Mapping[str, float] | Sequence[float], total: float = 1.0) -> Blends: r"""Return blends as L-pseudocomponents, :math:`x'_i = (x_i - L_i)/(T - \sum_j L_j)`. Parameters ---------- blends : pandas.DataFrame, numpy.ndarray or Sequence The blends, a component per column (a single blend may be one sequence). lower : Mapping[str, float] or Sequence[float] The lower bound of each component: by column name for a DataFrame (a component not named has the bound 0), or one per column. total : float The mixture's total, :math:`T`: 1 for proportions, 100 for percentages. Returns ------- pandas.DataFrame or numpy.ndarray The pseudocomponents, in the shape and type of ``blends`` (a DataFrame keeps its index and columns; a sequence comes back as an array). Raises ------ ValueError If a bound is negative or not a finite number, the bounds leave no room (:math:`\sum_j L_j \ge T`), ``lower`` does not match the components, or a blend has a value that is not a finite number. TypeError If ``lower`` names the components but ``blends`` is not a DataFrame. """ values, bounds, span = _prepare(blends, lower, total) return _like(blends, (values - bounds) / span)
[docs] def from_pseudocomponents( pseudocomponents: Blends, lower: Mapping[str, float] | Sequence[float], total: float = 1.0 ) -> Blends: r"""Return L-pseudocomponents as blends, :math:`x_i = L_i + (T - \sum_j L_j)\,x'_i`. Parameters ---------- pseudocomponents : pandas.DataFrame, numpy.ndarray or Sequence The pseudocomponents, a component per column (a single blend may be one sequence). lower : Mapping[str, float] or Sequence[float] The lower bound of each component, as :func:`to_pseudocomponents` takes them. total : float The mixture's total, :math:`T`. Returns ------- pandas.DataFrame or numpy.ndarray The blends, in the shape and type of ``pseudocomponents``. Raises ------ ValueError As :func:`to_pseudocomponents`. """ values, bounds, span = _prepare(pseudocomponents, lower, total) return _like(pseudocomponents, bounds + span * values)
def _prepare( blends: Blends, lower: Mapping[str, float] | Sequence[float], total: float ) -> tuple[np.ndarray, np.ndarray, float]: """Return the values as an array, the bound of each column, and the room the bounds leave.""" values = np.asarray(blends, dtype=float) if values.ndim not in (1, 2) or not np.all(np.isfinite(values)): msg = "The blends must be finite numbers, a component per column." raise ValueError(msg) k = values.shape[-1] if isinstance(lower, Mapping): if not isinstance(blends, pd.DataFrame): msg = "Lower bounds by name need the blends as a DataFrame with the components' names as columns." raise TypeError(msg) unknown = sorted(set(map(str, lower)) - {str(c) for c in blends.columns}) if unknown: msg = f"lower names {unknown}, which are not columns of the blends {list(blends.columns)}." raise ValueError(msg) named = {str(name): float(bound) for name, bound in lower.items()} bounds = np.array([named.get(str(column), 0.0) for column in blends.columns]) else: bounds = np.asarray(lower, dtype=float).ravel() if bounds.size != k: msg = f"lower must give one bound per component, {k}; got {bounds.size}." raise ValueError(msg) if not np.all(np.isfinite(bounds)) or np.any(bounds < 0): msg = f"Each lower bound must be a finite number, 0 or more; got {bounds.tolist()}." raise ValueError(msg) span = float(total) - float(bounds.sum()) if not np.isfinite(span) or span <= 0: msg = f"The lower bounds add to {bounds.sum():g}, which leaves no room in a total of {total:g}." raise ValueError(msg) return values, bounds, span def _like(template: Blends, values: np.ndarray) -> Blends: """Return ``values`` as ``template`` was given: a DataFrame keeps its index and columns.""" if isinstance(template, pd.DataFrame): return pd.DataFrame(values, index=template.index, columns=template.columns) return values