TGraphX Insights The TGraphX Graph and GraphBatch: How Tensor Graphs Are Stored and Batched
← Back to Insights

The TGraphX Graph and GraphBatch: How Tensor Graphs Are Stored and Batched

Target keyword: tgraphx graphbatch

The TGraphX Graph and GraphBatch: How Tensor Graphs Are Stored and Batched

If you work with TGraphX for more than a few minutes, two classes do most of the heavy lifting: Graph, which holds a single graph, and GraphBatch, which packs several graphs into one super-graph for efficient training. Understanding their fields and their batching rules removes most of the confusion that newcomers hit, because the framework is deliberately strict: it would rather raise an error at construction time than silently mangle your data. This tutorial walks through both objects as they exist in tgraphx/core/graph.py.

For the conceptual background on why nodes carry tensors at all, see Reading the TGraphX Source.

The Graph object

A Graph is constructed from node features plus an optional edge structure and labels:

python
import torch
        import tgraphx as tgx
        
        x = torch.randn(100, 3, 8, 8)                 # 100 nodes, each a [3, 8, 8] tensor
        edge_index = torch.randint(0, 100, (2, 300))  # 300 directed edges
        labels = torch.randint(0, 10, (100,))
        
        g = tgx.Graph(x=x, edge_index=edge_index, labels=labels)
        print(g.num_nodes, g.num_edges, g.feature_shape)   # 100 300 (3, 8, 8)
        

The constructor accepts PyG-style aliases so code generated from muscle memory tends to work: x resolves to node_features, y or labels to node_labels, and edge_attr to edge_features. Providing two conflicting aliases (say both y and labels) raises a ValueError rather than guessing. The same object exposes those aliases back as read/write properties — g.x, g.y, g.edge_attr — alongside descriptive ones:

Field / property Meaning Shape
node_features / x per-node tensor state [N, ...] (any rank)
edge_index directed edges as source/target rows [2, E]
edge_features / edge_attr per-edge tensor [E, ...] or None
node_labels / y / labels per-node targets [N, ...] or None
edge_labels per-edge targets [E, ...] or None
metadata arbitrary dict carried with the graph dict or None
feature_shape the per-node trailing shape tuple
has_edge_features whether edge tensors are present bool

Train/validation/test masks are not loose globals — when you pass train_mask=, val_mask=, or test_mask=, they are stored inside metadata['masks'] so they travel with the graph through moves and serialization. That single decision prevents a common bug where a split silently detaches from the data it describes.

Validation happens at construction

Every optional tensor is checked the moment you build the graph. In the source, the constructor calls validate_edge_index, validate_edge_weight, validate_edge_features, and a label checker before the object exists. A mask whose length does not match the node count, an edge_index that points past the last node, or labels with the wrong leading dimension all fail immediately with a message that names the offending tensor. This is the same philosophy covered in Shape-Aware Validation: catch the shape error where it is cheap to fix, not three layers deep in a training loop.

Batching: the disjoint union

GNN training batches graphs by placing them side by side as one large graph with no edges between the pieces — a disjoint union. GraphBatch does this in tgraphx/core/graph.py:

python
batch = tgx.GraphBatch([g1, g2, g3])
        print(batch.num_graphs)        # 3
        print(batch.node_features.shape)   # [N1 + N2 + N3, 3, 8, 8]
        print(batch.batch.shape)       # [N1 + N2 + N3]
        

Two things make this usable. First, edge_index is re-based: graph 2's edges are offset by N1, graph 3's by N1 + N2, so a single [2, E_total] tensor still points at the right nodes. Second, the object carries a batch vector of length N_total that maps each node back to its source graph index — exactly what pooling layers and graph-level readouts need to know which nodes belong together.

Quantity Per graph Batched
node features [Nᵢ, C, H, W] [ΣNᵢ, C, H, W]
edge index [2, Eᵢ], local ids [2, ΣEᵢ], offset ids
graph labels scalar / [...] [B, ...] stacked
node→graph map implicit batch [ΣNᵢ]

The strictness is a feature

GraphBatch validates compatibility before concatenating. All graphs must share the same per-node feature shape (node_features.shape[1:]); if graph 2 has [3, 8, 8] nodes and graph 0 has [3, 16, 16], you get a ValueError telling you to resize or pad first. The same rule applies to edge features. The source comment is blunt about why: "mixing-some-with-none is rejected rather than silently dropped, because silently dropping per-edge data is a footgun." If you have ever lost edge attributes to a quiet broadcast, you will appreciate the explicit failure.

For iterating over many graphs, tgraphx/core/dataloader.py provides GraphDataset and GraphDataLoader, which collate lists of Graph objects into GraphBatch instances so you can write an ordinary PyTorch training loop.

A small debugging tip

When a batch refuses to build, print the per-graph shapes before guessing: [tuple(g.feature_shape) for g in graphs]. Nine times out of ten the culprit is one graph whose node tensors were resized differently upstream. Because the failure is raised at GraphBatch construction with the offending index named, you rarely need a debugger.

Related guides

Conclusion

Graph stores one tensor-valued graph and validates it on construction; GraphBatch packs many into a disjoint union with re-based edges and a batch vector, refusing to proceed when shapes disagree. Both behaviours come straight from tgraphx/core/graph.py. Once you internalise the disjoint-union model and the shared-shape rule, batching tensor graphs stops being mysterious and the framework's error messages start reading like helpful advice rather than obstacles.