[Deep Leaning] [Tutorial] Classification on MNIST Dataset

文章目录

  • Importing Packages
  • Data Loading
    • Defining Dataloaders
    • Sanity Check of the Dataset
  • Model Definition
  • Loss Function Definition
  • Optimization Method
  • Training and Testing Procedures
  • Runtime
    • Performance before any optimization
    • Performance after 1 iteration of optimization
    • Performance after 100 iterations of optimization
    • Performance after 5 epochs of optimization
  • Visualize Feature Maps in CNNs


Importing Packages

%matplotlib inline
from __future__ import print_function
import matplotlib.pyplot as plt
import numpy as np
import time
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

from torchvision import datasets, transforms
from sklearn.metrics import confusion_matrix
from datetime import timedelta

torch.__version__

Data Loading

Defining Dataloaders

use_cuda = True if torch.cuda.is_available() else False
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print('We are using GPU.' if use_cuda else 'We are using CPU.')

MNIST数据集大小约为12MB,如果在给定路径下找不到该数据集,它将被自动下载。该数据集包含70,000个图像和相应的标签。

我们需要定义两个数据加载器,一个用于训练,一个用于测试。在训练过程中,批处理大小设置为16。

kwargs = {'num_workers': 1, 'pin_memory': True} if use_cuda else {}
kwargs['batch_size'] = 16
'''
设置num_workers为1和pin_memory为True是为了提高数据加载到GPU的速度。
这两个参数和PyTorch的DataLoader类有关,它负责在训练过程中有效地加载数据。

num_workers:
这个参数决定了用于数据加载的子进程的数量。
将num_workers设置为1意味着使用一个子进程来加载数据。
增加num_workers的值可以进一步提高数据加载速度,但同时也会占用更多的CPU资源。
选择合适数字取决于CPU资源和I/O限制。
为了充分利用GPU,可以尝试逐渐增加num_workers的值,直到达到最佳性能。
请注意,设置num_workers为0将在主进程中进行数据加载,这可能会降低整体性能。

pin_memory:
将此参数设置为True可以将数据存储在固定(或锁定)内存中。
这意味着当数据从CPU传输到GPU时,不会发生内存拷贝,从而减少了数据加载时间。
这在使用GPU训练模型时特别有用。
然而,锁定内存会占用系统的可用RAM,因此需要权衡资源利用率。

综上所述,可以尝试将num_workers设置为其他正整数值以优化数据加载速度,但要注意不要耗尽CPU资源。
同时,pin_memory在使用GPU时通常应设置为True,以提高数据传输效率。
在CPU训练时,将pin_memory设置为False可以节省RAM资源。
'''

# Using torch.utils.data.DataLoader for efficient dataloading during runtime.
train_loader = torch.utils.data.DataLoader(
    datasets.MNIST('../data', train=True, download=True,
                   transform=transforms.Compose([transforms.ToTensor(),
                                                 transforms.Normalize((0.1307,), (0.3081,))])),
                                           shuffle=True, **kwargs)
'''
torch.utils.data.DataLoader:
这是PyTorch提供的一个数据加载器类,用于加载数据并将其分成批次(batches)。

datasets.MNIST():
这是一个用于加载MNIST手写数字数据集的类。它有以下参数:
    root='../data':数据集的根目录。在这里,数据集将被下载到当前目录下的"data"文件夹中。
    train=True:表示加载训练数据。如果设置为False,则加载测试数据。
    download=True:表示如果数据集不存在,则自动下载数据集。
    transform=transforms.Compose([...]):这里定义了一个图像预处理的pipeline。在这个例子中,有两个预处理步骤:
        transforms.ToTensor():将图像转换为PyTorch张量(Tensor)。
        transforms.Normalize((0.1307,), (0.3081,)):对图像进行归一化。这里使用的均值是0.1307,标准差是0.3081。
        
shuffle=True:在每个训练周期(epoch)开始时,随机打乱数据集的顺序。
'''
test_loader = torch.utils.data.DataLoader(
    datasets.MNIST('../data', train=False, 
                   transform=transforms.Compose([transforms.ToTensor(),
                                                 transforms.Normalize((0.1307,), (0.3081,))])),
                                          shuffle=False, **kwargs)
