Graph Neural Networks for Drug Discovery


Deep Learning for Healthcare — Part 5: Message Passing on Molecular Graphs — August 2026

Graph Neural Networks and Message Passing

"Molecules are not sequences or grids — they are graphs. To understand chemistry, we must learn on graphs."

August 2026 · Giacomo Saccaggi


Why Graphs in Healthcare?

Much of healthcare data has an inherent graph structure that traditional neural networks — designed for sequences (RNNs) or grids (CNNs) — cannot naturally capture. Consider:

Graphs in healthcare

Healthcare data naturally forms graphs: molecules, drug interactions, protein networks, and knowledge graphs

Graph Learning Tasks

What can we do with graph-structured data? The tasks fall into several categories:

Task TypeDescriptionHealthcare Example
Node ClassificationAssign labels to individual nodesIs this protein a valid drug target? Is this patient high-risk?
Graph ClassificationAssign a label to the entire graphIs this molecule toxic? Will this drug be effective?
Link PredictionPredict missing or future edgesWill these two drugs interact adversely?
Graph GenerationGenerate new graphs with desired propertiesDesign novel drug molecules with specific binding affinity
Node ClusteringGroup similar nodes togetherIdentify patient subgroups with similar disease profiles

In drug discovery, the most common task is graph-level property prediction: given a molecular graph, predict whether it will be toxic, soluble, or bind to a specific target protein.


The Node Embedding Problem

The fundamental goal of graph neural networks is to learn node embeddings — low-dimensional vector representations of nodes that capture both their features and their structural position in the graph.

Node Embedding Goal

Map each node v to a d-dimensional vector zv ∈ ℝd such that:

similarity(u, v) in network ≈ similarity(zu, zv) in embedding space

Nodes that are "similar" in the graph should have similar embeddings.

But what defines "similarity" in a graph? This is where the design choices come in:

Node embedding concept

Node embeddings map graph nodes to a vector space where similar nodes are close together

Why Not Just Use Adjacency Matrix?

A naive approach would be to use the adjacency matrix row as a node's feature vector. But this has critical problems:

We need a function that generates embeddings based on local graph structure — this is exactly what GNNs provide.


Challenges of Learning on Graphs

Graphs are fundamentally different from the data structures deep learning was originally designed for. Here's why they're challenging:

ChallengeSequences/ImagesGraphs
SizeFixed (e.g., 224×224 image)Arbitrary number of nodes and edges
OrderingNatural order (left→right, top→bottom)No canonical ordering of nodes
LocalityLocal receptive fields (convolution)No grid structure for local operations
DynamicsStatic once capturedCan grow/shrink over time
FeaturesHomogeneous (pixels)Heterogeneous node and edge types

⚠️ The Permutation Invariance Problem

If we relabel all nodes in a graph (swap node 1 with node 3, etc.), the graph is unchanged but the adjacency matrix looks completely different. Any valid graph neural network must be permutation invariant — produce the same output regardless of how nodes are numbered.

These challenges rule out directly applying CNNs or RNNs. We need architectures specifically designed for graph-structured data.


Key Ideas of Graph Neural Networks

The breakthrough insight of GNNs is elegantly simple: a node's representation should be determined by its neighborhood. Instead of learning independent embeddings, we learn how to aggregate information from neighbors.

Idea 1: Neighborhood Aggregation

Every node collects ("aggregates") information from its neighbors. If I know what my friends know, I become more informed. This is the message passing paradigm.

Neighborhood aggregation

Each node aggregates information from its neighbors to update its representation

Aggregation Principle

For node v with neighbors N(v), the new representation is:

hv(new) = f(hv, AGGREGATE({hu : u ∈ N(v)}))

Where AGGREGATE is a permutation-invariant function (sum, mean, max, etc.)

The aggregation function must be permutation-invariant — the order in which we process neighbors shouldn't matter. Common choices:

Idea 2: Multiple Layers = Larger Receptive Fields

