NNDL实验五前馈神经网络-二分类任务

前言:附上kaggle连接,前馈神经网络实验,这次实验是使用Pytorch做神经网络,已经通过学习对前馈神经网络进行进一步的了解。这次实验主要是神经网络和对其进行二分类任务。大体思路是利用模型的构建步骤,构建数据集=>构建模型=>损失函数=>模型优化=>模型训练=>模型评估=>保存模型进行模型训练。

4.1神经元

神经网络的基本组成单元为带有非线性激活函数的神经元,其结构如如图4.2所示。神经元是对生物神经元的结构和特性的一种简化建模,接收一组输入信号并产生输出。
在这里插入图片描述

4.1.1 净活性值

假设一个神经元接收的输入为 x ∈ R D x\in R^{D} xRD,其权重向量为 w ∈ R D w\in R^{D} wRD,神经元所获得的输入信号,即净活性值z的计算方法为
z = w T x + b ( 4.1 ) z=w^{T}x+b (4.1) z=wTx+b(4.1)
其中 b b b为偏置。
为了提高预测样本的效率,我们通常会将个样本归为一组进行成批地预测。
z = X w + b ( 4.2 ) z=Xw+b (4.2) z=Xw+b(4.2)
其中 X ∈ R N × D X\in R^{N \times D} XRN×D N N N个样本的特征矩阵, z ∈ R N z\in R^{N} zRN N N N个预测值组成的列向量。
使用pytorch计算一组输入的净活性值,代码如下:

import torch

# 2个特征数为5的样本
X = torch.rand(size=[2, 5])

# 含有5个参数的权重向量
w = torch.rand(size=[5, 1])
# 偏置项
b = torch.rand(size=[1, 1])

# 使用'torch.matmul'实现矩阵相乘
z = torch.matmul(X, w) + b
print("input X:", X)
print("weight w:", w, "\nbias b:", b)
print("output z:", z)

在这里插入图片描述

【思考题】加权求和与仿射变换之间有什么区别和联系?

在这里插入图片描述在这里插入图片描述

答: 根据我的理解和查阅资料可知,可能存在误解,欢迎指正
加权和:相当于降维的一种,即将多维数据根据其重要性进行求和降成一维数据。
仿射变换:又称仿射映射,是指在几何中,一个向量空间进行一次线性变换并接上一个平移,变换为另一个向量空间。
联系:虽然两种解释是不同的,但是通过公式我们可以发现二者的计算过程是一样的。我认为加权和只是仿射变换的一维形式。即二维矩阵的第一个维度为一的情况下的仿射变换为加权和。就像深度学习中而利用神经网络进行二分类学习,最后一层隐藏层神经元和输出层神经元的关系一样。既可以当成加权和又可以当成仿射变换。
区别:加权和是一维数据进行处理。仿射变换则是二维数据,利用矩阵计算进行处理,在深度学习中的体现,加权和最终计算的是一个神经元的输入,仿射变换则对应的是一层神经元的输入。

拓展:仿射变化在图像变换中的应用:
仿射变换在图形中的变换包括:平移、缩放、旋转、斜切及它们的组合形式。这些变换的特点是:平行关系和线段的长度比例保持不变。
在这里插入图片描述
矩阵公式:

平移变换 [ x ′ y ′ 1 ] = [ 1 0 t x 0 1 t y 0 0 1 ] × [ x y 1 ] \begin{bmatrix} x^{'}\\ y^{'}\\ 1 \end{bmatrix}=\begin{bmatrix} 1 & 0 &t_{x} \\ 0 & 1 &t_{y} \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} x\\ y\\ 1 \end{bmatrix} xy1 = 100010txty1 × xy1
t x , t y t_{x},t_{y} tx,ty为x、y轴平移量的大小。
尺度变换 [ x ′ y ′ 1 ] = [ s 0 0 0 s 0 0 0 1 ] × [ x y 1 ] \begin{bmatrix} x^{'}\\ y^{'}\\ 1 \end{bmatrix}=\begin{bmatrix} s & 0 & 0 \\ 0 & s & 0 \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} x\\ y\\ 1 \end{bmatrix} xy1 = s000s0001 × xy1
s s s为变化的倍数
斜切变换: [ p ′ 1 ] = [ A B 0 0 ] × [ p 1 ] \begin{bmatrix} p^{'}\\ 1 \end{bmatrix}=\begin{bmatrix} A&B \\ 0&0 \end{bmatrix} \times \begin{bmatrix} p\\ 1 \end{bmatrix} [p1]=[A0B0]×[p1]
A 、 B A、B AB为x、y轴变换的自由度。
(温故而知新:激活函数在这里给神经网络带来了什么益处?最后给出答案)

4.1.2激活函数

净活性值 z z z再经过一个非线性函数 f ( ⋅ ) f(⋅) f()后,得到神经元的活性值 a a a

a = f ( z ) ,( 4.3 ) a=f(z),(4.3) a=f(z),(4.3
激活函数通常为非线性函数,可以增强神经网络的表示能力和学习能力。常用的激活函数有S型函数和ReLU函数。

4.1.2.1 Sigmoid型函数

Sigmoid 型函数是指一类S型曲线函数,为两端饱和函数。常用的 Sigmoid 型函数有 Logistic 函数和 Tanh 函数,其数学表达式为

Logistic 函数:
σ ( z ) = 1 1 + e x p ( − z ) ( 4.4 ) \sigma(z)=\frac{1}{1+exp(-z)} (4.4) σ(z)=1+exp(z)1(4.4)
Tanh函数
t a n h ( z ) = e x p ( z ) − e x p ( − z ) e x p ( z ) + e x p ( − z ) tanh(z)=\frac{exp(z)-exp(-z)}{exp(z)+exp(-z)} tanh(z)=exp(z)+exp(z)exp(z)exp(z)

Logistic函数和Tanh函数的代码实现和可视化如下:

%matplotlib inline
import matplotlib.pyplot as plt

# Logistic函数
def logistic(z):
    return 1.0 / (1.0 + torch.exp(-z))

# Tanh函数
def tanh(z):
    return (torch.exp(z) - torch.exp(-z)) / (torch.exp(z) + torch.exp(-z))

# 在[-10,10]的范围内生成10000个输入值,用于绘制函数曲线
z = torch.linspace(-10, 10, 10000)

plt.figure()
plt.plot(z.tolist(), logistic(z).tolist(), color='#e4007f', label="Logistic Function")
plt.plot(z.tolist(), tanh(z).tolist(), color='#f19ec2', linestyle ='--', label="Tanh Function")

ax = plt.gca() # 获取轴,默认有4个
# 隐藏两个轴,通过把颜色设置成none
ax.spines['top'].set_color('none')
ax.spines['right'].set_color('none')
# 调整坐标轴位置   
ax.spines['left'].set_position(('data',0))
ax.spines['bottom'].set_position(('data',0))
plt.legend(loc='lower right', fontsize='large')

plt.savefig('fw-logistic-tanh.pdf')
plt.show()

在kaggle等notebook上面运行的时候,需要调用%matplotlib inline,才能在输出图片,否则无法显示。
在这里插入图片描述

在pytorch中,可以通过调用torch.nn.functional.sigmoid和torch.nn.functional.tanh实现对张量的Logistic和Tanh计算。

4.1.2.2ReLU型函数

常见的ReLU函数有ReLU和带泄露的ReLU(Leaky ReLU),数学表达式分别为:
R e L U ( z ) = m a x ( 0 , z ) ( 4.6 ) ReLU(z)=max(0,z)(4.6) ReLU(z)=max(0,z)(4.6)
L e a k y R e L U ( z ) = m a x ( 0 , z ) + λ m i n ( 0 , z ) LeakyReLU(z)=max(0,z)+\lambda min(0,z) LeakyReLU(z)=max(0,z)+λmin(0,z)
其中 λ \lambda λ为超参数。

可视化ReLU和带泄露的ReLU的函数的代码实现和可视化如下:

# ReLU
def relu(z):
    return torch.maximum(z, torch.as_tensor(0.))

# 带泄露的ReLU
def leaky_relu(z, negative_slope=0.1):
  
    a1 = (torch.tensor((z > 0), dtype=torch.float32) * z) 
    a2 = (torch.tensor((z <= 0),  dtype=torch.float32) * (negative_slope * z))
    return a1 + a2

# 在[-10,10]的范围内生成一系列的输入值,用于绘制relu、leaky_relu的函数曲线
z = torch.linspace(-10, 10, 10000)

plt.figure()
plt.plot(z.tolist(), relu(z).tolist(), color="#e4007f", label="ReLU Function")
plt.plot(z.tolist(), leaky_relu(z).tolist(), color="#f19ec2", linestyle="--", label="LeakyReLU Function")

ax = plt.gca()
ax.spines['top'].set_color('none')
ax.spines['right'].set_color('none')
ax.spines['left'].set_position(('data',0))
ax.spines['bottom'].set_position(('data',0))
plt.legend(loc='upper left', fontsize='large')
plt.savefig('fw-relu-leakyrelu.pdf')
plt.show()

在这里插入图片描述

说明 在飞桨中,可以通过调用torch.nn.functional.relu和torch.nn.functional.leaky_relu完成ReLU与带泄露的ReLU的计算。

(选做)动手练习
本节重点介绍和实现了几个经典的Sigmoid函数和ReLU函数。
请动手实现《神经网络与深度学习》4.1节中提到的其他激活函数,如:Hard-Logistic、Hard-Tanh、ELU、Softplus、Swish等。
S w i s h ( x ) = x ∗ S i g m o i d ( x ) Swish(x) = x*Sigmoid(x) Swish(x)=xSigmoid(x)

def swish(z):
    return z*torch.tensor(1/(1+torch.exp(-z)))
z = torch.linspace(-10, 10, 10000)
print(z)
plt.figure()
plt.plot(z.tolist(), swish(z).tolist(), color="#e4007f", label="ELU Function")
plt.show()

在这里插入图片描述
S o f t p l u s ( x ) = l o g ( 1 + e x p ( x ) ) Softplus(x)=log(1+exp(x)) Softplus(x)=log(1+exp(x))

def softplus(z):
    return torch.log(1+torch.exp(z))
z = torch.linspace(-10, 10, 10000)
print(z)
plt.figure()
plt.plot(z.tolist(), softplus(z).tolist(), color="#e4007f", label="ELU Function")
plt.show()

在这里插入图片描述

4.2基于前馈神经网络的二分类任务

前馈神经网络的网络结构如图4.3所示。每一层获取前一层神经元的活性值,并重复上述计算得到该层的活性值,传入到下一层。整个网络中无反馈,信号从输入层向输出层逐层的单向传播,得到网络最后的输出 a ( L ) a^{(L)} a(L)
在这里插入图片描述

图4.3: 前馈神经网络结构

4.2.1 数据集构建

这里,我们使用第3.1.1节中构建的二分类数据集:Moon1000数据集,其中训练集640条、验证集160条、测试集200条。
该数据集的数据是从两个带噪音的弯月形状数据分布中采样得到,每个样本包含2个特征。

'''圆月型数据'''
import math
import copy
import torch
import numpy as np
from sklearn.datasets import load_iris

#新增make_moons函数
def make_moons(n_samples=1000, shuffle=True, noise=None):
    """
    生成带噪音的弯月形状数据
    输入:
        - 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)
    #使用'paddle.linspace'在0到pi上均匀取n_samples_out个值
    #使用'paddle.cos'计算上述取值的余弦值作为特征1,使用'paddle.sin'计算上述取值的正弦值作为特征2
    outer_circ_x = torch.cos(torch.linspace(0, math.pi, n_samples_out))
    outer_circ_y = torch.sin(torch.linspace(0, math.pi, n_samples_out))

    inner_circ_x = 1 - torch.cos(torch.linspace(0, math.pi, n_samples_in))
    inner_circ_y = 0.5 - torch.sin(torch.linspace(0, math.pi, n_samples_in))
    
    print('outer_circ_x.shape:', outer_circ_x.shape, 'outer_circ_y.shape:', outer_circ_y.shape)
    print('inner_circ_x.shape:', inner_circ_x.shape, 'inner_circ_y.shape:', inner_circ_y.shape)
    
    #使用'paddle.concat'将两类数据的特征1和特征2分别延维度0拼接在一起,得到全部特征1和特征2
    #使用'paddle.stack'将两类特征延维度1堆叠在一起
    X = torch.stack(
        [torch.concat([outer_circ_x, inner_circ_x]),
        torch.concat([outer_circ_y, inner_circ_y])],
        axis=1
    )

    print('after concat shape:', torch.concat([outer_circ_x, inner_circ_x]).shape)
    print('X shape:', X.shape)

    #使用'paddle. zeros'将第一类数据的标签全部设置为0
    #使用'paddle. ones'将第一类数据的标签全部设置为1
    y = torch.concat(
        [torch.zeros(size=[n_samples_out]), torch.ones(size=[n_samples_in])]
    )

    print('y shape:', y.shape)

    #如果shuffle为True,将所有数据打乱
    if shuffle:
        #使用'paddle.randperm'生成一个数值在0到X.shape[0],随机排列的一维Tensor做索引值,用于打乱数据
        idx = torch.randperm(X.shape[0])
        X = X[idx]
        y = y[idx]

    #如果noise不为None,则给特征值加入噪声
    if noise is not None:
        #使用'paddle.normal'生成符合正态分布的随机Tensor作为噪声,并加到原始特征上
        X += torch.normal(mean=0.0, std=noise, size=X.shape)

    return X, y


#加载数据集
def load_data(shuffle=True):
    """
    加载鸢尾花数据
    输入:
        - shuffle:是否打乱数据,数据类型为bool
    输出:
        - X:特征数据,shape=[150,4]
        - y:标签数据, shape=[150,3]
    """
    #加载原始数据
    X = np.array(load_iris().data, dtype=np.float32)
    y = np.array(load_iris().target, dtype=np.int64)

    X = torch.as_tensor(X)
    y = torch.as_tensor(y)

    #数据归一化
    X_min = torch.min(X, axis=0)
    X_max = torch.max(X, axis=0)
    X = (X-X_min) / (X_max-X_min)

    #如果shuffle为True,随机打乱数据
    if shuffle:
        idx = torch.randperm(X.shape[0])
        X_new = copy.deepcopy(X)
        y_new = copy.deepcopy(y)
        for i in range(X.shape[0]):
            X_new[i] = X[idx[i]]
            y_new[i] = y[idx[i]]
        X = X_new
        y = y_new

    return X, y
    # 采样1000个样本
n_samples = 1000
X, y = make_moons(n_samples=n_samples, shuffle=True, noise=0.5)

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])

在这里插入图片描述

4.2.2模型构建

为了更高效的构建前馈神经网络,我们先定义每一层的算子,然后再通过算子组合构建整个前馈神经网络。

假设网络的第 l l l层的输入为第 l − 1 l-1 l1层的神经元活性值 a ( l − 1 ) a(l−1) a(l1),经过一个仿射变换,得到该层神经元的净活性值 z z z,再输入到激活函数得到该层神经元的活性值 a a a

在实践中,为了提高模型的处理效率,通常将 N N N个样本归为一组进行成批地计算。假设网络第l层的输入为 A ( l − 1 ) ∈ R N × M l − 1 A(l−1)∈RN×Ml−1 A(l1)RN×Ml1,其中每一行为一个样本,则前馈网络中第l层的计算公式为

Z ( l ) = A ( l − 1 ) W ( l ) + b ( l ) ∈ R N × M l , ( 4.8 ) Z(l)=A(l−1)W(l)+b(l)∈RN×Ml,(4.8) Z(l)=A(l1)W(l)+b(l)RN×Ml,(4.8)
A ( l ) = f l ( Z ( l ) ) ∈ R N × M l , ( 4.9 ) A(l)=fl(Z(l))∈RN×Ml,(4.9) A(l)=fl(Z(l))RN×Ml,(4.9)
其中Z(l)为N个样本第l层神经元的净活性值, A ( l ) A(l) A(l) N N N个样本第 l l l层神经元的活性值, W ( l ) ∈ R M l − 1 × M l W(l)∈RMl−1×Ml W(l)RMl1×Ml为第 l l l层的权重矩阵, b ( l ) ∈ R 1 × M l b(l)∈R1×Ml b(l)R1×Ml为第 l l l层的偏置。

为了和代码的实现保存一致性,这里使用形状为(样本数量×特征维度)的张量来表示一组样本。样本的矩阵X是由N个x的行向量组成。而《神经网络与深度学习》中x为列向量,因此这里的权重矩阵 W W W和偏置 b b b和《神经网络与深度学习》中的表示刚好为转置关系。

为了使后续的模型搭建更加便捷,我们将神经层的计算,即公式(4.8)和(4.9),都封装成算子,这些算子都继承Op基类。

4.2.2.1 线性层算子

公式(4.8)对应一个线性层算子,权重参数采用默认的随机初始化,偏置采用默认的零初始化。代码实现如下:

# 实现线性层算子
class Linear(Op):
    def __init__(self, input_size, output_size, name, weight_init=torch.normal, bias_init=torch.zeros):
        """
        输入:
            - input_size:输入数据维度
            - output_size:输出数据维度
            - name:算子名称
            - weight_init:权重初始化方式,默认使用'paddle.standard_normal'进行标准正态分布初始化
            - bias_init:偏置初始化方式,默认使用全0初始化
        """
        
        self.params = {}
        # 初始化权重
        self.params['W'] = weight_init(shape=[input_size,output_size])
        # 初始化偏置
        self.params['b'] = bias_init(shape=[1,output_size])
        self.inputs = None

        self.name = name

    def forward(self, inputs):
        """
        输入:
            - inputs:shape=[N,input_size], N是样本数量
        输出:
            - outputs:预测值,shape=[N,output_size]
        """
        self.inputs = inputs

        outputs = torch.matmul(self.inputs, self.params['W']) + self.params['b']
        return outputs

4.2.2.2 Logstic算子

本节我们采用Logistic函数来作为公式(4.9)中的激活函数。这里也将Logistic函数实现一个算子,代码实现如下:

class Logistic(Op):
    def __init__(self):
        self.inputs = None
        self.outputs = None

    def forward(self, inputs):
        """
        输入:
            - inputs: shape=[N,D]
        输出:
            - outputs:shape=[N,D]
        """
        outputs = 1.0 / (1.0 + torch.exp(-inputs))
        self.outputs = outputs
        return outputs

4.2.3.3 层次串行组合

层次串行就是将不同的隐藏层、输入层、输出层之间串联起来 注意:不是并联!!!

在定义了神经层的线性层算子和激活函数算子之后,我们可以不断交叉重复使用它们来构建一个多层的神经网络。

下面我们实现一个两层的用于二分类任务的前馈神经网络,选用Logistic作为激活函数,可以利用上面实现的线性层和激活函数算子来组装。代码实现如下:

# 实现一个两层前馈神经网络
class Model_MLP_L2(Op):
    def __init__(self, input_size, hidden_size, output_size):
        """
        输入:
            - input_size:输入维度
            - hidden_size:隐藏层神经元数量
            - output_size:输出维度
        """
        self.fc1 = Linear(input_size, hidden_size, name="fc1")
        self.act_fn1 = Logistic()
        self.fc2 = Linear(hidden_size, output_size, name="fc2")
        self.act_fn2 = Logistic()

    def __call__(self, X):
        return self.forward(X)

    def forward(self, X):
        """
        输入:
            - X:shape=[N,input_size], N是样本数量
        输出:
            - a2:预测值,shape=[N,output_size]
        """
        z1 = self.fc1(X)
        a1 = self.act_fn1(z1)
        z2 = self.fc2(a1)
        a2 = self.act_fn2(z2)
        return a2

测试一下

现在,我们实例化一个两层的前馈网络,令其输入层维度为5,隐藏层维度为10,输出层维度为1。
并随机生成一条长度为5的数据输入两层神经网络,观察输出结果。

# 实例化模型
model = Model_MLP_L2(input_size=5, hidden_size=10, output_size=1)
# 随机生成1条长度为5的数据
X = torch.rand(size=[1, 5])
result = model(X)
print ("result: ", result)

在这里插入图片描述

4.2.3 损失函数

二分类交叉熵损失函数见第三章,这里不再赘述。

4.2.4模型优化

神经网络的参数主要是通过梯度下降法进行优化的,因此需要计算最终损失对每个参数的梯度。
由于神经网络的层数通常比较深,其梯度计算和上一章中的线性分类模型的不同的点在于:线性模型通常比较简单可以直接计算梯度,而神经网络相当于一个复合函数,需要利用链式法则进行反向传播来计算梯度。

4.2.4.1 反向传播算法

前馈神经网络的参数梯度通常使用误差反向传播算法来计算。使用误差反向传播算法的前馈神经网络训练过程可以分为以下三步:
1、前馈计算每一层的净活性值 Z l Z^{l} Zl A l A^{l} Al激活值,直到最后一层;
2、反向传播计算每一层的误差项 δ l = δ R δ z l \delta^{l} =\frac{\delta R}{\delta z^{l}} δl=δzlδR
3、计算每一层参数的梯度,并更新参数。
在上面实现算子的基础上,来实现误差反向传播算法。在上面的三个步骤中,
第1步是前向计算,可以利用算子的forward()方法来实现;
第2步是反向计算梯度,可以利用算子的backward()方法来实现;
第3步中的计算参数梯度也放到backward()中实现,更新参数放到另外的优化器中专门进行。
这样,在模型训练过程中,我们首先执行模型的forward(),再执行模型的backward(),就得到了所有参数的梯度,之后再利用优化器迭代更新参数。

以这我们这节中构建的两层全连接前馈神经网络Model_MLP_L2为例,下图给出了其前向和反向计算过程:
在这里插入图片描述
下面我们按照反向的梯度传播顺序,为每个算子添加backward()方法,并在其中实现每一层参数的梯度的计算。

4.2.4.2 损失函数

二分类交叉熵损失函数对神经网络的输出 y ^ \hat{y} y^的偏导数为:

∂ R ∂ y ^ = − 1 N ( d i a l o g ( 1 y ^ ) y − d i a l o g ( 1 1 − y ^ ) ( 1 − y ) ) ( 4.10 ) = − 1 N ( 1 y ^ ⊙ y − 1 1 − y ^ ⊙ ( 1 − y ) ) , ( 4.11 ) \frac{\partial R}{\partial \hat{\boldsymbol{y}}} = -\frac{1}{N}(\mathrm{dialog}(\frac{1}{\hat{\boldsymbol{y}}})\boldsymbol{y}-\mathrm{dialog}(\frac{1}{1-\hat{\boldsymbol{y}}})(1-\boldsymbol{y})) (4.10) \\ = -\frac{1}{N}(\frac{1}{\hat{\boldsymbol{y}}}\odot\boldsymbol{y}-\frac{1}{1-\hat{\boldsymbol{y}}}\odot(1-\boldsymbol{y})), (4.11) y^R=N1(dialog(y^1)ydialog(1y^1)(1y))(4.10)=N1(y^1y1y^1(1y)),(4.11)
其中 d i a l o g ( x ) dialog(\boldsymbol{x}) dialog(x)

# 实现交叉熵损失函数
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):
        """
        输入:
            - 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 = paddle.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)

4.2.4.3 Logsitc算子

在本节中,我们使用Logistic激活函数,所以这里为Logistic算子增加的反向函数。

Logistic算子的前向过程表示为 A = σ ( Z ) \boldsymbol{A}=\sigma(\boldsymbol{Z}) A=σ(Z),其中 σ \sigma σ为Logistic函数, Z ∈ R N × D \boldsymbol{Z} \in R^{N \times D} ZRN×D A ∈ R N × D \boldsymbol{A} \in R^{N \times D} ARN×D的每一行表示一个样本。

为了简便起见,我们分别用向量 a ∈ R D \boldsymbol{a} \in R^D aRD z ∈ R D \boldsymbol{z} \in R^D zRD表示同一个样本在激活函数前后的表示,则a对z的偏导数为:
∂ a ∂ z = d i a g ( a ⊙ ( 1 − a ) ) ∈ R D × D , ( 4.12 ) \frac{\partial \boldsymbol{a}}{\partial \boldsymbol{z}}=diag(\boldsymbol{a}\odot(1-\boldsymbol{a}))\in R^{D \times D}, (4.12) za=diag(a(1a))RD×D,(4.12)
按照反向传播算法,令δa=∂R∂a∈RD表示最终损失R对Logistic算子的单个输出a的梯度,则

δ z ≜ ∂ R ∂ z = ∂ a ∂ z δ a ( 4.13 ) = d i a g ( a ⊙ ( 1 − a ) ) δ ( a ) , ( 4.14 ) = a ⊙ ( 1 − a ) ⊙ δ ( a ) 。 ( 4.15 ) \delta_{\boldsymbol{z}} \triangleq \frac{\partial R}{\partial \boldsymbol{z}} = \frac{\partial \boldsymbol{a}}{\partial \boldsymbol{z}}\delta_{\boldsymbol{a}} (4.13) \\ = diag(\boldsymbol{a}\odot(1-\boldsymbol{a}))\delta_{\boldsymbol(a)}, (4.14) \\ = \boldsymbol{a}\odot(1-\boldsymbol{a})\odot\delta_{\boldsymbol(a)}。 (4.15) δzzR=zaδa(4.13)=diag(a(1a))δ(a),(4.14)=a(1a)δ(a)(4.15)
将上面公式利用批量数据表示的方式重写,令 δ A = ∂ R ∂ A ∈ R N × D \delta_{\boldsymbol{A}} =\frac{\partial R}{\partial \boldsymbol{A}} \in R^{N \times D} δA=ARRN×D表示最终损失R对Logistic算子输出A的梯度,损失函数对Logistic函数输入Z的导数为 δ Z = A ⊙ ( 1 − A ) ⊙ δ A ∈ R N × D , ( 4.16 ) \delta_{\boldsymbol{Z}}=\boldsymbol{A} \odot (1-\boldsymbol{A})\odot \delta_{\boldsymbol{A}} \in R^{N \times D},(4.16) δZ=A(1A)δARN×D,(4.16)
δ Z \delta_{\boldsymbol{Z}} δZ为Logistic算子反向传播的输出。
由于Logistic函数中没有参数,这里不需要在backward()方法中计算该算子参数的梯度。

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 paddle.multiply(grads,outputs_grad_inputs)

4.2.4.4 线性层

线性层算子Linear的前向过程表示为 Y = X W + b \boldsymbol{Y}=\boldsymbol{X}\boldsymbol{W}+\boldsymbol{b} Y=XW+b,其中输入为 X ∈ R N × M \boldsymbol{X} \in R^{N \times M} XRN×M,输出为 Y ∈ R N × D \boldsymbol{Y} \in R^{N \times D} YRN×D,参数为权重矩阵 W ∈ R M × D \boldsymbol{W} \in R^{M \times D} WRM×D和偏置 b ∈ R 1 × D \boldsymbol{b} \in R^{1 \times D} bR1×D X X X Y Y Y中的每一行表示一个样本。

为了简便起见,我们用向量 y ∈ R D \boldsymbol{y}\in R^D yRD x ∈ R M \boldsymbol{x}\in R^M xRM表示同一个样本在线性层算子中的输入和输出,则有 y = W T x + b T \boldsymbol{y}=\boldsymbol{W}^T\boldsymbol{x}+\boldsymbol{b}^T y=WTx+bT y y y对输入 x x x的偏导数为

∂ y ∂ x = W ∈ R D × M 。 ( 4.17 ) ∂y∂x=W∈RD×M。(4.17) yx=WRD×M(4.17)
线性层输入的梯度 按照反向传播算法,令δy=∂R∂y∈RD表示最终损失R对线性层算子的单个输出y的梯度,则

δ x ≜ ∂ R ∂ x = W δ y 。 ( 4.18 ) δx≜∂R∂x=Wδy。(4.18) δxRx=Wδy(4.18)
将上面公式利用批量数据表示的方式重写,令δY=∂R∂Y∈RN×D表示最终损失R对线性层算子输出Y的梯度,公式可以重写为

δ X = δ Y W T , ( 4.19 ) δX=δYWT,(4.19) δX=δYWT,(4.19)
其中δX为线性层算子反向函数的输出。

计算线性层参数的梯度 由于线性层算子中包含有可学习的参数W和b,因此backward()除了实现梯度反传外,还需要计算算子内部的参数的梯度。

δ y = ∂ R ∂ y ∈ R D δy=∂R∂y∈RD δy=RyRD表示最终损失R对线性层算子的单个输出y的梯度,则

δ W ≜ ∂ R ∂ W = x δ T y , ( 4.20 ) δ b ≜ ∂ R ∂ b = δ T y 。 ( 4.21 ) δW≜∂R∂W=xδTy,(4.20)δb≜∂R∂b=δTy。(4.21) δWRW=xδTy,(4.20)δbRb=δTy(4.21)
将上面公式利用批量数据表示的方式重写,令 δ Y = ∂ R ∂ Y ∈ R N × D δY=∂R∂Y∈RN×D δY=RYRN×D表示最终损失R对线性层算子输出Y的梯度,则公式可以重写为

δ W = X T δ Y , ( 4.22 ) δ b = 1 T δ Y 。 ( 4.23 ) δW=XTδY,(4.22)δb=1TδY。(4.23) δW=XTδY,(4.22)δb=1TδY(4.23)
具体实现代码如下:

class Linear(Op):
    def __init__(self, input_size, output_size, name, weight_init=torch.randn, bias_init=torch.zeros):
        self.params = {}
        self.params['W'] = weight_init(shape=[input_size, output_size])
        self.params['b'] = bias_init(shape=[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']) + self.params['b']
        return outputs

    def backward(self, grads):
        """
        输入:
            - grads:损失函数对当前层输出的导数
        输出:
            - 损失函数对当前层输入的导数
        """
        self.grads['W'] = torch.matmul(self.inputs.T, grads)
        self.grads['b'] = torch.sum(grads, axis=0)

        # 线性层输入的梯度
        return torch.matmul(grads, self.params['W'].T)

4.2.4.5 整个网络

实现完整的两层神经网络的前向和反向计算。代码实现如下:

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
        
    # 反向计算
    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)

4.2.4.6 优化器

在计算好神经网络参数的梯度之后,我们将梯度下降法中参数的更新过程实现在优化器中。

与第3章中实现的梯度下降优化器SimpleBatchGD不同的是,此处的优化器需要遍历每层,对每层的参数分别做更新。

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]

4.2.5 完善Runner类:RunnerV2_1

1、基于3.1.6实现的 RunnerV2 类主要针对比较简单的模型。而在本章中,模型由多个算子组合而成,通常比较复杂,因此本节继续完善并实现一个改进版: RunnerV2_1类,其主要加入的功能有:

2、支持自定义算子的梯度计算,在训练过程中调用self.loss_fn.backward()从损失函数开始反向计算梯度;
每层的模型保存和加载,将每一层的参数分别进行保存和加载。

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)

4.2.6 模型训练

基于RunnerV2_1,使用训练集和验证集进行模型训练,共训练2000个epoch。评价指标为第章介绍的accuracy。代码实现如下:


torch.seed()
epoch_num = 1000

model_saved_dir = "model"

# 输入层维度为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)

在这里插入图片描述
训练次数隐藏层维度=10。通过结果我们可以发现误差降低了,但是训练时间变长了,在实际的网络构建中要综合考虑各个神经元的层数。
在这里插入图片描述
学习率=0.8 ,学习率过大不易收敛。
在这里插入图片描述
学习率=0.01 ,学习率过小求解过慢。
在这里插入图片描述

# 打印训练集和验证集的损失
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.savefig('fw-loss2.pdf')
plt.show()

在这里插入图片描述

4.2.7 性能评价

使用测试集对训练中的最优模型进行评价,观察模型的评价指标。代码实现如下:

# 加载训练好的模型
runner.load_model(model_saved_dir)
# 在测试集上对模型进行评价
score, loss = runner.evaluate([X_test, y_test])
print("[Test] score/loss: {:.4f}/{:.4f}".format(score, loss))

在这里插入图片描述
从结果来看,模型在测试集上取得了较高的准确率。
下面对结果进行可视化:

import math

# 均匀生成40000个数据点
x1, x2 = torch.meshgrid(torch.linspace(-math.pi, math.pi, 200), torch.linspace(-math.pi, math.pi, 200))
x = torch.stack([torch.flatten(x1), torch.flatten(x2)], axis=1)

