目录

神经网络笔记(一)——Fully Connected Nets

这篇文章是我在完成CS231N-2021课程的Labassignment2/FullyConnectedNets.ipynb时的学习与实验的摘录与笔记。

参数初始化

补全cs231n/classifiers/fc_net.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
class FullyConnectedNet(object):

    def __init__(
        self,
        hidden_dims,
        input_dim=3 * 32 * 32,
        num_classes=10,
        dropout_keep_ratio=1,
        normalization=None,
        reg=0.0,
        weight_scale=1e-2,
        dtype=np.float32,
        seed=None,
    ):
        self.normalization = normalization
        self.use_dropout = dropout_keep_ratio != 1
        self.reg = reg
        self.num_layers = 1 + len(hidden_dims)
        self.dtype = dtype
        self.params = {}

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        self.params['W1']=weight_scale*np.random.randn(input_dim,hidden_dims[0])
        self.params['b1']=np.zeros(hidden_dims[0])
        for i in range(1,len(hidden_dims)):
          self.params['W'+str(i+1)]=weight_scale*np.random.randn(hidden_dims[i-1],hidden_dims[i])
          self.params['b'+str(i+1)]=np.zeros(hidden_dims[i])
        self.params['W'+str(len(hidden_dims)+1)]=weight_scale*np.random.randn(hidden_dims[-1],num_classes)
        self.params['b'+str(len(hidden_dims)+1)]=np.zeros(num_classes)
        pass

        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

损失函数和梯度计算:

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

    # Set train/test mode for batchnorm params and dropout param since they
    # behave differently during training and testing.
    if self.use_dropout:
        self.dropout_param["mode"] = mode
    if self.normalization == "batchnorm":
        for bn_param in self.bn_params:
            bn_param["mode"] = mode
    scores = None
    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    aff_outs=[]
    relu_outs=[]
    aff_caches=[]
    relu_caches=[]
    for i in range(self.num_layers):
        # affine forward
        aff_out, aff_cache=None, None
        if i==0:
        aff_out, aff_cache=affine_forward(X,self.params['W1'],self.params['b1'])
        else:
        aff_out, aff_cache=affine_forward(relu_outs[-1],self.params['W'+str(i+1)],self.params['b'+str(i+1)])
        aff_outs.append(aff_out)
        aff_caches.append(aff_cache)
        # ReLU forward
        relu_out, relu_cache=relu_forward(aff_outs[-1])
        relu_outs.append(relu_out)
        relu_caches.append(relu_cache)
        pass
    scores=relu_outs[-1]

    pass

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    
    # If test mode return early.
    if mode == "test":
        return scores

    loss, grads = 0.0, {}
    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    loss, grad=softmax_loss(scores,y)
    for i in range(self.num_layers):
        loss+=0.5*self.reg*np.sum(np.square(self.params['W'+str(i+1)]))
    # backprop
    for i in range(self.num_layers,0,-1):
        grad=relu_backward(grad, relu_caches[i-1])
        grad,grads['W'+str(i)],grads['b'+str(i)]=affine_backward(grad,aff_caches[i-1])
        grads['W'+str(i)]+=self.reg*self.params['W'+str(i)]


    pass

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    
    return loss, grads

参数更新

反向传播计算出的解析梯度用于进行参数的更新。

随机梯度下降(SGD)及各种更新方法

普通更新

沿着负梯度方向改变参数。假设有一个参数向量x及其梯度dx,那么最简单的更新的形式是:

1
2
# Vanilla update
x += - learning_rate * dx

其中learning_rate是一个超参数,它是一个固定的常量。当在整个数据集上进行计算时,只要学习率足够低,总是能在损失函数上得到非负的进展。

动量(Momentum)更新

这个方法在深度网络上几乎总能得到更好的收敛速度。该方法可以看成是从物理角度上对于最优化问题得到的启发。

1
2
3
# Momentum update
v = mu * v - learning_rate * dx # integrate velocity
x += v # integrate position

这里动量的物理意义与摩擦系数更一致。容易理解这里的“动量”抑制了速度,降低了系统的动能,不然质点在山底永远不会停下来。通过交叉验证,这个参数通常设为[0.5,0.9,0.95,0.99]中的一个。和学习率随着时间退火(下文有讨论)类似,动量随时间变化的设置有时能略微改善最优化的效果,其中动量在学习过程的后阶段会上升。一个典型的设置是刚开始将动量设为0.5而在后面的多个周期(epoch)中慢慢提升到0.99。

实验代码

