Source code for qsarkit.base.transformer
"""Base classes shared by every molecule-consuming component in qsarkit.
Every chemistry and representation component accepts
``Iterable[rdkit.Chem.Mol]`` as its primary input and follows the
scikit-learn estimator/transformer protocol (``fit`` / ``transform`` /
``fit_transform``), so they compose naturally with
``sklearn.pipeline.Pipeline`` and the rest of ``qsarkit.transform`` /
``qsarkit.model_selection``.
Examples
--------
Subclassing is the whole contract: implement ``_transform`` on a list of
molecules, and inherit input validation, ``fit``, ``fit_transform`` and
scikit-learn compatibility.
>>> import numpy as np
>>> from rdkit import Chem
>>> from qsarkit.base import MoleculeTransformer
>>> class HeavyAtomCount(MoleculeTransformer):
... def _transform(self, mols):
... return np.array([[m.GetNumHeavyAtoms()] for m in mols], dtype=float)
>>> HeavyAtomCount().fit_transform([Chem.MolFromSmiles("CCO")]).tolist()
[[3.0]]
Because it is a genuine scikit-learn transformer, it drops into a
pipeline and survives ``clone``:
>>> from sklearn.base import clone
>>> clone(HeavyAtomCount())
HeavyAtomCount()
References
----------
- Pedregosa et al. (2011). "Scikit-learn: Machine Learning in Python."
Journal of Machine Learning Research, 12, 2825-2830.
https://jmlr.org/papers/v12/pedregosa11a.html
- RDKit: Open-source cheminformatics. https://www.rdkit.org
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Iterable, List, Optional
from sklearn.base import BaseEstimator, TransformerMixin
from qsarkit.base.exceptions import InvalidMoleculeError
[docs]
def ensure_mol_list(mols: Iterable[Any]) -> List[Any]:
"""Materialize an ``Iterable[Mol]`` into a list, validating entries.
Parameters
----------
mols:
Iterable of ``rdkit.Chem.Mol`` objects. ``None`` entries are allowed
through (representing molecules that failed an earlier parsing
step) and are left untouched so callers can decide how to handle
them positionally.
Returns
-------
list
The materialized list.
Raises
------
InvalidMoleculeError
If an entry is neither ``None`` nor an RDKit ``Mol``.
Examples
--------
>>> from rdkit import Chem
>>> from qsarkit.base import ensure_mol_list
>>> len(ensure_mol_list(Chem.MolFromSmiles(s) for s in ("CCO", "CCN")))
2
``None`` is allowed through, because a molecule that failed an
earlier parsing step must keep its position -- dropping it here
would silently shift every downstream label by one:
>>> ensure_mol_list([None])
[None]
Anything else is a programming error and is reported as one:
>>> ensure_mol_list(["CCO"])
Traceback (most recent call last):
...
qsarkit.base.exceptions.InvalidMoleculeError: Element 0 is not an rdkit.Chem.Mol...
"""
from rdkit import Chem
out = []
for i, m in enumerate(mols):
if m is not None and not isinstance(m, Chem.Mol):
raise InvalidMoleculeError(
f"Element {i} is not an rdkit.Chem.Mol (got {type(m)!r})."
)
out.append(m)
return out
[docs]
class MoleculeTransformer(BaseEstimator, TransformerMixin, ABC):
"""Abstract base for stateless/stateful molecule -> X transformers.
Subclasses implement :meth:`_transform` and operate on
``Iterable[rdkit.Chem.Mol]``. ``fit`` is a no-op by default (most
chemistry transformers are stateless), but subclasses that need to
learn parameters from data (e.g. a fingerprint vocabulary or a
Mol2Vec embedding model) should override it.
Examples
--------
See the module docstring for a complete subclass. Input validation
is inherited, so a subclass never has to check its own arguments:
>>> import numpy as np
>>> from qsarkit.base import MoleculeTransformer
>>> class RingCount(MoleculeTransformer):
... def _transform(self, mols):
... return np.array([m.GetRingInfo().NumRings() for m in mols])
>>> RingCount().transform(["not a molecule"])
Traceback (most recent call last):
...
qsarkit.base.exceptions.InvalidMoleculeError: Element 0 is not an rdkit.Chem.Mol...
"""
[docs]
def fit(
self, mols: Iterable[Any], y: Optional[Iterable[Any]] = None
) -> "MoleculeTransformer":
"""Default no-op fit. Override in stateful subclasses."""
return self
@abstractmethod
def _transform(self, mols: List[Any]) -> Any:
"""Perform the actual transformation on a materialized list of Mols."""
[docs]
def transform(self, mols: Iterable[Any]) -> Any:
"""Validate input and dispatch to :meth:`_transform`."""
mol_list = ensure_mol_list(mols)
return self._transform(mol_list)
[docs]
class MoleculeToMoleculeTransformer(MoleculeTransformer, ABC):
"""Base for transformers that map Mol -> Mol (standardization, curation, ...).
Identical to :class:`MoleculeTransformer` in behaviour; the separate
type documents that ``transform`` returns molecules rather than a
feature matrix, so these can be chained with each other.
Examples
--------
>>> from rdkit import Chem
>>> from qsarkit.base import MoleculeToMoleculeTransformer
>>> class StripStereo(MoleculeToMoleculeTransformer):
... def _transform(self, mols):
... out = []
... for m in mols:
... copy = Chem.Mol(m)
... Chem.RemoveStereochemistry(copy)
... out.append(copy)
... return out
>>> mol = Chem.MolFromSmiles("C[C@H](N)C(=O)O")
>>> Chem.MolToSmiles(StripStereo().transform([mol])[0])
'CC(N)C(=O)O'
"""
[docs]
class FittableMoleculeTransformer(MoleculeTransformer, ABC):
"""Base for transformers with learned state (embeddings, vocabularies).
Adds an :attr:`is_fitted` flag and a ``_check_is_fitted`` guard, so
calling ``transform`` before ``fit`` raises a clear error instead of
producing silently meaningless features.
Examples
--------
>>> import numpy as np
>>> from rdkit import Chem
>>> from qsarkit.base import FittableMoleculeTransformer
>>> class MeanCentredSize(FittableMoleculeTransformer):
... def fit(self, mols, y=None):
... self.mean_ = np.mean([m.GetNumHeavyAtoms() for m in mols])
... self._is_fitted = True
... return self
... def _transform(self, mols):
... self._check_is_fitted()
... return np.array([m.GetNumHeavyAtoms() - self.mean_ for m in mols])
>>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "c1ccccc1")]
>>> transformer = MeanCentredSize()
>>> transformer.is_fitted
False
>>> transformer.transform(mols)
Traceback (most recent call last):
...
qsarkit.base.exceptions.ModelNotFittedError: MeanCentredSize must be fitted...
>>> transformer.fit(mols).transform(mols).tolist()
[-1.5, 1.5]
"""
def __init__(self) -> None:
self._is_fitted = False
@property
def is_fitted(self) -> bool:
return self._is_fitted
def _check_is_fitted(self) -> None:
from qsarkit.base.exceptions import ModelNotFittedError
if not self._is_fitted:
raise ModelNotFittedError(
f"{type(self).__name__} must be fitted before calling transform()."
)