本文章基于李沐老师的《动手学深度学习》(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 ^ ,将其与实际的结果y(标签)相对比,就可以得出运算正确与否。w为权重weight,b为偏置bias。
y ^ = w 1 x 1 + ⋯ + w d x d + b = w ⊤ x + b \hat{y} = w_1 x_1 + \cdots + w_d x_d + b =\mathbf{w}^\top \mathbf{x} + b
y ^ = w 1 x 1 + ⋯ + w d x d + b = w ⊤ x + b
损失函数(loss function)能够量化目标的实际值与预测值之间的差距。通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为0。回归问题中最常用的损失函数是平方误差函数。
当样本i i i 的预测值为y ^ ( i ) \hat{y}^{(i)} y ^ ( i ) ,其相应的真实标签为y ( i ) y^{(i)} y ( i ) 时,平方误差可以定义为以下公式:
l ( i ) ( w , b ) = 1 2 ( y ^ ( i ) − y ( i ) ) 2 l^{(i)}(\mathbf{w}, b) = \frac{1}{2} \left(\hat{y}^{(i)} - y^{(i)}\right)^2
l ( i ) ( w , b ) = 2 1 ( y ^ ( i ) − y ( i ) ) 2
使用梯度下降(gradient descent)的方法,可以使得参数不断地在损失函数递减的方向上更新。如果每次更新参数之前都把整个数据集遍历一边,执行速度会很慢,所以每次需要计算更新的时候随机抽取一小批样本,这种变体称为小批量随机梯度下降(minibatch stochastic gradient descent)。
在每次迭代中,我们首先随机抽样一个小批量B \mathcal{B} B ,它是由固定数量的训练样本组成的。然后,我们计算小批量的平均损失关于模型参数的导数(也可以称为梯度)。最后,我们将梯度乘以一个预先确定的正数η \eta η ,并从当前参数的值中减掉。
我们用下面的数学公式来表示这一更新过程(∂ \partial ∂ 表示偏导数):
( w , b ) ← ( w , b ) − η ∣ B ∣ ∑ i ∈ B ∂ ( 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)
( w , b ) ← ( w , b ) − ∣ B ∣ η i ∈ B ∑ ∂ ( w , b ) l ( i ) ( 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 npimport torchfrom torch.utils import datafrom d2l import torch as d2lw_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 ): """构造一个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 from torch import nnnet = 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。比如,标签y y y 是一个三维向量,其中( 1 , 0 , 0 ) (1, 0, 0) ( 1 , 0 , 0 ) 对应于“猫”、( 0 , 1 , 0 ) (0, 1, 0) ( 0 , 1 , 0 ) 对应于“鸡”、( 0 , 0 , 1 ) (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)\}.
y ∈ {( 1 , 0 , 0 ) , ( 0 , 1 , 0 ) , ( 0 , 0 , 1 )} .
为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数 (affine function)。每个输出对应于它自己的仿射函数。下面我们为每个输入计算三个未规范化的预测 (logit):o 1 o_1 o 1 、o 2 o_2 o 2 和o 3 o_3 o 3 。
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 . \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}
o 1 o 2 o 3 = x 1 w 11 + x 2 w 12 + x 3 w 13 + x 4 w 14 + b 1 , = x 1 w 21 + x 2 w 22 + x 3 w 23 + x 4 w 24 + b 2 , = x 1 w 31 + x 2 w 32 + x 3 w 33 + x 4 w 34 + b 3 .
我们能否将未规范化的预测o o o 直接视作我们感兴趣的输出呢?答案是否定的。因为将线性层的输出直接视为概率时存在一些问题:一方面,我们没有限制这些输出数字的总和为1。另一方面,根据输入的不同,它们可以为负值。要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1。此外,我们需要一个训练目标,来鼓励模型精准地估计概率。
社会科学家邓肯·卢斯于1959年在选择模型 (choice model)的理论基础上发明的softmax函数 正是这样做的:softmax函数将未规范化的预测变换为非负并且总和为1,同时要求模型保持可导。我们首先对每个未规范化的预测求幂,这样可以确保输出非负。为了确保最终输出的总和为1,我们再对每个求幂后的结果除以它们的总和。如下式:
y ^ = s o f t m a x ( o ) 其中 y ^ j = exp ( o j ) ∑ k exp ( o k ) \hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})\quad \text{其中}\quad \hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}
y ^ = softmax ( o ) 其中 y ^ j = ∑ k exp ( o k ) exp ( o j )
为了提高计算效率并且充分利用GPU,我们通常会针对小批量数据执行矢量计算。假设我们读取了一个批量的样本X \mathbf{X} X ,其中特征维度(输入数量)为d d d ,批量大小为n n n 。此外,假设我们在输出中有q q q 个类别。那么小批量特征为X ∈ R n × d \mathbf{X} \in \mathbb{R}^{n \times d} X ∈ R n × d ,权重为W ∈ R d × q \mathbf{W} \in \mathbb{R}^{d \times q} W ∈ R d × q ,偏置为b ∈ R 1 × q \mathbf{b} \in \mathbb{R}^{1\times q} b ∈ R 1 × q 。softmax回归的矢量计算表达式为:
O = X W + b , Y ^ = s o f t m a x ( O ) . \begin{aligned} \mathbf{O} &= \mathbf{X} \mathbf{W} + \mathbf{b}, \\ \hat{\mathbf{Y}} & = \mathrm{softmax}(\mathbf{O}). \end{aligned}
O Y ^ = XW + b , = softmax ( O ) .
根据最大似然估计,对于任何标签y \mathbf{y} y 和模型预测y ^ \hat{\mathbf{y}} y ^ ,损失函数(即为交叉熵误差,cross-entropy loss)如下。实际上,因为正确的类别只有一个,所以式中有q-1项为0。
l ( y , y ^ ) = − ∑ j = 1 q y j log y ^ j l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j
l ( y , y ^ ) = − j = 1 ∑ q y j log 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 torchfrom torch import nnfrom d2l import torch as d2lbatch_size = 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) 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)
多层感知机
感知机是具有输入和输出的算法,通过设定权重、偏置等参数,来约束从输入到输出的中间过程。通过感知机可以表示逻辑电路,但是单层感知机只能表示线性空间(对应“与或非”逻辑电路),多层感知机才可以表示非线性空间(以“异或门”电路为代表)。从理论上来说,通过组合感知机就可以表示计算机。
从线性到非线性
我们通过矩阵X ∈ R n × d \mathbf{X} \in \mathbb{R}^{n \times d} X ∈ R n × d 来表示n个样本的小批量,其中每个样本具有d个输入特征。对于具有h个隐藏单元的单隐藏层多层感知机,用H表示隐藏层的输出,称为隐藏表示(hidden representations)。在数学或代码中,H也被称为隐藏层变量(hidden-layer variable)或隐藏变量(hidden variable)。因为隐藏层和输出层都是全连接的,所以我们有隐藏层权重W1和隐藏层偏置b1以及输出层权重W2和输出层偏置b2。形式上,我们按如下方式计算单隐藏层多层感知机的输出O。
H = X W ( 1 ) + b ( 1 ) , O = H W ( 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}
H = X W ( 1 ) + b ( 1 ) , O = H W ( 2 ) + b ( 2 ) .
注意在添加隐藏层之后,模型现在需要跟踪和更新额外的参数。可我们能从中得到什么好处呢?你可能会惊讶地发现:在上面定义的模型里,我们没有好处!原因很简单:上面的隐藏单元由输入的仿射函数给出,而输出(softmax操作前)只是隐藏单元的仿射函数。仿射函数的仿射函数本身就是仿射函数,但是我们之前的线性模型已经能够表示任何仿射函数。
我们可以证明这一等价性,即对于任意权重值,我们只需合并隐藏层,便可产生具有参数W = W ( 1 ) W ( 2 ) \mathbf{W} = \mathbf{W}^{(1)}\mathbf{W}^{(2)} W = W ( 1 ) W ( 2 ) 和b = b ( 1 ) W ( 2 ) + b ( 2 ) \mathbf{b} = \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)} b = b ( 1 ) W ( 2 ) + b ( 2 ) 的等价单层模型:
O = ( X W ( 1 ) + b ( 1 ) ) W ( 2 ) + b ( 2 ) = X W ( 1 ) W ( 2 ) + b ( 1 ) W ( 2 ) + b ( 2 ) = X W + 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}.
O = ( X W ( 1 ) + b ( 1 ) ) W ( 2 ) + b ( 2 ) = X W ( 1 ) W ( 2 ) + b ( 1 ) W ( 2 ) + b ( 2 ) = XW + b .
为了发挥多层架构的潜力,我们还需要一个额外的关键要素:在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)σ \sigma σ 。激活函数的输出(例如,σ ( ⋅ ) \sigma(\cdot) σ ( ⋅ ) )被称为活性值(activations)。一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型:
H = σ ( X W ( 1 ) + b ( 1 ) ) , O = H W ( 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}
H = σ ( X W ( 1 ) + b ( 1 ) ) , O = H W ( 2 ) + b ( 2 ) .
由于X \mathbf{X} X 中的每一行对应于小批量中的一个样本,出于记号习惯的考量,我们定义非线性函数σ \sigma σ 也以按行的方式作用于其输入,即一次计算一个样本。但是在本节中,我们应用于隐藏层的激活函数通常不仅按行操作,也按元素操作。这意味着在计算每一层的线性部分之后,我们可以计算每个活性值,而不需要查看其他隐藏单元所取的值。对于大多数激活函数都是这样。为了构建更通用的多层感知机,我们可以继续堆叠这样的隐藏层,例如H ( 1 ) = σ 1 ( X W ( 1 ) + b ( 1 ) ) \mathbf{H}^{(1)} = \sigma_1(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}) H ( 1 ) = σ 1 ( X W ( 1 ) + 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)}) H ( 2 ) = σ 2 ( H ( 1 ) W ( 2 ) + 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)
ReLU ( x ) = max ( x , 0 ) pReLU ( x ) = max ( 0 , x ) + α min ( 0 , x )
sigmoid ( x ) = 1 1 + exp ( − x ) d d x sigmoid ( x ) = exp ( − x ) ( 1 + exp ( − x ) ) 2 = sigmoid ( x ) ( 1 − sigmoid ( 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)
sigmoid ( x ) = 1 + exp ( − x ) 1 d x d sigmoid ( x ) = ( 1 + exp ( − x ) ) 2 exp ( − x ) = sigmoid ( x ) ( 1 − sigmoid ( x ) )
tanh ( x ) = 1 − exp ( − 2 x ) 1 + exp ( − 2 x ) d d x tanh ( x ) = 1 − tanh 2 ( x ) \operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}\\
\frac{d}{dx} \operatorname{tanh}(x) = 1 - \operatorname{tanh}^2(x)
tanh ( x ) = 1 + exp ( − 2 x ) 1 − exp ( − 2 x ) d x d tanh ( x ) = 1 − tanh 2 ( x )
多层感知机的简洁实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import torchfrom torch import nnfrom d2l import torch as d2lnet = 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)