跳到主要内容

简单的训练个线性回归模型

前言

虽然现代的深度学习框架几乎可以自动化地进行所有这些工作,但从零开始实现可以确保我们真正知道自己在做什么。 同时,了解更细致的工作原理将方便我们自定义模型、自定义层或自定义损失函数。

下面以一个简单的小批量随机梯度下降(Mini-batch Stochastic Gradient Descent,简称 Mini-batch SGD)的例子来说明这个过程

假设我们有一个损失函数 L(w,b)\mathcal{L}(\mathbf{w}, b),其中 w\mathbf{w} 表示模型的权重参数,bb 表示偏置项,L\mathcal{L} 是关于训练数据的损失函数。训练数据包括特征矩阵 X\mathbf{X} 和对应的标签向量 y\mathbf{y}

  1. 随机选择小批量: 在每个训练迭代中,随机选择一个大小为 kk(通常是一个较小的正整数,如32或64)的小批量样本,其中包含了 X\mathbf{X}y\mathbf{y} 中的 kk 个样本点。我们用 B\mathcal{B} 来表示这个小批量。

  2. 前向传播: 使用当前的模型参数 w\mathbf{w}bb,以及小批量样本 B\mathcal{B},计算损失函数 L\mathcal{L} 的值:

    L(w,b,B)=1kiBL(w,b;xi,yi)\mathcal{L}(\mathbf{w}, b, \mathcal{B}) = \frac{1}{k} \sum_{i \in \mathcal{B}} \mathcal{L}(\mathbf{w}, b; \mathbf{x}_i, y_i)

    其中,L(w,b;xi,yi)\mathcal{L}(\mathbf{w}, b; \mathbf{x}_i, y_i) 表示对单个样本 (xi,yi)(\mathbf{x}_i, y_i) 计算的损失值。

  3. 计算梯度: 计算损失函数关于模型参数的梯度。这是通过对损失函数 L(w,b,B)\mathcal{L}(\mathbf{w}, b, \mathcal{B}) 分别对 w\mathbf{w}bb 求偏导数来实现的:

    wL(w,b,B)bL(w,b,B)\nabla_{\mathbf{w}} \mathcal{L}(\mathbf{w}, b, \mathcal{B}) \quad \text{和} \quad \nabla_{b} \mathcal{L}(\mathbf{w}, b, \mathcal{B})
  4. 参数更新: 使用计算得到的梯度来更新模型参数 w\mathbf{w}bb。这是通过以下更新规则来实现的:

    wwηwL(w,b,B)bbηbL(w,b,B)\mathbf{w} \leftarrow \mathbf{w} - \eta \nabla_{\mathbf{w}} \mathcal{L}(\mathbf{w}, b, \mathcal{B}) \quad \text{和} \quad b \leftarrow b - \eta \nabla_{b} \mathcal{L}(\mathbf{w}, b, \mathcal{B})

    其中,η\eta 是学习率,它决定了每次参数更新的步长大小。

  5. 重复迭代: 重复上述步骤,不断随机选择小批量、计算梯度和更新参数,直到满足停止条件,例如达到最大迭代次数或损失函数收敛到某个阈值。

小批量随机梯度下降允许模型在训练过程中逐渐学习并优化参数,同时降低了计算成本。这使得它成为深度学习中常用的优化算法之一。

import random
import torch
from d2l import torch as d2l

生成数据集

为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。

下面是对代码的数学解释:

  1. 首先,我们有一个真实的权重向量 w\mathbf{w} 和一个真实的偏置项 bb,它们定义了线性回归模型的真实关系:

    y=Xw+b\mathbf{y} = \mathbf{X} \mathbf{w} + b

    其中:

    • y\mathbf{y} 是一个包含标签的列向量,表示真实标签。
    • X\mathbf{X} 是一个包含特征的矩阵,每一行对应一个样本,每一列对应一个特征。
    • w\mathbf{w} 是权重向量,包含了线性模型的权重参数。
    • bb 是偏置项,表示当特征都为0时的标签。
  2. 这个 synthetic_data 函数生成模拟数据集:

    • X 是一个包含特征的矩阵,其元素是从均值为0、标准差为1的正态分布中随机生成的。这些特征值是模拟的特征。
    • y 是通过将特征矩阵 X 与真实权重向量 w\mathbf{w} 相乘并添加真实偏置项 bb 得到的,以及一个小的高斯噪声项(均值为0、标准差为0.01),这是为了模拟真实数据中的随机性。

