Convolutional Neural Networks (LeNet)

:label:sec_lenet

We now have all the ingredients required to assemble a fully-functional CNN. In our earlier encounter with image data, we applied a softmax regression model (:numref:sec_softmax_scratch) and an MLP model (:numref:sec_mlp_scratch) to pictures of clothing in the Fashion-MNIST dataset. To make such data amenable to softmax regression and MLPs, we first flattened each image from a $28\times28$ matrix into a fixed-length $784$-dimensional vector, and thereafter processed them with fully-connected layers. Now that we have a handle on convolutional layers, we can retain the spatial structure in our images. As an additional benefit of replacing fully-connected layers with convolutional layers, we will enjoy more parsimonious models that require far fewer parameters.

In this section, we will introduce LeNet, among the first published CNNs to capture wide attention for its performance on computer vision tasks. The model was introduced by (and named for) Yann LeCun, then a researcher at AT&T Bell Labs, for the purpose of recognizing handwritten digits in images :cite:LeCun.Bottou.Bengio.ea.1998. This work represented the culmination of a decade of research developing the technology. In 1989, LeCun published the first study to successfully train CNNs via backpropagation.

At the time LeNet achieved outstanding results matching the performance of support vector machines, then a dominant approach in supervised learning. LeNet was eventually adapted to recognize digits for processing deposits in ATM machines. To this day, some ATMs still run the code that Yann and his colleague Leon Bottou wrote in the 1990s!

LeNet

At a high level, LeNet (LeNet-5) consists of two parts: (i) a convolutional encoder consisting of two convolutional layers; and (ii) a dense block consisting of three fully-connected layers; The architecture is summarized in :numref:img_lenet.

Data flow in LeNet. The input is a handwritten digit, the output a probability over 10 possible outcomes. :label:img_lenet

The basic units in each convolutional block are a convolutional layer, a sigmoid activation function, and a subsequent average pooling operation. Note that while ReLUs and max-pooling work better, these discoveries had not yet been made in the 1990s. Each convolutional layer uses a $5\times 5$ kernel and a sigmoid activation function. These layers map spatially arranged inputs to a number of two-dimensional feature maps, typically increasing the number of channels. The first convolutional layer has 6 output channels, while the second has 16. Each $2\times2$ pooling operation (stride 2) reduces dimensionality by a factor of $4$ via spatial downsampling. The convolutional block emits an output with shape given by (batch size, number of channel, height, width).

In order to pass output from the convolutional block to the dense block, we must flatten each example in the minibatch. In other words, we take this four-dimensional input and transform it into the two-dimensional input expected by fully-connected layers: as a reminder, the two-dimensional representation that we desire has uses the first dimension to index examples in the minibatch and the second to give the flat vector representation of each example. LeNet’s dense block has three fully-connected layers, with 120, 84, and 10 outputs, respectively. Because we are still performing classification, the 10-dimensional output layer corresponds to the number of possible output classes.

While getting to the point where you truly understand what is going on inside LeNet may have taken a bit of work, hopefully the following code snippet will convince you that implementing such models with modern deep learning frameworks is remarkably simple. We need only to instantiate a Sequential block and chain together the appropriate layers.

```{.python .input} from d2l import mxnet as d2l from mxnet import autograd, gluon, init, np, npx from mxnet.gluon import nn npx.set_np()

net = nn.Sequential() net.add(nn.Conv2D(channels=6, kernel_size=5, padding=2, activation=’sigmoid’), nn.AvgPool2D(pool_size=2, strides=2), nn.Conv2D(channels=16, kernel_size=5, activation=’sigmoid’), nn.AvgPool2D(pool_size=2, strides=2),

  1. # `Dense` will transform an input of the shape (batch size, number of
  2. # channels, height, width) into an input of the shape (batch size,
  3. # number of channels * height * width) automatically by default
  4. nn.Dense(120, activation='sigmoid'),
  5. nn.Dense(84, activation='sigmoid'),
  6. nn.Dense(10))
  1. ```{.python .input}
  2. #@tab pytorch
  3. from d2l import torch as d2l
  4. import torch
  5. from torch import nn
  6. class Reshape(torch.nn.Module):
  7. def forward(self, x):
  8. return x.view(-1, 1, 28, 28)
  9. net = torch.nn.Sequential(
  10. Reshape(),
  11. nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
  12. nn.AvgPool2d(kernel_size=2, stride=2),
  13. nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
  14. nn.AvgPool2d(kernel_size=2, stride=2),
  15. nn.Flatten(),
  16. nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
  17. nn.Linear(120, 84), nn.Sigmoid(),
  18. nn.Linear(84, 10))

