GPUburnout commited on
Commit
979ea1d
·
verified ·
1 Parent(s): 9237b3c

Add SASA exposure gating of liability flags

Browse files
Files changed (2) hide show
  1. Dockerfile +3 -1
  2. app.py +121 -24
Dockerfile CHANGED
@@ -12,9 +12,11 @@ FROM condaforge/miniforge3:latest
12
  # ---- conda-only deps (pin python to mirror the verified env) --------------
13
  # anarci + hmmer -> bioconda (IMGT numbering; hmmer is ANARCI's engine)
14
  # openmm + pdbfixer -> conda-forge (ABodyBuilder2 structure refinement)
 
 
15
  RUN mamba install -y -n base \
16
  -c conda-forge -c bioconda \
17
- python=3.13 anarci hmmer openmm pdbfixer && \
18
  mamba clean -afy
19
 
20
  # ---- pip deps -------------------------------------------------------------
 
12
  # ---- conda-only deps (pin python to mirror the verified env) --------------
13
  # anarci + hmmer -> bioconda (IMGT numbering; hmmer is ANARCI's engine)
14
  # openmm + pdbfixer -> conda-forge (ABodyBuilder2 structure refinement)
15
+ # biopython is used for Shrake-Rupley SASA (exposure gating of liability flags);
16
+ # it also comes in via anarci, but pin it explicitly since the app now needs it.
17
  RUN mamba install -y -n base \
18
  -c conda-forge -c bioconda \
19
+ python=3.13 anarci hmmer openmm pdbfixer biopython && \
20
  mamba clean -afy
21
 
22
  # ---- pip deps -------------------------------------------------------------
app.py CHANGED
@@ -16,6 +16,7 @@ The liability flags are HEURISTIC screens, not disqualifiers. MyAbs yields in-si
16
  CANDIDATES, not patent-ready antibodies — wet-lab validation still required.
17
  """
18
 
 
19
  import os
20
  import time
21
  import gradio as gr
@@ -28,6 +29,13 @@ try:
28
  except Exception:
29
  HAVE_ANARCI = False
30
 
 
 
 
 
 
 
 
31
  print("Loading ABodyBuilder2 ensemble...")
32
  PREDICTOR = ABodyBuilder2()
33
  print("Ready.")
@@ -105,8 +113,17 @@ LEGEND_HTML = (
105
  + "</div>"
106
  )
107
 
108
- SEV_ORDER = {"High": 0, "Medium": 1, "Low": 2}
109
- SEV_ICON = {"High": "🔴", "Medium": "🟠", "Low": "🟡"}
 
 
 
 
 
 
 
 
 
110
 
111
 
112
  def clean(seq: str) -> str:
@@ -141,6 +158,54 @@ def cdr_of(imgt: int):
141
  return None
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  # ---------------------------------------------------------------- liabilities
145
  def scan_chain(chain_label: str, residues):
146
  """Scan one numbered chain for sequence-liability motifs.
@@ -203,10 +268,12 @@ def scan_chain(chain_label: str, residues):
203
  return flags, highlights
204
 
205
 
206
- def developability(heavy: str, light: str):
207
- """Scan both chains + CDR-H3 length. Returns (flags, highlights_by_chain, h3_len, ok)."""
208
- all_flags = []
209
- highlights = {"H": [], "L": []}
 
 
210
  h3_len = None
211
  ok = True
212
  for label, seq in (("H", heavy), ("L", light)):
@@ -214,15 +281,29 @@ def developability(heavy: str, light: str):
214
  if residues is None:
215
  ok = False
216
  continue
217
- flags, hi = scan_chain(label, residues)
218
- all_flags += flags
219
- highlights[label] += hi
220
  if label == "H":
221
  h3_len = sum(1 for (imgt, _ins, _aa) in residues if 105 <= imgt <= 117)
222
  if h3_len >= 18:
223
- all_flags.append(("High", "H", 105,
224
  f"Long CDR-H3 ({h3_len} aa) — aggregation risk", "CDR-H3"))
225
- return all_flags, highlights, h3_len, ok
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
 
228
  def format_flags(flags, h3_len, ok):
@@ -235,27 +316,43 @@ def format_flags(flags, h3_len, ok):
235
  "No glycosylation sequons, PTM hotspots, or free cysteines found in the CDRs "
236
  "or framework"
237
  + (f", and CDR-H3 length is normal ({h3_len} aa)" if h3_len else "")
238
- + ".\n\n_Structure-based hydrophobic-patch scoring is the next add._")
239
 
240
  flags_sorted = sorted(flags, key=lambda f: (SEV_ORDER[f[0]], f[1], f[2]))
241
  highs = sum(1 for f in flags if f[0] == "High")
242
  meds = sum(1 for f in flags if f[0] == "Medium")
243
  lows = sum(1 for f in flags if f[0] == "Low")
 
 
 
 
 
 
 
