面试复盘 一

面试复盘 一

Sat Sep 14 2024
11 分钟
2621 字

关于面试:开始

这里先讲面试的其中三个问题:

实现Batch Norm#

Batch Norm,批量规范化负责将一个batch的数据进行规范化。和很多地方一样,“规范”总是和均值方差相关。简单来说,规范化就是使得这个batch的数据的均值为0,方差为1,即

x^(i)=x(i)μBσB2+ϵy(i)=γx^(i)+β\begin{align*} \hat{x}^{(i)} &= \frac{x^{(i)} - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} \\ y^{(i)} &= \gamma \hat{x}^{(i)} + \beta \end{align*}

这里,我们给方差加上了一个小量ϵ\epsilon,是防止除0的情况。γ\gammaβ\beta是可学习的参数,形状均为(特征维度ii,),将数据映射到一个新的分布。如此以来,机器学习的IID假设就被强制满足了。这里需要注意的是,一个batch应该勉强满足IID,所以按理说batch大小需要远大于数据的种类数(对于有类别的数据)。batch太小时,可想见Batch Norm会起到反效果。Batch Norm也可以解决深度网络累积偏差过大导致的梯度爆炸问题。

关于γ\gammaβ\beta的必要性#

一般的解释是,如果使用x^(i)\hat{x}^{(i)}作为输出,那么batch norm的输出总是均值为0,方差为1的,网络的表达能力就受限了。我认为这个说法太过笼统。从分布的角度看,假设输入的是一个n维正态分布N(μ,σ2)nN(\bm\mu, \bm\sigma^2)^n,那么经过规范化后,输出的分布是N(0,1)nN(0, 1)^nγ\gammaβ\beta初始化分别是1和0,并不改变分布。而且,如果Batch Norm后面还有一个Linear层,不妨将γ\gamma重新写为对角矩阵Γ\Gamma,则显然Γ\Gammaβ\beta可以被后面的Linear层吸收。这也引出了一个重要的结论:Batch Norm必须用于激活函数之前,即

h=ϕ(BN(Wx+b)),\mathbf h = \phi(\operatorname{BN}(\mathbf W \mathbf x + \mathbf b)),

否则,Batch Norm是完全无效的。

稍微跑题了。但总之,作为一个简单的线性变换,表达能力受限这个说法,我表示存疑,尤其是对于学习相对关系的分类网络等而言。也有人说,这是为了避免激活函数时效,考虑到如Sigmoid这样的函数,在0附近近似是线性的。到了优化后期,还是需要启用激活函数的非线性部分,所以才加了偏移参数。

我个人认为,γ\gammaβ\beta的存在,其实是延后了分布偏移的过程,使得分布的偏移主动且可学习。如果分布真的是正态分布,那么这两个参数,如果手动设置,可以将输出的分布还原回输入的分布。但初始时将其设为0,1,强制将偏移延后,使得初始化的深层网络开始时是稳定的,然后逐步开始偏移,启用激活函数。这样来看,这两个参数的必要性更多和稳定性有关。

Batch Norm如何加速收敛#

我们主要考虑两种情况:

  1. 使用了类似于Sigmoid这样的激活函数。如果网络非常深,由于累积偏差,假设在某一层,大多数数据的取值落在[510][5,10]。观察Sigmoid可以发现,这个区间的梯度非常小。而Batch Norm初始时可以将这个区间的数据映射到0附近,这样梯度就不会消失。这也是为什么Batch Norm必须用在激活函数之前的原因。

  2. γ\gamma较大的情况。由于γ\gamma是可学习的,当某几层对输出的影响较大时,γ\gamma会倾向于变大,使得这部分区域的梯度变大,从而加速收敛。

由此看来,Batch Norm的作用和Sigmoid或类似函数有关。对于常用的ReLU等激活函数,则效果还是存疑的。不过搭配ReLU的话,可能就需要考虑设置非0的β\beta了。

实现(Numpy)#

上面的部分只讲了Batch Norm的目的和原理。实现的时候,需要考虑的就多了,比如:需要记录均值方差,以进行推理;以及如何更新均值和方差。其中更新方式,和优化器Adam等类似,使用动量。均值和方差的计算还考虑了全连接层和卷积层的情况。我想如果面试遇见,能考虑两种情况多半是加分项。

PYTHON
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
import numpy as np

def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum, train=True):
    # 判断当前模式是训练模式还是预测模式
    if not train:  # 如果是torch,相必大家都知道如何写。但是我当时被要求使用numpy就是了
        # 预测模式下,直接使用传入的移动均值和方差
        X_hat = (X - moving_mean) / np.sqrt(moving_var + eps)
    else:
        assert len(X.shape) in (2, 4)
        if len(X.shape) == 2:
            # 使用全连接层的情况,计算特征维上的均值和方差
            mean = X.mean(axis=0)
            var = ((X - mean) ** 2).mean(axis=0)
        else:
            # 使用二维卷积层的情况,计算通道维上的均值和方差
            mean = X.mean(axis=(0, 2, 3), keepdims=True) # 输入X的维度是(N, C, H, W),每个通道求均值
            var = ((X - mean) ** 2).mean(axis=(0, 2, 3), keepdims=True)
        # 训练模式下,用当前的均值和方差做标准化
        X_hat = (X - mean) / np.sqrt(var + eps)
        # 动量更新移动平均的均值和方差
        moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
        moving_var = momentum * moving_var + (1.0 - momentum) * var
    Y = gamma * X_hat + beta  # 缩放和移位
    return Y, moving_mean, moving_var


