angepapa commited on
Commit
8ff0ede
·
1 Parent(s): c0f41a4

Skip unused ParaSurf points in Space inference

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -0
  2. app.py +1 -0
  3. parasurf_wrapper.py +224 -0
Dockerfile CHANGED
@@ -93,6 +93,7 @@ RUN pip install --no-cache-dir \
93
  # editing app.py rebuilds in a couple of minutes instead of re-downloading ~5 GB.
94
  COPY --chown=user app.py $APP/app.py
95
  COPY --chown=user examples $APP/examples
 
96
 
97
  # Declared late so toggling them does not invalidate the expensive layers above.
98
  # Gradio's version check and HF telemetry both make network calls during import,
 
93
  # editing app.py rebuilds in a couple of minutes instead of re-downloading ~5 GB.
94
  COPY --chown=user app.py $APP/app.py
95
  COPY --chown=user examples $APP/examples
96
+ COPY --chown=user parasurf_wrapper.py $APP/AntiSite/antisite/parasurf/parasurf_wrapper.py
97
 
98
  # Declared late so toggling them does not invalidate the expensive layers above.
99
  # Gradio's version check and HF telemetry both make network calls during import,
app.py CHANGED
@@ -187,6 +187,7 @@ def load_extractor():
187
  _LOAD_LOCK = threading.Lock()
188
 
189
 
 
190
  def warm_up() -> None:
191
  """Build the checkpoint, both PLMs and ParaSurf once, up front.
192
 
 
187
  _LOAD_LOCK = threading.Lock()
188
 
189
 
190
+ @functools.lru_cache(maxsize=1)
191
  def warm_up() -> None:
192
  """Build the checkpoint, both PLMs and ParaSurf once, up front.
193
 
