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

custom_datasets.md

219 lines · skills/torch-geometric/references/custom_datasets.md at 5f2bc03

Open on GitHub
1# Custom graph datasets
2
3Use explicit node IDs, tensor dtypes and label shapes. `Data.validate()` checks graph structure but cannot detect a feature row attached to the wrong biological entity. Preserve the original ID-to-row mapping and test it separately. Fit feature encoders on the training data when the evaluation protocol requires an inductive split.
4

Custom graph datasets

Use explicit node IDs, tensor dtypes and label shapes. Data.validate() checks graph structure but cannot detect a feature row attached to the wrong biological entity. Preserve the original ID-to-row mapping and test it separately. Fit feature encoders on the training data when the evaluation protocol requires an inductive split.

Raw lines 1 to 4
5## InMemoryDataset
6
7This local example reads a list of tensor dictionaries from `root/raw/graphs.pt`; it performs no download. Each dictionary can contain `x`, `edge_index`, `y`, and an integer `num_nodes`. Create that file yourself from reviewed raw data before instantiating the class. Only load trusted artifacts, even when using restricted deserialization.
8
9```python
10import torch
11from torch_geometric.data import Data, InMemoryDataset
13class MyDataset(InMemoryDataset):
14 def __init__(self, root, transform=None, pre_transform=None, pre_filter=None,
15 force_reload=False):
16 super().__init__(root, transform, pre_transform, pre_filter,
17 force_reload=force_reload)
18 self.load(self.processed_paths[0])
20 @property
21 def raw_file_names(self):
22 return ['graphs.pt']
24 @property
25 def processed_file_names(self):
26 return ['data.pt']
28 def process(self):
29 records = torch.load(self.raw_paths[0], weights_only=True, map_location='cpu')
30 graphs = []
31 for record in records:
32 graph = Data.from_dict(record)
33 graph.validate(raise_on_error=True)
34 if self.pre_filter is not None and not self.pre_filter(graph):
35 continue
36 if self.pre_transform is not None:
37 graph = self.pre_transform(graph)
38 graph.validate(raise_on_error=True)
39 graphs.append(graph)
40 if not graphs:
41 raise ValueError('No graphs remain after filtering')
42 self.save(graphs, self.processed_paths[0])
43```
45`process()` is cached by `processed_file_names`; `transform` runs on access, `pre_transform` only during processing. Use a versioned cache or `force_reload=True` when raw files, feature vocabulary or preprocessing changes. `InMemoryDataset.save/load` is the current paired API; saved PyG objects are not interchangeable with an arbitrary tensor-only `torch.load` payload.

InMemoryDataset

This local example reads a list of tensor dictionaries from root/raw/graphs.pt; it performs no download. Each dictionary can contain x, edge_index, y, and an integer num_nodes. Create that file yourself from reviewed raw data before instantiating the class. Only load trusted artifacts, even when using restricted deserialization.

import torch
from torch_geometric.data import Data, InMemoryDataset

class MyDataset(InMemoryDataset):
    def __init__(self, root, transform=None, pre_transform=None, pre_filter=None,
                 force_reload=False):
        super().__init__(root, transform, pre_transform, pre_filter,
                         force_reload=force_reload)
        self.load(self.processed_paths[0])

    @property
    def raw_file_names(self):
        return ['graphs.pt']

    @property
    def processed_file_names(self):
        return ['data.pt']

    def process(self):
        records = torch.load(self.raw_paths[0], weights_only=True, map_location='cpu')
        graphs = []
        for record in records:
            graph = Data.from_dict(record)
            graph.validate(raise_on_error=True)
            if self.pre_filter is not None and not self.pre_filter(graph):
                continue
            if self.pre_transform is not None:
                graph = self.pre_transform(graph)
            graph.validate(raise_on_error=True)
            graphs.append(graph)
        if not graphs:
            raise ValueError('No graphs remain after filtering')
        self.save(graphs, self.processed_paths[0])

