在这一节中,我们将会动手实现一个批量归一化层,来验证批量归一化的实际作用。

反向传播

在上一节中,我们知道了批量归一化的正向计算过程,这一节中,为了实现完整的批量归一化层,我们首先需要推导它的反向传播公式,然后用代码实现。本节中的公式序号接上一节,以便于说明。

首先假设已知从上一层回传给批量归一化层的误差矩阵是:

\delta = {dJ \over dZ},\delta_i = {dJ \over dz_i} \tag{10}

求批量归一化层参数梯度

则根据公式9,求\gamma \beta的梯度:

{dJ \over d\gamma} = \sum{i=1}^m {dJ \over dz_i}{dz_i \over d\gamma}=\sum{i=1}^m \delta_i \cdot n_i \tag{11}

{dJ \over d\beta} = \sum{i=1}^m {dJ \over dz_i}{dz_i \over d\beta}=\sum{i=1}^m \delta_i \tag{12}

注意\gamma\beta的形状与批大小无关,只与特征值数量有关,我们假设特征值数量为1,所以它们都是一个标量。在从计算图看,它们都与N,Z的全集相关,而不是某一个样本,因此会用求和方式计算。

求批量归一化层的前传误差矩阵

下述所有乘法都是element-wise的矩阵点乘,不再特殊说明。

从正向公式中看,对z有贡献的数据链是:

  • $$z_i \leftarrow n_i \leftarrow x_i$$
  • $$z_i \leftarrow n_i \leftarrow \mu_B \leftarrow x_i$$
  • $$z_i \leftarrow n_i \leftarrow \sigma^2_B \leftarrow x_i$$
  • $$z_i \leftarrow n_i \leftarrow \sigma^2_B \leftarrow \mu_B \leftarrow x_i$$

从公式8,9:

{dJ \over dx_i} = {dJ \over d n_i}{d n_i \over dx_i} + {dJ \over d \sigma^2_B}{d \sigma^2_B \over dx_i} + {dJ \over d \mu_B}{d \mu_B \over dx_i} \tag{13}

公式13的右侧第一部分(与全连接层形式一样):

{dJ \over d n_i}= {dJ \over dz_i}{dz_i \over dn_i} = \delta_i \cdot \gamma\tag{14}

上式等价于:

{dJ \over d N}= \delta \cdot \gamma\tag{14}

公式14中,我们假设样本数为64,特征值数为10,则得到一个64x10的结果矩阵(因为1x10的矩阵会被广播为64x10的矩阵):

$$
\delta^{(64 \times 10)} \odot \gamma^{(1 \times 10)}=R^{(64 \times 10)}
$$

公式13的右侧第二部分,从公式8:

{d n_i \over dx_i}={1 \over \sqrt{\sigma^2_B + \epsilon}} \tag{15}

公式13的右侧第三部分,从公式8(注意$\sigma^2_B$是个标量,而且与X,N的全集相关,要用求和方式):

\begin{aligned} {dJ \over d \sigma^2B} = \sum{i=1}^m {dJ \over d n_i}{d n_i \over d \sigma^2_B} \ &= -{1 \over 2}(\sigma^2_B + \epsilon)m {dJ \over d n_i} \cdot (x_i-\mu_B) \end{aligned} \tag{16}

公式13的右侧第四部分,从公式7:

{d \sigma^2_B \over dx_i} = {2(x_i - \mu_B) \over m} \tag{17}

公式13的右侧第五部分,从公式7,8:

{dJ \over d \mu_B}={dJ \over d n_i}{d n_i \over d \mu_B} + {dJ \over d\sigma^2_B}{d \sigma^2_B \over d \mu_B} \tag{18}

公式18的右侧第二部分,根据公式8:

{d n_i \over d \mu_B}={-1 \over \sqrt{\sigma^2_B + \epsilon}} \tag{19}

公式18的右侧第四部分,根据公式7(\sigma^2_B\mu_B与全体x_i相关,所以要用求和):

{d \sigma^2B \over d \mu_B}=-{2 \over m}\sum{i=1}^m (x_i- \mu_B) \tag{20}

所以公式18是:

