Feature Request: Add explained variance ratios for X and Y to `PLSRegression`

### Describe the workflow you want to enable

Currently, `sklearn.cross_decomposition.PLSRegression` does not expose any measure of explained variance for either the predictor (`X`) or response (`Y`) spaces.

In different disciplines (such as multivartiate statistical process control, -omic sciences or chemometrics), the explained variance in `X` and `Y` spaces is a core interpretability metric and is routinely used to interpret PLS models — analogous to how PCA exposes `explained_variance_ratio_`. Not having access to it in the `PLSRegression` makes it difficult to:

- Quantify how much variance each latent variable captures,

- Compare models with different numbers of components, and

- Produce diagnostic or variance-explained plots (as is standard in different disciplines where latent space interpretability is important).


### Describe your proposed solution

#### Proposed solution

I propose adding two new attributes, automatically computed after fitting:

- `explained_x_variance_ratio_ `: `ndarray` of shape (`n_components`)
    Fraction of variance explained in X-space for each component.

- `explained_y_variance_ratio_ `: `ndarray` of shape (`n_components`)
    Fraction of variance explained in Y-space for each component.


These values are derived using standard PLS sequential deflation methodology (see Wegelin, 2000; Abdi, 2003), where variance explained = reduction in total variance after deflation per component, where each matrix is asymmetrically deflated per each component as follows:

$$X←X - \hat{X}_a = X - t_ap_a^T$$

$$Y←Y - \hat{Y}_a = Y - t_aq_a^T$$

This mirrors the `PCA` API (`explained_variance_ratio_`) and significantly improves usability for diagnostics and visualization.

#### Implementation sketch

```python
class PLSRegression(_PLS):
""" Documentation updated to include the follwoing attributes:

    Attributes
    ----------
    explained_x_variance_ratio_ : ndarray of shape (n_components,)
        Explained variance ratio in X-space (predictors) for each component.
        This measures how much variance in the predictor variables each latent
        variable captures. Calculated in fit().

    explained_y_variance_ratio_ : ndarray of shape (n_components,)
        Explained variance ratio in Y-space (response) for each component.
        This measures how much variance in the response
        each latent variable explains. Calculated in fit().
"""

    def __init__(self, ...):
        [this section is not modified]

    def fit(self, X, y):
        super().fit(X, y)
        # Calculate explained variance ratios
        (
            self.explained_x_variance_ratio_,
            self.explained_y_variance_ratio_,
        ) = self._calculate_explained_variance_ratio(X, y)

        return self

        def _calculate_explained_variance_ratio(
        self, X, y
    ) -> tuple[np.ndarray, np.ndarray]:
        """Calculate explained variance ratios using sequential deflation.

        This implements the variance decomposition for PLS regression following
        the deflation methodology described in Wegelin (2000).

        This method calculates how much variance each component explains by
        sequentially deflating the X and Y matrices. This is the standard
        approach in PLS and provides accurate component-wise variance.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training vectors. Accepts numpy arrays, pandas DataFrames.
        y : array-like of shape (n_samples,) or (n_samples, n_targets)
            Target vectors. Accepts 1D (univariate) or 2D (multivariate) targets.

        Returns
        -------
        tuple[ndarray, ndarray]
            - X variance ratios of shape (n_components,)
            - Y variance ratios of shape (n_components,)
        """
        # Convert to arrays and ensure y is 2D (handles pandas DataFrame/Series)
        X = np.asarray(X, dtype=float)
        y_array = np.asarray(y, dtype=float)
        y = np.atleast_2d(y_array).T if y_array.ndim == 1 else y_array

        # Center X and Y (PLS already centers data, but we need the original
        # centered versions)
        X_centered = X - X.mean(axis=0)
        y_centered = y - y.mean(axis=0)

        # Check for scaling
        if self.scale:
            X_std = X.std(axis=0, ddof=1)
            X_std[X_std == 0.0] = 1.0
            X_centered /= X_std
            y_std = y.std(axis=0, ddof=1)
            y_std[y_std == 0.0] = 1.0
            y_centered /= y_std

        # Total variance in centered data
        X_total_var = np.var(X_centered, axis=0, ddof=1).sum()
        y_total_var = np.var(y_centered, axis=0, ddof=1).sum()
        has_x_variance = not np.isclose(X_total_var, 0.0)
        has_y_variance = not np.isclose(y_total_var, 0.0)

        # Initialize matrices for deflation
        X_current = X_centered.copy()
        y_current = y_centered.copy()

        X_var_ratios = []
        y_var_ratios = []

        # For each component, calculate variance explained then deflate
        for a in range(self.n_components):
            # Get scores and loadings for component a (using slicing to keep 2D)
            t_a = self.x_scores_[:, a : a + 1]  # (n_samples, 1)
            p_a = self.x_loadings_[:, a : a + 1]  # (n_features_X, 1)
            q_a = self.y_loadings_[:, a : a + 1]  # (n_features_y, 1)

            # Reconstruct X and y using current component
            X_hat = t_a @ p_a.T
            y_hat = t_a @ q_a.T

            # Variance of current residual before deflation
            X_var_before = np.var(X_current, axis=0, ddof=1.0).sum()
            y_var_before = np.var(y_current, axis=0, ddof=1.0).sum()

            # Deflate X and y
            X_current -= X_hat
            y_current -= y_hat

            # Variance of residual after deflation
            X_var_after = np.var(X_current, axis=0, ddof=1.0).sum()
            y_var_after = np.var(y_current, axis=0, ddof=1.0).sum()

            # Store variance explained as ratio of total variance
            if has_x_variance:
                X_var_ratios.append((X_var_before - X_var_after) / X_total_var)
            else:
                X_var_ratios.append(0.0)
            if has_y_variance:
                y_var_ratios.append((y_var_before - y_var_after) / y_total_var)
            else:
                y_var_ratios.append(0.0)

        return np.array(X_var_ratios), np.array(y_var_ratios)
```

