手搓神经网络——矩阵求导

898 阅读11分钟

相关文章:

10 秒文章速览

本文主要论述矩阵计算中,如何对其求导

x@w+bw求导:d x@w+bdw=xT@α ,α形状为(b,m)\frac{\mathrm{d}\ x@w+b}{\mathrm{d} w} = x^{T} @ \alpha\ ,\alpha形状为(b, m)

x@w+bx求导:d x@w+bdx=α@wT ,α形状为(b,m)\frac{\mathrm{d}\ x@w+b}{\mathrm{d} x} = \alpha @ w^{T}\ ,\alpha形状为(b, m)

前言

本文是对于《手搓神经网络——BP反向传播》一文中高级的 BP 反向传播部分的详细论述

import numpy as np
import tensorflow as tf

输入,权重与偏置

输入,权重与偏置的属性

先来聊聊输入(x)、权重(w)、偏置(b)。无需再废话的是,这三者的类型都是数组numpy.ndarray

输入(x)

输入(x)的形状为(b, n)b表示 batch_size(批大小),n表示输入训练数据长度。下图表示 batch_size 为b的每个训练数据形状为(1, n)的训练集

手搓神经网络——输入,权重与偏置img-1.png

输入矩阵中,x 上标表示此训练数据在一个批大小中的位置,x 下标表示第b个训练数据的第n个参数

权重(w)

权重(w)的形状为(n, m)n表示输入数据数量,m表示该层输出数据数量(即该层的神经元数量)

手搓神经网络——输入,权重与偏置img-2.png

在全连接层中,每层的各个神经元相互交织连接。权重矩阵中,w 上标表示的是该层的第 m 个神经元,w 下标表示的是该层第 m 个神经元与第 n 个输入的联系

手搓神经网络——输入,权重与偏置img-3.png

偏置(b)

偏置(b)的形状为(1, m)m表示该层输出数据数量(即该层的神经元数量),图中的 b1、b2、b3...分别表示神经元1、神经元2、神经元3...的偏置

手搓神经网络——输入,权重与偏置img-4.png

输入,权重与偏置的计算

关于计算不多赘述,就是 x@w+bx@w+b ,在本文中@表示矩阵叉乘,用numpy表示即为x@w+b或者np.dot(x, w)+b,在将其计算结果带入激活函数,得到的即为神经网络层的输出,笔者认为前向运算的本质就是矩阵计算,反向传播的本质就是矩阵求导

矩阵求导

输出层计算部分之前,单独论述下矩阵求导。主要论述x@w+b 对 w 求导激活函数对 x@w+b 的求导

x@w+b 对 w 求导

下面有一个x@w+b表达式,嚯!看着挺复杂的,实则是笔者这里写的详细了些,与其说复杂倒不如是繁琐

手搓神经网络——输入,权重与偏置img-5.jpg

接下来,加一点魔法🪄,让x@w+b叉乘✖一个形状为(m, 1)的全一矩阵(矩阵里全是一①Ⅰ壹),m与权重(w)中m的含义相同。将这个矩阵记作 α1\alpha_{1}。哈哈,好像高中写数学题,要化简时经常要用到神奇的1

手搓神经网络——输入,权重与偏置img-6.jpg

这样子,得出的结果看着就简洁多了。然后如法炮制,只不过是让一个形状为(1, b)的全一矩阵叉乘✖ (x@w+b)@α1(x@w+b)@\alpha_{1},将这个矩阵记作 α2\alpha_{2}。哈哈哈,汗流浃背了吧,兄弟😅,你八成是看傻了,我两成是写傻了

手搓神经网络——输入,权重与偏置img-7.jpg

经过一坨又一坨的变换,最终以一个标量的表达式来表示最初的x@w+bα1\alpha_{1}α2\alpha_{2} 是为了化简成上方的形式而存在的,之所以要化简成上方形式是为了使表达式更简洁,也是为了便于对w求导)

计算 k=1bj=1mi=1nxikwij+bj\sum_{k=1}^{b}{\sum_{j=1}^{m}{\sum_{i=1}^{n}{x_{i}^{k} \cdot w_{i}^{j}} + b_{j}}}wijw_{i}^{j} 的导数

dydwij=ddwijk=1bj=1mi=1nxikwij+bj=k=1bxik\frac{\mathrm{dy}}{\mathrm{d}w_{i}^{j}} = \frac{\mathrm{d}}{\mathrm{d}w_{i}^{j}} \sum_{k=1}^{b}{\sum_{j=1}^{m}{\sum_{i=1}^{n}{x_{i}^{k} \cdot w_{i}^{j}} + b_{j}}} = \sum_{k=1}^{b}{x_{i}^{k}}

自此长篇大论得出的结论,x@w+bw的导数表示成矩阵形式为

手搓神经网络——输入,权重与偏置img-8.jpg

可以看得出x@w+b 对 w 的导数为 x 矩阵转置的行之和,且重复扩展成原形状(不是哥,你要问我怎么看出来是转置的?熟读并抄写10遍上文,还不晓得?熟读全文并背诵)

上面的话似乎有些难以理解,来点魔法。将形状为(b, m)全一矩阵记作 α3\alpha_{3},用数学的语言表达如下

手搓神经网络——输入,权重与偏置img-9.jpg

α3\alpha_{3} 的应用实现了两个功能,即上文所述的求行之和重复扩展

  • 求行之和:在矩阵叉乘中,可视为 xTx^{T} 的行与 α3\alpha_{3} 的列的点乘求和
    • 具体来说,xT@α3x^{T}@\alpha_{3} 的结果是一个列向量,其第 i 个元素是 xTx^{T} 的每一行的元素与 α3\alpha_{3} 的第 i 列的元素相乘并求和的结果。由于α3\alpha_{3} 的每一列全是1,这意味着每一行的所有元素都会被加起来。因此,xT@α3x^{T}@α3 的结果实际上是 xTx^{T} 的每一行的元素之和
  • 重复扩展:在矩阵叉乘中,可视为 xTx^{T} 的行与 α3\alpha_{3} 的列运算
    • 由于 xTx^{T} 的形状是(n, b)α3\alpha_{3} 的形状是(b, m),这意味着在两个矩阵运算后,得到的的结果为一个形状为(n, m)的矩阵,这与 ww 的形状一致

最后一句话收尾:

d x@w+bdw=xT@α ,α形状为(b,m)\frac{\mathrm{d}\ x@w+b}{\mathrm{d} w} = x^{T} @ \alpha\ ,\alpha形状为(b, m)

使用 Python 实现
tf.random.set_seed(123)
# 批大小
batch_size = 64
# 输入神经元数
inputs_num = 2
# 输出神经元数
outputs_num = 4

x = tf.random.uniform((batch_size, inputs_num))
w = tf.random.uniform((inputs_num, outputs_num))
b = tf.random.uniform((1, outputs_num))
# 这就是那个 α3
α = tf.ones((batch_size, outputs_num))

with tf.GradientTape() as tape:
   tape.watch(w)
   y = x @ w + b

print('tensorflow x@w+b 对 w 求导')
diff = tape.gradient(y, w).numpy()
print(diff)
print('numpy x@w+b 对 w 求导')
x = x.numpy()
α = α.numpy()
print(x.T @ α)
==============================
输出:
tensorflow x@w+b 对 w 求导
[[31.709307 31.709307 31.709307 31.709307]
 [34.111023 34.111023 34.111023 34.111023]]
numpy x@w+b 对 w 求导
[[31.709307 31.709307 31.709307 31.709307]
 [34.111023 34.111023 34.111023 34.111023]]

也可以用以下方式对w求导

# x@w+b 对 w 的导数为 x 矩阵转置的行之和
print(x.T.sum(axis=-1, keepdims=True))
# 重复扩展成原形状
print(x.T.sum(axis=-1, keepdims=True).repeat(repeats=4, axis=1))
==============================
输出:
[[31.709307]
 [34.111027]]
[[31.709307 31.709307 31.709307 31.709307]
 [34.111027 34.111027 34.111027 34.111027]]

激活函数对 w 的求导

根据链式法则,将激活函数(以sigmoid为例)对 w 进行求导。但在这里可以用 sigmoid sigmoid\ ' 代替 α3\alpha_{3},因为 sigmoidsigmoid 的形状与 α3\alpha_{3} 一致,所以所起的功能也一样

x x@w+bsigmoid(x@w+b)xT@sigmoid \begin{align*} x →\ &x@w+b &&→ &&sigmoid(x@w+b) \\ &x^{T} &&@ &&sigmoid\ ' \end{align*}

可得结论:d activation(x@w+b)dw=xT@activation \frac{\mathrm{d}\ activation(x@w+b)}{\mathrm{d} w} = x^{T} @ activation\ '

