PyTorch 深度学习(GPT 重译)(二)(3)

简介: PyTorch 深度学习(GPT 重译)(二)

PyTorch 深度学习(GPT 重译)(二)(2)https://developer.aliyun.com/article/1485204

5.3.1 从问题返回到 PyTorch

我们已经找到了模型和损失函数–我们已经在图 5.2 的高层图片中找到了一个很好的部分。现在我们需要启动学习过程并提供实际数据。另外,数学符号够了;让我们切换到 PyTorch–毕竟,我们来这里是为了乐趣

我们已经创建了我们的数据张量,现在让我们将模型写成一个 Python 函数:

# In[3]:
def model(t_u, w, b):
    return w * t_u + b

我们期望t_uwb分别是输入张量,权重参数和偏置参数。在我们的模型中,参数将是 PyTorch 标量(也称为零维张量),并且乘法操作将使用广播产生返回的张量。无论如何,是时候定义我们的损失了:

# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

请注意,我们正在构建一个差异张量,逐元素取平方,最终通过平均所有结果张量中的元素产生一个标量损失函数。这是一个均方损失

现在我们可以初始化参数,调用模型,

# In[5]:
w = torch.ones(())
b = torch.zeros(())
t_p = model(t_u, w, b)
t_p
# Out[5]:
tensor([35.7000, 55.9000, 58.2000, 81.9000, 56.3000, 48.9000, 33.9000,
        21.8000, 48.4000, 60.4000, 68.4000])

并检查损失的值:

# In[6]:
loss = loss_fn(t_p, t_c)
loss
# Out[6]:
tensor(1763.8846)

我们在本节中实现了模型和损失。我们终于到达了示例的核心:我们如何估计wb,使损失达到最小?我们首先手动解决问题,然后学习如何使用 PyTorch 的超能力以更通用、现成的方式解决相同的问题。

广播

我们在第三章提到了广播,并承诺在需要时更仔细地研究它。在我们的例子中,我们有两个标量(零维张量)wb,我们将它们与长度为 b 的向量(一维张量)相乘并相加。

通常——在 PyTorch 的早期版本中也是如此——我们只能对形状相同的参数使用逐元素二元操作,如加法、减法、乘法和除法。在每个张量中的匹配位置的条目将用于计算结果张量中相应条目。

广播,在 NumPy 中很受欢迎,并被 PyTorch 采用,放宽了大多数二元操作的这一假设。它使用以下规则来匹配张量元素:

  • 对于每个索引维度,从后往前计算,如果其中一个操作数在该维度上的大小为 1,则 PyTorch 将使用该维度上的单个条目与另一个张量沿着该维度的每个条目。
  • 如果两个大小都大于 1,则它们必须相同,并且使用自然匹配。
  • 如果两个张量中一个的索引维度比另一个多,则另一个张量的整体将用于沿着这些维度的每个条目。

这听起来很复杂(如果我们不仔细注意,可能会出错,这就是为什么我们在第 3.4 节中将张量维度命名的原因),但通常,我们可以写下张量维度来看看会发生什么,或者通过使用空间维度来展示广播的方式来想象会发生什么,就像下图所示。

当然,如果没有一些代码示例,这一切都只是理论:

# In[7]:
x = torch.ones(())
y = torch.ones(3,1)
z = torch.ones(1,3)
a = torch.ones(2, 1, 1)
print(f"shapes: x: {x.shape}, y: {y.shape}")
print(f"        z: {z.shape}, a: {a.shape}")

print("x * y:", (x * y).shape)
print("y * z:", (y * z).shape)
print("y * z * a:", (y * z * a).shape)
# Out[7]:
shapes: x: torch.Size([]), y: torch.Size([3, 1])
        z: torch.Size([1, 3]), a: torch.Size([2, 1, 1])
x * y: torch.Size([3, 1])
y * z: torch.Size([3, 3])

5.4 沿着梯度下降

