0%

学习提示

第二门课的知识点比较分散,开始展示每周的笔记之前,我会先梳理一下每周涉及的知识。

这一周会先介绍改进机器学习模型的基本方法。为了介绍这项知识,我们会学习两个新的概念:数据集的划分、偏差与方差问题。知道这两个概念后,我们就能够诊断当前机器学习模型存在的问题,进而找出改进的方法。

之后,我们会针对“高方差问题”,学习一系列解决此问题的方法。这些方法成为“正则化方法”。这周介绍的正则化方法有:添加正则化项、dropout、数据增强、提前停止。

最后,我们会学习几项和神经网络相关的技术。我们会学习用于加速训练的输入归一化,用于防止梯度计算出现问题的参数带权初始化,以及用于程序调试的梯度检查。

课堂笔记

数据集的划分:训练集/开发集/测试集

在使用机器学习的数据集时,我们一般把数据集分成三份:训练集、开发集、测试集。

机器学习是比深度学习的父集,表示一个更大的人工智能算法的集合。

开发集(Development Set)另一种常见的称呼是验证集(Validation Set),即保留交叉验证(Hold-out Cross Validation)。

三种数据集的定义

它们三者的区别如下:

训练集 开发集 测试集
用于优化参数
训练时可见?
最终测试时可见?

训练集就是令模型去拟合的数据。对于神经网络来说,我们把某类数据集输入进网络,之后用反向传播来优化网络的参数。这个过程中用的数据集就是训练集。

开发集是我们在训练时调整超参数时用到的数据集。我们会测试不同的超参数,看看模型在开发集上的性能,并选择令模型在开发集上最优的一组超参数。

测试集是我们最终用来评估模型的数据集。当模型在测试集上评测时,我们的模型已经不允许修改了。我们一般把模型在测试集上的评测结果作为模型的性能评估标准。

在我们之前实现的小猫分类项目中,准确来说,我们使用的不叫测试集,而叫做开发集,因为我们是根据那个”testing set”优化网络超参数的。

有人把训练集比作上课,开发集比作作业,测试集比作考试。如果你理解了这三个数据集的原理,会发现这个比喻还是挺贴切的。事实上,由于测试集不参与训练,一个机器学习项目可以没有测试集,就像我们哪怕不经过考试,也可以学到知识一样。

人们很容易混淆开发集/测试集。很多论文甚至把开发集作为最终的性能评估结果。但是很多时候审稿人对这些细节并不在意。作为有操守的研究者,应该严肃地区分开发集与测试集。

通过划分数据得到训练/测试集

在前一个机器学习纪元,人们通常会拿到一批数据,按7:3的比例划分训练集/测试集(对于没有超参数要调的模型),或者按6:2:2的比例划分训练集/开发集/测试集。

而在深度学习时代,数据量大大增加。实际上,开发集和测试集的目的都是评估模型,而评估模型所需的数据没有训练需要得那么多。所以,当整体的数据规模达到百万级,甚至更多时,我们只需要各取10000组数据作为开发集和测试集即可。

收集来自不同分布的数据集

除了从同一批数据中划分出不同的数据集,还有另一种得到训练集、测试集的方式——从不同分布中收集数据集。

分布是统计学里的概念,这里可以理解成不同来源,内容的“平均值”差别很大的数据。

比如,假如我们要为某个小猫分类器收集小猫的图片,我们的训练图片可以是来自互联网,而开发和验证的数据来自用户用收集拍摄的图片。

注意,由于开发集和验证集都是用来评估的,它们应该来自同一个分布。

偏差与方差

机器学习中,我们的模型会出现高偏差或/和高误差的问题。我们需要设法判断我们的模型是否有这些问题。

偏差(bias)与方差(variance)是统计学里的概念,前者表示一组数据离期待的平均值的差距,后者表示数据的离散程度。

试想一个射击运动员在打靶。偏差与打靶的总分数有关,因为总分越高,意味着每次射击都很靠近靶心;方差与选手的发挥稳定性有关,比如一个不稳定的选手可能一次9环,一次6环。

高偏差意味着模型总是不能得到很好的结果,高方差意味着模型不能很好地在所有数据集上取得好的结果(即只能在某些特定数据集上表现较好,在其他数据集上都表现较差)。

我们把高偏差的情况叫做“欠拟合”(可能模型还没有训练完,所以表现不够好),把高方差的情况叫做“过拟合”(模型在训练集上训练过头了,结果模型只能在训练集上有很好的表现,在其他数据集上表现偶读不好)。

让我们看课件里的一个点集分类的例子:

上图显示了欠拟合、“恰好”、过拟合这三种情况。

对于欠拟合的情况来说,一条直线并不足以把两类点分开,这个模型的整体表现较差。

对于过拟合的情况来说,模型过分追求训练集上的正确,结果产生了一条很奇怪的曲线。由于训练数据是有噪声(数据的标签不完全正确)的,这样的模型在真正的测试上可能表现不佳。

让我们人类来划分的话,最有可能给出的是中间那种划分结果。在这个模型中,虽然有些训练集中的点划分错了,但我们会认为这个模型在绝大多数数据上更合适。当我们用更多的测试数据来测试这个模型时,中间那幅图的测试结果肯定是这三种中最好的。

要判断机器学习模型是否存在高偏差或高方差的现象,可以去观察模型的训练集误差和开发集误差。以下是一个判断示例:

情况 1 2 3 4
训练集误差 1% 15% 0.5% 15%
开发集误差 11% 16% 1% 30%
诊断结果 高方差 高偏差 低误差、低方差 高误差、高方差

也就是说,如果开发集和训练集的表现差很多,就说明是高方差;如果训练集上的表现都很差,就是高偏差。

上面这些结论建立在最优误差——贝叶斯误差(Beyas Error)是0%的基础上下的判断。很多时候,仅通过输入数据中的信息,是不足以下判断的。比如告诉一个人是长头发,虽然这个人大概率是女生,但我们没有100%的把握说这是女生。如果我们知道人群中留长发的90%是女生,10%是男生,那么在这个“长头发分辨性别”的任务里的贝叶斯误差就是10%。

假如上面那个任务的贝叶斯误差是15%,那么我们认为情况2也是一个低误差的情况,因为它几乎做到了最优的准确率。

改进机器学习的基本方法

通过上一节介绍的看训练误差、测试误差的方式,我们能够诊断出我们的模型当前是否存在高偏差或高误差的问题。这一节我们来讨论如何解决这些问题。

首先检查高偏差问题。如果模型存在高偏差,则应该尝试使用更复杂的网络更多增加训练时间

确保模型没有高偏差问题后,才应该开始检查模型的方差。如果模型存在高方差,则应该增加数据使用正则化

此外,使用更合理的网络架构,往往对降低误差和方差都有效。

正则化 (Regularization)

其实正则化的意思就是“为防止过拟合而添加额外信息的过程”。在机器学习中,一种正则化方法是给损失函数添加一些与参数有关的额外项,以调整参数在梯度下降中的更新过程。正则化的数学原理我们会在下一节里学习,这一节先认识一下正则化是怎么操作的。

先看一下,对于简单的逻辑回归,我们应该怎么加正则化项。

原来,逻辑回归的损失函数是:

现在我们给它加一个和参数$w$有关的项

最右边那个 $\frac{\lambda}{2m}||w||^2_2$ 就是额外加进来的正则项。其中$\lambda$是一个可调的超参数,$||w||^2_2$表示计算向量$w$的l2范数,即:

也就是说,某向量的l2范数就是它所有分量平方再求和。

类似地,其实向量也有1范数,也可以用来做正则化:

1范数就是向量所有分量取绝对值再求和。

使用1范数做正则化会导致参数中出现很多0。人们还是倾向使用l2范数做正则化。

看到这里,大家或许会有问题:$b$也是逻辑回归的参数,为什么$w$有正则项,$b$就没有?实际上,要给$b$加正则项也可以。但是在大多数情况下,参数$w$的数量远多于$b$, 和$b$相关的正则项几乎不会影响到最终的损失函数。为了让整个过程更简洁一些,$b$的正则项就被省略了。(其实就是程序员们偷懒了,顺便让计算机也偷个懒)

当情况推广到神经网络时,添加正则项的方法是类似的,只不过参数$W$变成了矩阵而已。对应的正则项如下:

其中,

这种矩阵范数叫做Frobenius范数,叫它F-范数就行了。

如之前的文章所述,对于梯度下降算法来说,定义损失函数的根本目的是为了对参数求导。当参数$W$在损失函数里多了一项后,它的导数会有怎样的变化呢?

对于某参数向量$w$来说,其实它的导数就多了一项:

大家知道为什么正则项分母里有一个2了吗?没错,这是为了让求出来的导数更简洁一点。反正有超参数$\lambda$,分母多个2少个2没有任何区别。

最终,参数向量$w$会按如下的方式更新:

仔细一看,其实相较之前的梯度更新公式,只是$w$的系数从$1$变成了$1-\frac{\alpha\lambda}{m}$。因此,用l2范数做正则化的方法会被称为 “权重衰减(Weight Decay)” ,$\lambda$在某些编程框架中直接就被叫做weight decay

为什么正则项能减少方差

回忆前面见过的“高方差”的拟合曲线:

这个曲线之所以能够那么精确地过拟合,是因为这个曲线的参数过多。如果这个曲线的参数少一点,那么它就不会有那么复杂的形状,过拟合现象也会得到缓解。

也就是说,如果神经网络简单一点,每个参数对网络的影响小一点,那么网络就更难去过拟合那些极端的数据。

添加了正则项后,网络的参数都受到了一定的“惩罚”。因此,参数会倾向于变得更小,从而产生刚刚提到的减轻过拟合的效果。

Dropout (失活)

Dropout 怎么翻译都不好听,直接保持英文吧。

还有一种常用的正则化方法叫做 dropout,即随机使神经网络中的一些神经元“失活”。如下图所示:

我们可以令所有神经元在每轮训练中有50%的几率失活。在某轮训练中,神经网络的失活情况可能会像上图中下半部分所示:那些打叉的神经元不参与计算和,整个神经网络变得简单了许多。

在实现时,我们常常使用一种叫做”Inverted dropout”的实现方法。Inverted dropout 的思想是:对于神经网络的每一层,生成一个表示有哪些神经元失活的“失活矩阵”,再用这个矩阵去乘上这一层的激活输出(做乘法即令没有失效的激活保持原值,失效的激活取0)

其实现代码如下:

1
2
3
d = np.random.rand(a.shape[0], a.shape[1]) < keep_prob
a = a * d
a /= keep_prob

这段代码中,d是失活矩阵。该矩阵通过一个随机数矩阵和一个保留概率keep_prob做小于运算生成。np.random.rand可以生成一个矩阵,其中矩阵中每个数都会均匀地随机出现在0~1之间。这样,每个数小于keep_prob的概率都是keep_prob。比如keep_prob=0.8,那么每个神经元都有80%的几率得到保留,20%的几率被丢弃。

做完小于运算后,d其实是一个bool值矩阵。拿bool矩阵和一个普通矩阵做逐对乘法,就等于bool矩阵为True的地方取普通矩阵的原值,bool矩阵为False的地方取0。

最后,得到了丢弃掉某些神经元的激活输出a后,我们还要做一个操作a /= keep_prob。可以想象,如果我们丢掉了一些神经元,那么整个激活输出的“总和”的期望会变小。比如keep_prob为0.8,那么整个输出的大小都近似会变为原来的0.8倍。为了让输出的期望不变,我们要把激活输出除以keep_prob

如前文所强调的,dropout一次是对一层而言的。也就是说,每一层可以有不同的keep_prob

dropout可能对损失函数变化曲线产生影响。一般调试时,如果损失函数一直在降,就说明训练算法没什么问题。但是,加入dropout后,由于每次优化的参数不太一样了,损失函数可能不会单调递减。因此,为了调试神经网络,可以先关闭dropout。确定损失函数确实在下降后,再开启它。

由于在CV(计算机视觉)中,图像的输入规模都很大,数据不足而引起过拟合是一件常见的事。因此,dropout在CV中被广泛应用。

注意,dropout是一种训练策略。在测试的时候,不需要使用dropout。

和刚才一样,我们再来探讨一下为什么dropout能够生效。有了dropout,意味着神经网络的权重不能集中在部分神经元上,因为某个神经元随时都可能会失效。因此,神经网络的权重会更加平均。更加平均,意味着计算参数平方的l2范数会更小。也就是说,dropout令参数更平均,起到了和刚刚添加l2正则类似的效果。

其他正则化方法

  1. 数据增强

比如对于一幅图片,我们可以翻转、旋转、缩进,以生成“更多”的训练数据。

  1. 提前停止 (early stopping)

随着训练的进行,网络的损失函数可能越来越小,但开发集上的精度会越来越高。只是因为训练得越久,参数就会越来越大,即越来越倾向于过拟合。提早结束训练,能够让参数取到一个合适的值。

提前中止也有一些不好的地方。在机器学习中,训练模型可以分成两部分:让损失函数更小、防止模型过拟合。我们通常会对这两部分独立地进行优化,即控制优化方法不变,改变正则化方法;或者改变减小梯度的算法,保证模型不进行任何正则化操作。而提前中止实际上混淆了减小损失函数和防止模型过拟合这两件事,不利于采取更多的调试策略。

独立地看待问题的两个变量,这种方法叫做 “正交化”。这种控制变量的思想在科研、编程,甚至是处理人生中各种各样的问题时都很适用。

输入归一化(Normalization)

参考网上的翻译,我把 Normalization 翻译为归一化,Standardization 翻译成标准化。其实这两个中文翻译经常会混着用,翻译上的区别不用太在意。

我们应该尽可能让输入向量的每一个分量都满足标准正态分布。如果你对数学不熟,我们可以来看一个例子:

假设我们每个输出张量长度为2,即有两个分量:$x_1, x_2$。我们可以认为每个输入向量就是一个二维平面上的点。统计完了所有样本,我们或许可以发现所有样本的$x_1$位于[0, 5]这个区间,$x_2$位于[0, 3]这个区间,两个区间长度不一。而且,数据在$x_1$上比较分散,$x_2$上比较靠拢。这个训练样本显得非常凌乱。

如果我们让输入归一化,使输入向量的每一个分量都满足了正态分布,难么这些数据可能会长得这样:

这样,数据分布的区间不仅长度相同,而且离散的程度也相同了。

归一化可以通过以下方式实现:

注意,上式中我们计算方差时没有减均值,这是因为第二步更新的时候均值已经被减掉了。

简单概括这个数学公式,就是“减均值,除方差”。

如果输入数据在各个分量上更加均匀,梯度下降的优化会更加便捷。

这里直接记住这个结论,不用过于在意它的数学原理。一种比较直观的解释是:如果分量大小不一,则参数w的每个分量的“作用”也会大小不一。如果w的每个分量都按差不多的“步伐”进行更新,那些“影响力更大”的w分量就会更新得过头,而“影响力更小”的w分量就更新得不足。这样,梯度下降法要耗费更多步才能找到最优值。

梯度爆炸/弥散

如果一个神经网络的层数过深,可能会出现梯度极大或极小的情况,让我们看看这是怎么回事。

假设我们有上图这样一个“很深”的神经网络。我们取消所有的激活函数(即$g(x)=x$),取消所有参数$b$(即$b=0$),那么这个网络的公式就是

其中$W^NaN…W^1$都是2x2的矩阵。我们不妨假设它们都是同样的矩阵,那么上式可以写成

如果$W’$长这个样子:

那么经过$L-1$次矩阵乘法后,这个矩阵就变成这个样子:

由于这里的数值是随着$L$成指数增长的,$L$稍微取一个大一点的值,最后算出来的$A$就会特别大。回顾一下前面的知识,最后一层的$dZ=A-Y$,而$dW$又是和$dZ$相关的。最后的$A$很大,会导致所有算出来的梯度都很大。

这里要批评一下这门课。课堂里有一个地方讲得不够清楚:为什么$A$很大,参数的梯度$dW$就很大。课堂里只是带了一句,说可以用类似的方法得出$dW$的增长规律和$A$类似。但这里漏了一条逻辑链:算梯度的时候,$A$和$dW$有关联性($dZ$和$A$有关,$dW$和$dZ$有关)。直观上来看,$A$很大,不能推出梯度就很大。中间还是欠缺了一步逻辑推理的。学东西和看东西一定要养成批判性思维,考据每一步推理的合理性。

同理,如果矩阵里的数不是1.5,而是0.5,那么整个公式的数值就会指数级下降,从而导致梯度近乎“消失”。

梯度问题的解决方法——加权初始化

推荐一篇讲这个知识点的英文文章:https://towardsdatascience.com/weight-initialization-in-neural-networks-a-journey-from-the-basics-to-kaiming-954fb9b47c79.

刚刚我们讲到,梯度会爆炸或者弥散,本质原因是矩阵$W$的“大小”大于了1或者小于了1,从而使最后的计算结果过大或过小。但反过来想,如果我们令每一层的输出$A^{[l]}$的“大小”都在1附近,那么是不是就不会有梯度指数级变化的问题了呢?

让我们来看看该如何让每层输出$A^{[l]}$都保持一个合适的值。我们考察

这个简单的网络。从直觉上看,如果$n$越大,则公式里的项越多,$Z$也越大。事实上,用统计学知识计算过后,能知道:若$w_i$都是满足标准正态分布的,则$Z$的方差是$n$。我们不希望$Z$的值太大或太小,希望能通过修改$w_i$的大小,让$Z$的方差尽可能等于1。

为了做到这一点,我们可以在$w$的初始化方法上做一点文章。我们可以改变$w$的方差,以改变$Z$的方差。其实,我们只要令$w$的方差为$\frac{1}{n}$就行了。用代码表示就是这样的:

1
W_l = np.random.randn(shape) * np.sqrt(1 / n[l-1])

别忘了哦,这里n[l-1]是第l层参数矩阵W_l的长度,即每个参数向量$w$的长度。

但由于每一层的输入不是$Z$,而是$A=g(Z)$,我们在算方差时还要考虑到激活函数$g$的影响。

经 Kaiming He 等人的研究,使用 Relu 时,初始化的权重用np.sqrt(2/ n[l-1])比较好,即用下面的代码:

1
W_l = np.random.randn(shape) * np.sqrt(2 / n[l-1])

对于 tanh 函数,令权重为 np.sqrt(1 / n[l-1])就行,这叫做 Xavier Initialization。还有研究表明用 np.sqrt(2 / (n[l-1]+n[l]))也行。

总结一下,为了缓解梯度爆炸或梯度弥散的问题,可以对参数使用加权初始化。只需要初始化时多乘一个小系数,这个问题就能很大程度上有所缓解。

梯度检查

进行深度学习编程时,梯度计算是比较容易出BUG的地方。我们可以用一种简单的方法来近似估计一个函数的导数,并将其与我们算出来的导数做一个对比,看看我们的导数计算函数有没有写错。

导数估计公式如下:

这个式子随$\epsilon$收敛得较块,准确来说:

当$lim_{\epsilon \to 0}$时,上面(2)式的收敛速度是$O(\epsilon)$,(3)式的收敛速度是$O(\epsilon^2)$。选用(3)式估计导数是一个更好的选择。

我们可以利用上面的公式调试深度学习中的梯度计算。其步骤如下:

  1. 把所有参数$W^{[1]}, b^{[1]}…$ reshape 成向量,再把所有向量拼接(concatenate) 成一个新向量$\theta$。
  2. 现在,我们有损失函数$J(\theta)$和导数$d\theta$。
  3. 对于某一个参数$\theta_i$,计算其导数估计值:
  1. 比较$\hat{d_{\theta_i}}, d_{\theta_i}$,计算误差值:
  1. 遍历所有$\theta_i$,做这个检查。

一般可以令$\epsilon=10^{-7}$。如果error在$10^{-7}$这个量级,则说明导数计算得没什么问题。$10^{-5}$可能要注意一下,而$10^{-3}$则大概率说明这里的导数算得有问题。

使用此梯度检查法时,有一些小提示:

  • 不要每次训练的都用,只在训练前调试用。

梯度检查确实很慢,计算复杂度是$\Omega(|\theta|^2)$(这里没有用大O标记,因为复杂度的下界是那个值,而不是上界)(这个复杂度是$|\theta|$乘上算一遍推理的运算量得来的。推理至少遍历每个参数一遍,所以推理的复杂度是$\Omega(|\theta|)$)。

  • 如果梯度检查出现了问题,尝试debug具体出错的参数。

  • 别忘记损失函数中的正则化项。

  • 无法调试 dropout.

  • 有时候,当$W, b$过大时导数的计算才会出现较大的误差。可以尝试先训练几轮网络,等参数大了,再做一次梯度检查。

总结

这堂课的信息量十分大。让我们总结一下:

  • 数据集划分
    • 训练集/开发集/测试集的意义
    • 怎么去根据数据规模划分不同的数据集
  • 偏差与方差
    • 如何分辨高偏差与高方差问题
    • 高偏差与高方差问题的一般解决思路
  • 正则化
    • 权重衰减
    • dropout
    • 数据增强
    • 提前停止
  • 梯度问题
    • 梯度问题的产生原因
    • 缓解梯度问题的方法
  • 梯度检查的实现

这堂课中,正则化参数带权初始化是两个很重要的话题,展开来的话有很多东西要学。过段时间,我会在课堂内容的基础上,对这些知识进行拓展介绍。

代码实战

在本周的代码实战中,我们将继续以点集分类任务为例,完成参数初始化正则化两项任务。

参数初始化

项目地址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/Initialization

在参数初始化问题中,我们要探究不同初始化方法对梯度更新的影响。假设我们有下面这样一个点集分类数据集:

我们分别用下面三种方法去初始化参数:

1
2
3
4
5
6
7
8
9
10
if initialization == 'zeros':
self.W.append(np.zeros((neuron_cnt[i + 1], neuron_cnt[i])))
elif initialization == 'random':
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 5)
elif initialization == 'he':
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) *
np.sqrt(2 / neuron_cnt[i]))
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))

如果使用0初始化的话,就会出现之前学过的“参数对称性”问题。这个网络几乎学不到任何东西:

如果用比较大的值初始化的话,网络的梯度一直会很高,半天降不下来,学习速度极慢:

最后,我们使用比较高端的He Initialization.网络能够顺利学到东西了。

正则化

项目地址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/Regularization

正则化要解决的是过拟合。为了“迫使”网络产生过拟合,我“精心”构造一个点集分类数据集:

在这个分类任务中,比较理想的分类结果是一条直线。但是,由于表示噪声的蓝点比较多,网络可能会过拟合训练数据。

在这项实验中,我们将分别测试在“不使用正则化”、“使用正则项”、“使用dropout”这三种配置下网络的表现情况。

如我们所预计地,不使用正则化策略的网络会过拟合训练数据:

之后,我们按照公式,尝试给网络添加正则化项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def gradient_descent(self, learning_rate):
for i in range(self.num_layer):
if self.weight_decay:
LAMBDA = 4
self.W[i] = (1 - learning_rate * LAMBDA / self.m
) * self.W[i] - learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]
else:
self.W[i] -= learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]

def loss(self, Y: np.ndarray, Y_hat: np.ndarray) -> np.ndarray:
if self.weight_decay:
LAMBDA = 4
tot = np.mean(-(Y * np.log(Y_hat) + (1 - Y) * np.log(1 - Y_hat)))
for i in range(self.num_layer):
tot += np.sum(self.W[i] * self.W[i]) * LAMBDA / 2 / self.m
return tot
else:
return np.mean(-(Y * np.log(Y_hat) + (1 - Y) * np.log(1 - Y_hat)))

网络成功规避了过拟合。

接下来,我们来尝试使用dropout策略。在训练时,我们每层有50%的概率丢掉训练结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def forward(self, X, train_mode=True):
if train_mode:
self.m = X.shape[1]
A = X
self.A_cache[0] = A
for i in range(self.num_layer):
Z = np.dot(self.W[i], A) + self.b[i]
if i == self.num_layer - 1:
A = sigmoid(Z)
else:
A = get_activation_func(self.activation_func[i])(Z)
if train_mode and self.dropout and i < self.num_layer - 1:
keep_prob = 0.5
d = np.random.rand(*A.shape) < keep_prob
A = A * d / keep_prob
if train_mode:
self.Z_cache[i] = Z
self.A_cache[i + 1] = A

return A

同样,使用dropout后,我们也得到了一个比较满意的分类结果:

欢迎大家自行调试这两个项目~

经过前四周的学习,我们已经学完了《深度学习专项》的第一门课程《神经网络与深度学习》。让我们总结一下这几周学的知识,查缺补漏。

《神经网络与深度学习》知识回顾

概览

在有监督统计机器学习中,我们会得到一个训练集。训练集中的每一条训练样本输入输出组成。我们希望构建一个数学模型,使得该模型在学习了训练集中的规律后,能够建立起输入到输出的映射。

深度学习中,使用的数学模型是深度神经网络

神经网络一般可以由如下的计算图表示:

其中,每一个圆形的计算单元(又称神经元)一般表示$g(WX+b)$这一组计算。$W, b$是线性运算的参数,$g$是激活函数。

为了使神经网络学习到输入和正确输出的映射,我们要定义一个描述网络输出和正确输出之间差距的损失函数(即每个样本的网络输出与正确输出的误差函数的平均值),并最小化这个损失函数。这样,网络的“学习”就成为了一个优化问题。

为了对这个优化问题求解,通常的方法是梯度下降法,即通过求导,使每一个参数都沿着让损失函数减少最快的方向移动。

神经网络的结构

神经网络由输入层隐藏层输出层组成。计算神经网络的层数$L$时,我们只考虑隐藏层与输出层。

令$x^{(i)[j]}_k$表示某向量在第$i$个样本第$j$层的第$k$个分量。

若每层的神经元个数为$n^{[l]}$,特别地,令输入的通道数$n_x=n^{[0]}$,则每层参数的形状满足$W^{[l]}:(n^{[l]}, n^{[l-1]})$,$b^{[l]}:(n^{[l]}, 1)$。

常见的激活函数有sigmoid, tanh, relu, leaky_relu。一般隐藏层的激活函数$g^{[l]}(l < L)$用relu。对于二分类问题(输出为0或1),输出层的激活函数$g^{[L]}$应用sigmoid

神经网络的训练

  1. 初始化参数:随机初始化$W$并使其绝对值较小,用零初始化$b$。

重复执行以下步骤:

  1. 前向传播:直接运行神经网络,并缓存中间计算结果$A, Z$。

  2. 反向传播:倒着“运行”神经网络,根据求导链式法则,由网络输出求得每一个参数的导数。

  3. 梯度下降:对于每个参数$p = w^{[l]} or b^{[l]}$,用$p := p-\alpha dp$更新参数。其中$\alpha$叫学习率,表示参数更新的速度。

用numpy实现神经网络

  1. 把输入图片进行“压平”操作:
1
images = np.reshape(images, (-1))
  1. 初始化参数
1
2
W = np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.01
b = np.zeros((neuron_cnt[i + 1], 1))
  1. 前向传播
1
2
3
4
5
6
7
self.A_cache[0] = A
for i in range(self.num_layer):
Z = np.dot(self.W[i], A) + self.b[i]
A = get_activation_func(self.activation_func[i])(Z)
if train_mode:
self.Z_cache[i] = Z
self.A_cache[i + 1] = A
  1. 反向传播
1
2
3
4
5
6
7
8
for i in range(self.num_layer - 1, -1, -1):
dZ = dA * get_activation_de_func(self.activation_func[i])(
self.Z_cache[i])
dW = np.dot(dZ, self.A_cache[i].T) / self.m
db = np.mean(dZ, axis=1, keepdims=True)
dA = np.dot(self.W[i].T, dZ)
self.dW_cache[i] = dW
self.db_cache[i] = db
  1. 梯度下降
1
2
3
for i in range(self.num_layer):
self.W[i] -= learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]

第一阶段学习情况自评

学了几周,大家可能不太清楚自己现在的水平怎么样了。这里,我给大家提供了一个用于自我评价的标准,大家可以看看自己现在身处第几层。

Level 1 能谈论深度学习的程度

  • 知道深度学习能解决计算机视觉、自然语言处理等问题。
  • 能够说出“训练集”、“神经网络”等专有名词

Level 2 能调用深度学习框架的程度

  • 知道正向传播、反向传播、梯度下降的意义。
  • 虽然现在不会用代码实现学习算法,但通过后面的学习,能够用深度学习框架编写学习算法。

Level 3 掌握所有知识细节的程度

  • 反向传播的流程。
  • 为什么必须使用激活函数。
  • 为什么要随机初始化参数。
  • 正向传播时为什么要缓存,缓存的变量在反向传播时是怎么使用的。

Level 4 能从零开始实现一个分类器的程度

升级语音:别说是用numpy,就算是用纯C++,我也能造一个神经网络!

  • 掌握 numpy 的基本操作。
  • 能用 Python 编写一个神经网络框架。
  • 能用 numpy 实现神经网络的计算细节。
  • 能用 Python 实现读取数据集、输出精度等繁杂的操作。

第二阶段知识预览

《深度学习专项》第二门课的标题是《改进深度神经网络:调整超参数、正则化和优化》。从标题中也能看出,这门课会介绍广泛的改进深度神经网络性能的技术。具体来说,在三周的时间里,我们会学习:

  • 第一周:深度学习的实践层面
    • 训练集/开发集/测试集的划分
    • 偏差与方差
    • 机器学习的基础改进流程
    • 正则化
    • 输入归一化
    • 梯度问题与加权初始化
    • 梯度检查
  • 第二周:优化算法
    • 分批梯度下降
    • 更高级的梯度下降算法
    • 学习率衰减
  • 第三周:调整超参数、批归一化和编程框架
    • 调参策略
    • 批归一化
    • 多分类问题
    • 深度学习框架 TensorFlow

目录还是比较凌乱的,让我们具体看一下每项主要知识点的介绍:

  • 训练集/开发集/测试集的划分
    • 之前我们只把数据集划分成训练集和测试集两个部分。但实际上,我们还需要一个用于调试的“开发集”。
  • 偏差与方差
    • 机器学习模型的性能不够好,体现在高偏差和高方差两个方面。前者表示模型的描述能力不足,后者表示模型在训练集上过拟合。
    • 为了解决过拟合问题,我们要使用添加正则项、dropout等正则化方法。
  • 梯度问题
    • 在较深的神经网络中,数值运算结果可能会过大或过小,这会导致梯度爆炸或者梯度弥散。
    • 加权初始化可以解决这一问题。我们即将认识多种初始化参数的方法。
  • 优化梯度下降
    • 使用mini-batch:处理完部分训练数据后就执行梯度下降,而不用等处理完整个训练数据集。
    • 使用更高级的梯度下降算法,比如让梯度更平滑的momentum优化器,以及结合了多种算法的adam优化器。
    • 在训练一段时间后,减少学习率也能提高网络的收敛速度。
  • 调参策略
    • 在调试神经网络的超参数时,有一些超参数的优先级更高。我们应该按照优先级从高到低的顺序调参。
    • 在调参时,一种技巧是多次随机选取超参数,观察哪些配置下网络的表现最好。
  • 归一化
    • 对输入做归一化能够加速梯度下降。
    • 除了对输入做归一化外,我们还可以对每一层的输出做批归一化,这项技术能够让我们的网络更加健壮。
  • 多分类问题
    • 前几周我们一直关注的是二分类问题。我们将学习如何用类似的公式,把二分类问题推广到多分类问题。
  • 编程框架
    • 深度学习编程框架往往带有自动求导的功能,能够极大提升我们的开发效率。学完第二门课后,我们将一直使用TensorFlow来编程。
    • 我会顺便介绍所有编程任务的 PyTorch 等价实现。

可以看出,第二门课包含的内容非常多。甚至很多知识都只会在课堂上提一两句,得通过阅读原论文才能彻底学会这些知识。但是,这门课的知识都非常重要。学完了第二门课后,我们对于深度学习的理解能提升整整一个台阶。让我们做好准备,迎接下周的学习。


关注我社交媒体的人,肯定质疑我最近的行为:你最近怎么发文章只注意数量不注意质量啊?怎么一篇文章可以拆开来发好几遍啊?

这你就不懂了。我最近想看看,这些社交平台究竟有多捞:我提供了这么优质的文字内容,我看你们会不会去认真推广,会不会发掘优质内容。结果,我发现这些平台确实都很捞,根本不去好好推送的。没办法,我只好先写一批质量中等的文章,增加发文的次数。我倒要看看这些平台什么时候能给我符合我文章质量的关注量。文章看的人多,我才有继续创作更优质内容的动力。

今天花半小时看懂了“Image Style Transfer Using Convolutional Neural Networks Leon”这篇论文,又花半小时看懂了其 PyTorch 实现,最后用半个下午自己实现了一下这篇工作。现在晚上了,顺便给大家分享一手。

文章会一边介绍风格迁移的原理,一边展示部分代码。完整的代码会在附录里给出。

基于 CNN 的图像风格迁移

什么是风格迁移

我们都知道,每一幅画,都可以看成「内容」与「画风」的组合。

比如名画《呐喊》画了一个张着嘴巴的人,这是一种表现主义的画风。

还有梵高这幅《星夜》,非常有个人风格的一幅夜景。

再比如这幅画,一个二次元画风的少女。

最后展示的是一个帅哥,这是一张写实的照片。

所谓风格迁移,就是把一张图片的风格,嵌入到另一张图片的内容里,形成一张新的图片:

如上图所示,左上角的A是一幅真实的照片,BCD分别是把其他几幅画作的风格迁移到原图中形成的新图片。

究竟是什么技术能够实现这么神奇的「风格迁移」效果呢?别急,让我们从几个简单的例子慢慢学起。

复制一幅图片

如果你想复制一幅图片,你会怎么做?

在Windows上,你可以打开画图软件,点击左上角的选择框,把要复制的图片框起来。Ctrl+C、Ctrl+V,就能轻松完成图像复制。

但是,我觉得的这种方法太简单了,不能体现出我们这些学过数学的人的智慧。我打算用一个更高端的方法。

我把复制图像的任务,看成一个数学上的优化问题。已知源图像S,我要生成一个目标图像T,使得二者均方误差MSE(S-T)最小。这样,一个生成图像的问题,就变成求最优的T的优化问题。

对于这个问题,我们可以随机初始化一张图像T,然后对上面那个优化目标做梯度下降。几轮下来,我们就能求出最优的T——一幅和源图像S一模一样的目标图像。

这段逻辑可以PyTorch实现:

假设我们通过read_image函数读取了一个图片img,且把图片预处理成了[1, 3, H, W]的格式。

1
source_img = read_image('dldemos/StyleTransfer/picasso.jpg')

我们可以随机初始化一个[1, 3, H, W]大小的图片。由于这张图片是我们的优化对象,所以我们令input_img.requires_grad_(True),这样这张图片就可以被PyTorch自动优化了。

1
2
input_img = torch.randn(1, 3, *img_size)
input_img.requires_grad_(True)

之后,我们使用PyTorch的优化器LBFGS,并按照优化器的要求传入被优化参数。(这是这篇论文的作者推荐的优化器~)

1
optimizer = optim.LBFGS([input_img])

一切变量准备就绪后,我们可以执行梯度下降了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
steps = 0
while steps <= 10:

def closure():
global steps
optimizer.zero_grad()
loss = F.mse_loss(input_img, source_img)
loss.backward()
steps += 1
if steps % 5 == 0:
print(f"Step {steps}:")
print(f"Loss: {loss}")

return loss

optimizer.step(closure)

这段代码有一点要注意:由于LBFGS执行上的特殊性,我们要把执行梯度下降的代码封装成一个闭包(closure,即一个临时定义的函数),并把这个闭包传给optimizer.step

执行上面的代码进行梯度下降后,这个优化问题很快就能得到收敛。优化结束后,假设我们写好了一个后处理图片的函数save_image,我们可以这样保存它:

1
save_image(input_img, 'work_dirs/output.jpg')

理论上,这幅图片会和我们的源图像img一模一样。

大家看到这里,肯定一肚子疑惑:为什么要用这么复杂的方式去复制图像啊?就好像告诉你x=2,拿优化算法求和x完全相等的y一样。这不直接令y=2就行了吗?别急,让我们再看下去。

拟合神经网络的输出

刚才我们求解目标图像T的过程,其实可以看成是拟合T的某项特征S特征的过程。只不过,我们使用的是像素值这个最基本的特征。假如我们去拟合更特别的一些特征,会发生什么事呢?

Gatys 等科学家发现,如果用预训练VGG模型不同层的卷积输出作为拟合特征,则可以拟合出不同的图像:

如果你对预训练VGG模型不熟,也不用担心。VGG是一个包含很多卷积层的神经网络模型。所谓预训练VGG模型,就是在图像分类数据集上训练过的VGG模型。经过了预训练后,VGG模型的各个卷积层都能提取出图像的一些特征,尽管这些特征是我们人类无法理解的。

上图中,越靠右边的图像,是用越深的卷积层特征进行特征拟合恢复出来的图像。从这些图像恢复结果可以看出,更深的特征只会保留图像的内容(形状),而难以保留图像的纹理(天空的颜色、房子的颜色)。

看到这,大家可能有一些疑惑:这些图片具体是怎么拟合出来的呢?让我们和刚刚一样,详细地看一看这一图像生成过程。

假设我们想生成上面的图c,即第三个卷积层的拟合结果。我们已经得到了模型model_conv123,其包含了预训练VGG里的前三个卷积层。我们可以设立以下的优化目标:

1
2
3
source_feature = model_conv123(source_img)
input_feature = model_conv123(input_img)
# minimize MSE(source_feature, input_feature)

在实现时,我们只要稍微修改一下开始的代码即可。

首先,我们可以预处理出源图像的特征。注意,这里我们要用source_feature.detach()来把source_feature从计算图中取出,防止源图像被PyTorch自动更新。

1
2
source_img = read_image('dldemos/StyleTransfer/picasso.jpg')
source_feature = model_conv123(source_img).detach()

之后,我们可以用类似的方法做梯度下降:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
steps = 0
while steps <= 50:

def closure():
global steps
optimizer.zero_grad()
input_feature = model_conv123(input_img)
loss = F.mse_loss(input_feature, source_feature)
loss.backward()
steps += 1
if steps % 5 == 0:
print(f"Step {steps}:")
print(f"Loss: {loss}")

return loss

optimizer.step(closure)

看到没,我们刚刚这种利用优化问题生成目标图像的方法并不愚蠢,只是一开始大材小用了而已。通过这种方法,我们可以生成一幅拟合了源图像在神经网络中的深层特征的目标图像。那么,怎么利用这种方法完成风格迁移呢?

风格+内容=风格迁移

Gatys 等科学家发现,不仅是卷积结果可以当作拟合特征,VGG的一些其他中间结果也可以作为拟合特征。受到之前用CNN做纹理生成的工作[2]的启发,他们发现用卷积结果的Gram矩阵作为拟合特征可以得到另一种图像生成效果:

上图中,右边a-e是用VGG不同卷积结果的Gram矩阵作为拟合特征,得到的对左图的拟合图像。可以看出,用这种特征来拟合的话,生成图像会失去原图的内容(比如星星和物体的位置完全变了),但是会保持图像的整体风格。

这里稍微提一下Gram矩阵的计算方法。Gram矩阵定义在两个特征的矩阵F_1, F_2上。其中,每个特征矩阵F是VGG某层的卷积输出张量F_conv(shape: [n, h, w])reshape成一个矩阵F (shape: [n, h * w])的结果。Gram矩阵,就是两个特征矩阵F_1, F_2的内积,即F_1每个通道的特征向量和F_2每个通道的特征向量的相似度构成的矩阵。我们这里假设F_1=F_2,即对某个卷积特征自身生成Gram矩阵。这段逻辑用代码实现如下:

1
2
3
4
5
6
7
def gram(x: torch.Tensor):
# x 是VGG卷积层的输出张量
n, c, h, w = x.shape

features = x.reshape(n * c, h * w)
features = torch.mm(features, features.T)
return features

Gram矩阵表示的是通道之间的相似性,与位置无关。因此,Gram矩阵是一种具有空间不变性(spatial invariance)的指标,可以描述整幅图像的性质,适用于拟合风格。与之相对,我们之前拟合图像内容时用的是图像每一个位置的特征,这一个指标是和空间相关的。Gram矩阵只是拟合风格的一种可选指标。后续研究证明,还有其他类似的特征也能达到和Gram矩阵一样的效果。我们不需要过分纠结于Gram矩阵的原理。

看到这里,大家或许已经明白风格迁移是怎么实现的了。风格迁移,其实就是既拟合一幅图像的内容,又去拟合另一幅图像的风格。我们把前一幅图像叫做内容图像,后一幅图像叫做风格图像

我们在上一节知道了如何拟合内容,这一节知道了怎么去拟合风格。要把二者结合起来,只要令我们的优化目标既包含和内容图像的内容误差,又包含和风格图像的风格误差。在原论文中,这些误差是这样表达的:

上面第一行公式表达的是内容误差,第二行公式表达的是风格误差。

第一行公式中,$F$,$P$分别是生成图像的卷积特征和源图像的卷积特征。

第二行公式中,$F$是生成图像的卷积特征,$G$是$F$的Gram矩阵,$A$是源图像卷积特征的Gram矩阵,$E_l$表示第$l$层的风格误差。在论文中,总风格误差是某几层风格误差的加权和,其中权重为$w_l$。事实上,不仅总风格误差可以用多层风格误差的加权和表示,总内容误差也可以用多层内容误差的加权和表示。只是在原论文中,只使用了一层的内容误差。

第三行中,$\alpha, \beta$分别是内容误差的权重和风格误差的权重。实际上,我们只用考虑$\alpha, \beta$的比值即可。如果$\alpha$较大,则说明优化内容的权重更大,生成出来的图像更靠近内容图像。反之亦然。

只要用这个误差去替换我们刚刚代码实现中的误差,就可以完成图像的风格迁移了,听起来是不是十分简单?但是,用PyTorch实现风格迁移时还要考虑不少细节。在本文的附录中,我会对风格迁移的实现代码做一些讲解。

思考

其实这篇文章是比较早期的用神经网络做风格迁移的工作。在近两年里,肯定有许多试图改进此方法的研究。时至今日,再去深究这篇文章里的一些细节(为什么用Gram矩阵,应该用VGG的哪些层做拟合)已经意义不大了。我们应该关注的是这篇文章的主要思想。

这篇文章对我的最大启发是:神经网络不仅可以用于在大批数据集上训练,完成一项通用的任务,还可以经过预训练,当作一个特征提取器,为其他任务提供额外的信息。同样,要记住神经网络只是优化任务的一项特例,我们完全可以把梯度下降法用于普通的优化任务中。在这种利用了神经网络的参数,而不去更新神经网络参数的优化任务中,梯度下降法也是适用的。

此外,这篇文章中提到的「风格」也是很有趣的一项属性。这篇文章算是首次利用了神经网络中的信息,用于提取内容、风格等图像属性。这种提取属性(尤其是提取风格)的想法被运用到了很多的后续研究中,比如大名鼎鼎的StyleGAN。

长期以来,人们总是把神经网络当成黑盒。但是,这篇文章给了我们一个掀开黑盒的思路:通过拟合神经网络中卷积核的特征,我们能够窥见神经网络每一层保留了哪些信息。相信在之后的研究中,人们能够更细致地去研究神经网络的内在原理。

参考文献

[1] Gatys L A, Ecker A S, Bethge M. Image style transfer using convolutional neural networks[C]//Proceedings of the IEEE conference on computer vision and pattern recognition. 2016: 2414-2423.

[2] Gatys L, Ecker A S, Bethge M. Texture synthesis using convolutional neural networks[J]. Advances in neural information processing systems, 2015, 28.

[3] 代码实现:https://pytorch.org/tutorials/advanced/neural_style_tutorial.html

附录:PyTorch 实现风格迁移

这段代码实现是基于 PyTorch 官方教程 编写的。

本文的代码仓库链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/StyleTransfer

准备工作

首先,导入我们需要的库。我们要导入PyTorch的基本库,并导入torchvision做图像变换和初始化预训练模型。此外,我们用PIL读写图像。我们还可以顺手设置一下运算设备(cpu或gpu)。

1
2
3
4
5
6
7
8
import torch
import torch.nn.functional as F
import torch.optim as optim
import torchvision.models as models
import torchvision.transforms as transforms
from PIL import Image

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

之后是图像读取。为了正确计算误差,所有图像的形状必须是统一的。因此,在读取图像后,我们要对图像做Resize的预处理。预处理之后,我们得到的图像是c, h, w格式的,别忘了用unsqueeze加上batch那一维。

这里torchvision中的transforms表示一些预处理操作。部分操作只能对PIL图像进行,而不能对np.ndaray进行。所以,这里用PIL存取图像比用cv2更方便。

1
2
3
4
5
6
7
8
9
10
11
img_size = (256, 256)


def read_image(image_path):
pipeline = transforms.Compose(
[transforms.Resize((img_size)),
transforms.ToTensor()])

img = Image.open(image_path).convert('RGB')
img = pipeline(img).unsqueeze(0)
return img.to(device, torch.float)

保存图像时,只要调用PIL的API即可:

1
2
3
4
5
6
def save_image(tensor, image_path):
toPIL = transforms.ToPILImage()
img = tensor.detach().cpu().clone()
img = img.squeeze(0)
img = toPIL(img)
img.save(image_path)

误差计算

在 PyTorch 中定义误差时,比较优雅的做法是定义一个torch.autograd.Function。但是这样做比较麻烦,需要手写反向传播。由于本文中新介绍的误差全部都是基于MSE均方误差的,我们可以基于torch.nn.Module编写一些“虚假的”误差函数。

首先,编写内容误差:

1
2
3
4
5
6
7
8
9
class ContentLoss(torch.nn.Module):

def __init__(self, target: torch.Tensor):
super().__init__()
self.target = target.detach()

def forward(self, input):
self.loss = F.mse_loss(input, self.target)
return input

在神经网络中,这个类其实没有做任何运算(forward直接把input返回了)。但是,这个类缓存了内容误差值。我们稍后可以取出这个类实例的loss,丢进最终的误差计算公式里。这种通过插入一个不进行计算的torch.nn.Module来保存中间计算结果的方法,算是使用PyTorch的一个小技巧。

之后,编写gram矩阵的计算方法及风格误差的计算“函数”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def gram(x: torch.Tensor):
# x is a [n, c, h, w] array
n, c, h, w = x.shape

features = x.reshape(n * c, h * w)
features = torch.mm(features, features.T) / n / c / h / w
return features


class StyleLoss(torch.nn.Module):

def __init__(self, target: torch.Tensor):
super().__init__()
self.target = gram(target.detach()).detach()

def forward(self, input):
G = gram(input)
self.loss = F.mse_loss(G, self.target)
return input

这里实现风格误差的思路与内容误差同理。

获取预训练模型

VGG模型对输入数据的分布有要求(即对输入数据均值、标准差有要求)。为了方便起见,我们可以写一个归一化分布的层,作为最终模型的第一层:

1
2
3
4
5
6
7
8
9
class Normalization(torch.nn.Module):

def __init__(self, mean, std):
super().__init__()
self.mean = torch.tensor(mean).to(device).reshape(-1, 1, 1)
self.std = torch.tensor(std).to(device).reshape(-1, 1, 1)

def forward(self, img):
return (img - self.mean) / self.std

接下来,我们可以利用torchvision中的预训练VGG,提取出其中我们需要的模块。我们还需要获取刚刚编写的误差类的实例的引用,以计算最终的误差。

这段代码的实现思路是:我们不直接把VGG拿过来用,而是新建一个用torch.nn.Sequential表示的序列模型。我们先把标准化层加入这个序列,再把原VGG中的计算层逐个加入我们的新序列模型中。一旦我们发现某个计算层的计算结果要用作计算误差,我们就在这个层后面加一个用于捕获误差的误差模块。

整段逻辑用文字难以说清,大家可以直接看代码理解:

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
default_content_layers = ['conv_4']
default_style_layers = ['conv_1', 'conv_2', 'conv_3', 'conv_4', 'conv_5']

def get_model_and_losses(content_img, style_img, content_layers, style_layers):
num_loss = 0
expected_num_loss = len(content_layers) + len(style_layers)
content_losses = []
style_losses = []

model = torch.nn.Sequential(
Normalization([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]))
cnn = models.vgg19(pretrained=True).features.to(device).eval()
i = 0
for layer in cnn.children():
if isinstance(layer, torch.nn.Conv2d):
i += 1
name = f'conv_{i}'
elif isinstance(layer, torch.nn.ReLU):
name = f'relu_{i}'
layer = torch.nn.ReLU(inplace=False)
elif isinstance(layer, torch.nn.MaxPool2d):
name = f'pool_{i}'
elif isinstance(layer, torch.nn.BatchNorm2d):
name = f'bn_{i}'
else:
raise RuntimeError(
f'Unrecognized layer: {layer.__class__.__name__}')

model.add_module(name, layer)

if name in content_layers:
# add content loss:
target = model(content_img)
content_loss = ContentLoss(target)
model.add_module(f'content_loss_{i}', content_loss)
content_losses.append(content_loss)
num_loss += 1

if name in style_layers:
target_feature = model(style_img)
style_loss = StyleLoss(target_feature)
model.add_module(f'style_loss_{i}', style_loss)
style_losses.append(style_loss)
num_loss += 1

if num_loss >= expected_num_loss:
break

return model, content_losses, style_losses

这里有些地方要注意:VGG有多个模块,其中我们只需要包含卷积层的vgg19().features模块。另外,我们只需要那些用于计算误差的层,当我们发现所有和误差相关的层都放入了新模型后,就可以停止新建模块了。

用梯度下降生成图像

这里的步骤和正文中的类似,我们先准备好输入的噪声图像、模型、误差类实例的引用,并设置好哪些参数需要优化,哪些不需要。

