Source code for process_improve.multivariate._tpls

# (c) Kevin Dunn, 2010-2026. MIT License. Based on own private work over the years.
"""T-PLS (PLS for T-shaped data structures) estimator and its data container (ENG-01).

Holds :class:`DataFrameDict`, the container for the partitionable (Z, F) and
static (Y) data blocks, and :class:`TPLS`, the sklearn-compatible T-PLS
regressor. ("TPLS" here means PLS for T-shaped data structures, not "Total PLS"
or "Three-way PLS".)
"""

from __future__ import annotations

import inspect
import logging
import numbers
import time
import typing
import warnings
from collections.abc import Callable, KeysView
from functools import partial

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, RegressorMixin, _fit_context
from sklearn.metrics import (
    mean_absolute_error,
    mean_squared_error,
    r2_score,
    root_mean_squared_error,
)
from sklearn.utils import Bunch
from sklearn.utils.validation import check_array, check_is_fitted

from ._common import (
    DiagnoseResult,
    NotEnoughVarianceError,
    SpecificationWarning,
    _equal_values,
    _model_method,
    _nz,
    _own_axes,
    check_at_least_one,
    check_variance_left,
    check_y_varies,
    column_sd,
    components_to_fit,
    constant_columns,
    epsqrt,
    largest_ssq_column,
    refuse_empty_block,
    resolve_loop_settings,
    unobserved_columns,
    user_stacklevel,
)
from ._diagnostics import _t2_score_sd
from ._limits import JudgedRows, spe_calculation
from ._limits import ellipse_coordinates as _ellipse_coordinates
from ._limits import hotellings_t2_limit as _hotellings_t2_limit
from ._nipals import internal_pls_nipals_fit_one_pc, nan_to_zeros, regress_a_space_on_b_row
from ._optimal_inversion import Probe, finite_or
from ._optimal_inversion import inversion_sensitivity as _inversion_sensitivity
from ._optimal_inversion import optimal_inversion as _optimal_inversion
from .plots import Plot


