Step 2 — Wire a Network by Hand, Then Train One¶

Solution notebook. Two constructions. Part A builds a one-hidden-layer ReLU network, unit by unit and then as two matrix layers, that classifies points of the plane as inside or outside the diamond $|x_1|+|x_2|=1$ — exactly, with weights written down on paper rather than learned. Part B then rebuilds Step 1's bigram model as a differentiable parametric family and trains it by gradient descent, with a gradient derived by hand — no autograd yet. Both parts run in under a minute.

Part A is Lecture 2, Example 4.6 and Theorem 7.1; Part B is Lecture 2's multinomial logistic regression (Proposition 3.4) applied to one-hot character features. The derivations below are the "On paper first" sections of the task, written out.

Part A — Wire a Diamond Classifier by Hand¶

On paper first¶

1. Four units whose activations sum to $r=|x_1|+|x_2|$. For any real $t$, exactly one of $t$ and $-t$ is positive (or both are zero), so $\operatorname{ReLU}(t)+\operatorname{ReLU}(-t)=\max(t,0)+\max(-t,0)=|t|$. Apply it to each coordinate: the four hidden units

$$ h=\bigl(\operatorname{ReLU}(x_1),\ \operatorname{ReLU}(-x_1),\ \operatorname{ReLU}(x_2),\ \operatorname{ReLU}(-x_2)\bigr)^\top $$

have $h_1+h_2=|x_1|$ and $h_3+h_4=|x_2|$, so $\mathbf 1^\top h=r$.

2. As a matrix layer. Each unit is $\operatorname{ReLU}$ of an affine function of $\mathbf x$, so $a_1=W_1\mathbf x+b_1$, $h=\operatorname{ReLU}(a_1)$ with

$$ W_1=\begin{pmatrix}1&0\\-1&0\\0&1\\0&-1\end{pmatrix}\in\mathbb R^{4\times2}, \qquad b_1=\mathbf 0\in\mathbb R^4, \qquad a_1,h\in\mathbb R^4 . $$

3. The output layer. We want $z_{\mathrm{out}}=\gamma(r-1)$ and $z_{\mathrm{in}}=\gamma(1-r)$. Since $r=\mathbf 1^\top h$,

$$ W_2=\gamma\begin{pmatrix}1&1&1&1\\-1&-1&-1&-1\end{pmatrix}\in\mathbb R^{2\times4}, \qquad b_2=\gamma\begin{pmatrix}-1\\1\end{pmatrix}\in\mathbb R^2, \qquad z=W_2h+b_2 . $$

4. The probability and the boundary. For two classes, softmax reduces to a sigmoid of the logit difference (Lecture 2, Aside 3.1a): $p(\mathrm{in}\mid\mathbf x)=\sigma(z_{\mathrm{in}}-z_{\mathrm{out}}) =\sigma\bigl(2\gamma(1-|x_1|-|x_2|)\bigr)$. Since $\sigma$ is increasing with $\sigma(0)=\tfrac12$, the classifier says inside exactly when $|x_1|+|x_2|<1$: the boundary is the diamond with vertices $(\pm1,0)$, $(0,\pm1)$. The sign of $1-r$ does not depend on $\gamma$, so $\gamma$ rescales the logits — how confident the network is — without moving the boundary at all.

2.1 Write the hidden units separately¶

Five test points, one per row. Each column of h_units is one hidden unit evaluated on all five points at once.

In [1]:
import torch

X = torch.tensor([
    [ 0.0,  0.0],   # inside
    [ 0.5,  0.25],  # inside
    [ 1.0,  0.0],   # boundary
    [ 0.8,  0.5],   # outside
    [-0.4, -0.2],   # inside, negative coordinates
])
print(X.shape)      # (5, 2): five points, two features per point

h_units = torch.stack([
    torch.relu( X[:, 0]),
    torch.relu(-X[:, 0]),
    torch.relu( X[:, 1]),
    torch.relu(-X[:, 1]),
], dim=1)

print(h_units)
print(h_units.shape)                 # (5, 4)
r_units = h_units.sum(dim=1)
print(r_units)                       # |x1| + |x2| for each point
torch.Size([5, 2])
tensor([[0.0000, -0.0000, 0.0000, -0.0000],
        [0.5000, 0.0000, 0.2500, 0.0000],
        [1.0000, 0.0000, 0.0000, -0.0000],
        [0.8000, 0.0000, 0.5000, 0.0000],
        [0.0000, 0.4000, 0.0000, 0.2000]])