使用 Python 实现
tf.random.set_seed(123)
batch_size = 3
inputs_num = 2
outputs_num = 4
x = tf.random.uniform((batch_size, inputs_num))
w = tf.random.uniform((inputs_num, outputs_num))
b = tf.random.uniform((1, outputs_num))

with tf.GradientTape() as tape:
    tape.watch(w)
    y = tf.nn.sigmoid(x @ w + b)

print('tensorflow')
diff = tape.gradient(y, w).numpy()
print(diff)
print('numpy')
x = x.numpy()
y = y.numpy()
print(x.T @ (y*(1-y)))
==============================
输出:
tensorflow sigmoid(x@w+b) 对 w 求导
[[0.17910826 0.2533782 0.19129974 0.17476654]
 [0.32348415 0.4245056 0.34193107 0.3116489 ]]
numpy sigmoid(x@w+b) 对 w 求导
[[0.17910826 0.2533782 0.19129974 0.17476654]
 [0.32348415 0.4245056 0.34193107 0.3116489 ]]

x@w+b 对 x 求导

losswb求导是为了更新权重(w)、偏置(b),可why这里还要来对x求导。都说BP反向传播,哎,是吧?对x求导就是为了实现“传播”,具体的到误差传播部分在和你逼逼赖赖

回到正题,这个怎么弄,具体的就不多言了。与上面的方法大差不差,不过是照本宣科罢了。写的一坨一坨的都,是吧?反正也没人看。那就直接下结论了

d x@w+bdx=α@wT ,α形状为(b,m)\frac{\mathrm{d}\ x@w+b}{\mathrm{d} x} = \alpha @ w^{T}\ ,\alpha形状为(b, m)

使用 Python 实现
tf.random.set_seed(123)
batch_size = 3
inputs_num = 2
outputs_num = 4
x = tf.random.uniform((batch_size, inputs_num))
w = tf.random.uniform((inputs_num, outputs_num))
b = tf.random.uniform((1, outputs_num))
α = tf.ones((batch_size, outputs_num))

with tf.GradientTape() as tape:
    tape.watch(x)
    y = x @ w + b

print('tensorflow x@w+b 对 x 求导')
diff = tape.gradient(y, x).numpy()
print(diff)
print('numpy x@w+b 对 x 求导')
w = w.numpy()
α = α.numpy()
print(α @ w.T)
# print(w.T.sum(axis=0, keepdims=True).repeat(repeats=3, axis=0))
==============================
输出:
tensorflow x@w+b 对 x 求导
[[2.4701815 2.0973797]
 [2.4701815 2.0973797]
 [2.4701815 2.0973797]]
numpy x@w+b 对 x 求导
[[2.4701815 2.0973797]
 [2.4701815 2.0973797]
 [2.4701815 2.0973797]]

激活函数 对 x 求导

这儿也是,直接结论了

d activation(x@w+b)dx=activation @wT\frac{\mathrm{d}\ activation(x@w+b)}{\mathrm{d} x} = activation\ ' @ w^{T}

使用 Python 实现
tf.random.set_seed(123)
batch_size = 3
inputs_num = 2
outputs_num = 4

x = tf.random.uniform((batch_size, inputs_num))
w = tf.random.uniform((inputs_num, outputs_num))
b = tf.random.uniform((1, outputs_num))

with tf.GradientTape() as tape:
    tape.watch(x)
    y = tf.nn.sigmoid(x @ w + b)

print('tensorflow sigmoid(x@w+b) 对 x 求导')
diff = tape.gradient(y, x).numpy()
print(diff)
print('numpy sigmoid(x@w+b) 对 x 求导')
w = w.numpy()
y = y.numpy()
print((y*(1-y)) @ w.T)
==============================
输出:
tensorflow sigmoid(x@w+b) 对 x 求导
[[0.492706 0.41777146 ]
 [0.4669016 0.3959195 ]
 [0.35548916 0.3006734]]
numpy sigmoid(x@w+b) 对 x 求导
[[0.492706 0.4177715  ]
 [0.46690163 0.3959195]
 [0.35548916 0.3006734]]

再回首——高级的 BP 反向传播

既然是对《手搓神经网络——BP反向传播》一文中高级的 BP 反向传播部分的详细论述。那就再回到这一部分,来瞅瞅这里边的计算过程

这里构建的是一个极为简单的神经网络

  • 输入层:1个神经元
  • 隐藏层:3个神经元
  • 输出层:1个神经元

