scikit-opls 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scikit_opls/__init__.py +22 -0
- scikit_opls/_inspection.py +240 -0
- scikit_opls/_o2pls.py +569 -0
- scikit_opls/_o2pls_core.py +527 -0
- scikit_opls/_opls.py +801 -0
- scikit_opls/_opls_da.py +340 -0
- scikit_opls/_orthogonal.py +283 -0
- scikit_opls/_preprocessing.py +85 -0
- scikit_opls/_utils.py +69 -0
- scikit_opls/plotting.py +475 -0
- scikit_opls/py.typed +1 -0
- scikit_opls/validation.py +245 -0
- scikit_opls/version.py +8 -0
- scikit_opls-0.1.0.dist-info/METADATA +294 -0
- scikit_opls-0.1.0.dist-info/RECORD +17 -0
- scikit_opls-0.1.0.dist-info/WHEEL +4 -0
- scikit_opls-0.1.0.dist-info/licenses/LICENSE +21 -0
scikit_opls/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Scikit-OPLS: Orthogonal Projections to Latent Structures for scikit-learn."""
|
|
2
|
+
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as _version
|
|
6
|
+
|
|
7
|
+
from scikit_opls._o2pls import O2PLS
|
|
8
|
+
from scikit_opls._opls import OPLS
|
|
9
|
+
from scikit_opls._opls_da import OPLSDA
|
|
10
|
+
from scikit_opls.version import __version__ as _static_version
|
|
11
|
+
|
|
12
|
+
__version__ = _static_version
|
|
13
|
+
|
|
14
|
+
with suppress(PackageNotFoundError):
|
|
15
|
+
__version__ = _version("scikit-opls")
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"O2PLS",
|
|
19
|
+
"OPLS",
|
|
20
|
+
"OPLSDA",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Stateless math helpers for OPLS explained-variance and VIP diagnostics.
|
|
2
|
+
|
|
3
|
+
Private module — not part of the public API. Used by the fitted attributes of
|
|
4
|
+
[`OPLS`][scikit_opls.OPLS] and [`OPLSDA`][scikit_opls.OPLSDA]. VIP scores are
|
|
5
|
+
normalized so ``sum(vip**2) == n_features`` when component importance is
|
|
6
|
+
positive; degenerate inputs return zeros.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
_EPS = np.finfo(np.float64).eps
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _safe_total_ss(X: NDArray[np.float64]) -> float:
|
|
18
|
+
"""Total sum of squares with a nonzero guard."""
|
|
19
|
+
total = float(np.sum(np.asarray(X, dtype=np.float64) ** 2))
|
|
20
|
+
return max(total, _EPS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_x_scores_loadings(
|
|
24
|
+
X: NDArray[np.float64],
|
|
25
|
+
scores: NDArray[np.float64],
|
|
26
|
+
loadings: NDArray[np.float64],
|
|
27
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
28
|
+
X = np.asarray(X, dtype=np.float64)
|
|
29
|
+
scores = np.asarray(scores, dtype=np.float64)
|
|
30
|
+
loadings = np.asarray(loadings, dtype=np.float64)
|
|
31
|
+
if X.ndim != 2 or scores.ndim != 2 or loadings.ndim != 2:
|
|
32
|
+
raise ValueError("X, scores and loadings must all be 2D arrays.")
|
|
33
|
+
if scores.shape[0] != X.shape[0]:
|
|
34
|
+
raise ValueError("scores must have one row per sample of X.")
|
|
35
|
+
if loadings.shape[0] != X.shape[1]:
|
|
36
|
+
raise ValueError("loadings must have one row per feature of X.")
|
|
37
|
+
if scores.shape[1] != loadings.shape[1]:
|
|
38
|
+
raise ValueError("scores and loadings must have the same number of components.")
|
|
39
|
+
if not np.all(np.isfinite(X)):
|
|
40
|
+
raise ValueError("X must contain only finite values.")
|
|
41
|
+
if not np.all(np.isfinite(scores)):
|
|
42
|
+
raise ValueError("scores must contain only finite values.")
|
|
43
|
+
if not np.all(np.isfinite(loadings)):
|
|
44
|
+
raise ValueError("loadings must contain only finite values.")
|
|
45
|
+
return X, scores, loadings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def component_explained_x_variance(
|
|
49
|
+
X: NDArray[np.float64],
|
|
50
|
+
scores: NDArray[np.float64],
|
|
51
|
+
loadings: NDArray[np.float64],
|
|
52
|
+
) -> NDArray[np.float64]:
|
|
53
|
+
"""Per-component ``SS(t_i @ p_i.T) / SS(X)`` for fitted arrays."""
|
|
54
|
+
X, scores, loadings = _validate_x_scores_loadings(X, scores, loadings)
|
|
55
|
+
total = _safe_total_ss(X)
|
|
56
|
+
out = np.empty(scores.shape[1], dtype=np.float64)
|
|
57
|
+
for i in range(scores.shape[1]):
|
|
58
|
+
Xi = scores[:, [i]] @ loadings[:, [i]].T
|
|
59
|
+
out[i] = np.sum(Xi**2) / total
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def component_r2y_from_scores(
|
|
64
|
+
y: NDArray[np.float64],
|
|
65
|
+
scores: NDArray[np.float64],
|
|
66
|
+
y_loadings: NDArray[np.float64],
|
|
67
|
+
) -> NDArray[np.float64]:
|
|
68
|
+
"""Per-component Y R² increments from PLS scores/loadings.
|
|
69
|
+
|
|
70
|
+
This follows the PLS deflation view: each component explains part of Y through
|
|
71
|
+
``t_i q_i.T``.
|
|
72
|
+
"""
|
|
73
|
+
y_arr = np.asarray(y, dtype=np.float64)
|
|
74
|
+
if y_arr.ndim == 1:
|
|
75
|
+
y_arr = y_arr.reshape(-1, 1)
|
|
76
|
+
if y_arr.ndim != 2:
|
|
77
|
+
raise ValueError(f"y must be 1D or 2D, got shape {y_arr.shape}.")
|
|
78
|
+
T = np.asarray(scores, dtype=np.float64)
|
|
79
|
+
if T.ndim != 2:
|
|
80
|
+
raise ValueError(f"scores must be 2D, got shape {T.shape}.")
|
|
81
|
+
Q = np.asarray(y_loadings, dtype=np.float64)
|
|
82
|
+
if Q.ndim == 1:
|
|
83
|
+
# A 1D y_loadings is one value per component (single target), matching the
|
|
84
|
+
# (n_targets, n_components) convention used elsewhere (predictive_vip).
|
|
85
|
+
Q = Q.reshape(1, -1)
|
|
86
|
+
elif Q.ndim != 2:
|
|
87
|
+
raise ValueError(f"y_loadings must be 1D or 2D, got shape {Q.shape}.")
|
|
88
|
+
if T.shape[0] != y_arr.shape[0]:
|
|
89
|
+
raise ValueError("scores must have one row per sample of y.")
|
|
90
|
+
if Q.shape[1] != T.shape[1]:
|
|
91
|
+
raise ValueError("y_loadings must have one column per component.")
|
|
92
|
+
if not np.all(np.isfinite(y_arr)):
|
|
93
|
+
raise ValueError("y must contain only finite values.")
|
|
94
|
+
if not np.all(np.isfinite(T)):
|
|
95
|
+
raise ValueError("scores must contain only finite values.")
|
|
96
|
+
if not np.all(np.isfinite(Q)):
|
|
97
|
+
raise ValueError("y_loadings must contain only finite values.")
|
|
98
|
+
total = _safe_total_ss(y_arr - y_arr.mean(axis=0, keepdims=True))
|
|
99
|
+
out = np.empty(T.shape[1], dtype=np.float64)
|
|
100
|
+
for i in range(T.shape[1]):
|
|
101
|
+
Yi = T[:, [i]] @ Q[:, [i]].T
|
|
102
|
+
out[i] = np.sum(Yi**2) / total
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def explained_x_variance(
|
|
107
|
+
X: NDArray[np.float64],
|
|
108
|
+
scores: NDArray[np.float64],
|
|
109
|
+
loadings: NDArray[np.float64],
|
|
110
|
+
) -> float:
|
|
111
|
+
"""Nominal ``SS(T @ P.T) / SS(X)``; not clipped to ``[0, 1]``."""
|
|
112
|
+
X, scores, loadings = _validate_x_scores_loadings(X, scores, loadings)
|
|
113
|
+
if scores.shape[1] == 0:
|
|
114
|
+
return 0.0
|
|
115
|
+
total = float(np.sum(X**2))
|
|
116
|
+
if total <= 0.0:
|
|
117
|
+
return 0.0
|
|
118
|
+
return float(np.sum((scores @ loadings.T) ** 2) / total)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _weighted_vip(
|
|
122
|
+
weights: NDArray[np.float64], ss_per_component: NDArray[np.float64]
|
|
123
|
+
) -> NDArray[np.float64]:
|
|
124
|
+
"""Return VIP scores from component weights and importance values.
|
|
125
|
+
|
|
126
|
+
Zeros for empty components or zero total importance.
|
|
127
|
+
"""
|
|
128
|
+
if weights.ndim != 2:
|
|
129
|
+
raise ValueError(f"weights must be 2D, got shape {weights.shape}.")
|
|
130
|
+
n_features, n_components = weights.shape
|
|
131
|
+
ss = np.asarray(ss_per_component, dtype=np.float64)
|
|
132
|
+
if ss.shape != (n_components,):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"ss_per_component must have shape ({n_components},), got {ss.shape}."
|
|
135
|
+
)
|
|
136
|
+
if not np.all(np.isfinite(weights)):
|
|
137
|
+
raise ValueError("weights must be finite.")
|
|
138
|
+
if not np.all(np.isfinite(ss)):
|
|
139
|
+
raise ValueError("ss_per_component must be finite.")
|
|
140
|
+
if np.any(ss < -_EPS):
|
|
141
|
+
raise ValueError("ss_per_component must be non-negative.")
|
|
142
|
+
ss = np.maximum(ss, 0.0)
|
|
143
|
+
if n_components == 0:
|
|
144
|
+
return np.zeros(n_features, dtype=np.float64)
|
|
145
|
+
total = float(ss.sum())
|
|
146
|
+
if total <= 0.0:
|
|
147
|
+
return np.zeros(n_features, dtype=np.float64)
|
|
148
|
+
# Normalize each component's weights before squaring so VIP is driven by the
|
|
149
|
+
# component importance weights, not arbitrary scaling of the weight columns.
|
|
150
|
+
norms = np.linalg.norm(weights, axis=0, keepdims=True)
|
|
151
|
+
unit = weights / np.where(norms < _EPS, 1.0, norms)
|
|
152
|
+
contributions = (unit**2) @ ss
|
|
153
|
+
return np.sqrt(n_features * contributions / total)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def predictive_vip(
|
|
157
|
+
x_weights: NDArray[np.float64],
|
|
158
|
+
x_scores: NDArray[np.float64],
|
|
159
|
+
y_loadings: NDArray[np.float64],
|
|
160
|
+
) -> NDArray[np.float64]:
|
|
161
|
+
"""Return predictive PLS VIP from weights, scores and Y-loadings."""
|
|
162
|
+
if x_weights.ndim != 2:
|
|
163
|
+
raise ValueError(f"x_weights must be 2D, got shape {x_weights.shape}.")
|
|
164
|
+
if x_scores.ndim != 2:
|
|
165
|
+
raise ValueError(f"x_scores must be 2D, got shape {x_scores.shape}.")
|
|
166
|
+
if not np.all(np.isfinite(x_weights)):
|
|
167
|
+
raise ValueError("x_weights must contain only finite values.")
|
|
168
|
+
if not np.all(np.isfinite(x_scores)):
|
|
169
|
+
raise ValueError("x_scores must contain only finite values.")
|
|
170
|
+
|
|
171
|
+
_, n_components = x_weights.shape
|
|
172
|
+
if x_scores.shape[1] != n_components:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"x_scores must have the same number of components as x_weights."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
y_loadings = np.asarray(y_loadings, dtype=np.float64)
|
|
178
|
+
if y_loadings.ndim == 1:
|
|
179
|
+
if y_loadings.shape != (n_components,):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"y_loadings must have shape ({n_components},), got {y_loadings.shape}."
|
|
182
|
+
)
|
|
183
|
+
y_loadings_2d = y_loadings.reshape(1, -1)
|
|
184
|
+
elif y_loadings.ndim == 2:
|
|
185
|
+
if y_loadings.shape[1] != n_components:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"y_loadings must have one column per predictive component."
|
|
188
|
+
)
|
|
189
|
+
y_loadings_2d = y_loadings
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError(f"y_loadings must be 1D or 2D, got shape {y_loadings.shape}.")
|
|
192
|
+
if not np.all(np.isfinite(y_loadings_2d)):
|
|
193
|
+
raise ValueError("y_loadings must contain only finite values.")
|
|
194
|
+
|
|
195
|
+
# Standard PLS VIP weights each component by the Y sum of squares explained by
|
|
196
|
+
# that component: loading strength times score energy.
|
|
197
|
+
ssy = np.sum(y_loadings_2d**2, axis=0) * np.sum(x_scores**2, axis=0)
|
|
198
|
+
return _weighted_vip(x_weights, ssy)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def orthogonal_vip(
|
|
202
|
+
x_ortho_weights: NDArray[np.float64],
|
|
203
|
+
x_ortho_scores: NDArray[np.float64],
|
|
204
|
+
x_ortho_loadings: NDArray[np.float64],
|
|
205
|
+
) -> NDArray[np.float64]:
|
|
206
|
+
"""Return orthogonal VIP weighted by removed X variance."""
|
|
207
|
+
if x_ortho_weights.ndim != 2:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"x_ortho_weights must be 2D, got shape {x_ortho_weights.shape}."
|
|
210
|
+
)
|
|
211
|
+
if x_ortho_scores.ndim != 2:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"x_ortho_scores must be 2D, got shape {x_ortho_scores.shape}."
|
|
214
|
+
)
|
|
215
|
+
if x_ortho_loadings.ndim != 2:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"x_ortho_loadings must be 2D, got shape {x_ortho_loadings.shape}."
|
|
218
|
+
)
|
|
219
|
+
if not np.all(np.isfinite(x_ortho_weights)):
|
|
220
|
+
raise ValueError("x_ortho_weights must contain only finite values.")
|
|
221
|
+
if not np.all(np.isfinite(x_ortho_scores)):
|
|
222
|
+
raise ValueError("x_ortho_scores must contain only finite values.")
|
|
223
|
+
if not np.all(np.isfinite(x_ortho_loadings)):
|
|
224
|
+
raise ValueError("x_ortho_loadings must contain only finite values.")
|
|
225
|
+
|
|
226
|
+
n_features, n_components = x_ortho_weights.shape
|
|
227
|
+
if x_ortho_scores.shape[1] != n_components:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
"x_ortho_scores must have the same number of components as x_ortho_weights."
|
|
230
|
+
)
|
|
231
|
+
if x_ortho_loadings.shape != (n_features, n_components):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
"x_ortho_loadings must have shape "
|
|
234
|
+
f"({n_features}, {n_components}), got {x_ortho_loadings.shape}."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Orthogonal VIP mirrors predictive VIP, but component importance comes from
|
|
238
|
+
# removed X sum of squares instead of explained Y sum of squares.
|
|
239
|
+
ssx = np.sum(x_ortho_scores**2, axis=0) * np.sum(x_ortho_loadings**2, axis=0)
|
|
240
|
+
return _weighted_vip(x_ortho_weights, ssx)
|