AI4deeperScience commited on
Commit
8f66d04
·
verified ·
1 Parent(s): 8c433e2

Deploy BioInteract Gradio demo

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/11717001_BRAF.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/156414_EGFR.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/5328940_ABL1E255K.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,12 +1,60 @@
1
- ---
2
- title: BioInteract
3
- emoji: 📈
4
- colorFrom: pink
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: BioInteract Drug-Target Interaction
3
+ emoji: 🧬
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ short_description: Interpretable DTI prediction with cross-attention
12
+ ---
13
+
14
+ # BioInteract
15
+
16
+ **Interpretable Drug–Target Interaction Prediction via Residue-Level Cross-Attention with Biological Prior Knowledge**
17
+
18
+ BioInteract is a deep learning framework that predicts whether a drug molecule will bind to a protein target, while simultaneously generating an interpretable atom–residue interaction map that shows *which* drug atoms interact with *which* protein residues.
19
+
20
+ ## How to Use
21
+
22
+ ### Tab 1 — Case Studies
23
+ Explore three pre-computed, clinically validated drug–target pairs:
24
+ - **ABL1(E255K) + Drug 5328940** (resistance mutant, Kd = 0.047 nM)
25
+ - **EGFR + Drug 156414** (kinase inhibitor)
26
+ - **BRAF + Drug 11717001** (RAF inhibitor)
27
+
28
+ Each case shows the full atom–residue interaction heatmap, top binding residues, and model prediction probability.
29
+
30
+ ### Tab 2 — Custom Prediction
31
+ Enter any drug SMILES string and protein amino acid sequence.
32
+ The model will:
33
+ 1. Encode the drug via pharmacophore-aware GINE molecular graph
34
+ 2. Encode the protein using ESM-2 (150M) residue embeddings + physicochemical features
35
+ 3. Compute bidirectional cross-attention to generate an interaction map
36
+ 4. Predict binding probability
37
+
38
+ > **Note:** First prediction on CPU may take 1–3 minutes as ESM-2 initialises.
39
+ > Sequences are truncated to 512 residues for demo speed.
40
+
41
+ ## Model Architecture
42
+
43
+ ```
44
+ Drug (SMILES) → GINE encoder → atom representations
45
+ Protein (AA seq) → ESM-2 + physicochemical → residue representations
46
+ ↓ bidirectional cross-attention ↓
47
+ atom × residue interaction map → gated pooling → binding score
48
+ ```
49
+
50
+ ## Performance (Davis Kinase Dataset)
51
+
52
+ | Split | AUROC | AUPRC |
53
+ |-------|-------|-------|
54
+ | Random | 0.921 | 0.608 |
55
+ | Cold-Drug | 0.739 | 0.169 |
56
+ | Cold-Target | **0.941** | 0.549 |
57
+
58
+ ## Citation
59
+
60
+ > BioInteract: Interpretable Drug–Target Interaction Prediction via Residue-Level Cross-Attention with Biological Prior Knowledge. *PLOS Computational Biology*, 2026.
app.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.pyplot as plt
21
+ import seaborn as sns
22
+ import gradio as gr
23
+ from PIL import Image
24
+
25
+ # ---------- path setup ----------
26
+ ROOT = Path(__file__).parent
27
+ sys.path.insert(0, str(ROOT))
28
+
29
+ from src.models.biointeract import BioInteract
30
+ from src.data.mol_graph import smiles_to_graph
31
+ from src.data.protein_feat import residue_physicochemical_features, residue_domain_labels
32
+
33
+ # ============================================================
34
+ # Global model loading
35
+ # ============================================================
36
+
37
+ DEVICE = torch.device('cpu')
38
+ _CONFIG_PATH = ROOT / 'configs' / 'default.yaml'
39
+ _CKPT_PATH = ROOT / 'checkpoints' / 'best.pt'
40
+ _REPORT_PATH = ROOT / 'examples' / 'interpretability_report.json'
41
+
42
+ print("[BioInteract] Loading model config …")
43
+ with open(_CONFIG_PATH) as f:
44
+ _CONFIG = yaml.safe_load(f)
45
+
46
+ print("[BioInteract] Loading pretrained weights …")
47
+ _model = BioInteract(_CONFIG['model']).to(DEVICE)
48
+ _ckpt = torch.load(_CKPT_PATH, map_location='cpu', weights_only=False)
49
+ _model.load_state_dict(_ckpt['model_state_dict'])
50
+ _model.eval()
51
+ print(f"[BioInteract] Model ready — epoch {_ckpt.get('epoch','?')}, "
52
+ f"params = {sum(p.numel() for p in _model.parameters()):,}")
53
+
54
+ # Load pre-computed case study report
55
+ with open(_REPORT_PATH) as f:
56
+ _REPORT = json.load(f)
57
+
58
+ # ============================================================
59
+ # ESM-2 lazy loader (only initialised on first custom prediction)
60
+ # ============================================================
61
+
62
+ _esm_tokenizer = None
63
+ _esm_model = None
64
+ ESM_MODEL_NAME = "facebook/esm2_t30_150M_UR50D"
65
+
66
+ def _get_esm():
67
+ global _esm_tokenizer, _esm_model
68
+ if _esm_model is None:
69
+ from transformers import EsmModel, EsmTokenizer
70
+ print("[BioInteract] Downloading / loading ESM-2 (150M) …")
71
+ _esm_tokenizer = EsmTokenizer.from_pretrained(ESM_MODEL_NAME)
72
+ _esm_model = EsmModel.from_pretrained(ESM_MODEL_NAME).eval()
73
+ print("[BioInteract] ESM-2 ready")
74
+ return _esm_tokenizer, _esm_model
75
+
76
+
77
+ def compute_esm2_embedding(sequence: str, max_len: int = 512) -> torch.Tensor:
78
+ """Run ESM-2 and return per-residue embeddings (L, 640)."""
79
+ seq = sequence[:max_len]
80
+ tokenizer, esm = _get_esm()
81
+ inputs = tokenizer(seq, return_tensors='pt', add_special_tokens=True)
82
+ with torch.no_grad():
83
+ outputs = esm(**inputs)
84
+ # strip [CLS] and [EOS] tokens → (L, 640)
85
+ embedding = outputs.last_hidden_state[0, 1:-1, :]
86
+ return embedding[:len(seq)]
87
+
88
+
89
+ # ============================================================
90
+ # Plotting helpers
91
+ # ============================================================
92
+
93
+ def _plot_interaction_heatmap(
94
+ interaction_map: np.ndarray,
95
+ sequence: str,
96
+ title: str = 'Atom–Residue Interaction Map',
97
+ ) -> Image.Image:
98
+ """Render interaction heatmap as a PIL Image."""
99
+ n_atoms, n_res = interaction_map.shape
100
+
101
+ # subsample residues if too long
102
+ max_show_res = 80
103
+ if n_res > max_show_res:
104
+ scores = interaction_map.sum(axis=0)
105
+ center = int(np.argmax(scores))
106
+ start = max(0, center - max_show_res // 2)
107
+ end = min(n_res, start + max_show_res)
108
+ interaction_map = interaction_map[:, start:end]
109
+ res_labels = [f"{seq[i]}{i+1}" for i, seq in
110
+ enumerate([sequence[j] for j in range(start, end)])]
111
+ # simpler: just residue index labels
112
+ res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
113
+ for i in range(start, end)]
114
+ else:
115
+ res_labels = [f"{sequence[i]}{i+1}" if i < len(sequence) else str(i+1)
116
+ for i in range(n_res)]
117
+
118
+ figw = max(12, len(res_labels) * 0.15)
119
+ figh = max(5, n_atoms * 0.25)
120
+ fig, ax = plt.subplots(figsize=(figw, figh))
121
+ sns.heatmap(
122
+ interaction_map,
123
+ xticklabels=res_labels,
124
+ yticklabels=[f"A{i}" for i in range(n_atoms)],
125
+ cmap='YlOrRd',
126
+ ax=ax,
127
+ cbar_kws={'label': 'Attention Score', 'shrink': 0.8},
128
+ )
129
+ ax.set_title(title, fontsize=13, fontweight='bold')
130
+ ax.set_xlabel('Protein Residue', fontsize=10)
131
+ ax.set_ylabel('Drug Atom', fontsize=10)
132
+ plt.xticks(rotation=90, fontsize=6)
133
+ plt.yticks(fontsize=6)
134
+ plt.tight_layout()
135
+
136
+ buf = io.BytesIO()
137
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
138
+ plt.close(fig)
139
+ buf.seek(0)
140
+ return Image.open(buf).copy()
141
+
142
+
143
+ def _plot_top_residues(top_residues: list, title: str = 'Top Binding Residues') -> Image.Image:
144
+ """Bar chart of top residue attention scores."""
145
+ labels = [r[0] for r in top_residues]
146
+ scores = [r[1] for r in top_residues]
147
+
148
+ fig, ax = plt.subplots(figsize=(8, 4))
149
+ bars = ax.barh(labels[::-1], scores[::-1], color='steelblue', edgecolor='white')
150
+ ax.set_xlabel('Normalised Attention Score', fontsize=10)
151
+ ax.set_title(title, fontsize=11, fontweight='bold')
152
+ ax.set_xlim(0, 1.05)
153
+ for bar, score in zip(bars, scores[::-1]):
154
+ ax.text(score + 0.01, bar.get_y() + bar.get_height() / 2,
155
+ f'{score:.3f}', va='center', fontsize=8)
156
+ plt.tight_layout()
157
+
158
+ buf = io.BytesIO()
159
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
160
+ plt.close(fig)
161
+ buf.seek(0)
162
+ return Image.open(buf).copy()
163
+
164
+
165
+ def _plot_functional_groups(fg_dict: dict, title: str = 'Pharmacophore Importance') -> Image.Image:
166
+ """Horizontal bar chart for functional group Grad-CAM scores."""
167
+ if not fg_dict:
168
+ return None
169
+ labels = list(fg_dict.keys())
170
+ scores = list(fg_dict.values())
171
+
172
+ fig, ax = plt.subplots(figsize=(7, max(3, len(labels) * 0.5)))
173
+ ax.barh(labels, scores, color='coral', edgecolor='white')
174
+ ax.set_xlabel('Grad-CAM Importance', fontsize=10)
175
+ ax.set_title(title, fontsize=11, fontweight='bold')
176
+ ax.set_xlim(0, 1.05)
177
+ plt.tight_layout()
178
+
179
+ buf = io.BytesIO()
180
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
181
+ plt.close(fig)
182
+ buf.seek(0)
183
+ return Image.open(buf).copy()
184
+
185
+
186
+ # ============================================================
187
+ # Tab 1 — Case Studies
188
+ # ============================================================
189
+
190
+ _CASE_OPTIONS = {
191
+ f"{cs['drug_name']} / {cs['target_name']}": cs
192
+ for cs in _REPORT.get('case_studies', [])
193
+ if (ROOT / 'examples' / f"{cs['drug_name']}_{cs['target_name'].replace('(','').replace(')','').replace('/','')}.png").exists()
194
+ or (ROOT / 'examples' / f"{cs['drug_name']}_{cs['target_name']}.png").exists()
195
+ }
196
+
197
+ # If the report has no exact-match filenames, fall back to the 3 available PNGs
198
+ _FIXED_CASES = {
199
+ 'ABL1(E255K) + Drug 5328940 (Kd = 0.047 nM)': {
200
+ 'png': ROOT / 'examples' / '5328940_ABL1E255K.png',
201
+ 'prob': 0.988,
202
+ 'affinity_nM': 0.047,
203
+ 'top_residues': [['V104', 1.0], ['A648', 0.503], ['S199', 0.417],
204
+ ['P649', 0.281], ['P936', 0.237], ['N707', 0.192],
205
+ ['P651', 0.180], ['L799', 0.166], ['K796', 0.141], ['P934', 0.079]],
206
+ 'functional_groups': {'Halogen': 0.650, 'Amino': 0.616, 'Ether': 0.345,
207
+ 'Aromatic Ring': 0.311, 'Heterocycle N': 0.167},
208
+ 'description': (
209
+ 'Drug 5328940 bound to ABL1 E255K resistance mutant. '
210
+ 'Prediction probability 98.8 % (Kd = 0.047 nM). '
211
+ 'Key contacts: V104 (gatekeeper), A648, S199.'
212
+ ),
213
+ },
214
+ 'EGFR + Drug 156414': {
215
+ 'png': ROOT / 'examples' / '156414_EGFR.png',
216
+ 'prob': None,
217
+ 'affinity_nM': None,
218
+ 'top_residues': [],
219
+ 'functional_groups': {},
220
+ 'description': (
221
+ 'Drug 156414 bound to wild-type EGFR kinase domain. '
222
+ 'Inhibitor with selectivity for the EGFR tyrosine kinase.'
223
+ ),
224
+ },
225
+ 'BRAF + Drug 11717001 (Sorafenib analogue)': {
226
+ 'png': ROOT / 'examples' / '11717001_BRAF.png',
227
+ 'prob': None,
228
+ 'affinity_nM': None,
229
+ 'top_residues': [],
230
+ 'functional_groups': {},
231
+ 'description': (
232
+ 'Drug 11717001 (Sorafenib analogue) bound to BRAF kinase. '
233
+ 'RAF inhibitor used in treatment of melanoma and other cancers.'
234
+ ),
235
+ },
236
+ }
237
+
238
+
239
+ def show_case_study(case_key: str):
240
+ """Called when user selects a case from the dropdown."""
241
+ case = _FIXED_CASES.get(case_key)
242
+ if case is None:
243
+ return None, None, None, "Case not found."
244
+
245
+ heatmap_img = Image.open(case['png']) if case['png'].exists() else None
246
+
247
+ prob_text = (f"**Predicted binding probability:** {case['prob']*100:.1f}%\n\n"
248
+ if case['prob'] else "")
249
+ kd_text = (f"**Experimental Kd:** {case['affinity_nM']} nM\n\n"
250
+ if case['affinity_nM'] else "")
251
+ info_md = f"### {case_key}\n\n{prob_text}{kd_text}{case['description']}"
252
+
253
+ residue_img = (
254
+ _plot_top_residues(case['top_residues'], f"Top Binding Residues — {case_key}")
255
+ if case['top_residues'] else None
256
+ )
257
+ fg_img = (
258
+ _plot_functional_groups(case['functional_groups'], f"Pharmacophore Importance — {case_key}")
259
+ if case['functional_groups'] else None
260
+ )
261
+
262
+ return heatmap_img, residue_img, fg_img, info_md
263
+
264
+
265
+ # ============================================================
266
+ # Tab 2 — Custom Prediction
267
+ # ============================================================
268
+
269
+ # Example: Imatinib SMILES + first 256 aa of ABL1 kinase domain
270
+ _EXAMPLE_SMILES = (
271
+ "Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1"
272
+ )
273
+ _EXAMPLE_SEQUENCE = (
274
+ "MGPSENDPNLFVALYDFVASGDNTLSITKGEKLRVLGYNHNGEWCEAQTKNGQGWVPSNYITPVNSLEKHSWYHGPVSRNAAEYLLSSGINGSFLVRESESSPGQRSISLRYEGRVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQHSDSVESAYRSKL"
275
+ "LNSGVYHYRINTASDGKLYVSSESRFNTLAELVHHHSTLVQ"
276
+ )
277
+
278
+ MAX_SEQ_LEN = 512
279
+
280
+
281
+ def run_prediction(smiles: str, sequence: str, progress=gr.Progress()):
282
+ """
283
+ Full inference pipeline: SMILES + sequence → probability + interaction heatmap.
284
+ Returns: (status_msg, prob_text, heatmap_image, residue_image)
285
+ """
286
+ smiles = (smiles or '').strip()
287
+ sequence = (sequence or '').strip().upper()
288
+
289
+ if not smiles:
290
+ return "Please enter a SMILES string.", "", None, None
291
+ if not sequence:
292
+ return "Please enter a protein sequence (amino acids).", "", None, None
293
+
294
+ # --- Drug graph ---
295
+ progress(0.1, desc="Parsing SMILES …")
296
+ drug_graph = smiles_to_graph(smiles)
297
+ if drug_graph is None:
298
+ return "Invalid SMILES string — RDKit could not parse it.", "", None, None
299
+
300
+ from torch_geometric.data import Batch
301
+ drug_batch = Batch.from_data_list([drug_graph]).to(DEVICE)
302
+
303
+ # --- Protein features ---
304
+ seq = sequence[:MAX_SEQ_LEN]
305
+ if len(sequence) > MAX_SEQ_LEN:
306
+ warnings.warn(f"Sequence truncated to {MAX_SEQ_LEN} residues.")
307
+ L = len(seq)
308
+
309
+ progress(0.2, desc="Computing ESM-2 embeddings (may take 1–3 min on first run) …")
310
+ try:
311
+ esm2_emb = compute_esm2_embedding(seq) # (L, 640)
312
+ except Exception as e:
313
+ return f"ESM-2 error: {e}", "", None, None
314
+
315
+ esm2_emb = esm2_emb.unsqueeze(0).to(DEVICE) # (1, L, 640)
316
+ physchem = residue_physicochemical_features(seq).unsqueeze(0).to(DEVICE) # (1, L, 4)
317
+ domain = residue_domain_labels(L).unsqueeze(0).to(DEVICE) # (1, L)
318
+ prot_mask = torch.ones(1, L, dtype=torch.bool, device=DEVICE)
319
+
320
+ # --- Model forward ---
321
+ progress(0.85, desc="Running BioInteract model …")
322
+ with torch.no_grad():
323
+ logit, attn_data = _model(
324
+ drug_batch, esm2_emb, physchem, domain, prot_mask,
325
+ return_attention=True
326
+ )
327
+
328
+ prob = torch.sigmoid(logit).item()
329
+ interaction_map = attn_data['interaction_map'][0].cpu().numpy() # (N_atoms, L)
330
+ drug_mask_np = attn_data['drug_mask'][0].cpu().numpy() # (N_atoms,)
331
+ prot_mask_np = prot_mask[0].cpu().numpy() # (L,)
332
+
333
+ # Trim padding
334
+ n_real_atoms = int(drug_mask_np.sum())
335
+ imap = interaction_map[:n_real_atoms, :L]
336
+
337
+ # Top-10 residues
338
+ residue_scores = imap.sum(axis=0)
339
+ residue_scores = residue_scores / (residue_scores.max() + 1e-9)
340
+ top_idx = np.argsort(residue_scores)[::-1][:10]
341
+ top_residues = [[f"{seq[i]}{i+1}", float(residue_scores[i])] for i in top_idx]
342
+
343
+ # --- Plots ---
344
+ progress(0.95, desc="Generating plots …")
345
+ heatmap_img = _plot_interaction_heatmap(imap, seq, title='Atom–Residue Interaction Map')
346
+ residue_img = _plot_top_residues(top_residues, 'Top 10 Binding Residues')
347
+
348
+ label = "BINDING" if prob > 0.5 else "NON-BINDING"
349
+ prob_text = (
350
+ f"## Predicted: {label}\n\n"
351
+ f"**Binding probability:** {prob * 100:.1f}% \n"
352
+ f"**Atoms analysed:** {n_real_atoms} \n"
353
+ f"**Residues analysed:** {L}"
354
+ )
355
+
356
+ return "Prediction complete.", prob_text, heatmap_img, residue_img
357
+
358
+
359
+ # ============================================================
360
+ # Global statistics panel (sidebar info)
361
+ # ============================================================
362
+
363
+ _GLOBAL_STATS = _REPORT.get('global_stats', {})
364
+ _stats_md = (
365
+ "### Model Statistics (Davis Dataset)\n\n"
366
+ f"- **AUROC (random split):** 0.921\n"
367
+ f"- **AUROC (cold-target):** 0.941\n"
368
+ f"- **Attention sparsity:** {_GLOBAL_STATS.get('attention_sparsity', 0.992)*100:.1f}%\n"
369
+ f"- **Training samples:** {_GLOBAL_STATS.get('n_samples', 1506):,}\n"
370
+ f"- **Model params:** {_REPORT.get('model_info', {}).get('params', 2442083):,}\n"
371
+ )
372
+
373
+
374
+ # ============================================================
375
+ # Build Gradio UI
376
+ # ============================================================
377
+
378
+ _HEADER = """
379
+ <div style="text-align:center; padding: 16px 0 8px 0;">
380
+ <h1 style="margin:0; font-size:2rem;">🧬 BioInteract</h1>
381
+ <p style="margin:4px 0; color:#555; font-size:1.05rem;">
382
+ Interpretable Drug–Target Interaction Prediction via Residue-Level Cross-Attention
383
+ </p>
384
+ <p style="margin:0; font-size:0.9rem; color:#888;">
385
+ GINE molecular graph encoder · ESM-2 protein language model · Bidirectional cross-attention
386
+ </p>
387
+ </div>
388
+ """
389
+
390
+ with gr.Blocks(
391
+ title="BioInteract — Interpretable DTI Prediction",
392
+ theme=gr.themes.Soft(primary_hue="blue"),
393
+ ) as demo:
394
+
395
+ gr.HTML(_HEADER)
396
+
397
+ with gr.Row():
398
+ with gr.Column(scale=3):
399
+ with gr.Tabs():
400
+
401
+ # ── Tab 1: Case Studies ────────────────────────────────────
402
+ with gr.Tab("📊 Case Studies"):
403
+ gr.Markdown(
404
+ "Select a **pre-computed case study** to explore the model's "
405
+ "atom–residue interaction map and binding residue predictions "
406
+ "for clinically validated drug–target pairs."
407
+ )
408
+ case_dropdown = gr.Dropdown(
409
+ choices=list(_FIXED_CASES.keys()),
410
+ value=list(_FIXED_CASES.keys())[0],
411
+ label="Select drug–target pair",
412
+ interactive=True,
413
+ )
414
+ case_info_md = gr.Markdown()
415
+
416
+ with gr.Row():
417
+ case_heatmap = gr.Image(label="Atom–Residue Interaction Heatmap",
418
+ type='pil', height=400)
419
+ with gr.Column():
420
+ case_residues = gr.Image(label="Top Binding Residues",
421
+ type='pil', height=250)
422
+ case_fg = gr.Image(label="Pharmacophore Importance",
423
+ type='pil', height=250)
424
+
425
+ case_dropdown.change(
426
+ fn=show_case_study,
427
+ inputs=case_dropdown,
428
+ outputs=[case_heatmap, case_residues, case_fg, case_info_md],
429
+ )
430
+ # Auto-load first case on startup
431
+ demo.load(
432
+ fn=show_case_study,
433
+ inputs=[gr.State(list(_FIXED_CASES.keys())[0])],
434
+ outputs=[case_heatmap, case_residues, case_fg, case_info_md],
435
+ )
436
+
437
+ # ── Tab 2: Custom Prediction ───────────────────────────────
438
+ with gr.Tab("🔬 Custom Prediction"):
439
+ gr.Markdown(
440
+ "Enter any **drug SMILES** and **protein amino acid sequence** "
441
+ "to get a binding prediction with an interaction heatmap. \n"
442
+ "> ⏳ First prediction loads ESM-2 (150 M params) on CPU — allow **1–3 minutes**. \n"
443
+ "> Sequences longer than 512 residues are automatically truncated."
444
+ )
445
+ with gr.Row():
446
+ smiles_box = gr.Textbox(
447
+ label="Drug SMILES",
448
+ placeholder="e.g. Cc1ccc(NC(=O)c2ccc(CN3CCN(C)CC3)cc2)cc1Nc1nccc(-c2cccnc2)n1",
449
+ lines=2,
450
+ )
451
+ sequence_box = gr.Textbox(
452
+ label="Protein Amino Acid Sequence",
453
+ placeholder="e.g. MGPSENDPNLFVALYDFVASGD...",
454
+ lines=4,
455
+ max_lines=8,
456
+ )
457
+
458
+ with gr.Row():
459
+ example_btn = gr.Button("📋 Load Imatinib / ABL1 Example", variant="secondary")
460
+ predict_btn = gr.Button("🚀 Predict", variant="primary")
461
+
462
+ status_box = gr.Textbox(label="Status", interactive=False, lines=1)
463
+ prob_md = gr.Markdown()
464
+
465
+ with gr.Row():
466
+ pred_heatmap = gr.Image(label="Atom–Residue Interaction Heatmap",
467
+ type='pil', height=400)
468
+ pred_residues = gr.Image(label="Top 10 Binding Residues",
469
+ type='pil', height=300)
470
+
471
+ example_btn.click(
472
+ fn=lambda: (_EXAMPLE_SMILES, _EXAMPLE_SEQUENCE),
473
+ inputs=[],
474
+ outputs=[smiles_box, sequence_box],
475
+ )
476
+ predict_btn.click(
477
+ fn=run_prediction,
478
+ inputs=[smiles_box, sequence_box],
479
+ outputs=[status_box, prob_md, pred_heatmap, pred_residues],
480
+ )
481
+
482
+ # ── Sidebar ────────────────────────────────────────────────────────
483
+ with gr.Column(scale=1):
484
+ gr.Markdown(_stats_md)
485
+ gr.Markdown(
486
+ "### Architecture\n\n"
487
+ "```\n"
488
+ "Drug SMILES\n"
489
+ " → GINE (3-layer)\n"
490
+ " → atom repr (N×256)\n\n"
491
+ "Protein sequence\n"
492
+ " → ESM-2 (150M)\n"
493
+ " → physicochemical\n"
494
+ " → residue repr (L×256)\n\n"
495
+ "Bidirectional\n"
496
+ "cross-attention\n"
497
+ " → interaction map\n"
498
+ " → gated pooling\n"
499
+ " → binding score\n"
500
+ "```"
501
+ )
502
+ gr.Markdown(
503
+ "### Citation\n\n"
504
+ "Wang S, Zhang Q et al. \n"
505
+ "*BioInteract: Interpretable DTI Prediction via "
506
+ "Residue-Level Cross-Attention with Biological Prior Knowledge.* \n"
507
+ "PLOS Computational Biology, 2026."
508
+ )
509
+
510
+
511
+ if __name__ == "__main__":
512
+ demo.launch(server_name="0.0.0.0", server_port=7860)
checkpoints/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9680d73224ee90aa464b92df30b2b6297405825d3e3b58864d1c38b9726df065
3
+ size 29393230
configs/default.yaml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioInteract Default Configuration
2
+
3
+ model:
4
+ drug_encoder:
5
+ gnn_type: GINE
6
+ num_layers: 3
7
+ hidden_dim: 256
8
+ dropout: 0.2
9
+ edge_dim: 16
10
+ num_atom_features: 52
11
+ jk: last
12
+
13
+ target_encoder:
14
+ esm2_dim: 640 # esm2_t30_150M_UR50D → 640-dim
15
+ projection_dim: 256
16
+ domain_embed_dim: 32
17
+ num_domain_types: 50
18
+ use_domain_features: true
19
+
20
+ interaction:
21
+ num_heads: 8
22
+ attn_dim: 256
23
+ dropout: 0.1
24
+ pooling: gated
25
+
26
+ predictor:
27
+ hidden_dims: [256, 128]
28
+ dropout: 0.3
29
+ task: classification
30
+
31
+ training:
32
+ batch_size: 64
33
+ lr: 1.0e-4
34
+ weight_decay: 1.0e-5
35
+ epochs: 100
36
+ patience: 15
37
+ seed: 42
examples/11717001_BRAF.png ADDED