我们将使用梯度下降算法优化参数的损失函数。在本节中,我们将从第一原理建立对梯度下降如何工作的直觉,这将在未来对我们非常有帮助。正如我们提到的,有更有效地解决我们示例问题的方法,但这些方法并不适用于大多数深度学习任务。梯度下降实际上是一个非常简单的想法,并且在具有数百万参数的大型神经网络模型中表现出色。

图 5.5 优化过程的卡通描绘,一个人带有 w 和 b 旋钮,寻找使损失减少的旋钮转动方向

让我们从一个心理形象开始,我们方便地在图 5.5 中勾画出来。假设我们站在一台带有标有wb的两个旋钮的机器前。我们可以在屏幕上看到损失值,并被告知要将该值最小化。不知道旋钮对损失的影响,我们开始摆弄它们,并为每个旋钮决定哪个方向使损失减少。我们决定将两个旋钮都旋转到损失减少的方向。假设我们离最佳值很远:我们可能会看到损失迅速减少,然后随着接近最小值而减慢。我们注意到在某个时刻,损失再次上升,因此我们反转一个或两个旋钮的旋转方向。我们还了解到当损失变化缓慢时,调整旋钮更精细是个好主意,以避免达到损失再次上升的点。过一段时间,最终,我们收敛到一个最小值。

5.4.1 减小损失

梯度下降与我们刚刚描述的情景并没有太大不同。其思想是计算损失相对于每个参数的变化率,并将每个参数修改为减小损失的方向。就像我们在调节旋钮时一样,我们可以通过向wb添加一个小数并观察在该邻域内损失的变化来估计变化率:

# In[8]:
delta = 0.1
loss_rate_of_change_w = \
    (loss_fn(model(t_u, w + delta, b), t_c) -
     loss_fn(model(t_u, w - delta, b), t_c)) / (2.0 * delta)

这意味着在当前wb的值的邻域内,增加w会导致损失发生一些变化。如果变化是负的,那么我们需要增加w以最小化损失,而如果变化是正的,我们需要减少w。增加多少?根据损失的变化率对w应用变化是个好主意,特别是当损失有几个参数时:我们对那些对损失产生显著变化的参数应用变化。通常,总体上缓慢地改变参数是明智的,因为在当前w值的邻域之外,变化率可能截然不同。因此,我们通常应该通过一个小因子来缩放变化率。这个缩放因子有许多名称;我们在机器学习中使用的是learning_rate

# In[9]:
learning_rate = 1e-2
w = w - learning_rate * loss_rate_of_change_w

我们可以用b做同样的事情:

# In[10]:
loss_rate_of_change_b = \
    (loss_fn(model(t_u, w, b + delta), t_c) -
     loss_fn(model(t_u, w, b - delta), t_c)) / (2.0 * delta)
b = b - learning_rate * loss_rate_of_change_b

这代表了梯度下降的基本参数更新步骤。通过重复这些评估(并且只要我们选择足够小的学习率),我们将收敛到使给定数据上计算的损失最小的参数的最佳值。我们很快将展示完整的迭代过程,但我们刚刚计算变化率的方式相当粗糙,在继续之前需要进行升级。让我们看看为什么以及如何。

5.4.2 进行分析

通过重复评估模型和损失来计算变化率,以探究在wb邻域内损失函数的行为的方法在具有许多参数的模型中不具有良好的可扩展性。此外,并不总是清楚邻域应该有多大。我们在前一节中选择了delta等于 0.1,但这完全取决于损失作为wb函数的形状。如果损失相对于delta变化太快,我们将无法很好地了解损失减少最多的方向。

如果我们可以使邻域无限小,就像图 5.6 中那样,会发生什么?这正是当我们对参数的损失进行导数分析时发生的情况。在我们处理的具有两个或更多参数的模型中,我们计算损失相对于每个参数的各个导数,并将它们放入导数向量中:梯度

图 5.6 在离散位置评估时下降方向的估计差异与分析方法

计算导数

为了计算损失相对于参数的导数,我们可以应用链式法则,并计算损失相对于其输入(即模型的输出)的导数,乘以模型相对于参数的导数:

d loss_fn / d w = (d loss_fn / d t_p) * (d t_p / d w)

