multimodalart HF Staff commited on
Commit
2719465
·
verified ·
1 Parent(s): 709348f

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +49 -13
  2. app.py +373 -208
  3. requirements.txt +1 -14
README.md CHANGED
@@ -4,25 +4,61 @@ emoji: 🧬
4
  colorFrom: gray
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 5.49.1
8
  app_file: app.py
9
- short_description: Fast protein-ligand binding affinity prediction (Nesso-1)
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
- pinned: false
 
 
 
 
 
 
13
  ---
14
 
15
- # Nesso-1 · Protein–Ligand Binding Affinity
16
 
17
- Interactive demo of [`recursionpharma/nesso`](https://huggingface.co/recursionpharma/nesso)
18
- (Nesso-1)a fast, structure-based protein–ligand binding-affinity prediction model from
19
- [Valence Labs](https://valencelabs.com) (a Recursion company).
20
 
21
- Provide a protein amino-acid sequence and a ligand (SMILES) and the model predicts the
22
- binding affinity as log₁₀(IC₅₀ / µM) plus a binder / non-binder probability.
 
 
23
 
24
- - Model card: https://huggingface.co/recursionpharma/nesso
25
- - Code: https://github.com/recursionpharma/nesso
26
- - License: Apache-2.0
27
 
28
- Example inputs are taken from the Nesso-1 tutorial in the official repository.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: gray
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.22.0
8
  app_file: app.py
9
+ short_description: Protein-ligand binding affinity with Nesso-1
10
  python_version: "3.12"
11
  startup_duration_timeout: 1h
12
+ models:
13
+ - recursionpharma/nesso
14
+ - facebook/esm2_t33_650M_UR50D
15
+ tags:
16
+ - binding-affinity
17
+ - protein-ligand
18
+ - drug-discovery
19
  ---
20
 
21
+ # Nesso-1 binding affinity prediction
22
 
23
+ Predict protein–ligand binding affinity from **an amino-acid sequence and a SMILES
24
+ string**no MSA and no input structure required.
 
25
 
26
+ [Nesso-1](https://huggingface.co/recursionpharma/nesso) is a coarse-grained cofolding
27
+ model from Valence Labs (Recursion), released under Apache-2.0
28
+ ([code](https://github.com/recursionpharma/nesso),
29
+ [technical report](https://www.biorxiv.org/content/10.64898/2026.08.01.742196v1)).
30
 
31
+ ## What the Space runs
 
 
32
 
33
+ The app reproduces the reference `nesso predict` pipeline
34
+ ([docs/prediction.md](https://github.com/recursionpharma/nesso/blob/main/docs/prediction.md))
35
+ directly in-process, so the numbers match the CLI:
36
+
37
+ - RDKit ETKDG conformer generation for the ligand and CCD-backed protein tokenisation,
38
+ - ESM-2 650M (`facebook/esm2_t33_650M_UR50D`) single-sequence embeddings,
39
+ - Nesso-1 trunk with 5 recycling steps, two-stage pocket refinement
40
+ (`refine_protein_cutoff=22 Å`, 256-token budget), `affinity_protein_cutoff=15 Å`,
41
+ - `bf16-mixed` precision, and the model's own `predict_step` / `affinity.json` scalars.
42
+
43
+ cuEquivariance kernels are not installed (they are CUDA-12 only), which is the
44
+ `--no_kernels`-equivalent fall-back to the pure-PyTorch path; results are unchanged.
45
+
46
+ ## Reading the output
47
+
48
+ `affinity_pred_value` is log₁₀(IC₅₀ / µM): **−3 ≈ 1 nM** (strong binder), **0 ≈ 1 µM**,
49
+ **+2 ≈ 100 µM** (weak / non-binder). `affinity_probability_binary` is the binder
50
+ classification probability. `entropy_crop_pl` is the model's confidence in the predicted
51
+ protein–ligand interface — **0.0 means the ligand could not be confidently placed and the
52
+ prediction should not be trusted**.
53
+
54
+ Research use only. Not for clinical or diagnostic use.
55
+
56
+ ## Examples
57
+
58
+ - *Nesso tutorial complex + L-tyrosine* — the authors' own example from
59
+ [`tutorial/smiles.yaml`](https://github.com/recursionpharma/nesso/blob/main/tutorial/smiles.yaml)
60
+ (Apache-2.0).
61
+ - ABL1 / EGFR kinase domains and CDK2 — sequences from
62
+ [UniProt](https://www.uniprot.org) (P00519, P00533, P24941; CC-BY 4.0).
63
+ - Imatinib, gefitinib, staurosporine and caffeine SMILES from
64
+ [PubChem](https://pubchem.ncbi.nlm.nih.gov) (public domain).
app.py CHANGED
@@ -1,279 +1,444 @@
 
 
 
 
 
 
 
 
 
1
  import os
2
 
3
- # Point every cache (nesso checkpoint, CCD dict, ESM-2 weights) at a persistent,
4
- # writable location so downloads happen once and are reused across GPU calls.
5
- os.environ.setdefault("NESSO_CACHE", "/tmp/nesso_cache")
6
- os.environ.setdefault("HF_HOME", "/tmp/nesso_cache/huggingface")
7
- os.environ.setdefault("HF_HUB_CACHE", "/tmp/nesso_cache/huggingface")
8
 
9
- import spaces # noqa: E402 — must precede torch / CUDA-touching imports
10
 
11
- import json # noqa: E402
12
  import tempfile # noqa: E402
13
  import time # noqa: E402
14
  from pathlib import Path # noqa: E402
15
 
16
  import gradio as gr # noqa: E402
17
- import yaml as pyyaml # noqa: E402
18
-
19
-
20
- TITLE = "Nesso-1 · Protein–Ligand Binding Affinity"
21
-
22
- DESCRIPTION = """
23
- # 🧬 Nesso-1 · Binding Affinity Prediction
24
-
25
- **[Nesso-1](https://huggingface.co/recursionpharma/nesso)** is a fast, structure-based
26
- protein–ligand binding-affinity model from
27
- [Valence Labs](https://valencelabs.com) (a Recursion company). Give it a **protein
28
- amino-acid sequence** and a **ligand** (as a SMILES string), and it predicts binding
29
- affinity (as log₁₀(IC₅₀ / µM)) together with a binder / non-binder probability.
30
-
31
- Lower affinity ⇒ stronger binding: `-3.0 ≈ 1 nM` (strong) · `0.0 ≈ 1 µM` (moderate) ·
32
- `2.0 ≈ 100 µM` (weak).
33
-
34
- Links: [Model card](https://huggingface.co/recursionpharma/nesso) ·
35
- [Code](https://github.com/recursionpharma/nesso) ·
36
- [Blog](https://huggingface.co/blog/recursionpharma/nesso1)
37
- """
38
-
39
- # A representative protein used by the model authors' own tutorial examples.
40
- EXAMPLE_PROTEIN = (
41
- "MVTPEGNVSLVDESLLVGVTDEDRAVRSAHQFYERLIGLWAPAVMEAAHELGVFAALAEAPADSGELARRLDCDARAMRVLLDALYAY"
42
- "DVIDRIHDTNGFRYLLSAEARECLLPGTLFSLVGKFMHDINVAWPAWRNLAEVVRHGARDTSGAESPNGIAQEDYESLVGGINFWAPP"
43
- "IVTTLSRKLRASGRSGDATASVLDVGCGTGLYSQLLLREFPRWTATGLDVERIATLANAQALRLGVEERFATRAGDFWRGGWGTGYDL"
44
- "VLFANIFHLQTPASAVRLMRHAAACLAPDGLVAVVDQIVDADREPKTPQDRFALLFAASMTNTGGGDAYTFQEYEEWFTAAGLQRIET"
45
- "LDTPMHRILLARRATEPSAVPEGQASENLYFQ"
46
  )
47
-
48
-
49
- def _build_yaml(protein_seq: str, ligand_smiles: str) -> str:
50
- doc = {
51
- "sequences": [
52
- {"protein": {"id": "A", "sequence": protein_seq.strip()}},
53
- {"ligand": {"id": "B", "smiles": ligand_smiles.strip()}},
54
- ],
55
- "properties": [{"affinity": {"binder": "B"}}],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
- return pyyaml.safe_dump(doc, sort_keys=False)
58
-
59
-
60
- class _SerialExecutor:
61
- """Drop-in for ProcessPoolExecutor that runs tasks inline in the current
62
- process. Required on ZeroGPU: the @spaces.GPU worker is a daemonic fork and
63
- cannot spawn child processes ("daemonic processes are not allowed to have
64
- children"). Honors the initializer/initargs so nesso's per-worker CCD dict
65
- global is populated in-process.
66
- """
67
-
68
- def __init__(self, max_workers=None, initializer=None, initargs=()):
69
- if initializer is not None:
70
- initializer(*initargs)
71
-
72
- def __enter__(self):
73
- return self
74
-
75
- def __exit__(self, *exc):
76
- return False
77
-
78
- def submit(self, fn, *args, **kwargs):
79
- from concurrent.futures import Future
80
-
81
- fut = Future()
82
- try:
83
- fut.set_result(fn(*args, **kwargs))
84
- except BaseException as exc: # noqa: BLE001
85
- fut.set_exception(exc)
86
- return fut
87
-
88
-
89
- def _patch_nesso_multiprocessing():
90
- """Replace nesso.main's ProcessPoolExecutor with the serial shim."""
91
- import nesso.main as nm
92
-
93
- nm.ProcessPoolExecutor = _SerialExecutor
94
-
95
-
96
- def _run_nesso_cli(yaml_path: Path, out_dir: Path, recycling_steps: int, seed: int):
97
- """Invoke the official `nesso predict` command in-process (Click callback)."""
98
- from click.testing import CliRunner
99
- from nesso.main import cli
100
-
101
- _patch_nesso_multiprocessing()
102
-
103
- args = [
104
- "predict",
105
- str(yaml_path),
106
- "--out_dir",
107
- str(out_dir),
108
- "--accelerator",
109
- "gpu",
110
- "--recycling_steps",
111
- str(int(recycling_steps)),
112
- "--num_workers",
113
- "0",
114
- "--seed",
115
- str(int(seed)),
116
- ]
117
  try:
118
- runner = CliRunner(mix_stderr=False)
119
- except TypeError:
120
- # Click >= 8.2 removed the mix_stderr kwarg.
121
- runner = CliRunner()
122
- result = runner.invoke(cli, args, catch_exceptions=True)
123
- return result
124
 
125
 
126
- @spaces.GPU(duration=180)
 
 
 
127
  def predict_affinity(
128
- protein_seq: str,
129
  ligand_smiles: str,
130
- recycling_steps: int = 5,
131
  seed: int = 42,
132
  progress=gr.Progress(track_tqdm=True),
133
  ):
134
- """Predict protein–ligand binding affinity with Nesso-1.
135
 
136
  Args:
137
- protein_seq: Target protein amino-acid sequence (single-letter codes).
138
  ligand_smiles: Ligand as a SMILES string.
139
- recycling_steps: Number of trunk recycling iterations (higher = more compute).
140
- seed: Random seed for reproducibility.
141
 
142
  Returns:
143
- A markdown summary of the predicted binding affinity and binder probability,
144
- and the full raw prediction JSON.
145
  """
146
- protein_seq = (protein_seq or "").strip().replace("\n", "").replace(" ", "")
147
- ligand_smiles = (ligand_smiles or "").strip()
148
- if not protein_seq:
149
  raise gr.Error("Please provide a protein amino-acid sequence.")
150
- if not ligand_smiles:
 
 
 
 
 
 
 
 
 
 
151
  raise gr.Error("Please provide a ligand SMILES string.")
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  t0 = time.perf_counter()
154
- work = Path(tempfile.mkdtemp(prefix="nesso_"))
155
- yaml_path = work / "complex.yaml"
156
- yaml_path.write_text(_build_yaml(protein_seq, ligand_smiles))
157
- out_dir = work / "output"
158
-
159
- result = _run_nesso_cli(yaml_path, out_dir, recycling_steps, seed)
160
-
161
- # Locate the produced affinity.json
162
- affinity_files = list(out_dir.glob("predictions/*/affinity.json"))
163
- if not affinity_files:
164
- stdout = getattr(result, "stdout", "") or ""
165
- stderr = getattr(result, "stderr", "") or ""
166
- exc = ""
167
- if getattr(result, "exception", None) is not None:
168
- import traceback
169
-
170
- exc = "".join(
171
- traceback.format_exception(
172
- type(result.exception),
173
- result.exception,
174
- result.exception.__traceback__,
175
- )
176
- )
177
- raise gr.Error(
178
- "Prediction failed — no output produced.\n"
179
- f"stdout:\n{stdout[-1500:]}\n\nstderr:\n{stderr[-1500:]}\n\n{exc[-1500:]}"
180
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- data = json.loads(affinity_files[0].read_text())
183
  elapsed = time.perf_counter() - t0
184
 
185
- aff = data.get("affinity_pred_value")
186
- prob = data.get("affinity_probability_binary")
187
-
188
- def _ic50_str(v):
189
- if v is None:
190
- return "n/a"
191
- # v = log10(IC50 / uM) -> IC50 in uM = 10**v
192
- ic50_um = 10 ** v
193
- if ic50_um < 1e-3:
194
- return f"{ic50_um * 1e6:.2f} pM"
195
- if ic50_um < 1.0:
196
- return f"{ic50_um * 1e3:.2f} nM"
197
- if ic50_um < 1e3:
198
- return f"{ic50_um:.2f} µM"
199
- return f"{ic50_um / 1e3:.2f} mM"
200
-
201
- strength = ""
202
- if aff is not None:
203
- if aff <= -2:
204
- strength = "🟢 strong binder"
205
- elif aff <= 0:
206
- strength = "🟡 moderate binder"
207
- elif aff <= 2:
208
- strength = "🟠 weak binder"
209
- else:
210
- strength = "🔴 very weak / non-binder"
211
-
212
- summary = f"""### Prediction
213
-
214
- | Metric | Value |
215
  |---|---|
216
- | **Binding affinity** (log₁₀ IC₅₀/µM) | `{aff:.3f}` |
217
- | **Estimated IC₅₀** | **{_ic50_str(aff)}** |
218
- | **Binder probability** | `{prob:.3f}` |
219
- | **Interpretation** | {strength} |
 
220
 
221
- _Lower affinity ⇒ stronger binding. Computed in {elapsed:.1f}s._
 
 
222
  """
223
- return summary, data
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  CSS = """
227
- #col-container { max-width: 1000px; margin: 0 auto; }
228
  .dark .gradio-container { color: var(--body-text-color); }
229
  """
230
 
231
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title=TITLE) as demo:
232
  with gr.Column(elem_id="col-container"):
233
- gr.Markdown(DESCRIPTION)
234
-
 
 
 
 
 
 
 
 
 
235
  with gr.Row():
236
- with gr.Column(scale=3):
237
  protein = gr.Textbox(
238
  label="Protein sequence",
239
- placeholder="MKTAYIAKQ... (single-letter amino-acid codes)",
240
- lines=5,
 
241
  )
242
  ligand = gr.Textbox(
243
  label="Ligand SMILES",
244
- placeholder="e.g. N[C@@H](Cc1ccc(O)cc1)C(=O)O",
245
- lines=1,
246
  )
 
247
  with gr.Accordion("Advanced settings", open=False):
248
  recycling = gr.Slider(
249
- 1, 10, value=5, step=1, label="Recycling steps"
 
 
 
 
 
250
  )
251
  seed = gr.Number(value=42, precision=0, label="Seed")
252
- run = gr.Button("Predict binding affinity", variant="primary")
253
- with gr.Column(scale=2):
254
- out_md = gr.Markdown(label="Result")
255
- out_json = gr.JSON(label="Full prediction")
 
 
 
 
 
 
 
 
 
 
256
 
257
  gr.Examples(
258
  examples=[
259
- [EXAMPLE_PROTEIN, "N[C@@H](Cc1ccc(O)cc1)C(=O)O"],
260
- [EXAMPLE_PROTEIN, "CCO"],
261
- [EXAMPLE_PROTEIN, "Fc1ccc(cc1)C(=O)Nc1ccc(cc1)S(=O)(=O)N"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  ],
263
  inputs=[protein, ligand],
264
- outputs=[out_md, out_json],
265
  fn=predict_affinity,
266
  cache_examples=True,
267
  cache_mode="lazy",
268
  )
269
 
270
  run.click(
271
- predict_affinity,
272
  inputs=[protein, ligand, recycling, seed],
273
- outputs=[out_md, out_json],
274
  api_name="predict",
275
  )
276
 
277
-
278
  if __name__ == "__main__":
279
  demo.launch(mcp_server=True)
 
1
+ """Nesso-1 — protein–ligand binding affinity prediction on ZeroGPU.
2
+
3
+ Mirrors the reference `nesso predict` CLI path (see
4
+ https://github.com/recursionpharma/nesso, docs/prediction.md): same
5
+ preprocessing (RDKit ETKDG conformer + CCD-backed protein tokenisation),
6
+ same ESM-2 650M embeddings, same defaults (5 recycling steps, two-stage
7
+ pocket refinement, bf16-mixed precision), same `predict_step`.
8
+ """
9
+
10
  import os
11
 
12
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
13
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
 
 
 
14
 
15
+ import spaces # noqa: E402 — must precede any CUDA-touching import
16
 
17
+ import hashlib # noqa: E402
18
  import tempfile # noqa: E402
19
  import time # noqa: E402
20
  from pathlib import Path # noqa: E402
21
 
22
  import gradio as gr # noqa: E402
23
+ import torch # noqa: E402
24
+ from huggingface_hub import hf_hub_download # noqa: E402
25
+ from rdkit import Chem, RDLogger # noqa: E402
26
+ from rdkit.Chem import Draw # noqa: E402
27
+ from safetensors.torch import save_file # noqa: E402
28
+
29
+ from nesso.data import const # noqa: E402
30
+ from nesso.data.esm import ( # noqa: E402
31
+ DEFAULT_ESM2_MODEL,
32
+ extract_esm_embedding,
33
+ setup_esm_model,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  )
35
+ from nesso.data.featurizer import NessoFeaturizer # noqa: E402
36
+ from nesso.data.inference import ( # noqa: E402
37
+ STANDARD_AA,
38
+ InferenceDataset,
39
+ inference_collate,
40
+ )
41
+ from nesso.data.types import Manifest # noqa: E402
42
+ from nesso.data.yaml_input import ( # noqa: E402
43
+ load_ccd_mol_dict,
44
+ parse_schema,
45
+ validate_schema,
46
+ )
47
+ from nesso.model.models.nesso1 import Nesso1 # noqa: E402
48
+
49
+ RDLogger.DisableLog("rdApp.*")
50
+
51
+ REPO_ID = "recursionpharma/nesso"
52
+ REVISION = "v1.0.0"
53
+ MAX_RESIDUES = 1200
54
+ DEFAULT_RECYCLING = 5
55
+
56
+ # --------------------------------------------------------------------------------------
57
+ # Load everything once, at module scope (ZeroGPU packs the weights at startup).
58
+ # --------------------------------------------------------------------------------------
59
+ print("Downloading Nesso-1 assets…", flush=True)
60
+ CCD_PATH = Path(hf_hub_download(REPO_ID, "ccd.pkl", revision=REVISION))
61
+ WEIGHTS_PATH = Path(
62
+ hf_hub_download(REPO_ID, f"{REVISION}/model.safetensors", revision=REVISION)
63
+ )
64
+ hf_hub_download(REPO_ID, f"{REVISION}/hparams.json", revision=REVISION)
65
+
66
+ print("Loading CCD dictionary…", flush=True)
67
+ CCD_DICT = load_ccd_mol_dict(CCD_PATH)
68
+ STD_AA_MOLS = {aa: CCD_DICT.get(aa) for aa in STANDARD_AA}
69
+
70
+ print("Loading Nesso-1…", flush=True)
71
+ MODEL = Nesso1.from_pretrained(WEIGHTS_PATH.parent)
72
+ # Same predict_args the CLI sets (docs/prediction.md defaults).
73
+ MODEL.predict_args.update(
74
+ {
75
+ "pose_protein_cutoff": 15.0,
76
+ "recycling_steps": DEFAULT_RECYCLING,
77
+ "affinity_protein_cutoff": 15.0,
78
+ "refine_protein_inference": True,
79
+ "refine_protein_cutoff": 22.0,
80
+ "refine_protein_tokens_budget": 256,
81
+ "save_metadata": False,
82
  }
83
+ )
84
+ MODEL.eval()
85
+ MODEL.to("cuda")
86
+
87
+ print("Loading ESM-2 650M…", flush=True)
88
+ ESM_MODEL, ESM_TOKENIZER = setup_esm_model(DEFAULT_ESM2_MODEL, torch.device("cuda"))
89
+
90
+ torch.set_grad_enabled(False)
91
+ torch.set_float32_matmul_precision("highest")
92
+ print("Ready.", flush=True)
93
+
94
+ VALID_AA = set(const.prot_letter_to_token) - {"-"}
95
+
96
+
97
+ # --------------------------------------------------------------------------------------
98
+ # Helpers
99
+ # --------------------------------------------------------------------------------------
100
+ def _clean_sequence(raw: str) -> str:
101
+ """Normalise a pasted protein sequence (accepts FASTA, whitespace, lowercase)."""
102
+ lines = [ln for ln in (raw or "").splitlines() if not ln.strip().startswith(">")]
103
+ seq = "".join("".join(lines).split()).upper()
104
+ seq = "".join(ch for ch in seq if not ch.isdigit())
105
+ return seq
106
+
107
+
108
+ def _format_affinity(value: float) -> str:
109
+ """log10(IC50 / uM) -> a human-readable concentration."""
110
+ ic50_um = 10.0**value
111
+ if ic50_um < 1e-3:
112
+ return f"{ic50_um * 1e6:.2f} pM"
113
+ if ic50_um < 1.0:
114
+ return f"{ic50_um * 1e3:.2f} nM"
115
+ if ic50_um < 1e3:
116
+ return f"{ic50_um:.2f} µM"
117
+ return f"{ic50_um / 1e3:.2f} mM"
118
+
119
+
120
+ def _strength(value: float) -> str:
121
+ if value <= -2.0:
122
+ return "very strong (low-nM or better)"
123
+ if value <= -1.0:
124
+ return "strong"
125
+ if value <= 0.0:
126
+ return "moderate"
127
+ if value <= 1.0:
128
+ return "weak"
129
+ return "very weak / likely non-binder"
130
+
131
+
132
+ def _estimate_duration(
133
+ protein_sequence: str = "",
134
+ ligand_smiles: str = "",
135
+ recycling_steps: int = DEFAULT_RECYCLING,
136
+ *args,
137
+ **kwargs,
138
+ ) -> int:
139
+ try:
140
+ n = len(_clean_sequence(protein_sequence)) or 400
141
+ except Exception:
142
+ n = 400
143
  try:
144
+ steps = int(recycling_steps)
145
+ except Exception:
146
+ steps = DEFAULT_RECYCLING
147
+ return int(min(220, 25 + 0.05 * n + 4 * steps))
 
 
148
 
149
 
150
+ # --------------------------------------------------------------------------------------
151
+ # Inference
152
+ # --------------------------------------------------------------------------------------
153
+ @spaces.GPU(duration=_estimate_duration)
154
  def predict_affinity(
155
+ protein_sequence: str,
156
  ligand_smiles: str,
157
+ recycling_steps: int = DEFAULT_RECYCLING,
158
  seed: int = 42,
159
  progress=gr.Progress(track_tqdm=True),
160
  ):
161
+ """Predict the binding affinity between a protein and a small molecule.
162
 
163
  Args:
164
+ protein_sequence: Target protein as a single-letter amino-acid sequence (FASTA accepted).
165
  ligand_smiles: Ligand as a SMILES string.
166
+ recycling_steps: Number of trunk recycling iterations (Nesso-1 default is 5).
167
+ seed: Random seed (controls RDKit conformer generation and featurisation).
168
 
169
  Returns:
170
+ A 2D depiction of the ligand, a Markdown summary, the binder/non-binder
171
+ probabilities, and the raw `affinity.json` scalars produced by Nesso-1.
172
  """
173
+ seq = _clean_sequence(protein_sequence)
174
+ if not seq:
 
175
  raise gr.Error("Please provide a protein amino-acid sequence.")
176
+ bad = sorted(set(seq) - VALID_AA)
177
+ if bad:
178
+ raise gr.Error(f"Unsupported characters in the protein sequence: {bad}")
179
+ if len(seq) > MAX_RESIDUES:
180
+ raise gr.Error(
181
+ f"Sequence has {len(seq)} residues; this demo is capped at {MAX_RESIDUES}. "
182
+ "Paste the target domain (e.g. the kinase domain) instead of the full protein."
183
+ )
184
+
185
+ smiles = (ligand_smiles or "").strip()
186
+ if not smiles:
187
  raise gr.Error("Please provide a ligand SMILES string.")
188
+ mol = Chem.MolFromSmiles(smiles)
189
+ if mol is None:
190
+ raise gr.Error(f"RDKit could not parse the SMILES string: {smiles!r}")
191
+
192
+ steps = max(0, min(10, int(recycling_steps)))
193
+ seed = int(seed)
194
+
195
+ from lightning.pytorch import seed_everything
196
+
197
+ seed_everything(seed, workers=True)
198
+
199
+ ligand_png = Draw.MolToImage(mol, size=(420, 320))
200
 
201
  t0 = time.perf_counter()
202
+ work = Path(tempfile.mkdtemp(prefix="nesso-"))
203
+ processed = work / "processed"
204
+ mol_dir = processed / "rdkit_conformers"
205
+ structures_dir = processed / "structures"
206
+ records_dir = processed / "records"
207
+ esm_dir = processed / "esm_embeddings"
208
+ for d in (mol_dir, structures_dir, records_dir, esm_dir):
209
+ d.mkdir(parents=True, exist_ok=True)
210
+
211
+ record_id = "complex"
212
+ schema = {
213
+ "sequences": [
214
+ {"protein": {"id": "A", "sequence": seq}},
215
+ {"ligand": {"id": "B", "smiles": smiles}},
216
+ ],
217
+ "properties": [{"affinity": {"binder": "B"}}],
218
+ }
219
+ validate_schema(schema)
220
+
221
+ try:
222
+ structure, record, entity_to_seq, _ = parse_schema(
223
+ schema, mol_dir, ccd_dict=CCD_DICT, record_id=record_id
 
 
 
 
224
  )
225
+ except Exception as exc: # noqa: BLE001
226
+ raise gr.Error(f"Could not build the complex: {exc}") from exc
227
+
228
+ structure.dump(structures_dir / f"{record_id}.npz")
229
+ record.dump(records_dir / f"{record_id}.json")
230
+
231
+ # ESM-2 embeddings (same code path as the CLI's `run_esm`).
232
+ for protein_seq in entity_to_seq.values():
233
+ mid = hashlib.md5(protein_seq.encode("utf-8")).hexdigest() # noqa: S324
234
+ out_path = esm_dir / f"{mid}.safetensors"
235
+ if not out_path.exists():
236
+ emb = extract_esm_embedding(protein_seq, ESM_MODEL, ESM_TOKENIZER)
237
+ save_file({"embeddings": emb}, out_path)
238
+
239
+ featurizer = NessoFeaturizer(
240
+ esm_emb_dir=esm_dir, esm_emb_dim=1280, esm_num_layers=33
241
+ )
242
+ dataset = InferenceDataset(
243
+ manifest=Manifest([record]),
244
+ target_dir=processed,
245
+ featurizer=featurizer,
246
+ ligand_dir=mol_dir,
247
+ ccd_pkl=None,
248
+ use_esm_all_layers=False,
249
+ )
250
+ # Reuse the CCD-backed standard residues loaded once at startup.
251
+ dataset._standard_aa_mols = STD_AA_MOLS # noqa: SLF001
252
+
253
+ feats = dataset[0]
254
+ if feats.get("exception"):
255
+ raise gr.Error("Featurisation failed for this complex (see the Space logs).")
256
+
257
+ batch = inference_collate([feats])
258
+ batch = {
259
+ k: (v.to("cuda", non_blocking=True) if torch.is_tensor(v) else v)
260
+ for k, v in batch.items()
261
+ }
262
+
263
+ # `--precision bf16-mixed` equivalent.
264
+ MODEL.predict_args["recycling_steps"] = steps
265
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
266
+ out = MODEL.predict_step(batch, 0)
267
+
268
+ if out.get("exception"):
269
+ raise gr.Error("Prediction failed for this complex (see the Space logs).")
270
+
271
+ stats = {}
272
+ for key, value in out.items():
273
+ if not (key.startswith("affinity_") or key.startswith("entropy_")):
274
+ continue
275
+ if key == "entropy_pair":
276
+ continue
277
+ if torch.is_tensor(value) and value.numel() == 1:
278
+ stats[key] = round(float(value.item()), 4)
279
+ elif isinstance(value, (int, float)):
280
+ stats[key] = round(float(value), 4)
281
 
 
282
  elapsed = time.perf_counter() - t0
283
 
284
+ affinity = stats.get("affinity_pred_value")
285
+ prob = stats.get("affinity_probability_binary", 0.0)
286
+ entropy_pl = stats.get("entropy_crop_pl")
287
+
288
+ if entropy_pl is not None and entropy_pl == 0.0:
289
+ confidence = (
290
+ "⚠️ **Low confidence** `entropy_crop_pl` is 0.0, meaning the model could "
291
+ "not confidently place the ligand. Do not trust this prediction."
292
+ )
293
+ else:
294
+ confidence = (
295
+ f"Interface distogram entropy (`entropy_crop_pl`): **{entropy_pl:.3f}** "
296
+ "— higher is a more confident protein–ligand interface."
297
+ )
298
+
299
+ summary = f"""
300
+ ### Predicted binding affinity
301
+
302
+ | | |
 
 
 
 
 
 
 
 
 
 
 
303
  |---|---|
304
+ | **log₁₀(IC₅₀ / µM)** | **{affinity:.2f}** ({_strength(affinity)}) |
305
+ | Estimated IC₅₀ | **{_format_affinity(affinity)}** |
306
+ | pIC₅₀ (= 6 − value) | {6.0 - affinity:.2f} |
307
+ | Binder probability | {prob * 100:.1f}% |
308
+ | Ensemble members | {stats.get("affinity_pred_value1", float("nan")):.2f} / {stats.get("affinity_pred_value2", float("nan")):.2f} |
309
 
310
+ {confidence}
311
+
312
+ <sub>{len(seq)} residues · {mol.GetNumAtoms()} heavy atoms · {steps} recycling steps · {elapsed:.1f}s</sub>
313
  """
 
314
 
315
+ label = {"binder": float(prob), "non-binder": float(1.0 - prob)}
316
+ return ligand_png, summary, label, stats
317
+
318
+
319
+ # --------------------------------------------------------------------------------------
320
+ # UI
321
+ # --------------------------------------------------------------------------------------
322
+ TUTORIAL_PROTEIN = (
323
+ "MVTPEGNVSLVDESLLVGVTDEDRAVRSAHQFYERLIGLWAPAVMEAAHELGVFAALAEAPADSGELARRLDCDARAMRVL"
324
+ "LDALYAYDVIDRIHDTNGFRYLLSAEARECLLPGTLFSLVGKFMHDINVAWPAWRNLAEVVRHGARDTSGAESPNGIAQED"
325
+ "YESLVGGINFWAPPIVTTLSRKLRASGRSGDATASVLDVGCGTGLYSQLLLREFPRWTATGLDVERIATLANAQALRLGVE"
326
+ "ERFATRAGDFWRGGWGTGYDLVLFANIFHLQTPASAVRLMRHAAACLAPDGLVAVVDQIVDADREPKTPQDRFALLFAASM"
327
+ "TNTGGGDAYTFQEYEEWFTAAGLQRIETLDTPMHRILLARRATEPSAVPEGQASENLYFQ"
328
+ )
329
+ ABL1_KINASE = (
330
+ "ITMKHKLGGGQYGEVYEGVWKKYSLTVAVKTLKEDTMEVEEFLKEAAVMKEIKHPNLVQLLGVCTREPPFYIITEFMTYGN"
331
+ "LLDYLRECNRQEVNAVVLLYMATQISSAMEYLEKKNFIHRDLAARNCLVGENHLVKVADFGLSRLMTGDTYTAHAGAKFPI"
332
+ "KWTAPESLAYNKFSIKSDVWAFGVLLWEIATYGMSPYPGIDLSQVYELLEKDYRMERPEGCPEKVYELMRACWQWNPSDRP"
333
+ "SFAEIHQAF"
334
+ )
335
+ EGFR_KINASE = (
336
+ "FKKIKVLGSGAFGTVYKGLWIPEGEKVKIPVAIKELREATSPKANKEILDEAYVMASVDNPHVCRLLGICLTSTVQLITQL"
337
+ "MPFGCLLDYVREHKDNIGSQYLLNWCVQIAKGMNYLEDRRLVHRDLAARNVLVKTPQHVKITDFGLAKLLGAEEKEYHAEG"
338
+ "GKVPIKWMALESILHRIYTHQSDVWSYGVTVWELMTFGSKPYDGIPASEISSILEKGERLPQPPICTIDVYMIMVKCWMID"
339
+ "ADSRPKFRELIIEFSKMARDPQRYL"
340
+ )
341
+ CDK2 = (
342
+ "MENFQKVEKIGEGTYGVVYKARNKLTGEVVALKKIRLDTETEGVPSTAIREISLLKELNHPNIVKLLDVIHTENKLYLVFE"
343
+ "FLHQDLKKFMDASALTGIPLPLIKSYLFQLLQGLAFCHSHRVLHRDLKPQNLLINTEGAIKLADFGLARAFGVPVRTYTHE"
344
+ "VVTLWYRAPEILLGCKYYSTAVDIWSLGCIFAEMVTRRALFPGDSEIDQLFRIFRTLGTPDEVVWPGVTSMPDYKPSFPKW"
345
+ "ARQDFSKVVPPLDEDGRSLLSQMLHYDPNKRISAKAALAHPFFQDVTKPVPHLRL"
346
+ )
347
 
348
  CSS = """
349
+ #col-container { max-width: 1200px; margin: 0 auto; }
350
  .dark .gradio-container { color: var(--body-text-color); }
351
  """
352
 
353
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Nesso-1") as demo:
354
  with gr.Column(elem_id="col-container"):
355
+ gr.Markdown(
356
+ """
357
+ # 🧬 Nesso-1 — binding affinity prediction
358
+
359
+ Predict how strongly a small molecule binds a protein, from **sequence + SMILES only**
360
+ (no MSA, no structure). [Nesso-1](https://huggingface.co/recursionpharma/nesso) is a
361
+ coarse-grained cofolding model from Valence Labs (Recursion) —
362
+ [code](https://github.com/recursionpharma/nesso) ·
363
+ [technical report](https://www.biorxiv.org/content/10.64898/2026.08.01.742196v1).
364
+ """
365
+ )
366
  with gr.Row():
367
+ with gr.Column(scale=1):
368
  protein = gr.Textbox(
369
  label="Protein sequence",
370
+ placeholder="Single-letter amino-acid sequence (FASTA is fine)",
371
+ lines=8,
372
+ max_lines=12,
373
  )
374
  ligand = gr.Textbox(
375
  label="Ligand SMILES",
376
+ placeholder="CC1=C(C=C(C=C1)NC(=O)",
377
+ lines=2,
378
  )
379
+ run = gr.Button("Predict affinity", variant="primary")
380
  with gr.Accordion("Advanced settings", open=False):
381
  recycling = gr.Slider(
382
+ 1,
383
+ 8,
384
+ value=DEFAULT_RECYCLING,
385
+ step=1,
386
+ label="Recycling steps",
387
+ info="Nesso-1 was evaluated with 5. More steps = slower.",
388
  )
389
  seed = gr.Number(value=42, precision=0, label="Seed")
390
+ with gr.Column(scale=1):
391
+ summary_out = gr.Markdown(label="Prediction")
392
+ binder_out = gr.Label(label="Binder classification", num_top_classes=2)
393
+ ligand_out = gr.Image(label="Ligand", height=260)
394
+ with gr.Accordion("Raw output (affinity.json)", open=False):
395
+ json_out = gr.JSON(label="Nesso-1 scalars")
396
+
397
+ gr.Markdown(
398
+ "**Reading the output** — `affinity_pred_value` is log₁₀(IC₅₀ / µM): "
399
+ "**−3 ≈ 1 nM** (strong), **0 ≈ 1 µM** (moderate), **+2 ≈ 100 µM** (weak). "
400
+ "`entropy_crop_pl` measures confidence in the predicted protein–ligand "
401
+ "interface; **0.0 means the prediction should not be trusted**. "
402
+ "Research use only — not for clinical or diagnostic decisions."
403
+ )
404
 
405
  gr.Examples(
406
  examples=[
407
+ [TUTORIAL_PROTEIN, "N[C@@H](Cc1ccc(O)cc1)C(=O)O"],
408
+ [
409
+ ABL1_KINASE,
410
+ "CC1=C(C=C(C=C1)NC(=O)C2=CC=C(C=C2)CN3CCN(CC3)C)NC4=NC=CC(=N4)C5=CN=CC=C5",
411
+ ],
412
+ [
413
+ EGFR_KINASE,
414
+ "COC1=C(C=C2C(=C1)N=CN=C2NC3=CC(=C(C=C3)F)Cl)OCCCN4CCOCC4",
415
+ ],
416
+ [
417
+ CDK2,
418
+ "C[C@@]12[C@@H]([C@@H](C[C@@H](O1)N3C4=CC=CC=C4C5=C6C(=C7C8=CC=CC=C8N2C7=C53)CNC6=O)NC)OC",
419
+ ],
420
+ [ABL1_KINASE, "CN1C=NC2=C1C(=O)N(C(=O)N2C)C"],
421
+ ],
422
+ example_labels=[
423
+ "Nesso tutorial complex + L-tyrosine",
424
+ "ABL1 kinase domain + imatinib",
425
+ "EGFR kinase domain + gefitinib",
426
+ "CDK2 + staurosporine",
427
+ "ABL1 kinase domain + caffeine (negative control)",
428
  ],
429
  inputs=[protein, ligand],
430
+ outputs=[ligand_out, summary_out, binder_out, json_out],
431
  fn=predict_affinity,
432
  cache_examples=True,
433
  cache_mode="lazy",
434
  )
435
 
436
  run.click(
437
+ fn=predict_affinity,
438
  inputs=[protein, ligand, recycling, seed],
439
+ outputs=[ligand_out, summary_out, binder_out, json_out],
440
  api_name="predict",
441
  )
442
 
 
443
  if __name__ == "__main__":
444
  demo.launch(mcp_server=True)
requirements.txt CHANGED
@@ -1,14 +1 @@
1
- # Nesso-1 runtime deps (from its pyproject), plus the package itself with --no-deps
2
- # so it does not override the platform-managed gradio / spaces / huggingface_hub.
3
- numpy>=2.0
4
- lightning>=2.6.0
5
- rdkit>=2024.3.2
6
- einops>=0.8.0
7
- mashumaro>=3.14
8
- safetensors>=0.7.0
9
- transformers>=4.40.0
10
- scipy>=1.13.0
11
- pyyaml
12
- click>=8.1.7
13
- tqdm
14
- nesso @ git+https://github.com/recursionpharma/nesso.git
 
1
+ nesso @ git+https://github.com/recursionpharma/nesso.git@f0156e9a22326448684bae09ee96f73415902dcd