
第 1 章 基本概念
不要记住这些公式。如果能理解这些概念,那么你就可以自己发明符号。
——John Cochrane,Investments Notes
本章旨在解释一些基本的思维模型,这些模型对于理解神经网络的工作原理至关重要。具体地说,本章将介绍嵌套数学函数(nested mathematical function)及其导数(derivative)。我们将从最简单的构成要素开始逐步研究,证明可以构建由函数链组成的复杂函数。即使其中一个函数是接受多个输入的矩阵乘法,也可以计算函数输出相对于其输入的导数。另外,理解该过程对于理解神经网络至关重要,从第 2 章开始将涉及神经网络的内容。
当围绕神经网络的基本构成要素进行研究时,我们将从 3 个维度系统地描述所引入的每个概念。
● 以一个或多个方程式的形式所表示的数学。
● 解释过程的示意图,类似于在参加编码面试时画在白板上的图表。
● 包含尽可能少的特殊语法的代码(Python 是一个理想选择)。
如前言所述,理解神经网络的一大挑战是它需要多个思维模型。你在本章中就可以体会到这一点:对将讨论的概念来说,以上 3 个维度分别代表不同的基本特征,只有把它们结合在一起,才能对一些概念形成完整的认识,比如嵌套数学函数如何以及为何起作用。注意,要完整地解释神经网络的构成要素,以上 3 个维度缺一不可。
明白了这一点,接下来就可以开始本书的学习了。我将从一些非常简单的构成要素开始讲解,介绍如何基于这 3 个维度来理解不同的概念。第一个构成要素是一个简单但又至关重要的概念:函数。
1.1 函数
什么是函数?如何描述函数?与神经网络一样,函数也可以用多种方法描述,但没有一种方法能完整地描绘它。与其尝试给出简单的一句话描述,不如像盲人摸象那样,依次根据每个维度来了解函数。
数学
下面是两个用数学符号描述的函数示例。
●
●
以上有两个函数,分别为 和
,当输入数字
时,第一个函数将其转换为
,第二个函数则将其转换为
。
示意图
下面是一种描绘函数的方法。
● 绘制一个 平面(其中
表示横轴,
表示纵轴)。
● 绘制一些点,其中,点的 坐标是函数在某个范围内的输入(通常是等距的),
坐标则是该范围内函数的输出。
● 连接所绘制的点。
这种利用坐标的方法是法国哲学家勒内 • 笛卡儿发明的,它在许多数学领域非常有用,特别是微积分领域。图 1-1 展示了以上两个函数的示意图。
图 1-1:两个连续、基本可微的函数
然而,还有另一种描绘函数的方法,这种方法在学习微积分时并没有那么有用,但是对于思考深度学习模型非常有帮助。可以把函数看作接收数字(输入)并生成数字(输出)的盒子,就像小型工厂一样,它们对输入的处理有自己的内部规则。图 1-2 通过一般规则和具体的输入描绘了以上两个函数。
图 1-2:另一种描绘函数的方法
代码
最后,可以使用代码描述这两个函数。在开始之前,先介绍一下 NumPy 这个 Python 库,下面会基于该库编写函数。
NumPy 库
NumPy 是一个广泛使用的 Python 库,用于快速数值计算,其内部大部分使用 C 语言编写。简单地说,在神经网络中处理的数据将始终保存在一个多维数组中,主要是一维数组、二维数组、三维数组或四维数组,尤其以二维数组或三维数组居多。NumPy 库中的 ndarray 类能够让我们以直观且快速的方式计算这些数组。举一个最简单的例子,如果将数据存储在 Python 列表或列表的嵌套列表中,则无法使用常规语法实现数据对位相加或相乘,但 ndarray 类可以实现:
print("Python list operations:") a = [1,2,3] b = [4,5,6] print("a+b:", a+b) try: print(a*b) except TypeError: print("a*b has no meaning for Python lists") print() print("numpy array operations:") a = np.array([1,2,3]) b = np.array([4,5,6]) print("a+b:", a+b) print("a*b:", a*b) Python list operations: a+b: [1, 2, 3, 4, 5, 6] a*b has no meaning for Python lists numpy array operations: a+b: [5 7 9] a*b: [ 4 10 18]
ndarray 还具备 维数组所具有的多个特性:每个 ndarray 都具有
个轴(从 0 开始索引),第一个轴为轴 0,第二个轴为轴 1,以此类推。另外,由于二维 ndarray 较为常见,因此可以将轴 0 视为行,将轴 1 视为列,如图 1-3 所示。
图 1-3:一个二维 ndarray,其中轴 0 为行,轴 1 为列
NumPy 库的 ndarray 类还支持以直观的方式对这些轴应用函数。例如,沿轴 0(二维数组的行)求和本质上就是沿该轴“折叠数组”,返回的数组比原始数组少一个维度。对二维数组来说,这相当于对每一列进行求和:
print('a:') print(a) print('a.sum(axis=0):', a.sum(axis=0)) print('a.sum(axis=1):', a.sum(axis=1)) a: [[1 2] [3 4]] a.sum(axis=0): [4 6] a.sum(axis=1): [3 7]
ndarray 类支持将一维数组添加到最后一个轴上。对一个 行
列的二维数组 a 而言,这意味着可以添加长度为
的一维数组 b。NumPy 将以直观的方式进行加法运算,并将元素添加到 a 的每一行中 1。
a = np.array([[1,2,3], [4,5,6]]) b = np.array([10,20,30]) print("a+b:\n", a+b) a+b: [[11 22 33] [14 25 36]]
类型检查函数
如前所述,本书代码的主要目标是确保概念描述的准确性和清晰性。随着本书内容的展开,这将变得更具挑战性,后文涉及编写带有许多参数的函数,这些参数是复杂类的一部分。为了解决这个问题,本书将在整个过程中使用带有类型签名的函数。例如,在第 3 章中,我们将使用如下方式初始化神经网络:
def __init__(self, layers: List[Layer], loss: Loss, learning_rate: float = 0.01) -> None:
仅通过类型签名,就能了解该类的用途。与此相对,考虑以下可用于定义运算的类型签名:
def operation(x1, x2):
这个类型签名本身并没有给出任何提示。只有打印出每个对象的类型,查看对每个对象执行的运算,或者根据名称 x1 和 x2 进行猜测,才能够理解该函数的功能。这里可以改为定义具有类型签名的函数,如下所示:
def operation(x1: ndarray, x2: ndarray) -> ndarray:
很明显,这是接受两个 ndarray 的函数,可以用某种方式将它们组合在一起,并输出该组合的结果。由于它们读起来更为清楚,因此本书将使用经过类型检查的函数。
NumPy 库中的基础函数
了解前面的内容后,现在来编写之前通过 NumPy 库定义的函数:
def square(x: ndarray) -> ndarray: ''' 将输入ndarray中的每个元素进行平方运算。 ''' return np.power(x, 2) def leaky_relu(x: ndarray) -> ndarray: ''' 将Leaky ReLU函数应用于ndarray中的每个元素。 ''' return np.maximum(0.2 * x, x)
NumPy 库有一个奇怪的地方,那就是可以通过使用 np.function_name(ndarray) 或 ndarray.function_name 将许多函数应用于 ndarray。例如,前面的 ReLU 函数可以编写成 x.clip(min=0)。本书将尽量保持一致,在整个过程中遵循 np.function_name(ndarray) 约定,尤其会避免使用 ndarray.T 之类的技巧来转置二维 ndarray,而会使用 np.transpose(ndarray, (1, 0))。
如果能通过数学、示意图和代码这 3 个维度来表达相同的基本概念,就说明你已经初步拥有了真正理解深度学习所需的灵活思维。
1这样一来,后续便可以轻松地向矩阵乘法添加偏差。
1.2 导数
像函数一样,导数也是深度学习的一个非常重要的概念,大多数人可能很熟悉。同样,导数也可以用多种方式进行描述。总体来说,函数在某一点上的导数,可以简单地看作函数输出相对于该点输入的“变化率”。接下来基于前面介绍的 3 个维度来了解导数,从而更好地理解导数的原理。
数学
首先来从数学角度精确地定义导数:可以使用一个数字来描述极限,即当改变某个特定的输入值 时,函数
输出的变化:
通过为 设置非常小的值(例如 0.001),可以在数值上近似此极限。因此,可以将导数计算为:
虽然近似准确,但这只是完整导数思维模型的一部分,下面来从示意图的维度认识导数。
示意图
采用一种熟悉的方式:在含有函数 图像的笛卡儿坐标系上,简单地画出该函数的一条切线,则函数
在点
处的导数就是该线在点
处的斜率。正如本节中的数学描述一样,这里也可以通过两种方式实际计算这条线的斜率。第一种方式是使用微积分来实际计算极限,第二种方式是在
处和
处取连线
的斜率。后者如图 1-4 所示,如果学过微积分,应该会很熟悉。
图 1-4:导数即为斜率
正如 1.1 节所述,可以把函数想象成小型工厂。现在想象那些工厂的输入通过一根线连接到输出。求解导数相当于回答这样一个问题:如果将函数的输入 拉高一点,或者如果函数在
处可能不对称,因此把
拉低一点,那么根据工厂的内部运作机制,输出量将以这个小数值的多少倍进行变化呢?如图 1-5 所示。
图 1-5:导数可视化的另一种方法
对理解深度学习而言,第二种表示形式比第一种更为重要。
代码
可以通过编码来求解前面看到的导数的近似值:
from typing import Callable def deriv(func: Callable[[ndarray], ndarray], input_: ndarray, delta: float = 0.001) -> ndarray: ''' 计算函数func在input_数组中每个元素处的导数。 ''' return (func(input_ + delta) - func(input_ - delta)) / (2 * delta)
当说
是
(随机选的字母)的函数时,其实是指存在某个函数
,使得
。或者说,有一个函数
,它接受对象
并产生对象
。也可以说,
是函数
应用于
时产生的任意函数值:
可以将其编码为下面这种形式。
def f(input_: ndarray) -> ndarray: # 一些转换 return output P = f(E)
1.3 嵌套函数
现在来介绍一个概念,该概念将成为理解神经网络的基础:函数可以被“嵌套”,从而形成“复合”函数。“嵌套”到底是什么意思呢?假设有两个函数,按照数学惯例,它们分别为 和
,其中一个函数的输出将成为另一个函数的输入,这样就可以“把它们串在一起”。
数学
嵌套函数在数学上表示为:
这不太直观,因为有个奇怪的地方:嵌套函数是“从外而内”读取的,而而运算实际上是“从内而外”执行的。例如,尽管 读作“
接受
接受
对象而产生对象
”,但其真正含义是“首先将
应用于
,然后将
应用于该结果,最终得到对象
”。
示意图
要表示嵌套函数,最直观的方法是使用小型工厂表示法,又称盒子表示法。
如图 1-6 所示,输入进入第一个函数,转换之后进行输出。然后,这个输出进入第二个函数并再次转换,得到最终输出。
图 1-6:直观地表示嵌套函数
代码
前面已经介绍了两个维度,接下来从代码维度来认识嵌套函数。首先,为嵌套函数定义一个数据类型:
from typing import List # 函数接受一个ndarray作为参数并生成一个ndarray Array_Function = Callable[[ndarray], ndarray] # 链是一个函数列表 Chain = List[Array_Function]
然后,定义数据如何经过特定长度的链,以长度等于 2 为例,代码如下所示。
def chain_length_2(chain: Chain, a: ndarray) -> ndarray: ''' 在一行代码中计算“链”中的两个函数。 ''' assert len(chain) == 2, \ "Length of input 'chain' should be 2" f1 = chain[0] f2 = chain[1] return f2(f1(x))
另一种示意图
使用盒子表示法描述嵌套函数表明,该复合函数实际上就只是一个函数。因此,可以将该函数简单地表示为 ,如图 1-7 所示。
图 1-7:嵌套函数的另一种表示法
此外,在微积分中有一个定理,那就是由“基本可微”的函数组成的复合函数本身就是基本可微的!因此,可以将 视为另一个可计算导数的函数。计算复合函数的导数对于训练深度学习模型至关重要。
但是,现在需要一个公式,以便根据各个组成函数的导数来计算此复合函数的导数。这就是接下来要介绍的内容。
1.4 链式法则
链式法则是一个数学定理,用于计算复合函数的导数。从数学上讲,深度学习模型就是复合函数。因此,理解其导数的计算过程对于训练它们非常重要,接下来的几章将详述这一点。
数学
在数学上,这个定理看起来较为复杂,对于给定的值 ,我们有:
其中 只是一个伪变量,代表函数的输入。
当描述具有一个输入和一个输出的函数
的导数时,可以将代表该函数导数的函数表示为
。可以用其他伪变量替代
,这样做并不会对结果造成影响,就像
和
表示同一个意思一样。
稍后,我们将处理包含多个输入(例如
和
)的函数。一旦碰到这种情况,区分
和
之间的不同含义就是有意义的。
这就是为什么在前面的公式中,我们在所有的导数中将
放在了底部:
和
都是接受一个输入并产生一个输出的函数,在这些情况下(有一个输入和一个输出的函数),我们将在导数符号中使用
。
示意图
对理解链式法则而言,本节中的数学公式不太直观。对此,盒子表示法会更有帮助。下面通过简单的 示例来解释导数“应该”是什么,如图 1-8 所示。
图 1-8:链式法则示意图
直观地说,使用图 1-8 中的示意图,复合函数的导数应该是其组成函数的导数的乘积。假设在第一个函数中输入 5,并且当 时,第一个函数的导数是 3,那么用公式表示就是
。
然后取第一个盒子中的函数值,假设它是 1,即 ,再计算第二个函数
在这个值上的导数,即计算
。如图 1-8 所示,这个值是 -2。
想象这些函数实际上是串在一起的,如果将盒子 2 对应的输入更改 1 单位会导致盒子 2 的输出产生 -2 单位的变化,将盒子 2 对应的输入更改 3 单位则会导致盒子 2 的输出变化 -6(-2×3)单位。这就是为什么在链式法则的公式中,最终结果是一个乘积:。
利用数学和示意图这两个维度,我们可以通过使用链式法则来推断嵌套函数的输出相对于其输入的导数值。那么计算这个导数的代码如何编写呢?
代码
下面对此进行编码,并证明按照这种方式计算的导数会产生“看起来正确”的结果。这里将使用 square 函数 2 以及 sigmoid 函数,后者在深度学习中非常重要:
2参见 1.1 节的“NumPy 库中的基础函数”部分。
def sigmoid(x: ndarray) -> ndarray: ''' 将sigmoid函数应用于输入ndarray中的每个元素。 ''' return 1 / (1 + np.exp(-x))
现在编写链式法则:
def chain_deriv_2(chain: Chain, input_range: ndarray) -> ndarray: ''' 使用链式法则计算两个嵌套函数的导数:( f 2 f 1(x))′ = f 2′( f 1(x))*f 1′(x) 。 ''' assert len(chain) == 2, \ "This function requires 'Chain' objects of length 2" assert input_range.ndim == 1, \ "Function requires a 1 dimensional ndarray as input_range" f1 = chain[0] f2 = chain[1] # df1/dx f1_of_x = f1(input_range) # df1/du df1dx = deriv(f1, input_range) # df2/du(f1(x)) df2du = deriv(f2, f1(input_range)) # 在每一点上将这些量相乘 return df1dx * df2du
图 1-9 绘制了结果,并展示了链式法则的有效性:
PLOT_RANGE = np.arange(-3, 3, 0.01) chain_1 = [square, sigmoid] chain_2 = [sigmoid, square] plot_chain(chain_1, PLOT_RANGE) plot_chain_deriv(chain_1, PLOT_RANGE) plot_chain(chain_2, PLOT_RANGE) plot_chain_deriv(chain_2, PLOT_RANGE)
图 1-9:链式法则的有效性3
3请在图灵社区本书主页上查看该图的彩色版本。——编者注
链式法则似乎起作用了。当函数向上倾斜时,导数为正;当函数向下倾斜时,导数为负;当函数未发生倾斜时,导数为零。
因此,实际上只要各个函数本身是基本可微的,就可以通过数学公式和代码计算嵌套函数(或复合函数)的导数,例如 。
从数学上讲,深度学习模型是这些基本可微函数的长链。建议花时间手动执行稍长一点的详细示例(参见 1.5 节),这样有助于直观地理解链式法则,包括其运行方式以及在更复杂的模型中的应用。
1.5 示例介绍
仔细研究一条稍长的链,假设有 3 个基本可微的函数,分别是 、
和
,如何计算它们的导数呢?从前面提到的微积分定理可以知道,由任意有限个“基本可微”函数组成的复合函数都是基本可微的。因此,计算导数应该不难实现。
数学
从数学上讲,对于包含 3 个基本可微的函数的复合函数,其导数的计算公式如下:
仅仅看公式不是很直观,但相比 1.4 节介绍的 适用于长度为 2 的链,两者的基本逻辑是一样的。
示意图
要理解以上公式,最为直观的方法就是通过盒子示意图,如图 1-10 所示。
图 1-10:通过盒子表示法理解如何计算 3 个嵌套函数的导数
使用与 1.4 节中类似的逻辑:假设 的输入(称为
)通过一根线连接到输出(称为
),则将
改变较小量 Δ,将导致
变化 Δ 的
倍,进而导致链中的下一步
变化 Δ 的
倍,以此类推,直到最终表示变化的完整公式等于前一个链式法则乘以 Δ。请仔细思考上述解释和图 1-10 中的示意图,无须花费太长时间。在编写代码时,这一点会更容易理解。
代码
在给定组合函数的情况下,如何将本节中的公式转换为计算导数的代码呢?我们可以在这个简单的示例中看到神经网络前向传递和后向传递的雏形:
def chain_deriv_3(chain: Chain, input_range: ndarray) -> ndarray: ''' 使用链式法则来计算3个嵌套函数的导数: (f 3(f 2(f1)))' = f 3'(f 2(f 1(x))) * f 2'(f 1(x)) * f 1'(x)。 ''' assert len(chain) == 3, \ "This function requires 'Chain' objects to have length 3" f1 = chain[0] f2 = chain[1] f3 = chain[2] # f1(x) f1_of_x = f1(input_range) # f2(f1(x)) f2_of_x = f2(f1_of_x) # df3du df3du = deriv(f3, f2_of_x) # df2du df2du = deriv(f2, f1_of_x) # df1dx df1dx = deriv(f1, input_range) # 在每一点上将这些量相乘 return df1dx * df2du * df3du
注意,在计算这个嵌套函数的链式法则时,这里对它进行了两次“传递”。
● “向前”传递它,计算出 f1_of_x 和 f2_of_x,这个过程可以称作(或视作)“前向传递”。
● “向后”通过函数,使用在前向传递中计算出的量来计算构成导数的量。
最后,将这 3 个量相乘,得到导数。
接下来使用前面定义的 3 个简单函数来说明上面的方法是可行的,这 3 个函数分别为 sigmoid、square 和 leaky_relu。
PLOT_RANGE = np.range(-3, 3, 0.01) plot_chain([leaky_relu, sigmoid, square], PLOT_RANGE) plot_chain_deriv([leaky_relu, sigmoid, square], PLOT_RANGE)
图 1-11 显示了结果。
图 1-11:即使使用三重嵌套函数,链式法则也有效4
4请在图灵社区本书主页上查看该图的彩色版本。——编者注
再次将导数图与原始函数的斜率进行比较,可以看到链式法则确实正确地计算了导数。
基于以上理解,现在再来看一下具有多个输入的复合函数,这类函数遵循已经建立的相同原理,最终将更适用于深度学习。
1.6 多输入函数
至此,我们已经从概念上理解了如何将函数串在一起形成复合函数,并且知道了如何将这些函数表示为一系列输入或输出的盒子,以及如何计算这些函数的导数。一方面通过数学公式理解了导数,另一方面通过“向前”组件和“向后”组件,根据传递过程中计算出的量理解了导数。
在深度学习中处理的函数往往并非只有一个输入。相反,它们有多个输入,在某些步骤中,这些输入以相加、相乘或其他方式组合在一起。正如下面介绍的,我们同样可以计算这些函数的输出相对于其输入的导数。现在假设存在一个有多个输入的简单场景,其中两个输入相加,然后再输入给另一个函数。
数学
在这个例子中,从数学意义上开始讨论实际上很有帮助。如果输入是 和
,那么可以认为函数分两步进行。在步骤 1 中,
和
传到了将它们相加的函数。将该函数表示为
(整个过程使用希腊字母表示函数名),然后将函数的输出表示为
。从形式上看,这样很容易表示:
步骤 2 是将 传给某个函数
(
可以是任意连续函数,例如 sigmoid 函数或 square 函数,甚至是名称不以
开头的函数)。将此函数的输出表示为
,也就是:
将整个函数用 表示,可以写作:
从数学意义上理解,这样更为简洁,但这实际上是两个按顺序执行的运算,这一点在表达式中较为模糊。为了说明这一点,来看示意图。
示意图
既然谈到了多输入函数,现在来定义我们一直在讨论的一个概念:用箭头表示数学运算顺序的示意图叫作计算图(computational graph)。例如,图 1-12 展示了上述函数 的计算图。
图 1-12:多输入函数
可以看到,两个输入进入 输出
,然后
再被传递给了
。
代码
对此进行编码非常简单。但是要注意,必须添加一条额外的断言:
def multiple_inputs_add(x: ndarray, y: ndarray, sigma: Array_Function) -> float: ''' 具有多个输入和加法的函数,前向传递。 ''' assert x.shape == y.shape a = x + y return sigma(a)
与本章前面提到的函数不同,对于输入 ndarray 的每个元素,这个函数不只是简单地进行“逐元素”运算。每当处理将多个 ndarray 作为输入的运算时,都必须检查它们的形状,从而确保满足该运算所需的所有条件。在这里,对于像加法这样的简单运算,只需要检查形状是否相同,以便逐元素进行加法运算。
1.7 多输入函数的导数
可以根据函数的两个输入来计算其输出的导数,这一点很容易理解。
数学
链式法则在这些函数中的应用方式与前面各节介绍的方式相同。由于 是嵌套函数,因此可以这样计算导数:
当然, 的计算公式与此相同。
现在要注意:
无论 (或
)的值如何,x(或
)每增加一单位,
都会增加一单位。
基于这一点,稍后可以通过编写代码来计算这样一个函数的导数。
示意图
从概念上讲,计算多输入函数的导数与计算单输入函数的导数所用的方法是相同的:计算每个组成函数“后向”通过计算图的导数,然后将结果相乘即可得出总导数,如图 1-13 所示。
图 1-13:多输入函数后向通过计算图
代码
def multiple_inputs_add_backward(x: ndarray, y: ndarray, sigma: Array_Function) -> float: ''' 计算这个简单函数对两个输入的导数。 ''' # 计算前向传递结果 a = x + y # 计算导数 dsda = deriv(sigma, a) dadx, dady = 1, 1 return dsda * dadx, dsda * dady
当然,你可以修改以上代码,比如让 x 和 y 相乘,而不是相加。
接下来将研究一个更复杂的示例,该示例更接近于深度学习的工作原理:一个与前一示例类似的函数,但包含两个向量输入。
1.8 多向量输入函数
深度学习涉及处理输入为向量(vector)或矩阵(matrix)的函数。这些对象不仅可以进行加法、乘法等运算,还可以通过点积或矩阵乘法进行组合。前面提到的链式法则的数学原理,以及使用前向传递和后向传递计算函数导数的逻辑在这里仍然适用,本章剩余部分将对此展开介绍。
这些技术将最终成为理解深度学习有效性的关键。深度学习的目标是使模型拟合某些数据。更准确地说,这意味着要找到一个数学函数,以尽可能最优的方式将对数据的观测(将作为函数的输入)映射到对数据的目标预测(将作为函数的输出)。这些观测值将被编码为矩阵,通常以行作为观测值,每列则作为该观测值的数字特征。第 2 章将对此进行更详细的介绍,现阶段必须能够计算涉及点积和矩阵乘法的复杂函数的导数。
下面从数学维度精确定义上述概念。
数学
在神经网络中,表示单个数据点的典型方法是将 个特征列为一行,其中每个特征都只是一个数字,如
、
等表示如下:
这里要记住的一个典型示例是预测房价,第 2 章将从零开始针对这个示例构建神经网络。在该示例中, 、
等是房屋的数字特征,例如房屋的占地面积或到学校的距离。
1.9 基于已有特征创建新特征
神经网络中最常见的运算也许就是计算已有特征的加权和,加权和可以强化某些特征而弱化其他特征,从而形成一种新特征,但它本身仅仅是旧特征的组合。用数学上的一种简洁的方式表达就是使用该观测值的点积(dot product),包含与特征 等长的一组权重。下面分别从数学、示意图、代码等维度来探讨这个概念。
数学
如果存在如下情况:
那么可以将此运算的输出定义为:
注意,这个运算是矩阵乘法的一个特例,它恰好是一个点积,因为 只有一行,而
只有一列。
接下来介绍用示意图描绘它的几种方法。
示意图
可以通过一种简单的方法来描绘这种运算,如图 1-14 所示。
图 1-14:矩阵乘法(向量点积)示意图(一)
图 1-14 中的运算接受两个输入(都可以是 ndarray),并生成一个输出 ndarray。
对涉及多个输入的大量运算而言,这确实做了很大的简化。但是我们也可以突出显示各个运算和输入,如图 1-15 和图 1-16 所示。
图 1-15:矩阵乘法示意图(二)
图 1-16:矩阵乘法示意图(三)
注意,矩阵乘法(向量点积)是表示许多独立运算的一种简洁方法。这种运算会让后向传递的导数计算起来非常简洁,下一节将介绍这一点。
代码
对矩阵乘法进行编码很简单:
def matmul_forward(X: ndarray, W: ndarray) -> ndarray: ''' 计算矩阵乘法的前向传递结果。 ''' assert X.shape[1] == W.shape[0], \ ''' 对于矩阵乘法,第一个数组中的列数应与第二个数组中的行数相匹配。而这里, 第一个数组中的列数为{0},第二个数组中的行数为{1}。''' .format(X.shape[1], W.shape[0]) # 矩阵乘法 N = np.dot(X, W) return N
这里有一个新的断言,它确保了矩阵乘法的有效性。(这是第一个运算,它不仅处理相同大小的 ndarray,还逐元素执行运算,而现在的输出与输入实际上并不匹配。因此,这个断言很重要。)
1.10 多向量输入函数的导数
对于仅以一个数字作为输入并生成一个输出的函数,例如 或
,计算导数很简单,只需应用微积分中的规则即可。然而,对于向量函数,其导数就没有那么简单了:如果将点积写为
这种形式,那么自然会产生一个问题:
和
分别是什么?
数学
如何定义“矩阵的导数”?回顾一下,矩阵语法只是对一堆以特定形式排列的数字的简写,“矩阵的导数”实际上是指“矩阵中每个元素的导数”。由于 有一行,因此它可以这样定义:
然而, 的输出只是一个数字:
。可以看到,如果
改变了
单位,那么
将改变
单位。同理,其他的
元素也满足这种情况。因此可以得出下面的公式:
这个结果出乎意料地简练,掌握这一点极为关键,既可以理解深度学习的有效性,又可以知道如何清晰地实现深度学习。
以此类推,可以得到如下公式。
示意图
从概念上讲,我们只想执行图 1-17 所示的操作。
图 1-17:矩阵乘法的后向传递
如前面的例子所示,当只处理加法和乘法时,计算这些导数很容易。但是如何利用矩阵乘法实现类似的操作呢?图 1-17 中的示意图并不直观,要准确地定义它,必须求助于本节中的数学公式。
代码
从数学上推算答案应该是最困难的部分,对结果进行编码则比较简单:
def matmul_backward_first(X: ndarray, W: ndarray) -> ndarray: ''' 计算矩阵乘法相对于第一个参数的后向传递结果。 ''' # 后向传递 dNdX = np.transpose(W, (1, 0)) return dNdX
这里计算的 dNdX 表示 的每个元素相对于输出
的和的偏导数。在本书中,这个量有一个特殊的名称,即
的梯度(gradient)。这个概念是指,对于
的单个元素(例如
),dNdX 中的对应元素(具体来说是 dNdX[2])是向量点积
的输出相对于
的偏导数。在本书中,梯度仅指偏导数的多维对应物。具体来说,它是函数输出相对于该函数输入的每个元素的偏导数数组。
1.11 向量函数及其导数:再进一步
当然,深度学习模型不止涉及一个运算,它们包括长链式运算,其中一些是 1.10 节介绍的向量函数,另一些则只是将函数逐元素地应用于它们接受的 ndarray(输入)中。现在来计算包含这两种函数的复合函数的导数。假设函数接受向量 和向量
,执行 1.10 节描述的点积(将其表示为
),然后将向量输入到函数
中。这里将用新的语言来表达同样的目标:计算这个新函数的输出相对于向量
和向量
的梯度。从第 2 章开始,本书将详细介绍它如何与神经网络相关联。现在只需大致了解这个概念,也就是可以为任意复杂度的计算图计算梯度。
数学
公式很简单,如下所示。
示意图
图 1-18 与图 1-17 类似,只不过在最后添加了函数 。
图 1-18:与图 1-17 类似,但在最后添加了另一个函数
代码
可以像下面这样编写本例中的函数。
def matrix_forward_extra(X: ndarray, W: ndarray, sigma: Array_Function) -> ndarray: ''' 计算涉及矩阵乘法的函数(一个额外的函数)的前向传递结果。 ''' assert X.shape[1] == W.shape[0] # 矩阵乘法 N = np.dot(X, W) # 通过sigma传递矩阵乘法的输出 S = sigma(N) return S
向量函数及其导数:后向传递
类似地,后向传递只是前述示例的直接扩展。
数学
由于 是嵌套函数,具体来说就是
,因此该函数在
处的导数可以这样表示:
第一部分很简单:
这是一个很好的定义, 是连续函数,可以在任意点求导。在这里,只在
处对其求值。
此外,我们在 1.10 节的示例中已经推断出 。因此,可以这样表达:
与前面的示例一样,由于最终答案是数字 乘以
中的与
形状相同的向量,因此这个公式会得出一个与
形状相同的向量。
示意图
图 1-19 所示的这个函数的后向传递示意图与前面的例子类似,甚至不需要在数学上做过多解释。矩阵乘法的结果包含所计算的 函数的导数,只需要在这个导数的基础上再添加一个乘法。
图 1-19:带有矩阵乘法的图:后向传递
代码
对后向传递进行编码也很简单:
def matrix_function_backward_1(X: ndarray, W: ndarray, sigma: Array_Function) -> ndarray: ''' 计算矩阵函数相对于第一个元素的导数。 ''' assert X.shape[1] == W.shape[0] # 矩阵乘法 N = np.dot(X, W) # 通过sigma传递矩阵乘法的输出 S = sigma(N) # 后向计算 dSdN = deriv(sigma, N) # dNdX dNdX = np.transpose(W, (1, 0)) # 将它们相乘。因为这里的dNdX是1×1,所以顺序无关紧要 return np.dot(dSdN, dNdX)
注意,这里显示的动态效果与 1.5 节中 3 个嵌套函数示例显示的动态效果相同:计算前向传递(这里指 N)上的量,然后在后向传递期间进行使用。
这是对的吗?
如何判断正在计算的这些导数是否正确?测试起来很简单,就是稍微扰动输入并观察输出结果的变化。例如,在这种情况下, 为:
print(X) [[ 0.4723 0.6151 -1.7262]]
如果将 增加 0.01,即从 -1.7262 增加到 -1.7162,那么应该可以看到由输出梯度相对于
的前向函数生成的值有所增加,如图 1-20 所示。
图 1-20:梯度检查示意图
利用 matrix_function_backward_1 函数,可以看到梯度是 -0.1121:
print(matrix_function_backward_1(X, W, sigmoid)) [[ 0.0852 -0.0557 -0.1121]]
可以看到,在将 递增 0.01 之后,函数的输出相应减少了约 0.01 × -0.1121 = -0.001121,这可以帮助测试该梯度是否正确。如果减幅(或增幅)大于或小于此量,那么关于链式法则的计算就是错误的。然而,当执行计算时,少量增加
确实会使函数的输出值减小 0.01 × -0.1121,这意味着计算的导数是正确的!
1.12 节介绍的示例涉及前面介绍的所有运算,并且可以直接用于第 2 章将构建的模型。
1.12 包含两个二维矩阵输入的计算图
在深度学习和更通用的机器学习中,需要处理输入为两个二维数组的运算,其中一个数组表示一批数据 ,另一个表示权重
。这对建模上下文很有帮助,第 2 章将对此展开介绍。本章仅关注此运算背后的原理和数学意义,具体来说,就是通过一个简单的示例详细说明,我们不再以一维向量的点积为例,而是介绍二维矩阵的乘法。即便如此,本章介绍的推算过程仍然具有数学意义,并且实际上非常容易编码。
和以前一样,从数学上看,得出这些结果并不困难,但过程看起来有点复杂。不管怎样,结果还是相当清晰的。当然,我们会对其按步骤进行分解,并将其与代码和示意图联系起来。
数学
假设 和
如下所示:
这可能对应一个数据集,其中每个观测值都具有 3 个特征,3 行可能对应要对其进行预测的 3 个观测值。
现在将为这些矩阵定义以下简单的运算。
● 将这些矩阵相乘。和以前一样,将把执行此运算的函数表示为 ,将输出表示为
。因此,可以这样表示:
。
● 将结果 传递给可微函数
,并定义
。
和以前一样,现在的问题是:输出 相对于
和
的梯度是多少?可以简单地再次使用链式法则吗?为什么?
注意,本例与之前的示例有所不同: 不是数字,而是矩阵。那么,一个矩阵相对于另一矩阵的梯度意味着什么呢?
这就引出了一个微妙但十分重要的概念:可以在目标多维数组上执行任何一系列运算,但是要对某些输出定义好梯度,这需要对序列中的最后一个数组求和(或以其他方式聚合成单个数字),这样“ 中每个元素的变化会在多大程度上影响输出”这一问题才有意义。
因此,在最后添加第 3 个函数 ,该函数获取
中的元素并将其求和。
通过数学把它具体化。首先,把 和
相乘:
为了便于书写结果矩阵,这里将第 行的第
列表示为
。
接下来,将该结果输入到 中,这意味着将
应用于
矩阵中的每个元素:
最后,对这些元素求和:
现在回到了纯微积分的场景中:存在一个数字 ,想计算出
相对于
和
的梯度,也就是明确这些输入矩阵中每个元素(
、
等)的变化对
的影响。可以这样写:
至此,我们已经从数学上理解了所面临的问题,接下来讨论示意图层面和代码层面。
示意图
从概念上讲,与前面介绍的多输入函数的计算图相比,包含两个二维矩阵输入的运算所做的工作其实是类似的,如图 1-21 所示。
图 1-21:具有复杂前向传递函数的计算图
这里只是像以前一样向前发送输入。有一点需要明确:即使在这个更复杂的场景中,也应该能够使用链式法则计算所需的梯度。
代码
对于输入为两个二维数组的运算,可以像下面这样编码。
def matrix_function_forward_sum(X: ndarray, W: ndarray, sigma: Array_Function) -> float: ''' 输入ndarray(X、W)以及函数sigma,计算该函数的前向传递结果。 ''' assert X.shape[1] == W.shape[0] # 矩阵乘法 N = np.dot(X, W) # 通过sigma传递矩阵乘法的输出 S = sigma(N) # 将所有元素相加 L = np.sum(S) return L
1.13 有趣的部分:后向传递
现在,要对函数“执行后向传递”,这样一来,即使涉及矩阵乘法,也可以最终计算出 相对于输入 ndarray 的每个元素的梯度 5。在学完本章之后,就能轻松地在第 2 章中开始训练真正的机器学习模型。下面先从概念上明确要学习的内容。
5接下来重点计算 相对于
的梯度,但
的梯度可以通过类似的方法来计算。
数学
注意,可以直接计算。值 实际上是从
、
直到
的一个函数。
但是,这似乎很复杂。链式法则的全部要点就是将复杂函数的导数分解成简单的部分,对每个部分执行计算,然后把结果相乘。如此一来,对这些操作进行编码就变得很容易:只需要逐步进行前向传递,保存传递过程中的结果,然后使用这些结果来计算后向传递所需的所有导数。
下面展示这种方法仅适用于涉及矩阵的情况。开始深入讨论吧。
可以将 写成
。如果这是一个常规函数,就可以这样编写链式法则:
然后依次计算 3 个偏导数。前面在计算包含 3 个嵌套函数的复合函数的导数时,我们使用链式法则分别对每个函数进行了求导,这里要执行同样的操作。图 1-22(参见下一页)表明,该方法同样适用于这种函数。
由于一阶导数最直接,因此这里从一阶导数开始计算,主要是确定当 中每个元素值增加时,Λ 的输出
的增长情况。由于
是
中所有元素的总和,因此这个导数很简单:
只要 中的任何元素有所增加,比如增加 0.46 个单位,
就会增加 0.46 单位。
接下来得到 。这只是对
中元素进行求值的任一函数
的导数。在前面使用的
语法中,这同样很容易计算:
注意,我们现在可以肯定地说,能够将这两个导数按元素逐个相乘并计算 :
然而,现在陷入了困境。基于图 1-22 并应用链式法则,接下来要做的是获取 。但是,回想一下,
的输出
只是
与
矩阵相乘的结果。因此,这里要知道
(3×2 矩阵)中每个元素随着
中每个元素(3×3 矩阵)的增加而增加的量。如果上面的表述难以理解,只要明确一点就可以了,那就是现在根本不清楚如何定义它,或者无法确定这样做真的有效。
为什么会出现这个问题呢?以前,我们很幸运,由于 和
在形状上可以相互转换,因此可以证明
和
。现在可以得出类似的结论吗?
“?”的值
更具体地说,现在需要弄清楚以下公式中的“?”到底是什么。
答案
事实证明,根据乘法的计算方式,“?”处的内容就是 ,这和刚才看到的向量点积的简单示例是一样的。有一种方法可以验证这一点,那就是直接针对
中的每个元素计算
的偏导数。这样一来 6,得到的矩阵确实显著地分解成:
6更多介绍参见附录的“矩阵链式法则”。
其中第一个乘法是逐个元素执行的,第二个则是矩阵乘法。
这意味着,即使计算图中的运算涉及将矩阵与多行和多列相乘,并且即使这些运算的输出形状与输入的形状不同,仍然可以将这些运算包含在计算图中,并且使用“链式法则”逻辑对它们进行反向传播。这个结果非常重要,如果没有这个结果,那么训练深度学习模型将变得更加烦琐,后文会进一步介绍这一点。
示意图
本例的示意图与 1.12 节中的类似,如图 1-22 所示。
图 1-22:复杂函数中的后向传递
只需要计算每个组成函数的偏导数,在其输入处进行求值,并将结果相乘,就可以得到最终的导数。要依次计算这些偏导数,唯一的方法就是从上述数学层面计算。
代码
现在通过代码封装前面推导出的内容,此过程有助于加深对内容的理解:
def matrix_function_backward_sum_1(X: ndarray, W: ndarray, sigma: Array_Function) -> ndarray: ''' 计算矩阵函数相对于第一个矩阵输入的和的导数。 ''' assert X.shape[1] == W.shape[0] # 矩阵乘法 N = np.dot(X, W) # 通过sigma传递矩阵乘法的输出 S = sigma(N) # 将所有元素相加 L = np.sum(S) # 注意,这里按数量指代导数,这点与数学不同,在数学中使用的是它们的函数名 # dLdS——都是1 dLdS = np.ones_like(S) # dSdN dSdN = deriv(sigma, N) # dLdN dLdN = dLdS * dSdN # dNdX dNdX = np.transpose(W, (1, 0)) # dLdX dLdX = np.dot(dSdN, dNdX) return dLdX
现在,确认一切正常:
np.random.seed(190204) X = np.random.randn(3, 3) W = np.random.randn(3, 2) print("X:") print(X) print("L:") print(round(matrix_function_forward_sum(X, W, sigmoid), 4)) print() print("dLdX:") print(matrix_function_backward_sum_1(X, W , sigmoid)) X: [[-1.5775 -0.6664 0.6391] [-0.5615 0.7373 -1.4231] [-1.4435 -0.3913 0.1539]] L: 2.3755 dLdX: [[ 0.2489 -0.3748 0.0112] [ 0.126 -0.2781 -0.1395] [ 0.2299 -0.3662 -0.0225]]
和前面的示例一样,由于 dLdX 表示 相对于
的梯度,因此这意味着,左上角的元素表示
。
如果这个示例的矩阵数学是正确的,则将 增加 0.001 会导致
增加 0.01×0.2489 。事实上,代码的运行情况是这样的:
X1 = X.copy() X1[0, 0] += 0.001 print(round( (matrix_function_forward_sum(X1, W, sigmoid) - \ matrix_function_forward_sum(X, W, sigmoid)) / 0.001, 4)) 0.2489
看起来梯度的计算是正确的!
直观地描述梯度
回到前面提到的内容,将元素 传递给具有多重运算的函数,包括矩阵乘法、sigmoid 函数、求和运算。其中的矩阵乘法实际上是由矩阵
中的 9 个输入与矩阵
中的 6 个输入相结合,从而创建出的 6 个输出的简写。然而,也可以将其视为单独的函数 WNSL,如图 1-23 所示。
图 1-23:用单个函数 WNSL 来描述嵌套函数
由于每个函数都是可微的,因此整个函数就是一个以 为输入的可微函数。另外,梯度就是
。为了使其可视化,可以简单地绘制
随着
的变化而变化的情况。
的初始值是 -1.5775:
print("X:") print(X) X: [[-1.5775 -0.6664 0.6391] [-0.5615 0.7373 -1.4231] [-1.4435 -0.3913 0.1539]]
对于将 和
输入到前面定义的计算图中,或者说将
和
输入到前面代码调用的函数中,如果绘制整个过程中所得到的
值的图像,并且除了
(X[0, 0])外不做任何变动,可以得到图 1-24 所示的结果 7。
7这里展示的只是 matrix_function_backward_sum 函数的子集。完整函数可以从图灵社区本书主页下载。——编者注
图 1-24:在保持 和
的值为常数的情况下,
与
的对应关系
确实,在只变动 的情况下,这种关系看起来很明显。可以看到,此函数在纵轴上大约增加了 0.5(从刚好超过 2.1 到刚好超过 2.6),并且在横轴上大约增加了 2。因此,斜率大约为
,这正是刚刚计算的结果!
复杂的矩阵数学事实上正确地计算了 相对于
中所有元素的偏导数。此外,可以用类似的方法计算
相对于
的梯度。
![]()
相对于
的梯度的表达式为
。但是,
表达式中的因子是从
的导数中导出的,考虑到它们的顺序,
将位于
相对于
的梯度的表达式的左侧:
因此,尽管代码中出现了 dNdX = np.transpose(X, (1, 0)),但下一步将是:
dLdW = np.dot(dNdX, dSdN)
而不是之前的 dLdX = np.dot(dSdN, dNdX)。
1.14 小结
学完本章之后,你应该有信心理解复杂的嵌套数学函数,并通过将它们概念化为一系列盒子来解释它们的工作原理,每个盒子代表一个由线连接的单一组成函数。尤其需要注意的是,即使存在涉及二维 ndarray 的矩阵乘法,也可以编写代码来计算这些函数的输出相对于任何输入的导数,理解正确计算这些导数背后的数学原理。掌握这些基本概念,有助于接下来构建和训练神经网络。加油!