Home▸Articles▸Deep Learning

Graph Neural Networks for Molecular Data

Apply message-passing neural networks to molecular property prediction. Covers MPNN, AttentiveFP, SchNet, PyTorch Geometric implementation, node and edge feature design, graph batching, and when GNNs outperform Morgan fingerprints.

mysimulator teamUpdated January 2026≈ 9 min read▶ Open the simulation

Molecules are inherently graph-structured objects: atoms are nodes, bonds are edges. Unlike tabular data where all samples share the same feature space, molecular graphs are variable-size, irregular structures where the spatial arrangement of atoms determines chemical and biological properties. Graph Neural Networks (GNNs) exploit this natural graph structure, learning molecular representations end-to-end from the atomic and bond-level features, without the information loss introduced by flattening a molecule into a fixed-length fingerprint vector.

This guide covers the theoretical foundation of GNNs for molecular data, key architectures, node and edge feature engineering, practical implementation with PyTorch Geometric, graph batching mechanics, and a careful analysis of when GNNs outperform fingerprint-based models.

Why Molecules Are Naturally Graphs

A molecule is a weighted undirected graph G = (V, E) where:

This representation is complete: it captures all chemical information present in the connectivity (2D topology). For 3D-sensitive properties (binding pose, conformational effects), the representation can be extended with 3D coordinates as additional node or edge features.

The key advantage over fingerprints is that GNNs learn to compute representations of atom neighborhoods in a task-specific way. Where Morgan fingerprints use fixed-radius circular summaries with no awareness of what property matters, a GNN learns which structural patterns to aggregate based on the training signal from the property label.

Message Passing Neural Networks (MPNN) Intuition

The MPNN framework (Gilmer et al. 2017) unifies most GNN architectures for molecular property prediction into a common formalism with three components:

  1. Message: Each atom v sends a message to its neighbor u, computed from v's current representation and the edge feature e_uv: m_v = M(h_v, e_uv)
  2. Aggregation: Each atom u collects all incoming messages and aggregates them: a_u = SUM over neighbors v of m_v (or mean, max)
  3. Update: Each atom updates its representation using the aggregated message: h_u' = U(h_u, a_u) (often a GRU or MLP)

This is repeated for T message-passing steps (typically 3-6). After T steps, each atom's representation encodes information about its T-hop neighborhood. A final readout function (sum, mean, or attention-weighted sum over all atom representations) produces the molecular representation used for property prediction.

Conceptually, MPNN at T steps is analogous to Morgan fingerprints at radius T, except that the aggregation and encoding functions are learned rather than fixed by algorithm.

Node and Edge Features in Molecular GNNs

Atom (Node) Features

Feature Type Dimension Notes
Atomic number One-hot (C,N,O,S,F,Cl,Br,I,P,...) ~44 Element identity, most important feature
Degree One-hot (0-10) 11 Number of bonded neighbors
Hybridization One-hot (SP, SP2, SP3, SP3D, SP3D2) 6 Orbital hybridization
Aromaticity Binary 1 Whether atom is in aromatic ring
Formal charge Integer or one-hot 1 or 5 Ionic state
Num. Hs Integer or one-hot (0-4) 1 or 5 Implicit hydrogen count
In ring Binary 1 Ring membership
Chirality One-hot (CW, CCW, unspecified) 4 Stereo center information

Bond (Edge) Features

Feature Type Dimension
Bond type One-hot (single, double, triple, aromatic) 4
Conjugation Binary 1
In ring Binary 1
Stereo One-hot (Z, E, any, none) 4

A typical atom feature vector has 70-100 dimensions. In 3D GNNs (SchNet, DimeNet), edge features are replaced by interatomic distances and angles, requiring conformer geometry as input.

Key GNN Architectures

Architecture Key Idea Use Case
GCN (Kipf and Welling 2017) Spectral graph convolution, no edge features Baseline; works but ignores bond type
MPNN (Gilmer et al. 2017) General message-passing with edge features, GRU update QM9 quantum chemistry properties
AttentiveFP (Xiong et al. 2020) Graph attention for atom and molecule level; handles multi-graph pooling Drug-like property prediction, ADMET benchmarks
SchNet (Schutt et al. 2017) 3D continuous-filter convolutions using interatomic distances Quantum chemistry, requires 3D conformers
DimeNet (Gasteiger et al. 2020) Directional message passing using bond angles in 3D High-accuracy quantum property prediction
Uni-Mol (Zhou et al. 2022) Pre-trained 3D transformer on large molecular corpus General-purpose fine-tunable foundation model

Libraries: PyG and DGL

Two main Python libraries implement GNNs for graphs including molecules:

Code Example: Molecular GNN with PyTorch Geometric

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.data import Data, DataLoader
from torch_geometric.nn import MessagePassing, global_mean_pool, global_add_pool
from torch_geometric.utils import add_self_loops
from rdkit import Chem
from rdkit.Chem import AllChem
import numpy as np
from typing import List, Optional

