import time
import numpy as np
from scipy.sparse import issparse
from scipy.special import expit
from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
from sklearn.preprocessing import minmax_scale
from sklearn.utils.multiclass import unique_labels, type_of_target
from sklearn.utils.validation import check_is_fitted
from sklearn.utils.validation import validate_data
from ._algorithm import predict, fit_forward_selection
[docs]
class Calf(ClassifierMixin, TransformerMixin, BaseEstimator):
"""Coarse approximation linear function.
Calf fits a linear model with coefficients w = (w1, ..., wp)
to maximize the AUC of the targets predicted by the linear function.
Parameters
----------
grid : tuple, list, or int, default=(-1, 1)
The search grid for weight candidates.
auc_tol : float, default=1e-6
Tolerance above max AUC for inclusion of a feature index.
order_col : bool, default=False
Whether to order the columns by individual AUC prior to fitting.
verbose : bool, default=False
If True, print status messages.
Attributes
----------
coef_ : list of float
Estimated coefficients for the linear fit problem. Only
one target should be passed, and this is a 1D list of length
n_features.
auc_ : list of float
The cumulative AUC up to each selected feature.
weights_ : list of float
The non-zero weights assigned to the selected features.
feature_index_ : list of int
The indices of the features that contribute positive AUC.
classes_ : ndarray of shape (n_classes,)
The unique class labels.
X_ : {ndarray, sparse matrix}
The training input features.
y_ : ndarray
The target vector.
fit_time_ : float
The number of seconds to fit X to y.
Notes
-----
The feature matrix must be centered at 0. This can be accomplished with
sklearn.preprocessing.StandardScaler, or similar. No intercept is calculated.
Examples
--------
>>> import numpy as np
>>> from calfcv import Calf
>>> from sklearn.datasets import make_classification as mc
>>> X, y = mc(n_features=2, n_redundant=0, n_informative=2, n_clusters_per_class=1, random_state=42)
>>> np.round(X[0:3, :], 2)
array([[ 1.23, -0.76],
[ 0.7 , -1.38],
[ 2.55, 2.5 ]])
>>> y[0:3]
array([0, 0, 1])
>>> cls = Calf().fit(X, y)
>>> cls.score(X, y)
0.76
"""
def __init__(self, grid=(-1, 1), auc_tol=1e-6, order_col=False, verbose=False):
self.grid = [grid] if isinstance(grid, int) else grid
self.auc_tol = auc_tol
self.order_col = order_col
self.verbose = verbose
def _validate_input(self, X, reset=False):
"""Validate input data using scikit-learn utilities."""
return validate_data(
self, X=X, accept_sparse=["csr", "csc", "coo"], reset=reset
)
[docs]
def fit(self, X, y):
"""Fit the model according to the given training data.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
Training vector, where n_samples is the number of samples and
n_features is the number of features.
y : array-like of shape (n_samples,)
Binary target vector relative to X.
Returns
-------
self : object
Fitted estimator.
"""
if y is None:
raise ValueError("requires y to be passed, but the target y is None")
# 1. Validate data FIRST. This natively catches NaNs and raises a clean ValueError.
X, y = validate_data(
self, X=X, y=y, accept_sparse=["csr", "csc", "coo"], reset=True
)
# 2. Check target type SECOND. NaNs are already filtered out by this point.
y_type = type_of_target(y)
if y_type == "unknown":
raise ValueError("Unknown label type: target y must be binary.")
if y_type != "binary":
raise ValueError(
f"Only binary classification is supported. The type of the target is {y_type}."
)
self.classes_ = unique_labels(y)
# Catch Scikit-Learn's single-class tests
if len(self.classes_) < 2:
raise ValueError(
f"This solver needs samples of at least 2 classes in the data, "
f"but the data contains only one class: {self.classes_[0]}"
)
self.X_ = X
self.y_ = y
if self.verbose:
print(f"fitting {X.shape[1]} features.")
start = time.time()
# Direct call to algorithmic core without redundant sparse branching
self.auc_, self.weights_, self.feature_index_ = fit_forward_selection(
X,
y,
grid=self.grid,
auc_tol=self.auc_tol,
order_col=self.order_col,
verbose=self.verbose,
)
self.fit_time_ = time.time() - start
self.coef_ = [0] * X.shape[1]
for i, w in zip(self.feature_index_, self.weights_):
self.coef_[i] = w
return self
[docs]
def decision_function(self, X):
"""Identify confidence scores for the samples.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
The input features and samples to evaluate.
Returns
-------
scores : ndarray of shape (n_samples,)
The decision vector scaled between -1 and 1.
"""
check_is_fitted(self)
X = self._validate_input(X, reset=False)
# Fix 2: Ensure the matrix is sliceable
if issparse(X):
X = X.tocsr()
scores = np.array(
minmax_scale(
predict(X[:, self.feature_index_], self.weights_), feature_range=(-1, 1)
)
)
return scores
[docs]
def predict(self, X):
"""Predict class labels for samples in X.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
The data matrix for which we want to get the predictions.
Returns
-------
y_pred : ndarray of shape (n_samples,)
Vector containing the predicted class labels for each sample.
"""
check_is_fitted(self)
X = self._validate_input(X, reset=False)
if len(self.classes_) < 2:
y_class = self.y_
else:
y_class = np.heaviside(self.decision_function(X), 0).astype(int)
y_class = [self.classes_[x] for x in y_class]
return np.array(y_class)
[docs]
def predict_proba(self, X):
"""Probability estimates for samples in X.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
Vector to be scored, where n_samples is the number of samples and
n_features is the number of features.
Returns
-------
class_prob : ndarray of shape (n_samples, n_classes)
Returns the probability of the sample for each class in the model,
where classes are ordered as they are in `self.classes_`.
To create the probabilities, Calf uses the expit (sigmoid) function.
"""
check_is_fitted(self)
X = self._validate_input(X, reset=False)
y_proba = expit(self.decision_function(X))
class_prob = np.column_stack((1 - y_proba, y_proba))
return class_prob
def _more_tags(self):
return {"poor_score": True, "non_deterministic": True, "binary_only": True}
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.classifier_tags.poor_score = True
tags.classifier_tags.multi_class = False
tags.input_tags.sparse = True
tags.non_deterministic = True
tags.estimator_type = "classifier"
return tags