Supervised Learning Lab
Supervised learning imitates labeled examples. With graphs, attention and pointers, it can classify nodes, trace a convex hull, or score the roads of a traveling salesman’s tour.
Based on Chapter 11, “Supervised and unsupervised learning,” Sections 11.2, 11.3, 11.6, 11.7 and 11.9, in Alaa Khamis, Optimization Algorithms: AI techniques for design, planning, and control problems, Manning Publications, 2024 (ISBN 978-1633438835).
A graph convolutional network labeling the members of a karate club from 4 known labels.
Supervised learning
Supervised learning maps data to known labels: classification predicts classes, regression continuous values.
Section 11.2 of the book frames one predictive-maintenance dataset as classification, regression and forecasting, and introduces geometric deep learning.
Training a neural network
Training is optimization: backpropagation computes the gradient of the loss and Adam moves the weights downhill. Press Train and watch the decision boundary and loss curve.
Neural network playground
Machine learning with graphs
Graphs capture relationships, and tabular data becomes a graph once you choose the nodes and edges. The panel shows Listing 11.1: soccer players linked to their clubs and nationalities.
Section 11.3 of the book explains why graph data is non-Euclidean and compares graph-embedding methods.
Listing 11.1 as a graph
Message passing and graph convolution
In a graph convolutional network, each layer lets every node aggregate messages from its neighbors, H(t+1) = σ(Â H(t) W(t)). Click a node to put a signal there, then apply layers to watch it spread hop by hop.
Figures 11.6 and 11.7 in the book build the normalized matrix  step by step.
Signal spreading through Â
Node classification with a GCN
A two-layer GCN learns which side of a split karate club each member joined from just 4 labeled members. Press Train.
Listing 11.2 in the book applies the same recipe to the Cora citation network, with the full training setup.
Semi-supervised node classification
Squares: the 4 labeled membersAttention
Attention lets a model decide how much each input matters: a graph attention network scores each neighbor and normalizes the scores with a softmax. Switch between GAT and GCN or change the heads; below, click a word to see what it attends to.
Section 11.3.2 of the book derives equations 11.1 and 11.2, explains multi-head attention (Figure 11.10), and links GAT to the Transformer.
Node 5 attends to nodes 2, 4 and itself (Figure 11.9)
Which words does “bank” attend to?
Illustrative 2D word vectors, not a trained modelPointer networks and the convex hull
When the outputs are positions in the input, a pointer network uses attention as a pointer, picking one input element per step. Click Next pointer to build the convex hull of the points one vertex at a time.
Sections 11.3.3 and 11.9 of the book give equations 11.3 to 11.5 and train a ConvexNet (Listing 11.6).
Decoding a hull, one pointer at a time
Pointer scores here come from geometry, not a trained networkSolving the TSP with supervised graph ML
A graph network scores each edge’s chance of being in the optimal tour; a search step decodes a tour. Here the probabilities are a simple stand-in; set the beam width (1 is greedy) and press Decode.
Section 11.7 of the book walks through the five-step pipeline of Figure 11.16, and Listing 11.4 runs a pretrained model on TSP20 to TSP50.
Edge probabilities to a tour
Edge darkness: probabilityAmortized optimization: learning to optimize
A model can learn to map a problem’s parameters straight to its solution, so the training cost is amortized over many problems. Train on 64 random problems on the circle, then test on unseen ones.
Listing 11.3 in the book does this on the sphere with c-convex functions, using the objective itself as the training loss.
Predicting minimizers on the circle
Python in Google Colab
Open the book’s notebooks or the companion notebook with the from-scratch code below in Colab.
Notebooks
From-scratch implementations
# A multilayer perceptron trained by backpropagation and gradient descent (supervised classification) import numpy as np rng = np.random.default_rng(0) t = rng.uniform(0, np.pi, 200) X = np.vstack([np.c_[np.cos(t), np.sin(t)], np.c_[1 - np.cos(t), 0.5 - np.sin(t)]]) + 0.1 * rng.standard_normal((400, 2)) y = np.repeat([0, 1], 200) idx = rng.permutation(400); train, test = idx[:300], idx[300:] H = 16 W1, b1 = rng.normal(0, 1, (2, H)), np.zeros(H) W2, b2 = rng.normal(0, 1 / np.sqrt(H), (H, 1)), np.zeros(1) sigmoid = lambda z: 1 / (1 + np.exp(-z)) lr = 0.5 def forward(X): h = np.tanh(X @ W1 + b1) return h, sigmoid(h @ W2 + b2).ravel() for epoch in range(2001): h, p = forward(X[train]) loss = -np.mean(y[train] * np.log(p + 1e-12) + (1 - y[train]) * np.log(1 - p + 1e-12)) # cross-entropy dz = (p - y[train])[:, None] / len(train) # gradient of the loss at the output dW2, db2 = h.T @ dz, dz.sum(0) dh = dz @ W2.T * (1 - h ** 2) # backpropagate through tanh dW1, db1 = X[train].T @ dh, dh.sum(0) W1 -= lr * dW1; b1 -= lr * db1; W2 -= lr * dW2; b2 -= lr * db2 if epoch % 500 == 0: acc = lambda s: np.mean((forward(X[s])[1] > 0.5) == y[s]) print(f"epoch {epoch:4d}: loss {loss:.3f}, train accuracy {acc(train):.1%}, test accuracy {acc(test):.1%}") relu = lambda z: np.maximum(0, z) leaky = lambda z, a=0.2: np.where(z > 0, z, a * z) print("ReLU(-2), ReLU(3):", relu(-2.0), relu(3.0), " LeakyReLU(-2), LeakyReLU(3):", leaky(-2.0), leaky(3.0))
# Graph data and a graph convolutional network from scratch (Section 11.3, Listings 11.1 and 11.2) import numpy as np # 1. Tabular data to a graph (Listing 11.1): players linked to their clubs and nationalities players = {"L. Messi": ("Inter Miami", "Argentina"), "R. Lewandowski": ("Barcelona", "Poland"), "C. Ronaldo": ("Al-Nassr", "Portugal"), "Neymar Jr": ("Al-Hilal", "Brazil"), "K. Mbappé": ("PSG", "France"), "E. Haaland": ("Manchester City", "Norway"), "H. Kane": ("Tottenham Hotspur", "England"), "Luka Modrić": ("Real Madrid", "Croatia"), "L. Goretzka": ("Bayern Munich", "Germany"), "M. Salah": ("Liverpool", "Egypt")} edges = [(p, c, "plays_for") for p, (c, _) in players.items()] + [(p, n, "belongs_to") for p, (_, n) in players.items()] print(f"{len(players)} players -> graph with {len({e[1] for e in edges}) + len(players)} nodes and {len(edges)} edges") # 2. A two-layer GCN on Zachary's karate club (34 members, 78 friendships), trained with only 4 labeled nodes E = [(0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 10), (0, 11), (0, 12), (0, 13), (0, 17), (0, 19), (0, 21), (0, 31), (1, 2), (1, 3), (1, 7), (1, 13), (1, 17), (1, 19), (1, 21), (1, 30), (2, 3), (2, 7), (2, 8), (2, 9), (2, 13), (2, 27), (2, 28), (2, 32), (3, 7), (3, 12), (3, 13), (4, 6), (4, 10), (5, 6), (5, 10), (5, 16), (6, 16), (8, 30), (8, 32), (8, 33), (9, 33), (13, 33), (14, 32), (14, 33), (15, 32), (15, 33), (18, 32), (18, 33), (19, 33), (20, 32), (20, 33), (22, 32), (22, 33), (23, 25), (23, 27), (23, 29), (23, 32), (23, 33), (24, 25), (24, 27), (24, 31), (25, 31), (26, 29), (26, 33), (27, 33), (28, 31), (28, 33), (29, 32), (29, 33), (30, 32), (30, 33), (31, 32), (31, 33), (32, 33)] club = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) n = 34 A = np.zeros((n, n)) for u, v in E: A[u, v] = A[v, u] = 1 A_hat = A + np.eye(n) # add self-loops: a node keeps its own features d = A_hat.sum(1) A_norm = A_hat / np.sqrt(np.outer(d, d)) # D^-1/2 (A + I) D^-1/2 X = np.eye(n) # no features: one-hot identity per node rng = np.random.default_rng(0) W1 = rng.normal(0, 0.5, (n, 16)); W2 = rng.normal(0, 0.5, (16, 2)) labeled = [0, 33, 1, 32] # the instructor, the administrator and one friend of each lr = 0.2 for epoch in range(301): Z1 = A_norm @ X @ W1; H1 = np.maximum(0, Z1) # layer 1: aggregate neighbors, transform, ReLU Z2 = A_norm @ H1 @ W2 # layer 2: aggregate again (2-hop information) P = np.exp(Z2 - Z2.max(1, keepdims=True)); P /= P.sum(1, keepdims=True) G = np.zeros_like(P); G[labeled] = P[labeled]; G[labeled, club[labeled]] -= 1; G /= len(labeled) # cross-entropy gradient dW2 = (A_norm @ H1).T @ G dH1 = A_norm.T @ G @ W2.T * (Z1 > 0) dW1 = (A_norm @ X).T @ dH1 W1 -= lr * dW1; W2 -= lr * dW2 if epoch % 100 == 0: acc = np.mean(P.argmax(1) == club) print(f"epoch {epoch}: accuracy on all 34 members {acc:.0%} (trained on {len(labeled)} labels)")
# GCN vs. graph attention for node 5 of the 5-node graph in Figure 11.9 (equations 11.1 and 11.2) import numpy as np rng = np.random.default_rng(3) A = np.array([[0, 1, 1, 0, 0], [1, 0, 0, 1, 1], [1, 0, 0, 1, 0], [0, 1, 1, 0, 1], [0, 1, 0, 1, 0]]) # Figure 11.9 X = rng.normal(size=(5, 4)) # 4 features per node W = rng.normal(size=(4, 8)) / 2 # learnable weight matrix, 4 x 8 v = 4 # node 5 (index 4) nbrs = [u for u in range(5) if A[v, u]] + [v] # its neighbors 2 and 4, plus itself Hm = X @ W # transformed features gcn = Hm[nbrs].mean(0) # GCN: every neighbor counts equally print("neighbors of node 5:", [u + 1 for u in nbrs]) def softmax(z): e = np.exp(z - z.max()); return e / e.sum() heads = [] for h in range(3): # multi-head attention, H = 3 (Figure 11.10) a = rng.normal(size=16) # attention vector for this head e = np.array([np.maximum(0.2 * s, s) for s in (np.concatenate([Hm[v], Hm[u]]) @ a for u in nbrs)]) # LeakyReLU scores e_vu alpha = softmax(e) # equation 11.2: normalized attention coefficients heads.append(alpha @ Hm[nbrs]) print(f"head {h + 1}: alpha_52 = {alpha[0]:.2f}, alpha_54 = {alpha[1]:.2f}, alpha_55 = {alpha[2]:.2f}") gat = np.mean(heads, 0) # average the heads print("GCN embedding of node 5:", np.round(gcn, 2)) print("GAT embedding of node 5:", np.round(gat, 2)) # Dot-product attention over a sequence (Q, K, V) q, K = rng.normal(size=4), rng.normal(size=(6, 4)) w = softmax(K @ q / np.sqrt(4)) print("attention weights over 6 tokens:", np.round(w, 2), "sum =", round(w.sum(), 6))
# The convex hull as a pointer sequence (Section 11.9, Figures 11.11 and 11.19) import math import random rng = random.Random(4) P = [(rng.random(), rng.random()) for _ in range(12)] def cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) def jarvis(P): """Gift wrapping: start at the leftmost point and keep turning to the most clockwise point.""" start = min(range(len(P)), key=lambda i: P[i]) hull, cur = [], start while True: hull.append(cur) nxt = (cur + 1) % len(P) for j in range(len(P)): if cross(P[cur], P[nxt], P[j]) < 0: nxt = j cur = nxt if cur == start: return hull def graham(P): """Graham scan: sort by angle around the lowest point, then keep only left turns.""" o = min(range(len(P)), key=lambda i: (P[i][1], P[i][0])) rest = sorted((i for i in range(len(P)) if i != o), key=lambda i: math.atan2(P[i][1] - P[o][1], P[i][0] - P[o][0])) st = [o] for i in rest: while len(st) > 1 and cross(P[st[-2]], P[st[-1]], P[i]) <= 0: st.pop() st.append(i) return st def area(idx): pts = [P[i] for i in idx] return abs(sum(pts[i][0] * pts[i - 1][1] - pts[i - 1][0] * pts[i][1] for i in range(len(pts)))) / 2 h1, h2 = jarvis(P), graham(P) print("Jarvis march hull:", [i + 1 for i in h1]) print("Graham scan hull: ", [i + 1 for i in h2], f"(the same {len(h2)} points, area {area(h2):.3f})") # A pointer network outputs this sequence one index at a time: at step i, a softmax over the inputs # (equations 11.3 to 11.5) "points" to the next hull vertex, and the sequence ends with an <eos> token. print("pointer targets:", " -> ".join(str(i + 1) for i in h1), "-> <eos>") print("book's test results (accuracy, hull overlap) by points per sample: 5: 54.8%, 43.7% | 20: 84.8%, 92.7% | 45: 72.4%, 90.4%")
# The end-to-end pipeline of Figure 11.16 on a small TSP: edge probabilities, then greedy or beam-search decoding import math import random from itertools import combinations rng = random.Random(2) n = 10 P = [(rng.random(), rng.random()) for _ in range(n)] D = [[math.dist(a, b) for b in P] for a in P] tour_len = lambda t: sum(D[t[i]][t[(i + 1) % n]] for i in range(n)) # Stand-in for the trained GNN's "predict" step: edge scores from distances (a k-nearest-neighbor softmax). # A real model learns these probabilities from Concorde's optimal tours. prob = [[0.0] * n for _ in range(n)] for i in range(n): near = sorted((j for j in range(n) if j != i), key=lambda j: D[i][j])[:4] z = [math.exp(-D[i][j] / 0.1) for j in near] for j, w in zip(near, z): prob[i][j] = w / sum(z) def beam_search(width): beams = [([0], 0.0)] for _ in range(n - 1): cand = [(t + [j], s + math.log(prob[t[-1]][j] + 1e-9)) for t, s in beams for j in range(n) if j not in t] beams = sorted(cand, key=lambda c: -c[1])[:width] return min((t for t, _ in beams), key=tour_len) # pick the shortest of the surviving tours def held_karp(): """Exact dynamic programming, O(2^n n^2): the ground truth (Concorde's role in the book).""" best = {(1 << j, j): (D[0][j], [0, j]) for j in range(1, n)} for size in range(2, n): for sub in combinations(range(1, n), size): mask = sum(1 << j for j in sub) for j in sub: prev = mask ^ (1 << j) best[(mask, j)] = min(((best[(prev, k)][0] + D[k][j], best[(prev, k)][1] + [j]) for k in sub if k != j), key=lambda x: x[0]) full = sum(1 << j for j in range(1, n)) return min((best[(full, j)][0] + D[j][0], best[(full, j)][1]) for j in range(1, n)) opt, _ = held_karp() for w in (1, 3, 10): print(f"beam width {w:2d} ({'greedy' if w == 1 else 'beam search'}): {tour_len(beam_search(w)):.4f}") print(f"optimal (Held-Karp): {opt:.4f}")
# Amortized optimization (learning to optimize), a one-dimensional version of Listing 11.3 on the unit circle import numpy as np rng = np.random.default_rng(0) K = 4 # components per function, as in c_convex(n_components=4) def make_problem(rng): ys = rng.uniform(-np.pi, np.pi, K) # component centers on the circle alphas = 0.7 * rng.random(K) # offsets in [0, 0.7) return np.concatenate([np.cos(ys), np.sin(ys), alphas]) # the parameter vector def f(theta, p, gamma=0.5): """Soft-min of 0.5 d(theta, y_k)^2 + alpha_k, with d the arc (geodesic) distance.""" ys = np.arctan2(p[K:2 * K], p[:K]); al = p[2 * K:] d = np.abs((theta - ys + np.pi) % (2 * np.pi) - np.pi) c = 0.5 * d ** 2 + al return -gamma * np.log(np.exp(-c / gamma).sum()) train = [make_problem(rng) for _ in range(64)] test = [make_problem(rng) for _ in range(16)] grid = np.linspace(-np.pi, np.pi, 2000) solve = lambda p: grid[np.argmin([f(t, p) for t in grid])] # the slow way: search every time # model: parameters -> point on the circle, a small MLP whose 2 outputs are normalized like the sphere model H = 64 W1 = rng.normal(0, 0.3, (3 * K, H)); W2 = rng.normal(0, 0.3, (H, 2)); lr = 0.01 for it in range(3000): p = train[rng.integers(len(train))] h = np.tanh(p @ W1); z = h @ W2; theta = np.arctan2(z[1], z[0]) eps = 1e-4; g_theta = (f(theta + eps, p) - f(theta - eps, p)) / (2 * eps) # loss = f(model(p)), as in the book r2 = z @ z; dz = g_theta * np.array([-z[1], z[0]]) / r2 # d theta / d z dh = (W2 @ dz) * (1 - h ** 2) W2 -= lr * np.outer(h, dz); W1 -= lr * np.outer(p, dh) pred = lambda p: (lambda z: np.arctan2(z[1], z[0]))(np.tanh(p @ W1) @ W2) gap = lambda ps, guess: np.mean([f(guess(p), p) - f(solve(p), p) for p in ps]) print(f"mean gap f(prediction) - f(optimum), 0 is perfect:") print(f" trained problems {gap(train[:16], pred):.3f}, unseen problems {gap(test, pred):.3f}, random guess {gap(test, lambda p: rng.uniform(-np.pi, np.pi)):.3f}")
What to remember
Training is optimization
Backpropagation plus a gradient method fits the weights.
Graphs
GCNs pass messages; k layers see k hops.
Attention and pointers
Pointer networks use attention to output input positions.
Learned optimizers
Training time buys fast solutions to new instances.
References: T. Kipf and M. Welling, “Semi-supervised classification with graph convolutional networks,” 2017. P. Veličković et al., “Graph attention networks,” 2018. A. Vaswani et al., “Attention is all you need,” 2017. O. Vinyals, M. Fortunato and N. Jaitly, “Pointer networks,” 2015. C. K. Joshi, T. Laurent and X. Bresson, “An efficient graph convolutional network technique for the travelling salesman problem,” 2019. B. Amos, “Tutorial on amortized optimization,” 2023. W. Zachary, “An information flow model for conflict and fission in small groups,” 1977. A. Khamis, Optimization Algorithms: AI techniques for design, planning, and control problems, Manning, 2024, Chapter 11.
Check your understanding
Two quick questions on Chapter 11. Pick an answer to check it.
Q1. After k layers of a graph convolutional network, what information does a node’s embedding encode?
Each layer aggregates messages from direct neighbors, so information spreads one hop per layer: after k layers a node has heard from everything within k hops.Q2. Why is a pointer network, rather than a standard sequence-to-sequence model, a natural fit for the convex hull problem?
A standard seq2seq model has a fixed output vocabulary; a pointer network uses attention over the inputs to pick one of them at each step, so it handles any number of points.For more exercises with full solutions, see Appendix C, “Exercises and solutions,” in Optimization Algorithms. The coding solutions are also on GitHub (Chapter 11).