AI4deeperScience commited on
Commit
2c5c302
Β·
verified Β·
1 Parent(s): ccb728d

fix: python_version, requirements, academic UI redesign

Browse files
README.md CHANGED
@@ -4,8 +4,8 @@ emoji: 🧬
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: 4.44.0
8
- python_version: 3.10
9
  app_file: app.py
10
  pinned: false
11
  license: mit
 
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
+ sdk_version: 5.0.0
8
+ python_version: "3.11"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
app.py CHANGED
@@ -1,552 +1,764 @@
1
- """
2
- app.py β€” BioInteract Gradio Space
3
- Interpretable Drug–Target Interaction Prediction
4
-
5
- Two tabs:
6
- 1. Case Studies β€” pre-computed, clinically validated pairs
7
- 2. Custom Prediction β€” user supplies SMILES + protein sequence
8
- """
9
- import sys
10
- import json
11
- import io
12
- import warnings
13
- from pathlib import Path
14
-
15
- import numpy as np
16
- import yaml
17
- import matplotlib
18
- matplotlib.use('Agg')
19
- import matplotlib.pyplot as plt
20
- import seaborn as sns
21
- import gradio as gr
22
- from PIL import Image
23
-
24
- # ---------- path setup ----------
25
- ROOT = Path(__file__).parent
26
- sys.path.insert(0, str(ROOT))
27
-
28
- # torch-geometric import guard: provide useful error if wheels missing
29
- try:
30
- from torch_geometric.data import Batch as PyGBatch
31
- PYGEOMETRIC_AVAILABLE = True
32
- except ImportError as _pyg_err:
33
- PYGEOMETRIC_AVAILABLE = False
34
- _pyg_err_msg = str(_pyg_err)
35
-
36
- # protein features are pure-python and safe to import eagerly
37
- from src.data.protein_feat import residue_physicochemical_features, residue_domain_labels
38
-
39
- # Model and heavy-dependency lazy loader
40
- _model = None
41
- _DEVICE = None
42
- _CONFIG_PATH = ROOT / 'configs' / 'default.yaml'
43
- _CKPT_PATH = ROOT / 'checkpoints' / 'best.pt'
44
- _REPORT_PATH = ROOT / 'examples' / 'interpretability_report.json'
45
-
46
- def _load_model():
47
- """Lazy-load the PyTorch model and return a ready-to-use instance.
48
- This delays importing heavy packages (torch, torch_geometric, rdkit)
49
- until a user requests a custom prediction.
50
- """
51
- global _model, _DEVICE
52
- if _model is not None:
53
- return _model
54
-
55
- try:
56
- import torch
57
- except Exception as e:
58
- raise RuntimeError(f"PyTorch not available: {e}")
59
-
60
- # Import model code only when torch is available
61
- from src.models.biointeract import BioInteract
62
-
63
- print("[BioInteract] Loading model config …")
64
- with open(_CONFIG_PATH) as f:
65
- cfg = yaml.safe_load(f)
66
-
67
- print("[BioInteract] Loading pretrained weights …")
68
- device = torch.device('cpu')
69
- model = BioInteract(cfg['model']).to(device)
70
- ckpt = torch.load(_CKPT_PATH, map_location='cpu', weights_only=False)
71
- model.load_state_dict(ckpt['model_state_dict'])
72
- model.eval()
73
- _model = model
74
- _DEVICE = device
75
- print(f"[BioInteract] Model ready β€” epoch {ckpt.get('epoch','?')}, params = {sum(p.numel() for p in _model.parameters()):,}")
76
- return _model
77
-
78
- # Load pre-computed case study report (safe, JSON-only)
79
- with open(_REPORT_PATH) as f:
80
- _REPORT = json.load(f)
81
-
82
- # ============================================================
83
- # ESM-2 lazy loader (only initialised on first custom prediction)
84
- # ============================================================
85
-
86
- _esm_tokenizer = None
87
- _esm_model = None
88
- ESM_MODEL_NAME = "facebook/esm2_t30_150M_UR50D"
89
-
90
- def _get_esm():
91
- global _esm_tokenizer, _esm_model
92
- if _esm_model is None:
93
- from transformers import EsmModel, EsmTokenizer
94
- print("[BioInteract] Downloading / loading ESM-2 (150M) …")
95
- _esm_tokenizer = EsmTokenizer.from_pretrained(ESM_MODEL_NAME)
96
- _esm_model = EsmModel.from_pretrained(ESM_MODEL_NAME).eval()
97
- print("[BioInteract] ESM-2 ready")
98
- return _esm_tokenizer, _esm_model
99
-
100
-
101
- def compute_esm2_embedding(sequence: str, max_len: int = 512) -> torch.Tensor:
102
- """Run ESM-2 and return per-residue embeddings (L, 640)."""
103
- import torch
104
- seq = sequence[:max_len]
105
- tokenizer, esm = _get_esm()
106
- inputs = tokenizer(seq, return_tensors='pt', add_special_tokens=True)
107
- with torch.no_grad():
108
- outputs = esm(**inputs)
109
- # strip [CLS] and [EOS] tokens β†’ (L, 640)
110
- embedding = outputs.last_hidden_state[0, 1:-1, :]
111
- return embedding[:len(seq)]
112
-
113
-
114
- # ============================================================
115
- # Plotting helpers
116
- # ============================================================
117
-
118
- def _plot_interaction_heatmap(
119
- interaction_map: np.ndarray,
120
- sequence: str,
121
- title: str = 'Atom–Residue Interaction Map',
122
- ) -> Image.Image:
123
- """Render interaction heatmap as a PIL Image."""
124
- n_atoms, n_res = interaction_map.shape
125
-
126
- # subsample residues if too long
127
- max_show_res = 80
128
- if n_res > max_show_res:
129
- scores = interaction_map.sum(axis=0)
130
- center = int(np.argmax(scores))
131
- start = max(0, center - max_show_res // 2)
132
- end = min(n_res, start + max_show_res)
133
- interaction_map = interaction_map[:, start:end]
134
- res_labels = [f"{seq[i]}{i+1}" for i, seq in
135
- enumerate([sequence[j] for j in range(start, end)])]
136
- # simpler: just residue index labels
137
- res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
138
- for i in range(start, end)]
139
- else:
140
- res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
141
- for i in range(n_res)]
142
-
143
- figw = max(12, len(res_labels) * 0.15)
144
- figh = max(5, n_atoms * 0.25)
145
- fig, ax = plt.subplots(figsize=(figw, figh))
146
- sns.heatmap(
147
- interaction_map,
148
- xticklabels=res_labels,
149
- yticklabels=[f"A{i}" for i in range(n_atoms)],
150
- cmap='YlOrRd',
151
- ax=ax,
152
- cbar_kws={'label': 'Attention Score', 'shrink': 0.8},
153
- )
154
- ax.set_title(title, fontsize=13, fontweight='bold')
155
- ax.set_xlabel('Protein Residue', fontsize=10)
156
- ax.set_ylabel('Drug Atom', fontsize=10)
157
- plt.xticks(rotation=90, fontsize=6)
158
- plt.yticks(fontsize=6)
159
- plt.tight_layout()
160
-
161
- buf = io.BytesIO()
162
- fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
163
- plt.close(fig)
164
- buf.seek(0)
165
- return Image.open(buf).copy()
166
-
167
-
168
- def _plot_top_residues(top_residues: list, title: str = 'Top Binding Residues') -> Image.Image:
169
- """Bar chart of top residue attention scores."""
170
- labels = [r[0] for r in top_residues]
171
- scores = [r[1] for r in top_residues]
172
-
173
- fig, ax = plt.subplots(figsize=(8, 4))
174
- bars = ax.barh(labels[::-1], scores[::-1], color='steelblue', edgecolor='white')
175
- ax.set_xlabel('Normalised Attention Score', fontsize=10)
176
- ax.set_title(title, fontsize=11, fontweight='bold')
177
- ax.set_xlim(0, 1.05)
178
- for bar, score in zip(bars, scores[::-1]):
179
- ax.text(score + 0.01, bar.get_y() + bar.get_height() / 2,
180
- f'{score:.3f}', va='center', fontsize=8)
181
- plt.tight_layout()
182
-
183
- buf = io.BytesIO()
184
- fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
185
- plt.close(fig)
186
- buf.seek(0)
187
- return Image.open(buf).copy()
188
-
189
-
190
- def _plot_functional_groups(fg_dict: dict, title: str = 'Pharmacophore Importance') -> Image.Image:
191
- """Horizontal bar chart for functional group Grad-CAM scores."""
192
- if not fg_dict:
193
- return None
194
- labels = list(fg_dict.keys())
195
- scores = list(fg_dict.values())
196
-
197
- fig, ax = plt.subplots(figsize=(7, max(3, len(labels) * 0.5)))
198
- ax.barh(labels, scores, color='coral', edgecolor='white')
199
- ax.set_xlabel('Grad-CAM Importance', fontsize=10)
200
- ax.set_title(title, fontsize=11, fontweight='bold')
201
- ax.set_xlim(0, 1.05)
202
- plt.tight_layout()
203
-
204
- buf = io.BytesIO()
205
- fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
206
- plt.close(fig)
207
- buf.seek(0)
208
- return Image.open(buf).copy()
209
-
210
-
211
- # ============================================================
212
- # Tab 1 β€” Case Studies
213
- # ============================================================
214
-
215
- _CASE_OPTIONS = {
216
- f"{cs['drug_name']} / {cs['target_name']}": cs
217
- for cs in _REPORT.get('case_studies', [])
218
- if (ROOT / 'examples' / f"{cs['drug_name']}_{cs['target_name'].replace('(','').replace(')','').replace('/','')}.png").exists()
219
- or (ROOT / 'examples' / f"{cs['drug_name']}_{cs['target_name']}.png").exists()
220
- }
221
-
222
- # If the report has no exact-match filenames, fall back to the 3 available PNGs
223
- _FIXED_CASES = {
224
- 'ABL1(E255K) + Drug 5328940 (Kd = 0.047 nM)': {
225
- 'png': ROOT / 'examples' / '5328940_ABL1E255K.png',
226
- 'prob': 0.988,
227
- 'affinity_nM': 0.047,
228
- 'top_residues': [['V104', 1.0], ['A648', 0.503], ['S199', 0.417],
229
- ['P649', 0.281], ['P936', 0.237], ['N707', 0.192],
230
- ['P651', 0.180], ['L799', 0.166], ['K796', 0.141], ['P934', 0.079]],
231
- 'functional_groups': {'Halogen': 0.650, 'Amino': 0.616, 'Ether': 0.345,
232
- 'Aromatic Ring': 0.311, 'Heterocycle N': 0.167},
233
- 'description': (
234
- 'Drug 5328940 bound to ABL1 E255K resistance mutant. '
235
- 'Prediction probability 98.8 % (Kd = 0.047 nM). '
236
- 'Key contacts: V104 (gatekeeper), A648, S199.'
237
- ),
238
- },
239
- 'EGFR + Drug 156414': {
240
- 'png': ROOT / 'examples' / '156414_EGFR.png',
241
- 'prob': None,
242
- 'affinity_nM': None,
243
- 'top_residues': [],
244
- 'functional_groups': {},
245
- 'description': (
246
- 'Drug 156414 bound to wild-type EGFR kinase domain. '
247
- 'Inhibitor with selectivity for the EGFR tyrosine kinase.'
248
- ),
249
- },
250
- 'BRAF + Drug 11717001 (Sorafenib analogue)': {
251
- 'png': ROOT / 'examples' / '11717001_BRAF.png',
252
- 'prob': None,
253
- 'affinity_nM': None,
254
- 'top_residues': [],
255
- 'functional_groups': {},
256
- 'description': (
257
- 'Drug 11717001 (Sorafenib analogue) bound to BRAF kinase. '
258
- 'RAF inhibitor used in treatment of melanoma and other cancers.'
259
- ),
260
- },
261
- }
262
-
263
-
264
- def show_case_study(case_key: str):
265
- """Called when user selects a case from the dropdown."""
266
- case = _FIXED_CASES.get(case_key)
267
- if case is None:
268
- return None, None, None, "Case not found."
269
-
270
- heatmap_img = Image.open(case['png']) if case['png'].exists() else None
271
-
272
- prob_text = (f"**Predicted binding probability:** {case['prob']*100:.1f}%\n\n"
273
- if case['prob'] else "")
274
- kd_text = (f"**Experimental Kd:** {case['affinity_nM']} nM\n\n"
275
- if case['affinity_nM'] else "")
276
- info_md = f"### {case_key}\n\n{prob_text}{kd_text}{case['description']}"
277
-
278
- residue_img = (
279
- _plot_top_residues(case['top_residues'], f"Top Binding Residues β€” {case_key}")
280
- if case['top_residues'] else None
281
- )
282
- fg_img = (
283
- _plot_functional_groups(case['functional_groups'], f"Pharmacophore Importance β€” {case_key}")
284
- if case['functional_groups'] else None
285
- )
286
-
287
- return heatmap_img, residue_img, fg_img, info_md
288
-
289
-
290
- # ============================================================
291
- # Tab 2 β€” Custom Prediction
292
- # ============================================================
293
-
294
- # Example: Imatinib SMILES + first 256 aa of ABL1 kinase domain
295
- _EXAMPLE_SMILES = (
296
- "Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1"
297
- )
298
- _EXAMPLE_SEQUENCE = (
299
- "MGPSENDPNLFVALYDFVASGDNTLSITKGEKLRVLGYNHNGEWCEAQTKNGQGWVPSNYITPVNSLEKHSWYHGPVSRNAAEYLLSSGINGSFLVRESESSPGQRSISLRYEGRVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQHSDSVESAYRSKL"
300
- "LNSGVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQ"
301
- )
302
-
303
- MAX_SEQ_LEN = 512
304
-
305
-
306
- def run_prediction(smiles: str, sequence: str, progress=gr.Progress()):
307
- """
308
- Full inference pipeline: SMILES + sequence β†’ probability + interaction heatmap.
309
- Returns: (status_msg, prob_text, heatmap_image, residue_image)
310
- """
311
- smiles = (smiles or '').strip()
312
- sequence = (sequence or '').strip().upper()
313
-
314
- if not smiles:
315
- return "Please enter a SMILES string.", "", None, None
316
- if not sequence:
317
- return "Please enter a protein sequence (amino acids).", "", None, None
318
-
319
- try:
320
- _load_model()
321
- except Exception as e:
322
- return (f"Model not available: {e}", "", None, None)
323
-
324
- if not PYGEOMETRIC_AVAILABLE:
325
- return (
326
- f"torch-geometric is not available in this environment: {_pyg_err_msg}. "
327
- "Please use the Case Studies tab for pre-computed results.",
328
- "", None, None
329
- )
330
-
331
- # --- Drug graph ---
332
- progress(0.1, desc="Parsing SMILES …")
333
- # import heavy SMILES->graph conversion lazily
334
- from src.data.mol_graph import smiles_to_graph
335
- drug_graph = smiles_to_graph(smiles)
336
- if drug_graph is None:
337
- return "Invalid SMILES string β€” RDKit could not parse it.", "", None, None
338
-
339
- import torch
340
- device = _DEVICE
341
- drug_batch = PyGBatch.from_data_list([drug_graph]).to(device)
342
-
343
- # --- Protein features ---
344
- seq = sequence[:MAX_SEQ_LEN]
345
- if len(sequence) > MAX_SEQ_LEN:
346
- warnings.warn(f"Sequence truncated to {MAX_SEQ_LEN} residues.")
347
- L = len(seq)
348
-
349
- progress(0.2, desc="Computing ESM-2 embeddings (may take 1–3 min on first run) …")
350
- try:
351
- esm2_emb = compute_esm2_embedding(seq) # (L, 640)
352
- except Exception as e:
353
- return f"ESM-2 error: {e}", "", None, None
354
-
355
- esm2_emb = esm2_emb.unsqueeze(0).to(device) # (1, L, 640)
356
- physchem = residue_physicochemical_features(seq).unsqueeze(0).to(device) # (1, L, 4)
357
- domain = residue_domain_labels(L).unsqueeze(0).to(device) # (1, L)
358
- prot_mask = torch.ones(1, L, dtype=torch.bool, device=device)
359
-
360
- # --- Model forward ---
361
- progress(0.85, desc="Running BioInteract model …")
362
- with torch.no_grad():
363
- logit, attn_data = _model(
364
- drug_batch, esm2_emb, physchem, domain, prot_mask,
365
- return_attention=True
366
- )
367
-
368
- prob = torch.sigmoid(logit).item()
369
- interaction_map = attn_data['interaction_map'][0].cpu().numpy() # (N_atoms, L)
370
- drug_mask_np = attn_data['drug_mask'][0].cpu().numpy() # (N_atoms,)
371
- prot_mask_np = prot_mask[0].cpu().numpy() # (L,)
372
-
373
- # Trim padding
374
- n_real_atoms = int(drug_mask_np.sum())
375
- imap = interaction_map[:n_real_atoms, :L]
376
-
377
- # Top-10 residues
378
- residue_scores = imap.sum(axis=0)
379
- residue_scores = residue_scores / (residue_scores.max() + 1e-9)
380
- top_idx = np.argsort(residue_scores)[::-1][:10]
381
- top_residues = [[f"{seq[i]}{i+1}", float(residue_scores[i])] for i in top_idx]
382
-
383
- # --- Plots ---
384
- progress(0.95, desc="Generating plots …")
385
- heatmap_img = _plot_interaction_heatmap(imap, seq, title='Atom–Residue Interaction Map')
386
- residue_img = _plot_top_residues(top_residues, 'Top 10 Binding Residues')
387
-
388
- label = "BINDING" if prob > 0.5 else "NON-BINDING"
389
- prob_text = (
390
- f"## Predicted: {label}\n\n"
391
- f"**Binding probability:** {prob * 100:.1f}% \n"
392
- f"**Atoms analysed:** {n_real_atoms} \n"
393
- f"**Residues analysed:** {L}"
394
- )
395
-
396
- return "Prediction complete.", prob_text, heatmap_img, residue_img
397
-
398
-
399
- # ============================================================
400
- # Global statistics panel (sidebar info)
401
- # ============================================================
402
-
403
- _GLOBAL_STATS = _REPORT.get('global_stats', {})
404
- _stats_md = (
405
- "### Model Statistics (Davis Dataset)\n\n"
406
- f"- **AUROC (random split):** 0.921\n"
407
- f"- **AUROC (cold-target):** 0.941\n"
408
- f"- **Attention sparsity:** {_GLOBAL_STATS.get('attention_sparsity', 0.992)*100:.1f}%\n"
409
- f"- **Training samples:** {_GLOBAL_STATS.get('n_samples', 1506):,}\n"
410
- f"- **Model params:** {_REPORT.get('model_info', {}).get('params', 2442083):,}\n"
411
- )
412
-
413
-
414
- # ============================================================
415
- # Build Gradio UI
416
- # ============================================================
417
-
418
- _HEADER = """
419
- <div style="text-align:center; padding: 16px 0 8px 0;">
420
- <h1 style="margin:0; font-size:2rem;">🧬 BioInteract</h1>
421
- <p style="margin:4px 0; color:#555; font-size:1.05rem;">
422
- Interpretable Drug–Target Interaction Prediction via Residue-Level Cross-Attention
423
- </p>
424
- <p style="margin:0; font-size:0.9rem; color:#888;">
425
- GINE molecular graph encoder Β· ESM-2 protein language model Β· Bidirectional cross-attention
426
- </p>
427
- </div>
428
- """
429
-
430
- with gr.Blocks(
431
- title="BioInteract β€” Interpretable DTI Prediction",
432
- theme=gr.themes.Soft(primary_hue="blue"),
433
- ) as demo:
434
-
435
- gr.HTML(_HEADER)
436
-
437
- with gr.Row():
438
- with gr.Column(scale=3):
439
- with gr.Tabs():
440
-
441
- # ── Tab 1: Case Studies ────────────────────────────────────
442
- with gr.Tab("πŸ“Š Case Studies"):
443
- gr.Markdown(
444
- "Select a **pre-computed case study** to explore the model's "
445
- "atom–residue interaction map and binding residue predictions "
446
- "for clinically validated drug–target pairs."
447
- )
448
- case_dropdown = gr.Dropdown(
449
- choices=list(_FIXED_CASES.keys()),
450
- value=list(_FIXED_CASES.keys())[0],
451
- label="Select drug–target pair",
452
- interactive=True,
453
- )
454
- case_info_md = gr.Markdown()
455
-
456
- with gr.Row():
457
- case_heatmap = gr.Image(label="Atom–Residue Interaction Heatmap",
458
- type='pil', height=400)
459
- with gr.Column():
460
- case_residues = gr.Image(label="Top Binding Residues",
461
- type='pil', height=250)
462
- case_fg = gr.Image(label="Pharmacophore Importance",
463
- type='pil', height=250)
464
-
465
- case_dropdown.change(
466
- fn=show_case_study,
467
- inputs=case_dropdown,
468
- outputs=[case_heatmap, case_residues, case_fg, case_info_md],
469
- )
470
- # Auto-load first case on startup
471
- demo.load(
472
- fn=show_case_study,
473
- inputs=[gr.State(list(_FIXED_CASES.keys())[0])],
474
- outputs=[case_heatmap, case_residues, case_fg, case_info_md],
475
- )
476
-
477
- # ── Tab 2: Custom Prediction ───────────────────────────────
478
- with gr.Tab("πŸ”¬ Custom Prediction"):
479
- gr.Markdown(
480
- "Enter any **drug SMILES** and **protein amino acid sequence** "
481
- "to get a binding prediction with an interaction heatmap. \n"
482
- "> ⏳ First prediction loads ESM-2 (150 M params) on CPU β€” allow **1–3 minutes**. \n"
483
- "> Sequences longer than 512 residues are automatically truncated."
484
- )
485
- with gr.Row():
486
- smiles_box = gr.Textbox(
487
- label="Drug SMILES",
488
- placeholder="e.g. Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1",
489
- lines=2,
490
- )
491
- sequence_box = gr.Textbox(
492
- label="Protein Amino Acid Sequence",
493
- placeholder="e.g. MGPSENDPNLFVALYDFVASGD...",
494
- lines=4,
495
- max_lines=8,
496
- )
497
-
498
- with gr.Row():
499
- example_btn = gr.Button("πŸ“‹ Load Imatinib / ABL1 Example", variant="secondary")
500
- predict_btn = gr.Button("πŸš€ Predict", variant="primary")
501
-
502
- status_box = gr.Textbox(label="Status", interactive=False, lines=1)
503
- prob_md = gr.Markdown()
504
-
505
- with gr.Row():
506
- pred_heatmap = gr.Image(label="Atom–Residue Interaction Heatmap",
507
- type='pil', height=400)
508
- pred_residues = gr.Image(label="Top 10 Binding Residues",
509
- type='pil', height=300)
510
-
511
- example_btn.click(
512
- fn=lambda: (_EXAMPLE_SMILES, _EXAMPLE_SEQUENCE),
513
- inputs=[],
514
- outputs=[smiles_box, sequence_box],
515
- )
516
- predict_btn.click(
517
- fn=run_prediction,
518
- inputs=[smiles_box, sequence_box],
519
- outputs=[status_box, prob_md, pred_heatmap, pred_residues],
520
- )
521
-
522
- # ── Sidebar ────────────────────────────────────────────────────────
523
- with gr.Column(scale=1):
524
- gr.Markdown(_stats_md)
525
- gr.Markdown(
526
- "### Architecture\n\n"
527
- "```\n"
528
- "Drug SMILES\n"
529
- " β†’ GINE (3-layer)\n"
530
- " β†’ atom repr (NΓ—256)\n\n"
531
- "Protein sequence\n"
532
- " β†’ ESM-2 (150M)\n"
533
- " β†’ physicochemical\n"
534
- " β†’ residue repr (LΓ—256)\n\n"
535
- "Bidirectional\n"
536
- "cross-attention\n"
537
- " β†’ interaction map\n"
538
- " β†’ gated pooling\n"
539
- " β†’ binding score\n"
540
- "```"
541
- )
542
- gr.Markdown(
543
- "### Citation\n\n"
544
- "Wang S, Zhang Q et al. \n"
545
- "*BioInteract: Interpretable DTI Prediction via "
546
- "Residue-Level Cross-Attention with Biological Prior Knowledge.* \n"
547
- "PLOS Computational Biology, 2026."
548
- )
549
-
550
-
551
- if __name__ == "__main__":
552
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” BioInteract Gradio Space
3
+ Interpretable Drug–Target Interaction Prediction
4
+
5
+ Two tabs:
6
+ 1. Case Studies β€” pre-computed, clinically validated pairs
7
+ 2. Custom Prediction β€” user supplies SMILES + protein sequence
8
+ """
9
+ import sys
10
+ import json
11
+ import io
12
+ import warnings
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import torch
17
+ import yaml
18
+ import matplotlib
19
+ matplotlib.use('Agg')
20
+ import matplotlib as mpl
21
+ import matplotlib.pyplot as plt
22
+ import seaborn as sns
23
+ import gradio as gr
24
+ from PIL import Image
25
+
26
+ # ---------- path setup ----------
27
+ ROOT = Path(__file__).parent
28
+ sys.path.insert(0, str(ROOT))
29
+
30
+ from src.models.biointeract import BioInteract
31
+ from src.data.mol_graph import smiles_to_graph
32
+ from src.data.protein_feat import residue_physicochemical_features, residue_domain_labels
33
+
34
+ # ---------- publication-style matplotlib defaults ----------
35
+ mpl.rcParams.update({
36
+ 'font.family': 'DejaVu Serif',
37
+ 'font.size': 10,
38
+ 'axes.titlesize': 11,
39
+ 'axes.titleweight': 'bold',
40
+ 'axes.labelsize': 10,
41
+ 'axes.labelcolor': '#1a1a2e',
42
+ 'axes.edgecolor': '#444',
43
+ 'axes.linewidth': 0.8,
44
+ 'axes.spines.top': False,
45
+ 'axes.spines.right': False,
46
+ 'xtick.direction': 'out',
47
+ 'ytick.direction': 'out',
48
+ 'xtick.color': '#444',
49
+ 'ytick.color': '#444',
50
+ 'figure.facecolor': 'white',
51
+ 'axes.facecolor': '#fafafa',
52
+ 'grid.color': '#e0e0e0',
53
+ 'grid.linewidth': 0.5,
54
+ 'savefig.facecolor': 'white',
55
+ 'savefig.dpi': 150,
56
+ })
57
+
58
+ # ============================================================
59
+ # Global model loading
60
+ # ============================================================
61
+
62
+ DEVICE = torch.device('cpu')
63
+ _CONFIG_PATH = ROOT / 'configs' / 'default.yaml'
64
+ _CKPT_PATH = ROOT / 'checkpoints' / 'best.pt'
65
+ _REPORT_PATH = ROOT / 'examples' / 'interpretability_report.json'
66
+
67
+ print("[BioInteract] Loading model config …")
68
+ with open(_CONFIG_PATH) as f:
69
+ _CONFIG = yaml.safe_load(f)
70
+
71
+ print("[BioInteract] Loading pretrained weights …")
72
+ _model = BioInteract(_CONFIG['model']).to(DEVICE)
73
+ _ckpt = torch.load(_CKPT_PATH, map_location='cpu', weights_only=False)
74
+ _model.load_state_dict(_ckpt['model_state_dict'])
75
+ _model.eval()
76
+ print(f"[BioInteract] Model ready β€” epoch {_ckpt.get('epoch','?')}, "
77
+ f"params = {sum(p.numel() for p in _model.parameters()):,}")
78
+
79
+ with open(_REPORT_PATH) as f:
80
+ _REPORT = json.load(f)
81
+
82
+ # ============================================================
83
+ # ESM-2 lazy loader
84
+ # ============================================================
85
+
86
+ _esm_tokenizer = None
87
+ _esm_model = None
88
+ ESM_MODEL_NAME = "facebook/esm2_t30_150M_UR50D"
89
+
90
+
91
+ def _get_esm():
92
+ global _esm_tokenizer, _esm_model
93
+ if _esm_model is None:
94
+ from transformers import EsmModel, EsmTokenizer
95
+ print("[BioInteract] Downloading / loading ESM-2 (150M) …")
96
+ _esm_tokenizer = EsmTokenizer.from_pretrained(ESM_MODEL_NAME)
97
+ _esm_model = EsmModel.from_pretrained(ESM_MODEL_NAME).eval()
98
+ print("[BioInteract] ESM-2 ready")
99
+ return _esm_tokenizer, _esm_model
100
+
101
+
102
+ def compute_esm2_embedding(sequence: str, max_len: int = 512) -> torch.Tensor:
103
+ """Run ESM-2 and return per-residue embeddings (L, 640)."""
104
+ seq = sequence[:max_len]
105
+ tokenizer, esm = _get_esm()
106
+ inputs = tokenizer(seq, return_tensors='pt', add_special_tokens=True)
107
+ with torch.no_grad():
108
+ outputs = esm(**inputs)
109
+ embedding = outputs.last_hidden_state[0, 1:-1, :]
110
+ return embedding[:len(seq)]
111
+
112
+
113
+ # ============================================================
114
+ # Publication-quality plotting helpers
115
+ # ============================================================
116
+
117
+ _HEATMAP_CMAP = 'Blues'
118
+ _BAR_COLOR = '#1a4a7a'
119
+ _BAR_ACCENT = '#2e7cbf'
120
+ _FG_COLOR = '#8b2635'
121
+
122
+
123
+ def _plot_interaction_heatmap(
124
+ interaction_map: np.ndarray,
125
+ sequence: str,
126
+ title: str = 'Atom–Residue Interaction Map',
127
+ ) -> Image.Image:
128
+ """Render interaction heatmap with publication-quality styling."""
129
+ n_atoms, n_res = interaction_map.shape
130
+
131
+ max_show_res = 80
132
+ if n_res > max_show_res:
133
+ scores = interaction_map.sum(axis=0)
134
+ center = int(np.argmax(scores))
135
+ start = max(0, center - max_show_res // 2)
136
+ end = min(n_res, start + max_show_res)
137
+ interaction_map = interaction_map[:, start:end]
138
+ res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
139
+ for i in range(start, end)]
140
+ else:
141
+ res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
142
+ for i in range(n_res)]
143
+
144
+ figw = max(13, len(res_labels) * 0.16)
145
+ figh = max(5, n_atoms * 0.28)
146
+ fig, ax = plt.subplots(figsize=(figw, figh))
147
+
148
+ sns.heatmap(
149
+ interaction_map,
150
+ xticklabels=res_labels,
151
+ yticklabels=[f"a{i+1}" for i in range(n_atoms)],
152
+ cmap=_HEATMAP_CMAP,
153
+ ax=ax,
154
+ linewidths=0,
155
+ cbar_kws={'label': 'Normalised Attention Score', 'shrink': 0.75,
156
+ 'aspect': 20},
157
+ )
158
+ ax.set_title(title, pad=10)
159
+ ax.set_xlabel('Protein Residue', labelpad=6)
160
+ ax.set_ylabel('Drug Atom', labelpad=6)
161
+ plt.xticks(rotation=90, fontsize=5.5)
162
+ plt.yticks(fontsize=6, rotation=0)
163
+
164
+ for spine in ax.spines.values():
165
+ spine.set_visible(False)
166
+
167
+ fig.text(0.02, 0.01,
168
+ 'Colour intensity encodes normalised cross-attention weight',
169
+ fontsize=7, color='#666', style='italic')
170
+ plt.tight_layout(rect=[0, 0.03, 1, 1])
171
+
172
+ buf = io.BytesIO()
173
+ fig.savefig(buf, format='png', bbox_inches='tight')
174
+ plt.close(fig)
175
+ buf.seek(0)
176
+ return Image.open(buf).copy()
177
+
178
+
179
+ def _plot_top_residues(top_residues: list, title: str = 'Top Binding Residues') -> Image.Image:
180
+ """Horizontal bar chart with academic styling."""
181
+ labels = [r[0] for r in top_residues]
182
+ scores = [r[1] for r in top_residues]
183
+
184
+ fig, ax = plt.subplots(figsize=(8, 4.2))
185
+ colors = [_BAR_COLOR if s >= 0.5 else _BAR_ACCENT for s in scores[::-1]]
186
+ bars = ax.barh(labels[::-1], scores[::-1],
187
+ color=colors, edgecolor='none', height=0.65)
188
+
189
+ ax.set_xlabel('Normalised Attention Score', labelpad=6)
190
+ ax.set_title(title, pad=8)
191
+ ax.set_xlim(0, 1.12)
192
+ ax.axvline(0.5, color='#aaa', linewidth=0.8, linestyle='--', alpha=0.7)
193
+ ax.text(0.51, -0.6, 'threshold', fontsize=7, color='#888', style='italic')
194
+
195
+ for bar, score in zip(bars, scores[::-1]):
196
+ ax.text(score + 0.015, bar.get_y() + bar.get_height() / 2,
197
+ f'{score:.3f}', va='center', fontsize=8, color='#222')
198
+
199
+ ax.set_axisbelow(True)
200
+ ax.yaxis.set_tick_params(labelsize=9)
201
+ plt.tight_layout()
202
+
203
+ buf = io.BytesIO()
204
+ fig.savefig(buf, format='png', bbox_inches='tight')
205
+ plt.close(fig)
206
+ buf.seek(0)
207
+ return Image.open(buf).copy()
208
+
209
+
210
+ def _plot_functional_groups(fg_dict: dict, title: str = 'Pharmacophore Importance') -> Image.Image:
211
+ """Horizontal bar chart for functional group Grad-CAM scores."""
212
+ if not fg_dict:
213
+ return None
214
+ labels = list(fg_dict.keys())
215
+ scores = list(fg_dict.values())
216
+
217
+ fig, ax = plt.subplots(figsize=(7, max(3, len(labels) * 0.55)))
218
+ ax.barh(labels, scores, color=_FG_COLOR, edgecolor='none', height=0.6, alpha=0.85)
219
+ ax.set_xlabel('Grad-CAM Importance Score', labelpad=6)
220
+ ax.set_title(title, pad=8)
221
+ ax.set_xlim(0, 1.12)
222
+ for i, (label, score) in enumerate(zip(labels, scores)):
223
+ ax.text(score + 0.015, i, f'{score:.3f}', va='center', fontsize=8, color='#222')
224
+ ax.set_axisbelow(True)
225
+ plt.tight_layout()
226
+
227
+ buf = io.BytesIO()
228
+ fig.savefig(buf, format='png', bbox_inches='tight')
229
+ plt.close(fig)
230
+ buf.seek(0)
231
+ return Image.open(buf).copy()
232
+
233
+
234
+ # ============================================================
235
+ # Tab 1 β€” Case Studies
236
+ # ============================================================
237
+
238
+ _FIXED_CASES = {
239
+ 'ABL1(E255K) + Drug 5328940 (Kd = 0.047 nM)': {
240
+ 'png': ROOT / 'examples' / '5328940_ABL1E255K.png',
241
+ 'prob': 0.988,
242
+ 'affinity_nM': 0.047,
243
+ 'top_residues': [['V104', 1.0], ['A648', 0.503], ['S199', 0.417],
244
+ ['P649', 0.281], ['P936', 0.237], ['N707', 0.192],
245
+ ['P651', 0.180], ['L799', 0.166], ['K796', 0.141], ['P934', 0.079]],
246
+ 'functional_groups': {'Halogen': 0.650, 'Amino': 0.616, 'Ether': 0.345,
247
+ 'Aromatic Ring': 0.311, 'Heterocycle N': 0.167},
248
+ 'description': (
249
+ 'Drug 5328940 binds the ABL1 E255K resistance mutant with extremely high '
250
+ 'affinity (Kd = 0.047 nM). The model predicts binding with 98.8% probability. '
251
+ 'Key contacts include V104 (gatekeeper residue), A648, and S199, consistent '
252
+ 'with known structural data for Type II kinase inhibitors.'
253
+ ),
254
+ },
255
+ 'EGFR + Drug 156414': {
256
+ 'png': ROOT / 'examples' / '156414_EGFR.png',
257
+ 'prob': None,
258
+ 'affinity_nM': None,
259
+ 'top_residues': [],
260
+ 'functional_groups': {},
261
+ 'description': (
262
+ 'Drug 156414 targets the wild-type EGFR kinase domain. '
263
+ 'EGFR inhibitors are first-line treatments for non-small cell lung cancer '
264
+ 'with activating mutations. The interaction map highlights the ATP-binding cleft.'
265
+ ),
266
+ },
267
+ 'BRAF + Drug 11717001 (Sorafenib analogue)': {
268
+ 'png': ROOT / 'examples' / '11717001_BRAF.png',
269
+ 'prob': None,
270
+ 'affinity_nM': None,
271
+ 'top_residues': [],
272
+ 'functional_groups': {},
273
+ 'description': (
274
+ 'Drug 11717001 is a Sorafenib analogue targeting BRAF kinase, a driver '
275
+ 'oncogene in ~50% of cutaneous melanomas (V600E mutation). RAF inhibitors '
276
+ 'block the MAPK/ERK signalling cascade that promotes uncontrolled proliferation.'
277
+ ),
278
+ },
279
+ }
280
+
281
+
282
+ def show_case_study(case_key: str):
283
+ """Called when user selects a case from the dropdown."""
284
+ case = _FIXED_CASES.get(case_key)
285
+ if case is None:
286
+ return None, None, None, "Case not found."
287
+
288
+ heatmap_img = Image.open(case['png']) if case['png'].exists() else None
289
+
290
+ prob_text = (f"**Predicted binding probability:** {case['prob']*100:.1f}%\n\n"
291
+ if case['prob'] else "")
292
+ kd_text = (f"**Experimental affinity (Kd):** {case['affinity_nM']} nM\n\n"
293
+ if case['affinity_nM'] else "")
294
+ info_md = (
295
+ f"#### {case_key.strip()}\n\n"
296
+ f"{prob_text}{kd_text}"
297
+ f"**Clinical context:** {case['description']}"
298
+ )
299
+
300
+ residue_img = (
301
+ _plot_top_residues(case['top_residues'],
302
+ f'Top Binding Residues β€” {case_key.split("+")[0].strip()}')
303
+ if case['top_residues'] else None
304
+ )
305
+ fg_img = (
306
+ _plot_functional_groups(case['functional_groups'],
307
+ f'Pharmacophore Importance β€” {case_key.split("+")[0].strip()}')
308
+ if case['functional_groups'] else None
309
+ )
310
+
311
+ return heatmap_img, residue_img, fg_img, info_md
312
+
313
+
314
+ # ============================================================
315
+ # Tab 2 β€” Custom Prediction
316
+ # ============================================================
317
+
318
+ _EXAMPLE_SMILES = (
319
+ "Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1"
320
+ )
321
+ _EXAMPLE_SEQUENCE = (
322
+ "MGPSENDPNLFVALYDFVASGDNTLSITKGEKLRVLGYNHNGEWCEAQTKNGQGWVPSNYITPVNSLEKHSWYHGPVSRNAAEYLLSSGINGSFLVRESESSPGQRSISLRYEGRVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQHSDSVESAYRSKL"
323
+ "LNSGVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQ"
324
+ )
325
+
326
+ MAX_SEQ_LEN = 512
327
+
328
+
329
+ def run_prediction(smiles: str, sequence: str, progress=gr.Progress()):
330
+ """
331
+ Full inference pipeline: SMILES + sequence β†’ probability + interaction heatmap.
332
+ Returns: (status_msg, prob_text, heatmap_image, residue_image)
333
+ """
334
+ smiles = (smiles or '').strip()
335
+ sequence = (sequence or '').strip().upper()
336
+
337
+ if not smiles:
338
+ return "Input required: please provide a SMILES string.", "", None, None
339
+ if not sequence:
340
+ return "Input required: please provide an amino acid sequence.", "", None, None
341
+
342
+ progress(0.1, desc="Parsing SMILES string via RDKit …")
343
+ drug_graph = smiles_to_graph(smiles)
344
+ if drug_graph is None:
345
+ return "Parse error: RDKit could not interpret the SMILES string.", "", None, None
346
+
347
+ from torch_geometric.data import Batch
348
+ drug_batch = Batch.from_data_list([drug_graph]).to(DEVICE)
349
+
350
+ seq = sequence[:MAX_SEQ_LEN]
351
+ if len(sequence) > MAX_SEQ_LEN:
352
+ warnings.warn(f"Sequence truncated to {MAX_SEQ_LEN} residues.")
353
+ L = len(seq)
354
+
355
+ progress(0.2, desc="Computing ESM-2 residue embeddings (first run: ~2 min) …")
356
+ try:
357
+ esm2_emb = compute_esm2_embedding(seq)
358
+ except Exception as e:
359
+ return f"ESM-2 error: {e}", "", None, None
360
+
361
+ esm2_emb = esm2_emb.unsqueeze(0).to(DEVICE)
362
+ physchem = residue_physicochemical_features(seq).unsqueeze(0).to(DEVICE)
363
+ domain = residue_domain_labels(L).unsqueeze(0).to(DEVICE)
364
+ prot_mask = torch.ones(1, L, dtype=torch.bool, device=DEVICE)
365
+
366
+ progress(0.85, desc="Running BioInteract cross-attention inference …")
367
+ with torch.no_grad():
368
+ logit, attn_data = _model(
369
+ drug_batch, esm2_emb, physchem, domain, prot_mask,
370
+ return_attention=True
371
+ )
372
+
373
+ prob = torch.sigmoid(logit).item()
374
+ interaction_map = attn_data['interaction_map'][0].cpu().numpy()
375
+ drug_mask_np = attn_data['drug_mask'][0].cpu().numpy()
376
+
377
+ n_real_atoms = int(drug_mask_np.sum())
378
+ imap = interaction_map[:n_real_atoms, :L]
379
+
380
+ residue_scores = imap.sum(axis=0)
381
+ residue_scores = residue_scores / (residue_scores.max() + 1e-9)
382
+ top_idx = np.argsort(residue_scores)[::-1][:10]
383
+ top_residues = [[f"{seq[i]}{i+1}", float(residue_scores[i])] for i in top_idx]
384
+
385
+ progress(0.95, desc="Generating publication-quality figures …")
386
+ heatmap_img = _plot_interaction_heatmap(imap, seq, title='Atom–Residue Cross-Attention Map')
387
+ residue_img = _plot_top_residues(top_residues, 'Top 10 Predicted Binding Residues')
388
+
389
+ label = "**BINDING**" if prob > 0.5 else "**NON-BINDING**"
390
+ conf = "High confidence" if abs(prob - 0.5) > 0.3 else "Moderate confidence"
391
+ prob_text = (
392
+ f"### Prediction Result: {label}\n\n"
393
+ f"| Metric | Value |\n"
394
+ f"|--------|-------|\n"
395
+ f"| Binding probability | **{prob * 100:.1f}%** |\n"
396
+ f"| Confidence | {conf} |\n"
397
+ f"| Drug atoms analysed | {n_real_atoms} |\n"
398
+ f"| Protein residues analysed | {L} |\n"
399
+ )
400
+
401
+ return "Inference complete.", prob_text, heatmap_img, residue_img
402
+
403
+
404
+ # ============================================================
405
+ # Global statistics panel
406
+ # ============================================================
407
+
408
+ _GLOBAL_STATS = _REPORT.get('global_stats', {})
409
+
410
+ _SIDEBAR_HTML = f"""
411
+ <div style="background:#f8f9fa; border:1px solid #dee2e6; border-radius:6px; padding:16px; font-family:'Georgia',serif;">
412
+
413
+ <div style="border-bottom:2px solid #1a4a7a; margin-bottom:12px; padding-bottom:6px;">
414
+ <strong style="color:#1a4a7a; font-size:0.9rem; text-transform:uppercase; letter-spacing:0.04em;">
415
+ Performance Metrics
416
+ </strong><br>
417
+ <span style="color:#666; font-size:0.78rem;">Davis Kinase Benchmark Dataset</span>
418
+ </div>
419
+
420
+ <table style="width:100%; border-collapse:collapse; font-size:0.82rem; margin-bottom:14px;">
421
+ <thead>
422
+ <tr style="background:#1a4a7a; color:white;">
423
+ <th style="padding:6px 8px; text-align:left; font-weight:600;">Split</th>
424
+ <th style="padding:6px 8px; text-align:center; font-weight:600;">AUROC</th>
425
+ <th style="padding:6px 8px; text-align:center; font-weight:600;">AUPRC</th>
426
+ </tr>
427
+ </thead>
428
+ <tbody>
429
+ <tr style="background:#eef2f7;">
430
+ <td style="padding:5px 8px;">Random</td>
431
+ <td style="padding:5px 8px; text-align:center;">0.921</td>
432
+ <td style="padding:5px 8px; text-align:center;">0.608</td>
433
+ </tr>
434
+ <tr>
435
+ <td style="padding:5px 8px;">Cold-Drug</td>
436
+ <td style="padding:5px 8px; text-align:center;">0.739</td>
437
+ <td style="padding:5px 8px; text-align:center;">0.169</td>
438
+ </tr>
439
+ <tr style="background:#eef2f7;">
440
+ <td style="padding:5px 8px;"><strong>Cold-Target</strong></td>
441
+ <td style="padding:5px 8px; text-align:center;"><strong>0.941</strong></td>
442
+ <td style="padding:5px 8px; text-align:center;"><strong>0.549</strong></td>
443
+ </tr>
444
+ </tbody>
445
+ </table>
446
+
447
+ <div style="border-bottom:1px solid #dee2e6; margin-bottom:10px; padding-bottom:4px;">
448
+ <strong style="color:#1a4a7a; font-size:0.85rem;">Model Specifications</strong>
449
+ </div>
450
+ <table style="width:100%; border-collapse:collapse; font-size:0.81rem; margin-bottom:14px;">
451
+ <tr><td style="padding:4px 0; color:#555;">Parameters</td>
452
+ <td style="padding:4px 0; text-align:right;">{_REPORT.get('model_info', {}).get('params', 2_442_083):,}</td></tr>
453
+ <tr><td style="padding:4px 0; color:#555;">Training samples</td>
454
+ <td style="padding:4px 0; text-align:right;">{_GLOBAL_STATS.get('n_samples', 1506):,}</td></tr>
455
+ <tr><td style="padding:4px 0; color:#555;">Attention sparsity</td>
456
+ <td style="padding:4px 0; text-align:right;">{_GLOBAL_STATS.get('attention_sparsity', 0.992)*100:.1f}%</td></tr>
457
+ <tr><td style="padding:4px 0; color:#555;">GNN layers</td>
458
+ <td style="padding:4px 0; text-align:right;">3 Γ— GINE</td></tr>
459
+ <tr><td style="padding:4px 0; color:#555;">Attention heads</td>
460
+ <td style="padding:4px 0; text-align:right;">8</td></tr>
461
+ <tr><td style="padding:4px 0; color:#555;">Hidden dimension</td>
462
+ <td style="padding:4px 0; text-align:right;">256</td></tr>
463
+ </table>
464
+
465
+ <div style="border-bottom:1px solid #dee2e6; margin-bottom:10px; padding-bottom:4px;">
466
+ <strong style="color:#1a4a7a; font-size:0.85rem;">Architecture Overview</strong>
467
+ </div>
468
+ <pre style="background:#1a2a3a; color:#c8d8e8; padding:10px; border-radius:4px; font-size:0.72rem; line-height:1.5; margin:0; overflow:auto;">
469
+ Drug SMILES
470
+ β†’ GINE (3 layers, dim=256)
471
+ β†’ N Γ— atom vectors
472
+
473
+ Protein sequence
474
+ β†’ ESM-2 (150M params)
475
+ β†’ physicochemical (4-dim)
476
+ β†’ L Γ— residue vectors
477
+
478
+ Bidirectional cross-attention
479
+ β†’ NΓ—L interaction map
480
+ β†’ gated pooling
481
+ β†’ binding score</pre>
482
+
483
+ <div style="margin-top:14px; padding:10px; background:#fff8e1; border-left:3px solid #f9a825; border-radius:0 4px 4px 0; font-size:0.8rem; color:#555; line-height:1.5;">
484
+ <strong style="color:#e65100;">Reference</strong><br>
485
+ Wang S, Zhang Q <em>et al.</em> BioInteract: Interpretable DTI Prediction via
486
+ Residue-Level Cross-Attention with Biological Prior Knowledge.
487
+ <em>PLOS Computational Biology</em>, 2026.
488
+ </div>
489
+ </div>
490
+ """
491
+
492
+ # ============================================================
493
+ # Custom CSS
494
+ # ============================================================
495
+
496
+ _CSS = """
497
+ .gradio-container {
498
+ font-family: 'Georgia', 'Times New Roman', serif !important;
499
+ max-width: 1440px !important;
500
+ }
501
+ .tab-nav {
502
+ border-bottom: 2px solid #1a4a7a !important;
503
+ }
504
+ .tab-nav button {
505
+ font-size: 0.82rem !important;
506
+ font-weight: 600 !important;
507
+ letter-spacing: 0.03em !important;
508
+ text-transform: uppercase !important;
509
+ color: #555 !important;
510
+ }
511
+ .tab-nav button.selected {
512
+ color: #1a4a7a !important;
513
+ border-bottom: 2px solid #1a4a7a !important;
514
+ }
515
+ label span {
516
+ font-size: 0.82rem !important;
517
+ font-weight: 600 !important;
518
+ color: #1a2a3a !important;
519
+ text-transform: uppercase !important;
520
+ letter-spacing: 0.03em !important;
521
+ }
522
+ .gr-button-primary {
523
+ background: #1a4a7a !important;
524
+ border-color: #1a4a7a !important;
525
+ }
526
+ .gr-button-primary:hover {
527
+ background: #0d2137 !important;
528
+ }
529
+ footer { display: none !important; }
530
+ """
531
+
532
+ # ============================================================
533
+ # Build Gradio UI
534
+ # ============================================================
535
+
536
+ _HEADER_HTML = """
537
+ <div style="
538
+ background: linear-gradient(135deg, #0d2137 0%, #1a4a7a 100%);
539
+ padding: 24px 32px 20px;
540
+ border-radius: 8px;
541
+ margin-bottom: 4px;
542
+ ">
543
+ <h1 style="
544
+ color: #ffffff;
545
+ margin: 0 0 6px;
546
+ font-size: 1.65rem;
547
+ font-weight: 700;
548
+ font-family: 'Georgia', serif;
549
+ letter-spacing: -0.3px;
550
+ ">BioInteract</h1>
551
+ <p style="
552
+ color: #b0cee8;
553
+ margin: 0 0 14px;
554
+ font-size: 0.95rem;
555
+ font-style: italic;
556
+ font-family: 'Georgia', serif;
557
+ line-height: 1.4;
558
+ ">
559
+ Interpretable Drug–Target Interaction Prediction via
560
+ Residue-Level Cross-Attention with Biological Prior Knowledge
561
+ </p>
562
+ <div style="display: flex; gap: 8px; flex-wrap: wrap;">
563
+ <span style="background:rgba(255,255,255,0.13); color:#d0e8ff;
564
+ padding:3px 12px; border-radius:20px; font-size:0.76rem;
565
+ font-family:monospace; letter-spacing:0.02em;">
566
+ GINE Graph Encoder
567
+ </span>
568
+ <span style="background:rgba(255,255,255,0.13); color:#d0e8ff;
569
+ padding:3px 12px; border-radius:20px; font-size:0.76rem;
570
+ font-family:monospace; letter-spacing:0.02em;">
571
+ ESM-2 (150 M)
572
+ </span>
573
+ <span style="background:rgba(255,255,255,0.13); color:#d0e8ff;
574
+ padding:3px 12px; border-radius:20px; font-size:0.76rem;
575
+ font-family:monospace; letter-spacing:0.02em;">
576
+ Bidirectional Cross-Attention
577
+ </span>
578
+ <span style="background:rgba(200,230,80,0.2); color:#d4f0a0;
579
+ padding:3px 12px; border-radius:20px; font-size:0.76rem;
580
+ font-family:monospace; letter-spacing:0.02em;">
581
+ AUROC 0.941 (cold-target)
582
+ </span>
583
+ </div>
584
+ </div>
585
+ """
586
+
587
+ _ABSTRACT_HTML = """
588
+ <div style="
589
+ background:#f4f7fb;
590
+ border-left:4px solid #1a4a7a;
591
+ padding:12px 18px;
592
+ margin:8px 0 4px;
593
+ border-radius:0 5px 5px 0;
594
+ font-family:'Georgia',serif;
595
+ ">
596
+ <strong style="color:#1a4a7a; font-size:0.78rem; text-transform:uppercase;
597
+ letter-spacing:0.06em;">Abstract</strong>
598
+ <p style="margin:6px 0 0; font-size:0.88rem; color:#2a2a3e; line-height:1.65;">
599
+ BioInteract couples a pharmacophore-aware Graph Isomorphism Network with Edge features
600
+ (GINE) for molecular encoding with ESM-2 protein language model representations for
601
+ residue encoding. A bidirectional cross-attention mechanism generates an interpretable
602
+ atom–residue interaction map, enabling simultaneous prediction of binding affinity and
603
+ mechanistic insight into which drug substructures engage specific protein residues.
604
+ Evaluated on the Davis kinase dataset, the model achieves AUROC&nbsp;0.941 on the
605
+ cold-target split, demonstrating strong generalisation to unseen protein targets.
606
+ </p>
607
+ </div>
608
+ """
609
+
610
+ with gr.Blocks(
611
+ title="BioInteract β€” Interpretable DTI Prediction",
612
+ theme=gr.themes.Base(
613
+ primary_hue=gr.themes.colors.blue,
614
+ neutral_hue=gr.themes.colors.slate,
615
+ font=[gr.themes.GoogleFont("Source Serif 4"), "Georgia", "serif"],
616
+ ),
617
+ css=_CSS,
618
+ ) as demo:
619
+
620
+ gr.HTML(_HEADER_HTML)
621
+ gr.HTML(_ABSTRACT_HTML)
622
+
623
+ with gr.Row(equal_height=False):
624
+ # ── Main content area ──────────────────────────────────────────────
625
+ with gr.Column(scale=3):
626
+ with gr.Tabs():
627
+
628
+ # ── Tab 1: Case Studies ────────────────────────────────────
629
+ with gr.Tab("Case Studies"):
630
+ gr.Markdown(
631
+ "Select a pre-computed case study to examine the model's atom–residue "
632
+ "interaction map and predicted binding residues for clinically validated "
633
+ "drug–target pairs. All cases are drawn from the Davis kinase benchmark."
634
+ )
635
+ case_dropdown = gr.Dropdown(
636
+ choices=list(_FIXED_CASES.keys()),
637
+ value=list(_FIXED_CASES.keys())[0],
638
+ label="Drug–Target Pair",
639
+ interactive=True,
640
+ )
641
+ case_info_md = gr.Markdown(
642
+ container=True,
643
+ min_height=80,
644
+ )
645
+
646
+ with gr.Row():
647
+ case_heatmap = gr.Image(
648
+ label="Atom–Residue Cross-Attention Heatmap",
649
+ type='pil',
650
+ height=420,
651
+ )
652
+ with gr.Column():
653
+ case_residues = gr.Image(
654
+ label="Top Predicted Binding Residues",
655
+ type='pil',
656
+ height=280,
657
+ )
658
+ case_fg = gr.Image(
659
+ label="Pharmacophore Group Importance (Grad-CAM)",
660
+ type='pil',
661
+ height=240,
662
+ )
663
+
664
+ gr.Markdown(
665
+ "_Figure caption:_ The heatmap encodes normalised cross-attention "
666
+ "weights between each drug atom (rows) and protein residue (columns). "
667
+ "Darker cells indicate stronger predicted interactions. "
668
+ "The bar chart ranks residues by aggregated attention score."
669
+ )
670
+
671
+ case_dropdown.change(
672
+ fn=show_case_study,
673
+ inputs=case_dropdown,
674
+ outputs=[case_heatmap, case_residues, case_fg, case_info_md],
675
+ )
676
+ demo.load(
677
+ fn=lambda: show_case_study(list(_FIXED_CASES.keys())[0]),
678
+ inputs=[],
679
+ outputs=[case_heatmap, case_residues, case_fg, case_info_md],
680
+ )
681
+
682
+ # ── Tab 2: Custom Prediction ───────────────────────────────
683
+ with gr.Tab("Custom Prediction"):
684
+ gr.Markdown(
685
+ "Provide a drug SMILES string and a protein amino acid sequence "
686
+ "to obtain a binding prediction with an interpretable cross-attention map.\n\n"
687
+ "> **Note:** ESM-2 (150 M parameters) initialises on the first request; "
688
+ "please allow 1–3 minutes on CPU. Sequences exceeding 512 residues are "
689
+ "automatically truncated to the first 512 positions."
690
+ )
691
+
692
+ with gr.Row():
693
+ smiles_box = gr.Textbox(
694
+ label="Drug SMILES",
695
+ placeholder=(
696
+ "e.g. Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1"
697
+ "Nc1nccc(-c2cccnc2)n1 (Imatinib)"
698
+ ),
699
+ lines=2,
700
+ )
701
+ sequence_box = gr.Textbox(
702
+ label="Protein Amino Acid Sequence (single-letter code)",
703
+ placeholder="e.g. MGPSENDPNLFVALYDFVASGDNTLS…",
704
+ lines=4,
705
+ max_lines=8,
706
+ )
707
+
708
+ with gr.Row():
709
+ example_btn = gr.Button(
710
+ "Load Imatinib / ABL1 Example",
711
+ variant="secondary",
712
+ size="sm",
713
+ )
714
+ predict_btn = gr.Button(
715
+ "Run Prediction",
716
+ variant="primary",
717
+ size="lg",
718
+ )
719
+
720
+ status_box = gr.Textbox(
721
+ label="Status",
722
+ interactive=False,
723
+ lines=1,
724
+ placeholder="Awaiting input …",
725
+ )
726
+ prob_md = gr.Markdown(min_height=80)
727
+
728
+ with gr.Row():
729
+ pred_heatmap = gr.Image(
730
+ label="Atom–Residue Cross-Attention Heatmap",
731
+ type='pil',
732
+ height=420,
733
+ )
734
+ pred_residues = gr.Image(
735
+ label="Top 10 Predicted Binding Residues",
736
+ type='pil',
737
+ height=320,
738
+ )
739
+
740
+ gr.Markdown(
741
+ "_Interpretation:_ Rows correspond to heavy atoms of the drug molecule; "
742
+ "columns to protein residues. High-intensity cells indicate residues "
743
+ "predicted to form key contacts with the respective drug atoms. "
744
+ "The bar chart aggregates attention over all atoms for each residue."
745
+ )
746
+
747
+ example_btn.click(
748
+ fn=lambda: (_EXAMPLE_SMILES, _EXAMPLE_SEQUENCE),
749
+ inputs=[],
750
+ outputs=[smiles_box, sequence_box],
751
+ )
752
+ predict_btn.click(
753
+ fn=run_prediction,
754
+ inputs=[smiles_box, sequence_box],
755
+ outputs=[status_box, prob_md, pred_heatmap, pred_residues],
756
+ )
757
+
758
+ # ── Sidebar ────────────────────────────────────────────────────────
759
+ with gr.Column(scale=1, min_width=260):
760
+ gr.HTML(_SIDEBAR_HTML)
761
+
762
+
763
+ if __name__ == "__main__":
764
+ demo.launch(server_name="0.0.0.0", server_port=7860)
requirements.txt CHANGED
@@ -1,9 +1,15 @@
1
- audioop-lts
2
- numpy
3
- matplotlib
4
- seaborn
5
- pandas
6
- scipy
7
- scikit-learn
8
- PyYAML
9
- Pillow
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.4.0
3
+ torch-geometric==2.6.1
4
+ rdkit
5
+ transformers==5.6.1
6
+ numpy==1.26.4
7
+ pandas==2.0.3
8
+ scipy==1.13.1
9
+ scikit-learn==1.2.1
10
+ PyYAML==6.0.2
11
+ matplotlib==3.8.4
12
+ seaborn==0.12.2
13
+ tqdm==4.67.3
14
+ Pillow==9.4.0
15
+ gradio==5.49.1
src/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (179 Bytes). View file
 
src/data/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (184 Bytes). View file
 
src/data/__pycache__/mol_graph.cpython-310.pyc ADDED
Binary file (6.74 kB). View file
 
src/data/__pycache__/protein_feat.cpython-310.pyc ADDED
Binary file (3.46 kB). View file
 
src/models/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (186 Bytes). View file
 
src/models/__pycache__/biointeract.cpython-310.pyc ADDED
Binary file (5.87 kB). View file
 
src/models/__pycache__/drug_encoder.cpython-310.pyc ADDED
Binary file (7.01 kB). View file
 
src/models/__pycache__/interaction.cpython-310.pyc ADDED
Binary file (6.04 kB). View file
 
src/models/__pycache__/target_encoder.cpython-310.pyc ADDED
Binary file (3.23 kB). View file