# -------------------------------------------------------
# 1. Molecule to PyG Data object
# -------------------------------------------------------
ATOM_TYPES = ['C', 'N', 'O', 'S', 'F', 'Cl', 'Br', 'I', 'P', 'Si', 'Other']
HYBRIDIZATION = [Chem.rdchem.HybridizationType.SP,
                 Chem.rdchem.HybridizationType.SP2,
                 Chem.rdchem.HybridizationType.SP3,
                 Chem.rdchem.HybridizationType.SP3D,
                 Chem.rdchem.HybridizationType.SP3D2]
BOND_TYPES = [Chem.rdchem.BondType.SINGLE,
              Chem.rdchem.BondType.DOUBLE,
              Chem.rdchem.BondType.TRIPLE,
              Chem.rdchem.BondType.AROMATIC]

def one_hot(value, choices):
    return [int(value == c) for c in choices] + [int(value not in choices)]

def atom_features(atom) -> List[int]:
    """Compute 72-dimensional atom feature vector."""
    symbol = atom.GetSymbol()
    features = (
        one_hot(symbol, ATOM_TYPES) +                           # 12
        one_hot(atom.GetDegree(), list(range(11))) +            # 12
        one_hot(atom.GetHybridization(), HYBRIDIZATION) +       # 6
        [int(atom.GetIsAromatic())] +                           # 1
        one_hot(atom.GetFormalCharge(), [-2,-1,0,1,2]) +        # 6
        one_hot(atom.GetTotalNumHs(), [0,1,2,3,4]) +            # 6
        [int(atom.IsInRing())] +                                # 1
        one_hot(atom.GetChiralTag(), [
            Chem.rdchem.ChiralType.CHI_UNSPECIFIED,
            Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CW,
            Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CCW,
            Chem.rdchem.ChiralType.CHI_OTHER,
        ])                                                       # 5
    )
    return features  # total: 12+12+6+1+6+6+1+5 = 49 dims

def bond_features(bond) -> List[int]:
    """Compute 6-dimensional bond feature vector."""
    return (
        one_hot(bond.GetBondType(), BOND_TYPES) +    # 5
        [int(bond.GetIsConjugated())] +              # 1
        [int(bond.IsInRing())]                       # 1
    )  # total: 7 dims

def mol_to_graph(smiles: str, label: Optional[float] = None) -> Optional[Data]:
    """Convert SMILES to a PyG Data object."""
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None

    # Node features
    x = torch.tensor([atom_features(a) for a in mol.GetAtoms()], dtype=torch.float)

    # Edge index and edge features
    rows, cols, edge_attrs = [], [], []
    for bond in mol.GetBonds():
        i = bond.GetBeginAtomIdx()
        j = bond.GetEndAtomIdx()
        bf = bond_features(bond)
        rows += [i, j]
        cols += [j, i]
        edge_attrs += [bf, bf]  # Undirected: add both directions

    edge_index = torch.tensor([rows, cols], dtype=torch.long)
    edge_attr  = torch.tensor(edge_attrs, dtype=torch.float)

    y = torch.tensor([label], dtype=torch.float) if label is not None else None
    return Data(x=x, edge_index=edge_index, edge_attr=edge_attr, y=y)

# -------------------------------------------------------
# 2. MPNN Layer
# -------------------------------------------------------
class MPNNConv(MessagePassing):
    """
    One MPNN message-passing step with edge feature integration.
    Uses 'add' aggregation and an MLP for message computation.
    """
    def __init__(self, node_dim: int, edge_dim: int, out_dim: int):
        super().__init__(aggr='add')
        # Message network: maps (node_src, edge) -> message
        self.msg_mlp = nn.Sequential(
            nn.Linear(node_dim + edge_dim, out_dim),
            nn.ReLU(),
            nn.Linear(out_dim, out_dim),
        )
        # Update network: maps (node + aggregated message) -> new node
        self.update_gru = nn.GRUCell(out_dim, node_dim)

    def forward(self, x, edge_index, edge_attr):
        return self.propagate(edge_index, x=x, edge_attr=edge_attr)

    def message(self, x_j, edge_attr):
        # x_j: source node features, edge_attr: bond features
        return self.msg_mlp(torch.cat([x_j, edge_attr], dim=-1))

    def update(self, aggr_out, x):
        # GRU update: combine current node state with aggregated messages
        return self.update_gru(aggr_out, x)