torch.Size([5, 4])
tensor([0.0000, 0.7500, 1.0000, 1.3000, 0.6000])

The four columns are active on the four half-planes $x_1>0$, $x_1<0$, $x_2>0$, $x_2<0$ respectively — each unit measures how far the point sits into "its" half-plane and is silent elsewhere. For $(-0.4,-0.2)$: unit 1 sees $-0.4$ and outputs $0$; unit 2 sees $+0.4$ and outputs $0.4$; unit 3 sees $-0.2$, outputs $0$; unit 4 outputs $0.2$. Row: $(0,\,0.4,\,0,\,0.2)$, sum $0.6=|{-0.4}|+|{-0.2}|$. The five rows are the checkpoint matrix.

2.2 Assemble the first layer as a matrix¶

The same four units as one weight matrix and one bias vector. The lecture writes one input as a column and computes $W_1\mathbf x$; code stores a batch with one sample per row, so the same product is X @ W1.T: $(5\times2)(2\times4)+(4)=(5\times4)$, the bias added to every row by broadcasting.

In [2]:
W1 = torch.tensor([
    [ 1.0,  0.0],
    [-1.0,  0.0],
    [ 0.0,  1.0],
    [ 0.0, -1.0],
])
b1 = torch.zeros(4)

a1 = X @ W1.T + b1
h = torch.relu(a1)

print(a1.shape, h.shape)             # both (5, 4)
assert torch.allclose(h, h_units)
print('matrix layer == four separate units ✓')
torch.Size([5, 4]) torch.Size([5, 4])
matrix layer == four separate units ✓

2.3 Wire the output layer and softmax¶

$(5\times4)(4\times2)+(2)=(5\times2)$: two logits per point, columns ordered outside, inside. Subtracting each row's largest logit before exponentiating changes nothing — softmax is invariant under adding a constant to every logit in a row, since the constant factors out of numerator and denominator alike — but it keeps exp from overflowing when $\gamma$ is large.

In [3]:
gamma = 4.0
W2 = gamma * torch.tensor([
    [ 1.0,  1.0,  1.0,  1.0],   # outside logit
    [-1.0, -1.0, -1.0, -1.0],   # inside logit
])
b2 = gamma * torch.tensor([-1.0, 1.0])

logits = h @ W2.T + b2            # (5, 2)
shifted = logits - logits.max(dim=1, keepdim=True).values
weights = shifted.exp()
probs = weights / weights.sum(dim=1, keepdim=True)

print(logits)
print(probs)
print(probs.sum(dim=1))
tensor([[-4.0000,  4.0000],
        [-1.0000,  1.0000],
        [ 0.0000,  0.0000],
        [ 1.2000, -1.2000],
        [-1.6000,  1.6000]])
tensor([[3.3535e-04, 9.9966e-01],
        [1.1920e-01, 8.8080e-01],
        [5.0000e-01, 5.0000e-01],
        [9.1683e-01, 8.3173e-02],
        [3.9166e-02, 9.6083e-01]])
tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000])

Inside probabilities at $\gamma=4$: 0.9997, 0.8808, 0.5000, 0.0832, 0.9608 — i.e. $\sigma(8)$, $\sigma(2)$, $\sigma(0)$, $\sigma(-2.4)$, $\sigma(3.2)$, since $2\gamma(1-r)=8(1-r)$ and $r=0,\,0.75,\,1,\,1.3,\,0.6$. On the boundary point $(1,0)$ the logits tie and both probabilities are exactly $\tfrac12$; argmax would return index 0 (outside) there, a tie-breaking convention of the software, not a property of the classifier. Every row sums to 1.

2.4 Package one forward pass¶

The two layers as a function that returns every intermediate value. Then the two assertions the task asks for: the hidden layer sums to $|x_1|+|x_2|$, and the inside probability is $\sigma(2\gamma(1-|x_1|-|x_2|))$ — Theorem 7.1, checked numerically.

