Skip to content

Commit 02d6e3c

Browse files
authored
Merge pull request #16 from LinearBoost/v0.1.4
Using precomputed kernels
2 parents cf6c43f + 4f81032 commit 02d6e3c

File tree

2 files changed

+170
-34
lines changed

2 files changed

+170
-34
lines changed

src/linearboost/linear_boost.py

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import numpy as np
2727
from sklearn.base import clone
2828
from sklearn.ensemble import AdaBoostClassifier
29+
from sklearn.metrics.pairwise import pairwise_kernels
2930
from sklearn.pipeline import make_pipeline
3031
from sklearn.preprocessing import (
3132
MaxAbsScaler,
@@ -95,8 +96,9 @@ def _boost(self, iboost, X, y, sample_weight, random_state):
9596
iboost : int
9697
The index of the current boost iteration.
9798
98-
X : {array-like} of shape (n_samples, n_features)
99-
The training input samples.
99+
X : {array-like} of shape (n_samples, n_features) or (n_samples, n_samples)
100+
The training input samples. For kernel methods, this will be a
101+
precomputed kernel matrix.
100102
101103
y : array-like of shape (n_samples,)
102104
The target values (class labels).
@@ -375,6 +377,14 @@ class LinearBoostClassifier(_DenseAdaBoostClassifier):
375377
scaler_ : transformer
376378
The scaler instance used to transform the data.
377379
380+
X_fit_ : ndarray of shape (n_samples, n_features)
381+
The training data after scaling, stored when kernel != 'linear'
382+
for prediction purposes.
383+
384+
K_train_ : ndarray of shape (n_samples, n_samples)
385+
The precomputed kernel matrix on training data, stored when
386+
kernel != 'linear'.
387+
378388
Notes
379389
-----
380390
This classifier only supports binary classification tasks.
@@ -426,8 +436,20 @@ def __init__(
426436
degree=3,
427437
coef0=1,
428438
):
439+
# Create SEFR estimator with 'precomputed' kernel if we're using kernels
440+
# Use string comparison that's safe for arrays (will raise TypeError for arrays)
441+
try:
442+
if kernel == "linear":
443+
base_estimator = SEFR(kernel="linear")
444+
else:
445+
base_estimator = SEFR(kernel="precomputed")
446+
except (ValueError, TypeError):
447+
# If kernel is an array or invalid type, default to linear
448+
# Parameter validation will catch this later in fit()
449+
base_estimator = SEFR(kernel="linear")
450+
429451
super().__init__(
430-
estimator=SEFR(kernel=kernel, gamma=gamma, degree=degree, coef0=coef0),
452+
estimator=base_estimator,
431453
n_estimators=n_estimators,
432454
learning_rate=learning_rate,
433455
)
@@ -489,6 +511,37 @@ def _check_X_y(self, X, y) -> tuple[np.ndarray, np.ndarray]:
489511

490512
return X, y
491513

514+
def _get_kernel_matrix(self, X, Y=None):
515+
"""Compute kernel matrix between X and Y.
516+
517+
Parameters
518+
----------
519+
X : array-like of shape (n_samples_X, n_features)
520+
Input samples.
521+
Y : array-like of shape (n_samples_Y, n_features), default=None
522+
Input samples. If None, use X.
523+
524+
Returns
525+
-------
526+
K : ndarray of shape (n_samples_X, n_samples_Y)
527+
Kernel matrix.
528+
"""
529+
if Y is None:
530+
Y = X
531+
532+
if callable(self.kernel):
533+
return self.kernel(X, Y)
534+
else:
535+
return pairwise_kernels(
536+
X,
537+
Y,
538+
metric=self.kernel,
539+
filter_params=True,
540+
gamma=self.gamma,
541+
degree=self.degree,
542+
coef0=self.coef0,
543+
)
544+
492545
def fit(self, X, y, sample_weight=None) -> Self:
493546
"""Build a LinearBoost classifier from the training set (X, y).
494547
@@ -515,6 +568,7 @@ def fit(self, X, y, sample_weight=None) -> Self:
515568
if self.scaler not in _scalers:
516569
raise ValueError('Invalid scaler provided; got "%s".' % self.scaler)
517570

571+
# Apply scaling
518572
if self.scaler == "minmax":
519573
self.scaler_ = clone(_scalers["minmax"])
520574
else:
@@ -538,10 +592,20 @@ def fit(self, X, y, sample_weight=None) -> Self:
538592
X_transformed = X_transformed[nonzero_mask]
539593
y = y[nonzero_mask]
540594
sample_weight = sample_weight[nonzero_mask]
595+
541596
X_transformed, y = self._check_X_y(X_transformed, y)
542597
self.classes_ = np.unique(y)
543598
self.n_classes_ = self.classes_.shape[0]
544599

600+
# Store training data for kernel computation during prediction
601+
if self.kernel != "linear":
602+
self.X_fit_ = X_transformed
603+
# Precompute kernel matrix ONCE for all estimators
604+
self.K_train_ = self._get_kernel_matrix(X_transformed)
605+
training_data = self.K_train_
606+
else:
607+
training_data = X_transformed
608+
545609
if self.class_weight is not None:
546610
if isinstance(self.class_weight, str) and self.class_weight != "balanced":
547611
raise ValueError(
@@ -566,7 +630,8 @@ def fit(self, X, y, sample_weight=None) -> Self:
566630
category=FutureWarning,
567631
message=".*parameter 'algorithm' is deprecated.*",
568632
)
569-
return super().fit(X_transformed, y, sample_weight)
633+
# Pass the precomputed kernel matrix (or raw features for linear)
634+
return super().fit(training_data, y, sample_weight)
570635

571636
@staticmethod
572637
def _samme_proba(estimator, n_classes, X):
@@ -590,6 +655,15 @@ def _samme_proba(estimator, n_classes, X):
590655
)
591656

592657
def _boost(self, iboost, X, y, sample_weight, random_state):
658+
"""
659+
Implement a single boost using precomputed kernel matrix or raw features.
660+
661+
Parameters
662+
----------
663+
X : ndarray
664+
For kernel methods, this is the precomputed kernel matrix.
665+
For linear methods, this is the raw feature matrix.
666+
"""
593667
estimator = self._make_estimator(random_state=random_state)
594668
estimator.fit(X, y, sample_weight=sample_weight)
595669

@@ -668,13 +742,20 @@ class in ``classes_``, respectively.
668742
check_is_fitted(self)
669743
X_transformed = self.scaler_.transform(X)
670744

745+
if self.kernel == "linear":
746+
# For linear kernel, pass raw features
747+
test_data = X_transformed
748+
else:
749+
# For kernel methods, compute kernel matrix between test and training data
750+
test_data = self._get_kernel_matrix(X_transformed, self.X_fit_)
751+
671752
if self.algorithm == "SAMME.R":
672753
# Proper SAMME.R decision function
673754
classes = self.classes_
674755
n_classes = len(classes)
675756

676757
pred = sum(
677-
self._samme_proba(estimator, n_classes, X_transformed)
758+
self._samme_proba(estimator, n_classes, test_data)
678759
for estimator in self.estimators_
679760
)
680761
pred /= self.estimator_weights_.sum()
@@ -685,7 +766,7 @@ class in ``classes_``, respectively.
685766

686767
else:
687768
# Standard SAMME algorithm from AdaBoostClassifier (discrete)
688-
return super().decision_function(X_transformed)
769+
return super().decision_function(test_data)
689770

690771
def predict(self, X):
691772
"""Predict classes for X.

src/linearboost/sefr.py

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ class SEFR(LinearClassifierMixin, BaseEstimator):
4444
Specifies if a constant (a.k.a. bias or intercept) should be
4545
added to the decision function.
4646
47-
kernel : {'linear', 'poly', 'rbf', 'sigmoid'} or callable, default='linear'
47+
kernel : {'linear', 'poly', 'rbf', 'sigmoid', 'precomputed'} or callable, default='linear'
4848
Specifies the kernel type to be used in the algorithm.
4949
If a callable is given, it is used to pre-compute the kernel matrix.
50+
If 'precomputed', X is assumed to be a kernel matrix.
5051
5152
gamma : float, default=None
5253
Kernel coefficient for 'rbf', 'poly' and 'sigmoid'. If None, then it is
53-
set to 1.0 / n_features.
54+
set to 1.0 / n_features. Ignored when kernel='precomputed'.
5455
5556
degree : int, default=3
5657
Degree for 'poly' kernels. Ignored by other kernels.
@@ -80,7 +81,7 @@ class SEFR(LinearClassifierMixin, BaseEstimator):
8081
has feature names that are all strings.
8182
8283
X_fit_ : ndarray of shape (n_samples, n_features)
83-
The training data, stored when a kernel is used.
84+
The training data, stored when a kernel is used (except for 'precomputed').
8485
8586
Notes
8687
-----
@@ -100,7 +101,10 @@ class SEFR(LinearClassifierMixin, BaseEstimator):
100101

101102
_parameter_constraints: dict = {
102103
"fit_intercept": ["boolean"],
103-
"kernel": [StrOptions({"linear", "poly", "rbf", "sigmoid"}), callable],
104+
"kernel": [
105+
StrOptions({"linear", "poly", "rbf", "sigmoid", "precomputed"}),
106+
callable,
107+
],
104108
"gamma": [Interval(Real, 0, None, closed="left"), None],
105109
"degree": [Interval(Integral, 1, None, closed="left"), None],
106110
"coef0": [Real, None],
@@ -144,28 +148,58 @@ def _more_tags(self) -> dict[str, bool]:
144148
}
145149

146150
def _check_X(self, X) -> np.ndarray:
147-
X = validate_data(
148-
self,
149-
X,
150-
dtype="numeric",
151-
force_all_finite=True,
152-
reset=False,
153-
)
154-
if X.shape[1] != self.n_features_in_:
155-
raise ValueError(
156-
"Expected input with %d features, got %d instead."
157-
% (self.n_features_in_, X.shape[1])
151+
if self.kernel == "precomputed":
152+
X = validate_data(
153+
self,
154+
X,
155+
dtype="numeric",
156+
force_all_finite=True,
157+
reset=False,
158+
)
159+
# For precomputed kernels during prediction, X should be (n_test_samples, n_train_samples)
160+
if hasattr(self, "n_features_in_") and X.shape[1] != self.n_features_in_:
161+
raise ValueError(
162+
f"Precomputed kernel matrix should have {self.n_features_in_} columns "
163+
f"(number of training samples), got {X.shape[1]}."
164+
)
165+
else:
166+
X = validate_data(
167+
self,
168+
X,
169+
dtype="numeric",
170+
force_all_finite=True,
171+
reset=False,
158172
)
173+
if hasattr(self, "n_features_in_") and X.shape[1] != self.n_features_in_:
174+
raise ValueError(
175+
"Expected input with %d features, got %d instead."
176+
% (self.n_features_in_, X.shape[1])
177+
)
159178
return X
160179

161180
def _check_X_y(self, X, y) -> tuple[np.ndarray, np.ndarray]:
162-
X, y = check_X_y(
163-
X,
164-
y,
165-
dtype="numeric",
166-
force_all_finite=True,
167-
estimator=self,
168-
)
181+
if self.kernel == "precomputed":
182+
# For precomputed kernels, X should be a square kernel matrix
183+
X, y = check_X_y(
184+
X,
185+
y,
186+
dtype="numeric",
187+
force_all_finite=True,
188+
estimator=self,
189+
)
190+
if X.shape[0] != X.shape[1]:
191+
raise ValueError(
192+
f"Precomputed kernel matrix should be square, got shape {X.shape}."
193+
)
194+
else:
195+
X, y = check_X_y(
196+
X,
197+
y,
198+
dtype="numeric",
199+
force_all_finite=True,
200+
estimator=self,
201+
)
202+
169203
check_classification_targets(y)
170204

171205
if np.unique(y).shape[0] == 1:
@@ -180,6 +214,10 @@ def _check_X_y(self, X, y) -> tuple[np.ndarray, np.ndarray]:
180214
return X, y
181215

182216
def _get_kernel_matrix(self, X, Y=None):
217+
if self.kernel == "precomputed":
218+
# X is already a kernel matrix
219+
return X
220+
183221
if Y is None:
184222
Y = self.X_fit_
185223

@@ -203,9 +241,10 @@ def fit(self, X, y, sample_weight=None) -> Self:
203241
204242
Parameters
205243
----------
206-
X : {array-like, sparse matrix} of shape (n_samples, n_features)
244+
X : {array-like, sparse matrix} of shape (n_samples, n_features) or (n_samples, n_samples)
207245
Training vector, where `n_samples` is the number of samples and
208246
`n_features` is the number of features.
247+
If kernel='precomputed', X should be a square kernel matrix.
209248
210249
y : array-like of shape (n_samples,)
211250
Target vector relative to X.
@@ -219,15 +258,25 @@ def fit(self, X, y, sample_weight=None) -> Self:
219258
self
220259
Fitted estimator.
221260
"""
222-
_check_n_features(self, X=X, reset=True)
223-
_check_feature_names(self, X=X, reset=True)
261+
if self.kernel == "precomputed":
262+
_check_n_features(self, X=X, reset=True)
263+
_check_feature_names(self, X=X, reset=True)
264+
else:
265+
_check_n_features(self, X=X, reset=True)
266+
_check_feature_names(self, X=X, reset=True)
224267

225268
X, y = self._check_X_y(X, y)
226-
self.X_fit_ = X
269+
270+
# Store training data only for non-precomputed kernels
271+
if self.kernel != "precomputed":
272+
self.X_fit_ = X
273+
227274
self.classes_, y_ = np.unique(y, return_inverse=True)
228275

229276
if self.kernel == "linear":
230277
K = X
278+
elif self.kernel == "precomputed":
279+
K = X # X is already the kernel matrix
231280
else:
232281
K = self._get_kernel_matrix(X)
233282

@@ -277,10 +326,14 @@ def fit(self, X, y, sample_weight=None) -> Self:
277326
def decision_function(self, X):
278327
check_is_fitted(self)
279328
X = self._check_X(X)
329+
280330
if self.kernel == "linear":
281331
K = X
332+
elif self.kernel == "precomputed":
333+
K = X # X is already a kernel matrix
282334
else:
283335
K = self._get_kernel_matrix(X)
336+
284337
return (
285338
safe_sparse_dot(K, self.coef_.T, dense_output=True) + self.intercept_
286339
).ravel()
@@ -294,9 +347,10 @@ def predict_proba(self, X):
294347
295348
Parameters
296349
----------
297-
X : array-like of shape (n_samples, n_features)
350+
X : array-like of shape (n_samples, n_features) or (n_samples, n_train_samples)
298351
Vector to be scored, where `n_samples` is the number of samples and
299352
`n_features` is the number of features.
353+
If kernel='precomputed', X should have shape (n_samples, n_train_samples).
300354
301355
Returns
302356
-------
@@ -324,9 +378,10 @@ def predict_log_proba(self, X):
324378
325379
Parameters
326380
----------
327-
X : array-like of shape (n_samples, n_features)
381+
X : array-like of shape (n_samples, n_features) or (n_samples, n_train_samples)
328382
Vector to be scored, where `n_samples` is the number of samples and
329383
`n_features` is the number of features.
384+
If kernel='precomputed', X should have shape (n_samples, n_train_samples).
330385
331386
Returns
332387
-------

0 commit comments

Comments
 (0)