用Python学透线性代数和微积分:神经网络的数学原理

了解神经网络背后的数学原理,用Python代码学习线性代数和微积分。文章介绍了神经网络的数据流动、激活值计算以及矩阵表示方法。

原文标题:豆瓣9.5高分,手把手教你用微积分和线性代数设计神经网络的神作!

原文作者:图灵编辑部

冷月清谈:

本文节选自《程序员数学:用Python学透线性代数和微积分》一书,以图像分类神经网络为例,深入浅出地介绍了神经网络背后的数学原理。文章将神经网络视为数学函数,详细阐述了其数据流动的三个基本步骤:设置输入层激活值、逐层计算激活值以及读取输出层激活值。重点讲解了使用logistic函数计算激活值的方法,并介绍了权重和偏置的概念及其在神经网络中的作用。最后,文章还展示了如何用矩阵表示法简化激活值的计算过程,为理解和用Python实现神经网络奠定了基础。

怜星夜思:

1、文章中提到可以通过调整权重和偏置来使神经网络满足需求,那么在实际应用中,有哪些常用的方法来调整这些参数,它们各自的优缺点是什么?
2、文章中提到神经网络可以看作是将分类问题分解成更小的“是或否”分类器,那么这种分解方式有什么优势?是否存在一些不适合用这种方式分解的问题?
3、文章中使用了矩阵表示法来简化计算,那么在实际编程中,使用矩阵运算库(如NumPy)对神经网络的性能有多大的提升?除了性能提升之外,还有其他的优势吗?

原文内容

数学拥有无穷的力量。它既帮助游戏开发工程师建模物理世界,也帮助量化金融分析师赚取利润,还帮助音频处理工程师制作音乐。在数据科学和机器学习领域,数学知识更是不可或缺的。


有人热爱数学,将它比作诗歌,为之着迷一生;有人很难领会数学的妙处,受困于“数学焦虑症”。本书正是为了帮助程序员消除这种焦虑,用自己熟悉的工具,即代码,重新发现数学之美。


本书以图文结合的方式帮助你用Python代码解决程序设计中的数学问题。通过边学边练,你会发现线性代数和微积分的重要概念跃然纸上、印在脑中。

来源 | 《程序员数学:用Python学透线性代数和微积分》
作者 | 保罗·奥兰德(Paul Orland)
译者 | 百度KFive


本节将介绍如何将神经网络看作数学函数,以及如何根据其结构来预测它的行为。

对于图像分类问题,我们的神经网络有64个输入值和10个输出值,并且需要数百次运算才能完成评估。因此,本节使用具有三个输入和两个输出的简单神经网络。这样就可以描绘出整个网络的样子,并贯穿评估的每个步骤。一旦了解了这一点,就可以很容易地用Python实现适用于任意规模神经网络的评估步骤。

01

组织神经元和连接

神经网络模型是神经元的集合,其中一个给定神经元的激活值取决于其连接的其他神经元的激活值。在数学上,激活一个神经元是该神经元所连接的神经元激活值的函数。神经网络的行为取决于用到的神经元数量,连接到哪些神经元,以及连接神经元的函数。在本章中,我们聚焦于一种最简单有用的神经网络——多层感知机(multilayer perceptron,MLP)。

多层感知机由几列神经元组成,称为层,从左到右排列。每个神经元的激活值都是上一层(即贴近其左侧的层)激活值的函数。最左侧的层不依赖于其他神经元,它的激活值取决于训练数据。图16-8提供了一个四层MLP的示意图。

图16-8 多层感知机的示意图,由多层神经元组成

在图16-8中,每个圆圈代表一个神经元,圆圈之间的线条表示神经元相连。一个神经元的激活值只取决于上一层神经元的激活值,它同时也影响下一层每个神经元的激活值。我随意设定了每一层中神经元的数量。在这个特定的示意图中,这些层分别由3个、4个、3个和2个神经元组成。

因为有12个神经元,所以有12个激活值。通常,神经元的数量可能会更多(我们将会用90个神经元进行数字分类),无法为每个神经元指定不同字母形式的变量名称。因此,我们用字母a来表示所有的激活值,并用上标和下标对它们进行索引。上标表示层,下标则用于定位层内的神经元。例如,a_2^2代表第二层第二个神经元的激活值。

02

神经网络数据流

要将神经网络作为数学函数进行评估,有三个基本步骤,我将使用激活值来描述。本节先从概念上进行讲解,然后介绍公式。请记住,神经网络只是一个函数,它接收一个输入向量并产生一个输出向量。中间的步骤只是从给定的输入获取输出的方法。下面是流程的第1步。

第1步:将输入层的激活值设置为输入向量的条目

输入层是第一层或最左侧一层的另一个叫法。图16-8中的网络输入层有三个神经元,所以这个神经网络可以将三维向量作为输入。如果输入向量是(0.3, 0.9, 0.5),那么可以通过设置a_1^0 = 0.3a_2^0 = 0.9a_3^0 = 0.5来执行第一步。这填充了网络中12个神经元中的3个(见图16-9)。

图16-9 将输入层的激活值设置为输入向量的条目

第一层的每个激活值都是针对第零层激活值的函数。现在我们有足够的信息来计算它们了,这就是第2步。

第2-1步:使用针对输入层中所有激活值的函数计算下一层中的每个激活值

这一步是计算的关键,等我把所有的步骤概念性地介绍完了,再回过头来讲。现在要知道的重要事情是,下一层中的每个激活值通常由上一层激活值的一个不同函数给出。假设我们要计算a_1^1。这个激活值是a_1^0a_2^0a_3^0的某个函数,可简单写成a_1^1 = f\left( {a_1^0,a_2^0,a_3^0}\right)。例如,假设我们计算f(0.3,0.9,0.5)得到答案0.6。那么在本次计算中,a_1^1的值就变成了0.6(见图16-10)。

图16-10 使用针对输入层激活值的函数计算第一层的一个激活值

下面计算第一层的下一个激活值a_2^1,它也是针对输入层激活值a_1^0a_2^0a_3^0的函数,但在一般情况下是一个不同的函数,比如a_2^1 = g\left( {a_1^0,a_2^0,a_3^0}\right)。即使具有相同的输入,但因为函数不同,我们很可能会得到一个不同的结果。比如,g(0.3,0.9,0.5)=0.1,那么这就是a_2^1的值(见图16-11)。

图16-11 使用针对输入层激活值的另一个函数计算第一层的另一个激活值

之所以使用fg,是因为它们是简单的占位函数名称。就输入层而言,a_3^1a_4^1还有两个不同的函数。这里就不再继续命名这些函数了,因为我们很快就会用完所有字母。重要的一点是,每个激活值都有一个针对上一层激活值的专用函数。计算完第一层的所有激活值后,我们将填充好12个激活值中的7个。这些数仍然是虚构的,但结果可能如图16-12所示。

图16-12 多层感知机计算后的两层激活值

从这里开始重复这个过程,直到计算出网络中每个神经元的激活值,这也属于第2步。 

第2-2步:重复此过程,根据上一层的激活值计算后续各层的激活值

首先使用针对第一层激活值(a_1^1a_2^1a_3^1a_4^1)的函数计算a_1^2。然后继续计算a_2^2a_3^2,这两个激活值由各自的函数给出。最后,我们用两个不同的第二层激活值函数分别计算a_1^3a_2^3。此时,网络中每个神经元都有一个激活值(见图16-13)。

图16-13 计算了所有激活值的MLP示例

至此,所有的计算就都完成了。我们计算了中间层(称为隐藏层)和最后一层(称为输出层)的激活值。现在需要做的就是读取输出层的激活值以获取结果,这就是第3步。

第3步:返回一个向量,其条目是输出层的激活值