244
 
 
 
 
245
  lines = [
246
- f"### Developability flags — {highs} high · {meds} medium · {lows} low",
247
- "Flagged residues are drawn as **magenta sticks** on the structure.",
248
- "",
249
- "| Severity | Chain | IMGT | Location | Liability |",
250
- "|---|---|---|---|---|",
251
- ]
252
- for sev, chain, imgt, desc, loc in flags_sorted:
253
- lines.append(f"| {SEV_ICON[sev]} {sev} | {chain} | {imgt} | {loc} | {desc} |")
254
- lines += [
255
  "",
256
- "_Heuristic sequence-liability flags, not disqualifiers. Liabilities in CDRs matter "
257
- "most (exposed, near the paratope). Confirm experimentally before acting._",
258
  ]
 
 
 
 
 
 
 
 
 
259
  return "\n".join(lines)
260
 
261
 
@@ -306,7 +403,7 @@ def fold(heavy: str, light: str):
306
  f"Error: {e}", "", None, "", {}, False, reset_btn)
307
 
308
  pdb = open("myabs_fold.pdb").read()
309
- flags, highlights, h3_len, ok = developability(heavy, light)
310
  viewer = render_structure(pdb, spin=False, highlights=highlights)
311
  status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
312
  f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")
 
16
  CANDIDATES, not patent-ready antibodies — wet-lab validation still required.
17
  """
18
 
19
+ import io
20
  import os
21
  import time
22
  import gradio as gr
 
29
  except Exception:
30
  HAVE_ANARCI = False
31
 
32
+ try:
33
+ from Bio.PDB import PDBParser
34
+ from Bio.PDB.SASA import ShrakeRupley
35
+ HAVE_SASA = True
36
+ except Exception:
37
+ HAVE_SASA = False
38
+
39
  print("Loading ABodyBuilder2 ensemble...")
40
  PREDICTOR = ABodyBuilder2()
41
  print("Ready.")
 
113
  + "</div>"
114
  )
115
 
116
+ SEV_ORDER = {"High": 0, "Medium": 1, "Low": 2, "Minimal": 3}
117
+ SEV_ICON = {"High": "🔴", "Medium": "🟠", "Low": "🟡", "Minimal": "⚪"}
118
+ SEV_LEVELS = ["High", "Medium", "Low", "Minimal"]
119
+
120
+ # Tien et al. 2013 theoretical max ASA (Ų), for relative solvent accessibility.
121
+ MAXASA = {
122
+ "ALA": 129, "ARG": 274, "ASN": 195, "ASP": 193, "CYS": 167, "GLU": 223,
123
+ "GLN": 225, "GLY": 104, "HIS": 224, "ILE": 197, "LEU": 201, "LYS": 236,
124
+ "MET": 224, "PHE": 240, "PRO": 159, "SER": 155, "THR": 172, "TRP": 285,
125
+ "TYR": 263, "VAL": 174,
126
+ }
127
 
128
 
129
  def clean(seq: str) -> str:
 
158
  return None
159
 
160
 
161
+ # -------------------------------------------------------------- accessibility
162
+ def rsa_map(pdb: str):
163
+ """Per-residue relative solvent accessibility from the folded Fv.
164
+ Returns {(chain_id, imgt_resseq): rsa} or None if SASA is unavailable.
165
+ Computed on the whole Fv, so the VH/VL interface counts as buried."""
166
+ if not HAVE_SASA or not pdb:
167
+ return None
168
+ try:
169
+ model = PDBParser(QUIET=True).get_structure("ab", io.StringIO(pdb))[0]
170
+ ShrakeRupley().compute(model, level="R") # sets .sasa on each residue
171
+ out = {}
172
+ for chain in model:
173
+ for res in chain:
174
+ if res.resname not in MAXASA:
175
+ continue
176
+ rsa = res.sasa / MAXASA[res.resname]
177
+ key = (chain.id, res.id[1]) # (chain, resseq); insertion code dropped
178
+ out[key] = max(out.get(key, 0.0), rsa) # keep max across insertions
179
+ return out
180
+ except Exception:
181
+ return None
182
+
183
+
184
+ def exposure_tier(rsa):
185
+ """buried (<15%), partial (15-30%), exposed (>=30%), or None if no RSA."""
186
+ if rsa is None:
187
+ return None
188
+ if rsa < 0.15:
189
+ return "buried"
190
+ if rsa < 0.30:
191
+ return "partial"
192
+ return "exposed"
193
+
194
+
195
+ def adjust_severity(raw_sev: str, tier, desc: str) -> str:
196
+ """Down-rank a sequence-motif flag by how buried the residue is: buried motifs
197
+ can't undergo solvent-driven chemistry (oxidation, deamidation, glycosylation).
198
+ A free cysteine is a covalent/structural concern beyond exposure, so it is
199
+ never down-ranked more than one level."""
200
+ if tier is None:
201
+ return raw_sev
202
+ steps = {"exposed": 0, "partial": 1, "buried": 2}[tier]
203
+ if "cysteine" in desc.lower():
204
+ steps = min(steps, 1)
205
+ i = min(SEV_LEVELS.index(raw_sev) + steps, len(SEV_LEVELS) - 1)
206
+ return SEV_LEVELS[i]
207
+
208
+
209
  # ---------------------------------------------------------------- liabilities
210
  def scan_chain(chain_label: str, residues):
211
  """Scan one numbered chain for sequence-liability motifs.
 
