Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Graph Neural Networks (PyG). Node/graph classification, link prediction, GCN, GAT, GraphSAGE, heterogeneous graphs, molecular property prediction, for geometric deep learning.
.claude/skills/torch-geometric/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✗ | = Same ✗ | — | — |
| case-09 | ✗→✗ | = Same ✗ | — | — |
| case-13 | ✗→✗ | = Same ✗ | — | — |
| case-21 | ✗→✗ | = Same ✗ | — | — |
| case-22 | ✗→✗ | = Same ✗ | — | — |
PyTorch Geometric is a library built on PyTorch for developing and training Graph Neural Networks (GNNs). Apply this skill for deep learning on graphs and irregular structures, including mini-batch processing, multi-GPU training, and geometric deep learning applications.
This skill should be used when working with:
bashuv pip install torch_geometric
For additional dependencies (sparse operations, clustering):
bashuv pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-${TORCH}+${CUDA}.html
pythonimport torch from torch_geometric.data import Data # Create a simple graph with 3 nodes edge_index = torch.tensor([[0, 1, 1, 2], # source nodes [1, 0, 2, 1]], dtype=torch.long) # target nodes x = torch.tensor([[-1], [0], [1]], dtype=torch.float) # node features data = Data(x=x, edge_index=edge_index) print(f"Nodes: {data.num_nodes}, Edges: {data.num_edges}")
pythonfrom torch_geometric.datasets import Planetoid # Load Cora citation network dataset = Planetoid(root='/tmp/Cora', name='Cora') data = dataset[0] # Get the first (and only) graph print(f"Dataset: {dataset}") print(f"Nodes: {data.num_nodes}, Edges: {data.num_edges}") print(f"Features: {data.num_node_features}, Classes: {dataset.num_classes}")
PyG represents graphs using the torch_geometric.data.Data class with these key attributes:
data.x: Node feature matrix [num_nodes, num_node_features]data.edge_index: Graph connectivity in COO format [2, num_edges]data.edge_attr: Edge feature matrix [num_edges, num_edge_features] (optional)data.y: Target labels for nodes or graphsdata.pos: Node spatial positions [num_nodes, num_dimensions] (optional)data.train_mask, data.batch)Important: These attributes are not mandatory—extend Data objects with custom attributes as needed.
Edges are stored in COO (coordinate) format as a [2, num_edges] tensor:
python# Edge list: (0→1), (1→0), (1→2), (2→1) edge_index = torch.tensor([[0, 1, 1, 2], [1, 0, 2, 1]], dtype=torch.long)
PyG handles batching by creating block-diagonal adjacency matrices, concatenating multiple graphs into one large disconnected graph:
batch vector maps each node to its source graphpythonfrom torch_geometric.loader import DataLoader loader = DataLoader(dataset, batch_size=32, shuffle=True) for batch in loader: print(f"Batch size: {batch.num_graphs}") print(f"Total nodes: {batch.num_nodes}") # batch.batch maps nodes to graphs
GNNs in PyG follow a neighborhood aggregation scheme:
PyG provides 40+ convolutional layers. Common ones include:
GCNConv (Graph Convolutional Network):
pythonfrom torch_geometric.nn import GCNConv import torch.nn.functional as F class GCN(torch.nn.Module): def __init__(self, num_features, num_classes): super().__init__() self.conv1 = GCNConv(num_features, 16) self.conv2 = GCNConv(16, num_classes) def forward(self, data): x, edge_index = data.x, data.edge_index x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return F.log_softmax(x, dim=1)
GATConv (Graph Attention Network):
pythonfrom torch_geometric.nn import GATConv class GAT(torch.nn.Module): def __init__(self, num_features, num_classes): super().__init__() self.conv1 = GATConv(num_features, 8, heads=8, dropout=0.6) self.conv2 = GATConv(8 * 8, num_classes, heads=1, concat=False, dropout=0.6) def forward(self, data): x, edge_index = data.x, data.edge_index x = F.dropout(x, p=0.6, training=self.training) x = F.elu(self.conv1(x, edge_index)) x = F.dropout(x, p=0.6, training=self.training) x = self.conv2(x, edge_index) return F.log_softmax(x, dim=1)
GraphSAGE:
pythonfrom torch_geometric.nn import SAGEConv class GraphSAGE(torch.nn.Module): def __init__(self, num_features, num_classes): super().__init__() self.conv1 = SAGEConv(num_features, 64) self.conv2 = SAGEConv(64, num_classes) def forward(self, data): x, edge_index = data.x, data.edge_index x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return F.log_softmax(x, dim=1)
For custom layers, inherit from MessagePassing:
pythonfrom torch_geometric.nn import MessagePassing from torch_geometric.utils import add_self_loops, degree class CustomConv(MessagePassing): def __init__(self, in_channels, out_channels): super().__init__(aggr='add') # "add", "mean", or "max" self.lin = torch.nn.Linear(in_channels, out_channels) def forward(self, x, edge_index): # Add self-loops to adjacency matrix edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0)) # Transform node features x = self.lin(x) # Compute normalization row, col = edge_index deg = degree(col, x.size(0), dtype=x.dtype) deg_inv_sqrt = deg.pow(-0.5) norm = deg_inv_sqrt[row] * deg_inv_sqrt[col] # Propagate messages return self.propagate(edge_index, x=x, norm=norm) def message(self, x_j, norm): # x_j: features of source nodes return norm.view(-1, 1) * x_j
Key methods:
forward(): Main entry pointmessage(): Constructs messages from source to target nodesaggregate(): Aggregates messages (usually don't override—set aggr parameter)update(): Updates node embeddings after aggregationVariable naming convention: Appending _i or _j to tensor names automatically maps them to target or source nodes.
PyG provides extensive benchmark datasets:
python# Citation networks (node classification) from torch_geometric.datasets import Planetoid dataset = Planetoid(root='/tmp/Cora', name='Cora') # or 'CiteSeer', 'PubMed' # Graph classification from torch_geometric.datasets import TUDataset dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES') # Molecular datasets from torch_geometric.datasets import QM9 dataset = QM9(root='/tmp/QM9') # Large-scale datasets from torch_geometric.datasets import Reddit dataset = Reddit(root='/tmp/Reddit')
Check references/datasets_reference.md for a comprehensive list.
For datasets that fit in memory, inherit from InMemoryDataset:
pythonfrom torch_geometric.data import InMemoryDataset, Data import torch class MyOwnDataset(InMemoryDataset): def __init__(self, root, transform=None, pre_transform=None): super().__init__(root, transform, pre_transform) self.load(self.processed_paths[0]) @property def raw_file_names(self): return ['my_data.csv'] # Files needed in raw_dir @property def processed_file_names(self): return ['data.pt'] # Files in processed_dir def download(self): # Download raw data to self.raw_dir pass def process(self): # Read data, create Data objects data_list = [] # Example: Create a simple graph edge_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long) x = torch.randn(2, 16) y = torch.tensor([0], dtype=torch.long) data = Data(x=x, edge_index=edge_index, y=y) data_list.append(data) # Apply pre_filter and pre_transform if self.pre_filter is not None: data_list = [d for d in data_list if self.pre_filter(d)] if self.pre_transform is not None: data_list = [self.pre_transform(d) for d in data_list] # Save processed data self.save(data_list, self.processed_paths[0])
For large datasets that don't fit in memory, inherit from Dataset and implement len() and get(idx).
pythonimport pandas as pd import torch from torch_geometric.data import HeteroData # Load nodes nodes_df = pd.read_csv('nodes.csv') x = torch.tensor(nodes_df[['feat1', 'feat2']].values, dtype=torch.float) # Load edges edges_df = pd.read_csv('edges.csv') edge_index = torch.tensor([edges_df['source'].values, edges_df['target'].values], dtype=torch.long) data = Data(x=x, edge_index=edge_index)
pythonimport torch import torch.nn.functional as F from torch_geometric.datasets import Planetoid # Load dataset dataset = Planetoid(root='/tmp/Cora', name='Cora') data = dataset[0] # Create model model = GCN(dataset.num_features, dataset.num_classes) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) # Training model.train() for epoch in range(200): optimizer.zero_grad() out = model(data) loss = F.nll_loss(out[data.train_mask], data.y[data.train_mask]) loss.backward() optimizer.step() if epoch % 10 == 0: print(f'Epoch {epoch}, Loss: {loss.item():.4f}') # Evaluation model.eval() pred = model(data).argmax(dim=1) correct = (pred[data.test_mask] == data.y[data.test_mask]).sum() acc = int(correct) / int(data.test_mask.sum()) print(f'Test Accuracy: {acc:.4f}')
pythonfrom torch_geometric.datasets import TUDataset from torch_geometric.loader import DataLoader from torch_geometric.nn import global_mean_pool class GraphClassifier(torch.nn.Module): def __init__(self, num_features, num_classes): super().__init__() self.conv1 = GCNConv(num_features, 64) self.conv2 = GCNConv(64, 64) self.lin = torch.nn.Linear(64, num_classes) def forward(self, data): x, edge_index, batch = data.x, data.edge_index, data.batch x = self.conv1(x, edge_index) x = F.relu(x) x = self.conv2(x, edge_index) x = F.relu(x) # Global pooling (aggregate node features to graph-level) x = global_mean_pool(x, batch) x = self.lin(x) return F.log_softmax(x, dim=1) # Load dataset dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES') loader = DataLoader(dataset, batch_size=32, shuffle=True) model = GraphClassifier(dataset.num_features, dataset.num_classes) optimizer = torch.optim.Adam(model.parameters(), lr=0.01) # Training model.train() for epoch in range(100): total_loss = 0 for batch in loader: optimizer.zero_grad() out = model(batch) loss = F.nll_loss(out, batch.y) loss.backward() optimizer.step() total_loss += loss.item() if epoch % 10 == 0: print(f'Epoch {epoch}, Loss: {total_loss / len(loader):.4f}')
For large graphs, use NeighborLoader to sample subgraphs:
pythonfrom torch_geometric.loader import NeighborLoader # Create a neighbor sampler train_loader = NeighborLoader( data, num_neighbors=[25, 10], # Sample 25 neighbors for 1st hop, 10 for 2nd hop batch_size=128, input_nodes=data.train_mask, ) # Training model.train() for batch in train_loader: optimizer.zero_grad() out = model(batch) # Only compute loss on seed nodes (first batch_size nodes) loss = F.nll_loss(out[:batch.batch_size], batch.y[:batch.batch_size]) loss.backward() optimizer.step()
Important:
For graphs with multiple node and edge types, use HeteroData:
pythonfrom torch_geometric.data import HeteroData data = HeteroData() # Add node features for different types data['paper'].x = torch.randn(100, 128) # 100 papers with 128 features data['author'].x = torch.randn(200, 64) # 200 authors with 64 features # Add edges for different types (source_type, edge_type, target_type) data['author', 'writes', 'paper'].edge_index = torch.randint(0, 200, (2, 500)) data['paper', 'cites', 'paper'].edge_index = torch.randint(0, 100, (2, 300)) print(data)
Convert homogeneous models to heterogeneous:
pythonfrom torch_geometric.nn import to_hetero # Define homogeneous model model = GNN(...) # Convert to heterogeneous model = to_hetero(model, data.metadata(), aggr='sum') # Use as normal out = model(data.x_dict, data.edge_index_dict)
Or use HeteroConv for custom edge-type-specific operations:
pythonfrom torch_geometric.nn import HeteroConv, GCNConv, SAGEConv class HeteroGNN(torch.nn.Module): def __init__(self, metadata): super().__init__() self.conv1 = HeteroConv({ ('paper', 'cites', 'paper'): GCNConv(-1, 64), ('author', 'writes', 'paper'): SAGEConv((-1, -1), 64), }, aggr='sum') self.conv2 = HeteroConv({ ('paper', 'cites', 'paper'): GCNConv(64, 32), ('author', 'writes', 'paper'): SAGEConv((64, 64), 32), }, aggr='sum') def forward(self, x_dict, edge_index_dict): x_dict = self.conv1(x_dict, edge_index_dict) x_dict = {key: F.relu(x) for key, x in x_dict.items()} x_dict = self.conv2(x_dict, edge_index_dict) return x_dict
Apply transforms to modify graph structure or features:
pythonfrom torch_geometric.transforms import NormalizeFeatures, AddSelfLoops, Compose # Single transform transform = NormalizeFeatures() dataset = Planetoid(root='/tmp/Cora', name='Cora', transform=transform) # Compose multiple transforms transform = Compose([ AddSelfLoops(), NormalizeFeatures(), ]) dataset = Planetoid(root='/tmp/Cora', name='Cora', transform=transform)
Common transforms:
ToUndirected, AddSelfLoops, RemoveSelfLoops, KNNGraph, RadiusGraphNormalizeFeatures, NormalizeScale, CenterRandomNodeSplit, RandomLinkSplitAddLaplacianEigenvectorPE, AddRandomWalkPESee references/transforms_reference.md for the full list.
PyG provides explainability tools to understand model predictions:
pythonfrom torch_geometric.explain import Explainer, GNNExplainer # Create explainer explainer = Explainer( model=model, algorithm=GNNExplainer(epochs=200), explanation_type='model', # or 'phenomenon' node_mask_type='attributes', edge_mask_type='object', model_config=dict( mode='multiclass_classification', task_level='node', return_type='log_probs', ), ) # Generate explanation for a specific node node_idx = 10 explanation = explainer(data.x, data.edge_index, index=node_idx) # Visualize print(f'Node {node_idx} explanation:') print(f'Important edges: {explanation.edge_mask.topk(5).indices}') print(f'Important features: {explanation.node_mask[node_idx].topk(5).indices}')
For hierarchical graph representations:
pythonfrom torch_geometric.nn import TopKPooling, global_mean_pool class HierarchicalGNN(torch.nn.Module): def __init__(self, num_features, num_classes): super().__init__() self.conv1 = GCNConv(num_features, 64) self.pool1 = TopKPooling(64, ratio=0.8) self.conv2 = GCNConv(64, 64) self.pool2 = TopKPooling(64, ratio=0.8) self.lin = torch.nn.Linear(64, num_classes) def forward(self, data): x, edge_index, batch = data.x, data.edge_index, data.batch x = F.relu(self.conv1(x, edge_index)) x, edge_index, _, batch, _, _ = self.pool1(x, edge_index, None, batch) x = F.relu(self.conv2(x, edge_index)) x, edge_index, _, batch, _, _ = self.pool2(x, edge_index, None, batch) x = global_mean_pool(x, batch) x = self.lin(x) return F.log_softmax(x, dim=1)
python# Undirected check from torch_geometric.utils import is_undirected print(f"Is undirected: {is_undirected(data.edge_index)}") # Connected components from torch_geometric.utils import connected_components print(f"Connected components: {connected_components(data.edge_index)}") # Contains self-loops from torch_geometric.utils import contains_self_loops print(f"Has self-loops: {contains_self_loops(data.edge_index)}")
pythondevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) data = data.to(device) # For DataLoader for batch in loader: batch = batch.to(device) # Train...
python# Save torch.save(model.state_dict(), 'model.pth') # Load model = GCN(num_features, num_classes) model.load_state_dict(torch.load('model.pth')) model.eval()
When choosing layers, consider these capabilities:
See the GNN cheatsheet at references/layer_capabilities.md.
This skill includes detailed reference documentation:
references/layers_reference.md: Complete listing of all 40+ GNN layers with descriptions and capabilitiesreferences/datasets_reference.md: Comprehensive dataset catalog organized by categoryreferences/transforms_reference.md: All available transforms and their use casesreferences/api_patterns.md: Common API patterns and coding examplesUtility scripts are provided in scripts/:
scripts/visualize_graph.py: Visualize graph structure using networkx and matplotlibscripts/create_gnn_template.py: Generate boilerplate code for common GNN architecturesscripts/benchmark_model.py: Benchmark model performance on standard datasetsExecute scripts directly or read them for implementation patterns.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-24 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of -100 percentage points is the difference between those two pass rates over the 24 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.