Source code for qsarkit.representation.descriptors._calculator

"""Facade combining several named descriptor blocks into one feature matrix."""

from __future__ import annotations

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

import numpy as np
import numpy.typing as npt

from qsarkit.base import MoleculeTransformer
from qsarkit.representation.descriptors._base import BaseDescriptorTransformer
from qsarkit.representation.descriptors._constitutional import ConstitutionalDescriptors
from qsarkit.representation.descriptors._fragments import FragmentDescriptors
from qsarkit.representation.descriptors._lipinski import LipinskiDescriptors
from qsarkit.representation.descriptors._physicochemical import PhysicochemicalDescriptors
from qsarkit.representation.descriptors._rdkit_descriptors import RDKitDescriptors

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

__all__ = ["DescriptorCalculator"]

#: Registry of block-name -> zero-argument constructor. ``"3d"`` is
#: resolved lazily inside :meth:`DescriptorCalculator._members` (rather
#: than imported at module level) since it is the only block whose
#: constructor performs no import-time work but whose *use* commonly
#: implies a heavier RDKit conformer-generation dependency chain best kept
#: out of the hot import path.
_BLOCK_FACTORIES: Dict[str, Type[BaseDescriptorTransformer]] = {
    "constitutional": ConstitutionalDescriptors,
    "physicochemical": PhysicochemicalDescriptors,
    "lipinski": LipinskiDescriptors,
    "fragments": FragmentDescriptors,
    "rdkit_all": RDKitDescriptors,
}


[docs] class DescriptorCalculator(MoleculeTransformer): """Facade combining several named descriptor blocks into one design matrix. QSAR feature engineering rarely uses a single descriptor family in isolation; this transformer selects and concatenates several of the named blocks in :mod:`qsarkit.representation.descriptors` (and, optionally, arbitrary user-supplied ``MoleculeTransformer`` instances) behind a single ``fit``/``transform`` call, mirroring :class:`qsarkit.representation.fingerprints.FingerprintCombiner` for descriptor blocks. Parameters ---------- blocks : sequence of str, default ("physicochemical", "lipinski", "constitutional", "fragments") Names of built-in blocks to include, in order. Valid names are ``"constitutional"``, ``"physicochemical"``, ``"lipinski"``, ``"fragments"``, ``"rdkit_all"`` and ``"3d"``. extra_transformers : sequence of (str, MoleculeTransformer), optional Additional named transformers appended after the built-in blocks (e.g. a fingerprint, or a custom descriptor transformer). Each must implement ``fit``/``transform`` over ``Iterable[Mol]`` and ``get_feature_names_out()``. Attributes ---------- n_features_out_ : int Total width of the concatenated output, set after ``fit``. Examples -------- >>> from rdkit import Chem >>> from qsarkit.representation.descriptors import DescriptorCalculator >>> calc = DescriptorCalculator(blocks=["physicochemical", "lipinski"]) >>> X = calc.fit_transform([Chem.MolFromSmiles("CCO")]) >>> X.shape[1] == len(calc.get_feature_names_out()) True References ---------- - Todeschini, R. & Consonni, V. (2009). "Molecular Descriptors for Chemoinformatics." Wiley-VCH. https://doi.org/10.1002/9783527628766 - scikit-learn ``FeatureUnion`` documentation: https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.FeatureUnion.html """ def __init__( self, blocks: Sequence[str] = ( "physicochemical", "lipinski", "constitutional", "fragments", ), extra_transformers: Optional[Sequence[Tuple[str, MoleculeTransformer]]] = None, ) -> None: self.blocks = blocks self.extra_transformers = extra_transformers def _members(self) -> List[Tuple[str, MoleculeTransformer]]: members: List[Tuple[str, MoleculeTransformer]] = [] for name in self.blocks: if name == "3d": from qsarkit.representation.descriptors._3d import Descriptors3D members.append((name, Descriptors3D())) continue factory = _BLOCK_FACTORIES.get(name) if factory is None: raise ValueError( f"Unknown descriptor block {name!r}. Valid blocks are " f"{sorted([*_BLOCK_FACTORIES, '3d'])!r}." ) members.append((name, factory())) if self.extra_transformers: members.extend(self.extra_transformers) if not members: raise ValueError("DescriptorCalculator needs at least one block.") names = [name for name, _ in members] if len(set(names)) != len(names): raise ValueError(f"Block/transformer names must be unique, got {names!r}.") return members
[docs] def fit(self, mols: Any, y: Optional[Any] = None) -> "DescriptorCalculator": """Fit every member block on the same molecules. Parameters ---------- mols : Iterable[rdkit.Chem.Mol] y : array-like, optional Forwarded to each member's ``fit``. Returns ------- DescriptorCalculator self. """ from qsarkit.base import ensure_mol_list members = self._members() mol_list = ensure_mol_list(mols) for _, transformer in members: transformer.fit(mol_list, y) self.n_features_out_ = sum( int(np.asarray(t.get_feature_names_out()).shape[0]) for _, t in members ) return self
def _transform(self, mols: List[Optional["Mol"]]) -> npt.NDArray[np.float64]: members = self._members() blocks = [np.asarray(t.transform(mols), dtype=np.float64) for _, t in members] 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 block. Parameters ---------- input_features : sequence of str, optional Ignored; present for scikit-learn API compatibility. Returns ------- numpy.ndarray Array of ``str`` names, formatted ``"<block_name>__<feature>"``. """ names: List[str] = [] for block_name, transformer in self._members(): for feature_name in transformer.get_feature_names_out(): names.append(f"{block_name}__{feature_name}") return np.asarray(names, dtype=object)