Source code for qsarkit.models._facades

"""Unified ``name``-dispatching QSAR regressor/classifier facades."""

from __future__ import annotations

import warnings
from typing import Any, Dict, Literal, Optional, Sequence, Tuple, Union

import numpy as np
import numpy.typing as npt
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
from sklearn.ensemble import (
    HistGradientBoostingClassifier,
    HistGradientBoostingRegressor,
    RandomForestClassifier,
)
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.linear_model import ElasticNet, Lasso, LogisticRegression, Ridge, RidgeClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.svm import SVC
from sklearn.utils.multiclass import unique_labels

from qsarkit.base.exceptions import ModelNotFittedError
from qsarkit.models._gaussian_process import GaussianProcessQSAR
from qsarkit.models._neural_network import NeuralNetworkQSAR
from qsarkit.models._pls import PLSRegressor
from qsarkit.models._random_forest import RandomForestQSAR
from qsarkit.models._svm import SVMQSAR
from qsarkit.models._tanimoto_kernel import TanimotoKernel
from qsarkit.neighbors import JaccardKNeighborsClassifier, JaccardKNeighborsRegressor

__all__ = ["QSARRegressor", "QSARClassifier"]

_BuiltinName = Literal[
    "rf", "svm", "gbm", "xgboost", "lightgbm", "knn", "pls",
    "ridge", "lasso", "elasticnet", "mlp", "gp",
]
#: Either one of the built-in names, or any scikit-learn-compatible
#: estimator class or instance (XGBoost, LightGBM, CatBoost, your own).
_ModelName = Union[_BuiltinName, Any]

_VALID_NAMES: Tuple[str, ...] = (
    "rf", "svm", "gbm", "xgboost", "lightgbm", "knn", "pls",
    "ridge", "lasso", "elasticnet", "mlp", "gp",
)


def _with_defaults(params: Dict[str, Any], **defaults: Any) -> Dict[str, Any]:
    """The facade's defaults, overridable by the caller's ``model_params``.

    ``model_params`` is the documented way to configure a backend, so passing
    a keyword the facade also sets has to work. Splatting both into the
    constructor instead raises ``TypeError: got multiple values for keyword
    argument``, which is a confusing way to reject a documented use --
    ``QSARClassifier("rf", model_params={"n_estimators": 100})`` hit exactly
    that. The caller's value wins.
    """
    merged = dict(defaults)
    merged.update(params)
    return merged


def _invalid_name_error(name: str) -> ValueError:
    return ValueError(f"name must be one of {_VALID_NAMES}, got {name!r}.")


def _is_estimator_like(obj: Any) -> bool:
    """True for anything exposing the scikit-learn ``fit``/``predict`` pair.

    Deliberately duck-typed rather than an ``isinstance`` check against
    :class:`~sklearn.base.BaseEstimator`: XGBoost, LightGBM and CatBoost
    all ship estimators that follow the protocol without inheriting from
    it, and so do plenty of in-house wrappers.
    """
    target = obj if isinstance(obj, type) else type(obj)
    return callable(getattr(target, "fit", None)) and callable(
        getattr(target, "predict", None)
    )


def _describe_estimator(obj: Any) -> str:
    """Readable name for an estimator name, class or instance."""
    if isinstance(obj, str):
        return obj
    if isinstance(obj, type):
        return obj.__name__
    return type(obj).__name__


