机器学习和深度学习中比较重要的内容便是计算图,主流的框架如tensorflow,pytorch都是以计算图为主要框架。而计算图的核心便是自动求导。
所谓自动求导,就是在表达式或者网络结构确定之时,其(导数)梯度便也同时确定了。自动求导听上去很玄幻,很厉害,但其本质还是有向箭头的传递,该箭头是从自变量指向最终结果。
我们先定义表达式(由初等函数构造而成),在表达式构造完成之前不进行计算。完成后,传入自变量,在函数值通过箭头传递的同时,导数值也同时在箭头上传递(静态图的简单描述。静态图的概念这里先不说明,可以先按照函数表达式来理解,我们写一个函数的时候就是f(x) = ...的形式,先写好函数是如何计算的,再给X带入具体数值计算。)
这样之后给人的感觉就是计算表达式的同时,导数值也自动算出来了。
一. 手动求导
举个简单的例子,对于表达式:
我们在求导的时候,先求外层函数的导数,再求内层函数的导数,最终两者相乘,即:
接着对u(x)求导,最后得到:
再换一种写法的话:
这表明对函数求导的核心便是链式法则,而链式法则的每一步求导正和计算图的有向结构类似(这里先不讨论计算图的概念,只针对自动求导) 。
二. 引入算符
算符在量子力学中,是作用在一个函数得到另一个函数的操作。简单理解为将一个量变换为另一个量。
我们把这里的每一个函数看做一个算符,每一个算符看做一个节点,每一个量在通过节点之后就变成了另一个量。
在上面的表达式中,指数函数,三角函数,对数函数和幂函数一共四个函数,就是四个算符,四个节点,每一个节点都存在若干个变量:value(函数值),grad(导数),root(自变量),next(下一节点,最后一个节点的next为None)。
除了节点之外,最重要的就是自变量了。这里用placeholder(占位符)表示(模仿tensorflow)的写法。placeholder正如其名,作用就是占位(采用占位符的原因后续简述,这里先按照这个模式使用),像极了人们书写函数时f(x)中的x(或者说就是)。它也有grad(可有可无,自变量对自变量求导结果是1),size(数据尺寸,这里不会用到),root(就是自己)和next。
我们在构造一个函数表达式时,前一节点的输出变成了后一节点的输入。在计算节点value时,通过上一个节点的输出作为此节点中函数的输入计算而来。计算节点的grad时,通过前一节点的梯度乘该节点的导数得到。
三. 程序实现
我们用一个基类Variable作为上述placeholder与算符的基类。该类具有四个变量:value,grad,next,root。两个方法:func,func_grad。
# -*- coding: utf-8 -*-
import numpy as np
class Variable:
def __init__(self, value=None):
self.value = value
self.grad = None
self.next = None
self.root = None
def func(X):
return
def func_grad(X):
return
1.对于占位符,本来是没有value变量的,但为了形式的统一,在建立完方程形式,开始运行的时候,我还是给予了placeholder一个value属性(父亲遗传下来的东西,不用怎么行)。
class placeholder(Variable):
def __init__(self, size):
super().__init__(self)#运行父类的构造函数
self.size = size
self.root = self#root就是自己
self.grad = 1
2.对于算符,这里先简单的构建exp,sin,cos,ln和平方算符:
class exp(Variable):
def __init__(self, X):
super().__init__()#继承父类
X.next = self#作为上一个节点的下一步运算
self.root = X.root#声明自变量,复合函数自变量都是前一函数的自变量
def func(self, X):
return np.exp(X)
def func_grad(self, X):
return np.exp(X)
class sin(Variable):
def __init__(self, X):
super().__init__()
X.next = self
self.root = X.root
def func(self, X):
return np.sin(X)
def func_grad(self, X):
return np.cos(X)
class cos(Variable):
def __init__(self, X):
super().__init__()
X.next = self
self.root = X.root
def func(self, X):
return np.cos(X)
def func_grad(self, X):
return -np.sin(X)
class log(Variable):
def __init__(self, X):
super().__init__()
X.next = self
self.root = X.root
def func(self, X):
return np.log(X)
def func_grad(self, X):
return 1 / X
class square(Variable):
def __init__(self, X):
super().__init__()
X.next = self
self.root = X.root
def func(self, X):
return np.square(X)
def func_grad(self, X):
return 2 * X
构造节点的时候,我没有用全局列表保存每一步的操作以及名称,而是利用next的指针指向下一个需要计算的节点。
这样可以用最少的变量数目保存我们需要的所有信息。(放在堆上的类减少,栈上的变量增多,但是调用结束后,栈上的空间立刻就被释放了)
节点中存在root变量是为了开始计算的时候更方便些,不需要通过判断节点的上一节点是不是占位符来确定计算起始点,也不需要保存函数表达式,然后从头开始算。
3.最后定义Session对表达式求值
class Session:
def run(self, operator, feed_dict):
root = operator.root#计算起始点
root.value = feed_dict[root]#传入自变量的数据
while root.next is not operator.next:#计算到operator便停止计算
root.next.value = root.next.func(root.value)#计算节点的值
root.next.grad = root.grad * root.next.func_grad(root.value)#计算梯度
root = root.next#去往下一个节点
return root.value
通过判断目前计算的节点是否是目标节点来决定是否停止计算,这样便可以通过Session的run方法直接返回目标节点的value。
到此为止简单的自动求导就已经写好了,整个过程借鉴了tensorflow的套路,当然我没看过tensorflow的源码,只是根据自己目前为止用到过的内容简单搭建。
下面验证一下结果:
#构建表达式(计算图)
xs = placeholder((2, 2))
h1 = square(xs)
h2 = log(h1)
h3 = sin(h2)
h4 = exp(h3)
#具体数据
X = np.array([[1, 2], [3, 4]])
#建立Session计算
sess = Session()
out = sess.run(h4, feed_dict = {xs: X})
#自动求导
grad = h4.grad
#手动求导
grad0 = np.exp(np.sin(np.log(np.square(X)))) * np.cos(np.log(np.square(X))) * 1/np.square(X) * 2 * X
print()
print("自动求导:n", grad)
print("手动求导:n", grad0)
运行结果:
![6685fe2c9878070d790d2505ca741db5.png](https://i-blog.csdnimg.cn/blog_migrate/91d561e1f9b2f14a7b652e8c91ab2ba7.png)
完全正确√
结语:
一开始我搭建表达式的时候,对每个节点记录其上一步操作,这样可以通过递归的方式找到计算的起始点,然后喂入数据再计算。本来全连接层已经按这种方式写好,正在写卷积,但是某天突然醒悟之后便写下了此文,然后开始努力更改以前的东西QAQ。
实现了自动求导之后,下面就要开始静态图的搭建,还是会按照tensorflow2.0前的方式,不过既然新版本都出了,我还迷恋旧版本,ε=(´ο`*)))唉。
本人应用物理系,对ML, DL较感兴趣。可毕竟不是计算机系啦,用语不那么专业,操作可能显得复杂,希望大家能多多指导哦!