process() is cached by processed_file_names; transform runs on access, pre_transform only during processing. Use a versioned cache or force_reload=True when raw files, feature vocabulary or preprocessing changes. InMemoryDataset.save/load is the current paired API; saved PyG objects are not interchangeable with an arbitrary tensor-only torch.load payload.

Raw lines 5 to 46
47## Disk-backed Dataset
49This example has one tensor dictionary per `raw/graph_*.pt`; graphs are loaded and processed one at a time. A manifest indexes only the graphs retained by `pre_filter`, avoiding gaps and a false hardcoded length. Save tensor dictionaries so current `torch.load(weights_only=True)` can read them without loading a pickled `Data` class.
51```python
52import json
53from pathlib import Path
54import torch
55from torch_geometric.data import Data, Dataset
57class LargeDataset(Dataset):
58 def __init__(self, root, transform=None, pre_transform=None, pre_filter=None,
59 force_reload=False):
60 super().__init__(root, transform, pre_transform, pre_filter,
61 force_reload=force_reload)
62 self.files = json.loads(Path(self.processed_paths[0]).read_text())
63 if any(not (Path(self.processed_dir) / name).is_file() for name in self.files):
64 raise FileNotFoundError('Processed graph missing; rebuild this cache')
66 @property
67 def raw_file_names(self):
68 names = sorted(p.name for p in Path(self.raw_dir).glob('graph_*.pt'))
69 if not names:
70 raise FileNotFoundError('Put tensor dictionaries in raw/graph_*.pt')
71 return names
73 @property
74 def processed_file_names(self):
75 return ['index.json']
77 def process(self):
78 files = []
79 for raw_path in self.raw_paths:
80 graph = Data.from_dict(torch.load(raw_path, weights_only=True,
81 map_location='cpu'))
82 graph.validate(raise_on_error=True)
83 if self.pre_filter is not None and not self.pre_filter(graph):
84 continue
85 if self.pre_transform is not None:
86 graph = self.pre_transform(graph)
87 graph.validate(raise_on_error=True)
88 name = f'data_{len(files)}.pt'
89 torch.save(graph.to_dict(), Path(self.processed_dir) / name)
90 files.append(name)
91 Path(self.processed_paths[0]).write_text(json.dumps(files))
93 def len(self):
94 return len(self.files)
96 def get(self, idx):
97 record = torch.load(Path(self.processed_dir) / self.files[idx],
98 weights_only=True, map_location='cpu')
99 return Data.from_dict(record)
100```
102`get()` returns an untransformed graph; public `dataset[idx]` applies `transform`. This is a local single-writer cache example, not a concurrent or transactional database. Rebuild/version the cache when source files change.

Disk-backed Dataset

This example has one tensor dictionary per raw/graph_*.pt; graphs are loaded and processed one at a time. A manifest indexes only the graphs retained by pre_filter, avoiding gaps and a false hardcoded length. Save tensor dictionaries so current torch.load(weights_only=True) can read them without loading a pickled Data class.

import json
from pathlib import Path
import torch
from torch_geometric.data import Data, Dataset

class LargeDataset(Dataset):
    def __init__(self, root, transform=None, pre_transform=None, pre_filter=None,
                 force_reload=False):
        super().__init__(root, transform, pre_transform, pre_filter,
                         force_reload=force_reload)
        self.files = json.loads(Path(self.processed_paths[0]).read_text())
        if any(not (Path(self.processed_dir) / name).is_file() for name in self.files):
            raise FileNotFoundError('Processed graph missing; rebuild this cache')

    @property
    def raw_file_names(self):
        names = sorted(p.name for p in Path(self.raw_dir).glob('graph_*.pt'))
        if not names:
            raise FileNotFoundError('Put tensor dictionaries in raw/graph_*.pt')
        return names

    @property
    def processed_file_names(self):
        return ['index.json']

    def process(self):
        files = []
        for raw_path in self.raw_paths:
            graph = Data.from_dict(torch.load(raw_path, weights_only=True,
                                              map_location='cpu'))
            graph.validate(raise_on_error=True)
            if self.pre_filter is not None and not self.pre_filter(graph):
                continue
            if self.pre_transform is not None:
                graph = self.pre_transform(graph)
            graph.validate(raise_on_error=True)
            name = f'data_{len(files)}.pt'
            torch.save(graph.to_dict(), Path(self.processed_dir) / name)
            files.append(name)
        Path(self.processed_paths[0]).write_text(json.dumps(files))

    def len(self):
        return len(self.files)

    def get(self, idx):
        record = torch.load(Path(self.processed_dir) / self.files[idx],
                            weights_only=True, map_location='cpu')
        return Data.from_dict(record)

