Source code for process_improve.workbench.variant

# (c) Kevin Dunn, 2010-2026. MIT License.
"""A model variant over a recipe's table: what is modelled, and which rows it leaves out.

A :class:`Variant` names the model (PCA or PLS), its number of components, the X and
Y columns, and the rows it **excludes**. Each block is mean-centred and scaled to unit
variance (:class:`~process_improve.multivariate.MCUVScaler`) on the fitted rows.
Excluding is not dropping: an excluded row is left out of the fit (its centring and
scaling too), then projected through the fitted model, so its scores, Hotelling's T²
and SPE are still reported and it can still be drawn. That is how a reader sees where
a suspect batch would fall in a model that never saw it.

:meth:`Variant.fit` returns a :class:`Fit`, and :meth:`Fit.explain_group` answers the
question a cluster on a score plot asks: what do these rows have in common that the
others do not? :meth:`Variant.cross_validate` answers the one before it: how many
components does the data support?
"""

from __future__ import annotations

import dataclasses
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast

import numpy as np
import pandas as pd

from process_improve.multivariate import PCA, PLS, MCUVScaler

from ._expressions import python_literal
from .recipe import RowId, _is_row_id, _text, _texts

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence

#: The models a variant can be, by the name a project file stores.
MODELS: dict[str, type[PCA | PLS]] = {"PCA": PCA, "PLS": PLS}


