03_损失函数和优化算法

1 损失函数

损失函数(loss function),也叫代价函数(cost function)、误差函数(error function)、目标函数(objective function),是用来衡量模型参数质量的函数,衡量的方式是比较网络输出(预测值)和真实输出(真实值)的差异。模型通过最小化损失函数的值来调整参数,使其输出更接近真实值。主要有性能评估和优化指导两大作用。

1.1 分类任务

1.1.1 二元交叉熵

二分类任务常用二元交叉熵损失函数(Binary Cross-Entropy Loss)。

$$L=-\frac{1}{n}\sum_{i=1}^{n}\left(y_{i}\mathrm{log}\widehat{y}_{i}+(1-y_{i})\mathrm{log}(1-\widehat{y}_{i})\right)$$

  • 𝑦𝑖 为真实值(通常为 0 或 1)
  • 𝑦̂𝑖 为预测值(表示样本 𝑖 为 1 的概率)

这个公式是针对单个样本的,它根据真实标签 y 的取值(0 或 1)只保留其中一项来计算惩罚。BCE 只惩罚模型对正确分类的预测概率,预测概率越大,损失越小。

使用 sigmoid 激活函数在输出层进行二分类时,输出层只有一个节点,这个节点的输出表示样本属于类 1 的概率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
import torch.nn as nn
torch.manual_seed(66)

# 真实标签,必须为 one-hot 编码,且必须为浮点数
y_true = torch.tensor([[1], [0], [0]], dtype=torch.float)
# 最后一层的输入
last_in = torch.rand(3, 1)
# 预测结果,表示模型预测为 1 的概率
y_pred = torch.sigmoid(last_in)

criterion = nn.BCELoss()
loss = criterion(y_pred, y_true)
print(f'预测概率为 1 的概率为:\n{y_pred}\n损失为:{loss:.3f}')
预测概率为 1 的概率为:
tensor([[0.6219],
        [0.5057],
        [0.5565]])
损失为:0.664

1.1.2 多分类交叉熵

多分类任务常用多分类交叉熵损失函数(Categorical Cross-Entropy Loss)。

$$L=-\frac{1}{n}\sum_{i=1}^n\sum_{c=1}^Cy_{i,c}\log\widehat{y_{i,c}}$$

  • C 为类别数
  • yi, c 表示第 i 个样本是否为 c 类,使用独热编码 0 或 1
  • $\widehat{y_{i,c}}$ 表示第 i 个样本是类别 c 的预测概率

这个公式是针对 C 个类别的多分类问题,它利用了 One-Hot 编码的特性,只保留其中一项来计算惩罚,CCE 只惩罚对真实类别 c 的预测概率 $\widehat{y_{i,c}}$,相当于对每一个样本的正确标签预测概率值取 log ,然后求和。

使用 softmax 进行多分类时,有几个分类,输出层就有几个神经元,每个神经元输出一个分类的概率,所有概率和为 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
import numpy as np

# 手动实现,y_pred为 softmaw 输出预测值,y_true 为真实标签或独热编码标签
def cross_entropy_loss(y_pred, y_true):
# 如果二者形状一致,则说明 y_true 为独热编码标签
if y_pred.size == y_true.size:
y_true = y_true.argmaw(dim=1) # 转换为真实标签索引

# n 行代表 n 个样本
n = y_pred.shape[0]

# 加上一个小常数,防止 log(0)
return -np.sum(np.log(y_pred[np.arange(n), y_true] + 1e-7)) / n
1
2
3
4
5
6
7
8
9
10
11
12
13
# 真实值为标签:必须是一维的,并且必须是 64 位长整型
y_true = torch.tensor([3, 4, 1])
# 最后一层的输入矩阵,共 3 个样本,每个样本 5 个特征,对应最终输出层的 5 个类别
last_in = torch.randn(3, 5)
# 经过 softmaw 激活,CrossEntropyLoss 不需要此步骤
y_pred = torch.softmax(last_in, dim=-1)

# CrossEntropyLoss(Logits, Target) = NLLLoss(softmax(Logits),Target)
criterion = nn.CrossEntropyLoss()
loss = criterion(last_in, y_true)
print(f'预测概率:\n{y_pred}\n 损失:{loss:.3f}')

print(f'自定义交叉熵损失:{cross_entropy_loss(y_pred.detach().numpy(), y_true.detach().numpy())}')
预测概率:
tensor([[0.0875, 0.1952, 0.2651, 0.3005, 0.1516],
        [0.1701, 0.5141, 0.1368, 0.0314, 0.1476],
        [0.3212, 0.2801, 0.0933, 0.1782, 0.1272]])
 损失:1.463
自定义交叉熵损失:1.4625852902730305
1
2
3
4
5
6
# 真实值为概率:
y_true = torch.randn(3, 5).softmax(dim=-1)
print(f'真实值:\n{y_true}')

loss = criterion(last_in, y_true)
print(f'预测概率:\n{y_pred}\n 损失:{loss:.3f}')
真实值:
tensor([[0.3730, 0.2605, 0.1388, 0.1145, 0.1133],
        [0.0738, 0.0385, 0.4177, 0.2543, 0.2157],
        [0.3849, 0.4449, 0.0261, 0.1309, 0.0132]])
预测概率:
tensor([[0.0875, 0.1952, 0.2651, 0.3005, 0.1516],
        [0.1701, 0.5141, 0.1368, 0.0314, 0.1476],
        [0.3212, 0.2801, 0.0933, 0.1782, 0.1272]])
 损失:1.823

注意:CrossEntropyLoss 内部完成了 softmax 操作和计算损失,因此不需要再进行 softmax 操作,并且返回的是一批次的平均损失,结果是标量。CrossEntropyLoss 等价于 LogSoftmax + NLLLoss

1.2 回归任务

1.2.1 MAE(L1 Loss)

平均绝对误差(Mean Absolute Error,MAE),也称 L1 Loss。

$$L=\frac{1}{n}\sum_{i=1}^n|y_i-\hat{y_i}|$$

L1 Loss对异常值不太敏感,0 点不可导,产生稀疏矩阵,常常作为正则化项添加到其他损失函数中。 但是L1 Loss 最大的问题就是在 0 处不平滑,在优化过程中会跳过极小值。

1
2
3
4
5
6
y_true = torch.tensor([5.0, 6.2, 3.5, 4.0])
y_pred = torch.tensor([5.5, 9.2, 3.0, 4.0])

criterion = nn.L1Loss()
loss = criterion(y_true, y_pred)
print(f'L1Loss: {loss.item():.3f}')
L1Loss: 1.000

1.2.2 MSE(L2 Loss)

均方误差(Mean Squared Error ,MSE),也称 L2 Loss。

$$L=\frac{1}{n}\sum_{i=1}^{n}(y_{i}-\widehat{y}_{i})^{2}$$

L2 Loss 由于平方会放大误差,因此对离群点敏感,当预测值与真实值偏差较大时,容易发生梯度爆炸。也常常作为正则化项。

梯度爆炸:网络层之间的梯度(值大于1.0)重复相乘导致的指数级增长会产生梯度爆炸。

1
2
3
4
5
6
y_true = torch.tensor([5.0, 6.2, 3.5, 4.0])
y_pred = torch.tensor([5.5, 9.2, 3.0, 4.0])

criterion = nn.MSELoss()
loss = criterion(y_true, y_pred)
print(f'MSELoss: {loss.item():.3f}')
MSELoss: 2.375

至于为什么 API 命名为 L1LossMSELoss,如此不对称,是因为数学传统,L1Loss 是基于 L1 范数的,而 MSELoss 基于 L2 范数的,但 MSE 是平方而不是平方根,为了避免歧义,直接使用了 MSELoss,而不是 L2Loss。

1.2.3 Smooth L1

MAE 对异常值不敏感,但在 0 处不可导,MSE 在 0 处光滑,但在误差较大时容易梯度爆炸。于是我们将两者结合,在绝对值小于 1 时使用 MSE,大于 1 时使用 MAE,并进行缩放和平移,得到 Smooth L1 损失函数,既光滑又对异常值不敏感。

$$SmoothL1= \begin{cases} \frac{1}{2}(y_i-\widehat{y}_i)^2,|y_i-\widehat{y}_i|<1 \\ |y_i-\widehat{y}_i|-\frac{1}{2},|y_i-\widehat{y}_i|\geq1 & \end{cases}$$

1
2
3
4
5
6
y_true = torch.tensor([5.0, 6.2, 3.5, 4.0])
y_pred = torch.tensor([5.5, 9.2, 3.0, 4.0])

criterion = nn.SmoothL1Loss()
loss = criterion(y_true, y_pred)
print(f'SmppthL1Loss: {loss.item():.3f}')
SmppthL1Loss: 0.688

可视化三个损失函数,L1 Loss 是将 MSE 压缩 0.5 倍,并将 MAE 下移 0.5,使二者对齐平滑。此外还有很多对齐方法,比如 0.25 倍的 MSE 和 下移 1 的 MAE,在 x=2 时可以对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme()

def mae(x):
return abs(x)

def mse(x):
return x ** 2

def smooth_l1(x):
return np.where(abs(x) < 1, 0.5 * mse(x), mae(x) - 0.5)

x = np.arange(-2, 2, 0.01)
plt.plot(x, mae(x), label='MAE')
plt.plot(x, mse(x), label='MSE')
plt.plot(x, smooth_l1(x), label='Smooth L1')
plt.legend()
<matplotlib.legend.Legend at 0x310ada210>
png

2 数值微分

损失函数的值越小,说明我们的参数选择的越合适。想要求得损失函数的最小值,最基本的想法就是对损失函数求导,解出导数为 0 的点判断是否为极小值/最小值。然而实际的函数直接求导是困难的,不容易得到解析解,这时可以使用数值微分的方式,求到某点的导数,在实际工程中非常常见。

注意:实际上,深度学习中的优化器(Optimizer)在训练时使用的并不是数值微分,而是基于解析梯度(Analytical Gradient),通过一个名为 反向传播(Backpropagation, BP) 的算法来计算梯度,运算效率极高,这里使用数值微分旨在帮助理解梯度和它的作用。

2.1 导数

回忆导数的定义:

$$f^{\prime}(x)=\frac{df(x)}{dx}=\lim_{\Delta x\to0}\frac{f(x+\Delta x)-f(x)}{\Delta x}$$

x 发生一个微小的变化 Δx 时,函数值 f(x) 也会发生变化;当 Δx 趋近于 0 时,此时 f(x) 的“变化率”就是 x 这一点的导数值。

