Source code for qsarkit.feature_selection._mutual_info
"""Mutual-information-based feature selection."""
from __future__ import annotations
from typing import Literal, Optional
import numpy as np
import numpy.typing as npt
from sklearn.base import BaseEstimator
from sklearn.feature_selection import (
SelectorMixin,
mutual_info_classif,
mutual_info_regression,
)
from sklearn.utils.validation import check_is_fitted
__all__ = ["MutualInformationSelector"]
[docs]
class MutualInformationSelector(SelectorMixin, BaseEstimator):
"""Keep the descriptors most informative about the target, by mutual information.
Thin wrapper around ``sklearn.feature_selection.mutual_info_regression``
/ ``mutual_info_classif``, which estimate mutual information between
each descriptor and the target using a k-nearest-neighbour entropy
estimator. Unlike Pearson correlation, mutual information captures
non-linear dependencies, which matters for QSAR descriptors that
often relate to activity non-monotonically.
Parameters
----------
task : {"regression", "classification"}, default "regression"
Whether ``y`` is continuous or categorical.
k : int, optional
Keep the top-``k`` scoring descriptors. Mutually exclusive with
``percentile``.
percentile : float, optional
Keep descriptors scoring at or above this percentile (0-100) of
the score distribution. Mutually exclusive with ``k``. If
neither ``k`` nor ``percentile`` is given, defaults to 50 (keep
the top half).
random_state : int, optional
Seed forwarded to the mutual-information estimator's internal
noise injection (used to break ties in the nearest-neighbour
distances).
Attributes
----------
scores_ : ndarray of shape (n_features,)
Raw mutual-information score per descriptor.
support_ : ndarray of bool of shape (n_features,)
True for descriptors kept.
n_features_in_ : int
Number of descriptors seen during ``fit``.
Examples
--------
>>> import numpy as np
>>> from qsarkit.feature_selection import MutualInformationSelector
>>> rng = np.random.RandomState(0)
>>> X = rng.normal(size=(300, 3))
>>> y = X[:, 0] ** 2
>>> sel = MutualInformationSelector(k=1, random_state=0).fit(X, y)
>>> sel.support_.tolist()
[True, False, False]
References
----------
- Kraskov, A., Stogbauer, H. & Grassberger, P. (2004). "Estimating
Mutual Information." Phys. Rev. E, 69(6), 066138.
https://doi.org/10.1103/PhysRevE.69.066138
"""
scores_: npt.NDArray[np.float64]
support_: npt.NDArray[np.bool_]
n_features_in_: int
def __init__(
self,
task: Literal["regression", "classification"] = "regression",
k: Optional[int] = None,
percentile: Optional[float] = None,
random_state: Optional[int] = None,
) -> None:
self.task = task
self.k = k
self.percentile = percentile
self.random_state = random_state
[docs]
def fit(
self, X: npt.ArrayLike, y: Optional[npt.ArrayLike] = None
) -> "MutualInformationSelector":
"""Score every descriptor by mutual information with ``y``.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Descriptor matrix.
y : array-like of shape (n_samples,)
Target values.
Returns
-------
MutualInformationSelector
The fitted selector.
Raises
------
ValueError
If both ``k`` and ``percentile`` are given, or ``task`` is
invalid.
"""
if self.task not in ("regression", "classification"):
raise ValueError(
f"task must be 'regression' or 'classification', got {self.task!r}."
)
if self.k is not None and self.percentile is not None:
raise ValueError("Only one of `k` and `percentile` may be given, not both.")
arr = np.asarray(X, dtype=np.float64)
if arr.ndim != 2:
raise ValueError(f"X must be 2-dimensional, got shape {arr.shape}.")
n_features = arr.shape[1]
self.n_features_in_ = n_features
y_arr = np.asarray(y)
score_func = (
mutual_info_regression if self.task == "regression" else mutual_info_classif
)
self.scores_ = np.asarray(
score_func(arr, y_arr, random_state=self.random_state), dtype=np.float64
)
k = self.k
percentile = self.percentile
if k is None and percentile is None:
percentile = 50.0
if k is not None:
k_eff = min(k, n_features)
top_idx = np.argsort(-self.scores_)[:k_eff]
support = np.zeros(n_features, dtype=bool)
support[top_idx] = True
else:
assert percentile is not None # narrowed by the branch above
cutoff = np.percentile(self.scores_, percentile)
support = self.scores_ >= cutoff
self.support_ = np.asarray(support, dtype=np.bool_)
return self
def _get_support_mask(self) -> npt.NDArray[np.bool_]:
check_is_fitted(self)
return self.support_