• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sm00thix / ikpls / 28934299777

08 Jul 2026 10:02AM UTC coverage: 94.432% (+0.1%) from 94.323%
28934299777

push

github

web-flow
sklearn PLS.fit_transform returns (X-scores, Y-scores); parameter constraints & type hints (6.1.0) (#49)

* IKPLS 6.1.0: sklearn PLS.fit_transform returns (X-scores, Y-scores)

ikpls.sklearn.PLS previously inherited TransformerMixin.fit_transform, which
returns only the X-scores of transform(X). Override it to fit (through fit, so a
subclass overriding fit still runs) and return transform(X, y) -- the
(x_scores, y_scores) tuple, matching sklearn.cross_decomposition.PLSRegression
and ikpls.numpy.PLS.fit_transform. This is a behavior change for callers that
relied on the previous X-scores-only return.

It also introduces two expected failures in scikit-learn's check_estimator suite
(check_transformer_general, check_transformer_data_not_an_array): those compare
fit_transform(X, y) against transform(X), and scikit-learn only special-cases the
tuple return for a hard-coded set of class names (CROSS_DECOMPOSITION), of which
"PLS" is not one. scikit-learn's own PLSRegression has identical tuple behavior
and passes only by virtue of its name, so these are not real conformance
defects; the conformance test marks them expected-fail.

Bumps the version to 6.1.0.

* Add type hints and _parameter_constraints to ikpls.sklearn.PLS

Annotate the public method signatures of the scikit-learn wrapper (fit,
fit_transform, transform, inverse_transform, predict, predict_all_components,
score, y_rotations_, __sklearn_tags__), matching the NumPy backend's
numpy.typing conventions. The constructor parameters are deliberately left
unannotated: the sklearn contract stores them verbatim and validates at fit, and
annotating them would make the typeguard-instrumented test suite reject
non-conforming values at construction instead of yielding the clean fit-time
error.

Replace the ad-hoc _validate_n_components with scikit-learn's
_parameter_constraints / _validate_params: n_components is Interval(Integral, 1,
None), algorithm is Options({1, 2}), the centering/scaling flags are bool... (continued)

1238 of 1311 relevant lines covered (94.43%)

11.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.27
ikpls/numpy.py
1
"""
2
This file contains the PLS Class which implements partial least-squares regression
3
using Improved Kernel PLS by Dayal and MacGregor:
4
https://doi.org/10.1002/(SICI)1099-128X(199701)11:1%3C73::AID-CEM435%3E3.0.CO;2-%23
5

6
This class is written using NumPy. Its native API is optimized for fast
7
cross-validation: ``fit`` takes the number of components ``A`` directly, and
8
``predict`` can return predictions for every number of components ``1..A`` at
9
once. For use inside the scikit-learn ecosystem (e.g. ``cross_validate``,
10
``Pipeline``, ``GridSearchCV``), a thin scikit-learn-conformant wrapper,
11
``ikpls.sklearn.PLS``, subclasses ``BaseEstimator`` and delegates to this
12
class.
13

14
Author: Ole-Christian Galbo Engstrøm
15
E-mail: ocge@foss.dk
16
"""
17

18
from collections.abc import Callable, Hashable
20✔
19
from typing import Any, Iterable, Optional, Tuple, Union
20✔
20

21
import joblib
20✔
22
import numpy as np
20✔
23
import numpy.typing as npt
20✔
24
from joblib import Parallel, delayed
20✔
25
from sklearn.exceptions import NotFittedError
20✔
26

27
from ikpls._impl.pls_steps import improved_kernel_pls_inner_loop
20✔
28
from ikpls._impl.r_y_mapping import _R_Y_Mapping
20✔
29

30

31
class PLS:
20✔
32
    """
33
    Implements partial least-squares regression using Improved Kernel PLS by Dayal and
34
    MacGregor:
35
    https://doi.org/10.1002/(SICI)1099-128X(199701)11:1%3C73::AID-CEM435%3E3.0.CO;2-%23
36

37
    Parameters
38
    ----------
39
    algorithm : int, default=1
40
        Whether to use Improved Kernel PLS Algorithm #1 or #2.
41

42
    center_X : bool, default=True
43
        Whether to center `X` before fitting by subtracting its row of
44
        column-wise means from each row.
45

46
    center_Y : bool, default=True
47
        Whether to center `Y` before fitting by subtracting its row of
48
        column-wise means from each row.
49

50
    scale_X : bool, default=True
51
        Whether to scale `X` before fitting by dividing each row with the row of `X`'s
52
        column-wise standard deviations.
53

54
    scale_Y : bool, default=True
55
        Whether to scale `Y` before fitting by dividing each row with the row of `Y`'s
56
        column-wise standard deviations.
57

58
    ddof : int, default=0
59
        The delta degrees of freedom to use when computing the sample standard
60
        deviation. A value of 0 corresponds to the biased estimate of the sample
61
        standard deviation, while a value of 1 corresponds to Bessel's correction for
62
        the sample standard deviation.
63

64
    copy : bool, default=True
65
            Whether to copy `X` and `Y` in before potentially applying centering and
66
            scaling. If True, then the data is copied before fitting. If False, and `dtype`
67
            matches the type of `X` and `Y`, then centering and scaling is done inplace,
68
            modifying both arrays.
69

70
    dtype : type[np.floating], default=numpy.float64
71
        The float datatype to use in computation of the PLS algorithm. This should be
72
        numpy.float32 or numpy.float64. Using a lower precision than float64
73
        will yield significantly worse results when using an increasing number of
74
        components due to propagation of numerical errors.
75

76
    Raises
77
    ------
78
    ValueError
79
        If `algorithm` is not 1 or 2.
80

81
    Notes
82
    -----
83
    Any centering and scaling is undone before returning predictions to ensure that
84
    predictions are on the original scale. If both centering and scaling are True, then
85
    the data is first centered and then scaled.
86
    """
87

88
    def __init__(
20✔
89
        self,
90
        algorithm: int = 1,
91
        center_X: bool = True,
92
        center_Y: bool = True,
93
        scale_X: bool = True,
94
        scale_Y: bool = True,
95
        ddof: int = 0,
96
        copy: bool = True,
97
        dtype: type[np.floating] = np.float64,
98
    ) -> None:
99
        self.algorithm = algorithm
20✔
100
        self.center_X = center_X
20✔
101
        self.center_Y = center_Y
20✔
102
        self.scale_X = scale_X
20✔
103
        self.scale_Y = scale_Y
20✔
104
        self.ddof = ddof
20✔
105
        self.copy = copy
20✔
106
        self.dtype = dtype
20✔
107
        self.eps = np.finfo(dtype).eps
20✔
108
        self.name = f"Improved Kernel PLS Algorithm #{algorithm}"
20✔
109
        if self.algorithm not in [1, 2]:
20✔
110
            raise ValueError(
×
111
                f"Invalid algorithm: {self.algorithm}. Algorithm must be 1 or 2."
112
            )
113
        self.A = None
20✔
114
        self.N = None
20✔
115
        self.K = None
20✔
116
        self.M = None
20✔
117
        self.B = None
20✔
118
        self.W = None
20✔
119
        self.P = None
20✔
120
        self.Q = None
20✔
121
        self.C = None  # Y-weights; aliased to Q in fit (see the C attribute docs)
20✔
122
        self.R = None
20✔
123
        self.R_Y = None
20✔
124
        self.T = None
20✔
125
        self.X_mean = None
20✔
126
        self.Y_mean = None
20✔
127
        self.X_std = None
20✔
128
        self.Y_std = None
20✔
129
        self.max_stable_components = None
20✔
130
        self.fitted_ = False
20✔
131

132
    def _convert_input_to_array(
20✔
133
        self, arr: Optional[npt.ArrayLike]
134
    ) -> Optional[npt.NDArray[np.floating]]:
135
        """
136
        Converts input array to numpy array of type self.dtype. Inserts a second axis
137
        if the input array is 1-dimensional.
138

139
        Parameters
140
        ----------
141
        arr : ArrayLike or None
142
            Input array.
143

144
        Returns
145
        -------
146
        array_converted : Array of shape of input array or None
147
            Converted input array.
148
        """
149
        if arr is not None:
20✔
150
            arr = np.asarray(arr, dtype=self.dtype)
20✔
151
            if arr.ndim == 1:
20✔
152
                arr = arr.reshape(-1, 1)
10✔
153
        return arr
20✔
154

155
    def _copy_arrays(
20✔
156
        self,
157
        copy_X: bool,
158
        copy_Y: bool,
159
        X: Optional[npt.NDArray[np.floating]] = None,
160
        Y: Optional[npt.NDArray[np.floating]] = None,
161
    ) -> Tuple[Optional[npt.NDArray[np.floating]], Optional[npt.NDArray[np.floating]]]:
162
        """
163
        Copies `X and `Y` so that downstream centering and scaling do not modify the
164
        input directly but instead work on copies thereof.
165

166
        Parameters
167
        ----------
168
        copy_X: bool
169
            Whether to copy `X`.
170

171
        copy_Y : bool
172
            Whether to copy `Y`.
173

174
        X : Array of shape (N, K) or None
175
            Predictor variables.
176

177
        Y : Array of shape (N, M) or None
178
            Response variables.
179

180
        Returns
181
        -------
182
        X_copy : Array of shape (N, K) or None
183
            A copy of `X` if it is provided and the following conditions are met:
184
            `copy_X` is True, and `X` will be either centered or scaled.
185

186
        Y_copy : Array of shape (N, M) or None
187
            A copy of `Y` if it is provided and the following conditions are met:
188
            `copy_Y` is True, and `Y` will be either centered or scaled.
189
        """
190
        if copy_X and X is not None and (self.center_X or self.scale_X):
20✔
191
            X = X.copy()
20✔
192
        if copy_Y and Y is not None and (self.center_Y or self.scale_Y):
20✔
193
            Y = Y.copy()
20✔
194
        return X, Y
20✔
195

196
    def _get_input(
20✔
197
        self,
198
        copy: bool,
199
        X: Optional[npt.ArrayLike] = None,
200
        Y: Optional[npt.ArrayLike] = None,
201
        sample_weight: Optional[npt.ArrayLike] = None,
202
    ) -> Tuple[
203
        Optional[npt.ArrayLike], Optional[npt.ArrayLike], Optional[npt.ArrayLike]
204
    ]:
205
        """
206
        Applies `self._convert_input_to_array` on `X`, `Y`, and `sample_weight` and then
207
        applies `self._copy_arrays` to `X` and `Y`. See those two functions for a
208
        description of parameters and return values.
209

210
        Returns
211
        -------
212
        X, Y, sample_weight
213
            Converted inputs for safe downstream processing.
214
        """
215

216
        new_X = self._convert_input_to_array(arr=X)
20✔
217
        new_Y = self._convert_input_to_array(arr=Y)
20✔
218
        copy_X = copy and (np.may_share_memory(new_X, X))
20✔
219
        copy_Y = copy and (np.may_share_memory(new_Y, Y))
20✔
220
        sample_weight = self._convert_input_to_array(arr=sample_weight)
20✔
221
        if sample_weight is not None:
20✔
222
            self._check_nonnegative_weights(sample_weight=sample_weight)
10✔
223
        new_X, new_Y = self._copy_arrays(copy_X=copy_X, copy_Y=copy_Y, X=new_X, Y=new_Y)
20✔
224
        return new_X, new_Y, sample_weight
20✔
225

226
    def _center_scale_X_Y(
20✔
227
        self,
228
        X: Optional[npt.NDArray[np.floating]] = None,
229
        Y: Optional[npt.NDArray[np.floating]] = None,
230
    ) -> Tuple[Optional[npt.NDArray[np.floating]], Optional[npt.NDArray[np.floating]]]:
231
        """
232
        Centers and scales the input array based on the model's parameters.
233

234
        Parameters
235
        ----------
236
        X : Array of shape (N, K) or None
237
            Predictor variables.
238

239
        Y : Array of shape (N, M) or None
240
            Response variables.
241

242
        Returns
243
        -------
244
        X_centered_scaled : Array of shape (N, K) or None
245
            Potentially centered and scaled `X`.
246

247
        Y_centered_scaled : Array of shape (N, M) or None
248
            Potentially centered and scaled `Y`.
249
        """
250
        if X is not None:
20✔
251
            if self.center_X:
20✔
252
                X -= self.X_mean if self.X_mean is not None else X
20✔
253
            if self.scale_X:
20✔
254
                X /= self.X_std if self.X_std is not None else X
20✔
255
        if Y is not None:
20✔
256
            if self.center_Y:
20✔
257
                Y -= self.Y_mean if self.Y_mean is not None else Y
20✔
258
            if self.scale_Y:
20✔
259
                Y /= self.Y_std if self.Y_std is not None else Y
20✔
260
        return (X, Y)
20✔
261

262
    def _un_center_scale_X_Y(
20✔
263
        self,
264
        X: Optional[npt.NDArray[np.floating]] = None,
265
        Y: Optional[npt.NDArray[np.floating]] = None,
266
    ) -> Tuple[Optional[npt.NDArray[np.floating]], Optional[npt.NDArray[np.floating]]]:
267
        """
268
        Restores `X` and `Y`to their original scale and location by undoing any
269
        centering and scaling.
270

271
        Parameters
272
        ----------
273
        X : Array of shape (N, K) or None
274
            Predictor variables.
275

276
        Y : Array of shape (N, M) or None
277
            Response variables.
278

279
        Returns
280
        -------
281
        X_restored : Array of shape (N, K) or None
282
            `X` with any centering and scaling undone.
283

284
        Y_restored : Array of shape (N, M) or None
285
            `Y` with any centering and scaling undone.
286
        """
287
        if X is not None:
20✔
288
            if self.scale_X:
20✔
289
                X *= self.X_std
10✔
290
            if self.center_X:
20✔
291
                X += self.X_mean
10✔
292
        if Y is not None:
20✔
293
            if self.scale_Y:
20✔
294
                Y *= self.Y_std
20✔
295
            if self.center_Y:
20✔
296
                Y += self.Y_mean
20✔
297
        return (X, Y)
20✔
298

299
    def _check_nonnegative_weights(self, sample_weight: npt.NDArray[np.floating]) -> None:
20✔
300
        """
301
        Checks that weights are non-negative.
302

303
        Parameters
304
        ----------
305
        sample_weight : Array of shape (N, 1)
306
            Sample weights used to fit PLS.
307

308
        Returns
309
        -------
310
        None
311

312
        Raises
313
        ------
314
        ValueError
315
            If weights are not all non-negative.
316
        """
317
        if np.any(sample_weight < 0):
10✔
318
            raise ValueError("Weights must be non-negative.")
10✔
319

320
    def _check_num_nonzero_weights_greater_than_ddof(
20✔
321
        self, num_nonzero_weights: Union[int, np.integer]
322
    ) -> None:
323
        """
324
        Checks that the number of nonzero weights are greater than `self.ddof` if
325
        either of `self.scale_X` or `self.scale_Y` is True so we must compute the
326
        corresponding weighted standard deviation.
327

328
        Parameters
329
        ----------
330
        num_nonzero_weights : int
331
            The number of nonzero weights. ``np.count_nonzero`` returns a builtin
332
            ``int`` on NumPy < 2.4 and an ``np.intp`` on newer NumPy, so both are
333
            admitted."
334

335
        Returns
336
        -------
337
        None
338

339
        Raises
340
        ------
341
        ValueError
342
            If the number of nonzero weights is not greater than `self.ddof`
343
        """
344
        if self.scale_X or self.scale_Y:
14✔
345
            if not num_nonzero_weights > self.ddof:
10✔
346
                raise ValueError(
10✔
347
                    f"The number of nonzero weights: {num_nonzero_weights} must be greater than ddof: {self.ddof}"
348
                )
349

350
    def _compute_mean_and_std(
20✔
351
        self,
352
        X: npt.NDArray[np.floating],
353
        Y: npt.NDArray[np.floating],
354
        sample_weight: Optional[npt.NDArray[np.floating]],
355
    ) -> None:
356
        """
357
        Computes the weighted means and standard deviations for `X` and `Y`.
358

359
        Parameters
360
        ----------
361
        X : Array of shape (N, K)
362
            Predictor variables.
363

364
        Y : Array of shape (N, M):
365
            Response variables.
366

367
        sample_weight : Array of shape (N, 1) or None
368
            Sample weights.
369

370
        Attributes
371
        ----------
372
        X_mean : Array of shape (1, K) or None
373
            Mean of X. If centering is not performed, this is None. If weights are
374
            used, then this is the weighted mean.
375

376
        Y_mean : Array of shape (1, M) or None
377
            Mean of Y. If centering is not performed, this is None. If weights are
378
            used, then this is the weighted mean.
379

380
        X_std : Array of shape (1, K) or None
381
            Sample standard deviation of X. If scaling is not performed, this is None.
382
            If weights are used, then this is the weighted standard deviation.
383

384
        Y_std : Array of shape (1, M) or None
385
            Sample standard deviation of Y. If scaling is not performed, this is None.
386
            If weights are used, then this is the weighted standard deviation.
387

388
        Returns
389
        -------
390
        None
391

392
        Raises
393
        ------
394
        ValueError
395
            If `sample_weight` are provided and scaling of `X` or `Y` is required and the
396
            number of non-negative weights is less or equal to `self.ddof`.
397
        """
398
        if not (self.center_X or self.center_Y or self.scale_X or self.scale_Y):
20✔
399
            return
10✔
400
        if sample_weight is not None:
20✔
401
            flattened_weights = sample_weight.flatten()
10✔
402
            if self.scale_X or self.scale_Y:
10✔
403
                num_non_zero_weights = np.count_nonzero(sample_weight)
10✔
404
                self._check_num_nonzero_weights_greater_than_ddof(
14✔
405
                    num_nonzero_weights=num_non_zero_weights
406
                )
407
                scale_dof = num_non_zero_weights - self.ddof
10✔
408
                avg_non_zero_weights = np.sum(sample_weight) / num_non_zero_weights
10✔
409
        else:
410
            flattened_weights = None
20✔
411

412
        if self.center_X or self.scale_X:
20✔
413
            self.X_mean = np.asarray(
20✔
414
                np.average(X, axis=0, weights=flattened_weights, keepdims=True),
415
                dtype=self.dtype,
416
            )
417

418
        if self.center_Y or self.scale_Y:
20✔
419
            self.Y_mean = np.asarray(
20✔
420
                np.average(Y, axis=0, weights=flattened_weights, keepdims=True),
421
                dtype=self.dtype,
422
            )
423

424
        if self.scale_X:
20✔
425
            if sample_weight is None:
20✔
426
                self.X_std = X.std(
20✔
427
                    axis=0,
428
                    ddof=self.ddof,
429
                    dtype=self.dtype,
430
                    keepdims=True,
431
                    mean=self.X_mean,
432
                )
433
            else:
434
                self.X_std = np.sqrt(
10✔
435
                    np.sum(sample_weight * (X - self.X_mean) ** 2, axis=0, keepdims=True)
436
                    / (scale_dof * avg_non_zero_weights)
437
                )
438
            self.X_std[self.X_std <= self.eps] = 1
20✔
439

440
        if self.scale_Y:
20✔
441
            if sample_weight is None:
20✔
442
                self.Y_std = Y.std(
20✔
443
                    axis=0,
444
                    ddof=self.ddof,
445
                    dtype=self.dtype,
446
                    keepdims=True,
447
                    mean=self.Y_mean,
448
                )
449
            else:
450
                self.Y_std = np.sqrt(
10✔
451
                    np.sum(sample_weight * (Y - self.Y_mean) ** 2, axis=0, keepdims=True)
452
                    / (scale_dof * avg_non_zero_weights)
453
                )
454
            self.Y_std[self.Y_std <= self.eps] = 1
20✔
455

456
    def fit(
20✔
457
        self,
458
        X: npt.ArrayLike,
459
        Y: npt.ArrayLike,
460
        A: int,
461
        sample_weight: Optional[npt.ArrayLike] = None,
462
    ) -> "PLS":
463
        """
464
        Fits Improved Kernel PLS Algorithm #1 on `X` and `Y` using `A` components.
465

466
        Parameters
467
        ----------
468
        X : Array of shape (N, K)
469
            Predictor variables.
470

471
        Y : Array of shape (N, M) or (N,)
472
            Response variables.
473

474
        A : int
475
            Number of components in the PLS model.
476

477
        sample_weight : Array of shape (N,) or None, optional, default=None
478
            Weights for each observation. If None, then all observations are weighted
479
            equally.
480

481
        Attributes
482
        ----------
483
        A : int
484
            Number of components in the PLS model.
485

486
        max_stable_components : int
487
            Number of leading, numerically stable components. Equals `A` unless a
488
            component is detected as unstable, in which case it is the index of the
489
            first such component. A component is unstable if its X-weight norm
490
            underflows below machine epsilon (the X-Y cross-covariance is exhausted)
491
            or if its score norm ``t^T t`` collapses relative to the largest score
492
            norm seen so far (a null-space direction past the numerical rank of X).
493
            Such components carry no predictive information: the regression
494
            coefficients are carried forward, so `predict` with more than
495
            `max_stable_components` components returns the same result as with exactly
496
            `max_stable_components`. Their scores/loadings (`W`, `P`, `Q`, `R`, `T`),
497
            however, are not meaningful -- zeroed here in the NumPy backend, left
498
            un-normalized in the JAX backend -- so slice to `max_stable_components`
499
            before comparing them across backends.
500

501
        B : Array of shape (A, K, M)
502
            PLS regression coefficients tensor.
503

504
        W : Array of shape (K, A)
505
            PLS weights matrix for X.
506

507
        P : Array of shape (K, A)
508
            PLS loadings matrix for X.
509

510
        Q : Array of shape (M, A)
511
            PLS Loadings matrix for Y.
512

513
        R : Array of shape (K, A)
514
            PLS weights matrix to compute scores T directly from original X.
515

516
        R_Y : Mapping[int, Array]
517
            Mapping from number of components to PLS weights matrix to compute scores U
518
            directly from original Y. Keys range from 1 to A. Values are arrays of
519
            shape (M, n_components) where n_components is the key. Values are computed
520
            lazily and cached upon first access. See Notes for more information.
521

522
        C : Array of shape (M, A)
523
            PLS Y-weights. In Improved Kernel PLS these equal the Y-loadings Q
524
            (matching scikit-learn's PLSRegression, whose Y-weights equal its
525
            Y-loadings). Exposed as an alias of Q under the conventional Y-weights
526
            name.
527

528
        T : Array of shape (N, A)
529
            PLS scores matrix of X. Only assigned for Improved Kernel PLS Algorithm #1.
530
            IMPORTANT: If weights are provided, these are NOT the scores of X but
531
            instead weighted scores. In this case, scores can be computerd using
532
            transform.
533

534
        X_mean : Array of shape (1, K) or None
535
            Mean of X. If centering is not performed, this is None. If weights are
536
            used, then this is the weighted mean.
537

538
        Y_mean : Array of shape (1, M) or None
539
            Mean of Y. If centering is not performed, this is None. If weights are
540
            used, then this is the weighted mean.
541

542
        X_std : Array of shape (1, K) or None
543
            Sample standard deviation of X. If scaling is not performed, this is None.
544
            If weights are used, then this is the weighted standard deviation.
545

546
        Y_std : Array of shape (1, M) or None
547
            Sample standard deviation of Y. If scaling is not performed, this is None.
548
            If weights are used, then this is the weighted standard deviation.
549

550
        Returns
551
        -------
552
        self : PLS
553
            Fitted model.
554

555
        Raises
556
        ------
557
        ValueError
558
            If `sample_weight` are provided and not all weights are non-negative.
559

560
        ValueError
561
            If `sample_weight` are provided, and `scale_X` or `scale_Y` is True, and the
562
            number of non-zero weights is not greater than `ddof`.
563

564
        Warns
565
        -----
566
        UserWarning.
567
            If at any point during iteration over the number of components `A`, the
568
            residual goes below machine precision for np.float64.
569

570
        See Also
571
        --------
572
        transform : Transforms `X` and `Y` to their respective scores.
573
        fit_transform : Fits the model and returns the scores of `X` and `Y`.
574

575
        Notes
576
        -----
577
        `R_Y` is provided for convenience only as it is not required to derive `B`.
578
        Therefore, every value in `R_Y` is computed lazily and only actually evaluated
579
        when accessed by its key for the first time after a call to `fit` - either by
580
        the user or because it is needed by `transform`. After a value is computed, it
581
        is cached for fast future retrieval. R_Y is implemented as a concrete Mapping.
582
        """
583
        self.fitted_ = True
20✔
584
        X, Y, sample_weight = self._get_input(copy=self.copy, X=X, Y=Y, sample_weight=sample_weight)
20✔
585
        self._compute_mean_and_std(X=X, Y=Y, sample_weight=sample_weight)
20✔
586
        X, Y = self._center_scale_X_Y(X=X, Y=Y)
20✔
587

588
        N, K = X.shape
20✔
589
        M = Y.shape[1]
20✔
590

591
        self.A = A
20✔
592
        self.N = N
20✔
593
        self.K = K
20✔
594
        self.M = M
20✔
595

596
        # Step 1
597
        if sample_weight is not None:
20✔
598
            if self.algorithm == 2 or K < M:
10✔
599
                XTW = (X * sample_weight).T
10✔
600
                XTY = XTW @ Y
14✔
601
            else:
602
                XTY = X.T @ (Y * sample_weight)
10✔
603
        else:
604
            XTY = X.T @ Y
20✔
605

606
        # Used for algorithm #2
607
        if self.algorithm == 2:
20✔
608
            if sample_weight is not None:
20✔
609
                XTX = XTW @ X
10✔
610
            else:
611
                XTX = X.T @ X
20✔
612
        else:
613
            if sample_weight is not None:
20✔
614
                X = np.sqrt(sample_weight) * X
10✔
615

616
        # Execute Improved Kernel PLS steps 2-5 (shared with fast cross-validation).
617
        # The inner loop reports how many leading components are numerically stable
618
        # (see _stability_warning); it emits the underflow/collapse warnings itself.
619
        self.B, W, P, Q, R, T, max_stable = improved_kernel_pls_inner_loop(
20✔
620
            self.algorithm,
621
            A,
622
            K,
623
            M,
624
            XTY,
625
            X=X if self.algorithm == 1 else None,
626
            XTX=XTX if self.algorithm == 2 else None,
627
            dtype=self.dtype,
628
            eps=self.eps,
629
        )
630
        self.max_stable_components = max_stable
20✔
631
        self.W = W
20✔
632
        self.P = P
20✔
633
        self.Q = Q
20✔
634
        self.C = self.Q  # Y-weights == Y-loadings (see the C attribute docs)
20✔
635
        self.R = R
20✔
636
        self.R_Y = _R_Y_Mapping(self.Q, backend="numpy")
20✔
637
        if self.algorithm == 1:
20✔
638
            self.T = T
20✔
639

640
        return self
20✔
641

642
    def predict(
20✔
643
        self, X: npt.ArrayLike, n_components: Optional[int] = None
644
    ) -> npt.NDArray[np.floating]:
645
        """
646
        Predicts on `X` with `B` using `n_components` components. If `n_components` is
647
        None, then predictions are returned for each individual number of components.
648

649
        Parameters
650
        ----------
651
        X : Array of shape (N, K)
652
            Predictor variables.
653

654
        n_components : int or None, optional, default=None.
655
            Number of components in the PLS model. If None, then each individual number
656
            of components is used.
657

658
        Returns
659
        -------
660
        Y_pred : Array of shape (N, M) or (A, N, M)
661
            If `n_components` is an int, then an array of shape (N, M) with the
662
            predictions for that specific number of components is used. If
663
            `n_components` is None, returns a prediction for each number of components
664
            up to `A`.
665

666
        Raises
667
        ------
668
        NotFittedError
669
            If the model has not been fitted before calling `predict()`.
670
        """
671
        if not self.fitted_:
20✔
672
            raise NotFittedError(
10✔
673
                "This model is not fitted yet, call 'fit' with approriate arguments before 'predict'."
674
            )
675
        X, _, _ = self._get_input(copy=True, X=X)
20✔
676
        X, _ = self._center_scale_X_Y(X=X)
20✔
677

678
        if n_components is None:
20✔
679
            Y_pred = X @ self.B
20✔
680
        else:
681
            Y_pred = X @ self.B[n_components - 1]
20✔
682

683
        _, Y_pred = self._un_center_scale_X_Y(Y=Y_pred)
20✔
684
        return Y_pred
20✔
685

686
    def transform(
20✔
687
        self,
688
        X: Optional[npt.ArrayLike] = None,
689
        Y: Optional[npt.ArrayLike] = None,
690
        n_components: Optional[int] = None,
691
    ) -> Union[npt.ArrayLike, Tuple[npt.ArrayLike, npt.ArrayLike], None]:
692
        """
693
        Transforms `X` and `Y` to their respective scores using `n_components` components.
694
        If `n_components` is None, then scores for all components up to `A` are returned.
695

696
        Parameters
697
        ----------
698
        X : Array of shape (N, K) or None, optional, default=None
699
            Predictor variables.
700

701
        Y : Array of shape (N, M) or None, optional, default=None
702
            Response variables.
703

704
        n_components : int or None, optional, default=None.
705
            Number of components in the PLS model. If None, then scores for all
706
            components up to `A` are returned.
707

708
        Returns
709
        -------
710
        T : Array of shape (N, n_components) or (N, A) or None
711
            X scores of `X`. If `n_components` is an int, then the scores up to
712
            `n_components` is used. If `n_components` is None, returns scores for all
713
            components up to `A`.
714

715
        U : Array of shape (N, n_components) or (N, A) or None
716
            Y scores of `Y`. If `n_components` is an int, then the scores up to
717
            `n_components` is used. If `n_components` is None, returns scores for all
718
            components up to `A`.
719

720
        Raises
721
        ------
722
        NotFittedError
723
            If the model has not been fitted before calling `transform()`.
724

725
        See Also
726
        --------
727
        fit_transform : Fits the model and returns the scores of `X` and `Y`.
728
        inverse_transform : Reconstructs `X` and `Y` from their respective scores.
729

730
        Notes
731
        -----
732
        If multiple calls to `transform` are made with Y provided and a previously seen
733
        value of `n_components`, then self.R_Y[n_components] is only computed once and
734
        stored for future use.
735

736
        Any centering and scaling of `X`and `Y` is carried out before computation of
737
        the scores and is undone afterwards.
738
        """
739
        if not self.fitted_:
10✔
740
            raise NotFittedError(
10✔
741
                "This model is not fitted yet, call 'fit' with approriate arguments before 'transform'."
742
            )
743
        if n_components is None:
10✔
744
            n_components = self.A
10✔
745
        X, Y, _ = self._get_input(copy=True, X=X, Y=Y)
10✔
746
        X, Y = self._center_scale_X_Y(X=X, Y=Y)
10✔
747
        if X is not None:
10✔
748
            T = X @ self.R[:, :n_components]
10✔
749
        if Y is not None:
10✔
750
            U = Y @ self.R_Y[n_components]
10✔
751
        if X is not None and Y is not None:
10✔
752
            return T, U
10✔
753
        if X is not None:
10✔
754
            return T
10✔
755
        if Y is not None:
10✔
756
            return U
10✔
757
        return None
10✔
758

759
    def fit_transform(
20✔
760
        self,
761
        X: npt.ArrayLike,
762
        Y: npt.ArrayLike,
763
        A: int,
764
        sample_weight: Optional[npt.ArrayLike] = None,
765
    ) -> Tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
766
        """
767
        Fits Improved Kernel PLS Algorithm #1 on `X` and `Y` using `A` components and
768
        returns T and U which are the scores of `X` and `Y`, respectively.
769

770
        Parameters
771
        ----------
772
        X : Array of shape (N, K)
773
            Predictor variables.
774

775
        Y : Array of shape (N, M) or (N,)
776
            Response variables.
777

778
        A : int
779
            Number of components in the PLS model.
780

781
        sample_weight : Array of shape (N,) or None, optional, default=None
782
            Weights for each observation. If None, then all observations are weighted
783
            equally.
784

785
        Returns
786
        -------
787
        T : Array of shape (N, A)
788
            PLS scores matrix of X.
789

790
        U : Array of shape (N, A)
791
            PLS scores matrix of Y.
792

793
        See Also
794
        --------
795
        transform : Transforms `X` and `Y` to their respective scores.
796
        inverse_transform : Reconstructs `X` and `Y` from their respective scores.
797
        """
798
        self.fit(X=X, Y=Y, A=A, sample_weight=sample_weight)
10✔
799
        if self.algorithm == 1 and sample_weight is None:
10✔
800
            return np.copy(self.T), self.transform(Y=Y)
10✔
801
        return self.transform(X=X, Y=Y)
10✔
802

803
    def inverse_transform(
20✔
804
        self,
805
        T: Optional[npt.ArrayLike] = None,
806
        U: Optional[npt.ArrayLike] = None,
807
    ) -> Union[npt.ArrayLike, Tuple[npt.ArrayLike, npt.ArrayLike], None]:
808
        """
809
        Reconstructs `X` and `Y` from their respective scores.
810

811
        Parameters
812
        ----------
813
        T : Array of shape (N, n_X_components) or (N, A) or None, optional, default=None
814
            Scores of predictor variables (X).
815
        U : Array of shape (N, n_Y_components) or (N, A) or None, optional, default=None
816
            Scores of response variables (Y).
817

818
        Returns
819
        -------
820
        X_reconstructed : Array of shape (N, K)
821
            If `T` is not None, returns the reconstructed `X`.
822
        Y_reconstructed : Array of shape (N, M)
823
            If `U` is not None, returns the reconstructed `Y`.
824

825
        Raises
826
        ------
827
        NotFittedError
828
            If the model has not been fitted before calling `inverse_transform()`.
829

830
        See Also
831
        --------
832
        transform : Transforms `X` and `Y` to their respective scores.
833
        fit_transform : Fits the model and returns the scores of `X` and `Y`.
834
        """
835
        if not self.fitted_:
10✔
836
            raise NotFittedError(
10✔
837
                "This model is not fitted yet, call 'fit' with approriate arguments before 'inverse_transform'."
838
            )
839
        X_reconstructed = None
10✔
840
        Y_reconstructed = None
10✔
841
        T = self._convert_input_to_array(arr=T)
10✔
842
        U = self._convert_input_to_array(arr=U)
10✔
843

844
        if T is not None:
10✔
845
            X_components = T.shape[1]
10✔
846
            X_reconstructed = T @ self.P[:, :X_components].T
10✔
847

848
        if U is not None:
10✔
849
            Y_components = U.shape[1]
10✔
850
            Y_reconstructed = U @ self.Q[:, :Y_components].T
10✔
851

852
        X_reconstructed, Y_reconstructed = self._un_center_scale_X_Y(
10✔
853
            X=X_reconstructed, Y=Y_reconstructed
854
        )
855

856
        if X_reconstructed is not None and Y_reconstructed is not None:
14✔
857
            return X_reconstructed, Y_reconstructed
10✔
858
        elif X_reconstructed is not None:
10✔
859
            return X_reconstructed
10✔
860
        elif Y_reconstructed is not None:
×
861
            return Y_reconstructed
×
862
        else:
863
            return None
10✔
864

865
    def cross_validate(
20✔
866
        self,
867
        X: npt.ArrayLike,
868
        Y: npt.ArrayLike,
869
        A: int,
870
        folds: Iterable[Hashable],
871
        metric_function: Union[
872
            Callable[
873
                [
874
                    npt.NDArray[np.floating],
875
                    npt.NDArray[np.floating],
876
                    npt.NDArray[np.floating],
877
                ],
878
                Any,
879
            ],
880
            Callable[
881
                [
882
                    npt.NDArray[np.floating],
883
                    npt.NDArray[np.floating],
884
                ],
885
                Any,
886
            ],
887
        ],
888
        preprocessing_function: Union[
889
            None,
890
            Union[
891
                Callable[
892
                    [
893
                        npt.NDArray[np.floating],
894
                        npt.NDArray[np.floating],
895
                        npt.NDArray[np.floating],
896
                        npt.NDArray[np.floating],
897
                        npt.NDArray[np.floating],
898
                        npt.NDArray[np.floating],
899
                    ],
900
                    Tuple[
901
                        npt.NDArray[np.floating],
902
                        npt.NDArray[np.floating],
903
                        npt.NDArray[np.floating],
904
                        npt.NDArray[np.floating],
905
                    ],
906
                ],
907
                Callable[
908
                    [
909
                        npt.NDArray[np.floating],
910
                        npt.NDArray[np.floating],
911
                        npt.NDArray[np.floating],
912
                        npt.NDArray[np.floating],
913
                    ],
914
                    Tuple[
915
                        npt.NDArray[np.floating],
916
                        npt.NDArray[np.floating],
917
                        npt.NDArray[np.floating],
918
                        npt.NDArray[np.floating],
919
                    ],
920
                ],
921
            ],
922
        ] = None,
923
        sample_weight: Optional[npt.ArrayLike] = None,
924
        n_jobs: int = -1,
925
        verbose: int = 10,
926
    ) -> dict[Hashable, Any]:
927
        """
928
        Performs cross-validation for the Partial Least-Squares (PLS) model on given
929
        data. `preprocessing_function` will be applied before any potential centering
930
        and scaling as determined by `self.center_X`, `self.center_Y`, `self.scale_X`,
931
        and `self.scale_Y`. Any such potential centering and scaling is applied for
932
        each split using training set statistics to avoid data leakage from the
933
        validation set. If `sample_weight` are provided, then these will be provided in
934
        `preprocessing_function` and `metric_function` and used for any centering and
935
        scaling in the cross-validation procedure.
936

937
        Parameters
938
        ----------
939
        X : Array of shape (N, K)
940
            Predictor variables.
941

942
        Y : Array of shape (N, M) or (N,)
943
            Response variables.
944

945
        A : int
946
            Number of components in the PLS model.
947

948
        folds : Iterable of Hashable with N elements
949
            An iterable defining cross-validation splits. Each unique value in
950
            `folds` corresponds to a different fold.
951

952
        metric_function : Callable receiving arrays `Y_val` (N_val, M), `Y_pred`\
953
        (A, N_val, M), and, if `sample_weight` is not None, also, `weights_val` (N_val,),\
954
        and returning Any.
955
            Computes a metric based on true values `Y_val` and predicted values
956
            `Y_pred`. `Y_pred` contains a prediction for all `A` components.
957

958
        preprocessing_function : Callable or None, optional, default=None,
959
            A function that preprocesses the training and validation data for each
960
            fold. It should return preprocessed arrays for `X_train`, `Y_train`,
961
            `X_val`, and `Y_val`. If Callable, it should receive arrays `X_train`,
962
            `Y_train`, `X_val`, `Y_val`, and, if `sample_weight` is not None, also
963
            `weights_train`, and `weights_val`, and returning a Tuple of preprocessed
964
            `X_train`, `Y_train`, `X_val`, and `Y_val`.
965

966
        sample_weight : Array of shape (N,) or None, optional, default=None
967
            Weights for each observation. If None, then all observations are weighted
968
            equally.
969

970
        n_jobs : int, optional default=-1
971
            Number of parallel jobs to use. A value of -1 will use the minimum of all
972
            available cores and the number of unique values in `folds`.
973

974
        verbose : int, optional default=10
975
            Controls verbosity of parallel jobs.
976

977
        Returns
978
        -------
979
        metrics : dict of Hashable to Any
980
            A dictionary mapping each unique value in `folds` to the result of
981
            evaluating `metric_function` on the validation set corresponding to that
982
            value.
983

984
        Raises
985
        ------
986
        ValueError
987
            If `sample_weight` are provided and not all weights are non-negative.
988

989
        Notes
990
        -----
991
        This method is used to perform cross-validation on the PLS model with different
992
        data splits and evaluate its performance using user-defined metrics.
993
        """
994
        X, Y, sample_weight = self._get_input(copy=False, X=X, Y=Y, sample_weight=sample_weight)
20✔
995

996
        if sample_weight is not None:
20✔
997
            self._check_nonnegative_weights(sample_weight=sample_weight)
10✔
998
            flattened_weights = sample_weight.squeeze()
10✔
999
        else:
1000
            flattened_weights = None
20✔
1001

1002
        folds_dict = self._init_folds_dict(folds)
20✔
1003
        num_splits = len(folds_dict)
20✔
1004
        all_indices = np.arange(X.shape[0])
20✔
1005

1006
        if n_jobs == -1:
20✔
1007
            n_jobs = min(num_splits, joblib.cpu_count())
20✔
1008
        else:
1009
            n_jobs = min(num_splits, n_jobs)
10✔
1010

1011
        def worker(val_indices: npt.NDArray[np.int_]) -> Any:
20✔
1012
            train_indices = np.setdiff1d(all_indices, val_indices, assume_unique=True)
×
1013
            X_train = X[train_indices]
×
1014
            Y_train = Y[train_indices]
×
1015
            X_val = X[val_indices]
×
1016
            Y_val = Y[val_indices]
×
1017
            if flattened_weights is not None:
×
1018
                weights_train = flattened_weights[train_indices]
×
1019
                weights_val = flattened_weights[val_indices]
×
1020
            else:
1021
                weights_train = None
×
1022
                weights_val = None
×
1023

1024
            if preprocessing_function is not None:
×
1025
                if flattened_weights is not None:
×
1026
                    X_train, Y_train, X_val, Y_val = preprocessing_function(
×
1027
                        X_train, Y_train, X_val, Y_val, weights_train, weights_val
1028
                    )
1029
                else:
1030
                    X_train, Y_train, X_val, Y_val = preprocessing_function(
×
1031
                        X_train, Y_train, X_val, Y_val
1032
                    )
1033

1034
            self.fit(X_train, Y_train, A, weights_train)
×
1035
            Y_pred = self.predict(X_val, n_components=None)
×
1036
            if flattened_weights is not None:
×
1037
                return metric_function(Y_val, Y_pred, weights_val)
×
1038
            return metric_function(Y_val, Y_pred)
×
1039

1040
        metrics_list = Parallel(n_jobs=n_jobs, verbose=verbose)(
20✔
1041
            delayed(worker)(val_indices) for val_indices in folds_dict.values()
1042
        )
1043
        metrics_dict = dict(zip(folds_dict, metrics_list))
20✔
1044
        return metrics_dict
20✔
1045

1046
    def _init_folds_dict(
20✔
1047
        self, folds: Iterable[Hashable]
1048
    ) -> dict[Hashable, npt.NDArray[np.int_]]:
1049
        """
1050
        Generates a list of validation indices for each fold in `folds`.
1051

1052
        Parameters
1053
        ----------
1054
        folds : Iterable of Hashable with N elements
1055
            An iterable defining cross-validation splits. Each unique value in
1056
            `folds` corresponds to a different fold.
1057

1058
        Returns
1059
        -------
1060
        index_dict : dict of Hashable to Array
1061
            A dictionary mapping each unique value in `folds` to an array of
1062
            validation indices.
1063
        """
1064
        index_dict = {}
20✔
1065
        for i, fold in enumerate(folds):
20✔
1066
            try:
20✔
1067
                index_dict[fold].append(i)
20✔
1068
            except KeyError:
20✔
1069
                index_dict[fold] = [i]
20✔
1070
        for fold in index_dict:
20✔
1071
            index_dict[fold] = np.asarray(index_dict[fold], dtype=int)
20✔
1072
        return index_dict
20✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc