EVERYTHING AIAI engineering, made visual
0/23 complete
LESSON 21 · MATHEMATICS × AI · BUILD

See the shape
of a network.

A graph is nodes and edges. Write it as a matrix and every question about its shape becomes linear algebra: A[i][j] = 1 moves information one hop, the Laplacian counts the pieces, and a GNN is neighbor-averaging with learned weights.

90 MIN · 8 CHAPTERSPREREQ · LESSONS 01–03
FIG. 21 / BFS RIPPLES THROUGH A GRAPH
WAVE 0 · NODE 0 queue in, queue out reached current waiting
LESSON 21TYPE · BUILD~90 MINPREREQ · LESSONS 01–03ORIGINAL LESSON ↗
THE 60-SECOND VERSIONThen show me the graphs ↓
01 / WHO CONNECTS TO WHOM

A graph is a connection table.

Nodes hold the things (people, atoms, pages) and edges hold the relationships. The adjacency list stores a neighbour set per node — cheap for sparse graphs. The adjacency matrix stores every pair in an n × n grid — memory-hungry, but now every graph question is a matrix operation.

A[i][j] = 1 ⇔ edge i–j
02 / BFS WIDE, DFS DEEP

Same graph, two strategies.

BFS keeps a queue: it finishes every node at distance d before touching distance d + 1, so first discovery is a shortest path in hops. DFS keeps a stack: it follows one branch to its end before backtracking, which is what finds components, cycles and topological order. Give the edges weights and Dijkstra is BFS with a priority queue.

queue → hop counts · stack → connectivity
03 / SPECTRUM & MESSAGE PASSING

The Laplacian reveals. Aggregation mixes.

L = D − A is the matrix whose eigenvalues read structure without a single traversal: the number of zero eigenvalues equals the number of connected components, and the Fiedler vector splits the graph at its weakest seam. A GNN layer is the same adjacency multiply with learned weights: collect neighbours, average, transform, repeat — K layers, K hops.

L = D − A · Hᵏ⁺¹ = σ(A_norm Hᵏ W)
MENTAL MODEL IN ONE SENTENCE

Write a graph as a matrix (who connects to whom) and every question about its shape becomes linear algebra: multiplying by the adjacency matrix moves information one hop along the edges, and the eigenvectors of the Laplacian reveal how many pieces the graph has and where it is weakest. Graph neural networks are that matrix multiply with learned weights.

By the end you will be able to read a graph as a list or a matrix, run BFS and DFS and say what each one finds, compute shortest paths on weighted graphs with Dijkstra, read a Laplacian’s eigenvalues as components and bottlenecks, split a graph with the Fiedler vector, and explain a GNN layer as neighbor aggregation with learned weights.

NODES, EDGES, MATRICES

Everything connected
is a graph.

When the connections between examples carry the signal, a flat table throws that signal away. A graph keeps it — and one matrix makes it computable.

A graph G = (V, E) is a set of nodes (or vertices) V and a set of edges E. Each edge joins two nodes. With a graph you can ask questions a table cannot answer: who is central, what is connected to what, how few hops between two things, where does the network fall apart.

Edges come in four flavours. They can be undirected (friendship — symmetric) or directed (a follow — one way only), and unweighted (the edge exists or it does not) or weighted (each edge carries a number: a distance, a cost, a strength).

Graph typeWhat the edges meanExample
Undirected, unweightedFriendship: either you are friends or you are notFacebook
Directed, unweightedFollowing: one arrow, no obligation to follow backX / Twitter
Undirected, weightedDistances: each road has a length or travel timeRoad map
Directed, weightedWeb links: a page points at another, with link strengthPageRank

The core representation is the adjacency matrix A. For a graph with n nodes, A is n × n with A[i][j] = 1 when there is an edge from node i to node j, and 0 otherwise. For a weighted graph, A[i][j] is the weight. For an undirected graph, A is symmetric: A[i][j] = A[j][i]. The number of edges touching a node is its degree, and the degree matrix D is diagonal with those counts:

triangle on nodes 0, 1, 2 degrees and degree matrix A = [[0, 1, 1], D = [[2, 0, 0], [1, 0, 1], [0, 2, 0], [1, 1, 0]] [0, 0, 2]] row 0 sums to 2 → node 0 has degree 2 sum of all degrees = 2 + 2 + 2 = 6 = 2 × 3 edges ✓ (every edge is counted twice)

In code you often store the same graph as an adjacency list — a dictionary from each node to its neighbours. The two are interchangeable, and each wins in a different job:

OperationAdjacency listAdjacency matrix
Walk a node’s neighboursO(degree) — only real edgesO(n) — scans the whole row
Test whether edge u–v existsO(degree)O(1) — one lookup
MemoryO(V + E) — great for sparse graphsO(V²) — mostly zeros in practice
Matrix operations / message passingNeeds conversion firstNatural: A @ H mixes neighbours

Build a graph, watch its matrix

Toggle any cell of the adjacency matrix to add or remove an edge. Click a node (or its button) to highlight its neighbours. Undirected graphs always keep A symmetric.

ADJACENCY MATRIX · CLICK A CELL TO TOGGLE AN EDGE
ADJACENCY LIST 0 → 1, 2 1 → 0, 2 2 → 0, 1, 3 3 → 2, 4 4 → 3 degrees: (2, 2, 3, 2, 1) sum of degrees = 10 = 2 × 5 edges ✓ one connected component

A list stores only real neighbours and is cheap for sparse graphs; the matrix spends n² cells but turns “is there an edge?” and every spectral operation into linear algebra.

WALKS, PATHS, CONNECTIVITY

Multiplying by A
counts the walks.

A single matrix power answers “in how many ways can information travel from i to j in m steps?” That question is the bridge between graph structure and linear algebra.

A walk is any sequence of edges where each edge starts where the last one ended; nodes and edges may repeat. A path is a walk that never repeats a node. Every path is a walk, but a walk can loop forever without ever becoming a path. The triangle below shows why the distinction matters for counting — walks multiply, paths need care.

A graph is connected when every node can reach every other by some walk; a connected component is a maximal connected piece. Two triangles with no bridge are two components: no walk crosses from one to the other, so the adjacency matrix splits into two blocks and never mixes them.

0120123node 0 revisitedWALK 0→1→2→0 (all edges of the triangle, no path)PATH 0→1→2→3
A walk may wander and repeat nodes; a path visits every node at most once. Every path is a walk, but almost no walk is a path. BFS earns its keep because the first time it reaches a node, the route is a true shortest path.
triangle on nodes 0, 1, 2 A² = A @ A row-times-column says: (A²)[i][j] = Σ_k A[i][k] · A[k][j] A² = [[2, 1, 1], (A²)[0][0] = 0·0 + 1·1 + 1·1 = 2 [1, 2, 1], (A²)[0][1] = 0·1 + 1·0 + 1·1 = 1 [1, 1, 2]] (A²)[0][2] = 0·1 + 1·1 + 1·0 = 1 (A²)[0][1] = 1: the only 2-step walk from 0 to 1 goes 0 → 2 → 1.
Derivation: every power of A counts walks, and trace(A³) counts triangles
  1. Expand the definition: (A²)[i][j] = Σₖ A[i][k]·A[k][j]. Each k that survives is a node adjacent to both i and j — the middle of a two-edge walk i → k → j. So (A²)[i][j] counts exactly the 2-step walks from i to j.
  2. The diagonal is special: (A²)[i][i] counts walks that leave i along one edge and come straight back. Each neighbour of i contributes one, so the diagonal equals the degree — in the triangle, 2 for every node.
  3. The same argument repeats: (Aᵐ)[i][j] counts m-step walks, because multiplying by A once more inserts one more middle node.
  4. A closed 3-step walk is a triangle walked once, and each triangle is counted six times: three starting nodes × two directions. For the triangle, trace(A³) = 2 + 2 + 2 = 6 and 6 / 6 = 1 triangle — exactly right.
A³ = A² @ A: diagonal (A³)[0][0] = 2·0 + 1·1 + 1·1 = 2 trace(A³) = 2 + 2 + 2 = 6 # of triangles = 6 / 6 = 1 ✓ two disjoint triangles: A is block-diagonal, and Aᵐ stays block-diagonal → no walk ever crosses, which is what "two connected components" means.

This is the sentence to remember from the chapter: multiplying by A moves information one hop. A GNN layer does exactly this multiply, then learns which neighbour information to keep.

Quick check

In the triangle graph, (A²)[0][0] = 2. What does that 2 count?

BFS VS DFS

Two ways to get lost.
One way to be sure.

Breadth-first search grows in rings and finds fewest-hop routes. Depth-first search dives to the end of one branch and finds structure. The difference is one data structure: queue versus stack.

Breadth-first search (BFS) keeps a queue — first in, first out. It visits every node at distance 0, then every node at distance 1, then distance 2, and so on. Because discovery order is distance order, the first time BFS touches a node it has found a shortest path in hops. This is why hop counts in social networks (“how many introductions away?”) are a BFS question.

Depth-first search (DFS) keeps a stack — last in, first out. It follows one branch as far as it can before backtracking. The route it takes to a node is not the shortest one, but the shape of its exploration is exactly what finds connected components, detects cycles, and produces a topological ordering (the order backpropagation runs in). Both visit every node and edge once: O(V + E).

AlgorithmData structureFindsUse case
BFSQueue (FIFO)Shortest paths in unweighted graphsSocial distance, knowledge-graph traversal
DFSStack (LIFO) or recursionComponents, cycles, topological orderConnectivity, dependency ordering

BFS and DFS, one step at a time

Run both algorithms from the same source. BFS keeps a queue and grows in rings; DFS keeps a stack and dives until it hits a dead end. The tree edges are the ones each algorithm actually used.

STEP 1 / 9 Queue starts with the source, node 0. Nodes leave in discovery order, so every node at distance d is processed before any node at distance d + 1. queue: [0] visited: — hop distances: 0, -1, -1, -1, -1, -1, -1

Both run in O(V + E). The difference is what they are for: BFS answers “how few hops?”, DFS answers “what is connected?” and produces cycle and ordering information.

Derivation: why the first BFS visit is a shortest path
  1. The queue holds nodes in the order they were discovered, and every node discovered from a node at distance d lands at the back with distance d + 1.
  2. So BFS finishes every distance-d node before it ever dequeues a distance-(d+1) node. Suppose a node first appears at distance d. Any other route to it must come from a previously visited node; that node is at distance at least d − 1, so the route has length at least d. Nothing shorter exists.
  3. Induction on d turns that step into the general statement: BFS distances are exactly the minimum hop counts, for every node.
the lab graph, BFS from 0: visit order: 0, 1, 2, 3, 5, 4, 6 levels: 0, 1, 1, 2, 2, 3, 3 node 5 first appears at level 2, and 0 → 2 → 5 is a 2-hop route ✓ node 6 first appears at level 3; its only neighbours are 5 and 4, both at level 2, so every route from 0 needs at least 3 hops ✓ DFS from 0 visits 0, 1, 3, 2, 5, 6, 4: it reaches node 6 through 0 → 1 → 3 → 2 → 5 → 6, five edges, not because 6 is far away but because DFS was busy going deep.

The moral: “has been visited” and “is at its true distance” are different facts. Only BFS can claim the second one every time it visits.

BFS and DFS from scratch — Pythonpython
from collections import deque

graph = {0: {1, 2}, 1: {0, 3}, 2: {0, 3, 5}, 3: {1, 2, 4},
         4: {3, 6}, 5: {2, 6}, 6: {5, 4}}


def bfs(graph, start):
    visited = {start}
    order, dist = [], {start: 0}
    queue = deque([start])            # FIFO
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in sorted(graph[node]):
            if neighbor not in visited:
                visited.add(neighbor)
                dist[neighbor] = dist[node] + 1
                queue.append(neighbor)
    return order, dist


def dfs(graph, start):
    visited, order = set(), []
    stack = [start]                   # LIFO
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        for neighbor in reversed(sorted(graph[node])):
            if neighbor not in visited:
                stack.append(neighbor)
    return order


print(bfs(graph, 0))   # (order, {distances}) — shortest hops
print(dfs(graph, 0))   # order only — connectivity, cycles, orderings
Same graph, same O(V + E): the queue returns distances, the stack returns exploration order.
Quick check

Friendship edges all count one hop. You want the fewest introductions between two people. Which traversal gives the answer, and why?

WEIGHTED SHORTEST PATHS

Not all edges
are equal.

Give every edge a cost and “shortest” stops meaning “fewest hops”. Dijkstra’s algorithm settles the cheapest node first and never has to reconsider.

A weighted graph attaches a number to every edge: a distance, a travel time, a price, a strength. Now the length of a route is the sum of its weights, and two routes with the same number of edges can cost wildly different amounts. BFS cannot tell them apart — it treats every edge as length 1.

Dijkstra’s algorithm is the fix. Keep a tentative distance for every node, ∞ except the source at 0. Repeatedly pick the unsettled node with the smallest tentative distance, declare it final, and relax its edges: for each neighbour, if going through the settled node is cheaper than the current best, update it. With a priority queue the whole thing runs in O((V + E) log V).

Dijkstra, edge by edge

Weighted roads, cheapest route first. At each step Dijkstra settles the node with the smallest tentative distance, then relaxes its edges. Try comparing the final weights with the plain hop counts.

STEP 1 Every node starts at distance ∞ except the source 0 at 0. The frontier table is the whole algorithm's memory. distances: 0: 0 1: ∞ 2: ∞ 3: ∞ 4: ∞ 5: ∞ hops (BFS): 0, 1, 1, 2, 2, 3 weights (Dijkstra, final): 0, 3, 1, 8, 10, 13

All weights equal → Dijkstra is BFS. Negative weights break it: the “settled can never improve” promise needs weights ≥ 0.

Worked trace: Dijkstra on the lab graph from node 0

Every line is one settle plus the relaxations it causes. The bracket is the distance table (node 0 → node 5); bold marks what changed.

start: d = [0, ∞, ∞, ∞, ∞, ∞] settle 0 (d=0): relax 0→1 (4), 0→2 (1) d = [0, 4, 1, ∞, ∞, ∞] settle 2 (d=1): relax 2→1 (1+2=3 < 4), 2→3 (9), 2→4 (11) d = [0, 3, 1, 9, 11, ∞] settle 1 (d=3): relax 1→3 (3+5=8 < 9) d = [0, 3, 1, 8, 11, ∞] settle 3 (d=8): relax 3→4 (8+2=10 < 11), 3→5 (14) d = [0, 3, 1, 8, 10, 14] settle 4 (d=10): relax 4→5 (10+3=13 < 14) d = [0, 3, 1, 8, 10, 13] settle 5 (d=13): done route to 5, following the parents: 0 → 2 → 1 → 3 → 4 → 5 check the weights: 1 + 2 + 5 + 2 + 3 = 13 ✓

Why settling is safe. When u has the smallest tentative distance, every other route from the source must first reach some unsettled node x. That x already has d[x] ≥ d[u], and the rest of the route adds only non-negative weights — so no route through x can beat d[u]. The settling step is permanent, which is why each node is processed once.

BFS hop counts from 0: [0, 1, 1, 2, 2, 3] Dijkstra weights: [0, 3, 1, 8, 10, 13] node 1 and node 2 are both 1 hop away, but one costs 3 and one costs 1. node 3 looks "close" at 2 hops; it actually costs 8 — still better than node 4 at 10, which is why the orders differ.
THE LAPLACIAN

D minus A reads
the shape of the graph.

One matrix built from the degrees and the edges. Its eigenvalues count the pieces of the graph and measure how close it is to falling apart — with no traversal at all.

The graph Laplacian is L = D − A: put each node’s degree on the diagonal, subtract the adjacency matrix, and every edge becomes a −1 in two symmetric spots. For the triangle:

D = [[2, 0, 0], A = [[0, 1, 1], L = [[ 2, -1, -1], [0, 2, 0], [1, 0, 1], [-1, 2, -1], [0, 0, 2]] [1, 1, 0]] [-1, -1, 2]] eigenvalues of L: 0, 3, 3 the 0 comes with the all-ones eigenvector: (1, 1, 1) the 3s say the triangle is as tightly connected as 3 nodes can be

Those eigenvalues are not decoration. They encode four facts worth memorising:

PropertyPlain English
All eigenvalues ≥ 0L is positive semi-definite — a sum of squares can never go negative.
Zero eigenvalues = connected componentsOne zero for a connected graph, k zeros for k separate pieces.
Smallest nonzero eigenvalue γ (the Fiedler value)Algebraic connectivity: small γ means a bottleneck, a single weak seam holding the graph together.
Signs of the Fiedler vectorThe natural two-way split: nodes on either side of the seam get opposite signs.

The Laplacian finds the weakest seam

Two triangles (nodes 0–2 and 3–5) joined by adjustable bridges. L is computed live, its Fiedler vector colours every node, and the dashed edges are the ones the sign split cuts. Add bridges and watch γ rise.

2-1-1000-12-1000-1-13-10000-13-1-1000-12-1000-1-12
eigenvalues of L (ascending): 0, 0.4384, 3, 3, 3, 4.5616 λ₂ = 0.4384 = (5 − √17)/2 = 0.438447187… trace check: Σλ = 14 = Σ degrees = 14 ✓ Rayleigh quotient vᵀLv/vᵀv = 0.4384 ≈ λ₂ ✓ max |Lv − λ₂v| = 1.4e-15 Fiedler vector: (-0.465, -0.465, -0.261, 0.261, 0.465, 0.465) positive group: {3, 4, 5} negative group: {0, 1, 2} best cut: 2–3 (1 edge)

One bridge: λ₂ ≈ 0.438 and the cut is exactly that bridge. More bridges raise λ₂ (1, 2, 2.268 …) and make the two communities harder to separate — spectral clustering is a relaxation of minimum cut.

Derivation: the quadratic form, the zero eigenvalues, and the Fiedler vector
  1. Take any vector x that assigns a number to every node. The quadratic form is xᵀLx = Σᵢ dᵢxᵢ² − Σᵢⱼ Aᵢⱼxᵢxⱼ: the degree part adds a square per edge end, the adjacency part subtracts each edge twice.
  2. Group by edge and each edge (i, j) contributes xᵢ² + xⱼ² − 2xᵢxⱼ = (xᵢ − xⱼ)². So xᵀLx = Σ_edges (xᵢ − xⱼ)² — a sum of squares, which proves every eigenvalue is ≥ 0.
  3. The sum is zero exactly when x is constant across every edge, i.e. constant on each connected component. Each component therefore contributes one independent constant vector with eigenvalue 0 — which is why the multiplicity of 0 counts components.
  4. Among unit vectors perpendicular to the all-ones vector, the Fiedler vector minimises Σ (xᵢ − xⱼ)²: it is the smoothest non-constant labelling. Smoothness forces well-connected nodes to share values, so its sign change lands on the graph’s weakest cut.
numeric check on the triangle, x = [1, 0, -1]: Σ_edges (xᵢ − xⱼ)² = (1−0)² + (1−(−1))² + (0−(−1))² = 1 + 4 + 1 = 6 Lx = [3, 0, −3] = 3x → x is an eigenvector with λ = 3 ✓ xᵀLx = 1·3 + 0·0 + (−1)(−3) = 6 and xᵀx = 1² + 0² + 1² = 2 Rayleigh quotient xᵀLx / xᵀx = 6 / 2 = 3 ✓ (equals the eigenvalue) constant vector x = [1, 1, 1]: Lx = [0, 0, 0] = 0·x ✓ (the zero mode)