class _CustomEstimatorMixin:
    """Shared handling of user-supplied estimators and per-method kwargs.

    Both facades accept, in place of a built-in ``name``, any object
    following the scikit-learn protocol -- an instance, or a class to be
    constructed from ``model_args``/``model_params``. Extra keyword
    arguments for ``fit``, ``predict`` and ``predict_proba`` are carried
    separately, because those belong to the call rather than to the
    constructor and some libraries only accept them there (XGBoost's
    ``eval_set``, LightGBM's ``callbacks``, CatBoost's ``verbose``).
    """

    __slots__ = ()

    # Supplied by the concrete facades. Declared here so the mixin type
    # checks on its own; both subclasses set them in __init__.
    name: _ModelName
    model_args: Optional[Sequence[Any]]
    model_params: Optional[Dict[str, Any]]

    def _build_custom(self) -> Any:
        """Instantiate or clone the user-supplied estimator."""
        from sklearn.base import clone

        estimator = self.name
        args = tuple(self.model_args or ())
        params = dict(self.model_params or {})

        if isinstance(estimator, type):
            return estimator(*args, **params)

        if args:
            raise ValueError(
                "model_args applies only when an estimator *class* is passed. "
                f"Got an instance of {type(estimator).__name__}; configure it "
                "directly, or pass the class instead."
            )
        # Clone so repeated fits never mutate the object the caller handed
        # us -- the same contract sklearn's own meta-estimators follow.
        # Non-sklearn estimators without get_params are used as given.
        try:
            built = clone(estimator)
        except (TypeError, RuntimeError):
            built = estimator
        if params:
            try:
                built.set_params(**params)
            except (AttributeError, ValueError) as exc:
                raise ValueError(
                    f"Could not apply model_params to "
                    f"{type(estimator).__name__}: {exc}. Pass the estimator "
                    "class instead of an instance to have them handed to the "
                    "constructor."
                ) from exc
        return built

    def _validate_name(self) -> None:
        """Reject a ``name`` that is neither a known string nor estimator-like."""
        if isinstance(self.name, str):
            if self.name not in _VALID_NAMES:
                raise _invalid_name_error(self.name)
            return
        if not _is_estimator_like(self.name):
            raise ValueError(
                f"name must be one of {_VALID_NAMES}, or an object following "
                "the scikit-learn API (a class or instance with .fit and "
                f".predict). Got {type(self.name).__name__!r}, which has "
                f"{'no fit' if not hasattr(self.name, 'fit') else 'no predict'} "
                "method."
            )

    @property
    def _uses_custom_estimator(self) -> bool:
        return not isinstance(self.name, str)


class _PLSDAClassifier(ClassifierMixin, BaseEstimator):
    """Binary partial least squares discriminant analysis (PLS-DA).

    Internal helper backing ``QSARClassifier(name="pls")``: fits
    :class:`~qsarkit.models.PLSRegressor` on a 0/1-encoded binary target
    and thresholds the continuous PLS output at 0.5 to obtain class
    predictions, with :meth:`predict_proba` given by a clipped version of
    that same continuous score. PLS-DA is the standard way chemometricians
    turn a regression tool (PLS) into a classifier when the number of
    descriptors greatly exceeds the number of compounds, a regime where
    logistic regression is unstable but PLS's latent-variable projection
    remains well behaved.

    Parameters
    ----------
    n_components : int, default 2
    scale : bool, default True
    max_iter : int, default 500
    tol : float, default 1e-6
    copy : bool, default True
        Forwarded verbatim to the internal :class:`~qsarkit.models.PLSRegressor`.

    Attributes
    ----------
    classes_ : ndarray of shape (2,)
        The two class labels seen during :meth:`fit`.
    pls_ : PLSRegressor
        The fitted internal PLS regressor.

    References
    ----------
    - Barker, M. & Rayens, W. (2003). "Partial least squares for
      discrimination." J. Chemometrics, 17(3), 166-173.
      https://doi.org/10.1002/cem.785
    """

    classes_: npt.NDArray[np.generic]
    pls_: PLSRegressor

    def __init__(
        self,
        n_components: int = 2,
        *,
        scale: bool = True,
        max_iter: int = 500,
        tol: float = 1e-6,
        copy: bool = True,
    ) -> None:
        self.n_components = n_components
        self.scale = scale
        self.max_iter = max_iter
        self.tol = tol
        self.copy = copy

    def fit(self, X: npt.ArrayLike, y: npt.ArrayLike) -> "_PLSDAClassifier":
        """Fit the underlying PLS regressor on a 0/1-encoded target."""
        y_arr = np.asarray(y)
        self.classes_ = unique_labels(y_arr)
        if len(self.classes_) != 2:
            raise ValueError(
                "PLS-DA (name='pls') supports binary classification only, "
                f"got {len(self.classes_)} classes."
            )
        y_binary = np.searchsorted(self.classes_, y_arr).astype(np.float64)
        self.pls_ = PLSRegressor(
            n_components=self.n_components,
            scale=self.scale,
            max_iter=self.max_iter,
            tol=self.tol,
            copy=self.copy,
        ).fit(X, y_binary)
        return self

    def _score(self, X: npt.ArrayLike) -> npt.NDArray[np.float64]:
        return np.asarray(self.pls_.predict(X), dtype=np.float64).ravel()

    def predict(self, X: npt.ArrayLike) -> npt.NDArray[np.generic]:
        """Predict the class whose side of the 0.5 threshold the score falls on."""
        idx = (self._score(X) >= 0.5).astype(np.intp)
        return self.classes_[idx]

    def predict_proba(self, X: npt.ArrayLike) -> npt.NDArray[np.float64]:
        """Class probabilities from the clipped continuous PLS score."""
        p1 = np.clip(self._score(X), 0.0, 1.0)
        return np.column_stack([1.0 - p1, p1])


