多分类问题与卷积模型的优化

首先导入用到的库:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
import torchvision
import glob
from torchvision import transforms
from torch.utils import data
from PIL import Image  # 将使用Image 库读取图片
from matplotlib import font_manager

1. 创建自定义Dataset类

首先来看本次项目使用的数据集。

本次使用4种天气图片数据集Multi-class Weather Dataset for Image Classification,

数据集网址:https://data.mendeley.com/datasets/4drtyfjtfy/1

用于天气识别的图像 –用于多类天气识别,此数据集是1125张图像的集合,分为四个类别。

阴天(cloudy)300,雨天(rain) 215 、晴天(shine)253、日出(sunrise) 357

数据集包含日出(sunrise)、晴天(shine)、阴天(cloudy)、雨天(rain)4种天气,所有图片均在dataset2文件夹中,图片的文件名称标注其类别,示例图片和文件结构如图所示:

在这里插入图片描述

这明显是一个四分类问题,全部图片均在一个文件夹中,当然也没有划分训练数据和测试数据。这种形式不能直接使用torchvision.datasets.ImageFolder读取,当然我们完全可以编写代码根据图片名称移动到不同的文件夹,然后使用与之前同样的方式创建dataset。

本次将演示更加普遍性的读取方式,即直接使用自定义Dataset类创建输入dataset。
要创建自定义的Dataset类,需要继承自父类torch.utils.data.Dataset来创建一个子类,这个子类必须重写魔术方法__getitem__(),从而支持获取给定键的数据的功能。
getitem()方法是Python类中常用的一个方法,通过定义此方法,类的实例将可被切片和索引。

创建Dataset子类还常常选择重写__len__()方法,通过实现此方法,可使用len()方法获取Dataset类实例的长度。

下面编写代码,首先使用glob库获取所有图片的路径,然后定义类别名称与类别编号的字典,再从路径中提取出图片标签,这样获取的标签与图片路径是一一对应的:

imgs = glob.glob(r'./dataset2/*.jpg') # 获取全部图片路径
print(imgs[:3])  # 打印查看获取的前3条路径

species = ['cloudy', 'rain', 'shine', 'sunrise']  # 4种类别名称
# 字典推导式获取类别到编号的字典
species_to_idx = dict((c, i) for i, c in enumerate(species))
print(species_to_idx)  # 输出{'cloudy': 0, 'rain': 1, 'shine': 2, 'sunrise': 3}
# 字典推导式获取编号到类别的字典
idx_to_species = dict((v, k) for k, v in species_to_idx.items())
print(idx_to_species)  # 输出{0: 'cloudy', 1: 'rain', 2: 'shine', 3: 'sunrise'}
# 下面提取图片路径列表对应的标签列表
labels = [] # 创建空列表用以存放标签
for img in imgs:
    # 对全部图片路径迭代
    for i, c in enumerate(species):
        # 迭代4种类别名称
        if c in img:  # 判断图片路径中是否包含类别名称
            labels.append(i)  # 将对应类别编码添加到类别列表

print(labels[:3])  # 打印查看获取的前3个标签,发现与前3张图片是对应的

'''
['./dataset2\\cloudy1.jpg', './dataset2\\cloudy10.jpg', './dataset2\\cloudy100.jpg']
{'cloudy': 0, 'rain': 1, 'shine': 2, 'sunrise': 3}
{0: 'cloudy', 1: 'rain', 2: 'shine', 3: 'sunrise'}
[0, 0, 0]
'''

获取图片路径列表与对应的标签列表后,就可以着手编写自定义的Dataset类了,不过在创建Dataset类之前,需要先定义预处理图片的transform。

transform可以帮助预处理图片,由于本次测试使用的4种天气数据图片大小各不相同,可以使用transforms.Resize()方法直接将图片调整到同样大小的96×96,然后使用ToTensor()方法和Normalize()方法。

transform = transforms.Compose([
    transforms.Resize((96, 96)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[.5, .5, .5], std=[.5, .5, .5])
])

这里图片大小是超参数,需要自己定义,越大的图片越能保存图片信息,但是太大的图片容易导致显存溢出,这里选择了96×96。太小的图片虽然节省计算资源,但也需要注意,如果创建的模型比较深的话,有可能图片最后越来越小,甚至比池化层的池化核还要小,这时就会报错。

你可能对于直接将图片调整大小感到疑惑,不同大小和长宽比的图片直接调整会导致图像扭曲,但是这并不妨碍我们辨认其类别。除了Resize()方法,还可以使用transforms.RandomResizedCrop()、transforms.CenterCrop()、transforms.RandomCrop()等方法,均可实现统一图片大小的目的。

然后创建Dataset类,这里自定义的Dataset类名为WT_dataset,创建Dataset类需要继承data.Dataset这个父类,同时重写__getitem__()方法和__len__()方法,代码如下:

class WTdataset(data.Dataset):
    def __init__(self, imgs_path, lables):
        self.imgs_path = imgs_path,
        self.lables = lables

    def __getitem__(self, index):
        img_path = self.imgs_path[0][index]
        lable = self.lables[index]
        pil_img = Image.open(img_path)
        pil_img = pil_img.convert("RGB")  # 此行可选,如有黑白图片会被转为RGB格式
        pil_img = transform(pil_img)
        return pil_img, lable

    def __len__(self):
        return len(self.imgs_path[0])

