EUAdvancer

CS231n-模块化神经网络分类Cifar10

bg
之前我实现了两层的神经网络,它也体现了它应有的效果,但是它实在是过于简陋以至于很多行之有效的方法不在其中,同时它的可拓展性也不够强,不能随意的构建需要的神经网络模型,所以接下来就是将神经网络的实现进行模块化,同时实现了一些更能发挥神经网络效能的方法

模块化神经网络的结构

我们需要一个能构建任意层数神经网络的模型,其中涉及到最多的自然是前向传播和反向传播过程,我们回顾一下一个前向传播过程需要哪几步,对除了最后一层参数(W)外,每层的参数(w)和它的输入(x)进行点积,再加上偏差(b),然后使用激活函数进行激活输出,最后一层不需要激活,因此对于不确定的层数我们需要做的就是将这个操作单独封装出来以用在任意层数的神经网络,反向传播同理,因此模块化神经网络的第一步就是将这些操作单独封装在layer_utils.py下。在实现完第一步后,我们需要通过上面实现的方法构建一个神经网络模型,实现前向传播和反向传播从而计算出网络的损失值和梯度,这些代码我们可以封装到fullyConnectedNet.py中。既然模型已经构建好了,那么下一步就是训练模型,同时一些predict函数,可视化模型损失值走向,验证集正确率等操作都在这一步完成,这一步我们实现在solver.py中。那么到现在为止,整个模块化神经网络的分工就完毕了,我们再简单的列一下步骤

  1. 将前向传播和反向传播的每一步的操作,封装在layer_utils.py下
  2. 构建一个神经网络模型,实现前向传播和反向传播以计算loss和grads,封装在fullyConnectedNet.py中
  3. 构建一个训练模型,用于训练构建好的模型,同时实现一些可视化模型,预测正确率等操作,封装在solver.py中

layer_utils的实现

一层的前向传播如下

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
def affine_forward(x, w, b):
"""
x: N * D
w: D * C
b: 1 * C
"""

out = x.dot(w) + b
cache = (x, w, b)
return out, cache

def affine_backward(dout, cache):
"""
dout: N * C
"""

x, w, b = cache

dx = dout.dot(w.T) # N * D
dw = x.T.dot(dout) # D * C
db = np.sum(dout, axis=0) # 1 * N
return dx, dw, db

def relu_forward(x):
out = np.maximum(0, x)
cache = x
return out, cache

def relu_backward(dout, cache):
x = cache

dx = dout
dx[x <= 0] = 0
return dx

这里需要注意的是输入输出的参数一定设定的统一,这有利于后面的拼接的实现。(cache中是存放之后用于反向传播求导的一些参数)当然上面的函数仍然不算一个前向传播,所以为了封装的更彻底一些我们可以将点积过程和激活过程合并在一起

1
2
3
4
5
6
7
8
9
10
11
def affine_relu_forward(x, w, b):
out_fc, cache_fc = affine_forward(x, w, b)
out, cache_relu = relu_forward(out_fc)
cache = (cache_fc, cache_relu)
return out, cache

def affine_relu_backward(dout, cache):
cache_fc, cache_relu = cache
da = relu_backward(dout, cache_relu)
dx, dw, db = affine_backward(da, cache_fc)
return dx, dw, db

另外输出层的softmax或者svm的loss函数同样可以封装在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
def softmax_loss(scores, y):
N = scores.shape[0]

scores = scores - np.max(scores, axis=1, keepdims=True)
exp_scores = np.exp(scores)
pro_scores = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)

loss = -1 * np.sum(np.log(pro_scores[np.arange(N), y])) / N

dscores = pro_scores
dscores[np.arange(N), y] -= 1
dscores /= N
return loss, dscores

fullyConnectedNet的实现

构建一个模型必然需要初始化参数,所以一个weight_scale参数来控制初始化是很必要的,同时还需要的参数是reg,因为在计算梯度和损失时我们需要L2正则化来防止过拟合,因此我们的初始化模型函数如下

1
2
3
4
5
6
7
8
9
10
def __init__(self, input_dim, hidden_dim, num_classes, weight_scale, dropout=0, reg=0.0, use_batchnorm=False):
self.reg = reg