利用这个定义,在深度学习这种黑箱中,我们可以在不知道导数表达式,甚至不知道函数表达式的情况下,直接以数值计算的方式,利用微小的差分来求函数某点的导数值,这种方法称为数值微分

1
2
3
4
5
6
7
8
9
10
def f(x):  
return x ** 2

# 数值微分求导数,x 为标量
def numerical_diff(f, x):
h = 1e-4
return (f(x + h) - f(x - h)) / (h * 2)

print(f'x=2 处的导数:{numerical_diff(f, 2):.3f}')
print(f'x=4 处的导数:{numerical_diff(f, 4):.3f}')
x=2 处的导数:4.000
x=4 处的导数:8.000

这里以 x 为中心,计算两边发生微小变化后的差分,可以避免只计算单向增大时的误差。这种方法称为 中心差分。另外,微小值 delta 不能太小,否则会导致浮点数表示的精度不够,出现舍入误差。

2.2 偏导数

如果函数 f 的自变量并非单个元素,而是多个元素。比如:

f(x, y) = x2 + y2 + xy

可以将其他自变量都置为常数,只求 xy 一个自变量的导数,称为偏导数:

$$ \frac{\partial f}{\partial x} = 2x + y \qquad \frac{\partial f}{\partial y} = 2y + x$$

推广到多个自变量:

$$\frac{\partial f}{\partial x_i}(a_1,a_2,...,a_n)=\lim_{\Delta x_i\to0}\frac{f(a_1,...a_i+\Delta x_i,...,a_n)-f(a_1,...a_i,...,a_n)}{\Delta x_i}$$

偏导数同样可以用数值微分的方法求解,即只改变一个自变量、其它不变,做差分计算函数值的变化率。应用到深度学习中,就是只改变一个参数、其他不变,计算损失函数的偏导数。

2.3 梯度

多元函数 𝑓(𝑥1, …, 𝑥𝑛) 关于每个变量 𝑥𝑖 都有偏导数 $\frac{\partial f}{\partial x_i}$,在点 𝑎 处,这些偏导数定义了一个向量,称为 f 在点 a 的梯度:

$$\nabla f(a)=\left[\frac{\partial f}{\partial x_1}(a),...,\frac{\partial f}{\partial x_n}(a)\right]$$

函数 f 在点 a 有无数个方向导数,沿方向 移动时函数变化率为:

Ddf = |∇f|cos θ

其中 θf 的夹角,要想找到让函数下降最快的方向,也就是让变化率最负,显然当 cos θ = −1 时,也就是梯度的反方向,下降最快。

应用到深度学习中,使用数值微分计算出在 a 点关于每个参数的偏导数,得到的向量也就是该点的梯度,梯度代表的是函数值增大最快的方向,寻找损失函数的最小值需要沿着负梯度方向。负梯度代表的是函数值减小最快的方向,但并不一定直接指向函数图像的最低点。

在函数的极小值、极大值和鞍点处,梯度为 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def loss(w):  # 损失函数可以不知道表达式,但可以通过计算得出确定参数计算出损失值
w1, w2 = w
return w1 ** 2 + w2 ** 2 + 5


# 数值微分求梯度,w 为向量
def _numerical_grad(loss, w):
h = 1e-4
grad = np.zeros_like(w) # 创建与 w 相同形状的梯度向量

# 遍历 w 的每一个自变量
for i in range(w.size):
tmp = w[i] # 保存当前自变量的值
w[i] = tmp + h # 只改变当前自变量的值
fwh1 = loss(w) # 计算 loss(w+h)
w[i] = tmp - h # 只改变当前自变量的值
fwh2 = loss(w) # 计算 loss(w-h)
w[i] = tmp # 恢复当前自变量的值

grad[i] = (fwh1 - fwh2) / (2 * h) # 计算梯度

return grad


# 数值微分求梯度,W 为输入矩阵,每一行为一个样本
def numerical_grad(loss, W):
if W.ndim == 1: # 输入为向量
return _numerical_grad(loss, W)
else: # 输入为矩阵
grad = np.zeros_like(W) # 创建一个和输入矩阵形状相同的梯度矩阵
for i, w in enumerate(W): # 遍历矩阵每一行
grad[i] = _numerical_grad(loss, w)
return grad

# 两个参数,三个样本求梯度
numerical_grad(loss, np.array([[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]]))
array([[ 4.,  6.],
       [ 8., 10.],
       [12., 14.]])

2.4 利用数值微分训练神经网络

这里不使用 PyTorch,手动实现一个两层的神经网络,利用数值微分的思想计算梯度,来完成经典案例———手写数字识别。

没有损失函数表达式如何求最小值?通过固定其他参数,只改变一个参数,求解损失函数对于该参数的导数,对所有参数做此操作,得到在当前点的梯度向量,然后按照梯度向量的反方向进行参数更新。

激活函数实现:

1
2
3
4
5
def tanh(x):
return np.tanh(x)

def softmax(x):
return np.exp(x) / np.sum(np.exp(x), axis=-1).reshape(-1, 1)

损失函数在 1.1.2 节已经实现完毕,梯度下降在 2.3 节已经实现完毕,这里不再重复。

定义两层神经网络模型:

  • 输入层:输入维度 64,对应 8 * 8 的图片
  • 隐藏层:输入维度 50 ,经过 Tanh 激活函数,输出维度 10
  • 输出层:输入维度 10,经过 Softmax 激活函数,对应 10 个数字类别
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class TwoLayerNet(object):
def __init__(self, input_size, output_size, hidden_size=50, weight_init=0.01):
self.param = {}
self.param['W1'] = weight_init * np.random.randn(input_size, hidden_size)
self.param['b1'] = np.zeros(hidden_size)
self.param['W2'] = weight_init * np.random.randn(hidden_size, output_size)
self.param['b2'] = np.zeros(output_size)

def forward(self, x):
"""前向传播,完成一次预测

Args:
x (_type_): 要预测的样本特征

Returns:
_type_: 经过 softmax 的预测概率
"""
W1, W2 = self.param['W1'], self.param['W2']
b1, b2 = self.param['b1'], self.param['b2']

a1 = x @ W1 + b1
z1 = tanh(a1)
a2 = z1 @ W2 + b2
z2 = softmax(a2)

return z2

def criterion(self, y_proba, y_true):
"""计算多元交叉熵损失

Args:
y_proba (_type_): 预测的概率值
y_true (_type_): 真实值标签

Returns:
_type_: 损失值
"""

return cross_entropy_loss(y_proba, y_true)

def accuracy(self, y_proba, y_true):
"""计算准确率

Args:
x (_type_): 预测的概率值
y_true (_type_): 真实值标签

Returns:
_type_: 准确率
"""
y_pred = np.argmax(y_proba, axis=1)
correct_num = (y_pred == y_true).sum()
acc = correct_num / len(y_true)

return acc

def grad(self, x, y_true):
"""计算当前参数的梯度

Args:
x (_type_): 要预测的样本特征
y_true (_type_): 真实值标签

Returns:
_type_: 每个权重的梯度
"""
# 这里 w 只是占位,没有实际作用,实际调用还是当前对象的 criterion 方法
# 充当适配器的作用
def loss_func(w):
# 1. 执行前向传播
y_proba = self.forward(x)
# 2. 计算损失
return self.criterion(y_proba, y_true)

grads = {}
# 传递的 param 是引用,所以在函数内修改了 param,也会修改当前对象的 param
grads['W1'] = numerical_grad(loss_func, self.param['W1'])
grads['b1'] = numerical_grad(loss_func, self.param['b1'])
grads['W2'] = numerical_grad(loss_func, self.param['W2'])
grads['b2'] = numerical_grad(loss_func, self.param['b2'])

return grads

定义函数获取数据集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

def get_data():
load_data = load_digits()
X = load_data.data
y = load_data.target.reshape(-1, 1)

X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.8, stratify=y, random_state=66)

train_data = np.hstack([X_train, y_train])
test_data = np.hstack([X_test, y_test])

return train_data, test_data

定义函数进行训练,这里可以使用 tensorboard 动态地可视化训练过程。使用 pip install tensorboard 安装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import time
from torch.utils.tensorboard import SummaryWriter

def train(epoch=100, batch_size=32, lr=0.1):
train_data, test_data = get_data() # 获取数据
train_size = train_data.shape[0] # 训练集大小
iter_num = train_size // batch_size + 1 # 每个 epoch 迭代次数

model = TwoLayerNet(input_size=64, output_size=10)

"""创建 SummaryWriter 对象"""
writer = SummaryWriter(
log_dir=f'runs/{time.strftime('%Y-%m-%d_%H-%M-%S')}'
)

loss_list = []
acc_list = []
for epoch in range(epoch):
total_loss = 0 # 每轮总损失
batch_num = 0 # 批次数
correct_num = 0 # 预测正确的数量
train_num = 0 # 总样本数
for i in range(iter_num):
# 随机获取一个 batch 索引
batch_mask = np.random.choice(train_size, batch_size)
train = train_data[batch_mask] # 获取 batch 数据
X_train, y_train = train[:, :-1], train[:, -1].astype(np.int64) # 划分特征和标签

# 前向传播,预测概率
y_proba = model.forward(X_train)
# 计算损失,cross_entropy_loss 返回的是每批次的平均损失
loss = model.criterion(y_proba, y_train)
total_loss += loss
batch_num += 1

# 计算预测标签
y_pred = y_proba.argmax(axis=1)
# 计算批次正确个数和批次数量
correct_num += (y_pred == y_train).sum()
train_num += len(y_train)

# 计算梯度,更新参数
grads = model.grad(X_train, y_train)
for key in model.param.keys():
model.param[key] -= lr * grads[key]

# 手打进度条
print(
f"\rEpoch: {epoch+1:0>2}"
f"[{'=' * int(batch_num / iter_num * 30):<30}] "
f"{batch_num / iter_num * 100:>6.2f}% "
f"Loss: {total_loss / batch_num:.3f} "
f"Acc: {correct_num / train_num * 100:.3f}%",
end=''
)

print()

epoch_loss = total_loss / batch_num
epoch_acc = correct_num / train_num
loss_list.append(epoch_loss)
acc_list.append(epoch_acc)

"""记录 loss 和 acc 到 Tensorboard"""
writer.add_scalar('loss', epoch_loss, epoch)
writer.add_scalar('acc', epoch_acc, epoch)

return model, loss_list, acc_list