模型图如下 手搓神经网络——输入,权重与偏置img-10.jpg

前向计算

👉关于前向计算,这里忽略了激活函数(因为作图时忘了激活函数,不想重作)。但实际上,激活函数还是存在的,在后面的反向求导部分中,仍然会考虑激活函数的计算👈

隐藏层计算

在这里输入(x)的 batch_size 为1,以下分别是隐藏层内的计算过程的可视化图与算式

这里的隐藏层输出的形状实际应为(1, 3),考虑美观这里故画作(3, 1)

手搓神经网络——输入,权重与偏置img-11.png 手搓神经网络——输入,权重与偏置img-12.jpg

输出层计算

以下分别是输出层内的计算过程的可视化图与算式

手搓神经网络——输入,权重与偏置img-13.png 手搓神经网络——输入,权重与偏置img-14.jpg

反向求导

关于反向求导,这里说白了就是计算损失loss对其权重(w)、偏置(b)的导数。但在前文矩阵求导中已经细细讲解过,现在是实际应用的时候了

为了简化后面的式子,这里先提前作规定。hhsigmoid1sigmoid_{1} 的含义相同,只是别名罢了,这样是为了使后期式子的表达更简洁

h=sigmoid1sigmoid1=sigmoid(x@w1+b1)sigmoid2=sigmoid(h@w2+b2)mse=mse(true,sigmoid2)h =sigmoid1 sigmoid1 =sigmoid1(1sigmoid1)sigmoid2 =sigmoid2(1sigmoid2)mse =2n(sigmoid2true)\begin{align*} h &= sigmoid_{1} \\ sigmoid_{1} &= sigmoid(x@w_{1}+b_{1}) \\ sigmoid_{2} &= sigmoid(h@w_{2}+b_{2}) \\ mse &= mse(true, sigmoid_{2}) \\ h\ ' &= sigmoid_{1}\ ' \\ sigmoid_{1}\ ' &= sigmoid_{1}\cdot(1-sigmoid_{1}) \\ sigmoid_{2}\ ' &= sigmoid_{2}\cdot(1-sigmoid_{2}) \\ mse\ ' &= \frac{2}{n}\cdot(sigmoid_{2}-true) \\ \end{align*}

输出层求导

d lossdw2\frac{\mathrm{d}\ loss}{\mathrm{d} w_{2}}

至此,上面瞎逼乱讲的扯了这么多,再回到最初,来看看 msemse 怎么对 w2w_{2} 求导。先来瞧瞧输出层中的计算过程

上面是输出层中的计算过程,下面是各个计算过程的导数。其实就是运用链式法则啦

hh@W2+b2sigmoid2msehT@sigmoid2 mse \begin{align*} h → &h@W_{2}+b_{2} &→& &&sigmoid_{2} &→& &&mse \\ &h^{T} &@& &&sigmoid_{2}\ ' &\cdot& &&mse\ ' \end{align*}

将下面的各个求导结果相乘 hT@sigmoid2 mse h^{T} @ sigmoid_{2}\ ' \cdot mse\ ' 就是 msemsew2w_{2} 的导数

two 个为什么

  • 为什么 hTh^{T}sigmoid2 sigmoid_{2}\ ' 之间是叉乘 @@
    • 因为 d activation(x@w+b)dw=xT@activation \frac{\mathrm{d}\ activation(x@w+b)}{\mathrm{d} w} = x^{T} @ activation\ ' 所以 hT@sigmoid h^{T} @ sigmoid\ '
  • 为什么 sigmoid2 sigmoid_{2}\ 'mse mse\ ' 之间是点乘 \cdot
    • 因为在 sigmoid2 sigmoid_{2}\ 'mse mse\ ' 的计算过程中,不涉及矩阵的叉乘。通俗点说。就是不涉及@运算符,所以在用链式法则,可当作标量处理

损失 losslossw2w_{2} 求导的数学表达式如下

手搓神经网络——输入,权重与偏置img-15.jpg

d lossdb\frac{\mathrm{d}\ loss}{\mathrm{d} b}

哈哈哈,不是吧阿sir,这个还需要再讲吗?嗯哼???😛😛😛只要上面看得懂,这里确实没啥好说的了,就是求导的对象不同而已啦(此对象非彼对象😡💢💢)

根据导数的加法计算法则,有 d lossdb2=1\frac{\mathrm{d}\ loss}{\mathrm{d} b_{2}} = 1,所以 losslossb2b_{2} 的导数为1

更新参数

回到《手搓神经网络——BP反向传播》的结论

计算误差对参数(权重w and 偏置b)的导数,根据其导数的正负即可得出参数更新的方向(是加➕ or 是减🗡️)

参数更新的计算方式:参数自身减去lr乘❎误差对参数的求导结果

得到 w2w_{2} 的更新计算方式

w2new=lrhT @ sigmoid2 mse w_{2}^{new} -= lr \cdot h^{T}\ @\ sigmoid_{2}\ ' \cdot mse\ '

得到 b2b_{2} 的更新计算方式

b2new=lrsigmoid2 mse b_{2}^{new} -= lr \cdot sigmoid_{2}\ ' \cdot mse\ '

《手搓神经网络——BP反向传播》- 高级的 BP 反向传播中,故

  • w2更新方式:w2 -= lr * h.T @ sigmoid.diff(pred) * mse.diff(true, pred)
  • b2更新方式:b2 -= lr * sigmoid.diff(pred) * mse.diff(true, pred)

误差传播

既然是误差反向传播,这里仅仅是更新了输出层的参数。而误差还需要继续向前传播,传至隐藏层,以实现隐藏层的参数更新。上文通篇在讲如何对权重(w)求导,而这里所谓的“传播”其实质是对要求导数的参数的某个计算部分中存在关联的参数求导,利用这一计算结果,可以对神经网络前部分的参数继续求导,以实现参数的更新。诶,😞这么说着都好拗口,只恨肚里没墨水,文笔不行呐!😥

下图是完整的神经网络模型的计算过程,链式法则的精髓是y求导时,yx导数为x形成y的各个计算部分的导数的乘积(但在矩阵求导中,“乘积”二字变得不太适用,上文内容已经体现出了)

手搓神经网络——输入,权重与偏置img-16.jpg

为了对 w1w_{1} 求导,根据链式法则,需要先对 sigmoid1sigmoid_{1}h@w2+b2h@w_{2}+b_{2}sigmoid2sigmoid_{2}msemse 这些计算部分求导。其中,h@w2+b2h@w_{2}+b_{2} 很特殊啊,因为里头的 h (sigmoid1)h\ (即sigmoid_{1})w1w_{1} 存在关联,也就是说 hhw1w_{1} 进行一定运算后的一个计算部分。前面不是有提到“yx导数为x形成y的各个计算部分的导数”嘛,也正是因此,要对 w1w_{1} 求导就必须先对 hh 求导

再来理解理解“对要求导数的参数的某个计算部分中存在关联的参数求导”

  • 要求导数的参数就是 w1w_{1}
  • 某个计算部分就是 h@w2+b2h@w_{2}+b_{2}
  • 存在关联的参数就是 hh

这句话其实就是对应了“yx导数为x形成y的各个计算部分的导数”。好比在上图中,求各个计算部分的导数,就是为了便于求某一参数的导数

隐藏层求导

d lossdw1\frac{\mathrm{d}\ loss}{\mathrm{d} w_{1}}

w1w_{1} 的求导明显变得复杂了,甚至多了括号。why?因为这可是在矩阵求导,不是标量求导

反正公式在这里了 d activation(x@w+b)dx=activation @wT\frac{\mathrm{d}\ activation(x@w+b)}{\mathrm{d} x} = activation\ ' @ w^{T},所以下面的式子中, w2Tw_{2}^{T} 会出现在最后面,这都与矩阵叉乘的计算方式有关

手搓神经网络——输入,权重与偏置img-17.jpg

d lossdb1\frac{\mathrm{d}\ loss}{\mathrm{d} b_{1}}

这个更没必要多说了吧

手搓神经网络——输入,权重与偏置img-18.jpg

更新参数

w1w_{1} 的更新计算方式

手搓神经网络——输入,权重与偏置img-19.jpg

b1b_{1} 的更新计算方式

手搓神经网络——输入,权重与偏置img-20.jpg

《手搓神经网络——BP反向传播》- 高级的 BP 反向传播中,故

  • w1更新方式:w1 -= lr * x.T @ (sigmoid.diff(h) * ((sigmoid.diff(pred) * mse.diff(true, pred)) @ w2.T))
  • b1更新方式:b1 -= lr * (sigmoid.diff(h) * ((sigmoid.diff(pred) * mse.diff(true, pred)) @ w2.T))

In the End

写的又长又臭的,欸哟喂,我的天呐!!!