1
2
3
4
5
6
input_img = torch.randn(1, 3, *img_size, device=device)
model, content_losses, style_losses = get_model_and_losses(
content_img, style_img, default_content_layers, default_style_layers)

input_img.requires_grad_(True)
model.requires_grad_(False)

之后,我们声明好用到的超参数。这两个超参数能够控制图像是更靠近内容图像还是风格图像。

1
2
style_img = read_image('dldemos/StyleTransfer/picasso.jpg')
content_img = read_image('dldemos/StyleTransfer/dancing.jpg')

这两张图片来自官方教程。链接分别为picasso, dancing

最后,执行熟悉的梯度下降即可:

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
optimizer = optim.LBFGS([input_img])
steps = 0
prev_loss = 0
while steps <= 1000 and prev_loss < 100:

def closure():
with torch.no_grad():
input_img.clamp_(0, 1)
global steps
global prev_loss
optimizer.zero_grad()
model(input_img)
content_loss = 0
style_loss = 0
for l in content_losses:
content_loss += l.loss
for l in style_losses:
style_loss += l.loss
loss = content_weight * content_loss + style_weight * style_loss
loss.backward()
steps += 1
if steps % 50 == 0:
print(f'Step {steps}:')
print(f'Loss: {loss}')
# Open next line to save intermediate result
# save_image(input_img, f'work_dirs/output_{steps}.jpg')
prev_loss = loss
return loss

optimizer.step(closure)

由于我们有先验知识,知道图像位于(0, 1)之间,每一轮优化前我们可以手动约束一下图像的数值以加速训练。

运行程序的时候会有一些特殊情况。有些时候,任务的误差loss会突然涨到一个很高的值,过几轮才会恢复正常。为了保证输出的loss总是不那么大,我加了一个prev_loss < 100的要求。

这里steps的值是可以调的,误差究竟多小才算小也取决于实际任务以及content_weight, style_weight的大小。这些超参数都是可以去调试的。

最后,我们可以保存最终输出的图像:

1
2
3
with torch.no_grad():
input_img.clamp_(0, 1)
save_image(input_img, 'work_dirs/output.jpg')

正常情况下,运行上面这些的代码,可以得到下面的运行结果(我的style_weight/content_weight=1e6)

彩蛋

在理解了风格迁移是在做什么后,我就立刻想到:可不可以用风格迁移,把照片渲染成二次元风格呢?

成功完成代码实现后,我立马尝试把动漫风格迁移到我的照片上:

这效果也太差了吧?!我不服气,多输出了几幅中间结果。这下好了,结果更诡异了:

我都搞不清楚,这是进入了二次元,还是进入了显像管电视机。

可以看出,这种算法生成出来的二次元图像,还是保留了二次元图片中的一些风格:线条分明,颜色是一块一块的。但是整体效果太差了。

只能说,这种算法的局限性还是太强了。想进入二次元,任重而道远啊。

吐槽

我的智力和效率已经到达了一个可怕的地步。一天时间内,我在正常生活的同时,完成了论文阅读、复现、写文章、吹牛。这种执行能力太强了。如果我每天以这样的效率学东西,成为科研大牛指日可待。

可惜,搞科研并不是我的归宿。其实写这篇文章的时候,我也在想是什么东西在支持我一直做下去。写文章对现在的我来说是没有任何收益的。想高效获取金钱上的收益,也不该写这种类型的文章。但是我就是想写。不知道究竟是为了完成我的一些个人目标,还是为了向他人展示我修炼多年的表达能力、学习能力,还是纯粹以吹牛为乐。我已经搞不太清楚了。只要觉得好玩,就一直做下去吧。

最近,我玩视频游戏的时间越来越少了。因为,生活,对我来说,就是一场最具难度、最有挑战性的游戏。

体验完了“浅度”神经网络后,我们终于等到了这门课的正题——深度神经网络了。

其实这节课并没有引入太多新的知识,只是把上节课的2层网络拓展成了L层网络。对于编程能力强的同学(或者认真研究了我上节课的编程实战代码的同学,嘿嘿嘿),学完了上节课的内容后,就已经有能力完成这节课的作业了。

课堂笔记

深度神经网络概述与符号标记

所谓深度神经网络,只是神经网络的隐藏层数量比较多而已,它的本质结构和前两课中的神经网络是一样的。让我们再复习一下神经网络中的标记:

$L$表示网络的层数。

在这个网络中,$L=4$。(注意:输入层并不计入层数,但可以用第“0”层称呼输入层)

上标中括号的标号$l (l \in [0, L])$表示和第$l$层相关的数据。比如, $n^{[l]}$是神经网络第$l$层的神经元数(即每层输出向量的长度)。

这幅图里 $n^{[1]}=5$, $n^{[3]}=3$,以此类推。值得注意的是,$n^{[0]}=n_x=3$。回想第二课的知识,$n_x$是输入向量的长度。

再比如,$a^{[l]}$是第$l$层的输出向量。$a^{[l]}=g^{[l]}(z^{[l]})$,其中$g^{[l]}$是第$l$层的激活函数,$z^{[l]}$是第$l$层的中间运算结果。$W[l], b[l]$是第$l$层的参数。

和上节课的单隐层神经网络类似,对于$L$层的网络,我们如下方法对单样本做前向传播(推理):

其中,输入输出分别为:$x=a^{[0]}, \hat{y}=a^{[L]}$。

当我们考虑全体样本$X, Y$时,上面的算式可以写成:

其中,输入输出分别为:$X=A^{[0]}, \hat{Y}=A^{[L]}$。

从公式上看,使用向量化计算全体样本只是把小写字母换成了大写字母而已。用代码实现时,我们甚至也只需要照搬上述公式就行。但我们要记住,全体样本是把每个样本以列向量的形式横向堆叠起来,堆成了一个矩阵。我们心中对$X, Y$的矩阵形状要有数。

在实现深度神经网络时,我们不可避免地引入了一个新的for循环:循环遍历网络的每一层。这个for循环是无法消除的。要记住,我们要消除的for循环,只有向量化计算中的for循环。它们之所以能被消除,是因为向量化计算可以使用并行加速,而不是for循环本身有问题。我们甚至可以把“向量化加法”、“向量化乘法”这些运算视为最小的运算单元。而在写其他代码时,不用刻意去规避for循环。

参数矩阵的形状是:$W^{[l]}: (n^{[l]} , n^{[l-1]}), b^{[l]} : (n^{[l]} , 1)$

每一层的输入输出矩阵形状是:$A^{[l]} : (n^{[l]} , m)$

如果忘了$W$的形状,就拿矩阵乘法形状规则$(a , b) \cdot (b , c)=(a , c)$推一下。

某张量的梯度张量与其形状相同。比如$dW$的形状也是$(n^{[l]} , n^{[l-1]})$。

为什么用更深的网络

这里给出一种不严谨、出于直觉的解释:网络中越靠前的层,捕捉的信息越初级;越深的层,捕捉的信息越高级。比如对于上图所示的人脸检测网络,网络的第一层可能识别的是图片的边缘,第二层识别的是人的五官,第三层识别的是整个人脸。更深的网络有助于捕捉更高层次的特征。

另外,从计算复杂度的角度来看,用更深的网络,而不是在同一层网络里叠加更多神经元,往往能更轻易地拟合出一个函数。比如要拟合函数a+b+c+d,我们可以先算(a+b)和(c+d),再算(a+b)+(c+d)。这只需要2“层”计算。如果把上面4个数相加变成$n$个数相加,我们只需要构造$logn$层网络。但如果用单层网络拟合$n$个数相加,网络可能要尝试尝试$a, b, a+b, c, a+b+c, …$这一共$O(2^n)$种公式,需要在1层里放$O(2^n)$个神经元。

这一章反正不是严谨的科学证明,内容听听就好。深度神经网络好不好用,究竟用多少层的网络,这些决定都取决于实际的问题。只不过大多数任务用深度神经网络实现都能生效。

深度神经网络的训练流程

前两节课,我们的网络只有1层或2层。我们或许可以直接写出它们的训练步骤。现在,对于$L$层的网络,我们必须系统化地写出它们的训练流程。

首先是前向传播;

在前向传播时,我们要缓存(cache)一些临时变量,以辅助反向传播:

反向传播则按下面的步骤计算(注意观察被缓存的变量是怎么使用的):

上面的公式里,默认损失函数$L(a, y) = -(y loga + (1-y) log(1-a))$

记不住公式没关系,编程的时候对着翻译就行。

从算法的角度来看,梯度下降法只需要用反向传播算法。我们这里之所以做一遍正向传播,是因为反向传播要用到正向传播的中间运算结果。从逻辑关系来看,是反向传播函数调用了正向传播函数,而不是“先正向传播,再反向传播”的并列关系,虽然编程时是用后者来表达。

参数与超参数

之前,我们在不经意间就已经接触了“超参数”这个词,但一直没有对它下一个定义。现在,我们来正式介绍超参数这个概念,以及它和参数的关系。

对于我们之前的神经网络,参数包括:

  • $W$
  • $b$

这些参数和数学里的参数意义一样,表示函数的参数。

而超参数则包括:

  • 学习率 $\alpha$
  • 训练迭代次数
  • 网络层数 $L$

我们直接从超参数的作用来给超参数下定义。超参数的取值会决定参数$W, b$的取值,它们往往只参与训练,而不参与最后的推理计算。可以说,除了网络中要学习的参数外,网络中剩下的可以变动的数值,都是超参数。

一个简单区别超参数的方法是:超参数一般是我们手动调的。我们常说“调参”,说的是超参数。

吴恩达老师鼓励我们多尝试调参,对于不同的问题可以尝试不同的超参数。

神经网络与大脑

生物的神经由“树突”,“轴突”等部分组成。生物信号会通过这些部分在神经里传播。神经网络的工作原理和生物神经的原理有那么一点类似

但迄今为止,生物神经的原理还没有被破解。我们把神经网络当成一个$x \to y$的映射就好了。

总结

这一课主要是介绍编程实现的思路,没有过多的知识点:

  • 实现任意层数的神经网络
    • 前向传播
    • 反向传播
    • 缓存信息
  • 为什么用更深的网络
  • 分辨超参数与参数

如果大家对神经网络的前向传播或反向传播还有问题,欢迎去回顾上一篇笔记:https://zhouyifan.net/2022/05/23/DLS-note-3/。

代码实战

说实话,这堂课的编程作业只能称为“体验写代码的感觉”,不能堂堂正正地称为“写程序”。整个框架都搭好了,每行输入输出的变量都给好了,只要填一填函数调用就行了。这样编程实在是不好玩,没有难度,学不到东西。

所以,是时候自己动手写代码了!

两周前,使用逻辑回归做小猫分类并不成功。看看这周换了“深度”神经网络后,我们能不能在小猫分类上取得更好的成绩。

通用分类器类

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
class BaseRegressionModel(metaclass=abc.ABCMeta):

def __init__(self):
pass

@abc.abstractmethod
def forward(self, X: np.ndarray, train_mode=True) -> np.ndarray:
pass

@abc.abstractmethod
def backward(self, Y: np.ndarray) -> np.ndarray:
pass

@abc.abstractmethod
def gradient_descent(self, learning_rate: float) -> np.ndarray:
pass

@abc.abstractmethod
def save(self, filename: str):
pass

@abc.abstractmethod
def load(self, filename: str):
pass

def loss(self, Y: np.ndarray, Y_hat: np.ndarray) -> np.ndarray:
return np.mean(-(Y * np.log(Y_hat) + (1 - Y) * np.log(1 - Y_hat)))

def evaluate(self, X: np.ndarray, Y: np.ndarray, return_loss=False):
Y_hat = self.forward(X, train_mode=False)
Y_hat_predict = np.where(Y_hat > 0.5, 1, 0)
accuracy = np.mean(np.where(Y_hat_predict == Y, 1, 0))
if return_loss:
loss = self.loss(Y, Y_hat)
return accuracy, loss
else:
return accuracy

这次我们还是用基于这个通用分类器类编写代码。相比上篇笔记中的代码,这个类有以下改动:

  • 由于这次模型要训练很久,我给模型加入了save,load用于保存和读取模型。
  • evaluate 不再直接输出结果,而是返回一些评测结果,供调用其的函数使用。注意,这次我们不仅算了测试集上的准确率,还算了测试集上的损失函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def train(model: BaseRegressionModel,
X,
Y,
step,
learning_rate,
print_interval=100,
test_X=None,
test_Y=None):
for s in range(step):
Y_hat = model.forward(X)
model.backward(Y)
model.gradient_descent(learning_rate)
if s % print_interval == 0:
loss = model.loss(Y, Y_hat)
print(f"Step: {s}")
print(f"Train loss: {loss}")
if test_X is not None and test_Y is not None:
accuracy, loss = model.evaluate(test_X,
test_Y,
return_loss=True)
print(f"Test loss: {loss}")
print(f"Test accuracy: {accuracy}")

模型训练的代码和上篇笔记中的也几乎完全相同,只是多输出了一点调试信息。

工具函数

这次我们希望能够更灵活地使用激活函数。因此,我编写了两个根据字符串获取激活函数、激活函数梯度的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_activation_func(name):
if name == 'sigmoid':
return sigmoid
elif name == 'relu':
return relu
else:
raise KeyError(f'No such activavtion function {name}')


def get_activation_de_func(name):
if name == 'sigmoid':
return sigmoid_de
elif name == 'relu':
return relu_de
else:
raise KeyError(f'No such activavtion function {name}')

实现任意层数的神经网络

上篇笔记中,我们实现了一个单隐层的神经网络。而这周,我们要实现一个更加通用的神经网络。这个神经网络的层数、每层的神经元数、激活函数都可以修改。还是照着上次的思路,让我们看看这个子类的每个方法是怎么实现的。

1
class DeepNetwork(BaseRegressionModel):

模型初始化

1
2
3
4
5
6
7
8
9
10
11
def __init__(self, neuron_cnt: List[int], activation_func: List[str]):
assert len(neuron_cnt) - 1 == len(activation_func)
self.num_layer = len(neuron_cnt) - 1
self.neuron_cnt = neuron_cnt
self.activation_func = activation_func
self.W: List[np.ndarray] = []
self.b: List[np.ndarray] = []
for i in range(self.num_layer):
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.2)
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))

我们要在模型初始化时确定模型的结构,因此我们需要刚刚提到的神经网络的层数、每层的神经元数、激活函数这三个信息。在初始化函数中,我们只需要传入每层神经元数的列表、激活函数名的列表即可,神经网络的层数可以通过列表长度来获取。

注意,在构建神经网络时,我们不仅要设置每一层的神经元数,还需要设置输入层的向量长度(回忆一下,输入层虽然不计入网络的层数,但我们还需要根据输入的向量长度设置参数矩阵W的形状)。因此,神经元数列表比激活函数名列表多了一个元素:

1
assert len(neuron_cnt) - 1 == len(activation_func)

获取了构建模型所需信息(超参数)后,我们按照公式,根据这些信息初始化模型的参数:

1
2
3
4
for i in range(self.num_layer):
self.W.append(
np.random.randn(neuron_cnt[i + 1], neuron_cnt[i]) * 0.2)
self.b.append(np.zeros((neuron_cnt[i + 1], 1)))

别忘了,我们要随机初始化W,且令W为一个较小的值。这里我随便取了个0.2。后面的课程会介绍这个值该怎么设置。

此外,我们还要先准备好缓存的列表:

1
2
3
4
self.Z_cache = [None] * self.num_layer
self.A_cache = [None] * (self.num_layer + 1)
self.dW_cache = [None] * self.num_layer
self.db_cache = [None] * self.num_layer

由于输入层(A0)的信息也要保留,因此A_cache的长度要多1。

前向传播

回忆这节课提到的前向传播公式:

我们只需要按照公式做运算并缓存变量即可(下标和公式不完全对应):

1
2
3
4
5
6
7
8
9
10
11
12
def forward(self, X, train_mode=True):
if train_mode:
self.m = X.shape[1]
A = X
self.A_cache[0] = A
for i in range(self.num_layer):
Z = np.dot(self.W[i], A) + self.b[i]
A = get_activation_func(self.activation_func[i])(Z)
if train_mode:
self.Z_cache[i] = Z
self.A_cache[i + 1] = A
return A

注意,虽然公式里写了要缓存模型参数W, b,但实际上在代码实现时,模型参数本来就是类的成员属性,不需要额外去缓存它们。

反向传播

同样,按照公式翻译反向传播即可:

1
2
3
4
5
6
7
8
9
10
11
12
def backward(self, Y):
dA = -Y / self.A_cache[-1] + (1 - Y) / (1 - self.A_cache[-1])
assert (self.m == Y.shape[1])

for i in range(self.num_layer - 1, -1, -1):
dZ = dA * get_activation_de_func(self.activation_func[i])(
self.Z_cache[i])
dW = np.dot(dZ, self.A_cache[i].T) / self.m
db = np.mean(dZ, axis=1, keepdims=True)
dA = np.dot(self.W[i].T, dZ)
self.dW_cache[i] = dW
self.db_cache[i] = db

这里除了第一个dA要由误差函数的导数单独算出来外,其他的梯度直接照着公式算就可以了。由于我们把反向传播和更新参数分成了两步,这里额外缓存了dW, db的值。

梯度下降

这次,梯度下降需要循环对所有参数进行:

1
2
3
4
def gradient_descent(self, learning_rate):
for i in range(self.num_layer):
self.W[i] -= learning_rate * self.dW_cache[i]
self.b[i] -= learning_rate * self.db_cache[i]

存取模型

在介绍存取模型的函数之前,我们要认识两个新的numpy API。

第一个API是np.savez(filename, a_name=a, b_name=b, ...)。它可以把ndarray类型的数据a, b, ...以键值对的形式记录进一个.npz文件中,其中键值对的键是数据的名称,值是数据的值。

第二个API是np.load(filename)。它可以从.npz里读取出一个词典。词典中存储的键值对就是我们刚刚保存的键值对。

比如,我们可以用如下方法存取W, b两个ndarray

1
2
3
4
5
6
W = np.zeros((1, 1))
b = np.zeros((1, 1))
np.savez('a.npz', W=W, b=b)
params = np.load('a.npz')
assert W == params['W']
assert b == params['b']

学会了这两个API的用法后,我们来看看该怎么存取神经网络的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def save(self, filename: str):
save_dict = {}
for i in range(len(self.W)):
save_dict['W' + str(i)] = self.W[i]
for i in range(len(self.b)):
save_dict['b' + str(i)] = self.b[i]
np.savez(filename, **save_dict)

def load(self, filename: str):
params = np.load(filename)
for i in range(len(self.W)):
self.W[i] = params['W' + str(i)]
for i in range(len(self.b)):
self.b[i] = params['b' + str(i)]

和刚刚介绍的用法一样,这里我们要给神经网络中每一个参数取一个独一无二的名字,再把所有名字和值合并成键值对。保存和读取,就是对键值对的写和读。

这里我使用了**save_dict这种传参方式。在Python中,func(**dict)的作用是把一个词典的值当作函数的键值对参数。比如我要写func(a=a, b=b),我可以定义一个词典d={'a':a, 'b':b},再用func(**d)把词典传入函数的键值对参数。

小猫分类器

这次,我们还是使用和第二篇笔记中相同的猫狗数据集,作为这次任务的训练集和测试集:

1
2
3
4
5
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
# train_X: [n_x, m]
# train_Y: [1, m]

这次我直接在get_cat_set函数里把输入数据的形状调好了,总算不用在main函数看乱糟糟的代码了。

之后,我们根据输入的向量长度,初始化一个较深的神经网络:

1
2
3
n_x = train_X.shape[0]
model = DeepNetwork([n_x, 30, 30, 20, 20, 1],
['relu', 'relu', 'relu', 'relu', 'sigmoid'])

如这段代码所示,我创建了一个有4个隐藏层的神经网络,其中隐藏层的通道数分别为30, 30, 20, 20。除了最后一层用sigmoid以外,每一层都用relu作为激活函数。

初始化完模型后,我们可以开始训练模型了:

1
2
3
4
5
6
7
8
9
10
model.load('work_dirs/model.npz')
train(model,
train_X,
train_Y,
500,
learning_rate=0.01,
print_interval=10,
test_X=test_X,
test_Y=test_Y)
model.save('work_dirs/model.npz')

其中,模型是否要读取和保存是可以“灵活地”注释掉的。只需要简单地调用train函数,我们的模型就可以训起来了。

实验结果

本来,写完了代码,讨论实验结果,是一件令人心情愉悦的事情:忙了大半天,总算能看一看自己的成果了。但是,在深度学习项目中,实验其实是最烦人的部分。

如第一课所述,深度学习是一个以实验为主的研究方向,深度学习项目是建立在“实验-开发”这一循环上的。一旦实验效果不佳,你就得去重新调代码。普通的编程项目,你能预计程序有怎样的输出,出了问题你能顺藤摸瓜找到bug。而对于深度学习项目,模型效果不佳,可能是代码有bug,也可能是模型不行。哪怕是知道是模型不行,你也很难确切地知道应该怎么去提升模型。因此,对于深度学习项目,开始实验,仅仅是麻烦的开始。

这不,在开启这周的小猫分类任务实验时,我是满怀期待的:上次用的逻辑回归确实太烂了。这周换了一个这么强大的模型,应该没问题了吧。

结果呢,模型跑了半天,精度还比不过逻辑回归。

我也不好去直接debug啊。我只好对着屏幕大眼瞪小眼,硬生生地用我的人脑编译器去调试代码。

我看了很久,屏幕都快被我眼睛发出的射线射穿了,我还是找不到bug。

我只好断定:这不是代码的问题,是模型或数据的问题。

调了半天超参数后,开始训练。模型用蜗牛般的速度训练了一个小时后,总算达到了 60.5% 的准确率。还好,这个模型没有太丢人,总算比之前逻辑回归的 57.5% 要高上一点点了。

可又过了半小时,这模型的准确率又只有 58.5% 了,准确度就再也上不去了。

这个结果实在是太气人了。相比逻辑回归,这么深的网络竟然只有这么小的提升。

消气后,我冷静地分析了下为什么这个“深度”神经网络的提升这么小:

  1. 上次逻辑回归使用的测试集太小,结果不准确。模型可能碰巧多猜对了几次。
  2. 还有很多训练优化的手段我没有用上。

还有一些其他方面的原因。现在所有运算都是在CPU上运行的,速度特别慢。如果放到GPU上运算,模型的训练会快上很多。实验速度快了,就有更多的机会去调试模型的超参数,得到更优的模型。

其实,我已经学完了后面几周的课程,知道该怎么优化神经网络的训练;我也知道该怎么在GPU上训练模型。但是,出于教学的考虑,为了让使用的知识尽可能少,我没有提前去使用一些更高级的优化方法和编程手段。我大概只发挥了一成的模型优化水平,使用GPU后实验速度保守估计提升10倍。这样算来,我现在展示的实力,最多只有真实实力的1%。一旦我稍稍拿出5成实力,这个模型的性能就会有显著提升;一旦我释放所有能量,再去多看几篇论文,使用更加优秀的分类模型,那我的模型在这个数据集上的分类精度就可以登顶全球了。只是这样太张扬了不太好。

这样一想,我也没有必要为这个模型的垃圾性能而生气。后面还有的是改进的机会。希望在后面的编程实战中,我们能一点一点见证这个分类模型的提升。

代码链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/DeepNetwork

在上节课中,我们学习了逻辑回归——一种经典的学习算法。我兴致勃勃地用它训练了一个猫狗分类模型,结果只得到了57%这么个惨淡的准确率。正好,这周开始学习如何实现更复杂的模型了。这次,我一定要一雪前耻!

开始学这周的课之前,先回忆一下上周我们学习了什么。

对于一个神经网络,我们要定义它的网络结构(一个数学公式),定义损失函数。根据损失函数和网络结构,我们可以对网络的参数求导,并用梯度下降法最小化损失函数。

也就是说,不管是什么神经网络,都由以下几部分组成:

  • 网络结构
  • 损失函数
  • 优化策略

而在编程实现神经网络时,我们不仅要用计算机语言定义上面这几项内容,还需要收集数据预处理数据

在这堂课中,我们要学一个更复杂的模型,其知识点逃不出上面这些范围。在之后的学习中我们还会看到,浅层神经网络的损失函数优化策略和上节课的逻辑回归几乎是一模一样的。我们要关心的,主要是网络结构上的变化。

在学习之前,我们可以先有一个心理准备,知道大概要学到哪些东西。

课堂笔记

神经网络概述与符号标记

上节课我们使用的逻辑回归过于简单,它只能被视为只有一个神经元(计算单元)的神经网络。如上图第一行所示。

一般情况下,神经网络都是由许多神经元组成的。我们把一次性计算的神经元都算作“一层”。比如上图第二行的网络有两层,第一层有3个神经元,第二层有1个神经元。

上节课中,对于一个样本$x$,一层的神经网络是用下面的公式计算的:

而这节课将使用的两层神经网络,也使用类似的公式计算:

