EUAdvancer

CS231n-TwoLayerNet分类Cifar10


这次实现的是最简单的2层神经网络,相比于之前的svm和softmax识别率已经有很大提高了,其中比较重要的为反向传播和调参部分

神经网络结构

首先对比生物神经元和数学模型。在生物学中,每个神经元都从它的树突获得输入信号,然后沿着它唯一的轴突产生输出信号。在神经元的计算模型中,沿着轴突传播的信号将基于突触的突触强度,与其他神经元的树突进行乘法交互。其观点是,突触的强度(W权重),是可学习的且可以控制一个神经元对于另一个神经元的影响强度(还可以控制影响方向:使其兴奋(正权重)或使其抑制(负权重))。在基本模型中,树突将信号传递到细胞体,信号在细胞体中相加。如果最终之和高于某个阈值,那么神经元将会激活,向其轴突输出一个峰值信号。

我们可以把人工神经网络的过程总结如下:

  • 每个神经元都对它的输入(X)和权重(W)进行点积,然后加上偏差(b),最后使用非线性函数(或称为激活函数,sigmoid或Relu等)进行输出

然后我们把神经网络进行图形化,下图的层与层之间是全连接层

全连接层:全连接层中的神经元与其前后两层的神经元是完全成对连接的,但是在同一个全连接层内的神经元之间没有连接

输入的X为输入层,最终输出的为输出层(注意:输出层的神经元一般是不会有激活函数的(或者也可以认为它们有一个线性相等的激活函数)。这是因为最后的输出层大多用于表示分类评分值),中间的均为隐藏层。如果仅有以上部分,神经网络的输出层的神经元是不具备分类能力的,所以我们需要一个合适的损失函数,常见的是softmax的交叉熵损失或者svm的折叶损失,因此神经网络的输出层也可以看作是SVM层或Softmax层。

前向传播

前向传播就是矩阵和乘法以及激活函数的交织,根据上述人工网络总结我们可以用python写出(以一个两层的神经网络为例,注意输出层不需要激活

1
2
3
4
5
6
# Compute the forward pass
Relu = lambda x: np.maximum(0, x)
z1 = X.dot(W1.T) + b1 # N * H
a1 = Relu(z1)
z2 = a1.dot(W2.T) + b2 # N * C
scores = z2

反向传播

在理解反向传播之前我们需要先了解链式法则,这里不做多的介绍,它主要用于求一个复合函数的导数,就是把复合函数的导数全都相乘即可得到整个式子的导数。而反向传播正是运用了这个思想

上图表示前向传播从输入计算到输出(绿色),反向传播从尾部开始,根据链式法则递归地向前计算梯度(显示为红色),一直到网络的输入端。计算主要思路为当前梯度表达式乘以上层传回的梯度代码表示为

1
2
3
4
5
6
7
8
9
10
11
12
# 设置输入值
x = -2; y = 5; z = -4
# 进行前向传播
q = x + y # q becomes 3
f = q * z # f becomes -12
# 进行反向传播:
# 首先回传到 f = q * z
dfdz = q # df/dz = q, 所以关于z的梯度是3
dfdq = z # df/dq = z, 所以关于q的梯度是-4
# 现在回传到q = x + y
dfdx = 1.0 * dfdq # dq/dx = 1. 这里的乘法是因为链式法则
dfdy = 1.0 * dfdq # dq/dy = 1

以同样的思路我们可以用python实现一个两层的神经网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Backward pass: compute gradients
grads = {}
# Compute the gradient of z2 (scores)
dz2 = -(ground_true - pro_scores) / num_examples # N * C
# Backprop into W2, b2 and a1
dW2 = dz2.T.dot(a1) # C * H
db2 = np.sum(dz2, axis=0) # 1 * C
da1 = dz2.dot(W2) # N * H
# Backprop into z1
dz1 = da1
dz1[a1 <= 0] = 0 # N * H
# Backprop into W1, b1
dW1 = dz1.T.dot(X) # H * D
db1 = np.sum(dz1, axis=0) # 1 * H

上述求dz2其实就是对softmax求导,softmax公式如下

它的求导是分情况的

所以联想到我们以前的公式,其实求得就是以前用于求W的偏导数的一部分

所以上述公式是求W的偏导值也不难理解了,因为 z2 = w2 a1 + b2, 所以dw1 = a1 dz2,而图片的X就是这里的a1

前后向传播代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def loss(self, X, y, reg):
"""
X: N * D
y: N * 1
"""

W1 = self.parameters['W1'] # H * D
b1 = self.parameters['b1'] # H * 1
W2 = self.parameters['W2'] # C * H
b2 = self.parameters['b2'] # C * 1
num_examples = X.shape[0]

# Compute the forward pass
Relu = lambda x: np.maximum(0, x)
z1 = X.dot(W1.T) + b1 # N * H
a1 = Relu(z1)
z2 = a1.dot(W2.T) + b2 # N * C
scores = z2

if y is None:
return scores

# Compute the loss
exp_scores = np.exp(scores - np.max(scores, axis=1, keepdims=True))
pro_scores = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)
ground_true = np.zeros(scores.shape)
ground_true[range(num_examples), y] = 1
loss = -np.sum(ground_true * np.log(pro_scores)) / num_examples + 0.5 * reg * (np.sum(W1 * W1) + np.sum(W2 * W2))