# -------------------------------------------------------
# 3. Full MPNN Molecular Model
# -------------------------------------------------------
class MolMPNN(nn.Module):
    def __init__(self, node_in_dim: int = 49, edge_in_dim: int = 7,
                 hidden_dim: int = 256, n_layers: int = 4,
                 n_tasks: int = 1, dropout: float = 0.1):
        super().__init__()

        # Input projection
        self.node_proj = nn.Linear(node_in_dim, hidden_dim)
        self.edge_proj = nn.Linear(edge_in_dim, hidden_dim)

        # Message passing layers
        self.mp_layers = nn.ModuleList([
            MPNNConv(hidden_dim, hidden_dim, hidden_dim)
            for _ in range(n_layers)
        ])

        # Readout: sum over all atoms
        self.dropout = nn.Dropout(dropout)

        # Output MLP
        self.mlp = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_dim // 2, n_tasks),
        )

    def forward(self, data):
        x, edge_index, edge_attr, batch = (
            data.x, data.edge_index, data.edge_attr, data.batch
        )

        # Initial projections
        x = F.relu(self.node_proj(x))
        edge_attr = F.relu(self.edge_proj(edge_attr))

        # Message passing
        for mp_layer in self.mp_layers:
            x = x + self.dropout(mp_layer(x, edge_index, edge_attr))  # Residual

        # Global pooling: sum all atom representations
        mol_repr = global_add_pool(x, batch)  # (batch_size, hidden_dim)

        return self.mlp(mol_repr)  # (batch_size, n_tasks)

# -------------------------------------------------------
# 4. Training on ESOL (aqueous solubility) dataset
# -------------------------------------------------------
from torch_geometric.datasets import MoleculeNet

# Load ESOL dataset (1128 molecules, regression task)
dataset = MoleculeNet(root='/tmp/esol', name='ESOL')

# Convert to train/val/test split
n = len(dataset)
train_data = dataset[:int(0.8 * n)]
val_data   = dataset[int(0.8 * n):int(0.9 * n)]
test_data  = dataset[int(0.9 * n):]

train_loader = DataLoader(train_data, batch_size=32, shuffle=True)
val_loader   = DataLoader(val_data, batch_size=32)
test_loader  = DataLoader(test_data, batch_size=32)

# Infer node/edge dims from dataset
sample = dataset[0]
node_dim = sample.x.shape[1]   # 9 in MoleculeNet ESOL
edge_dim = sample.edge_attr.shape[1] if sample.edge_attr is not None else 0

model = MolMPNN(
    node_in_dim=node_dim,
    edge_in_dim=max(edge_dim, 1),
    hidden_dim=128,
    n_layers=4,
    n_tasks=1,
    dropout=0.1
)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.MSELoss()

def train_epoch(loader):
    model.train()
    total_loss = 0.0
    for data in loader:
        out = model(data).squeeze(-1)
        y   = data.y.squeeze(-1)
        loss = criterion(out, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * data.num_graphs
    return total_loss / len(loader.dataset)

def evaluate(loader):
    model.eval()
    preds, targets = [], []
    with torch.no_grad():
        for data in loader:
            preds.append(model(data).squeeze(-1))
            targets.append(data.y.squeeze(-1))
    preds   = torch.cat(preds)
    targets = torch.cat(targets)
    rmse = ((preds - targets) ** 2).mean().sqrt().item()
    return rmse

for epoch in range(1, 101):
    train_loss = train_epoch(train_loader)
    val_rmse   = evaluate(val_loader)
    if epoch % 10 == 0:
        print(f"Epoch {epoch:3d}: train_loss={train_loss:.4f}, val_RMSE={val_rmse:.4f}")

test_rmse = evaluate(test_loader)
print(f"Test RMSE: {test_rmse:.4f}")

When GNNs Outperform Fingerprints

The question of when to use a GNN versus ECFP4 + gradient boosting is practical and depends on several factors:

Scenario GNN Advantage Fingerprint Advantage
Dataset size: above 50K GNN learns richer representations with more data XGBoost still competitive but GNN tends to edge ahead
Dataset size: below 10K Little advantage; may overfit RF/XGBoost + ECFP4 usually wins or ties
Scaffold-diverse evaluation GNN generalizes better across scaffolds Fingerprints interpolate within scaffold series well
Quantum properties (dipole, HOMO-LUMO) Significant advantage (especially 3D GNNs) Fingerprints cannot capture quantum effects
Interpretability Atom-level attention maps (AttentiveFP) SHAP values on bits directly indicate structural alerts
Transfer learning Pre-trained GNNs (Uni-Mol, GIN) boost small datasets No natural pre-training paradigm for fingerprints

The practical recommendation: always establish a fingerprint baseline first. If it achieves adequate performance, stop there. Invest GNN development effort only when the baseline shows a clear ceiling (scaffold split evaluation significantly worse than random split) or when the property has a known dependence on 3D structure.

Practical Tips: Batching and Variable Graph Sizes

Unlike tabular data where all samples have the same feature dimension, molecular graphs have variable numbers of atoms and bonds. PyG handles this through a super-graph batching mechanism: multiple molecular graphs are merged into a single disconnected graph, with a batch index tensor mapping each node to its originating molecule. This allows vectorized GPU computation across the batch without padding.

Further Reading

Try it live

Explore a related model hands-on: open Graph Neural Networks: Learning on Relational Data and change its parameters while it runs. Everything stays in your browser.

▶ Open Graph Neural Networks: Learning on Relational Data simulation

What did you find?

Add reproduction steps (optional)