In [4]:
def diamond_net(X, gamma=4.0):
    W1 = torch.tensor([
        [ 1.0,  0.0],
        [-1.0,  0.0],
        [ 0.0,  1.0],
        [ 0.0, -1.0],
    ], dtype=X.dtype)
    b1 = torch.zeros(4, dtype=X.dtype)

    W2 = gamma * torch.tensor([
        [ 1.0,  1.0,  1.0,  1.0],
        [-1.0, -1.0, -1.0, -1.0],
    ], dtype=X.dtype)
    b2 = gamma * torch.tensor([-1.0, 1.0], dtype=X.dtype)

    a1 = X @ W1.T + b1
    h = torch.relu(a1)
    logits = h @ W2.T + b2

    shifted = logits - logits.max(dim=1, keepdim=True).values
    weights = shifted.exp()
    probs = weights / weights.sum(dim=1, keepdim=True)
    return a1, h, logits, probs

a1, h, logits, probs = diamond_net(X)
assert a1.shape == (5, 4)
assert h.shape == (5, 4)
assert logits.shape == (5, 2)
assert probs.shape == (5, 2)

# the two assertions of our own
r = X.abs().sum(dim=1)                                   # |x1| + |x2|
assert torch.allclose(h.sum(dim=1), r)
assert torch.allclose(probs[:, 1], torch.sigmoid(2 * 4.0 * (1 - r)))
print('hidden sum = |x1|+|x2| ✓   p(in) = sigmoid(2γ(1-|x1|-|x2|)) ✓')
hidden sum = |x1|+|x2| ✓   p(in) = sigmoid(2γ(1-|x1|-|x2|)) ✓

2.5 Draw the decision surface¶

Evaluate the network on a $201\times201$ grid and plot the inside probability. meshgrid turns two coordinate axes into all coordinate pairs; flattening and stacking makes the usual one-point-per-row batch; reshape puts the answers back on the grid for the plotting routine.

In [5]:
import matplotlib.pyplot as plt

axis = torch.linspace(-1.6, 1.6, 201)
gx, gy = torch.meshgrid(axis, axis, indexing='xy')
grid = torch.stack([gx.reshape(-1), gy.reshape(-1)], dim=1)

_, _, _, grid_probs = diamond_net(grid, gamma=4.0)
p_inside = grid_probs[:, 1].reshape(gx.shape)

plt.figure(figsize=(6, 5))
contour_plot = plt.contourf(gx.numpy(), gy.numpy(), p_inside.numpy(),
                            levels=30, cmap='Purples')
plt.contour(gx.numpy(), gy.numpy(), p_inside.numpy(),
            levels=[0.5], colors='black', linewidths=2)
plt.scatter(X[:, 0], X[:, 1], c='red', s=30)
plt.xlabel('x1')
plt.ylabel('x2')
plt.axis('equal')
plt.colorbar(contour_plot, label='p(inside | x)')
plt.show()
No description has been provided for this image

The black $p=\tfrac12$ contour is the diamond with vertices $(\pm1,0)$, $(0,\pm1)$. A single logistic-regression unit can only draw one line in this plane; four hidden ReLUs measure distances from the two axes, and their sum bends one line into four.

Part B — From Counts to Parameters¶

Rebuild Step 1's bigram model as a differentiable parametric family and train it by gradient descent — with a gradient derived by hand, no autograd. The pivot of the course: from estimating a table to optimizing a function.

