小刚带你深入浅出深度学习

1、前言

近些年深度学习的突然兴起,以及其令人惊讶的迅猛发展,让人类不得不再次感慨造物主的神奇。就如同人类通过蝙蝠能精准的避开障碍物飞行发明了雷达,仿照鸟类使用翅膀飞行制造出了飞机一样,罗森布拉特根据生物神经细胞简单的抽象出了第一代的“感知机”。可别小看这个感知机,即便是当今最大的深度学习模型,它的底层也是也是由“感知机”所描述的:如权量(突触)、偏置(阈值)及激活函数(细胞体)所组成。

然而人们从对深度学习的”认识“ -> “普遍接受” -> “疯狂痴迷”的转变并非一帆风顺,这中间经历了2次寒冬,我们在”深度学习突然崛起的历史必然性“ 会详细介绍。尽管如此曲折,但最终还是迎来了属于它的高光时代。

深度学习的神经网络架构并非”盲目“的从形式上模拟人类大脑的神经元传输结构。近些年数学家们也越来越清晰的认识并能从数据的角度清楚的解释为啥多层的神经网络能解决复杂的问题了。我们将在“从数学角度理解神经网络为何能解决复杂问题” 为大家阐明这一点。

深度学习这一概念那么的充满神秘色彩,是否真的那么的遥不可及?不是的,幸运的是一些成熟的深度学习深度学习框架(例如:pytorch)拉近了普通程序员,科学家们与这个神秘结构的距离。了解这些框架的核心组成,能让我们不仅仅会用这些框架,更能了解其运行的原理。“深度学习底层框架解析”会揭开PyTorch深度学习框架内部的结构。

最后我们会以一个简单的pytorch示例开启我们的深度学习深度学习之旅~

2、深度学习突然崛起的历史必然性

2.1、机器学习,深度学习是啥?

机器学习理论主要是设计和分析一些让计算机可以自动“学习”的算法。机器学习算法是一类从数据中自动分析获得规律,并利用规律对未知数据进行预测的算法。其中包括监督学习、无监督学习和强化学习等多种学习方式。

深度学习(英语:deep learning)是机器学习的分支,是一种以人工神经网络为架构,对资料进行表征学习的算法。深度学习中的形容词“深度”是指在网络中使用多层。 早期线性感知器不能成为通用分类器,但具有非多项式激活函数和一个无限宽度隐藏层(在深度学习的多层中位于输入层和输出层之间)的网络可以成为通用分类器。

2.2、深度学习突然崛起是这个时代的产物

2.2.1、第一次寒冬

这张图我们看到从罗森布拉特提出了 ”感知机“ 后,并没有被广泛的普及。明斯基&佩珀特认为感知机只能解决线性可分问题,而且即使增加隐层的数量,由于没有有效的学习算法,感知机也很难实用。明斯基对感知机的批评是致命的,使刚刚起步的连接主义 AI 跌入低谷达 10 多年之久。

我们接下来先回到历史的这个转折点,了解一下:感知机的原理、它的局限性、反向传播如何解决了这个问题。

2.2.1.1、感知机的原理

”感知机“ 是有美国学者 Frank Rosenblatt(罗森布拉特) 在1957年提出来的,它也是深度学习的起源算法。

感知机 它的形式为接收多个输入信号输出一个信号,它的这种表现形式是生物神经细胞的简单抽象。下面是摘选”维基百科“中对”感知机“的描述:

感知机是生物神经细胞的简单抽象。神经细胞结构大致可分为:树突、突触、细胞体及轴突。单个神经细胞可被视为一种只有两种状态的机器——激动时为‘是’,而未激动时为‘否’。神经细胞的状态取决于从其它的神经细胞收到的输入信号量,及突触的强度(抑制或加强)。当信号量总和超过了某个阈值时,细胞体就会激动,产生电脉冲。电脉冲沿着轴突并通过突触传递到其它神经元。为了模拟神经细胞行为,与之对应的感知机基础概念被提出,如权量(突触)、偏置(阈值)及激活函数(细胞体)。

简而言之,感知机是一个根据加权输入的总和是否满足阈值来做出”是“或”否“(输出1或0)的决策的简易程序。在生活中,你可能会以下面这样的方式做出一些决定。例如:你会从一些朋友那里了解到他们有多喜欢一部电影,但你相信其中几个朋友对于电影的品味比其他人更高,因此,你会给他们更高的权重。如果朋友喜爱程度的总量足够大的话(即大于某个无意识的阈值),你就会决定去看这部电影。如果感知机有朋友的话,那么它就会以这种方式来决定是否看一部电影 —— 摘自《AI 3.0》

下图是一个接收两个输入信号的感知机例子。x1、x2是输入信号,y是输出信号,w1、w2是权重(w是weight的首字母),图中○表示为一个”神经元“。
输入信号被送往神经元时,会被分别乘以固定的权重(w1x1、w2x2)。神经元会计算传送过来的信号的总和,只有当这个总和超过了某个阈值时,才会输出 1。这也称为 ”神经元被激活“
图2.1

感知机的运行原理只有这些了! 整体来说它关注的主要是权重激活。把上述内容用数学式子来表示,就是:

2.2.1.2、感知机的研究之旅已经走入了死胡同?

明斯基等 于 1969 年出版的书《感知机》中指出,感知机只能解决线性可分问题。简单来说早期的”感知机“ 只有一层,它无法解决线性不可分问题。这个其实可以通过增加层数来解决(下面会提到)。然而明斯基等也接着提出:”而且即使增加隐层的数量,由于没有有效的学习算法,感知机也很难实用“

2.2.1.2.1、感知机只能解决线性可分问题

上面我们介绍了感知机的结构,它只有一层神经网络,只能解决线性可分问题,比如它只能解决”与门“、”或门“、却无法实现”异或门“。

举个例子,我们将感知机的权重参数设置为(b, w1, w2) = (-0.5, 1.0, 1.0)时,满足下面的真值条件,用下面的式子表示:

偏置:偏置单元有时候也能理解为截距项,就是函数的截距,与一般的线性方程y=Wx+b中的b的意义是一样的,控制着函数偏离原点的距离

上面式子表示的感知机会生成由直线 -0.5 + x1 + x2 = 0 分割开的两个空间。其中一个空间输出1,另一个空间输出0,如下图所示:

x1,x2,y值对应的关系如下:

x1 x2 y
0 0 0
1 0 1
0 1 1
1 1 1

上面的图展示了感知机解决了 ”或门“ 将△和○分开的例子。如果换成异或门呢?
异或门的x1,x2,y的对应关系如下:

x1 x2 y
0 0 0
1 0 1
0 1 1
1 1 0

我们用○表示0的值,△表示1的值

如图所示,我们无法用一条直线将○和△区分开,只能用到像图中橙色的曲线分割。在数学中,由直线分割的空间称为线性空间,由曲线分割而成的空间称为非线性空间

如明斯基等人所说—— “再加一层可以解决这个问题” 。我们尝试加一层试试:
我们知道”与门“,”非门“,”与非门“,”或门“ 都是线性可分的,我们试着引入多层并将它们组合一下解决这个问题。

这个计算式由两个输入(x1,x2),两层和一个输出(y)组成,第一层的输出对应第二层的输入(s1, s2),x1,x2,s1,s2,y的值的对应关系如下:

x1 x2 s1 s2 y
0 0 1 0 0
1 0 1 1 1
0 1 1 1 1
1 1 0 1 0

我们把第一层的输出s1,s2盖住(隐藏层),只看x1, x2, y, 发现其结果已经满足了 “异或门” 的要求了。
这个就是感知机的初级形态,它已经初步向人类通过线性分类器的多层组合能解决一些复杂的数据问题了。但是随着参数增加,层数加深,感知机遇到了计算性能问题,下面的”反向传播“就是解决这个问题

2.2.1.2.2、增加层数能解决复杂的数学问题
激活函数:

谈到这个问题,这里引入一个概念—— ”激活函数“
实际上神经网络是通过一系列的线性变换(上面聊感知机时提到的)和可微激活(我们接下来会提到)来建立模型,这样可以得到近似高度非线性过程的模型。

