pocketnet 0.1.0__tar.gz

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,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
+ include LICENSE README.md
@@ -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,43 @@
1
+ # PocketNet
2
+
3
+ Ligand-binding pocket prediction from a protein structure. A gated message-passing network over the atom graph feeds an
4
+ attention-pooling read-out over the 32 nearest atoms of each solvent-accessible surface point; surface points are scored,
5
+ clustered and ranked into pockets. The package bundles a five-model ensemble (about 20 MB of weights); no download is needed at run time.
6
+
7
+ ```bash
8
+ pip install pocketnet
9
+ pocketnet predict protein.pdb -o results/
10
+ ```
11
+
12
+ ```python
13
+ from pocketnet import PocketPredictor
14
+
15
+ pred = PocketPredictor() # uses the GPU if available, otherwise CPU
16
+ for p in pred.predict("protein.pdb"): # at most 10 pockets, best first
17
+ print(p["rank"], p["score"], p["probability"], p["center"], p["residues"][:5])
18
+ ```
19
+
20
+ Each pocket has `rank`, `score` (sum of its 24 highest surface-point scores), `probability` (isotonic calibration of that score on held-out
21
+ proteins; indicative only), `center` (x, y, z in angstrom), `n_points` and `residues` (`chain:number` within 8 A of the centre).
22
+ 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.
23
+
24
+ ## Input
25
+ PDB-format files with `ATOM` records (standard residues; hydrogens and alternate locations other than A are ignored, first model only).
26
+ mmCIF is not supported. Structures above about 14,000 atoms were not seen in training.
27
+
28
+ ## Performance (summary)
29
+ 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
30
+ and Chen11 after sequence- and structure-identity filtering against the training set. PocketNet is on par with P2Rank for the first-ranked pocket,
31
+ 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.
32
+
33
+ Limitations: PUResNet was run through our own wrapper; VN-EGNN, EquiPocket, DeepSurf and Kalasanty were not benchmarked; the model was trained on
34
+ PLAS-20K complexes (simulation-derived protein-ligand structures), so performance on very different targets (membrane proteins, large assemblies, cryptic sites) is untested.
35
+
36
+ ## Repository layout
37
+ `pocketnet/` package and weights, `tests/`, `examples/3POZ.pdb`, `training/` (data preparation and training), `benchmarks/` (scoring on the P2Rank datasets).
38
+
39
+ ## Citation
40
+ Arulsamy S, Natarajan K. PocketNet (manuscript in preparation). Please check the repository for an updated citation.
41
+
42
+ ## License
43
+ MIT. See `LICENSE`.
@@ -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
@@ -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}}
@@ -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())
@@ -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)
@@ -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)]))
@@ -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()]
@@ -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]
@@ -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,24 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ pocketnet/__init__.py
6
+ pocketnet/api.py
7
+ pocketnet/calibration.json
8
+ pocketnet/cli.py
9
+ pocketnet/feat.py
10
+ pocketnet/model.py
11
+ pocketnet/pdbio.py
12
+ pocketnet/pockets.py
13
+ pocketnet.egg-info/PKG-INFO
14
+ pocketnet.egg-info/SOURCES.txt
15
+ pocketnet.egg-info/dependency_links.txt
16
+ pocketnet.egg-info/entry_points.txt
17
+ pocketnet.egg-info/requires.txt
18
+ pocketnet.egg-info/top_level.txt
19
+ pocketnet/weights/v3_fold0.pt
20
+ pocketnet/weights/v3_fold1.pt
21
+ pocketnet/weights/v3_fold2.pt
22
+ pocketnet/weights/v3_fold3.pt
23
+ pocketnet/weights/v3_fold4.pt
24
+ tests/test_predict.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pocketnet = pocketnet.cli:main
@@ -0,0 +1,6 @@
1
+ numpy>=1.23
2
+ scipy>=1.9
3
+ torch>=2.0
4
+
5
+ [test]
6
+ pytest
@@ -0,0 +1 @@
1
+ pocketnet
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pocketnet"
7
+ version = "0.1.0"
8
+ description = "Ligand-binding pocket prediction from protein structures with a message-passing / attention-pooling network (five-model ensemble, weights included)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Stalin Arulsamy" }, { name = "Kiruthiga Natarajan" }]
13
+ keywords = ["binding site prediction", "drug discovery", "protein structure", "graph neural network", "bioinformatics"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Chemistry",
17
+ ]
18
+ dependencies = ["numpy>=1.23", "scipy>=1.9", "torch>=2.0"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/precisionmatics/pocketnet"
22
+ Issues = "https://github.com/precisionmatics/pocketnet/issues"
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest"]
26
+
27
+ [project.scripts]
28
+ pocketnet = "pocketnet.cli:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["pocketnet"]
32
+
33
+ [tool.setuptools.package-data]
34
+ pocketnet = ["weights/*.pt", "calibration.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ import json
2
+ from pathlib import Path
3
+ import numpy as np
4
+ from pocketnet import PocketPredictor
5
+
6
+ EXAMPLE = Path(__file__).resolve().parents[1] / "examples" / "3POZ.pdb"
7
+
8
+
9
+ def test_predict_example_on_cpu():
10
+ res = PocketPredictor(device="cpu").predict(EXAMPLE)
11
+ assert 1 <= len(res) <= 10
12
+ assert [r["rank"] for r in res] == list(range(1, len(res) + 1))
13
+ assert all(res[i]["score"] >= res[i + 1]["score"] for i in range(len(res) - 1))
14
+ assert all(0.0 <= r["probability"] <= 1.0 for r in res)
15
+ # the ATP-site pocket of the EGFR kinase domain (ligand TAK-285) is within 4 A of the first pocket centre
16
+ lig = np.array([[float(l[30:38]), float(l[38:46]), float(l[46:54])] for l in EXAMPLE.read_text().splitlines() if l.startswith("HETATM") and l[17:20].strip() == "03P"])
17
+ if len(lig):
18
+ d = np.linalg.norm(lig - np.array(res[0]["center"]), axis=1).min()
19
+ assert d <= 4.0
20
+ json.dumps(res)