Skill Tracer
Skills › Scientific Agent Skills › torch-geometric › heterogeneous.md

heterogeneous.md

255 lines · skills/torch-geometric/references/heterogeneous.md at 5f2bc03

Open on GitHub
1# Heterogeneous Graph Learning — Full Reference
2

Heterogeneous Graph Learning — Full Reference

Raw lines 1 to 2
3## Creating HeteroData
4
5```python
6from torch_geometric.data import HeteroData
7
8data = HeteroData()
9
10# Node features — keyed by node type string
11data['paper'].x = ... # [num_papers, num_features_paper]
12data['author'].x = ... # [num_authors, num_features_author]
13data['institution'].x = ... # [num_institutions, num_features_institution]
15# Edge indices — keyed by (source_type, edge_type, dest_type) triplet
16data['paper', 'cites', 'paper'].edge_index = ... # [2, num_edges]
17data['author', 'writes', 'paper'].edge_index = ... # [2, num_edges]
18data['author', 'affiliated_with', 'institution'].edge_index = ... # [2, num_edges]
20# Edge features (optional)
21data['paper', 'cites', 'paper'].edge_attr = ... # [num_edges, num_edge_features]
23# Additional node attributes
24data['paper'].y = ... # labels
25data['paper'].train_mask = ... # boolean mask
26```

Creating HeteroData

from torch_geometric.data import HeteroData

data = HeteroData()

# Node features — keyed by node type string
data['paper'].x = ...       # [num_papers, num_features_paper]
data['author'].x = ...      # [num_authors, num_features_author]
data['institution'].x = ... # [num_institutions, num_features_institution]

# Edge indices — keyed by (source_type, edge_type, dest_type) triplet
data['paper', 'cites', 'paper'].edge_index = ...              # [2, num_edges]
data['author', 'writes', 'paper'].edge_index = ...            # [2, num_edges]
data['author', 'affiliated_with', 'institution'].edge_index = ... # [2, num_edges]

# Edge features (optional)
data['paper', 'cites', 'paper'].edge_attr = ...  # [num_edges, num_edge_features]

# Additional node attributes
data['paper'].y = ...           # labels
data['paper'].train_mask = ...  # boolean mask
Raw lines 3 to 27
28### Accessing data
30```python
31# Single store access
32data['paper'] # NodeStore for papers
33data['paper', 'cites', 'paper'] # EdgeStore for cites edges
34data['paper', 'paper'] # Shorthand if edge type is unambiguous
35data['cites'] # Shorthand if edge type name is unique
37# Dict access for model input
38data.x_dict # {'paper': tensor, 'author': tensor, ...}
39data.edge_index_dict # {('paper','cites','paper'): tensor, ...}
40data.edge_attr_dict
42# Metadata
43node_types, edge_types = data.metadata()
45# Modify
46data['paper'].year = ... # Add new attribute
47del data['field_of_study'] # Delete node type
48del data['has_topic'] # Delete edge type
50# Convert
51data.to('cuda:0') # Transfer to GPU
52data.to_homogeneous() # Convert to typed homogeneous graph
53```

Accessing data

# Single store access
data['paper']                          # NodeStore for papers
data['paper', 'cites', 'paper']       # EdgeStore for cites edges
data['paper', 'paper']                 # Shorthand if edge type is unambiguous
data['cites']                          # Shorthand if edge type name is unique

# Dict access for model input
data.x_dict                            # {'paper': tensor, 'author': tensor, ...}
data.edge_index_dict                   # {('paper','cites','paper'): tensor, ...}
data.edge_attr_dict

# Metadata
node_types, edge_types = data.metadata()

# Modify
data['paper'].year = ...               # Add new attribute
del data['field_of_study']             # Delete node type
del data['has_topic']                  # Delete edge type

# Convert
data.to('cuda:0')                      # Transfer to GPU
data.to_homogeneous()                  # Convert to typed homogeneous graph
Raw lines 28 to 54
55### Transforms on HeteroData
57```python
58import torch_geometric.transforms as T
60data = T.ToUndirected()(data) # Add reverse edge types
61data = T.AddSelfLoops()(data) # Add self-loops for same-type edges
62data = T.NormalizeFeatures()(data) # Normalize features across all types
63```
65`ToUndirected()` is important — it creates reverse edge types (e.g., `('paper', 'rev_writes', 'author')`) so messages flow in both directions.

Transforms on HeteroData

import torch_geometric.transforms as T

data = T.ToUndirected()(data)       # Add reverse edge types
data = T.AddSelfLoops()(data)       # Add self-loops for same-type edges
data = T.NormalizeFeatures()(data)  # Normalize features across all types

