Spaces:
Sleeping
Sleeping
Add SASA exposure gating of liability flags
Browse files- Dockerfile +3 -1
- 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
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
| 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,
|
| 218 |
-
|
| 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 |
-
|
| 224 |
f"Long CDR-H3 ({h3_len} aa) — aggregation risk", "CDR-H3"))
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
+ ".
|
| 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 — {
|
| 247 |
-
"
|
| 248 |
-
|
| 249 |
-
|
| 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 |
-
"
|
| 257 |
-
"
|
| 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).")
|