# 为了节省时间,这里只训练 5 轮
model, loss_list, acc_list = train(epoch=5)
Epoch: 01[==============================] 100.00%  Loss: 1.295  Acc: 65.903%
Epoch: 02[==============================] 100.00%  Loss: 0.349  Acc: 92.639%
Epoch: 03[==============================] 100.00%  Loss: 0.222  Acc: 95.139%
Epoch: 04[==============================] 100.00%  Loss: 0.156  Acc: 96.944%
Epoch: 05[==============================] 100.00%  Loss: 0.111  Acc: 97.708%

使用现成工具 tqdm 显示进度条,不需要自己手写 print。使用pip install tqdm 安装。

1
2
3
4
5
6
7
from tqdm import tqdm

dataloader = range(100000000)

for epoch in range(3):
for batch in tqdm(dataloader, desc='训练'):
pass
训练: 100%|██████████| 100000000/100000000 [00:06<00:00, 16450174.04it/s]
训练: 100%|██████████| 100000000/100000000 [00:06<00:00, 16451340.61it/s]
训练: 100%|██████████| 100000000/100000000 [00:06<00:00, 16359374.67it/s]

也可以动态调整进度条后方显示的内容。

1
2
3
4
5
6
7
8
9
10
11
dataloader = range(10000)

loss = 6.0
acc = 0.1

for epoch in range(3):
loop = tqdm(dataloader, desc=f'Epoch {epoch+1}')
for batch in loop:
loop.set_postfix(loss=loss, acc=acc)
loss -= 0.0002
acc += 0.00003
Epoch 1: 100%|██████████| 10000/10000 [00:02<00:00, 3789.39it/s, acc=0.4, loss=4]    
Epoch 2: 100%|██████████| 10000/10000 [00:02<00:00, 3831.96it/s, acc=0.7, loss=2]    
Epoch 3: 100%|██████████| 10000/10000 [00:02<00:00, 3960.02it/s, acc=1, loss=0.0002]   

绘制损失函数和正确率对于训练轮次的曲线图。

1
2
3
4
5
6
7
8
_, axs = plt.subplots(1, 2, figsize=(12, 4))
axs[0].plot(loss_list)
axs[1].plot(acc_list)

axs[0].set_title('Loss')
axs[0].set_xlabel('Epoch')
axs[1].set_title('Accuracy')
axs[1].set_xlabel('Epoch')
Text(0.5, 0, 'Epoch')
png

如果使用 TensorBoard,在命令行中输入 tensorboard --logdir=runs(runs 为保存日志文件的目录)启动 TensorBoard,在浏览器中打开 http://localhost:6006

在测试集完成预测,输出分类报告。

1
2
3
4
5
6
7
8
9
10
def test():
train_data, test_data = get_data() # 获取数据
X_test, y_test = test_data[:, :-1], test_data[:, -1].astype(np.int64)
y_proba = model.forward(X_test)
y_pred = y_proba.argmax(axis=1)

print(classification_report(y_test, y_pred))


test()
              precision    recall  f1-score   support

           0       1.00      0.97      0.99        36
           1       0.97      0.95      0.96        37
           2       1.00      1.00      1.00        35
           3       0.90      1.00      0.95        37
           4       1.00      0.94      0.97        36
           5       1.00      0.89      0.94        36
           6       0.95      1.00      0.97        36
           7       1.00      1.00      1.00        36
           8       0.90      1.00      0.95        35
           9       0.94      0.89      0.91        36

    accuracy                           0.96       360
   macro avg       0.97      0.96      0.96       360
weighted avg       0.97      0.96      0.96       360

3 梯度下降和优化

3.1 梯度下降法

梯度下降法(Gradient Descent)就是一种利用梯度最小化损失函数的迭代优化算法。核心是沿着损失函数的负梯度方向逐步调整参数,从而逼近函数的最小值。

模拟梯度下降优化目标函数 w1 ** 2 + w2 ** 2 + 5,经过 15 轮迭代已经非常逼近目标函数的最小值 5,充分体现了梯度下降可以通过迭代寻找目标函数的最小值。

1
2
3
4
5
6
7
8
9
10
11
def gradient_descent(loss, init_W, lr=0.01, epochs=100):
w = init_W.copy()

for epoch in range(epochs):
grad = numerical_grad(loss, w)
w -= lr * grad
print(f'epoch: {epoch + 1}, w1={w[0]:.3f}, w2={w[1]:.3f}, loss={loss(w):.3f}')

return w

gradient_descent(loss, np.array([2.0, 3.0]), lr=0.1, epochs=15)
epoch: 1, w1=1.600, w2=2.400, loss=13.320
epoch: 2, w1=1.280, w2=1.920, loss=10.325
epoch: 3, w1=1.024, w2=1.536, loss=8.408
epoch: 4, w1=0.819, w2=1.229, loss=7.181
epoch: 5, w1=0.655, w2=0.983, loss=6.396
epoch: 6, w1=0.524, w2=0.786, loss=5.893
epoch: 7, w1=0.419, w2=0.629, loss=5.572
epoch: 8, w1=0.336, w2=0.503, loss=5.366
epoch: 9, w1=0.268, w2=0.403, loss=5.234
epoch: 10, w1=0.215, w2=0.322, loss=5.150
epoch: 11, w1=0.172, w2=0.258, loss=5.096
epoch: 12, w1=0.137, w2=0.206, loss=5.061
epoch: 13, w1=0.110, w2=0.165, loss=5.039
epoch: 14, w1=0.088, w2=0.132, loss=5.025
epoch: 15, w1=0.070, w2=0.106, loss=5.016