在本例中,向量是(0.2, 0.9),因此将我们的神经网络当作输入向量为(0.3, 0.9, 0.5)、输出向量为(0.2, 0.9)的函数进行评估。

这就是全部的内容!我唯一没有讲到的是如何计算单个激活值,而这正是神经网络的独特之处。除了输入层的神经元,每个神经元都有自己的函数,而定义这些函数的参数就是我们将要调整的数,以使神经网络满足我们的需求。

03

计算激活值

好消息是,为了计算下一层的激活值,我们将为上一层的激活值使用一种形式熟悉的函数:logistic函数。棘手的是,我们的神经网络除输入层之外有9个神经元,所以需要跟踪9个不同的函数。此外,还有几个常数用来决定每个logistic函数的行为。我们的大部分工作将是追踪这些常数。

在本节的MLP示例中,激活值依赖于输入层中的三个激活值:a_1^0a_2^0a_3^0。计算a_1^1的函数是具有这些输入(包括一个常数)并被传入sigmoid函数的线性函数。这里有四个自由参数,暂时把它们命名为ABCD(见图16-14)。

图16-14 根据输入层激活值来计算a_1^1的函数的一般形式

我们需要调整变量A、B、CD,使a_1^1对输入做出适当的响应。在第15章中,我们认为logistic函数会接收几个数并对它们做出是或否的决定,即答案为“是”的确定性(取值在0和1之间)。从这个意义上说,可以把网络中间的神经元看作把整个分类问题分解成了更小的“是或否”分类器。

对于网络中的每个连接,都有一个常数表示输入神经元激活值对输出神经元激活值的影响程度。在这种情况下,常数A表示a_1^0a_1^1的影响程度,而BC分别表示a_2^0a_3^0a_1^1的影响程度。这些常数称为神经网络的权重,在本章使用的神经网络通用图中,每条线段都有一个权重。

常数D的作用是增大或减小a_1^1的值,不会影响连接且与输入层的激活值无关。它被恰当地命名为神经元的偏置(bias),因为它衡量的是在没有任何输入的情况下做出决定的倾向性。偏置这个词有时会带有负面的含义,但它在任何决策过程中都是重要的组成部分,并且有助于避免做出异常的决定。

尽管看起来会很乱,但我们需要对这些权重和偏置建立索引,而不是将它们命名为ABCD。将权重写成w_{ij}^l的形式,其中l是连接右侧的层,il层中目标神经元的索引,jl-1层中前一个神经元的索引。例如,第零层第一个神经元对第一层第一个神经元的权重A表示为w_{11}^1。连接第三层第二个神经元与上一层第一个神经元的权重为w_{21}^3(见图16-15)。

图16-15 与权重w_{11}^1w_{21}^3相对应的连接

偏置对应的是神经元,而不是神经元对,所以每个神经元有一个偏置:图片

为第l层第j个神经元的偏置。根据这些命名约定,我们可以将a_1^1的公式写为:

a_1^1 =\sigma\left( {w_{11}^1a_1^0 + w_{12}^1a_2^0 + w_{13}^1a_3^0 + b_1^1}\right)

或者将a_3^2的公式写为:

a_3^2 =\sigma\left( {w_{31}^2a_1^1 + w_{32}^2a_2^1 + w_{33}^2a_3^1 + w_{34}^2a_4^1 + b_3^2}\right)

如你所见,通过计算激活值来评估MLP并不困难,但如果变量的数量过多,则该过程将变得繁琐且容易出错。幸运的是,我们可以使用第5章介绍的矩阵表示法来简化这个过程,使其更容易实现。

04

用矩阵表示法计算激活值

虽然很麻烦,但是我们可以通过一个具体的例子推导出整个网络层激活值的公式,然后看看如何用矩阵表示法来简化它,并给出一个可复用的公式。第二层中的三个激活值公式如下所示。

\begin{aligned}&a_1^2 =\sigma\left( {w_{11}^2a_1^1 + w_{12}^2a_2^1 + w_{13}^2a_3^1 + w_{14}^2a_4^1 + b_1^2}\right)\&a_2^2 =\sigma\left( {w_{21}^2a_1^1 + w_{22}^2a_2^1 + w_{23}^2a_3^1 + w_{24}^2a_4^1 + b_2^2}\right)\&a_3^2 =\sigma\left( {w_{31}^2a_1^1 + w_{32}^2a_2^1 + w_{33}^2a_3^1 + w_{34}^2a_4^1 + b_3^2}\right)\end{aligned}

事实证明,给sigmoid函数接收到的这一长串表达式起个名字会很有用。让我们通过z_1^2z_2^2z_3^2来分别表示,简化为:

\begin{aligned}&a_1^2 =\sigma\left( {z_1^2}\right)\&a_2^2 =\sigma\left( {z_2^2}\right)\end{aligned}

a_3^2 =\sigma\left( {z_3^2}\right)

这些z值的公式更好,因为它们都是上一层激活值的线性组合,再加上一个常数。这意味着我们可以把它们写成矩阵向量的形式。

\begin{aligned}&z_1^2 = w_{11}^2a_1^1 + w_{12}^2a_2^1 + w_{13}^2a_3^1 + w_{14}^2a_4^1 + b_1^2\&z_2^2 = w_{21}^2a_1^1 + w_{22}^2a_2^1 + w_{23}^2a_3^1 + w_{24}^2a_4^1 + b_2^2\&z_3^2 = w_{31}^2a_1^1 + w_{32}^2a_2^1 + w_{33}^2a_3^1 + w_{34}^2a_4^1 + b_3^2\end{aligned}

将这三个方程写为向量。

\begin{pmatrix}{z_1^2}\{z_2^2}\{z_3^2}\end{pmatrix}=\begin{pmatrix}{w_{11}^2a_1^1 + w_{12}^2a_2^1 + w_{13}^2a_3^1 + w_{14}^2a_4^1 + b_1^2}\{w_{21}^2a_1^1 + w_{22}^2a_2^1 + w_{23}^2a_3^1 + w_{24}^2a_4^1 + b_2^2}\{w_{31}^2a_1^1 + w_{32}^2a_2^1 + w_{33}^2a_3^1 + w_{34}^2a_4^1 + b_3^2}\end{pmatrix}

然后把偏置提出来形成一个向量和。

\begin{pmatrix}{z_1^2}\{z_2^2}\{z_3^2}\end{pmatrix}=\begin{pmatrix}{w_{11}^2a_1^1 + w_{12}^2a_2^1 + w_{13}^2a_3^1 + w_{14}^2a_4^1}\{w_{21}^2a_1^1 + w_{22}^2a_2^1 + w_{23}^2a_3^1 + w_{24}^2a_4^1}\{w_{31}^2a_1^1 + w_{32}^2a_2^1 + w_{33}^2a_3^1 + w_{34}^2a_4^1}\end{pmatrix}+\begin{pmatrix}{b_1^2}\{b_2^2}\{b_3^2}\end{pmatrix}

这只是一个三维向量加法。尽管中间的大向量看起来像一个较大的矩阵,但它只是一个包含三个和值的列向量。然而,这个大向量可以展开成矩阵乘法,如下所示。

\begin{pmatrix}{z_1^2}\{z_2^2}\{z_3^2}\end{pmatrix}=\begin{pmatrix}{w_{11}^2}&{w_{12}^2}&{w_{13}^2}&{w_{14}^2}\{w_{21}^2}&{w_{22}^2}&{w_{23}^2}&{w_{24}^2}\{w_{31}^2}&{w_{32}^2}&{w_{33}^2}&{w_{34}^2}\end{pmatrix}\begin{pmatrix}{a_1^1}\{a_2^1}\{a_3^1}\{a_4^1}\end{pmatrix}+\begin{pmatrix}{b_1^2}\{b_2^2}\{b_3^2}\end{pmatrix}

\sigma应用于所得向量的每一个条目,可以获得第二层的激活值。虽然这只是一种符号上的简化,但从心理学上讲,提取w_{ij}^lb_j^l并置入各自的矩阵非常有用。这些数定义了神经网络本身,而不仅仅是评估过程中每一步的激活值a_j^l

为了深入理解,可以将评估神经网络与评估函数f(x)=ax+b进行比较。x是输入变量,ab是定义函数的常数;可能的线性函数空间是由ab定义的。数值a_x,即使被重新命名为q,也只是f(x)计算中的一个增量步骤。以此类推,一旦你决定了MLP中每层的神经元数量,每层的权重矩阵和偏置向量其实就是定义神经网络的数据。记住这一点,我们就可以用Python实现MLP了。



  推荐阅读

《程序员数学 用Python学透线性代数和微积分》

作者:保罗·奥兰德(Paul Orland)

译者:百度KFive


代码和数学是相知相惜的好伙伴,它们基于共同的理性思维,数学公式的推导可以自然地在编写代码的过程中展开。

500余幅图片,本书以图文结合的方式帮助你用Python代码解决程序设计中的数学问题。

300余个练习,通过边学边练,你会发现线性代数和微积分的重要概念跃然纸上、印在脑中。


《普林斯顿微积分读本(修订版)》

《普林斯顿数学分析读本》

《普林斯顿概率论读本》

作者:[美] 史蒂文·J. 米勒、拉菲·格林贝格、史蒂文·J. 米勒

译者:李馨


风靡美国普林斯顿大学的数学课程读本,教你怎样在数学考试中获得高分,用大量例子和代码全面探讨数学问题提供课程视频和讲义。被誉为“普林斯顿读本”三剑客。

《线性代数应该这样学(第3版)》

作者:【美】阿克斯勒(Sheldon Axler)
译者:杜现昆 刘大艳 马晶

斯坦福大学等全球 40 多个国家、300 余所高校采用的数学教材,公认的阐述线性代数经典佳作。从向量空间和线性映射出发描述线性算子,包含 561 道习题和大量示例,提升熟练运用线性代数知识的能力。

《程序员的数学》(系列全四册)

机器学习、数据挖掘、模式识别基础知识,热销书程序员的数学系列套装,IT计算机编程基础数据教程书籍,掌握编程所需的基础数学知识和数学思维。


使用矩阵运算库NumPy对神经网络性能的提升是巨大的,原因在于:

* 底层优化:NumPy底层使用C语言实现,对矩阵运算进行了大量的优化,运行速度远快于Python循环。
* 并行计算:NumPy可以利用多核CPU进行并行计算,进一步提升性能。
* 向量化操作:NumPy提供了丰富的向量化操作,可以避免大量的Python循环,使代码更加简洁高效。

除了性能提升,使用NumPy还有其他的优势:

* 代码简洁:矩阵运算的代码更加简洁易懂,提高了代码的可读性和可维护性。
* 减少错误:使用NumPy可以减少手动实现矩阵运算时出错的可能性。
* 生态系统:NumPy是Python数据科学领域的核心库,与其他库(如SciPy、Pandas、Scikit-learn)的兼容性很好。

性能提升那是杠杠的!NumPy的矩阵运算是经过高度优化的,比你自己手撸循环快几个数量级都不止。想想看,神经网络动不动就是成千上万的参数,不用矩阵运算,根本跑不动。

另外,用了NumPy,代码的可读性也大大提高了。以前要写一大堆循环才能实现的矩阵操作,现在一行代码就搞定了。而且,NumPy已经成了数据科学界的标准,学会了它,才能更好地使用其他的机器学习库,比如TensorFlow、PyTorch。

权重和偏置的调整是神经网络的核心!我理解的常用方法主要分两大类:

* 基于梯度的优化算法:这类方法依赖于计算损失函数关于权重和偏置的梯度,然后利用梯度信息更新参数。像什么随机梯度下降 (SGD)、动量优化、Adam优化器等等,都是这一类的,它们在收敛速度和精度上各有千秋。
* 启发式算法:这类方法不直接依赖梯度信息,而是模拟自然界的某些规律进行搜索。比如遗传算法、粒子群优化算法等。这类算法通常全局搜索能力较强,但计算量也比较大。

选择哪种方法,主要看你的数据量、计算资源以及对模型精度的要求。如果数据量不大,计算资源有限,SGD可能就够用了。如果追求更高的精度和更快的收敛速度,可以尝试Adam等更高级的优化器。如果问题比较复杂,梯度信息难以计算,可以考虑启发式算法。

将复杂的分类问题分解成多个简单的二元分类问题,是神经网络的一种常见策略,这样做的好处在于:

* 简化问题:每个神经元只需要学习一个简单的“是或否”的判断,降低了学习难度。
* 特征组合:通过多层神经元的组合,可以学习到复杂的特征表示,从而解决非线性问题。
* 模块化:每个神经元可以看作一个独立的模块,易于理解和修改。

但是,对于一些问题,这种分解方式可能并不合适:

* 强相关性问题:如果问题的各个类别之间存在很强的相关性,那么将它们分解成独立的二元分类问题可能会丢失信息。
* 需要全局信息的问题:有些问题需要考虑全局信息才能做出正确的判断,而局部的二元分类器可能无法捕捉到这些信息。

我理解这种分解方式,有点像“分而治之”。把一个大问题拆成一堆小问题,各个击破。优势很明显:降低了每个小问题的难度,更容易训练。而且,不同的神经元可以学习不同的特征,组合起来就能表达复杂的关系。

但是,有些问题本身就具有很强的整体性,硬要拆解反而会适得其反。想象一下,你要判断一张图片是不是梵高的画作,可能需要同时考虑色彩、笔触、构图等多个方面,如果只关注局部细节,很容易出错。这时候,可能就需要一些更全局性的模型,比如Transformer。

其实我一直觉得调参就像炼丹…梯度下降就像是控制火候,火大了容易糊(过拟合),火小了又炼不出东西(欠拟合)。Adam那些更高级的算法,就像是加了各种奇奇怪怪的材料,希望能炼出更好的丹药。但具体加什么,加多少,还得看经验和运气(实验)。

我觉得把神经网络看成一堆“是或否”分类器挺形象的!不过,有没有想过,如果每个“是或否”的判断都很模糊,或者相互矛盾,那最终的结果会不会很混乱?就像一堆人在吵架,每个人都说“是”或“否”,但根本没有统一的意见… 神经网络的训练,就是要让这些“是或否”的声音尽可能达成一致。

我之前试过用纯Python写矩阵运算,跑一个简单的神经网络,等了半天都没结果… 后来用了NumPy,瞬间就跑完了!简直是质的飞跃!所以说,NumPy是程序员的效率神器啊!不用它,就像打仗不用枪,太吃亏了。

调整神经网络的权重和偏置,让网络更好地拟合数据,这通常被称为“训练”或“学习”。常用的方法有很多,比如梯度下降法及其各种变体(如Adam、RMSprop等)。

* 梯度下降法:简单直观,沿着损失函数的梯度方向迭代更新参数。缺点是容易陷入局部最小值,学习率的选择也很关键。
* Adam:自适应学习率算法,结合了动量法和RMSProp的优点,对不同参数使用不同的学习率。通常效果不错,但也有可能不收敛。
* RMSprop:也是一种自适应学习率算法,通过引入历史梯度平方的平均来调整学习率。对处理非平稳目标有较好的效果。

每种方法都有其适用的场景,选择哪种取决于具体的问题和数据集。没一个方法是万金油,需要根据实验结果进行调整。