Skip to content

Commit 05ab06c

Browse files
authored
Merge pull request #10 from HauserGroup/targeted-improvements
Tiny Targeted improvements
2 parents c1506d1 + 382ebca commit 05ab06c

10 files changed

Lines changed: 280 additions & 12 deletions

File tree

CHANGES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ and default-value changes will be documented here.
2121

2222
### Changed (breaking, pre-1.0)
2323

24+
- Renamed the fitted attribute `rmsee_` to `rmse_` (uncorrected training root mean
25+
squared error). The old name implied a degrees-of-freedom-corrected calibration
26+
error, which it never computed; no alias is kept (pre-1.0).
2427
- Removed `OPLSDA`'s `probability` parameter and its in-sample Platt calibration
2528
(`predict_proba`, `raw_score`). `OPLSDA` is now a clean score classifier:
2629
`decision_function` returns the raw signed OPLS regression output and `predict`
@@ -53,6 +56,17 @@ and default-value changes will be documented here.
5356

5457
### Added
5558

59+
- `OPLS.coef_raw_` / `OPLS.intercept_raw_`: linear coefficients on the original raw
60+
input feature space, collapsing scaling, the orthogonal filter and the predictive
61+
PLS into one map, so `X @ coef_raw_.T + intercept_raw_` reproduces `predict(X)`.
62+
No bare sklearn `coef_` alias is exposed (it would be the raw-space coefficient,
63+
not the engine's filtered-space one).
64+
65+
- `OPLS.filter_transform(X)` returns the preprocessed, orthogonal-filtered `X`
66+
actually passed to the predictive PLS engine (so
67+
`pls_.predict(filter_transform(X))` matches `predict(X)`); useful for diagnostics
68+
and downstream modelling.
69+
5670
- Zensical documentation site (`zensical.toml`, mkdocstrings, numpy docstring style)
5771
with a `zensical build` CI gate and a GitHub Pages (Actions) deploy workflow.
5872

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,18 @@ model = OPLS(n_components=1, n_orthogonal=2, scale="standard").fit(X, y)
4040
model.predict(X) # predictions
4141
model.transform(X) # predictive scores
4242
model.transform_orthogonal(X) # orthogonal scores
43+
model.filter_transform(X) # preprocessed, orthogonal-filtered X fed to the engine
4344
model.r2x_, model.r2y_ # fit summaries
4445
model.vip_ # variable importance (predictive), lazy property
4546
```
4647

48+
The whole fitted pipeline (scaling → orthogonal filter → predictive PLS) is linear,
49+
so it collapses to coefficients on the raw input space:
50+
51+
```python
52+
y_hat = (X @ model.coef_raw_.T + model.intercept_raw_).ravel() # == model.predict(X)
53+
```
54+
4755
Let cross-validated Q2 choose the number of orthogonal components with
4856
scikit-learn's `GridSearchCV` — no bespoke estimator needed (`scoring=None` gives
4957
out-of-fold R2, which equals Q2 for `OPLS`):

docs/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ model = OPLS(n_components=1, n_orthogonal=2).fit(X, y)
1414
model.predict(X) # predicted y
1515
model.transform(X) # predictive scores
1616
model.transform_orthogonal(X) # orthogonal scores
17-
model.r2y_, model.rmsee_ # training-fit summaries
17+
model.r2y_, model.rmse_ # training-fit summaries
1818
```
1919

2020
## Choosing `n_orthogonal` by cross-validation

src/scikit_opls/_inspection.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
``vip_`` / ``ortho_vip_`` properties on :class:`~scikit_opls.OPLS` and
55
:class:`~scikit_opls.OPLSDA`; these functions compute them from fitted weights.
66
7-
VIP (Variable Importance in Projection) follows Galindo-Prieto et al. (2014):
7+
VIP (Variable Importance in Projection) is defined in the style of Galindo-Prieto
8+
et al. (2014); these are not intended to reproduce ropls VIP values exactly:
89
9-
- predictive VIP weights each predictive component by the Y variance it explains;
10-
- orthogonal VIP weights each orthogonal component by the X variance it explains.
10+
- predictive VIP is the standard PLS VIP of the predictive model fitted on the
11+
orthogonally filtered X, weighting each component by the Y variance it explains;
12+
- orthogonal VIP is an X-variance-weighted score for the removed orthogonal
13+
components, weighting each component by the X variance it explains.
1114
1215
For non-empty blocks with positive explained variance, VIP is normalized so that
1316
sum(vip**2) == n_features. Empty or degenerate blocks return zeros.

src/scikit_opls/_opls.py

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,62 @@
4343
from 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+
46102
class 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_"):

src/scikit_opls/_orthogonal.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ def opls_filter(X: ArrayLike, Y: ArrayLike, n_components: int) -> OrthogonalComp
269269
270270
Notes
271271
-----
272+
The predictive direction is computed once from the original ``(X, Y)`` and
273+
reused for every orthogonal component. For univariate ``Y`` this is exact, not a
274+
shortcut: each orthogonal score is constructed exactly orthogonal to ``Y``, so
275+
removing it leaves ``Xᵀy`` (hence the predictive direction ``w_p ∝ Xᵀy``)
276+
unchanged. Recomputing ``w_p`` from each deflated residual would yield the same
277+
direction, so the canonical Trygg-Wold OPLS algorithm coincides with this
278+
fixed-direction filter for single-response OPLS.
279+
272280
When ``n_components=0``, ``Y`` is not inspected because no predictive direction
273281
is needed; the returned predictive weight is a zero vector.
274282
"""

tests/test_opls.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,29 @@ def test_opls_n_components_exceeds_post_filter_rank_raises():
308308
ValueError, match="exceeds the numerical rank of X after orthogonal filtering"
309309
):
310310
OPLS(n_components=3, n_orthogonal=1).fit(X, y)
311+
312+
313+
def test_filter_transform_matches_predict_path():
314+
"""predict(X) == pls_.predict(filter_transform(X)), the matrix fed to the engine."""
315+
X, y = _regression_data(seed=3)
316+
model = OPLS(n_components=1, n_orthogonal=2).fit(X, y)
317+
Xf = model.filter_transform(X)
318+
assert_allclose(model.pls_.predict(Xf).ravel(), model.predict(X), atol=1e-10)
319+
320+
321+
def test_filter_transform_zero_orthogonal_is_preprocessed_x():
322+
"""With n_orthogonal=0 the filter is a no-op: just the preprocessed X."""
323+
from scikit_opls._preprocessing import apply_scaling
324+
325+
X, y = _regression_data(seed=4)
326+
model = OPLS(n_components=1, n_orthogonal=0).fit(X, y)
327+
expected = apply_scaling(np.asarray(X, dtype=float), model.x_mean_, model.x_std_)
328+
assert_allclose(model.filter_transform(X), expected, atol=1e-12)
329+
330+
331+
def test_filter_transform_requires_fit():
332+
from sklearn.exceptions import NotFittedError
333+
334+
X, _ = _regression_data(seed=5)
335+
with pytest.raises(NotFittedError):
336+
OPLS().filter_transform(X)

tests/test_opls_coefficients.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Raw-space OPLS coefficients reproduce predict() as a single linear map."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
from sklearn.utils._testing import assert_allclose
8+
9+
from scikit_opls import OPLS, OPLSDA
10+
11+
12+
@pytest.mark.parametrize("scale", ["none", "center", "pareto", "standard"])
13+
@pytest.mark.parametrize("n_orthogonal", [0, 1, 2])
14+
def test_raw_coefficients_reproduce_predict(scale, n_orthogonal):
15+
rng = np.random.default_rng(0)
16+
X = rng.normal(size=(50, 8))
17+
beta = np.array([1.5, -0.7, 0.4, 0.0, 0.0, 0.2, 0.0, -0.3])
18+
y = X @ beta + 0.1 * rng.normal(size=50)
19+
20+
model = OPLS(n_components=1, n_orthogonal=n_orthogonal, scale=scale).fit(X, y)
21+
22+
y_predict = model.predict(X)
23+
y_linear = (X @ model.coef_raw_.T + model.intercept_raw_).ravel()
24+
assert_allclose(y_predict, y_linear, rtol=1e-10, atol=1e-10)
25+
26+
27+
@pytest.mark.parametrize("scale", ["none", "center", "pareto", "standard"])
28+
@pytest.mark.parametrize("n_orthogonal", [0, 1, 2])
29+
def test_raw_coefficients_reproduce_predict_on_new_data(scale, n_orthogonal):
30+
rng = np.random.default_rng(1)
31+
X = rng.normal(size=(60, 10))
32+
X_new = rng.normal(size=(17, 10))
33+
beta = rng.normal(size=10)
34+
y = X @ beta + 0.2 * rng.normal(size=60)
35+
36+
model = OPLS(n_components=1, n_orthogonal=n_orthogonal, scale=scale).fit(X, y)
37+
38+
y_predict = model.predict(X_new)
39+
y_linear = (X_new @ model.coef_raw_.T + model.intercept_raw_).ravel()
40+
assert_allclose(y_predict, y_linear, rtol=1e-10, atol=1e-10)
41+
42+
43+
def test_raw_coefficients_reproduce_predict_with_multiple_predictive_components():
44+
rng = np.random.default_rng(2)
45+
X = rng.normal(size=(80, 12))
46+
beta = rng.normal(size=12)
47+
y = X @ beta + 0.2 * rng.normal(size=80)
48+
49+
model = OPLS(n_components=2, n_orthogonal=1, scale="standard").fit(X, y)
50+
51+
y_predict = model.predict(X)
52+
y_linear = (X @ model.coef_raw_.T + model.intercept_raw_).ravel()
53+
assert_allclose(y_predict, y_linear, rtol=1e-10, atol=1e-10)
54+
55+
56+
def test_raw_coefficients_shape_and_no_coef_alias():
57+
rng = np.random.default_rng(4)
58+
X = rng.normal(size=(40, 6))
59+
y = X[:, 0] - 0.5 * X[:, 1] + 0.1 * rng.normal(size=40)
60+
61+
model = OPLS(n_components=1, n_orthogonal=1).fit(X, y)
62+
63+
assert model.coef_raw_.shape == (1, X.shape[1])
64+
# The raw coefficients are deliberately not exposed as a bare sklearn coef_.
65+
assert not hasattr(model, "coef_")
66+
67+
68+
def test_oplsda_inner_opls_has_raw_coefficients():
69+
rng = np.random.default_rng(3)
70+
X = rng.normal(size=(40, 6))
71+
y = np.array([0, 1] * 20)
72+
73+
clf = OPLSDA(n_components=1, n_orthogonal=1, scale="standard").fit(X, y)
74+
75+
assert hasattr(clf.opls_, "coef_raw_")
76+
assert hasattr(clf.opls_, "intercept_raw_")
77+
78+
score_predict = clf.decision_function(X)
79+
score_linear = (X @ clf.opls_.coef_raw_.T + clf.opls_.intercept_raw_).ravel()
80+
assert_allclose(score_predict, score_linear, rtol=1e-10, atol=1e-10)

0 commit comments

Comments
 (0)