orangeplsda 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.
- orangeplsda/__init__.py +10 -0
- orangeplsda/oplsda_learner.py +324 -0
- orangeplsda/plsda_learner.py +120 -0
- orangeplsda/widgets/__init__.py +8 -0
- orangeplsda/widgets/icons/OPLSDA.svg +11 -0
- orangeplsda/widgets/icons/PLSDA.svg +11 -0
- orangeplsda/widgets/owoplsda.py +313 -0
- orangeplsda/widgets/owplsda.py +192 -0
- orangeplsda-0.1.0.dist-info/METADATA +304 -0
- orangeplsda-0.1.0.dist-info/RECORD +13 -0
- orangeplsda-0.1.0.dist-info/WHEEL +5 -0
- orangeplsda-0.1.0.dist-info/entry_points.txt +2 -0
- orangeplsda-0.1.0.dist-info/top_level.txt +1 -0
orangeplsda/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
orangeplsda - PLS-DA (Partial Least Squares Discriminant Analysis) for Orange3.
|
|
3
|
+
|
|
4
|
+
Provides a classification learner and widget for PLS-DA.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .plsda_learner import PLSDALearner, PLSDAModel
|
|
8
|
+
from .oplsda_learner import OPLSDALearner, OPLSDAModel
|
|
9
|
+
|
|
10
|
+
__all__ = ["PLSDALearner", "PLSDAModel", "OPLSDALearner", "OPLSDAModel"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OPLS-DA (Orthogonal Partial Least Squares Discriminant Analysis)
|
|
3
|
+
learner and model.
|
|
4
|
+
|
|
5
|
+
OPLS-DA separates predictive (class-correlated) from orthogonal
|
|
6
|
+
(class-uncorrelated) variation in X, producing clearer interpretation
|
|
7
|
+
for biomarker discovery. Implements the Trygg & Wold (2002) algorithm.
|
|
8
|
+
|
|
9
|
+
The model fits orthogonal components via NIPALS-style deflation, then
|
|
10
|
+
extracts the predictive component. The S-Plot (Wiklund et al., 2008)
|
|
11
|
+
is derived from the predictive loadings and correlations.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from Orange.base import Learner
|
|
16
|
+
from Orange.classification.base_classification import (
|
|
17
|
+
SklLearnerClassification, SklModelClassification,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["OPLSDAModel", "OPLDALearner"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OPLSDAModel(SklModelClassification):
|
|
24
|
+
"""OPLS-DA classification model.
|
|
25
|
+
|
|
26
|
+
Wraps the fitted OPLS parameters (predictive + orthogonal) and
|
|
27
|
+
provides predictions, projections, and S-Plot data.
|
|
28
|
+
"""
|
|
29
|
+
supports_multiclass = True
|
|
30
|
+
|
|
31
|
+
def __init__(self, skl_model):
|
|
32
|
+
# The "skl_model" attribute is used by SklModel for domain
|
|
33
|
+
# handling, but we don't use sklearn here — store it as None
|
|
34
|
+
# and put our real parameters in custom attributes.
|
|
35
|
+
super().__init__(skl_model)
|
|
36
|
+
self.n_predictive = 0
|
|
37
|
+
self.n_ortho = 0
|
|
38
|
+
# predictive
|
|
39
|
+
self.w_pred = None # (n_features, 1)
|
|
40
|
+
self.p_pred = None # (n_features, 1)
|
|
41
|
+
self.t_pred_mean = 0.0 # mean of training t_pred
|
|
42
|
+
self.t_pred_std = 1.0
|
|
43
|
+
# orthogonal per component
|
|
44
|
+
self.w_ortho = [] # list of (n_features, 1) vectors
|
|
45
|
+
self.p_ortho = [] # list of (n_features, 1) vectors
|
|
46
|
+
# scaling
|
|
47
|
+
self.x_mean = None
|
|
48
|
+
self.x_std = None
|
|
49
|
+
self.y_mean = None
|
|
50
|
+
self.y_std = None
|
|
51
|
+
self.scaled = False
|
|
52
|
+
# class info
|
|
53
|
+
self.classes = None
|
|
54
|
+
self.n_classes = 0
|
|
55
|
+
# raw Y predictions for probability computation
|
|
56
|
+
self.y_pred_model = None # PLS regression model (fitted on deflated X)
|
|
57
|
+
|
|
58
|
+
def _deflate(self, X):
|
|
59
|
+
"""Apply orthogonal deflation to new X."""
|
|
60
|
+
Xd = X.copy()
|
|
61
|
+
for i in range(self.n_ortho):
|
|
62
|
+
t_o = Xd @ self.w_ortho[i].ravel()
|
|
63
|
+
Xd -= np.outer(t_o, self.p_ortho[i].ravel())
|
|
64
|
+
return Xd
|
|
65
|
+
|
|
66
|
+
def _predict_raw(self, X):
|
|
67
|
+
"""Compute raw Y scores (predictive Y response)."""
|
|
68
|
+
if self.scaled and self.x_mean is not None:
|
|
69
|
+
Xc = (X - self.x_mean) / self.x_std
|
|
70
|
+
else:
|
|
71
|
+
Xc = X.copy()
|
|
72
|
+
# deflate
|
|
73
|
+
Xd = self._deflate(Xc)
|
|
74
|
+
# predictive score
|
|
75
|
+
t_pred = Xd @ self.w_pred.ravel()
|
|
76
|
+
# project through the PLS model
|
|
77
|
+
y_pred = self.y_pred_model.predict(Xd)
|
|
78
|
+
# unscale Y
|
|
79
|
+
if self.scaled and self.y_std is not None:
|
|
80
|
+
y_pred = y_pred * self.y_std + self.y_mean
|
|
81
|
+
return y_pred
|
|
82
|
+
|
|
83
|
+
def predict(self, X):
|
|
84
|
+
"""Predict class labels and probabilities.
|
|
85
|
+
|
|
86
|
+
Returns a tuple (values, probs) where:
|
|
87
|
+
- values: integer class indices (argmax of raw Y scores)
|
|
88
|
+
- probs: softmax-transformed class probabilities
|
|
89
|
+
"""
|
|
90
|
+
y_raw = self._predict_raw(X)
|
|
91
|
+
if y_raw.ndim == 1:
|
|
92
|
+
y_raw = y_raw.reshape(-1, 1)
|
|
93
|
+
values = np.argmax(y_raw, axis=1).astype(float)
|
|
94
|
+
# softmax for probabilities
|
|
95
|
+
y_max = y_raw.max(axis=1, keepdims=True)
|
|
96
|
+
exp_s = np.exp(y_raw - y_max)
|
|
97
|
+
probs = exp_s / exp_s.sum(axis=1, keepdims=True)
|
|
98
|
+
return values, probs
|
|
99
|
+
|
|
100
|
+
def __str__(self):
|
|
101
|
+
return f"OPLSDAModel(n_pred={self.n_predictive}, n_ortho={self.n_ortho})"
|
|
102
|
+
|
|
103
|
+
def get_splot(self):
|
|
104
|
+
"""Return S-Plot coordinates (p and pcorr) for the predictive
|
|
105
|
+
component.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
p : ndarray (n_features,)
|
|
110
|
+
Covariance loading (predictive loading p_pred)
|
|
111
|
+
pcorr : ndarray (n_features,)
|
|
112
|
+
Correlation loading: corr(X_j, t_pred)
|
|
113
|
+
scores : ndarray (n_samples,)
|
|
114
|
+
Predictive scores t_pred for the training data
|
|
115
|
+
"""
|
|
116
|
+
p = self.p_pred.ravel().copy()
|
|
117
|
+
# t_pred scores from training
|
|
118
|
+
scores = None # not stored by default, need to compute
|
|
119
|
+
return p, scores
|
|
120
|
+
|
|
121
|
+
def plot_data(self, X_train):
|
|
122
|
+
"""Compute complete S-Plot data from training X.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
X_train : ndarray (n_samples, n_features)
|
|
127
|
+
The training data (original scale)
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
p : ndarray (n_features,)
|
|
132
|
+
pcorr : ndarray (n_features,)
|
|
133
|
+
t_pred : ndarray (n_samples,)
|
|
134
|
+
"""
|
|
135
|
+
if self.scaled and self.x_mean is not None:
|
|
136
|
+
Xc = (X_train - self.x_mean) / self.x_std
|
|
137
|
+
else:
|
|
138
|
+
Xc = X_train.copy()
|
|
139
|
+
Xd = self._deflate(Xc)
|
|
140
|
+
t_pred = Xd @ self.w_pred.ravel()
|
|
141
|
+
|
|
142
|
+
n = len(t_pred)
|
|
143
|
+
sd_t = np.std(t_pred, ddof=1)
|
|
144
|
+
sd_X = np.std(Xd, axis=0, ddof=1)
|
|
145
|
+
|
|
146
|
+
p = self.p_pred.ravel().copy()
|
|
147
|
+
# pcorr_j = p_j * sd(t_pred) / sd(X_j)
|
|
148
|
+
pcorr = np.where(sd_X > 1e-15, p * sd_t / sd_X, 0.0)
|
|
149
|
+
return p, pcorr, t_pred
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class OPLSDALearner(SklLearnerClassification):
|
|
153
|
+
"""OPLS-DA (Orthogonal Partial Least Squares Discriminant Analysis)
|
|
154
|
+
learner.
|
|
155
|
+
|
|
156
|
+
Fits an OPLS model that separates predictive (class-correlated)
|
|
157
|
+
variation from orthogonal (class-uncorrelated) variation.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
n_components : int
|
|
162
|
+
Number of predictive PLS components (typically 1 for OPLS)
|
|
163
|
+
n_ortho : int
|
|
164
|
+
Number of orthogonal components to remove
|
|
165
|
+
scale : bool
|
|
166
|
+
Whether to autoscale X (unit variance) before fitting
|
|
167
|
+
max_iter : int
|
|
168
|
+
Maximum NIPALS iterations
|
|
169
|
+
"""
|
|
170
|
+
__wraps__ = object # dummy — no sklearn model
|
|
171
|
+
__returns__ = OPLSDAModel
|
|
172
|
+
supports_multiclass = True
|
|
173
|
+
|
|
174
|
+
def __init__(self, n_components=1, n_ortho=1, scale=True,
|
|
175
|
+
max_iter=500, preprocessors=None):
|
|
176
|
+
super().__init__(preprocessors=preprocessors)
|
|
177
|
+
# NOTE: must set self._params (NOT self.params) — SklLearner.params is
|
|
178
|
+
# a property whose setter filters keys through __wraps__.__init__'s
|
|
179
|
+
# signature. Since __wraps__ = object here (pure-numpy learner), that
|
|
180
|
+
# filter drops every key, leaving params empty.
|
|
181
|
+
self._params = {
|
|
182
|
+
"n_components": n_components,
|
|
183
|
+
"n_ortho": n_ortho,
|
|
184
|
+
"scale": scale,
|
|
185
|
+
"max_iter": max_iter,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def _pls_first(self, X, Y):
|
|
189
|
+
"""Compute the first PLS component of (X, Y) robustly.
|
|
190
|
+
|
|
191
|
+
Uses sklearn's PLSRegression which handles multivariate Y (one-hot
|
|
192
|
+
class indicators) correctly. Returns the weight, score, X-loading and
|
|
193
|
+
Y-loading vectors for the first component.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
w : ndarray (n_features,)
|
|
198
|
+
t : ndarray (n_samples,)
|
|
199
|
+
p : ndarray (n_features,)
|
|
200
|
+
c : ndarray (n_class,)
|
|
201
|
+
"""
|
|
202
|
+
from sklearn.cross_decomposition import PLSRegression
|
|
203
|
+
pls = PLSRegression(n_components=1)
|
|
204
|
+
pls.fit(X, Y)
|
|
205
|
+
return (pls.x_weights_[:, 0].copy(),
|
|
206
|
+
pls.x_scores_[:, 0].copy(),
|
|
207
|
+
pls.x_loadings_[:, 0].copy(),
|
|
208
|
+
pls.y_loadings_[:, 0].copy())
|
|
209
|
+
|
|
210
|
+
def fit(self, X, Y, W=None):
|
|
211
|
+
"""Fit the OPLS-DA model.
|
|
212
|
+
|
|
213
|
+
X : ndarray (n_samples, n_features)
|
|
214
|
+
Y : ndarray (n_samples,) — discrete class labels (0, 1, 2, ...)
|
|
215
|
+
W : ignored
|
|
216
|
+
"""
|
|
217
|
+
n_classes = len(np.unique(Y))
|
|
218
|
+
n_comp = self.params["n_components"]
|
|
219
|
+
n_ortho = self.params["n_ortho"]
|
|
220
|
+
do_scale = self.params["scale"]
|
|
221
|
+
|
|
222
|
+
# Clamp
|
|
223
|
+
n_comp = max(min(n_comp, X.shape[1], X.shape[0] - 1, n_classes), 1)
|
|
224
|
+
n_ortho = max(min(n_ortho, X.shape[1], X.shape[0] - 1), 0)
|
|
225
|
+
|
|
226
|
+
# One-hot encode Y
|
|
227
|
+
Y_enc = np.zeros((len(Y), n_classes))
|
|
228
|
+
for i, l in enumerate(Y):
|
|
229
|
+
Y_enc[i, int(l)] = 1.0
|
|
230
|
+
|
|
231
|
+
# Center / scale
|
|
232
|
+
x_mean = X.mean(axis=0)
|
|
233
|
+
x_std = np.std(X, axis=0, ddof=1)
|
|
234
|
+
x_std[x_std < 1e-15] = 1.0
|
|
235
|
+
y_mean = Y_enc.mean(axis=0)
|
|
236
|
+
y_std = np.std(Y_enc, axis=0, ddof=1)
|
|
237
|
+
y_std[y_std < 1e-15] = 1.0
|
|
238
|
+
|
|
239
|
+
Xc = (X - x_mean) / x_std if do_scale else X - x_mean
|
|
240
|
+
Yc = (Y_enc - y_mean) / y_std if do_scale else Y_enc - y_mean
|
|
241
|
+
|
|
242
|
+
model = OPLSDAModel(None)
|
|
243
|
+
model.x_mean = x_mean
|
|
244
|
+
model.x_std = x_std
|
|
245
|
+
model.y_mean = y_mean
|
|
246
|
+
model.y_std = y_std if do_scale else None
|
|
247
|
+
model.scaled = do_scale
|
|
248
|
+
model.classes = np.unique(Y)
|
|
249
|
+
model.n_classes = n_classes
|
|
250
|
+
model.n_predictive = n_comp
|
|
251
|
+
model.n_ortho = n_ortho
|
|
252
|
+
|
|
253
|
+
Xk = Xc.copy()
|
|
254
|
+
Yk = Yc.copy()
|
|
255
|
+
|
|
256
|
+
# For each orthogonal component: compute PLS direction, remove ortho
|
|
257
|
+
for _ in range(n_ortho):
|
|
258
|
+
w, t, p, c = self._pls_first(Xk, Yk)
|
|
259
|
+
# Orthogonal weight: w_ortho is p with w component removed
|
|
260
|
+
denom = w @ w + 1e-15
|
|
261
|
+
w_o = p - w * (w @ p) / denom
|
|
262
|
+
nw = np.linalg.norm(w_o)
|
|
263
|
+
if nw < 1e-15:
|
|
264
|
+
break
|
|
265
|
+
w_o = w_o / nw
|
|
266
|
+
t_o = Xk @ w_o
|
|
267
|
+
t2_o = t_o @ t_o
|
|
268
|
+
if t2_o < 1e-15:
|
|
269
|
+
break
|
|
270
|
+
p_o = Xk.T @ t_o / t2_o
|
|
271
|
+
Xk -= np.outer(t_o, p_o)
|
|
272
|
+
model.w_ortho.append(w_o)
|
|
273
|
+
model.p_ortho.append(p_o)
|
|
274
|
+
|
|
275
|
+
model.n_ortho = len(model.w_ortho)
|
|
276
|
+
|
|
277
|
+
# Final predictive component
|
|
278
|
+
w, t_pred, p, c = self._pls_first(Xk, Yk)
|
|
279
|
+
model.w_pred = w.reshape(-1, 1)
|
|
280
|
+
model.p_pred = p.reshape(-1, 1)
|
|
281
|
+
|
|
282
|
+
# Store a simple PLS model for Y prediction (on deflated X)
|
|
283
|
+
# We use the pseudo-inverse: B = (Xk'Xk)^{-1} Xk' Yk
|
|
284
|
+
try:
|
|
285
|
+
B = np.linalg.lstsq(Xk, Yk, rcond=None)[0]
|
|
286
|
+
except np.linalg.LinAlgError:
|
|
287
|
+
B = np.zeros((Xk.shape[1], Yk.shape[1]))
|
|
288
|
+
model.y_pred_model = _PLSWrapper(B)
|
|
289
|
+
|
|
290
|
+
# Training predictive scores (used for S-Plot)
|
|
291
|
+
model.t_pred_mean = float(np.mean(t_pred))
|
|
292
|
+
model.t_pred_std = float(np.std(t_pred, ddof=1))
|
|
293
|
+
if model.t_pred_std < 1e-15:
|
|
294
|
+
model.t_pred_std = 1.0
|
|
295
|
+
|
|
296
|
+
return model
|
|
297
|
+
|
|
298
|
+
def incompatibility_reason(self, domain):
|
|
299
|
+
reason = None
|
|
300
|
+
if not domain.has_discrete_class:
|
|
301
|
+
reason = "Categorical (discrete) class variable expected.\n" \
|
|
302
|
+
"OPLS-DA requires a class variable, not numeric target."
|
|
303
|
+
elif len(domain.class_vars) > 1:
|
|
304
|
+
reason = "OPLS-DA supports only a single class variable."
|
|
305
|
+
return reason
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def fitted_parameters(self) -> list[Learner.FittedParameter]:
|
|
309
|
+
return [
|
|
310
|
+
self.FittedParameter("n_components", "Predictive comp.", int, 1, None),
|
|
311
|
+
self.FittedParameter("n_ortho", "Orthogonal comp.", int, 0, None),
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
def __str__(self):
|
|
315
|
+
return f"OPLDALearner(pred={self.params['n_components']}, ortho={self.params['n_ortho']})"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class _PLSWrapper:
|
|
319
|
+
"""Minimal wrapper so the model can predict Y from deflated X."""
|
|
320
|
+
def __init__(self, B):
|
|
321
|
+
self.B = B
|
|
322
|
+
|
|
323
|
+
def predict(self, X):
|
|
324
|
+
return X @ self.B
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PLS-DA (Partial Least Squares Discriminant Analysis) learner and model.
|
|
3
|
+
|
|
4
|
+
PLSDALearner wraps sklearn's PLSRegression for classification:
|
|
5
|
+
1. One-hot encodes class targets (Y)
|
|
6
|
+
2. Fits PLSRegression on the encoded targets
|
|
7
|
+
3. Predicts by taking the argmax of regression outputs
|
|
8
|
+
4. Provides probabilities via softmax transformation
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from sklearn.cross_decomposition import PLSRegression as SKLPLSRegression
|
|
13
|
+
|
|
14
|
+
from Orange.base import Learner, SklLearner
|
|
15
|
+
from Orange.classification.base_classification import (
|
|
16
|
+
SklLearnerClassification, SklModelClassification,
|
|
17
|
+
)
|
|
18
|
+
from Orange.data import DiscreteVariable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["PLSDALearner", "PLSDAModel"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PLSDAModel(SklModelClassification):
|
|
25
|
+
"""PLS-DA classification model.
|
|
26
|
+
|
|
27
|
+
Wraps a fitted sklearn PLSRegression model. Prediction is done by taking
|
|
28
|
+
the argmax of the continuous regression outputs. Probabilities are
|
|
29
|
+
derived via softmax.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
supports_multiclass = True
|
|
33
|
+
|
|
34
|
+
def predict(self, X):
|
|
35
|
+
"""Predict class labels and probabilities.
|
|
36
|
+
|
|
37
|
+
Returns a tuple (values, probs) where:
|
|
38
|
+
- values: integer class indices (argmax of regression outputs)
|
|
39
|
+
- probs: softmax-transformed class probabilities
|
|
40
|
+
"""
|
|
41
|
+
# PLSRegression predict returns n_samples x n_classes
|
|
42
|
+
raw_scores = self.skl_model.predict(X)
|
|
43
|
+
|
|
44
|
+
if raw_scores.ndim == 1:
|
|
45
|
+
raw_scores = raw_scores.reshape(-1, 1)
|
|
46
|
+
|
|
47
|
+
# Class predictions: argmax over classes
|
|
48
|
+
values = np.argmax(raw_scores, axis=1).astype(float)
|
|
49
|
+
|
|
50
|
+
# Probabilities via softmax
|
|
51
|
+
exp_scores = np.exp(raw_scores - raw_scores.max(axis=1, keepdims=True))
|
|
52
|
+
probs = exp_scores / exp_scores.sum(axis=1, keepdims=True)
|
|
53
|
+
|
|
54
|
+
return values, probs
|
|
55
|
+
|
|
56
|
+
def __str__(self):
|
|
57
|
+
return f"PLSDAModel(n_components={self.skl_model.n_components})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PLSDALearner(SklLearnerClassification):
|
|
61
|
+
"""PLS-DA (Partial Least Squares Discriminant Analysis) learner.
|
|
62
|
+
|
|
63
|
+
Uses sklearn's PLSRegression internally. For classification, the
|
|
64
|
+
discrete target is one-hot encoded into indicator variables, and the
|
|
65
|
+
regression is performed on those. Predictions are decoded back to class
|
|
66
|
+
labels via argmax.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__wraps__ = SKLPLSRegression
|
|
70
|
+
__returns__ = PLSDAModel
|
|
71
|
+
supports_multiclass = True
|
|
72
|
+
|
|
73
|
+
def fit(self, X, Y, W=None):
|
|
74
|
+
"""Fit the PLS-DA model.
|
|
75
|
+
|
|
76
|
+
One-hot encodes the discrete class labels Y into indicator variables,
|
|
77
|
+
fits PLSRegression, and returns a PLSDAModel.
|
|
78
|
+
"""
|
|
79
|
+
params = self.params.copy()
|
|
80
|
+
# Clamp n_components to feasible range
|
|
81
|
+
n_classes = len(np.unique(Y))
|
|
82
|
+
params["n_components"] = min(
|
|
83
|
+
X.shape[1], X.shape[0] - 1, n_classes, params["n_components"]
|
|
84
|
+
)
|
|
85
|
+
params["n_components"] = max(params["n_components"], 1)
|
|
86
|
+
|
|
87
|
+
# One-hot encode Y: n_samples x n_classes indicator matrix
|
|
88
|
+
Y_encoded = np.zeros((len(Y), n_classes))
|
|
89
|
+
for i, label in enumerate(Y):
|
|
90
|
+
Y_encoded[i, int(label)] = 1.0
|
|
91
|
+
|
|
92
|
+
clf = self.__wraps__(**params)
|
|
93
|
+
clf.fit(X, Y_encoded)
|
|
94
|
+
return self.__returns__(clf)
|
|
95
|
+
|
|
96
|
+
def __init__(self, n_components=2, scale=True, max_iter=500,
|
|
97
|
+
preprocessors=None):
|
|
98
|
+
super().__init__(preprocessors=preprocessors)
|
|
99
|
+
self.params = vars()
|
|
100
|
+
|
|
101
|
+
def incompatibility_reason(self, domain):
|
|
102
|
+
"""Check if the domain is compatible with PLS-DA."""
|
|
103
|
+
reason = None
|
|
104
|
+
if not domain.has_discrete_class:
|
|
105
|
+
reason = "Categorical (discrete) class variable expected.\n" \
|
|
106
|
+
"PLS-DA requires a class variable, not a numeric target."
|
|
107
|
+
elif len(domain.class_vars) > 1:
|
|
108
|
+
reason = "PLS-DA supports only a single class variable."
|
|
109
|
+
return reason
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def fitted_parameters(self) -> list[Learner.FittedParameter]:
|
|
113
|
+
return [
|
|
114
|
+
self.FittedParameter(
|
|
115
|
+
"n_components", "Components", int, 1, None
|
|
116
|
+
)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def __str__(self):
|
|
120
|
+
return f"PLSDALearner(n_components={self.params.get('n_components', 2)})"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
2
|
+
<rect x="4" y="4" width="40" height="40" rx="6" fill="#E91E63"/>
|
|
3
|
+
<text font-family="Arial, sans-serif" font-size="8" font-weight="bold" fill="white" x="24" y="16" text-anchor="middle">OPLS</text>
|
|
4
|
+
<text font-family="Arial, sans-serif" font-size="8" fill="#F48FB1" x="24" y="30" text-anchor="middle">DA</text>
|
|
5
|
+
<line x1="8" y1="36" x2="40" y2="36" stroke="#FCE4EC" stroke-width="1" opacity="0.6"/>
|
|
6
|
+
<circle cx="14" cy="34" r="2" fill="#fff" opacity="0.8"/>
|
|
7
|
+
<circle cx="20" cy="30" r="2" fill="#fff" opacity="0.5"/>
|
|
8
|
+
<circle cx="28" cy="32" r="2" fill="#fff" opacity="0.5"/>
|
|
9
|
+
<circle cx="34" cy="28" r="2" fill="#fff" opacity="0.8"/>
|
|
10
|
+
<circle cx="24" cy="30" r="3" fill="#fff" opacity="0.9"/>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#2196F3"/>
|
|
5
|
+
<stop offset="100%" stop-color="#1565C0"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="4" y="4" width="40" height="40" rx="6" fill="url(#g)"/>
|
|
9
|
+
<text font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="white" x="24" y="24" text-anchor="middle" dominant-baseline="central">PLS</text>
|
|
10
|
+
<text font-family="Arial, sans-serif" font-size="10" fill="#BBDEFB" x="24" y="36" text-anchor="middle" dominant-baseline="central">DA</text>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OWOPLSDA widget — OPLS-DA (Orthogonal Partial Least Squares
|
|
3
|
+
Discriminant Analysis) with S-Plot for biomarker discovery.
|
|
4
|
+
|
|
5
|
+
Separates predictive from orthogonal variation and provides
|
|
6
|
+
an S-Plot visualization for variable selection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from AnyQt.QtCore import Qt
|
|
11
|
+
from AnyQt.QtGui import QColor, QPen
|
|
12
|
+
|
|
13
|
+
import pyqtgraph as pg
|
|
14
|
+
from pyqtgraph import PlotWidget
|
|
15
|
+
|
|
16
|
+
from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable, \
|
|
17
|
+
StringVariable
|
|
18
|
+
from Orange.widgets import gui
|
|
19
|
+
from Orange.widgets.settings import Setting
|
|
20
|
+
from Orange.widgets.utils.owlearnerwidget import OWBaseLearner
|
|
21
|
+
from Orange.widgets.utils.signals import Output
|
|
22
|
+
from Orange.widgets.utils.widgetpreview import WidgetPreview
|
|
23
|
+
from Orange.widgets.widget import Msg
|
|
24
|
+
|
|
25
|
+
from orangeplsda import OPLSDALearner
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OWOPLSDA(OWBaseLearner):
|
|
29
|
+
name = "OPLS-DA"
|
|
30
|
+
description = "Orthogonal Partial Least Squares Discriminant Analysis " \
|
|
31
|
+
"with S-Plot for biomarker discovery."
|
|
32
|
+
icon = "icons/OPLSDA.svg"
|
|
33
|
+
priority = 87
|
|
34
|
+
keywords = ["orthogonal partial least squares", "discriminant analysis",
|
|
35
|
+
"classification", "OPLS-DA", "S-Plot", "biomarker"]
|
|
36
|
+
|
|
37
|
+
LEARNER = OPLSDALearner
|
|
38
|
+
|
|
39
|
+
class Outputs(OWBaseLearner.Outputs):
|
|
40
|
+
data = Output(
|
|
41
|
+
"Data with Scores",
|
|
42
|
+
Table,
|
|
43
|
+
default=True,
|
|
44
|
+
)
|
|
45
|
+
components = Output(
|
|
46
|
+
"Components",
|
|
47
|
+
Table,
|
|
48
|
+
explicit=True,
|
|
49
|
+
)
|
|
50
|
+
splot_data = Output(
|
|
51
|
+
"S-Plot Data",
|
|
52
|
+
Table,
|
|
53
|
+
explicit=True,
|
|
54
|
+
)
|
|
55
|
+
biomarkers = Output(
|
|
56
|
+
"Selected Biomarkers",
|
|
57
|
+
Table,
|
|
58
|
+
explicit=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
n_components = Setting(1)
|
|
62
|
+
n_ortho = Setting(1)
|
|
63
|
+
scale = Setting(True)
|
|
64
|
+
max_iter = Setting(500)
|
|
65
|
+
|
|
66
|
+
want_main_area = True
|
|
67
|
+
resizing_enabled = True
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
super().__init__()
|
|
71
|
+
self.splot_p = None
|
|
72
|
+
self.splot_pcorr = None
|
|
73
|
+
self.splot_feature_names = None
|
|
74
|
+
|
|
75
|
+
# Plot area for S-Plot
|
|
76
|
+
self.plot_widget = pg.PlotWidget(
|
|
77
|
+
title="S-Plot (P(corr) vs P)"
|
|
78
|
+
)
|
|
79
|
+
self.plot_widget.setLabel("bottom", "P — Covariance Loading")
|
|
80
|
+
self.plot_widget.setLabel("left", "P(corr) — Correlation Loading")
|
|
81
|
+
self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
|
|
82
|
+
self.plot_widget.setAspectLocked(False)
|
|
83
|
+
self.mainArea.layout().addWidget(self.plot_widget)
|
|
84
|
+
|
|
85
|
+
self.scatter_item = None
|
|
86
|
+
self.selected_indices = []
|
|
87
|
+
|
|
88
|
+
def add_main_layout(self):
|
|
89
|
+
box = gui.vBox(self.controlArea, "Optimization Parameters")
|
|
90
|
+
gui.spin(
|
|
91
|
+
box, self, "n_components", 1, 10, 1,
|
|
92
|
+
label="Predictive components: ",
|
|
93
|
+
alignment=Qt.AlignRight, controlWidth=80,
|
|
94
|
+
callback=self.settings_changed,
|
|
95
|
+
)
|
|
96
|
+
gui.spin(
|
|
97
|
+
box, self, "n_ortho", 0, 20, 1,
|
|
98
|
+
label="Orthogonal components: ",
|
|
99
|
+
alignment=Qt.AlignRight, controlWidth=80,
|
|
100
|
+
callback=self.settings_changed,
|
|
101
|
+
)
|
|
102
|
+
gui.spin(
|
|
103
|
+
box, self, "max_iter", 5, 1000000, 50,
|
|
104
|
+
label="Iteration limit: ",
|
|
105
|
+
alignment=Qt.AlignRight, controlWidth=100,
|
|
106
|
+
callback=self.settings_changed,
|
|
107
|
+
checkCallback=self.settings_changed,
|
|
108
|
+
)
|
|
109
|
+
gui.checkBox(
|
|
110
|
+
box, self, "scale",
|
|
111
|
+
"Scale features",
|
|
112
|
+
callback=self.settings_changed,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def create_learner(self):
|
|
116
|
+
return OPLSDALearner(
|
|
117
|
+
n_components=self.n_components,
|
|
118
|
+
n_ortho=self.n_ortho,
|
|
119
|
+
scale=self.scale,
|
|
120
|
+
max_iter=self.max_iter,
|
|
121
|
+
preprocessors=self.preprocessors,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def update_model(self):
|
|
125
|
+
super().update_model()
|
|
126
|
+
data_with_scores = None
|
|
127
|
+
components_table = None
|
|
128
|
+
splot_table = None
|
|
129
|
+
biomarkers_table = None
|
|
130
|
+
|
|
131
|
+
if self.model is not None:
|
|
132
|
+
data_with_scores = self._create_output_data()
|
|
133
|
+
components_table = self._create_output_components()
|
|
134
|
+
splot_table = self._create_splot_table()
|
|
135
|
+
biomarkers_table = self._compute_biomarkers()
|
|
136
|
+
self._draw_splot()
|
|
137
|
+
|
|
138
|
+
self.Outputs.data.send(data_with_scores)
|
|
139
|
+
self.Outputs.components.send(components_table)
|
|
140
|
+
self.Outputs.splot_data.send(splot_table)
|
|
141
|
+
self.Outputs.biomarkers.send(biomarkers_table)
|
|
142
|
+
|
|
143
|
+
def _create_output_data(self):
|
|
144
|
+
"""Augment data with OPLS scores and predictions."""
|
|
145
|
+
data = self.data
|
|
146
|
+
model = self.model
|
|
147
|
+
model_data = model.data_to_model_domain(data)
|
|
148
|
+
Xt = model_data.X
|
|
149
|
+
|
|
150
|
+
# Deflate and get scores
|
|
151
|
+
X_scaled = (Xt - model.x_mean) / model.x_std \
|
|
152
|
+
if model.scaled else Xt - model.x_mean
|
|
153
|
+
Xd = X_scaled.copy()
|
|
154
|
+
for i in range(model.n_ortho):
|
|
155
|
+
t_o = Xd @ model.w_ortho[i].ravel()
|
|
156
|
+
Xd -= np.outer(t_o, model.p_ortho[i].ravel())
|
|
157
|
+
t_pred = Xd @ model.w_pred.ravel()
|
|
158
|
+
|
|
159
|
+
# Orthogonal scores
|
|
160
|
+
ortho_names = []
|
|
161
|
+
for i in range(model.n_ortho):
|
|
162
|
+
ortho_names.append(f"t_o{i + 1}")
|
|
163
|
+
|
|
164
|
+
# Predicted class + probabilities
|
|
165
|
+
y_raw = model._predict_raw(Xt)
|
|
166
|
+
if y_raw.ndim == 1:
|
|
167
|
+
y_raw = y_raw.reshape(-1, 1)
|
|
168
|
+
pred_class = np.argmax(y_raw, axis=1)
|
|
169
|
+
y_max = y_raw.max(axis=1, keepdims=True)
|
|
170
|
+
exp_s = np.exp(y_raw - y_max)
|
|
171
|
+
probs = exp_s / exp_s.sum(axis=1, keepdims=True)
|
|
172
|
+
|
|
173
|
+
class_var = data.domain.class_var
|
|
174
|
+
score_names = ["t_pred"] + ortho_names
|
|
175
|
+
prob_names = [
|
|
176
|
+
f"p({class_var.name}={v})" for v in class_var.values
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
new_attrs = data.domain.attributes \
|
|
180
|
+
+ tuple(ContinuousVariable(n) for n in score_names)
|
|
181
|
+
new_metas = data.domain.metas \
|
|
182
|
+
+ (DiscreteVariable("Predicted", values=class_var.values),) \
|
|
183
|
+
+ tuple(ContinuousVariable(n) for n in prob_names)
|
|
184
|
+
|
|
185
|
+
new_domain = Domain(new_attrs, data.domain.class_vars, new_metas)
|
|
186
|
+
aug_data = data.transform(new_domain)
|
|
187
|
+
|
|
188
|
+
n_orig_attrs = len(data.domain.attributes)
|
|
189
|
+
n_new_attr = len(score_names)
|
|
190
|
+
with aug_data.unlocked(aug_data.X):
|
|
191
|
+
aug_data.X[:, n_orig_attrs] = t_pred
|
|
192
|
+
for i in range(model.n_ortho):
|
|
193
|
+
t_o_idx = n_orig_attrs + 1 + i
|
|
194
|
+
if t_o_idx < aug_data.X.shape[1]:
|
|
195
|
+
Xd2 = X_scaled.copy()
|
|
196
|
+
for j in range(i + 1):
|
|
197
|
+
tt = Xd2 @ model.w_ortho[j].ravel()
|
|
198
|
+
Xd2 -= np.outer(tt, model.p_ortho[j].ravel())
|
|
199
|
+
aug_data.X[:, t_o_idx] = Xd2 @ model.w_ortho[i].ravel()
|
|
200
|
+
|
|
201
|
+
with aug_data.unlocked(aug_data.metas):
|
|
202
|
+
aug_data.metas[:, -len(prob_names) - 1] = pred_class.astype(float)
|
|
203
|
+
aug_data.metas[:, -len(prob_names):] = probs
|
|
204
|
+
|
|
205
|
+
aug_data.name = f"{data.name} - OPLS-DA scores"
|
|
206
|
+
return aug_data
|
|
207
|
+
|
|
208
|
+
def _create_output_components(self):
|
|
209
|
+
"""Return a Table of predictive and orthogonal loadings."""
|
|
210
|
+
model = self.model
|
|
211
|
+
n_total = 1 + model.n_ortho
|
|
212
|
+
comp_names = ["Predictive"] + [f"Ortho {i + 1}" for i in range(model.n_ortho)]
|
|
213
|
+
|
|
214
|
+
attr_names = [a.name for a in model.domain.attributes]
|
|
215
|
+
dom = Domain(
|
|
216
|
+
[ContinuousVariable(n) for n in comp_names],
|
|
217
|
+
metas=[StringVariable("Variable")],
|
|
218
|
+
)
|
|
219
|
+
X = np.zeros((len(attr_names), n_total))
|
|
220
|
+
X[:, 0] = model.p_pred.ravel()
|
|
221
|
+
for i in range(model.n_ortho):
|
|
222
|
+
X[:, 1 + i] = model.p_ortho[i].ravel()
|
|
223
|
+
metas = np.array(attr_names, dtype=object).reshape(-1, 1)
|
|
224
|
+
comp = Table.from_numpy(dom, X=X, metas=metas)
|
|
225
|
+
comp.name = "OPLS-DA components"
|
|
226
|
+
return comp
|
|
227
|
+
|
|
228
|
+
def _create_splot_table(self):
|
|
229
|
+
"""Return S-Plot coordinates as a Table."""
|
|
230
|
+
model = self.model
|
|
231
|
+
data = self.data
|
|
232
|
+
model_data = model.data_to_model_domain(data)
|
|
233
|
+
Xt = model_data.X
|
|
234
|
+
|
|
235
|
+
p_vals, pcorr_vals, t_pred = model.plot_data(Xt)
|
|
236
|
+
self.splot_p = p_vals
|
|
237
|
+
self.splot_pcorr = pcorr_vals
|
|
238
|
+
self.splot_feature_names = [a.name for a in model.domain.attributes]
|
|
239
|
+
|
|
240
|
+
dom = Domain(
|
|
241
|
+
[ContinuousVariable("p"),
|
|
242
|
+
ContinuousVariable("p(corr)")],
|
|
243
|
+
metas=[StringVariable("Variable")],
|
|
244
|
+
)
|
|
245
|
+
X = np.column_stack((p_vals, pcorr_vals))
|
|
246
|
+
metas = np.array(self.splot_feature_names, dtype=object).reshape(-1, 1)
|
|
247
|
+
st = Table.from_numpy(dom, X=X, metas=metas)
|
|
248
|
+
st.name = "S-Plot data"
|
|
249
|
+
return st
|
|
250
|
+
|
|
251
|
+
def _compute_biomarkers(self):
|
|
252
|
+
"""Identify top biomarkers by |p| > threshold."""
|
|
253
|
+
if self.splot_p is None or self.splot_feature_names is None:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# Biomarkers = variables with |p(corr)| > 0.5 (or high |p|)
|
|
257
|
+
p_abs = np.abs(self.splot_p)
|
|
258
|
+
pcorr_abs = np.abs(self.splot_pcorr)
|
|
259
|
+
score = p_abs * pcorr_abs # combined importance
|
|
260
|
+
|
|
261
|
+
idx = np.argsort(score)[::-1]
|
|
262
|
+
dom = Domain(
|
|
263
|
+
[ContinuousVariable("p"),
|
|
264
|
+
ContinuousVariable("p(corr)"),
|
|
265
|
+
ContinuousVariable("Importance")],
|
|
266
|
+
metas=[StringVariable("Variable")],
|
|
267
|
+
)
|
|
268
|
+
X = np.column_stack((
|
|
269
|
+
self.splot_p[idx], self.splot_pcorr[idx], score[idx],
|
|
270
|
+
))
|
|
271
|
+
metas = np.array(
|
|
272
|
+
[self.splot_feature_names[i] for i in idx],
|
|
273
|
+
dtype=object,
|
|
274
|
+
).reshape(-1, 1)
|
|
275
|
+
bt = Table.from_numpy(dom, X=X, metas=metas)
|
|
276
|
+
bt.name = "Biomarkers (S-Plot)"
|
|
277
|
+
return bt
|
|
278
|
+
|
|
279
|
+
def _draw_splot(self):
|
|
280
|
+
"""Draw the S-Plot in the widget."""
|
|
281
|
+
self.plot_widget.clear()
|
|
282
|
+
if self.splot_p is None or self.splot_pcorr is None:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self.scatter_item = pg.ScatterPlotItem(
|
|
286
|
+
x=self.splot_p,
|
|
287
|
+
y=self.splot_pcorr,
|
|
288
|
+
pen=pg.mkPen(0.3, width=0.5),
|
|
289
|
+
brush=pg.mkBrush(60, 120, 200, 180),
|
|
290
|
+
size=6,
|
|
291
|
+
)
|
|
292
|
+
self.plot_widget.addItem(self.scatter_item)
|
|
293
|
+
|
|
294
|
+
# Horizontal/vertical lines at 0
|
|
295
|
+
self.plot_widget.addLine(x=0, pen=QColor(180, 180, 180, 120))
|
|
296
|
+
self.plot_widget.addLine(y=0, pen=QColor(180, 180, 180, 120))
|
|
297
|
+
|
|
298
|
+
# Label top/bottom biomarkers
|
|
299
|
+
scores = np.abs(self.splot_p) * np.abs(self.splot_pcorr)
|
|
300
|
+
top3 = np.argsort(scores)[-3:]
|
|
301
|
+
label_pen = pg.mkPen(color=(40, 40, 40))
|
|
302
|
+
for i in top3:
|
|
303
|
+
txt = pg.TextItem(
|
|
304
|
+
text=self.splot_feature_names[i],
|
|
305
|
+
anchor=(0.5, 1.5),
|
|
306
|
+
color=(40, 40, 40),
|
|
307
|
+
)
|
|
308
|
+
txt.setPos(self.splot_p[i], self.splot_pcorr[i])
|
|
309
|
+
self.plot_widget.addItem(txt)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__": # pragma: no cover
|
|
313
|
+
WidgetPreview(OWOPLSDA).run(Table("zoo"))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OWPLSDA widget — Partial Least Squares Discriminant Analysis.
|
|
3
|
+
|
|
4
|
+
Provides a PLS-DA classification widget for Orange3 that works with
|
|
5
|
+
categorical (discrete) class variables, unlike the existing PLS-R widget
|
|
6
|
+
which only works with numeric targets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from AnyQt.QtCore import Qt
|
|
11
|
+
|
|
12
|
+
from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable, \
|
|
13
|
+
StringVariable
|
|
14
|
+
from Orange.widgets import gui
|
|
15
|
+
from Orange.widgets.settings import Setting
|
|
16
|
+
from Orange.widgets.utils.owlearnerwidget import OWBaseLearner
|
|
17
|
+
from Orange.widgets.utils.signals import Output
|
|
18
|
+
from Orange.widgets.utils.widgetpreview import WidgetPreview
|
|
19
|
+
from Orange.widgets.widget import Msg
|
|
20
|
+
|
|
21
|
+
from orangeplsda import PLSDALearner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OWPLSDA(OWBaseLearner):
|
|
25
|
+
name = "PLS-DA"
|
|
26
|
+
description = "Partial Least Squares Discriminant Analysis " \
|
|
27
|
+
"for classification with categorical targets."
|
|
28
|
+
icon = "icons/PLSDA.svg"
|
|
29
|
+
priority = 86
|
|
30
|
+
keywords = ["partial least squares", "discriminant analysis",
|
|
31
|
+
"classification", "PLS-DA"]
|
|
32
|
+
|
|
33
|
+
LEARNER = PLSDALearner
|
|
34
|
+
|
|
35
|
+
class Outputs(OWBaseLearner.Outputs):
|
|
36
|
+
data = Output(
|
|
37
|
+
"Data with Scores",
|
|
38
|
+
Table,
|
|
39
|
+
default=True,
|
|
40
|
+
)
|
|
41
|
+
components = Output(
|
|
42
|
+
"Components",
|
|
43
|
+
Table,
|
|
44
|
+
explicit=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
class Warning(OWBaseLearner.Warning):
|
|
48
|
+
few_features = Msg(
|
|
49
|
+
"Number of components reduced to match data dimensions."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
n_components = Setting(2)
|
|
53
|
+
max_iter = Setting(500)
|
|
54
|
+
scale = Setting(True)
|
|
55
|
+
|
|
56
|
+
def add_main_layout(self):
|
|
57
|
+
"""Build the widget's control area UI."""
|
|
58
|
+
optimization_box = gui.vBox(
|
|
59
|
+
self.controlArea, "Optimization Parameters"
|
|
60
|
+
)
|
|
61
|
+
gui.spin(
|
|
62
|
+
optimization_box, self, "n_components", 1, 50, 1,
|
|
63
|
+
label="Components: ",
|
|
64
|
+
alignment=Qt.AlignRight, controlWidth=100,
|
|
65
|
+
callback=self.settings_changed,
|
|
66
|
+
)
|
|
67
|
+
gui.spin(
|
|
68
|
+
optimization_box, self, "max_iter", 5, 1000000, 50,
|
|
69
|
+
label="Iteration limit: ",
|
|
70
|
+
alignment=Qt.AlignRight, controlWidth=100,
|
|
71
|
+
callback=self.settings_changed,
|
|
72
|
+
checkCallback=self.settings_changed,
|
|
73
|
+
)
|
|
74
|
+
gui.checkBox(
|
|
75
|
+
optimization_box, self, "scale",
|
|
76
|
+
"Scale features",
|
|
77
|
+
callback=self.settings_changed,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def create_learner(self):
|
|
81
|
+
return PLSDALearner(
|
|
82
|
+
n_components=self.n_components,
|
|
83
|
+
scale=self.scale,
|
|
84
|
+
max_iter=self.max_iter,
|
|
85
|
+
preprocessors=self.preprocessors,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def update_model(self):
|
|
89
|
+
"""Called after the model is (re-)trained. Sends outputs."""
|
|
90
|
+
super().update_model()
|
|
91
|
+
|
|
92
|
+
data_with_scores = None
|
|
93
|
+
components_table = None
|
|
94
|
+
|
|
95
|
+
if self.model is not None:
|
|
96
|
+
data_with_scores = self._create_output_data()
|
|
97
|
+
components_table = self._create_output_components()
|
|
98
|
+
|
|
99
|
+
self.Outputs.data.send(data_with_scores)
|
|
100
|
+
self.Outputs.components.send(components_table)
|
|
101
|
+
|
|
102
|
+
def _create_output_data(self):
|
|
103
|
+
"""Create a table with PLS scores, predicted classes, and probabilities.
|
|
104
|
+
|
|
105
|
+
Augments the input data with:
|
|
106
|
+
- PLS X-scores (T1, T2, ...)
|
|
107
|
+
- PLS Y-scores (U1, U2, ...)
|
|
108
|
+
- Predicted class
|
|
109
|
+
- Class probabilities (one column per class value)
|
|
110
|
+
"""
|
|
111
|
+
data = self.data
|
|
112
|
+
model = self.model
|
|
113
|
+
|
|
114
|
+
# Transform data through the model's domain (handles preprocessing
|
|
115
|
+
# like one-hot encoding of categorical features, scaling, etc.)
|
|
116
|
+
model_data = model.data_to_model_domain(data)
|
|
117
|
+
|
|
118
|
+
# Project into PLS space via the model
|
|
119
|
+
n_comp = model.skl_model.n_components
|
|
120
|
+
x_scores = model.skl_model.transform(model_data.X)
|
|
121
|
+
|
|
122
|
+
# Predict class and get probabilities
|
|
123
|
+
raw_scores = model.skl_model.predict(model_data.X)
|
|
124
|
+
if raw_scores.ndim == 1:
|
|
125
|
+
raw_scores = raw_scores.reshape(-1, 1)
|
|
126
|
+
pred_class = np.argmax(raw_scores, axis=1)
|
|
127
|
+
|
|
128
|
+
# Softmax probabilities
|
|
129
|
+
exp_s = np.exp(raw_scores - raw_scores.max(axis=1, keepdims=True))
|
|
130
|
+
probs = exp_s / exp_s.sum(axis=1, keepdims=True)
|
|
131
|
+
|
|
132
|
+
# Build augmented domain
|
|
133
|
+
class_var = data.domain.class_var
|
|
134
|
+
score_names_x = [f"T{i + 1}" for i in range(n_comp)]
|
|
135
|
+
prob_names = [
|
|
136
|
+
f"p({class_var.name}={v})" for v in class_var.values
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
# Add PLS scores as attributes
|
|
140
|
+
new_attrs = data.domain.attributes \
|
|
141
|
+
+ tuple(ContinuousVariable(n) for n in score_names_x)
|
|
142
|
+
|
|
143
|
+
# Add predicted class and probabilities as metas
|
|
144
|
+
new_metas = data.domain.metas \
|
|
145
|
+
+ (DiscreteVariable("Predicted", values=class_var.values),) \
|
|
146
|
+
+ tuple(ContinuousVariable(n) for n in prob_names)
|
|
147
|
+
|
|
148
|
+
new_domain = Domain(new_attrs, data.domain.class_vars, new_metas)
|
|
149
|
+
|
|
150
|
+
# Build augmented data by transforming original data to new domain,
|
|
151
|
+
# then filling in the new columns
|
|
152
|
+
aug_data = data.transform(new_domain)
|
|
153
|
+
|
|
154
|
+
# Fill in PLS scores (first new attributes)
|
|
155
|
+
n_orig_attrs = len(data.domain.attributes)
|
|
156
|
+
with aug_data.unlocked(aug_data.X):
|
|
157
|
+
aug_data.X[:, n_orig_attrs:n_orig_attrs + n_comp] = x_scores
|
|
158
|
+
|
|
159
|
+
# Fill in predicted class and probabilities (last metas)
|
|
160
|
+
with aug_data.unlocked(aug_data.metas):
|
|
161
|
+
aug_data.metas[:, -len(prob_names) - 1] = pred_class.astype(float)
|
|
162
|
+
aug_data.metas[:, -len(prob_names):] = probs
|
|
163
|
+
|
|
164
|
+
aug_data.name = f"{data.name} - PLS-DA scores"
|
|
165
|
+
return aug_data
|
|
166
|
+
|
|
167
|
+
def _create_output_components(self):
|
|
168
|
+
"""Build a components (loadings) table showing X and Y loadings."""
|
|
169
|
+
model = self.model
|
|
170
|
+
skl = model.skl_model
|
|
171
|
+
|
|
172
|
+
n_components = skl.x_loadings_.shape[1]
|
|
173
|
+
|
|
174
|
+
# X loadings — use model domain attributes which match preprocessed features
|
|
175
|
+
attr_names = [a.name for a in model.domain.attributes]
|
|
176
|
+
comp_names = [f"Comp {i + 1}" for i in range(n_components)]
|
|
177
|
+
|
|
178
|
+
dom = Domain(
|
|
179
|
+
[ContinuousVariable(n) for n in comp_names],
|
|
180
|
+
metas=[StringVariable("Variable")],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
X = skl.x_loadings_ # n_features x n_comp
|
|
184
|
+
metas = np.array(attr_names, dtype=object).reshape(-1, 1)
|
|
185
|
+
|
|
186
|
+
components = Table.from_numpy(dom, X=X, metas=metas)
|
|
187
|
+
components.name = "PLS-DA components (X loadings)"
|
|
188
|
+
return components
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__": # pragma: no cover
|
|
192
|
+
WidgetPreview(OWPLSDA).run(Table("zoo"))
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orangeplsda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PLS-DA & OPLS-DA with S-Plot — classification, biomarker discovery, and orthogonal signal correction for Orange3
|
|
5
|
+
Author: Philipp Weller
|
|
6
|
+
Author-email: philipp.weller@googlemail.com
|
|
7
|
+
Project-URL: Source, https://github.com/philippweller/WellerLab/tree/main/orange-plsda-addon
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/philippweller/WellerLab/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: Orange3>=3.40.0
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: scikit-learn
|
|
18
|
+
Dynamic: author
|
|
19
|
+
Dynamic: author-email
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: project-url
|
|
24
|
+
Dynamic: requires-dist
|
|
25
|
+
Dynamic: summary
|
|
26
|
+
|
|
27
|
+
# orange-plsda-addon
|
|
28
|
+
|
|
29
|
+
PLS-DA & OPLS-DA für **Orange3** — Leistungsstarke Klassifikations-Widgets für
|
|
30
|
+
die multivariate Analyse (Chemometrie, Biomarker-Findung).
|
|
31
|
+
|
|
32
|
+
- **PLS-DA** (Partial Least Squares Discriminant Analysis) — Klassifikation
|
|
33
|
+
mittels `PLSRegression` mit One-hot-kodiertem Ziel
|
|
34
|
+
- **OPLS-DA** (Orthogonal PLS-DA, Trygg & Wold 2002) — trennt prädiktive von
|
|
35
|
+
orthogonaler (klassen-unabhängiger) Variation
|
|
36
|
+
|
|
37
|
+
Beide Widgets erscheinen in Orange unter der Kategorie **PLS-DA**, direkt
|
|
38
|
+
neben dem eingebauten PLS-R.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ⚡ Automatischer Installer (empfohlen)
|
|
43
|
+
|
|
44
|
+
Dieses Paket ist Teil des **WellerLab**-Monorepos
|
|
45
|
+
(`github.com/philippweller/WellerLab`). Der gemeinsame Installer
|
|
46
|
+
`orange-install.py` (auf Repo-Ebene) findet **Oranges eigenes Python**
|
|
47
|
+
automatisch und installiert das Paket korrekt (macOS / Windows / Linux). Kein
|
|
48
|
+
manuelles Pfad-Raten mehr:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# PLS-DA installieren:
|
|
52
|
+
python orange-install.py plsda
|
|
53
|
+
|
|
54
|
+
# NMR-Add-on installieren:
|
|
55
|
+
python orange-install.py nmr
|
|
56
|
+
|
|
57
|
+
# nur zeigen, welches Python erkannt wurde:
|
|
58
|
+
python orange-install.py --show
|
|
59
|
+
|
|
60
|
+
# bestehende Installation prüfen:
|
|
61
|
+
python orange-install.py --check plsda
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Läuft mit **jedem** Python (nur Standardbibliothek). Klappt die
|
|
65
|
+
Auto-Erkennung ausnahmsweise nicht:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python orange-install.py --python /pfad/zum/orange/python
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> ▶️ **Direkt ausführen ohne Clone** (das Add-on wird trotzdem via GitHub
|
|
72
|
+
> installiert):
|
|
73
|
+
> ```bash
|
|
74
|
+
> curl -fsSL https://raw.githubusercontent.com/philippweller/WellerLab/main/orange-install.py -o orange-install.py && python3 orange-install.py plsda
|
|
75
|
+
> ```
|
|
76
|
+
|
|
77
|
+
Das Tool bedient auch den **Windows-`--no-user`-Fall** automatisch, sodass
|
|
78
|
+
das Add-on in Oranges eigene `site-packages` landet statt in die unsichtbare
|
|
79
|
+
User-Site.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 📋 Voraussetzungen
|
|
84
|
+
|
|
85
|
+
| Voraussetzung | Hinweis |
|
|
86
|
+
|---|---|
|
|
87
|
+
| Orange3 ≥ 3.40 installiert | Unter **Hilfe → Über** die Version prüfen |
|
|
88
|
+
| Internet-Zugriff auf GitHub | zum Herunterladen des Repos |
|
|
89
|
+
|
|
90
|
+
> ⚠️ **Wichtig:** Es muss immer **Oranges eigenes Python** verwendet werden,
|
|
91
|
+
> nicht `/usr/bin/python3`! Sonst wird das Add-on in der falschen Python-Umgebung
|
|
92
|
+
> installiert und Orange findet es nicht.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 🔍 Oranges eingebettetes Python finden (wichtig!)
|
|
97
|
+
|
|
98
|
+
Der Pfad zu Oranges Python **hängt von der Orange-Version ab**. Je nachdem
|
|
99
|
+
ob Orange mit Python 3.11 oder 3.12 gebaut wurde, heißt das Binary
|
|
100
|
+
`python3`, `python3.11` oder `python3.12`.
|
|
101
|
+
|
|
102
|
+
**Sichere Methode — das echte Python-Binary lokalisieren:**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ls /Applications/Orange.app/Contents/Frameworks/Python.framework/Versions/
|
|
106
|
+
# zeigt z.B.: 3.12 und Current -> 3.12
|
|
107
|
+
|
|
108
|
+
# Dann das echte Binary finden:
|
|
109
|
+
find /Applications/Orange.app -name "python3*" -type f 2>/dev/null | grep -i bin
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Typische gültige Pfade (je nach Version):
|
|
113
|
+
|
|
114
|
+
| Orange mit | Python-Binary |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Python 3.11 | `.../Versions/Current/bin/python3` |
|
|
117
|
+
| Python 3.11 (nur versioniert) | `.../Versions/Current/bin/python3.11` |
|
|
118
|
+
| Python 3.12 | `.../Versions/Current/bin/python3.12` |
|
|
119
|
+
| Intel-Mac mit 3.12 | `.../Versions/Current/bin/python3.12-intel64` |
|
|
120
|
+
|
|
121
|
+
> `Current` ist ein Symlink zur installierten Version (`Current -> 3.12`),
|
|
122
|
+
> funktioniert also in allen Fällen. Ersetze in den Befehlen unten
|
|
123
|
+
> `ORANGEPY` durch **den** gefundenen Pfad.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 🚀 Installation (Schritt für Schritt)
|
|
128
|
+
|
|
129
|
+
### macOS (Orange.app als `.app` installiert)
|
|
130
|
+
|
|
131
|
+
**Schritt 1 — Oranges eingebettetes Python prüfen:**
|
|
132
|
+
|
|
133
|
+
Öffne einen Terminal und führe aus (nutze den Pfad aus dem Abschnitt
|
|
134
|
+
[Oranges eingebettetes Python finden](#-oranges-eingebettetes-python-finden-wichtig);
|
|
135
|
+
je nach Orange-Version heißt das Binary `python3`, `python3.11` oder `python3.12`):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
/Applications/Orange.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3.12 --version
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Es sollte eine Python-3.x-Version ausgeben. Notiere dir diesen Pfad — er wird
|
|
142
|
+
in den nächsten Schritten gebraucht (im Folgenden abgekürzt als `ORANGEPY`).
|
|
143
|
+
|
|
144
|
+
**Schritt 2 — Add-on von GitHub installieren:**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
ORANGEPY=/Applications/Orange.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3.12
|
|
148
|
+
$ORANGEPY -m pip install git+https://github.com/philippweller/WellerLab.git@main#subdirectory=orange-plsda-addon
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Schritt 3 — Installation prüfen (optional, empfohlen):**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
$ORANGEPY -c "from orangeplsda import PLSDALearner; print('OK')"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Wenn `OK` erscheint, ist das Paket korrekt installiert.
|
|
158
|
+
|
|
159
|
+
**Schritt 4 — In Orange öffnen:**
|
|
160
|
+
|
|
161
|
+
Orange starten. Die Widgets **PLS-DA** und **OPLS-DA** erscheinen in der
|
|
162
|
+
Widget-Leiste unter **PLS-DA**. Du musst Orange nicht neu starten — es reicht,
|
|
163
|
+
das Canvas-Fenster erneut zu öffnen.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### Windows (Orange über den "Orange Command Prompt")
|
|
168
|
+
|
|
169
|
+
**Schritt 1 — Orange Command Prompt öffnen:**
|
|
170
|
+
|
|
171
|
+
Startmenü → *Orange* → *Orange Command Prompt* (bzw. *Qt Console*).
|
|
172
|
+
|
|
173
|
+
**Schritt 2 — Installieren:**
|
|
174
|
+
|
|
175
|
+
```cmd
|
|
176
|
+
python -m pip install git+https://github.com/philippweller/WellerLab.git@main#subdirectory=orange-plsda-addon
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Schritt 3 — Prüfen:**
|
|
180
|
+
|
|
181
|
+
```cmd
|
|
182
|
+
python -c "from orangeplsda import PLSDALearner; print('OK')"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Schritt 4 — Orange öffnen** und unter **PLS-DA** nachschauen.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### Linux / Conda / Venv
|
|
190
|
+
|
|
191
|
+
**Schritt 1 — Umgebung aktivieren:**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
conda activate orange # oder: source .venv/bin/activate
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Schritt 2 — Installieren:**
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
pip install git+https://github.com/philippweller/WellerLab.git@main#subdirectory=orange-plsda-addon
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Schritt 3 — Prüfen:**
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
python -c "from orangeplsda import PLSDALearner; print('OK')"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Schritt 4 — Orange starten** und das Widget suchen.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 🔄 Updates einspielen (bei neuen Versionen)
|
|
214
|
+
|
|
215
|
+
Auf **jedem** Rechner, auf dem das Add-on installiert ist:
|
|
216
|
+
|
|
217
|
+
**Schritt 1 — aktuelle Version installieren (Überschreibt die alte):**
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# macOS
|
|
221
|
+
$ORANGEPY -m pip install --upgrade --force-reinstall git+https://github.com/philippweller/WellerLab.git@main#subdirectory=orange-plsda-addon
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
# Windows / Linux / Conda
|
|
226
|
+
python -m pip install --upgrade --force-reinstall git+https://github.com/philippweller/WellerLab.git@main#subdirectory=orange-plsda-addon
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Schritt 2 — Orange neu starten**, damit die neuen Widget-Versionen geladen werden.
|
|
230
|
+
|
|
231
|
+
> Hinweis: `--force-reinstall` wird empfohlen, da Orange die Widgets beim
|
|
232
|
+
> Canvas-Öffnen zwischenspeichert.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🗑️ Deinstallation
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# macOS
|
|
240
|
+
$ORANGEPY -m pip uninstall orangeplsda -y
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# Windows / Linux / Conda
|
|
245
|
+
python -m pip uninstall orangeplsda -y
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Danach Orange neu starten — die Widgets sind verschwunden.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 🧪 Kurzer Funktionstest (Entwickler)
|
|
253
|
+
|
|
254
|
+
Mit Oranges Python auf einem der mitgelieferten Datensätze:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
python -c "
|
|
258
|
+
from Orange.data import Table
|
|
259
|
+
from Orange.base import Model
|
|
260
|
+
from orangeplsda import PLSDALearner
|
|
261
|
+
from orangeplsda.oplsda_learner import OPLSDALearner
|
|
262
|
+
for L, kw in [(PLSDALearner, dict(n_components=2)),
|
|
263
|
+
(OPLSDALearner, dict(n_components=1, n_ortho=1))]:
|
|
264
|
+
t = Table('iris'); m = L(**kw)(t)
|
|
265
|
+
p, pr = m(t, ret=Model.ValueProbs)
|
|
266
|
+
print(L.__name__, round(float((p == t.Y.flatten()).mean()), 3), pr.shape)
|
|
267
|
+
"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Erwartete Ausgabe (beispielhaft, kann leicht abweichen):
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
PLSDALearner 0.813 (150, 3)
|
|
274
|
+
OPLSDALearner 0.8 (150, 3)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## 🛠️ Fehlerbehebung
|
|
280
|
+
|
|
281
|
+
| Problem | Lösung |
|
|
282
|
+
|---|---|
|
|
283
|
+
| "`python3` ist nicht vorhanden" / Datei nicht gefunden | Das Binary heißt je nach Orange-Version `python3`, `python3.11` oder `python3.12` (z.B. Orange mit Python 3.12). Mit `find /Applications/Orange.app -name "python3*" -type f` den echten Namen ermitteln. |
|
|
284
|
+
| `from orangeplsda import ...` schlägt fehl | Du hast `/usr/bin/python3` statt Oranges Python benutzt. Siehe Schritt 1 oben. |
|
|
285
|
+
| Widget erscheint nicht in Orange | Gelöschte `*.egg-info`/`__pycache__` prüfen; Orange vollständig neu starten; `pip show orangeplsda` ausführen. |
|
|
286
|
+
| `Host key verification failed` beim pip install | Läuft nur bei einem gepushten SSH-Workflow, nicht bei `git+https://`. Nutze die https-URL. |
|
|
287
|
+
| alte Version bleibt | `pip install --force-reinstall` verwenden (siehe Updates). |
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 📦 Entwicklung / Repo lokal ausprobieren
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
git clone git@github.com:philippweller/orange-plsda-addon.git
|
|
295
|
+
cd orange-plsda-addon
|
|
296
|
+
# Editable-Install mit Oranges Python:
|
|
297
|
+
$ORANGEPY -m pip install -e .
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 📄 Lizenz
|
|
303
|
+
|
|
304
|
+
MIT © Philipp Weller
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
orangeplsda/__init__.py,sha256=zgCCNLE81dw33rlhy2REUHvXzt33In_W1muf9V10xl8,327
|
|
2
|
+
orangeplsda/oplsda_learner.py,sha256=Y90BsZON1tlt8TTJRyvd_FJtzW-L2am6Zjt0ctkp5eg,11149
|
|
3
|
+
orangeplsda/plsda_learner.py,sha256=fZbhsutGCOTmXwE23bPGEt6SrOZTTHZU-v89OGZ1Crg,4096
|
|
4
|
+
orangeplsda/widgets/__init__.py,sha256=WhoYDwW6n8iT7ggBqJXAsMxOVwFIL6V_Zn-uQzwS6BU,139
|
|
5
|
+
orangeplsda/widgets/owoplsda.py,sha256=VbXluAZGoVGSLofnIILU4bSv7yS05-_et6mXCX6MM0M,10839
|
|
6
|
+
orangeplsda/widgets/owplsda.py,sha256=Eem7r5drfPr97AMynpeUdamYqrDo3Cxl3hVOhLtkDCI,6603
|
|
7
|
+
orangeplsda/widgets/icons/OPLSDA.svg,sha256=uWyf09OgRhD0rfE5lCz1HpwYviwpVAJLsvfBsaOeIbQ,770
|
|
8
|
+
orangeplsda/widgets/icons/PLSDA.svg,sha256=NolAdlEDpdn2OaiWs-x25yPFYBtMcxuN5nOtbptFFVM,631
|
|
9
|
+
orangeplsda-0.1.0.dist-info/METADATA,sha256=flhS8qHgOlFkno5AXpNnqty60tT1XthAJR6imAQV37k,9314
|
|
10
|
+
orangeplsda-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
orangeplsda-0.1.0.dist-info/entry_points.txt,sha256=Xm1rYknZVE8c5DZ_4R8-WuTx5Jv4PcdF1QsfHD_M4ik,46
|
|
12
|
+
orangeplsda-0.1.0.dist-info/top_level.txt,sha256=W6UoRienZbpiQHCvCLtWJB2ccLVgyHBYOA4WB6OHDBM,12
|
|
13
|
+
orangeplsda-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
orangeplsda
|