BioInteract / src /models /interaction.py
AI4deeperScience's picture
release: publish claim-safe Q9UQB9 custom prediction workflow
5b3b0dc verified
Raw
History Blame Contribute Delete
7.19 kB
"""Cross-attention module for model-native atom--residue attribution.
The module returns an attention matrix ``M`` over drug atoms and protein
residues. Its values are learned model weights that help inspect a prediction;
they are not molecular contacts, a binding pocket, or a structural mechanism.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class CrossAttentionInteraction(nn.Module):
"""
Multi-head cross-attention between drug atoms and protein residues.
Returns:
- Fused representation for prediction
- Attention weights for model-native atom--residue attribution
"""
def __init__(self,
hidden_dim: int = 256,
num_heads: int = 8,
dropout: float = 0.1):
super().__init__()
assert hidden_dim % num_heads == 0
self.hidden_dim = hidden_dim
self.num_heads = num_heads
self.head_dim = hidden_dim // num_heads
self.scale = math.sqrt(self.head_dim)
# drug atoms attend to protein residues
self.W_q_drug = nn.Linear(hidden_dim, hidden_dim)
self.W_k_prot = nn.Linear(hidden_dim, hidden_dim)
self.W_v_prot = nn.Linear(hidden_dim, hidden_dim)
# protein residues attend to drug atoms (bidirectional)
self.W_q_prot = nn.Linear(hidden_dim, hidden_dim)
self.W_k_drug = nn.Linear(hidden_dim, hidden_dim)
self.W_v_drug = nn.Linear(hidden_dim, hidden_dim)
# output projections
self.out_proj_drug = nn.Linear(hidden_dim, hidden_dim)
self.out_proj_prot = nn.Linear(hidden_dim, hidden_dim)
# layer norms
self.ln_drug = nn.LayerNorm(hidden_dim)
self.ln_prot = nn.LayerNorm(hidden_dim)
self.dropout = nn.Dropout(dropout)
def _attention(self, Q, K, V, mask=None):
"""
Standard scaled dot-product attention.
Returns:
output: attended values
attn_weights: softmax attention weights (for interpretability)
"""
# Q: (B, H, Lq, d), K: (B, H, Lk, d), V: (B, H, Lk, d)
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale # (B,H,Lq,Lk)
if mask is not None:
# mask shape: (B, 1, 1, Lk) β€” broadcast over heads and queries
scores = scores.masked_fill(~mask, float('-inf'))
attn_weights = F.softmax(scores, dim=-1)
attn_weights = self.dropout(attn_weights)
output = torch.matmul(attn_weights, V) # (B, H, Lq, d)
return output, attn_weights
def _reshape_to_heads(self, x, batch_size):
"""(B, L, D) β†’ (B, H, L, d)"""
return x.view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
def forward(self,
drug_repr: torch.Tensor,
drug_mask: torch.Tensor,
prot_repr: torch.Tensor,
prot_mask: torch.Tensor):
"""
Bidirectional cross-attention between drug atoms and protein residues.
Args:
drug_repr: (B, N_atoms, D) β€” per-atom drug representations
drug_mask: (B, N_atoms) β€” boolean mask for drug atoms
prot_repr: (B, L_residues, D) β€” per-residue protein representations
prot_mask: (B, L_residues) β€” boolean mask for protein residues
Returns:
drug_updated: (B, N_atoms, D) β€” drug repr enriched by protein context
prot_updated: (B, L_residues, D) β€” protein repr enriched by drug context
interaction_map: (B, N_atoms, L_residues) β€” attention-based interaction matrix
this is the key output for interpretability analysis
"""
B = drug_repr.size(0)
# --- Drug β†’ Protein attention ---
Q_d = self._reshape_to_heads(self.W_q_drug(drug_repr), B)
K_p = self._reshape_to_heads(self.W_k_prot(prot_repr), B)
V_p = self._reshape_to_heads(self.W_v_prot(prot_repr), B)
# mask: (B, L_residues) β†’ (B, 1, 1, L_residues)
prot_attn_mask = prot_mask.unsqueeze(1).unsqueeze(2) if prot_mask is not None else None
drug_attended, drug_to_prot_attn = self._attention(Q_d, K_p, V_p, prot_attn_mask)
# drug_to_prot_attn: (B, H, N_atoms, L_residues)
drug_attended = drug_attended.transpose(1, 2).contiguous().view(B, -1, self.hidden_dim)
drug_attended = self.out_proj_drug(drug_attended)
drug_updated = self.ln_drug(drug_repr + drug_attended)
# --- Protein β†’ Drug attention ---
Q_p = self._reshape_to_heads(self.W_q_prot(prot_repr), B)
K_d = self._reshape_to_heads(self.W_k_drug(drug_repr), B)
V_d = self._reshape_to_heads(self.W_v_drug(drug_repr), B)
drug_attn_mask = drug_mask.unsqueeze(1).unsqueeze(2) if drug_mask is not None else None
prot_attended, _ = self._attention(Q_p, K_d, V_d, drug_attn_mask)
prot_attended = prot_attended.transpose(1, 2).contiguous().view(B, -1, self.hidden_dim)
prot_attended = self.out_proj_prot(prot_attended)
prot_updated = self.ln_prot(prot_repr + prot_attended)
# --- Interaction map (averaged over heads) ---
# This is what we visualise and validate against binding sites
interaction_map = drug_to_prot_attn.mean(dim=1) # (B, N_atoms, L_residues)
return drug_updated, prot_updated, interaction_map
class GatedPooling(nn.Module):
"""
Gated pooling for aggregating atom/residue-level features into
a fixed-size vector for prediction.
Instead of mean/max pooling, we learn which atoms and residues
are most important for the final prediction. The gate weights
are themselves interpretable signals.
"""
def __init__(self, hidden_dim: int):
super().__init__()
self.gate = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, 1),
)
self.transform = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
)
def forward(self, x: torch.Tensor, mask: torch.Tensor = None) -> torch.Tensor:
"""
Args:
x: (B, L, D) β€” sequence of feature vectors
mask: (B, L) β€” boolean mask
Returns:
pooled: (B, D) β€” single feature vector per sample
"""
gate_scores = self.gate(x).squeeze(-1) # (B, L)
if mask is not None:
gate_scores = gate_scores.masked_fill(~mask, float('-inf'))
gate_weights = F.softmax(gate_scores, dim=-1) # (B, L)
transformed = self.transform(x) # (B, L, D)
pooled = torch.bmm(gate_weights.unsqueeze(1), transformed).squeeze(1) # (B, D)
return pooled