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/L18_generative_models.py.
import warnings warnings.filterwarnings("ignore", category=FutureWarning) # harmless Intel-GPU probe message printed by torch on this machine warnings.filterwarnings("ignore", message=".*Level Zero.*") import itertools import time from collections import Counter 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 import torch.nn as nn import torch.nn.functional as F SEED = 0 rng = np.random.default_rng(SEED) torch.manual_seed(SEED) # several open notebooks share the CPU; more threads = contention torch.set_num_threads(4) # shared datasets (Cora etc. are cached here) and this folder's disk cache DATA_DIR = mo.notebook_dir().parents[1] / "data" 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, } )
L18 · Deep generative models for graphs
CS224W Machine Learning with Graphs · hands-on companion to Lecture 18
Audience: you did L03–L06; you know what an RNN/GRU is. Prerequisites: maximum likelihood, the chain rule of probability; REINFORCE helps but is explained.
By the end you can:
- compute the likelihood of a generated graph as a product of edge decisions (the lecture's 4-cycle example);
- explain why BFS ordering makes GraphRNN tractable, and see it in the adjacency matrix;
-
train a small GraphRNN (node-level GRU emitting edge probabilities to the last \(M\) nodes) on grid graphs, sample new graphs, and evaluate them against the data with graph statistics;
-
train a GCPN-style generator with reinforcement learning: a GNN policy adds edges, invalid actions are penalised, and the final reward is a graph property.
In short. Learn \(p_{\text{model}}(G)\) close to \(p_{\text{data}}(G)\) and sample from it. Autoregressive models use the chain rule \(p(x)=\prod_t p(x_t\mid x_{<t})\) over actions. GraphRNN writes a graph + node ordering as a sequence of sequences: a node-level RNN adds nodes; for each new node an edge-level model decides its links to previous nodes. BFS ordering limits those links to a small frontier. GCPN generates goal-directed graphs (molecules) with a GNN policy, validity checks and RL on property rewards.
Outline
- The likelihood of one generated graph — the lecture's worked example.
- Node orderings — random vs. BFS: why GraphRNN only needs to look back \(M\) nodes.
- GraphRNN-lite — train on grids, sample, evaluate.
- GCPN-lite — goal-directed generation with a GNN policy and REINFORCE.
- Exercises, research links.
1 · The likelihood of one generated graph
Generate the 4-cycle 1–2, 1–3, 2–4, 3–4 in BFS order. At each new node the model outputs a probability for each edge to an earlier node; the graph's probability is the product of the probabilities of the decisions actually taken (an edge, or no edge), times the probability of stopping. Training maximises its log, i.e. minimises the binary cross-entropy of each decision.
decisions = [ ("2", "1", 0.9, 1), ("3", "1", 0.6, 1), ("3", "2", 0.3, 0), ("4", "1", 0.2, 0), ("4", "2", 0.7, 1), ("4", "3", 0.8, 1), ] p_stop = 0.6 _rows = [ { "new node": n, "to earlier node": e, "p(edge)": p, "sampled": "edge" if s else "no edge", "factor": p if s else 1 - p, "BCE = −log factor": -np.log(p if s else 1 - p), } for n, e, p, s in decisions ] _rows.append( { "new node": "5", "to earlier node": "—", "p(edge)": np.nan, "sampled": "EOS (stop)", "factor": p_stop, "BCE = −log factor": -np.log(p_stop), } ) likelihood_table = pd.DataFrame(_rows) mo.vstack( [ likelihood_table.round(3), mo.md( f"$p(S^\\pi)=\\prod$ factors $=$ **{likelihood_table.factor.prod():.4f}**; " f"total BCE $=-\\log p=$ **{likelihood_table['BCE = −log factor'].sum():.3f}**" ), ] )
Product ≈ 0.10, as on the slide (\(0.9\cdot0.42\cdot0.448\cdot0.6\)). Strictly, \(p(G)=\sum_\pi p(G,\pi)\) over all node orderings, which is intractable, so GraphRNN trains on sampled BFS orderings.
2 · Node orderings: why BFS
With a random ordering a new node may link to any earlier node, so the edge model must look back over the whole history. With a BFS ordering a node can only link to nodes in the current BFS frontier, so all its edges fall within the last \(M\) positions. The adjacency matrix becomes a narrow band around the diagonal.
order_graph = nx.convert_node_labels_to_integers(nx.grid_2d_graph(5, 6)) def max_lookback(g, order): pos = {v: i for i, v in enumerate(order)} return max(abs(pos[u] - pos[v]) for u, v in g.edges) _rand_order = list( np.random.default_rng(SEED).permutation(order_graph.number_of_nodes()) ) _bfs_order = list(nx.bfs_tree(order_graph, 0)) _fig, _axs = plt.subplots(1, 2, figsize=(7, 3.3)) for _ax, (_name, _order) in zip( _axs, [("random order", _rand_order), ("BFS order", _bfs_order)] ): _ax.imshow( nx.to_numpy_array(order_graph, nodelist=_order), cmap="Greys" ) _ax.set_title( f"{_name}: max look-back M = {max_lookback(order_graph, _order)}", fontsize=9, ) _ax.set_xticks([]) _ax.set_yticks([]) _fig
On a \(5\times6\) grid a random ordering needs to look back 28 positions (almost the whole graph); BFS needs only 5, the length of the grid's shorter side. GraphRNN therefore predicts, for each new node, just \(M\) edge bits (to the last \(M\) nodes), with \(M\) fixed per dataset.
3 · GraphRNN-lite
- Data: 200 grid graphs, \(2\)–\(4\) × \(3\)–\(6\) nodes (sizes vary: 6–24 nodes).
- Sequence: BFS from a random start; row \(i\) = edges of node \(i\) to nodes \(i-1,\dots,i-M\) (\(M=8\)).
-
Model: a node-level GRU reads the previous row and outputs \(M\) edge probabilities plus a stop probability. (Full GraphRNN uses a second, edge-level RNN so the \(M\) decisions depend on each other. We sample them independently and reject an all-empty row, the "GraphRNN-S" simplification.)
-
Training: teacher forcing + BCE (§1). Sampling: run the GRU on its own outputs.
M_LOOKBACK = 8 · 117 lines
M_LOOKBACK = 8 def grid_dataset(n=200, seed=SEED): r = np.random.default_rng(seed) return [ nx.convert_node_labels_to_integers( nx.grid_2d_graph(int(r.integers(2, 5)), int(r.integers(3, 7))) ) for _ in range(n) ] def bfs_sequence(g, r): order = list(nx.bfs_tree(g, int(r.integers(g.number_of_nodes())))) S = np.zeros((len(order), M_LOOKBACK)) for i in range(1, len(order)): for j in range(max(0, i - M_LOOKBACK), i): if g.has_edge(order[i], order[j]): S[i, i - 1 - j] = 1.0 # column k = edge to node i-1-k return S class GraphRNN(nn.Module): def __init__(self, hidden=64): super().__init__() self.inp, self.gru, self.out = ( nn.Linear(M_LOOKBACK, hidden), nn.GRU(hidden, hidden, batch_first=True), nn.Linear(hidden, M_LOOKBACK + 1), ) def forward(self, x, h=None): o, h = self.gru(self.inp(x), h) return self.out(o), h # M edge logits + 1 stop logit def batch_from(seqs, max_n): B = len(seqs) X, Y, W = ( np.zeros((B, max_n, M_LOOKBACK)), np.zeros((B, max_n, M_LOOKBACK + 1)), np.zeros((B, max_n, M_LOOKBACK + 1)), ) for b, S in enumerate(seqs): n = len(S) X[b, 0] = 1.0 X[b, 1:n] = S[ 1:n ] # start-of-sequence row of ones, then teacher forcing Y[b, : n - 1, :M_LOOKBACK] = S[1:n] Y[b, n - 1, M_LOOKBACK] = ( 1.0 # next row's edges; stop after the last node ) for i in range(n): W[b, i, : min(i + 1, M_LOOKBACK)] = ( 1.0 if i < n - 1 else 0.0 ) # only edges to nodes that exist W[b, i, M_LOOKBACK] = 1.0 t = lambda a: torch.tensor(a, dtype=torch.float) return t(X), t(Y), t(W) def train_graphrnn(graphs, steps=3000, seed=SEED): torch.manual_seed(seed) r = np.random.default_rng(seed) model = GraphRNN() opt = torch.optim.Adam(model.parameters(), lr=3e-3) max_n = max(g.number_of_nodes() for g in graphs) + 1 losses = [] for step in range(steps): X, Y, W = batch_from( [ bfs_sequence(graphs[i], r) for i in r.choice(len(graphs), 32) ], max_n, ) loss = ( F.binary_cross_entropy_with_logits( model(X)[0], Y, reduction="none" ) * W ).sum() / W.sum() opt.zero_grad() loss.backward() opt.step() losses.append(loss.item()) return model, losses @torch.no_grad() def sample_graph(model, max_nodes=40, generator=None): g = nx.Graph() g.add_node(0) x, h = torch.ones(1, 1, M_LOOKBACK), None for i in range(1, max_nodes): logits, h = model(x, h) p = torch.sigmoid(logits[0, 0]) if torch.rand(1, generator=generator) < p[M_LOOKBACK]: break # stop for _ in range( 20 ): # reject empty rows (GraphRNN-S simplification) row = ( torch.rand(M_LOOKBACK, generator=generator) < p[:M_LOOKBACK] ).float() row[i:] = 0 if row.sum() > 0: break g.add_node(i) g.add_edges_from( (i, i - 1 - k) for k in range(M_LOOKBACK) if row[k] ) x = row.view(1, 1, M_LOOKBACK) return g grid_graphs = grid_dataset() graphrnn_run = mo.ui.run_button( label="Train GraphRNN-lite (≈ 15 s) and sample 100 graphs" ) graphrnn_run
mo.stop( · 53 lines
mo.stop( not graphrnn_run.value, mo.md("Press the button to train and sample.") ) _t = time.time() graphrnn_model, _losses = train_graphrnn(grid_graphs) _gen = torch.Generator().manual_seed(SEED) generated = [ sample_graph(graphrnn_model, generator=_gen) for _ in range(100) ] _er = [ nx.gnm_random_graph(g.number_of_nodes(), g.number_of_edges(), seed=i) for i, g in enumerate(generated) ] # same sizes, random wiring def graph_stats(gs): degs = Counter(d for g in gs for _, d in g.degree()) tot = sum(degs.values()) return { "nodes (mean)": np.mean([g.number_of_nodes() for g in gs]), "mean degree": np.mean( [2 * g.number_of_edges() / g.number_of_nodes() for g in gs] ), "clustering": np.mean([nx.average_clustering(g) for g in gs]), "connected": np.mean([nx.is_connected(g) for g in gs]), "share of degree-1 nodes": degs[1] / tot, "share of degree ≥ 5 nodes": sum( v for k, v in degs.items() if k >= 5 ) / tot, } graphrnn_eval = pd.DataFrame( { "data (grids)": graph_stats(grid_graphs), "GraphRNN-lite samples": graph_stats(generated), "Erdős–Rényi, same sizes": graph_stats(_er), } ).T _fig, _axs = plt.subplots(1, 5, figsize=(11, 2.3)) for _ax, _g in zip(_axs, generated[:5]): nx.draw( _g, nx.kamada_kawai_layout(_g), ax=_ax, node_size=25, width=0.8 ) _ax.set_title(f"{_g.number_of_nodes()} nodes", fontsize=8) mo.vstack( [ mo.md( f"trained in {time.time() - _t:.0f} s; final BCE {np.mean(_losses[-100:]):.3f}" ), graphrnn_eval.round(3), _fig, ] )
_rev = reveal( · 21 lines
_rev = reveal( r""" Measured (100 samples): the samples look like **grids and ladders** (square faces, degrees 2–4) and match the data's mean degree (2.88 vs 2.83); all are connected. They are not perfect: a few triangles (clustering 0.08 vs 0 in grids) and a few degree-1 / degree-5 nodes slip in, the price of sampling the $M$ edge bits independently (the full GraphRNN's edge-level RNN fixes exactly this). Samples are a bit smaller (12 vs 14 nodes): the stop head fires slightly early. An **Erdős–Rényi** graph with the same sizes is much further off (clustering 0.23, 11% leaves, 13% hubs, 22% disconnected): the model has learned *structure*, not just density. """, predict="Compared with the training grids, what will GraphRNN-lite samples get right (mean degree, connectivity) and wrong (triangles, size)?", ) _post = mo.md(r""" ## 4 · GCPN-lite: goal-directed generation with RL GCPN builds molecules atom by atom with a **GNN policy**, checks **chemical validity** at each step (step reward), and optimises a **property** score at the end with policy gradients. Our miniature: 10 nodes, 12 "add an edge" actions; **valence rule** = max degree 3 (an invalid action is penalised −0.2 and ignored); **property** = number of triangles. The policy is a 2-layer GNN scoring every non-edge; training is REINFORCE with a moving-average baseline: $\nabla J\approx\sum_t (R-b)\nabla\log\pi(a_t\mid G_t)$. """) mo.vstack([_rev, _post])
RL_N, RL_STEPS, RL_MAXDEG = 10, 12, 3 · 66 lines
RL_N, RL_STEPS, RL_MAXDEG = 10, 12, 3 RL_PAIRS = torch.tensor(list(itertools.combinations(range(RL_N), 2))) class EdgePolicy(nn.Module): def __init__(self, d=32): super().__init__() self.inp = nn.Linear(RL_MAXDEG + 2, d) self.layers = nn.ModuleList(nn.Linear(2 * d, d) for _ in range(2)) self.edge = nn.Sequential( nn.Linear(2 * d, d), nn.ReLU(), nn.Linear(d, 1) ) def forward(self, A): deg = A.sum(1).long().clamp(max=RL_MAXDEG + 1) h = F.relu(self.inp(F.one_hot(deg, RL_MAXDEG + 2).float())) for lin in self.layers: h = F.relu( lin(torch.cat([h, A @ h], 1)) ) # message passing on the current graph u, v = RL_PAIRS[:, 0], RL_PAIRS[:, 1] return self.edge(torch.cat([h[u] + h[v], h[u] * h[v]], 1)).squeeze( 1 ) # one logit per candidate edge def count_triangles(A): return int(torch.trace(A @ A @ A).item() / 6) def rollout(policy, greedy=False): A, logps, penalty = torch.zeros(RL_N, RL_N), [], 0.0 for _ in range(RL_STEPS): logits = policy(A.clone()).masked_fill( A[RL_PAIRS[:, 0], RL_PAIRS[:, 1]] > 0, -1e9 ) # can't re-add an edge dist = torch.distributions.Categorical(logits=logits) a = logits.argmax() if greedy else dist.sample() logps.append(dist.log_prob(a)) u, v = RL_PAIRS[a] if A[u].sum() >= RL_MAXDEG or A[v].sum() >= RL_MAXDEG: penalty -= 0.2 # invalid: violates the valence rule else: A[u, v] = A[v, u] = 1.0 return A, torch.stack(logps), count_triangles(A) + penalty def train_gcpn(iters=250, batch=16, seed=SEED): torch.manual_seed(seed) policy = EdgePolicy() opt = torch.optim.Adam(policy.parameters(), lr=3e-3) baseline, curve = 0.0, [] for _ in range(iters): eps = [rollout(policy) for _ in range(batch)] R = torch.tensor([e[2] for e in eps]) baseline = 0.9 * baseline + 0.1 * R.mean().item() loss = ( -sum( (r - baseline) * lp.sum() for (_, lp, _), r in zip(eps, R) ) / batch ) opt.zero_grad() loss.backward() opt.step() curve.append(R.mean().item()) return policy, curve gcpn_run = mo.ui.run_button(label="Train the RL generator (≈ 20 s)") gcpn_run
mo.stop( not gcpn_run.value, mo.md("Press the button to train the policy with REINFORCE."), ) _t = time.time() gcpn_policy, _curve = train_gcpn() torch.manual_seed(SEED) _random_reward = np.mean([rollout(EdgePolicy())[2] for _ in range(100)]) _A, _, _best = rollout(gcpn_policy, greedy=True) _fig, _axs = plt.subplots( 1, 2, figsize=(8, 2.8), gridspec_kw={"width_ratios": [1.6, 1]} ) _axs[0].plot(_curve) _axs[0].axhline( _random_reward, color="grey", ls=":", label="untrained policy" ) _axs[0].set_xlabel("iteration") _axs[0].set_ylabel("mean episode reward") _axs[0].legend(fontsize=8) _g = nx.from_numpy_array(_A.numpy()) _pos = {} for _k, _comp in enumerate( sorted(nx.connected_components(_g), key=len, reverse=True) ): for _v, _xy in nx.circular_layout(_g.subgraph(_comp)).items(): _pos[_v] = _xy * (0.8 if len(_comp) > 1 else 0) + np.array( [2.2 * _k, 0] ) nx.draw(_g, _pos, ax=_axs[1], node_size=60) _axs[1].set_title( f"greedy sample: {count_triangles(_A)} triangles, max degree {int(_A.sum(1).max())}", fontsize=9, ) plt.tight_layout() mo.vstack( [ _fig, mo.md( f"Trained in {time.time() - _t:.0f} s. Untrained policy: mean reward {_random_reward:.2f}; trained: {np.mean(_curve[-20:]):.2f}." ), ] )
_rev = reveal( · 26 lines
_rev = reveal( r""" Measured: the untrained policy averages **0.28** triangles; after ≈ 60 iterations REINFORCE reaches **8**, the optimum under the rules: 12 edges and max degree 3 allow at most two disjoint $K_4$'s, 4 triangles each, and that is what the greedy sample builds, leaving two nodes unused. The policy also learned to **never** violate the valence rule (no −0.2 penalties at the end). Step rewards for validity plus a final property reward is GCPN's recipe; the real system adds an adversarial "looks like a real molecule" reward and imitation of real molecules, so it doesn't drift into chemically odd but high-scoring structures. """, predict="A small olympiad problem first: with 12 edges and maximum degree 3, what is the largest possible number of triangles? Will REINFORCE find that optimum?", ) _post = mo.md(r""" --- ## 5 · Exercises **Exercise 1 (likelihood).** A model generates a triangle in order 1, 2, 3: $p(2\text{–}1)=0.8$ (edge), $p(3\text{–}1)=0.5$ (edge), $p(3\text{–}2)=0.4$ (edge), then stops with $p=0.9$. What is $p(S^\pi)$? → `ex1_p` (4 decimals). **Exercise 2 (BFS look-back).** What maximum look-back $M$ does BFS need on an $a\times b$ grid started at a corner, with $a \le b$ (rows ≤ columns)? Check your formula numerically and put the value for a $4\times7$ grid in `ex2_M`. **Exercise 3 (reward design).** Change the RL property to "number of **squares** (4-cycles)". Which structure should the optimal 12-edge, degree-≤3 graph on 10 nodes have? Write your guess, then open the solution. """) mo.vstack([_rev, _post])
ex1_p = None · 2 lines
ex1_p = None ex2_M = None
def _verdict(ok, answered): · 39 lines
def _verdict(ok, answered): if not answered: return mo.callout(mo.md("Not answered yet."), kind="neutral") return mo.callout( mo.md("Correct!" if ok else "Not yet — try again or open a hint."), kind="success" if ok else "warn", ) _g47 = nx.convert_node_labels_to_integers( nx.grid_2d_graph(4, 7), ordering="sorted" ) _M47 = max_lookback(_g47, list(nx.bfs_tree(_g47, 0))) mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_p is not None and abs(ex1_p - 0.8 * 0.5 * 0.4 * 0.9) < 1e-4, ex1_p is not None, ), mo.md("**Exercise 2**"), _verdict(ex2_M == _M47, ex2_M is not None), mo.accordion( { "Solution 1": mo.md( r"$0.8\cdot0.5\cdot0.4\cdot0.9=$ **0.144**." ), "Solution 2": mo.md( f"Measured with `max_lookback(nx.grid_2d_graph(4, 7) …, bfs order)`: **{_M47}**. BFS from a corner sweeps diagonals, " "so the look-back equals the length of the longest diagonal frontier: the shorter side, **M = a**." ), "Solution 3": mo.md( "A **cube** $Q_3$ (8 nodes, 12 edges, every degree 3) has 6 square faces, the maximum here; so the optimum is a cube plus 2 " "isolated nodes. Try it: replace `count_triangles` with a 4-cycle counter (e.g. via `(trace(A⁴) − 2|E| − 4·Σ C(d,2))/8`)." ), } ), ] )
6 · Connecting to your research (SNA on X/Twitter)
-
Null models for your networks. A generator trained on your retweet ego-networks or cascades gives realistic synthetic graphs for testing analysis pipelines, and a stronger null model than Erdős–Rényi (§3 showed how far ER is from structured data).
-
Anomaly detection: cascades with very low likelihood under a GraphRNN trained on organic cascades are candidates for coordination. The likelihood in §1 is directly computable.
-
Goal-directed generation (GCPN) maps to "what minimal set of edges would most increase cross-camp exposure?". This is intervention design with a GNN policy and a property reward (keep the validity constraints realistic!).
Summary
- Autoregressive graph generation: \(p(G,\pi)=\prod\) edge decisions × stop; training = BCE per decision (worked example: 0.10).
- BFS ordering bounds the look-back (\(5\times6\) grid: 28 → 5), which makes GraphRNN tractable.
- GraphRNN-lite learned grid-like graphs (mean degree 2.88 vs 2.83, all connected), far closer than Erdős–Rényi; independent edge sampling causes a few artefacts.
- GCPN-lite: a GNN policy with valence penalties and a property reward reached the optimum (8 triangles) with REINFORCE.
Next — L19 · Conclusion: the GNN design space (GraphGym-style controlled random search and task similarity).