array([0.07036874, 0.10555312])

3.2 训练术语

  • Epoch:1 个 Epoch 表示模型完整遍历一次训练数据集的过程。 单次遍历数据集通常不足以让模型收敛,模型需要多次遍历数据集才能逐步优化模型参数、学习数据中的模式,
  • Batch Size:Batch Size 是每次训练时输入的样本数量。例如 batch_size=32 表示每次用 32 个样本计算一次梯度,并取平均值作为本次迭代的参数更新方向。 小批量数据计算梯度比单样本更稳定,比全批量更高效。并且较小的 Batch Size 可能带来更多噪声,有助于模型泛化。
  • Iteration:一次 Iteration 表示完成一个 Batch 数据的正向传播(预测)和反向传播(更新参数)的过程,也就是训练轮数和批次数的乘积。

3.3 SGD

SGD(Stochastic Gradient Descent)原本只随机选择一个样本计算梯度来更新参数,但是在 PyTorch 中经常使用 optim.SGD 配合 DataLoader,实际上执行的是 Mini-Batch Gradient Descent (小批量梯度下降)。

每个样本都对应一个自己的损失函数,Mini-Batch 的思想就是选取一小批样本,计算它们的平均损失对模型参数的梯度,用来近似整个训练集的损失函数对参数的梯度。

由于 Mini-Batch 引入了梯度的随机性,这间接提高了模型的泛化能力并避免过拟合。

Mini-Batch 是对整个训练集真实梯度的一个有噪音的估计,这种噪音让优化过程不会笔直地冲向损失函数表面的最尖锐、最狭窄的局部极小值,而尖锐极小值通常与较差的泛化能力(过拟合)相关。随机性帮助优化过程探索更广阔的区域,更容易找到平坦的极小值。平坦的极小值意味着模型对输入数据的微小变化更不敏感,通常能带来更好的泛化能力。

Batch Size 梯度估计 泛化能力 收敛速度(时间)
大 Batch (e.g., 256+) 准确稳定 倾向于收敛到尖锐极小值,泛化能力可能较差(更容易过拟合)。 训练时间短(利用并行计算)
小 Batch (e.g., 16-64) 噪音大 倾向于收敛到平坦极小值,泛化能力(不容易过拟合)。 训练时间长(利用效率低)
Full Batch (全批量) 最准确 最容易过拟合 训练时间最长

注意这里讨论的是泛化能力,而不仅仅是收敛稳定性。

虽然大批量的梯度更新在训练时看起来更稳定、路径更平滑,但它们会倾向于找到训练集损失最低但泛化能力差(对测试数据微小变化敏感)的尖锐极小值。

而小批量的训练路径虽然波动,但这种波动(随机性)却是一种正则化形式,帮助模型找到了一个泛化性能更好、更鲁棒的平坦极小值。

SGD 的更新公式:

W ← W − η

SGD 实现简单,理解方便,然而有些问题,比如:

  • 局部最优解:陷入局部最优,尤其在非凸函数中,难以找到全局最优解。
  • 鞍点:陷入鞍点,梯度为 0,导致训练停滞。
  • 收敛速度慢:高维或非凸函数中,收敛速度较慢。
  • 学习率选择:学习率过大导致震荡或不收敛,过小则收敛速度慢。

针对以上问题,有些常用的优化算法如:

  1. 引入动量
    • Momentum
  2. 学习率衰减
  3. 自适应学习率
    • AdaGrad
    • RMSprop
  4. 结合自适应学习率和动量
    • Adam

3.4 Momentum

Momentum(动量法)模拟物理学中的动量概念。在梯度下降时,不仅仅考虑当前梯度,还考虑之前的梯度方向。

$$v\leftarrow\alpha v+(1-\alpha)\nabla \\ W\leftarrow W-\eta v$$

  • v: 动量,历史负梯度的加权和
  • α: 动量的衰减率,也就是历史梯度的权重,通常取 0.9
  • η: 学习率
  • : 当前梯度

动量法通过累计历史梯度,能够减缓优化过程中的震荡,并且很有可能在遇到鞍点或局部最优解时,冲过去从而取得更优解。

1
2
3
4
5
6
import torch.optim as optim

model = nn.Linear(1, 1)

# 在 SGD 上直接使用 momentum 属性
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
  • 优点:
    • 平滑梯度,减少梯度方向的震荡
    • 加速收敛,梯度方向一致时,可以加速参数的更新
    • 可以帮助模型冲出局部最小值
  • 缺点:
    • 动量系数需要手动调整
    • 动量过大,模型可能越过最优解

3.5 学习率衰减

较大的学习率可以加快收敛速度, 但可能在最优解附近震荡或不收敛;较小的学习率可以提高收敛的精度,但训练速度慢,可能陷入局部最优解。学习率衰减是一种平衡策略,初期使用较大学习率快速接近最优解,后期逐渐减小学习率,使参数更稳定地收敛到最优解。

  • 等间隔衰减:每隔固定的训练周期(epoch),学习率按一定的比例下降。