ToUndirected() is important — it creates reverse edge types (e.g., ('paper', 'rev_writes', 'author')) so messages flow in both directions.

Raw lines 55 to 66
67## Building Heterogeneous GNN Models

Building Heterogeneous GNN Models

Raw lines 67 to 68
69### Option 1: Auto-convert with `to_hetero()`
71Write a standard homogeneous GNN, then convert:
73```python
74import torch
75from torch_geometric.nn import SAGEConv, to_hetero
76import torch_geometric.transforms as T
77from torch_geometric.datasets import OGB_MAG
79# Illustrative large download; preserve the OGB split and embedding provenance.
80dataset = OGB_MAG(root='./data', preprocess='metapath2vec', transform=T.ToUndirected())
81data = dataset[0]
83class GNN(torch.nn.Module):
84 def __init__(self, hidden_channels, out_channels):
85 super().__init__()
86 # Use (-1, -1) for lazy init with bipartite support
87 self.conv1 = SAGEConv((-1, -1), hidden_channels)
88 self.conv2 = SAGEConv((-1, -1), out_channels)
90 def forward(self, x, edge_index):
91 x = self.conv1(x, edge_index).relu()
92 x = self.conv2(x, edge_index)
93 return x
95model = GNN(64, dataset.num_classes)
96model = to_hetero(model, data.metadata(), aggr='sum')
98# Initialize lazy modules
99with torch.no_grad():
100 out = model(data.x_dict, data.edge_index_dict)
101```
103With skip-connections (important for attention-based models):
105```python
106from torch_geometric.nn import GATConv, Linear, to_hetero
108class GAT(torch.nn.Module):
109 def __init__(self, hidden_channels, out_channels):
110 super().__init__()
111 self.conv1 = GATConv((-1, -1), hidden_channels, add_self_loops=False)
112 self.lin1 = Linear(-1, hidden_channels)
113 self.conv2 = GATConv((-1, -1), out_channels, add_self_loops=False)
114 self.lin2 = Linear(-1, out_channels)
116 def forward(self, x, edge_index):
117 # Skip connection replaces self-loops for bipartite message passing
118 x = self.conv1(x, edge_index) + self.lin1(x)
119 x = x.relu()
120 x = self.conv2(x, edge_index) + self.lin2(x)
121 return x
123model = GAT(64, dataset.num_classes)
124model = to_hetero(model, data.metadata(), aggr='sum')
125```

Option 1: Auto-convert with to_hetero()

Write a standard homogeneous GNN, then convert:

import torch
from torch_geometric.nn import SAGEConv, to_hetero
import torch_geometric.transforms as T
from torch_geometric.datasets import OGB_MAG

# Illustrative large download; preserve the OGB split and embedding provenance.
dataset = OGB_MAG(root='./data', preprocess='metapath2vec', transform=T.ToUndirected())
data = dataset[0]