上节课中,参数$w$是一个列向量。这节课的参数$W$是一个矩阵。我们稍后会见到$W$的全貌。

这里的方括号上标$[l]$表示第$l$层相关的变量。总结一下,$a_i^{j}$表示第$k$个样本在网络第$j$层中向量的第$i$个分量。

事实上,输入$x$可以看成$a^{[0]}$。

这里的a是activation(激活)意思,每个$a$都是激活函数的输出。

为了方便称呼,我们给神经网络的层取了些名字:

其中,输入叫做“输入层”,最后一个计算层叫做“输出层”,中间其余的层都叫做“隐藏层”。事实上,由于第一个输入层不参与计算,它不会计入网络的总层数,只是为了方便称呼才这么叫。因此,上面这个网络看上去有3层,但叫做“双层神经网络”,或“单隐藏层神经网络”。

单样本多神经元的计算

让我们先看一下,对于一个输入样本$x^{(1)}$,神经网络是怎么计算输出的。

如图,输入 $x$ 是一个形状为$3 \times 1$的列向量。第一层有三个神经元,第一个神经元的参数是$w_1^{[1]}, b_1^{[1]}$,第二个是$w_2^{[1]}, b_2^{[1]}$,第三个是$w_3^{[1]}, b_3^{[1]}$。

$w_i^{[1]}$的形状是$1 \times 3$,$b_i^{[1]}$是常数。

每个神经元的计算公式和上节课的逻辑回归相同,都是$z_i^{[1]}=w_i^{[1]}x+b_i^{[1]}$,$a_i^{[1]}=\sigma(z_i^{[1]})$($i \in [1, 2, 3]$)。

回忆一下,上一节课里$w$的形状是$n_x \times1$,即一个长度为$n_x$的列向量,其中$n_x$是输入向量的长度(此处为3)。$b$是一个常数。计算结果时,我们要把$w$转置,计算$w^Tx+b$。这里的$w_i^{[1]}$是一个行向量,其形状是$1 \times n_x$,计算时不用转置。计算时直接$w_i^{[1]}x+b_i^{[1]}$就行。

因为有三个神经元,我们得到三个计算结果$a_1^{[1]}, a_2^{[1]}, a_3^{[1]}$。我们可以把它们合起来当成一个$3 \times 1$的列向量$a^{[1]}$,就像输入$x$一样。

之后,这三个输出作为输入传入第二层的神经元,计算$z^{[2]}=W_1^{[2]}a^{[1]}+b^{[2]}$, $\hat{y}=a^{[2]}=\sigma(z^{[2]})$。这个算式和上周的逻辑回归一模一样。

总结一下,如果某一层有$n$个神经元,那么这一层的输出就是一个长度为$n$的列向量。这个输出会被当作下一层的输入。神经网络的每一层都按同样的方式计算着。

对于单隐层神经网络,隐藏层的参数$W^{[1]}$的形状是$n_1 \times n_x$,其中$n_1$是隐藏层神经元个数,$n_x$是每个输入样本的向量长度。参数$b^{[1]}$的形状是$n_1 \times 1$。输出层参数$W^{[2]}$的形状是$1 \times n_1$,$b^{[2]}$的形状是$1 \times 1$。

多样本多神经元的计算

和上一节课一样,让我们把一个输入样本拓展到多个样本,看看整个计算公式该怎么写。

对于第$i$个输入样本$x^{(i)}$,我们要计算:

直接写的话,我们要写个for循环,把$i$从$0$遍历到$m-1$。

回忆一下,$m$是样本总数。

但是,如果把输入打包在一起,形成一个$n_x \times m$的矩阵$X$,那么整个计算过程可以用十分相似的向量化计算公式表示:

这里的$X$,$A$相当于横向“拉长了”:

激活函数

在神经网络中,我们每做完一个线性运算$Z=WX+b$后,都会做一个$\sigma(Z)$的操作。上周我们讲这个$\sigma$(sigmoid函数)是为了把实数的输入映射到$[0, 1]$。这是它在逻辑回归的作用。而在普通的神经网络中,$\sigma$就有别的作用了——激活线性输出。$\sigma$其实只是激活输出的激活函数的一员,还有很多其他函数都可以用作为激活函数。我们现在暂时不管这个“激活”是什么意思,先认识一下常见的激活函数。

画这些函数的代码见后文。

它们的数学公式如下:

其中leaky_relu里的$k$是一个常数,这个常数要小于1。图中的leaky_relu的$k$取了0.1。

现在来介绍一下这些激活函数。

sigmiod,老熟人了,这个函数可以把实数上的输入映射到$(0, 1)$。tanh其实是sigmoid的一个“位移版”(二者的核心都是$e^x$),它可以把实数的输入映射到$(-1, 1)$。

这两个函数有一个问题:当x极大或者极小的时候,函数的梯度几乎为0。从图像上来看,也就是越靠近左边或者右边,函数曲线就越平。梯度过小,会导致梯度下降法每次更新的幅度较小,从而使网络训练速度变慢。

为了解决梯度变小的问题,研究者们又提出了relu函数(rectified linear unit, 线性整流单元)。别看这个名字很高大上,relu函数本身其实很简单:你是正数,就取原来的值;你是负数,就取0。非常的简单直接。把这个函数用作激活函数,梯度总是不会太小,可以有效加快训练速度。

有人觉得relu对负数太“一刀切”了,把relu在负数上的值改成了一个随输入$x$变化的,十分接近0的值。这样一个新的relu函数就叫做leaky relu。(大家应该知道为什么leaky_relu的$k$要小于1了吧)

写在博客里的题外话:浅谈文章的统一性。为什么这里relu用的是小写呢?按照英文的写法,应该是ReLU才对啊?这里是不是写文章的时候不够严谨啊?其实不是。我们这里其实统一用的是代码写法,即全部单词小写。我们首次介绍relu时,是在上文的图片和公式里。那里面用的是小写的relu。后文其实是对这种描述的一个统一,表示“前文用到的relu”,而不是一般用语中的ReLU。在后面的文章中,我会使用ReLU这个称呼。

如果有严谨的文字工作者,还会质疑道:“你这篇文章里有些单词应该用公式框起来,有些应该用代码框起来,怎么直接用文本表示啊?”这是因为微信公众号对公式的支持很烂,我编辑得累死了,不想动脑去思考到底用公式还是用代码了。要把一个东西写得天衣无缝,需要耗费大量的时间。为了权衡,我抛弃了部分严谨性,换来了写文章的效率。

如何选择激活函数

tanh由于其值域比sigmoid大,原理又一模一样,所以tanh在数学上严格优于sigmoid。除非是输出恰好处于$(0, 1)$(比如逻辑回归的输出),不然宁可用tanh也不要用sigmoid。

现在大家都默认使用relu作为激活函数,偶尔也有使用leaky_relu的。吴恩达老师鼓励大家多多尝试不同的激活函数。

在之前介绍的公式中,我们所有激活函数$g$都默认用的是$g=\sigma$。准确来说,单隐层神经网络公式应该写成下面这种形式:

由于第二层网络的输出落在[0, 1],我们第二个激活函数还是可以用sigmoid,即$g^{[2]}=\sigma$。

激活函数的作用

假设我们有一个两层神经网络:

其中激活函数用$g$表示。

假如我们不使用激活函数,即令$g(x)=x$的话,这个神经网络就变成了:

我们把$W_2W_1$看成一个新的“$W$”,$(b_1+b_2)$看成一个新的”$b$”,那么这其实是一个单层神经网络。

也就是说,如果我们不用激活函数,那么无论神经网络有多少层,这个神经网络都等价于只有一层。这种神经网络永远只能拟合一个线性函数。

为了让神经网络取拟合一个非线性的,超级复杂的函数,我们必须要使用激活函数。

激活函数的导数(选读)

为了让大家重新体验一下高中学数学的感觉,这里求导的步骤推得十分详细。

sigmoid

上篇笔记也吐槽过了,想写出最后一步,需要发动数学家的固有技能:「注意到」。这不怎么学数学的人谁能注意到最后这一步啊。

tanh

回忆一下,$(\frac{u}{v})’=(\frac{u’v-uv’}{v^2})$。

最后这步我依然注意不到。我猜原函数$f(x)$是用$f’(x)=(1+ f(x))(1-f(x))$这个微分方程构造出来的,而不是反过来恰好发现导数能够写得这么简单。

relu

这个导求得神清气爽。

leaky relu

学数学的人可能会很在意:relu和leaky relu在0处没有导数啊!碰到0你怎么梯度下降啊?实际上,我们编程的时候,不用管那么多,直接也令0处的导数为1就行(即导数在0处的右极限)。

对神经网络做梯度下降

回顾一下,如果只有两个参数$w, b$,应该用下式做梯度下降:

回忆一下,$\alpha$是学习率,表示梯度更新的速度,一般取$0.0001$这种很小的值。

现在,我们有4个参数:$W^{[1]},W^{[2]}, b^{[1]},b^{[2]}$,它们也应该按照同样的规则执行梯度下降:

剩下的问题就是怎么求导了。让我们再看一遍神经网络正向传播的公式:

由于我们令$g^{[2]}=\sigma$,所以神经网络第二层(输出层)的导数可以直接套用上周的导数公式:

注意! 上周我们算的是$AdZ^T$,这周是$dZ^{[2]}A^{[1]T}$。这是因为参数$W$转置了一下。上周的$w$是列向量,这周每个神经元的权重$W_i$是行向量。

之后,我们来看第一层。首先求$dZ^{[1]}$:

注意,上式中右边第一项$dA^{[1]}$是$\frac{dJ}{dA^{[1]}}$的简写,第二项$\frac{dA^{[1]}}{dZ^{[1]}}$是实实在在的求导。

这里$dA^{[1]}$和$dW^{[2]}$的计算是对称的哟。

之后的$dW^{[1]}, db^{[1]}$的公式和前面$dW^{[2]}, db^{[2]}$的相同:

别忘了,$X=A^{[0]}$。

这些求导的步骤写成代码如下:

1
2
3
4
5
6
dZ2=A2-Y
dW2=np.dot(dZ2, A1.T) / m
db2=np.sum(dZ2, axis=1, keepdims=True) / m
dZ1=np.dot(W2.T, dZ2) * g1_backward(Z1)
dW1=np.dot(dZ1, X.T) / m
db1=np.sum(dZ1, axis=1, keepdims=True) / m

再次温馨提示,搞不清楚数学公式的细节没关系,直接拿来用就好了。要学会的是算法的整体思路。

这段代码有一点需要注意:

1
2
db2=np.sum(dZ2, axis=1, keepdims=True)
db1=np.sum(dZ1, axis=1, keepdims=True)

这个keepdims=True是必不可少的。使用np.sum, np.mean这种会导致维度变少的计算时,如果加了keepdims=True,会让变少的那一个维度保持长度1.比如一个[4, 3]的矩阵,我们对第二维做求和,理论上得到的是一个[4]的向量。但如果用了keepdims=True,就会得到一个[4, 1]的矩阵。

保持向量的维度,可以让某些广播运算正确进行。比如我要用[4, 3]的矩阵减去[4]的矩阵就会报错,而减去[4, 1]的矩阵就不会报错。

参数随机初始化

再次回顾下,梯度下降算法的结构如下:

1
2
3
4
初始化参数
迭代 k 步:
算参数的梯度
用梯度更新参数

对于这节课新学的单隐层神经网络,求导、更新参数的过程我们已经学完了。我们还有一个东西没有详细探究:参数的初始化方式。现在,我们来详细研究一下参数初始化。

在上节课中,我们用一句话就带过了参数初始化方法:令参数全为0就行了。这种初始化方法在这节课还有用吗?让我们来看课堂里提到的一个示例:

如上图,对于输入长度为2,第一层有2个神经元的网络,其第一层参数$W^{[1]}$为[[0, 0], [0, 0]]。这样算出来的神经元输出$a^{[1]}_1,a^{[1]}_2$是一样的。而更新梯度时,每一个神经元的参数$W^{[1]}_1, W^{[1]}_2$的梯度都只和该神经元的输出有关。这样,每个神经元参数的导数dw都是一模一样的。导数一样,初始化的值也一样,那么每个神经元的参数的值会一直保持相同。这样,不论我们在某一层使用了多少个神经元,都等价于只使用一个神经元。

为了不发生这样的情况,我们需要让每一个神经元的参数$w$都取不同的值。这可以通过随机初始化实现。只需要使用下面的代码就可以随机初始化$w$:

1
w = np.random.randn((h, w)) * 0.01

注意,这里我们给随机出的数乘了个0.01。这是因为出于经验,人们更倾向于使用更小的参数,以计算出更小的结果,防止激活函数(如tanh)在绝对值过大时梯度过小的问题。

后面的课会详细介绍该如何初始化这些参数,以及初始化参数可以解决哪些问题。

而$b$和之前一样,直接用0初始化就行了。

知识总结

在这堂课中,我们正式认识了神经网络的定义。原来,上周的逻辑回归只是一个特殊的神经网络。它只有一个输出层,并且使用sigmoid作激活函数。而这周,我们学习了如何定义一个两层(一个隐藏层、一个输出层)的神经网络,并且知道如何在网络中使用不同的激活函数。

让我们来看一下这节课的知识点:

  • 神经网络的定义
    • 输入层、隐藏层、输出层
    • 每一层每一个神经元相关的参数该怎么表示
  • 神经网络的计算方式
    • 单样本 -> 多样本
    • 正向传播与反向传播
  • 激活函数
    • 直观认识激活函数——激活函数属于神经网络计算中的哪一部分?
    • 常见的四种激活函数:sigmoid, tanh, relu, leaky_relu
    • 如何选择激活函数
    • 为什么要使用激活函数
  • 神经网络与逻辑回归的区别——参数初始化问题
    • 为什么不能用0初始化$W$
    • 随机初始化$W$
    • 可以用0初始化$b$

代码实战

这节课的编程作业是搞一个点集分类器。此任务的数据集如下图所示:

在平面上,已知有一堆红色的点和绿色的点。我们希望任意给定一个点,程序能够判断这个点是红点还是绿点。

让我们人类来分类的话,肯定会认为左边一片花瓣和右上角两片花瓣是绿色的,剩下三片花瓣是红色的(有部分点不满足这个规律,可以认为这些点是噪声,即不正确的数据)。让神经网络来做这个任务,会得到怎样的结果呢?

现在,让我们用这周学的单隐层神经网络,来实现这个分类器。

虽然前面说这周要继续挑战猫狗分类任务,但我估摸着这周的模型可能还是简单了一点。等下周学了再强大一点的模型,我再来复仇。

项目链接:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/ShallowNetwork

通用分类器类

在上节课的编程实战中,我们很暴力地写了“一摊”代码。说实话,有编程洁癖的我是不能接受那种潦草的代码的。如果代码写得太乱,就根本不能复用,根本不可读,根本不能体现编程的逻辑之美。

这周,我将解除封印,释放我30%的编程水平,展示一个比较优雅的通用分类器类该怎么写。我们会先把上周的逻辑回归用继承基类的方式实现一遍,再实现一遍这周的浅层神经网络。

分类器基类的代码如下:

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

class BaseRegressionModel(metaclass=abc.ABCMeta):
# Use Cross Entropy as the cost function

def __init__(self):
pass

@abc.abstractmethod
def forward(self, X, train_mode=True):
# if self.train_mode:
# forward_train()
# else:
# forward_test()
pass

@abc.abstractmethod
def backward(self, Y):
pass

@abc.abstractmethod
def gradient_descent(self, learning_rate=0.001):
pass

def loss(self, Y_hat, Y):
return np.mean(-(Y * np.log(Y_hat) + (1 - Y) * np.log(1 - Y_hat)))

def evaluate(self, X, Y):
Y_hat = self.forward(X, train_mode=False)
predicts = np.where(Y_hat > 0.5, 1, 0)
score = np.mean(np.where(predicts == Y, 1, 0))
print(f'Accuracy: {score}')

为了简化代码,我们用BaseRegressionModel表示一个使用交叉熵为损失函数的二分类模型。这样,我们所有的模型都可以共用一套损失函数loss、一套评估方法evaluate。这里损失函数和评估方法的实现都是从上周的代码里复制过来的。

让我们分别看一下其他几个类方法的描述:

  • __init__: 模型的参数应该在__init__方法里初始化。
  • forward:正向传播函数。这个函数即可以用于测试,也可以用于训练。如果是用于训练,就要令参数train_mode=True。为什么要区分训练和测试呢?这是因为,正向传播在训练的时候需要额外保存一些数据(缓存),保存数据是存在开销的。在测试的时候,我们可以不做缓存,以降低程序运行开销。
  • backward:反向传播函数。这个函数用于forward之后的梯度计算。算出来的梯度会缓存起来,供反向传播使用。
  • gradient_descent:用梯度下降更新模型的参数。(一般框架会把优化器和模型分开写。由于我们现在只学了梯度下降这一种优化策略,所以直接把梯度下降当成了模型类的方法)

有了这样一个分类器基类后,我们可以用统一的方式训练模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def train_model(model: BaseRegressionModel,
X_train,
Y_train,
X_test,
Y_test,
steps=1000,
learning_rate=0.001,
print_interval=100):
for step in range(steps):
Y_hat = model.forward(X_train)
model.backward(Y_train)
model.gradient_descent(learning_rate)
if step % print_interval == 0:
train_loss = model.loss(Y_hat, Y_train)
print(f'Step {step}')
print(f'Train loss: {train_loss}')
model.evaluate(X_test, Y_test)

有了一个初始化好的模型model后,我们在训练函数train_model里可以直接开始循环训练模型。每次我们先调用model.forward做正向传播,缓存一些数据,再调用model.backward反向传播算梯度,最后调用model.gradient_descent更新模型的参数。每训练一定的步数,我们监控一次模型的训练情况,输出模型的训练loss和测试精度。

看吧,是不是使用了类来实现神经网络后,整个代码清爽而整洁?

工具函数

1
2
3
4
5
6
7
8
9
10
import numpy as np

def sigmoid(x):
return 1 / (1 + np.exp(-x))

def relu(x):
return np.maximum(x, 0)

def relu_de(x):
return np.where(x > 0, 1, 0)

同样,为了让代码更整洁,我把一些工具函数单独放到了一个文件里。现在,如上面的代码所示,我们的工具函数只有几个损失函数及它们的导数。(用于sigmoid只用于最后一层,我们可以直接用dZ=A-Y跳一个导数计算步骤,所以这里没有写sigmoid的导数)。

复现逻辑回归

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
class LogisticRegression(BaseRegressionModel):

def __init__(self, n_x):
super().__init__()
self.n_x = n_x
self.w = np.zeros((n_x, 1))
self.b = 0

def forward(self, X, train_mode=True):
Z = np.dot(self.w.T, X) + self.b
A = sigmoid(Z) # hat_Y = A
if train_mode:
self.m_cache = X.shape[1]
self.X_cache = X
self.A_cache = A
return A

def backward(self, Y):
d_Z = self.A_cache - Y
d_w = np.dot(self.X_cache, d_Z.T) / self.m_cache
d_b = np.mean(d_Z)
self.d_w_cache = d_w
self.d_b_cache = d_b

def gradient_descent(self, learning_rate=0.001):
self.w -= learning_rate * self.d_w_cache
self.b -= learning_rate * self.d_b_cache

逻辑回归是上节课的内容,这里就不讲解,直接贴代码了。大家可以通过这个例子看一看BaseRegressionModel的子类应该怎么写。

实现单隐层神经网络

有了基类后,我们更加明确代码中哪些地方是要重新写,不能复用以前的代码了。在实现浅层神经网络时,我们要重写模型初始化正向传播反向传播梯度下降这几个步骤。

模型初始化

我们要在__init__里初始化模型的参数。回忆一下这周的单隐层神经网络推理公式:

其中,有四个参数$W^{[1]}, W^{[2]}, b^{[1]}, b^{[2]}$,它们的形状分别是$n_1 \times n_x$, $1 \times n_1$, $n_1 \times 1$, $1 \times 1$。我们需要在这里决定$n_x, n_1$这两个数。

$n_x$由输入向量的长度决定。由于我们是做2维平面点集分类,每一个输入数据就是一个二维的点。因此,在稍后初始化模型时,我们会令$n_x=2$。

$n_1$属于网络的超参数,我们可以调整这个参数的值。

计划好了初始化函数的输入参数后,我们来看看初始化函数的代码:

1
2
3
4
5
6
7
8
def __init__(self, n_x, n_1):
super().__init__()
self.n_x = n_x
self.n_1 = n_1
self.W1 = np.random.randn(n_1, n_x) * 0.01
self.b1 = np.zeros((n_1, 1))
self.W2 = np.random.randn(1, n_1) * 0.01
self.b2 = np.zeros((1, 1))

别忘了,前面我们学过,初始化W时要使用随机初始化,且让初始化出来的值比较小。

正向传播

我们打算神经网络令第一层的激活函数为relu,第二层的激活函数为sigmoid。因此,模型的正向传播公式如下:

用代码表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
def forward(self, X, train_mode=True):
Z1 = np.dot(self.W1, X) + self.b1
A1 = relu(Z1)
Z2 = np.dot(self.W2, A1) + self.b2
A2 = sigmoid(Z2)
if train_mode:
self.m_cache = X.shape[1]
self.X_cache = X
self.Z1_cache = Z1
self.A1_cache = A1
self.A2_cache = A2
return A2

其中train_mode里的内容是我们待会儿要在反向传播用到的数据,这里需要先缓存起来。

事实上,我是边写反向传播函数,边写这里if train_mode:里面的缓存数据的。编程不一定要按照顺序写。

反向传播

翻译一下这些公式:

用代码写就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def backward(self, Y):
dZ2 = self.A2_cache - Y
dW2 = np.dot(dZ2, self.A1_cache.T) / self.m_cache
db2 = np.sum(dZ2, axis=1, keepdims=True) / self.m_cache
dA1 = np.dot(self.W2.T, dZ2)

dZ1 = dA1 * relu_de(self.Z1_cache)
dW1 = np.dot(dZ1, self.X_cache.T) / self.m_cache
db1 = np.sum(dZ1, axis=1, keepdims=True) / self.m_cache

self.dW2_cache = dW2
self.dW1_cache = dW1
self.db2_cache = db2
self.db1_cache = db1

算完梯度后,我们要把它们缓存起来,用于之后的梯度下降。

梯度下降

1
2
3
4
5
def gradient_descent(self, learning_rate=0.001):
self.W1 -= learning_rate * self.dW1_cache
self.b1 -= learning_rate * self.db1_cache
self.W2 -= learning_rate * self.dW2_cache
self.b2 -= learning_rate * self.db2_cache

梯度已经算好了,梯度下降就没什么好讲的了。

挑战点集分类问题

数据收集

这里我已经提前实现好了生成数据集的函数。本文的附录里会介绍这些函数的细节。

使用项目里的 generate_point_set 函数可以生成一个平面点集分类数据集:

1
2
3
4
x, y, label = generate_point_set()
# x: [240]
# y: [240]
# label: [240]

其中,x[i]是第i个点的横坐标,y[i]是第i个点的纵坐标,label[i]是第i个点的标签。标签为0表示是红色的点,标签为1表示是绿色的点。

数据预处理

得到了原始数据后,我们要把数据处理成矩阵X和Y,其中X的形状是[2, m],Y的形状是[1, m],其中m是样本大小。之后,我们还需要把原始数据拆分成训练集和测试集。

第一步生成矩阵的代码如下:

1
2
3
4
X = np.stack((x, y), axis=1)
Y = np.expand_dims(label, axis=1)
# X: [240, 2]
# Y: [240, 1]

大家应该能猜出stackexpand_dims是什么意思。stack能把两个张量堆起来,比如这里把表示x,y坐标的一维向量合成起来,变成一个向量(长度为2)的向量(长度为240)。expand_dims就是凭空给张量加一个长度为1的维度,比如这里给Y添加了axis=1上的维度。

第二步划分数据集的方法如下:

1
2
3
4
5
6
7
8
9
indices = np.random.permutation(X.shape[0])
X_train = X[indices[0:200], :].T
Y_train = Y[indices[0:200], :].T
X_test = X[indices[200:], :].T
Y_test = Y[indices[200:], :].T
# X_train: [2, 200]
# Y_train: [1, 200]
# X_test: [2, 40]
# Y_test: [1, 40]

注意,我们划分数据集的时候最好要随机划分。我这里使用np.random.permutation生成了一个排列,把这个排列作为下标来打乱数据集。

大家看不懂这段代码的话,可以想象这样一个例子:老师想抽10个人去值日,于是,他把班上同学的学号打乱,在打乱后的学号列表中,把前10个学号的同学叫了出来。代码里indices就是用随机排列生成的一个“打乱过的学号”,根据这个随机索引值,我们把前200个索引的数据当成训练集,200号索引之后的数据当成测试集。

经过这些处理,数据就符合课堂上讲过的形状要求了。

使用模型

1
2
3
4
5
6
7
8
9
10
n_x = 2

