pocketnet 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.
- pocketnet/__init__.py +8 -0
- pocketnet/api.py +92 -0
- pocketnet/calibration.json +1 -0
- pocketnet/cli.py +31 -0
- pocketnet/feat.py +106 -0
- pocketnet/model.py +71 -0
- pocketnet/pdbio.py +21 -0
- pocketnet/pockets.py +45 -0
- pocketnet/weights/v3_fold0.pt +0 -0
- pocketnet/weights/v3_fold1.pt +0 -0
- pocketnet/weights/v3_fold2.pt +0 -0
- pocketnet/weights/v3_fold3.pt +0 -0
- pocketnet/weights/v3_fold4.pt +0 -0
- pocketnet-0.1.0.dist-info/METADATA +68 -0
- pocketnet-0.1.0.dist-info/RECORD +19 -0
- pocketnet-0.1.0.dist-info/WHEEL +5 -0
- pocketnet-0.1.0.dist-info/entry_points.txt +2 -0
- pocketnet-0.1.0.dist-info/licenses/LICENSE +9 -0
- pocketnet-0.1.0.dist-info/top_level.txt +1 -0
pocketnet/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""PocketNet: ligand-binding pocket prediction from protein structures.
|
|
2
|
+
|
|
3
|
+
>>> from pocketnet import PocketPredictor
|
|
4
|
+
>>> pockets = PocketPredictor().predict("examples/3POZ.pdb")
|
|
5
|
+
>>> pockets[0]["center"], pockets[0]["score"]
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
from .api import PocketPredictor, predict # noqa: E402,F401
|
pocketnet/api.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""High-level interface: a five-model PocketNet ensemble that turns a PDB file into ranked pockets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, os, tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import numpy as np, torch
|
|
6
|
+
from . import feat, pockets
|
|
7
|
+
from .model import PocketNet
|
|
8
|
+
from .pdbio import read_pdb
|
|
9
|
+
|
|
10
|
+
_HERE = Path(__file__).resolve().parent
|
|
11
|
+
WEIGHTS = sorted((_HERE / "weights").glob("v3_fold*.pt"))
|
|
12
|
+
MAX_ATOMS = 14000 # structures above this size were not used in training; larger inputs are accepted but untested
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load(path, device):
|
|
16
|
+
sd = torch.load(path, map_location=device, weights_only=True)
|
|
17
|
+
hidden = sd["emb.weight"].shape[1]
|
|
18
|
+
layers = len([k for k in sd if k.startswith("layers.") and k.endswith("msg.0.weight")])
|
|
19
|
+
m = PocketNet(hidden, layers).to(device)
|
|
20
|
+
m.load_state_dict(sd)
|
|
21
|
+
return m.eval()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PocketPredictor:
|
|
25
|
+
"""Averages the sigmoid scores of the five fold models and extracts at most ten ranked pockets.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
device : "cuda", "cpu" or None (cuda if available).
|
|
30
|
+
weights : optional list of checkpoint paths (default: the five bundled fold models).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, device: str | None = None, weights=None):
|
|
34
|
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
35
|
+
self.models = [_load(w, self.device) for w in (weights or WEIGHTS)]
|
|
36
|
+
cal = json.loads((_HERE / "calibration.json").read_text())
|
|
37
|
+
self._cal_x, self._cal_y = np.asarray(cal["x"]), np.asarray(cal["y"])
|
|
38
|
+
|
|
39
|
+
@torch.no_grad()
|
|
40
|
+
def score_points(self, structure):
|
|
41
|
+
"""Return (surface_points [P,3], ensemble_score [P]) for a PDB file path or a PDB-format string."""
|
|
42
|
+
P = self._read(structure)
|
|
43
|
+
dev = self.device
|
|
44
|
+
ty, el = feat.atom_types(P["res"], P["name"])
|
|
45
|
+
x = torch.tensor(P["xyz"], device=dev); e = torch.tensor(el, device=dev); t = torch.tensor(ty, device=dev)
|
|
46
|
+
pts, nrm = feat.sas_points(x, e)
|
|
47
|
+
pf = feat.point_feats(pts, nrm, x, e)
|
|
48
|
+
s = 0
|
|
49
|
+
for m in self.models:
|
|
50
|
+
if dev == "cuda":
|
|
51
|
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
52
|
+
lo, _ = m(t, x, pts, pf)
|
|
53
|
+
else:
|
|
54
|
+
lo, _ = m(t, x, pts, pf)
|
|
55
|
+
s = s + torch.sigmoid(lo.float())
|
|
56
|
+
return pts.cpu().numpy(), (s / len(self.models)).cpu().numpy(), P
|
|
57
|
+
|
|
58
|
+
def predict(self, structure, max_pockets: int = 10, residue_radius: float = 8.0):
|
|
59
|
+
"""Ranked pockets for a PDB file (path or text).
|
|
60
|
+
|
|
61
|
+
Each pocket is a dict with ``rank``, ``score`` (sum of its 24 highest point scores), ``probability``
|
|
62
|
+
(isotonic calibration of the score on held-out proteins; indicative only), ``center`` (x, y, z in angstrom),
|
|
63
|
+
``n_points`` and ``residues`` (chain:number of residues with an atom within ``residue_radius`` of the centre).
|
|
64
|
+
"""
|
|
65
|
+
pts, s, P = self.score_points(structure)
|
|
66
|
+
found = pockets.extract_with_members(pts, s, topn=max_pockets, **pockets.TUNED)
|
|
67
|
+
out = []
|
|
68
|
+
for rank, c in enumerate(found, 1):
|
|
69
|
+
ctr = np.asarray(c["center"], float)
|
|
70
|
+
near = np.linalg.norm(P["xyz"] - ctr[None], axis=1) <= residue_radius
|
|
71
|
+
res = sorted({f"{P['chain'][j]}:{P['rid'][j]}" for j in np.where(near)[0]})
|
|
72
|
+
out.append(dict(rank=rank, score=float(c["score"]), probability=float(np.interp(c["score"], self._cal_x, self._cal_y)),
|
|
73
|
+
center=[round(float(v), 3) for v in ctr], n_points=int(len(c["idx"])), residues=res))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def _read(self, structure):
|
|
77
|
+
text = str(structure)
|
|
78
|
+
if "\n" in text or text.lstrip().startswith(("ATOM", "HEADER", "REMARK", "CRYST1", "HETATM")):
|
|
79
|
+
with tempfile.NamedTemporaryFile("w", suffix=".pdb", delete=False) as f:
|
|
80
|
+
f.write(text); name = f.name
|
|
81
|
+
try: P, _ = read_pdb(name)
|
|
82
|
+
finally: os.unlink(name)
|
|
83
|
+
else:
|
|
84
|
+
P, _ = read_pdb(text)
|
|
85
|
+
if len(P["xyz"]) < 30:
|
|
86
|
+
raise ValueError("fewer than 30 protein atoms were read (only ATOM records of standard residues are used; mmCIF is not supported)")
|
|
87
|
+
return P
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def predict(structure, device=None, **kw):
|
|
91
|
+
"""One-shot convenience wrapper around :class:`PocketPredictor`."""
|
|
92
|
+
return PocketPredictor(device).predict(structure, **kw)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"x": [0.106, 0.699, 1.292, 1.885, 2.478, 3.071, 3.664, 4.257, 4.851, 5.444, 6.037, 6.63, 7.223, 7.816, 8.409, 9.002, 9.595, 10.189, 10.782, 11.375, 11.968, 12.561, 13.154, 13.747, 14.34, 14.934, 15.527, 16.12, 16.713, 17.306, 17.899, 18.492, 19.085, 19.679, 20.272, 20.865, 21.458, 22.051, 22.644, 23.237], "y": [0.02, 0.032, 0.032, 0.032, 0.045, 0.045, 0.046, 0.046, 0.054, 0.057, 0.061, 0.12, 0.12, 0.136, 0.144, 0.144, 0.169, 0.169, 0.216, 0.216, 0.216, 0.3, 0.329, 0.348, 0.348, 0.349, 0.503, 0.503, 0.55, 0.551, 0.551, 0.551, 0.636, 0.68, 0.734, 0.768, 0.819, 0.912, 0.912, 0.98], "n_pockets": 5324, "n_proteins": 1000, "rank_hit_rate": {"1": 0.8316532258064516, "2": 0.6538849646821393, "3": 0.4606986899563319, "4": 0.23684210526315788, "5": 0.1338709677419355, "6": 0.08018867924528301}}
|
pocketnet/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Command line interface: ``pocketnet predict protein.pdb -o results/``."""
|
|
2
|
+
import argparse, csv, json, os, sys
|
|
3
|
+
from . import __version__
|
|
4
|
+
from .api import PocketPredictor
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None):
|
|
8
|
+
ap = argparse.ArgumentParser(prog="pocketnet", description="Predict ligand-binding pockets with PocketNet.")
|
|
9
|
+
ap.add_argument("--version", action="version", version=f"pocketnet {__version__}")
|
|
10
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
11
|
+
p = sub.add_parser("predict", help="rank pockets of one or more PDB files")
|
|
12
|
+
p.add_argument("pdb", nargs="+", help="input PDB file(s) (ATOM records; mmCIF is not supported)")
|
|
13
|
+
p.add_argument("-o", "--out", default="pocketnet_out", help="output directory (default: pocketnet_out)")
|
|
14
|
+
p.add_argument("--device", choices=["cpu", "cuda"], default=None, help="default: cuda if available, else cpu")
|
|
15
|
+
p.add_argument("--max-pockets", type=int, default=10)
|
|
16
|
+
a = ap.parse_args(argv)
|
|
17
|
+
os.makedirs(a.out, exist_ok=True)
|
|
18
|
+
pred = PocketPredictor(a.device)
|
|
19
|
+
for path in a.pdb:
|
|
20
|
+
res = pred.predict(path, max_pockets=a.max_pockets); stem = os.path.basename(path)
|
|
21
|
+
with open(os.path.join(a.out, stem + "_pockets.csv"), "w", newline="") as f:
|
|
22
|
+
w = csv.writer(f); w.writerow(["rank", "score", "probability", "center_x", "center_y", "center_z", "n_points", "residues"])
|
|
23
|
+
for r in res: w.writerow([r["rank"], f"{r['score']:.3f}", f"{r['probability']:.3f}", *r["center"], r["n_points"], " ".join(r["residues"])])
|
|
24
|
+
with open(os.path.join(a.out, stem + "_pockets.json"), "w") as f: json.dump(res, f, indent=1)
|
|
25
|
+
print(f"{stem}: {len(res)} pockets")
|
|
26
|
+
for r in res[:5]: print(f" {r['rank']:>2} score {r['score']:6.2f} p~{r['probability']:.2f} centre {r['center']} ({len(r['residues'])} residues)")
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
sys.exit(main())
|
pocketnet/feat.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Protein featurisation for pocket detection. Everything heavy runs on GPU (torch)."""
|
|
2
|
+
import math, numpy as np, torch
|
|
3
|
+
|
|
4
|
+
AA = ['ALA','ARG','ASN','ASP','CYS','GLN','GLU','GLY','HIS','ILE','LEU','LYS','MET','PHE','PRO','SER','THR','TRP','TYR','VAL']
|
|
5
|
+
RMAP = {'HIE':'HIS','HID':'HIS','HIP':'HIS','HSD':'HIS','HSE':'HIS','HSP':'HIS','CYX':'CYS','CYM':'CYS','ASH':'ASP','GLH':'GLU','LYN':'LYS','MSE':'MET'}
|
|
6
|
+
ATOMS = { # standard heavy-atom names per residue (PDB naming)
|
|
7
|
+
'ALA':'N CA C O CB','ARG':'N CA C O CB CG CD NE CZ NH1 NH2','ASN':'N CA C O CB CG OD1 ND2','ASP':'N CA C O CB CG OD1 OD2',
|
|
8
|
+
'CYS':'N CA C O CB SG','GLN':'N CA C O CB CG CD OE1 NE2','GLU':'N CA C O CB CG CD OE1 OE2','GLY':'N CA C O',
|
|
9
|
+
'HIS':'N CA C O CB CG ND1 CD2 CE1 NE2','ILE':'N CA C O CB CG1 CG2 CD1','LEU':'N CA C O CB CG CD1 CD2',
|
|
10
|
+
'LYS':'N CA C O CB CG CD CE NZ','MET':'N CA C O CB CG SD CE','PHE':'N CA C O CB CG CD1 CD2 CE1 CE2 CZ',
|
|
11
|
+
'PRO':'N CA C O CB CG CD','SER':'N CA C O CB OG','THR':'N CA C O CB OG1 CG2','TRP':'N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2',
|
|
12
|
+
'TYR':'N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH','VAL':'N CA C O CB CG1 CG2'}
|
|
13
|
+
VOCAB = {}
|
|
14
|
+
for r in AA:
|
|
15
|
+
for a in ATOMS[r].split(): VOCAB[(r, a)] = len(VOCAB) + 1 # 0 = unknown
|
|
16
|
+
for r in AA: VOCAB[(r, 'OXT')] = len(VOCAB) + 1
|
|
17
|
+
N_TYPES = len(VOCAB) + 2 # +unknown(0) +other-residue(last)
|
|
18
|
+
OTHER = N_TYPES - 1
|
|
19
|
+
ALIAS = {'OC1':'O','OC2':'OXT','O1':'O','O2':'OXT'}
|
|
20
|
+
EL = {'C':0,'N':1,'O':2,'S':3,'P':4}
|
|
21
|
+
VDW = torch.tensor([1.70, 1.55, 1.52, 1.80, 1.80, 1.80]) # C N O S P other
|
|
22
|
+
|
|
23
|
+
def atom_types(res, name):
|
|
24
|
+
out = np.zeros(len(res), np.int64); el = np.zeros(len(res), np.int64)
|
|
25
|
+
for i, (r, a) in enumerate(zip(res, name)):
|
|
26
|
+
r = RMAP.get(r, r); a = ALIAS.get(a, a)
|
|
27
|
+
out[i] = VOCAB.get((r, a), 0) if r in ATOMS else OTHER
|
|
28
|
+
el[i] = EL.get(a[0], 5)
|
|
29
|
+
return out, el
|
|
30
|
+
|
|
31
|
+
def fib_sphere(n, device):
|
|
32
|
+
i = torch.arange(n, device=device, dtype=torch.float32) + 0.5
|
|
33
|
+
phi = torch.acos(1 - 2 * i / n); th = math.pi * (1 + 5 ** 0.5) * i
|
|
34
|
+
return torch.stack([torch.cos(th) * torch.sin(phi), torch.sin(th) * torch.sin(phi), torch.cos(phi)], 1)
|
|
35
|
+
|
|
36
|
+
@torch.no_grad()
|
|
37
|
+
def sas_points(xyz, el, probe=1.4, n_sphere=48, chunk=4096):
|
|
38
|
+
"""Shrake-Rupley points on the solvent-accessible surface. Returns pts [P,3], normals [P,3] (pointing out)."""
|
|
39
|
+
dev = xyz.device; r = VDW.to(dev)[el] + probe
|
|
40
|
+
sph = fib_sphere(n_sphere, dev)
|
|
41
|
+
pts = (xyz[:, None, :] + r[:, None, None] * sph[None]).reshape(-1, 3)
|
|
42
|
+
own = torch.arange(len(xyz), device=dev).repeat_interleave(n_sphere)
|
|
43
|
+
keep = torch.ones(len(pts), dtype=torch.bool, device=dev)
|
|
44
|
+
for s in range(0, len(pts), chunk):
|
|
45
|
+
d = torch.cdist(pts[s:s+chunk], xyz) # [c,N]
|
|
46
|
+
inside = d < (r[None] - 1e-3)
|
|
47
|
+
inside[torch.arange(inside.shape[0], device=dev), own[s:s+chunk]] = False
|
|
48
|
+
keep[s:s+chunk] = ~inside.any(1)
|
|
49
|
+
pts = pts[keep]; nrm = (pts - xyz[own[keep]]); nrm = nrm / nrm.norm(dim=1, keepdim=True)
|
|
50
|
+
return pts, nrm
|
|
51
|
+
|
|
52
|
+
@torch.no_grad()
|
|
53
|
+
def occupancy_grid(xyz, el, cell=0.8, pad=18.0):
|
|
54
|
+
dev = xyz.device; lo = xyz.min(0).values - pad; hi = xyz.max(0).values + pad
|
|
55
|
+
dims = ((hi - lo) / cell).ceil().long() + 1
|
|
56
|
+
grid = torch.zeros(tuple(dims.tolist()), dtype=torch.bool, device=dev)
|
|
57
|
+
rr = (VDW.to(dev)[el] + 0.2) # slightly inflated
|
|
58
|
+
m = int(math.ceil(2.1 / cell)); rng = torch.arange(-m, m + 1, device=dev)
|
|
59
|
+
off = torch.stack(torch.meshgrid(rng, rng, rng, indexing='ij'), -1).reshape(-1, 3) # [K,3]
|
|
60
|
+
for s in range(0, len(xyz), 2048):
|
|
61
|
+
a = xyz[s:s+2048]; c = ((a - lo) / cell).round().long() # [n,3]
|
|
62
|
+
idx = c[:, None, :] + off[None]; pos = lo + idx.float() * cell
|
|
63
|
+
ok = ((pos - a[:, None, :]).norm(dim=-1) <= rr[s:s+2048, None])
|
|
64
|
+
idx = idx[ok]; grid[idx[:, 0], idx[:, 1], idx[:, 2]] = True
|
|
65
|
+
return grid, lo, cell
|
|
66
|
+
|
|
67
|
+
@torch.no_grad()
|
|
68
|
+
def point_feats(pts, nrm, xyz, el, aa_sel=None, n_rays=64, step=0.8, rmax=16.0):
|
|
69
|
+
"""Rotation-invariant per-point descriptors: ray-cast burial (all dirs + cones about the normal), shell counts."""
|
|
70
|
+
dev = pts.device
|
|
71
|
+
grid, lo, cell = occupancy_grid(xyz, el)
|
|
72
|
+
rays = fib_sphere(n_rays, dev) # [R,3]
|
|
73
|
+
ns = int(rmax / step); t = (torch.arange(1, ns + 1, device=dev, dtype=torch.float32) * step)
|
|
74
|
+
P = len(pts); fh = torch.empty(P, n_rays, device=dev) # first-hit distance
|
|
75
|
+
for s in range(0, P, 2048):
|
|
76
|
+
p = pts[s:s+2048] # [c,3]
|
|
77
|
+
q = p[:, None, None, :] + rays[None, :, None, :] * t[None, None, :, None] # [c,R,S,3]
|
|
78
|
+
idx = ((q - lo) / cell).round().long()
|
|
79
|
+
idx = torch.minimum(torch.maximum(idx, torch.zeros_like(idx)), torch.tensor(grid.shape, device=dev) - 1)
|
|
80
|
+
occ = grid[idx[..., 0], idx[..., 1], idx[..., 2]] # [c,R,S]
|
|
81
|
+
first = torch.where(occ.any(-1), occ.float().argmax(-1), torch.full_like(occ[..., 0], ns, dtype=torch.long))
|
|
82
|
+
fh[s:s+2048] = torch.where(first >= ns, torch.full_like(first, rmax * 2, dtype=torch.float32), t[first.clamp(max=ns - 1)])
|
|
83
|
+
cosang = nrm @ rays.T # [P,R]
|
|
84
|
+
f = []
|
|
85
|
+
for rng in (4.0, 8.0, 12.0, 16.0):
|
|
86
|
+
b = (fh <= rng).float(); f.append(b.mean(1))
|
|
87
|
+
for cth in (0.866, 0.5, 0.0): # cones 30/60/90 deg about normal
|
|
88
|
+
m = (cosang >= cth).float(); f.append((b * m).sum(1) / m.sum(1).clamp(min=1))
|
|
89
|
+
f.append(fh.clamp(max=rmax).mean(1) / rmax)
|
|
90
|
+
f.append(fh.clamp(max=rmax).min(1).values / rmax)
|
|
91
|
+
# opposite-hemisphere enclosure (pocket depth): distance to first hit along -normal cone
|
|
92
|
+
m = (cosang <= -0.5).float(); f.append(((fh.clamp(max=rmax) * m).sum(1) / m.sum(1).clamp(min=1)) / rmax)
|
|
93
|
+
# shell counts by element class
|
|
94
|
+
d = torch.cdist(pts, xyz); eo = torch.nn.functional.one_hot(el, 6).float() # [N,6]
|
|
95
|
+
for lo_, hi_ in ((0, 4.0), (4.0, 6.0), (6.0, 8.0), (8.0, 12.0)):
|
|
96
|
+
w = ((d >= lo_) & (d < hi_)).float(); f.append((w.sum(1, keepdim=True) / 30.0)[:, 0])
|
|
97
|
+
c = w @ eo; f.extend([c[:, k] / 12.0 for k in range(4)]) # C N O S
|
|
98
|
+
return torch.stack(f, 1) # [P, ~32]
|
|
99
|
+
N_PF = 4 * 4 + 3 + 4 * 5
|
|
100
|
+
|
|
101
|
+
def knn(x, y, k, chunk=2048):
|
|
102
|
+
"""for each row of x the k nearest rows of y -> (dist [n,k], idx [n,k])"""
|
|
103
|
+
ds, ix = [], []
|
|
104
|
+
for s in range(0, len(x), chunk):
|
|
105
|
+
d = torch.cdist(x[s:s+chunk], y); v, i = d.topk(min(k, y.shape[0]), largest=False); ds.append(v); ix.append(i)
|
|
106
|
+
return torch.cat(ds), torch.cat(ix)
|
pocketnet/model.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import math, torch, torch.nn as nn, torch.nn.functional as F
|
|
2
|
+
from . import feat
|
|
3
|
+
|
|
4
|
+
class RBF(nn.Module):
|
|
5
|
+
def __init__(self, n, lo, hi):
|
|
6
|
+
super().__init__(); self.register_buffer('mu', torch.linspace(lo, hi, n)); self.g = (hi - lo) / n
|
|
7
|
+
def forward(self, d): return torch.exp(-((d[..., None] - self.mu) / self.g) ** 2)
|
|
8
|
+
|
|
9
|
+
class Layer(nn.Module):
|
|
10
|
+
def __init__(self, h, ne):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self.msg = nn.Sequential(nn.Linear(2 * h + ne, h), nn.SiLU(), nn.Linear(h, h))
|
|
13
|
+
self.gate = nn.Linear(2 * h + ne, h)
|
|
14
|
+
self.upd = nn.Sequential(nn.Linear(2 * h, h), nn.SiLU(), nn.Linear(h, h))
|
|
15
|
+
self.n1 = nn.LayerNorm(h)
|
|
16
|
+
def forward(self, h, ei, ea):
|
|
17
|
+
i, j = ei; z = torch.cat([h[i], h[j], ea], 1)
|
|
18
|
+
m = self.msg(z) * torch.sigmoid(self.gate(z))
|
|
19
|
+
agg = torch.zeros_like(h, dtype=m.dtype).index_add_(0, i, m) / 12.0
|
|
20
|
+
return self.n1(h + self.upd(torch.cat([h, agg], 1)))
|
|
21
|
+
|
|
22
|
+
class PocketNet(nn.Module):
|
|
23
|
+
def __init__(self, h=128, layers=6, k_atom=24, k_pt=32, r_atom=6.0, r_pt=10.0, drop=0.1):
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.k_atom, self.k_pt, self.r_atom, self.r_pt = k_atom, k_pt, r_atom, r_pt
|
|
26
|
+
self.emb = nn.Embedding(feat.N_TYPES, h); self.bur = nn.Linear(4, h)
|
|
27
|
+
self.rbf = RBF(16, 0, r_atom); self.layers = nn.ModuleList([Layer(h, 16) for _ in range(layers)])
|
|
28
|
+
self.atom_head = nn.Linear(h, 1)
|
|
29
|
+
hp = 64
|
|
30
|
+
self.proj = nn.Linear(h, hp)
|
|
31
|
+
self.rax = RBF(12, -r_pt, r_pt); self.rrad = RBF(10, 0, r_pt)
|
|
32
|
+
self.att = nn.Sequential(nn.Linear(22 + hp, 128), nn.SiLU(), nn.Linear(128, 4)) # 4 attention heads
|
|
33
|
+
self.val = nn.Linear(hp + 22, hp)
|
|
34
|
+
self.pfn = nn.Sequential(nn.Linear(feat.N_PF, 128), nn.SiLU(), nn.Linear(128, 128))
|
|
35
|
+
self.out = nn.Sequential(nn.Linear(4 * hp + 128 + hp, 256), nn.SiLU(), nn.Dropout(drop), nn.Linear(256, 128), nn.SiLU(), nn.Linear(128, 1))
|
|
36
|
+
|
|
37
|
+
@torch.no_grad()
|
|
38
|
+
def graph(self, ty, xyz, pts, pf):
|
|
39
|
+
"""Per-protein geometry: everything that does not need gradients."""
|
|
40
|
+
d, idx = feat.knn(xyz, xyz, self.k_atom + 1)
|
|
41
|
+
d, idx = d[:, 1:], idx[:, 1:]; m = d < self.r_atom
|
|
42
|
+
i = torch.arange(len(xyz), device=xyz.device)[:, None].expand_as(idx)
|
|
43
|
+
cnt = torch.stack([(d < 4).sum(1), (d < 6).sum(1), (d < 3).sum(1), (d < 5).sum(1)], 1).float() / 24.0
|
|
44
|
+
d1, i1 = feat.knn(pts, xyz, 1); nrm = pts - xyz[i1[:, 0]]; nrm = nrm / nrm.norm(dim=1, keepdim=True).clamp(min=1e-6)
|
|
45
|
+
dp, ip = feat.knn(pts, xyz, self.k_pt)
|
|
46
|
+
rel = xyz[ip] - pts[:, None, :]
|
|
47
|
+
ax = (rel * nrm[:, None, :]).sum(-1); rad = (dp ** 2 - ax ** 2).clamp(min=0).sqrt()
|
|
48
|
+
return dict(ty=ty, cnt=cnt, ei=torch.stack([i[m], idx[m]]), d=d[m], i1=i1[:, 0], ip=ip, ax=ax, rad=rad, dp=dp, pf=pf, na=len(xyz), npt=len(pts))
|
|
49
|
+
|
|
50
|
+
def collate(self, gs):
|
|
51
|
+
off = 0; ei = []; i1 = []; ip = []; o = 0
|
|
52
|
+
for g in gs:
|
|
53
|
+
ei.append(g['ei'] + off); i1.append(g['i1'] + off); ip.append(g['ip'] + off); off += g['na']
|
|
54
|
+
cat = lambda k: torch.cat([g[k] for g in gs])
|
|
55
|
+
return dict(ty=cat('ty'), cnt=cat('cnt'), ei=torch.cat(ei, 1), d=cat('d'), i1=torch.cat(i1), ip=torch.cat(ip), ax=cat('ax'), rad=cat('rad'),
|
|
56
|
+
dp=cat('dp'), pf=cat('pf'), sizes=[g['npt'] for g in gs])
|
|
57
|
+
|
|
58
|
+
def forward_graph(self, g):
|
|
59
|
+
h = self.emb(g['ty']) + self.bur(g['cnt']); ea = self.rbf(g['d'])
|
|
60
|
+
for l in self.layers: h = l(h, g['ei'], ea)
|
|
61
|
+
a_logit = self.atom_head(h)[:, 0]; hp = self.proj(h)
|
|
62
|
+
ge = torch.cat([self.rax(g['ax']), self.rrad(g['rad'])], -1)
|
|
63
|
+
m = g['dp'] < self.r_pt; hj = hp[g['ip']]
|
|
64
|
+
a = self.att(torch.cat([ge, hj], -1)).masked_fill(~m[..., None], -1e4).softmax(1)
|
|
65
|
+
v = self.val(torch.cat([hj, ge], -1))
|
|
66
|
+
pooled = torch.einsum('pkh,pkv->phv', a, v).reshape(len(g['pf']), -1)
|
|
67
|
+
z = torch.cat([pooled, self.pfn(g['pf']), hp[g['i1']]], 1)
|
|
68
|
+
return self.out(z)[:, 0], a_logit
|
|
69
|
+
|
|
70
|
+
def forward(self, ty, xyz, pts, pf):
|
|
71
|
+
return self.forward_graph(self.collate([self.graph(ty, xyz, pts, pf)]))
|
pocketnet/pdbio.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from . import feat
|
|
3
|
+
def read_pdb(path, lig_codes=None):
|
|
4
|
+
"""-> dict(res,name,rid,chain,xyz) of protein heavy atoms (first model, altloc A/blank) and list of ligand coord arrays."""
|
|
5
|
+
prot, ligs = [], {}
|
|
6
|
+
with open(path) as f:
|
|
7
|
+
for ln in f:
|
|
8
|
+
if ln.startswith('ENDMDL'): break
|
|
9
|
+
rec = ln[:6]
|
|
10
|
+
if rec not in ('ATOM ', 'HETATM'): continue
|
|
11
|
+
if ln[16] not in ' A1': continue
|
|
12
|
+
el = (ln[76:78].strip() or ln[12:16].strip()[:1]).upper()
|
|
13
|
+
if el in ('H', 'D'): continue
|
|
14
|
+
res = ln[17:20].strip(); xyz = (float(ln[30:38]), float(ln[38:46]), float(ln[46:54]))
|
|
15
|
+
if rec == 'ATOM ' or (res == 'MSE'):
|
|
16
|
+
prot.append((ln[12:16].strip(), res, int(ln[22:26]) if ln[22:26].strip().lstrip('-').isdigit() else 0, ln[21], el, *xyz))
|
|
17
|
+
elif lig_codes and res in lig_codes:
|
|
18
|
+
ligs.setdefault((res, ln[21], ln[22:27]), []).append(xyz)
|
|
19
|
+
P = dict(name=np.array([p[0] for p in prot]), res=np.array([p[1] for p in prot]), rid=np.array([p[2] for p in prot]),
|
|
20
|
+
chain=np.array([p[3] for p in prot]), xyz=np.array([p[5:8] for p in prot], np.float32))
|
|
21
|
+
return P, [np.array(v, np.float32) for v in ligs.values()]
|
pocketnet/pockets.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Turn per-point scores into ranked pockets; DCA/DCC metrics (P2Rank convention)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.cluster.hierarchy import linkage, fcluster
|
|
4
|
+
|
|
5
|
+
def extract(pts, s, thr_q=0.97, link=5.0, topn=10, max_pts=600, method='average', top_k=12, min_pts=3):
|
|
6
|
+
"""pts [P,3] numpy, s [P] scores -> list of (score, center[3]) sorted desc."""
|
|
7
|
+
if len(pts) < 3: return []
|
|
8
|
+
k = max(min_pts, min(max_pts, int(round(len(pts) * (1 - thr_q)))))
|
|
9
|
+
sel = np.argsort(-s)[:k]; p, v = pts[sel], s[sel]
|
|
10
|
+
if len(p) < 2: return [(float(v.sum()), p[0])]
|
|
11
|
+
lab = fcluster(linkage(p, method), link, 'distance'); out = []
|
|
12
|
+
for c in np.unique(lab):
|
|
13
|
+
m = lab == c
|
|
14
|
+
if m.sum() < min_pts: continue
|
|
15
|
+
vc, pc = v[m], p[m]; o = np.argsort(-vc)[:top_k]
|
|
16
|
+
w = vc[o] - vc.min() + 1e-6 if False else np.maximum(vc[o], 1e-6)
|
|
17
|
+
out.append((float(vc[o].sum()), (pc[o] * w[:, None]).sum(0) / w.sum()))
|
|
18
|
+
out.sort(key=lambda t: -t[0]); return out[:topn]
|
|
19
|
+
|
|
20
|
+
def evaluate_one(pockets, lig, n_lig=1, thr=4.0):
|
|
21
|
+
"""DCA success@n, @n+2 and DCC; pockets list of (score, center); lig [A,3]."""
|
|
22
|
+
if not pockets: return dict(top1=0, top3=0, topany=0, dcc1=0, dist=np.inf, found=0)
|
|
23
|
+
c = np.stack([p[1] for p in pockets]); d = np.linalg.norm(c[:, None, :] - lig[None], axis=-1).min(1)
|
|
24
|
+
dcc = np.linalg.norm(c - lig.mean(0), axis=-1)
|
|
25
|
+
return dict(top1=float(d[:n_lig].min() <= thr), top3=float(d[:n_lig + 2].min() <= thr), topany=float(d.min() <= thr),
|
|
26
|
+
dcc1=float(dcc[:n_lig].min() <= thr), dist=float(d[0]), found=len(pockets))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TUNED = dict(thr_q=0.97, link=6.5, max_pts=300, top_k=24)
|
|
30
|
+
|
|
31
|
+
def extract_with_members(pts, s, thr_q=0.97, link=6.5, topn=10, max_pts=300, method='average', top_k=24, min_pts=3):
|
|
32
|
+
"""Same clustering as `extract`, but also returns each pocket's member point indices (into pts).
|
|
33
|
+
-> list of dict(score, center[3], idx[int array], top_idx[int array])."""
|
|
34
|
+
if len(pts) < 3: return []
|
|
35
|
+
k = max(min_pts, min(max_pts, int(round(len(pts) * (1 - thr_q)))))
|
|
36
|
+
sel = np.argsort(-s)[:k]; p, v = pts[sel], s[sel]
|
|
37
|
+
if len(p) < 2: return [dict(score=float(v.sum()), center=p[0], idx=sel, top_idx=sel)]
|
|
38
|
+
lab = fcluster(linkage(p, method), link, 'distance'); out = []
|
|
39
|
+
for c in np.unique(lab):
|
|
40
|
+
m = lab == c
|
|
41
|
+
if m.sum() < min_pts: continue
|
|
42
|
+
vc, pc, ic = v[m], p[m], sel[m]; o = np.argsort(-vc)[:top_k]
|
|
43
|
+
w = np.maximum(vc[o], 1e-6)
|
|
44
|
+
out.append(dict(score=float(vc[o].sum()), center=(pc[o] * w[:, None]).sum(0) / w.sum(), idx=ic, top_idx=ic[o]))
|
|
45
|
+
out.sort(key=lambda t: -t['score']); return out[:topn]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pocketnet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ligand-binding pocket prediction from protein structures with a message-passing / attention-pooling network (five-model ensemble, weights included).
|
|
5
|
+
Author: Stalin Arulsamy, Kiruthiga Natarajan
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/precisionmatics/pocketnet
|
|
8
|
+
Project-URL: Issues, https://github.com/precisionmatics/pocketnet/issues
|
|
9
|
+
Keywords: binding site prediction,drug discovery,protein structure,graph neural network,bioinformatics
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.23
|
|
20
|
+
Requires-Dist: scipy>=1.9
|
|
21
|
+
Requires-Dist: torch>=2.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# PocketNet
|
|
27
|
+
|
|
28
|
+
Ligand-binding pocket prediction from a protein structure. A gated message-passing network over the atom graph feeds an
|
|
29
|
+
attention-pooling read-out over the 32 nearest atoms of each solvent-accessible surface point; surface points are scored,
|
|
30
|
+
clustered and ranked into pockets. The package bundles a five-model ensemble (about 20 MB of weights); no download is needed at run time.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install pocketnet
|
|
34
|
+
pocketnet predict protein.pdb -o results/
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pocketnet import PocketPredictor
|
|
39
|
+
|
|
40
|
+
pred = PocketPredictor() # uses the GPU if available, otherwise CPU
|
|
41
|
+
for p in pred.predict("protein.pdb"): # at most 10 pockets, best first
|
|
42
|
+
print(p["rank"], p["score"], p["probability"], p["center"], p["residues"][:5])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each pocket has `rank`, `score` (sum of its 24 highest surface-point scores), `probability` (isotonic calibration of that score on held-out
|
|
46
|
+
proteins; indicative only), `center` (x, y, z in angstrom), `n_points` and `residues` (`chain:number` within 8 A of the centre).
|
|
47
|
+
The CLI writes `<input>_pockets.csv` and `.json`. A run takes about 1 s per protein on a GPU and a few seconds on a CPU.
|
|
48
|
+
|
|
49
|
+
## Input
|
|
50
|
+
PDB-format files with `ATOM` records (standard residues; hydrogens and alternate locations other than A are ignored, first model only).
|
|
51
|
+
mmCIF is not supported. Structures above about 14,000 atoms were not seen in training.
|
|
52
|
+
|
|
53
|
+
## Performance (summary)
|
|
54
|
+
Evaluated with P2Rank's ligand definitions and a 4 A distance-to-any-ligand-atom criterion, all methods capped at 10 pockets, on COACH420, HOLO4K
|
|
55
|
+
and Chen11 after sequence- and structure-identity filtering against the training set. PocketNet is on par with P2Rank for the first-ranked pocket,
|
|
56
|
+
ahead of P2Rank at larger pocket budgets, and behind GrASP at top-n on some sets. Full tables, confidence intervals, McNemar tests and caveats are in the manuscript.
|
|
57
|
+
|
|
58
|
+
Limitations: PUResNet was run through our own wrapper; VN-EGNN, EquiPocket, DeepSurf and Kalasanty were not benchmarked; the model was trained on
|
|
59
|
+
PLAS-20K complexes (simulation-derived protein-ligand structures), so performance on very different targets (membrane proteins, large assemblies, cryptic sites) is untested.
|
|
60
|
+
|
|
61
|
+
## Repository layout
|
|
62
|
+
`pocketnet/` package and weights, `tests/`, `examples/3POZ.pdb`, `training/` (data preparation and training), `benchmarks/` (scoring on the P2Rank datasets).
|
|
63
|
+
|
|
64
|
+
## Citation
|
|
65
|
+
Arulsamy S, Natarajan K. PocketNet (manuscript in preparation). Please check the repository for an updated citation.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
pocketnet/__init__.py,sha256=wZPZeHbjtOzI5j8HoXrKVpH5b5Md2h8kiWhAC6hu3rw,309
|
|
2
|
+
pocketnet/api.py,sha256=s1W-RBPWsDuuX7XI5i5FtmROfqhyBQFOUnnfa4MUoZE,4330
|
|
3
|
+
pocketnet/calibration.json,sha256=RQR-rxoujGHm-u671pF65N5y4FeF6_VokAT2S-ApNzI,796
|
|
4
|
+
pocketnet/cli.py,sha256=KWXpEEcAquYVArLJDnLIV_RmMmdgg59C6hGQvWle5uY,1873
|
|
5
|
+
pocketnet/feat.py,sha256=tEBT3lmap906eRkznDybC98F5XrST9z4yNLpTtVTgG4,6703
|
|
6
|
+
pocketnet/model.py,sha256=XROG-ZqY4pPdkpRwjiInAhGcOcke6MLQQvYKXk09yNQ,4079
|
|
7
|
+
pocketnet/pdbio.py,sha256=5NS6Af553HrK57HfnPpb4wmS2ouPAJ4yFOj22O2kDlw,1253
|
|
8
|
+
pocketnet/pockets.py,sha256=hghRxeIJ-WfXx7TISMFvbqcU0RGtVQQbDLhYpLczaIc,2609
|
|
9
|
+
pocketnet/weights/v3_fold0.pt,sha256=V3OoMWGlY-ZxqHYO4P9iTkwtdiLL2s-JRBjsuqq4GyY,4179301
|
|
10
|
+
pocketnet/weights/v3_fold1.pt,sha256=7PrBR_Kw0aEZ8KblYL_Qw_47vdizqOiWf367G56iG-0,4179301
|
|
11
|
+
pocketnet/weights/v3_fold2.pt,sha256=FrUadMugGKFuB8JT6HJT4dTER6HRJ1OLNLrwmaTvTWo,4179301
|
|
12
|
+
pocketnet/weights/v3_fold3.pt,sha256=l2F_VKuvYurAXtuvQem45sz4wLn92JOppCzGocXQBjw,4179301
|
|
13
|
+
pocketnet/weights/v3_fold4.pt,sha256=41RXCp2XMm7LU92nHu1Me0B0Avi7r87Gc30_wSEpb8c,4179301
|
|
14
|
+
pocketnet-0.1.0.dist-info/licenses/LICENSE,sha256=gZKJ2nJt23zP1Aa5tSgJ2SJUJ-WJCZXK6uykw-oTceY,1096
|
|
15
|
+
pocketnet-0.1.0.dist-info/METADATA,sha256=BDtGpeWnZdZdNiFaSSsIH5HVQxvprHjESPsGS3AjpQk,3605
|
|
16
|
+
pocketnet-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
pocketnet-0.1.0.dist-info/entry_points.txt,sha256=KxHWO3hWkLewIoFz0GjDcNttjLpTMEE7EyUU7Kl6aHo,49
|
|
18
|
+
pocketnet-0.1.0.dist-info/top_level.txt,sha256=NO2AV5ueTNUKCjUZXO2Y5ckyLHQGM7CYpomhxSzOQ6U,10
|
|
19
|
+
pocketnet-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stalin Arulsamy and Kiruthiga Natarajan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pocketnet
|