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/cs224w/L02_node_embeddings.py.
import warnings warnings.filterwarnings("ignore", category=FutureWarning) warnings.filterwarnings( "ignore", message=".*Level Zero.*" ) # harmless Intel-GPU probe message import hashlib import json import time from pathlib import Path import marimo as mo from gl.nb import reveal import numpy as np import pandas as pd import networkx as nx import matplotlib.pyplot as plt import torch from gl.datasets import planetoid import torch.nn.functional as F from torch_geometric.utils import to_undirected, negative_sampling from sklearn.decomposition import PCA from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score from sklearn.model_selection import StratifiedKFold, cross_val_score SEED = 0 rng = np.random.default_rng(SEED) torch.manual_seed(SEED) torch.set_num_threads(4) # several open notebooks/kernels share the CPU; more threads = contention DATA_DIR = ( mo.notebook_dir().parents[1] / "data" ) # shared with the other notebooks (Cora is cached here) CACHE_DIR = mo.notebook_dir() / "cache" CACHE_DIR.mkdir(exist_ok=True) plt.rcParams.update( { "figure.dpi": 100, "axes.spines.top": False, "axes.spines.right": False, } )
L02 · Node embeddings
CS224W Machine Learning with Graphs · hands-on companion to Lecture 2
Audience: you finished L01 (graphs as edge_index, node/link/graph tasks).
Prerequisites: PyTorch basics (tensors, nn.Module, an optimiser loop), logistic regression.
By the end you can:
- write the encoder → decoder → similarity template and a shallow encoder (
nn.Embedding); - generate uniform (DeepWalk) and biased (node2vec, \(p,q\)) random walks from scratch and predict their behaviour;
- train skip-gram with negative sampling on walks and evaluate the embeddings on node classification and link prediction (Cora);
- see that DeepWalk is secretly matrix factorisation (NetMF), and name the limits of shallow embeddings.
In short. Replace hand-made features with a learned map \(f: u\mapsto \mathbf z_u\in\mathbb R^d\). An encoder gives each node a vector, a decoder scores pairs (\(\mathbf z_u^\top\mathbf z_v\)), and training makes the score match a chosen similarity: "\(u\) and \(v\) co-occur on short random walks". node2vec biases the walks with \(p,q\) between BFS-like (local) and DFS-like (exploratory). The catch: a lookup table — no features, no new nodes. GNNs (L03) fix that.
Outline
- The template — encoder (a lookup table), decoder (dot product), similarity.
- Random walks — uniform walks and what "co-occurrence" looks like.
- node2vec's biased walk — the \(p,q\) step by hand, then BFS-like vs DFS-like walks.
- Skip-gram with negative sampling — train embeddings on walks, from scratch, on Karate.
- Real data: Cora — node classification and link prediction with node2vec embeddings (cached, run on demand).
- It was matrix factorisation all along — NetMF in a few lines of linear algebra.
- Limitations — two identical cliques, and unseen nodes.
- Exercises, pitfall (link-prediction leakage), research links.
1 · The template: encoder → decoder → similarity
- Encoder \(\text{ENC}(v)=\mathbf z_v = Z[:,v]\): a shallow encoder is just a table with one free \(d\)-vector per node — in PyTorch,
nn.Embedding(N, d). - Decoder \(\text{DEC}(\mathbf z_u,\mathbf z_v)=\mathbf z_u^\top\mathbf z_v\).
- Similarity \(S(u,v)\): the modelling choice. Adjacency? Shared neighbours? Co-occurrence on random walks? Training pushes \(\mathbf z_u^\top\mathbf z_v \approx S(u,v)\).
Parameters: \(N\times d\). For Cora (\(N\)=2708, \(d\)=64) that is 173k numbers, and none of them is shared between nodes.
karate = nx.karate_club_graph() club = np.array( [karate.nodes[v]["club"] == "Officer" for v in karate], dtype=int ) karate_pos = nx.spring_layout(karate, seed=SEED) karate_adj = { v: np.array(sorted(karate.neighbors(v))) for v in karate } # adjacency lists, used by the walkers def uniform_walk(adj, start, length, r): """DeepWalk: each step moves to a uniformly random neighbour.""" walk = [start] for _ in range(length - 1): walk.append(int(r.choice(adj[walk[-1]]))) return walk walk_start = mo.ui.slider( 0, 33, value=16, label="start node", show_value=True ) walk_len = mo.ui.slider( 2, 30, value=10, label="walk length", show_value=True ) walk_seed = mo.ui.slider(0, 20, value=0, label="seed", show_value=True) mo.hstack([walk_start, walk_len, walk_seed])
_walk = uniform_walk( karate_adj, walk_start.value, walk_len.value, np.random.default_rng(walk_seed.value), ) _fig, _ax = plt.subplots(figsize=(6, 3.8)) nx.draw_networkx_edges(karate, karate_pos, ax=_ax, alpha=0.15) nx.draw_networkx_nodes( karate, karate_pos, ax=_ax, node_size=90, node_color=["#4C78A8" if c == 0 else "#F58518" for c in club], ) nx.draw_networkx_edges( karate, karate_pos, edgelist=list(zip(_walk[:-1], _walk[1:])), ax=_ax, edge_color="crimson", width=2.5, alpha=0.8, ) nx.draw_networkx_labels( karate, karate_pos, labels={v: v for v in set(_walk)}, font_size=7, ax=_ax, ) _ax.set_title("walk: " + " → ".join(map(str, _walk)), fontsize=8) _ax.axis("off") _ax
Try it: move the start node and seed. Walks wander mostly inside the start node's faction and keep returning to the hubs (0 and 33). That is the signal DeepWalk uses: nodes that co-occur on short walks should get similar vectors, just as word2vec treats words that share sentences.
Below we run 20 walks of length 10 from every node and count how often two nodes appear within a window of ±w steps.
That co-occurrence matrix — not the adjacency matrix — is the similarity \(S(u,v)\) the embeddings will try to reproduce.
def cooccurrence(walks, n, window): C = np.zeros((n, n)) for walk in walks: for i, u in enumerate(walk): for v in walk[max(0, i - window) : i + window + 1]: if v != u: C[u, v] += 1 return C window_slider = mo.ui.slider( 1, 8, value=3, label="window w", show_value=True ) window_slider
_r = np.random.default_rng(SEED) karate_walks = [ uniform_walk(karate_adj, v, 10, _r) for v in karate for _ in range(20) ] _C = cooccurrence(karate_walks, 34, window_slider.value) _order = np.argsort( club, kind="stable" ) # Mr. Hi's faction first, then the Officer's _A = nx.to_numpy_array(karate, nodelist=range(34), weight=None) _fig, _axs = plt.subplots(1, 2, figsize=(8, 3.6)) _axs[0].imshow(_A[np.ix_(_order, _order)], cmap="Greys") _axs[0].set_title("adjacency A (nodes sorted by faction)", fontsize=9) _axs[1].imshow(np.log1p(_C[np.ix_(_order, _order)]), cmap="magma") _axs[1].set_title( f"log(1 + co-occurrence), window {window_slider.value}", fontsize=9 ) for _a in _axs: _a.axhline(club.size - club.sum() - 0.5, color="cyan", lw=0.8) _a.axvline(club.size - club.sum() - 0.5, color="cyan", lw=0.8) _a.set_xticks([]) _a.set_yticks([]) _within = ( _C[np.ix_(club == 0, club == 0)].sum() + _C[np.ix_(club == 1, club == 1)].sum() ) _axs[1].set_xlabel( f"{_within / _C.sum():.0%} of co-occurrence mass is within a faction", fontsize=9, ) _fig
At window 1 the co-occurrence matrix is essentially the adjacency (only ≈ 13% of pairs non-zero). At window 3, ≈ 78% of pairs co-occur — the similarity now reaches multi-hop neighbours — yet ≈ 79% of the mass stays inside a faction (two bright diagonal blocks). Wider windows blur the factions (≈ 72% at \(w=8\)). The window is a knob between local and global similarity.
3 · node2vec: a biased second-order walk
node2vec remembers where it came from. The walker just moved \(t\to w\); for each neighbour \(x\) of \(w\) the unnormalised weight is $\(\alpha_{pq}(t,x)=\begin{cases}1/p & d(t,x)=0 \;(\text{go back})\\ 1 & d(t,x)=1 \;(\text{stay near } t)\\ 1/q & d(t,x)=2 \;(\text{move away})\end{cases}\)$ Large \(p\) → rarely return. Small \(q\) → prefer moving away (DFS-like, exploratory); large \(q\) → stay local (BFS-like). The lecture's worked example: \(w\) has neighbours \(t\), \(s_1\) (also adjacent to \(t\)), \(s_2\), \(s_3\).
def node2vec_step_probs(adj_sets, prev, cur, p, q): """Transition probabilities from `cur`, given the walker arrived from `prev` (node2vec, unweighted graph).""" nbrs = sorted(adj_sets[cur]) w = np.array( [ 1 / p if x == prev else 1.0 if x in adj_sets[prev] else 1 / q for x in nbrs ] ) return nbrs, w / w.sum() # the lecture's example graph toy_n2v = nx.Graph( [("t", "w"), ("t", "s1"), ("w", "s1"), ("w", "s2"), ("w", "s3")] ) toy_sets = {v: set(toy_n2v[v]) for v in toy_n2v} p_slider = mo.ui.slider( steps=[0.25, 0.5, 1, 2, 4, 8], value=4, label="return p", show_value=True, ) q_slider = mo.ui.slider( steps=[0.25, 0.5, 1, 2, 4, 8], value=0.5, label="in-out q", show_value=True, ) mo.vstack( [ mo.md( "**Try it:** the lecture uses $p=4, q=0.5$ (→ 0.048, 0.19, 0.38, 0.38), then swaps to $p=0.5, q=4$." ), mo.hstack([p_slider, q_slider], justify="start"), ] )
_nbrs, _probs = node2vec_step_probs( toy_sets, "t", "w", p_slider.value, q_slider.value ) _fig, _axs = plt.subplots( 1, 2, figsize=(8, 2.8), gridspec_kw={"width_ratios": [1, 1.4]} ) _pos = { "t": (0, 0), "w": (1, 0), "s1": (0.5, 0.8), "s2": (2, 0.5), "s3": (2, -0.5), } nx.draw( toy_n2v, _pos, ax=_axs[0], with_labels=True, node_color=["#F58518" if v == "w" else "#ddd" for v in toy_n2v], node_size=500, font_size=9, ) _axs[0].annotate( "", xy=(0.85, 0), xytext=(0.15, 0), arrowprops=dict(arrowstyle="->", color="crimson", lw=2), ) _axs[0].set_title("walker came t → w", fontsize=9) _bars = _axs[1].bar( _nbrs, _probs, color=[ "#E45756" if x == "t" else "#72B7B2" if x == "s1" else "#4C78A8" for x in _nbrs ], ) _axs[1].bar_label(_bars, labels=[f"{v:.3f}" for v in _probs], fontsize=8) _axs[1].set_ylim(0, 1) _axs[1].set_title( f"next-step probabilities (p={p_slider.value}, q={q_slider.value})", fontsize=9, ) _fig
def node2vec_walk(adj_sets, start, length, p, q, r): """One node2vec walk. The first step is uniform; later steps use node2vec_step_probs.""" walk = [start, int(r.choice(sorted(adj_sets[start])))] while len(walk) < length: nbrs, probs = node2vec_step_probs( adj_sets, walk[-2], walk[-1], p, q ) walk.append(int(nbrs[r.choice(len(nbrs), p=probs)])) return walk # a ring of 12 cliques of 6 nodes: clear local structure + long-range ring structure caveman = nx.connected_caveman_graph(12, 6) caveman_sets = {v: set(caveman[v]) for v in caveman} _clique_of = {v: v // 6 for v in caveman} _dist = dict(nx.all_pairs_shortest_path_length(caveman)) def walk_profile(p, q, n_walks=300, length=20, seed=SEED): r = np.random.default_rng(seed) rows = [] for _ in range(n_walks): s = int(r.integers(caveman.number_of_nodes())) w = node2vec_walk(caveman_sets, s, length, p, q, r) rows.append( { "distinct nodes": len(set(w)), "distinct cliques": len({_clique_of[v] for v in w}), "final distance from start": _dist[s][w[-1]], "immediate returns": sum(a == b for a, b in zip(w, w[2:])), } ) return pd.DataFrame(rows).mean() pd.DataFrame( { "BFS-like (p=0.5, q=4)": walk_profile(0.5, 4), "DeepWalk (p=1, q=1)": walk_profile(1, 1), "DFS-like (p=4, q=0.25)": walk_profile(4, 0.25), } ).T.round(2)
Same graph, same walk length (20), three settings. BFS-like walks stay put: ≈ 6 distinct nodes, ≈ 1.3 cliques, and ≈ 6.5 immediate back-steps. DFS-like walks cover twice as many nodes and ≈ 3 cliques and end ≈ 4 hops away. So \(p,q\) decide which nodes co-occur, and therefore what "similar" means for the embeddings.
4 · Skip-gram with negative sampling (SGNS), from scratch
For a centre node \(u\) and a context node \(v\) seen within the window, maximise \(\log\sigma(\mathbf z_u^\top\mathbf z_v)\). The exact objective needs a softmax over all \(N\) nodes for every pair, which is far too expensive. Negative sampling replaces it with \(k\) random "noise" nodes \(n_i\sim P_n\propto \deg^{3/4}\): $\(\mathcal L(u,v)=-\log\sigma(\mathbf z_u^\top\mathbf z_v)-\sum_{i=1}^{k}\log\sigma(-\mathbf z_u^\top\mathbf z_{n_i}).\)$
def walks_to_pairs(walks, window): · 83 lines
def walks_to_pairs(walks, window): """All (centre, context) pairs within ±window steps (plain Python, fine for small graphs).""" centres, contexts = [], [] for walk in walks: for i, u in enumerate(walk): for v in walk[max(0, i - window) : i + window + 1]: if v != u: centres.append(u) contexts.append(v) return np.array(centres), np.array(contexts) def train_sgns( centres, contexts, n_nodes, dim=16, n_neg=5, epochs=3, lr=0.02, batch=1024, seed=SEED, ): """Skip-gram with negative sampling on (centre, context) pairs, ONE table Z (decoder z_u . z_v). Returns (Z as numpy, per-epoch mean losses).""" torch.manual_seed(seed) centres, contexts = torch.as_tensor(centres), torch.as_tensor(contexts) noise = ( torch.bincount(centres, minlength=n_nodes).float().pow(0.75) ) # P_n ∝ freq^(3/4) noise /= noise.sum() Z = torch.nn.Embedding(n_nodes, dim) torch.nn.init.normal_(Z.weight, std=0.1) opt = torch.optim.Adam(Z.parameters(), lr=lr) losses = [] for _ in range(epochs): perm, total = torch.randperm(len(centres)), 0.0 for k in range(0, len(perm), batch): idx = perm[k : k + batch] zu, zv = Z(centres[idx]), Z(contexts[idx]) zn = Z( torch.multinomial( noise, len(idx) * n_neg, replacement=True ) ).view(len(idx), n_neg, dim) pos = F.logsigmoid((zu * zv).sum(-1)) # log σ(z_u·z_v) neg = F.logsigmoid(-(zn @ zu.unsqueeze(-1)).squeeze(-1)).sum( -1 ) # Σ log σ(-z_u·z_n) loss = -(pos + neg).mean() opt.zero_grad() loss.backward() opt.step() total += loss.item() * len(idx) losses.append(total / len(perm)) return Z.weight.detach().numpy(), losses emb_controls = mo.ui.dictionary( { "p": mo.ui.slider( steps=[0.25, 0.5, 1, 2, 4], value=1, label="p", show_value=True ), "q": mo.ui.slider( steps=[0.25, 0.5, 1, 2, 4], value=1, label="q", show_value=True ), "window": mo.ui.slider( 1, 6, value=3, label="window", show_value=True ), "dim": mo.ui.slider( steps=[2, 4, 8, 16, 32], value=16, label="dim d", show_value=True, ), } ) mo.vstack( [ mo.md( "**Try it:** train node2vec on Karate (≈ 1 s). Compare $q=4$ (local) with $q=0.25$ (exploratory), and $d=2$ with $d=16$." ), mo.hstack(list(emb_controls.elements.values()), justify="start"), ] )
_t0 = time.time() · 59 lines
_t0 = time.time() _r = np.random.default_rng(SEED) _sets = {v: set(karate[v]) for v in karate} _walks = [ node2vec_walk( _sets, v, 10, emb_controls.value["p"], emb_controls.value["q"], _r ) for v in karate for _ in range(20) ] karate_Z, _losses = train_sgns( *walks_to_pairs(_walks, emb_controls.value["window"]), 34, dim=emb_controls.value["dim"], epochs=5, ) _secs = time.time() - _t0 _acc = cross_val_score( LogisticRegression(max_iter=2000), karate_Z, club, cv=StratifiedKFold(5, shuffle=True, random_state=SEED), ).mean() _xy = ( karate_Z if karate_Z.shape[1] == 2 else PCA(2, random_state=SEED).fit_transform(karate_Z) ) _fig, _axs = plt.subplots( 1, 2, figsize=(8, 3.2), gridspec_kw={"width_ratios": [1, 1.6]} ) _axs[0].plot(range(1, len(_losses) + 1), _losses, "o-") _axs[0].set_xlabel("epoch") _axs[0].set_title("SGNS loss", fontsize=9) _axs[1].scatter( _xy[:, 0], _xy[:, 1], c=["#4C78A8" if c == 0 else "#F58518" for c in club], s=60, edgecolors="k", linewidths=0.4, ) for _v in karate: _axs[1].annotate( str(_v), _xy[_v], fontsize=6, ha="center", va="center" ) _kind = ( "raw 2-D" if karate_Z.shape[1] == 2 else f"PCA of {karate_Z.shape[1]}-D" ) _axs[1].set_title( f"embeddings ({_kind}); faction accuracy {_acc:.2f}", fontsize=9 ) plt.tight_layout() mo.vstack( [_fig, mo.md(f"trained in {_secs:.1f} s on {len(_walks)} walks")] )
Even this small model separates the factions: ≈ 0.94 cross-validated accuracy from the embedding alone, with no labels used in training. The loss barely moves, from ≈ 4.13 to ≈ 4.10; the "no information" value is \(6\log 2\approx4.16\). On 34 nodes, the "random" negatives are often real context nodes (the hubs 0 and 33 show up in almost every walk), so the loss has a high floor. Judge embeddings by downstream tasks, not by the SGNS loss.
Try it: \(d=2\) still separates the factions, but less cleanly. \(q=4\) (BFS-like) makes nodes with the same immediate neighbourhood close; \(q=0.25\) pulls whole communities together.
5 · Real data: node2vec on Cora
Cora: 2,708 papers, 5,278 citation links, 7 topics. The Python walker above is too slow here, so we vectorise it. All
walkers step at once; the \(p,q\) bias is applied by rejection sampling (propose a uniform neighbour, accept with
probability \(\alpha_{pq}/\max\alpha\)), which is how PyG's torch_cluster.random_walk does it.
cora = planetoid("Cora")[0] · 64 lines
cora = planetoid("Cora")[0] print(cora) def node2vec_walks_fast( edge_index, n, walks_per_node, length, p, q, seed=SEED ): """Vectorised node2vec walks (rejection sampling). Returns an int array [n * walks_per_node, length].""" r = np.random.default_rng(seed) src, dst = edge_index.numpy() order = np.argsort(src, kind="stable") src, dst = src[order], dst[order] rowptr = np.searchsorted(src, np.arange(n + 1)) deg = np.diff(rowptr) edge_keys = np.sort( src.astype(np.int64) * n + dst ) # for "is x adjacent to prev?" lookups is_edge = lambda a, b: np.isin( a.astype(np.int64) * n + b, edge_keys, assume_unique=False ) starts = np.repeat(np.arange(n), walks_per_node) starts = starts[deg[starts] > 0] walks = np.empty((len(starts), length), dtype=np.int64) walks[:, 0] = starts walks[:, 1] = dst[ rowptr[starts] + (r.random(len(starts)) * deg[starts]).astype(int) ] max_w = max(1 / p, 1.0, 1 / q) for t in range(2, length): prev, cur = walks[:, t - 2], walks[:, t - 1] nxt = np.empty_like(cur) todo = np.arange(len(cur)) while len(todo): c = cur[todo] x = dst[ rowptr[c] + (r.random(len(todo)) * deg[c]).astype(int) ] # uniform proposal alpha = np.where( x == prev[todo], 1 / p, np.where(is_edge(prev[todo], x), 1.0, 1 / q), ) accept = r.random(len(todo)) < alpha / max_w nxt[todo[accept]] = x[accept] todo = todo[~accept] walks[:, t] = nxt return walks def walks_to_pairs_array(walks, window): """Vectorised (centre, context) pairs for a [num_walks, length] walk array.""" c, o = [], [] for k in range(1, window + 1): a, b = walks[:, :-k].ravel(), walks[:, k:].ravel() c += [a, b] o += [b, a] return np.concatenate(c), np.concatenate(o) _t = time.time() _demo_walks = node2vec_walks_fast( cora.edge_index, cora.num_nodes, 10, 20, p=1, q=0.5 ) mo.md( f"Generated **{_demo_walks.shape[0]:,} walks** of length {_demo_walks.shape[1]} in **{time.time() - _t:.1f} s**. " f"First walk: `{_demo_walks[0].tolist()}`" )
Vectorised, all 27,080 walks take well under a second. (Sanity check, run while building this notebook: on the toy graph the fast walker's empirical \(t\to w\to\cdot\) frequencies were 0.047 / 0.196 / 0.375 / 0.382, matching the exact 0.048 / 0.19 / 0.38 / 0.38.)
Two evaluations, both standard:
- Node classification — fit logistic regression on \(\mathbf z_v\) for the 140 labelled training papers, test on the 1,000 test papers.
- Link prediction — hide 10% of the edges before training the embeddings, then rank hidden edges vs. random non-edges by \(\mathbf z_u^\top\mathbf z_v\) (ROC AUC). Training on the full graph would leak the test edges (see the pitfall in §8).
Each embedding takes ≈ 25–35 s on a laptop CPU, so it runs on demand and is cached to notebooks/cs224w/cache/:
re-runs with the same settings load from disk instantly.
def cached_array(tag, params, compute): · 94 lines
def cached_array(tag, params, compute): """Load `tag_<hash(params)>.npy` from CACHE_DIR, or compute + save. Returns (array, was_cached).""" key = hashlib.sha1( json.dumps(params, sort_keys=True).encode() ).hexdigest()[:12] path = CACHE_DIR / f"{tag}_{key}.npy" if path.exists(): return np.load(path), True arr = compute() np.save(path, arr) return arr, False def make_link_split(edge_index, n, frac=0.1, seed=SEED): """Hide `frac` of the undirected edges; return (train edge_index, test positives, test negatives).""" g = torch.Generator().manual_seed(seed) und = edge_index[ :, edge_index[0] < edge_index[1] ] # each undirected edge once perm = torch.randperm(und.size(1), generator=g) n_test = int(frac * und.size(1)) test_pos, train_und = und[:, perm[:n_test]], und[:, perm[n_test:]] torch.manual_seed(seed) test_neg = negative_sampling( edge_index, num_nodes=n, num_neg_samples=n_test ) # true non-edges return to_undirected(train_und), test_pos, test_neg cora_train_ei, cora_test_pos, cora_test_neg = make_link_split( cora.edge_index, cora.num_nodes ) def node2vec_embedding( edge_index, tag, p, q, dim=64, walks_per_node=10, length=20, window=5 ): params = dict( tag=tag, p=p, q=q, dim=dim, walks_per_node=walks_per_node, length=length, window=window, seed=SEED, ) def compute(): walks = node2vec_walks_fast( edge_index, cora.num_nodes, walks_per_node, length, p, q ) Z, _ = train_sgns( *walks_to_pairs_array(walks, window), cora.num_nodes, dim=dim, epochs=1, lr=0.01, batch=4096, ) return Z return cached_array("cora_n2v", params, compute) def evaluate_cora(Z_full, Z_train): tr, te, y = ( cora.train_mask.numpy(), cora.test_mask.numpy(), cora.y.numpy(), ) acc = ( LogisticRegression(max_iter=3000) .fit(Z_full[tr], y[tr]) .score(Z_full[te], y[te]) ) score = lambda Z, e: (Z[e[0].numpy()] * Z[e[1].numpy()]).sum(1) auc = roc_auc_score( np.r_[ np.ones(cora_test_pos.size(1)), np.zeros(cora_test_neg.size(1)) ], np.r_[ score(Z_train, cora_test_pos), score(Z_train, cora_test_neg) ], ) return acc, auc cora_setting = mo.ui.dropdown( { "DeepWalk (p=1, q=1)": (1, 1), "BFS-like (p=1, q=4)": (1, 4), "DFS-like (p=1, q=0.25)": (1, 0.25), }, value="DeepWalk (p=1, q=1)", label="walk setting", ) cora_run = mo.ui.run_button(label="Train / load embeddings") mo.hstack([cora_setting, cora_run], justify="start")
mo.stop( · 47 lines
mo.stop( not cora_run.value, mo.md( "Press **Train / load embeddings** (≈ 1 min first time per setting, instant afterwards)." ), ) _p, _q = cora_setting.value _t = time.time() cora_Z, _hit1 = node2vec_embedding(cora.edge_index, "full", _p, _q) cora_Z_train, _hit2 = node2vec_embedding( cora_train_ei, "train-edges", _p, _q ) _acc, _auc = evaluate_cora(cora_Z, cora_Z_train) _tr, _te, _y = ( cora.train_mask.numpy(), cora.test_mask.numpy(), cora.y.numpy(), ) _raw_acc = ( LogisticRegression(max_iter=3000) .fit(cora.x.numpy()[_tr], _y[_tr]) .score(cora.x.numpy()[_te], _y[_te]) ) _xy = PCA(2, random_state=SEED).fit_transform(cora_Z) _fig, _ax = plt.subplots(figsize=(5.5, 3.6)) _ax.scatter(_xy[:, 0], _xy[:, 1], c=_y, cmap="tab10", s=4, alpha=0.7) _ax.set_title( f"Cora node2vec embeddings (PCA), colour = topic — {cora_setting.selected_key}", fontsize=8, ) _ax.set_xticks([]) _ax.set_yticks([]) mo.hstack( [ _fig, mo.md(f""" | metric | value | |---|---| | node classification acc (embedding) | **{_acc:.3f}** | | node classification acc (raw bag-of-words features) | {_raw_acc:.3f} | | link prediction AUC (held-out edges) | **{_auc:.3f}** | | cache | {"hit (loaded from disk)" if _hit1 and _hit2 else "computed and saved"} | | time | {time.time() - _t:.1f} s | """), ], widths=[3, 2], )
_rev = reveal( · 27 lines
_rev = reveal( r""" Measured on this machine (one run each, seed 0, 64-D, 1 epoch): | walk setting | node classification acc | link-prediction AUC | |---|---|---| | DeepWalk (p=1, q=1) | 0.654 | 0.854 | | BFS-like (p=1, q=4) | 0.594 | 0.853 | | DFS-like (p=1, q=0.25) | 0.636 | 0.855 | All beat logistic regression on the raw bag-of-words features (0.576) **without seeing a single label during training**. Topic labels on Cora follow **communities** (homophily), so the local BFS-like walks are worst for classification, while link prediction barely cares. In the PCA plot most topics (colours) occupy their own region. One topic (red) is clearly separated; the rest overlap, which is why a linear classifier tops out around 0.65. """, predict="Which walk setting (DeepWalk p=q=1, BFS-like q=4, DFS-like q=0.25) classifies Cora's topics best, and do any beat logistic regression on the raw words (≈ 0.58) without seeing a label? Hint: are Cora's topics communities or structural roles?", ) _post = mo.md(r""" ## 6 · It was matrix factorisation all along (NetMF) Qiu et al. (2018) showed that DeepWalk with window $T$ and $b$ negatives implicitly factorises $$M=\log\!\Big(\tfrac{\text{vol}(G)}{bT}\Big(\textstyle\sum_{r=1}^{T}P^r\Big)D^{-1}\Big),\qquad P=D^{-1}A,\ \ \text{vol}(G)=\textstyle\sum_v d_v.$$ So we can skip walks and SGD: build $M$ (clip at 1 before the log, as in the paper), take a rank-$d$ SVD $M\approx U_d\Sigma_dV_d^\top$, and use $Z=U_d\sqrt{\Sigma_d}$. Exact, deterministic, and on Cora only a few seconds. """) mo.vstack([_rev, _post])
def netmf_embedding(edge_index, n, window=5, neg=5, dim=64): A = torch.zeros(n, n) A[edge_index[0], edge_index[1]] = 1.0 deg = A.sum(1).clamp(min=1) # isolated nodes would divide by 0 P = A / deg[:, None] # random-walk transition matrix D^-1 A S, Pr = torch.zeros(n, n), torch.eye(n) for _ in range(window): Pr = Pr @ P S += Pr # Σ_{r=1..T} P^r M = ( (deg.sum() / (neg * window)) * S / deg[None, :] ) # ... D^-1 on the right M = torch.log(M.clamp(min=1.0)) # truncated log, as in NetMF U, sig, _ = torch.linalg.svd(M) return (U[:, :dim] * sig[:dim].sqrt()).numpy() _t = time.time() netmf_Z = netmf_embedding(cora.edge_index, cora.num_nodes) netmf_Z_train = netmf_embedding(cora_train_ei, cora.num_nodes) _acc, _auc = evaluate_cora(netmf_Z, netmf_Z_train) mo.md( f"**NetMF on Cora** (T=5, b=5, d=64): node classification acc **{_acc:.3f}**, link-prediction AUC **{_auc:.3f}** — " f"computed in {time.time() - _t:.1f} s, no random walks, no SGD." )
NetMF reaches 0.625 accuracy and 0.840 AUC, close to the SGD-trained node2vec (0.654 / 0.854): the same objective, solved in closed form. (The exact numbers differ because SGNS with one epoch and a single table only approximately optimises the objective.) This is also Homework 1 Q2: "node embedding ⇔ matrix factorisation" with decoder \(\mathbf z_i^\top\mathbf z_j\).
7 · Limitations → why we need GNNs
(a) Structure vs. proximity (HW1 Q2.5–2.8). Take two disjoint 10-cliques. Every node is structurally identical, but a walk never crosses between cliques, so nothing ties clique A's embeddings to clique B's.
two_cliques = nx.disjoint_union( nx.complete_graph(10), nx.complete_graph(10) ) _r = np.random.default_rng(SEED) _sets = {v: set(two_cliques[v]) for v in two_cliques} _walks = [ node2vec_walk(_sets, v, 10, 1, 1, _r) for v in two_cliques for _ in range(30) ] cliques_Z, _ = train_sgns(*walks_to_pairs(_walks, 3), 20, dim=8, epochs=5) _Zn = cliques_Z / np.linalg.norm(cliques_Z, axis=1, keepdims=True) _cos = _Zn @ _Zn.T _same = _cos[:10, :10][~np.eye(10, dtype=bool)].mean() _cross = _cos[:10, 10:].mean() _fig, _ax = plt.subplots(figsize=(3.4, 3)) _im = _ax.imshow(_cos, cmap="RdBu_r", vmin=-1, vmax=1) plt.colorbar(_im, ax=_ax, fraction=0.046) _ax.set_title("cosine(z_u, z_v), nodes 0–9 | 10–19", fontsize=8) _ax.set_xticks([]) _ax.set_yticks([]) mo.hstack( [ _fig, mo.md(f""" mean cosine **within** a clique: **{_same:.2f}** mean cosine **across** cliques: **{_cross:.2f}** Node 0 and node 10 play the *same role* (a member of a 10-clique), yet their vectors point in **opposite** directions: negative sampling actively pushes nodes that never co-occur apart. Random-walk similarity is **proximity**, not **structural role**. struc2vec (HW Q2.8) fixes this by walking on a *structural-similarity* graph; GNNs (L03) fix it because the same function of the neighbourhood gives the same output for identical neighbourhoods. """), ], widths=[2, 3], )
(b) Transductive only. \(Z\) has one row per node seen in training. A new paper added to Cora tomorrow has no row; you must re-run walks and SGD for the whole graph. (c) No features. The 1,433-dim bag-of-words of each paper is ignored (the raw features alone already give 0.576). (d) \(O(|V|d)\) parameters, none shared.
GNNs (L03) fix (b)–(d) at once: the embedding becomes a function of the node's features and its neighbourhood.
8 · Exercises
Exercise 1 (worked example, swapped knobs). For the toy graph of §3 with the walker coming \(t\to w\), and \(p=0.5,\ q=4\),
what is the probability of stepping back to \(t\)? Put it in ex1_back_prob (3 decimals).
Exercise 2 (HW1 Q1.2). For the Karate graph, the uniform random walk has transition matrix \(P=D^{-1}A\). Find its
stationary distribution \(\pi\) (\(\pi^\top P=\pi^\top\), \(\sum\pi=1\)) and put it in ex2_pi as a length-34 NumPy array.
Any method is fine: power iteration, an eigenvector, or a closed form.
ex1_back_prob = None ex2_pi = None
def _verdict( · 51 lines
def _verdict( ok, answered, msg_ok="Correct!", msg_no="Not yet — try again or open a hint.", ): if not answered: return mo.callout(mo.md("Not answered yet."), kind="neutral") return mo.callout( mo.md(msg_ok if ok else msg_no), kind="success" if ok else "warn" ) _A = nx.to_numpy_array(karate, nodelist=range(34), weight=None) _pi_true = _A.sum(1) / _A.sum() _ex2_ok = ( ex2_pi is not None and np.shape(ex2_pi) == (34,) and np.allclose(ex2_pi, _pi_true, atol=1e-4) ) mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_back_prob is not None and abs(ex1_back_prob - 2 / 3.5) < 2e-3, ex1_back_prob is not None, ), mo.md("**Exercise 2**"), _verdict(_ex2_ok, ex2_pi is not None), mo.accordion( { "Hint 1": mo.md( "Weights: $t: 1/p$, $s_1: 1$, $s_2, s_3: 1/q$. Normalise." ), "Solution 1": mo.md( r"Weights $2, 1, 0.25, 0.25$, sum 3.5, so $P(\text{back})=2/3.5\approx 0.571$. Or call `node2vec_step_probs(toy_sets, 't', 'w', 0.5, 4)`." ), "Hint 2": mo.md( "Try power iteration: start with uniform $\\pi$ and repeat `pi = pi @ P`. Then look at the result next to the degrees." ), "Solution 2": mo.md(r"""For an undirected connected graph $\pi_v = d_v / 2|E|$: detailed balance $\pi_u P_{uv} = \frac{d_u}{2|E|}\frac{1}{d_u} = \frac{1}{2|E|}$ is symmetric. ```python A = nx.to_numpy_array(karate, nodelist=range(34), weight=None) ex2_pi = A.sum(1) / A.sum() ``` (Power iteration also converges here because Karate is connected and non-bipartite.) Hubs are visited most, which is why negative sampling uses $\text{freq}^{3/4}$ to flatten the distribution a little."""), } ), ] )
9 · Pitfall: evaluating link prediction with embeddings that saw the test edges
It is tempting to train embeddings once on the full graph and reuse them for every task. For link prediction that leaks: the hidden edges shaped the embeddings. Same NetMF method, same test pairs, two training graphs:
_score = lambda Z, e: (Z[e[0].numpy()] * Z[e[1].numpy()]).sum(1) _labels = np.r_[ np.ones(cora_test_pos.size(1)), np.zeros(cora_test_neg.size(1)) ] _auc_leak = roc_auc_score( _labels, np.r_[_score(netmf_Z, cora_test_pos), _score(netmf_Z, cora_test_neg)], ) _auc_clean = roc_auc_score( _labels, np.r_[ _score(netmf_Z_train, cora_test_pos), _score(netmf_Z_train, cora_test_neg), ], ) mo.callout( mo.md(f"""**Leaky** (embeddings trained on all edges, test edges included): AUC **{_auc_leak:.3f}** **Clean** (embeddings trained without the test edges): AUC **{_auc_clean:.3f}** The leaky number is what you'd report by mistake. **Fix:** split the edges *first* (L05 formalises this with message edges vs. supervision edges and `RandomLinkSplit`), and train the embeddings on the training edges only."""), kind="warn", )
10 · Connecting to your research (SNA on X/Twitter)
-
Embeddings of a retweet / follow graph are a strong unsupervised baseline for stance and community detection: run
node2vec_walks_fast+train_sgnson youruser_relation_insideedges, then cluster or fit a classifier on a few labelled users. -
Choose \(p,q\) by the question. "Which camp?" (homophily) → DeepWalk / DFS-like (\(q\le1\)). "Which role (broadcaster, amplifier, bridge)?" → role similarity, which random walks capture poorly (§7a); use structural features (L20) or GNNs.
-
Directed graphs: a walk on the follow graph only moves along follows. Decide whether to symmetrise (who is connected) or not (who reaches whom) — and split edges before training if you evaluate link prediction (§9).
-
New accounts have no embedding (§7b). That is the practical reason to move to GNNs for live bot detection.
Summary
- Shallow embedding = lookup table \(Z\); decoder \(\mathbf z_u^\top\mathbf z_v\); the similarity you choose is the model.
- DeepWalk/node2vec: similarity = co-occurrence on (biased) random walks; train with skip-gram + negative sampling.
- \(p\) controls returning, \(q\) controls exploring: BFS-like (\(q>1\)) → local structure, DFS-like (\(q<1\)) → communities.
- On Cora, label-free embeddings beat raw features for classification (0.65 vs 0.58) and reach ≈ 0.85 link AUC.
- DeepWalk ≡ factorising a log-transformed multi-step transition matrix (NetMF): same quality, no SGD.
- Limits: proximity ≠ role, transductive, ignores features, \(O(|V|d)\) parameters → L03: Graph Neural Networks.