Second fully worked example — the 4-node path. Nodes 0–1–2–3 in a line have L = [[1,−1,0,0],[−1,2,−1,0],[0,−1,2,−1],[0,0,−1,1]]. Its exact eigenvalues are 0, 2 − √2, 2, 2 + √2 ≈ 0, 0.586, 2, 3.414, and the Fiedler vector is proportional to (1, √2 − 1, −(√2 − 1), −1):

trace check: 0 + (2−√2) + 2 + (2+√2) = 6 = 1 + 2 + 2 + 1 (sum of degrees) ✓ first row of Lv: 1·1 − (√2−1) = 2 − √2 = λ₂ · 1 ✓ λ₂ = 2 − √2 ≈ 0.5858, and the sign split cuts the middle edge 1–2: positive {0, 1}, negative {2, 3}. bonus check (Matrix-Tree theorem): the product of nonzero eigenvalues divided by n equals the number of spanning trees. path: (2−√2)·2·(2+√2) / 4 = 4 / 4 = 1 spanning tree ✓ two triangles joined by one bridge (6 nodes): eigenvalues 0, (5−√17)/2 ≈ 0.438, 3, 3, 3, (5+√17)/2 ≈ 4.562 product of nonzero eigenvalues = ((5−√17)(5+√17)/4)·27 = (8/4)·27 = 54 54 / 6 nodes = 9 spanning trees = 3 per triangle × the one bridge ✓
Quick check

A graph has two connected components. What does the spectrum of its Laplacian contain?

CLUSTERS & PAGERANK

Eigenvectors find
the communities.

Signs of the Fiedler vector split a graph in two. The stationary distribution of a random walk with restart ranks its nodes. Both come from the same idea: an eigenvector is a fixed shape of a repeated operation.

Spectral clustering turns the Laplacian’s eigenvectors into coordinates. The recipe: build L, take the k smallest eigenvectors (skipping the trivial all-ones one for a connected graph), use each node’s eigenvector values as its new coordinates, then run k-means — or for k = 2, just split by the sign of the Fiedler vector. The eigenvectors of L are the smoothest functions on the graph: nodes joined by many edges get similar values, so the first non-trivial one changes sign exactly where the graph is easiest to cut. Spectral clustering is a relaxation of the minimum-cut problem.

PageRank points the same machinery at directed graphs. Imagine a surfer on the web graph: with probability d they follow a random outgoing link; with probability 1 − d they get bored and restart on a uniformly random page. The long-run fraction of time spent on each page is its PageRank — the stationary distribution of a random walk with restart. Writing it out for one page v:

π(v) = (1 − d)/n + d · Σ π(u) / out_degree(u) u → v (1 − d)/n is the restart term; the sum collects rank flowing in along every incoming link, split evenly among each page's out-links. d = 0.85 is the classic damping: 85% follow a link, 15% teleport.

PageRank is a random walk with restart

A surfer follows a random link with probability d, and restarts on a random page with probability 1 − d. Run the power iteration and watch the scores settle. Bigger node = more important.

ROUND 0 · CHANGE ‖π′ − π‖₁ = 0.000e+0 scores: 0: 0.25000 1: 0.25000 2: 0.25000 3: 0.25000 sum = 1 (always 1 ✓) node 0 gets links from 2 (1 out-link) and 3 (2 out-links): next π(0) = 0.0375 + 0.2125 + 0.1062 = 0.3563

Converged values here are ≈ 0.364, 0.192, 0.325, 0.119: node 0 beats the busier node 2 because the most important page links to it — rank is recursive. At d → 1 the walk trusts links almost fully; at d → 0 everyone is equal.

Worked iterations: PageRank by hand on the 4-page web

The lab graph is 0 → 1, 2 · 1 → 2, 3 · 2 → 0 · 3 → 0, 2, with out-degrees (2, 2, 1, 2). Start uniform and apply the formula with d = 0.85, so the restart term is (1 − 0.85)/4 = 0.0375.

round 1: π(0) = 0.0375 + 0.85·(0.25/1 + 0.25/2) = 0.35625 π(1) = 0.0375 + 0.85·(0.25/2) = 0.14375 π(2) = 0.0375 + 0.85·(0.25/2 + 0.25/2 + 0.25/2) = 0.35625 π(3) = 0.0375 + 0.85·(0.25/2) = 0.14375 sum = 1.00000 ✓ round 2: π(0) = 0.0375 + 0.85·(0.35625 + 0.14375/2) = 0.40141 π(1) = 0.0375 + 0.85·(0.35625/2) = 0.18891 π(2) = 0.0375 + 0.85·(0.35625 + 0.14375 + 0.14375)/2 = 0.31109 π(3) = 0.0375 + 0.85·(0.14375/2) = 0.09859 converged (Δ < 10⁻⁶): 0.3640, 0.1922, 0.3246, 0.1192