在深度神经网络中,最简单的单元是线性运算(缩放+偏移),然后是 激活函数激活函数 有两个重要作用:

  • 在模型内部,它允许输出函数在不同的值上有不同的斜率,这个是线性函数无法做到的。通过巧妙地为许多输出设置不同的斜率,神经网络可以近似任意函数。这就决定了它能解决复杂的数学问题。
  • 在网络的最后一层,它的作用是将前面的线性运算的输出集中到给定的范围内。

让我们来看看第二个作用是什么意思。假设我们正在给图像分配一个识别为动物”狗“的分数。此时一些狗的品种(例如:金毛犬等)的图片应该得分较高,而飞机和垃圾车的图片应该得分低。”熊“的图片得分也应该低,不过它的得分会比垃圾车的图片得分会高一些。

问题是,我们必须定义一个”高分“:我们在神经网络中主要使用的是float32类型的整个范围,这意味着我们可能得到相当高的分数,这样定义高分也不合理。回到我们的网络模型,模型的输出实际是上面我们了解到的线性方程 w*x + b的输出。这些乘法不会自然的限制在特定的输出范围

那么我们现在的目标是要将线性操作的输出严格的限制在一个特定的范围内,这样输出的接收者就不必处理例如”小狗“为12分,”熊“为-10分,”垃圾车“为-1000分的数值输入了

在神经网络中常见的做法是使用函数: Sigmoid(), tanh双曲正切函数等。这些函数的曲线在输入 x 趋于负无穷大时逐渐接近 0 或 -1。从概念上讲,以这种方式形成的函数工作很好,因为在线性函数输出的中间有一块区域,我们的神经元(同样,它只是一个随后被激活的线性函数)对它很敏感,而其它所有东西都集中在边界值旁边。正如我们在下图看到的,垃圾车的图片得分为-0.97,而熊的图片的得分在-0.3~0.3.

结果,垃圾车的图片被标记为”不是狗“,一张狗的图片被标记为”显然是狗“,而熊的图片则被被标记为介于这二者之间的某个地方。如果我们用pytorch来识别,我们会得到识别后的输出值,垃圾车的线性输出值为-2.2,经过激活函数(双曲正切函数tanh)后输出为 -0.9 ;熊为线性输出值为 0.1,经过激活函数后输出为 0.099 ,狗的图片线性输出值为2.5,而经过激活函数后输出为 0.986

我们发现线性函数输出值是不确定的,但是经过 激活函数 后能限定在一个可理解的范围。另外我们也发现当熊的图片在敏感范围内时,对熊的图片的微小改变将导致显著的变化。例如:我们可以从熊切换到北极熊(北极熊有一张模糊的,更类似犬科的脸),当我们滑向图中”非常像狗“的一端时,可以看到y轴就会向上升。相反,树袋熊会表现的不像狗,我们会看到激活函数输出的结果会下降。我们没有办法把垃圾车弄的看起来像狗,因为即使有剧烈的变化,我们可能也只能看到从-0.97到-0.8左右的变化。

深度学习领域除了tanh激活函数,还有相当多的激活函数,例如:Hardtanh, sigmoid, softplus, ReLu, LeakyReLu等等。激活函数是很奇怪的,因为有这么多被证明是成功的函数,这说明对这些函数几乎没有什么严格的要求。因此,我们将讨论一些关于激活函数的一般性问题,这些问题可能在特定情况下被简单的证明是错误。也就是说,根据定义,激活函数有如下特性:

  • 激活函数是非线性的。在没有激活函数的情况下,重复应用(w * x + b)会导致相同线性形式(仿射线性)的函数。非线性使得整个网络能够逼近更复杂的函数。
  • 激活函数是可微的,因此可以通过它们计算梯度。
  • 它们至少有一个敏感范围,在这个范围内,对输入的变化会导致输出产生相应的变化,这是训练所需要的。
  • 它们包含许多不敏感(或饱和)的范围,即输入的变化导致输出的变化很小或没有变化。

综合起来,所有这些组成了一个非常强大的机制:在一个由线性+激活单元构成的网络中,当不同的输入呈现给网络时,不同的单元会对相同的输入在不同范围内响应;与这些输入相关的错误将主要影响在敏感区域工作的神经元,使其它单元不受学习过程的影响。

我们开始读如何加入许多线性+激活单元并将它们一个接一个的叠加,从而得到一个能够近似复杂函数的数学对象有了更深的理解。

解决复杂的数学问题:

通过一系列线性变换和可微激活建立模型,可以得到近似高度非线性过程的模型,并且通过梯度下降(下面会提到)可以很好的估计其参数。即使在处理具有数百万个参数的模型时,这一点仍然适用。深度神经网络之所以如此吸引人,是因为它使我们不必过于担心代表数据的确切函数,不管它是二次函数、分段多项式函数还是其它函数。对于深度神经网络模型,我们有一个通用逼近器和一种估计其参数的方法(下面会提到)。这个逼近器可以根据我们的需求进行定制,就模型容量和建模复杂输入输出关系的能力而言,只需要组合简单的构建块。

我们看一些例子,下面图中显示了4个神经元——A,B,C和D,每个神经元都有自己的权重和偏置。每个神经元使用tanh激活函数(最小值为-1,最大值为1)。不同的权重和偏置会移动中心点,并改变从最小值到最大值过渡的剧烈程度,但它们显然都具有相同的总体形状。

接下来我们将2对神经元相加(A+B,C+D)。这时,我们开始看到一些有趣的特性,它们模拟了单个神经元层。A + B 显示了一个轻微的S曲线,极值接近于0,但在中间同时有一个正凸起和一个负凸起。相反, C + D只有一个较大的正凸起,其峰值高于我们的单神经元最大值1.

接下来我们开始组合神经元,就像2层网络一样: C(A + B)和 D (A + B) 。这两个组合和 A + B 所显示的正凸起和负凸起相同,但正峰更为微妙。C (A + B) + D (A + B) 的组合显示出一种新的性质:在主要感兴趣区域的左边,有2个明显的负凸点,可能还有非常微妙的第2个正峰。所有这些只有2层4个神经元!

选择这些神经元的参数只是为了得到一个视觉上有趣的效果。训练的目的是找到这些权重和偏置的可接受值,以便生成的网络正确的执行任务,例如根据地理坐标和一年中的时间预测可能的温度。成功的执行一项任务,意味着在与训练数据相同的的数据生成过程中生成的不可见数据上获得正确的输出。一个成功训练的网络,能通过它的权重和偏置捕捉数据的内在结构,以有意义的数值表示的形式正确的处理以前未见过的数据。

回到我们传统的物理学和数学研究,物理学家和应用数学家的工作往往是从第一原理出发,对一种现象进行功能性的描述,然后从测量中估计未知的参数,从而得到一个精确的,真实世界的模型。另一方面,深度神经网络是一组函数,能够近似出大范围的输入和输出的关系,而不需要我们对某一现象构建解释模型。在某种程度上,我们放弃解释,以换取解决日益复杂的问题的可能性。换句话说,我们有时缺乏能力、信息或计算资源来为我们所呈现的事物建立一个明确的模型,所以数据驱动是我们前进的唯一途径

2.2.1.3、反向传播

第一次接触”反向传播“这个术语会很陌生。”反向传播“是一种 数学算法 ,它是为深度学习神经网络优化参数而存在的,我们需要介绍以下内容后,大家才能对”反向传播“有所理解:

  1. ”反向传播“在神经网络中解决了什么数学问题?
  2. 为什么说它的出现把神经网络从”第一次寒冬”拉回到了春天?
2.2.1.3.1、”反向传播“在神经网络中解决了什么数学问题?

上面的章节我们了解到了神经网络是多层神经元之间的“权重”+“偏置”的线性函数交织成的神经网络。为了让这个网络能解决某些特定问题,需要有一套“合适这个问题”的所有参数的的“权重”,“偏置”值。我们通过上面的学习也了解到,大量的参数和他们对应的“权重”,“偏置”值能构成复杂的曲线函数,能“拟合”我们要解决的问题。

简单说“反向传播“就是要 ”找到这些参数的‘权重’,‘偏置’值“ 。有些人会有疑问,这个问题看起来也不是很难,为啥明斯基等人会认为这个问题是神经网络的”瓶颈“呢,甚至这个”瓶颈“让整个神经网络领域陷入了一段时间的”寒冬“。

