PFASGroups 3.2.2__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.
@@ -0,0 +1,439 @@
1
+ """
2
+ PFAS Molecule Prioritization Module
3
+ ====================================
4
+
5
+ This module provides functions to prioritize PFAS molecules based on:
6
+ 1. Distributional similarity to a reference list (using fingerprint KL divergence)
7
+ 2. Fluorinated component characteristics (total fluorine content and component size distribution)
8
+
9
+ Author: HalogenGroups
10
+ Version: 2.2.4
11
+ """
12
+
13
+ from typing import Union, List, Tuple, Optional, Dict
14
+ import numpy as np
15
+ from rdkit import Chem
16
+
17
+ from .parser import parse_smiles, parse_mols
18
+ from .PFASEmbeddings import PFASEmbeddingSet
19
+
20
+
21
+ def prioritise_molecules(
22
+ molecules: Union[List[str], List[Chem.Mol], PFASEmbeddingSet],
23
+ reference: Optional[Union[List[str], List[Chem.Mol], PFASEmbeddingSet]] = None,
24
+ group_selection: str = 'all',
25
+ count_mode: str = 'max_component',
26
+ halogens: Union[str, List[str]] = 'F',
27
+ saturation: Optional[str] = None,
28
+ a: float = 1.0,
29
+ b: float = 1.0,
30
+ percentile: float = 90.0,
31
+ return_scores: bool = True,
32
+ ascending: bool = False,
33
+ progress: bool = False,
34
+ ) -> Union[PFASEmbeddingSet, Tuple[PFASEmbeddingSet, np.ndarray]]:
35
+ """
36
+ Prioritize PFAS molecules based on similarity to a reference or intrinsic properties.
37
+
38
+ Parameters
39
+ ----------
40
+ molecules : list of str, list of rdkit.Chem.Mol, or PFASEmbeddingSet
41
+ Molecules to prioritize. Can be:
42
+ - List of SMILES strings
43
+ - List of RDKit molecule objects
44
+ - PFASEmbeddingSet object (pre-computed results)
45
+
46
+ reference : list of str, list of rdkit.Chem.Mol, PFASEmbeddingSet, or None
47
+ Reference molecules for similarity comparison. If provided, molecules are
48
+ prioritized by distributional similarity (lower KL divergence = higher priority).
49
+ If None, prioritization is based on intrinsic fluorinated component properties.
50
+
51
+ group_selection : str, default 'all'
52
+ PFAS group selection for fingerprint generation when using reference:
53
+ - 'all': All 115 groups (OECD + generic)
54
+ - 'oecd': OECD-defined groups (1-28)
55
+ - 'generic': Generic functional groups (29-115)
56
+ - 'telomers': Telomer-related groups
57
+ - 'generic+telomers': Combined selection
58
+
59
+ count_mode : str, default 'binary'
60
+ Fingerprint encoding mode when using reference:
61
+ - 'binary': 1 if present, 0 if absent
62
+ - 'count': Number of matches
63
+ - 'max_component': Maximum component size
64
+
65
+ halogens : str or list of str, default 'F'
66
+ Which halogen(s) to include when generating fingerprints for reference
67
+ comparison. Passed directly to ``PFASEmbeddingSet.to_fingerprint``.
68
+
69
+ saturation : str or None, default None
70
+ Saturation filter applied to component SMARTS when generating
71
+ fingerprints. ``None`` (default) includes both per- and
72
+ polyfluorinated / polyhalogenated components, which gives the broadest
73
+ coverage and avoids zero scores for candidates that only contain
74
+ polyfluorinated chains. Pass ``'per'`` or ``'poly'`` to restrict.
75
+
76
+ a : float, default 1.0
77
+ Weight for total fluorinated component size (sum of all component sizes).
78
+ Used when reference is None. Higher values prioritize molecules with more
79
+ total fluorination.
80
+
81
+ b : float, default 1.0
82
+ Weight for component size percentile. Used when reference is None.
83
+ Higher values prioritize molecules with larger individual fluorinated components.
84
+
85
+ percentile : float, default 90.0
86
+ Percentile value (0-100) for component size distribution. Used when reference
87
+ is None. Common values:
88
+ - 90.0: Focus on largest 10% of components
89
+ - 75.0: Focus on largest 25% of components
90
+ - 50.0: Median component size
91
+
92
+ return_scores : bool, default True
93
+ If True, returns tuple of (prioritized_results, scores).
94
+ If False, returns only prioritized_results.
95
+
96
+ ascending : bool, default False
97
+ Sort order. If False (default), highest priority first.
98
+ If True, lowest priority first.
99
+
100
+ Returns
101
+ -------
102
+ PFASEmbeddingSet or tuple
103
+ If return_scores=True: (prioritized_results, scores)
104
+ If return_scores=False: prioritized_results only
105
+
106
+ prioritized_results : PFASEmbeddingSet
107
+ Molecules sorted by priority
108
+ scores : np.ndarray
109
+ Priority scores for each molecule
110
+
111
+ Examples
112
+ --------
113
+ # Priority by similarity to reference list
114
+ >>> from PFASGroups import prioritise_molecules
115
+ >>> inventory = ["FC(F)(F)C(F)(F)C(=O)O", "FC(F)(F)C(F)(F)C(F)(F)C(=O)O"]
116
+ >>> reference = ["FC(F)(F)C(F)(F)C(=O)O"] # Known priority compounds
117
+ >>> results, scores = prioritise_molecules(inventory, reference=reference)
118
+ >>> print(f"Most similar: {results[0]['smiles']}")
119
+
120
+ # Priority by fluorination characteristics
121
+ >>> results, scores = prioritise_molecules(
122
+ ... inventory,
123
+ ... a=1.0, # Weight for total fluorination
124
+ ... b=2.0, # Weight for largest components
125
+ ... percentile=90
126
+ ... )
127
+ >>> print(f"Highest priority: {results[0]['smiles']}")
128
+
129
+ # Emphasize total fluorine content
130
+ >>> results = prioritise_molecules(inventory, a=2.0, b=0.5, return_scores=False)
131
+
132
+ # Focus on molecules with longest chains
133
+ >>> results = prioritise_molecules(inventory, a=0.5, b=2.0, percentile=95)
134
+
135
+ Notes
136
+ -----
137
+ **Reference-based prioritization:**
138
+
139
+ Uses cosine similarity between each candidate's fingerprint vector and the
140
+ mean fingerprint of the reference set:
141
+
142
+ score_i = (fp_i · mean_ref) / (||fp_i|| × ||mean_ref||)
143
+
144
+ - Higher cosine similarity = more similar group profile to reference = higher priority
145
+ - Molecules that activate the same PFAS groups as the reference rank highest
146
+ - Molecules with no group matches receive score 0
147
+
148
+ **Intrinsic prioritization (no reference):**
149
+
150
+ Score = a × Σ(component_sizes) + b × percentile(component_sizes, p)
151
+
152
+ Where:
153
+ - Σ(component_sizes): Total number of fluorinated carbons across all components
154
+ - percentile(component_sizes, p): Size of fluorinated components at pth percentile
155
+
156
+ This approach prioritizes molecules based on:
157
+ - Total fluorination burden (a parameter)
158
+ - Presence of long perfluorinated chains (b and percentile parameters)
159
+
160
+ **Tuning guidelines:**
161
+
162
+ For environmental persistence concerns:
163
+ - High b, high percentile (e.g., b=2.0, p=90): Long-chain compounds
164
+
165
+ For bioaccumulation potential:
166
+ - Balanced a and b (e.g., a=1.0, b=1.0): Both total and chain length
167
+
168
+ For screening priority:
169
+ - High a, moderate b (e.g., a=2.0, b=1.0, p=75): Total fluorine load
170
+
171
+ See Also
172
+ --------
173
+ PFASEmbeddingSet.to_array : Convert results to fingerprints
174
+ PFASEmbedding.compare_kld : Compare embedding distributions
175
+ """
176
+ # Step 1: Convert input to PFASEmbeddingSet if needed
177
+ if isinstance(molecules, PFASEmbeddingSet):
178
+ results = molecules
179
+ elif isinstance(molecules, list):
180
+ if len(molecules) == 0:
181
+ raise ValueError("molecules list is empty")
182
+
183
+ # Check if list contains SMILES strings or Mol objects
184
+ if isinstance(molecules[0], str):
185
+ results = parse_smiles(molecules, progress=progress)
186
+ elif isinstance(molecules[0], Chem.Mol):
187
+ results = parse_mols(molecules, progress=progress)
188
+ else:
189
+ raise TypeError(
190
+ f"molecules must be list of SMILES strings or RDKit Mol objects, "
191
+ f"got {type(molecules[0])}"
192
+ )
193
+ else:
194
+ raise TypeError(
195
+ f"molecules must be list of SMILES/Mol objects or PFASEmbeddingSet, "
196
+ f"got {type(molecules)}"
197
+ )
198
+
199
+ # Step 2: Compute priority scores
200
+ if reference is not None:
201
+ # Reference-based: cosine similarity of embedding vectors
202
+ scores = _prioritise_by_reference(
203
+ results, reference, group_selection, count_mode, halogens, saturation,
204
+ progress=progress,
205
+ )
206
+ else:
207
+ # Intrinsic: fluorinated component characteristics
208
+ scores = _prioritise_by_components(results, a, b, percentile)
209
+
210
+ # Step 3: Sort by priority
211
+ sorted_indices = np.argsort(scores)
212
+ if not ascending:
213
+ sorted_indices = sorted_indices[::-1]
214
+
215
+ # Create prioritized results
216
+ prioritized_results = PFASEmbeddingSet([results[i] for i in sorted_indices])
217
+
218
+ if return_scores:
219
+ return prioritized_results, scores[sorted_indices]
220
+ else:
221
+ return prioritized_results
222
+
223
+
224
+ def _prioritise_by_reference(
225
+ results: PFASEmbeddingSet,
226
+ reference: Union[List[str], List[Chem.Mol], PFASEmbeddingSet],
227
+ group_selection: str,
228
+ count_mode: str,
229
+ halogens: Union[str, List[str]] = 'F',
230
+ saturation: Optional[str] = None,
231
+ progress: bool = False,
232
+ ) -> np.ndarray:
233
+ """Prioritize by cosine similarity to the mean embedding of a reference set.
234
+
235
+ Parameters
236
+ ----------
237
+ results : PFASEmbeddingSet
238
+ Molecules to prioritize.
239
+ reference : list of str, list of rdkit.Chem.Mol, or PFASEmbeddingSet
240
+ Reference molecules.
241
+ group_selection : str
242
+ PFAS group selection ('all', 'oecd', 'generic', …).
243
+ count_mode : str
244
+ Count mode for the embedding ('binary', 'count', 'max_component', …).
245
+ halogens : str or list of str, default 'F'
246
+ Passed to parse_smiles / parse_mols for the reference.
247
+ saturation : str or None, default None
248
+ Passed to parse_smiles / parse_mols for the reference.
249
+ progress : bool, default False
250
+
251
+ Returns
252
+ -------
253
+ np.ndarray
254
+ Priority scores in [0, 1] (higher = more similar to reference).
255
+ """
256
+ # Convert reference to PFASEmbeddingSet if needed
257
+ if isinstance(reference, PFASEmbeddingSet):
258
+ ref_results = reference
259
+ elif isinstance(reference, list):
260
+ if len(reference) == 0:
261
+ raise ValueError("reference list is empty")
262
+ if isinstance(reference[0], str):
263
+ ref_results = parse_smiles(reference, halogens=halogens,
264
+ saturation=saturation, progress=progress)
265
+ elif isinstance(reference[0], Chem.Mol):
266
+ ref_results = parse_mols(reference, halogens=halogens,
267
+ saturation=saturation, progress=progress)
268
+ else:
269
+ raise TypeError(
270
+ f"reference must be list of SMILES strings or RDKit Mol objects, "
271
+ f"got {type(reference[0])}"
272
+ )
273
+ else:
274
+ raise TypeError(
275
+ f"reference must be list of SMILES/Mol objects or PFASEmbeddingSet, "
276
+ f"got {type(reference)}"
277
+ )
278
+
279
+ # Build mean reference vector: shape (n_cols,)
280
+ ref_mat = ref_results.to_array(
281
+ group_selection=group_selection,
282
+ component_metrics=[count_mode],
283
+ )
284
+ ref_vec = np.mean(np.asarray(ref_mat), axis=0).astype(float)
285
+ ref_norm = float(np.linalg.norm(ref_vec))
286
+
287
+ if ref_norm == 0.0:
288
+ import warnings
289
+ warnings.warn(
290
+ "Reference embedding is all-zero: no molecules in the reference "
291
+ "matched any group for the given group_selection / count_mode settings. "
292
+ "Returning zero scores.",
293
+ UserWarning,
294
+ stacklevel=3,
295
+ )
296
+ return np.zeros(len(results), dtype=float)
297
+
298
+ # Build candidate matrix: shape (n_candidates, n_cols)
299
+ cand_mat = np.asarray(results.to_array(
300
+ group_selection=group_selection,
301
+ component_metrics=[count_mode],
302
+ )).astype(float)
303
+
304
+ # Cosine similarity: score_i = (cand_i · ref_vec) / (||cand_i|| × ||ref_vec||)
305
+ dot_products = cand_mat @ ref_vec # (n_candidates,)
306
+ cand_norms = np.linalg.norm(cand_mat, axis=1) # (n_candidates,)
307
+
308
+ scores = np.zeros(len(dot_products), dtype=float)
309
+ nonzero = cand_norms > 0
310
+ scores[nonzero] = dot_products[nonzero] / (cand_norms[nonzero] * ref_norm)
311
+
312
+ return np.clip(scores, 0.0, 1.0)
313
+
314
+
315
+ def _prioritise_by_components(
316
+ results: PFASEmbeddingSet,
317
+ a: float,
318
+ b: float,
319
+ percentile: float
320
+ ) -> np.ndarray:
321
+ """
322
+ Prioritize by fluorinated component characteristics.
323
+
324
+ Score = a × sum(component_sizes) + b × percentile(component_sizes, p)
325
+
326
+ Parameters
327
+ ----------
328
+ results : PFASEmbeddingSet
329
+ Molecules to prioritize
330
+ a : float
331
+ Weight for total component size
332
+ b : float
333
+ Weight for component size percentile
334
+ percentile : float
335
+ Percentile value (0-100)
336
+
337
+ Returns
338
+ -------
339
+ np.ndarray
340
+ Priority scores (higher = higher priority)
341
+ """
342
+ scores = []
343
+
344
+ for mol_result in results:
345
+ # Extract all fluorinated component sizes from all matched groups
346
+ all_component_sizes = []
347
+
348
+ for match in mol_result['matches']:
349
+ if match.get('type') == 'HalogenGroup' and 'components_sizes' in match:
350
+ component_sizes = match['components_sizes']
351
+ if isinstance(component_sizes, list) and len(component_sizes) > 0:
352
+ all_component_sizes.extend(component_sizes)
353
+
354
+ if len(all_component_sizes) == 0:
355
+ # No fluorinated components found
356
+ score = 0.0
357
+ else:
358
+ all_component_sizes = np.array(all_component_sizes)
359
+
360
+ # Compute score components
361
+ total_size = float(np.sum(all_component_sizes))
362
+ percentile_size = float(np.percentile(all_component_sizes, percentile))
363
+
364
+ # Combined score
365
+ score = a * total_size + b * percentile_size
366
+
367
+ scores.append(score)
368
+
369
+ return np.array(scores)
370
+
371
+
372
+ def get_priority_statistics(
373
+ results: PFASEmbeddingSet,
374
+ scores: np.ndarray,
375
+ top_n: int = 10
376
+ ) -> Dict:
377
+ """
378
+ Get statistics about prioritization results.
379
+
380
+ Parameters
381
+ ----------
382
+ results : PFASEmbeddingSet
383
+ Prioritized molecules
384
+ scores : np.ndarray
385
+ Priority scores
386
+ top_n : int, default 10
387
+ Number of top molecules to analyze
388
+
389
+ Returns
390
+ -------
391
+ dict
392
+ Statistics including:
393
+ - 'n_molecules': Total number of molecules
394
+ - 'score_mean': Mean priority score
395
+ - 'score_std': Standard deviation of scores
396
+ - 'score_min': Minimum score
397
+ - 'score_max': Maximum score
398
+ - 'top_n_smiles': SMILES of top N molecules
399
+ - 'top_n_scores': Scores of top N molecules
400
+ - 'top_n_groups': Most common groups in top N molecules
401
+
402
+ Examples
403
+ --------
404
+ >>> results, scores = prioritise_molecules(smiles_list, reference=ref_list)
405
+ >>> stats = get_priority_statistics(results, scores, top_n=10)
406
+ >>> print(f"Mean score: {stats['score_mean']:.3f}")
407
+ >>> print(f"Top molecule: {stats['top_n_smiles'][0]}")
408
+ """
409
+ stats = {
410
+ 'n_molecules': len(results),
411
+ 'score_mean': float(np.mean(scores)),
412
+ 'score_std': float(np.std(scores)),
413
+ 'score_min': float(np.min(scores)),
414
+ 'score_max': float(np.max(scores)),
415
+ 'score_median': float(np.median(scores)),
416
+ }
417
+
418
+ # Top N molecules
419
+ n = min(top_n, len(results))
420
+ stats['top_n_smiles'] = [results[i]['smiles'] for i in range(n)]
421
+ stats['top_n_scores'] = scores[:n].tolist()
422
+
423
+ # Most common groups in top N
424
+ group_counts = {}
425
+ for i in range(n):
426
+ for match in results[i]['matches']:
427
+ if match.get('type') == 'HalogenGroup':
428
+ group_name = match.get('group_name', 'Unknown')
429
+ group_counts[group_name] = group_counts.get(group_name, 0) + 1
430
+
431
+ # Sort by frequency
432
+ sorted_groups = sorted(group_counts.items(), key=lambda x: x[1], reverse=True)
433
+ stats['top_n_groups'] = sorted_groups[:10] # Top 10 most common groups
434
+
435
+ return stats
436
+
437
+
438
+ # Alias for American spelling
439
+ prioritize_molecules = prioritise_molecules