4343from scikit_opls ._utils import _has_nonzero_variation
4444
4545
46+ def _orthogonal_filter_matrix (
47+ x_ortho_weights : NDArray [np .float64 ],
48+ x_ortho_loadings : NDArray [np .float64 ],
49+ ) -> NDArray [np .float64 ]:
50+ """Right-side linear operator ``F`` such that ``X_filtered == X_scaled @ F``.
51+
52+ The replayed orthogonal filter applies ``X <- X - (X w_i) p_iᵀ`` for each
53+ component, i.e. right multiplication by ``I - outer(w_i, p_i)``. Composing them
54+ in order yields the single matrix equivalent of :func:`apply_orthogonal_filter`.
55+ """
56+ W = np .asarray (x_ortho_weights , dtype = np .float64 )
57+ P = np .asarray (x_ortho_loadings , dtype = np .float64 )
58+ n_features = W .shape [0 ]
59+ eye = np .eye (n_features , dtype = np .float64 )
60+ F = eye .copy ()
61+ for i in range (W .shape [1 ]):
62+ F = F @ (eye - np .outer (W [:, i ], P [:, i ]))
63+ return F
64+
65+
66+ def _compose_raw_coefficients (
67+ coef_filtered : NDArray [np .float64 ],
68+ intercept_filtered : float | NDArray [np .float64 ],
69+ x_mean : NDArray [np .float64 ],
70+ x_std : NDArray [np .float64 ],
71+ x_ortho_weights : NDArray [np .float64 ],
72+ x_ortho_loadings : NDArray [np .float64 ],
73+ ) -> tuple [NDArray [np .float64 ], NDArray [np .float64 ]]:
74+ """Collapse filtered/scaled-space PLS coefficients into raw-X coefficients.
75+
76+ The fitted prediction is linear: ``X -> (X - mean) / std -> @ F -> @ Bᶠ + b``,
77+ where ``b`` is the predictive engine's prediction offset (``pls_.predict(0)``,
78+ not ``pls_.intercept_``). With ``B_scaled = F @ Bᶠ`` and ``inv_scale = 1 / std``
79+ this reduces to ``y = X @ B_raw + (b - (mean * inv_scale) @ B_scaled)`` where
80+ ``B_raw = inv_scale[:, None] * B_scaled``.
81+ """
82+ coef_arr = np .asarray (coef_filtered , dtype = np .float64 )
83+ if coef_arr .ndim == 1 :
84+ coef_arr = coef_arr .reshape (1 , - 1 )
85+ # sklearn PLSRegression exposes coef_ as (n_targets, n_features); work with the
86+ # transpose B as (n_features, n_targets).
87+ b_filtered = coef_arr .T
88+
89+ f_matrix = _orthogonal_filter_matrix (x_ortho_weights , x_ortho_loadings )
90+ b_scaled = f_matrix @ b_filtered
91+
92+ inv_scale = 1.0 / np .asarray (x_std , dtype = np .float64 )
93+ b_raw = inv_scale [:, None ] * b_scaled
94+
95+ offset_scaled = np .asarray (x_mean , dtype = np .float64 ) * inv_scale
96+ intercept_raw = np .asarray (intercept_filtered , dtype = np .float64 ) - (
97+ offset_scaled @ b_scaled
98+ )
99+ return b_raw .T , intercept_raw
100+
101+
46102class OPLS (RegressorMixin , TransformerMixin , BaseEstimator ):
47103 """Orthogonal Projections to Latent Structures regression.
48104
@@ -76,15 +132,24 @@ class OPLS(RegressorMixin, TransformerMixin, BaseEstimator):
76132 coefficients act on the preprocessed, orthogonal-filtered space, and
77133 cannot be directly multiplied with raw input ``X``. Use ``predict(X)``
78134 for raw-input predictions.
135+ coef_raw_ : ndarray of shape (1, n_features)
136+ Linear coefficients on the original *raw* input feature space, collapsing the
137+ scaling, orthogonal filter and predictive PLS into one linear map.
138+ ``predict(X) == (X @ coef_raw_.T + intercept_raw_).ravel()`` up to
139+ floating-point tolerance. (No sklearn ``coef_`` alias is exposed.)
79140 intercept_ : float or ndarray
80141 Intercept of the underlying PLS model for predictions from the preprocessed,
81142 orthogonal-filtered X block to the original y scale.
143+ intercept_raw_ : float or ndarray
144+ Intercept paired with ``coef_raw_`` for prediction from raw input ``X``.
82145 pls_ : PLSRegression
83146 The fitted predictive engine.
84147 x_mean_, x_std_ : ndarray
85148 Centering/scaling vectors applied to ``X``.
86- r2x_, r2x_ortho_, r2y_, rmsee_ : float
87- Training-set fit summaries. ``r2x_`` is computed from the predictive PLS
149+ r2x_, r2x_ortho_, r2y_, rmse_ : float
150+ Training-set fit summaries. ``rmse_`` is the uncorrected training root mean
151+ squared error (no degrees-of-freedom correction). ``r2x_`` is computed from
152+ the predictive PLS
88153 scores/loadings on the filtered ``X`` block, relative to the preprocessed
89154 original ``X``. ``r2x_ortho_`` is computed from the removed orthogonal
90155 scores/loadings. These are diagnostic summaries, not a guaranteed exact
@@ -106,6 +171,12 @@ class OPLS(RegressorMixin, TransformerMixin, BaseEstimator):
106171 Classic OPLS uses ``n_components=1``; ``n_orthogonal=0`` reduces to ordinary
107172 ``PLSRegression``, and ``n_components>1`` is orthogonal-filtered multi-component
108173 PLS (interpret score plots / S-plots component-wise).
174+
175+ Constant and near-constant columns are retained rather than removed, preserving
176+ alignment with the input feature matrix, feature names, VIP arrays and
177+ ``coef_filtered_``. To drop them, prepend
178+ :class:`~sklearn.feature_selection.VarianceThreshold` in a
179+ :class:`~sklearn.pipeline.Pipeline`.
109180 """
110181
111182 n_features_in_ : int
@@ -121,12 +192,14 @@ class OPLS(RegressorMixin, TransformerMixin, BaseEstimator):
121192 x_scores_ : NDArray [np .float64 ]
122193 y_loadings_ : NDArray [np .float64 ]
123194 coef_filtered_ : NDArray [np .float64 ]
195+ coef_raw_ : NDArray [np .float64 ]
124196 intercept_ : float | NDArray [np .float64 ]
197+ intercept_raw_ : float | NDArray [np .float64 ]
125198 pls_ : PLSRegression
126199 r2x_ : float
127200 r2x_ortho_ : float
128201 r2y_ : float
129- rmsee_ : float
202+ rmse_ : float
130203 _n_features_out : int
131204
132205 _parameter_constraints : dict = {
@@ -226,14 +299,29 @@ def fit(self, X: ArrayLike, y: ArrayLike) -> OPLS:
226299 self .y_loadings_ = self .pls_ .y_loadings_
227300 self .coef_filtered_ = self .pls_ .coef_
228301 self .intercept_ = self .pls_ .intercept_
302+ # The engine's prediction offset is predict(0), not intercept_: sklearn's
303+ # PLSRegression centers the filtered X internally, so predict(Z) ==
304+ # Z @ coef_.T + predict(0) but intercept_ omits that centering term (it only
305+ # coincides with predict(0) when the filtered X is already centered).
306+ engine_offset = self .pls_ .predict (
307+ np .zeros ((1 , X_filtered .shape [1 ]), dtype = np .float64 )
308+ ).ravel ()
309+ self .coef_raw_ , self .intercept_raw_ = _compose_raw_coefficients (
310+ self .coef_filtered_ ,
311+ engine_offset ,
312+ self .x_mean_ ,
313+ self .x_std_ ,
314+ self .x_ortho_weights_ ,
315+ self .x_ortho_loadings_ ,
316+ )
229317
230318 y_fit = self .pls_ .predict (X_filtered )
231319 self .r2x_ = explained_x_variance (Xs , self .x_scores_ , self .x_loadings_ )
232320 self .r2x_ortho_ = explained_x_variance (
233321 Xs , self .x_ortho_scores_ , self .x_ortho_loadings_
234322 )
235323 self .r2y_ = float (r2_score (y , y_fit ))
236- self .rmsee_ = float (root_mean_squared_error (y , y_fit ))
324+ self .rmse_ = float (root_mean_squared_error (y , y_fit ))
237325 return self
238326
239327 def predict (self , X : ArrayLike ) -> NDArray [np .float64 ]:
@@ -288,13 +376,37 @@ def transform_orthogonal(self, X: ArrayLike) -> NDArray[np.float64]:
288376 check_is_fitted (self )
289377 return self ._filter (X )[1 ]
290378
379+ def filter_transform (self , X : ArrayLike ) -> NDArray [np .float64 ]:
380+ """Return ``X`` after preprocessing and orthogonal filtering.
381+
382+ This is the matrix actually passed to the predictive PLS engine, so
383+ ``self.pls_.predict(self.filter_transform(X))`` matches ``self.predict(X)``
384+ (up to output shape). The result is in the preprocessed, orthogonal-filtered
385+ space, **not** on the raw input scale. With ``n_orthogonal=0`` it is just the
386+ preprocessed ``X``.
387+
388+ Parameters
389+ ----------
390+ X : array-like of shape (n_samples, n_features)
391+ Samples to preprocess and filter.
392+
393+ Returns
394+ -------
395+ X_filtered : ndarray of shape (n_samples, n_features)
396+ Preprocessed ``X`` with the fitted orthogonal variation removed.
397+ """
398+ check_is_fitted (self )
399+ return self ._filter (X )[0 ]
400+
291401 @property
292402 def vip_ (self ) -> NDArray [np .float64 ]:
293- """Predictive VIP per feature (Galindo-Prieto 2014) ; ndarray (n_features,).
403+ """Predictive VIP per feature; ndarray (n_features,).
294404
295- Variable Importance in Projection of the predictive block, normalised so
405+ Standard PLS Variable Importance in Projection computed from the predictive
406+ model fitted on the orthogonally filtered ``X``, normalised so
296407 ``sum(vip_**2) == n_features``. Computed lazily on first access from the
297- fitted weights.
408+ fitted weights. Defined in the style of Galindo-Prieto et al. (2014); not
409+ intended to reproduce ropls VIP values exactly.
298410 """
299411 check_is_fitted (self )
300412 if not hasattr (self , "_vip_" ):
0 commit comments