Skip to content

Commit 4ac59f7

Browse files
committed
NO_JIRA Added Pharmacophore generator script to create CrossMiner queries from Ligand Overlay outputs. Includes basic tests and examples.
1 parent 8cc6a94 commit 4ac59f7

10 files changed

Lines changed: 967 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Pharmacophore Query Generator
2+
3+
This tool provides the user with the ability to create pharmacophore queries from the results of a ligand overlay.
4+
5+
The pharmacophore queries are produced to be used with CrossMiner.
6+
7+
## Requirements
8+
9+
- [CSD Python API](https://downloads.ccdc.cam.ac.uk/documentation/API/) installed.
10+
- Access to CSD CrossMiner and the feature definitions (`.cpf`) files.
11+
- Access to the CSD Ligand Overlay Tool.
12+
13+
## Licensing Requirements
14+
15+
CSD-Discovery, CSD-Enterprise and Research Partner suites would all be sufficient.
16+
17+
## Instructions on Running
18+
19+
### Feature Definitions
20+
21+
The CrossMiner feature definition (`.cpf`) files are **not** shipped with this repo.
22+
Supply the location of the feature definitions from your CrossMiner installation with
23+
`-f`/`--feature_definitions`; this should be the directory containing the definition files
24+
(either directly, or in `any`/`protein`/`small_molecule` subdirectories).
25+
Usually, the location is `C:\users\<username>\CCDC\ccdc-software\csd-crossminer\feature_definitions`
26+
27+
```
28+
python main.py -i <overlay_folder> -o <output_folder> -f <feature_definitions_folder>
29+
```
30+
31+
The output folder (`-o`/`--output_folder`) is optional; if it is not supplied, the queries are
32+
written to a `queries` folder created in the current directory.
33+
34+
### Options for Ligand Overlay Output
35+
36+
* `cluster`: Cluster the similar pharmacophore features based on proximity
37+
* `projected`: Treat pharmacophore features as projected when appropriate e.g. acceptors and donors
38+
39+
There is also the option to specify a specific Ligand Overlay from all the results. If this is not specified, all overlays are used.
40+
When all overlays are used, the pharmacophore query will be a union of all the features from all the overlays.
41+
These features are then clustered based on proximity AND prevalence.
42+
43+
### Using the Queries Generated
44+
45+
If you would like to use the queries generated with this tool, they can be opened in CrossMiner to run a search.
46+
A file `crossminer_search.py` has also been provided which contains a Python function for the most simply kind of CrossMiner search.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from collections import defaultdict
2+
3+
import numpy as np
4+
5+
from datastructures import PharmFeaturePoint
6+
7+
8+
def cluster_features(features: list[PharmFeaturePoint]) -> list[PharmFeaturePoint]:
9+
"""Cluster all features where relevant."""
10+
groups = defaultdict(list)
11+
for feature in features:
12+
groups[feature.label].append(feature)
13+
14+
clustered_features = []
15+
for label, group in groups.items():
16+
if label == 'hydrophobe':
17+
clustered_features.extend(_cluster_similar_features(group))
18+
elif label in ('acceptor_projected', 'donor_projected'):
19+
clustered_features.extend(_cluster_similar_features(group, check_vp=True))
20+
else:
21+
clustered_features.extend(group)
22+
return clustered_features
23+
24+
25+
def _cluster_similar_features(
26+
features: list[PharmFeaturePoint], cluster_radius: float = 2.0, check_vp: bool = False
27+
) -> list[PharmFeaturePoint]:
28+
"""
29+
Cluster specific features that are close to each other.
30+
For projected features, also check virtual point distances.
31+
32+
Args:
33+
features: List of features to cluster
34+
cluster_radius: Distance between features to be included in clustering (Ang)
35+
check_vp: Whether to also require virtual points to be within the radius
36+
"""
37+
if len(features) < 2:
38+
return features
39+
40+
clusters = _connected_components(
41+
features,
42+
lambda a, b: _is_close(a, b, cluster_radius, check_vp),
43+
)
44+
return [_merge_cluster(cluster, check_vp) for cluster in clusters]
45+
46+
47+
def _is_close(
48+
a: PharmFeaturePoint, b: PharmFeaturePoint, cluster_radius: float, check_vp: bool
49+
) -> bool:
50+
"""Whether two features are within ``cluster_radius`` (and, if ``check_vp``, their virtual points too)."""
51+
if np.linalg.norm(a - b) >= cluster_radius:
52+
return False
53+
if check_vp and np.linalg.norm(a.virtual_point - b.virtual_point) >= cluster_radius:
54+
return False
55+
return True
56+
57+
58+
def _connected_components(
59+
features: list[PharmFeaturePoint], is_linked
60+
) -> list[list[PharmFeaturePoint]]:
61+
"""
62+
Single-linkage grouping: features are placed in the same cluster if a chain of
63+
``is_linked`` neighbours connects them.
64+
"""
65+
unclustered = features.copy()
66+
clusters = []
67+
68+
while unclustered:
69+
cluster = [unclustered.pop(0)]
70+
# Grow the cluster breadth-first: any unclustered feature linked to a member joins it.
71+
i = 0
72+
while i < len(cluster):
73+
member = cluster[i]
74+
remaining = []
75+
for feature in unclustered:
76+
if is_linked(feature, member):
77+
cluster.append(feature)
78+
else:
79+
remaining.append(feature)
80+
unclustered = remaining
81+
i += 1
82+
clusters.append(cluster)
83+
84+
return clusters
85+
86+
87+
def _merge_cluster(cluster: list[PharmFeaturePoint], check_vp: bool) -> PharmFeaturePoint:
88+
"""Collapse a cluster into a single feature at its centroid (singletons are returned unchanged)."""
89+
if len(cluster) == 1:
90+
return cluster[0]
91+
92+
centroid = np.mean([c.coordinates for c in cluster], axis=0).round(4)
93+
vp_centroid = (
94+
np.mean([c.virtual_point for c in cluster], axis=0).round(4) if check_vp else None
95+
)
96+
return PharmFeaturePoint(centroid, label=cluster[0].label, virtual_point=vp_centroid)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from pathlib import Path
2+
3+
from ccdc.pharmacophore import Pharmacophore
4+
5+
def search(query_file: Path, database_file: Path):
6+
settings = Pharmacophore.Search.Settings()
7+
settings.max_hit_structures = 20
8+
settings.max_hits_per_structure = 1
9+
settings.max_hit_rmsd = 1.0
10+
searcher = Pharmacophore.Search(settings)
11+
feature_db = Pharmacophore.FeatureDatabase.from_file(database_file)
12+
query = Pharmacophore.Query.from_file(str(query_file))
13+
hits = searcher.search(
14+
model=query,
15+
database=feature_db,
16+
verbose=True,
17+
)
18+
return hits
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
from typing import Iterable, Optional
6+
7+
import numpy as np
8+
9+
10+
@dataclass
11+
class FeatureTolerances:
12+
"""
13+
Allowed feature types and their tolerances.
14+
15+
Each field is a valid feature label; its value is the tolerance:
16+
a single weight, or a (parent, virtual_point) pair for projected features.
17+
"""
18+
acceptor: float = 1.0
19+
acceptor_projected: tuple[float, float] = (0.8, 0.8)
20+
donor_projected: tuple[float, float] = (1.0, 1.0)
21+
hydrophobe: float = 1.0
22+
ring_planar_projected: tuple[float, float] = (1.0, 1.0)
23+
ring_non_planar: tuple[float, float] = (1.0, 1.0)
24+
halogen: float = 1.0
25+
26+
def __getitem__(self, key: str) -> float | tuple[float, float]:
27+
return getattr(self, key)
28+
29+
30+
class PharmFeaturePoint(np.ndarray):
31+
def __new__(
32+
cls,
33+
*coordinates: float | Iterable[float],
34+
label: Optional[str] = None,
35+
virtual_point: Optional[np.ndarray] = None,
36+
):
37+
if len(coordinates) == 1:
38+
arr = np.asarray(coordinates[0], dtype=float)
39+
elif len(coordinates) == 3:
40+
arr = np.asarray(coordinates, dtype=float)
41+
else:
42+
raise TypeError("Coordinates must be either an iterable of length three, or three floats")
43+
if arr.shape != (3,):
44+
raise ValueError("Coordinates must be a 3-element array")
45+
obj = arr.view(cls)
46+
obj.label = label
47+
obj.virtual_point = virtual_point
48+
return obj
49+
50+
def __array_finalize__(self, obj):
51+
if obj is None:
52+
return
53+
self.label = getattr(obj, 'label', None)
54+
self.virtual_point = getattr(obj, 'virtual_point', None)
55+
56+
def __repr__(self):
57+
return (
58+
f"PharmFeaturePoint({self.x}, {self.y}, {self.z}, "
59+
f"label={self.label}, virtual_points={self.virtual_point}), "
60+
)
61+
62+
def __str__(self):
63+
return self.__repr__()
64+
65+
@property
66+
def coordinates(self) -> np.ndarray:
67+
return np.asarray(self)
68+
69+
@property
70+
def x(self):
71+
return self[0]
72+
73+
@property
74+
def y(self):
75+
return self[1]
76+
77+
@property
78+
def z(self):
79+
return self[2]
80+
81+
@property
82+
def tolerance(self) -> float | tuple[float, float]:
83+
"""
84+
Get the tolerance for the feature based on its label.
85+
Returns:
86+
A single tolerance for features with a single tolerance or a tuple for those with two tolerances.
87+
"""
88+
if self.label is None:
89+
raise LookupError("Feature label must be set to determine tolerances.")
90+
return FeatureTolerances()[self.label]
91+
92+
@property
93+
def weight_parent(self) -> float:
94+
if isinstance(self.tolerance, float):
95+
return self.tolerance
96+
elif isinstance(self.tolerance, tuple):
97+
return self.tolerance[0]
98+
else:
99+
raise ValueError("Incorrect tolerances loaded for feature point.")
100+
101+
@property
102+
def weight_vp(self) -> float:
103+
return self.tolerance[1]
104+
105+
106+
107+
@dataclass
108+
class OverlayData:
109+
input_folder: Path
110+
output_folder: Path
111+
# Pharmacophore file from the pharmacophores folder
112+
pharm_file: Path
113+
# Overlay solution file (the chosen one from the many produced)
114+
overlay_file: Path
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import argparse
2+
from pathlib import Path
3+
4+
from cluster import cluster_features
5+
from datastructures import OverlayData
6+
from overlay import OverlayToPharmFeatures
7+
from write_query import FeaturesToCrossMinerQuery
8+
9+
10+
def str_to_bool(value: str) -> bool:
11+
return value.lower() in {'t', 'true', '1', 'yes', 'y'}
12+
13+
14+
def parse_args():
15+
parser = argparse.ArgumentParser(
16+
description="Create Pharmacophore Features from a Ligand Overlay"
17+
)
18+
parser.add_argument('-i', '--input_folder', type=str, help='Input file(s) path.')
19+
parser.add_argument('-o', '--output_folder', type=str, default=None,
20+
help="Output folder path. Defaults to a 'queries' folder in the current directory.")
21+
parser.add_argument('-f', '--feature_definitions', type=str, required=True,
22+
help='Path to the directory containing the CrossMiner feature definition (.cpf) files.')
23+
parser.add_argument('-c', '--cluster', type=str_to_bool, default=False,
24+
help='Cluster features if they are close together or common across multiple inputs.')
25+
parser.add_argument('-p', '--projected', type=str_to_bool, default=False,
26+
help='Use projected acceptor features or point features.')
27+
parser.add_argument('-id', '--overlay_id', type=int, default=0,
28+
help='Overlay ID to process. If 0 or not specified, all overlays will be processed.')
29+
30+
return parser.parse_args()
31+
32+
33+
def main():
34+
args = parse_args()
35+
36+
input_folder = Path(args.input_folder)
37+
if not input_folder.exists():
38+
raise FileNotFoundError(f"Input folder {input_folder} does not exist.")
39+
40+
feature_definitions = Path(args.feature_definitions)
41+
if not feature_definitions.is_dir():
42+
raise FileNotFoundError(f"Feature definitions folder {feature_definitions} does not exist.")
43+
44+
output_folder = Path(args.output_folder) if args.output_folder else Path('queries')
45+
output_folder.mkdir(parents=True, exist_ok=True)
46+
47+
if (args.overlay_id == 0) or (args.overlay_id is None):
48+
overlay_files = sorted(input_folder.glob('solution_*.mol2'))
49+
pharm_files = sorted(input_folder.glob('pharmacophores/solution_pharm_*.mol2'))
50+
else:
51+
overlay_files = [input_folder / f'solution_{args.overlay_id:02}.mol2']
52+
pharm_files = [input_folder / f'pharmacophores/solution_pharm_{args.overlay_id:02}.mol2']
53+
feature_sets = []
54+
for pharm_file, overlay_file in zip(pharm_files, overlay_files):
55+
overlay_data = OverlayData(
56+
input_folder=input_folder,
57+
output_folder=output_folder,
58+
pharm_file=pharm_file,
59+
overlay_file=overlay_file
60+
)
61+
feature_sets.append(OverlayToPharmFeatures(overlay_data, projected=args.projected).features)
62+
63+
for i, feature_set in enumerate(feature_sets, 1):
64+
if args.cluster:
65+
feature_set = cluster_features(feature_set)
66+
67+
query = FeaturesToCrossMinerQuery(
68+
pharm_feature_points=feature_set,
69+
feature_definitions=feature_definitions,
70+
output_file=output_folder / f'features_{i}.cm',
71+
)
72+
query.write_feature_file()
73+
74+
75+
if __name__ == '__main__':
76+
main()

0 commit comments

Comments
 (0)