Skip to content

Commit d0dc35c

Browse files
committed
Adding multioutput support for KPCovC
1 parent 880aa65 commit d0dc35c

File tree

5 files changed

+231
-142
lines changed

5 files changed

+231
-142
lines changed

src/skmatter/decomposition/_kernel_pcovc.py

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22

33
from sklearn import clone
4+
from sklearn.multioutput import MultiOutputClassifier
45
from sklearn.svm import LinearSVC
56
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
67
from sklearn.linear_model import (
@@ -52,6 +53,9 @@ class KernelPCovC(LinearClassifierMixin, _BaseKPCov):
5253
5354
n_components == n_samples
5455
56+
n_outputs : int
57+
The number of outputs when ``fit`` is performed.
58+
5559
svd_solver : {'auto', 'full', 'arpack', 'randomized'}, default='auto'
5660
If auto :
5761
The solver is selected by a default policy based on `X.shape` and
@@ -78,13 +82,14 @@ class KernelPCovC(LinearClassifierMixin, _BaseKPCov):
7882
- ``sklearn.linear_model.LogisticRegressionCV()``
7983
- ``sklearn.svm.LinearSVC()``
8084
- ``sklearn.discriminant_analysis.LinearDiscriminantAnalysis()``
85+
- ``sklearn.multioutput.MultiOutputClassifier()``
8186
- ``sklearn.linear_model.RidgeClassifier()``
8287
- ``sklearn.linear_model.RidgeClassifierCV()``
8388
- ``sklearn.linear_model.Perceptron()``
8489
8590
If a pre-fitted classifier is provided, it is used to compute :math:`{\mathbf{Z}}`.
86-
If None, ``sklearn.linear_model.LogisticRegression()``
87-
is used as the classifier.
91+
If None and ``n_outputs < 2``, ``sklearn.linear_model.LogisticRegression()`` is used.
92+
If None and ``n_outputs == 2``, ``sklearn.multioutput.MultiOutputClassifier()`` is used.
8893
8994
kernel : {"linear", "poly", "rbf", "sigmoid", "precomputed"} or callable, default="linear"
9095
Kernel.
@@ -132,6 +137,9 @@ class KernelPCovC(LinearClassifierMixin, _BaseKPCov):
132137
133138
Attributes
134139
----------
140+
n_outputs : int
141+
The number of outputs when ``fit`` is performed.
142+
135143
classifier : estimator object
136144
The linear classifier passed for fitting. If pre-fitted, it is assummed
137145
to be fit on a precomputed kernel :math:`\mathbf{K}` and :math:`\mathbf{Y}`.
@@ -268,9 +276,11 @@ def fit(self, X, Y, W=None):
268276
self: object
269277
Returns the instance itself.
270278
"""
271-
X, Y = validate_data(self, X, Y, y_numeric=False)
279+
X, Y = validate_data(self, X, Y, multi_output=True, y_numeric=False)
280+
272281
check_classification_targets(Y)
273282
self.classes_ = np.unique(Y)
283+
self.n_outputs = 1 if Y.ndim == 1 else Y.shape[1]
274284

275285
super()._set_fit_params(X)
276286

@@ -285,6 +295,7 @@ def fit(self, X, Y, W=None):
285295
LogisticRegressionCV,
286296
LinearSVC,
287297
LinearDiscriminantAnalysis,
298+
MultiOutputClassifier,
288299
RidgeClassifier,
289300
RidgeClassifierCV,
290301
SGDClassifier,
@@ -300,27 +311,37 @@ def fit(self, X, Y, W=None):
300311
", or `precomputed`"
301312
)
302313

303-
if self.classifier != "precomputed":
304-
if self.classifier is None:
305-
classifier = LogisticRegression()
306-
else:
307-
classifier = self.classifier
314+
multioutput = self.n_outputs != 1
315+
precomputed = self.classifier == "precomputed"
308316

309-
# for convergence warnings
310-
if hasattr(classifier, "max_iter") and (
311-
classifier.max_iter is None or classifier.max_iter < 500
312-
):
313-
classifier.max_iter = 500
317+
if self.classifier is None or precomputed:
318+
# used as the default classifier for subsequent computations
319+
classifier = (
320+
MultiOutputClassifier(LogisticRegression())
321+
if multioutput
322+
else LogisticRegression()
323+
)
324+
else:
325+
classifier = self.classifier
314326

315-
# Check if classifier is fitted; if not, fit with precomputed K
316-
self.z_classifier_ = check_cl_fit(classifier, K, Y)
317-
W = self.z_classifier_.coef_.T
327+
if hasattr(classifier, "max_iter") and (
328+
classifier.max_iter is None or classifier.max_iter < 500
329+
):
330+
classifier.max_iter = 500
331+
332+
if precomputed and W is None:
333+
_ = clone(classifier).fit(K, Y)
334+
if multioutput:
335+
W = np.hstack([_.coef_.T for _ in _.estimators_])
336+
else:
337+
W = _.coef_.T
318338

319339
else:
320-
# If precomputed, use default classifier to predict Y from T
321-
classifier = LogisticRegression(max_iter=500)
322-
if W is None:
323-
W = LogisticRegression().fit(K, Y).coef_.T
340+
self.z_classifier_ = check_cl_fit(classifier, K, Y)
341+
if multioutput:
342+
W = np.hstack([est_.coef_.T for est_ in self.z_classifier_.estimators_])
343+
else:
344+
W = self.z_classifier_.coef_.T
324345

325346
Z = K @ W
326347

@@ -333,10 +354,16 @@ def fit(self, X, Y, W=None):
333354

334355
self.classifier_ = clone(classifier).fit(K @ self.pkt_, Y)
335356

336-
self.ptz_ = self.classifier_.coef_.T
337-
self.pkz_ = self.pkt_ @ self.ptz_
357+
if multioutput:
358+
self.ptz_ = np.hstack(
359+
[est_.coef_.T for est_ in self.classifier_.estimators_]
360+
)
361+
self.pkz_ = self.pkt_ @ self.ptz_
362+
else:
363+
self.ptz_ = self.classifier_.coef_.T
364+
self.pkz_ = self.pkt_ @ self.ptz_
338365

339-
if len(Y.shape) == 1 and type_of_target(Y) == "binary":
366+
if not multioutput and type_of_target(Y) == "binary":
340367
self.pkz_ = self.pkz_.reshape(
341368
K.shape[1],
342369
)
@@ -345,6 +372,7 @@ def fit(self, X, Y, W=None):
345372
)
346373

347374
self.components_ = self.pkt_.T # for sklearn compatibility
375+
348376
return self
349377

350378
def predict(self, X=None, T=None):
@@ -424,9 +452,12 @@ def decision_function(self, X=None, T=None):
424452
425453
Returns
426454
-------
427-
Z : numpy.ndarray, shape (n_samples,) or (n_samples, n_classes)
455+
Z : numpy.ndarray, shape (n_samples,) or (n_samples, n_classes), or a list of \
456+
n_outputs such arrays if n_outputs > 1
428457
Confidence scores. For binary classification, has shape `(n_samples,)`,
429-
for multiclass classification, has shape `(n_samples, n_classes)`
458+
for multiclass classification, has shape `(n_samples, n_classes)`.
459+
If n_outputs > 1, the list can contain arrays with differing shapes
460+
depending on the number of classes in each output of Y.
430461
"""
431462
check_is_fitted(self, attributes=["pkz_", "ptz_"])
432463

@@ -439,9 +470,21 @@ def decision_function(self, X=None, T=None):
439470
if self.center:
440471
K = self.centerer_.transform(K)
441472

442-
# Or self.classifier_.decision_function(K @ self.pkt_)
443-
return K @ self.pkz_ + self.classifier_.intercept_
473+
if self.n_outputs == 1:
474+
# Or self.classifier_.decision_function(K @ self.pkt_)
475+
return K @ self.pkz_ + self.classifier_.intercept_
476+
else:
477+
return [
478+
est_.decision_function(K @ self.pkt_)
479+
for est_ in self.classifier_.estimators_
480+
]
444481

445482
else:
446483
T = check_array(T)
447-
return T @ self.ptz_ + self.classifier_.intercept_
484+
485+
if self.n_outputs == 1:
486+
T @ self.ptz_ + self.classifier_.intercept_
487+
else:
488+
return [
489+
est_.decision_function(T) for est_ in self.classifier_.estimators_
490+
]

src/skmatter/decomposition/_pcovc.py

Lines changed: 39 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222

2323

2424
# No inheritance from MultiOutputMixin because decision_function would fail
25-
# test_check_estimator.py 'check_classifier_multioutput' (line 2479 of estimator_checks.py)
26-
# - this is the only test for MultiOutputClassifiers, so is it OK to exclude this tag?
25+
# test_check_estimator.py 'check_classifier_multioutput' (line 2479 of estimator_checks.py).
26+
# This is the only test for multioutput classifiers, so is it OK to exclude this tag?
2727

2828
# did a search of all classifiers that inherit from MultiOutputMixin - none of them implement
29-
# decision function, so I don't think we need to inherit
29+
# decision function
3030

3131

3232
class PCovC(LinearClassifierMixin, _BasePCov):
@@ -120,6 +120,7 @@ class PCovC(LinearClassifierMixin, _BasePCov):
120120
- ``sklearn.linear_model.LogisticRegressionCV()``
121121
- ``sklearn.svm.LinearSVC()``
122122
- ``sklearn.discriminant_analysis.LinearDiscriminantAnalysis()``
123+
- ``sklearn.multioutput.MultiOutputClassifier()``
123124
- ``sklearn.linear_model.RidgeClassifier()``
124125
- ``sklearn.linear_model.RidgeClassifierCV()``
125126
- ``sklearn.linear_model.Perceptron()``
@@ -131,8 +132,8 @@ class PCovC(LinearClassifierMixin, _BasePCov):
131132
`sklearn.pipeline.Pipeline` with model caching.
132133
In such cases, the classifier will be re-fitted on the same
133134
training data as the composite estimator.
134-
If None and ``Y.ndim < 2``, ``sklearn.linear_model.LogisticRegression()`` is used.
135-
If None and ``Y.ndim == 2``, ``sklearn.multioutput.MultiOutputClassifier()`` is used.
135+
If None and ``n_outputs < 2``, ``sklearn.linear_model.LogisticRegression()`` is used.
136+
If None and ``n_outputs == 2``, ``sklearn.multioutput.MultiOutputClassifier()`` is used.
136137
137138
iterated_power : int or 'auto', default='auto'
138139
Number of iterations for the power method computed by
@@ -164,6 +165,9 @@ class PCovC(LinearClassifierMixin, _BasePCov):
164165
n_components, or the lesser value of n_features and n_samples
165166
if n_components is None.
166167
168+
n_outputs : int
169+
The number of outputs when ``fit`` is performed.
170+
167171
classifier : estimator object
168172
The linear classifier passed for fitting.
169173
@@ -263,16 +267,14 @@ def fit(self, X, Y, W=None):
263267
264268
Y : numpy.ndarray, shape (n_samples,) or (n_samples, n_outputs)
265269
Training data, where n_samples is the number of samples and
266-
n_outputs is the number of outputs. If ``self.classifier`` is an instance
267-
of ``sklearn.multioutput.MultiOutputClassifier()``, Y can be of shape
268-
(n_samples, n_outputs).
270+
n_outputs is the number of outputs.
269271
270272
W : numpy.ndarray, shape (n_features, n_classes)
271273
Classification weights, optional when classifier is ``precomputed``. If
272274
not passed, it is assumed that the weights will be taken from a
273275
linear classifier fit between :math:`\mathbf{X}` and :math:`\mathbf{Y}`.
274-
In the case of a multioutput classifier ``classifier``,
275-
`` W = np.hstack([est_.coef_.T for est_ in classifier.estimators_])``.
276+
In the multioutput case,
277+
`` W = np.hstack([est_.coef_.T for est_ in classifier.estimators_])``.
276278
"""
277279
X, Y = validate_data(self, X, Y, multi_output=True, y_numeric=False)
278280

@@ -303,49 +305,31 @@ def fit(self, X, Y, W=None):
303305
", or `precomputed`"
304306
)
305307

306-
if self.n_outputs == 1 and isinstance(self.classifier, MultiOutputClassifier):
307-
raise ValueError(
308-
"Classifier cannot be an instance of `MultiOutputClassifier` when Y is 1D"
309-
)
308+
multioutput = self.n_outputs != 1
309+
precomputed = self.classifier == "precomputed"
310310

311-
if (
312-
self.n_outputs != 1
313-
and self.classifier not in ["precomputed", None]
314-
and not (
315-
isinstance(self.classifier, MultiOutputClassifier)
316-
or self.classifier == "precomputed"
317-
)
318-
):
319-
raise ValueError(
320-
"Classifier must be an instance of `MultiOutputClassifier` when Y is 2D"
311+
if self.classifier is None or precomputed:
312+
# used as the default classifier for subsequent computations
313+
classifier = (
314+
MultiOutputClassifier(LogisticRegression())
315+
if multioutput
316+
else LogisticRegression()
321317
)
318+
else:
319+
classifier = self.classifier
322320

323-
if self.n_outputs == 1:
324-
if self.classifier != "precomputed":
325-
classifier = self.classifier or LogisticRegression()
326-
self.z_classifier_ = check_cl_fit(classifier, X, Y)
327-
W = self.z_classifier_.coef_.T
328-
321+
if precomputed and W is None:
322+
_ = clone(classifier).fit(X, Y)
323+
if multioutput:
324+
W = np.hstack([_.coef_.T for _ in _.estimators_])
329325
else:
330-
# to be used later on as the classifier fit between T and Y
331-
classifier = LogisticRegression()
332-
if W is None:
333-
W = clone(classifier).fit(X, Y).coef_.T
334-
326+
W = _.coef_.T
335327
else:
336-
if self.classifier != "precomputed":
337-
classifier = self.classifier or MultiOutputClassifier(
338-
estimator=LogisticRegression()
339-
)
340-
self.z_classifier_ = check_cl_fit(classifier, X, Y)
328+
self.z_classifier_ = check_cl_fit(classifier, X, Y)
329+
if multioutput:
341330
W = np.hstack([est_.coef_.T for est_ in self.z_classifier_.estimators_])
342-
343331
else:
344-
# to be used later on as the classifier fit between T and Y
345-
classifier = MultiOutputClassifier(estimator=LogisticRegression())
346-
if W is None:
347-
_ = clone(classifier).fit(X, Y)
348-
W = np.hstack([_.coef_.T for _ in _.estimators_])
332+
W = self.z_classifier_.coef_.T
349333

350334
Z = X @ W
351335

@@ -358,21 +342,21 @@ def fit(self, X, Y, W=None):
358342
# classifier and steal weights to get pxz and ptz
359343
self.classifier_ = clone(classifier).fit(X @ self.pxt_, Y)
360344

361-
if self.n_outputs == 1:
362-
self.ptz_ = self.classifier_.coef_.T
363-
# print(self.ptz_.shape)
364-
self.pxz_ = self.pxt_ @ self.ptz_
365-
else:
345+
if multioutput:
366346
self.ptz_ = np.hstack(
367347
[est_.coef_.T for est_ in self.classifier_.estimators_]
368348
)
369349
# print(f"pxt {self.pxt_.shape}")
370350
# print(f"ptz {self.ptz_.shape}")
371351
self.pxz_ = self.pxt_ @ self.ptz_
372352
# print(f"pxz {self.pxz_.shape}")
353+
else:
354+
self.ptz_ = self.classifier_.coef_.T
355+
# print(self.ptz_.shape)
356+
self.pxz_ = self.pxt_ @ self.ptz_
373357

374358
# print(self.ptz_.shape)
375-
if len(Y.shape) == 1 and type_of_target(Y) == "binary":
359+
if not multioutput and type_of_target(Y) == "binary":
376360
self.pxz_ = self.pxz_.reshape(
377361
X.shape[1],
378362
)
@@ -472,9 +456,9 @@ def decision_function(self, X=None, T=None):
472456
Z : numpy.ndarray, shape (n_samples,) or (n_samples, n_classes), or a list of \
473457
n_outputs such arrays if n_outputs > 1
474458
Confidence scores. For binary classification, has shape `(n_samples,)`,
475-
for multiclass classification, has shape `(n_samples, n_classes)`. If n_outputs > 1,
476-
the list can contain arrays with differing shapes depending on the
477-
number of classes in each output of Y.
459+
for multiclass classification, has shape `(n_samples, n_classes)`.
460+
If n_outputs > 1, the list can contain arrays with differing shapes
461+
depending on the number of classes in each output of Y.
478462
"""
479463
check_is_fitted(self, attributes=["pxz_", "ptz_"])
480464

@@ -529,36 +513,3 @@ def transform(self, X=None):
529513
and n_features is the number of features.
530514
"""
531515
return super().transform(X)
532-
533-
# def score(self, X, Y, sample_weight=None):
534-
# """Return the accuracy on the given test data and labels. Contains support
535-
# for multiclass-multioutput data.
536-
537-
# Parameters
538-
# ----------
539-
# X : array-like of shape (n_samples, n_features)
540-
# Test samples.
541-
542-
# Y : array-like of shape (n_samples,) or (n_samples, n_outputs)
543-
# True labels for `X`.
544-
545-
# sample_weight : array-like of shape (n_samples,), default=None
546-
# Sample weights. Can only be used if the PCovC instance
547-
# has been trained on single-target data.
548-
549-
# Returns
550-
# -------
551-
# score : float
552-
# Accuracy scores. If the PCovC instance was trained on a 1D Y,
553-
# this will call the ``score()`` function defined by
554-
# ``sklearn.base.ClassifierMixin``. If trained on a 2D Y, this will
555-
# call the ``score()`` function defined by
556-
# ``sklearn.multioutput.MultiOutputClassifier``.
557-
# """
558-
# X, Y = validate_data(self, X, Y, reset=False)
559-
560-
# if isinstance(self.classifier_, MultiOutputClassifier):
561-
# # LinearClassifierMixin.score fails with multioutput-multiclass Y
562-
# return self.classifier_.score(X @ self.pxt_, Y)
563-
# else:
564-
# return self.classifier_.score(X @ self.pxt_, Y, sample_weight=sample_weight)

0 commit comments

Comments
 (0)