1
2
3
4
5
6
7
8
# 每过 10 轮,学习率衰减为之前的 0.6 倍
scheduler_lr = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.6)

for epoch in range(10):
for i in range(10): # 模拟一个 batch
optimizer.step() # 每批更新优化器
scheduler_lr.step() # 每轮更新学习率
print(scheduler_lr.get_last_lr(), end=' ')
[0.1] [0.1] [0.1] [0.1] [0.1] [0.1] [0.1] [0.1] [0.1] [0.06] 
  • 制定间隔衰减:在指定的 epoch,让学习率按比例衰减。
1
2
# 在 10、50、100 轮时,学习率衰减为 0.6 倍
optim.lr_scheduler.MultiStepLR(optimizer, milestones=[10, 50, 100], gamma=0.6)
<torch.optim.lr_scheduler.MultiStepLR at 0x3160cf6b0>
  • 指数衰减:学习率按照指数函数进行衰减,一般底数要接近 1。
1
2
# 底数为 0.95 的指数衰减
optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.95)
<torch.optim.lr_scheduler.ExponentialLR at 0x316895d30>

3.6 AdaGrad

Adagrad(Adaptive Gradient)是一种自适应梯度优化算法,Adagrad 的核心思想是为每个参数维护一个独立的学习率。它会根据每个参数的历史梯度平方和来调整学习率,对于更新频繁的参数,学习率会降低;对于更新不频繁的参数,学习率会增加。

$$G_i \leftarrow G_i+\nabla^2 \\ W_i \leftarrow W_i-\frac{\eta}{\sqrt{G_i}+\epsilon}\nabla$$

  • Gi:第 i 个参数的历史梯度平方和
  • η:学习率
  • ϵ:一个很小的值,防止除零

随着训练进行,累计梯度平方和变大,所对应的学习率会逐渐减小,所以 AdaGrad 刚开始可以使用较大的学习率。

1
optimizer = optim.Adagrad(model.parameters(), lr=0.1)
  • 优点:
    • 自适应学习率,不用手动调整
    • 适用于稀疏特征,大多数特征值为 0,对应的梯度也可能为 0,对应参数的累计梯度和较小,能够保持较大的学习率,促进这些特征的学习,能够有效利用数据中信息
  • 缺点:
    • 随着训练的进行,学习率最终会减小到非常小的值,过早使学习率过低可能导致算法无法找到最优解。

3.7 RMSProp

RMSProp(Root Mean Square Propagation,均方根传播)是在 AdaGrad 基础上的改进,旨在解决 Adagrad 在训练后期学习率降低过快的问题。它并非累计每个参数的历史梯度平方,而是逐渐遗忘过去的梯度,采用指数移动加权平均,呈指数地减小过去梯度的尺度。

$$G_i \leftarrow \alpha G_i+ (1-\alpha)\nabla^2 \\ W_i \leftarrow W_i-\frac{\eta}{\sqrt{G_i}+\epsilon}\nabla$$

  • α:用于控制历史梯度的影响程度,通常取 0.9-0.99 之间的值
  • η:学习率
  • Gi:第 i 个参数的近期梯度平方的加权平均值
  • ϵ:小常数防止除零
1
2
# alpha 控制历史梯度影响程度
optimizer = optim.RMSprop(model.parameters(), lr=0.01, alpha=0.9)

优点同 AdaGrad 相同,解决了学习率过早衰减的问题,并且在非凸优化问题的解决上表现良好,缺点是超参数 α 需要手动设置。

3.8 Adam

Adam(Adaptive Moment Estimation,自适应矩估计)结合了 Momentum 和 RMSprop 的优点,同时利用梯度的一阶矩估计(即动量)和二阶矩估计(即 RMSprop 中的平方梯度),能够适应性地调整每个参数的学习率,同时利用历史梯度的信息来加速训练。

$$

\

WW-$$

  • v:一阶矩估计,类似动量
  • h:二阶矩估计,类似平方梯度
  • α1:一阶矩估计的指数衰减率,控制历史梯度的影响程度,一般取 0.9。值越大,历史信息的影响越大,梯度变化越平滑。
  • α2:二阶矩估计的指数衰减率,控制历史平方梯度的影响程度,一般取 0.999。值越大,历史信息的影响越大,学习率调整越平滑。
  • :修正后的估计,由于 α 初始值较大,而历史梯度没有经过累计还很小,当前梯度信息被压缩太小,整个 vh 会很小,通过除去指数修正偏差。
  • t:迭代次数
  • ϵ:小常数防止除零错误
1
2
# beta = (0.9, 0.999) 分别代表 α1 和 α2
optimizer = optim.Adam(model.parameters(), lr=0.01, betas=(0.9, 0.999))
  • 优点
    • 自适应学习率
    • 快速收敛
    • 适用稀疏数据
  • 缺点
    • 强大的自适应性可能导致过拟合

4 正则化

过拟合是在模型训练中很容易遇到的问题,通过正则化的方式可以降低模型复杂度,从而防止过拟合。在深度学习中神经网络容易遇到的问题还有梯度消失和梯度爆炸,借鉴机器学习的思路,扩展了正则化的范围,常见的正则化方法有 Batch Normalization、权值衰减、Dropout、早停法等。

4.1 Batch Normalization

