Source code for qsarkit.representation.fingerprints._combiner

"""Concatenation of several fingerprint transformers into one feature matrix."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple

import numpy as np
import numpy.typing as npt

from qsarkit.base import MoleculeTransformer

if TYPE_CHECKING:  # pragma: no cover
    from rdkit.Chem import Mol

__all__ = ["FingerprintCombiner"]


[docs] class FingerprintCombiner(MoleculeTransformer): """Horizontally concatenate several fingerprint/descriptor transformers. Many QSAR pipelines combine a circular fingerprint with a structural-key or physicochemical descriptor block (e.g. ECFP4 + MACCS, or ECFP4 + Lipinski descriptors) because the two capture complementary information. ``FingerprintCombiner`` fits each member transformer independently on the same molecules and concatenates their outputs column-wise, exposing the combination as a single scikit-learn transformer that fits and transforms in one call. Parameters ---------- transformers : sequence of (str, MoleculeTransformer) Named member transformers. Each must implement ``fit``/``transform`` (or ``fit_transform``) over ``Iterable[Mol]`` and return a 2-D ``numpy.ndarray`` with a consistent number of rows. Names are used to prefix ``get_feature_names_out()`` and must be unique. weights : sequence of float, optional Per-member multiplicative weight applied to that member's output block before concatenation. Defaults to 1.0 for every member. Attributes ---------- n_features_out_ : int Total width of the concatenated output, set after ``fit``. Notes ----- This mirrors the intent of ``sklearn.pipeline.FeatureUnion`` but is specialized to the ``Iterable[Mol]`` input contract used throughout qsarkit (``FeatureUnion`` itself works fine with these transformers too; this class exists for a lighter-weight, dependency-free alternative and for symmetry with :class:`qsarkit.transform.MoleculeFeatureUnion`, which wraps the same pattern for the ``qsarkit.transform`` namespace). Examples -------- >>> from rdkit import Chem >>> from qsarkit.representation.fingerprints import ( ... FingerprintCombiner, MorganFingerprint, MACCSKeysFingerprint, ... ) >>> combiner = FingerprintCombiner([ ... ("morgan", MorganFingerprint(n_bits=32)), ... ("maccs", MACCSKeysFingerprint()), ... ]) >>> X = combiner.fit_transform([Chem.MolFromSmiles("CCO")]) >>> X.shape (1, 199) References ---------- - Pedregosa et al. (2011). "Scikit-learn: Machine Learning in Python." J. Mach. Learn. Res., 12, 2825-2830. https://jmlr.org/papers/v12/pedregosa11a.html - scikit-learn ``FeatureUnion`` documentation: https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.FeatureUnion.html - Nembri, S. et al. (2016). "In Silico Prediction of Cytochrome P450-Drug Interaction: QSARs for CYP3A4 and CYP2C9." Int. J. Mol. Sci., 17(6), 914. https://doi.org/10.3390/ijms17060914 (example of combined fingerprint + descriptor QSAR feature sets). """ def __init__( self, transformers: Sequence[Tuple[str, MoleculeTransformer]], weights: Optional[Sequence[float]] = None, ) -> None: self.transformers = transformers self.weights = weights def _validate(self) -> None: if not self.transformers: raise ValueError("FingerprintCombiner needs at least one transformer.") names = [name for name, _ in self.transformers] if len(set(names)) != len(names): raise ValueError(f"Transformer names must be unique, got {names!r}.") if self.weights is not None and len(self.weights) != len(self.transformers): raise ValueError( f"weights has length {len(self.weights)}, expected " f"{len(self.transformers)} (one per transformer)." )
[docs] def fit( self, mols: Any, y: Optional[Any] = None ) -> "FingerprintCombiner": """Fit every member transformer on the same molecules. Parameters ---------- mols : Iterable[rdkit.Chem.Mol] y : array-like, optional Forwarded to each member's ``fit``. Returns ------- FingerprintCombiner self. """ from qsarkit.base import ensure_mol_list self._validate() mol_list = ensure_mol_list(mols) for _, transformer in self.transformers: transformer.fit(mol_list, y) self.n_features_out_ = sum( int(np.asarray(t.get_feature_names_out()).shape[0]) for _, t in self.transformers ) return self
def _transform(self, mols: List[Optional["Mol"]]) -> npt.NDArray[np.float64]: self._validate() weights = self.weights if self.weights is not None else [1.0] * len( self.transformers ) blocks = [ np.asarray(transformer.transform(mols), dtype=np.float64) * weight for (_, transformer), weight in zip(self.transformers, weights) ] return np.asarray(np.concatenate(blocks, axis=1), dtype=np.float64)
[docs] def get_feature_names_out( self, input_features: Optional[Sequence[str]] = None ) -> npt.NDArray[np.object_]: """Return prefixed feature names from every member transformer. Parameters ---------- input_features : sequence of str, optional Ignored; present for scikit-learn API compatibility. Returns ------- numpy.ndarray Array of ``str`` names, formatted ``"<member_name>__<feature>"``. """ self._validate() names: List[str] = [] for member_name, transformer in self.transformers: for feature_name in transformer.get_feature_names_out(): names.append(f"{member_name}__{feature_name}") return np.asarray(names, dtype=object)