在WT_dataset类的初始化方法__init__()中接收两个列表,图片路径列表和对应的标签列表,并创建属性。

在__getitem__()方法中接收一个输入索引(index),返回对应索引的图片和标签。代码中pil_img.convert(“RGB”)是将图片转为RGB格式,这一步并不是必需的,如果你的数据集全部是彩色图片,可以去掉此行代码,因为4种天气数据集中混杂着个别的黑白图片(channel为1),所以这里加上这一步转换,确保所有图片的通道数均为3,然后用transform处理图片并将图片和标签返回。

在__len__()方法中直接返回了路径列表的长度,这正是数据集的大小。定义好WT_dataset类之后,如果要使用这个类创建输入数据dataset,只需要使用图片列表和对应标签列表实例化这个类。

dataset = WTdataset(imgs, labels)
count = len(dataset)
print(count)  # 打印数据集大小,显示1122

下面来划分训练数据集和测试数据集,PyTorch提供了torch.utils.data.random_split()方法帮助划分dataset,它有两个参数:一是要划分的dataset,二是划分的每一部分的大小,代码如下:

train_count = int(0.8 * count)  # 训练数据个数,这里选择全部数据的80号作为训练数据集
test_count = count - train_count  # 剩余的数据为测试数据集
# 划分训练数据集和测试数据集
train_dataset, test_dataset = data.random_split(dataset, [train_count, test_count])
print(len(train_dataset), len(test_dataset))  # 输出897,225

下面使用得到的train_dataset和test_dataset分别创建dataloader,并绘图查看数据集中的图片:

BTACH_SIZE = 16  # 批次大小
train_dl = torch.utils.data.DataLoader(
    train_dataset,
    batch_size=BTACH_SIZE,
    shuffle=True)
test_dl = torch.utils.data.DataLoader(
    test_dataset,
    batch_size=BTACH_SIZE
)
print('train_dl_iter--->',iter(train_dl))
# print(next(iter(train_dl)))
imgs_batch, labels_batch = next(iter(train_dl))  # 返回一个批次的训练数据
# print(len(imgs_batch))
# print(len(labels_batch))

# 绘制批次中前 6张图片
plt.figure(figsize=(12, 8))
for i, (img, label) in enumerate(zip(imgs_batch[:6], labels_batch[:6])):
    # 设置channel最后,并还原到取值0~1
    img = (img.permute(1, 2, 0).numpy() + 1) / 2
    plt.subplot(2, 3, i + 1)
    plt.title(idx_to_species.get(label.item()))  # 使用类别名称作为title
    plt.imshow(img)

在这里插入图片描述

至此数据预处理部分做完了,下面可以开始创建分类模型。

2. 基础卷积模型

本次实验为四分类模型,输出层最后的输出单元数为4,图片输入阶段统一调整到了96×96,仍然使用前面定义好的train()和test()函数训练,学习速率设置为0.0005,训练结束后绘制正确率和损失的变化曲线,下面是模型代码:

class Net(nn.Module):
    def __init__(self):
        super(Net,self).__init__()
        self.conv1 = nn.Conv2d(3,16,3)
        self.conv2 = nn.Conv2d(16,32,3)
        self.conv3 = nn.Conv2d(32,64,3)
        self.fc1 = nn.Linear(64*10*10,1024)
        self.fc2 = nn.Linear(1024,4)

    def forward(self,x):
        x = F.relu(self.conv1(x))
        x = F.max_pool2d(x,2)
        x = F.relu(self.conv2(x))
        x = F.max_pool2d(x,2)
        x = F.relu(self.conv3(x))
        x = F.max_pool2d(x,2)
        x = x.view(-1,64*10*10)
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return x

device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))
# 打印当前可用设备
model = Net().to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.0005)

定义train()和test()函数:

def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)  #获取当前数据集样本总数量
    num_batches = len(dataloader)  #获取当前dataloader总批次数

    # train_loss用于累计所有批次的损失之和,correct用于累计预测正确的样本总数
    train_loss, correct = 0, 0
    for x, y in dataloader:
        #对dataloader进行迭代
        x, y = x.to(device), y.to(device)  #每一批次的数据设置为使用当前device
        #进行预测,并计算一个批次的损失
        pred = model(x)
        loss = loss_fn(pred, y)  #返回的是平均损失

        #使用反向传播算法,根据损失优化模型参数
        optimizer.zero_grad()  #将模型参数的梯度先全部归零
        loss.backward()  #损失反向传播,计算模型参数梯度
        optimizer.step()  #根据梯度优化参数
        with torch.no_grad():
            #correct用于累计预测正确的样本总数
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
            # train_loss用于累计所有批次的损失之和
            train_loss += loss.item()
    # train_loss是所有批次的损失之和,所以计算全部样本的平均损失时需要除以总批次数
    train_loss /= num_batches
    #correct是预测正确的样本总数,若计算整个epoch总体正确率,需除以样本总数量
    correct /= size
    return train_loss, correct


