molfp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
molfp-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chanin Nantasenamat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
molfp-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: molfp
3
+ Version: 0.2.0
4
+ Summary: Interpretable molecular fingerprints with SHAP-driven chemical explanations
5
+ Author: Chanin Nantasenamat
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dataprofessor/molfp
8
+ Project-URL: Repository, https://github.com/dataprofessor/molfp
9
+ Keywords: cheminformatics,molecular-fingerprints,drug-discovery,QSAR,RDKit,SHAP,interpretability,scikit-learn
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
20
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.22
25
+ Requires-Dist: rdkit>=2023.03
26
+ Requires-Dist: scikit-learn>=1.2
27
+ Requires-Dist: scipy>=1.9
28
+ Requires-Dist: joblib>=1.2
29
+ Provides-Extra: shap
30
+ Requires-Dist: shap>=0.42; extra == "shap"
31
+ Provides-Extra: all
32
+ Requires-Dist: shap>=0.42; extra == "all"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7.0; extra == "dev"
35
+ Requires-Dist: shap>=0.42; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # molfp
39
+
40
+ **Interpretable molecular fingerprints with SHAP-driven chemical explanations.**
41
+
42
+ A native Python/RDKit library that computes 13 molecular fingerprint types and provides built-in tools for interpretation, collision detection, activity cliff analysis, bioactivity profiling, and SHAP-based feature explanation — capabilities not found in any other fingerprint library.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install molfp # core (RDKit + scikit-learn)
48
+ pip install molfp[shap] # + SHAP interpretation
49
+ pip install molfp[all] # everything
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ import molfp
56
+
57
+ # Compute fingerprints
58
+ fp = molfp.maccs("CCO") # → list of 166 ints
59
+ fp = molfp.ecfp4("c1ccccc1", n_bits=2048) # → list of 2048 ints
60
+ fps = molfp.maccs_batch(["CCO", "c1ccccc1"]) # → numpy (2, 166)
61
+
62
+ # All 13 types
63
+ molfp.available()
64
+ # ['maccs', 'ecfp4', 'pubchem', 'substructure', 'substructure_count',
65
+ # 'klekota_roth', 'klekota_roth_count', 'estate', 'atompairs2d',
66
+ # 'atompairs2d_count', 'rdk', 'rdk_ext', 'rdk_graphonly']
67
+ ```
68
+
69
+ ## Features
70
+
71
+ ### 13 Fingerprint Types
72
+
73
+ | Type | Bits | Function |
74
+ |------|------|----------|
75
+ | MACCS Keys | 166 | `molfp.maccs()` |
76
+ | ECFP4 (Morgan r=2) | 1024 | `molfp.ecfp4()` |
77
+ | PubChem/CACTVS | 881 | `molfp.pubchem()` |
78
+ | SubstructureFP | 307 | `molfp.substructure()` |
79
+ | Substructure Count | 307 | `molfp.substructure_count()` |
80
+ | Klekota-Roth | 4860 | `molfp.klekota_roth()` |
81
+ | Klekota-Roth Count | 4860 | `molfp.klekota_roth_count()` |
82
+ | E-State | 79 | `molfp.estate()` |
83
+ | AtomPairs2D | 780 | `molfp.atompairs2d()` |
84
+ | AtomPairs2D Count | 780 | `molfp.atompairs2d_count()` |
85
+ | RDKit path-based | 1024 | `molfp.rdk()` |
86
+ | RDKit Extended | 1024 | `molfp.rdk_ext()` |
87
+ | RDKit GraphOnly | 1024 | `molfp.rdk_graphonly()` |
88
+
89
+ ### Bit-Level Explainability
90
+
91
+ Map each ON bit to the atoms/bonds that triggered it — across all 13 FP types:
92
+
93
+ ```python
94
+ molfp.explain("CC(=O)Oc1ccccc1C(=O)O", "ecfp4")
95
+ # {11: [(1, 1)], 33: [(0, 0)], 64: [(6, 1), (7, 1)], ...}
96
+ # bit 64 → center atoms 6 and 7 at radius 1
97
+ ```
98
+
99
+ ### Chemical Annotation
100
+
101
+ Human-readable names for every fingerprint bit:
102
+
103
+ ```python
104
+ molfp.annotate("substructure", 83)
105
+ # {'name': 'Carboxylic_acid', 'smarts': '[CX3...](=[OX1])...', 'category': 'functional_group'}
106
+
107
+ molfp.annotate("pubchem", 9)
108
+ # {'description': 'Element count: C >= 2', 'category': 'element_count'}
109
+ ```
110
+
111
+ ### SHAP Interpretation with Chemical Meaning
112
+
113
+ One call: fingerprint → model → SHAP → chemical annotation → per-class SAR:
114
+
115
+ ```python
116
+ result = molfp.interpret(smiles, activities, fp_type="maccs", metric="pchembl")
117
+
118
+ # Per-class drivers with present/absent effect:
119
+ # ACTIVE:
120
+ # ↑ present methyl group (-CH3) SHAP +0.004 (freq 23% vs 19%)
121
+ # ↓ present oxygen atom SHAP -0.003 (freq 13% vs 17%)
122
+ ```
123
+
124
+ ### Hash Collision Detection
125
+
126
+ Detect when different substructures hash to the same bit:
127
+
128
+ ```python
129
+ molfp.detect_collisions("c1ccccc1", "ecfp4", n_bits=256)
130
+ # {63: [2360741695, 3976623167]} → 2 hashes collided at bit 63
131
+
132
+ molfp.collision_rate(smiles_list, "ecfp4", n_bits=1024)
133
+ # {'collision_rate': 0.027, 'collided_bits': 15, ...}
134
+ ```
135
+
136
+ ### Activity Cliff Detection
137
+
138
+ Find structurally similar molecules with large activity differences:
139
+
140
+ ```python
141
+ cliffs = molfp.activity_cliffs(smiles, activities, "maccs",
142
+ sim_threshold=0.8, activity_threshold=2.0)
143
+ ```
144
+
145
+ ### Bioactivity Class Profiling
146
+
147
+ Classify molecules, analyze intra/inter-class similarity, find enriched bits:
148
+
149
+ ```python
150
+ labels = molfp.classify(pchembl_values, metric="pchembl")
151
+ result = molfp.class_similarity(smiles, labels, "maccs")
152
+ enriched = molfp.enriched_bits(smiles, labels, "maccs")
153
+ ```
154
+
155
+ ### Structure Standardization
156
+
157
+ Desalt, neutralize, canonicalize tautomers, deduplicate by InChIKey:
158
+
159
+ ```python
160
+ molfp.standardize_smiles("CC(=O)Oc1ccccc1C(=O)[O-].[Na+]")
161
+ # → "CC(=O)Oc1ccccc1C(=O)O" (desalted + neutralized)
162
+
163
+ result = molfp.preprocess(smiles_list)
164
+ # n_input: 1000 → n_after_standardization: 985 → n_after_deduplication: 923
165
+ ```
166
+
167
+ ### Distance Functions
168
+
169
+ Vectorized Tanimoto, Dice, Cosine, Sokal-Michener, Russel-Rao:
170
+
171
+ ```python
172
+ molfp.tanimoto(fp_a, fp_b) # single pair → float
173
+ molfp.pairwise(X, metric="tanimoto") # (N, N_bits) → (N, N) matrix (vectorized)
174
+ ```
175
+
176
+ ### scikit-learn Compatible
177
+
178
+ All 13 FP types as sklearn transformers with Pipeline, GridSearchCV, and FeatureUnion support:
179
+
180
+ ```python
181
+ from sklearn.pipeline import make_pipeline
182
+ from sklearn.ensemble import RandomForestClassifier
183
+
184
+ pipe = make_pipeline(
185
+ molfp.ECFPTransformer(n_bits=2048, n_jobs=-1),
186
+ RandomForestClassifier()
187
+ )
188
+ pipe.fit(smiles_train, y_train)
189
+ ```
190
+
191
+ ### Sparse Matrix & Parallelism
192
+
193
+ ```python
194
+ molfp.klekota_roth_batch(smiles, sparse=True, n_jobs=-1)
195
+ # → scipy.sparse.csr_matrix, 97% memory savings for KR 4860-bit FPs
196
+ ```
197
+
198
+ ## Comparison with scikit-fingerprints (skfp)
199
+
200
+ | Feature | molfp | skfp |
201
+ |---------|-------|------|
202
+ | Fingerprint types | 13 | 35 |
203
+ | Bit-level explainability | All 13 FPs | None |
204
+ | Chemical annotation | Built-in | Feature names only |
205
+ | SHAP + chemical meaning | `interpret()` | None |
206
+ | Hash collision detection | `detect_collisions()` | None |
207
+ | Activity cliff detection | `activity_cliffs()` | None |
208
+ | Bioactivity profiling | `classify()`, `enriched_bits()` | None |
209
+ | Consensus fingerprints | `fuse()` | None |
210
+ | Structure standardization | Full pipeline | Basic |
211
+ | sklearn compatible | Yes | Yes |
212
+ | Sparse + parallel | Yes | Yes |
213
+
214
+ ## License
215
+
216
+ MIT
molfp-0.2.0/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # molfp
2
+
3
+ **Interpretable molecular fingerprints with SHAP-driven chemical explanations.**
4
+
5
+ A native Python/RDKit library that computes 13 molecular fingerprint types and provides built-in tools for interpretation, collision detection, activity cliff analysis, bioactivity profiling, and SHAP-based feature explanation — capabilities not found in any other fingerprint library.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install molfp # core (RDKit + scikit-learn)
11
+ pip install molfp[shap] # + SHAP interpretation
12
+ pip install molfp[all] # everything
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```python
18
+ import molfp
19
+
20
+ # Compute fingerprints
21
+ fp = molfp.maccs("CCO") # → list of 166 ints
22
+ fp = molfp.ecfp4("c1ccccc1", n_bits=2048) # → list of 2048 ints
23
+ fps = molfp.maccs_batch(["CCO", "c1ccccc1"]) # → numpy (2, 166)
24
+
25
+ # All 13 types
26
+ molfp.available()
27
+ # ['maccs', 'ecfp4', 'pubchem', 'substructure', 'substructure_count',
28
+ # 'klekota_roth', 'klekota_roth_count', 'estate', 'atompairs2d',
29
+ # 'atompairs2d_count', 'rdk', 'rdk_ext', 'rdk_graphonly']
30
+ ```
31
+
32
+ ## Features
33
+
34
+ ### 13 Fingerprint Types
35
+
36
+ | Type | Bits | Function |
37
+ |------|------|----------|
38
+ | MACCS Keys | 166 | `molfp.maccs()` |
39
+ | ECFP4 (Morgan r=2) | 1024 | `molfp.ecfp4()` |
40
+ | PubChem/CACTVS | 881 | `molfp.pubchem()` |
41
+ | SubstructureFP | 307 | `molfp.substructure()` |
42
+ | Substructure Count | 307 | `molfp.substructure_count()` |
43
+ | Klekota-Roth | 4860 | `molfp.klekota_roth()` |
44
+ | Klekota-Roth Count | 4860 | `molfp.klekota_roth_count()` |
45
+ | E-State | 79 | `molfp.estate()` |
46
+ | AtomPairs2D | 780 | `molfp.atompairs2d()` |
47
+ | AtomPairs2D Count | 780 | `molfp.atompairs2d_count()` |
48
+ | RDKit path-based | 1024 | `molfp.rdk()` |
49
+ | RDKit Extended | 1024 | `molfp.rdk_ext()` |
50
+ | RDKit GraphOnly | 1024 | `molfp.rdk_graphonly()` |
51
+
52
+ ### Bit-Level Explainability
53
+
54
+ Map each ON bit to the atoms/bonds that triggered it — across all 13 FP types:
55
+
56
+ ```python
57
+ molfp.explain("CC(=O)Oc1ccccc1C(=O)O", "ecfp4")
58
+ # {11: [(1, 1)], 33: [(0, 0)], 64: [(6, 1), (7, 1)], ...}
59
+ # bit 64 → center atoms 6 and 7 at radius 1
60
+ ```
61
+
62
+ ### Chemical Annotation
63
+
64
+ Human-readable names for every fingerprint bit:
65
+
66
+ ```python
67
+ molfp.annotate("substructure", 83)
68
+ # {'name': 'Carboxylic_acid', 'smarts': '[CX3...](=[OX1])...', 'category': 'functional_group'}
69
+
70
+ molfp.annotate("pubchem", 9)
71
+ # {'description': 'Element count: C >= 2', 'category': 'element_count'}
72
+ ```
73
+
74
+ ### SHAP Interpretation with Chemical Meaning
75
+
76
+ One call: fingerprint → model → SHAP → chemical annotation → per-class SAR:
77
+
78
+ ```python
79
+ result = molfp.interpret(smiles, activities, fp_type="maccs", metric="pchembl")
80
+
81
+ # Per-class drivers with present/absent effect:
82
+ # ACTIVE:
83
+ # ↑ present methyl group (-CH3) SHAP +0.004 (freq 23% vs 19%)
84
+ # ↓ present oxygen atom SHAP -0.003 (freq 13% vs 17%)
85
+ ```
86
+
87
+ ### Hash Collision Detection
88
+
89
+ Detect when different substructures hash to the same bit:
90
+
91
+ ```python
92
+ molfp.detect_collisions("c1ccccc1", "ecfp4", n_bits=256)
93
+ # {63: [2360741695, 3976623167]} → 2 hashes collided at bit 63
94
+
95
+ molfp.collision_rate(smiles_list, "ecfp4", n_bits=1024)
96
+ # {'collision_rate': 0.027, 'collided_bits': 15, ...}
97
+ ```
98
+
99
+ ### Activity Cliff Detection
100
+
101
+ Find structurally similar molecules with large activity differences:
102
+
103
+ ```python
104
+ cliffs = molfp.activity_cliffs(smiles, activities, "maccs",
105
+ sim_threshold=0.8, activity_threshold=2.0)
106
+ ```
107
+
108
+ ### Bioactivity Class Profiling
109
+
110
+ Classify molecules, analyze intra/inter-class similarity, find enriched bits:
111
+
112
+ ```python
113
+ labels = molfp.classify(pchembl_values, metric="pchembl")
114
+ result = molfp.class_similarity(smiles, labels, "maccs")
115
+ enriched = molfp.enriched_bits(smiles, labels, "maccs")
116
+ ```
117
+
118
+ ### Structure Standardization
119
+
120
+ Desalt, neutralize, canonicalize tautomers, deduplicate by InChIKey:
121
+
122
+ ```python
123
+ molfp.standardize_smiles("CC(=O)Oc1ccccc1C(=O)[O-].[Na+]")
124
+ # → "CC(=O)Oc1ccccc1C(=O)O" (desalted + neutralized)
125
+
126
+ result = molfp.preprocess(smiles_list)
127
+ # n_input: 1000 → n_after_standardization: 985 → n_after_deduplication: 923
128
+ ```
129
+
130
+ ### Distance Functions
131
+
132
+ Vectorized Tanimoto, Dice, Cosine, Sokal-Michener, Russel-Rao:
133
+
134
+ ```python
135
+ molfp.tanimoto(fp_a, fp_b) # single pair → float
136
+ molfp.pairwise(X, metric="tanimoto") # (N, N_bits) → (N, N) matrix (vectorized)
137
+ ```
138
+
139
+ ### scikit-learn Compatible
140
+
141
+ All 13 FP types as sklearn transformers with Pipeline, GridSearchCV, and FeatureUnion support:
142
+
143
+ ```python
144
+ from sklearn.pipeline import make_pipeline
145
+ from sklearn.ensemble import RandomForestClassifier
146
+
147
+ pipe = make_pipeline(
148
+ molfp.ECFPTransformer(n_bits=2048, n_jobs=-1),
149
+ RandomForestClassifier()
150
+ )
151
+ pipe.fit(smiles_train, y_train)
152
+ ```
153
+
154
+ ### Sparse Matrix & Parallelism
155
+
156
+ ```python
157
+ molfp.klekota_roth_batch(smiles, sparse=True, n_jobs=-1)
158
+ # → scipy.sparse.csr_matrix, 97% memory savings for KR 4860-bit FPs
159
+ ```
160
+
161
+ ## Comparison with scikit-fingerprints (skfp)
162
+
163
+ | Feature | molfp | skfp |
164
+ |---------|-------|------|
165
+ | Fingerprint types | 13 | 35 |
166
+ | Bit-level explainability | All 13 FPs | None |
167
+ | Chemical annotation | Built-in | Feature names only |
168
+ | SHAP + chemical meaning | `interpret()` | None |
169
+ | Hash collision detection | `detect_collisions()` | None |
170
+ | Activity cliff detection | `activity_cliffs()` | None |
171
+ | Bioactivity profiling | `classify()`, `enriched_bits()` | None |
172
+ | Consensus fingerprints | `fuse()` | None |
173
+ | Structure standardization | Full pipeline | Basic |
174
+ | sklearn compatible | Yes | Yes |
175
+ | Sparse + parallel | Yes | Yes |
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,103 @@
1
+ """
2
+ molfp — Native Python/RDKit molecular fingerprint library.
3
+
4
+ 13 fingerprint types + 7 novel analysis modules:
5
+ - explain: bit-level explainability (which atoms set which bits)
6
+ - annotate: chemical names/descriptions for every bit
7
+ - collisions: hash collision detection for hashed FPs
8
+ - similarity: similarity distribution diagnostics
9
+ - cliffs: activity cliff detection
10
+ - fusion: consensus fingerprint construction
11
+ - isomers: structural isomer awareness
12
+
13
+ Usage:
14
+ import molfp
15
+
16
+ # Compute fingerprints
17
+ fp = molfp.maccs("CCO")
18
+ fps = molfp.maccs_batch(["CCO", "c1ccccc1"])
19
+
20
+ # Explain which atoms set which bits
21
+ molfp.explain("CCO", "ecfp4")
22
+
23
+ # Get chemical annotation for a bit
24
+ molfp.annotate("substructure", 1)
25
+
26
+ # Detect hash collisions
27
+ molfp.detect_collisions("c1ccccc1", "ecfp4")
28
+
29
+ # Find activity cliffs
30
+ molfp.activity_cliffs(smiles, activities, "ecfp4")
31
+
32
+ # Build optimal fusion fingerprint
33
+ molfp.fuse(smiles, activities, ["ecfp4", "maccs", "pubchem"])
34
+
35
+ # Check discriminative power
36
+ molfp.discriminative_power(smiles)
37
+ """
38
+
39
+ __version__ = "0.2.0"
40
+
41
+ # ── Core fingerprint functions ────────────────────────────────────────────────
42
+ from .fingerprints import (
43
+ maccs,
44
+ ecfp4,
45
+ pubchem,
46
+ substructure,
47
+ substructure_count,
48
+ klekota_roth,
49
+ klekota_roth_count,
50
+ estate,
51
+ atompairs2d,
52
+ atompairs2d_count,
53
+ rdk,
54
+ rdk_ext,
55
+ rdk_graphonly,
56
+ maccs_batch,
57
+ ecfp4_batch,
58
+ pubchem_batch,
59
+ substructure_batch,
60
+ substructure_count_batch,
61
+ klekota_roth_batch,
62
+ klekota_roth_count_batch,
63
+ estate_batch,
64
+ atompairs2d_batch,
65
+ atompairs2d_count_batch,
66
+ rdk_batch,
67
+ rdk_ext_batch,
68
+ rdk_graphonly_batch,
69
+ available,
70
+ get,
71
+ compute_batch,
72
+ FINGERPRINTS,
73
+ )
74
+
75
+ # ── Novel analysis modules ────────────────────────────────────────────────────
76
+ from .explain import explain
77
+ from .annotate import annotate, get_annotations
78
+ from .collisions import detect_collisions, collision_rate, collision_pairs
79
+ from .similarity import similarity_matrix, similarity_profile, compare_fps
80
+ from .cliffs import activity_cliffs, cliff_summary
81
+ from .fusion import fuse, complementarity
82
+ from .isomers import find_identical_fps, discriminative_power, isomer_sensitivity
83
+ from .bioactivity import (
84
+ classify, class_similarity, enriched_bits, depleted_bits, profile as bioactivity_profile,
85
+ )
86
+ from .standardize import (
87
+ standardize_mol, standardize_smiles, get_inchikey,
88
+ desalt, neutralize, canonicalize_tautomer,
89
+ standardize_batch, deduplicate, preprocess,
90
+ )
91
+ from .sklearn import (
92
+ MACCSTransformer, ECFPTransformer, PubChemTransformer,
93
+ SubstructureTransformer, SubstructureCountTransformer,
94
+ KlekotaRothTransformer, KlekotaRothCountTransformer,
95
+ EStateTransformer, AtomPairs2DTransformer, AtomPairs2DCountTransformer,
96
+ RDKTransformer, RDKExtTransformer, RDKGraphOnlyTransformer,
97
+ get_transformer, TRANSFORMERS,
98
+ )
99
+ from .interpret import interpret, interpret_molecule, compare_molecules
100
+ from .distance import (
101
+ tanimoto, dice, cosine, sokal_michener, russel_rao,
102
+ distance, pairwise, pairwise_distance, available_metrics,
103
+ )
@@ -0,0 +1,157 @@
1
+ """
2
+ PubChem/CACTVS fingerprint (881 bits) — internal implementation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from rdkit import Chem
10
+
11
+ from . import _smarts
12
+
13
+
14
+ _ELEMENT_TO_ANUM = {}
15
+ for _z in range(1, 119):
16
+ _sym = Chem.GetPeriodicTable().GetElementSymbol(_z)
17
+ _ELEMENT_TO_ANUM[_sym] = _z
18
+
19
+
20
+ def _classify_rings(mol):
21
+ """Classify SSSR rings by size and type. Returns {(type, size): count}."""
22
+ ri = mol.GetRingInfo()
23
+ counts = {}
24
+
25
+ for ring in ri.AtomRings():
26
+ size = len(ring)
27
+ atomic_nums = [mol.GetAtomWithIdx(i).GetAtomicNum() for i in ring]
28
+
29
+ all_carbon = all(z == 6 for z in atomic_nums)
30
+ has_nitrogen = any(z == 7 for z in atomic_nums)
31
+ has_hetero = any(z not in (1, 6) for z in atomic_nums)
32
+
33
+ bonds = []
34
+ for i in range(size):
35
+ b = mol.GetBondBetweenAtoms(ring[i], ring[(i + 1) % size])
36
+ if b:
37
+ bonds.append(b)
38
+
39
+ is_aromatic = all(b.GetIsAromatic() for b in bonds)
40
+ is_saturated = all(b.GetBondType() == Chem.BondType.SINGLE for b in bonds)
41
+ is_sat_or_arom = is_saturated or is_aromatic
42
+ is_unsaturated = not is_saturated and not is_aromatic
43
+
44
+ counts[("countAnyRing", size)] = counts.get(("countAnyRing", size), 0) + 1
45
+
46
+ if is_sat_or_arom:
47
+ if all_carbon:
48
+ k = ("countSaturatedOrAromaticCarbonOnlyRing", size)
49
+ counts[k] = counts.get(k, 0) + 1
50
+ if has_nitrogen:
51
+ k = ("countSaturatedOrAromaticNitrogenContainingRing", size)
52
+ counts[k] = counts.get(k, 0) + 1
53
+ if has_hetero:
54
+ k = ("countSaturatedOrAromaticHeteroContainingRing", size)
55
+ counts[k] = counts.get(k, 0) + 1
56
+
57
+ if is_unsaturated:
58
+ if all_carbon:
59
+ k = ("countUnsaturatedCarbonOnlyRing", size)
60
+ counts[k] = counts.get(k, 0) + 1
61
+ if has_nitrogen:
62
+ k = ("countUnsaturatedNitrogenContainingRing", size)
63
+ counts[k] = counts.get(k, 0) + 1
64
+ if has_hetero:
65
+ k = ("countUnsaturatedHeteroContainingRing", size)
66
+ counts[k] = counts.get(k, 0) + 1
67
+
68
+ if is_aromatic:
69
+ counts[("countAromaticRing", "any")] = counts.get(("countAromaticRing", "any"), 0) + 1
70
+ if has_hetero:
71
+ counts[("countHeteroAromaticRing", "any")] = counts.get(("countHeteroAromaticRing", "any"), 0) + 1
72
+
73
+ return counts
74
+
75
+
76
+ def load_definitions() -> tuple[list[tuple[int, int, int]], list[tuple[int, str, int | str, int]], list[tuple[int, Chem.rdchem.Mol]]]:
77
+ """Parse pubchem_fingerprint_definitions.txt.
78
+
79
+ Returns (section1, section2, smarts_bits).
80
+ """
81
+ path = _smarts._data_path("pubchem_fingerprint_definitions.txt")
82
+ section1, section2, smarts_bits = [], [], []
83
+ current_section = None
84
+
85
+ with open(path, encoding="utf-8") as f:
86
+ for line in f:
87
+ line = line.rstrip()
88
+ if not line or line.startswith("#") or line.startswith("====="):
89
+ continue
90
+ if "SECTION 1" in line:
91
+ current_section = 1; continue
92
+ elif "SECTION 2" in line:
93
+ current_section = 2; continue
94
+ elif any(f"SECTION {i}" in line for i in range(3, 8)):
95
+ current_section = "smarts"; continue
96
+ if line.startswith("Format:") or line.startswith("Ring type") or line.startswith(" "):
97
+ continue
98
+
99
+ parts = line.split()
100
+ if not parts or not parts[0].isdigit():
101
+ continue
102
+ bit = int(parts[0])
103
+
104
+ if current_section == 1:
105
+ anum = _ELEMENT_TO_ANUM.get(parts[1], 0)
106
+ section1.append((bit, anum, int(parts[3])))
107
+ elif current_section == 2:
108
+ ring_size = parts[2] if parts[2] == "any" else int(parts[2])
109
+ section2.append((bit, parts[1], ring_size, int(parts[4])))
110
+ elif current_section == "smarts" and len(parts) > 1:
111
+ pat = Chem.MolFromSmarts(parts[1])
112
+ if pat is not None:
113
+ smarts_bits.append((bit, pat))
114
+
115
+ return section1, section2, smarts_bits
116
+
117
+
118
+ class PubChemComputer:
119
+ """Lazy-loaded PubChem FP computer."""
120
+
121
+ def __init__(self):
122
+ self._loaded = False
123
+ self._s1 = self._s2 = self._smarts = None
124
+
125
+ def _ensure_loaded(self):
126
+ if not self._loaded:
127
+ self._s1, self._s2, self._smarts = load_definitions()
128
+ self._loaded = True
129
+
130
+ def compute(self, mol: Chem.rdchem.Mol) -> list[int]:
131
+ """Compute 881-bit PubChem FP for an RDKit Mol. Returns list of ints."""
132
+ self._ensure_loaded()
133
+ bits = [0] * 881
134
+
135
+ # Section 1: element counts
136
+ elem_counts = {}
137
+ for atom in mol.GetAtoms():
138
+ anum = atom.GetAtomicNum()
139
+ elem_counts[anum] = elem_counts.get(anum, 0) + 1
140
+ elem_counts[1] = sum(a.GetTotalNumHs() for a in mol.GetAtoms())
141
+
142
+ for bit, anum, threshold in self._s1:
143
+ if elem_counts.get(anum, 0) >= threshold:
144
+ bits[bit] = 1
145
+
146
+ # Section 2: ring counts
147
+ ring_counts = _classify_rings(mol)
148
+ for bit, ring_type, ring_size, threshold in self._s2:
149
+ if ring_counts.get((ring_type, ring_size), 0) >= threshold:
150
+ bits[bit] = 1
151
+
152
+ # Sections 3-7: SMARTS
153
+ for bit, pat in self._smarts:
154
+ if mol.HasSubstructMatch(pat):
155
+ bits[bit] = 1
156
+
157
+ return bits