get() returns an untransformed graph; public dataset[idx] applies transform. This is a local single-writer cache example, not a concurrent or transactional database. Rebuild/version the cache when source files change.

Raw lines 47 to 103
104## CSV node/edge alignment
106Use a node table with unique, non-null IDs for feature-bearing nodes. A repeated interaction table can supply a featureless node universe only after explicit deduplication. Preserve string identifiers and leading zeros through `dtype=` when needed.
108```python
109import pandas as pd
110import torch
112def load_node_csv(path, index_col, encoders=None, dtype=None):
113 df = pd.read_csv(path, index_col=index_col, dtype=dtype)
114 if df.index.hasnans or not df.index.is_unique:
115 raise ValueError('Node IDs must be unique and non-null')
116 mapping = {node_id: i for i, node_id in enumerate(df.index)}
117 x = None
118 if encoders:
119 columns = [encoder(df[col]) for col, encoder in encoders.items()]
120 if any(t.ndim != 2 or t.size(0) != len(df) for t in columns):
121 raise ValueError('Every encoder must return [num_nodes, features]')
122 x = torch.cat(columns, dim=-1)
123 return x, mapping
125def load_edge_csv(path, src_index_col, src_mapping, dst_index_col, dst_mapping,
126 encoders=None, dtype=None):
127 df = pd.read_csv(path, dtype=dtype)
128 src = df[src_index_col].map(src_mapping)
129 dst = df[dst_index_col].map(dst_mapping)
130 if src.isna().any() or dst.isna().any():
131 raise ValueError('Unknown or null edge endpoint')
132 edge_index = torch.tensor([src.tolist(), dst.tolist()], dtype=torch.long)
133 edge_attr = None
134 if encoders:
135 columns = [encoder(df[col]) for col, encoder in encoders.items()]
136 if any(t.ndim != 2 or t.size(0) != len(df) for t in columns):
137 raise ValueError('Every encoder must return [num_edges, features]')
138 edge_attr = torch.cat(columns, dim=-1)
139 return edge_index, edge_attr
141class IdentityEncoder:
142 def __init__(self, dtype=torch.float32):
143 self.dtype = dtype
144 def __call__(self, series):
145 return torch.as_tensor(series.to_numpy(copy=True), dtype=self.dtype).reshape(-1, 1)
147class GenresEncoder:
148 def __init__(self, categories, sep='|'):
149 self.mapping = {name: i for i, name in enumerate(sorted(set(categories)))}
150 self.sep = sep
151 def __call__(self, series):
152 x = torch.zeros(len(series), len(self.mapping))
153 for i, value in enumerate(series):
154 if not isinstance(value, str):
155 raise ValueError('Missing/non-string genre')
156 for name in value.split(self.sep):
157 if name not in self.mapping:
158 raise ValueError(f'Unknown genre: {name}')
159 x[i, self.mapping[name]] = 1
160 return x
161```
163For MovieLens-shaped local tables, use a persisted genre vocabulary, a deduplicated `users.csv`, and explicit node counts:
165```python
166from torch_geometric.data import HeteroData
168movie_x, movies = load_node_csv('movies.csv', 'movieId',
169 encoders={'genres': GenresEncoder(['Action', 'Comedy', 'Drama'])})
170_, users = load_node_csv('users.csv', 'userId')
171edge_index, ratings = load_edge_csv('ratings.csv', 'userId', users,
172 'movieId', movies, encoders={'rating': IdentityEncoder()})
173data = HeteroData()
174data['user'].num_nodes = len(users)
175data['movie'].num_nodes = len(movies)
176data['movie'].x = movie_x
177data['user', 'rates', 'movie'].edge_index = edge_index
178data['user', 'rates', 'movie'].rating = ratings.view(-1) # Regression target
179assert data.validate(raise_on_error=True)
180```
182Featureless users still need model inputs (e.g. learned embeddings); a node count alone cannot be fed to `SAGEConv`. Rating prediction and binary link existence have different targets/splits. Do not feed held-out ratings as `edge_attr`, copy them into reverse message edges, or cast fractional ratings to integer classes.
184Optional text features (illustrative; model weights were not downloaded):
186```python
187from sentence_transformers import SentenceTransformer
189encoder = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
190# texts is a sequence of strings in exactly the persisted node-table order.
191x = encoder.encode(texts, convert_to_tensor=True).cpu()
192```
194Record the model revision, truncation and preprocessing; the model's tokenizer length can silently truncate long node descriptions.