def test(dataloader, model):
    size = len(dataloader.dataset)
    num_batches = len(dataloader)
    test_loss, correct = 0, 0
    with torch.no_grad():
        for x, y in dataloader:
            x, y = x.to(device),y.to(device)
            pred = model(x)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

    test_loss /= num_batches
    correct /= size
    return test_loss, correct

测试训练30个epoch:

epochs = 30  #一个epoch代表对全部数据训练一遍
train_loss = []  #每个epoch训练中训练数据集的平均损失被添加到此列表
train_acc = []  #每个epoch训练中训练数据集的平均正确率被添加到此列表
test_loss = []  #每个epoch训练中测试数据集的平均损失被添加到此列表
test_acc = []  # 每个epoch 训练中测试数据集的平均正确率被添加到此列表
for epoch in range(epochs):
    #调用train()函数训练
    epoch_loss, epoch_acc = train(train_dl, model, loss_fn, optimizer)
    #调用test()函数测试
    epoch_test_loss, epoch_test_acc = test(test_dl, model)
    train_loss.append(epoch_loss)
    train_acc.append(epoch_acc)
    test_loss.append(epoch_test_loss)
    test_acc.append(epoch_test_acc)
    #定义一个打印模板
    template = ("epoch: {:2d}, train_loss: {:.5f}, train_acc: {:.1f}% ,test_loss: {:.5f}, test_acc: {:.1f}%")
    #输出当前epoch 的训练集损失、训练集正确率、测试集损失、测试集正确率
    print(template.format(epoch, epoch_loss, epoch_acc * 100, epoch_test_loss, epoch_test_acc * 100))


print("Done!")

绘制训练与测试损失比较图像:

# 调用windows中字体文件,使label标签中的中文正常显示,不然会乱码
font = font_manager.FontProperties(fname=r"C:\\Windows\\Fonts\\msyh.ttc",size=20)

# 绘制训练与测试损失比较图像
plt.plot(range(1, epochs + 1), train_loss, label='train_loss')
plt.plot(range(1, epochs + 1), test_loss, label='test_loss', ls="--")
plt.xlabel('训练与测试损失比较',fontproperties=font)
plt.legend()
plt.show()

在这里插入图片描述

绘制训练与测试成功率比较图像:

# 绘制训练与测试成功率比较图像
plt.plot(range(1, epochs+1), train_acc, label='train_acc')
plt.plot(range(1, epochs+1), test_acc, label='test_acc', ls="--")
plt.xlabel('训练与测试成功率比较',fontproperties=font)
plt.legend()
plt.show()

在这里插入图片描述

观察图中损失变化曲线会发现,训练数据的正确率要远远高于测试数据的正确率,相应的训练数据损失要比测试数据损失低很多。

前面我们已经讲过,在机器学习中,当模型在训练数据上获得很高的正确率,而测试数据集上反而比较低,说明模型出现了过拟合。过拟合问题是机器学习和深度学习中经常遇到的问题,也是我们调参和模型优化要解决的主要问题之一。

过拟合产生的根本原因在于:训练数据不能代表全部样本数据,模型根据训练数据优化参数,势必造成模型在参与训练的这部分数据上表现得很优秀,而在未见到过的数据上表现就不一定同样的好了。这也是在训练模型时为什么一定要有测试数据或者验证数据,因为只有在测试数据和验证数据上模型的表现才是客观的,值得信任的。因此绝对不能将模型在训练数据上的表现作为模型的评价指标。

在评价模型时,为了更加客观,很多时候人们会将数据分成3个部分:训练数据、验证数据和测试数据。模型在训练数据上训练,并根据在验证数据上的表现进行调参,这样经过多轮调参后,模型获得了在验证数据上最好的表现,可以认为训练和调参完毕,最后再在测试数据上测试模型的正确率作为模型的最终评价。

为什么要这样设计?这是因为模型在调参过程中是根据验证数据调参的,通过多轮的调参,模型实际上已经间接地看到了验证数据,可以认为模型对验证数据是友好的。因此不能将验证数据上的表现作为模型的最终评价,这就是为什么最后要在测试数据上测试模型作为模型最终评价的原因。

上面简单介绍了过拟合的定义和客观评价模型的方法,在很多时候为了简单,一般直接将数据分为两部分,也就是训练数据和测试数据。

回到上面的模型,现在模型出现了过拟合,我们要思考如何抑制过拟合。根据对过拟合的理解很容易想到,如果训练数据足够多,训练数据分布将更具有代表性,模型就不容易过拟合。所以针对过拟合问题,更多的训练数据能够抑制过拟合。但是,当没有这么多数据或者数据难以获得的时候,就需要从模型优化的角度去考虑抑制过拟合了。

3. Dropout抑制过拟合

Dropout是神经网络抑制过拟合的一个有效手段。它是指在神经网络的训练过程中,对于神经网络单元的输出按照一定的概率将其暂时从网络中丢弃。这种丢弃是暂时和随机的,对于训练中的随机梯度下降,由于是随机丢弃,故而每一个mini-batch都在训练不同的网络。Dropout能够模拟具有大量不同网络结构的神经网络,使网络中的节点更具有鲁棒性,它是深度学习中最常见的抑制过拟合的手段之一。