回想一下我们的模型是一个线性函数,我们的损失是平方和。让我们找出导数的表达式。回想一下损失的表达式:

# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

记住d x² / d x = 2 x,我们得到

# In[11]:
def dloss_fn(t_p, t_c):
    dsq_diffs = 2 * (t_p - t_c) / t_p.size(0)    # ❶
    return dsq_diffs

❶ 分割是来自均值的导数。

将导数应用于模型

对于模型,回想一下我们的模型是

# In[3]:
def model(t_u, w, b):
    return w * t_u + b

我们得到这些导数:

# In[12]:
def dmodel_dw(t_u, w, b):
    return t_u
# In[13]:
def dmodel_db(t_u, w, b):
    return 1.0
定义梯度函数

将所有这些放在一起,返回损失相对于wb的梯度的函数是

# In[14]:
def grad_fn(t_u, t_c, t_p, w, b):
    dloss_dtp = dloss_fn(t_p, t_c)
    dloss_dw = dloss_dtp * dmodel_dw(t_u, w, b)
    dloss_db = dloss_dtp * dmodel_db(t_u, w, b)
    return torch.stack([dloss_dw.sum(), dloss_db.sum()])     # ❶

❶ 求和是我们在模型中将参数应用于整个输入向量时隐式执行的广播的反向。

用数学符号表示相同的想法如图 5.7 所示。再次,我们对所有数据点进行平均(即,求和并除以一个常数),以获得每个损失的偏导数的单个标量量。

图 5.7 损失函数相对于权重的导数

5.4.3 迭代拟合模型

现在我们已经准备好优化我们的参数了。从参数的一个暂定值开始,我们可以迭代地对其应用更新,进行固定次数的迭代,或直到wb停止改变。有几个停止标准;现在,我们将坚持固定次数的迭代。

训练循环

既然我们在这里,让我们介绍另一个术语。我们称之为训练迭代,我们在其中为所有训练样本更新参数一个时代

完整的训练循环如下(code/p1ch5/1_parameter_estimation .ipynb):

# In[15]:
def training_loop(n_epochs, learning_rate, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        w, b = params
        t_p = model(t_u, w, b)                             # ❶
        loss = loss_fn(t_p, t_c)
        grad = grad_fn(t_u, t_c, t_p, w, b)                # ❷
        params = params - learning_rate * grad
        print('Epoch %d, Loss %f' % (epoch, float(loss)))  # ❸
    return params

❶ 正向传播

❷ 反向传播

❸ 这个记录行可能非常冗长。

用于文本输出的实际记录逻辑更复杂(请参见同一笔记本中的第 15 单元:mng.bz/pBB8),但这些差异对于理解本章的核心概念并不重要。

现在,让我们调用我们的训练循环:

# In[17]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_u,
    t_c = t_c)
# Out[17]:
Epoch 1, Loss 1763.884644
    Params: tensor([-44.1730,  -0.8260])
    Grad:   tensor([4517.2969,   82.6000])
Epoch 2, Loss 5802485.500000
    Params: tensor([2568.4014,   45.1637])
    Grad:   tensor([-261257.4219,   -4598.9712])
Epoch 3, Loss 19408035840.000000
    Params: tensor([-148527.7344,   -2616.3933])
    Grad:   tensor([15109614.0000,   266155.7188])
...
Epoch 10, Loss 90901154706620645225508955521810432.000000
    Params: tensor([3.2144e+17, 5.6621e+15])
    Grad:   tensor([-3.2700e+19, -5.7600e+17])
Epoch 11, Loss inf
    Params: tensor([-1.8590e+19, -3.2746e+17])
    Grad:   tensor([1.8912e+21, 3.3313e+19])
tensor([-1.8590e+19, -3.2746e+17])
过度训练

等等,发生了什么?我们的训练过程实际上爆炸了,导致损失变为inf。这清楚地表明params正在接收太大的更新,它们的值开始来回振荡,因为每次更新都超过了,下一个更正得更多。优化过程不稳定:它发散而不是收敛到最小值。我们希望看到对params的更新越来越小,而不是越来越大,如图 5.8 所示。