我们先了解一下在神经网络中如何找到这些”权重“,”偏置“参数的:

  1. 找到误差
    初始情况下我们会随机设置全部参数的”权重“和”偏置“值,例如:w1,b1,w2,b2, 这里我们先假设有两个输入(x1, x2),一个输出y1(这里我们假设输出只有一个数值),在监督学习里面,我们会给出一个期望值y2。这两个值肯定会有误差,在神经网络中会通过”损失函数“定义这个误差。例如我们使用方差来定义这个误差—— (y1-y2)^2。

  2. 通过误差调整”权重“和”偏置“
    这里我们把期望值y2看做一个常量。这个方差的函数曲线的形状大概是这样的:
    alt text

我们想要这个函数的值最小,在数学上面可以用这个损失值((y1-y2)^2)对影响它的参数w, b求导,得出的结果为w, b的变化对损失值的影响(变化率),如果w, b往变化率(神经网络中叫 梯度 )正方向移动,那么损失值越大;相反如果w, b往变化率(梯度)反方向移动,那么损失值越小。从上图来看最小值在曲线的凹点,往往是导数为 0 的地方

于是神经网络接下来会这样计算梯度:
1. 先求损失函数对w, b每个的偏导数
2. 往导数的负方向移动一小段距离。在神经网络有个 学习率 的概念,假设我们定义该值为0.01。这个值乘以导数值,得到变化的一个 距离。我们用当前的w, b减去各自偏导数乘以学习率得到的各自的这个距离,得到这一次学习调整后的值。我们认为该次调整w 和 b 是对损失值往减小的方向在调整。
3. 神经网络需要不断的”学习“(重复步骤2)才能找到导数为0的地方,此时w和b基本不会变化,损失值也趋于一个很小的稳定范围。

整体的过程大致是下面这样:

2.2.1.3.3、为什么说它的出现把神经网络从”第一次寒冬”拉回到了春天?

上面一节我们了解到,神经网络中通过”学习“来优化”权重”和”偏置“参数,这个”学习“是神经网络的核心操作。如果这个操作有瓶颈,那么的确会影响到整个”学习“是否能进行下去。

”学习“过程再拆解,最耗时的瓶颈是求导。1969年,明斯基和佩珀特给出了一个数学证明,感知机学习算法随着任务规模的扩大需要大量的的权重和阈值,表现不佳

我们了解一下,传统的数学方式求导是怎么做的。常规做法是”数值微分“:使用二点估计法,如下图要计算曲线x处的斜率(即函数f 对 x 的导数)

我们可以经过(x-h,f(x-h))和(x+h,f(x+h))二点的割线,其斜率为:

不过这种计算方式会比较耗时,当今的神经网络模型参数可能达到几百万的规模,如果按照普通的通过函数在对于权重和偏置在某一个值的导数,这样学习基本是无法实现的。于是”反向传播“孕育而生。

要正确的理解误差反向传播,有两种方法:一种是基于数学式;另一种是基于 计算图 。这里推荐计算图的方式,在主流的神经网络框架中,也都是使用的计算图的方式(例如:pytorch)

从结构李戡,当数据从网络的一端输入,到另一端输出,中间经过多层叠加的线性方程和激活函数。整体串起来会形成一个复杂的计算图。这个计算图是由输入数据和基本操作符(+,-, *, , 乘方,幂 等等)串联而成。”反向传播“就如同它的名字,它计算导数的过程是从计算图的结果开始反方向向计算图的开始端传递来计算完成的。”反向传播“的计算效率主要体现在下面两个点:

  1. 导数计算的链式法则
  2. 计算图的局部计算优点

链式法则结合计算图的反向传播的流程图描述如下:

如果要求函数结果对x的偏导数,对应链式数学表达式为 dz/dx = dz/dz * dz/dt * dt/dx。接下来结合计算图的计算局部性,我们只需要计算**2(乘方),加法这些简单计算的导数即可,然后将局部的计算结果通过链式法则传递下去。

数学中我们学习了对于+,-,*,\,乘方等等的导数公式:

  • y = x^2,其中y对x的导数为 2x
  • z = x + y, 其中z对x的偏导数为1

这里是常见的数学函数及其导数的表格:链接

结合这些数学函数的导数计算公式,我们可以轻松的为每个数学运算符固化下来它们的导数计算方法,并通过上游反向传播过来的结果计算出函数结果对当前输入的偏导数了。上面计算图的导数计算如下下图所示:

以上就是”反向传播“的核心思想,我们用计算图的方式简单的讲解了它的计算原理,通过关键的两个点:导数计算的链式法则和计算图中单个运算符的导数计算公式能快速高效的完成某个参数的偏导数了。

2.2.2、第二次寒冬

纵然数学家们通过“反向传播”(反向传播,又叫BP算法,英语:Backpropagation,意为误差反向传播,缩写为BP,)巧妙的解决了深读学习网络的导数计算问题,但这次的寒冬主要是因为:

  1. 当时计算资源不足
  2. 训练的数据集也很小

这两个关键点直接导致无法满足训练深层网络的要求,从而导致神经网络发展逐渐放缓。我们现在来看其实神经网络能度过第二次寒冬也是历史发展所驱的,因为我们知道“数据”、“算法”、“算力”被认为是人工智能的三驾马车,这些属于基础建设,这些上不来,”神经网络“当然停滞不前。

随着更优的算法,更高性能的计算能力(GPU)和更大数据集的时代背景(大数据),也就是”神经网络“在这个时代的助力下顺利的度过了第二个寒冬,并迅速受到了世界各国相关领域研究人员和高科技公司的重视,让”伸进网络“迅速进入了可以投入应用的时代。到历史的这一节点,”神经网络“度过了艰难的两次”寒冬“,迎来了属于它的时代。

纵观”神经网络“的发展历史,深度学习的许多研究成果,离不开对大脑认知原理的研究,尤其是视觉原理的研究:

1981 年的诺贝尔医学奖,颁发给了 David Hubel(出生于加拿大的美国神经生物学家) 和TorstenWiesel,以及 Roger Sperry。前两位的主要贡献,是“发现了视觉系统的信息处理”,可视皮层是分级的。

人类识别气球的视觉原理如下:从原始信号摄入开始(瞳孔摄入像素 Pixels),接着做初步处理(大脑皮层某些细胞发现边缘和方向),然后抽象(大脑判定,眼前的物体的形状,是圆形的),然后进一步抽象(大脑进一步判定该物体是只气球)

这何尝不印证了人类再次通过”仿生学“致敬了造物主的神奇(大脑认知原理)。当然它能有今天的发展离不开属于它的”天时“,没有当今时代的”数据“和”算力“也许还会有很长一段时间处于第二次“寒冬”之中。

3、神经网络是如何运作的

3.1、神经网络的结构

上面第二章节我们了解到感知机增加层级的深度后是具备解决复杂的数学问题的能力的。我们再来回顾一下:

  • 将神经元的多个输入x,x2,…,xn 整理为加权输入z
    z = w1x1 + w2x2 + … + wnxn + b
    其中w1,w2,…, wn为权重,b为偏置,n为输入的个数。
  • 神经元通过激活函数a(z),根据加权输入z 输出y:y = a(z)

神经元的示意图如下:

将上面这样的神经元连接成网络状,就形成了神经网络

一个简单的全连接网络的结构如下:
划分神经元,通过这些神经元处理信号,并从输出层得到结果。

构成这个网络的各层称为:输入层、隐藏层(也叫中间层)、输出层。

3.2、分层结构是如何运作的

我们以下面这个例子来说明这个结构是如何运作的。

例题:建立一个神经网络,用来识别宽高为4x3像素的单色二值图像的手写数字0还是1.

神经网络示例:

这个示例中输入层的神经元总数为12个,分别对应图像数据的12个像素值。输出层由两个神经元构成,这是因为我们的题目是要识别两种手写的数字0和1。需要一个在读取手写数字0时输出较大值(即神经元被激活),以及一个在读取手写数字1时输出较大值的神经元。这里对隐藏层神经元的输出值应用了激活函数(我们上面知道,激活函数的作用能将输出值限定到一个范围),保证输出值范围为0-1.

即:

为啥输出层的两个神经元能分别对各自识别的手写数字能分别做出较大输出值的反应,是由于隐藏层具有提取输入图像的特征的作用。接下来会详细介绍:

由于真实手写的数字不都像上面图中那样规范,可能有各种风格,例如手写数字“0”可能有以下几种写法:

对于这种没有标准答案,识别较困难的问题,就可以交给“网络”来判断。我们用3层神经网络来描述这个识别过程。假设隐藏层有3个神经元A,B,C;输出层有2个神经元分别输出0和1;输入层有12个神经元分别对应图像的12个像素。如下图所示:

输入层像素值为黑色则活跃(值为1),为白色则不活跃(值为0)。隐藏层的3个神经元从输入层获得活跃度信息,将这些信息进行整合(对每个输入神经元有独立的权重值),根据其值的大小决定自己是否活跃,并继续将这个活跃度传递给上层(这里是输出层)。

由于隐藏层中的A,B,C有不同的喜好(即对各自的12个输入神经元有不同的权重值偏好),假设他们分别对3种模式有偏好:

继续往上看一层,这里有两个神经元,它们从下层的3个隐藏层的神经元哪里得到活跃度信息。同样的他们也将得到的活跃度信息进行整合,根据其值的大小决定自己是否活跃。此时如果“输出0”神经元的活跃度比“输出1”的神经元的活跃度打,那么神经网络就判定图像为数字0,反之则判定为1。

随着神经网络的学习的进行,不断的通过调整权重减小误差,渐渐的“输出0”神经元对神经元A、神经元C的权重加大;“输出1”神经元对神经元B的权重加大。此时输出层和隐藏的权重关系如下图所示(粗线表示权重大):

我们再看隐藏层、输入图像之间的权重关系和上面的关系串起来后的权重关系:

此时模型识别出来图像中为数字0了。

这个模型中我们遗漏了偏置,偏置在模型里面可以理解为“噪声”。如果这些噪声对神经对模式识别影响比较大,无法正确传递活跃度信息,此时就需要减少“噪声”,也就是偏置。

3.3、更多的模型

3.3.1、卷积神经网络-CNN

CNN,全称convolution neural network,卷积神经网络。我们上面识别0和1的简单网络结构属于全连接的神经网络,在全连接层中,相邻层的神经元全部连接在一起。而接下来介绍的卷积神经网络结构是专门为了解决图像识别问题而设计的。

3.3.1.1、卷积神网络解决的问题

那么全连接层存在什么问题呢?那就是数据的形状被忽视了。比如,输入的数据是图像时,图像是由宽,高,和颜色值(RGB)组成的3维形状。而我们上面看到的全连接层,它的输入会展开为一层的一维数据。例如:我们上面的数字图片是由4x3=12个单色像素组成,它是有形状的,它的形状是4x3x1, 而我们把它输入排成1列后,它的形状已经被我们忽视了。

再思考一下我们是怎么从一副内容复杂的图像,例如一间杂乱的杂物间图片里面去找一只隐藏在其中的铅笔的。我们脑海里是不是先框定一块扫描区域,这个区域大小和铅笔差不多的长组成的宽高一样的正方形区域(太大可能容易遗漏,太小扫描慢且也容易看不全),然后从图片的左上角开始一行一行的扫描,直到扫描到图片的右下角,这时把所有的铅笔都找出来了。而这个就和卷积神经网络的“卷积层”的结构,以及它的扫描方式很像。

我们再来举一个例子说明卷积网络的这种结构比全连接为啥更善于处理图像识别的问题。例如我们要在一张复杂的图片里面找是否包含”手拿着气球“这样的内容。如果用全连接神经网络则基本很难完成,回顾上面我们识别”0“和”1“的简单图片,我们的第二个隐藏层的三个神经元分别负责A,B,C三种模式,而输出层0神经元给予A,C的高权重让该神经元能轻松识别0,相反输出1神经元给予B高权重,让该神经元能快速识别1. 你有没有发现:A、B、C神经元都已经假设了包含黑颜色的像素出现的位置不能有太大的偏移,例如A是左边1列要有黑色像素,而不能偏移一列,这样和B识别为一样的了,这样的限制对图片的预处理要求高,这对于简单图片来说可能容易,但对于复杂图片就无能为力了。回到我们的题目——在一张复杂的图片里面找是否包含”手拿着气球“,这个手可能出现在图片的左边,可能是右边,也可能在图片偏上,所以我们要识别的特征在图片的位置是会 ”偏移“ 的。另外,我们不会像全连接神经网络一样用整张图片的所有像素参与”手拿着气球“的特征评估,因为”手拿气球“区域外的内容完全不重要,例如我们可以在一辆飞速的汽车里拿着,也可以在一条马路旁边拿着等,这样它的参数只会在扫描的区域内,于是相比全连接神经网络参数会少非常多
所以总结来说卷积神经网络有以下特征:

  1. 领域的局部操作
  2. 平移不变性
  3. 模型的参数大幅减少

3.3.1.2、卷积神经网络的结构和运行方式

卷积神经网络的结构图如下(其中F代表Filter, P代表Pooling):

卷积层会图片左上角开始逐行扫描,每扫描完一个区域会向右移动大小为1的步幅,如下图所示:

那”池化层“是啥,它是进一步整理”卷积层“的活跃值,池化层中浓缩了池化层区域大小的活跃度的值。下图中展示了步幅为2的2x2的最大池化的处理过程。最大池化是取最大值的运算。2x2表示池化目标区域的大小。一般来说,池化窗口的大小和步幅应设置为相同的值。

池化的目的是对微小的位置变化具有健壮性。也就是对于输入数据之间有微小的差异,池化结果是相同的。例如下面的第二个图在水平方向上移动了一个元素,但输出是相同的。

3.3.1.3、卷积神经网络识别图片的原理

我们还是以简单的单色数字图片举例,例如我们需要识别数字图片”1“,”2“,”3“,其中一个卷积层能识别下面的特征(一个‘/’形状的特征):

我们先来考察一个数字”2“图片,从数学角度考察这个卷积层处理这个图片的过程。

于是卷积结果为:

接下来对卷积结果加权输入,假设偏置为-1:

对输出调用一下激活函数(这里用Sigmoid, Sigmoid会让结果范围限定到0-1之间),结果如下:
。同理我们分别得到”1“,”3“的池化结果分别为:

从”1“,”2“,”3“的图像的池化结果来看到,”2“的图像的池化结果由比数字”1“,”3“的图像池化结果大的值构成。如果池化层神经单元的输出值较大,就表示原始图像中包含了较多的”/“形状的特征,由此可知,卷积层对手写数字”2“的检测发挥了作用。最后输出层将上一层(池化层)的信息组合起来,并根据这些信息得出整个网络的判断结论。

3.3.2、循环神经网络-RNN

我们此前见到的神经网络是具有前馈(forword)结构的网络。前馈指信号向一个方向前进,它的特点是输出只取决于输入。而RNN(循环神经网络)是具有下图所示的循环结构的网络:

上图中的循环结构使RNN的输出前馈到自身。所以RNN网络有”状态“。也就是说,当数据输入到RNN时,状态被更新,输出由状态决定。

我们用式子来介绍RNN。假设我们以输入为时间序列数据xt、输出隐藏状态ht的RNN为例子进行思考。这里的t指的是时间序列数据的时间t(或第t个)。由于RNN的状态被称为隐藏状态(hidden state),所以在式子中用h来表示RNN的状态。下面是RNN的正向传播的式子, 我们用双曲正切函数tanh做激活函数(它限制输出为-1~1):

ht = tanh(ht-1Wh + xtWx + b)

我们看下这个式子的符号,RNN有两个权重:一个是Wx, 用于将输入x转换为隐藏状态h; 另一个是权重Wh,用于将前一个时刻的RNN的输出转换为下一个时刻的输出。另外还是偏置b。

LSTM是一个识别精度更高的RNN,它除了使用隐藏状态h, 还使用了记忆单元。本文不做详细介绍,感兴趣的同学可以参考《深度学习进阶:自然语言处理》第6章的内容。

4、神经网络底层框架的组成

4.1、训练一个神经网络需要具备什么

上面文章的篇幅我们主要介绍了“神经网络”通过“学习”的过程不断的优化模型中的参数(权重和偏置),使得该模型能够趋向于解决特定的复杂数学问题。一个完成了“学习”的模型,我们也会有一个关键的步骤:使用“测试数据”来评估该模型的泛华能力——“验证”。例如评估该模型是否过度拟合了训练数据(是否发生了过拟合),以及泛华能力如何等。

所以一个模型的“学习”和“验证”过程整体包含以下几个角色:

  1. 一个设计良好的分层模型
  2. 两份数据:“训练数据”和“测试数据”,分别用于“学习”训练模型和“验证”评估模型
  3. 损失函数:计算“训练数据”前向传播得到的实际结果
  4. 优化器:通过反向传播获得的权重和偏置的导数,计算一次迭代需要调整权重和偏置的幅度

“学习”关键步骤:

  1. 前向传播:输入数据经过模型当前的权重和偏置得到模型的输出的过程
  2. 计算误差:上一步前向传播得到的模型的实际输出结果,和训练数据的期望结果,通过损失函数得到当次训练的误差
  3. 反向传播:使用误差的“反向传播”,获得所有权重和偏置的导数
  4. 更新权重和偏置:通过“优化器”和第3步计算的导数得到一次迭代调整后的权重和偏置值
  5. 不断的重复1-5,实现迭代“学习”

流程图如下:
现在市面上神经网络的模型训练过程都是这样的流程

4.2、pytorch 框架

2020年-2024年底,机器学习框架中pytorch一直都是最热门的,我们接下来对框架的介绍和使用也都倾向于pytorch

详细见:https://paperswithcode.com/trends

上面介绍了模型训练和验证的一般流程,而在每个神经网络框架中会有差异,因为框架的职责是需要提供开发工具脚手架,它的目标是尽可能的提升研究者使用该框架后能快速训练模型的效率,而不要把时间花在一些无用的重复工作上。例如pytorch会额外提供以下能力:

  1. 加载数据源:以各种方式(文本、图片等方式)加载用户提供的数据源;同时转换为框架内部使用的“张量”;同时也提供了小批量迭代器,提供给模型的输入数据;也提供了多进程并行加载数据的能力
  2. 支持将模型训练(“学习”)的过程在多个服务器/GPU上进行分布式训练
  3. 将训练完的模型导出为不同的格式:ONNX(一种与外部框架无关的模型描述和交换格式)、TorchScript(一种延迟执行的‘图模型’运行环境)等。
  4. 支持将训练完的模型部署到生产服务器上

如下图所示:

4.3、更多的优化器——牛顿法

我们此前介绍的优化器核心是提供一个”学习率“,然后根据导数往前探索着前进。有时为了不因为”学习率“的值过大导致错过了最低点,我们可能会将”学习率“设置的很小,但是这样想要降低到最小值可能会非常的慢。牛顿法就是解决这个问题的。

在说这个问题之前,我们先介绍一下它的核心解题方法——泰勒展开式

在数学中,泰勒公式(英语:Taylor’sFormula)是一个用函数在某点的信息描述其附近取值的公式。这个公式来自于微积分的泰勒定理(Taylor’s theorem),泰勒定理描述了一个可微函数,如果函数足够光滑的话,在已知函数在某一点的各阶导数值的情况之下,泰勒公式可以用这些导数值做系数构建一个多项式来近似函数在这一点的邻域中的值,这个多项式称为泰勒多项式(Taylor polynomial)。泰勒公式还给出了余项即这个多项式和实际的函数值之间的偏差。

阅读完上面摘自百度百科的介绍,我们可以简单理解为泰勒展开式实际是估计一个任意函数在某点领域的值的公式。而我们的”优化器“不也是此时某个点,想要继续通过”学习率“和”导数“往在领域附近最低点试探吗。这种试探的行为在神经网络里面也叫”超参数调优“,然而这个行为更多的依靠经验,比如上面提到的”学习率“太小会影响收敛的速度,太大又可能错过最低点。假如我们有个明确的数学公式能直接告诉我们领域附近的最小值,然后我们直接前进到这个领域的值会不会更好呢,因为它更准确,也能更快。

我们也通过数学式子实际来证实这一点吧。我们先思考如何对 y = f(x)函数求最小值。要推导出计算表达式,首先需要通过泰勒展开将 y = f(x) 变换成如下的形式:

f(x) = f(a) + f(a)(x-a) + 1/2!f‘’(a)(x-a)2 + 1/3!f‘’’(a)(x-a)3 + …

通过泰勒展开,我们可以将f表示为已某一点a为起点的x的多项式。在多项式中,一阶导数、二阶导数、三阶导数…,各阶导数的项在不断增加,如果我们选择在某一阶结束展开,就可以近似的表示f(x)。这里我们选择在二阶导数结束展开:

f(x) ≈ f(a) + f(a)(x-a) + 1/2f‘’(a)(x-a)2

上面的式子使用到二阶导数为止的项对f(x)这个函数进行了近似处理。如果着眼于变量x,就可以看出这个表达式是x的二次函数。也就是说,”某个函数“ y = f(x)近似为x的二次函数。因此,这种近似叫作二次近似。是不是很神奇,很熟悉。接下来我们是不是需要寻找这个二次函数导数为0的地方就是这个近似值的最小值了。如下图所示,二次近似函数是一条在点a处与y = f(x)相交的曲线。

接下来我们找到二次函数的导数为0的点,式子如下所示,求函数对x导数为0:
d/dx(f(a) + f(a)(x-a) + 1/2*f‘’(a)(x-a)2) = 0
f(a) + f‘’(a)(x-a) = 0
x = a - f(a) / f‘’(a)

于是我们得出,二次近似函数的最小值位于点x = a - f(a) / f‘’(a) 处。换言之,我们只要将a的位置更新移动 -f(a) / f‘’(a) 即可。如下图所示:

图中我们将a的位置更新到anew的位置,更新后的a的位置可以重复同样的操作,这就是使用牛顿法进行的优化操作。与前面通过“学习率”的梯度下降法比较来看,牛顿法的特点就更明显了。我们来对比一下:

  1. 梯度下降法:x <- x - a * f(x)
  2. 牛顿法:x <- x - f(x) / f‘’(x)

从式子可以看出,两种方法都更新了x,但它们的更新方式不同。在地图下降法中,系数 a 是手动设置的值,x的值通过沿着梯度的方向(一阶导数)前进 a 的方式来更新,而牛顿法则通过二阶导数自动调整梯度下降法中的 a。换言之,我们是不是可以将牛顿法看成 a = 1/f‘’(x) 的方法。

所以总结来说,梯度下降法只利用了一阶导数的信息,而牛顿法还利用了二阶导数的信息。拿物理学中的概念来说就是梯度下降法只使用速度信息,而牛顿法使用速度和加速度的信息。牛顿法是通过增加的二阶导数的信息来提高搜索效率,从而提高快速到达目的地的概率。

5、开始第一个Pytorch示例

可以参考官网的指引安装pytorch, 这里不做详细介绍。

5.1、数据预处理

在pytorch中,神经网络将张量作为输入,并生成张量作为输出。那张量是啥?

pytorch 引入了一种基本的数据结构:张量(tensor)。在深度学习中,张量可以将向量和矩阵推广到任意维度,这个概念的另一个名称是多维数组。

目前市场上的一些深度学习应用程序,它们总是以某种形式获取数据,如图像或文本数据,并以另一种形式生成数据,如标签、数字或更多的图像或文本。从这个角度来看,深度学习实际上需要构建一个能够将数据从一种表示转换为另一种表示的系统。这种转换是通过一系列样本中提取共性来驱动的,例如,系统可能会记录狗的一般形状和金毛猎犬的典型颜色。通过结合这2种图像属性,系统可以正确的将具有给定形状和颜色的图像映射到金毛猎犬标签上,而不是将图像映射成一只黑色的狗或者黄褐色的猫。由此构成的系统可以接收大量的类似的输入,并为这些输入产生有意义的输出。

也就是说深度神经网络通常分步学习将数据从一种形式转换为另一种形式,这意味着每个阶段转换的数据可以被认为是一个中间表征序列。对于图像识别,早期表征可以是诸如边缘检测或某些纹理,如毛发。更深层次的表征可以捕捉更复杂的结构,如耳朵,鼻子或眼睛等。一般来说,这些中间表征是一组浮点数,它们描述输入的特征。

该过程首先将我们的输入转换为浮点数,因此需要一种方法,将我们希望处理的实际数据编码为网络可以理解的数据,然后将输出解码为我们可以理解和使用的数据。