print('Dataloaders initialized.')

Sanity Check of the Dataset

Print some basic information about the dataset.

print('{} examples in the training set.'.format(len(train_loader) * 16))
print('{} examples in the testing set.'.format(len(test_loader) * 16))

b_imgs, b_labels = next(iter(train_loader))
'''
next() 和 iter() 是 Python 中用于处理迭代器(iterator)的内置函数。
通过使用 iter() 函数,train_loader 被转换为一个迭代器对象。
然后,next() 函数被调用,以获取迭代器的下一个元素,即训练数据的批次。
这样,b_imgs 和 b_labels 就分别包含了训练数据批次的图像和标签。
'''
print('A batch of imgs shape:', b_imgs.size())
print('A batch of labels shape:', b_labels.size())
print('label batch:', b_labels)

Show some images and labels to ensure they are paired.

def plot_images(images, cls_true, img_shape=None, cls_pred=None):
    assert len(images) == len(cls_true) == 9

    # Create figure with 3x3 sub-plots.
    fig, axes = plt.subplots(3, 3)
    fig.subplots_adjust(hspace=0.3, wspace=0.3)

    for i, ax in enumerate(axes.flat):
        # Plot image.
        ax.imshow(images[i].reshape((28,28)), cmap='binary')

        # Show true and predicted classes.
        if cls_pred is None:
            xlabel = "True: {0}".format(cls_true[i])
        else:
            xlabel = "True: {0}, Pred: {1}".format(cls_true[i], cls_pred[i])

        # Show the classes as the label on the x-axis.
        ax.set_xlabel(xlabel)

        # Remove ticks from the plot.
        ax.set_xticks([])
        ax.set_yticks([])

    # Ensure the plot is shown correctly with multiple plots
    # in a single Notebook cell.
    plt.show()

# Plot a few images to see if data is correct
images = b_imgs[:9].numpy()
cls_true = b_labels[:9].numpy()

plot_images(images=images, cls_true=cls_true)

Model Definition

Models defined using PyTorch toolkit should be a class inheriting from torch.nn.Module.

在__init__方法中,我们应该实例化在前向传播过程中将使用的子模块(例如nn.Conv2d,nn.Linear)。这些子模块应该作为成员变量通过self引用,例如self.conv1,self.fc1。

nn.Conv2d层应该使用参数(in_channels,out_channels,kernel_size,stride=1,padding=0,dilation=1,groups=1,bias=True,padding_mode=‘zeros’)进行实例化。in_channels表示该层的输入通道数。out_channels表示该层的输出通道数。kernel_size是卷积核的大小。可以在这里找到该模块的详细信息。

nn.Linear层应该使用参数(in_features,out_features,bias=True)进行实例化。可以在这里找到该模块的详细信息。
请注意,在nn.Dropout2d中,参数是元素被置零的概率,而不是保留的概率。

forward方法定义了当输入x被馈送到模型中时,该模型的运行时行为。不包含可学习权重的函数,如ReLU、MaxPooling,可以直接在此forward方法中使用(例如F.relu,F.max_pool2d),而不是在__init__中实例化。

