그래프 신경망 약물-표적 상호작용 예측 — SARS-CoV-2 Mpro 표적 약물 재활용 스크리닝 실전
약물 후보 저분자와 단백질 표적 간 결합 여부·강도를 예측하는 태스크는 신약 발견의 핵심입니다. 도킹(편 08)이 3D 좌표를 다루는 방식이라면 이 편은 화학 구조 그래프를 다루는 방식입니다. SMILES 문자열을 노드(원자)와 엣지(결합)로 이루어진 그래프로 표현하고, 그래프 신경망(GNN)이 이 위상 정보를 학습해 결합을 예측합니다. 소형 GPU 하나로 수만 개 약물 후보를 초 단위 스크리닝할 수 있고, attention 시각화로 결합에 어떤 원자가 기여하는지 해석까지 가능합니다. 이 편은 SARS-CoV-2 main protease(Mpro)를 표적으로 하는 약물 재활용(drug repurposing) 시나리오를 예시로 완결 파이프라인을 구축합니다.
📚 선수 편 권고 (강력 권고)
이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.
- DryBench ai-native #2 신경망 기초
- DryBench ai-native #12 PyTorch 기초
- DryBench ai-native #13 HuggingFace와 상용 API
선수 편 없이 진입할 경우, 이 편에서 다루는 그래프 컨볼루션 · 메시지 패싱 · 어텐션 헤드 · PyTorch autograd 원리에 대한 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.
우리 DryBench에서 이거 배웠잖아
DryBench ai-native #2에서 신경망이 입력 위 로컬 패턴을 필터로 잡고 층을 쌓아 계층적 표현을 학습한다는 것을, #12에서 PyTorch가 tensor · autograd · GPU 이동의 기본기라는 것을, #13에서 HuggingFace가 pretrained 모델의 표준 로딩 API를 제공한다는 것을 배웠습니다.
그래프 신경망(GNN)은 이 원리를 불규칙한 그래프 구조로 확장합니다. 이미지는 격자 픽셀, 텍스트는 선형 시퀀스라 CNN · RNN이 자연스럽지만 분자는 노드마다 이웃 수가 다른 그래프. GNN의 메시지 패싱은 각 노드가 이웃과 정보를 교환하며 자신의 표현을 갱신하는 방식이라 분자 · 단백질 · 소셜 네트워크 같은 그래프 데이터에 표준으로 자리잡았습니다. 이 편은 그 원리를 약물-표적 상호작용(DTI) 예측에 하드코어로 적용합니다.
하드코어 문제 정의
실전 시나리오: SARS-CoV-2 Mpro 표적 약물 재활용
2020~2023 팬데믹 시기 여러 연구팀이 승인된 기존 약물 라이브러리(FDA-approved · DrugBank · Repurposing Hub)에서 SARS-CoV-2 main protease(Mpro, 3CLpro) 결합 후보를 스크리닝했습니다. 실제 실험은 각 약물당 수만 원+며칠이라 in silico 사전 필터가 필수입니다. 우리 파이프라인 목표:
- 입력: 승인된 약물 3000개 SMILES + Mpro 서열 (UniProt P0DTD1, 306 residues) + PDB 구조 (예: 6LU7 · 7BQY).
- 출력: 각 약물의 Mpro 결합 확률·강도 스코어, 상위 20개 추천.
- 검증: PDBbind · BindingDB에 실측된 Mpro 리간드 · 알려진 protease inhibitor (nirmatrelvir 등)와의 정합성.
- 비용: 로컬 소형 GPU에서 30분 이내 완결.
DTI의 근본 과제
- 분류 task: 결합 vs 비결합 이진 예측 (BindingDB · DrugBank · BioSNAP).
- 회귀 task: pIC50 · Kd · Ki 등 결합 강도 정량 (Davis · KIBA · Metz benchmark).
- cold-target 일반화: 학습에 없던 새 표적 단백질에 대한 예측력 (Mpro는 SARS-CoV-2 pandemic 이전 데이터에 등장 X).
- cold-drug 일반화: 학습에 없던 새 약물 후보에 대한 예측력.
- assay 다양성: 학습 데이터가 여러 assay (biochemical · cellular · in vivo) 혼합. Assay별 편향 존재.
기존 접근 스펙트럼과 이 편의 위치
- Random Forest + ECFP feature: 빠르고 baseline. R² 0.4~0.5.
- 1D CNN + SMILES char (DeepDTA 2018): 시퀀스로 처리, 구조 정보 손실. R² 0.5~0.6 [1].
- GraphDTA (Nguyen 2020): 첫 그래프 기반 DTI SOTA. R² 0.6~0.65 [2].
- MGraphDTA (Yang 2022): multi-scale graph · attention. R² 0.65~0.70 [3].
- GeNNius (2024): 초경량 · 초고속. 학습 · 추론 매우 빠름 [4].
- DrugBAN (2023): bilinear attention · 해석성 강화.
- Foundation model (ChemBERTa · MolFormer · DeepChem): 대규모 pre-training 후 finetune.
- AlphaFold3 · Boltz-2 (편 11): 도킹 통합. 정확도 최상위지만 GPU 부담.
이 편에서는 GAT 기반 baseline (본편) + ChemBERTa embedding 통합 (확장 아이디어) + attention 시각화까지 다룹니다.
이 편의 목표 지표
- BindingDB 이진 분류 AUC 0.90 이상 (random split).
- Davis Pearson r 0.80 이상.
- Cold-target Setting 2 AUC 0.75 이상 (일반화 검증).
- Mpro 시나리오에서 알려진 inhibitor top-20 rank recall 0.5 이상 (즉 상위 20 중 실제 known inhibitor 10개 이상 회수).
- 학습 wall-clock 30분 이하 (소형 GPU).
- 추론 처리량 초당 1000+ SMILES.
도구 스택과 인프라 요구
| 도구 | 역할 | 라이선스 |
|---|---|---|
| RDKit | SMILES → 분자 그래프 · fingerprint | BSD-3-Clause |
| PyTorch Geometric (PyG) | 그래프 신경망 프레임워크 | MIT |
| DGL (선택) | PyG 대안 | Apache 2.0 |
| GeNNius (GitHub) | 초경량 GNN baseline | MIT (예상) |
| ChemBERTa (HuggingFace) | pre-trained 분자 임베딩 (선택) | Apache 2.0 |
| MolFormer (IBM) | 대안 분자 foundation | Apache 2.0 |
| BindingDB · Davis · KIBA · BioSNAP | 학습·검증 벤치 | 학술 무료 |
| DrugBank · DrugRepurposing Hub | 약물 라이브러리 | 학술 무료 · 등록 |
인프라 요구:
- 소형 소비자 GPU (RTX 4060 8GB 이상 권장, CPU도 학습 · 추론 가능하지만 5~10x 느림).
- RAM 16GB 이상.
- 디스크: BindingDB dump 약 500MB, Davis · KIBA 각 100MB 이하, DrugBank 약 200MB.
학습자 재현 예상 비용: API 비용 0 (완전 로컬). GPU 시간 30분 이내 학습, 추론은 초 단위.
파이프라인 실전 구현
전체 흐름:
Step 1. SMILES → 분자 그래프
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]: """미매칭이면 마지막 slot에 1 (OOV 처리).""" 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. 파싱 실패 시 None.""" 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]] # 양방향 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. 표적 단백질 인코딩 (ESM2 임베딩 or CNN baseline)
def protein_to_onehot(sequence: str, max_length: int = 1200) -> torch.Tensor: """단백질 서열 → (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): """단백질 서열 → 임베딩 벡터 (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 임베딩 wrapper (편 02와 유사)."""
MODEL_ID = "facebook/esm2_t6_8M_UR50D" # 8M small · 매우 가벼움
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 기반 DTI 모델
from torch_geometric.nn import GATConv, global_mean_pool
class GAT_DTI(torch.nn.Module): """분자 GAT + 단백질 CNN/ESM + concat MLP → DTI 예측."""
def __init__( self, atom_feat_dim: int = 133, # atom_features 출력 차원 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 · 학습 loop
from torch_geometric.loader import DataLoader as PyGLoader
class DTIDataset(torch.utils.data.Dataset): """DTI 학습 데이터셋."""
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 (일반화 검증)
DTI 벤치의 진짜 어려움. Random split보다 cold split이 실전 신약 발견 시나리오에 더 가깝습니다 [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 표적은 학습에 등장 안 함.""" 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 약물은 학습에 등장 안 함.""" 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 약물 재활용 스크리닝
import pandas as pd
MPRO_SEQUENCE = ( # SARS-CoV-2 Mpro (UniProt P0DTD1, 306 residues) "SGFRKMAFPSGKVEGCMVQVTCGTTTLNGLWLDDVVYCPRHVICTSEDMLNPNYEDLLIRKSNHNFLVQAGNVQLRVIGHSMQNCVLKLKVDTANPKTPKYKFVR" "IQPGQTFSVLACYNGSPSGVYQCAMRPNFTIKGSFLNGSCGSVGFNIDYDCVSFCYMHHMELPTGVHAGTDLEGNFYGPFVDRQTAQAAGTDTTITVNVLAWLYA" "AVINGDRWFLNRFTTTLNDFNLVAMKYNYEPLTQDHVDILGPLSAQTGIAVLDMCASLKELLQNGMNGRTILGSALLEDEFTPFDVVRQCSGVTFQ")
KNOWN_MPRO_INHIBITORS = [ # nirmatrelvir (Paxlovid 성분) "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) 등 예시 # 실전에서는 ChEMBL · DrugBank 등에서 fetch]
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: """승인 약물 라이브러리에서 Mpro 결합 후보 상위 K 리턴.""" 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: """상위 K에 알려진 inhibitor가 얼마나 포함되나?""" 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 시각화 (해석성)
GAT의 attention weight로 어떤 원자가 예측에 기여하는지 시각화.
def visualize_attention_on_molecule( model: GAT_DTI, smiles: str, protein_sequence: str, output_path: str = "attention.png", device: str = "cuda",) -> None: """GAT attention을 분자 위 색상 오버레이.""" 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). 원자별 attention aggregate 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) # RDKit로 색상 highlight 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 시각화 저장: {output_path} (예측 score={pred.item():.3f})")통합 파이프라인
def full_pipeline( training_records: list[dict], mpro_repurposing_library: list[tuple[str, str]], output_dir: Path, device: str = "cuda",) -> None: """DTI 모델 학습 → Mpro 스크리닝 → attention 시각화.""" 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] GAT_DTI 학습") model = train_dti(train, val, epochs=50, task="classification", device=device) print("[3/4] Mpro 재활용 스크리닝") 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] 상위 3개 attention 시각화") 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, ) # 알려진 inhibitor 재현 검증 validation = validate_against_known_inhibitors( model, top20, KNOWN_MPRO_INHIBITORS, ) print(f"알려진 inhibitor recall@20: {validation['recall_of_known']:.2f}")성능·비용·알려진 실패 케이스
성능 참고 (공개 벤치 인용)
| 모델 | 벤치 (Davis · KIBA · BindingDB) | AUC / Pearson r | 출처 |
|---|---|---|---|
| Random Forest + ECFP | Davis 회귀 | 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 (급락) | Pahikkala 2015 [5] |
| SARS-CoV-2 Mpro 벤치 (COVID Moonshot) | 후보 라이브러리 | 다양한 논문 벤치 | Moonshot consortium [6] |
학습자 재현 예상 비용
- API 비용 0 (완전 로컬).
- 소형 소비자 GPU 기준 Davis 학습 (30k 페어) 약 30~60분.
- 추론: 초당 1000+ SMILES.
- Mpro 스크리닝 3000 약물 → 초 단위 완료.
알려진 실패 케이스 5건 (커뮤니티·논문 수집형)
-
Cold-target 성능 급락 (일반화 실패)
증상: Random split에서 AUC 0.90인데 cold-target에서 0.55로 폭락.
원인: 학습 데이터의 흔한 표적 (kinase 등)에 과적합. 새 표적은 서열 표현이 다름.
회피: (a) 표적 인코더를 ESM2/ESM3 pretrained로 강화, (b) Contrastive learning으로 표적 임베딩 공간 규제, (c) 벤치를 cold split으로 강제 (본 편 Step 5), (d) MolFormer · ChemBERTa 분자 임베딩 초기값 활용.
출처: Pahikkala et al. "Toward more realistic drug-target interaction predictions." Brief Bioinform 2015 [5]. -
SMILES canonicalization 부재로 중복 학습
증상: 같은 분자의 여러 SMILES 표기가 다른 샘플로 학습되어 데이터 유출.
원인: RDKit canonicalize 안 하면CCOvsC(C)O같은 표기가 다르게 처리.
회피: 학습 데이터 전처리 시Chem.MolToSmiles(mol, canonical=True)강제, 중복 제거, tautomer standardization (RDKitMolStandardize).
출처: RDKit Discussions [7]. -
PyG DataLoader collate 실수 (분자 배치 + 단백질 배치 불일치)
증상: 학습 시 batch 안 분자와 단백질이 어긋나거나 shape mismatch.
원인: PyG의 Data 객체는 자동 배치되지만 단백질 tensor는 별도 처리 필요.
회피: 커스텀 collate 함수로 (a) 분자는 PyG batch, (b) 단백질은 torch stack, (c) 라벨도 stack. 또는 protein feature도 PyG Data에 attach.
출처: PyG GitHub Discussions [8]. -
Assay 다양성으로 라벨 편향
증상: 학습 데이터의 label이 여러 assay (biochemical Kd · cellular IC50 · in vivo)에서 옴. 값 스케일이 다름.
원인: assay별 dynamic range · 검출 한계 상이.
회피: (a) assay type을 feature로 추가, (b) 같은 assay 내에서만 학습, (c) log 변환 후 z-score 정규화, (d) multi-task learning (assay별 head 분리).
출처: Landrum et al. RDKit chemistry blog [7]. -
Attention 시각화의 해석 함정
증상: GAT attention이 높은 원자가 반드시 결합 기여도 높은 것 아님. 해석 오도.
원인: Attention weight는 학습 결과의 부산물, causal · biological meaning 보장 X.
회피: (a) Integrated Gradients · GNNExplainer 등 여러 explainability 방법 앙상블, (b) attention을 hypothesis generation으로만, 실측 검증 병행, (c) 여러 seed로 학습 후 안정적으로 나오는 원자만 신뢰.
출처: Jain & Wallace "Attention is not Explanation." NAACL 2019 [9].
확장 아이디어
- Foundation embedding 결합: ChemBERTa · MolFormer · SELFormer 임베딩을 GNN 노드 feature 초기값으로.
- Multi-task learning: DTI + solubility (aqueous) + hERG toxicity + BBB permeability 동시 학습.
- Explainability 강화: Attention + GNNExplainer + IntegratedGradients 앙상블.
- Active learning: 불확실성 높은 새 페어를 우선 실험 → 라벨 획득 → 재학습.
- 3D-aware GNN: SchNet · DimeNet · Equiformer 등 3D 좌표 활용 → 도킹(편 08)과 결합.
- Structure-based rerank: GNN 상위 후보를 Boltz-2(편 11)로 3D 결합 친화도 재검증.
다음 편
- 편 08
docking-hybrid-diffusion: GNN 예측 상위 후보를 도킹으로 pose 검증. - 편 11
structure-affinity-boltz: Boltz-2로 최종 affinity 랭킹. - 편 12
single-cell-perturbation: DTI 결과를 세포 반응 예측과 연결. - 편 14
bio-mcp-agent: DTI 예측을 MCP tool로 노출 → agent 자율 스크리닝.
참고 문헌
- Ö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 - 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 - 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 - 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 - Pahikkala T, Airola A, Pietilä S, et al. "Toward more realistic drug-target interaction predictions." Briefings in Bioinformatics 2015.
- COVID Moonshot consortium:
https://postera.ai/moonshot/ - RDKit Discussions:
https://github.com/rdkit/rdkit/discussions - PyG GitHub Discussions:
https://github.com/pyg-team/pytorch_geometric/discussions - Jain S, Wallace BC. "Attention is not Explanation." NAACL 2019.
https://arxiv.org/abs/1902.10186 - PyTorch Geometric documentation:
https://pytorch-geometric.readthedocs.io/ - DGL (Deep Graph Library):
https://www.dgl.ai/ - BindingDB:
https://www.bindingdb.org/ - Davis benchmark: Davis MI et al. Nat Biotechnol 2011.
- KIBA benchmark: Tang J et al. J Chem Inf Model 2014.
- BioSNAP dataset:
https://snap.stanford.edu/biodata/ - ChemBERTa:
https://huggingface.co/DeepChem/ChemBERTa-77M-MTR - MolFormer (IBM):
https://github.com/IBM/molformer - DrugBank:
https://go.drugbank.com/ - Drug Repurposing Hub (Broad Institute):
https://clue.io/repurposing - UniProt SARS-CoV-2 Mpro (P0DTD1):
https://www.uniprot.org/uniprotkb/P0DTD1/entry