文章目录
- 1. 多层感知器模型与模型训练
- 1.1 多层感知器模型
- 1.2 损失函数
- 1.3 优化器与初始化模型
- 1.4 编写训练循环
- 1.5 案例代码整合
最近看了日月光华老师的《PyTorch深度学习简明实战》,将里面的代码自己动手复现了一遍,还是受益良多,书里面的代码个别有点小问题,这里是我整理的一份学习笔记记录。
1. 多层感知器模型与模型训练
1.1 多层感知器模型
为了解决MNIST手写数字分类的问题,创建一个简单的多层感知器,这个模型仍然使用torch.nn.Linear层创建。
torch.nn.Linear层是全连接层,本质上就是对全部输入加权求和,它要求输入数据集的形状为一维的,如果使用批量运算,则增加一个batch维,也就是需要输入数据是二维的形状,第一维是batch维,第二维是数据特征,即(batch_size,feature_length)形式。通过打印train_dl中迭代的数据集的形状,train_dl返回每个批次的imgs的形状,即torch.Size([64, 1, 28, 28])。显然这个输入无法直接交给Linear层处理,这里可以对输入使用view()方法更改其形状为二维的,这样就可以直接交给Linear层计算了。本次创建的多层感知器使用两个Linear层作为中间隐藏层,每一层使用ReLU函数激活,最后输出分类数,模型代码如下:
import torch
from torch import nnclass Model(nn.Module): # 创建模型,继承自nn.Moduledef __init__(self):super().__init__()# 第一层输入展平后的特征长度 28×28,创建120个神经元self.liner_1 = nn.Linear(28 * 28, 120)# 第二层输入的是前一层的输出,创建84个神经元self.liner_2 = nn.Linear(120, 84)# 输出层接收第二层的输入84,输出分类个数10self.liner_3 = nn.Linear(84, 10)def forward(self, input):x = input.view(-1, 28 * 28)# 将输入展平为二维,(1, 28,28)→(28*28)x = torch.relu(self.liner_1(x)) # 连接第一层liner_1并使用ReLU函数激活x = torch.relu(self.liner_2(x)) # 连接第二层liner 2并使用ReLU函数激活# 输出层,输出张量的长度,与类别数量一致x = self.liner_3(x)return x
上述代码中创建了3个Linear层,第一层接收的是view()方法修改形状为二维数据后的图片输入,也就是28×28,会发现我们在初始化层时,不用考虑batch这个维度,因为框架默认第一维是batch维,框架会根据批次大小自动处理,不需要在编写代码时体现,所以第一个Linear层的第一个参数是输入维度的长度,也就是28×28;第二个参数表示输出维度,这个参数是我们自己决定的,这里设置为120。那就是表示第一层会创建120个神经元,也就是输出的维度长度为120。为了方便理解,我们用简单的绘图来表示这一层,Linear层示意图如下图所示:
输入的维度为784,连接到120个神经元,得到120个输出,这正是第一层self.liner_1所完成的,这里每一个连接都是框架通过随机初始化一个权重和一个偏置进行加权求和实现的。理解了第一个Linear层之后,后面的第二层就很容易理解了,第二层的输入是前一层的输出,也就是输入维度为120,第二层创建84个神经元,所以2个参数是(120, 84),第三个Linear层是输出层,输出层的输出维度大小为分类个数。这里数据集是MNIST手写数字分类数据集,共有10类手写数字图片,所以最后一层参数为(84, 10)。
如何理解这个输出呢?我们在处理多分类问题时,常常使用softmax函数使模型输出C个可能不同的值上的概率(C代表类别数),softmax函数的公式如下:
模型的返回值为含C个分量的概率向量,每个分量对应一个输出类别的概率。由于输出C个分量为概率,经过softmax函数计算的C个分量之和始终为1。这样,我们想知道预测结果的话,只需要计算一下哪一个类别的概率取值最大,就表示模型预测的是哪一个类别。可以看出,softmax函数相当于一个归一化函数,将输出长度为C的张量归一化为类别概率。细心的读者会发现,上面模型输出并没有使用softmax函数,而是直接输出了长度为C的张量,这是因为我们想要预测的是输出类别,也就是计算哪一个位置代表的这一类输出概率最大,因此,只需要查看输出的张量中哪一个位置的值最大即可,即没必要使用softmax函数归一化这些值也能直接得到预测结果。
1.2 损失函数
下面重点讲解分类模型的损失函数。在处理分类问题时,继续使用解决线性回归问题的均方误差作为损失函数当然也是可以的,但效果不是很好。如果我们将输出使用softmax函数计算,那么分类问题的输出是一个分布概率,也就是在C个可能不同类别上的分布概率,我们设计的损失函数应该体现模型输出的概率分布与实际的概率分布之间的损失。均方误差所惩罚的是与损失为同一数量级的情形,在分类问题上使用均方误差并不合适,例如一个三分类问题,模型的输出是一个类似(0.2,0.3, 0.5)这样的概率分布,而实际的标签是(0, 1, 0),显然两者之间的均方误差损失太小,根据损失反向传播优化参数会特别的慢,甚至不能训练。对于这种概率分布类型的问题,一般采取如下的交叉熵(cross entropy)损失函数会更为有效。单个样本的交叉熵损失计算公式如下:
公式中各参数的含义如下。
-
C——类别数。
-
log——对数运算。
-
yo,n——符号函数(0或者1),如果样本o的真实类别等于n,取1,否则取0。
-
po,n——观测样本o属于类别n的预测概率。
根据以上公式对所有样本计算损失后,求平均的结果就是交叉熵损失。交叉熵相比均方误差更适合度量概率分布损失,当概率分布差异较大时,它可以输出了一个更大的损失值(惩罚),甚至接近于无穷大,从而使模型参数更新更快、学习速度更快。交叉熵损失函数的放大作用使得训练完成后,模型不太可能做出错误预测。因此,交叉熵更适合作为分类模型的损失函数。总之,当分类问题结合交叉熵作为损失函数时,它可以放大分类损失,在模型效果差的时候学习速度比较快,在模型效果好的时候损失变小,学习速度变慢。PyTorch内置了计算交叉熵损失的函数,初始化交叉熵损失函数的代码如下:
loss_fn = nn.CrossEntropyLoss() # 初始化交叉熵损失函数
该损失函数结合了nn.LogSoftmax()和nn.NLLLoss()两个函数,读者可以认为它融合了softmax计算和交叉熵损失计算,这也正好说明了为什么不在模型的输出那里做softmax计算的原因,就是因为选择nn.CrossEntropyLoss()时,损失函数会在内部对输出进行softmax计算,然后再将得到的概率分布与实际的分布做交叉熵损失计算。nn.CrossEntropyLoss()损失函数在做分类训练时是最常使用的损失函数。此损失函数有两个可能用到的参数:一是weight,使用此参数可给予不同类别以不同的权重,有利于解决类别不均衡的问题;二是ignore_index,此参数用于忽略某一类别带来的损失,常用在图像语义分割中,个别类别我们并不在乎是否可以正确分割,可用此参数忽略其带来的损失。还要特别注意,nn.CrossEntropyLoss()损失函数要求实际类别为数值编码形式,也就是0,1,2,…,C等这样的类别编码形式,而不是独热编码。可以注意到,第4章加载的MNIST数据集的标签正是这种形式,因此可以直接使用此损失函数计算损失。
1.3 优化器与初始化模型
优化是调整模型参数以减少每个训练步骤中的模型误差的过程。优化算法定义此过程的执行方式,所有优化逻辑都封装在优化器对象中。在此示例中,我们使用随机梯度下降优化器,也就是SGD优化器,此外,PyTorch中内置了许多不同的优化器,如Adam和RMSProp等,它们更适用于不同类型的模型和数据,尤其以Adam优化器最常用。PyTorch的优化器均在torch.optim模块下,代码如下:
optimizer = torch.optim.SGD(model.parameters(), lr=0.001) # 初始化优化器
以上代码初始化了内置的SGD优化器,它有两个最重要的参数。
-
params,表示需要优化的模型参数。可调用模型的model.parameters()方法以生成器形式返回模型中需要优化的参数。
-
lr,表示学习速率(learning rate),类型为float,用来指定优化速率。
初始化模型
下面代码首先获取当前环境可用的训练设备(CPU或GPU),然后初始化前面编写的多层感知器模型,并在初始化模型后设置模型使用当前可用的device。
device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device)) # 如果安装了GPu版本,显示Using cuda device
model = Model().to(device) # 初始化模型,并设置模型使用device
1.4 编写训练循环
前面已经将训练和测试数据、优化器、损失函数和模型等全部准备好了,下面编写训练循环。为了方便以后代码复用,我们将编写一个训练函数train()和一个测试函数test(),在这两个函数中分别对全部训练数据和全部测试数据进行一次训练或测试。下面先来看train()函数的代码,注意阅读代码注释:
def train(dataloader, model, loss_fn, optimizer):size = len(dataloader.dataset) #获取当前数据集样本总数量num_batches = len(dataloader) #获取当前dataloader总批次数# train_loss用于累计所有批次的损失之和,correct用于累计预测正确的样本总数train_loss, correct = 0, 0for 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 /= sizereturn train_loss, correct
在上述代码中的train()函数中,首先使用len(dataloader.dataset)获取训练数据集样本总数量,使用len(dataloader)获取当前dataloader总批次数;然后对传进来的训练数据dataloader进行迭代,在迭代过程中,首先调用模型对当前批次的输入进行预测,并根据真实标签y计算一个批次中样本的平均损失;然后使用反向传播算法,根据损失优化模型参数;最后为了方便了解模型随着训练在数据集上的损失和正确率变化情况,初始化一个correct变量,并用它累计所有批次中预测正确的样本总数;初始化一个train_loss变量,用于累计所有批次的损失之和,这里的train_loss是所有批次的损失之和,所以计算全部样本的平均损失时需要除以总批次数,correct是预测正确的样本总数,计算整个epoch总体正确率,需要除以样本总数量。至此就得到了训练中平均正确率和平均损失值。
test()函数代码与train()函数类似,不过在test()函数代码中,仅仅测试模型在测试数据集的表现,也就是计算模型在测试数据集上的正确率和损失,并没有使用反向传播算法根据损失优化模型参数等部分的代码:
def test(dataloader, model):size = len(dataloader.dataset)num_batches = len(dataloader)test_loss, correct = 0, 0with 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_batchescorrect /= sizereturn test_loss, correct
下面就可以开始编写训练循环了,对全部训练数据集训练50个epoch(一个epoch代表对全部数据训练一遍),并使用列表记录这50个epoch训练中训练数据集和测试数据集上平均损失值和正确率的变化,以便了解模型的训练情况,并思考优化的方向。
epochs = 50 #一个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!")
输出可以观察到,随着模型训练,train_loss和test_loss在逐渐下降,而train_acc和test_acc在逐渐上升,这说明训练是有效的。还可以将训练中这些指标绘图,以便更加直观地了解模型训练情况,损失变化绘图代码如下:
# 调用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()
通过观察训练曲线可以看到,随着训练epoch数量的增加,刚开始时损失在快速下降,到后面时损失曲线越来越平,下降速度变慢;正确率也有类似的特点,随着epoch增加,正确率的曲线也接近水平,说明模型训练已经接近饱和,继续训练不能更好地优化模型,此时我们可以停止训练了。
1.5 案例代码整合
import torch
import torchvision
from torch import nn
from torchvision.transforms import ToTensor
import matplotlib.pyplot as plt
from matplotlib import font_manager
%matplotlib inlineclass Model(nn.Module): # 创建模型,继承自nn.Moduledef __init__(self):super().__init__()# 第一层输入展平后的特征长度 28×28,创建120个神经元self.liner_1 = nn.Linear(28 * 28, 120)# 第二层输入的是前一层的输出,创建84个神经元self.liner_2 = nn.Linear(120, 84)# 输出层接收第二层的输入84,输出分类个数10self.liner_3 = nn.Linear(84, 10)def forward(self, input):x = input.view(-1, 28 * 28)# 将输入展平为二维,(1, 28,28)→(28*28)x = torch.relu(self.liner_1(x)) # 连接第一层liner_1并使用ReLU函数激活x = torch.relu(self.liner_2(x)) # 连接第二层liner_2并使用ReLU函数激活# 输出层,输出张量的长度,与类别数量一致x = self.liner_3(x)return xdevice = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device)) # 如果安装了GPu版本,显示Using cuda device
model = Model().to(device) # 初始化模型,并设置模型使用deviceloss_fn = nn.CrossEntropyLoss() # 初始化交叉熵损失函数
optimizer = torch.optim.SGD(model.parameters(), lr=0.001) # 初始化优化器def train(dataloader, model, loss_fn, optimizer):size = len(dataloader.dataset) #获取当前数据集样本总数量num_batches = len(dataloader) #获取当前dataloader总批次数# train_loss用于累计所有批次的损失之和,correct用于累计预测正确的样本总数train_loss, correct = 0, 0for 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 /= sizereturn train_loss, correctdef test(dataloader, model):size = len(dataloader.dataset)num_batches = len(dataloader)test_loss, correct = 0, 0with 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_batchescorrect /= sizereturn test_loss, correcttrain_ds = torchvision.datasets.MNIST('data/', train=True, transform=ToTensor(), download=True)
test_ds = torchvision.datasets.MNIST('data/', train=False, transform=ToTensor(), download=True)train_dl = torch.utils.data.DataLoader(train_ds, batch_size=64, shuffle=True)
test_dl = torch.utils.data.DataLoader(test_ds, batch_size=46)epochs = 50 #一个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()