import warnings
from typing import Any, ClassVar, Self, TypeVar
import numpy as np
from numpy.typing import ArrayLike
from scipy.special import expit
from sklearn.base import clone
from sklearn.utils._param_validation import HasMethods
from sklearn.utils.validation import check_is_fitted, validate_data
from ..._types import FloatArrayLike, FloatNDArray, IntNDArray, ParameterConstraint
try:
from xgboost import XGBClassifier
except ImportError:
XGBClassifier = TypeVar('XGBClassifier') # type: ignore[misc, assignment]
try:
from lightgbm import LGBMClassifier
except ImportError:
LGBMClassifier = TypeVar('LGBMClassifier') # type: ignore[misc, assignment]
try:
from catboost import CatBoostClassifier
except ImportError:
CatBoostClassifier = TypeVar('CatBoostClassifier') # type: ignore[misc, assignment]
from ..._common import Parameter
from ...metrics import BaseMetric, Capability
from .._base.cost_sensitive import CostSensitiveClassifier
from ._backends import ( # ruff: ignore[unused-import] (re-exported for tests/models/test_csboost.py)
_BASE_SCORE_PROBA,
_BASE_SCORE_RAW,
BoostingBackend,
backend_for,
)
def _backend_for_estimator(estimator: Any) -> BoostingBackend | None:
""":func:`backend_for`, threading through this module's own (patchable) classifier names."""
return backend_for(estimator, xgb_cls=XGBClassifier, lgbm_cls=LGBMClassifier, catboost_cls=CatBoostClassifier)
[docs]
class CSBoostClassifier(CostSensitiveClassifier):
"""
Cost-sensitive gradient boosting classifier.
CSBoostClassifier supports :class:`xgboost:xgboost.XGBClassifier`, :class:`lightgbm:lightgbm.LGBMClassifier`
and `CatBoostClassifier
<https://catboost.ai/docs/en/concepts/python-reference_catboostclassifier>`__ as base estimators.
By default, it uses XGBoost classifier with default hyperparameters.
Read more in the :ref:`User Guide <csboost>`.
.. seealso::
:class:`~empulse.models.CSLogitClassifier` : Cost-sensitive logistic regression classifier.
:class:`~empulse.models.CSTreeClassifier` : Cost-sensitive decision tree classifier.
:class:`~empulse.models.CSForestClassifier` : Cost-sensitive random forest classifier.
Parameters
----------
estimator : :class:`xgboost:xgboost.XGBClassifier`, :class:`lightgbm:lightgbm.LGBMClassifier` \
or `CatBoostClassifier <https://catboost.ai/docs/en/concepts/python-reference_catboostclassifier>`__, optional
XGBoost or LightGBM classifier to be fit with desired hyperparameters.
If not provided, a XGBoost classifier with default hyperparameters is used.
tp_cost : float or array-like, shape=(n_samples,), default=0.0
Cost of true positives. If ``float``, then all true positives have the same cost.
If array-like, then it is the cost of each true positive classification.
Is overwritten if another `tp_cost` is passed to the ``fit`` method.
.. note::
It is not recommended to pass instance-dependent costs to the ``__init__`` method.
Instead, pass them to the ``fit`` method.
tn_cost : float or array-like, shape=(n_samples,), default=0.0
Cost of true negatives. If ``float``, then all true negatives have the same cost.
If array-like, then it is the cost of each true negative classification.
Is overwritten if another `tn_cost` is passed to the ``fit`` method.
.. note::
It is not recommended to pass instance-dependent costs to the ``__init__`` method.
Instead, pass them to the ``fit`` method.
fn_cost : float or array-like, shape=(n_samples,), default=0.0
Cost of false negatives. If ``float``, then all false negatives have the same cost.
If array-like, then it is the cost of each false negative classification.
Is overwritten if another `fn_cost` is passed to the ``fit`` method.
.. note::
It is not recommended to pass instance-dependent costs to the ``__init__`` method.
Instead, pass them to the ``fit`` method.
fp_cost : float or array-like, shape=(n_samples,), default=0.0
Cost of false positives. If ``float``, then all false positives have the same cost.
If array-like, then it is the cost of each false positive classification.
Is overwritten if another `fp_cost` is passed to the ``fit`` method.
.. note::
It is not recommended to pass instance-dependent costs to the ``__init__`` method.
Instead, pass them to the ``fit`` method.
loss : :class:`~empulse.metrics.BaseMetric` or None, default=None
Loss function to optimize. Loss parameters are passed as ``loss_params``
to the :meth:`~empulse.models.CSBoostClassifier.fit` method.
Attributes
----------
classes_ : numpy.ndarray, shape=(n_classes,)
Unique classes in the target.
estimator_ : :class:`xgboost:xgboost.XGBClassifier`
Fitted XGBoost classifier.
References
----------
.. [1] Höppner, S., Baesens, B., Verbeke, W., & Verdonck, T. (2022).
Instance-dependent cost-sensitive learning for detecting transfer fraud.
European Journal of Operational Research, 297(1), 291-300.
Examples
--------
.. code-block:: python
import numpy as np
from empulse.models import CSBoostClassifier
from sklearn.datasets import make_classification
X, y = make_classification()
fn_cost = np.random.rand(y.size) # instance-dependent cost
fp_cost = 5 # constant cost
model = CSBoostClassifier()
model.fit(X, y, fn_cost=fn_cost, fp_cost=fp_cost)
y_proba = model.predict_proba(X)
Example with passing instance-dependent costs through cross-validation:
.. code-block:: python
import numpy as np
from empulse.models import CSBoostClassifier
from sklearn import set_config
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
set_config(enable_metadata_routing=True)
X, y = make_classification()
fn_cost = np.random.rand(y.size)
fp_cost = 5
pipeline = Pipeline([
('scaler', StandardScaler()),
('model', CSBoostClassifier().set_fit_request(fn_cost=True, fp_cost=True))
])
cross_val_score(pipeline, X, y, params={'fn_cost': fn_cost, 'fp_cost': fp_cost})
Example with passing instance-dependent costs through a grid search:
.. code-block:: python
import numpy as np
from empulse.metrics import expected_cost_loss
from empulse.models import CSBoostClassifier
from sklearn import set_config
from sklearn.datasets import make_classification
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import make_scorer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from xgboost import XGBClassifier
set_config(enable_metadata_routing=True)
X, y = make_classification(n_samples=50)
fn_cost = np.random.rand(y.size)
fp_cost = 5
pipeline = Pipeline([
('scaler', StandardScaler()),
('model', CSBoostClassifier(
XGBClassifier(n_jobs=2, n_estimators=10)
).set_fit_request(fn_cost=True, fp_cost=True))
])
param_grid = {
'model__estimator__learning_rate': np.logspace(-5, 0, 5),
}
scorer = make_scorer(
expected_cost_loss,
response_method='predict_proba',
greater_is_better=False,
)
scorer = scorer.set_score_request(fn_cost=True, fp_cost=True)
grid_search = GridSearchCV(pipeline, param_grid=param_grid, scoring=scorer)
grid_search.fit(X, y, fn_cost=fn_cost, fp_cost=fp_cost)
"""
estimator_: XGBClassifier | LGBMClassifier | CatBoostClassifier
_parameter_constraints: ClassVar[ParameterConstraint] = {
'estimator': [HasMethods(['fit', 'predict_proba']), None],
**CostSensitiveClassifier._parameter_constraints,
}
def __init__(
self,
estimator: XGBClassifier | LGBMClassifier | CatBoostClassifier | None = None,
*,
tp_cost: FloatArrayLike | float = 0.0,
tn_cost: FloatArrayLike | float = 0.0,
fn_cost: FloatArrayLike | float = 0.0,
fp_cost: FloatArrayLike | float = 0.0,
loss: BaseMetric | None = None,
) -> None:
self.estimator = estimator
super().__init__(tp_cost=tp_cost, tn_cost=tn_cost, fp_cost=fp_cost, fn_cost=fn_cost, loss=loss)
[docs]
def fit(
self,
X: FloatArrayLike,
y: ArrayLike,
*,
tp_cost: FloatArrayLike | float | Parameter = Parameter.UNCHANGED,
tn_cost: FloatArrayLike | float | Parameter = Parameter.UNCHANGED,
fn_cost: FloatArrayLike | float | Parameter = Parameter.UNCHANGED,
fp_cost: FloatArrayLike | float | Parameter = Parameter.UNCHANGED,
fit_params: dict[str, Any] | None = None,
**loss_params: Any,
) -> Self:
"""
Fit the model.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Training data.
y : array-like of shape (n_samples,)
Target values.
tp_cost : float or array-like, shape=(n_samples,), default=$UNCHANGED$
Cost of true positives. If ``float``, then all true positives have the same cost.
If array-like, then it is the cost of each true positive classification.
tn_cost : float or array-like, shape=(n_samples,), default=$UNCHANGED$
Cost of true negatives. If ``float``, then all true negatives have the same cost.
If array-like, then it is the cost of each true negative classification.
fn_cost : float or array-like, shape=(n_samples,), default=$UNCHANGED$
Cost of false negatives. If ``float``, then all false negatives have the same cost.
If array-like, then it is the cost of each false negative classification.
fp_cost : float or array-like, shape=(n_samples,), default=$UNCHANGED$
Cost of false positives. If ``float``, then all false positives have the same cost.
If array-like, then it is the cost of each false positive classification.
fit_params : dict
Additional keyword arguments to pass to the estimator's fit method.
**loss_params : dict
Additional keyword arguments to pass to the loss function if using a custom loss function.
Returns
-------
self : CSBoostClassifier
Fitted CSBoost model.
"""
super().fit(
X,
y,
tp_cost=tp_cost,
tn_cost=tn_cost,
fn_cost=fn_cost,
fp_cost=fp_cost,
fit_params=fit_params,
**loss_params,
)
return self
def _fit(
self,
X: FloatNDArray,
y: IntNDArray,
loss: BaseMetric,
*,
fit_params: dict[str, Any] | None = None,
**loss_params: Any,
) -> Self:
fit_params = {} if fit_params is None else dict(fit_params)
# allow sample weights still to be passed as kwargs to comply with sklearn interface
if 'sample_weight' in loss_params:
fit_params['sample_weight'] = loss_params.pop('sample_weight')
# Checked against `self.estimator` (not yet cloned/fitted), before any estimator is
# built, so this raises before any of `self`'s state changes -- matching what a
# constructor-supplied backend rejects, e.g. CatBoost banning `sample_weight`.
if self.estimator is not None:
early_backend = _backend_for_estimator(self.estimator)
if early_backend is not None:
early_backend.check_fit_params(fit_params)
if self.estimator is None:
backend = self._initialize_default_estimator(y=y, loss=loss, **loss_params)
else:
backend = self._initialize_custom_estimator(y=y, loss=loss, **loss_params)
with warnings.catch_warnings():
for message, category in backend.warning_filters:
warnings.filterwarnings('ignore', message=message, category=category)
self.estimator_.fit(X, y, **backend.fit_kwargs(X, y), **fit_params)
return self
def _initialize_default_estimator(
self,
y: FloatNDArray,
loss: BaseMetric,
**loss_params: Any,
) -> BoostingBackend:
xgb_cls = None if isinstance(XGBClassifier, TypeVar) else XGBClassifier
backend = BoostingBackend(name='xgboost', classifier=xgb_cls)
if backend.classifier is None:
raise ImportError(
f'XGBoost package is required to use {type(self).__name__}. '
'Install the boosting backends through `pip install empulse[boosting]` or '
'`pip install xgboost`'
)
objective = self._get_objective(backend, y, loss=loss, **loss_params)
self.estimator_ = backend.build_default(objective)
return backend
def _initialize_custom_estimator(
self,
y: FloatNDArray,
loss: BaseMetric,
**loss_params: Any,
) -> BoostingBackend:
backend = _backend_for_estimator(self.estimator)
if backend is None:
raise TypeError('Estimator must be an instance of XGBClassifier, LGBMClassifier, or CatBoostClassifier')
objective = self._get_objective(backend, y=y, loss=loss, **loss_params)
self.estimator_ = backend.apply_objective(clone(self.estimator), objective)
return backend
def _get_objective(
self,
backend: BoostingBackend,
y: FloatNDArray,
loss: BaseMetric,
**loss_params: Any,
) -> Any:
# MaxProfit requires dynamic thresholding from current round predictions, and LogCost's
# per-sample loss is non-linear in the predicted probability (unlike Cost/Savings), so both
# evaluate gradients/hessians directly from the metric each iteration instead of going through
# a precomputed constant.
capabilities = loss.capabilities
if Capability.BOOST_OBJECTIVE in capabilities:
return backend.wrap_objective(loss, y, loss_params, precomputed=False)
if Capability.PRECOMPUTED_BOOST_OBJECTIVE not in capabilities:
raise ValueError(
f'{type(self).__name__} requires a loss whose strategy supports gradient boosting '
f"(neither 'boost_objective' nor 'precomputed_boost_objective'; got the "
f'{loss.strategy.name!r} strategy).'
)
return backend.wrap_objective(loss, y, loss_params, precomputed=True)
[docs]
def predict_proba(self, X: ArrayLike) -> FloatNDArray:
"""
Predict class probabilities for X.
Parameters
----------
X : 2D numpy.ndarray, shape=(n_samples, n_features)
The input samples.
Returns
-------
y_pred : 2D numpy.ndarray, shape=(n_samples, n_classes)
Predicted class probabilities.
"""
check_is_fitted(self)
X = validate_data(self, X, reset=False)
y_proba: FloatNDArray
backend = _backend_for_estimator(self.estimator_)
raw_score = backend.raw_score(self.estimator_, X) if backend is not None else None
if raw_score is not None:
y_proba = expit(raw_score + _BASE_SCORE_RAW)
return np.column_stack([1 - y_proba, y_proba])
y_proba = self.estimator_.predict_proba(X)
return y_proba