model1 = LogisticRegression(n_x)
model2 = ShallowNetwork(n_x, 2)
model3 = ShallowNetwork(n_x, 4)
model4 = ShallowNetwork(n_x, 10)
train_model(model1, X_train, Y_train, X_test, Y_test, 500, 0.0001, 50)
train_model(model2, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)
train_model(model3, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)
train_model(model4, X_train, Y_train, X_test, Y_test, 2000, 0.01, 100)

由于我们前面已经定义好了模型,使用模型的过程就很惬意了。这里直接初始化我们自己编写的类,再用训练函数训练模型即可。

为了比较不同的模型,从感性上认识不同模型间的区别,在示例代码中我训练了4个模型。第一个模型是逻辑回归,后三个模型分别是隐藏层有2、4、10个神经元的单隐藏层神经网络。

模仿这堂课的编程作业,我也贴心地实现了模型可视化函数:

1
2
3
visualize_X = generate_plot_set()
plot_result = model4.forward(visualize_X, train_mode=False)
visualize(X, Y, plot_result)

只要运行上面这些代码,大家就可以看到模型具体是怎么分类2维平面上所有点的。让我们在下一节里看看这些函数的运行效果。

实验报告

好了,最好玩的地方来了。让我们有请四位选手,看看他们在二维点分类任务上表现如何。

首先是逻辑回归:

逻辑回归选手也太菜了吧!他只能模拟一条直线。这条直线虽然把下面两片红色花瓣包进去了,但忽略了左上角的花瓣。太弱了,太弱了!

隐藏层只有2个神经元的选手也菜得不行,和逻辑回归一起可谓是“卧龙凤雏”啊!

4-神经元选手似乎在尝试做出一些改变!好像有一次的运行结果还挺不错!但怎么我感觉他的发挥不是很稳定啊?他是在瞎蒙吧?

好,那我们最后上场的是4号选手10-神经元网络。4号选手可谓是受到万众的期待啊。据说,他有着“二维点分类小丸子”的称号,让我们来看一看他的表现:

只见4号网络手起刀落,刀刀见血。不论是怎么运行程序,他都能精准无误地把点集正确分类。我宣布,他就是本届点集分类大赛的冠军!让我们祝贺他!

程序里很多超参数是可调的,数据集也是可以随意修改的。欢迎大家去使用本课的代码,比较一下不同的神经网络。

总结

通过这节课的编程练习后,大家应该掌握以下编程技能:

  • 编写单隐层神经网络的正向传播
  • 编写单隐层神经网络的反向传播
  • 正确初始化神经网络的参数
  • 常见激活函数及其导数的实现

此外,通过浏览我的项目,大家应该能够提前学到以下技能:

  • 在神经网络中使用缓存的方法保存数据

当然,我相信我的项目里还展示了许多编程技术。这些技能严格来说不在本课程的要求范围内,大家可以自行体悟。

附赠内容

如何画激活函数

1
2
import matplotlib.pyplot as plt
import numpy as np

先导入第三方库。

1
2
3
4
5
6
7
8
9
10
11
def sigmoid(x):
return 1 / (1 + np.exp(-x))

def tanh(x):
return (np.exp(x) - np.exp(-x)) / (np.exp(x) + np.exp(-x))

def relu(x):
return np.maximum(x, 0)

def leaky_relu(x):
return np.maximum(x, 0.1 * x)

再定义好激活函数的公式。

1
2
3
4
5
x = np.linspace(-3, 3, 100)
y1 = sigmoid(x)
y2 = tanh(x)
y3 = relu(x)
y4 = leaky_relu(x)

画函数,其实就是生成函数上的一堆点,再把相邻的点用直线两两连接起来。为了生成函数上的点,我们先用np.linspace(-3, 3, 100)生成100个位于[-3, 3]上的x坐标值,用这些x坐标值算出每个函数的y坐标值。

1
2
3
4
5
plt.subplot(2, 2, 1)
plt.axvline(x=0, color='k')
plt.axhline(y=0, color='k')
plt.plot(x, y1)
plt.title('sigmoid')

之后就是调用API了。这里只展示一下sigmoid函数是怎么画出来的。plt.subplot(a, b, c)表示你要在一个a x b的网格里的第c个格子里画图。 plt.axvline(x=0, color='k') plt.axhline(y=0, color='k')用于生成x,y轴,plt.plot(x, y1)用于画函数曲线,plt.title('sigmoid')用于给图像写标题。

1
plt.show()

用类似的方法画完所有函数后,调用plt.show()把图片显示出来就大功告成了。

这段代码的链接:https://github.com/SingleZombie/DL-Demos/blob/master/dldemos/ShallowNetwork/plot_activation_func.py

学API本身没有任何技术含量,知道API能做什么,有需求的时候去查API用法即可。

画花

看完上面的内容,有些人肯定会想:“诶,你数据集里那朵花画得挺不错啊,你是不是学过美术的啊?”嘿嘿,你们能这么想,我很荣幸。其实那朵花是用程序生成出来的。作为笔记的赠品,我打算顺手介绍一下该怎么用高中知识画出前面的那朵花。

代码文件:dldemos/ShallowNetwork/genereate_points.py

流程一览

这幅图足以概括花朵绘制的流程。

  1. 生成半个椭圆。
  2. 合成完整的椭圆。
  3. 把椭圆移到x正半轴。
  4. 复制、旋转椭圆。

有人会说:“这前三步可以用一步就完成吧?你直接生成一个在x正半轴上的椭圆就好了,干嘛要拆开来?”别急,看了后文你就知道了。我这么做,完全是为了多展示一点知识,可谓是用心良苦啊。

画半个椭圆

椭圆的公式是$\frac{x^2}{a^2}+\frac{y^2}{b^2}=1$,其中$a$是椭圆在x轴上的轴长,$b$是在y轴上的轴长。我画的椭圆的长轴为20,短轴为10,其形状和公式如图所示。

但程序可不认得这个公式。为了生成椭圆上的点,我们可以遍历横坐标x,用公式$y=b\sqrt{1-\frac{x^2}{a^2}}$算出对应的y坐标。

这段生成半椭圆的代码如下所示:

1
2
3
4
5
6
def half_oval(cnt, h=10, w=20):
x = np.linspace(-w, w, cnt)
y = np.sqrt(h * h * (1 - x * x / w / w))
return np.stack((x, y), 1)

petal1 = half_oval(20)

hafl_ovel的参数分别表示椭圆上点的数量、y轴上轴长、x轴上轴长。根据刚刚的理论分析,我们在第二、三行算出所有点的x, y坐标。第四行用np.stack((x, y), 1)把坐标合并起来。

这里要介绍一下stack函数的用法。stack用于把多个张量(第一个参数)按某一维(第二个参数)堆叠起来。第一个参数很好理解,而第二个参数“堆叠的维度”就不是那么好理解了。让我们针对这份代码,看两个取不同维度的例子。

在我们这份代码中,执行完第二、三行后,x[x1, x2 ..., xn]这样一个形状为[n]的向量,y也是[y1, y2 ..., yn]这样一个形状为[n]的向量。

当堆叠维度取0时,x会变成[[x1, x2, ..., xn]]([1, n])的矩阵,y会变成[[y1, y2, ..., yn]]([1, n])的矩阵。之后,两个矩阵的第一维会拼起来,变成[[x1, x2, ..., xn], [y1, y2, ..., yn]]这样一个形状为[2, n]的矩阵。

当堆叠维度取1时,x会变成[[x1], [x2], ..., [xn]]([n, 1])的矩阵,y会变成[[y1], [y2], ..., [yn]]([n, 1])的矩阵。之后,两个矩阵的第二维会拼起来,变成[[x1, y1], [x2, y2]..., [xn, yn]]这样一个形状为[n, 2]的矩阵。

我们希望生成一个坐标的数组,即形状为[n 2]的矩阵。因此,我们会堆叠维度1(第二个维度),即使用如下代码:

1
np.stack((x, y), 1)

总之,经过以上操作,half_oval会返回一个形状为[n, 2]的坐标数组,表示半个花瓣上每个点的坐标。

翻转合并椭圆

要把半椭圆垂直翻转,实际上只要令半椭圆上所有点的y坐标取反即可:

但是,这种写法不够高级。我们可以写成矩阵乘法的形式:

如果你对矩阵乘法不熟,只需要知道

设翻转矩阵为$F$,坐标向量为$p$,则翻转后的向量$p’$可以写成:

这里我们默认$p$和$p’$都是列向量。但是,刚刚我们生成点的坐标时,每个坐标都是一个行向量。也就是说,$p$和$p’$其实都是行向量。因此,上式应该改成:

最后我们要算的是$p’$,因此可以对上式两边再取转置:

有了这些数学上的分析,我们可以写代码了。

首先是生成翻转矩阵:

1
2
def vertical_flip():
return np.array([[1, 0], [0, -1]])

之后生成翻转后的花瓣:

1
petal2 = np.dot(half_oval(20), vertical_flip().T)

现在,我们有开始得到的petal1和翻转后的petal2,它们的形状都是[n, 2]。我们希望把这两个坐标数组合并起来。这可以通过下面这行代码实现:

1
petal = np.concatenate((petal1, petal2), 0)

concatenate用于按某一维(第二个参数)拼接张量(第一个参数)。回顾一下,刚刚的半椭圆张量的形状[n, 2]表示有n2维坐标。合并两个半椭圆后,我们应该得到2n个点,即得到一个形状为[2n, 2]的张量。因此,这里我们要把两个半椭圆数组按第一维(0号维度)拼接。

concatenate和刚刚提到的stack有点像。其实,stack就是新建了一个维度,再做concatenate操作。stack一般由于把单独计算出来的x, y, z这样的坐标堆叠成一个坐标数组/坐标张量,concatenate一般用于合并多个性质一样的张量,比如这里的合并两个坐标数组。

移动椭圆

移动椭圆很简单,只要给所有坐标加同一个向量就行了:

1
petal += [25, 0]

注意,这里的petal是一个形状为[2n, 2]的张量,而[25, 0]是一个形状为[2]的张量。这一个逐元素的加法操作之所以能够被程序正常解读,是因为上周提到的“广播”操作。通过使用广播,[25, 0]这个向量被加到了坐标数组中的每一个坐标里。

旋转花瓣,生成花朵

如上图1所示,一个坐标$(x, y)$可以用它到原点的距离$r$和与x正半轴夹角$\theta$表示:

那么,如上图2所示,假设现在把一个夹角为$\theta$的$(x_1, y_1)$旋转$\alpha$后得到了$(x_2, y_2)$,$(x_2, y_2)$可以表示为:

但是,我们现在只知道$(x_1, y_1)$这个坐标。给定$x_1, y_1, \alpha$,该怎么计算出$x_2, y_2$呢?

这里,我们可以用高中学过的三角函数两角和公式,把刚才那个三角函数“拆开”:

我们又已知:

因此,$x_2, y_2$可以用下面的式子表示:

这个式子用矩阵乘法表达如下:

也就是说,旋转操作也可以用一个矩阵表示。我们可以用和刚刚做翻转操作相同的办法,对坐标数组做旋转。以下是代码实现:

1
2
3
def rotate(theta):
return np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])

这个函数可以生成一个让坐标旋转theta弧度的矩阵。

这样,我们如果想让一个坐标数组旋转60度,可以写下面的代码:

1
new_petal = np.dot(petal, rotate(np.radians(60)).T)

在生成花朵时,我们除了生成第一片花瓣外,还要通过旋转生成另外5朵花瓣,并把花瓣合并起来。这整个流程的代码如下:

1
2
3
4
5
6
7
8
petal1 = half_oval(20)
petal2 = np.dot(half_oval(20), vertical_flip().T)
petal = np.concatenate((petal1, petal2), 0)
petal += [25, 0]
flower = petal.copy()
for i in range(5):
new_petal = np.dot(petal.copy(), rotate(np.radians(60) * (i + 1)).T)
flower = np.concatenate((flower, new_petal), 0)

我们可以给每个坐标打上0或1的标签,0表示点是红色,1表示点是绿色。然后,我们把各个花瓣染成不同的颜色:

1
2
3
4
label = np.zeros([40 * 6])
label[0:40] = 1
label[40:80] = 1
label[120:160] = 1

再做一些操作就可以用matplotlib画出花朵了:

1
2
3
4
5
6
7
8
9
10
11
12
x = flower[:, 0]
y = flower[:, 1]

c = np.where(label == 0, 'r', 'g')

import matplotlib.pyplot as plt
plt.scatter(x, y, c=c)

plt.xlim(-50, 50)
plt.ylim(-50, 50)

plt.show()

在数据中加入噪声

大家可以发现,我生成的花朵数据中,有几个点的颜色“不太对劲”。这是为了模拟训练数据中的噪声数据。让我们看看这些噪声是怎么添加的。

为了让部分数据的标签出错,我们只需要随机挑选出一些数据,然后令它们的标签取反(0变1,1变0)即可。这里涉及一个问题:该怎样从n个数据中随机挑选出若干个数据呢?

在我项目中,我使用的方法如下:

1
2
3
4
5
from numpy.random import default_rng

rng = default_rng()
noise_indice1 = rng.choice(40 * 6, 10, replace=False)
label[noise_indice1] = 1 - label[noise_indice1]

生成随机数需要一个随机数生成器。这里我用rng = default_rng()生成了一个默认的随机数生成器,它从均匀分布生成随机数。

noise_indice1 = rng.choice(40 * 6, 10, replace=False)用于生成多个不重复的随机数。rng.choice的第一个参数40*6表示生成出来的随机数位于区间[1, 40*6]。第二个参数10表示生成10个随机数。replace=False表示生成的随机数不重复。

最后,我们用label[noise_indice1] = 1 - label[noise_indice1]把随机选中的标签取反。

感想

我很早之前就在计划如何构建我的个人IP。没想到,从上周日开始,我不知不觉地开始认真地在公开渠道上发文章了。

发完文章后,我其实抱有很大的期待,希望能有很多人来读我的文章(哪怕是早已养成了不以他人的评价来评价自己的我,也不能免俗)。很可惜,文章似乎并不是很受欢迎。

还好我有着强大的自信心,心态一点也不受影响。首先,我自己有着强大的鉴别能力,在我自己来看,我的文章水准不低;其次,我的部署教程经 OpenMMLab 发表,受到了不少赞誉,客观上证明我当前的写作水平很强。文章不受欢迎,肯定另有原因。

首先,是我现在没有曝光度。这是当然的,毕竟我之前一点名气也没有,平台并不会去推荐你的文章,能够接触到你文章的人本来就少。另外,我的文章十分冗长,用我自己的话来讲,“根本不是给人来看的”(本来写文章的目的就是为了总结我自己的学习心得,提升我的学习效果)。虽然认真读起来,其实还可以,但几乎没有人有足够的动力去把我这些文章认真读完。

这两个问题,我都会去想办法解决。曝光度的问题我已经想好了办法,在这里就不提了。而第二点,文章可读性这点,对现在的我来说非常好解决。说实话,我不是写不出大家很愿意去读的文章,而是不愿写。如果你想去迎合他人的体验,那你肯定要付出额外的心血。我现在的主业是学习,不是搞自媒体,我之前比较高傲,懒得去把文章写得更加适合大部分人群阅读。但是,现在,我生气了,我认真了,我很不服气。我不是做不好,而是没有去做。我一旦出手,必定是一鸣惊人。

从这周开始,我的博客只发笔记原稿。发到其他平台上时,我会做一定的修改,使之阅读体验更好。

最可怕的是,我还是不会花大量的时间去讨好读者,我还是会保证我的学习工作不受影响。我会拿出我的真实实力,真正的人性洞察能力,真正的时间分配能力,真正的权衡利弊的能力,以最高效率生产出质量优秀的文章。以我这些精心写作的博客原稿为基础,我有自信生产出大量有趣、有深度的文章。我靠这些文字火不起来,可以理解,因为认真愿意去学深度学习的人,没有那么多。但是,我有足够的信心,我认为我的文章一定会受到很多人的好评。

另外,我刚刚是承认我仅凭这些深度学习教程文章是火不起来的。但我并没有承认我的个人IP火不起来。究竟我之后还会干出哪些大事?我这里不讲,且看历史是怎么发展的。


嘿嘿嘿,为了准备之后的编程实况解说,我这一课的编程是一边在录制一边编的。结果我发挥超神,3小时左右就把这一课代码写完了,其中实现逻辑回归和通用分类器框架花了40分钟,实现神经网络花了20分钟,剩下时间都在捣鼓Numpy API,在可视化网络的输出结果。可以说我的编程水平相比普通人已经登峰造极了。但我还会继续精进我的编程技术,直至出神入化,神鬼莫及的境界。

顺带一提,第一次编写一个程序的直播是没有节目效果的。你大部分时间都会花在思考上,你脑子里想的东西是无法即时传递给观众的。哪怕是搞节目效果能力这么强的我,录出来的视频也不太好看。要做编程教学视频,必须要提前写一遍代码,第二次重新编同一段程序的时候,才有可能游刃有余地解说。

这堂课要学习的是逻辑回归——一种求解二分类任务的算法。同时,这堂课会补充实现逻辑回归必备的数学知识、编程知识。学完这堂课后,同学们应该能够用Python实现一个简单的小猫辨别器。

学习提示

如上图所示,深度学习和编程,本来就是相对独立的两块知识。

深度学习本身的知识包括数学原理和实验经验这两部分。深度学习最早来自于数学中的优化问题。随着其结构的复杂化,很多时候我们解释不清为什么某个模型性能更高,只能通过重复实验来验证模型的有效性。因此,深度学习很多情况下变成了一门“实验科学”。

深度学习中,只有少量和编程有关系的知识,比如向量化计算、自动求导器等。得益于活跃的开源社区,只要熟悉了这些少量的编程技巧,人人都可以完成简单的深度学习项目。但是,真正想要搭建一个实用的深度学习项目,需要完成大量“底层”的编程工作,要求开发者有着广泛的编程经验。

通过上吴恩达老师的课,我们应该能比较好地掌握深度学习的数学原理,并且了解深度学习中少量的编程知识。而广泛的编程经验、修改模型的经验,这些都是只上这门课学不到的。

获取修改模型的经验这项任务过于复杂,不太可能短期学会,几乎可以作为研究生的课题了。而相对而言,编程的经验就很好获得了。

我的系列笔记会补充很多编程实战项目,希望读者能够通过完成类似的编程项目,在学习课内知识之余,提升广义上的编程能力。比如在这周的课程里,我们会用课堂里学到的逻辑回归从头搭建一个分类器。

课堂笔记

本节课的目标

在这节课里,我们要完成一个二分类任务。所谓二分类任务,就是给一个问题,然后给出一个“是”或“否”的回答。比如给出一张照片,问照片里是否有一只猫。

这节课中,我们用到的方法是逻辑回归。逻辑回归可以看成是一个非常简单的神经网络。

符号标记

从这节课开始,我们会用到一套统一的符号标记:

$(x, y)$ 是一个训练样本。其中,$x$ 是一个长度为 $n_x$ 的一维向量,即 $x \in \mathcal{R}^{n_x}$。$y$ 是一个实数,取0或1,即$y \in \{0, 1\}$。取0表示问题的的答案为“否”,取1表示问题的答案为“是”。

这套课默认读者对统计机器学习有基本的认识,似乎没有过多介绍训练集是什么。在有监督统计机器学习中,会给出训练数据。训练数据中的每一条训练样本包含一个“问题”和“问题的答案”。神经网络根据输入的问题给出一个自己的解答,再和正确的答案对比,通过这样一个“学习”的过程来优化解答能力。

对计算机知识有所了解的人会知道,在计算机中,颜色主要是通过RGB(红绿蓝)三种颜色通道表示。每一种通道一般用长度8位的整数表示,即用一个0~255的数表示某颜色在红、绿、蓝上的深浅程度。这样,一个颜色就可以用一个长度为3的向量表示。一幅图像,其实就是许多颜色的集合,即许多长度为3的向量的集合。颜色通道,再算上某颜色所在像素的位置$(x, y)$,图像就可以看成一个3维张量$I \in \mathcal{R}^{H \times W \times 3}$,其中$H$是图像高度,$W$是图像宽度,$3$是图像的通道数。在把图像输入逻辑回归时,我们会把图像“拉直”成一个一维向量。这个向量就是前面提到的网络输入$x$,其中$x$的长度$n_x$满足$n_x = H \times W \times 3$。这里的“拉直”操作就是把张量里的数据按照顺序一个一个填入新的一维向量中。

其实向量就是一维的,但我还是很喜欢强调它是“一维”的。这是因为在计算机中所有数据都可以看成是数组(甚至C++的数组就叫vector)。二维数组不过是一维数组的数组,三位数组不过是二维数组的数组。在数学中,为了方便称呼,把一维数组叫“向量”,二维数组叫“矩阵”,三维及以上数组叫“张量”。其实在我看来它们之间只是一个维度的差别而已,叫“三维向量”、“一维张量”这种不是那么严谨的称呼也没什么问题。

实际上,我们有很多个训练样本。样本总数记为$m$。第$i$个训练样本叫做$(x^{(i)}, y^{(i)})$。在后面使用其他标记时,也会使用上标$(i)$表示第$i$个训练样本得到的计算结果。

所有输入数据的集合构成一个矩阵(其中每个输入样本用列向量的形式表示,这是为了方便计算机的计算):

同理,所有真值也构成集合 $Y$:

由于每个样本$y^{(i)}$是一个实数,所以集合$Y$是一个向量。

逻辑回归的公式描述

逻辑回归是一个学习算法,用于对真值只有0或1的“逻辑”问题进行建模。给定输入$x$,逻辑回归输出一个$\hat{y}$。这个$\hat{y}$是对真值$y$的一个估计,准确来说,它描述的是$y=1$的概率,即$\hat{y}=P(y=1 | x)$

逻辑回归会使用一个带参数的函数计算$\hat{y}$。这里的参数包括$w \in \mathcal{R}^{n_x}, b \in \mathcal{R}$。

说起用于拟合的函数,最容易想到的是线性函数$w^Tx+b$(即做点乘再加$b$: $w^Tx+b = (\Sigma_{i=1}^{n_x}w_ix_i)+b$ )。但线性函数的值域是$(- \infty,+\infty)$(即全体实数$\mathcal{R}$),概率的取值是$[0, 1]$。我们还需要一个定义域为$\mathcal{R}$,值域为$[0, 1]$,把线性函数映射到$[0, 1]$上的一个函数。

逻辑回归中,使用的映射函数是sigmoid函数$\sigma$,它的定义为:

这个函数可以有效地完成映射,它的函数图像长这个样子:

这里不用计较为什么使用这个函数,只需要知道这个函数的趋势:$x$越小,$\sigma (x)$越靠近0;$x$越大,$\sigma (x)$越靠近1。

也就是说,最终的逻辑回归公式长这个样子:$\hat{y} = \sigma(w^Tx+b)$。

逻辑回归的损失函数(Cost Function)

所有的机器学习问题本质上是一个优化问题,一般我们会定义一个损失函数(Cost Function),再通过优化参数来最小化这个损失函数。

回顾一下我们的任务目标:我们定义了逻辑回归公式$\hat{y} = \sigma(w^Tx+b)$,我们希望$\hat{y}$尽可能和$y$相近。这里的“相近”,就是我们的优化目标。损失函数,可以看成是$y, \hat{y}$间的“距离”。

逻辑回归中,定义了每个输出和真值的误差函数(Loss Function),这个误差函数叫交叉熵

不使用另一种常见的误差函数均方误差的原因是,交叉熵较均方误差梯度更加平滑,更容易在之后的优化中找到全局最优解。

误差函数是定义在每个样本上的,而损失函数是定义在整个样本上的,表示所有样本误差的“总和”。这个“总和”其实就是平均值,即损失函数$J(w, b)$为:

优化算法——梯度下降

有了优化目标,接下来的问题就是如何用优化算法求出最优值。这里使用的是梯度下降(Gradient Descent) 法。梯度下降的思想很符合直觉:如果要让函数值更小,就应该让函数的输入沿着函数值下降最快的方向(梯度的方向)移动。

以课件中的一元函数为例:

一元函数的梯度值就是导数值,方向只有正和负两个方向。我们要根据每个点的导数,让每个点向左或向右“运动”,以使函数值更小。

从图像里可以看出,如果是参数最开始在A点,则往右走函数值才会变少;反之,对于B点,则应该往左移动。

每个点都应该向最小值“一小步一小步”地移动,直至抵达最低点。为什么要“一小步”移动呢?可以想象,如果一次移动的“步伐”过大,改变参数不仅不会让优化函数变小,甚至会让待优化函数变大。比如从A点开始,同样是往右移动,如果“步伐”过大,A点就会迈过最低点的红点,甚至跑到B点的上面。那么这样下去,待优化函数会越来越大,优化就失败了。

为了让优化能顺利进行,梯度下降法使用学习率(Learning Rate) 来控制参数优化的“步伐”,即用如下方法更新损失函数$J(w)$的参数:

这里的 $\alpha$ 就是学习率,它控制了每次梯度更新的幅度。