# 预测对应类别
y = runner.predict(x)
y = torch.squeeze(torch.tensor((y>=0.5),dtype=torch.float32),axis=-1)

# 绘制类别区域
plt.ylabel('x2')
plt.xlabel('x1')
plt.scatter(x[:,0].tolist(), x[:,1].tolist(), c=y.tolist(), cmap=plt.cm.Spectral)

plt.scatter(X_train[:, 0].tolist(), X_train[:, 1].tolist(), marker='*', c=torch.squeeze(y_train,axis=-1).tolist())
plt.scatter(X_dev[:, 0].tolist(), X_dev[:, 1].tolist(), marker='*', c=torch.squeeze(y_dev,axis=-1).tolist())
plt.scatter(X_test[:, 0].tolist(), X_test[:, 1].tolist(), marker='*', c=torch.squeeze(y_test,axis=-1).tolist())

在这里插入图片描述
【思考题】对比
3.1 基于Logistic回归的二分类任务 4.2 基于前馈神经网络的二分类任务
谈谈自己的看法

答:1、复杂度来说,神经网络肯定是比Logisitc回归更加复杂的。但是通过二者的模型评价,同样都是圆月型数据集,我们发现更复杂的不一定是拟合效果最好的,Logsitc虽然头脑简单四肢发达,但是做事比神经网络得分更好,误差更低。这给我们的启示是,在选择模型的时候我们要尝试各种各样的模型来进行训练。而不是只优化一种网络模型。有时往往越简单的效果越好,就像生活一样,太复杂往往过的很心累。
2、通过这两次的实验,我发现,前馈神经网络当隐藏层维度较低的时候和Logistic回归和时间用时差不多但是一但神经元多较多,在相同数据集的情况下,用时差距就很明显了。

