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/L01_introduction.py.
import warnings warnings.filterwarnings("ignore", category=FutureWarning) import marimo as mo import numpy as np import pandas as pd import networkx as nx import matplotlib.pyplot as plt import os import torch from gl.datasets import graph, summary from gl.nb import json_cache from torch_geometric.data import Data, HeteroData from torch_geometric.utils import ( from_networkx, to_undirected, is_undirected, ) from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler 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 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, } )
L01 · Introduction — graphs as data
CS224W Machine Learning with Graphs · hands-on companion to Lecture 1
Audience: you know Python, NumPy and basic ML; graphs may be new as data. Prerequisites: none beyond that. No GNNs yet — that starts in L03.
By the end you can:
- turn raw relational records into a graph in NetworkX and in PyG (
edge_index,Data,HeteroData); - explain — and show in code — why an MLP on the adjacency matrix breaks when nodes are relabelled;
- fold a bipartite graph with \(BB^\top\) and say what information folding throws away;
- set up a node-, a link- and a graph-level prediction task on real graphs.
In short. Most interesting data is relational. A graph (nodes + edges) is the most general way to write that down. Graphs have no fixed size, no "top-left corner" and no node order, so models must respect that. Almost every graph-ML problem is a prediction about a node, a link, or a (sub)graph.
Outline
- A graph from raw records — Zachary's Karate Club in NetworkX, then as PyG tensors (
edge_index). - Why ordinary deep learning chokes — relabel the nodes and watch an MLP change its mind.
- Choosing a representation — one Twitter-like log, four different graphs.
- Bipartite graphs and folding — \(BB^\top\) by hand and in code; what folding loses.
- Three levels of prediction — a node task, a link task and a graph task, each solved with simple features.
- Exercises, pitfalls, and links to your research.
1 · A graph from raw records
Zachary's Karate Club (1977): 34 members of a university club; an edge means two members interacted outside the club.
The club later split in two (Mr. Hi vs Officer). It ships with NetworkX and is the "hello world" of graph ML —
Colab 0 and Colab 1 both start here.
Vocabulary: nodes \(V\) (\(N=|V|\)), edges \(E\), the degree of a node = number of incident edges.
karate = nx.karate_club_graph() club = np.array( [karate.nodes[v]["club"] == "Officer" for v in karate.nodes], dtype=int ) # 0 = Mr. Hi, 1 = Officer degrees = np.array([d for _, d in karate.degree()]) { "nodes N": karate.number_of_nodes(), "edges |E|": karate.number_of_edges(), "directed?": karate.is_directed(), "average degree 2|E|/N": round( 2 * karate.number_of_edges() / karate.number_of_nodes(), 2 ), "max degree (node)": (int(degrees.max()), int(degrees.argmax())), "density |E| / (N choose 2)": round(nx.density(karate), 3), "Officer members": int(club.sum()), }
karate_pos = nx.spring_layout(karate, seed=SEED) def draw_karate( highlight=None, ax=None, title="Karate Club — colour = faction after the split", ): ax = ax or plt.subplots(figsize=(6, 4))[1] colors = ["#4C78A8" if c == 0 else "#F58518" for c in club] nx.draw_networkx_edges(karate, karate_pos, ax=ax, alpha=0.3) nx.draw_networkx_nodes( karate, karate_pos, ax=ax, node_color=colors, node_size=[60 + 25 * d for d in degrees], edgecolors="k", linewidths=0.5, ) if highlight is not None: nx.draw_networkx_nodes( karate, karate_pos, nodelist=list(highlight), ax=ax, node_color="none", edgecolors="crimson", linewidths=2.5, node_size=500, ) nx.draw_networkx_labels(karate, karate_pos, ax=ax, font_size=7) ax.set_title(title, fontsize=10) ax.axis("off") return ax draw_karate()
34 nodes, 78 undirected edges, average degree ≈ 4.6, density ≈ 0.14. Two hubs dominate: node 0 (Mr. Hi, blue) and node 33 (the Officer, orange, degree 17). The two factions are visibly two dense regions joined by a few bridges — the graph structure already "knows" the split. Node size = degree.
From NetworkX to tensors: edge_index
PyG stores a graph as a Data object. Its core is edge_index, a \(2\times|E|\) integer tensor: column \(k\) is the edge
source → target. An undirected edge is stored twice (both directions), because message passing (L03) is directional.
_edges = torch.tensor( list(karate.edges()), dtype=torch.long ).t() # 2 x 78, one direction only karate_edge_index = to_undirected(_edges) # 2 x 156, both directions karate_data = Data( x=torch.tensor(degrees, dtype=torch.float).unsqueeze( 1 ), # one feature per node: its degree edge_index=karate_edge_index, y=torch.tensor(club), ) print(karate_data) print("first 8 columns of edge_index:\n", karate_edge_index[:, :8]) print( "undirected?", is_undirected(karate_edge_index), "| stored edges:", karate_edge_index.size(1), )
node_picker = mo.ui.slider(0, 33, value=0, label="node v", show_value=True) · 4 lines
node_picker = mo.ui.slider(0, 33, value=0, label="node v", show_value=True) mo.md( f"**Try it:** pick a node and compare its neighbours read three ways — NetworkX, the `edge_index` tensor and the adjacency-matrix row. {node_picker}" )
_v = node_picker.value _from_nx = sorted(karate.neighbors(_v)) _from_ei = sorted( karate_edge_index[1, karate_edge_index[0] == _v].tolist() ) # targets of edges leaving v _A_row = np.flatnonzero( nx.to_numpy_array(karate, nodelist=range(34), weight=None)[_v] ).tolist() mo.hstack( [ draw_karate( highlight=_from_nx, title=f"neighbours of node {_v} (red rings)", ), mo.md(f""" | source | neighbours of {_v} | |---|---| | `karate.neighbors(v)` | {_from_nx} | | `edge_index[1, edge_index[0]==v]` | {_from_ei} | | nonzeros of row $A_{{v,:}}$ | {_A_row} | degree = **{len(_from_nx)}**, all three agree: **{_from_nx == _from_ei == _A_row}** """), ], widths=[3, 2], align="center", )
2 · Why can't we just flatten \(A\) into an MLP?
The lecture lists three problems: size (\(n\) varies), order (relabelling the nodes gives \(PAP^\top\), a different matrix for the same graph), and scale (\(n^2\) inputs). Let's see the order problem directly.
We take two untrained node-level models that each output one number per node:
- MLP on the flattened adjacency: \(\text{vec}(A)\in\mathbb R^{34^2}\to\) hidden \(\to \mathbb R^{34}\).
- Neighbour sum: \(h_v = \sum_{u\in N(v)} x_u\) — the symmetric aggregation every GNN is built on (L03).
A node-level model is permutation equivariant if relabelling the nodes just relabels the outputs: \(f(PAP^\top)=P\,f(A)\). We relabel with a random permutation and map the outputs back to the original node names.
A_karate = torch.tensor( nx.to_numpy_array(karate, nodelist=range(34), weight=None), dtype=torch.float, ) x_degree = A_karate.sum(1, keepdim=True) # node feature = degree torch.manual_seed(SEED) flat_mlp = torch.nn.Sequential( torch.nn.Linear(34 * 34, 64), torch.nn.ReLU(), torch.nn.Linear(64, 34) ) def mlp_on_adjacency(A, x): return flat_mlp(A.reshape(1, -1)).squeeze( 0 ) # one output per *position* 0..33 def neighbour_sum(A, x): return (A @ x).squeeze(1) # h_v = sum of neighbours' features perm_seed = mo.ui.slider( 0, 50, value=1, label="permutation seed", show_value=True ) perm_seed
def equivariance_gap(model, seed): """Relabel nodes with permutation `perm` (new position i holds old node perm[i]); compare outputs per original node.""" perm = torch.from_numpy(np.random.default_rng(seed).permutation(34)) A_perm = A_karate[perm][:, perm] # P A P^T x_perm = x_degree[perm] with torch.no_grad(): out_orig = model(A_karate, x_degree) out_perm = model(A_perm, x_perm) out_back = torch.empty_like(out_perm) out_back[perm] = out_perm # undo the relabelling return out_orig, out_back _rows = [] for _name, _model in [ ("MLP on vec(A)", mlp_on_adjacency), ("neighbour sum", neighbour_sum), ]: _o, _b = equivariance_gap(_model, perm_seed.value) _rows.append( { "model": _name, "node 0 (orig)": round(_o[0].item(), 3), "node 0 (relabelled)": round(_b[0].item(), 3), "node 33 (orig)": round(_o[33].item(), 3), "node 33 (relabelled)": round(_b[33].item(), 3), "max |difference| over nodes": round( (_o - _b).abs().max().item(), 4 ), } ) pd.DataFrame(_rows)
The neighbour sum gives exactly the same number to each node after relabelling (difference 0.0), whatever seed you pick. The MLP gives node 0 a different output once the rows are shuffled — its weights are tied to positions in the matrix, not to nodes. Move the slider: the MLP's gap changes with every permutation, the sum's never does. Train the MLP as long as you like; there are \(34!\) labellings of the same graph and it would have to learn all of them.
Takeaway: any graph operator must treat a node's neighbours as a set — sum, mean, max, attention. This one constraint shapes the whole course.
3 · Choosing a graph representation
"What are the nodes, and what are the edges?" decides which questions you can ask. Below is a small, synthetic Twitter-like log (generated here, two camps of users) with three event types: follows, retweets, and hashtag use. From the same log we build four different graphs.
def make_social_log(n_users=40, n_tweets=160, seed=SEED): · 52 lines
def make_social_log(n_users=40, n_tweets=160, seed=SEED): """Synthetic X/Twitter-like log with two camps; same-camp interactions are more likely (homophily).""" r = np.random.default_rng(seed) camp = (np.arange(n_users) >= n_users // 2).astype(int) hashtags = [ "#economy", "#protest", "#football", "#election", "#music", "#tech", ] camp_tags = {0: [0, 1, 3], 1: [2, 3, 4, 5]} users = pd.DataFrame( {"user": [f"u{i}" for i in range(n_users)], "camp": camp} ) follows = [ (f"u{i}", f"u{j}") for i in range(n_users) for j in range(n_users) if i != j and r.random() < (0.18 if camp[i] == camp[j] else 0.02) ] authors = r.integers(0, n_users, n_tweets) tweets = pd.DataFrame( { "tweet": [f"t{k}" for k in range(n_tweets)], "author": [f"u{a}" for a in authors], "hashtag": [ hashtags[r.choice(camp_tags[camp[a]])] for a in authors ], } ) retweets = [] for k, a in enumerate(authors): for _ in range(r.poisson(1.5)): u = r.integers(0, n_users) if u != a and (camp[u] == camp[a] or r.random() < 0.15): retweets.append((f"u{u}", f"t{k}")) return ( users, pd.DataFrame(follows, columns=["follower", "followee"]), tweets, pd.DataFrame(retweets, columns=["user", "tweet"]), ) users_df, follows_df, tweets_df, retweets_df = make_social_log() { "users": len(users_df), "follow events": len(follows_df), "tweets": len(tweets_df), "retweet events": len(retweets_df), }
def build_views(): camp_of = dict(zip(users_df.user, users_df.camp)) views = {} g = nx.DiGraph() g.add_nodes_from(users_df.user) g.add_edges_from(follows_df.itertuples(index=False)) views["follows (directed)"] = g rt = retweets_df.merge(tweets_df, on="tweet") # who retweeted whom g = nx.DiGraph() g.add_nodes_from(users_df.user) for (u, a), w in rt.groupby(["user", "author"]).size().items(): g.add_edge(u, a, weight=int(w)) views["retweets (directed, weighted)"] = g ut = tweets_df.groupby("author").hashtag.apply(set) g = nx.Graph() g.add_nodes_from(users_df.user) for i, u in enumerate(ut.index): for v in ut.index[i + 1 :]: shared = len(ut[u] & ut[v]) if shared: g.add_edge(u, v, weight=shared) views["co-hashtag (folded, undirected)"] = g g = nx.Graph() g.add_nodes_from(users_df.user, kind="user") g.add_nodes_from(tweets_df.tweet, kind="tweet") g.add_nodes_from(tweets_df.hashtag.unique(), kind="hashtag") g.add_edges_from(zip(tweets_df.author, tweets_df.tweet), rel="posts") g.add_edges_from( zip(retweets_df.user, retweets_df.tweet), rel="retweets" ) g.add_edges_from( zip(tweets_df.tweet, tweets_df.hashtag), rel="has_tag" ) views["heterogeneous (users, tweets, hashtags)"] = g return views, camp_of social_views, camp_of = build_views() view_picker = mo.ui.dropdown( list(social_views), value="follows (directed)", label="representation" ) view_picker
def describe_view(g): · 53 lines
def describe_view(g): und = g.to_undirected() if g.is_directed() else g stats = { "nodes": g.number_of_nodes(), "edges": g.number_of_edges(), "directed": g.is_directed(), "density": round(nx.density(g), 3), "connected components": nx.number_connected_components(und), } if g.is_directed(): stats["reciprocity"] = round(nx.reciprocity(g), 3) users = [n for n in g if n in camp_of] same = [ camp_of[u] == camp_of[v] for u, v in g.edges() if u in camp_of and v in camp_of ] if same: stats["user–user edges inside a camp"] = f"{np.mean(same):.0%}" return stats _g = social_views[view_picker.value] _fig, _ax = plt.subplots(figsize=(6, 4)) _pos = nx.spring_layout( _g, seed=SEED, k=0.35 if _g.number_of_nodes() > 60 else None ) _kind = nx.get_node_attributes(_g, "kind") _palette = {"tweet": "#BBBBBB", "hashtag": "#54A24B"} _colors = [ _palette[_kind[n]] if _kind.get(n) in _palette else ("#4C78A8" if camp_of[n] == 0 else "#F58518") for n in _g ] _sizes = [ 12 if _kind.get(n) == "tweet" else 120 if _kind.get(n) == "hashtag" else 45 for n in _g ] nx.draw_networkx_edges(_g, _pos, ax=_ax, alpha=0.15, arrows=False) nx.draw_networkx_nodes( _g, _pos, ax=_ax, node_color=_colors, node_size=_sizes, linewidths=0 ) _ax.set_title( view_picker.value + " (blue/orange = camp, green = hashtag, grey = tweet)", fontsize=9, ) _ax.axis("off") mo.hstack([_ax, describe_view(_g)], widths=[3, 2])
Try it: switch the representation. The same 40 users give very different graphs:
- follows — 151 directed edges, density ≈ 0.10, low reciprocity (≈ 15% of follows are mutual): direction matters.
- retweets — sparser (104 edges) but weighted; ≈ 88% of edges stay inside a camp, so it is the most "camp-revealing" view.
-
co-hashtag (folded) — density jumps to ≈ 0.60: one shared hashtag (
#electionis used by both camps) links everyone who used it. Folding manufactures edges. -
heterogeneous — 206 nodes of three types; no information thrown away, but you now need models that know about types (L09).
Each choice makes different questions answerable. In PyG the heterogeneous view is a HeteroData object, with one
edge_index per relation type (source type, relation, target type):
def to_heterodata(): uid = {u: i for i, u in enumerate(users_df.user)} tid = {t: i for i, t in enumerate(tweets_df.tweet)} tags = sorted(tweets_df.hashtag.unique()) hid = {h: i for i, h in enumerate(tags)} data = HeteroData() data["user"].num_nodes = len(uid) data["user"].y = torch.tensor( users_df.camp.values ) # a node label to predict later data["tweet"].num_nodes = len(tid) data["hashtag"].num_nodes = len(hid) pairs = lambda a, b: torch.tensor([list(a), list(b)], dtype=torch.long) data["user", "follows", "user"].edge_index = pairs( follows_df.follower.map(uid), follows_df.followee.map(uid) ) data["user", "posts", "tweet"].edge_index = pairs( tweets_df.author.map(uid), tweets_df.tweet.map(tid) ) data["user", "retweets", "tweet"].edge_index = pairs( retweets_df.user.map(uid), retweets_df.tweet.map(tid) ) data["tweet", "has_tag", "hashtag"].edge_index = pairs( tweets_df.tweet.map(tid), tweets_df.hashtag.map(hid) ) return data social_hetero = to_heterodata() print(social_hetero) print("metadata (node types, relation types):", social_hetero.metadata())
4 · Bipartite graphs and folding
A bipartite graph has two node sets \(U, V\) and every edge goes across (users→movies, authors→papers, users→hashtags). Its incidence matrix is \(B\in\{0,1\}^{|U|\times|V|}\). Folding onto \(U\) gives $$ (BB^\top){ij} = \sum_k B{ik}B_{jk} = #\text{shared neighbours of } i \text{ and } j, $$ with each node's degree on the diagonal. Folding onto \(V\) is \(B^\top B\). The lecture's worked example: three users rate four movies.
movie_users = ["Ali", "Sara", "Reza"] movie_names = ["m1", "m2", "m3", "m4"] B_movies = np.array([[1, 1, 0, 0], [1, 1, 1, 0], [0, 0, 1, 1]]) add_blockbuster = mo.ui.checkbox( label="add a blockbuster column that everyone rated" ) downweight = mo.ui.checkbox( label="down-weight shared items by 1 / log(1 + item degree)" ) mo.vstack( [ mo.md("**Try it:** toggle a hub item and the usual fix."), add_blockbuster, downweight, ] )
def fold(B, item_weights=None): """Return (user-user, item-item) folded matrices; optional per-item weights for the user side.""" w = np.ones(B.shape[1]) if item_weights is None else item_weights return (B * w) @ B.T, B.T @ B def heatmap(M, rows, cols, title, ax): ax.imshow(M, cmap="Blues", aspect="auto") ax.set_xticks(range(len(cols)), cols, rotation=45, fontsize=8) ax.set_yticks(range(len(rows)), rows, fontsize=8) for (i, j), val in np.ndenumerate(M): ax.text( j, i, f"{val:.2f}".rstrip("0").rstrip("."), ha="center", va="center", fontsize=8, color="white" if val > 0.6 * M.max() else "black", ) ax.set_title(title, fontsize=9) _B = ( np.hstack([B_movies, np.ones((3, 1), dtype=int)]) if add_blockbuster.value else B_movies ) _items = movie_names + (["blockbuster"] if add_blockbuster.value else []) _w = 1 / np.log1p(_B.sum(0)) if downweight.value else None _UU, _VV = fold(_B, _w) _fig, _axs = plt.subplots( 1, 3, figsize=(9, 2.8), gridspec_kw={"width_ratios": [1.3, 1, 1.3]} ) heatmap(_B, movie_users, _items, "B (users × movies)", _axs[0]) heatmap( _UU, movie_users, movie_users, "user–user B·diag(w)·Bᵀ" if downweight.value else "user–user BBᵀ", _axs[1], ) heatmap(_VV, _items, _items, "movie–movie BᵀB", _axs[2]) plt.tight_layout() _fig
Without toggles you get the lecture's numbers: Ali–Sara share 2 movies, Sara–Reza 1, Ali–Reza 0; the diagonal is each user's degree; movies m1, m2 are co-rated by 2 users — an item-to-item recommendation signal.
Tick blockbuster: Ali and Reza suddenly become neighbours (0 → 1) and every movie pair gains a link — the hub turns the folded graph into a near-clique. Tick down-weight: the blockbuster's contribution shrinks (Ali–Reza 1 → 0.72) while links through rare items keep more weight. This same "divide by degree" idea returns as degree normalisation in GCNs (L03).
What is lost: from \(BB^\top\) alone you cannot tell which movies were shared — why L11 runs GNNs on the bipartite graph itself.
5 · Three levels of prediction
Every task in the course is one of these. Below, each is solved with hand-made graph features and a linear model — the classic pipeline (see bonus notebook L20). L02 onward replaces the hand-made features with learned ones.
| level | question | here |
|---|---|---|
| node | a property of one node | which faction does each Karate member join? |
| link | does an edge exist / will it appear? | recover hidden Karate friendships |
| graph | one label for a whole graph | which random-graph model generated this graph? |
_dist0 = nx.single_source_shortest_path_length(karate, 0) _dist33 = nx.single_source_shortest_path_length(karate, 33) _pagerank = nx.pagerank(karate, weight=None) _clust = nx.clustering(karate) node_features = pd.DataFrame( { "degree": degrees, "clustering": [_clust[v] for v in karate], "pagerank": [_pagerank[v] for v in karate], "dist_to_0 - dist_to_33": [_dist0[v] - _dist33[v] for v in karate], } ) def cv_accuracy(cols, repeats=5): """Mean accuracy of standardised logistic regression over `repeats` shuffled, stratified 5-fold splits.""" clf = make_pipeline( StandardScaler(), LogisticRegression(max_iter=1000) ) return np.mean( [ cross_val_score( clf, node_features[cols].values, club, cv=StratifiedKFold(5, shuffle=True, random_state=r), ).mean() for r in range(repeats) ] ) pd.DataFrame( { "features": [ "degree only", "degree + clustering + pagerank", "+ relative distance to the two leaders", ], "accuracy (5×5-fold CV)": [ cv_accuracy(["degree"]), cv_accuracy(["degree", "clustering", "pagerank"]), cv_accuracy(list(node_features.columns)), ], } ).round(3)
"Importance" features (degree, PageRank, clustering) score below chance (0.38–0.45): both factions have a hub and a periphery, so how central you are says nothing about which side you are on. One positional feature — am I closer to node 0 or to node 33? — jumps to ≈ 0.91. Structure vs. position is a distinction that comes back in L07.
Link level. Hide a fraction of the edges, then score every non-edge pair by how many neighbours the two nodes share. If the hidden edges get higher scores than random non-edges, the graph predicts its own missing links.
hide_frac = mo.ui.slider( 0.05, 0.5, step=0.05, value=0.15, label="fraction of edges hidden", show_value=True, ) hide_frac
def link_prediction_demo(frac, seed=SEED): r = np.random.default_rng(seed) edges = list(karate.edges()) hidden_idx = r.choice( len(edges), int(frac * len(edges)), replace=False ) hidden = [edges[i] for i in hidden_idx] g_obs = karate.copy() g_obs.remove_edges_from(hidden) non_edges = list( nx.non_edges(karate) ) # truly absent pairs = negatives candidates = hidden + non_edges labels = np.r_[np.ones(len(hidden)), np.zeros(len(non_edges))] scorers = { "common neighbours": lambda u, v: len( list(nx.common_neighbors(g_obs, u, v)) ), "Jaccard": lambda u, v: next( nx.jaccard_coefficient(g_obs, [(u, v)]) )[2], "Adamic–Adar": lambda u, v: next( nx.adamic_adar_index(g_obs, [(u, v)]) )[2], "random guess": lambda u, v: r.random(), } return { name: roc_auc_score(labels, [f(u, v) for u, v in candidates]) for name, f in scorers.items() }, len(hidden) _aucs, _n_hidden = link_prediction_demo(hide_frac.value) mo.md( f"Hidden **{_n_hidden}** edges, scored against all {len(list(nx.non_edges(karate)))} non-edges. ROC AUC:\n\n" + "\n".join(f"- **{k}**: {v:.3f}" for k, v in _aucs.items()) )
With 15% hidden, Adamic–Adar (shared neighbours, weighted by \(1/\log\deg\) — the same hub discount as in §4) reaches AUC ≈ 0.76 and common neighbours ≈ 0.72, against ≈ 0.5 for a random guess. (With only ~11 positives, the random-guess AUC itself swings between ≈ 0.35 and 0.56 — small test sets are noisy.) Hide 50% and all heuristics fall to ≈ 0.6: they need the observed neighbourhoods to be intact. Learned embeddings (L02) and GNNs (L05) do the same job with learned scores.
Graph level. Generate many small graphs from three random-graph models with the same average degree and ask a classifier which model produced each graph, using one feature vector per graph.
def graph_stats(g): · 63 lines
def graph_stats(g): deg = np.array([d for _, d in g.degree()]) return [ deg.mean(), deg.std(), deg.max(), nx.average_clustering(g), nx.transitivity(g), nx.number_connected_components(g), ] graph_stat_names = [ "mean degree", "degree std", "max degree", "avg clustering", "transitivity", "#components", ] def make_graph_dataset(n_per_class=60, n=50, seed=SEED): r = np.random.default_rng(seed) makers = { "Erdős–Rényi": lambda s: nx.gnm_random_graph( n, 2 * n, seed=s ), # avg degree 4 "Barabási–Albert": lambda s: nx.barabasi_albert_graph( n, 2, seed=s ), # avg degree ≈ 4, hubs "Watts–Strogatz": lambda s: nx.watts_strogatz_graph( n, 4, 0.1, seed=s ), # avg degree 4, clustered } X, y = [], [] for label, make in enumerate(makers.values()): for _ in range(n_per_class): X.append(graph_stats(make(int(r.integers(1e9))))) y.append(label) return np.array(X), np.array(y), list(makers) graph_X, graph_y, graph_classes = make_graph_dataset() _clf = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)) _acc = cross_val_score( _clf, graph_X, graph_y, cv=StratifiedKFold(5, shuffle=True, random_state=SEED), ).mean() _means = ( pd.DataFrame(graph_X, columns=graph_stat_names) .groupby(graph_y) .mean() .round(2) ) _means.index = graph_classes mo.vstack( [ mo.md( f"5-fold accuracy over {len(graph_y)} graphs: **{_acc:.3f}** (chance = 0.333). Class means of the features:" ), _means, ] )
Accuracy 1.000: three simple statistics fingerprint each model. Barabási–Albert graphs have hubs (max degree ≈ 17, degree std ≈ 3.1); Watts–Strogatz graphs are clustered (clustering ≈ 0.38) and regular (std ≈ 0.6); Erdős–Rényi graphs have neither and sometimes fall apart into several components. Real graph-level tasks (is this molecule toxic?) are much harder, which is why graph classification gets learned readouts in L06.
5b · Real social networks: which model do they resemble?
The three random models above each get one property of real networks right. Real social networks, from the repo's
dataset catalogue (gl.datasets; sources and licences in data/README.md), show all of them at once. Seven networks
from 34 to 256,000 nodes: Zachary's karate club, Lusseau's dolphins, NCAA football games, US political blogs, an EU
institution's e-mail, Facebook ego networks, and the Higgs boson retweet graph from Twitter.
Predict first: which of the three models' signatures (hubs, high clustering, short paths) will the real networks share? Will the retweet graph look like the friendship graphs?
REAL_NETS = ["karate", "dolphins", "football", "polblogs", "email-eu-core", "ego-facebook", "higgs-retweet"] def describe(name): g = graph(name) st = summary(g) und = g.to_undirected() if g.is_directed() else g deg = np.array([d for _, d in und.degree()]) vals, counts = np.unique(deg[deg > 0], return_counts=True) ccdf = 1 - np.cumsum(counts) / counts.sum() + counts / counts.sum() # P(degree >= k) st["degree"] = vals.tolist() st["ccdf"] = ccdf.tolist() return st real_run = mo.ui.run_button(label="Load 7 real networks (downloads ≈ 3 MB the first time; then cached)") real_run
_cache = CACHE_DIR / "L01_real_networks.json" mo.stop( not (real_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/L01_real_networks.json`."), ) real_stats = {n: json_cache(_cache, {"network": n, "v": 1}, lambda n=n: describe(n)) for n in REAL_NETS} _cols = ["nodes", "edges", "directed", "mean degree", "max degree / mean", "avg clustering", "avg path (sampled)", "reciprocity", "giant component"] real_table = pd.DataFrame({n: {c: v[c] for c in _cols} for n, v in real_stats.items()}).T # an Erdős–Rényi graph with the same density has expected clustering = density real_table["clustering ÷ ER"] = real_table["avg clustering"] / ( real_table["mean degree"] / (real_table["nodes"] - 1) ) _fig, _ax = plt.subplots(figsize=(5.2, 3.3)) for _n, _v in real_stats.items(): _ax.loglog(_v["degree"], _v["ccdf"], ".-", ms=3, lw=0.8, label=_n) _ax.set_xlabel("degree k") _ax.set_ylabel("P(degree ≥ k)") _ax.set_title("Degree distributions (log-log)", fontsize=9) _ax.legend(fontsize=6.5) plt.tight_layout() mo.vstack([_fig, real_table.round(3)])
_t = real_table · 24 lines
_t = real_table _soc = [n for n in _t.index if n != "higgs-retweet"] _hubs = [n for n in _t.index if _t.loc[n, "max degree / mean"] > 5] _flat = [n for n in _t.index if n not in _hubs] _and = lambda xs: ", ".join(xs[:-1]) + " and " + xs[-1] if len(xs) > 1 else "".join(xs) # noqa: E731 mo.md(rf""" **Reading the table.** *Hubs* (BA's signature): in {", ".join(_hubs)} the largest degree is {_t.loc[_hubs, "max degree / mean"].min():.0f}–{_t.loc[_hubs, "max degree / mean"].max():,.0f}× the mean, the long tails on the log-log plot; {_and(_flat)} {"has" if len(_flat) == 1 else "have"} none (at most {_t.loc[_flat, "max degree / mean"].max():.1f}× the mean): a league schedule, a small club or an animal group caps how many ties one member can have. *Clustering* (WS's signature): every network except the retweet graph is {_t.loc[_soc, "clustering ÷ ER"].min():.0f}–{_t.loc[_soc, "clustering ÷ ER"].max():.0f}× more clustered than an Erdős–Rényi graph of the same density. *Short paths* (ER's one strength): average distances of {_t["avg path (sampled)"].min():.1f}–{_t["avg path (sampled)"].max():.1f} steps even at a quarter of a million nodes. Real social networks are all three at once: **heavy-tailed, clustered small worlds**, which no one of the three models produces alone. The retweet graph is the odd one: average clustering {_t.loc["higgs-retweet", "avg clustering"]:.3f} against {_t.loc[_soc, "avg clustering"].min():.2f}–{_t.loc[_soc, "avg clustering"].max():.2f} for the others (still {_t.loc["higgs-retweet", "clustering ÷ ER"]:,.0f}× an ER graph of its tiny density, which has essentially none), reciprocity {_t.loc["higgs-retweet", "reciprocity"]:.3f} (almost no one retweets back), and the best-connected account has {_t.loc["higgs-retweet", "max degree / mean"]:,.0f}× the mean degree. Retweets form **stars** around a few broadcasters, not the triangles of friendship. Which graph you build from X data (L01 §3) decides which of these structures you see. """)
6 · Exercises
Exercise 1 (by hand). A paper with \(k\) authors is folded into a co-authorship graph. How many edges does that single
paper create for \(k=10\)? Put the number in ex1_answer.
Exercise 2 (code). Fold the synthetic log's user→tweet retweet bipartite graph onto users: two users are linked
if they retweeted at least one common tweet. How many user–user edges does the folded graph have (no self-loops)?
Put the number in ex2_answer. Tools: retweets_df, users_df, pd.crosstab, @.
ex1_answer = None ex2_answer = None
def _check(answer, expected): · 45 lines
def _check(answer, expected): if answer is None: return mo.callout(mo.md("Not answered yet."), kind="neutral") ok = np.isclose(answer, expected) return mo.callout( mo.md("Correct!" if ok else f"Not yet — you have {answer}."), kind="success" if ok else "warn", ) _B = ( pd.crosstab(retweets_df.user, retweets_df.tweet) .clip(upper=1) .reindex(users_df.user, fill_value=0) .values ) _UU = _B @ _B.T np.fill_diagonal(_UU, 0) mo.vstack( [ mo.md("**Exercise 1**"), _check(ex1_answer, 45), mo.md("**Exercise 2**"), _check(ex2_answer, int((_UU > 0).sum() // 2)), mo.accordion( { "Hint 1": mo.md( "Every pair of the $k$ authors becomes an edge: a $k$-clique." ), "Solution 1": mo.md( r"$\binom{10}{2}=45$. One 10-author paper weighs as much as 45 two-author papers — another reason to down-weight by degree." ), "Hint 2": mo.md( "Build the user × tweet incidence matrix $B$ (0/1), compute $BB^\\top$, zero the diagonal, count the non-zero pairs once." ), "Solution 2": mo.md("""```python B = (pd.crosstab(retweets_df.user, retweets_df.tweet).clip(upper=1) .reindex(users_df.user, fill_value=0).values) UU = B @ B.T np.fill_diagonal(UU, 0) ex2_answer = int((UU > 0).sum() // 2) # each pair counted twice in a symmetric matrix ```"""), } ), ] )
7 · Pitfall: one-directional edge_index for an undirected graph
torch.tensor(list(G.edges())).t() stores each undirected edge once. Message passing (L03) only sends messages
along stored directions, so half of every friendship silently disappears. The node that "has no incoming edges"
receives nothing:
_one_way = torch.tensor(list(karate.edges()), dtype=torch.long).t() _in_deg_one_way = torch.bincount(_one_way[1], minlength=34) _in_deg_fixed = torch.bincount(to_undirected(_one_way)[1], minlength=34) pd.DataFrame( { "": ["one-way edge_index", "to_undirected(edge_index)"], "is_undirected": [ is_undirected(_one_way), is_undirected(to_undirected(_one_way)), ], "columns": [_one_way.size(1), to_undirected(_one_way).size(1)], "in-degree of node 0": [ int(_in_deg_one_way[0]), int(_in_deg_fixed[0]), ], "in-degree of node 33": [ int(_in_deg_one_way[33]), int(_in_deg_fixed[33]), ], } )
mo.vstack( · 30 lines
mo.vstack( [ mo.callout( mo.md(r"""**Pitfall, shown above:** with the one-way tensor, Mr. Hi (node 0) has in-degree **0** — every edge `(0, v)` was stored as `0 → v`, so in message passing node 0 would hear from nobody. Node 33 looks fine (17) only because NetworkX happened to list its edges as `u → 33`. **Fix:** `to_undirected(edge_index)` (or `T.ToUndirected()`), and check with `is_undirected`."""), kind="warn", ), mo.md(r""" ## 8 · Connecting to your research (SNA on X/Twitter) * **Node level:** bot detection, stance / ideology, influencer identification — the *Officer vs Mr. Hi* task with millions of users. * **Link level:** follow / retweet prediction — "who engages with this narrative next?" (Adamic–Adar is a strong, cheap baseline). * **Graph level:** classify retweet **cascades** (organic vs coordinated) from the early cascade graph — the §5 graph task with real cascades. * **Representation is the research question.** Before modelling, write down which questions the heterogeneous users–tweets–hashtags graph answers that the folded follow / retweet / co-hashtag graphs cannot (and vice versa). §3 showed that a single shared hashtag can turn a polarised network into a dense one. ## Summary - A graph = nodes + edges (+ direction, weights, types). In PyG: `Data(x, edge_index, y)` and `HeteroData` per relation type. - Graph models must be **permutation equivariant**: an MLP on $\text{vec}(A)$ changes its answer under relabelling; a neighbour sum doesn't. - Folding $B\mapsto BB^\top$ creates co-occurrence graphs but loses *which* items were shared and turns hubs into cliques. - Every task is node-, link- or graph-level; hand-made features already go a long way, and *position* can matter more than *importance*. **Next — L02 · Node embeddings:** replace the hand-made features of §5 with vectors learned from random walks (DeepWalk, node2vec). """), ] )