2.2 自动求导和人工智能框架

上一节的GD法解决了基于梯度的通用迭代算法问题,BP算法解决了多元复合函数求导问题,这使得我们向通用深度学习框架前进了一大步。但是对于简单函数,我们仍然需要声明每个函数的导函数,例如sin(x)的导函数是cos(x)等。接下来,我们从很容易理解的表达式出发,一步一步地用面向对象的方法解决这个问题。

2.2.1 表达式和自动求偏导

这一小节我们考虑的问题是:如何才能自动求得表达式的偏导函数?让我们用面向对象的方法进行思考。首先,把表达式抽象为一个名为Exp的类,根据需要,可以在其中定义方法deriv(),用来计算表达式针对某一变量的偏导函数,见下面的代码片段:

还记得我们是怎么考虑递归程序设计的吗?第一步就是考虑递归边界。同样,在进行面向对象思考时,第一步应该考虑最简单的表达式是什么。显然,最简单的表达式是常数和变量,例如3、5、xy等。所以我们在前面代码的基础上添加两个类Const和Variable,它们都是Exp类的子类。代码如下:

在Const的构造函数__init__()中,我们定义了一个参数value来表示这个常数对象的值。在Variable的构造函数中,我们定义了一个参数name来表示变量的名字,例如x。我们还重定义了Const和Variable的__repr__()方法。这样,在打印一个表达式时,可以根据它是常数还是变量,调用不同的__repr__()方法,从而得到不同的字符串。

由于我们创建表达式Exp类的目的是求偏导,所以接着要考虑的是常数和变量的偏导该怎么求?显然,常数对任何自变量的偏导都是0。对于变量来说,如果它与自变量相同,偏导就是1;否则,偏导就是0。所以我们只需在Const和Variable中重定义deriv()函数即可。代码如下:

比常数和变量稍复杂一点的表达式就是加减乘除。以加法为例,我们先定义Exp的子类Add。在Add的构造函数__init__()中添加left和right两个参数,分别表示加法运算的左右两个子表达式。由于加法的偏导等于偏导的加法,所以很容易重定义deriv()。以下是代码片段(我们把Add类置于类Variable之后,并对主程序做了轻微改动。代码的其他部分与之前相同,保持不变):

输出结果:

(123+x)

(0+1)

前面我们讲分数的加减乘除时(见代码1-19),已经知道如何把运算符和类的内部成员函数挂钩,并且已经知道了__add__()与__radd__()的区别。所以,通过重定义这些运算符函数就可以达到简化使用的目的。下面是对__add__()与__radd__()的重定义。我们在主程序中直接使用了+运算符,并且,为了方便直接使用Python的常数(例如789),我们还新构建了一个函数to_exp(),用来把一个可能的Python常数转化为一个Exp对象。代码的其他部分与之前相同,保持不变。

由于有了加法运算符,所以这里就可以把Add.deriv()函数中的Add()调用换成+。

以此类推,我们可以把减、乘、除、乘方、求反、对数、正弦、余弦等几乎所有的数学函数都实现,进而实现所有这些函数的任意组合运算以及相应的求导函数运算。下面仅举几个简单例子。

第一个例子是乘法。乘法求导的公式是(uv)′=u′v+uv′,不论uv是函数还是常数,公式都成立。所以,它的求导实现如下,请在前面代码的基础上添加以下代码:

注意,在重定义Mul的deriv()函数时,我们直接使用了∗和+运算符。

第二个例子是除法。对于除法运算,读者在重定义Exp的__rtruediv__()时,要注意左右子表达式的次序是颠倒的,不要出错。这是因为Python语言对于形如A+B这样的运算是这样考虑的:

1)如果A是一个对象,则调用这个对象的__add__(B)函数,B是参数。如果这个函数不存在就报错。

2)如果A是基本数据类型,例如整数(int)、浮点数(float)、布尔行(bool),则调用B的__radd__(A)。注意,add前多了一个r,并且A是参数,所以B.__radd__(A)的真实含义和次序是A+B。

下面是除法的实现:

第三个例子是乘方运算。我们可以对yuv等号两边取对数然后再求导,得到(uv)′=uv-1u′v+uv′lnu)。所以乘方及其偏导的实现是这样的:

代码2-11的最后一行我们调用了一个还没有实现的函数log,这个函数用来实现求对数运算。它的特殊地方在于,在Python中没有与之对应的运算符,而加、减、乘、除、乘方等运算是有对应运算符的,例如乘方的运算符是两个星号(∗∗)。更一般地,在计算机语言中,大多没有对数运算符。即使有,一般也不允许用户对其重定义。遇到这种情况,我们可以定义一个一般函数来代替,使用时同样很方便。注意,(logvu)′=。请在类Const之后放置以下代码,并且在文件头部执行import math,因为要定义常数e。

至此我们给出了在Python中有对应运算符和没有对应运算符的函数实现的例子。以此类推,读者请务必自行实现所有其他基本运算和函数,包括但不限于加、减、乘、除、乘方、对数、求反、绝对值、三角函数、反三角函数……如果某个函数或者运算符没有实现,会影响后面程序的运行。之后我们就可以轻松计算任意函数或者复合多元函数的偏导。下面是一个例子:

运行结果是:

exp=(789+x)

deriv=(0+1)

exp=(x+789)

deriv=(1+0)

exp=((3 ∗(x ∗∗2))+(x ∗∗5))

deriv =(((3 ∗((x ∗∗(2-1))∗((1 ∗ 2)+((x ∗ 0)∗ log(x,2.718281828459045)))))+(0 ∗(x ∗∗2)))+((x ∗∗(5-1))∗((1 ∗5)+((x ∗0)∗log(x,2.718281828459045)))))

其中最后一个结果是3x2+x5x的导数。结果是对的,但显然太烦琐了。优化的办法是对常数做特别处理,例如,3+5可以简化为8,a+0可以简化为a。我们可以在Exp类中定义一个simplify()函数,返回一个简化了的表达式,不能简化时就返回self自身。下面,我们对加法和减法进行简化(省略号代表类或者代码中的其他部分与上一节的代码相同)。

请读者按照同样的方法简化所有其他运算和函数。注意,有些类型是不用简化的,例如Const和Variable(想想看,为什么?)。

所有运算和函数都被简化之后,接下来我们就要考虑如何方便地使用simplify()函数。对于表达式Const(3)+Const(5)来说,我们期望能直接用Const(8)代替。为了达到这个目的,我们在Exp的所有运算符函数(例如__add__())中对运算结果调用simplify()即可。下面是在类Exp和函数log()中调用simplify()的示例[3]

最后运行程序,得到的结果是:

exp=(789+x)

deriv=1

exp=(x+789)

deriv=1

exp=((3 ∗(x ∗∗2))+(x ∗∗5))

deriv=((3 ∗(x ∗2))+((x ∗∗4)∗5))

2.2.2 表达式求值

当自变量的值已经给定时,我们有时希望给出表达式的值或导数的值。由于偏导函数也是一种表达式,所以,这个问题可归结为如何求表达式的值。而Exp的所有子类中,只有变量Variable的值是不确定的,其他表达式的值可以通过计算得到。所以,问题又归结为如何对变量进行赋值。

根据面向对象方法,我们解决这个问题的办法是在父类Exp中定义一个新方法eval(∗∗env[4])。该成员函数用来把当前表达式转为另一个表达式,参数evn中保存了变量的值。例如,当x=5时,表达式x+3会被转化为5+3,然后又简化为8。代码如下:

在上面的代码中,我们先在Exp中定义了eval()函数,然后在Exp的子类Neg、Const、Log、Variable中重定义了这个函数。请大家自行重定义Exp所有子类中的eval()函数,然后运行上面的程序(完整代码见p02_6_eval.py),得到如下结果:

exp=((3 ∗(x ∗∗2))+(x ∗∗5))

deric=((3 ∗(x ∗2))+((x ∗∗4)∗5))

exp[x=0]=0

deriv[x=0]=0

exp[x=1]=4

deriv[x=1]=11

exp[x=2]=44

deriv[x=2]=92

exp[x=3]=270

deriv[x=3]=423

exp[x=4]=1072

deriv[x=4]=1304

2.2.3 求解任意方程

前面我们介绍了GD法和表达式以及表达式求导和求值。下面把它们结合在一起,构成一个智能框架,从而能够求解用户给出的任意方程或者方程组。

例如,为了求解方程x2+3x-10=0,我们首先要让用户输入x∗∗2+3∗x-10这样的字符串,然后通过Python的内部函数eval()[5]把该字符串转成Exp类型。最后,用GD法的式(2-2)求解该表达式等于0的解。根据自顶向下原则,我们先写主程序:

主程序中的关键是用到了Python的内部函数eval(),它可以把用户输入的字符串当成一个Python表达式进行求值。假设用户输入字符串x∗∗2+3∗x-10,eval()就会把它理解为Python表达式,然后计算它的值。你可以把字符串理解为Python代码,替换eval()调用。例如exp=eval(exp)就相当于exp=x∗∗2 +3∗x-10。由于我们在这一句之前定义了x=Variable(′x′),所以Python能够调用我们前面为Exp编写的代码,从而构成一个Exp表达式。假如没有x=Variable(′x′)这一句,运行时Python会报告变量x没有定义。

接着我们调用了solve()函数,它被用来求解表达式等于0的解。见代码2-18:参数exp表示输入的任意表达式;variable是该表达式中要求解的变量;epoches为int型,表示最大迭代次数,缺省值为50000;lr为学习步长(learning rate),即式(2-2)中的a,缺省值为0.01;eps是解的精度要求,缺省值为0.001。我们按照算法2-1对函数solve()进行了实现。

程序运行时,用户只需输入一个合法的、以x为自变量的Python表达式即可。下面是个例子:

Please input the expression such as x∗∗2+3∗x-10,press enter to exit:x∗∗2+3∗x-10

(((x ∗∗2)+(3 ∗ x))-10)

x=1.999921322820418

Please input the expression such as x∗∗2+3∗x-10,press enter to exit:

注意,如果希望用户使用log()这样的没有运算符的函数,应该在程序里把上节我们定义的log()函数引入(import)进来;否则,程序无法识别这样的函数。

2.2.4 求解任意方程组

对上节的代码稍加改进,我们就可以求解任意多元方程组。方法是:

1)把solve的参数exp、variable分别改为exps和variables,表示输入的方程和变量都是一个列表。

2)在solve的函数体中,把局部变量value改为values,对应于每一个变量的值。

3)构建一个新的表达式exp,它等于所有方程的平方和。这样,在求得exp的最小值时,顺带就求得了原来方程组的解(下文我们会把这样的exp称为目标函数)。

4)计算并记录exp对每一个变量的偏导函数derivs。

5)在循环体中求每一个变量的变化值,然后更新所有变量。

6)返回解即可。

解方程组的代码如下:

所以,虽然上述代码是针对方程组的,但它的逻辑与前面解方程的程序是等价的;只不过解方程时只处理一个未知数、一个值,而解方程组时处理一堆未知数、一堆值。算法逻辑并没有改变。这也是一个比较重要的程序设计技巧,即先针对简单的数编写程序,然后再把其中的数据改为多维,但不改变程序逻辑,从而达到提升程序功能的目的。

这个程序允许用户最多使用3个未知数xyz。输入完毕后直接按回车键,系统就会自动对方程组进行求解。下面是一个例子,求解方程组

Please input an expression with variables of x/y/z:3∗x+2∗y-6

(((3 ∗ x)+(2 ∗ y))-6)

Please input an expression with variables of x/y/z:x-5∗y∗∗2+1

((x-(5 ∗(y ∗∗2)))+1)

Please input an expression with variables of x/y/z:

x=1.5213930455427034

y=0.710675704190072

z=1.0

2.2.5 求解任意函数的极小值

前面我们给出了方程和方程组的解法。我们已经构建了一个通用程序,或者说程序设计框架的雏形。下面我们更进一步,考虑求解任意多个函数的极小值。

在说明式(2-1)和式(2-2)时,已经证明了解方程等价于求极小值,所以solve()函数只需做轻微改动即可。

运行结果如下:

Please input an expression with variables of x/y/z:(x-2)∗∗2

((x-2)∗∗2)

Please input an expression with variables of x/y/z:(y+3)∗∗2

((y+3)∗∗2)

Please input an expression with variables of x/y/z:

x=1.987524486650139

y=-2.9500979466005552

z=1.0

min(((x-2)∗∗2))=0.000155638433342562

min(((y+3)∗∗2))=0.002490214933481036

2.2.6 张量、计算图和人工智能框架

至此,我们已经非常接近人工智能框架Tensorflow(简称为TF)的实质了。TF的实质是什么?TF的实质就是一个自动求微分(偏导数)的工具。TF中的基本概念其实就是表达式,即前面反复操作的Exp及其子类。只不过TF中不叫表达式,而是叫作张量。所谓张量,就是可以求值、求偏导的表达式对象。另外,TF中的张量不仅可以像Exp一样取值为标量,也可以取值为向量、矩阵或者高维矩阵;而我们的Exp只能取值为标量[6]

张量可以构成计算图。那计算图的实质又是什么呢?计算图的实质就是依赖关系图,即用来表明张量之间依赖关系的有向无环图。由于后者与BP算法密不可分,所以BP算法自然也就成了TF的核心算法之一了。

张量、计算图、GD法和BP算法构成了TF的基础,TF的其他概念、算法几乎都是建立在这个基础之上的。现在,由于学习了表达式、求值和求偏导,我们可以比较轻松地迈入TF的世界了。