图 5.8 顶部:由于步长过大,在凸函数(类似抛物线)上发散的优化。底部:通过小步骤收敛的优化。

我们如何限制learning_rate * grad的幅度?嗯,这看起来很容易。我们可以简单地选择一个较小的learning_rate,实际上,当训练不如我们希望的那样顺利时,学习率是我们通常更改的事物之一。我们通常按数量级更改学习率,因此我们可以尝试使用1e-31e-4,这将使更新的幅度减少数量级。让我们选择1e-4,看看效果如何:

# In[18]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-4,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_u,
    t_c = t_c)
# Out[18]:
Epoch 1, Loss 1763.884644
    Params: tensor([ 0.5483, -0.0083])
    Grad:   tensor([4517.2969,   82.6000])
Epoch 2, Loss 323.090546
    Params: tensor([ 0.3623, -0.0118])
    Grad:   tensor([1859.5493,   35.7843])
Epoch 3, Loss 78.929634
    Params: tensor([ 0.2858, -0.0135])
    Grad:   tensor([765.4667,  16.5122])
...
Epoch 10, Loss 29.105242
    Params: tensor([ 0.2324, -0.0166])
    Grad:   tensor([1.4803, 3.0544])
Epoch 11, Loss 29.104168
    Params: tensor([ 0.2323, -0.0169])
    Grad:   tensor([0.5781, 3.0384])
...
Epoch 99, Loss 29.023582
    Params: tensor([ 0.2327, -0.0435])
    Grad:   tensor([-0.0533,  3.0226])
Epoch 100, Loss 29.022669
    Params: tensor([ 0.2327, -0.0438])
    Grad:   tensor([-0.0532,  3.0226])
tensor([ 0.2327, -0.0438])

不错–行为现在稳定了。但还有另一个问题:参数的更新非常小,因此损失下降非常缓慢,最终停滞。我们可以通过使learning_rate自适应来避免这个问题:即根据更新的幅度进行更改。有一些优化方案可以做到这一点,我们将在本章末尾的第 5.5.2 节中看到其中一个。

然而,在更新项中还有另一个潜在的麻烦制造者:梯度本身。让我们回过头看看在优化期间第 1 个时期的grad

5.4.4 标准化输入

我们可以看到,权重的第一轮梯度大约比偏置的梯度大 50 倍。这意味着权重和偏置存在于不同比例的空间中。如果是这种情况,一个足够大以便有意义地更新一个参数的学习率对于另一个参数来说会太大而不稳定;而对于另一个参数来说合适的速率将不足以有意义地改变第一个参数。这意味着除非改变问题的表述,否则我们将无法更新我们的参数。我们可以为每个参数设置单独的学习率,但对于具有许多参数的模型来说,这将是太麻烦的事情;这是我们不喜欢的照看的一种方式。

有一个更简单的方法来控制事物:改变输入,使得梯度不那么不同。我们可以确保输入的范围不会远离-1.01.0的范围,粗略地说。在我们的情况下,我们可以通过简单地将t_u乘以 0.1 来实现接近这个范围:

# In[19]:
t_un = 0.1 * t_u

在这里,我们通过在变量名后附加一个n来表示t_u的归一化版本。此时,我们可以在我们的归一化输入上运行训练循环:

# In[20]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_un,                  # ❶
    t_c = t_c)
# Out[20]:
Epoch 1, Loss 80.364342
    Params: tensor([1.7761, 0.1064])
    Grad:   tensor([-77.6140, -10.6400])
Epoch 2, Loss 37.574917
    Params: tensor([2.0848, 0.1303])
    Grad:   tensor([-30.8623,  -2.3864])
Epoch 3, Loss 30.871077
    Params: tensor([2.2094, 0.1217])
    Grad:   tensor([-12.4631,   0.8587])
...
Epoch 10, Loss 29.030487
    Params: tensor([ 2.3232, -0.0710])
    Grad:   tensor([-0.5355,  2.9295])