CSV node/edge alignment

Use a node table with unique, non-null IDs for feature-bearing nodes. A repeated interaction table can supply a featureless node universe only after explicit deduplication. Preserve string identifiers and leading zeros through dtype= when needed.

import pandas as pd
import torch

def load_node_csv(path, index_col, encoders=None, dtype=None):
    df = pd.read_csv(path, index_col=index_col, dtype=dtype)
    if df.index.hasnans or not df.index.is_unique:
        raise ValueError('Node IDs must be unique and non-null')
    mapping = {node_id: i for i, node_id in enumerate(df.index)}
    x = None
    if encoders:
        columns = [encoder(df[col]) for col, encoder in encoders.items()]
        if any(t.ndim != 2 or t.size(0) != len(df) for t in columns):
            raise ValueError('Every encoder must return [num_nodes, features]')
        x = torch.cat(columns, dim=-1)
    return x, mapping

def load_edge_csv(path, src_index_col, src_mapping, dst_index_col, dst_mapping,
                  encoders=None, dtype=None):
    df = pd.read_csv(path, dtype=dtype)
    src = df[src_index_col].map(src_mapping)
    dst = df[dst_index_col].map(dst_mapping)
    if src.isna().any() or dst.isna().any():
        raise ValueError('Unknown or null edge endpoint')
    edge_index = torch.tensor([src.tolist(), dst.tolist()], dtype=torch.long)
    edge_attr = None
    if encoders:
        columns = [encoder(df[col]) for col, encoder in encoders.items()]
        if any(t.ndim != 2 or t.size(0) != len(df) for t in columns):
            raise ValueError('Every encoder must return [num_edges, features]')
        edge_attr = torch.cat(columns, dim=-1)
    return edge_index, edge_attr

class IdentityEncoder:
    def __init__(self, dtype=torch.float32):
        self.dtype = dtype
    def __call__(self, series):
        return torch.as_tensor(series.to_numpy(copy=True), dtype=self.dtype).reshape(-1, 1)

class GenresEncoder:
    def __init__(self, categories, sep='|'):
        self.mapping = {name: i for i, name in enumerate(sorted(set(categories)))}
        self.sep = sep
    def __call__(self, series):
        x = torch.zeros(len(series), len(self.mapping))
        for i, value in enumerate(series):
            if not isinstance(value, str):
                raise ValueError('Missing/non-string genre')
            for name in value.split(self.sep):
                if name not in self.mapping:
                    raise ValueError(f'Unknown genre: {name}')
                x[i, self.mapping[name]] = 1
        return x

For MovieLens-shaped local tables, use a persisted genre vocabulary, a deduplicated users.csv, and explicit node counts:

from torch_geometric.data import HeteroData

movie_x, movies = load_node_csv('movies.csv', 'movieId',
    encoders={'genres': GenresEncoder(['Action', 'Comedy', 'Drama'])})
_, users = load_node_csv('users.csv', 'userId')
edge_index, ratings = load_edge_csv('ratings.csv', 'userId', users,
    'movieId', movies, encoders={'rating': IdentityEncoder()})
