Neural Networks II — Convolutional Networks¶

The convolution built from scratch → a PyTorch CNN, and the payoff of structure¶

The previous notebook ended on a lesson: a plain MLP does not beat gradient-boosted trees on tabular data, because tabular columns have no structure for a network to exploit. Images are the opposite. A 28×28 image is not 784 unordered numbers — neighbouring pixels are strongly related, edges and shapes are local, and the same object can appear shifted around the frame. The convolutional neural network (CNN) (LeCun et al., 1998) is built around exactly those facts, and this is where deep learning decisively pulls ahead.

Three ideas give the CNN its edge over a dense net:

  • Local receptive fields — each unit looks at a small patch, not the whole image, matching the locality of visual features;
  • Weight sharing — the same small filter slides across the whole image, so a feature detector learned in one corner works everywhere (and the parameter count collapses);
  • Translation equivariance — shift the input, and the feature map shifts with it; the network does not have to relearn a shape in every position.

We build the core operation — the convolution — from scratch and validate it against PyTorch, then assemble and train a real CNN on Fashion-MNIST, and show it beating both a dense MLP and a linear classifier with fewer parameters. Python-only, as with the whole subsection.

1. The data — Fashion-MNIST¶

Fashion-MNIST (Xiao, Rasul & Vollgraf, 2017) is a drop-in, harder replacement for the classic MNIST digits: 70,000 grayscale images, 28×28 pixels, in 10 clothing classes (T-shirt, trouser, pullover, dress, coat, sandal, shirt, sneaker, bag, ankle boot), split 60,000 train / 10,000 test. Pixel intensities are in [0,1]. The goal is 10-way image classification — assign each photo to its garment type. It is a clean benchmark for the whole deep-learning workflow while being genuinely non-trivial (several classes — shirt, coat, pullover — look alike even to humans). The grid below shows one example per class.

In [1]:
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"        # avoid libiomp double-load kernel crash on Windows
import numpy as np, matplotlib.pyplot as plt, time, warnings
warnings.filterwarnings("ignore")
import torch, torch.nn as nn, torchvision, torchvision.transforms as T
torch.set_num_threads(2)
from torch.utils.data import DataLoader
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
torch.manual_seed(0)
root="data"
train=torchvision.datasets.FashionMNIST(root,train=True,download=True,transform=T.ToTensor())
test =torchvision.datasets.FashionMNIST(root,train=False,download=True,transform=T.ToTensor())
classes=train.classes
print(f"Fashion-MNIST: {len(train)} train / {len(test)} test, 28x28 grayscale, {len(classes)} classes")
print("classes:", classes)
fig,ax=plt.subplots(2,5,figsize=(11,4.6))
Y=np.array(train.targets)
for c in range(10):
    i=np.where(Y==c)[0][0]; a=ax[c//5,c%5]; a.imshow(train[i][0][0],cmap="gray"); a.set_title(classes[c],fontsize=9); a.axis("off")
plt.suptitle("Fashion-MNIST — one example per class"); plt.tight_layout(); plt.show()
Fashion-MNIST: 60000 train / 10000 test, 28x28 grayscale, 10 classes
classes: ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
No description has been provided for this image

2. What a convolution does — from scratch¶

A convolution (technically cross-correlation) slides a small filter (kernel) over the image; at each position it takes the dot product of the filter with the local patch, producing a feature map that lights up where the pattern matches. A $3\times3$ vertical-edge filter, for instance, responds strongly at vertical boundaries. conv2d_scratch below is the whole operation in two loops; we apply hand-designed edge filters to see what convolution extracts, and confirm it reproduces PyTorch's F.conv2d to $10^{-7}$. In a CNN these filters are not hand-designed — they are learned by backprop, but the operation is exactly this.

In [2]:
def conv2d_scratch(img, kern):
    H,W=img.shape; kh,kw=kern.shape; out=np.zeros((H-kh+1,W-kw+1),np.float32)
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i,j]=(img[i:i+kh,j:j+kw]*kern).sum()
    return out
img=train[0][0][0].numpy().astype(np.float32)
kernels={"vertical edge":np.array([[1,0,-1]]*3,np.float32),
         "horizontal edge":np.array([[1,1,1],[0,0,0],[-1,-1,-1]],np.float32),
         "blur (3x3 mean)":np.ones((3,3),np.float32)/9}