Notice the surprise: node 2 receives links from three pages, yet node 0 finishes on top. Node 2 is the hub everyone points at, and the hub itself points at node 0 — importance is recursive, which is exactly the property that made PageRank beat counting in-links. Power iteration converges because the restart term keeps rank from getting stuck in cycles; each round is one matrix–vector multiply, and a network with billions of pages still needs only a few dozen rounds.

The same picture for clustering, with exact numbers. Take two triangles joined by a single bridge (the lab above with one bridge): eigenvalues 0, (5 − √17)/2 ≈ 0.438, 3, 3, 3, (5 + √17)/2 ≈ 4.562. The Fiedler vector is proportional to (0.465, 0.465, 0.261, −0.261, −0.465, −0.465): positive on one triangle, negative on the other, with the smallest magnitudes at the two bridge endpoints. The sign split cuts exactly the bridge — the minimum cut. Add bridges and γ rises (≈ 1, 2, 2.268 for two, three, four bridges), and the two communities become harder to separate.

MESSAGE PASSING = GNN

Neighbours talk.
That is a GNN layer.

A graph neural network is one operation repeated: every node collects its neighbours’ features, aggregates them, transforms them with shared learnable weights, and does it again. K rounds reach K hops.

The core operation of a Graph Neural Network (GNN) is message passing. Each node v holds a feature vector h_v — its current description. One round replaces it with a function of its neighbourhood:

h_v⁽ᵏ⁺¹⁾ = σ( W · mean{ h_u⁽ᵏ⁾ : u ∈ N(v) } ) aggregate: take the simplest summary of the neighbours — the mean transform: multiply by the shared, learned weight matrix W activate: σ (ReLU) keeps the result non-linear, like any layer stack every node's feature as a row of H and the same round is one product: H⁽ᵏ⁺¹⁾ = σ( A_norm · H⁽ᵏ⁾ · W ) A_norm = A with each row ÷ degree

Message passing, hop by hop

Each node averages its neighbours’ feature vectors (its own too, with self-loops), multiplies by a shared weight matrix W, and applies ReLU. Step the rounds and watch colour diffuse along the path.

node 0 aggregates: h1 = [0.000, 1.000] h0 = [1.000, 0.000] mean = [0.500, 0.500] W · mean = [0.750, 0.750] → ReLU → [0.750, 0.750] all features, round 1: node 0: [0.75, 0.75] node 1: [1.00, 1.00] node 2: [0.83, 1.17] node 3: [1.00, 1.00] node 4: [0.75, 0.75]

After 1 round, node 0 holds information from nodes {0, 1} — its 1-hop neighbourhood. A K-layer GNN has a K-hop receptive field; too many rounds blend every colour into the same average.

Worked round on the triangle (plus the second round)

The source example: three fully connected nodes A, B, C with features h_A = [1, 0], h_B = [0, 1], h_C = [1, 1]. Every node has degree 2, so A_norm is the adjacency matrix with each row divided by 2.

A_norm = [[0, ½, ½], H = [[1, 0], [½, 0, ½], [0, 1], [½, ½, 0]] [1, 1]] round 1: A_norm @ H = [[0.5, 1.0], ← A averaged h_B and h_C [1.0, 0.5], ← B averaged h_A and h_C [0.5, 0.5]] ← C averaged h_A and h_B with W = [[1, 0.5], [0.5, 1]] and ReLU: h' A = [0.5, 1.0] @ W = [0.5·1 + 1.0·0.5, 0.5·0.5 + 1.0·1] = [1.0, 1.25] (same idea for B and C; C lands on [0.75, 0.75]) round 2 with W = identity, to keep the arithmetic visible: A_norm @ [[0.5, 1.0], [1.0, 0.5], [0.5, 0.5]] = [[0.75, 0.5], ← A now mixes in information from B's neighbours [0.5, 0.75], ← i.e. from its 2-hop neighbourhood [0.75, 0.75]] after one round a node knows its 1-hop neighbours; after K rounds, K hops.

Self-loops and symmetric normalization. The classic GCN adds the node’s own feature by setting  = A + I, then divides each entry by √(dᵢdⱼ) instead of the row sum: A_norm = D̂^(−1/2)  D̂^(−1/2). On the triangle every degree becomes 3, so the operator is a plain average over the closed neighbourhood — a node keeps a third of itself:

closed neighbourhood of A = {A, B, C}, so (after W = identity and ReLU) h' A = ([1, 0] + [0, 1] + [1, 1]) / 3 = [2/3, 2/3] ✓ symmetric normalization keeps the operator's eigenvalues in [−1, 1], so stacked layers cannot blow up; it is one matrix away from the normalized Laplacian: L_sym = I − D^(−1/2) A D^(−1/2).

Why not just sum the neighbours? A hub with 1,000 neighbours would hand back a vector 1,000× larger than a leaf, and after a few layers the largest-degree node would dominate every number. Dividing by degree — and symmetrically by √(dᵢdⱼ) — makes every node’s update comparable, the same trick normalization layers use elsewhere.

One GNN layer — NumPypython
import numpy as np

