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.
@@ -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,8 @@
1
+ """
2
+ Widget definitions for orangeplsda.
3
+ """
4
+
5
+ from .owplsda import OWPLSDA
6
+ from .owoplsda import OWOPLSDA
7
+
8
+ __all__ = ["OWPLSDA", "OWOPLSDA"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [orange.widgets]
2
+ PLS-DA = orangeplsda.widgets
@@ -0,0 +1 @@
1
+ orangeplsda