[docs] class DataFrameDict(dict): """Container for the partitionable (Z, F) and static (Y) data blocks used by TPLS."""
[docs] def __init__(self, datadict: dict[str, dict[str, pd.DataFrame]]): """ Initialize a DataFrameDict to handle partitionable and static dataframes. datadict: Dictionary with 3 keys, one for each block: Z, F and Y. Each block is itself a dictionary of dataframes: dict[str, dict[str, pd.DataFrame]] """ self.partitionable_blocks: list[str] = ["Z", "F", "Y"] self.datadict: dict[str, dict[str, pd.DataFrame]] = {} for block in self.partitionable_blocks: self.datadict[block] = datadict.get(block, {}) first_group = next(iter(self.datadict["F"].keys())) self.n_samples = self.datadict["F"][first_group].shape[0] self.shape = (self.n_samples, len(self.datadict)) # Some basic checks: each dataframe inside each block has the same number of rows for block in self.partitionable_blocks: for group, df in self.datadict[block].items(): if not isinstance(df, pd.DataFrame): raise TypeError( f"Expected a DataFrame for block {block}, group '{group}'; got {type(df).__name__}." ) if df.shape[0] != self.n_samples: raise ValueError( f"DataFrames in block {block} must have the same number of rows ({self.n_samples}). " f"Group {group} has {df.shape[0]} rows." )
[docs] def keys(self) -> KeysView[str]: # type: ignore[override] # reason: narrows dict.keys to str view """Return the keys of the DataFrameDict.""" return self.datadict.keys()
def __setitem__(self, key: str, value: pd.DataFrame | dict) -> None: """Set a DataFrame for a specific key in the DataFrameDict.""" if key not in self.partitionable_blocks: raise KeyError(f"Key {key} is not a valid partitionable block. Valid keys are: {self.partitionable_blocks}") if not isinstance(value, pd.DataFrame): raise TypeError(f"Expected a DataFrame for key {key}, got {type(value)}.") if value.shape[0] != self.n_samples: raise ValueError( f"DataFrames in block {key} must have the same number of rows ({self.n_samples}). " f"Provided DataFrame has {value.shape[0]} rows." ) # The setter intentionally stores a bare DataFrame here (not the nested # dict[str, DataFrame] used at construction); preserve that behaviour. self.datadict[key] = value # type: ignore[assignment] # reason: legacy bare-DataFrame storage @typing.overload def __getitem__(self, lookup: str) -> dict[str, pd.DataFrame]: pass @typing.overload def __getitem__(self, lookup: int | list[int] | list[np.int64] | np.ndarray | tuple) -> DataFrameDict: pass def __getitem__( self, lookup: int | list[int] | list[np.int64] | str | np.ndarray | tuple ) -> DataFrameDict | dict[str, pd.DataFrame]: """Return a new DataFrameDict with partitioned data.""" if isinstance(lookup, str): return self.datadict[lookup] # returns the `dict[str, pd.DataFrame]` version of the function datadict: dict[str, dict[str, pd.DataFrame]] = {} for block in self.partitionable_blocks: datadict[block] = {} for group, df in self.datadict[block].items(): match lookup: case int() | np.integer(): datadict[block][group] = df.iloc[[lookup]] case list(): datadict[block][group] = df.iloc[lookup] # type: ignore[index] # reason: list of np.int64 case np.ndarray(): datadict[block][group] = df.iloc[lookup.tolist()] case tuple(): if lookup[1] == Ellipsis: datadict[block][group] = df.iloc[[int(item) for item in lookup[0]]] else: raise TypeError(f"Invalid tuple structure for lookup: {lookup}") case _: raise TypeError( f"Lookup must be an int, list of ints, or a string. Got {lookup}; {type(lookup)}" ) return DataFrameDict(datadict) def __len__(self): """Return the number of samples in the DataFrameDict.""" return self.n_samples @staticmethod def _value_equal(a: object, b: object) -> bool: """Compare two stored block values, which may be DataFrames or nested dicts of them.""" if isinstance(a, pd.DataFrame) and isinstance(b, pd.DataFrame): return a.equals(b) if isinstance(a, dict) and isinstance(b, dict): return a.keys() == b.keys() and all(DataFrameDict._value_equal(a[k], b[k]) for k in a) # A block is a dict of DataFrames, or a bare DataFrame set through __setitem__: anything else # here is one of each, which are never equal. return False def __eq__(self, other: object) -> bool: """Value-based equality over the held data. Two :class:`DataFrameDict` instances are equal when they share the same block structure and every contained :class:`pandas.DataFrame` is equal (via :meth:`pandas.DataFrame.equals`). The inherited ``dict.__eq__`` would instead compare the (always-empty) ``dict`` base - all data lives in ``self.datadict`` - and so report every instance as equal regardless of its contents (CodeQL ``py/missing-equals``). """ if self is other: return True if not isinstance(other, DataFrameDict): return NotImplemented return ( self.partitionable_blocks == other.partitionable_blocks and self.datadict.keys() == other.datadict.keys() and all(self._value_equal(self.datadict[b], other.datadict[b]) for b in self.datadict) ) def __ne__(self, other: object) -> bool: """Negation of :meth:`__eq__`. Defined explicitly because the C-level ``dict.__ne__`` would otherwise bypass the Python ``__eq__`` above and compare the empty ``dict`` bases. """ result = self.__eq__(other) return result if result is NotImplemented else not result # All data lives in ``self.datadict`` and instances are mutable, so a # ``DataFrameDict`` is deliberately unhashable (matching ``dict``); make the # intent explicit now that ``__eq__`` is defined. __hash__ = None # type: ignore[assignment] # reason: intentionally unhashable, mirrors dict def __repr__(self): """Return a string representation of the DataFrameDict.""" groups_in_block_f = list(self.datadict["F"].keys()) groups_in_block_z = list(self.datadict["Z"].keys()) groups_in_block_y = list(self.datadict["Y"].keys()) output = f"DataFrameDict with {len(self)} samples and {len(self.datadict)} blocks: {list(self.datadict.keys())}" output += f"\n F groups: {groups_in_block_f}" output += f"\n Z groups: {groups_in_block_z}" output += f"\n Y groups: {groups_in_block_y}" return output
logger = logging.getLogger(__name__)
[docs] class TPLS(RegressorMixin, BaseEstimator): """ TPLS algorithm for T-shaped data structures (we also include standard pre-processing of the data inside this class). Source: Garcia-Munoz, https://doi.org/10.1016/j.chemolab.2014.02.006, Chem.Intell.Lab.Sys. v133, p 49 to 62, 2014. We change the notation from the original paper to avoid confusion with a generic "X" matrix, and match symbols that are more natural for our use. Notation mapping (paper → this code): - X^T → D: ``d_matrix`` (external), ``d_mats`` (internal) - Database of properties - X → D^T: transposed D (not used directly) - R → F: ``f_mats`` - Formula matrices - Z → Z: ``z_mats`` - Process conditions - Y → Y: ``y_mats`` - Quality indicators Notes on the blocks: 1. Matrices in F, Z and Y must all have the same number of rows. 2. Columns in F must be the same as the **rows** in D. They are paired by name, in any order; a material that no blend uses has a column of zeros in F. 3. Conditions in Z may be missing (turning it into an L-shaped data structure). 4. A blend with nothing observed in F, Z or Y, or a material with nothing observed in D, is fitted with a zero score or loading in that block and no weight in that block's regressions, as PCA and PLS fit an all-missing row. Parameters ---------- n_components : int A parameter used to specify the number of components. d_matrix : dict[str, pd.DataFrame] A dictionary containing the properties of each group of materials. Keys are group names; values are DataFrames with properties as columns and materials as rows. This "D" matrix is provided once at construction and reused for fitting, prediction and cross-validation. max_iter : int, optional The maximum number of iterations for the TPLS algorithm. Default is 500. A component that has not converged by then is the state of its last pass, and the fit warns. skip_f_matrix_preprocessing : bool, optional If True, the F (formula) matrices are used as-is, skipping the internal centering and scaling of the F block. Default is False. The caller then owns F's preprocessing: a column with no variance, which scaling would leave out with a warning, is used as given, without one. A missing cell is still left out, and a column with no observed value is still named. tol : float, optional Relative convergence tolerance for the per-component super-score loop: the component is converged once the norm of the difference between two successive super-score vectors, relative to the norm of the previous one, falls below this. Default is ``epsqrt`` (about 1.5e-8), which is the value that was hard-coded before this became a parameter, so the default fit is unchanged. It must lie between machine precision and 10, as on every iterative estimator. It is listed last so that no existing positional argument changed position. Notes ----- The input ``X`` passed to :meth:`fit` and :meth:`diagnose` is a dictionary with 3 keys: - ``F``: Formula matrices (rows = blends, columns = materials). ``F = {"Group A": df_formulas_a, "Group B": df_formulas_b, ...}`` - ``Z``: Process conditions - one row per blend, one column per condition, in one or more groups. Each Z group has its own weights and block score, as a block of a multi-block PLS. - ``Y``: Product quality indicators - one row per blend, one column per indicator. The ``D`` matrix (database of material properties) is supplied once at construction via the ``d_matrix`` argument; it is not part of ``X``. Attributes ---------- n_components_ : int The number of components fitted. Everything after ``fit`` reads it, not the ``n_components`` parameter, so ``set_params`` without a refit changes nothing. max_iter_ : int The ``max_iter`` the model was fitted with, which :meth:`display_results` reports. skip_f_matrix_preprocessing_ : bool Whether the fit used the F block as given; :meth:`diagnose` treats new F blocks the same way. n_samples : int Number of samples (rows) in the training data. n_substances : int Number of materials (columns) in the F matrix, summed across groups. n_conditions : int Number of process-condition columns summed across Z blocks. n_outputs : int Number of quality-indicator columns summed across Y blocks. is_fitted_ : bool Set to True once ``fit()`` completes. fitting_statistics : dict Per-component ``iterations``, ``convergance_tolerance``, ``milliseconds`` and ``converged`` lists. ``converged`` holds whether the component met ``tol`` within ``max_iter`` passes, as ``fitting_info_["converged"]`` does on MBPLS and MBPCA. preproc_ : dict Nested per-block per-group preprocessors, indexed as ``preproc_[block][group]`` where ``block`` is ``"D"``, ``"F"``, ``"Y"`` or ``"Z"``. With ``skip_f_matrix_preprocessing`` the ``"F"`` entries are learned but not applied. sums_of_squares_ : list[dict] Per-component per-block sums of squares. r2_frac : list[dict] Per-component per-block cumulative R^2 fractions. feature_importance : dict Per-block per-group variable-importance (populated for ``"D"`` and ``"F"``). d_mats, f_mats, z_mats, y_mats : dict[str, np.ndarray] Deflated block matrices (per group for D/F, per Z-block / Y-block for Z/Y). not_na_d, not_na_f, not_na_z, not_na_y : dict[str, np.ndarray] Boolean observed-value masks matching the shapes above. A column excluded for having no variance is marked absent in every row. observation_names : pd.Index Row index shared across the F/Z/Y blocks. property_names, material_names : dict[str, list[str]] Column and row names of each D group. condition_names, quality_names : dict[str, list[str]] Column names of each Z-block and Y-block respectively. t_scores_super : pd.DataFrame, shape (n_samples, n_components) Super-block scores ``T``. r_loadings_f : dict[str, pd.DataFrame] F-block weights per group. w_loadings_z : dict[str, pd.DataFrame] Z-block weights per Z-block. w_loadings_super : pd.DataFrame Super-block weights, rows ``["Z", "F"]`` (or just ``["F"]`` when there are no process conditions). Each Z group is a block of its own in the super-level, so several Z groups give one row each, labelled ``Z["<group>"]``, before the ``"F"`` row. s_loadings_d, v_loadings_d : dict[str, pd.DataFrame] D-block scores and loadings per group. p_loadings_f : dict[str, pd.DataFrame] F-block loadings per group (used for deflation). p_loadings_z : dict[str, pd.DataFrame] Z-block loadings per Z-block. q_loadings_y : dict[str, pd.DataFrame] Y-block loadings per Y-block. hat_ : dict[str, pd.DataFrame] Per-Y-block predictions on the preprocessed (centred / scaled) scale. hat : dict[str, pd.DataFrame] Per-Y-block in-sample predictions on the *original* scale. spe : dict[str, dict[str, pd.DataFrame or pd.Series]] Nested ``spe[block][group]`` squared prediction error tables. spe_limit : dict[str, dict[str, Callable]] Nested ``spe_limit[block][group]`` callables. Each is a :func:`functools.partial` over :func:`spe_calculation` bound to the block's SPE array; call it with a confidence level to obtain the SPE limit. This is a deliberate divergence from the flat ``spe_limit`` method on PCA / PLS. hotellings_t2 : pd.DataFrame, shape (n_samples, n_components) Cumulative Hotelling's T^2 per super-component: column ``a`` holds ``sum_{j<=a} (t_j / s_j)^2``, the same form as PCA / PLS and as :meth:`diagnose` (#502). scaling_factor_for_scores : pd.Series Standard deviation of each super-score column, computed with the unbiased (N - 1) divisor; used by the ellipse / T^2 helpers. .. note:: TPLS deliberately does **not** follow the sklearn trailing- underscore convention for its fitted attributes. Names such as ``t_scores_super``, ``spe``, ``hotellings_t2``, and the ``*_loadings_*`` family are written without the trailing ``_`` to keep the chemometrics symbol names readable. Attributes that are set in ``fit`` for the class's own bookkeeping (``is_fitted_``, ``preproc_``, ``required_blocks_``) do carry the underscore. Example ------- >>> import numpy as np >>> import pandas as pd >>> rng = np.random.default_rng() >>> >>> n_props_a, n_props_b = 6, 4 # Two groups of properties: A and B. >>> n_materials_a, n_materials_b = 12, 8 # Number of materials in each group. >>> n_formulas = 40 # Number of formulas in matrix F. >>> n_outputs = 3 >>> n_conditions = 2 >>> >>> properties = { >>> "Group A": pd.DataFrame(rng.standard_normal((n_materials_a, n_props_a))), >>> "Group B": pd.DataFrame(rng.standard_normal((n_materials_b, n_props_b))), >>> } >>> formulas = { >>> "Group A": pd.DataFrame(rng.standard_normal((n_formulas, n_materials_a))), >>> "Group B": pd.DataFrame(rng.standard_normal((n_formulas, n_materials_b))), >>> } >>> process_conditions = {"Conditions": pd.DataFrame(rng.standard_normal((n_formulas, n_conditions)))} >>> quality_indicators = {"Quality": pd.DataFrame(rng.standard_normal((n_formulas, n_outputs)))} >>> all_data = {"Z": process_conditions, "F": formulas, "Y": quality_indicators} >>> estimator = TPLS(n_components=4, d_matrix=properties) >>> estimator.fit(DataFrameDict(all_data)) """ # This is a dictionary allowing to define the type of parameters. # It used to validate parameter within the `_fit_context` decorator. _parameter_constraints: typing.ClassVar = { "n_components": [numbers.Integral], "max_iter": [numbers.Integral], "tol": [numbers.Real], "d_matrix": [dict, None], } def __init__( self, n_components: int, d_matrix: dict, max_iter: int = 500, skip_f_matrix_preprocessing: bool = False, # Appended rather than slotted in beside ``max_iter`` so that no existing # positional argument changes position (#588). tol: float = epsqrt, ): super().__init__() # Stored unchecked, as sklearn requires: fit checks them, however they were set (#1071). self.n_components = n_components self.d_matrix = d_matrix # This is required input dict containing the properties for each group. self.max_iter = max_iter self.tol = tol self.skip_f_matrix_preprocessing = skip_f_matrix_preprocessing def __sklearn_is_fitted__(self) -> bool: """Whether ``fit`` has completed, for :func:`~sklearn.utils.validation.check_is_fitted`. A fit that fails part way has set some attributes that end in an underscore, so the check scikit-learn makes by default, that one exists, would pass on that model. """ return getattr(self, "is_fitted_", False) @property def plot(self) -> Plot: """Score and loading plots of the fitted model: ``model.plot.scores()`` and ``model.plot.loadings()``.""" return Plot(self) # ENG-05: convenience methods forwarding to the standalone functions. These # used to be ``functools.partial`` instances bound in ``fit``; defining them # as real methods keeps ``help`` / ``inspect.signature`` accurate and the # fitted model picklable. # Inversion as an optimisation: which lots, at what fractions, and which conditions, under rules. optimal_inversion = _model_method(_optimal_inversion) inversion_sensitivity = _model_method(_inversion_sensitivity) def _inversion_columns(self) -> list[tuple[str, str, list[str]]]: """Each input group, in the order optimal inversion reads them: (block, group, its column names).""" columns = [("F", g, list(self.material_names[g])) for g in self.material_names] return columns + [("Z", g, list(self.condition_names[g])) for g in self.condition_names] def _inversion_probe(self, conf_level: float) -> Probe: """ Describe this model for optimal inversion: the inputs are each group's lot fractions, then the process conditions; each F and Z group is a residual block; rows are read through diagnose. A group the model uses no column of, as a group with no variation, which has zero weight (#1144), is an input but not a residual block: its SPE is 0 whatever the inputs, so it is no rule, and its limit of 0 would divide the search by 0. """ columns = self._inversion_columns() present = {"F": self.not_na_f, "Z": self.not_na_z} residual_blocks = [(block, group) for block, group, _ in columns if present[block][group].any()] names = tuple(name for _, _, group in columns for name in group) if len(set(names)) != len(names): # shortcut: inputs are named by their column name alone; qualify them by group if two groups share a name. msg = "Two TPLS groups share a lot or condition name; optimal inversion needs every input named once." raise ValueError(msg) edges = np.cumsum([0] + [len(group) for _, _, group in columns]) # The pragmas below mark the changes no result can show: the rows' names, which diagnose's arrays are read # without, the zip's strict, as there is one edge more than there are groups, and the float casts of # frames that hold float64 already. def diagnose(rows: np.ndarray) -> tuple[np.ndarray, np.ndarray, list[np.ndarray], np.ndarray, np.ndarray]: index = [f"p{i}" for i in range(len(rows))] # pragma: no mutate - the arrays are read by position blocks: dict[str, dict[str, pd.DataFrame]] = {} for (block, group, cols), lo, hi in zip( columns, edges[:-1], edges[1:], strict=True, # pragma: no mutate - one edge more than there are groups ): blocks.setdefault(block, {})[group] = pd.DataFrame( rows[:, lo:hi], index=index, # pragma: no mutate - the arrays are read by position columns=cols, ) out = self.diagnose(DataFrameDict(blocks)) y = np.hstack( [out.hat[g].to_numpy(dtype=float) for g in self.quality_names] # pragma: no mutate - float64 already ) residual = [ out.residuals[b][g].to_numpy(dtype=float) # pragma: no mutate - float64 already for b, g in residual_blocks ] spe = np.column_stack( [ out.spe[b][g] .iloc[:, -1] .to_numpy( dtype=float # pragma: no mutate - float64 already ) for b, g in residual_blocks ] ) t2 = out.cumulative_hotellings_t2.iloc[:, -1].to_numpy( dtype=float # pragma: no mutate - float64 already ) return ( out.t_scores_super.to_numpy(dtype=float), # pragma: no mutate - float64 already y, residual, t2, spe, ) def preproc(key: str, block: str, group: str, cols: list[str]) -> np.ndarray: return ( self.preproc_[block][group][key] .reindex(cols) .to_numpy( dtype=float # pragma: no mutate - float64 already ) ) return Probe( names=names, y_names=tuple(n for g in self.quality_names for n in self.quality_names[g]), groups={g: tuple(self.material_names[g]) for g in self.material_names}, block_names=tuple(f"{b}/{g}" for b, g in residual_blocks), block_limits=tuple(float(self.spe_limit[b][g](conf_level)) for b, g in residual_blocks), score_sd=_t2_score_sd(self.scaling_factor_for_scores.to_numpy()), t2_limit=float(self.hotellings_t2_limit(conf_level, judged_rows="new")), # an input is a new row y_scale=np.concatenate([finite_or(self.preproc_["Y"][g]["scale"], 1.0) for g in self.quality_names]), centre=np.concatenate([np.nan_to_num(preproc("center", b, g, c)) for b, g, c in columns]), spread=np.concatenate([finite_or(preproc("scale", b, g, c), 1.0) for b, g, c in columns]), diagnose=diagnose, ) # fmt: skip
[docs] def hotellings_t2_limit(self, conf_level: float = 0.95, *, judged_rows: JudgedRows | None = None) -> float: """Hotelling's T2 limit at the given confidence level (see :func:`hotellings_t2_limit`). ``judged_rows`` is ``"fitted"`` to judge the rows the model was fitted to (its ``hotellings_t2``), or ``"new"`` to judge rows it was not fitted to. ``None`` gives the ``"new"`` limit until 3.0 and the ``"fitted"`` one from 3.0, with a :class:`FutureWarning`. """ return _hotellings_t2_limit( conf_level=conf_level, n_components=self.n_components_, n_rows=self.hotellings_t2.shape[0], judged_rows=judged_rows, )
[docs] def ellipse_coordinates( self, score_horiz: int, score_vert: int, conf_level: float = 0.95, n_points: int = 100, *, judged_rows: JudgedRows | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Coordinates of the T2 confidence ellipse (see :func:`ellipse_coordinates`).""" return _ellipse_coordinates( score_horiz=score_horiz, score_vert=score_vert, conf_level=conf_level, n_points=n_points, n_components=self.n_components_, scaling_factor_for_scores=self.scaling_factor_for_scores, n_rows=self.t_scores_super.shape[0], judged_rows=judged_rows, )
[docs] @_fit_context(prefer_skip_nested_validation=True) def fit(self, X: DataFrameDict, y: object = None) -> TPLS: # noqa: PLR0915 """Fit the preprocessing parameters and also the latent variable model from the training data. Parameters ---------- X : DataFrameDict The training input samples. A plain ``dict`` raises :class:`TypeError`. Keys that must be present: ``"F"``, ``"Z"``, and ``"Y"``. See the class docstring for the shape of each block. y : object, optional Must be ``None``. The signature exists only for sklearn API compatibility; a T-shaped model takes its response from ``X["Y"]``. Anything else raises, rather than being silently ignored as it was before #565. Returns ------- self : object Returns self. Raises ------ ValueError If ``y`` is not ``None``, or if ``n_components``, ``max_iter`` or ``tol`` is out of range, or if a material of a group of F is not a row of that group of ``d_matrix``, or the reverse. TypeError If ``X`` is not a :class:`DataFrameDict`, or ``d_matrix`` is not a dict of DataFrames. NotEnoughVarianceError If the first Y block has no variance, or no group has a column of F and a column of D that vary, as MBPLS raises for an X with no variance; if the first Y block has no variance left for a component, as when the components before it fit Y exactly, in the words of PLS and MBPLS; or if a component's scores are not finite because a block has no variation for the iteration to follow. A group whose F or D has no variation, beside one whose F and D vary, is fitted with zero weight (#1144): its weights and loadings are 0, and a warning names it. Warns ----- SpecificationWarning If a component did not converge within ``max_iter`` passes (#1163). """ _reject_separate_y(y, "TPLS.fit") if not isinstance(X, DataFrameDict): raise TypeError(f"X must be a DataFrameDict; got {type(X).__name__}.") check_at_least_one("n_components", self.n_components) # No missing_data_settings, so the resolver's message naming the estimator is never reached. settings = resolve_loop_settings(tol=self.tol, max_iter=self.max_iter, missing_data_settings=None) self._input_data_checks(X) # F in D's order from here on, so the centre and scale learned for F are in the order diagnose reads it. X = DataFrameDict( {**X.datadict, "F": {k: _f_in_the_order_of_d(f, self.d_matrix[k], k) for k, f in X["F"].items()}} ) # A fit that fails part way leaves the model unfitted, not the previous fit with some parts replaced. self.is_fitted_ = False self.required_blocks_ = {"D", "F", "Y", "Z"} # "Z" block is optional; an empty one is added if not provided group_keys = list(self.d_matrix) # What the fit used, which everything after it reads, so that set_params without a refit changes nothing. self.max_iter_ = settings["md_max_iter"] self.skip_f_matrix_preprocessing_ = self.skip_f_matrix_preprocessing # Storage for pre-processing and the raw matrices self.fitting_statistics: dict[str, list] = { "iterations": [], "convergance_tolerance": [], "milliseconds": [], "converged": [], } # Per block / per group preprocessing parameters. "center" and "scale" are pd.Series (one entry # per column); the D-block additionally stores a scalar "block" factor (float). The heterogeneous # value types make `Any` the honest inner annotation here. See `_preprocess`. self.preproc_: dict[str, dict[str, dict[str, typing.Any]]] = {key: {} for key in self.required_blocks_} self.feature_importance: dict[str, dict[str, pd.Series]] = {key: {} for key in self.required_blocks_} # Float copies: int64 columns are accepted, and the blocks are deflated in place, which # cannot write a float result back into an integer array. self.d_mats: dict[str, np.ndarray] = { key: self.d_matrix[key].to_numpy(dtype=float, copy=True) for key in group_keys } self.f_mats: dict[str, np.ndarray] = {key: X["F"][key].to_numpy(dtype=float, copy=True) for key in group_keys} self.z_mats: dict[str, np.ndarray] = {key: X["Z"][key].to_numpy(dtype=float, copy=True) for key in X["Z"]} self.y_mats: dict[str, np.ndarray] = {key: X["Y"][key].to_numpy(dtype=float, copy=True) for key in X["Y"]} # Empty model coefficients self.n_substances = sum(self.f_mats[key].shape[1] for key in group_keys) self.n_conditions = sum(self.z_mats[key].shape[1] for key in self.z_mats) self.n_outputs = sum(self.y_mats[key].shape[1] for key in self.y_mats) self.n_samples = self.f_mats[group_keys[0]].shape[0] # Learn the centering and scaling parameters for key in X["Y"]: self.preproc_["Y"][key] = {} self.preproc_["Y"][key]["center"], self.preproc_["Y"][key]["scale"] = ( self._learn_center_and_scaling_parameters(X["Y"][key], f'Y["{key}"]') ) for key in X["Z"]: self.preproc_["Z"][key] = {} self.preproc_["Z"][key]["center"], self.preproc_["Z"][key]["scale"] = ( self._learn_center_and_scaling_parameters(X["Z"][key], f'Z["{key}"]') ) for key, df_d in self.d_matrix.items(): self.preproc_["D"][key] = {} self.preproc_["D"][key]["center"], self.preproc_["D"][key]["scale"] = ( self._learn_center_and_scaling_parameters(df_d, f'D["{key}"]') ) # Block-scale each D-block by 1 / sqrt(P_i * M_i), where P_i = number of lots (rows) and # M_i = number of properties (columns). After column-wise auto-scaling this makes # trace(X_i^T X_i) ~= 1 for every block, removing bias toward blocks that simply have more lots # or more properties. See Garcia-Munoz (2014), section 2.1 (https://doi.org/10.1016/j.chemolab.2014.02.006). n_lots, n_properties = df_d.shape self.preproc_["D"][key]["block"] = float(np.sqrt(n_lots * n_properties)) # # Also do the same for the formula matrix. F used as given is not scaled, so its centre and # scale are learned only for optimal inversion, which starts and steps from them. self.preproc_["F"][key] = {} self.preproc_["F"][key]["center"], self.preproc_["F"][key]["scale"] = ( self._learn_center_and_scaling_parameters(X["F"][key], f'F["{key}"]') ) # Then implement the preprocessing on the raw data self._preprocess_data() # Sum of square values for each column in each block (dicts) per component (elements in the list) # The first entry is after centering and scale (baseline variance) but before fitting any components. # The second entry is after fitting one component, and so on. # You can sum the sums-of-squares values for all columns to get the total variance for each block. self.sums_of_squares_: list[dict[str, dict[str, np.ndarray]]] = [ { "D": {key: np.nansum(self.d_mats[key] ** 2, axis=0) for key in group_keys}, "F": {key: np.nansum(self.f_mats[key] ** 2, axis=0) for key in group_keys}, "Z": {key: np.nansum(self.z_mats[key] ** 2, axis=0) for key in X["Z"]}, "Y": {key: np.nansum(self.y_mats[key] ** 2, axis=0) for key in X["Y"]}, } ] # These are *fractional* R2 values, i.e. always less than or equal to 1.0. # As a list: entry 0 is zeros; entry 1 is after fitting the first component, and so on. # The keys are the blocks, and the values are dictionaries with group keys as keys. # The values are the R2 values for each column in the block. self.r2_frac: list[dict[str, dict[str, np.ndarray]]] = [ { "D": {key: np.zeros(self.d_mats[key].shape[1]) for key in group_keys}, "F": {key: np.zeros(self.f_mats[key].shape[1]) for key in group_keys}, "Z": {key: np.zeros(self.z_mats[key].shape[1]) for key in self.z_mats}, "Y": {key: np.zeros(self.y_mats[key].shape[1]) for key in self.y_mats}, } ] # Create the missing value maps, except we store the opposite, i.e., not missing, since these are more useful. # We refer to these as `pmaps` in the code (present maps, as opposed to `mmap` or missing maps). They are # taken from the preprocessed blocks, not the raw ones, so a column excluded for having no variance (NaN # after scaling) is absent too: it is zeroed below, and counted as present it shrank every score (#513). self.not_na_d = {key: ~np.isnan(self.d_mats[key]) for key in group_keys} self.not_na_f = {key: ~np.isnan(self.f_mats[key]) for key in group_keys} self.not_na_z = {key: ~np.isnan(self.z_mats[key]) for key in X["Z"]} self.not_na_y = {key: ~np.isnan(self.y_mats[key]) for key in X["Y"]} # Then set missing data values to zeros (not because we are ignoring the values), but because we will use # the missing value maps to identify where the missing values are and therefore ignore them. But set to zero, # so these values have no influence on the calculations. self.d_mats = {key: nan_to_zeros(self.d_mats[key]) for key in group_keys} self.f_mats = {key: nan_to_zeros(self.f_mats[key]) for key in group_keys} self.z_mats = {key: nan_to_zeros(self.z_mats[key]) for key in X["Z"]} self.y_mats = {key: nan_to_zeros(self.y_mats[key]) for key in X["Y"]} self._refuse_blocks_with_no_variance() self._name_blocks_with_no_variance(X) # Storage for the model objects. Make a copy only of the Numpy values to use in the Estimator. # A copy, so that a name the caller gives this axis later does not rename the model's frames self.observation_names = X["F"][group_keys[0]].index.copy() self.property_names = {key: self.d_matrix[key].columns.to_list() for key in group_keys} self.material_names = {key: self.d_matrix[key].index.to_list() for key in group_keys} self.condition_names = {key: X["Z"][key].columns.to_list() for key in X["Z"]} self.quality_names = {key: X["Y"][key].columns.to_list() for key in X["Y"]} # Model parameters. Naming convention: x_i_j # x = block letter (P, W, R, T, etc) # i = block type: `scores` [for the observations (rows)] or `loadings` [for the variables (columns)] # j = block name [z, f, d, y, super] # ---------------- self.t_scores_super: pd.DataFrame = pd.DataFrame(index=self.observation_names) # The frames start from copies of the blocks' own axes, not the name lists, so each keeps its axis # name (#1083), and renaming the caller's axis later does not rename the model's. self.r_loadings_f: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=self.d_matrix[key].index.copy()) for key in group_keys } self.w_loadings_z: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=X["Z"][key].columns.copy()) for key in self.z_mats } self.w_loadings_super = pd.DataFrame(index=self._super_block_names()) # Capture the correlation of the properties in D; for the last component. self.s_loadings_d: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=self.d_matrix[key].columns.copy()) for key in group_keys } # Captures the deflation of the properties in D; for the last component. self.v_loadings_d: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=self.d_matrix[key].columns.copy()) for key in group_keys } self.p_loadings_f: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=self.d_matrix[key].index.copy()) for key in group_keys } self.p_loadings_z: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=X["Z"][key].columns.copy()) for key in self.z_mats } self.q_loadings_y: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=X["Y"][key].columns.copy()) for key in self.y_mats } # Model performance # ----------------- # 1. Prediction matrices (hat matrices for Y-space) in pre-processed space self.hat_: dict[str, pd.DataFrame] = { key: pd.DataFrame(index=self.observation_names, columns=X["Y"][key].columns.copy(), dtype=float).fillna(0) for key in self.y_mats } # 2. Squared prediction error (SPE) for each observation, per component, per block # Most blocks store a (N x 1) DataFrame of SPE values; the D-space stores a Series. self.spe: dict[str, dict[str, pd.DataFrame | pd.Series]] = {key: {} for key in self.required_blocks_} self.spe_limit: dict[str, dict[str, Callable]] = {key: {} for key in self.required_blocks_} # Every component's weights on the materials come through D, which runs out of directions after as # many components as its rank: later ones were fitted to rounding noise, with scores near 1e15 (#1149). self.n_components_ = components_to_fit( self.n_components, self.n_samples, max(min(d.shape) for d in self.d_matrix.values()), columns="the largest rank a group of D can have, the fewer of its materials and its properties", ) self._fit_iterative_regressions() self.is_fitted_ = True return self
[docs] def diagnose(self, X: DataFrameDict) -> Bunch: # noqa: C901 """ Model inference on new data. This will pre-process the new data and apply those subsequently to the latent variable model. Example ------- # Training phase: estimator = TPLS(n_components=2).fit(training_data) # Testing/inference phase. A plain dict raises TypeError, so wrap it. # You need at least the F block for a new prediction; "Z" is optional. new_data = DataFrameDict({"Z": ..., "F": ...}) predictions = estimator.diagnose(new_data) Parameters ---------- X : DataFrameDict The input samples. Returns ------- sklearn.utils.Bunch A bunch with the following fields: - ``hat`` (dict[str, DataFrame]) : predicted Y per Y-group, on the original (un-scaled) scale, indexed by the observation names of ``X``. - ``t_scores_super`` (DataFrame, shape ``(n_new, n_components)``) : super-scores for the new observations. - ``spe`` (dict[str, dict[str, DataFrame]]) : per-component squared prediction error for the ``"Z"`` and ``"F"`` blocks, keyed first by block name and then by group; each inner DataFrame has shape ``(n_new, n_components)``. - ``cumulative_hotellings_t2`` (DataFrame, shape ``(n_new, n_components)``) : cumulative Hotelling's T² per new observation after each component, as in ``hotellings_t2``, the fitted attribute. Every ``diagnose`` of the family returns it in this shape (#983). - ``hotellings_t2`` : deprecated since 2.4.0, to be removed in 3.0; the same DataFrame as ``cumulative_hotellings_t2``. Reading it warns. - ``residuals`` (dict[str, dict[str, DataFrame]]) : what is left of each ``"Z"`` and ``"F"`` group after all the components, in the preprocessed (centred and scaled) units, keyed like ``spe``. The last column of ``spe`` is the length of each row; a missing cell, and a column excluded for having no variance, leave 0. """ check_is_fitted(self) # Check if fit had been called if not isinstance(X, DataFrameDict): raise TypeError(f"X must be a DataFrameDict; got {type(X).__name__}.") # DataFrameDict holds every group of every block at one row count (its constructor # and __setitem__ both enforce it), so only the column names need checking here. for key, df_f in X["F"].items(): if set(df_f.columns) != set(self.material_names[key]): raise ValueError( f"Columns in block F, group [{key}] must match training data column names for each material." ) for key, df_z in X["Z"].items(): if set(df_z.columns) != set(self.condition_names[key]): raise ValueError(f"Column names in block Z, group [{key}] must match training data column names.") # In the training column order, since the scores below read the values by position: F used as # given was scored in the order it came. As floats, so a nullable column's NA reads as missing. x_f = {key: frame[self.material_names[key]].to_numpy(dtype=float) for key, frame in X["F"].items()} x_z = {key: frame[self.condition_names[key]].to_numpy(dtype=float) for key, frame in X["Z"].items()} for key, values in x_f.items(): if not self.skip_f_matrix_preprocessing_: x_f[key] = (values - self.preproc_["F"][key]["center"].to_numpy()) / self.preproc_["F"][key][ "scale" ].to_numpy() for key, values in x_z.items(): x_z[key] = (values - self.preproc_["Z"][key]["center"].to_numpy()) / self.preproc_["Z"][key][ "scale" ].to_numpy() # From the preprocessed blocks, as in fit(), so a column excluded for having no variance is absent. # A missing cell is then 0, so it adds nothing to a score's numerator, as in fit(). not_na_f = {key: ~np.isnan(values) for key, values in x_f.items()} not_na_z = {key: ~np.isnan(values) for key, values in x_z.items()} x_f = {key: np.where(not_na_f[key], values, 0.0) for key, values in x_f.items()} x_z = {key: np.where(not_na_z[key], values, 0.0) for key, values in x_z.items()} names_observations = X["F"][next(iter(X["F"]))].index n_rows, n_components = len(names_observations), self.n_components_ scores = np.empty((n_rows, n_components)) spe = {"Z": {key: np.empty((n_rows, n_components)) for key in x_z}, "F": {key: scores.copy() for key in x_f}} weights_super = self.w_loadings_super.to_numpy() for pc_a in range(n_components): # The F score: each row of all the F groups at once regressed on r, over its observed cells. A # row with nothing observed in F has no F score: NaN (0 / 0), as PCA and PLS transform an # all-missing row. r = {key: self.r_loadings_f[key].to_numpy()[:, pc_a] for key in x_f} with np.errstate(invalid="ignore"): score_f = sum(x_f[key] @ r[key] for key in x_f) / sum(not_na_f[key] @ r[key] ** 2 for key in x_f) # One Z score per Z group likewise, from that group's own weights, as fit() scores each group on its # own (#981), in the training order of the rows of w_loadings_super; none for a model without Z. A # row with no value in a group scores 0 there, as in fit() (#513). Z is optional here: a group left # out of X scores 0. score_z = {key: np.zeros(n_rows) for key in self.w_loadings_z} for key, values in x_z.items(): w = self.w_loadings_z[key].to_numpy()[:, pc_a] rows = not_na_z[key].any(axis=1) score_z[key][rows] = values[rows] @ w / (not_na_z[key][rows] @ w**2) # The super score: the block scores, [Z groups..., F] or [F], weighted by the super weights. scores[:, pc_a] = np.column_stack([*score_z.values(), score_f]) @ weights_super[:, pc_a] # Deflate each block, to score the next component and to compute SPE. Only the observed cells, # as in fit(): a missing cell has no residual (#1028). for block, values, observed, loadings in ( ("F", x_f, not_na_f, self.p_loadings_f), ("Z", x_z, not_na_z, self.p_loadings_z), ): for key in values: explained = np.outer(scores[:, pc_a], loadings[key].to_numpy()[:, pc_a]) values[key] = values[key] - explained * observed[key] spe[block][key][:, pc_a] = np.sqrt(np.sum(values[key] ** 2, axis=1)) # Predictions are returned in un-scaled form, so they are in the same units as the training data. components = list(range(1, n_components + 1)) hat = { key: pd.DataFrame( scores @ self.q_loadings_y[key].to_numpy().T * self.preproc_["Y"][key]["scale"].to_numpy() + self.preproc_["Y"][key]["center"].to_numpy(), index=names_observations, columns=self.q_loadings_y[key].index, ) for key in self.y_mats } # The T2 values, with the same cumulative per-component form (and the same rank-deficiency # guard) as ``fit`` stores in ``self.hotellings_t2``, so the two agree for the same rows (#502). t2 = pd.DataFrame(self._cumulative_hotellings_t2(scores), index=names_observations, columns=components) return DiagnoseResult( f"multivariate.{type(self).__name__}", hat=hat, t_scores_super=pd.DataFrame(scores, index=names_observations, columns=components), spe={ block: { key: pd.DataFrame(values, index=X[block][key].index, columns=components) for key, values in spe[block].items() } for block in ("Z", "F") }, cumulative_hotellings_t2=t2, hotellings_t2=t2, # The blocks as deflated by the last component: the vectors whose lengths are SPE, for optimal inversion. residuals={ "F": { key: pd.DataFrame(values, index=X["F"][key].index, columns=self.p_loadings_f[key].index) for key, values in x_f.items() }, "Z": { key: pd.DataFrame(values, index=X["Z"][key].index, columns=self.p_loadings_z[key].index) for key, values in x_z.items() }, }, )
[docs] def display_results(self, show_cumulative_stats: bool = True) -> str: """Display the results of the model fitting.""" if not self.__sklearn_is_fitted__(): raise RuntimeError("The model is not fitted yet. Please call `fit` first.") t2_limits = [self.hotellings_t2_limit(level, judged_rows="new") for level in (0.95, 0.99)] output = f"Hotelling's T2 limit for new rows [95% limit]: {t2_limits[0]:.4g}\n" output += f" [99% limit]: {t2_limits[1]:.4g}\n" # output += f"SPE limits: {self.spe_limit['Y'](self.spe['Y'])}\n" sep = "------ ---------- ---------- ---------- ---------- -------------\n" output += sep if show_cumulative_stats: header = "LV # sum(R2: D) sum(R2: Z) sum(R2: F) sum(R2: Y)| ms [iter]" else: header = "LV # R2: D R2: Z R2: F R2: Y| ms [iter]" output += header + "\n" + sep # Each block's R2 per component: the mean, over its groups, of the mean R2 of a group's columns. r2 = { block: np.array([np.mean([np.nanmean(v) for v in entry[block].values()]) for entry in self.r2_frac[1:]]) for block in ["D", "F", "Y"] + (["Z"] if self.n_conditions > 0 else []) } if show_cumulative_stats: r2 = {block: np.cumsum(values) for block, values in r2.items()} for a, iterations in enumerate(self.fitting_statistics["iterations"]): time_ms = self.fitting_statistics["milliseconds"][a] r2_z = f"{r2['Z'][a] * 100:>10.1f}" if "Z" in r2 else f"{'-':>10}" time_iter = f"{time_ms:>5.0f} [{iterations:>4d}]" line = ( f"LV {a + 1:<2} {r2['D'][a] * 100:>10.1f} {r2_z} {r2['F'][a] * 100:>10.1f} {r2['Y'][a] * 100:>10.1f}" ) line += f"|{time_iter:>13}" if iterations >= self.max_iter_: line += "** (max iter reached)" output += line + "\n" output += sep ms_per_iter = round( sum(self.fitting_statistics["milliseconds"]) / sum(self.fitting_statistics["iterations"]), 2 ) output += f"Timing: {ms_per_iter} ms/iter; {sum(self.fitting_statistics['iterations'])} iterations required\n" output += f"Total time: {sum(self.fitting_statistics['milliseconds']) / 1000:.2f} seconds\n" output += f"Average tolerance: {np.mean(self.fitting_statistics['convergance_tolerance']):.4g}\n" output += "Settings\n---------\n" output += f"n_components: {self.n_components_}\n" output += f"max_iter: {self.max_iter_}\n" output += f"skip_f_matrix_preprocessing: {self.skip_f_matrix_preprocessing_}\n" return output
[docs] def score(self, X: DataFrameDict, y: object = None, sample_weight: np.ndarray | None = None) -> float: """Return the :func:`~sklearn.metrics.r2_score` of the Y block on test data. See RegressorMixin.score for the general contract. Parameters ---------- X : DataFrameDict Test samples. The nested ``"Y"`` block supplies the actual response values; ``X["Z"]`` and ``X["F"]`` drive the prediction. y : object, optional Must be ``None``. The Y-data comes from ``X["Y"]``, not from a separate argument (the sklearn ``RegressorMixin.score`` signature is preserved only for API compatibility). Anything else raises, rather than being silently ignored as it was before #565. sample_weight : np.ndarray or None Optional per-sample weight forwarded to :func:`~sklearn.metrics.r2_score`. Returns ------- score : float :func:`~sklearn.metrics.r2_score` of ``X["Y"]``, the one Y block a TPLS model fits, against ``self.diagnose(X).hat``: the mean of the columns' :math:`R^2`. Raises ------ ValueError If ``y`` is not ``None``, or if ``X["Y"]`` holds no blocks. Notes ----- Only sklearn's **default** scoring reaches this method. A named scorer *string* never does:: cross_val_score(TPLS(...), X=DataFrameDict(blocks), cv=5) # works cross_val_score(TPLS(...), X=DataFrameDict(blocks), cv=5, scoring="r2") # all NaN sklearn builds ``scoring="r2"`` into a ``_Scorer`` whose ``__call__`` requires a ``y_true`` argument. TPLS carries its response inside ``X["Y"]``, so ``cross_val_score`` receives no ``y`` to hand the scorer, and the call fails inside sklearn before this method is reached: instrumentation shows this method called 3 times out of 3 folds under default scoring and 0 times under ``scoring="r2"``. Because TPLS never gets control it cannot turn that into a clear error, and sklearn's ``error_score`` (default ``np.nan``) records every fold as ``NaN`` behind a ``UserWarning``. Use :func:`make_tpls_scorer` for any metric other than the default. sklearn passes a *callable* ``scoring=`` through untouched and calls it as ``scorer(estimator, X_test)``, which a callable with an optional ``y`` accepts:: cross_val_score(TPLS(...), X=DataFrameDict(blocks), cv=5, scoring=make_tpls_scorer("r2")) # works cross_val_score(TPLS(...), X=DataFrameDict(blocks), cv=5, scoring=make_tpls_scorer("neg_mean_squared_error")) ``make_tpls_scorer("r2")`` reproduces this method's value fold for fold, so it is a drop-in replacement for the broken string form. If a string scorer is used anyway, pass ``error_score="raise"`` to see the underlying ``TypeError`` instead of a silent ``NaN``:: cross_val_score(TPLS(...), X=DataFrameDict(blocks), cv=5, scoring="r2", error_score="raise") # TypeError: _Scorer._score() missing 1 required positional argument: 'y_true' See Also -------- make_tpls_scorer : Build a ``scoring=`` callable that TPLS can honour. """ _reject_separate_y(y, "TPLS.score") y_pred = self.diagnose(X).hat y_actual = X["Y"] if not y_actual: msg = "y_actual is empty: X['Y'] must contain at least one block to compute a score." raise ValueError(msg) return float(np.mean([r2_score(y_actual[key], y_pred[key], sample_weight=sample_weight) for key in y_actual]))
[docs] def help(self) -> str: """Help for the TPLS Estimator. Data organization ----------------- Quick tips ---------- Build model: tpls = TPLS(n_components=2, d_matrix=d_matrix).fit(X) Get model's predictions: tpls.hat <-- the hat-matrix, i.e., the predictions Predict on new data: tpls.diagnose(X_new) See model summary: tpls.display_results() This help page: tpls.help() Statistical values ------------------ .t_scores_super Super scores for the entire model [pd.DataFrame] .hotellings_t2 Hotelling's T2 values for each observation, per component [pd.DataFrame] .spe Squared prediction error for each block [dict of pd.DataFrames] .hotellings_t2_limit() Returns the Hotelling's T2 limit for the model [float] .spe_limit[block][group]() Return the SPE limit for a group in a block; e.g. .spe_limit["Y"][group]() [float] .vip() Variable importance (VIP) for the D- and F-blocks [dict] """ # This method's docstring, dedented. Removing every run of eight spaces, as this did, also # removed the spaces that line up the columns, and ran ".spe" into its description. return inspect.cleandoc(self.help.__doc__ or "")
def _input_data_checks(self, X: DataFrameDict) -> None: """Check the incoming data, and the ``d_matrix`` it is fitted with. ``fit``, the only caller, has already checked that ``X`` is a DataFrameDict, and a DataFrameDict always holds exactly the Z, F and Y blocks, so neither is checked here. ``d_matrix`` is checked here rather than in ``__init__``, so that a value set through ``set_params`` is checked too (#1071). """ if not isinstance(self.d_matrix, dict): raise TypeError(f"d_matrix must be a dict of DataFrames; got {type(self.d_matrix).__name__}.") if not all(isinstance(df, pd.DataFrame) for df in self.d_matrix.values()): raise TypeError("d_matrix must contain pandas DataFrames as values.") # Group names are labels, used exactly as given. Passing them through ``str()`` in some # places and not in others meant a non-string group name could never be fitted. if set(X["F"]) != set(self.d_matrix): raise ValueError("The keys in F must match the keys in D.") # The inner PLS model regresses the first Y block alone on the super scores: a second Y block # was deflated with the first one's loadings, without a word. if len(X["Y"]) != 1: msg = f"TPLS fits one Y block; got {list(X['Y'])}." raise ValueError(msg) # A block of blends needs two of them, as across the family: one would be taken for no variance (#1149) for key in X["Y"]: self._validate_df(X["Y"][key], f'Y["{key}"]', min_rows=2) for key in X["Z"]: self._validate_df(X["Z"][key], f'Z["{key}"]', min_rows=2) for key in self.d_matrix: self._validate_df(self.d_matrix[key], f'd_matrix["{key}"]') self._validate_df(X["F"][key], f'F["{key}"]', min_rows=2) def _learn_center_and_scaling_parameters(self, y: pd.DataFrame, label: str) -> tuple[pd.Series, pd.Series]: """ Learn the centering and scaling parameters for one block. Parameters ---------- y : pd.DataFrame The block to learn from. label : str Where this block sits, e.g. ``'Z["Conditions"]'``, used only in the warnings for excluded columns. Returns ------- centering : pd.Series The centering parameters. scaling : pd.Series The scaling parameters. Warns ----- UserWarning If a column has no observed value. Its scale is set to ``NaN``, which makes the whole column ``NaN`` and then zero, so the column is excluded from the fit (#982). A column with no variance, whose observed values are all equal or which has one, is excluded the same way, and is named once the fit has not refused the data (:meth:`_name_blocks_with_no_variance`). It used to be excluded without a word (#513). """ # pandas 2 hands a reduction the caller's own column labels, so a name given to them after # the fit renamed the centre and scale too; pandas 3 does not, and both keep a copy now. y = _own_axes(y) centering = y.mean() # ddof=1, as pandas' std, and in any units: pandas' std was inf near 1e160 and 0 near 1e-160 (#1070) scaling = pd.Series(column_sd(y), index=y.columns) # An all-NaN column has a NaN mean, so it ends up excluded exactly as a constant one does; # it is named separately, in the warning the whole family gives (#1098), because "no # variance" would send the caller to the wrong cause. unobserved = unobserved_columns(y, label) # No variance: its observed values are all equal, in any units, as MCUVScaler reads a # column. An sd below a floor (it was sqrt(eps)) also took a column in small units for a # constant one. A block of one row, such as a group of D with one material, has no column # that varies (#1144). An all-NaN column has a NaN maximum and minimum, which are not # equal, so it is not counted here. return centering, scaling.mask(_equal_values(y) | unobserved) # a column with no information has a NaN scale def _name_blocks_with_no_variance(self, X: DataFrameDict) -> None: """Warn about each block that has columns with no variance, in the family's words (#1114). Called once the fit has not refused the data, so data with no variance at all gets that error alone, as in MBPLS. A group of D, F or Z with no variance gets zero weight (#1144), and is named as a group. F used as given keeps a constant column in the fit, so the column is not named (#1084); a group of it with no variation at all gets zero weight, and is. """ for key, block in X["Y"].items(): constant_columns(block, f'Y["{key}"]', self) for key, block in X["Z"].items(): constant_columns(block, f'Z["{key}"]', self) for key, block in X["F"].items(): constant_columns(self.d_matrix[key], f'D["{key}"]', self) if not self.skip_f_matrix_preprocessing_ or self.preproc_["F"][key]["scale"].isna().all(): constant_columns(block, f'F["{key}"]', self) def _validate_df(self, df: pd.DataFrame, label: str, min_rows: int = 1) -> None: """ Refuse a block that is not numbers TPLS can use: no columns, a column that is not float64 or int64, an infinite value, or fewer than ``min_rows`` rows (`check_array` from scikit-learn, whose other defaults hold for a DataFrame). A missing value is allowed. Parameters ---------- df : {pd.DataFrame} label : str Where the block sits, as ``'Z["group"]'``, named in the message for a block with no columns. min_rows : int, default=1 The fewest rows the block may have. """ refuse_empty_block(df, label) # Ensure all columns are dtype "float64" or "int64". (``np.dtypes.IntDType`` is the # C ``int``, which is int32, so it must not stand in for int64 here.) if not all(good_cols := [isinstance(col, (np.dtypes.Float64DType, np.dtypes.Int64DType)) for col in df.dtypes]): bad_columns = df.columns[[not item for item in good_cols]].to_list() raise ValueError( f"All columns in the DataFrame must be of type float64 or int64. Bad columns: {bad_columns}" ) check_array(df, ensure_all_finite="allow-nan", ensure_min_samples=min_rows, estimator=self) def _has_converged(self, starting_vector: np.ndarray, revised_vector: np.ndarray) -> bool: """Whether the scores converged: the norm of their change is less than ``tol`` times the norm before it. The loop that calls this also stops after ``max_iter`` passes. """ # Floor ``||starting_vector||`` -- when both vectors collapse to # zero (degenerate block / fully-deflated component) the bare # division produced NaN, the convergence test became permanently # False, and the loop ran to max_iter. SEC-21 (#270) sub-item 1. delta_gap = float( np.linalg.norm(starting_vector - revised_vector) / _nz(float(np.linalg.norm(starting_vector))) ) return delta_gap < self.tol @staticmethod def _regress_f_group_on_u(f_group: np.ndarray, u: np.ndarray, present: np.ndarray) -> np.ndarray: """Step 2 for one F group: ``h = F'u / u'u``, or NaN where ``F'u`` is only rounding error (#1144). A group with no variation over the blends, used as given, meets a centred ``u`` with an ``F'u`` of about 1e-17 times ``||F|| ||u||``: exactly 0 with some numpy builds, not with others. Regressing D on that remainder gave F weights near 1e15 and took F out of the model without a word, or refused the fit, by build. Such a group now gets zero weight in place of this (:meth:`_zero_weight`). Judged against ``||F|| ||u||``, a group that varies, but whose ``F'u`` is as small, gives no direction on every build, and the fit refuses it. """ h = regress_a_space_on_b_row(f_group.T, u.T, present.T) # The two sides are equal only where F is all 0, and then h is 0 and D cannot be regressed on it: the fit # refuses the component either way, so < and <= give one result. if ( np.linalg.norm(h) * np.linalg.norm(u) < epsqrt * np.linalg.norm(f_group) # pragma: no mutate - <= differs only where F is all 0 ): return np.full_like(h, np.nan) # pragma: no mutate - a fill of None is NaN in a float array return h def _refuse_blocks_with_no_variance(self) -> None: """Refuse nothing to explain, or nothing to explain it by, before the iteration, as MBPLS does (#1144). Scaling leaves a constant column absent, and an absent cell is 0 here, so a block with no variance is all zeros, in any units. The iteration explains the first Y block. A group of F carries something only with its D, as its blend scores weigh F by the properties: a group whose F, or whose D, has no column that varies gets zero weight, as a block with no variance does in MBPLS. Its F is judged by the scale learned from it, so that F used as given, which is not scaled, is judged as scaled F is. With no group left, the blends have no F score, which the model needs whether or not Z varies. """ y_key, y_block = next(iter(self.y_mats.items())) check_y_varies(y_block, label=f'Y["{y_key}"]', explained_by="F, D and Z") self._groups_without_variation = { key for key in self.d_mats if self.preproc_["F"][key]["scale"].isna().all() or self.preproc_["D"][key]["scale"].isna().all() } if self._groups_without_variation == set(self.d_mats): raise NotEnoughVarianceError( "There is no variance in F and D: no group has a column of F and a column of D that vary, so the " "blends have no F score to explain Y by. A TPLS model needs one, even when Z varies." ) def _zero_weight(self, per_group: dict[str, np.ndarray]) -> dict[str, np.ndarray]: """Give each group with no variation a zero vector in place of its regression: zero weight (#1144). Regressed as the others are, such a group gave 0 / 0 (its F or D all absent, so all 0) or rounding noise (F used as given, whose ``F'u`` is about 1e-17 times its size), and either one stopped the fit, or took the other groups out of the model, by numpy build. """ return { key: np.zeros_like(vector) if key in self._groups_without_variation else vector for key, vector in per_group.items() } def _refuse_scores_that_are_not_finite(self, component: int, t_super_i: np.ndarray) -> None: """Raise ``NotEnoughVarianceError`` when a component's scores are not finite. A NaN never leaves the iteration once it is in it, and the convergence test can never pass on it, so the fit would run to ``max_iter`` and return NaN everywhere (#709). A row or a material with nothing observed no longer causes one: it gets a zero score or loading and no weight (#513), and nor does a group whose F or D has no variation: it gets zero weight (#1144). What is left is a direction the iteration cannot take, because the vector it regresses on is zero: a group of F that does not covary with Y, or a block that the earlier components have used up. It was a ``ValueError``; it is the family's error for data without the variance to fit, as the checks before the iteration are (#1144). """ if np.isfinite(t_super_i).all(): return msg = ( f"TPLS cannot fit component {component}: its scores are not finite, because a block has " "no variation for the iteration to follow. Check that each group of F covaries with the " "first Y block, or fit fewer components." ) raise NotEnoughVarianceError(msg) def _super_block_names(self) -> list[str]: """Row labels of ``w_loadings_super``: one per Z group, as the fit scores each on its own, then F.""" if self.n_conditions == 0: return ["F"] # A single Z group keeps the plain "Z" label it always had. return ["Z", "F"] if len(self.z_mats) == 1 else [*(f'Z["{key}"]' for key in self.z_mats), "F"] def _store_model_coefficients( self, pc_a_column: int, # one-based index for the component t_super_i: np.ndarray, r_i: dict[str, np.ndarray], w_i_z: dict[str, np.ndarray], w_super_i: np.ndarray, s_i: dict[str, np.ndarray], ) -> None: """Store the model coefficients for later use.""" self.t_scores_super = self.t_scores_super.join( pd.DataFrame(t_super_i, index=self.observation_names, columns=[pc_a_column]) ) # These are loadings really, not scores, for each group in the F block. self.r_loadings_f = { key: self.r_loadings_f[key].join( pd.DataFrame(r_i[key], index=self.material_names[key], columns=[pc_a_column]) ) for key in r_i } # These are the loadings for the Z space self.w_loadings_z = { key: self.w_loadings_z[key].join( pd.DataFrame(w_i_z[key], index=self.condition_names[key], columns=[pc_a_column]) ) for key in w_i_z } self.w_loadings_super = self.w_loadings_super.join( pd.DataFrame(w_super_i, index=self._super_block_names(), columns=[pc_a_column]) ) self.s_loadings_d = { key: self.s_loadings_d[key].join( pd.DataFrame(s_i[key], index=self.property_names[key], columns=[pc_a_column]) ) for key in s_i } def _calculate_and_store_deflation_matrices( self, pc_a: int, t_super_i: np.ndarray, q_super_i: np.ndarray, r_i: dict[str, np.ndarray], ) -> None: """ Calculate and store the deflation matrices for the TPLS model. Deflate the matrices stored in the instance object. Returns the prediction matrices in a dictionary. """ # Step 13: Deflate the Z matrix with a loadings vector, pz_b (_b is for block) pz_b = { key: regress_a_space_on_b_row(df_z.T, t_super_i.T, self.not_na_z[key].T) for key, df_z in self.z_mats.items() } for key in self.z_mats: self.z_mats[key] -= (t_super_i @ pz_b[key].T) * self.not_na_z[key] self.p_loadings_z = { key: self.p_loadings_z[key].join(pd.DataFrame(pz_b[key], index=self.condition_names[key], columns=[pc_a])) for key in pz_b } # Step 13. p_i = F_i' t_i / t_i't_i. Regress the columns of F_i on t_i; store slope coeff in vectors p_i. # Note: the "t" vector is the t_i vector from the inner PLS model, marked as "Tt" in figure 4 of the paper. # It is the score column from the super score matrix regression onto Y. # A group with no variation takes no part in the model: its F and D are not deflated (#1144) pf_i = self._zero_weight( { key: regress_a_space_on_b_row(df_f.T, t_super_i.T, self.not_na_f[key].T) for key, df_f in self.f_mats.items() } ) self.p_loadings_f = { key: self.p_loadings_f[key].join(pd.DataFrame(pf_i[key], index=self.material_names[key], columns=[pc_a])) for key in pf_i } # Step 13: v_i = D_i' r_i / r_i'r_i. Regress the rows of D_i (properties) on r_i; store slopes in v_i. v_i = self._zero_weight( { key: regress_a_space_on_b_row(df_d.T, r_i[key].T, self.not_na_d[key].T) for key, df_d in self.d_mats.items() } ) self.v_loadings_d = { key: self.v_loadings_d[key].join(pd.DataFrame(v_i[key], index=self.property_names[key], columns=[pc_a])) for key in v_i } # Step 14. Do the actual deflation. for key in self.d_mats: # Step to deflate F matrix self.f_mats[key] -= (t_super_i @ pf_i[key].T) * self.not_na_f[key] # Two sets of matrices to deflate: properties D and formulas F. self.d_mats[key] -= (r_i[key] @ v_i[key].T) * self.not_na_d[key] # Deflate the Y-space as well self.q_loadings_y = { key: self.q_loadings_y[key].join(pd.DataFrame(q_super_i, index=self.quality_names[key], columns=[pc_a])) for key in self.y_mats } for key in self.y_mats: self.hat_[key] += t_super_i @ q_super_i.T self.y_mats[key] -= (t_super_i @ q_super_i.T) * self.not_na_y[key] def _update_performance_statistics(self) -> None: """Calculate and store the performance statistics of the model, such as SSQ, R2, etc.""" # Calculate the sums of squares for each block, per column. # Note: the `ddof=0` is used to calculate the population variance, which is proportional to the SSQ. calc_ssq = { "D": {key: np.nansum(self.d_mats[key] ** 2, axis=0) for key in self.d_mats}, "F": {key: np.nansum(self.f_mats[key] ** 2, axis=0) for key in self.f_mats}, "Z": {key: np.nansum(self.z_mats[key] ** 2, axis=0) for key in self.z_mats}, "Y": {key: np.nansum(self.y_mats[key] ** 2, axis=0) for key in self.y_mats}, } self.sums_of_squares_.append(calc_ssq) # Calculate the incremental (not cumulative!) R2 values for each block, per column: # Cumulative R2 values can be found by summation. The R2 values are **always** fractional (between 0 and 1). ssq_prior_pc = self.sums_of_squares_[-2] ssq_start_0 = self.sums_of_squares_[0] # A column left out for having no variance has a sum of squares of 0, and an R2 of NaN. with np.errstate(invalid="ignore"): calc_r2 = { "D": { key: (ssq_prior_pc["D"][key] - calc_ssq["D"][key]) / ssq_start_0["D"][key] for key in self.d_mats }, "F": { key: (ssq_prior_pc["F"][key] - calc_ssq["F"][key]) / ssq_start_0["F"][key] for key in self.f_mats }, "Z": { key: (ssq_prior_pc["Z"][key] - calc_ssq["Z"][key]) / ssq_start_0["Z"][key] for key in self.z_mats }, "Y": { key: (ssq_prior_pc["Y"][key] - calc_ssq["Y"][key]) / ssq_start_0["Y"][key] for key in self.y_mats }, } self.r2_frac.append(calc_r2) # VIP for each block, for given number of components we currently have. VIP are cumulative. # For the D-block and F-block: you want to be able to use the VIPs to compare across the entire block, without # regards to the banding in groups that might have to be done. So use the total R2 for that block, and do not # work per group. # Default ("vip") feature importance: standard VIP computed on the raw block loadings. This matches # the variable importance reported by Garcia-Munoz (2014) (e.g. Table 4). The opt-in "deflated" # variant, exposed through `vip(method="deflated")`, instead uses the direct (rotated) weights # S(V^T S)^-1 and P(P^T P)^-1; see `_feature_importance`. loadings_s = np.concatenate(list(self.s_loadings_d.values())) loadings_f = np.concatenate(list(self.p_loadings_f.values())) vip_split_d, vip_split_f = self._split_block_vip(loadings_s, loadings_f) self.feature_importance["D"] = vip_split_d self.feature_importance["F"] = vip_split_f def _split_block_vip( self, loadings_d: np.ndarray, loadings_f: np.ndarray ) -> tuple[dict[str, pd.Series], dict[str, pd.Series]]: """Compute VIP for the D- and F-blocks from (n_features x A) loading/weight matrices. The per-component R2 weighting is taken from `self.r2_frac` (the first entry is the pre-fit baseline and is skipped). The flat VIP vector for each block is split back into the original per-group :class:`pandas.Series`, indexed by feature name. Shared by the default VIP (raw block loadings) and the opt-in deflated direct-weights variant. """ r2_d_a = [_mean_over_groups(r2_frac["D"]) for r2_frac in self.r2_frac] r2_f_a = [_mean_over_groups(r2_frac["F"]) for r2_frac in self.r2_frac] vip_d = self._calculate_vip(loadings_d, np.array(r2_d_a[1:]), self._informative_width("D")) vip_f = self._calculate_vip(loadings_f, np.array(r2_f_a[1:]), self._informative_width("F")) # Split each flat VIP vector back into the original groups it was concatenated from. # For example: with two groups of 17 and 3 columns, `vip_d` has 20 values; the first 17 belong to # the first group and the last 3 to the second. vip_split_d = {} start = 0 for key in self.property_names: end = start + len(self.property_names[key]) vip_split_d[key] = pd.Series(vip_d[start:end], index=self.s_loadings_d[key].index) start = end vip_split_f = {} start = 0 for key in self.material_names: end = start + len(self.material_names[key]) vip_split_f[key] = pd.Series(vip_f[start:end], index=self.r_loadings_f[key].index) start = end return vip_split_d, vip_split_f def _feature_importance(self, method: str) -> dict[str, dict[str, pd.Series]]: """Return the D-/F-block feature importance for the requested `method`. Parameters ---------- method : {"vip", "deflated"} ``"vip"`` returns the standard VIP stored at fit time (computed from the raw block loadings, matching Garcia-Munoz, 2014). ``"deflated"`` computes VIP on the direct (rotated) weights that map the *original* variables onto the scores while accounting for the deflation across components: ``S(V^T S)^-1`` for the D-block and ``P(P^T P)^-1`` for the F-block, where ``S`` and ``V`` are the D-block correlation- and deflation-loadings and ``P`` is the F-block deflation loading (equation 7 in the paper). """ if method == "vip": return self.feature_importance s_mat = np.concatenate(list(self.s_loadings_d.values())) # (K_D x A) v_mat = np.concatenate(list(self.v_loadings_d.values())) # (K_D x A) p_mat = np.concatenate(list(self.p_loadings_f.values())) # (K_F x A) # Direct (rotated) weights; `pinv` keeps this well-defined if the A x A core is near-singular. w_d = s_mat @ np.linalg.pinv(v_mat.T @ s_mat) w_f = p_mat @ np.linalg.pinv(p_mat.T @ p_mat) split_d, split_f = self._split_block_vip(w_d, w_f) return {"D": split_d, "F": split_f, "Z": {}, "Y": {}}
[docs] def vip( self, block: str | None = None, method: str = "vip" ) -> dict[str, dict[str, pd.Series]] | dict[str, pd.Series]: """Return Variable Importance in Projection (VIP) scores for TPLS blocks. VIP scores are computed during fitting for the D-block (material properties) and F-block (formulation variables) and stored in :attr:`feature_importance`. Each component is weighted by its share of that block's own variance (its per-column R2, averaged over the columns of each group and then over the groups with a column that varies), not of Y as a PLS VIP is. A group with no variation has zero weight, and a VIP of 0. Parameters ---------- block : str or None, default=None Which block to return. Must be ``"D"`` or ``"F"``, or ``None`` to return all blocks. method : {"vip", "deflated"}, default="vip" How importance is measured. - ``"vip"`` (default): standard VIP on the raw block loadings, as reported by Garcia-Munoz (2014). These are the values stored in :attr:`feature_importance`. - ``"deflated"``: VIP computed on the direct (rotated) weights that map the *original* variables onto the scores while accounting for the deflation across components, ``S(V^T S)^-1`` for the D-block and ``P(P^T P)^-1`` for the F-block. This is an alternative importance measure; it does not change :attr:`feature_importance`. Returns ------- dict If *block* is ``None``: a four-key dict keyed by block, of the shape ``{"D": {group: pd.Series, ...}, "F": {group: pd.Series, ...}, "Z": {}, "Y": {}}``. VIP is defined only for the ``D``- and ``F``-blocks, so the ``"Z"`` and ``"Y"`` entries are always empty dicts; they are kept for shape-stability across every ``vip()`` caller. If *block* is ``"D"`` or ``"F"``: the inner dict ``{group: pd.Series, ...}`` for that block, where each ``pd.Series`` is indexed by feature names. Raises ------ ValueError If the model is not fitted, *block* is not ``"D"``, ``"F"``, or ``None``, or *method* is not ``"vip"`` or ``"deflated"``. Examples -------- >>> tpls = TPLS(...).fit(data) >>> tpls.vip() # all blocks, standard VIP >>> tpls.vip("D") # D-block only → {group_name: pd.Series, ...} >>> tpls.vip("D", method="deflated") # D-block deflated direct-weights importance """ check_is_fitted(self) if method not in ("vip", "deflated"): msg = f"method must be 'vip' or 'deflated'; got {method!r}." raise ValueError(msg) if block is not None and block not in ("D", "F"): msg = f"block must be 'D', 'F', or None; got {block!r}." raise ValueError(msg) importance = self._feature_importance(method) if block is None: return importance return importance[block]
@property def d_block_scaling_(self) -> dict[str, float]: """Block-scaling factor applied to each D-block (read-only). After column-wise centring and auto-scaling, every D-block ``X_i`` is additionally divided by ``sqrt(P_i * M_i)`` (``P_i`` = number of lots/rows, ``M_i`` = number of properties/columns) so that ``trace(X_i^T X_i) ~= 1``, removing bias toward blocks with more lots or properties (Garcia-Munoz, 2014, section 2.1). Returns ------- dict[str, float] Mapping of D-block group name to its scalar block-scaling factor. Raises ------ AttributeError If the model has not been fitted yet. """ check_is_fitted(self) return {key: self.preproc_["D"][key]["block"] for key in self.preproc_["D"]} def _informative_width(self, block: str) -> int | None: """Return the count of columns of D or F that vary, in the groups with weight: a VIP's width (#913). A column with no variance, or one of a group with zero weight (#1144), has a loading of 0, as in MBPCA and MBPLS, so counted in the width it raised the VIP of every other column, and the fit's VIP changed although nothing it used had. ``None`` for a model fitted before groups had zero weight: it keeps the full width it was fitted with. """ if not hasattr(self, "_groups_without_variation"): return None return sum( int(self.preproc_[block][key]["scale"].notna().sum()) for key in self.d_mats if key not in self._groups_without_variation ) @staticmethod def _calculate_vip(loadings: np.ndarray, r2_vector: np.ndarray, width: int | None) -> np.ndarray: """Calculate the VIP values for the current component. The `loadings` has as many rows as there are feature varaibles, and A columns, where A = number of components. The `r2_vector` is a vector of fractional R^2 values for the current component, with `A` entries. The `r2_vector` values should be between 0 and 1; the fraction of variance explained by the component for that given `loadings` matrix. The VIP values are calculated as follows: VIP = sqrt(n * sum((r2_vector * (loadings ** 2)) / sum(r2_vector))) where n is ``width``, the number of columns that vary (:meth:`_informative_width`), or, when it is ``None``, the number of rows in the loadings matrix. """ n = loadings.shape[0] if width is None else width return np.sqrt(n * np.sum(r2_vector * (loadings**2), axis=1) / np.sum(r2_vector)) def _cumulative_hotellings_t2(self, t_scores_super: np.ndarray) -> np.ndarray: """Cumulative Hotelling's T2 per component from super-scores. Matches the PCA / PLS convention (#502): column ``a`` holds ``sum_{j<=a} (t_j / s_j)^2`` where ``s_j`` is the training score standard deviation (unbiased, N - 1 divisor) stored in ``scaling_factor_for_scores``. A component whose score standard deviation is at most epsqrt times the largest (a rank-deficient fit) carries no T2 information and adds 0, by the rule every other T2 follows (:func:`~process_improve.multivariate._diagnostics._t2_score_sd`). Parameters ---------- t_scores_super : np.ndarray of shape (n_obs, n_components) Super-scores of the observations to evaluate. Returns ------- np.ndarray of shape (n_obs, n_components) Cumulative Hotelling's T2 after each component. """ score_sd = _t2_score_sd(self.scaling_factor_for_scores.to_numpy()) return np.cumsum(t_scores_super**2 / score_sd**2, axis=1) def _calculate_model_statistics_and_limits(self) -> None: """Calculate and store the model limits. Limits calculated: 1. Hotelling's T2 limits 2. Squared prediction error limits Other calculations: 1. The model's Y-space predictions are scaled back to the original space. """ # Hotelling's T2, cumulative per component, in the same form as PCA / PLS # and as ``diagnose`` (#502): column ``a`` holds ``sum_{j<=a} (t_j / s_j)^2``. # The score standard deviations use the unbiased (N - 1) divisor, which is the # estimator the F-distribution limit in ``hotellings_t2_limit`` is derived for; # the previous N divisor inflated T2 by N / (N - 1). N is at least 2: one blend's # Y column centres to 0, so the first regression on it is 0 / 0 and fit refuses it. scores_np = self.t_scores_super.to_numpy() n_obs = scores_np.shape[0] self.scaling_factor_for_scores = pd.Series( np.sqrt(np.diag(scores_np.T @ scores_np) / (n_obs - 1)), index=[a + 1 for a in range(self.n_components_)], name="Standard deviation per score", ) self.hotellings_t2 = pd.DataFrame( self._cumulative_hotellings_t2(scores_np), index=self.observation_names, columns=list(range(1, self.n_components_ + 1)), ) # Squared prediction error limits. This is a measure of the prediction error = difference between the actual # and predicted values. Since the matrices are deflated by the predictive part of the model already, the # data in these matrices is already the prediction error. Calculate the **squared** portion, and store it. column_name = [f"SPE with A={self.n_components_}"] def row_spe(residuals: np.ndarray, present: np.ndarray) -> np.ndarray: # A row with nothing observed in the block has no SPE there, as in MBPCA and MBPLS (#1114). A block # the model uses no column of, as a group with no variation, has none left in any row: 0, as MBPLS # gives a block with no variance, and as diagnose gives it (#1144). return np.where(present.any(axis=1) | ~present.any(), np.sqrt(np.sum(np.square(residuals), axis=1)), np.nan) self.spe["Y"] = { key: pd.DataFrame( row_spe(self.y_mats[key], self.not_na_y[key]), index=self.observation_names, columns=column_name ) for key in self.y_mats } self.spe["Z"] = { key: pd.DataFrame( row_spe(self.z_mats[key], self.not_na_z[key]), index=self.observation_names, columns=column_name ) for key in self.z_mats } # SPE for the D-space, one per material: the rows of D. self.spe["D"] = { key: pd.Series(row_spe(self.d_mats[key], self.not_na_d[key]), index=self.r_loadings_f[key].index) for key in self.d_mats } self.spe["F"] = { key: pd.DataFrame( row_spe(self.f_mats[key], self.not_na_f[key]), index=self.observation_names, columns=column_name ) for key in self.f_mats } # Each limit over the rows that have an SPE for block in ("Y", "Z", "D", "F"): self.spe_limit[block] = { key: partial(spe_calculation, spe.dropna().to_numpy()) for key, spe in self.spe[block].items() } # The Y-space predictions, scaled back from the preprocessed space to the original one. self.hat: dict[str, pd.DataFrame] = { key: self.hat_[key] * self.preproc_["Y"][key]["scale"].to_numpy() + self.preproc_["Y"][key]["center"].to_numpy() for key in self.y_mats } def _preprocess_data(self) -> None: """Pre-process the training data.""" for key in self.f_mats: if not self.skip_f_matrix_preprocessing_: self.f_mats[key] = ( self.f_mats[key] - self.preproc_["F"][key]["center"].to_numpy()[None, :] ) / self.preproc_["F"][key]["scale"].to_numpy()[None, :] self.d_mats[key] = ( (self.d_mats[key] - self.preproc_["D"][key]["center"].to_numpy()[None, :]) / self.preproc_["D"][key]["scale"].to_numpy()[None, :] / self.preproc_["D"][key]["block"] # scalar block-scaling factor ) for key in self.z_mats: self.z_mats[key] = ( self.z_mats[key] - self.preproc_["Z"][key]["center"].to_numpy()[None, :] ) / self.preproc_["Z"][key]["scale"].to_numpy()[None, :] for key in self.y_mats: self.y_mats[key] = ( self.y_mats[key] - self.preproc_["Y"][key]["center"].to_numpy()[None, :] ) / self.preproc_["Y"][key]["scale"].to_numpy()[None, :] def _fit_iterative_regressions(self) -> None: """Fit the model via iterative regressions and store the model coefficients in the class instance.""" # Formula matrix: assemble all not-na maps from blocks in F: make a single matrix. pmap_f = np.concatenate(list(self.not_na_f.values()), axis=1) # No variance left in the Y block the iteration explains is judged against its size before the first # component, as PLS and MBPLS judge it: a Y fitted exactly is refused in their words (#1128, #1149). y_size = float(np.sqrt(np.sum(next(iter(self.y_mats.values())) ** 2))) # Follow the steps in the paper on page 54 for pc_a in range(self.n_components_): check_variance_left("Y", next(iter(self.y_mats.values())), pc_a, y_size) milliseconds_start = time.time() # Step 1: Start from the Y column with the largest sum of squares, as PLS does (#195). The first # column can be constant, all zero after preprocessing, and then the iteration has nothing to follow (#954). u_super_i = largest_ssq_column(next(iter(self.y_mats.values()))) # The first pass always runs. A test before it, against a previous u primed as u + 1, ended the # loop before it began once tol was above about 0.7, and the fit failed for want of a score. for n_iter in range(1, self.max_iter_ + 1): # noqa: B007 - the statistics after the loop read it u_prior = u_super_i # rebound below, never changed in place # Step 2. h_i = F_i' u / u'u. Regress the columns of F on u_i, and store the slope coeff in vectors h_i h_i = self._zero_weight( { key: self._regress_f_group_on_u(df_f, u_super_i, self.not_na_f[key]) for key, df_f in self.f_mats.items() } ) # A NaN here makes every later step and the scores NaN, through numpy's warnings: refuse it now. self._refuse_scores_that_are_not_finite(pc_a + 1, np.concatenate(list(h_i.values()))) # Step 3. s_i = D_i' h_i / h_i'h_i. Regress the rows of D_i on h_i, and store slope coeff in vectors s_i s_i = self._zero_weight( { key: regress_a_space_on_b_row(df_d.T, h_i[key].T, self.not_na_d[key].T) for key, df_d in self.d_mats.items() } ) # Step 4: combine the entries in s_i to form a joint `s` and normalize it to unit length. joint_s_normalized = np.linalg.norm(np.concatenate(list(s_i.values()))) s_i = {key: s / joint_s_normalized for key, s in s_i.items()} # Step 5: r_i = D_i s_i / s_i's_i. Regress columns of D_i on s_i, and store slope coefficients in r_i. r_i = self._zero_weight( { key: regress_a_space_on_b_row(df_d, s_i[key].T, self.not_na_d[key]) for key, df_d in self.d_mats.items() } ) # Step 6: Combine the entries in r_i to form a joint r (which is the name of the method in the paper). # Horizontally concatenate all matrices in F_i to form a joint F matrix. # Regress rows of the joint F matrix onto the joint r vector. Store coeff in block scores, t_f joint_r = np.concatenate(list(r_i.values())) joint_f = np.concatenate(list(self.f_mats.values()), axis=1) t_f = regress_a_space_on_b_row(joint_f, joint_r.T, pmap_f) # If there is a Condition matrix (non-empty Z block) if self.n_conditions > 0: # Step 7: w_i = Z_i' u / u'u. Regress the columns of Z on u_i, and store the slope coefficients # in vectors w_i. w_i_z = { key: regress_a_space_on_b_row(df_z.T, u_super_i.T, self.not_na_z[key].T) for key, df_z in self.z_mats.items() } # Step 8: Normalize joint w to unit length. See MB-PLS by Westerhuis et al. 1998. This is normal. # Floor each per-block norm so a degenerate Z block doesn't yield NaN. SEC-21 (#270) sub-item 1. w_i_z = {key: w / _nz(float(np.linalg.norm(w))) for key, w in w_i_z.items()} # Step 9: regress rows of Z on w_i, and store slope coefficients in t_z. There is an error in the # paper here, but in figure 4 it is clear what should be happening. t_zb = { key: regress_a_space_on_b_row(df_z, w_i_z[key].T, self.not_na_z[key]) for key, df_z in self.z_mats.items() } t_z = np.hstack(list(t_zb.values())) else: # Step 7: No Z block. Take an empty matrix across to the the superblock. w_i_z = {} t_z = np.zeros((t_f.shape[0], 0)) # empty matrix: in other words, no Z block # Step 10: Combine t_z and t_f to form a joint t matrix. t_combined = np.concatenate([t_z, t_f], axis=1) # Step 11: Build an inner PLS model: using the t_combined as the X matrix, and the Y (quality space) # as the Y matrix. # Every block score is present. A mask of float ones would weigh the cells as these booleans do. complete = np.ones(t_combined.shape, dtype=bool) # pragma: no mutate - float ones weigh the same inner_pls = internal_pls_nipals_fit_one_pc( x_space=t_combined, y_space=np.array(next(iter(self.y_mats.values()))), x_present_map=complete, y_present_map=np.array(next(iter(self.not_na_y.values()))), ) u_super_i = inner_pls["u_i"] # steps 2 and 7 regress F and Z on it, and it is the convergence check t_super_i = inner_pls["t_i"] q_super_i = inner_pls["q_i"] w_super_i = inner_pls["w_i"] self._refuse_scores_that_are_not_finite(pc_a + 1, t_super_i) # Read after the loop: the last pass allowed can converge too (#1080). converged = self._has_converged(starting_vector=u_prior, revised_vector=u_super_i) if converged: break logger.debug( "TPLS: super-component %d %s in %d iterations", pc_a + 1, "converged" if converged else "did not converge", n_iter, ) # After convergance. Step 12: Now store information. # ================= # Floor ``||u_prior||`` -- see SEC-21 (#270) sub-item 1. delta_gap = float(np.linalg.norm(u_prior - u_super_i) / _nz(float(np.linalg.norm(u_prior)))) self.fitting_statistics["iterations"].append(n_iter) self.fitting_statistics["convergance_tolerance"].append(delta_gap) self.fitting_statistics["milliseconds"].append((time.time() - milliseconds_start) * 1000) self.fitting_statistics["converged"].append(converged) # Store model coefficients self._store_model_coefficients( pc_a + 1, t_super_i=t_super_i, r_i=r_i, w_i_z=w_i_z, w_super_i=w_super_i, s_i=s_i ) # Calculate and store the deflation vectors. See equation 7 on page 55. self._calculate_and_store_deflation_matrices(pc_a + 1, t_super_i=t_super_i, q_super_i=q_super_i, r_i=r_i) # Update performance statistics for this component self._update_performance_statistics() # Step 15: Calculate the final model limits (after all components have been fitted). self._calculate_model_statistics_and_limits() self._warn_about_components_not_converged() def _warn_about_components_not_converged(self) -> None: """Warn about each component whose loop stopped at ``max_iter``, in the words of MBPLS and MBPCA. The loop is not a power iteration, and it can alternate between two states for ever: the component is then the state of the last pass, which differs from one platform to another (#1163). """ failed = [a + 1 for a, converged in enumerate(self.fitting_statistics["converged"]) if not converged] if failed: warnings.warn( f"TPLS NIPALS did not converge within max_iter={self.max_iter_} for " f"component(s) {failed}; results for those components may be unreliable.", SpecificationWarning, stacklevel=user_stacklevel(), )
# Named regression metrics understood by :func:`make_tpls_scorer`, each paired with the # sign that turns it into a "higher is better" score. The signs and names mirror # sklearn's own ``neg_*`` convention, so a TPLS scorer is read the same way as the string # scorer it stands in for. _TPLS_METRICS: dict[str, tuple[Callable[..., float], int]] = { "r2": (r2_score, 1), "neg_mean_absolute_error": (mean_absolute_error, -1), "neg_mean_squared_error": (mean_squared_error, -1), "neg_root_mean_squared_error": (root_mean_squared_error, -1), } def _mean_over_groups(r2_per_column: dict[str, np.ndarray]) -> float: """Return a block's R² for one component: the mean over its groups of each group's mean over its columns. A group with no column that varies has no R² (0 / 0, NaN) and is left out. It made the block's R², and so the VIP of every group of the block, NaN (#1169). Its own VIP is 0, as in MBPCA and MBPLS: its loadings are 0, as it has zero weight (#1144). """ return float(np.mean([np.nanmean(r2) for r2 in r2_per_column.values() if not np.isnan(r2).all()])) def _f_in_the_order_of_d(f_group: pd.DataFrame, d_group: pd.DataFrame, group: object) -> pd.DataFrame: """Return F's columns in the order of D's rows, each material paired with its properties by name (#1197). They were paired by position, so F with the materials of a group in another order than D fitted each material with another one's properties, without a word. A material that only one of them has, or that D names twice, has no pair, and is refused by name. Parameters ---------- f_group : pd.DataFrame One group of F: blends as rows, materials as columns. d_group : pd.DataFrame The same group of D: materials as rows, properties as columns. group : object The group's name, used in the messages. Returns ------- pd.DataFrame ``f_group`` with its columns in the order of ``d_group``'s rows. Raises ------ ValueError If D names a material twice, or a material of F or of D is not in the other. """ materials = d_group.index if materials.has_duplicates: twice = materials[materials.duplicated()].unique().to_list() msg = f'd_matrix["{group}"] names a material more than once: {twice}.' raise ValueError(msg) if unknown := f_group.columns.difference(materials, sort=False).to_list(): msg = ( f'F["{group}"] has materials that d_matrix["{group}"] does not have: {unknown}. ' "Each column of F is paired with the row of d_matrix of the same name." ) raise ValueError(msg) if unused := materials.difference(f_group.columns, sort=False).to_list(): msg = ( f'd_matrix["{group}"] has materials that F["{group}"] has no column for: {unused}. ' "Give a material that no blend uses a column of zeros in F." ) raise ValueError(msg) return f_group[materials] def _reject_separate_y(y: object, where: str) -> None: """Raise if a caller supplies a conventional ``y`` to a T-shaped model. TPLS reads its response out of ``X["Y"]``, so a separate ``y`` is never the target it scores against. Silently ignoring it (the behaviour before #565) let callers believe a response had been supplied when it had not. Parameters ---------- y : object The value passed as ``y``. Anything other than ``None`` is an error. where : str Qualified name of the caller, used to open the error message. Raises ------ ValueError If ``y`` is not ``None``. """ if y is None: return msg = ( f"{where} does not take a separate `y`: a T-shaped model carries its response " 'inside X["Y"]. Put the response in the "Y" block of the DataFrameDict and leave ' "`y` as None. For cross-validation with a named metric, pass " "scoring=make_tpls_scorer('r2') rather than scoring='r2'." ) raise ValueError(msg)
[docs] def make_tpls_scorer( metric: str | Callable[..., float] = "r2", *, greater_is_better: bool = True, **metric_kwargs: object, ) -> Callable[..., float]: """Build a ``scoring=`` callable that works with :class:`TPLS` (#565). A *named* scorer string cannot be used with TPLS. sklearn turns ``scoring="r2"`` into a ``_Scorer`` whose ``__call__`` takes ``y_true`` as a required positional argument, but a T-shaped model carries its response inside ``X["Y"]`` rather than in a separate ``y``, so :func:`~sklearn.model_selection.cross_val_score` has no ``y`` to hand over and calls the scorer as ``scorer(estimator, X_test)``. The call then fails on the missing argument *before TPLS is reached*, and sklearn's ``error_score`` records every fold as ``NaN`` behind a ``UserWarning``. sklearn passes a *callable* ``scoring=`` through untouched and invokes it the same way, so a callable whose ``y`` is optional receives that two-argument call cleanly and can read the response out of ``X["Y"]`` itself. That is what this factory returns. Parameters ---------- metric : str or Callable[..., float], optional Either one of ``"r2"``, ``"neg_mean_absolute_error"``, ``"neg_mean_squared_error"`` or ``"neg_root_mean_squared_error"``, whose sign convention matches sklearn's, or a callable ``metric(y_true, y_pred, **kwargs)`` returning a float. Default is ``"r2"``, which reproduces :meth:`TPLS.score`. greater_is_better : bool, optional Consulted only when ``metric`` is a callable: ``False`` flips the sign so the result still obeys sklearn's "higher is better" contract. The named metrics carry their own sign and ignore this. Default is True. **metric_kwargs : object Extra keyword arguments forwarded to ``metric`` on every call. Returns ------- scorer : Callable[..., float] A callable with signature ``scorer(estimator, X, y=None, sample_weight=None)``, usable as the ``scoring=`` argument of :func:`~sklearn.model_selection.cross_val_score`, :func:`~sklearn.model_selection.cross_validate` and the ``*SearchCV`` classes. Several Y blocks are combined the way :meth:`TPLS.score` combines them: an unweighted mean over the blocks in ``X["Y"]``, not a pooled multiblock statistic. Raises ------ ValueError If ``metric`` is a string that is not a known metric name. The returned scorer raises :class:`ValueError` in turn if it is called with a non-``None`` ``y``, or with an ``X`` whose ``"Y"`` block is empty. See Also -------- TPLS.score : The default scoring path, equivalent to ``make_tpls_scorer("r2")``. Examples -------- >>> from sklearn.model_selection import cross_val_score >>> scorer = make_tpls_scorer("neg_root_mean_squared_error") >>> model = TPLS(n_components=2, d_matrix=d_matrix) # doctest: +SKIP >>> cross_val_score(model, X=blocks, cv=5, scoring=scorer) # doctest: +SKIP array([-1.07, -0.98, -1.11, -1.02, -1.05]) """ if callable(metric): score_func: Callable[..., float] = metric sign = 1 if greater_is_better else -1 name = getattr(metric, "__name__", "metric") else: try: score_func, sign = _TPLS_METRICS[metric] except KeyError: msg = ( f"Unknown metric {metric!r} for make_tpls_scorer. Choose one of " f"{sorted(_TPLS_METRICS)}, or pass a callable metric(y_true, y_pred)." ) raise ValueError(msg) from None name = metric def tpls_scorer( estimator: TPLS, X: DataFrameDict, y: object = None, sample_weight: np.ndarray | None = None, ) -> float: """Score a fitted :class:`TPLS` model against the response inside ``X["Y"]``.""" _reject_separate_y(y, "A TPLS scorer") check_is_fitted(estimator) y_actual = X["Y"] if not y_actual: msg = 'X["Y"] must contain at least one block to score a TPLS model.' raise ValueError(msg) y_pred = estimator.diagnose(X).hat per_block = [ score_func(y_actual[key], y_pred[key], sample_weight=sample_weight, **metric_kwargs) for key in y_actual ] return sign * float(np.mean(per_block)) tpls_scorer.__name__ = f"tpls_scorer({name})" tpls_scorer.__qualname__ = tpls_scorer.__name__ return tpls_scorer