def message_pass(A, H, W, self_loops=True):
    """One GNN layer: aggregate neighbours, transform, activate."""
    if self_loops:
        A = A + np.eye(A.shape[0])              # GCN-style A_hat = A + I
        d = A.sum(axis=1)
        A_norm = A / np.sqrt(np.outer(d, d))    # D^-1/2 A D^-1/2
    else:
        row_sums = A.sum(axis=1, keepdims=True)
        A_norm = A / np.maximum(row_sums, 1)    # plain mean over neighbours
    return np.maximum(0, A_norm @ H @ W)        # ReLU(A_norm @ H @ W)


A = np.array([[0, 1, 1], [1, 0, 1], [1, 1, 0]], float)
H = np.array([[1, 0], [0, 1], [1, 1]], float)  # one feature vector per node
W = np.array([[1, 0.5], [0.5, 1]], float)      # learned: trains like any layer

for round_number in range(1, 4):
    H = message_pass(A, H, W)
    print(f"round {round_number}:")
    print(np.round(H, 3))                       # each round: one more hop
Aggregate, transform, activate — the whole layer in four lines; only W is learned.
Quick check

You run two rounds of mean-aggregation message passing on a connected graph. Whose original features can influence a given node now?

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The Laplacian and message-passing questions are the ones that turn “I’ve seen a graph” into “I can use one”.

0 / 5 answered · 0 correct

01What does an entry A[i][j] = 1 in the adjacency matrix represent?

02What data structure does BFS use, and what does it find?

03The Laplacian L = D − A of a connected graph has how many zero eigenvalues?

04In GNN message passing, h_v^(k+1) = σ(W · mean{h_u^(k) : u ∈ neighbours(v)}) computes…