[docs] @dataclass(frozen=True) class Variant: """A model over a table: its kind, size, blocks, scaling, and excluded rows. Parameters ---------- kind : {"PCA", "PLS"} n_components : int x_columns : tuple of str The X block. Empty takes every numeric column that is not in ``y_columns``. y_columns : tuple of str The Y block; PLS needs at least one column, PCA none. excluded : tuple of row ids Rows left out of the fit (and of its centring and scaling), then projected. name : str What the reader calls this variant. """ kind: str = "PCA" n_components: int = 2 x_columns: tuple[str, ...] = () y_columns: tuple[str, ...] = () excluded: tuple[RowId, ...] = () name: str = "Model 1" def __post_init__(self) -> None: """Refuse a model kind that is not offered, a component count below one, and PLS without a Y.""" if self.kind not in MODELS: raise ValueError(f"Unknown model {self.kind!r}; choose from {', '.join(MODELS)}.") if not isinstance(self.n_components, int) or isinstance(self.n_components, bool) or self.n_components < 1: raise ValueError(f"n_components must be a whole number of at least 1, got {self.n_components!r}.") if self.kind == "PLS" and not self.y_columns: raise ValueError("A PLS model needs at least one Y column.")
[docs] def blocks(self, table: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame | None]: """Return the X block and the Y block (None for PCA) of ``table``, every row kept.""" y_cols = list(self.y_columns) x_cols = list(self.x_columns) or [ c for c in table.columns if c not in y_cols and pd.api.types.is_numeric_dtype(table[c]) ] missing = [c for c in [*x_cols, *y_cols] if c not in table.columns] if missing: raise ValueError(f"The table has no column {', '.join(map(str, missing))}.") text = [c for c in [*x_cols, *y_cols] if not pd.api.types.is_numeric_dtype(table[c])] if text: raise ValueError(f"The columns {', '.join(map(str, text))} are not numbers; read them as numbers first.") x_block = table[x_cols].astype(float) return x_block, (table[y_cols].astype(float) if y_cols else None)
[docs] def included(self, table: pd.DataFrame) -> pd.Index: """Return the rows of ``table`` the model is fitted to: every row the variant does not exclude. Raises ------ ValueError If an excluded row is not in the table, or too few rows remain to fit ``n_components`` components. """ unknown = [r for r in self.excluded if r not in table.index] if unknown: raise ValueError(f"The excluded rows {', '.join(map(str, unknown))} are not in the table.") included = table.index.difference(pd.Index(list(self.excluded)), sort=False) if len(included) <= self.n_components: raise ValueError( f"{len(included)} rows remain after the exclusions, too few for {self.n_components} components." ) return included
[docs] def fit(self, table: pd.DataFrame) -> Fit: """Fit the variant to ``table``, then project its excluded rows through the model. Raises ------ ValueError If an excluded row is not in the table, or too few rows remain to fit ``n_components`` components. """ x_all, y_all = self.blocks(table) included = self.included(table) scaler_x = MCUVScaler().fit(x_all.loc[included]) x_scaled = scaler_x.transform(x_all) if self.kind == "PLS": y_block = cast("pd.DataFrame", y_all) # __post_init__ refuses PLS without Y scaler_y = MCUVScaler().fit(y_block.loc[included]) y_scaled = scaler_y.transform(y_block) # The blocks are already scaled on the fitted rows: PLS must not scale them again. model: PCA | PLS = PLS(n_components=self.n_components, scale=False).fit( x_scaled.loc[included], y_scaled.loc[included] ) else: scaler_y, y_scaled = None, None model = PCA(n_components=self.n_components).fit(x_scaled.loc[included]) return Fit( variant=self, table=table, model=model, x_scaled=x_scaled, y_scaled=y_scaled, scalers=(scaler_x, scaler_y), )
[docs] def cross_validate(self, table: pd.DataFrame, *, random_state: int = 0) -> CrossValidation: """Cross-validate every number of components on the fitted rows, to choose ``n_components``. The library's own selectors do the work, with their defaults: PCA holds out single cells and predicts each from a model that never saw it (:meth:`PCA.select_n_components`, element-wise k-fold), and PLS holds out whole rows, in shuffled folds repeated ten times (:meth:`PLS.select_n_components`). Both fit the centring and scaling inside every fold, so they are given the raw blocks. The answer does not depend on this variant's ``n_components``. Parameters ---------- table : pd.DataFrame The recipe's table; the variant's excluded rows are left out, as in :meth:`fit`. random_state : int, default 0 Seed of the fold assignment, so that the same table gives the same answer. Returns ------- CrossValidation """ x_all, y_all = self.blocks(table) x = x_all.loc[self.included(table)] if self.kind == "PLS": y = cast("pd.DataFrame", y_all).loc[x.index] # Y in units of its own standard deviation, so the prediction error is pooled over the targets # with the equal weights the model gives them; in its own units, a target with large numbers (a # molecular weight) would decide the count alone. The folds re-centre and re-scale it as before. result = PLS.select_n_components(x, y / y.std(), random_state=random_state) q2, held_out, folds = result.r2y_validated["total"], "rows", result.per_fold_rmsecv.shape[1] else: result = PCA.select_n_components(x, random_state=random_state) q2, held_out, folds = result.q2, "cells", result.per_fold_press.shape[1] largest = dataclasses.replace(self, n_components=len(q2)).fit(table) return CrossValidation( variant=self, r2=largest.model.r2_cumulative_.rename("R2"), q2=q2.rename("Q2"), q2_se=result.q2_se.rename("Q2 standard error"), n_components=int(result.n_components), rule=str(result.selection_rule), held_out=held_out, folds=int(folds), random_state=random_state, )
[docs] def to_python(self, frame: str = "df") -> str: """Return the code that fits this variant to the table named ``frame``, as a recipe's script leaves it.""" lines = [f"from process_improve.multivariate import {self.kind}, MCUVScaler", ""] if self.x_columns: lines.append(f"X = {frame}[{python_literal(list(self.x_columns))}].astype(float)") else: drop = f".drop(columns={python_literal(list(self.y_columns))})" if self.y_columns else "" lines.append(f'X = {frame}{drop}.select_dtypes("number").astype(float)') if self.y_columns: lines.append(f"Y = {frame}[{python_literal(list(self.y_columns))}].astype(float)") lines.append(f"excluded = {python_literal(list(self.excluded))} # left out of the fit, projected afterwards") lines.append("included = X.index.difference(excluded, sort=False)") lines.append("scaler_x = MCUVScaler().fit(X.loc[included])") if self.kind == "PLS": lines.append("scaler_y = MCUVScaler().fit(Y.loc[included])") lines.append( f"model = PLS(n_components={self.n_components}, scale=False).fit(" "scaler_x.transform(X.loc[included]), scaler_y.transform(Y.loc[included]))" ) else: lines.append(f"model = PCA(n_components={self.n_components}).fit(scaler_x.transform(X.loc[included]))") if self.excluded: # the scaler refuses an empty block, so a variant that excludes nothing projects nothing lines.append("projected = model.project(scaler_x.transform(X.loc[excluded])) # scores, T2 and SPE") return "\n".join(lines) + "\n"
[docs] def to_dict(self) -> dict[str, Any]: """Return the variant as JSON-ready data.""" out = dataclasses.asdict(self) return {k: list(v) if isinstance(v, tuple) else v for k, v in out.items()}
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> Variant: """Rebuild a variant from :meth:`to_dict`'s output, checking every field.""" excluded = data.get("excluded", []) if not isinstance(excluded, list | tuple) or not all(_is_row_id(v) for v in excluded): raise TypeError(f"'excluded' must be a list of row ids, got {excluded!r}.") n = data.get("n_components", 2) if not isinstance(n, int) or isinstance(n, bool): raise TypeError(f"'n_components' must be a whole number, got {n!r}.") return cls( kind=_text(data, "kind", default="PCA"), n_components=n, x_columns=_texts({"x": data.get("x_columns", [])}, "x"), y_columns=_texts({"y": data.get("y_columns", [])}, "y"), excluded=tuple(excluded), name=_text(data, "name", default="Model 1"), )
[docs] @dataclass(frozen=True) class Fit: """A fitted variant: the model, and every row's scores, T² and SPE, the excluded rows projected.""" variant: Variant table: pd.DataFrame model: PCA | PLS x_scaled: pd.DataFrame y_scaled: pd.DataFrame | None scalers: tuple[MCUVScaler, MCUVScaler | None] @property def excluded(self) -> pd.Index: """The rows left out of the fit, in table order.""" return self.table.index[self.table.index.isin(list(self.variant.excluded))]
[docs] def rows(self) -> pd.DataFrame: """Return one row per table row: its scores, Hotelling's T² and SPE at all components, and ``excluded``. A fitted row carries the values the fit computed; an excluded row the values of projecting it through the model, its missing cells estimated from the observed ones (:meth:`PCA.project`). """ model = self.model fitted = model.scores_.copy() fitted["T2"] = model.hotellings_t2_.iloc[:, -1].to_numpy() fitted["SPE"] = model.spe_.iloc[:, -1].to_numpy() frames = [fitted.assign(excluded=False)] if len(self.excluded): projected = model.project(self.x_scaled.loc[self.excluded]) scores = pd.DataFrame(projected.scores.to_numpy(), index=self.excluded, columns=model.scores_.columns) scores["T2"] = projected.hotellings_t2.to_numpy() scores["SPE"] = projected.spe.to_numpy() frames.append(scores.assign(excluded=True)) return pd.concat(frames).reindex(self.table.index)
[docs] def r2x(self) -> list[float]: """Return the fraction of the fitted X block's variance each component explains, in order.""" x_fit = self.x_scaled.loc[self.model.scores_.index].to_numpy() total = float(np.nansum(x_fit**2)) residual = [ float(np.nansum(self.model.spe_.iloc[:, k].to_numpy() ** 2)) for k in range(self.model.n_components_) ] cumulative = [1.0 - r / total for r in residual] return [cumulative[0], *np.diff(cumulative).tolist()]
[docs] def limits(self, conf_level: float = 0.95) -> dict[str, float]: """Return the T² and SPE limits at ``conf_level``. ``"T2"`` judges the rows the model was fitted to, by the beta law, and ``"T2 new rows"`` the rows it left out or never saw, by the F law (#978). """ return { "T2": float(self.model.hotellings_t2_limit(conf_level, judged_rows="fitted")), "T2 new rows": float(self.model.hotellings_t2_limit(conf_level, judged_rows="new")), "SPE": float(self.model.spe_limit(conf_level=conf_level)), }
[docs] def explain_group( self, group: Sequence[RowId], reference: Sequence[RowId] | None = None, components: Sequence[int] | None = None, top: int = 5, ) -> GroupExplanation: """Explain why ``group`` sits where it does: which variables move its average score away from the reference's. Parameters ---------- group : sequence of row ids The rows of interest, for example a lasso on a score plot. Excluded rows may be part of it. reference : sequence of row ids, optional The rows to compare against. Default: every fitted row outside the group. components : sequence of int, optional 1-based components to explain; default all. top : int, default 5 How many variables :attr:`GroupExplanation.top` names. Returns ------- GroupExplanation """ index = self.table.index group_ids = _rows_in(index, group, "group") if reference is None: fitted = self.model.scores_.index reference_ids = fitted[~fitted.isin(group_ids)] else: reference_ids = _rows_in(index, reference, "reference") if len(reference_ids) == 0: raise ValueError("The reference holds no rows: there is nothing to compare the group against.") chosen = list(components) if components is not None else list(range(1, self.variant.n_components + 1)) x = _filled(self) contributions = pd.DataFrame( { f"PC{a}": self.model.group_contributions(x, group=group_ids, reference=reference_ids, component=a) for a in chosen } ) biggest = contributions.abs().max(axis=1).sort_values(ascending=False) top_variables = list(biggest.index[: max(0, top)]) raw, _ = self.variant.blocks(self.table) g, r = raw.loc[group_ids], raw.loc[reference_ids] spread = r.std(ddof=1).replace(0.0, np.nan) summary = pd.DataFrame( { "group mean": g.mean(), "reference mean": r.mean(), "difference": g.mean() - r.mean(), "difference in reference SDs": (g.mean() - r.mean()) / spread, } ).loc[biggest.index] return GroupExplanation( group=list(group_ids), reference=list(reference_ids), contributions=contributions.loc[biggest.index], top=top_variables, summary=summary, evidence=raw[top_variables].assign(in_group=index.isin(group_ids), in_reference=index.isin(reference_ids)), )
[docs] @dataclass(frozen=True) class CrossValidation: """How well each number of components predicts values the model was not fitted to. Attributes ---------- variant : Variant The variant that was cross-validated; its ``n_components`` plays no part. r2 : pd.Series Cumulative R² of the fitted rows per number of components, indexed ``1..A``: R²X for PCA, R²Y for PLS. It only rises as components are added. q2 : pd.Series The same quantity measured on held-out values, Q², on the same index. It falls once a component fits noise, which is what separates it from ``r2``. q2_se : pd.Series One standard error of ``q2``, from the spread over the folds. n_components : int The number of components the selector's rule recommends. rule : str That rule: ``"min"`` (the lowest prediction error) or ``"1se"`` (the fewest components within one standard error of it). held_out : {"cells", "rows"} What a fold holds out: single cells (PCA) or whole rows (PLS). folds : int The number of fold fits behind each number of components, over all repeats. random_state : int The seed of the fold assignment. """ variant: Variant r2: pd.Series q2: pd.Series q2_se: pd.Series n_components: int rule: str held_out: str folds: int random_state: int
[docs] def to_python(self) -> str: """Return the code that repeats this cross-validation, after the variant's code defines ``X`` and ``Y``.""" seed = f"random_state={self.random_state}" if self.variant.kind == "PLS": return ( "y_fit = Y.loc[included]\n" f"cv = PLS.select_n_components(X.loc[included], y_fit / y_fit.std(), {seed}) # targets weighed alike\n" 'q2 = cv.r2y_validated["total"] # Q2 per number of components; cv.n_components is the one to use\n' ) return ( f"cv = PCA.select_n_components(X.loc[included], {seed})\n" "q2 = cv.q2 # Q2 per number of components; cv.n_components is the one to use\n" )
[docs] @dataclass(frozen=True) class GroupExplanation: """Why a group of rows differs from a reference, variable by variable. Attributes ---------- group, reference : list of row ids contributions : pd.DataFrame One row per variable, one column per component: each variable's share of the difference between the group's average score and the reference's. A column sums to that difference. Rows are ordered by their largest absolute share. top : list of str The variables with the largest shares, the ones to look at first. summary : pd.DataFrame Per variable, in the same order, the group's and the reference's means in the table's own units, their difference, and the difference in units of the reference's standard deviation. evidence : pd.DataFrame The raw values of the top variables for every row, in table order, with ``in_group`` and ``in_reference`` flags: what to draw beside the bars. """ group: list[RowId] reference: list[RowId] contributions: pd.DataFrame top: list[str] summary: pd.DataFrame evidence: pd.DataFrame
[docs] def score_difference(self) -> pd.Series: """Return, per component, the difference between the group's average score and the reference's.""" return self.contributions.sum()
def _rows_in(index: pd.Index, rows: Sequence[RowId], name: str) -> pd.Index: selected = index[index.isin(list(rows))] unknown = [r for r in rows if r not in index] if unknown: raise ValueError(f"The {name} names rows that are not in the table: {', '.join(map(str, unknown[:5]))}.") if len(selected) == 0: raise ValueError(f"The {name} holds no rows.") return selected def _filled(fit: Fit) -> pd.DataFrame: """Return the scaled X block with each missing cell replaced by the model's estimate of it. The group's average of a column with a missing cell would otherwise be missing, and so would every contribution. A fitted row is reconstructed from its scores, an excluded one from its projected scores: ``x_hat = t P'`` with the X loadings. """ x = fit.x_scaled if not x.isna().to_numpy().any(): return x rows = fit.rows() scores = rows.iloc[:, : fit.variant.n_components].to_numpy() loadings = fit.model.loadings_ if isinstance(fit.model, PCA) else fit.model.x_loadings_ estimate = pd.DataFrame(scores @ loadings.to_numpy().T, index=x.index, columns=x.columns) return x.fillna(estimate)