【动手学深度学习】第三章笔记:线性回归、SoftMax 回归、交叉熵损失
这章感觉没什么需要特别记住的东西,感觉忘了回来翻一翻代码就好。
3.1 线性回归
3.1.1 线性回归的基本元素
1. 线性模型
用符号标识的矩阵 \(\boldsymbol{X} \in \mathbb{R}^{n\times d}\) 可以很方便地引用整个数据集中的 \(n\) 个样本。其中 \(\boldsymbol{X}\) 地每一行是一个样本,每一列是一种特征。
对于特征集合 \(\boldsymbol{X}\),预测值 \(\hat{\boldsymbol{y}} \in \mathbb{R}^n\) 可以通过矩阵-向量乘法表示为
然后求和的过程使用广播机制。另外,即使确信特征与标签的潜在关系是线性的,也会加入一个噪声项以考虑观测误差带来的影响。
2. 损失函数
这里采用的损失函数为平方误差函数。当样本 \(i\) 的预测值为 \(\hat{y}^{(i)}\),其相应的真实标签为 \(y^{(i)}\) 时,平方误差可以定义为:
这里的系数 \(\frac{1}{2}\) 的目的是为了求导后常数为 \(1\)。
因此,整个数据集上的损失均值为:
最后在训练模型时要找一组参数 \((\boldsymbol{w}^*, b^*)\) ,最小化总损失,即如:
3. 解析解
这里原书写的不是很清楚。具体合并大概是
是这样合并的,然后问题就转化为最小化 \(||\boldsymbol{y} - \boldsymbol{Xw}||^2\),然后令损失关于 \(\boldsymbol{w}\) 的导数设为 \(0\),那么有解析解:
当然了,一般的深度学习问题也没有解析解能给你求出来(233)。
4. 随机梯度下降
在每次需要计算更新的时候随机抽取一小批样本,这种变体叫做小批量随机梯度下降(minibatch stochastic gradient descent)。
大致可以写作如下公式:
其中,\(|B|\) 是 batch size,\(\eta\) 是学习率。
5. 用模型进行预测
由于在统计学中,推断(inference)更多地表示基于数据集估计参数,所以请尽量将给定特征的情况下估计目标的过程称为预测。
3.1.2 向量化加速
先定义一下 Timer
类,这个类可以丢进小本本里。
class Timer:
def __init__(self):
self.times = []
self.start()
def start(self):
self.tik = time.time()
def stop(self):
self.times.append(time.time() - self.tik)
return self.times[-1]
def avg(self):
return sum(self.times) / len(self.times)
def sum(self):
return sum(self.times)
def cumsum(self):
return np.array(self.times).cumsum().tolist()
然后用下面的 python 循环加法和 tensor 的向量加法比较。可以发现,尽量使用 PyTorch 向量化后的 tensor 进行运算。不得不感慨一下 python 是真的慢啊,即使是 tensor 加法也还要比 C++ 慢(tensor 的基础运算应该就是拿 C++ 实现的)。这也侧面证明向量加法在 CPU 环境下应该没有涉及到应用多核。
n = 10000
a = torch.ones(n)
b = torch.ones(n)
c = torch.zeros(n)
timer = Timer()
for i in range(n):
c[i] = a[i] + b[i]
f'{timer.stop():.5f} sec'
# '0.09908 sec'
timer.start()
d = a + b
f'{timer.stop():.5f} sec'
'0.00035 sec'
3.1.3 正态分布与平方损失
正态分布概率密度函数如下:
然后本书假设观测中包含的噪声服从正态分布。噪声正态分布如:\(y = \boldsymbol{w}^T\boldsymbol{x} + b + \epsilon\),其中,\(\epsilon \sim N(0, \sigma^2)\)。
通过给定 \(\boldsymbol{x}\) 观测到特定的 \(y\) 的似然为:
那么参数 \(\boldsymbol{w}\) 和 b 的最优值是使整个数据集的似然最大的值:
等价于最小化负对数似然:
要让上式最小,即让最后一项最小。因此在有高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计。
3.2 线性回归的从零开始实现
这节就不详细写了,有一些代码里感觉有意思的点写在这里好了。
- 假如说有一个 numpy 数组或者 PyTorch 的向量 \(\boldsymbol{x}\),那么可以用
y = x[torch.tensr([1, 2, 5])]
来获得一个只包含 \(x_1, x_2, x_5\) 的 \(\boldsymbol{y}\)。
优化算法代码如下:
def sgd(params, lr, batch_size):
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()
也就是说本质上是让它在不计算梯度的情况下,更新 param,然后让它的梯度更新成零。
训练用的代码为:
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss
for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y)
l.sum().backward()
sgd([w, b], lr, batch_size)
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')
这里有一大堆没定义的东西在书中前文提到了,但是我这里仅提供训练代码仅供示意。
linreg(X, w, b)
表示用 \(\boldsymbol{X}, \boldsymbol{w}, b\) 计算 \(\hat{\boldsymbol{y}}\)squared_loss(y_hat, y)
为均方损失features
和labels
是数据。data_iter
是一个生成器,负责迭代 batch_size 大小的数据。
练习中问了个很有趣的问题:
如果将权重初始化为零,会发生什么?算法仍然有效吗?
参考文章 谈谈神经网络权重为什么不能初始化为0,来回答这一问题。
-
如同书中只有一层线性层的时候:
由于
\[\hat{\boldsymbol{y}} = \boldsymbol{Xw} + b \\ l^{(i)}(\boldsymbol{w}, b) = \frac{1}{2} (\hat{y}^{(i)}-y^{(i)})^2 \\ L = \sum_{i=1}^n l^{(i)}(\boldsymbol{w}, b) \]代入,有
\[L = \sum_{i=1}^n l^{(i)}(\boldsymbol{w}, b) = \sum_{i=1}^n \frac{1}{2} (\hat{y}^{(i)}-y^{(i)})^2 = \sum_{i=1}^n \frac{1}{2} (\boldsymbol{x}^{(i)^T} \boldsymbol{w} + b -y^{(i)})^2 \]求导数,有
\[\frac{\partial L}{\partial w_0} = \sum_{i=1}^n x_0^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) \\ \frac{\partial L}{\partial w_1} = \sum_{i=1}^n x_1^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) \\ \]那么在第一次求导的时候,得
\[\frac{\partial L}{\partial w_0} = \sum_{i=1}^n x_0^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) = \sum_{i=1}^n x_0^{(i)} (b-y^{(i)}) \\ \frac{\partial L}{\partial w_1} = \sum_{i=1}^n x_1^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) = \sum_{i=1}^n x_1^{(i)} (b-y^{(i)}) \\ \]那么,导数不为 \(0\),就确实对于算法没有影响。
-
但是如果不是只有一层线性层的话:
不妨假设此时有两层线性层。第一层 \(\boldsymbol{W^{(0)} \in \mathbb{R}^{2 \times 2}}\),第二层 \(\boldsymbol{W}^{(1)} \in \mathbb{R}^2\)。过第一个线性层后输出的值就与输入 \(\boldsymbol{X}\) 无关了,那么再过第二个线性层后得到的结果就仅与第一个线性层的偏置以及第二个线性层有关了。那么,第二个线性层的权重关于第一个线性层权重的 Jacobi 矩阵是什么样子的呢?由于 \(y_{i} = (\sum_{j=1}^n w_{ij} x_j) + b\),因此 \(\frac{\mathrm{d}y_i}{\mathrm{d}x_j} = w_{ij} = 0\)。所以该矩阵是 \(0\) 矩阵,因此无法更新第一个线性层。然后又由于过了第一个线性层就与输入 \(\boldsymbol{X}\) 无关,所以权重不可以初始化为 \(0\)。
3.3 线性回归的简洁实现
仍然不全抄,只写一些有趣的代码放在这里。
torch.utils.data.DataLoader
返回的是一个可迭代对象(Iterable)而不是一个迭代器(Iterator)。
可以用 iter()
函数构造 Python 迭代器,并使用 next()
函数从迭代器中获取第一项。如下所示:
next(iter(torch.utils.data.DataLoader(dataset, batch_size, shuffle=is_train)))
然后用 net = nn.Sequential(nn.Linear(2, 1))
得到模型,那么,除了新写一个类初始化参数外,怎么初始化第一层的参数呢?通过 net[0]
选择网络中第一层,然后使用 weight.data
和 bias.data
方法来访问参数。
net[0].weight
是 torch.nn.parameter.Parameter
类的实例,这个类很有趣。一种被视为模块参数的张量。Parameter 是 Tensor 的子类,当与 Module 类一起使用时具有非常特殊的属性:当 Parameter 类被分配为 Module 类的属性时,它们会自动添加到其参数列表中,并将出现在例如在 parameters()
迭代器中。但是分配一个 Tensor 就没有这样的效果。调用 net[0].weight
返回:
net[0].weight
# Parameter containing:
# tensor([[-0.3418, -0.5904]], requires_grad=True)
调用 net[0].weight.data
返回 tensor,这说明它们仅仅是张量:
net[0].weight.data
# tensor([[-0.3418, -0.5904]])
还可以使用替换方法 normal_
和 fill_
来重写参数值。这两个方法是在 torch.tensor()
里面的方法。
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)
损失函数 nn.MSELoss()
及优化算法 torch.optim.SGD(net.parameters(), lr=0.03)
。这里 net.parameters()
是个生成器,同时也是一个特殊的迭代器。输出一下它:
loss = nn.MSELoss()
trainer = torch.optim.SGD(net.parameters(), lr=0.03)
print(net.parameters())
# <generator object Module.parameters at 0x716c1cf61150>
下面是训练代码,这段代码先把梯度清零再做反向传播,证明把梯度清零不会把 Jacobi 矩阵之类的中间状态清理掉,实践中极其不推荐像书中这么写,最好还是在求 loss 之前就清理梯度:
num_epochs = 3
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.000226
epoch 2, loss 0.000103
epoch 3, loss 0.000103
"""
3.4 softmax 回归
希望在对硬性类别分类的同时使用软性带有概率的模型。
3.4.1 模型
本章介绍了表示分类数据的简单方法:独热编码(one-hot encoding)。独热编码是一个向量,它的分量和类别一样多。类别对应的分量设置为 \(1\),其他所有分量设置为 \(0\)。
本节的网络架构仍为线性层,这里有和输出一样多的仿射函数,向量形式记作 \(\boldsymbol{o} = \boldsymbol{Wx} + \boldsymbol{b}\)。具有 \(d\) 个输入和 \(q\) 个输出的全连接层,参数开销为 \(O(dq)\),但是论文 Beyond Fully-Connected Layers with Quaternions: Parameterization of Hypercomplex Multiplications with \(\frac{1}{n}\) Parameters 提及可以将具有 \(d\) 个输入和 \(q\) 个输出的全连接层的成本减少到 \(O(dq/n)\),其中超参数 \(n\) 可以设定,以在实际应用中在参数节省和模型有效性之间进行平衡。(完全没读这论文说了啥233)
Softmax 函数可以表示为:
其中,
文中说,尽管 softmax 是一个非线性函数,但 softmax 回归的输出仍然由输入特征的仿射变换决定。因此,softmax 回归是一个线性模型(linear model)。
3.4.2 损失函数
1. 对数似然
假设数据集 \(\{ \boldsymbol{X}, \boldsymbol{Y}\}\) 具有 \(n\) 个样本,其中索引 \(i\) 的样本由特征向量 \(\boldsymbol{x}^{(i)}\) 和独热标签向量 \(\boldsymbol{y}^{(i)}\) 组成。因此可以将估计值与实际值比较:
最大化似然仍然是等价于熟悉的最小化负对数似然:
其中,对于任何标签 \(\boldsymbol{y}\) 和模型预测 \(\hat{\boldsymbol{y}}\),损失函数为:
上式一般被称为交叉熵损失。
2. softmax 及其导数
利用 softmax 定义可得:
考虑相对于任何未规范化的预测 \(o_j\) 的导数,可以得到:
不妨设 \(s_i = \text{softmax} (\boldsymbol{o})_i\),再求二阶导:
课后题还要求 \(\text{softmax} (\boldsymbol{o})\) 给出的分布方差,并和二阶导匹配起来,所以有
上面式子里除以 \(q-1\) 是符合统计学中无偏估计的做法。当然和除以 \(q\) 差别也不太大。
3. 信息论浅谈
信息论的基本想法是一个不太可能的事件居然发生了,要比一个非常可能的事件发生,能提供更多的信息。如果要通过这种基本想法来量化信息,可以遵循以下三个点:
- 非常可能发生的事件信息量要比较少,并且极端情况下,确保能够发生的事件应该没有信息量。
- 较不可能发生的事件具有更高的信息量。
- 独立事件应具有增量的信息。例如,投掷的硬币两次正面朝上传递的信息量,应该是投掷一次硬币正面朝上的信息量的两倍。
为了满足上述 \(3\) 个性质,因此定义一个事件 \(x\) 的自信息(self-information)为
这里定义的 \(I(x)\) 单位是奈特(nat)。一奈特是以 \(\frac{1}{e}\) 的概率观测到一个事件时获得的信息量。
自信息只处理单个的输出。可以用香农熵对整个概率分布中不确定性总量进行量化:
这个也可以记作 \(H(P)\)。一个分布的香农熵是指遵循这个分布的事件所产生的期望信息总量。
如果对同一个随机变量 \(x\) 有两个单独的概率分布 \(P(x)\) 和 \(Q(x)\),可以使用KL散度(Kullback-Leibler divergence)来衡量这两个分布的差异:
在离散型变量的情况下,KL 散度衡量的是,当使用一种被设计成能够使得概率分布 \(Q\) 产生的消息的长度最小的编码,发送包含由概率分布 \(P\) 产生的符号的消息时,所需要的额外信息量。
KL 散度有一些有用的性质如下:
- 非负
- KL 散度为 \(0\),当且仅当 \(P\) 和 \(Q\) 在离散性变量的情况下是相同的分布,或者在连续型变量的情况下是“几乎处处”相同的。
由于上述两个性质,因此它经常被用作分布之间的某种距离。然而,它并不满足交换性,即 \(D_{KL}(P||Q) \not = D_{KL}(Q||P)\)。
假设此时有一个分布 \(p(x)\),并且希望用另一个分布 \(q(x)\) 来近似它,那么就可以选择最小化 \(D_{KL}(p||q)\) 或者最小化 \(D_{KL}(q||p)\)。这其中选择哪一个 KL 散度是取决于问题的。选择 \(D_{KL}(p||q)\) 的目的是为了让近似分布 \(q\) 在真实分布 \(p\) 放置高概率的所有地方都放置高概率,而选择 \(D_{KL}(q||p)\) 的目的是为了让近似分布 \(q\) 在真实分布 \(p\) 放置低概率的所有地方都很少放置高概率。
一个和 KL 散度密切联系的量是交叉熵,即 \(H(P,Q) = H(P) + D_{KL}(P||Q) = -\mathbb{E}_{x \sim P} \log Q(x)\)。因此针对 \(Q\) 最小化交叉熵等价于最小化 KL 散度。
3.5 图像分类数据集
本章其实没啥亮点,有趣的内容稍微写一下:
一个用来展示图片以及标题的函数,有 num_rows 行 num_cols 列。
def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):
figsize = (num_cols * scale, num_rows * scale)
_, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
axes = axes.flatten()
for i, (ax, img) in enumerate(zip(axes, imgs)):
if torch.is_tensor(img):
ax.imshow(img.numpy())
else:
ax.imshow(img)
ax.axes.get_xaxis().set_visible(False)
ax.axes.get_yaxis().set_visible(False)
if titles:
ax.set_title(titles[i])
return axes
此外,num_workers
这个参数表示了使用子进程读取数据的个数。如果调小 batch_size
的话即使是 CPU 运行的代码速度也会减慢,在 num_workers=4
的时候,测试时间长度如下表:
batch_size | 时间 |
---|---|
1 | 117.74 |
4 | 28 |
256 | 3.11 |
3.6 softmax 回归的从零开始实现
仍然是有趣的内容:
torch.normal()
能够返回一个其中所有值都符合正态分布的 tensor。
Accumulator
类对多个变量进行累加。
class Accumulator:
def __init__(self, n):
self.data = [0.0] * n
def add(self, *args):
self.data = [a + float(b) for a, b in zip(self.data, args)]
def reset(self):
self.data = [0.0] * len(self.data)
def __getitem__(self, idx):
return self.data[idx]
还有一个可以在动画中绘制图表的实用程序类 Animator
。此函数仅能在 notebook 中使用。
import torch
from IPython import display
from d2l import torch as d2l
class Animator: #@save
"""在动画中绘制数据"""
def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
ylim=None, xscale='linear', yscale='linear',
fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, ncols=1,
figsize=(3.5, 2.5)):
# 增量地绘制多条线
if legend is None:
legend = []
d2l.use_svg_display()
self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
if nrows * ncols == 1:
self.axes = [self.axes, ]
# 使用lambda函数捕获参数
self.config_axes = lambda: d2l.set_axes(
self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
self.X, self.Y, self.fmts = None, None, fmts
def add(self, x, y):
# 向图表中添加多个数据点
if not hasattr(y, "__len__"):
y = [y]
n = len(y)
if not hasattr(x, "__len__"):
x = [x] * n
if not self.X:
self.X = [[] for _ in range(n)]
if not self.Y:
self.Y = [[] for _ in range(n)]
for i, (a, b) in enumerate(zip(x, y)):
if a is not None and b is not None:
self.X[i].append(a)
self.Y[i].append(b)
self.axes[0].cla()
for x, y, fmt in zip(self.X, self.Y, self.fmts):
self.axes[0].plot(x, y, fmt)
self.config_axes()
display.display(self.fig)
display.clear_output(wait=True)
这个类应该怎么用呢?见下方代码
animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
legend=['train loss', 'train acc', 'test acc'])
for epoch in range(num_epochs):
train_metrics = (train_loss, train_acc)
animator.add(epoch + 1, train_metrics + (test_acc,))
3.7 softmax 回归的简洁实现
如何在类外给所有线性层初始化?可以使用 nn.Module.apply(fn)
可以做到。它的本来作用是递归地对所有子模块(包括自己)做相同的操作。如:
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)
即对 net 中所有 Linear 层初始化参数。
为防止由于指数函数导致的上溢出,因此再继续 softmax 运算之前,先从所有 \(o_k\) 中减去 \(\max (o_k)\),事实上这样不会改变 softmax 的返回值:
又由于有些 \(\exp(o_j - max(o_k))\) 具有较大的负值,可能导致求完指数函数后直接下溢出归零,并使得 \(\log(\hat{y}_j)\) 的值变为负无穷大。反向传播几步之后,可能会发现满屏幕的 nan
。因此将交叉熵和 softmax 操作结合在一起:
这些具体落实到代码上是模型过完最后一个线性层不要做 softmax 操作,直接往 PyTorch 的 CrossEntropyLoss 里面丢就行了,因为它已经结合好了。