Post

深度学习基础

记录深度学习基础知识,包括线性回归、softmax、多层感知机、自动求导与反向传播等

深度学习基础

深度学习相关了解

初步了解了一下深度学习的研究领域,近几年主要是在和计算机视觉以及自然语言处理方面不断发展,根据目前的表现也可以和当下热门的具身智能进行结合。

进行了pytorch相关的环境配置并了解了torch中张量和numpy以及pandas中多维矩阵的使用和处理,这两个数据并不是同一种类型的,但是支持互相转换。

使用torch和numpy实现矩阵运算的工程和数学知识

在数学上一个向量默认为列向量,但是在代码实现中直接通过torch.arange(4)生成的只是一个数组,如果需要变成数学上的向量或者矩阵,则需要使用reshape(4,1)来变成4行1列的一个符合数学直观的向量。

也可以使用行向量来理解,更加直观。

可以使用keepdims来使得求和后的矩阵维度(轴数)保持不变,然后通过广播机制来保证矩阵运算可以正常进行。

标量关于向量的导数是行向量; 向量关于标量的导数是列向量; 向量关于向量的导数是矩阵

自动求导与反向传播

不同于数学上求关于函数的导数与通过数值拟合的数值求导,自动求导先通过正向传播(累积) 将求导计算分解为一个计算图(通常是DAG)然后通过反向传播链式法则一步步计算回来。

正向的求导需要存储大量的中间变量,对于高维度的计算会占用大量的内存资源,反向的则不需要,因此选择了在反向传播回来时一次性计算参数的梯度,内存复杂度为 $O(1)$ 并且反向累积还能进行剪枝,剔除不需要求导的部分。但是一个变量的梯度计算仍旧需要 $O(n)$ 的计算复杂度。

可以通过 y.backward() 来对y这个标量进行关于x每个分量的反向传播计算梯度。并且在获取x.grad之前必须要进行进行一次backward因为计算梯度是很贵的,

但是通常在深度学习中我们会尝试计算损失函数中的导数,一般都是标量或者向量,所以一般会将y通过sum函数转换为一个标量最后再进行求导。

还可以通过detach函数将链式求导中的一些参数分离出来,从而实现固定的效果。

cs231n解释: 当我们遇到了大量的矩阵计算以及嵌套的函数时,对整体求解析导数非常困难。但是我们最终需要的只是 $\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y},\frac{\partial f}{\partial z}$ 。因此求导过程可以看作一个计算图,只要图中的每一个局部都能自己求梯度并且传播那么就能实现系统的求导

传播就是通过链式求导法则来实现的。反向传播就像下图一样通过上下游梯度 $\frac{\partial L}{\partial x}=\frac{\partial L}{\partial z}\cdot\frac{\partial z}{\partial x}$ 来传递: 另一个更加复杂的例子:红色为上游梯度,绿色为当前算子的梯度,需要反向传播计算下游梯度(同时作为下一个反传的上游)

然后我们需要将标量的反传推广到向量甚至矩阵(张量)时,在计算过程中可能会出现一些维度改变,但是相对于损失函数的变量梯度与原始变量具有相同的维度。前面有提到过关于标量、向量和矩阵求导的维度变化。

线性回归与基础优化算法

经典的线性回归模型,通常使用平方损失来进行真实值和预估值的比较: \(\ell(y, \hat{y}) = \frac{1}{2}(y - \hat{y})^2\) 对于没有闭式解的情况则采用梯度下降(Gradient Descending) 的方法,和机器学习中的方法是一样的。

\[\mathbf{w}_t = \mathbf{w}_{t-1} - \eta \frac{\partial \ell}{\partial \mathbf{w}_{t-1}}\]

$\eta$ 是学习率,后面的是梯度,意味着需要沿着梯度反向的方向以学习率进行下降迭代。