class MnistConvNet(nn.Module):
    def __init__(self, return_fmaps=False):
        super(MnistConvNet, self).__init__()
        '''
        这个操作是PyTorch模型定义的必须操作。
        调用super(MnistConvNet, self).init()相当于调用nn.Module的构造函数__init__(),这样模型就可以拥有nn.Module的基本功能和属性,例如计算图的构建、反向传播、参数优化等。
        调用super()函数时,需要传递当前类的名称和实例对象作为参数。
        这是因为super()函数需要确定当前类的方法解析顺序(MRO, Method Resolution Order),以便在调用父类方法时正确地查找继承链中的下一个类。
        '''
        self.conv1 = nn.Conv2d(1, 32, 7, stride=1, padding=3)
        '''
        Conv1d(一维卷积):
        一维卷积主要用于处理序列数据,如时间序列、文本或音频信号。
        在一维卷积中,卷积核沿着输入数据的一个维度(通常是长度)滑动。

        Conv2d(二维卷积):
        torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True)
        二维卷积主要用于处理图像数据。
        在二维卷积中,卷积核沿着输入数据的两个维度(通常是高度和宽度)滑动。

        Conv3d(三维卷积):
        三维卷积主要用于处理体数据(volumetric data)和视频数据。
        在三维卷积中,卷积核沿着输入数据的三个维度(通常是深度、高度和宽度)滑动。
        '''
        self.conv2 = nn.Conv2d(32, 64, 5, stride=1, padding=2)
        self.dropout1 = nn.Dropout2d(0.25)
        self.dropout2 = nn.Dropout(0.5)
        '''
        nn.Dropout2d()通常用于二维特征图,主要用在卷积神经网络的卷积层。其作用是随机将整个通道的值置为0。

        nn.Dropout()则是在所有的输入特征上独立地工作,将每个元素以一定的概率置为0。这种方法通常用于全连接层或者一维的特征向量。
        '''
        self.fc1 = nn.Linear(7*7*64, 128) # 64是通道数,7x7是高度和宽度
        self.fc2 = nn.Linear(128, 10)
        self.return_fmaps = return_fmaps

    def set_return_fmaps(self, v=True):
        self.return_fmaps = v

    def forward(self, x):
        fmaps = []
        x = self.conv1(x)
        fmaps.append(x)
        print('after conv1, x.size:', x.size())
        x = F.relu(x)  # Functions like ReLU, MaxPooling can be used in forward method as there is no weights in them to store.
        x = F.max_pool2d(x, 2)
        '''
        torch.nn.functional.max_pool2d(input, kernel_size, stride=None, padding=0, dilation=1, ceil_mode=False, return_indices=False)
        如果输入特征图的宽度和高度都是偶数,池化后的特征图尺寸将减半,即宽度和高度都除以2。
        如果输入特征图的宽度和高度有一个是奇数,池化后的特征图尺寸将向下取整,即宽度和高度除以2,并且最后一行或最后一列的像素将被舍弃。
        '''
        print('after pool1, x.size:', x.size())
        x = self.conv2(x)
        fmaps.append(x)
        print('after conv2, x.size:', x.size())
        x = F.relu(x)
        x = F.max_pool2d(x, 2)
        print('after pool2, x.size:', x.size())
        x = self.dropout1(x) # 并不会改变特征图的尺寸
        x = torch.flatten(x, 1)
        print('after flatten, x.size:', x.size())
        x = self.fc1(x)
        print('after fc1, x.size:', x.size())
        x = F.relu(x)
        '''
        在神经网络中,通常先计算线性变换(如全连接层或卷积层),然后应用激活函数。
        这种顺序使得模型能够学习非线性特征,并有助于神经网络的训练和收敛。
        '''
        x = self.dropout2(x)
        logits = self.fc2(x)
        '''
        logits = self.fc2(x)表示将经过第一个全连接层和ReLU激活函数处理后的输出特征x,输入到第二个全连接层中进行线性变换,得到模型的输出logits。
        在这个网络中,self.fc2(x)表示将第一个全连接层的输出特征x输入到第二个全连接层中,得到模型的输出logits。
        此时,logits是一个二维张量,其中的每一行代表了一个输入样本对应的预测概率分布,每个元素代表了该样本属于相应类别的概率。
        模型的输出logits将会被用于计算模型的损失函数和进行模型的预测。
        需要注意的是,在这个网络中,模型的输出层并没有经过激活函数处理。
        这是因为在使用交叉熵损失函数进行多类别分类时,通常会将模型的输出层视作未归一化的对数概率(logits)输出,而不是使用softmax等激活函数对输出进行归一化处理。
        这种做法可以提高数值稳定性和训练效率,同时避免了softmax中的数值溢出问题。
        '''
        print('logits.size:', logits.size())
        if self.return_fmaps:
            return logits, fmaps
        else:
            return logits