其实这里还有两个问题:参数$w$该如何初始化;该执行梯度下降多少次。在这个问题中初始化对结果影响不大,可以简单地令$w=0$。而优化的次数没有硬性的需求,先执行若干次,根据误差是否收敛再决定是否继续优化即可。

前置知识补充

到这里,逻辑回归的知识已经讲完了。让我们梳理一下:

在逻辑回归问题中,我们有输入样本集$X$和其对应的期望输出$Y$,我们希望找到拟合函数$\hat{Y}=w^TX+b$,使得$\hat{Y}$和$Y$尽可能接近,即让损失函数$J(w, b)=mean(-(Ylog\hat{Y}+(1-Y)log(1-\hat{Y})))$尽可能小。

这里的$X,Y,\hat{Y}$表示的是全体样本。稍后我们会讨论如何用公式表示全体样本的计算。

我们可以用$0$来初始化所有待优化参数$w, b$,并执行梯度下降

若干次后得到一个较优的拟合函数。

为了让大家成功用代码实现逻辑回归,这门课贴心地给大家补充了数学知识和编程知识。

在我的笔记中,补充编程知识的记录会潦草一些。

求导

这部分对中国学生来说十分简单,因为求导公式是高中教材的内容。

导数即函数每时每刻的变化率,比如位移对时间的导数就是速度。以常见函数为例,对于直线$y=kx$,函数的变化率时时刻刻都是$k$;对于二次函数$y=x^2$,$x$处的导数是$2x$。

计算图

其实,所有复杂的数学运算都可以拆成计算图表示法。

计算中的”图”其实是一个计算机概念,表示由节点和边组成的集合。不熟悉的话,当成日常用语里的图来理解也无妨。

比如上图中,哪怕是简单的运算$2a+b$,也可以拆成两步:先算$2 \times a$,再算$(2a) + b$。

这里的“步”指原子运算,即最简单的运算。原子运算可以是加减乘除,也可以是求指数、求对数。复杂的运算,只是对简单运算的组合、嵌套。

明明简简单单可以用一行公式表示的事,要费很大的功夫画一张计算图呢?这是因为,对函数求导满足“链式法则”,借助计算图,可以更方便地用链式法则算出所有参数的导数。比如在上图中要求$f$对$a$的导数,使用链式法则的话,可以通过先求$f$对$c$的导数,再求$c$对$a$的导数得到。

利用计算图对逻辑回归求导

逻辑回归有计算图:

现在利用链式法则从右向左求导:

这些运算里最难“注意到”的是$\frac{e^{-z}}{(1+e^{-z})^2} = a(1-a)$。

在学计算机科学的知识时,可以适当忽略一些数学证明,把算好的公式直接拿来用,比如这里的$\frac{dL}{dz}=a-y$。

$\frac{dL}{dw_i}, \frac{dL}{db}$就是我们要的梯度了,用它们去更新原来的参数即可。值得一提的是,这里的梯度是对一个样本而言。对于全部$m$个样本来说,本轮的梯度应该是所有样本的梯度的平均值。后面我们会学习如何对所有样本求导。

Python 向量化计算

在刚刚的一轮迭代中,我们要用到两次循环:

  1. 对$m$个样本循环处理
  2. 对$n_x$个权重$w_i$与对应的$x_i$相乘

直接拿 Python 写这些 for 循环,程序会跑得很慢的,这里最好使用向量化计算。在这一节里我们补充一下 Python 基础知识,下一节介绍怎么用它们实现逻辑回归的向量化实现。

课程中提到向量化的好处是可以用SIMD(单指令多数据流)优化,这个概念可以理解成计算机会同时对16个或32个数做计算。如果输入的数据是向量的话,相比一个一个做for循环,一次算16,32个数的计算速度会更快。

但实际上,除了无法使用SIMD以外,Python的低效也是拖慢速度的原因之一。哪怕是不用SIMD,单纯地用C++的for循环实现向量化计算,都能比用Python的循环快上很多。

Python 的 numpy 库提供了向量化计算的接口。比如以下是向量化的例子:

1
2
3
4
5
import numpy as np
a = np.zeros((10)) # 新建长度为10的向量,值为0
b = np.ones((10)) # 新建长度为10的向量,值为1
a = a + b # 10个数同时做加法
a = np.exp(a) # 对10个数都做指数运算

numpy 允许一种叫做“广播”的操作,这种操作能够完成不同形状数据间的运算。

1
2
3
a = np.ones(10) # a的形状:[10]
k = np.array([3]) # 用列表[3]新建张量,k的形状:[1]
a = k * a # 广播

这里k的shape为[1],a的shape为[10]。用k乘a,实际上就是令a[i] = k[0] * a[i]。也就是说,k[0]“广播”到了a的每一个元素上。

有一种快速理解广播的方法:可以认为k的形状从[1]变成了[10],再让k和a逐个元素做乘法。

同理,如果用一个a[x, y]的矩阵加一个b[x, 1]的矩阵,实际上是做了下面的运算:

1
2
3
for i in range(x):
for j in range(y):
a[i, j] = a[i, j] + b[i, 0]

用刚刚介绍的方法来理解,可以认为b[x, 1]扩充成了[x, y],再和a做逐个元素的加法运算。

向量化计算前向和反向传播

现在,有了求导的基础知识和向量化计算的基础知识,让我们来写一下如何用矩阵表示逻辑回归中的运算,并用Python代码描述这些计算过程。

单样本的正向传播:

推广到多样本:

这里的$X, A, \hat{Y}$是把原来单样本的列向量$x_i, \hat{y}_i$横向堆叠起来形成的矩阵,即:

单样本反向传播:

$dz$ 是 $\frac{dJ}{dz}$的简写,其他变量同理。编程时也按同样的方式命名。

所有的$\ast$都表示逐元素乘法。比如$[1, 2, 3] \ast [1, 2, 3]=[1, 4, 9]$。$\ast$满足前面提到的广播,比如$[2] \ast [1, 2, 3]=[2, 4, 6]$。

多样本反向传播:

用代码描述多样本前向传播和反向传播就是:

1
2
3
4
5
6
Z = np.dot(w.T, x)+b
A = sigmoid(Z)
dZ = A-Y
dw = np.dot(X, dZ.T) / m
db = np.mean(dZ)
# db=np.sum(dZ) / m

np.dot实现了求向量内积或矩阵乘法,np.sum实现了求和,np.mean实现了求均值。

总结

这堂课的主要知识点有:

  • 什么是二分类问题。
  • 如何对建立逻辑回归模型。
    • Sigmoid 函数 $\sigma(z)=\frac{1}{1 + e^{-z}}$
  • 误差函数与损失函数
    • 逻辑回归的误差函数:$L(\hat{y}, y)=-(y log\hat{y} + (1-y) log(1-\hat{y}))$
  • 用梯度下降算法优化损失函数
  • 计算图的概念及如何利用计算图算梯度

学完这堂课后,应该掌握的编程技能有:

  • 了解numpy基本知识
    • resize
    • .T
    • exp
    • dot
    • mean, sum
  • 用numpy做向量化计算
  • 实现逻辑回归
    • 对输入数据做reshape的预处理
    • 用向量化计算算$\hat{y}$及参数的梯度
    • 迭代优化损失函数

代码实战

这节课有两个编程作业:第一个作业要求使用numpy实现对张量的一些操作,第二个作业要求用逻辑回归实现一个分类器。这些编程作业是在python的notebook上编写的。每道题给出了代码框架,只要写关键的几行代码就行。对我来说,编程体验极差。作为编程最强王者,怎能受此“嗟来之码”的屈辱?我决定从零开始,自己收集数据,并用numpy实现逻辑回归。

其实我不分享作业代码的真正原因是:Coursera不允许公开展示作业代码。在之后的笔记中,我也会分享如何用自己的代码实现每堂课的编程目标。

这篇笔记用到的代码已在GitHub上开源:https://github.com/SingleZombie/DL-Demos/tree/master/LogisticRegression 。下文展示的代码和原本的代码有略微的出入,建议大家对着源代码阅读后文。

程序设计

不管写什么程序,都要先想好整体的架构,再开始动手写代码。

深度学习项目的架构比较固定。一般一个深度学习项目由以下几部分组成:

  • 数据预处理
  • 定义网络结构
  • 定义损失函数
  • 定义优化策略
  • 用训练pipeline串联起网络、损失函数、优化策略
  • 测试模型精度

当然,实现深度学习项目比一般的编程项目多一个步骤:除了写代码外,完成深度学习项目还需要收集数据。

接下来,我将按照数据收集数据处理网络结构损失函数训练测试这几部分介绍这个项目。之后的笔记也会以这个形式介绍编程项目。

数据收集

说起最经典的二分类任务,大家都会想起小猫分类(或许跟吴恩达老师的课比较流行有关)。在这个项目中,我也顺应潮流,选择了一个猫狗数据集(https://www.kaggle.com/datasets/fusicfenta/cat-and-dog?resource=download)。

在此数据集中,数据是按以下结构存储的:

在二分类任务中,数据的标签为0或1(表示是否是小猫)。而此数据集只是把猫、狗的图片分别放到了不同的文件夹里,这意味着我们待会儿要手动给这些数据打上0或1的标签。

数据预处理

由于训练集和测试集的目录结构相同,我们先写一个读数据集的函数:

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
input_shape=(224, 224)
def load_dataset(dir, data_num):
cat_images = glob(osp.join(dir, 'cats', '*.jpg'))
dog_images = glob(osp.join(dir, 'dogs', '*.jpg'))
cat_tensor = []
dog_tensor = []

for idx, image in enumerate(cat_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
cat_tensor.append(i)

for idx, image in enumerate(dog_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
dog_tensor.append(i)

X = cat_tensor + dog_tensor
Y = [1] * len(cat_tensor) + [0] * len(dog_tensor)
X_Y = list(zip(X, Y))
shuffle(X_Y)
X, Y = zip(*X_Y)
return X, Y

函数先是用glob读出文件夹下所有猫狗的图片路径,再按文件路径依次把文件读入。接着,函数为数据生成了0或1的标签。最后,函数把数据打乱,并返回数据。让我们来看看这段代码里有哪些要注意的地方。

在具体介绍代码之前,要说明一下我在这个数据集上做的两个特殊处理:

  1. 这个函数有一个参数data_num,表示我们要读取data_num张猫+data_num张狗的数据。原数据集有上千张图片,直接读进内存肯定会把内存塞爆。为了实现上的方便,我加了一个控制数据数量的参数。在这个项目中,我只用了800张图片做训练集。
  2. 原图片是很大的,为了节约内存,我把所有图片都变成了input_shape=(224, 224)的大小。

接下来,我们再了解一下数据处理中的一些知识。在读数据的时候,把数据归一化(令数据分布在(-1, 1)这个区间内)十分关键。如果不这样做的话,loss里的$loge^{z}$会趋近$log0$,梯度的收敛速度会极慢,训练会难以进行。这是这节课上没有讲的内容,但是它在实战中非常关键。

这个时候输出loss的话,会得到一个Python无法表示的数字:nan。在训练中如果看到loss是nan,多半就是数据没有归一化的原因。这个是一个非常常见的bug,一定要记得做数据归一化!

第三节课里讲了激活函数的收敛速度问题。

现在来详细看代码。

下面的代码用于从文件系统中读取所有图片文件,并把文件的绝对路径保存进一个list。如果大家有疑问,可以自行搜索glob函数的用法。

1
2
cat_images = glob(osp.join(dir, 'cats', '*.jpg'))
dog_images = glob(osp.join(dir, 'dogs', '*.jpg'))

在之后的两段for循环中,我们通过设定循环次数来控制读取的图片数。在循环里,我们先读入文件,再归一化文件,最后把图片resize到(224, 224)。

1
2
3
4
5
6
for idx, image in enumerate(cat_images):
if idx >= data_num:
break
i = cv2.imread(image) / 255
i = cv2.resize(i, input_shape)
cat_tensor.append(i)

在这段代码里,归一化是靠

1
i = cv2.imread(image) / 255

实现的。

这里我们知道输入是图像,颜色通道最大值是255,所以才这样归一化。在很多问题中,我们并不知道数据的边界是多少,这个时候只能用普通的归一化方法了。一种简单的归一化方法是把每个输入向量的模设为1。后面的课程里会详细介绍归一化方法。

读完数据后,我们用以下代码生成了训练输入和对应的标签:

1
2
X = cat_tensor + dog_tensor
Y = [1] * len(cat_tensor) + [0] * len(dog_tensor)

Python里,[1] * 10可以把列表[1]复制10次。

现在,我们的数据是“[猫,猫,猫……狗,狗,狗]”这样整整齐齐地排列着,没有打乱。由于我们是一次性拿整个训练集去训练,训练数据不打乱倒也没事。但为了兼容之后其他训练策略,这里我还是习惯性地把数据打乱了:

1
2
3
X_Y = list(zip(X, Y))
shuffle(X_Y)
X, Y = zip(*X_Y)

使用这三行“魔法Python”可以打乱list对中的数据。

有了读一个文件夹的函数load_dataset,用下面的代码就可以读训练集和测试集:

1
2
3
4
def generate_data(dir='data/archive/dataset', input_shape=(224, 224)):
train_X, train_Y = load_dataset(osp.join(dir, 'training_set'), 400)
test_X, test_Y = load_dataset(osp.join(dir, 'test_set'), 100)
return train_X, train_Y, test_X, test_Y

这里训练集有400+400=800张图片,测试集有100+100=200张图片。如果大家发现内存还是占用太多的话,可以改小这两个数字。

网络结构

在这个项目中,我们使用的是逻辑回归算法。它可以看成是只有一个神经元的神经网络。如之前的课堂笔记所述,我们网络的公式是:

这里我们要实现两个函数:

  1. resize_input:由于图片张量的形状是[h, w, c](高、宽、颜色通道),而网络的输入是一个列向量,我们要把图片张量resize一下。
  2. sigmoid: 我们要用numpy函数组合出一个sigmoid函数。

熟悉了numpy的API后,实现这两个函数还是很容易的:

1
2
3
4
5
6
7
def resize_input(a: np.ndarray):
h, w, c = a.shape
a.resize((h * w * c))
return a

def sigmoid(x):
return 1 / (1 + np.exp(-x))

这里我代码实现上写得有点“脏”,调用resize_input做数据预处理是放在main函数里的:

1
2
3
4
5
6
7
8
9
10
train_X, train_Y, test_X, test_Y = generate_data()

train_X = [resize_input(x) for x in train_X]
test_X = [resize_input(x) for x in test_X]
train_X = np.array(train_X).T
train_Y = np.array(train_Y)
train_Y = train_Y.reshape((1, -1))
test_X = np.array(test_X).T
test_Y = np.array(test_Y)
test_Y = test_Y.reshape((1, -1))

array = array.reshape(a, b) 等价于 array.resize(a, b)。但是,reshape的某一维可以写成-1,表示这一维的大小让程序自己用除法算出来。比如总共有a * b个元素,调用reshape(-1, a)-1的那一维会变成b

经过这些预处理代码,X的shape会变成[$n_x$, $m$],Y的shape会变成[$1$, $m$],和课堂里讲的内容一致。

有了sigmoid函数和正确shape的输入,我们可以写出网络的推理函数:

1
2
def predict(w, b, X):
return sigmoid(np.dot(w.T, X) + b)

损失函数与梯度下降

如前面的笔记所述,损失函数可以用下面的方法计算:

1
2
def loss(y_hat, y):
return np.mean(-(y * np.log(y_hat) + (1 - y) * np.log(1 - y_hat)))

我们定义损失函数,实际上为了求得每个参数的梯度。在求梯度时,其实用不到损失函数本身,只需要知道每个参数对于损失函数的导数。在这个项目中,损失函数只用于输出,以监控当前的训练进度。

而在梯度下降中,我们不需要用到损失函数,只需要算出每个参数的梯度并执行梯度下降:

1
2
3
4
5
6
7
8
def train_step(w, b, X, Y, lr):
m = X.shape[1]
Z = np.dot(w.T, X) + b
A = sigmoid(Z)
d_Z = A - Y
d_w = np.dot(X, d_Z.T) / m
d_b = np.mean(d_Z)
return w - lr * d_w, b - lr * d_b

在这段代码中,我们根据前面算好的公式,算出了w, b的梯度并对w, b进行更新。

训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def init_weights(n_x=224 * 224 * 3):
w = np.zeros((n_x, 1))
b = 0.0
return w, b

def train(train_X, train_Y, step=1000, learning_rate=0.00001):
w, b = init_weights()
print(f'learning rate: {learning_rate}')
for i in range(step):
w, b = train_step(w, b, train_X, train_Y, learning_rate)

# 输出当前训练进度
if i % 10 == 0:
y_hat = predict(w, b, train_X)
ls = loss(y_hat, train_Y)
print(f'step {i} loss: {ls}')
return w, b

有了刚刚的梯度下降函数train_step,训练实现起来就很方便了。我们只需要设置一个训练总次数step,再调用train_step更新参数即可。

测试

在深度学习中,我们要用一个网络从来没有见过的数据集做测试,以验证网络能否泛化到一般的数据上。这里我们直接使用数据集中的test_set,用下面的代码计算分类任务的准确率:

1
2
3
4
5
def test(w, b, test_X, test_Y):
y_hat = predict(w, b, test_X)
predicts = np.where(y_hat > 0.5, 1, 0)
score = np.mean(np.where(predicts == test_Y, 1, 0))
print(f'Accuracy: {score}')

这里的np.where没有在课堂里讲过,这里补充介绍一下。predicts=np.where(y_hat > 0.5, 1, 0)这一行,等价于下面的循环:

1
2
3
4
5
6
7
# 新建一个和y_hat一样形状的ndarray
predicts = np.zeros(y_hat.shape)
for i, v in enumerate(y_hat):
if v > 0.5:
predicts[i] = 1
else:
predicts[i] = 0

也就是说,我们对y_hat做了逐元素的判断v > 0.5?,如果判断成立,则赋值1,否则赋值0。这就好像是一个老师在批改学生的作业,如果对了,就给1分,否则给0分。

y_hat > 0.5是有实际意义的:在二分类问题中,如果网络输出图片是小猫的概率大于0.5,我们就认为图片就是小猫的图片;否则,我们认为不是。

之后,我们用另一个(np.where(predicts == test_Y, 1, 0)来“批改作业”:如果预测值和真值一样,则打1分,否则打0分。

最后,我们用score = np.mean(...)算出每道题分数的平均值,来给整个网络的表现打一个总分。

这里要注意一下,整个项目中我们用了两个方式来评价网络:我们监控了loss,因为loss反映了网络在训练集上的表现;我们计算了网络在测试集上的准确度,因为准确度反映了网络在一般数据上的表现。之后的课堂里应该也会讲到如何使用这些指标来进一步优化网络,这里会算它们就行了。

调参

嘿嘿,想不到吧,除了之前计划的章节外,这里还多了一个趣味性比较强的调参章节。

使用错误代码得到的结果,千万不要学我

搞深度学习,最好玩的地方就是调参数了。通过优化网络的超参数,我们能看到网络的性能在不断变好,准确率在不断变高。这个感觉就和考试分数越来越高,玩游戏刷的伤害越来越高给人带来的成就感一样。

在这个网络中,可以调的参数只有一个学习率。通过玩这个参数,我们能够更直观地认识学习率对梯度下降的影响。

这里我分享一下我的调参结果:

如果学习率>=0.0003,网络更新的步伐过大,从而导致梯度不收敛,训练失败。

text
1
2
3
4
learning rate: 0.0003
step 0 loss: 0.6918513655136874
step 10 loss: 0.9047000002073068
step 20 loss: 0.9751763789675365

学习率==0.0002的话,网络差不多能以最快的速度收敛。

text
1
2
3
4
learning rate: 0.0002
step 0 loss: 0.692168431534233
step 10 loss: 0.684254876013497
step 20 loss: 0.6780829877162996

学习率==0.0001,甚至==0.00003也能训练,但是训练速度会变慢。

text
1
2
3
4
learning rate: 0.0001
step 0 loss: 0.6926003513589579
step 10 loss: 0.6883167092427446
step 20 loss: 0.684621635180076

这里判断网络的收敛速度时,要用到的指标是损失函数。我的代码里默认每10次训练输出一次损失函数的值。

一般大家不会区别误差和损失函数,会把损失函数叫成 loss。

为了节约时间,一开始我只训练了1000步,最后准确率只有0.57左右。哪怕我令输出全部为1,从期望上都能得到0.5的准确率。这个结果确实不尽如人意。

我自己亲手设计的模型,结果怎么能这么差呢?肯定是训练得不够。我一怒之下,加了个零,让程序跑10000步训练。看着loss不断降低,从0.69,到0.4,再到0.3,最后在0.24的小数点第3位之后变动,我的心情也越来越激动:能不能再低点,能不能再创新低?那感觉就像股市开盘看到自己买的股票高开,不断祈祷庄家快点买入一样。

在电脑前,盯着不断更新的控制台快一小时后,loss定格在了0.2385,我总算等到了10000步训练结束的那一刻。模型即将完成测试,准确率即将揭晓。
我定睛一看——准确率居然还只有0.575!

这肯定不是我代码的问题,一定是逻辑回归这个模型太烂了!希望在之后的课程中,我们能够用更复杂的模型跑出更好的结果。

欢迎大家也去下载这个demo(https://github.com/SingleZombie/DL-Demos/tree/master/LogisticRegression),一起调一调参数~

修好bug后的结果

第一次写的代码竟然把梯度全部算错了,这太离谱了,我也不知道当时写代码的时候脑子里进了多少水。修好bug后,我又跑了一次训练。

首先,按照上次的经验,学习率0.0002,跑1000步,就得到了0.59的准确率。这效果差的也太多吧!

接下来训练10000步,我又满怀期待地盯着控制台,看着梯度降到了0.2395。

精度测出来了——好家伙,又是0.575。

行吧,起码文章的内容不用大改了。逻辑回归太菜了,和代码确实没什么关系。

其实,这段写bug经历对我来说是很赚的。我学到了:在梯度算得有问题的情况下,网络可以正常训练,甚至loss还会正常降低。但是,网络的正确率肯定会更低。一定要尊重数学规律,老老实实地按照数学推导的结果写公式。如果没有写bug,我反而学不到这么多东西,反而很亏。

吐槽

把这篇文章刚发到博客上的时候,这篇文章有一堆错误:$W,w$不分,损失函数乱写……。写这种教学文章一定要严谨,尤其是涉及了数学运算的。很多时候程序有bug,根本看不出来。希望我能引以为戒,学踏实了,把文章检查了几遍了,再把文章发出来。

突然又发现一个bug:reshape不是inplace运算。我写得也太潦草了吧!

最近在学吴恩达的《深度学习专项》(Deep Learning Specialization)。为了让学习更有效率(顺便有一些博文上的产出),我准备写一些学习笔记。笔记的内容比较简单,没有什么原创性的内容,主要是对课堂的知识进行梳理(这些文章的标题虽然叫”笔记“,但根据我之前的分类,这些文章由于原创性较低,被划分在”知识记录“里)。如果读者也在学这门课的话,可以对照我总结出来的知识,查缺补漏。之后几节课有课后作业,我会在笔记里讲解我的编程思路,给读者一些编程上的启发。

文章中的正文主要是对课堂内容的总结。引用里的内容和每篇笔记的总结是我个人的观点或评论。

什么是神经网络

我们把一个有输入有输出的计算单元叫做“神经元”。神经元可以简单地理解成一个线性函数。比如要预测房价和房屋面积的关系,我们可以近似地用一个线性函数去拟合。这个函数就是一个神经元。

事实上,一个神经元不仅包含一个线性函数,还包括一个激活函数。这里提到了激活函数 ReLU 的概念,其具体内容应该会在后面介绍。

神经元的堆叠,构成了神经网络,如下图所示。

在用一个神经元来表示房价和房屋面积的关系时,神经元的输入是房屋面积,输出是房价。而用多层神经元时,每个神经元的意义可能都不一样。比如中间的神经元可能会根据输入的邮政编码、地址特征,输出一个表示房屋地段的中间特征。在神经网络中,这些特征都是自动生成的(意味着我们只需要管理神经网络的输入和输出,而不用指定中间的特征,也不用理解它们究竟有没有实际意义)。

以前的一些机器学习要手动设置特征。而神经网络这种自动生成特征的性质,是其成功的原因之一。

用神经网络做监督学习

要理解监督学习,其实应该要对比无监督学习。本节实际上是介绍了监督学习的几个例子。

常见的神经网络有三类:

  1. 标准神经网络(即全连接网络)可以用于房屋分类、广告分类问题。(这些问题一般输入是结构化的)
  2. 卷积神经网络(CNN)一般用于图像相关的问题,比如图片猫狗分类,自动驾驶中识别其他车辆的位置。
  3. 循环神经网络(RNN)一般用于处理有时序的序列数据,比如和声音、文字有关的应用都需要RNN。

结构化数据,就是所有其数据项都是人能理解的(房子的面积、价格)。对比来看,无结构化的数据的具体含义是无法直接解释的,比如图像每一个像素值、声音某时刻的频率和响度、某一个文字/单词。

为什么最近深度学习“起飞”了?

这张图足以解释深度学习腾飞的原因。随着数据量的增加,所有方法都有性能的上限。而对于神经网络来说,结构越复杂的神经网络,其性能上限越高。复杂的神经网络(深度学习方法)在海量数据不断产生的今天更具优势。

光有大量的数据,没有使用数据的方法是不够的。总结来看,深度学习在近几年得到发展的原因有下:

  • 互联网的发展使得数字数据大量增长。
  • GPU等计算设备使得处理数据的硬件变强。
  • 深度学习的算法不断更新迭代,从软件层面上加快了数据处理。(比如激活函数的改进,从sigmoid到ReLU)

深度学习本质上还是以实验为主。计算能力上来了,研究人员做实验做得快了,各种各样的深度学习的应用也就出来了。各种应用又鼓舞着更多人参与深度学习研究。也就是说,是计算能力的提升使得近年来深度学习进入了良好的正反馈循环中。

总结

第一周的课没有什么深奥的内容,主要是给对深度学习不太熟悉的同学们介绍了下背景知识。

在我看来,这周的课需要记住的东西有:

  • 神经元有输入和输出的计算单元。神经元堆叠成了神经网络。
  • 大致有三种不同类型的神经网络,适用于不同的任务。
  • 神经网络的性能随其规模和数据量而增长。
  • 计算效率的提高使深度学习近期得到飞速发展。

PyTorch 自定义算子教程:两种方法实现加法算子(附LibTorch Windows环境配置教程)

我们都知道,PyTorch做卷积等底层运算时,都是用C++实现的。有时,我们再怎么去调用PyTorch定义好的算子,也无法满足我们的需求。这时,我们就要考虑用C++自定义一个PyTorch的算子了。

PyTorch提供了两种添加C++算子的方法:编译动态库并嵌入TorchScript[1]、用PyTorch的C++拓展接口[2]。前者适合导入独立的C++项目,后者需要用PyTorch的API设置编译信息,只适合小型C++项目,更适合于把新算子共享给他人的情况。由于我还没有用过torch的C++接口,这里先用第一种方法写一套独立的算子实现示例,跑通整个流程,再基于同一份代码,用第二种方法实现一次,以全方位地介绍PyTorch自定义算子的方法。

前置准备:

  • 装好了CMake
  • 装好了PyTorch
  • 装好了OpenCV
  • 看得懂C++、Python

知识点预览:

  • 如何配置LibTorch
  • 第一个Torch C++程序
  • 如何自己写简单的CMake
  • 如何用Visual Studio写CMake项目
  • 如何编译使用简单的动态库
  • 如何用两种方法实现PyTorch自定义算子
  • 如何用setuptools自动编译C++源代码

(以上是我写这篇文章之前还不会的东西。)

  • 如何用PyTest做单元测试

参考教程

[1] 添加TorchScript拓展 https://pytorch.org/tutorials/advanced/torch_script_custom_ops.html

[2] PyTorch的C++拓展 https://pytorch.org/tutorials/advanced/cpp_extension.html

[3] 安装LibTorch https://pytorch.org/cppdocs/installing.html

[4] VS CMake https://docs.microsoft.com/zh-cn/cpp/build/cmake-projects-in-visual-studio?view=msvc-170&viewFallbackFrom=vs-2019

配置 LibTorch 开发环境

我们这个项目是使用CMake开发的,理论上任何平台都能使用。我是在Windows上测试的,理论上Windows上碰到的毛病会多一些,Linux上可能直接用就没问题了。

对于我们这个CMake项目来说,成功添加路径,使得find_package(Torch)(找到Torch的CMake配置)不报错就算配置环境成功。当然,貌似由于Torch依赖于OpenCV,找OpenCV包也得成功才行。

参考教程是[3],但对于像我一样什么都不懂的新手来说,由于CMake有些东西要配置,这篇官方教程还不太够用。

下载 LibTorch

想用PyTorch的C++相关内容的话,要先去下载LibTorch库。

在获取PyTorch的Python版本下载命令处,可以找到LibTorch的安装链接:

和装PyTorch Python版的时候类似,选好自己的版本,之后点击某个链接下载就行。第一个链接是Release版,第二个是Debug版。由于我是编程高手,不要调试,所以直接选择了Release版。建议大家去下Debug版方便随时调试。

添加环境变量

下一步要把LibTorch的动态库所在目录加入环境变量中,以使程序运行时能够找得到依赖的动态库(编译是没问题的)。

xxxxxxxx\libtorch\lib这个目录添加进环境变量即可。

如果是在Windows上,添加环境变量时有一个细节要注意:

相信90%的人装PyTorch前都是把Cuda装好了的。在添加LibTorch的动态库目录时一定要注意,要把这个路径移到Cuda路径的上面。详细原因见FAQ

Hello LibTorch

接下来我们要用一个能调试CMake程序的环境来完成第一个C++ LibTorch程序。

创建一个崭新的文件夹,在里面添加一个CMakeLists.txt:

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
project(equi_conv)

find_package(Torch REQUIRED)
find_package(OpenCV REQUIRED)

add_executable(equi_conv op.cpp)
target_compile_features(equi_conv PRIVATE cxx_std_14)
target_link_libraries(equi_conv "${TORCH_LIBRARIES}")
target_link_libraries(equi_conv opencv_core opencv_imgproc)

里面的equi_conv可以换成你喜欢的项目名。我使用的项目名是equi_conv,这个名称会在后面多次出现。理论上我显示equi_conv的地方显示的应该是你自己的项目名。

注意! 一般情况下CMake是找不到Torch和OpenCV的,要手动设置CMake Configure附加命令中的Torch_DIROpenCV_DIR这两个参数,比如我的附加命令是

1
-D Torch_DIR="D:/Download/libtorch-win-shared-with-deps-1.11.0+cu113/libtorch/share/cmake/Torch" -D OpenCV_DIR="D:/OpenCV/opencv/build"

Torch_DIR"xxxxxxxx/libtorch/share/cmake/Torch,OpenCV_DIR大约是xxxxxxxx/opencv/build。每个人的具体路径可能不一样,只要记住,这两个路径里都得是包含了.cmake文件的。根据编程环境的不同,设置这两个CMake参数的位置也不同,详见后文。

官方教程[3]给了一种很骚的提供路径的方法:-DCMAKE_PREFIX_PATH="$(python -c 'import torch.utils; print(torch.utils.cmake_prefix_path)')"。这个命令是调用Python脚本以添加PyTorch默认的CMake搜索目录。但是这个命令有一些问题:1) 当前命令行环境里不一定能正确调用Python及访问torch库(比如PyTorch是用conda装的,而当前环境不是对应的conda环境);2) 我们下载的libtorch似乎难以对得上PyTorch包里默认的libtorch路径。这行命令似乎仅适用于处于正确Python环境下,把libtorch装到了/libtorch目录下的Linux系统。为了命令的兼容性,我们不用这么骚的操作,老老实实自己设置LibTorch目录和OpenCV目录。

再写一个叫op.cpp的C++源文件。

1
2
3
4
5
6
7
8
#include <torch/torch.h>
#include <iostream>

int main() {
torch::Tensor tensor = torch::rand({ 2, 3 });
std::cout << tensor << std::endl;
return 0;
}

如果你是高手,可以不去配环境,直接手敲CMake命令。但为了方便,接下来我们还是准备调试运行这个程序。配置CMake调试环境有很多方法,这里先给一个Windows上Visual Studio的方案[4]

准备好上面那个CMakeLists.txt后,用VS打开这个CMake文件(相信大家的VS都是2017版本以上的,旧版本是没有CMake的功能的~):

如果文件没写错VS会自动配置(Configure)CMake。在工具栏中可以手动中断或开始CMake的配置。

还可以点击上面的“{PROJECT_NAME}的CMake设置”来设置CMake命令中要用的参数(比如-D参数)

注意,一开始CMake只有Debug版的配置,可以点左上角的加号手动加一个Release版的配置。

同时,如图中所示,xxx_DIR应该卸载CMake命令参数里面。

配置好后去上面的工具栏点击”生成-全部生成”就可以把程序编译好了。接下来按熟悉的F5就可以运行程序了。

再介绍一个VSCode的CMake编程环境,这个基本上是全平台通用的。不过同样,我还是在Windows上测试的,以Windows上的配置为主。

通过搜索”Windows CMake VSCode cl 配置”等关键词,我搜索到了一篇很好的教程,我是照着这篇教程配的环境。如果是Linux的话,换一下编译器应该就能拿过来用了。

为了添加-D等配置参数,可以用ctrl+,打开设置,修改工作区设置里的CMake Configure命令:

如果一切正常,程序会输出随机张量的内容。

LibTorch A+B

C++ 侧

修改op.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)
{
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

cv::Mat res = m1 + m2;

torch::Tensor output = torch::from_blob(res.ptr<float>(), { t1.size(0), t1.size(1), 3});
return output.clone();
}

TORCH_LIBRARY(my_ops, m)
{
m.def("my_add", my_add);
}

我们要实现一个新PyTorch算子my_add,该实现函数先把两个PyTorch Tensor转换成OpenCV Mat,用Mat做加法,再把Mat转回Tensor。整个代码非常易懂,哪怕对LibTorch和OpenCV的语法不熟,也基本猜得出每行代码的作用。

1
2
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

一开始,先包含LibTorch、OpenCV的头文件。

1
torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)

我们要实现的是一个PyTorch的加法,因此实现函数中所有的张量类型都是torch::Tensor。加法输入是两个量,输出是一个量,因此最后的函数头要这样写。

1
2
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));

做为严谨的程序员,我们要对输入的Tensor做一定的检查(实际上这两个检查还不够,由于我们默认输入图像的通道是3,还应该检查一下通道数。但这样检查下去可能会没完没了了,这里仅仅是提醒大家要养成良好的编程习惯)(其实是我写了两行就懒得写下去了)。

1
2
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

这两行是用Tensor构造Mat。从这两行代码中,可以学到两点:1)可以通过tensor.data_ptr<float>来获取Tensor存储数据的指针;2)不同框架下的数据结构互转时一般是传指针,再传shape。

OpenCV这里有一点点特殊。OpenCV的Mat是二维的,要维护一个H-W-C(高-宽-通道)的数据,需要传一个基础数据类型CV_32FC3,即3通道浮点数。

从代码中可以猜出来,tensor.size(i)可以获取Tensor第i维的长度。

1
cv::Mat res = m1 + m2;

不用猜都知道这是调用了Mat的加法。

1
torch::Tensor output = torch::from_blob(res.ptr<float>(), { t1.size(0), t1.size(1), 3});

这一行是Mat转Tensor,同样是传了数据指针和张量形状。

这里第二个参数是个叫at::IntArrayRef的类型的。这个类型会用在Tensor的shape上。该类型的最简单的初始化方式就是用大括号把值框进去,就像Python里用方括号或圆括号传List和Set一样。

相比生成OpenCV Mat,这里没有传数据类型。原因如前文所述,应该是由于OpenCV的数据类型里包含了维度信息,所以OpenCV的Mat构造时要额外传这个信息。

1
return output.clone();

最后返回的是tensor.clone()。官方教程里说,用指针创建Tensor时会复用原来的指针,而不会新申请内存。函数结束后,Mat里的资源会释放,等于说这个用Mat创建出的Tensor也失效了。因此要clone()一下,让数据在函数结束后依然存在。

1
2
3
4
TORCH_LIBRARY(my_ops, m)
{
m.def("my_add", my_add);
}

最后调用API把C++函数绑定到Python上,现在可以不用追究这些代码的具体原理,只要知道这样写Python就可以访问到my_add了。

这里可以改动的内容其实有两处:算子的域my_ops,算子名/函数名my_add。前面那个my_ops在PyTorch的某些地方会用到,这里我们先不管,随便取一个名字即可。

现在我们要编译的是一个包含一个函数的动态库,而不是一个包含main的应用程序了。因此,我们要修改一下CMakeLists.txt中的编译选项:

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
project(equi_conv)

find_package(Torch REQUIRED)
find_package(OpenCV REQUIRED)

add_library(equi_conv SHARED op.cpp)
target_compile_features(equi_conv PRIVATE cxx_std_14)
target_link_libraries(equi_conv "${TORCH_LIBRARIES}")
target_link_libraries(equi_conv opencv_core opencv_imgproc)

其实就改了一行:add_library(equi_conv SHARED op.cpp),这样可以把编译目标变成一个动态库。

代码没错的话,重新Configure和Generate后动态库就编译好了。

Python 侧

我们写一个单元测试Python脚本来测试一下我们的算子能否在PyTorch里成功运行:

1
2
3
4
5
6
7
8
9
10
11
12
import torch

lib_path = r"D:\Repo\equi_conv\EquiConv\out\build\x64-Release\equi_conv.dll"
torch.ops.load_library(lib_path)


def test_add():
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = torch.ops.my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

再一次,为了体现我们编程时的严谨性,我们使用pytest来测试这个脚本。pip install pytest就可以轻松安装好这个Python单元测试工具。但如果你实在太懒了,不想下pytest,就得在后面补一行test_add()手动调用一下这个函数。

1
2
lib_path = r"D:\Repo\equi_conv\EquiConv\out\build\x64-Release\equi_conv.dll"
torch.ops.load_library(lib_path)

import torch就不说了。这两行代码是调用PyTorch的API来读取我们刚刚编译出来的动态库。我们这里只需要把动态库路径改成自己的就好,别的都不用改。

1
2
3
4
5
6
def test_add():
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = torch.ops.my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

后面这些代码就是实际单元测试的代码里,代码非常简单:生成两个随机tensor,比较一下我们的加法和PyTorch自己的加法是否结果一致。

值得注意的是,用torch.ops.my_ops.my_add可以调用我们刚刚那个C++函数。前面的torch.ops都是写死的,后面的my_add是我们自己定义的函数名。而my_ops,则是我们刚刚调API时填的“算子域”了。算子域在注册Python符号表的时候还会用到,这里不用管那么多,把算子域理解成一个命名空间,一个防止算子命名冲突的东西即可。

torch.allclose可以简单地理解为一个要求两个Tensor所有值都几乎相等的比较函数。

在该文件夹下运行命令pytest,屏幕上显示绿色的1 passed xxxxxxxxxx即说明单元测试成功运行。