学习率需要进行合适的选择,不能太大也不能太小(震荡 / 收敛太慢)。 但是去拿不样本计算会消耗大量时间,因此需要采用批量随机梯度下降(通常是默认的梯度下降算法),批量大小b也是重要的超参数。随机,指的是每批选取的样本都是随机的。

1
2
3
4
5
6
7
8
9
def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # 这些样本是随机读取的,没有特定的顺序
    random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        batch_indices = torch.tensor(
            indices[i: min(i + batch_size, num_examples)])
        yield features[batch_indices], labels[batch_indices]

关于线性回归的训练过程,主要是:

  • 初始化参数。
  • 设定训练轮数,批量大小等超参数;
  • 通过sgd对损失函数loss进行计算使得参数权重不断更新。

要注意在梯度下降的计算时可以在学习率或者梯度任意一处除以样本量(或者批量大小)n,来维持计算始终处于一个scale上即可。

softmax回归

softmax是一个多元分类模型,对每个类给出一个置信度,n个类就输出n个元素,和为1。主要想法是对某一个类给出远大于其他类的预测概率。在softmax操作中,需要对每个样本的每一行进行规范化,来实现输出是一个概率。 $$

\mathrm{softmax}(\mathbf{X}){ij} = \frac{\exp(\mathbf{X}{ij})}{\sum_k \exp(\mathbf{X}_{ik})}.

$$ $e$ 可以放大变化,同时也能将logits转化为正数,但通过 $e$ 来直接进行操作可能出现数据上溢

以交叉熵作为衡量预测概率和真实概率的损失, \(l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_i y_i \log \hat{y}_i = - \log \hat{y}_y\) 其梯度就是真实概率和预测概率之间的区别: \(\partial_{o_i} l(\mathbf{y}, \hat{\mathbf{y}}) = \text{softmax}(\mathbf{o})_i - y_i\) 梯度下降时就是让两个预测概率更加接近。

softmax分类时使用的交叉熵损失计算的数学直观,精妙地让损失函数只关注正确的类别有没有分类对,所以最终损失函数也被简化为了一个简单的公式,$L_i = -\log(0.13)$ 。

损失函数的选择,L2 loss就是前面的均方损失,L1 loss则为绝对值误差。

可以发现,L2 Loss会随着预测和真实值距离的变远而变得更大,因此梯度也会变得更大。但当离得比较远的时候其实也并不希望用那么大的梯度来更新,L1 Loss则在每个地方都保持了同样的梯度,更加稳定。但是也存在问题,在趋近于0时,不平滑性会导致梯度会发生震荡,导致在预测和真实值接近时的不稳定。

结合这两个,给出一个新的损失函数:

这个结合了上两个的优点,较远处采用L1依然稳定下降,接近后采用L2,更加平滑稳定。

softmax关注于分类正确的数量占总数的占比,我们可以将每扫完一个batch后的accuracy存下来然后观察训练情况。

感知机与线性分类器

感知机输出1或-1来进行二分类, \(o = \sigma (\langle \mathbf{w}, \mathbf{x} \rangle + b) \quad \sigma(x) = \begin{cases} 1 & \text{if } x > 0 \\ -1 & \text{otherwise} \end{cases}\)

其实也是当分类错误时,$-y \langle \mathbf{w}, \mathbf{x} \rangle<0$ ,损失函数>0(有max的存在),直到全部正确才=0。

感知机的收敛时确定的,并且类似SVM有个margin

但是感知机无法拟合XOR异或函数,因为其只能进行线性分割。

为了解决XOR的拟合问题,需要采用多层感知机,学习两层后将两层连接(逻辑上是取交集)。形成了一个单隐藏层的多层感知机(MLP)

多层感知机通过隐藏层非线性的激活函数实现了非线性的模型。 如果激活函数仍然是线性的,最后累加起来仍旧是一个线性的,又变成单层感知机了。 所以激活函数可以用来避免层数的塌陷

  • 最经典的激活函数是sigmoid函数,是一种软的阶跃函数;
  • Tanh激活函数和sigmoid很像,但将输入投影到-1到1;
  • 以及ReLU激活函数,x大于0则为x线性输出:$ReLU(x)=max(x,0)$ ;ReLU好处是算的很快,不用做那么多指数运算。并且这几个激活函数事实上相差不大。

增加更多的非线性性能给模型带来比线性分类器更强的分类性能,带来非线性性的组件即激活函数。如果我们没有激活函数的作用,再多层的网络最后也会变成一层,如下图: $x \in \mathbb{R}^D,\quad W_1 \in \mathbb{R}^{H \times D},\quad W_2 \in \mathbb{R}^{C \times H}$

激活函数有很多种,ReLU是效果比较好的一种,前面也讲到过。

如果要处理多元分类,加上softmax即可。隐藏层数和隐藏层大小是感知机中的超参数。

在写单隐藏层感知机时,需要注意输入和输出的维度:

1
2
3
4
5
6
7
8
9
10
num_inputs, num_outputs, num_hiddens = 784, 10, 256

W1 = nn.Parameter(torch.randn(
    num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
    num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))

params = [W1, b1, W2, b2]

第一层的输出应当满足隐藏层的输入,如果这样做的话,后续的矩阵乘法应该是 $\mathbf{x}\mathbf{W}$ 才能满足数学上的要求。事实上不同的写法之间只是转置的关系,了解一下即可。

K则交叉验证

在数据不够时,通常采用这个方法来进行训练和验证。 能做到尽可能使用数据集。

给定一个模型种类,有两个主要因素:参数数量和参数值的选择范围。

学过的机器学习理论,VC维,一个分类模型,最大的可被完美分类的数据集大小。例如N维感知机的VC维是N+1。但是在深度学习中,深度学习模型的VC维难以计算。

权重衰减和Dropout

限制参数值选择范围的硬性限制: \(\min \ell(\mathbf{w}, b) \quad \text{subject to} \quad \|\mathbf{w}\|^2 \leq \theta\) 通常使用软性限制,即加入正则化项: \(\min \ell(\mathbf{w}, b) + \frac{\lambda}{2}\|\mathbf{w}\|^2\) 通过控制 $\lambda$ 来控制对模型的惩罚力度,进而控制模型的复杂度,这部分也是机器学习中学过的。

那么考虑这个 $\lambda$ 并更新我们的梯度计算公式, \(\frac{\partial}{\partial \mathbf{w}} \left( \ell(\mathbf{w}, b) + \frac{\lambda}{2} \|\mathbf{w}\|^2 \right) = \frac{\partial \ell(\mathbf{w}, b)}{\partial \mathbf{w}} + \lambda \mathbf{w}\) 移项,得到 \(\mathbf{w}_{t+1} = (1 - \eta \lambda) \mathbf{w}_t - \eta \frac{\partial \ell(\mathbf{w}_t, b_t)}{\partial \mathbf{w}_t}\) 在每次更新梯度的时候我们对 $\mathbf{w}_t$ 前面添加了一个 $\eta \lambda$ (通常小于1)这就是权重衰退

丢弃法(Dropout)从效果上来说可以看作一个正则项,只在训练时起到作用,并且是在层与层之间发挥作用。