self.params = {}
layer_dims = [input_dim] + hidden_dim + [num_classes]
self.num_layers = len(hidden_dim) + 1

for i in range(self.num_layers):
self.params['W' + str(i + 1)] = weight_scale * np.random.randn(layer_dims[i], layer_dims[i + 1])
self.params['b' + str(i + 1)] = np.zeros(layer_dims[i + 1])

注意上面参数中的hidden_dim是一个列表,[100]代表只有一个隐藏层的神经网络,[100, 100, 100]则代表有3个隐藏层的神经网络,小随机数初始化参数过程中我们对每一层都初始化一次W和b。接下来就是前向传播了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def loss(self, X, y=None):
mode = 'test' if y is None else 'train'

caches = []
out = X
for i in range(self.num_layers - 1):
W = self.params['W' + str(i + 1)]
b = self.params['b' + str(i + 1)]
out, cache = affine_relu_forward(out, W, b)
caches.append(cache)

W = self.params['W' + str(self.num_layers)]
b = self.params['b' + str(self.num_layers)]
out, cache = affine_forward(out, W, b)
caches.append(cache)
scores = out

if y is None:
return scores

前向传播最需要注意的是最后一层参数不需要激活。再加上反向传播代码如下

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
def loss(self, X, y=None):
mode = 'test' if y is None else 'train'

caches = []
out = X
for i in range(self.num_layers - 1):
W = self.params['W' + str(i + 1)]
b = self.params['b' + str(i + 1)]
out, cache = affine_relu_forward(out, W, b)
caches.append(cache)

W = self.params['W' + str(self.num_layers)]
b = self.params['b' + str(self.num_layers)]
out, cache = affine_forward(out, W, b)
caches.append(cache)
scores = out

if y is None:
return scores

grads = {}
dout, dw, db = affine_backward(dscores, caches[-1])
grads['W' + str(self.num_layers)] = dw + self.reg * self.params['W' + str(self.num_layers)]
grads['b' + str(self.num_layers)] = db
for i in range(self.num_layers - 1)[::-1]:
cache = caches[i]
dout, dw, db = affine_relu_backward(dout, cache)
grads['W' + str(i + 1)] = dw + self.reg * self.params['W' + str(i + 1)]
grads['b' + str(i + 1)] = db

return loss, grads

solver的实现

模型已经训练好了,接下来就是训练它了,回忆一下训练模型需要哪些参数,可以参考我的上一篇文章的调参部分 CS231n-TwoLayerNet分类Cifar10 ,大部分参数就如上面所说的那般,但是我们在这里还需要加一个参数,这个在上次的两层网络中并没有提到,那就是最优化过程中的参数更新方法,在上次的实现中默认使用了sgd的更新方法,而在这里当然要完善起来,由于参数更新方法并不少,所以我们可以将它单独封装在optim.py中

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import numpy as np

def sgd(w, dw, config=None):
if config is None:
config = {}
config.setdefault('learning_rate', 1e-2)

next_w = w - config['learning_rate'] * dw
return next_w, config

def sgd_momentum(w, dw, config=None):
if config is None:
config = {}
config.setdefault('learning_rate', 1e-2)
config.setdefault('momentum', 0.9)
# v is a variable, so we needn't to setdefault'
v = config.get('velocity', np.zeros_like(w))

learning_rate = config['learning_rate']
momentum = config['momentum']

v = momentum * v - learning_rate * dw
next_w = w + v
config['velocity'] = v

return next_w, config

def adam(w, dw, config=None):
if config is None:
config = {}
config.setdefault('learning_rate', 1e-2)
config.setdefault('beta1', 0.9)
config.setdefault('beta2', 0.999)
config.setdefault('epsilon', 1e-8)
t = config.get('t', 0)
v = config.get('v', np.zeros_like(w))
m = config.get('m', np.zeros_like(w))

learning_rate = config['learning_rate']
beta1 = config['beta1']
beta2 = config['beta2']
epsilon = config['epsilon']

t += 1
m = beta1 * m + (1 - beta1) * dw
v = beta2 * v + (1 - beta2) * (dw ** 2)
m_bias = m / (1 - beta1 ** t)
v_bias = v / (1 - beta2 ** t)
next_w = w - learning_rate * m_bias / (np.sqrt(v_bias) + epsilon)