所以,这段代码的目的是生成一个包含特征矩阵 X 和对应标签向量 y 的合成数据集,以便模拟一个线性回归问题。生成的数据集将包含一些真实的线性关系,并添加了一些随机噪声,以使数据更接近真实情况。这个数据集可以用于训练和测试线性回归模型的性能。

def synthetic_data(w, b, num_examples):  #@save
"""生成 y=Xw+b+噪声"""
X = torch.normal(0, 1, (num_examples, len(w)))
y = torch.matmul(X, w) + b
y += torch.normal(0, 0.01, y.shape)
# 参数 (-1, 1) 表示要将张量 y 重新排列成一个列向量,其中列数为 1。
# -1 的含义是根据其他维度的大小来自动确定该维度的大小,以保持总元素数量不变。
return X, y.reshape((-1, 1))

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)
print('features:', features[0],'\nlabel:', labels[0])
features: tensor([-1.1426,  1.5449]) 
label: tensor([-3.3552])

注意,features 中的每一行都包含一个二维数据样本, labels 中的每一行都包含一维标签值(一个标量)。

通过生成第二个特征 features[:, 1] 和 labels 的散点图, 可以直观观察到两者之间的线性关系。

提示

features[:, 1] 表示对二维张量(或矩阵) features 进行切片操作,以获取其中所有行的第二列。这类切片操作常用于从多维张量中选择特定的列或行。

具体来说,如果 features 是一个形状为 (num_samples, num_features) 的二维张量,那么 features[:, 1] 将返回一个包含 features 所有行中第二列元素的一维张量或一维数组。

例如,如果 features 是以下形状的矩阵:

[[x11, x12, x13],
[x21, x22, x23],
[x31, x32, x33]]

那么 features[:, 1] 操作将返回一个包含 [x12, x22, x32] 这些元素的一维张量。

这种切片操作通常用于选择特定特征列或属性列,以便用于建模或其他数据处理任务。在机器学习中,通常将这些特征作为输入传递给模型。

d2l.set_figsize()
d2l.plt.scatter(features[:, (1)].detach().numpy(), labels.detach().numpy(), 1);

svg

这行代码是用于绘制散点图(Scatter Plot),用于可视化特征(features)和标签(labels)之间的关系。让我一步一步解释它:

  1. d2l.plt.scatter 是一个用于绘制散点图的函数调用,它来自于某个自定义的库(可能是 "d2l",但具体的库名称在你的代码中可能有所不同)。这个函数用于创建散点图,其中的参数包括:

    • features[:, (1)].detach().numpy():这是要在散点图上显示的特征值。features 是一个张量,[:, (1)] 表示选择所有行(样本)的第二列(特征)。.detach() 是用于将张量从计算图中分离的操作,.numpy() 将张量转换为NumPy数组。
    • labels.detach().numpy():这是要在散点图上显示的标签值。与特征一样,它将标签从张量转换为NumPy数组。
    • 1:这是指定散点的大小(以点为单位)。在这里,它指定散点的大小为1。
  2. 整行代码的目标是绘制一个散点图,其中 x 轴表示特征的值(features[:, (1)]),y 轴表示标签的值(labels),并且散点的大小为1。

这种可视化方法有助于观察特征与标签之间的关系,特别是在回归问题中,你可以看到数据点在特征和标签空间中的分布情况,以及是否存在一种线性或非线性的关系。

读取数据集

回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。 由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据。

在下面的代码中,我们定义一个 data_iter 函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为 batch_size 的小批量。 每个小批量包含一组特征和标签。

def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))
# 这些样本是随机读取的,没有特定的顺序
random.shuffle(indices)
for i in range(0, num_examples, batch_size):
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])
yield features[batch_indices], labels[batch_indices]

