molfp 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ )
molfp/_pubchem.py ADDED
@@ -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
molfp/_smarts.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ SMARTS pattern loading, compilation, and matching utilities.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib.resources
8
+ import os
9
+
10
+ from rdkit import Chem
11
+
12
+
13
+ def _data_path(filename):
14
+ """Resolve path to a data file in molfp/data/."""
15
+ return os.path.join(os.path.dirname(__file__), "data", filename)
16
+
17
+
18
+ def load_smarts_file(filename: str) -> list[tuple[str, str]]:
19
+ """Load SMARTS patterns from a data file. Returns list of (name, smarts) tuples."""
20
+ path = _data_path(filename)
21
+ patterns = []
22
+ with open(path, encoding="utf-8") as f:
23
+ for line in f:
24
+ line = line.strip()
25
+ if not line or line.startswith("#"):
26
+ continue
27
+ if ":" in line and not line.startswith("["):
28
+ name, smarts = line.split(":", 1)
29
+ patterns.append((name.strip(), smarts.strip()))
30
+ else:
31
+ patterns.append((f"bit_{len(patterns)}", line))
32
+ return patterns
33
+
34
+
35
+ def load_plain_smarts(filename: str) -> list[tuple[str, str]]:
36
+ """Load a file where each non-empty, non-comment line is a SMARTS string."""
37
+ path = _data_path(filename)
38
+ patterns = []
39
+ with open(path, encoding="utf-8") as f:
40
+ for i, line in enumerate(f):
41
+ line = line.strip()
42
+ if line and not line.startswith("#"):
43
+ patterns.append((f"bit_{i}", line))
44
+ return patterns
45
+
46
+
47
+ def compile_smarts(patterns: list[tuple[str, str]]) -> tuple[list[tuple[str, Chem.rdchem.Mol | _SaltMatcher]], list[tuple[str, str]]]:
48
+ """Compile (name, smarts) list. Returns (compiled, failed).
49
+
50
+ Handles special patterns like the Salt disconnected-charge pattern.
51
+ """
52
+ compiled = []
53
+ failed = []
54
+ for name, smarts in patterns:
55
+ pat = Chem.MolFromSmarts(smarts)
56
+ if pat is not None:
57
+ compiled.append((name, pat))
58
+ elif _is_salt_pattern(smarts):
59
+ compiled.append((name, _SaltMatcher()))
60
+ else:
61
+ failed.append((name, smarts))
62
+ return compiled, failed
63
+
64
+
65
+ def _is_salt_pattern(smarts):
66
+ return "[-1,-2,-3,-4,-5,-6,-7" in smarts and "+1,+2,+3,+4,+5,+6,+7" in smarts
67
+
68
+
69
+ class _SaltMatcher:
70
+ pass
71
+
72
+
73
+ def _check_salt(mol):
74
+ has_neg = has_pos = False
75
+ for atom in mol.GetAtoms():
76
+ fc = atom.GetFormalCharge()
77
+ if fc < 0:
78
+ has_neg = True
79
+ if fc > 0:
80
+ has_pos = True
81
+ if has_neg and has_pos:
82
+ return True
83
+ return False
84
+
85
+
86
+ def match_binary(mol: Chem.rdchem.Mol, compiled_patterns: list[tuple[str, Chem.rdchem.Mol | _SaltMatcher]]) -> list[int]:
87
+ """Match patterns, return list of 0/1 ints."""
88
+ result = []
89
+ for _, pat in compiled_patterns:
90
+ if isinstance(pat, _SaltMatcher):
91
+ result.append(1 if _check_salt(mol) else 0)
92
+ else:
93
+ result.append(1 if mol.HasSubstructMatch(pat) else 0)
94
+ return result
95
+
96
+
97
+ def match_count(mol: Chem.rdchem.Mol, compiled_patterns: list[tuple[str, Chem.rdchem.Mol | _SaltMatcher]]) -> list[int]:
98
+ """Count pattern matches, return list of ints."""
99
+ result = []
100
+ for _, pat in compiled_patterns:
101
+ if isinstance(pat, _SaltMatcher):
102
+ result.append(1 if _check_salt(mol) else 0)
103
+ else:
104
+ result.append(len(mol.GetSubstructMatches(pat)))
105
+ return result
molfp/annotate.py ADDED
@@ -0,0 +1,198 @@
1
+ """
2
+ Chemical annotation: human-readable names and descriptions for fingerprint bits.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from rdkit import Chem
10
+ from rdkit.Chem import MACCSkeys
11
+
12
+ from . import _smarts
13
+ from .fingerprints import _get_substructure_patterns, _get_kr_patterns, _get_pubchem_computer
14
+
15
+ _annotation_cache = {}
16
+
17
+
18
+ def annotate(fp_type: str, bit_idx: int) -> dict[str, Any]:
19
+ """Get human-readable annotation for a specific fingerprint bit.
20
+
21
+ Args:
22
+ fp_type: fingerprint name (e.g. "substructure", "pubchem", "maccs")
23
+ bit_idx: bit index (0-based)
24
+
25
+ Returns:
26
+ dict with keys: name, smarts (if applicable), description, category
27
+ """
28
+ annotations = get_annotations(fp_type)
29
+ if bit_idx not in annotations:
30
+ return {"name": f"bit_{bit_idx}", "description": "Unknown bit", "category": "unknown"}
31
+ return annotations[bit_idx]
32
+
33
+
34
+ def get_annotations(fp_type: str) -> dict[int, dict[str, Any]]:
35
+ """Get all annotations for a fingerprint type. Returns {bit_idx: annotation_dict}."""
36
+ if fp_type in _annotation_cache:
37
+ return _annotation_cache[fp_type]
38
+
39
+ dispatch = {
40
+ "maccs": _annotate_maccs,
41
+ "substructure": _annotate_substructure,
42
+ "substructure_count": _annotate_substructure,
43
+ "klekota_roth": _annotate_kr,
44
+ "klekota_roth_count": _annotate_kr,
45
+ "pubchem": _annotate_pubchem,
46
+ "estate": _annotate_estate,
47
+ "ecfp4": _annotate_hashed,
48
+ "atompairs2d": _annotate_hashed,
49
+ "atompairs2d_count": _annotate_hashed,
50
+ "rdk": _annotate_hashed,
51
+ "rdk_ext": _annotate_hashed,
52
+ "rdk_graphonly": _annotate_hashed,
53
+ }
54
+ if fp_type not in dispatch:
55
+ raise KeyError(f"Unknown fp_type: {fp_type}")
56
+
57
+ result = dispatch[fp_type](fp_type)
58
+ _annotation_cache[fp_type] = result
59
+ return result
60
+
61
+
62
+ def _annotate_maccs(fp_type):
63
+ annotations = {}
64
+ patts = MACCSkeys.smartsPatts
65
+ for bit_idx in range(166):
66
+ rdkit_bit = bit_idx + 1
67
+ if rdkit_bit in patts:
68
+ entry = patts[rdkit_bit]
69
+ sma = entry[0] if len(entry) > 0 else ""
70
+ desc = entry[1] if len(entry) > 1 else ""
71
+ if not isinstance(desc, str):
72
+ desc = f"MACCS key {rdkit_bit}"
73
+ annotations[bit_idx] = {
74
+ "name": f"MACCS_{rdkit_bit}",
75
+ "smarts": sma,
76
+ "description": desc if desc else f"MACCS key {rdkit_bit}",
77
+ "category": "substructure",
78
+ }
79
+ else:
80
+ annotations[bit_idx] = {
81
+ "name": f"MACCS_{rdkit_bit}",
82
+ "description": f"MACCS key {rdkit_bit} (no public definition)",
83
+ "category": "substructure",
84
+ }
85
+ return annotations
86
+
87
+
88
+ def _annotate_substructure(fp_type):
89
+ patterns = _get_substructure_patterns()
90
+ annotations = {}
91
+ for i, (name, pat) in enumerate(patterns):
92
+ smarts = ""
93
+ if isinstance(pat, _smarts._SaltMatcher):
94
+ smarts = "([-charge]).([+charge])"
95
+ else:
96
+ smarts = Chem.MolToSmarts(pat) if pat else ""
97
+ annotations[i] = {
98
+ "name": name,
99
+ "smarts": smarts,
100
+ "description": f"Inte:Ligand functional group: {name}",
101
+ "category": "functional_group",
102
+ }
103
+ return annotations
104
+
105
+
106
+ def _annotate_kr(fp_type):
107
+ raw = _smarts.load_plain_smarts("klekota_roth_smarts.txt")
108
+ annotations = {}
109
+ for i, (name, smarts_str) in enumerate(raw):
110
+ annotations[i] = {
111
+ "name": f"KRFP{i + 1}",
112
+ "smarts": smarts_str,
113
+ "description": f"Klekota-Roth substructure {i + 1}",
114
+ "category": "bioactivity_substructure",
115
+ }
116
+ return annotations
117
+
118
+
119
+ def _annotate_pubchem(fp_type):
120
+ computer = _get_pubchem_computer()
121
+ computer._ensure_loaded()
122
+ annotations = {}
123
+
124
+ for bit, anum, threshold in computer._s1:
125
+ sym = Chem.GetPeriodicTable().GetElementSymbol(anum) if anum > 0 else "?"
126
+ annotations[bit] = {
127
+ "name": f"PubchemFP{bit}",
128
+ "description": f"Element count: {sym} >= {threshold}",
129
+ "category": "element_count",
130
+ }
131
+
132
+ for bit, ring_type, ring_size, threshold in computer._s2:
133
+ annotations[bit] = {
134
+ "name": f"PubchemFP{bit}",
135
+ "description": f"Ring: {ring_type} size={ring_size} >= {threshold}",
136
+ "category": "ring_count",
137
+ }
138
+
139
+ for bit, pat in computer._smarts:
140
+ smarts = Chem.MolToSmarts(pat) if pat else ""
141
+ section = "3-7"
142
+ if 263 <= bit <= 326:
143
+ section = "3 (atom pairs)"
144
+ elif 327 <= bit <= 415:
145
+ section = "4 (nearest neighbors)"
146
+ elif 416 <= bit <= 459:
147
+ section = "5 (detailed neighbors)"
148
+ elif 460 <= bit <= 712:
149
+ section = "6 (bond-specific)"
150
+ elif 713 <= bit <= 880:
151
+ section = "7 (ring substitution)"
152
+ annotations[bit] = {
153
+ "name": f"PubchemFP{bit}",
154
+ "smarts": smarts,
155
+ "description": f"Section {section} substructure",
156
+ "category": "smarts",
157
+ }
158
+
159
+ return annotations
160
+
161
+
162
+ def _annotate_estate(fp_type):
163
+ from rdkit.Chem.EState import AtomTypes as AT
164
+ AT.BuildPatts()
165
+ patterns = AT.esPatterns
166
+
167
+ atom_type_names = [
168
+ "sLi","ssBe","ssssBe","ssBH","sssB","ssssB",
169
+ "sCH3","dCH2","ssCH2","tCH","dsCH","aaCH","sssCH","ddC","tsC",
170
+ "dssC","aasC","aaaC","ssssC","sNH3","sNH2","ssNH2","dNH","ssNH",
171
+ "aaNH","tN","sssNH","dsN","aaN","sssN","ddsN","aasN","ssssN",
172
+ "sOH","dO","ssO","aaO","sF","sSiH3","ssSiH2","sssSiH","ssssSi",
173
+ "sPH2","ssPH","sssP","dsssP","sssssP","sSH","dS","ssS","aaS",
174
+ "dssS","ddssS","ssssssS","sCl","sGeH3","ssGeH2","sssGeH","ssssGe",
175
+ "sAsH2","ssAsH","sssAs","sssdAs","sssssAs","sSeH","dSe","ssSe",
176
+ "aaSe","dssSe","ddssSe","sBr","sSnH3","ssSnH2","sssSnH","ssssSn",
177
+ "sI","sPbH3","ssPbH2","sssPbH","ssssPb",
178
+ ]
179
+ annotations = {}
180
+ for i in range(79):
181
+ name = atom_type_names[i] if i < len(atom_type_names) else f"EState_{i}"
182
+ smarts = ""
183
+ if patterns and i < len(patterns):
184
+ _, pat = patterns[i]
185
+ if pat is not None:
186
+ smarts = Chem.MolToSmarts(pat)
187
+ annotations[i] = {
188
+ "name": name,
189
+ "smarts": smarts,
190
+ "description": f"E-State atom type: {name}",
191
+ "category": "atom_type",
192
+ }
193
+ return annotations
194
+
195
+
196
+ def _annotate_hashed(fp_type):
197
+ """For hashed fingerprints, bits don't have fixed meanings."""
198
+ return {} # empty — use explain() with a specific molecule instead