```{.python .input}

@tab tensorflow

from d2l import tensorflow as d2l import tensorflow as tf from tensorflow.distribute import MirroredStrategy, OneDeviceStrategy

def net(): return tf.keras.models.Sequential([ tf.keras.layers.Conv2D(filters=6, kernel_size=5, activation=’sigmoid’, padding=’same’), tf.keras.layers.AvgPool2D(pool_size=2, strides=2), tf.keras.layers.Conv2D(filters=16, kernel_size=5, activation=’sigmoid’), tf.keras.layers.AvgPool2D(pool_size=2, strides=2), tf.keras.layers.Flatten(), tf.keras.layers.Dense(120, activation=’sigmoid’), tf.keras.layers.Dense(84, activation=’sigmoid’), tf.keras.layers.Dense(10)])

  1. We took a small liberty with the original model,
  2. removing the Gaussian activation in the final layer.
  3. Other than that, this network matches
  4. the original LeNet-5 architecture.
  5. By passing a single-channel (black and white)
  6. $28 \times 28$ image through the network
  7. and printing the output shape at each layer,
  8. we can inspect the model to make sure
  9. that its operations line up with
  10. what we expect from :numref:`img_lenet_vert`.
  11. ![Compressed notation for LeNet-5.](/uploads/projects/d2l-ai-CN/img/lenet-vert.svg)
  12. :label:`img_lenet_vert`
  13. ```{.python .input}
  14. X = np.random.uniform(size=(1, 1, 28, 28))
  15. net.initialize()
  16. for layer in net:
  17. X = layer(X)
  18. print(layer.name, 'output shape:\t', X.shape)

```{.python .input}

@tab pytorch

X = torch.randn(size=(1, 1, 28, 28), dtype=torch.float32) for layer in net: X = layer(X) print(layer.class.name,’output shape: \t’,X.shape)

  1. ```{.python .input}
  2. #@tab tensorflow
  3. X = tf.random.uniform((1, 28, 28, 1))
  4. for layer in net().layers:
  5. X = layer(X)
  6. print(layer.__class__.__name__, 'output shape: \t', X.shape)

Note that the height and width of the representation at each layer throughout the convolutional block is reduced (compared with the previous layer). The first convolutional layer uses 2 pixels of padding to compensate for the reduction in height and width that would otherwise result from using a $5 \times 5$ kernel. In contrast, the second convolutional layer forgoes padding, and thus the height and width are both reduced by 4 pixels. As we go up the stack of layers, the number of channels increases layer-over-layer from 1 in the input to 6 after the first convolutional layer and 16 after the second convolutional layer. However, each pooling layer halves the height and width. Finally, each fully-connected layer reduces dimensionality, finally emitting an output whose dimension matches the number of classes.

Training

Now that we have implemented the model, let us run an experiment to see how LeNet fares on Fashion-MNIST.

```{.python .input}

@tab all

batch_size = 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

  1. While CNNs have fewer parameters,
  2. they can still be more expensive to compute
  3. than similarly deep MLPs
  4. because each parameter participates in many more
  5. multiplications.
  6. If you have access to a GPU, this might be a good time
  7. to put it into action to speed up training.
  8. :begin_tab:`mxnet, pytorch`
  9. For evaluation, we need to make a slight modification
  10. to the `evaluate_accuracy` function that we described
  11. in :numref:`sec_softmax_scratch`.
  12. Since the full dataset is in the main memory,
  13. we need to copy it to the GPU memory before the model uses GPU to compute with the dataset.
  14. :end_tab:
  15. ```{.python .input}
  16. def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
  17. """Compute the accuracy for a model on a dataset using a GPU."""
  18. if not device: # Query the first device where the first parameter is on
  19. device = list(net.collect_params().values())[0].list_ctx()[0]
  20. # No. of correct predictions, no. of predictions
  21. metric = d2l.Accumulator(2)
  22. for X, y in data_iter:
  23. X, y = X.as_in_ctx(device), y.as_in_ctx(device)
  24. metric.add(d2l.accuracy(net(X), y), d2l.size(y))
  25. return metric[0] / metric[1]

