CNN识别MNIST手写字

发布于 2023-08-29  312 次阅读


CNN识别MNIST手写字

开源地址:https://github.com/MYJOKERML/CNN

学了这么久,终于可以自己搭建一个CNN了,还记得大一时看了好多次这样的代码,然后全都无功而返。现在会看去年的自己,哈,这小家伙居然连个python代码都看不懂,连个class都不会。也许再等一年后再次回头时我可以笑着说,哈,一年前的自己居然看一个小时论文要翻半个小时字典,自己的进步真的肉眼可见啊,也得益于日益进步的科技,在copilot的协助下,现在普通的代码已经主要以理清逻辑为主了,人类的生产力又再一次有了质的飞跃,真的了不起。

话不多说,直接上代码记录自己的学习过程。

  1. 导入相关包

    import torch
    from torch import nn, optim
    from torchvision import datasets, transforms, models
    from torch.utils.data import DataLoader, random_split
    
    from matplotlib import pyplot as plt
    from PIL import Image
  2. 划分数据集并实现数据增强

    这里数据增强我才用了常见的数据增强办法,随机旋转图片和平移图片,以及颜色变换。我注释掉了随机水平翻转,因为注意到很多数字水平翻转后并没有任何意义。

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    # 定义数据预处理转换,包括数据增强操作
    transform = transforms.Compose([
       transforms.RandomRotation(degrees=15),  # 随机旋转
       transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),  # 随机平移
       transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),  # 随机颜色变换
       # transforms.RandomHorizontalFlip(),  # 随机水平翻转
       transforms.ToTensor(),
    ])
    
    dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
    
    # 定义训练集和测试集的比例
    train_ration = 0.8
    test_ration = 1 - train_ration
    
    # 计算训练集和测试集的数量
    train_size = int(train_ration * len(dataset))
    test_size = len(dataset) - train_size
    
    # 按照计算的数量随机划分训练集和测试集
    train_dataset, test_dataset = random_split(dataset, [train_size, test_size])
  3. 加载数据集并查看第一张图片

    # 加载数据并查看数据
    # from einops import rearrange
    
    BATCH_SIZE = 64
    train_loader = DataLoader(dataset=train_dataset, batch_size=BATCH_SIZE, shuffle=True)
    test_loader = DataLoader(dataset=test_dataset, batch_size=BATCH_SIZE, shuffle=False)
    
    for i, (images, labels) in enumerate(train_loader):
       print(i, images.shape, labels.shape)
       plt.imshow(images[0][0], cmap='gray')
       plt.title(labels[0].item())
       break
  4. 搭建卷积神经网络

    卷积核一般是3*3的大小,于是令padding=1就可以保持原图像大小不变。

    最开始只搭了两层卷积层,然后2层全连接层,事实证明这样非常容易过拟合,通常第一轮epoch结束后就有90%几的正确率,5轮之后会有97%的正确率,显然过拟合了,我自己手写了一张 8 的图片,训练了好多次一直都是判断为 0 。。。

    后来我想明白了,结果不理想的原因是网络深度太浅,根本提取不到足够大小范围的图片,于是后面搭了4层卷积层,3层全连接层,效果确实变好了。

    最加深网络时特征提取多了(即 out_channels 设大了),于是模型很难训练,正确率一直在70%几上不去,想了想应该是因为本来图片尺寸就小28*28,又是灰度图,没这么多特征,所以最后输出通道数就比较小,成功改善了结果。最后训练出来正确率在94%左右。

    本来一般是有加池化层的,但现在算力足够了,也就没加池化层,完全可以直接算。

    class CNN(nn.Module):
       def __init__(self):
           super(CNN, self).__init__()
           # 卷积层提取图片特征
           self.conv1 = nn.Sequential(
               nn.Conv2d(1, 16, 3, 1, 1),
               nn.ReLU()
           )
           self.conv2 = nn.Sequential(
               nn.Conv2d(16, 16, 3, 1, 1),
               nn.ReLU()
           )
    
           # 添加更多的卷积层
           self.conv3 = nn.Sequential(
               nn.Conv2d(16, 32, 3, 1, 1),
               nn.ReLU()
           )
           self.conv4 = nn.Sequential(
               nn.Conv2d(32, 32, 3, 1, 1),
               nn.ReLU()
           )
    
           # 添加池化层
           # self.pool = nn.MaxPool2d(2, 2)
    
           self.fc_input_size = 32 * 28 * 28
    
           self.fc1 = nn.Sequential(nn.Linear(self.fc_input_size, 256), nn.Dropout(0.2), nn.ReLU())
           self.fc2 = nn.Sequential(nn.Linear(256, 128), nn.Dropout(0.2), nn.ReLU())
           self.fc3 = nn.Sequential(nn.Linear(128, 10), nn.Softmax(dim=1))
    
       def forward(self, x):
           x = self.conv1(x)
           # x = self.pool(x)
           x = self.conv2(x)
           # x = self.pool(x)
           x = self.conv3(x)
           # x = self.pool(x)
           x = self.conv4(x)
           # x = self.pool(x)
    
           x = x.view(x.shape[0], -1)
    
           x = self.fc1(x)
           x = self.fc2(x)
           x = self.fc3(x)
    
           return x
  5. 定义损失函数和优化器

    # 定义损失函数和优化器
    model = CNN()
    model = model.to(device)
    criterion = nn.CrossEntropyLoss()
    lr=0.001 # 学习率
    optimizer = optim.Adam(model.parameters(), lr)
  6. 训练模型和测试模型

    from tqdm import tqdm
    
    best_loss = float('inf')  # 初始化为正无穷大,确保第一个损失值一定会小于它
    
    # 训练模型
    def train():
       model.train()
       total_loss = 0 # 用于计算平均损失
       num_batches = len(train_loader) # 用于记录训练的batch数目
    
       with tqdm(total=num_batches, desc='Training', unit='batch') as pbar:
           for i, data in enumerate(train_loader):
               inputs, labels = data
               inputs, labels = inputs.to(device), labels.to(device)
               optimizer.zero_grad() # 梯度清零
               outputs = model(inputs)
               loss = criterion(outputs, labels)
               loss.backward() # 反向传播
               optimizer.step() # 更新参数
    
               total_loss += loss.item()
               current_loss = loss.item()
    
               if i % 100 == 0:
                   # print('Train Step: {}\tLoss: {:.3f}'.format(i, loss.item()))
                   avg_loss = total_loss / (i + 1)
                   pbar.set_postfix({'cur_loss': '{:.3f}'.format(loss), 'avg_loss':'{:.3f}'.format(avg_loss)})
                   pbar.update(100) # 每处理100个batch更新一次tqdm
                   # 判断当前损失是否比最佳损失小,如果是则保存模型状态
               if current_loss < best_loss:
                   best_loss = current_loss
                   torch.save(model.state_dict(), 'best_model.pth')  # 保存模型
    
       print('Best loss:', best_loss)            
    
    # 测试模型
    def test():
       model.eval()
       correct = 0
       total = 0
       with torch.no_grad(): # 测试过程中不需要计算梯度
           with tqdm(total=len(test_loader), desc="Testing", unit="batch") as pbar:
               for data in test_loader:
                   inputs, labels = data
                   inputs, labels = inputs.to(device), labels.to(device)
                   outputs = model(inputs)
                   _, predicted = torch.max(outputs.data, dim=1)
                   total += labels.size(0)
                   # print(predicted.shape, labels.shape)
                   correct += (predicted == labels).sum().item()
                   pbar.update(1)# 每处理1个batch更新一次tqdm
    
       print('Accuracy on test set: {:.3f}'.format(correct / total))
    
    print('Before training:', end=' ')
    test()
  7. 保存和加载模型

    上面已经保存了loss最低的模型,但可能过拟合,因此再保存一个最后一次运行的模型,加载loss最低的模型进行测试。

    # 保存最后一次模型
    
    torch.save(model.state_dict(), os.path.join(save_path, 'model.pth'))
    
    # 加载模型
    model = CNN()
    model.load_state_dict(torch.load(os.path.join(save_path, 'best_model.pth')))
    model = model.to(device)
    
    # 预测
    def predict(img):
       model.eval()
       img = img.to(device)
       with torch.no_grad():
           output = model(img)
           # print(output)
           _, predicted = torch.max(output.data, dim=1)
           return predicted.item()
    
    import random
    # 随机选取一张图片进行预测
    index = random.randint(0, len(test_dataset))
    img = test_dataset[index][0].unsqueeze(0)
    label = test_dataset[index][1]
    plt.imshow(img[0][0], cmap='gray')
    plt.title('label: {}'.format(label))
    plt.show()
    print('predict: {}'.format(predict(img)))

    自己手写了一张数字 8 的图片进行测试。

    8

    先对图片进行缩放,再转换为灰度图,再转换成张量之后输入模型进行预测。之前一直预测为0,但更改模型深度后就可以较好地预测出该图片为8。

    from PIL import Image
    
    img = Image.open('./8.jpg')
    
    transform = transforms.Compose([
       transforms.Resize((28, 28)),
       transforms.Grayscale(),
       transforms.ToTensor(),
    ])
    
    print("Original size: ", img.size)
    img = transform(img)
    print(img.shape)
    plt.title('label: 8')
    plt.imshow(img[0], cmap='gray')
    plt.show()
    
    print('predict: {}'.format(predict(img.unsqueeze(0))))

    8


整天不想事儿,就想着干饭