A single aggregation step only captures immediate neighbors (1-hop). By stacking multiple layers, each node's representation incorporates information from progressively larger neighborhoods.

Multi-layer GNN

Stacking GNN layers: Layer 1 captures 1-hop neighbors, Layer 2 captures 2-hop, etc.

Layer-wise Propagation

Layer 0: hv(0) = xv (initial node features)

Layer k: hv(k) = σ(W(k) · AGGREGATE({hu(k-1) : u ∈ N(v) ∪ {v}}))

After K layers, hv(K) captures the K-hop neighborhood of node v.

This is powerful: with just 3-4 layers, most nodes in a molecular graph "see" the entire molecule. But beware of over-smoothing — with too many layers, all node embeddings converge to the same value!

💡 Receptive Field Growth

In a molecule with average degree ~3:

For drug-like molecules (20-50 atoms), 3-4 layers typically suffice.


Graph Convolutional Networks (GCN)

The Graph Convolutional Network (Kipf & Welling, 2017) is the foundational GNN architecture. It uses a simple aggregation rule inspired by spectral graph theory.

GCN Layer Definition

GCN Update Rule

For node v at layer l:

hv(l) = σ(W(l) · Σu∈N(v)∪{v} (1/√(d̃u·d̃v)) · hu(l-1))

Where:

The normalization by √(d̃u·d̃v) prevents nodes with many neighbors from having exploding activations.

Matrix Formulation

The beauty of GCN is that it can be expressed as efficient matrix operations:

GCN in Matrix Form

H(l) = σ(Â · H(l-1) · W(l))

Where:

import torch
import torch.nn as nn
import torch.nn.functional as F

class GCNLayer(nn.Module):
    """Single Graph Convolutional Layer."""
    
    def __init__(self, in_features, out_features):
        super().__init__()
        self.linear = nn.Linear(in_features, out_features, bias=False)
    
    def forward(self, x, adj_normalized):
        """
        Args:
            x: Node features [N, in_features]
            adj_normalized: Normalized adjacency matrix  [N, N]
        Returns:
            Updated node features [N, out_features]
        """
        # Aggregate neighbor features: Â · X
        support = torch.mm(adj_normalized, x)
        # Transform: (Â · X) · W
        output = self.linear(support)
        return output


class GCN(nn.Module):
    """Two-layer GCN for node classification."""
    
    def __init__(self, n_features, n_hidden, n_classes, dropout=0.5):
        super().__init__()
        self.gcn1 = GCNLayer(n_features, n_hidden)
        self.gcn2 = GCNLayer(n_hidden, n_classes)
        self.dropout = dropout
    
    def forward(self, x, adj_normalized):
        # Layer 1: features → hidden
        h = self.gcn1(x, adj_normalized)
        h = F.relu(h)
        h = F.dropout(h, self.dropout, training=self.training)
        
        # Layer 2: hidden → output
        h = self.gcn2(h, adj_normalized)
        return h  # Logits for each node

GCN Limitations

GCN is elegant but limited:

Scalable GCN: Sampling Approaches

For large graphs, full-batch GCN is infeasible. Two popular solutions:

MethodIdeaTrade-off
GraphSAGESample a fixed number of neighbors per node at each layerVariance from sampling, but constant memory per batch
FastGCNSample nodes at each layer independently using importance samplingMore efficient but can miss important connections
# GraphSAGE-style sampling: sample k neighbors per node
def sample_neighbors(node_ids, adj_list, num_samples):
    """Sample fixed number of neighbors for each node."""
    sampled = []
    for node in node_ids:
        neighbors = adj_list[node]
        if len(neighbors) >= num_samples:
            sampled_neighbors = np.random.choice(neighbors, num_samples, replace=False)
        else:
            # Sample with replacement if not enough neighbors
            sampled_neighbors = np.random.choice(neighbors, num_samples, replace=True)
        sampled.append(sampled_neighbors)
    return np.array(sampled)

Message Passing Neural Networks (MPNN)

