本文主要讲述如何用pytorch
编写基础的深度学习架构,并尝试用基础的架构拟合一个加法函数。
程序代码段
基本的代码段如下:
1 | import torch |
步骤说明
这是一个用pytorch
搭建的基础的深度学习代码框架。所有的深度学习流程,都遵循着这样的步骤:
- 数据预处理。
- 模型加载。
- 训练流程的编写。
- 模型的测试与保存。
预备知识:张量
pytorch
中,最基本的数据是tensor
——张量。pytorch
中的张量与其他领域不同,您可以将之理解为标量、矩阵以及矩阵之矩阵的集合。比如:
1 | torch.tensor(1) # 这是一个标量式的张量,可以看作一个数字(标量),无形状可言 |
您可以通过Tensor.shape
来查看一个张量的形状。
也可以将其理解为,标量、列表、等长列表之列表的集合。这是从数据组织的角度,而非计算角度来理解张量。
数据预处理
深度学习的数据通常按照如下的方法组织:(输入,预期输出)。数据集的质量直接影响着模型的表现。
作为示例,我们所使用的数据集是代码生成的,在应用pytorch
编码时,我们首先会将以其他形式组织的数据集,变化为torch.utils.data.Dataset
的子类,这个类是将各种各样数据集做规范化的一个类。
作为子类,我们通常要重写这样三个方法:__init__()
、__len__()
和__getitem__()
。
__init__()
是构造器,我们通常在这个方法中对数据做一些预处理,将数据变为(输入,预期输出)这样的格式,并将全部数据转化为可以迭代的结构,便于后续的迭代操作。在上述的示例代码中,我们在这个方法里完成了:导入数据、将数据组织为列表这两个功能。
__len__()
方法,需要返回数据集的大小。
__getitem__()
方法,则需要完成这样的功能:给定索引,返回数据集中对应的子数据。
数据预处理的关键在于预处理,通常,在代码编写完毕后,我们要print一些处理后的数据,观察它们的数据、形状是否与预期相符。
在加载数据为Dataset
之后,我们还需要一个DataLoader
来将Dataset
进一步处理。DataLoader
能够完成包括但不限于这样几个工作:对数据集进行分组、打乱数据集顺序等。在实际的训练过程中,如果从Dataset
里,每次挑出一个来训练,会很慢,所以训练通常是按照批次(batch)来训练的,DataLoader
就完成了这样一个工作。
模型加载:构造器
我们通常会借助torch.nn.Module
,实现我们的模型。
我们通常会重写它的两个方法:__init__()
和forward()
。
在构造器__init__()
中,我们会声明模型的架构,即内部包含哪些层。在我们的示例代码中,我们在构造器里定义了一个线性层(Linear Layer),所谓线性层,实际上功能和这样一个函数类似:
$$
\mathbf{y}=\mathbf{Wx}+\mathbf{b}
$$
torch.nn.Linear(2,1)
的意思就是,声明上面公式中的$\mathbf{W}$是一个2$\times$1的矩阵。这个2和1代表的是in_features
与out_features
。所谓feature
就是输入的特征的数量,如果一个数据,能被表征成torch.tensor([1,2,3])
的形式,其特征有3个。
不过,和线性代数中不同的是,线性层可以对多个数据同时做操作。还记得我们之前说过的DataLoader
吗,我们通常用模型+DataLoader
的形式进行训练。两个数据组织成torch.tensor([[1,2,3], [1,2,3]])
的形式,那么,线性层可以对这两个数据同时处理:
1 | test_layer = torch.nn.Linear(3,1) |
如果您本地的结果和我不一样,无需担心,因为每次线性层初始化,其权重都会随机生成。
模型加载:前向传播
我们来讨论一下forward()
方法。这个方法负责前向传播。前向传播过程,实际上就是推理过程。您需要在这个方法中编写处理输入的逻辑。依靠在__init__()
中预先设置好的层,对输入一步步进行处理。我们的示例代码很简单,其forward()
方法实现了这样一个功能,对于输入,经过线性层处理,然后输出。
训练流程编写
到目前为止,我们加载好了数据集,也编写了模型架构,让我们开始编写训练代码!
训练流程通常为:
初始化数据与模型。
迭代数据。
依靠模型,生成对输入的预测值。
计算预测值与预期的误差。
依靠优化器对模型权重进行优化、更新。
第零步:初始化
这里我们主要谈DataLoader
的设置:
1 | DataLoader(train_dataset, batch_size=32, shuffle=True) |
这里的batch_size
是批次的大小,即模型同时对多少个数据进行处理,而shuffle
参数则表示我们要对数据集进行打乱,我们通常对训练集进行打乱,原因见附录。
第一步:迭代数据
我们有时会将DataLoader
组织成一个枚举类,即:
1 | for batch_idx, (x, y) in enumerate(train_loader): |
因为有时,我们需要获取数据的下标信息。
第二步:生成预测
由于模型通常已经写好了__call__()
方法,直接用model(x)
类似的代码段即可。
第三步:计算误差
我们会预先设置一些损失函数,来计算误差。损失函数的选择通常是任务敏感的。比如分类任务常用交叉熵损失,预测任务常用均方误差损失。我们想实现一个加法器,那么就是一个预测任务,选用均方误差比较好,其公式为:
$$
MSE(T)=E((T-\theta)^2)=\frac{1}{n}\sum_{i=1}^n(y_i-\hat{y}_i)^2
$$
选用L1误差函数等也都可以。
第四步:优化
优化器是一个根据loss计算梯度,优化模型权重的模块。
为什么要优化?当然是为了loss最低。这是一个极其显然的想法。此外,我们也知道,现在可以改变的东西,只有模型的权重,所以我们的任务变成了:如何寻找到权重-损失函数的鞍点,横轴是权重,纵轴是损失。怎么找?梯度下降法。
假设我们的权重只有一个,而损失函数恰好满足$y=x^2$这个函数:
那么我们作这样一张图:
假设我们现在的函数在黑橙交点(这个点也是函数线上的点),为了优化,我们需要左移,左移的步长是橘红交点左侧红线的长度,这就引出了我们第一个公式:
$$
\exist \lambda, F(x-\lambda\nabla F(x_0))<F(x_0)
$$
其中,$x_0$就是我们的黑橙交点。这个$\lambda$就是学习率(learning rate,代码中的$lr$)。
优化器会根据学习率的大小,寻找到一个最优秀的参数设置。不过我们也能看出来,如果学习率太大,移动的步长就会很大,这会导致参数设置在极值点的两侧来回震荡;而学习率太小,移动的步长太小,这会导致训练很慢。
代码中,我们首先要将优化器的梯度设置为0,然后计算loss对于参数的梯度,最后应用优化器进行优化,这便是:
1 | optimizer.zero_grad() |
三行的作用。
测试流程编写
测试的时候,我们不需要模型更新参数,所以需要指定:
1 | with torch.no_grad() |
这表示,我们不需要梯度计算,因为梯度计算只和参数的优化有关,关闭它可以加快推理过程。
测试过程中,我们只需要让模型前向传播,然后得出loss即可。我们通常使用平均损失来观察模型的效果。
视觉化(Visualization)的重要性
视觉化是指将训练过程中的某些参数显示出来,这是很重要的。很多运算都是计算机自动完成的,不会显示出来,这不利于我们观测训练的过程。
常见的视觉化方法有这么几个:对于训练过程,可以应用tqdm
设置进度条,观测训练的耗时;对于训练效果,可以设置训练几个epoch
后,在验证集上进行推理,观察效果……
总结
基础深度学习模型的代码编写,有这样几个重点:
- 数据的预处理:将数据进行预先处理,并转化为可以迭代的结构。
- 模型的编写:模型内部包含哪些模块?前向传播该怎么做?
- 训练过程的编写:正确选择损失函数、优化器,设置参数。
- 视觉化:设置合适的视觉化方式,便于用户观测训练过程。
附录
训练集、验证集、测试集
训练集是用于训练、优化模型参数的集合;验证集是在训练过程中,用于推理,检测模型临时训练效果的集合;测试集则是在训练后,单纯用于推理,检测模型训练效果的集合。
Shuffle的必要性
假设我们的权重-损失函数有三个鞍点,其中只有一个是最低点。如果不shuffle,假设第一个epoch训练完毕,模型权重到达了一个局部最低点,而非极低点,那么后续的训练有可能无法跳出这个点。为了避免这个,我们通常会打乱数据集,创造更多的数据组合。