Dropout的原理如图所示(图片来自Dropout论文A Simple Way to Prevent Neural Networks from Overfitting):

在这里插入图片描述

Dropout的运行机制大体如下。

(1)随机(临时)丢弃网络中部分隐藏神经元的输出,图中打叉的神经元为临时被丢弃的神经元。

(2)把输入通过修改后的网络前向传播,然后把得到的损失结果通过修改的网络反向传播,按照随机梯度下降算法更新没有被删除的神经元对应的参数(w, b)。

(3)继续重复这一过程。

在神经网络中也可以用相同的训练数据训练多个模型,并根据训练的多个模型投票来决定最后的结果。这种“综合起来取平均”的策略通常可以有效防止过拟合问题,因为不同的网络可能关注不同的特征,产生不同的过拟合,取平均则有可能让一些拟合互相抵消。Dropout机制通过随机丢弃不同的隐藏神经元,使得每次训练的网络发生变化,就类似在训练创建不同的网络。但是在最后预测的时候,我们会使用全部神经元共同预测,这样就相当于对很多个不同的神经网络取平均,从而减少过拟合。

Dropout也可以减少神经元之间复杂的共适应关系,Dropout随机丢弃部分神经元,导致两个神经元不一定每次都在一个Dropout网络中出现。这时候权值的更新不再依赖于有固定关系的隐含节点的共同作用,阻止了某些特征仅仅在其他特定特征出现时才有效果的情况,迫使网络去学习更加鲁棒的特征。直观的理解就是,神经网络在做出某种预测时不应该对一些特定的神经元太过敏感,即使丢失这些神经元,网络也可以从众多其他特征中学习到一些共同的模式。

上面提到了Dropout层在训练和预测时的表现是不同的。训练时随机丢弃一定比例的神经元,但预测时使用全部神经元,也就是说模型存在两种模式:训练模式和预测模式,两种模式下模型中的Dropout层表现不同。训练时需要模型处于训练模式,Dropout层发挥作用,随机丢弃一定比例的神经元的输出;测试和应用时模型需要处于预测模式,Dropout层不发挥作用,模型使用全部神经元做出预测。

为了区分两种模式,可以通过使用model.train()和model.eval()两个方法将模型设置为训练模式和测试模式,因此,为了适应模型中可能包含Dropout层,稍微修改前面写好的train()函数和test()函数,增加设置模型模式的代码,在以后的训练中可以参考使用这两个训练函数,代码如下:

def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)  #获取当前数据集样本总数量
    num_batches = len(dataloader)  #获取当前dataloader总批次数

    # train_loss用于累计所有批次的损失之和,correct用于累计预测正确的样本总数
    train_loss, correct = 0, 0
    model.train() # 模型训练模式
    for x, y in dataloader:
        #对dataloader进行迭代
        x, y = x.to(device), y.to(device)  #每一批次的数据设置为使用当前device
        #进行预测,并计算一个批次的损失
        pred = model(x)
        loss = loss_fn(pred, y)  #返回的是平均损失

        #使用反向传播算法,根据损失优化模型参数
        optimizer.zero_grad()  #将模型参数的梯度先全部归零
        loss.backward()  #损失反向传播,计算模型参数梯度
        optimizer.step()  #根据梯度优化参数
        with torch.no_grad():
            #correct用于累计预测正确的样本总数
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
            # train_loss用于累计所有批次的损失之和
            train_loss += loss.item()
    # train_loss是所有批次的损失之和,所以计算全部样本的平均损失时需要除以总批次数
    train_loss /= num_batches
    #correct是预测正确的样本总数,若计算整个epoch总体正确率,需除以样本总数量
    correct /= size
    return train_loss, correct


def test(dataloader, model):
    size = len(dataloader.dataset)
    num_batches = len(dataloader)
    model.eval() # 模型为预测模式
    test_loss, correct = 0, 0
    with torch.no_grad():
        for x, y in dataloader:
            x, y = x.to(device),y.to(device)
            pred = model(x)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

    test_loss /= num_batches
    correct /= size
    return test_loss, correct

PyTorch中内置了Dropout层的实现,一般可直接调用F.dropout()、F.dropout2d()或F.dropout3d()来为模型添加Dropout层,F.dropout()适用于一维数据的Dropout层,如添加在常见的全连接层或一维卷积层后;F.dropout2d()适用于二维卷积后添加Dropout层,需要说明的是,卷积层后添加Dropout层较少使用,效果也不是很明显,这是因为图像的相邻像素之间有相关性,随机地丢弃卷积输出特征像素点,抑制过拟合的效果有限。Dropout层的第一个参数是输入的tensor,另外一个参数是p,代表丢弃的神经元的比例,默认值为0.5,Dropout层一般添加到模型靠近输出的部分,下面改造上面的卷积模型,增加Dropout层,代码如下:

class Net(nn.Module):
    def __init__(self):
        super(Net,self).__init__()
        self.conv1 = nn.Conv2d(3,16,3)
        self.conv2 = nn.Conv2d(16,32,3)
        self.conv3 = nn.Conv2d(32,64,3)
        self.fc1 = nn.Linear(64*10*10,1024)
        self.fc2 = nn.Linear(1024,4)

    def forward(self,x):
        x = F.relu(self.conv1(x))
        x = F.max_pool2d(x,2)
        x = F.relu(self.conv2(x))
        x = F.max_pool2d(x,2)
        x = F.relu(self.conv3(x))
        x = F.max_pool2d(x,2)
        x = x.view(-1,64*10*10)
        x = F.dropout(x)  # Dropout层,使用默认比例p=0.5
        x = F.relu(self.fc1(x))
        x = F.dropout(x) # Dropout层,使用默认比例p=0.5
        x = self.fc2(x)
        return x

我们在靠近输出部分的池化层和全连接层后面添加了两个Dropout层,均使用默认的概率50%丢弃中间层神经元输出,下图所示是添加了Dropout层后的模型训练输出情况(正确率和损失的变化曲线)。

在这里插入图片描述

在这里插入图片描述

可以看到训练数据和测试数据之间的正确率差距和损失的差距变小了,也就是说过拟合的程度得到了抑制,不仅如此,同样在训练30个epoch后,添加Dropout层的最高正确率达到了94.7%,超过了未添加Dropout层时的最高正确率94.2%。过拟合的减小实际上增加了优化模型的空间,我们也可自行尝试继续训练甚至增加模型拟合能力。

4. 批标准化

机器学习中通常会对数据做预处理,将数据处理为无量纲的数据。处理的方法有归一化、标准化等,归一化是指把数据映射到0~1或者−1~1;标准化是将数据设置为均值为0、标准差为1。这些预处理方法被广泛地使用在许多机器学习算法中,如支持向量机、逻辑回归等算法。

机器学习中处理数据是基于这样的假设,训练数据和测试数据满足相同分布,通过训练数据获得的模型能够在测试集获得同样好的效果。深度学习也是基于这样的假设,我们希望数据是相同分布的无量纲数据,可以复习前面已经讲过的实例,在图片预处理中都做了归一化或者标准化。

但这仅仅是在数据预处理阶段,当数据进入多层神经网络后,模型参数发生更新,除了输入层的数据外,后面网络每一层的输入数据分布,随着模型参数的更新和ReLU等激活函数的作用会发生偏移。以网络第二层为例,网络第二层的输入,是由第一层的参数和输入计算后激活得到的,而第一层的参数在整个训练过程中一直在变化,因此必然引起后面每一层输入数据分布的改变。我们把网络中间层在训练过程中数据分布的改变称为“Internal Covariate Shift”。 内部协变量转移。

批标准化的提出就是要解决在训练过程中,中间层数据分布发生改变的情况。

BN层对数据处理主要分为以下3步。

(1)求每一个训练批次数据的均值和方差。

(2)使用求得的均值和方差对该批次的训练数据做归一化。

(3)尺度变换和偏移:使用标准化之后的输出乘以γ调整数值大小,再加上β增加偏移后,得到新的输出值。

BN层的最后一步是一个数据的平移变换,为什么要做这个变换呢?因为批数据在归一化后,取值范围会被限制在正态分布下,使得网络的表达能力下降。为解决该问题,引入两个新的参数:γ和β,对输出进行平移变化。

模型添加BN层有很多好处,很明显,BN层的添加使得中间层的数据输入分布不会发生大的偏移,这有利于创建比较深的模型。事实上,在BN层提出之前,深度学习模型无法太深,可能十几层就已经是极限了。BN层提出后,模型的深度可以大大增加,当然,现代模型深度可以深达上千层,这不仅得益于BN层,还使用到残差等结构。

BN层的使用还有很多好处,它使得模型梯度传递更加顺畅,可以有效防止梯度消失和梯度爆炸。使用BN层后,学习速率可以设置得大一点,以加快训练速度。BN层还能使模型对于模型参数的初始化方式和模型参数初始取值不敏感,使得网络学习更加稳定,提高模型训练精度。

另外,BN层还具有一定的正则化效果,这一点类似于Dropout层。要特别注意的是,与Dropout层类似,BN层在模型训练和模型预测时的行为并不一样。在训练时BN层计算一个批次数据的均值和方差,对数据做归一化,同时在训练过程中模型计算所有批次均值和方差的移动均值;当模型处于预测模式时,模型将使用这个移动均值对要预测的数据做归一化。

PyTorch中常用到的批标准化层有3个,分别为nn.BatchNorm1d、nn.BatchNorm2d和nn.BatchNorm3d。
nn.BatchNorm1d适用于对2D或3D输入应用批标准化,也就是适用于一维卷积层和全连接层后的批标准化;
nn.BatchNorm2d适用于在4D输入(图片数据或者二维卷积的输出特征)上应用批标准化;
nn.BatchNorm3d适用于在5D输入(视频或图片序列)上应用批标准化。