The Message Passing Neural Network (Gilmer et al., ICML 2017) is a general framework that unifies and extends previous GNN architectures. Crucially for drug discovery, MPNN handles both node features and edge features.

Molecule as graph

A molecule represented as a graph: atoms are nodes with features (element type, charge, hybridization), bonds are edges with features (single/double/triple, conjugated, in ring)

The MPNN Framework

MPNN operates in two phases:

  1. Message Passing Phase: Nodes exchange information with neighbors for T time steps
  2. Readout Phase: Aggregate all node embeddings into a single graph-level embedding

Message Passing Phase

At each time step t:

1. Compute messages:

mvt+1 = Σw∈N(v) Mt(hvt, hwt, evw)

2. Update node states:

hvt+1 = Ut(hvt, mvt+1)

Where:

The key innovation is the message function Mt that incorporates edge features. In molecules, this lets the model learn that information flows differently across single vs. double bonds.

Readout Phase

After T message passing steps, aggregate node embeddings into a graph embedding:

ĥG = R({hvT | v ∈ G})

The readout function R must be permutation-invariant. Options:

MPNN readout

Readout phase: all node embeddings are aggregated into a single graph-level representation

Edge-Conditioned Message Function

A common choice for Mt is the edge-conditioned convolution:

Edge Network Message Function

Mt(hv, hw, evw) = A(evw) · hw

Where A(evw) is a neural network that outputs a d×d matrix based on edge features.

This means: the edge features determine how the neighbor's information is transformed before being sent.

For efficiency, A is typically implemented as:

class EdgeNetwork(nn.Module):
    """Network that produces transformation matrix from edge features."""
    
    def __init__(self, edge_dim, hidden_dim):
        super().__init__()
        # Edge features → transformation matrix (flattened)
        self.net = nn.Sequential(
            nn.Linear(edge_dim, hidden_dim * 2),
            nn.ReLU(),
            nn.Linear(hidden_dim * 2, hidden_dim * hidden_dim)
        )
        self.hidden_dim = hidden_dim
    
    def forward(self, edge_features):
        """
        Args:
            edge_features: [num_edges, edge_dim]
        Returns:
            Transformation matrices: [num_edges, hidden_dim, hidden_dim]
        """
        flat = self.net(edge_features)  # [num_edges, hidden_dim²]
        return flat.view(-1, self.hidden_dim, self.hidden_dim)

GRU Update Function

The update function combines the node's current state with incoming messages. Using a GRU allows the model to selectively retain or forget information:

class MPNNUpdate(nn.Module):
    """GRU-based update function for MPNN."""
    
    def __init__(self, hidden_dim):
        super().__init__()
        self.gru = nn.GRUCell(hidden_dim, hidden_dim)
    
    def forward(self, h, m):
        """
        Args:
            h: Current node states [num_nodes, hidden_dim]
            m: Aggregated messages [num_nodes, hidden_dim]
        Returns:
            Updated node states [num_nodes, hidden_dim]
        """
        return self.gru(m, h)

Complete MPNN Implementation

Let's build a complete MPNN for molecular property prediction in PyTorch.

Molecular Graph Representation

First, we need to represent molecules as graphs with appropriate features:

import torch
import numpy as np
from rdkit import Chem

def atom_features(atom):
    """Extract features for a single atom."""
    return np.array([
        # Atom type (one-hot): C, N, O, S, F, Cl, Br, I, P, other
        atom.GetAtomicNum() == 6,   # Carbon
        atom.GetAtomicNum() == 7,   # Nitrogen
        atom.GetAtomicNum() == 8,   # Oxygen
        atom.GetAtomicNum() == 16,  # Sulfur
        atom.GetAtomicNum() == 9,   # Fluorine
        atom.GetAtomicNum() == 17,  # Chlorine
        atom.GetAtomicNum() == 35,  # Bromine
        atom.GetAtomicNum() == 53,  # Iodine
        atom.GetAtomicNum() == 15,  # Phosphorus
        # Degree (one-hot): 0, 1, 2, 3, 4, 5+
        atom.GetDegree() == 0,
        atom.GetDegree() == 1,
        atom.GetDegree() == 2,
        atom.GetDegree() == 3,
        atom.GetDegree() == 4,
        atom.GetDegree() >= 5,
        # Formal charge
        atom.GetFormalCharge(),
        # Num hydrogens
        atom.GetTotalNumHs(),
        # Aromaticity
        atom.GetIsAromatic(),
        # In ring
        atom.IsInRing(),
    ], dtype=np.float32)