5.1.1、加载图片数据

图像被表示为一个排列在具有高度和宽度的规则网格的标量集合中,其中高度和宽度以像素为单位。每个网格点(像素)可能有一个标量,它将表示为一个灰度图像;或者每个网格点有多个标量,这时每个标量通常会呈现不同的颜色,例如:RGB。

在python 中有很多加载图像的方法,例如我们可以通过imageio模块加载图片:

img_arr = imageio.imread('../data/a.png')
img_arr.shape

out: (720, 1280, 3)

此时img是一个具有3个维度的类NumPy数组对象。两个空间维度是尺寸——宽度和高度,第3个维度对应RGB3个通道。
在Pytorch里面的图像数据要求张量排列为 C x H x W(分别对应通道,高度,宽度)。我们可以使用张量的permute()方法改变维度索引位置。给定一个已知的 H x W x C 的输入张量,我们先布局通C,然后是高度和宽度:

img = torch.from_numpy(img_arr)
out = img.permute(2, 0, 1)

以上只描述了单幅图像,通常我们会提供多图像的数据集做为神经网络的输入,我们可以在第一维存储数据,这样可以获得一个 N x C x H x W 的张量。首先我们预分配一个适当大小的张量:

batch_size = 3
batch = torch.zeros(batch_size, 3, 256, 256, dtype=torch.uint8)

这表明我们的批处理有3幅高和宽均为256个像素的图像组成。接下里我们可以从一个输入目录中加载所有的图像,并将它们存储在张量中:

import os
data_dir = '../data/imgs'
filenames = [name for name in os.listdir(data_dir) if os.path.splitext(name)[-1] == '.png']

for i, filename in enumerate(filenames):
    img_arr = imageio.imread(os.path.join(data_dir, filename))
    img_t = torch.from_numpy(img_arr)
    img_t = img_t.permute(2, 0, 1)
    img_t = img_t[:3] #只保留前3个通道,即:RGB
    batch[i] = img_t #填充批量图片数据

5.1.2、数据归一化

神经网络的输入数据需要进行归一化,以保证输入数据在数值上具有统一的尺度,避免不同特征之间的数值差异过大,同时避免噪声和异常值的影响,从而提高网络的训练效果和稳定性。神经网络基于梯度下降算法进行参数优化,归一化能够使不同特征之间的数值差异变小,从而使得梯度下降算法的收敛速度变快。有两种方式:

归一化‌: 将数据缩放到一个特定的范围,通常是0到1之间。这个过程是通过将每个特征值减去最小值并除以其范围(最大值减最小值)来完成的。归一化有助于处理不同范围的特征值,使模型训练更加稳定,特别适合那些对数值范围敏感的算法,比如K近邻算法(KNN)、支持向量机(SVM)等。‌
‌标准化‌: 也称为Z-score标准化,是将数据转换为均值为0、方差为1的标准正态分布。这个过程涉及将每个特征值减去其平均值,并除以标准差。标准化特别适用于特征值遵循正态分布的情况,或者算法假设数据呈正态分布时,如线性回归、逻辑回归等。

5.1.3、表示表格数据

我们在机器学习中遇到的最简单的数据形式是电子表格,CSV文件或数据库。首先我们假定样本在表中的出现顺序没有意义,这样的表是独立样本的集合。不像时间序列那样(在时间序列中,样本是有时间维度关联的)。
表格中列的值可以是数字,表示样本属性的字符串(如:“blue”),因此,表格的数据通常是异构数据:不同的列具有不同的类型。我们可以用一列显示苹果的重量,在另一列编码苹果的颜色。

另外,Pytorch的张量是齐次的。虽然整数和布尔型也支持,但Pytorch中的信息通常被编码为浮点数。这种数字编码实际是经过深思熟虑的,因为神经网络是一种数学实体,它以实数为输入,通过连续应用矩阵乘法和非线性函数产生实数作为输出

处理表格数据,我们的第一项工作是将实际的异构数据编码为浮点数表示的张量,以供神经网络使用。下面让我们以“葡萄酒”的表格数据开始(可以从这里下载这份excel),这个表格包含葡萄酒的化学特征以及质量评分。

这份excel包含分号分隔的值的集合,这些值组织在12个列中。第一行为列名的标题行。前11列包含与化学特征相关的变量的值。最后一列包含从0(非常糟糕)到10(优秀)的质量评分。以下是列名在数据集的显示顺序:

“fixed acidity”;”volatile acidity”;”citric acid”;”residual sugar”;”chlorides”;”free sulfur dioxide”;”total sulfur dioxide”;”density”;”pH”;”sulphates”;”alcohol”;”quality”

我们接下来要做的是根据化学特征预测质量评分。我们需要先加载数据,然后将其转换为Pytorch张量。Pytorch提供了几种可选的加载CSV文件的方法,例如:

  • Python自带的csv模块
  • NumPy
  • pandas

第3种方法最节省时间和内存。我们这里用比较受数据科学欢迎的NumPy库,而且PyTorch具有与NumPy无缝互操作的特性。接下来让我们加载文件,并将生成的NumPy数组转换为PyTorch张量:

import csv
wine_path = "../data/p1ch4/tabular-wine/winequality-white.csv"
wineq_numpy = np.loadtxt(wine_path, dtype=np.float32, delimiter=";",
                         skiprows=1)
#打印wineq_numpy变量                         
wineq_numpy

输出:

array([[ 7.  ,  0.27,  0.36, ...,  0.45,  8.8 ,  6.  ],
       [ 6.3 ,  0.3 ,  0.34, ...,  0.49,  9.5 ,  6.  ],
       [ 8.1 ,  0.28,  0.4 , ...,  0.44, 10.1 ,  6.  ],
       ...,
       [ 6.5 ,  0.24,  0.19, ...,  0.46,  9.4 ,  6.  ],
       [ 5.5 ,  0.29,  0.3 , ...,  0.38, 12.8 ,  7.  ],
       [ 6.  ,  0.21,  0.38, ...,  0.32, 11.8 ,  6.  ]], dtype=float32)

这里我们规定了二维数组的类型(32位浮点数)和用于分隔每行数据的分割符,并约定不读第一行(因为第一行是列名)。让我们看看是不是读取了所有的数据:

col_list = next(csv.reader(open(wine_path), delimiter=';'))

#输出wineq_numpy的形状,也输出表格的第一行(即列名)
wineq_numpy.shape, col_list

输出:

((4898, 12),
 ['fixed acidity',
  'volatile acidity',
  'citric acid',
  'residual sugar',
  'chlorides',
  'free sulfur dioxide',
  'total sulfur dioxide',
  'density',
  'pH',
  'sulphates',
  'alcohol',
  'quality'])

接下来我们将NumPy数组转换为PyTorch张量:

wineq = torch.from_numpy(wineq_numpy)

wineq.shape, wineq.dtype

输出:

(torch.Size([4898, 12]), torch.float32)

至此我们有了一个浮点数的torch.Tensor对象,它包含所有的列,也包含最后一列(即质量评分)。

从上面的介绍我们了解到表格的数据一般是异构的,所以接下来需要处理表格的数据,让其能作为网络的输入。我们需要了解数据有以下3种类别:

  1. 连续值,这些值是严格有序的。例如包裹A的重量是3千克还是10千克,或者包裹B是来自200英里还是2000英里之外,说包裹A比B重2千克,或者说包裹B比A距离远100英里都是有固定意义的。另外文献重进一步划分了连续值为:比例尺度和区间尺度,在前面的例子说一个包裹的质量或距离是另一个包裹的2倍或3倍是比例尺度。另一方面,一天中的时间,说6:00是3:00的2倍就不合理了,因此一天中的时间只提供了一个区间尺度。

  2. 序数值,这种值对“连续值”的严格排序仍然存在,但值之间的固定关系不再适用。一个很好的例子就是点一份小杯,中杯或大杯的饮料,将小杯映射为1、中杯为2、大杯为3.大杯饮料比中杯大,就像3比2大一样,但它没有告诉我们大了多少。所以我们除了对这些值进行排序,我们无法对它们进行“数学运算”,试图将大杯等于3、小杯等于1的平均值计算不会得到中杯饮料的体积。

  3. 分类值,分类值对其值既没有排序意义,也没有数字意义,通常只是分配任意数字的可能性的枚举。将水设定为1,咖啡设定为2,苏打水设定为3,牛奶设定为4,就是一个很好的例子。把水放在前面,把牛奶放在最后,这并没有什么逻辑可言,只是需要不同的值来区分它们。我们可以将咖啡设定为10,牛奶设定为-3,并不会有明显变化。因为分类数值没有意义,所以它们也被称为名义尺度。