这里的 range(0, num_examples, batch_size) 是一个用于生成迭代器的 Python 内置函数 range 的调用,其目的是迭代遍历一个范围内的整数序列。让我来解释这个特定的用法:

  • 0:这是序列的起始值,即从 0 开始。
  • num_examples:这是序列的结束值,但不包括在内。num_examples 是数据集中的样本总数,所以这个范围的结束点是 num_examples
  • batch_size:这是步长或增量值,决定了迭代器每次迭代移动多少步。在这个上下文中,它表示每个批次(batch)的大小。

这个迭代器的目的是将数据集中的样本分成批次,每个批次包含 batch_size 个样本。它在每次迭代时生成一个批次的样本。

例如,假设 num_examples 等于 1000,batch_size 等于 64。那么 range(0, num_examples, batch_size) 会生成以下整数序列:

0, 64, 128, 192, 256, 320, 384, 448, 512, 576, 640, 704, 768, 832, 896

这些整数代表每个批次的起始索引,迭代器将按照这些索引将数据集分成多个批次。在每次迭代中,迭代器会返回一个包含 batch_size 个样本的批次(最后一个批次可能不足 batch_size)。这个方式可以有效地遍历整个数据集并分批次加载数据,用于训练机器学习模型。

通常,我们利用 GPU 并行运算的优势,处理合理大小的“小批量”。 每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算。 GPU 可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多。

我们直观感受一下小批量运算:读取第一个小批量数据样本并打印。 每个批量的特征维度显示批量大小和输入特征数。 同样的,批量的标签形状与 batch_size 相等。

batch_size = 10

for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y)
break
tensor([[ 0.3808,  0.4956],
[ 0.8543, 0.3773],
[ 0.7449, 0.6441],
[-0.4987, 0.8931],
[ 0.6910, -0.1177],
[ 0.1778, -0.0566],
[ 0.0323, -0.2857],
[-1.1351, -0.7352],
[-0.2917, -0.4744],
[-2.1795, -0.3229]])
tensor([[3.2759],
[4.6151],
[3.5123],
[0.1728],
[5.9827],
[4.7529],
[5.2323],
[4.4315],
[5.2331],
[0.9380]])

当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。上面实现的迭代对教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。 在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。

初始化模型参数

在我们开始用小批量随机梯度下降优化我们的模型参数之前, 我们需要先有一些参数。 在下面的代码中,我们通过从均值为 0、标准差为 0.01 的正态分布中采样随机数来初始化权重, 并将偏置初始化为 0。

w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。 有了这个梯度,我们就可以向减小损失的方向更新每个参数。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。可以直接使用自动微分来计算梯度。

定义模型

接下来,我们必须定义模型,将模型的输入和参数同模型的输出关联起来。 回想一下,要计算线性模型的输出, 我们只需计算输入特征 X\mathbf{X} 和模型权重 w\mathbf{w} 的矩阵-向量乘法后加上偏置 bb。 注意,上面的 Xw\mathbf{X}\mathbf{w} 是一个向量,而 bb 是一个标量。

计算的广播机制: 当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。

def linreg(X, w, b):  #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

定义损失函数

因为需要计算损失函数的梯度,所以我们应该先定义损失函数。这里我们使用平方损失函数。在实现中,我们需要将真实值 y 的形状转换为和预测值 y_hat 的形状相同。

def squared_loss(y_hat, y):  #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

定义优化算法

在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。

接下来,朝着减少损失的方向更新我们的参数。下面的函数实现小批量随机梯度下降更新。 该函数接受模型参数集合、学习速率和批量大小作为输入。每一步更新的大小由学习速率 lr 决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size) 来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。

def sgd(params, lr, batch_size):  #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()

正式开始训练

现在我们已经准备好了模型训练所有需要的要素,可以实现主要的训练过程部分了。 理解这段代码至关重要,因为从事深度学习后, 相同的训练过程几乎一遍又一遍地出现。 在每次迭代中,我们读取一小批量训练样本,并通过我们的模型来获得一组预测。 计算完损失后,我们开始反向传播,存储每个参数的梯度。 最后,我们调用优化算法 sgd 来更新模型参数。

