CIFAR-10 is 32 by 32 colour photographs in ten classes. It is small enough to train on a laptop and hard enough that the architecture genuinely matters, which is why it has been the standard teaching problem for a decade.
The data
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize(MEAN, STD)])
train_all = datasets.CIFAR10('data', train=True, download=True, transform=tf)
test_all = datasets.CIFAR10('data', train=False, download=True, transform=tf)
CLASSES = train_all.classes
train_set = Subset(train_all, range(4000))
val_set = Subset(test_all, range(2000))
print('training images', len(train_all))
print('classes:', CLASSES)
image, label = train_all[7]
print('\none example: shape %s, label %d (%s)'
% (tuple(image.shape), label, CLASSES[label]))
import collections
counts = collections.Counter(train_all.targets[:8000])
print('\nclass balance in the training subset:')
for i, name in enumerate(CLASSES):
print(' %-12s %d' % (name, counts[i]))
training images 50000
classes: ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']
one example: shape (3, 32, 32), label 7 (horse)
class balance in the training subset:
airplane 796
automobile 765
bird 817
cat 809
deer 806
dog 765
frog 832
horse 809
ship 809
truck 792
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize(MEAN, STD)])
train_all = datasets.CIFAR10('data', train=True, download=True, transform=tf)
test_all = datasets.CIFAR10('data', train=False, download=True, transform=tf)
CLASSES = train_all.classes
train_set = Subset(train_all, range(4000))
val_set = Subset(test_all, range(2000))
raw = datasets.CIFAR10('data', train=True, download=True)
image, label = raw[7]
grey = torch.tensor([[sum(image.getpixel((x, y))) / 765.0
for x in range(32)] for y in range(32)])
ramp = ' .:-=+*#%@'
print('%s, as brightness:' % CLASSES[label])
for row in grey:
print(''.join(ramp[min(9, int(v * 9.999))] for v in row.tolist()))
horse, as brightness:
...::..:::--:--::--::::.::....:.
....:.::::==:-=+++++===:----====
....-:.::=*+==+#***#************
...:-:...-=-=+*#######****######
--:.........:-+#%%%%##*++=**####
++:......:::---=*%%%#*+==--==*##
#+::...:::---==-=*#***+--::--*##
#+::. .-------===++++++=--::-*#*
%=.:. .------===++++====--:::+#*
%+.::..:--:===-==++==-==--::.=##
#=:::..:---=+=--=+===--=-:...-**
#=::..:.:-=++=--====-::--:...-**
#=:..=+::-====:-==---:::-:...-**
#+:.:*#*--=---::----:-::-:.. =##
#+::+#%%*-::::::---::::::... =#*
*--*%%###=.....---::::::. . =#*
#-+%%###%*....-==-:...:+:.=: -**
%##%%###%#-...--:.....=#- ++..*#
###%%#####*:..:-::..:=*#= =*: +#
####%######-..=#*+++*###+.:*= =#
###########= .+%%%######*.-#*.=#
###########= .*%########*:=##:=#
###########+ .*#######**+.=**-=#
*****####**+ .+####*****=.=**::*
**#**##****+. :*#***+++*-.=**:.*
*#####*****+.-.:=+***+++:.=*+::+
**####**#**+.==..=**+++=::=++--+
**#######**+.-+-.:=++++=-======+
****####***=:=++--=+++++++++=+=+
++***++***+==+++=+++++++++++++++
++*+++++***++++++**++++==+++++++
++++++++**+++++++++=++===+=++===
A dense baseline, so the comparison is honest
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize(MEAN, STD)])
train_all = datasets.CIFAR10('data', train=True, download=True, transform=tf)
test_all = datasets.CIFAR10('data', train=False, download=True, transform=tf)
CLASSES = train_all.classes
train_set = Subset(train_all, range(4000))
val_set = Subset(test_all, range(2000))
def fit(model, epochs=4, lr=0.05, train=None, log=False, seed=0):
torch.manual_seed(seed)
loader = DataLoader(train if train is not None else train_set,
batch_size=128, shuffle=True)
val = DataLoader(val_set, batch_size=512)
opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9,
weight_decay=5e-4)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=lr, total_steps=epochs * len(loader))
loss_fn = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
loss_fn(model(xb), yb).backward()
opt.step()
sched.step()
if log:
print(' epoch %d done' % (epoch + 1))
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in val:
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
torch.manual_seed(0)
dense = nn.Sequential(nn.Flatten(),
nn.Linear(3 * 32 * 32, 512), nn.ReLU(),
nn.Linear(512, 256), nn.ReLU(),
nn.Linear(256, 10))
print('parameters %d' % sum(p.numel() for p in dense.parameters()))
print('accuracy %.4f' % fit(dense, epochs=8))
parameters 1707274
accuracy 0.4310
The convolutional version
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize(MEAN, STD)])
train_all = datasets.CIFAR10('data', train=True, download=True, transform=tf)
test_all = datasets.CIFAR10('data', train=False, download=True, transform=tf)
CLASSES = train_all.classes
train_set = Subset(train_all, range(4000))
val_set = Subset(test_all, range(2000))
def fit(model, epochs=4, lr=0.05, train=None, log=False, seed=0):
torch.manual_seed(seed)
loader = DataLoader(train if train is not None else train_set,
batch_size=128, shuffle=True)
val = DataLoader(val_set, batch_size=512)
opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9,
weight_decay=5e-4)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=lr, total_steps=epochs * len(loader))
loss_fn = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
loss_fn(model(xb), yb).backward()
opt.step()
sched.step()
if log:
print(' epoch %d done' % (epoch + 1))
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in val:
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
torch.manual_seed(0)
cnn = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(),
nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(64 * 8 * 8, 256), nn.ReLU(),
nn.Linear(256, 10))
print('parameters %d' % sum(p.numel() for p in cnn.parameters()))
print('accuracy %.4f' % fit(cnn, epochs=8))
parameters 1116970
accuracy 0.4975
Fewer parameters, better result
This is the demonstration the whole week exists for, and it is worth sitting with. The convolutional model has fewer weights than the dense one and is substantially more accurate, on identical data, with an identical training recipe. The difference is not capacity. It is that one architecture knows pixels have neighbours and the other does not.
Watching the shapes
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
MEAN, STD = (0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize(MEAN, STD)])
train_all = datasets.CIFAR10('data', train=True, download=True, transform=tf)
test_all = datasets.CIFAR10('data', train=False, download=True, transform=tf)
CLASSES = train_all.classes
train_set = Subset(train_all, range(4000))
val_set = Subset(test_all, range(2000))
def fit(model, epochs=4, lr=0.05, train=None, log=False, seed=0):
torch.manual_seed(seed)
loader = DataLoader(train if train is not None else train_set,
batch_size=128, shuffle=True)
val = DataLoader(val_set, batch_size=512)
opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9,
weight_decay=5e-4)
sched = torch.optim.lr_scheduler.OneCycleLR(
opt, max_lr=lr, total_steps=epochs * len(loader))
loss_fn = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
loss_fn(model(xb), yb).backward()
opt.step()
sched.step()
if log:
print(' epoch %d done' % (epoch + 1))
model.eval()
right = seen = 0
with torch.no_grad():
for xb, yb in val:
right += (model(xb).argmax(1) == yb).sum().item()
seen += yb.numel()
return right / seen
torch.manual_seed(0)
layers = [nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(64 * 8 * 8, 10)]
x = torch.zeros(1, 3, 32, 32)
print('%-22s %-18s %10s' % ('layer', 'output', 'params'))
print('%-22s %-18s %10s' % ('input', str(tuple(x.shape[1:])), ''))
for layer in layers:
x = layer(x)
n = sum(p.numel() for p in layer.parameters())
print('%-22s %-18s %10d'
% (type(layer).__name__, str(tuple(x.shape[1:])), n))
layer output params
input (3, 32, 32)
Conv2d (32, 32, 32) 896
ReLU (32, 32, 32) 0
Conv2d (32, 32, 32) 9248
ReLU (32, 32, 32) 0
MaxPool2d (32, 16, 16) 0
Conv2d (64, 16, 16) 18496
ReLU (64, 16, 16) 0
MaxPool2d (64, 8, 8) 0
Flatten (4096,) 0
Linear (10,) 40970
Read the parameter column. Almost everything is in the final linear layer, because flattening an 8 by 8 by 64 map produces 4,096 numbers and connecting those to anything is expensive. That single observation is what global average pooling exists to fix, and day 5 measures it.
Day 3 takeaway
On real images a convolutional network beats a dense one of similar size comfortably, using fewer parameters. Print the shape and parameter count at every layer, and expect the flatten-then-linear step to dominate the parameter count unless you do something about it.