(好久不更~)前文中,参照tensorflow的方式实现了简单的自动求导。接下来要在自动求导的基底(模板)上搭建简单的bp神经网络。
计算图
前文曾多次提到计算图,关于什么是计算图,有很多种说法。既然它被称为图,便具有图的基本元素:点和线。如下图:
点:节点,用来储存变量。比如输入X,隐含层h,输出y
线(箭头):操作(算符),用来确定两个节点之间的联系,或者说由前一个节点经过这个操作后可以得到后面的节点。
可以利用上一篇构造复合函数的方式来理解它。构造函数f(x),需要自变量x和一个映射f,这里的x是节点,映射f是操作。如果另y=f(x),那么y就是新的节点,映射f的箭头是从x指向y。多个不同的映射可以组成一个大网络,储存更复杂的信息。
计算图的好处在于,它是微分链式法则的直观体现,每个节点的梯度都可以看做上一个节点的梯度与本层导数的乘积。因此,计算图网络结构建立的同时,每个节点的梯度也是确定了的。
全连接层
说到全连接层,就不得不提一下BP神经网络。神经网络是模方大脑神经元的连接方式,通过建立神经元之间的相互联系来对某个问题进行建模的方法。BP神经网络是在众多神经网络中一枝独秀,利用梯度下降方法,可以最快最准的接近局部最优值(缺点当然是容易陷入局部最优,因此也出现了很多改进算法)。
从最简单的线性回归说起。我们有两组数据,自变量:x,目标:t。对目标t的估计值为:
估计值和目标之间的均方误差为:
可以做出loss关于a0和a1的三维曲面:
可以看到他是一个二次函数形式,必然存在最小值。我们对a0和a1分别求偏导:
最小二乘法是利用这两个偏导数等于0求出取到最值时的a0和a1:
而梯度下降法则是将a0和a1按照上面算出的导数,以某个速率向最值点靠近(以他们的导数乘某个常数为速率,以梯度的负数为方向,做爬坡运动)
对于线性回归问题来说,loss是个抛物线,必然存在最值点:
但是对于更复杂的问题来说,就不一定了,loss不一定是抛物线,可能存在多个极值点,这样的话最小二乘法便不再好用,但是梯度下降却可以尽最大可能搜索最优值(虽然不容易跳出局部最优,但还是比最小二乘法要好吧)
但是随着问题复杂度的提升,自变量和因变量之间也不再是简单的线性关系,这时就要加入一个激活函数,但是光加入激活函数只能表示一些简单的非线性模型,对于更加复杂的非线性模型也无能为力。
所以现在的问题是如何利用简单的方程表示出复杂的方程。这一点在高等数学中其实已经学到了。对于一个复杂的函数,我们总可以把他展开成级数的形式,而且我们还可以证明,展开的级数就是泰勒级数,这个展开也叫做泰勒展开。
我们可以利用最简单的幂函数的叠加来趋近一个极其复杂的函数。那在这个问题上,也可以利用无数个简单的非线性函数的叠加来拟合一个极其复杂的非线性函数,所以就有了下图:
把n个线性关系叠加起来再通过激活函数计算得到最终的估计值:
损失值loss(均方差)为:
要继续利用梯度下降法,就需要计算loss关于每个w的导数:
这样就建立了梯度下降与链式法则之间联系,和上一文《简单实现自动求导》一模一样。
这里可以体现出计算图的好处。我们不需要再手动计算这些复杂的梯度值到底是多少,在计算图建立的同时,它就已经帮我们算好了。我们只需要给他一个学习的指令,他就可以按照我们要求的训练方式学习。
全连接层,顾名思义,重在“全连接”,他将所有的输入都和输出相连,如下图的两层神经网络,一个输入,一个隐含层,一个输出。
在黑框中可以看到,所有的输入X都和所有隐含层的节点相连,所有隐含层的节点都和输出y相连。
或许会问,难道还有不全连接的神经网络吗。当然有,先不说一些复杂的结果,最基本的卷积神经网络CNN就不是全连接的,它利用卷积核扫描图像,而卷积核一般都是比图像小的多的方形如3×3,5×5。
两层的神经网络理论上可以拟合任意的非线性函数。在梯度计算方面与之前稍有不同:先看第一层,第j个输出为:
要计算w的该变量,就需要计算前面那个链式表达式,它其中的一项:
所以:
对于很多层的网络:
我们给定一个变量δ,它实际上是每一层神经网络的梯度,下式为输出层的梯度:
那么权重改变量:
而上一层的梯度:
权重改变量:
因此,在网络训练过程中,先计算每一层的梯度和权重改变量,算完之后再对每一层的权重梯度下降。值得注意的是:不可以每计算一层的权重改变量,就对该层的权重做出改变。这样会影响后面的计算。
写到这儿,需要的理论知识已经有了。接下来就是如何搭建静态图:
上一文有提到,我搭建的计算图中只有两个基本变量:Variable和placeholder。
placeholder作为需要传入数据的变量,Variable则作为需要训练的变量(这里用不到不需要训练的变量,不然应该再给一个Constant更完整,我这里其实简化了很多操作^_^)
与之前相同的是,Variable包括value和grad两个变量。
不同的是,Variable包含了last,next,root和target四个指针(可以这么叫吧)。last指向前一个节点,next指向后一个节点,root指向输入的placeholder(就是起始节点),target指向目标的placeholder(要计算loss,就必须要计算输出和目标之间的差距),一条路径上的每个节点的root和target都相同(这是为了方便对每个节点求值的时候都能从placeholder开始)。
placeholder在图中只起到占位的作用,它用来确定计算内存与复杂度,需要在后续求值的时候传入数据。每个节点都有root指针,可以确保在求该节点值的时候,从它的root开始,先传入数据,然后利用next指针指向下一个节点,计算下一个节点的值和导数。
值得一提的是,这里我没有使用一个全局变量来储存整个图结构,而是用上述的四个指针将整个计算图建立起来。类似于{root,next1(last1),next2(last2)。。。target}。
如果不使用root指针,还有两个方法:
1.储存整个网络的图结构,这也是一般计算图的方案,这样就可以从图的起始节点开始算起。
2.要求一个节点的值,就要先知道上一个节点的值,然后带入上一个节点的激活函数才能得到这个节点的值。同理要知道前一个节点的值,就要求上上个节点的值,以此类推。而当上上一个节点是placeholder时,上上个节点值就是传入的数据,然后再计算上个节点的值。这不正是递归算法嘛,先往回走,走到初值时,再往前走。那用一个root指针岂不是取代了往回走的过程,我直接告诉它该从哪里开始往前走即可。
下面放入代码:
先说明一下整个代码的结构:
mytensor文件夹中包含五个py文件,__init__用于从tensor文件夹外部调用内部的文件。mytensor(这里不是指文件夹,而是mytensor文件夹内的py文件)用来定义基类Varibale,placeholder,一些基本函数,还有不同的Loss。nn用于定义不同的神经网络。train用于定义不同的训练方式。cnn_func包含卷积中的一些操作(这里不会提到)。
代码文件mytensor.py
# -*- coding: utf-8 -*-
import numpy as np
#Variable基类
class Variable:
def __init__(self, value=None):
self.value = value
self.grad = None
self.next = None
self.last = None
self.root = None
self.target = None
self.eval_func = Line
if isinstance(value, np.ndarray):
self.size = value.shape
else:
self.size = None
def __add__(self, other):
res = Variable()
res.root = self.root
self.next, other.next = res, res
res.last = self
res.func = lambda x: x + other.value
res.func_grad = lambda x: 1
return res
def func(self, X):
return 1
def func_grad(self, X):
return 1
def run(self, feed_dict, need_grad=False):
#喂入数据
root = self.root#找到root
root.value = feed_dict[root]#给root喂入数据
target = self.target#找到target
if target is not None:
target.value = feed_dict[target]#给target喂入数据
#求值过程
while root.next is not self.next:
root.next.value = root.next.func(root.value)
if need_grad:#如果需要求导
root.next.grad = root.grad * root.next.func_grad(root.value)
root = root.next
return root.value
__add__函数用来定义两个Variable的相加算法。这里Variable多出了eval_func变量,虽然这里用不到,但后续有类继承它时才会用到。
run方法便是在求该节点值得时候会用到,与之前所说相同,先找到节点的root,然后从root开始一步一步往前传,如果需要自动求导的话,need_grad传入True即可。
定义完基类,接下来就是继承基类的子类了,首先是placeholder:
###############占位#####################
class placeholder(Variable):
def __init__(self, size):
super().__init__(self)
self.size = size
self.root = self
self.grad = 1
然后定义一些全连接层中可能用到的激活函数:
class relu(Variable):
def __init__(self, X):
super().__init__()
X.next = self
self.last = X
self.root = X.root
def func(self, X):
return np.maximum(0, X)
def func_grad(self, X):
res = np.zeros(X.shape)
res[X >= 0] = 1
return res
class Line(Variable):
def __init__(self, X):
super().__init__(self)
self.last = X
self.root = X.root
def func(self, X):
return X
def func_grad(self, X):
return np.ones(X.shape)
class softmax(Variable):
def __init__(self, X):
super().__init__(self)
X.next = self
self.last = X
self.root = X.root
def func(self, X):
return np.exp(X) / np.sum(np.exp(X), axis = 1).reshape(X.shape[0], 1)
def func_grad(self, X):
return np.ones(X.shape)
class sigmoid(Variable):
def __init__(self, X):
super().__init__(self)
X.next = self
self.last = X
self.root = X.root
def func(self, X):
return 1 / (1 + np.exp(-X))
def func_grad(self, X):
return self.func(X) * (1 - self.func(X))
class square(Variable):
def __init__(self, X):
super().__init__(self)
X.next = self
self.last = X
self.root = X.root
def func(self, X):
return np.square(X)
def func_grad(self, X):
return 2 * X
写法与上一文自动求导中定义初等函数的方法完全一致(多了一个next指针)。
然后定义损失函数:
class MeanSquareLoss(Variable):
def __init__(self, yhat, y):
super().__init__(self)
self.target = y
self.last = yhat
self.root = yhat.root
yhat.next = self
def func(self, yhat):
return np.mean(np.square(yhat - self.target.value)) / 2
def func_grad(self, yhat, grad):
return (self.target.value - yhat) * grad
class SoftmaxCrossEntropy(Variable):
def __init__(self, yhat, y):
super().__init__(self)
self.target = y
self.last = yhat
self.root = yhat.root
yhat.next = self
def func(self, yhat):
return np.mean(-np.log(yhat) * self.target.value)
def func_grad(self, yhat, grad):
return (self.target.value - yhat)
这里不定义损失函数也是可以的。但是还需要用定义初等函数的方法写一下mean函数(square函数我倒是写了)。然后在训练的时候,可以把loss = MeanSquareLoss(yhat,y)写为loss = mean(square(yhat - y))
然后是Session。其实我这里写Session只是为了形式上好看点,失去了tensorflow中Session的意义。。
###############Session####################
class Session:
def run(self, operator, feed_dict, need_grad = False):
return operator.run(feed_dict, need_grad)
上述代码全部包含在mytensor.py中。
在nn.py文件中,就开始构建真正的全连接层了:
# -*- coding: utf-8 -*-
#导入一些要用到的函数
from mytensor.mytensor import Variable
import mytensor.mytensor as mt
import numpy as np
#全连接层
class FullConnection(Variable):
def __init__(self, X, W, b=None, eval_func=None, need_trans=False):
super().__init__(self)
X.next = self
self.last = X
self.root = X.root
self.W = W
if b == None:
self.b = Variable(np.zeros(W.size[1]))
else:
self.b = b
self.eval_func = eval_func
self.need_trans = need_trans
def func(self, X):
if self.need_trans:
N = X.shape[0]
D = np.prod(X.shape[1:])
X = np.reshape(X, (N, D))
h = X.dot(self.W.value) + self.b.value
if self.eval_func is None:
self.eval_func = mt.Line
h = self.eval_func(self.W).func(h)
self.value = h
return h
def func_grad(self, dout, first=False):
if first:
grad = self.eval_func(self.W).func_grad(self.value)
return grad
else:
dw = self.last.value.T.dot(dout)
db = np.sum(dout, axis = 0)
grad = self.last.eval_func(self.W).func_grad(self.last.value)
dout = np.reshape(dout.dot(self.W.value.T), self.last.value.shape) * grad
return dout, dw, db
可以看到全连接层的构造和之前的基本函数大的框架是一致的。构造函数中要有父类的构造函数,然后重写func和func_grad。
不同之处在于,全连接层的变量更多,有权重W,偏置b,激活函数eval_func。need_trans是针对卷积操作时判断是否需要把输入的矩阵转化为一个长链的布尔值。
func函数用来计算节点的值,就是基本的f(Xw+b)。
而func_grad输入上一层的梯度值,计算本层的梯度值以及权重和偏置的改变量,并返回。
不管是全连接层还是CNN,RNN。网络构造都是这个模式。构造函数中储存所有的变量。func中是前传操作,func_grad中利用上一层的梯度,计算本层的权重,偏置该变量和本层的梯度。
这样便可以将不同的网络结构统一起来,让后续工作简单化。
现在可以说是万事俱备只欠东风了。该定义的都定义了,该搭建的也都搭建好了,我们已经可以搭建出任意层数的神经网络,并且定义它的loss值。可以认为:前传过程已经没有任何问题了。但是现在需要个一个东西让他们跑起来——训练。
train.py中只定义了GradientDecent训练方式:
# -*- coding: utf-8 -*-
#训练方式
from mytensor.mytensor import Variable
#梯度下降法
class GradientDescentOptimizer(Variable):
def __init__(self, alpha, loss):
super().__init__()
self.alpha = alpha
self.loss = loss
def run(self, feed_dict, end=False):
now = self.loss
now.run(feed_dict)
dnow = now.last
now.grad = [now.func_grad(dnow.value, dnow.func_grad(None, True))]
while dnow is not now.root:
dnow.grad = dnow.func_grad(now.grad[0])
now, dnow = dnow, dnow.last
root = self.loss.root
while root.next is not self.loss:
try:
root.next.W.value += self.alpha * root.next.grad[1]
root.next.b.value += self.alpha * root.next.grad[2]
except:
pass
root = root.next
它和别的类不同,没有func和func_grad。但它却重写了run方法。毕竟它是优化器,与之前的节点都不同(其实这里不需要继承父类)。
这里首先要计算完每一层的梯度,每一层权重和偏置的改变量,然后才可以对权重和偏置做出改变,否则会影响每一层梯度的计算。本质上这些计算得是并行的,但是由于本层的梯度必须要利用前一层的梯度,拥有串行的性质。这就导致了我们必须得写两个循环。
使用try-except是因为,卷积中池化层没有权重和偏置,为了程序的统一才用这个方法。
这样所有的方法都已经写好,如果从mytensor文件夹外调用的就需要__init__.py了。
__init__.py中定义了接口的调用方式
# -*- coding: utf-8 -*-
from .nn import FullConnection
from .nn import Conv2D
from .nn import MaxPool
from .train import GradientDescentOptimizer
from .mytensor import Variable, placeholder
from .mytensor import matmul
from .mytensor import exp, sin, cos, log
from .mytensor import square, relu, softmax, sigmoid, Line
from .mytensor import MeanSquareLoss, SoftmaxCrossEntropy
from .mytensor import Session
用mnist数据集测试:
# -*- coding: utf-8 -*-
'''mnist测试'''
import numpy as np
import mytensor as mt
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import LabelBinarizer
from sklearn import metrics
data = np.load("mnist.npz")
def DataStandard(X):
return StandardScaler().fit_transform(X)
def DataTrans(y):
return LabelBinarizer().fit_transform(y)
X_train, y_train, X_val, y_val, X_test, y_test = data['X'], data['y'], data['X_val'], data['y_val'], data['X_test'], data['y_test']
X_train, X_val, X_test = DataStandard(X_train), DataStandard(X_val), DataStandard(X_test)
y_train, y_val, y_test = DataTrans(y_train), DataTrans(y_val), DataTrans(y_test)
BATCH_SIZE = 128
#权重和偏置
W1 = mt.Variable(np.random.uniform(-0.01, 0.01, (784, 128)))
b1 = mt.Variable(np.random.uniform(-0.01, 0.01, (128)))
W2 = mt.Variable(np.random.uniform(-0.01, 0.01, (128, 10)))
b2 = mt.Variable(np.random.uniform(-0.01, 0.01, (10)))
#占位符
xs = mt.placeholder((None, 784))
ys = mt.placeholder((None, 10))
#两层神经网络
h1 = mt.nn.FullConnection(xs, W1, b1, mt.relu)
h2 = mt.nn.FullConnection(h1, W2, b2, mt.softmax)
#定义loss和train
loss = mt.SoftmaxCrossEntropy(h2, ys)
train = mt.train.GradientDescentOptimizer(1e-4, loss)
sess = mt.Session()
start = 0
for i in range(10000):
end = start + BATCH_SIZE
if end >= X_train.shape[0]:
end = X_train.shape[0] - 1
X_batch = X_train[start: end]
y_batch = y_train[start: end]
start = end
if start == X_train.shape[0] - 1:
start = 0
sess.run(train, {xs: X_batch, ys: y_batch})
if (i % 100 == 0):
los = sess.run(loss, {xs: X_val, ys: y_val})
output = sess.run(h2, {xs: X_val})
y_pred = np.argmax(output, axis = 1)
acc = metrics.accuracy_score(y_pred, np.argmax(y_val, axis = 1))
print("times : {}, loss : {}, accuracy : {}".format(i, los, acc))
los = sess.run(loss, {xs: X_test, ys: y_test})
output = sess.run(h2, {xs: X_test})
y_pred = np.argmax(output, axis = 1)
acc = metrics.accuracy_score(y_pred, np.argmax(y_test, axis = 1))
print("test data: loss : {}, accuracy : {}".format(los, acc))
。。。
最后得到93%正确率。其实这在全连接层还算是可以接受的结果。如果利用卷积池化来处理的话,准确率必然会大大提高。
后记:数模又双叒叕参与奖了,怀着悲愤的心情才抽出时间写下此文。