Today’s labels are categorical rather than real: MNIST handwritten digits, \(0\) through \(9\). We build the softmax and cross-entropy machinery from the reading by hand, then fit it with plain gradient descent because the weights have no closed-form solution.
import numpy as npimport matplotlib.pyplot as pltimport scienceplotsfrom matplotlib.colors import LinearSegmentedColormapfrom sklearn.datasets import fetch_openmlplt.style.use(["science", "no-latex"])TEAL, CARDINAL, GRAY ="#009090", "#9c1b33", "#c9c9c9"CARDINAL_TEAL = LinearSegmentedColormap.from_list("cardinal_teal", [CARDINAL, "white", TEAL])rng = np.random.default_rng(10)X_all, y_all = fetch_openml("mnist_784", version=1, return_X_y=True, as_frame=False, parser="auto")y_all = y_all.astype(int)# subsample for a demo that trains in seconds, not minutesidx = rng.choice(len(X_all), size=6000, replace=False)X_all, y_all = X_all[idx], y_all[idx]X_all = X_all /255.0# pixels to [0, 1]X_all = np.column_stack([X_all, np.ones(len(X_all))]) # fold in the bias columnn_train =5000X_train, y_train = X_all[:n_train], y_all[:n_train]X_val, y_val = X_all[n_train:], y_all[n_train:]print(f"training on {n_train} digits, validating on {len(X_val)}")
training on 5000 digits, validating on 1000
Build the one-hot labels, and implement the gradient exactly as derived in lecture: \(\nabla_{\mathbf{z}} \ell = \mathbf{p} - \mathbf{y}\), chained through \(\mathbf{z} = \mathbf{X}\mathbf{W}\) to give \(\frac{1}{n}\mathbf{X}^\top(\mathbf{P} - \mathbf{Y})\).
k =10# digit classesd = X_train.shape[1]def one_hot(y, k): Y = np.zeros((len(y), k)) Y[np.arange(len(y)), y] =1return YY_train = one_hot(y_train, k)Y_val = one_hot(y_val, k)def softmax(Z): Z = Z - Z.max(axis=1, keepdims=True) # for numerical stability; doesn't change the output expZ = np.exp(Z)return expZ / expZ.sum(axis=1, keepdims=True)def cross_entropy(P, Y):return-np.mean(np.sum(Y * np.log(P +1e-12), axis=1))def accuracy(P, y):return np.mean(P.argmax(axis=1) == y)
Train for \(300\) gradient steps at \(\alpha = 0.5\) starting from \(\mathbf{W}^{(0)} = \mathbf{0}\), tracking both losses as we go.
W = np.zeros((d, k))alpha =0.5n_steps =300steps, train_losses, val_losses, val_accs = [], [], [], []for t inrange(n_steps): P_train = softmax(X_train @ W)if t %5==0: # both scores measured at the same weights, before the step steps.append(t) train_losses.append(cross_entropy(P_train, Y_train)) P_val = softmax(X_val @ W) val_losses.append(cross_entropy(P_val, Y_val)) val_accs.append(accuracy(P_val, y_val)) grad = X_train.T @ (P_train - Y_train) / n_train # softmax minus one-hot, averaged W -= alpha * gradfig, ax = plt.subplots(figsize=(7, 3))ax.plot(steps, train_losses, color=CARDINAL, linewidth=1.6, label="Training cross-entropy")ax.plot(steps, val_losses, color=TEAL, linewidth=1.6, label="Validation cross-entropy")ax.set_xlabel("Gradient descent step")ax.set_ylabel("Cross-entropy loss")ax.legend(frameon=False)plt.show()print(f"final validation accuracy: {accuracy(softmax(X_val @ W), y_val):.1%}")
final validation accuracy: 90.9%
The validation loss remains above the training loss: we are fitting \(785 \times 10 = 7{,}850\) parameters to \(5{,}000\) points, so the fitted weights absorb some training noise.
Inspect the learned class templates
Each column of \(\mathbf{W}\) is a length-\(785\) weight vector for one digit class. Drop the bias entry, reshape the rest into a \(28 \times 28\) image, and look at it.
Teal pixels push that digit’s score up, cardinal pixels push it down. The column for \(0\) is a teal ring around a cardinal center, because ink in the middle of the frame is evidence against a zero; the column for \(1\) is a teal stripe down the middle with cardinal on either side. Each class gets exactly one template, which is all a linear model has room for.
Change the learning rate
Change alpha_new below and re-run. Does a bigger step always get there sooner?
This linear softmax model reaches \(90.9\%\) accuracy on real handwritten digits using the softmax-minus-one-hot gradient derived in class. Its remaining errors reflect the linear decision boundary; the Neural Networks unit introduces nonlinear boundaries next.