BN层最主要的参数是num_features,也就是输入特征的大小。一般将BN层添加在卷积或全连接层的后面,下面在卷积模型中添加使用BN层,代码修改如下:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 16, 3)
        # 初始化第一个BN层,它的输入特征数是输入图像的特征层数:16
        self.bn1 = nn.BatchNorm2d(16)
        self.conv2 = nn.Conv2d(16, 32, 3)
        # 初始化第二个BN层,它的输入特征数是输入图像的特征层数:32
        self.bn2 = nn.BatchNorm2d(32)
        self.conv3 = nn.Conv2d(32, 64, 3)
        # 初始化第三个BN层,它的输入特征数是输入图像的特征层数:64
        self.bn3 = nn.BatchNorm2d(64)
        self.fc1 = nn.Linear(64 * 10 * 10, 1024)
        self.fc2 = nn.Linear(1024, 4)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.bn1(x)  # BN层应用在卷积层后
        x = F.max_pool2d(x, 2)
        x = F.relu(self.conv2(x))
        x = self.bn2(x)
        x = F.max_pool2d(x, 2)
        x = F.relu(self.conv3(x))
        x = self.bn3(x)
        x = F.max_pool2d(x, 2)
        x = x.view(-1, 64 * 10 * 10)
        x = F.dropout(x)  # Dropout层,使用默认比例p=0.5
        x = F.relu(self.fc1(x))
        x = F.dropout(x)  # Dropout层,使用默认比例p=0.5
        x = self.fc2(x)
        return x

在这里插入图片描述

因为本项目的模型并不深,可以看到正确率和损失的变化曲线与添加Dropout层后的差别不大,最高正确率与上面持平。但是要注意到,损失曲线仍然存在下降趋势,说明增加训练epoch将可能得到更好的结果,这就交给大家自行实验了。

5. 学习速率衰减

学习速率对训练过程有着巨大的影响,在模型训练开始,我们希望学习速率能大一些,损失函数快速下降;在训练接近损失函数底部时,我们希望学习速率能减小,这样就能防止跳过极值点。学习速率衰减在训练时是常见的优化训练方法,下面简单地了解如何在训练中实现学习速率衰减。

PyTorch提供了lr_scheduler类来实现学习速率的衰减。例如,希望每训练7个epoch,学习速率衰减为原来的1/10,可以使用lr_scheduler.StepLR()方法,这个方法有3个主要参数:第一个是优化器实例,第二个是step_size,它代表间隔的epoch数,第三个参数是衰减系数gamma,它表示每经过step_size个epoch训练,将对优化器的学习速率乘以此系数,因此衰减系数gamma应设置为一个小于1的正数。

下面是在代码中实现学习速率衰减的演示,每7个epoch以0.1为衰减系数对学习速率进行衰减。

# 每7个epoch以0.1为衰减系数对学习速率进行衰减
from torch.optim import lr_scheduler
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
exp_lr_scheduler = lr_scheduler.StepLR(optimizer, step_size=7, gamma=0.1)

然后在训练循环的代码中,添加一行代码exp_lr_scheduler.step(),用来记录已经训练了多少个epoch并触发学习速率的衰减。为了方便后续直接调用训练,将训练循环的代码封装到一个fit()函数中,这样后续需要训练时,直接调用fit()函数即可。训练函数fit()的代码如下:

def fit(epochs,train_dl,test_dl, model, loss_fn, optimizer,exp_lr_scheduler=None):
    #一个epoch代表对全部数据训练一遍
    train_loss = []  # 每个epoch训练中训练数据集的平均损失被添加到此列表
    train_acc = []  # 每个epoch训练中训练数据集的平均正确率被添加到此列表
    test_loss = []  # 每个epoch训练中测试数据集的平均损失被添加到此列表
    test_acc = []  # 每个epoch 训练中测试数据集的平均正确率被添加到此列表
    for epoch in range(epochs):
        # 调用train()函数训练
        epoch_loss, epoch_acc = train(train_dl, model, loss_fn, optimizer)
        # 调用test()函数测试
        epoch_test_loss, epoch_test_acc = test(test_dl, model)
        train_loss.append(epoch_loss)
        train_acc.append(epoch_acc)
        test_loss.append(epoch_test_loss)
        test_acc.append(epoch_test_acc)
        if exp_lr_scheduler:
            exp_lr_scheduler.step() # 学习速率衰减
        # 定义一个打印模板
        template = ("epoch: {:2d}, train_loss: {:.5f}, train_acc: {:.1f}% ,test_loss: {:.5f}, test_acc: {:.1f}%")
        # 输出当前epoch 的训练集损失、训练集正确率、测试集损失、测试集正确率
        print(template.format(epoch, epoch_loss, epoch_acc * 100, epoch_test_loss, epoch_test_acc * 100))

    print("Done!")
    return train_loss,test_loss,train_acc,test_acc
epochs = 30
train_loss,test_loss,train_acc,test_acc = fit(epochs,train_dl,test_dl, model, loss_fn, optimizer,exp_lr_scheduler=None)

在这里插入图片描述

这样就实现了在训练过程中,每经过7个epoch学习速率乘以0.1,达到了学习速率衰减的目的。至于初始学习速率的选取以及衰减的系数都属于超参数,需要我们在实验中自行做出选择。

6. 最终优化整合代码