05How does spectral clustering use the Fiedler vector (the eigenvector of L's second-smallest eigenvalue)?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Five problems, all fully worked with numbers you can check by hand. Try first; the answer is one click away.

  1. PageRank from scratch: score(v) = (1−d)/n + d·Σ score(u)/out_degree(u) over links u → v. Start uniform on the 4-page web 0 → 1,2 · 1 → 2,3 · 2 → 0 · 3 → 0,2 with d = 0.85 and run until the change is below 10⁻⁶. Verify the scores sum to 1 at every round.
    Show one worked answer

    With n = 4 the restart term is 0.0375 per round. Round 1: π(0) = 0.0375 + 0.85·(0.25 + 0.125) = 0.35625; π(1) = 0.0375 + 0.85·0.125 = 0.14375; π(2) = 0.0375 + 0.85·0.375 = 0.35625; π(3) = 0.14375. Sum = 1 ✓. Round 2: π(0) = 0.0375 + 0.85·(0.35625 + 0.071875) = 0.40141; π(1) = 0.0375 + 0.85·0.178125 = 0.18891; π(2) = 0.0375 + 0.85·0.321875 = 0.31109; π(3) = 0.0375 + 0.85·0.071875 = 0.09859. Continuing to convergence gives (0.3640, 0.1922, 0.3246, 0.1192). Node 0 finishes above node 2 even though node 2 has more in-links: node 2 is the hub, and the hub links to node 0 — rank is recursive. At every round Σπ = 1 because the restart term injects exactly (1−d) and the d part only moves rank along edges.

  2. Two triangles on nodes 0–2 and 3–5 are joined by the single bridge 2–3. Write L, compute its eigenvalues and Fiedler vector, and confirm the sign split cuts exactly the bridge. What happens to the Fiedler value as you add more bridges?
    Show one worked answer

    L is block diagonal except the 2–3 entries: degrees are (2,2,3,3,2,2), so the diagonal is 2,2,3,3,2,2 and off-diagonal entries are −1 per edge. Eigenvalues: 0, (5−√17)/2 ≈ 0.438, 3, 3, 3, (5+√17)/2 ≈ 4.562. Check the trace: the eigenvalues sum to 14 = 2 + 2 + 3 + 3 + 2 + 2 (sum of degrees = 2 × 7 edges) ✓. The Fiedler vector is proportional to (0.465, 0.465, 0.261, −0.261, −0.465, −0.465): positive on triangle A, negative on triangle B, smallest magnitude at the two bridge endpoints. Splitting by sign cuts only edge 2–3 — the minimum cut. Adding bridges raises λ₂ (≈ 1 for two bridges, 2 for three, 2.268 for four in the lab), because the two communities are now genuinely better connected and harder to separate. Bonus check: the product of the nonzero eigenvalues divided by n equals the number of spanning trees: (0.438·3·3·3·4.562)/6 = 54/6 = 9 = 3 trees per triangle × the one mandatory bridge ✓.

  3. Implement Dijkstra on the weighted graph 0–1 (4), 0–2 (1), 1–2 (2), 1–3 (5), 2–3 (8), 2–4 (10), 3–4 (2), 3–5 (6), 4–5 (3) from node 0. Compare the result with BFS on the same graph when every weight is set to 1.
    Show one worked answer

    Trace: settle 0 (0), relax 1←4, 2←1; settle 2 (1), relax 1←1+2=3, 3←9, 4←11; settle 1 (3), relax 3←3+5=8; settle 3 (8), relax 4←8+2=10, 5←14; settle 4 (10), relax 5←10+3=13; settle 5 (13). Final distances [0, 3, 1, 8, 10, 13] with the route to 5 being 0 → 2 → 1 → 3 → 4 → 5 (1+2+5+2+3 = 13 ✓). With all weights 1, Dijkstra's priority queue settles in nondecreasing hop order and the distances become the BFS hop counts [0, 1, 1, 2, 2, 3] — the two algorithms agree exactly, so BFS is the special case of Dijkstra where every edge costs the same. Note the disagreement on weights: nodes 1 and 2 are both one hop away, but node 2 costs 1 and node 1 costs 3.

  4. Build a 2-layer message-passing network on the triangle with h_A = [1,0], h_B = [0,1], h_C = [1,1]. Use W₁ = I for round 1 and W₂ = [[1, 0.5], [0.5, 1]] for round 2 (ReLU after each). Show that after 2 rounds every node's vector contains information from across the whole triangle.
    Show one worked answer

    Round 1: A_norm = A/2 (each node has degree 2), so A_norm @ H = [[0.5, 1], [1, 0.5], [0.5, 0.5]]; multiplying by W₁ = I and ReLU leaves it unchanged. Round 2: A_norm @ H₁ = [[0.75, 0.5], [0.5, 0.75], [0.75, 0.75]] — node A averaged B's and C's round-1 vectors, which already contained A's original feature, so all three original features are present. Then W₂: h_A = [0.75 + 0.25, 0.375 + 0.5] = [1.0, 0.875], h_B = [0.875, 1.0], h_C = [1.125, 1.125] (ReLU changes nothing). Check the two-hop claim directly: on this graph every node is within 2 hops of every other (the diameter is 1), so the second round places the whole graph's information in each node — the general statement is that K rounds collect the K-hop neighbourhood.

  5. Compute the Laplacian of the 4-node path 0–1–2–3 by hand. Find its eigenvalues and Fiedler vector exactly, verify Lv = λ₂v for the first two rows, and check the spanning-tree identity on the product of nonzero eigenvalues.
    Show one worked answer

    L = [[1, −1, 0, 0], [−1, 2, −1, 0], [0, −1, 2, −1], [0, 0, −1, 1]]. Exact eigenvalues: 0, 2 − √2 ≈ 0.5858, 2, and 2 + √2 ≈ 3.4142. Trace check: their sum is 6 = 1 + 2 + 2 + 1 ✓. Fiedler vector (unnormalised, sign fixed by the first entry): v = (1, √2 − 1, −(√2 − 1), −1) ≈ (1, 0.414, −0.414, −1). First row of Lv: 1·1 − (√2 − 1) = 2 − √2 = λ₂·1 ✓. Second row: −1 + 2(√2 − 1) − (−(√2 − 1)) = 3√2 − 4 ≈ 0.243, and λ₂·(√2 − 1) = (2 − √2)(√2 − 1) = 3√2 − 4 ✓. The sign split cuts the middle edge 1–2: positive {0, 1}, negative {2, 3} — the only cut of a path that splits it into two pieces of equal size. Spanning trees: the product of nonzero eigenvalues divided by n is (2 − √2)·2·(2 + √2)/4 = (2 · (4 − 2))/4 = 1, and a path has exactly one spanning tree ✓.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • featureOne input column: a single measured property of each example (age, pixel value, word count). In a graph, every node carries its own feature vector. (Lesson 01)
  • eigenvector & eigenvalueA direction a matrix only stretches, and the stretch factor λ with Av = λv. The Laplacian's eigenvectors are the coordinates spectral clustering uses. (Lesson 03)
  • ReLUThe non-linearity σ(z) = max(0, z) applied after a layer's matrix multiply; it keeps stacked layers from collapsing into one. Message passing ends with it too. (Lesson 02)
  • gradient descentThe training loop that nudges every weight downhill on the loss surface. It is what learns the GNN's matrix W. (Lesson 08)
  • stationary distributionThe long-run distribution of a Markov chain, unchanged by one more step. PageRank is the stationary distribution of the web random walk. (Lesson 22)
  • k-meansAn iterative clustering algorithm that assigns points to the nearest of k centres, then moves each centre to the mean of its points. Spectral clustering runs it on eigenvector coordinates. (Outside these lessons)
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 01, Lesson 21) and the Math Foundations Notebook reference build. The graph builder, BFS/DFS stepper, Dijkstra stepper, PageRank lab, Laplacian/Fiedler lab and message-passing lab are original to this page, as are the exact (5 ± √17)/2 dumbbell eigenvalue, the 4-node-path worked eigendecomposition, the second worked message-passing round, the Matrix-Tree spanning-tree checks and the fully worked exercise answers. Every displayed number is computed in your browser from the values shown. All labs run in your browser.