fig,ax=plt.subplots(1,4,figsize=(14,3.6)); ax[0].imshow(img,cmap="gray"); ax[0].set_title("original"); ax[0].axis("off")
for a,(nm,k) in zip(ax[1:],kernels.items()):
    a.imshow(conv2d_scratch(img,k),cmap="gray"); a.set_title(f"filter: {nm}"); a.axis("off")
plt.tight_layout(); plt.show()
kv=kernels["vertical edge"]
with torch.no_grad():
    ref=torch.nn.functional.conv2d(torch.tensor(img).view(1,1,28,28),torch.tensor(kv).view(1,1,3,3)).numpy()[0,0]
print("from-scratch conv2d vs torch F.conv2d: max|diff|", float(np.abs(conv2d_scratch(img,kv)-ref).max()))
print("A convolution is a learned pattern-matcher slid across the image -- the same filter everywhere (weight sharing),")
print("looking at a small patch at a time (local receptive field). That is the CNN's whole inductive bias for images.")
No description has been provided for this image
from-scratch conv2d vs torch F.conv2d: max|diff| 1.1920928955078125e-07
A convolution is a learned pattern-matcher slid across the image -- the same filter everywhere (weight sharing),
looking at a small patch at a time (local receptive field). That is the CNN's whole inductive bias for images.

3. Pooling and the CNN architecture¶

Two more pieces complete a CNN. Pooling (max-pool over $2\times2$ blocks) downsamples each feature map, keeping the strongest response in each neighbourhood — it shrinks the spatial size, adds a little translation invariance, and cuts computation. Stacking conv → ReLU → pool blocks builds a hierarchy: early layers detect edges, later layers combine them into shapes and objects. A final flatten + fully-connected head maps the learned features to the 10 class scores.

Where the parameters go is worth looking at before the comparison. Our CNN (two conv blocks → 128-unit head) has ~207k parameters — but only 4,800 of them, 2.3%, are convolutional. Sixteen shared $3\times3$ filters cost 160 weights; a dense layer over 784 pixels costs 200,704. Almost the entire parameter count is the fully-connected head. That makes the usual "CNNs win because they have fewer parameters" framing too glib, and §5 replaces it with a sharper claim the numbers actually support.