# -*- coding: UTF-8 -*-
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
import torchvision
import glob
from torchvision.transforms import ToTensor
from matplotlib import font_manager
from torchvision import transforms
from torch.utils import data
from PIL import Image  # 将使用Image 库读取图片

imgs = glob.glob(r'./dataset2/*.jpg')
# 获取全部图片路径
print(imgs[:3])  # 打印查看获取的前3条路径

species = ['cloudy', 'rain', 'shine', 'sunrise']  # 4种类别名称
# 字典推导式获取类别到编号的字典
species_to_idx = dict((c, i) for i, c in enumerate(species))
print(species_to_idx)  # 输出{'cloudy': 0, 'rain': 1, 'shine': 2, 'sunrise': 3}
# 字典推导式获取编号到类别的字典
idx_to_species = dict((v, k) for k, v in species_to_idx.items())
print(idx_to_species)  # 输出{0: 'cloudy', 1: 'rain', 2: 'shine', 3: 'sunrise'}
# 下面提取图片路径列表对应的标签列表
labels = []
# 创建空列表用以存放标签
for img in imgs:
    # 对全部图片路径迭代
    for i, c in enumerate(species):
        # 迭代4种类别名称
        if c in img:  # 判断图片路径中是否包含类别名称
            labels.append(i)  # 将对应类别编码添加到类别列表

print(labels[:3])  # 打印查看获取的前3个标签,发现与前3张图片是对应的

'''
['./dataset2\\cloudy1.jpg', './dataset2\\cloudy10.jpg', './dataset2\\cloudy100.jpg']
{'cloudy': 0, 'rain': 1, 'shine': 2, 'sunrise': 3}
{0: 'cloudy', 1: 'rain', 2: 'shine', 3: 'sunrise'}
[0, 0, 0]
'''
transform = transforms.Compose([
    transforms.Resize((96, 96)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[.5, .5, .5], std=[.5, .5, .5])
])


class WTdataset(data.Dataset):
    def __init__(self, imgs_path, lables):
        self.imgs_path = imgs_path,
        self.lables = lables

    def __getitem__(self, index):
        img_path = self.imgs_path[0][index]
        lable = self.lables[index]
        pil_img = Image.open(img_path)
        pil_img = pil_img.convert("RGB")  # 此行可选,如有黑白图片会被转为RGB格式
        pil_img = transform(pil_img)
        return pil_img, lable

    def __len__(self):
        return len(self.imgs_path[0])


dataset = WTdataset(imgs, labels)
count = len(dataset)
# print(count)  # 打印数据集大小,显示1122

train_count = int(0.8 * count)  # 训练数据个数,这里选择全部数据的80号作为训练数据集
test_count = count - train_count  # 剩余的数据为测试数据集
# 划分训练数据集和测试数据集
train_dataset, test_dataset = data.random_split(dataset, [train_count, test_count])
# print(len(train_dataset), len(test_dataset))  # 输出897,225
BTACH_SIZE = 16  # 批次大小
train_dl = torch.utils.data.DataLoader(
    train_dataset,
    batch_size=BTACH_SIZE,
    shuffle=True)
test_dl = torch.utils.data.DataLoader(
    test_dataset,
    batch_size=BTACH_SIZE
)
# print('train_dl_iter--->',iter(train_dl))
# print(next(iter(train_dl)))
imgs_batch, labels_batch = next(iter(train_dl))  # 返回一个批次的训练数据


# print(len(imgs_batch))
# print(len(labels_batch))

# 绘制批次中前 6张图片
# plt.figure(figsize=(12, 8))
# for i, (img, label) in enumerate(zip(imgs_batch[:6], labels_batch[:6])):
#     # 设置channel最后,并还原到取值0~1
#     img = (img.permute(1, 2, 0).numpy() + 1) / 2
#     plt.subplot(2, 3, i + 1)
#     plt.title(idx_to_species.get(label.item()))  # 使用类别名称作为title
#     plt.imshow(img)


class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 16, 3)
        # 初始化第一个BN层,它的输入特征数是输入图像的特征层数:16
        self.bn1 = nn.BatchNorm2d(16)
        self.conv2 = nn.Conv2d(16, 32, 3)
        # 初始化第二个BN层,它的输入特征数是输入图像的特征层数:32
        self.bn2 = nn.BatchNorm2d(32)
        self.conv3 = nn.Conv2d(32, 64, 3)
        # 初始化第二个BN层,它的输入特征数是输入图像的特征层数:64
        self.bn3 = nn.BatchNorm2d(64)
        self.fc1 = nn.Linear(64 * 10 * 10, 1024)
        self.fc2 = nn.Linear(1024, 4)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.bn1(x)  # BN层应用在卷积层后
        x = F.max_pool2d(x, 2)
        x = F.relu(self.conv2(x))
        x = self.bn2(x)
        x = F.max_pool2d(x, 2)
        x = F.relu(self.conv3(x))
        x = self.bn3(x)
        x = F.max_pool2d(x, 2)
        x = x.view(-1, 64 * 10 * 10)
        x = F.dropout(x)  # Dropout层,使用默认比例p=0.5
        x = F.relu(self.fc1(x))
        x = F.dropout(x)  # Dropout层,使用默认比例p=0.5
        x = self.fc2(x)
        return x