通过指定适当的 start_dim 参数,你可以控制从哪个维度开始展平,以便根据需要重新组织张量的形状。

如果不提供 start_dim 参数,则默认从第一个维度(索引为 0)开始展平。

# 输入张量的形状是 (2, 3, 4)
a = torch.tensor([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]],
                [[13, 14, 15, 16], [17, 18, 19, 20], [21, 22, 23, 24]]])

print(a)
# 输出:
# tensor([[[ 1,  2,  3,  4],
#          [ 5,  6,  7,  8],
#          [ 9, 10, 11, 12]],
#
#         [[13, 14, 15, 16],
#          [17, 18, 19, 20],
#          [21, 22, 23, 24]]])

flattened = torch.flatten(a, start_dim=1)
'''
通过指定适当的 start_dim 参数,你可以控制从哪个维度开始展平,以便根据需要重新组织张量的形状。
如果不提供 start_dim 参数,则默认从第一个维度(索引为 0)开始展平。
'''
print(flattened)
# 输出:
# tensor([[ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12],
#         [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]])

print(flattened.shape)
# 输出:
# torch.Size([2, 12])

通过 torch.flatten(a, start_dim=1),我们从索引为 1 的维度开始展平。

展平后,输出张量 flattened 的形状为 (2, 12),其中第一个维度保持不变,而第二个维度将 3 和 4 这两个维度展平为一维,形成了长度为 12 的新维度。

Now we can instantiate our CNN model.

model = MnistConvNet().to(device)
print('Model initialized.')

Loss Function Definition

We define the cross-entropy loss for this task.

交叉熵是分类任务中使用的一种损失函数。该损失函数是一个可微的函数,始终为正,并在模型的预测完全匹配目标值时达到最小值。在训练过程中,我们的模型权重会被更新以最小化该损失函数。

PyTorch内置了一个用于计算交叉熵损失的函数。需要注意的是,该函数在内部将softmax函数和交叉熵损失结合为单个操作,以提高效率,因此在模型定义中我们不需要手动使用softmax函数。

cross_entropy = nn.CrossEntropyLoss()

Optimization Method

We define the Adam optimization method in this task.

现在我们有一个需要最小化的损失函数,我们可以创建一个优化器。在这种情况下,我们使用Adam优化器,它是梯度下降的一种高级变体。

lr = 0.001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)

Training and Testing Procedures

We now define training and testing procedures which handles the runtime of the optimization.

首先,我们定义了训练的单个迭代过程,它使用一批数据来训练模型。数据批次被输入到模型中,根据模型的输出计算损失函数。然后,通过将损失函数进行反向传播(loss.backward)计算参数的梯度,并更新参数(optimizer.step)。

然后,我们定义了一个训练的周期(epoch),它将整个数据集正向和反向地通过模型一次。

def train_iter(log_interval, model, device, optimizer, loss_func, data, target):
    '''
    Train the model for a single iteration.
    An iteration is when a single batch of data is passed forward and 
    backward through the neural network.
    '''
    data, target = data.to(device), target.to(device)  # Move this batch of data to the specified device.
    optimizer.zero_grad()  # Zero out the old gradients (so we only use new gradients for a new update iteration).
    output = model(data)  # Forward the data through the model.
    loss = loss_func(output, target)  # Calculate the loss
    loss.backward()  # Backward the loss and calculate gradients for parameters.
    optimizer.step()  # Update the parameters.
    return loss

def train_epoch(log_interval, model, device, train_loader, optimizer, epoch, loss_func):
    '''
    Train the model for an epoch.
    An epoch is when the entire dataset is passed forward and 
    backward through the neural network for once.
    The number of batches in a dataset is equal to number of iterations for one epoch.
    '''
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):  # Iterate through the entire dataset to form an epoch.
        loss = train_iter(log_interval, model, device, optimizer, loss_func, data, target)  # Train for an iteration.
        if batch_idx % log_interval == 0:
            print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
                epoch, batch_idx * len(data), len(train_loader.dataset),
                100. * batch_idx / len(train_loader), loss.item()))