def bond_features(bond):
    """Extract features for a single bond."""
    bt = bond.GetBondType()
    return np.array([
        # Bond type (one-hot)
        bt == Chem.rdchem.BondType.SINGLE,
        bt == Chem.rdchem.BondType.DOUBLE,
        bt == Chem.rdchem.BondType.TRIPLE,
        bt == Chem.rdchem.BondType.AROMATIC,
        # Conjugation
        bond.GetIsConjugated(),
        # In ring
        bond.IsInRing(),
    ], dtype=np.float32)


def mol_to_graph(smiles):
    """Convert SMILES string to graph representation."""
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    
    # Node features
    atom_feats = np.array([atom_features(atom) for atom in mol.GetAtoms()])
    
    # Edge indices and features
    edge_indices = []
    edge_feats = []
    for bond in mol.GetBonds():
        i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
        bf = bond_features(bond)
        # Add both directions (undirected graph)
        edge_indices.append([i, j])
        edge_indices.append([j, i])
        edge_feats.append(bf)
        edge_feats.append(bf)
    
    edge_index = np.array(edge_indices).T  # [2, num_edges]
    edge_attr = np.array(edge_feats)        # [num_edges, edge_dim]
    
    return {
        'node_features': atom_feats,
        'edge_index': edge_index,
        'edge_features': edge_attr,
        'num_nodes': len(atom_feats)
    }

The MPNN Model

import torch
import torch.nn as nn

class MPNN(nn.Module):
    """Message Passing Neural Network for molecular property prediction."""
    
    def __init__(self, node_dim, edge_dim, hidden_dim, output_dim, 
                 num_message_passing=3, dropout=0.1):
        super().__init__()
        
        self.hidden_dim = hidden_dim
        self.num_message_passing = num_message_passing
        
        # Initial node embedding
        self.node_encoder = nn.Linear(node_dim, hidden_dim)
        
        # Edge network: edge features → transformation matrix
        self.edge_network = nn.Sequential(
            nn.Linear(edge_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim * hidden_dim)
        )
        
        # GRU for node state updates
        self.gru = nn.GRUCell(hidden_dim, hidden_dim)
        
        # Readout network
        self.readout = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim, output_dim)
        )
    
    def forward(self, node_features, edge_index, edge_features, batch):
        """
        Args:
            node_features: [total_nodes, node_dim] - all nodes in batch
            edge_index: [2, total_edges] - edge connectivity
            edge_features: [total_edges, edge_dim] - edge attributes
            batch: [total_nodes] - graph membership for each node
        Returns:
            Graph-level predictions: [batch_size, output_dim]
        """
        # Initial node embeddings
        h = self.node_encoder(node_features)  # [N, hidden_dim]
        
        # Compute edge transformations once
        edge_transform = self.edge_network(edge_features)  # [E, hidden_dim²]
        edge_transform = edge_transform.view(-1, self.hidden_dim, self.hidden_dim)
        
        # Message passing iterations
        for _ in range(self.num_message_passing):
            h = self.message_passing_step(h, edge_index, edge_transform)
        
        # Readout: aggregate node embeddings per graph
        graph_embed = self.global_mean_pool(h, batch)  # [batch_size, hidden_dim]
        
        # Final prediction
        out = self.readout(graph_embed)
        return out
    
    def message_passing_step(self, h, edge_index, edge_transform):
        """Single message passing step."""
        source, target = edge_index  # [E], [E]
        
        # Get source node embeddings
        h_source = h[source]  # [E, hidden_dim]
        
        # Apply edge-conditioned transformation: A(e) @ h_source
        # edge_transform: [E, hidden_dim, hidden_dim]
        # h_source: [E, hidden_dim] → [E, hidden_dim, 1]
        messages = torch.bmm(edge_transform, h_source.unsqueeze(-1)).squeeze(-1)
        
        # Aggregate messages per target node
        num_nodes = h.size(0)
        aggregated = torch.zeros(num_nodes, self.hidden_dim, device=h.device)
        aggregated.scatter_add_(0, target.unsqueeze(-1).expand_as(messages), messages)
        
        # Update node states with GRU
        h_new = self.gru(aggregated, h)
        return h_new
    
    def global_mean_pool(self, h, batch):
        """Average pooling over nodes in each graph."""
        batch_size = batch.max().item() + 1
        
        # Sum node embeddings per graph
        graph_sum = torch.zeros(batch_size, h.size(1), device=h.device)
        graph_sum.scatter_add_(0, batch.unsqueeze(-1).expand_as(h), h)
        
        # Count nodes per graph
        ones = torch.ones(h.size(0), device=h.device)
        count = torch.zeros(batch_size, device=h.device)
        count.scatter_add_(0, batch, ones)
        
        # Mean
        graph_embed = graph_sum / count.unsqueeze(-1).clamp(min=1)
        return graph_embed

