fpembed 0.1.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.
fpembed/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """FPembed — Generalized fingerprint embedding library backed by scikit-fingerprints.
2
+
3
+ Provides :class:`EmbeddedFingerprintGenerator` for generating compressed
4
+ molecular fingerprints from SMILES, SELFIES, or RDKit Mol objects, plus
5
+ the standalone :func:`compress_fingerprint` compression function and
6
+ lightweight SMILES helpers.
7
+
8
+ Example:
9
+ >>> from fpembed import EmbeddedFingerprintGenerator
10
+ >>> gen = EmbeddedFingerprintGenerator(
11
+ ... fp_type="ecfp", fp_size=2048, compression=16, fp_params={"radius": 2}
12
+ ... )
13
+ >>> emb = gen.GetFingerprintFromSmiles("CCO")
14
+ >>> emb.shape
15
+ (128,)
16
+ """
17
+
18
+ from fpembed.generator import EmbeddedFingerprintGenerator
19
+ from fpembed.compression import compress_fingerprint
20
+ from fpembed.smiles_utils import parse_smiles, canonicalize_smiles
21
+ from fpembed.hashing import fp_params_hash
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "EmbeddedFingerprintGenerator",
27
+ "compress_fingerprint",
28
+ "parse_smiles",
29
+ "canonicalize_smiles",
30
+ "fp_params_hash",
31
+ "__version__",
32
+ ]
fpembed/compression.py ADDED
@@ -0,0 +1,154 @@
1
+ """Fingerprint compression orchestrator.
2
+
3
+ Routes ``compress_fingerprint()`` calls to the appropriate sub-module
4
+ based on the ``method`` parameter. Supports block-wise weight schemes
5
+ (geometric, linear, log, uniform) and global projection methods
6
+ (hadamard, random_projection).
7
+
8
+ The default behavior (``method="geometric"``, no ``method_params``)
9
+ produces output identical to the original implementation.
10
+
11
+ Public API
12
+ ----------
13
+ compress_fingerprint : Compress a fingerprint array.
14
+ _is_power_of_two : Check whether an integer is a power of two.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ import numpy as np
22
+ import numpy.typing as npt
23
+
24
+ from fpembed.compression_blockwise import compress_blockwise
25
+ from fpembed.compression_projection import (
26
+ build_rp_matrix,
27
+ build_srht_signs,
28
+ compress_hadamard,
29
+ compress_random_projection,
30
+ )
31
+
32
+ _BLOCKWISE_METHODS = {"geometric", "linear", "log", "uniform"}
33
+ _PROJECTION_METHODS = {"hadamard", "random_projection"}
34
+ _ALL_METHODS = _BLOCKWISE_METHODS | _PROJECTION_METHODS
35
+
36
+ _VALID_PARAMS: dict[str, set[str]] = {
37
+ "geometric": {"interleave"},
38
+ "linear": {"interleave"},
39
+ "log": {"interleave"},
40
+ "uniform": {"interleave"},
41
+ "hadamard": {"seed"},
42
+ "random_projection": {"seed", "sparse"},
43
+ }
44
+
45
+ _DEFAULT_SEED = 42
46
+
47
+
48
+ def _validate_method_params(method: str, method_params: dict) -> None:
49
+ """Validate method_params keys and value types for the given method."""
50
+ valid_keys = _VALID_PARAMS[method]
51
+ unrecognized = set(method_params) - valid_keys
52
+ if unrecognized:
53
+ raise ValueError(
54
+ f"Unrecognized method_params keys {unrecognized} for method "
55
+ f"'{method}'. Valid keys: {valid_keys}"
56
+ )
57
+
58
+ if "seed" in method_params:
59
+ seed = method_params["seed"]
60
+ if not isinstance(seed, int) or isinstance(seed, bool) or seed < 0:
61
+ raise ValueError(
62
+ f"seed must be a non-negative integer, got {seed!r}"
63
+ )
64
+
65
+ if "sparse" in method_params:
66
+ sparse = method_params["sparse"]
67
+ if not isinstance(sparse, bool):
68
+ raise ValueError(
69
+ f"sparse must be a boolean, got {type(sparse).__name__}"
70
+ )
71
+
72
+ if "interleave" in method_params:
73
+ interleave = method_params["interleave"]
74
+ if not isinstance(interleave, bool):
75
+ raise ValueError(
76
+ f"interleave must be a boolean, got {type(interleave).__name__}"
77
+ )
78
+ if interleave and method in _PROJECTION_METHODS:
79
+ raise ValueError(
80
+ f"interleave is not applicable to method '{method}'"
81
+ )
82
+
83
+
84
+ def compress_fingerprint(
85
+ vector: npt.NDArray[Any],
86
+ size: int,
87
+ *,
88
+ method: str = "geometric",
89
+ method_params: dict | None = None,
90
+ ) -> npt.NDArray[np.float64]:
91
+ """Compress a binary fingerprint into a dense float embedding.
92
+
93
+ Parameters
94
+ ----------
95
+ vector : ndarray
96
+ Input fingerprint — ``(L,)`` or ``(N, L)``.
97
+ size : int
98
+ Compression factor.
99
+ method : str
100
+ Compression method name.
101
+ method_params : dict or None
102
+ Method-specific parameters.
103
+
104
+ Returns
105
+ -------
106
+ ndarray of float64
107
+ Compressed embedding.
108
+
109
+ Raises
110
+ ------
111
+ ValueError
112
+ If *size* is invalid, *method* is unsupported, or *method_params*
113
+ contains invalid keys/values.
114
+ """
115
+ if not isinstance(size, int) or size <= 0:
116
+ raise ValueError(
117
+ "Size must be a positive integer. Try size = 16, or size = 32"
118
+ )
119
+
120
+ if method not in _ALL_METHODS:
121
+ raise ValueError(
122
+ f"Unsupported method '{method}'. "
123
+ f"Supported: {', '.join(sorted(_ALL_METHODS))}"
124
+ )
125
+
126
+ if method_params is None:
127
+ method_params = {}
128
+
129
+ _validate_method_params(method, method_params)
130
+
131
+ # Block-wise methods
132
+ if method in _BLOCKWISE_METHODS:
133
+ interleave = method_params.get("interleave", False)
134
+ return compress_blockwise(vector, size, scheme=method, interleave=interleave)
135
+
136
+ # Projection methods
137
+ fp_len = vector.shape[-1] if vector.ndim > 1 else vector.shape[0]
138
+ output_dim = fp_len // size
139
+
140
+ if method == "hadamard":
141
+ seed = method_params.get("seed", _DEFAULT_SEED)
142
+ signs = build_srht_signs(fp_len, seed)
143
+ return compress_hadamard(vector, size, signs)
144
+
145
+ # method == "random_projection"
146
+ seed = method_params.get("seed", _DEFAULT_SEED)
147
+ sparse = method_params.get("sparse", False)
148
+ matrix = build_rp_matrix(fp_len, output_dim, seed, sparse)
149
+ return compress_random_projection(vector, matrix)
150
+
151
+
152
+ def _is_power_of_two(n: int) -> bool:
153
+ """Return True if *n* is a positive power of two."""
154
+ return n > 0 and (n & (n - 1)) == 0
@@ -0,0 +1,105 @@
1
+ """Block-wise fingerprint compression with pluggable weight schemes.
2
+
3
+ Provides weight vector construction for geometric, linear, logarithmic,
4
+ and uniform schemes, plus block partitioning (contiguous and interleaved)
5
+ with weighted dot-product reduction.
6
+
7
+ Public API
8
+ ----------
9
+ build_block_weights : Build a normalized weight vector for a given scheme.
10
+ compress_blockwise : Compress a fingerprint using block-wise weighted dot product.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ _SUPPORTED_SCHEMES = ("geometric", "linear", "log", "uniform")
21
+
22
+
23
+ def build_block_weights(
24
+ block_size: int, scheme: str = "geometric"
25
+ ) -> npt.NDArray[np.float64]:
26
+ """Return a normalized weight vector of length *block_size*.
27
+
28
+ Parameters
29
+ ----------
30
+ block_size : int
31
+ Number of bits per block (must be >= 1).
32
+ scheme : str
33
+ One of ``"geometric"``, ``"linear"``, ``"log"``, ``"uniform"``.
34
+
35
+ Returns
36
+ -------
37
+ ndarray of float64
38
+ 1-D array of length *block_size*, summing to 1.0.
39
+
40
+ Raises
41
+ ------
42
+ ValueError
43
+ If *scheme* is not recognized.
44
+ """
45
+ if scheme == "geometric":
46
+ # Log-space construction for numerical stability (moved from compression.py)
47
+ log_weights = np.arange(block_size, dtype=np.float64) - block_size
48
+ weights = np.exp2(log_weights)
49
+ elif scheme == "linear":
50
+ weights = np.arange(1, block_size + 1, dtype=np.float64)
51
+ elif scheme == "log":
52
+ weights = np.log2(np.arange(2, block_size + 2, dtype=np.float64))
53
+ elif scheme == "uniform":
54
+ return np.full(block_size, 1.0 / block_size, dtype=np.float64)
55
+ else:
56
+ raise ValueError(
57
+ f"Unsupported weight scheme '{scheme}'. "
58
+ f"Supported: {', '.join(_SUPPORTED_SCHEMES)}"
59
+ )
60
+ weights /= weights.sum()
61
+ return weights
62
+
63
+
64
+ def compress_blockwise(
65
+ vector: npt.NDArray[Any],
66
+ size: int,
67
+ scheme: str = "geometric",
68
+ interleave: bool = False,
69
+ ) -> npt.NDArray[np.float64]:
70
+ """Compress a binary fingerprint using block-wise weighted dot product.
71
+
72
+ Parameters
73
+ ----------
74
+ vector : ndarray
75
+ Input fingerprint — ``(L,)`` for single or ``(N, L)`` for batch.
76
+ size : int
77
+ Block size (compression factor).
78
+ scheme : str
79
+ Weight scheme name.
80
+ interleave : bool
81
+ If True, use strided (interleaved) partitioning; if False, contiguous.
82
+
83
+ Returns
84
+ -------
85
+ ndarray of float64
86
+ Compressed embedding — ``(1, L//size)`` or ``(N, L//size)``.
87
+ """
88
+ single = vector.ndim == 1
89
+ if single:
90
+ vector = vector[np.newaxis, :]
91
+
92
+ rows, cols = vector.shape
93
+ n_blocks = cols // size
94
+
95
+ weights = build_block_weights(size, scheme)
96
+
97
+ if interleave:
98
+ # Strided: bit[i] → block[i % n_blocks]
99
+ blocks = vector.reshape(rows, size, n_blocks).transpose(0, 2, 1)
100
+ else:
101
+ # Contiguous: bit[i] → block[i // size]
102
+ blocks = vector.reshape(rows, n_blocks, size)
103
+
104
+ embedded: npt.NDArray[np.float64] = np.einsum("ijk,k->ij", blocks, weights)
105
+ return embedded
@@ -0,0 +1,181 @@
1
+ """Global projection compression methods: SRHT and random projection.
2
+
3
+ Provides the Fast Walsh-Hadamard Transform (FWHT), Subsampled Randomized
4
+ Hadamard Transform (SRHT) compression, and seeded random projection
5
+ (dense Gaussian and sparse Achlioptas variants).
6
+
7
+ Public API
8
+ ----------
9
+ fwht : In-place Fast Walsh-Hadamard Transform.
10
+ build_srht_signs : Generate random ±1 sign vector for SRHT.
11
+ compress_hadamard : Compress via SRHT (sign flips → FWHT → truncate).
12
+ build_rp_matrix : Build a random projection matrix (dense or sparse).
13
+ compress_random_projection : Compress via matrix multiplication.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+
23
+
24
+ def fwht(x: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
25
+ """In-place Fast Walsh-Hadamard Transform (butterfly algorithm).
26
+
27
+ Operates in O(L log L) using only additions and subtractions.
28
+
29
+ Parameters
30
+ ----------
31
+ x : ndarray
32
+ 1-D float64 array of length L (must be a power of 2).
33
+
34
+ Returns
35
+ -------
36
+ ndarray
37
+ The same array, modified in-place.
38
+ """
39
+ length = len(x)
40
+ h = 1
41
+ while h < length:
42
+ for i in range(0, length, h * 2):
43
+ for j in range(i, i + h):
44
+ a = x[j]
45
+ b = x[j + h]
46
+ x[j] = a + b
47
+ x[j + h] = a - b
48
+ h *= 2
49
+ return x
50
+
51
+
52
+ def build_srht_signs(
53
+ length: int, seed: int
54
+ ) -> npt.NDArray[np.float64]:
55
+ """Generate a random ±1 sign vector for SRHT.
56
+
57
+ Parameters
58
+ ----------
59
+ length : int
60
+ Fingerprint length L.
61
+ seed : int
62
+ Random seed for reproducibility.
63
+
64
+ Returns
65
+ -------
66
+ ndarray of float64
67
+ 1-D array of ±1 values, length L.
68
+ """
69
+ rng = np.random.RandomState(seed)
70
+ return rng.choice([-1.0, 1.0], size=length).astype(np.float64)
71
+
72
+
73
+ def compress_hadamard(
74
+ vector: npt.NDArray[Any],
75
+ size: int,
76
+ signs: npt.NDArray[np.float64],
77
+ ) -> npt.NDArray[np.float64]:
78
+ """Compress using Subsampled Randomized Hadamard Transform.
79
+
80
+ Applies sign flips, FWHT, normalization by 1/√L, and truncation to
81
+ D = L // size dimensions.
82
+
83
+ Parameters
84
+ ----------
85
+ vector : ndarray
86
+ Input fingerprint — ``(L,)`` or ``(N, L)``.
87
+ size : int
88
+ Compression factor.
89
+ signs : ndarray
90
+ Precomputed ±1 sign vector of length L.
91
+
92
+ Returns
93
+ -------
94
+ ndarray of float64
95
+ Compressed embedding — ``(1, D)`` or ``(N, D)``.
96
+ """
97
+ single = vector.ndim == 1
98
+ if single:
99
+ vector = vector[np.newaxis, :]
100
+
101
+ rows, cols = vector.shape
102
+ output_dim = cols // size
103
+ norm = 1.0 / np.sqrt(cols)
104
+
105
+ result = np.empty((rows, output_dim), dtype=np.float64)
106
+ for i in range(rows):
107
+ row = vector[i].astype(np.float64) * signs
108
+ fwht(row)
109
+ row *= norm
110
+ result[i] = row[:output_dim]
111
+
112
+ return result
113
+
114
+
115
+ def build_rp_matrix(
116
+ fp_length: int,
117
+ output_dim: int,
118
+ seed: int,
119
+ sparse: bool = False,
120
+ ) -> npt.NDArray[np.float64]:
121
+ """Build a random projection matrix R of shape ``(output_dim, fp_length)``.
122
+
123
+ Dense Gaussian: ``R[i,j] ~ N(0, 1/D)``.
124
+ Sparse Achlioptas: ``R[i,j] ∈ {-1, 0, +1}`` with ``P(0) = 2/3``,
125
+ scaled by ``√(3/D)``.
126
+
127
+ Parameters
128
+ ----------
129
+ fp_length : int
130
+ Input dimension L.
131
+ output_dim : int
132
+ Output dimension D.
133
+ seed : int
134
+ Random seed for reproducibility.
135
+ sparse : bool
136
+ If True, use Achlioptas variant.
137
+
138
+ Returns
139
+ -------
140
+ ndarray of float64
141
+ 2-D array of shape ``(D, L)``.
142
+ """
143
+ rng = np.random.RandomState(seed)
144
+
145
+ if sparse:
146
+ # Achlioptas: P(-1) = 1/6, P(0) = 2/3, P(+1) = 1/6
147
+ raw = rng.choice([-1, 0, 0, 0, 0, 1], size=(output_dim, fp_length))
148
+ scale = np.sqrt(3.0 / output_dim)
149
+ return raw.astype(np.float64) * scale
150
+ else:
151
+ # Dense Gaussian: N(0, 1/D)
152
+ std = 1.0 / np.sqrt(output_dim)
153
+ return rng.randn(output_dim, fp_length).astype(np.float64) * std
154
+
155
+
156
+ def compress_random_projection(
157
+ vector: npt.NDArray[Any],
158
+ matrix: npt.NDArray[np.float64],
159
+ ) -> npt.NDArray[np.float64]:
160
+ """Compress using a precomputed random projection matrix.
161
+
162
+ Computes ``embedding = vector @ matrix.T``.
163
+
164
+ Parameters
165
+ ----------
166
+ vector : ndarray
167
+ Input fingerprint — ``(L,)`` or ``(N, L)``.
168
+ matrix : ndarray
169
+ Projection matrix of shape ``(D, L)``.
170
+
171
+ Returns
172
+ -------
173
+ ndarray of float64
174
+ Compressed embedding — ``(1, D)`` or ``(N, D)``.
175
+ """
176
+ single = vector.ndim == 1
177
+ if single:
178
+ vector = vector[np.newaxis, :]
179
+
180
+ embedded = vector.astype(np.float64) @ matrix.T
181
+ return embedded
fpembed/generator.py ADDED
@@ -0,0 +1,348 @@
1
+ """EmbeddedFingerprintGenerator — unified fingerprint class backed by skfp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections import namedtuple
7
+ from typing import Any, TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+ from rdkit import Chem
12
+ from skfp.fingerprints import (
13
+ AtomPairFingerprint,
14
+ ECFPFingerprint,
15
+ LayeredFingerprint,
16
+ PatternFingerprint,
17
+ RDKitFingerprint,
18
+ TopologicalTorsionFingerprint,
19
+ )
20
+
21
+ from fpembed.compression import (
22
+ compress_fingerprint,
23
+ _is_power_of_two,
24
+ _ALL_METHODS,
25
+ _validate_method_params,
26
+ _DEFAULT_SEED,
27
+ )
28
+ from fpembed.compression_projection import (
29
+ build_rp_matrix,
30
+ build_srht_signs,
31
+ compress_hadamard,
32
+ compress_random_projection,
33
+ )
34
+ from fpembed.hashing import fp_params_hash
35
+ from fpembed.smiles_utils import canonicalize_smiles, parse_smiles
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Sequence
39
+
40
+ CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])
41
+ _EMPTY_CACHE_INFO = CacheInfo(hits=0, misses=0, maxsize=0, currsize=0)
42
+
43
+ _SUPPORTED_FP_TYPES = (
44
+ "ecfp", "atom_pair", "topological_torsion", "rdkit", "layered", "pattern",
45
+ )
46
+
47
+
48
+ def _build_skfp(fp_type: str, fp_size: int, fp_params: dict) -> Any:
49
+ """Instantiate the appropriate skfp fingerprint object."""
50
+ if fp_type == "ecfp":
51
+ return ECFPFingerprint(
52
+ fp_size=fp_size, radius=fp_params.get("radius", 2),
53
+ count=False, n_jobs=1,
54
+ )
55
+ if fp_type == "atom_pair":
56
+ return AtomPairFingerprint(
57
+ fp_size=fp_size, min_distance=fp_params.get("min_distance", 1),
58
+ max_distance=fp_params.get("max_distance", 30),
59
+ count=False, n_jobs=1,
60
+ )
61
+ if fp_type == "topological_torsion":
62
+ return TopologicalTorsionFingerprint(
63
+ fp_size=fp_size,
64
+ torsion_atom_count=fp_params.get("torsion_atom_count", 4),
65
+ count=False, n_jobs=1,
66
+ )
67
+ if fp_type == "rdkit":
68
+ return RDKitFingerprint(
69
+ fp_size=fp_size, min_path=fp_params.get("min_path", 1),
70
+ max_path=fp_params.get("max_path", 7), count=False, n_jobs=1,
71
+ )
72
+ if fp_type == "layered":
73
+ return LayeredFingerprint(
74
+ fp_size=fp_size, min_path=fp_params.get("min_path", 1),
75
+ max_path=fp_params.get("max_path", 7), n_jobs=1,
76
+ )
77
+ return PatternFingerprint(fp_size=fp_size, n_jobs=1)
78
+
79
+
80
+ class EmbeddedFingerprintGenerator:
81
+ """Unified fingerprint generator backed by scikit-fingerprints.
82
+
83
+ Supports six fp_types: ecfp, atom_pair, topological_torsion,
84
+ rdkit, layered, pattern. Compression via log-space weighted mask.
85
+ """
86
+
87
+ __slots__ = (
88
+ "_fp_type", "_fp_size", "_compression", "_fp_params",
89
+ "_method", "_method_params", "_projection_cache",
90
+ "_skfp_fp", "_params_hash", "_cache_size", "_cached_compute",
91
+ )
92
+
93
+ def __init__(
94
+ self,
95
+ fp_type: str,
96
+ fp_size: int,
97
+ compression: int | None,
98
+ fp_params: dict | None = None,
99
+ cache_size: int | None = None,
100
+ method: str = "geometric",
101
+ method_params: dict | None = None,
102
+ ) -> None:
103
+ if fp_params is None:
104
+ fp_params = {}
105
+ if method_params is None:
106
+ method_params = {}
107
+ if fp_type not in _SUPPORTED_FP_TYPES:
108
+ raise ValueError(
109
+ f"Unsupported fp_type '{fp_type}'. "
110
+ f"Supported: {', '.join(_SUPPORTED_FP_TYPES)}"
111
+ )
112
+ if method not in _ALL_METHODS:
113
+ raise ValueError(
114
+ f"Unsupported method '{method}'. "
115
+ f"Supported: {', '.join(sorted(_ALL_METHODS))}"
116
+ )
117
+ _validate_method_params(method, method_params)
118
+
119
+ effective: int = 0 if (compression is None or compression == 0) else compression
120
+ if effective != 0:
121
+ if not _is_power_of_two(effective):
122
+ raise ValueError(f"compression must be a power of 2, got {effective}")
123
+ if effective > fp_size / 2:
124
+ raise ValueError(
125
+ f"compression ({effective}) must be <= fp_size / 2 ({fp_size // 2})"
126
+ )
127
+ if fp_size % effective != 0:
128
+ raise ValueError(
129
+ f"fp_size ({fp_size}) must be evenly divisible by compression ({effective})"
130
+ )
131
+
132
+ if method == "hadamard" and not _is_power_of_two(fp_size):
133
+ raise ValueError("Hadamard method requires fp_size to be a power of 2")
134
+
135
+ self._fp_type = fp_type
136
+ self._fp_size = fp_size
137
+ self._compression = effective
138
+ self._fp_params = dict(fp_params)
139
+ self._method = method
140
+ self._method_params = dict(method_params)
141
+ self._cache_size = cache_size
142
+ self._skfp_fp: Any = _build_skfp(fp_type, fp_size, fp_params)
143
+
144
+ # Precompute projection artifacts for projection methods
145
+ self._projection_cache: Any = None
146
+ if effective != 0 and method == "hadamard":
147
+ seed = self._method_params.get("seed", _DEFAULT_SEED)
148
+ self._projection_cache = build_srht_signs(fp_size, seed)
149
+ elif effective != 0 and method == "random_projection":
150
+ seed = self._method_params.get("seed", _DEFAULT_SEED)
151
+ sparse = self._method_params.get("sparse", False)
152
+ output_dim = fp_size // effective
153
+ self._projection_cache = build_rp_matrix(fp_size, output_dim, seed, sparse)
154
+
155
+ self._params_hash = fp_params_hash(
156
+ fp_type, fp_params, method=method, method_params=method_params,
157
+ )
158
+ if cache_size is not None:
159
+ @functools.lru_cache(maxsize=cache_size)
160
+ def _cached(smiles: str) -> npt.NDArray[np.float64] | None:
161
+ mol = parse_smiles(smiles)
162
+ return None if mol is None else self.GetFingerprintAsNumPy(mol)
163
+ self._cached_compute: Any = _cached
164
+ else:
165
+ self._cached_compute = None
166
+
167
+ @property
168
+ def fp_type(self) -> str:
169
+ """Fingerprint type identifier."""
170
+ return self._fp_type
171
+
172
+ @property
173
+ def fp_size(self) -> int:
174
+ """Fingerprint bit-vector length."""
175
+ return self._fp_size
176
+
177
+ @property
178
+ def compression(self) -> int:
179
+ """Compression factor (0 means no compression)."""
180
+ return self._compression
181
+
182
+ @property
183
+ def fp_params(self) -> dict:
184
+ """Type-specific fingerprint parameters (copy)."""
185
+ return dict(self._fp_params)
186
+
187
+ @property
188
+ def method(self) -> str:
189
+ """Compression method name."""
190
+ return self._method
191
+
192
+ @property
193
+ def method_params(self) -> dict:
194
+ """Method-specific parameters (copy)."""
195
+ return dict(self._method_params)
196
+
197
+ @property
198
+ def params_hash(self) -> str:
199
+ """Read-only 16-char hex hash of fp_type + fp_params."""
200
+ return self._params_hash
201
+
202
+ def _raw_fp(self, mol: Chem.Mol) -> npt.NDArray[np.float64]:
203
+ return self._skfp_fp.transform([mol]).flatten().astype(np.float64)
204
+
205
+ def GetRawFingerprintAsNumPy(self, mol: Chem.Mol) -> npt.NDArray[np.float64]:
206
+ """Return the raw (uncompressed) fingerprint for a single Mol."""
207
+ if mol is None:
208
+ raise ValueError("mol must not be None")
209
+ return self._raw_fp(mol)
210
+
211
+ def GetFingerprintAsNumPy(self, mol: Chem.Mol) -> npt.NDArray[np.float64]:
212
+ """Return the (optionally compressed) fingerprint for a single Mol."""
213
+ if mol is None:
214
+ raise ValueError("mol must not be None")
215
+ raw = self._raw_fp(mol)
216
+ if self._compression == 0:
217
+ return raw
218
+ # Projection methods: use cached artifacts directly
219
+ if self._method == "hadamard":
220
+ return compress_hadamard(raw, self._compression, self._projection_cache).flatten()
221
+ if self._method == "random_projection":
222
+ return compress_random_projection(raw, self._projection_cache).flatten()
223
+ # Block-wise methods: delegate to orchestrator
224
+ return compress_fingerprint(
225
+ raw, self._compression,
226
+ method=self._method, method_params=self._method_params,
227
+ ).flatten()
228
+
229
+ def _out_dim(self) -> int:
230
+ return self._fp_size // self._compression if self._compression > 0 else self._fp_size
231
+
232
+ def GetFingerprintsAsNumPy(
233
+ self, mols: Sequence[Chem.Mol | None], chunk_size: int | None = None,
234
+ ) -> tuple[npt.NDArray[np.float64], list[int]]:
235
+ """Batch fingerprints. Returns (embeddings, invalid_indices)."""
236
+ if chunk_size is not None and chunk_size <= 0:
237
+ raise ValueError("chunk_size must be a positive integer or None")
238
+ total = len(mols)
239
+ invalid: list[int] = []
240
+ valid: list[npt.NDArray[np.float64]] = []
241
+ cs = chunk_size or total
242
+ for start in range(0, total, cs):
243
+ for i in range(start, min(start + cs, total)):
244
+ mol = mols[i]
245
+ if mol is None:
246
+ invalid.append(i)
247
+ continue
248
+ try:
249
+ valid.append(self.GetFingerprintAsNumPy(mol))
250
+ except Exception:
251
+ invalid.append(i)
252
+ if valid:
253
+ return np.vstack(valid), invalid
254
+ return np.empty((0, self._out_dim()), dtype=np.float64), invalid
255
+
256
+ def GetFingerprintFromSmiles(
257
+ self, smiles: str, validate: bool = True, canonicalize: bool = True,
258
+ ) -> npt.NDArray[np.float64] | None:
259
+ """Generate a fingerprint from a SMILES string."""
260
+ if validate:
261
+ if canonicalize:
262
+ smiles = canonicalize_smiles(smiles) or ""
263
+ if not smiles:
264
+ return None
265
+ elif parse_smiles(smiles) is None:
266
+ return None
267
+ if self._cached_compute is not None:
268
+ return self._cached_compute(smiles)
269
+ mol = parse_smiles(smiles)
270
+ return None if mol is None else self.GetFingerprintAsNumPy(mol)
271
+
272
+ def GetFingerprintsFromSmiles(
273
+ self, smiles_list: Sequence[str],
274
+ validate: bool = True, canonicalize: bool = True,
275
+ ) -> tuple[npt.NDArray[np.float64], list[int]]:
276
+ """Batch fingerprints from SMILES. Returns (embeddings, invalid_indices)."""
277
+ invalid: list[int] = []
278
+ valid: list[npt.NDArray[np.float64]] = []
279
+ for i, smi in enumerate(smiles_list):
280
+ try:
281
+ r = self.GetFingerprintFromSmiles(smi, validate=validate, canonicalize=canonicalize)
282
+ except Exception:
283
+ r = None
284
+ if r is None:
285
+ invalid.append(i)
286
+ else:
287
+ valid.append(r)
288
+ if valid:
289
+ return np.vstack(valid), invalid
290
+ return np.empty((0, self._out_dim()), dtype=np.float64), invalid
291
+
292
+ def GetFingerprintFromSelfies(
293
+ self, selfies_str: str, validate: bool = True, canonicalize: bool = True,
294
+ ) -> npt.NDArray[np.float64] | None:
295
+ """Generate a fingerprint from a SELFIES string."""
296
+ import selfies as sf # type: ignore[import-untyped]
297
+ try:
298
+ smiles = sf.decoder(selfies_str)
299
+ except Exception:
300
+ return None
301
+ if smiles is None:
302
+ return None
303
+ return self.GetFingerprintFromSmiles(smiles, validate=validate, canonicalize=canonicalize)
304
+
305
+ def GetFingerprintsFromSelfies(
306
+ self, selfies_list: Sequence[str],
307
+ validate: bool = True, canonicalize: bool = True,
308
+ ) -> tuple[npt.NDArray[np.float64], list[int]]:
309
+ """Batch fingerprints from SELFIES. Returns (embeddings, invalid_indices)."""
310
+ invalid: list[int] = []
311
+ valid: list[npt.NDArray[np.float64]] = []
312
+ for i, sel in enumerate(selfies_list):
313
+ try:
314
+ r = self.GetFingerprintFromSelfies(sel, validate=validate, canonicalize=canonicalize)
315
+ except Exception:
316
+ r = None
317
+ if r is None:
318
+ invalid.append(i)
319
+ else:
320
+ valid.append(r)
321
+ if valid:
322
+ return np.vstack(valid), invalid
323
+ return np.empty((0, self._out_dim()), dtype=np.float64), invalid
324
+
325
+ def clear_cache(self) -> None:
326
+ """Clear the LRU cache. No-op if caching is disabled."""
327
+ if self._cached_compute is not None:
328
+ self._cached_compute.cache_clear()
329
+
330
+ def cache_info(self) -> CacheInfo:
331
+ """Return cache statistics (all zeros when caching is disabled)."""
332
+ if self._cached_compute is not None:
333
+ raw = self._cached_compute.cache_info()
334
+ return CacheInfo(hits=raw.hits, misses=raw.misses, maxsize=raw.maxsize, currsize=raw.currsize)
335
+ return _EMPTY_CACHE_INFO
336
+
337
+ def __repr__(self) -> str:
338
+ comp = self._compression if self._compression > 0 else None
339
+ base = (
340
+ f"EmbeddedFingerprintGenerator(fp_type='{self._fp_type}', "
341
+ f"fp_size={self._fp_size}, compression={comp}, "
342
+ f"fp_params={self._fp_params}"
343
+ )
344
+ if self._method != "geometric" or self._method_params:
345
+ base += f", method='{self._method}'"
346
+ base += f", method_params={self._method_params}"
347
+ base += ")"
348
+ return base
fpembed/hashing.py ADDED
@@ -0,0 +1,48 @@
1
+ """Fingerprint parameter hashing for stable cache keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+
9
+ def fp_params_hash(
10
+ fp_type: str,
11
+ fp_params: dict,
12
+ *,
13
+ method: str | None = None,
14
+ method_params: dict | None = None,
15
+ ) -> str:
16
+ """Compute a 16-char SHA-256 prefix of sorted JSON of {fp_type, **fp_params}.
17
+
18
+ Bool values in *fp_params* are normalized to Python ``bool`` before
19
+ serialization to prevent ``True``/``1`` collisions.
20
+
21
+ Args:
22
+ fp_type: Fingerprint type identifier (e.g. ``"ecfp"``).
23
+ fp_params: Type-specific fingerprint parameters.
24
+ method: Compression method name (optional).
25
+ method_params: Method-specific parameters (optional).
26
+
27
+ Returns:
28
+ A 16-character hexadecimal string.
29
+ """
30
+ normalized = {
31
+ k: int(v) if isinstance(v, bool) else v
32
+ for k, v in fp_params.items()
33
+ }
34
+ payload: dict = {"fp_type": fp_type, **normalized}
35
+
36
+ effective_params = method_params or {}
37
+ if method is not None and not (method == "geometric" and not effective_params):
38
+ norm_mp = {
39
+ k: int(v) if isinstance(v, bool) else v
40
+ for k, v in effective_params.items()
41
+ }
42
+ payload["method"] = method
43
+ if norm_mp:
44
+ payload["method_params"] = norm_mp
45
+
46
+ return hashlib.sha256(
47
+ json.dumps(payload, sort_keys=True).encode()
48
+ ).hexdigest()[:16]
fpembed/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,63 @@
1
+ """SMILES/molecule helper functions for the FPembed package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rdkit import Chem
6
+ from rdkit import rdBase
7
+
8
+ # Suppress RDKit warnings for invalid SMILES (handled via return values)
9
+ rdBase.DisableLog("rdApp.error")
10
+ rdBase.DisableLog("rdApp.*")
11
+
12
+
13
+ def parse_smiles(smiles: str) -> Chem.Mol | None:
14
+ """Convert a SMILES string to an RDKit Mol object.
15
+
16
+ Returns ``None`` for any input that RDKit cannot parse, without raising
17
+ an exception.
18
+
19
+ Args:
20
+ smiles: A SMILES string representing a molecule.
21
+
22
+ Returns:
23
+ An RDKit Mol object, or ``None`` if the input is invalid.
24
+
25
+ Examples:
26
+ >>> parse_smiles("CCO") # ethanol
27
+ <rdkit.Chem.rdchem.Mol object at ...>
28
+ >>> parse_smiles("not_a_molecule") is None
29
+ True
30
+ """
31
+ try:
32
+ return Chem.MolFromSmiles(smiles)
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def canonicalize_smiles(smiles: str) -> str | None:
38
+ """Canonicalize a SMILES string.
39
+
40
+ Parses the input with RDKit and returns the canonical SMILES form.
41
+ Returns ``None`` for any input that cannot be parsed or canonicalized.
42
+
43
+ Args:
44
+ smiles: A SMILES string to canonicalize.
45
+
46
+ Returns:
47
+ The canonical SMILES string, or ``None`` if the input is invalid.
48
+
49
+ Examples:
50
+ >>> canonicalize_smiles("OCC")
51
+ 'CCO'
52
+ >>> canonicalize_smiles("c1ccccc1")
53
+ 'c1ccccc1'
54
+ >>> canonicalize_smiles("invalid") is None
55
+ True
56
+ """
57
+ try:
58
+ mol = Chem.MolFromSmiles(smiles)
59
+ if mol is None:
60
+ return None
61
+ return Chem.MolToSmiles(mol)
62
+ except Exception:
63
+ return None
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: fpembed
3
+ Version: 0.1.0
4
+ Summary: Generalized fingerprint embedding library
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ License-File: LICENSE
8
+ Requires-Dist: rdkit==2026.3.1
9
+ Requires-Dist: numpy==2.2.6
10
+ Requires-Dist: selfies==1.0.2
11
+ Requires-Dist: scikit-fingerprints==2.0.0
12
+ Provides-Extra: app
13
+ Requires-Dist: nicegui>=3.8.0; extra == "app"
14
+ Requires-Dist: optuna>=3.1.0; extra == "app"
15
+ Requires-Dist: pyyaml>=6.0; extra == "app"
16
+ Requires-Dist: altair>=5.0; extra == "app"
17
+ Requires-Dist: pandas>=1.5.2; extra == "app"
18
+ Requires-Dist: scikit-learn>=1.2.0; extra == "app"
19
+ Requires-Dist: tqdm>=4.64.1; extra == "app"
20
+ Dynamic: license-file
@@ -0,0 +1,13 @@
1
+ fpembed/__init__.py,sha256=aicXqkvkMOwA6sU1nnn-7DFBqF_cB27nk4G2CAmqMao,1030
2
+ fpembed/compression.py,sha256=V3WkgeYblVpOMcgJIma1gieE8DFtfIIaTe6QmUf9yQE,4751
3
+ fpembed/compression_blockwise.py,sha256=KmGVsgbaxaT-77pyURGAn3Gdqq_0G81vpgwpNFpXz6U,3134
4
+ fpembed/compression_projection.py,sha256=zx6U-gqqWje4OtXQMovXA8PrBYfZezm0q4PbYycbmxQ,4780
5
+ fpembed/generator.py,sha256=20PikSUcoDVtb0SIWj7qu78yT4zqR2JIf-YSg58LeAs,13275
6
+ fpembed/hashing.py,sha256=l2glvBBgxKNzRRNXlc_6-OdMW1126rvkw_8GFVlih5Y,1424
7
+ fpembed/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
+ fpembed/smiles_utils.py,sha256=MnvnPRexwhA_-JLKLgXRXk72Cibf3LHaax02jrH3lyg,1670
9
+ fpembed-0.1.0.dist-info/licenses/LICENSE,sha256=_M7NYM9qJY7gueiMrR51K-1DQ4VG6x06T1uR9nEVmCE,1094
10
+ fpembed-0.1.0.dist-info/METADATA,sha256=1Kd0_SOiMAk_cFVH2B6ZZxWCt1dtVf68ygFpU0Ro7rE,681
11
+ fpembed-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ fpembed-0.1.0.dist-info/top_level.txt,sha256=V7Uu_OVnu-a4YO2BjhhuiVz4bJ3Q9_zicZAgpCqhf4c,8
13
+ fpembed-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Markovskii
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.
@@ -0,0 +1 @@
1
+ fpembed