PyTorch 是一个开源的深度学习框架。它主要用于开发和训练机器学习模型,尤其是深度学习模型。PyTorch的核心提供了两个主要功能:
- 一个n维张量,类似于numpy,但可以在GPU上运行
- 自动微分,用于构建和训练神经网络
我们将使用一个全连接的ReLU网络作为我们的示例。该网络将有一个隐藏层,并通过梯度下降训练,最小化网络输出与真实输出之间的欧几里得距离,以拟合随机数据。
#### 网络结构:
1. **输入层:** 设输入为 $\mathbf{x} \in \mathbb{R}^n$,其中 $n$ 是输入的特征数量。
2. **隐藏层:** 使用 ReLU 激活函数的全连接层。设隐藏层的神经元数量为 $h$,那么隐藏层的输出为 $\mathbf{z}^{(1)} \in \mathbb{R}^h$。
3. **输出层:** 输出层没有激活函数,直接计算网络的预测值 $\hat{\mathbf{y}} \in \mathbb{R}^m$,其中 $m$ 是输出的维度。
#### 网络的前向传播:
- **输入到隐藏层的加权和:**
$$
\mathbf{z}^{(1)} = W^{(1)} \mathbf{x} + b^{(1)}
$$
其中 $W^{(1)} \in \mathbb{R}^{h \times n}$ 是输入到隐藏层的权重矩阵,$b^{(1)} \in \mathbb{R}^h$ 是偏置项。
- **ReLU 激活函数:**
$$
\mathbf{a}^{(1)} = \text{ReLU}(\mathbf{z}^{(1)}) = \max(0, \mathbf{z}^{(1)})
$$
这里 $\mathbf{a}^{(1)} \in \mathbb{R}^h$ 是隐藏层的激活值,ReLU 函数对每个元素进行逐元素操作。
- **输出层的加权和:**
$$
\hat{\mathbf{y}} = W^{(2)} \mathbf{a}^{(1)} + b^{(2)}
$$
其中 $W^{(2)} \in \mathbb{R}^{m \times h}$ 是隐藏层到输出层的权重矩阵,$b^{(2)} \in \mathbb{R}^m$ 是输出层的偏置项。
#### 损失函数:
为了训练网络,我们使用欧几里得距离(均方误差)作为损失函数。设真实输出为 $\mathbf{y} \in \mathbb{R}^m$,损失函数定义为:
$$
L(\hat{\mathbf{y}}, \mathbf{y}) = \frac{1}{2} \left\| \hat{\mathbf{y}} - \mathbf{y} \right\|^2 = \frac{1}{2} \sum_{i=1}^m (\hat{y}_i - y_i)^2
$$
其中 $\hat{\mathbf{y}}$ 是网络的预测值,$\mathbf{y}$ 是真实标签。
#### 反向传播和梯度下降:
我们使用梯度下降算法来优化网络参数 $W^{(1)}, b^{(1)}, W^{(2)}, b^{(2)}$。首先,我们需要计算损失函数相对于这些参数的梯度。通过反向传播,我们得到:
### 1. 输出层的梯度计算
#### 输出层的梯度
首先,计算损失函数对输出 $\hat{\mathbf{y}}$ 的梯度:
$$
\frac{\partial L}{\partial \hat{\mathbf{y}}} = \hat{\mathbf{y}} - \mathbf{y}
$$
然后,输出层的权重 $ W^{(2)} $ 和偏置 $ b^{(2)} $ 的梯度计算。
- **输出层权重的梯度**:
输出层的加权和为:
$$
\hat{\mathbf{y}} = W^{(2)} \mathbf{a}^{(1)} + b^{(2)}
$$
对于 $ W^{(2)} $,通过链式法则:
$$
\frac{\partial \hat{\mathbf{y}}}{\partial W^{(2)}} = \mathbf{a}^{(1)T}
$$
因此,损失函数对 $ W^{(2)} $ 的梯度为:
$$
\frac{\partial L}{\partial W^{(2)}} = (\hat{\mathbf{y}} - \mathbf{y}) \mathbf{a}^{(1)T}
$$
- **输出层偏置的梯度**:
对 $ b^{(2)} $ 的梯度为:
$$
\frac{\partial L}{\partial b^{(2)}} = \hat{\mathbf{y}} - \mathbf{y}
$$
### 2. 隐藏层的梯度计算
接下来,计算隐藏层的梯度。为了将输出误差反向传播到隐藏层,我们首先计算损失函数对隐藏层激活值 $ \mathbf{a}^{(1)} $ 的梯度。
- **隐藏层激活值 $ \mathbf{a}^{(1)} $ 的梯度**:
损失函数对隐藏层激活值的梯度通过链式法则得到:
$$
\frac{\partial L}{\partial \mathbf{a}^{(1)}} = W^{(2)T} (\hat{\mathbf{y}} - \mathbf{y})
$$
- **ReLU 激活函数的梯度**:
ReLU 函数 $ \mathbf{a}^{(1)} = \text{ReLU}(\mathbf{z}^{(1)}) = \max(0, \mathbf{z}^{(1)}) $ 的梯度是逐元素的:
$$
\frac{\partial \mathbf{a}^{(1)}}{\partial \mathbf{z}^{(1)}} = \mathbf{1}_{\mathbf{z}^{(1)} > 0}
$$
这里 $ \mathbf{1}_{\mathbf{z}^{(1)} > 0} $ 是指示函数,当 $ \mathbf{z}^{(1)} > 0 $ 时值为 1,否则为 0。
结合链式法则,损失函数对 $ \mathbf{z}^{(1)} $ 的梯度为:
$$
\frac{\partial L}{\partial \mathbf{z}^{(1)}} = \frac{\partial L}{\partial \mathbf{a}^{(1)}} \odot \mathbf{1}_{\mathbf{z}^{(1)} > 0}
$$
### 3. 隐藏层权重和偏置的梯度计算
- **隐藏层权重的梯度**:
隐藏层的加权和为:
$$
\mathbf{z}^{(1)} = W^{(1)} \mathbf{x} + b^{(1)}
$$
对 $ W^{(1)} $ 的梯度为:
$$
\frac{\partial L}{\partial W^{(1)}} = \frac{\partial L}{\partial \mathbf{z}^{(1)}} \mathbf{x}^T
$$
- **隐藏层偏置的梯度**:
对 $ b^{(1)} $ 的梯度为:
$$
\frac{\partial L}{\partial b^{(1)}} = \frac{\partial L}{\partial \mathbf{z}^{(1)}}
$$
### 4. 梯度下降更新规则
利用上述梯度,我们可以通过梯度下降法更新网络的参数。
- **更新权重**:
$$
W^{(1)} := W^{(1)} - \eta \frac{\partial L}{\partial W^{(1)}}, \quad W^{(2)} := W^{(2)} - \eta \frac{\partial L}{\partial W^{(2)}}
$$
- **更新偏置**:
$$
b^{(1)} := b^{(1)} - \eta \frac{\partial L}{\partial b^{(1)}}, \quad b^{(2)} := b^{(2)} - \eta \frac{\partial L}{\partial b^{(2)}}
$$
其中,$ \eta $ 是学习率。
### 目录
- 热身:numpy
- PyTorch:张量
- PyTorch:自动求导
- PyTorch:nn
- PyTorch:优化器
- PyTorch:自定义nn模块
- PyTorch:控制流与权重共享
## 热身:numpy
在介绍 PyTorch 之前,我们将首先使用 numpy 实现这个网络。
Numpy 提供了一个 n 维数组对象,以及许多用于操作这些数组的函数。Numpy 是一个通用的科学计算框架;它并不了解计算图、深度学习或梯度。然而,我们可以轻松地使用 numpy 通过手动实现前向和反向传播,来拟合一个两层网络到随机数据,操作方法如下:
```python
import numpy as np
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机输入和输出数据
x = np.random.randn(N, D_in)
y = np.random.randn(N, D_out)
# 随机初始化权重
w1 = np.random.randn(D_in, H)
w2 = np.random.randn(H, D_out)
learning_rate = 1e-6
for t in range(500):
# 前向传播:计算预测的 y
h = x.dot(w1)
h_relu = np.maximum(h, 0)
y_pred = h_relu.dot(w2)
# 计算并打印损失
loss = np.square(y_pred - y).sum()
print(t, loss)
# 反向传播:计算 w1 和 w2 相对于损失的梯度
grad_y_pred = 2.0 * (y_pred - y)
grad_w2 = h_relu.T.dot(grad_y_pred)
grad_h_relu = grad_y_pred.dot(w2.T)
grad_h = grad_h_relu.copy()
grad_h[h < 0] = 0
grad_w1 = x.T.dot(grad_h)
# 更新权重
w1 -= learning_rate * grad_w1
w2 -= learning_rate * grad_w2
```
## PyTorch:张量
Numpy 是一个很好的框架,但它不能利用 GPU 来加速其数值计算。对于现代的深度神经网络,GPU 往往能提供 [50倍或更高](https://github.com/jcjohnson/cnn-benchmarks) 的加速,因此不幸的是,单单依靠 numpy 对于现代深度学习来说是不够的。
在这里,我们介绍 PyTorch 最基本的概念:**张量(Tensor)**。PyTorch 张量在概念上与 numpy 数组相同:张量是一个 n 维数组,PyTorch 提供了许多用于操作这些张量的函数。你可能希望在 numpy 中进行的任何计算,也可以使用 PyTorch 张量来完成;你应该将它们视为科学计算的通用工具。
然而,与 numpy 不同,PyTorch 张量可以利用 GPU 来加速它们的数值计算。要在 GPU 上运行 PyTorch 张量,你可以在构造张量时使用 `device` 参数,将张量放置在 GPU 上。
在这里,我们使用 PyTorch 张量来拟合一个两层网络到随机数据。像上面的 numpy 示例一样,我们手动实现了网络的前向和反向传播,使用 PyTorch 张量上的操作:
```python
import torch
device = torch.device('cpu')
# device = torch.device('cuda') # 取消注释以在 GPU 上运行
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机输入和输出数据
x = torch.randn(N, D_in, device=device)
y = torch.randn(N, D_out, device=device)
# 随机初始化权重
w1 = torch.randn(D_in, H, device=device)
w2 = torch.randn(H, D_out, device=device)
learning_rate = 1e-6
for t in range(500):
# 前向传播:计算预测的 y
h = x.mm(w1)
h_relu = h.clamp(min=0)
y_pred = h_relu.mm(w2)
# 计算并打印损失;损失是一个标量,存储在一个形状为 () 的 PyTorch 张量中;
# 我们可以使用 loss.item() 获取它的值作为 Python 数字。
loss = (y_pred - y).pow(2).sum()
print(t, loss.item())
# 反向传播:计算 w1 和 w2 相对于损失的梯度
grad_y_pred = 2.0 * (y_pred - y)
grad_w2 = h_relu.t().mm(grad_y_pred)
grad_h_relu = grad_y_pred.mm(w2.t())
grad_h = grad_h_relu.clone()
grad_h[h < 0] = 0
grad_w1 = x.t().mm(grad_h)
# 使用梯度下降更新权重
w1 -= learning_rate * grad_w1
w2 -= learning_rate * grad_w2
```
## PyTorch:自动求导(Autograd)
在上面的示例中,我们需要手动实现神经网络的前向和反向传播。对于一个简单的两层网络,手动实现反向传播并不困难,但对于大型复杂的网络来说,这会变得非常繁琐。
幸运的是,我们可以使用 [自动微分](https://en.wikipedia.org/wiki/Automatic_differentiation) 来自动化神经网络中反向传播的计算。PyTorch 中的 **autograd** 包正是提供了这个功能。在使用 autograd 时,网络的前向传播将定义一个 **计算图**;图中的节点将是张量,边将是从输入张量生成输出张量的函数。然后,通过反向传播这个计算图,我们可以轻松地计算梯度。
这听起来很复杂,但实际上使用起来非常简单。如果我们想要计算某个张量的梯度,我们只需要在构造该张量时设置 `requires_grad=True`。对该张量的任何 PyTorch 操作都会导致构建一个计算图,这样我们就可以在之后通过该图进行反向传播。如果 `x` 是一个具有 `requires_grad=True` 的张量,那么在反向传播之后,`x.grad` 将是一个张量,保存着 `x` 相对于某个标量值的梯度。
有时,你可能希望在对 `requires_grad=True` 的张量进行某些操作时,防止 PyTorch 构建计算图;例如,我们通常不希望在训练神经网络时反向传播经过权重更新步骤。在这种情况下,我们可以使用 `torch.no_grad()` 上下文管理器来防止构建计算图。
在这里,我们使用 PyTorch 张量和 autograd 来实现我们的两层网络;现在我们不再需要手动实现网络的反向传播:
```python
import torch
device = torch.device('cpu')
# device = torch.device('cuda') # 取消注释以在 GPU 上运行
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机张量用于存放输入和输出
x = torch.randn(N, D_in, device=device)
y = torch.randn(N, D_out, device=device)
# 创建随机张量用于权重;设置 requires_grad=True 表示我们希望在反向传播时计算这些张量的梯度。
w1 = torch.randn(D_in, H, device=device, requires_grad=True)
w2 = torch.randn(H, D_out, device=device, requires_grad=True)
learning_rate = 1e-6
for t in range(500):
# 前向传播:使用张量操作计算预测的 y。由于 w1 和 w2 的 requires_grad=True,
# 涉及这些张量的操作将导致 PyTorch 构建计算图,从而自动计算梯度。
# 由于我们不再手动实现反向传播,所以不需要保留中间值的引用。
y_pred = x.mm(w1).clamp(min=0).mm(w2)
# 计算并打印损失。损失是一个形状为 () 的张量,loss.item()
# 是一个 Python 数字,表示它的值。
loss = (y_pred - y).pow(2).sum()
print(t, loss.item())
# 使用 autograd 计算反向传播。此调用将计算损失相对于所有
# `requires_grad=True` 的张量的梯度。调用后,w1.grad 和 w2.grad
# 将分别是保存损失相对于 w1 和 w2 的梯度的张量。
loss.backward()
# 使用梯度下降更新权重。对于这一步,我们只是希望就地修改
# w1 和 w2 的值;我们不希望为更新步骤构建计算图,因此
# 使用 torch.no_grad() 上下文管理器来防止 PyTorch 为更新步骤
# 构建计算图
with torch.no_grad():
w1 -= learning_rate * w1.grad
w2 -= learning_rate * w2.grad
# 在运行反向传播后手动将梯度归零
w1.grad.zero_()
w2.grad.zero_()
```
## PyTorch:nn
计算图和自动求导是定义复杂操作符并自动求导的非常强大的范式;然而,对于大型神经网络来说,原始的自动求导可能有点过于底层。
在构建神经网络时,我们通常会把计算过程分成**层**,其中一些层包含**可学习的参数**,这些参数将在学习过程中进行优化。
在 PyTorch 中,`nn` 包定义了一组**模块(Modules)**,这些模块大致相当于神经网络中的层。一个模块接收输入张量并计算输出张量,但它也可以保存内部状态,例如包含可学习参数的张量。`nn` 包还定义了一些在训练神经网络时常用的损失函数。
在这个示例中,我们使用 `nn` 包来实现我们的两层网络:
```python
import torch
device = torch.device('cpu')
# device = torch.device('cuda') # 取消注释以在 GPU 上运行
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机张量用于存放输入和输出
x = torch.randn(N, D_in, device=device)
y = torch.randn(N, D_out, device=device)
# 使用 nn 包将模型定义为一系列层。nn.Sequential 是一个模块,
# 它包含其他模块,并按顺序应用这些模块来生成输出。每个 Linear 模块
# 使用线性函数从输入计算输出,并保存内部张量用于其权重和偏置。
# 构造模型后,我们使用 .to() 方法将模型移到指定设备。
model = torch.nn.Sequential(
torch.nn.Linear(D_in, H),
torch.nn.ReLU(),
torch.nn.Linear(H, D_out),
).to(device)
# nn 包还包含了常用损失函数的定义;在这个示例中,我们将使用均方误差(MSE)作为损失函数。
# 设置 reduction='sum' 意味着我们计算的是平方误差的*总和*,而不是平均值;
# 这样做是为了与上面的示例一致,我们手动计算损失,但实际上更常见的做法是
# 使用平均平方误差作为损失,将 reduction 设置为 'elementwise_mean'。
loss_fn = torch.nn.MSELoss(reduction='sum')
learning_rate = 1e-4
for t in range(500):
# 前向传播:通过将 x 传递给模型来计算预测的 y。模块对象重载了 __call__ 运算符,
# 所以你可以像调用函数一样调用它们。调用时,你将输入数据的张量传递给模块,
# 它会输出一个包含数据的张量。
y_pred = model(x)
# 计算并打印损失。我们传递包含预测值和真实值的张量,损失函数返回一个包含损失的张量。
loss = loss_fn(y_pred, y)
print(t, loss.item())
# 在运行反向传播之前,将梯度归零。
model.zero_grad()
# 反向传播:计算损失相对于模型中所有可学习参数的梯度。
# 在内部,每个模块的参数都存储在具有 requires_grad=True 的张量中,
# 因此此调用将为模型中所有可学习参数计算梯度。
loss.backward()
# 使用梯度下降更新权重。每个参数都是张量,因此我们可以像以前一样访问它的
# 数据和梯度。
with torch.no_grad():
for param in model.parameters():
param.data -= learning_rate * param.grad
```
## PyTorch:优化器(optim)
到目前为止,我们通过手动修改包含可学习参数的张量来更新模型的权重。对于像随机梯度下降这样的简单优化算法来说,这并不是很大的负担,但实际上我们通常使用更复杂的优化器,如 AdaGrad、RMSProp、Adam 等来训练神经网络。
PyTorch 中的 `optim` 包抽象了优化算法的概念,并提供了常用优化算法的实现。
在这个示例中,我们将像之前一样使用 `nn` 包来定义我们的模型,但我们将使用 `optim` 包提供的 Adam 算法来优化模型:
```python
import torch
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机张量用于存放输入和输出
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
# 使用 nn 包定义模型和损失函数。
model = torch.nn.Sequential(
torch.nn.Linear(D_in, H),
torch.nn.ReLU(),
torch.nn.Linear(H, D_out),
)
loss_fn = torch.nn.MSELoss(reduction='sum')
# 使用 optim 包定义一个优化器,它将更新模型的权重。
# 这里我们使用 Adam;optim 包包含许多其他优化算法。
# Adam 构造函数的第一个参数告诉优化器应该更新哪些张量。
learning_rate = 1e-4
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
for t in range(500):
# 前向传播:通过将 x 传递给模型来计算预测的 y。
y_pred = model(x)
# 计算并打印损失。
loss = loss_fn(y_pred, y)
print(t, loss.item())
# 在反向传播之前,使用优化器对象将所有梯度归零,
# 这些梯度将用于更新模型的可学习权重。
optimizer.zero_grad()
# 反向传播:计算损失相对于模型参数的梯度。
loss.backward()
# 调用优化器的 step 函数会更新其参数。
optimizer.step()
```
## PyTorch:自定义 nn 模块
有时你可能需要定义比现有模块序列更复杂的模型;对于这些情况,你可以通过继承 `nn.Module` 并定义一个 `forward` 函数来接收输入张量,并使用其他模块或张量上的其他自动求导操作来生成输出张量,从而定义你自己的模块。
在这个示例中,我们将我们的两层网络实现为一个自定义的 `Module` 子类:
```python
import torch
class TwoLayerNet(torch.nn.Module):
def __init__(self, D_in, H, D_out):
"""
在构造函数中,我们实例化两个 nn.Linear 模块并将它们作为成员变量。
"""
super(TwoLayerNet, self).__init__()
self.linear1 = torch.nn.Linear(D_in, H)
self.linear2 = torch.nn.Linear(H, D_out)
def forward(self, x):
"""
在 forward 函数中,我们接受一个输入数据的张量,并且必须返回一个输出数据的张量。
我们可以使用构造函数中定义的模块,以及对张量进行任意(可微分的)操作。
"""
h_relu = self.linear1(x).clamp(min=0)
y_pred = self.linear2(h_relu)
return y_pred
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机张量用于存放输入和输出
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
# 通过实例化上面定义的类来构造模型。
model = TwoLayerNet(D_in, H, D_out)
# 构造损失函数和优化器。在 SGD 构造函数中调用 model.parameters() 将包含模型中两个 nn.Linear 模块的可学习参数。
loss_fn = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)
for t in range(500):
# 前向传播:通过将 x 传递给模型计算预测的 y
y_pred = model(x)
# 计算并打印损失
loss = loss_fn(y_pred, y)
print(t, loss.item())
# 梯度归零,执行反向传播并更新权重。
optimizer.zero_grad()
loss.backward()
optimizer.step()
```
## PyTorch:控制流 + 权重共享
对于这个模型,我们可以使用正常的 Python 控制流来实现循环,并且可以通过在定义前向传播时多次重用相同的模块来实现最内层层之间的权重共享。
我们可以轻松地将这个模型实现为一个 Module 子类:
```python
import random
import torch
class DynamicNet(torch.nn.Module):
def __init__(self, D_in, H, D_out):
"""
在构造函数中,我们构造了三个 nn.Linear 实例,它们将在前向传播中使用。
"""
super(DynamicNet, self).__init__()
self.input_linear = torch.nn.Linear(D_in, H)
self.middle_linear = torch.nn.Linear(H, H)
self.output_linear = torch.nn.Linear(H, D_out)
def forward(self, x):
"""
对于模型的前向传播,我们随机选择 0、1、2 或 3,并且重用 middle_linear 模块
那么多次来计算隐藏层的表示。
由于每次前向传播都会构建一个动态的计算图,因此我们可以在定义模型的前向传播时使用
正常的 Python 控制流操作符,如循环或条件语句。
在这里,我们还看到,在定义计算图时,重用同一个模块多次是完全安全的。
"""
h_relu = self.input_linear(x).clamp(min=0)
for _ in range(random.randint(0, 3)):
h_relu = self.middle_linear(h_relu).clamp(min=0)
y_pred = self.output_linear(h_relu)
return y_pred
# N 是批次大小;D_in 是输入维度;
# H 是隐藏层维度;D_out 是输出维度。
N, D_in, H, D_out = 64, 1000, 100, 10
# 创建随机张量用于存放输入和输出。
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
# 通过实例化上面定义的类来构造模型
model = DynamicNet(D_in, H, D_out)
# 构造损失函数和优化器。由于训练这个奇特的模型使用普通的随机梯度下降会非常困难,
# 所以我们使用动量优化器
criterion = torch.nn.MSELoss(reduction='sum')
optimizer = torch.optim.SGD(model.parameters(), lr=1e-4, momentum=0.9)
for t in range(500):
# 前向传播:通过将 x 传递给模型来计算预测的 y
y_pred = model(x)
# 计算并打印损失
loss = criterion(y_pred, y)
print(t, loss.item())
# 梯度归零,执行反向传播并更新权重。
optimizer.zero_grad()
loss.backward()
optimizer.step()
```