Training Loop

def train_mpnn(model, train_loader, val_loader, epochs=100, lr=0.001):
    """Train MPNN for molecular property prediction."""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = model.to(device)
    
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    criterion = nn.MSELoss()  # For regression; use BCEWithLogitsLoss for classification
    
    best_val_loss = float('inf')
    
    for epoch in range(epochs):
        # Training
        model.train()
        train_loss = 0
        for batch in train_loader:
            batch = batch.to(device)
            
            optimizer.zero_grad()
            pred = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch)
            loss = criterion(pred.squeeze(), batch.y)
            loss.backward()
            optimizer.step()
            
            train_loss += loss.item() * batch.num_graphs
        
        train_loss /= len(train_loader.dataset)
        
        # Validation
        model.eval()
        val_loss = 0
        with torch.no_grad():
            for batch in val_loader:
                batch = batch.to(device)
                pred = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch)
                loss = criterion(pred.squeeze(), batch.y)
                val_loss += loss.item() * batch.num_graphs
        
        val_loss /= len(val_loader.dataset)
        
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(model.state_dict(), 'best_mpnn.pt')
        
        if (epoch + 1) % 10 == 0:
            print(f'Epoch {epoch+1:3d} | Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f}')
    
    return best_val_loss

💡 MPNN vs GCN: When to Use What

Use GCN when:

Use MPNN when:


Graph Attention Networks (GAT)

Standard GCN treats all neighbors equally (after degree normalization). But in many applications, some neighbors are more important than others. Graph Attention Networks (Veličković et al., ICLR 2018) learn to weight neighbors differently using attention.

Attention Mechanism

For each edge (i, j), compute an attention coefficient:

GAT Attention

Step 1: Compute raw attention scores:

eij = LeakyReLU(aT · [W·hi || W·hj])

Step 2: Normalize using softmax over neighbors:

αij = softmaxj(eij) = exp(eij) / Σk∈N(i) exp(eik)

Step 3: Weighted aggregation:

hi' = σ(Σj∈N(i) αij · W · hj)

Where:

Graph attention

GAT computes attention weights for each neighbor, allowing the model to focus on the most relevant connections

Multi-head Attention

Like Transformers, GAT benefits from multiple attention heads that learn different aspects of neighbor importance:

class GATLayer(nn.Module):
    """Graph Attention Layer with multi-head attention."""
    
    def __init__(self, in_dim, out_dim, num_heads=4, dropout=0.1, concat=True):
        super().__init__()
        self.num_heads = num_heads
        self.out_dim = out_dim
        self.concat = concat
        
        # Separate weight matrices per head
        self.W = nn.Linear(in_dim, out_dim * num_heads, bias=False)
        
        # Attention mechanism per head
        self.attention = nn.Parameter(torch.zeros(num_heads, 2 * out_dim))
        nn.init.xavier_uniform_(self.attention)
        
        self.leaky_relu = nn.LeakyReLU(0.2)
        self.dropout = nn.Dropout(dropout)
    
    def forward(self, h, edge_index):
        """
        Args:
            h: Node features [N, in_dim]
            edge_index: [2, E]
        Returns:
            Updated features [N, out_dim * num_heads] if concat else [N, out_dim]
        """
        N = h.size(0)
        
        # Linear transformation: [N, out_dim * num_heads]
        Wh = self.W(h).view(N, self.num_heads, self.out_dim)  # [N, H, D]
        
        source, target = edge_index
        
        # Get source and target node embeddings for each edge
        Wh_source = Wh[source]  # [E, H, D]
        Wh_target = Wh[target]  # [E, H, D]
        
        # Concatenate and compute attention scores
        edge_features = torch.cat([Wh_source, Wh_target], dim=-1)  # [E, H, 2D]
        e = (edge_features * self.attention).sum(dim=-1)  # [E, H]
        e = self.leaky_relu(e)
        
        # Softmax over incoming edges per node
        alpha = self.edge_softmax(e, target, N)  # [E, H]
        alpha = self.dropout(alpha)
        
        # Weighted aggregation
        weighted = Wh_source * alpha.unsqueeze(-1)  # [E, H, D]
        
        out = torch.zeros(N, self.num_heads, self.out_dim, device=h.device)
        out.scatter_add_(0, target.view(-1, 1, 1).expand_as(weighted), weighted)
        
        if self.concat:
            return out.view(N, -1)  # [N, H*D]
        else:
            return out.mean(dim=1)   # [N, D] - average over heads
    
    def edge_softmax(self, e, target, num_nodes):
        """Compute softmax over incoming edges for each node."""
        # Subtract max for numerical stability
        e_max = torch.zeros(num_nodes, e.size(1), device=e.device)
        e_max.scatter_reduce_(0, target.unsqueeze(-1).expand_as(e), e, 'amax')
        e = e - e_max[target]
        
        # Exp and sum
        exp_e = e.exp()
        sum_exp = torch.zeros(num_nodes, e.size(1), device=e.device)
        sum_exp.scatter_add_(0, target.unsqueeze(-1).expand_as(exp_e), exp_e)
        
        return exp_e / (sum_exp[target] + 1e-10)

When to Use GAT

ScenarioGCNGAT
All neighbors equally importantWorks but overkill
Some neighbors more relevantCan't learn this✓ Learns attention weights
Need interpretabilityLimited✓ Attention weights reveal importance
Computational budgetFasterMore parameters, slower

MPNN for Quantum Chemistry

The original MPNN paper demonstrated state-of-the-art results on the QM9 dataset — predicting quantum mechanical properties of molecules.

QM9 Dataset

QM9 contains ~134,000 small organic molecules with up to 9 heavy atoms (C, N, O, F). For each molecule, 13 quantum chemical properties were computed using density functional theory (DFT):

PropertySymbolUnitDescription
Dipole momentμDebyeMeasure of charge separation
PolarizabilityαBohr³Response to electric field
HOMO energyεHOMOeVHighest occupied molecular orbital
LUMO energyεLUMOeVLowest unoccupied molecular orbital
GapΔεeVLUMO - HOMO energy
Zero-point energyZPVEeVVibrational ground state energy
Heat capacityCvcal/mol·KAt 298K
Atomization energyU0eVAt 0K

Results

MPNN achieved significant improvements over previous methods:

PropertyPrevious BestMPNNImprovement
μ (Dipole)0.0280.01643%
α (Polar.)0.250.09263%
εHOMO0.00360.002239%
Gap0.00470.003232%
ZPVE0.00060.0001772%
U00.450.1958%

The HOMO-LUMO gap is particularly important in drug discovery — it correlates with molecular stability and reactivity.