Setup: data and tokenizer (Step 1's solution, reproduced)¶

Every notebook in this series is self-contained: it re-creates what it needs from earlier steps in one compact cell, so you can run it top to bottom without opening the others. On Colab, uncomment the download.

In [6]:
# !wget -q https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
import torch

with open('input.txt') as f:
    text = f.read()
vocab = sorted(set(text))
V = len(vocab)
stoi = {ch: i for i, ch in enumerate(vocab)}
itos = {i: ch for i, ch in enumerate(vocab)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: ''.join(itos[i] for i in ids)
ids = torch.tensor(encode(text), dtype=torch.long)

assert len(text) == 1_115_394 and V == 65
print(f'{len(text):,} characters, vocab {V}')
1,115,394 characters, vocab 65

2.6 Data as tensors¶

Every adjacent pair, as parallel input/target vectors.

In [7]:
xs, ys = ids[:-1], ids[1:]
n = len(xs)
ar = torch.arange(n)
print(f'{n:,} training pairs')
1,115,393 training pairs

2.7 Forward pass¶

Row $a$ of $W$ is the logit vector for input character $a$, so W[xs] gathers all $n$ logit rows at once. The max-subtraction is the shift-invariance of softmax (Lecture 2, Prop. 1.2) spent on numerical stability — prove in one line that it changes nothing, then try removing it with 10× larger initial weights and watch the infs.

In [8]:
def loss_and_p(W, X=None, Y=None):
    X, Y = (xs, ys) if X is None else (X, Y)
    idx = torch.arange(len(X))
    logits = W[X]
    logits = logits - logits.max(dim=1, keepdim=True).values
    p = logits.exp()
    p = p / p.sum(dim=1, keepdim=True)
    return -p[idx, Y].log().mean(), p

2.8 Backward pass — by hand¶

The derivation (do it on paper before reading the code): for one pair $(a,b)$, $\ell = -z_b + \log\sum_c e^{z_c}$, so $\partial\ell/\partial z_j = p_j - \mathbf 1_{j=b}$ — probabilities minus target. Averaged over the batch, each example's $p - y$ row is accumulated into row $x_t$ of $W$; that is exactly what index_add_ does.

In [9]:
def grad_fn(W):
    _, p = loss_and_p(W)
    dlogits = p.clone()
    dlogits[ar, ys] -= 1.0          # p - y
    dlogits /= n
    dW = torch.zeros_like(W)
    dW.index_add_(0, xs, dlogits)   # route each row back to its input char
    return dW

Validate before training — in float64¶

Central finite differences against the analytic gradient. The dtype is the whole game here. The loss is a mean over a million examples, so a single entry of $\nabla_W$ is $\sim 2\times10^{-5}$; the difference $\mathcal L(W+hE)-\mathcal L(W-hE)\approx 2hg \approx 4\times10^{-8}$ sits below float32's resolution $\varepsilon|\mathcal L| \approx 3\times10^{-7}$, and the check fails at relative error ≈ 5.9 on a perfectly correct gradient. In float64 the same check passes at $\sim 3\times10^{-7}$. If your finite-difference check ever fails, suspect the dtype before the mathematics. (We restrict to a 20k-pair subset so each loss evaluation is cheap.)

In [10]:
torch.manual_seed(1337)
sub = 20_000
Xs, Ys = xs[:sub], ys[:sub]
Wt = (torch.randn(V, V) * 0.1).double()

# analytic gradient on the subset
_, p = loss_and_p(Wt, Xs, Ys)
dl = p.clone(); dl[torch.arange(sub), Ys] -= 1.0; dl /= sub
g = torch.zeros_like(Wt); g.index_add_(0, Xs, dl)

h, errs = 1e-3, []
for (i, j) in [(5, 7), (20, 40), (33, 2), (60, 60), (12, 12)]:
    Wp = Wt.clone(); Wp[i, j] += h
    Wm = Wt.clone(); Wm[i, j] -= h
    num = (loss_and_p(Wp, Xs, Ys)[0] - loss_and_p(Wm, Xs, Ys)[0]) / (2 * h)
    errs.append(abs((num - g[i, j]) / g[i, j]).item())
print(f'max relative error (float64): {max(errs):.2e}')
assert max(errs) < 1e-5
print('gradient verified — never trust one you haven\'t finite-differenced')
max relative error (float64): 3.29e-07
gradient verified — never trust one you haven't finite-differenced

The count model, for comparison¶

Step 1's answer, which two theorems say gradient descent must approach: the MLE is the normalized count matrix (Lecture 1, Thm 3.3), and the loss is convex in $W$ (Lecture 2, Ex. 2), so GD cannot be trapped elsewhere.

In [11]:
N = torch.zeros((V, V), dtype=torch.long)
N.index_put_((xs, ys), torch.ones(n, dtype=torch.long), accumulate=True)
P = (N + 1).float()
P = P / P.sum(1, keepdim=True)
ce_count = -P[xs, ys].log().mean().item()
print(f'count model (add-one) CE: {ce_count:.4f}')
count model (add-one) CE: 2.4549

2.9 Gradient descent¶

First, a learning-rate sweep — 30 steps each from the same start. The reference numbers:

lr 0.1 1 50 500
loss @ 30 4.158 4.024 2.742 5.401

The tiny rates are converging, uselessly slowly; 500 has gone above its starting point (overshooting the valley every step). Why does this convex problem tolerate lr 50 when Step 3's network will want 0.1? Because the gradient here is a mean over $10^6$ examples spread across 4,225 entries — of order $10^{-5}$ — and the step size compensates. Learning rates are not transferable constants; they are inverse to the gradient scale of the specific problem.

In [12]:
torch.manual_seed(0)
W0 = torch.randn(V, V) * 0.01
for lr in (0.1, 1.0, 50.0, 500.0):
    W = W0.clone()
    for _ in range(30):
        W -= lr * grad_fn(W)
    print(f'lr={lr:>6}: loss after 30 steps = {loss_and_p(W)[0].item():.4f}')
lr=   0.1: loss after 30 steps = 4.1577
lr=   1.0: loss after 30 steps = 4.0238
lr=  50.0: loss after 30 steps = 2.7420
lr= 500.0: loss after 30 steps = 5.4008

Now the real run: 300 full-batch steps at lr 50. Reference trajectory: 4.1731 → 2.6530 → 2.5690 → 2.5352 → 2.5168 → 2.5052 → 2.4973 (loss 2.4971 after the final update).

In [13]:
W = W0.clone()
for step in range(301):
    L, _ = loss_and_p(W)
    if step % 50 == 0:
        print(f'step {step:>4}  loss {L.item():.4f}', flush=True)
    W -= 50.0 * grad_fn(W)
step    0  loss 4.1731
step   50  loss 2.6530
step  100  loss 2.5690
step  150  loss 2.5352
step  200  loss 2.5168
step  250  loss 2.5052
step  300  loss 2.4973

2.10 The punchline — read it carefully¶

Close, and visibly not there: 2.497 vs 2.455, still 0.042 nats apart after 300 steps. Both proofs are about the limit; the gap is about the rate, and the rate is governed by conditioning. Row $a$ of $W$ receives gradient mass proportional to how often character $a$ occurs — common characters get strong signal, rare ones almost none — so agreement should be excellent on busy rows and poor on rare ones. The next cell measures exactly that prediction.

In [14]:
Wsm = torch.softmax(W, dim=1)
diff = (Wsm - P).abs()
busy = N.sum(1) > 1000
print(f'final GD loss              : {loss_and_p(W)[0].item():.4f}')
print(f'count model CE             : {ce_count:.4f}')
print(f'max  |softmax(W) - P|      : {diff.max().item():.4f}')
print(f'mean |softmax(W) - P|      : {diff.mean().item():.5f}')
print(f'max  on rows with >1000 obs: {diff[busy].max().item():.4f}'
      f'   ({busy.sum().item()} such rows)')
worst = diff.max(dim=1).values.argmax().item()
print(f'worst row: {itos[worst]!r}, which occurs '
      f'{N.sum(1)[worst].item()} times in the corpus')
final GD loss              : 2.4971
count model CE             : 2.4549
max  |softmax(W) - P|      : 0.5421
mean |softmax(W) - P|      : 0.00522
max  on rows with >1000 obs: 0.0698   (53 such rows)
worst row: 'Q', which occurs 231 times in the corpus

Reference values: max 0.542 over all entries, 0.0052 on average, 0.070 on the 53 well-populated rows — and the worst row belongs to Q (231 occurrences against e's 94,611), exactly as the conditioning argument predicts: the rows GD leaves wrong are the rows the data barely constrains. Run the sampler from Step 1 on softmax(W) and the text is indistinguishable from the count model's, because sampling almost never visits the rows that are still wrong.

Which of Step 1's ingredients does $W \approx 0$ at initialization play the role of? Initializing all logits near zero means all rows start near uniform — the same place add-one smoothing shrinks toward — and 300 steps has not fully erased that prior from the starved rows. Early stopping is regularization; you have just watched it act.

→ Continue with Step 3: the last gradient anyone derives by hand, and the machine that derives all the rest.

Going further — change confidence without changing geometry¶

The same grid at $\gamma\in\{0.25,1,4,20\}$, one colour scale for all four (vmin=0, vmax=1, so the panels are comparable).

In [15]:
gammas = [0.25, 1.0, 4.0, 20.0]
fig, axes = plt.subplots(1, 4, figsize=(16, 4), sharex=True, sharey=True)
for ax, g in zip(axes, gammas):
    _, _, _, gp = diamond_net(grid, gamma=g)
    pi = gp[:, 1].reshape(gx.shape).numpy()
    im = ax.contourf(gx.numpy(), gy.numpy(), pi, levels=torch.linspace(0, 1, 31).numpy(),
                     cmap='Purples', vmin=0, vmax=1)
    ax.contour(gx.numpy(), gy.numpy(), pi, levels=[0.5], colors='black', linewidths=2)
    ax.set_title(f'γ = {g}')
    ax.set_aspect('equal')
fig.colorbar(im, ax=axes, label='p(inside | x)', shrink=0.8)
plt.show()

# the boundary does not move: p = 1/2 on the diamond's vertices for every γ
vertices = torch.tensor([[1., 0.], [0., 1.], [-1., 0.], [0., -1.]])
for g in gammas:
    _, _, _, vp = diamond_net(vertices, gamma=g)
    assert torch.allclose(vp[:, 1], torch.full((4,), 0.5))
print('p(inside) = 1/2 on all four vertices, for every γ ✓')
No description has been provided for this image
p(inside) = 1/2 on all four vertices, for every γ ✓
  1. The diamond itself — every point with $|x_1|+|x_2|=1$ — has probability exactly $\tfrac12$ in all four plots, because there $z_{\mathrm{in}}=z_{\mathrm{out}}=0$ whatever $\gamma$ is.
  2. As $\gamma$ grows, probabilities away from the boundary saturate toward $0$ and $1$: the purple band of uncertainty narrows, and at $\gamma=20$ the plot is essentially a two-colour picture of the diamond's indicator function.
  3. As $\gamma\to0$, both logits go to $0$ and every point tends to $p=\tfrac12$: the network is still correct (the sign of $z_{\mathrm{in}}-z_{\mathrm{out}}$ never changes) but maximally unconfident, and the whole plane fades to the same mid-purple.
  4. Softmax at temperature $T$ is $\operatorname{softmax}(z/T)$. Here every logit is proportional to $\gamma$, so $\operatorname{softmax}(z(\gamma))=\operatorname{softmax}(z(1)/T)$ with $T=1/\gamma$: changing $\gamma$ is changing the temperature. Temperature rescales a distribution's sharpness and leaves its argmax — the geometry — untouched. (Lecture 7 returns to this when we sample.)

Going further — move and stretch the diamond¶

For a centre $(a,b)$ and radii $r_1,r_2$ we want the hidden activations to sum to $\dfrac{|x_1-a|}{r_1}+\dfrac{|x_2-b|}{r_2}$. Since $|x_1-a|/r_1=\operatorname{ReLU}\!\bigl(\tfrac{x_1-a}{r_1}\bigr)+\operatorname{ReLU}\!\bigl(\tfrac{a-x_1}{r_1}\bigr)$, the four units are affine in $\mathbf x$ with

$$ W_1=\begin{pmatrix}1/r_1&0\\-1/r_1&0\\0&1/r_2\\0&-1/r_2\end{pmatrix}, \qquad b_1=\begin{pmatrix}-a/r_1\\ a/r_1\\ -b/r_2\\ b/r_2\end{pmatrix}, $$

and the output layer is unchanged: inside when the sum is below $1$. The boundary $\frac{|x_1-a|}{r_1}+\frac{|x_2-b|}{r_2}=1$ has vertices $(a\pm r_1,b)$ and $(a,b\pm r_2)$.

In [16]:
def stretched_diamond_net(X, a=0.5, b=-0.25, r1=1.2, r2=0.6, gamma=4.0):
    W1 = torch.tensor([
        [ 1/r1,   0.0],
        [-1/r1,   0.0],
        [  0.0,  1/r2],
        [  0.0, -1/r2],
    ], dtype=X.dtype)
    b1 = torch.tensor([-a/r1, a/r1, -b/r2, b/r2], dtype=X.dtype)
    W2 = gamma * torch.tensor([[1., 1., 1., 1.], [-1., -1., -1., -1.]], dtype=X.dtype)
    b2 = gamma * torch.tensor([-1.0, 1.0], dtype=X.dtype)

    h = torch.relu(X @ W1.T + b1)
    logits = h @ W2.T + b2
    return torch.softmax(logits, dim=1)        # same as the by-hand softmax above

a, b, r1, r2 = 0.5, -0.25, 1.2, 0.6
axis2 = torch.linspace(-1.5, 2.5, 201)
gx2, gy2 = torch.meshgrid(axis2, axis2, indexing='xy')
grid2 = torch.stack([gx2.reshape(-1), gy2.reshape(-1)], dim=1)
p2 = stretched_diamond_net(grid2, a, b, r1, r2)[:, 1].reshape(gx2.shape)

plt.figure(figsize=(6, 5))
plt.contourf(gx2.numpy(), gy2.numpy(), p2.numpy(), levels=30, cmap='Purples')
plt.contour(gx2.numpy(), gy2.numpy(), p2.numpy(), levels=[0.5], colors='black', linewidths=2)
predicted = torch.tensor([[a + r1, b], [a - r1, b], [a, b + r2], [a, b - r2]])
plt.scatter(predicted[:, 0], predicted[:, 1], c='red', s=40, zorder=3, label='predicted vertices')
plt.legend(); plt.axis('equal'); plt.xlabel('x1'); plt.ylabel('x2')
plt.show()

assert torch.allclose(stretched_diamond_net(predicted, a, b, r1, r2)[:, 1], torch.full((4,), 0.5))
print('p(inside) = 1/2 exactly at the four predicted vertices ✓')
No description has been provided for this image
p(inside) = 1/2 exactly at the four predicted vertices ✓

Going further — a square needs another composition¶

$\max(u,v)=\operatorname{ReLU}(u-v)+v$, so $\max(|x_1|,|x_2|)$ needs one more ReLU layer after the absolute values are formed — a composition, not wider first layer. With $h$ the four units above, $u=h_1+h_2$ and $v=h_3+h_4$:

$$ a_2=W_{2}'\,h=\begin{pmatrix}1&1&-1&-1\\0&0&1&1\end{pmatrix}h =\begin{pmatrix}u-v\\ v\end{pmatrix}, \qquad h_2=\operatorname{ReLU}(a_2)=\begin{pmatrix}\operatorname{ReLU}(u-v)\\ v\end{pmatrix} $$

(the second unit passes $v\ge0$ through unchanged), and $\mathbf 1^\top h_2=\max(u,v)$. The output layer is the same rule as before on this sum. Dimensions: $W_1\in\mathbb R^{4\times2}$, $W_2'\in\mathbb R^{2\times4}$, $W_3\in\mathbb R^{2\times2}$.

In [17]:
def square_net(X, gamma=4.0):
    h = torch.relu(X @ W1.T + b1)                                 # (N, 4): |x1|, |x2| split into halves
    W2p = torch.tensor([[1., 1., -1., -1.], [0., 0., 1., 1.]])    # (2, 4)
    h2 = torch.relu(h @ W2p.T)                                    # (N, 2): (ReLU(u - v), v)
    W3 = gamma * torch.tensor([[1., 1.], [-1., -1.]])             # (2, 2)
    b3 = gamma * torch.tensor([-1.0, 1.0])
    return torch.softmax(h2 @ W3.T + b3, dim=1)

m = torch.maximum(X[:, 0].abs(), X[:, 1].abs())
assert torch.allclose(square_net(X)[:, 1], torch.sigmoid(2 * 4.0 * (1 - m)))

p_sq = square_net(grid)[:, 1].reshape(gx.shape)
plt.figure(figsize=(5, 5))
plt.contourf(gx.numpy(), gy.numpy(), p_sq.numpy(), levels=30, cmap='Purples')
plt.contour(gx.numpy(), gy.numpy(), p_sq.numpy(), levels=[0.5], colors='black', linewidths=2)
plt.axis('equal'); plt.title('max(|x1|, |x2|) < 1: three layers, one square')
plt.show()
No description has been provided for this image

Count the wiring. The diamond network has $4\cdot2+4+2\cdot4+2=22$ scalar parameters, of which $4+0+8+2=14$ are nonzero: four in $W_1$ (one per unit, choosing an axis and a sign), eight in $W_2$ (every hidden unit feeds both logits, with opposite signs), two in $b_2$ (the threshold $r=1$). Every one of them has a job you can name — which is the point of building a network by hand before letting gradient descent fill the numbers in.

→ Continue with Step 3: differentiate this very network — first with an autograd engine you write yourself, then with PyTorch's — and then train the first model of the course whose weights nobody wrote down.