```{.python .input}

@tab pytorch

def evaluate_accuracy_gpu(net, data_iter, device=None): #@save “””Compute the accuracy for a model on a dataset using a GPU.””” net.eval() # Set the model to evaluation mode if not device: device = next(iter(net.parameters())).device

  1. # No. of correct predictions, no. of predictions
  2. metric = d2l.Accumulator(2)
  3. for X, y in data_iter:
  4. X, y = X.to(device), y.to(device)
  5. metric.add(d2l.accuracy(net(X), y), d2l.size(y))
  6. return metric[0] / metric[1]
  1. We also need to update our training function to deal with GPUs.
  2. Unlike the `train_epoch_ch3` defined in :numref:`sec_softmax_scratch`,
  3. we now need to move each minibatch of data
  4. to our designated device (hopefully, the GPU)
  5. prior to making the forward and backward propagations.
  6. The training function `train_ch6` is also similar
  7. to `train_ch3` defined in :numref:`sec_softmax_scratch`.
  8. Since we will be implementing networks with many layers
  9. going forward, we will rely primarily on high-level APIs.
  10. The following training function assumes a model created from high-level APIs
  11. as input and is optimized accordingly.
  12. We initialize the model parameters
  13. on the device indicated by the `device` argument, using Xavier initialization as introduced in :numref:`subsec_xavier`.
  14. Just as with MLPs, our loss function is cross-entropy,
  15. and we minimize it via minibatch stochastic gradient descent.
  16. Since each epoch takes tens of seconds to run,
  17. we visualize the training loss more frequently.
  18. ```{.python .input}
  19. #@save
  20. def train_ch6(net, train_iter, test_iter, num_epochs, lr,
  21. device=d2l.try_gpu()):
  22. """Train a model with a GPU (defined in Chapter 6)."""
  23. net.initialize(force_reinit=True, ctx=device, init=init.Xavier())
  24. loss = gluon.loss.SoftmaxCrossEntropyLoss()
  25. trainer = gluon.Trainer(net.collect_params(),
  26. 'sgd', {'learning_rate': lr})
  27. animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
  28. legend=['train loss', 'train acc', 'test acc'])
  29. timer, num_batches = d2l.Timer(), len(train_iter)
  30. for epoch in range(num_epochs):
  31. # Sum of training loss, sum of training accuracy, no. of examples
  32. metric = d2l.Accumulator(3)
  33. for i, (X, y) in enumerate(train_iter):
  34. timer.start()
  35. # Here is the major difference from `d2l.train_epoch_ch3`
  36. X, y = X.as_in_ctx(device), y.as_in_ctx(device)
  37. with autograd.record():
  38. y_hat = net(X)
  39. l = loss(y_hat, y)
  40. l.backward()
  41. trainer.step(X.shape[0])
  42. metric.add(l.sum(), d2l.accuracy(y_hat, y), X.shape[0])
  43. timer.stop()
  44. train_l = metric[0] / metric[2]
  45. train_acc = metric[1] / metric[2]
  46. if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
  47. animator.add(epoch + (i + 1) / num_batches,
  48. (train_l, train_acc, None))
  49. test_acc = evaluate_accuracy_gpu(net, test_iter)
  50. animator.add(epoch + 1, (None, None, test_acc))
  51. print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
  52. f'test acc {test_acc:.3f}')
  53. print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
  54. f'on {str(device)}')