Epoch 11, Loss 28.941875
    Params: tensor([ 2.3284, -0.1003])
    Grad:   tensor([-0.5240,  2.9264])
...
Epoch 99, Loss 22.214186
    Params: tensor([ 2.7508, -2.4910])
    Grad:   tensor([-0.4453,  2.5208])
Epoch 100, Loss 22.148710
    Params: tensor([ 2.7553, -2.5162])
    Grad:   tensor([-0.4446,  2.5165])
tensor([ 2.7553, -2.5162])

❶ 我们已经将t_u更新为我们的新的、重新缩放的t_un

即使我们将学习率设置回1e-2,参数在迭代更新过程中不会爆炸。让我们看一下梯度:它们的数量级相似,因此对两个参数使用相同的learning_rate效果很好。我们可能可以比简单地乘以 10 进行更好的归一化,但由于这种方法对我们的需求已经足够好,我们暂时将坚持使用这种方法。

注意 这里的归一化绝对有助于训练网络,但你可以提出一个论点,即对于这个特定问题,严格来说并不需要优化参数。这绝对正确!这个问题足够小,有很多方法可以击败参数。然而,对于更大、更复杂的问题,归一化是一个简单而有效(如果不是至关重要!)的工具,用来改善模型的收敛性。

让我们运行足够的迭代次数来看到params的变化变得很小。我们将n_epochs更改为 5,000:

# In[21]:
params = training_loop(
    n_epochs = 5000,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_un,
    t_c = t_c,
    print_params = False)
params
# Out[21]:
Epoch 1, Loss 80.364342
Epoch 2, Loss 37.574917
Epoch 3, Loss 30.871077
...
Epoch 10, Loss 29.030487
Epoch 11, Loss 28.941875
...
Epoch 99, Loss 22.214186
Epoch 100, Loss 22.148710
...
Epoch 4000, Loss 2.927680
Epoch 5000, Loss 2.927648
tensor([  5.3671, -17.3012])

很好:我们的损失在我们沿着梯度下降方向改变参数时减少。它并没有完全降到零;这可能意味着没有足够的迭代次数收敛到零,或者数据点并不完全位于一条直线上。正如我们预料的那样,我们的测量并不完全准确,或者在读数中存在噪音。

但是看:wb的值看起来非常像我们需要用来将摄氏度转换为华氏度的数字(在我们将输入乘以 0.1 进行归一化之后)。确切的值将是w=5.5556b=-17.7778。我们时髦的温度计一直显示的是华氏温度。没有什么大的发现,除了我们的梯度下降优化过程有效!

5.4.5 再次可视化

让我们重新审视一下我们一开始做的事情:绘制我们的数据。说真的,这是任何从事数据科学的人都应该做的第一件事。始终大量绘制数据:

# In[22]:
%matplotlib inline
from matplotlib import pyplot as plt
t_p = model(t_un, *params)                    # ❶
fig = plt.figure(dpi=600)
plt.xlabel("Temperature (°Fahrenheit)")
plt.ylabel("Temperature (°Celsius)")
plt.plot(t_u.numpy(), t_p.detach().numpy())   # ❷
plt.plot(t_u.numpy(), t_c.numpy(), 'o')

❶ 记住我们是在归一化的未知单位上进行训练。我们还使用参数解包。

❷ 但我们正在绘制原始的未知值。

我们在这里使用了一个名为参数解包的 Python 技巧:*params意味着将params的元素作为单独的参数传递。在 Python 中,这通常是用于列表或元组的,但我们也可以在 PyTorch 张量中使用参数解包,这些张量沿着主导维度分割。因此,在这里,model(t_un, *params)等同于model(t_un, params[0], params[1])

此代码生成图 5.9。我们的线性模型似乎是数据的一个很好的模型。看起来我们的测量有些不稳定。我们应该给我们的验光师打电话换一副新眼镜,或者考虑退还我们的高级温度计。


图 5.9 我们的线性拟合模型(实线)与输入数据(圆圈)的绘图

5.5 PyTorch 的 autograd:反向传播一切