[docs] class QSARRegressor(_CustomEstimatorMixin, RegressorMixin, BaseEstimator): """Unified, ``name``-dispatching QSAR regressor facade. Wraps a broad menu of regression algorithms behind one scikit-learn estimator, so pipelines and benchmarking code can sweep across algorithm families by changing a single string rather than importing and configuring each estimator by hand. Parameters ---------- name : {"rf", "svm", "gbm", "xgboost", "lightgbm", "knn", "pls", \ "ridge", "lasso", "elasticnet", "mlp", "gp"} Which algorithm to build. One-line rationale for each default: - ``"rf"``: :class:`~qsarkit.models.RandomForestQSAR` — robust, scale-insensitive default for tabular molecular descriptors (Svetnik et al. 2003). - ``"svm"``: :class:`~qsarkit.models.SVMQSAR` — RBF support-vector regression, strong on non-linear SAR with few hundred compounds (Burbidge et al. 2001). - ``"gbm"``: ``HistGradientBoostingRegressor`` — fast, regularized gradient boosting with no extra install required. - ``"xgboost"``: ``xgboost.XGBRegressor`` if installed, otherwise the ``"gbm"`` fallback with a warning (Chen & Guestrin 2016). - ``"lightgbm"``: ``lightgbm.LGBMRegressor`` if installed, otherwise the ``"gbm"`` fallback with a warning (Ke et al. 2017). - ``"knn"``: :class:`~qsarkit.neighbors.JaccardKNeighborsRegressor` — similarity-based read-across on fingerprints. - ``"pls"``: :class:`~qsarkit.models.PLSRegressor` — handles more descriptors than compounds via latent-variable projection (Wold et al. 2001). - ``"ridge"``: ``Ridge`` — L2-regularized linear baseline (Hoerl & Kennard 1970). - ``"lasso"``: ``Lasso`` — L1-regularized, sparse linear model for descriptor selection (Tibshirani 1996). - ``"elasticnet"``: ``ElasticNet`` — L1/L2 compromise, robust to correlated descriptors (Zou & Hastie 2005). - ``"mlp"``: :class:`~qsarkit.models.NeuralNetworkQSAR` — non-linear feed-forward network (Winkler 2004). - ``"gp"``: :class:`~qsarkit.models.GaussianProcessQSAR` — Tanimoto- kernel Gaussian process with predictive uncertainty (Ralaivola et al. 2005). random_state : int, optional Seed forwarded to the underlying estimator, where applicable. Ignored when ``name`` is an estimator instance, which is used as configured. model_params : dict, optional Keyword arguments for the underlying estimator's constructor, overriding any default (e.g. ``model_params={"n_estimators": 200}`` for ``name="rf"``). When ``name`` is an *instance*, these are applied with ``set_params``. model_args : sequence, optional Positional arguments for the constructor. Only meaningful when ``name`` is an estimator *class*; passing them alongside an instance raises, since the instance is already built. fit_params : dict, optional Extra keyword arguments passed to the estimator's ``fit``. Some libraries only accept certain options there rather than in the constructor -- XGBoost's ``eval_set``, LightGBM's ``callbacks``, CatBoost's ``verbose``, or a ``sample_weight`` array. predict_params : dict, optional Extra keyword arguments passed to the estimator's ``predict``. Attributes ---------- estimator_ : BaseEstimator The fitted underlying estimator. Examples -------- >>> from sklearn.datasets import make_regression >>> X, y = make_regression(n_samples=40, n_features=5, random_state=0) >>> model = QSARRegressor(name="rf", random_state=0).fit(X, y) >>> model.predict(X).shape (40,) ``name`` also accepts any object following the scikit-learn API, so the facade is not limited to the built-in menu. An instance is used as configured: >>> from sklearn.linear_model import Ridge >>> model = QSARRegressor(Ridge(alpha=2.0)).fit(X, y) >>> type(model.estimator_).__name__, model.estimator_.alpha ('Ridge', 2.0) The instance you pass is cloned, never mutated, so one configured template can seed several models: >>> template = Ridge(alpha=1.0) >>> _ = QSARRegressor(template).fit(X, y) >>> hasattr(template, "coef_") # still unfitted False A *class* is constructed from ``model_args``/``model_params``: >>> model = QSARRegressor(Ridge, model_params={"alpha": 5.0}).fit(X, y) >>> model.estimator_.alpha 5.0 ``fit_params`` reaches arguments the constructor does not take -- the same mechanism serves ``sample_weight`` here and ``eval_set`` for XGBoost or CatBoost: >>> import numpy as np >>> weights = np.linspace(0.5, 1.5, len(y)) >>> model = QSARRegressor(Ridge, fit_params={"sample_weight": weights}).fit(X, y) >>> model.predict(X).shape (40,) References ---------- - Breiman, L. (2001). "Random Forests." Machine Learning, 45(1), 5-32. https://doi.org/10.1023/A:1010933404324 - Cortes, C. & Vapnik, V. (1995). "Support-Vector Networks." Machine Learning, 20(3), 273-297. https://doi.org/10.1007/BF00994018 - Chen, T. & Guestrin, C. (2016). "XGBoost: A Scalable Tree Boosting System." KDD 2016. https://doi.org/10.1145/2939672.2939785 - Ke, G. et al. (2017). "LightGBM: A Highly Efficient Gradient Boosting Decision Tree." NeurIPS 2017. - Wold, S., Sjostrom, M. & Eriksson, L. (2001). Chemometrics and Intelligent Laboratory Systems, 58(2), 109-130. https://doi.org/10.1016/S0169-7439(01)00155-1 - Hoerl, A. E. & Kennard, R. W. (1970). "Ridge Regression: Biased Estimation for Nonorthogonal Problems." Technometrics, 12(1), 55-67. https://doi.org/10.1080/00401706.1970.10488634 - Tibshirani, R. (1996). "Regression Shrinkage and Selection via the Lasso." J. R. Stat. Soc. B, 58(1), 267-288. https://doi.org/10.1111/j.2517-6161.1996.tb02080.x - Zou, H. & Hastie, T. (2005). "Regularization and Variable Selection via the Elastic Net." J. R. Stat. Soc. B, 67(2), 301-320. https://doi.org/10.1111/j.1467-9868.2005.00503.x - Winkler, D. A. (2004). "Neural Networks as Robust Tools in Drug Design and Analysis." Mol. Biotechnol., 27(2), 139-167. https://doi.org/10.1385/MB:27:2:139 - Ralaivola, L. et al. (2005). "Graph Kernels for Chemical Informatics." Neural Networks, 18(8), 1093-1110. https://doi.org/10.1016/j.neunet.2005.07.009 """ estimator_: BaseEstimator def __init__( self, name: _ModelName, random_state: Optional[int] = None, model_params: Optional[Dict[str, Any]] = None, model_args: Optional[Sequence[Any]] = None, fit_params: Optional[Dict[str, Any]] = None, predict_params: Optional[Dict[str, Any]] = None, ) -> None: self.name = name self.random_state = random_state self.model_params = model_params self.model_args = model_args self.fit_params = fit_params self.predict_params = predict_params def _build(self) -> BaseEstimator: if self._uses_custom_estimator: return self._build_custom() params = dict(self.model_params or {}) name = self.name if name == "rf": return RandomForestQSAR(**_with_defaults(params, random_state=self.random_state)) if name == "svm": return SVMQSAR(**params) if name == "gbm": return HistGradientBoostingRegressor( **_with_defaults(params, random_state=self.random_state) ) if name == "xgboost": try: import xgboost as xgb except ImportError: warnings.warn( "xgboost is not installed; falling back to sklearn " "HistGradientBoostingRegressor. Install qsarkit-learn[boosting] " "for XGBoost.", stacklevel=2, ) return HistGradientBoostingRegressor( **_with_defaults(params, random_state=self.random_state) ) return xgb.XGBRegressor(**_with_defaults(params, random_state=self.random_state)) if name == "lightgbm": try: import lightgbm as lgb except ImportError: warnings.warn( "lightgbm is not installed; falling back to sklearn " "HistGradientBoostingRegressor. Install qsarkit-learn[boosting] " "for LightGBM.", stacklevel=2, ) return HistGradientBoostingRegressor( **_with_defaults(params, random_state=self.random_state) ) return lgb.LGBMRegressor(**_with_defaults(params, random_state=self.random_state)) if name == "knn": return JaccardKNeighborsRegressor(**params) if name == "pls": return PLSRegressor(**params) if name == "ridge": return Ridge(**_with_defaults(params, random_state=self.random_state)) if name == "lasso": return Lasso(**_with_defaults(params, random_state=self.random_state)) if name == "elasticnet": return ElasticNet(**_with_defaults(params, random_state=self.random_state)) if name == "mlp": return NeuralNetworkQSAR(**_with_defaults(params, random_state=self.random_state)) if name == "gp": return GaussianProcessQSAR(**_with_defaults(params, random_state=self.random_state)) raise _invalid_name_error(name)
[docs] def fit(self, X: npt.ArrayLike, y: npt.ArrayLike) -> "QSARRegressor": """Build (from ``name``) and fit the underlying regressor. Parameters ---------- X : array-like of shape (n_samples, n_features) y : array-like of shape (n_samples,) Returns ------- QSARRegressor The fitted estimator. Raises ------ ValueError If ``name`` is not a recognized algorithm name. """ self._validate_name() self.estimator_ = self._build() self.estimator_.fit(X, y, **(self.fit_params or {})) return self
def _check_fitted(self) -> None: if not hasattr(self, "estimator_"): raise ModelNotFittedError( f"{type(self).__name__} must be fitted before calling predict()." )
[docs] def predict(self, X: npt.ArrayLike) -> npt.NDArray[np.float64]: """Predict by delegating to the fitted underlying regressor. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- ndarray of shape (n_samples,) """ self._check_fitted() return np.asarray( self.estimator_.predict(X, **(self.predict_params or {})), dtype=np.float64, )
[docs] class QSARClassifier(_CustomEstimatorMixin, ClassifierMixin, BaseEstimator): """Unified, ``name``-dispatching QSAR classifier facade. The classification counterpart of :class:`QSARRegressor`: wraps a broad menu of classification algorithms behind one scikit-learn estimator, selected by a single ``name`` string. Parameters ---------- name : {"rf", "svm", "gbm", "xgboost", "lightgbm", "knn", "pls", \ "ridge", "lasso", "elasticnet", "mlp", "gp"} Which algorithm to build. One-line rationale for each default: - ``"rf"``: ``RandomForestClassifier`` — robust default ensemble for tabular fingerprints/descriptors (Svetnik et al. 2003). - ``"svm"``: ``SVC`` (RBF, ``probability=True``) — strong non-linear classifier on molecular fingerprints (Cortes & Vapnik 1995; Burbidge et al. 2001). - ``"gbm"``: ``HistGradientBoostingClassifier`` — fast, regularized gradient boosting, no extra install. - ``"xgboost"``: ``xgboost.XGBClassifier`` if installed, otherwise the ``"gbm"`` fallback with a warning (Chen & Guestrin 2016). - ``"lightgbm"``: ``lightgbm.LGBMClassifier`` if installed, otherwise the ``"gbm"`` fallback with a warning (Ke et al. 2017). - ``"knn"``: :class:`~qsarkit.neighbors.JaccardKNeighborsClassifier` — similarity-based read-across on fingerprints. - ``"pls"``: an internal binary PLS-DA wrapper around :class:`~qsarkit.models.PLSRegressor` (Barker & Rayens 2003). - ``"ridge"``: ``RidgeClassifier`` — fast linear baseline; **has no native** ``predict_proba`` (calling it raises ``AttributeError``). - ``"lasso"``: ``LogisticRegression(penalty="l1", solver="liblinear")`` — sparse linear classifier (Tibshirani 1996). - ``"elasticnet"``: ``LogisticRegression(penalty="elasticnet", \ solver="saga")`` — L1/L2 compromise (Zou & Hastie 2005). - ``"mlp"``: ``MLPClassifier`` — non-linear feed-forward network (Winkler 2004). - ``"gp"``: ``GaussianProcessClassifier`` with a :class:`~qsarkit.models.TanimotoKernel` — probabilistic fingerprint-similarity classifier (Ralaivola et al. 2005). random_state : int, optional Seed forwarded to the underlying estimator, where applicable. Ignored when ``name`` is an estimator instance, which is used as configured. model_params : dict, optional Keyword arguments for the underlying estimator's constructor, overriding any default (e.g. ``model_params={"n_estimators": 200}`` for ``name="rf"``). When ``name`` is an *instance*, these are applied with ``set_params``. model_args : sequence, optional Positional arguments for the constructor. Only meaningful when ``name`` is an estimator *class*; passing them alongside an instance raises, since the instance is already built. fit_params : dict, optional Extra keyword arguments passed to the estimator's ``fit``. Some libraries only accept certain options there rather than in the constructor -- XGBoost's ``eval_set``, LightGBM's ``callbacks``, CatBoost's ``verbose``, or a ``sample_weight`` array. predict_params : dict, optional Extra keyword arguments passed to the estimator's ``predict``. predict_proba_params : dict, optional Extra keyword arguments passed to the estimator's ``predict_proba``. Attributes ---------- estimator_ : BaseEstimator The fitted underlying scikit-learn (or xgboost/lightgbm) estimator. classes_ : ndarray Class labels, taken from the fitted underlying estimator. Examples -------- >>> from sklearn.datasets import make_classification >>> X, y = make_classification(n_samples=40, n_features=5, random_state=0) >>> model = QSARClassifier(name="rf", random_state=0).fit(X, y) >>> model.predict(X).shape (40,) As for :class:`QSARRegressor`, ``name`` accepts any estimator following the scikit-learn API -- CatBoost, XGBoost, LightGBM or your own -- as a class or an instance: >>> from sklearn.tree import DecisionTreeClassifier >>> model = QSARClassifier( ... DecisionTreeClassifier, model_params={"max_depth": 3}, random_state=0 ... ).fit(X, y) >>> type(model.estimator_).__name__ 'DecisionTreeClassifier' >>> model.predict_proba(X).shape (40, 2) ``classes_`` is taken from the estimator when it exposes one, and otherwise from the labels seen during ``fit``, so a wrapper that omits the attribute still works: >>> model.classes_.tolist() [0, 1] References ---------- - Breiman, L. (2001). Machine Learning, 45(1), 5-32. https://doi.org/10.1023/A:1010933404324 - Cortes, C. & Vapnik, V. (1995). Machine Learning, 20(3), 273-297. https://doi.org/10.1007/BF00994018 - Chen, T. & Guestrin, C. (2016). KDD 2016. https://doi.org/10.1145/2939672.2939785 - Ke, G. et al. (2017). NeurIPS 2017. - Barker, M. & Rayens, W. (2003). J. Chemometrics, 17(3), 166-173. https://doi.org/10.1002/cem.785 - Hoerl, A. E. & Kennard, R. W. (1970). Technometrics, 12(1), 55-67. https://doi.org/10.1080/00401706.1970.10488634 - Tibshirani, R. (1996). J. R. Stat. Soc. B, 58(1), 267-288. https://doi.org/10.1111/j.2517-6161.1996.tb02080.x - Zou, H. & Hastie, T. (2005). J. R. Stat. Soc. B, 67(2), 301-320. https://doi.org/10.1111/j.1467-9868.2005.00503.x - Winkler, D. A. (2004). Mol. Biotechnol., 27(2), 139-167. https://doi.org/10.1385/MB:27:2:139 - Ralaivola, L. et al. (2005). Neural Networks, 18(8), 1093-1110. https://doi.org/10.1016/j.neunet.2005.07.009 """ estimator_: BaseEstimator classes_: npt.NDArray[np.generic] def __init__( self, name: _ModelName, random_state: Optional[int] = None, model_params: Optional[Dict[str, Any]] = None, model_args: Optional[Sequence[Any]] = None, fit_params: Optional[Dict[str, Any]] = None, predict_params: Optional[Dict[str, Any]] = None, predict_proba_params: Optional[Dict[str, Any]] = None, ) -> None: self.name = name self.random_state = random_state self.model_params = model_params self.model_args = model_args self.fit_params = fit_params self.predict_params = predict_params self.predict_proba_params = predict_proba_params def _build(self) -> BaseEstimator: if self._uses_custom_estimator: return self._build_custom() params = dict(self.model_params or {}) name = self.name if name == "rf": return RandomForestClassifier( **_with_defaults(params, n_estimators=500, random_state=self.random_state) ) if name == "svm": return SVC(**_with_defaults(params, kernel="rbf", probability=True)) if name == "gbm": return HistGradientBoostingClassifier( **_with_defaults(params, random_state=self.random_state) ) if name == "xgboost": try: import xgboost as xgb except ImportError: warnings.warn( "xgboost is not installed; falling back to sklearn " "HistGradientBoostingClassifier. Install qsarkit-learn[boosting] " "for XGBoost.", stacklevel=2, ) return HistGradientBoostingClassifier( **_with_defaults(params, random_state=self.random_state) ) return xgb.XGBClassifier(**_with_defaults(params, random_state=self.random_state)) if name == "lightgbm": try: import lightgbm as lgb except ImportError: warnings.warn( "lightgbm is not installed; falling back to sklearn " "HistGradientBoostingClassifier. Install qsarkit-learn[boosting] " "for LightGBM.", stacklevel=2, ) return HistGradientBoostingClassifier( **_with_defaults(params, random_state=self.random_state) ) return lgb.LGBMClassifier(**_with_defaults(params, random_state=self.random_state)) if name == "knn": return JaccardKNeighborsClassifier(**params) if name == "pls": return _PLSDAClassifier(**params) if name == "ridge": return RidgeClassifier(**_with_defaults(params, random_state=self.random_state)) if name == "lasso": return LogisticRegression( penalty="l1", solver="liblinear", random_state=self.random_state, **params, ) if name == "elasticnet": return LogisticRegression( penalty="elasticnet", solver="saga", l1_ratio=0.5, random_state=self.random_state, **params, ) if name == "mlp": return MLPClassifier(**params) if name == "gp": # Tanimoto suits fingerprints; on signed descriptors it is not # positive semi-definite, so `_build` is given the training data # in fit() and picks accordingly. return GaussianProcessClassifier( kernel=self._default_gp_kernel(), random_state=self.random_state, **params, ) raise _invalid_name_error(name) def _default_gp_kernel(self) -> Any: """Tanimoto for non-negative data, RBF otherwise.""" from sklearn.gaussian_process.kernels import RBF, WhiteKernel data = getattr(self, "_fit_X", None) if data is not None and data.size and np.all(data >= 0): return TanimotoKernel() return RBF() + WhiteKernel()
[docs] def fit(self, X: npt.ArrayLike, y: npt.ArrayLike) -> "QSARClassifier": """Build (from ``name``) and fit the underlying classifier. Parameters ---------- X : array-like of shape (n_samples, n_features) y : array-like of shape (n_samples,) Class labels. Returns ------- QSARClassifier The fitted estimator. Raises ------ ValueError If ``name`` is not a recognized algorithm name. """ self._validate_name() # Recorded so `_build` can choose a data-appropriate GP kernel. self._fit_X = np.asarray(X, dtype=np.float64) self.estimator_ = self._build() self.estimator_.fit(X, y, **(self.fit_params or {})) # A custom estimator need not expose `classes_` (CatBoost names it # `classes_` only after fit; some wrappers omit it entirely), so # fall back to the labels actually seen. found = getattr(self.estimator_, "classes_", None) self.classes_ = np.asarray(unique_labels(y) if found is None else found) return self
def _check_fitted(self) -> None: if not hasattr(self, "estimator_"): raise ModelNotFittedError( f"{type(self).__name__} must be fitted before calling predict()." )
[docs] def predict(self, X: npt.ArrayLike) -> npt.NDArray[np.generic]: """Predict by delegating to the fitted underlying classifier. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- ndarray of shape (n_samples,) """ self._check_fitted() return np.asarray(self.estimator_.predict(X, **(self.predict_params or {})))
[docs] def predict_proba(self, X: npt.ArrayLike) -> npt.NDArray[np.float64]: """Class probabilities, by delegating to the fitted classifier. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- ndarray of shape (n_samples, n_classes) Raises ------ ModelNotFittedError If called before :meth:`fit`. AttributeError If the underlying estimator has no ``predict_proba`` (e.g. ``name="ridge"``, whose ``RidgeClassifier`` backend has no native probability estimates). """ self._check_fitted() if not hasattr(self.estimator_, "predict_proba"): raise AttributeError( f"The '{_describe_estimator(self.name)}' backend " f"({type(self.estimator_).__name__}) does not support " "predict_proba." ) return np.asarray( self.estimator_.predict_proba(X, **(self.predict_proba_params or {})), dtype=np.float64, )