Debugging Common GNN Errors with TGraphX
GNN debugging is different from standard neural network debugging. A typical feed-forward network takes a matrix of independent samples; errors are usually straightforward tensor shape mismatches or wrong label dtypes. In GNNs, the input is a graph — a structured object with node features, edge indices, optional edge features, and batch assignments. Errors appear as shape mismatches involving graph topology, device placement inconsistencies, silent semantic bugs that produce wrong gradients, and data leakage caused by improper mask handling.
TGraphX is built with explicit validation at every public API boundary. Most inputs are checked against expected shapes, dtypes, and value ranges before computation begins, producing descriptive error messages rather than cryptic downstream failures. This article catalogs the most common GNN errors, explains their root causes, and shows how to diagnose and fix them using TGraphX's doctor module and validation utilities.
What Makes GNN Errors Hard to Debug
Before diving into specific errors, it is worth understanding why GNN debugging is harder than standard deep learning debugging:
Graph structure propagates errors. A wrong edge index does not simply produce a wrong output for one node — it corrupts the neighborhood aggregation for every node connected to that edge, which then propagates to neighbors of neighbors in subsequent layers.
Silent errors are common. A transposed edge index [E, 2] instead of [2, E], or a label vector of the wrong length, can silently produce output with no Python exception. The model trains, loss decreases, and accuracy appears normal — until you realize your edge index was silently treated as two-node features for E nodes.
Mask mismanagement causes data leakage. Using the test mask during training, or constructing masks that overlap, inflates evaluation metrics without raising any error.
Mini-batch graphs have variable size. In a mini-batch training loop, each subgraph has a different number of nodes. Indexing logits naively without using the batch's seed node accessor produces silently wrong loss values.
Prerequisites
This article assumes:
- Basic familiarity with PyTorch and GNNs
- TGraphX installed (
pip install tgraphx) - Some prior GNN training experience (having encountered at least one of these errors in practice)
For a general GNN tutorial, start with the MNIST-as-graph guide before debugging more complex setups. For shape-validation principles, see the shape-aware validation guide.
Category 1: Tensor Shape Errors
Error: edge_index must have shape [2, E]; got [E, 2]
This is the single most common TGraphX error. The convention is [2, E] — the first row contains source node indices, the second row destination indices. When you load an edge list as a list of (src, dst) pairs and stack them, you get [E, 2].
import torch
# Wrong: loading edge pairs naively
edges = [(0, 1), (1, 2), (2, 3), (3, 0)]
edge_index_wrong = torch.tensor(edges) # shape [4, 2] — wrong!
# Correct: transpose after stacking
edge_index = torch.tensor(edges).T.contiguous()
print(edge_index.shape) # [2, 4]
# Alternative: build from separate src/dst lists
srcs = [0, 1, 2, 3]
dsts = [1, 2, 3, 0]
edge_index = torch.tensor([srcs, dsts], dtype=torch.long)
print(edge_index.shape) # [2, 4]
Error: x must have shape [N, C, H, W] (spatial_rank=2); got [N, H, W, C]
PyTorch uses channels-first convention. NumPy arrays loaded from images or converted from TensorFlow (channels-last) must be permuted:
import numpy as np
# NumPy image [H, W, C] → PyTorch [C, H, W]
x_np = np.random.randn(28, 28, 3)
x_pt = torch.from_numpy(x_np).permute(2, 0, 1).float()
print(x_pt.shape) # [3, 28, 28]
# Batch of images [N, H, W, C] → [N, C, H, W]
x_batch_np = np.random.randn(100, 28, 28, 3)
x_batch_pt = torch.from_numpy(x_batch_np).permute(0, 3, 1, 2).float()
print(x_batch_pt.shape) # [100, 3, 28, 28]
Error: node_features has 100 nodes but node_labels has 50
Node feature and label arrays must have the same first dimension. This error arises when preprocessing steps drop nodes without updating the label array, or when label filtering is applied to a different graph than the one being used for training.
from tgraphx import Graph
x = torch.randn(100, 32)
labels = torch.randint(0, 4, (50,)) # wrong size
# Graph constructor catches this immediately with a clear message
try:
g = Graph(node_features=x, edge_index=torch.zeros(2, 0, dtype=torch.long), node_labels=labels)
except ValueError as e:
print(e) # "node_features has 100 nodes but node_labels has 50"
Category 2: Device Mismatch Errors
Error: Expected all tensors to be on the same device, but found at least two devices
Node features may be on CPU while edge index is on CUDA, or vice versa. The fix is to move everything to a single device before the forward pass.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Method 1: use Graph.to()
g = g.to(device)
model = model.to(device)
# Method 2: move tensors individually
x = x.to(device)
edge_index = edge_index.to(device)
labels = labels.to(device)
Silent device error: model weights on CPU but graph on GPU
This happens when you create the model before moving it to GPU, and then the forward pass allocates new tensors on CPU:
# Always move model AND data before any forward pass
model = MyGNN().to(device)
x = x.to(device)
edge_index = edge_index.to(device)
# Verify
assert next(model.parameters()).device.type == x.device.type, "device mismatch"
Category 3: Edge Index Out-of-Range Errors
Error: edge_index references node 95, but num_nodes=50
Edge indices reference node 95 but the feature matrix only has 50 rows. Common cause: extracting a subgraph without re-indexing node identities to start from 0.
# Diagnose before passing to a model
edge_index = torch.randint(0, 100, (2, 400), dtype=torch.long)
x = torch.randn(50, 32) # only 50 nodes
max_node = edge_index.max().item()
num_nodes = x.shape[0]
assert max_node < num_nodes, f"edge_index references node {max_node}, but num_nodes={num_nodes}"
# Fix: re-index with compact_nodes
from tgraphx.doctor import validate_graph
validate_graph(x, edge_index) # raises with detailed message
Error: edge_index must have dtype torch.long; got torch.float32
Common when loading edges from a CSV or NumPy float array without explicit casting:
edge_index_float = torch.rand(2, 400) * 100 # accidental float edges
edge_index = edge_index_float.long() # cast to int64
Category 4: NaN Loss and Gradient Issues
Warning: Loss is NaN from the first training step
Several root causes produce NaN loss immediately:
Cause 1: Exploding gradients in deep GNNs
# Apply gradient clipping after backward()
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
Cause 2: NaN in node features
# Check before training
assert not x.isnan().any(), f"NaN values found in node features"
assert not x.isinf().any(), f"Inf values found in node features"
# Normalize features to prevent scale issues
x = (x - x.mean(dim=0)) / (x.std(dim=0) + 1e-8)
Cause 3: Wrong label dtype for cross-entropy
assert labels.dtype == torch.long, f"Labels must be torch.long, got {labels.dtype}"
# Fix:
labels = labels.long()
Cause 4: Isolated nodes with zero-degree in softmax
Some aggregation operations divide by node degree. Isolated nodes (degree 0) produce division by zero → NaN.
from tgraphx.doctor import check_isolated_nodes
isolated = check_isolated_nodes(edge_index, num_nodes=x.shape[0])
print(f"Isolated nodes: {isolated.sum().item()}")
# Add self-loops or remove isolated nodes before training
Category 5: Validation Accuracy Does Not Improve
Symptom: Training loss decreases but val accuracy stagnates near random
This pattern typically indicates one of three problems:
Problem 1: Model in train mode during evaluation
Dropout is active during model.train(). Always switch to model.eval() before computing validation metrics:
model.train() # during training steps
# ...training...
model.eval() # switch before evaluation
with torch.no_grad():
val_logits = model(g.node_features, g.edge_index)
val_acc = (
val_logits[g.val_mask].argmax(1) == g.node_labels[g.val_mask]
).float().mean()
print(f"Val accuracy: {val_acc.item():.4f}")
Problem 2: Data leakage — test nodes included in training
# Verify masks do not overlap
assert (train_mask & val_mask).sum() == 0, "train and val masks overlap"
assert (train_mask & test_mask).sum() == 0, "train and test masks overlap"
assert (val_mask & test_mask).sum() == 0, "val and test masks overlap"
Problem 3: Wrong mask indexing — val labels don't correspond to val nodes
# Wrong: indexing logits and labels independently
wrong_val_acc = (logits[:50].argmax(1) == labels[:50]).float().mean()
# Correct: use the same boolean mask for both
correct_val_acc = (
logits[val_mask].argmax(1) == labels[val_mask]
).float().mean()
Category 6: Mini-Batch Training Errors
Error: logit and label sizes mismatch in mini-batch training
In mini-batch GNN training, each subgraph contains both target (seed) nodes and their sampled neighbors. Logits produced by the model have one entry per node in the subgraph — not just the target nodes. Using raw logits against the target labels produces a size mismatch.
# Wrong: raw logit slice
logits = model(batch.node_features, batch.edge_index)
loss = F.cross_entropy(logits, batch.node_labels) # wrong size
# Correct: use seed node accessor
logits = model(batch.node_features, batch.edge_index)
seed_logits = batch.seed_logits(logits) # logits for target nodes only
seed_labels = batch.seed_y # corresponding labels
loss = F.cross_entropy(seed_logits, seed_labels)
Using TGraphX Doctor for Systematic Diagnosis
TGraphX's doctor module provides programmatic validation that catches issues before they cause cryptic runtime errors:
from tgraphx.doctor import validate_graph, check_isolated_nodes, run_diagnostics
from tgraphx import Graph
# Quick validation of edge index and features
validate_graph(x, edge_index) # raises ValueError with details if anything is wrong
# Run a full diagnostic report
g = Graph(
node_features=x,
edge_index=edge_index,
)
report = run_diagnostics(g)
print(report.summary())
# Prints: number of nodes, edges, isolated nodes, self-loops, duplicate edges,
# device consistency, dtype consistency
For errors encountered during a training run, TGraphX provides an error explanation utility:
import tgraphx as tgx
try:
out = layer(x, bad_edge_index)
except ValueError as e:
guidance = tgx.explain_error(e)
print(guidance) # actionable next steps for the specific error
A Systematic Debugging Checklist
Before opening an issue or spending hours on a debugging session, verify each item in this checklist:
edge_index.dtype == torch.long— not float32edge_index.shape == (2, E)— not(E, 2)edge_index.max() < x.shape[0]— no out-of-bounds referencesx.device == edge_index.device— same device for all tensorsx.shape[0] == labels.shape[0]— feature-label alignmentx.isnan().any() == False— no NaN in featureslabels.dtype == torch.longfor classificationmodel.eval()andtorch.no_grad()during validation- No overlap between train, val, and test masks
batch.seed_logits(logits)used in mini-batch training, not raw logit slice- For spatial features:
x.dim() == 4for 2D spatial,x.dim() == 5for 3D
Limitations and Honest Notes
This guide covers the most frequent errors encountered in TGraphX workflows on standard graphs with static topology. It does not cover:
- Custom layer debugging (errors inside user-defined
nn.Modulesubclasses) - Distributed training errors on multi-GPU setups
- Numerical precision issues beyond NaN/Inf detection
- Operating system or filesystem-specific errors (e.g., Windows path encoding for data loading)
- Temporal graph-specific errors (ordering and causality issues in TGN/TGAT training)
For temporal graph debugging, see the temporal GNN guide. For reproducibility-related debugging (results that differ run-to-run), see the GNN research reproducibility guide.
Frequently Asked Questions
What is the difference between validate_graph and run_diagnostics?
validate_graph is a lightweight check that raises ValueError on the first problem found. run_diagnostics runs all checks and returns a structured report with counts and severity levels for all detected issues.
I see a NaN loss only on some batches, not all. Why?
NaN appearing intermittently often indicates isolated nodes in some mini-batches (zero degree → division by zero in normalized aggregation), or occasional bad samples with extreme feature values. Check for isolated nodes in each batch and apply feature clipping or normalization.
My model trains but test accuracy is much lower than val accuracy. Why?
This is the canonical sign of data leakage. Verify that val and test masks are disjoint, that the same graph construction and normalization pipeline is applied at test time, and that the test set contains genuinely unseen examples.
Where is the TGraphX doctor module documented?
Source code is on GitHub. The package is available at PyPI.