class GNN(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        # Use (-1, -1) for lazy init with bipartite support
        self.conv1 = SAGEConv((-1, -1), hidden_channels)
        self.conv2 = SAGEConv((-1, -1), out_channels)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        x = self.conv2(x, edge_index)
        return x

model = GNN(64, dataset.num_classes)
model = to_hetero(model, data.metadata(), aggr='sum')

# Initialize lazy modules
with torch.no_grad():
    out = model(data.x_dict, data.edge_index_dict)

With skip-connections (important for attention-based models):

from torch_geometric.nn import GATConv, Linear, to_hetero

class GAT(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = GATConv((-1, -1), hidden_channels, add_self_loops=False)
        self.lin1 = Linear(-1, hidden_channels)
        self.conv2 = GATConv((-1, -1), out_channels, add_self_loops=False)
        self.lin2 = Linear(-1, out_channels)

    def forward(self, x, edge_index):
        # Skip connection replaces self-loops for bipartite message passing
        x = self.conv1(x, edge_index) + self.lin1(x)
        x = x.relu()
        x = self.conv2(x, edge_index) + self.lin2(x)
        return x

model = GAT(64, dataset.num_classes)
model = to_hetero(model, data.metadata(), aggr='sum')
Raw lines 69 to 126
127### Option 2: HeteroConv wrapper (different conv per edge type)
129```python
130from torch_geometric.nn import HeteroConv, GCNConv, SAGEConv, GATConv, Linear
132class HeteroGNN(torch.nn.Module):
133 def __init__(self, hidden_channels, out_channels, num_layers):
134 super().__init__()
136 self.convs = torch.nn.ModuleList()
137 for _ in range(num_layers):
138 conv = HeteroConv({
139 ('paper', 'cites', 'paper'): GCNConv(-1, hidden_channels),
140 ('author', 'writes', 'paper'): SAGEConv((-1, -1), hidden_channels),
141 ('paper', 'rev_writes', 'author'): GATConv((-1, -1), hidden_channels,
142 add_self_loops=False),
143 }, aggr='sum')
144 self.convs.append(conv)
146 self.lin = Linear(hidden_channels, out_channels)
148 def forward(self, x_dict, edge_index_dict):
149 for conv in self.convs:
150 x_dict = conv(x_dict, edge_index_dict)
151 x_dict = {key: x.relu() for key, x in x_dict.items()}
152 return self.lin(x_dict['paper'])
154model = HeteroGNN(64, dataset.num_classes, num_layers=2)
155with torch.no_grad():
156 out = model(data.x_dict, data.edge_index_dict)
157```

Option 2: HeteroConv wrapper (different conv per edge type)

from torch_geometric.nn import HeteroConv, GCNConv, SAGEConv, GATConv, Linear

class HeteroGNN(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels, num_layers):
        super().__init__()

        self.convs = torch.nn.ModuleList()
        for _ in range(num_layers):
            conv = HeteroConv({
                ('paper', 'cites', 'paper'): GCNConv(-1, hidden_channels),
                ('author', 'writes', 'paper'): SAGEConv((-1, -1), hidden_channels),
                ('paper', 'rev_writes', 'author'): GATConv((-1, -1), hidden_channels,
                                                            add_self_loops=False),
            }, aggr='sum')
            self.convs.append(conv)

        self.lin = Linear(hidden_channels, out_channels)

    def forward(self, x_dict, edge_index_dict):
        for conv in self.convs:
            x_dict = conv(x_dict, edge_index_dict)
            x_dict = {key: x.relu() for key, x in x_dict.items()}
        return self.lin(x_dict['paper'])

model = HeteroGNN(64, dataset.num_classes, num_layers=2)
with torch.no_grad():
    out = model(data.x_dict, data.edge_index_dict)
Raw lines 127 to 158
159### Option 3: HGTConv (native heterogeneous operator)
161```python
162from torch_geometric.nn import HGTConv, Linear
164class HGT(torch.nn.Module):
165 def __init__(self, hidden_channels, out_channels, num_heads, num_layers):
166 super().__init__()
168 self.lin_dict = torch.nn.ModuleDict()
169 for node_type in data.node_types:
170 self.lin_dict[node_type] = Linear(-1, hidden_channels)
172 self.convs = torch.nn.ModuleList()
173 for _ in range(num_layers):
174 conv = HGTConv(hidden_channels, hidden_channels, data.metadata(),
175 heads=num_heads)
176 self.convs.append(conv)
178 self.lin = Linear(hidden_channels, out_channels)
180 def forward(self, x_dict, edge_index_dict):
181 x_dict = {key: self.lin_dict[key](x).relu()
182 for key, x in x_dict.items()}
183 for conv in self.convs:
184 x_dict = conv(x_dict, edge_index_dict)
185 return self.lin(x_dict['paper'])
186```
188`HGTConv` uses `heads=`, not `num_heads=` or `group=`; hidden width must be divisible by the head count. Every node type needed in the next layer must receive messages. Add semantically justified reverse relations (and pair them during link splits), or explicitly handle missing outputs. `HeteroConv`/HGT examples here return paper logits directly; `to_hetero` returns a dictionary.

Option 3: HGTConv (native heterogeneous operator)

from torch_geometric.nn import HGTConv, Linear

class HGT(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels, num_heads, num_layers):
        super().__init__()

        self.lin_dict = torch.nn.ModuleDict()
        for node_type in data.node_types:
            self.lin_dict[node_type] = Linear(-1, hidden_channels)

        self.convs = torch.nn.ModuleList()
        for _ in range(num_layers):
            conv = HGTConv(hidden_channels, hidden_channels, data.metadata(),
                           heads=num_heads)
            self.convs.append(conv)

        self.lin = Linear(hidden_channels, out_channels)

    def forward(self, x_dict, edge_index_dict):
        x_dict = {key: self.lin_dict[key](x).relu()
                  for key, x in x_dict.items()}
        for conv in self.convs:
            x_dict = conv(x_dict, edge_index_dict)
        return self.lin(x_dict['paper'])

HGTConv uses heads=, not num_heads= or group=; hidden width must be divisible by the head count. Every node type needed in the next layer must receive messages. Add semantically justified reverse relations (and pair them during link splits), or explicitly handle missing outputs. HeteroConv/HGT examples here return paper logits directly; to_hetero returns a dictionary.

Raw lines 159 to 189
190## Training with HeteroData
192These loops use the `to_hetero` model from Option 1, which returns a dict. With Option 2/3, use its tensor output directly instead of indexing `["paper"]`. Move the model and batch to the same device before optimization; select models on validation labels only.
194```python
195import torch.nn.functional as F
196```

Training with HeteroData

These loops use the to_hetero model from Option 1, which returns a dict. With Option 2/3, use its tensor output directly instead of indexing ["paper"]. Move the model and batch to the same device before optimization; select models on validation labels only.

import torch.nn.functional as F
Raw lines 190 to 197
198### Full-batch
200```python
201def train():
202 model.train()
203 optimizer.zero_grad()
204 out = model(data.x_dict, data.edge_index_dict)
205 mask = data['paper'].train_mask
206 loss = F.cross_entropy(out['paper'][mask], data['paper'].y[mask])
207 loss.backward()
208 optimizer.step()
209 return float(loss)
210```

Full-batch

def train():
    model.train()
    optimizer.zero_grad()
    out = model(data.x_dict, data.edge_index_dict)
    mask = data['paper'].train_mask
    loss = F.cross_entropy(out['paper'][mask], data['paper'].y[mask])
    loss.backward()
    optimizer.step()
    return float(loss)
Raw lines 198 to 211
212### Mini-batch with NeighborLoader
214```python
215from torch_geometric.loader import NeighborLoader
217train_loader = NeighborLoader(
218 data,
219 num_neighbors=[15] * 2, # per hop (applies to all edge types)
220 batch_size=128,
221 input_nodes=('paper', data['paper'].train_mask),
224# Fine-grained neighbor control per edge type:
225# num_neighbors = {key: [15] * 2 for key in data.edge_types}
227def train():
228 model.train()
229 total_examples = total_loss = 0
230 for batch in train_loader:
231 optimizer.zero_grad()
232 batch = batch.to(device)
233 batch_size = batch['paper'].batch_size
234 out = model(batch.x_dict, batch.edge_index_dict)
235 loss = F.cross_entropy(out['paper'][:batch_size],
236 batch['paper'].y[:batch_size])
237 loss.backward()
238 optimizer.step()
239 total_examples += batch_size
240 total_loss += float(loss) * batch_size
241 return total_loss / total_examples
242```
244HGTLoader is also available for type-aware sampling:
246```python
247from torch_geometric.loader import HGTLoader
249loader = HGTLoader(data, num_samples=[512] * 2, batch_size=128,
250 input_nodes=('paper', data['paper'].train_mask))
251```
253Sampling examples require optional backends (`pyg-lib`/`torch-sparse` for NeighborLoader, `torch-sparse` for HGTLoader); homogeneous NeighborLoader sampling was exercised with pyg-lib; these heterogeneous sampling snippets and HGTLoader were source-checked only. Do not use target labels as node features. Pretrained structural features can also encode topology unavailable in an inductive/temporal task.
255Source: [heterogeneous tutorial](https://pytorch-geometric.readthedocs.io/en/latest/tutorial/heterogeneous.html), [HGTConv](https://pytorch-geometric.readthedocs.io/en/latest/generated/torch_geometric.nn.conv.HGTConv.html).

Mini-batch with NeighborLoader

from torch_geometric.loader import NeighborLoader

train_loader = NeighborLoader(
    data,
    num_neighbors=[15] * 2,              # per hop (applies to all edge types)
    batch_size=128,
    input_nodes=('paper', data['paper'].train_mask),
)

# Fine-grained neighbor control per edge type:
# num_neighbors = {key: [15] * 2 for key in data.edge_types}

def train():
    model.train()
    total_examples = total_loss = 0
    for batch in train_loader:
        optimizer.zero_grad()
        batch = batch.to(device)
        batch_size = batch['paper'].batch_size
        out = model(batch.x_dict, batch.edge_index_dict)
        loss = F.cross_entropy(out['paper'][:batch_size],
                               batch['paper'].y[:batch_size])
        loss.backward()
        optimizer.step()
        total_examples += batch_size
        total_loss += float(loss) * batch_size
    return total_loss / total_examples

HGTLoader is also available for type-aware sampling:

from torch_geometric.loader import HGTLoader

loader = HGTLoader(data, num_samples=[512] * 2, batch_size=128,
                   input_nodes=('paper', data['paper'].train_mask))

Sampling examples require optional backends (pyg-lib/torch-sparse for NeighborLoader, torch-sparse for HGTLoader); homogeneous NeighborLoader sampling was exercised with pyg-lib; these heterogeneous sampling snippets and HGTLoader were source-checked only. Do not use target labels as node features. Pretrained structural features can also encode topology unavailable in an inductive/temporal task.

Source: heterogeneous tutorial, HGTConv.

Raw lines 212 to 255