From 00861712f413860e45352516d0b7411167a94ecc Mon Sep 17 00:00:00 2001 From: RaulFD-creator Date: Fri, 21 Aug 2026 19:37:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20#90=20Implement=20new?= =?UTF-8?q?=20repengine=20for=20using=20scikit-fingerprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autopeptideml/reps/fps.py | 101 ++++++++++++++++++++++ docs/repenginebase.md | 1 + docs/repengineskfp.md | 171 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + tests/test_fps.py | 148 +++++++++++++++++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 docs/repengineskfp.md create mode 100644 tests/test_fps.py diff --git a/autopeptideml/reps/fps.py b/autopeptideml/reps/fps.py index 82ef750..8701f2e 100644 --- a/autopeptideml/reps/fps.py +++ b/autopeptideml/reps/fps.py @@ -10,6 +10,55 @@ raise ImportError("You need to install rdkit to use this method.", " Try: `pip install rdkit`") +# Mapping from lowercase string keys to skfp fingerprint classes. +# Populated lazily on first use so that importing this module does not require +# scikit-fingerprints to be installed. +_SKFP_CLASS_MAP: Optional[Dict[str, Any]] = None + + +def _get_skfp_class_map() -> Dict[str, Any]: + global _SKFP_CLASS_MAP + if _SKFP_CLASS_MAP is not None: + return _SKFP_CLASS_MAP + try: + import skfp.fingerprints as _sfp + except ImportError: + raise ImportError( + "You need to install scikit-fingerprints to use skfp-backed fingerprints. " + "Try: `pip install scikit-fingerprints`" + ) + _SKFP_CLASS_MAP = { + 'atompair': _sfp.AtomPairFingerprint, + 'autocorr': _sfp.AutocorrFingerprint, + 'avalon': _sfp.AvalonFingerprint, + 'bcut2d': _sfp.BCUT2DFingerprint, + 'ecfp': _sfp.ECFPFingerprint, + 'erg': _sfp.ERGFingerprint, + 'estate': _sfp.EStateFingerprint, + 'functionalgroups': _sfp.FunctionalGroupsFingerprint, + 'ghosecrippen': _sfp.GhoseCrippenFingerprint, + 'klekotaroth': _sfp.KlekotaRothFingerprint, + 'laggner': _sfp.LaggnerFingerprint, + 'layered': _sfp.LayeredFingerprint, + 'lingo': _sfp.LingoFingerprint, + 'maccs': _sfp.MACCSFingerprint, + 'map': _sfp.MAPFingerprint, + 'mhfp': _sfp.MHFPFingerprint, + 'mordred': _sfp.MordredFingerprint, + 'mqns': _sfp.MQNsFingerprint, + 'pattern': _sfp.PatternFingerprint, + 'pharmacophore': _sfp.PharmacophoreFingerprint, + 'pubchem': _sfp.PubChemFingerprint, + 'rdkit': _sfp.RDKitFingerprint, + 'rdkit2d': _sfp.RDKit2DDescriptorsFingerprint, + 'secfp': _sfp.SECFPFingerprint, + 'topologicaltorsion': _sfp.TopologicalTorsionFingerprint, + 'usr': _sfp.USRFingerprint, + 'usrcat': _sfp.USRCATFingerprint, + 'vsa': _sfp.VSAFingerprint, + } + return _SKFP_CLASS_MAP + class PepFunn_Generator: radius: int = 2 @@ -168,3 +217,55 @@ def dim(self) -> int: :return: The number of bits in the fingerprint (i.e., `nbits`). """ return self.nbits + + +class RepEngineSkfp(RepEngineBase): + """ + Wraps any `scikit-fingerprints` (skfp) fingerprint class as a + `RepEngineBase`-compatible engine. + + Attributes: + :type engine: str + :param engine: Fixed to ``'skfp'``. + + :type name: str + :param name: ``'skfp-'``, e.g. ``'skfp-maccs'``. + + :type generator: BaseFingerprintTransformer + :param generator: The underlying skfp transformer instance. + """ + engine = 'skfp' + + def __init__(self, rep: str, **kwargs): + """ + :type rep: str + :param rep: Lowercase fingerprint key, e.g. ``'maccs'``, ``'ecfp'``, + ``'atompair'``. See :func:`_get_skfp_class_map` for all valid keys. + + :type **kwargs: dict + :param **kwargs: Forwarded verbatim to the skfp fingerprint constructor + (e.g. ``fp_size``, ``radius``, ``count``). + """ + super().__init__(rep, **kwargs) + self.generator = self._load_generator(rep, **kwargs) + self.name = f'{self.engine}-{rep}' + + def _load_generator(self, rep: str, **kwargs): + class_map = _get_skfp_class_map() + key = rep.lower() + if key not in class_map: + raise NotImplementedError( + f"skfp fingerprint '{rep}' is not supported. " + f"Valid keys: {sorted(class_map)}" + ) + return class_map[key](**kwargs) + + def _preprocess_batch(self, batch: List[str]) -> List[str]: + # skfp accepts SMILES strings directly; no preprocessing needed. + return list(batch) + + def _rep_batch(self, batch: List[str]) -> np.ndarray: + return self.generator.transform(batch) + + def dim(self) -> int: + return int(self.generator.n_features_out) diff --git a/docs/repenginebase.md b/docs/repenginebase.md index 328e65d..621e9d5 100644 --- a/docs/repenginebase.md +++ b/docs/repenginebase.md @@ -141,6 +141,7 @@ Apply any necessary preprocessing to a batch before representation computation ( | Class | Module | Description | |---|---|---| | [`RepEngineFP`](repenginefp.md) | `autopeptideml.reps.fps` | Molecular fingerprints via RDKit (ECFP, FCFP, PepFuNN). | +| [`RepEngineSkfp`](repengineskfp.md) | `autopeptideml.reps.fps` | 28 fingerprint families via scikit-fingerprints (MACCS, AtomPair, Avalon, PubChem, Mordred, …). | | [`RepEngineLM`](repenginelm.md) | `autopeptideml.reps.lms` | Pre-trained language model embeddings (ESM2, ProtT5, MoLFormer, …). | | [`RepEngineOnehot`](repengineseqbased.md) | `autopeptideml.reps.seq_based` | Fixed-length one-hot encoding for canonical amino acid sequences. | diff --git a/docs/repengineskfp.md b/docs/repengineskfp.md new file mode 100644 index 0000000..becd422 --- /dev/null +++ b/docs/repengineskfp.md @@ -0,0 +1,171 @@ +# `RepEngineSkfp` — scikit-fingerprints Engine + +**Module:** `autopeptideml.reps.fps` +**Inherits from:** [`RepEngineBase`](repenginebase.md) + +## Overview + +`RepEngineSkfp` wraps any [scikit-fingerprints](https://scikit-fingerprints.github.io/scikit-fingerprints/) (`skfp`) fingerprint class as a drop-in `RepEngineBase`-compatible engine. It gives access to 28 fingerprint families beyond the RDKit-backed ones available in [`RepEngineFP`](repenginefp.md), including MACCS, AtomPair, TopologicalTorsion, Avalon, PubChem, Mordred, and more. + +**Requires:** `pip install scikit-fingerprints` +(RDKit is also required as a transitive dependency.) + +--- + +## Attributes + +| Attribute | Type | Description | +|---|---|---| +| `engine` | `str` | Fixed to `'skfp'`. | +| `name` | `str` | Auto-generated as `'skfp-'`, e.g. `'skfp-maccs'`. | +| `generator` | `BaseFingerprintTransformer` | The underlying skfp transformer instance. | + +--- + +## Constructor + +```python +RepEngineSkfp(rep: str, **kwargs) +``` + +| Parameter | Type | Description | +|---|---|---| +| `rep` | `str` | Lowercase fingerprint key (see [Supported fingerprints](#supported-fingerprints)). | +| `**kwargs` | `Any` | Forwarded verbatim to the skfp fingerprint constructor (e.g. `fp_size`, `radius`, `count`). | + +--- + +## Methods + +### `compute_reps` *(inherited)* + +```python +compute_reps( + mols: List[str], + verbose: bool = False, + batch_size: int = 12 +) -> np.ndarray +``` + +Compute fingerprints for a list of SMILES strings. Returns an array of shape `(n_mols, dim)`. + +--- + +### `dim` + +```python +dim() -> int +``` + +Returns `generator.n_features_out` — the feature dimensionality reported by the underlying skfp transformer. + +--- + +### `_preprocess_batch` + +```python +_preprocess_batch(batch: List[str]) -> List[str] +``` + +Returns the batch unchanged. `skfp` transformers accept SMILES strings directly and handle `Mol` conversion internally. + +--- + +### `_rep_batch` + +```python +_rep_batch(batch: List[str]) -> np.ndarray +``` + +Delegates to `generator.transform(batch)`. Returns a dense `np.ndarray` of shape `(len(batch), dim)`. + +--- + +### `_load_generator` + +```python +_load_generator(rep: str, **kwargs) -> BaseFingerprintTransformer +``` + +Looks up `rep` in the internal class map and instantiates the matching skfp class with `**kwargs`. Raises `NotImplementedError` for unknown keys. + +--- + +## Supported fingerprints + +| Key | skfp class | Fixed `dim` | Notes | +|---|---|---|---| +| `atompair` | `AtomPairFingerprint` | `fp_size` | Hashed atom-pair counts | +| `autocorr` | `AutocorrFingerprint` | 192 | 2D autocorrelation descriptors | +| `avalon` | `AvalonFingerprint` | `fp_size` | Avalon substructure fingerprint | +| `bcut2d` | `BCUT2DFingerprint` | 64 | BCUT2D descriptors | +| `ecfp` | `ECFPFingerprint` | `fp_size` | Extended connectivity (Morgan); pass `use_pharmacophoric_invariants=True` for FCFP | +| `erg` | `ERGFingerprint` | 315 | Extended reduced graph | +| `estate` | `EStateFingerprint` | 79 | Electrotopological state | +| `functionalgroups` | `FunctionalGroupsFingerprint` | 85 | Functional group presence | +| `ghosecrippen` | `GhoseCrippenFingerprint` | 110 | Ghose-Crippen atom types | +| `klekotaroth` | `KlekotaRothFingerprint` | `fp_size` | Klekota-Roth substructure | +| `laggner` | `LaggnerFingerprint` | 307 | Laggner substructure | +| `layered` | `LayeredFingerprint` | `fp_size` | RDKit layered fingerprint | +| `lingo` | `LingoFingerprint` | `fp_size` | SMILES n-gram similarity | +| `maccs` | `MACCSFingerprint` | 166 | MACCS structural keys | +| `map` | `MAPFingerprint` | `fp_size` | MinHashed atom-pair | +| `mhfp` | `MHFPFingerprint` | `fp_size` | MinHashed fingerprint | +| `mordred` | `MordredFingerprint` | 1613 | Mordred 2D descriptors | +| `mqns` | `MQNsFingerprint` | 42 | Molecular quantum numbers | +| `pattern` | `PatternFingerprint` | `fp_size` | RDKit pattern fingerprint | +| `pharmacophore` | `PharmacophoreFingerprint` | `fp_size` | 2D pharmacophore | +| `pubchem` | `PubChemFingerprint` | 881 | PubChem substructure keys | +| `rdkit` | `RDKitFingerprint` | `fp_size` | RDKit path fingerprint | +| `rdkit2d` | `RDKit2DDescriptorsFingerprint` | 200 | RDKit 2D descriptors | +| `secfp` | `SECFPFingerprint` | `fp_size` | SMILES extended connectivity | +| `topologicaltorsion` | `TopologicalTorsionFingerprint` | `fp_size` | Topological torsion | +| `usr` | `USRFingerprint` | 12 | Ultrafast shape recognition (3D) | +| `usrcat` | `USRCATFingerprint` | 60 | USR + CREDO atom types (3D) | +| `vsa` | `VSAFingerprint` | 71 | Van der Waals surface area bins | + +> **3D fingerprints** (`usr`, `usrcat`) require molecules with pre-computed conformations. Pass RDKit `Mol` objects with the `conf_id` property set rather than bare SMILES strings. + +--- + +## Examples + +### MACCS keys (fixed 166-bit) + +```python +from autopeptideml.reps.fps import RepEngineSkfp + +engine = RepEngineSkfp('maccs') +smiles = [ + 'C[C@H](N)C(=O)N[C@@H](CCCNC(=N)N)C(=O)NCC(=O)O', # Ala-Arg-Gly + 'N[C@@H](Cc1ccccc1)C(=O)N[C@@H](CS)C(=O)O', # Phe-Cys +] +X = engine.compute_reps(smiles) +print(X.shape) # (2, 166) +print(engine.dim()) # 166 +``` + +### ECFP via skfp (variable bit size, count variant) + +```python +engine = RepEngineSkfp('ecfp', fp_size=2048, radius=3, count=True) +X = engine.compute_reps(smiles) +print(X.shape) # (2, 2048) +print(engine.dim()) # 2048 +``` + +### AtomPair fingerprint + +```python +engine = RepEngineSkfp('atompair', fp_size=512) +X = engine.compute_reps(smiles) +print(X.shape) # (2, 512) +``` + +--- + +## Notes + +- `RepEngineSkfp` and [`RepEngineFP`](repenginefp.md) both live in `autopeptideml.reps.fps` and share the same `RepEngineBase` interface. +- For ECFP / FCFP via RDKit (the existing path in `build_models`), continue to use `RepEngineFP`. `RepEngineSkfp('ecfp', ...)` is an independent implementation backed by scikit-fingerprints. +- The class map is populated lazily: importing `autopeptideml.reps.fps` does **not** require `scikit-fingerprints` to be installed until `RepEngineSkfp` is actually instantiated. diff --git a/mkdocs.yml b/mkdocs.yml index fcaceca..f4a964c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,7 @@ nav: - Representations: - RepEngineBase: repenginebase.md - RepEngineFP: repenginefp.md + - RepEngineSkfp: repengineskfp.md - RepEngineLM: repenginelm.md - RepEngineOnehot: repengineseqbased.md - Pipeline: pipeline.md diff --git a/tests/test_fps.py b/tests/test_fps.py new file mode 100644 index 0000000..b10109b --- /dev/null +++ b/tests/test_fps.py @@ -0,0 +1,148 @@ +""" +Tests for RepEngineFP (rdkit-backed) and RepEngineSkfp (scikit-fingerprints-backed). + +A small set of SMILES for a tri-peptide Ala-Arg-Gly is used throughout so that +every test stays fast and self-contained. +""" +import json + +import numpy as np +import pytest + +from autopeptideml.reps.fps import RepEngineFP, RepEngineSkfp + +# Two valid peptide SMILES used across all tests. +SMILES = [ + 'C[C@H](N)C(=O)N[C@@H](CCCNC(=N)N)C(=O)NCC(=O)O', # Ala-Arg-Gly + 'N[C@@H](Cc1ccccc1)C(=O)N[C@@H](CS)C(=O)O', # Phe-Cys +] + + +# --------------------------------------------------------------------------- +# RepEngineFP — rdkit-backed +# --------------------------------------------------------------------------- + +class TestRepEngineFP: + def test_ecfp_shape(self): + re = RepEngineFP('ecfp', nbits=1024, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 1024) + + def test_ecfp_dim(self): + re = RepEngineFP('ecfp', nbits=512, radius=3) + assert re.dim() == 512 + + def test_ecfp_count_shape(self): + re = RepEngineFP('ecfp-count', nbits=512, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 512) + assert re.dim() == 512 + + def test_fcfp_shape(self): + re = RepEngineFP('fcfp', nbits=256, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 256) + + def test_output_dtype_is_numeric(self): + re = RepEngineFP('ecfp', nbits=128, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert np.issubdtype(out.dtype, np.number) + + def test_output_is_binary(self): + """Bit (non-count) fingerprints must only contain 0 and 1.""" + re = RepEngineFP('ecfp', nbits=1024, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert set(np.unique(out)).issubset({0, 1}) + + def test_deterministic(self): + re = RepEngineFP('ecfp', nbits=1024, radius=2) + a = re.compute_reps(SMILES, batch_size=2) + b = re.compute_reps(SMILES, batch_size=2) + np.testing.assert_array_equal(a, b) + + def test_different_smiles_differ(self): + re = RepEngineFP('ecfp', nbits=1024, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert not np.array_equal(out[0], out[1]) + + def test_serialisation(self): + re = RepEngineFP('ecfp', nbits=256, radius=4) + d = json.loads(str(re)) + assert d == {'rep': 'ecfp', 'nbits': 256, 'radius': 4} + + def test_invalid_rep_raises(self): + with pytest.raises(NotImplementedError): + RepEngineFP('nonexistent', nbits=128, radius=2) + + def test_invalid_smiles_returns_zeros(self): + """An unparseable SMILES must produce an all-zero fingerprint, not raise.""" + re = RepEngineFP('ecfp', nbits=512, radius=2) + out = re.compute_reps(['not_a_smiles'], batch_size=1) + assert out.shape == (1, 512) + assert out.sum() == 0 + + +# --------------------------------------------------------------------------- +# RepEngineSkfp — scikit-fingerprints-backed +# --------------------------------------------------------------------------- + +skfp = pytest.importorskip('skfp', reason='scikit-fingerprints not installed') + + +class TestRepEngineSkfp: + def test_ecfp_shape(self): + re = RepEngineSkfp('ecfp', fp_size=1024, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 1024) + + def test_ecfp_dim(self): + re = RepEngineSkfp('ecfp', fp_size=512, radius=2) + assert re.dim() == 512 + + def test_maccs_shape_and_dim(self): + """MACCS has a fixed size of 166 bits.""" + re = RepEngineSkfp('maccs') + out = re.compute_reps(SMILES, batch_size=2) + assert re.dim() == 166 + assert out.shape == (2, 166) + + def test_atompair_shape(self): + re = RepEngineSkfp('atompair', fp_size=512) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 512) + + def test_topologicaltorsion_shape(self): + re = RepEngineSkfp('topologicaltorsion', fp_size=256) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 256) + + def test_count_variant(self): + """count=True fingerprints may contain values > 1.""" + re = RepEngineSkfp('ecfp', fp_size=512, radius=2, count=True) + out = re.compute_reps(SMILES, batch_size=2) + assert out.shape == (2, 512) + assert re.dim() == 512 + + def test_output_is_binary_for_bit_fp(self): + re = RepEngineSkfp('maccs') + out = re.compute_reps(SMILES, batch_size=2) + assert set(np.unique(out)).issubset({0, 1}) + + def test_deterministic(self): + re = RepEngineSkfp('ecfp', fp_size=1024, radius=2) + a = re.compute_reps(SMILES, batch_size=2) + b = re.compute_reps(SMILES, batch_size=2) + np.testing.assert_array_equal(a, b) + + def test_different_smiles_differ(self): + re = RepEngineSkfp('ecfp', fp_size=1024, radius=2) + out = re.compute_reps(SMILES, batch_size=2) + assert not np.array_equal(out[0], out[1]) + + def test_name(self): + re = RepEngineSkfp('maccs') + assert re.name == 'skfp-maccs' + + def test_invalid_rep_raises(self): + with pytest.raises(NotImplementedError): + RepEngineSkfp('nonexistent_fp')