# (c) Kevin Dunn, 2010-2026. MIT License. Based on own private work over the years.
"""Shared primitives for the multivariate package (ENG-01).
This leaf module holds the small, dependency-free building blocks used across
the PCA / PLS / TPLS / multiblock implementations: the :data:`DataMatrix` type
alias, the :data:`epsqrt` tolerance, the NIPALS denominator floor helper
:func:`_nz`, and :class:`SpecificationWarning`. Nothing here imports any of the
sibling submodules, so it sits at the base of the dependency graph.
"""
from __future__ import annotations
import functools
import inspect
import warnings
from collections.abc import Callable
from typing import Any, Literal, TypeAlias, cast
import numpy as np
import pandas as pd
from scipy import sparse
from sklearn.model_selection import KFold, RepeatedKFold, RepeatedStratifiedKFold, StratifiedKFold
from sklearn.utils import Bunch
from sklearn.utils.validation import check_array
from process_improve._warnings import user_stacklevel
# Names re-exported to the rest of the package. Declared explicitly so CodeQL
# does not flag the ``DataMatrix`` type alias (only ever referenced in lazy
# annotation strings under ``from __future__ import annotations``) as unused.
__all__ = [
"Q2_MIN_INCREMENT",
"DataMatrix",
"DiagnoseResult",
"NotEnoughVarianceError",
"SelectionRule",
"SpecificationWarning",
"UncentredDataWarning",
"check_response_block",
"components_with_spread",
"constant_columns",
"epsqrt",
"informative_widths",
"text_table",
"unobserved_columns",
"user_stacklevel",
"y_is_exhausted",
"y_reach",
]
#: Component-selection rules supported by ``PLS.select_n_components``.
#:
#: ``"1se"`` (the current default) applies the one-standard-error rule of
#: Breiman, Friedman, Olshen & Stone (1984, *CART* sec.3.4.3), endorsed by
#: Hastie, Tibshirani & Friedman (*The Elements of Statistical Learning*,
#: sec.7.10): pick the smallest component count whose mean cross-validated
#: error is within one standard error of the lowest. Needs per-fold errors,
#: so repeated K-fold is recommended.
#:
#: ``"min"`` returns the component count with the lowest cross-validated error.
#: On data sets where the validated error keeps drifting down by noise-level
#: amounts after the systematic components are exhausted, this routinely runs
#: to (or near) the maximum component count.
#:
#: ``"q2_increment"`` keeps adding components while each one raises the
#: cumulative cross-validated :math:`Q^2` by at least ``min_q2_increase``;
#: it is a Wold's-R-style heuristic with an absolute (not relative) threshold.
#:
#: ``"randomization"`` is Van der Voet's (1994, *Chemom. Intell. Lab. Syst.*
#: 25(2):313-323) randomization test: under the null that two PLS models have
#: the same predictive ability, randomly flip the sign of each observation's
#: paired squared-residual difference between a candidate model and the
#: argmin-RMSECV reference. The smallest model whose *p*-value exceeds
#: ``alpha`` (i.e. fails to reject the null) is chosen - statistically
#: indistinguishable from the best, but more parsimonious.
SelectionRule = Literal["1se", "min", "q2_increment", "randomization"]
#: Default minimum increase in the cross-validated :math:`Q^2` for an extra
#: component to be judged worth keeping under the ``"q2_increment"`` selection
#: rule. ``0.01`` means "must add at least one percentage point of
#: cross-validated explained variance".
Q2_MIN_INCREMENT = 0.01
DataMatrix: TypeAlias = np.ndarray | pd.DataFrame
epsqrt = np.sqrt(np.finfo(float).eps)
def components_with_spread(score_sd: np.ndarray) -> np.ndarray:
"""Return which components hold some of the data's spread: the one rule every Hotelling's T² uses (#1026, #1124).
A component whose training score standard deviation is at most ``epsqrt`` times the largest
holds none of it: the last component of a rank-deficient fit has an sd near 1e-16 times the
first, for instance. Its standardised score is noise over noise for a training row and
enormous for a new row off the model plane, so it adds 0 to T². It is also left out of every
matrix that a T² or a trimmed score regression inverts, which it would make singular.
The floor is relative to the largest sd in any units. It used to be ``epsqrt`` itself while
every sd was below 1, which took every component of data in small units for one with no
spread, and gave them a T² of 0 (#1070).
Parameters
----------
score_sd : np.ndarray of shape (n_components,)
The training score standard deviations (``scaling_factor_for_scores_``, or the square
root of ``explained_variance_``).
Returns
-------
np.ndarray of shape (n_components,), dtype bool
True for each component with spread.
"""
score_sd = np.asarray(score_sd, dtype=float)
# fmax skips a NaN sd, as max(1.0, ...) did: that component is left out, the others are not.
return score_sd > epsqrt * float(np.fmax.reduce(score_sd))
def column_sd(values: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Return the sample standard deviation (``ddof=1``) of each column, missing values skipped, in any units.
A square overflows beyond about 1e154 and underflows to 0 below about 1e-162, so a standard
deviation taken in the data's own units was ``inf`` for data near 1e160 and 0 for data near
1e-160 (#1070). Each column is divided first by the power of two just above its largest
absolute value, which brings it within (-1, 1), and the result multiplied back. Dividing by a
power of two is exact, so a column whose standard deviation did not overflow or underflow
gives the same one, bit for bit.
Parameters
----------
values : np.ndarray or pd.DataFrame of shape (n_samples, n_features)
The data, with NaN where a value is missing.
Returns
-------
np.ndarray of shape (n_features,)
The standard deviations, NaN for a column with fewer than two observed values. One beyond
the largest float is inf, with numpy's overflow warning, as ``numpy.nanstd`` gives it.
"""
values = np.asarray(values, dtype=float)
largest = np.fmax.reduce(np.abs(values), axis=0) # fmax skips NaN, and an all-NaN column, without a warning
_, exponent = np.frexp(np.where(np.isfinite(largest) & (largest > 0), largest, 1.0))
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning) # fewer than two observed values: NaN, which is the answer
spread = np.nanstd(np.ldexp(values, -exponent), axis=0, ddof=1)
# ldexp scales by the power of two without forming it: at 2**1023 and above that power is 2**1024, which
# overflowed to inf and gave the column a standard deviation of inf * 0, NaN (#1149).
return np.ldexp(spread, exponent)
#: Smallest positive float; used to floor NIPALS denominators away from zero.
_DENOM_FLOOR = float(np.finfo(float).tiny)
def _nz(denominator: float) -> float:
"""Floor a non-negative NIPALS denominator away from zero.
Sum-of-squares and vector-norm denominators (``v @ v``, ``norm(v)``) are
non-negative. When a score or loading vector collapses to (near) zero during
NIPALS - a fully-deflated component, or a degenerate / perfectly collinear
block - the denominator is ~0 and the division would yield ``inf``/``nan``
that silently poisons the fitted model. Flooring to the smallest positive
float leaves every well-conditioned value untouched (real denominators are
far larger) while turning the degenerate ``0/0`` into a finite (~0)
projection, since the numerator collapses with the same vector.
"""
return max(_DENOM_FLOOR, denominator)
def _own_axes(frame: pd.DataFrame) -> pd.DataFrame:
"""Return ``frame`` with copies of its row and column labels, and its values unchanged and not copied.
pandas passes an index on by reference: a frame built on the caller's ``X.index`` holds
that very object, so a name given to the caller's axis after the fit renamed the
model's frames too. A model takes its labels from here, or from ``Index.copy``, instead.
"""
owned = frame.copy(deep=False)
owned.index = frame.index.copy()
owned.columns = frame.columns.copy()
return owned
#: The ``missing_data_settings`` keys removed in 2.0, and the constructor parameter that
#: replaced each one. ``md_method`` is not here: it names the imputation algorithm, which
#: is the one axis ``missing_data_settings`` still owns.
_REMOVED_MD_KEYS: dict[str, str] = {"md_tol": "tol", "md_max_iter": "max_iter"}
#: The historical bounds on the NIPALS tolerance, kept verbatim from the per-estimator
#: checks this helper replaced. The ceiling is arbitrary but catches a caller who passed a
#: percentage; the floor is where a relative comparison stops being meaningful.
_TOL_CEILING = 10.0
def check_at_least_one(name: str, value: float | None) -> None:
"""Refuse a count that is not a whole number of at least 1, from ``fit`` (#1071).
``__init__`` stores its arguments unchanged, as sklearn requires, and ``set_params`` and
``clone`` do not pass through any check there, so ``fit`` is the one place every route to
a setting reaches. ``None`` passes: an estimator that accepts it reads it as "as many as
the data allow".
Parameters
----------
name : str
The parameter's name, used in the error message.
value : int or None
The value to check.
Raises
------
ValueError
If ``value`` is not a whole number (NaN included), or is below 1.
"""
check_whole(name, value)
if value is not None and value < 1:
raise ValueError(f"{name} must be >= 1; got {value}.")
def check_whole(name: str, value: float | None) -> None:
"""Refuse a count that is not a whole number; ``None`` passes (#1114).
A count of 2.5 was read as 2, without a word, or failed in a slice with a ``TypeError``.
A float with no fractional part is refused too, as scikit-learn's ``Integral`` constraint
refuses it in an estimator's settings.
Parameters
----------
name : str
The parameter's name, used in the error message.
value : int or None
The value to check.
Raises
------
ValueError
If ``value`` is not an integer, NaN included.
"""
if value is not None and not isinstance(value, (int, np.integer)):
raise ValueError(f"{name} must be a whole number; got {value}.")
class FoldsByClass(int):
"""A number of cross-validation folds, each class shuffled into them on its own: PLS-DA's integer ``cv`` (#1149).
PLS-DA runs PLS's cross-validation on its class-indicator matrix. A plain integer shuffles
the rows into folds whatever their class, so a training fold could hold no row of a class:
every column of its indicator matrix was then constant, and the fold's fit refused it, as
a ``nested_cv`` on twelve rows of two classes did for 7 seeds in 40. This integer is the
number of folds everywhere PLS reads one; where PLS shuffles the rows into folds,
:func:`_shuffled_folds` shuffles each class into them on its own, as
:class:`~sklearn.model_selection.StratifiedKFold` does, with the same seed.
"""
__slots__ = ()
def _shuffled_folds(
n_splits: int, X: pd.DataFrame, Y: pd.DataFrame, *, n_repeats: int = 1, random_state: int | None = None
) -> list[tuple[np.ndarray, np.ndarray]]:
"""Return the folds an integer ``cv`` stands for: the rows shuffled into ``n_splits`` folds, ``n_repeats`` times.
A :class:`FoldsByClass` shuffles each class into the folds on its own, the class of a row
being the column of the indicator matrix ``Y`` that marks it; any other count shuffles the
rows whatever their response.
Parameters
----------
n_splits : int
The number of folds.
X, Y : pd.DataFrame
The rows to split, and their response.
n_repeats : int, default=1
How many times the rows are shuffled into folds afresh.
random_state : int or None
The seed of the shuffle, as scikit-learn's splitters take it.
Returns
-------
list of (np.ndarray, np.ndarray)
The training and held-out positions of each fold, repeat by repeat.
"""
if isinstance(n_splits, FoldsByClass):
classes = np.argmax(np.asarray(Y), axis=1)
if n_repeats == 1:
return list(StratifiedKFold(int(n_splits), shuffle=True, random_state=random_state).split(X, classes))
repeated = RepeatedStratifiedKFold(n_splits=int(n_splits), n_repeats=n_repeats, random_state=random_state)
return list(repeated.split(X, classes))
if n_repeats == 1:
return list(KFold(int(n_splits), shuffle=True, random_state=random_state).split(X))
return list(RepeatedKFold(n_splits=int(n_splits), n_repeats=n_repeats, random_state=random_state).split(X))
def components_to_fit(
n_components: int | None, n_rows: int, n_columns: int, *, columns: str = "the number of columns"
) -> int:
"""Return how many components a fit computes: ``n_components``, cut to what the data can give (#505).
No more components than the smaller of ``n_rows`` and ``n_columns`` can be computed. PCA, PLS
and OPLS cut a larger count to it with this warning; the other members of the family went on
into a raw numpy error, a ``RuntimeWarning``, or components fitted to rounding noise (#1149).
``None`` asks for that many. The constructor parameter stays as the caller set it, as sklearn's
``clone`` needs: the count is the fitted ``n_components_``.
Parameters
----------
n_components : int or None
The count asked for.
n_rows, n_columns : int
The two sizes the count cannot exceed.
columns : str
What ``n_columns`` is, as the warning names it.
Returns
-------
int
The count to fit.
Raises
------
ValueError
If ``n_components`` is not a whole number of at least 1.
Warns
-----
SpecificationWarning
If ``n_components`` is more than the data can give.
"""
check_at_least_one("n_components", n_components)
most = int(min(n_rows, n_columns))
if n_components is None:
return most
if most < n_components:
warnings.warn(
"The requested number of components is more than can be computed from data. The maximum number of "
f"components is the minimum of either the number of rows ({n_rows}) or {columns} ({n_columns}).",
SpecificationWarning,
stacklevel=user_stacklevel(),
)
return most
return int(n_components)
def refuse_empty_block(block: DataMatrix, label: str) -> None:
"""Refuse a block with no columns, naming it (#1077).
scikit-learn's check of a DataFrame with no columns fails inside numpy, with "at least one
array or dtype is required", which names neither the block nor the cause. An array is read
the same way, so a single-block estimator can check its input before scikit-learn does (#1114).
Parameters
----------
block : pd.DataFrame or np.ndarray
The block to check.
label : str
Where the block sits, as ``'y'``, ``'X["name"]'`` or ``'Z["group"]'``.
Raises
------
ValueError
If ``block`` has no columns.
"""
if np.shape(block)[1:] == (0,): # 2-D with no columns; anything else is left to the checks that follow
raise ValueError(f"Block {label} has no columns; a block needs at least one.")
def resolve_loop_settings(
*,
tol: float,
max_iter: int,
missing_data_settings: dict | None,
estimator_name: str = "",
) -> dict:
"""Resolve one estimator's ``tol`` and ``max_iter`` into the mapping its NIPALS loop reads.
Every latent-variable estimator that iterates resolves its loop settings here, so the
rule is defined once instead of five times (#588). The constructor's ``tol`` and
``max_iter`` supply the values, and any other keys of a caller's
``missing_data_settings`` are added alongside them. Both are checked here, at fit
time, so a value set through ``set_params`` or ``clone`` is refused too (#1071).
The keys ``md_tol`` and ``md_max_iter``, which used to override ``tol`` and
``max_iter``, were removed in 2.0 and are refused. They described a convergence
setting as a missing-data setting, which it is not: the loop runs, and needs a
tolerance, whether or not a cell is missing. Other unknown keys pass through, but
these two raise, because ignoring a convergence setting a caller asked for would
change the fit without saying so.
Parameters
----------
tol : float
The estimator's convergence tolerance, relative to the norm of the score vector.
max_iter : int
The estimator's per-component iteration cap.
missing_data_settings : dict or None
The caller's dict, or None. Keys other than the removed two are passed through
untouched, so an estimator with its own keys (``md_method`` on ``PLS``) still sees
them.
estimator_name : str
Used in the error message, so the reader knows which class to change.
Returns
-------
dict
The loop settings under the internal keys ``md_tol`` and ``md_max_iter``, plus any
other keys the caller supplied.
Raises
------
ValueError
If ``missing_data_settings`` holds ``md_tol`` or ``md_max_iter``, if ``max_iter``
is below 1, or if the tolerance is out of bounds.
"""
settings: dict = {"md_tol": tol, "md_max_iter": max_iter}
if isinstance(missing_data_settings, dict):
removed = [key for key in _REMOVED_MD_KEYS if key in missing_data_settings]
if removed:
where = f"{estimator_name}(missing_data_settings=...)" if estimator_name else "missing_data_settings"
keys = " and ".join(repr(key) for key in removed)
replacements = ", ".join(f"{_REMOVED_MD_KEYS[key]}={missing_data_settings[key]!r}" for key in removed)
raise ValueError(
f"{where} no longer takes {keys}, removed in 2.0. Pass {replacements} to the constructor instead."
)
settings.update(missing_data_settings)
check_at_least_one("max_iter", max_iter)
settings["md_max_iter"] = int(settings["md_max_iter"])
if not settings["md_tol"] < _TOL_CEILING:
raise ValueError(f"Tolerance should not be too large; got {settings['md_tol']}.")
if not settings["md_tol"] > epsqrt**1.95:
raise ValueError(f"Tolerance must exceed machine precision; got {settings['md_tol']}.")
return settings
def _reject_sparse(X: object, estimator_name: str) -> None:
"""Raise if ``X`` is a SciPy sparse matrix, naming the remedy that actually helps.
NIPALS centres and scales every column, which destroys sparsity, so there is nothing
to gain from a sparse representation here and every estimator in this package sets
``accept_sparse=False``. sklearn's own message for that says to call ``.toarray()``,
which is the expensive way round when the sparsity came from a one-hot block: the
dense array is materialised anyway, but only after the sparse one has been built.
The usual source is a :class:`~sklearn.compose.ColumnTransformer` whose one-hot block
pushed the result over ``sparse_threshold`` (default 0.3), which flips the *whole*
concatenated output to sparse. Two knobs at that level avoid the round trip
altogether, and the message names both (#399).
Parameters
----------
X : object
The candidate input. Anything SciPy does not consider sparse passes through.
estimator_name : str
Named in the message, so a Pipeline failure says which step rejected the input.
Raises
------
TypeError
If ``X`` is sparse.
"""
if not sparse.issparse(X):
return
msg = (
f"{estimator_name} does not accept sparse input: NIPALS centres and scales every "
"column, which destroys sparsity, so there is nothing to gain from it. This usually "
"arrives from a ColumnTransformer whose one-hot block pushed the concatenated output "
"over `sparse_threshold` (default 0.3). Pass `sparse_threshold=0` to the "
"ColumnTransformer, or `OneHotEncoder(sparse_output=False)`, so it hands over a dense "
"array directly. `X.toarray()` also works, but on a wide one-hot block it builds the "
"sparse matrix first and then the dense one anyway."
)
raise TypeError(msg)
[docs]
class SpecificationWarning(UserWarning):
"""Parent warning class."""
class DiagnoseResult(Bunch):
"""The :class:`~sklearn.utils.Bunch` a ``diagnose`` returns; reading its ``hotellings_t2`` warns (#983).
``cumulative_hotellings_t2`` holds Hotelling's T² in one shape from every estimator: a
DataFrame with a row per diagnosed row and a column per component, column ``a`` holding the
T² over the first ``a`` components, as the fitted T² attribute does. ``hotellings_t2`` was
that DataFrame from PCA and TPLS, and its last column, a Series, from PLS, MBPLS and MBPCA.
It still holds what it did, until 3.0. Reading it by name, as an attribute, with ``[]`` or
with ``get``, gives a :class:`DeprecationWarning`; reading the whole result, as ``dict(result)``
or ``result.items()`` do, does not.
Parameters
----------
estimator : str
The estimator's name under ``process_improve``, such as ``"multivariate.PLS"``, for the
warning.
**fields
The fields of the result.
"""
def __init__(self, estimator: str, **fields: Any) -> None: # noqa: ANN401 - a field of any type
super().__init__(**fields)
# Not a field: Bunch would store an attribute as a key.
self.__dict__["_estimator"] = estimator
def _warn_if_deprecated(self, key: object) -> None:
if key == "hotellings_t2":
# An unpickled result has no _estimator: Bunch.__setstate__ restores no attribute.
estimator = self.__dict__.get("_estimator", "multivariate model")
warnings.warn(
f"process_improve.{estimator}.diagnose(...).hotellings_t2 is deprecated since 2.4.0 and will be "
"removed in 3.0; use .cumulative_hotellings_t2 instead, which holds Hotelling's T² in the same "
"shape from every estimator: a column per component, cumulative, as the fitted T² attribute. "
"Its last column, .cumulative_hotellings_t2.iloc[:, -1], is the T² of the full model.",
category=DeprecationWarning,
stacklevel=user_stacklevel(),
)
def __getitem__(self, key: object) -> Any: # noqa: ANN401 - a field of any type
self._warn_if_deprecated(key)
return super().__getitem__(key)
def get(self, key: object, default: Any = None) -> Any: # noqa: ANN401 - as dict.get
self._warn_if_deprecated(key)
return super().get(key, default)
[docs]
class UncentredDataWarning(SpecificationWarning):
"""Emitted when a model that fits no intercept is handed an un-centred block.
Raised by :meth:`PLS.fit <process_improve.multivariate.PLS.fit>` under
``scale=False``, which centres nothing and fits no intercept, so a block
carrying a non-zero mean displaces every prediction.
It exists as its own class so a caller who fits un-centred data on purpose
can permit *this* diagnostic without going blind to the rest. Under a
``filterwarnings = error`` policy::
# pytest.ini / pyproject.toml, or @pytest.mark.filterwarnings on one test
filterwarnings =
error
ignore::process_improve.multivariate.UncentredDataWarning
Narrower still, and with no global filter at all, is the estimator flag:
``PLS(..., scale=False, warn_on_uncentred=False)`` silences the check for
that one model and leaves every other ``SpecificationWarning`` in force.
Subclasses :class:`SpecificationWarning`, so filters and ``pytest.warns``
assertions written against the parent keep matching it.
"""
class NotEnoughVarianceError(RuntimeError, ValueError):
"""Raised when more components are requested than the data's variance supports.
During NIPALS extraction (PCA / PLS) the deflated data array can run out of
variance before the requested number of components is reached, even when that
count is within ``min(N, K)`` - for example with rank-deficient or perfectly
collinear data. The model cannot compute any further components in that case.
Subclasses :class:`RuntimeError` so existing ``except RuntimeError`` handlers
keep working, while callers that want to react specifically (e.g. trimming the
component count during cross-validation) can catch this narrower type. It is a
:class:`ValueError` as well, because it replaced the ``ValueError`` TPLS raised for
data with no variance and the ``LinAlgError`` (a ``ValueError``) PLS and OPLS
raised for a Y with no covariance with X: an ``except ValueError`` written for
those still catches it.
"""
def check_variance_left(block: str, values: np.ndarray, extracted: int, size: float) -> None:
"""Refuse component ``extracted + 1`` when ``block`` has no variance left, relative to its size.
``size`` is the block's norm before the first component. The floor is relative to it, as OPLS
judges it, so the same data in smaller units are not taken for none (#1070). PLS judges X and Y
this way before every component, and MBPLS judges Y (#1128), so a Y that is fitted exactly is
refused by both, in the same words. Missing cells are left out of the sum.
Raises
------
NotEnoughVarianceError
If ``block`` has no variance left.
"""
if np.sqrt(np.nansum(values**2)) <= epsqrt * size:
raise NotEnoughVarianceError(
f"There is no variance left in the data array for {block}: cannot compute any more components "
f"beyond component {extracted}."
)
def _align_to_fit_features(X: pd.DataFrame, fit_feature_names: pd.Index) -> pd.DataFrame:
"""Validate and align new-data columns against the features seen during ``fit``.
PCA / PLS only checked that data passed to ``transform`` / ``predict`` had the
right *number* of columns. A correctly-shaped frame whose columns are renamed
or reordered would otherwise be projected positionally (PCA, ``X.values @ P``)
or silently label-aligned to all-``NaN`` (PLS, ``X @ direct_weights_``),
producing wrong scores with no error raised (issue #195). This helper makes
that consistency explicit, mirroring scikit-learn's ``feature_names_in_``
handling:
* If both the training data and ``X`` carry string feature names, the *set*
of names must match (otherwise :class:`ValueError`); columns supplied in a
different order are reordered to the training order.
* If the training data had names but ``X`` does not (e.g. a bare ndarray was
passed), the columns are taken to correspond positionally and are labelled
with the training names, so downstream label-aligned arithmetic stays
correct rather than collapsing to ``NaN``.
* If the training data itself had no (string) feature names, there is nothing
to validate and ``X`` is returned unchanged.
The caller is expected to have already validated the column *count*.
Parameters
----------
X : pd.DataFrame
New data passed to ``transform`` / ``predict`` (already coerced to a
DataFrame and count-checked by the caller).
fit_feature_names : pd.Index
The ``X.columns`` captured during ``fit`` (``self._feature_names``).
Returns
-------
pd.DataFrame
``X`` with columns aligned to the training feature order.
Raises
------
ValueError
If both sides carry string names but the sets of names differ.
"""
fit_names = list(fit_feature_names)
if not (fit_names and all(isinstance(name, str) for name in fit_names)):
# Training data had no string feature names (e.g. fitted from an ndarray);
# only the column count is meaningful, which the caller already checked.
return X
new_names = list(X.columns)
if not all(isinstance(name, str) for name in new_names):
# New data carries default positional columns (e.g. came in as an
# ndarray). Assume positional correspondence and label it with the
# training names so label-aligned operations behave correctly.
X = X.copy()
X.columns = pd.Index(fit_names)
return X
if set(new_names) != set(fit_names):
missing = [name for name in fit_names if name not in set(new_names)]
unexpected = [name for name in new_names if name not in set(fit_names)]
raise ValueError(
"Feature names of the data passed to predict/transform do not match "
"those seen during fit. "
f"Missing columns: {missing}; unexpected columns: {unexpected}."
)
if new_names != fit_names:
# Same names, different order: reorder to the training order so the
# positional projection lines up.
X = X[fit_names]
return X
def _float_frame(data: DataMatrix) -> pd.DataFrame:
"""Return ``data`` as a DataFrame of floats, a pandas missing value (pd.NA) as NaN, as ``fit`` reads it (#1144).
An array's rows and columns are numbered from 0, and a Series keeps its name, as
``pd.DataFrame`` gives them. A frame of floats is returned as it is: a copy can change
its memory layout, and with it the order sums run in.
"""
frame = pd.DataFrame(data)
return frame if (frame.dtypes == np.float64).all() else frame.astype(float)
def _component_curve(values: np.ndarray | pd.Series | list[float], name: str) -> np.ndarray:
"""Return a per-component curve as a float array whose entry ``i`` belongs to ``i + 1`` components.
Every selection rule below returns a position plus one, which is a component count
only when the curve starts at one component and skips none. An array or list cannot
say otherwise, so it is taken as that; a count that was not evaluated keeps its place
as NaN. A Series can say otherwise, through its index, and is refused unless the
index is ``1..A``: dropping it would turn a curve indexed from 2 into an answer one
count too small, with no sign of it (#513).
Raises
------
ValueError
If ``values`` is a Series whose index is not ``1..A``.
"""
if isinstance(values, pd.Series) and not values.index.equals(pd.RangeIndex(1, len(values) + 1)):
msg = f"{name} must be indexed by component count 1..{len(values)}; got {values.index.tolist()}."
raise ValueError(msg)
return np.asarray(values, dtype=float)
def _recommend_n_components_q2(
q2_cumulative: np.ndarray | pd.Series | list[float],
*,
min_increment: float = Q2_MIN_INCREMENT,
) -> int:
r"""Recommend a component count from a cumulative cross-validated :math:`Q^2` curve.
Walks outward from one component, keeping component ``a`` only if it lifts
the cumulative :math:`Q^2` by at least ``min_increment`` (with an implied
:math:`Q^2` of ``0`` before any component). The first component that fails
that test - a plateau, a drop, or a non-finite entry - stops the search,
and the recommendation is the last component that passed (never fewer than
one, since a model needs at least one component).
This is a Wold's-R-style heuristic: parsimonious and cheap, but the
threshold is absolute and hand-tuned. Prefer the 1-SE rule
(:func:`_recommend_n_components_one_se`) when per-fold errors are available.
Entry ``i`` of the curve must belong to ``i + 1`` components; see
:func:`_component_curve`.
"""
q2 = _component_curve(q2_cumulative, "q2_cumulative")
recommended = 1
previous = 0.0
for a, value in enumerate(q2, start=1):
if not np.isfinite(value) or (value - previous) < min_increment:
break
recommended = a
previous = float(value)
return recommended
def _recommend_n_components_one_se(
mean_error: np.ndarray | pd.Series | list[float],
se_error: np.ndarray | pd.Series | list[float],
) -> int:
r"""Recommend a component count by the one-standard-error rule.
Given the mean cross-validated error per component count (``mean_error``,
e.g. RMSECV across folds and repeats) and its standard error
(``se_error``), find ``a* = nanargmin(mean_error)`` and return the
*smallest* component count ``a`` whose ``mean_error[a]`` is no larger than
``mean_error[a*] + se_error[a*]``.
The 1-SE rule (Breiman, Friedman, Olshen & Stone 1984, *CART* sec.3.4.3;
Hastie, Tibshirani & Friedman, *ESL* sec.7.10) trades a tiny amount of
fit for a more parsimonious model whose error is statistically
indistinguishable from the best. It is the cheapest robustness upgrade for
integer hyperparameters like the number of latent variables.
Non-finite entries in ``mean_error`` are skipped. The selection always
returns at least ``1``. If ``se_error`` at the minimum is non-finite, zero
or negative, the rule degenerates to the argmin. Entry ``i`` of each curve
must belong to ``i + 1`` components; see :func:`_component_curve`.
"""
mean = _component_curve(mean_error, "mean_error")
se = _component_curve(se_error, "se_error")
if mean.shape != se.shape:
raise ValueError(f"mean_error and se_error must have the same shape; got {mean.shape} and {se.shape}.")
if mean.ndim != 1:
raise ValueError("mean_error must be 1-D.")
finite = np.isfinite(mean)
if not finite.any():
# Caller should already have raised before now; fall through to 1 so
# this helper never returns a sentinel.
return 1
masked = np.where(finite, mean, np.inf)
best = int(np.argmin(masked))
# Floored at zero, so the minimum always meets its own threshold and the search
# below always finds a count.
se_best = float(se[best]) if np.isfinite(se[best]) and se[best] > 0 else 0.0
return int(np.flatnonzero(masked <= masked[best] + se_best)[0]) + 1
def _select_n_components(
rule: SelectionRule,
*,
mean_error: np.ndarray | pd.Series | list[float],
se_error: np.ndarray | pd.Series | list[float] | None = None,
q2_cumulative: np.ndarray | pd.Series | list[float] | None = None,
min_q2_increase: float = Q2_MIN_INCREMENT,
) -> int:
"""Dispatch component selection to one of the supported rules.
See :data:`SelectionRule` for the rule semantics. Raises ``ValueError`` if
a rule is requested without the data it needs (``"1se"`` needs
``se_error``; ``"q2_increment"`` needs ``q2_cumulative``), or if a curve it
reads is a Series not indexed ``1..A`` (see :func:`_component_curve`).
"""
if rule == "min":
mean = _component_curve(mean_error, "mean_error")
if not np.isfinite(mean).any():
return 1
return int(np.nanargmin(mean)) + 1
if rule == "1se":
if se_error is None:
raise ValueError("selection_rule='1se' requires per-component standard errors.")
return _recommend_n_components_one_se(mean_error, se_error)
if rule == "q2_increment":
if q2_cumulative is None:
raise ValueError("selection_rule='q2_increment' requires a cumulative Q^2 curve.")
return _recommend_n_components_q2(q2_cumulative, min_increment=min_q2_increase)
raise ValueError(
f"Unknown selection_rule {rule!r}; expected one of "
f"'1se', 'min', 'q2_increment', 'randomization' "
f"('randomization' is handled by PLS.select_n_components, not this dispatcher)."
)
def _vandervoet_randomization(
per_obs_sse: np.ndarray,
*,
total_rmsecv: np.ndarray,
n_permutations: int = 999,
alpha: float = 0.01,
random_state: int | np.random.Generator | None = None,
) -> tuple[int, np.ndarray]:
"""Van der Voet (1994) randomization test for PLS component selection.
Compares every candidate model against the reference (argmin-RMSECV)
model under the null that the two have the same predictive ability.
For each observation the paired difference of squared residuals
``D_i = sse[a, i] - sse[a*, i]`` is computed; under the null its sign
is random, so the permutation distribution of ``T = sum_i D_i`` is
obtained by flipping each ``D_i``'s sign with probability 1/2 over
``n_permutations`` draws. The *p*-value is the right-tail probability
of seeing a sum as large as the observed one (``T_obs >= T_perm``);
the recommendation is the smallest ``a`` whose ``p > alpha`` -
statistically indistinguishable from the reference, but more
parsimonious.
Parameters
----------
per_obs_sse : np.ndarray of shape (n_components, n_samples)
Out-of-fold per-observation squared total residual at every
component count, summed across Y columns. Rows that are NaN
(observation never held out) are dropped.
total_rmsecv : np.ndarray of shape (n_components,)
Pooled total RMSECV per component count; used to pick the
reference model ``a*`` = ``nanargmin(total_rmsecv) + 1``.
n_permutations : int, default 999
Number of sign-flip permutations.
alpha : float, default 0.01
Significance level. Smaller values are more parsimonious.
random_state : int, numpy.random.Generator or None, optional
Seed or generator for reproducible permutations.
Returns
-------
recommended : int
Smallest 1-based component count with ``p > alpha``.
p_values : np.ndarray of shape (n_components,)
Right-tail *p*-value per candidate; the reference model gets
``1.0`` by construction (paired differences are all zero).
References
----------
Van der Voet, H. (1994). Comparing the predictive accuracy of
models using a simple randomization test. *Chemom. Intell. Lab.
Syst.*, 25(2), 313-323.
"""
a_count = per_obs_sse.shape[0]
a_ref = int(np.nanargmin(total_rmsecv))
rng = np.random.default_rng(random_state)
p_values = np.zeros(a_count)
p_values[a_ref] = 1.0
sse_ref = per_obs_sse[a_ref]
for a in range(a_count):
if a == a_ref:
continue
d = per_obs_sse[a] - sse_ref
# Drop observations with NaN (a custom splitter may have left some
# rows unheld), since the paired difference is undefined there.
d = d[np.isfinite(d)]
if d.size == 0:
p_values[a] = 1.0
continue
t_obs = float(d.sum())
signs = rng.choice([-1.0, 1.0], size=(n_permutations, d.size))
t_perm = (signs * d).sum(axis=1)
# Right-tail probability under the null. Add 1 to both numerator
# and denominator (the "permutation test +1" correction) so the
# p-value is strictly positive even at the extreme.
p_values[a] = float((np.sum(t_perm >= t_obs) + 1) / (n_permutations + 1))
recommended = a_ref + 1 # fall back to the reference if nothing qualifies
for a in range(a_count):
if p_values[a] > alpha:
recommended = a + 1
break
return recommended, p_values
def _equal_weight_r2_total(per_target: np.ndarray) -> np.ndarray:
r"""Pool per-target validated :math:`R^2_Y` with every target weighted equally.
The ``"total"`` column of a validated-:math:`R^2_Y` table is
``1 - sum_m PRESS_m / sum_m TSS_m`` on the *original* Y scale, so a target
whose spread is two orders of magnitude larger than its neighbours' decides
the number almost on its own. Autoscaling Y first fixes that, and costs
nothing to compute here: after mean-centring and unit-variance scaling every
target's TSS is the same, so the pooled ratio collapses to the arithmetic
mean of the per-target values,
.. math::
1 - \frac{\sum_m \mathrm{PRESS}_m / s_m^2}{\sum_m \mathrm{TSS}_m / s_m^2}
= \frac{1}{M} \sum_m \left(1 - \frac{\mathrm{PRESS}_m}{\mathrm{TSS}_m}\right).
Targets with no spread contribute ``NaN`` per-target values and are left out
of the mean rather than dragging it to ``NaN``.
Parameters
----------
per_target : np.ndarray
Validated :math:`R^2` per target, shape ``(A, M)``.
Returns
-------
np.ndarray
The equal-weight pooled value per component count, shape ``(A,)``.
"""
values = np.asarray(per_target, dtype=float)
if values.ndim != 2:
raise ValueError(f"per_target must be 2-D (A x M); got shape {values.shape}.")
with warnings.catch_warnings():
# An all-NaN row (every target constant) means "nothing to pool"; NaN
# is the right answer and the RuntimeWarning that says so is noise.
warnings.simplefilter("ignore", RuntimeWarning)
return np.nanmean(values, axis=1) if values.shape[1] else np.full(values.shape[0], np.nan)
def _model_method(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a module-level ``fn(model, ...)`` as an introspectable instance method.
ENG-05: estimators (PCA / PLS / ...) expose convenience methods such as
``score_plot``, ``spe_limit`` and ``vip`` that forward to the standalone
functions with ``self`` supplied as the ``model`` argument. Defining them
via this factory at class-body time - rather than binding
``functools.partial`` instances in ``fit`` - means ``help`` and
``inspect.signature`` report the underlying function (minus ``self``), the
fitted model stays picklable, and subclasses can override cleanly.
"""
@functools.wraps(fn)
def method(self: object, *args, **kwargs) -> object:
return fn(self, *args, **kwargs)
return method
def _scale_block_contributions(blocks: dict[str, np.ndarray], scaling: str) -> dict[str, np.ndarray]:
"""Apply a contribution-plot scaling jointly across every block.
The scalings are those of Miller, Swanson and Heckler (1994). Both are taken
over the blocks together, not one block at a time: a super score pools all
the blocks, so scaling each block separately would make bars from different
blocks incomparable, which is the one thing a multi-block contribution plot
is read for.
"""
if scaling == "none":
return blocks
# A missing cell's contribution is NaN, so both scalings skip NaN: NaN in the max or a
# row's sum left the bars unscaled. A largest of 0, or a row whose total is at most epsqrt
# times the largest row's, has nothing to scale and is left as it is. Both floors are relative,
# so bars in small units are scaled as in units of 1; an absolute epsqrt left them unscaled (#1070).
if scaling == "maximum":
largest = max((float(np.nanmax(np.abs(v))) for v in blocks.values() if np.isfinite(v).any()), default=0.0)
scalar = largest if largest > 0 else 1.0
return {name: values / scalar for name, values in blocks.items()}
if scaling == "within":
totals = sum(np.nansum(np.abs(values), axis=1) for values in blocks.values())
per_row = np.where(totals > epsqrt * np.max(totals, initial=0.0), totals, 1.0).reshape(-1, 1)
return {name: values / per_row for name, values in blocks.items()}
msg = f"scaling must be one of 'none', 'maximum' or 'within', got {scaling!r}."
raise ValueError(msg)
def largest_ssq_column(values: np.ndarray) -> np.ndarray:
"""Return a copy of the column of ``values`` with the largest sum of squares, as an ``(N, 1)`` array.
NIPALS seeds its iteration from this column (#195). Any non-degenerate seed converges to
the same component, but the first column can be constant (all zero after preprocessing),
and then the iteration has nothing to follow. The highest-variance column also needs
fewer iterations. Missing cells are skipped in the sum and set to 0 in the returned column.
Parameters
----------
values : np.ndarray
An ``(N, K)`` array, possibly with NaN.
Returns
-------
np.ndarray
The chosen column, ``(N, 1)``, float, NaN replaced by 0.
"""
ssq = np.nansum(values**2, axis=0)
# Autoscaled columns all have the same sum of squares up to round-off, so a plain argmax picks
# whichever is one ulp larger on this platform. Take the first column within round-off of the
# largest instead: the start, and so the iteration count, is then the same everywhere.
start_col = int(np.flatnonzero(ssq >= ssq.max() * (1 - 1e-9))[0])
return np.nan_to_num(values[:, [start_col]].astype(float))
def _cross_size(x_blocks: list[np.ndarray], y: np.ndarray) -> float:
"""Return ``sqrt(sum_b ||X_b' Y||^2)``, missing cells counted as zero: how much of Y the blocks can still reach."""
y0 = np.nan_to_num(y)
return float(np.sqrt(sum(np.sum((np.nan_to_num(x).T @ y0) ** 2) for x in x_blocks)))
def y_reach(x_blocks: list[np.ndarray], y: np.ndarray) -> tuple[float, float]:
"""Return what :func:`y_is_exhausted` compares against: ``||X'Y||`` and Y's sum of squares, before any component."""
return _cross_size(x_blocks, y), float(np.nansum(y**2))
def check_x_explains_y(x_blocks: list[np.ndarray], y: np.ndarray) -> None:
"""Refuse a fit in which Y is uncorrelated with every column of X: it has no first component (#1144).
``X'Y`` is judged against the largest it can be, ``||X|| ||Y||``, so the data's units do not
matter. With ``X'Y`` about zero, no direction of X covaries with Y, and the first weight is 0 / 0:
PLS and OPLS raised a ``LinAlgError`` that blamed collinear columns, and MBPLS fitted every weight
0 and predicted NaN. A component after the first is fitted along X instead
(:func:`y_is_exhausted`), since the ones before it carry the model. A block with no sum of squares
at all is left to the variance checks, which name it. Missing cells count as zero.
Raises
------
NotEnoughVarianceError
If Y is uncorrelated with every column of X.
"""
x_size = np.sqrt(sum(float(np.nansum(x**2)) for x in x_blocks))
if _cross_size(x_blocks, y) < epsqrt * x_size * np.sqrt(float(np.nansum(y**2))):
raise NotEnoughVarianceError("There is nothing in X to explain Y by: Y is uncorrelated with every column of X.")
def y_is_exhausted(
estimator: str, a: int, x_blocks: list[np.ndarray], y: np.ndarray, initial: tuple[float, float]
) -> bool:
"""Return whether component ``a + 1`` has nothing left in Y to explain, warning when it has (#913).
When what is left of Y is uncorrelated with what is left of X (``X'Y`` about zero
after deflation), the next component has no direction to find: NIPALS then changes
sign at every iteration and reaches ``max_iter``, and its warning points at the
iteration settings rather than at the number of components. This happens in designed
experiments, where one component of orthogonal coded factors already gives the
least-squares fit. ``X'Y`` is judged against its size before the first component.
The caller then fits that component, and every later one, along the largest
direction left in X (NIPALS with X as its own target, which converges to the leading
principal component of the deflated X). Its Y-loading is 0 (#1074), so the
predictions, R²Y and a cross-validated PRESS stay as they were at ``a`` components,
every fitted frame keeps the number of components asked for, and ``P'W`` stays
invertible. The caller asks once: deflating X along a direction of X keeps ``X'Y``
about zero.
Parameters
----------
estimator : str
Name used in the warning.
a : int
Number of components fitted so far.
x_blocks : list[np.ndarray]
The deflated X (one array, or one per block).
y : np.ndarray
The deflated Y.
initial : tuple[float, float]
:func:`y_reach` of the data before the first component.
Returns
-------
bool
True when the fit should stop at ``a`` components.
Raises
------
NotEnoughVarianceError
Before the first component, if Y is uncorrelated with every column of X
(:func:`check_x_explains_y`): there is then no component for the later ones to follow.
"""
initial_cross, initial_ssy = initial
if a == 0:
check_x_explains_y(x_blocks, y)
return False
if _cross_size(x_blocks, y) > epsqrt * initial_cross:
return False
r2y = 1.0 - float(np.nansum(y**2)) / initial_ssy if initial_ssy > 0 else 0.0
warnings.warn(
f"{estimator}: from component {a + 1} on there is nothing left in Y that X can explain: what is left of Y "
f"is uncorrelated with what is left of X (R²Y stays at {r2y:.4g}). The data support {a} component(s); "
"each later one takes the largest direction left in X and leaves the predictions unchanged.",
SpecificationWarning,
stacklevel=user_stacklevel(),
)
return True
def informative_widths(blocks: dict[str, np.ndarray]) -> dict[str, int]:
"""Return, per preprocessed block, the number of columns whose values vary, at least 1 (#913).
A multiblock model divides each block by the square root of its width, so that blocks
of unequal width carry equal weight. A constant column carries no information: centring
and scaling turn it into a column of zeros. Counted in the width, it lowered the
weight of its block, and the fit changed although the data had not. A column with
fewer than two observed values is constant too.
Parameters
----------
blocks : dict[str, np.ndarray]
Each block after centring and scaling, ``(n_samples, K_b)``, NaN where missing.
Returns
-------
dict[str, int]
The count of varying columns per block, at least 1 so a block of constants
divides by 1.
"""
out: dict[str, int] = {}
for name, values in blocks.items():
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning) # a column with no observed value has no spread
spread = np.nanstd(values, axis=0)
out[name] = max(1, int(np.count_nonzero(spread > epsqrt)))
return out
def text_table(header: list[str], rows: list[list[str]]) -> list[str]:
"""Lay out a table as lines of text: each column right-aligned to its widest cell, header included (#1083).
Parameters
----------
header : list[str]
The column headers.
rows : list[list[str]]
The formatted cells of each row, one per column.
Returns
-------
list[str]
The header, a rule of dashes as long as it, then one line per row, each indented by two
spaces, with the cells separated by ``" | "``.
"""
widths = [max(len(cell) for cell in column) for column in zip(header, *rows, strict=True)]
lines = [
" | ".join(cell.rjust(width) for cell, width in zip(line, widths, strict=True)) for line in (header, *rows)
]
return [f" {line}" for line in [lines[0], "-" * len(lines[0]), *lines[1:]]]
def _rows_with_data(values: np.ndarray) -> np.ndarray:
"""Report, for each row, whether it has at least one observed cell."""
return np.any(~np.isnan(values), axis=1)
def unobserved_columns(block: pd.DataFrame, label: str) -> np.ndarray:
"""Return which columns of a training block have no observed value, and warn, naming them, if any do.
Such a column carries no information, so every estimator of the family leaves it out of
the model: it gets a zero loading or weight, and a value a new row has in it adds nothing
to that row's scores or SPE. PCA, PLS, MBPCA and MBPLS used to differ here, from a silent
fit to a refusal (#1098); TPLS's wording (#982) is now the family's.
Parameters
----------
block : pd.DataFrame
The training block, NaN where missing.
label : str
Where the block sits, as ``'X'``, ``'Y'``, ``'X["name"]'`` or ``'F["group"]'``.
Returns
-------
np.ndarray of bool, shape (n_features,)
True for a column with no observed value.
"""
unobserved = block.isna().to_numpy().all(axis=0)
if unobserved.any():
named = ", ".join(repr(col) for col in block.columns[unobserved])
warnings.warn( # a UserWarning, warn's default category
f"Block {label} has columns with no observed values, which carry no information and are "
f"excluded from the model: {named}. Drop them, or check the data for a sensor that was never logged.",
stacklevel=user_stacklevel(),
)
return unobserved
def _equal_values(block: pd.DataFrame) -> np.ndarray:
"""Return, per column, whether its observed values are all equal, as :class:`MCUVScaler` reads a column.
True also for a column with one observed value, and so for every column of a block of one row.
False for a column with no observed value: its NaN maximum and minimum are not equal.
"""
return (block.max() == block.min()).to_numpy()
def check_x_varies(blocks: list[pd.DataFrame], *, explains_y: bool) -> None:
"""Refuse an X in which nothing varies: every column of every block is constant or has no observed value (#1144).
Such an X tells no row from another, so there is no direction for a component to take. MBPLS
refused it; PLS and OPLS refused it at their first component, with another message; PCA and
MBPCA fitted it, with scores that told no row apart. The family now refuses it in MBPLS's
words, before any warning names its columns. A block with no variance beside one that varies
is fitted, with zero weight, and :func:`constant_columns` names it.
Parameters
----------
blocks : list[pd.DataFrame]
The blocks of X as the caller gave them, NaN where missing.
explains_y : bool
Whether X explains a Y, as in PLS, or is described by its components, as in PCA.
Raises
------
NotEnoughVarianceError
If no column of any block varies.
"""
if any((~_equal_values(block) & block.notna().to_numpy().any(axis=0)).any() for block in blocks):
return
where = "every column of every block" if len(blocks) > 1 else "every column"
so = "there is nothing to explain Y by" if explains_y else "there is nothing for a component to describe"
raise NotEnoughVarianceError(f"There is no variance in X: {where} is constant or has no observed value, so {so}.")
def check_y_varies(y: np.ndarray, *, label: str = "Y", explained_by: str = "X") -> None:
"""Refuse a response with nothing to explain: every column is constant or has no observed value (#1149).
Every weight of such a fit is 0, so a new row would have no score. PLS refused it at its
first component, as a Y with no variance *left*; MBPLS, OPLS and TPLS refused it in these
words, which are now the family's. ``y`` is the response as the fit centres it, where such
a column is 0 throughout, and a missing cell counts as 0. A model that does not centre
refuses a Y of zeros the same way.
Parameters
----------
y : np.ndarray of shape (n_samples, n_targets)
The response block over the rows the fit uses, as the fit centres and scales it.
label : str
Where the block sits, as ``'Y'`` or ``'Y["name"]'``.
explained_by : str
The blocks that explain it, as ``'X'`` or ``'F, D and Z'``.
Raises
------
NotEnoughVarianceError
If no column of ``y`` varies.
"""
if not np.any(np.nan_to_num(y)):
raise NotEnoughVarianceError(
f"There is no variance in {label}: every response is constant or has no observed value, so there is "
f"nothing for {explained_by} to explain."
)
def constant_columns(block: pd.DataFrame, label: str, estimator: object) -> np.ndarray:
"""Return which columns of a training block have no variance, and warn, naming them, if any do (#1114).
A column whose observed values are all equal, or which has one, carries no information about
how the rows differ. TPLS named it; PCA, PLS, MBPCA and MBPLS fitted it without a word. Its
wording is now the family's. A column with no observed value is not counted here:
:func:`unobserved_columns` names it, for its own cause. A block of one row has no column that
varies.
A block in which no column varies, beside blocks that do, gets zero weight in the model: its
scores, weights and contributions are 0. It is named as a block, in one sentence, rather than
column by column (#1144). An X in which nothing varies at all is refused before this is called
(:func:`check_x_varies`), and so is a Y.
Only a fit the caller asked for warns. A fit that another routine of this package runs, on a
cross-validation fold or on a matrix it derived (ASCA's effect matrices, FPCA's basis
coefficients), would name a column the caller never gave it, or one that varies in the
caller's data.
Parameters
----------
block : pd.DataFrame
The training block, NaN where missing.
label : str
Where the block sits, as ``'X'``, ``'Y'``, ``'X["name"]'`` or ``'Z["group"]'``.
estimator : object
The estimator being fitted.
Returns
-------
np.ndarray of bool, shape (n_features,)
True for a column with no variance.
"""
constant = _equal_values(block)
if constant.any() and _fit_asked_for_by_the_caller(estimator):
if (constant | block.isna().to_numpy().all(axis=0)).all():
message = (
f"Block {label} has no variance: every column is constant or has no observed value, so it gets "
"zero weight in the model. Drop the block, or check the data for a stuck sensor."
)
else:
named = ", ".join(repr(col) for col in block.columns[constant])
message = (
f"Block {label} has columns with no variance, which carry no information: {named}. Drop them, "
"or check the data for a stuck sensor."
)
warnings.warn(message, stacklevel=user_stacklevel()) # a UserWarning, warn's default category
return constant
def _fit_asked_for_by_the_caller(estimator: object) -> bool:
"""Return whether the caller's code, not another routine of this package, is fitting ``estimator``.
The outermost frame of this package under the caller's is the routine the caller called. It
is a method of ``estimator`` itself (its ``fit``, past an inherited ``fit`` and scikit-learn's
wrapper) when the caller fits it; it is a cross-validation, a resampling or another estimator
when that routine fits it on the caller's behalf.
"""
frame = inspect.currentframe()
outermost = None
while frame is not None:
package = str(frame.f_globals.get("__name__", "")).partition(".")[0]
if package == "process_improve":
outermost = frame
elif package not in ("sklearn", "mutmut"): # scikit-learn's wrapper of fit; mutmut's trampoline
break
frame = frame.f_back
return outermost is not None and outermost.f_locals.get("self") is estimator
def check_response_block(Y: pd.DataFrame, n_samples: int) -> None:
"""Refuse a response block X's rows cannot pair with, or one with an infinite value, as X is refused (#1114).
``validate_data`` checks X alone. The next to meet Y was a scaler, which reported an infinite
response as "Input X contains infinity", and PRM's robust centring, which met a Y of another
length as an ``IndexError``.
Parameters
----------
Y : pd.DataFrame
The response block, NaN where missing.
n_samples : int
The number of rows of X.
Raises
------
ValueError
If ``Y`` has a row count other than ``n_samples``, or an infinite value.
"""
if Y.shape[0] != n_samples:
raise ValueError(
f"The X and Y arrays must have the same number of rows: X has {n_samples} and Y has {Y.shape[0]}."
)
check_array(Y, dtype="numeric", ensure_all_finite="allow-nan", ensure_min_features=0, input_name="Y")
[docs]
class BlockSet(dict):
"""A ``dict[str, pd.DataFrame]`` of equal-height blocks that can also be sliced by row (#193).
:meth:`MBPCA.fit <process_improve.multivariate.methods.MBPCA.fit>` and
:meth:`MBPLS.fit <process_improve.multivariate.methods.MBPLS.fit>` take a
plain ``dict[str, pd.DataFrame]``, which is convenient to build and
impossible to resample: a dict has no notion of "row 7 of every block". Any
resampling or cross-validation pass needs exactly that. ``Resampler``, for
instance, asks its data only for ``len(x)`` and ``x[indices]``.
TPLS already has :class:`~process_improve.multivariate.methods.DataFrameDict`
for this, but it is hardwired to the ``Z``/``F``/``Y`` block names and to a
nested ``dict[str, dict[str, DataFrame]]`` layout, so the multi-block models
could not borrow it. ``BlockSet`` is the flat equivalent: a real ``dict``
subclass, so anything that already accepts the plain dict keeps working,
plus row indexing.
.. warning::
``len(blocks)`` is the number of **rows**, not the number of blocks. That
is surprising for a dict, and it is deliberate: it is the convention
``DataFrameDict`` already set, and it is what the resampling code means
by the length of a dataset. Use ``len(blocks.keys())`` to count blocks.
Parameters
----------
blocks : dict[str, pd.DataFrame]
One entry per block. Every block must be a DataFrame with the same
number of rows; widths may differ.
Raises
------
ValueError
If ``blocks`` is empty, or the blocks disagree on their row count.
TypeError
If any value is not a DataFrame.
Examples
--------
>>> blocks = BlockSet({"a": df_a, "b": df_b}) # doctest: +SKIP
>>> len(blocks) # rows, not blocks # doctest: +SKIP
40
>>> blocks[[0, 1, 2]].keys() # a 3-row BlockSet # doctest: +SKIP
dict_keys(['a', 'b'])
"""
def __init__(self, blocks: dict[str, pd.DataFrame]):
if not isinstance(blocks, dict):
raise TypeError(f"blocks must be a dict of DataFrames, one per block; got {type(blocks).__name__}.")
if not blocks:
raise ValueError("At least one block is required.")
# Two passes, so the "is it a frame?" message lives in one place: the row
# count has to come from a block already known to be a DataFrame, and
# checking the first one separately would mean writing that message twice.
for name, block in blocks.items():
if not isinstance(block, pd.DataFrame):
raise TypeError(f"Block {name!r} must be a pandas DataFrame; got {type(block).__name__}.")
first_name, first = next(iter(blocks.items()))
n_samples = first.shape[0]
for name, block in blocks.items():
if block.shape[0] != n_samples:
raise ValueError(
f"Every block must have the same number of rows ({n_samples}, from block {first_name!r}). "
f"Block {name!r} has {block.shape[0]}."
)
super().__init__(blocks)
self.n_samples = int(n_samples)
self.shape = (self.n_samples, len(blocks))
def __len__(self) -> int:
"""Return the number of rows; see the warning in the class docstring."""
return self.n_samples
def __getitem__(self, lookup: str | int | list | np.ndarray) -> pd.DataFrame | BlockSet:
"""Look a block up by name, or slice every block to the same rows."""
if isinstance(lookup, str):
return cast("pd.DataFrame", super().__getitem__(lookup))
return BlockSet({name: _row_slice(block, lookup, name) for name, block in self.items()})
def __eq__(self, other: object) -> bool:
"""Value-based equality over the held blocks.
The inherited ``dict.__eq__`` compares the values with ``==``, which for
two DataFrames returns an element-wise frame; Python then asks that frame
for its truth value and pandas raises ``ValueError``. Equality therefore
appeared to work only when the two operands were the *same object* (and
the identity short-circuit fired) and blew up otherwise. Comparing with
:meth:`pandas.DataFrame.equals` makes the answer depend on the content,
as it must for a class that also carries ``n_samples`` and ``shape``
(CodeQL ``py/missing-equals``).
Any mapping is accepted on the other side, not just a ``BlockSet``, so
that comparing against the plain ``dict[str, DataFrame]`` the caller
started from answers instead of raising. That stays symmetric: Python
tries the subclass's ``__eq__`` first, so ``plain_dict == block_set``
reaches this method too.
"""
if self is other:
return True
if not isinstance(other, dict):
return NotImplemented
return self.keys() == other.keys() and all(_block_equal(block, other[name]) for name, block in self.items())
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 raw dict values again.
"""
result = self.__eq__(other)
return result if result is NotImplemented else not result
# Blocks are mutable frames, so a ``BlockSet`` is unhashable just as ``dict``
# is; make that explicit now that ``__eq__`` is defined.
__hash__ = None # type: ignore[assignment] # reason: intentionally unhashable, mirrors dict
def _block_equal(mine: pd.DataFrame, theirs: object) -> bool:
"""Compare one block against the other side's value for the same name.
``theirs`` is a DataFrame whenever the other side is a :class:`BlockSet`, but
an arbitrary object when it is a plain dict, and ``mine == theirs`` would then
hand pandas' element-wise result to :func:`bool`.
"""
return isinstance(theirs, pd.DataFrame) and mine.equals(theirs)
def _row_slice(block: pd.DataFrame, lookup: int | list | np.ndarray, name: str) -> pd.DataFrame:
"""Take rows from one block, keeping the result two-dimensional.
Every branch normalises the lookup to a *list* of row positions and the
function has a single exit, so a scalar can never leak out as a Series: one
row must still arrive at ``fit`` as a one-row frame.
"""
if isinstance(lookup, int | np.integer):
rows = [int(lookup)]
elif isinstance(lookup, np.ndarray):
rows = lookup.tolist()
elif isinstance(lookup, list):
rows = [int(index) for index in lookup]
else:
raise TypeError(
f"Row lookup must be an int, a list of ints, or an ndarray; "
f"got {type(lookup).__name__} while slicing block {name!r}."
)
return block.iloc[rows]