Practical Considerations

Batching Graphs

Unlike images where batching is straightforward (stack along a new dimension), graphs have variable sizes. The solution: create one large disconnected graph containing all molecules in the batch.

def collate_graphs(graphs):
    """Combine multiple graphs into a batch."""
    batch_node_features = []
    batch_edge_index = []
    batch_edge_features = []
    batch_labels = []
    batch_membership = []  # Which graph each node belongs to
    
    node_offset = 0
    for i, g in enumerate(graphs):
        num_nodes = g['node_features'].shape[0]
        
        batch_node_features.append(g['node_features'])
        batch_edge_features.append(g['edge_features'])
        
        # Offset edge indices
        edge_index = g['edge_index'] + node_offset
        batch_edge_index.append(edge_index)
        
        # Track which graph each node belongs to
        batch_membership.extend([i] * num_nodes)
        
        batch_labels.append(g['label'])
        node_offset += num_nodes
    
    return {
        'node_features': torch.cat([torch.tensor(x) for x in batch_node_features]),
        'edge_index': torch.cat([torch.tensor(x) for x in batch_edge_index], dim=1),
        'edge_features': torch.cat([torch.tensor(x) for x in batch_edge_features]),
        'batch': torch.tensor(batch_membership),
        'labels': torch.tensor(batch_labels)
    }

PyTorch Geometric

In practice, use PyTorch Geometric (PyG), which provides optimized implementations of common GNN operations:

from torch_geometric.nn import GCNConv, GATConv, NNConv, Set2Set
from torch_geometric.data import Data, DataLoader

class MPNN_PyG(nn.Module):
    """MPNN using PyTorch Geometric's NNConv (edge network convolution)."""
    
    def __init__(self, node_dim, edge_dim, hidden_dim, output_dim, num_layers=3):
        super().__init__()
        
        self.node_encoder = nn.Linear(node_dim, hidden_dim)
        
        # Edge network for NNConv
        edge_nn = nn.Sequential(
            nn.Linear(edge_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim * hidden_dim)
        )
        
        # Message passing layers
        self.convs = nn.ModuleList()
        self.grus = nn.ModuleList()
        for _ in range(num_layers):
            self.convs.append(NNConv(hidden_dim, hidden_dim, edge_nn, aggr='add'))
            self.grus.append(nn.GRUCell(hidden_dim, hidden_dim))
        
        # Set2Set readout for better aggregation
        self.readout = Set2Set(hidden_dim, processing_steps=3)
        
        # Final MLP
        self.mlp = nn.Sequential(
            nn.Linear(2 * hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim)
        )
    
    def forward(self, data):
        x = self.node_encoder(data.x)
        
        for conv, gru in zip(self.convs, self.grus):
            m = conv(x, data.edge_index, data.edge_attr)
            x = gru(m, x)
        
        # Graph-level readout
        graph_embed = self.readout(x, data.batch)
        
        return self.mlp(graph_embed)

Computational Complexity

⚠️ MPNN Limitations

For large-scale graphs (social networks, citation graphs), use GCN with sampling or more efficient architectures like GraphSAINT or ClusterGCN.


Summary

We've covered the theory and implementation of Graph Neural Networks for drug discovery:

ArchitectureKey FeatureBest For
GCNSimple neighborhood aggregationLarge graphs, node classification
GraphSAGESampling for scalabilityVery large graphs, inductive learning
MPNNEdge features via message functionsMolecules, small graphs with rich edge info
GATLearned attention weightsWhen neighbor importance varies

Key Takeaways

  1. Graphs are everywhere in healthcare: molecules, drug interactions, protein networks, knowledge graphs
  2. GNNs learn node embeddings by aggregating information from neighbors
  3. Multiple layers capture larger neighborhoods: k layers = k-hop receptive field
  4. Edge features matter for molecules: MPNN explicitly models bond types
  5. Readout functions aggregate nodes into graph-level representations for property prediction

📚 Key Papers


References