data = HeteroData()
data['user'].num_nodes = len(users)
data['movie'].num_nodes = len(movies)
data['movie'].x = movie_x
data['user', 'rates', 'movie'].edge_index = edge_index
data['user', 'rates', 'movie'].rating = ratings.view(-1)  # Regression target
assert data.validate(raise_on_error=True)

Featureless users still need model inputs (e.g. learned embeddings); a node count alone cannot be fed to SAGEConv. Rating prediction and binary link existence have different targets/splits. Do not feed held-out ratings as edge_attr, copy them into reverse message edges, or cast fractional ratings to integer classes.

Optional text features (illustrative; model weights were not downloaded):

from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
# texts is a sequence of strings in exactly the persisted node-table order.
x = encoder.encode(texts, convert_to_tensor=True).cpu()

Record the model revision, truncation and preprocessing; the model's tokenizer length can silently truncate long node descriptions.

Raw lines 104 to 195
196## Conversions
198```python
199import networkx as nx
200from torch_geometric.utils import from_networkx, from_scipy_sparse_matrix
202G = nx.Graph()
203G.add_node('isolated', feature=[1.0, 0.0])
204G.add_node('connected', feature=[0.0, 1.0])
205node_order = list(G.nodes()) # PyG row order; persist this mapping.
206data = from_networkx(G, group_node_attrs=['feature'])
207```
209`from_networkx` preserves attributes under their names by default; it does **not** automatically assign `x`/`edge_attr`. Group numeric attributes explicitly (`group_node_attrs`, `group_edge_attrs`); all nodes/edges must have the same attribute keys. An undirected NetworkX graph becomes both directed edge orientations. Check node order, isolates and multiedge policy after conversion.
211```python
212edge_index, edge_weight = from_scipy_sparse_matrix(adj_matrix)
213data = Data(x=features, edge_index=edge_index, edge_weight=edge_weight,
214 num_nodes=adj_matrix.shape[0])
215```
217Rows are sources and columns targets for this COO conversion; scalar adjacency values are edge weights, and stored zeros remain edges. Confirm square shape for a homogeneous graph. Native sparse message passing may instead expect transposed adjacency (`adj_t`). One-hot node IDs or learned ID embeddings can memorize seen nodes and do not automatically generalize to new entities.
219Sources: [dataset tutorial](https://pytorch-geometric.readthedocs.io/en/latest/tutorial/create_dataset.html), [CSV tutorial](https://pytorch-geometric.readthedocs.io/en/latest/tutorial/load_csv.html), [conversion utilities](https://pytorch-geometric.readthedocs.io/en/latest/modules/utils.html), [SentenceTransformer API](https://sbert.net/docs/package_reference/sentence_transformer/SentenceTransformer.html).

Conversions

import networkx as nx
from torch_geometric.utils import from_networkx, from_scipy_sparse_matrix

G = nx.Graph()
G.add_node('isolated', feature=[1.0, 0.0])
G.add_node('connected', feature=[0.0, 1.0])
node_order = list(G.nodes())  # PyG row order; persist this mapping.
data = from_networkx(G, group_node_attrs=['feature'])

from_networkx preserves attributes under their names by default; it does not automatically assign x/edge_attr. Group numeric attributes explicitly (group_node_attrs, group_edge_attrs); all nodes/edges must have the same attribute keys. An undirected NetworkX graph becomes both directed edge orientations. Check node order, isolates and multiedge policy after conversion.

edge_index, edge_weight = from_scipy_sparse_matrix(adj_matrix)
data = Data(x=features, edge_index=edge_index, edge_weight=edge_weight,
            num_nodes=adj_matrix.shape[0])

Rows are sources and columns targets for this COO conversion; scalar adjacency values are edge weights, and stored zeros remain edges. Confirm square shape for a homogeneous graph. Native sparse message passing may instead expect transposed adjacency (adj_t). One-hot node IDs or learned ID embeddings can memorize seen nodes and do not automatically generalize to new entities.

Sources: dataset tutorial, CSV tutorial, conversion utilities, SentenceTransformer API.

Raw lines 196 to 219