config['m'] = m
config['v'] = v
config['t'] = t

return next_w, config

def rmsprop(w, dw, config=None):
if config is None:
config = {}
config.setdefault('learning_rate', 1e-2)
config.setdefault('decay_rate', 0.99)
epsilon = config.setdefault('epsilon', 1e-8)
cache = config.get('cache', np.zeros_like(w))

learning_rate = config['learning_rate']
decay_rate = config['decay_rate']
epsilon = config['epsilon']

cache = decay_rate * cache + (1 - decay_rate) * (dw ** 2)
next_w = w - learning_rate * dw / (np.sqrt(cache) + epsilon)

config['cache'] = cache
return next_w, config

因为很多更新方法的参数并不是只有learning_rate一个参数,所以我们统一将它放在config中,具体的实现我们可以按照公式来实现,并且接下来我们会使用adam作为我们模型的梯度下降方法。同时solver的初始化函数也可以构造出来了

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
def __init__(self, model, data, **kwargs):
self.model = model

self.X_train = data['X_train']
self.y_train = data['y_train']
self.X_val = data['X_val']
self.y_val = data['y_val']

self.batch_size = kwargs.pop('batch_size', 100)
self.iters_per_ann = kwargs.pop('iters_per_ann', 100)
self.num_epochs = kwargs.pop('num_epochs', 10)
self.update_rule = kwargs.pop('update_rule', 'sgd')
self.optim_config = kwargs.pop('optim_config', {})
self.verbose = kwargs.pop('verbose', True)
self.print_every = kwargs.pop('print_every', 10)
self.lr_decay = kwargs.pop('lr_decay', 1.0)

if not hasattr(optim, self.update_rule):
raise ValueError('Invalid update_rule "%s"' % self.update_rule)
self.update_rule = getattr(optim, self.update_rule)

self.optim_configs = {}
for p in self.model.params:
d = {k: v for k, v in self.optim_config.items()}
self.optim_configs[p] = d


self.loss_history = []
self.train_acc_history = []
self.val_acc_history = []

其中optim_config 就是用于参数更新方法的参数,并且由于每层的W都会有不一样的optim_config(在更新的过程中部分参数会发生变化) ,所以我们还需要为每一层都初始化一个optim_config。接下来就是训练的过程,下面是一次梯度下降的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _step(self):
N = self.X_train.shape[0]

sample_index = np.random.choice(N, self.batch_size, replace=True)
X_batch = self.X_train[sample_index, :]
y_batch = self.y_train[sample_index]

loss, grads = self.model.loss(X_batch, y_batch)
self.loss_history.append(loss)
for p, w in self.model.params.items():
dw = grads[p]
config = self.optim_configs[p]
next_w, next_config = self.update_rule(w, dw, config)
self.model.params[p], self.optim_configs[p] = next_w, next_config

每次的梯度下降都随机取小批量数据进行训练,在计算完新的参数是别忘了将新的用于梯度下降的参数optim_config保存下来,再接着就是迭代个n次就可以把模型训练完了。当然作为一个优秀的solver当然不能将功能仅限于此,我们还需要对这个模型进行评估和可视化来判断我们到底需要的是不是它,所以我们还可以实现用于检验正确率的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def check_accuracy(self, X, y, num_samples=None):
N = X.shape[0]
if num_samples is not None:
mask = np.random.choice(N, num_samples, replace=True)
N = num_samples
X = X[mask]
y = y[mask]

num_batchsize = int(N / self.batch_size)
y_pred = []
for i in range(num_batchsize):
start = int(i * self.batch_size)
end = int((i + 1) * self.batch_size)
scores = self.model.loss(X[start:end])
y_pred.append(np.argmax(scores, axis=1))
y_pred = np.hstack(y_pred)
acc = (y_pred == y).mean()
return acc

其中使用num_batchsize来分块拼接是防止内存不够,而num_samples则是用于自定义检验的个数。这个时候也许你还记得在初始化是构建的loss_history,train_acc_history 等列表,如果你在训练过程中将他们赋予了值,你也可以可视化它们来判断你的模型是否出错或者效果如何

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def visualization_model(self):
plt.subplot(2, 1, 1)
plt.title('Training loss')
plt.plot(self.loss_history, 'o')
plt.xlabel('Iteration')