In [3]:
def maxpool_scratch(fm, s=2):
    H,W=fm.shape; out=np.zeros((H//s,W//s),np.float32)
    for i in range(H//s):
        for j in range(W//s): out[i,j]=fm[i*s:i*s+s, j*s:j*s+s].max()
    return out
fm=np.maximum(0, conv2d_scratch(img, kernels["vertical edge"]))     # conv -> ReLU
fig,ax=plt.subplots(1,2,figsize=(8,3.8))
ax[0].imshow(fm,cmap="gray"); ax[0].set_title(f"feature map after conv+ReLU  {fm.shape}"); ax[0].axis("off")
ax[1].imshow(maxpool_scratch(fm),cmap="gray"); ax[1].set_title(f"after 2x2 max-pool  {maxpool_scratch(fm).shape}"); ax[1].axis("off")
plt.tight_layout(); plt.show()
def make_cnn():
    return nn.Sequential(nn.Conv2d(1,16,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),
                         nn.Conv2d(16,32,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),
                         nn.Flatten(),nn.Linear(32*7*7,128),nn.ReLU(),nn.Linear(128,10))
cnn=make_cnn()
print("CNN architecture:"); print(cnn)
_tot=sum(p.numel() for p in cnn.parameters())
_conv=sum(p.numel() for n,p in cnn.named_parameters() if n.startswith(("0.","3.")))
print(f"\nCNN parameters: {_tot:,}")
print(f"   convolutional layers : {_conv:>8,}  ({100*_conv/_tot:.1f}%)")
print(f"   dense head           : {_tot-_conv:>8,}  ({100*(_tot-_conv)/_tot:.1f}%)")
print("\nWorth noticing before the comparison below: only 2% of this network's weights are in the convolutions.")
print("The shared 3x3 filters are almost free -- 16 filters cost 160 parameters, a dense layer over 784 pixels costs")
print("200,704 -- and nearly everything in the parameter count is the fully-connected head that follows them.")
No description has been provided for this image
CNN architecture:
Sequential(
  (0): Conv2d(1, 16, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (1): ReLU()
  (2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (3): Conv2d(16, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
  (4): ReLU()
  (5): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  (6): Flatten(start_dim=1, end_dim=-1)
  (7): Linear(in_features=1568, out_features=128, bias=True)
  (8): ReLU()
  (9): Linear(in_features=128, out_features=10, bias=True)
)

CNN parameters: 206,922
   convolutional layers :    4,800  (2.3%)
   dense head           :  202,122  (97.7%)

Worth noticing before the comparison below: only 2% of this network's weights are in the convolutions.
The shared 3x3 filters are almost free -- 16 filters cost 160 parameters, a dense layer over 784 pixels costs
200,704 -- and nearly everything in the parameter count is the fully-connected head that follows them.

4. Training the CNN¶

Training is the same loop as the MLP notebook — forward, cross-entropy loss, backward() (autograd), Adam step — now over mini-batches of images. Multi-class classification uses the softmax + cross-entropy loss (nn.CrossEntropyLoss, the multi-class generalisation of the binary log-loss). A few passes over the 60,000 training images is enough to reach high test accuracy; the curve shows test accuracy climbing each epoch.

In [4]:
def run(model, epochs=4, lr=1e-3):
    dl=DataLoader(train,batch_size=128,shuffle=True); dlt=DataLoader(test,batch_size=1000)
    opt=torch.optim.Adam(model.parameters(),lr=lr); lf=nn.CrossEntropyLoss(); accs=[]
    for ep in range(epochs):
        model.train()
        for xb,yb in dl: opt.zero_grad(); lf(model(xb),yb).backward(); opt.step()
        model.eval(); c=0
        with torch.no_grad():
            for xb,yb in dlt: c+=(model(xb).argmax(1)==yb).sum().item()
        accs.append(c/len(test))
    return model,accs
torch.manual_seed(0); cnn=make_cnn(); t=time.time(); cnn,cnn_acc=run(cnn,epochs=4)
print(f"CNN trained in {time.time()-t:.0f}s;  test accuracy by epoch: {[round(a,3) for a in cnn_acc]}")
fig,ax=plt.subplots(figsize=(7,4)); ax.plot(range(1,len(cnn_acc)+1),cnn_acc,"o-",color=BLUE,lw=2)
ax.set_xlabel("epoch"); ax.set_ylabel("test accuracy"); ax.set_title(f"CNN on Fashion-MNIST — {cnn_acc[-1]:.1%} test accuracy"); ax.set_xticks(range(1,len(cnn_acc)+1))
plt.tight_layout(); plt.show()
CNN trained in 57s;  test accuracy by epoch: [0.832, 0.875, 0.888, 0.89]
No description has been provided for this image

5. The payoff — CNN vs MLP vs linear, on structured data¶

Now the comparison the whole subsection has been building toward. We train, on the same pixels, three models: a linear softmax classifier (the logistic-regression analogue), a dense MLP (the ex1 architecture, ignoring the 2-D layout), and the CNN. The CNN wins clearly. It also happens to use fewer parameters than this particular MLP, but that comparison is worth resisting — it depends on a width we chose arbitrarily, and 98% of the CNN's own weights sit in its dense head anyway. The cell below makes the stronger case by fitting two more models: an MLP at half the size, and a CNN with its dense head removed. The first shows the dense net is not capacity-starved: doubling its parameters lifts accuracy by about a point, while a few thousand convolutional weights lift it by rather more — gains that are comparable in absolute terms but differ by a factor of tens per parameter. The second is an honest counterweight: strip the head and accuracy collapses, so the filters are not doing everything on their own. The realisation of ex1's thesis is not "fewer parameters" but better-directed ones: give a network the assumption that nearby pixels belong together and its ordinary capacity becomes far more productive.

In [5]:
torch.manual_seed(0); lin=nn.Sequential(nn.Flatten(),nn.Linear(784,10)); lin,lin_acc=run(lin,epochs=4)
torch.manual_seed(0); mlp=nn.Sequential(nn.Flatten(),nn.Linear(784,256),nn.ReLU(),nn.Linear(256,128),nn.ReLU(),nn.Linear(128,10)); mlp,mlp_acc=run(mlp,epochs=4)
# is the MLP simply parameter-starved? halve it and see what that costs.
torch.manual_seed(0); mlp_s=nn.Sequential(nn.Flatten(),nn.Linear(784,128),nn.ReLU(),nn.Linear(128,64),nn.ReLU(),nn.Linear(64,10)); mlp_s,mlp_s_acc=run(mlp_s,epochs=4)
# and can the convolutions carry the load alone, with the dense head removed?
torch.manual_seed(0); cnn_gap=nn.Sequential(nn.Conv2d(1,16,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),
                                            nn.Conv2d(16,32,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),
                                            nn.AdaptiveAvgPool2d(1),nn.Flatten(),nn.Linear(32,10))
cnn_gap,gap_acc=run(cnn_gap,epochs=4)
names=["linear (softmax)","dense MLP","CNN"]; accs=[lin_acc[-1],mlp_acc[-1],cnn_acc[-1]]
pars=[sum(p.numel() for p in m.parameters()) for m in (lin,mlp,cnn)]
_np=lambda m: sum(p.numel() for p in m.parameters())
print(f"{'model':22s}{'parameters':>12s}{'test accuracy':>15s}")
for _n, _pc, _a in zip(names, pars, accs): print(f"{_n:22s}{_pc:>12,}{_a:>15.3f}")
print(f"{'dense MLP, halved':22s}{_np(mlp_s):>12,}{mlp_s_acc[-1]:>15.3f}")
print(f"{'CNN, no dense head':22s}{_np(cnn_gap):>12,}{gap_acc[-1]:>15.3f}")

fig,ax=plt.subplots(1,2,figsize=(13,4.4))
b=ax[0].bar(names,accs,color=[GREY,ORANGE,BLUE]); ax[0].set_ylim(0.8,0.92); ax[0].set_ylabel("test accuracy")
for i,a in enumerate(accs): ax[0].text(i,a+0.002,f"{a:.3f}",ha="center")
ax[0].set_title("Accuracy on Fashion-MNIST")
ax[1].bar(names,[p/1000 for p in pars],color=[GREY,ORANGE,BLUE]); ax[1].set_ylabel("parameters (thousands)")
for i,p in enumerate(pars): ax[1].text(i,p/1000+3,f"{p/1000:.0f}k",ha="center")
ax[1].set_title("Model size — CNN wins with FEWER parameters")
plt.tight_layout(); plt.show()
print(f"linear {accs[0]:.3f}  <  MLP {accs[1]:.3f}  <  CNN {accs[2]:.3f}   (params: {pars[0]/1000:.0f}k / {pars[1]/1000:.0f}k / {pars[2]/1000:.0f}k)")
print(f"\nThe CNN does use ~{100*(1-pars[2]/pars[1]):.0f}% fewer weights than this MLP -- but that is a weak argument, since it depends on")
print("the MLP width we happened to pick. Two further fits make the real point instead.")
_extra=_np(mlp)-_np(mlp_s); _gain_dense=mlp_acc[-1]-mlp_s_acc[-1]; _gain_conv=accs[2]-accs[1]
_ratio=(_gain_conv/4800)/(_gain_dense/_extra)
print(f"\n1. Is the MLP just too small? Halve it -- {_np(mlp_s):,} parameters against {_np(mlp):,} -- and accuracy moves from")
print(f"   {mlp_s_acc[-1]:.4f} to {mlp_acc[-1]:.4f}. So {_extra:,} extra dense weights buy {_gain_dense:+.4f}, while the CNN's 4,800")
print(f"   convolutional weights buy {_gain_conv:+.4f} over that LARGER MLP. In absolute terms those gains are comparable;")
print(f"   per parameter they are not, by a factor of about {_ratio:.0f}. Capacity is not what the dense net is short of --")
print("   what it lacks is the assumption that nearby pixels belong together, and that assumption is nearly free.")
print(f"\n2. Can the filters carry it alone? Replace the dense head with global average pooling -- {_np(cnn_gap):,} parameters,")
print(f"   almost all convolutional -- and accuracy falls to {gap_acc[-1]:.4f}. So the convolutions are not doing the whole job:")
print("   they supply features the dense head could not have learned, but the head is still what classifies. The honest")
print("   claim is that a small amount of well-structured computation redirects a large amount of ordinary capacity.")
model                   parameters  test accuracy
linear (softmax)             7,850          0.835
dense MLP                  235,146          0.875
CNN                        206,922          0.890
dense MLP, halved          109,386          0.865
CNN, no dense head           5,130          0.729
No description has been provided for this image
linear 0.835  <  MLP 0.875  <  CNN 0.890   (params: 8k / 235k / 207k)

The CNN does use ~12% fewer weights than this MLP -- but that is a weak argument, since it depends on
the MLP width we happened to pick. Two further fits make the real point instead.

1. Is the MLP just too small? Halve it -- 109,386 parameters against 235,146 -- and accuracy moves from
   0.8648 to 0.8749. So 125,760 extra dense weights buy +0.0101, while the CNN's 4,800
   convolutional weights buy +0.0148 over that LARGER MLP. In absolute terms those gains are comparable;
   per parameter they are not, by a factor of about 38. Capacity is not what the dense net is short of --
   what it lacks is the assumption that nearby pixels belong together, and that assumption is nearly free.

2. Can the filters carry it alone? Replace the dense head with global average pooling -- 5,130 parameters,
   almost all convolutional -- and accuracy falls to 0.7294. So the convolutions are not doing the whole job:
   they supply features the dense head could not have learned, but the head is still what classifies. The honest
   claim is that a small amount of well-structured computation redirects a large amount of ordinary capacity.

6. Proportions vs predictions, and what the CNN learned¶

Three diagnostics. Confusion matrix — where the errors go: the CNN nearly never confuses a sneaker with a bag, but shirt / coat / pullover / T-shirt trade mistakes, exactly the visually similar classes. Reliability curve — the multi-class "proportions vs predictions": bin test images by the model's predicted confidence (its top softmax probability) and plot the actual accuracy in each bin; on the 45° line the confidence is the probability of being right. Learned first-layer filters — the $3\times3$ kernels the network discovered by backprop, the trained analogue of the hand-designed edge filters in §2.

In [6]:
from sklearn.metrics import confusion_matrix
dlt=DataLoader(test,batch_size=1000); probs=[]; preds=[]; ys=[]
cnn.eval()
with torch.no_grad():
    for xb,yb in dlt:
        pr=torch.softmax(cnn(xb),1); probs.append(pr.numpy()); preds.append(pr.argmax(1).numpy()); ys.append(yb.numpy())
probs=np.concatenate(probs); preds=np.concatenate(preds); ys=np.concatenate(ys); conf=probs.max(1)
fig,ax=plt.subplots(1,2,figsize=(12.5,4.8))
cm=confusion_matrix(ys,preds); cmn=cm/cm.sum(1,keepdims=True)
im=ax[0].imshow(cmn,cmap="Blues",vmin=0,vmax=1); ax[0].set_xticks(range(10)); ax[0].set_xticklabels(classes,rotation=90,fontsize=7); ax[0].set_yticks(range(10)); ax[0].set_yticklabels(classes,fontsize=7)
ax[0].set_xlabel("predicted"); ax[0].set_ylabel("true"); ax[0].set_title("Confusion matrix (row-normalized)"); plt.colorbar(im,ax=ax[0],fraction=0.046)
bins=np.linspace(conf.min(),1,11); idx=np.clip(np.digitize(conf,bins[1:-1]),0,9)
bc=[conf[idx==k].mean() for k in range(10)]; ba=[(preds[idx==k]==ys[idx==k]).mean() for k in range(10)]
ax[1].plot([bc[0],1],[bc[0],1],"k--",lw=1,label="perfect (confidence = accuracy)"); ax[1].plot(bc,ba,"o-",color=BLUE,lw=2,label="CNN")
ax[1].set_xlabel("predicted confidence (top softmax prob)"); ax[1].set_ylabel("actual accuracy"); ax[1].set_title("Reliability — proportions vs predictions"); ax[1].legend()
plt.tight_layout(); plt.show()
# learned first-layer filters — separate clean grid
W=cnn[0].weight.detach().numpy()[:,0]
fig2,axf=plt.subplots(2,8,figsize=(12,3.2))
for k in range(16):
    a=axf[k//8,k%8]; a.imshow(W[k],cmap="gray"); a.axis("off")
fig2.suptitle("Learned first-layer 3x3 filters (edge/texture detectors, discovered by backprop)"); plt.tight_layout(); plt.show()
per=[(preds[ys==c]==c).mean() for c in range(10)]
print("Per-class accuracy:", {classes[c]:round(per[c],2) for c in range(10)})
print(f"Hardest class: {classes[int(np.argmin(per))]} ({min(per):.2f}) -- shirts/coats/pullovers are the confusable set.")
def _ece(Pm,Yv,nb=10):
    cf=Pm.max(1); pr=Pm.argmax(1); ok=(pr==Yv); bs=np.linspace(0,1,nb+1); e=0.0
    for k in range(nb):
        m=(cf>bs[k])&(cf<=bs[k+1])
        if m.sum(): e+=m.mean()*abs(ok[m].mean()-cf[m].mean())
    return e
_acc=(preds==ys).mean()
print(f"Reliability, measured rather than eyeballed: mean confidence {conf.mean():.4f} against actual accuracy {_acc:.4f},")
print(f"an expected calibration error of {_ece(probs,ys):.4f}. The CNN is over-confident by {conf.mean()-_acc:+.4f} -- which is to say")
print("essentially calibrated. That is worth flagging because softmax networks are usually described as over-confident;")
print("that reputation comes from long training runs that drive the training loss toward zero, and after four epochs")
print("this one has not got there. The learned filters are edge/texture detectors -- discovered, not designed.")
No description has been provided for this image
No description has been provided for this image
Per-class accuracy: {'T-shirt/top': np.float64(0.91), 'Trouser': np.float64(0.97), 'Pullover': np.float64(0.9), 'Dress': np.float64(0.93), 'Coat': np.float64(0.79), 'Sandal': np.float64(0.95), 'Shirt': np.float64(0.54), 'Sneaker': np.float64(0.98), 'Bag': np.float64(0.97), 'Ankle boot': np.float64(0.95)}
Hardest class: Shirt (0.54) -- shirts/coats/pullovers are the confusable set.
Reliability, measured rather than eyeballed: mean confidence 0.8982 against actual accuracy 0.8897,
an expected calibration error of 0.0085. The CNN is over-confident by +0.0085 -- which is to say
essentially calibrated. That is worth flagging because softmax networks are usually described as over-confident;
that reputation comes from long training runs that drive the training loss toward zero, and after four epochs
this one has not got there. The learned filters are edge/texture detectors -- discovered, not designed.

7. Summary¶

A CNN is a neural network whose architecture encodes the structure of images — local receptive fields, shared filters, translation equivariance — and that is exactly why it wins where the flat MLP could not. We built the core convolution from scratch (matched to PyTorch at $10^{-7}$), saw pooling downsample feature maps, assembled a conv→pool→conv→pool→fc CNN, and on Fashion-MNIST it beat a dense MLP and a linear classifier. The parameter story turned out to need care: the CNN is only ~12% smaller than the MLP it beats, and 98% of its own weights are in the dense head, so the win is not really about size. Doubling the MLP's size buys about a point of accuracy, while the CNN's 4,800 convolutional weights buy rather more than that — comparable in absolute terms, but tens of times larger per parameter. The dense net was never short of capacity; what it lacked was the assumption that nearby pixels belong together, and that assumption costs almost nothing to encode. Removing the CNN's head in turn collapses its accuracy, so the filters are not sufficient alone either. The honest statement is that a small amount of well-structured computation makes ordinary capacity far more productive — the mirror image of the tabular result in ex1. The diagnostics showed sensible errors (the confusable shirt/coat/pullover set), reasonable calibration, and learned edge-detector filters.

Cross-links and what's next. The convolution's weight sharing across positions is the same idea the next notebook applies across time — an RNN/LSTM shares weights across a sequence, the temporal analogue of the CNN's spatial sharing, and we take it to financial sequences (volatility/returns) against the GARCH and time-series arcs. Attention (the transformer notebook) then replaces both recurrences and convolutions with a learned, content-based mixing of positions. The Bayesian thread continues in the capstone: MC-dropout turns any of these nets — CNN included — into an approximate Bayesian model with calibrated uncertainty (Gal & Ghahramani, 2016).

Next: Recurrent networks / LSTMs on financial time series.