本文章基于李沐老师的《动手学深度学习》(pytorch版),在此学习观礼膜拜。
课程主页:https://courses.d2l.ai/zh-v2
教材: https://zh-v2.d2l.ai/
课程论坛讨论:https://discuss.d2l.ai/c/16Pytorch
论坛: https://discuss.pytorch.org/

线性回归

介绍一些基本元素:
训练数据集(Training Dataset)、验证数据集(Validation Dataset)、测试数据集(Test Dataset)。这些数据是我们进行线性回归甚至是深度学习的基础。数据集中的每一行数据称作一个样本(sample),里面帮助预测所用到的数据称为特征(feature)或协变量(convariate),标注好的对应的目标称为标签(label)或目标(target)。
由特征x经过网络训练计算可以得到预测结果y^\hat{y},将其与实际的结果y(标签)相对比,就可以得出运算正确与否。w为权重weight,b为偏置bias。

y^=w1x1++wdxd+b=wx+b\hat{y} = w_1 x_1 + \cdots + w_d x_d + b =\mathbf{w}^\top \mathbf{x} + b

损失函数(loss function)能够量化目标的实际值与预测值之间的差距。通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为0。回归问题中最常用的损失函数是平方误差函数。
当样本ii的预测值为y^(i)\hat{y}^{(i)},其相应的真实标签为y(i)y^{(i)}时,平方误差可以定义为以下公式:

l(i)(w,b)=12(y^(i)y(i))2l^{(i)}(\mathbf{w}, b) = \frac{1}{2} \left(\hat{y}^{(i)} - y^{(i)}\right)^2

使用梯度下降(gradient descent)的方法,可以使得参数不断地在损失函数递减的方向上更新。如果每次更新参数之前都把整个数据集遍历一边,执行速度会很慢,所以每次需要计算更新的时候随机抽取一小批样本,这种变体称为小批量随机梯度下降(minibatch stochastic gradient descent)。
在每次迭代中,我们首先随机抽样一个小批量B\mathcal{B},它是由固定数量的训练样本组成的。然后,我们计算小批量的平均损失关于模型参数的导数(也可以称为梯度)。最后,我们将梯度乘以一个预先确定的正数η\eta,并从当前参数的值中减掉。
我们用下面的数学公式来表示这一更新过程(\partial表示偏导数):

(w,b)(w,b)ηBiB(w,b)l(i)(w,b)(\mathbf{w},b) \leftarrow (\mathbf{w},b) - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b)

批量大小和学习率的值通常是手动预先指定,而不是通过模型训练得到的。这些可以调整但不在训练过程中更新的参数称为超参数hyperparameter)。调参(hyperparameter tuning)是选择超参数的过程。超参数通常是我们根据训练迭代结果来调整的,而训练迭代结果是在独立的验证数据集(validation dataset)上评估得到的。
这里贴上线性回归的简洁实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import numpy as np
import torch
from torch.utils import data
from d2l import torch as d2l

w_real=torch.tensor([[1.0,2],[3,4],[0,1],[2,3]])
b_real=torch.tensor([0,3.0])
num_examples=3000

def synthetic_data(w, b, num_examples):
X=torch.normal(0,1,(num_examples,len(w)))
y=torch.matmul(X,w)+b
y+=torch.normal(0,0.01,y.shape)
return X, y

features, labels = synthetic_data(w_real, b_real, num_examples)

lr=0.01
batch_size=30
num_epochs=5

def load_array(data_arrays, batch_size, is_train=True): #@save
"""构造一个PyTorch数据迭代器"""
dataset = data.TensorDataset(*data_arrays)
return data.DataLoader(dataset, batch_size, shuffle=is_train)

batch_size = 10
data_iter = load_array((features, labels), batch_size)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# nn是神经网络的缩写
from torch import nn
net = nn.Sequential(nn.Linear(4,2))
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(2)

loss = nn.MSELoss()
trainer = torch.optim.SGD(net.parameters(), lr=lr)

for epoch in range(num_epochs):
for X, y in data_iter:
l = loss(net(X) ,y)
trainer.zero_grad()
l.backward()
trainer.step()
l = loss(net(features), labels)
print(f'epoch {epoch + 1}, loss {l:f}')

