Spaces:
Sleeping
Sleeping
Deploy BioInteract Gradio demo
Browse files- .gitattributes +3 -0
- README.md +60 -12
- app.py +512 -0
- checkpoints/best.pt +3 -0
- configs/default.yaml +37 -0
- examples/11717001_BRAF.png +3 -0
- examples/156414_EGFR.png +3 -0
- examples/5328940_ABL1E255K.png +3 -0
- examples/interpretability_report.json +609 -0
- requirements.txt +19 -0
- src/__init__.py +0 -0
- src/data/__init__.py +0 -0
- src/data/mol_graph.py +252 -0
- src/data/protein_feat.py +108 -0
- src/models/__init__.py +0 -0
- src/models/biointeract.py +223 -0
- src/models/drug_encoder.py +209 -0
- src/models/interaction.py +187 -0
- src/models/target_encoder.py +97 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
examples/156414_EGFR.png
ADDED
|
Git LFS Details
|
examples/5328940_ABL1E255K.png
ADDED
|
Git LFS Details
|
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
|