BioPlayground

🧬
목록으로

그래프 신경망 약물-표적 상호작용 예측 — SARS-CoV-2 Mpro 표적 약물 재활용 스크리닝 실전

SMILES를 RDKit으로 분자 그래프로 변환하고 PyTorch Geometric 그래프 신경망(GAT)으로 약물-표적 상호작용(DTI)을 학습·예측. SARS-CoV-2 Mpro 표적 약물 재활용 시나리오, BindingDB · Davis · KIBA 벤치 재현, cold-target/cold-drug 일반화 검증, GAT attention으로 결합 원자 시각화까지 완결 파이프라인.

중급
|
40
|
검증 완료 (2026-07)
진행률0/15 (0%)

그래프 신경망 약물-표적 상호작용 예측 — SARS-CoV-2 Mpro 표적 약물 재활용 스크리닝 실전

약물 후보 저분자와 단백질 표적 간 결합 여부·강도를 예측하는 태스크는 신약 발견의 핵심입니다. 도킹(편 08)이 3D 좌표를 다루는 방식이라면 이 편은 화학 구조 그래프를 다루는 방식입니다. SMILES 문자열을 노드(원자)와 엣지(결합)로 이루어진 그래프로 표현하고, 그래프 신경망(GNN)이 이 위상 정보를 학습해 결합을 예측합니다. 소형 GPU 하나로 수만 개 약물 후보를 초 단위 스크리닝할 수 있고, attention 시각화로 결합에 어떤 원자가 기여하는지 해석까지 가능합니다. 이 편은 SARS-CoV-2 main protease(Mpro)를 표적으로 하는 약물 재활용(drug repurposing) 시나리오를 예시로 완결 파이프라인을 구축합니다.

📚 선수 편 권고 (강력 권고)

이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.

선수 편 없이 진입할 경우, 이 편에서 다루는 그래프 컨볼루션 · 메시지 패싱 · 어텐션 헤드 · 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.

도구 스택과 인프라 요구

도구역할라이선스
RDKitSMILES → 분자 그래프 · fingerprintBSD-3-Clause
PyTorch Geometric (PyG)그래프 신경망 프레임워크MIT
DGL (선택)PyG 대안Apache 2.0
GeNNius (GitHub)초경량 GNN baselineMIT (예상)
ChemBERTa (HuggingFace)pre-trained 분자 임베딩 (선택)Apache 2.0
MolFormer (IBM)대안 분자 foundationApache 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분 이내 학습, 추론은 초 단위.

파이프라인 실전 구현

전체 흐름:

mermaid

Step 1. SMILES → 분자 그래프

python
from dataclasses import dataclass
from typing import Any
import torch
from torch_geometric.data import Data
from 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 data

Step 2. 표적 단백질 인코딩 (ESM2 임베딩 or CNN baseline)

python
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 모델

python
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 out

Step 4. DataLoader · 학습 loop

python
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].

python
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, val

Step 6. SARS-CoV-2 Mpro 약물 재활용 스크리닝

python
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로 어떤 원자가 예측에 기여하는지 시각화.

python
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})")

통합 파이프라인

python
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 + ECFPDavis 회귀r = 0.55Legacy
DeepDTA (1D CNN)Davisr = 0.62Öztürk et al., Bioinformatics 2018 [1]
GraphDTA (GAT)Davisr = 0.68Nguyen et al., Bioinformatics 2020 [2]
MGraphDTADavis · KIBAr = 0.72Yang et al., Chem Sci 2022 [3]
GeNNiusBindingDBAUC 0.92ML4BM Lab 2024 [4]
DrugBANBioSNAPAUC 0.89Bai et al., Nat Mach Intell 2023
Cold-target SettingDavis cold-targetr = 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건 (커뮤니티·논문 수집형)

  1. 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].

  2. SMILES canonicalization 부재로 중복 학습
    증상: 같은 분자의 여러 SMILES 표기가 다른 샘플로 학습되어 데이터 유출.
    원인: RDKit canonicalize 안 하면 CCO vs C(C)O 같은 표기가 다르게 처리.
    회피: 학습 데이터 전처리 시 Chem.MolToSmiles(mol, canonical=True) 강제, 중복 제거, tautomer standardization (RDKit MolStandardize).
    출처: RDKit Discussions [7].

  3. 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].

  4. 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].

  5. 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 자율 스크리닝.

참고 문헌

  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