补全cs231n/optim.pysgd_momentum函数实现动量更新(注意这里不是Nesterov动量),代码片段如下:

 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 sgd_momentum(w, dw, config=None):
    """
    Performs stochastic gradient descent with momentum.

    config format:
    - learning_rate: Scalar learning rate.
    - momentum: Scalar between 0 and 1 giving the momentum value.
      Setting momentum = 0 reduces to sgd.
    - velocity: A numpy array of the same shape as w and dw used to store a
      moving average of the gradients.
    """
    if config is None:
        config = {}
    config.setdefault("learning_rate", 1e-2)
    config.setdefault("momentum", 0.9)
    v = config.get("velocity", np.zeros_like(w))

    next_w = None
    ###########################################################################
    # TODO: Implement the momentum update formula. Store the updated value in #
    # the next_w variable. You should also use and update the velocity v.     #
    ###########################################################################
    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

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

    pass

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

相对误差输出信息参考如下:

1
2
next_w error:  8.882347033505819e-09
velocity error:  4.269287743278663e-09

在实验中我们可以直观的看到SGD+动量能够更快的收敛。

Nesterov动量

与普通动量有些许不同,最近变得比较流行。在理论上对于凸函数它能得到更好的收敛,在实践中也确实比标准动量表现更好一些。

1
2
3
v_prev = v # back this up
v = mu * v - learning_rate * dx # velocity update stays the same
x += -mu * v_prev + (1 + mu) * v # position update changes form

学习率退火

如果学习率很高,系统的动能就过大,参数向量就会无规律地跳动,不能够稳定到损失函数更深更窄的部分去。

在实践中,我们发现随步数衰减的随机失活(dropout)更受欢迎,因为它使用的超参数(衰减系数和以周期为时间单位的步数)比更有解释性。

二阶方法

需要求解Hessian矩阵,其操作非常耗费时间和空间。在深度学习和卷积神经网络中,使用L-BFGS之类的二阶方法并不常见。相反,基于(Nesterov的)动量更新的各种随机梯度下降方法更加常用,因为它们更加简单且容易扩展。

逐参数适应学习率方法

前面讨论的所有方法都是对学习率进行全局地操作,并且对所有的参数都是一样的。学习率调参是很耗费计算资源的过程,所以很多工作投入到发明能够适应性地对学习率调参的方法,甚至是逐个参数适应学习率调参。很多这些方法依然需要其他的超参数设置,但是其观点是这些方法对于更广范围的超参数比原始的学习率方法有更良好的表现。

在CS231N的实验中需要实现RMSprop和Adam两个方法,这两种方法可以视作对Adagrad方法的改进。

Adagrad

核心思想是接收到高梯度值的权重更新的效果被减弱,而接收到低梯度值的权重的更新效果将会增强。一个缺点是,在深度学习中单调的学习率被证明通常过于激进且过早停止学习。这里是Adagrad方法的一个直观解释。代码如下:

1
2
3
# Assume the gradient dx and parameter vector x
cache += dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)

RMSprop

这个方法用一种很简单的方式修改了Adagrad方法,让它不那么激进,单调地降低了学习率。具体说来,就是它使用了一个梯度平方的滑动平均:

1
2
cache =  decay_rate * cache + (1 - decay_rate) * dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)

在上面的代码中,decay_rate是一个超参数,常用的值是[0.9,0.99,0.999]。其中x+=和Adagrad中是一样的,但是cache变量是不同的。因此,RMSProp仍然是基于梯度的大小来对每个权重的学习率进行修改,这同样效果不错。但是和Adagrad不同,其更新不会让学习率单调变小。

实验中的代码实现如下:

1
2
3
4
5
6
7
# *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

config["cache"] =  config["decay_rate"] * config["cache"] + (1 - config["decay_rate"]) * dw**2
next_w = w - config["learning_rate"] * dw / (np.sqrt(config["cache"]) + config["epsilon"])
pass

# *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

Adam

Adam是最近才提出的一种更新方法,它看起来像是RMSProp的动量版。简化的代码是下面这样:

1
2
3
m = beta1*m + (1-beta1)*dx
v = beta2*v + (1-beta2)*(dx**2)
x += - learning_rate * m / (np.sqrt(v) + eps)

注意这个更新方法看起来真的和RMSProp很像,除了使用的是平滑版的梯度m,而不是用的原始梯度向量dx。论文中推荐的参数值eps=1e-8, beta1=0.9, beta2=0.999。在实际操作中,我们推荐Adam作为默认的算法,一般而言跑起来比RMSProp要好一点。但是也可以试试SGD+Nesterov动量。完整的Adam更新算法也包含了一个偏置(bias)矫正机制,因为m,v两个矩阵初始为0,在没有完全热身之前存在偏差,需要采取一些补偿措施。

实验中的Adam方法要求实现偏置(bias)矫正机制。根据论文,实验中的相关代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

config["t"] += 1
config["m"] = config["beta1"]*config["m"] + (1-config["beta1"])*dw
config["v"] = config["beta2"]*config["v"] + (1-config["beta2"])*(dw**2)
m_hat=config["m"]/(1-config["beta1"]**config["t"])
v_hat=config["v"]/(1-config["beta2"]**config["t"])
next_w = w - config["learning_rate"] * m_hat / (np.sqrt(v_hat) + config["epsilon"])
pass

# *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****