# 以下为输出结果
epoch 1, loss 0.060943
epoch 2, loss 0.000256
epoch 3, loss 0.000101
epoch 4, loss 0.000100
epoch 5, loss 0.000100

softmax回归

当需要解决分类问题的时候,统计学家很早以前就发明了一种表示分类数据的简单方法:独热编码(one-hot encoding)。独热编码是一个向量,它的分量和类别一样多。类别对应的分量设置为1,其他所有分量设置为0。比如,标签yy是一个三维向量,其中(1,0,0)(1, 0, 0)对应于“猫”、(0,1,0)(0, 1, 0)对应于“鸡”、(0,0,1)(0, 0, 1)对应于“狗”:

y{(1,0,0),(0,1,0),(0,0,1)}.y \in \{(1, 0, 0), (0, 1, 0), (0, 0, 1)\}.

为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数(affine function)。每个输出对应于它自己的仿射函数。下面我们为每个输入计算三个未规范化的预测(logit):o1o_1o2o_2o3o_3

o1=x1w11+x2w12+x3w13+x4w14+b1,o2=x1w21+x2w22+x3w23+x4w24+b2,o3=x1w31+x2w32+x3w33+x4w34+b3.\begin{aligned} o_1 &= x_1 w_{11} + x_2 w_{12} + x_3 w_{13} + x_4 w_{14} + b_1,\\ o_2 &= x_1 w_{21} + x_2 w_{22} + x_3 w_{23} + x_4 w_{24} + b_2,\\ o_3 &= x_1 w_{31} + x_2 w_{32} + x_3 w_{33} + x_4 w_{34} + b_3. \end{aligned}

我们能否将未规范化的预测oo直接视作我们感兴趣的输出呢?答案是否定的。因为将线性层的输出直接视为概率时存在一些问题:一方面,我们没有限制这些输出数字的总和为1。另一方面,根据输入的不同,它们可以为负值。要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1。此外,我们需要一个训练目标,来鼓励模型精准地估计概率。
社会科学家邓肯·卢斯于1959年在选择模型(choice model)的理论基础上发明的softmax函数正是这样做的:softmax函数将未规范化的预测变换为非负并且总和为1,同时要求模型保持可导。我们首先对每个未规范化的预测求幂,这样可以确保输出非负。为了确保最终输出的总和为1,我们再对每个求幂后的结果除以它们的总和。如下式:

y^=softmax(o)其中y^j=exp(oj)kexp(ok)\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})\quad \text{其中}\quad \hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}

为了提高计算效率并且充分利用GPU,我们通常会针对小批量数据执行矢量计算。假设我们读取了一个批量的样本X\mathbf{X},其中特征维度(输入数量)为dd,批量大小为nn。此外,假设我们在输出中有qq个类别。那么小批量特征为XRn×d\mathbf{X} \in \mathbb{R}^{n \times d},权重为WRd×q\mathbf{W} \in \mathbb{R}^{d \times q},偏置为bR1×q\mathbf{b} \in \mathbb{R}^{1\times q}。softmax回归的矢量计算表达式为:

O=XW+b,Y^=softmax(O).\begin{aligned} \mathbf{O} &= \mathbf{X} \mathbf{W} + \mathbf{b}, \\ \hat{\mathbf{Y}} & = \mathrm{softmax}(\mathbf{O}). \end{aligned}

根据最大似然估计,对于任何标签y\mathbf{y}和模型预测y^\hat{\mathbf{y}},损失函数(即为交叉熵误差,cross-entropy loss)如下。实际上,因为正确的类别只有一个,所以式中有q-1项为0。