The testing procedure is by taking the predictions of our model on the test set and calculate the accuracy.

def test(model, device, test_loader, loss_func):
    '''
    Testing the model on the entire test set.
    '''
    model.eval()  # Switch the model to evaluation mode, which prevents the dropout behavior.
    test_loss = 0
    correct = 0
    with torch.no_grad():  # Because this is testing and no optimization is required, the gradients are not needed.
        for data, target in test_loader:  # Iterate through the entire test set.
            data, target = data.to(device), target.to(device)  # Move this batch of data to the specified device.
            output = model(data)  # Forward the data through the model.
            test_loss += target.size(0)*loss_func(output, target).item()  # Sum up batch loss
            pred = output.argmax(dim=1, keepdim=True)  # Get the index of the max log-probability
            correct += pred.eq(target.view_as(pred)).sum().item()  # Count the correct predictions.

    test_loss /= len(test_loader.dataset)  # Average the loss on the entire testing set.

    print('\nTest set: Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(
        test_loss, correct, len(test_loader.dataset),
        100. * correct / len(test_loader.dataset)))

Runtime

Performance before any optimization

We first show the accuracy of a randomly initialized model on test set. The accuracy is around 10% as it is just a random guess.

test(model, device, test_loader, cross_entropy)

Performance after 1 iteration of optimization

log_interval = 1
train_data_iter = iter(train_loader)

model.train()
data, target = next(train_data_iter)
train_iter(log_interval, model, device, optimizer, cross_entropy, data, target)
test(model, device, test_loader, cross_entropy)

Performance after 100 iterations of optimization

log_interval = 10
model.train()
for batch_idx in range(100):
    data, target = next(train_data_iter)
    loss = train_iter(log_interval, model, device, optimizer, cross_entropy, data, target)
    if batch_idx % log_interval == 0:
        print('Train iter: {}\tLoss: {:.6f}'.format(
            batch_idx, loss.item()))
test(model, device, test_loader, cross_entropy)

Performance after 5 epochs of optimization

log_interval = 200
for epoch in range(5):
    train_epoch(log_interval, model, device, train_loader, optimizer, epoch, cross_entropy)
    test(model, device, test_loader, cross_entropy)

Visualize Feature Maps in CNNs

def plot_feature_maps(fmaps):
    assert len(fmaps) == 25

    # Create figure with 5x5 sub-plots.
    fig, axes = plt.subplots(5, 5, figsize=(7,7))
    fig.subplots_adjust(hspace=0.1, wspace=0.1)

    for i, ax in enumerate(axes.flat):
        # Normalize the feature maps for plotting.
        f_min, f_max = fmaps[i].min(), fmaps[i].max()
        normed_fmap = (fmaps[i] - f_min) / (f_max - f_min)

        # Plot image.
        ax.imshow(normed_fmap, cmap='binary')

        # Remove ticks from the plot.
        ax.set_xticks([])
        ax.set_yticks([])

    # Ensure the plot is shown correctly with multiple plots
    # in a single Notebook cell.
    plt.show()

model.set_return_fmaps(True)  # Set return feature maps.
b_imgs, _ = next(iter(train_loader))
_, b_fmaps = model(b_imgs.to(device))

# For Conv1 feature maps:
fmaps_conv1 = b_fmaps[0][0, :25].detach().cpu().numpy()  # Convert the pytorch variable to a numpy array.
plot_feature_maps(fmaps_conv1)

# For Conv2 feature maps:
fmaps_conv2 = b_fmaps[1][0, :25].detach().cpu().numpy()  # Convert the pytorch variable to a numpy array.
plot_feature_maps(fmaps_conv2)

正如我们所预期的,较低层的特征图具有更高的分辨率,而较深层的特征图具有较低的分辨率。对于特征图的不同通道(例如,25个小图像的第一个网格),它们看起来像是模糊版本的输入图像,并突出显示了不同的特征。

你可能感兴趣的:(深度学习,pytorch,cnn)