parasurf_wrapper.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen ParaSurf extractor — exposes per-residue 256-d pooled surface features.
2
+
3
+ ParaSurf natively operates on surface points (one per heavy atom, produced by DMS). This
4
+ wrapper runs a forward pass over all surface points of an antibody and aggregates to the
5
+ residue level:
6
+
7
+ residue score = max over atoms' sigmoid(logit) # matches ParaSurf Eq. 1
8
+ residue feature = mean over atoms' 256-d pre-classifier vectors
9
+
10
+ Features are captured via a forward-pre-hook on the classifier layer, so we do not modify
11
+ ParaSurf's model code. Always frozen (requires_grad=False, eval mode).
12
+
13
+ Run ParaSurf once per antibody, cache the (res_ids, scores, features) to disk, then train
14
+ against the cache.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+
28
+ # Make ParaSurf importable without installing it.
29
+ _PARASURF_ROOT = Path(__file__).resolve().parents[2] / "ParaSurf"
30
+ if str(_PARASURF_ROOT) not in sys.path:
31
+ sys.path.insert(0, str(_PARASURF_ROOT))
32
+
33
+ from ParaSurf.model.ParaSurf_model import DilatedBottleneck, ResNet3D_Transformer # noqa: E402
34
+ from ParaSurf.train.features import KalasantyFeaturizer # noqa: E402
35
+ from ParaSurf.train.protein import Protein_pred # noqa: E402
36
+
37
+
38
+ FEATURE_DIM = 256 # post-GAP, pre-classifier
39
+
40
+
41
+ @dataclass
42
+ class ParaSurfOutput:
43
+ """Per-residue ParaSurf outputs for one antibody."""
44
+
45
+ res_ids: list[str] # "resnum_chain" (+ optional insertion code), PDB order of first appearance
46
+ scores: torch.Tensor # [N_residues] — max-aggregated sigmoid scores
47
+ features: torch.Tensor # [N_residues, 256] — mean-aggregated pre-classifier features
48
+
49
+
50
+ class ParaSurfExtractor(nn.Module):
51
+ """Frozen ParaSurf wrapper.
52
+
53
+ Loads the 3D ResNet + Transformer backbone, registers a pre-hook on the classifier to
54
+ capture 256-d features, and runs inference batched over an antibody's surface points.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ weights_path: str | os.PathLike,
60
+ device: str = "cuda",
61
+ grid_size: int = 41,
62
+ feature_channels: int = 22,
63
+ voxel_size: int = 1,
64
+ ):
65
+ super().__init__()
66
+ self.device = torch.device(device if torch.cuda.is_available() or device == "cpu" else "cpu")
67
+ self.grid_size = grid_size
68
+ self.feature_channels = feature_channels
69
+
70
+ model = ResNet3D_Transformer(
71
+ in_channels=feature_channels,
72
+ block=DilatedBottleneck,
73
+ num_blocks=[3, 4, 6, 3],
74
+ num_classes=1,
75
+ )
76
+ state = torch.load(str(weights_path), map_location=self.device, weights_only=True)
77
+ model.load_state_dict(state)
78
+ model.to(self.device).eval()
79
+ for p in model.parameters():
80
+ p.requires_grad_(False)
81
+ self.model = model
82
+
83
+ self.featurizer = KalasantyFeaturizer(grid_size, voxel_size)
84
+
85
+ # Forward-pre-hook on classifier captures the 256-d vector entering it.
86
+ # During eval, dropout is identity, so this is exactly the post-GAP feature.
87
+ self._feature_buffer: list[torch.Tensor] = []
88
+ self.model.classifier.register_forward_pre_hook(self._capture_features)
89
+
90
+ # Populated by compute() — kept around so callers (e.g. infer_3d) can run
91
+ # ParaSurf's binding-site extractor on the same Protein_pred instance.
92
+ self.last_prot = None
93
+ self.last_surf_file: Path | None = None
94
+
95
+ def _capture_features(self, _module, inputs):
96
+ self._feature_buffer.append(inputs[0].detach().cpu())
97
+
98
+ @torch.no_grad()
99
+ def compute(
100
+ self,
101
+ pdb_path: str | os.PathLike,
102
+ batch_size: int = 64,
103
+ add_forcefields: bool = True,
104
+ add_atom_radius_features: bool = True,
105
+ ) -> ParaSurfOutput:
106
+ """Run ParaSurf on one antibody PDB and return per-residue outputs.
107
+
108
+ Expects the PDB to already be cleaned (water/ions removed) as ParaSurf expects.
109
+ Creates a sibling directory for DMS surface files; leaves them on disk so repeat
110
+ calls skip the DMS step.
111
+ """
112
+ pdb_path = Path(pdb_path)
113
+
114
+ # DMS surface point generation + featurizer setup — reuse ParaSurf's pipeline.
115
+ prot = Protein_pred(str(pdb_path), save_path=str(pdb_path.parent))
116
+ self.featurizer.get_channels(prot.mol, add_forcefields, add_atom_radius_features)
117
+
118
+ # Map surf-point index -> is-atom-type (matches ParaSurf's blind_predict logic).
119
+ surf_file = next(p for p in Path(prot.save_path).iterdir() if "surfpoints" in p.name)
120
+ atom_type_mask: list[bool] = []
121
+ with surf_file.open() as f:
122
+ for line in f:
123
+ parts = line.split()
124
+ atom_type_mask.append(len(parts) > 6 and parts[6] == "A")
125
+
126
+ atom_mask_np = np.asarray(atom_type_mask, dtype=bool)
127
+ if atom_mask_np.shape[0] != len(prot.surf_points):
128
+ raise RuntimeError(
129
+ f"Surface-point count mismatch: file has {atom_mask_np.shape[0]} rows, "
130
+ f"but ParaSurf loaded {len(prot.surf_points)} points."
131
+ )
132
+
133
+ # AntiSite aggregates only atom-type surface points (one per heavy atom).
134
+ # Reentrant/contact points were previously run through the expensive voxel
135
+ # CNN and then discarded below. Select the retained points before feature
136
+ # construction instead; samples are independent in eval mode, so their
137
+ # scores and 256-d features are unchanged.
138
+ atom_point_indices = np.flatnonzero(atom_mask_np)
139
+ print(
140
+ f"ParaSurf: evaluating {len(atom_point_indices)}/{len(prot.surf_points)} "
141
+ f"atom-type surface points on {self.device} (batch={batch_size})"
142
+ )
143
+
144
+ # Forward in batches; the pre-hook captures features in lockstep with scores.
145
+ self._feature_buffer.clear()
146
+ scores_list: list[np.ndarray] = []
147
+ input_data = torch.zeros(
148
+ (batch_size, self.grid_size, self.grid_size, self.grid_size, self.feature_channels),
149
+ device=self.device,
150
+ )
151
+ n_points = len(atom_point_indices)
152
+ batch_cnt = 0
153
+ for point_idx in atom_point_indices:
154
+ p = prot.surf_points[point_idx]
155
+ n = prot.surf_normals[point_idx]
156
+ input_data[batch_cnt] = torch.tensor(
157
+ self.featurizer.grid_feats(p, n, prot.heavy_atom_coords),
158
+ device=self.device,
159
+ )
160
+ batch_cnt += 1
161
+ if batch_cnt == batch_size:
162
+ logits = self.model(input_data)
163
+ scores_list.append(torch.sigmoid(logits).cpu().numpy())
164
+ batch_cnt = 0
165
+ if batch_cnt > 0:
166
+ logits = self.model(input_data[:batch_cnt])
167
+ scores_list.append(torch.sigmoid(logits).cpu().numpy())
168
+
169
+ scores_all = np.concatenate(scores_list, axis=0).reshape(-1) # [n_points]
170
+ features_all = torch.cat(self._feature_buffer, dim=0) # [n_points, 256]
171
+ assert scores_all.shape[0] == n_points == features_all.shape[0], (
172
+ f"Shape mismatch: scores={scores_all.shape}, features={features_all.shape}, n_points={n_points}"
173
+ )
174
+
175
+ # Every computed sample is now an atom-type point, in the original order.
176
+ scores_atoms = scores_all # [n_heavy_atoms]
177
+ features_atoms = features_all # [n_heavy_atoms, 256]
178
+
179
+ # Map each atom to its residue. Keys follow ParaSurf's convention: "resnum_chain"
180
+ # (plus "_insertion" when present). Order = first appearance in the PDB.
181
+ res_id_per_atom: list[str] = []
182
+ with pdb_path.open() as f:
183
+ for line in f:
184
+ if line.startswith("ATOM") and line.split()[2][0] != "H":
185
+ chain_id = line[21]
186
+ resnum = line[22:26].strip()
187
+ insertion = line[26].strip()
188
+ rid = f"{resnum}_{chain_id}"
189
+ if insertion:
190
+ rid = f"{rid}_{insertion}"
191
+ res_id_per_atom.append(rid)
192
+
193
+ if len(res_id_per_atom) != scores_atoms.shape[0]:
194
+ raise RuntimeError(
195
+ f"Heavy-atom count mismatch: PDB has {len(res_id_per_atom)} heavy atoms, "
196
+ f"surface file has {scores_atoms.shape[0]} atom-type points."
197
+ )
198
+
199
+ # Aggregate per residue (preserve first-appearance order).
200
+ atom_idx_per_res: dict[str, list[int]] = {}
201
+ ordered_res_ids: list[str] = []
202
+ for i, rid in enumerate(res_id_per_atom):
203
+ if rid not in atom_idx_per_res:
204
+ atom_idx_per_res[rid] = []
205
+ ordered_res_ids.append(rid)
206
+ atom_idx_per_res[rid].append(i)
207
+
208
+ n_res = len(ordered_res_ids)
209
+ res_scores = torch.zeros(n_res)
210
+ res_features = torch.zeros(n_res, FEATURE_DIM)
211
+ for i, rid in enumerate(ordered_res_ids):
212
+ idxs = atom_idx_per_res[rid]
213
+ res_scores[i] = float(scores_atoms[idxs].max())
214
+ res_features[i] = features_atoms[idxs].mean(dim=0)
215
+
216
+ # Stash for downstream pocket extraction.
217
+ self.last_prot = prot
218
+ self.last_surf_file = Path(surf_file)
219
+
220
+ return ParaSurfOutput(
221
+ res_ids=ordered_res_ids,
222
+ scores=res_scores,
223
+ features=res_features,
224
+ )