在这一小节中,我们将从零开始实现整个线性回归网络模型, 包括数据流水线、模型、损失函数和小批量随机梯度下降优化器。 虽然现代的深度学习框架几乎可以自动化地进行所有这些工作,但从零开始实现可以确保你真正知道自己在做什么。 同时,了解更细致的工作原理将方便我们自定义模型、自定义层或自定义损失函数。 在这一节中,我们将只使用张量和自动求导。 在之后的章节中,我们会充分利用深度学习框架的优势,介绍更简洁的实现方式。
import torch #引入torch库
import random #引入random随机库
为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。 我们将使用低维数据,这样可以很容易地将其可视化。 在下面的代码中,我们生成一个包含1000个样本的数据集, 每个样本包含从标准正态分布中采样的2个特征。 我们的合成数据集是一个矩阵 X ∈ R 1000 × 2 X \in R^{1000\times2} X∈R1000×2 。
我们使用线性模型参数 w = [ 2 , − 3 , 4 ] T , b = 4.2 w = [2, -3, 4]^{T}, b = 4.2 w=[2,−3,4]T,b=4.2 和噪声项 ϵ \epsilon ϵ 生成数据集及其标签
y = X w + b + ϵ y = Xw + b + \epsilon y=Xw+b+ϵ
你可以将 ϵ \epsilon ϵ 视为模型预测和标签时的潜在观测误差。在这里我们认为标准假设成立,即 ϵ \epsilon ϵ 服从均值为0的正态分布。 为了简化问题,我们将标准差设为0.01。 下面的代码生成合成数据集。
#生成符合正态分布的数据集
def synthetic_data(w, b, num_examples):
#生成 y = Xw + b + 噪声
X = torch.normal(0, 1, (num_examples, len(w))) #生成均值为0, 标准差为1的正态分布,即为标准正态分布
y = torch.matmul(X, w) + b #matmul即矩阵乘法,即X与偏置w进行矩阵乘积后,再加上偏置量b
y += torch.normal(0, 0.01, y.shape) #y的值再加上一个均值为0,标准差为0.01的正态分布
return X, y.reshape(-1, 1) #返回数据集,其中X的形状为(num_examples,len(w)), y的形状为(num_examples)
true_w = torch.tensor([2, -3.4]) #定义权重,也即向量w为一维数据
true_b = 4.2 #定义偏置量b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000) #生成数据集,其中w向量为权重,b为偏置量
features.shape, labels.shape #输出样本和标签的形状
(torch.Size([1000, 2]), torch.Size([1000, 1]))
注意,features中的每一行都包含一个二维数据样本, labels中的每一行都包含一维标签值(一个标量)。
print('features:', features[0], '\nlabels:', labels[0])
features: tensor([1.2393, 1.5488])
labels: tensor([1.4208])
通过生成第二个特征features[:, 1]和labels的散点图, 可以直观观察到两者之间的线性关系。
import matplotlib.pyplot as plt
##生成第二个特征features[:, 1]和labels的散点图
plt.scatter(features[:,(1)].detach().numpy(), labels.detach().numpy(), 1)
回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。 由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据。
在下面的代码中,我们定义一个data_iter函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量。 每个小批量包含一组特征和标签。
import random
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( #每次获取batch_size个索引
indices[i: min(i+batch_size, num_examples)]
)
yield features[batch_indices], labels[batch_indices] #产生batch_size个数据集与对应的标签
通常,我们利用GPU并行运算的优势,处理合理大小的“小批量”。 每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算。 GPU可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多。
我们直观感受一下小批量运算:读取第一个小批量数据样本并打印。 每个批量的特征维度显示批量大小和输入特征数。 同样的,批量的标签形状与batch_size相等。
batch_size = 10
#迭代器,每次返回10个数据集及10个对应的标签
for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y) #输出一个小批量的数据集,此数据集的大小为 10
break
tensor([[-0.5891, 1.6614],
[-0.3371, -1.5203],
[ 0.5990, -0.0308],
[-0.0863, 1.3714],
[-1.6459, 1.2546],
[-0.5164, -1.0317],
[ 0.4163, 0.3304],
[ 1.3406, 1.4694],
[ 0.2425, 0.5404],
[-1.1285, 0.0089]])
tensor([[-2.6142],
[ 8.6999],
[ 5.5168],
[-0.6438],
[-3.3551],
[ 6.6700],
[ 3.9009],
[ 1.8827],
[ 2.8339],
[ 1.9212]])
当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。上面实现的迭代对于教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。
在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。
在我们开始用小批量随机梯度下降优化我们的模型参数之前, 我们需要先有一些参数。 在下面的代码中,我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重, 并将偏置初始化为0。
w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True) #定义模型参数 W, 共2个分量,符合正态分布,可求梯度
b = torch.zeros(1, requires_grad=True) #定义模型参数 b,初始化为0,可求梯度
在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。 有了这个梯度,我们就可以向减小损失的方向更新每个参数。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。使用自动微分来计算梯度。
接下来,我们必须定义模型,将模型的输入和参数同模型的输出关联起来。
回想一下,要计算线性模型的输出, 我们只需计算输入特征 X X X 和模型权重 w w w 的矩阵-向量乘法后加上偏置。 注意,上面 X w Xw Xw 的是一个向量,而 b b b 是一个标量。 回想一下之前描述的广播机制: 当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。
def lineRegression(X, w, b):
#计算预测的值 y
return torch.matmul(X, w) + b #注意矩阵X与向量w的结果为一个向量,b为一个标量
因为需要计算损失函数的梯度,所以我们应该先定义损失函数。
这里我们使用 平方损失函数 。 在实现中,我们需要将真实值y的形状转换为和预测值y_hat的形状相同。
#定义平方损失函数
def squared_loss(y_hat, y):
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2
正如我们之前讨论的,线性回归有解析解。 尽管线性回归有解析解,但本书中的其他模型却没有。 这里我们介绍小批量随机梯度下降。
在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。接下来,朝着减少损失的方向更新我们的参数。 下面的函数实现小批量随机梯度下降更新。 该函数接受模型参数集合、学习速率和批量大小作为输入。每一步更新的大小由学习速率lr决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size) 来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。
#定义模型优化算法
#其中params为待更新的参数,lr为学习率,batch_size为小批量样本大小
def sgd(params, lr, batch_size):
#torch.no_grad() 是一个上下文管理器,被该语句 wrap 起来的部分将不会track 梯度
with torch.no_grad():
#使用优化方法梯度下降算法分别更新w和b参数
for param in params:
param -= lr*(param.grad/batch_size) #使参数朝向梯度的反方向移动,试图最小化损失函数
param.grad.zero_() #清空梯度,以免梯度进行累加
现在我们已经准备好了模型训练所有需要的要素,可以实现主要的训练过程部分了。
理解这段代码至关重要,因为从事深度学习后, 你会一遍又一遍地看到几乎相同的训练过程。
1、 在每次迭代中,我们读取一小批量训练样本,并通过我们的模型来获得一组预测。
2、 计算完损失后,我们开始反向传播,存储每个参数的梯度。 最后,我们调用优化算法sgd来更新模型参数。
概括一下,我们将执行以下循环:
在每个迭代周期(epoch)中,我们使用data_iter函数遍历整个数据集, 并将训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。 这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设为10和0.01。
lr = 0.01 #定义学习率为 0.01
num_epochs = 10 #定义迭代次数10
net = lineRegression #定义网络模型为线性网络模型
loss = squared_loss #定义损失函数为平方损失函数
#注意,我们的目标是使 平方损失函数取得最小值(广泛的讲,即最优化的结果)
for epoch in range(num_epochs): #迭代训练10次
for X, y in data_iter(batch_size, features, labels): #多次遍历训练小批量数据集
y_hat = net(X, w, b) #调用线性网络模型获取小批量样本的预测值
l = loss(y_hat, y) #调用平方损失函数计算损失值
l.sum().backward() #调用反向传播函数,计算出w和b关于损失函数的梯度
sgd([w,b], lr, batch_size) #执行模型优化函数,使用梯度grad更新参数w和b
with torch.no_grad():
train_l = loss(net(features,w,b), labels) #优化之后的损失
print(f'epoch{epoch+1}, loss{float(train_l.mean()):f}') #输出优化过后的损失值
epoch1, loss2.335004
epoch2, loss0.353009
epoch3, loss0.053931
epoch4, loss0.008335
epoch5, loss0.001331
epoch6, loss0.000249
epoch7, loss0.000082
epoch8, loss0.000055
epoch9, loss0.000051
epoch10, loss0.000051
因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。
因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。 事实上,真实参数和通过训练学到的参数确实非常接近。
print(f'w的估计误差{true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差{true_b - b}')
w的估计误差tensor([ 0.0004, -0.0006], grad_fn=)
b的估计误差tensor([8.9645e-05], grad_fn=)
可见,我们训练模型得到的参数w和b和真实的w和b十分相近。
注意,我们不应该想当然地认为我们能够完美地求解参数。在机器学习中,我们通常不太关心恢复真正的参数,而更关心如何高度准确预测参数。
幸运的是,即使是在复杂的优化问题上,随机梯度下降通常也能找到非常好的解。
我们学习了深度网络是如何实现和优化的。在这一过程中只使用张量和自动微分,不需要定义层或复杂的优化器。