268
  return flags, highlights
269
 
270
 
271
+ def developability(heavy: str, light: str, pdb: str = None):
272
+ """Scan both chains + CDR-H3 length, then gate by solvent exposure using the
273
+ folded structure. Returns (flags, highlights_by_chain, h3_len, ok).
274
+ Each flag: (sev, chain, imgt, desc, loc, rsa, tier) where sev is the
275
+ exposure-adjusted severity and rsa/tier are None when no structure is given."""
276
+ raw_flags = []
277
  h3_len = None
278
  ok = True
279
  for label, seq in (("H", heavy), ("L", light)):
 
281
  if residues is None:
282
  ok = False
283
  continue
284
+ flags, _hi = scan_chain(label, residues)
285
+ raw_flags += flags
 
286
  if label == "H":
287
  h3_len = sum(1 for (imgt, _ins, _aa) in residues if 105 <= imgt <= 117)
288
  if h3_len >= 18:
289
+ raw_flags.append(("High", "H", 105,
290
  f"Long CDR-H3 ({h3_len} aa) — aggregation risk", "CDR-H3"))
291
+
292
+ # Exposure-gate each flag against the folded structure (if available).
293
+ rmap = rsa_map(pdb)
294
+ enriched = []
295
+ highlights = {"H": [], "L": []}
296
+ for sev, chain, imgt, desc, loc in raw_flags:
297
+ # CDR-H3 length is a whole-loop property, not single-residue exposure.
298
+ is_h3len = desc.startswith("Long CDR-H3")
299
+ rsa = None if is_h3len else (rmap.get((chain, imgt)) if rmap else None)
300
+ tier = exposure_tier(rsa)
301
+ adj = sev if is_h3len else adjust_severity(sev, tier, desc)
302
+ enriched.append((adj, chain, imgt, desc, loc, rsa, tier))
303
+ # Paint only residues whose flag survives exposure gating (High/Medium).
304
+ if adj in ("High", "Medium") and not is_h3len and chain in highlights:
305
+ highlights[chain].append(imgt)
306
+ return enriched, highlights, h3_len, ok
307
 
308
 
309
  def format_flags(flags, h3_len, ok):
 
316
  "No glycosylation sequons, PTM hotspots, or free cysteines found in the CDRs "
317
  "or framework"
318
  + (f", and CDR-H3 length is normal ({h3_len} aa)" if h3_len else "")
319
+ + ".")
320
 
321
  flags_sorted = sorted(flags, key=lambda f: (SEV_ORDER[f[0]], f[1], f[2]))
322
  highs = sum(1 for f in flags if f[0] == "High")
323
  meds = sum(1 for f in flags if f[0] == "Medium")
324
  lows = sum(1 for f in flags if f[0] == "Low")
325
+ mins = sum(1 for f in flags if f[0] == "Minimal")
326
+ have_exposure = any(f[6] is not None for f in flags)
327
+
328
+ def _exp_cell(rsa, tier):
329
+ if rsa is None:
330
+ return "—"
331
+ return f"{tier} ({rsa * 100:.0f}%)"
332
 
333
+ tally = f"{highs} high · {meds} medium · {lows} low"
334
+ if mins:
335
+ tally += f" · {mins} minimal"
336
  lines = [
337
+ f"### Developability flags — {tally}",
338
+ ("Severity is **adjusted by solvent exposure** from the fold: buried motifs are "
339
+ "down-ranked because they can't undergo solvent-driven chemistry. Surviving "
340
+ "(high/medium) liabilities are drawn as **magenta sticks** on the structure."
341
+ if have_exposure else
342
+ "Flagged residues are drawn as **magenta sticks** on the structure."),
 
 
 
343
  "",
344
+ "| Severity | Exposure | Chain | IMGT | Location | Liability |",
345
+ "|---|---|---|---|---|---|",
346
  ]
347
+ for sev, chain, imgt, desc, loc, rsa, tier in flags_sorted:
348
+ lines.append(
349
+ f"| {SEV_ICON[sev]} {sev} | {_exp_cell(rsa, tier)} | {chain} | {imgt} | {loc} | {desc} |"
350
+ )
351
+ footer = ("_Heuristic screens, not disqualifiers. Exposure (relative solvent accessibility) "
352
+ "sharpens the ranking but is not the whole story: an exposed motif can still be "
353
+ "fine (far from the paratope, slow kinetics, controlled by formulation). Confirm "
354
+ "experimentally before acting._")
355
+ lines += ["", footer]
356
  return "\n".join(lines)
357
 
358
 
 
403
  f"Error: {e}", "", None, "", {}, False, reset_btn)
404
 
405
  pdb = open("myabs_fold.pdb").read()
406
+ flags, highlights, h3_len, ok = developability(heavy, light, pdb)
407
  viewer = render_structure(pdb, spin=False, highlights=highlights)
408
  status = (f"Folded in {dt:.1f} s · VH {len(heavy)} aa / VL {len(light)} aa · "
409
  f"CDRs highlighted (H: yellow/orange/red, L: cyan/blue).")