l(y,y^)=j=1qyjlogy^jl(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j

下面给出softmax回归的简洁实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

num_epochs = 10
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

多层感知机

感知机是具有输入和输出的算法,通过设定权重、偏置等参数,来约束从输入到输出的中间过程。通过感知机可以表示逻辑电路,但是单层感知机只能表示线性空间(对应“与或非”逻辑电路),多层感知机才可以表示非线性空间(以“异或门”电路为代表)。从理论上来说,通过组合感知机就可以表示计算机。

从线性到非线性

我们通过矩阵XRn×d\mathbf{X} \in \mathbb{R}^{n \times d}来表示n个样本的小批量,其中每个样本具有d个输入特征。对于具有h个隐藏单元的单隐藏层多层感知机,用H表示隐藏层的输出,称为隐藏表示(hidden representations)。在数学或代码中,H也被称为隐藏层变量(hidden-layer variable)或隐藏变量(hidden variable)。因为隐藏层和输出层都是全连接的,所以我们有隐藏层权重W1和隐藏层偏置b1以及输出层权重W2和输出层偏置b2。形式上,我们按如下方式计算单隐藏层多层感知机的输出O。

H=XW(1)+b(1), O=HW(2)+b(2).\begin{aligned} \mathbf{H} & = \mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}, \ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}. \end{aligned}

注意在添加隐藏层之后,模型现在需要跟踪和更新额外的参数。可我们能从中得到什么好处呢?你可能会惊讶地发现:在上面定义的模型里,我们没有好处!原因很简单:上面的隐藏单元由输入的仿射函数给出,而输出(softmax操作前)只是隐藏单元的仿射函数。仿射函数的仿射函数本身就是仿射函数,但是我们之前的线性模型已经能够表示任何仿射函数。

我们可以证明这一等价性,即对于任意权重值,我们只需合并隐藏层,便可产生具有参数W=W(1)W(2)\mathbf{W} = \mathbf{W}^{(1)}\mathbf{W}^{(2)}b=b(1)W(2)+b(2)\mathbf{b} = \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)}的等价单层模型:

O=(XW(1)+b(1))W(2)+b(2)=XW(1)W(2)+b(1)W(2)+b(2)=XW+b.\mathbf{O} = (\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W} + \mathbf{b}.

为了发挥多层架构的潜力,我们还需要一个额外的关键要素:在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)σ\sigma。激活函数的输出(例如,σ()\sigma(\cdot))被称为活性值(activations)。一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型:

H=σ(XW(1)+b(1)), O=HW(2)+b(2). \begin{aligned} \mathbf{H} & = \sigma(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}), \ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}.\ \end{aligned}

由于X\mathbf{X}中的每一行对应于小批量中的一个样本,出于记号习惯的考量,我们定义非线性函数σ\sigma也以按行的方式作用于其输入,即一次计算一个样本。但是在本节中,我们应用于隐藏层的激活函数通常不仅按行操作,也按元素操作。这意味着在计算每一层的线性部分之后,我们可以计算每个活性值,而不需要查看其他隐藏单元所取的值。对于大多数激活函数都是这样。为了构建更通用的多层感知机,我们可以继续堆叠这样的隐藏层,例如H(1)=σ1(XW(1)+b(1))\mathbf{H}^{(1)} = \sigma_1(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})H(2)=σ2(H(1)W(2)+b(2))\mathbf{H}^{(2)} = \sigma_2(\mathbf{H}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)}),一层叠一层,从而产生更有表达能力的模型。

激活函数

激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活,它们将输入信号转换为输出的可微运算。大多数激活函数都是非线性的。由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数。

ReLU(x)=max(x,0)pReLU(x)=max(0,x)+αmin(0,x)\operatorname{ReLU}(x) = \max(x, 0)\\ \operatorname{pReLU}(x) = \max(0, x) + \alpha \min(0, x)

sigmoid(x)=11+exp(x)ddxsigmoid(x)=exp(x)(1+exp(x))2=sigmoid(x)(1sigmoid(x))\operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}\\ \frac{d}{dx} \operatorname{sigmoid}(x) = \frac{\exp(-x)}{(1 + \exp(-x))^2} = \operatorname{sigmoid}(x)\left(1-\operatorname{sigmoid}(x)\right)

tanh(x)=1exp(2x)1+exp(2x)ddxtanh(x)=1tanh2(x)\operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}\\ \frac{d}{dx} \operatorname{tanh}(x) = 1 - \operatorname{tanh}^2(x)

多层感知机的简洁实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)

batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)

train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)