实验任务:基于前馈神经网络的二分类任务
1数据集构建
导入nndl包中的make_moons函数创建数据集,随机生成1000个样本(n_samples),并将全部数据进行打乱(shuffle=True),考虑是否在数据集中加入噪声(noise)。
将1000个样本划分为训练集、验证集和测试集,并进行可视化。
# 采样1000个样本
n_samples = 1000
X, y = make_moons(n_samples=n_samples, shuffle=True, noise=0.2)
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])
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()
2 模型构建
为了更高效的构建前馈神经网络,我们先定义每一层的算子,然后再通过算子组合构建整个前馈神经网络。
- 线性层算子
class Linear(Op):
def __init__(self, input_size, output_size, name, weight_init=torch.rand, bias_init=torch.zeros):
self.params = {}
self.params['W'] = weight_init(size=[input_size, output_size])
self.params['b'] = bias_init(size=[1, output_size])
self.inputs = None
self.grads = {}
self.name = name
def forward(self, inputs):
self.inputs = inputs
outputs = torch.matmul(self.inputs, self.params['W'].double()) + self.params['b'].double()
return outputs
初始化方法(__init__),接收到的input_size表示模型需要处理的数据的维度,output_size表示输出的数据的维度,weight_init表示权重初始化的方法(默认使用的是标准正态分布初始化),bias_init表示的是偏置初始化方式(默认使用0初始化)。
前向传播方法(forward),接受到的是形状为[N,input_size](N为样本数量)的张量,
outputs = torch.matmul(self.inputs, self.params['W'].double()) + self.params['b'].double()实现Z(l)=A(l-1)W(l)+b(l)得到该层神经元的净活性值。
2.Logistic算子
class Logistic(Op):
def __init__(self):
self.inputs = None
self.outputs = None
self.params = None
def forward(self, inputs):
outputs = 1.0 / (1.0 + torch.exp(-inputs))
self.outputs = outputs
return outputs
如上图所示,前向传播方法(forward)中利用outputs = 1.0 / (1.0 + torch.exp(-inputs))实现了Logistic函数得到该层活性值
3.串行组合
# 实现一个两层前馈神经网络
class Model_MLP_L2(Op):
def __init__(self, input_size, hidden_size, output_size):
# 线性层
self.fc1 = Linear(input_size, hidden_size, name="fc1")
# Logistic激活函数层
self.act_fn1 = Logistic()
self.fc2 = Linear(hidden_size, output_size, name="fc2")
self.act_fn2 = Logistic()
self.layers = [self.fc1, self.act_fn1, self.fc2, self.act_fn2]
def __call__(self, X):
return self.forward(X)
# 前向计算
def forward(self, X):
z1 = self.fc1(X)
a1 = self.act_fn1(z1)
z2 = self.fc2(a1)
a2 = self.act_fn2(z2)
return a2
将上述定义的Linear算子和Logistic算子交叉重复使用来构建成多层的神经网络,如下图所示:
在前向传播算法(forward)中进行调用实现整个模型:
- 实例化测试
model = Model_MLP_L2(input_size=5, hidden_size=10, output_size=1)
# 随机生成1条长度为5的数据
X = torch.rand([1, 5])
result = model(X)
print ("result: ", result)
设置超参数输入层维度(input_size=5)、隐藏层维度(hidden_size=10)和输出层维度(output_size=1)
3 损失函数
# 实现交叉熵损失函数
class BinaryCrossEntropyLoss(Op):
def __init__(self, model):
self.predicts = None
self.labels = None
self.num = None
self.model = model
def __call__(self, predicts, labels):
return self.forward(predicts, labels)
def forward(self, predicts, labels):
self.predicts = predicts
self.labels = labels
self.labels = self.labels.float()
self.predicts = self.predicts.float()
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, axis=1)
return loss
实现交叉熵损失函数模型:
其原理为:在给定y的情况下,如果预测的概率分布^y与标签真是的分布y越接近,则交叉熵越小;如果^y和y越远,交叉熵越大。
程序中由Loss = -1. / self.num * (torch.matmul(self.labels.t(), torch.log(self.predicts)) + torch.matmul((1 - self.labels.t()), torch.log(1 - self.predicts)))求得损失值。
4 模型优化
神经网络的层数通常比较深,其梯度计算和上一章中的线性分类模型的不同的点在于:
线性模型通常比较简单可以直接计算梯度,而神经网络相当于一个复合函数,需要利用链式法则进行反向传播来计算梯度。
(1)反向传播算法
第1步是前向计算,可以利用算子的forward()方法来实现;
第2步是反向计算梯度,可以利用算子的backward()方法来实现;
第3步中的计算参数梯度也放到backward()中实现,更新参数放到另外的优化器中专门进行。
(2)损失函数
class BinaryCrossEntropyLoss(Op):
def __init__(self, model):
self.predicts = None
self.labels = None
self.num = None
self.model = model
def __call__(self, predicts, labels):
return self.forward(predicts, labels)
def forward(self, predicts, labels):
self.predicts = predicts
self.labels = labels
self.labels = self.labels.float()
self.predicts = self.predicts.float()
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, axis=1)
return loss
def backward(self):
# 计算损失函数对模型预测的导数
loss_grad_predicts = -1.0 * (self.labels / self.predicts -
(1 - self.labels) / (1 - self.predicts)) / self.num
# 梯度反向传播
self.model.backward(loss_grad_predicts)
二分类交叉熵损失函数对神经网络的输出^y的偏导数为:
反向传播算法(backward)中由loss_grad_predicts = -1.0 * (self.labels / self.predicts -(1 - self.labels) / (1 - self.predicts)) / self.num实现得到损失函数对模型预测的导数
(3)Logistic算子
class Logistic(Op):
def __init__(self):
self.inputs = None
self.outputs = None
self.params = None
def forward(self, inputs):
outputs = 1.0 / (1.0 + torch.exp(-inputs))
self.outputs = outputs
return outputs
def backward(self, grads):
# 计算Logistic激活函数对输入的导数
outputs_grad_inputs = torch.multiply(self.outputs, (1.0 - self.outputs))
return torch.multiply(grads,outputs_grad_inputs)
为Logistic算子增加反向函数(backward);
outputs_grad_inputs = torch.multiply(self.outputs, (1.0 - self.outputs))计算得到Logistic激活函数对输入的导数
(4)线性层
class Linear(Op):
def __init__(self, input_size, output_size, name, weight_init=torch.rand, bias_init=torch.zeros):
self.params = {}
self.params['W'] = weight_init(size=[input_size, output_size])
self.params['b'] = bias_init(size=[1, output_size])
self.inputs = None
self.grads = {}
self.name = name
def forward(self, inputs):
self.inputs = inputs
outputs = torch.matmul(self.inputs, self.params['W'].double()) + self.params['b'].double()
return outputs
def backward(self, grads):
"""
输入:
- grads:损失函数对当前层输出的导数
输出:
- 损失函数对当前层输入的导数
"""
self.grads['W'] = torch.matmul(self.inputs.T, grads)
self.grads['b'] = torch.sum(grads, dim=0)
# 线性层输入的梯度
self.params['W'] = self.params['W'].double()
return torch.matmul(grads, self.params['W'].T)
线性层输入的梯度:
计算线性层参数的梯度:
(5)整个网络
实现完整的两层神经网络的前向和反向计算
在上述代码基础之上添加反向传播算法(backward)依次计算其梯度:
# 反向计算
def backward(self, loss_grad_a2):
loss_grad_z2 = self.act_fn2.backward(loss_grad_a2)
loss_grad_a1 = self.fc2.backward(loss_grad_z2)
loss_grad_z1 = self.act_fn1.backward(loss_grad_a1)
loss_grad_inputs = self.fc1.backward(loss_grad_z1)
(6)优化器
from nndl.opitimizer import Optimizer
class BatchGD(Optimizer):
def __init__(self, init_lr, model):
super(BatchGD, self).__init__(init_lr=init_lr, model=model)
def step(self):
# 参数更新
for layer in self.model.layers: # 遍历所有层
if isinstance(layer.params, dict):
for key in layer.params.keys():
layer.params[key] = layer.params[key] - self.init_lr * layer.grads[key]
在计算好神经网络参数的梯度之后,我们将梯度下降法中参数的更新过程实现在优化器中。与第3章中实现的梯度下降优化器SimpleBatchGD不同的是,此处的优化器需要遍历每层,对每层的参数分别做更新。
该段代码定义了继承自Optimizer基类的BatchGD(优化器的一种特定形式),super(BatchGD, self).__init__(init_lr=init_lr, model=model)使用super()函数调用父类(Optimizer)的构造函数,在创建一个BatchGD的实例时,会同时调用Optimizer的构造函数,并使用传递的参数(init_lr和model)进行初始化。
layer.params[key] = layer.params[key] - self.init_lr * layer.grads[key]使用梯度下降算法来更新当前层的参数,每个参数减去学习率(self.init_lr)乘以该参数的梯度(layer.grads[key]),通过遍历所有参数并使用该公式进行更新。
5 完善Runner类:RunnerV2_1
import os
class RunnerV2_1(object):
def __init__(self, model, optimizer, metric, loss_fn, **kwargs):
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)
# 传入模型保存路径
save_dir = kwargs.get("save_dir", 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) # return a tensor
self.train_loss.append(trn_loss.item())
# 计算评估指标
trn_score = self.metric(logits, y).item()
self.train_scores.append(trn_score)
self.loss_fn.backward()
# 参数更新
self.optimizer.step()
dev_score, dev_loss = self.evaluate(dev_set)
# 如果当前指标为最优指标,保存该模型
if dev_score > best_score:
print(f"[Evaluate] best accuracy performence has been updated: {best_score:.5f} --> {dev_score:.5f}")
best_score = dev_score
if save_dir:
self.save_model(save_dir)
if log_epochs and epoch % log_epochs == 0:
print(f"[Train] epoch: {epoch}/{num_epochs}, loss: {trn_loss.item()}")
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_dir):
# 对模型每层参数分别进行保存,保存文件名称与该层名称相同
for layer in self.model.layers: # 遍历所有层
if isinstance(layer.params, dict):
torch.save(layer.params, os.path.join(save_dir, layer.name + ".pdparams"))
def load_model(self, model_dir):
# 获取所有层参数名称和保存路径之间的对应关系
model_file_names = os.listdir(model_dir)
name_file_dict = {}
for file_name in model_file_names:
name = file_name.replace(".pdparams", "")
name_file_dict[name] = os.path.join(model_dir, file_name)
# 加载每层参数
for layer in self.model.layers: # 遍历所有层
if isinstance(layer.params, dict):
name = layer.name
file_path = name_file_dict[name]
layer.params = torch.load(file_path)
Train:传入训练轮数(num_epochs)和日志打印频率(log_epochs)。在循环中根据num_epochs指定的次数进行训练,在每个训练轮次中该方法从train_set获取训练数据x和标签y,使用指定模型(self.model)对数据进行预测并计算交叉熵损失,调用self.loss_fn.backward()从损失函数开始反向计算梯度。
每层的模型保存和加载,将每一层的参数分别进行保存和加载,在训练过程记录全局最优指标。
6 模型训练
torch.random.manual_seed(123)
epoch_num = 2000
model_saved_dir = "D:\\xiazai"
# 输入层维度为2
input_size = 2
# 隐藏层维度为5
hidden_size = 5
# 输出层维度为1
output_size = 1
# 定义网络
model = Model_MLP_L2(input_size=input_size, hidden_size=hidden_size, output_size=output_size)
# 损失函数
loss_fn = BinaryCrossEntropyLoss(model)
# 优化器
learning_rate = 0.2
optimizer = BatchGD(learning_rate, model)
# 评价方法
metric = accuracy
# 实例化RunnerV2_1类,并传入训练配置
runner = RunnerV2_1(model, optimizer, metric, loss_fn)
runner.train([X_train, y_train], [X_dev, y_dev], num_epochs=epoch_num, log_epochs=50, save_dir=model_saved_dir)
# 打印训练集和验证集的损失
plt.figure()
plt.plot(range(epoch_num), runner.train_loss, color="#e4007f", label="Train loss")
plt.plot(range(epoch_num), runner.dev_loss, color="#f19ec2", linestyle='--', label="Dev loss")
plt.xlabel("epoch", fontsize='large')
plt.ylabel("loss", fontsize='large')
plt.legend(fontsize='x-large')
plt.show()
使用训练集和验证集进行模型训练,共训练2000个epoch。评价指标为accuracy。
Accuracy函数内部根据传入的preds的形状判断是二分类任务还是多分类任务。如果是二分类任务,通过怕那段每个概率值是否大于0.5划分类别。若是多分类任务通过torch.argmax()计算每个样本的最大概率对应的类别作为预测类别。
函数计算准确率的方法是去预测类别与真实类别相等的样本数占总样本数的比例。
7 性能评价
#加载训练好的模型
runner.load_model(model_saved_dir)
# 在测试集上对模型进行评价
score, loss = runner.evaluate([X_test, y_test])
print("[Test] score/loss: {:.4f}/{:.4f}".format(score, loss))
使用测试集对训练中的最优模型进行评价,观察模型的评价指标。
【思考题】
对比“基于Logistic回归的二分类任务”与“基于前馈神经网络的二分类任务”,谈谈自己的看法。
主要区别在于所用模型的结构和所依赖的数学原理
- Logistic回归
Logistic回归时利用sigmoid函数将线性回归的结果压缩到(0,1)的区间上,当特征维度较高时,可能会遇到过拟合问题,不能处理非线性问题。
2.前馈神经网络
前馈神经网络通过构建多层神经网络来模拟人脑的学习过程,可以有效处理非线性问题,可以有效处理高纬度的数据,通过调整层数和神经元数量可以控制模型的复杂度。
前馈神经网络相较于Logistic回归需要更长的训练时间,可能会遇到梯度消失或梯度爆炸的问题。
综上所述,在二分类问题选择算法时可以根据所要处理数据的特点来选择。