接下来我们继续处理前面的表格,从上面的数值类别知道,我们可以将分数视为一个连续变量,或者将其视为一个标签。在这两种方式中,我们通常会从输入数据张量中删除分数,并将其保存在单独的张量中,而不必将其输入模型中:

data = wineq[:, :-1] # 逗号前是行,逗号后是列;逗号前冒号前后没有数字代表所有行;逗号后冒号后为-1,代表所有列表除了最后一列。冒号前后代表起始和结束位置,但是结束位置不包含在内。

data, data.shape

输出:

(tensor([[ 7.00,  0.27,  ...,  0.45,  8.80],
         [ 6.30,  0.30,  ...,  0.49,  9.50],
         ...,
         [ 5.50,  0.29,  ...,  0.38, 12.80],
         [ 6.00,  0.21,  ...,  0.32, 11.80]]), torch.Size([4898, 11]))

我们单独选择所有行的最后一列,看看输出:

target = wineq[:, -1] 
target, target.shape

输出:
输出了分数那一列的所有值

(tensor([6., 6.,  ..., 7., 6.]), torch.Size([4898]))

5.1.3.1、独热编码

如果我们想将target张量转换为标签张量,我们可以采用“独热编码”的方式。“独热编码”经常用于处理“字符串标签”,如葡萄酒的颜色,那么我们可以给每个字符串分配一个整数。我们还是继续用分数这一列来举例说明“独热编码”,简单来说“独热编码”会将10个得分的每个分数分别编码早一个由10个元素组成的向量中,除了其中一个元素为1,其他所有元素设置为0,每个分数都有一个不同的索引。这样分数1可以映射为向量(1,0,0,0,0,0,0,0,0,0)。

让我们停下来思考一下,为什么科学家们会将分类数据转成独热编码来输出给网络呢?由于分类值没有意义,它的值很随机,所以直接将其输入网络,它对输出的影响无法评估,无法对分类数值调优。转换为独热编码后,该输入参数从1个变为了10个,且对应索引的值固定为1(也和其他数据的归一化、标准化的值范围对齐了),这样下一个隐藏层可以分别对这10个输入有不同的权重,我们就可以对这10个输入值做不同权重值调整,从而实现不同的分类输入产生不同的输出的结果。

上面提到的两种方法有明显的区别,将葡萄酒质量评分保存在一个整数向量中,可以对分数进行排序,在这种情况下,这种做法可能是完全合适的,因为1分比4分低,符合这种情景,那么采用这种方式就很好。另一种情景,如果分数完全是离散的,比如葡萄酒的品种,那么采用独热编码更合适。

可以使用PyTorch里面的scatter_()方法能获得一个独热编码,该方法能沿着参数提供的索引方向将源张量的值填充到输入张量中:

target_onehot = torch.zeros(target.shape[0], 10)

target_onehot.scatter_(1, target.unsqueeze(1), 1.0)

上面的代码先通过torch.zeros初始化了一个大小-行数为target.shape0,列数为10,值为0的张量。然后使用scatter_方法(注意方法名字以下划线结尾,这是PyTorch的一个约定,它表明该方法不会返回一个新的张量,而是在适当的位置修改张量)。

上面代码输出为(数据被精简,看不出来分数的独热编码索引值):

tensor([[0., 0.,  ..., 0., 0.],
        [0., 0.,  ..., 0., 0.],
        ...,
        [0., 0.,  ..., 0., 0.],
        [0., 0.,  ..., 0., 0.]])

至此这个表格数据就可以输入到网络了,这个是普通异构表格数据的做法。

5.1.4、处理时间序列数据

这里讨论另一个数据集:来自华盛顿特区的自行车共享系统的数据集,包括2011-2012年华盛顿自行车共享系统每小时的自行车租赁数量,以及天气和季节信息。这个表格的结构如下:

原始数据:

instant,dteday,season,yr,mnth,hr,holiday,weekday,workingday,weathersit,temp,atemp,hum,windspeed,casual,registered,cnt
1,2011-01-01,1,0,1,0,0,6,0,1,0.24,0.2879,0.81,0,3,13,16
2,2011-01-01,1,0,1,1,0,6,0,1,0.22,0.2727,0.8,0,8,32,40
3,2011-01-01,1,0,1,2,0,6,0,1,0.22,0.2727,0.8,0,5,27,32
4,2011-01-01,1,0,1,3,0,6,0,1,0.24,0.2879,0.75,0,3,10,13
5,2011-01-01,1,0,1,4,0,6,0,1,0.24,0.2879,0.75,0,0,1,1
6,2011-01-01,1,0,1,5,0,6,0,2,0.24,0.2576,0.75,0.0896,0,1,1
7,2011-01-01,1,0,1,6,0,6,0,1,0.22,0.2727,0.8,0,2,0,2
8,2011-01-01,1,0,1,7,0,6,0,1,0.2,0.2576,0.86,0,1,2,3
9,2011-01-01,1,0,1,8,0,6,0,1,0.24,0.2879,0.75,0,1,7,8
10,2011-01-01,1,0,1,9,0,6,0,1,0.32,0.3485,0.76,0,8,6,14
...

我们看下这个原始数据形状和步长:

bikes.shape, bikes.stride()

输出:

(torch.Size([17520, 17]), (17, 1))

表示这个表格有17520行数据,总共有17列。步长为(17,1),代表步长为每12个数据前进一行。我们接下来想把数据调整为3个维度,增加一个天维度。表格中第6列为一天的小时数(第6列的值得范围为0-23),我们可以以一天24小时为划分继续将表格的二维数据拆分为3维度。

daily_bikes = bikes.view(-1, 24, bikes.shape[1])
daily_bikes.shape, daily_bikes.stride()

输出:

(torch.Size([730, 24, 17]), (408, 17, 1))

其中view()调用,它会改变张量查看存储的相同数据的方式(注意这里的方法没有以下划线结尾,代表它会返回一个新的张量)。这里使用-1作为占位符,表示在给定维度和原始元素数量进行自动调整,而不用管还剩下多少索引。

接下来如果我们想调整表格里面维度的顺序(例如第1个和第2个的维度),可以用transpose方法:

daily_bikes = daily_bikes.transpose(1, 2)
daily_bikes.shape, daily_bikes.stride()

输出:

(torch.Size([730, 17, 24]), (408, 1, 17))

好了,至此已经按时间处理好数据的顺序了。为了让这份表格数据能输入到网络,我们还需要继续处理表格里面的每一列数据(是否分类数值、连续数值)。

5.1.4.1、准备训练数据

我们知道天气的变量应该是分类数据,例如:1表示晴天,4表示大雨/大雪。从上面的介绍我们知道如果表示为分类数值,那么应该将变量转换为独热编码的向量。
为了更容易的呈现数据,我们暂时只关注一天的数据。我们先初始化一个零填充的矩阵,其行数等于一天的小时数,列数为天气状况的分类数:

first_day = bikes[:24].long()
weather_onehot = torch.zeros(first_day.shape[0], 4)
first_day[:,9]

输出:

tensor([1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 2, 2,
        2, 2])

其中first_day[:,9]表示获取第九列的所有数据(这里只有一天中24小时的天气状况,其中天气状况为1-4的分类值)。接下来同上面独热编码介绍的方法一样,我们将这每行的分类值映射到矩阵中:

weather_onehot.scatter_(
    dim=1, 
    index=first_day[:,9].unsqueeze(1).long() - 1, # 这里减去1,表示索引从0开始.这里表格的第9列为weathersit,即天气状况
    value=1.0)

输出:

tensor([[1., 0., 0., 0.],
        [1., 0., 0., 0.],
        ...,
        [0., 1., 0., 0.],
        [0., 1., 0., 0.]])

然后使用cat()函数将矩阵连接到原始数据集:

torch.cat((bikes[:24], weather_onehot), 1)[:1] #这里在维度1(即列)拼接独热编码后的数据后,打印第一行的数据([:1]这个表示取第一行)

