Code walkthrough Pytorch Geometric on movielens data training GCN, SageConv, LightGCN
deep-learning
recommendation
Author
Aman Pandey
Published
July 15, 2026
π What Do We Expect from a Mature Deep Learning Library?
PyTorch Geometric (PyG): From Prototyping to Enterprise Production
π 1. Active Community & World-Class Documentation
Thriving Open Source Community: ~24,000 GitHub stars & 4,000+ forks. The latest PyG 2.7 release alone had 53 contributors across 282+ commits. Active official Slack. Not a dead academic repoβships monthly!
Exhaustive Documentation: Full ReadTheDocs site with dedicated tutorials for every subsystem (sampling, distributed training, explainability, compilation).
π 2. Production Scalability & Out-of-Memory (OOM) Scaling
Distributed & Multi-GPU Training:
torch_geometric.distributed (v2.5+) β Built for multi-node graphs larger than single-machine RAM using graph partitioning + DDP.
Plain multi-GPU via standard DistributedDataParallel.
Large-Graph Subgraph Sampling:NeighborLoader, ClusterGCN, and Hierarchical Neighborhood Sampling (trims subgraphs before each GNN layer).
Remote Backends:FeatureStore & GraphStore plug straight into external graph databases or vector storesβskipping expensive ETL steps.
π οΈ 3. Architecture, API & Compiler Stack
Dual-Tier API: High-level modular layers (GCNConv, GraphSAGE, HGTConv) vs. low-level MessagePassing base class for custom aggregation logic.
Heterogeneous Graph Support: First-class native support for multi-entity graphs (HeteroData) using specialized layers like HGTConv and HANConv.
Config-Driven Sweeps (GraphGym): Run YAML-defined experiment sweeps across dozens of architectures via torch_geometric.graphgym.
A single graph in PyG is modeled by the Data class. It encapsulates nodes, pairwise connections (edges), and their associated features/targets across three granularities: node-level, edge-level, and graph-level.
2.11.0 cpu
pytorch geometric installation done
ββββββββββββββββββββββββββββββββββββββββ2.4/2.4 MB25.0 MB/s eta 0:00:00ββββββββββββββββββββββββββββββββββββββ681.6/681.6 kB33.3 MB/s eta 0:00:00ββββββββββββββββββββββββββββββββββββββββ1.4/1.4 MB24.3 MB/s eta 0:00:00
Bunch of extra pytorch dependencies installation done
π PyG Data Structures in Practice
Homogeneous (Data) vs. Heterogeneous (HeteroData) Graphs
π‘ Whatβs the Difference?
Homogeneous Graph (Data): All nodes belong to a single entity type (e.g., all users or all molecules) and all edges represent one relationship type.
π Link Prediction Splitting with RandomLinkSplit
How to Split Graph Edges Without Causing Data Leakage
π€ Why Canβt We Use Standard train_test_split?
In traditional ML, splitting data means randomly picking rows. In Graph Link Prediction, this breaks everything: * Data Leakage Risk: If a test edge remains inside data.edge_index, your GNN will perform message passing directly across the target edge during forward propagationβeffectively letting the model βcheatβ and read the answer. * The Solution:RandomLinkSplit decouples the graph into two distinct edge sets per split: 1. edge_index (Message Passing Graph): The structural graph used to aggregate neighbor features. 2. edge_label_index (Supervision Targets): The specific pairs we predict and compute loss against.
π¨ Visual Mechanism of RandomLinkSplit
svgviewer-output.svg
βοΈ Important Parameter Breakdown
num_val=0.0 & num_test=0.2: Instructs PyG to perform a strict 80% Train / 20% Test split, removing the validation stage entirely.
is_undirected=True: Crucial for symmetric graphs. Guarantees that if edge \((u \rightarrow v)\) is removed for testing, its reverse twin \((v \rightarrow u)\) is also removed, completely sealing information leaks.
add_negative_train_samples=False: Prevents static negative pair generation during the split. Leaves negative edge generation to the LinkNeighborLoader dynamically per batch.
Code
import torchfrom torch_geometric.transforms import RandomLinkSplit# Set random seed for reproducibilitytorch.manual_seed(42)# ==============================================================================# 1. DEFINE RANDOM LINK SPLIT (Train / Test Split Only)# ==============================================================================transform = RandomLinkSplit( num_val=0.0, # Remove validation set (0% Val) num_test=0.2, # 20% held-out test edges add_negative_train_samples=False, # Handled dynamically by LinkNeighborLoader edge_types=[('user', 'rates', 'movie')], rev_edge_types=[('movie', 'rev_rates', 'user')])# ==============================================================================# 2. EXECUTE THE SPLIT ON MOVIELENS DATA# ==============================================================================train_data, val_data, test_data = transform(movielens_data)print(train_data)# Shortcut variable for the primary supervision relationtarget_rel = ('user', 'rates', 'movie')# ==============================================================================# 3. VERIFY TENSOR SHAPES & HETEROGENEOUS RELATIONAL ATTRIBUTES# ==============================================================================print("="*65)print(" βοΈ HETEROGENEOUS RANDOM LINK SPLIT SUMMARY ")print("="*65)print(f"Original Forward Edges ('user','rates','movie') : {movielens_data[target_rel].num_edges}")print("-"*65)print(f"Train Message-Passing Edges : {train_data[target_rel].edge_index.shape}")print(f"Train Target Supervision Pairs : {train_data[target_rel].edge_label_index.shape}")print(f"Train Label Distribution : {torch.bincount(train_data[target_rel].edge_label.int()).tolist()} (All 1.0 Positives)")print("-"*65)print(f"Test Message-Passing Edges : {test_data[target_rel].edge_index.shape}")print(f"Test Target Supervision Pairs : {test_data[target_rel].edge_label_index.shape}")print(f"Test Label Distribution : {torch.bincount(test_data[target_rel].edge_label.int()).tolist()} (50% Pos / 50% Neg)")print("="*65)
In full-graph training, every single GNN forward pass processes the entire adjacency matrix (\(\mathbf{A} \in \mathbb{R}^{|V| \times |V|}\)). For real-world graphs with millions of users and items, this causes immediate Out-Of-Memory (OOM) errors on GPU devices.
LinkNeighborLoader solves this through localized mini-batch neighborhood sampling: 1. Seed Selection: Selects a mini-batch of target supervision edges from edge_label_index. 2. Dynamic Negative Sampling: If configured, generates unobserved negative target pairs on-the-fly. 3. \(k\)-Hop Expansion: Recursively samples a bounded number of neighbors around the seed nodes layer by layer (\(N_1, N_2, \dots, N_L\)). 4. Subgraph Extraction & Relabeling: Extracts the localized induced subgraph and relabels global node IDs into contiguous local indices (\(0 \dots N_{\text{subgraph}}-1\)).
π¨ Visualizing 1 Iteration of Subgraph Extraction
Screenshot 2026-07-21 at 7.51.18β―PM.png
π Anatomy of 1 Mini-Batch Iteration Output
When executing batch = next(iter(train_loader)), PyG returns a localized data container holding four vital tensors:
batch.n_id\(\to\)[Num_Subgraph_Nodes]
Vector of Global Node IDs included inside this specific sampled neighborhood subgraph.
Used to index global node feature matrices or embedding lookup tables: x_sub = model.embedding(batch.n_id).
batch.edge_index\(\to\)[2, Num_Subgraph_Edges]
Sparse adjacency matrix representing message-passing connectivity strictly within the sampled subgraph.
Local Relabeling: Indices are mapped into range \([0 \dots N_{\text{subgraph}}-1]\) so standard PyG convolution layers function seamlessly.
import umapimport numpy as npimport plotly.graph_objects as go# gcn_final_user, gcn_final_movie from:# gcn_final_user, gcn_final_movie = get_full_embeddings(gcn_model, train_data)user_np = gcn_final_user.detach().cpu().numpy()movie_np = gcn_final_movie.detach().cpu().numpy()# Stack into ONE array so both types land in the same 3D coordinate spacecombined = np.concatenate([user_np, movie_np], axis=0)node_type_labels = np.array(['user'] *len(user_np) + ['movie'] *len(movie_np))node_ids = np.concatenate([np.arange(len(user_np)), np.arange(len(movie_np))])reducer = umap.UMAP(n_components=3, n_neighbors=15, min_dist=0.1, metric='cosine', random_state=42)embedding_3d = reducer.fit_transform(combined)fig = go.Figure()colors = {'user': '#1f77b4', 'movie': '#ff7f0e'}for node_type in ['user', 'movie']: mask = node_type_labels == node_type fig.add_trace(go.Scatter3d( x=embedding_3d[mask, 0], y=embedding_3d[mask, 1], z=embedding_3d[mask, 2], mode='markers', name=node_type, marker=dict(size=3, color=colors[node_type], opacity=0.7), text=[f"{node_type}{i}"for i in node_ids[mask]], hovertemplate="%{text}<br>x: %{x:.2f}<br>y: %{y:.2f}<br>z: %{z:.2f}<extra></extra>", ))fig.update_layout( title="3D UMAP of GCN Embeddings (Users + Movies)", scene=dict(xaxis_title="UMAP-1", yaxis_title="UMAP-2", zaxis_title="UMAP-3"), width=900, height=800, legend=dict(itemsizing='constant'),)fig.show()
/usr/local/lib/python3.12/dist-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
Example - User0βMovie0,Movie1. User1βMovie1,Movie2. We already have two real relations from the raw data:
('user', 'rates', 'movie'): edges User0βMovie0, User0βMovie1, User1βMovie1, User1βMovie2
('movie', 'rev_rates', 'user'): the reverse of the above
A metapath is just: βwalk along relation A, then immediately walk along relation B, and treat the startβend of that 2-hop walk as if it were a single new edge.β
Metapath 1: [(βuserβ,βratesβ,βmovieβ), (βmovieβ,βrev_ratesβ,βuserβ)] means βstart at a user, go to a movie they rated, then go back to any user who also rated that movie.β Walk it by hand:
User0 --rates--> Movie1 --rev_rates--> User1 (both rated Movie1)
User1 --rates--> Movie1 --rev_rates--> User0 (same pair, other direction)
User0 --rates--> Movie0 --rev_rates--> User0 (trivial: User0 back to themself)
AddMetaPaths collapses each of these 2-hop walks into one direct edge, producing a brand new relation (βuserβ, βmetapath_0β, βuserβ) with edges like User0 βοΈ User1 β a direct connection between two users who never had a direct edge in the original graph, created purely because they share a movie. This is the βpeople who liked similar things are connectedβ signal.
Metapath 2 is the mirror image: [(βmovieβ,βrev_ratesβ,βuserβ), (βuserβ,βratesβ,βmovieβ)] walks movie β user β movie, producing (βmovieβ, βmetapath_1β, βmovieβ) β Movie0 and Movie1 get connected because User0 rated both.
So after the transform, data.edge_types grows from 2 relations to 4:
Why max_sample=20: without a cap, one wildly popular movie with 500 raters would generate up to 500Γ499 β 250,000 user-user metapath edges from that single movie alone (every rater connects to every other rater β a complete clique). max_sample=20 caps how many neighbors get sampled per node when building each metapath edge, so popular items donβt blow up the edge count into something unmanageable.
Why it matters for the model: SAGEConv on (βuserβ,βmetapath_0β,βuserβ) lets a userβs embedding directly absorb signal from other users with similar taste β something a plain 2-hop rates/rev_rates graph technically also achieves through 2 layers of message passing (as we walked through earlier), but the metapath edge makes it an explicit 1-hop relation the model can weight and learn about directly, rather than something that only emerges implicitly through depth.
Code
import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch_geometric.nn import SAGEConv, to_heterofrom torch_geometric.transforms import AddMetaPathsfrom torch_geometric.loader import LinkNeighborLoadertorch.manual_seed(42)target_rel = ('user', 'rates', 'movie')# ==============================================================================# 1. LOADER HELPER# ==============================================================================def make_loader(data, shuffle, neg_ratio):return LinkNeighborLoader( data=data, num_neighbors=[20, 10], batch_size=1024, edge_label_index=(target_rel, data[target_rel].edge_label_index), edge_label=data[target_rel].edge_label, neg_sampling_ratio=neg_ratio, shuffle=shuffle, )# ==============================================================================# 2. METAPATH-AUGMENTED GRAPH (derived only from train edges β no test leakage)# ==============================================================================metapaths = [ [('user', 'rates', 'movie'), ('movie', 'rev_rates', 'user')], # user -> user (co-rated a movie) [('movie', 'rev_rates', 'user'), ('user', 'rates', 'movie')], # movie -> movie (co-rated by a user)]meta_transform = AddMetaPaths(metapaths=metapaths, drop_orig_edge_types=False, max_sample=20)train_data_sage = meta_transform(train_data.clone())test_data_sage = meta_transform(test_data.clone())print(train_data_sage)print("Relations:", train_data_sage.metadata()[1])sage_train_loader = make_loader(train_data_sage, True, 1.0)sage_test_loader = make_loader(test_data_sage, False, 0.0)
/usr/local/lib/python3.12/dist-packages/torch_geometric/edge_index.py:863: UserWarning:
Sparse invariant checks are implicitly disabled. Memory errors (e.g. SEGFAULT) will occur when operating on a sparse tensor which violates the invariants, but checks incur performance overhead. To silence this warning, explicitly opt in or out. See `torch.sparse.check_sparse_tensor_invariants.__doc__` for guidance. (Triggered internally at /pytorch/aten/src/ATen/Context.cpp:760.)
/usr/local/lib/python3.12/dist-packages/torch_geometric/edge_index.py:863: UserWarning:
Sparse CSR tensor support is in beta state. If you miss a functionality in the sparse tensor support, please submit a feature request to https://github.com/pytorch/pytorch/issues. (Triggered internally at /pytorch/aten/src/ATen/SparseCsrTensorImpl.cpp:49.)
This only matters for a node type that receives from more than one relation β which, now that we have metapaths, both user and movie do:
'user' receives from: ('movie', 'rev_rates', 'user') AND ('user', 'metapath_0', 'user')
'movie' receives from: ('user', 'rates', 'movie') AND ('movie', 'metapath_1', 'movie')
to_hetero()runs each relationβs cloned SAGEConv separately, producing two separate output tensors for user β one from the direct rating relation, one from the metapath relation. aggr=βmeanβ is the rule for merging those two tensors into the single user embedding the next layer sees:
import umapimport numpy as npimport plotly.graph_objects as go# ==============================================================================# EXTRACT ALL EMBEDDINGS from the trained SAGE model# ==============================================================================sage_final_user, sage_final_movie = get_full_embeddings_generic(sage_model, train_data_sage)user_np = sage_final_user.detach().cpu().numpy()movie_np = sage_final_movie.detach().cpu().numpy()# Stack into ONE array so both types land in the same 3D coordinate spacecombined = np.concatenate([user_np, movie_np], axis=0)node_type_labels = np.array(['user'] *len(user_np) + ['movie'] *len(movie_np))node_ids = np.concatenate([np.arange(len(user_np)), np.arange(len(movie_np))])# ==============================================================================# UMAP -> 3D# ==============================================================================reducer = umap.UMAP(n_components=3, n_neighbors=15, min_dist=0.1, metric='cosine', random_state=42)embedding_3d = reducer.fit_transform(combined)# ==============================================================================# PLOTLY 3D SCATTER# ==============================================================================fig = go.Figure()colors = {'user': '#1f77b4', 'movie': '#ff7f0e'}for node_type in ['user', 'movie']: mask = node_type_labels == node_type fig.add_trace(go.Scatter3d( x=embedding_3d[mask, 0], y=embedding_3d[mask, 1], z=embedding_3d[mask, 2], mode='markers', name=node_type, marker=dict(size=3, color=colors[node_type], opacity=0.7), text=[f"{node_type}{i}"for i in node_ids[mask]], hovertemplate="%{text}<br>x: %{x:.2f}<br>y: %{y:.2f}<br>z: %{z:.2f}<extra></extra>", ))fig.update_layout( title="3D UMAP of SAGE + Metapath Embeddings (Users + Movies)", scene=dict(xaxis_title="UMAP-1", yaxis_title="UMAP-2", zaxis_title="UMAP-3"), width=900, height=800, legend=dict(itemsizing='constant'),)fig.show()
/usr/local/lib/python3.12/dist-packages/umap/umap_.py:1952: UserWarning:
n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
Pros and Cons
Pros - Captures High-Order Connectivity with multiple hops. - GNNs natively handle heterogeneous graphs. - Superior Recommendation Accuracy - Handles Data Sparsity Better - Explainability which can be utilised in recommendation.