Skip to content

Commit a2d6028

Browse files
authored
refactor: remove target transformation (#13)
1 parent 6e8121f commit a2d6028

File tree

2 files changed

+23
-33
lines changed

2 files changed

+23
-33
lines changed

src/neo_ls_svm/_affine_feature_map.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Affine feature map."""
22

33
from functools import cached_property
4-
from typing import TypeVar, cast
4+
from typing import Any, TypeVar, cast
55

66
import numpy as np
77
import numpy.typing as npt
@@ -86,7 +86,7 @@ def transform(self, X: FloatMatrix[F]) -> FloatMatrix[F]:
8686
if A.shape[1] < A.shape[0]
8787
else (X - shift) @ (A / scale.T)
8888
)
89-
)
89+
).astype(X.dtype)
9090
if self.append_features and A is not None:
9191
X_transformed = np.hstack((X, X_transformed))
9292
return X_transformed
@@ -108,7 +108,7 @@ def inverse_transform(self, X_transformed: FloatMatrix[F]) -> FloatMatrix[F]:
108108
if A is not None:
109109
pinvA = cast(FloatMatrix[F], self.pseudo_inverse)
110110
X = X @ pinvA
111-
X = (X * scale + shift).astype(shift.dtype)
111+
X = (X * scale + shift).astype(X.dtype)
112112
return X
113113

114114
def get_feature_names_out(
@@ -130,3 +130,7 @@ def get_feature_names_out(
130130
if self.append_features and A is not None:
131131
output_features = np.hstack((input_features_array, output_features))
132132
return output_features
133+
134+
def _more_tags(self) -> dict[str, Any]:
135+
# https://scikit-learn.org/stable/developers/develop.html#estimator-tags
136+
return {"preserves_dtype": [np.float64, np.float32]}

src/neo_ls_svm/_neo_ls_svm.py

+16-30
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,13 @@ def __init__( # noqa: PLR0913
5454
primal_feature_map: KernelApproximatingFeatureMap | None = None,
5555
dual_feature_map: AffineSeparator | None = None,
5656
dual: bool | None = None,
57-
max_epochs: int = 1,
5857
refit: bool = False,
5958
random_state: int | np.random.RandomState | None = 42,
6059
estimator_type: Literal["classifier", "regressor"] | None = None,
6160
) -> None:
6261
self.primal_feature_map = primal_feature_map
6362
self.dual_feature_map = dual_feature_map
6463
self.dual = dual
65-
self.max_epochs = max_epochs
6664
self.refit = refit
6765
self.random_state = random_state
6866
self.estimator_type = estimator_type
@@ -140,9 +138,7 @@ def _optimize_β̂_γ(
140138
= 1 / (self.γs_[np.newaxis, :] + λ[:, np.newaxis])
141139
with np.errstate(divide="ignore", invalid="ignore"):
142140
loo_residuals = (φβ̂ @ - y[:, np.newaxis]) / (1 - h @ )
143-
loo_residuals = loo_residuals * self.y_scale_
144-
y_true = y * self.y_scale_ + self.y_shift_
145-
ŷ_loo = loo_residuals + y_true[:, np.newaxis]
141+
ŷ_loo = y[:, np.newaxis] + loo_residuals
146142
# In the case of binary classification, clip overly positive and overly negative
147143
# predictions' residuals to 0 when the labels are positive and negative, respectively.
148144
if self._estimator_type == "classifier":
@@ -163,14 +159,14 @@ def _optimize_β̂_γ(
163159
self.loo_leverage_ = h @ [:, optimum]
164160
self.loo_error_ = self.loo_errors_γs_[optimum]
165161
if self._estimator_type == "classifier":
166-
self.loo_score_ = accuracy_score(y_true, np.sign(ŷ_loo[:, optimum]), sample_weight=s)
162+
self.loo_score_ = accuracy_score(y, np.sign(ŷ_loo[:, optimum]), sample_weight=s)
167163
elif self._estimator_type == "regressor":
168-
self.loo_score_ = r2_score(y_true, ŷ_loo[:, optimum], sample_weight=s)
164+
self.loo_score_ = r2_score(y, ŷ_loo[:, optimum], sample_weight=s)
169165
β̂, γ = β̂ @ [:, optimum], self.γs_[optimum]
170166
# Resolve the linear system for better accuracy.
171167
if self.refit:
172168
β̂ = np.linalg.solve(γ * C + A, φSTSy)
173-
self.residuals_ = (np.real(φ @ β̂) - y) * self.y_scale_
169+
self.residuals_ = np.real(φ @ β̂) - y
174170
if self._estimator_type == "classifier":
175171
self.residuals_[(y > 0) & (self.residuals_ > 0)] = 0
176172
self.residuals_[(y < 0) & (self.residuals_ < 0)] = 0
@@ -273,9 +269,7 @@ def _optimize_α̂_γ(
273269
np.fill_diagonal(F_loo, 0)
274270
α̂_loo = α̂ @ (1 / (self.γs_[np.newaxis, :] * ρ + λ[:, np.newaxis]))
275271
ŷ_loo = np.sum(F_loo[:, np.newaxis, :] * H_loo, axis=2) * α̂_loo + F_loo @ α̂_loo
276-
ŷ_loo = ŷ_loo * self.y_scale_ + self.y_shift_
277-
y_true = y * self.y_scale_ + self.y_shift_
278-
loo_residuals = ŷ_loo - y_true[:, np.newaxis]
272+
loo_residuals = ŷ_loo - y[:, np.newaxis]
279273
# In the case of binary classification, clip overly positive and overly negative
280274
# predictions' residuals to 0 when the labels are positive and negative, respectively.
281275
if self._estimator_type == "classifier":
@@ -295,21 +289,21 @@ def _optimize_α̂_γ(
295289
self.loo_residuals_ = loo_residuals[:, optimum]
296290
self.loo_error_ = self.loo_errors_γs_[optimum]
297291
if self._estimator_type == "classifier":
298-
self.loo_score_ = accuracy_score(y_true, np.sign(ŷ_loo[:, optimum]), sample_weight=s)
292+
self.loo_score_ = accuracy_score(y, np.sign(ŷ_loo[:, optimum]), sample_weight=s)
299293
elif self._estimator_type == "regressor":
300-
self.loo_score_ = r2_score(y_true, ŷ_loo[:, optimum], sample_weight=s)
294+
self.loo_score_ = r2_score(y, ŷ_loo[:, optimum], sample_weight=s)
301295
α̂, γ = α̂_loo[:, optimum], self.γs_[optimum]
302296
# Resolve the linear system for better accuracy.
303297
if self.refit:
304298
α̂ = np.linalg.solve(γ * ρ * np.diag(sn**-2) + K, y)
305-
self.residuals_ = (F @ α̂ - y) * self.y_scale_
299+
self.residuals_ = F @ α̂ - y
306300
if self._estimator_type == "classifier":
307301
self.residuals_[(y > 0) & (self.residuals_ > 0)] = 0
308302
self.residuals_[(y < 0) & (self.residuals_ < 0)] = 0
309303
# TODO: Print warning if optimal γ is found at the edge.
310304
return α̂, γ
311305

312-
def fit( # noqa: PLR0915
306+
def fit(
313307
self, X: FloatMatrix[F], y: GenericVector, sample_weight: FloatVector[F] | None = None
314308
) -> "NeoLSSVM":
315309
"""Fit this predictor."""
@@ -347,19 +341,10 @@ def fit( # noqa: PLR0915
347341
y_ = np.ones(y.shape, dtype=X.dtype)
348342
y_[negatives] = -1
349343
elif self._estimator_type == "regressor":
350-
y_ = cast(npt.NDArray[np.floating[Any]], y)
344+
y_ = y.astype(X.dtype)
351345
else:
352346
message = "Target type not supported"
353347
raise ValueError(message)
354-
# Fit robust shift and scale parameters for the target y.
355-
if self._estimator_type == "classifier":
356-
self.y_shift_: float = 0.0
357-
self.y_scale_: float = 1.0
358-
elif self._estimator_type == "regressor":
359-
l, self.y_shift_, u = np.quantile(y_, [0.05, 0.5, 0.95]) # noqa: E741
360-
self.y_scale_ = np.maximum(np.abs(l - self.y_shift_), np.abs(u - self.y_shift_))
361-
self.y_scale_ = 1.0 if self.y_scale_ <= np.finfo(X.dtype).eps else self.y_scale_
362-
y_ = ((y_ - self.y_shift_) / self.y_scale_).astype(X.dtype)
363348
# Determine whether we want to solve this in the primal or dual space.
364349
self.dual_ = X.shape[0] <= 1024 if self.dual is None else self.dual # noqa: PLR2004
365350
self.primal_ = not self.dual_
@@ -390,7 +375,7 @@ def fit( # noqa: PLR0915
390375
self.predict_proba_calibrator_ = IsotonicRegression(
391376
out_of_bounds="clip", y_min=0, y_max=1, increasing=True
392377
)
393-
ŷ_loo = self.loo_residuals_ + y_
378+
ŷ_loo = y_ + self.loo_residuals_
394379
target = np.zeros_like(y_)
395380
target[y_ == np.max(y_)] = 1.0
396381
self.predict_proba_calibrator_.fit(ŷ_loo, target, sample_weight_)
@@ -404,10 +389,11 @@ def decision_function(self, X: FloatMatrix[F]) -> FloatVector[F]:
404389
φ = cast(KernelApproximatingFeatureMap, self.primal_feature_map_).transform(X)
405390
ŷ = np.real(φ @ self.β̂_)
406391
else:
407-
# Shift and scale X, then predict as ŷ(x) := k(x, X) â + 1'â.
392+
# Apply an affine transformation to X, then predict as ŷ(x) := k(x, X) â + 1'â.
408393
X = cast(AffineFeatureMap, self.dual_feature_map_).transform(X)
409394
K = rbf_kernel(X, self.X_, gamma=0.5)
410-
ŷ = K @ self.α̂_ + np.sum(self.α̂_)
395+
b = np.sum(self.α̂_)
396+
ŷ = K @ self.α̂_ + b
411397
return ŷ
412398

413399
def predict(self, X: FloatMatrix[F]) -> GenericVector:
@@ -423,8 +409,8 @@ def predict(self, X: FloatMatrix[F]) -> GenericVector:
423409
# Remap to the original class labels.
424410
ŷ = self.classes_[((ŷ_df + 1) // 2).astype(np.intp)]
425411
elif self._estimator_type == "regressor":
426-
# Undo the label shift and scale.
427-
ŷ = ŷ_df.astype(np.float64) * self.y_scale_ + self.y_shift_
412+
# The decision function is the point prediction.
413+
ŷ = ŷ_df
428414
# Map back to the training target dtype.
429415
ŷ = ŷ.astype(self.y_dtype_)
430416
return ŷ

0 commit comments

Comments
 (0)