输出(注意看输出的最后4列为1,0,0,0,整体表格数据的列数从17列变为21列):

tensor([[ 1.0000,  1.0000,  1.0000,  0.0000,  1.0000,  0.0000,  0.0000,
          6.0000,  0.0000,  1.0000,  0.2400,  0.2879,  0.8100,  0.0000,
          3.0000, 13.0000, 16.0000,  1.0000,  0.0000,  0.0000,  0.0000]])

用同样的方法应用于表格的数据,我们前面将表格的形状改为(B,C,L),其中L=24。我们首先创建一个零张量,让其具有相同的B和L,但是附加列数为C:

daily_weather_onehot = torch.zeros(daily_bikes.shape[0], 4,
                                   daily_bikes.shape[2])
daily_weather_onehot.shape

输出:

torch.Size([730, 4, 24])

然后我们将独热编码映射到第C维张量中:

daily_weather_onehot.scatter_(
    1, daily_bikes[:,9,:].long().unsqueeze(1) - 1, 1.0)

daily_weather_onehot.shape

输出:

torch.Size([730, 4, 24])

我们沿着C维度进行连接:

daily_bikes = torch.cat((daily_bikes, daily_weather_onehot), dim=1)

我们同样检查其它列的数值是否也需要处理,如果是连续数值则把值归一化或标准化,使其值得范围落在01或者-11之间。做完这些,这份表格数据就可以输送到网络了。

5.1.5、处理一个句子

从上面掌握的知识我们了解到,句子里面的每个单词不是连续数值、也没有必然的顺序关系,我们自然而然联想到用独热编码的方法来处理。但是单词量是巨大的,单一本书中就有7000个单词,独热编码的方式就无能为力了。

此外独热编码存在一个问题:给单词任意分配数字这种方法,无法获取单词之间的相关关系。假设网络已经习得“我大声笑了出来”这个短句与积极的评论有关联,然后它遇到了一个新的短句“我欣赏其中的幽默”,网络也无法识别这两句含义的相近之处。

语言学家约翰.费斯在1957年提出“你会通过与一个单词一同出现的词来认识它”,这就是说,一个单词的含义可以依据与其经常一同出现的其他词来定义,而这些单词又可以依据与它们经常一同出现的单词来定义,以此类推。比如:“憎恶”这个词往往与“讨厌”出现在相同的语境中;“笑”这个词往往会与“幽默”出现在相同的语境中。

科学家们结合这种使用场景,想出了一个巧妙地解决方案,这个方案以这样一种方式将单词嵌入,即把上下文中使用的单词映射到嵌入的临近区域。例如:我们可能会决定通过选择沿着坐标轴映射基本名词和形容词来构建嵌入空间。我们可以生成一个二维空间,在这里将坐标轴映射到名词,例如水果(0.0-0.33),花(0.33-0.66)和狗(0.66-1.0),以及形容词红色(0.0-0.2),橙色(0.2-0.4),黄色(0.4-0.6)、白色(0.6-0.8),棕色(0.8-1.0)。我们的目标是将实际的水果、鲜花和狗等放在嵌入空间中。如下图所示:

由此产生的嵌入一个有趣的方面是,相似的单词最终不仅聚集在一起,而且还与其他的单词具有一致的空间关系。例如如果我们拿苹果的嵌入向量,然后用其他的单词的向量与该向量执行减法加法操作,如执行苹果-红-甜+黄+酸这种向量减法操作后,最后将得到的向量与柠檬的向量非常相似。

5.2、开始第一个模型训练

这里我们开始一个简单的二分类问题,我们随机生成2部分数据,然后创建一个模型里面只有一层网络(我们模拟的数据是线性可分的)。

定义模型:

    # 定义模型
    seq_model = nn.Sequential(
            nn.Linear(2, 1),
            nn.Sigmoid()
    )

    #打印模型信息
    seq_model

输出:

Sequential(
  (0): Linear(in_features=2, out_features=1, bias=True)
  (1): Sigmoid()
)

接下来我们选择一个优化器,这里用最简单的随机梯度下降——SGD(SGD全称Stochastic Gradient Descent),代码如下,其中optim.SGD的第二个参数为学习率:

    # 模型参数初始化
    print('\n使用SGD训练模型')
    learning_rate = 0.1
    # 使用optim模块中的现成优化器
    optimizer = optim.SGD(seq_model.parameters(), lr=learning_rate)

接着我们要实现模型训练的函数,依次执行前向传播、计算损失、梯度清零、反向传播和更新参数。其中梯度清零是为了保证本次学习不受前一次的梯度影响,因为我们每一次的学习都是重新在新的位置计算损失,而此次位置的寻找梯度方向和前一次已经没有关系了。所以要求梯度清零在调用loss.backward()函数之前,更新参数则发生在调用loss.backward()函数进行反向传播以后,此时是根据backward计算完的梯度开始更新参数。代码如下:

def model_training(x, y, n_epochs, optimizer, model, loss_fn):
    """ 训练 """
    for epoch in range(1, n_epochs + 1):
        # 前向传播
        y_pred = model(x)
        # 计算损失
        loss = loss_fn(y_pred, y)
        # 梯度清零
        optimizer.zero_grad()
        # 反向传播
        loss.backward()
        # 更新参数
        optimizer.step()

        if epoch == 1 or epoch % 10 == 1:
            print('轮次:%d,\t损失:%f' % (epoch, float(loss)))

然后我们调用model_training函数以训练模型。注意,这里的损失函数用的是PyTorch提供的nn.BCELoss类,该类用于计算目标和输出之间的二元交叉熵(Binary Cross Entropy, BCE):

model_training(
        x=x,
        y=y,
        n_epochs=500,
        optimizer=optimizer,
        model=seq_model,
        loss_fn=nn.BCELoss()
    )

这样就完成了模型的训练,以下是训练的过程:

轮次:1,	损失:0.612369
轮次:11,	损失:0.111691
轮次:21,	损失:0.067470
轮次:31,	损失:0.050455
...
轮次:471,	损失:0.009196
轮次:481,	损失:0.009088
轮次:491,	损失:0.008982

我们看下训练后的模型参数(权重和偏置):

优化后的模型参数
0.weight Parameter containing:
tensor([[0.2751, 2.1931]], requires_grad=True)
0.bias Parameter containing:
tensor([-0.4562], requires_grad=True)

我们把这个二分类结果展示出来如下图所示:

6、结束语

至此我们已经了解了深度学习的历史必然性,它是如何用数学来解释和用模型来模拟人的大脑皮层神经传输的,以及一些性能优化算法。我们也了解到深度学习不仅仅是科学家们的研究课题,它已经走出实验室,普及到全社会了,我们可以轻松的使用一些深度学习框架在本地就能实现模型的训练。当然了要训练一个大的模型离不开它的三驾马车:“数据”、“算法”、“算力”,当我们要真的实现一个大的模型时,我们还需要具备最基本的大数据输入,大的集群算力,优秀的算法提升性能。好在一些训练好的模型也已经发布在网上,我们可以直接使用,省去了我们开发者自己训练的成本。

最后以一张图片致敬过去一年看过的书:

如果你要问深度学习的学习路径,我可以推荐我的路径:

  1. AI 3.0: 可选,了解了Ai的历史必然性,以及一些优秀的模型内部的实现思想,能帮助你开拓眼界
  2. 深度学习的数学:建议必读,这本书用数学的方式把全连接网络、卷积网络、前向传播、反向传播讲的通俗易懂。读完这本书你能理解深度学习的底层实现。
  3. 深度学习入门(基于python的理论和实现):建议必读,这本书就是著名的“鱼书”,这本也是基本原理的介绍,和《深度学习的数学》很像,但它是从python实现的角度出来来介绍的。可以两本书互为补充,帮助加深印象。
  4. 深度学习入门(自制框架) :可选,这本书在第一本“鱼书”基础之上的,讲解了如何从0到1写一个深度学习框架,通过这本书你可以了解市面上流行的深度学习框架它的组成,运行方式,底层的实现原理。在你使用的时候就不会因为框架的过于复杂而被拒之门外。
  5. PyTorch深度学习实战:建议必读前半部分,后半部分做实践过程中的工具书参考使用。一本对PyTorch框架使用介绍比较好的书,可以通过这本书开启你得PyTorch之旅。