# Backward pass: compute gradients
grads = {}
# Compute the gradient of z2 (scores)
dz2 = -(ground_true - pro_scores) / num_examples # N * C
# Backprop into W2, b2 and a1
dW2 = dz2.T.dot(a1) # C * H
db2 = np.sum(dz2, axis=0) # 1 * C
da1 = dz2.dot(W2) # N * H
# Backprop into z1
dz1 = da1
dz1[a1 <= 0] = 0 # N * H
# Backprop into W1, b1
dW1 = dz1.T.dot(X) # H * D
db1 = np.sum(dz1, axis=0) # 1 * H

# add the regularization
grads['W1'] = dW1 + reg * W1
grads['b1'] = db1
grads['W2'] = dW2 + reg * W2
grads['b2'] = db2

return loss, grads

调参

这是个非常重要的一步,因为在实现神经网络的过程中我们会涉及到很多超参数,所以一个有效的调参方法也是很重要的。在这次的实现当中,我需要调参的一共有7个

  • hidden layer size(隐藏层神经元个数)
  • learning rate(梯度下降学习率)
  • decay of learning rate(学习率退火速度)
  • iterations of per of annealing the learning rate(每一次学习率退火所需迭代次数)
  • numer of training epochs(训练整个数据集为一个周期的周期训练次数)
  • regularization strength(正则化强度)
  • batch_size(小批量训练个数)

同时对每次训练结果通过以下两种可视化图形以进行调参

这个用于调整学习率(当然在实际应用中会有更多的噪音,更高的batch_size可以使线条更加平滑),同时判断梯度下降是否正确(loss如果不断增大那么肯定是出问题了)

这个用于判断模型是否过拟合,同时因为横坐标为epoch(一个epoch代表训练整个数据集一次),如果验证集(绿线)的正确率在某个时刻趋于平缓的水平线,那么代表训练周期不需要再提高了,因此可以确定迭代次数。

而对于初始值的设定,学习率的初始值设定在10 ** uniform(-6, 1),训练5 epoches左右,然后不断缩小范围,训练更多次epoches,从而确定初始学习率的大小,大概在1e-3左右;正则强度一般为[0.01, 0.05, 0.1, 0.5, 0.9, 0.95, 0.99],其他则需要不断尝试

完整代码和效果

代码见 github/cs231n,而结果经过不断的调参,Cifar10的最终结果大概在50%左右,注意每次运行的结果都不一定会一样,因为权重W的初始值是随机的。

1
2
3
4
5
6
7
8
lr 7.000000e-04, reg 1.000000e-02, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.514000
lr 7.000000e-04, reg 1.000000e-01, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.513000
lr 9.000000e-04, reg 1.000000e-02, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.538000
lr 9.000000e-04, reg 1.000000e-01, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.519000
lr 1.000000e-03, reg 1.000000e-02, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.519000
lr 1.000000e-03, reg 1.000000e-01, lrd 9.500000e-01, ipla 4.000000e+02, ne 1.500000e+01, bs 2.500000e+02 val accuracy: 0.498000
best validation accuracy achieved during cross-validation: 0.538, which parameters is (0.0009, 0.01, 0.95, 400, 15, 250)
Test accuracy: 0.5041

结语

这次的收获应该是挺多的,首先对反向传播有了更深刻的了解,同时也领悟到了调参的重要性,这个过程确实蛮有意思的!