#### Usage Example

```python
pls = PLSRegression().fit(X, y)
explained_x_variance = pls.explained_x_variance_ratio_
explained_y_variance = pls.explained_y_variance_ratio_
```

#### Testing

I have tested that this implementation provides the same results as the ones described by:

Abdi, H. (2003). Partial Least Squares (PLS) Regression. In Lewis-Beck M., Bryman A., Futing T. (Eds.), Encyclopedia of Social Sciences Research Methods. Thousand Oaks (CA): Sage.

I have also tested that this implementation does not break the `scikit-learn` estimator API:

```python
# Test compliance with scikit-learn
def test_compliance_pls_regression():
    # Arrange
    transformer = PLSRegression()
    # Act & Assert
    check_estimator(transformer)
```

### Next steps

If maintainers agree this feature fits within scope, I would be happy to:

- Open a PR adding the attributes to PLSRegression,

- Include updated documentation and examples, and

- Add unit tests verifying correctness against synthetic datasets and published examples.


### Describe alternatives you've considered, if relevant

_No response_

### Additional context

- No changes to existing API or behavior.
- Attributes are only added post-fit (similar to PCA’s `explained_variance_ratio_`).
- Could be extended to other PLS methods *(e.g., `PLSCanonical`) but it requires that other methods expose the `x_scores_` and the `y_scores_` (currently are not exposed) and to symmetrically  deflate the `Y` matrix.

### Previous issues

This request is not new, there were some previous. issues:

- https://github.com/scikit-learn/scikit-learn/issues/19896  (open but no  complete implementation suggested)
- https://github.com/scikit-learn/scikit-learn/issues/30470 (closed)

COMMENT:
I think that makes sense. Feel free to open a PR with the changes. However, note that we're short on reviewer time, and might take a while before we could review and merge your PR.

Labels: New Feature