Skip to content

Commit ede4507

Browse files
authored
Merge pull request #11 from HauserGroup/actual-actual-o2pls
Actual actual o2pls
2 parents 05ab06c + e1c1a46 commit ede4507

15 files changed

Lines changed: 1799 additions & 13 deletions

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ and default-value changes will be documented here.
5656

5757
### Added
5858

59+
- `O2PLS`, a dense two-block O2PLS estimator with X/Y preprocessing, sequential
60+
X- and Y-orthogonal filtering, final joint-subspace re-estimation, and
61+
bidirectional `predict` / `predict_x` methods. The v1 implementation is dense
62+
only and exposes `coef_filtered_` for scaled, X-filtered inputs rather than a
63+
raw-space `coef_` alias.
64+
5965
- `OPLS.coef_raw_` / `OPLS.intercept_raw_`: linear coefficients on the original raw
6066
input feature space, collapsing scaling, the orthogonal filter and the predictive
6167
PLS into one map, so `X @ coef_raw_.T + intercept_raw_` reproduces `predict(X)`.

docs/api/o2pls.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# O2PLS
2+
3+
::: scikit_opls.O2PLS

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ cleaned `X`. With `n_orthogonal=0` it reduces exactly to `PLSRegression`.
1111
## Highlights
1212

1313
- [`OPLS`](api/opls.md) — regressor and supervised transformer.
14+
- [`O2PLS`](api/o2pls.md) — two-block joint/orthogonal decomposition with
15+
bidirectional prediction.
1416
- Cross-validated `n_orthogonal` selection via scikit-learn's `GridSearchCV`
1517
(see [Quickstart](quickstart.md)).
1618
- [`OPLSDA`](api/opls_da.md) — binary classifier composing `OPLS`.

docs/quickstart.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,32 @@ model.transform_orthogonal(X) # orthogonal scores
1717
model.r2y_, model.rmse_ # training-fit summaries
1818
```
1919

20+
## Two-block O2PLS
21+
22+
```python
23+
import numpy as np
24+
from scikit_opls import O2PLS
25+
26+
rng = np.random.default_rng(1)
27+
Z = rng.normal(size=(80, 2))
28+
X = Z @ rng.normal(size=(2, 20)) + 0.1 * rng.normal(size=(80, 20))
29+
Y = Z @ rng.normal(size=(2, 10)) + 0.1 * rng.normal(size=(80, 10))
30+
31+
model = O2PLS(n_components=2, n_x_orthogonal=1, n_y_orthogonal=1).fit(X, Y)
32+
model.predict(X) # predicted joint Y structure, raw Y units
33+
model.predict_x(Y) # predicted joint X structure, raw X units
34+
model.transform(X) # X-side joint scores
35+
model.transform_y(Y) # Y-side joint scores
36+
```
37+
38+
`O2PLS.coef_filtered_` maps scaled, X-orthogonally-filtered `X` to scaled
39+
predicted `Y`; it is not a raw-space sklearn `coef_` alias. The joint loadings use
40+
the O2PLS orthonormal convention, so joint loadings equal joint weights.
41+
Requested orthogonal components may be truncated with a `ConvergenceWarning` when
42+
the enlarged preliminary subspace leaves no numerically resolvable block-specific
43+
residual variation, especially when the requested component total approaches a
44+
block's rank or feature dimension.
45+
2046
## Choosing `n_orthogonal` by cross-validation
2147

2248
Use scikit-learn's `GridSearchCV` directly — `OPLS` has no path structure, so a

examples/o2pls_synthetic.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Synthetic two-block O2PLS example."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
from sklearn.metrics import r2_score
7+
8+
from scikit_opls import O2PLS
9+
10+
11+
def _make_blocks(seed: int = 0):
12+
"""Generate two blocks with joint and block-specific latent structure."""
13+
rng = np.random.default_rng(seed)
14+
n_samples = 100
15+
n_joint = 2
16+
latent, _ = np.linalg.qr(rng.normal(size=(n_samples, 4)))
17+
joint = latent[:, :n_joint] * np.array([5.0, 2.0])
18+
x_specific = latent[:, 2:3] * 4.0
19+
y_specific = latent[:, 3:4] * 3.0
20+
21+
x_basis, _ = np.linalg.qr(rng.normal(size=(12, 3)))
22+
y_basis, _ = np.linalg.qr(rng.normal(size=(8, 3)))
23+
x_joint = joint @ x_basis[:, :n_joint].T
24+
y_joint = joint @ y_basis[:, :n_joint].T
25+
X = x_joint + x_specific @ x_basis[:, 2:3].T
26+
Y = y_joint + y_specific @ y_basis[:, 2:3].T
27+
X += 0.01 * rng.normal(size=X.shape)
28+
Y += 0.01 * rng.normal(size=Y.shape)
29+
return X, Y, x_joint, y_joint
30+
31+
32+
def main() -> None:
33+
"""Fit O2PLS and report joint-structure recovery on synthetic data."""
34+
X, Y, x_joint, y_joint = _make_blocks()
35+
model = O2PLS(
36+
n_components=2,
37+
n_x_orthogonal=1,
38+
n_y_orthogonal=1,
39+
scale="center",
40+
).fit(X, Y)
41+
42+
print(f"X shape: {X.shape}")
43+
print(f"Y shape: {Y.shape}")
44+
print(f"X-orthogonal components: {model.n_x_orthogonal_}")
45+
print(f"Y-orthogonal components: {model.n_y_orthogonal_}")
46+
print(f"R2X joint / orthogonal: {model.r2x_:.3f} / {model.r2x_ortho_:.3f}")
47+
print(f"R2Y joint / orthogonal: {model.r2y_:.3f} / {model.r2y_ortho_:.3f}")
48+
print(f"Predicted joint Y R2: {r2_score(y_joint, model.predict(X)):.3f}")
49+
print(f"Predicted joint X R2: {r2_score(x_joint, model.predict_x(Y)):.3f}")
50+
51+
52+
if __name__ == "__main__":
53+
main()

src/scikit_opls/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Scikit-OPLS: Orthogonal Projections to Latent Structures for scikit-learn."""
22

3+
from scikit_opls._o2pls import O2PLS
34
from scikit_opls._opls import OPLS
45
from scikit_opls._opls_da import OPLSDA
56

67
__version__ = "0.1.0"
78

89
__all__ = [
10+
"O2PLS",
911
"OPLS",
1012
"OPLSDA",
1113
"__version__",

0 commit comments

Comments
 (0)