学习步骤
学习过程便是给定输入值、期望值和初始权重,给模型输入数据(正向传播),通过初始权重与该数据进行一系列的数学预算得出输出值,然后与期望值相比较得出误差也就是损失函数,通过损失函数的链式法则计算得出误差的相对梯度,然后在使误差减少的方向上更新权重(反向传播),不断迭代该过程直到输出值与期望值基本一致
其实就我而言,有一个更容易理解的例子,PID调参,给定输入信号和期望信号,通过输入信号和期望信号之间的差别,以此来调节PID参数知道输出为期望信号,这里的PID参数就和学习过程中的误差一样,所有的模型都是为了得出最为合适的误差,而最优控制中的代价函数就相当于学习过程中的损失函数
损失函数
定义
损失函数是一个计算单个数值的函数,学习过程将试图使其的值最小化,其中最为简单的损失值便是期望值exp_value减去输出值out_value,但是这样有一个问题,这样的损失值有正有负不利于计算,因此一般需要将所有的损失值都为正数,最简单的做法便是加一个绝对值或者一个平方等等,不同的选择侧重点也不同。
但是对于平方和绝对值的比较,还有其他的因素,首先便是平方在零点的梯度为0,而绝对值在零点的梯度为1,更重要的是平方对错误的惩罚更大,通常来说,有更多的小错误的结果要远远好于有少量大错误的结果。
定义损失函数
以下是为了对线性模型进行参数估计所写的损失函数
假设有两个温度计,其中一个温度计的使用的是摄氏度,而另外一个温度计不确定其单位是什么,因此为了确定这个温度计的单位,将两个温度计在同一个地方下记录所得的温度t_c和t_u,其中t_c是使用摄氏度作为单位的温度计,t_u是另一个不知什么单位的温度计,首先假设该温度符合线性规律,因此可以构建相应的模型和损失函数
import torch def model(t_u, w, b): """线性参数模型""" return w * t_u + b def loss_fn(t_o, t_c): """损失函数""" loss = (t_o - t_c) ** 2 return loss.mean() # 实际值(期望值)和输入值 t_c = [0.5, 14.0, 15.0, 28.0, 11.0, 8.0, 3.0, -4.0, 6.0, 13.0, 21.0] t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4] t_c = torch.tensor(t_c) t_u = torch.tensor(t_u) # 参数,建立零维张量,但是其具有广播性质 w = torch.ones(()) b = torch.zeros(()) t_o = model(t_u, w, b) # 建立模型 loss = loss_fn(t_o, t_c) # 建立损失函数 print(loss) #结果:tensor(1763.8848)可以看出损失值非常大,因此w=1,b=0是一定不可以的
手动实现反向传播--沿着梯度下降的方向
手动参数更新
从上面可以看出初始的参数是不行的,因此需要调节参数,而调节参数的方法便是找到梯度下降的方向,就比如我们有w和b两个按钮,朝着某一个方向拨动这两个按钮,损失函数一定会减小,而这个方向就是梯度下降的方向
但是程序中我们没有这么只管的感受,因此就需要添加一个损失变化率,通过损失变化率构建一个损失变化率的正比例函数从而判断梯度下降的方向,而调节参数时一般希望参数的变化弧度小一些(也可以先大一些,当损失函数小于某个阈值再进行微调)
因此又引入一个新的概念,使用一个很小的比例因子来衡量变化率,这个比例因子就是机器学习中的学习率
delta = 0.1 # 建立损失变化率 learning_rate = 1e-2 # 建立学习率 # 参数更新 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) w = w - loss_rate_of_change_w * learning_rate 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 - loss_rate_of_change_w * learning_rate
计算梯度
但是我们也发现了,这样的模型是不适合大部分模型的,因为你无法确定这个损失变化率是大还是小了,虽然后面有学习率的校正,但是也无法改变损失变化率存在较大的错误,因此我们可以直接使用链式法则计算梯度
def model(t_u, w, b): """线性参数模型""" return w * t_u + b def dmodel_dw(t_u, w, b): """模型关于w的导数""" return t_u def dmodel_db(t_u, w, b): """模型关于b的导数""" return 1.0 def loss_fn(t_p, t_c): """损失函数""" loss = (t_p - t_c) ** 2 return loss.mean() def dloss_fn(t_p, t_c): """损失函数的梯度""" # 2 * (t_p - t_c)是t_p的导数,而2 * (t_p - t_c) / t_p.size(0)是这个导数的均值 d_loss = 2 * (t_p - t_c) / t_p.size(0) return d_loss 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()])
迭代以适应模型
一般来说都会存在一些是迭代停止的条件,但是这里为了方便,直接使用固定次数的迭代
循环训练
通过不断的迭代训练可以得出参数的值
import torch def model(t_u, w, b): """线性参数模型""" return w * t_u + b def dmodel_dw(t_u, w, b): """模型关于w的导数""" return t_u def dmodel_db(t_u, w, b): """模型关于b的导数""" return 1.0 def loss_fn(t_p, t_c): """损失函数""" loss = (t_p - t_c) ** 2 return loss.mean() def dloss_fn(t_p, t_c): """损失函数的梯度""" # 2 * (t_p - t_c)是t_p的导数,而2 * (t_p - t_c) / t_p.size(0)是这个导数的均值 d_loss = 2 * (t_p - t_c) / t_p.size(0) return d_loss 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()]) 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 # 实际值(期望值)和输入值 t_c = [0.5, 14.0, 15.0, 28.0, 11.0, 8.0, 3.0, -4.0, 6.0, 13.0, 21.0] t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4] t_c = torch.tensor(t_c) t_u = torch.tensor(t_u) training_loop(n_epochs = 100, learning_rate = 0.1, params = torch.tensor([1.0, 0.0]), t_u = t_u, t_c = t_c)
过度训练
我们运行上面的代码,发现其结果区域无穷大了
Epoch 1, Loss 1763.884766 Epoch 2, Loss 598445440.000000 Epoch 3, Loss 206446260125696.000000 Epoch 4, Loss 71217897007795404800.000000 Epoch 5, Loss 24568095486228264728395776.000000 Epoch 6, Loss 8475276008177205458128322166784.000000 Epoch 7, Loss 2923723288522990802135412736614465536.000000 Epoch 8, Loss inf这是因为每次更新修正过度,就会导致每次更新更加过度,从而导致了过度训练,通常我们使用更小的学习率来改正这个错误
training_loop(n_epochs = 5000, learning_rate = 0.00001, params = torch.tensor([1.0, 0.0]), t_u = t_u, t_c = t_c)将学习率改为0.00001后再运行代码,得到下面的结果
Epoch 1, Loss 1763.884766 params:tensor([ 9.5483e-01, -8.2600e-04]) grad:tensor([4517.2964, 82.6000]) Epoch 2, Loss 1565.761353 params:tensor([ 0.9123, -0.0016]) grad:tensor([4251.5220, 77.9184]) Epoch 3, Loss 1390.265503 params:tensor([ 0.8723, -0.0023]) grad:tensor([4001.3838, 73.5123]) ···· Epoch 4998, Loss 29.506208 params:tensor([2.2360, 0.0655]) grad:tensor([-4.2409, 2.2956]) Epoch 4999, Loss 29.505981 params:tensor([2.2360, 0.0655]) grad:tensor([-4.2387, 2.2960]) Epoch 5000, Loss 29.505749 params:tensor([2.2361, 0.0655]) grad:tensor([-4.2364, 2.2964])我们发现最后的值区域稳定,但是损失函数还是很大,这是因为参数接收到的更新又太过于小了,所以损失函数下降非常慢,最终停滞不前,一般我们可以通过学习率自适应控制来解决这个问题即根据更新的大小进行学习率的更改,这个问题会在下面的章节进行解读
归一化输入
除了学习率的问题,还有一个问题也需要解决,就是梯度本身,我们发现w和b的权重在最开始时相差了仅50倍,这意味着w和b存在与不同的比例空间中,在这种情况下,如果学习率足够大,能够有效的更新其中一个参数,但是对于另一个参数,学习率就会变得不稳定,也就是说一个学习率可以很好的更新w的参数,但是这个学习率并不意味着其也是更新b的最佳学习率
为了解决这个问题,有一个很容易想到的方法,那就是给每一个参数分别添加学习率,但是当这个参数又很多个的时候这个方法便不适用了,但是还有一个更简单的方法即改变输入,也就是确保输入的范围不会偏离输出的范围太远,最简单的做法就是将t_u乘以0.1,同时将学习率调大
t_un = 0.1 * t_u training_loop(n_epochs = 5000, learning_rate = 0.01, params = torch.tensor([1.0, 0.0]), t_u = t_un, t_c = t_c) Epoch 1, Loss 80.364342 params:tensor([1.7761, 0.1064]) grad:tensor([-77.6140, -10.6400]) Epoch 2, Loss 37.574913 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 4998, Loss 2.927647 params:tensor([ 5.3671, -17.3012]) grad:tensor([-0.0001, 0.0006]) Epoch 4999, Loss 2.927647 params:tensor([ 5.3671, -17.3012]) grad:tensor([-0.0001, 0.0006]) Epoch 5000, Loss 2.927648 params:tensor([ 5.3671, -17.3012]) grad:tensor([-0.0001, 0.0006])我们可以发现这个损失函数显著的降低了,虽然还有3,但是由于噪声等一系列因素的干扰下,损失函数为3是一个很不错的值,同时这个数据通过可视化来看确实不是一条直线
输入通常都需要进行归一化处理,因为这样可以很好的避免不同特征参数需要使用不同的学习率,也可以更好的加快特征尺度的收敛但是对于输出来说,则需要视情况而定,对于如上述的回归任务,以及自编码器和生成模型时,输出都需要进行归一化,但是对于分类任务,分类标签不可以归一化
自动实现反向传播
在张量的特性中有一条十分重要,也是与数组之间最大的区别,那就是张量可以自动求导,我们通过上面的式子可以看出,其实使用numpy也可以很简单的完成上述的任务,只是需要把广播的函数也自动扩展以下就行,所以这并不能特别体现pytorch的实用性,因此在通常我们都不适用上述的代码进行手动计算
自动计算梯度
在上文中也介绍了张量可以自动求导,因此在这里详细说一下如何实现自动求导
原因
张量还有一个十分重要的特性,就是它会记忆自己从何而来,也就是可以记住产生它们的操作和父类,这便意味着只需要提供一个前向表达式,张量可以根据这个表达式自动求导
函数
自动梯度计算函数
tensor.backward(gradient, retain_graph, create_graph, inputs)
gradient:可选,对张量某一个元素求导时所赋的值,如果张量为标量即其只有一个元素,则输入None即可,也可以指定一个值进行求导,否则需要给出求导元素的值,默认为None
retain_graph:可选,bool型,是否保留计算梯度的图在内存中,默认为False
create_graph:可选,bool型,是否创建计算梯度的图,默认为False
inputs:可选,当张量为多输入时,可以指定计算特定输入的梯度,默认为None
梯度值储存
tensor.grad()
定义一个关于tensor的函数,使用backward进行梯度计算后,不会在表达式中表示,而是会储存在tensor.grad这一属性中
# 情况1:标量输出(最常见) x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) y = x.sum() # y是标量,y=x1+x2+x3 y.backward() print(x.grad) # [1., 1., 1.] # 情况2:非标量输出(需要提供gradient参数) x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) y = x * 2 # y是向量 [2., 4., 6.] # 提供gradient参数(雅可比向量积) y.backward(gradient=torch.tensor([1.0, 1.0, 1.0])) print(x.grad) # [2., 2., 2.]
梯度值清零
x.grad.zero_()
当计算完梯度值后,需要即时清理,否则梯度值会积累
w = torch.tensor(1.0, requires_grad=True) for i in range(3): loss = w**2 loss.backward() print(f"第{i+1}次梯度:", w.grad) # 梯度会累积:2, 4, 6
应用自动求导
还是上面的式子,但将手动求导换为自动求导
import torch def model(t_u, w, b): return t_u * w + b def loss_fn(t_p, t_c): loss = (t_p - t_c) ** 2 return loss.mean() def training_loop(n_epochs, learning_rate, params, t_u, t_c): """训练模型""" for epoch in range(1, n_epochs + 1): t_p = model(t_u, *params) loss = loss_fn(t_p, t_c) loss.backward() with torch.no_grad(): params -= learning_rate * params.grad params.grad.zero_() print('Epoch %d, Loss %f' % (epoch, float(loss))) return params # 实际值(期望值)和输入值 t_c = [0.5, 14.0, 15.0, 28.0, 11.0, 8.0, 3.0, -4.0, 6.0, 13.0, 21.0] t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4] t_c = torch.tensor(t_c) t_u = torch.tensor(t_u) t_un = t_u * 0.01 training_loop(n_epochs = 5000, learning_rate = 0.1, params = torch.tensor([1.0, 0.0], requires_grad = True), t_u = t_un, t_c = t_c)
优化器
在之前的代码中有如下代码
with torch.no_grad(): params -= learning_rate * params.grad params.grad.zero_()它实现的便是优化器最核心的操作,也就是根据梯度更新参数,由此也可以看出优化器的核心任务就是根据梯度改变参数的值,上述代码的对于参数改变过于简约,一般优化器还包括动量(即历史梯度)、自适应学习率(在上文中的过度训练中有所提出)、正则化(用以防止模型过拟合)和状态管理(保存优化器的状态)
公共函数
pytorch中的每个优化器都包含以下函数
zero_gard()--梯度清零
step.zero_gard()--根据梯度更新所有参数
常用优化器
SGD(随机梯度下降)
optimizer = optim.SGD(
params, # 要优化的参数
lr, # 学习率
momentum, # 动量:0表示无动量
dampening, # 动量阻尼
weight_decay, # L2正则化系数
nesterov # 是否使用Nesterov动量
)
这是最基本的优化器,其核心参数如下
params: ParamsT, lr: Union[float, Tensor] = 1e-3, # 学习率需要是列表或元组 momentum: float = 0, dampening: float = 0, weight_decay: Union[float, Tensor] = 0, # 正则化系数需要是列表或元组 nesterov: bool = False
Adam
optimizer = optim.Adam( params # 要优化的参数
lr # 学习率
betas # 一阶和二阶矩估计的指数衰减率 eps: float = 1e-8, # 数值稳定性
weight_decay # L2正则化
amsgrad # 是否使用AMSGrad变体
)
这神经网络目前最为流行的优化器
optimizer = optim.Adam(params: ParamsT, lr: Union[float, Tensor] = 1e-3, betas: tuple[Union[float, Tensor], Union[float, Tensor]] = (0.9, 0.999), eps: float = 1e-8, weight_decay: float = 0, amsgrad: bool = False, )
优化器选择
优化器 适用场景 优点 缺点 SGD 简单问题、凸优化 简单、理论保证 收敛慢、需要调学习率 SGD with Momentum 图像处理大多数场景 加速收敛、减少震荡 需要调两个参数 Adam 深度学习默认选择 自适应学习率、收敛快 可能在某些任务上不如SGD RMSprop RNN、非平稳目标 适合非平稳问题 超参数敏感 Adagrad 稀疏数据 自适应学习率 学习率衰减太快
应用
在应用前我们需要谨记优化器的流程
1、梯度清零--optimizer.zero_grad()
2、反向传播--loss.backward()
3、梯度裁剪--torch.nn.utils.clip_grad_norm_()
3、更新参数--optimizer.step()
4、更新学习率--对于简单或要求不高的问题可以不使用
其中梯度裁剪会在后续的内容中介绍
而对于更新学习率,其是优化我们所用优化器的最好方法,因此选择更好的学习率除了前文提及的自适应学习率外,还有余弦退火调度等等
依然使用上述的例子
import torch import torch.optim as optim def model(t_u, w, b): return t_u * w + b def loss_fn(t_p, t_c): loss = (t_p - t_c) ** 2 return loss.mean() def training_loop(n_epochs, optimizer, params, t_u, t_c): """训练模型""" for epoch in range(1, n_epochs + 1): t_p = model(t_u, *params) loss = loss_fn(t_p, t_c) optimizer.zero_grad() loss.backward() optimizer.step() print('Epoch %d, Loss %f' % (epoch, float(loss))) return params # 实际值(期望值)和输入值 t_c = [0.5, 14.0, 15.0, 28.0, 11.0, 8.0, 3.0, -4.0, 6.0, 13.0, 21.0] t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4] t_c = torch.tensor(t_c) t_u = torch.tensor(t_u) t_un = t_u * 0.01 params = torch.tensor([1.0, 0.0], requires_grad = True) learning_rate = 1e-5 optimizer = optim.SGD([params], lr = learning_rate) training_loop(n_epochs = 5000, optimizer = optimizer, params = params, t_u = t_un, t_c = t_c)
评估模型的可靠性
这算是整个训练的最后一步,评估或者验证模型的可靠性
模型损失--训练集
模型损失针对于训练集而言,其可以看出模型是否能够完全拟合训练集或者说模型是否有能力处理数据中的相关信息,仍然是刚才的例子,模型损失就是我们输出的loss值,我们可以看出不管使用什么方法,调节迭代次数、学习率或者使用不同的优化器都无法使得这个loss值变为2或者更小的数,这是因为我们使用的线性模型是没有机会完全拟合所给的输入值的,因为所给的输入值本就是非线性的
从上面这段话也可以看出来,减小模型损失有以下方法
1、增加迭代次数
2、调节更加合适的学习率
3、使用合适的优化器或者在优化器中添加正则化
4、使用更加合适的模型进行拟合
当然对于数据层面也包括对于数据的预处理、增强以及特征值的提取等等,但是这些是所有只要与数据相关技术都需要进行的前提条件,无论是简单的归一化、层次分析,还是复杂的图像处理、深度学习,但在这里不过多介绍数据处理的方式
验证损失--验证集
验证损失是针对于验证集来说的,其是模型在训练过程中从未见过的"新数据"上计算出的损失。它是评估模型泛化能力(处理新数据的能力)和诊断过拟合的关键指标。
二者关系
模型损失和验证损失在模型的验证中需要同时观察,模型损失是衡量模型的学习能力,验证损失是衡量模型的应用能力
|------|------------------------------|
| 理想情况 | 模型损失和验证损失同步下降,同时都可以稳定在一个较低的值 |
| 过拟合 | 模型损失下降,但是验证损失上升 |
| 欠拟合 | 模型损失和验证损失值都非常高且没有下降的趋势 |一般来说,对于数据的处理,当数据集足够的多时,我们可以使用N折交叉验证的方法来将原始数据分为训练集和验证集,同时可以将模型损失和验证损失可视化,以更加方便的评估模型
仍然使用上面的例子
import torch import torch.optim as optim def model(t_u, w, b): return t_u * w + b def loss_fn(t_p, t_c): loss = (t_p - t_c) ** 2 return loss.mean() def training_loop(n_epochs, optimizer, params, train_t_c, val_t_c, train_t_u, val_t_u): """训练模型""" for epoch in range(1, n_epochs + 1): train_t_p = model(train_t_u, *params) train_loss = loss_fn(train_t_p, train_t_c) val_t_p = model(val_t_u, *params) val_loss = loss_fn(val_t_p, val_t_c) optimizer.zero_grad() train_loss.backward() optimizer.step() print('Epoch %d, Training Loss %f, Validation Loss %f' % (epoch, float(train_loss), float(val_loss))) return params # 实际值(期望值)和输入值 t_c = [0.5, 14.0, 15.0, 28.0, 11.0, 8.0, 3.0, -4.0, 6.0, 13.0, 21.0] t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4] t_c = torch.tensor(t_c) t_u = torch.tensor(t_u) # 交叉验证 t_un = t_u * 0.01 train_t_c = t_c[0:8] val_t_c = t_c[8:12] train_t_u = t_un[0:8] val_t_u = t_un[8:12] params = torch.tensor([1.0, 0.0], requires_grad = True) learning_rate = 0.1 optimizer = optim.SGD([params], lr = learning_rate) training_loop(n_epochs = 5000, optimizer = optimizer, params = params, train_t_c = train_t_c, val_t_c = val_t_c, train_t_u = train_t_u, val_t_u = val_t_u)