```{.python .input}

@tab pytorch

@save

def trainch6(net, train_iter, test_iter, num_epochs, lr, device=d2l.try_gpu()): “””Train a model with a GPU (defined in Chapter 6).””” def init_weights(m): if type(m) == nn.Linear or type(m) == nn.Conv2d: torch.nn.init.xavier_uniform(m.weight) net.apply(init_weights) print(‘training on’, device) net.to(device) optimizer = torch.optim.SGD(net.parameters(), lr=lr) loss = nn.CrossEntropyLoss() animator = d2l.Animator(xlabel=’epoch’, xlim=[1, num_epochs], legend=[‘train loss’, ‘train acc’, ‘test acc’]) timer, num_batches = d2l.Timer(), len(train_iter) for epoch in range(num_epochs):

  1. # Sum of training loss, sum of training accuracy, no. of examples
  2. metric = d2l.Accumulator(3)
  3. for i, (X, y) in enumerate(train_iter):
  4. timer.start()
  5. net.train()
  6. optimizer.zero_grad()
  7. X, y = X.to(device), y.to(device)
  8. y_hat = net(X)
  9. l = loss(y_hat, y)
  10. l.backward()
  11. optimizer.step()
  12. with torch.no_grad():
  13. metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
  14. timer.stop()
  15. train_l = metric[0]/metric[2]
  16. train_acc = metric[1]/metric[2]
  17. if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
  18. animator.add(epoch + (i + 1) / num_batches,
  19. (train_l, train_acc, None))
  20. test_acc = evaluate_accuracy_gpu(net, test_iter)
  21. animator.add(epoch + 1, (None, None, test_acc))
  22. print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
  23. f'test acc {test_acc:.3f}')
  24. print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
  25. f'on {str(device)}')
  1. ```{.python .input}
  2. #@tab tensorflow
  3. class TrainCallback(tf.keras.callbacks.Callback): #@save
  4. """A callback to visiualize the training progress."""
  5. def __init__(self, net, train_iter, test_iter, num_epochs, device_name):
  6. self.timer = d2l.Timer()
  7. self.animator = d2l.Animator(
  8. xlabel='epoch', xlim=[1, num_epochs], legend=[
  9. 'train loss', 'train acc', 'test acc'])
  10. self.net = net
  11. self.train_iter = train_iter
  12. self.test_iter = test_iter
  13. self.num_epochs = num_epochs
  14. self.device_name = device_name
  15. def on_epoch_begin(self, epoch, logs=None):
  16. self.timer.start()
  17. def on_epoch_end(self, epoch, logs):
  18. self.timer.stop()
  19. test_acc = self.net.evaluate(
  20. self.test_iter, verbose=0, return_dict=True)['accuracy']
  21. metrics = (logs['loss'], logs['accuracy'], test_acc)
  22. self.animator.add(epoch + 1, metrics)
  23. if epoch == self.num_epochs - 1:
  24. batch_size = next(iter(self.train_iter))[0].shape[0]
  25. num_examples = batch_size * tf.data.experimental.cardinality(
  26. self.train_iter).numpy()
  27. print(f'loss {metrics[0]:.3f}, train acc {metrics[1]:.3f}, '
  28. f'test acc {metrics[2]:.3f}')
  29. print(f'{num_examples / self.timer.avg():.1f} examples/sec on '
  30. f'{str(self.device_name)}')
  31. #@save
  32. def train_ch6(net_fn, train_iter, test_iter, num_epochs, lr,
  33. device=d2l.try_gpu()):
  34. """Train a model with a GPU (defined in Chapter 6)."""
  35. device_name = device._device_name
  36. strategy = tf.distribute.OneDeviceStrategy(device_name)
  37. with strategy.scope():
  38. optimizer = tf.keras.optimizers.SGD(learning_rate=lr)
  39. loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
  40. net = net_fn()
  41. net.compile(optimizer=optimizer, loss=loss, metrics=['accuracy'])
  42. callback = TrainCallback(net, train_iter, test_iter, num_epochs,
  43. device_name)
  44. net.fit(train_iter, epochs=num_epochs, verbose=0, callbacks=[callback])
  45. return net

Now let us train and evaluate the LeNet-5 model.

```{.python .input}

@tab all

lr, num_epochs = 0.9, 10 train_ch6(net, train_iter, test_iter, num_epochs, lr) ```

Summary

  • A CNN is a network that employs convolutional layers.
  • In a CNN, we interleave convolutions, nonlinearities, and (often) pooling operations.
  • In a CNN, convolutional layers are typically arranged so that they gradually decrease the spatial resolution of the representations, while increasing the number of channels.
  • In traditional CNNs, the representations encoded by the convolutional blocks are processed by one or more fully-connected layers prior to emitting output.
  • LeNet was arguably the first successful deployment of such a network.

Exercises

  1. Replace the average pooling with max pooling. What happens?
  2. Try to construct a more complex network based on LeNet to improve its accuracy.
    1. Adjust the convolution window size.
    2. Adjust the number of output channels.
    3. Adjust the activation function (e.g., ReLU).
    4. Adjust the number of convolution layers.
    5. Adjust the number of fully connected layers.
    6. Adjust the learning rates and other training details (e.g., initialization and number of epochs.)
  3. Try out the improved network on the original MNIST dataset.
  4. Display the activations of the first and second layer of LeNet for different inputs (e.g., sweaters and coats).

:begin_tab:mxnet Discussions :end_tab:

:begin_tab:pytorch Discussions :end_tab:

:begin_tab:tensorflow Discussions :end_tab: