完整代码地址:Paddle_MNIST_Classification
如果对你有帮助,请 ⭐️ 一下 。
我们尝试使用与房价预测相同的简单神经网络解决手写数字识别问题,但是效果并不理想。原因是手写数字识别的输入是 28 × 28 28 × 28 28×28 的像素值,输出是 0 - 9 的数字标签,而线性回归模型无法捕捉二维图像数据中蕴含的复杂信息,如下图所示。无论是牛顿第二定律任务,还是房价预测任务,输入特征和输出预测值之间的关系均可以使用“直线”刻画(使用线性方程来表达)。但手写数字识别任务的输入像素和输出数字标签之间的关系显然不是线性的,甚至这个关系复杂到我们靠人脑难以直观理解的程度。
因此,我们需要尝试使用其他更复杂、更强大的网络来构建手写数字识别任务,观察一下训练效果,即将“横纵式”教学法从横向展开,如下图所示。本节主要介绍两种常见的网络结构:
在介绍网络结构前,需要先进行数据处理,代码与上一节保持一致。
数据集下载地址:https://aistudio.baidu.com/aistudio/datasetdetail/116648
import os
import random
import paddle
import numpy as np
import matplotlib.pyplot as plt
import gzip
import json
import paddle.nn as nn
import paddle.nn.functional as F
import paddle.optimizer as opt
import argparse
# 定义数据集读取器
# 定义数据集读取器
def load_data(mode="train", batch_size=4):
print("Loading MNIST dataset form {}...".format(args.dataset_path))
data = json.load(gzip.open(args.dataset_path))
print("MNIST Dataset has been loaded!")
# 对数据集进行划分
train_set, val_set, test_set = data
img_rows = 28
img_cols = 28
if mode == "train":
imgs, labels = train_set[0], train_set[1]
elif mode == "valid":
imgs, labels = val_set[0], val_set[1]
elif mode == "eval":
imgs, labels = test_set[0], test_set[1]
else:
raise Exception("mode can only be one of ['train', 'valid', 'eval']")
# 校验数据
imgs_length = len(imgs)
assert len(imgs) == len(labels), "length of train_imgs({}) should be the same as train_labels({})".format(len(imgs), len(labels))
# 定义数据集每个数据的序号,根据序号读取数据
index_lst = list(range(imgs_length))
# 定义数据生成器
def data_generator():
if mode == "train":
random.shuffle(index_lst)
imgs_lst = []
labels_lst = []
for i in index_lst:
# 在深度学习中,常见的数据类型是32位浮点数(float32),因为这种数据类型在数值计算中具有较好的精度和效率
# 并且在常见的深度学习框架中也是默认的数据类型
img = np.array(imgs[i]).astype("float32")
label = np.array(labels[i]).astype("float32")
img = np.reshape(imgs[i], newshape=[1, img_rows, img_cols]).astype("float32") # [H, W] -> [C, H, W]
label = np.reshape(labels[i], newshape=[1]).astype("float32")
imgs_lst.append(img)
labels_lst.append(label)
if len(imgs_lst) == batch_size:
yield np.array(imgs_lst), np.array(labels_lst) # 返回一个迭代器
imgs_lst = []
labels_lst = []
# 如果剩余数据的数目小于batch size,则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
if len(imgs_lst) > 0:
yield np.array(imgs_lst), np.array(labels_lst)
return data_generator
if __name__ == "__main__":
train_data_generator = load_data(mode="train")
valid_data_generator = load_data(mode="valid")
test_data_generator = load_data(mode="eval")
# 生成训练集数据
for batch_imgs, batch_labels in train_data_generator():
# 这里可以将batch_imgs和batch_labels用于训练深度学习模型的一次训练
...
# 生成验证集数据
for batch_imgs, batch_labels in valid_data_generator():
# 这里可以将batch_imgs和batch_labels用于验证深度学习模型的性能
...
# 生成测试集数据
for batch_imgs, batch_labels in test_data_generator():
# 这里可以将batch_imgs和batch_labels用于测试深度学习模型的性能
...
说明:以上数据加载函数 load_data
返回一个数据迭代器 train_loader
,该 train_loader
在每次迭代时的数据 shape
为 [batch_size, 784]
,因此需要将该数据形式 reshape
为图像数据形式 [batch_size, 1, 28, 28]
,其中第二维代表图像的通道数(在 MNIST 数据集中每张图片的通道数为 1,传统 RGB 图片通道数为 3)。
经典的全连接神经网络来包含四层网络:①输入层、②③两个隐含层和④输出层,将手写数字识别任务通过全连接神经网络表示,如下图所示。
说明:隐含层引入非线性激活函数 Sigmoid 是为了增加神经网络的非线性能力。
举例来说,如果一个神经网络采用线性变换,有四个输入 x 1 x_1 x1 ~ x 4 x_4 x4 ,一个输出 y y y。假设第一层的变换是 z 1 = x 1 − x 2 z_1 = x_1 - x_2 z1=x1−x2 和 z 2 = x 3 + x 4 z_2 = x_3 + x_4 z2=x3+x4 ,第二层的变换是 y = z 1 + z 2 y = z_1 + z_2 y=z1+z2,则将两层的变换展开后得到 y = x 1 − x 2 + x 3 + x 4 y = x_1 - x_2 + x_3 + x_4 y=x1−x2+x3+x4 。也就是说,无论中间累积了多少层线性变换,原始输入和最终输出之间依然是线性关系。
Sigmoid 是早期神经网络模型中常见的非线性变换函数,通过如下代码,绘制出 Sigmoid 的函数曲线。
x = np.arange(-8, 8, 0.2)
y = sigmoid(x)
plt.plot(x, y)
plt.xlabel("x-axis")
plt.ylabel("y-axis")
plt.title("Sigmoid Curve Demonstration")
plt.savefig("sigmoid.png")
针对手写数字识别的任务,网络层的设计如下:
下述代码为经典全连接神经网络的实现。完成网络结构定义后,即可训练神经网络。
# 全连接层神经网络实现
class MNIST_FC_Model(nn.Layer):
def __init__(self):
super(MNIST_FC_Model, self).__init__()
# 定义两层全连接隐含层,输出维度是10,当前设定隐含节点数为10,可根据任务调整
self.classifier = nn.Sequential(nn.Linear(in_features=784, out_features=10),
nn.Sigmoid(),
nn.Linear(in_features=10, out_features=10),
nn.Sigmoid())
# 定义一层全连接输出层,输出维度是1
self.head = nn.Linear(in_features=10, out_features=1)
def forward(self, x):
# x.shape: [bath size, 1, 28, 28]
x = paddle.flatten(x, start_axis=1) # [bath size, 784]
x = self.classifier(x)
y = self.head(x)
return y
训练代码如下:
def plot_loss_curve(loss_list):
plt.figure(figsize=(10,5))
freqs = [i for i in range(1, len(loss_list) + 1)]
# 绘制训练损失变化曲线
plt.plot(freqs, loss_list, color='#e4007f', label="Train loss")
# 绘制坐标轴和图例
plt.ylabel("loss", fontsize='large')
plt.xlabel("epoch", fontsize='large')
plt.legend(loc='upper right', fontsize='x-large')
plt.savefig(f"train_loss_curve for {args.model_name}.png")
class ModelNameError(Exception):
pass
def main(args):
# 设置使用CPU还是GPU训练
paddle.set_device(args.device)
load_data(mode="train")
# 定义模型
if args.model_name == "FC":
model = MNIST_FC_Model()
elif args.model_name == "CNN":
model = MNIST_CNN_Model()
else:
raise ModelNameError("请选择正确的模型(CNN或FC)!")
model.train()
# 调用加载数据的函数,获取 MNIST 训练数据集
train_loader = load_data("train", batch_size=args.batch_size)
# 定义 SGD 优化器
optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
# 保存loss
loss_list = []
for epoch in range(1, args.epochs+1):
epoch_loss = []
for data in train_loader():
imgs, labels = data
imgs = paddle.to_tensor(imgs)
labels = paddle.to_tensor(labels)
# 前向推理
preds = model(imgs)
# 计算损失
loss = F.square_error_cost(preds, labels)
avg_loss = paddle.mean(loss)
# 反向传播
avg_loss.backward()
# 保存每次迭代的损失
epoch_loss.append(avg_loss.item())
"""
Note:
对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
0-D其实就是一个list, shape为 (165, )
print(f"epoch_loss: {np.shape(epoch_loss)}") # epoch_loss: (254,)
print(f"type: {type(epoch_loss)}") # type:
"""
# 优化器
optimizer.step()
# 清空梯度
optimizer.clear_grad()
# 保存模型和优化器参数
if epoch % 10 == 0:
paddle.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict()
}, path=f"{args.save_path}/model_{args.model_name}_{epoch}.pdparams")
# 保存每个epoch的loss
current_epoch_loss = np.mean(epoch_loss)
loss_list.append(current_epoch_loss)
epoch_loss.clear()
print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}")
print(f"模型最终loss为: {loss_list[-1]:.4f}")
# 绘制Loss-Epoch曲线图
plot_loss_curve(loss_list)
print(model)
def parse_args():
parser = argparse.ArgumentParser()
# 超参数
parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
parser.add_argument("--lr", type=float, default=0.01, help="Learning rate")
parser.add_argument("--batch_size", type=int, default=100, help="Batch size")
parser.add_argument("--dataset_path", type=str, default="../Datasets/mnist.json.gz", help="Path to the dataset file")
parser.add_argument("--save_path", type=str, default="results/", help="The path of saving model & params")
parser.add_argument("--device", type=str, default="gpu", help="cpu or cuda")
# parser.add_argument("num_classes", type=int, default=1, help="Number of classes")
parser.add_argument("--model_name", type=str, default="CNN", help="The name of saving model (CNN or FC)")
# 解析命令行参数
args = parser.parse_args()
return args
if __name__ == "__main__":
# 固定随机种子
seed = 10010
paddle.seed(seed)
np.random.seed(seed)
random.seed(seed)
args = parse_args()
if not os.path.exists(args.save_path):
os.mkdir(args.save_path)
main(args)
训练结果:
Loading MNIST dataset form ../Datasets/mnist.json.gz...
MNIST Dataset has been loaded!
W0806 21:53:44.839164 19704 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 8.6, Driver API Version: 11.6, Runtime API Version: 11.6
W0806 21:53:44.844149 19704 gpu_resources.cc:149] device: 0, cuDNN Version: 8.0.
Loading MNIST dataset form ../Datasets/mnist.json.gz...
MNIST Dataset has been loaded!
Epoch: 1 Loss: 6.2972
Epoch: 2 Loss: 3.4520
Epoch: 3 Loss: 2.8110
Epoch: 4 Loss: 2.4083
Epoch: 5 Loss: 2.1359
Epoch: 6 Loss: 1.9417
Epoch: 7 Loss: 1.7970
Epoch: 8 Loss: 1.6858
Epoch: 9 Loss: 1.5899
Epoch: 10 Loss: 1.5088
模型最终loss为: 1.5088
MNIST_FC_Model(
(classifier): Sequential(
(0): Linear(in_features=784, out_features=10, dtype=float32)
(1): Sigmoid()
(2): Linear(in_features=10, out_features=10, dtype=float32)
(3): Sigmoid()
)
(head): Linear(in_features=10, out_features=1, dtype=float32)
)
虽然使用经典的全连接神经网络可以提升一定的准确率,但其输入数据的形式导致丢失了图像像素间的空间信息,这影响了网络对图像内容的理解。对于计算机视觉问题,效果最好的模型仍然是卷积神经网络。卷积神经网络针对视觉问题的特点进行了网络结构优化,可以直接处理原始形式的图像数据,保留像素间的空间信息,因此更适合处理视觉问题。
卷积神经网络由多个卷积层和池化层组成,如下图所示。卷积层负责对输入进行扫描以生成更抽象的特征表示,池化层对这些特征表示进行过滤,保留最关键的特征信息。
两层卷积和池化的神经网络实现如下所示。
# 多层卷积神经网络实现
class MNIST_CNN_Model(paddle.nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=1)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x)
return x
训练代码如下:
def plot_loss_curve(loss_list):
plt.figure(figsize=(10,5))
freqs = [i for i in range(1, len(loss_list) + 1)]
# 绘制训练损失变化曲线
plt.plot(freqs, loss_list, color='#e4007f', label="Train loss")
# 绘制坐标轴和图例
plt.ylabel("loss", fontsize='large')
plt.xlabel("epoch", fontsize='large')
plt.legend(loc='upper right', fontsize='x-large')
plt.savefig(f"train_loss_curve for {args.model_name}.png")
class ModelNameError(Exception):
pass
def main(args):
# 设置使用CPU还是GPU训练
paddle.set_device(args.device)
load_data(mode="train")
# 定义模型
if args.model_name == "FC":
model = MNIST_FC_Model()
elif args.model_name == "CNN":
model = MNIST_CNN_Model()
else:
raise ModelNameError("请选择正确的模型(CNN或FC)!")
model.train()
# 调用加载数据的函数,获取 MNIST 训练数据集
train_loader = load_data("train", batch_size=args.batch_size)
# 定义 SGD 优化器
optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
# 保存loss
loss_list = []
for epoch in range(1, args.epochs+1):
epoch_loss = []
for data in train_loader():
imgs, labels = data
imgs = paddle.to_tensor(imgs)
labels = paddle.to_tensor(labels)
# 前向推理
preds = model(imgs)
# 计算损失
loss = F.square_error_cost(preds, labels)
avg_loss = paddle.mean(loss)
# 反向传播
avg_loss.backward()
# 保存每次迭代的损失
epoch_loss.append(avg_loss.item())
"""
Note:
对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
0-D其实就是一个list, shape为 (165, )
print(f"epoch_loss: {np.shape(epoch_loss)}") # epoch_loss: (254,)
print(f"type: {type(epoch_loss)}") # type:
"""
# 优化器
optimizer.step()
# 清空梯度
optimizer.clear_grad()
# 保存模型和优化器参数
if epoch % 10 == 0:
paddle.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict()
}, path=f"{args.save_path}/model_{args.model_name}_{epoch}.pdparams")
# 保存每个epoch的loss
current_epoch_loss = np.mean(epoch_loss)
loss_list.append(current_epoch_loss)
epoch_loss.clear()
print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}")
print(f"模型最终loss为: {loss_list[-1]:.4f}")
# 绘制Loss-Epoch曲线图
plot_loss_curve(loss_list)
print(model)
def parse_args():
parser = argparse.ArgumentParser()
# 超参数
parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
parser.add_argument("--lr", type=float, default=0.01, help="Learning rate")
parser.add_argument("--batch_size", type=int, default=100, help="Batch size")
parser.add_argument("--dataset_path", type=str, default="../Datasets/mnist.json.gz", help="Path to the dataset file")
parser.add_argument("--save_path", type=str, default="results/", help="The path of saving model & params")
parser.add_argument("--device", type=str, default="gpu", help="cpu or cuda")
# parser.add_argument("num_classes", type=int, default=1, help="Number of classes")
parser.add_argument("--model_name", type=str, default="CNN", help="The name of saving model (CNN or FC)")
# 解析命令行参数
args = parser.parse_args()
return args
if __name__ == "__main__":
# 固定随机种子
seed = 10010
paddle.seed(seed)
np.random.seed(seed)
random.seed(seed)
args = parse_args()
if not os.path.exists(args.save_path):
os.mkdir(args.save_path)
main(args)
结果如下:
Loading MNIST dataset form ../Datasets/mnist.json.gz...
MNIST Dataset has been loaded!
W0806 22:08:38.485733 9196 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 8.6, Driver API Version: 11.6, Runtime API Version: 11.6
W0806 22:08:38.491715 9196 gpu_resources.cc:149] device: 0, cuDNN Version: 8.0.
Loading MNIST dataset form ../Datasets/mnist.json.gz...
MNIST Dataset has been loaded!
W0806 22:08:49.360900 9196 gpu_resources.cc:275] WARNING: device: . The installed Paddle is compiled with CUDNN 8.4, but CUDNN version in your machine is 8.0, which may cause serious incompatible bug. Please recompile or reinstall Paddle with compatible CUDNN version.
Epoch: 1 Loss: 8.0465
Epoch: 2 Loss: 2.1561
Epoch: 3 Loss: 1.8953
Epoch: 4 Loss: 1.7307
Epoch: 5 Loss: 1.6125
Epoch: 6 Loss: 1.5700
Epoch: 7 Loss: 1.4907
Epoch: 8 Loss: 1.4380
Epoch: 9 Loss: 1.3960
Epoch: 10 Loss: 1.3458
模型最终loss为: 1.3458
MNIST_CNN_Model(
(classifier): Sequential(
(0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(1): ReLU()
(2): MaxPool2D(kernel_size=2, stride=2, padding=0)
(3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(4): ReLU()
(5): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=1, dtype=float32)
)
比较经典全连接神经网络和卷积神经网络的损失变化,可以发现卷积神经网络的损失值下降更快,且最终的损失值更小。
前几章的内容中,我们尝试通过更复杂的模型(经典的全连接神经网络和卷积神经网络),提升手写数字识别模型训练的准确性。本节我们继续将“横纵式”教学法从横向展开,如下图所示,探讨损失函数的优化对模型训练效果的影响。
损失函数是模型优化的目标,用于在众多的参数取值中,识别最理想的取值。损失函数的计算在训练过程的代码中,每一轮模型训练的过程都相同,分如下三步:
在之前的方案中,我们复用了房价预测模型的损失函数-均方误差(MSE)。从预测效果来看,虽然损失不断下降,模型的预测值逐渐逼近真实值,但模型的最终效果不够理想。究其根本,不同的深度学习任务需要有各自适宜的损失函数。我们以房价预测和手写数字识别两个任务为例,详细剖析其中的缘由如下:
那么,什么是分类任务的合理输出呢?分类任务本质上是“某种特征组合下的分类概率”,下面以一个简单案例说明,如下图所示。
在本案例中,医生根据肿瘤大小 x x x 作为肿瘤性质 y y y 的参考判断(判断的因素有很多,肿瘤大小只是其中之一),那么我们观测到该模型判断的结果是 x x x 和 y y y 的标签(1 为恶性,0 为良性)。而这个数据背后的规律是不同大小的肿瘤,属于恶性肿瘤的概率。观测数据是真实规律抽样下的结果,分类模型应该拟合这个真实规律,输出属于该分类标签的概率。
在 PyTorch 和 PaddlePaddle 中,Softmax 函数的主要作用是将原始的模型输出转换为概率分布。
在深度学习中,特别是分类任务中,通常模型的最后一层输出是一组原始的分数或得分(score),并且这些分数并不符合概率分布的要求,即它们不一定在 [ 0 , 1 ] [0, 1] [0,1] 范围内,也不一定和为 1。
Softmax 函数会对这些原始分数进行转换,将它们转换为概率分布,使得每个类别的预测概率在 [ 0 , 1 ] [0, 1] [0,1] 范围内,并且所有类别的概率之和为 1。
Softmax 函数的定义如下:
对于给定的输入向量 z = [ z 1 , z 2 , . . . , z n ] z = [z_1, z_2, ..., z_n] z=[z1,z2,...,zn],Softmax 函数的输出 y = [ y 1 , y 2 , . . . , y n ] y = [y_1, y_2, ..., y_n] y=[y1,y2,...,yn] 计算如下:
y i = exp ( z i ) ∑ ( exp ( z 1 ) , exp ( z 2 ) , . . . , exp ( z n ) ) y_i = \frac{\exp(z_i)}{\sum(\exp(z_1), \exp(z_2), ..., \exp(z_n))} yi=∑(exp(z1),exp(z2),...,exp(zn))exp(zi)
其中 exp ( x ) \exp(x) exp(x) 是自然指数函数。
在 PyTorch 中,可以使用 torch.nn.functional.softmax
函数来进行 Softmax 操作,而在 PaddlePaddle 中,可以使用 paddle.nn.functional.softmax
函数。
在分类任务中,Softmax 函数通常与交叉熵损失函数(CrossEntropyLoss)结合使用,用于将模型的输出转换为概率分布,并计算损失。这样可以方便地进行多类别分类问题的训练和评估。
如果模型能输出 10 个标签的概率,对应真实标签的概率输出尽可能接近 100%,而其他标签的概率输出尽可能接近 0%,且所有输出概率之和为 1。这是一种更合理的假设!与此对应,真实的标签值可以转变成一个 10 维度的 one-hot 向量,在对应数字的位置上为 1,其余位置为 0,比如标签 “6” 可以转变成 [0,0,0,0,0,0,1,0,0,0]
。
为了实现上述思路,需要引入 Softmax 函数,它可以将原始输出转变成对应标签的概率,公式如下,其中 C C C 是标签类别个数。
s o f t m a x ( x i ) = e x i ∑ j = 0 N i = 0 , 1 , . . . , C − 1 \mathrm{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j=0}^N}\quad i=0, 1, ..., C-1 softmax(xi)=∑j=0Nexii=0,1,...,C−1
从公式的形式可见,每个输出的范围均为 [ 0 , 1 ] [0, 1] [0,1],且所有输出之和等于 1,这是这种变换后可被解释成概率的基本前提。对应到代码上,需要在前向计算中,对全连接网络的输出层增加一个 Softmax 运算:outputs = F.softmax(outputs)
。
下图是一个三个标签的分类模型(三分类)使用的 Softmax 输出层,从中可见原始输出的三个数字 3、1、-3,经过 Softmax 层后转变成加和为 1 的三个概率值 0.88、0.12、0。
上文解释了为何让分类模型的输出拟合概率的原因,但为何偏偏用 Softmax 函数完成这个职能? 下面以二分类问题(只输出两个标签)进行原理的探讨。
对于二分类问题,使用两个输出接入 Softmax 作为输出层,等价于使用单一输出接入 Sigmoid 函数。如下图所示,利用两个标签的输出概率之和为 1 的条件,Softmax 输出 0.6 和 0.4 两个标签概率,从数学上等价于输出一个标签的概率 0.6。
在这种情况下,只有一层的模型为 S ( w T x i ) S(w^T x_i) S(wTxi), S S S 为 Sigmoid 函数。模型预测为 1 的概率为 S ( w T x i ) S(w^T x_i) S(wTxi),模型预测为 0 的概率为 1 − S ( w T x i ) 1-S(w^T x_i) 1−S(wTxi)。
下图是肿瘤大小和肿瘤性质的数据图。从图中可发现,往往尺寸越大的肿瘤几乎全部是恶性,尺寸极小的肿瘤几乎全部是良性。只有在中间区域,肿瘤的恶性概率会从 0 逐渐到 1(绿色区域),这种数据的分布是符合多数现实问题的规律。如果我们直接线性拟合,相当于红色的直线,会发现直线的纵轴 0-1 的区域会拉的很长,而我们期望拟合曲线 0-1 的区域与真实的分类边界区域重合。那么,观察下 Sigmoid 的曲线趋势可以满足我们对个问题的一切期望,它的概率变化会集中在一个边界区域,有助于模型提升边界区域的分辨率。
这就类似于公共区域使用的带有恒温装置的热水器温度阀门,如下图所示。由于人体适应的水温在 34 ℃ ~ 42℃ 之间,我们更期望阀门的水温条件集中在这个区域,而不是在 0 ℃ ~ 100℃ 度之间线性分布。
在模型输出为分类标签的概率时,直接以标签和概率做比较也不够合理,人们更习惯使用交叉熵误差作为分类问题的损失衡量。
交叉熵损失函数的设计是基于最大似然思想:最大概率得到观察结果的假设是真的。如何理解呢?举个例子来说,如下图所示。
有两个外形相同的盒子,甲盒中有 99 个白球,1 个蓝球;乙盒中有 99 个蓝球,1 个白球。一次试验取出了一个蓝球,请问这个球应该是从哪个盒子中取出的?
相信大家简单思考后均会得出更可能是从乙盒中取出的,因为从乙盒中取出一个蓝球的概率( P ( D ∣ h ) P(D|h) P(D∣h))更高,所以观察到一个蓝球更可能是从乙盒中取出的( P ( h ∣ D ) P(h|D) P(h∣D))。 D D D 是观测的数据,即蓝球白球; h h h 是模型,即甲盒乙盒。这就是贝叶斯公式所表达的思想:
P ( h ∣ D ) ∝ P ( h ) ⋅ P ( D ∣ h ) (1) P(h|D) \propto P(h) \cdot P(D|h) \tag{1} P(h∣D)∝P(h)⋅P(D∣h)(1)
其中,符号" ∝ \propto ∝"表示"与之成正比"。这意味着 P ( h ∣ D ) P(h|D) P(h∣D)与 P ( h ) ⋅ P ( D ∣ h ) P(h) \cdot P(D|h) P(h)⋅P(D∣h)成正比,它们之间存在一个比例关系。
依据贝叶斯公式,某二分类模型“生成” n n n 个训练样本的概率为:
P ( x 1 ) ⋅ S ( w T x 1 ) ⋅ P ( x 2 ) ⋅ ( 1 − S ( w T x 2 ) ) ⋅ . . . ⋅ P ( x n ) ⋅ S ( w T x n ) (2) P(x_1) \cdot S(w^Tx_1) \cdot P(x_2) \cdot (1 - S(w^Tx_2)) \cdot ... \cdot P(x_n) \cdot S(w^Tx_n) \tag{2} P(x1)⋅S(wTx1)⋅P(x2)⋅(1−S(wTx2))⋅...⋅P(xn)⋅S(wTxn)(2)
公式 2 是对某个二分类模型生成 n n n 个训练样本的概率进行建模的表达式。我们解释一下其中的符号和含义:
现在,我们可以逐个解释这个公式:
P ( x 1 ) ⋅ S ( w T x 1 ) P(x_1) \cdot S(w^T x_1) P(x1)⋅S(wTx1):表示样本 x 1 x_1 x1 的特征发生的概率乘以在给定特征 x 1 x_1 x1 的条件下,模型预测为正例(类别为 1)的概率。这里使用了 sigmoid 函数将模型的预测转化为概率值。
P ( x 2 ) ⋅ ( 1 − S ( w T x 2 ) ) P(x_2) \cdot (1 - S(w^T x_2)) P(x2)⋅(1−S(wTx2)):表示样本 x 2 x_2 x2 的特征发生的概率乘以在给定特征 x 2 x_2 x2 的条件下,模型预测为负例(类别为 0)的概率。由于 sigmoid 函数将正例概率映射为 ( 0 , 1 ) (0,1) (0,1) 区间,所以负例的概率为 ( 1 − 正例概率 ) (1 - \text{正例概率}) (1−正例概率)。
…以此类推,对于 n n n 个样本,我们将每个样本特征发生的概率与相应的模型预测概率进行乘积。
这个公式通常用于构建一个似然函数,用于最大化似然估计来拟合二分类模型。通过最大化这个似然函数,我们可以找到最优的权重参数 w w w,使得模型在训练数据上的拟合效果最好。
需要注意的是,这个公式是一个假设建模,它假设样本特征和模型预测之间是相互独立的,这在实际场景中可能并不总是成立。因此,它只是在二分类模型中的一种简化假设。在实际应用中,我们可能会使用更复杂的模型和方法来建模和拟合数据。
说明:对于二分类问题,模型为 S ( w T x i ) S(w^Tx_i) S(wTxi), S S S 为 Sigmoid 函数:
经过公式推导,使得上述概率最大等价于最小化交叉熵,得到交叉熵的损失函数。交叉熵的公式如下:
L = − [ ∑ k = 1 n t k log y k + ( 1 − t k ) log ( 1 − y k ) ] L = -\left[ \sum_{k=1}^n t_k \log{y_k} + (1 - t_k) \log{(1 - y_k)} \right] L=−[k=1∑ntklogyk+(1−tk)log(1−yk)]
其中, log \log log 表示以 e e e 为底数的自然对数。 y k y_k yk 代表模型输出, t k t_k tk 代表各个标签。 t k t_k tk 中只有正确解的标签为 1,其余均为 0(one-hot表示)。
因此,交叉熵只计算对应着“正确解”标签的输出的自然对数。比如,假设正确标签的索引是“2”,与之对应的神经网络的输出是 0.6,则交叉熵误差是 − log 0.6 = 0.51 −\log{0.6}=0.51 −log0.6=0.51;若“2”对应的输出是 0.1,则交叉熵误差为 − log 0.1 = 2.30 −\log{0.1}=2.30 −log0.1=2.30。由此可见,交叉熵误差的值是由正确标签所对应的输出结果决定的。
自然对数的函数曲线可由如下代码实现。
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(0.01, 1.0, 0.01)
y = np.log(x)
plt.figure()
plt.plot(x, y)
plt.title("$y = \log(x)$")
plt.xlabel("x-axis")
plt.ylabel("y-axis")
plt.savefig("log.png")
如自然对数的图形所示,当 x x x 等于 1 时, y y y 为 0;随着 x x x 向 0 靠近, y y y 逐渐变小。因此,正确解标签对应的输出越大(预测越正确),交叉熵的值越接近 0;当输出为 1 时,交叉熵误差为 0。反之,如果正确解标签对应的输出越小(预测越错误),则交叉熵的值越大。
在手写数字识别任务中,仅改动三行代码,就可以将在现有模型的损失函数替换成交叉熵(Cross_entropy)。
int
,体现它是一个标签而不是实数值(飞桨框架默认将标签处理成 int64
)。在数据处理部分,需要修改标签变量 labels
的格式,代码如下所示。
label = np.reshape(labels[i], [1]).astype(‘float32’)
label = np.reshape(labels[i], [1]).astype(‘int64’)
class MNIST_Dataset(io.Dataset):
"""创建一个类MnistDataset,继承paddle.io.Dataset 这个类
MnistDataset的作用和上面load_data()函数的作用相同,均是构建一个迭代器
Args:
io (_type_): _description_
"""
def __init__(self, mode="train"):
data = json.load(gzip.open(args.dataset_path))
train_set, val_set, test_set = data
if mode == "train":
self.imgs, self.labels = train_set[0], train_set[1]
elif mode == "valid":
self.imgs, self.labels = val_set[0], val_set[1]
elif mode == "eval":
self.imgs, self.labels = test_set[0], test_set[1]
else:
raise Exception("mode can only be one of ['train', 'valid', 'eval']")
# 校验数据
assert len(self.imgs) == len(self.labels), "length of train_imgs({}) should be the same as train_labels({})".format(len(self.imgs), len(self.labels))
def __getitem__(self, idx):
# img = np.array(self.imgs[idx]).astype('float32')
# label = np.array(self.labels[idx]).astype('int64')
img = np.reshape(self.imgs[idx], newshape=[1, 28, 28]).astype("float32")
label = np.reshape(self.labels[idx], newshape=[1]).astype("int64")
return img, label
def __len__(self):
return len(self.imgs)
在网络定义部分,需要修改输出层结构,代码如下所示。
self.fc = Linear(in_features=980, out_features=1)
self.fc = Linear(in_features=980, out_features=10)
# 多层卷积神经网络实现
class MNIST_CNN_Model(nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=10)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x)
return x
修改计算损失的函数,从均方误差(常用于回归问题)到交叉熵误差(常用于分类问题),代码如下所示。
loss = paddle.nn.functional.square_error_cost(predict, label)
loss = paddle.nn.functional.cross_entropy(predict, label)
def evaluation(model: nn.Layer, datasets):
model.eval()
acc_list = []
for batch_idx, data in enumerate(datasets()):
imgs, labels = data
imgs = paddle.to_tensor(imgs)
labels = paddle.to_tensor(labels)
pred = model(imgs)
acc = metric.accuracy(input=pred, label=labels)
acc_list.append(acc.item())
# 计算多个batch的平均准确率
acc_val_mean = np.array(acc_list).mean()
return acc_val_mean
def main(args):
# 设置使用CPU还是GPU训练
paddle.set_device(args.device)
# 定义模型
if args.model_name == "FC":
model = MNIST_FC_Model()
elif args.model_name == "CNN":
model = MNIST_CNN_Model()
else:
raise ModelNameError("请选择正确的模型(CNN或FC)!")
model.train()
# 加载数据,获取 MNIST 训练数据集
train_dataset = MNIST_Dataset(mode="train")
val_dataset = MNIST_Dataset(mode="valid")
# 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
# DataLoader 返回的是一个批次数据迭代器,并且是异步的;
train_loader = io.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
val_loader = io.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, drop_last=True)
# 定义 SGD 优化器
optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
# 保存loss
loss_list = []
acc_list = []
for epoch in range(1, args.epochs+1):
epoch_loss = []
for data in train_loader():
imgs, labels = data
imgs = paddle.to_tensor(imgs)
labels = paddle.to_tensor(labels)
# 前向推理
preds = model(imgs)
# 计算损失
loss = F.cross_entropy(preds, labels)
avg_loss = paddle.mean(loss)
# 反向传播
avg_loss.backward()
# 保存每次迭代的损失
epoch_loss.append(avg_loss.item()) # type: ignore
"""
Note:
对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
0-D其实就是一个list, shape为 (165, )
print(f"epoch_loss: {np.shape(epoch_loss)}") # epoch_loss: (254,)
print(f"type: {type(epoch_loss)}") # type:
"""
# 优化器
optimizer.step()
# 清空梯度
optimizer.clear_grad()
# 保存模型和优化器参数
if epoch % 10 == 0:
paddle.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict()
}, path=f"{args.save_path}/model_{args.model_name}_{epoch}.pdparams")
# 保存每个epoch的loss
current_epoch_loss = np.mean(epoch_loss)
loss_list.append(current_epoch_loss)
epoch_loss.clear()
acc_epoch = evaluation(model, val_loader)
acc_list.append(acc_epoch)
print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}\tacc: {acc_epoch*100:.4f}%")
print(f"模型最终loss为: {loss_list[-1]:.4f}")
print(f"模型最终accuracy为: {acc_list[-1]*100:.4f}%")
# 绘制Loss-Epoch曲线图
plot_loss_curve(loss_list)
print(model)
结果:
W0807 03:46:48.053212 49884 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 7.5, Driver API Version: 10.2, Runtime API Version: 10.2
W0807 03:46:48.055718 49884 gpu_resources.cc:149] device: 0, cuDNN Version: 8.2.
Epoch: 1 Loss: 0.5758 acc: 93.1800%
Epoch: 2 Loss: 0.2281 acc: 95.5700%
Epoch: 3 Loss: 0.1672 acc: 96.4800%
Epoch: 4 Loss: 0.1347 acc: 96.9400%
Epoch: 5 Loss: 0.1132 acc: 97.3300%
Epoch: 6 Loss: 0.1001 acc: 97.5700%
Epoch: 7 Loss: 0.0905 acc: 97.6500%
Epoch: 8 Loss: 0.0829 acc: 97.8600%
Epoch: 9 Loss: 0.0775 acc: 97.8300%
Epoch: 10 Loss: 0.0724 acc: 97.9400%
模型最终loss为: 0.0724
模型最终accuracy为: 97.9400%
MNIST_CNN_Model(
(classifier): Sequential(
(0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(1): ReLU()
(2): MaxPool2D(kernel_size=2, stride=2, padding=0)
(3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(4): ReLU()
(5): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=10, dtype=float32)
)
虽然上述训练过程的损失明显比使用均方误差算法要小,但因为损失函数量纲的变化,我们无法从比较两个不同的 Loss 得出谁更加优秀。怎么解决这个问题呢?我们可以回归到问题的本质,谁的分类准确率更高来判断。
全连接神经网络、卷积神经网络,在模型的最后阶段,都是使用 Softmax 进行处理。
现在我们训练好了模型,可以使用模型进行预测,预测图片如下:
由于我们修改了模型的输出格式,因此使用模型做预测时的代码也需要做相应的调整。从模型输出 10 个标签的概率中选择最大的,将其标签编号输出。
def load_one_img():
img = Image.open(args.img_path).convert("L") # 转为灰度图
img = img.resize((28, 28))
img = np.array(img).reshape(1, 1, 28, 28).astype(np.float32)
# 归一化
img = 1.0 - img / 255
return img
def predict():
# 读取要预测的图片
img = load_one_img()
img = paddle.to_tensor(img)
# 定义模型
if args.model_name == "FC":
model = MNIST_FC_Model()
elif args.model_name == "CNN":
model = MNIST_CNN_Model()
else:
raise ModelNameError("请选择正确的模型(CNN或FC)!")
# 加载模型权重
param_state_dict = paddle.load(args.weights_path)
model.load_dict(param_state_dict["model_state_dict"])
# 声明模型状态
model.eval()
# 前向推理
pred = model(img)
"""
推理结果为: Tensor(shape=[1, 10], dtype=float32, place=Place(gpu:0), stop_gradient=False,
[[0.00000163, 0.00267692, 0.00088234, 0.04414432, 0.00028779, 0.00000287,
0.00000097, 0.95190734, 0.00004345, 0.00005248]])
推理结果.shape为: [1, 10]
推理结果.type为:
"""
# 取概率最大的位置
max_class = paddle.argmax(pred).item() # type: ignore
# 画出这张图片并给出相关信息
# 将图片数据转换为 PIL 图像对象
img_data = img.numpy()[0][0] * 255 # type: ignore
img_data = img_data.astype(np.uint8)
img_pil = Image.fromarray(img_data, "L")
# 显示图片
plt.imshow(img_data, cmap='gray')
plt.title(f"Predicted Image -> class: {max_class} | prob: {pred[:, max_class].item() * 100:.2f}%")
plt.axis('off') # 去除坐标轴
plt.savefig("predict_res.png")
print(f"预测值的数字为: {max_class}\t预测概率为: {pred[:, max_class].item() * 100:.2f}%")
结果如下:
W0807 06:41:04.598986 152326 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 7.5, Driver API Version: 10.2, Runtime API Version: 10.2
W0807 06:41:04.601702 152326 gpu_resources.cc:149] device: 0, cuDNN Version: 8.2.
预测值的数字为: 7 预测概率为: 95.19
上一章我们明确了分类任务的损失函数(优化目标)的相关概念和实现方法,本节我们依旧横向展开"横纵式"教学法,如下图所示,本节主要探讨在手写数字识别任务中,使得损失达到最小的参数取值的实现方法。
在深度学习神经网络模型中,通常使用标准的随机梯度下降算法更新参数,学习率代表参数更新幅度的大小,即步长。当学习率最优时,模型的有效容量最大,最终能达到的效果最好。学习率和深度学习任务类型有关,合适的学习率往往需要大量的实验和调参经验。
探索学习率最优值时需要注意如下两点:
在训练前,我们往往不清楚一个特定问题设置成怎样的学习率是合理的,因此在训练时可以尝试调小或调大,通过观察 Loss 下降的情况判断合理的学习率,设置学习率的代码如下所示。
parser.add_argument("--lr", type=float, default=0.1, help="Learning rate")
这里我们设置 3 种不同的学习率:[0.9, 0.5, 0.25, 0.1, 0.01, 0.001]
,看一下模型的 Loss 曲线和准确率(模型为 CNN)。
学习率 | 训练集Loss | 验证集准确率 |
---|---|---|
0.9 | 2.3030 | 9.90% |
0.7 | 2.3026 | 9.90% |
0.5 | 0.0334 | 98.60% |
0.25 | 0.0276 | 98.85% |
0.1 | 0.0252 | 98.79% |
0.01 | 0.0724 | 97.94% |
0.001 | 0.2686 | 93.76% |
画一下图像(这里因为 lr=0.9
和 lr=0.5
的结果太离谱,所以不画了):
根据给出的结果表格,我们可以看到在不同的学习率下,模型在训练集上的 Loss 和在验证集上的准确率表现是不同的。
学习率为 0.9 和 0.7 时:
当学习率较大(0.9 和 0.7)时,模型在训练集上的 Loss 较高(2.3030 和 2.3026),而在验证集上的准确率都为 9.90%。这种情况下,学习率过大可能导致模型在训练过程中跳过最优解,导致训练不稳定,从而影响了模型的性能。
学习率为 0.5、0.25 和 0.1 时:
当学习率适中(0.5、0.25 和 0.1)时,模型在训练集上的 Loss 较低,特别是学习率为 0.1 时,训练集 Loss 最低(0.0252)。在验证集上,学习率为 0.25 时的准确率最高(98.85%),并且学习率为 0.1 时的准确率也很高(98.79%)。这表明适当的学习率可以帮助模型更好地收敛到最优解,从而提高了验证集上的准确率。
学习率为 0.01 和 0.001 时:
当学习率较小(0.01 和 0.001)时,模型在训练集上的 Loss 仍然较低,但在验证集上的准确率有所下降。学习率为 0.01 时,验证集准确率为 97.94%,学习率为 0.001 时,验证集准确率为 93.76%。这表明学习率过小可能导致模型收敛过慢,或者陷入局部最优解,导致验证集准确率下降。
综上所述,适当的学习率是训练深度学习模型非常重要的超参数之一。太大的学习率可能导致训练不稳定,而太小的学习率可能导致收敛缓慢。在本例中,学习率为 0.25 时,在验证集上获得了最高的准确率(98.85%),并且学习率为 0.1 时,在训练集上获得了最低的 Loss(0.0252),因此可以认为学习率为 0.25 或 0.1 是较好的选择。但具体的最佳学习率也可能取决于数据集的特点和模型的复杂性,需要进一步的调整和验证。
学习率是优化器的一个参数,调整学习率看似是一件非常麻烦的事情,需要不断的调整步长,观察训练时间和 Loss 的变化。经过研究员的不断的实验,当前已经形成了四种比较成熟的优化算法:SGD、Momentum、AdaGrad和Adam,效果如下图所示。
Q:优化算法不是如何调整参数更新的吗,会影响学习率吗?
A:是的,优化算法是用来调整模型参数更新的方法。在训练深度学习模型时,优化算法的选择会影响学习率以及模型的收敛速度和性能。
学习率是优化算法中的一个重要超参数,用来控制每次参数更新的步长。优化算法的作用是根据损失函数的梯度信息来调整模型参数,使得模型能够朝着更优的方向进行更新。不同的优化算法会以不同的方式来调整参数,其中包括使用学习率来控制更新的步长。
常见的优化算法,例如随机梯度下降(SGD)、动量法(Momentum)、Adam 等,都有一个学习率的超参数。学习率的大小会直接影响参数更新的幅度。如果学习率太大,参数更新可能会跳过最优解,导致训练不稳定;如果学习率太小,参数更新可能会变得过于缓慢,导致模型收敛缓慢或陷入局部最优解。
为了找到合适的学习率,通常需要进行学习率调度或学习率衰减。学习率调度可以在训练过程中动态地调整学习率的大小,例如随着训练轮数的增加逐渐减小学习率。这样可以使得学习率在训练初期较大,有利于快速收敛,而在训练后期逐渐减小,有利于更精细的调整参数。学习率衰减是优化算法的一种变体,可以使得学习率随着训练步数逐渐减小,有助于模型更好地收敛。
综上所述,优化算法的选择和学习率的设置是训练深度学习模型中两个重要的因素。合适的优化算法和学习率设置可以加速收敛、提高模型性能,而不当的选择可能导致训练困难或结果不理想。因此,在训练模型时,通常需要根据具体任务和数据集的特点,仔细选择合适的优化算法和学习率设置。
每次训练少量数据,抽样偏差导致的参数收敛过程中震荡。
引入物理“动量”的概念,累积速度,减少震荡,使参数更新的方向更稳定。
每个批次的数据含有抽样误差,导致梯度更新的方向波动较大。如果我们引入物理动量的概念,给梯度下降的过程加入一定的“惯性”累积,就可以减少更新路径上的震荡,即每次更新的梯度由“历史多次梯度的累积方向”和“当次梯度”加权相加得到。历史多次梯度的累积方向往往是从全局视角更正确的方向,这与“惯性”的物理概念很像,也是为何其起名为 “Momentum” 的原因。
类似不同品牌和材质的篮球有一定的重量差别,街头篮球队中的投手(擅长中远距离投篮)喜欢稍重篮球的比例较高。一个很重要的原因是,重的篮球惯性大,更不容易受到手势的小幅变形或风吹的影响。
根据不同参数距离最优解的远近,动态调整学习率。学习率逐渐下降,依据各参数变化大小调整学习率。
通过调整学习率的实验可以发现:当某个参数的现值距离最优解较远时(表现为梯度的绝对值较大),我们期望参数更新的步长大一些,以便更快收敛到最优解。当某个参数的现值距离最优解较近时(表现为梯度的绝对值较小),我们期望参数的更新步长小一些,以便更精细的逼近最优解。
类似于打高尔夫球,专业运动员第一杆开球时,通常会大力打一个远球,让球尽量落在洞口附近。当第二杆面对离洞口较近的球时,他会更轻柔而细致的推杆,避免将球打飞。与此类似,参数更新的步长应该随着优化过程逐渐减少,减少的程度与当前梯度的大小有关。根据这个思想编写的优化算法称为“AdaGrad”,Ada 是 Adaptive 的缩写,表示“适应环境而变化”的意思。RMSProp 是在 AdaGrad 基础上的改进,学习率随着梯度变化而适应,解决 AdaGrad 学习率急剧下降的问题。
由于动量和自适应学习率两个优化思路是正交的,因此可以将两个思路结合起来,这就是当前广泛应用的算法。
每种优化算法均有更多的参数设置,详情可查阅飞桨的官方 API 文档。理论最合理的未必在具体案例中最有效,所以模型调参是很有必要的,最优的模型配置往往是在一定“理论”和“经验”的指导下实验出来的。
推荐博文:优化函数和损失函数的区别与联系
我们可以尝试选择不同的优化算法训练模型,观察训练时间和损失变化的情况,代码实现如下。
# 定义 SGD 优化器
if args.optimizer == "sgd" or "SGD":
optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
elif args.optimizer == "momentum" or "Momentum":
optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters())
elif args.optimizer == "adagrad" or "Adagrad":
optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters())
elif args.optimizer == "adam" or "Adam":
optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters())
else:
raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")
为了更加明显展现不同优化器对模型性能的影响,这里将学习率设置为 0.09
(并非最佳值 0.25),结果如下。
优化器 | 训练集Loss | 验证集准确率 |
---|---|---|
SGD | 0.0263 | 98.69% |
Momentum | 0.0264 | 98.74% |
Adagrad | 0.0264 | 98.69% |
Adam | 0.0263 | 98.72% |
Loss 并不是画错了,而是几乎没有区别,所以重合在了一起
根据给出的结果表格,我们可以看到使用不同的优化器对模型进行训练后,在训练集上的 Loss 和验证集上的准确率略有差异。下面对每个优化器的性能进行简要分析:
SGD(随机梯度下降)优化器:
SGD 是最基本的优化算法之一,它在每次更新参数时仅考虑当前的梯度。在本例中,SGD 在训练集上获得了较低的 Loss,而在验证集上获得了较高的准确率。
Momentum(动量法)优化器:
Momentum 优化器在每次参数更新时,除了考虑当前的梯度,还考虑之前的梯度的累积信息。这样可以加速模型训练过程,有助于跳出局部最优解。在本例中,Momentum 在验证集上获得了稍高于 SGD 的准确率,但训练集 Loss 略高于 SGD。
Adagrad(自适应梯度算法)优化器:
Adagrad 优化器在每个参数上使用不同的学习率,根据每个参数的历史梯度信息来自适应地调整学习率。它可以加速收敛,对于稀疏数据有一定的优势。在本例中,Adagrad 的结果在验证集上与 SGD 几乎相同,但略高于 SGD 的训练集 Loss。
Adam(自适应矩估计优化器)优化器:
Adam 优化器结合了 Momentum 和 Adagrad 的优点,使用动量法来加速收敛,同时根据每个参数的历史梯度信息自适应地调整学习率。在本例中,Adam 的结果在验证集上略高于 SGD,但准确率较高。
综上所述,不同的优化器在训练深度学习模型时表现略有差异。每种优化器都有其优点和适用场景。在实际应用中,需要根据具体任务、数据集和模型的特点来选择合适的优化器和超参数设置,以达到更好的模型性能和收敛速度。在实践中,往往需要进行一系列的实验和调优,以找到最优的优化器和参数组合。
nn.Sequtial()
是否给层加名字class MNIST_CNN_Model(paddle.nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=1)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x)
return x
MNIST_CNN_Model(
(classifier): Sequential(
(0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(1): ReLU()
(2): MaxPool2D(kernel_size=2, stride=2, padding=0)
(3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(4): ReLU()
(5): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=1, dtype=float32)
)
class MNIST_CNN_Model(paddle.nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
("Conv_1", nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2)),
("ReLU_1", nn.ReLU()),
("MaxPool_1", nn.MaxPool2D(kernel_size=2, stride=2)),
("Conv_2",nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2)),
("ReLU_2", nn.ReLU()),
("MaxPool_2", nn.MaxPool2D(kernel_size=2, stride=2)))
self.head = nn.Linear(in_features=980, out_features=1)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x)
return x
结果:
MNIST_CNN_Model(
(classifier): Sequential(
(Conv_1): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(ReLU_1): ReLU()
(MaxPool_1): MaxPool2D(kernel_size=2, stride=2, padding=0)
(Conv_2): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(ReLU_2): ReLU()
(MaxPool_2): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=1, dtype=float32)
)
nn.Sequential
在 PyTorch 和 PaddlePaddle 中不同nn.Sequential
在 PyTorch 和 PaddlePaddle 中都用于构建顺序神经网络模型,但它们之间有一些不同之处。
输入参数:
nn.Sequential
接受一个包含多个前馈网络模块的列表作为输入参数。每个模块可以是拉直层(fatten
)、全连接层、卷积层、LSTM 层等。paddle.nn.Sequential
也接受一个包含多个前馈网络模块的列表作为输入参数,但与 PyTorch 不同的是,PaddlePaddle 还允许传入一个有序字典,其中键是层的名字,值是对应的层。执行顺序:
nn.Sequential
会按照输入参数的顺序依次调用每个模块,并将输出作为下一个模块的输入。因此,每个模块的输出维度应该与下一个模块的输入维度相匹配。paddle.nn.Sequential
也会按照输入参数的顺序依次调用每个模块。函数调用方式:
forward()
函数来定义前向传播过程。在 nn.Sequential
中,每个模块的前向传播过程会被自动调用。forward()
函数来定义前向传播过程。在 paddle.nn.Sequential
中,每个模块的前向传播过程也会被自动调用。总之,nn.Sequential
在 PyTorch 和 PaddlePaddle 中的主要区别在于输入参数和执行顺序,但它们都用于构建顺序神经网络模型,并且都支持传入多个前馈网络模块。
0-D
如果我们对于一个 0-D
的 Tensor 而言,使用了 tensor.numpy()
,则 Paddle 会发出警告,内容如下:
I0806 22:35:25.274586 8800 eager_method.cc:140] Warning:: 0D Tensor cannot be used as 'Tensor.numpy()[0]' . In order to avoid this problem, 0D Tensor will be changed to 1D numpy currently, but it's not correct and will be removed in release 2.6. For Tensor contain only one element, Please modify 'Tensor.numpy()[0]' to 'float(Tensor)' as soon as possible, otherwise 'Tensor.numpy()[0]' will raise error in release 2.6.
I0806 22:35:25.274586 8800 eager_method.cc:140]警告:0D张量不能用作’Tensor.numpy()[0]'。为了避免此问题,当前0D张量将更改为1D numpy,但这不正确,将在2.6版本中删除。对于只包含一个元素的张量,请尽快将’Tensor.numpy()[0]‘修改为’float(Tensor)’,否则’Tensor.numpy()[0]'将在2.6版本中引发错误。
这只是一个警告,对训练没有影响,但终端会被这个警告占满,就很烦。
解决方案:对于一个 0-D
的 Tensor 而言,直接使用 tensor.item()
就行,别用 tensor.numpy()
。
epoch_loss.append(avg_loss.numpy()) # PaddlePaddle会发出警告
epoch_loss.append(avg_loss.item()) # 无事发生
0-D
其实就是一个list
,shape
为(165, )
# 多层卷积神经网络实现
class MNIST_CNN_Model(nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=args.num_classes)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = F.softmax(x, axis=-1) # [batch size, num_classes]
return x
运行结果:
W0807 03:46:48.053212 49884 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 7.5, Driver API Version: 10.2, Runtime API Version: 10.2
W0807 03:46:48.055718 49884 gpu_resources.cc:149] device: 0, cuDNN Version: 8.2.
Epoch: 1 Loss: 0.5758 acc: 93.1800%
Epoch: 2 Loss: 0.2281 acc: 95.5700%
Epoch: 3 Loss: 0.1672 acc: 96.4800%
Epoch: 4 Loss: 0.1347 acc: 96.9400%
Epoch: 5 Loss: 0.1132 acc: 97.3300%
Epoch: 6 Loss: 0.1001 acc: 97.5700%
Epoch: 7 Loss: 0.0905 acc: 97.6500%
Epoch: 8 Loss: 0.0829 acc: 97.8600%
Epoch: 9 Loss: 0.0775 acc: 97.8300%
Epoch: 10 Loss: 0.0724 acc: 97.9400%
模型最终loss为: 0.0724
模型最终accuracy为: 97.9400%
MNIST_CNN_Model(
(classifier): Sequential(
(0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(1): ReLU()
(2): MaxPool2D(kernel_size=2, stride=2, padding=0)
(3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(4): ReLU()
(5): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=10, dtype=float32)
)
# 多层卷积神经网络实现
class MNIST_CNN_Model(nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=args.num_classes)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x) # [batch size, num_classes]
# 加入Softmax函数
x = F.softmax(x, axis=-1) # [batch size, num_classes]
return x
运行结果:
W0807 05:35:26.604616 53322 gpu_resources.cc:119] Please NOTE: device: 0, GPU Compute Capability: 7.5, Driver API Version: 10.2, Runtime API Version: 10.2
W0807 05:35:26.607673 53322 gpu_resources.cc:149] device: 0, cuDNN Version: 8.2.
Epoch: 1 Loss: 2.0690 acc: 54.33%
Epoch: 2 Loss: 1.9185 acc: 57.96%
Epoch: 3 Loss: 1.8933 acc: 58.39%
Epoch: 4 Loss: 1.8850 acc: 58.67%
Epoch: 5 Loss: 1.8805 acc: 58.86%
Epoch: 6 Loss: 1.8774 acc: 58.94%
Epoch: 7 Loss: 1.8751 acc: 59.15%
Epoch: 8 Loss: 1.8352 acc: 67.76%
Epoch: 9 Loss: 1.7916 acc: 68.12%
Epoch: 10 Loss: 1.7871 acc: 68.27%
模型最终loss为: 1.7871
模型最终accuracy为: 68.27%
MNIST_CNN_Model(
(classifier): Sequential(
(0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(1): ReLU()
(2): MaxPool2D(kernel_size=2, stride=2, padding=0)
(3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
(4): ReLU()
(5): MaxPool2D(kernel_size=2, stride=2, padding=0)
)
(head): Linear(in_features=980, out_features=10, dtype=float32)
)
CrossEntropyLoss 是用于分类任务的常见损失函数,它将 softmax 函数和交叉熵损失结合在一起。Softmax 函数用于将原始输出转换为概率分布,然后交叉熵损失用于衡量模型输出的概率分布与真实标签之间的差异。
加入 Softmax 函数后,模型准确率下降的原因是因为在原始网络中已经使用了 CrossEntropyLoss(交叉熵损失函数),而 CrossEntropyLoss 会在其内部应用 Softmax 函数。
如果在模型的 forward
方法中手动添加了 softmax 函数,那么在使用 CrossEntropyLoss 时就相当于进行了两次 softmax 操作,这可能导致梯度的数值变得更小,从而影响训练的稳定性和准确性。
解决方法:
forward
方法中移除,保留 CrossEntropyLoss 的默认行为即可。这样可以避免重复使用 softmax。一般情况下,对于分类任务,推荐在网络的最后一层输出层使用全连接层,并在 CrossEntropyLoss 中自动应用 softmax,而不是手动在 forward
方法中添加 softmax 函数。
我们看以下我们的相关代码:
for epoch in range(1, args.epochs+1):
epoch_loss = []
for data in train_loader():
imgs, labels = data
imgs = paddle.to_tensor(imgs)
labels = paddle.to_tensor(labels)
# 前向推理
preds = model(imgs)
# 计算损失(交叉熵损失)
loss = F.cross_entropy(preds, labels)
avg_loss = paddle.mean(loss)
# 反向传播
avg_loss.backward()
# 保存每次迭代的损失
epoch_loss.append(avg_loss.item()) # type: ignore
# 优化器
optimizer.step()
# 清空梯度
optimizer.clear_grad()
确实如此,我们的损失是通过 F.cross_entropy
方法计算的,该函数有一个参数 use_softmax=True
,即在使用该函数的时候会自动使用 softmax 进行处理,因此如果我们想要在模型中加入 softmax 操作,那么在使用 F.cross_entropy()
方法时就应该让 use_softmax=True
。
一般情况下,对于分类任务,推荐在网络的最后一层输出层使用全连接层
Q:如果我们的真实标签用 [ 0 , 1 , 2 , . . . , n ] [0, 1, 2, ..., n] [0,1,2,...,n] 这样的形式来编码的(对应不同类别),网络的预测结果经过 softmax 处理后是一个 ∈ [ 0 , 1 ] , ∑ = 1 \in [0, 1], \sum=1 ∈[0,1],∑=1 的结果,那么在计算 Loss 的时候是否需要对输出也要进行相似的处理以得到最大概率对应的类别呢?
A:在这种情况下,如果我们的真实标签使用整数形式编码为 [ 0 , 1 , 2 , . . . , n ] [0, 1, 2, ..., n] [0,1,2,...,n],而网络的输出经过 softmax 处理后是一个概率分布( ∈ [ 0 , 1 ] \in [0, 1] ∈[0,1],且概率之和为 1),我们在计算损失时不需要对输出进行相似的处理。
在 PyTorch 和 PaddlePaddle 中,交叉熵损失函数(nn.CrossEntropyLoss
或 paddle.nn.CrossEntropyLoss
)已经处理了这种情况。我们可以直接使用整数形式的真实标签,而不需要对输出进行处理,它会自动处理 softmax 操作。
这样做的原因是交叉熵损失函数会在内部将真实标签转换为 one-hot 编码形式,并与 softmax 处理后的输出进行比较,以计算损失。因此,我们可以直接使用整数形式的真实标签,并将 softmax 输出作为模型的预测结果,交叉熵损失函数会自动处理编码和计算损失。
简而言之,当使用交叉熵损失函数时,不需要对输出进行额外的处理,直接使用整数形式的真实标签即可。交叉熵损失函数会自动处理编码和 softmax 操作,并计算损失。
在开启分布式训练后,可能无法正常 print
,而是训练完毕后一次性 print
,这样就很烦。
import sys
# 设置标准输出不缓冲
sys.stdout.reconfigure(line_buffering=True)
这样应该就可以正常 print
了。
完整代码地址:Paddle_MNIST_Classification
如果对你有帮助,请 ⭐️ 一下,谢谢。