contents
前馈神经网络(part1)
写在开头
经过前面对于线性模型的回归与分类问题的学习,相信大家对于神经网络框架已经较为熟悉,本章我们将使用类似生物学中神经元的结构,通过前馈神经网络,进行问题的求解。
神经元
神经元大家肯定是都知道的,在生物学意义上本人做出不准确定义:神经元有若干树突用于接收来自其他神经元的电信号,再通过细胞体的一系列处理,通过轴突发出结果信号。抽象到计算机上,神经元结构通过对一组输入给予不同权重,进行求和后加入激活函数用于得到该神经元最终的输出。
净活性值
这个概念看起来非常牛逼,其实就是在神经元的值进入激活函数前的那个值。我们定义一个神经元有d个输入
x
1
…
x
d
x_1 \dots x_d
x1…xd,每个输入有权重
w
i
,
i
∈
[
1
,
d
]
且
i
∈
N
w_i, i∈[1,d]且i∈ N
wi,i∈[1,d]且i∈N,偏置值
b
b
b,可得净活性值:
z
=
∑
i
=
1
d
w
i
x
i
+
b
z=\sum_{i=1}^{d}w_ix_i+b
z=i=1∑dwixi+b
净活性值经过非线性函数
f
(
⋅
)
f(·)
f(⋅)后得到活性值
a
a
a。
对于计算净活性值,我们这边依然用到了torch.nn.Linear,因为净活性值的计算与线性函数计算相同。
x = torch.tensor([1,1,4,5,1,4],dtype=torch.float)
lin = torch.nn.Linear(6,1)
print('weights : {}'.format(lin.weight.data))
print('bia : {}'.format(lin.bias.data))
print('out : {}'.format(lin(x).detach()))
- 思考:加权求和与仿射变换之间有什么区别和联系?
答:联系:加权求和可以看作就是仿射变换,都有对每个输入加权求和的过程。区别:仿射变换在计算时对计算矩阵额外增加一行[0,0,…,0,1],对列向量底部增加一个1。可以看作是笛卡尔坐标系向齐次坐标系转换后进行计算。
激活函数
得到了净活性值后,我们需要像真的神经元有阈值一样,为该值增加一个激活函数用于控制输出。激活函数通常为非线性函数,可以增强神经网络的表示能力和学习能力。
以下所有的激活函数,在文中并未放置其公式,具体函数参见网页链接
Sigmoid(S)型函数
S型函数主要特征为函数图像呈现S的形状,其有Logistic函数和Tanh函数。可视化代码和效果如下:
x = torch.linspace(-10.,10.)
fig, (ax0,ax1) = plt.subplots(2,1,figsize=(10,10))
ax0.set_title('Logistic')
ax1.set_title('Tanh')
ax0.set_yticks((-1,0,1))
ax1.set_yticks((-1,0,1))
ax0.plot(x,torch.sigmoid(x))
ax1.plot(x,torch.tanh(x))
ReLU型函数
ReLU函数常见的为ReLU、LeakyReLU。这两个函数区别为当输入小于0时,LeakyReLU会有一个非常小的值(泄露就是在这儿),他们在大于等于0的地方函数处处相同。
x = torch.linspace(-10.,2.)
fig, (ax0,ax1) = plt.subplots(2,1,figsize=(10,10))
ax0.set_title('ReLU')
ax1.set_title('LeakyReLU')
ax0.plot(x,torch.relu(x))
ax1.plot(x,torch.nn.LeakyReLU()(x))
其他函数
书上几乎所有的函数,在torch中均有函数封装,如果要自己造轮子的话,可以按照书上的公式敲一遍代码,这边仅使用torch内置函数进行绘制,这边只放部分,造轮子内容在github。
x = torch.linspace(-10.,10.)
fig,*axs=plt.subplots(4,2,figsize=(10,15))
axs[0][0][0].plot(x,torch.nn.Hardsigmoid()(x))
axs[0][0][0].set_title('Hard-Logistic')
axs[0][0][1].plot(x,torch.nn.Hardtanh()(x))
axs[0][0][1].set_title('Hard-Tanh')
axs[0][1][0].plot(x,torch.nn.ELU()(x))
axs[0][1][0].set_title('ELU')
axs[0][1][1].plot(x,torch.nn.Softplus()(x))
axs[0][1][1].set_title('Softplus')
axs[0][2][0].plot(x,torch.nn.CELU()(x))
axs[0][2][0].set_title('CELU')
axs[0][2][1].plot(x,torch.nn.SiLU()(x))
axs[0][2][1].set_title('Swish(SiLU)')
axs[0][3][0].plot(x,torch.nn.Hardswish()(x))
axs[0][3][0].set_title('Hard-swish')
axs[0][3][1].plot(x,torch.nn.Mish()(x))
axs[0][3][1].set_title('Mish')
基于前馈神经网络的二分类任务
数据集构建
数据集使用第3.1.1节中构建的二分类数据集:Moon1000数据集,其中训练集640条、验证集160条、测试集200条。该数据集的数据是从两个带噪音的弯月形状数据分布中采样得到,每个样本包含2个特征。
本章不再重复该部分代码。结果如下:
模型构建
实例化一个两层的前馈网络,令其输入层维度为5,隐藏层维度为10,输出层维度为1。
并随机生成一条长度为5的数据输入两层神经网络,观察输出结果。
根据题意,很容易得到模型构建的代码。这边直接使用torch框架:
class Model(torch.nn.Module):
def __init__(self):
super(Model, self).__init__()
self.inputs = torch.nn.Linear(2,5)
self.activ1 = torch.nn.Sigmoid()
self.hidden = torch.nn.Linear(5,10)
self.activ2 = torch.nn.Sigmoid()
self.output = torch.nn.Linear(10,1)
self.activ3 = torch.nn.Sigmoid()
def forward(self,x):
x = self.inputs(x)
x = self.activ1(x)
x = self.hidden(x)
x = self.activ2(x)
x = self.output(x)
return self.activ3(x)
model = Model()
我们同时也可以使用torch.nn.Sequential来进行模型构建。
损失函数
这次的损失函数使用和前面Logistic回归实验内容相同,这边不再赘述。
crite = torch.nn.BCELoss()
模型优化
这边的神经网络模型,已经拥有不止一层的计算,这样的模型结构类似下图,每个线上面都有一个权值,因此对于误差的反向传播,我们要将所有值更新一遍,之前线性分类所使用的简单求导显然没法照搬使用。
反向传播算法
我们在反向传播时,需要一层一层地对参数进行更新,由此我们定义优化的类SGD如下:
class SGD():
def __init__(self, lr=0.01, momentum=0):
self.lr = lr
self.momentum = momentum
self.w_ = None
def update(self, w, grad):
if self.w_ is None:
self.w_ = np.zeros(np.shape(w))
self.w_ = self.momentum * self.w_ + (1 - self.momentum) * grad
return w - self.lr * self.w_
其中的梯度值grad逆着网络进行反向传播,然后每层都记录下梯度,更新参数并反向不断更新。
我们当然也可以直接使用torch的SGD优化器,该优化器本身就支持神经网络:
optim = torch.optim.SGD(model.parameters(),0.01)
上述求导方式如同链条,因此我们称其为链式求导法则,该参数更新过程被称为反向传播。具体内容和实例将更新至hw3中。
损失函数
交叉熵损失函数根据其函数对于输出的偏导,可得求导函数代码如下:
def backward(self, y, p):
p = np.clip(p, 1e-15, 1 - 1e-15)
return - (y / p) + (1 - y) / (1 - p)
Logistic算子
Logistic函数输出对于输入的求导 f ′ ( x ) = f ( x ) ( 1 − f ( x ) ) f'(x)=f(x)(1-f(x)) f′(x)=f(x)(1−f(x)),非常清楚简单,其反向代码如下:
def backward(self, x):
return self.__call__(x) * (1 - self.__call__(x))
线性层
计算线性层的梯度,通过计算其后一层对于每个权重的偏导数可得。
其代码如下:
def grad(self, next_back_grad):
W = self.W
grad_w = self.layer_input.T.dot(next_back_grad)
grad_w0 = np.sum(next_back_grad, axis=0, keepdims=True)
self.W = self.W_opt.update(self.W, grad_w)
self.w0 = self.w0_opt.update(self.w0, grad_w0)
next_back_grad = next_back_grad.dot(W.T)
return next_back_grad # 记录累计的梯度
整个网络
整个网络的构建由前向的结果输出和反向的链式求导参数更新组成,我们这边以前面构建的torch模型为例,前向计算为将每一层按照全连接的方式连接,并且按照顺序进入各个神经元并输出,一层神经元的输出又作为下一层的输入重复过程,最终得到输出结果。
优化器
优化器的轮子已经在前面的反向传播算法内容中由SGD类构建完成,在此不再赘述。
完善Runner类
在自造轮子的Runner类中,我们增加按层进行反向传播的函数:
def backward(self, loss_grad):
for layer in reversed(self.layers):
loss_grad = layer.backward(loss_grad)
其中layer为前面所有算子、线性函数类的父类,其中包含反向传播的backward函数,reversed用于将层倒过来(毕竟是反向传播)。
保存参数我们选择将每一层的内容当作一个字典,按照如下类似json的格式进行存储:
numpy.array([
{'name':'layer1',
'type':'该层种类',
'specific':{
//各种属于种类的特殊属性,如输入,输出,偏置,权重等
}
}],
[//layer2],
...,
])
模型训练
使用训练集和验证集进行模型训练,共训练2000个epoch。评价指标为accuracy。
这边代码和以前别无二异,依然直接使用Runner类,这边不再赘述,直接给出训练过程和结果。
性能评价
性能评价的代码和以前也是一样滴uvu:
out = model(dataset_test[:,:-1]).squeeze()
acc = accuracy(out,dataset_test[:,-1])
for i in range(out.shape[0]):
plt.scatter(dataset_test[i,0],dataset_test[i,1],c=['red','green'][torch.round(out[i]) == torch.round(dataset_test[i,-1])])
loss = crite(dataset_test[:,-1],out)
print('acc = {}, loss = {}'.format(acc,loss))
结果:
对比思考
- 3.1 基于Logistic回归的二分类任务 4.2 基于前馈神经网络的二分类任务,谈谈自己的看法。
共同点:
首先:Logistic回归和本次基于前馈神经网络的二分类都是二分类任务(送一点分😔);
其次:使用的最终激活函数、误差计算函数和优化方法本质上相同。
不同点:
一:Logistic回归比较适合线性可分的样本,对于异或问题这类无法解决,而前馈神经网络则能够很好地解决这个问题(能以任意精度拟合任意复杂函数);
二:Logistic回归相当于单层的多输入单输出的全连接神经网络,而反之则是多个Logistic回归(如果用Logistic回归)的层叠复合。
写在最后
本次实验我们实现了功能更强、结构更复杂、计算参数更多的前馈神经网络,了解了其基本性质和很牛的计算方法,理解本次实验内容有助于我们理解神经网络的一般结构和原理,以及优化的计算和应用。