Graph Neural Network Drug-Target Interaction Prediction: A Practical Guide to SARS-CoV-2 Mpro Drug Repurposing Screening
Predicting the binding affinity and strength between drug candidate molecules and protein targets is a crucial task in drug discovery. While docking (Part 08) handles 3D coordinates, this part focuses on handling chemical structure graphs. SMILES strings are represented as graphs composed of nodes (atoms) and edges (bonds), and a Graph Neural Network (GNN) learns from this topological information to predict binding. With a single small GPU, you can screen tens of thousands of drug candidates in seconds, and visualize attention to interpret which atoms contribute to the binding. This part builds a complete pipeline using SARS-CoV-2 main protease (Mpro) as a target for drug repurposing.
📚 Recommended Prerequisites (Strongly Recommended)
This is an advanced, in-depth AI×Bio module. We strongly recommend that you first review the following DryBench modules before starting.
- DryBench ai-native #2 Neural Network Basics
- DryBench ai-native #12 PyTorch Basics
- DryBench ai-native #13 HuggingFace and OpenAI APIs
If you attempt to start without the prerequisites, you will find it difficult to follow the practical code in this part, as it proceeds without re-explaining the principles of graph convolution, message passing, attention heads, and PyTorch autograd.
We Learned This in DryBench
In DryBench ai-native #2, you learned that neural networks learn hierarchical representations by stacking layers that filter local patterns on the input. In #12, you learned that PyTorch is the foundation for tensors, autograd, and GPU operations. In #13, you learned that HuggingFace provides a standard API for loading pre-trained models.
Graph Neural Networks (GNNs) extend this principle to irregular graph structures. Images are grids of pixels, and text is a linear sequence, making CNNs and RNNs natural choices. However, molecules are graphs where each node has a different number of neighbors. The message passing in GNNs involves each node exchanging information with its neighbors and updating its representation. This makes GNNs a standard choice for graph data such as molecules, proteins, and social networks. This part applies this principle in a hardcore manner to drug-target interaction (DTI) prediction.
Hardcore Problem Definition
Practical Scenario: SARS-CoV-2 Mpro Drug Repurposing
During the 2020-2023 pandemic, several research teams screened libraries of approved drugs (FDA-approved, DrugBank, Repurposing Hub) for potential binders to SARS-CoV-2 main protease (Mpro, 3CLpro). Since in vitro experiments cost tens of thousands of dollars per drug and take days, in silico pre-filtering is essential. Our pipeline aims to:
- Input: 3000 approved drug SMILES + Mpro sequence (UniProt P0DTD1, 306 residues) + PDB structure (e.g., 6LU7, 7BQY).
- Output: Binding probability and strength score for each drug against Mpro, and a recommendation of the top 20 candidates.
- Validation: Consistency with experimentally measured Mpro ligands in PDBbind and BindingDB, and known protease inhibitors (e.g., nirmatrelvir).
- Cost: Complete the entire pipeline on a local small GPU in under 30 minutes.
Fundamental Challenges in DTI
- Classification task: Binary prediction of binding vs. non-binding (BindingDB, DrugBank, BioSNAP).
- Regression task: Quantitative prediction of binding affinity, such as pIC50, Kd, or Ki (Davis, KIBA, Metz benchmark).
- Cold-target generalization: Prediction performance on new target proteins not seen during training (Mpro did not appear in data prior to the SARS-CoV-2 pandemic).
- Cold-drug generalization: Prediction performance on new drug candidates not seen during training.
- Assay diversity: Training data is a mixture of different assays (biochemical, cellular, in vivo). Assay-specific biases exist.
Existing Approaches and the Position of This Part
- Random Forest + ECFP feature: Fast and a good baseline. R² 0.4-0.5.
- 1D CNN + SMILES char (DeepDTA 2018): Processes as a sequence, losing structural information. R² 0.5-0.6 [1].
- GraphDTA (Nguyen 2020): The first graph-based DTI SOTA. R² 0.6-0.65 [2].
- MGraphDTA (Yang 2022): Multi-scale graph and attention. R² 0.65-0.70 [3].
- GeNNius (2024): Ultra-light and ultra-fast. Very fast training and inference [4].
- DrugBAN (2023): Bilinear attention and enhanced interpretability.
- Foundation model (ChemBERTa, MolFormer, DeepChem): Fine-tuning after large-scale pre-training.
- AlphaFold3, Boltz-2 (Part 11): Integrates docking. Highest accuracy but high GPU requirements.
In this part, we will cover a GAT-based baseline (main part) + ChemBERTa embedding integration (extension idea) + attention visualization.
Target Metrics for This Part
- BindingDB binary classification AUC of 0.90 or higher (random split).
- Davis Pearson r of 0.80 or higher.
- Cold-target setting 2 AUC of 0.75 or higher (generalization validation).
- In the Mpro scenario, a top-20 rank recall of 0.5 or higher for known inhibitors (i.e., retrieve at least 10 of the 20 known inhibitors in the top 20).
- Training wall-clock time of less than 30 minutes (small GPU).
- Inference throughput of 1000+ SMILES per second.
Tools and Infrastructure Requirements
| Tool | Role | License |
|---|---|---|
| RDKit | SMILES → Molecular graph, fingerprint | BSD-3-Clause |
| PyTorch Geometric (PyG) | Graph neural network framework | MIT |
| DGL (Optional) | PyG alternative | Apache 2.0 |
| GeNNius (GitHub) | Ultra-light GNN baseline | MIT (expected) |
| ChemBERTa (HuggingFace) | Pre-trained molecular embeddings (optional) | Apache 2.0 |
| MolFormer (IBM) | Alternative molecular foundation | Apache 2.0 |
| BindingDB, Davis, KIBA, BioSNAP | Training/validation benchmarks | Academic free |
| DrugBank, DrugRepurposing Hub | Drug library | Academic free, registration required |
Infrastructure Requirements:
- Small consumer GPU (RTX 4060 8GB or higher recommended; CPU is also possible for training and inference, but it will be 5-10x slower).
- 16GB RAM or more.
- Disk: BindingDB dump is about 500MB, Davis and KIBA are each less than 100MB, and DrugBank is about 200MB.
Estimated Cost for Learners: API cost is 0 (completely local). GPU time is less than 30 minutes for training, and inference is in seconds.
Practical Pipeline Implementation
Overall flow:
Step 1. SMILES → Molecular Graph
from dataclasses import dataclassfrom typing import Any
import torchfrom torch_geometric.data import Datafrom rdkit import Chem, RDLogger
RDLogger.DisableLog("rdApp.*")
ATOM_FEATURES = { "atomic_num": list(range(1, 119)), "degree": [0, 1, 2, 3, 4, 5, 6], "formal_charge": [-3, -2, -1, 0, 1, 2, 3], "hybridization": [ Chem.rdchem.HybridizationType.SP, Chem.rdchem.HybridizationType.SP2, Chem.rdchem.HybridizationType.SP3, Chem.rdchem.HybridizationType.SP3D, Chem.rdchem.HybridizationType.SP3D2, ], "num_h": [0, 1, 2, 3, 4], "is_aromatic": [False, True], "is_in_ring": [False, True],}
def one_hot(value: Any, choices: list) -> list[int]: """If mismatch, put 1 in the last slot (OOV handling).""" if value in choices: idx = choices.index(value) else: idx = len(choices) - 1 result = [0] * len(choices) result[idx] = 1 return result
def atom_features(atom: Chem.Atom) -> list[float]: features = [] features += one_hot(atom.GetAtomicNum(), ATOM_FEATURES["atomic_num"]) features += one_hot(atom.GetDegree(), ATOM_FEATURES["degree"]) features += one_hot(atom.GetFormalCharge(), ATOM_FEATURES["formal_charge"]) features += one_hot(atom.GetHybridization(), ATOM_FEATURES["hybridization"]) features += one_hot(atom.GetTotalNumHs(), ATOM_FEATURES["num_h"]) features += one_hot(atom.GetIsAromatic(), ATOM_FEATURES["is_aromatic"]) features += one_hot(atom.IsInRing(), ATOM_FEATURES["is_in_ring"]) return features
def bond_features(bond: Chem.Bond) -> list[float]: bt = bond.GetBondType() return [ int(bt == Chem.rdchem.BondType.SINGLE), int(bt == Chem.rdchem.BondType.DOUBLE), int(bt == Chem.rdchem.BondType.TRIPLE), int(bt == Chem.rdchem.BondType.AROMATIC), int(bond.GetIsConjugated()), int(bond.IsInRing()), ]
def smiles_to_graph(smiles: str, drug_id: str = "") -> Data | None: """SMILES → PyG Data. Returns None if parsing fails.""" mol = Chem.MolFromSmiles(smiles) if mol is None or mol.GetNumAtoms() == 0: return None canonical = Chem.MolToSmiles(mol, canonical=True) node_features = torch.tensor( [atom_features(atom) for atom in mol.GetAtoms()], dtype=torch.float, ) edge_indices = [] edge_attrs = [] for bond in mol.GetBonds(): i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() feat = bond_features(bond) edge_indices += [[i, j], [j, i]] # Bidirectional edge_attrs += [feat, feat] if not edge_indices: edge_index = torch.empty((2, 0), dtype=torch.long) edge_attr = torch.empty((0, 6), dtype=torch.float) else: edge_index = torch.tensor(edge_indices, dtype=torch.long).t().contiguous() edge_attr = torch.tensor(edge_attrs, dtype=torch.float) data = Data(x=node_features, edge_index=edge_index, edge_attr=edge_attr) data.smiles = canonical data.drug_id = drug_id return dataStep 2. Target Protein Encoding (ESM2 embedding or CNN baseline)
def protein_to_onehot(sequence: str, max_length: int = 1200) -> torch.Tensor: """Protein sequence → (max_length, 21) one-hot.""" aa_to_idx = {aa: i for i, aa in enumerate("ACDEFGHIKLMNPQRSTVWY")} seq = sequence[:max_length].upper() encoded = torch.zeros(max_length, 21) for i, aa in enumerate(seq): encoded[i, aa_to_idx.get(aa, 20)] = 1.0 return encoded
class ProteinCNN(torch.nn.Module): """Protein sequence → embedding vector (baseline)."""
def __init__(self, output_dim: int = 128): super().__init__() self.conv1 = torch.nn.Conv1d(21, 32, kernel_size=8, padding=3) self.conv2 = torch.nn.Conv1d(32, 64, kernel_size=8, padding=3) self.conv3 = torch.nn.Conv1d(64, output_dim, kernel_size=8, padding=3) self.pool = torch.nn.AdaptiveAvgPool1d(1)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (B, max_length, 21) x = x.transpose(1, 2) # (B, 21, L) x = torch.relu(self.conv1(x)) x = torch.relu(self.conv2(x)) x = torch.relu(self.conv3(x)) return self.pool(x).squeeze(-1)
class ProteinESM2(torch.nn.Module): """ESM2 embedding wrapper (similar to 02)."""
MODEL_ID = "facebook/esm2_t6_8M_UR50D" # 8M small, very lightweight
def __init__(self, device: str = "cuda"): super().__init__() from transformers import AutoTokenizer, AutoModel self.tokenizer = AutoTokenizer.from_pretrained(self.MODEL_ID) self.model = AutoModel.from_pretrained(self.MODEL_ID).to(device).eval() self.device = device self.output_dim = self.model.config.hidden_size # 320 for 8M
@torch.no_grad() def forward(self, sequences: list[str]) -> torch.Tensor: embs = [] for seq in sequences: inputs = self.tokenizer(seq, return_tensors="pt", truncation=True, max_length=1024).to(self.device) out = self.model(**inputs) embs.append(out.last_hidden_state.mean(dim=1).squeeze(0)) return torch.stack(embs).to(self.device)Step 3. GAT-based DTI Model
from torch_geometric.nn import GATConv, global_mean_pool
class GAT_DTI(torch.nn.Module): """Molecular GAT + protein CNN/ESM + concat MLP → DTI prediction."""
def __init__( self, atom_feat_dim: int = 133, # atom_features output dimension gat_hidden: int = 128, gat_heads: int = 4, protein_dim: int = 128, fusion_dim: int = 256, task: str = "classification", # "regression" or "classification" ): super().__init__() self.gat1 = GATConv(atom_feat_dim, gat_hidden, heads=gat_heads, dropout=0.1) self.gat2 = GATConv(gat_hidden * gat_heads, gat_hidden, heads=1, dropout=0.1) self.protein_encoder = ProteinCNN(output_dim=protein_dim) self.fusion = torch.nn.Sequential( torch.nn.Linear(gat_hidden + protein_dim, fusion_dim), torch.nn.ReLU(), torch.nn.Dropout(0.2), torch.nn.Linear(fusion_dim, fusion_dim // 2), torch.nn.ReLU(), ) self.head = torch.nn.Linear(fusion_dim // 2, 1) self.task = task
def forward(self, mol_data: Data, protein_onehot: torch.Tensor, return_attention: bool = False) -> torch.Tensor: x, edge_index = mol_data.x, mol_data.edge_index if return_attention: x, (_, alpha1) = self.gat1(x, edge_index, return_attention_weights=True) else: x = self.gat1(x, edge_index) x = torch.relu(x) x = self.gat2(x, edge_index) mol_emb = global_mean_pool(x, mol_data.batch) # (B, gat_hidden) prot_emb = self.protein_encoder(protein_onehot) # (B, protein_dim) combined = torch.cat([mol_emb, prot_emb], dim=-1) hidden = self.fusion(combined) out = self.head(hidden).squeeze(-1) if self.task == "classification": out = torch.sigmoid(out) if return_attention: return out, alpha1 return outStep 4. DataLoader & Training Loop
from torch_geometric.loader import DataLoader as PyGLoader
class DTIDataset(torch.utils.data.Dataset): """DTI training dataset."""
def __init__(self, records: list[dict]): self.records = [] for r in records: g = smiles_to_graph(r["smiles"], drug_id=r.get("drug_id", "")) if g is None: continue g.protein = protein_to_onehot(r["protein_sequence"]) g.y = torch.tensor(r["label"], dtype=torch.float) g.target_id = r.get("target_id", "") self.records.append(g)
def __len__(self): return len(self.records)
def __getitem__(self, idx): return self.records[idx]
def train_dti( train_records: list[dict], val_records: list[dict], epochs: int = 50, batch_size: int = 64, lr: float = 1e-3, device: str = "cuda", task: str = "classification",) -> torch.nn.Module: train_ds = DTIDataset(train_records) val_ds = DTIDataset(val_records) train_loader = PyGLoader(train_ds, batch_size=batch_size, shuffle=True) val_loader = PyGLoader(val_ds, batch_size=batch_size) model = GAT_DTI(task=task).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) criterion = torch.nn.BCELoss() if task == "classification" else torch.nn.MSELoss() best_val_metric = 0.0 if task == "classification" else float("inf") for epoch in range(epochs): # Train model.train() total_loss = 0.0 for batch in train_loader: batch = batch.to(device) optimizer.zero_grad() pred = model(batch, batch.protein.view(batch.num_graphs, -1, 21)) loss = criterion(pred, batch.y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() # Validate val_metric = evaluate_model(model, val_loader, device, task) print(f"Epoch {epoch+1}: train_loss={total_loss/len(train_loader):.4f}, val_metric={val_metric:.4f}") # Best 저장 if (task == "classification" and val_metric > best_val_metric) or \ (task == "regression" and val_metric < best_val_metric): best_val_metric = val_metric torch.save(model.state_dict(), "best_dti.pt") return model
def evaluate_model(model, loader, device: str, task: str) -> float: from sklearn.metrics import roc_auc_score, mean_squared_error model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in loader: batch = batch.to(device) pred = model(batch, batch.protein.view(batch.num_graphs, -1, 21)) all_preds.extend(pred.cpu().tolist()) all_labels.extend(batch.y.cpu().tolist()) if task == "classification": return roc_auc_score(all_labels, all_preds) return mean_squared_error(all_labels, all_preds, squared=False)Step 5. Cold-target, Cold-drug split (Generalization validation)
The real challenge of DTI benchmarks. Cold splitting is closer to real-world drug discovery scenarios than random splitting [5].
import random
def cold_target_split( records: list[dict], val_target_ratio: float = 0.2, seed: int = 42,) -> tuple[list, list]: """Cold-target split: validation targets are not in the training set.""" random.seed(seed) all_targets = list({r["target_id"] for r in records}) random.shuffle(all_targets) n_val = int(len(all_targets) * val_target_ratio) val_targets = set(all_targets[:n_val]) train = [r for r in records if r["target_id"] not in val_targets] val = [r for r in records if r["target_id"] in val_targets] return train, val
def cold_drug_split( records: list[dict], val_drug_ratio: float = 0.2, seed: int = 42,) -> tuple[list, list]: """Cold-drug split: validation drugs are not in the training set.""" random.seed(seed) all_drugs = list({r["drug_id"] for r in records}) random.shuffle(all_drugs) n_val = int(len(all_drugs) * val_drug_ratio) val_drugs = set(all_drugs[:n_val]) train = [r for r in records if r["drug_id"] not in val_drugs] val = [r for r in records if r["drug_id"] in val_drugs] return train, valStep 6. SARS-CoV-2 Mpro drug repurposing screening
import pandas as pd
MPRO_SEQUENCE = ( # SARS-CoV-2 Mpro (UniProt P0DTD1, 306 residues) "SGFRKMAFPSGKVEGCMVQVTCGTTTLNGLWLDDVVYCPRHVICTSEDMLNPNYEDLLIRKSNHNFLVQAGNVQLRVIGHSMQNCVLKLKVDTANPKTPKYKFVR" "IQPGQTFSVLACYNGSPSGVYQCAMRPNFTIKGSFLNGSCGSVGFNIDYDCVSFCYMHHMELPTGVHAGTDLEGNFYGPFVDRQTAQAAGTDTTITVNVLAWLYA" "AVINGDRWFLNRFTTTLNDFNLVAMKYNYEPLTQDHVDILGPLSAQTGIAVLDMCASLKELLQNGMNGRTILGSALLEDEFTPFDVVRQCSGVTFQ")
KNOWN_MPRO_INHIBITORS = [ # nirmatrelvir (Paxlovid component) "CC1(C)C2CC1C(NC(=O)C1CCCN1C(=O)C(NC(=O)OC(C)(C)C)C(C)(C)C)C(=O)NC(C#N)CC2=O", # ensitrelvir (Xocova) etc. # In practice, fetch from ChEMBL, DrugBank, etc.]
def screen_mpro_repurposing( model: torch.nn.Module, drug_smiles_list: list[tuple[str, str]], # (drug_id, smiles) protein_sequence: str = MPRO_SEQUENCE, top_k: int = 20, device: str = "cuda",) -> pd.DataFrame: """Return top K candidates from approved drug library for Mpro.""" model.eval() prot_encoded = protein_to_onehot(protein_sequence).unsqueeze(0).to(device) results = [] for drug_id, smiles in drug_smiles_list: g = smiles_to_graph(smiles, drug_id) if g is None: continue g.batch = torch.zeros(g.x.size(0), dtype=torch.long) g = g.to(device) with torch.no_grad(): pred = model(g, prot_encoded).item() results.append({ "drug_id": drug_id, "smiles": smiles, "mpro_score": pred, }) df = pd.DataFrame(results).sort_values("mpro_score", ascending=False) return df.head(top_k)
def validate_against_known_inhibitors( model: torch.nn.Module, all_screened: pd.DataFrame, known_inhibitors_smiles: list[str], top_k: int = 20,) -> dict: """How many of the known inhibitors are in the top K?""" known_set = set(known_inhibitors_smiles) top_smiles = set(all_screened.head(top_k)["smiles"].tolist()) recall = len(top_smiles & known_set) / max(len(known_set), 1) return { "top_k": top_k, "recall_of_known": recall, "known_in_top_k": list(top_smiles & known_set), }Step 7. Attention Visualization (Interpretability)
Visualize which atoms contribute to the prediction using the GAT's attention weights.
def visualize_attention_on_molecule( model: GAT_DTI, smiles: str, protein_sequence: str, output_path: str = "attention.png", device: str = "cuda",) -> None: """Overlay GAT attention on the molecule.""" from rdkit.Chem import Draw from rdkit.Chem.Draw import rdMolDraw2D from matplotlib.cm import get_cmap g = smiles_to_graph(smiles) g.batch = torch.zeros(g.x.size(0), dtype=torch.long) g = g.to(device) prot = protein_to_onehot(protein_sequence).unsqueeze(0).to(device) model.eval() with torch.no_grad(): pred, alpha = model(g, prot, return_attention=True) # alpha: (num_edges, num_heads). Aggregate atom-level attention edge_index = g.edge_index.cpu().numpy() alpha_np = alpha.mean(dim=1).cpu().numpy() # (num_edges,) n_atoms = g.x.size(0) atom_attn = np.zeros(n_atoms) for e_idx, dest in enumerate(edge_index[1]): atom_attn[dest] += float(alpha_np[e_idx]) atom_attn = atom_attn / max(atom_attn.max(), 1e-8) # Color highlight with RDKit mol = Chem.MolFromSmiles(smiles) cmap = get_cmap("Reds") highlight_colors = {i: cmap(float(atom_attn[i]))[:3] for i in range(n_atoms)} drawer = rdMolDraw2D.MolDraw2DCairo(500, 500) drawer.drawOptions().addAtomIndices = False rdMolDraw2D.PrepareAndDrawMolecule( drawer, mol, highlightAtoms=list(range(n_atoms)), highlightAtomColors=highlight_colors, ) drawer.FinishDrawing() with open(output_path, "wb") as f: f.write(drawer.GetDrawingText()) print(f"Attention visualization saved: {output_path} (prediction score={pred.item():.3f})")Integrated Pipeline
def full_pipeline( training_records: list[dict], mpro_repurposing_library: list[tuple[str, str]], output_dir: Path, device: str = "cuda",) -> None: """Train DTI model → Mpro screening → attention visualization.""" output_dir.mkdir(parents=True, exist_ok=True) print("[1/4] Cold-target split") train, val = cold_target_split(training_records, val_target_ratio=0.2) print(f" train {len(train)} / val {len(val)}") print("[2/4] Train GAT_DTI") model = train_dti(train, val, epochs=50, task="classification", device=device) print("[3/4] Mpro repurposing screening") top20 = screen_mpro_repurposing(model, mpro_repurposing_library, top_k=20, device=device) top20.to_csv(output_dir / "mpro_top20.csv", index=False) print("[4/4] Visualize attention for top 3") for _, row in top20.head(3).iterrows(): visualize_attention_on_molecule( model, row["smiles"], MPRO_SEQUENCE, output_path=str(output_dir / f"attn_{row['drug_id']}.png"), device=device, ) # Validate against known inhibitors validation = validate_against_known_inhibitors( model, top20, KNOWN_MPRO_INHIBITORS, ) print(f"Recall of known inhibitors@20: {validation['recall_of_known']:.2f}")
## Performance, Cost, and Known Failure Cases
### Performance Reference (Using Public Benchmarks)
| Model | Benchmark (Davis, KIBA, BindingDB) | AUC / Pearson r | Source ||------|------------------------------|:----------------:|------|| Random Forest + ECFP | Davis Regression | r = 0.55 | Legacy || DeepDTA (1D CNN) | Davis | r = 0.62 | Öztürk et al., Bioinformatics 2018 [1] || GraphDTA (GAT) | Davis | r = 0.68 | Nguyen et al., Bioinformatics 2020 [2] || MGraphDTA | Davis, KIBA | r = 0.72 | Yang et al., Chem Sci 2022 [3] || GeNNius | BindingDB | AUC 0.92 | ML4BM Lab 2024 [4] || DrugBAN | BioSNAP | AUC 0.89 | Bai et al., Nat Mach Intell 2023 || Cold-target Setting | Davis cold-target | r = 0.35~0.50 (significant drop) | Pahikkala 2015 [5] || SARS-CoV-2 Mpro Benchmark (COVID Moonshot) | Candidate library | Various paper benchmarks | Moonshot consortium [6] |
### Estimated Cost for Learner Reproduction
- API cost: 0 (fully local).- Davis training (30k pairs) on a small consumer GPU: approximately 30-60 minutes.- Inference: 1000+ SMILES per second.- Mpro screening of 3000 drugs: completed in seconds.
### 5 Known Failure Cases (Community/Paper Collection)
1. **Sharp performance drop in cold-target scenarios (generalization failure)** Symptoms: AUC of 0.90 in random split, but drops to 0.55 in cold-target setting. Cause: Overfitting to common targets (kinases, etc.) in the training data. New targets have different sequence representations. Mitigation: (a) Enhance the target encoder with ESM2/ESM3 pretrained models, (b) Regulate the target embedding space with contrastive learning, (c) Force the benchmark to use a cold split (Step 5 in this section), (d) Use ChemBERTa/MolFormer molecular embedding initialization. Source: Pahikkala et al. "Toward more realistic drug-target interaction predictions." Brief Bioinform 2015 [5].
2. **Duplicate learning due to the absence of SMILES canonicalization** Symptoms: Multiple SMILES representations of the same molecule are treated as different samples during training, leading to data leakage. Cause: If RDKit canonicalization is not performed, representations like `CCO` vs. `C(C)O` are treated differently. Mitigation: Force `Chem.MolToSmiles(mol, canonical=True)` during training data preprocessing, remove duplicates, and perform tautomer standardization (RDKit `MolStandardize`). Source: RDKit Discussions [7].
3. **PyG DataLoader collate error (mismatch between molecule and protein batches)** Symptoms: Molecules and proteins within a batch are misaligned or have shape mismatches during training. Cause: PyG's Data objects are automatically batched, but protein tensors need separate handling. Mitigation: Use a custom collate function to (a) batch molecules with PyG, (b) stack protein tensors with torch, and (c) stack labels. Alternatively, attach protein features to the PyG Data object. Source: PyG GitHub Discussions [8].
4. **Label bias due to assay diversity** Symptoms: The labels in the training data come from various assays (biochemical Kd, cellular IC50, in vivo). The value scales differ. Cause: Different assays have different dynamic ranges and detection limits. Mitigation: (a) Add assay type as a feature, (b) train only on data from the same assay, (c) log-transform and z-score normalize, (d) use multi-task learning (separate heads for each assay). Source: Landrum et al. RDKit chemistry blog [7].
5. **Pitfalls in interpreting attention visualization** Symptoms: High-attention atoms in GAT are not necessarily the atoms that contribute most to binding. Misleading interpretation. Cause: Attention weights are byproducts of the learning process and do not guarantee causal or biological meaning. Mitigation: (a) Ensemble multiple explainability methods such as Integrated Gradients and GNNExplainer, (b) use attention only for hypothesis generation and combine it with experimental validation, (c) trust only atoms that consistently appear in multiple training runs with different seeds. Source: Jain & Wallace "Attention is not Explanation." NAACL 2019 [9].
## Extension Ideas
- **Combine foundation embeddings:** Use ChemBERTa, MolFormer, and SELFormer embeddings as initial node features for the GNN.- **Multi-task learning:** Train simultaneously on DTI, solubility (aqueous), hERG toxicity, and BBB permeability.- **Enhanced explainability:** Ensemble attention, GNNExplainer, and IntegratedGradients.- **Active learning:** Prioritize experimentation on new pairs with high uncertainty, acquire labels, and retrain.- **3D-aware GNN:** Use 3D coordinates from models like SchNet, DimeNet, and Equiformer and combine them with docking (Section 08).- **Structure-based reranking:** Re-evaluate the top candidates from the GNN using Boltz-2 (Section 11) for 3D binding affinity.
## Next Section
- Section 08: `docking-hybrid-diffusion`: Validate the poses of the top candidates predicted by the GNN using docking.- Section 11: `structure-affinity-boltz`: Use Boltz-2 to perform the final affinity ranking.- Section 12: `single-cell-perturbation`: Connect DTI results to single-cell response predictions.- Section 14: `bio-mcp-agent`: Expose DTI predictions as a tool in the MCP platform, enabling autonomous screening by an agent.
## References
1. Öztürk H, Özgür A, Ozkirimli E. "DeepDTA: deep drug-target binding affinity prediction." Bioinformatics 2018. `https://academic.oup.com/bioinformatics/article/34/17/i821/5093245`2. Nguyen T, Le H, Quinn TP, et al. "GraphDTA: Predicting drug-target binding affinity with graph neural networks." Bioinformatics 2020. `https://academic.oup.com/bioinformatics/article/37/8/1140/5942970`3. Yang Z, Zhong W, Zhao L, Chen CY-C. "MGraphDTA: deep multiscale graph neural network for explainable drug-target binding affinity prediction." Chemical Science 2022. `https://pubs.rsc.org/en/content/articlelanding/2022/sc/d1sc05180f`4. Muñoz-Gil G, et al. "GeNNius: An ultrafast drug-target interaction inference method based on graph neural networks." 2024. `https://github.com/ML4BM-Lab/GeNNius`5. Pahikkala T, Airola A, Pietilä S, et al. "Toward more realistic drug-target interaction predictions." Briefings in Bioinformatics 2015.6. COVID Moonshot consortium: `https://postera.ai/moonshot/`7. RDKit Discussions: `https://github.com/rdkit/rdkit/discussions`8. PyG GitHub Discussions: `https://github.com/pyg-team/pytorch_geometric/discussions`9. Jain S, Wallace BC. "Attention is not Explanation." NAACL 2019. `https://arxiv.org/abs/1902.10186`10. PyTorch Geometric documentation: `https://pytorch-geometric.readthedocs.io/`11. DGL (Deep Graph Library): `https://www.dgl.ai/`12. BindingDB: `https://www.bindingdb.org/`13. Davis benchmark: Davis MI et al. Nat Biotechnol 2011.14. KIBA benchmark: Tang J et al. J Chem Inf Model 2014.15. BioSNAP dataset: `https://snap.stanford.edu/biodata/`16. ChemBERTa: `https://huggingface.co/DeepChem/ChemBERTa-77M-MTR`17. MolFormer (IBM): `https://github.com/IBM/molformer`18. DrugBank: `https://go.drugbank.com/`19. Drug Repurposing Hub (Broad Institute): `https://clue.io/repurposing`20. UniProt SARS-CoV-2 Mpro (P0DTD1): `https://www.uniprot.org/uniprotkb/P0DTD1/entry`