人工神经网络初探
神经网络与人类大脑
人类虽然拥有智慧,但对智慧是如何产生的却不得而知,对于大脑结构的模仿或许是一个探索的起点,神经网络的起点就在这里。准确地说,这里所述的神经网络其实是人工神经网络,仅仅是模仿了人脑神经网络的部分结构特征与机理。在本小节,我们来对神经网络与人类大脑来作一番对比,比较两者的不同与联系。
人类神经系统的基本单元是神经元,约有 1000 亿个,是一种高度分化的细胞。神经元能够接受、整合、传导和输出信息,实现信息的传递、重组以及交换,正是基于此,人类大脑能够作出极其复杂的决策。人脑的神经元大体结构如下图所示:
胞体以及树突上的受体用于接收信息,当信息达到特定阈值后,产生动作电位并且由轴突传递给另外的神经元。由于有多个轴突末梢,因此信息可以传递给多个神经元,反之,同一个神经元也可以接收来自多个神经元的信息。注意到这里有两个关键点:
特定阈值。
神经元之间多对多的关系。
人们便是基于此设计了人工神经元的结构,其数学表达式如下:
其中,n表示有n个x的输入,w表示与输入进行线性运算的参数,b为偏移量,f表示非线性变化(也叫作激活函数),这便是一个人工设计的神经元基本结构的数学表达式。
对照真实的神经元,x表示由其它神经元传递过来的信息,w及 b表示树突接收信息之后进行的转化重组处理,求和并且加入非线性变化便是胞体中对信息进行整合及设置阈值的功能。人类神经元的形态以及功能多种多样,相应地,在人工神经结构中,不同的 w、b以及f组合,便构成了处理不同信息的神经元。神经网络则是多个不同神经元的组合结果,而当神经元的传递超过一级的时候,神经网络结构在深度上进行延展,便称之为深度神经网络,学习过程则称为深度学习(在实际称呼中,我们并不区分这些叫法的细微差别)。
在上文中提到,f表示非线性变化,也叫作激活函数,这又是怎么一回事呢?
先从人类大脑的反应说起,在日常生活中,可以观察到,每个人对疼痛的敏感程度不一样,比如有的人蚊子叮一下就很痛,有的人要用力打一顿才会痛。也就是说,对于疼痛的传递,需要超过一定的阈值才能传导到人类的感觉层面,并且由于每个人阈值的不同导致了对于同一刺激产生不同反应。确实,从真实神经元的信息传导层面而言,当神经元之间的递质达到一定浓度时,兴奋才能进行传递。
如此可见,在真实世界中,输入(疼痛的刺激)与输出(疼痛的感觉)并不是呈简单的线性关系,往往用非线性关系来描述更确切。如何使得两者之间呈非线性关系?这便是非线性函数或者说激活函数的作用了。从“激活”本身的语义出发,激活函数可通俗地理解为,定义在某些特定刺激条件下产生特定活动的函数。
而从数学的角度而言,如果神经元结构中只存在线性计算,即使有多个神经元组合计算,最终也属于线性计算,因此不能拟合出复杂的函数。而加入非线性计算之后,可以增强神经网络的表达能力,理论上能表示任何复杂的运算,Universal approximation theorem(Hornik et al., 1989; Cybenko, 1989,中文称为万能近似定理)表明:前馈神经网络,只需具备单层隐含层和有限个神经单元,就能以任意精度拟合任意复杂度的函数,这是个已经被证明的定理。因此激活函数是神经网络中的重要且不可或缺的结构。
当然,在实践中,UAT 可能基于以下原因无法实现(否则神经网络将是万能的,而事实并不如此):
用于训练的优化算法可能找不到用于期望函数的参数值;
训练算法可能由于过拟合而选择了错误的函数。
接下来总结几种在神经网络结构中常见的激活函数:
Sigmoid 就是一种常用的激活函数,数学表达式如下:
Sigmoid 能够把输入映射到 0 和 1 之间,在物理上最接近神经元,可以被表示成概率,或者用于数据的归一化。但是它有两个严重的缺陷:
梯度消失,导数 f’(x)=f(x)(1−f(x)),当 x 趋于无穷时,f(x) 的两侧导数逐渐趋于 0。在梯度反向传播(用于训练神经网络参数的一种方法)时,Sigmoid 向下传递的梯度包含了一个f’(x) 因子,因此,一旦落入两端的平滑区,f’(x) 就变得接近于 0(如上图所示,Sigmoid 函数两端的梯度接近于 0),导致了梯度反向传播的梯度也非常小。此时,网络参数很难得到有效训练,这种现象被称为梯度消失,一般在 5 层以内就会产生梯度消失的现象。
Sigmoid函数的输出均大于 0,这就使得输出不是 0 均值,称为偏置现象,这将会导致后一层的神经元将得到上一层输出的非 0 均值的信号作为输入,对数据的分布变动影响较大。
另一个比较常见的激活函数为 tanh 函数,数学表达式如下:
tanh 的特点有:
能够把输入映射到 -1 和 1 之间,并且以 0 为中心对称。
梯度比 Sigmoid 形梯度强(导数更陡峭),因此收敛更快。
同时也存在着与 Sigmoid 函数类似的对两端值不敏感,梯度消失的问题。
还有一种非常简单的激活函数叫作 ReLU,数学表达式如下:
Relu 全称为 Rectified Linear Units,可以翻译成线性整流单元或者修正线性单元。虽然其表达式非常简单,但却有其独特的优势:
计算非常简单,对于复杂的深度学习训练而言,能节省不少训练时间。
这种单边的输出特性和生物学意义上的神经元阈值机制十分相像:当输入小于 0 时,输出为 0,当输入大于等于 0 时,输出保持不变。也就是说,当输入小于 0 时,此神经元不产生任何作用,处于失活状态,这也和人脑的工作机制类似。在 2003 年,Lennie 等神经科学家推测,在一般情况下,大脑同时被激活的神经元只有 1~4%,也就是说,大脑中大部分神经元处于失活状态,只有少部分神经元处理特定任务,比如人在说话时只有一部分与语言相关的大脑区域处于激活状态。
从数学特性而言,当 x>0 时,梯度不变,解决了 Sigmoid 及 tanh 常见的梯度消失问题。
除了以上所述的几种激活函数,还有 Leaky ReLU,Exponential Linear Units 等激活函数。各种激活函数有其各自的优缺点,在不同的数据集及模型上表现也会有差异,因此在训练模型的过程中,激活函数的选择也是需要调节的重要参数。
神经网络的灵感一部分来源于人们对于大脑本身的探索,对于人脑的模拟可以说是研究人工智能的一大起点。人工神经元包含了线性与非线性运算,按照万能近似定理,由多个及多层神经元组合的网络可以逼近任何复杂的运算,有潜力解决一些复杂的现实问题。另外,神经网络中的非线性运算,即激活函数,目前人们构造了几十种函数,应用于各种不同的神经网络结构,深度学习工程师需要熟悉常用的激活函数及其优缺点,并在不同的场合下灵活运用。
多层感知机
在理解多层感知机(Multi-Layer Perception, MLP)之前,我们先简要地介绍深度学习的起源算法——感知机(Perceptron),事实上,多个感知机相连便成了深层次的感知机。感知机接受多个输入并且只有一个输出,模拟的便是人类神经元的基本结构,如下图所示为感知机的基本结构(还可以在纵向上进行扩充):
假设为xi输入,o为输出,wi以及b为参数,那么从输入到输出经历了什么样的历程呢?首先对输入进行线性运算:
接下来对线性运算结果z进行 signsign(符号函数)非线性运算得到最终结果o:
当z>0,sign(z)=1
当z=0,sign(z)=0
当z<0,sign(z)=−1
这就是感知机的基本原理,非常简单,可应用于二分类,但是对复杂一点的问题就无能为力了,因此应用场景十分有限。而多层感知机,又可称为深度神经网络(Deep Neural Network, DNN),是感知机的扩充结构,由输入层、隐含层(可以有多层)以及输出层构成,每一层之间都是全连接关系,如下图所示:
比较上两图,可以发现:
MLP 在横向上进行了扩充,具备多层,因此能够进行更加复杂的运算,增强了模型的表达能力;
模型的输出能有多个,可以针对多分类问题;
感知机所应用的非线性函数(或者说激活函数)是简单的 sign,处理过程过于粗暴,输出形式过于简单,而 MLP 结构可以应用 Sigmoid、Softmax、tanh 以及 ReLU 等,而且在不同地方分情况应用,拟合能力进一步增强。
我们可以简单地理解为:感知机就像是人脑中单层神经元,信息处理方式简单,只能解决一些异常简单的问题,而 MLP 则是由众多的神经元构成的巨大网络,便能够协同运作,对信息进行复杂的加工以处理更有难度的问题。
其实, MLP 在上世纪 80 年代就相当流行,但是由于环境限制效果不如支持向量机(SVM),如今随着数据量的增多、计算力的增强,MLP 的结构能够设计地更加复杂,因此又在深度学习的浪潮下强势回归,一般在具体应用中与其它神经网络结构结合使用。
综合而言,多层感知机即由多次线性和非线性结合而成的数学表达式,接收特定形式的输入,通过运算,得到特定形式的输出。这里的输入输出可以理解为我们的实际数据,比如输入为“学生平时的行为”,输出为“成绩”,输入为“房产的地段交通等信息”,输出为“房价”。对于人类的大脑而言,神经网络中的机理是已经比较符合逻辑并作一定的推理预测出输出,而对于人工设计的神经网络来说,我们只设计了其形式,但其中的参数值,一开始是随机给定的,所以在一开始,我们可以认为,对于某一输入,经过神经网络运算之后,其结果可能是“乱来的”,并不符合事实情况。
这就涉及到了一个问题,在具备一些训练数据(即数据具备输入输出)的情况下,如何训练神经网络中的这些参数,使得它们具备比较靠谱的预测能力呢?
最初,所有的神经网络的参数随机初始化,对于输入向量,这些参数通过网络计算可以决定输出向量。而对于给定的输入,我们已知期望的输出,那么可以通过比较预测的输出与期望间的差距(对于不同的任务会应用不同的「损失器」来衡量,比如分类任务中用「交叉熵」),即错误的程度,基于此进行网络的参数修正,在神经网络中一般应用梯度下降法作为优化方法(具体的优化方法又可分为 SGD、Momentum、Adam 等)。在进行多次参数修正,直到输出误差低于制定的标准以后,我们就得到了一个学习过的人工神经网络,该网络被认为是可以接受新输入并预测出靠谱输出的,因为其从训练样本(标注数据)和错误(误差传播)中学习到了知识。
总结而言,有了一些训练数据(包含输入输出),设计好了神经网络模型的结构并初始化模型参数,我们把训练数据喂给模型,应用梯度下降法更新模型参数,在理想情况下就能够得到一个比较聪明的神经网络,对于当前任务下的新的输入,能够预测其输出,作出合理决策。
基于 PyTorch 搭建多层感知机
在神经网络实践中,一般借助于神经网络框架进行深度学习模型的搭建,在本课程中,我们均应用 PyTorch 进行相关实验。
PyTorch 于 2017 年由 Facebook 人工智能研究院开源,但其设计理念源于早在 2002 年就诞生于纽约大学的 Torch,它基于一门小众语言 Lua 作为接口,所以应用人群不是很多。PyTorch 的幕后团队考虑到 Python 的普适性以及传播性,在对 Torch 进行重构的基础上推出了 Python 版本的 Torch,即 PyTorch。
PyTorch 在学术界优势很大,关于用到深度学习模型的文章,除了 Google,其他大部分都是通过 PyTorch 进行实验,原因有二:
PyTorch 库足够简单,和 NumPy,SciPy 等可以无缝连接,而且基于 tensor 的 GPU 加速非常给力。
训练网络迭代的核心——梯度的计算,Autograd 架构(借鉴于 Chainer),基于 PyTorch 以动态地设计网络,而无需笨拙地定义静态网络图,才能去进行计算,想要对网络有任务修改,都要从头开始构建静态图。基于简单,灵活的设计,PyTorch 速成为了学术界的主流深度学习框架。
当然,PyTorch 的劣势在于模型部署,在这一方面 TensorFlow 更有优势。
基于 PyTorch 中功能丰富的工具类,你会发现,搭建神经网络并不是你想象中的那么难。接下来应用 PyTorch 搭建一个简单的多层感知机网络。
在 PyTorch 里面自定义一个模型,一般需要继承 Module 类,并且一定要实现两个基本的函数:
构造函数 init(),实现网络层的定义。
层的逻辑运算函数,即所谓的前向计算函数 forward() 函数,在其中实现前向运算,即输入在模型中的运算过程。
import torch.nn as nn
import torch.nn.functional as F
class Net(nn.Module):
# 模型定义,继承 Module 类
def __init__(self, input_dim, hidden_dim, output_dim):
super(Net, self).__init__()
# 隐藏层,参数大小:input_dim * hidden_dim
self.fc1 = nn.Linear(input_dim, hidden_dim)
# 输出层,参数大小:hidden_dim * output_dim
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.fc1(x) # 输入 x 经过隐藏层线性运算
x = F.relu(x) # 经过激活函数 ReLU 运算
x = F.relu(self.fc2(x)) # 经过输出层线性及激活运算,最后输出
return x
以上我们构建了一个简单的多层神经网络,如果不特别定义如何初始化参数,模型会以默认的方式预初始化参数。接下来可以初始化此类,注意需要确定三个参数,假定我们当前有一批数据,输入为房产的各种特征(包括面积、装修风格、小区绿化面积、周围学校个数等),可综合表示为 128 维的向量,而输出为房产的价格,分为“高,中,低”三种类型,那么这里的参数可分别设定为:
INPUT_DIM = 128 # 输入数据的大小为 128
HIDDEN_DIM = 216 # 隐层大小为 216
OUTPUT_DIM = 3 # 输出大小为 3
初始化模型 Net:
my_first_model = Net(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM)
print(my_first_model) # 打印显示模型结构
随机初始化一批数据 x(在模型训练中,一般是一批批数据进行训练,因此每次输入模型的数据有多个),输入模型,察看输出结果。
import torch
# 生成模拟数据
N = 10 # 假设一批数据为 10 个
x = torch.randn(N, INPUT_DIM) # 数据大小: [10,128]
注意,在 PyTorch 中没有调用模型的forward()前向传播,只实列化模型类后把输入传入即可。这是因为 Python 类中的 _call_ 可以让类像函数一样调用,在基类 nn.Module 中实现了此函数,因此,当执行 model(x) 的时候,底层会自动调用 forward() 方法计算结果。
例如,定义类 A,定义 _call_() ,函数内部调用了 forward():
class A:
def __call__(self, param):
res = self.forward(param)
return res
def forward(self, input_):
print('forward() 函数被调用了!')
return input_
初始化类 A,直接输入 forward() 所需要的参数,不用基于 A.forward() 的形式来调用:
a = A()
input_param = a('i')
对于初始化的 my_first_model,同理:
output = my_first_model(x) # 不需要调用 forward()
print(output)
print(output.shape)
观察上述输出,可知其大小为10∗3,对应 10 个输入,每个输入对应一个 3 维的向量,一般,向量中哪个值越大,可认为模型预测为哪个类别(房价“高,中,低”)。
接下来,我们随机生成一些输入输出数据,并且应用梯度反向传播的方式更新模型的参数。
# 随机产生一百对输入输出数据
x = torch.randn(100, INPUT_DIM)
y = torch.randint(0, 3, (100,)) # 输出值为 0,1,2 三种类别
x, y
定义损失器,用于衡量模型预测值与真实值的差别。这里作为分类任务,采用交叉熵衡量预测值与真实值的差别。假设对于某一样本,预测值为:[0.1,0.2,0.7],真实值为:[0,0,1](表示真实值为第三种类别),那么损失值为:
loss = - (0log0.1 + 0log0.2 + 1*log0.7) = 2.3
当损失值越小,说明预测值与真实值越相近。
criterion = torch.nn.CrossEntropyLoss()
criterion
这里需要注意的一点是,损失函数 nn.CrossEntropyLoss() 结合了 nn.LogSoftmax() 和 nn.NLLLoss() 两个函数的运算过程,前者对输出值进行 Softmax 以及取对数地运算,后者进行交叉熵的损失运算,因此如果使用 nn.CrossEntropyLoss() 为损失函数,在模型 Net() 中,我们便不需要对最后的输出进行 Softmax 计算其概率值了。
定义优化器,即梯度反向传播的方式,这里应用了比较常见的 Adam 的梯度下降方法。
optimizer = torch.optim.Adam(my_first_model.parameters(), lr=0.01)
optimizer
梯度下降法是神经网络模型训练最常用的优化算法,理想的梯度下降算法要满足两点:收敛速度要快;而且能趋向于全局收敛。为了这个目标,出现了很多经典梯度下降算法的变种,比如 SGD、Momentum、NAG、Adam 等,其中,Adam 是一种比较稳定、综合能力强的方法。
接下来是训练过程:
for t in range(10): # 应用数据反复训练 10 次,也称为 10 个 epoch
# 第一步:数据的前向传播,计算预测值 p_pred
y_pred = my_first_model(x)
# 第二步:计算计算预测值 p_pred 与真实值的误差
loss = criterion(y_pred, y)
print(f"第 {t} 个 Epoch, 损失是 {loss.item()}")
# 在反向传播之前,将模型的梯度归零
my_first_model.zero_grad()
# 第三步:反向传播误差
loss.backward()
# 直接通过梯度一步到位,更新整个网络的训练参数
optimizer.step()
以上便是我们应用一个简单的多层模型以及模拟数据进行模型训练的过程。在真实的项目中,其实也可以概括为这么几个步骤:
训练数据预处理
模型、损失器、优化器定义
模型训练及测试