第3章 线性分类
3.1 基于Logistic回归的二分类任务
3.1.1 数据集构建
3.1.1.1导入基本库
import math
import copy
import torch
import matplotlib.pyplot as plt
3.1.1.2 构造带噪音的弯月形状数据集
构建一个简单的分类任务,并构建训练集、验证集和测试集。
本任务的数据来自带噪音的两个弯月形状函数,每个弯月对一个类别。我们采集1000条样本,每个样本包含2个特征。
随机采集1000个样本,并进行可视化。
代码如下:
def creat_dataset(n_samples, shuffle, noise):
"""
输入:
- n_samples: 数据量大小,数据类型为int
- shuffle: 是否打乱数据,数据类型为bool
- noise: 以多大的程度增加噪声,数据类型为None或float,noise为None时表示不增加噪声
输出:
- X:特征数据, shape=[n_samples,2]
- y:标签数据, shape=[n_samples]
"""
n_samples_out = n_samples // 2
n_samples_in = n_samples - n_samples_out
# 采集第1类数据,特征为(x,y)
# 使用torch.linspace在0到pi上均匀取n_samples_out个值
# 使用torch.cos计算上述取值的余弦值作为特征1,使用torch.sin计算上述取值的正弦值作为特征2
a = torch.cos(torch.linspace(0, math.pi, n_samples_out))
b = torch.sin(torch.linspace(0, math.pi, n_samples_out))
c = 1 - torch.cos(torch.linspace(0, math.pi, n_samples_in))
d = 0.5 - torch.sin(torch.linspace(0, math.pi, n_samples_in))
# 使用torch.cat将两类数据的特征1和特征2分别延维度0拼接在一起,得到全部特征1和特征2
# 使用torch.stack将两类特征延维度1堆叠在一起
X = torch.stack([torch.cat([a, c]),
torch.cat([b, d])], dim=1)
# 使用torch. zeros将第一类数据的标签全部设置为0
# 使用torch. ones将第一类数据的标签全部设置为1
y = torch.cat([torch.zeros(n_samples_out),
torch.ones(n_samples_in)])
# 如果shuffle为True,将所有数据打乱
if shuffle:
idx = torch.randperm(X.shape[0]) # 使用torch.randperm生成一个数值在0到X.shape[0],随机排列的一维Tensor做索引值,用于打乱数据
X = X[idx]
y = y[idx]
# 如果noise不为None,则给特征值加入噪声
if noise is not None:
X += torch.normal(0.0, noise, X.shape) # 使用torch.normal生成符合正态分布的随机Tensor作为噪声,并加到原始特征上
return X, y
3.1.1.3 可视化数据集
n_samples = 1000
shuffle = True
noise = 0.5
X, y = creat_dataset(n_samples, shuffle, noise)
plt.figure(figsize=(5, 5))
plt.scatter(x=X[:, 0].tolist(), y=X[:, 1].tolist(), marker='*', c=y.tolist())
plt.xlim(-3, 4)
plt.ylim(-3, 4)
plt.savefig('linear-dataset-vis.pdf')
plt.show()
运行结果如下:
3.1.1.4 划分训练集、验证集、测试集
# 拆分训练集,验证集,测试集
num_train = 640 # 训练集个数
num_dev = 160 # 验证集个数
num_test = 200 # 测试集个数
X_train, y_train = X[:num_train], y[:num_train]
X_dev, y_dev = X[num_train:num_train + num_dev], y[num_train:num_train + num_dev]
X_test, y_test = X[num_train + num_dev:], y[num_train + num_dev:]
y_train = y_train.reshape([-1, 1])
y_dev = y_dev.reshape([-1, 1])
y_test = y_test.reshape([-1, 1])
3.1.2模型构建
3.1.2.1Logistic函数简介
Logistic回归是一种常用的处理二分类问题的线性模型。与线性回归一样,Logstic回归也会将输入特征与权重做线性叠加。不同之处在于,Logstic回归引入了非线性函数g:
R
D
→
(
0
,
1
)
\mathbb{R}^{D}\rightarrow (0,1)
RD→(0,1) 预测类别标签的后验概率p(y=1|x),从而解决连续的线性函数不适合进行分类的问题。
p
(
y
=
1
∣
x
)
=
σ
(
ω
T
x
+
b
)
,(
3.1
)
p(y=1|x)=\sigma (\omega ^Tx+b),(3.1)
p(y=1∣x)=σ(ωTx+b),(3.1)
其中判别函数σ(.)为Logstic函数,也成为激活函数,作用是将线性函数f(x;w;b)的输出从师叔区间“挤压”到(0,1)之间,用来表示概率,Logstic函数定义为:
σ
(
x
)
=
1
1
+
e
−
x
。(
3.2
)
σ(x)=\frac{1}{1+e^{-x}}。(3.2)
σ(x)=1+e−x1。(3.2)
3.1.2.2 Logstic函数定义及绘制
# 我们基于Logistic函数构建线性回归的模型
# 定义Logistic函数
def logistic(x):
return 1 / (1 + torch.exp(-x))
# 在[-10,10]的范围内生成一系列的输入值,用于绘制函数曲线
x = torch.linspace(-10, 10, 10000)
plt.figure()
plt.plot(x.tolist(), logistic(x).tolist(), color="r", label="Logistic Function")
# 设置坐标轴
ax = plt.gca()
# 取消右侧和上侧坐标轴
ax.spines['top'].set_color('none')
ax.spines['right'].set_color('none')
# 设置默认的x轴和y轴方向
ax.xaxis.set_ticks_position('bottom')
ax.yaxis.set_ticks_position('left')
# 设置坐标原点为(0,0)
ax.spines['left'].set_position(('data', 0))
ax.spines['bottom'].set_position(('data', 0))
# 添加图例
plt.legend()
plt.savefig('linear-logistic.pdf')
plt.show()
运行结果如下:
3.1.2.3构建Logistic回归算子
from nndl import op
#构建Logistic回归算子
class model_LR(op.Op):
def __init__(self, input_dim):
super(model_LR, self).__init__()
self.params = {}
# 将线性层的权重参数全部初始化为0
self.params['w'] = torch.zeros([input_dim, 1])
# self.params['w'] = torch.normal(mean=0, std=0.01, shape=[input_dim, 1])
# 将线性层的偏置参数初始化为0
self.params['b'] = torch.zeros([1])
def __call__(self, inputs):
return self.forward(inputs)
def forward(self, inputs):
"""
输入:
- inputs: shape=[N,D], N是样本数量,D为特征维度
输出:
- outputs:预测标签为1的概率,shape=[N,1]
"""
# 线性计算
score = torch.matmul(inputs, self.params['w']) + self.params['b']
# Logistic 函数
outputs = logistic(score)
return outputs
#模型构建完成
测试一下
#简单测试一下
# 固定随机种子,保持每次运行结果一致
torch.manual_seed(0)
# 随机生成3条长度为4的数据
inputs = torch.randn([3,4])
print('Input is:', inputs)
# 实例化模型
model = model_LR(4)
outputs = model(inputs)
print('Output is:', outputs)
#模型构建完成
3.1.3 损失函数
3.1.3.1交叉熵损失函数简介
交叉熵是用来评估当前训练得到的概率分布与真实分布的差异情况,减少交叉熵损失就是在提高模型的预测准确率。是真实分布的概率,是模型通过数据计算出来的概率估计。
从平方损失函数运用到多分类场景下,可知平方损失函数对每一个输出结果都十分看重,而交叉熵损失函数只对正确分类的结果看重。交叉熵损失函数只和分类正确的预测结果有关。而平方损失函数还和错误的分类有关,平方损失函数除了让正确分类尽量变大,还会让错误分类都变得更加平均,但实际中后面的这个调整使没必要的。但是对于回归问题这样的考虑就显得重要了,因而回归问题上使用交叉熵并不适合。
交叉熵公式定义为:
H
(
p
,
q
)
=
−
∑
n
i
=
1
p
(
x
i
)
l
o
g
(
q
(
x
i
)
)
,(
3.3
)
H(p,q)=− ∑_n^{i=1}p(x_i)log(q(x_i)),(3.3)
H(p,q)=−n∑i=1p(xi)log(q(xi)),(3.3)
3.1.3.2 交叉熵损失函数的定义
# 实现交叉熵损失函数
class BinaryCrossEntropyLoss(op.Op):
def __init__(self):
self.predicts = None
self.labels = None
self.num = None
def __call__(self, predicts, labels):
return self.forward(predicts, labels)
def forward(self, predicts, labels):
"""
输入:
- predicts:预测值,shape=[N, 1],N为样本数量
- labels:真实标签,shape=[N, 1]
输出:
- 损失值:shape=[1]
"""
self.predicts = predicts
self.labels = labels
self.num = self.predicts.shape[0]
loss = -1. / self.num * (torch.matmul(self.labels.t(), torch.log(self.predicts)) + torch.matmul((1-self.labels.t()), torch.log(1-self.predicts)))
loss = torch.squeeze(loss, dim=1)
return loss
3.1.4 模型优化
不同于线性回归中直接使用最小二乘法即可进行模型参数的求解,Logistic回归需要使用优化算法对模型参数进行有限次地迭代来获取更优的模型,从而尽可能地降低风险函数的值。
在机器学习任务中,最简单、常用的优化算法是梯度下降法。
使用梯度下降法进行模型优化,首先需要初始化参数W和 b,然后不断地计算它们的梯度,并沿梯度的反方向更新参数。
3.1.4.1 梯度计算
在Logistic回归中,风险函数R(w,b)关于参数w和b的偏导数为:
∂
R
(
w
,
b
)
∂
w
=
−
1
N
∑
n
=
1
N
x
(
n
)
(
y
(
n
)
−
y
^
(
n
)
)
=
−
1
N
X
T
(
y
−
y
^
)
\frac{\partial R(w,b)}{\partial w}=-\frac{1}{N}\sum_{n=1}^{N}x^{(n)}(y^{(n)}-\hat{y}^{(n)})=-\frac{1}{N}X^{T}(y-\hat{y})
∂w∂R(w,b)=−N1n=1∑Nx(n)(y(n)−y^(n))=−N1XT(y−y^)
∂
R
(
w
,
b
)
∂
b
=
−
1
N
∑
n
=
1
N
(
y
(
n
)
−
y
^
(
n
)
)
=
−
1
N
s
u
m
(
y
−
y
^
)
\frac{\partial R(w,b)}{\partial b}=-\frac{1}{N}\sum_{n=1}^{N}(y^{(n)}-\hat{y}^{(n)})=-\frac{1}{N}sum(y-\hat{y})
∂b∂R(w,b)=−N1n=1∑N(y(n)−y^(n))=−N1sum(y−y^)
用代码实现:
class model_LR(op.Op):
def __init__(self, input_dim):
super(model_LR, self).__init__()
# 存放线性层参数
self.params = {}
# 将线性层的权重参数全部初始化为0
self.params['w'] = torch.zeros([input_dim, 1])
# self.params['w'] = paddle.normal(mean=0, std=0.01, shape=[input_dim, 1])
# 将线性层的偏置参数初始化为0
self.params['b'] = torch.zeros([1])
# 存放参数的梯度
self.grads = {}
self.X = None
self.outputs = None
def __call__(self, inputs):
return self.forward(inputs)
def forward(self, inputs):
self.X = inputs
# 线性计算
score = torch.matmul(inputs, self.params['w']) + self.params['b']
# Logistic 函数
self.outputs = logistic(score)
return self.outputs
def backward(self, labels):
N = labels.shape[0]
# 计算偏导数
self.grads['w'] = -1 / N * torch.matmul(self.X.t(), (labels - self.outputs))
self.grads['b'] = -1 / N * torch.sum(labels - self.outputs)
3.1.4.2 参数更新
在计算参数的梯度之后,我们按照下面公式更新参数:
w
←
w
−
α
∂
R
(
w
,
b
)
∂
w
w\overset{}{\leftarrow} w-\alpha \frac{\partial R(w,b)}{\partial w}
w←w−α∂w∂R(w,b)
b
←
b
−
α
∂
R
(
w
,
b
)
∂
b
b\overset{}{\leftarrow} b-\alpha \frac{\partial R(w,b)}{\partial b}
b←b−α∂b∂R(w,b)
其中α为学习率。
将上面的参数更新过程包装为优化器,定义一个类,命名为Optimizer,方便以后使用。代码如下:
from abc import abstractmethod
# 优化器基类
class Optimizer(object):
def __init__(self, init_lr, model):
# 初始化学习率,用于参数更新的计算
self.init_lr = init_lr
# 指定优化器需要优化的模型
self.model = model
@abstractmethod
def step(self):
pass
然后实现一个梯度下降法的优化器函数SimpleBatchGD来执行参数更新过程。其中step函数从模型的grads属性取出参数的梯度并更新。代码实现如下:
class SimpleBatchGD(Optimizer):
def __init__(self, init_lr, model):
super(SimpleBatchGD, self).__init__(init_lr=init_lr, model=model)
def step(self):
# 参数更新
# 遍历所有参数,按照公式更新参数
if isinstance(self.model.params, dict):
for key in self.model.params.keys():
self.model.params[key] = self.model.params[key] - self.init_lr * self.model.grads[key]
3.1.5 评价指标
在分类任务中,通常使用准确率(Accuracy)作为评价指标。即正确数据除以所有数据。
A
=
1
N
∑
n
=
1
N
I
(
y
(
n
)
=
y
^
(
n
)
)
A=\frac{1}{N}\sum_{n=1}^{N}I(y^{(n)}=\hat{y}^{(n)})
A=N1n=1∑NI(y(n)=y^(n))
函数代码如下:
def accuracy(preds, labels):
# 判断是二分类任务还是多分类任务,preds.shape[1]=1时为二分类任务,preds.shape[1]>1时为多分类任务
if preds.shape[1] == 1:
# 二分类时,判断每个概率值是否大于0.5,当大于0.5时,类别为1,否则类别为0
preds = torch.as_tensor((preds >= 0.5),dtype=torch.float32)
else:
# 多分类时,使用'torch.argmax'计算最大元素索引作为类别
preds = torch.argmax(preds, dim=1).int()
return torch.mean((preds == labels).float())
测试一下这段代码:
# 假设模型的预测值为[[0.],[1.],[1.],[0.]],真实类别为[[1.],[1.],[0.],[0.]],计算准确率
preds = torch.tensor([[0.], [1.], [1.], [0.]])
labels = torch.tensor([[1.], [1.], [0.], [0.]])
print("accuracy is:", accuracy(preds, labels))
运行结果如下:
易知第1位和第三位预测正确,即正确率为百分之五十。
3.1.6 完善Runner类
基于RunnerV1,本章的RunnerV2类在训练过程中使用梯度下降法进行网络优化,模型训练过程中计算在训练集和验证集上的损失及评估指标并打印,训练过程中保存最优模型。代码实现如下:
# 用RunnerV2类封装整个训练过程
class RunnerV2(object):
def __init__(self, model, optimizer, metric, loss_fn):
self.model = model
self.optimizer = optimizer
self.loss_fn = loss_fn
self.metric = metric
# 记录训练过程中的评价指标变化情况
self.train_scores = []
self.dev_scores = []
# 记录训练过程中的损失函数变化情况
self.train_loss = []
self.dev_loss = []
def train(self, train_set, dev_set, **kwargs):
# 传入训练轮数,如果没有传入值则默认为0
num_epochs = kwargs.get("num_epochs", 0)
# 传入log打印频率,如果没有传入值则默认为100
log_epochs = kwargs.get("log_epochs", 100)
# 传入模型保存路径,如果没有传入值则默认为"best_model.pdparams"
save_path = kwargs.get("save_path", "best_model.pdparams")
# 梯度打印函数,如果没有传入则默认为"None"
print_grads = kwargs.get("print_grads", None)
# 记录全局最优指标
best_score = 0
# 进行num_epochs轮训练
for epoch in range(num_epochs):
X, y = train_set
# 获取模型预测
logits = self.model(X)
# 计算交叉熵损失
trn_loss = self.loss_fn(logits, y).item()
self.train_loss.append(trn_loss)
# 计算评价指标
trn_score = self.metric(logits, y).item()
self.train_scores.append(trn_score)
# 计算参数梯度
self.model.backward(y)
if print_grads is not None:
# 打印每一层的梯度
print_grads(self.model)
# 更新模型参数
self.optimizer.step()
dev_score, dev_loss = self.evaluate(dev_set)
# 如果当前指标为最优指标,保存该模型
if dev_score > best_score:
self.save_model(save_path)
print(f"best accuracy performence has been updated: {best_score:.5f} --> {dev_score:.5f}")
best_score = dev_score
if epoch % log_epochs == 0:
print(f"[Train] epoch: {epoch}, loss: {trn_loss}, score: {trn_score}")
print(f"[Dev] epoch: {epoch}, loss: {dev_loss}, score: {dev_score}")
def evaluate(self, data_set):
X, y = data_set
# 计算模型输出
logits = self.model(X)
# 计算损失函数
loss = self.loss_fn(logits, y).item()
self.dev_loss.append(loss)
# 计算评价指标
score = self.metric(logits, y).item()
self.dev_scores.append(score)
return score, loss
def predict(self, X):
return self.model(X)
def save_model(self, save_path):
torch.save(self.model.params, save_path)
def load_model(self, model_path):
self.model.params = torch.load(model_path)
3.1.7 模型训练
Logistic回归模型的训练,使用交叉熵损失函数和梯度下降法进行优化。
使用训练集和验证集进行模型训练,共训练 500个epoch,每隔50个epoch打印出训练集上的指标。代码实现如下:
# 固定随机种子,保持每次运行结果一致
torch.manual_seed(102)
input_dim = 2 # 特征维度
lr = 0.1
# 实例化模型
model = model_LR(input_dim=input_dim)
# 指定优化器
optimizer = SimpleBatchGD(init_lr=lr, model=model)
# 指定损失函数
loss_fn = BinaryCrossEntropyLoss()
# 指定评价方式
metric = accuracy
# 实例化RunnerV2类,并传入训练配置
runner = RunnerV2(model, optimizer, metric, loss_fn)
runner.train([X_train, y_train], [X_dev, y_dev], num_epochs=500, log_epochs=50, save_path="best_model.pdparams")
运行结果如下:
可视化观察训练集与验证集的准确率和损失的变化情况。
# 可视化观察训练集与验证集的指标变化情况
def plot(runner,fig_name):
plt.figure(figsize=(10,5))
plt.subplot(1,2,1)
epochs = [i for i in range(len(runner.train_scores))]
# 绘制训练损失变化曲线
plt.plot(epochs, runner.train_loss, color='#e4007f', label="Train loss")
# 绘制评价损失变化曲线
plt.plot(epochs, runner.dev_loss, color='#f19ec2', linestyle='--', label="Dev loss")
# 绘制坐标轴和图例
plt.ylabel("loss", fontsize='large')
plt.xlabel("epoch", fontsize='large')
plt.legend(loc='upper right', fontsize='x-large')
plt.subplot(1,2,2)
# 绘制训练准确率变化曲线
plt.plot(epochs, runner.train_scores, color='#e4007f', label="Train accuracy")
# 绘制评价准确率变化曲线
plt.plot(epochs, runner.dev_scores, color='#f19ec2', linestyle='--', label="Dev accuracy")
# 绘制坐标轴和图例
plt.ylabel("score", fontsize='large')
plt.xlabel("epoch", fontsize='large')
plt.legend(loc='lower right', fontsize='x-large')
plt.tight_layout()
plt.savefig(fig_name)
plt.show()
plot(runner, fig_name='linear-acc.pdf')
运行结果如下:
从输出结果可以看到,在训练集与验证集上,loss得到了收敛,同时准确率指标都达到了较高的水平,训练比较充分。
3.1.8 模型评价
使用测试集对训练完成后的最终模型进行评价,观察模型在测试集上的准确率和loss数据。
代码如下:
score, loss = runner.evaluate([X_test, y_test])
print("[Test] score/loss: {:.4f}/{:.4f}".format(score, loss))
运行结果如下:
可视化观察拟合的决策边界 Xw+b=0。
代码如下:
def decision_boundary(w, b, x1):
w1, w2 = w
x2 = (- w1 * x1 - b) / w2
return x2
plt.figure(figsize=(5, 5))
# 绘制原始数据
plt.scatter(X[:, 0].tolist(), X[:, 1].tolist(), marker='*', c=y.tolist())
w = model.params['w']
b = model.params['b']
x1 = torch.linspace(-2, 3, 1000)
x2 = decision_boundary(w, b, x1)
# 绘制决策边界
plt.plot(x1.tolist(), x2.tolist(), color="red")
plt.show()
运行结果如下:
3.2 基于Softmax回归的多分类任务
Logistic回归可以有效地解决二分类问题。
但在分类任务中,还有一类多分类问题,即类别数C大于2 的分类问题。
Softmax回归就是Logistic回归在多分类问题上的推广。
3.2.1 数据集构建
数据来自3个不同的簇,每个簇对一个类别。我们采集1000条样本,每个样本包含2个特征。
代码如下:
import torch
import numpy as np
def make_multiclass_classification(n_samples=100, n_features=2, n_classes=3, shuffle=True, noise=0.1):
# 计算每个类别的样本数量
n_samples_per_class = [int(n_samples / n_classes) for k in range(n_classes)]
for i in range(n_samples - sum(n_samples_per_class)):
n_samples_per_class[i % n_classes] += 1
# 将特征和标签初始化为0
X = torch.zeros([n_samples, n_features])
y = torch.zeros([n_samples], dtype=torch.int32)
# 随机生成3个簇中心作为类别中心
centroids = torch.randperm(2 ** n_features)[:n_classes]
centroids_bin = np.unpackbits(centroids.numpy().astype('uint8')).reshape(-1, 8)[:, -n_features:]
centroids = torch.tensor(centroids_bin, dtype=torch.float32)
# 控制簇中心的分离程度
centroids = 1.5 * centroids - 1
# 随机生成特征值
X[:, :n_features] = torch.randn([n_samples, n_features])
stop = 0
# 将每个类的特征值控制在簇中心附近
for k, centroid in enumerate(centroids):
start, stop = stop, stop + n_samples_per_class[k]
# 指定标签值
y[start:stop] = k % n_classes
X_k = X[start:stop, :n_features]
# 控制每个类别特征值的分散程度
A = 2 * torch.rand([n_features, n_features]) - 1
X_k[...] = torch.matmul(X_k, A)
X_k += centroid
X[start:stop, :n_features] = X_k
# 如果noise不为None,则给特征加入噪声
if noise > 0.0:
# 生成noise掩膜,用来指定给那些样本加入噪声
noise_mask = torch.rand([n_samples]) < noise
for i in range(len(noise_mask)):
if noise_mask[i]:
# 给加噪声的样本随机赋标签值
y[i] = torch.randint(n_classes, size=[1])
# 如果shuffle为True,将所有数据打乱
if shuffle:
idx = torch.randperm(X.shape[0])
X = X[idx]
y = y[idx]
return X, y
通过matplotlib.pyplot可视化看一下生成的数据集:
# 固定随机种子,保持每次运行结果一致
torch.manual_seed(100)
# 采样1000个样本
n_samples = 1000
X, y = make_multiclass_classification(n_samples=n_samples, n_features=2, n_classes=3, noise=0.2)
# 可视化生产的数据集,不同颜色代表不同类别
plt.figure(figsize=(5, 5))
plt.scatter(x=X[:, 0].tolist(), y=X[:, 1].tolist(), marker='*', c=y.tolist())
plt.savefig('linear-dataset-vis2.pdf')
plt.show()
运行结果如下:
下面我们将数据分类,分成训练集、验证集和测试集。其中训练集640条、验证集160条、测试集200条。
num_train = 640
num_dev = 160
num_test = 200
X_train, y_train = X[:num_train], y[:num_train]
X_dev, y_dev = X[num_train:num_train + num_dev], y[num_train:num_train + num_dev]
X_test, y_test = X[num_train + num_dev:], y[num_train + num_dev:]
3.2.2 模型构建
3.2.2.1 Softmax函数
Softmax函数可以将多个标量映射为一个概率分布。对于一个K维向量, x = [ x 1 , x 2 . . . . . . x k ] x=[x_1,x_2......x_k] x=[x1,x2......xk],Softmax的计算公式为 : s o f t m a x ( x k ) = e x p ( x k ) ∑ i = 1 K e x p ( x i ) ( 3.11 ) softmax(x_k)=\frac{exp(x_k)}{\sum^{K}_{i=1}{exp(x_i)}} (3.11) softmax(xk)=∑i=1Kexp(xi)exp(xk)(3.11)
在Softmax函数的计算过程中,要注意上溢出和下溢出的问题。假设Softmax 函数中所有的都是相同大小的数值a,理论上,所有的输出都应该为。但需要考虑如下两种特殊情况:
- a为一个非常大的负数,此时exp(a) 会发生下溢出现象。计算机在进行数值计算时,当数值过小,会被四舍五入为0。此时,Softmax函数的分母会变为0,导致计算出现问题;
- a为一个非常大的正数,此时会导致exp(a)发生上溢出现象,导致计算出现问题。
为了解决上溢出和下溢出的问题,在计算Softmax函数时,可以使用−max(x)代替。 此时,通过减去最大值,最大为0,避免了上溢出的问题;同时,分母中至少会包含一个值为1的项,从而也避免了下溢出的问题。
def softmax(X):
x_max = torch.max(X, dim=1, keepdim=True)
x_exp = torch.exp(X - x_max.values)
partition = torch.sum(x_exp, dim=1, keepdim=True)
return x_exp / partition
# 测试一下
X = torch.tensor([[0.1, 0.2, 0.3, 0.4], [1, 2, 3, 4]])
predict = softmax(X)
print(predict)
运行结果:
3.2.2.2 Softmax回归算子
在Softmax回归中,类别标签y∈{1,2,…,C}。给定一个样本x,使用Softmax回归预测的属于类别c的条件概率为
p
(
y
=
c
∣
x
)
=
s
o
f
t
m
a
x
(
w
c
T
x
+
b
c
)
,(
3.12
)
p(y=c|x)=softmax(w ^T_cx+b_c),(3.12)
p(y=c∣x)=softmax(wcTx+bc),(3.12)
其中
w
c
w_c
wc是第 c 类的权重向量,
b
c
b_c
bc是第 c 类的偏置。
Softmax回归模型其实就是线性函数与Softmax函数的组合。
将N个样本归为一组进行成批地预测。
Y
=
s
o
f
t
m
a
x
(
X
W
+
b
)
,
(
3.13
)
Y^=softmax(XW+b),(3.13)
Y=softmax(XW+b),(3.13)
其中
X
∈
R
N
×
D
X∈\mathbb{R}^{N×D}
X∈RN×D为N个样本的特征矩阵,W=[
w
1
w_1
w1,……,
w
c
w_c
wc]为C个类的权重向量组成的矩阵,
Y
^
∈
R
C
\hat{Y}∈\mathbb{R}^C
Y^∈RC为所有类别的预测条件概率组成的矩阵。
我们根据公式(3.13)实现Softmax回归算子,代码实现如下:
from nndl import op
class model_SR(op.Op):
def __init__(self, input_dim, output_dim):
super(model_SR, self).__init__()
self.params = {}
# 将线性层的权重参数全部初始化为0
self.params['W'] = torch.zeros([input_dim, output_dim])
# self.params['W'] = paddle.normal(mean=0, std=0.01, shape=[input_dim, output_dim])
# 将线性层的偏置参数初始化为0
self.params['b'] = torch.zeros([output_dim])
self.outputs = None
def __call__(self, inputs):
return self.forward(inputs)
def forward(self, inputs):
# 线性计算
score = torch.matmul(inputs, self.params['W']) + self.params['b']
# Softmax 函数
self.outputs = softmax(score)
return self.outputs
# 测试一下
# 随机生成1条长度为4的数据
inputs = torch.randn([1, 4])
print('Input is:', inputs)
# 实例化模型,这里令输入长度为4,输出类别数为3
model = model_SR(input_dim=4, output_dim=3)
outputs = model(inputs)
print('Output is:', outputs)
运行结果如下:
从输出结果可以看出,采用全0初始化后,属于每个类别的条件概率均为
1
C
\frac{1}{C}
C1。这是因为,不论输入值的大小为多少,线性函数
f
(
x
,
W
,
b
)
f(x,W,b)
f(x,W,b)的输出值恒为0。此时,再经过Softmax函数的处理,每个类别的条件概率恒等。
3.2.3 损失函数
Softmax回归同样使用交叉熵损失作为损失函数,并使用梯度下降法对参数进行优化。通常使用C维的one-hot类型向量y∈{0,1}C来表示多分类任务中的类别标签。对于类别c,其向量表示为:
y
=
[
I
(
1
=
c
)
,
I
(
2
=
c
)
,
…
,
I
(
C
=
c
)
]
T
,(
3.14
)
y=[I(1=c),I(2=c),…,I(C=c)]^T,(3.14)
y=[I(1=c),I(2=c),…,I(C=c)]T,(3.14)
其中
I
(
⋅
)
I(⋅)
I(⋅)是指示函数,即括号内的输入为“真”,
I
(
⋅
)
=
1
I(⋅)=1
I(⋅)=1;否则,
I
(
⋅
)
=
0
I(⋅)=0
I(⋅)=0。
给定有 N N N个训练样本的训练集 ( x ( n ) , y ( n ) ) n = 1 N {(x(n),y(n))}^N_{n=1} (x(n),y(n))n=1N,令 y ^ ( n ) = s o f t m a x ( W T x ( n ) + b ) \hat{y}^{(n)}=softmax(W^Tx^{(n)}+b) y^(n)=softmax(WTx(n)+b)为样本 x ( n ) x^{(n)} x(n)在每个类别的后验概率。多分类问题的交叉熵损失函数定义为:
R
(
W
,
b
)
=
−
1
N
∑
n
=
1
N
(
y
(
n
)
)
T
l
o
g
y
^
(
n
)
=
−
1
N
∑
n
=
1
N
∑
c
=
1
C
y
c
(
n
)
l
o
g
y
^
c
(
n
)
.
(
3.15
)
R(W,b)=−\frac{1}{N}\sum_{n=1}^N(y^{(n)})^Tlog\hat{y}^{(n)}=−\frac{1}{N}\sum_{n=1}^N\sum_{c=1}^Cy^{(n)}_clog\hat{y}^{(n)}_c.(3.15)
R(W,b)=−N1n=1∑N(y(n))Tlogy^(n)=−N1n=1∑Nc=1∑Cyc(n)logy^c(n).(3.15)
观察上式,y(n)c在c为真实类别时为1,其余都为0。也就是说,交叉熵损失只关心正确类别的预测概率,因此,上式又可以优化为:
R
(
W
,
b
)
=
−
1
N
∑
n
=
1
N
l
o
g
[
y
^
(
n
)
]
y
(
n
)
,
(
3.16
)
R(W,b)=−\frac{1}{N}\sum_{n=1}^Nlog[\hat{y}^{(n)}]_{y^{(n)}},(3.16)
R(W,b)=−N1n=1∑Nlog[y^(n)]y(n),(3.16)
其中
y
(
n
)
y^{(n)}
y(n)是第n个样本的标签。
因此,多类交叉熵损失函数的代码实现如下:
class MultiCrossEntropyLoss(op.Op):
def __init__(self):
self.predicts = None
self.labels = None
self.num = None
def __call__(self, predicts, labels):
return self.forward(predicts, labels)
def forward(self, predicts, labels):
"""
输入:
- predicts:预测值,shape=[N, 1],N为样本数量
- labels:真实标签,shape=[N, 1]
输出:
- 损失值:shape=[1]
"""
self.predicts = predicts
self.labels = labels
self.num = self.predicts.shape[0]
loss = 0
for i in range(0, self.num):
index = self.labels[i]
loss -= torch.log(self.predicts[i][index])
return loss / self.num
# 假设真实标签为第1类
labels = torch.tensor([0])
# 计算风险函数
mce_loss = MultiCrossEntropyLoss()
print(mce_loss(outputs, labels))
运行结果如下:
3.2.4 模型优化
使用梯度下降法进行参数学习。
3.2.4.1 梯度计算
计算风险函数R(W,b)关于参数W和b的偏导数。在Softmax回归中,计算方法为:
∂
R
(
W
,
b
)
∂
W
=
−
1
N
∑
n
=
1
N
x
(
n
)
(
y
(
n
)
−
y
^
(
n
)
)
T
=
−
1
N
X
T
(
y
−
y
^
)
,
(
3.17
)
\frac{∂R(W,b)}{∂W}=-\frac{1}{N}∑_{n=1}^{N}x^{(n)}(y^{(n)}−\hat{y}^{(n)})^T=−\frac{1}{N}X^T(y−\hat{y}),(3.17)
∂W∂R(W,b)=−N1n=1∑Nx(n)(y(n)−y^(n))T=−N1XT(y−y^),(3.17)
∂
R
(
W
,
b
)
∂
W
=
−
1
N
∑
n
=
1
N
(
y
(
n
)
−
y
^
(
n
)
)
T
=
−
1
N
(
y
−
y
^
)
,
(
3.17
)
\frac{∂R(W,b)}{∂W}=-\frac{1}{N}∑_{n=1}^{N}(y^{(n)}−\hat{y}^{(n)})^T=−\frac{1}{N}(y−\hat{y}),(3.17)
∂W∂R(W,b)=−N1n=1∑N(y(n)−y^(n))T=−N1(y−y^),(3.17)
其中
X
∈
R
N
×
D
X∈\mathbb{R}^{N×D}
X∈RN×D为N个样本组成的矩阵,
y
∈
R
N
y∈\mathbb{R}^N
y∈RN为N个样本标签组成的向量,
y
^
∈
R
N
\hat{y}∈\mathbb{R}^N
y^∈RN为N个样本的预测标签组成的向量,1为N维的全1向量。
将上述计算方法定义在模型的backward函数中,代码实现如下:
class model_SR(op.Op):
def __init__(self, input_dim, output_dim):
super(model_SR, self).__init__()
self.params = {}
# 将线性层的权重参数全部初始化为0
self.params['W'] = torch.zeros([input_dim, output_dim])
# 将线性层的偏置参数初始化为0
self.params['b'] = torch.zeros([output_dim])
# 存放参数的梯度
self.grads = {}
self.X = None
self.outputs = None
self.output_dim = output_dim
def __call__(self, inputs):
return self.forward(inputs)
def forward(self, inputs):
self.X = inputs
# 线性计算
score = torch.matmul(self.X, self.params['W']) + self.params['b']
# Softmax 函数
self.outputs = softmax(score)
return self.outputs
def backward(self, labels):
# 计算偏导数
labels = labels.long()
N =labels.shape[0]
labels = torch.nn.functional.one_hot(labels, self.output_dim)
self.grads['W'] = -1 / N * torch.matmul(self.X.t(), (labels-self.outputs))
self.grads['b'] = -1 / N * torch.matmul(torch.ones([N]), (labels-self.outputs))
3.2.4.2 参数更新
在计算参数的梯度之后,我们使用3.1.4.2中实现的梯度下降法进行参数更新。
3.2.5 模型训练
实例化RunnerV2类,并传入训练配置。使用训练集和验证集进行模型训练,共训练500个epoch。每隔50个epoch打印训练集上的指标。代码实现如下:
torch.manual_seed(102)
input_dim = 2 # 特征维度
output_dim = 3 # 类别数
lr = 0.1
# 实例化模型
model = model_SR(input_dim=input_dim, output_dim=output_dim)
# 指定优化器
optimizer = SimpleBatchGD(init_lr=lr, model=model)
# 指定损失函数
loss_fn = MultiCrossEntropyLoss()
# 指定评价方式
metric = accuracy
# 实例化RunnerV2类
runner = RunnerV2(model, optimizer, metric, loss_fn)
# 模型训练
runner.train([X_train, y_train], [X_dev, y_dev], num_epochs=500, log_eopchs=50, eval_epochs=1, save_path="best_model.pdparams")
# 可视化观察训练集与验证集的准确率变化情况
plot(runner,fig_name='linear-acc2.pdf')
运行结果如下:
3.2.6 模型评价
使用测试集对训练完成后的最终模型进行评价,观察模型在测试集上的准确率。
score, loss = runner.evaluate([X_test, y_test])
print("[Test] score/loss: {:.4f}/{:.4f}".format(score, loss))
可视化观察类别划分结果。
# 均匀生成40000个数据点
x1, x2 = torch.meshgrid(torch.linspace(-3.5, 2, 200), torch.linspace(-4.5, 3.5, 200), indexing='xy')
x = torch.stack([torch.flatten(x1), torch.flatten(x2)], dim=1)
# 预测对应类别
y = runner.predict(x)
y = torch.argmax(y, dim=1)
# 绘制类别区域
plt.ylabel('x2')
plt.xlabel('x1')
plt.scatter(x[:, 0].tolist(), x[:, 1].tolist(), c=y.tolist(), cmap=plt.cm.Spectral)
torch.manual_seed(102)
n_samples = 1000
X, y = make_multiclass_classification(n_samples=n_samples, n_features=2, n_classes=3, noise=0.2)
plt.scatter(X[:, 0].tolist(), X[:, 1].tolist(), marker='*', c=y.tolist())
plt.show()
运行结果如下:
注:提前停止是在使用梯度下降法进行模型优化时常用的正则化方法。对于某些拟合能力非常强的机器学习算法,当训练轮数较多时,容易发生过拟合现象。为了解决这一问题,通常会在模型优化时,使用验证集上的错误代替期望错误。当验证集上的错误率不在下降时,就停止迭代。
3.3 实践:基于Softmax回归完成鸢尾花分类任务
实践流程主要包括以下7个步骤:数据处理、模型构建、损失函数定义、优化器构建、模型训练、模型评价和模型预测等。
- 数据处理:根据网络接收的数据格式,完成相应的预处理操作,保证模型正常读取;
- 模型构建:定义Softmax回归模型类;
- 训练配置:训练相关的一些配置,如:优化算法、评价指标等;
- 组装Runner类:Runner用于管理模型训练和测试过程;
- 模型训练和测试:利用Runner进行模型训练、评价和测试。
主要配置如下:
- 数据:Iris数据集;
- 模型:Softmax回归模型;
- 损失函数:交叉熵损失;
- 优化器:梯度下降法;
- 评价指标:准确率。
3.3.1 数据处理
3.3.1.1 数据集介绍
Iris数据集,也称为鸢尾花数据集,包含了3种鸢尾花类别(Setosa、Versicolour、Virginica),每种类别有50个样本,共计150个样本。其中每个样本中包含了4个属性:花萼长度、花萼宽度、花瓣长度以及花瓣宽度,本实验通过鸢尾花这4个属性来判断该样本的类别。
3.1.1.2 数据清洗
- 缺失值分析:对数据集中的缺失值或异常值等情况进行分析和处理,保证数据可以被模型正常读取。代码实现如下:
import pandas
import numpy as np
from sklearn.datasets import load_iris
iris_features = np.array(load_iris().data, dtype=np.float32)
iris_labels = np.array(load_iris().target, dtype=np.int32)
print(pandas.isna(iris_features).sum())
print(pandas.isna(iris_labels).sum())
输出结果为0,由此可知鸢尾花数据集中不存在缺失值的情况。
- 异常值处理:通过箱线图直观的显示数据分布,并观测数据中的异常值。
import matplotlib.pyplot as plt
def boxplot(features):
feature_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']
plt.figure(figsize=(5, 5), dpi=200)
plt.subplots_adjust(wspace=0.6)
for i in range(4): # 每个特征画一个箱线图
plt.subplot(2, 2, i+1)
# 画箱线图
plt.boxplot(features[:, i],
showmeans=True,
whiskerprops={"color": "#E20079", "linewidth": 0.4, 'linestyle': "--"},
flierprops={"markersize": 0.4},
meanprops={"markersize": 1})
plt.title(feature_names[i], fontdict={"size": 5}, pad=2)
# y方向刻度
plt.yticks(fontsize=4, rotation=90)
plt.tick_params(pad=0.5)
# x方向刻度
plt.xticks([])
plt.savefig('ml-vis.pdf')
plt.show()
boxplot(iris_features)
运行结果如下:
从输出结果看,数据中基本不存在异常值,不需要进行异常值处理。
3.3.1.3 数据读取
本实验中将数据集划分为了三个部分:
- 训练集:用于确定模型参数;
- 验证集:与训练集独立的样本集合,用于使用提前停止策略选择最优模型;
- 测试集:用于估计应用效果。
在本实验中,将80%的数据用于模型训练,10%的数据用于模型验证,10%的数据用于模型测试。代码实现如下:
import torch
# 加载数据集
def load_data(shuffle=True):
# 加载原始数据
X = np.array(load_iris().data, dtype=np.float32)
y = np.array(load_iris().target, dtype=np.int32)
X = torch.tensor(X)
y = torch.tensor(y)
# 数据归一化
X_min = torch.min(X, dim=0)
X_max = torch.max(X, dim=0)
X = (X-X_min.values) / (X_max.values-X_min.values)
# 如果shuffle为True,随机打乱数据
if shuffle:
idx = torch.randperm(X.shape[0])
X = X[idx]
y = y[idx]
return X, y
torch.manual_seed(102)
num_train = 120
num_dev = 15
num_test = 15
X, y = load_data(shuffle=True)
print("X shape: ", X.shape, "y shape: ", y.shape)
X_train, y_train = X[:num_train], y[:num_train]
X_dev, y_dev = X[num_train:num_train + num_dev], y[num_train:num_train + num_dev]
X_test, y_test = X[num_train + num_dev:], y[num_train + num_dev:]
# 打印X_train和y_train的维度
print("X_train shape: ", X_train.shape, "y_train shape: ", y_train.shape)
# 打印前5个数据的标签
print(y_train[:5])
输出结果如下:
3.3.2 模型评价
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate([X_test, y_test])
print("[Test] score/loss: {:.4f}/{:.4f}".format(score, loss))
运行结果如下:
3.3.3 模型预测
# 预测测试集数据
logits = runner.predict(X_test)
# 观察其中一条样本的预测结果
pred = torch.argmax(logits[0]).numpy()
print("pred:",pred)
# 获取该样本概率最大的类别
label = y_test[0].numpy()
print("label:",label)
# 输出真实类别与预测类别
print("The true category is {0} and the predicted category is {1}".format(label, pred))
运行结果如下:
问题解答
这里回答一下老师布置的问题:
问题1:Logistic回归在不同的书籍中,有许多其他的称呼,具体有哪些?你认为哪个称呼最好?
对数几率回归、逻辑斯蒂回归、逻辑回归等。我认为逻辑回归这个称呼最好,因为logistic直译过来就有逻辑的意思,又是其音译过来的前两个音。相对于另外的,逻辑回归更加简洁。
问题2:什么是激活函数?为什么要用激活函数?常见激活函数有哪些?
- 激活函数就是加入到人工神经网络中的一个函数,目的在于帮助神经网络从数据中学习复杂模式。相比于人类大脑中基于神经元的模型,激活函数是决定向下一个神经元传递何种信息的单元,这也正是激活函数在人工神经网络中的作用。激活函数接收前一个单元输出的信号,并将其转换成某种可以被下一个单元接收的形式。
- 激活函数有助于我们根据要求将神经元的输出值限定在一定的范围内。如果输出值不被限定在某个范围内,它可能会变得非常大,特别是在具有数百万个参数的深层神经网络中,从而导致计算量过大。例如,有一些激活函数对于不同的输入值(0 或1) 会输出特定的值。激活函数最重要的特点是它具有在神经网络中加入非线性的能力,为了使模型能够学习非线性模式(或者说具有更高的复杂度),特定的非线性层(激活函数)被加入其中。
- 现在常用到的激活函数有Relu、sigmoid、tanh等。
问题三:Logistic函数是激活函数。Softmax函数是激活函数么?谈谈你的看法。
是。
心得体会
我收回上次作业中说公式很简单的话,这次作业比预想的花费时间长了好多,不光是编写公式时总是看错位置,也因为一些函数理解的不充分。而且确实很有难度。不写了,滚去写其他作业了。诸多纰漏,望海涵。
参考资料
【神经网络】激活函数softmax,sigmoid,tanh,relu总结
美!最常用的10个激活函数!
进阶torch:torch.stack和torch.cat + one-hot报错