device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))
# 打印当前可用设备
model = Net().to(device)
loss_fn = nn.CrossEntropyLoss()
# optimizer = torch.optim.Adam(model.parameters(), lr=0.0005)

# 每7个epoch以0.1为衰减系数对学习速率进行衰减
from torch.optim import lr_scheduler
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
exp_lr_scheduler = lr_scheduler.StepLR(optimizer, step_size=7, gamma=0.1)


def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)  # 获取当前数据集样本总数量
    num_batches = len(dataloader)  # 获取当前dataloader总批次数

    # train_loss用于累计所有批次的损失之和,correct用于累计预测正确的样本总数
    train_loss, correct = 0, 0
    model.train()  # 模型训练模式
    for x, y in dataloader:
        # 对dataloader进行迭代
        x, y = x.to(device), y.to(device)  # 每一批次的数据设置为使用当前device
        # 进行预测,并计算一个批次的损失
        pred = model(x)
        loss = loss_fn(pred, y)  # 返回的是平均损失

        # 使用反向传播算法,根据损失优化模型参数
        optimizer.zero_grad()  # 将模型参数的梯度先全部归零
        loss.backward()  # 损失反向传播,计算模型参数梯度
        optimizer.step()  # 根据梯度优化参数
        with torch.no_grad():
            # correct用于累计预测正确的样本总数
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
            # train_loss用于累计所有批次的损失之和
            train_loss += loss.item()
    # train_loss是所有批次的损失之和,所以计算全部样本的平均损失时需要除以总批次数
    train_loss /= num_batches
    # correct是预测正确的样本总数,若计算整个epoch总体正确率,需除以样本总数量
    correct /= size
    return train_loss, correct


def test(dataloader, model):
    size = len(dataloader.dataset)
    num_batches = len(dataloader)
    model.eval()  # 模型为预测模式
    test_loss, correct = 0, 0
    with torch.no_grad():
        for x, y in dataloader:
            x, y = x.to(device), y.to(device)
            pred = model(x)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()

    test_loss /= num_batches
    correct /= size
    return test_loss, correct

def fit(epochs,train_dl,test_dl, model, loss_fn, optimizer,exp_lr_scheduler=None):
    #一个epoch代表对全部数据训练一遍
    train_loss = []  # 每个epoch训练中训练数据集的平均损失被添加到此列表
    train_acc = []  # 每个epoch训练中训练数据集的平均正确率被添加到此列表
    test_loss = []  # 每个epoch训练中测试数据集的平均损失被添加到此列表
    test_acc = []  # 每个epoch 训练中测试数据集的平均正确率被添加到此列表
    for epoch in range(epochs):
        # 调用train()函数训练
        epoch_loss, epoch_acc = train(train_dl, model, loss_fn, optimizer)
        # 调用test()函数测试
        epoch_test_loss, epoch_test_acc = test(test_dl, model)
        train_loss.append(epoch_loss)
        train_acc.append(epoch_acc)
        test_loss.append(epoch_test_loss)
        test_acc.append(epoch_test_acc)
        if exp_lr_scheduler:
            exp_lr_scheduler.step() # 学习速率衰减
        # 定义一个打印模板
        template = ("epoch: {:2d}, train_loss: {:.5f}, train_acc: {:.1f}% ,test_loss: {:.5f}, test_acc: {:.1f}%")
        # 输出当前epoch 的训练集损失、训练集正确率、测试集损失、测试集正确率
        print(template.format(epoch, epoch_loss, epoch_acc * 100, epoch_test_loss, epoch_test_acc * 100))

    print("Done!")
    return train_loss,test_loss,train_acc,test_acc
epochs = 30
train_loss,test_loss,train_acc,test_acc = fit(epochs,train_dl,test_dl, model, loss_fn, optimizer,exp_lr_scheduler=None)

# 调用windows中字体文件,使label标签中的中文正常显示,不然会乱码
font = font_manager.FontProperties(fname=r"C:\\Windows\\Fonts\\msyh.ttc", size=20)

# 绘制训练与测试损失比较图像
plt.plot(range(1, epochs + 1), train_loss, label='train_loss')
plt.plot(range(1, epochs + 1), test_loss, label='test_loss', ls="--")
plt.xlabel('训练与测试损失比较', fontproperties=font)
plt.legend()
plt.show()

# 绘制训练与测试成功率比较图像
plt.plot(range(1, epochs + 1), train_acc, label='train_acc')
plt.plot(range(1, epochs + 1), test_acc, label='test_acc', ls="--")
plt.xlabel('训练与测试成功率比较', fontproperties=font)
plt.legend()
plt.show()

本学习笔记出自日月光华老师的《PyTorch深度学习简明实战》,看书复现代码,书中有个别代码有些小问题我这里顺便修改了,(看了三本pytorch深度学习的书,里面的代码都多少有点小问题,至此怀疑好像所有的深度学习相关的书里的代码都可能存在小bug),这本书写的相对来说算比较好懂的,出书的都不容易,难免有一些疏忽,作为读者要怀着感恩的心情去读书,感谢大佬布道!

  • 8
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冰履踏青云

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值