plt.subplot(2, 1, 2)
plt.title('Accuracy')
plt.plot(self.train_acc_history, '-o', label='train')
plt.plot(self.val_acc_history, '-o', label='val')
plt.plot([0.5] * len(self.val_acc_history), 'k--')
plt.xlabel('Epoch')
plt.legend(loc='lower right')
plt.gcf().set_size_inches(15, 12)
plt.show()

那么,到现在为止,我们的模型已经完全实现好了吗?不,并没有,在文章的开头我说过,原先的模型的功能过于简陋,很多优秀的方法并没有实现在其中,而在这次的模型中我们需要将这些功能一一实现,上面的optim.py中的更新方法是其中的一部分,它们加快了模型的梯度下降速度,而接下来的这些方法同样非常有意义

Batch Normalization

虽然Relu激活函数已经有效解决了sigmoid的过饱和问题(梯度消失),但是它也同时依赖于一个好的参数的初始化,而batch normalization则可以解决这个问题,它能使网络对于不好的初始值有更强的鲁棒性,加快梯度下降速度以及解决梯度消失问题。因此我们在原始输出后加上batch normalization(即全连接层与激活函数之间添加一个batch normalization层)使得结果(输出信号各个维度)的均值为0,方差为1。具体技巧均在batch normalization参考文献中,这里我就贴上算法实现步骤

上图是在前向传播batch normalization层步骤,代码实现如下

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
def batchnorm_forward(x, gamma, beta, bn_param):
eps = bn_param.setdefault('eps', 1e-5)
momentum = bn_param.setdefault('momentum', 0.9)

mode = bn_param['mode']

N, D = x.shape
running_mean = bn_param.get('running_mean', np.zeros(D, dtype=x.dtype))
running_var = bn_param.get('running_var', np.zeros(D, dtype=x.dtype))

if mode == 'train':
sample_mean = np.mean(x, axis=0) # 1 * D
sample_var = np.var(x, axis=0) # 1 * D
x_normalized = (x - sample_mean) / np.sqrt(sample_var+ eps) # N * D
out = x_normalized * gamma + beta # N * D

running_mean = momentum * running_mean + (1 - momentum) * sample_mean
running_var = momentum * running_var + (1 - momentum) * sample_var

cache = (x, sample_mean, sample_var, x_normalized, gamma, eps)
elif mode == 'test':
x_normalized = (x - running_mean) / np.sqrt(running_var + eps)
out = x_normalized * gamma + beta
cache = None
else:
raise ValueError('Invalid forward batchnorm mode "%s"' % mode)

# Store the updated running means back into bn_param
bn_param['running_mean'] = running_mean
bn_param['running_var'] = running_var

return out, cache

注意在训练或测试的步骤不同的。接下来是反向传播阶段求导公式(利用链式法则更新)

因为它的参数gamma和beta也是需要更新的,所以我们需要求dx,dgamma以及dbeta,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
def batchnorm_backward(dout, cache):
x, sample_mean, sample_var, x_normalized, gamma, eps = cache

dx_normalized = dout * gamma # N * D
sample_std_inv = 1 / np.sqrt(sample_var + eps) # 1 * D
x_mu = x - sample_mean # N * D
dsample_var = -0.5 * np.sum(dx_normalized * x_mu, axis=0) * (sample_std_inv ** 3)
dsample_mean = -1 * (np.sum(dx_normalized * sample_std_inv, axis=0) + 2 * dsample_var * np.mean(x_mu, axis=0))
dx = dx_normalized * sample_std_inv + 2 * dsample_var * x_mu / x.shape[0] + dsample_mean / x.shape[0]
dgamma = np.sum(dout * x_normalized, axis=0) # 1 * D
dbeta = np.sum(dout, axis=0) # 1 * D

return dx, dgamma, dbeta

以上代码同样可以添加到layer_utils.py中,然后在模型的前反向传播中增加batch normalization层即可。当然在这过程中我们同样要创建一个bn_params用于不同层