在深度神经网络训练过程中,由于前面层的参数更新会导致后面层输入的改变,每一层输入的分布都在不断变化,这种现象被称为内部协变量偏移 (Internal Covariate Shift)。这会带来以下问题:

  • 训练缓慢: 每层都需要不断适应新的输入分布,导致学习效率降低。
  • 梯度消失/爆炸: 输入分布的变化可能导致梯度变得过大或过小,影响训练的稳定性。
  • 对初始化敏感: 合适的初始化参数变得更加重要,否则难以训练。

Batch Normalization 通过将每层网络的输入归一化到一个标准分布,可以调整各层的激活值分布使其拥有适当的广度,BN 层通常放在激活函数之前,这意味着经过 BN 层之后的数据更加适合激活函数,不容易出现梯度爆炸和梯度消失。

$$\hat{x}=\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}} \qquad y=\gamma\hat{x}+\beta$$

计算一个 batch 中每个特征通道的均值 μ 和方差 σ,然后除减均值除方差进行归一化(ϵ为小常数),为了保证网络的表达能力,引入两个可学习的参数 γ (缩放因子)和 β (平移因子)对归一化后的输出进行缩放和平移。

优点: - 加速训练:允许使用更高的学习率,加快收敛速度。 - 提高泛化能力:在一定程度上具有正则化效果,因为它在训练过程中引入了噪声(由小批量估计的均值和方差产生的噪声)。 - 缓解初始化敏感性:使得模型对参数初始化的依赖性减小,从而提高了训练的稳定性。

缺点: - 对批次大小敏感:当批次大小很小时,均值和方差的估计可能不准确,影响归一化效果。 - 在 RNN 中应用较为复杂:在循环神经网络(RNN)中应用 Batch Normalization 较为复杂,需要进行特殊的处理。

切换模型模式会有不同机制:

  1. 训练时: model.train()
    • BN 层会计算当前批次的均值 μ 和方差 σ²,并对当前批次的数据进行规范化。
    • BN 层还会维护一个全局均值全局方差的移动平均值,用于推理阶段。
  2. 推理时: model.eval()
    • 推理时,直接使用训练阶段计算的全局均值全局方差
1
2
3
4
5
6
7
8
""" 
BatchNorm1d:主要应用于全连接层或处理一维数据的网络,例如文本处理。它接收形状为 (N, num_features) 的张量作为输入。
BatchNorm2d:主要应用于卷积神经网络,处理二维图像数据或特征图。它接收形状为 (N, C, H, W) 的张量作为输入。
BatchNorm3d:主要用于三维卷积神经网络 (3D CNN),处理三维数据,例如视频或医学图像。它接收形状为 (N, C, D, H, W) 的张量作为输入。
"""
in_ = torch.randint(0, 10, size=(8, 3), dtype=torch.float32)
bn2d = nn.BatchNorm1d(num_features=3)
bn2d(in_)
tensor([[ 0.6912, -0.1234,  0.1234],
        [-1.8892,  1.1929,  1.4397],
        [-0.0461, -1.4397,  1.7688],
        [ 1.0598, -0.7816, -0.2057],
        [-0.7833, -1.1106, -0.5347],
        [-0.7833,  1.5220, -0.8638],
        [ 0.6912,  0.5347, -1.1929],
        [ 1.0598,  0.2057, -0.5347]], grad_fn=<NativeBatchNormBackward0>)

4.2 权值衰减

由于权重参数取值过大是很多过拟合产生的原因,因此可以在学习的过程中对大的权重进行惩罚,可以有效地抑制过拟合,这种方法被称为权值衰减

一般会对损失函数加上一个权重的范数;最常见的就是 L2 范数的平方: $$L^{\prime}=L+\frac{1}{2}\cdot\lambda\cdot||W||^2$$

  • ||W||:权重W = (w1, w1, ..., w1)的 L2 范数,即$\sqrt{w_1^2+w_2^2+...+w_n^2}$
  • λ:控制正则化强度的超参数

惩罚项求导之后得到 𝜆𝑊 ,所以在求权重梯度时,需要为之前误差反向传播法的结果加上 𝜆𝑊

4.3 Dropout

Dropout(随机失活,暂退法)是一种在学习的过程中随机关闭神经元的方法,通常放在激活函数之后,全连接层之前。

训练时每个神经元都有概率 𝑝 (通常为0.2~0.5) 被临时关闭,迫使网络不依赖特定神经元,强迫网络学习到更加鲁棒的特征表示,同时未被关闭的神经元的输出值以 $\frac{1}{(1−𝑝)}$ 的比例进行缩放,以保持期望值不变。

由于每次 Dropout 是随机失活,迭代训练不同的子网络,引入了随机性,近似集成 Bagging 的效果。

1
2
3
4
5
z = torch.randn(3, 5)
dropout = nn.Dropout(0.5)

# 每个神经元的输出都有 50% 的概率被强制置零
dropout(z)
tensor([[ 1.7553,  0.8046, -0.0000,  0.0000, -1.1799],
        [-2.8858,  0.0000,  0.3864,  0.0000, -2.3464],
        [-0.0000, -0.0000, -0.0000,  0.0000, -0.0000]])

03_损失函数和优化算法
https://zhubaoduo.com/2024/08/07/大模型开发/05 深度学习/03_损失函数和优化算法/
作者
baoduozhu
发布于
2024年8月7日
许可协议