Source code for shepherd_score.pharm_utils.pharmacophore

"""
Generate pharmacophores from a RDKit conformer.

Parts of code adapted from Francois Berenger / Tsuda Lab and RDKit.

References:

- Tsuda Lab: https://github.com/tsudalab/ACP4/blob/master/bin/acp4_ph4.py
  (From https://doi.org/10.1021/acs.jcim.2c01623)
- RDKit: https://github.com/rdkit/rdkit/blob/master/rdkit/Chem/Features/FeatDirUtilsRD.py
- RDKit: https://github.com/rdkit/rdkit/blob/master/rdkit/Chem/Features/ShowFeats.py
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Iterable, List, Optional, Tuple, Dict, Union

import numpy as np
from scipy.spatial import distance, Delaunay

import rdkit
from rdkit import Chem
from rdkit.Chem import AllChem

# pharmacophores
from shepherd_score.pharm_utils.pharmvec import GetDonorFeatVects, GetAcceptorFeatVects, GetAromaticFeatVects, GetHalogenFeatVects
from shepherd_score.score.constants import P_TYPES

PT = Chem.GetPeriodicTable()

feature_colors = {
  'Donor': (0, 1, 1),
  'Acceptor': (1, 0, 1),
  'NegIonizable': (1, 0, 0),
  'Anion': (1,0,0),
  'PosIonizable': (0, 0, 1),
  'Cation': (0,0,1),
  'ZnBinder': (1, .5, .5),
  'Zn': (1, .5, .5),
  'Aromatic': (1, .8, .2),
  'LumpedHydrophobe': (.5, .25, 0),
  'Hydrophobe': (.5, .25, 0),
  'Halogen': (.13, .55, .13),
  'Dummy': (0., .4, .55)
}

# Below is used to get hydrophobic groups
#### From https://github.com/tsudalab/ACP4/blob/master/bin/acp4_ph4.py ####
#### Credit to Francois Berenger and Tsuda Lab ####
#### https://doi.org/10.1021/acs.jcim.2c01623 ####

# These are the same as Pharmer / Pmapper
__hydrophobic_smarts = [
    "a1aaaaa1",
    "a1aaaa1",
    # branched terminals as one point
    "[$([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])&!$(**[CH3X4,CH2X3,CH1X2,F,Cl,Br,I])]",
    "[$(*([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])[CH3X4,CH2X3,CH1X2,F,Cl,Br,I])&!$(*([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])[CH3X4,CH2X3,CH1X2,F,Cl,Br,I])]([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])[CH3X4,CH2X3,CH1X2,F,Cl,Br,I]",
    "*([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])([CH3X4,CH2X3,CH1X2,F,Cl,Br,I])[CH3X4,CH2X3,CH1X2,F,Cl,Br,I]",
    # simple rings only; need to combine points to get good results for 3d structures
    "[C&r3]1~[C&r3]~[C&r3]1",
    "[C&r4]1~[C&r4]~[C&r4]~[C&r4]1",
    "[C&r5]1~[C&r5]~[C&r5]~[C&r5]~[C&r5]1",
    "[C&r6]1~[C&r6]~[C&r6]~[C&r6]~[C&r6]~[C&r6]1",
    "[C&r7]1~[C&r7]~[C&r7]~[C&r7]~[C&r7]~[C&r7]~[C&r7]1",
    "[C&r8]1~[C&r8]~[C&r8]~[C&r8]~[C&r8]~[C&r8]~[C&r8]~[C&r8]1",
    # aliphatic chains
    "[CH2X4,CH1X3,CH0X2]~[CH3X4,CH2X3,CH1X2,F,Cl,Br,I]",
    "[$([CH2X4,CH1X3,CH0X2]~[$([!#1]);!$([CH2X4,CH1X3,CH0X2])])]~[CH2X4,CH1X3,CH0X2]~[CH2X4,CH1X3,CH0X2]",
    "[$([CH2X4,CH1X3,CH0X2]~[CH2X4,CH1X3,CH0X2]~[$([CH2X4,CH1X3,CH0X2]~[$([!#1]);!$([CH2X4,CH1X3,CH0X2])])])]~[CH2X4,CH1X3,CH0X2]~[CH2X4,CH1X3,CH0X2]~[CH2X4,CH1X3,CH0X2]",
    # sulfur (apparently)
    "[$([S]~[#6])&!$(S~[!#6])]"
]

[docs] def pattern_of_smarts(s): return Chem.MolFromSmarts(s)
__hydrophobic_patterns = list(map(pattern_of_smarts, __hydrophobic_smarts)) # geometric center of a matched pattern def __average_match(conf, matched_pattern): avg_x = 0.0 avg_y = 0.0 avg_z = 0.0 count = float(len(matched_pattern)) for i in matched_pattern: xyz = conf.GetAtomPosition(i) avg_x += xyz.x avg_y += xyz.y avg_z += xyz.z center = (avg_x / count, avg_y / count, avg_z / count) return center def __find_matches(mol, patterns, return_atom_ids: bool = False): res = [] conf = mol.GetConformer() for pat in patterns: # get all matches for that pattern matched = mol.GetSubstructMatches(pat) for m in matched: # get the center of each matched group avg = __average_match(conf, m) if return_atom_ids: # Derive aromaticity from the matched atoms themselves rather than the # SMARTS text, so it stays correct if __hydrophobic_smarts is edited. is_aromatic = all(mol.GetAtomWithIdx(i).GetIsAromatic() for i in m) res.append((avg, set(m), is_aromatic)) else: res.append(avg) return res def __average(vecs): sum_x = 0.0 sum_y = 0.0 sum_z = 0.0 n = float(len(vecs)) for (x, y, z) in vecs: sum_x += x sum_y += y sum_z += z return (sum_x / n, sum_y / n, sum_z / n) def _rdkit_point3d_to_tuple(point: Chem.Geometry.Point3D): """ Convert an rdkit Point3D to a tuple. For reasons I can not explain, it's 1000x faster to convert this way instead of calling tuple(point) def pt_to_tuple(pt): return (pt.x, pt.y, pt.z) %timeit tuple(pt) %timeit pt_to_tuple(pt) Gives: 527 μs ± 16.1 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each) 252 ns ± 1.77 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each) """ return (point.x, point.y, point.z) def _copy_point3d(point: Chem.Geometry.Point3D) -> Chem.Geometry.Point3D: """ Independent copy of a Point3D, ~6x faster than copy.deepcopy(point) since it skips the generic (memo dict, __reduce_ex__ lookup, ...) deepcopy machinery for what is just 3 floats. """ return Chem.rdGeometry.Point3D(point.x, point.y, point.z)
[docs] def find_hydrophobes(mol: rdkit.Chem.rdchem.Mol, cluster_hydrophobic: bool = True, return_atom_ids: bool = False): """ Find hydrophobes and cluster them. Arguments --------- mol : rdkit Mol object with a conformer. cluster_hydrophobic : bool (default=True) to cluster hydrophobic atoms if they fall within 2A. return_atom_ids : bool (default=False) When True, returns a list of ``(center_tuple, aromatic_atom_ids_set)`` pairs instead of plain center tuples. ``aromatic_atom_ids_set`` is the union of atom indices from aromatic-ring matches that ended up in the cluster; it is an empty set for clusters composed entirely of non-aromatic matches. Returns ------- list of tuples containing coordinates for the locations for each hydrophobe, or (when ``return_atom_ids=True``) a list of ``(center, set[int])`` pairs. """ all_hydrophobes = __find_matches(mol, __hydrophobic_patterns, return_atom_ids=return_atom_ids) if not cluster_hydrophobic: return all_hydrophobes # Extract centers regardless of return_atom_ids; when True each entry is # (center, atom_ids, is_aromatic), otherwise each entry is already the center tuple. centers = [h[0] for h in all_hydrophobes] if return_atom_ids else all_hydrophobes n = len(all_hydrophobes) idx2cluster = list(range(n)) if n > 1: # Precompute all pairwise distances in one vectorized call within_cutoff = distance.squareform(distance.pdist(np.asarray(centers))) <= 2.0 for i in range(n): cluster_id = idx2cluster[i] for j in range(i + 1, n): if within_cutoff[i, j]: idx2cluster[j] = cluster_id grouped = [] for cid in set(idx2cluster): group_centers = [] aromatic_ids = set() for i, h in enumerate(all_hydrophobes): if idx2cluster[i] != cid: continue group_centers.append(centers[i]) if return_atom_ids and h[2]: # h[2] is is_aromatic aromatic_ids |= h[1] avg = __average(group_centers) grouped.append((avg, aromatic_ids) if return_atom_ids else avg) return grouped
### End Tsuda Lab code def _get_points_fibonacci(num_samples): """ Generate points on unit sphere using fibonacci approach. Adapted from Morfeus: https://github.com/digital-chemistry-laboratory/morfeus/blob/main/morfeus/geometry.py Parameters ---------- num_samples : int Number of points to sample from the surface of a sphere Returns ------- np.ndarray (num_samples,3) Coordinates of the sampled points. """ offset = 2.0 / num_samples increment = np.pi * (3.0 - np.sqrt(5.0)) i = np.arange(num_samples) y = ((i * offset) - 1) + (offset / 2) r = np.sqrt(1 - np.square(y)) phi = np.mod((i + 1), num_samples) * increment x = np.cos(phi) * r z = np.sin(phi) * r points = np.column_stack((x, y, z)) return points def __outside_hull(sample_points: np.ndarray, hull: Delaunay ) -> np.ndarray: """ Test if points in `sample_points` are outside of the convex hull formed by the atoms. Arguments --------- sample_points : (N,3) np.ndarray of the points to check if outside the "interior" of the molecule. hull : scipy.spatial.Delaunay object initialized by the positions of the atoms of the molecule. Returns ------- (N,) np.ndarray of booleans describing if sample_points are outside of the convex hull """ return hull.find_simplex(sample_points) < 0 def __is_accessible(interaction_sphere, atom_pos, radii, mask_atom_idx): """ Check if at least 2% of sampled points fall within a surface-accessible volume of the molecule. This is 2% of the original 200 points (4 points). Currently using SAS with a probe radius of 0.8A rather than vdW volume. vdW volume will fail to exclude buried pharmacophores. Also experimented with checking if the interaction points fell within a convex hull and buried volume with Morpheus which both had limited improvements. Arguments --------- interaction_sphere : np.ndarray (M, 3) of points to check accessibility of a potentially interacting atom. M <= 200 atom_pos : np.ndarray (N, 3) Positions of atoms in molecule. radii : np.ndarray (N,) vdW radii for each corresponding atom. mask_atom_idx : np.ndarray of bool (N,) contains atom indices to ignore if the interaction points are within their SA volumes. For example, the acceptor atom or the donating hydrogens. Returns ------- bool """ # compute distances from each sampled point to all atoms (except excluded) dist_matrix = distance.cdist(interaction_sphere, atom_pos[mask_atom_idx]) mask = np.all(dist_matrix >= radii + 0.8, axis=1) # mask for points within vdW + probe radius interaction_sphere = interaction_sphere[mask] # if hull is not None: # # If you actually want to include this, then only compute Delaunay ONCE per molecule (outside this func). # hull = Delaunay(mol.GetConformer().GetPositions()) # sas_mask = np.all(dist_matrix[mask] >= radii + 0.8, axis=1) # points within SAS defined volume # hull_mask = __outside_hull(interaction_sphere, hull).astype(bool) # points within hull # interaction_sphere = interaction_sphere[hull_mask | sas_mask] num_accessible = len(interaction_sphere) # number of non-colliding points if num_accessible > 4: # at least 2% accessible from initial total 200 points return True else: return False def _is_donator_accessible(mol: rdkit.Chem.rdchem.Mol, hydrogens: Union[List[rdkit.Chem.rdchem.Atom], None], pharm_pos: Tuple, unit_vec: Tuple, ) -> bool: """ Check accessbility of donator atoms inspired by protocol of Pharao. DOI: 10.1016/j.jmgm.2008.04.003 Check whether at least 2% of the points sampled on a sphere of 1.8A radius is accessible. i.e., beyond the SAS Arguments --------- mol : rdkit Mol with conformer pharm_pos : tuple holding coords of anchor point unit_vec : tuple holding coords of releative unit vector num_nbrs : int of the number of neighbors to the acceptor (heavy + hydr) Returns ------- bool """ if hydrogens is None: hyd_atom_ids = [] else: hyd_atom_ids = [h.GetIdx() for h in hydrogens] hyd_atom_ids_set = set(hyd_atom_ids) radii = np.array([PT.GetRvdw(atom.GetAtomicNum()) for i, atom in enumerate(mol.GetAtoms()) if i not in hyd_atom_ids_set]) # Pharmacophore position is about 1.2A in direction of vector pharm_pos = np.array(pharm_pos) + 1.2*np.array(unit_vec) # unit sphere interaction_sphere = _get_points_fibonacci(200) interaction_radius = 1.8 # angstroms interaction_sphere *= interaction_radius interaction_sphere += pharm_pos # move to position of pharmacophore atom_pos = mol.GetConformer().GetPositions() # don't include the hydrogens themselves mask_atom_idx = np.isin(np.arange(len(atom_pos)), hyd_atom_ids, invert=True) return __is_accessible(interaction_sphere, atom_pos, radii, mask_atom_idx) def _is_acceptor_accessible(mol: rdkit.Chem.rdchem.Mol, acceptor_atom: rdkit.Chem.rdchem.Atom, pharm_pos: Tuple, unit_vec: Tuple, num_nbrs: int, ) -> bool: """ Check accessbility of acceptor atoms inspired by protocol of Pharao. DOI: 10.1016/j.jmgm.2008.04.003 Check whether at least 2% of the points sampled on a sphere of 1.8A radius is accessible. i.e., beyond the SAS Arguments --------- mol : rdkit Mol with conformer acceptor_atom : rdkit Atom from mol that is the acceptor pharm_pos : tuple holding coords of anchor point unit_vec : tuple holding coords of releative unit vector num_nbrs : int of the number of neighbors to the acceptor (heavy + hydr) Returns ------- bool """ acceptor_atom_id = acceptor_atom.GetIdx() radii = np.array([PT.GetRvdw(atom.GetAtomicNum()) for i, atom in enumerate(mol.GetAtoms()) if i != acceptor_atom_id]) pharm_pos = np.array(pharm_pos) # unit sphere interaction_sphere = _get_points_fibonacci(200) # mask out irrelevant parts of the sphere if num_nbrs >= 3: # hemisphere vec = np.array(unit_vec) inds = np.where(np.dot(vec, interaction_sphere.T) > 0)[0] interaction_sphere = interaction_sphere[inds] elif num_nbrs == 2: # Little more than a hemisphere, sqrt(2)/2 = -0.7071 -> 180+45 deg vec = np.array(unit_vec) inds = np.where(np.dot(vec, interaction_sphere.T) > -0.7071)[0] interaction_sphere = interaction_sphere[inds] # otherwise full sphere interaction_radius = 1.8 # angstroms interaction_sphere *= interaction_radius interaction_sphere += pharm_pos # move to position of pharmacophore atom_pos = mol.GetConformer().GetPositions() # don't include the atom itself mask_atom_idx = np.where(np.arange(len(atom_pos)) != acceptor_atom_id)[0] return __is_accessible(interaction_sphere, atom_pos, radii, mask_atom_idx) ### From rdkit: # https://github.com/rdkit/rdkit/blob/master/rdkit/Chem/Features/FeatDirUtilsRD.py # https://github.com/rdkit/rdkit/blob/master/rdkit/Chem/Features/ShowFeats.py def _average_vectors(vectors: List): """ Arguments --------- vectors : List of rdkit geometry point3d objects. These should be unit vectors. Returns ------- rdkit.Geometry.rdGeometry.Point3D object that is an average of the provided vectors. """ avg_vec = 0 for v in vectors: if avg_vec == 0: avg_vec = _copy_point3d(v) else: avg_vec += v avg_vec.Normalize() return avg_vec # Lazily create and cache the feature factory _cached_factory: rdkit.Chem.rdMolChemicalFeatures.MolChemicalFeatureFactory | None = ( None )
[docs] def get_pharmacophores_dict(mol: rdkit.Chem.rdchem.Mol, multi_vector: bool = True, exclude: List[int] = [], check_access: bool = False, scale: float = 1.0, return_atom_ids: bool = False, ) -> Dict: """ Get the positions of pharmacophore anchors and their associated unit vectors. Returns a dictionary. Adapted from rdkit.Chem.Features.ShowFeats.ShowMolFeats. Parameters ---------- mol : rdkit.Chem.Mol RDKit Mol object with a conformer. multi_vector : bool, optional Whether to represent pharmacophores with multiple vectors. Default is ``True``. exclude : list, optional List of atom indices to not include as a HBD. Default is []. check_access : bool, optional Check if HBD/HBA are accessible to the molecular surface. Default is ``False``. scale : float, optional Length of the vector in Angstroms. Default is 1.0. return_atom_ids : bool, optional When ``True``, each family sub-dict also contains an ``'A'`` key holding a list of atom-id sets (one set per emitted pharmacophore, aligned with ``'P'``). For hydrophobes, only aromatic-ring-derived clusters carry non-empty sets; aliphatic clusters have empty sets. Default is ``False``. Returns ------- dict Dictionary with format ``{'FeatureName': {'P': [(anchor coord), ...], 'V': [(rel. vec), ...]}}``. When ``return_atom_ids=True``, each entry also has ``'A': [set_of_int, ...]`` aligned with ``'P'``. """ global _cached_factory pharmacophores = {} if _cached_factory is None: dirname = os.path.dirname(__file__) fdef_file = os.path.join(dirname, "smarts_features.fdef") _cached_factory = AllChem.BuildFeatureFactory(fdef_file) mol_feats = _cached_factory.GetFeaturesForMol(mol) conf = mol.GetConformer() # Filter only these for rdkit processing, we will compute hydrophobes later keep = ('Aromatic', 'ZnBinder', 'Donor', 'Acceptor', 'Cation', 'Anion', 'Halogen') # Non-hydrophobe pharmacophore processing for feat in mol_feats: family = feat.GetFamily() # type of pharmacophore if family not in keep: continue if family not in pharmacophores: pharmacophores[family] = {'P': [], 'V': []} if return_atom_ids: pharmacophores[family]['A'] = [] if return_atom_ids: feat_atom_ids = set(feat.GetAtomIds()) if family == 'Aromatic': pos = feat.GetPos() anchor, vec = GetAromaticFeatVects(conf = conf, featAtoms = feat.GetAtomIds(), featLoc = pos, return_both = multi_vector, scale = scale) if not multi_vector: anchor = anchor[0] vec = vec[0] elif family == 'Donor': aids = feat.GetAtomIds() if len(aids) == 1: featAtom = mol.GetAtomWithIdx(aids[0]) # Multivector by default anchor, vec, hydrogen_list = GetDonorFeatVects(conf = conf, featAtoms = aids, scale = scale, exclude = exclude) if vec is not None and len(vec) > 1: avg_vec = _average_vectors(vec) else: if vec is None: avg_vec = None else: avg_vec = _copy_point3d(vec[0]) if check_access: if anchor is None or avg_vec is None: continue # Convert Point3D -> tuple; see _rdkit_point3d_to_tuple. anchor_pt = anchor if not isinstance(anchor, list) else anchor[0] if not _is_donator_accessible(mol = mol, hydrogens = hydrogen_list, pharm_pos = _rdkit_point3d_to_tuple(anchor_pt), unit_vec = _rdkit_point3d_to_tuple(avg_vec) ): continue # don't keep this pharmacophore # If only one vector per pharmacophore if not multi_vector and anchor is not None: anchor = anchor[0] vec = _copy_point3d(avg_vec) elif family == 'Acceptor': aids = feat.GetAtomIds() if len(aids) == 1: featAtom = mol.GetAtomWithIdx(aids[0]) # Multivector by default anchor, vec = GetAcceptorFeatVects(conf = conf, featAtoms = aids, scale = scale) if vec is not None and len(vec) > 1: avg_vec = _average_vectors(vec) else: if vec is None: avg_vec = None else: avg_vec = _copy_point3d(vec[0]) if check_access: if anchor is None or avg_vec is None: continue numNbrs = len(featAtom.GetNeighbors()) anchor_pt = anchor if not isinstance(anchor, list) else anchor[0] if not _is_acceptor_accessible(mol = mol, acceptor_atom = featAtom, pharm_pos = _rdkit_point3d_to_tuple(anchor_pt), unit_vec = _rdkit_point3d_to_tuple(avg_vec), num_nbrs = numNbrs): continue # don't keep this pharmacophore # If only one vector per pharmacophore if not multi_vector and anchor is not None: anchor = anchor[0] vec = _copy_point3d(avg_vec) elif family == 'Halogen': aids = feat.GetAtomIds() if len(aids) == 1: featAtom = mol.GetAtomWithIdx(aids[0]) anchor, vec = GetHalogenFeatVects(conf = conf, featAtoms = aids, scale = scale) anchor = anchor[0] vec = vec[0] else: anchor = feat.GetPos() vec = Chem.rdGeometry.Point3D(0,0,0) if anchor is not None and vec is not None: if isinstance(anchor, list): pharmacophores[family]['P'].extend(_rdkit_point3d_to_tuple(x) for x in anchor) pharmacophores[family]['V'].extend(_rdkit_point3d_to_tuple(x) for x in vec) if return_atom_ids: pharmacophores[family]['A'].extend(feat_atom_ids for _ in anchor) else: pharmacophores[family]['P'].append(_rdkit_point3d_to_tuple(anchor)) pharmacophores[family]['V'].append(_rdkit_point3d_to_tuple(vec)) if return_atom_ids: pharmacophores[family]['A'].append(feat_atom_ids) # Hydrophobe processing hydrophobes_raw = find_hydrophobes(mol=mol, cluster_hydrophobic=True, return_atom_ids=return_atom_ids) if return_atom_ids: hydrophobe_centers = [entry[0] for entry in hydrophobes_raw] hydrophobe_atom_ids = [entry[1] for entry in hydrophobes_raw] else: hydrophobe_centers = hydrophobes_raw hydrophobe_atom_ids = None pharmacophores['Hydrophobe'] = { 'P': hydrophobe_centers, 'V': [(0, 0, 0)] * len(hydrophobe_centers), } if return_atom_ids: pharmacophores['Hydrophobe']['A'] = hydrophobe_atom_ids return pharmacophores
_RING_PRIORITY_TYPE_INDICES = frozenset({ P_TYPES.index('Aromatic'), P_TYPES.index('Hydrophobe'), }) def _heavy_atoms_in_ring(mol: rdkit.Chem.rdchem.Mol, ring: Tuple[int, ...]) -> set[int]: return {i for i in ring if mol.GetAtomWithIdx(i).GetAtomicNum() > 1} def _max_priority_atoms_in_shared_rings(ring_heavy_sets: List[set], atom_ids: set[int], priority_atoms: set[int]) -> int: """ Return the maximum number of priority atoms found in any single ring that overlaps ``atom_ids``. ``ring_heavy_sets`` is the list of per-ring heavy-atom sets, precomputed once per molecule (see :func:`priority_pharm_labels`) so that ring info is not rebuilt for every pharmacophore. """ if not atom_ids or not priority_atoms: return 0 best = 0 for heavy in ring_heavy_sets: if not (heavy & atom_ids): continue best = max(best, len(heavy & priority_atoms)) return best
[docs] def priority_pharm_labels(mol: rdkit.Chem.rdchem.Mol, atom_ids_per_pharm: List[set], pharm_types: np.ndarray, priority_atoms: Iterable[int], min_ring_priority_atoms: int = 3) -> np.ndarray: """ Compute a 0/1 priority label for each pharmacophore. Parameters ---------- mol : rdkit.Chem.Mol Molecule used to resolve ring membership for aromatic/hydrophobe labels. atom_ids_per_pharm : list of sets One set of atom indices per pharmacophore, aligned with the X/P/V arrays. pharm_types : np.ndarray Pharmacophore type indices aligned with ``atom_ids_per_pharm`` (same order as ``P_TYPES``). priority_atoms : iterable of int Atom indices considered "priority". min_ring_priority_atoms : int, optional Minimum number of heavy ring atoms that must also be in ``priority_atoms`` before an aromatic or aromatic-derived hydrophobe is labeled 1. Use ``1`` to treat any single priority atom in the ring as sufficient. Default is ``3``. Returns ------- np.ndarray, shape (N,), dtype int64 1 where the pharmacophore is priority, else 0. Non-ring pharmacophores use simple atom-id intersection. Aromatic and aromatic-derived hydrophobe pharmacophores additionally require at least ``min_ring_priority_atoms`` heavy atoms from a shared ring to appear in ``priority_atoms``. """ priority = {int(a) for a in priority_atoms} ring_heavy_sets: Optional[List[set]] = None labels = [] for aids, pharm_type in zip(atom_ids_per_pharm, pharm_types): if not aids or priority.isdisjoint(aids): labels.append(0) continue if int(pharm_type) in _RING_PRIORITY_TYPE_INDICES: if ring_heavy_sets is None: ring_heavy_sets = [_heavy_atoms_in_ring(mol, ring) for ring in mol.GetRingInfo().AtomRings()] ring_priority_count = _max_priority_atoms_in_shared_rings( ring_heavy_sets, aids, priority) labels.append(1 if ring_priority_count >= min_ring_priority_atoms else 0) else: labels.append(1) return np.array(labels, dtype=np.int64)
[docs] @dataclass(eq=False) class Pharmacophore: """ Container for the pharmacophores extracted from a molecule. For backwards compatibility with the original 3-tuple return of :func:`get_pharmacophores`, instances unpack and index as ``(types, positions, vectors)``:: X, P, V = get_pharmacophores(mol) # still works Named attributes (``.types``, ``.positions``, ``.vectors``) are also available. When built with ``return_atom_ids=True``, the per-pharmacophore atom-id sets are retained on ``.atom_ids`` and priority labels can be computed lazily — for any number of priority-atom sets or thresholds — without re-extracting pharmacophores. Attributes ---------- types : np.ndarray Pharmacophore type indices (``P_TYPES`` order), shape (N,). positions : np.ndarray Anchor positions, shape (N, 3). vectors : np.ndarray Relative unit vectors, shape (N, 3). mol : rdkit.Chem.Mol or None Source molecule, needed for ring-aware :meth:`priority_labels`. atom_ids : list of set or None Per-pharmacophore atom-id sets aligned with ``types``; ``None`` unless the container was built with ``return_atom_ids=True``. labels : np.ndarray or None Priority labels aligned with ``types``, populated when ``get_pharmacophores`` is called with ``priority_atoms``; ``None`` otherwise. """ types: np.ndarray positions: np.ndarray vectors: np.ndarray mol: Optional[rdkit.Chem.rdchem.Mol] = None atom_ids: Optional[List[set]] = None labels: Optional[np.ndarray] = None def _as_tuple(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: return (self.types, self.positions, self.vectors) def __iter__(self): return iter(self._as_tuple()) def __len__(self) -> int: return 3 def __getitem__(self, idx): return self._as_tuple()[idx]
[docs] def priority_labels(self, priority_atoms: Iterable[int], min_ring_priority_atoms: int = 3) -> np.ndarray: """ Compute a 0/1 priority label per pharmacophore against ``priority_atoms``. Requires the container to have been built with ``return_atom_ids=True``. See :func:`priority_pharm_labels` for the labeling semantics. Parameters ---------- priority_atoms : iterable of int Atom indices considered "priority". min_ring_priority_atoms : int, optional Minimum heavy ring atoms in ``priority_atoms`` before an aromatic or aromatic-derived hydrophobe is labeled 1. Default is ``3``. Returns ------- np.ndarray, shape (N,), dtype int64 """ if self.atom_ids is None: raise ValueError( "priority_labels requires per-pharmacophore atom ids; rebuild with " "get_pharmacophores(..., return_atom_ids=True)." ) return priority_pharm_labels(self.mol, self.atom_ids, self.types, priority_atoms, min_ring_priority_atoms=min_ring_priority_atoms)
[docs] def get_pharmacophores(mol: rdkit.Chem.rdchem.Mol, multi_vector: bool = True, exclude: List[int] = [], check_access: bool = False, scale: float = 1.0, return_atom_ids: bool = False, priority_atoms: Optional[Iterable[int]] = None, min_ring_priority_atoms: int = 3, ) -> Pharmacophore: """ Get the identity, anchor positions, and relative unit vectors for each pharmacophore. Pharmacophore ordering for indexing: ('Acceptor', 'Donor', 'Aromatic', 'Hydrophobe', 'Cation', 'Anion', 'ZnBinder') Notes ----- The ``check_access`` parameter is currently based on whether interaction points sampled from a sphere's surface with a radius of 1.8A from the acceptor/donor atom falls outside the solvent accessible surface defined by the vdW radius + 0.8A of the neighboring atoms. This works for buried acceptors/donors, but may be prone to false positives. For example, CN(C)C would have its sole HBA rejected. Other approaches such as buried volume should be considered in the future. Parameters ---------- mol : rdkit.Chem.Mol RDKit Mol object with conformer. multi_vector : bool, optional Whether to represent pharmacophores with multiple vectors. Default is ``True``. exclude : list, optional List of hydrogen indices to not include as a HBD. Default is []. check_access : bool, optional Check if HBD/HBA are accessible to the molecular surface. Default is ``False``. scale : float, optional Length of a pharmacophore vector in Angstroms. Default is 1.0. return_atom_ids : bool, optional When ``True``, the returned :class:`Pharmacophore` retains the per-pharmacophore atom-id sets on ``.atom_ids``, enabling lazy priority labeling via :meth:`Pharmacophore.priority_labels`. Implied by ``priority_atoms``. Default is ``False``. priority_atoms : iterable of int, optional Atom indices considered "priority". When provided, priority labels are computed in this single call and stored on ``.labels`` of the returned container (atom ids are retained automatically). See :meth:`Pharmacophore.priority_labels` for the labeling semantics. Default is ``None``. min_ring_priority_atoms : int, optional Only used when ``priority_atoms`` is provided. Minimum heavy ring atoms in ``priority_atoms`` before an aromatic or aromatic-derived hydrophobe is labeled 1. Set to ``1`` to label any pharmacophore whose ring shares a single priority atom. Default is ``3``. Returns ------- Pharmacophore Container that unpacks as the original ``(X, P, V)`` 3-tuple for backwards compatibility, where: - ``X`` (``.types``): pharmacophore type indices (``P_TYPES`` order), shape (N,). - ``P`` (``.positions``): anchor positions, shape (N, 3). - ``V`` (``.vectors``): relative unit vectors, shape (N, 3); adding P and V gives the extended point. When ``return_atom_ids=True`` (or ``priority_atoms`` is given), ``.atom_ids`` holds per-pharmacophore atom-id sets and ``.priority_labels(priority_atoms, ...)`` computes 0/1 priority labels. When ``priority_atoms`` is given, ``.labels`` is also populated in this call. """ return_atom_ids = return_atom_ids or (priority_atoms is not None) pharmacophores_dict = get_pharmacophores_dict(mol=mol, multi_vector=multi_vector, check_access=check_access, scale=scale, exclude=exclude, return_atom_ids=return_atom_ids) N = sum(len(pharmacophores_dict[family]['P']) for family in pharmacophores_dict) X = np.empty((N,), dtype=np.int64) P = np.empty((N, 3), dtype=np.float64) V = np.empty((N, 3), dtype=np.float64) atom_ids_per_pharm: Optional[List[set]] = [] if return_atom_ids else None start_idx = 0 for family in pharmacophores_dict: this_len = len(pharmacophores_dict[family]['P']) if this_len == 0: continue end_idx = start_idx + this_len X[start_idx:end_idx] = P_TYPES.index(family) P[start_idx:end_idx, :] = pharmacophores_dict[family]['P'] V[start_idx:end_idx, :] = pharmacophores_dict[family]['V'] if return_atom_ids: atom_ids_per_pharm.extend(pharmacophores_dict[family]['A']) start_idx = end_idx pharm = Pharmacophore(types=X, positions=P, vectors=V, mol=mol if return_atom_ids else None, atom_ids=atom_ids_per_pharm) if priority_atoms is not None: pharm.labels = pharm.priority_labels( priority_atoms, min_ring_priority_atoms=min_ring_priority_atoms) return pharm