概括一下,我们将执行以下循环:

  1. 初始化模型参数: 首先,我们初始化模型的参数,包括权重 w\mathbf{w} 和偏置 bb。通常,这些参数会初始化为随机值或者零值。

    w(0)随机初始化,b(0)随机初始化\mathbf{w}^{(0)} \leftarrow \text{随机初始化}, \quad b^{(0)} \leftarrow \text{随机初始化}
  2. 定义损失函数: 选择适当的损失函数来度量模型的性能。损失函数通常表示为关于模型参数的函数。

    L(w,b)=损失函数\mathcal{L}(\mathbf{w}, b) = \text{损失函数}
  3. 选择优化算法: 选择合适的优化算法,例如梯度下降(Gradient Descent),以更新模型参数以最小化损失函数。优化算法使用损失函数的梯度来指导参数的更新。

  4. 循环迭代: 迭代训练模型,重复以下步骤直到满足停止条件(例如达到最大迭代次数或损失函数收敛到某个阈值):

    a. 前向传播: 使用当前模型参数进行前向传播计算预测值 y^\hat{y}

    y^=Xw+b\hat{y} = \mathbf{X} \mathbf{w} + b

    b. 计算损失: 使用损失函数计算模型的预测值与真实标签之间的差距。

    L(w,b)=损失函数(X,y,w,b)\mathcal{L}(\mathbf{w}, b) = \text{损失函数}(\mathbf{X}, \mathbf{y}, \mathbf{w}, b)

    c. 计算梯度: 计算损失函数关于模型参数的梯度,用于指导参数的更新。

    w,b=梯度(L(w,b))\nabla_{\mathbf{w}}, \nabla_{b} = \text{梯度}(\mathcal{L}(\mathbf{w}, b))

    d. 参数更新: 使用优化算法根据梯度更新模型参数。

    wwηw,bbηb\mathbf{w} \leftarrow \mathbf{w} - \eta \nabla_{\mathbf{w}}, \quad b \leftarrow b - \eta \nabla_{b}

    其中,η\eta 是学习率,控制每次参数更新的步长。

  5. 模型评估: 定期评估模型在验证集上的性能,以监控模型的泛化能力。

  6. 停止条件: 根据评估结果或达到预定的迭代次数,决定是否终止训练。

  7. 使用模型: 在训练结束后,可以使用训练好的模型进行预测或推断。

这个循环将不断迭代,直到模型收敛到一个满意的状态或满足了停止条件。训练模型的目标是最小化损失函数,从而使模型能够对新的、未见过的数据进行准确的预测。这是机器学习中一种常见的训练模型的流程,它可以适用于各种不同的机器学习任务。

下面的代码中在每个迭代周期(epoch)中,我们使用 data_iter 函数遍历整个数据集, 并将训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。 这里的迭代周期个数 num_epochs 和学习率 lr 都是超参数,分别设为 3 和 0.03。 设置超参数很棘手,需要通过反复试验进行调整。 我们现在忽略这些细节

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) # X和y的小批量损失
# 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
# 并以此计算关于[w,b]的梯度
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}')

print(f'\ntrue_w: {true_w}\nw: {w}')
print(f'\ntrue_b: {true_b}\nb: {b}')
epoch 1, loss 0.031188
epoch 2, loss 0.000115
epoch 3, loss 0.000052

true_w: tensor([ 2.0000, -3.4000])
w: tensor([[ 1.9999],
[-3.3995]], requires_grad=True)

true_b: 4.2
b: tensor([4.1994], requires_grad=True)

可以看到在第三个迭代周期结束后,线性回归的损失已经降到了 0.000052 左右。 而且,学到的参数与真实参数也较为接近。

因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。 因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。 事实上,真实参数和通过训练学到的参数确实非常接近。

注意,我们不应该想当然地认为我们能够完美地求解参数。 在机器学习中,我们通常不太关心恢复真正的参数,而更关心如何高度准确预测参数。 幸运的是,即使是在复杂的优化问题上,随机梯度下降通常也能找到非常好的解。 其中一个原因是,在深度网络中存在许多参数组合能够实现高度精确的预测。