至此,我们算是成功在Python里调用了一个C++写的算子。只需要写上torch.ops.my_ops.my_add`,我们就能够在任何地方(比如模型的forward函数)调用我们的算子。聪明的人看到这里,已经学会随心所欲地在PyTorch里嵌入自己的高效率的C++算子了。

配好环境,搭好框架后,我们自己实现算子倒是非常舒服。问题是,如果我们要把这些算子给别人使用的话,要么是给别人源代码,让别人自己配置LibTorch编译环境;要么是把所有Torch版本数 * Cuda版本数 * 操作系统数这么多个动态库给预编译出来。

要是能抛掉LibTorch,让有PyTorch和Cuda环境的用户自己编译源代码,似乎一个平衡开发者体验和用户体验的选择。所以,这里再介绍之前讲过的第二种添加算子的方法:直接在PyTorch里添加C++拓展。

PyTorch Extension A+B

用Python的setuptools也可以编译一些C++项目但,由于其头文件目录、依赖的库目录这些编译选项需要手动设置,setuptools仅适用于编译比较简单的C++项目。

在同文件夹中,编写以下的setup.py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from setuptools import setup
from torch.utils import cpp_extension

include_dirs = [r'D:\OpenCV\opencv\build\include']
library_dirs = [r'D:\OpenCV\opencv\build\x64\vc15\lib']
libraries = [r'opencv_world452']

setup(name='my_add',
ext_modules=[
cpp_extension.CppExtension('my_ops', ['op2.cpp'],
include_dirs=include_dirs,
library_dirs=library_dirs,
libraries=libraries)
],
cmdclass={'build_ext': cpp_extension.BuildExtension})

在这个源文件中,要改的就是以下三个路径(代码块中显示的是我的路径):

1
2
3
include_dirs = [r'D:\OpenCV\opencv\build\include']
library_dirs = [r'D:\OpenCV\opencv\build\x64\vc15\lib']
libraries = [r'opencv_world452']

这三个路径用于配置OpenCV的编译选项,分别表示OpenCV的包含目录(头文件目录)、静态库目录、静态库名。用Visual Studio导入过第三方库的,肯定对这三个选项不陌生。

如果是在 Linux 上,前两个路径大概是”/usr/local/include/opencv2”, “/usr/local/lib” 。最后的库名填写opencv_core即可。

至于PyTorch相关的编译选项,我们不需要手动设置。这是因为我们用了PyTorch封装的添加C++拓展接口,PyTorch有关的路径已经被填好了。

1
2
3
4
5
6
7
8
setup(name='my_add',
ext_modules=[
cpp_extension.CppExtension('my_ops', ['op2.cpp'],
include_dirs=include_dirs,
library_dirs=library_dirs,
libraries=libraries)
],
cmdclass={'build_ext': cpp_extension.BuildExtension})

在调用setup时,name是整个项目的名字,可以随便取。my_ops和刚刚一样,是命名空间的名字,我们还是保持my_ops这个名字。op2.cpp就是要编译的源文件了,这里我们待会再讨论。剩下的参数这些传进去就行了。

我们再在op.cpp的基础上新建一个新的C++源文件op2.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)
{
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

cv::Mat res = m1 + m2;

torch::Tensor output = torch::from_blob(res.ptr<float>(), {t1.size(0), t1.size(1), 3});
return output.clone();
}

PYBIND11_MODULE(my_ops, m)
{
m.def("my_add", my_add);
}

其实修改的就是这一行TORCH_LIBRARY(my_ops, m)->PYBIND11_MODULE(my_ops, m),没有调用TorchScript的绑定接口,而是直接用Pybind绑定了C++函数。

接下来,在当前文件夹下运行命令python setup.py install即可编译刚刚的C++源文件了。成功的话大概会有Finished processing dependencies for my-add==0.0.0这样的提示。

编译结束后,我们在原来test_add.py的基础上添加一些单元测试,看看用这种新方法编译完C++拓展后怎么调用C++函数。

1
2
3
4
5
6
7
def test_add2():
import my_ops
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

由于我们刚刚编译了一个命名空间为my_ops的包,我们可以用import my_ops导入这个刚刚编译好的库了。现在调用C++函数的方法变成了my_ops.my_add,其他地方都没有变化。

运行pytest test_add.py::test_add2可以单独测试这一个函数。当然懒的话直接pytest可以把刚刚那个测试和这个测试一起做一遍。单元测试通过就说明我们成功运行了C++拓展。

事实上,这种安装方式还是不够友好。由于我们用到了OpenCV,OpenCV的库路径还是要手动设置。这种安装方式只有在除PyTorch本身外不需要任何第三方库时比较友好。不然的话要么让用户自己手动设置路径,要么在代码库里引用别的开源库,再一个一个重写路径。大型项目还是用CMake等编译系统来编译比较友好。

总结

在这篇文章中,我介绍了两种在PyTorch里调用C++新算子的方法。只要看懂了这篇文章,就算是彻底打通了PyTorch与C++的桥梁,以后写代码可以专注于C++算子的实现及PyTorch对算子的封装,剩下的绑定算子的内容直接套这个模板就行。

两种算子实现方法的区别主要在于编译选项的设置上和用户在编译算子的体验上。应根据项目的实际情况选择一种方案。

这篇文章强行调用OpenCV实现了Tensor加法,看上去是多此一举,实际上这是为了展示如何在添加自定义算子时使用第三方库。但为了简化他人编译的过程,实际实现算子时最好只用原本的PyTorch API。

FAQ

运行LibTorch的示例程序,无法定位程序输入点 xxxxx 于动态链接库 xxxxx

这个问题找了我老半天,就找到2~3个相关的答案,全是治标不治本的方法。

有人说,是动态库路径的问题。我测试了一下,直接运行编译好的程序会报错,但是把程序放到LibTorch的动态库目录下就不会报错。我已经隐隐约约地感觉到,不是动态库找不到,而是动态库路径的优先顺序出了问题。

果不其然,最后我在这篇文章里找到了问题的真正原因:Cuda的动态库和LibTorch的冲突了(PyTorch和Cuda要背大锅)。那篇文章中暴力删掉了Cuda的动态库,但是温柔的我们绝对不要这样做。按照前面章节的内容,调整LibTorch与Cuda的路径优先级即可。

貌似官方教程提到了类似的错误。这里再提供一种可能的解决问题的思路(反正我没试)。

[WinError 126] 找不到指定的模块

这个问题说明Python的PyTorch库版本和下载的LibTorch C++版本不一致。用pip show torch查看当前的PyTorch版本,去重新下载对应的LibTorch即可。

OSError: xxx Undefined symbol (Linux)

要把 LibTorch 的动态库加入 LD_LIBRARY_PATH 里。

有关博客“学习”分类下子类别的说明

貌似之前说明过一次,这里再整理一遍。

  • 工具用法指南:几乎没有技术含量的,把下载安装过程的踩坑过程原封不动地讲一遍。
  • 知识记录:对现有成体系知识的描述,尤其会写教科书、公开课上的知识,较少我个人的见解。
  • 知识整理:对某一工具、知识、技术的说明,主要以我个人的见解、整理为主。

另外,“学习”类别和“记录”类别挺容易混淆的。这里我再做个规定:“记录”以具体的任务为导向,比如先有要写的作业、要看的论文、要做的项目、规划好的旅游计划,再对这些事情进行描述。而“学习”中包含的文章,更多是一种主观的,以学到东西为目的而写的文章。如果我看了一篇论文,只写论文的内容的话,会分到“记录”里;如果我想调研一个主题的文章,会把调研结果放到“学习-笔记”里;如果我看了很多论文,有了原创性非常强的一篇描述知识的文章,会放到“知识分享”(未来的“创作-知识”)里。

由于这个学期没有返校,我变懒了不少。明明有不少可以写的东西,却没有去写。等学期结束了有时间了我会好好补写一些博客。

这几天,我在赶一个高性能计算的大作业。题目要求优化一段代码,使程序的运行时间尽可能短。大作业本来是一个令人烦躁忧虑,头皮发麻的事物,但在deadline的紧逼之下,我仿佛按下了大脑的启动按钮,火力全开地应对起这个大作业来。于是,我的漫长的编程时间就开始了。——看到这里,如何你对编程不是非常了解,可能完全没有读下去的兴致。但我保证我会用外行人也能看懂的方式,来描述我这次有趣的写大作业经历。

再具体地讲一下我的大作业要求。我的大作业题目是代码优化,跑代码花费的时间是评价成绩的唯一指标。当然,代码的正确性不能受到影响,你不能让程序刚进去就关掉。代码跑得越快,分数越高。如果代码速度是原来的2倍,则可以拿到60分及格。如果达到原来的2.5,3,3.5,4倍,则可以分别拿到80,90, 95, 100分。成绩评判标准唯一且清晰。

我是作业截止的最后4天开始看这个大作业的。看完这个评分标准,我心里先是一乐:“哈哈,终于有一个评分透明的大作业了,写论文什么的成绩太容易受老师主观评价影响了。”接着,我又发现事情有些不太对:“既然老师敢给这么高的分数,说明代码想优化到3倍或者4倍是很困难的。我的时间这么少,恐怕只能拿个80分吧。”我也没再想下去,反正代码能优化多少就优化多少,也没有那种先定目标再开始干活的必要。

我的编程任务正式开始了。准确来说,我做的不是编程,是合理而优雅地修改老师给的代码,让这个代码运行速度更快一些,而不改变程序本身的意思。要打比方的话,我是一个拿着手术刀的医生,我需要精准地切掉病变部位,还病人一个更好的身体。比较幸运的是,我的工作可以反复调试,代码出了问题可以重新修改,而不需要担心造成什么破坏性的后果。

代码的功能是计算化学反应的一些参数。这段代码产生的程序不会产生任何花哨的网页、按钮,只会在默默地运行数十秒中后,冷冰冰地在控制台上输出一行数学计算的结果。运行这段代码就好像把一个优等生关进一个房间,让他把数学卷子全做出来再离开一样。只不过程序在输入的参数完全相同的情况下,每次都会执行一模一样的操作,最终得到完全相同,完全正确的结果。

这段代码可谓是又臭又长。丝毫没有注释,一个文件里的代码(代码分布在多个文件中)全是看不懂的化学常量,另一个文件的代码全是看似毫无逻辑的计算步骤。在浏览过代码,手足无措数秒钟后,我迅速转换了思维:“化学反应的代码归根结底就是计算一个很长的公式。我没有必要看懂为什么这么做,我只需要知道哪些加减乘除运算可以被我优化就行了。”我的这种冷静能力与思维跳转能力非常惊人。

这个大作业中,我学到了查看代码性能瓶颈的方法。经过检测,代码最耗时的部分竟然是一个exp()自然指数运算。自然指数本身是一个很容易理解的函数,高中数学就讲过这个函数的性质。这个函数在数学上很好表达,在程序中计算起来却非常麻烦。因为这个函数的值往往是一个无限不循环数,而程序只能进行有限的计算。程序只能通过多次的加法、乘法来得到一个十分近似的结果。程序总计进行了3亿次自然指数运算,代码性能瓶颈出现在这确实也情有可原。

程序中的指数运算,需要调用标准库。标准库是高级程序员们反复锤炼,被无数人反复验证的代码。这个自然指数运算对我来说是根本不可能修改的。看到这个情况,我心都凉了半截。

虽然一上来就碰到如此困难的情况,但我再次调整了心态。指数运算耗费了三成的时间,还有七成的运行时间可以被优化。我把目光又放到了其他运算速度较慢的代码上。凭借多年的高级语言(比较抽象、接近自然语言的程序语言)编程经验,我意气风发,大刀阔斧地对代码进行优化。我主要对代码进行了预处理、循环合并这两类优化。预处理就是把一些程序中不会变动的常量提前算好,避免每次重新计算。就好像你提前买一箱抽纸,这样几个月都不用再去买纸一样。循环合并就是把条件一样,需要反复做的事情,再每个“反复”中一起做掉。比如让你去测全校同学的身高体重,量身高和量体重的仪器摆在一起,这里假设两台仪器不能同时工作。你不能让同学挨个测完身高,回教室休息一下,再一个一个回来量体重。每个人量身高体重的时间虽然没变,但来回教室、排队等待的时间浪费了。最好的方法是每个人先量身高,再量体重。如果你懂编程,你或许能理解,这里的来回教室时间就是CPU从内存中读数据的时间,排队时间可以看成循环变量、控制流耗费的时间,在一个循环里做尽可能多的事能减小时间开销。

以上两类优化是非常基础的优化。经过优化后,程序运行时间从26秒到了17秒。很可惜,速度还没有达到2倍,我的成绩连及格也没有。我反复查看了其他部分的代码,绞尽脑汁也没有想出哪里可以优化。于是,我只好把目光再次放到了开始的指数运算上。

经过观察,我发现指数运算是一批一批做的,也就是一次会对多个数据依次执行步骤相同的指数运算,而标准库只能每次对一个数据进行指数运算。这里面有没有可以利用的空间呢?我凭借着十多年与搜索引擎打交道的经验,总算找到了一个比较厉害的数学运算库。里面有一种对批量数据进行指数运算的函数。我满怀期望地用这种”高级“函数替换了原来的函数。结果非常令人惊喜:程序的执行时间从17秒降到了12~13秒,速度整体变成了原来的2倍,我总算及格了。

开心之余,我总感觉自己忘了些什么事。仔细回忆了一下,代码优化不仅要快,原来程序的输出结果还不能被更改。我还没有验证新代码的正确性呢!我赶快把新代码和旧代码的结果进行了比较,发现新代码的输出结果发生了变化!

我改动了那么多处,究竟是哪一步出错了啊?!为了找出代码中的BUG,我采用了古老而有效的控制变量法。我把旧代码粘贴了回去,一段一段地把代码更新成新代码。如果某一次更新后运行结果有问题,就说明这段代码有问题。我顺利地找出了不少的低级错误。可是,我最不想碰到的情况发生了——

我发现指数运算的那整段代码中存在问题。我尝试地把指数运算的那一行改回了旧版本的代码,输出结果就正常了。也就是说,这种优化的指数运算会导致结果不正确。

我又慌了,心想指数运算这道坎可能就是过不去了。但我内心中突然涌现出的自信告诉我,我的新代码没有错误。这个新的指数运算函数是intel公司写的,如果有问题,只可能是他们有问题。代码的运行结果虽然改变了,但是代码不一定有错——这两句话并不是矛盾的。在精度较高的数学计算中,如果小数点后面好多位出现了偏差,只能算是结果有误差,不能说结果错误。况且原来的代码也只是对数学计算结果的一种近似,你怎么能保证原来的代码就离正确的结果更近呢?给你两块手表,两者差了几秒,你能知道哪块手表是正确的时间吗?保证这样的心理,我对新代码输出结果的误差进行了计算。

经检验,新代码和源代码的相对误差在小数点后6位,也就是0.0001%这个数量级。老师可没有强调结果要完全相同,或者误差保持在什么范围内。从道理上来看,只要保证代码整体的正确性就行。在强烈的自我暗示下,我接受了代码输出结果不完全相同,但我却是正确的这一事实。

如前面所述,我已经尽可能地在其他地方优化代码,就目前而言,这份代码对我来说是最优的。第一天天色已晚,我选择偃旗息鼓,明日再战。

第二天十点,烈日当空,天朗气清。我再次开始着手优化代码。经过缜密的分析,我觉得我缺少部分的知识,我需要从别的角度入手,用一些我不太熟悉的方法优化代码。

程序的运行可以分成串行和并行。串行的概念非常简单,我们人的大脑就是串行的。你不能说我左半边脑子在浏览选项题,右边的大脑跑去想填空题去了。并行就是多个串行,可以理解成多个人合作做一件事。写论文时,你写正文,我写摘要,我们同时开始写,这就是一种并行。显然,N个人干活,必然不能让事情的快N倍。因为人与人之间的交流存在着极大的效率浪费。如若不然,为什么每个企业要设置那么复杂的管理体系呢?并行程序也是如此,在硬件支持的情况下,程序并行可以提高速度,但也有一定的资源损耗。

我学过并行的知识,了解并行的概念,但没有并行编程的经验。于是,我只好以”C++ 并行编程”为搜索词去搜索有关信息。很快,我就搜到了一个满意的答案:有一种叫OpenMP的并行编程API,只需要在程序里加一些代码,就能把串行的程序变成并行的程序。但是,要是只加少量代码的话,只能并行执行循环结构,且只有对重复次数较多的循环起到优化作用。比如搬1000个箱子,让10个人来搬来搬肯定比1个搬快,而且大家只需要在搬完后交换一次信息,确认一下所有箱子都搬完了。想把更复杂的代码并行执行并达到优化效果,就要学更多的知识了。考虑到我所剩的时间不多,我打算只用OpenMP来并行优化循环。

我写了个算加法的循环,并用OpenMP并行优化。经过实验后,这个简单循环的运算确实变快了,优化成功了。看来,并行加速并不是很难啊。现在大作业代码的性能瓶颈还是那个指数运算。指数运算是作用在一个数组上的,目前的实现方式是循环对数组的每一个元素做指数运算。如果能把这个循环并行化,但代码的运算速度肯定能快上很多。于是,我把OpenMP的并行代码运用到了指数运算循环上。

可是,并行化后,代码不仅没有变快,反而变慢了。甚至随着并行线程数(可以理解为同时有多少个人在做同一个工作)增加,代码会运行得越来越慢。

做为一个自信的程序员,碰到问题时,我的第一反应肯定不是觉得我自己写错了,而是这个OpenMP调用得用问题。可能我使用这个工具的方法不太对。既然这样,也没时间去学新的东西了,没有轮子就自己造轮子,我只好自己来写一个多线程的并行程序了。于是,我删掉了OpenMP的代码,手写了创造线程、用线程做指数运算、同步线程的代码。我自己写的代码,肯定没问题了吧?

结果,用我自己的并行代码运行程序后,程序的运行速度也变慢了。做为一个正常的程序员,在代码全是自己写的情况下还发现了运行上的问题,第一反应就是出bug了。于是,我把那段并行的代码拎了出来,单独测试了好久。可是,无论怎么调试,都没有发现问题。第二天就在无聊而令人烦恼的调试中度过了。

晚上,躺在床上,苦恼地思索着代码里的问题。突然,我想到:是不是我的代码一直没有问题,而是并行这个方案有问题?做循环的次数只有10多次,如果用多线程的话,线程之间沟通的时间,就远远多于并行运算本身减少的时间。正所谓“三个和尚没水喝啊”。没办法,第二天就这么浪费了。我及时止损,准备使用新的方法优化程序。

经过昨晚的计划后,第三天,我早早地打开电脑查询一个新的技术。我顺着并行编程这一条线索,想到了另一个并行技术——SIMD(单指令多数据流)。这个技术就好比之前是一个人搬一箱货物,现在这个人可以拿手推车,一次搬几箱货物。在SIMD中,唯一增加的成本,就是货物得提前按组打包,这样才能够一组一组地搬运。这一项成本远远小于之前多线程之间沟通的成本。我去网上查了什么AVX指令集,学会了如何一次对4个数据进行计算。这样,不仅是那个指数运算,还有一些相邻的乘除法运算也可以顺便用并行

这次程序运算时间在8、9秒左右浮动。严格来说,程序并没有优化到三倍。但是,要是精心挑选一组比较看得过去的测试时间的话,应该能在报告里声称我把程序优化了3倍。这下,80分才算勉勉强强拿到。

代码真的就不能再优化了吗?既然老师敢说把速度优化4倍就能拿到满分,说明这份代码肯定还是有优化空间的。我把这份代码从头到尾读了一遍又一遍,在我的知识范围内,能用的优化小伎俩都用上了。但是,程序的运算时间几乎没有减少。我感觉自己已经弹尽粮绝了。

经过多次优化后,指数运算的用时占比已经不算很多了。一些对一个常量数组的遍历、取值、运算的用时占比逐渐高了起来。就好像一个邮递员要挨家挨户上门取件,再把货物送到另一个地方一样。这一部分的时间完全耗费在了跑到每个人家门口,敲门等人开门上,跑腿的时间反倒是可以忽略不计。这部分循环操作数组的代码是不能用之前的并行来优化的。这部分的代码没有什么过多的操作,自然也几乎没有改动的空间。我通读代码,看到这一团改不了的代码时,总会无可奈何地快速跳过。

我对代码优化已经绝望了。走神时,我想起了计算机体系结构课里的知识:现在CPU采用了流水线的设计,跳转指令(比如循环)会导致流水线的断流。为了让代码更快运行,某些时候能不用分支、循环就不要用。

我突然产生了一个奇怪的念头:循环吗……如果我把从常量数组取值的循环全部拆掉如何?这个可以理解成快递员上门取一层楼的货品时,可以用循环表示:取这个房间的货,往前走;去这个房间的货,往前走;……;如果这一层没有住户了,就上一层楼。但是,这是一个常量数组,即我可以提前知道这栋楼有几层,每层有哪几个房间。这样,快递员的指令就变成了:去101取件;去102取件……去606取件这样确切的命令。快递员不用动脑筋去观察什么时候把这层楼走完,可以上一层楼了。抛弃掉回产生跳转指令的循环后,按理说代码能变快很多。

但是,我们初学编程认识循环时,就知道循环是用来简化代码的。现在,我却要反常识地把循环拆掉,把要执行的代码展开来,一行一行写出来。这太反常识了。当然,循环拆掉代码展开后,代码会变得特别长,其中会包含很多重复的代码。与其我手动写,不如写个脚本自动把这些代码生成出来。于是,我写了一段生成代码的Python代码,把原来的循环拆掉了。

我持着怀疑的态度,一边苦笑地看着那些丑陋的代码,一边等待着程序运行结束。没想到,程序的运算时间竟然大幅度变少了!这次运行时间之间减少到了6秒多,程序的速度基本上是最开始的4倍了。我还没来得及烦恼怎么跨过优化3.5倍这道分数砍,程序一下就优化到满分了。

这太有趣了!违反常理地拆掉循环竟然能让代码加速。我关掉了再也不想多看一眼的代码,开开心心地把优化四倍的结果写进实验报告里。然后,我开始着手写这篇文章,记录一下这段过程紧张,结局却是Happy Ending的代码优化之旅。

噢,对了,源代码我是有的,但我一定不会开源。我能想象到,老师肯定会把同一份大作业用好几年。为了让这门课公平一点,我是不会上传代码的。当然了,稍微有一点编程水平的人,看完这篇文章后,都知道了该怎么优化程序了。这篇文章也算是给和我一样初学代码优化的人一些学习上的启发吧。

这是2020年6月份的文章,我当时写到一半搁笔没写了。趁现在有时间,赶快填一个坑。

现在重温这段经历,当时提笔时的激动已经没了,只剩下了怀念。两年前,我肯定想不到这四天不到的代码优化经历,竟然是我本科期间学代码优化技术学得最多、学习效率最高的一段时间,也想不到几年后的现在的几天后我即将开始新的工作,会把之前学到的这些代码优化技术全部用出来。

对了,最近我在写很多文章。今天不认真的文字写了3000~4000,之前比较认真的写得话一天3000字都不到。我本来以为这个速度很慢,上网一看,这才是正常速度。把写东西单纯当一个爱好也挺不错嘛。虽然既没有编程有趣,也没有编程挣钱就是了。

最近,我欣赏了《白色相簿1》的游戏和动画,心中五味杂陈,一直有话想说却不知道该怎么表达。今天,我从高烧中熬过了一晚,身体和精神上都得到了净化。趁此机会,我打算写一写我最近的一些感想。文章的结构和内容也不去仔细琢磨了。想到哪写到哪。

结果我没能够第一次性写完。第二次写的时候想了下,希望这篇文章能总结一些我的个人观点,并一如既往地传递我的积极的人生态度。

心理学之习惯论

社会学中有“原生家庭”这一概念。原生家庭,指孩子从小和父母一起构成的家庭。书本里说,一个人后天的行为都是原生家庭的再现。后天和其他人的相处模式,其实来自与和原生家庭里某位家庭成员的相处模式。心理治疗师在了解咨询人时,也首先会询问咨询人童年的信息。稍有调查就能发现,科学家们十分认同“童年对一个人后天的性格有很大的影响”这一观点。

仅基于这一观点,我根据我自己的观察与分析,想提出一个更一般的观点:人的性格,完全取决于人的习惯。什么是性格?拿“开朗”与“内敛”这一对相斥的性格来举例。当你和一个人讲话时,如果说这个人很开朗,那么这个人或许会笑着主动接过你的话题;如果这个人比较内敛,那么这个人或许回答个两句就不做声了。仔细一看,性格不就是某种意义上的习惯嘛:人在某种情况下最自然的反应,就是习惯。说一个人有早起的习惯、跑步的习惯,其实就是说一个人最自然的起床事件是早上6点,或者是一个人到了下午会自然地出门跑步。推广地来看,性格就是习惯。

如果只是把常识中的定义推广,那并没有什么用。我提出的“性格取决于习惯”的观点,有什么意义呢?其实我想强调两点:和你后天会养成跑步的习惯一样,性格不仅仅形成于人的童年;性格和习惯一样,是人长期以来刻在大脑里的信息,虽然难以改变,但是坚持下来还是有改变的可能的。

我认为,人的大脑在遇见新的事件时,会产生一个面对这个事件的解决方法。如果这个方法总是有效,人在面对特定事件时就会有一套固定的反应。这就是人的习惯。人在面对自己的情感时做出的习惯反应,就是性格。由于人在一出生时就要面临饥饿、恐惧等会伴随人一生的情感,所以人的性格大都在童年时被决定下来。人在后天也可能获得某一性格,想象一个从小衣食无忧的富家子弟,突然要在荒郊野外一个人生活,那么这个人会在后天才培养起面对负面情感的性格。总之,新性格诞生于新事件中形成的固有反应。

当人已经形成了某一性格时,这通常意味着人已经从许多生活经历中巩固了这一性格:比如一个人健谈,可能是他从小只要好好和父母交流,就能满足自己的需要;可能是从小喜欢和朋友聊天,感到很快乐;可能是喜欢让他人听自己侃侃而谈,享受被尊重的感觉……在长期生活经验的影响下,这个人的大脑已经形成了“只要和别人多说话,就能给自己带来快乐”的简单反射。这些一条条生活经验,使一个人的性格根深蒂固,成为了人的一部分。不过,反过来说,人的性格还是有改变的可能,只要逐条否定自己过去的做法即可。一个人习惯说谎,可能是小时候每次说实话都会被父母骂,而撒谎总能成功逃避。要彻底改变爱说谎的性格,需要面对自己童年的伤痕,思考自己以前每次说谎的后果,让自己彻底明白说谎不总是能让自己和他人变好。

这里从程序的角度总结一下我的“习惯论”:人在面对事物时,会把自己的计划写在一张表格上。表格的第一列是碰到的事件,第二列是碰到事件的解决方法。事件是”碰到饥饿“、”碰到恐惧“这些低层次的情感或者是”是不是要起床“这些很容易表达出来的事件。人在碰到新事件时,会另起一行,记录下事件的名称和自己的解决方法。下次再碰到同样的事件时,人会尝试同样的解决方法,并试图加以修正。当这个解决方法已经用过多次后,人在碰到事件时就不会加以思考,而是顺其自然地采用固定的解决方法。这种处理机制的动机也很好理解:人的思考能力是有限的,如果什么东西都要过一遍脑子,人早就累死了。因此,人通过”性格“这种采取过去相同行为的优化方法来减轻自己的思考量。

孤独感从何而来

上述内容只能算是一个不严谨的观点。我这辈子应该没时间去研究心理学,不会将其系统化。我之所以写那么多,是想提供一种心理分析工具,来分析人的各类心理活动。

孤独,展开来说是人感觉“一个人很难受,如果有人在自己身边就好了”。做为一种常见的情感,大家都可以轻松地说出孤独的定义。但是,仔细一看,为什么人们都会觉得“如果有人在自己身边就好了”呢?仔细去挖掘这一想法的动机,会发现孤独并没有看起来那么简单。

既然孤独是一个所有人都会有的情感,那么分析它就要抓住所有人的共性:婴儿时期。刚出生的时候,所有人都是十分相似的。大家对这个世界一无所知,却天生被赋予了“要在世界上努力活下去”的本能。饿了,会哭;热了,会哭;要睡着了,会哭……在身体上有不适时,婴儿会害怕。这时,通常母亲会安抚婴儿的情绪。婴儿第一次认识到了其他生命的存在,知道了其他生命能够减轻自己的负面情绪。在成长的过程中,人们认识了其他亲人,认识了伙伴,发现确实和他人相处能够令自己更加安心,这一条解决方法被记录在了人的大脑里。反过来讲,遇到麻烦时,也会下意识地寻求他人的帮助。寻求不到,就变成了孤独。

按理说人长大了,学会自己找东西吃,自己生活了,不会再有幼年时的那些担忧了。为什么还会感到孤独呢?这是因为,孤独不仅是由饥饿等简单的情绪构成的。只要身体上,或者尤其是心理上有了不适,人感觉自己已经无法处理一切后,就会像刚出生的婴儿一样感到害怕。过去的习惯让人下意识去寻求帮助,但却发现周围的人已经帮助不到自己了——随着年龄的增加,人的烦恼也愈发复杂,终有一天,人的烦恼只有自己能理解。所以,人感到了孤独。

贫瘠的表达能力

一瞬间的不适,只能归于害怕。长时间的害怕,才足以称之为孤独。正因为人与人之间的交流效率实在是太低了,以至于人在大部分情况下无法互相传递情感,才导致了孤独的常驻。

相比动物,人发明了语言,发明了文字,似乎就拥有了无穷的表达能力。但实际上,人类之间传递信息的效率比想象中要低出许多。

相比大家都有这样的经历吧:向他人问路后,他人自顾自的讲了一通。碍于面子,我们只好感谢对方的帮助,再顺着他人指着的直线方向寻求下一个人的帮助。在参观点一份套餐,我们只需要报出套餐的名字,甚至只要简单说一句“我要那个”就够了。交谈的时候,我们很多时候并没有听清别人在讲什么,有的时候仅仅通过对方的申请才大概脑补出对方的回答是积极的还是消极的。就连有充足时间组织语言的文字聊天,不配上表情包什么的,总是会令对话十分尴尬。

哪怕有了语言和文字,人类之间的沟通效率还是太低了。不然,为什么一些简单的概念要花45分钟来讲清楚呢?为什么同样是上课,有人学得好,有人学得差呢?为什么有的时候几行代码的事情要花好几页的长篇大论来解释呢?

要是说构筑于理性之上的深邃的理论,只要花上时间,不管再久,都是能够讲清楚的。那么,更加复杂的,用感性编织出来的无形的情感,有时恐怕用再多的言语也无法说清楚吧。

我曾经为了追寻高质量的交流,严密地建立了一个人的交流模型。人与人之间交流的效率,或者通俗说是深度,至少由交流欲望、对交流背景的了解程度、思辨能力相乘而得(相乘意味着只要一个因素偏低,最终的结果就也会偏低)。比如说想进行深刻的学术交流,就要在双方午后都乐意探讨学术时,与在同一领域做研究的聪明而乐于表达的人进行交流。

把这个模型放到情感上,就会得出非常悲伤的结论了:人和任何人交流情感的效率,几乎都无法超过自己和自己交流情感的效率。人为了处理自己的不安,有极强地感受自己情感的意愿;自己一般是最了解自己的。只有思辨能力,算得上是一个可变的因素。有些人处理自己心理的能力不行,有的时候对情感的沟通效率没有深谙人心的心理咨询师强。但在绝大多数情况下,人只能感受到自己强烈的情感,而他人是无法体会到相同的感觉的。

长时间的害怕孕育了孤独,人自然地寻求着效率最高的,自我心理疏导的方式来排解这些负面感情。或许熟练之后,害怕的感觉不在了,那个被称为“孤独”的感觉却伴随着只能自我对话的习惯,永远凝固并附着在我们的心上。

在一起

在我看来,人注定是孤独的,这是一个从理论分析上来看无解的问题。

这是一个所有人都自然而然会碰到的问题。幸运的是,几乎所有人都在不自觉地努力寻找解决孤独发方法。

虽然从前面的分析可以看出,孤独不是那么简单的一个概念。但在大众普遍的认知里,孤独的原因是“一个人”。要是能和另一个人长期待在一起,就不会有孤独感了。因此,在大众的认知里,两个人交往,意味着两个人会“在一起”了。

但是,在一起真的能解决问题吗?

如果是为了身体上的接触,那通过基因获得的一瞬间的快感是五八支语长久以来的孤独的吧。如果是恋爱的话,也只是在激素和新鲜感的驱动下,从一个幻想中的完美对象里汲取自己欠缺的情感。哪怕是数十年亲人般的陪伴,也有可能只是过多的共同经历把双方重塑成了一个新的集体,分离已经成了如伤害自己一般不会去想的选项了。

再近的距离、再久的同行,也无法解决情感无法交流这一本质问题。

但或许,消除孤独并不需要无损地体会或传递情感。相处得久了,两个人之间可能会产生的能力:不需要完全理解对方,只要一个信号,就能顺利地索取与给予所需的情感。在这种情况下,两人之间构造了一种新的沟通方式,一个简单的行动,几句简单的叮咛,反倒能化作携带了无数信息的暖流,流入人的心里。说到底,人只能体会到自己的想法,自己因为害怕而寻求帮助,所以也能够用自己的方式去理解别人的行动。

不过恕我直言,在我的观察统计下,凭大部分人的思考水平,世界上能做到这种程度的家庭少之又少。只有少数的夫妻能够较好地消除孤独,并把美好而恰当的关怀传递给孩子。

向世界宣泄自我

如果没有人能好好倾听自己的复杂的想法,为什么不干脆放弃向某个人说明,并用自己的方式把自己的想法完完全全地展示出来呢?

之前我看到网上有人讨论,为什么伟大的艺术家或思想家都有抑郁的倾向。有人说,不是他们创作后才抑郁,而是因为抑郁,参创作出了伟大的作品。我认为这是正确。越是进行深刻的思考,越是能得出常人难以理解的结论,越是缺少和常人的共同语言,孤独感越发沉重。于是,他们选择逃避,逃避进了自己最擅长的事情里,继续思考着。在正反馈的作用下,不被常人理解的痛苦加深到了难以忍受的程度,只好放弃与常人的交流,把自己长久的以来的感受宣泄给整个世界。

但要说那些创作者是抑郁吗?我看未必。固然有很多创作者最后都选择了自杀,但哪怕是这些人,他们生前都是热爱着生活的吧。正是因为抱有着热爱,所以不断创作着,不断发泄着情感,不断与黑暗的内心抗争着。他们明明知道,自己的作品被会被其他人欣赏,赞美的话语永远无法传进他们的耳内。即使如此,他们还是把作品留传下来,把高尚的思想传递给他人,给予每一个和自己相同困境的人以温暖。这样的人,绝对称不上是对这个世界失望吧?

中学时,叫我写一篇800字在作品会让我头疼死。但后来,我发现有东西想写时,文字就会从我的指尖流到屏幕上。先是理论,再是情感。只要有想表达的东西,这些抽象的想法总能转换成具体的文字。既然如此,那艺术家就更加幸福了。他们有画笔,有音符。无限的信息,被压缩到了简单的实体中。再也不用考虑有没有人愿意倾听,再也不用考虑有没有人能够理解,只需要把自己的情感,原原本本地创作出来就好了。

结语

在人的出厂设置被确定后,人的孤独就是不可避免的事情了。但也不必过度悲观,选好表达方式,想好表达对象,每个人都可以战胜孤独。