心得体会

下面是新的体会 在掌握了建立模型的步骤,同时使用类来封装模型的训练过程,但是在pytorch和pddle之间的转换还是容易出错,有时改代码都需要好久,同时也温习了计算机图形学,图形变换等,每次都会有新的收获。很棒。但是由于机器学习中一般都是自己手写底层代码,没有引入过pytorch,在自己尝试自己写的时候还是有错误。例如每次我都是傻呵呵在反向传递前更新参数。

疑问解答

在4.1.1留下的疑问,答:增强了神经网络的非线性特征,

参考

torch.nn.Linear详解
STN 网络:仿射变换 (刚体变换、透视变换)
仿射变换(Affine Transformation)原理及应用
欠拟合与过拟合

补充

由于圆月状图像产生效果不是很好,所以我们将噪声降成0.15来使其更呈显著的圆月状,其他部分无须进行更改,然后进行测试,同时也相当于进行模型效果的检验。通过这个细节,我发现我和其他同学之间的差距了,同学们细心认真,能够发现实验中的不足之处。这正是我需要学习的地方。
在这里插入图片描述
分类结果:通过分类结果可知,这次建立的神经网络模型还是有一定的局限性的,欠拟合
在这里插入图片描述

改变模型的超参数学习率后,可以看见训练效果很好,无论在在查训练集还是测试集还是验证集上,均有显著的良好的分类的效果

