Graph Pooling Methods Compared: Mean, Sum, Max, DiffPool
Graph classification — predicting a label for an entire graph rather than individual nodes — requires collapsing a variable-size set of node representations into a single fixed-size vector. This operation is called graph pooling or graph readout. The choice of pooling method is often overlooked but can meaningfully affect model performance, training stability, and what information the model can capture. This article compares global pooling methods (mean, sum, max) and the hierarchical DiffPool concept, explains when each approach works better, and shows how to use TGraphX's pooling layers.
What This Builds On
This article assumes you understand GNN message-passing and the concept of graph-level vs node-level tasks. The GIN architecture article explains why sum aggregation is theoretically more expressive than mean or max — the same argument applies to readout pooling. The TGraphX vs PyTorch Geometric comparison provides context on pooling differences between frameworks.
Global Pooling: The Simple Readout
Global pooling collapses all node representations into a single graph-level representation in one step. No hierarchy, no learned assignment — just an aggregation function applied across all N nodes.
TGraphX provides three global pooling layers in tgraphx.layers.pooling:
import torch
from tgraphx.layers.pooling import GlobalMeanPool, GlobalSumPool, GlobalMaxPool
# Node features: [N, hidden_dim] for vector features
# or [N, C, H, W] for spatial features
x_nodes = torch.randn(50, 64) # 50 nodes, 64-dim vectors
# batch: which graph each node belongs to (for batched graph classification)
batch = torch.zeros(50, dtype=torch.long) # all nodes belong to graph 0
mean_pool = GlobalMeanPool()
sum_pool = GlobalSumPool()
max_pool = GlobalMaxPool()
out_mean = mean_pool(x_nodes, batch) # [1, 64]
out_sum = sum_pool(x_nodes, batch) # [1, 64]
out_max = max_pool(x_nodes, batch) # [1, 64]
print(out_mean.shape, out_sum.shape, out_max.shape)
For batched graph classification with GraphBatch:
from tgraphx import GraphBatch
# Create a batch of 4 graphs with varying numbers of nodes
graphs = []
for _ in range(4):
n = torch.randint(10, 30, (1,)).item()
graphs.append({
'node_features': torch.randn(n, 64),
'edge_index': torch.randint(0, n, (2, n * 3), dtype=torch.long)
})
# Build batch tensor and batch index
x_all = torch.cat([g['node_features'] for g in graphs])
sizes = [g['node_features'].size(0) for g in graphs]
batch = torch.cat([torch.full((s,), i, dtype=torch.long) for i, s in enumerate(sizes)])
out = mean_pool(x_all, batch) # [4, 64] — one vector per graph
Global Mean Pooling
Mean pooling computes the average of all node representations:
h_G = (1/N) Σ_i h_i
This is equivalent to a low-pass filter over the graph: it captures the average behavior of nodes, not the extreme values or the total signal. Mean pooling is permutation invariant (node ordering doesn't matter), differentiable, and produces outputs in the same numerical range as the node features regardless of graph size.
The limitation of mean pooling is that it is not injective over multisets. Two graphs with completely different node features can produce the same mean. Specifically, mean pooling cannot distinguish between a graph with two identical nodes and a graph with a single node whose feature equals the shared value.
Mean pooling works well when:
- Graph size varies widely, and you do not want the readout to be dominated by large graphs
- The "average" node representation captures the global property you care about
- You need numerical stability across graphs of very different sizes
Global Sum Pooling
Sum pooling aggregates all node representations:
h_G = Σ_i h_i
Sum pooling is theoretically more expressive than mean pooling. The key insight (from the GIN paper) is that sum is injective over multisets: different multisets of node features produce different sums, while mean can collapse different multisets to the same value. If the task requires counting nodes with specific properties, sum pooling is the right choice.
The limitation of sum pooling is sensitivity to graph size. A graph with 1,000 nodes will produce outputs with roughly 1,000x larger magnitude than a graph with 1 node, assuming similar per-node representations. Without careful normalization, this causes instability in downstream layers. Batch normalization after the readout layer is strongly recommended with sum pooling.
Sum pooling works well when:
- Graph size is relatively uniform across the dataset
- The task depends on the total count of nodes with certain properties
- Theoretical expressiveness is a priority (e.g., graph isomorphism benchmarks)
Global Max Pooling
Max pooling takes the element-wise maximum across all node representations:
h_G[j] = max_i h_i[j] for each feature dimension j
Max pooling captures the most extreme value in each feature dimension, ignoring all other nodes. It is robust to graph size (the output magnitude doesn't grow with N) and is well-suited for detecting the presence of any node with a particular property.
The limitation of max pooling is that it ignores the distribution of node values. Two graphs — one with a single extreme node and one where most nodes have the same extreme value — produce identical max-pool outputs. It discards information about how common or rare a feature value is.
Max pooling works well when:
- The task requires detecting whether any node satisfies a condition (presence/absence tasks)
- Graph size varies dramatically and you need size-invariance
- Node features have been trained to detect specific structural patterns (late layers of a well-trained GNN)
Comparison Table
| Property | Global Mean | Global Sum | Global Max |
|---|---|---|---|
| Injective over multisets | No | Yes (theoretically) | No |
| Size sensitivity | Low | High | Low |
| Captures count information | No | Yes | No |
| Captures presence/absence | Weakly | Yes | Yes |
| Numerical stability | High | Medium (grows with N) | High |
| Best for | Average properties | Counting, expressiveness | Presence detection |
| TGraphX class | GlobalMeanPool |
GlobalSumPool |
GlobalMaxPool |
Hierarchical Pooling: The DiffPool Concept
Global pooling discards all structural information — it does not matter how nodes are connected, only their feature values. Hierarchical pooling aims to preserve structural information by progressively coarsening the graph through a sequence of learned clustering steps.
DiffPool (Ying et al., 2018) is the canonical differentiable hierarchical pooling method. At each level, a GNN computes a soft assignment matrix S ∈ R^{N × K} that assigns each node to one of K clusters. The cluster features and adjacency are:
X_new = S^T · X
A_new = S^T · A · S
Where X is the current node feature matrix and A is the adjacency matrix. The process repeats at each level, progressively reducing the number of nodes until the graph is small enough for global pooling.
TGraphX does not currently provide a native DiffPool implementation. However, the core pattern can be approximated using the existing layers and pooling:
import torch
import torch.nn as nn
import torch.nn.functional as F
from tgraphx.layers.sage import TensorGraphSAGELayer
from tgraphx.layers.pooling import GlobalMeanPool
class SimpleDiffPoolBlock(nn.Module):
"""Simplified hierarchical pooling block (DiffPool-inspired)."""
def __init__(self, in_channels, out_channels, num_clusters):
super().__init__()
# GNN to compute node embeddings
self.embed_gnn = TensorGraphSAGELayer(in_channels, out_channels)
# GNN to compute soft cluster assignments
self.assign_gnn = TensorGraphSAGELayer(in_channels, num_clusters)
def forward(self, x, edge_index, num_nodes):
# Node embeddings
h = F.relu(self.embed_gnn(x, edge_index))
# Soft assignments: [N, K]
s = F.softmax(self.assign_gnn(x, edge_index), dim=-1)
# Pool: compute cluster features [K, out_channels]
x_pooled = s.T @ h # [K, out_channels]
# Note: coarsening the adjacency requires a dense adjacency matrix
# which is expensive for large graphs. For illustration:
# A_dense = to_dense_adj(edge_index, max_num_nodes=num_nodes)[0]
# A_pooled = s.T @ A_dense @ s # [K, K]
# For simplicity, return pooled features and the assignment
return x_pooled, s
# Example usage in a 2-level hierarchical pooler
class HierarchicalGNNClassifier(nn.Module):
def __init__(self, in_channels, hidden, num_classes, k1=16, k2=4):
super().__init__()
self.pool1 = SimpleDiffPoolBlock(in_channels, hidden, k1)
self.pool2 = SimpleDiffPoolBlock(hidden, hidden, k2)
self.readout = GlobalMeanPool()
self.classifier = nn.Linear(hidden, num_classes)
def forward(self, x, edge_index, batch=None):
n = x.size(0)
# Level 1: N -> k1 clusters
x1, s1 = self.pool1(x, edge_index, n)
# Level 2: k1 -> k2 clusters (no edge structure maintained in this simplified version)
# In a full DiffPool, you'd use A_pooled as the new adjacency
x2, s2 = self.pool2(x1, torch.zeros(2, 0, dtype=torch.long), k1)
# Global readout over k2 cluster representations
out = self.readout(x2, torch.zeros(k2, dtype=torch.long))
return self.classifier(out)
When to Choose Hierarchical Pooling
Hierarchical pooling adds significant complexity and computational cost. It is worth considering when:
- The task explicitly depends on hierarchical structure (e.g., molecular scaffolds, document sections within chapters within books)
- Global pooling produces poor performance and you have evidence that structural hierarchy is the missing signal
- The graphs are large enough that a single global aggregation loses important local structure
The caveats are substantial: DiffPool is notoriously difficult to train, requires careful regularization (assignment entropy and adjacency reconstruction losses), and does not consistently outperform global pooling with well-tuned GNN layers. Many practitioners find that concatenating global mean, sum, and max pooling is a competitive alternative with far less implementation complexity.
Combining Multiple Pooling Methods
A practical ensemble approach uses all three global pooling methods and concatenates the results:
class MultiPoolClassifier(nn.Module):
def __init__(self, in_channels, hidden, num_classes):
super().__init__()
self.gnn1 = TensorGraphSAGELayer(in_channels, hidden)
self.gnn2 = TensorGraphSAGELayer(hidden, hidden)
self.mean_pool = GlobalMeanPool()
self.sum_pool = GlobalSumPool()
self.max_pool = GlobalMaxPool()
self.classifier = nn.Linear(hidden * 3, num_classes)
def forward(self, x, edge_index, batch=None):
x = F.relu(self.gnn1(x, edge_index))
x = self.gnn2(x, edge_index)
h_mean = self.mean_pool(x, batch) # [B, hidden]
h_sum = self.sum_pool(x, batch) # [B, hidden]
h_max = self.max_pool(x, batch) # [B, hidden]
h = torch.cat([h_mean, h_sum, h_max], dim=1) # [B, hidden * 3]
return self.classifier(h)
This concatenation captures average properties, count information, and extreme values simultaneously. In practice it often outperforms any single pooling method by a noticeable margin.
Limitations and Honest Notes
All global pooling methods are permutation invariant but also structure-blind in the sense that the same multiset of node features always produces the same readout, regardless of how those nodes are connected. Two graphs with identical node feature distributions but completely different structures produce the same global pooling output. If the task requires discriminating graphs based on their structure rather than their node feature distributions, global pooling is fundamentally limited.
DiffPool and other hierarchical methods attempt to address this by incorporating adjacency information in the pooling operation. However, the adjacency coarsening step requires either a dense adjacency matrix (prohibitive for large graphs) or approximations that weaken the structural guarantees.
Benchmark results comparing pooling methods are sensitive to the specific dataset, the number of GNN layers used before pooling, and the feature dimension. Do not assume that the best pooling method for one molecular dataset generalizes to another.
Frequently Asked Questions
Does pooling choice matter more than the GNN layer choice?
Generally, the GNN layer choice (SAGE vs GAT vs GIN) has a larger effect on performance than the pooling choice. The exception is when graphs vary dramatically in size, where pooling choice interacts with normalization in ways that matter a lot.
Can I use different pooling for node classification vs graph classification?
Pooling is only relevant for graph classification, where you need a graph-level readout. For node classification, there is no pooling step — the node representations are passed directly to a classifier.
Is attention-based pooling better than mean/max/sum?
Attention pooling (assigning learned scalar weights to each node before summing) is a natural extension that often works well. It adds a small MLP per node to compute the attention weight. TGraphX does not provide a dedicated attention pooling layer, but it is straightforward to implement as a wrapper around the existing pooling layers.