一、背景
目前chat-GPT大行其道,又一次掀起了神经网络应用的热潮,无论是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?
现在救命稻草来了,本文帮助爱编程的你对神经网络的掌握从零基础达到进阶级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这篇文章程序员专享。
虽然会有很多公式你也许听不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序员,所以我写的代码也不会很差)。
本文将带领大家深入了解神经网络的历史、技术特点,重点探讨神经网络的推导、拟合、迭代(反向传播算法)、加速(梯度下降),并带领大家用代码实现出来,让大家具备能够设计自己的神经网络的能力。
相信很多同学都有类似疑问,能提出这个问题的同学说明都是探索或使用过神经网络的。这个问题我们先不回答,如果大家有耐心看完全文相信会有答案(字数较多,先吸口气耐心读完)。
二、神经网络是什么?
神经网络由神经元构成,神经元见上图。对于神经元的研究由来已久,1904年生物学家就已经知晓了神经元的组成结构。
一个神经元通常具有多个树突,主要用来接受传入信息;而轴突只有一条,轴突尾端有许多轴突末梢可以给其他多个神经元传递信息。轴突末梢跟其他神经元的树突产生连接,从而传递信号。这个连接的位置在生物学上叫做“突触”。
神经网络就是通过神经元树突和轴突递次链接形成整个网络构成。
三、神经网络发展史
神经网络自发展开始,基本每10年一次兴起,本次正好对应chat-GPT上。
硬件和算法持续发展反过来也持续推进神经网络的演进,特别是GPU的并行加速的能力的急速提升,最新的Nvidia A100达到8192个cuda核,超大的计算能力更是加速了神经网络的高速发展。
四、神经网络实现
我们按具备计算能力的层数来区分神经网络的层数,其中输出层也是计算层。
首先从单个计算层神经网络感知器和线性单元谈起:
1、perceptron(感知器,单层神经网络)
1958年,计算科学家Rosenblatt提出,权重是训练的,感知器类似一个逻辑回归模型,可以做线性分类任务。
感知器由以下部分组成:
a、输入
一个感知器可以接收多个输入。
b、权重
每个输入上有一个权值w,此外还有一个偏置项b,也就是w0
c、激活函数
这里采用阶跃函数
如果换成线性函数f(z)=z,网络模型就变成线性单元。
d、输出
y=激活函数(w.x+b),矩阵点乘。
e、训练原理
原理部分不做强求,跳过也不影响对框架进行编码实现。
=========下面分割线内进入公式告警区========
这里为了推导方便,我们使用线性单元进行推导,仅需在感知器的基础上把激活函数换成线性函数f(z)=z即可。
i 目标
神经网络的本质目标就是拟合,即找到一个函数 y=f(x)使之最佳地吻合现有数据点(来自训练集)的过程。
拟合函数y=激活函数(w.x+b),这里拟合函数原型是确定的,激活函数也是确定的,只要找出满足条件的w和b即可。
ii 如何观测目标达成
即怎样表达拟合效果的好坏呢?我们首先要定义目标函数,也叫损失函数,
其中,表示e表示标记值(来自训练集)和计算值之间的方差。
目标函数E(w)定义为:
用它来标识标记值和计算值的整体偏差,拟合的目标就是对所有训练数据的损失和尽可能的小。至于为啥要除以2,只是为了消除系数,对结果没有影响。其中w就是神经网络里面的权重。
为了得到更好的w,我们只需要找E(w)对训练数据的近似最小值即可。
iii 如何找到目标
这里使用迭代法来找E的近似最小值,即通过从一个初始估计出发寻找一系列近似解来解决问题(一般是解方程或者方程组)的数学过程。
iv 如何提效过程
此时这个问题就被转化为一个优化问题。一个常用方法就是高等数学中的求导,但是这里的问题由于参数不止一个,求导后计算导数等于0的运算量很大,所以一般来说解决这个优化问题使用的是梯度下降算法。
梯度下降算法每次计算参数在当前的梯度,然后让参数向着梯度的反方向前进一段距离,不断重复。梯度越大的地方,我们希望迭代步子大点,加速收敛;反之,意味接近极点了,步子小点,以免跳过极值,直到梯度接近零时截止。一般这个时候,所有的参数恰好使损失函数达到一个最低值的状态。
这样核心问题就变成计算E(w)的梯度,梯度中最重要的是E(w)的偏导数。
和的导数等于导数的和:
我们现在只关注求和中的项,根据链式求导:
至此,大功告成。
==========告警解除=========
v 再次优化
上述推导算法叫做批梯度下降(Batch Gradient Descent):每次更新的迭代,要遍历训练数据中所有的样本进行计算,如果我们的样本非常大,比如数百万到数亿,那么计算量异常巨大。
优化算法叫随机梯度下降算法(Stochastic Gradient Descent, SGD):每次更新的迭代,只计算一个样本。
SGD不仅仅效率高,而且随机性有时候反而是好事。今天的目标函数是一个『凸函数』,沿着梯度反方向就能找到全局唯一的最小值。然而对于非凸函数来说,存在许多局部最小值。随机性有助于我们逃离某些很糟糕的局部最小值,从而获得一个更好的模型。
采用SGD的最终迭代公式:
t为训练数据的实际值;y为计算值;
是一个称为学习速率的常数,其作用是控制每一步调整权重的幅度。
每次从训练数据中取出一个样本的输入向量,使用感知器计算其输出,再根据上面的规则来调整权重。每处理一个样本就调整一次权重,经过多轮迭代后(即全部的训练数据被反复处理多轮),就可以训练出感知器的权重,使之达到目标函数的要求。
f、设计感知器
我们设计一个感知器,使用迭代训练方法,原理见上。用它来实现and运算,我们采用监督学习(给出输入特征和对应的标注好的输出标签迭代出权重)的方式,训练数据如下:
i 模型代码
from functools import reduce
class Perceptron(object):
def __init__(self, input_num, activator):
'''
初始化感知器,设置输入参数的个数,以及激活函数。
激活函数的类型为double -> double
'''
self.activator = activator
# 权重向量初始化为0
self.weights = [0.0 for _ in range(input_num)]
# 偏置项初始化为0
self.bias = 0.0
def __str__(self):
'''
打印学习到的权重、偏置项
'''
return 'weights\t:%s\nbias\t:%f\n' % (self.weights, self.bias)
def predict(self, input_vec):
'''
输入向量,输出感知器的计算结果
'''
# 把input_vec[x1,x2,x3...]和weights[w1,w2,w3,...]打包在一起
# 变成[(x1,w1),(x2,w2),(x3,w3),...]
# 然后利用map函数计算[x1*w1, x2*w2, x3*w3]
# 最后利用reduce求和
return self.activator(
reduce(lambda a, b: a + b,
[x*w for x,w in zip(input_vec, self.weights)]
, 0.0) + self.bias)
def train(self, input_vecs, labels, iteration, rate):
'''
输入训练数据:一组向量、与每个向量对应的label;以及训练轮数、学习率
'''
for i in range(iteration):
print(list(self.weights))
self._one_iteration(input_vecs, labels, rate)
def _one_iteration(self, input_vecs, labels, rate):
'''
一次迭代,把所有的训练数据过一遍
'''
# 把输入和输出打包在一起,成为样本的列表[(input_vec, label), ...]
# 而每个训练样本是(input_vec, label)
samples = zip(input_vecs, labels)
# 对每个样本,按照感知器规则更新权重
for (input_vec, label) in samples:
# 计算感知器在当前权重下的输出
output = self.predict(input_vec)
# 更新权重
self._update_weights(input_vec, output, label, rate)
def _update_weights(self, input_vec, output, label, rate):
'''
按照感知器规则更新权重
'''
# 把input_vec[x1,x2,x3,...]和weights[w1,w2,w3,...]打包在一起
# 变成[(x1,w1),(x2,w2),(x3,w3),...]
# 然后利用感知器规则更新权重
delta = label - output
self.weights = [w+rate*delta*x for x,w in zip(input_vec, self.weights)]
# 更新bias
self.bias += rate * delta
ii 测试代码
from perceptron import Perceptron
def f(x):
'''
定义激活函数f
'''
return 1 if x > 0 else 0
def get_training_dataset():
'''
基于and真值表构建训练数据
'''
# 构建训练数据
# 输入向量列表
input_vecs = [[1,1], [0,0], [1,0], [0,1]]
# 期望的输出列表,注意要与输入一一对应
# [1,1] -> 1, [0,0] -> 0, [1,0] -> 0, [0,1] -> 0
labels = [1, 0, 0, 0]
return input_vecs, labels
def train_and_perceptron():
'''
使用and真值表训练感知器
'''
# 创建感知器,输入参数个数为2(因为and是二元函数),激活函数为f
p = Perceptron(2, f)
# 训练,迭代10轮, 学习速率为0.1
input_vecs, labels = get_training_dataset()
p.train(input_vecs, labels, 10, 0.1)
#返回训练好的感知器
return p
if __name__ == '__main__':
# 训练and感知器
and_perception = train_and_perceptron()
# 打印训练获得的权重
print (and_perception)
# 测试
print ('1 and 1 = %d' % and_perception.predict([1, 1]))
print ('0 and 0 = %d' % and_perception.predict([0, 0]))
print ('1 and 0 = %d' % and_perception.predict([1, 0]))
print ('0 and 1 = %d' % and_perception.predict([0, 1]))
iii 效果
可以很好地实现线性分类:
iv 局限
只能处理线性问题,非线性问题比如异或没法处理:
2、多层神经网络(以全连接网络为例,两个或两个以上计算层)
a、结构
跟感知器比,多了1-n个中间隐藏层
b、激活函数
我们选择:
其导数也比较有趣:
c、超参
神经网络的连接方式、网络的层数、每层的节点数
d、计算方式
e、训练原理
========以下又要进入公式警告区,酌情进入=======
根据梯度下降
而
根据链式求导
其中net为节点j加权输入,为
下面变成求
因为多计算层神经网络有隐藏层,所以对输出层和隐藏层计算方式并不相同。
i 输出层
ii 隐藏层
稍微麻烦一点
假设Downstream(j)为节点j的下游集合
设
汇总
===========告警解除===========
f、优势
多层神经网络可以无限逼近任意连续函数,功能更加强大。
这是什么意思呢?也就是说,面对复杂的非线性分类任务,多层(带隐藏层)神经网络可以分类得很好。
红色的线与蓝色的线代表数据。而红色区域和蓝色区域代表由神经网络划开的区域,两者的分界线就是决策分界。(案例来自colah blog)
我们可以把输出层的决策分界单独拿出来看一下,就是下图。
可以看到,输出层的决策分界仍然是直线。关键就是,从输入层到隐藏层时,数据发生了空间变换。也就是说,多层神经网络中,隐藏层对原始的数据进行了一个空间变换,使其可以被线性分类,然后输出层的决策分界划出了一个线性分类分界线,对其进行分类。
这样就导出了多层神经网络可以做非线性分类的关键--隐藏层。联想到我们一开始推导出的矩阵公式,我们知道,矩阵和向量相乘,本质上就是对向量的坐标空间进行一个变换。因此,隐藏层的参数矩阵的作用就是使得数据的原始坐标空间从线性不可分,转换成了线性可分。
多层神经网络通过多层的线性模型模拟了数据内真实的非线性函数。因此,多层的神经网络的本质就是复杂函数拟合。
g、实现
综上可以看出,对于任一隐藏层j,计算其梯度时,都需要其后面层的节点属性参与,即每次迭代隐藏层权重w时,都需要从后往前计算,这就叫反向传播(back propagation)。
根据上述原理,我们就可以给出多层全连接神经网络的训练代码,这里还是采用DDD建模的方式,给出领域类图,大家可以自己尝试DDD建模。
- Network 神经网络对象,提供API接口。它由若干层对象组成以及连接对象组成。
- Layer 层对象,由多个节点组成。
- Node 节点对象计算和记录节点自身的信息(比如输出值、误差项等),以及与这个节点相关的上下游的连接。
- Connection 每个连接对象都要记录该连接的权重。
- Connections 仅仅作为Connection的集合对象,提供一些集合操作。
考虑到文章中每出现10行代码,阅读量会下降10%的规律,这里代码就不贴了,有兴趣的同学可以参照原理和类图自己实现。具体代码实现大家可以参加我的《AI神经网络架构战训营》,我们全手工,不用任何python三方库。如果需要参考代码可以跟我联系。
我采用mnist(手写数字识别,6万张训练图片,1万张测试图片)训练,超参设计:
- 连接方式:全连接网络
- 层数:784(输入层:mnist训练集,单张图片像素28*28)*300*10(输出层),共238510个参数
在我的机器(4c8g)上测试了一下,1个epoch大约需要9000多秒,准确率可以达到95%以上,所以要对代码做很多的性能优化工作,比如:
- 密集计算部分优化成c/c++
- 反向传播算法改成并行计算,实现多个节点同步或异步并行。
- 支持GPU计算
。。。
这里回答下开篇提到的问题,即有这么多开源库的情况下,为啥还要手工实现神经网络重新造轮子呢?
答案就是在真正生产场景训练神经网络过程中,必须深刻了解模型特性,才可能选择合适的模型和超参,使之与要解决的问题以及数据特性匹配上;
另一方面为了提升准确率、预防过拟合和加快训练收敛过程,都需要对模型进行反复遴选和组合、对超参进行频繁调整,调整的方向和幅度等都需要深入了解模型原理才能做到。
学习神经网络建模,掌握模型原理并亲手进行简易实现是学习过程中必不可少的环节,不可逾越。
五、小结
成年人的注意力集中的时间是非常短暂的,恭喜你看到这里。说明你的学习力已经超越大多数成年人。
学到这里你已经掌握了神经网络的基本原理,如果有兴趣的话,甚至可以动手实现一个神经网络并能解决手写体识别等问题。
而且一旦掌握了这些概念和算法,再学习其他神经网络,比如卷积神经网络、循环神经网络、长短记忆神经网络、递归神经网络,就事倍功半了,一个好的开始往往是成功的一半,恭喜你又收获了一个硬知识。
这里只是第一课,后续会择机开展其他神经网络的剖析和介绍。
六、参考资料
1.Tom M. Mitchell, "机器学习", 曾华军等译, 机械工业出版社
2.CS 224N / Ling 284, Neural Networks for Named Entity Recognition
3.LeCun et al. Gradient-Based Learning Applied to Document Recognition 1998
4.零基础入门深度学习,韩炳涛