1
2
3
4
if self.use_batchnorm:
self.bn_configs = {}
for i in range(self.num_layers - 1):
self.bn_configs['W' + str(i + 1)] = {'mode': 'train'}

也许你很疑惑问什么gamma和beta为什么不一起放进去,其实这很简单,这两个参数是会在反向传播后和权重一起进行更新的,而不能像optim.py中的一些参数直接可以更新,所以需要将它们和权重放在一起

Dropout

之前我们对于过拟合的处理就是L2正则化,而在神经网络中,dropout可以和它互为补充,它的核心思想就是让神经元以超参数p的概率被激活或者被设置为0,因此我们在relu激活后进行dropout失活,它同样分训练和测试模型,测试模式下我们不需要对输出做任何事(因为我们在训练模式下对激活数据按照p进行范围数据调整,如果不做调整,测试模式下也要做一些修改),前反向代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def dropout_forward(x, df_param):
p = df_param['p']
mode = df_param['mode']

if mode == 'train':
mask = (np.random.rand(*x.shape) < p) / p # 如果这里不除以P来调整,在test下要乘以P
out = x * mask
elif mode == 'test':
out = x
mask = None

cache = (mask, df_param)
return x, cache

def dropout_backward(dout, cache):
mask, df_param = cache
mode = df_param['mode']

if mode == 'train':
dx = dout * mask
elif mode == 'test':
dx = dout
return dx

如果我们使用dropout层,我们同样要设置dp_param,当然因为它每层都一样且不变,所以不需要为每层都设置参数

1
2
if self.use_dropout:
self.dp_param = {'mode': 'train', 'p': dropout}

最后在前反向传播中加上dropout层即完成了整个模型
在这里我总结一下我们最终实现的前向传播的过程:{affine - [batch norm] - relu - [dropout]} x (L - 1) - affine - softmax

使用模型

到了现在,整个模块化的神经网络基本已经实现完成了,如果你想要测试一下它的效果,你可以新建一个test.py文件,按照这样就能自定义你想要的任何形式的神经网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
model = FullyConnectedNet(input_dims, hidden_dims, num_classes, weight_scale=ws,  use_batchnorm=True, dropout=0.9)
solver = Solver(model, small_data,
optim_config={
'learning_rate': 1e-3
},
batch_size=50,
iters_per_ann=400,
num_epochs=10,
update_rule='adam',
print_every=200,
verbose=True,
lr_decay = 1)

solver.train()
solver.visualization_model()

如果你想检验这个模型在测试集上的正确率,可以这样

1
2
test_acc = solver.check_accuracy(data['X_test'], data['y_test'])
print('Test accuracy: {}'.format(test_acc))

当然你可以再写一个用于调参的函数来测试不同参数的效果,这些都基于以上的核心写就可以了,这部分代码可以参考完整代码,我经过简单的调参,最终测试了cifar10如下(上面的验证集准确率较低是因为我在调参用的训练集是随机取了5000个):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.75, lr 0.0009 val accuracy: 0.432000
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.75, lr 0.002 val accuracy: 0.407000
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.8, lr 0.0009 val accuracy: 0.405000
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.8, lr 0.002 val accuracy: 0.405000
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.9, lr 0.0009 val accuracy: 0.408000
ws 0.01, bs 250, ipa 400, id 0.95, dp 0.9, lr 0.002 val accuracy: 0.419000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.75, lr 0.0009 val accuracy: 0.424000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.75, lr 0.002 val accuracy: 0.401000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.8, lr 0.0009 val accuracy: 0.391000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.8, lr 0.002 val accuracy: 0.410000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.9, lr 0.0009 val accuracy: 0.408000
ws 0.02, bs 250, ipa 400, id 0.95, dp 0.9, lr 0.002 val accuracy: 0.396000
best validation accuracy achieved during cross-validation: 0.432, which parameters is (0.01, 250, 400, 0.95, 0.75, 0.0009)
Test accuracy: 0.5248

完整代码

代码见 github/cs231n

结语

这次是终于完成了整个神经网络模型的构建,成就感还是蛮足的,自己实现一遍的感觉真的不一样,对细节的把握提高了很多,也提高了不少信心