class BatchNorm:
    # num_features:完全连接层的输出数量或卷积层的输出通道数。
    # num_dims:2表示完全连接层,4表示卷积层
    def __init__(self, num_features, num_dims):
        if num_dims == 2:
            shape = (1, num_features)
        else:
            shape = (1, num_features, 1, 1)
        # 拉伸和偏移参数,分别初始化为1和0
        self.gamma = np.ones(shape)
        self.beta = np.zeros(shape)
        # 非模型参数的变量初始化为0和1
        self.moving_mean = np.zeros(shape)
        self.moving_var = np.ones(shape)

    def forward(self, X):
        # 保存更新过的moving_mean和moving_var
        Y, self.moving_mean, self.moving_var = batch_norm(
            X, self.gamma, self.beta, self.moving_mean,
            self.moving_var, eps=1e-5, momentum=0.9)
        
        return Y

Backward更新γ\gammaβ\beta的部分,我不觉得是一时半会能用numpy写出来的。我觉得写出这两个参数就可以了,要是要求写计算图和自动微分也太变态了。

其他#

我记得当时在上深度学习课的时候,好像是沈为讲的,就是说在新的工作中,Batch Norm被发现在某些情况下并不有用。况且,即使作者进行了解释,人们并为真正确认这种方法到底为何有效。只能说,深度学习是这样的。这样看来,现在的计算机视觉新技术,像NeRF技术明确把神经网络当作拟合器并使用很小的网络,3D Gaussian Splatting彻底不使用神经网络,起码在解释性上是优雅的。不过这也导致了我在面试时默写不出Batch Norm。我不知道该如何评价了。

区分Batch Norm和Layer Norm#

上面详细解释了Batch Norm。这里直接讲Layer Norm,相必读者自然能明白两者的区别。简单来说,Layer Norm就是将上面的实现改为了

PYTHON
1
2
3
4
5
6
7
8
    if len(X.shape) == 2:
        # 使用全连接层的情况,计算Batch维上的均值和方差
        mean = X.mean(axis=1)
        var = ((X - mean) ** 2).mean(axis=1)
    else:
        # 使用二维卷积层的情况,计算Batch上的均值和方差
        mean = X.mean(axis=(1, 2, 3), keepdims=True) # 输入X的维度是(N, C, H, W),每个通道求均值
        var = ((X - mean) ** 2).mean(axis=(1, 2, 3), keepdims=True)

显然,Layer Norm是对每个样本的所有特征进行规范化,而Batch Norm是对每个特征的所有样本进行规范化。

使用那个Norm取决于网络结构和所使用数据的信息分布。考虑图像和文本,图像往往只能包含某些特定的信息,图片和图片之间内容往往不同,而长文本之间各自就已经独立,比如一个作文数据集,每篇作文的分布是相似的。在计算机视觉领域,对于数据(N,图片)来说,我们往往认为每一组图片的整体分布是一致的,所以Batch Norm更适合。而对于自然语言处理等领域,数据更多是(N,句子),我们认为每段文本的分布是一致的,所以Layer Norm更适合。Transformer网络中就使用了Layer Norm。

解释DDP和DP#

DP(Data Parallelism)是数据并行,DDP(Distributed Data Parallelism)是分布式数据并行。这两个概念都是大模型训练的基础知识。

DP#

DP,是将模型复制在多个GPU上。以8卡服务器为例,在一个Batch训练时,这个Batch会被分为8份(由于梯度会合并,严格来说并不要求8等份,但没病的话一般是等份)。每个GPU处理自己分到的数据,各自进行求导,反向传播的时候,各自计算梯度,然后将梯度汇总到一个GPU上(默认为GPU0),更新参数,然后将新的模型拷贝回其他GPU。也有说法说是其他GPU将前向计算结果拷贝到GPU0,然后GPU0再计算Loss。这样的话想来会更慢,而且主卡的显存占用会更高。

其中,如果是普通的机器,梯度的拷贝和模型的拷贝需要通过CPU。不考虑GPU能够直接通信的集群,可以进行的优化是:CPU负责分发数据,回收梯度,整合梯度,发回各个GPU,GPU各自使用梯度更新自己的模型。这样的话,虽然CPU整合梯度的速度不如GPU,但是免除了CPU将7卡的梯度一个个发送到GPU0的,和复制模型的时间,理论上效率会高的多。还有一个问题就是,主卡的显存占用可能会过高,考虑到由于Dropout等,不同梯度图并不是直接合并的。但是本人卡最多的机器也就是2080Ti双卡,并没有进行测试的机会。

DDP#

上面提到的优化,就是DDP的主要思路。DDP的区别就是以同步梯度,每张卡独立更新参数,代替单卡更新参数和模型拷贝。此外,DDP还会使用NCCL库(Nvidia的库,看了下居然是纯软件,就是说不需要加NVlink之类的)进行GPU间数据同步,主要使用all_reduce机制。

这样一来,一般来说都不会去使用DP,一般都是使用DDP的。DP几乎已经弃用了。

Reference#

PS: 后面的Transformer将单独开一篇独立标题,所以没有面试复盘-2了。