在我们的小冒险中,我们刚刚看到了反向传播的一个简单示例:我们使用链式法则向后传播导数,计算了函数组合(模型和损失)相对于它们最内部参数(wb)的梯度。这里的基本要求是,我们处理的所有函数都可以在解析上进行微分。如果是这种情况,我们可以一次性计算出相对于参数的梯度–我们之前称之为“损失变化率”。

即使我们有一个包含数百万参数的复杂模型,只要我们的模型是可微的,计算相对于参数的损失梯度就相当于编写导数的解析表达式并评估它们一次。当然,编写一个非常深层次的线性和非线性函数组合的导数的解析表达式并不是一件有趣的事情。这也不是特别快的过程。

5.5.1 自动计算梯度

这就是当 PyTorch 张量发挥作用时的时候,PyTorch 组件 autograd 就派上用场了。第三章介绍了张量是什么以及我们可以在它们上调用什么函数的全面概述。然而,我们遗漏了一个非常有趣的方面:PyTorch 张量可以记住它们的来源,即生成它们的操作和父张量,并且可以自动提供这些操作相对于它们的输入的导数链。这意味着我们不需要手动推导我们的模型;给定一个前向表达式,无论多么嵌套,PyTorch 都会自动提供该表达式相对于其输入参数的梯度。

应用 autograd

此时,继续前进的最佳方式是重新编写我们的温度计校准代码,这次使用 autograd,并看看会发生什么。首先,我们回顾一下我们的模型和损失函数。

code/p1ch5/2_autograd.ipynb

# In[3]:
def model(t_u, w, b):
    return w * t_u + b
# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

让我们再次初始化一个参数张量:

# In[5]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
使用 grad 属性

注意张量构造函数中的 requires_grad=True 参数?该参数告诉 PyTorch 跟踪由于对 params 进行操作而产生的张量的整个家族树。换句话说,任何将 params 作为祖先的张量都将访问从 params 到该张量的链式函数。如果这些函数是可微的(大多数 PyTorch 张量操作都是可微的),导数的值将自动填充为 params 张量的 grad 属性。

一般来说,所有 PyTorch 张量都有一个名为 grad 的属性。通常,它是 None

# In[6]:
params.grad is None
# Out[6]:
True

PyTorch 深度学习(GPT 重译)(二)(4)https://developer.aliyun.com/article/1485206

相关文章
|
前端开发 JavaScript 安全
JavaScript 权威指南第七版(GPT 重译)(七)(4)
JavaScript 权威指南第七版(GPT 重译)(七)
24 0
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(3)
JavaScript 权威指南第七版(GPT 重译)(七)
33 0
|
前端开发 JavaScript Unix
JavaScript 权威指南第七版(GPT 重译)(七)(2)
JavaScript 权威指南第七版(GPT 重译)(七)
42 0
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(1)
JavaScript 权威指南第七版(GPT 重译)(七)
60 0
|
13天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(4)
JavaScript 权威指南第七版(GPT 重译)(六)
90 2
JavaScript 权威指南第七版(GPT 重译)(六)(4)
|
13天前
|
前端开发 JavaScript API
JavaScript 权威指南第七版(GPT 重译)(六)(3)
JavaScript 权威指南第七版(GPT 重译)(六)
55 4
|
13天前
|
XML 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(2)
JavaScript 权威指南第七版(GPT 重译)(六)
60 4
JavaScript 权威指南第七版(GPT 重译)(六)(2)
|
13天前
|
前端开发 JavaScript 安全
JavaScript 权威指南第七版(GPT 重译)(六)(1)
JavaScript 权威指南第七版(GPT 重译)(六)
27 3
JavaScript 权威指南第七版(GPT 重译)(六)(1)
|
13天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(五)(4)
JavaScript 权威指南第七版(GPT 重译)(五)
39 9
|
13天前
|
前端开发 JavaScript 程序员
JavaScript 权威指南第七版(GPT 重译)(五)(3)
JavaScript 权威指南第七版(GPT 重译)(五)
36 8