{dJ \over d \mu_B}=-{\delta \cdot \gamma \over \sqrt{\sigma^2_B + \epsilon}} - {2 \over m}{dJ \over d \sigmam (x_i- \mu_B) \tag{18}

公式13的右侧第六部分,从公式6:

{d \mu_B \over dx_i} = {1 \over m} \tag{21}

所以,公式13最后是这样的:

{dJ \over dx_i} = {\delta \cdot \gamma \over \sqrt{\sigma^2_B + \epsilon}} + {dJ \over d\sigma^2_B} \cdot {2(x_i - \mu_B) \over m} + {dJ \over d\mu_B} \cdot {1 \over m} \tag{13}

代码实现

初始化类

  1. class BnLayer(CLayer):
  2. def __init__(self, input_size, momentum=0.9):
  3. self.gamma = np.ones((1, input_size))
  4. self.beta = np.zeros((1, input_size))
  5. self.eps = 1e-5
  6. self.input_size = input_size
  7. self.output_size = input_size
  8. self.momentum = momentum
  9. self.running_mean = np.zeros((1,input_size))
  10. self.running_var = np.zeros((1,input_size))

后面三个变量,momentum、running_mean、running_var,是为了计算/记录历史方差均差的。

前向计算

  1. def forward(self, input, train=True):
  2. ......

前向计算完全按照上一节中的公式6到公式9实现。要注意在训练/测试阶段的不同算法,用train是否为True来做分支判断。

反向传播

  1. def backward(self, delta_in, flag):
  2. ......

d_norm_x需要多次使用,所以先计算出来备用,以增加代码性能。

公式16中有一个(\sigma^2_B + \epsilon)^{-3/2}次方,在前向计算中,我们令:

  1. self.var = np.mean(self.x_mu**2, axis=0, keepdims=True) + self.eps
  2. self.std = np.sqrt(self.var)

则:

$$
self.var \times self.std = self.var \times self.var{(3/2)}
$$

放在分母中就是(-3/2)次方了。

另外代码中有很多np.sum(…, axis=0, keepdims=True),这个和全连接层中的多样本计算一个道理,都是按样本数求和,并保持维度,便于后面的矩阵运算。

更新参数

  1. def update(self, learning_rate=0.1):
  2. self.gamma = self.gamma - self.d_gamma * learning_rate
  3. self.beta = self.beta - self.d_beta * learning_rate

更新\gamma\beta时,我们使用0.1作为学习率。在初始化代码中,并没有给批量归一化层指定学习率,如果有需求的话,读者可以自行添加这部分逻辑。

批量归一化层的实际应用

首先回忆一下MNIST的图片分类网络,当时的模型如图15-15所示。

批量归一化的实现 - 图1

当时用了6个epoch(5763个Iteration),达到了0.12的预计loss值而停止训练。我们看看使用批量归一化后的样子,如图15-16所示。

批量归一化的实现 - 图2

在全连接层和激活函数之间,加入一个批量归一化层,最后的分类函数Softmax前面不能加批量归一化。

主程序代码

  1. if __name__ == '__main__':
  2. ......
  3. params = HyperParameters_4_1(
  4. learning_rate, max_epoch, batch_size,
  5. net_type=NetType.MultipleClassifier,
  6. init_method=InitialMethod.MSRA,
  7. stopper=Stopper(StopCondition.StopLoss, 0.12))
  8. net = NeuralNet_4_1(params, "MNIST")
  9. fc1 = FcLayer_1_1(num_input, num_hidden1, params)
  10. net.add_layer(fc1, "fc1")
  11. bn1 = BnLayer(num_hidden1)
  12. net.add_layer(bn1, "bn1")
  13. r1 = ActivationLayer(Relu())
  14. net.add_layer(r1, "r1")
  15. ......

前后都省略了一些代码,注意上面代码片段中的bn1,就是应用了批量归一化层。

运行结果

为了比较,我们使用与14.6中完全一致的参数设置来训练这个有批量归一化的模型,得到如图15-17所示的结果。

批量归一化的实现 - 图3

打印输出的最后几行如下:

  1. ......
  2. epoch=4, total_iteration=4267
  3. loss_train=0.079916, accuracy_train=0.968750
  4. loss_valid=0.117291, accuracy_valid=0.967667
  5. time used: 19.44783306121826
  6. save parameters
  7. testing...
  8. 0.9663

列表15-12比较一下使用批量归一化前后的区别。

表15-12 批量归一化的作用

不使用批量归一化 使用批量归一化
停止条件 loss < 0.12 loss < 0.12
训练次数 6个epoch(5763次迭代) 4个epoch(4267次迭代)
花费时间 17秒 19秒
准确率 96.97% 96.63%

使用批量归一化后,迭代速度提升,但是花费时间多了2秒,这是因为批量归一化的正向和反向计算过程还是比较复杂的,需要花费一些时间,但是批量归一化确实可以帮助网络快速收敛。如果使用GPU的话,花费时间上的差异应该可以忽略。

在准确率上的差异可以忽略,由于样本误差问题和随机初始化参数的差异,会造成最后的训练结果有细微差别。

代码位置

原代码位置:ch15, Level6

个人代码:Mnist_BN**

keras实现

  1. from ExtendedDataReader.MnistImageDataReader import *
  2. from keras.models import Sequential
  3. from keras.layers import Dense, BatchNormalization
  4. import matplotlib.pyplot as plt
  5. import os
  6. os.environ['KMP_DUPLICATE_LIB_OK']='True'
  7. def load_data():
  8. dataReader = MnistImageDataReader(mode="vector")
  9. dataReader.ReadData()
  10. dataReader.NormalizeX()
  11. dataReader.NormalizeY(NetType.MultipleClassifier)
  12. dataReader.GenerateValidationSet(k=20)
  13. x_train, y_train = dataReader.XTrain, dataReader.YTrain
  14. x_test, y_test = dataReader.XTest, dataReader.YTest
  15. x_val, y_val = dataReader.XDev, dataReader.YDev
  16. x_train = x_train.reshape(x_train.shape[0], 28 * 28)
  17. x_test = x_test.reshape(x_test.shape[0], 28 * 28)
  18. x_val = x_val.reshape(x_val.shape[0], 28 * 28)
  19. return x_train, y_train, x_test, y_test, x_val, y_val
  20. def build_model():
  21. model = Sequential()
  22. model.add(Dense(128, activation='relu', input_shape=(784, )))
  23. model.add(BatchNormalization())
  24. model.add(Dense(64, activation='relu'))
  25. model.add(BatchNormalization())
  26. model.add(Dense(32, activation='relu'))
  27. model.add(BatchNormalization())
  28. model.add(Dense(16, activation='relu'))
  29. model.add(BatchNormalization())
  30. model.add(Dense(10, activation='softmax'))
  31. model.compile(optimizer='Adam',
  32. loss='categorical_crossentropy',
  33. metrics=['accuracy'])
  34. return model
  35. #画出训练过程中训练和验证的精度与损失
  36. def draw_train_history(history):
  37. plt.figure(1)
  38. # summarize history for accuracy
  39. plt.subplot(211)
  40. plt.plot(history.history['accuracy'])
  41. plt.plot(history.history['val_accuracy'])
  42. plt.title('model accuracy')
  43. plt.ylabel('accuracy')
  44. plt.xlabel('epoch')
  45. plt.legend(['train', 'validation'])
  46. # summarize history for loss
  47. plt.subplot(212)
  48. plt.plot(history.history['loss'])
  49. plt.plot(history.history['val_loss'])
  50. plt.title('model loss')
  51. plt.ylabel('loss')
  52. plt.xlabel('epoch')
  53. plt.legend(['train', 'validation'])
  54. plt.show()
  55. if __name__ == '__main__':
  56. x_train, y_train, x_test, y_test, x_val, y_val = load_data()
  57. # print(x_train.shape)
  58. # print(x_test.shape)
  59. # print(x_val.shape)
  60. model = build_model()
  61. history = model.fit(x_train, y_train, epochs=20, batch_size=64, validation_data=(x_val, y_val))
  62. draw_train_history(history)
  63. loss, accuracy = model.evaluate(x_test, y_test)
  64. print("test loss: {}, test accuracy: {}".format(loss, accuracy))
  65. weights = model.get_weights()
  66. print("weights: ", weights)

模型输出

  1. test loss: 0.08176513702145312, test accuracy: 0.978600025177002

模型损失以及准确率曲线

批量归一化的实现 - 图4