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/L20_bonus_classic_ml_scaling.py.
import warnings · 46 lines
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 os import time 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 adjusted_homophily, graph, node_data, planetoid from gl.nb import json_cache import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv from torch_geometric.nn.models import CorrectAndSmooth from torch_geometric.utils import ( negative_sampling, subgraph, to_undirected, ) from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score 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, } )
L20 · Bonus A — classic graph ML and scaling
CS224W Machine Learning with Graphs · hands-on companion to the bonus page (course notes ch. 1, 19, 20)
Audience: anyone who did L01–L05. Prerequisites: none beyond those.
By the end you can:
-
compute node features (degree, clustering, betweenness, closeness, graphlet orbits) and link features (common neighbours, Jaccard, Adamic–Adar, Katz) by hand and in code, reproducing the notes' worked example;
-
use labels as signals: label propagation and Correct & Smooth, and see how close they get to a GCN on Cora;
- reason about the cost of training on big graphs and try neighbour-sampling arithmetic, Cluster-GCN and Simplified GCN (SGC).
In short. How much can we get from the graph structure itself, cheaply? Hand-crafted features + a linear model; labels that flow along edges (homophily) — label propagation, Correct & Smooth; and scaling tricks that make GNN training fit in memory: neighbour sampling (\(M\cdot H^K\)), Cluster-GCN (train on communities), SGC (\(\tilde A^KX\) precomputed once).
Outline
- Node-level features on the notes' 5-node example (degree, clustering, betweenness, closeness, graphlet orbits).
- Link-level features — CN, Jaccard, Adamic–Adar, Katz; then link prediction on Cora.
- Labels as signals — label propagation; Correct & Smooth vs. MLP vs. GCN on Cora.
- Scaling — sampling cost arithmetic, Cluster-GCN, Simplified GCN.
- Exercises (the notes' practice problems), research links.
1 · Node-level features
The notes' worked example: a triangle A–B–C with a tail A–D–E.
toy = nx.Graph( [("A", "B"), ("A", "C"), ("B", "C"), ("A", "D"), ("D", "E")] ) def graphlet_orbits(g, v): """Counts of the 2- and 3-node graphlet orbits touching v: edge, path end, path middle, triangle.""" nb = set(g[v]) tri = sum(1 for a in nb for b in nb if a < b and g.has_edge(a, b)) path_mid = sum( 1 for a in nb for b in nb if a < b and not g.has_edge(a, b) ) path_end = sum(1 for u in nb for w in g[u] if w != v and w not in nb) return len(nb), path_end, path_mid, tri _betw = nx.betweenness_centrality(toy, normalized=False) _dist = dict(nx.all_pairs_shortest_path_length(toy)) toy_features = pd.DataFrame( [ { "node": v, "degree": toy.degree(v), "clustering": nx.clustering(toy, v), "betweenness": _betw[v], "closeness 1/Σd (notes)": 1 / sum(_dist[v].values()), "closeness (n−1)/Σd (networkx)": nx.closeness_centrality( toy, v ), "GDV (edge, path-end, path-mid, triangle)": graphlet_orbits( toy, v ), } for v in sorted(toy) ] ) toy_features.round(3)
Matches the notes: A has degree 3, clustering 1/3, betweenness 4 and closeness 1/5, the most central node by all three measures;
B and C have clustering 1; D is the bridge to E (betweenness 3). networkx's closeness_centrality multiplies by \(n-1\) (A: 0.8 = 4/5); the
ranking is the same. The graphlet degree vector (GDV) counts which position a node occupies in each small pattern: A sits in 1 triangle and
in the middle of 2 open paths; E is only ever a path end.
2 · Link-level features
For a candidate pair \((u,v)\): common neighbours \(|N(u)\cap N(v)|\), Jaccard \(\frac{|N(u)\cap N(v)|}{|N(u)\cup N(v)|}\), Adamic–Adar \(\sum_{w\in N(u)\cap N(v)}1/\log d_w\), and the global Katz index \(S=\sum_{\ell\ge1}\beta^\ell A^\ell=(I-\beta A)^{-1}-I\) (counts walks of all lengths, discounted).
_nodes = sorted(toy) _A = nx.to_numpy_array(toy, nodelist=_nodes) katz_beta = 0.1 _katz = np.linalg.inv(np.eye(5) - katz_beta * _A) - np.eye(5) _idx = {v: i for i, v in enumerate(_nodes)} def link_scores(g, u, v): cn = len(set(g[u]) & set(g[v])) return { "pair": f"{u}–{v}", "common neighbours": cn, "Jaccard": next(nx.jaccard_coefficient(g, [(u, v)]))[2], "Adamic–Adar": next(nx.adamic_adar_index(g, [(u, v)]))[2], f"Katz (β={katz_beta})": _katz[_idx[u], _idx[v]], } pd.DataFrame( [ link_scores(toy, "B", "D"), link_scores(toy, "C", "E"), link_scores(toy, "B", "E"), ] ).round(4)
B–D: one common neighbour (A), Jaccard 1/3, Adamic–Adar \(1/\log 3 = 0.91\), all as in the notes. C–E has no common neighbour, so every local score is 0, but Katz is still positive (\(\approx\beta^3\): the walk C–A–D–E), so it ranks C–E above truly unreachable pairs.
On Cora. Hide 10% of edges, score them against an equal number of non-edges:
cora = planetoid("Cora")[0] · 49 lines
cora = planetoid("Cora")[0] _und = cora.edge_index[:, cora.edge_index[0] < cora.edge_index[1]] _perm = torch.randperm( _und.size(1), generator=torch.Generator().manual_seed(SEED) ) _n_test = _und.size(1) // 10 _test_pos, _train_und = _und[:, _perm[:_n_test]], _und[:, _perm[_n_test:]] torch.manual_seed(SEED) _test_neg = negative_sampling( cora.edge_index, num_nodes=cora.num_nodes, num_neg_samples=_n_test ) cora_train_graph = nx.Graph() cora_train_graph.add_nodes_from(range(cora.num_nodes)) cora_train_graph.add_edges_from(_train_und.t().tolist()) _pairs = [ tuple(p) for p in torch.cat([_test_pos, _test_neg], 1).t().tolist() ] _labels = np.r_[np.ones(_n_test), np.zeros(_n_test)] _A_tr = nx.to_scipy_sparse_array( cora_train_graph, nodelist=range(cora.num_nodes), format="csr" ).astype(float) _katz_sum, _walks = 0, _A_tr.copy() # truncated Katz: walks of length 1..4 for _l in range(1, 5): _katz_sum = _katz_sum + (0.05**_l) * _walks _walks = ( _walks @ _A_tr ) # matrix power (scipy's ** would be elementwise!) _heur = { "common neighbours": [ len(set(cora_train_graph[u]) & set(cora_train_graph[v])) for u, v in _pairs ], "Jaccard": [ s for *_, s in nx.jaccard_coefficient(cora_train_graph, _pairs) ], "Adamic–Adar": [ s for *_, s in nx.adamic_adar_index(cora_train_graph, _pairs) ], "Katz (β=0.05, walks ≤ 4)": [_katz_sum[u, v] for u, v in _pairs], } pd.DataFrame( [ { "heuristic": k, "link-prediction AUC on Cora": roc_auc_score(_labels, v), } for k, v in _heur.items() ] ).round(3)
_rev = reveal( · 19 lines
_rev = reveal( r""" Measured (10% held-out edges vs. as many non-edges): common neighbours, Jaccard and Adamic–Adar all reach **AUC ≈ 0.75**: about a quarter of Cora's held-out edges have no common neighbour left, and those all score 0. **Katz reaches 0.85**: longer walks rescue those pairs, matching L02's node2vec (0.85) with no training at all. """, predict="What fraction of Cora's held-out edges have no common neighbour left, and what does that do to the AUC of common neighbours vs. Katz?", ) _post = mo.md(r""" ## 3 · Labels are signals too: label propagation and Correct & Smooth **Label propagation:** clamp the known labels, and repeatedly set every other node's class distribution to the average of its neighbours' (with symmetric normalisation and a restart weight $\alpha$): $Y^{(t+1)}=\alpha\,\tilde A Y^{(t)}+(1-\alpha)Y^{(0)}$. **Correct & Smooth** (Huang et al. 2021): take *any* base predictor (here an MLP on the features), then (1) **correct**: spread its *errors* on the training nodes along the edges; (2) **smooth**: spread the corrected predictions, treating training labels as known. """) mo.vstack([_rev, _post])
def norm_adj(edge_index, n): · 83 lines
def norm_adj(edge_index, n): A = torch.zeros(n, n) A[edge_index[0], edge_index[1]] = 1.0 d = A.sum(1).clamp(min=1).pow(-0.5) return d[:, None] * A * d[None, :] cora_A_norm = norm_adj(cora.edge_index, cora.num_nodes) _y_onehot = F.one_hot(cora.y, 7).float() _tr, _te = cora.train_mask, cora.test_mask def label_propagation(alpha=0.9, iters=50): Y0 = torch.zeros(cora.num_nodes, 7) Y0[_tr] = _y_onehot[_tr] Y = Y0.clone() for _ in range(iters): Y = alpha * cora_A_norm @ Y + (1 - alpha) * Y0 return Y def train_mlp(epochs=200, seed=SEED): torch.manual_seed(seed) mlp = nn.Sequential( nn.Linear(cora.num_features, 64), nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, 7), ) opt = torch.optim.Adam(mlp.parameters(), lr=0.01, weight_decay=5e-4) for _ in range(epochs): mlp.train() opt.zero_grad() F.cross_entropy(mlp(cora.x)[_tr], cora.y[_tr]).backward() opt.step() mlp.eval() with torch.no_grad(): return F.softmax(mlp(cora.x), 1) def train_gcn(epochs=200, seed=SEED): torch.manual_seed(seed) c1, c2 = GCNConv(cora.num_features, 64), GCNConv(64, 7) opt = torch.optim.Adam( list(c1.parameters()) + list(c2.parameters()), lr=0.01, weight_decay=5e-4, ) f = lambda training: c2( F.dropout(F.relu(c1(cora.x, cora.edge_index)), 0.5, training), cora.edge_index, ) for _ in range(epochs): opt.zero_grad() F.cross_entropy(f(True)[_tr], cora.y[_tr]).backward() opt.step() with torch.no_grad(): return F.softmax(f(False), 1) acc = lambda P: (P.argmax(1)[_te] == cora.y[_te]).float().mean().item() _lp = label_propagation() _mlp = train_mlp() _cs = CorrectAndSmooth( num_correction_layers=50, correction_alpha=0.9, num_smoothing_layers=50, smoothing_alpha=0.8, autoscale=True, ) _corrected = _cs.correct(_mlp, cora.y[_tr], _tr, cora.edge_index) _smoothed = _cs.smooth(_corrected, cora.y[_tr], _tr, cora.edge_index) _gcn = train_gcn() pd.DataFrame( [ { "method": "label propagation (labels + graph, no features)", "test acc": acc(_lp), }, {"method": "MLP (features only)", "test acc": acc(_mlp)}, {"method": "MLP + Correct (C)", "test acc": acc(_corrected)}, { "method": "MLP + Correct & Smooth (C&S)", "test acc": acc(_smoothed), }, {"method": "2-layer GCN", "test acc": acc(_gcn)}, ] ).round(3)
_rev = reveal( · 14 lines
_rev = reveal( r""" Measured (Cora, 140 labels): * **Label propagation alone: 0.71**, without looking at a single word of the papers, purely labels + homophily. * **MLP: 0.57** (features only). The *correct* step alone changes nothing here: the MLP fits its 140 training nodes perfectly, so there are almost no training errors to propagate. **Smoothing** lifts it to **0.75**. * **GCN: 0.80.** C&S closes most of the gap with a model that never does message passing during training. On larger benchmarks (OGB-Products, Arxiv) the C&S paper even beats GNNs at a fraction of the cost. """, predict="Rank label propagation alone, an MLP, MLP + Correct & Smooth, and a GCN on Cora.", ) _rev
3b · Label propagation as a camp detector: real networks with known labels
The 0.71 above is Cora's. For SNA the use case is "I know a few accounts per camp; which camp is everyone else in?".
Seven networks with ground truth from gl.datasets: political books (liberal / neutral / conservative), political
blogs (liberal / conservative), college football (conference), EU e-mail (department), LastFM Asia (country), Twitch
England (mature content) and Deezer Europe (gender). Label 10% of the nodes at random, propagate (α = 0.9, 50 steps,
labels and graph only, no features), score the other 90% against the majority-class baseline; 5 seeds.
Predict first: on which networks does propagation beat the majority class by a wide margin, and on which is it no better than guessing?
LP_SETS = ["polbooks", "polblogs", "football", "email-eu-core", "lastfm-asia", "twitch-en", "deezer-europe"] def lp_benchmark(name, share=0.1, seeds=5, alpha=0.9, iters=50): import scipy.sparse as sp if name in ("lastfm-asia", "twitch-en", "deezer-europe"): d = node_data(name) ei, y = d.edge_index.numpy(), d.y.numpy() else: g = graph(name).to_undirected() nodes = sorted(g) idx = {v: i for i, v in enumerate(nodes)} lab = dict(g.nodes(data="label")) y = pd.factorize(pd.Series([lab[v] for v in nodes]))[0] # labels may be strings; missing -> -1 e = np.array([(idx[u], idx[v]) for u, v in g.edges()]) ei = np.concatenate([e.T, e.T[::-1]], 1) n, k = len(y), int(y.max()) + 1 A = sp.csr_matrix((np.ones(ei.shape[1]), (ei[0], ei[1])), shape=(n, n)) A.data[:] = 1.0 dinv = 1 / np.sqrt(np.maximum(np.asarray(A.sum(1)).ravel(), 1)) A = sp.diags(dinv) @ A @ sp.diags(dinv) accs, base = [], [] for seed in range(seeds): r = np.random.default_rng(seed) known = r.random(n) < share Y0 = np.zeros((n, k)) Y0[known, y[known]] = 1 Y = Y0.copy() for _ in range(iters): Y = alpha * (A @ Y) + (1 - alpha) * Y0 pred = np.where(Y.sum(1) > 0, Y.argmax(1), np.bincount(y[known]).argmax()) accs.append(float((pred[~known] == y[~known]).mean())) base.append(float((y[~known] == np.bincount(y[known]).argmax()).mean())) return {"nodes": n, "classes": k, "adjusted homophily": adjusted_homophily(ei, y), "label propagation": float(np.mean(accs)), "majority class": float(np.mean(base))} lp_run = mo.ui.run_button(label="Run on seven networks (downloads ≈ 15 MB once; ≈ 1 min; then cached)") lp_run
_cache = CACHE_DIR / "L20_label_propagation.json" mo.stop( not (lp_run.value or os.environ.get("GL_RUN_EXPERIMENTS") == "1") and not _cache.exists(), mo.md("Press the button. Results are cached in `notebooks/cs224w/cache/L20_label_propagation.json`."), ) lp_table = pd.DataFrame({n: json_cache(_cache, {"network": n, "share": 0.1, "seeds": 5, "v": 1}, lambda n=n: lp_benchmark(n)) for n in LP_SETS}).T lp_table["lift over majority"] = lp_table["label propagation"] - lp_table["majority class"] lp_table = lp_table.sort_values("adjusted homophily", ascending=False) _fig, _ax = plt.subplots(figsize=(5.2, 3.2)) _ax.axhline(0, c="grey", lw=0.8) for _n, _r in lp_table.iterrows(): _ax.scatter(_r["adjusted homophily"], _r["lift over majority"], s=40, c="#4c78a8") _ax.annotate(_n, (_r["adjusted homophily"], _r["lift over majority"]), fontsize=7, xytext=(4, 3), textcoords="offset points") _ax.set_xlabel("adjusted homophily") _ax.set_ylabel("accuracy − majority class") _ax.set_title("Label propagation with 10% seeds", fontsize=9) plt.tight_layout() _t = lp_table _good = [n for n in _t.index if _t.loc[n, "lift over majority"] > 0.2] _none = [n for n in _t.index if _t.loc[n, "lift over majority"] < 0.05] mo.vstack([ mo.hstack([_fig, _t.round(3)], widths=[2, 3]), mo.md(f""" Propagation lifts accuracy far above the majority class on {", ".join(_good)} (lift {_t.loc[_good, "lift over majority"].min():.2f}–{_t.loc[_good, "lift over majority"].max():.2f}) and adds {"almost nothing on " + ", ".join(_none) if _none else "something everywhere"}. What separates the two groups is adjusted homophily, not network size or the number of classes; within the first group the lift also depends on how many classes there are to beat. Political camps on a link graph are the textbook homophilous label, which is why "a few seed accounts per camp" works for stance on X; a personal attribute such as gender or a streamer's content rating is not, and no amount of propagation recovers it. """), ])
4 · Scaling
Full-batch training needs every node's \(K\)-hop computation graph in memory. Per mini-batch of \(M\) target nodes: neighbour sampling touches up to \(M\cdot H^K\) nodes (\(H\) sampled neighbours per hop); Cluster-GCN about \(K\cdot M\cdot D_{avg}\) (only edges inside the batch's clusters survive); SGC precomputes \(\tilde A^KX\) once and then trains a logistic regression.
scale_controls = mo.ui.dictionary( { "M": mo.ui.slider( steps=[256, 512, 1024, 4096], value=1024, label="targets per batch M", show_value=True, ), "H": mo.ui.slider( steps=[5, 10, 15, 25], value=15, label="sampled neighbours H", show_value=True, ), "K": mo.ui.slider( 1, 4, value=3, label="layers K", show_value=True ), "D": mo.ui.slider( steps=[5, 10, 20, 50], value=20, label="average degree", show_value=True, ), } ) scale_controls
_v = scale_controls.value _ns, _cg = _v["M"] * _v["H"] ** _v["K"], _v["K"] * _v["M"] * _v["D"] mo.md(f""" | method | node computations per batch | | |---|---|---| | neighbour sampling $M H^K$ | **{_ns:,}** | | | Cluster-GCN $K M D_{{avg}}$ | **{_cg:,}** | {_ns / _cg:.0f}× cheaper (but drops between-cluster edges) | **Try it:** the notes' practice problem is $M=1024, H=15, K=3, D=20$: 3.5M vs 61k, about **56×**. Sampling cost explodes with depth $K$; Cluster-GCN grows linearly in it. """)
def cluster_gcn(clusters_per_batch=4, epochs=60, seed=SEED): · 109 lines
def cluster_gcn(clusters_per_batch=4, epochs=60, seed=SEED): """Partition Cora with Louvain, train a 2-layer GCN on random groups of clusters (edges between groups are dropped).""" g = nx.Graph() g.add_nodes_from(range(cora.num_nodes)) g.add_edges_from(cora.edge_index.t().tolist()) parts = [ sorted(c) for c in nx.community.louvain_communities(g, seed=seed) ] torch.manual_seed(seed) r = np.random.default_rng(seed) c1, c2 = GCNConv(cora.num_features, 64), GCNConv(64, 7) opt = torch.optim.Adam( list(c1.parameters()) + list(c2.parameters()), lr=0.01, weight_decay=5e-4, ) kept = [] for _ in range(epochs): order = r.permutation(len(parts)) for b in range(0, len(order), clusters_per_batch): nodes = torch.tensor( sorted( sum( ( parts[i] for i in order[b : b + clusters_per_batch] ), [], ) ) ) ei, _ = subgraph( nodes, cora.edge_index, relabel_nodes=True, num_nodes=cora.num_nodes, ) kept.append(ei.size(1)) mask = cora.train_mask[nodes] if mask.sum() == 0: continue out = c2( F.dropout(F.relu(c1(cora.x[nodes], ei)), 0.5, True), ei ) opt.zero_grad() F.cross_entropy(out[mask], cora.y[nodes][mask]).backward() opt.step() with torch.no_grad(): pred = c2( F.relu(c1(cora.x, cora.edge_index)), cora.edge_index ).argmax(1) # full-graph inference n_batches = int(np.ceil(len(parts) / clusters_per_batch)) return ( len(parts), (pred[cora.test_mask] == cora.y[cora.test_mask]) .float() .mean() .item(), 1 - sum(kept[-n_batches:]) / cora.edge_index.size(1), ) def sgc(K=2): X = cora.x.clone() A_hat = norm_adj( torch.cat( [cora.edge_index, torch.arange(cora.num_nodes).repeat(2, 1)], 1 ), cora.num_nodes, ) # with self-loops for _ in range(K): X = A_hat @ X # precomputed once clf = LogisticRegression(max_iter=2000, C=1.0).fit( X[cora.train_mask].numpy(), cora.y[cora.train_mask].numpy() ) return clf.score( X[cora.test_mask].numpy(), cora.y[cora.test_mask].numpy() ) _t = time.time() _n_parts, _cg_acc, _dropped = cluster_gcn() _t_cg = time.time() - _t _t = time.time() _sgc_acc = sgc() _t_sgc = time.time() - _t _t = time.time() _gcn_acc = acc(train_gcn()) _t_gcn = time.time() - _t pd.DataFrame( [ { "method": "full-batch GCN", "test acc": _gcn_acc, "seconds": _t_gcn, "note": "", }, { "method": f"Cluster-GCN ({_n_parts} Louvain clusters, 4 per batch)", "test acc": _cg_acc, "seconds": _t_cg, "note": f"{_dropped:.0%} of edges dropped in an epoch", }, { "method": "SGC (²X precomputed + logistic regression)", "test acc": _sgc_acc, "seconds": _t_sgc, "note": "no non-linearity", }, ] ).round(3)
_rev = reveal( · 24 lines
_rev = reveal( r""" Measured: **Cluster-GCN 0.796** vs. full-batch **0.798**, although each mini-batch only sees 4 of 102 Louvain clusters and 11% of edges are never used in an epoch (communities keep most edges inside). **SGC 0.825**: two propagation steps, no non-linearity, a logistic regression, and it *beats* the GCN here; Cora's labels are smooth over the graph. All three take ≈ 1.5 s on Cora; the difference is memory and scalability on graphs with $10^8$ nodes. """, predict="Cluster-GCN sees 4 of 102 clusters per batch: how much accuracy does it lose against full batch? Will SGC (no non-linearity) beat the GCN?", ) _post = mo.md(r""" --- ## 5 · Exercises (the notes' practice problems) **Exercise 1 (star centrality).** In a star with centre $h$ and $n-1$ leaves, give the betweenness of $h$ for $n=10$ → `ex1_betweenness`, and the closeness $1/\sum d$ of a **leaf** for $n=10$ → `ex1_leaf_closeness`. **Exercise 2 (label propagation by hand).** Triangle u–v–w plus an edge w–x; u is labelled 1, x labelled 0; v and w start at 0.5 and are updated synchronously to the **plain average of their neighbours** until convergence. Final value of w? → `ex2_w`. (Check by coding it.) **Exercise 3 (SGC).** Why does SGC add self-loops? Think about a bipartite graph with $K=1$, then open the solution. """) mo.vstack([_rev, _post])
ex1_betweenness = None · 3 lines
ex1_betweenness = None ex1_leaf_closeness = None ex2_w = None
def _verdict(ok, answered): · 42 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", ) mo.vstack( [ mo.md("**Exercise 1**"), _verdict( ex1_betweenness == 36 and ex1_leaf_closeness is not None and abs(ex1_leaf_closeness - 1 / 17) < 1e-6, ex1_betweenness is not None or ex1_leaf_closeness is not None, ), mo.md("**Exercise 2**"), _verdict( ex2_w is not None and abs(ex2_w - 0.6) < 1e-3, ex2_w is not None, ), mo.accordion( { "Solution 1": mo.md( r"Every pair of the 9 leaves is connected only through $h$: $\binom92=36$. A leaf is 1 from $h$ and 2 from the other 8 leaves: $1/(1+16)=1/17$." ), "Solution 2": mo.md(r"""Fixed point: $v=(1+w)/2$, $w=(1+v+0)/3$ → $6w = 3 + w$ → **w = 0.6**, $v=0.8$. ```python v, w = 0.5, 0.5 for _ in range(100): v, w = (1 + w) / 2, (1 + v + 0) / 3 ```"""), "Solution 3": mo.md( "Without self-loops, $\\tilde A^K X$ only mixes features of nodes at walk distance exactly $K$: for odd $K$ on a bipartite graph a " "node's own features vanish. Self-loops add walks that 'stay', so every node keeps part of its own features and all " "nodes within distance $K$ contribute; they also make the filter more low-pass." ), } ), ] )
6 · Connecting to your research (SNA on X/Twitter)
-
Baselines first. Before any GNN on your retweet graph: degree/PageRank/betweenness/clustering + logistic regression for node tasks, Adamic–Adar / Katz for link tasks, label propagation for "which camp?". On Cora these cheap methods reached 0.71–0.85 of the way.
-
Label propagation is your camp detector when you have a few seed accounts per camp; C&S lets you add a text classifier (e.g. your gemma4 tweet tags aggregated per user) as the base predictor and smooth it over the graph.
-
Scaling to millions of users: SGC (precompute \(\tilde A^2X\) once with sparse products) or Cluster-GCN on Louvain communities. You already compute communities in
usa-active/.
Summary
- Node features (degree, clustering, betweenness 4 / closeness 1/5 for A) and link features (CN, Jaccard, AA ≈ 0.75 AUC; Katz 0.85 on Cora).
- Labels as signals: label propagation 0.71 with no features; MLP 0.57 → C&S 0.75; GCN 0.80.
- Scaling: sampling costs \(MH^K\) (3.5M) vs Cluster-GCN \(KMD\) (61k); Cluster-GCN ≈ full-batch accuracy; SGC 0.83 with a linear model.
Next — L21 · Bonus B: geometric GNNs, network motifs and subgraph matching, GNNExplainer.