我们对于输入加入的噪音应该是无偏差的。即: \(\mathbf{E}[\mathbf{x}'] = \mathbf{x}\) \(x'_i = \begin{cases} 0 & \text{with probability } p \\ \frac{x_i}{1-p} & \text{otherwise} \end{cases}\) 丢弃法通常将多层感知机的一些隐藏层输出项置零来起到控制模型复杂度的作用,是否丢弃取决于dropout的概率,也是一个超参数。实现逻辑还是很简单的,看看代码就很清楚。

数值稳定性以及如何提高

在神经网络中,通常需要求多层的梯度并考虑激活函数,但是这样大量的矩阵计算会导致梯度在几次之后出现极大的波动,即梯度爆炸或者梯度消失 因此梯度爆炸会带来两个主要的问题:

  • 一个是值超出值域(16位浮点数的数值区间其实不大)
  • 另一个就是对于学习率会及其敏感,合适的学习率被限制在及其有限的一个空间中。因此可能需要在训练过程中动态调整学习率。

像sigmoid函数则会产生梯度消失的问题,可以发现在输入在向两端处走的过程中,sigmoid的梯度已经非常快速地趋近于0了。

因此同理,梯度消失也会带来几个问题:

  • 梯度容易变为无限趋近于0;
  • 进而导致学习率已经无法影响训练进展了;
  • 在反传时导致较深的网络其实已经相当于没有了,仅仅顶层训练的较好。

为了解决训练过程中梯度会出现的这类问题,一般会将乘法变为加法(ResNet、LSTM)、控制梯度在一个合理范围内并进行裁剪、将梯度归一化或者设置合理的初始化权重与激活函数

一种合理初始化权重的方法是将每一层的权重都考虑为相同期望相同方差的,这里我们先不考虑激活函数。

最终需要实现: \(n_{t-1}\gamma_t = 1 \text{ 和 } n_t\gamma_t = 1\) 其中 $n_{t-1}$ 是第 t-1 层输入的维度,我们无法控制。$\gamma_t$ 是第 $t$ 层权重的方差,可以控制。

但是上面这两个条件难以同时实现,因此我们采用一种折中的办法,即Xavier初始化 这样可以尽可能做到将每层权重的方差适配每一层的输入和输出维度来尽可能找到合适的权重形状。特别是对于那些网络层与层之间差别较大的网络。

现在再考虑激活函数的影响,理论分析上我们采用线性激活函数,虽然这在实际使用中不会用到。 最终我们可以发现激活函数需要满足:$f(x)=x$ 的函数条件。

分析下常用的激活函数,tanh和ReLU在接近0时(一般神经网络的权重都大概在0.几)都是可近似的,但是sigmoid需要进行调整 这也可以解释为什么sigmoid其实并没有Relu这么常用。

层和块

很多网络有很多非常复杂的全连接层,因此一个个研究这些层其实是一件非常复杂的事情。但是研究一整个模型又是很宏观且抽象的,那么可以考虑一个块,包含有一个或多个层来进行解释。

类似于cpp中的类和继承关系,我们可以通过嵌套、继承或者自己重写等方式来进行更加灵活的自定义。具体可以看代码实现,很容易看出来其中不同块(module)之间的关系。、

比如这个就是进行了嵌套,当然还有很多其他的内容例如通过init函数来进行这个类示例的初始化,并且通过参数中的 nn.Module 继承了原本的一个类。

除此之外每个Module都有一个_modules属性,帮助这个块在这个字典中查找需要初始化的参数。

相关的参数我们也可以通过层和块之间来进行访问,并且也能使我们的模型流程更加清晰。

这里要注意我们在进行参数初始化的时候不能把权重全部初始化为同一个常数,比如全部1或者0。因为这样的话一个 nn.Linear(4,8) ,对于同一个隐藏层中的 8 个神经元,它们接收到的输入是完全一样的,权重也完全一样。这意味着这 8 个神经元产生的输出也将完全相同。称为对称性无法被打破,进而也会影响后面计算梯度时的反向传播。

一般我们不会将一整个网络存下来,存储的都是权重,通过 net.state_dict() 函数来存储权重等参数信息。

关于GPU和计算设备

我们在使用GPU或者是CPU进行运算时,通常需要保证全部运算的量都在同一个设备上,比如全部在cpu或者全部在GPU0而不是GPU1上,因为将数据信息在不不同设备之间进行移动是一件非常耗时的事情,特别是对于将数据从GPU挪到CPU上时。

This post is licensed under CC BY 4.0 by the author.