最近看到一篇极视角转的文章算法推导核心!一次性梳理清楚,是时候搞定矩阵求导了!,想到前不久面试爱奇艺的时候一面的小哥一直让我手推全连接的公式推导,以及用Python+Numpy将过程实现,感觉自己对深度学习核心之一的矩阵求导并不是很熟悉(对链式法则更熟悉的是单元素标量的求导),为此写下这篇作为后续工作的笔记之用,也供需要的小伙伴查询。
前向传播
代码大部分参考Python——numpy实现简单BP神经网络识别手写数字,将batch从1设置为64,以符合一般意义的做法。
该网络只有两层,维度为
的输入层
和维度为
的输出层
,中间为全连接,网络定义为:
nn = NeuralNetwork([784, 10]) # 神经网络各层神经元个数
维度为
的权重矩阵
和维度为
的偏差矩阵
对应的代码:
for i in range(1, len(layers)):# 正态分布初始化
self.weights.append(np.random.randn(layers[i-1], layers[i]))
self.bias.append(np.random.randn(layers[i]))
正向传播的公式为
,其中激活函数为sigmoid函数即
,对应的代码为:
def sigmoid(x): # 激活函数采用Sigmoid
return 1 / (1 + np.exp(-x))
损失函数采用平方误差
,所以输出
的梯度为
,对应的代码为:
# 平方误差得到的梯度值,非loss
error = (a[-1] - label)
这里利用的一个知识就是算法推导核心!一次性梳理清楚,是时候搞定矩阵求导了!提及的,标量对矩阵
的求导得到的是大小为
的矩阵,其中
。
梯度的反向传播
然后就开始梯度的反向传播,这里采用链式法则,
,因为
,而
,所以
,对应的代码为:
def sigmoid_derivative(x):# Sigmoid的导数
return sigmoid(x) * (1 - sigmoid(x))
deltas = [error * self.activation_deriv(a[-1])]# 保存各层误差值的列表
需要注意的一点是这里是*(即矩阵内的元素乘),而不是np.dot(即矩阵乘),这是因为使用的是激活函数,激活函数本身也是对单个元素进行操作。
而deltas里面的元素表示的激活函数的输入即
的梯度,那么权重矩阵
和维度为
的偏差矩阵
的梯度可以表示为:
(对应的定理是
以及链式法则
)、
,对应的代码为(需要除以batch):
layer = np.atleast_2d(a[i])
delta = np.atleast_2d(deltas[i])
# print ("delta.shape = %s" % str(delta.shape))# (64, 10)
# reduce in dimension 0
self.weights[i] -= learning_rate * layer.T.dot(delta) / batch
self.bias[i] -= learning_rate * np.sum(delta, axis=0) / batch
如果是多层网络,可以利用链式法则从后往前不断得到各层的梯度:
layer_num = len(a) - 2# 倒数第二层开始
for j in range(layer_num, 0, -1):
deltas.append(deltas[-1].dot(self.weights[j].T) * self.activation_deriv(a[j]))# 误差的反向传播
训练后可以看到loss和训练准确率的变化:
其中蓝色曲线表示的是loss,可以看到很快就变为0了,橙色曲线趋近的是batch数量(在本例中为64),测试的结果为:
训练集大小33597,测试集大小8403
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5249/5249 [00:01<00:00, 2855.96it/s]
训练完成!
开始检测模型:
模型识别正确率: 0.7891229322860883
这个结果对两层网络是很棒的。
对于激活函数为ReLU的情况,可以参考论智:只用NumPy实现神经网络:
def relu(Z):
return np.maximum(0,Z)
def relu_backward(dA, Z):
dZ = np.array(dA, copy = True)
dZ[Z <= 0] = 0;
return dZ;
因为大于0的情况导数为1,小于0的情况导数为0,因此反向推导时只需要将输入小于0的部分设置为0即可。
后记
其实本篇还有一些疑问没有解答,有时间看书后再解决吧:
1.矩阵的求导
算法推导核心!一次性梳理清楚,是时候搞定矩阵求导了!里面提到,矩阵对矩阵求导并不具有向量对向量求导的链式法则,但是这里输入
、输出
因为具有batch均为矩阵,这是一点疑问。
我能想象中的一点是batch只是为了并行化处理,batch之间的元素是相互不干扰的,比如看下面这段代码:
deltas.append(deltas[-1].dot(self.weights[j].T) * self.activation_deriv(a[j]))# 误差的反向传播
deltas[-1]的维度是
,self.weights[j].T的维度是
,其中64即batch所处的维度是不参与计算的。
2.激活函数的导数
def sigmoid_derivative(x):# Sigmoid的导数
return sigmoid(x) * (1 - sigmoid(x))
但是用公式推导后发现激活函数应该是这样的:
def sigmoid_derivative(x):# Sigmoid的导数
return x * (1 - x)
因为传入的参数已经经过sigmoid激活函数了,但是测试结果表明最初的代码是正确的(测试准确率为0.110,对比最初的代码的准确率为0.789),这就百思不得姐了。
【已完结】