存不存在过拟合风险?
答:我认为是不存在过拟合,因为过拟合指的是在测试集和训练集上效果良好,但是在验证集上效果不好。通过图片我们可以发现,,不仅仅在测试集和训练集上效果良好,在验证及上也取得良好的效果,所以不存在过拟合。

在这里插入图片描述

欠拟合和过拟合产生的原因及解决办法(机器学习回顾)

过拟合和欠拟合

过拟合(overfitting)和欠拟合(underfitting)是导致模型泛化能力不高的两种常见原因,都是模型学习能力与数据复杂度之间失配的结果。“欠拟合”常常在模型学习能力较弱,而数据复杂度较高的情况出现,此时模型由于学习能力不足,无法学习到数据集中的“一般规律”,因而导致泛化能力弱。与之相反,“过拟合”常常在模型学习能力过强的情况中出现,此时的模型学习能力太强,以至于将训练集单个样本自身的特点都能捕捉到,并将其认为是“一般规律”,同样这种情况也会导致模型泛化能力下降。过拟合与欠拟合的区别在于,欠拟合在训练集和测试集上的性能都较差,而过拟合往往能较好地学习训练集数据的性质,而在测试集上的性能较差。

过拟合的解决办法

正则化(见第三章课后作业)
drop out(丢弃某些神经元)

