前两章,我们用梯度下降法进行了第一次模型训练。方向是对的,但一步把权重从 [0.5, 0.5] 更新到了 [6854, 14147],损失值随之爆炸,模型训练失败。
问题的根源在于:梯度只告诉我们下坡的方向,却不能决定步子应该迈多大。
梯度很大,只说明当前这个方向的坡度很陡,并不意味着应该迈一大步。步子太大,会直接越过山谷,冲到对面的坡上,反而比出发点更高。这种现象称为发散。
解决办法是引入一个比例系数,把每次更新的步幅等比例缩小,这个系数称为学习率(Learning Rate),记作 。
加入学习率后,梯度下降的更新公式变为:
学习率需要仔细选择:
太大:更新步幅过大,损失值左右震荡,甚至发散;
太小:更新步幅过小,损失值下降极慢,需要大量训练才能收敛;
合适:损失值稳定地逐步下降,最终收敛到最优解附近。
超参数¶
学习率是我们遇到的第一个超参数(Hyperparameter)。
模型参数(权重和偏置)是模型自动学习的对象:每次训练后,它们会根据梯度被更新。
超参数则不同:它们不会被模型训练更新,而是需要我们在训练开始前根据经验和实验进度设定。学习率控制的是训练步幅,后续章节还会遇到其他超参数。
不断测试、调整超参数以获得最佳训练效果的过程,称为调参(Hyperparameter Tuning)。调参分为两类,通常会结合使用:
手动调参:训练前根据测试数据和经验,手动设置超参数;
自动调参:训练过程中由程序代码根据训练进度调整超参数。
优化器¶
随着学习率的引入,实践中通常会使用优化器(Optimizer)来统一管理学习率的使用(参数更新)和自动调参。
import numpy as np张量¶
class Tensor:
def __init__(self, data):
self.data = np.array(data)
self.grad = np.zeros_like(self.data)
self.gradient_fn = None
self.parents = set()
def backward(self):
if self.gradient_fn is not None:
self.gradient_fn()
for p in self.parents:
p.backward()
def __str__(self):
return f'Tensor({self.data})'数据¶
feature = Tensor([28.1, 58.0])
label = Tensor([165])class Linear:
def __init__(self, in_size, out_size):
self.weight = Tensor(np.ones((out_size, in_size)) / in_size)
self.bias = Tensor(np.zeros(out_size))
def __call__(self, x: Tensor):
return self.forward(x)
def forward(self, x: Tensor):
p = Tensor(x.data @ self.weight.data.T + self.bias.data)
def gradient_fn():
self.weight.grad += p.grad * x.data
self.bias.grad += np.sum(p.grad)
p.gradient_fn = gradient_fn
return p
# 可训练模型参数列表
@property
def parameters(self):
return [self.weight, self.bias]损失函数(均方误差)¶
class MSELoss:
def __call__(self, p: Tensor, y: Tensor):
return self.loss(p, y)
def loss(self, p: Tensor, y: Tensor):
mse = Tensor(np.mean(np.square(y.data - p.data)))
def gradient_fn():
p.grad += -2 * (y.data - p.data)
mse.gradient_fn = gradient_fn
mse.parents = [p]
return mse优化器(随机梯度下降)¶
我们实现的第一个优化器是随机梯度下降(Stochastic Gradient Descent,SGD)优化器。它的作用非常简单,就是每次模型训练(一次前向传播和一次反向传播)后,立即根据反向传播计算出的梯度,结合学习率,来更新所有参数的数值。
创建一个优化器,我们需要知道:
parameters(参数列表):网络模型所有需要更新的参数,比如模型的权重和偏置。需要注意的是,这里只包括可训练的参数,中间值、预测值这些虽然也是张量,在反向传播中也会计算它们的梯度,但并不需要优化器来更新它们的数值;lr(学习率):参数更新的比例系数。
步进函数¶
步进函数(step)实现优化器最基本的功能:更新参数。
随机梯度下降优化器的步进函数很简单:在参数更新前,先将梯度乘以学习率来减小每次梯度下降的步幅。
class SGDOptimizer:
def __init__(self, parameters, lr):
self.parameters = parameters
self.lr = lr
# 步进函数(更新参数)
def step(self):
for p in self.parameters:
p.data -= p.grad * self.lr超参数¶
学习率¶
随机梯度下降优化器非常简单实用,但是不具备自动调参的功能。因此我们主要依靠手动调参。
学习率的手动调参是一个摸索实验的过程。通常我们会根据采用的模型选择一个初始值,比如:0.01。然后根据模型训练的结果增大(比如:0.1)或者减小(比如:0.001)学习率。
💡 提示:我们这里采用了一个非常小的学习率,就是手动调参的结果。主要原因是我们没有对训练数据进行标准化(Standardization)处理,导致梯度计算结果偏大。
LEARNING_RATE = 0.00001建模¶
我们需要创建一个优化器的实例,创建时需要传入模型参数列表和学习率超参数。
model = Linear(2, 1)
loss_fn = MSELoss()
optimizer = SGDOptimizer(model.parameters, lr=LEARNING_RATE)训练¶
模型训练中,我们不再调用模型的反向函数(已经取消),而是调用优化器的步进函数(step)来完成参数更新。
# 前向传播(构建计算图)
prediction = model(feature)
# 计算损失
loss = loss_fn(prediction, label)
# 反向传播(计算梯度)
loss.backward()
# 更新参数
optimizer.step()推理¶
prediction = model(feature)
print(f'prediction:\t{prediction}')prediction: Tensor([53.18309379])
评估¶
loss = loss_fn(prediction, label)
print(f'loss:\t{loss}')loss: Tensor(12503.020514375934)
从评估结果可以看到,损失值从 1,026,548,766,283 缩小到了 12,503。不仅回到了正常范围内,而且小于模型训练前的 14,872。这说明学习率的引入是有效的,而且我们(手动调参)选择的数值也是合理的。
课后练习¶
父节点列表(
parents)和参数列表(parameters)的设计,使得计算图的遍历、反向传播和参数更新都可以自动化完成。尝试把不同的参数从父节点列表或者参数列表中移除,观察反向传播的结果如何?分别把学习率改成
0.001、0.0001、0.000001,各训练一次,观察损失值的变化和学习率的关系。