# (c) Kevin Dunn, 2010-2026. MIT License. Based on own private work over the years.
"""Post-fit diagnostics and matrix-correlation helpers (ENG-01).
Functions that read a *fitted* PCA / PLS model and summarise it: variable
importance (VIP), squared cosine, observation contributions, the eigenvalue
summary, supplementary-variable projection, and the RV / modified-RV matrix
correlation coefficients. They consume model attributes only (via duck typing),
so they depend just on :mod:`process_improve.multivariate._common` and ``center``
from :mod:`process_improve.multivariate._preprocessing`, and annotate the model
parameter as ``BaseEstimator`` to avoid importing the estimator modules.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import leaves_list, linkage, optimal_leaf_ordering
from scipy.stats import f as f_dist
from sklearn.base import BaseEstimator
from sklearn.utils import Bunch
from .._linalg import safe_inverse
from ..univariate.metrics import _check_probability
from ._common import (
DataMatrix,
_align_to_fit_features,
_scale_block_contributions,
components_with_spread,
epsqrt,
)
from ._nipals import quick_regress, ssq
from ._preprocessing import center
from ._projection import project_rows
# These diagnostics operate on a *fitted* PCA or PLS model via duck typing (they
# read attributes such as ``scores_`` / ``spe_`` after guarding with
# ``hasattr``). They are annotated against ``BaseEstimator`` rather than the
# concrete ``PCA`` / ``PLS`` classes so this leaf module does not import the
# estimator modules - which import these functions in turn - and so avoids a
# module-level import cycle.
[docs]
def vip(model: BaseEstimator, n_components: int | None = None) -> pd.Series:
r"""Calculate Variable Importance in Projection (VIP) scores.
Works with fitted :class:`PCA` and :class:`PLS` models. For PCA the
principal-component loadings ``loadings_`` are used as the weight matrix;
for PLS the X-block weights ``x_weights_`` are used.
The formula is:
.. math::
\\text{VIP}_j = \\sqrt{K \\cdot
\\frac{\\sum_{a=1}^{A} r2_a \\cdot w_{ja}^2}{\\sum_{a=1}^{A} r2_a}}
where :math:`K` is the number of features, :math:`A` the number of
components, :math:`r2_a` the model's ``r2_per_component_`` at component
:math:`a`, and :math:`w_{ja}` the weight for feature :math:`j` in
component :math:`a`.
The same formula weights each component by a different block's variance on
the two models: :math:`r2_a` is the fraction of **X** variance component
:math:`a` explains for PCA (:math:`R^2X`), and the fraction of **Y**
variance for PLS (:math:`R^2Y`). A PLS VIP therefore ranks the variables by
how much they contribute to the components that predict Y, and a PCA VIP by
how much they contribute to the components that describe X; the two are
not comparable with each other.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
n_components : int or None, default=None
Number of components to include. ``None`` uses all fitted components.
Returns
-------
pd.Series
VIP scores indexed by feature names, named ``"VIP"``.
Raises
------
ValueError
If the model is not fitted, if neither ``x_weights_`` nor
``loadings_`` is found, or if *n_components* is out of range.
Notes
-----
The ``K`` factor in the formula above normalises the scores so that
.. math::
\sum_{j=1}^{K} \text{VIP}_j^2 = K
*exactly*, for any model, on any data. That identity is what makes the
familiar "VIP > 1" rule a sensible relative cut-off: the mean square is 1
by construction, so a score above 1 means the variable is
above-average within this model.
It also means **the number of variables exceeding VIP 1 is not a test
statistic.** That count describes the *shape* of the VIP distribution, not
whether any relationship exists, and it barely moves when the response is
permuted: a null built on it has almost no power and will report a
false-discovery rate near 100% on data that genuinely contains signal. If
you want to ask "is there anything here at all", permute the response and
compare *out-of-sample* performance instead. See
:func:`~process_improve.multivariate.check_predictive_signal`.
See Also
--------
process_improve.multivariate.check_predictive_signal :
A permutation null on Q² that does respond to signal.
Examples
--------
>>> pls = PLS(n_components=3).fit(X_scaled, Y_scaled)
>>> pls.vip() # bound convenience method after fit()
>>> vip(pls) # or call the standalone function directly
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.vip(n_components=2)
"""
if not hasattr(model, "r2_per_component_"):
msg = "Model is not fitted. Call fit() before computing VIP."
raise ValueError(msg)
if hasattr(model, "x_weights_"):
weights: pd.DataFrame = model.x_weights_
elif hasattr(model, "loadings_"):
weights = model.loadings_
else:
msg = "Model must have 'x_weights_' (PLS) or 'loadings_' (PCA) to compute VIP."
raise ValueError(msg)
r2: np.ndarray = model.r2_per_component_.values
w: np.ndarray = weights.values # (n_features, total_components)
total_components = w.shape[1]
if n_components is not None and not (1 <= n_components <= total_components):
msg = f"n_components must be between 1 and {total_components}, got {n_components}."
raise ValueError(msg)
w = w[:, :n_components] # a slice up to None keeps every component
r2 = r2[:n_components]
n_features = w.shape[0]
vip_values = np.sqrt(n_features * np.sum(r2 * w**2, axis=1) / np.sum(r2))
return pd.Series(vip_values, index=weights.index, name="VIP")
def _select_response(beta: pd.DataFrame, response: str | int | None) -> str:
"""Resolve a response selector to a single column label of ``beta``."""
columns = list(beta.columns)
if response is None:
if len(columns) != 1:
msg = f"This model has several responses ({columns}); pass response=<name> to pick one."
raise ValueError(msg)
return columns[0]
if isinstance(response, (int, np.integer)) and response not in columns:
if not (0 <= int(response) < len(columns)):
msg = f"response index {response} is out of range for {len(columns)} responses."
raise ValueError(msg)
return columns[int(response)]
if response not in columns:
msg = f"response {response!r} is not one of the model responses {columns}."
raise ValueError(msg)
return response # type: ignore[return-value]
def _target_projection_arrays(
model: BaseEstimator, X: DataMatrix, response: str | int | None
) -> tuple[pd.DataFrame, np.ndarray, np.ndarray, np.ndarray, str]:
"""Return ``(X_aligned, t_tp, p_tp, w_tp, response_label)`` for the TP component.
The target-projection direction is the (unit-normalised) regression vector
``b`` for the chosen response, read from ``model.beta_coefficients_``; the
scores are ``t = X w_tp`` and the loadings ``p = X^T t / (t^T t)``. ``X``
is the data ``predict`` takes, moved into the model's scaled space first,
as :func:`t2_contributions` and :func:`spe_contributions` move it.
"""
if not hasattr(model, "beta_coefficients_"):
msg = "Model is not fitted, or is not a PLS model with 'beta_coefficients_'."
raise ValueError(msg)
beta = model.beta_coefficients_
label = _select_response(beta, response)
# The TP direction must live in the SAME space as the X the scores are
# computed from. ``beta_coefficients_`` is deliberately rescaled to raw
# X -> raw Y units in fit(); with scale=True (the default) that vector is
# NOT a direction in the model's internal (scaled) space, and normalising
# it does not repair the per-column unit mismatch. Use the scaled-space
# regression vector (direct_weights_ @ y_loadings_.T) instead, and map X
# into the same internal space via the model's own scaler when present.
b_scaled = model.direct_weights_.to_numpy() @ model.y_loadings_.to_numpy().T
b = b_scaled[:, list(beta.columns).index(label)]
norm_b = float(np.sqrt(b @ b))
if norm_b <= epsqrt:
msg = f"The regression vector for response {label!r} is ~0; it is not predicted by X."
raise ValueError(msg)
w_tp = b / norm_b
if not isinstance(X, pd.DataFrame):
X = pd.DataFrame(X)
x_frame: pd.DataFrame = _align_to_fit_features(X, beta.index)
x_scaler = getattr(model, "_x_scaler", None)
if x_scaler is not None:
x_frame = x_scaler.transform(x_frame)
X_values = x_frame.to_numpy(dtype=float)
# A missing cell made every score, loading and ratio NaN (#1114). It is skipped, as NIPALS
# skips it: a row's score is regressed on the observed part of w_tp (0 for a row with nothing
# observed), and a feature's loading on the scores of the rows where it is observed.
t_tp = quick_regress(X_values, w_tp[:, np.newaxis], per="row").ravel()
ttt = float(t_tp @ t_tp)
if ttt <= epsqrt:
msg = "The target-projected scores have ~0 variance; cannot form the TP loading."
raise ValueError(msg)
p_tp = quick_regress(X_values, t_tp[:, np.newaxis], per="column").ravel()
return x_frame, t_tp, p_tp, w_tp, label
def target_projection(model: BaseEstimator, X: DataMatrix, response: str | int | None = None) -> Bunch:
r"""Target-projected (TP) component of a fitted PLS model for one response.
Target projection (Kvalheim and Karstang, 1989) rotates the PLS solution so
that a *single* latent component carries all of the predictive information
for one response. The component points along the regression vector
:math:`b` (the column of ``beta_coefficients_`` for that response):
.. math::
w_{\text{TP}} = \frac{b}{\lVert b \rVert}, \qquad
t_{\text{TP}} = X\, w_{\text{TP}}, \qquad
p_{\text{TP}} = \frac{X^\top t_{\text{TP}}}{t_{\text{TP}}^\top t_{\text{TP}}}.
The TP component is the basis for the selectivity ratio
(:func:`selectivity_ratio`).
A missing cell (NaN) is skipped, as the NIPALS fit skips it: a row's score
is regressed on the observed part of :math:`w_{\text{TP}}`, and a
feature's loading on the scores of the rows where it is observed. A row
with nothing observed scores 0 and adds nothing, and a feature with
nothing observed has a loading of 0.
Parameters
----------
model : PLS
A fitted PLS model (must expose ``beta_coefficients_``).
X : array-like of shape (n_samples, n_features)
The data the model's ``predict`` takes, in the units it was fitted
on. A model that centres and scales ``X`` itself (``PLS(scale=True)``,
the default) does so here first; ``PLS(scale=False)`` does not, and is
given ``X`` scaled as its training data was.
response : str or int or None, default=None
Which response (Y column) to project onto. ``None`` is allowed only for
a single-response model; otherwise pass the response label (or its
integer position).
Returns
-------
sklearn.utils.Bunch
With fields ``scores`` (pd.Series, the TP scores per sample),
``loadings`` (pd.Series, the TP loading per feature), ``weights``
(pd.Series, the unit TP weight per feature) and ``response`` (the
resolved response label).
Raises
------
ValueError
If the model is not a fitted PLS, the response selector is invalid, or
the regression vector / TP scores are degenerate (~0).
References
----------
Kvalheim, O. M. and Karstang, T. V. (1989). Interpretation of latent-variable
regression models. *Chemometrics and Intelligent Laboratory Systems*, 7(1-2),
39-51.
Examples
--------
>>> pls = PLS(n_components=3).fit(X_scaled, y_scaled)
>>> tp = pls.target_projection(X_scaled) # bound convenience method
>>> tp.scores.head()
See Also
--------
selectivity_ratio : Per-variable explained/residual ratio on the TP component.
"""
X_df, t_tp, p_tp, w_tp, label = _target_projection_arrays(model, X, response)
return Bunch(
scores=pd.Series(t_tp, index=X_df.index, name="TP score"),
loadings=pd.Series(p_tp, index=X_df.columns, name="TP loading"),
weights=pd.Series(w_tp, index=X_df.columns, name="TP weight"),
response=label,
)
def _selectivity_ratio_one(
model: BaseEstimator, X: DataMatrix, response: str | int | None, conf_level: float
) -> pd.Series:
"""Compute the selectivity ratio per feature for a single response (public-API helper)."""
X_df, t_tp, p_tp, _w_tp, label = _target_projection_arrays(model, X, response)
X_values = X_df.to_numpy(dtype=float)
# Per-feature explained and residual sums of squares on the TP component, over the feature's
# observed cells: they add up to its observed sum of squares, as the loading is fitted there.
ss_explained = (p_tp**2) * ((t_tp**2) @ ~np.isnan(X_values))
ss_residual = ssq(X_values - np.outer(t_tp, p_tp), axis=0)
with np.errstate(invalid="ignore", divide="ignore"):
sr = ss_explained / ss_residual
# Rounding leaves a feature the TP component reproduces exactly a residual of about 1e-32 of its
# sum of squares, not 0. A residual of at most eps times the explained sum of squares (a norm at
# most epsqrt times the feature's) counts as none, so every ratio from 1 / eps up is inf.
sr[sr >= 1.0 / epsqrt**2] = np.inf
sr[np.isnan(sr)] = 0.0 # 0 / 0: no sum of squares, or a ratio that missing values leave undefined
series = pd.Series(sr, index=X_df.columns, name="selectivity_ratio")
# F-based critical value (Rajalahti et al., 2009): SR_j above this is
# "significant" at conf_level, with N-2 and N-3 degrees of freedom. The
# sensory layer prefers a permutation test, so this is advisory metadata.
# With N <= 3 a degree of freedom is below 1, and scipy returns NaN.
# N is the feature's own count of observed rows, over which its ratio is formed (#1144), so the
# value is one per feature, a Series whatever the counts: one type for the code that reads it.
n_observed = (~np.isnan(X_values)).sum(axis=0)
critical = f_dist.ppf(conf_level, dfn=n_observed - 2, dfd=n_observed - 3)
series.attrs["f_critical"] = pd.Series(critical, index=X_df.columns, name="f_critical")
series.attrs["conf_level"] = float(conf_level)
series.attrs["response"] = label
return series
def selectivity_ratio(
model: BaseEstimator,
X: DataMatrix,
response: str | int | None = None,
*,
conf_level: float = 0.95,
) -> pd.Series | pd.DataFrame:
r"""Compute the selectivity ratio of each feature on the target-projected component.
The selectivity ratio (Rajalahti et al., 2009) ranks each feature by how
much of its variance the *predictive* (target-projected) direction explains.
On the TP component (:func:`target_projection`), for feature :math:`j`:
.. math::
\text{SR}_j = \frac{\text{SS}_{\text{explained},j}}
{\text{SS}_{\text{residual},j}}
= \frac{p_{\text{TP},j}^2\, (t_{\text{TP}}^\top t_{\text{TP}})}
{\sum_i (x_{ij} - t_{\text{TP},i}\, p_{\text{TP},j})^2}.
A large SR means the feature is well aligned with the predictive direction.
Unlike VIP, it is a true explained/residual variance ratio and can be
compared against an F distribution. Note that two collinear features carry
near-identical SR: the selectivity ratio ranks predictive relevance, it does
not break ties between mutually collinear features.
A feature the TP component reproduces exactly has no residual, and its SR is
``inf``, so a descending sort ranks it first. The residual counts as none
when its sum of squares is at most machine epsilon (about 2.2e-16) times the
explained one, which is a residual norm of at most about 1.5e-8 times the
feature's: rounding alone leaves about 1e-32 of its sum of squares. Every
finite SR is therefore below ``1 / eps``, about 4.5e15. A feature with no
sum of squares at all, such as a constant column after :class:`MCUVScaler`,
has neither an explained nor a residual part (0 / 0), and its SR is 0.
A missing cell (NaN) is skipped, as in :func:`target_projection`, and both
sums of squares of a feature run over its observed cells, where they add
up to the feature's sum of squares. A feature with nothing observed has
none, and its SR is 0. The F critical value of a feature counts its
observed rows too.
Parameters
----------
model : PLS
A fitted PLS model (must expose ``beta_coefficients_``).
X : array-like of shape (n_samples, n_features)
The data the model's ``predict`` takes, in the units it was fitted
on. A model that centres and scales ``X`` itself (``PLS(scale=True)``,
the default) does so here first; ``PLS(scale=False)`` does not, and is
given ``X`` scaled as its training data was.
response : str or int or None, default=None
Which response to compute SR for. ``None`` returns a feature-by-response
DataFrame when the model has several responses, or a Series for a
single-response model.
conf_level : float, default=0.95
Confidence level for the advisory F-based critical value, attached to
the result's ``.attrs["f_critical"]``: a Series indexed by feature,
with :math:`N - 2` and :math:`N - 3` degrees of freedom, :math:`N`
being the number of rows the feature is observed in. So
``ratio > ratio.attrs["f_critical"]`` compares each feature with its own.
Returns
-------
pd.Series or pd.DataFrame
Selectivity ratios indexed by feature, each at least 0 and ``inf`` for
a feature with no residual. A Series for one response (with
``f_critical`` / ``conf_level`` / ``response`` in ``.attrs``), or a
feature-by-response DataFrame when ``response`` is ``None`` and the
model has several responses.
Raises
------
ValueError
If the model is not a fitted PLS or the response selector is invalid.
References
----------
Rajalahti, T., Arneberg, R., Berven, F. S., Myhr, K.-M., Ulvik, R. J. and
Kvalheim, O. M. (2009). Biomarker discovery in mass spectral profiles by
means of selectivity ratio plot. *Chemometrics and Intelligent Laboratory
Systems*, 95(1), 35-48.
Examples
--------
>>> pls = PLS(n_components=3).fit(X_scaled, y_scaled)
>>> pls.selectivity_ratio(X_scaled).sort_values(ascending=False).head()
See Also
--------
target_projection : The target-projected component the ratio is built on.
vip : Variable Importance in Projection, an alternative importance measure.
"""
_check_probability("conf_level", conf_level)
beta = getattr(model, "beta_coefficients_", None)
if response is None and beta is not None and beta.shape[1] > 1:
return pd.DataFrame({col: _selectivity_ratio_one(model, X, col, conf_level) for col in beta.columns})
return _selectivity_ratio_one(model, X, response, conf_level)
[docs]
def squared_cosine(model: BaseEstimator, n_components: int | None = None) -> pd.DataFrame:
r"""Calculate the squared cosine (cos2): quality of representation of observations.
Works with fitted :class:`PCA` and :class:`PLS` models. The squared cosine
of observation :math:`i` on component :math:`a` is the squared score
divided by that observation's total variation budget:
.. math::
\\cos^2_{ia} = \\frac{t_{ia}^2}
{\\sum_{a=1}^{A} t_{ia}^2 + \\text{SPE}_i^2}
where :math:`t_{ia}` is the score and :math:`\\text{SPE}_i` the residual
(squared prediction error) of the observation. Across all components the
cos2 values plus the residual fraction sum to 1. A value close to 1 means
the observation is well represented on that component. For :class:`PCA`,
whose loadings are orthonormal, the denominator equals the squared distance
of the observation from the origin, matching the classical definition.
cos2 complements the existing diagnostics: Hotelling's T² measures distance
*within* the model plane, SPE measures distance *to* it, and cos2 reports
how much of an observation's total variation a given component captures.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
n_components : int or None, default=None
Number of components to return. ``None`` returns all fitted components.
Returns
-------
pd.DataFrame
cos2 values of shape (n_samples, n_components), indexed by sample.
Raises
------
ValueError
If the model is not fitted, or if *n_components* is out of range.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.squared_cosine() # bound convenience method after fit()
>>> squared_cosine(pca, n_components=2) # or call the function directly
"""
if not hasattr(model, "scores_") or not hasattr(model, "spe_"):
msg = "Model is not fitted. Call fit() before computing the squared cosine."
raise ValueError(msg)
scores = model.scores_
total_components = scores.shape[1]
if n_components is not None and not (1 <= n_components <= total_components):
msg = f"n_components must be between 1 and {total_components}, got {n_components}."
raise ValueError(msg)
score_ss = scores.to_numpy() ** 2
residual_ss = model.spe_.to_numpy()[:, -1] ** 2
total_ss = score_ss.sum(axis=1) + residual_ss
with np.errstate(invalid="ignore"): # 0 / 0 for a row at the origin; total_ss >= score_ss, so never x / 0
cos2 = score_ss[:, :n_components] / total_ss[:, None]
cos2[~np.isfinite(cos2)] = 0.0
return pd.DataFrame(cos2, index=scores.index, columns=scores.columns[:n_components])
[docs]
def observation_contributions(model: BaseEstimator, n_components: int | None = None) -> pd.DataFrame:
r"""Calculate the contribution of each observation to each component.
Works with fitted :class:`PCA` and :class:`PLS` models. The contribution of
observation :math:`i` to component :math:`a` is its squared score divided
by the sum of squared scores of all observations on that component:
.. math::
\\text{contribution}_{ia} = \\frac{t_{ia}^2}{\\sum_{i=1}^{N} t_{ia}^2}
Values lie between 0 and 1 and each column sums to 1, so a contribution
well above the average :math:`1/N` flags an observation that strongly
shapes that component. The exception is a component whose score column
has zero variance (``sum(t_{ia}^2) = 0``): the division cannot be
computed, so the column is returned as zeros rather than NaN, and its
sum is 0 rather than 1.
Note that this is *not* the same diagnostic as the ``score_contributions``
method, despite the similar name. ``score_contributions`` is *per-variable*
and signed: it decomposes one observation's position in score space back
onto the original variables ("which **variables** explain why this
observation sits where it does?"). ``observation_contributions`` is
*per-observation* and non-negative: it reports each observation's share of
a component's total inertia ("which **observations** most strongly shape
this component?"). The two are orthogonal views of the same score matrix
and are not interchangeable.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
n_components : int or None, default=None
Number of components to return. ``None`` returns all fitted components.
Returns
-------
pd.DataFrame
Contributions of shape (n_samples, n_components), indexed by sample.
Each column sums to 1 except columns for components whose score has
zero variance, which are returned as zeros and sum to 0.
Raises
------
ValueError
If the model is not fitted, or if *n_components* is out of range.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.observation_contributions()
>>> observation_contributions(pca, n_components=2)
See Also
--------
PCA.score_contributions : The per-variable counterpart - decomposes one
observation's score-space position back onto the original variables.
"""
if not hasattr(model, "scores_"):
msg = "Model is not fitted. Call fit() before computing observation contributions."
raise ValueError(msg)
scores = model.scores_
total_components = scores.shape[1]
if n_components is not None and not (1 <= n_components <= total_components):
msg = f"n_components must be between 1 and {total_components}, got {n_components}."
raise ValueError(msg)
score_ss = scores.to_numpy() ** 2
column_ss = score_ss.sum(axis=0)
with np.errstate(invalid="ignore"): # 0 / 0 for a score with no spread; column_ss >= score_ss, so never x / 0
contributions = score_ss[:, :n_components] / column_ss[:n_components]
contributions[~np.isfinite(contributions)] = 0.0
return pd.DataFrame(contributions, index=scores.index, columns=scores.columns[:n_components])
def _contribution_inputs(model: BaseEstimator, X: DataMatrix) -> tuple[pd.DataFrame, np.ndarray, np.ndarray]:
"""Return ``(X_rows, R, P)``: ``X`` in the space the model was fitted in, and the model's matrices.
``X`` is the data ``transform`` and ``diagnose`` take, and is read through the
model's own ``_scaled_rows``, as they read it (#1125): checked against the fit,
its columns in the fitted order, centred and scaled when the model does that
itself (``PLS(scale=True)``), and 0 in a column the fit left out (#1098), so
that column contributes 0 (#1114).
``R`` generates the scores (``T = X @ R``) and ``P`` reconstructs the X-block
(``X_hat = T @ P.T``). For PCA both are the loadings; for PLS ``R`` is the
direct (rotated) weights ``direct_weights_`` and ``P`` is ``x_loadings_``.
PLS is recognised by the presence of ``direct_weights_``. OPLS, which has
neither, gives its own through ``_contribution_matrices`` (#1144).
"""
if not hasattr(model, "scores_"):
msg = "Model is not fitted. Call fit() before computing contributions."
raise ValueError(msg)
if hasattr(model, "_contribution_matrices"):
R, P = model._contribution_matrices()
else:
is_pls = hasattr(model, "direct_weights_")
P = np.asarray(model.x_loadings_ if is_pls else model.loadings_)
R = np.asarray(model.direct_weights_ if is_pls else model.loadings_)
# An array's columns are taken by position, labelled as fitted, as they always were here: a frame
# lets _scaled_rows do that without sklearn's warning that the rows carry no feature names. A frame
# passes through pd.DataFrame with its labels and values as they are.
return model._scaled_rows(pd.DataFrame(X)), R, P
def _complete_rows_apart(X_values: np.ndarray, rows_product: Callable[[np.ndarray], np.ndarray]) -> np.ndarray:
"""Evaluate ``rows_product(rows)`` for the complete rows and for the rest, separately.
The last bit of a matrix product can depend on how many rows it has, since BLAS
splits the work by shape (Apple's Accelerate does). Multiplying the complete rows on
their own makes them match, bit for bit, what the same rows give when ``X`` has no
missing cells, so a complete row's contributions do not depend on the other rows.
"""
incomplete = np.isnan(X_values).any(axis=1)
out = np.empty(X_values.shape)
for rows in (~incomplete, incomplete):
out[rows] = rows_product(rows)
return out
def _scores_and_guides(
model: BaseEstimator, X_values: np.ndarray, R: np.ndarray, P: np.ndarray, method: str | None, ridge: float
) -> tuple[np.ndarray, np.ndarray | None]:
"""Return the scores of every row and, when rows have missing cells, the per-row score-generating matrices.
A complete row's score is ``x @ R``. A row with missing cells has its scores
estimated from the observed cells by
:func:`~process_improve.multivariate._projection.project_rows`, the code
``project``, ``transform`` and ``diagnose`` score it with, through the
operator ``M`` of its pattern (``t_hat = M @ x_observed``); its
contributions use ``M`` where a complete row uses ``R``. ``method=None``
takes the estimator the model's ``transform`` and ``diagnose`` use, so the
contributions split the scores they give (#1144). A row whose estimator
cannot be inverted is NaN, with a warning, as there. The second return value
is ``None`` when every row is complete, and otherwise an array of shape
(n_samples, n_features, n_components) holding ``R`` for the complete rows
and, for the others, ``M.T`` on the observed features and zero on the
missing ones.
"""
if not np.isnan(X_values).any():
return X_values @ R, None
estimated = project_rows(
P,
R,
np.asarray(model.explained_variance_),
X_values,
method=getattr(
model,
"_MISSING_CELL_ESTIMATOR",
"tsr", # pragma: no mutate - every estimator of the package sets it, so this default is not read
)
if method is None
else method,
ridge=ridge,
# SCP projects onto the weights, as NIPALS does; for PCA they are the loadings (the operator's default).
x_weights=np.asarray(model.x_weights_) if hasattr(model, "direct_weights_") else None,
x_residuals=getattr(model, "_x_residuals", None),
singular="nan", # pragma: no mutate - project_rows reads any value but "raise" as "nan"
with_guides=True,
)
return estimated.scores, estimated.guides
def _t2_score_sd(score_sd: np.ndarray) -> np.ndarray:
"""Return the score standard deviations Hotelling's T² divides by, ``inf`` for a component it leaves out.
A component with no spread, by :func:`~process_improve.multivariate._common.components_with_spread`,
carries no T²: a divisor of ``inf`` makes it add 0 for any finite score. Every T² of PCA,
PLS, MBPCA and MBPLS goes through this one rule, so a row gets the same T² from ``fit`` as
from ``diagnose`` or ``project``.
Parameters
----------
score_sd : np.ndarray of shape (n_components,)
The training score standard deviations (``scaling_factor_for_scores_``).
Returns
-------
np.ndarray of shape (n_components,)
``score_sd``, with ``inf`` for each component left out.
"""
return np.where(components_with_spread(score_sd), score_sd, np.inf)
def t2_precision(covariance: np.ndarray, score_sd: np.ndarray, *, what: str) -> np.ndarray:
"""Return the matrix ``S^-1`` of the T² ``t' S^-1 t`` for a full score covariance ``S`` (#1124).
A component with no spread, by the rule :func:`_t2_score_sd` applies to the training score
standard deviations ``score_sd``, gets a zero row and column, so it adds 0 to T², and the
inverse is taken over the other components only: their block of ``S``. The whole of ``S``
is singular when a component has no spread, since its scores are all about 0.
Parameters
----------
covariance : np.ndarray of shape (n_components, n_components)
The score covariance ``S``.
score_sd : np.ndarray of shape (n_components,)
The model's training score standard deviations, which decide the components with spread.
what : str
What ``S`` is, for the error :func:`~process_improve._linalg.safe_inverse` raises when the
block of the components with spread is itself singular.
Returns
-------
np.ndarray of shape (n_components, n_components)
"""
keep = np.flatnonzero(components_with_spread(score_sd))
precision = np.zeros_like(covariance, dtype=float)
precision[np.ix_(keep, keep)] = safe_inverse(covariance[np.ix_(keep, keep)], what=what)
return precision
def cumulative_block_t2(scores: np.ndarray) -> np.ndarray:
"""Return the cumulative Hotelling's T² of each row of a multiblock model's block scores (#513).
Each component is scaled by the scores' second moment about zero,
``sum(t**2) / (n - 1)`` over the ``n`` rows that have a score, which is how
``explained_variance_`` scales the super scores, so a block's T² and the super T²
follow one convention. T² measures the distance from the model's centre, the
origin of the score space, so the spread it is scaled by is the spread about that
origin too. The block scores used to be scaled by their variance about their own
mean instead, which differs as soon as missing data leaves them off-centre, and
which a single row without a score turned into NaN, so that every row's T² in the
block went unscaled. A component with no spread in the block is left out by
:func:`_t2_score_sd`, as from every other T² (#1028).
Parameters
----------
scores : np.ndarray of shape (n_samples, n_components)
One block's scores, NaN for a row with nothing observed in the block.
Returns
-------
np.ndarray of shape (n_samples, n_components)
Column ``a`` holds the T² over the first ``a`` components; NaN for a row with
no score. A component with no spread adds nothing.
"""
present = np.isfinite(scores)
second_moment = np.nansum(scores**2, axis=0) / np.maximum(1, present.sum(axis=0) - 1)
return np.cumsum((scores**2) / _t2_score_sd(np.sqrt(second_moment)) ** 2, axis=1)
def t2_contributions(
model: BaseEstimator,
X: DataMatrix,
components: list[int] | None = None,
*,
method: str | None = None,
ridge: float = 0.0,
) -> pd.DataFrame:
r"""Per-variable contributions to Hotelling's :math:`T^2`.
Works with fitted :class:`PCA` and :class:`PLS` models. Decomposes each
observation's :math:`T^2` onto the original variables. The contribution of
variable :math:`k` for observation :math:`i` is
.. math::
c^{T^2}_{ik} = x_{ik} \sum_{a} \frac{t_{ia}}{s_a^2}\, R_{ka},
where :math:`t_{ia}` are the scores, :math:`s_a^2` is the score variance of
component :math:`a` (``scaling_factor_for_scores_`` squared) and :math:`R` is
the score-generating matrix (loadings for PCA, ``direct_weights_`` for PLS,
so that :math:`T = XR`). Summed over the variables this telescopes to
:math:`\sum_a t_{ia}^2 / s_a^2`, i.e. the observation's :math:`T^2`. The
values are signed; a large magnitude flags a variable that drives the
observation away from the model centre. This is the standard MSPC
diagnostic (Westerhuis, Gurden and Smilde, 2000). A component whose score
standard deviation is ~0 contributes nothing, as it adds nothing to the
stored ``hotellings_t2_``.
A row with missing cells (NaN) gets its scores from its observed cells
alone, with the estimator named by ``method``, the missing-data operators
of :meth:`PCA.project` and :meth:`PLS.project`. Its contributions are then
defined at every observed cell and NaN at the missing ones, which add
nothing to the row sums (``sum(axis=1)`` skips NaN). The default estimator
is the one the model's ``transform`` and ``diagnose`` score the row with,
``"tsr"`` for PCA and PLS and ``"scp"`` for the adaptive models, so the
contributions split the scores, T² and SPE they give it (#1144).
``method="scp"`` is the single-component projection the NIPALS fit uses:
on a model fitted with missing data, the training rows then reproduce the
stored ``scores_``, ``hotellings_t2_`` and ``spe_`` to within the NIPALS
convergence tolerance, as the complete rows do. A column the fit left out,
having no observed value, is read at 0, as :meth:`PCA.transform` reads it,
so it contributes 0 whatever ``X`` holds there.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`.
Passing the training data reproduces the model's stored ``hotellings_t2_``
(with ``method="scp"`` when it has missing cells).
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
components : list of int, optional
**1-based** component indices to decompose over, matching the model's
column convention. ``None`` (default) uses all fitted components, so the
row sums equal the cumulative :math:`T^2`.
method : {"tsr", "scp", "pmp"} or None, default=None
Score estimator for the rows with missing cells; ignored when ``X`` is
complete. ``None`` takes the one the model's ``transform`` and
``diagnose`` use. See :meth:`PCA.project`.
ridge : float, default=0.0
Regularisation for the ``"tsr"`` and ``"pmp"`` estimators, as in
:meth:`PCA.project`.
Returns
-------
pd.DataFrame
Signed contributions of shape (n_samples, n_features), NaN at the
missing cells. Each row sums to the observation's :math:`T^2` over the
selected components.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> contrib = pca.t2_contributions(X_scaled)
>>> contrib.sum(axis=1) # equals pca.hotellings_t2_.iloc[:, -1]
See Also
--------
spe_contributions : The residual-space counterpart.
PCA.score_contributions : Decomposes a single score-space movement.
"""
X_df, R, P = _contribution_inputs(model, X)
X_values = np.ascontiguousarray(X_df.to_numpy(dtype=float)) # one memory layout, whatever the input's
A = R.shape[1]
if components is None:
idx = np.arange(A)
else:
idx = np.asarray(components, dtype=int) - 1
if idx.size == 0 or idx.min() < 0 or idx.max() >= A:
msg = f"components must be 1-based indices within 1..{A}, got {components}."
raise ValueError(msg)
# A component whose score sd is ~0 carries no T2: fit() leaves it out of
# hotellings_t2_ by this same rule, and it contributes 0 here too, whatever X
# scores on it, rather than inf or NaN.
s2 = _t2_score_sd(np.asarray(model.scaling_factor_for_scores_))[idx] ** 2
scores, guides = _scores_and_guides(model, X_values, R, P, method, ridge)
weights = scores[:, idx] / s2 # (n, len(idx))
factors = _complete_rows_apart(X_values, lambda rows: weights[rows] @ R[:, idx].T)
if guides is not None:
rows = np.flatnonzero(np.isnan(X_values).any(axis=1))
factors[rows] = np.einsum(
"ia,ika->ik", # pragma: no mutate - einsum reads upper-case subscripts as lower-case ones
weights[rows],
guides[rows][:, :, idx],
)
contributions = X_values * factors
return pd.DataFrame(contributions, index=X_df.index, columns=X_df.columns)
def spe_contributions(
model: BaseEstimator,
X: DataMatrix,
*,
method: str | None = None,
ridge: float = 0.0,
) -> pd.DataFrame:
r"""Per-variable squared-prediction-error (SPE / DModX) contributions.
Works with fitted :class:`PCA` and :class:`PLS` models. Returns the signed
residual of each variable after reconstructing the X-block from the full
model:
.. math::
e_{ik} = x_{ik} - \hat{x}_{ik}, \qquad \hat{X} = T P^\top,
where :math:`P` is the reconstruction loadings (``loadings_`` for PCA,
``x_loadings_`` for PLS). The squared residuals sum across variables to the
observation's SPE; equivalently ``(spe_contributions(X) ** 2).sum(axis=1)``
equals the stored ``spe_`` (final column) squared. The signs show whether a
variable sits above or below its reconstruction, which is the standard SPE
contribution plot used to diagnose why an observation has a high residual.
A row with missing cells (NaN) gets its scores from its observed cells
alone, with the estimator named by ``method``, the missing-data operators
of :meth:`PCA.project` and :meth:`PLS.project`. Its contributions are then
defined at every observed cell and NaN at the missing ones, which add
nothing to the row sums (``sum(axis=1)`` skips NaN). The default estimator
is the one the model's ``transform`` and ``diagnose`` score the row with,
``"tsr"`` for PCA and PLS and ``"scp"`` for the adaptive models, so the
contributions split the scores, T² and SPE they give it (#1144).
``method="scp"`` is the single-component projection the NIPALS fit uses:
on a model fitted with missing data, the training rows then reproduce the
stored ``scores_``, ``hotellings_t2_`` and ``spe_`` to within the NIPALS
convergence tolerance, as the complete rows do. A column the fit left out,
having no observed value, is read at 0, as :meth:`PCA.transform` reads it,
so it contributes 0 whatever ``X`` holds there.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`.
Passing the training data reproduces the model's stored ``spe_``
(with ``method="scp"`` when it has missing cells).
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
method : {"tsr", "scp", "pmp"} or None, default=None
Score estimator for the rows with missing cells; ignored when ``X`` is
complete. ``None`` takes the one the model's ``transform`` and
``diagnose`` use. See :meth:`PCA.project`.
ridge : float, default=0.0
Regularisation for the ``"tsr"`` and ``"pmp"`` estimators, as in
:meth:`PCA.project`.
Returns
-------
pd.DataFrame
Signed per-variable residuals of shape (n_samples, n_features), NaN at
the missing cells. The squared row sums equal the observation's SPE
squared, over its observed cells.
Examples
--------
>>> pca = PCA(n_components=2).fit(X_scaled)
>>> resid = pca.spe_contributions(X_scaled)
>>> (resid ** 2).sum(axis=1) # equals pca.spe_.iloc[:, -1] ** 2
See Also
--------
t2_contributions : The :math:`T^2` (score-space) counterpart.
"""
X_df, R, P = _contribution_inputs(model, X)
X_values = np.ascontiguousarray(X_df.to_numpy(dtype=float)) # one memory layout, whatever the input's
scores, _guides = _scores_and_guides(model, X_values, R, P, method, ridge)
residuals = _complete_rows_apart(X_values, lambda rows: X_values[rows] - scores[rows] @ P.T)
return pd.DataFrame(residuals, index=X_df.index, columns=X_df.columns)
def _score_contribution_terms(model: BaseEstimator, X: DataMatrix, component: int) -> tuple[pd.DataFrame, np.ndarray]:
"""Return ``(X_aligned, r_a)`` for the requested 1-based ``component``."""
X_df, R, _ = _contribution_inputs(model, X)
A = R.shape[1]
if not (1 <= int(component) <= A):
msg = f"component must be a 1-based index within 1..{A}, got {component}."
raise ValueError(msg)
return X_df, R[:, int(component) - 1]
_SCORE_CONTRIBUTIONS_USAGE = """\
score_contributions() takes the data X, as transform does, not a score vector.
A contribution is x_ik * R_ka, so the per-variable terms sum to the score being
decomposed. That needs the observation's data, which a score vector does not
carry. See Miller, Swanson and Heckler (1994).
model.score_contributions(X) # all observations, component 1
model.score_contributions(X).iloc[i] # just observation i
To compare two observations or two groups:
model.group_contributions(X, group=[a], reference=[b])
For contributions to Hotelling's T-squared, which pools every component:
model.t2_contributions(X)
"""
_SCORE_VECTOR_KEYWORDS = frozenset({"t_end", "components", "weighted"})
def _reject_score_vector_call(X: object, keywords: dict) -> None:
"""Raise a usage error if called with a score vector instead of ``X``."""
looks_like_scores = isinstance(X, (pd.Series, np.ndarray, list, tuple)) and np.ndim(X) == 1
if looks_like_scores or (set(keywords) & _SCORE_VECTOR_KEYWORDS):
raise TypeError(_SCORE_CONTRIBUTIONS_USAGE)
if keywords:
unexpected = ", ".join(sorted(keywords))
msg = f"score_contributions() got an unexpected keyword argument: {unexpected}."
raise TypeError(msg)
[docs]
def score_contributions(
model: BaseEstimator,
X: DataMatrix,
component: int = 1,
scaling: str = "none",
*,
method: str | None = None,
ridge: float = 0.0,
**deprecated: object,
) -> pd.DataFrame:
r"""Per-variable contributions to a single score, :math:`t_a`.
Works with fitted :class:`PCA` and :class:`PLS` models. A score is a
weighted sum of the (preprocessed) variables, so it splits exactly into one
term per variable. The contribution of variable :math:`k` to the score of
observation :math:`i` on component :math:`a` is
.. math::
c_{ik}^{(a)} = x_{ik}\, R_{ka}, \qquad
\sum_{k=1}^{K} c_{ik}^{(a)} = t_{ia},
where :math:`R` is the score-generating matrix (``loadings_`` for PCA,
``direct_weights_`` for PLS, so that :math:`T = XR`). This is the
contribution of Miller, Swanson and Heckler (1994); the generalisation from
PCA loadings to any latent-variable model's score-generating weights follows
Westerhuis, Gurden and Smilde (2000).
The distinction from a loading plot is the point of the diagnostic. A
loading :math:`R_{ka}` describes the whole data set; a contribution
:math:`x_{ik} R_{ka}` describes *one observation*, and a variable with a
large loading contributes nothing when that observation sits at its mean.
Ranking variables by loading can therefore point at a different cause than
ranking them by contribution.
A row with missing cells (NaN) gets its scores from its observed cells
alone, with the estimator named by ``method``, the missing-data operators
of :meth:`PCA.project` and :meth:`PLS.project`. Its contributions are then
defined at every observed cell and NaN at the missing ones, which add
nothing to the row sums (``sum(axis=1)`` skips NaN). The default estimator
is the one the model's ``transform`` and ``diagnose`` score the row with,
``"tsr"`` for PCA and PLS and ``"scp"`` for the adaptive models, so the
contributions split the scores, T² and SPE they give it (#1144).
``method="scp"`` is the single-component projection the NIPALS fit uses:
on a model fitted with missing data, the training rows then reproduce the
stored ``scores_``, ``hotellings_t2_`` and ``spe_`` to within the NIPALS
convergence tolerance, as the complete rows do. A column the fit left out,
having no observed value, is read at 0, as :meth:`PCA.transform` reads it,
so it contributes 0 whatever ``X`` holds there.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`.
Passing the training data reproduces the model's stored ``scores_``
(with ``method="scp"`` when it has missing cells).
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
component : int, default=1
**1-based** component index whose score is decomposed, matching the
model's column convention.
scaling : {"none", "maximum", "within"}, default="none"
Presentation scaling from Miller, Swanson and Heckler (1994). ``"none"``
returns the raw contributions, which sum to the score. ``"maximum"``
divides by the largest absolute contribution anywhere in ``X``, so a bar
of :math:`\pm 1` marks the most extreme variable-observation pair in the
data set. ``"within"`` divides each row by the sum of its absolute
contributions, so each row is on a common footing. Both scalings leave
the *pattern* of bars within a row unchanged; neither preserves the sum
to the score.
method : {"tsr", "scp", "pmp"} or None, default=None
Score estimator for the rows with missing cells; ignored when ``X`` is
complete. ``None`` takes the one the model's ``transform`` and
``diagnose`` use. See :meth:`PCA.project`.
ridge : float, default=0.0
Regularisation for the ``"tsr"`` and ``"pmp"`` estimators, as in
:meth:`PCA.project`.
**deprecated
Rejected. Captures ``t_end``, ``components`` and ``weighted`` so that a
call passing a score vector rather than ``X`` raises a
:class:`TypeError` explaining the correct usage. Passing a 1-D ``X``
raises the same error.
Returns
-------
pd.DataFrame
Signed contributions of shape (n_samples, n_features), NaN at the
missing cells. With the default ``scaling="none"``, each row sums to
that observation's score on the selected component.
Examples
--------
>>> pca = PCA(n_components=2).fit(X_scaled)
>>> contrib = pca.score_contributions(X_scaled, component=1)
>>> contrib.sum(axis=1) # equals pca.scores_[1]
>>> contrib.loc["33"].abs().sort_values() # what makes observation 33 extreme
References
----------
Miller, P., Swanson, R.E. and Heckler, C.E. (1994). "Contribution plots: a
missing link in multivariate quality control." Applied Mathematics and
Computer Science, 8(4), 775-792.
Westerhuis, J.A., Gurden, S.P. and Smilde, A.K. (2000). "Generalized
contribution plots in multivariate statistical process monitoring."
Chemometrics and Intelligent Laboratory Systems, 51(1), 95-114.
See Also
--------
group_contributions : The same decomposition for a group of observations, or
for the difference between two groups.
t2_contributions : Decomposes Hotelling's :math:`T^2`, which pools all
components rather than reading one at a time.
spe_contributions : The residual-space counterpart.
"""
_reject_score_vector_call(X, deprecated)
X_df, r_a = _score_contribution_terms(model, X, component)
X_values = X_df.to_numpy(dtype=float)
_, R, P = _contribution_inputs(model, X)
_scores, guides = _scores_and_guides(model, X_values, R, P, method, ridge)
contributions = X_values * (r_a if guides is None else guides[:, :, int(component) - 1])
# A missing cell's contribution is NaN, so both scalings skip NaN (as the row sums do). One block is the
# multi-block case with a single block, so the floors are the same in both (#1070).
scaled = _scale_block_contributions({"X": contributions}, scaling)["X"]
return pd.DataFrame(scaled, index=X_df.index, columns=X_df.columns)
[docs]
def group_contributions(
model: BaseEstimator,
X: DataMatrix,
group: Sequence | None = None,
reference: Sequence | None = None,
component: int = 1,
weights: Sequence | None = None,
) -> pd.Series:
r"""Per-variable contributions to a group's average score, or to a shift.
The group form of :func:`score_contributions`. Combining the data rows
before multiplying by the score-generating weights answers "what do these
observations have in common?" rather than "why is this one observation
unusual?", which is the question a cluster on a score plot, or a level shift
part-way through a data set, actually poses.
In general any linear combination of the rows may be used
(Miller, Swanson and Heckler, 1994):
.. math::
c_k = \Bigl(\sum_i w_i x_{ik}\Bigr) R_{ka}, \qquad
\sum_k c_k = \sum_i w_i t_{ia}.
The common cases have their own arguments. With ``group`` alone the weights
are :math:`1/n_G` over the group, comparing its mean against the model
centre. With ``group`` and ``reference`` they are :math:`+1/n_G` and
:math:`-1/n_H`, so the contributions sum to the difference in average score,
which is the level-shift diagnostic of the paper's Figure 9. Pass ``weights``
directly for anything else: the paper suggests the first-order orthogonal
polynomial when a run of batches is drifting rather than stepping.
A row with missing cells takes part with the contributions
:func:`score_contributions` gives it, from its observed cells alone. A
missing cell has no part in its row's score, so it adds nothing here,
and the contributions still sum to the weighted combination of the
scores, now the estimated ones for such a row.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`.
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
group : sequence, optional
Index labels of the observations of interest, or a boolean mask the
same length as ``X``. Selection is by label, not by position; pass
``X.index[...]`` to select positionally. Required unless ``weights``
is given.
reference : sequence, optional
Index labels selecting the observations to compare against. ``None``
(default) compares the group against the model centre.
component : int, default=1
**1-based** component index whose score is decomposed.
weights : sequence, optional
One weight per row of ``X``, giving the linear combination directly.
Mutually exclusive with ``group`` / ``reference``.
Returns
-------
pd.Series
Signed contributions, one per variable. Sums to the weighted combination
of the scores: the group's average score, the difference in average
score between the two groups, or :math:`\sum_i w_i t_{ia}`.
Examples
--------
>>> pca = PCA(n_components=2).fit(X_scaled)
>>> # Five batches that cluster together on the score plot:
>>> pca.group_contributions(X_scaled, group=[31, 142, 147, 220, 221])
>>> # What shifted at batch 74? (ten batches either side, by position)
>>> pca.group_contributions(
... X_scaled, group=X_scaled.index[64:74], reference=X_scaled.index[74:84]
... )
>>> # A run of batches drifting rather than stepping: weight by a
>>> # first-order orthogonal polynomial over the run.
>>> slope = np.zeros(len(X_scaled))
>>> slope[40:60] = np.arange(20) - 9.5
>>> pca.group_contributions(X_scaled, weights=slope, component=3)
See Also
--------
score_contributions : The single-observation form.
"""
# Each row's contributions, from its observed cells alone when it has a missing one, as
# score_contributions gives them; a missing cell is NaN there and adds nothing to its row's
# score, so it adds nothing to the combination either, rather than making its column NaN.
per_row = score_contributions(model, X, component=component)
if weights is not None:
if group is not None or reference is not None:
msg = "Pass either weights, or group/reference, not both."
raise ValueError(msg)
w = np.asarray(weights, dtype=float)
if w.ndim != 1 or w.size != len(per_row):
msg = f"weights must have one entry per row of X ({len(per_row)}), got shape {w.shape}."
raise ValueError(msg)
else:
if group is None:
msg = "Pass group (optionally with reference), or weights."
raise ValueError(msg)
w = _mean_weights(per_row, group, "group")
if reference is not None:
w -= _mean_weights(per_row, reference, "reference")
values = per_row.to_numpy()
return pd.Series(w @ np.where(np.isnan(values), 0.0, values), index=per_row.columns, name="group_contributions")
def _mean_weights(X_df: pd.DataFrame, selector: Sequence, name: str) -> np.ndarray:
"""Row weights that average the selected rows: ``1/n`` on each, 0 elsewhere."""
selected = _select_rows(X_df, selector, name)
if len(selected) == 0:
msg = f"{name} selected no observations from X."
raise ValueError(msg)
w = np.zeros(len(X_df))
w[X_df.index.get_indexer_for(selected.index)] = 1.0 / len(selected)
return w
def _select_rows(X_df: pd.DataFrame, selector: Sequence, name: str) -> pd.DataFrame:
"""Select rows of ``X_df`` by index label or by boolean mask.
Deliberately label-based only. Falling back to positional selection when a
label happens to be missing would make the meaning depend on the data: on a
frame indexed 1..54, ``[0, 1, 2]`` would be positions (0 is not a label)
while ``[10, 11, 12]`` would be labels, silently selecting a different set
of rows. Callers who want positions pass ``X.index[...]``.
"""
values = list(selector)
if values and all(isinstance(v, (bool, np.bool_)) for v in values):
if len(values) != len(X_df):
msg = f"{name} is a boolean mask of length {len(values)}, but X has {len(X_df)} rows."
raise ValueError(msg)
return X_df.loc[np.asarray(values)]
missing = [v for v in values if v not in X_df.index]
if missing:
msg = (
f"{name} contains entries that are not index labels of X: {missing}. "
"Selection is by index label or boolean mask; to select by position, "
"pass X.index[...] instead."
)
raise ValueError(msg)
return X_df.loc[values]
def _complete_through_model(
model: BaseEstimator, X: DataMatrix
) -> tuple[pd.DataFrame, np.ndarray, np.ndarray, np.ndarray]:
"""Align ``X`` to the fit and return ``(X_aligned, x, R, P)`` with every missing cell filled.
A missing cell is replaced by the model's reconstruction of it, from the scores that
the row's observed cells give by single-component projection, as the NIPALS fit scores
such a row, so the cross-products MEDA and oMEDA are built from are of complete data.
"""
X_df, R, P = _contribution_inputs(model, X)
x = X_df.to_numpy(dtype=float)
missing = np.isnan(x)
if missing.any():
scores, _ = _scores_and_guides(
model,
x,
R,
P,
method="scp", # pragma: no mutate - _validate_method lowercases it, so "SCP" is the same
ridge=0.0, # pragma: no mutate - SCP does not use the ridge, so 1.0 is the same
)
x = np.where(missing, scores @ P.T, x)
return X_df, x, R, P
def _meda_index(cross: np.ndarray, modelled: np.ndarray, scale: np.ndarray, *, signed: bool) -> np.ndarray:
"""Return ``(2 cross - modelled) * modelled / scale``, with ``|modelled|`` when signed, and 0 where scale is 0."""
weight = np.abs(modelled) if signed else modelled
# A variable with no sum of squares makes scale 0; a divisor of inf gives its entries 0, without a warning.
return (2.0 * cross - modelled) * weight / np.where(scale > 0, scale, np.inf)
def _seriation_order(matrix: np.ndarray) -> np.ndarray:
"""Order rows and columns so that strongly related variables sit next to each other.
Average-linkage clustering on the distance ``1 - |m|`` (symmetrised), with the
leaves put in the order that maximises the similarity of neighbours (Bar-Joseph,
Gifford and Jaakkola, 2001), so each block of related variables is contiguous.
Two variables have only one order, up to reversal.
"""
n = len(matrix)
if n < 3:
return np.arange(n)
similarity = np.abs((matrix + matrix.T) / 2.0)
distance = np.clip(1.0 - similarity, 0.0, None)
condensed = distance[np.triu_indices(n, 1)] # scipy's condensed form: the upper triangle, row by row
return leaves_list(optimal_leaf_ordering(linkage(condensed, method="average"), condensed))
[docs]
def meda(model: BaseEstimator, X: DataMatrix, *, signed: bool = True, seriate: bool = False) -> pd.DataFrame:
r"""Map how the fitted model relates every pair of variables (MEDA).
MEDA, missing-data methods for exploratory data analysis (Camacho, 2010), asks
for each pair of variables how well one is predicted from the other alone,
through the model. A correlation matrix answers that from all the directions in
the data; MEDA answers it from the model's components only. It therefore shows
the relationships the model represents, and adding a component shows which
relationships that component brings in.
Entry :math:`(j, k)` is the goodness of prediction of variable :math:`k` when
only variable :math:`j` is known and the scores are estimated from it by
regression. With :math:`S = X^\top X` and :math:`S_A = S R P^\top`, the part
of it the model reproduces (``R`` gives the scores, ``P`` the reconstruction:
both the loadings for PCA, the direct weights and X-loadings for PLS),
.. math::
q^2_{jk} = 1 - \frac{\lVert x_k - \hat{x}_{k \mid j}\rVert^2}{\lVert x_k \rVert^2}
= \frac{(2 S_{jk} - S_{A,jk})\, S_{A,jk}}{S_{jj} S_{kk}},
\qquad \hat{x}_{k \mid j} = x_j \frac{S_{A,jk}}{S_{jj}}.
The signed index, the default, replaces the last :math:`S_{A,jk}` by its
absolute value, as the MEDA Toolbox does (Camacho et al., 2015). The size is
unchanged where the model reproduces the relationship, and the sign then says
whether the two variables move together or in opposition. The diagonal is
:math:`r_k (2 - r_k)` for the fraction :math:`r_k` of variable :math:`k`'s sum
of squares that the model reproduces, which lies in :math:`[0, 1]`.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model. Every fitted component is used; to see the map
at fewer components, fit a model with fewer.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`. Usually the
training data itself. A missing cell is filled with the model's
reconstruction of it before the cross-products are formed.
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
signed : bool, default=True
Return the signed index. ``False`` returns the goodness of prediction
:math:`q^2_{jk}` itself.
seriate : bool, default=False
Reorder the rows and columns so that groups of related variables are
contiguous (hierarchical clustering on :math:`1 - |\text{MEDA}|` with
optimal leaf ordering). The labels travel with the values.
Returns
-------
pd.DataFrame
The K x K map, rows and columns labelled by variable. It is symmetric
for PCA, and close to symmetric for PLS.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.meda(X_scaled, seriate=True)
>>> pca.meda_plot(X_scaled)
See Also
--------
omeda : Which variables separate one group of observations from another.
References
----------
Camacho, J. (2010). Missing-data theory in the context of exploratory data
analysis. Chemometrics and Intelligent Laboratory Systems 103(1), 8-18.
Camacho, J., Perez-Villegas, A., Rodriguez-Gomez, R. A. and Jimenez-Manas, E.
(2015). Multivariate Exploratory Data Analysis (MEDA) Toolbox for Matlab.
Chemometrics and Intelligent Laboratory Systems 143, 49-57.
"""
X_df, x, R, P = _complete_through_model(model, X)
cross = x.T @ x
modelled = cross @ R @ P.T
sums_of_squares = np.diag(cross)
values = _meda_index(cross, modelled, np.outer(sums_of_squares, sums_of_squares), signed=signed)
names = X_df.columns
if seriate:
order = _seriation_order(values)
values, names = values[np.ix_(order, order)], names[order]
return pd.DataFrame(values, index=names, columns=names)
def _omeda_dummy(
X_df: pd.DataFrame, group: Sequence | None, reference: Sequence | None, weights: Sequence | None
) -> np.ndarray:
"""Build oMEDA's dummy: +1 on ``group`` and -1 on ``reference``, or ``weights``; each side peaks at 1 in size."""
if weights is not None:
if group is not None or reference is not None:
msg = "Pass either weights, or group/reference, not both."
raise ValueError(msg)
dummy = np.asarray(weights, dtype=float).copy()
if dummy.ndim != 1 or dummy.size != len(X_df):
msg = f"weights must have one entry per row of X ({len(X_df)}), got shape {dummy.shape}."
raise ValueError(msg)
# Each side is scaled so its largest weight is 1 in size (Camacho, 2011), as
# the +1 / -1 dummy of group and reference below already is.
positive, negative = dummy > 0, dummy < 0
if positive.any():
dummy[positive] /= dummy[positive].max()
if negative.any():
dummy[negative] /= -dummy[negative].min()
else:
if group is None:
msg = "Pass group (optionally with reference), or weights."
raise ValueError(msg)
dummy = np.zeros(len(X_df))
in_group = X_df.index.get_indexer_for(_select_rows(X_df, group, "group").index)
dummy[in_group] = 1.0
if reference is not None:
in_reference = X_df.index.get_indexer_for(_select_rows(X_df, reference, "reference").index)
if np.intersect1d(in_group, in_reference).size:
msg = "group and reference share observations; each observation belongs to one side."
raise ValueError(msg)
dummy[in_reference] = -1.0
if not np.any(dummy):
msg = "The comparison selects no observations: every weight is zero."
raise ValueError(msg)
return dummy
[docs]
def omeda(
model: BaseEstimator,
X: DataMatrix,
group: Sequence | None = None,
reference: Sequence | None = None,
*,
weights: Sequence | None = None,
) -> pd.Series:
r"""Find the variables that separate one group of observations from another (oMEDA).
Observation-based MEDA (Camacho, 2011) answers the question a pair of clusters
on a score plot poses: which variables put these observations here and those
there? It compares the groups only along the directions the model trusts, so a
variable that differs between them but that the model does not capture scores
low.
A dummy vector :math:`d` marks the comparison: :math:`+1` on ``group``,
:math:`-1` on ``reference`` and 0 elsewhere, or ``weights`` with each side
rescaled so its largest weight is 1 in size. With :math:`\hat{X} = X R P^\top`
the data reconstructed through the model,
.. math::
\text{oMEDA}_k = \frac{(2\, x_k^\top d - \hat{x}_k^\top d)\,
\lvert \hat{x}_k^\top d \rvert}{\sqrt{d^\top d}}.
This is the MEDA index with the dummy in place of a second variable. A
positive value means the variable is higher in ``group`` than in ``reference``
(or than the model centre, with no reference), a negative value that it is
lower. The size grows with the difference and with how well the model
reproduces it. The dummy sums the observations rather than averaging them, so
a comparison of unequal groups leans towards the larger one, as in the
original method.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model; every fitted component is used.
X : array-like of shape (n_samples, n_features)
The data the model's ``transform`` and ``diagnose`` take, in the units
it was fitted on. A model that centres and scales ``X`` itself
(``PLS(scale=True)``, the default) does so here first; ``PCA`` and
``PLS(scale=False)`` do not, and are given ``X`` scaled as their
training data was, for example with :class:`MCUVScaler`. A missing
cell is filled with the model's reconstruction of it.
Until #1125 the model's own scaling was skipped here: code that
scaled ``X`` by hand for a ``scale=True`` model now scales it twice,
and passes the unscaled data instead.
group : sequence, optional
Index labels of the observations of interest, or a boolean mask the same
length as ``X``. Selection is by label, not by position; pass
``X.index[...]`` to select positionally. Required unless ``weights`` is
given.
reference : sequence, optional
Index labels, or a boolean mask, selecting the observations to compare
against. ``None`` (default) compares the group against the model centre.
weights : sequence, optional
One weight per row of ``X``: a general dummy vector, for comparisons
other than two groups. Mutually exclusive with ``group`` / ``reference``.
Returns
-------
pd.Series
One signed value per variable, named ``"omeda"``.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> # The cluster at the top right of the score plot, against the rest:
>>> rest = X_scaled.index.difference(cluster)
>>> pca.omeda(X_scaled, group=cluster, reference=rest).sort_values()
>>> pca.omeda_plot(X_scaled, group=cluster, reference=rest)
See Also
--------
group_contributions : The contributions to one component's score shift.
meda : The map of how the model relates the variables to each other.
References
----------
Camacho, J. (2011). Observation-based missing data methods for exploratory data
analysis to unveil the connection between observations and variables in latent
subspace models. Journal of Chemometrics 25(11), 592-600.
"""
X_df, x, R, P = _complete_through_model(model, X)
dummy = _omeda_dummy(X_df, group, reference, weights)
observed = x.T @ dummy
modelled = (x @ R @ P.T).T @ dummy
values = (2.0 * observed - modelled) * np.abs(modelled) / np.sqrt(dummy @ dummy)
return pd.Series(values, index=X_df.columns, name="omeda")
[docs]
def eigenvalue_summary(model: BaseEstimator) -> pd.DataFrame:
"""Summarize the variance captured by each component as a tidy table.
Works with fitted :class:`PCA` and :class:`PLS` models. Returns one row per
component, collecting ``explained_variance_``, ``r2_per_component_`` and
``r2_cumulative_`` into a single table.
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
Returns
-------
pd.DataFrame
Indexed by component, with columns ``eigenvalue`` (the variance of the
component scores), ``percent_variance`` and ``cumulative_percent``. For
PCA the percentages refer to variance in X; for PLS they refer to the
variance in Y explained by each component.
Raises
------
ValueError
If the model is not fitted.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.eigenvalue_summary()
>>> eigenvalue_summary(pca)
"""
if not hasattr(model, "r2_per_component_"):
msg = "Model is not fitted. Call fit() before computing the eigenvalue summary."
raise ValueError(msg)
summary = pd.DataFrame(
{
"eigenvalue": model.explained_variance_,
"percent_variance": model.r2_per_component_.to_numpy() * 100.0,
"cumulative_percent": model.r2_cumulative_.to_numpy() * 100.0,
},
index=model.r2_per_component_.index,
)
summary.index.name = "component"
return summary
[docs]
def project_variables(model: BaseEstimator, supplementary_data: DataMatrix) -> pd.DataFrame:
"""Project supplementary (passive) variables onto a fitted model.
Works with fitted :class:`PCA` and :class:`PLS` models. Supplementary
variables are extra columns that did not take part in fitting the model but
were measured on the *same observations*. Each supplementary variable is
represented by its correlation with each component's scores, the standard
representation for passive quantitative variables. This is the column-wise
counterpart of ``transform``, which projects supplementary *rows* (new
observations).
Parameters
----------
model : PCA or PLS
A fitted PCA or PLS model.
supplementary_data : array-like of shape (n_samples, n_supplementary)
Passive variables measured on the same observations used to fit the
model. Must have the same number of rows as the training data.
Returns
-------
pd.DataFrame
Correlations of shape (n_supplementary, n_components): the coordinate
of each supplementary variable on each component.
Raises
------
ValueError
If the model is not fitted, or if *supplementary_data* does not have
the same number of rows as the training data.
Examples
--------
>>> pca = PCA(n_components=3).fit(X_scaled)
>>> pca.project_variables(passive_columns)
>>> project_variables(pca, passive_columns)
"""
if not hasattr(model, "scores_"):
msg = "Model is not fitted. Call fit() before projecting variables."
raise ValueError(msg)
scores = model.scores_
if not isinstance(supplementary_data, pd.DataFrame):
supplementary_data = pd.DataFrame(supplementary_data)
if supplementary_data.shape[0] != scores.shape[0]:
msg = (
f"Supplementary data must have {scores.shape[0]} rows (the number of "
f"observations used to fit the model), got {supplementary_data.shape[0]}."
)
raise ValueError(msg)
xs = supplementary_data.to_numpy(dtype=float)
t = scores.to_numpy()
xs_centered = xs - xs.mean(axis=0)
t_centered = t - t.mean(axis=0)
xs_norm = np.sqrt((xs_centered**2).sum(axis=0))
t_norm = np.sqrt((t_centered**2).sum(axis=0))
with np.errstate(invalid="ignore", divide="ignore"):
correlations = (xs_centered.T @ t_centered) / np.outer(xs_norm, t_norm)
correlations[~np.isfinite(correlations)] = 0.0
return pd.DataFrame(correlations, index=supplementary_data.columns, columns=scores.columns)
def _column_centred_array(data: DataMatrix, name: str) -> np.ndarray:
"""Coerce *data* to a 2-D float array with every column mean-centred."""
arr = np.asarray(data, dtype=float)
if arr.ndim == 1:
arr = arr[:, np.newaxis]
if arr.ndim != 2:
msg = f"{name} must be 1- or 2-dimensional, got {arr.ndim} dimensions."
raise ValueError(msg)
return np.asarray(center(arr))
def _matrix_correlation(X: DataMatrix, Y: DataMatrix, *, modified: bool = False) -> float:
"""Shared core of the RV and modified-RV (RV2) coefficients."""
x_centred = _column_centred_array(X, "X")
y_centred = _column_centred_array(Y, "Y")
if x_centred.shape[0] != y_centred.shape[0]:
msg = f"X and Y must have the same number of rows; got {x_centred.shape[0]} and {y_centred.shape[0]}."
raise ValueError(msg)
s_x = x_centred @ x_centred.T
s_y = y_centred @ y_centred.T
if modified:
np.fill_diagonal(s_x, 0.0)
np.fill_diagonal(s_y, 0.0)
denominator = np.sqrt(np.sum(s_x * s_x) * np.sum(s_y * s_y))
if denominator == 0.0:
return np.nan
return float(np.sum(s_x * s_y) / denominator)
[docs]
def rv_coefficient(X: DataMatrix, Y: DataMatrix) -> float:
"""Compute the RV coefficient between two data blocks.
The RV coefficient (Robert and Escoufier, 1976) measures how much common
structure two matrices, measured on the *same observations*, share. It is a
multivariate generalisation of the squared Pearson correlation: it compares
the observation-by-observation configuration matrices :math:`XX^T` and
:math:`YY^T` rather than individual variables.
Parameters
----------
X : array-like of shape (n_samples, n_features_x)
First data block.
Y : array-like of shape (n_samples, n_features_y)
Second data block. Must have the same number of rows as *X*; the
number of columns may differ.
Returns
-------
float
The RV coefficient in the range [0, 1]. A value of 1 means the two
blocks describe the same configuration of observations up to a
rotation and an overall scaling; 0 means no shared structure.
``nan`` is returned if either block has no variance.
Notes
-----
Each column is mean-centred internally, since the RV coefficient is defined
on centred data. The blocks are **not** scaled; scale the columns yourself
(for example with :class:`MCUVScaler`) when the variables have different
units.
For high-dimensional data (many more variables than observations) the RV
coefficient is biased upwards and tends towards 1 even for unrelated
blocks. Use :func:`rv2_coefficient` in that regime.
References
----------
Robert, P. and Escoufier, Y. (1976). A unifying tool for linear
multivariate statistical methods: the RV-coefficient. *Journal of the
Royal Statistical Society, Series C*, 25(3), 257-265.
See Also
--------
rv2_coefficient : Modified RV coefficient, unbiased for high-dimensional data.
Examples
--------
>>> rv_coefficient(X, Y)
>>> rv_coefficient(X, X) # 1.0: a block is perfectly correlated with itself
"""
return _matrix_correlation(X, Y)
[docs]
def rv2_coefficient(X: DataMatrix, Y: DataMatrix) -> float:
"""Compute the modified RV coefficient (RV2) between two data blocks.
The modified RV coefficient (Smilde et al., 2009) is a variant of
:func:`rv_coefficient` that removes the diagonals of the configuration
matrices :math:`XX^T` and :math:`YY^T` before comparing them. This removes
the upward bias that makes the ordinary RV coefficient tend towards 1 for
high-dimensional data, so RV2 stays near 0 for genuinely unrelated blocks.
Parameters
----------
X : array-like of shape (n_samples, n_features_x)
First data block.
Y : array-like of shape (n_samples, n_features_y)
Second data block. Must have the same number of rows as *X*; the
number of columns may differ.
Returns
-------
float
The modified RV coefficient, in the range [-1, 1]. A value of 1 means
the two blocks describe the same configuration of observations; values
near 0 mean no shared structure, and small negative values can occur.
``nan`` is returned if either block has no variance.
Notes
-----
Each column is mean-centred internally but the blocks are **not** scaled;
scale the columns yourself (for example with :class:`MCUVScaler`) when the
variables have different units.
References
----------
Smilde, A. K., Kiers, H. A. L., Bijlsma, S., Rubingh, C. M. and van Erk,
M. J. (2009). Matrix correlations for high-dimensional data: the modified
RV-coefficient. *Bioinformatics*, 25(3), 401-405.
See Also
--------
rv_coefficient : The original RV coefficient.
Examples
--------
>>> rv2_coefficient(X, Y)
"""
return _matrix_correlation(X, Y, modified=True)