This is a read-only view of the notebook source: text and code, no outputs. To run it, open the live marimo server when the workstation tunnel is up, or run uv run marimo edit notebooks/tutorials/PyG-Tutorial.py.
PyTorch Geometric on an Intel GPU
A hands-on, interactive introduction to graph neural networks (GNNs) with PyG (PyTorch Geometric), running on the Intel Arc iGPU through PyTorch's XPU backend.
| chapter | what you'll learn | interactive bits |
|---|---|---|
| 0 · Meet PyG | what GNNs are, why graphs need their own library, PyG's building blocks | task explorer |
| 1 · Graphs as tensors | the Data object, message passing, GCN normalization, writing a layer from scratch |
edit a graph, step through message passing |
| 2 · The Cora dataset | citation networks, features, splits, homophily | k-hop neighbourhood explorer |
| 3 · The algorithms | GCN, GAT, GraphSAGE, MLP baseline, training techniques | tabs & accordions |
| 4 · Train & compare | full training loop, model comparison over seeds | live hyper-parameter controls |
| 5 · Look inside | predictions, attention weights, learned embeddings | node inspector, t-SNE |
| 6 · Going deeper | over-smoothing, GPU vs CPU benchmark, quiz | experiments on demand |
On Intel GPUs the device string is
"xpu", not"cuda". Everything else is ordinary PyTorch.
Heavy cells (training, experiments) only run when you click their button, so feel free to play.
import time import altair as alt import marimo as mo import networkx as nx import numpy as np import pandas as pd import torch import torch.nn.functional as F import torch_geometric from sklearn.manifold import TSNE from torch_geometric.data import Data from torch_geometric.datasets import Planetoid from torch_geometric.nn import GATConv, GCNConv, MessagePassing, SAGEConv from torch_geometric.transforms import NormalizeFeatures from torch_geometric.utils import ( add_self_loops, degree, homophily, k_hop_subgraph, scatter, to_dense_adj, )
def pick_device() -> torch.device: """Prefer the Intel GPU (XPU), then NVIDIA (CUDA), then CPU.""" if torch.xpu.is_available(): return torch.device("xpu") if torch.cuda.is_available(): return torch.device("cuda") return torch.device("cpu") device = pick_device() if device.type == "xpu": _name = torch.xpu.get_device_name(0) _mem = torch.xpu.get_device_properties(0).total_memory / 1e9 _detail = f"**{_name}**, {_mem:.1f} GB (shared with system RAM)" elif device.type == "cuda": _detail = f"**{torch.cuda.get_device_name(0)}**" else: _detail = "no GPU found, falling back to CPU" mo.callout( mo.md( f""" **Device:** `{device}`, {_detail} torch `{torch.__version__}` · PyG `{torch_geometric.__version__}` """ ), kind="success" if device.type != "cpu" else "warn", )
def draw_graph( · 62 lines
def draw_graph( edge_index, pos, nodes, color, title="", size=320, width=380, height=280, show_ids=True, ): """Draw a graph with Altair. edge_index: [2, E] tensor (directed pairs; each undirected edge is drawn once) pos: {node_id: (x, y)} layout, e.g. from networkx.spring_layout nodes: DataFrame with a `node` column plus anything used for color/tooltip color: an Altair color encoding (string shorthand or alt.Color) """ pairs = np.unique(np.sort(edge_index.cpu().numpy().T, axis=1), axis=0) pairs = pairs[ pairs[:, 0] != pairs[:, 1] ] # drop self-loops from the drawing edges = pd.DataFrame( { "x": [pos[s][0] for s, _ in pairs], "y": [pos[s][1] for s, _ in pairs], "x2": [pos[t][0] for _, t in pairs], "y2": [pos[t][1] for _, t in pairs], } ) nodes = nodes.assign( x=[pos[n][0] for n in nodes["node"]], y=[pos[n][1] for n in nodes["node"]], ) x_enc = alt.X( "x:Q", axis=None, scale=alt.Scale(zero=False, padding=20) ) y_enc = alt.Y( "y:Q", axis=None, scale=alt.Scale(zero=False, padding=20) ) layers = [ alt.Chart(edges) .mark_rule(color="gray", opacity=0.45) .encode(x=x_enc, y=y_enc, x2="x2:Q", y2="y2:Q"), alt.Chart(nodes) .mark_circle(size=size, opacity=1, stroke="white", strokeWidth=1.5) .encode( x=x_enc, y=y_enc, color=color, tooltip=[c for c in nodes.columns if c not in ("x", "y")], ), ] if show_ids: layers.append( alt.Chart(nodes) .mark_text(fontSize=11, fontWeight="bold", color="white") .encode(x=x_enc, y=y_enc, text="node:N") ) return alt.layer(*layers).properties( title=title, width=width, height=height )
0 · Meet PyG
Why graphs?
Lots of data is really about relationships: papers citing papers, users following users (your Twitter/X data is a graph!), atoms bonded in a molecule, roads between cities, products bought together. A graph \(G = (V, E)\) has nodes \(V\) (often with feature vectors) and edges \(E\) between them.
Why do graphs need a special library?
Images and text sit on regular grids: every pixel has exactly 8 neighbours and every word one predecessor. Graphs don't:
- irregular neighbourhoods: one node may have 1 neighbour, another 10,000
- no natural order: renumbering the nodes must not change the answer (permutation invariance)
- sparse: most node pairs are not connected, so a dense \(N \times N\) adjacency matrix wastes memory
- variable size: a batch of molecules contains graphs with different node counts
A GNN handles all of this with one idea, message passing: every node repeatedly collects messages from its neighbours and updates its own vector. Stack \(k\) layers and a node knows about its \(k\)-hop neighbourhood.
What is PyG?
PyTorch Geometric (Fey & Lenssen, 2019) is the most widely used GNN library for PyTorch. It provides
sparse, GPU-friendly scatter/gather kernels plus a large collection of ready-made layers and datasets.
Since it is just PyTorch, models are torch.nn.Modules, training is a normal loop, and .to("xpu") works.
mo.vstack( · 37 lines
mo.vstack( [ mo.md("### PyG's building blocks"), mo.mermaid( """ graph LR A["datasets<br/>Planetoid, TUDataset, OGB..."] --> B["transforms<br/>NormalizeFeatures, ToUndirected..."] B --> C["data.Data / HeteroData<br/>x, edge_index, y, masks"] C --> D["loader<br/>DataLoader, NeighborLoader"] D --> E["nn<br/>GCNConv, GATConv, SAGEConv...<br/>(all built on MessagePassing)"] E --> F["loss + optimizer<br/>plain PyTorch"] C -. helpers .- G["utils<br/>degree, k_hop_subgraph,<br/>to_networkx, scatter..."] """ ), mo.md( r""" | module | what it gives you | used in this notebook | |---|---|---| | `torch_geometric.data` | `Data` (one graph), `HeteroData` (several node/edge types), `Batch` | toy graph, Cora | | `torch_geometric.datasets` | 100+ benchmark datasets that download themselves | `Planetoid("Cora")` | | `torch_geometric.transforms` | preprocessing applied when a graph is loaded | `NormalizeFeatures()` | | `torch_geometric.nn` | 60+ conv layers, pooling, normalization, full models | `GCNConv`, `GATConv`, `SAGEConv`, `MessagePassing` | | `torch_geometric.loader` | mini-batching many small graphs, or sampling subgraphs of one huge graph | (explained in §3) | | `torch_geometric.utils` | graph helpers: degrees, self-loops, subgraphs, conversions | `degree`, `k_hop_subgraph`, `homophily` | | `torch_geometric.explain` | explain predictions (GNNExplainer, ...) | (next steps) | Every conv layer follows the same **message passing** template, which you'll implement yourself in §1: $$ \mathbf{x}_i' \;=\; \gamma\Big(\mathbf{x}_i,\; \bigoplus_{j \in \mathcal{N}(i)} \phi\big(\mathbf{x}_i, \mathbf{x}_j, \mathbf{e}_{ji}\big)\Big) $$ $\phi$ = **message** function, $\bigoplus$ = **aggregation** (sum / mean / max), $\gamma$ = **update** function. """ ), ] )
_node = mo.md( · 52 lines
_node = mo.md( r""" **Predict a label for each node.** *Example:* the topic of each paper in a citation network, or whether a social-media account is a bot. Usually *semi-supervised*: only some nodes are labelled. ```python out = model(data.x, data.edge_index) # [num_nodes, num_classes] loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask]) ``` **This notebook does node classification on Cora.** """ ) _link = mo.md( r""" **Predict whether an edge exists (or will).** *Example:* friend recommendation, drug–target interaction, "users who bought X also bought Y". ```python z = model.encode(data.x, data.edge_index) # node embeddings score = (z[src] * z[dst]).sum(dim=-1) # dot product per candidate edge loss = F.binary_cross_entropy_with_logits(score, edge_label) ``` Helpers: `transforms.RandomLinkSplit`, `utils.negative_sampling`. """ ) _graph = mo.md( r""" **Predict one label for a whole graph.** *Example:* is this molecule toxic? Which community does this sub-network belong to? ```python from torch_geometric.loader import DataLoader from torch_geometric.nn import global_mean_pool for batch in DataLoader(dataset, batch_size=32): # many small graphs -> one big disconnected graph h = model(batch.x, batch.edge_index) g = global_mean_pool(h, batch.batch) # [num_graphs, hidden] ``` Datasets: `TUDataset("MUTAG")`, `MoleculeNet`, OGB. """ ) mo.vstack( [ mo.md("### Three kinds of graph tasks"), mo.ui.tabs( { "Node classification": _node, "Link prediction": _link, "Graph classification": _graph, } ), ] )
1 · Graphs as tensors
A torch_geometric.data.Data object is a small container of tensors:
| attribute | shape | meaning |
|---|---|---|
x |
[num_nodes, num_features] |
node feature matrix |
edge_index |
[2, num_edges] |
edges in COO format: row 0 = source node, row 1 = target node |
edge_attr |
[num_edges, num_edge_features] |
optional edge features (bond type, weight, ...) |
y |
[num_nodes] or [1] |
labels (per node or per graph) |
PyG stores edges as a list, not a matrix: memory grows with the number of edges, not nodes².
Undirected edges are stored twice (0→1 and 1→0) so messages can flow both ways.
Edit the edge list below and watch every tensor, the degree vector and the drawing update.
toy_edges_text = mo.ui.text_area( · 7 lines
toy_edges_text = mo.ui.text_area( value="0-1, 1-2, 2-0, 2-3, 3-4, 4-5, 5-3", label="Undirected edges (`a-b`, separated by commas or new lines)", full_width=True, rows=2, ) toy_edges_text
def parse_edges(text: str) -> list[tuple[int, int]]: · 82 lines
def parse_edges(text: str) -> list[tuple[int, int]]: pairs = [] for token in text.replace("\n", ",").split(","): if "-" in token: a, b = token.split("-", 1) pairs.append((int(a.strip()), int(b.strip()))) return pairs try: _pairs = parse_edges(toy_edges_text.value) except ValueError: _pairs = [] mo.stop( not _pairs, mo.callout( mo.md("Couldn't parse any edges. Use the form `0-1, 1-2`."), kind="danger", ), ) # Store each undirected edge in both directions toy_edge_index = torch.tensor( [ [a for a, b in _pairs] + [b for a, b in _pairs], [b for a, b in _pairs] + [a for a, b in _pairs], ] ) _n = int(toy_edge_index.max()) + 1 # Small integer features so the arithmetic below is easy to follow toy_x = torch.randint( 0, 6, (_n, 2), generator=torch.Generator().manual_seed(1) ).float() toy_graph = Data(x=toy_x, edge_index=toy_edge_index).to(device) toy_pos = nx.spring_layout(nx.Graph(_pairs), seed=7) for _i in range( _n ): # isolated node ids (gaps in numbering) still need a position toy_pos.setdefault(_i, (0.0, 0.0)) _deg = degree(toy_graph.edge_index[1], num_nodes=_n).int().cpu() _nodes = pd.DataFrame( { "node": range(_n), "degree": _deg.numpy(), "x": [str(v.tolist()) for v in toy_x], } ) mo.hstack( [ draw_graph( toy_graph.edge_index, toy_pos, _nodes.rename(columns={"x": "features"}), color=alt.Color( "degree:O", scale=alt.Scale(scheme="blues"), legend=alt.Legend(title="degree"), ), title="Your graph (color = degree)", ), mo.md( f""" `{toy_graph}` **`x`** (features, one row per node): ``` {toy_x.int().tolist()} ``` **`edge_index`** (`{toy_graph.num_edges}` directed edges = 2 × {len(_pairs)}): ``` sources: {toy_edge_index[0].tolist()} targets: {toy_edge_index[1].tolist()} ``` **degree**: `{_deg.tolist()}` · undirected: **{toy_graph.is_undirected()}** · isolated nodes: **{toy_graph.has_isolated_nodes()}** · device: `{toy_graph.x.device}` """ ), ], widths=[1, 1], align="start", )
Message passing, step by step
One round of message passing for node \(i\):
- message: every neighbour \(j\) sends a vector to \(i\) (here simply its features \(\mathbf{x}_j\))
-
aggregate: combine the incoming messages with a permutation-invariant function (sum, mean or max), so the result doesn't depend on neighbour order
-
update: produce the new vector \(\mathbf{x}_i'\) (here: just the aggregate; real layers add a linear map + nonlinearity)
Adding self-loops lets a node keep its own information in the mix. Pick a node and an aggregation:
mp_node = mo.ui.dropdown( · 10 lines
mp_node = mo.ui.dropdown( {str(i): i for i in range(toy_graph.num_nodes)}, value="2", label="target node", ) mp_aggr = mo.ui.radio( ["sum", "mean", "max"], value="mean", label="aggregation", inline=True ) mp_self_loops = mo.ui.switch(value=False, label="add self-loops") mo.hstack([mp_node, mp_aggr, mp_self_loops], justify="start", gap=2)
_ei = toy_graph.edge_index.cpu() · 88 lines
_ei = toy_graph.edge_index.cpu() if mp_self_loops.value: _ei, _ = add_self_loops(_ei, num_nodes=toy_graph.num_nodes) _x = toy_x _i = mp_node.value _src = _ei[0][_ei[1] == _i] # neighbours j that send a message j -> i _msgs = _x[_src] if len(_src) == 0: _agg = torch.zeros(_x.size(1)) elif mp_aggr.value == "sum": _agg = _msgs.sum(0) elif mp_aggr.value == "mean": _agg = _msgs.mean(0) else: _agg = _msgs.max(0).values # The same thing for ALL nodes at once, vectorised: this is what PyG does on the GPU _all = scatter( _x[_ei[0]], _ei[1], dim=0, dim_size=toy_graph.num_nodes, reduce=mp_aggr.value, ) _role = [ "target" if n == _i else ("neighbour" if n in _src.tolist() else "other") for n in range(toy_graph.num_nodes) ] _nodes = pd.DataFrame( { "node": range(toy_graph.num_nodes), "role": _role, "features": [str(v.int().tolist()) for v in _x], } ) _chart = draw_graph( toy_graph.edge_index, toy_pos, _nodes, color=alt.Color( "role:N", scale=alt.Scale( domain=["target", "neighbour", "other"], range=["#e45756", "#4c78a8", "#bab0ac"], ), ), title=f"Messages flowing into node {_i}", ) _table = pd.DataFrame( { "from node j": _src.tolist(), "message x_j": [str(m.int().tolist()) for m in _msgs], } ) mo.hstack( [ _chart, mo.vstack( [ mo.md(f"**Incoming messages** ({len(_src)}):"), mo.ui.table( _table, selection=None, show_column_summaries=False, pagination=False, ), mo.md( rf""" **{mp_aggr.value}** of messages → $\mathbf{{x}}_{_i}' =$ `{[round(v, 2) for v in _agg.tolist()]}` Vectorised for every node with `torch_geometric.utils.scatter(x[src], dst, reduce="{mp_aggr.value}")`: ``` {[[round(v, 2) for v in row] for row in _all.tolist()]} ``` Row {_i} matches the hand computation. One `scatter` call replaces a Python loop over nodes. """ ), ] ), ], widths=[1, 1], align="start", )
How GCN weighs neighbours: symmetric normalization
Plain sum makes high-degree nodes explode, and plain mean ignores how "popular" a neighbour is. GCN uses the normalized adjacency matrix with self-loops:
where \(\tilde d_i\) = degree + 1. A message from a hub (large \(\tilde d_j\)) is turned down, so one popular node can't dominate. Compare the three matrices for your graph:
_n = toy_graph.num_nodes · 38 lines
_n = toy_graph.num_nodes _A = to_dense_adj(toy_graph.edge_index.cpu(), max_num_nodes=_n)[0] _A_tilde = _A + torch.eye(_n) _d = _A_tilde.sum(1) _A_hat = _A_tilde / torch.sqrt(_d[:, None] * _d[None, :]) def _heatmap(mat: torch.Tensor, title: str, fmt: str): _df = pd.DataFrame( [(i, j, float(mat[i, j])) for i in range(_n) for j in range(_n)], columns=["row i", "col j", "value"], ) _base = alt.Chart(_df).encode(x=alt.X("col j:O"), y=alt.Y("row i:O")) return ( _base.mark_rect().encode( color=alt.Color( "value:Q", scale=alt.Scale(scheme="blues"), legend=None ), tooltip=["row i", "col j", alt.Tooltip("value:Q", format=fmt)], ) + _base.mark_text(fontSize=10).encode( text=alt.Text("value:Q", format=fmt), color=alt.condition( "datum.value > 0.6", alt.value("white"), alt.value("black") ), ) ).properties( width=min(40 * _n, 320), height=min(40 * _n, 320), title=title ) mo.ui.tabs( { "A (adjacency)": _heatmap(_A, "A: 1 if edge i–j", ".0f"), "A + I (self-loops)": _heatmap(_A_tilde, "A + I", ".0f"), "Â (GCN-normalized)": _heatmap( _A_hat, "Â = D^-1/2 (A+I) D^-1/2", ".2f" ), } )
Write your own GCN layer with MessagePassing
Every PyG layer subclasses MessagePassing. You only describe one edge and PyG vectorises it:
__init__(aggr="add"): choose the aggregation \(\bigoplus\)forward(): prepare tensors, then callself.propagate(edge_index, ...)-
message(x_j, ...): what neighbour \(j\) sends. Suffix_jmeans "source node of each edge" and_imeans "target node"; PyG gathers these rows for you -
update(aggr_out): optional post-processing (\(\gamma\))
Below is GCN from scratch. The check at the end proves it gives exactly the same output as PyG's GCNConv
on the full Cora graph (on the GPU).
class MyGCNConv(MessagePassing): """GCN layer from scratch: x_i' = sum_{j in N(i) ∪ {i}} 1/sqrt(d_i d_j) · W x_j + b""" def __init__(self, in_channels: int, out_channels: int): super().__init__(aggr="add") # ⊕ = sum self.lin = torch.nn.Linear(in_channels, out_channels, bias=False) self.bias = torch.nn.Parameter(torch.zeros(out_channels)) def forward(self, x, edge_index): # 1. self-loops so every node also "messages" itself edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0)) # 2. linear transform first (cheaper: fewer columns flow along the edges) x = self.lin(x) # 3. symmetric normalization coefficient per edge src, dst = edge_index deg = degree(dst, x.size(0), dtype=x.dtype) deg_inv_sqrt = deg.pow(-0.5) deg_inv_sqrt[deg_inv_sqrt == float("inf")] = 0 norm = deg_inv_sqrt[src] * deg_inv_sqrt[dst] # 4. propagate = message() on every edge -> aggregate per target node -> update() return self.propagate(edge_index, x=x, norm=norm) + self.bias def message(self, x_j, norm): # x_j: [num_edges, out_channels], features of each edge's source node return norm.view(-1, 1) * x_j
torch.manual_seed(0) · 22 lines
torch.manual_seed(0) _ref = GCNConv(dataset.num_features, 16).to(device) _mine = MyGCNConv(dataset.num_features, 16).to(device) with ( torch.no_grad() ): # copy the weights so both layers compute the same function _mine.lin.weight.copy_(_ref.lin.weight) _mine.bias.copy_(_ref.bias) _out_ref = _ref(graph.x, graph.edge_index) _out_mine = _mine(graph.x, graph.edge_index) _max_diff = (_out_ref - _out_mine).abs().max().item() mo.callout( mo.md( f""" **MyGCNConv vs PyG GCNConv on Cora** (loaded in §2: {graph.num_nodes:,} nodes, on `{device}`): output shape `{tuple(_out_mine.shape)}`, max absolute difference **{_max_diff:.2e}**, identical: **{torch.allclose(_out_ref, _out_mine, atol=1e-5)}** """ ), kind="success" if _max_diff < 1e-5 else "danger", )
2 · The Cora citation network
Cora is the "MNIST of graph learning". Each node is a machine-learning paper and each edge a citation (treated as undirected). Each paper has a 1,433-dim binary bag-of-words vector: 1 if a word from a fixed dictionary appears in it.
The task, semi-supervised node classification: only 140 papers (20 per class) are labelled for training. The model must infer the topic of the other papers by combining their words and the topics of the papers they cite.
Planetoid downloads the data once into data/Planetoid/, and the NormalizeFeatures() transform rescales
each row of x to sum to 1, so long papers don't get bigger inputs than short ones.
dataset = Planetoid( root=str(mo.notebook_dir().parents[1] / "data" / "Planetoid"), name="Cora", transform=NormalizeFeatures(), ) graph = dataset[0].to(device) CLASS_NAMES = [ "Case-Based", "Genetic Algorithms", "Neural Networks", "Probabilistic Methods", "Reinforcement Learning", "Rule Learning", "Theory", ] mo.hstack( [ mo.stat(f"{graph.num_nodes:,}", label="nodes (papers)"), mo.stat(f"{graph.num_edges // 2:,}", label="undirected edges"), mo.stat(f"{dataset.num_features:,}", label="features per node"), mo.stat(dataset.num_classes, label="classes"), mo.stat( int(graph.train_mask.sum()), label="labelled for training" ), mo.stat(int(graph.test_mask.sum()), label="test nodes"), ], justify="start", wrap=True, )
_counts = torch.bincount( · 10 lines
_counts = torch.bincount( graph.y.cpu(), minlength=dataset.num_classes ).numpy() alt.Chart( pd.DataFrame({"topic": CLASS_NAMES, "papers": _counts}) ).mark_bar().encode( x=alt.X("papers:Q", title="papers"), y=alt.Y("topic:N", sort="-x", title=None), tooltip=["topic", "papers"], ).properties(title="Class balance in Cora", width=480, height=200)
_split = pd.DataFrame( · 55 lines
_split = pd.DataFrame( { "split": [ "train", "validation", "test", "unlabelled (still used for message passing!)", ], "nodes": [ int(graph.train_mask.sum()), int(graph.val_mask.sum()), int(graph.test_mask.sum()), int( ( ~(graph.train_mask | graph.val_mask | graph.test_mask) ).sum() ), ], "used for": [ "computing the loss & gradients", "choosing the best epoch / hyper-parameters", "final, untouched evaluation", "only their features & edges: this is the *transductive* setting", ], } ) _row = graph.x[0].cpu() mo.accordion( { "Train / validation / test masks": mo.vstack( [ mo.md( "PyG stores splits as **boolean masks** over nodes (`graph.train_mask`, ...). " "The whole graph goes through the model every time; the mask only decides *which " "nodes' predictions count* for the loss or the metric." ), mo.ui.table( _split, selection=None, show_column_summaries=False, pagination=False, ), ] ), "What does a feature vector look like?": mo.md( f""" Paper 0 contains **{int((_row > 0).sum())}** of the {dataset.num_features:,} dictionary words. After `NormalizeFeatures` each non-zero entry is `1 / {int((_row > 0).sum())}` ≈ **{_row.max():.4f}** and the row sums to **{_row.sum():.2f}**. Overall `x` is **{(graph.x == 0).float().mean().item():.1%} zeros**: very sparse, like most text features. """ ), }, multiple=True, )
_h_edge = homophily(graph.edge_index, graph.y, method="edge") · 34 lines
_h_edge = homophily(graph.edge_index, graph.y, method="edge") _h_node = homophily(graph.edge_index, graph.y, method="node") _deg = degree(graph.edge_index[1], graph.num_nodes).cpu() _deg_df = pd.DataFrame({"degree": _deg.clamp(max=20).int().numpy()}) mo.vstack( [ mo.md( rf""" ### Why do GNNs work here? Homophily *"Birds of a feather flock together"*: papers mostly cite papers **on the same topic**. **Edge homophily** is the fraction of edges joining two nodes of the same class: $$h = \frac{{|\{{(i,j) \in E : y_i = y_j\}}|}}{{|E|}}$$ For Cora **h = {_h_edge:.2f}** (node-averaged: {_h_node:.2f}). With 7 classes, random edges would give about 0.14. So a neighbour's topic is a strong hint about a node's own topic, and message passing exploits exactly that. On *heterophilous* graphs (e.g. fraudsters connecting to normal users), plain GCN struggles. """ ), alt.Chart(_deg_df) .mark_bar() .encode( x=alt.X("degree:O", title="node degree (20 = 20 or more)"), y=alt.Y("count():Q", title="papers"), ) .properties( width=520, height=160, title=f"Degree distribution: median {int(_deg.median())}, max {int(_deg.max())}, a long tail of hubs", ), ] )
Explore a node's receptive field
A GNN with \(k\) layers computes node \(i\)'s output from its \(k\)-hop neighbourhood, its receptive field.
torch_geometric.utils.k_hop_subgraph extracts it. Pick a paper and the number of hops (= layers):
ego_node = mo.ui.number( · 7 lines
ego_node = mo.ui.number( start=0, stop=graph.num_nodes - 1, value=1701, label="paper id" ) ego_hops = mo.ui.slider( 1, 3, value=2, label="hops (= GNN layers)", show_value=True ) mo.hstack([ego_node, ego_hops], justify="start", gap=2)
_center = int(ego_node.value) · 80 lines
_center = int(ego_node.value) _subset, _sub_ei, _mapping, _ = k_hop_subgraph( _center, ego_hops.value, graph.edge_index.cpu(), relabel_nodes=True, num_nodes=graph.num_nodes, ) _y = graph.y.cpu() _n_sub = _subset.numel() _same = (_y[_subset] == _y[_center]).float().mean().item() _max_draw = 350 _stats = mo.vstack( [ mo.stat( f"{_n_sub:,}", label=f"papers within {ego_hops.value} hop(s)", bordered=True, ), mo.stat( f"{_n_sub / graph.num_nodes:.1%}", label="of the whole graph", bordered=True, ), mo.stat( f"{_same:.0%}", label=f"share the topic of #{_center}", bordered=True, ), mo.md( f"True topic of #{_center}: **{CLASS_NAMES[int(_y[_center])]}**" ), ] ) if _n_sub > _max_draw: _view = mo.callout( mo.md( f"{_n_sub:,} nodes is too many to draw nicely (limit {_max_draw}). Try fewer hops or another paper. " "This explosion of the receptive field is why large graphs use **neighbour sampling** (§3)." ), kind="info", ) else: _g = nx.Graph(_sub_ei.t().tolist()) _g.add_nodes_from(range(_n_sub)) _pos = nx.spring_layout(_g, seed=0, k=1.5 / max(_n_sub, 1) ** 0.5) _hop = nx.single_source_shortest_path_length(_g, int(_mapping)) _nodes = pd.DataFrame( { "node": range(_n_sub), "paper": _subset.tolist(), "topic": [CLASS_NAMES[int(c)] for c in _y[_subset]], "hop": [_hop.get(i, -1) for i in range(_n_sub)], } ) _view = draw_graph( _sub_ei, _pos, _nodes, color=alt.Color("topic:N", scale=alt.Scale(domain=CLASS_NAMES)), size=[0, 260, 90, 40][ego_hops.value], show_ids=False, width=460, height=360, title=f"{ego_hops.value}-hop neighbourhood of paper #{_center} (big = closer)", ) _center_xy = pd.DataFrame( {"x": [_pos[int(_mapping)][0]], "y": [_pos[int(_mapping)][1]]} ) _view = _view + alt.Chart(_center_xy).mark_point( shape="diamond", size=500, filled=False, color="black", strokeWidth=2, ).encode(x="x:Q", y="y:Q") mo.hstack([_view, _stats], widths=[3, 1], align="start")
3 · The algorithms
All three GNNs below are instances of the message-passing template from §0. They differ only in how a node weighs and combines its neighbours. The MLP ignores edges completely and is our baseline: the gap between MLP and the GNNs measures how much the graph structure is worth.
In every model here: 2 layers → each paper sees papers up to 2 citations away, with ELU activations and dropout in between. Notation: \(\mathbf{h}_i\) = node \(i\)'s vector, \(W\) = learnable weight matrix, \(\mathcal{N}(i)\) = neighbours of \(i\).
_gcn = mo.md( · 99 lines
_gcn = mo.md( r""" #### GCN: Graph Convolutional Network (Kipf & Welling, ICLR 2017) $$ \mathbf{h}_i' = \sigma\Big( \sum_{j \in \mathcal{N}(i) \cup \{i\}} \frac{1}{\sqrt{\tilde d_i \tilde d_j}}\; W \mathbf{h}_j \Big) \qquad\text{(matrix form: } H' = \sigma(\hat{A} H W)\text{)} $$ **Intuition:** a degree-normalized *average* of yourself and your neighbours, then a linear layer. It came from approximating *spectral graph convolutions* (filters on the graph Laplacian's eigenvectors) with a cheap first-order polynomial, which is why it's called a "convolution". - ✅ simple, fast, few parameters, a very strong baseline on homophilous graphs - ❌ all neighbours get **fixed** weights (from degrees only), so it can't learn that some citations matter more - ❌ transductive in its classic form; deep stacks **over-smooth** (§6) You implemented exactly this layer in §1 (`MyGCNConv`). """ ) _gat = mo.md( r""" #### GAT: Graph Attention Network (Veličković et al., ICLR 2018) Instead of fixed weights, GAT **learns** how much node $i$ should attend to each neighbour $j$: $$ e_{ij} = \text{LeakyReLU}\big(\mathbf{a}^\top [\,W\mathbf{h}_i \,\|\, W\mathbf{h}_j\,]\big), \qquad \alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k \in \mathcal{N}(i) \cup \{i\}} \exp(e_{ik})}, \qquad \mathbf{h}_i' = \sigma\Big(\sum_{j} \alpha_{ij}\, W \mathbf{h}_j\Big) $$ The attention scores $\alpha_{ij}$ are a **softmax over each node's neighbourhood**, so they sum to 1. **Multi-head attention:** run $K$ independent attention mechanisms and concatenate their outputs, as in Transformers. This stabilises training. Here hidden size = heads × per-head size. - ✅ adaptive neighbour weights, often the most accurate of the three on citation graphs - ✅ attention weights are inspectable (see §5!) - ❌ more parameters and compute per edge; dropout on attention is important against overfitting *Fun fact:* a Transformer is a GAT on a fully-connected graph. """ ) _sage = mo.md( r""" #### GraphSAGE: SAmple and aggreGatE (Hamilton et al., NeurIPS 2017) $$ \mathbf{h}_i' = \sigma\Big( W_1 \mathbf{h}_i \;+\; W_2 \cdot \text{mean}_{j \in \mathcal{N}(i)} \mathbf{h}_j \Big) $$ **Intuition:** keep **separate weights for "me" and "my neighbourhood"** instead of mixing yourself in as just another neighbour. The original paper also introduced **neighbour sampling**: use only a fixed number of random neighbours per node and layer, so cost no longer depends on hub degrees. - ✅ **inductive**: learns a function of features + neighbourhood, so it works on nodes/graphs never seen in training - ✅ scales to graphs with billions of edges (Pinterest's PinSage) via mini-batches of sampled subgraphs - ❌ on small homophilous graphs like Cora its separate self-weight makes it overfit a bit more than GCN Other aggregators (`aggr="max"`, `"lstm"`, ...) are one argument away in `SAGEConv`. """ ) _mlp = mo.md( r""" #### MLP: the "no graph" baseline $$ \mathbf{h}_i' = \sigma(W \mathbf{h}_i) $$ A plain 2-layer neural network on each paper's words **alone**: edges are ignored. It's the honest baseline: if a GNN can't beat it, the graph isn't helping. On Cora expect around **55–60%** for the MLP versus around **80%** for GNNs: 140 labelled bag-of-words vectors just aren't enough, but citations spread the label information to the whole graph. """ ) _cmp = mo.md( r""" | | **MLP** | **GCN** | **GAT** | **GraphSAGE** | |---|---|---|---|---| | uses edges | ✗ | ✓ | ✓ | ✓ | | neighbour weights | — | fixed: $1/\sqrt{d_i d_j}$ | **learned** attention $\alpha_{ij}$ | mean (+ separate self weight) | | PyG layer | `torch.nn.Linear` | `GCNConv` | `GATConv` | `SAGEConv` | | inductive | ✓ | partly | ✓ | ✓ (designed for it) | | cost per layer | $O(N)$ | $O(E)$ | $O(E \cdot K)$ heads | $O(E)$, or sampled | | typical Cora test acc. | ~57% | ~81% | ~82–83% | ~79–80% | Test these claims yourself in §4 with **Compare all models**. """ ) mo.ui.tabs( { "GCN": _gcn, "GAT": _gat, "GraphSAGE": _sage, "MLP baseline": _mlp, "Side by side": _cmp, } )
mo.vstack( · 66 lines
mo.vstack( [ mo.md("### Training techniques used in this notebook"), mo.accordion( { "Cross-entropy loss on the training mask only": mo.md( r""" The model outputs one score (*logit*) per class for **every** node. `F.cross_entropy` applies a softmax and penalises $-\log p(\text{true class})$, but we index with `train_mask` so only the 140 labelled papers contribute. The rest still pass messages, so the gradients flow through their features too. """ ), "Full-batch training": mo.md( r""" Cora is small enough that the **entire graph fits on the GPU**, so each epoch = one forward/backward pass over all 2,708 nodes. That is why one "epoch" is a single optimizer step here. """ ), "Dropout (on features and on attention)": mo.md( r""" During training, randomly zero a fraction $p$ of the inputs and hidden units (scaling the rest by $1/(1-p)$). This forces redundancy and is the main defence against overfitting with only 140 labels. GAT also drops **attention coefficients**, i.e. randomly ignores some edges each step. `model.eval()` switches dropout off. """ ), "Weight decay (L2 regularization) + Adam": mo.md( r""" **Adam** adapts the learning rate per parameter using running averages of the gradient and its square, so it's robust to the choice of `lr`. **Weight decay** $\lambda$ adds $\lambda \|W\|^2$ to the loss, pulling weights towards zero. The Kipf & Welling defaults, `lr=0.01, weight_decay=5e-4`, are the defaults below. """ ), "Model selection on the validation set (early-stopping style)": mo.md( r""" Every epoch we measure validation accuracy and **keep a copy of the weights from the best epoch**. After training those weights are restored. The test set is never used to make choices, so the test accuracy stays an honest estimate. """ ), "Feature normalization": mo.md( r""" `NormalizeFeatures()` makes each paper's word vector sum to 1, so papers with many words don't produce larger activations than short ones. """ ), "Scaling up: mini-batches & neighbour sampling": mo.md( r""" For graphs that don't fit in memory, PyG's `NeighborLoader` samples e.g. 10 neighbours at hop 1 and 5 at hop 2 for a batch of seed nodes, and builds a small subgraph per batch (GraphSAGE-style): ```python from torch_geometric.loader import NeighborLoader loader = NeighborLoader(data, num_neighbors=[10, 5], batch_size=512, input_nodes=data.train_mask) for batch in loader: out = model(batch.x, batch.edge_index)[: batch.batch_size] # seed nodes come first ``` *Note:* fast sampling uses the optional `pyg-lib` / `torch-sparse` CPU extensions; the sampled batches can still be trained on the XPU. """ ), }, multiple=True, ), ] )
class GNN(torch.nn.Module): """Two-layer node classifier. kind ∈ {"MLP", "GCN", "GAT", "GraphSAGE"}. Returns (logits, hidden embeddings).""" def __init__( self, kind: str, in_dim: int, hidden: int, out_dim: int, dropout: float, heads: int = 8, ): super().__init__() self.kind = kind self.dropout = dropout if kind == "MLP": self.conv1 = torch.nn.Linear(in_dim, hidden) self.conv2 = torch.nn.Linear(hidden, out_dim) elif kind == "GCN": self.conv1 = GCNConv(in_dim, hidden) self.conv2 = GCNConv(hidden, out_dim) elif kind == "GAT": # `heads` attention heads of size hidden // heads, concatenated back to `hidden` self.conv1 = GATConv( in_dim, hidden // heads, heads=heads, dropout=dropout ) self.conv2 = GATConv(hidden, out_dim, heads=1, dropout=dropout) elif kind == "GraphSAGE": self.conv1 = SAGEConv(in_dim, hidden) self.conv2 = SAGEConv(hidden, out_dim) else: raise ValueError(kind) def _layer(self, layer, x, edge_index): return layer(x) if self.kind == "MLP" else layer(x, edge_index) def forward(self, x, edge_index): x = F.dropout(x, p=self.dropout, training=self.training) h = F.elu(self._layer(self.conv1, x, edge_index)) h = F.dropout(h, p=self.dropout, training=self.training) return self._layer(self.conv2, h, edge_index), h
4 · Train & compare
Pick a model and hyper-parameters, then click Train model. Ideas to try:
- MLP vs GCN: how much do citations add?
- dropout 0.0 with 500 epochs: watch train accuracy hit 100% while validation drops (overfitting)
- learning rate 0.05 or 0.001: too fast is noisy, too slow is undertrained
- GAT with 1 vs 8 heads
model_kind = mo.ui.dropdown( · 46 lines
model_kind = mo.ui.dropdown( ["MLP", "GCN", "GAT", "GraphSAGE"], value="GCN", label="model" ) hidden_dim = mo.ui.slider( 16, 256, step=16, value=64, label="hidden size", show_value=True ) gat_heads = mo.ui.dropdown( {"1": 1, "2": 2, "4": 4, "8": 8}, value="8", label="GAT heads" ) learning_rate = mo.ui.dropdown( ["0.001", "0.005", "0.01", "0.05"], value="0.01", label="learning rate" ) weight_decay = mo.ui.dropdown( ["0", "5e-4", "5e-3"], value="5e-4", label="weight decay" ) dropout_rate = mo.ui.slider( 0.0, 0.8, step=0.1, value=0.5, label="dropout", show_value=True ) num_epochs = mo.ui.slider( 50, 500, step=50, value=200, label="epochs", show_value=True ) train_button = mo.ui.run_button(label="Train model", kind="success") mo.vstack( [ mo.md("### Training controls"), mo.hstack( [ model_kind, gat_heads, learning_rate, weight_decay, train_button, ], justify="start", gap=1.5, wrap=True, ), mo.hstack( [hidden_dim, dropout_rate, num_epochs], justify="start", gap=1.5, wrap=True, ), ] )
def evaluate(m: torch.nn.Module) -> dict[str, float]: · 46 lines
def evaluate(m: torch.nn.Module) -> dict[str, float]: """Accuracy on the train / val / test masks (dropout off, no gradients).""" m.eval() with torch.no_grad(): pred = m(graph.x, graph.edge_index)[0].argmax(dim=1) return { split: (pred[mask] == graph.y[mask]).float().mean().item() for split, mask in ( ("train", graph.train_mask), ("val", graph.val_mask), ("test", graph.test_mask), ) } def fit( m: torch.nn.Module, epochs: int, lr: float, wd: float, title: str | None = None, ) -> pd.DataFrame: """Full-batch training on graph.train_mask. Restores the best-validation weights; returns per-epoch history.""" optimizer = torch.optim.Adam(m.parameters(), lr=lr, weight_decay=wd) best_val, best_state, rows = -1.0, None, [] epoch_iter = range(1, epochs + 1) if title is not None: epoch_iter = mo.status.progress_bar(epoch_iter, title=title) for epoch in epoch_iter: m.train() optimizer.zero_grad() logits, _ = m(graph.x, graph.edge_index) loss = F.cross_entropy( logits[graph.train_mask], graph.y[graph.train_mask] ) loss.backward() optimizer.step() acc = evaluate(m) rows.append({"epoch": epoch, "loss": loss.item(), **acc}) if acc["val"] > best_val: best_val = acc["val"] best_state = { k: v.detach().clone() for k, v in m.state_dict().items() } m.load_state_dict(best_state) return pd.DataFrame(rows)
mo.stop( · 59 lines
mo.stop( not train_button.value, mo.md("*Pick settings above and click **Train model**.*"), ) torch.manual_seed(0) model = GNN( model_kind.value, dataset.num_features, hidden_dim.value, dataset.num_classes, dropout=dropout_rate.value, heads=gat_heads.value, ).to(device) _start = time.perf_counter() history = fit( model, num_epochs.value, float(learning_rate.value), float(weight_decay.value), title=f"Training {model_kind.value} on {device}", ) if device.type == "xpu": torch.xpu.synchronize() train_seconds = time.perf_counter() - _start final_acc = evaluate(model) mo.hstack( [ mo.stat( f"{final_acc['test']:.1%}", label=f"{model_kind.value} test accuracy", bordered=True, ), mo.stat( f"{final_acc['val']:.1%}", label="validation accuracy (best epoch)", bordered=True, ), mo.stat( f"{int(history['val'].idxmax()) + 1}", label="best epoch", bordered=True, ), mo.stat( f"{train_seconds:.2f}s", label=f"{num_epochs.value} epochs on {device}", bordered=True, ), mo.stat( f"{sum(p.numel() for p in model.parameters()):,}", label="parameters", bordered=True, ), ], justify="start", wrap=True, )
_loss_chart = ( · 42 lines
_loss_chart = ( alt.Chart(history) .mark_line() .encode(x="epoch:Q", y=alt.Y("loss:Q", title="training loss")) .properties(width=320, height=220, title="Loss") ) _acc_long = history.melt( id_vars="epoch", value_vars=["train", "val", "test"], var_name="split", value_name="accuracy", ) _acc_chart = ( alt.Chart(_acc_long) .mark_line() .encode( x="epoch:Q", y=alt.Y( "accuracy:Q", scale=alt.Scale(domain=[0, 1]), axis=alt.Axis(format="%"), ), color=alt.Color("split:N", sort=["train", "val", "test"]), tooltip=[ "epoch", "split", alt.Tooltip("accuracy:Q", format=".1%"), ], ) .properties(width=320, height=220, title="Accuracy") ) mo.vstack( [ _loss_chart | _acc_chart, mo.md( "Train accuracy shoots to ~100% because only 140 nodes are labelled; the **val/test** curves are what " "matter. The gap between them is overfitting, which dropout and weight decay keep in check." ), ] )
Compare all models
One run can be lucky. A fair comparison trains each model with several random seeds and reports mean ± standard deviation. This uses the hidden size, learning rate, weight decay, dropout and epochs from the controls above (about a minute on the GPU).
compare_seeds = mo.ui.slider( · 7 lines
compare_seeds = mo.ui.slider( 1, 5, value=3, label="seeds per model", show_value=True ) compare_button = mo.ui.run_button( label="Compare all models", kind="neutral" ) mo.hstack([compare_seeds, compare_button], justify="start", gap=2)
mo.stop( · 107 lines
mo.stop( not compare_button.value, mo.md("*Click **Compare all models** to run the experiment.*"), ) _kinds = ["MLP", "GCN", "GAT", "GraphSAGE"] _runs = [] for _kind, _seed in mo.status.progress_bar( [(k, s) for k in _kinds for s in range(compare_seeds.value)], title="Training every model × seed", ): torch.manual_seed(_seed) _m = GNN( _kind, dataset.num_features, hidden_dim.value, dataset.num_classes, dropout=dropout_rate.value, heads=gat_heads.value, ).to(device) _t0 = time.perf_counter() fit( _m, num_epochs.value, float(learning_rate.value), float(weight_decay.value), ) _runs.append( { "model": _kind, "seed": _seed, "test accuracy": evaluate(_m)["test"], "seconds": time.perf_counter() - _t0, } ) comparison_df = pd.DataFrame(_runs) _summary = ( comparison_df.groupby("model", sort=False) .agg( mean=("test accuracy", "mean"), std=("test accuracy", "std"), seconds=("seconds", "mean"), ) .reset_index() .fillna(0) ) _bars = ( alt.Chart(_summary) .mark_bar(opacity=0.8) .encode( x=alt.X("model:N", sort=_kinds, title=None), y=alt.Y( "mean:Q", title="test accuracy", scale=alt.Scale(domain=[0, 1]), axis=alt.Axis(format="%"), ), color=alt.Color("model:N", sort=_kinds, legend=None), tooltip=[ "model", alt.Tooltip("mean:Q", format=".1%"), alt.Tooltip("std:Q", format=".1%"), alt.Tooltip("seconds:Q", format=".1f", title="s / run"), ], ) ) _err = ( alt.Chart(_summary) .mark_errorbar(ticks=True) .encode( x=alt.X("model:N", sort=_kinds), y=alt.Y("lo:Q", title=""), y2="hi:Q", ) .transform_calculate( lo="datum.mean - datum.std", hi="datum.mean + datum.std" ) ) _pts = ( alt.Chart(comparison_df) .mark_circle(color="black", size=30) .encode( x=alt.X("model:N", sort=_kinds), y="test accuracy:Q", tooltip=[ "model", "seed", alt.Tooltip("test accuracy:Q", format=".1%"), ], ) ) _best = _summary.loc[_summary["mean"].idxmax()] mo.vstack( [ (_bars + _err + _pts).properties( width=420, height=260, title="Test accuracy (bar = mean, line = ±1 std, dots = seeds)", ), mo.md( f"Best: **{_best['model']}** at {_best['mean']:.1%} ± {_best['std']:.1%}. Graph models beat the MLP by " f"**{_summary.set_index('model').loc[['GCN', 'GAT', 'GraphSAGE'], 'mean'].mean() - _summary.set_index('model').loc['MLP', 'mean']:.0%} points** " "on average: that is the value of the citation graph." ), ] )
5 · Look inside the trained model
The cells below use the model you trained in §4. Retrain with different settings and they all update.
Inspect a single prediction
Pick a paper to see the model's class probabilities next to the topics of its neighbours. Misclassified nodes are the most interesting: often their neighbourhood "votes" for a different topic than their true one.
inspect_node = mo.ui.number( · 4 lines
inspect_node = mo.ui.number( start=0, stop=graph.num_nodes - 1, value=1701, label="paper id" ) inspect_node
model.eval() · 113 lines
model.eval() with torch.no_grad(): _logits, _ = model(graph.x, graph.edge_index) _probs = _logits.softmax(dim=1).cpu() _pred_all = _probs.argmax(1) _y = graph.y.cpu() _i = int(inspect_node.value) _wrong_test = ( torch.nonzero((_pred_all != _y) & graph.test_mask.cpu()) .flatten()[:12] .tolist() ) _split = ( "train" if graph.train_mask[_i] else "val" if graph.val_mask[_i] else "test" if graph.test_mask[_i] else "unlabelled" ) _ei = graph.edge_index.cpu() _nbrs = _ei[0][_ei[1] == _i] _nbr_df = pd.DataFrame( { "neighbour": _nbrs.tolist(), "true topic": [CLASS_NAMES[int(_y[j])] for j in _nbrs], "predicted": [CLASS_NAMES[int(_pred_all[j])] for j in _nbrs], "labelled (train)": [bool(graph.train_mask[j]) for j in _nbrs], } ) _vote = _nbr_df["true topic"].mode().iloc[0] if len(_nbr_df) else "n/a" _prob_df = pd.DataFrame( { "topic": CLASS_NAMES, "probability": _probs[_i].numpy(), "is": [ "true" if c == int(_y[_i]) else "" for c in range(len(CLASS_NAMES)) ], } ) _prob_chart = ( alt.Chart(_prob_df) .mark_bar() .encode( x=alt.X( "probability:Q", scale=alt.Scale(domain=[0, 1]), axis=alt.Axis(format="%"), ), y=alt.Y("topic:N", sort=CLASS_NAMES, title=None), color=alt.condition( "datum.is == 'true'", alt.value("#54a24b"), alt.value("#9ecae9"), ), tooltip=["topic", alt.Tooltip("probability:Q", format=".1%")], ) .properties( width=300, height=190, title="Model's probabilities (green = true topic)", ) ) _ok = int(_pred_all[_i]) == int(_y[_i]) mo.vstack( [ mo.hstack( [ mo.stat( CLASS_NAMES[int(_y[_i])], label="true topic", bordered=True, ), mo.stat( CLASS_NAMES[int(_pred_all[_i])], label=f"predicted ({_probs[_i].max():.0%} confident)", bordered=True, direction="increase" if _ok else "decrease", caption="correct" if _ok else "wrong", ), mo.stat(_split, label="split", bordered=True), mo.stat( str(len(_nbrs)), label=f"neighbours · majority topic: {_vote}", bordered=True, ), ], justify="start", wrap=True, ), mo.hstack( [ _prob_chart, mo.ui.table( _nbr_df, selection=None, show_column_summaries=False, page_size=8, ), ], align="start", ), mo.md( f"Some **misclassified test papers** to try: `{_wrong_test}`" ), ] )
GAT attention: who does a node listen to?
If the trained model is a GAT, we can ask its first layer for the attention coefficients \(\alpha_{ij}\)
with conv(x, edge_index, return_attention_weights=True). Uniform attention would give every neighbour
(and the self-loop) \(1/(\text{degree}+1)\). Bars above the dashed line get more attention than that.
This uses the paper chosen in the inspector above.
if model.kind != "GAT": · 99 lines
if model.kind != "GAT": _out = mo.callout( mo.md( "Select **GAT** in the training controls (§4) and click **Train model** to see attention weights." ), kind="info", ) else: model.eval() with torch.no_grad(): _, (_att_ei, _alpha) = model.conv1( graph.x, graph.edge_index, return_attention_weights=True ) _att_ei, _alpha = ( _att_ei.cpu(), _alpha.cpu(), ) # _alpha: [num_edges + self-loops, heads] _i = int(inspect_node.value) _mask = _att_ei[1] == _i _src = _att_ei[0][_mask] _a = _alpha[_mask] _y = graph.y.cpu() _labels = [("self" if int(j) == _i else str(int(j))) for j in _src] _mean_df = pd.DataFrame( { "neighbour": _labels, "attention": _a.mean(1).numpy(), "topic": [CLASS_NAMES[int(_y[j])] for j in _src], } ) _uniform = 1 / len(_src) _bars = ( alt.Chart(_mean_df) .mark_bar() .encode( x=alt.X("neighbour:N", sort="-y", title="source node"), y=alt.Y("attention:Q", title="attention (mean over heads)"), color=alt.Color( "topic:N", scale=alt.Scale(domain=CLASS_NAMES) ), tooltip=[ "neighbour", "topic", alt.Tooltip("attention:Q", format=".3f"), ], ) ) _rule = ( alt.Chart(pd.DataFrame({"u": [_uniform]})) .mark_rule(strokeDash=[4, 4], color="black") .encode(y="u:Q") ) _head_df = pd.DataFrame( [ (lab, f"head {h}", float(_a[k, h])) for k, lab in enumerate(_labels) for h in range(_a.size(1)) ], columns=["neighbour", "head", "attention"], ) _heat = ( alt.Chart(_head_df) .mark_rect() .encode( x=alt.X("neighbour:N", title="source node"), y=alt.Y("head:N", title=None), color=alt.Color( "attention:Q", scale=alt.Scale(scheme="oranges") ), tooltip=[ "neighbour", "head", alt.Tooltip("attention:Q", format=".3f"), ], ) ) _out = mo.vstack( [ mo.hstack( [ (_bars + _rule).properties( width=380, height=220, title=f"Attention into paper #{_i} ({CLASS_NAMES[int(_y[_i])]})", ), _heat.properties( width=320, height=220, title="Per head: heads specialise differently", ), ], align="start", ), mo.md( f"Each column sums to 1 per head (softmax over the {len(_src)} incoming edges incl. self-loop)." ), ] ) _out
What did the network learn? Embeddings
The hidden layer gives every paper a 64-ish-dim vector (the node embedding). t-SNE squashes those vectors to 2-D while keeping close points close. If papers of the same topic form clusters, even though the model saw labels for only 5% of them, the GNN has learned a meaningful representation. Hover a point to see its true and predicted topic. Try it with the MLP: the clusters get much messier.
def node_embeddings(m: torch.nn.Module) -> tuple[np.ndarray, np.ndarray]: · 35 lines
def node_embeddings(m: torch.nn.Module) -> tuple[np.ndarray, np.ndarray]: """Hidden-layer vectors and predicted classes for every node, as NumPy arrays.""" m.eval() with torch.no_grad(): logits, hidden = m(graph.x, graph.edge_index) return hidden.cpu().numpy(), logits.argmax(dim=1).cpu().numpy() _hidden, _pred = node_embeddings(model) _xy = TSNE(n_components=2, init="pca", random_state=0).fit_transform( _hidden ) _true = graph.y.cpu().numpy() embedding_df = pd.DataFrame( { "x": _xy[:, 0], "y": _xy[:, 1], "topic": [CLASS_NAMES[i] for i in _true], "predicted": [CLASS_NAMES[i] for i in _pred], "correct": _pred == _true, } ) alt.Chart(embedding_df).mark_circle(size=18, opacity=0.75).encode( x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), color=alt.Color("topic:N", legend=alt.Legend(title="true topic")), shape=alt.Shape( "correct:N", legend=alt.Legend(title="prediction correct") ), tooltip=["topic", "predicted", "correct"], ).properties( width=560, height=420, title=f"t-SNE of {model_kind.value} hidden embeddings", )
6 · Going deeper
Why not 16 layers? Over-smoothing
CNNs for images often have 50+ layers, but GNNs usually stop at 2–3. Each GCN layer averages a node with its neighbours. Repeat that \(k\) times (\(\hat{A}^k X\)) and every node in a connected component drifts towards the same vector: the features "over-smooth" and the classes become indistinguishable. Mathematically, \(\hat{A}^k\) converges to a rank-1 projection as \(k \to \infty\).
Left (instant): apply \(\hat{A}\) to Cora's raw features \(k\) times with no learning, and measure how similar nodes become (mean cosine similarity of random node pairs: 0 = unrelated, 1 = identical). Right (on demand): actually train GCNs of increasing depth. If train accuracy drops too, the deep model isn't overfitting; it has lost the information needed to tell nodes apart.
def gcn_propagate( · 52 lines
def gcn_propagate( x: torch.Tensor, edge_index: torch.Tensor, k: int ) -> torch.Tensor: """Compute Â^k x with  = D^-1/2 (A+I) D^-1/2 (weight-free GCN propagation).""" ei, _ = add_self_loops(edge_index, num_nodes=x.size(0)) src, dst = ei deg = degree(dst, x.size(0), dtype=x.dtype) norm = (deg[src] * deg[dst]).rsqrt().unsqueeze(1) for _ in range(k): x = scatter( norm * x[src], dst, dim=0, dim_size=x.size(0), reduce="sum" ) return x def mean_cosine(h: torch.Tensor, n_pairs: int = 20_000) -> float: """Average cosine similarity over random node pairs.""" g = torch.Generator().manual_seed(0) a = torch.randint(0, h.size(0), (n_pairs,), generator=g).to(h.device) b = torch.randint(0, h.size(0), (n_pairs,), generator=g).to(h.device) return F.cosine_similarity(h[a], h[b], dim=1).mean().item() _rows = [] _x = graph.x for _k in range(0, 65): if _k in (0, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64): _rows.append( { "k (propagation steps)": _k, "mean cosine similarity": mean_cosine(_x), } ) _x = gcn_propagate(_x, graph.edge_index, 1) smoothing_df = pd.DataFrame(_rows) smoothing_chart = ( alt.Chart(smoothing_df) .mark_line(point=True) .encode( x=alt.X("k (propagation steps):Q", scale=alt.Scale(type="symlog")), y=alt.Y( "mean cosine similarity:Q", scale=alt.Scale(domain=[0, 1]) ), tooltip=[ "k (propagation steps)", alt.Tooltip("mean cosine similarity:Q", format=".3f"), ], ) .properties( width=330, height=230, title="Similarity of Âᵏ X: nodes become alike", ) )
depth_choices = mo.ui.multiselect( · 7 lines
depth_choices = mo.ui.multiselect( ["1", "2", "3", "4", "6", "8", "12", "16"], value=["1", "2", "4", "8", "16"], label="depths to train", ) depth_button = mo.ui.run_button(label="Train deep GCNs", kind="neutral") mo.hstack([depth_choices, depth_button], justify="start", gap=2)
class DeepGCN(torch.nn.Module): · 85 lines
class DeepGCN(torch.nn.Module): """GCN with `depth` layers (depth-1 hidden layers of size `hidden`).""" def __init__( self, depth: int, in_dim: int, hidden: int, out_dim: int, dropout: float = 0.5, ): super().__init__() dims = [in_dim] + [hidden] * (depth - 1) + [out_dim] self.convs = torch.nn.ModuleList( GCNConv(a, b) for a, b in zip(dims[:-1], dims[1:]) ) self.dropout = dropout def forward(self, x, edge_index): h = x for conv in self.convs[:-1]: h = F.elu( conv(F.dropout(h, self.dropout, self.training), edge_index) ) logits = self.convs[-1]( F.dropout(h, self.dropout, self.training), edge_index ) return logits, h if depth_button.value: _rows = [] for _d in mo.status.progress_bar( sorted(int(v) for v in depth_choices.value), title="Training GCNs of growing depth", ): torch.manual_seed(0) _m = DeepGCN(_d, dataset.num_features, 64, dataset.num_classes).to( device ) fit(_m, 200, 0.01, 5e-4) _acc = evaluate(_m) _rows.append( { "layers": _d, "train accuracy": _acc["train"], "test accuracy": _acc["test"], } ) depth_df = pd.DataFrame(_rows).melt( "layers", var_name="metric", value_name="value" ) _right = ( alt.Chart(depth_df) .mark_line(point=True) .encode( x=alt.X("layers:Q", scale=alt.Scale(type="log", base=2)), y=alt.Y("value:Q", scale=alt.Scale(domain=[0, 1])), color="metric:N", tooltip=[ "layers", "metric", alt.Tooltip("value:Q", format=".3f"), ], ) .properties( width=330, height=230, title="Trained GCNs: very deep ones fail to fit even the training set", ) ) else: _right = mo.md( "*Click **Train deep GCNs** (about 30 s) to fill this chart.*" ) mo.vstack( [ mo.hstack([smoothing_chart, _right], align="start"), mo.md( "**Fixes used in practice:** residual / skip connections, `JumpingKnowledge` (combine all layers' outputs), " "`PairNorm`, `DropEdge`, or decoupled models like APPNP that propagate far *without* stacking weights. " "All of these exist in `torch_geometric.nn`." ), ] )
Benchmark: Intel GPU vs CPU
GPUs win when there is lots of parallel work: dense feature matmuls and gathering messages over many edges. On tiny graphs, kernel-launch overhead eats into the gain. The benchmark times pure training steps (forward + backward + optimizer) for a GCN after a short warm-up, on:
- Cora: 2.7k nodes, 1,433 features
- Synthetic: random graph with 100k nodes, 1M edges, 256 features
bench_button = mo.ui.run_button(label="Run benchmark", kind="neutral") · 2 lines
bench_button = mo.ui.run_button(label="Run benchmark", kind="neutral") bench_button
mo.stop( · 99 lines
mo.stop( not bench_button.value, mo.md("*Click **Run benchmark** (about 30 s).*"), ) def time_training( x, edge_index, y, num_classes: int, dev: torch.device, steps: int = 20 ) -> float: """Average seconds per training step of a 2-layer GCN on `dev`.""" x, edge_index, y = x.to(dev), edge_index.to(dev), y.to(dev) m = GNN("GCN", x.size(1), 128, num_classes, dropout=0.5).to(dev) opt = torch.optim.Adam(m.parameters(), lr=0.01) def step(): opt.zero_grad() F.cross_entropy(m(x, edge_index)[0], y).backward() opt.step() def sync(): if dev.type == "xpu": torch.xpu.synchronize() elif dev.type == "cuda": torch.cuda.synchronize() for _ in range(3): # warm-up: first calls compile/allocate kernels step() sync() t0 = time.perf_counter() for _ in range(steps): step() sync() return (time.perf_counter() - t0) / steps _gen = torch.Generator().manual_seed(0) _n, _e = 100_000, 1_000_000 _workloads = { "Cora (2.7k nodes)": ( graph.x.cpu(), graph.edge_index.cpu(), graph.y.cpu(), dataset.num_classes, ), "Synthetic (100k nodes, 1M edges)": ( torch.randn(_n, 256, generator=_gen), torch.randint(0, _n, (2, _e), generator=_gen), torch.randint(0, 10, (_n,), generator=_gen), 10, ), } _devices = [torch.device("cpu")] + ( [device] if device.type != "cpu" else [] ) _rows = [] for _name, (_x, _ei, _y, _c) in mo.status.progress_bar( list(_workloads.items()), title="Benchmarking" ): for _dev in _devices: _rows.append( { "workload": _name, "device": str(_dev), "ms_per_step": 1000 * time_training(_x, _ei, _y, _c, _dev), } ) benchmark_df = pd.DataFrame(_rows) _speedup = benchmark_df.pivot( index="workload", columns="device", values="ms_per_step" ) if device.type != "cpu": _speedup["speedup"] = _speedup["cpu"] / _speedup[str(device)] mo.vstack( [ alt.Chart(benchmark_df) .mark_bar() .encode( x=alt.X( "ms_per_step:Q", title="ms per training step (lower is better)", ), y=alt.Y("device:N", title=None), color="device:N", row=alt.Row( "workload:N", title=None, header=alt.Header(labelAngle=0, labelAlign="left"), ), tooltip=[ "workload", "device", alt.Tooltip("ms_per_step:Q", format=".1f"), ], ) .properties(width=420, height=60), mo.ui.table(_speedup.round(2).reset_index(), selection=None), ] )
quiz = mo.ui.dictionary( · 44 lines
quiz = mo.ui.dictionary( { "q1": mo.ui.radio( [ "a dense N×N adjacency matrix", "a [2, num_edges] list of (source, target) pairs", "a networkx Graph", ], label="**1.** How does PyG store a graph's edges in `edge_index`?", ), "q2": mo.ui.radio( [ "it sees papers up to 2 citations away", "it only sees its direct neighbours", "it sees the whole graph", ], label="**2.** In a 2-layer GNN, what does each node's output depend on?", ), "q3": mo.ui.radio( ["GCN", "GAT", "GraphSAGE"], label="**3.** Which layer *learns* a different weight for each neighbour?", ), "q4": mo.ui.radio( [ "only the 140 training nodes are in the forward pass", "all nodes pass messages, but only training nodes contribute to the loss", "the masks remove test edges from the graph", ], label="**4.** What does `train_mask` do during training on Cora?", ), "q5": mo.ui.radio( [ "the GPU runs out of memory", "node representations become too similar (over-smoothing)", "the learning rate becomes too small", ], label="**5.** Why does accuracy drop when stacking many GCN layers?", ), } ) mo.vstack( [mo.md("### Check yourself: quick quiz"), *quiz.elements.values()], gap=0.75, )
_answers = { · 38 lines
_answers = { "q1": ( "a [2, num_edges] list of (source, target) pairs", "COO format: memory grows with edges, not nodes².", ), "q2": ( "it sees papers up to 2 citations away", "Each layer adds one hop to the receptive field (§2 explorer).", ), "q3": ( "GAT", "GAT computes attention α_ij per edge; GCN uses fixed 1/√(d_i d_j); SAGE uses a plain mean.", ), "q4": ( "all nodes pass messages, but only training nodes contribute to the loss", "Transductive setting: the whole graph is used, the mask only selects which predictions are scored.", ), "q5": ( "node representations become too similar (over-smoothing)", "Repeated averaging (Âᵏ) makes nodes alike (§6).", ), } _lines, _score = [], 0 for _q, (_right_answer, _why) in _answers.items(): _given = quiz.value[_q] if _given is None: _lines.append(f"- **{_q[1]}.** ⏳ not answered yet") elif _given == _right_answer: _score += 1 _lines.append(f"- **{_q[1]}.** ✅ correct. {_why}") else: _lines.append(f"- **{_q[1]}.** ❌ not quite. {_why}") mo.callout( mo.md( f"**Score: {_score} / {len(_answers)}**\n\n" + "\n".join(_lines) ), kind="success" if _score == len(_answers) else "neutral", )
Wrap-up & next steps
What you've seen
- PyG represents a graph as tensors (
x,edge_index,y, masks) in aDataobject - every GNN layer is message passing: message → aggregate → update, implemented with GPU
scatterops -
GCN (fixed degree weights), GAT (learned attention) and GraphSAGE (self + neighbour mean) beat an MLP by ~20 points on Cora because the graph is homophilous
-
2–3 layers is usually the sweet spot because of over-smoothing
Using the Intel GPU in your own code
- Move data and models with
.to("xpu"), exactly like.to("cuda") - Call
torch.xpu.synchronize()before reading a timer; GPU work is asynchronous - The iGPU shares system RAM, so large graphs fit, but bandwidth is lower than on a discrete card
- If an op isn't implemented for XPU yet, run with
PYTORCH_ENABLE_XPU_FALLBACK=1to send just that op to the CPU - Core layers and
torch_geometric.utils.scatterrun natively on XPU. The optional compiled extensions (pyg-lib,torch-scatter,torch-sparse,torch-cluster) have no XPU builds, so leave them uninstalled unless a feature (e.g. fastNeighborLoadersampling) asks for them
Try next
- Swap
name="Cora"for"CiteSeer"or"PubMed"(19.7k nodes) and rerun everything - Graph classification:
TUDataset("data/TU", "MUTAG")+DataLoader+global_mean_pool - Link prediction:
RandomLinkSplit+ dot-product decoder, e.g. "who will this user interact with?" -
Your own data: build a user–user graph from your tweets/mentions with
networkx, thentorch_geometric.utils.from_networkx(G) -
Explain predictions:
torch_geometric.explain.ExplainerwithGNNExplainer - Heterogeneous graphs (users + tweets + hashtags as different node types):
HeteroDataandto_hetero()
📚 PyG docs · Stanford CS224W: ML with Graphs · Hamilton, Graph Representation Learning (free book)