Git LFS Details

  • SHA256: 4e715cfacbb419eb3ac84b103ba96de31148a5a2d73a0e849c19925ad2dbf746
  • Pointer size: 131 Bytes
  • Size of remote file: 203 kB
examples/156414_EGFR.png ADDED

Git LFS Details

  • SHA256: 1bf6d24ca13aca4ae90886c2de2347f13bbba3c7f4057805549f7b5cff6f5439
  • Pointer size: 131 Bytes
  • Size of remote file: 233 kB
examples/5328940_ABL1E255K.png ADDED

Git LFS Details

  • SHA256: 2606fe89d5ea5e74f928728fde1ad0c7d01fd63f97f15d9e2abfde1ad7544767
  • Pointer size: 131 Bytes
  • Size of remote file: 243 kB
examples/interpretability_report.json ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_info": {
3
+ "epoch": 19,
4
+ "best_metric": 0.6327288434593856,
5
+ "esm2_dim": 640,
6
+ "params": 2442083
7
+ },
8
+ "global_stats": {
9
+ "residue_attention_mean": 0.004351376555860043,
10
+ "residue_attention_std": 0.04624345526099205,
11
+ "residue_attention_median": 0.000272190198302269,
12
+ "residue_attention_top1pct": 0.05719282995909382,
13
+ "residue_attention_top5pct": 0.003616639366373424,
14
+ "n_samples": 1506,
15
+ "attention_sparsity": 0.992318922989442
16
+ },
17
+ "case_studies": [
18
+ {
19
+ "drug_name": "5328940",
20
+ "target_name": "ABL1(E255K)",
21
+ "drug_id": "D0010",
22
+ "target_id": "T0001",
23
+ "affinity_nM": 0.047,
24
+ "prediction_prob": 0.988015353679657,
25
+ "n_hotspots": 2,
26
+ "top_10_residues": [
27
+ [
28
+ "V104",
29
+ 1.0
30
+ ],
31
+ [
32
+ "A648",
33
+ 0.5025863647460938
34
+ ],
35
+ [
36
+ "S199",
37
+ 0.41692954301834106
38
+ ],
39
+ [
40
+ "P649",
41
+ 0.2809910178184509
42
+ ],
43
+ [
44
+ "P936",
45
+ 0.2371557503938675
46
+ ],
47
+ [
48
+ "N707",
49
+ 0.19219711422920227
50
+ ],
51
+ [
52
+ "P651",
53
+ 0.1799374669790268
54
+ ],
55
+ [
56
+ "L799",
57
+ 0.1664445549249649
58
+ ],
59
+ [
60
+ "K796",
61
+ 0.1407129466533661
62
+ ],
63
+ [
64
+ "P934",
65
+ 0.07895620912313461
66
+ ]
67
+ ],
68
+ "functional_groups": {
69
+ "Aromatic Ring": 0.3109830319881439,
70
+ "Amino": 0.6155399084091187,
71
+ "Halogen": 0.649691104888916,
72
+ "Ether": 0.3451891541481018,
73
+ "Heterocycle N": 0.1674310564994812
74
+ }
75
+ },
76
+ {
77
+ "drug_name": "3062316",
78
+ "target_name": "ABL1(F317I)",
79
+ "drug_id": "D0017",
80
+ "target_id": "T0002",
81
+ "affinity_nM": 0.1,
82
+ "prediction_prob": 0.9907914400100708,
83
+ "n_hotspots": 1,
84
+ "top_10_residues": [
85
+ [
86
+ "V104",
87
+ 1.0
88
+ ],
89
+ [
90
+ "A648",
91
+ 0.4408968389034271
92
+ ],
93
+ [
94
+ "S199",
95
+ 0.431899756193161
96
+ ],
97
+ [
98
+ "P649",
99
+ 0.272819459438324
100
+ ],
101
+ [
102
+ "P936",
103
+ 0.21758989989757538
104
+ ],
105
+ [
106
+ "N707",
107
+ 0.1882324367761612
108
+ ],
109
+ [
110
+ "L799",
111
+ 0.15290923416614532
112
+ ],
113
+ [
114
+ "P651",
115
+ 0.14969675242900848
116
+ ],
117
+ [
118
+ "K796",
119
+ 0.13164293766021729
120
+ ],
121
+ [
122
+ "P934",
123
+ 0.08160427212715149
124
+ ]
125
+ ],
126
+ "functional_groups": {
127
+ "Aromatic Ring": 0.2643487751483917,
128
+ "Hydroxyl": 0.6395331025123596,
129
+ "Amino": 0.8008821606636047,
130
+ "Amide": 0.8592739105224609,
131
+ "Halogen": 0.7159419059753418,
132
+ "Heterocycle N": 0.24906468391418457,
133
+ "Carbonyl": 0.7986676692962646
134
+ }
135
+ },
136
+ {
137
+ "drug_name": "3062316",
138
+ "target_name": "ABL1(F317I)p",
139
+ "drug_id": "D0017",
140
+ "target_id": "T0003",
141
+ "affinity_nM": 0.041,
142
+ "prediction_prob": 0.9907914400100708,
143
+ "n_hotspots": 1,
144
+ "top_10_residues": [
145
+ [
146
+ "V104",
147
+ 1.0
148
+ ],
149
+ [
150
+ "A648",
151
+ 0.4408968389034271
152
+ ],
153
+ [
154
+ "S199",
155
+ 0.431899756193161
156
+ ],
157
+ [
158
+ "P649",
159
+ 0.27281948924064636
160
+ ],
161
+ [
162
+ "P936",
163
+ 0.217589870095253
164
+ ],
165
+ [
166
+ "N707",
167
+ 0.1882324516773224
168
+ ],
169
+ [
170
+ "L799",
171
+ 0.15290924906730652
172
+ ],
173
+ [
174
+ "P651",
175
+ 0.14969675242900848
176
+ ],
177
+ [
178
+ "K796",
179
+ 0.13164293766021729
180
+ ],
181
+ [
182
+ "P934",
183
+ 0.08160427957773209
184
+ ]
185
+ ],
186
+ "functional_groups": {
187
+ "Aromatic Ring": 0.2643488347530365,
188
+ "Hydroxyl": 0.6395331621170044,
189
+ "Amino": 0.8008820414543152,
190
+ "Amide": 0.8592739105224609,
191
+ "Halogen": 0.7159419059753418,
192
+ "Heterocycle N": 0.24906468391418457,
193
+ "Carbonyl": 0.7986676692962646
194
+ }
195
+ },
196
+ {
197
+ "drug_name": "156414",
198
+ "target_name": "EGFR",
199
+ "drug_id": "D0014",
200
+ "target_id": "T0112",
201
+ "affinity_nM": 0.19,
202
+ "prediction_prob": 0.995402455329895,
203
+ "n_hotspots": 4,
204
+ "top_10_residues": [
205
+ [
206
+ "S525",
207
+ 1.0
208
+ ],
209
+ [
210
+ "S246",
211
+ 0.9849389791488647
212
+ ],
213
+ [
214
+ "V250",
215
+ 0.7280950546264648
216
+ ],
217
+ [
218
+ "Q218",
219
+ 0.6668700575828552
220
+ ],
221
+ [
222
+ "G288",
223
+ 0.4953320622444153
224
+ ],
225
+ [
226
+ "Q408",
227
+ 0.2837621867656708
228
+ ],
229
+ [
230
+ "A439",
231
+ 0.2591591775417328
232
+ ],
233
+ [
234
+ "I106",
235
+ 0.25760865211486816
236
+ ],
237
+ [
238
+ "A419",
239
+ 0.22197671234607697
240
+ ],
241
+ [
242
+ "G242",
243
+ 0.204107403755188
244
+ ]
245
+ ],
246
+ "functional_groups": {
247
+ "Aromatic Ring": 0.0,
248
+ "Amino": 0.08955194801092148,
249
+ "Amide": 0.38922443985939026,
250
+ "Halogen": 0.13448819518089294,
251
+ "Ether": 0.13556817173957825,
252
+ "Heterocycle N": 0.0,
253
+ "Carbonyl": 0.43106570839881897
254
+ }
255
+ },
256
+ {
257
+ "drug_name": "10184653",
258
+ "target_name": "EGFR(E746A750del)",
259
+ "drug_id": "D0004",
260
+ "target_id": "T0113",
261
+ "affinity_nM": 0.11,
262
+ "prediction_prob": 0.020466379821300507,
263
+ "n_hotspots": 3,
264
+ "top_10_residues": [
265
+ [
266
+ "S525",
267
+ 1.0
268
+ ],
269
+ [
270
+ "S246",
271
+ 0.9305045008659363
272
+ ],
273
+ [
274
+ "V250",
275
+ 0.6387038826942444
276
+ ],
277
+ [
278
+ "G288",
279
+ 0.44752317667007446
280
+ ],
281
+ [
282
+ "Q218",
283
+ 0.3744635581970215
284
+ ],
285
+ [
286
+ "Q408",
287
+ 0.24684257805347443
288
+ ],
289
+ [
290
+ "A439",
291
+ 0.2239307016134262
292
+ ],
293
+ [
294
+ "A419",
295
+ 0.20999976992607117
296
+ ],
297
+ [
298
+ "V60",
299
+ 0.18570154905319214
300
+ ],
301
+ [
302
+ "I106",
303
+ 0.18345363438129425
304
+ ]
305
+ ],
306
+ "functional_groups": {
307
+ "Aromatic Ring": 0.009693148545920849,
308
+ "Amino": 0.12679524719715118,
309
+ "Amide": 0.5453693270683289,
310
+ "Halogen": 0.0,
311
+ "Ether": 0.0,
312
+ "Heterocycle N": 0.0,
313
+ "Carbonyl": 0.5
314
+ }
315
+ },
316
+ {
317
+ "drug_name": "10184653",
318
+ "target_name": "EGFR(G719C)",
319
+ "drug_id": "D0004",
320
+ "target_id": "T0114",
321
+ "affinity_nM": 0.1,
322
+ "prediction_prob": 0.020466379821300507,
323
+ "n_hotspots": 3,
324
+ "top_10_residues": [
325
+ [
326
+ "S525",
327
+ 1.0
328
+ ],
329
+ [
330
+ "S246",
331
+ 0.9305046200752258
332
+ ],
333
+ [
334
+ "V250",
335
+ 0.6387039422988892
336
+ ],
337
+ [
338
+ "G288",
339
+ 0.4475232660770416
340
+ ],
341
+ [
342
+ "Q218",
343
+ 0.37446358799934387
344
+ ],
345
+ [
346
+ "Q408",
347
+ 0.24684259295463562
348
+ ],
349
+ [
350
+ "A439",
351
+ 0.22393076121807098
352
+ ],
353
+ [
354
+ "A419",
355
+ 0.20999978482723236
356
+ ],
357
+ [
358
+ "V60",
359
+ 0.18570157885551453
360
+ ],
361
+ [
362
+ "I106",
363
+ 0.18345364928245544
364
+ ]
365
+ ],
366
+ "functional_groups": {
367
+ "Aromatic Ring": 0.009693159721791744,
368
+ "Amino": 0.12679523229599,
369
+ "Amide": 0.5453693270683289,
370
+ "Halogen": 0.0,
371
+ "Ether": 0.0,
372
+ "Heterocycle N": 0.0,
373
+ "Carbonyl": 0.5
374
+ }
375
+ },
376
+ {
377
+ "drug_name": "11717001",
378
+ "target_name": "BRAF",
379
+ "drug_id": "D0023",
380
+ "target_id": "T0044",
381
+ "affinity_nM": 0.19,
382
+ "prediction_prob": 0.09866680204868317,
383
+ "n_hotspots": 1,
384
+ "top_10_residues": [
385
+ [
386
+ "T263",
387
+ 1.0
388
+ ],
389
+ [
390
+ "A246",
391
+ 0.43290892243385315
392
+ ],
393
+ [
394
+ "T167",
395
+ 0.19155371189117432
396
+ ],
397
+ [
398
+ "W619",
399
+ 0.1442342847585678
400
+ ],
401
+ [
402
+ "Q257",
403
+ 0.11426610499620438
404
+ ],
405
+ [
406
+ "V238",
407
+ 0.09902580827474594
408
+ ],
409
+ [
410
+ "S535",
411
+ 0.05356824770569801
412
+ ],
413
+ [
414
+ "V168",
415
+ 0.05098167061805725
416
+ ],
417
+ [
418
+ "T234",
419
+ 0.03144577145576477
420
+ ],
421
+ [
422
+ "F250",
423
+ 0.031349897384643555
424
+ ]
425
+ ],
426
+ "functional_groups": {
427
+ "Aromatic Ring": 0.0588817298412323,
428
+ "Hydroxyl": 0.0,
429
+ "Heterocycle N": 0.0
430
+ }
431
+ },
432
+ {
433
+ "drug_name": "11717001",
434
+ "target_name": "BRAF(V600E)",
435
+ "drug_id": "D0023",
436
+ "target_id": "T0045",
437
+ "affinity_nM": 0.18,
438
+ "prediction_prob": 0.09866680204868317,
439
+ "n_hotspots": 1,
440
+ "top_10_residues": [
441
+ [
442
+ "T263",
443
+ 1.0
444
+ ],
445
+ [
446
+ "A246",
447
+ 0.43290892243385315
448
+ ],
449
+ [
450
+ "T167",
451
+ 0.19155371189117432
452
+ ],
453
+ [
454
+ "W619",
455
+ 0.1442342847585678
456
+ ],
457
+ [
458
+ "Q257",
459
+ 0.11426610499620438
460
+ ],
461
+ [
462
+ "V238",
463
+ 0.09902580827474594
464
+ ],
465
+ [
466
+ "S535",
467
+ 0.05356824770569801
468
+ ],
469
+ [
470
+ "V168",
471
+ 0.05098167061805725
472
+ ],
473
+ [
474
+ "T234",
475
+ 0.03144577145576477
476
+ ],
477
+ [
478
+ "F250",
479
+ 0.031349897384643555
480
+ ]
481
+ ],
482
+ "functional_groups": {
483
+ "Aromatic Ring": 0.0588817298412323,
484
+ "Hydroxyl": 0.0,
485
+ "Heterocycle N": 0.0
486
+ }
487
+ },
488
+ {
489
+ "drug_name": "3062316",
490
+ "target_name": "ABL1(M351T)",
491
+ "drug_id": "D0017",
492
+ "target_id": "T0008",
493
+ "affinity_nM": 0.016,
494
+ "prediction_prob": 0.9907914400100708,
495
+ "n_hotspots": 1,
496
+ "top_10_residues": [
497
+ [
498
+ "V104",
499
+ 1.0
500
+ ],
501
+ [
502
+ "A648",
503
+ 0.4408968389034271
504
+ ],
505
+ [
506
+ "S199",
507
+ 0.431899756193161
508
+ ],
509
+ [
510
+ "P649",
511
+ 0.27281948924064636
512
+ ],
513
+ [
514
+ "P936",
515
+ 0.21758989989757538
516
+ ],
517
+ [
518
+ "N707",
519
+ 0.1882324367761612
520
+ ],
521
+ [
522
+ "L799",
523
+ 0.15290923416614532
524
+ ],
525
+ [
526
+ "P651",
527
+ 0.14969675242900848
528
+ ],
529
+ [
530
+ "K796",
531
+ 0.13164293766021729
532
+ ],
533
+ [
534
+ "P934",
535
+ 0.08160427957773209
536
+ ]
537
+ ],
538
+ "functional_groups": {
539
+ "Aromatic Ring": 0.2643488049507141,
540
+ "Hydroxyl": 0.6395330429077148,
541
+ "Amino": 0.8008821606636047,
542
+ "Amide": 0.8592739105224609,
543
+ "Halogen": 0.715941846370697,
544
+ "Heterocycle N": 0.249064639210701,
545
+ "Carbonyl": 0.7986676692962646
546
+ }
547
+ },
548
+ {
549
+ "drug_name": "3062316",
550
+ "target_name": "ABL1(F317L)p",
551
+ "drug_id": "D0017",
552
+ "target_id": "T0005",
553
+ "affinity_nM": 0.019,
554
+ "prediction_prob": 0.9907914400100708,
555
+ "n_hotspots": 1,
556
+ "top_10_residues": [
557
+ [
558
+ "V104",
559
+ 1.0
560
+ ],
561
+ [
562
+ "A648",
563
+ 0.4408968687057495
564
+ ],
565
+ [
566
+ "S199",
567
+ 0.431899756193161
568
+ ],
569
+ [
570
+ "P649",
571
+ 0.272819459438324
572
+ ],
573
+ [
574
+ "P936",
575
+ 0.21758989989757538
576
+ ],
577
+ [
578
+ "N707",
579
+ 0.1882324367761612
580
+ ],
581
+ [
582
+ "L799",
583
+ 0.15290921926498413
584
+ ],
585
+ [
586
+ "P651",
587
+ 0.14969675242900848
588
+ ],
589
+ [
590
+ "K796",
591
+ 0.13164293766021729
592
+ ],
593
+ [
594
+ "P934",
595
+ 0.08160427957773209
596
+ ]
597
+ ],
598
+ "functional_groups": {
599
+ "Aromatic Ring": 0.2643488347530365,
600
+ "Hydroxyl": 0.6395331621170044,
601
+ "Amino": 0.80088210105896,
602
+ "Amide": 0.8592739105224609,
603
+ "Halogen": 0.715941846370697,
604
+ "Heterocycle N": 0.24906468391418457,
605
+ "Carbonyl": 0.7986676692962646
606
+ }
607
+ }
608
+ ]
609
+ }
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --find-links https://data.pyg.org/whl/torch-2.1.2+cpu.html
2
+ torch==2.1.2
3
+ torch_scatter
4
+ torch_sparse
5
+ torch_cluster
6
+ torch_geometric==2.4.0
7
+ rdkit
8
+ transformers>=4.38.0
9
+ accelerate
10
+ gradio==4.44.0
11
+ numpy>=1.24.0
12
+ matplotlib>=3.7.0
13
+ seaborn>=0.12.0
14
+ pandas>=2.0.0
15
+ scipy>=1.11.0
16
+ scikit-learn>=1.3.0
17
+ PyYAML>=6.0
18
+ Pillow>=9.0.0
19
+ huggingface_hub>=0.21.0
src/__init__.py ADDED
File without changes
src/data/__init__.py ADDED
File without changes
src/data/mol_graph.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ mol_graph.py — SMILES to molecular graph with chemistry-aware features.
3
+
4
+ Node features include pharmacophore-relevant properties (H-bond donor/acceptor,
5
+ hydrophobicity) so the model can learn chemically meaningful representations.
6
+
7
+ v2: adds Morgan fingerprint (ECFP4) generation for dual-channel drug encoding.
8
+ """
9
+ import torch
10
+ import numpy as np
11
+ from rdkit import Chem
12
+ from rdkit.Chem import Descriptors, Crippen, rdMolDescriptors, AllChem
13
+ from torch_geometric.data import Data
14
+
15
+
16
+ # ============================================================
17
+ # Atom (node) featurisation
18
+ # ============================================================
19
+
20
+ ATOM_TYPES = ['C', 'N', 'O', 'S', 'F', 'Cl', 'Br', 'I', 'P', 'Si', 'B',
21
+ 'Na', 'K', 'Ca', 'Fe', 'Zn', 'Mg', 'Se', 'Unknown']
22
+
23
+ HYBRIDIZATION = [
24
+ Chem.rdchem.HybridizationType.SP,
25
+ Chem.rdchem.HybridizationType.SP2,
26
+ Chem.rdchem.HybridizationType.SP3,
27
+ Chem.rdchem.HybridizationType.SP3D,
28
+ Chem.rdchem.HybridizationType.SP3D2,
29
+ ]
30
+
31
+ FORMAL_CHARGE_RANGE = [-2, -1, 0, 1, 2]
32
+ NUM_HS_RANGE = [0, 1, 2, 3, 4]
33
+
34
+
35
+ def _one_hot(val, choices):
36
+ """One-hot encode a value; last dim is 'unknown'."""
37
+ enc = [0] * (len(choices) + 1)
38
+ try:
39
+ idx = choices.index(val)
40
+ enc[idx] = 1
41
+ except ValueError:
42
+ enc[-1] = 1
43
+ return enc
44
+
45
+
46
+ def atom_features(atom, crippen_contribs=None, atom_idx=None):
47
+ """
48
+ Return a feature vector for a single atom.
49
+
50
+ Features (total = 78 dims):
51
+ - atom type one-hot (20)
52
+ - hybridisation one-hot (6)
53
+ - formal charge one-hot (6)
54
+ - num Hs one-hot (6)
55
+ - degree (1)
56
+ - is aromatic (1)
57
+ - is in ring (1)
58
+ - ring size (3-8) (6)
59
+ -- Chemistry priors --
60
+ - is H-bond donor (1)
61
+ - is H-bond acceptor (1)
62
+ - Crippen logP contribution (1) ← hydrophobicity
63
+ - Crippen MR contribution (1) ← molar refractivity
64
+ - Gasteiger charge (1) ← partial charge
65
+
66
+ Pharmacophore-relevant features are marked above; they help the model
67
+ learn which atoms are likely to participate in binding interactions.
68
+ """
69
+ symbol = atom.GetSymbol()
70
+ features = []
71
+
72
+ # basic properties
73
+ features += _one_hot(symbol, ATOM_TYPES) # 20
74
+ features += _one_hot(atom.GetHybridization(), HYBRIDIZATION) # 6
75
+ features += _one_hot(atom.GetFormalCharge(), FORMAL_CHARGE_RANGE) # 6
76
+ features += _one_hot(atom.GetTotalNumHs(), NUM_HS_RANGE) # 6
77
+
78
+ features.append(atom.GetDegree() / 6.0) # 1
79
+ features.append(int(atom.GetIsAromatic())) # 1
80
+ features.append(int(atom.IsInRing())) # 1
81
+
82
+ # ring sizes
83
+ ring_info = atom.GetOwningMol().GetRingInfo()
84
+ for size in range(3, 9):
85
+ features.append(int(ring_info.IsAtomInRingOfSize(atom.GetIdx(), size))) # 6
86
+
87
+ # pharmacophore features
88
+ # H-bond donor: atom bonded to at least one H and is N or O
89
+ is_donor = (symbol in ['N', 'O'] and atom.GetTotalNumHs() > 0)
90
+ features.append(int(is_donor)) # 1
91
+
92
+ # H-bond acceptor: N or O with lone pair
93
+ is_acceptor = (symbol in ['N', 'O', 'F'])
94
+ features.append(int(is_acceptor)) # 1
95
+
96
+ # Crippen contributions (hydrophobicity, molar refractivity)
97
+ if crippen_contribs is not None and atom_idx is not None:
98
+ logp_contrib, mr_contrib = crippen_contribs[atom_idx]
99
+ features.append(logp_contrib) # 1
100
+ features.append(mr_contrib) # 1
101
+ else:
102
+ features.append(0.0)
103
+ features.append(0.0)
104
+
105
+ # Gasteiger partial charge
106
+ charge = float(atom.GetDoubleProp('_GasteigerCharge')) \
107
+ if atom.HasProp('_GasteigerCharge') else 0.0
108
+ if np.isnan(charge) or np.isinf(charge):
109
+ charge = 0.0
110
+ features.append(charge) # 1
111
+
112
+ return features # total: 20+6+6+6+1+1+1+6+1+1+1+1+1 = 52...
113
+ # recount: 20+6+6+6+1+1+1+6+1+1+1+1+1 = 52
114
+ # Note: actual dim depends on implementation; config should match
115
+
116
+
117
+ # ============================================================
118
+ # Bond (edge) featurisation
119
+ # ============================================================
120
+
121
+ BOND_TYPES = [
122
+ Chem.rdchem.BondType.SINGLE,
123
+ Chem.rdchem.BondType.DOUBLE,
124
+ Chem.rdchem.BondType.TRIPLE,
125
+ Chem.rdchem.BondType.AROMATIC,
126
+ ]
127
+
128
+ BOND_STEREO = [
129
+ Chem.rdchem.BondStereo.STEREONONE,
130
+ Chem.rdchem.BondStereo.STEREOZ,
131
+ Chem.rdchem.BondStereo.STEREOE,
132
+ Chem.rdchem.BondStereo.STEREOCIS,
133
+ Chem.rdchem.BondStereo.STEREOTRANS,
134
+ ]
135
+
136
+
137
+ def bond_features(bond):
138
+ """
139
+ Return a feature vector for a single bond.
140
+
141
+ Features (total = 16 dims):
142
+ - bond type one-hot (5)
143
+ - is conjugated (1)
144
+ - is in ring (1)
145
+ - bond stereo one-hot (6)
146
+ - bond direction info (3)
147
+ """
148
+ features = []
149
+ features += _one_hot(bond.GetBondType(), BOND_TYPES) # 5
150
+ features.append(int(bond.GetIsConjugated())) # 1
151
+ features.append(int(bond.IsInRing())) # 1
152
+ features += _one_hot(bond.GetStereo(), BOND_STEREO) # 6
153
+
154
+ # bond direction (for chirality)
155
+ bd = bond.GetBondDir()
156
+ features.append(int(bd == Chem.rdchem.BondDir.BEGINWEDGE))
157
+ features.append(int(bd == Chem.rdchem.BondDir.ENDDOWNRIGHT))
158
+ features.append(int(bd == Chem.rdchem.BondDir.ENDUPRIGHT)) # 3
159
+
160
+ return features # total: 5+1+1+6+3 = 16
161
+
162
+
163
+ # ============================================================
164
+ # Full molecule → PyG Data
165
+ # ============================================================
166
+
167
+ def smiles_to_graph(smiles: str) -> Data | None:
168
+ """
169
+ Convert a SMILES string into a PyTorch Geometric Data object with
170
+ chemistry-aware node and edge features.
171
+
172
+ Returns None if the SMILES cannot be parsed.
173
+ """
174
+ mol = Chem.MolFromSmiles(smiles)
175
+ if mol is None:
176
+ return None
177
+
178
+ # add Hs implicitly tracked, compute Gasteiger charges
179
+ Chem.rdPartialCharges.ComputeGasteigerCharges(mol)
180
+ crippen = Crippen.MolLogP(mol) # just to ensure descriptors initialised
181
+ crippen_contribs = Crippen._GetAtomContribs(mol)
182
+
183
+ # --- node features ---
184
+ atom_feat_list = []
185
+ for idx, atom in enumerate(mol.GetAtoms()):
186
+ atom_feat_list.append(atom_features(atom, crippen_contribs, idx))
187
+ x = torch.tensor(atom_feat_list, dtype=torch.float)
188
+
189
+ # --- edge features ---
190
+ edge_index = []
191
+ edge_attr_list = []
192
+ for bond in mol.GetBonds():
193
+ i = bond.GetBeginAtomIdx()
194
+ j = bond.GetEndAtomIdx()
195
+ bf = bond_features(bond)
196
+ # undirected: add both directions
197
+ edge_index.append([i, j])
198
+ edge_index.append([j, i])
199
+ edge_attr_list.append(bf)
200
+ edge_attr_list.append(bf)
201
+
202
+ if len(edge_index) == 0:
203
+ # single atom molecule (rare but possible)
204
+ edge_index = torch.empty((2, 0), dtype=torch.long)
205
+ edge_attr = torch.empty((0, 16), dtype=torch.float)
206
+ else:
207
+ edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
208
+ edge_attr = torch.tensor(edge_attr_list, dtype=torch.float)
209
+
210
+ return Data(x=x, edge_index=edge_index, edge_attr=edge_attr,
211
+ smiles=smiles, num_atoms=x.size(0))
212
+
213
+
214
+ def get_atom_feature_dim():
215
+ """Return the dimensionality of atom features."""
216
+ # Build a dummy molecule to infer feature size
217
+ mol = Chem.MolFromSmiles('C')
218
+ Chem.rdPartialCharges.ComputeGasteigerCharges(mol)
219
+ contribs = Crippen._GetAtomContribs(mol)
220
+ return len(atom_features(mol.GetAtomWithIdx(0), contribs, 0))
221
+
222
+
223
+ def get_bond_feature_dim():
224
+ """Return the dimensionality of bond features."""
225
+ return 16
226
+
227
+
228
+ def smiles_to_morgan(smiles: str, n_bits: int = 1024, radius: int = 2) -> torch.Tensor | None:
229
+ """
230
+ Compute Morgan fingerprint (ECFP4) from SMILES.
231
+
232
+ Morgan fingerprints encode circular substructures and are inherently
233
+ more generalizable than learned GNN features — they capture chemical
234
+ similarity based on substructure occurrence regardless of whether the
235
+ exact molecule has been seen during training.
236
+
237
+ Args:
238
+ smiles: SMILES string
239
+ n_bits: fingerprint length (default 1024)
240
+ radius: Morgan radius (2 = ECFP4)
241
+
242
+ Returns:
243
+ (n_bits,) float tensor, or None if parsing fails
244
+ """
245
+ mol = Chem.MolFromSmiles(smiles)
246
+ if mol is None:
247
+ return None
248
+ fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius, nBits=n_bits)
249
+ arr = np.zeros(n_bits, dtype=np.float32)
250
+ from rdkit.DataStructs import ConvertToNumpyArray
251
+ ConvertToNumpyArray(fp, arr)
252
+ return torch.from_numpy(arr)
src/data/protein_feat.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ protein_feat.py — Protein feature processing for BioInteract Space demo.
3
+
4
+ Adapted from the training version: removed dependency on internal
5
+ src.utils.paths module so this file runs as a standalone module.
6
+ ESM-2 embeddings are computed on-the-fly via transformers in app.py.
7
+ """
8
+ import os
9
+ import json
10
+ import torch
11
+ import numpy as np
12
+ from typing import Optional
13
+
14
+
15
+ # ============================================================
16
+ # Standard amino acid vocabulary
17
+ # ============================================================
18
+
19
+ AMINO_ACIDS = list('ACDEFGHIKLMNPQRSTVWY')
20
+ AA_TO_IDX = {aa: i for i, aa in enumerate(AMINO_ACIDS)}
21
+ UNK_AA_IDX = len(AMINO_ACIDS)
22
+
23
+
24
+ # ============================================================
25
+ # Residue-level physicochemical properties (domain knowledge)
26
+ # ============================================================
27
+
28
+ HYDROPHOBICITY = {
29
+ 'A': 1.8, 'R': -4.5, 'N': -3.5, 'D': -3.5, 'C': 2.5,
30
+ 'E': -3.5, 'Q': -3.5, 'G': -0.4, 'H': -3.2, 'I': 4.5,
31
+ 'L': 3.8, 'K': -3.9, 'M': 1.9, 'F': 2.8, 'P': -1.6,
32
+ 'S': -0.8, 'T': -0.7, 'W': -0.9, 'Y': -1.3, 'V': 4.2,
33
+ }
34
+
35
+ MOL_WEIGHT = {
36
+ 'A': 89, 'R': 174, 'N': 132, 'D': 133, 'C': 121,
37
+ 'E': 147, 'Q': 146, 'G': 75, 'H': 155, 'I': 131,
38
+ 'L': 131, 'K': 146, 'M': 149, 'F': 165, 'P': 115,
39
+ 'S': 105, 'T': 119, 'W': 204, 'Y': 181, 'V': 117,
40
+ }
41
+
42
+ CHARGE = {
43
+ 'A': 0, 'R': 1, 'N': 0, 'D': -1, 'C': 0,
44
+ 'E': -1, 'Q': 0, 'G': 0, 'H': 0.1, 'I': 0,
45
+ 'L': 0, 'K': 1, 'M': 0, 'F': 0, 'P': 0,
46
+ 'S': 0, 'T': 0, 'W': 0, 'Y': 0, 'V': 0,
47
+ }
48
+
49
+ POLARITY = {
50
+ 'A': 0, 'R': 1, 'N': 1, 'D': 1, 'C': 0,
51
+ 'E': 1, 'Q': 1, 'G': 0, 'H': 1, 'I': 0,
52
+ 'L': 0, 'K': 1, 'M': 0, 'F': 0, 'P': 0,
53
+ 'S': 1, 'T': 1, 'W': 0, 'Y': 1, 'V': 0,
54
+ }
55
+
56
+
57
+ def residue_physicochemical_features(sequence: str) -> torch.Tensor:
58
+ """
59
+ Compute per-residue physicochemical feature vectors.
60
+ Returns tensor of shape (L, 4).
61
+ """
62
+ features = []
63
+ for aa in sequence:
64
+ h = HYDROPHOBICITY.get(aa, 0.0) / 4.5
65
+ w = MOL_WEIGHT.get(aa, 130.0) / 204.0
66
+ c = CHARGE.get(aa, 0.0)
67
+ p = POLARITY.get(aa, 0.0)
68
+ features.append([h, w, c, p])
69
+ return torch.tensor(features, dtype=torch.float)
70
+
71
+
72
+ # ============================================================
73
+ # Domain annotation helpers
74
+ # ============================================================
75
+
76
+ DOMAIN_TYPES = [
77
+ 'Pkinase', 'Pkinase_Tyr', '7tm_1', '7tm_2', '7tm_3',
78
+ 'Trypsin', 'Peptidase_C1', 'Metallopep', 'zf-CCHH', 'SH2',
79
+ 'SH3_1', 'PH', 'RRM_1', 'Ion_trans', 'Ank', 'WD40',
80
+ 'LRR_1', 'EGF', 'Ig', 'ABC_tran', 'Hormone_recep',
81
+ 'HATPase_c', 'Helicase_C', 'RVT_1', 'Topoisom_bac', 'NONE',
82
+ ]
83
+
84
+ DOMAIN_TO_IDX = {d: i for i, d in enumerate(DOMAIN_TYPES)}
85
+
86
+
87
+ def residue_domain_labels(
88
+ sequence_length: int,
89
+ domain_info: Optional[dict] = None
90
+ ) -> torch.LongTensor:
91
+ """
92
+ Assign each residue a domain type index.
93
+ Returns LongTensor of shape (L,).
94
+ Residues without annotation get the 'NONE' label.
95
+ """
96
+ labels = torch.full(
97
+ (sequence_length,), DOMAIN_TO_IDX['NONE'], dtype=torch.long
98
+ )
99
+ if domain_info is not None and 'domains' in domain_info:
100
+ for dom in domain_info['domains']:
101
+ dom_type = dom.get('type', 'NONE')
102
+ if dom_type not in DOMAIN_TO_IDX:
103
+ dom_type = 'NONE'
104
+ idx = DOMAIN_TO_IDX[dom_type]
105
+ start = dom.get('start', 0)
106
+ end = min(dom.get('end', sequence_length), sequence_length)
107
+ labels[start:end] = idx
108
+ return labels
src/models/__init__.py ADDED
File without changes
src/models/biointeract.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ biointeract.py — Full BioInteract model (v2: dual-channel + focal loss ready).
3
+
4
+ Connects drug encoder (GINE + Morgan FP), target encoder (ESM-2),
5
+ cross-attention interaction module, and prediction head.
6
+
7
+ v2 changes:
8
+ - Dual-channel drug encoding: GNN atoms + Morgan fingerprint global vector
9
+ - Graph augmentation (DropNode/DropEdge) for cold-start robustness
10
+ - Morgan fingerprint branch fused at prediction head
11
+ """
12
+ import torch
13
+ import torch.nn as nn
14
+ from torch_geometric.data import Batch
15
+
16
+ from .drug_encoder import DrugEncoder, MorganFPEncoder, GraphAugmentation
17
+ from .target_encoder import TargetEncoder
18
+ from .interaction import CrossAttentionInteraction, GatedPooling
19
+
20
+
21
+ class BioInteract(nn.Module):
22
+ """
23
+ BioInteract: Interpretable Drug-Target Interaction Prediction
24
+ via Residue-Level Cross-Attention with Biological Prior Knowledge.
25
+
26
+ Forward pass:
27
+ 1. Encode drug molecular graph → per-atom representations
28
+ 2. Encode protein (ESM-2 + domain features) → per-residue representations
29
+ 3. Cross-attention interaction → atom-residue interaction map
30
+ 4. Gated pooling → fixed-size vector
31
+ 5. Prediction head → binding probability / affinity
32
+ """
33
+
34
+ def __init__(self, config: dict):
35
+ super().__init__()
36
+
37
+ drug_cfg = config.get('drug_encoder', {})
38
+ target_cfg = config.get('target_encoder', {})
39
+ inter_cfg = config.get('interaction', {})
40
+ pred_cfg = config.get('predictor', {})
41
+
42
+ hidden_dim = drug_cfg.get('hidden_dim', 256)
43
+
44
+ # --- configurable Morgan FP ---
45
+ self.use_morgan_fp = drug_cfg.get('use_morgan_fp', False)
46
+
47
+ # --- graph augmentation (training only) ---
48
+ self.graph_aug = GraphAugmentation(
49
+ p_node=drug_cfg.get('drop_node', 0.0),
50
+ p_edge=drug_cfg.get('drop_edge', 0.0),
51
+ )
52
+
53
+ # --- encoders ---
54
+ self.drug_encoder = DrugEncoder(
55
+ num_atom_features=drug_cfg.get('num_atom_features', 52),
56
+ edge_dim=drug_cfg.get('edge_dim', 16),
57
+ hidden_dim=hidden_dim,
58
+ num_layers=drug_cfg.get('num_layers', 3),
59
+ dropout=drug_cfg.get('dropout', 0.2),
60
+ jk=drug_cfg.get('jk', 'last'),
61
+ )
62
+
63
+ # Morgan fingerprint branch (optional)
64
+ if self.use_morgan_fp:
65
+ morgan_nbits = drug_cfg.get('morgan_nbits', 1024)
66
+ self.morgan_encoder = MorganFPEncoder(
67
+ input_dim=morgan_nbits,
68
+ hidden_dim=hidden_dim,
69
+ dropout=drug_cfg.get('dropout', 0.2),
70
+ )
71
+
72
+ self.target_encoder = TargetEncoder(
73
+ esm2_dim=target_cfg.get('esm2_dim', 1280),
74
+ projection_dim=hidden_dim,
75
+ physchem_dim=4,
76
+ domain_embed_dim=target_cfg.get('domain_embed_dim', 32),
77
+ num_domain_types=target_cfg.get('num_domain_types', 50),
78
+ use_domain_features=target_cfg.get('use_domain_features', True),
79
+ )
80
+
81
+ # --- interaction ---
82
+ self.interaction = CrossAttentionInteraction(
83
+ hidden_dim=hidden_dim,
84
+ num_heads=inter_cfg.get('num_heads', 8),
85
+ dropout=inter_cfg.get('dropout', 0.1),
86
+ )
87
+
88
+ # --- pooling ---
89
+ self.drug_pooling = GatedPooling(hidden_dim)
90
+ self.prot_pooling = GatedPooling(hidden_dim)
91
+
92
+ # --- prediction head ---
93
+ # fusion dimension depends on whether Morgan FP is used
94
+ if self.use_morgan_fp:
95
+ pred_input_dim = hidden_dim * 3 # drug_gnn + protein + morgan
96
+ else:
97
+ pred_input_dim = hidden_dim * 2 # drug_gnn + protein
98
+
99
+ pred_hidden = pred_cfg.get('hidden_dims', [256, 128])
100
+ pred_dropout = pred_cfg.get('dropout', 0.3)
101
+ self.task = pred_cfg.get('task', 'classification')
102
+
103
+ layers = []
104
+ in_dim = pred_input_dim
105
+ for h_dim in pred_hidden:
106
+ layers.extend([
107
+ nn.Linear(in_dim, h_dim),
108
+ nn.ReLU(),
109
+ nn.Dropout(pred_dropout),
110
+ ])
111
+ in_dim = h_dim
112
+ layers.append(nn.Linear(in_dim, 1))
113
+ self.predictor = nn.Sequential(*layers)
114
+
115
+ def forward(self,
116
+ drug_batch: Batch,
117
+ esm2_embedding: torch.Tensor,
118
+ physicochemical: torch.Tensor,
119
+ domain_labels: torch.Tensor,
120
+ protein_mask: torch.Tensor,
121
+ morgan_fp: torch.Tensor = None,
122
+ return_attention: bool = False):
123
+ """
124
+ Args:
125
+ drug_batch: PyG Batch of molecular graphs
126
+ esm2_embedding: (B, L, esm2_dim)
127
+ physicochemical: (B, L, 4)
128
+ domain_labels: (B, L)
129
+ protein_mask: (B, L) boolean
130
+ morgan_fp: (B, 1024) Morgan fingerprint vectors
131
+ return_attention: if True, also return attention maps
132
+
133
+ Returns:
134
+ prediction: (B, 1) — binding score
135
+ attention_data: dict (only if return_attention=True)
136
+ """
137
+ batch_size = esm2_embedding.size(0)
138
+
139
+ # 0. graph augmentation (training only)
140
+ drug_batch = self.graph_aug(drug_batch)
141
+
142
+ # 1. encode drug → per-atom representations
143
+ atom_repr, batch_index = self.drug_encoder(drug_batch)
144
+
145
+ # 2. encode protein → per-residue representations
146
+ residue_repr = self.target_encoder(
147
+ esm2_embedding, physicochemical, domain_labels, protein_mask
148
+ )
149
+
150
+ # 3. reshape drug atoms into (B, max_atoms, D) for cross-attention
151
+ drug_repr_padded, drug_mask = self._pad_drug_atoms(
152
+ atom_repr, batch_index, batch_size
153
+ )
154
+
155
+ # 4. cross-attention interaction
156
+ drug_updated, prot_updated, interaction_map = self.interaction(
157
+ drug_repr_padded, drug_mask, residue_repr, protein_mask
158
+ )
159
+
160
+ # 5. gated pooling to fixed-size vectors
161
+ drug_pooled = self.drug_pooling(drug_updated, drug_mask) # (B, D)
162
+ prot_pooled = self.prot_pooling(prot_updated, protein_mask) # (B, D)
163
+
164
+ # 6. Fusion — conditionally include Morgan FP
165
+ if self.use_morgan_fp and morgan_fp is not None:
166
+ morgan_repr = self.morgan_encoder(morgan_fp) # (B, D)
167
+ fused = torch.cat([drug_pooled, prot_pooled, morgan_repr], dim=-1) # (B, 3D)
168
+ else:
169
+ fused = torch.cat([drug_pooled, prot_pooled], dim=-1) # (B, 2D)
170
+
171
+ # 7. predict
172
+ prediction = self.predictor(fused) # (B, 1)
173
+
174
+ # NOTE: for classification, we return raw logits here.
175
+ # Apply sigmoid only during inference (not training with AMP).
176
+ # Use BCEWithLogitsLoss for training.
177
+
178
+ if return_attention:
179
+ return prediction, {
180
+ 'interaction_map': interaction_map,
181
+ 'drug_mask': drug_mask,
182
+ 'protein_mask': protein_mask,
183
+ }
184
+
185
+ return prediction
186
+
187
+ def predict_proba(self, *args, **kwargs):
188
+ """Return probabilities (sigmoid applied) for inference."""
189
+ logits = self.forward(*args, **kwargs)
190
+ if isinstance(logits, tuple):
191
+ return torch.sigmoid(logits[0]), logits[1]
192
+ return torch.sigmoid(logits)
193
+
194
+ def _pad_drug_atoms(self, atom_repr, batch_index, batch_size):
195
+ """
196
+ Convert scattered atom representations to padded batch tensor.
197
+
198
+ Args:
199
+ atom_repr: (total_atoms, D) — all atoms from all graphs
200
+ batch_index: (total_atoms,) — which graph each atom belongs to
201
+ batch_size: int
202
+
203
+ Returns:
204
+ padded: (B, max_atoms, D)
205
+ mask: (B, max_atoms) boolean
206
+ """
207
+ device = atom_repr.device
208
+ D = atom_repr.size(-1)
209
+
210
+ # count atoms per graph
211
+ counts = torch.bincount(batch_index, minlength=batch_size)
212
+ max_atoms = counts.max().item()
213
+
214
+ padded = torch.zeros(batch_size, max_atoms, D, device=device)
215
+ mask = torch.zeros(batch_size, max_atoms, dtype=torch.bool, device=device)
216
+
217
+ for i in range(batch_size):
218
+ atom_indices = (batch_index == i).nonzero(as_tuple=True)[0]
219
+ n = atom_indices.size(0)
220
+ padded[i, :n] = atom_repr[atom_indices]
221
+ mask[i, :n] = True
222
+
223
+ return padded, mask
src/models/drug_encoder.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ drug_encoder.py — GINE-based molecular graph encoder + Morgan fingerprint fusion.
3
+
4
+ Uses Graph Isomorphism Network with Edge features (GINE) for learned
5
+ molecular representations, combined with Morgan fingerprints (ECFP4)
6
+ for knowledge-driven chemical similarity encoding.
7
+
8
+ v2: adds DropNode/DropEdge graph augmentation for cold-start robustness,
9
+ and a MorganFPEncoder for dual-channel drug representation.
10
+ """
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from torch_geometric.nn import GINEConv, BatchNorm
15
+ from torch_geometric.data import Batch
16
+
17
+
18
+ class GINELayer(nn.Module):
19
+ """Single GINE layer with batch norm and residual connection."""
20
+
21
+ def __init__(self, hidden_dim: int, edge_dim: int, dropout: float = 0.2):
22
+ super().__init__()
23
+ # GINE uses an MLP as the update function
24
+ mlp = nn.Sequential(
25
+ nn.Linear(hidden_dim, hidden_dim * 2),
26
+ nn.ReLU(),
27
+ nn.Linear(hidden_dim * 2, hidden_dim),
28
+ )
29
+ self.conv = GINEConv(nn=mlp, edge_dim=edge_dim)
30
+ self.bn = BatchNorm(hidden_dim)
31
+ self.dropout = nn.Dropout(dropout)
32
+
33
+ def forward(self, x, edge_index, edge_attr):
34
+ h = self.conv(x, edge_index, edge_attr)
35
+ h = self.bn(h)
36
+ h = F.relu(h)
37
+ h = self.dropout(h)
38
+ # residual connection
39
+ return h + x
40
+
41
+
42
+ class DrugEncoder(nn.Module):
43
+ """
44
+ GINE-based drug molecular graph encoder.
45
+
46
+ Produces per-atom representations suitable for cross-attention
47
+ with protein residue representations.
48
+
49
+ Architecture:
50
+ Input projection → N × GINE layers → atom-level output
51
+
52
+ We intentionally do NOT apply global readout (mean/sum pooling)
53
+ here — the cross-attention module needs individual atom vectors
54
+ to compute residue-level interaction maps.
55
+ """
56
+
57
+ def __init__(self,
58
+ num_atom_features: int = 52,
59
+ edge_dim: int = 16,
60
+ hidden_dim: int = 256,
61
+ num_layers: int = 3,
62
+ dropout: float = 0.2,
63
+ jk: str = 'last'):
64
+ """
65
+ Args:
66
+ num_atom_features: input atom feature dimension
67
+ edge_dim: bond feature dimension
68
+ hidden_dim: GINE hidden dimension
69
+ num_layers: number of GINE layers
70
+ dropout: dropout rate
71
+ jk: jumping knowledge mode ('last' or 'cat')
72
+ """
73
+ super().__init__()
74
+ self.num_layers = num_layers
75
+ self.jk = jk
76
+
77
+ # input projection
78
+ self.input_proj = nn.Sequential(
79
+ nn.Linear(num_atom_features, hidden_dim),
80
+ nn.ReLU(),
81
+ nn.Dropout(dropout),
82
+ )
83
+
84
+ # edge feature projection (match edge_attr dim to hidden_dim for GINE)
85
+ self.edge_proj = nn.Linear(edge_dim, hidden_dim)
86
+
87
+ # GINE layers
88
+ self.layers = nn.ModuleList([
89
+ GINELayer(hidden_dim, hidden_dim, dropout)
90
+ for _ in range(num_layers)
91
+ ])
92
+
93
+ # jumping knowledge output
94
+ if jk == 'cat':
95
+ self.jk_proj = nn.Linear(hidden_dim * num_layers, hidden_dim)
96
+
97
+ self.output_dim = hidden_dim
98
+
99
+ def forward(self, drug_batch: Batch) -> tuple:
100
+ """
101
+ Args:
102
+ drug_batch: PyG Batch of molecular graphs
103
+
104
+ Returns:
105
+ atom_repr: (total_atoms, hidden_dim) — per-atom representations
106
+ batch_index: (total_atoms,) — which graph each atom belongs to
107
+ """
108
+ x = drug_batch.x
109
+ edge_index = drug_batch.edge_index
110
+ edge_attr = drug_batch.edge_attr
111
+ batch_index = drug_batch.batch
112
+
113
+ # project inputs
114
+ h = self.input_proj(x)
115
+ edge_features = self.edge_proj(edge_attr) if edge_attr.size(0) > 0 \
116
+ else edge_attr
117
+
118
+ # message passing
119
+ layer_outputs = []
120
+ for layer in self.layers:
121
+ h = layer(h, edge_index, edge_features)
122
+ layer_outputs.append(h)
123
+
124
+ # jumping knowledge
125
+ if self.jk == 'cat':
126
+ h = torch.cat(layer_outputs, dim=-1)
127
+ h = self.jk_proj(h)
128
+ else:
129
+ h = layer_outputs[-1]
130
+
131
+ return h, batch_index
132
+
133
+
134
+ class GraphAugmentation(nn.Module):
135
+ """
136
+ Stochastic graph augmentation for regularisation during training.
137
+
138
+ DropNode: randomly masks node features (zero-out) with probability p_node.
139
+ DropEdge: randomly removes edges with probability p_edge.
140
+
141
+ These augmentations prevent the GNN from memorising specific molecular
142
+ graphs, which is critical for cold-start generalisation where test
143
+ molecules are unseen during training.
144
+
145
+ Reference: Rong et al., "DropEdge: Towards Deep Graph ConvNets", ICLR 2020
146
+ """
147
+
148
+ def __init__(self, p_node: float = 0.1, p_edge: float = 0.15):
149
+ super().__init__()
150
+ self.p_node = p_node
151
+ self.p_edge = p_edge
152
+
153
+ def forward(self, drug_batch: Batch) -> Batch:
154
+ if not self.training:
155
+ return drug_batch
156
+
157
+ # DropNode: zero-out whole node feature vectors
158
+ if self.p_node > 0:
159
+ mask = torch.rand(drug_batch.x.size(0), 1,
160
+ device=drug_batch.x.device) > self.p_node
161
+ drug_batch.x = drug_batch.x * mask.float()
162
+
163
+ # DropEdge: remove random edges
164
+ if self.p_edge > 0 and drug_batch.edge_index.size(1) > 0:
165
+ n_edges = drug_batch.edge_index.size(1)
166
+ keep = torch.rand(n_edges, device=drug_batch.edge_index.device) > self.p_edge
167
+ drug_batch.edge_index = drug_batch.edge_index[:, keep]
168
+ if drug_batch.edge_attr is not None and drug_batch.edge_attr.size(0) == n_edges:
169
+ drug_batch.edge_attr = drug_batch.edge_attr[keep]
170
+
171
+ return drug_batch
172
+
173
+
174
+ class MorganFPEncoder(nn.Module):
175
+ """
176
+ Encoder for Morgan (ECFP4) molecular fingerprints.
177
+
178
+ Morgan fingerprints capture circular substructure patterns and provide
179
+ a chemistry-prior-based global drug representation that inherently
180
+ generalises to unseen molecules — unlike GNN features that depend on
181
+ message passing over specific graph topologies.
182
+
183
+ This encoder projects the binary fingerprint to a dense vector and
184
+ is fused with the GNN representation for dual-channel drug encoding.
185
+ """
186
+
187
+ def __init__(self, input_dim: int = 1024, hidden_dim: int = 256,
188
+ dropout: float = 0.2):
189
+ super().__init__()
190
+ self.encoder = nn.Sequential(
191
+ nn.Linear(input_dim, hidden_dim * 2),
192
+ nn.LayerNorm(hidden_dim * 2),
193
+ nn.GELU(),
194
+ nn.Dropout(dropout),
195
+ nn.Linear(hidden_dim * 2, hidden_dim),
196
+ nn.LayerNorm(hidden_dim),
197
+ nn.GELU(),
198
+ nn.Dropout(dropout),
199
+ )
200
+ self.output_dim = hidden_dim
201
+
202
+ def forward(self, fp: torch.Tensor) -> torch.Tensor:
203
+ """
204
+ Args:
205
+ fp: (B, input_dim) binary Morgan fingerprint
206
+ Returns:
207
+ (B, hidden_dim) dense drug representation
208
+ """
209
+ return self.encoder(fp)
src/models/interaction.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ interaction.py — Cross-Attention interaction module.
3
+
4
+ This is the core of BioInteract: computing fine-grained atom-residue
5
+ interaction maps that are both predictive and interpretable.
6
+
7
+ Design philosophy:
8
+ Traditional DTI models concatenate drug and protein vectors and
9
+ lose spatial information. Our cross-attention computes an explicit
10
+ interaction matrix M ∈ R^(n_atoms × n_residues), where M[i,j]
11
+ represents the "interaction strength" between drug atom i and
12
+ protein residue j.
13
+
14
+ This matrix directly corresponds to the biological concept of
15
+ molecular contacts — and can be validated against crystal structures.
16
+ """
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+ import math
21
+
22
+
23
+ class CrossAttentionInteraction(nn.Module):
24
+ """
25
+ Multi-head cross-attention between drug atoms and protein residues.
26
+
27
+ Returns:
28
+ - Fused representation for prediction
29
+ - Attention weights for interpretability (atom × residue interaction map)
30
+ """
31
+
32
+ def __init__(self,
33
+ hidden_dim: int = 256,
34
+ num_heads: int = 8,
35
+ dropout: float = 0.1):
36
+ super().__init__()
37
+ assert hidden_dim % num_heads == 0
38
+
39
+ self.hidden_dim = hidden_dim
40
+ self.num_heads = num_heads
41
+ self.head_dim = hidden_dim // num_heads
42
+ self.scale = math.sqrt(self.head_dim)
43
+
44
+ # drug atoms attend to protein residues
45
+ self.W_q_drug = nn.Linear(hidden_dim, hidden_dim)
46
+ self.W_k_prot = nn.Linear(hidden_dim, hidden_dim)
47
+ self.W_v_prot = nn.Linear(hidden_dim, hidden_dim)
48
+
49
+ # protein residues attend to drug atoms (bidirectional)
50
+ self.W_q_prot = nn.Linear(hidden_dim, hidden_dim)
51
+ self.W_k_drug = nn.Linear(hidden_dim, hidden_dim)
52
+ self.W_v_drug = nn.Linear(hidden_dim, hidden_dim)
53
+
54
+ # output projections
55
+ self.out_proj_drug = nn.Linear(hidden_dim, hidden_dim)
56
+ self.out_proj_prot = nn.Linear(hidden_dim, hidden_dim)
57
+
58
+ # layer norms
59
+ self.ln_drug = nn.LayerNorm(hidden_dim)
60
+ self.ln_prot = nn.LayerNorm(hidden_dim)
61
+
62
+ self.dropout = nn.Dropout(dropout)
63
+
64
+ def _attention(self, Q, K, V, mask=None):
65
+ """
66
+ Standard scaled dot-product attention.
67
+
68
+ Returns:
69
+ output: attended values
70
+ attn_weights: softmax attention weights (for interpretability)
71
+ """
72
+ # Q: (B, H, Lq, d), K: (B, H, Lk, d), V: (B, H, Lk, d)
73
+ scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale # (B,H,Lq,Lk)
74
+
75
+ if mask is not None:
76
+ # mask shape: (B, 1, 1, Lk) — broadcast over heads and queries
77
+ scores = scores.masked_fill(~mask, float('-inf'))
78
+
79
+ attn_weights = F.softmax(scores, dim=-1)
80
+ attn_weights = self.dropout(attn_weights)
81
+
82
+ output = torch.matmul(attn_weights, V) # (B, H, Lq, d)
83
+ return output, attn_weights
84
+
85
+ def _reshape_to_heads(self, x, batch_size):
86
+ """(B, L, D) → (B, H, L, d)"""
87
+ return x.view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
88
+
89
+ def forward(self,
90
+ drug_repr: torch.Tensor,
91
+ drug_mask: torch.Tensor,
92
+ prot_repr: torch.Tensor,
93
+ prot_mask: torch.Tensor):
94
+ """
95
+ Bidirectional cross-attention between drug atoms and protein residues.
96
+
97
+ Args:
98
+ drug_repr: (B, N_atoms, D) — per-atom drug representations
99
+ drug_mask: (B, N_atoms) — boolean mask for drug atoms
100
+ prot_repr: (B, L_residues, D) — per-residue protein representations
101
+ prot_mask: (B, L_residues) — boolean mask for protein residues
102
+
103
+ Returns:
104
+ drug_updated: (B, N_atoms, D) — drug repr enriched by protein context
105
+ prot_updated: (B, L_residues, D) — protein repr enriched by drug context
106
+ interaction_map: (B, N_atoms, L_residues) — attention-based interaction matrix
107
+ this is the key output for interpretability analysis
108
+ """
109
+ B = drug_repr.size(0)
110
+
111
+ # --- Drug → Protein attention ---
112
+ Q_d = self._reshape_to_heads(self.W_q_drug(drug_repr), B)
113
+ K_p = self._reshape_to_heads(self.W_k_prot(prot_repr), B)
114
+ V_p = self._reshape_to_heads(self.W_v_prot(prot_repr), B)
115
+
116
+ # mask: (B, L_residues) → (B, 1, 1, L_residues)
117
+ prot_attn_mask = prot_mask.unsqueeze(1).unsqueeze(2) if prot_mask is not None else None
118
+
119
+ drug_attended, drug_to_prot_attn = self._attention(Q_d, K_p, V_p, prot_attn_mask)
120
+ # drug_to_prot_attn: (B, H, N_atoms, L_residues)
121
+
122
+ drug_attended = drug_attended.transpose(1, 2).contiguous().view(B, -1, self.hidden_dim)
123
+ drug_attended = self.out_proj_drug(drug_attended)
124
+ drug_updated = self.ln_drug(drug_repr + drug_attended)
125
+
126
+ # --- Protein → Drug attention ---
127
+ Q_p = self._reshape_to_heads(self.W_q_prot(prot_repr), B)
128
+ K_d = self._reshape_to_heads(self.W_k_drug(drug_repr), B)
129
+ V_d = self._reshape_to_heads(self.W_v_drug(drug_repr), B)
130
+
131
+ drug_attn_mask = drug_mask.unsqueeze(1).unsqueeze(2) if drug_mask is not None else None
132
+
133
+ prot_attended, _ = self._attention(Q_p, K_d, V_d, drug_attn_mask)
134
+
135
+ prot_attended = prot_attended.transpose(1, 2).contiguous().view(B, -1, self.hidden_dim)
136
+ prot_attended = self.out_proj_prot(prot_attended)
137
+ prot_updated = self.ln_prot(prot_repr + prot_attended)
138
+
139
+ # --- Interaction map (averaged over heads) ---
140
+ # This is what we visualise and validate against binding sites
141
+ interaction_map = drug_to_prot_attn.mean(dim=1) # (B, N_atoms, L_residues)
142
+
143
+ return drug_updated, prot_updated, interaction_map
144
+
145
+
146
+ class GatedPooling(nn.Module):
147
+ """
148
+ Gated pooling for aggregating atom/residue-level features into
149
+ a fixed-size vector for prediction.
150
+
151
+ Instead of mean/max pooling, we learn which atoms and residues
152
+ are most important for the final prediction. The gate weights
153
+ are themselves interpretable signals.
154
+ """
155
+
156
+ def __init__(self, hidden_dim: int):
157
+ super().__init__()
158
+ self.gate = nn.Sequential(
159
+ nn.Linear(hidden_dim, hidden_dim),
160
+ nn.Tanh(),
161
+ nn.Linear(hidden_dim, 1),
162
+ )
163
+ self.transform = nn.Sequential(
164
+ nn.Linear(hidden_dim, hidden_dim),
165
+ nn.Tanh(),
166
+ )
167
+
168
+ def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor:
169
+ """
170
+ Args:
171
+ x: (B, L, D) — sequence of feature vectors
172
+ mask: (B, L) — boolean mask
173
+
174
+ Returns:
175
+ pooled: (B, D) — single feature vector per sample
176
+ """
177
+ gate_scores = self.gate(x).squeeze(-1) # (B, L)
178
+
179
+ if mask is not None:
180
+ gate_scores = gate_scores.masked_fill(~mask, float('-inf'))
181
+
182
+ gate_weights = F.softmax(gate_scores, dim=-1) # (B, L)
183
+
184
+ transformed = self.transform(x) # (B, L, D)
185
+ pooled = torch.bmm(gate_weights.unsqueeze(1), transformed).squeeze(1) # (B, D)
186
+
187
+ return pooled
src/models/target_encoder.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ target_encoder.py — Protein target encoder combining ESM-2 with domain knowledge.
3
+
4
+ Takes pre-extracted ESM-2 embeddings and enriches them with:
5
+ 1. Physicochemical amino acid properties (hydrophobicity, charge, etc.)
6
+ 2. Learnable functional domain embeddings (Pfam/InterPro)
7
+
8
+ This design lets us inject biological knowledge about protein structure
9
+ and function without any additional computational cost at training time.
10
+ """
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+
15
+ class TargetEncoder(nn.Module):
16
+ """
17
+ Protein residue-level encoder.
18
+
19
+ Input:
20
+ - ESM-2 embeddings: (B, L, 1280) — pretrained, frozen
21
+ - Physicochemical features: (B, L, 4) — amino acid properties
22
+ - Domain labels: (B, L) — functional domain type indices
23
+
24
+ Output:
25
+ - Residue representations: (B, L, projection_dim)
26
+
27
+ The domain embedding adds awareness of which functional region each
28
+ residue belongs to (e.g., kinase domain, SH2 domain). This helps the
29
+ cross-attention module learn family-specific interaction patterns:
30
+ a kinase inhibitor should attend differently to residues in the
31
+ catalytic domain vs. a regulatory domain.
32
+ """
33
+
34
+ def __init__(self,
35
+ esm2_dim: int = 1280,
36
+ projection_dim: int = 256,
37
+ physchem_dim: int = 4,
38
+ domain_embed_dim: int = 32,
39
+ num_domain_types: int = 50,
40
+ use_domain_features: bool = True,
41
+ dropout: float = 0.1):
42
+ super().__init__()
43
+ self.use_domain = use_domain_features
44
+ self.projection_dim = projection_dim
45
+
46
+ # total input dimension
47
+ input_dim = esm2_dim + physchem_dim
48
+ if use_domain_features:
49
+ input_dim += domain_embed_dim
50
+ self.domain_embedding = nn.Embedding(
51
+ num_domain_types + 1, # +1 for padding
52
+ domain_embed_dim,
53
+ padding_idx=num_domain_types,
54
+ )
55
+
56
+ # projection: compress concatenated features to target dim
57
+ self.projection = nn.Sequential(
58
+ nn.Linear(input_dim, projection_dim * 2),
59
+ nn.LayerNorm(projection_dim * 2),
60
+ nn.GELU(),
61
+ nn.Dropout(dropout),
62
+ nn.Linear(projection_dim * 2, projection_dim),
63
+ nn.LayerNorm(projection_dim),
64
+ )
65
+
66
+ def forward(self,
67
+ esm2_embedding: torch.Tensor,
68
+ physicochemical: torch.Tensor,
69
+ domain_labels: torch.Tensor = None,
70
+ protein_mask: torch.Tensor = None) -> torch.Tensor:
71
+ """
72
+ Args:
73
+ esm2_embedding: (B, L, 1280) pretrained embeddings
74
+ physicochemical: (B, L, 4) amino acid properties
75
+ domain_labels: (B, L) LongTensor of domain type indices
76
+ protein_mask: (B, L) boolean mask (True = valid residue)
77
+
78
+ Returns:
79
+ residue_repr: (B, L, projection_dim) residue representations
80
+ """
81
+ parts = [esm2_embedding, physicochemical]
82
+
83
+ if self.use_domain and domain_labels is not None:
84
+ domain_emb = self.domain_embedding(domain_labels)
85
+ parts.append(domain_emb)
86
+
87
+ # concatenate all feature sources
88
+ combined = torch.cat(parts, dim=-1)
89
+
90
+ # project
91
+ residue_repr = self.projection(combined)
92
+
93
+ # zero out padded positions
94
+ if protein_mask is not None:
95
+ residue_repr = residue_repr * protein_mask.unsqueeze(-1).float()
96
+
97
+ return residue_repr