在神经网络的训练过程中,过拟合是由于神经元过多,导致拟合效果过好,我们可以通过一定的概率减少一些神经元来降低模型的复杂度。

early stopping(早停)

早停就是字面意思,在拟合效果还没有达到过拟合的效果就停止。
具体的操作步骤:在每一个Epoch结束时计算validation data的accuracy,当accuracy不再提高时,就停止训练。当然我们并不会在accuracy一降低的时候就停止训练,因为可能经过这个Epoch后,accuracy降低了,但是随后的Epoch又让accuracy又上去了,所以不能根据一两次的连续降低就判断不再提高。一般的做法是,在训练的过程中,记录到目前为止最好的validation accuracy,当连续10次Epoch(或者更多次)没达到最佳accuracy时,则可以认为accuracy不再提高了。此时便可以停止迭代了(Early Stopping)。这种策略也称为“No-improvement-in-n”,n即Epoch的次数,可以根据实际情况取,如10、20、30……

欠拟合的解决办法

1、增加新特征,可以考虑加入进特征组合、高次特征,来增大假设空间
2、添加多项式特征,这个在机器学习算法里面用的很普遍,例如将线性模型通过添加二次项或者三次项使模型泛化能力更强
3、减少正则化参数,正则化的目的是用来防止过拟合的,但是模型出现了欠拟合,则需要减少正则化参数
4、使用非线性模型,比如核SVM 、决策树、深度学习等模型
5、调整模型的容量(capacity),通俗地,模型的容量是指其拟合各种函数的能力
6、容量低的模型可能很难拟合训练集;使用集成学习方法,如Bagging ,将多个弱学习器Bagging

  • 7
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值